extract conversion_engine: shared TTS segment iteration loop for WebUI and PyQt

- domain/conversion_engine.py: run_tts_segment_loop() with CancelChecker,
  SegmentStats, SegmentInfo protocols; on_segment callback for per-segment
  subtitle processing; process_and_write_subtitles() helper
- conversion_runner.py: emit_text() delegates TTS iteration to engine
- pyqt/conversion.py: inner tts_segments loop replaced with engine call,
  on_segment handles dual merged+chapter subtitle writers
- routes/utils/settings.py: re-exports load_settings, coerce_int/float,
  llm_ready, settings_defaults from domain for backward compat
This commit is contained in:
Artem Akymenko
2026-07-20 07:35:58 +00:00
parent 476063bc3d
commit 5d30903149
4 changed files with 283 additions and 89 deletions
+190
View File
@@ -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)
+43 -44
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_pipeline import tts_segments from abogen.domain.conversion_engine import run_tts_segment_loop, SegmentStats, SegmentInfo
from abogen.domain.audio_buffer import ( from abogen.domain.audio_buffer import (
create_silence, create_silence,
mix_audio, mix_audio,
@@ -950,41 +950,32 @@ class ConversionThread(QThread):
(f"Warning: Text normalization failed: {exc}", "orange") (f"Warning: Text normalization failed: {exc}", "orange")
) )
for seg in tts_segments( def _qt_check_cancel() -> bool:
text_segment,
backend=self.backend,
voice=loaded_voice,
speed=self.speed,
split_pattern=active_split_pattern,
current_time=current_time,
):
if self.cancel_requested: if self.cancel_requested:
sink_stack.close() sink_stack.close()
self.conversion_finished.emit("Cancelled", None) self.conversion_finished.emit("Cancelled", None)
return return True
current_segment += 1 return False
self.processed_char_count += len(seg.graphemes)
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( 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}"
) )
if self.subtitle_mode != "Disabled" and info.tokens:
# Write audio tokens_with_timestamps = list(info.tokens)
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)
chapter_tokens_with_timestamps = [ chapter_tokens_with_timestamps = [
{ {
"start": chapter_current_time + (t["start"] - seg.chunk_start), "start": chapter_current_time + (t["start"] - info.chunk_start),
"end": chapter_current_time + (t["end"] - seg.chunk_start), "end": chapter_current_time + (t["end"] - info.chunk_start),
"text": t["text"], "text": t["text"],
"whitespace": t["whitespace"], "whitespace": t["whitespace"],
} }
for t in seg.tokens for t in info.tokens
] ]
if merge_chapters_at_end: if merge_chapters_at_end:
new_entries = [] new_entries = []
@@ -992,7 +983,7 @@ class ConversionThread(QThread):
tokens_with_timestamps, tokens_with_timestamps,
new_entries, new_entries,
self.max_subtitle_words, self.max_subtitle_words,
fallback_end_time=seg.chunk_start + seg.duration, fallback_end_time=info.chunk_start + info.duration,
) )
if merged_subtitle_writer: if merged_subtitle_writer:
for start, end, text in new_entries: for start, end, text in new_entries:
@@ -1003,29 +994,37 @@ class ConversionThread(QThread):
chapter_tokens_with_timestamps, chapter_tokens_with_timestamps,
new_chapter_entries, new_chapter_entries,
self.max_subtitle_words, self.max_subtitle_words,
fallback_end_time=chapter_current_time + seg.duration, fallback_end_time=chapter_current_time + info.duration,
) )
if chapter_subtitle_writer: if chapter_subtitle_writer:
for start, end, text in new_chapter_entries: for start, end, text in new_chapter_entries:
chapter_subtitle_writer.write_entry(start, end, text) chapter_subtitle_writer.write_entry(start, end, text)
# Update timing
if merge_chapters_at_end:
current_time += seg.duration
if chapter_sink: if chapter_sink:
chapter_current_time += seg.duration chapter_current_time += info.duration
# Progress stats = SegmentStats(
percent = min( processed_chars=self.processed_char_count,
int(self.processed_char_count / self.total_char_count * 100), current_time=current_time,
99, etr_start_time=self.etr_start_time,
) total_characters=self.total_char_count,
etr_str = calc_etr_str( )
time.time() - self.etr_start_time,
self.processed_char_count, run_tts_segment_loop(
self.total_char_count, text=text_segment,
) backend=self.backend,
self.progress_updated.emit(percent, etr_str) 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) # Add silence between chapters for merged output (except after the last chapter)
if merge_chapters_at_end and chapter_idx < total_chapters: if merge_chapters_at_end and chapter_idx < total_chapters:
+42 -45
View File
@@ -106,7 +106,7 @@ from abogen.domain.output_paths import (
from abogen.domain.device import select_device as _select_device from abogen.domain.device import select_device as _select_device
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.progress import ProgressTracker, calc_etr_str from abogen.domain.progress import ProgressTracker, calc_etr_str
from abogen.domain.subtitle_generation import process_subtitle_tokens
from abogen.domain.audio_helpers import ( from abogen.domain.audio_helpers import (
build_ffmpeg_command as _build_ffmpeg_command, build_ffmpeg_command as _build_ffmpeg_command,
to_float32 as _to_float32, 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.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_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 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) effective_speed = float(speed_override if speed_override is not None else job.speed)
try: 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( def _on_progress(pct: int, etr: str) -> None:
normalized, 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, backend=backend,
voice=resolved_voice, voice=resolved_voice,
speed=effective_speed, speed=effective_speed,
split_pattern=split_pattern, split_pattern=split_pattern,
current_time=current_time, stats=stats,
): check_cancel=canceller,
canceller() on_progress=_on_progress,
local_segments += 1 chapter_sink=chapter_sink,
processed_chars += len(seg.graphemes) audio_sink=audio_sink,
job.processed_characters = processed_chars preview_callback=_preview,
if job.total_characters: subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled",
job.progress = min(processed_chars / job.total_characters, 0.999) max_subtitle_words=job.max_subtitle_words,
job.etr_str = calc_etr_str( lang_code=job.language,
time.time() - etr_start_time, use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"),
processed_chars, )
job.total_characters, current_time = stats.current_time
)
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
if subtitle_writer and audio_sink and accumulated_tokens: if subtitle_writer and audio_sink and accumulated_tokens:
_use_spacy = job.subtitle_mode not in ("Disabled", "Line") process_and_write_subtitles(
new_entries: List[tuple] = []
process_subtitle_tokens(
accumulated_tokens, accumulated_tokens,
new_entries, subtitle_writer,
job.max_subtitle_words, subtitle_mode=job.subtitle_mode,
job.subtitle_mode, max_subtitle_words=job.max_subtitle_words,
job.language, lang_code=job.language,
use_spacy_segmentation=_use_spacy, use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"),
fallback_end_time=current_time, 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: except OverflowError as exc:
job.add_log( job.add_log(
+8
View File
@@ -8,8 +8,16 @@ from abogen.domain.settings_core import (
CHUNK_LEVEL_OPTIONS, CHUNK_LEVEL_OPTIONS,
CHUNK_LEVEL_VALUES, CHUNK_LEVEL_VALUES,
DEFAULT_ANALYSIS_THRESHOLD, DEFAULT_ANALYSIS_THRESHOLD,
SAVE_MODE_LABELS,
_NORMALIZATION_BOOLEAN_KEYS,
_NORMALIZATION_STRING_KEYS,
coerce_bool, coerce_bool,
coerce_float,
coerce_int,
integration_defaults, integration_defaults,
load_settings,
llm_ready,
settings_defaults,
) )
_NORMALIZATION_GROUPS = [ _NORMALIZATION_GROUPS = [