Compare commits

...
30 Commits
Author SHA1 Message Date
Artem Akymenko fcec4e9fe5 fix: test_stretch_reduces_duration — remove stale self param, fix mock data size, fix atempo assertion 2026-07-22 15:32:18 +03:00
Artem Akymenko 72d5e3d1db fix: add keys() method to VoiceCache for resolve_intro compatibility 2026-07-22 15:31:43 +03:00
Artem Akymenko d3682e7672 refactor: dynamic ConversionRequest validation, remove 'or default' from adapters
- __post_init__: _apply_none_defaults() iterates dataclasses.fields() dynamically
- _NUMERIC_CONSTRAINTS and _ENUM_CONSTRAINTS dicts replace per-field if chains
- Both adapters pass values as-is (no 'or default' fallbacks)
- 18 validation tests + updated adapter tests for Enum assertions
2026-07-22 11:33:44 +00:00
Artem Akymenko 0805e9fdae refactor: Language Enum with ISO codes
- Language enum: en-US, en-GB, es, fr, hi, it, ja, pt-BR, zh
- Engine-specific mappings (kokoro → single-letter) live in pipeline_factory and synthesize
- spacy_utils uses Language enum keys for model mapping
- split_pattern uses Language enum properties (is_cjk)
- Updated all tests to use ISO codes
2026-07-22 10:54:39 +00:00
Artem Akymenko 4aef73ff85 refactor: remove infrastructure enum duplicates
- SubtitleFormat/SubtitleMode now only in domain/enums.py
- Added VTT to SubtitleFormat
- Renamed SENTENCE_HIGHLIGHTING → SENTENCE_HIGHLIGHT for consistency
- Infrastructure subtitle_writer imports from domain
2026-07-22 09:16:40 +00:00
Artem Akymenko f6a8008f51 refactor: typed Enums for format/mode fields
- SubtitleMode, OutputFormat, SaveMode, SubtitleFormat, InputFormat
- Properties: dot_ext, is_lossless, is_book, is_subtitle
- from_str/from_path class methods with normalization
- Updated domain and application layers to use Enums
- 17 new tests for enum validation and properties
2026-07-22 09:01:50 +00:00
Artem Akymenko dc5257252f refactor: run_tts_segment_loop also accepts SynthParams
- Reduces from 14 params to 5 unique params + SynthParams
- synthesize_text now passes params through cleanly
- PyQt intro/outro direct calls updated
2026-07-22 08:28:19 +00:00
Artem Akymenko c4cebb8822 refactor: SynthParams dataclass for synthesize_text
- Frozen dataclass in domain/conversion_engine.py with common params
- synthesize_text now takes params=SynthParams + unique kwargs
- Executor, PyQt legacy, WebUI legacy, and tests updated
- Adding new common params now only requires changing the dataclass
2026-07-22 08:21:45 +00:00
Artem Akymenko 93f5a46485 refactor: deduplicate synth params in executor
- Compute use_spacy and effective_subtitle_mode once instead of 6x each
- Reduces repeated ternary expressions across synthesize_text calls
2026-07-22 11:06:14 +03:00
Artem Akymenko 5f169a4921 refactor: replace executor _slugify with domain sanitize_filename_for_chapter
- Use existing domain function instead of duplicated local implementation
- Domain version includes OS-specific sanitization
2026-07-22 11:06:14 +03:00
Artem Akymenko df5705779e refactor: extract ConversionCancelled to conversion_ports
- Single definition in application layer
- Both adapters import from ports instead of defining locally
2026-07-22 11:06:14 +03:00
Artem Akymenko d0fe221176 refactor: ConversionPlan.request forward ref
- request type: Any → ConversionRequest via TYPE_CHECKING
2026-07-22 07:31:46 +00:00
Artem Akymenko 17700426fd clean: dead code removal + unused imports
- executor: remove dead subtitle_writer stub (lines 121-125)
- executor: replace getattr with direct field access on ResolvedVoice
- service: remove duplicate split_pattern import
- service: remove unused import time
- planner: remove unused import os
- adapters: remove unused import threading, time
2026-07-22 07:21:23 +00:00
Artem Akymenko 16b3f7d8a8 fix: clean direct_text in planner + remove redundant if/else
- _extract_source_text now applies clean_text() to direct_text (was skipped)
- _parse_chapters simplified: identical branches collapsed to single call
2026-07-22 07:05:50 +00:00
Artem Akymenko 1a3741ec50 fix: max_subtitle_words default 5 → 50
All UI layers use 50 (settings, Job, Thread, adapters). The value 5 was
incorrect and only masked by adapter fallbacks.
2026-07-21 14:58:58 +03:00
Artem Akymenko 7f317ca784 test: adapter field mapping + import layering tests
- 28 adapter tests (WebUI + PyQt): field mapping, events, provider, resolver
- 15 import/layering tests: no PyQt/WebUI in app layer, all models importable
2026-07-21 11:47:46 +00:00
Artem Akymenko d0e42ee691 fix: executor subtitle_writer leak + adapter Path/None-default fixes
- executor: manage subtitle_writer via ExitStack (stack.callback)
- executor: remove manual subtitle_writer.close()
- WebUI adapter: wrap source_path, output_folder, cover_image_path in Path()
- WebUI adapter: remove unused threading/time imports
- PyQt adapter: fix getattr(attr, None) or default for 4 fields
2026-07-21 11:47:36 +00:00
Artem Akymenko b1392084e1 chore: add .coverage to .gitignore 2026-07-21 09:35:36 +00:00
Artem Akymenko 71916aa39f fix: conversion_service import bug + coverage tests
Fix import error in _prepare_tts_context:
- apply_normalization_overrides doesn't exist in domain.normalization
- merge_pronunciation_overrides expects job-like object, not two lists
- Use _MockJob adapter to bridge ConversionRequest to existing API

Add test_application_coverage.py (23 tests):
- ConversionService: simple, logs, cancellation, empty text, multi-chapter, intro/outro, error
- OutputLayoutService: custom folder, source path, project, merged path, chapter path, should_merge
- Executor gaps: no layout, m4b, separate chapters, no intro/outro, voice fallback, silence

Coverage: 80% -> 92%
2026-07-21 09:34:47 +00:00
Artem Akymenko 4c4434c309 fix: sanitize_output_stem signature + audio_sink import
Two pre-existing bugs found during test coverage analysis:

1. sanitize_output_stem() only accepted 1 arg but resolve_project_layout
   passed 2 args (name, index) via sanitize_fn parameter.
   Fix: added optional index parameter to sanitize_output_stem.

2. audio_sink.py imported get_internal_cache_path from
   abogen.infrastructure.cache which doesn't exist.
   Fix: import from abogen.utils where the function lives.
2026-07-21 09:18:51 +00:00
Artem Akymenko 7973de3868 feat: ConversionService
Main orchestrator for the conversion flow. Both UIs call run_conversion().

Functions:
- run_conversion(request, events, pipeline_provider, voice_resolver) -> ConversionResult
- _prepare_tts_context(request, events) -> TTSContext

The service ties together planner, executor, and finalizers.
2026-07-21 11:30:15 +03:00
Artem Akymenko fd659d0f4f feat: PyQt adapter
Converts PyQt ConversionThread to ConversionRequest for the application layer.

Functions:
- build_conversion_request_from_thread(thread) -> ConversionRequest
- PyQtEvents: wraps thread signals for logging, progress, cancellation
- PyQtPipelineProvider: wraps existing backend
- PyQtVoiceResolver: wraps load_voice_cached

Subtitle file/timestamp special paths remain in ConversionThread.run().
2026-07-21 11:29:47 +03:00
Artem Akymenko e53251ef81 feat: WebUI adapter
Converts WebUI Job to ConversionRequest for the application layer.

Functions:
- build_conversion_request_from_job(job) -> ConversionRequest
- WebJobEvents: wraps Job for logging, progress, cancellation
- WebPipelineProvider: wraps PipelinePool for TTS backends
- WebVoiceResolver: wraps voice resolution function

The adapter is the bridge between WebUI layer and application/domain.
Application layer never accesses Job directly.
2026-07-21 11:29:38 +03:00
Artem Akymenko cd3cc9bce7 refactor: extract OutputLayoutService
Extract output path resolution from conversion_planner.py into
application/output_layout_service.py as a standalone service.

Functions:
- resolve_output_layout(request) -> OutputLayout
- resolve_merged_path(layout, request) -> Path
- resolve_chapter_path(layout, request, title, index) -> Path
- should_merge_output(request) -> bool

Planner now imports from output_layout_service instead of inline logic.
2026-07-21 11:17:28 +03:00
Artem Akymenko 75a3ad517a test: executor tests with fake backend/sink/ports
7 tests for the unified conversion executor:
- simple text conversion
- multi-chapter with separate chapter output
- voice markers
- intro/outro
- cancellation behavior
- progress reporting
- metadata preservation

Uses FakeBackend, FakeAudioSink, FakeSubtitleWriter, FakeEvents,
FakePipelineProvider, FakeVoiceResolver to test without real TTS.
2026-07-21 11:16:41 +03:00
Artem Akymenko a6b7ce69aa feat: unified conversion executor (execute_conversion)
Takes ConversionPlan + ports, executes TTS conversion, returns ConversionResult.
- Opens/closes audio sinks and subtitle writers
- Processes intro/outro
- Executes chapter loop with heading + body segments
- Collects chapter_markers and chunk_markers
- Uses domain functions only (no UI imports)
2026-07-21 11:16:41 +03:00
Artem Akymenko 680418fa1d test: planner tests + domain regression tests
51 tests for the unified conversion planner:
- build_conversion_plan: direct text, voice markers, chunks, chapters, intro/outro, output layout
- Domain regression: chapter parsing, voice markers, TTSContext, voice resolution, intro/outro, output paths, subtitles
- All tests use domain functions only (no UI, no TTS, no audio I/O)
2026-07-21 11:16:41 +03:00
Artem Akymenko 53b850ef41 feat: unified conversion planner (build_conversion_plan)
Pure function that takes ConversionRequest -> ConversionPlan.
Handles chapter parsing, voice markers, chunks, intro/outro, output layout.
Replaces duplicated planning logic in both PyQt and WebUI runners.
2026-07-21 11:16:41 +03:00
Artem Akymenko 7ed4eca68c feat: application layer models and ports for conversion unification
- application/conversion_models.py: SegmentPlan, ChapterPlan, ConversionPlan, OutputLayout, IntroOutroSpec
- application/conversion_request.py: ConversionRequest (normalized input)
- application/conversion_result.py: ConversionResult, ConversionError (normalized output)
- application/conversion_ports.py: protocols (ConversionEvents, PipelineProvider, VoiceResolver, SubtitleWriter, AudioSink)

These are pure data models and interfaces. No implementation yet.
2026-07-21 11:16:41 +03:00
Artem Akymenko e1e49e8a0f test: regression tests for conversion flow unification
Three new test files describing expected behavior before refactoring:
- test_conversion_planner.py: chapter parsing, voice markers, TTSContext, intro/outro, output paths, subtitles (30 tests)
- test_conversion_request.py: settings, context building, chapter selection, cancellation/logging protocols (15 tests)
- test_conversion_executor.py: synthesize_text, process_and_write_subtitles, full pipeline with fake backend/sink (12 tests)

All 1310 tests pass (1253 existing + 57 new).
2026-07-21 11:16:40 +03:00
37 changed files with 5648 additions and 191 deletions
+1
View File
@@ -39,3 +39,4 @@ dist/
test_assets/ test_assets/
dev_notes/ dev_notes/
.claude/ .claude/
.coverage
+8
View File
@@ -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.
"""
+434
View File
@@ -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
+95
View File
@@ -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
+348
View File
@@ -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
+112
View File
@@ -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."""
...
+156
View 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}"
)
+47
View File
@@ -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
+172
View File
@@ -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,
)
+150
View File
@@ -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
+1 -1
View File
@@ -54,7 +54,7 @@ def _ensure_ffmpeg() -> None:
def _get_ffmpeg_cache_root() -> str: 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") return get_internal_cache_path("ffmpeg")
+41 -52
View File
@@ -23,6 +23,7 @@ from typing import Any, Callable, List, Optional, Protocol
from abogen.domain.audio_sink import AudioSink from abogen.domain.audio_sink import AudioSink
from abogen.domain.conversion_pipeline import tts_segments from abogen.domain.conversion_pipeline import tts_segments
from abogen.domain.enums import SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.progress import calc_etr_str from abogen.domain.progress import calc_etr_str
from abogen.domain.subtitle_generation import process_subtitle_tokens from abogen.domain.subtitle_generation import process_subtitle_tokens
@@ -55,44 +56,29 @@ class SegmentInfo:
def run_tts_segment_loop( def run_tts_segment_loop(
*, *,
text: str, text: str,
params: SynthParams,
backend: Any, backend: Any,
voice: Any, voice: Any,
speed: float, speed: float,
split_pattern: str, split_pattern: str,
stats: SegmentStats,
check_cancel: CancelChecker,
on_progress: Callable[[int, str], None],
chapter_sink: Optional[AudioSink] = None, chapter_sink: Optional[AudioSink] = None,
audio_sink: Optional[AudioSink] = None,
preview_callback: Optional[Callable[[str], None]] = None, preview_callback: Optional[Callable[[str], None]] = None,
on_segment: Optional[Callable[[SegmentInfo], 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]: ) -> tuple[int, list]:
"""Run the core TTS segment iteration loop. """Run the core TTS segment iteration loop.
Args: Args:
text: Normalized text to synthesize. text: Normalized text to synthesize.
params: Common synthesis parameters (stats, callbacks, sinks, etc.).
backend: TTS pipeline instance (Kokoro or Supertonic). backend: TTS pipeline instance (Kokoro or Supertonic).
voice: Voice name/id for the backend. voice: Voice name/id for the backend.
speed: Speech speed multiplier. speed: Speech speed multiplier.
split_pattern: Regex pattern used by the TTS engine for sentence splitting. 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. preview_callback: Called with a short preview string per segment.
on_segment: Called with a SegmentInfo for each segment *before* on_segment: Called with a SegmentInfo for each segment *before*
audio is written. Useful for callers that need per-segment audio is written. Useful for callers that need per-segment
subtitle processing (e.g. PyQt dual-writer pattern). subtitle processing (e.g. PyQt dual-writer pattern).
When provided, the default subtitle accumulation is skipped. 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: Returns:
Tuple of (segment_count, accumulated_subtitle_tokens). Tuple of (segment_count, accumulated_subtitle_tokens).
@@ -108,26 +94,26 @@ def run_tts_segment_loop(
voice=voice, voice=voice,
speed=speed, speed=speed,
split_pattern=split_pattern, split_pattern=split_pattern,
current_time=stats.current_time, current_time=params.stats.current_time,
): ):
if check_cancel(): if params.check_cancel():
break break
local_segments += 1 local_segments += 1
stats.processed_chars += len(seg.graphemes) params.stats.processed_chars += len(seg.graphemes)
# Progress # Progress
if stats.total_characters: if params.stats.total_characters:
percent = min(int(stats.processed_chars / stats.total_characters * 100), 99) percent = min(int(params.stats.processed_chars / params.stats.total_characters * 100), 99)
else: 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( etr_str = calc_etr_str(
time.time() - stats.etr_start_time, time.time() - params.stats.etr_start_time,
stats.processed_chars, params.stats.processed_chars,
stats.total_characters, params.stats.total_characters,
) )
on_progress(percent, etr_str) params.on_progress(percent, etr_str)
# Preview / log # Preview / log
if preview_callback: if preview_callback:
@@ -140,23 +126,23 @@ def run_tts_segment_loop(
audio=seg.audio, audio=seg.audio,
tokens=list(seg.tokens) if seg.tokens else [], tokens=list(seg.tokens) if seg.tokens else [],
duration=seg.duration, 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) on_segment(info)
# Write audio # Write audio
if chapter_sink: if chapter_sink:
chapter_sink.write(seg.audio) chapter_sink.write(seg.audio)
if audio_sink: if params.audio_sink:
audio_sink.write(seg.audio) params.audio_sink.write(seg.audio)
# Accumulate subtitle tokens (default path; skipped if on_segment handles it) # 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) accumulated_tokens.extend(seg.tokens)
# Update timing # Update timing
if audio_sink: if params.audio_sink:
stats.current_time += seg.duration params.stats.current_time += seg.duration
return local_segments, accumulated_tokens return local_segments, accumulated_tokens
@@ -191,24 +177,34 @@ def process_and_write_subtitles(
subtitle_writer.write_entry(start=start, end=end, text=text) 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( def synthesize_text(
*, *,
text: str, text: str,
tts_context: TTSContext, params: SynthParams,
backend: Any, backend: Any,
voice: Any, voice: Any,
speed: float, speed: float,
stats: SegmentStats,
check_cancel: CancelChecker,
on_progress: Callable[[int, str], None],
chapter_sink: Optional[AudioSink] = None, chapter_sink: Optional[AudioSink] = None,
audio_sink: Optional[AudioSink] = None,
preview_callback: Optional[Callable[[str], None]] = None, preview_callback: Optional[Callable[[str], None]] = None,
on_segment: Optional[Callable[[SegmentInfo], 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, split_pattern_override: Optional[str] = None,
) -> tuple[int, list]: ) -> tuple[int, list]:
"""Normalize text and run TTS — the single entry point for both UIs. """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. Combines TTSContext.normalize() + run_tts_segment_loop() into one call.
UI-specific concerns (provider resolution, progress display) stay in the UI. 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( return run_tts_segment_loop(
text=normalized, text=normalized,
params=params,
backend=backend, backend=backend,
voice=voice, voice=voice,
speed=speed, speed=speed,
split_pattern=split_pattern_override or tts_context.split_pattern, split_pattern=split_pattern_override or params.tts_context.split_pattern,
stats=stats,
check_cancel=check_cancel,
on_progress=on_progress,
chapter_sink=chapter_sink, chapter_sink=chapter_sink,
audio_sink=audio_sink,
preview_callback=preview_callback, preview_callback=preview_callback,
on_segment=on_segment, on_segment=on_segment,
subtitle_mode=subtitle_mode,
max_subtitle_words=max_subtitle_words,
lang_code=lang_code,
use_spacy_segmentation=use_spacy_segmentation,
) )
+3 -1
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from abogen.domain.enums import SubtitleMode
from typing import Any, Callable, Dict, Iterator, List, Optional from typing import Any, Callable, Dict, Iterator, List, Optional
import numpy as np import numpy as np
@@ -221,7 +223,7 @@ def emit_text_to_sinks(
# Flush subtitle tokens # Flush subtitle tokens
if subtitle_writer and accumulated_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] = [] new_entries: List[tuple] = []
process_subtitle_tokens( process_subtitle_tokens(
accumulated_tokens, accumulated_tokens,
+179
View File
@@ -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]}")
+8 -5
View File
@@ -76,7 +76,7 @@ def sanitize_filename_for_chapter(title: str, index: int, max_len: int = 80) ->
return f"{index:02d}_{sanitized}" 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 base = Path(name or "").stem
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_") sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
return sanitized or "output" 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) chapter.text = newline_regex.sub(" ", chapter.text)
from abogen.domain.enums import SaveMode
def resolve_output_directory( def resolve_output_directory(
*, *,
save_mode: str, save_mode: str,
@@ -108,13 +111,13 @@ def resolve_output_directory(
user_output_path: Optional[Path], user_output_path: Optional[Path],
user_cache_outputs: Optional[Path], user_cache_outputs: Optional[Path],
) -> 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 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 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) 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_output_path
return user_cache_outputs or Path(".") return user_cache_outputs or Path(".")
+22 -1
View File
@@ -9,9 +9,23 @@ from __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from abogen.domain.device import select_device from abogen.domain.device import select_device
from abogen.domain.enums import Language
from abogen.domain.voice_resolution import initialize_voice_cache from abogen.domain.voice_resolution import initialize_voice_cache
from abogen.tts_plugin.utils import create_pipeline, is_plugin_registered 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: def resolve_device(use_gpu: bool) -> str:
"""Determine compute device from job and global config flags.""" """Determine compute device from job and global config flags."""
@@ -36,11 +50,18 @@ def create_pipeline_for_job(
if not is_plugin_registered(provider): if not is_plugin_registered(provider):
provider = "kokoro" 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": if provider == "supertonic":
return create_pipeline("supertonic") return create_pipeline("supertonic")
device = resolve_device(use_gpu) 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: def dispose_pipelines(pipelines: Dict[str, Any]) -> None:
+16 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
"""Unified split pattern logic extracted from 3 copies.""" """Unified split pattern logic extracted from 3 copies."""
import re import re
from abogen.domain.enums import Language, SubtitleMode
PUNCTUATION_SENTENCE = r".!?。!?" PUNCTUATION_SENTENCE = r".!?。!?"
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、," PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
@@ -18,23 +19,32 @@ def get_split_pattern(language: str, subtitle_mode: str) -> str:
Returns: Returns:
Split pattern string 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 # For English, always use newline splitting only
if language in ("a", "b"): if lang in (Language.EN_US, Language.EN_GB):
return "\n" return "\n"
# Determine spacing pattern based on language # 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 # For CJK languages, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting. # 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+" return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
if subtitle_mode == "Line": if mode == SubtitleMode.LINE:
return "\n" return "\n"
elif subtitle_mode == "Sentence": elif mode == SubtitleMode.SENTENCE:
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+" return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
elif subtitle_mode == "Sentence + Comma": elif mode == SubtitleMode.SENTENCE_COMMA:
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+" return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
else: else:
return r"\n+" return r"\n+"
+11 -9
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import re import re
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from abogen.domain.enums import Language, SubtitleMode
# Punctuation constants for sentence splitting # Punctuation constants for sentence splitting
PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ?? PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ??
@@ -50,17 +52,17 @@ def process_subtitle_tokens(
# spaCy is disabled when subtitle mode is "Disabled" or "Line" # spaCy is disabled when subtitle mode is "Disabled" or "Line"
use_spacy_for_english = ( use_spacy_for_english = (
use_spacy_segmentation use_spacy_segmentation
and subtitle_mode not in ["Disabled", "Line"] and subtitle_mode not in [SubtitleMode.DISABLED, SubtitleMode.LINE]
and lang_code in ["a", "b"] and lang_code in [Language.EN_US, Language.EN_GB]
and subtitle_mode in ["Sentence", "Sentence + Comma"] and subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA]
) )
if subtitle_mode == "Sentence + Highlighting": if subtitle_mode == SubtitleMode.SENTENCE_HIGHLIGHT:
_process_karaoke_highlighting( _process_karaoke_highlighting(
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
) )
elif subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]: elif subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA, SubtitleMode.LINE]:
if use_spacy_for_english and subtitle_mode != "Line": if use_spacy_for_english and subtitle_mode != SubtitleMode.LINE:
_process_spacy_sentences( _process_spacy_sentences(
processed_tokens, subtitle_entries, max_subtitle_words, processed_tokens, subtitle_entries, max_subtitle_words,
subtitle_mode, lang_code, fallback_end_time 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] sentence_boundaries = [sent.end_char for sent in doc.sents]
# For "Sentence + Comma" mode, also split on commas # For "Sentence + Comma" mode, also split on commas
if subtitle_mode == "Sentence + Comma": if subtitle_mode == SubtitleMode.SENTENCE_COMMA:
comma_positions = [ comma_positions = [
i + 1 for i, c in enumerate(full_text) if c == "," i + 1 for i, c in enumerate(full_text) if c == ","
] ]
@@ -242,9 +244,9 @@ def _process_regex_sentences(
) -> None: ) -> None:
"""Process tokens using regex for sentence boundary detection.""" """Process tokens using regex for sentence boundary detection."""
# Define separator pattern based on mode # Define separator pattern based on mode
if subtitle_mode == "Line": if subtitle_mode == SubtitleMode.LINE:
separator = r"\n" separator = r"\n"
elif subtitle_mode == "Sentence": elif subtitle_mode == SubtitleMode.SENTENCE:
# Use punctuation without comma # Use punctuation without comma
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]" separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
else: # Sentence + Comma else: # Sentence + Comma
+4
View File
@@ -33,6 +33,10 @@ class VoiceCache:
"""Clear all cached voices.""" """Clear all cached voices."""
self._cache.clear() self._cache.clear()
def keys(self):
"""Return cached voice specs."""
return self._cache.keys()
def __contains__(self, voice_spec: str) -> bool: def __contains__(self, voice_spec: str) -> bool:
return self.contains(voice_spec) return self.contains(voice_spec)
+1 -14
View File
@@ -6,23 +6,10 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import List, Optional, TextIO from typing import List, Optional, TextIO
from abogen.domain.enums import SubtitleFormat, SubtitleMode
from abogen.subtitle_utils import clean_subtitle_text 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): class SubtitleAlignment(Enum):
LEFT = "left" LEFT = "left"
CENTER = "center" CENTER = "center"
+32 -18
View File
@@ -36,7 +36,7 @@ from abogen.domain.output_paths import (
) )
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32 from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.audio_sink import AudioSink, open_audio_sink
from abogen.domain.conversion_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.intro_outro import resolve_intro, resolve_outro
from abogen.domain.audio_buffer import ( from abogen.domain.audio_buffer import (
create_silence, create_silence,
@@ -773,17 +773,20 @@ class ConversionThread(QThread):
etr_start_time=self.etr_start_time, etr_start_time=self.etr_start_time,
total_characters=self.total_char_count, 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( run_tts_segment_loop(
text=intro_spec.text, text=intro_spec.text,
params=intro_synth,
backend=self.backend, backend=self.backend,
voice=loaded_intro_voice, voice=loaded_intro_voice,
speed=self.speed, speed=self.speed,
split_pattern=self.split_pattern, 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 self.processed_char_count = intro_stats.processed_chars
current_time = intro_stats.current_time current_time = intro_stats.current_time
@@ -1016,18 +1019,26 @@ class ConversionThread(QThread):
total_characters=self.total_char_count, total_characters=self.total_char_count,
) )
try: synth_params = SynthParams(
synthesize_text(
text=text_segment,
tts_context=self._tts_context, tts_context=self._tts_context,
backend=self.backend,
voice=loaded_voice,
speed=self.speed,
stats=stats, stats=stats,
check_cancel=_qt_check_cancel, check_cancel=_qt_check_cancel,
on_progress=_qt_on_progress, on_progress=_qt_on_progress,
chapter_sink=chapter_sink,
audio_sink=merged_sink if merge_chapters_at_end else None, 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,
params=synth_params,
backend=self.backend,
voice=loaded_voice,
speed=self.speed,
chapter_sink=chapter_sink,
on_segment=_qt_on_segment, on_segment=_qt_on_segment,
split_pattern_override=active_split_pattern, split_pattern_override=active_split_pattern,
) )
@@ -1098,17 +1109,20 @@ class ConversionThread(QThread):
etr_start_time=self.etr_start_time, etr_start_time=self.etr_start_time,
total_characters=self.total_char_count, 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( run_tts_segment_loop(
text=outro_spec.text, text=outro_spec.text,
params=outro_synth,
backend=self.backend, backend=self.backend,
voice=loaded_outro_voice, voice=loaded_outro_voice,
speed=self.speed, speed=self.speed,
split_pattern=self.split_pattern, 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 self.processed_char_count = outro_stats.processed_chars
current_time = outro_stats.current_time current_time = outro_stats.current_time
+192
View File
@@ -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
View File
@@ -2,21 +2,23 @@
Lazy-loaded spaCy utilities for sentence segmentation. Lazy-loaded spaCy utilities for sentence segmentation.
""" """
from abogen.domain.enums import Language
# Cached spaCy module and models (lazy loaded) # Cached spaCy module and models (lazy loaded)
_spacy = None _spacy = None
_nlp_cache = {} _nlp_cache = {}
# Language code to spaCy model mapping # Language code to spaCy model mapping
SPACY_MODELS = { SPACY_MODELS = {
"a": "en_core_web_sm", # American English Language.EN_US: "en_core_web_sm",
"b": "en_core_web_sm", # British English Language.EN_GB: "en_core_web_sm",
"e": "es_core_news_sm", # Spanish Language.ES: "es_core_news_sm",
"f": "fr_core_news_sm", # French Language.FR: "fr_core_news_sm",
"i": "it_core_news_sm", # Italian Language.IT: "it_core_news_sm",
"p": "pt_core_news_sm", # Brazilian Portuguese Language.PT_BR: "pt_core_news_sm",
"z": "zh_core_web_sm", # Mandarin Chinese Language.ZH: "zh_core_web_sm",
"j": "ja_core_news_sm", # Japanese Language.JA: "ja_core_news_sm",
"h": "xx_sent_ud_sm", # Hindi (multi-language model) Language.HI: "xx_sent_ud_sm",
} }
@@ -36,10 +38,9 @@ def _load_spacy():
def get_spacy_model(lang_code, log_callback=None): def get_spacy_model(lang_code, log_callback=None):
""" """
Get or load a spaCy model for the given language code. Get or load a spaCy model for the given language code.
Downloads the model automatically if not available.
Args: 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 log_callback: Optional function to log messages
Returns: Returns:
@@ -58,6 +59,14 @@ def get_spacy_model(lang_code, log_callback=None):
else: else:
print(msg) 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 # Check if model is cached
if lang_code in _nlp_cache: if lang_code in _nlp_cache:
return _nlp_cache[lang_code] return _nlp_cache[lang_code]
+162
View File
@@ -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,
)
+12 -8
View File
@@ -117,7 +117,7 @@ from abogen.domain.audio_buffer import (
) )
from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.audio_sink import AudioSink, open_audio_sink
from abogen.domain.pipeline_factory import PipelinePool from abogen.domain.pipeline_factory import PipelinePool
from abogen.domain.conversion_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_loader import VoiceCache, resolve_voice
from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target
@@ -462,23 +462,27 @@ def run_conversion_job(job: Job) -> None:
def _preview(text: str) -> None: def _preview(text: str) -> None:
job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or ''}: {text[:80]}") job.add_log(f"{prefix}{stats.processed_chars:,}/{job.total_characters or ''}: {text[:80]}")
local_segments, accumulated_tokens = synthesize_text( synth_params = SynthParams(
text=source_text,
tts_context=tts_context, tts_context=tts_context,
backend=backend,
voice=resolved_voice,
speed=effective_speed,
stats=stats, stats=stats,
check_cancel=canceller, check_cancel=canceller,
on_progress=_on_progress, on_progress=_on_progress,
chapter_sink=chapter_sink,
audio_sink=audio_sink, audio_sink=audio_sink,
preview_callback=_preview,
subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled", subtitle_mode=job.subtitle_mode if (subtitle_writer and audio_sink) else "Disabled",
max_subtitle_words=job.max_subtitle_words, max_subtitle_words=job.max_subtitle_words,
lang_code=job.language, lang_code=job.language,
use_spacy_segmentation=job.subtitle_mode not in ("Disabled", "Line"), 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 current_time = stats.current_time
if subtitle_writer and audio_sink and accumulated_tokens: if subtitle_writer and audio_sink and accumulated_tokens:
+23 -2
View File
@@ -7,8 +7,22 @@ from flask import current_app, send_file
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from abogen.domain.device import select_device as _select_device 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 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 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: 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: with _preview_pipeline_lock:
pipeline = _preview_pipelines.get(key) pipeline = _preview_pipelines.get(key)
if pipeline is not None: if pipeline is not None:
return pipeline return pipeline
from abogen.tts_plugin.utils import create_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 _preview_pipelines[key] = pipeline
return pipeline return pipeline
+684
View File
@@ -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
+500
View File
@@ -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
+413
View File
@@ -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
+513
View File
@@ -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"
+560
View File
@@ -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
+286
View File
@@ -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
+18 -1
View File
@@ -2,6 +2,7 @@
import numpy as np import numpy as np
import pytest import pytest
from unittest.mock import patch
from abogen.domain.audio_buffer import fit_audio_to_duration, ffmpeg_time_stretch, SAMPLE_RATE from abogen.domain.audio_buffer import fit_audio_to_duration, ffmpeg_time_stretch, SAMPLE_RATE
@@ -51,9 +52,25 @@ class TestFfmpegTimeStretch:
result = ffmpeg_time_stretch(np.array([], dtype="float32"), 2.0, SAMPLE_RATE) result = ffmpeg_time_stretch(np.array([], dtype="float32"), 2.0, SAMPLE_RATE)
assert len(result) == 0 assert len(result) == 0
def test_stretch_reduces_duration(self): @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") audio = np.random.randn(48000).astype("float32")
result = ffmpeg_time_stretch(audio, 2.0, SAMPLE_RATE) 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) < len(audio)
assert len(result) > 0 assert len(result) > 0
assert result.dtype == np.float32 assert result.dtype == np.float32
+14 -4
View File
@@ -41,16 +41,26 @@ class TestCreatePipelineForJob:
def test_kokoro_provider(self, _dev, _reg, mock_create): def test_kokoro_provider(self, _dev, _reg, mock_create):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
result = create_pipeline_for_job("kokoro", "en", use_gpu=False) 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 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.create_pipeline")
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=False) @patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=False)
@patch("abogen.domain.pipeline_factory.resolve_device", return_value="cpu") @patch("abogen.domain.pipeline_factory.resolve_device", return_value="cpu")
def test_unknown_provider_falls_back_to_kokoro(self, _dev, _reg, mock_create): def test_unknown_provider_falls_back_to_kokoro(self, _dev, _reg, mock_create):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
result = create_pipeline_for_job("unknown_provider", "en", use_gpu=False) 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.create_pipeline")
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=True) @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): def test_empty_provider_defaults_to_kokoro(self, _dev, _reg, mock_create):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
result = create_pipeline_for_job("", "en", use_gpu=False) 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.create_pipeline")
@patch("abogen.domain.pipeline_factory.is_plugin_registered", return_value=True) @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): def test_none_provider_defaults_to_kokoro(self, _dev, _reg, mock_create):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
result = create_pipeline_for_job(None, "en", use_gpu=False) 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: class TestDisposePipelines:
+131
View File
@@ -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")
+221
View File
@@ -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
View File
@@ -12,49 +12,49 @@ from abogen.domain.split_pattern import get_split_pattern
class TestEnglish: class TestEnglish:
def test_english_sentence(self): 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): 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): 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): 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): def test_english_gb(self):
assert get_split_pattern("b", "Sentence") == "\n" assert get_split_pattern("en-GB", "Sentence") == "\n"
# --- CJK languages --- # --- CJK languages ---
class TestCJK: class TestCJK:
def test_chinese_disabled(self): def test_chinese_disabled(self):
pattern = get_split_pattern("z", "Disabled") pattern = get_split_pattern("zh", "Disabled")
assert pattern != "\n" assert pattern != "\n"
assert r"\n+" in pattern assert r"\n+" in pattern
def test_chinese_line(self): def test_chinese_line(self):
pattern = get_split_pattern("z", "Line") pattern = get_split_pattern("zh", "Line")
assert pattern != "\n" assert pattern != "\n"
assert r"\n+" in pattern assert r"\n+" in pattern
def test_chinese_sentence(self): def test_chinese_sentence(self):
pattern = get_split_pattern("z", "Sentence") pattern = get_split_pattern("zh", "Sentence")
assert r"\n+" in pattern assert r"\n+" in pattern
def test_chinese_sentence_comma(self): 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 assert r"\n+" in pattern
def test_japanese_disabled(self): def test_japanese_disabled(self):
pattern = get_split_pattern("j", "Disabled") pattern = get_split_pattern("ja", "Disabled")
assert pattern != "\n" assert pattern != "\n"
assert r"\n+" in pattern assert r"\n+" in pattern
def test_japanese_sentence(self): def test_japanese_sentence(self):
pattern = get_split_pattern("j", "Sentence") pattern = get_split_pattern("ja", "Sentence")
assert r"\n+" in pattern assert r"\n+" in pattern
@@ -62,18 +62,18 @@ class TestCJK:
class TestOtherLanguages: class TestOtherLanguages:
def test_spanish_sentence(self): def test_spanish_sentence(self):
pattern = get_split_pattern("e", "Sentence") pattern = get_split_pattern("es", "Sentence")
assert r"\n+" in pattern assert r"\n+" in pattern
def test_spanish_line(self): def test_spanish_line(self):
assert get_split_pattern("e", "Line") == "\n" assert get_split_pattern("es", "Line") == "\n"
def test_spanish_disabled(self): def test_spanish_disabled(self):
# canonical: \n+ for non-CJK Disabled # 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): 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 assert r"\n+" in pattern
def test_unknown_lang(self): def test_unknown_lang(self):
@@ -85,17 +85,17 @@ class TestOtherLanguages:
class TestPatternStructure: class TestPatternStructure:
def test_sentence_has_lookbehind(self): def test_sentence_has_lookbehind(self):
pattern = get_split_pattern("e", "Sentence") pattern = get_split_pattern("es", "Sentence")
assert r"(?<=" in pattern assert r"(?<=" in pattern
def test_sentence_comma_has_comma_chars(self): 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 assert "," in pattern
def test_cjk_spacing_uses_star(self): def test_cjk_spacing_uses_star(self):
pattern = get_split_pattern("z", "Sentence") pattern = get_split_pattern("zh", "Sentence")
assert r"\s*" in pattern assert r"\s*" in pattern
def test_non_cjk_spacing_uses_plus(self): 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 assert r"\s+" in pattern