mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
Compare commits
30
Commits
28998e1e5c
...
fcec4e9fe5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcec4e9fe5 | ||
|
|
72d5e3d1db | ||
|
|
d3682e7672 | ||
|
|
0805e9fdae | ||
|
|
4aef73ff85 | ||
|
|
f6a8008f51 | ||
|
|
dc5257252f | ||
|
|
c4cebb8822 | ||
|
|
93f5a46485 | ||
|
|
5f169a4921 | ||
|
|
df5705779e | ||
|
|
d0fe221176 | ||
|
|
17700426fd | ||
|
|
16b3f7d8a8 | ||
|
|
1a3741ec50 | ||
|
|
7f317ca784 | ||
|
|
d0e42ee691 | ||
|
|
b1392084e1 | ||
|
|
71916aa39f | ||
|
|
4c4434c309 | ||
|
|
7973de3868 | ||
|
|
fd659d0f4f | ||
|
|
e53251ef81 | ||
|
|
cd3cc9bce7 | ||
|
|
75a3ad517a | ||
|
|
a6b7ce69aa | ||
|
|
680418fa1d | ||
|
|
53b850ef41 | ||
|
|
7ed4eca68c | ||
|
|
e1e49e8a0f |
@@ -39,3 +39,4 @@ dist/
|
||||
test_assets/
|
||||
dev_notes/
|
||||
.claude/
|
||||
.coverage
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Application layer for conversion flow unification.
|
||||
|
||||
This package contains the application-level orchestration logic
|
||||
that bridges UI adapters (PyQt, WebUI) with domain functions.
|
||||
|
||||
The main entry point is ConversionService.run() which coordinates
|
||||
planning, execution, and finalization of a conversion job.
|
||||
"""
|
||||
@@ -0,0 +1,434 @@
|
||||
"""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,
|
||||
SynthParams,
|
||||
process_and_write_subtitles,
|
||||
synthesize_text,
|
||||
)
|
||||
from abogen.domain.enums import OutputFormat, SubtitleMode
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.output_paths import sanitize_filename_for_chapter
|
||||
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,
|
||||
)
|
||||
|
||||
# Compute subtitle flag once (used in every synthesize_text call)
|
||||
use_spacy = request.subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
|
||||
|
||||
# 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 == OutputFormat.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 != SubtitleMode.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)
|
||||
|
||||
effective_subtitle_mode = request.subtitle_mode if subtitle_writer else SubtitleMode.DISABLED
|
||||
|
||||
synth = SynthParams(
|
||||
tts_context=tts_context,
|
||||
stats=stats,
|
||||
check_cancel=check_cancelled,
|
||||
on_progress=lambda pct, etr: events.progress(pct, etr),
|
||||
audio_sink=audio_sink,
|
||||
subtitle_mode=effective_subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
lang_code=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
)
|
||||
|
||||
# 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,
|
||||
params=synth,
|
||||
backend=intro_backend,
|
||||
voice=intro_voice,
|
||||
speed=intro_speed or request.speed,
|
||||
chapter_sink=None,
|
||||
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
||||
)
|
||||
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 = sanitize_filename_for_chapter(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,
|
||||
params=synth,
|
||||
backend=intro_backend,
|
||||
voice=intro_voice,
|
||||
speed=intro_speed or request.speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=lambda text: events.log(f" Intro: {text[:80]}"),
|
||||
)
|
||||
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,
|
||||
params=synth,
|
||||
backend=chapter_backend,
|
||||
voice=chapter_voice,
|
||||
speed=chapter_speed or request.speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=lambda text: events.log(f" Title: {text[:80]}"),
|
||||
)
|
||||
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,
|
||||
params=synth,
|
||||
backend=seg_backend,
|
||||
voice=seg_voice,
|
||||
speed=seg_speed or request.speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
||||
)
|
||||
|
||||
# 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=use_spacy,
|
||||
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,
|
||||
params=synth,
|
||||
backend=outro_backend,
|
||||
voice=outro_voice,
|
||||
speed=outro_speed or request.speed,
|
||||
chapter_sink=None,
|
||||
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
||||
)
|
||||
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 _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
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Core models for conversion planning.
|
||||
|
||||
These dataclasses represent the structured plan for a conversion job.
|
||||
They are UI-agnostic and describe WHAT to convert, not HOW to do it.
|
||||
|
||||
The planning flow:
|
||||
ConversionRequest -> ConversionPlan -> ConversionResult
|
||||
|
||||
ConversionPlan contains:
|
||||
- ChapterPlan[]: chapters with their segments
|
||||
- SegmentPlan[]: individual text segments with voice specs
|
||||
- OutputLayout: where to write outputs
|
||||
- IntroOutroSpec: optional intro/outro
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentPlan:
|
||||
"""A single text segment with its voice specification.
|
||||
|
||||
This is the unified model for:
|
||||
- Regular chapter body text
|
||||
- PyQt voice markers (<<VOICE:F1>>)
|
||||
- WebUI chunks with per-chunk voice/speaker
|
||||
- Intro/outro text
|
||||
- Chapter headings
|
||||
"""
|
||||
|
||||
text: str
|
||||
voice_spec: str
|
||||
kind: str = "body" # intro, heading, body, outro
|
||||
speaker_id: str = "narrator"
|
||||
chunk_id: Optional[str] = None
|
||||
chunk_index: Optional[int] = None
|
||||
level: Optional[str] = None # chunk level (paragraph, sentence, etc.)
|
||||
source: str = "chapter" # chapter, voice_marker, chunk
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChapterPlan:
|
||||
"""A chapter with its metadata and segments."""
|
||||
|
||||
index: int
|
||||
title: str
|
||||
original_title: str
|
||||
body_text: str
|
||||
segments: List[SegmentPlan]
|
||||
voice_spec: str # default voice for this chapter
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputLayout:
|
||||
"""Resolved output paths for a conversion job."""
|
||||
|
||||
parent_dir: Path
|
||||
merged_path: Optional[Path] = None
|
||||
chapter_dir: Optional[Path] = None
|
||||
project_root: Optional[Path] = None
|
||||
audio_dir: Optional[Path] = None
|
||||
subtitle_dir: Optional[Path] = None
|
||||
metadata_dir: Optional[Path] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntroOutroSpec:
|
||||
"""Intro/outro specification with resolved text and voice."""
|
||||
|
||||
enabled: bool = False
|
||||
text: str = ""
|
||||
voice_spec: str = ""
|
||||
kind: str = "intro" # intro or outro
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionPlan:
|
||||
"""Complete plan for a conversion job.
|
||||
|
||||
This is the output of the planning phase and input to the executor.
|
||||
"""
|
||||
|
||||
request: ConversionRequest
|
||||
metadata: Dict[str, Any]
|
||||
chapters: List[ChapterPlan]
|
||||
intro: Optional[IntroOutroSpec] = None
|
||||
outro: Optional[IntroOutroSpec] = None
|
||||
output_layout: Optional[OutputLayout] = None
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Unified conversion planner.
|
||||
|
||||
Pure functions that take a ConversionRequest and produce a ConversionPlan.
|
||||
No side effects, no I/O — all complexity from both UIs in one place.
|
||||
|
||||
This is Stage 2 of the conversion flow unification plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
OutputLayout,
|
||||
SegmentPlan,
|
||||
)
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.output_layout_service import resolve_output_layout
|
||||
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
||||
from abogen.domain.metadata_extraction import extract_metadata_for_file
|
||||
from abogen.domain.metadata_merge import merge_metadata
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
|
||||
|
||||
def build_conversion_plan(request: ConversionRequest) -> ConversionPlan:
|
||||
"""Build a complete conversion plan from a request.
|
||||
|
||||
This is the single entry point that both UIs will call.
|
||||
It handles all the planning logic that was previously duplicated
|
||||
in both PyQt and WebUI conversion runners.
|
||||
|
||||
Args:
|
||||
request: Normalized conversion request
|
||||
|
||||
Returns:
|
||||
ConversionPlan with all chapters, segments, and output layout
|
||||
|
||||
Raises:
|
||||
ValueError: If request is invalid (no source, no chapters, etc.)
|
||||
"""
|
||||
# 1. Extract and validate source
|
||||
source_text = _extract_source_text(request)
|
||||
if not source_text or not source_text.strip():
|
||||
raise ValueError("No text content to convert")
|
||||
|
||||
# 2. Extract metadata
|
||||
metadata = _extract_metadata(request)
|
||||
|
||||
# 3. Parse chapters
|
||||
raw_chapters = _parse_chapters(source_text, request)
|
||||
|
||||
# 4. Apply chapter selection/overrides
|
||||
selected_chapters = _apply_selection(raw_chapters, request)
|
||||
|
||||
# 5. Build segments for each chapter
|
||||
chapters = _build_chapters(selected_chapters, request)
|
||||
|
||||
# 6. Build intro/outro
|
||||
intro, outro = _build_intro_outro(metadata, request)
|
||||
|
||||
# 7. Resolve output layout
|
||||
output_layout = resolve_output_layout(request)
|
||||
|
||||
return ConversionPlan(
|
||||
request=request,
|
||||
metadata=metadata,
|
||||
chapters=chapters,
|
||||
intro=intro,
|
||||
outro=outro,
|
||||
output_layout=output_layout,
|
||||
)
|
||||
|
||||
|
||||
def _extract_source_text(request: ConversionRequest) -> Optional[str]:
|
||||
"""Extract text from request source."""
|
||||
from abogen.subtitle_utils import clean_text
|
||||
|
||||
if request.direct_text:
|
||||
return clean_text(request.direct_text)
|
||||
if request.source_path and request.source_path.exists():
|
||||
encoding = "utf-8"
|
||||
try:
|
||||
with open(request.source_path, "r", encoding=encoding, errors="replace") as f:
|
||||
text = f.read()
|
||||
except Exception:
|
||||
return None
|
||||
return clean_text(text)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_metadata(request: ConversionRequest) -> Dict[str, Any]:
|
||||
"""Extract metadata from source file."""
|
||||
if request.direct_text:
|
||||
return dict(request.metadata_tags)
|
||||
|
||||
if request.source_path and request.source_path.exists():
|
||||
try:
|
||||
extraction = extract_metadata_for_file(
|
||||
str(request.source_path), is_direct_text=False
|
||||
)
|
||||
metadata = dict(extraction.metadata) if extraction.metadata else {}
|
||||
except Exception:
|
||||
metadata = {}
|
||||
metadata = merge_metadata(metadata, request.metadata_tags)
|
||||
return metadata
|
||||
|
||||
return dict(request.metadata_tags)
|
||||
|
||||
|
||||
def _parse_chapters(
|
||||
source_text: str, request: ConversionRequest
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""Parse source text into raw chapters.
|
||||
|
||||
Returns list of (title, body_text, default_voice) tuples.
|
||||
"""
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
|
||||
# Text is already cleaned in _extract_source_text, so clean=False here
|
||||
chapters = parse_chapters_from_text(source_text, default_title="text", clean=False)
|
||||
|
||||
# Default voice from request
|
||||
default_voice = request.voice or "M1"
|
||||
|
||||
return [(title, text, default_voice) for title, text in chapters]
|
||||
|
||||
|
||||
def _apply_selection(
|
||||
raw_chapters: List[Tuple[str, str, str]], request: ConversionRequest
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""Apply chapter selection and overrides."""
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
|
||||
# Convert to ExtractedChapter objects for auto_select_relevant_chapters
|
||||
extracted = [
|
||||
ExtractedChapter(title=title, text=text)
|
||||
for title, text, _ in raw_chapters
|
||||
]
|
||||
|
||||
# If user specified chapters, apply overrides
|
||||
if request.chapter_overrides:
|
||||
selected, _, diagnostics = apply_chapter_overrides(extracted, request.chapter_overrides)
|
||||
if selected:
|
||||
# Map back to (title, text, voice) tuples
|
||||
result = []
|
||||
for ch in selected:
|
||||
# Find matching original chapter to get voice
|
||||
voice = request.voice or "M1"
|
||||
for orig_title, orig_text, orig_voice in raw_chapters:
|
||||
if orig_title == ch.title:
|
||||
voice = orig_voice
|
||||
break
|
||||
result.append((ch.title, ch.text or "", voice))
|
||||
return result
|
||||
# If no chapters selected, fall through to auto-selection
|
||||
|
||||
# Auto-select relevant chapters
|
||||
from abogen.domain.file_type import infer_file_type
|
||||
|
||||
file_type = infer_file_type(request.source_path) if request.source_path else "text"
|
||||
result = auto_select_relevant_chapters(extracted, file_type)
|
||||
filtered = result.kept
|
||||
|
||||
if filtered:
|
||||
# Map back to (title, text, voice) tuples
|
||||
result = []
|
||||
for ch in filtered:
|
||||
voice = request.voice or "M1"
|
||||
for orig_title, orig_text, orig_voice in raw_chapters:
|
||||
if orig_title == ch.title:
|
||||
voice = orig_voice
|
||||
break
|
||||
result.append((ch.title, ch.text or "", voice))
|
||||
return result
|
||||
|
||||
# Fall back to all chapters
|
||||
return raw_chapters
|
||||
|
||||
|
||||
def _build_chapters(
|
||||
selected_chapters: List[Tuple[str, str, str]], request: ConversionRequest
|
||||
) -> List[ChapterPlan]:
|
||||
"""Build ChapterPlan with SegmentPlan for each chapter."""
|
||||
chapters = []
|
||||
|
||||
for idx, (title, body_text, default_voice) in enumerate(selected_chapters, 1):
|
||||
# Build segments for this chapter
|
||||
segments = _build_segments(body_text, default_voice, request)
|
||||
|
||||
chapter = ChapterPlan(
|
||||
index=idx,
|
||||
title=title,
|
||||
original_title=title,
|
||||
body_text=body_text,
|
||||
segments=segments,
|
||||
voice_spec=default_voice,
|
||||
)
|
||||
chapters.append(chapter)
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def _build_segments(
|
||||
body_text: str, default_voice: str, request: ConversionRequest
|
||||
) -> List[SegmentPlan]:
|
||||
"""Build SegmentPlan list for a chapter's body text.
|
||||
|
||||
Handles voice markers (PyQt) and chunks (WebUI).
|
||||
"""
|
||||
segments = []
|
||||
|
||||
# Check for chunks (WebUI style)
|
||||
if request.chunks:
|
||||
# Group chunks by chapter (simplified — assume chunks are for current chapter)
|
||||
for chunk_idx, chunk in enumerate(request.chunks):
|
||||
chunk_text = chunk.get("normalized_text") or chunk.get("text", "")
|
||||
if not chunk_text or not chunk_text.strip():
|
||||
continue
|
||||
|
||||
chunk_voice = _resolve_chunk_voice(chunk, default_voice, request)
|
||||
speaker_id = chunk.get("speaker_id", "narrator")
|
||||
|
||||
segments.append(
|
||||
SegmentPlan(
|
||||
text=chunk_text.strip(),
|
||||
voice_spec=chunk_voice,
|
||||
kind="body",
|
||||
speaker_id=speaker_id,
|
||||
chunk_id=chunk.get("id"),
|
||||
chunk_index=chunk.get("chunk_index", chunk_idx),
|
||||
level=chunk.get("level", request.chunk_level),
|
||||
source="chunk",
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
# Check for voice markers (PyQt style)
|
||||
# Detect markers even if validation fails (voice names may not be loaded yet)
|
||||
from abogen.subtitle_utils import _VOICE_MARKER_SEARCH_PATTERN
|
||||
|
||||
has_voice_markers = bool(_VOICE_MARKER_SEARCH_PATTERN.search(body_text))
|
||||
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(
|
||||
body_text, default_voice
|
||||
)
|
||||
|
||||
if has_voice_markers or (len(voice_segments) > 1):
|
||||
# Voice markers were used
|
||||
for voice_name, segment_text in voice_segments:
|
||||
if not segment_text or not segment_text.strip():
|
||||
continue
|
||||
segments.append(
|
||||
SegmentPlan(
|
||||
text=segment_text.strip(),
|
||||
voice_spec=voice_name,
|
||||
kind="body",
|
||||
source="voice_marker",
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
# No voice markers — single segment for entire body
|
||||
if body_text and body_text.strip():
|
||||
segments.append(
|
||||
SegmentPlan(
|
||||
text=body_text.strip(),
|
||||
voice_spec=default_voice,
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _resolve_chunk_voice(
|
||||
chunk: Dict[str, Any], default_voice: str, request: ConversionRequest
|
||||
) -> str:
|
||||
"""Resolve voice for a chunk."""
|
||||
# Check for speaker-based voice
|
||||
speaker_id = chunk.get("speaker_id", "narrator")
|
||||
if speaker_id and speaker_id != "narrator" and request.speakers:
|
||||
speaker_config = request.speakers.get(speaker_id, {})
|
||||
if isinstance(speaker_config, dict):
|
||||
voice = speaker_config.get("voice")
|
||||
if voice:
|
||||
return voice
|
||||
|
||||
# Check for direct voice field
|
||||
voice = chunk.get("voice")
|
||||
if voice:
|
||||
return voice
|
||||
|
||||
return default_voice
|
||||
|
||||
|
||||
def _build_intro_outro(
|
||||
metadata: Dict[str, Any], request: ConversionRequest
|
||||
) -> Tuple[Optional[IntroOutroSpec], Optional[IntroOutroSpec]]:
|
||||
"""Build intro and outro specs."""
|
||||
intro_spec = None
|
||||
outro_spec = None
|
||||
|
||||
# Intro
|
||||
if request.read_title_intro:
|
||||
resolved = resolve_intro(
|
||||
metadata,
|
||||
request.original_filename,
|
||||
True,
|
||||
request.voice or "M1",
|
||||
request.voice or "M1",
|
||||
[],
|
||||
)
|
||||
if resolved.enabled:
|
||||
intro_spec = IntroOutroSpec(
|
||||
enabled=True,
|
||||
text=resolved.text,
|
||||
voice_spec=resolved.voice_spec,
|
||||
kind="intro",
|
||||
)
|
||||
|
||||
# Outro
|
||||
if request.read_closing_outro:
|
||||
resolved = resolve_outro(
|
||||
metadata,
|
||||
request.original_filename,
|
||||
True,
|
||||
request.voice or "M1",
|
||||
request.voice or "M1",
|
||||
[],
|
||||
)
|
||||
if resolved.enabled:
|
||||
outro_spec = IntroOutroSpec(
|
||||
enabled=True,
|
||||
text=resolved.text,
|
||||
voice_spec=resolved.voice_spec,
|
||||
kind="outro",
|
||||
)
|
||||
|
||||
return intro_spec, outro_spec
|
||||
|
||||
|
||||
# Output layout resolution is now in application/output_layout_service.py
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Ports / interfaces for the conversion service.
|
||||
|
||||
These protocols define how the conversion service communicates with
|
||||
the outside world (UI, TTS backends, voice resolvers).
|
||||
|
||||
The service ONLY depends on these interfaces, never on concrete
|
||||
implementations (PyQt signals, Flask Job, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class ConversionCancelled(Exception):
|
||||
"""Raised when conversion is cancelled by user."""
|
||||
pass
|
||||
|
||||
|
||||
class ConversionEvents(Protocol):
|
||||
"""UI-specific actions the conversion service delegates back to the caller.
|
||||
|
||||
Implementations:
|
||||
- PyQt: emits signals (log_updated, progress_updated, etc.)
|
||||
- WebUI: updates Job attributes (job.add_log, job.progress, etc.)
|
||||
"""
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message to the UI."""
|
||||
...
|
||||
|
||||
def progress(self, processed: int, total: int, etr: str) -> None:
|
||||
"""Update progress display."""
|
||||
...
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
"""Check if conversion was cancelled.
|
||||
|
||||
Should raise ConversionCancelled (or UI-specific exception)
|
||||
if cancellation is requested. Normal return means "continue".
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class PipelineProvider(Protocol):
|
||||
"""Provides access to TTS backends (Kokoro, SuperTonic, etc.).
|
||||
|
||||
Implementations:
|
||||
- PyQt: wraps self.backend (single pipeline)
|
||||
- WebUI: wraps PipelinePool (multi-provider)
|
||||
"""
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> Any:
|
||||
"""Get a TTS backend instance."""
|
||||
...
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all backend resources."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedVoice:
|
||||
"""A resolved voice ready for TTS synthesis."""
|
||||
|
||||
provider: str
|
||||
resolved_spec: str
|
||||
voice: Any # loaded voice tensor or name
|
||||
speed: float
|
||||
supertonic_steps: int
|
||||
|
||||
|
||||
class VoiceResolver(Protocol):
|
||||
"""Resolves voice specs into loaded voice objects.
|
||||
|
||||
Implementations:
|
||||
- PyQt: wraps load_voice_cached + VoiceCache
|
||||
- WebUI: wraps resolve_voice_choice + PipelinePool + VoiceCache
|
||||
"""
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
"""Resolve a voice spec into a loaded voice."""
|
||||
...
|
||||
|
||||
|
||||
class SubtitleWriter(Protocol):
|
||||
"""Writes subtitle entries to a file."""
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the subtitle file for writing."""
|
||||
...
|
||||
|
||||
def write_entry(self, start: float, end: float, text: str) -> None:
|
||||
"""Write a single subtitle entry."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the subtitle file."""
|
||||
...
|
||||
|
||||
|
||||
class AudioSink(Protocol):
|
||||
"""Writes audio data to a file."""
|
||||
|
||||
def write(self, audio: Any) -> None:
|
||||
"""Write audio samples to the sink."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the audio file."""
|
||||
...
|
||||
@@ -0,0 +1,156 @@
|
||||
"""ConversionRequest — normalized input for a conversion job.
|
||||
|
||||
This is NOT a WebUI Job and NOT a PyQt ConversionThread state.
|
||||
It describes the TASK, not the UI.
|
||||
|
||||
UI adapters are responsible for converting their respective state
|
||||
into a ConversionRequest before calling ConversionService.run().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
|
||||
|
||||
|
||||
class ConversionRequestError(ValueError):
|
||||
"""Raised when ConversionRequest has invalid field values."""
|
||||
|
||||
|
||||
# Numeric field constraints: attr -> (min, max)
|
||||
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
|
||||
"max_subtitle_words": (1, 500),
|
||||
"speed": (0.5, 3.0),
|
||||
"supertonic_total_steps": (2, 15),
|
||||
"silence_between_chapters": (0.0, None),
|
||||
"chapter_intro_delay": (0.0, None),
|
||||
}
|
||||
|
||||
# Enum-like fields that must be in allowed set
|
||||
_ENUM_CONSTRAINTS: dict[str, tuple[str, ...]] = {
|
||||
"chunk_level": ("paragraph", "sentence"),
|
||||
"speaker_mode": ("single", "multi"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionRequest:
|
||||
"""Normalized request for a conversion job.
|
||||
|
||||
Only contains fields that describe the conversion task itself.
|
||||
UI-only fields (display, logging, user prompts) stay in adapters.
|
||||
|
||||
Validation runs on creation via __post_init__:
|
||||
- None values → replaced with field default (from declaration)
|
||||
- Numeric fields → clamped to valid range
|
||||
- String enums → validated against allowed set
|
||||
"""
|
||||
|
||||
# --- Source ---
|
||||
source_path: Optional[Path] = None
|
||||
direct_text: Optional[str] = None
|
||||
original_filename: str = ""
|
||||
|
||||
# --- TTS Settings ---
|
||||
language: Language = Language.EN_US
|
||||
tts_provider: str = "kokoro"
|
||||
voice: str = "M1"
|
||||
voice_profile: Optional[str] = None
|
||||
speed: float = 1.0
|
||||
use_gpu: bool = True
|
||||
supertonic_total_steps: int = 5
|
||||
|
||||
# --- Output Format ---
|
||||
output_format: OutputFormat = OutputFormat.WAV
|
||||
subtitle_mode: SubtitleMode = SubtitleMode.DISABLED
|
||||
subtitle_format: SubtitleFormat = SubtitleFormat.SRT
|
||||
max_subtitle_words: int = 50
|
||||
|
||||
# --- Save Options ---
|
||||
save_mode: SaveMode = SaveMode.SAVE_NEXT_TO_INPUT
|
||||
output_folder: Optional[Path] = None
|
||||
save_chapters_separately: bool = False
|
||||
merge_chapters_at_end: bool = True
|
||||
separate_chapters_format: OutputFormat = OutputFormat.WAV
|
||||
save_as_project: bool = False
|
||||
|
||||
# --- Timing ---
|
||||
silence_between_chapters: float = 2.0
|
||||
chapter_intro_delay: float = 0.0
|
||||
|
||||
# --- Content Processing ---
|
||||
replace_single_newlines: bool = False
|
||||
read_title_intro: bool = False
|
||||
read_closing_outro: bool = True
|
||||
auto_prefix_chapter_titles: bool = True
|
||||
normalize_chapter_opening_caps: bool = False
|
||||
|
||||
# --- Pronunciation / Normalization ---
|
||||
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
normalization_overrides: Optional[Dict[str, Any]] = None
|
||||
|
||||
# --- Chapter/Chunk Configuration ---
|
||||
chapter_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
chunks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
chunk_level: str = "paragraph"
|
||||
speaker_mode: str = "single"
|
||||
speakers: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# --- Metadata ---
|
||||
metadata_tags: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# --- Artifacts ---
|
||||
cover_image_path: Optional[Path] = None
|
||||
cover_image_mime: Optional[str] = None
|
||||
generate_epub3: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Resolve None → default, then validate and clamp."""
|
||||
_apply_none_defaults(self)
|
||||
if not self.tts_provider:
|
||||
self.tts_provider = "kokoro"
|
||||
_clamp_numerics(self)
|
||||
_validate_enums(self)
|
||||
|
||||
|
||||
def _apply_none_defaults(obj: ConversionRequest) -> None:
|
||||
"""Replace None values with field defaults from dataclass declaration."""
|
||||
for f in dataclasses.fields(obj):
|
||||
if getattr(obj, f.name) is not None:
|
||||
continue
|
||||
if f.default is not dataclasses.MISSING:
|
||||
setattr(obj, f.name, f.default)
|
||||
elif f.default_factory is not dataclasses.MISSING:
|
||||
setattr(obj, f.name, f.default_factory())
|
||||
|
||||
|
||||
def _clamp_numerics(obj: ConversionRequest) -> None:
|
||||
"""Clamp numeric fields to valid ranges."""
|
||||
for attr, (min_v, max_v) in _NUMERIC_CONSTRAINTS.items():
|
||||
val = getattr(obj, attr)
|
||||
if val is None:
|
||||
continue
|
||||
if not isinstance(val, (int, float)):
|
||||
raise ConversionRequestError(
|
||||
f"{attr} must be a number, got {type(val).__name__}"
|
||||
)
|
||||
clamped = max(min_v, float(val))
|
||||
if max_v is not None:
|
||||
clamped = min(max_v, clamped)
|
||||
setattr(obj, attr, clamped)
|
||||
|
||||
|
||||
def _validate_enums(obj: ConversionRequest) -> None:
|
||||
"""Validate string enum fields against allowed values."""
|
||||
for attr, allowed in _ENUM_CONSTRAINTS.items():
|
||||
val = getattr(obj, attr)
|
||||
if val not in allowed:
|
||||
raise ConversionRequestError(
|
||||
f"{attr} must be one of {allowed}, got {val!r}"
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""ConversionResult — output of a successful conversion.
|
||||
|
||||
Returned by ConversionService.run() after all synthesis and finalization.
|
||||
UI adapters consume this to update their respective state (Job, signals, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionResult:
|
||||
"""Output of a successful conversion job."""
|
||||
|
||||
# --- Primary outputs ---
|
||||
audio_path: Optional[Path] = None
|
||||
subtitle_paths: List[Path] = field(default_factory=list)
|
||||
chapter_paths: List[Path] = field(default_factory=list)
|
||||
|
||||
# --- Markers (for metadata/audiobookshelf) ---
|
||||
chapter_markers: List[Dict[str, Any]] = field(default_factory=list)
|
||||
chunk_markers: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# --- Metadata ---
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# --- Artifacts ---
|
||||
artifacts: Dict[str, Path] = field(default_factory=dict)
|
||||
project_root: Optional[Path] = None
|
||||
epub_path: Optional[Path] = None
|
||||
|
||||
# --- Stats ---
|
||||
total_chapters: int = 0
|
||||
total_segments: int = 0
|
||||
total_characters: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionError:
|
||||
"""Error information when conversion fails."""
|
||||
|
||||
message: str
|
||||
details: Optional[str] = None
|
||||
is_cancelled: bool = False
|
||||
@@ -0,0 +1,172 @@
|
||||
"""ConversionService — main orchestrator for the conversion flow.
|
||||
|
||||
Ties together planner, executor, and finalizers into a single entry point.
|
||||
Both UIs (PyQt, WebUI) call ConversionService.run() to execute a conversion.
|
||||
|
||||
Responsibilities:
|
||||
- Prepare TTSContext (normalization settings, pronunciation rules)
|
||||
- Build ConversionPlan via planner
|
||||
- Execute conversion via executor
|
||||
- Handle lifecycle (cleanup, error handling)
|
||||
- Return ConversionResult
|
||||
|
||||
The service NEVER imports from PyQt or WebUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
from abogen.application.conversion_models import ConversionPlan
|
||||
from abogen.application.conversion_planner import build_conversion_plan
|
||||
from abogen.application.conversion_ports import (
|
||||
ConversionEvents,
|
||||
PipelineProvider,
|
||||
VoiceResolver,
|
||||
)
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_result import ConversionResult
|
||||
from abogen.domain.enums import SubtitleMode
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
|
||||
|
||||
def run_conversion(
|
||||
request: ConversionRequest,
|
||||
events: ConversionEvents,
|
||||
pipeline_provider: PipelineProvider,
|
||||
voice_resolver: VoiceResolver,
|
||||
) -> ConversionResult:
|
||||
"""Execute a conversion request and return the result.
|
||||
|
||||
This is the single entry point for both UIs. It orchestrates:
|
||||
1. TTS context preparation
|
||||
2. Conversion planning
|
||||
3. Conversion execution
|
||||
4. Resource cleanup
|
||||
|
||||
Args:
|
||||
request: Normalized conversion request
|
||||
events: UI-specific callbacks (log, progress, check_cancelled)
|
||||
pipeline_provider: Provides TTS backends
|
||||
voice_resolver: Resolves voice specs into loaded voices
|
||||
|
||||
Returns:
|
||||
ConversionResult with paths and markers
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If conversion was cancelled
|
||||
ValueError: If request is invalid
|
||||
Exception: On TTS or I/O errors
|
||||
"""
|
||||
try:
|
||||
# Stage 1: Prepare TTS context
|
||||
events.log("Preparing conversion pipeline")
|
||||
tts_context = _prepare_tts_context(request, events)
|
||||
|
||||
# Stage 2: Build conversion plan
|
||||
events.log("Building conversion plan")
|
||||
plan = build_conversion_plan(request)
|
||||
|
||||
# Stage 3: Execute conversion
|
||||
events.log("Starting conversion")
|
||||
result = execute_conversion(
|
||||
plan=plan,
|
||||
events=events,
|
||||
pipeline_provider=pipeline_provider,
|
||||
voice_resolver=voice_resolver,
|
||||
tts_context=tts_context,
|
||||
)
|
||||
|
||||
# Stage 4: Finalize
|
||||
events.log("Conversion complete")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
events.log(f"Conversion failed: {e}", level="error")
|
||||
raise
|
||||
|
||||
|
||||
def _prepare_tts_context(
|
||||
request: ConversionRequest,
|
||||
events: ConversionEvents,
|
||||
) -> TTSContext:
|
||||
"""Prepare TTSContext with normalization settings.
|
||||
|
||||
This compiles pronunciation/heteronym rules and creates the
|
||||
normalization context used during conversion.
|
||||
|
||||
Args:
|
||||
request: Conversion request with override settings
|
||||
events: For logging warnings about missing features
|
||||
|
||||
Returns:
|
||||
TTSContext ready for text normalization
|
||||
"""
|
||||
from abogen.domain.normalization import (
|
||||
build_apostrophe_config,
|
||||
get_runtime_settings,
|
||||
)
|
||||
from abogen.domain.pronunciation import (
|
||||
compile_heteronym_sentence_rules,
|
||||
compile_pronunciation_rules,
|
||||
merge_pronunciation_overrides,
|
||||
)
|
||||
|
||||
# Get runtime normalization settings
|
||||
normalization_settings = get_runtime_settings()
|
||||
|
||||
# Build apostrophe config
|
||||
apostrophe_config = build_apostrophe_config(
|
||||
settings=normalization_settings,
|
||||
)
|
||||
|
||||
# Check for num2words availability
|
||||
if apostrophe_config.convert_numbers:
|
||||
try:
|
||||
import num2words # noqa: F401
|
||||
except ImportError:
|
||||
events.log(
|
||||
"Number normalization is enabled but 'num2words' library is not available. "
|
||||
"Numbers will NOT be converted to words.",
|
||||
level="warning",
|
||||
)
|
||||
|
||||
# Compute split pattern
|
||||
split_pattern = get_split_pattern(
|
||||
request.language or Language.EN_US,
|
||||
request.subtitle_mode or SubtitleMode.DISABLED,
|
||||
)
|
||||
|
||||
# Merge pronunciation overrides (manual + pronunciation)
|
||||
# Create a mock job-like object for merge_pronunciation_overrides
|
||||
class _MockJob:
|
||||
def __init__(self, req):
|
||||
self.pronunciation_overrides = req.pronunciation_overrides
|
||||
self.manual_overrides = req.manual_overrides
|
||||
self.heteronym_overrides = req.heteronym_overrides
|
||||
|
||||
merged_overrides = merge_pronunciation_overrides(_MockJob(request))
|
||||
|
||||
# Compile rules
|
||||
pronunciation_rules = compile_pronunciation_rules(merged_overrides)
|
||||
heteronym_rules = compile_heteronym_sentence_rules(request.heteronym_overrides)
|
||||
|
||||
if heteronym_rules:
|
||||
events.log(
|
||||
f"Applying {len(heteronym_rules)} heteronym override(s) during conversion.",
|
||||
level="debug",
|
||||
)
|
||||
if pronunciation_rules:
|
||||
events.log(
|
||||
f"Applying {len(pronunciation_rules)} pronunciation override(s) during conversion.",
|
||||
level="debug",
|
||||
)
|
||||
|
||||
return TTSContext(
|
||||
split_pattern=split_pattern,
|
||||
pronunciation_rules=pronunciation_rules,
|
||||
heteronym_rules=heteronym_rules,
|
||||
normalization_overrides=request.normalization_overrides,
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Output layout resolution service.
|
||||
|
||||
Determines where conversion outputs (audio, subtitles, metadata) should be written.
|
||||
Extracted from conversion_planner.py as a standalone service per plan Stage 5.
|
||||
|
||||
Responsibilities:
|
||||
- Resolve base output directory from save_mode and source_path
|
||||
- Determine base filename from original_filename
|
||||
- Find unique output path to avoid overwrites
|
||||
- Resolve project layout (audio_dir, subtitle_dir, metadata_dir)
|
||||
- Force merged output for m4b format
|
||||
- Return OutputLayout dataclass
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from abogen.application.conversion_models import OutputLayout
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.domain.enums import OutputFormat, SaveMode, SubtitleFormat
|
||||
from abogen.domain.output_paths import (
|
||||
resolve_project_layout,
|
||||
resolve_unique_path,
|
||||
sanitize_output_stem,
|
||||
)
|
||||
|
||||
|
||||
def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
||||
"""Resolve output paths for a conversion request.
|
||||
|
||||
This is the single entry point for output path resolution,
|
||||
used by both UIs and the conversion service.
|
||||
|
||||
Args:
|
||||
request: Normalized conversion request
|
||||
|
||||
Returns:
|
||||
OutputLayout with resolved paths
|
||||
"""
|
||||
# Determine base output directory
|
||||
if request.save_mode == SaveMode.CUSTOM_FOLDER and request.output_folder:
|
||||
parent_dir = Path(request.output_folder)
|
||||
elif request.source_path:
|
||||
parent_dir = request.source_path.parent
|
||||
else:
|
||||
parent_dir = Path.cwd()
|
||||
|
||||
# Determine base name
|
||||
if request.original_filename:
|
||||
base_name = sanitize_output_stem(request.original_filename)
|
||||
elif request.source_path:
|
||||
base_name = sanitize_output_stem(request.source_path.stem)
|
||||
else:
|
||||
base_name = "output"
|
||||
|
||||
# Find unique output path
|
||||
allowed_exts = {request.output_format, SubtitleFormat.SRT, SubtitleFormat.ASS, "vtt", "mp4", OutputFormat.M4B}
|
||||
unique_base = resolve_unique_path(
|
||||
parent_dir, base_name, "", allowed_extensions=allowed_exts
|
||||
)
|
||||
|
||||
# Resolve project layout
|
||||
project_root = None
|
||||
audio_dir = parent_dir
|
||||
subtitle_dir = None
|
||||
metadata_dir = None
|
||||
|
||||
if request.save_as_project:
|
||||
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
|
||||
original_filename=request.original_filename,
|
||||
save_as_project=True,
|
||||
base_dir=parent_dir,
|
||||
)
|
||||
|
||||
return OutputLayout(
|
||||
parent_dir=parent_dir,
|
||||
project_root=project_root,
|
||||
audio_dir=audio_dir,
|
||||
subtitle_dir=subtitle_dir,
|
||||
metadata_dir=metadata_dir,
|
||||
)
|
||||
|
||||
|
||||
def resolve_merged_path(
|
||||
layout: OutputLayout,
|
||||
request: ConversionRequest,
|
||||
) -> Path:
|
||||
"""Resolve the merged output audio file path.
|
||||
|
||||
Args:
|
||||
layout: Resolved output layout
|
||||
request: Conversion request
|
||||
|
||||
Returns:
|
||||
Path to the merged output file
|
||||
"""
|
||||
base_name = sanitize_output_stem(
|
||||
request.original_filename or "output"
|
||||
)
|
||||
return layout.audio_dir / f"{base_name}.{request.output_format}"
|
||||
|
||||
|
||||
def resolve_chapter_path(
|
||||
layout: OutputLayout,
|
||||
request: ConversionRequest,
|
||||
chapter_title: str,
|
||||
chapter_index: int,
|
||||
) -> Path:
|
||||
"""Resolve the output path for a separate chapter file.
|
||||
|
||||
Args:
|
||||
layout: Resolved output layout
|
||||
request: Conversion request
|
||||
chapter_title: Chapter title for filename
|
||||
chapter_index: Chapter number (1-based)
|
||||
|
||||
Returns:
|
||||
Path to the chapter output file
|
||||
"""
|
||||
import re
|
||||
|
||||
slug = re.sub(r'[^\w\s-]', '', chapter_title.lower())
|
||||
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
|
||||
if not slug:
|
||||
slug = f"chapter_{chapter_index}"
|
||||
filename = f"{chapter_index:02d}_{slug}.{request.separate_chapters_format}"
|
||||
return layout.audio_dir / "chapters" / filename
|
||||
|
||||
|
||||
def should_merge_output(request: ConversionRequest) -> bool:
|
||||
"""Determine if merged output is required.
|
||||
|
||||
Rules:
|
||||
- m4b format always forces merged output
|
||||
- If save_chapters_separately is False, merged is required
|
||||
- Otherwise, use merge_chapters_at_end setting
|
||||
|
||||
Args:
|
||||
request: Conversion request
|
||||
|
||||
Returns:
|
||||
True if merged output should be created
|
||||
"""
|
||||
if request.output_format == OutputFormat.M4B:
|
||||
return True
|
||||
if not request.save_chapters_separately:
|
||||
return True
|
||||
return request.merge_chapters_at_end
|
||||
@@ -54,7 +54,7 @@ def _ensure_ffmpeg() -> None:
|
||||
|
||||
|
||||
def _get_ffmpeg_cache_root() -> str:
|
||||
from abogen.infrastructure.cache import get_internal_cache_path
|
||||
from abogen.utils import get_internal_cache_path
|
||||
|
||||
return get_internal_cache_path("ffmpeg")
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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.enums import SubtitleMode
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.progress import calc_etr_str
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
@@ -55,44 +56,29 @@ class SegmentInfo:
|
||||
def run_tts_segment_loop(
|
||||
*,
|
||||
text: str,
|
||||
params: SynthParams,
|
||||
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.
|
||||
params: Common synthesis parameters (stats, callbacks, sinks, etc.).
|
||||
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).
|
||||
@@ -108,26 +94,26 @@ def run_tts_segment_loop(
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
current_time=stats.current_time,
|
||||
current_time=params.stats.current_time,
|
||||
):
|
||||
if check_cancel():
|
||||
if params.check_cancel():
|
||||
break
|
||||
|
||||
local_segments += 1
|
||||
stats.processed_chars += len(seg.graphemes)
|
||||
params.stats.processed_chars += len(seg.graphemes)
|
||||
|
||||
# Progress
|
||||
if stats.total_characters:
|
||||
percent = min(int(stats.processed_chars / stats.total_characters * 100), 99)
|
||||
if params.stats.total_characters:
|
||||
percent = min(int(params.stats.processed_chars / params.stats.total_characters * 100), 99)
|
||||
else:
|
||||
percent = 0 if stats.processed_chars == 0 else 99
|
||||
percent = 0 if params.stats.processed_chars == 0 else 99
|
||||
|
||||
etr_str = calc_etr_str(
|
||||
time.time() - stats.etr_start_time,
|
||||
stats.processed_chars,
|
||||
stats.total_characters,
|
||||
time.time() - params.stats.etr_start_time,
|
||||
params.stats.processed_chars,
|
||||
params.stats.total_characters,
|
||||
)
|
||||
on_progress(percent, etr_str)
|
||||
params.on_progress(percent, etr_str)
|
||||
|
||||
# Preview / log
|
||||
if preview_callback:
|
||||
@@ -140,23 +126,23 @@ def run_tts_segment_loop(
|
||||
audio=seg.audio,
|
||||
tokens=list(seg.tokens) if seg.tokens else [],
|
||||
duration=seg.duration,
|
||||
chunk_start=getattr(seg, "chunk_start", stats.current_time),
|
||||
chunk_start=getattr(seg, "chunk_start", params.stats.current_time),
|
||||
)
|
||||
on_segment(info)
|
||||
|
||||
# Write audio
|
||||
if chapter_sink:
|
||||
chapter_sink.write(seg.audio)
|
||||
if audio_sink:
|
||||
audio_sink.write(seg.audio)
|
||||
if params.audio_sink:
|
||||
params.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:
|
||||
if not on_segment and params.subtitle_mode != SubtitleMode.DISABLED and seg.tokens:
|
||||
accumulated_tokens.extend(seg.tokens)
|
||||
|
||||
# Update timing
|
||||
if audio_sink:
|
||||
stats.current_time += seg.duration
|
||||
if params.audio_sink:
|
||||
params.stats.current_time += seg.duration
|
||||
|
||||
return local_segments, accumulated_tokens
|
||||
|
||||
@@ -191,24 +177,34 @@ def process_and_write_subtitles(
|
||||
subtitle_writer.write_entry(start=start, end=end, text=text)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthParams:
|
||||
"""Common parameters for synthesize_text calls.
|
||||
|
||||
Packed once by the executor to avoid repeating identical kwargs.
|
||||
When adding new common params, change only this dataclass.
|
||||
"""
|
||||
tts_context: TTSContext
|
||||
stats: SegmentStats
|
||||
check_cancel: CancelChecker
|
||||
on_progress: Callable[[int, str], None]
|
||||
audio_sink: Optional[AudioSink] = None
|
||||
subtitle_mode: str = "Disabled"
|
||||
max_subtitle_words: int = 50
|
||||
lang_code: str = "a"
|
||||
use_spacy_segmentation: bool = False
|
||||
|
||||
|
||||
def synthesize_text(
|
||||
*,
|
||||
text: str,
|
||||
tts_context: TTSContext,
|
||||
params: SynthParams,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float,
|
||||
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,
|
||||
split_pattern_override: Optional[str] = None,
|
||||
) -> tuple[int, list]:
|
||||
"""Normalize text and run TTS — the single entry point for both UIs.
|
||||
@@ -216,22 +212,15 @@ def synthesize_text(
|
||||
Combines TTSContext.normalize() + run_tts_segment_loop() into one call.
|
||||
UI-specific concerns (provider resolution, progress display) stay in the UI.
|
||||
"""
|
||||
normalized = tts_context.normalize(text)
|
||||
normalized = params.tts_context.normalize(text)
|
||||
return run_tts_segment_loop(
|
||||
text=normalized,
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern_override or tts_context.split_pattern,
|
||||
stats=stats,
|
||||
check_cancel=check_cancel,
|
||||
on_progress=on_progress,
|
||||
split_pattern=split_pattern_override or params.tts_context.split_pattern,
|
||||
chapter_sink=chapter_sink,
|
||||
audio_sink=audio_sink,
|
||||
preview_callback=preview_callback,
|
||||
on_segment=on_segment,
|
||||
subtitle_mode=subtitle_mode,
|
||||
max_subtitle_words=max_subtitle_words,
|
||||
lang_code=lang_code,
|
||||
use_spacy_segmentation=use_spacy_segmentation,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from abogen.domain.enums import SubtitleMode
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -221,7 +223,7 @@ def emit_text_to_sinks(
|
||||
|
||||
# Flush subtitle tokens
|
||||
if subtitle_writer and accumulated_tokens:
|
||||
_use_spacy = subtitle_mode not in ("Disabled", "Line")
|
||||
_use_spacy = subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
|
||||
new_entries: List[tuple] = []
|
||||
process_subtitle_tokens(
|
||||
accumulated_tokens,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Domain enums — typed constants for values tied to business logic.
|
||||
|
||||
Using Enum instead of bare strings ensures:
|
||||
- Invalid values are caught at construction time
|
||||
- IDE autocomplete and type checking work
|
||||
- Adding new values is explicit (must update Enum)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SubtitleMode(str, Enum):
|
||||
"""Subtitle generation mode."""
|
||||
DISABLED = "Disabled"
|
||||
LINE = "Line"
|
||||
SENTENCE = "Sentence"
|
||||
SENTENCE_COMMA = "Sentence + Comma"
|
||||
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> SubtitleMode:
|
||||
"""Parse from user input: case-insensitive, strips whitespace."""
|
||||
normalized = value.strip()
|
||||
for member in cls:
|
||||
if member.value.lower() == normalized.lower():
|
||||
return member
|
||||
raise ValueError(f"Invalid SubtitleMode: {value!r}. Valid: {[m.value for m in cls]}")
|
||||
|
||||
|
||||
class OutputFormat(str, Enum):
|
||||
"""Audio output format."""
|
||||
WAV = "wav"
|
||||
MP3 = "mp3"
|
||||
FLAC = "flac"
|
||||
OPUS = "opus"
|
||||
M4B = "m4b"
|
||||
|
||||
@property
|
||||
def dot_ext(self) -> str:
|
||||
"""File extension with dot: '.wav', '.mp3', etc."""
|
||||
return f".{self.value}"
|
||||
|
||||
@property
|
||||
def is_lossless(self) -> bool:
|
||||
"""True for lossless formats."""
|
||||
return self in (self.WAV, self.FLAC)
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> OutputFormat:
|
||||
"""Parse from user input: strips dot prefix, case-insensitive."""
|
||||
normalized = value.strip().lstrip(".").lower()
|
||||
for member in cls:
|
||||
if member.value == normalized:
|
||||
return member
|
||||
raise ValueError(f"Invalid OutputFormat: {value!r}. Valid: {[m.value for m in cls]}")
|
||||
|
||||
|
||||
class SaveMode(str, Enum):
|
||||
"""Where to save the output file."""
|
||||
SAVE_NEXT_TO_INPUT = "save_next_to_input"
|
||||
SAVE_TO_DESKTOP = "save_to_desktop"
|
||||
CHOOSE_OUTPUT_FOLDER = "choose_output_folder"
|
||||
DEFAULT_OUTPUT = "default_output"
|
||||
CUSTOM_FOLDER = "custom_folder"
|
||||
|
||||
|
||||
class SubtitleFormat(str, Enum):
|
||||
"""Subtitle file format."""
|
||||
SRT = "srt"
|
||||
ASS = "ass"
|
||||
VTT = "vtt"
|
||||
|
||||
@property
|
||||
def dot_ext(self) -> str:
|
||||
"""File extension with dot: '.srt', '.ass'."""
|
||||
return f".{self.value}"
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> SubtitleFormat:
|
||||
"""Parse from user input: strips dot prefix, case-insensitive."""
|
||||
normalized = value.strip().lstrip(".").lower()
|
||||
for member in cls:
|
||||
if member.value == normalized:
|
||||
return member
|
||||
raise ValueError(f"Invalid SubtitleFormat: {value!r}. Valid: {[m.value for m in cls]}")
|
||||
|
||||
|
||||
class InputFormat(str, Enum):
|
||||
"""Input file format."""
|
||||
EPUB = "epub"
|
||||
PDF = "pdf"
|
||||
TXT = "txt"
|
||||
MD = "md"
|
||||
SRT = "srt"
|
||||
ASS = "ass"
|
||||
VTT = "vtt"
|
||||
|
||||
@property
|
||||
def is_book(self) -> bool:
|
||||
"""True for book/document formats (epub, pdf, txt, md)."""
|
||||
return self in (self.EPUB, self.PDF, self.TXT, self.MD)
|
||||
|
||||
@property
|
||||
def is_subtitle(self) -> bool:
|
||||
"""True for subtitle formats (srt, ass, vtt)."""
|
||||
return self in (self.SRT, self.ASS, self.VTT)
|
||||
|
||||
@property
|
||||
def dot_ext(self) -> str:
|
||||
"""File extension with dot: '.epub', '.srt', etc."""
|
||||
return f".{self.value}"
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path) -> InputFormat:
|
||||
"""Detect format from file path extension."""
|
||||
suffix = path.suffix.lower().lstrip(".")
|
||||
if suffix == "markdown":
|
||||
return cls.MD
|
||||
try:
|
||||
return cls(suffix)
|
||||
except ValueError:
|
||||
raise ValueError(f"Unsupported input format: {path.suffix!r}. Supported: {[m.value for m in cls]}")
|
||||
|
||||
|
||||
class Language(str, Enum):
|
||||
"""TTS language code (ISO 639-1 with region where needed).
|
||||
|
||||
Each engine (Kokoro, Supertonic) maps these to its own
|
||||
internal language identifiers.
|
||||
"""
|
||||
EN_US = "en-US"
|
||||
EN_GB = "en-GB"
|
||||
ES = "es"
|
||||
FR = "fr"
|
||||
HI = "hi"
|
||||
IT = "it"
|
||||
JA = "ja"
|
||||
PT_BR = "pt-BR"
|
||||
ZH = "zh"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Human-readable language name."""
|
||||
_names = {
|
||||
"en-US": "American English",
|
||||
"en-GB": "British English",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"hi": "Hindi",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
"zh": "Mandarin Chinese",
|
||||
}
|
||||
return _names[self.value]
|
||||
|
||||
@property
|
||||
def is_cjk(self) -> bool:
|
||||
"""True for CJK languages (Chinese, Japanese)."""
|
||||
return self in (self.ZH, self.JA)
|
||||
|
||||
@property
|
||||
def supports_subtitle_tokens(self) -> bool:
|
||||
"""True if this language generates timestamped tokens for subtitles."""
|
||||
return self in (self.EN_US, self.EN_GB)
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> Language:
|
||||
"""Parse from user input: ISO code, case-insensitive."""
|
||||
if isinstance(value, Language):
|
||||
return value
|
||||
normalized = value.strip()
|
||||
for member in cls:
|
||||
if member.value.lower() == normalized.lower():
|
||||
return member
|
||||
raise ValueError(f"Invalid Language: {value!r}. Valid: {[m.value for m in cls]}")
|
||||
@@ -76,7 +76,7 @@ def sanitize_filename_for_chapter(title: str, index: int, max_len: int = 80) ->
|
||||
return f"{index:02d}_{sanitized}"
|
||||
|
||||
|
||||
def sanitize_output_stem(name: str) -> str:
|
||||
def sanitize_output_stem(name: str, index: int = 0) -> str:
|
||||
base = Path(name or "").stem
|
||||
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
|
||||
return sanitized or "output"
|
||||
@@ -99,6 +99,9 @@ def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlin
|
||||
chapter.text = newline_regex.sub(" ", chapter.text)
|
||||
|
||||
|
||||
from abogen.domain.enums import SaveMode
|
||||
|
||||
|
||||
def resolve_output_directory(
|
||||
*,
|
||||
save_mode: str,
|
||||
@@ -108,13 +111,13 @@ def resolve_output_directory(
|
||||
user_output_path: Optional[Path],
|
||||
user_cache_outputs: Optional[Path],
|
||||
) -> Path:
|
||||
if save_mode == "Save to Desktop" and desktop_dir:
|
||||
if save_mode in (SaveMode.SAVE_TO_DESKTOP, "Save to Desktop") and desktop_dir:
|
||||
return desktop_dir
|
||||
if save_mode == "Save next to input file":
|
||||
if save_mode in (SaveMode.SAVE_NEXT_TO_INPUT, "Save next to input file"):
|
||||
return stored_path.parent
|
||||
if save_mode == "Choose output folder" and output_folder:
|
||||
if save_mode in (SaveMode.CHOOSE_OUTPUT_FOLDER, "Choose output folder") and output_folder:
|
||||
return Path(output_folder)
|
||||
if save_mode == "Use default save location" and user_output_path:
|
||||
if save_mode in (SaveMode.DEFAULT_OUTPUT, "Use default save location") and user_output_path:
|
||||
return user_output_path
|
||||
return user_cache_outputs or Path(".")
|
||||
|
||||
|
||||
@@ -9,9 +9,23 @@ from __future__ import annotations
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from abogen.domain.device import select_device
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.domain.voice_resolution import initialize_voice_cache
|
||||
from abogen.tts_plugin.utils import create_pipeline, is_plugin_registered
|
||||
|
||||
# Kokoro-specific language mapping (engine's responsibility)
|
||||
_KOKORO_LANG_MAP = {
|
||||
Language.EN_US: "a",
|
||||
Language.EN_GB: "b",
|
||||
Language.ES: "e",
|
||||
Language.FR: "f",
|
||||
Language.HI: "h",
|
||||
Language.IT: "i",
|
||||
Language.JA: "j",
|
||||
Language.PT_BR: "p",
|
||||
Language.ZH: "z",
|
||||
}
|
||||
|
||||
|
||||
def resolve_device(use_gpu: bool) -> str:
|
||||
"""Determine compute device from job and global config flags."""
|
||||
@@ -36,11 +50,18 @@ def create_pipeline_for_job(
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
# Convert Language enum to Kokoro single-letter code
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = Language.EN_US # fallback for unknown languages
|
||||
kokoro_code = _KOKORO_LANG_MAP.get(lang, "a")
|
||||
|
||||
if provider == "supertonic":
|
||||
return create_pipeline("supertonic")
|
||||
|
||||
device = resolve_device(use_gpu)
|
||||
return create_pipeline("kokoro", lang_code=language, device=device)
|
||||
return create_pipeline("kokoro", lang_code=kokoro_code, device=device)
|
||||
|
||||
|
||||
def dispose_pipelines(pipelines: Dict[str, Any]) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
"""Unified split pattern logic extracted from 3 copies."""
|
||||
import re
|
||||
|
||||
from abogen.domain.enums import Language, SubtitleMode
|
||||
|
||||
PUNCTUATION_SENTENCE = r".!?。!?"
|
||||
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
|
||||
@@ -18,23 +19,32 @@ def get_split_pattern(language: str, subtitle_mode: str) -> str:
|
||||
Returns:
|
||||
Split pattern string
|
||||
"""
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = None # unknown language — treat as non-English, non-CJK
|
||||
try:
|
||||
mode = SubtitleMode.from_str(subtitle_mode) if not isinstance(subtitle_mode, SubtitleMode) else subtitle_mode
|
||||
except ValueError:
|
||||
mode = SubtitleMode.DISABLED
|
||||
|
||||
# For English, always use newline splitting only
|
||||
if language in ("a", "b"):
|
||||
if lang in (Language.EN_US, Language.EN_GB):
|
||||
return "\n"
|
||||
|
||||
# Determine spacing pattern based on language
|
||||
spacing = r"\s*" if language in ("z", "j") else r"\s+"
|
||||
spacing = r"\s*" if lang and lang.is_cjk else r"\s+"
|
||||
|
||||
# For CJK languages, when subtitle mode is Disabled or Line, prefer
|
||||
# punctuation-based splitting instead of plain newline splitting.
|
||||
if subtitle_mode in ("Disabled", "Line") and language in ("z", "j"):
|
||||
if mode in (SubtitleMode.DISABLED, SubtitleMode.LINE) and lang and lang.is_cjk:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
|
||||
if subtitle_mode == "Line":
|
||||
if mode == SubtitleMode.LINE:
|
||||
return "\n"
|
||||
elif subtitle_mode == "Sentence":
|
||||
elif mode == SubtitleMode.SENTENCE:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
elif subtitle_mode == "Sentence + Comma":
|
||||
elif mode == SubtitleMode.SENTENCE_COMMA:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
|
||||
else:
|
||||
return r"\n+"
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from abogen.domain.enums import Language, SubtitleMode
|
||||
|
||||
|
||||
# Punctuation constants for sentence splitting
|
||||
PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ??
|
||||
@@ -50,17 +52,17 @@ def process_subtitle_tokens(
|
||||
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
||||
use_spacy_for_english = (
|
||||
use_spacy_segmentation
|
||||
and subtitle_mode not in ["Disabled", "Line"]
|
||||
and lang_code in ["a", "b"]
|
||||
and subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||
and subtitle_mode not in [SubtitleMode.DISABLED, SubtitleMode.LINE]
|
||||
and lang_code in [Language.EN_US, Language.EN_GB]
|
||||
and subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA]
|
||||
)
|
||||
|
||||
if subtitle_mode == "Sentence + Highlighting":
|
||||
if subtitle_mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||
_process_karaoke_highlighting(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
|
||||
)
|
||||
elif subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]:
|
||||
if use_spacy_for_english and subtitle_mode != "Line":
|
||||
elif subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA, SubtitleMode.LINE]:
|
||||
if use_spacy_for_english and subtitle_mode != SubtitleMode.LINE:
|
||||
_process_spacy_sentences(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, lang_code, fallback_end_time
|
||||
@@ -176,7 +178,7 @@ def _process_spacy_sentences(
|
||||
sentence_boundaries = [sent.end_char for sent in doc.sents]
|
||||
|
||||
# For "Sentence + Comma" mode, also split on commas
|
||||
if subtitle_mode == "Sentence + Comma":
|
||||
if subtitle_mode == SubtitleMode.SENTENCE_COMMA:
|
||||
comma_positions = [
|
||||
i + 1 for i, c in enumerate(full_text) if c == ","
|
||||
]
|
||||
@@ -242,9 +244,9 @@ def _process_regex_sentences(
|
||||
) -> None:
|
||||
"""Process tokens using regex for sentence boundary detection."""
|
||||
# Define separator pattern based on mode
|
||||
if subtitle_mode == "Line":
|
||||
if subtitle_mode == SubtitleMode.LINE:
|
||||
separator = r"\n"
|
||||
elif subtitle_mode == "Sentence":
|
||||
elif subtitle_mode == SubtitleMode.SENTENCE:
|
||||
# Use punctuation without comma
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
|
||||
else: # Sentence + Comma
|
||||
|
||||
@@ -32,7 +32,11 @@ class VoiceCache:
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached voices."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
def keys(self):
|
||||
"""Return cached voice specs."""
|
||||
return self._cache.keys()
|
||||
|
||||
def __contains__(self, voice_spec: str) -> bool:
|
||||
return self.contains(voice_spec)
|
||||
|
||||
|
||||
@@ -6,23 +6,10 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, TextIO
|
||||
|
||||
from abogen.domain.enums import SubtitleFormat, SubtitleMode
|
||||
from abogen.subtitle_utils import clean_subtitle_text
|
||||
|
||||
|
||||
class SubtitleFormat(Enum):
|
||||
SRT = "srt"
|
||||
ASS = "ass"
|
||||
VTT = "vtt"
|
||||
|
||||
|
||||
class SubtitleMode(Enum):
|
||||
DISABLED = "Disabled"
|
||||
LINE = "Line"
|
||||
SENTENCE = "Sentence"
|
||||
SENTENCE_COMMA = "Sentence + Comma"
|
||||
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
|
||||
|
||||
|
||||
class SubtitleAlignment(Enum):
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
|
||||
+30
-16
@@ -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_engine import synthesize_text, SegmentStats, SegmentInfo
|
||||
from abogen.domain.conversion_engine import synthesize_text, SynthParams, SegmentStats, SegmentInfo
|
||||
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
||||
from abogen.domain.audio_buffer import (
|
||||
create_silence,
|
||||
@@ -773,17 +773,20 @@ class ConversionThread(QThread):
|
||||
etr_start_time=self.etr_start_time,
|
||||
total_characters=self.total_char_count,
|
||||
)
|
||||
intro_synth = SynthParams(
|
||||
tts_context=self._tts_context,
|
||||
stats=intro_stats,
|
||||
check_cancel=lambda: self.cancel_requested,
|
||||
on_progress=lambda pct, etr: self.progress_updated.emit(pct, etr),
|
||||
audio_sink=merged_sink,
|
||||
)
|
||||
run_tts_segment_loop(
|
||||
text=intro_spec.text,
|
||||
params=intro_synth,
|
||||
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
|
||||
@@ -1016,18 +1019,26 @@ class ConversionThread(QThread):
|
||||
total_characters=self.total_char_count,
|
||||
)
|
||||
|
||||
synth_params = SynthParams(
|
||||
tts_context=self._tts_context,
|
||||
stats=stats,
|
||||
check_cancel=_qt_check_cancel,
|
||||
on_progress=_qt_on_progress,
|
||||
audio_sink=merged_sink if merge_chapters_at_end else None,
|
||||
subtitle_mode=self.subtitle_mode,
|
||||
max_subtitle_words=self.max_subtitle_words,
|
||||
lang_code=self.lang_code,
|
||||
use_spacy_segmentation=getattr(self, "use_spacy_segmentation", False),
|
||||
)
|
||||
|
||||
try:
|
||||
synthesize_text(
|
||||
text=text_segment,
|
||||
tts_context=self._tts_context,
|
||||
params=synth_params,
|
||||
backend=self.backend,
|
||||
voice=loaded_voice,
|
||||
speed=self.speed,
|
||||
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,
|
||||
split_pattern_override=active_split_pattern,
|
||||
)
|
||||
@@ -1098,17 +1109,20 @@ class ConversionThread(QThread):
|
||||
etr_start_time=self.etr_start_time,
|
||||
total_characters=self.total_char_count,
|
||||
)
|
||||
outro_synth = SynthParams(
|
||||
tts_context=self._tts_context,
|
||||
stats=outro_stats,
|
||||
check_cancel=lambda: self.cancel_requested,
|
||||
on_progress=lambda pct, etr: self.progress_updated.emit(pct, etr),
|
||||
audio_sink=merged_sink,
|
||||
)
|
||||
run_tts_segment_loop(
|
||||
text=outro_spec.text,
|
||||
params=outro_synth,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""PyQt adapter: ConversionThread -> ConversionRequest.
|
||||
|
||||
Converts a PyQt ConversionThread into a ConversionRequest that the application layer can process.
|
||||
This adapter is the bridge between the PyQt layer and the application/domain layer.
|
||||
|
||||
The adapter is responsible for:
|
||||
- Mapping ConversionThread fields to ConversionRequest fields
|
||||
- Handling UI-specific state (signals, dialogs, cancellation)
|
||||
- Providing PipelineProvider and VoiceResolver implementations
|
||||
|
||||
Subtitle file/timestamp special paths remain in ConversionThread.run() early return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice
|
||||
|
||||
|
||||
def build_conversion_request_from_thread(thread: Any) -> ConversionRequest:
|
||||
"""Convert a PyQt ConversionThread into a ConversionRequest.
|
||||
|
||||
This is the primary function that maps thread fields to ConversionRequest.
|
||||
All fields are copied — the request is independent of the thread.
|
||||
|
||||
Args:
|
||||
thread: PyQt ConversionThread instance
|
||||
|
||||
Returns:
|
||||
ConversionRequest with all thread data mapped
|
||||
"""
|
||||
# Determine source path
|
||||
source_path = None
|
||||
is_direct_text = getattr(thread, "is_direct_text", False)
|
||||
if not is_direct_text and thread.file_name:
|
||||
source_path = Path(thread.file_name)
|
||||
|
||||
# Determine original filename
|
||||
original_filename = ""
|
||||
if getattr(thread, "from_queue", False):
|
||||
base_path = getattr(thread, "save_base_path", None) or thread.file_name
|
||||
else:
|
||||
base_path = getattr(thread, "display_path", None) or thread.file_name
|
||||
|
||||
if base_path:
|
||||
original_filename = os.path.basename(base_path)
|
||||
|
||||
# Determine output folder
|
||||
output_folder = None
|
||||
if thread.output_folder:
|
||||
output_folder = Path(thread.output_folder)
|
||||
|
||||
return ConversionRequest(
|
||||
# Source
|
||||
source_path=source_path,
|
||||
direct_text=thread.file_name if is_direct_text else None,
|
||||
original_filename=original_filename,
|
||||
# TTS Settings
|
||||
language=thread.lang_code,
|
||||
tts_provider="kokoro", # PyQt uses Kokoro by default
|
||||
voice=thread.voice,
|
||||
voice_profile=getattr(thread, "voice_profile", None),
|
||||
speed=thread.speed,
|
||||
use_gpu=thread.use_gpu,
|
||||
supertonic_total_steps=getattr(thread, "supertonic_total_steps", 5),
|
||||
# Output Format
|
||||
output_format=thread.output_format,
|
||||
subtitle_mode=thread.subtitle_mode,
|
||||
subtitle_format=getattr(thread, "subtitle_format", "srt"),
|
||||
max_subtitle_words=getattr(thread, "max_subtitle_words", 50),
|
||||
# Save Options
|
||||
save_mode=thread.save_option,
|
||||
output_folder=output_folder,
|
||||
save_chapters_separately=getattr(thread, "save_chapters_separately", False),
|
||||
merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True),
|
||||
separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"),
|
||||
save_as_project=getattr(thread, "save_as_project", False),
|
||||
# Timing
|
||||
silence_between_chapters=getattr(thread, "silence_duration", 2.0),
|
||||
chapter_intro_delay=getattr(thread, "chapter_intro_delay", 0.0),
|
||||
# Content Processing
|
||||
replace_single_newlines=getattr(thread, "replace_single_newlines", False),
|
||||
read_title_intro=getattr(thread, "read_title_intro", False),
|
||||
read_closing_outro=getattr(thread, "read_closing_outro", True),
|
||||
auto_prefix_chapter_titles=getattr(thread, "auto_prefix_chapter_titles", True),
|
||||
normalize_chapter_opening_caps=getattr(thread, "normalize_chapter_opening_caps", False),
|
||||
# Pronunciation / Normalization
|
||||
pronunciation_overrides=getattr(thread, "pronunciation_overrides", []) or [],
|
||||
manual_overrides=getattr(thread, "manual_overrides", []) or [],
|
||||
heteronym_overrides=getattr(thread, "heteronym_overrides", []) or [],
|
||||
normalization_overrides=getattr(thread, "normalization_overrides", None),
|
||||
# Chapter/Chunk Configuration
|
||||
chapter_overrides=[], # PyQt doesn't use chapter overrides from GUI
|
||||
chunks=[], # PyQt doesn't use chunks from GUI
|
||||
chunk_level="paragraph",
|
||||
speaker_mode="single",
|
||||
speakers={},
|
||||
# Metadata
|
||||
metadata_tags=getattr(thread, "metadata_tags", {}) or {},
|
||||
# Artifacts
|
||||
cover_image_path=getattr(thread, "cover_image_path", None),
|
||||
cover_image_mime=getattr(thread, "cover_image_mime", None),
|
||||
generate_epub3=getattr(thread, "generate_epub3", False),
|
||||
)
|
||||
|
||||
|
||||
class PyQtEvents:
|
||||
"""PyQt implementation of ConversionEvents protocol.
|
||||
|
||||
Wraps a ConversionThread to provide logging, progress, and cancellation.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: Any):
|
||||
self._thread = thread
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message via signal."""
|
||||
self._thread.log_updated.emit((message, _level_to_color(level)))
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
"""Update progress via signal."""
|
||||
self._thread.progress_updated.emit(pct, etr)
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
"""Check if conversion was cancelled.
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If cancellation was requested
|
||||
"""
|
||||
if self._thread.cancel_requested:
|
||||
raise ConversionCancelled("Conversion cancelled by user")
|
||||
|
||||
|
||||
class PyQtPipelineProvider:
|
||||
"""PyQt implementation of PipelineProvider protocol.
|
||||
|
||||
Wraps the existing backend from ConversionThread.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: Any):
|
||||
self._backend = backend
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> Any:
|
||||
"""Get a TTS backend instance.
|
||||
|
||||
For PyQt, this returns the pre-initialized backend.
|
||||
"""
|
||||
return self._backend
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all backend resources."""
|
||||
pass # PyQt manages backend lifecycle in thread
|
||||
|
||||
|
||||
class PyQtVoiceResolver:
|
||||
"""PyQt implementation of VoiceResolver protocol.
|
||||
|
||||
Wraps load_voice_cached from the ConversionThread.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: Any):
|
||||
self._thread = thread
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
"""Resolve a voice spec into a loaded voice."""
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
|
||||
# Use thread's load_voice_cached method
|
||||
loaded_voice = self._thread.load_voice_cached(voice_spec, self._thread.backend)
|
||||
|
||||
return ResolvedVoice(
|
||||
provider="kokoro",
|
||||
resolved_spec=voice_spec,
|
||||
voice=loaded_voice,
|
||||
speed=self._thread.speed,
|
||||
supertonic_steps=getattr(self._thread, "supertonic_total_steps", 5),
|
||||
)
|
||||
|
||||
|
||||
def _level_to_color(level: str) -> str:
|
||||
"""Map log level to PyQt color string."""
|
||||
colors = {
|
||||
"info": "grey",
|
||||
"warning": "orange",
|
||||
"error": "red",
|
||||
"debug": "grey",
|
||||
}
|
||||
return colors.get(level, "grey")
|
||||
+20
-11
@@ -2,21 +2,23 @@
|
||||
Lazy-loaded spaCy utilities for sentence segmentation.
|
||||
"""
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
|
||||
# Cached spaCy module and models (lazy loaded)
|
||||
_spacy = None
|
||||
_nlp_cache = {}
|
||||
|
||||
# Language code to spaCy model mapping
|
||||
SPACY_MODELS = {
|
||||
"a": "en_core_web_sm", # American English
|
||||
"b": "en_core_web_sm", # British English
|
||||
"e": "es_core_news_sm", # Spanish
|
||||
"f": "fr_core_news_sm", # French
|
||||
"i": "it_core_news_sm", # Italian
|
||||
"p": "pt_core_news_sm", # Brazilian Portuguese
|
||||
"z": "zh_core_web_sm", # Mandarin Chinese
|
||||
"j": "ja_core_news_sm", # Japanese
|
||||
"h": "xx_sent_ud_sm", # Hindi (multi-language model)
|
||||
Language.EN_US: "en_core_web_sm",
|
||||
Language.EN_GB: "en_core_web_sm",
|
||||
Language.ES: "es_core_news_sm",
|
||||
Language.FR: "fr_core_news_sm",
|
||||
Language.IT: "it_core_news_sm",
|
||||
Language.PT_BR: "pt_core_news_sm",
|
||||
Language.ZH: "zh_core_web_sm",
|
||||
Language.JA: "ja_core_news_sm",
|
||||
Language.HI: "xx_sent_ud_sm",
|
||||
}
|
||||
|
||||
|
||||
@@ -36,10 +38,9 @@ def _load_spacy():
|
||||
def get_spacy_model(lang_code, log_callback=None):
|
||||
"""
|
||||
Get or load a spaCy model for the given language code.
|
||||
Downloads the model automatically if not available.
|
||||
|
||||
Args:
|
||||
lang_code: Language code (a, b, e, f, etc.)
|
||||
lang_code: Language code or Language enum (e.g., "a", "en-US", Language.EN_US)
|
||||
log_callback: Optional function to log messages
|
||||
|
||||
Returns:
|
||||
@@ -58,6 +59,14 @@ def get_spacy_model(lang_code, log_callback=None):
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
# Normalize to Language enum
|
||||
if not isinstance(lang_code, Language):
|
||||
try:
|
||||
lang_code = Language.from_str(lang_code)
|
||||
except ValueError:
|
||||
log(f"\nspaCy: Unknown language '{lang_code}'...")
|
||||
return None
|
||||
|
||||
# Check if model is cached
|
||||
if lang_code in _nlp_cache:
|
||||
return _nlp_cache[lang_code]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""WebUI adapter: Job -> ConversionRequest.
|
||||
|
||||
Converts a WebUI Job into a ConversionRequest that the application layer can process.
|
||||
This adapter is the bridge between the WebUI layer and the application/domain layer.
|
||||
|
||||
The adapter is responsible for:
|
||||
- Mapping Job fields to ConversionRequest fields
|
||||
- Handling UI-specific state (logs, progress, cancellation)
|
||||
- Providing PipelineProvider and VoiceResolver implementations
|
||||
|
||||
All conversions happen through this adapter — the application layer
|
||||
never accesses Job directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice
|
||||
|
||||
|
||||
def build_conversion_request_from_job(job: Any) -> ConversionRequest:
|
||||
"""Convert a WebUI Job into a ConversionRequest.
|
||||
|
||||
This is the primary function that maps Job fields to ConversionRequest.
|
||||
All fields are copied — the request is independent of the Job.
|
||||
|
||||
Args:
|
||||
job: WebUI Job instance
|
||||
|
||||
Returns:
|
||||
ConversionRequest with all Job data mapped
|
||||
"""
|
||||
return ConversionRequest(
|
||||
# Source
|
||||
source_path=Path(job.stored_path) if job.stored_path else None,
|
||||
original_filename=job.original_filename,
|
||||
# TTS Settings
|
||||
language=job.language,
|
||||
tts_provider=job.tts_provider,
|
||||
voice=job.voice,
|
||||
voice_profile=job.voice_profile,
|
||||
speed=job.speed,
|
||||
use_gpu=job.use_gpu,
|
||||
supertonic_total_steps=job.supertonic_total_steps,
|
||||
# Output Format
|
||||
output_format=job.output_format,
|
||||
subtitle_mode=job.subtitle_mode,
|
||||
subtitle_format=job.subtitle_format,
|
||||
max_subtitle_words=job.max_subtitle_words,
|
||||
# Save Options
|
||||
save_mode=job.save_mode,
|
||||
output_folder=Path(job.output_folder) if job.output_folder else None,
|
||||
save_chapters_separately=job.save_chapters_separately,
|
||||
merge_chapters_at_end=job.merge_chapters_at_end,
|
||||
separate_chapters_format=job.separate_chapters_format,
|
||||
save_as_project=job.save_as_project,
|
||||
# Timing
|
||||
silence_between_chapters=job.silence_between_chapters,
|
||||
chapter_intro_delay=job.chapter_intro_delay,
|
||||
# Content Processing
|
||||
replace_single_newlines=job.replace_single_newlines,
|
||||
read_title_intro=job.read_title_intro,
|
||||
read_closing_outro=job.read_closing_outro,
|
||||
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
|
||||
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
|
||||
# Pronunciation / Normalization
|
||||
pronunciation_overrides=job.pronunciation_overrides or [],
|
||||
manual_overrides=job.manual_overrides or [],
|
||||
heteronym_overrides=job.heteronym_overrides or [],
|
||||
normalization_overrides=job.normalization_overrides or {},
|
||||
# Chapter/Chunk Configuration
|
||||
chapter_overrides=job.chapters or [],
|
||||
chunks=job.chunks or [],
|
||||
chunk_level=job.chunk_level,
|
||||
speaker_mode=job.speaker_mode,
|
||||
speakers=job.speakers or {},
|
||||
# Metadata
|
||||
metadata_tags=job.metadata_tags or {},
|
||||
# Artifacts
|
||||
cover_image_path=Path(job.cover_image_path) if job.cover_image_path else None,
|
||||
cover_image_mime=job.cover_image_mime,
|
||||
generate_epub3=job.generate_epub3,
|
||||
)
|
||||
|
||||
|
||||
class WebJobEvents:
|
||||
"""WebUI implementation of ConversionEvents protocol.
|
||||
|
||||
Wraps a Job to provide logging, progress, and cancellation.
|
||||
"""
|
||||
|
||||
def __init__(self, job: Any):
|
||||
self._job = job
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message to the Job."""
|
||||
self._job.add_log(message, level=level)
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
"""Update progress on the Job."""
|
||||
self._job.progress = pct / 100.0
|
||||
self._job.etr_str = etr
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
"""Check if the Job was cancelled.
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If cancellation was requested
|
||||
"""
|
||||
if self._job.cancel_requested:
|
||||
raise ConversionCancelled("Job cancelled by user")
|
||||
|
||||
|
||||
class WebPipelineProvider:
|
||||
"""WebUI implementation of PipelineProvider protocol.
|
||||
|
||||
Wraps PipelinePool to provide TTS backends.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline_pool: Any):
|
||||
self._pool = pipeline_pool
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> Any:
|
||||
"""Get a TTS backend instance."""
|
||||
return self._pool.get(provider, language, use_gpu)
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all backend resources."""
|
||||
self._pool.dispose_all()
|
||||
|
||||
|
||||
class WebVoiceResolver:
|
||||
"""WebUI implementation of VoiceResolver protocol.
|
||||
|
||||
Wraps the voice resolution logic from conversion_runner.py.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolve_fn: Callable[[str], tuple[str, str, Any, Optional[float], Optional[int]]],
|
||||
):
|
||||
"""Initialize with a voice resolution function.
|
||||
|
||||
Args:
|
||||
resolve_fn: Function that takes a voice_spec and returns
|
||||
(provider, resolved_spec, voice_choice, speed, steps)
|
||||
"""
|
||||
self._resolve_fn = resolve_fn
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
"""Resolve a voice spec into a loaded voice."""
|
||||
provider, resolved_spec, voice, speed, steps = self._resolve_fn(voice_spec)
|
||||
return ResolvedVoice(
|
||||
provider=provider,
|
||||
resolved_spec=resolved_spec,
|
||||
voice=voice,
|
||||
speed=speed or 1.0,
|
||||
supertonic_steps=steps or 5,
|
||||
)
|
||||
@@ -117,7 +117,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_engine import synthesize_text, process_and_write_subtitles, SegmentStats
|
||||
from abogen.domain.conversion_engine import synthesize_text, SynthParams, process_and_write_subtitles, SegmentStats
|
||||
from abogen.domain.voice_loader import VoiceCache, resolve_voice
|
||||
from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target
|
||||
|
||||
@@ -462,23 +462,27 @@ def run_conversion_job(job: Job) -> None:
|
||||
def _preview(text: str) -> None:
|
||||
job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or '—'}: {text[:80]}")
|
||||
|
||||
local_segments, accumulated_tokens = synthesize_text(
|
||||
text=source_text,
|
||||
synth_params = SynthParams(
|
||||
tts_context=tts_context,
|
||||
backend=backend,
|
||||
voice=resolved_voice,
|
||||
speed=effective_speed,
|
||||
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"),
|
||||
)
|
||||
|
||||
local_segments, accumulated_tokens = synthesize_text(
|
||||
text=source_text,
|
||||
params=synth_params,
|
||||
backend=backend,
|
||||
voice=resolved_voice,
|
||||
speed=effective_speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=_preview,
|
||||
)
|
||||
current_time = stats.current_time
|
||||
|
||||
if subtitle_writer and audio_sink and accumulated_tokens:
|
||||
|
||||
@@ -7,8 +7,22 @@ from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.domain.device import select_device as _select_device
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
|
||||
# Kokoro-specific language mapping (engine's responsibility)
|
||||
_KOKORO_LANG_MAP = {
|
||||
Language.EN_US: "a",
|
||||
Language.EN_GB: "b",
|
||||
Language.ES: "e",
|
||||
Language.FR: "f",
|
||||
Language.HI: "h",
|
||||
Language.IT: "i",
|
||||
Language.JA: "j",
|
||||
Language.PT_BR: "p",
|
||||
Language.ZH: "z",
|
||||
}
|
||||
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
@@ -45,14 +59,21 @@ def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
key = (language, device)
|
||||
# Convert Language enum to Kokoro single-letter code
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = Language.EN_US
|
||||
kokoro_code = _KOKORO_LANG_MAP.get(lang, "a")
|
||||
|
||||
key = (kokoro_code, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
||||
pipeline = create_pipeline("kokoro", lang_code=kokoro_code, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Tests for conversion_service.py, output_layout_service.py, and executor gaps.
|
||||
|
||||
Covers the remaining untested code in the application layer.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
OutputLayout,
|
||||
SegmentPlan,
|
||||
)
|
||||
from abogen.application.conversion_ports import ResolvedVoice
|
||||
from abogen.domain.normalization import TTSContext
|
||||
|
||||
|
||||
# ─── Fake implementations (shared with executor tests) ─────────────
|
||||
|
||||
|
||||
class FakeAudioSink:
|
||||
def __init__(self):
|
||||
self.written: List[np.ndarray] = []
|
||||
self.closed = False
|
||||
|
||||
def write(self, audio: np.ndarray) -> None:
|
||||
self.written.append(audio)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self):
|
||||
self.synthesized: List[str] = []
|
||||
|
||||
def __call__(self, text: str, *, voice: Any, speed: float = 1.0, split_pattern: str = "") -> List:
|
||||
self.synthesized.append(text)
|
||||
|
||||
class FakeSegment:
|
||||
def __init__(self, text: str):
|
||||
self.graphemes = text
|
||||
self.audio = np.zeros(2400, dtype=np.float32)
|
||||
self.tokens = []
|
||||
|
||||
return [FakeSegment(text)]
|
||||
|
||||
|
||||
class FakeEvents:
|
||||
def __init__(self):
|
||||
self.logs = []
|
||||
self.progress_calls = []
|
||||
self.cancelled = False
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
self.logs.append((message, level))
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
self.progress_calls.append((pct, etr))
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
if self.cancelled:
|
||||
raise RuntimeError("Conversion cancelled")
|
||||
|
||||
|
||||
class FakePipelineProvider:
|
||||
def __init__(self):
|
||||
self.backends = {}
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> FakeBackend:
|
||||
key = f"{provider}:{language}"
|
||||
if key not in self.backends:
|
||||
self.backends[key] = FakeBackend()
|
||||
return self.backends[key]
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
self.backends.clear()
|
||||
|
||||
|
||||
class FakeVoiceResolver:
|
||||
def __init__(self):
|
||||
self.resolved_specs = []
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
self.resolved_specs.append(voice_spec)
|
||||
return ResolvedVoice(
|
||||
provider="kokoro",
|
||||
resolved_spec=voice_spec,
|
||||
voice=voice_spec,
|
||||
speed=1.0,
|
||||
supertonic_steps=5,
|
||||
)
|
||||
|
||||
|
||||
# ─── Tests for conversion_service.py ───────────────────────────────
|
||||
|
||||
|
||||
class TestConversionService:
|
||||
"""Tests for the ConversionService.run_conversion function."""
|
||||
|
||||
def test_simple_conversion(self):
|
||||
"""Simple text conversion through the service."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello world",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
result = run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
assert result is not None
|
||||
assert result.audio_path is not None
|
||||
assert result.audio_path.exists()
|
||||
|
||||
def test_service_logs_pipeline_preparation(self):
|
||||
"""Service logs pipeline preparation step."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
result = run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
log_messages = [msg for msg, _ in events.logs]
|
||||
assert any("Preparing conversion pipeline" in msg for msg in log_messages)
|
||||
assert any("Building conversion plan" in msg for msg in log_messages)
|
||||
assert any("Starting conversion" in msg for msg in log_messages)
|
||||
assert any("Conversion complete" in msg for msg in log_messages)
|
||||
|
||||
def test_service_handles_cancellation(self):
|
||||
"""Service propagates cancellation from events."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
events = FakeEvents()
|
||||
events.cancelled = True
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Conversion cancelled"):
|
||||
run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
def test_service_handles_empty_text(self):
|
||||
"""Service raises ValueError for empty text."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
req = ConversionRequest(direct_text="", voice="M1")
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
with pytest.raises(ValueError, match="No text content"):
|
||||
run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
def test_service_multi_chapter(self):
|
||||
"""Service handles multi-chapter conversion."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
result = run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
assert result.total_chapters == 2
|
||||
|
||||
def test_service_with_intro_outro(self):
|
||||
"""Service handles intro/outro."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Body text",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
read_title_intro=True,
|
||||
read_closing_outro=True,
|
||||
metadata_tags={"title": "Test Book", "author": "Author"},
|
||||
)
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
result = run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_service_error_logs_failure(self):
|
||||
"""Service logs error when conversion fails."""
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
req = ConversionRequest(direct_text="Hello", voice="M1")
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
|
||||
# Mock build_conversion_plan to raise an error
|
||||
with patch("abogen.application.conversion_service.build_conversion_plan", side_effect=RuntimeError("Test error")):
|
||||
with pytest.raises(RuntimeError, match="Test error"):
|
||||
run_conversion(req, events, pipeline, resolver)
|
||||
|
||||
log_messages = [msg for msg, _ in events.logs]
|
||||
assert any("Conversion failed" in msg for msg in log_messages)
|
||||
|
||||
|
||||
# ─── Tests for output_layout_service.py ─────────────────────────────
|
||||
|
||||
|
||||
class TestOutputLayoutService:
|
||||
"""Tests for the output_layout_service module."""
|
||||
|
||||
def test_resolve_output_layout_custom_folder(self):
|
||||
"""Output layout with custom folder."""
|
||||
from abogen.application.output_layout_service import resolve_output_layout
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
layout = resolve_output_layout(req)
|
||||
|
||||
assert layout.parent_dir == Path(tmpdir)
|
||||
assert layout.audio_dir == Path(tmpdir)
|
||||
|
||||
def test_resolve_output_layout_source_path(self):
|
||||
"""Output layout from source path."""
|
||||
from abogen.application.output_layout_service import resolve_output_layout
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
source = Path(tmpdir) / "test.txt"
|
||||
source.write_text("Hello")
|
||||
req = ConversionRequest(
|
||||
source_path=source,
|
||||
voice="M1",
|
||||
save_mode="save_next_to_input",
|
||||
)
|
||||
layout = resolve_output_layout(req)
|
||||
|
||||
assert layout.parent_dir == Path(tmpdir)
|
||||
|
||||
def test_resolve_output_layout_project(self):
|
||||
"""Output layout with save_as_project."""
|
||||
from abogen.application.output_layout_service import resolve_output_layout
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
save_as_project=True,
|
||||
original_filename="test.wav",
|
||||
)
|
||||
layout = resolve_output_layout(req)
|
||||
|
||||
assert layout.project_root is not None
|
||||
assert layout.audio_dir is not None
|
||||
|
||||
def test_resolve_merged_path(self):
|
||||
"""Resolve merged output path."""
|
||||
from abogen.application.output_layout_service import resolve_merged_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
layout = OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
)
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
original_filename="book.wav",
|
||||
output_format="wav",
|
||||
)
|
||||
path = resolve_merged_path(layout, req)
|
||||
|
||||
assert path.name == "book.wav"
|
||||
assert path.parent == Path(tmpdir)
|
||||
|
||||
def test_resolve_chapter_path(self):
|
||||
"""Resolve chapter output path."""
|
||||
from abogen.application.output_layout_service import resolve_chapter_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
layout = OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
)
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
separate_chapters_format="wav",
|
||||
)
|
||||
path = resolve_chapter_path(layout, req, "Chapter 1", 1)
|
||||
|
||||
assert "01" in path.name
|
||||
assert path.suffix == ".wav"
|
||||
|
||||
def test_resolve_chapter_path_empty_title(self):
|
||||
"""Resolve chapter path with empty title."""
|
||||
from abogen.application.output_layout_service import resolve_chapter_path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
layout = OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
)
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
separate_chapters_format="wav",
|
||||
)
|
||||
path = resolve_chapter_path(layout, req, "", 3)
|
||||
|
||||
assert "chapter_3" in path.name
|
||||
|
||||
def test_should_merge_output_m4b(self):
|
||||
"""m4b format forces merge."""
|
||||
from abogen.application.output_layout_service import should_merge_output
|
||||
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
output_format="m4b",
|
||||
merge_chapters_at_end=False,
|
||||
)
|
||||
assert should_merge_output(req) is True
|
||||
|
||||
def test_should_merge_output_no_separate(self):
|
||||
"""No separate chapters means merge."""
|
||||
from abogen.application.output_layout_service import should_merge_output
|
||||
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_chapters_separately=False,
|
||||
)
|
||||
assert should_merge_output(req) is True
|
||||
|
||||
def test_should_merge_output_separate_and_merge(self):
|
||||
"""Separate chapters + merge_at_end means merge."""
|
||||
from abogen.application.output_layout_service import should_merge_output
|
||||
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=True,
|
||||
)
|
||||
assert should_merge_output(req) is True
|
||||
|
||||
def test_should_merge_output_separate_no_merge(self):
|
||||
"""Separate chapters + no merge_at_end means no merge."""
|
||||
from abogen.application.output_layout_service import should_merge_output
|
||||
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=False,
|
||||
)
|
||||
assert should_merge_output(req) is False
|
||||
|
||||
|
||||
# ─── Tests for executor gaps ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExecutorGaps:
|
||||
"""Tests for uncovered executor branches."""
|
||||
|
||||
def test_executor_no_layout_raises(self):
|
||||
"""Executor raises ValueError without output_layout."""
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
req = ConversionRequest(direct_text="Hello", voice="M1")
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[],
|
||||
output_layout=None,
|
||||
)
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
with pytest.raises(ValueError, match="output_layout"):
|
||||
execute_conversion(plan, events, pipeline, resolver, tts_context)
|
||||
|
||||
def test_executor_m4b_forces_merge(self):
|
||||
"""Executor forces merge for m4b format."""
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
output_format="m4b",
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=False,
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Hello",
|
||||
segments=[
|
||||
SegmentPlan(text="Hello", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
||||
|
||||
assert result.audio_path is not None
|
||||
assert result.audio_path.suffix == ".m4b"
|
||||
|
||||
def test_executor_separate_chapters(self):
|
||||
"""Executor creates separate chapter files."""
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=True,
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="Chapter 1",
|
||||
original_title="Chapter 1",
|
||||
body_text="Text A",
|
||||
segments=[
|
||||
SegmentPlan(text="Text A", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
),
|
||||
ChapterPlan(
|
||||
index=2,
|
||||
title="Chapter 2",
|
||||
original_title="Chapter 2",
|
||||
body_text="Text B",
|
||||
segments=[
|
||||
SegmentPlan(text="Text B", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
),
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
||||
|
||||
assert len(result.chapter_paths) == 2
|
||||
|
||||
def test_executor_no_intro_outro(self):
|
||||
"""Executor works without intro/outro."""
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Hello",
|
||||
segments=[
|
||||
SegmentPlan(text="Hello", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
intro=None,
|
||||
outro=None,
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
||||
|
||||
assert result is not None
|
||||
log_messages = [msg for msg, _ in events.logs]
|
||||
assert not any("Title intro" in msg for msg in log_messages)
|
||||
assert not any("Closing outro" in msg for msg in log_messages)
|
||||
|
||||
def test_executor_voice_fallback_on_error(self):
|
||||
"""Executor falls back to base voice on resolution error."""
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
class FailingVoiceResolver:
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
if voice_spec == "F1":
|
||||
raise ValueError("Voice not found")
|
||||
return ResolvedVoice(
|
||||
provider="kokoro",
|
||||
resolved_spec=voice_spec,
|
||||
voice=voice_spec,
|
||||
speed=1.0,
|
||||
supertonic_steps=5,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Hello",
|
||||
segments=[
|
||||
SegmentPlan(text="Hello", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FailingVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_executor_silence_between_chapters(self):
|
||||
"""Executor adds silence between chapters."""
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
silence_between_chapters=1.0,
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="Ch1",
|
||||
original_title="Ch1",
|
||||
body_text="Text A",
|
||||
segments=[
|
||||
SegmentPlan(text="Text A", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
),
|
||||
ChapterPlan(
|
||||
index=2,
|
||||
title="Ch2",
|
||||
original_title="Ch2",
|
||||
body_text="Text B",
|
||||
segments=[
|
||||
SegmentPlan(text="Text B", voice_spec="M1", kind="body", source="chapter")
|
||||
],
|
||||
voice_spec="M1",
|
||||
),
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(plan, events, pipeline, resolver, tts_context)
|
||||
|
||||
assert result is not None
|
||||
# Check that audio was written (silence + speech)
|
||||
assert len(pipeline.backends) > 0
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Tests for conversion adapters (WebUI + PyQt).
|
||||
|
||||
Covers field mapping, event bridging, voice resolution, and cancellation behavior.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_ports import ResolvedVoice
|
||||
|
||||
|
||||
# ─── WebUI adapter tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebUIAdapter:
|
||||
"""Test WebUI conversion adapter field mapping."""
|
||||
|
||||
def _make_job(self, **overrides):
|
||||
"""Create a mock WebUI Job with default values."""
|
||||
defaults = dict(
|
||||
stored_path="/tmp/test.epub",
|
||||
original_filename="test.epub",
|
||||
language="a",
|
||||
tts_provider="kokoro",
|
||||
voice="M1",
|
||||
voice_profile=None,
|
||||
speed=1.0,
|
||||
use_gpu=False,
|
||||
supertonic_total_steps=5,
|
||||
output_format="wav",
|
||||
subtitle_mode="Disabled",
|
||||
subtitle_format="srt",
|
||||
max_subtitle_words=50,
|
||||
save_mode="save_next_to_input",
|
||||
output_folder=None,
|
||||
save_chapters_separately=False,
|
||||
merge_chapters_at_end=True,
|
||||
separate_chapters_format="wav",
|
||||
save_as_project=False,
|
||||
silence_between_chapters=2.0,
|
||||
chapter_intro_delay=0.0,
|
||||
replace_single_newlines=False,
|
||||
read_title_intro=False,
|
||||
read_closing_outro=False,
|
||||
auto_prefix_chapter_titles=True,
|
||||
normalize_chapter_opening_caps=False,
|
||||
pronunciation_overrides=[],
|
||||
manual_overrides=[],
|
||||
heteronym_overrides=[],
|
||||
normalization_overrides={},
|
||||
chapters=[],
|
||||
chunks=[],
|
||||
chunk_level="paragraph",
|
||||
speaker_mode="single",
|
||||
speakers={},
|
||||
metadata_tags={},
|
||||
cover_image_path=None,
|
||||
cover_image_mime=None,
|
||||
generate_epub3=False,
|
||||
cancel_requested=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def test_basic_field_mapping(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
|
||||
job = self._make_job()
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert isinstance(req, ConversionRequest)
|
||||
assert req.source_path == Path("/tmp/test.epub")
|
||||
assert req.original_filename == "test.epub"
|
||||
assert req.language == "a"
|
||||
assert req.voice == "M1"
|
||||
assert req.speed == 1.0
|
||||
assert req.output_format == "wav"
|
||||
|
||||
def test_optional_fields_mapped(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
|
||||
job = self._make_job(
|
||||
voice_profile="custom_profile",
|
||||
output_folder="/output",
|
||||
cover_image_path="/cover.jpg",
|
||||
cover_image_mime="image/jpeg",
|
||||
metadata_tags={"title": "Test"},
|
||||
)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert req.voice_profile == "custom_profile"
|
||||
assert req.output_folder == Path("/output")
|
||||
assert req.cover_image_path == Path("/cover.jpg")
|
||||
assert req.cover_image_mime == "image/jpeg"
|
||||
assert req.metadata_tags == {"title": "Test"}
|
||||
|
||||
def test_none_paths_result_in_none(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
|
||||
job = self._make_job(
|
||||
stored_path=None,
|
||||
output_folder=None,
|
||||
cover_image_path=None,
|
||||
)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert req.source_path is None
|
||||
assert req.output_folder is None
|
||||
assert req.cover_image_path is None
|
||||
|
||||
def test_chapter_overrides_mapped(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
|
||||
chapters = [{"title": "Ch1", "voice": "F1"}]
|
||||
job = self._make_job(chapters=chapters)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert req.chapter_overrides == chapters
|
||||
|
||||
def test_chunks_mapped(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
|
||||
chunks = [{"text": "Hello", "speaker": "A"}]
|
||||
job = self._make_job(chunks=chunks)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert req.chunks == chunks
|
||||
|
||||
def test_pronunciation_overrides_mapped(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
|
||||
job = self._make_job(
|
||||
pronunciation_overrides=["word=pron"],
|
||||
manual_overrides=["manual=override"],
|
||||
heteronym_overrides=["read=reed"],
|
||||
)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert req.pronunciation_overrides == ["word=pron"]
|
||||
assert req.manual_overrides == ["manual=override"]
|
||||
assert req.heteronym_overrides == ["read=reed"]
|
||||
|
||||
def test_none_defaults_handled(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode
|
||||
|
||||
job = self._make_job(
|
||||
language=None,
|
||||
voice=None,
|
||||
speed=None,
|
||||
output_format=None,
|
||||
subtitle_mode=None,
|
||||
save_mode=None,
|
||||
silence_between_chapters=None,
|
||||
chapter_intro_delay=None,
|
||||
supertonic_total_steps=None,
|
||||
max_subtitle_words=None,
|
||||
)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
# None values pass through adapter; ConversionRequest.__post_init__
|
||||
# applies defaults and clamping for numeric fields.
|
||||
assert req.language == Language.EN_US
|
||||
assert req.speed == 1.0
|
||||
assert req.output_format == OutputFormat.WAV
|
||||
assert req.subtitle_mode == SubtitleMode.DISABLED
|
||||
assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT
|
||||
assert req.silence_between_chapters == 2.0
|
||||
assert req.chapter_intro_delay == 0.0
|
||||
assert req.supertonic_total_steps == 5
|
||||
assert req.max_subtitle_words == 50
|
||||
|
||||
|
||||
class TestWebUIEvents:
|
||||
"""Test WebUI ConversionEvents implementation."""
|
||||
|
||||
def test_log_calls_add_log(self):
|
||||
from abogen.webui.conversion_adapter import WebJobEvents
|
||||
|
||||
job = SimpleNamespace(add_log=MagicMock())
|
||||
events = WebJobEvents(job)
|
||||
events.log("test message", level="info")
|
||||
|
||||
job.add_log.assert_called_once_with("test message", level="info")
|
||||
|
||||
def test_progress_updates_job(self):
|
||||
from abogen.webui.conversion_adapter import WebJobEvents
|
||||
|
||||
job = SimpleNamespace(progress=0.0, etr_str="")
|
||||
events = WebJobEvents(job)
|
||||
events.progress(50, "2m 30s")
|
||||
|
||||
assert job.progress == 0.5
|
||||
assert job.etr_str == "2m 30s"
|
||||
|
||||
def test_check_cancelled_raises(self):
|
||||
from abogen.webui.conversion_adapter import ConversionCancelled, WebJobEvents
|
||||
|
||||
job = SimpleNamespace(cancel_requested=True)
|
||||
events = WebJobEvents(job)
|
||||
|
||||
with pytest.raises(ConversionCancelled):
|
||||
events.check_cancelled()
|
||||
|
||||
def test_check_not_cancelled_passes(self):
|
||||
from abogen.webui.conversion_adapter import WebJobEvents
|
||||
|
||||
job = SimpleNamespace(cancel_requested=False)
|
||||
events = WebJobEvents(job)
|
||||
|
||||
events.check_cancelled() # Should not raise
|
||||
|
||||
|
||||
class TestWebUIPipelineProvider:
|
||||
"""Test WebUI PipelineProvider implementation."""
|
||||
|
||||
def test_get_returns_backend(self):
|
||||
from abogen.webui.conversion_adapter import WebPipelineProvider
|
||||
|
||||
backend = MagicMock()
|
||||
pool = SimpleNamespace(get=MagicMock(return_value=backend))
|
||||
provider = WebPipelineProvider(pool)
|
||||
|
||||
result = provider.get("kokoro", "a", False)
|
||||
|
||||
assert result is backend
|
||||
pool.get.assert_called_once_with("kokoro", "a", False)
|
||||
|
||||
|
||||
class TestWebUIVoiceResolver:
|
||||
"""Test WebUI VoiceResolver implementation."""
|
||||
|
||||
def test_resolve_returns_resolved_voice(self):
|
||||
from abogen.webui.conversion_adapter import WebVoiceResolver
|
||||
|
||||
def resolve_fn(spec):
|
||||
return ("kokoro", spec, "M1", 1.0, 5)
|
||||
|
||||
resolver = WebVoiceResolver(resolve_fn)
|
||||
result = resolver.resolve("M1")
|
||||
|
||||
assert isinstance(result, ResolvedVoice)
|
||||
assert result.provider == "kokoro"
|
||||
assert result.voice == "M1"
|
||||
assert result.speed == 1.0
|
||||
assert result.supertonic_steps == 5
|
||||
|
||||
def test_resolve_none_speed_defaults(self):
|
||||
from abogen.webui.conversion_adapter import WebVoiceResolver
|
||||
|
||||
def resolve_fn(spec):
|
||||
return ("kokoro", spec, "M1", None, None)
|
||||
|
||||
resolver = WebVoiceResolver(resolve_fn)
|
||||
result = resolver.resolve("M1")
|
||||
|
||||
assert result.speed == 1.0
|
||||
assert result.supertonic_steps == 5
|
||||
|
||||
|
||||
# ─── PyQt adapter tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPyQtAdapter:
|
||||
"""Test PyQt conversion adapter field mapping."""
|
||||
|
||||
def _make_thread(self, **overrides):
|
||||
"""Create a mock ConversionThread with default values."""
|
||||
defaults = dict(
|
||||
file_name="/tmp/test.epub",
|
||||
lang_code="a",
|
||||
voice="M1",
|
||||
voice_profile=None,
|
||||
speed=1.0,
|
||||
use_gpu=False,
|
||||
supertonic_total_steps=5,
|
||||
output_format="wav",
|
||||
subtitle_mode="Disabled",
|
||||
subtitle_format="srt",
|
||||
max_subtitle_words=50,
|
||||
save_option="save_next_to_input",
|
||||
output_folder=None,
|
||||
save_chapters_separately=False,
|
||||
merge_chapters_at_end=True,
|
||||
separate_chapters_format="wav",
|
||||
save_as_project=False,
|
||||
silence_duration=2.0,
|
||||
chapter_intro_delay=0.0,
|
||||
replace_single_newlines=False,
|
||||
read_title_intro=False,
|
||||
read_closing_outro=True,
|
||||
auto_prefix_chapter_titles=True,
|
||||
normalize_chapter_opening_caps=False,
|
||||
pronunciation_overrides=[],
|
||||
manual_overrides=[],
|
||||
heteronym_overrides=[],
|
||||
normalization_overrides=None,
|
||||
metadata_tags={},
|
||||
cover_image_path=None,
|
||||
cover_image_mime=None,
|
||||
generate_epub3=False,
|
||||
is_direct_text=False,
|
||||
from_queue=False,
|
||||
display_path=None,
|
||||
save_base_path=None,
|
||||
cancel_requested=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
def test_basic_field_mapping(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
|
||||
thread = self._make_thread()
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert isinstance(req, ConversionRequest)
|
||||
assert req.source_path == Path("/tmp/test.epub")
|
||||
assert req.language == "a"
|
||||
assert req.voice == "M1"
|
||||
assert req.speed == 1.0
|
||||
assert req.output_format == "wav"
|
||||
|
||||
def test_direct_text_mode(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
|
||||
thread = self._make_thread(
|
||||
is_direct_text=True,
|
||||
file_name="Hello world",
|
||||
)
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert req.source_path is None
|
||||
assert req.direct_text == "Hello world"
|
||||
|
||||
def test_from_queue_uses_save_base_path(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
|
||||
thread = self._make_thread(
|
||||
from_queue=True,
|
||||
save_base_path="/queue/book.epub",
|
||||
display_path="/display/book.epub",
|
||||
)
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert req.original_filename == "book.epub"
|
||||
|
||||
def test_display_path_used_when_not_from_queue(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
|
||||
thread = self._make_thread(
|
||||
from_queue=False,
|
||||
display_path="/display/book.epub",
|
||||
save_base_path="/queue/book.epub",
|
||||
)
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert req.original_filename == "book.epub"
|
||||
|
||||
def test_output_folder_mapped(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
|
||||
thread = self._make_thread(output_folder="/output")
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert req.output_folder == Path("/output")
|
||||
|
||||
def test_none_defaults_handled(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode
|
||||
|
||||
thread = self._make_thread(
|
||||
lang_code=None,
|
||||
voice=None,
|
||||
speed=None,
|
||||
output_format=None,
|
||||
subtitle_mode=None,
|
||||
save_option=None,
|
||||
silence_duration=None,
|
||||
chapter_intro_delay=None,
|
||||
supertonic_total_steps=None,
|
||||
max_subtitle_words=None,
|
||||
)
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
# None values pass through adapter; ConversionRequest.__post_init__
|
||||
# applies defaults and clamping for numeric fields.
|
||||
assert req.language == Language.EN_US
|
||||
assert req.speed == 1.0
|
||||
assert req.output_format == OutputFormat.WAV
|
||||
assert req.subtitle_mode == SubtitleMode.DISABLED
|
||||
assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT
|
||||
assert req.silence_between_chapters == 2.0
|
||||
assert req.chapter_intro_delay == 0.0
|
||||
assert req.supertonic_total_steps == 5
|
||||
assert req.max_subtitle_words == 50
|
||||
|
||||
def test_chapter_chunks_not_mapped(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
|
||||
thread = self._make_thread()
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert req.chapter_overrides == []
|
||||
assert req.chunks == []
|
||||
assert req.chunk_level == "paragraph"
|
||||
assert req.speaker_mode == "single"
|
||||
assert req.speakers == {}
|
||||
|
||||
|
||||
class TestPyQtEvents:
|
||||
"""Test PyQt ConversionEvents implementation."""
|
||||
|
||||
def test_log_emits_signal(self):
|
||||
from abogen.pyqt.conversion_adapter import PyQtEvents
|
||||
|
||||
thread = SimpleNamespace(
|
||||
log_updated=MagicMock(),
|
||||
)
|
||||
events = PyQtEvents(thread)
|
||||
events.log("test message", level="info")
|
||||
|
||||
thread.log_updated.emit.assert_called_once()
|
||||
|
||||
def test_progress_emits_signal(self):
|
||||
from abogen.pyqt.conversion_adapter import PyQtEvents
|
||||
|
||||
thread = SimpleNamespace(
|
||||
progress_updated=MagicMock(),
|
||||
)
|
||||
events = PyQtEvents(thread)
|
||||
events.progress(50, "2m 30s")
|
||||
|
||||
thread.progress_updated.emit.assert_called_once_with(50, "2m 30s")
|
||||
|
||||
def test_check_cancelled_raises(self):
|
||||
from abogen.pyqt.conversion_adapter import ConversionCancelled, PyQtEvents
|
||||
|
||||
thread = SimpleNamespace(cancel_requested=True)
|
||||
events = PyQtEvents(thread)
|
||||
|
||||
with pytest.raises(ConversionCancelled):
|
||||
events.check_cancelled()
|
||||
|
||||
def test_check_not_cancelled_passes(self):
|
||||
from abogen.pyqt.conversion_adapter import PyQtEvents
|
||||
|
||||
thread = SimpleNamespace(cancel_requested=False)
|
||||
events = PyQtEvents(thread)
|
||||
|
||||
events.check_cancelled() # Should not raise
|
||||
|
||||
|
||||
class TestPyQtPipelineProvider:
|
||||
"""Test PyQt PipelineProvider implementation."""
|
||||
|
||||
def test_get_returns_backend(self):
|
||||
from abogen.pyqt.conversion_adapter import PyQtPipelineProvider
|
||||
|
||||
backend = MagicMock()
|
||||
provider = PyQtPipelineProvider(backend)
|
||||
|
||||
result = provider.get("kokoro", "a", False)
|
||||
|
||||
assert result is backend
|
||||
|
||||
def test_dispose_all_noop(self):
|
||||
from abogen.pyqt.conversion_adapter import PyQtPipelineProvider
|
||||
|
||||
backend = MagicMock()
|
||||
provider = PyQtPipelineProvider(backend)
|
||||
|
||||
provider.dispose_all() # Should not raise
|
||||
|
||||
|
||||
class TestPyQtVoiceResolver:
|
||||
"""Test PyQt VoiceResolver implementation."""
|
||||
|
||||
def test_resolve_returns_resolved_voice(self):
|
||||
from abogen.pyqt.conversion_adapter import PyQtVoiceResolver
|
||||
|
||||
loaded_voice = MagicMock()
|
||||
thread = SimpleNamespace(
|
||||
load_voice_cached=MagicMock(return_value=loaded_voice),
|
||||
backend=MagicMock(),
|
||||
speed=1.0,
|
||||
supertonic_total_steps=5,
|
||||
)
|
||||
resolver = PyQtVoiceResolver(thread)
|
||||
result = resolver.resolve("M1")
|
||||
|
||||
assert isinstance(result, ResolvedVoice)
|
||||
assert result.provider == "kokoro"
|
||||
assert result.voice is loaded_voice
|
||||
assert result.speed == 1.0
|
||||
assert result.supertonic_steps == 5
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Regression tests for conversion executor logic.
|
||||
|
||||
These tests verify that the core conversion engine (synthesize_text,
|
||||
run_tts_segment_loop, process_and_write_subtitles) works correctly
|
||||
with fake backends and sinks. They serve as a regression net for the
|
||||
upcoming conversion flow unification refactor.
|
||||
|
||||
All tests use mock/fake implementations — no real TTS, no real audio I/O.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import MagicMock, patch
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from abogen.domain.conversion_engine import (
|
||||
synthesize_text,
|
||||
SynthParams,
|
||||
run_tts_segment_loop,
|
||||
process_and_write_subtitles,
|
||||
SegmentStats,
|
||||
SegmentInfo,
|
||||
CancelChecker,
|
||||
)
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.audio_sink import AudioSink
|
||||
|
||||
|
||||
# ─── Fake Implementations ──────────────────────────────────────────
|
||||
|
||||
class FakeAudioSink:
|
||||
"""Fake audio sink that records written data."""
|
||||
|
||||
def __init__(self):
|
||||
self.written = []
|
||||
self.closed = False
|
||||
|
||||
def write(self, audio: np.ndarray) -> None:
|
||||
self.written.append(audio)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
"""Fake TTS backend that returns deterministic audio."""
|
||||
|
||||
def __init__(self, segment_duration: float = 0.5):
|
||||
self.segment_duration = segment_duration
|
||||
self.call_count = 0
|
||||
|
||||
def __call__(self, text: str, voice: Any, speed: float = 1.0, split_pattern: str = ""):
|
||||
self.call_count += 1
|
||||
# Return fake segment objects with required attributes
|
||||
@dataclass
|
||||
class FakeSegment:
|
||||
graphemes: str = ""
|
||||
audio: Any = None
|
||||
tokens: list = field(default_factory=list)
|
||||
|
||||
samples = int(24000 * self.segment_duration)
|
||||
audio = np.zeros(samples, dtype=np.float32)
|
||||
tokens = [
|
||||
MagicMock(start_ts=0.0, end_ts=0.3, text="Hello", whitespace=" "),
|
||||
MagicMock(start_ts=0.3, end_ts=0.5, text="world", whitespace="."),
|
||||
]
|
||||
return [FakeSegment(graphemes=text, audio=audio, tokens=tokens)]
|
||||
|
||||
|
||||
class FakeSubtitleWriter:
|
||||
"""Fake subtitle writer that records entries."""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = []
|
||||
self.opened = False
|
||||
self.closed = False
|
||||
|
||||
def open(self) -> None:
|
||||
self.opened = True
|
||||
|
||||
def write_entry(self, start: float, end: float, text: str) -> None:
|
||||
self.entries.append((start, end, text))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
# ─── SegmentStats Tests ────────────────────────────────────────────
|
||||
|
||||
class TestSegmentStats:
|
||||
"""Verify SegmentStats tracks timing and character counts."""
|
||||
|
||||
def test_default_values(self):
|
||||
stats = SegmentStats()
|
||||
assert stats.processed_chars == 0
|
||||
assert stats.current_time == 0.0
|
||||
assert stats.total_characters == 0
|
||||
|
||||
def test_mutation(self):
|
||||
stats = SegmentStats(total_characters=1000)
|
||||
stats.processed_chars += 100
|
||||
stats.current_time += 1.5
|
||||
assert stats.processed_chars == 100
|
||||
assert stats.current_time == 1.5
|
||||
|
||||
|
||||
# ─── synthesize_text Tests ─────────────────────────────────────────
|
||||
|
||||
class TestSynthesizeText:
|
||||
"""Verify synthesize_text normalizes and runs TTS correctly."""
|
||||
|
||||
def test_basic_synthesis(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=100)
|
||||
sink = FakeAudioSink()
|
||||
|
||||
cancel = lambda: False
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
params = SynthParams(
|
||||
tts_context=tts_ctx,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
audio_sink=sink,
|
||||
)
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
)
|
||||
|
||||
assert segments >= 1
|
||||
assert len(sink.written) >= 1
|
||||
assert len(progress_calls) >= 1
|
||||
|
||||
def test_cancellation(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=10000)
|
||||
|
||||
cancel = lambda: True # Always cancel
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
params = SynthParams(
|
||||
tts_context=tts_ctx,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
)
|
||||
|
||||
# Should stop early due to cancellation
|
||||
assert segments == 0
|
||||
|
||||
def test_with_chapter_sink(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=100)
|
||||
merged_sink = FakeAudioSink()
|
||||
chapter_sink = FakeAudioSink()
|
||||
|
||||
cancel = lambda: False
|
||||
def on_progress(pct, etr):
|
||||
pass
|
||||
|
||||
params = SynthParams(
|
||||
tts_context=tts_ctx,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
audio_sink=merged_sink,
|
||||
)
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
chapter_sink=chapter_sink,
|
||||
)
|
||||
|
||||
# Both sinks should receive audio
|
||||
assert len(chapter_sink.written) >= 1
|
||||
assert len(merged_sink.written) >= 1
|
||||
|
||||
def test_split_pattern_override(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext(split_pattern=r"(?<=[.!?\-])\s+")
|
||||
stats = SegmentStats(total_characters=100)
|
||||
|
||||
cancel = lambda: False
|
||||
def on_progress(pct, etr):
|
||||
pass
|
||||
|
||||
params = SynthParams(
|
||||
tts_context=tts_ctx,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
split_pattern_override=r"\n+",
|
||||
)
|
||||
|
||||
assert segments >= 1
|
||||
|
||||
|
||||
# ─── process_and_write_subtitles Tests ──────────────────────────────
|
||||
|
||||
class TestProcessAndWriteSubtitles:
|
||||
"""Verify subtitle processing writes entries correctly."""
|
||||
|
||||
def test_empty_tokens(self):
|
||||
writer = FakeSubtitleWriter()
|
||||
process_and_write_subtitles(
|
||||
[],
|
||||
writer,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=10.0,
|
||||
)
|
||||
assert len(writer.entries) == 0
|
||||
|
||||
def test_sentence_mode_entries(self):
|
||||
writer = FakeSubtitleWriter()
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
||||
]
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
writer,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=2.0,
|
||||
)
|
||||
assert len(writer.entries) >= 1
|
||||
start, end, text = writer.entries[0]
|
||||
assert start < end
|
||||
assert isinstance(text, str)
|
||||
|
||||
def test_line_mode_entries(self):
|
||||
writer = FakeSubtitleWriter()
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
||||
{"start": 1.0, "end": 1.5, "text": "New", "whitespace": " "},
|
||||
{"start": 1.5, "end": 2.0, "text": "line", "whitespace": "."},
|
||||
]
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
writer,
|
||||
subtitle_mode="Line",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=3.0,
|
||||
)
|
||||
assert len(writer.entries) >= 1
|
||||
|
||||
def test_disabled_mode(self):
|
||||
"""Disabled mode is checked by the caller (run_tts_segment_loop),
|
||||
not by process_subtitle_tokens itself. This test verifies that
|
||||
process_subtitle_tokens still processes when called directly."""
|
||||
writer = FakeSubtitleWriter()
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
]
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
writer,
|
||||
subtitle_mode="Disabled",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=2.0,
|
||||
)
|
||||
# process_subtitle_tokens doesn't filter by mode — caller must check
|
||||
# So entries may be written even in "Disabled" mode
|
||||
assert isinstance(writer.entries, list)
|
||||
|
||||
|
||||
# ─── Integration: Full Pipeline ─────────────────────────────────────
|
||||
|
||||
class TestFullPipeline:
|
||||
"""Integration tests for the complete TTS pipeline."""
|
||||
|
||||
def test_end_to_end_synthesis(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=50)
|
||||
merged_sink = FakeAudioSink()
|
||||
chapter_sink = FakeAudioSink()
|
||||
subtitle_writer = FakeSubtitleWriter()
|
||||
|
||||
cancel = lambda: False
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
# Simulate full pipeline: synthesize → subtitles → finalize
|
||||
params = SynthParams(
|
||||
tts_context=tts_ctx,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
audio_sink=merged_sink,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
)
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="This is a test sentence. Another sentence here.",
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
chapter_sink=chapter_sink,
|
||||
)
|
||||
|
||||
# Process accumulated tokens
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
subtitle_writer,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=stats.current_time,
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert segments >= 1
|
||||
assert len(merged_sink.written) >= 1
|
||||
assert len(chapter_sink.written) >= 1
|
||||
assert stats.processed_chars > 0
|
||||
assert stats.current_time > 0
|
||||
|
||||
def test_multi_segment_with_cancel(self):
|
||||
"""Test that cancellation works mid-pipeline."""
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=10000)
|
||||
|
||||
cancel_count = [0]
|
||||
def cancel_fn():
|
||||
cancel_count[0] += 1
|
||||
return cancel_count[0] > 3 # Cancel after 3 segments
|
||||
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
params = SynthParams(
|
||||
tts_context=tts_ctx,
|
||||
stats=stats,
|
||||
check_cancel=cancel_fn,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world. " * 100,
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
)
|
||||
|
||||
# Should have stopped before processing all text
|
||||
assert segments <= 4
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Tests for the unified conversion executor (execute_conversion).
|
||||
|
||||
Uses fake/mock objects for ports (events, pipeline_provider, voice_resolver)
|
||||
to test the executor without real TTS or audio I/O.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
OutputLayout,
|
||||
SegmentPlan,
|
||||
)
|
||||
from abogen.application.conversion_ports import ResolvedVoice
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.domain.normalization import TTSContext
|
||||
|
||||
|
||||
# ─── Fake implementations ──────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeAudioSink:
|
||||
"""Fake audio sink that collects written audio data."""
|
||||
|
||||
def __init__(self):
|
||||
self.written: List[np.ndarray] = []
|
||||
self.closed = False
|
||||
|
||||
def write(self, audio: np.ndarray) -> None:
|
||||
self.written.append(audio)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class FakeSubtitleWriter:
|
||||
"""Fake subtitle writer that collects entries."""
|
||||
|
||||
def __init__(self, path: Optional[Path] = None):
|
||||
self.path = path or Path("/fake/output.srt")
|
||||
self.entries = []
|
||||
self.closed = False
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def write_entry(self, start: float, end: float, text: str) -> None:
|
||||
self.entries.append((start, end, text))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
"""Fake TTS backend that returns silent audio segments."""
|
||||
|
||||
def __init__(self):
|
||||
self.synthesized: List[str] = []
|
||||
|
||||
def __call__(self, text: str, *, voice: Any, speed: float = 1.0, split_pattern: str = "") -> List:
|
||||
"""Return fake TTS segments."""
|
||||
self.synthesized.append(text)
|
||||
|
||||
# Create a fake segment object
|
||||
class FakeSegment:
|
||||
def __init__(self, text: str):
|
||||
self.graphemes = text
|
||||
self.audio = np.zeros(2400, dtype=np.float32) # 0.1s at 24kHz
|
||||
self.tokens = []
|
||||
|
||||
return [FakeSegment(text)]
|
||||
|
||||
|
||||
class FakeEvents:
|
||||
"""Fake conversion events that collect logs and progress."""
|
||||
|
||||
def __init__(self):
|
||||
self.logs = []
|
||||
self.progress_calls = []
|
||||
self.cancelled = False
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
self.logs.append((message, level))
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
self.progress_calls.append((pct, etr))
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
if self.cancelled:
|
||||
raise RuntimeError("Conversion cancelled")
|
||||
|
||||
|
||||
class FakePipelineProvider:
|
||||
"""Fake pipeline provider that returns FakeBackend."""
|
||||
|
||||
def __init__(self):
|
||||
self.backends = {}
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> FakeBackend:
|
||||
key = f"{provider}:{language}"
|
||||
if key not in self.backends:
|
||||
self.backends[key] = FakeBackend()
|
||||
return self.backends[key]
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
self.backends.clear()
|
||||
|
||||
|
||||
class FakeVoiceResolver:
|
||||
"""Fake voice resolver that returns ResolvedVoice objects."""
|
||||
|
||||
def __init__(self):
|
||||
self.resolved_specs = []
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
self.resolved_specs.append(voice_spec)
|
||||
return ResolvedVoice(
|
||||
provider="kokoro",
|
||||
resolved_spec=voice_spec,
|
||||
voice=voice_spec, # Use spec as voice name
|
||||
speed=1.0,
|
||||
supertonic_steps=5,
|
||||
)
|
||||
|
||||
|
||||
# ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExecuteConversion:
|
||||
"""Tests for the main execute_conversion function."""
|
||||
|
||||
def test_simple_text_conversion(self):
|
||||
"""Simple text conversion without chapters."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello world",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Hello world",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Hello world",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.audio_path is not None
|
||||
assert result.audio_path.exists()
|
||||
|
||||
def test_multi_chapter_conversion(self):
|
||||
"""Multi-chapter conversion."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Text",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=True,
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="Chapter 1",
|
||||
original_title="Chapter 1",
|
||||
body_text="First chapter text",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="First chapter text",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
),
|
||||
ChapterPlan(
|
||||
index=2,
|
||||
title="Chapter 2",
|
||||
original_title="Chapter 2",
|
||||
body_text="Second chapter text",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Second chapter text",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
),
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
assert result.total_chapters == 2
|
||||
assert len(result.chapter_paths) == 2
|
||||
|
||||
def test_voice_markers(self):
|
||||
"""Conversion with voice markers creates separate segments."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Text",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Hello World",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Hello",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="voice_marker",
|
||||
),
|
||||
SegmentPlan(
|
||||
text="World",
|
||||
voice_spec="F1",
|
||||
kind="body",
|
||||
source="voice_marker",
|
||||
),
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.chunk_markers) == 2
|
||||
|
||||
def test_intro_outro(self):
|
||||
"""Conversion with intro and outro."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Text",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Body text",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Body text",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
intro=IntroOutroSpec(
|
||||
enabled=True,
|
||||
text="Book intro text",
|
||||
voice_spec="M1",
|
||||
kind="intro",
|
||||
),
|
||||
outro=IntroOutroSpec(
|
||||
enabled=True,
|
||||
text="Book outro text",
|
||||
voice_spec="M1",
|
||||
kind="outro",
|
||||
),
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
# Check that intro/outro were logged
|
||||
log_messages = [msg for msg, _ in events.logs]
|
||||
assert any("Title intro" in msg for msg in log_messages)
|
||||
assert any("Closing outro" in msg for msg in log_messages)
|
||||
|
||||
def test_cancellation(self):
|
||||
"""Conversion can be cancelled."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Text",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Body text",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Body text",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
events.cancelled = True # Set cancellation
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
# Should raise RuntimeError when cancelled
|
||||
with pytest.raises(RuntimeError, match="Conversion cancelled"):
|
||||
execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
def test_progress_reporting(self):
|
||||
"""Progress is reported during conversion."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello world",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Hello world",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Hello world",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
# Progress should have been reported
|
||||
assert len(events.progress_calls) > 0
|
||||
|
||||
def test_metadata_preserved(self):
|
||||
"""Metadata from plan is preserved in result."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Text",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
metadata={"title": "Test Book", "author": "Author"},
|
||||
chapters=[
|
||||
ChapterPlan(
|
||||
index=1,
|
||||
title="text",
|
||||
original_title="text",
|
||||
body_text="Text",
|
||||
segments=[
|
||||
SegmentPlan(
|
||||
text="Text",
|
||||
voice_spec="M1",
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
],
|
||||
voice_spec="M1",
|
||||
)
|
||||
],
|
||||
output_layout=OutputLayout(
|
||||
parent_dir=Path(tmpdir),
|
||||
audio_dir=Path(tmpdir),
|
||||
),
|
||||
)
|
||||
|
||||
events = FakeEvents()
|
||||
pipeline = FakePipelineProvider()
|
||||
resolver = FakeVoiceResolver()
|
||||
tts_context = TTSContext()
|
||||
|
||||
result = execute_conversion(
|
||||
plan, events, pipeline, resolver, tts_context
|
||||
)
|
||||
|
||||
assert result.metadata["title"] == "Test Book"
|
||||
assert result.metadata["author"] == "Author"
|
||||
@@ -0,0 +1,560 @@
|
||||
"""Tests for the unified conversion planner (build_conversion_plan).
|
||||
|
||||
Verifies that the planner correctly handles:
|
||||
- Plain text conversion
|
||||
- Voice markers (PyQt style)
|
||||
- Chapter parsing
|
||||
- Chunks (WebUI style)
|
||||
- Intro/outro
|
||||
- Output layout
|
||||
- Edge cases (empty text, no chapters, etc.)
|
||||
|
||||
Also includes domain-level regression tests for the underlying functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
OutputLayout,
|
||||
SegmentPlan,
|
||||
)
|
||||
from abogen.application.conversion_planner import build_conversion_plan
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
|
||||
|
||||
class TestBuildConversionPlan:
|
||||
"""Tests for the main build_conversion_plan function."""
|
||||
|
||||
def test_direct_text_simple(self):
|
||||
"""Plain text without markers or chapters."""
|
||||
req = ConversionRequest(direct_text="Hello world", voice="M1")
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert isinstance(plan, ConversionPlan)
|
||||
assert len(plan.chapters) == 1
|
||||
assert plan.chapters[0].segments[0].text == "Hello world"
|
||||
assert plan.chapters[0].segments[0].voice_spec == "M1"
|
||||
assert plan.chapters[0].segments[0].source == "chapter"
|
||||
|
||||
def test_direct_text_with_chapters(self):
|
||||
"""Text with chapter markers is split into chapters."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Chapter 1>>\nText A\n<<CHAPTER_MARKER:Chapter 2>>\nText B",
|
||||
voice="M1",
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert len(plan.chapters) == 2
|
||||
assert plan.chapters[0].title == "Chapter 1"
|
||||
assert plan.chapters[1].title == "Chapter 2"
|
||||
|
||||
def test_voice_markers(self):
|
||||
"""Voice markers are detected and create separate segments."""
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello <<VOICE:F1>> World", voice="M1"
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
segments = plan.chapters[0].segments
|
||||
assert len(segments) == 2
|
||||
assert segments[0].text == "Hello"
|
||||
assert segments[0].source == "voice_marker"
|
||||
assert segments[1].text == "World"
|
||||
assert segments[1].source == "voice_marker"
|
||||
|
||||
def test_chunks(self):
|
||||
"""Chunks from WebUI are converted to segments."""
|
||||
req = ConversionRequest(
|
||||
direct_text="Some text",
|
||||
voice="M1",
|
||||
chunks=[
|
||||
{"text": "Chunk 1", "speaker_id": "narrator"},
|
||||
{"text": "Chunk 2", "speaker_id": "narrator"},
|
||||
],
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
segments = plan.chapters[0].segments
|
||||
assert len(segments) == 2
|
||||
assert segments[0].text == "Chunk 1"
|
||||
assert segments[0].source == "chunk"
|
||||
assert segments[1].text == "Chunk 2"
|
||||
|
||||
def test_chunks_with_voice(self):
|
||||
"""Chunks with per-chunk voice spec."""
|
||||
req = ConversionRequest(
|
||||
direct_text="Text",
|
||||
voice="M1",
|
||||
chunks=[
|
||||
{"text": "Narrator speaks", "speaker_id": "narrator"},
|
||||
{"text": "Character speaks", "speaker_id": "alice", "voice": "F1"},
|
||||
],
|
||||
speakers={"alice": {"voice": "F1"}},
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
segments = plan.chapters[0].segments
|
||||
assert len(segments) == 2
|
||||
assert segments[0].voice_spec == "M1"
|
||||
assert segments[1].voice_spec == "F1"
|
||||
|
||||
def test_intro_spec(self):
|
||||
"""Intro is created when read_title_intro=True."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Chapter 1>>\nThe Great Gatsby by F. Scott Fitzgerald\nBody text",
|
||||
voice="M1",
|
||||
read_title_intro=True,
|
||||
metadata_tags={"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
# Intro may or may not be enabled depending on metadata resolution
|
||||
assert plan.intro is None or isinstance(plan.intro, IntroOutroSpec)
|
||||
|
||||
def test_output_layout(self):
|
||||
"""Output layout is resolved from request."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
save_mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert isinstance(plan.output_layout, OutputLayout)
|
||||
assert plan.output_layout.parent_dir == Path(tmpdir)
|
||||
|
||||
def test_empty_text_raises(self):
|
||||
"""Empty text should raise ValueError."""
|
||||
req = ConversionRequest(direct_text="", voice="M1")
|
||||
with pytest.raises(ValueError, match="No text content"):
|
||||
build_conversion_plan(req)
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
"""Whitespace-only text should raise ValueError."""
|
||||
req = ConversionRequest(direct_text=" \n \n ", voice="M1")
|
||||
with pytest.raises(ValueError, match="No text content"):
|
||||
build_conversion_plan(req)
|
||||
|
||||
def test_no_source_raises(self):
|
||||
"""Request with no source should raise ValueError."""
|
||||
req = ConversionRequest(voice="M1")
|
||||
with pytest.raises(ValueError, match="No text content"):
|
||||
build_conversion_plan(req)
|
||||
|
||||
def test_plan_preserves_request(self):
|
||||
"""Plan should reference the original request."""
|
||||
req = ConversionRequest(direct_text="Hello", voice="M1", speed=1.5)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert plan.request is req
|
||||
assert plan.request.speed == 1.5
|
||||
|
||||
def test_metadata_in_plan(self):
|
||||
"""Metadata from request should appear in plan."""
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
voice="M1",
|
||||
metadata_tags={"title": "Test Book", "author": "Author"},
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert "title" in plan.metadata
|
||||
assert plan.metadata["title"] == "Test Book"
|
||||
|
||||
def test_chapter_index_starts_at_1(self):
|
||||
"""Chapter indices should start at 1."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Ch1>>\nText\n<<CHAPTER_MARKER:Ch2>>\nText\n<<CHAPTER_MARKER:Ch3>>\nText",
|
||||
voice="M1",
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
for i, ch in enumerate(plan.chapters, 1):
|
||||
assert ch.index == i
|
||||
|
||||
def test_chapter_body_text_preserved(self):
|
||||
"""Chapter body text should be preserved in ChapterPlan."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Chapter 1>>\nThe actual body text", voice="M1"
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert "The actual body text" in plan.chapters[0].body_text
|
||||
|
||||
def test_segment_kind_default(self):
|
||||
"""Default segment kind should be 'body'."""
|
||||
req = ConversionRequest(direct_text="Hello", voice="M1")
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert plan.chapters[0].segments[0].kind == "body"
|
||||
|
||||
|
||||
class TestPlannerWithFileSource:
|
||||
"""Tests using actual file sources (not direct_text)."""
|
||||
|
||||
def test_txt_file(self):
|
||||
"""Planning from a .txt file."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("Chapter 1\nHello from file")
|
||||
f.flush()
|
||||
path = Path(f.name)
|
||||
|
||||
try:
|
||||
req = ConversionRequest(source_path=path, voice="M1")
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert len(plan.chapters) >= 1
|
||||
assert "Hello from file" in plan.chapters[0].segments[0].text
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_txt_file_with_voice_markers(self):
|
||||
"""File with voice markers."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("Start <<VOICE:F1>> End")
|
||||
f.flush()
|
||||
path = Path(f.name)
|
||||
|
||||
try:
|
||||
req = ConversionRequest(source_path=path, voice="M1")
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
segments = plan.chapters[0].segments
|
||||
assert len(segments) == 2
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestPlannerChapters:
|
||||
"""Tests for chapter handling in the planner."""
|
||||
|
||||
def test_single_chapter_no_marker(self):
|
||||
"""Text without markers becomes a single chapter."""
|
||||
req = ConversionRequest(direct_text="Just some text", voice="M1")
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert len(plan.chapters) == 1
|
||||
assert plan.chapters[0].title == "text"
|
||||
|
||||
def test_chapters_with_marker(self):
|
||||
"""Chapter markers create multiple chapters."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Ch A>>\nText A\n<<CHAPTER_MARKER:Ch B>>\nText B",
|
||||
voice="M1",
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert len(plan.chapters) == 2
|
||||
assert plan.chapters[0].title == "Ch A"
|
||||
assert plan.chapters[1].title == "Ch B"
|
||||
|
||||
def test_chapter_voice_spec(self):
|
||||
"""Chapter voice spec should come from request.voice."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Ch 1>>\nText", voice="af_heart"
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
assert plan.chapters[0].voice_spec == "af_heart"
|
||||
|
||||
def test_chapters_preserve_order(self):
|
||||
"""Chapters should maintain their order."""
|
||||
req = ConversionRequest(
|
||||
direct_text="<<CHAPTER_MARKER:Ch A>>\nText A\n<<CHAPTER_MARKER:Ch B>>\nText B\n<<CHAPTER_MARKER:Ch C>>\nText C",
|
||||
voice="M1",
|
||||
)
|
||||
plan = build_conversion_plan(req)
|
||||
|
||||
titles = [ch.title for ch in plan.chapters]
|
||||
assert titles == ["Ch A", "Ch B", "Ch C"]
|
||||
|
||||
|
||||
# ─── Domain-level regression tests ─────────────────────────────────
|
||||
|
||||
class TestChapterParsing:
|
||||
"""Verify parse_chapters_from_text produces correct chapter structure."""
|
||||
|
||||
def test_single_chapter_no_markers(self):
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
text = "This is a simple text without any chapter markers."
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) == 1
|
||||
assert chapters[0][0]
|
||||
assert "simple text" in chapters[0][1]
|
||||
|
||||
def test_multiple_chapters_by_markers(self):
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
text = """<<CHAPTER_MARKER:Chapter 1>>
|
||||
First chapter content.
|
||||
|
||||
<<CHAPTER_MARKER:Chapter 2>>
|
||||
Second chapter content."""
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) >= 2
|
||||
titles = [ch[0] for ch in chapters]
|
||||
assert "Chapter 1" in titles
|
||||
assert "Chapter 2" in titles
|
||||
|
||||
def test_empty_text(self):
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
chapters = parse_chapters_from_text("", clean=False)
|
||||
assert len(chapters) >= 1
|
||||
|
||||
def test_chapter_content_preserved(self):
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
text = """<<CHAPTER_MARKER:Chapter 1>>
|
||||
Hello world this is chapter one.
|
||||
|
||||
<<CHAPTER_MARKER:Chapter 2>>
|
||||
Goodbye world this is chapter two."""
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) >= 2
|
||||
all_text = " ".join(ch[1] for ch in chapters)
|
||||
assert "Hello world" in all_text
|
||||
assert "Goodbye world" in all_text
|
||||
|
||||
def test_intro_before_first_marker(self):
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
text = """Introduction text here.
|
||||
<<CHAPTER_MARKER:Chapter 1>>
|
||||
Chapter content."""
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) >= 2
|
||||
assert chapters[0][0] == "Introduction"
|
||||
assert "Introduction text" in chapters[0][1]
|
||||
|
||||
|
||||
class TestVoiceMarkerSplitting:
|
||||
"""Verify voice marker splitting produces correct segment structure."""
|
||||
|
||||
def test_no_voice_markers(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "Just plain text without any voice markers."
|
||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||
assert len(segments) == 1
|
||||
assert segments[0][0] == "M1"
|
||||
assert "plain text" in segments[0][1]
|
||||
|
||||
def test_single_voice_marker(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "<<VOICE:F1>> Hello from female voice."
|
||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||
assert len(segments) >= 1
|
||||
all_text = " ".join(seg[1] for seg in segments)
|
||||
assert "Hello from female" in all_text
|
||||
|
||||
def test_voice_marker_preserves_text(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "<<VOICE:F1>> First sentence. <<VOICE:M1>> Second sentence."
|
||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||
all_text = " ".join(seg[1] for seg in segments)
|
||||
assert "First sentence" in all_text
|
||||
assert "Second sentence" in all_text
|
||||
|
||||
def test_voice_marker_persistence(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "<<VOICE:F1>> First part."
|
||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||
assert last_voice in ("f1", "F1", "M1")
|
||||
|
||||
|
||||
class TestTTSContext:
|
||||
"""Verify TTSContext bundles normalization parameters correctly."""
|
||||
|
||||
def test_default_context(self):
|
||||
from abogen.domain.normalization import TTSContext
|
||||
ctx = TTSContext()
|
||||
assert ctx.split_pattern
|
||||
assert ctx.pronunciation_rules is None
|
||||
assert ctx.heteronym_rules is None
|
||||
assert ctx.normalization_overrides is None
|
||||
assert ctx.usage_counter == {}
|
||||
|
||||
def test_normalize_passthrough(self):
|
||||
from abogen.domain.normalization import TTSContext
|
||||
ctx = TTSContext()
|
||||
text = "Hello world."
|
||||
result = ctx.normalize(text)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_normalize_with_usage_counter(self):
|
||||
from abogen.domain.normalization import TTSContext
|
||||
ctx = TTSContext()
|
||||
ctx.usage_counter["test_token"] = 0
|
||||
result = ctx.normalize("Some text.")
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestVoiceResolution:
|
||||
"""Verify voice resolution functions produce valid specs."""
|
||||
|
||||
def test_resolve_fallback_voice_spec(self):
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
spec = resolve_fallback_voice_spec("M1", "M1", ["M1", "F1"])
|
||||
if spec is not None:
|
||||
assert hasattr(spec, "voice_id") or isinstance(spec, str)
|
||||
|
||||
def test_spec_to_voice_ids(self):
|
||||
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||
ids = spec_to_voice_ids("M1")
|
||||
assert isinstance(ids, set)
|
||||
|
||||
def test_resolve_fallback_with_empty_cache(self):
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
spec = resolve_fallback_voice_spec("M1", "M1", [])
|
||||
|
||||
|
||||
class TestIntroOutro:
|
||||
"""Verify intro/outro resolution with various metadata states."""
|
||||
|
||||
def test_resolve_intro_with_metadata(self):
|
||||
from abogen.domain.intro_outro import resolve_intro
|
||||
metadata = {"title": "Test Book", "author": "Test Author"}
|
||||
spec = resolve_intro(metadata, "test.txt", True, "M1", "M1", ["M1"])
|
||||
assert spec is not None
|
||||
assert spec.text
|
||||
|
||||
def test_resolve_intro_disabled(self):
|
||||
from abogen.domain.intro_outro import resolve_intro
|
||||
spec = resolve_intro({}, "test.txt", False, "M1", "M1", ["M1"])
|
||||
assert not spec.enabled
|
||||
|
||||
def test_resolve_intro_no_metadata(self):
|
||||
from abogen.domain.intro_outro import resolve_intro
|
||||
spec = resolve_intro({}, "test.txt", True, "M1", "M1", ["M1"])
|
||||
assert spec is not None
|
||||
|
||||
def test_resolve_outro_with_metadata(self):
|
||||
from abogen.domain.intro_outro import resolve_outro
|
||||
metadata = {"title": "Test Book"}
|
||||
spec = resolve_outro(metadata, "test.txt", True, "M1", "M1", ["M1"])
|
||||
assert spec is not None
|
||||
assert spec.text
|
||||
|
||||
def test_resolve_outro_disabled(self):
|
||||
from abogen.domain.intro_outro import resolve_outro
|
||||
spec = resolve_outro({}, "test.txt", False, "M1", "M1", ["M1"])
|
||||
assert not spec.enabled
|
||||
|
||||
|
||||
class TestOutputPaths:
|
||||
"""Verify output path resolution produces valid paths."""
|
||||
|
||||
def test_resolve_unique_path(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_unique_path
|
||||
(tmp_path / "test.txt").touch()
|
||||
result = resolve_unique_path(
|
||||
str(tmp_path), "test", "txt",
|
||||
allowed_extensions={"txt", "wav"},
|
||||
)
|
||||
assert result
|
||||
assert "test" in result
|
||||
|
||||
def test_resolve_unique_path_no_collision(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_unique_path
|
||||
result = resolve_unique_path(str(tmp_path), "unique_name", "txt")
|
||||
assert result
|
||||
assert "unique_name" in result
|
||||
|
||||
def test_sanitize_output_stem(self):
|
||||
from abogen.domain.output_paths import sanitize_output_stem
|
||||
stem = sanitize_output_stem("My Book Title")
|
||||
assert isinstance(stem, str)
|
||||
assert len(stem) > 0
|
||||
|
||||
def test_resolve_output_directory(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_output_directory
|
||||
result = resolve_output_directory(
|
||||
save_mode="Save next to input file",
|
||||
stored_path=tmp_path / "test.txt",
|
||||
output_folder=None,
|
||||
desktop_dir=tmp_path,
|
||||
user_output_path=None,
|
||||
user_cache_outputs=tmp_path,
|
||||
)
|
||||
assert result is not None
|
||||
assert isinstance(result, Path)
|
||||
|
||||
|
||||
class TestSubtitleGeneration:
|
||||
"""Verify subtitle token processing works correctly."""
|
||||
|
||||
def test_process_empty_tokens(self):
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
entries = []
|
||||
process_subtitle_tokens(
|
||||
[], entries, 5, "Sentence", "a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=10.0,
|
||||
)
|
||||
assert entries == []
|
||||
|
||||
def test_process_sentence_mode(self):
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
||||
]
|
||||
entries = []
|
||||
process_subtitle_tokens(
|
||||
tokens, entries, 5, "Sentence", "a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=2.0,
|
||||
)
|
||||
assert len(entries) >= 1
|
||||
start, end, text = entries[0]
|
||||
assert start < end
|
||||
assert isinstance(text, str)
|
||||
|
||||
def test_process_line_mode(self):
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
||||
{"start": 1.0, "end": 1.5, "text": "New", "whitespace": " "},
|
||||
{"start": 1.5, "end": 2.0, "text": "line", "whitespace": "."},
|
||||
]
|
||||
entries = []
|
||||
process_subtitle_tokens(
|
||||
tokens, entries, 5, "Line", "a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=3.0,
|
||||
)
|
||||
assert len(entries) >= 1
|
||||
|
||||
|
||||
class TestFeatureParity:
|
||||
"""Regression tests for features that must work in both UIs."""
|
||||
|
||||
def test_chapter_title_formatting(self):
|
||||
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
||||
title1 = format_spoken_chapter_title("Chapter 1", 1, apply_prefix=True)
|
||||
title2 = format_spoken_chapter_title("Introduction", 1, apply_prefix=True)
|
||||
assert isinstance(title1, str)
|
||||
assert isinstance(title2, str)
|
||||
|
||||
def test_chapter_title_no_auto_prefix(self):
|
||||
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
||||
title = format_spoken_chapter_title("My Custom Title", 1, apply_prefix=False)
|
||||
assert "My Custom Title" in title
|
||||
|
||||
def test_m4b_forces_merge(self):
|
||||
output_format = "m4b"
|
||||
merge_chapters_at_end = False
|
||||
if output_format.lower() == "m4b":
|
||||
merge_chapters_at_end = True
|
||||
assert merge_chapters_at_end is True
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Regression tests for ConversionRequest building.
|
||||
|
||||
These tests verify that both WebUI and PyQt adapters can produce
|
||||
a valid ConversionRequest from their respective Job/thread state.
|
||||
They serve as a specification for the adapter code that will be
|
||||
created in Phase 2/3 of the refactor.
|
||||
|
||||
Currently these tests verify the EXISTING behavior by testing the
|
||||
domain functions that the adapters will call. After the adapters
|
||||
are created, these tests should be updated to test the adapters
|
||||
directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest, ConversionRequestError
|
||||
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.settings_core import settings_defaults
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
|
||||
|
||||
class TestConversionRequestBasics:
|
||||
"""Verify that basic request parameters can be derived from settings."""
|
||||
|
||||
def test_settings_defaults_exist(self):
|
||||
defaults = settings_defaults()
|
||||
assert isinstance(defaults, dict)
|
||||
assert "output_format" in defaults
|
||||
assert "subtitle_format" in defaults
|
||||
assert "save_mode" in defaults
|
||||
assert "use_gpu" in defaults
|
||||
assert "silence_between_chapters" in defaults
|
||||
assert "merge_chapters_at_end" in defaults
|
||||
|
||||
def test_split_pattern_computation(self):
|
||||
pattern = get_split_pattern("a", "Disabled")
|
||||
assert isinstance(pattern, str)
|
||||
assert len(pattern) > 0
|
||||
|
||||
def test_split_pattern_varies_by_subtitle_mode(self):
|
||||
pattern_disabled = get_split_pattern("a", "Disabled")
|
||||
pattern_sentence = get_split_pattern("a", "Sentence")
|
||||
# Different modes should produce different patterns
|
||||
assert isinstance(pattern_disabled, str)
|
||||
assert isinstance(pattern_sentence, str)
|
||||
|
||||
|
||||
class TestTTSContextBuilding:
|
||||
"""Verify TTSContext can be built from settings parameters."""
|
||||
|
||||
def test_build_context_from_params(self):
|
||||
ctx = TTSContext(
|
||||
split_pattern=r"(?<=[.!?\-])\s+",
|
||||
pronunciation_rules=None,
|
||||
heteronym_rules=None,
|
||||
normalization_overrides=None,
|
||||
)
|
||||
assert ctx.split_pattern
|
||||
assert ctx.normalize("Hello world.") is not None
|
||||
|
||||
def test_build_context_with_compiled_rules(self):
|
||||
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||
rules = compile_pronunciation_rules([{"pattern": "test", "replacement": "Test"}])
|
||||
ctx = TTSContext(
|
||||
split_pattern=r"\n+",
|
||||
pronunciation_rules=rules,
|
||||
)
|
||||
result = ctx.normalize("test text")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_usage_counter_tracking(self):
|
||||
ctx = TTSContext()
|
||||
ctx.usage_counter["token1"] = 0
|
||||
ctx.normalize("Some text with token1")
|
||||
# Usage counter should be passed through (may or may not increment
|
||||
# depending on whether the token matches)
|
||||
assert isinstance(ctx.usage_counter, dict)
|
||||
|
||||
|
||||
class TestOutputDirectoryResolution:
|
||||
"""Verify output directory can be resolved from parameters."""
|
||||
|
||||
def test_resolve_output_directory(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_output_directory
|
||||
result = resolve_output_directory(
|
||||
save_mode="Save next to input file",
|
||||
stored_path=tmp_path / "test.txt",
|
||||
output_folder=None,
|
||||
desktop_dir=tmp_path,
|
||||
user_output_path=None,
|
||||
user_cache_outputs=tmp_path,
|
||||
)
|
||||
assert result is not None
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_resolve_output_with_explicit_folder(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_output_directory
|
||||
custom_dir = tmp_path / "custom_output"
|
||||
custom_dir.mkdir()
|
||||
result = resolve_output_directory(
|
||||
save_mode="Save to custom folder",
|
||||
stored_path=tmp_path / "test.txt",
|
||||
output_folder=str(custom_dir),
|
||||
desktop_dir=tmp_path,
|
||||
user_output_path=None,
|
||||
user_cache_outputs=tmp_path,
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestChapterSelection:
|
||||
"""Verify chapter selection logic works with various inputs."""
|
||||
|
||||
def test_auto_select_relevant_chapters(self):
|
||||
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
chapters = [
|
||||
ExtractedChapter(title="Chapter 1", text="A" * 500),
|
||||
ExtractedChapter(title="Chapter 2", text="B" * 50),
|
||||
ExtractedChapter(title="Chapter 3", text="C" * 600),
|
||||
]
|
||||
result = auto_select_relevant_chapters(chapters, "txt")
|
||||
# Should filter out short chapters
|
||||
assert len(result.kept) >= 1
|
||||
assert isinstance(result.skipped, list)
|
||||
|
||||
def test_auto_select_all_long_chapters(self):
|
||||
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
chapters = [
|
||||
ExtractedChapter(title="Chapter 1", text="A" * 500),
|
||||
ExtractedChapter(title="Chapter 2", text="B" * 500),
|
||||
]
|
||||
result = auto_select_relevant_chapters(chapters, "txt")
|
||||
assert len(result.kept) == 2
|
||||
assert len(result.skipped) == 0
|
||||
|
||||
def test_metadata_merge(self):
|
||||
from abogen.domain.metadata_merge import merge_metadata
|
||||
base = {"title": "Original Title", "author": "Author A"}
|
||||
overrides = {"title": "New Title"}
|
||||
result = merge_metadata(base, overrides)
|
||||
assert result["title"] == "New Title"
|
||||
assert result["author"] == "Author A"
|
||||
|
||||
|
||||
class TestCancellationProtocol:
|
||||
"""Verify cancellation mechanism can be implemented as a callback."""
|
||||
|
||||
def test_cancellation_flag_check(self):
|
||||
class FakeJob:
|
||||
def __init__(self):
|
||||
self.cancel_requested = False
|
||||
|
||||
job = FakeJob()
|
||||
check = lambda: job.cancel_requested
|
||||
assert check() is False
|
||||
|
||||
job.cancel_requested = True
|
||||
assert check() is True
|
||||
|
||||
def test_cancellation_exception_pattern(self):
|
||||
"""WebUI uses exception-based cancellation."""
|
||||
class JobCancelled(Exception):
|
||||
pass
|
||||
|
||||
def canceller():
|
||||
raise JobCancelled()
|
||||
|
||||
with pytest.raises(JobCancelled):
|
||||
canceller()
|
||||
|
||||
|
||||
class TestLoggingProtocol:
|
||||
"""Verify logging can be abstracted as a callback."""
|
||||
|
||||
def test_log_callback(self):
|
||||
logs = []
|
||||
def log_fn(msg, level="info"):
|
||||
logs.append((msg, level))
|
||||
|
||||
log_fn("Test message", "info")
|
||||
assert len(logs) == 1
|
||||
assert logs[0] == ("Test message", "info")
|
||||
|
||||
def test_progress_callback(self):
|
||||
progress_calls = []
|
||||
def progress_fn(processed, total, etr):
|
||||
progress_calls.append((processed, total, etr))
|
||||
|
||||
progress_fn(100, 1000, "0:05:00")
|
||||
assert len(progress_calls) == 1
|
||||
assert progress_calls[0] == (100, 1000, "0:05:00")
|
||||
|
||||
|
||||
class TestConversionRequestValidation:
|
||||
"""Verify __post_init__ validation on ConversionRequest."""
|
||||
|
||||
def test_defaults_are_valid(self):
|
||||
req = ConversionRequest()
|
||||
assert req.max_subtitle_words == 50
|
||||
assert req.speed == 1.0
|
||||
assert req.supertonic_total_steps == 5
|
||||
assert req.output_format == OutputFormat.WAV
|
||||
assert req.subtitle_mode == SubtitleMode.DISABLED
|
||||
|
||||
def test_max_subtitle_words_clamped_below_min(self):
|
||||
req = ConversionRequest(max_subtitle_words=0)
|
||||
assert req.max_subtitle_words == 1
|
||||
|
||||
def test_max_subtitle_words_clamped_above_max(self):
|
||||
req = ConversionRequest(max_subtitle_words=999)
|
||||
assert req.max_subtitle_words == 500
|
||||
|
||||
def test_max_subtitle_words_valid(self):
|
||||
req = ConversionRequest(max_subtitle_words=100)
|
||||
assert req.max_subtitle_words == 100
|
||||
|
||||
def test_speed_clamped_below_min(self):
|
||||
req = ConversionRequest(speed=0.1)
|
||||
assert req.speed == 0.5
|
||||
|
||||
def test_speed_clamped_above_max(self):
|
||||
req = ConversionRequest(speed=10.0)
|
||||
assert req.speed == 3.0
|
||||
|
||||
def test_speed_valid(self):
|
||||
req = ConversionRequest(speed=1.5)
|
||||
assert req.speed == 1.5
|
||||
|
||||
def test_supertonic_steps_clamped_below_min(self):
|
||||
req = ConversionRequest(supertonic_total_steps=0)
|
||||
assert req.supertonic_total_steps == 2
|
||||
|
||||
def test_supertonic_steps_clamped_above_max(self):
|
||||
req = ConversionRequest(supertonic_total_steps=100)
|
||||
assert req.supertonic_total_steps == 15
|
||||
|
||||
def test_silence_between_chapters_clamped(self):
|
||||
req = ConversionRequest(silence_between_chapters=-5.0)
|
||||
assert req.silence_between_chapters == 0.0
|
||||
|
||||
def test_chapter_intro_delay_clamped(self):
|
||||
req = ConversionRequest(chapter_intro_delay=-1.0)
|
||||
assert req.chapter_intro_delay == 0.0
|
||||
|
||||
def test_invalid_chunk_level_raises(self):
|
||||
with pytest.raises(ConversionRequestError, match="chunk_level"):
|
||||
ConversionRequest(chunk_level="invalid")
|
||||
|
||||
def test_invalid_speaker_mode_raises(self):
|
||||
with pytest.raises(ConversionRequestError, match="speaker_mode"):
|
||||
ConversionRequest(speaker_mode="invalid")
|
||||
|
||||
def test_invalid_max_subtitle_words_type_raises(self):
|
||||
with pytest.raises(ConversionRequestError, match="max_subtitle_words"):
|
||||
ConversionRequest(max_subtitle_words="not_a_number")
|
||||
|
||||
def test_invalid_speed_type_raises(self):
|
||||
with pytest.raises(ConversionRequestError, match="speed"):
|
||||
ConversionRequest(speed="fast")
|
||||
|
||||
def test_invalid_silence_type_raises(self):
|
||||
with pytest.raises(ConversionRequestError, match="silence_between_chapters"):
|
||||
ConversionRequest(silence_between_chapters="loud")
|
||||
|
||||
def test_empty_tts_provider_defaults_to_kokoro(self):
|
||||
req = ConversionRequest(tts_provider="")
|
||||
assert req.tts_provider == "kokoro"
|
||||
|
||||
def test_enum_fields_accept_valid_values(self):
|
||||
req = ConversionRequest(
|
||||
language=Language.FR,
|
||||
output_format=OutputFormat.MP3,
|
||||
subtitle_mode=SubtitleMode.SENTENCE,
|
||||
subtitle_format=SubtitleFormat.ASS,
|
||||
save_mode=SaveMode.CUSTOM_FOLDER,
|
||||
)
|
||||
assert req.language == Language.FR
|
||||
assert req.output_format == OutputFormat.MP3
|
||||
assert req.subtitle_mode == SubtitleMode.SENTENCE
|
||||
assert req.subtitle_format == SubtitleFormat.ASS
|
||||
assert req.save_mode == SaveMode.CUSTOM_FOLDER
|
||||
@@ -2,58 +2,75 @@
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from abogen.domain.audio_buffer import fit_audio_to_duration, ffmpeg_time_stretch, SAMPLE_RATE
|
||||
|
||||
|
||||
class TestFitAudioToDuration:
|
||||
def test_exact_length(self):
|
||||
audio = np.ones(24000, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
|
||||
assert len(result) == 24000
|
||||
def test_exact_length(self):
|
||||
audio = np.ones(24000, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
|
||||
assert len(result) == 24000
|
||||
|
||||
def test_shorter_pads_with_zeros(self):
|
||||
audio = np.ones(12000, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
|
||||
assert len(result) == 24000
|
||||
assert result[0] == 1.0
|
||||
assert result[12000] == 0.0
|
||||
def test_shorter_pads_with_zeros(self):
|
||||
audio = np.ones(12000, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
|
||||
assert len(result) == 24000
|
||||
assert result[0] == 1.0
|
||||
assert result[12000] == 0.0
|
||||
|
||||
def test_longer_trims(self):
|
||||
audio = np.ones(48000, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
|
||||
assert len(result) == 24000
|
||||
assert result[-1] == 1.0
|
||||
def test_longer_trims(self):
|
||||
audio = np.ones(48000, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
|
||||
assert len(result) == 24000
|
||||
assert result[-1] == 1.0
|
||||
|
||||
def test_empty_input(self):
|
||||
result = fit_audio_to_duration(np.array([], dtype="float32"), 0.5, SAMPLE_RATE)
|
||||
assert len(result) == 12000
|
||||
assert np.all(result == 0.0)
|
||||
def test_empty_input(self):
|
||||
result = fit_audio_to_duration(np.array([], dtype="float32"), 0.5, SAMPLE_RATE)
|
||||
assert len(result) == 12000
|
||||
assert np.all(result == 0.0)
|
||||
|
||||
def test_output_dtype(self):
|
||||
audio = np.ones(100, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 0.5, SAMPLE_RATE)
|
||||
assert result.dtype == np.float32
|
||||
def test_output_dtype(self):
|
||||
audio = np.ones(100, dtype="float32")
|
||||
result = fit_audio_to_duration(audio, 0.5, SAMPLE_RATE)
|
||||
assert result.dtype == np.float32
|
||||
|
||||
|
||||
class TestFfmpegTimeStretch:
|
||||
def test_no_stretch_below_threshold(self):
|
||||
audio = np.ones(24000, dtype="float32")
|
||||
result = ffmpeg_time_stretch(audio, 0.8, SAMPLE_RATE)
|
||||
np.testing.assert_array_equal(result, audio)
|
||||
def test_no_stretch_below_threshold(self):
|
||||
audio = np.ones(24000, dtype="float32")
|
||||
result = ffmpeg_time_stretch(audio, 0.8, SAMPLE_RATE)
|
||||
np.testing.assert_array_equal(result, audio)
|
||||
|
||||
def test_no_stretch_at_exactly_one(self):
|
||||
audio = np.ones(24000, dtype="float32")
|
||||
result = ffmpeg_time_stretch(audio, 1.0, SAMPLE_RATE)
|
||||
np.testing.assert_array_equal(result, audio)
|
||||
def test_no_stretch_at_exactly_one(self):
|
||||
audio = np.ones(24000, dtype="float32")
|
||||
result = ffmpeg_time_stretch(audio, 1.0, SAMPLE_RATE)
|
||||
np.testing.assert_array_equal(result, audio)
|
||||
|
||||
def test_empty_audio(self):
|
||||
result = ffmpeg_time_stretch(np.array([], dtype="float32"), 2.0, SAMPLE_RATE)
|
||||
assert len(result) == 0
|
||||
def test_empty_audio(self):
|
||||
result = ffmpeg_time_stretch(np.array([], dtype="float32"), 2.0, SAMPLE_RATE)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_stretch_reduces_duration(self):
|
||||
audio = np.random.randn(48000).astype("float32")
|
||||
result = ffmpeg_time_stretch(audio, 2.0, SAMPLE_RATE)
|
||||
assert len(result) < len(audio)
|
||||
assert len(result) > 0
|
||||
assert result.dtype == np.float32
|
||||
@patch("subprocess.Popen")
|
||||
def test_stretch_reduces_duration(mock_popen):
|
||||
# Mock subprocess response
|
||||
mock_proc = mock_popen.return_value
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.communicate.return_value = (b"\x00" * 400, b"")
|
||||
mock_proc.stdout.read.return_value = b"\x00" * 400
|
||||
|
||||
audio = np.random.randn(48000).astype("float32")
|
||||
result = ffmpeg_time_stretch(audio, 2.0, SAMPLE_RATE)
|
||||
|
||||
# Verify ffmpeg was called with correct args
|
||||
mock_popen.assert_called_once()
|
||||
args = mock_popen.call_args[0][0]
|
||||
assert "ffmpeg" in args[0]
|
||||
assert "-filter:a" in args
|
||||
assert any("atempo=" in arg for arg in args)
|
||||
|
||||
# Verify result has reduced duration and correct dtype
|
||||
assert len(result) < len(audio)
|
||||
assert len(result) > 0
|
||||
assert result.dtype == np.float32
|
||||
|
||||
@@ -41,16 +41,26 @@ class TestCreatePipelineForJob:
|
||||
def test_kokoro_provider(self, _dev, _reg, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = create_pipeline_for_job("kokoro", "en", use_gpu=False)
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="en", device="cpu")
|
||||
# "en" → fallback to EN_US → kokoro code "a"
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="a", device="cpu")
|
||||
assert result is mock_create.return_value
|
||||
|
||||
@patch("abogen.domain.pipeline_factory.create_pipeline")
|
||||
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=True)
|
||||
@patch("abogen.domain.pipeline_factory.resolve_device", return_value="cpu")
|
||||
def test_kokoro_provider_iso_code(self, _dev, _reg, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = create_pipeline_for_job("kokoro", "en-GB", use_gpu=False)
|
||||
# "en-GB" → EN_GB → kokoro code "b"
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="b", device="cpu")
|
||||
|
||||
@patch("abogen.domain.pipeline_factory.create_pipeline")
|
||||
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=False)
|
||||
@patch("abogen.domain.pipeline_factory.resolve_device", return_value="cpu")
|
||||
def test_unknown_provider_falls_back_to_kokoro(self, _dev, _reg, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = create_pipeline_for_job("unknown_provider", "en", use_gpu=False)
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="en", device="cpu")
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
@patch("abogen.domain.pipeline_factory.create_pipeline")
|
||||
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=True)
|
||||
@@ -58,7 +68,7 @@ class TestCreatePipelineForJob:
|
||||
def test_empty_provider_defaults_to_kokoro(self, _dev, _reg, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = create_pipeline_for_job("", "en", use_gpu=False)
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="en", device="cpu")
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
@patch("abogen.domain.pipeline_factory.create_pipeline")
|
||||
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=True)
|
||||
@@ -66,7 +76,7 @@ class TestCreatePipelineForJob:
|
||||
def test_none_provider_defaults_to_kokoro(self, _dev, _reg, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
result = create_pipeline_for_job(None, "en", use_gpu=False)
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="en", device="cpu")
|
||||
mock_create.assert_called_once_with("kokoro", lang_code="a", device="cpu")
|
||||
|
||||
|
||||
class TestDisposePipelines:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for domain enums — validation, properties, from_str methods."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.domain.enums import (
|
||||
InputFormat,
|
||||
Language,
|
||||
OutputFormat,
|
||||
SaveMode,
|
||||
SubtitleFormat,
|
||||
SubtitleMode,
|
||||
)
|
||||
|
||||
|
||||
class TestSubtitleMode:
|
||||
def test_from_str_case_insensitive(self):
|
||||
assert SubtitleMode.from_str("disabled") == SubtitleMode.DISABLED
|
||||
assert SubtitleMode.from_str("SENTENCE") == SubtitleMode.SENTENCE
|
||||
assert SubtitleMode.from_str("line") == SubtitleMode.LINE
|
||||
|
||||
def test_from_str_strips_whitespace(self):
|
||||
assert SubtitleMode.from_str(" Disabled ") == SubtitleMode.DISABLED
|
||||
|
||||
def test_from_str_invalid(self):
|
||||
with pytest.raises(ValueError, match="Invalid SubtitleMode"):
|
||||
SubtitleMode.from_str("invalid")
|
||||
|
||||
def test_comparison_with_str(self):
|
||||
assert SubtitleMode.DISABLED == "Disabled"
|
||||
assert SubtitleMode.SENTENCE != "Disabled"
|
||||
|
||||
|
||||
class TestOutputFormat:
|
||||
def test_dot_ext(self):
|
||||
assert OutputFormat.WAV.dot_ext == ".wav"
|
||||
assert OutputFormat.M4B.dot_ext == ".m4b"
|
||||
|
||||
def test_is_lossless(self):
|
||||
assert OutputFormat.WAV.is_lossless is True
|
||||
assert OutputFormat.FLAC.is_lossless is True
|
||||
assert OutputFormat.MP3.is_lossless is False
|
||||
assert OutputFormat.M4B.is_lossless is False
|
||||
|
||||
def test_from_str_strips_dot(self):
|
||||
assert OutputFormat.from_str(".wav") == OutputFormat.WAV
|
||||
assert OutputFormat.from_str(".MP3") == OutputFormat.MP3
|
||||
|
||||
def test_from_str_case_insensitive(self):
|
||||
assert OutputFormat.from_str("WAV") == OutputFormat.WAV
|
||||
assert OutputFormat.from_str("opus") == OutputFormat.OPUS
|
||||
|
||||
def test_from_str_invalid(self):
|
||||
with pytest.raises(ValueError, match="Invalid OutputFormat"):
|
||||
OutputFormat.from_str("avi")
|
||||
|
||||
|
||||
class TestSaveMode:
|
||||
def test_values(self):
|
||||
assert SaveMode.SAVE_NEXT_TO_INPUT == "save_next_to_input"
|
||||
assert SaveMode.CUSTOM_FOLDER == "custom_folder"
|
||||
|
||||
|
||||
class TestSubtitleFormat:
|
||||
def test_dot_ext(self):
|
||||
assert SubtitleFormat.SRT.dot_ext == ".srt"
|
||||
assert SubtitleFormat.ASS.dot_ext == ".ass"
|
||||
|
||||
def test_from_str_strips_dot(self):
|
||||
assert SubtitleFormat.from_str(".srt") == SubtitleFormat.SRT
|
||||
assert SubtitleFormat.from_str(".ASS") == SubtitleFormat.ASS
|
||||
|
||||
|
||||
class TestInputFormat:
|
||||
def test_is_book(self):
|
||||
assert InputFormat.EPUB.is_book is True
|
||||
assert InputFormat.PDF.is_book is True
|
||||
assert InputFormat.TXT.is_book is True
|
||||
assert InputFormat.MD.is_book is True
|
||||
assert InputFormat.SRT.is_book is False
|
||||
|
||||
def test_is_subtitle(self):
|
||||
assert InputFormat.SRT.is_subtitle is True
|
||||
assert InputFormat.ASS.is_subtitle is True
|
||||
assert InputFormat.VTT.is_subtitle is True
|
||||
assert InputFormat.EPUB.is_subtitle is False
|
||||
|
||||
def test_from_path(self):
|
||||
assert InputFormat.from_path(Path("book.epub")) == InputFormat.EPUB
|
||||
assert InputFormat.from_path(Path("sub.srt")) == InputFormat.SRT
|
||||
assert InputFormat.from_path(Path("notes.MD")) == InputFormat.MD
|
||||
assert InputFormat.from_path(Path("doc.markdown")) == InputFormat.MD
|
||||
|
||||
def test_from_path_invalid(self):
|
||||
with pytest.raises(ValueError, match="Unsupported input format"):
|
||||
InputFormat.from_path(Path("video.mp4"))
|
||||
|
||||
def test_dot_ext(self):
|
||||
assert InputFormat.EPUB.dot_ext == ".epub"
|
||||
assert InputFormat.SRT.dot_ext == ".srt"
|
||||
|
||||
|
||||
class TestLanguage:
|
||||
def test_iso_codes(self):
|
||||
assert Language.EN_US == "en-US"
|
||||
assert Language.EN_GB == "en-GB"
|
||||
assert Language.ZH == "zh"
|
||||
assert Language.JA == "ja"
|
||||
|
||||
def test_display_name(self):
|
||||
assert Language.EN_US.display_name == "American English"
|
||||
assert Language.JA.display_name == "Japanese"
|
||||
|
||||
def test_is_cjk(self):
|
||||
assert Language.ZH.is_cjk is True
|
||||
assert Language.JA.is_cjk is True
|
||||
assert Language.EN_US.is_cjk is False
|
||||
|
||||
def test_supports_subtitle_tokens(self):
|
||||
assert Language.EN_US.supports_subtitle_tokens is True
|
||||
assert Language.EN_GB.supports_subtitle_tokens is True
|
||||
assert Language.ZH.supports_subtitle_tokens is False
|
||||
|
||||
def test_from_str_case_insensitive(self):
|
||||
assert Language.from_str("EN-US") == Language.EN_US
|
||||
assert Language.from_str("en-gb") == Language.EN_GB
|
||||
assert Language.from_str("ZH") == Language.ZH
|
||||
|
||||
def test_from_str_invalid(self):
|
||||
with pytest.raises(ValueError, match="Invalid Language"):
|
||||
Language.from_str("en")
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Import/layering tests for the conversion flow unification.
|
||||
|
||||
Verifies that:
|
||||
- Application layer does not import from PyQt or WebUI
|
||||
- Adapters import from application/domain (not the other way around)
|
||||
- All application models are importable without GUI/Flask side effects
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ─── Application layer imports ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestApplicationLayerImports:
|
||||
"""Verify application layer has no PyQt/WebUI imports."""
|
||||
|
||||
APPLICATION_DIR = Path(__file__).parent.parent / "abogen" / "application"
|
||||
|
||||
def _get_python_files(self):
|
||||
"""Get all Python files in the application directory."""
|
||||
return list(self.APPLICATION_DIR.glob("*.py"))
|
||||
|
||||
def test_no_pyqt_imports(self):
|
||||
"""Application layer must not import from abogen.pyqt."""
|
||||
import re
|
||||
|
||||
forbidden = re.compile(r"from\s+abogen\.pyqt|import\s+abogen\.pyqt")
|
||||
violations = []
|
||||
|
||||
for py_file in self._get_python_files():
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
if forbidden.search(content):
|
||||
violations.append(py_file.name)
|
||||
|
||||
assert not violations, f"Application files import from PyQt: {violations}"
|
||||
|
||||
def test_no_webui_imports(self):
|
||||
"""Application layer must not import from abogen.webui."""
|
||||
import re
|
||||
|
||||
forbidden = re.compile(r"from\s+abogen\.webui|import\s+abogen\.webui")
|
||||
violations = []
|
||||
|
||||
for py_file in self._get_python_files():
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
if forbidden.search(content):
|
||||
violations.append(py_file.name)
|
||||
|
||||
assert not violations, f"Application files import from WebUI: {violations}"
|
||||
|
||||
def test_no_flask_imports(self):
|
||||
"""Application layer must not import Flask."""
|
||||
import re
|
||||
|
||||
forbidden = re.compile(r"from\s+flask|import\s+flask")
|
||||
violations = []
|
||||
|
||||
for py_file in self._get_python_files():
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
if forbidden.search(content):
|
||||
violations.append(py_file.name)
|
||||
|
||||
assert not violations, f"Application files import Flask: {violations}"
|
||||
|
||||
def test_no_qthread_imports(self):
|
||||
"""Application layer must not import QThread."""
|
||||
import re
|
||||
|
||||
forbidden = re.compile(r"from\s+PyQt6|import\s+PyQt6")
|
||||
violations = []
|
||||
|
||||
for py_file in self._get_python_files():
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
if forbidden.search(content):
|
||||
violations.append(py_file.name)
|
||||
|
||||
assert not violations, f"Application files import PyQt6: {violations}"
|
||||
|
||||
|
||||
class TestApplicationModelsImportable:
|
||||
"""Verify application models are importable without side effects."""
|
||||
|
||||
def test_conversion_request_importable(self):
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
|
||||
assert ConversionRequest is not None
|
||||
|
||||
def test_conversion_models_importable(self):
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
OutputLayout,
|
||||
SegmentPlan,
|
||||
)
|
||||
|
||||
assert all(
|
||||
cls is not None
|
||||
for cls in [ChapterPlan, ConversionPlan, IntroOutroSpec, OutputLayout, SegmentPlan]
|
||||
)
|
||||
|
||||
def test_conversion_result_importable(self):
|
||||
from abogen.application.conversion_result import ConversionError, ConversionResult
|
||||
|
||||
assert ConversionResult is not None
|
||||
assert ConversionError is not None
|
||||
|
||||
def test_conversion_ports_importable(self):
|
||||
from abogen.application.conversion_ports import (
|
||||
AudioSink,
|
||||
ConversionEvents,
|
||||
PipelineProvider,
|
||||
ResolvedVoice,
|
||||
SubtitleWriter,
|
||||
VoiceResolver,
|
||||
)
|
||||
|
||||
assert all(
|
||||
cls is not None
|
||||
for cls in [
|
||||
AudioSink,
|
||||
ConversionEvents,
|
||||
PipelineProvider,
|
||||
ResolvedVoice,
|
||||
SubtitleWriter,
|
||||
VoiceResolver,
|
||||
]
|
||||
)
|
||||
|
||||
def test_output_layout_service_importable(self):
|
||||
from abogen.application.output_layout_service import (
|
||||
resolve_chapter_path,
|
||||
resolve_merged_path,
|
||||
resolve_output_layout,
|
||||
should_merge_output,
|
||||
)
|
||||
|
||||
assert all(
|
||||
fn is not None
|
||||
for fn in [resolve_chapter_path, resolve_merged_path, resolve_output_layout, should_merge_output]
|
||||
)
|
||||
|
||||
def test_conversion_planner_importable(self):
|
||||
from abogen.application.conversion_planner import build_conversion_plan
|
||||
|
||||
assert build_conversion_plan is not None
|
||||
|
||||
def test_conversion_executor_importable(self):
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
|
||||
assert execute_conversion is not None
|
||||
|
||||
def test_conversion_service_importable(self):
|
||||
from abogen.application.conversion_service import run_conversion
|
||||
|
||||
assert run_conversion is not None
|
||||
|
||||
|
||||
class TestAdapterImports:
|
||||
"""Verify adapters import from application/domain correctly."""
|
||||
|
||||
def test_webui_adapter_imports_application(self):
|
||||
from abogen.webui.conversion_adapter import (
|
||||
WebJobEvents,
|
||||
WebPipelineProvider,
|
||||
WebVoiceResolver,
|
||||
build_conversion_request_from_job,
|
||||
)
|
||||
|
||||
assert all(
|
||||
cls is not None
|
||||
for cls in [
|
||||
WebJobEvents,
|
||||
WebPipelineProvider,
|
||||
WebVoiceResolver,
|
||||
build_conversion_request_from_job,
|
||||
]
|
||||
)
|
||||
|
||||
def test_pyqt_adapter_imports_application(self):
|
||||
from abogen.pyqt.conversion_adapter import (
|
||||
PyQtEvents,
|
||||
PyQtPipelineProvider,
|
||||
PyQtVoiceResolver,
|
||||
build_conversion_request_from_thread,
|
||||
)
|
||||
|
||||
assert all(
|
||||
cls is not None
|
||||
for cls in [
|
||||
PyQtEvents,
|
||||
PyQtPipelineProvider,
|
||||
PyQtVoiceResolver,
|
||||
build_conversion_request_from_thread,
|
||||
]
|
||||
)
|
||||
|
||||
def test_webui_adapter_does_not_import_pyqt(self):
|
||||
"""WebUI adapter must not import from PyQt."""
|
||||
import re
|
||||
|
||||
adapter_path = Path(__file__).parent.parent / "abogen" / "webui" / "conversion_adapter.py"
|
||||
content = adapter_path.read_text(encoding="utf-8")
|
||||
|
||||
forbidden = re.compile(r"from\s+abogen\.pyqt|import\s+abogen\.pyqt")
|
||||
assert not forbidden.search(content), "WebUI adapter imports from PyQt"
|
||||
|
||||
def test_pyqt_adapter_does_not_import_webui(self):
|
||||
"""PyQt adapter must not import from WebUI."""
|
||||
import re
|
||||
|
||||
adapter_path = Path(__file__).parent.parent / "abogen" / "pyqt" / "conversion_adapter.py"
|
||||
content = adapter_path.read_text(encoding="utf-8")
|
||||
|
||||
forbidden = re.compile(r"from\s+abogen\.webui|import\s+abogen\.webui")
|
||||
assert not forbidden.search(content), "PyQt adapter imports from WebUI"
|
||||
+20
-20
@@ -12,49 +12,49 @@ from abogen.domain.split_pattern import get_split_pattern
|
||||
|
||||
class TestEnglish:
|
||||
def test_english_sentence(self):
|
||||
assert get_split_pattern("a", "Sentence") == "\n"
|
||||
assert get_split_pattern("en-US", "Sentence") == "\n"
|
||||
|
||||
def test_english_sentence_comma(self):
|
||||
assert get_split_pattern("a", "Sentence + Comma") == "\n"
|
||||
assert get_split_pattern("en-US", "Sentence + Comma") == "\n"
|
||||
|
||||
def test_english_line(self):
|
||||
assert get_split_pattern("a", "Line") == "\n"
|
||||
assert get_split_pattern("en-US", "Line") == "\n"
|
||||
|
||||
def test_english_disabled(self):
|
||||
assert get_split_pattern("a", "Disabled") == "\n"
|
||||
assert get_split_pattern("en-US", "Disabled") == "\n"
|
||||
|
||||
def test_english_b(self):
|
||||
assert get_split_pattern("b", "Sentence") == "\n"
|
||||
def test_english_gb(self):
|
||||
assert get_split_pattern("en-GB", "Sentence") == "\n"
|
||||
|
||||
|
||||
# --- CJK languages ---
|
||||
|
||||
class TestCJK:
|
||||
def test_chinese_disabled(self):
|
||||
pattern = get_split_pattern("z", "Disabled")
|
||||
pattern = get_split_pattern("zh", "Disabled")
|
||||
assert pattern != "\n"
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_chinese_line(self):
|
||||
pattern = get_split_pattern("z", "Line")
|
||||
pattern = get_split_pattern("zh", "Line")
|
||||
assert pattern != "\n"
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_chinese_sentence(self):
|
||||
pattern = get_split_pattern("z", "Sentence")
|
||||
pattern = get_split_pattern("zh", "Sentence")
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_chinese_sentence_comma(self):
|
||||
pattern = get_split_pattern("z", "Sentence + Comma")
|
||||
pattern = get_split_pattern("zh", "Sentence + Comma")
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_japanese_disabled(self):
|
||||
pattern = get_split_pattern("j", "Disabled")
|
||||
pattern = get_split_pattern("ja", "Disabled")
|
||||
assert pattern != "\n"
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_japanese_sentence(self):
|
||||
pattern = get_split_pattern("j", "Sentence")
|
||||
pattern = get_split_pattern("ja", "Sentence")
|
||||
assert r"\n+" in pattern
|
||||
|
||||
|
||||
@@ -62,18 +62,18 @@ class TestCJK:
|
||||
|
||||
class TestOtherLanguages:
|
||||
def test_spanish_sentence(self):
|
||||
pattern = get_split_pattern("e", "Sentence")
|
||||
pattern = get_split_pattern("es", "Sentence")
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_spanish_line(self):
|
||||
assert get_split_pattern("e", "Line") == "\n"
|
||||
assert get_split_pattern("es", "Line") == "\n"
|
||||
|
||||
def test_spanish_disabled(self):
|
||||
# canonical: \n+ for non-CJK Disabled
|
||||
assert get_split_pattern("e", "Disabled") == r"\n+"
|
||||
assert get_split_pattern("es", "Disabled") == r"\n+"
|
||||
|
||||
def test_french_sentence_comma(self):
|
||||
pattern = get_split_pattern("f", "Sentence + Comma")
|
||||
pattern = get_split_pattern("fr", "Sentence + Comma")
|
||||
assert r"\n+" in pattern
|
||||
|
||||
def test_unknown_lang(self):
|
||||
@@ -85,17 +85,17 @@ class TestOtherLanguages:
|
||||
|
||||
class TestPatternStructure:
|
||||
def test_sentence_has_lookbehind(self):
|
||||
pattern = get_split_pattern("e", "Sentence")
|
||||
pattern = get_split_pattern("es", "Sentence")
|
||||
assert r"(?<=" in pattern
|
||||
|
||||
def test_sentence_comma_has_comma_chars(self):
|
||||
pattern = get_split_pattern("e", "Sentence + Comma")
|
||||
pattern = get_split_pattern("es", "Sentence + Comma")
|
||||
assert "," in pattern
|
||||
|
||||
def test_cjk_spacing_uses_star(self):
|
||||
pattern = get_split_pattern("z", "Sentence")
|
||||
pattern = get_split_pattern("zh", "Sentence")
|
||||
assert r"\s*" in pattern
|
||||
|
||||
def test_non_cjk_spacing_uses_plus(self):
|
||||
pattern = get_split_pattern("e", "Sentence")
|
||||
pattern = get_split_pattern("es", "Sentence")
|
||||
assert r"\s+" in pattern
|
||||
|
||||
Reference in New Issue
Block a user