diff --git a/abogen/application/conversion_executor.py b/abogen/application/conversion_executor.py index 898424c..9a873c3 100644 --- a/abogen/application/conversion_executor.py +++ b/abogen/application/conversion_executor.py @@ -29,6 +29,7 @@ from abogen.application.conversion_result import ConversionResult from abogen.domain.audio_sink import open_audio_sink from abogen.domain.conversion_engine import ( SegmentStats, + SynthParams, process_and_write_subtitles, synthesize_text, ) @@ -138,6 +139,18 @@ def execute_conversion( effective_subtitle_mode = request.subtitle_mode if subtitle_writer else "Disabled" + synth = SynthParams( + tts_context=tts_context, + stats=stats, + check_cancel=check_cancelled, + on_progress=lambda pct, etr: events.progress(pct, etr), + audio_sink=audio_sink, + subtitle_mode=effective_subtitle_mode, + max_subtitle_words=request.max_subtitle_words, + lang_code=request.language, + use_spacy_segmentation=use_spacy, + ) + # Chapter directory chapter_dir = None if request.save_chapters_separately and len(plan.chapters) > 1: @@ -154,20 +167,12 @@ def execute_conversion( intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu) synthesize_text( text=plan.intro.text, - tts_context=tts_context, + params=synth, backend=intro_backend, voice=intro_voice, speed=intro_speed or request.speed, - stats=stats, - check_cancel=check_cancelled, - on_progress=lambda pct, etr: events.progress(pct, etr), chapter_sink=None, - audio_sink=audio_sink, preview_callback=lambda text: events.log(f" {text[:80]}"), - subtitle_mode=effective_subtitle_mode, - max_subtitle_words=request.max_subtitle_words, - lang_code=request.language, - use_spacy_segmentation=use_spacy, ) intro_emitted = True events.log("Intro synthesized.") @@ -209,20 +214,12 @@ def execute_conversion( intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu) synthesize_text( text=plan.intro.text, - tts_context=tts_context, + params=synth, backend=intro_backend, voice=intro_voice, speed=intro_speed or request.speed, - stats=stats, - check_cancel=check_cancelled, - on_progress=lambda pct, etr: events.progress(pct, etr), chapter_sink=chapter_sink, - audio_sink=audio_sink, preview_callback=lambda text: events.log(f" Intro: {text[:80]}"), - subtitle_mode=effective_subtitle_mode, - max_subtitle_words=request.max_subtitle_words, - lang_code=request.language, - use_spacy_segmentation=use_spacy, ) intro_emitted = True if request.chapter_intro_delay > 0: @@ -239,20 +236,12 @@ def execute_conversion( if heading_text: synthesize_text( text=heading_text, - tts_context=tts_context, + params=synth, backend=chapter_backend, voice=chapter_voice, speed=chapter_speed or request.speed, - stats=stats, - check_cancel=check_cancelled, - on_progress=lambda pct, etr: events.progress(pct, etr), chapter_sink=chapter_sink, - audio_sink=audio_sink, preview_callback=lambda text: events.log(f" Title: {text[:80]}"), - subtitle_mode=effective_subtitle_mode, - max_subtitle_words=request.max_subtitle_words, - lang_code=request.language, - use_spacy_segmentation=use_spacy, ) if request.chapter_intro_delay > 0: _append_silence( @@ -282,20 +271,12 @@ def execute_conversion( seg_start_time = stats.current_time local_segments, accumulated_tokens = synthesize_text( text=segment.text, - tts_context=tts_context, + params=synth, backend=seg_backend, voice=seg_voice, speed=seg_speed or request.speed, - stats=stats, - check_cancel=check_cancelled, - on_progress=lambda pct, etr: events.progress(pct, etr), chapter_sink=chapter_sink, - audio_sink=audio_sink, preview_callback=lambda text: events.log(f" {text[:80]}"), - subtitle_mode=effective_subtitle_mode, - max_subtitle_words=request.max_subtitle_words, - lang_code=request.language, - use_spacy_segmentation=use_spacy, ) # Process subtitles @@ -366,20 +347,12 @@ def execute_conversion( synthesize_text( text=plan.outro.text, - tts_context=tts_context, + params=synth, backend=outro_backend, voice=outro_voice, speed=outro_speed or request.speed, - stats=stats, - check_cancel=check_cancelled, - on_progress=lambda pct, etr: events.progress(pct, etr), chapter_sink=None, - audio_sink=audio_sink, preview_callback=lambda text: events.log(f" {text[:80]}"), - subtitle_mode=effective_subtitle_mode, - max_subtitle_words=request.max_subtitle_words, - lang_code=request.language, - use_spacy_segmentation=use_spacy, ) events.log("Outro synthesized.") diff --git a/abogen/domain/conversion_engine.py b/abogen/domain/conversion_engine.py index ded6cef..f595cf7 100644 --- a/abogen/domain/conversion_engine.py +++ b/abogen/domain/conversion_engine.py @@ -191,24 +191,34 @@ def process_and_write_subtitles( subtitle_writer.write_entry(start=start, end=end, text=text) +@dataclass(frozen=True) +class SynthParams: + """Common parameters for synthesize_text calls. + + Packed once by the executor to avoid repeating identical kwargs. + When adding new common params, change only this dataclass. + """ + tts_context: TTSContext + stats: SegmentStats + check_cancel: CancelChecker + on_progress: Callable[[int, str], None] + audio_sink: Optional[AudioSink] = None + subtitle_mode: str = "Disabled" + max_subtitle_words: int = 50 + lang_code: str = "a" + use_spacy_segmentation: bool = False + + def synthesize_text( *, text: str, - tts_context: TTSContext, + params: SynthParams, backend: Any, voice: Any, speed: float, - stats: SegmentStats, - check_cancel: CancelChecker, - on_progress: Callable[[int, str], None], chapter_sink: Optional[AudioSink] = None, - audio_sink: Optional[AudioSink] = None, preview_callback: Optional[Callable[[str], None]] = None, on_segment: Optional[Callable[[SegmentInfo], None]] = None, - subtitle_mode: str = "Disabled", - max_subtitle_words: int = 50, - lang_code: str = "a", - use_spacy_segmentation: bool = False, split_pattern_override: Optional[str] = None, ) -> tuple[int, list]: """Normalize text and run TTS — the single entry point for both UIs. @@ -216,22 +226,22 @@ def synthesize_text( Combines TTSContext.normalize() + run_tts_segment_loop() into one call. UI-specific concerns (provider resolution, progress display) stay in the UI. """ - normalized = tts_context.normalize(text) + normalized = params.tts_context.normalize(text) return run_tts_segment_loop( text=normalized, backend=backend, voice=voice, speed=speed, - split_pattern=split_pattern_override or tts_context.split_pattern, - stats=stats, - check_cancel=check_cancel, - on_progress=on_progress, + split_pattern=split_pattern_override or params.tts_context.split_pattern, + stats=params.stats, + check_cancel=params.check_cancel, + on_progress=params.on_progress, chapter_sink=chapter_sink, - audio_sink=audio_sink, + audio_sink=params.audio_sink, preview_callback=preview_callback, on_segment=on_segment, - subtitle_mode=subtitle_mode, - max_subtitle_words=max_subtitle_words, - lang_code=lang_code, - use_spacy_segmentation=use_spacy_segmentation, + subtitle_mode=params.subtitle_mode, + max_subtitle_words=params.max_subtitle_words, + lang_code=params.lang_code, + use_spacy_segmentation=params.use_spacy_segmentation, ) diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index 4537518..27dea5b 100644 --- a/abogen/pyqt/conversion.py +++ b/abogen/pyqt/conversion.py @@ -36,7 +36,7 @@ from abogen.domain.output_paths import ( ) from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32 from abogen.domain.audio_sink import AudioSink, open_audio_sink -from abogen.domain.conversion_engine import synthesize_text, SegmentStats, SegmentInfo +from abogen.domain.conversion_engine import synthesize_text, SynthParams, SegmentStats, SegmentInfo from abogen.domain.intro_outro import resolve_intro, resolve_outro from abogen.domain.audio_buffer import ( create_silence, @@ -1016,18 +1016,26 @@ class ConversionThread(QThread): total_characters=self.total_char_count, ) + synth_params = SynthParams( + tts_context=self._tts_context, + stats=stats, + check_cancel=_qt_check_cancel, + on_progress=_qt_on_progress, + audio_sink=merged_sink if merge_chapters_at_end else None, + subtitle_mode=self.subtitle_mode, + max_subtitle_words=self.max_subtitle_words, + lang_code=self.lang_code, + use_spacy_segmentation=getattr(self, "use_spacy_segmentation", False), + ) + try: synthesize_text( text=text_segment, - tts_context=self._tts_context, + params=synth_params, backend=self.backend, voice=loaded_voice, speed=self.speed, - stats=stats, - check_cancel=_qt_check_cancel, - on_progress=_qt_on_progress, chapter_sink=chapter_sink, - audio_sink=merged_sink if merge_chapters_at_end else None, on_segment=_qt_on_segment, split_pattern_override=active_split_pattern, ) diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 9785b93..f9051b9 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -117,7 +117,7 @@ from abogen.domain.audio_buffer import ( ) from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.pipeline_factory import PipelinePool -from abogen.domain.conversion_engine import synthesize_text, process_and_write_subtitles, SegmentStats +from abogen.domain.conversion_engine import synthesize_text, SynthParams, process_and_write_subtitles, SegmentStats from abogen.domain.voice_loader import VoiceCache, resolve_voice from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target @@ -462,23 +462,27 @@ def run_conversion_job(job: Job) -> None: def _preview(text: str) -> None: job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or '—'}: {text[:80]}") - local_segments, accumulated_tokens = synthesize_text( - text=source_text, + synth_params = SynthParams( tts_context=tts_context, - backend=backend, - voice=resolved_voice, - speed=effective_speed, stats=stats, check_cancel=canceller, on_progress=_on_progress, - chapter_sink=chapter_sink, audio_sink=audio_sink, - preview_callback=_preview, subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled", max_subtitle_words=job.max_subtitle_words, lang_code=job.language, use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"), ) + + local_segments, accumulated_tokens = synthesize_text( + text=source_text, + params=synth_params, + backend=backend, + voice=resolved_voice, + speed=effective_speed, + chapter_sink=chapter_sink, + preview_callback=_preview, + ) current_time = stats.current_time if subtitle_writer and audio_sink and accumulated_tokens: diff --git a/tests/test_conversion_executor.py b/tests/test_conversion_executor.py index 90dc369..9a59299 100644 --- a/tests/test_conversion_executor.py +++ b/tests/test_conversion_executor.py @@ -16,6 +16,7 @@ from typing import Any from abogen.domain.conversion_engine import ( synthesize_text, + SynthParams, run_tts_segment_loop, process_and_write_subtitles, SegmentStats, @@ -133,18 +134,22 @@ class TestSynthesizeText: def on_progress(pct, etr): progress_calls.append((pct, etr)) - segments, tokens = synthesize_text( - text="Hello world.", + params = SynthParams( tts_context=tts_ctx, - backend=backend, - voice="M1", - speed=1.0, 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 @@ -159,17 +164,21 @@ class TestSynthesizeText: def on_progress(pct, etr): progress_calls.append((pct, etr)) - segments, tokens = synthesize_text( - text="Hello world.", + params = SynthParams( tts_context=tts_ctx, - backend=backend, - voice="M1", - speed=1.0, 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 @@ -184,19 +193,23 @@ class TestSynthesizeText: def on_progress(pct, etr): pass - segments, tokens = synthesize_text( - text="Hello world.", + params = SynthParams( tts_context=tts_ctx, - backend=backend, - voice="M1", - speed=1.0, stats=stats, check_cancel=cancel, on_progress=on_progress, - chapter_sink=chapter_sink, 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 @@ -210,15 +223,19 @@ class TestSynthesizeText: def on_progress(pct, etr): pass - segments, tokens = synthesize_text( - text="Hello world.", + params = SynthParams( tts_context=tts_ctx, - backend=backend, - voice="M1", - speed=1.0, 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+", ) @@ -323,16 +340,11 @@ class TestFullPipeline: progress_calls.append((pct, etr)) # Simulate full pipeline: synthesize → subtitles → finalize - segments, tokens = synthesize_text( - text="This is a test sentence. Another sentence here.", + params = SynthParams( tts_context=tts_ctx, - backend=backend, - voice="M1", - speed=1.0, stats=stats, check_cancel=cancel, on_progress=on_progress, - chapter_sink=chapter_sink, audio_sink=merged_sink, subtitle_mode="Sentence", max_subtitle_words=5, @@ -340,6 +352,15 @@ class TestFullPipeline: 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, @@ -373,16 +394,20 @@ class TestFullPipeline: def on_progress(pct, etr): progress_calls.append((pct, etr)) - segments, tokens = synthesize_text( - text="Hello world. " * 100, + params = SynthParams( tts_context=tts_ctx, - backend=backend, - voice="M1", - speed=1.0, 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