diff --git a/abogen/domain/conversion_engine.py b/abogen/domain/conversion_engine.py index 078bd61..2558569 100644 --- a/abogen/domain/conversion_engine.py +++ b/abogen/domain/conversion_engine.py @@ -11,8 +11,8 @@ The core pattern is identical across both UIs: After the loop, the caller processes accumulated subtitle tokens. This module provides ``run_tts_segment_loop`` which encapsulates that -iteration, while leaving UI-specific concerns (progress display, subtitle -processing strategy) to the caller via callbacks. +iteration, and ``synthesize_text`` which adds normalization on top — +the single entry point both UIs should call for text-to-speech. """ from __future__ import annotations @@ -23,6 +23,7 @@ from typing import Any, Callable, List, Optional, Protocol from abogen.domain.audio_sink import AudioSink from abogen.domain.conversion_pipeline import tts_segments +from abogen.domain.normalization import TTSContext from abogen.domain.progress import calc_etr_str from abogen.domain.subtitle_generation import process_subtitle_tokens @@ -188,3 +189,49 @@ def process_and_write_subtitles( ) for start, end, text in new_entries: subtitle_writer.write_entry(start=start, end=end, text=text) + + +def synthesize_text( + *, + text: str, + tts_context: TTSContext, + 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 = 5, + 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. + + 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) + 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, + chapter_sink=chapter_sink, + audio_sink=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, + ) diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index 65914a5..202ec3c 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 run_tts_segment_loop, SegmentStats, SegmentInfo +from abogen.domain.conversion_engine import synthesize_text, SegmentStats, SegmentInfo from abogen.domain.intro_outro import resolve_intro, resolve_outro from abogen.domain.audio_buffer import ( create_silence, @@ -982,14 +982,6 @@ class ConversionThread(QThread): print("Using split pattern: (unprintable)") for text_segment in text_segments: - # Normalize text before TTS - try: - text_segment = self._tts_context.normalize(text_segment) - except Exception as exc: - self.log_updated.emit( - (f"Warning: Text normalization failed: {exc}", "orange") - ) - def _qt_check_cancel() -> bool: if self.cancel_requested: sink_stack.close() @@ -1049,19 +1041,25 @@ class ConversionThread(QThread): total_characters=self.total_char_count, ) - run_tts_segment_loop( - text=text_segment, - backend=self.backend, - voice=loaded_voice, - speed=self.speed, - split_pattern=active_split_pattern, - 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, - ) + try: + synthesize_text( + text=text_segment, + tts_context=self._tts_context, + 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, + ) + except Exception as exc: + self.log_updated.emit( + (f"Warning: TTS failed: {exc}", "orange") + ) self.processed_char_count = stats.processed_chars current_time = stats.current_time diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 9576984..ff6eb5d 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -32,7 +32,6 @@ from abogen.utils import ( get_user_output_path, ) from abogen.voice_profiles import load_profiles, normalize_profile_entry -from abogen.llm_client import LLMClientError from abogen.infrastructure.subtitle_writer import make_subtitle_writer from abogen.domain.chapter_titles import ( simplify_heading_text as _simplify_heading_text, @@ -118,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 run_tts_segment_loop, process_and_write_subtitles, SegmentStats +from abogen.domain.conversion_engine import synthesize_text, 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 @@ -422,15 +421,7 @@ def run_conversion_job(job: Job) -> None: supertonic_steps_override: Optional[int] = None, ) -> int: nonlocal processed_chars, current_time - if split_pattern is None: - split_pattern = tts_context.split_pattern source_text = str(text or "") - try: - normalized = tts_context.normalize(source_text) - except LLMClientError as exc: - job.add_log(f"LLM normalization failed: {exc}", level="error") - raise - local_segments = 0 provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro" if provider == "supertonic": @@ -467,12 +458,12 @@ 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 = run_tts_segment_loop( - text=normalized, + local_segments, accumulated_tokens = synthesize_text( + text=source_text, + tts_context=tts_context, backend=backend, voice=resolved_voice, speed=effective_speed, - split_pattern=split_pattern, stats=stats, check_cancel=canceller, on_progress=_on_progress,