refactor: SynthParams dataclass for synthesize_text

- Frozen dataclass in domain/conversion_engine.py with common params
- synthesize_text now takes params=SynthParams + unique kwargs
- Executor, PyQt legacy, WebUI legacy, and tests updated
- Adding new common params now only requires changing the dataclass
This commit is contained in:
Artem Akymenko
2026-07-22 08:21:45 +00:00
parent 93f5a46485
commit c4cebb8822
5 changed files with 130 additions and 110 deletions
+18 -45
View File
@@ -29,6 +29,7 @@ from abogen.application.conversion_result import ConversionResult
from abogen.domain.audio_sink import open_audio_sink from abogen.domain.audio_sink import open_audio_sink
from abogen.domain.conversion_engine import ( from abogen.domain.conversion_engine import (
SegmentStats, SegmentStats,
SynthParams,
process_and_write_subtitles, process_and_write_subtitles,
synthesize_text, synthesize_text,
) )
@@ -138,6 +139,18 @@ def execute_conversion(
effective_subtitle_mode = request.subtitle_mode if subtitle_writer else "Disabled" 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 directory
chapter_dir = None chapter_dir = None
if request.save_chapters_separately and len(plan.chapters) > 1: 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) intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu)
synthesize_text( synthesize_text(
text=plan.intro.text, text=plan.intro.text,
tts_context=tts_context, params=synth,
backend=intro_backend, backend=intro_backend,
voice=intro_voice, voice=intro_voice,
speed=intro_speed or request.speed, speed=intro_speed or request.speed,
stats=stats,
check_cancel=check_cancelled,
on_progress=lambda pct, etr: events.progress(pct, etr),
chapter_sink=None, chapter_sink=None,
audio_sink=audio_sink,
preview_callback=lambda text: events.log(f" {text[:80]}"), 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 intro_emitted = True
events.log("Intro synthesized.") events.log("Intro synthesized.")
@@ -209,20 +214,12 @@ def execute_conversion(
intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu) intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu)
synthesize_text( synthesize_text(
text=plan.intro.text, text=plan.intro.text,
tts_context=tts_context, params=synth,
backend=intro_backend, backend=intro_backend,
voice=intro_voice, voice=intro_voice,
speed=intro_speed or request.speed, 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, chapter_sink=chapter_sink,
audio_sink=audio_sink,
preview_callback=lambda text: events.log(f" Intro: {text[:80]}"), 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 intro_emitted = True
if request.chapter_intro_delay > 0: if request.chapter_intro_delay > 0:
@@ -239,20 +236,12 @@ def execute_conversion(
if heading_text: if heading_text:
synthesize_text( synthesize_text(
text=heading_text, text=heading_text,
tts_context=tts_context, params=synth,
backend=chapter_backend, backend=chapter_backend,
voice=chapter_voice, voice=chapter_voice,
speed=chapter_speed or request.speed, 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, chapter_sink=chapter_sink,
audio_sink=audio_sink,
preview_callback=lambda text: events.log(f" Title: {text[:80]}"), 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: if request.chapter_intro_delay > 0:
_append_silence( _append_silence(
@@ -282,20 +271,12 @@ def execute_conversion(
seg_start_time = stats.current_time seg_start_time = stats.current_time
local_segments, accumulated_tokens = synthesize_text( local_segments, accumulated_tokens = synthesize_text(
text=segment.text, text=segment.text,
tts_context=tts_context, params=synth,
backend=seg_backend, backend=seg_backend,
voice=seg_voice, voice=seg_voice,
speed=seg_speed or request.speed, 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, chapter_sink=chapter_sink,
audio_sink=audio_sink,
preview_callback=lambda text: events.log(f" {text[:80]}"), 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 # Process subtitles
@@ -366,20 +347,12 @@ def execute_conversion(
synthesize_text( synthesize_text(
text=plan.outro.text, text=plan.outro.text,
tts_context=tts_context, params=synth,
backend=outro_backend, backend=outro_backend,
voice=outro_voice, voice=outro_voice,
speed=outro_speed or request.speed, speed=outro_speed or request.speed,
stats=stats,
check_cancel=check_cancelled,
on_progress=lambda pct, etr: events.progress(pct, etr),
chapter_sink=None, chapter_sink=None,
audio_sink=audio_sink,
preview_callback=lambda text: events.log(f" {text[:80]}"), 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.") events.log("Outro synthesized.")
+29 -19
View File
@@ -191,24 +191,34 @@ def process_and_write_subtitles(
subtitle_writer.write_entry(start=start, end=end, text=text) 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( def synthesize_text(
*, *,
text: str, text: str,
tts_context: TTSContext, params: SynthParams,
backend: Any, backend: Any,
voice: Any, voice: Any,
speed: float, speed: float,
stats: SegmentStats,
check_cancel: CancelChecker,
on_progress: Callable[[int, str], None],
chapter_sink: Optional[AudioSink] = None, chapter_sink: Optional[AudioSink] = None,
audio_sink: Optional[AudioSink] = None,
preview_callback: Optional[Callable[[str], None]] = None, preview_callback: Optional[Callable[[str], None]] = None,
on_segment: Optional[Callable[[SegmentInfo], 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, split_pattern_override: Optional[str] = None,
) -> tuple[int, list]: ) -> tuple[int, list]:
"""Normalize text and run TTS — the single entry point for both UIs. """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. Combines TTSContext.normalize() + run_tts_segment_loop() into one call.
UI-specific concerns (provider resolution, progress display) stay in the UI. 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( return run_tts_segment_loop(
text=normalized, text=normalized,
backend=backend, backend=backend,
voice=voice, voice=voice,
speed=speed, speed=speed,
split_pattern=split_pattern_override or tts_context.split_pattern, split_pattern=split_pattern_override or params.tts_context.split_pattern,
stats=stats, stats=params.stats,
check_cancel=check_cancel, check_cancel=params.check_cancel,
on_progress=on_progress, on_progress=params.on_progress,
chapter_sink=chapter_sink, chapter_sink=chapter_sink,
audio_sink=audio_sink, audio_sink=params.audio_sink,
preview_callback=preview_callback, preview_callback=preview_callback,
on_segment=on_segment, on_segment=on_segment,
subtitle_mode=subtitle_mode, subtitle_mode=params.subtitle_mode,
max_subtitle_words=max_subtitle_words, max_subtitle_words=params.max_subtitle_words,
lang_code=lang_code, lang_code=params.lang_code,
use_spacy_segmentation=use_spacy_segmentation, use_spacy_segmentation=params.use_spacy_segmentation,
) )
+14 -6
View File
@@ -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_helpers import build_ffmpeg_command, to_float32
from abogen.domain.audio_sink import AudioSink, open_audio_sink 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.intro_outro import resolve_intro, resolve_outro
from abogen.domain.audio_buffer import ( from abogen.domain.audio_buffer import (
create_silence, create_silence,
@@ -1016,18 +1016,26 @@ class ConversionThread(QThread):
total_characters=self.total_char_count, 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: try:
synthesize_text( synthesize_text(
text=text_segment, text=text_segment,
tts_context=self._tts_context, params=synth_params,
backend=self.backend, backend=self.backend,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
stats=stats,
check_cancel=_qt_check_cancel,
on_progress=_qt_on_progress,
chapter_sink=chapter_sink, chapter_sink=chapter_sink,
audio_sink=merged_sink if merge_chapters_at_end else None,
on_segment=_qt_on_segment, on_segment=_qt_on_segment,
split_pattern_override=active_split_pattern, split_pattern_override=active_split_pattern,
) )
+12 -8
View File
@@ -117,7 +117,7 @@ from abogen.domain.audio_buffer import (
) )
from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.audio_sink import AudioSink, open_audio_sink
from abogen.domain.pipeline_factory import PipelinePool 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_loader import VoiceCache, resolve_voice
from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target 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: def _preview(text: str) -> None:
job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or ''}: {text[:80]}") job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or ''}: {text[:80]}")
local_segments, accumulated_tokens = synthesize_text( synth_params = SynthParams(
text=source_text,
tts_context=tts_context, tts_context=tts_context,
backend=backend,
voice=resolved_voice,
speed=effective_speed,
stats=stats, stats=stats,
check_cancel=canceller, check_cancel=canceller,
on_progress=_on_progress, on_progress=_on_progress,
chapter_sink=chapter_sink,
audio_sink=audio_sink, audio_sink=audio_sink,
preview_callback=_preview,
subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled", subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled",
max_subtitle_words=job.max_subtitle_words, max_subtitle_words=job.max_subtitle_words,
lang_code=job.language, lang_code=job.language,
use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"), 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 current_time = stats.current_time
if subtitle_writer and audio_sink and accumulated_tokens: if subtitle_writer and audio_sink and accumulated_tokens:
+57 -32
View File
@@ -16,6 +16,7 @@ from typing import Any
from abogen.domain.conversion_engine import ( from abogen.domain.conversion_engine import (
synthesize_text, synthesize_text,
SynthParams,
run_tts_segment_loop, run_tts_segment_loop,
process_and_write_subtitles, process_and_write_subtitles,
SegmentStats, SegmentStats,
@@ -133,18 +134,22 @@ class TestSynthesizeText:
def on_progress(pct, etr): def on_progress(pct, etr):
progress_calls.append((pct, etr)) progress_calls.append((pct, etr))
segments, tokens = synthesize_text( params = SynthParams(
text="Hello world.",
tts_context=tts_ctx, tts_context=tts_ctx,
backend=backend,
voice="M1",
speed=1.0,
stats=stats, stats=stats,
check_cancel=cancel, check_cancel=cancel,
on_progress=on_progress, on_progress=on_progress,
audio_sink=sink, audio_sink=sink,
) )
segments, tokens = synthesize_text(
text="Hello world.",
params=params,
backend=backend,
voice="M1",
speed=1.0,
)
assert segments >= 1 assert segments >= 1
assert len(sink.written) >= 1 assert len(sink.written) >= 1
assert len(progress_calls) >= 1 assert len(progress_calls) >= 1
@@ -159,17 +164,21 @@ class TestSynthesizeText:
def on_progress(pct, etr): def on_progress(pct, etr):
progress_calls.append((pct, etr)) progress_calls.append((pct, etr))
segments, tokens = synthesize_text( params = SynthParams(
text="Hello world.",
tts_context=tts_ctx, tts_context=tts_ctx,
backend=backend,
voice="M1",
speed=1.0,
stats=stats, stats=stats,
check_cancel=cancel, check_cancel=cancel,
on_progress=on_progress, 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 # Should stop early due to cancellation
assert segments == 0 assert segments == 0
@@ -184,19 +193,23 @@ class TestSynthesizeText:
def on_progress(pct, etr): def on_progress(pct, etr):
pass pass
segments, tokens = synthesize_text( params = SynthParams(
text="Hello world.",
tts_context=tts_ctx, tts_context=tts_ctx,
backend=backend,
voice="M1",
speed=1.0,
stats=stats, stats=stats,
check_cancel=cancel, check_cancel=cancel,
on_progress=on_progress, on_progress=on_progress,
chapter_sink=chapter_sink,
audio_sink=merged_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 # Both sinks should receive audio
assert len(chapter_sink.written) >= 1 assert len(chapter_sink.written) >= 1
assert len(merged_sink.written) >= 1 assert len(merged_sink.written) >= 1
@@ -210,15 +223,19 @@ class TestSynthesizeText:
def on_progress(pct, etr): def on_progress(pct, etr):
pass pass
segments, tokens = synthesize_text( params = SynthParams(
text="Hello world.",
tts_context=tts_ctx, tts_context=tts_ctx,
backend=backend,
voice="M1",
speed=1.0,
stats=stats, stats=stats,
check_cancel=cancel, check_cancel=cancel,
on_progress=on_progress, 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+", split_pattern_override=r"\n+",
) )
@@ -323,16 +340,11 @@ class TestFullPipeline:
progress_calls.append((pct, etr)) progress_calls.append((pct, etr))
# Simulate full pipeline: synthesize → subtitles → finalize # Simulate full pipeline: synthesize → subtitles → finalize
segments, tokens = synthesize_text( params = SynthParams(
text="This is a test sentence. Another sentence here.",
tts_context=tts_ctx, tts_context=tts_ctx,
backend=backend,
voice="M1",
speed=1.0,
stats=stats, stats=stats,
check_cancel=cancel, check_cancel=cancel,
on_progress=on_progress, on_progress=on_progress,
chapter_sink=chapter_sink,
audio_sink=merged_sink, audio_sink=merged_sink,
subtitle_mode="Sentence", subtitle_mode="Sentence",
max_subtitle_words=5, max_subtitle_words=5,
@@ -340,6 +352,15 @@ class TestFullPipeline:
use_spacy_segmentation=False, 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 accumulated tokens
process_and_write_subtitles( process_and_write_subtitles(
tokens, tokens,
@@ -373,16 +394,20 @@ class TestFullPipeline:
def on_progress(pct, etr): def on_progress(pct, etr):
progress_calls.append((pct, etr)) progress_calls.append((pct, etr))
segments, tokens = synthesize_text( params = SynthParams(
text="Hello world. " * 100,
tts_context=tts_ctx, tts_context=tts_ctx,
backend=backend,
voice="M1",
speed=1.0,
stats=stats, stats=stats,
check_cancel=cancel_fn, check_cancel=cancel_fn,
on_progress=on_progress, 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 # Should have stopped before processing all text
assert segments <= 4 assert segments <= 4