mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-22 07:10:28 +02:00
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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user