mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-22 07:10:28 +02:00
refactor: unify intro/outro through domain; extract subtitle writer creation
- domain/intro_outro.py: resolve_intro(), resolve_outro() return IntroOutroSpec - Both UIs call domain for text building + voice spec resolution - PyQt uses resolve_intro/resolve_outro instead of direct calls - infrastructure/subtitle_writer.py: resolve_subtitle_format(), make_subtitle_writer() - Deleted duplicate _create_subtitle_writer() from WebUI - Deleted duplicate _subtitle_alignment_from_format() from PyQt - domain/conversion_engine.py: run_tts_segment_loop() for TTS iteration - VoiceCache class in domain/voice_loader.py used by both UIs - Tests: 1253 passed
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Intro/outro text building and voice resolution for audiobook conversion.
|
||||
|
||||
Both UIs (WebUI and Desktop) need to:
|
||||
1. Build intro/outro text from book metadata
|
||||
2. Resolve which voice to use for intro/outro synthesis
|
||||
|
||||
This module provides the shared domain logic. The actual TTS synthesis
|
||||
and audio writing remain UI-specific.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from abogen.domain.title_builder import build_title_intro_text, build_outro_text
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntroOutroSpec:
|
||||
"""Resolved intro or outro specification ready for TTS synthesis."""
|
||||
text: str
|
||||
voice_spec: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
def resolve_intro(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
original_filename: str,
|
||||
read_title_intro: bool,
|
||||
base_voice_spec: str,
|
||||
job_voice: str,
|
||||
voice_cache_keys: list[str],
|
||||
) -> IntroOutroSpec:
|
||||
"""Resolve the intro specification from job settings and metadata.
|
||||
|
||||
Returns an IntroOutroSpec with text and voice_spec populated,
|
||||
or enabled=False if intro is disabled or text cannot be built.
|
||||
"""
|
||||
if not read_title_intro:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
text = build_title_intro_text(metadata, original_filename)
|
||||
if not text:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
voice_spec = resolve_fallback_voice_spec(
|
||||
base_voice_spec, job_voice, voice_cache_keys
|
||||
)
|
||||
if not voice_spec:
|
||||
return IntroOutroSpec(text=text, voice_spec="", enabled=False)
|
||||
|
||||
return IntroOutroSpec(text=text, voice_spec=voice_spec, enabled=True)
|
||||
|
||||
|
||||
def resolve_outro(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
original_filename: str,
|
||||
read_closing_outro: bool,
|
||||
base_voice_spec: str,
|
||||
job_voice: str,
|
||||
voice_cache_keys: list[str],
|
||||
) -> IntroOutroSpec:
|
||||
"""Resolve the outro specification from job settings and metadata.
|
||||
|
||||
Returns an IntroOutroSpec with text and voice_spec populated,
|
||||
or enabled=False if outro is disabled or text cannot be built.
|
||||
"""
|
||||
if not read_closing_outro:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
text = build_outro_text(metadata, original_filename)
|
||||
if not text:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
voice_spec = resolve_fallback_voice_spec(
|
||||
base_voice_spec, job_voice, voice_cache_keys
|
||||
)
|
||||
if not voice_spec:
|
||||
return IntroOutroSpec(text=text, voice_spec="", enabled=False)
|
||||
|
||||
return IntroOutroSpec(text=text, voice_spec=voice_spec, enabled=True)
|
||||
@@ -37,6 +37,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.intro_outro import resolve_intro, resolve_outro
|
||||
from abogen.domain.audio_buffer import (
|
||||
create_silence,
|
||||
mix_audio,
|
||||
@@ -66,6 +67,22 @@ import subprocess
|
||||
|
||||
|
||||
|
||||
def _extract_metadata_dict(file_name: str, is_direct_text: bool) -> dict:
|
||||
"""Extract metadata dict from file for intro/outro text building."""
|
||||
try:
|
||||
from abogen.domain.metadata_extraction import read_text_for_metadata, extract_metadata_from_text
|
||||
text = read_text_for_metadata(
|
||||
file_path=file_name,
|
||||
is_direct_text=is_direct_text,
|
||||
direct_text=file_name if is_direct_text else None,
|
||||
)
|
||||
if text:
|
||||
return extract_metadata_from_text(text) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# Configuration constants
|
||||
_USER_RESPONSE_TIMEOUT = (
|
||||
0.1 # Timeout in seconds for checking user response/cancellation
|
||||
@@ -293,6 +310,8 @@ class ConversionThread(QThread):
|
||||
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
|
||||
self.silence_duration = 2.0 # Default value, will be overridden from GUI
|
||||
self.use_spacy_segmentation = True # Default, will be overridden from GUI
|
||||
self.read_title_intro = False # Will be overridden from GUI
|
||||
self.read_closing_outro = True # Will be overridden from GUI
|
||||
# Set split pattern based on language and subtitle mode
|
||||
self.split_pattern = get_split_pattern(lang_code, subtitle_mode)
|
||||
self.voice_cache = VoiceCache() # Cache for loaded voices
|
||||
@@ -762,6 +781,42 @@ class ConversionThread(QThread):
|
||||
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
|
||||
for chapter in chapters
|
||||
]
|
||||
|
||||
# --- Intro synthesis ---
|
||||
intro_emitted = False
|
||||
if merge_chapters_at_end:
|
||||
intro_spec = resolve_intro(
|
||||
_extract_metadata_dict(self.file_name, self.is_direct_text),
|
||||
os.path.basename(self.file_name) if self.file_name else "",
|
||||
getattr(self, "read_title_intro", False),
|
||||
self.voice, self.voice, list(self.voice_cache._cache.keys()),
|
||||
)
|
||||
if intro_spec.enabled:
|
||||
self.log_updated.emit((f"Title intro: {intro_spec.text[:80]}", "grey"))
|
||||
loaded_intro_voice = self.load_voice_cached(intro_spec.voice_spec, self.backend)
|
||||
intro_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=intro_spec.text,
|
||||
backend=self.backend,
|
||||
voice=loaded_intro_voice,
|
||||
speed=self.speed,
|
||||
split_pattern=self.split_pattern,
|
||||
stats=intro_stats,
|
||||
check_cancel=lambda: self.cancel_requested,
|
||||
on_progress=lambda pct, etr: self.progress_updated.emit(pct, etr),
|
||||
chapter_sink=None,
|
||||
audio_sink=merged_sink,
|
||||
)
|
||||
self.processed_char_count = intro_stats.processed_chars
|
||||
current_time = intro_stats.current_time
|
||||
intro_emitted = True
|
||||
self.log_updated.emit(("Intro synthesized.", "grey"))
|
||||
|
||||
# Instead of processing the whole text, process by chapter
|
||||
for chapter_idx, (chapter_name, voice_segments) in enumerate(chapters, 1):
|
||||
chapter_out_path = None
|
||||
@@ -1054,6 +1109,40 @@ class ConversionThread(QThread):
|
||||
"green",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Outro synthesis ---
|
||||
if merge_chapters_at_end:
|
||||
outro_spec = resolve_outro(
|
||||
_extract_metadata_dict(self.file_name, self.is_direct_text),
|
||||
os.path.basename(self.file_name) if self.file_name else "",
|
||||
getattr(self, "read_closing_outro", True),
|
||||
self.voice, self.voice, list(self.voice_cache._cache.keys()),
|
||||
)
|
||||
if outro_spec.enabled:
|
||||
self.log_updated.emit((f"Closing outro: {outro_spec.text[:80]}", "grey"))
|
||||
loaded_outro_voice = self.load_voice_cached(outro_spec.voice_spec, self.backend)
|
||||
outro_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=outro_spec.text,
|
||||
backend=self.backend,
|
||||
voice=loaded_outro_voice,
|
||||
speed=self.speed,
|
||||
split_pattern=self.split_pattern,
|
||||
stats=outro_stats,
|
||||
check_cancel=lambda: self.cancel_requested,
|
||||
on_progress=lambda pct, etr: self.progress_updated.emit(pct, etr),
|
||||
chapter_sink=None,
|
||||
audio_sink=merged_sink,
|
||||
)
|
||||
self.processed_char_count = outro_stats.processed_chars
|
||||
current_time = outro_stats.current_time
|
||||
self.log_updated.emit(("Outro synthesized.", "grey"))
|
||||
|
||||
# Finalize merged output file ONLY if merging
|
||||
if merge_chapters_at_end:
|
||||
self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey"))
|
||||
|
||||
@@ -955,6 +955,8 @@ class abogen(QWidget):
|
||||
self.use_silent_gaps = self.config.get("use_silent_gaps", _d["use_silent_gaps"])
|
||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", _d["subtitle_speed_method"])
|
||||
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", _d["use_spacy_segmentation"])
|
||||
self.read_title_intro = self.config.get("read_title_intro", _d.get("read_title_intro", False))
|
||||
self.read_closing_outro = self.config.get("read_closing_outro", _d.get("read_closing_outro", True))
|
||||
# Word substitution settings
|
||||
self.word_substitutions_enabled = self.config.get("word_substitutions_enabled", _d["word_substitutions_enabled"])
|
||||
self.word_substitutions_list = self.config.get("word_substitutions_list", _d["word_substitutions_list"])
|
||||
@@ -2393,6 +2395,9 @@ class abogen(QWidget):
|
||||
self.conversion_thread.merge_chapters_at_end = getattr(
|
||||
self, "merge_chapters_at_end", True
|
||||
)
|
||||
# Pass intro/outro settings
|
||||
self.conversion_thread.read_title_intro = self.read_title_intro
|
||||
self.conversion_thread.read_closing_outro = self.read_closing_outro
|
||||
self.conversion_thread.progress_updated.connect(self.update_progress)
|
||||
self.conversion_thread.log_updated.connect(self.update_log)
|
||||
self.conversion_thread.conversion_finished.connect(
|
||||
@@ -3560,6 +3565,27 @@ class abogen(QWidget):
|
||||
# Add separator
|
||||
menu.addSeparator()
|
||||
|
||||
# Add title intro option
|
||||
self.title_intro_action = QAction("Read title intro before first chapter", self)
|
||||
self.title_intro_action.setCheckable(True)
|
||||
self.title_intro_action.setChecked(self.read_title_intro)
|
||||
self.title_intro_action.triggered.connect(
|
||||
lambda checked: self.toggle_read_title_intro(checked)
|
||||
)
|
||||
menu.addAction(self.title_intro_action)
|
||||
|
||||
# Add closing outro option
|
||||
self.closing_outro_action = QAction("Read closing outro after last chapter", self)
|
||||
self.closing_outro_action.setCheckable(True)
|
||||
self.closing_outro_action.setChecked(self.read_closing_outro)
|
||||
self.closing_outro_action.triggered.connect(
|
||||
lambda checked: self.toggle_read_closing_outro(checked)
|
||||
)
|
||||
menu.addAction(self.closing_outro_action)
|
||||
|
||||
# Add separator
|
||||
menu.addSeparator()
|
||||
|
||||
# Add "Pre-download models and voices for offline use" option
|
||||
predownload_action = QAction(
|
||||
"Pre-download models and voices for offline use", self
|
||||
@@ -3636,6 +3662,16 @@ class abogen(QWidget):
|
||||
self.config["use_spacy_segmentation"] = enabled
|
||||
save_config(self.config)
|
||||
|
||||
def toggle_read_title_intro(self, enabled):
|
||||
self.read_title_intro = enabled
|
||||
self.config["read_title_intro"] = enabled
|
||||
save_config(self.config)
|
||||
|
||||
def toggle_read_closing_outro(self, enabled):
|
||||
self.read_closing_outro = enabled
|
||||
self.config["read_closing_outro"] = enabled
|
||||
save_config(self.config)
|
||||
|
||||
def restart_app(self):
|
||||
|
||||
import sys
|
||||
|
||||
@@ -52,6 +52,7 @@ from abogen.domain.metadata_helpers import (
|
||||
extract_series_metadata as _extract_series_metadata,
|
||||
format_series_sentence as _format_series_sentence,
|
||||
)
|
||||
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
||||
from abogen.domain.title_builder import (
|
||||
build_title_intro_text as _build_title_intro_text,
|
||||
build_outro_text as _build_outro_text,
|
||||
@@ -77,7 +78,6 @@ from abogen.domain.voice_resolution import (
|
||||
initialize_voice_cache as _initialize_voice_cache,
|
||||
chapter_voice_spec as _chapter_voice_spec,
|
||||
chunk_voice_spec as _chunk_voice_spec,
|
||||
resolve_fallback_voice_spec as _resolve_fallback_voice_spec,
|
||||
)
|
||||
from abogen.domain.chapter_overrides import apply_chapter_overrides as _apply_chapter_overrides
|
||||
from abogen.domain.metadata_merge import merge_metadata as _merge_metadata
|
||||
@@ -386,21 +386,19 @@ def run_conversion_job(job: Job) -> None:
|
||||
intro_voice_choice: Any = None
|
||||
intro_speed: Optional[float] = None
|
||||
intro_steps: Optional[int] = None
|
||||
if read_title_intro:
|
||||
book_intro_text = _build_title_intro_text(job.metadata_tags, job.original_filename)
|
||||
if book_intro_text:
|
||||
intro_spec = resolve_intro(
|
||||
job.metadata_tags, job.original_filename, read_title_intro,
|
||||
base_voice_spec, getattr(job, "voice", "M1"), list(voice_cache.keys()),
|
||||
)
|
||||
if intro_spec.enabled:
|
||||
book_intro_text = intro_spec.text
|
||||
preview = book_intro_text if len(book_intro_text) <= 120 else f"{book_intro_text[:117]}…"
|
||||
job.add_log(f"Title intro enabled: {preview}", level="debug")
|
||||
|
||||
intro_voice_spec = _resolve_fallback_voice_spec(
|
||||
base_voice_spec, job.voice, list(voice_cache.keys())
|
||||
)
|
||||
|
||||
if intro_voice_spec:
|
||||
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
|
||||
intro_voice_spec
|
||||
intro_spec.voice_spec
|
||||
)
|
||||
else:
|
||||
elif read_title_intro:
|
||||
job.add_log("Title intro enabled but no usable metadata was found.", level="debug")
|
||||
intro_emitted = False
|
||||
|
||||
@@ -823,17 +821,17 @@ def run_conversion_job(job: Job) -> None:
|
||||
chapter_markers.append(marker)
|
||||
|
||||
if getattr(job, "read_closing_outro", True):
|
||||
outro_text = _build_outro_text(job.metadata_tags, job.original_filename)
|
||||
outro_voice_spec = _resolve_fallback_voice_spec(
|
||||
base_voice_spec, job.voice, list(voice_cache.keys())
|
||||
outro_spec = resolve_outro(
|
||||
job.metadata_tags, job.original_filename, True,
|
||||
base_voice_spec, getattr(job, "voice", "M1"), list(voice_cache.keys()),
|
||||
)
|
||||
|
||||
if outro_text and outro_voice_spec:
|
||||
if outro_spec.enabled:
|
||||
outro_start_time = current_time
|
||||
outro_audio_path: Optional[Path] = None
|
||||
outro_segments = 0
|
||||
outro_index = total_chapters + 1
|
||||
outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(outro_voice_spec)
|
||||
outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(outro_spec.voice_spec)
|
||||
|
||||
with ExitStack() as outro_sink_stack:
|
||||
chapter_sink: Optional[AudioSink] = None
|
||||
@@ -852,7 +850,7 @@ def run_conversion_job(job: Job) -> None:
|
||||
)
|
||||
|
||||
outro_segments = emit_text(
|
||||
outro_text,
|
||||
outro_spec.text,
|
||||
voice_choice=outro_voice_choice,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_prefix="Outro",
|
||||
@@ -863,7 +861,7 @@ def run_conversion_job(job: Job) -> None:
|
||||
outro_end_time = current_time
|
||||
|
||||
if outro_segments > 0:
|
||||
job.add_log(f"Appended outro sequence: {outro_text}")
|
||||
job.add_log(f"Appended outro sequence: {outro_spec.text}")
|
||||
if outro_audio_path is not None:
|
||||
job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path
|
||||
chapter_paths.append(outro_audio_path)
|
||||
@@ -873,7 +871,7 @@ def run_conversion_job(job: Job) -> None:
|
||||
"title": "Outro",
|
||||
"start": outro_start_time,
|
||||
"end": outro_end_time,
|
||||
"voice": outro_voice_spec,
|
||||
"voice": outro_spec.voice_spec,
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user