refactor: group ConversionRequest fields into config objects

Domain config types (domain/config_types.py):
- PronunciationConfig: pronunciation/heteronym/normalization overrides
- SubtitleConfig: mode, format, max_words
- CoverConfig: path, mime

Domain functions now accept config objects:
- build_tts_context(subtitle=, pronunciation=) instead of 9 individual params
- make_subtitle_writer(subtitle=) instead of 3 params
- process_and_write_subtitles(subtitle=) instead of 2 params
- embed_m4b_metadata(cover=) instead of 2 params
- build_epub3_package(cover=) instead of 2 params

ConversionRequest: 18 flat fields + 8 config objects
Application/config.py re-exports domain types
All tests updated to new API
This commit is contained in:
Artem Akymenko
2026-07-28 13:41:45 +03:00
parent 146cc81271
commit 953bef1e71
19 changed files with 354 additions and 257 deletions
+21 -13
View File
@@ -5,13 +5,20 @@ If the object is None, the feature is disabled.
This keeps ConversionRequest clean: no boolean flags for feature toggles, This keeps ConversionRequest clean: no boolean flags for feature toggles,
no scattered parameters across unrelated fields. no scattered parameters across unrelated fields.
Domain config types (PronunciationConfig, SubtitleConfig) live in
domain/config_types.py — domain defines the contract, app fills them.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from abogen.domain.config_types import CoverConfig, PronunciationConfig, SubtitleConfig
from abogen.domain.enums import OutputFormat, SaveMode
@dataclass(frozen=True) @dataclass(frozen=True)
class WordSubstitutionConfig: class WordSubstitutionConfig:
@@ -48,19 +55,6 @@ class Epub3ExportConfig:
book_id: str = "" book_id: str = ""
@dataclass(frozen=True)
class PronunciationConfig:
"""Pronunciation and normalization override settings.
Groups all pronunciation/heteronym/normalization overrides
that are compiled into a TTSContext before conversion.
"""
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
@dataclass(frozen=True) @dataclass(frozen=True)
class ChapterChunkConfig: class ChapterChunkConfig:
"""Chapter and chunk configuration. """Chapter and chunk configuration.
@@ -85,3 +79,17 @@ class ChapterChunkConfig:
raise ValueError( raise ValueError(
f"speaker_mode must be one of {_VALID_SPEAKER_MODES}, got {self.speaker_mode!r}" f"speaker_mode must be one of {_VALID_SPEAKER_MODES}, got {self.speaker_mode!r}"
) )
@dataclass(frozen=True)
class SaveConfig:
"""Save/output settings.
Groups save mode, output folder, chapter splitting, and merge options.
"""
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
+15 -21
View File
@@ -193,7 +193,7 @@ def execute_conversion(
) )
# Compute subtitle flag once (used in every synthesize_text call) # Compute subtitle flag once (used in every synthesize_text call)
use_spacy = request.subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE) use_spacy = request.subtitle.mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
# Output paths # Output paths
output_layout = plan.output_layout output_layout = plan.output_layout
@@ -201,7 +201,7 @@ def execute_conversion(
raise ValueError("ConversionPlan must have an output_layout") raise ValueError("ConversionPlan must have an output_layout")
# Determine if merged output is needed # Determine if merged output is needed
merge_chapters = request.merge_chapters_at_end or not request.save_chapters_separately merge_chapters = request.save.merge_chapters_at_end or not request.save.save_chapters_separately
if request.output_format == OutputFormat.M4B: if request.output_format == OutputFormat.M4B:
merge_chapters = True merge_chapters = True
@@ -232,19 +232,17 @@ def execute_conversion(
# Open subtitle writer if needed # Open subtitle writer if needed
subtitle_writer: Optional[SubtitleWriter] = None subtitle_writer: Optional[SubtitleWriter] = None
if request.subtitle_mode != SubtitleMode.DISABLED and audio_sink: if request.subtitle.mode != SubtitleMode.DISABLED and audio_sink:
subtitle_writer = make_subtitle_writer( subtitle_writer = make_subtitle_writer(
audio_path, audio_path,
request.subtitle_format, request.subtitle,
request.subtitle_mode,
max_words=request.max_subtitle_words,
) )
if subtitle_writer: if subtitle_writer:
subtitle_writer.open() subtitle_writer.open()
stack.callback(subtitle_writer.close) stack.callback(subtitle_writer.close)
result.subtitle_paths.append(subtitle_writer.path) result.subtitle_paths.append(subtitle_writer.path)
effective_subtitle_mode = request.subtitle_mode if subtitle_writer else SubtitleMode.DISABLED effective_subtitle_mode = request.subtitle.mode if subtitle_writer else SubtitleMode.DISABLED
synth = SynthParams( synth = SynthParams(
tts_context=tts_context, tts_context=tts_context,
@@ -253,14 +251,14 @@ def execute_conversion(
on_progress=lambda pct, etr: events.progress(pct, etr), on_progress=lambda pct, etr: events.progress(pct, etr),
audio_sink=audio_sink, audio_sink=audio_sink,
subtitle_mode=effective_subtitle_mode, subtitle_mode=effective_subtitle_mode,
max_subtitle_words=request.max_subtitle_words, max_subtitle_words=request.subtitle.max_words,
language=request.language, language=request.language,
use_spacy_segmentation=use_spacy, use_spacy_segmentation=use_spacy,
) )
# Chapter directory # Chapter directory
chapter_dir = None chapter_dir = None
if request.save_chapters_separately and len(plan.chapters) > 1: if request.save.save_chapters_separately and len(plan.chapters) > 1:
chapter_dir = output_layout.audio_dir / "chapters" chapter_dir = output_layout.audio_dir / "chapters"
chapter_dir.mkdir(parents=True, exist_ok=True) chapter_dir.mkdir(parents=True, exist_ok=True)
@@ -307,11 +305,11 @@ def execute_conversion(
chapter_path = None chapter_path = None
if chapter_dir: if chapter_dir:
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx) chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
chapter_path = chapter_dir / f"{chapter_filename}.{request.separate_chapters_format}" chapter_path = chapter_dir / f"{chapter_filename}.{request.save.separate_chapters_format}"
chapter_sink = stack.enter_context( chapter_sink = stack.enter_context(
open_audio_sink( open_audio_sink(
chapter_path, chapter_path,
request.separate_chapters_format, request.save.separate_chapters_format,
cancel_check=check_cancelled, cancel_check=check_cancelled,
) )
) )
@@ -319,19 +317,17 @@ def execute_conversion(
# Per-chapter subtitle writer # Per-chapter subtitle writer
chapter_subtitle_writer: Optional[SubtitleWriter] = None chapter_subtitle_writer: Optional[SubtitleWriter] = None
if chapter_dir and request.subtitle_mode != SubtitleMode.DISABLED and chapter_sink: if chapter_dir and request.subtitle.mode != SubtitleMode.DISABLED and chapter_sink:
from abogen.infrastructure.subtitle_writer import resolve_subtitle_format from abogen.infrastructure.subtitle_writer import resolve_subtitle_format
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx) chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
subtitle_ext, _ = resolve_subtitle_format( subtitle_ext, _ = resolve_subtitle_format(
request.subtitle_format, request.subtitle_mode request.subtitle
) )
chapter_subtitle_path = chapter_dir / f"{chapter_filename}.{subtitle_ext}" chapter_subtitle_path = chapter_dir / f"{chapter_filename}.{subtitle_ext}"
chapter_subtitle_writer = make_subtitle_writer( chapter_subtitle_writer = make_subtitle_writer(
chapter_subtitle_path, chapter_subtitle_path,
request.subtitle_format, request.subtitle,
request.subtitle_mode,
max_words=request.max_subtitle_words,
) )
if chapter_subtitle_writer: if chapter_subtitle_writer:
chapter_subtitle_writer.open() chapter_subtitle_writer.open()
@@ -439,7 +435,7 @@ def execute_conversion(
spacy_segments, active_split = spacy_pre_tts_segmentation( spacy_segments, active_split = spacy_pre_tts_segmentation(
seg_text, seg_text,
request.language, request.language,
request.subtitle_mode, request.subtitle.mode,
is_subtitle_input=is_subtitle_input, is_subtitle_input=is_subtitle_input,
use_spacy_segmentation=use_spacy, use_spacy_segmentation=use_spacy,
log_callback=lambda msg: events.log(msg), log_callback=lambda msg: events.log(msg),
@@ -468,8 +464,7 @@ def execute_conversion(
process_and_write_subtitles( process_and_write_subtitles(
accumulated_tokens, accumulated_tokens,
subtitle_writer, subtitle_writer,
subtitle_mode=request.subtitle_mode, subtitle=request.subtitle,
max_subtitle_words=request.max_subtitle_words,
language=request.language, language=request.language,
use_spacy_segmentation=use_spacy, use_spacy_segmentation=use_spacy,
fallback_end_time=stats.current_time, fallback_end_time=stats.current_time,
@@ -478,8 +473,7 @@ def execute_conversion(
process_and_write_subtitles( process_and_write_subtitles(
accumulated_tokens, accumulated_tokens,
chapter_subtitle_writer, chapter_subtitle_writer,
subtitle_mode=request.subtitle_mode, subtitle=request.subtitle,
max_subtitle_words=request.max_subtitle_words,
language=request.language, language=request.language,
use_spacy_segmentation=use_spacy, use_spacy_segmentation=use_spacy,
fallback_end_time=stats.current_time, fallback_end_time=stats.current_time,
+14 -27
View File
@@ -16,11 +16,15 @@ from typing import Any, Dict, List, Optional
from abogen.application.conversion_config import ( from abogen.application.conversion_config import (
ChapterChunkConfig, ChapterChunkConfig,
CoverConfig,
Epub3ExportConfig, Epub3ExportConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
SubtitleInputConfig, SubtitleInputConfig,
WordSubstitutionConfig, WordSubstitutionConfig,
) )
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode from abogen.domain.enums import Language, OutputFormat
class ConversionRequestError(ValueError): class ConversionRequestError(ValueError):
@@ -29,7 +33,6 @@ class ConversionRequestError(ValueError):
# Numeric field constraints: attr -> (min, max) # Numeric field constraints: attr -> (min, max)
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = { _NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
"max_subtitle_words": (1, 500),
"speed": (0.5, 3.0), "speed": (0.5, 3.0),
"supertonic_total_steps": (2, 15), "supertonic_total_steps": (2, 15),
"silence_between_chapters": (0.0, None), "silence_between_chapters": (0.0, None),
@@ -44,11 +47,10 @@ class ConversionRequest:
Only contains fields that describe the conversion task itself. Only contains fields that describe the conversion task itself.
UI-only fields (display, logging, user prompts) stay in adapters. UI-only fields (display, logging, user prompts) stay in adapters.
Feature toggles use config objects or boolean flags: Feature toggles use config objects (None = disabled):
- word_substitution, subtitle_input, chapter_chunk = config objects (None = disabled) - word_substitution, subtitle_input, chapter_chunk, epub3_export
- generate_epub3 = boolean flag - pronunciation (raw data, compiled by app layer)
- subtitle, save, cover (grouped parameters)
Pronunciation overrides are raw data (lists of dicts), compiled by app layer.
Validation runs on creation via __post_init__: Validation runs on creation via __post_init__:
- None values → replaced with field default (from declaration) - None values → replaced with field default (from declaration)
@@ -71,17 +73,6 @@ class ConversionRequest:
# --- Output Format --- # --- Output Format ---
output_format: OutputFormat = OutputFormat.WAV 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 --- # --- Timing ---
silence_between_chapters: float = 2.0 silence_between_chapters: float = 2.0
@@ -97,15 +88,11 @@ class ConversionRequest:
# --- Metadata --- # --- Metadata ---
metadata_tags: Dict[str, Any] = field(default_factory=dict) metadata_tags: Dict[str, Any] = field(default_factory=dict)
# --- Pronunciation overrides (raw data, compiled by app layer) --- # --- Grouped configs ---
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) subtitle: SubtitleConfig = field(default_factory=SubtitleConfig)
manual_overrides: List[Dict[str, Any]] = field(default_factory=list) save: SaveConfig = field(default_factory=SaveConfig)
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list) cover: CoverConfig = field(default_factory=CoverConfig)
normalization_overrides: Optional[Dict[str, Any]] = None pronunciation: PronunciationConfig = field(default_factory=PronunciationConfig)
# --- Artifacts ---
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
# --- Feature configs (None = disabled) --- # --- Feature configs (None = disabled) ---
epub3_export: Optional[Epub3ExportConfig] = None epub3_export: Optional[Epub3ExportConfig] = None
+4 -10
View File
@@ -67,11 +67,8 @@ def run_conversion(
usage_counter: Dict[str, int] = defaultdict(int) usage_counter: Dict[str, int] = defaultdict(int)
tts_context = build_tts_context( tts_context = build_tts_context(
language=request.language, language=request.language,
subtitle_mode=request.subtitle_mode.value if request.subtitle_mode else "Disabled", subtitle=request.subtitle,
pronunciation_overrides=request.pronunciation_overrides, pronunciation=request.pronunciation,
manual_overrides=request.manual_overrides,
heteronym_overrides=request.heteronym_overrides,
normalization_overrides=request.normalization_overrides,
usage_counter=usage_counter, usage_counter=usage_counter,
log_callback=lambda level, msg: events.log(msg, level=level), log_callback=lambda level, msg: events.log(msg, level=level),
) )
@@ -154,15 +151,13 @@ def _finalize(
from abogen.infrastructure.exporters import ExportService from abogen.infrastructure.exporters import ExportService
export_svc = ExportService() export_svc = ExportService()
cover_path = request.cover_image_path if request.cover_image_path and request.cover_image_path.exists() else None
try: try:
export_svc.embed_m4b_metadata( export_svc.embed_m4b_metadata(
audio_path=result.audio_path, audio_path=result.audio_path,
metadata=result.metadata or {}, metadata=result.metadata or {},
chapters=result.chapter_markers or [], chapters=result.chapter_markers or [],
cover_path=cover_path, cover=request.cover,
cover_mime=request.cover_image_mime,
log_callback=lambda msg, level="info": events.log(msg, level=level), log_callback=lambda msg, level="info": events.log(msg, level=level),
) )
except Exception as exc: except Exception as exc:
@@ -195,8 +190,7 @@ def _finalize(
chunks=request.chapter_chunk.chunks if request.chapter_chunk else [], chunks=request.chapter_chunk.chunks if request.chapter_chunk else [],
audio_path=audio_asset, audio_path=audio_asset,
speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else "single", speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else "single",
cover_image_path=request.cover_image_path, cover=request.cover,
cover_image_mime=request.cover_image_mime,
) )
result.epub_path = epub_path result.epub_path = epub_path
result.artifacts["epub3"] = epub_path result.artifacts["epub3"] = epub_path
+6 -6
View File
@@ -39,8 +39,8 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
OutputLayout with resolved paths OutputLayout with resolved paths
""" """
# Determine base output directory # Determine base output directory
if request.save_mode == SaveMode.CUSTOM_FOLDER and request.output_folder: if request.save.mode == SaveMode.CUSTOM_FOLDER and request.save.output_folder:
parent_dir = Path(request.output_folder) parent_dir = Path(request.save.output_folder)
elif request.source_path: elif request.source_path:
parent_dir = request.source_path.parent parent_dir = request.source_path.parent
else: else:
@@ -66,7 +66,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
subtitle_dir = None subtitle_dir = None
metadata_dir = None metadata_dir = None
if request.save_as_project: if request.save.save_as_project:
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout( project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
original_filename=request.original_filename, original_filename=request.original_filename,
save_as_project=True, save_as_project=True,
@@ -124,7 +124,7 @@ def resolve_chapter_path(
slug = re.sub(r'[\s_]+', '_', slug).strip('_') slug = re.sub(r'[\s_]+', '_', slug).strip('_')
if not slug: if not slug:
slug = f"chapter_{chapter_index}" slug = f"chapter_{chapter_index}"
filename = f"{chapter_index:02d}_{slug}.{request.separate_chapters_format}" filename = f"{chapter_index:02d}_{slug}.{request.save.separate_chapters_format}"
return layout.audio_dir / "chapters" / filename return layout.audio_dir / "chapters" / filename
@@ -144,6 +144,6 @@ def should_merge_output(request: ConversionRequest) -> bool:
""" """
if request.output_format == OutputFormat.M4B: if request.output_format == OutputFormat.M4B:
return True return True
if not request.save_chapters_separately: if not request.save.save_chapters_separately:
return True return True
return request.merge_chapters_at_end return request.save.merge_chapters_at_end
+52
View File
@@ -0,0 +1,52 @@
"""Domain config types — shared contracts for domain functions.
These dataclasses group parameters that domain functions receive.
Domain defines them, app layer fills them.
Why here (domain) and not application:
- build_tts_context() is in domain → needs PronunciationConfig
- make_subtitle_writer() is in infrastructure → needs SubtitleConfig
- embed_m4b_metadata() is in infrastructure → needs CoverConfig
- Domain should not depend on application layer (DIP)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from abogen.domain.enums import SubtitleFormat, SubtitleMode
@dataclass(frozen=True)
class PronunciationConfig:
"""Pronunciation and normalization override settings.
Used by build_tts_context() to compile override rules.
"""
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
@dataclass(frozen=True)
class SubtitleConfig:
"""Subtitle output settings.
Used by make_subtitle_writer() and process_and_write_subtitles().
"""
mode: SubtitleMode = SubtitleMode.DISABLED
format: SubtitleFormat = SubtitleFormat.SRT
max_words: int = 50
@dataclass(frozen=True)
class CoverConfig:
"""Cover image settings.
Used by embed_m4b_metadata() and build_epub3_package().
"""
path: Optional[Path] = None
mime: Optional[str] = None
+15 -5
View File
@@ -151,24 +151,34 @@ def process_and_write_subtitles(
accumulated_tokens: list[dict], accumulated_tokens: list[dict],
subtitle_writer: Any, subtitle_writer: Any,
*, *,
subtitle_mode: str, subtitle: "SubtitleConfig | str",
max_subtitle_words: int, max_subtitle_words: int | None = None,
language: Language, language: Language,
use_spacy_segmentation: bool, use_spacy_segmentation: bool,
fallback_end_time: float, fallback_end_time: float,
) -> None: ) -> None:
"""Process accumulated subtitle tokens and write entries to a subtitle writer. """Process accumulated subtitle tokens and write entries to a subtitle writer.
This is the standard subtitle post-processing step shared by both UIs. Accepts a SubtitleConfig object or a subtitle mode string
for backward compatibility.
""" """
from abogen.domain.config_types import SubtitleConfig
if isinstance(subtitle, SubtitleConfig):
mode_str = subtitle.mode.value
words = subtitle.max_words
else:
mode_str = subtitle
words = max_subtitle_words or 50
if not accumulated_tokens or not subtitle_writer: if not accumulated_tokens or not subtitle_writer:
return return
new_entries: list[tuple] = [] new_entries: list[tuple] = []
process_subtitle_tokens( process_subtitle_tokens(
accumulated_tokens, accumulated_tokens,
new_entries, new_entries,
max_subtitle_words, words,
subtitle_mode, mode_str,
language, language,
use_spacy_segmentation=use_spacy_segmentation, use_spacy_segmentation=use_spacy_segmentation,
fallback_end_time=fallback_end_time, fallback_end_time=fallback_end_time,
+27 -23
View File
@@ -129,12 +129,9 @@ def prepare_text_for_tts(
def build_tts_context( def build_tts_context(
*, *,
language: Language, language: Language,
subtitle_mode: str = "Disabled", subtitle: "SubtitleConfig | str" = "Disabled",
pronunciation_overrides: Optional[List[Dict[str, Any]]] = None, pronunciation: Optional["PronunciationConfig"] = None,
manual_overrides: Optional[List[Dict[str, Any]]] = None,
heteronym_overrides: Optional[List[Dict[str, Any]]] = None,
speakers: Optional[Dict[str, Any]] = None, speakers: Optional[Dict[str, Any]] = None,
normalization_overrides: Optional[Mapping[str, Any]] = None,
usage_counter: Optional[Dict[str, int]] = None, usage_counter: Optional[Dict[str, int]] = None,
log_callback: Optional[Callable[[str, str], None]] = None, log_callback: Optional[Callable[[str, str], None]] = None,
) -> TTSContext: ) -> TTSContext:
@@ -145,19 +142,17 @@ def build_tts_context(
Args: Args:
language: Language enum value. language: Language enum value.
subtitle_mode: Subtitle mode string. subtitle: SubtitleConfig object or subtitle mode string.
pronunciation_overrides: List of pronunciation override dicts. pronunciation: PronunciationConfig with override rules.
manual_overrides: List of manual override dicts.
heteronym_overrides: List of heteronym override dicts.
speakers: Speaker profile mapping. speakers: Speaker profile mapping.
normalization_overrides: Per-job normalization setting overrides.
usage_counter: Mutable dict for tracking override usage. usage_counter: Mutable dict for tracking override usage.
log_callback: Callable(level, message) for warnings. log_callback: Callable(level, message) for warnings.
Returns: Returns:
TTSContext ready for text normalization. TTSContext ready for text normalization.
""" """
from abogen.domain.enums import Language, SubtitleMode from abogen.domain.config_types import PronunciationConfig, SubtitleConfig
from abogen.domain.enums import SubtitleMode
from abogen.domain.pronunciation import ( from abogen.domain.pronunciation import (
compile_heteronym_sentence_rules, compile_heteronym_sentence_rules,
compile_pronunciation_rules, compile_pronunciation_rules,
@@ -169,12 +164,25 @@ def build_tts_context(
if log_callback: if log_callback:
log_callback(level, msg) log_callback(level, msg)
# Resolve subtitle mode
if isinstance(subtitle, SubtitleConfig):
resolved_subtitle = subtitle.mode
else:
try:
resolved_subtitle = SubtitleMode.from_str(subtitle) if not isinstance(subtitle, SubtitleMode) else subtitle
except ValueError:
resolved_subtitle = SubtitleMode.DISABLED
# Resolve pronunciation config
if pronunciation is None:
pronunciation = PronunciationConfig()
# Get runtime normalization settings # Get runtime normalization settings
runtime_settings = get_runtime_settings() runtime_settings = get_runtime_settings()
# Apply per-job normalization overrides # Apply per-job normalization overrides
if normalization_overrides: if pronunciation.normalization_overrides:
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides) runtime_settings = _apply_overrides(runtime_settings, pronunciation.normalization_overrides)
# Build apostrophe config # Build apostrophe config
apostrophe_config = build_apostrophe_config(settings=runtime_settings) apostrophe_config = build_apostrophe_config(settings=runtime_settings)
@@ -202,16 +210,12 @@ def build_tts_context(
# Compute split pattern # Compute split pattern
if not isinstance(language, Language): if not isinstance(language, Language):
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}") raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
try: split_pattern = get_split_pattern(language, resolved_subtitle)
mode = SubtitleMode.from_str(subtitle_mode) if not isinstance(subtitle_mode, SubtitleMode) else subtitle_mode
except ValueError:
mode = SubtitleMode.DISABLED
split_pattern = get_split_pattern(language, mode)
# Merge pronunciation overrides (accepts dict or object) # Merge pronunciation overrides
source = { source = {
"pronunciation_overrides": pronunciation_overrides or [], "pronunciation_overrides": pronunciation.pronunciation_overrides,
"manual_overrides": manual_overrides or [], "manual_overrides": pronunciation.manual_overrides,
"speakers": speakers or {}, "speakers": speakers or {},
"language": language, "language": language,
} }
@@ -219,7 +223,7 @@ def build_tts_context(
# Compile rules # Compile rules
pronunciation_rules = compile_pronunciation_rules(merged_overrides) pronunciation_rules = compile_pronunciation_rules(merged_overrides)
heteronym_rules = compile_heteronym_sentence_rules(heteronym_overrides or []) heteronym_rules = compile_heteronym_sentence_rules(pronunciation.heteronym_overrides)
if heteronym_rules: if heteronym_rules:
_log( _log(
@@ -236,6 +240,6 @@ def build_tts_context(
split_pattern=split_pattern, split_pattern=split_pattern,
pronunciation_rules=pronunciation_rules, pronunciation_rules=pronunciation_rules,
heteronym_rules=heteronym_rules, heteronym_rules=heteronym_rules,
normalization_overrides=normalization_overrides, normalization_overrides=pronunciation.normalization_overrides,
usage_counter=usage_counter if usage_counter is not None else {}, usage_counter=usage_counter if usage_counter is not None else {},
) )
+5
View File
@@ -516,9 +516,14 @@ def build_epub3_package(
chunks: Iterable[Dict[str, Any]], chunks: Iterable[Dict[str, Any]],
audio_path: Path, audio_path: Path,
speaker_mode: str = "single", speaker_mode: str = "single",
cover: "CoverConfig | None" = None,
cover_image_path: Optional[Path] = None, cover_image_path: Optional[Path] = None,
cover_image_mime: Optional[str] = None, cover_image_mime: Optional[str] = None,
) -> Path: ) -> Path:
from abogen.domain.config_types import CoverConfig
if isinstance(cover, CoverConfig):
cover_image_path = cover.path
cover_image_mime = cover.mime
builder = EPUB3PackageBuilder( builder = EPUB3PackageBuilder(
output_path=output_path, output_path=output_path,
book_id=book_id, book_id=book_id,
+5
View File
@@ -132,11 +132,16 @@ class ExportService:
audio_path: Path, audio_path: Path,
metadata: Dict[str, Any], metadata: Dict[str, Any],
chapters: List[Dict[str, Any]], chapters: List[Dict[str, Any]],
cover: "CoverConfig | None" = None,
cover_path: Optional[Path] = None, cover_path: Optional[Path] = None,
cover_mime: Optional[str] = None, cover_mime: Optional[str] = None,
log_callback: Optional[callable] = None, log_callback: Optional[callable] = None,
) -> None: ) -> None:
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen.""" """Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
from abogen.domain.config_types import CoverConfig
if isinstance(cover, CoverConfig):
cover_path = cover.path
cover_mime = cover.mime
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters) ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
metadata_args = self._metadata_to_ffmpeg_args(metadata) metadata_args = self._metadata_to_ffmpeg_args(metadata)
+36 -19
View File
@@ -278,24 +278,28 @@ def create_subtitle_writer(
def resolve_subtitle_format( def resolve_subtitle_format(
subtitle_format: str | None, subtitle: "SubtitleConfig | str | None",
subtitle_mode: str, subtitle_mode: str | None = None,
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Resolve a subtitle_format setting string to (file_extension, alignment). """Resolve a subtitle config to (file_extension, alignment).
Handles the PyQt convention where format strings encode alignment Accepts a SubtitleConfig object or individual format/mode strings
(e.g. ``"ass_centered_narrow"`` → extension ``"ass"``, alignment for backward compatibility.
``"center_narrow"``).
Also enforces that ``"Sentence + Highlighting"`` mode requires ASS.
Returns: Returns:
Tuple of (file_extension, alignment) suitable for Tuple of (file_extension, alignment) suitable for
:func:`create_subtitle_writer`. :func:`create_subtitle_writer`.
""" """
fmt = (subtitle_format or "srt").lower() from abogen.domain.config_types import SubtitleConfig
if subtitle_mode == "Sentence + Highlighting" and fmt == "srt": if isinstance(subtitle, SubtitleConfig):
fmt = subtitle.format.value.lower()
mode_str = subtitle.mode.value
else:
fmt = (subtitle or "srt").lower()
mode_str = subtitle_mode or "Disabled"
if mode_str == "Sentence + Highlighting" and fmt == "srt":
fmt = "ass" fmt = "ass"
if "ass" in fmt: if "ass" in fmt:
@@ -317,26 +321,39 @@ def resolve_subtitle_format(
def make_subtitle_writer( def make_subtitle_writer(
audio_path: Path, audio_path: Path,
subtitle_format: str | None, subtitle: "SubtitleConfig | str | None",
subtitle_mode: str, subtitle_mode: str | None = None,
max_words: int = 50, max_words: int | None = None,
) -> SubtitleWriter | None: ) -> SubtitleWriter | None:
"""Convenience: resolve format and create a writer, or return None if disabled. """Convenience: resolve format and create a writer, or return None if disabled.
Returns ``None`` when ``subtitle_mode`` is ``"Disabled"`` or the Accepts a SubtitleConfig object or individual format/mode strings
for backward compatibility.
Returns ``None`` when subtitle mode is ``"Disabled"`` or the
format is unsupported. format is unsupported.
""" """
if subtitle_mode == "Disabled": from abogen.domain.config_types import SubtitleConfig
return None
extension, alignment = resolve_subtitle_format(subtitle_format, subtitle_mode) if isinstance(subtitle, SubtitleConfig):
mode_str = subtitle.mode.value
if mode_str == "Disabled":
return None
words = subtitle.max_words
else:
mode_str = subtitle_mode or subtitle or "Disabled"
if mode_str == "Disabled":
return None
words = max_words or 50
extension, alignment = resolve_subtitle_format(subtitle, subtitle_mode)
try: try:
return create_subtitle_writer( return create_subtitle_writer(
audio_path.with_suffix(f".{extension}"), audio_path.with_suffix(f".{extension}"),
extension, extension,
subtitle_mode, mode_str,
alignment=alignment, alignment=alignment,
max_words=max_words, max_words=words,
) )
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
+7 -4
View File
@@ -533,14 +533,17 @@ class ConversionThread(QThread):
) )
# --- Compile normalization rules (heteronym + pronunciation) --- # --- Compile normalization rules (heteronym + pronunciation) ---
from abogen.domain.config_types import PronunciationConfig
from abogen.domain.normalization import build_tts_context from abogen.domain.normalization import build_tts_context
self._tts_context = build_tts_context( self._tts_context = build_tts_context(
language=self.lang_code, language=self.lang_code,
subtitle_mode=self.subtitle_mode, subtitle=self.subtitle_mode,
pronunciation_overrides=getattr(self, "pronunciation_overrides", None), pronunciation=PronunciationConfig(
manual_overrides=getattr(self, "manual_overrides", None), pronunciation_overrides=getattr(self, "pronunciation_overrides", None) or [],
heteronym_overrides=getattr(self, "heteronym_overrides", None), manual_overrides=getattr(self, "manual_overrides", None) or [],
heteronym_overrides=getattr(self, "heteronym_overrides", None) or [],
normalization_overrides=getattr(self, "normalization_overrides", None), normalization_overrides=getattr(self, "normalization_overrides", None),
),
log_callback=lambda level, msg: self.log_updated.emit((msg, "grey" if level == "info" else "orange")), log_callback=lambda level, msg: self.log_updated.emit((msg, "grey" if level == "info" else "orange")),
) )
+24 -14
View File
@@ -20,7 +20,11 @@ from typing import Any
from abogen.application.conversion_config import ( from abogen.application.conversion_config import (
ChapterChunkConfig, ChapterChunkConfig,
CoverConfig,
Epub3ExportConfig, Epub3ExportConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
) )
from abogen.application.conversion_ports import ConversionCancelled from abogen.application.conversion_ports import ConversionCancelled
from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_request import ConversionRequest
@@ -57,16 +61,6 @@ def _build_request(job: Job) -> ConversionRequest:
supertonic_total_steps=job.supertonic_total_steps, supertonic_total_steps=job.supertonic_total_steps,
# Output Format # Output Format
output_format=_resolve_output_format(job.output_format), output_format=_resolve_output_format(job.output_format),
subtitle_mode=_resolve_subtitle_mode(job.subtitle_mode),
subtitle_format=_resolve_subtitle_format(job.subtitle_format),
max_subtitle_words=job.max_subtitle_words,
# Save Options
save_mode=_resolve_save_mode(job.save_mode),
output_folder=job.output_folder,
save_chapters_separately=job.save_chapters_separately,
merge_chapters_at_end=job.merge_chapters_at_end,
separate_chapters_format=_resolve_output_format(job.separate_chapters_format),
save_as_project=job.save_as_project,
# Timing # Timing
silence_between_chapters=job.silence_between_chapters, silence_between_chapters=job.silence_between_chapters,
chapter_intro_delay=job.chapter_intro_delay, chapter_intro_delay=job.chapter_intro_delay,
@@ -78,14 +72,30 @@ def _build_request(job: Job) -> ConversionRequest:
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps, normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
# Metadata # Metadata
metadata_tags=job.metadata_tags or {}, metadata_tags=job.metadata_tags or {},
# Artifacts # Grouped configs
cover_image_path=job.cover_image_path, subtitle=SubtitleConfig(
cover_image_mime=job.cover_image_mime, mode=_resolve_subtitle_mode(job.subtitle_mode),
# Pronunciation overrides (raw data) format=_resolve_subtitle_format(job.subtitle_format),
max_words=job.max_subtitle_words,
),
save=SaveConfig(
mode=_resolve_save_mode(job.save_mode),
output_folder=job.output_folder,
save_chapters_separately=job.save_chapters_separately,
merge_chapters_at_end=job.merge_chapters_at_end,
separate_chapters_format=_resolve_output_format(job.separate_chapters_format),
save_as_project=job.save_as_project,
),
cover=CoverConfig(
path=job.cover_image_path,
mime=job.cover_image_mime,
),
pronunciation=PronunciationConfig(
pronunciation_overrides=job.pronunciation_overrides or [], pronunciation_overrides=job.pronunciation_overrides or [],
manual_overrides=job.manual_overrides or [], manual_overrides=job.manual_overrides or [],
heteronym_overrides=job.heteronym_overrides or [], heteronym_overrides=job.heteronym_overrides or [],
normalization_overrides=job.normalization_overrides or None, normalization_overrides=job.normalization_overrides or None,
),
# Feature configs # Feature configs
epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None, epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None,
chapter_chunk=ChapterChunkConfig( chapter_chunk=ChapterChunkConfig(
+41 -33
View File
@@ -11,6 +11,12 @@ from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
import pytest import pytest
from abogen.application.conversion_config import (
CoverConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
)
from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_request import ConversionRequest
from abogen.application.conversion_models import ( from abogen.application.conversion_models import (
ChapterPlan, ChapterPlan,
@@ -137,8 +143,7 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello world", direct_text="Hello world",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
events = FakeEvents() events = FakeEvents()
@@ -156,8 +161,7 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
events = FakeEvents() events = FakeEvents()
@@ -177,8 +181,7 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
events = FakeEvents() events = FakeEvents()
events.cancelled = True events.cancelled = True
@@ -204,8 +207,7 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B", direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
events = FakeEvents() events = FakeEvents()
@@ -221,8 +223,7 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Body text", direct_text="Body text",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
read_title_intro=True, read_title_intro=True,
read_closing_outro=True, read_closing_outro=True,
metadata_tags={"title": "Test Book", "author": "Author"}, metadata_tags={"title": "Test Book", "author": "Author"},
@@ -256,9 +257,10 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir), pronunciation=PronunciationConfig(
normalization_overrides={"normalization_numbers": False}, normalization_overrides={"normalization_numbers": False},
),
) )
events = FakeEvents() events = FakeEvents()
@@ -273,9 +275,10 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir), pronunciation=PronunciationConfig(
normalization_overrides={"normalization_apostrophe_mode": "llm"}, normalization_overrides={"normalization_apostrophe_mode": "llm"},
),
) )
events = FakeEvents() events = FakeEvents()
@@ -290,8 +293,7 @@ class TestConversionService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
events = FakeEvents() events = FakeEvents()
@@ -314,8 +316,7 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
layout = resolve_output_layout(req) layout = resolve_output_layout(req)
@@ -332,7 +333,7 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
source_path=source, source_path=source,
voice="M1", voice="M1",
save_mode="save_next_to_input", save=SaveConfig(mode="save_next_to_input"),
) )
layout = resolve_output_layout(req) layout = resolve_output_layout(req)
@@ -346,9 +347,11 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir), output_folder=Path(tmpdir),
save_as_project=True, save_as_project=True,
),
original_filename="test.wav", original_filename="test.wav",
) )
layout = resolve_output_layout(req) layout = resolve_output_layout(req)
@@ -388,7 +391,7 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
separate_chapters_format="wav", save=SaveConfig(separate_chapters_format="wav"),
) )
path = resolve_chapter_path(layout, req, "Chapter 1", 1) path = resolve_chapter_path(layout, req, "Chapter 1", 1)
@@ -407,7 +410,7 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
separate_chapters_format="wav", save=SaveConfig(separate_chapters_format="wav"),
) )
path = resolve_chapter_path(layout, req, "", 3) path = resolve_chapter_path(layout, req, "", 3)
@@ -421,7 +424,7 @@ class TestOutputLayoutService:
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
output_format="m4b", output_format="m4b",
merge_chapters_at_end=False, save=SaveConfig(merge_chapters_at_end=False),
) )
assert should_merge_output(req) is True assert should_merge_output(req) is True
@@ -432,7 +435,7 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_chapters_separately=False, save=SaveConfig(save_chapters_separately=False),
) )
assert should_merge_output(req) is True assert should_merge_output(req) is True
@@ -443,8 +446,10 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save=SaveConfig(
save_chapters_separately=True, save_chapters_separately=True,
merge_chapters_at_end=True, merge_chapters_at_end=True,
),
) )
assert should_merge_output(req) is True assert should_merge_output(req) is True
@@ -455,8 +460,10 @@ class TestOutputLayoutService:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save=SaveConfig(
save_chapters_separately=True, save_chapters_separately=True,
merge_chapters_at_end=False, merge_chapters_at_end=False,
),
) )
assert should_merge_output(req) is False assert should_merge_output(req) is False
@@ -501,11 +508,13 @@ class TestExecutorGaps:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir), output_folder=Path(tmpdir),
output_format="m4b",
save_chapters_separately=True, save_chapters_separately=True,
merge_chapters_at_end=False, merge_chapters_at_end=False,
),
output_format="m4b",
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -546,10 +555,12 @@ class TestExecutorGaps:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir), output_folder=Path(tmpdir),
save_chapters_separately=True, save_chapters_separately=True,
merge_chapters_at_end=True, merge_chapters_at_end=True,
),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -599,8 +610,7 @@ class TestExecutorGaps:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -657,8 +667,7 @@ class TestExecutorGaps:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -698,8 +707,7 @@ class TestExecutorGaps:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
silence_between_chapters=1.0, silence_between_chapters=1.0,
) )
plan = ConversionPlan( plan = ConversionPlan(
+7 -11
View File
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from abogen.domain.enums import Language from abogen.domain.config_types import SubtitleConfig
from abogen.domain.enums import Language, SubtitleMode
from abogen.domain.conversion_engine import ( from abogen.domain.conversion_engine import (
synthesize_text, synthesize_text,
SynthParams, SynthParams,
@@ -253,8 +254,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles( process_and_write_subtitles(
[], [],
writer, writer,
subtitle_mode="Sentence", subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
max_subtitle_words=5,
language=Language.EN_US, language=Language.EN_US,
use_spacy_segmentation=False, use_spacy_segmentation=False,
fallback_end_time=10.0, fallback_end_time=10.0,
@@ -270,8 +270,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles( process_and_write_subtitles(
tokens, tokens,
writer, writer,
subtitle_mode="Sentence", subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
max_subtitle_words=5,
language=Language.EN_US, language=Language.EN_US,
use_spacy_segmentation=False, use_spacy_segmentation=False,
fallback_end_time=2.0, fallback_end_time=2.0,
@@ -292,8 +291,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles( process_and_write_subtitles(
tokens, tokens,
writer, writer,
subtitle_mode="Line", subtitle=SubtitleConfig(mode=SubtitleMode.LINE, max_words=5),
max_subtitle_words=5,
language=Language.EN_US, language=Language.EN_US,
use_spacy_segmentation=False, use_spacy_segmentation=False,
fallback_end_time=3.0, fallback_end_time=3.0,
@@ -311,8 +309,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles( process_and_write_subtitles(
tokens, tokens,
writer, writer,
subtitle_mode="Disabled", subtitle=SubtitleConfig(mode=SubtitleMode.DISABLED, max_words=5),
max_subtitle_words=5,
language=Language.EN_US, language=Language.EN_US,
use_spacy_segmentation=False, use_spacy_segmentation=False,
fallback_end_time=2.0, fallback_end_time=2.0,
@@ -366,8 +363,7 @@ class TestFullPipeline:
process_and_write_subtitles( process_and_write_subtitles(
tokens, tokens,
subtitle_writer, subtitle_writer,
subtitle_mode="Sentence", subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
max_subtitle_words=5,
language=Language.EN_US, language=Language.EN_US,
use_spacy_segmentation=False, use_spacy_segmentation=False,
fallback_end_time=stats.current_time, fallback_end_time=stats.current_time,
+8 -16
View File
@@ -12,6 +12,7 @@ from unittest.mock import MagicMock
import numpy as np import numpy as np
import pytest import pytest
from abogen.application.conversion_config import SaveConfig
from abogen.application.conversion_executor import execute_conversion from abogen.application.conversion_executor import execute_conversion
from abogen.application.conversion_models import ( from abogen.application.conversion_models import (
ChapterPlan, ChapterPlan,
@@ -150,8 +151,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello world", direct_text="Hello world",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -198,10 +198,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Text", direct_text="Text",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir), save_chapters_separately=True, merge_chapters_at_end=True),
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=True,
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -262,8 +259,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Text", direct_text="Text",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -315,8 +311,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Text", direct_text="Text",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -377,8 +372,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Text", direct_text="Text",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -424,8 +418,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello world", direct_text="Hello world",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
@@ -471,8 +464,7 @@ class TestExecuteConversion:
req = ConversionRequest( req = ConversionRequest(
direct_text="Text", direct_text="Text",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = ConversionPlan( plan = ConversionPlan(
request=req, request=req,
+2 -2
View File
@@ -125,12 +125,12 @@ class TestBuildConversionPlan:
def test_output_layout(self): def test_output_layout(self):
"""Output layout is resolved from request.""" """Output layout is resolved from request."""
from abogen.application.conversion_config import SaveConfig
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest( req = ConversionRequest(
direct_text="Hello", direct_text="Hello",
voice="M1", voice="M1",
save_mode="custom_folder", save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
output_folder=Path(tmpdir),
) )
plan = build_conversion_plan(req) plan = build_conversion_plan(req)
+31 -25
View File
@@ -16,7 +16,14 @@ from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from abogen.application.conversion_request import ConversionRequest, ConversionRequestError from abogen.application.conversion_request import ConversionRequest, ConversionRequestError
from abogen.application.conversion_config import ChapterChunkConfig, WordSubstitutionConfig from abogen.application.conversion_config import (
ChapterChunkConfig,
CoverConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
WordSubstitutionConfig,
)
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.settings_core import settings_defaults from abogen.domain.settings_core import settings_defaults
@@ -202,23 +209,11 @@ class TestConversionRequestValidation:
def test_defaults_are_valid(self): def test_defaults_are_valid(self):
req = ConversionRequest() req = ConversionRequest()
assert req.max_subtitle_words == 50 assert req.subtitle.max_words == 50
assert req.speed == 1.0 assert req.speed == 1.0
assert req.supertonic_total_steps == 5 assert req.supertonic_total_steps == 5
assert req.output_format == OutputFormat.WAV assert req.output_format == OutputFormat.WAV
assert req.subtitle_mode == SubtitleMode.DISABLED 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): def test_speed_clamped_below_min(self):
req = ConversionRequest(speed=0.1) req = ConversionRequest(speed=0.1)
@@ -256,10 +251,6 @@ class TestConversionRequestValidation:
with pytest.raises(ValueError, match="speaker_mode"): with pytest.raises(ValueError, match="speaker_mode"):
ConversionRequest(chapter_chunk=ChapterChunkConfig(speaker_mode="invalid")) ConversionRequest(chapter_chunk=ChapterChunkConfig(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): def test_invalid_speed_type_raises(self):
with pytest.raises(ConversionRequestError, match="speed"): with pytest.raises(ConversionRequestError, match="speed"):
ConversionRequest(speed="fast") ConversionRequest(speed="fast")
@@ -276,12 +267,27 @@ class TestConversionRequestValidation:
req = ConversionRequest( req = ConversionRequest(
language=Language.FR, language=Language.FR,
output_format=OutputFormat.MP3, output_format=OutputFormat.MP3,
subtitle_mode=SubtitleMode.SENTENCE, subtitle=SubtitleConfig(
subtitle_format=SubtitleFormat.ASS, mode=SubtitleMode.SENTENCE,
save_mode=SaveMode.CUSTOM_FOLDER, format=SubtitleFormat.ASS,
),
save=SaveConfig(mode=SaveMode.CUSTOM_FOLDER),
) )
assert req.language == Language.FR assert req.language == Language.FR
assert req.output_format == OutputFormat.MP3 assert req.output_format == OutputFormat.MP3
assert req.subtitle_mode == SubtitleMode.SENTENCE assert req.subtitle.mode == SubtitleMode.SENTENCE
assert req.subtitle_format == SubtitleFormat.ASS assert req.subtitle.format == SubtitleFormat.ASS
assert req.save_mode == SaveMode.CUSTOM_FOLDER assert req.save.mode == SaveMode.CUSTOM_FOLDER
def test_config_objects_constructed(self):
req = ConversionRequest(
subtitle=SubtitleConfig(max_words=100),
cover=CoverConfig(path=Path("/tmp/cover.jpg"), mime="image/jpeg"),
pronunciation=PronunciationConfig(normalization_overrides={"key": "val"}),
save=SaveConfig(save_as_project=True),
)
assert req.subtitle.max_words == 100
assert req.cover.path == Path("/tmp/cover.jpg")
assert req.cover.mime == "image/jpeg"
assert req.pronunciation.normalization_overrides == {"key": "val"}
assert req.save.save_as_project is True
+14 -8
View File
@@ -2,7 +2,8 @@
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from abogen.domain.enums import Language from abogen.domain.config_types import PronunciationConfig, SubtitleConfig
from abogen.domain.enums import Language, SubtitleMode
from abogen.domain.normalization import prepare_text_for_tts, normalize_text_for_pipeline, build_tts_context, TTSContext from abogen.domain.normalization import prepare_text_for_tts, normalize_text_for_pipeline, build_tts_context, TTSContext
@@ -156,16 +157,16 @@ class TestBuildTtsContext:
assert isinstance(ctx, TTSContext) assert isinstance(ctx, TTSContext)
def test_default_split_pattern(self): def test_default_split_pattern(self):
ctx = build_tts_context(language=Language.EN_US, subtitle_mode="Disabled") ctx = build_tts_context(language=Language.EN_US, subtitle="Disabled")
assert isinstance(ctx.split_pattern, str) assert isinstance(ctx.split_pattern, str)
assert len(ctx.split_pattern) > 0 assert len(ctx.split_pattern) > 0
def test_english_uses_newline_split(self): def test_english_uses_newline_split(self):
ctx = build_tts_context(language=Language.EN_US, subtitle_mode="Disabled") ctx = build_tts_context(language=Language.EN_US, subtitle="Disabled")
assert ctx.split_pattern == "\n" assert ctx.split_pattern == "\n"
def test_cjk_uses_punctuation_split(self): def test_cjk_uses_punctuation_split(self):
ctx = build_tts_context(language=Language.JA, subtitle_mode="Disabled") ctx = build_tts_context(language=Language.JA, subtitle="Disabled")
assert r"\n" in ctx.split_pattern assert r"\n" in ctx.split_pattern
def test_pronunciation_overrides_compiled(self): def test_pronunciation_overrides_compiled(self):
@@ -178,7 +179,7 @@ class TestBuildTtsContext:
] ]
ctx = build_tts_context( ctx = build_tts_context(
language=Language.EN_US, language=Language.EN_US,
pronunciation_overrides=overrides, pronunciation=PronunciationConfig(pronunciation_overrides=overrides),
) )
assert ctx.pronunciation_rules is not None assert ctx.pronunciation_rules is not None
assert len(ctx.pronunciation_rules) >= 1 assert len(ctx.pronunciation_rules) >= 1
@@ -193,7 +194,7 @@ class TestBuildTtsContext:
] ]
ctx = build_tts_context( ctx = build_tts_context(
language=Language.EN_US, language=Language.EN_US,
manual_overrides=overrides, pronunciation=PronunciationConfig(manual_overrides=overrides),
) )
assert ctx.pronunciation_rules is not None assert ctx.pronunciation_rules is not None
assert len(ctx.pronunciation_rules) >= 1 assert len(ctx.pronunciation_rules) >= 1
@@ -207,8 +208,10 @@ class TestBuildTtsContext:
] ]
ctx = build_tts_context( ctx = build_tts_context(
language=Language.EN_US, language=Language.EN_US,
pronunciation=PronunciationConfig(
pronunciation_overrides=pronunciation, pronunciation_overrides=pronunciation,
manual_overrides=manual, manual_overrides=manual,
),
) )
found_right = any( found_right = any(
r.get("replacement") == "RIGHT" for r in ctx.pronunciation_rules r.get("replacement") == "RIGHT" for r in ctx.pronunciation_rules
@@ -229,7 +232,7 @@ class TestBuildTtsContext:
] ]
ctx = build_tts_context( ctx = build_tts_context(
language=Language.EN_US, language=Language.EN_US,
heteronym_overrides=overrides, pronunciation=PronunciationConfig(heteronym_overrides=overrides),
) )
assert ctx.heteronym_rules is not None assert ctx.heteronym_rules is not None
@@ -244,7 +247,10 @@ class TestBuildTtsContext:
def test_normalization_overrides_stored(self): def test_normalization_overrides_stored(self):
overrides = {"normalization_numbers": False} overrides = {"normalization_numbers": False}
ctx = build_tts_context(language=Language.EN_US, normalization_overrides=overrides) ctx = build_tts_context(
language=Language.EN_US,
pronunciation=PronunciationConfig(normalization_overrides=overrides),
)
assert ctx.normalization_overrides is overrides assert ctx.normalization_overrides is overrides
def test_speakers_used_for_pronunciation(self): def test_speakers_used_for_pronunciation(self):