mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
- executor: remove dead subtitle_writer stub (lines 121-125) - executor: replace getattr with direct field access on ResolvedVoice - service: remove duplicate split_pattern import - service: remove unused import time - planner: remove unused import os - adapters: remove unused import threading, time
465 lines
18 KiB
Python
465 lines
18 KiB
Python
"""Unified conversion executor.
|
|
|
|
Takes a ConversionPlan and ports, executes the TTS conversion,
|
|
and returns a ConversionResult. No UI imports allowed.
|
|
|
|
This is Stage 6 of the conversion flow unification plan.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from contextlib import ExitStack
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from abogen.application.conversion_models import (
|
|
ChapterPlan,
|
|
ConversionPlan,
|
|
IntroOutroSpec,
|
|
SegmentPlan,
|
|
)
|
|
from abogen.application.conversion_ports import (
|
|
AudioSink,
|
|
ConversionEvents,
|
|
PipelineProvider,
|
|
SubtitleWriter,
|
|
VoiceResolver,
|
|
)
|
|
from abogen.application.conversion_result import ConversionResult
|
|
from abogen.domain.audio_sink import open_audio_sink
|
|
from abogen.domain.conversion_engine import (
|
|
SegmentStats,
|
|
process_and_write_subtitles,
|
|
synthesize_text,
|
|
)
|
|
from abogen.domain.normalization import TTSContext
|
|
from abogen.infrastructure.subtitle_writer import make_subtitle_writer
|
|
|
|
|
|
def execute_conversion(
|
|
plan: ConversionPlan,
|
|
events: ConversionEvents,
|
|
pipeline_provider: PipelineProvider,
|
|
voice_resolver: VoiceResolver,
|
|
tts_context: TTSContext,
|
|
*,
|
|
check_cancelled: Optional[Callable[[], None]] = None,
|
|
) -> ConversionResult:
|
|
"""Execute a conversion plan and return the result.
|
|
|
|
Args:
|
|
plan: The conversion plan from build_conversion_plan()
|
|
events: UI-specific callbacks (log, progress, check_cancelled)
|
|
pipeline_provider: Provides TTS backends
|
|
voice_resolver: Resolves voice specs into loaded voices
|
|
tts_context: Normalization context for text processing
|
|
check_cancelled: Optional cancellation checker (overrides events.check_cancelled)
|
|
|
|
Returns:
|
|
ConversionResult with paths and markers
|
|
|
|
Raises:
|
|
ConversionCancelled: If conversion is cancelled
|
|
"""
|
|
request = plan.request
|
|
result = ConversionResult(metadata=plan.metadata)
|
|
|
|
# Determine cancellation checker
|
|
if check_cancelled is None:
|
|
check_cancelled = lambda: events.check_cancelled()
|
|
|
|
# Stats for progress tracking
|
|
total_characters = sum(
|
|
len(ch.body_text) for ch in plan.chapters
|
|
)
|
|
if plan.intro and plan.intro.enabled:
|
|
total_characters += len(plan.intro.text)
|
|
if plan.outro and plan.outro.enabled:
|
|
total_characters += len(plan.outro.text)
|
|
|
|
stats = SegmentStats(
|
|
processed_chars=0,
|
|
current_time=0.0,
|
|
etr_start_time=time.time(),
|
|
total_characters=total_characters,
|
|
)
|
|
|
|
# Output paths
|
|
output_layout = plan.output_layout
|
|
if not output_layout:
|
|
raise ValueError("ConversionPlan must have an output_layout")
|
|
|
|
# Determine if merged output is needed
|
|
merge_chapters = request.merge_chapters_at_end or not request.save_chapters_separately
|
|
if request.output_format.lower() == "m4b":
|
|
merge_chapters = True
|
|
|
|
# Resolve voices
|
|
base_voice_spec = request.voice or "M1"
|
|
base_provider, base_voice_choice, base_speed, base_steps = _resolve_voice(
|
|
voice_resolver, base_voice_spec, request
|
|
)
|
|
|
|
# Use ExitStack for resource management
|
|
with ExitStack() as stack:
|
|
# Open merged audio sink
|
|
audio_sink: Optional[AudioSink] = None
|
|
audio_path = None
|
|
if merge_chapters:
|
|
audio_path = output_layout.audio_dir / f"{_base_name(request)}.{request.output_format}"
|
|
meta = plan.metadata if plan.metadata else None
|
|
audio_sink = stack.enter_context(
|
|
open_audio_sink(
|
|
audio_path,
|
|
request.output_format,
|
|
metadata=meta,
|
|
cancel_check=check_cancelled,
|
|
)
|
|
)
|
|
result.audio_path = audio_path
|
|
|
|
# Open subtitle writer if needed
|
|
subtitle_writer: Optional[SubtitleWriter] = None
|
|
if request.subtitle_mode != "Disabled" and audio_sink:
|
|
subtitle_writer = make_subtitle_writer(
|
|
audio_path,
|
|
request.subtitle_format,
|
|
request.subtitle_mode,
|
|
max_words=request.max_subtitle_words,
|
|
)
|
|
if subtitle_writer:
|
|
subtitle_writer.open()
|
|
stack.callback(subtitle_writer.close)
|
|
result.subtitle_paths.append(subtitle_writer.path)
|
|
|
|
# Chapter directory
|
|
chapter_dir = None
|
|
if request.save_chapters_separately and len(plan.chapters) > 1:
|
|
chapter_dir = output_layout.audio_dir / "chapters"
|
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Process intro
|
|
intro_emitted = False
|
|
if plan.intro and plan.intro.enabled and merge_chapters:
|
|
events.log(f"Title intro: {plan.intro.text[:80]}")
|
|
intro_provider, intro_voice, intro_speed, intro_steps = _resolve_voice(
|
|
voice_resolver, plan.intro.voice_spec, request
|
|
)
|
|
intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu)
|
|
synthesize_text(
|
|
text=plan.intro.text,
|
|
tts_context=tts_context,
|
|
backend=intro_backend,
|
|
voice=intro_voice,
|
|
speed=intro_speed or request.speed,
|
|
stats=stats,
|
|
check_cancel=check_cancelled,
|
|
on_progress=lambda pct, etr: events.progress(pct, etr),
|
|
chapter_sink=None,
|
|
audio_sink=audio_sink,
|
|
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
|
subtitle_mode=request.subtitle_mode if subtitle_writer else "Disabled",
|
|
max_subtitle_words=request.max_subtitle_words,
|
|
lang_code=request.language,
|
|
use_spacy_segmentation=request.subtitle_mode not in ("Disabled", "Line"),
|
|
)
|
|
intro_emitted = True
|
|
events.log("Intro synthesized.")
|
|
|
|
# Chapter loop
|
|
for chapter_idx, chapter in enumerate(plan.chapters, 1):
|
|
check_cancelled()
|
|
|
|
chapter_display = f"Chapter {chapter_idx}/{len(plan.chapters)}: {chapter.title}"
|
|
events.log(f"Processing {chapter_display}")
|
|
|
|
# Resolve chapter voice
|
|
chapter_provider, chapter_voice, chapter_speed, chapter_steps = _resolve_voice(
|
|
voice_resolver, chapter.voice_spec, request
|
|
)
|
|
chapter_backend = pipeline_provider.get(chapter_provider, request.language, request.use_gpu)
|
|
|
|
# Per-chapter sink
|
|
chapter_sink: Optional[AudioSink] = None
|
|
chapter_path = None
|
|
if chapter_dir:
|
|
chapter_filename = _slugify(chapter.title, chapter_idx)
|
|
chapter_path = chapter_dir / f"{chapter_filename}.{request.separate_chapters_format}"
|
|
chapter_sink = stack.enter_context(
|
|
open_audio_sink(
|
|
chapter_path,
|
|
request.separate_chapters_format,
|
|
cancel_check=check_cancelled,
|
|
)
|
|
)
|
|
result.chapter_paths.append(chapter_path)
|
|
|
|
# Intro delay before first chapter
|
|
if not intro_emitted and plan.intro and plan.intro.enabled:
|
|
# Intro will be emitted with first chapter
|
|
intro_provider, intro_voice, intro_speed, intro_steps = _resolve_voice(
|
|
voice_resolver, plan.intro.voice_spec, request
|
|
)
|
|
intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu)
|
|
synthesize_text(
|
|
text=plan.intro.text,
|
|
tts_context=tts_context,
|
|
backend=intro_backend,
|
|
voice=intro_voice,
|
|
speed=intro_speed or request.speed,
|
|
stats=stats,
|
|
check_cancel=check_cancelled,
|
|
on_progress=lambda pct, etr: events.progress(pct, etr),
|
|
chapter_sink=chapter_sink,
|
|
audio_sink=audio_sink,
|
|
preview_callback=lambda text: events.log(f" Intro: {text[:80]}"),
|
|
subtitle_mode=request.subtitle_mode if subtitle_writer else "Disabled",
|
|
max_subtitle_words=request.max_subtitle_words,
|
|
lang_code=request.language,
|
|
use_spacy_segmentation=request.subtitle_mode not in ("Disabled", "Line"),
|
|
)
|
|
intro_emitted = True
|
|
if request.chapter_intro_delay > 0:
|
|
_append_silence(
|
|
request.chapter_intro_delay,
|
|
chapter_sink=chapter_sink,
|
|
audio_sink=audio_sink,
|
|
stats=stats,
|
|
)
|
|
|
|
# Process heading
|
|
if chapter.title:
|
|
heading_text = _format_heading(chapter.title, chapter_idx, request)
|
|
if heading_text:
|
|
synthesize_text(
|
|
text=heading_text,
|
|
tts_context=tts_context,
|
|
backend=chapter_backend,
|
|
voice=chapter_voice,
|
|
speed=chapter_speed or request.speed,
|
|
stats=stats,
|
|
check_cancel=check_cancelled,
|
|
on_progress=lambda pct, etr: events.progress(pct, etr),
|
|
chapter_sink=chapter_sink,
|
|
audio_sink=audio_sink,
|
|
preview_callback=lambda text: events.log(f" Title: {text[:80]}"),
|
|
subtitle_mode=request.subtitle_mode if subtitle_writer else "Disabled",
|
|
max_subtitle_words=request.max_subtitle_words,
|
|
lang_code=request.language,
|
|
use_spacy_segmentation=request.subtitle_mode not in ("Disabled", "Line"),
|
|
)
|
|
if request.chapter_intro_delay > 0:
|
|
_append_silence(
|
|
request.chapter_intro_delay,
|
|
chapter_sink=chapter_sink,
|
|
audio_sink=audio_sink,
|
|
stats=stats,
|
|
)
|
|
|
|
# Process body segments
|
|
chapter_chunk_markers: List[Dict[str, Any]] = []
|
|
for seg_idx, segment in enumerate(chapter.segments):
|
|
check_cancelled()
|
|
|
|
# Resolve segment voice (may differ from chapter voice)
|
|
if segment.voice_spec != chapter.voice_spec:
|
|
seg_provider, seg_voice, seg_speed, seg_steps = _resolve_voice(
|
|
voice_resolver, segment.voice_spec, request
|
|
)
|
|
seg_backend = pipeline_provider.get(seg_provider, request.language, request.use_gpu)
|
|
else:
|
|
seg_provider = chapter_provider
|
|
seg_voice = chapter_voice
|
|
seg_speed = chapter_speed
|
|
seg_backend = chapter_backend
|
|
|
|
seg_start_time = stats.current_time
|
|
local_segments, accumulated_tokens = synthesize_text(
|
|
text=segment.text,
|
|
tts_context=tts_context,
|
|
backend=seg_backend,
|
|
voice=seg_voice,
|
|
speed=seg_speed or request.speed,
|
|
stats=stats,
|
|
check_cancel=check_cancelled,
|
|
on_progress=lambda pct, etr: events.progress(pct, etr),
|
|
chapter_sink=chapter_sink,
|
|
audio_sink=audio_sink,
|
|
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
|
subtitle_mode=request.subtitle_mode if subtitle_writer else "Disabled",
|
|
max_subtitle_words=request.max_subtitle_words,
|
|
lang_code=request.language,
|
|
use_spacy_segmentation=request.subtitle_mode not in ("Disabled", "Line"),
|
|
)
|
|
|
|
# Process subtitles
|
|
if subtitle_writer and audio_sink and accumulated_tokens:
|
|
process_and_write_subtitles(
|
|
accumulated_tokens,
|
|
subtitle_writer,
|
|
subtitle_mode=request.subtitle_mode,
|
|
max_subtitle_words=request.max_subtitle_words,
|
|
lang_code=request.language,
|
|
use_spacy_segmentation=request.subtitle_mode not in ("Disabled", "Line"),
|
|
fallback_end_time=stats.current_time,
|
|
)
|
|
|
|
# Record chunk marker
|
|
if segment.source in ("chunk", "voice_marker"):
|
|
chapter_chunk_markers.append({
|
|
"id": segment.chunk_id,
|
|
"chapter_index": chapter_idx - 1,
|
|
"chunk_index": segment.chunk_index or seg_idx,
|
|
"start": seg_start_time,
|
|
"end": stats.current_time,
|
|
"speaker_id": segment.speaker_id,
|
|
"voice": segment.voice_spec,
|
|
"level": segment.level or request.chunk_level,
|
|
"characters": len(segment.text),
|
|
})
|
|
|
|
# Silence between chapters
|
|
if chapter_idx < len(plan.chapters) and request.silence_between_chapters > 0:
|
|
_append_silence(
|
|
request.silence_between_chapters,
|
|
chapter_sink=chapter_sink,
|
|
audio_sink=audio_sink,
|
|
stats=stats,
|
|
)
|
|
|
|
# Close chapter sink
|
|
if chapter_sink:
|
|
chapter_sink.close()
|
|
|
|
# Add chapter marker
|
|
result.chapter_markers.append({
|
|
"chapter_index": chapter_idx - 1,
|
|
"title": chapter.title,
|
|
"start": stats.current_time - (stats.current_time - seg_start_time) if chapter.segments else stats.current_time,
|
|
"end": stats.current_time,
|
|
})
|
|
|
|
result.chunk_markers.extend(chapter_chunk_markers)
|
|
|
|
# Process outro
|
|
if plan.outro and plan.outro.enabled and merge_chapters:
|
|
events.log(f"Closing outro: {plan.outro.text[:80]}")
|
|
outro_provider, outro_voice, outro_speed, outro_steps = _resolve_voice(
|
|
voice_resolver, plan.outro.voice_spec, request
|
|
)
|
|
outro_backend = pipeline_provider.get(outro_provider, request.language, request.use_gpu)
|
|
|
|
# Silence before outro
|
|
if request.silence_between_chapters > 0:
|
|
_append_silence(
|
|
request.silence_between_chapters,
|
|
chapter_sink=None,
|
|
audio_sink=audio_sink,
|
|
stats=stats,
|
|
)
|
|
|
|
synthesize_text(
|
|
text=plan.outro.text,
|
|
tts_context=tts_context,
|
|
backend=outro_backend,
|
|
voice=outro_voice,
|
|
speed=outro_speed or request.speed,
|
|
stats=stats,
|
|
check_cancel=check_cancelled,
|
|
on_progress=lambda pct, etr: events.progress(pct, etr),
|
|
chapter_sink=None,
|
|
audio_sink=audio_sink,
|
|
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
|
subtitle_mode=request.subtitle_mode if subtitle_writer else "Disabled",
|
|
max_subtitle_words=request.max_subtitle_words,
|
|
lang_code=request.language,
|
|
use_spacy_segmentation=request.subtitle_mode not in ("Disabled", "Line"),
|
|
)
|
|
events.log("Outro synthesized.")
|
|
|
|
# Set result metadata
|
|
result.total_chapters = len(plan.chapters)
|
|
result.total_segments = sum(len(ch.segments) for ch in plan.chapters)
|
|
result.total_characters = total_characters
|
|
|
|
if output_layout.project_root:
|
|
result.project_root = output_layout.project_root
|
|
|
|
return result
|
|
|
|
|
|
# ─── Helpers ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _resolve_voice(
|
|
resolver: VoiceResolver,
|
|
voice_spec: str,
|
|
request: Any,
|
|
) -> Tuple[str, Any, Optional[float], Optional[int]]:
|
|
"""Resolve a voice spec and return (provider, voice, speed, steps)."""
|
|
try:
|
|
resolved = resolver.resolve(voice_spec)
|
|
return (
|
|
resolved.provider,
|
|
resolved.voice,
|
|
resolved.speed,
|
|
resolved.supertonic_steps,
|
|
)
|
|
except Exception:
|
|
# Fallback to base voice
|
|
resolved = resolver.resolve(request.voice or "M1")
|
|
return (
|
|
resolved.provider,
|
|
resolved.voice,
|
|
resolved.speed,
|
|
resolved.supertonic_steps,
|
|
)
|
|
|
|
|
|
def _base_name(request: Any) -> str:
|
|
"""Get base name for output file."""
|
|
from abogen.domain.output_paths import sanitize_output_stem
|
|
|
|
if request.original_filename:
|
|
return sanitize_output_stem(request.original_filename)
|
|
return "output"
|
|
|
|
|
|
def _slugify(title: str, index: int) -> str:
|
|
"""Create a safe filename slug from chapter title."""
|
|
import re
|
|
slug = re.sub(r'[^\w\s-]', '', title.lower())
|
|
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
|
|
if not slug:
|
|
slug = f"chapter_{index}"
|
|
return f"{index:02d}_{slug}"
|
|
|
|
|
|
def _format_heading(title: str, index: int, request: Any) -> str:
|
|
"""Format chapter heading for TTS."""
|
|
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
|
|
|
if request.auto_prefix_chapter_titles:
|
|
return format_spoken_chapter_title(title, index, apply_prefix=True)
|
|
return title
|
|
|
|
|
|
def _append_silence(
|
|
duration: float,
|
|
*,
|
|
chapter_sink: Optional[AudioSink],
|
|
audio_sink: Optional[AudioSink],
|
|
stats: SegmentStats,
|
|
) -> None:
|
|
"""Append silence to sinks."""
|
|
from abogen.domain.audio_buffer import create_silence
|
|
|
|
silence = create_silence(duration)
|
|
if silence.size == 0:
|
|
return
|
|
if chapter_sink:
|
|
chapter_sink.write(silence)
|
|
if audio_sink:
|
|
audio_sink.write(silence)
|
|
stats.current_time += duration
|