diff --git a/abogen/domain/conversion_engine.py b/abogen/domain/conversion_engine.py new file mode 100644 index 0000000..078bd61 --- /dev/null +++ b/abogen/domain/conversion_engine.py @@ -0,0 +1,190 @@ +"""Shared TTS iteration loop used by both WebUI and PyQt conversion runners. + +The core pattern is identical across both UIs: + + for seg in tts_segments(text, backend, voice, speed, split_pattern, current_time): + check_cancel() + update_progress(seg) + write_audio(seg, sink) + accumulate_subtitles(seg) + +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. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +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.progress import calc_etr_str +from abogen.domain.subtitle_generation import process_subtitle_tokens + + +class CancelChecker(Protocol): + """Returns True if conversion has been cancelled.""" + def __call__(self) -> bool: ... + + +@dataclass +class SegmentStats: + """Running statistics updated per TTS segment.""" + processed_chars: int = 0 + current_time: float = 0.0 + etr_start_time: float = field(default_factory=time.time) + total_characters: int = 0 + + +@dataclass +class SegmentInfo: + """Read-only info about a TTS segment, passed to on_segment callback.""" + graphemes: str + audio: Any + tokens: list + duration: float + chunk_start: float + + +def run_tts_segment_loop( + *, + text: str, + backend: Any, + voice: Any, + speed: float, + split_pattern: str, + 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, +) -> tuple[int, list]: + """Run the core TTS segment iteration loop. + + Args: + text: Normalized text to synthesize. + backend: TTS pipeline instance (Kokoro or Supertonic). + voice: Voice name/id for the backend. + speed: Speech speed multiplier. + split_pattern: Regex pattern used by the TTS engine for sentence splitting. + stats: Running character/timing stats (mutated in place). + check_cancel: Called each segment; if it returns True, iteration stops. + on_progress: Called with (percent, etr_str) after each segment. + chapter_sink: Optional audio sink for the current chapter. + audio_sink: Optional audio sink for the merged output. + preview_callback: Called with a short preview string per segment. + on_segment: Called with a SegmentInfo for each segment *before* + audio is written. Useful for callers that need per-segment + subtitle processing (e.g. PyQt dual-writer pattern). + When provided, the default subtitle accumulation is skipped. + subtitle_mode: Subtitle mode string (e.g. "Disabled", "Sentence"). + max_subtitle_words: Max words per subtitle entry. + lang_code: Language code for subtitle processing. + use_spacy_segmentation: Whether spaCy sentence boundaries are active. + + Returns: + Tuple of (segment_count, accumulated_subtitle_tokens). + The caller is responsible for processing subtitle tokens via + ``process_subtitle_tokens`` and writing entries to subtitle writers. + """ + local_segments = 0 + accumulated_tokens: list[dict] = [] + + for seg in tts_segments( + text, + backend=backend, + voice=voice, + speed=speed, + split_pattern=split_pattern, + current_time=stats.current_time, + ): + if check_cancel(): + break + + local_segments += 1 + stats.processed_chars += len(seg.graphemes) + + # Progress + if stats.total_characters: + percent = min(int(stats.processed_chars / stats.total_characters * 100), 99) + else: + percent = 0 if stats.processed_chars == 0 else 99 + + etr_str = calc_etr_str( + time.time() - stats.etr_start_time, + stats.processed_chars, + stats.total_characters, + ) + on_progress(percent, etr_str) + + # Preview / log + if preview_callback: + preview_callback(seg.graphemes or "[silence]") + + # Per-segment callback (for callers needing segment-level access) + if on_segment: + info = SegmentInfo( + graphemes=seg.graphemes, + audio=seg.audio, + tokens=list(seg.tokens) if seg.tokens else [], + duration=seg.duration, + chunk_start=getattr(seg, "chunk_start", stats.current_time), + ) + on_segment(info) + + # Write audio + if chapter_sink: + chapter_sink.write(seg.audio) + if audio_sink: + audio_sink.write(seg.audio) + + # Accumulate subtitle tokens (default path; skipped if on_segment handles it) + if not on_segment and subtitle_mode != "Disabled" and seg.tokens: + accumulated_tokens.extend(seg.tokens) + + # Update timing + if audio_sink: + stats.current_time += seg.duration + + return local_segments, accumulated_tokens + + +def process_and_write_subtitles( + accumulated_tokens: list[dict], + subtitle_writer: Any, + *, + subtitle_mode: str, + max_subtitle_words: int, + lang_code: str, + use_spacy_segmentation: bool, + fallback_end_time: float, +) -> None: + """Process accumulated subtitle tokens and write entries to a subtitle writer. + + This is the standard subtitle post-processing step shared by both UIs. + """ + if not accumulated_tokens or not subtitle_writer: + return + new_entries: list[tuple] = [] + process_subtitle_tokens( + accumulated_tokens, + new_entries, + max_subtitle_words, + subtitle_mode, + lang_code, + use_spacy_segmentation=use_spacy_segmentation, + fallback_end_time=fallback_end_time, + ) + for start, end, text in new_entries: + subtitle_writer.write_entry(start=start, end=end, text=text) diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index 85d1dcd..c786efd 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_pipeline import tts_segments +from abogen.domain.conversion_engine import run_tts_segment_loop, SegmentStats, SegmentInfo from abogen.domain.audio_buffer import ( create_silence, mix_audio, @@ -950,41 +950,32 @@ class ConversionThread(QThread): (f"Warning: Text normalization failed: {exc}", "orange") ) - for seg in tts_segments( - text_segment, - backend=self.backend, - voice=loaded_voice, - speed=self.speed, - split_pattern=active_split_pattern, - current_time=current_time, - ): + def _qt_check_cancel() -> bool: if self.cancel_requested: sink_stack.close() self.conversion_finished.emit("Cancelled", None) - return - current_segment += 1 - self.processed_char_count += len(seg.graphemes) + return True + return False + + def _qt_on_progress(pct: int, etr: str) -> None: + self.processed_char_count = stats.processed_chars + self.progress_updated.emit(pct, etr) + + def _qt_on_segment(info: SegmentInfo) -> None: + nonlocal chapter_current_time self.log_updated.emit( - f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {seg.graphemes}" + f"\n{stats.processed_chars:,}/{self.total_char_count:,}: {info.graphemes}" ) - - # Write audio - if merge_chapters_at_end and merged_sink: - merged_sink.write(seg.audio) - if chapter_sink: - chapter_sink.write(seg.audio) - - # Subtitle logic - if self.subtitle_mode != "Disabled" and seg.tokens: - tokens_with_timestamps = list(seg.tokens) + if self.subtitle_mode != "Disabled" and info.tokens: + tokens_with_timestamps = list(info.tokens) chapter_tokens_with_timestamps = [ { - "start": chapter_current_time + (t["start"] - seg.chunk_start), - "end": chapter_current_time + (t["end"] - seg.chunk_start), + "start": chapter_current_time + (t["start"] - info.chunk_start), + "end": chapter_current_time + (t["end"] - info.chunk_start), "text": t["text"], "whitespace": t["whitespace"], } - for t in seg.tokens + for t in info.tokens ] if merge_chapters_at_end: new_entries = [] @@ -992,7 +983,7 @@ class ConversionThread(QThread): tokens_with_timestamps, new_entries, self.max_subtitle_words, - fallback_end_time=seg.chunk_start + seg.duration, + fallback_end_time=info.chunk_start + info.duration, ) if merged_subtitle_writer: for start, end, text in new_entries: @@ -1003,29 +994,37 @@ class ConversionThread(QThread): chapter_tokens_with_timestamps, new_chapter_entries, self.max_subtitle_words, - fallback_end_time=chapter_current_time + seg.duration, + fallback_end_time=chapter_current_time + info.duration, ) if chapter_subtitle_writer: for start, end, text in new_chapter_entries: chapter_subtitle_writer.write_entry(start, end, text) - - # Update timing - if merge_chapters_at_end: - current_time += seg.duration if chapter_sink: - chapter_current_time += seg.duration + chapter_current_time += info.duration - # Progress - percent = min( - int(self.processed_char_count / self.total_char_count * 100), - 99, - ) - etr_str = calc_etr_str( - time.time() - self.etr_start_time, - self.processed_char_count, - self.total_char_count, - ) - self.progress_updated.emit(percent, etr_str) + stats = SegmentStats( + processed_chars=self.processed_char_count, + current_time=current_time, + etr_start_time=self.etr_start_time, + 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, + ) + + self.processed_char_count = stats.processed_chars + current_time = stats.current_time # Add silence between chapters for merged output (except after the last chapter) if merge_chapters_at_end and chapter_idx < total_chapters: diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index e6fc1d5..6b3e2ac 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -106,7 +106,7 @@ from abogen.domain.output_paths import ( from abogen.domain.device import select_device as _select_device from abogen.domain.split_pattern import get_split_pattern from abogen.domain.progress import ProgressTracker, calc_etr_str -from abogen.domain.subtitle_generation import process_subtitle_tokens + from abogen.domain.audio_helpers import ( build_ffmpeg_command as _build_ffmpeg_command, to_float32 as _to_float32, @@ -118,7 +118,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_pipeline import tts_segments +from abogen.domain.conversion_engine import run_tts_segment_loop, process_and_write_subtitles, SegmentStats from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target @@ -434,59 +434,56 @@ def run_conversion_job(job: Job) -> None: effective_speed = float(speed_override if speed_override is not None else job.speed) try: - accumulated_tokens: List[dict] = [] + stats = SegmentStats( + processed_chars=processed_chars, + current_time=current_time, + etr_start_time=etr_start_time, + total_characters=job.total_characters or 0, + ) + prefix = f"{preview_prefix} · " if preview_prefix else "" - for seg in tts_segments( - normalized, + def _on_progress(pct: int, etr: str) -> None: + nonlocal processed_chars + processed_chars = stats.processed_chars + job.processed_characters = processed_chars + if stats.total_characters: + job.progress = min(processed_chars / stats.total_characters, 0.999) + else: + job.progress = 0.0 if processed_chars == 0 else 0.999 + job.etr_str = etr + + 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, backend=backend, voice=resolved_voice, speed=effective_speed, split_pattern=split_pattern, - current_time=current_time, - ): - canceller() - local_segments += 1 - processed_chars += len(seg.graphemes) - job.processed_characters = processed_chars - if job.total_characters: - job.progress = min(processed_chars / job.total_characters, 0.999) - job.etr_str = calc_etr_str( - time.time() - etr_start_time, - processed_chars, - job.total_characters, - ) - else: - job.progress = 0.0 if processed_chars == 0 else 0.999 - - preview_text = seg.graphemes or "[silence]" - prefix = f"{preview_prefix} · " if preview_prefix else "" - job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}") - - if chapter_sink: - chapter_sink.write(seg.audio) - if audio_sink: - audio_sink.write(seg.audio) - - if subtitle_writer and audio_sink: - accumulated_tokens.extend(seg.tokens) - - if audio_sink: - current_time += seg.duration + 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"), + ) + current_time = stats.current_time if subtitle_writer and audio_sink and accumulated_tokens: - _use_spacy = job.subtitle_mode not in ("Disabled", "Line") - new_entries: List[tuple] = [] - process_subtitle_tokens( + process_and_write_subtitles( accumulated_tokens, - new_entries, - job.max_subtitle_words, - job.subtitle_mode, - job.language, - use_spacy_segmentation=_use_spacy, + subtitle_writer, + subtitle_mode=job.subtitle_mode, + max_subtitle_words=job.max_subtitle_words, + lang_code=job.language, + use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"), fallback_end_time=current_time, ) - for start, end, text in new_entries: - subtitle_writer.write_entry(start=start, end=end, text=text) except OverflowError as exc: job.add_log( diff --git a/abogen/webui/routes/utils/settings.py b/abogen/webui/routes/utils/settings.py index 247938f..d022a5a 100644 --- a/abogen/webui/routes/utils/settings.py +++ b/abogen/webui/routes/utils/settings.py @@ -8,8 +8,16 @@ from abogen.domain.settings_core import ( CHUNK_LEVEL_OPTIONS, CHUNK_LEVEL_VALUES, DEFAULT_ANALYSIS_THRESHOLD, + SAVE_MODE_LABELS, + _NORMALIZATION_BOOLEAN_KEYS, + _NORMALIZATION_STRING_KEYS, coerce_bool, + coerce_float, + coerce_int, integration_defaults, + load_settings, + llm_ready, + settings_defaults, ) _NORMALIZATION_GROUPS = [