mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
Domain config types (domain/config_types.py): - PronunciationConfig: pronunciation/heteronym/normalization overrides - SubtitleConfig: mode, format, max_words - CoverConfig: path, mime Domain functions now accept config objects: - build_tts_context(subtitle=, pronunciation=) instead of 9 individual params - make_subtitle_writer(subtitle=) instead of 3 params - process_and_write_subtitles(subtitle=) instead of 2 params - embed_m4b_metadata(cover=) instead of 2 params - build_epub3_package(cover=) instead of 2 params ConversionRequest: 18 flat fields + 8 config objects Application/config.py re-exports domain types All tests updated to new API
411 lines
12 KiB
Python
411 lines
12 KiB
Python
"""Regression tests for conversion executor logic.
|
|
|
|
These tests verify that the core conversion engine (synthesize_text,
|
|
run_tts_segment_loop, process_and_write_subtitles) works correctly
|
|
with fake backends and sinks. They serve as a regression net for the
|
|
upcoming conversion flow unification refactor.
|
|
|
|
All tests use mock/fake implementations — no real TTS, no real audio I/O.
|
|
"""
|
|
|
|
import pytest
|
|
import numpy as np
|
|
from unittest.mock import MagicMock, patch
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from abogen.domain.config_types import SubtitleConfig
|
|
from abogen.domain.enums import Language, SubtitleMode
|
|
from abogen.domain.conversion_engine import (
|
|
synthesize_text,
|
|
SynthParams,
|
|
run_tts_segment_loop,
|
|
process_and_write_subtitles,
|
|
SegmentStats,
|
|
SegmentInfo,
|
|
CancelChecker,
|
|
)
|
|
from abogen.domain.normalization import TTSContext
|
|
from abogen.domain.audio_sink import AudioSink
|
|
|
|
|
|
# ─── Fake Implementations ──────────────────────────────────────────
|
|
|
|
class FakeAudioSink:
|
|
"""Fake audio sink that records written data."""
|
|
|
|
def __init__(self):
|
|
self.written = []
|
|
self.closed = False
|
|
|
|
def write(self, audio: np.ndarray) -> None:
|
|
self.written.append(audio)
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
self.close()
|
|
|
|
|
|
class FakeBackend:
|
|
"""Fake TTS backend that returns deterministic audio."""
|
|
|
|
def __init__(self, segment_duration: float = 0.5):
|
|
self.segment_duration = segment_duration
|
|
self.call_count = 0
|
|
|
|
def __call__(self, text: str, voice: Any, speed: float = 1.0, split_pattern: str = ""):
|
|
self.call_count += 1
|
|
# Return fake segment objects with required attributes
|
|
@dataclass
|
|
class FakeSegment:
|
|
graphemes: str = ""
|
|
audio: Any = None
|
|
tokens: list = field(default_factory=list)
|
|
|
|
samples = int(24000 * self.segment_duration)
|
|
audio = np.zeros(samples, dtype=np.float32)
|
|
tokens = [
|
|
MagicMock(start_ts=0.0, end_ts=0.3, text="Hello", whitespace=" "),
|
|
MagicMock(start_ts=0.3, end_ts=0.5, text="world", whitespace="."),
|
|
]
|
|
return [FakeSegment(graphemes=text, audio=audio, tokens=tokens)]
|
|
|
|
|
|
class FakeSubtitleWriter:
|
|
"""Fake subtitle writer that records entries."""
|
|
|
|
def __init__(self):
|
|
self.entries = []
|
|
self.opened = False
|
|
self.closed = False
|
|
|
|
def open(self) -> None:
|
|
self.opened = True
|
|
|
|
def write_entry(self, start: float, end: float, text: str) -> None:
|
|
self.entries.append((start, end, text))
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
def __enter__(self):
|
|
self.open()
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
self.close()
|
|
|
|
|
|
# ─── SegmentStats Tests ────────────────────────────────────────────
|
|
|
|
class TestSegmentStats:
|
|
"""Verify SegmentStats tracks timing and character counts."""
|
|
|
|
def test_default_values(self):
|
|
stats = SegmentStats()
|
|
assert stats.processed_chars == 0
|
|
assert stats.current_time == 0.0
|
|
assert stats.total_characters == 0
|
|
|
|
def test_mutation(self):
|
|
stats = SegmentStats(total_characters=1000)
|
|
stats.processed_chars += 100
|
|
stats.current_time += 1.5
|
|
assert stats.processed_chars == 100
|
|
assert stats.current_time == 1.5
|
|
|
|
|
|
# ─── synthesize_text Tests ─────────────────────────────────────────
|
|
|
|
class TestSynthesizeText:
|
|
"""Verify synthesize_text normalizes and runs TTS correctly."""
|
|
|
|
def test_basic_synthesis(self):
|
|
backend = FakeBackend()
|
|
tts_ctx = TTSContext()
|
|
stats = SegmentStats(total_characters=100)
|
|
sink = FakeAudioSink()
|
|
|
|
cancel = lambda: False
|
|
progress_calls = []
|
|
def on_progress(pct, etr):
|
|
progress_calls.append((pct, etr))
|
|
|
|
params = SynthParams(
|
|
tts_context=tts_ctx,
|
|
stats=stats,
|
|
check_cancel=cancel,
|
|
on_progress=on_progress,
|
|
audio_sink=sink,
|
|
)
|
|
|
|
segments, tokens = synthesize_text(
|
|
text="Hello world.",
|
|
params=params,
|
|
backend=backend,
|
|
voice="M1",
|
|
speed=1.0,
|
|
)
|
|
|
|
assert segments >= 1
|
|
assert len(sink.written) >= 1
|
|
assert len(progress_calls) >= 1
|
|
|
|
def test_cancellation(self):
|
|
backend = FakeBackend()
|
|
tts_ctx = TTSContext()
|
|
stats = SegmentStats(total_characters=10000)
|
|
|
|
cancel = lambda: True # Always cancel
|
|
progress_calls = []
|
|
def on_progress(pct, etr):
|
|
progress_calls.append((pct, etr))
|
|
|
|
params = SynthParams(
|
|
tts_context=tts_ctx,
|
|
stats=stats,
|
|
check_cancel=cancel,
|
|
on_progress=on_progress,
|
|
)
|
|
|
|
segments, tokens = synthesize_text(
|
|
text="Hello world.",
|
|
params=params,
|
|
backend=backend,
|
|
voice="M1",
|
|
speed=1.0,
|
|
)
|
|
|
|
# Should stop early due to cancellation
|
|
assert segments == 0
|
|
|
|
def test_with_chapter_sink(self):
|
|
backend = FakeBackend()
|
|
tts_ctx = TTSContext()
|
|
stats = SegmentStats(total_characters=100)
|
|
merged_sink = FakeAudioSink()
|
|
chapter_sink = FakeAudioSink()
|
|
|
|
cancel = lambda: False
|
|
def on_progress(pct, etr):
|
|
pass
|
|
|
|
params = SynthParams(
|
|
tts_context=tts_ctx,
|
|
stats=stats,
|
|
check_cancel=cancel,
|
|
on_progress=on_progress,
|
|
audio_sink=merged_sink,
|
|
)
|
|
|
|
segments, tokens = synthesize_text(
|
|
text="Hello world.",
|
|
params=params,
|
|
backend=backend,
|
|
voice="M1",
|
|
speed=1.0,
|
|
chapter_sink=chapter_sink,
|
|
)
|
|
|
|
# Both sinks should receive audio
|
|
assert len(chapter_sink.written) >= 1
|
|
assert len(merged_sink.written) >= 1
|
|
|
|
def test_split_pattern_override(self):
|
|
backend = FakeBackend()
|
|
tts_ctx = TTSContext(split_pattern=r"(?<=[.!?\-])\s+")
|
|
stats = SegmentStats(total_characters=100)
|
|
|
|
cancel = lambda: False
|
|
def on_progress(pct, etr):
|
|
pass
|
|
|
|
params = SynthParams(
|
|
tts_context=tts_ctx,
|
|
stats=stats,
|
|
check_cancel=cancel,
|
|
on_progress=on_progress,
|
|
)
|
|
|
|
segments, tokens = synthesize_text(
|
|
text="Hello world.",
|
|
params=params,
|
|
backend=backend,
|
|
voice="M1",
|
|
speed=1.0,
|
|
split_pattern_override=r"\n+",
|
|
)
|
|
|
|
assert segments >= 1
|
|
|
|
|
|
# ─── process_and_write_subtitles Tests ──────────────────────────────
|
|
|
|
class TestProcessAndWriteSubtitles:
|
|
"""Verify subtitle processing writes entries correctly."""
|
|
|
|
def test_empty_tokens(self):
|
|
writer = FakeSubtitleWriter()
|
|
process_and_write_subtitles(
|
|
[],
|
|
writer,
|
|
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
|
|
language=Language.EN_US,
|
|
use_spacy_segmentation=False,
|
|
fallback_end_time=10.0,
|
|
)
|
|
assert len(writer.entries) == 0
|
|
|
|
def test_sentence_mode_entries(self):
|
|
writer = FakeSubtitleWriter()
|
|
tokens = [
|
|
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
|
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
|
]
|
|
process_and_write_subtitles(
|
|
tokens,
|
|
writer,
|
|
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
|
|
language=Language.EN_US,
|
|
use_spacy_segmentation=False,
|
|
fallback_end_time=2.0,
|
|
)
|
|
assert len(writer.entries) >= 1
|
|
start, end, text = writer.entries[0]
|
|
assert start < end
|
|
assert isinstance(text, str)
|
|
|
|
def test_line_mode_entries(self):
|
|
writer = FakeSubtitleWriter()
|
|
tokens = [
|
|
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
|
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
|
{"start": 1.0, "end": 1.5, "text": "New", "whitespace": " "},
|
|
{"start": 1.5, "end": 2.0, "text": "line", "whitespace": "."},
|
|
]
|
|
process_and_write_subtitles(
|
|
tokens,
|
|
writer,
|
|
subtitle=SubtitleConfig(mode=SubtitleMode.LINE, max_words=5),
|
|
language=Language.EN_US,
|
|
use_spacy_segmentation=False,
|
|
fallback_end_time=3.0,
|
|
)
|
|
assert len(writer.entries) >= 1
|
|
|
|
def test_disabled_mode(self):
|
|
"""Disabled mode is checked by the caller (run_tts_segment_loop),
|
|
not by process_subtitle_tokens itself. This test verifies that
|
|
process_subtitle_tokens still processes when called directly."""
|
|
writer = FakeSubtitleWriter()
|
|
tokens = [
|
|
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
|
]
|
|
process_and_write_subtitles(
|
|
tokens,
|
|
writer,
|
|
subtitle=SubtitleConfig(mode=SubtitleMode.DISABLED, max_words=5),
|
|
language=Language.EN_US,
|
|
use_spacy_segmentation=False,
|
|
fallback_end_time=2.0,
|
|
)
|
|
# process_subtitle_tokens doesn't filter by mode — caller must check
|
|
# So entries may be written even in "Disabled" mode
|
|
assert isinstance(writer.entries, list)
|
|
|
|
|
|
# ─── Integration: Full Pipeline ─────────────────────────────────────
|
|
|
|
class TestFullPipeline:
|
|
"""Integration tests for the complete TTS pipeline."""
|
|
|
|
def test_end_to_end_synthesis(self):
|
|
backend = FakeBackend()
|
|
tts_ctx = TTSContext()
|
|
stats = SegmentStats(total_characters=50)
|
|
merged_sink = FakeAudioSink()
|
|
chapter_sink = FakeAudioSink()
|
|
subtitle_writer = FakeSubtitleWriter()
|
|
|
|
cancel = lambda: False
|
|
progress_calls = []
|
|
def on_progress(pct, etr):
|
|
progress_calls.append((pct, etr))
|
|
|
|
# Simulate full pipeline: synthesize → subtitles → finalize
|
|
params = SynthParams(
|
|
tts_context=tts_ctx,
|
|
stats=stats,
|
|
check_cancel=cancel,
|
|
on_progress=on_progress,
|
|
audio_sink=merged_sink,
|
|
subtitle_mode="Sentence",
|
|
max_subtitle_words=5,
|
|
language=Language.EN_US,
|
|
use_spacy_segmentation=False,
|
|
)
|
|
|
|
segments, tokens = synthesize_text(
|
|
text="This is a test sentence. Another sentence here.",
|
|
params=params,
|
|
backend=backend,
|
|
voice="M1",
|
|
speed=1.0,
|
|
chapter_sink=chapter_sink,
|
|
)
|
|
|
|
# Process accumulated tokens
|
|
process_and_write_subtitles(
|
|
tokens,
|
|
subtitle_writer,
|
|
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
|
|
language=Language.EN_US,
|
|
use_spacy_segmentation=False,
|
|
fallback_end_time=stats.current_time,
|
|
)
|
|
|
|
# Verify results
|
|
assert segments >= 1
|
|
assert len(merged_sink.written) >= 1
|
|
assert len(chapter_sink.written) >= 1
|
|
assert stats.processed_chars > 0
|
|
assert stats.current_time > 0
|
|
|
|
def test_multi_segment_with_cancel(self):
|
|
"""Test that cancellation works mid-pipeline."""
|
|
backend = FakeBackend()
|
|
tts_ctx = TTSContext()
|
|
stats = SegmentStats(total_characters=10000)
|
|
|
|
cancel_count = [0]
|
|
def cancel_fn():
|
|
cancel_count[0] += 1
|
|
return cancel_count[0] > 3 # Cancel after 3 segments
|
|
|
|
progress_calls = []
|
|
def on_progress(pct, etr):
|
|
progress_calls.append((pct, etr))
|
|
|
|
params = SynthParams(
|
|
tts_context=tts_ctx,
|
|
stats=stats,
|
|
check_cancel=cancel_fn,
|
|
on_progress=on_progress,
|
|
)
|
|
|
|
segments, tokens = synthesize_text(
|
|
text="Hello world. " * 100,
|
|
params=params,
|
|
backend=backend,
|
|
voice="M1",
|
|
speed=1.0,
|
|
)
|
|
|
|
# Should have stopped before processing all text
|
|
assert segments <= 4
|