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,
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 dataclasses import dataclass, field
from pathlib import Path
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)
class WordSubstitutionConfig:
@@ -48,19 +55,6 @@ class Epub3ExportConfig:
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)
class ChapterChunkConfig:
"""Chapter and chunk configuration.
@@ -85,3 +79,17 @@ class ChapterChunkConfig:
raise ValueError(
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)
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_layout = plan.output_layout
@@ -201,7 +201,7 @@ def execute_conversion(
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
merge_chapters = request.save.merge_chapters_at_end or not request.save.save_chapters_separately
if request.output_format == OutputFormat.M4B:
merge_chapters = True
@@ -232,19 +232,17 @@ def execute_conversion(
# Open subtitle writer if needed
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(
audio_path,
request.subtitle_format,
request.subtitle_mode,
max_words=request.max_subtitle_words,
request.subtitle,
)
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
effective_subtitle_mode = request.subtitle.mode if subtitle_writer else SubtitleMode.DISABLED
synth = SynthParams(
tts_context=tts_context,
@@ -253,14 +251,14 @@ def execute_conversion(
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,
max_subtitle_words=request.subtitle.max_words,
language=request.language,
use_spacy_segmentation=use_spacy,
)
# Chapter directory
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.mkdir(parents=True, exist_ok=True)
@@ -307,11 +305,11 @@ def execute_conversion(
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_path = chapter_dir / f"{chapter_filename}.{request.save.separate_chapters_format}"
chapter_sink = stack.enter_context(
open_audio_sink(
chapter_path,
request.separate_chapters_format,
request.save.separate_chapters_format,
cancel_check=check_cancelled,
)
)
@@ -319,19 +317,17 @@ def execute_conversion(
# Per-chapter subtitle writer
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
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
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_writer = make_subtitle_writer(
chapter_subtitle_path,
request.subtitle_format,
request.subtitle_mode,
max_words=request.max_subtitle_words,
request.subtitle,
)
if chapter_subtitle_writer:
chapter_subtitle_writer.open()
@@ -439,7 +435,7 @@ def execute_conversion(
spacy_segments, active_split = spacy_pre_tts_segmentation(
seg_text,
request.language,
request.subtitle_mode,
request.subtitle.mode,
is_subtitle_input=is_subtitle_input,
use_spacy_segmentation=use_spacy,
log_callback=lambda msg: events.log(msg),
@@ -468,8 +464,7 @@ def execute_conversion(
process_and_write_subtitles(
accumulated_tokens,
subtitle_writer,
subtitle_mode=request.subtitle_mode,
max_subtitle_words=request.max_subtitle_words,
subtitle=request.subtitle,
language=request.language,
use_spacy_segmentation=use_spacy,
fallback_end_time=stats.current_time,
@@ -478,8 +473,7 @@ def execute_conversion(
process_and_write_subtitles(
accumulated_tokens,
chapter_subtitle_writer,
subtitle_mode=request.subtitle_mode,
max_subtitle_words=request.max_subtitle_words,
subtitle=request.subtitle,
language=request.language,
use_spacy_segmentation=use_spacy,
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 (
ChapterChunkConfig,
CoverConfig,
Epub3ExportConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
SubtitleInputConfig,
WordSubstitutionConfig,
)
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
from abogen.domain.enums import Language, OutputFormat
class ConversionRequestError(ValueError):
@@ -29,7 +33,6 @@ class ConversionRequestError(ValueError):
# 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),
@@ -44,11 +47,10 @@ class ConversionRequest:
Only contains fields that describe the conversion task itself.
UI-only fields (display, logging, user prompts) stay in adapters.
Feature toggles use config objects or boolean flags:
- word_substitution, subtitle_input, chapter_chunk = config objects (None = disabled)
- generate_epub3 = boolean flag
Pronunciation overrides are raw data (lists of dicts), compiled by app layer.
Feature toggles use config objects (None = disabled):
- word_substitution, subtitle_input, chapter_chunk, epub3_export
- pronunciation (raw data, compiled by app layer)
- subtitle, save, cover (grouped parameters)
Validation runs on creation via __post_init__:
- None values → replaced with field default (from declaration)
@@ -71,17 +73,6 @@ class ConversionRequest:
# --- 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
@@ -97,15 +88,11 @@ class ConversionRequest:
# --- Metadata ---
metadata_tags: Dict[str, Any] = field(default_factory=dict)
# --- Pronunciation overrides (raw data, compiled by app layer) ---
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
# --- Artifacts ---
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
# --- Grouped configs ---
subtitle: SubtitleConfig = field(default_factory=SubtitleConfig)
save: SaveConfig = field(default_factory=SaveConfig)
cover: CoverConfig = field(default_factory=CoverConfig)
pronunciation: PronunciationConfig = field(default_factory=PronunciationConfig)
# --- Feature configs (None = disabled) ---
epub3_export: Optional[Epub3ExportConfig] = None
+4 -10
View File
@@ -67,11 +67,8 @@ def run_conversion(
usage_counter: Dict[str, int] = defaultdict(int)
tts_context = build_tts_context(
language=request.language,
subtitle_mode=request.subtitle_mode.value if request.subtitle_mode else "Disabled",
pronunciation_overrides=request.pronunciation_overrides,
manual_overrides=request.manual_overrides,
heteronym_overrides=request.heteronym_overrides,
normalization_overrides=request.normalization_overrides,
subtitle=request.subtitle,
pronunciation=request.pronunciation,
usage_counter=usage_counter,
log_callback=lambda level, msg: events.log(msg, level=level),
)
@@ -154,15 +151,13 @@ def _finalize(
from abogen.infrastructure.exporters import ExportService
export_svc = ExportService()
cover_path = request.cover_image_path if request.cover_image_path and request.cover_image_path.exists() else None
try:
export_svc.embed_m4b_metadata(
audio_path=result.audio_path,
metadata=result.metadata or {},
chapters=result.chapter_markers or [],
cover_path=cover_path,
cover_mime=request.cover_image_mime,
cover=request.cover,
log_callback=lambda msg, level="info": events.log(msg, level=level),
)
except Exception as exc:
@@ -195,8 +190,7 @@ def _finalize(
chunks=request.chapter_chunk.chunks if request.chapter_chunk else [],
audio_path=audio_asset,
speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else "single",
cover_image_path=request.cover_image_path,
cover_image_mime=request.cover_image_mime,
cover=request.cover,
)
result.epub_path = 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
"""
# Determine base output directory
if request.save_mode == SaveMode.CUSTOM_FOLDER and request.output_folder:
parent_dir = Path(request.output_folder)
if request.save.mode == SaveMode.CUSTOM_FOLDER and request.save.output_folder:
parent_dir = Path(request.save.output_folder)
elif request.source_path:
parent_dir = request.source_path.parent
else:
@@ -66,7 +66,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
subtitle_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(
original_filename=request.original_filename,
save_as_project=True,
@@ -124,7 +124,7 @@ def resolve_chapter_path(
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}"
filename = f"{chapter_index:02d}_{slug}.{request.save.separate_chapters_format}"
return layout.audio_dir / "chapters" / filename
@@ -144,6 +144,6 @@ def should_merge_output(request: ConversionRequest) -> bool:
"""
if request.output_format == OutputFormat.M4B:
return True
if not request.save_chapters_separately:
if not request.save.save_chapters_separately:
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],
subtitle_writer: Any,
*,
subtitle_mode: str,
max_subtitle_words: int,
subtitle: "SubtitleConfig | str",
max_subtitle_words: int | None = None,
language: Language,
use_spacy_segmentation: bool,
fallback_end_time: float,
) -> None:
"""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:
return
new_entries: list[tuple] = []
process_subtitle_tokens(
accumulated_tokens,
new_entries,
max_subtitle_words,
subtitle_mode,
words,
mode_str,
language,
use_spacy_segmentation=use_spacy_segmentation,
fallback_end_time=fallback_end_time,
+27 -23
View File
@@ -129,12 +129,9 @@ def prepare_text_for_tts(
def build_tts_context(
*,
language: Language,
subtitle_mode: str = "Disabled",
pronunciation_overrides: Optional[List[Dict[str, Any]]] = None,
manual_overrides: Optional[List[Dict[str, Any]]] = None,
heteronym_overrides: Optional[List[Dict[str, Any]]] = None,
subtitle: "SubtitleConfig | str" = "Disabled",
pronunciation: Optional["PronunciationConfig"] = None,
speakers: Optional[Dict[str, Any]] = None,
normalization_overrides: Optional[Mapping[str, Any]] = None,
usage_counter: Optional[Dict[str, int]] = None,
log_callback: Optional[Callable[[str, str], None]] = None,
) -> TTSContext:
@@ -145,19 +142,17 @@ def build_tts_context(
Args:
language: Language enum value.
subtitle_mode: Subtitle mode string.
pronunciation_overrides: List of pronunciation override dicts.
manual_overrides: List of manual override dicts.
heteronym_overrides: List of heteronym override dicts.
subtitle: SubtitleConfig object or subtitle mode string.
pronunciation: PronunciationConfig with override rules.
speakers: Speaker profile mapping.
normalization_overrides: Per-job normalization setting overrides.
usage_counter: Mutable dict for tracking override usage.
log_callback: Callable(level, message) for warnings.
Returns:
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 (
compile_heteronym_sentence_rules,
compile_pronunciation_rules,
@@ -169,12 +164,25 @@ def build_tts_context(
if log_callback:
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
runtime_settings = get_runtime_settings()
# Apply per-job normalization overrides
if normalization_overrides:
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
if pronunciation.normalization_overrides:
runtime_settings = _apply_overrides(runtime_settings, pronunciation.normalization_overrides)
# Build apostrophe config
apostrophe_config = build_apostrophe_config(settings=runtime_settings)
@@ -202,16 +210,12 @@ def build_tts_context(
# Compute split pattern
if not isinstance(language, Language):
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
try:
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)
split_pattern = get_split_pattern(language, resolved_subtitle)
# Merge pronunciation overrides (accepts dict or object)
# Merge pronunciation overrides
source = {
"pronunciation_overrides": pronunciation_overrides or [],
"manual_overrides": manual_overrides or [],
"pronunciation_overrides": pronunciation.pronunciation_overrides,
"manual_overrides": pronunciation.manual_overrides,
"speakers": speakers or {},
"language": language,
}
@@ -219,7 +223,7 @@ def build_tts_context(
# Compile rules
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:
_log(
@@ -236,6 +240,6 @@ def build_tts_context(
split_pattern=split_pattern,
pronunciation_rules=pronunciation_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 {},
)
+5
View File
@@ -516,9 +516,14 @@ def build_epub3_package(
chunks: Iterable[Dict[str, Any]],
audio_path: Path,
speaker_mode: str = "single",
cover: "CoverConfig | None" = None,
cover_image_path: Optional[Path] = None,
cover_image_mime: Optional[str] = None,
) -> Path:
from abogen.domain.config_types import CoverConfig
if isinstance(cover, CoverConfig):
cover_image_path = cover.path
cover_image_mime = cover.mime
builder = EPUB3PackageBuilder(
output_path=output_path,
book_id=book_id,
+5
View File
@@ -132,11 +132,16 @@ class ExportService:
audio_path: Path,
metadata: Dict[str, Any],
chapters: List[Dict[str, Any]],
cover: "CoverConfig | None" = None,
cover_path: Optional[Path] = None,
cover_mime: Optional[str] = None,
log_callback: Optional[callable] = None,
) -> None:
"""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)
metadata_args = self._metadata_to_ffmpeg_args(metadata)
+36 -19
View File
@@ -278,24 +278,28 @@ def create_subtitle_writer(
def resolve_subtitle_format(
subtitle_format: str | None,
subtitle_mode: str,
subtitle: "SubtitleConfig | str | None",
subtitle_mode: str | None = None,
) -> 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
(e.g. ``"ass_centered_narrow"`` extension ``"ass"``, alignment
``"center_narrow"``).
Also enforces that ``"Sentence + Highlighting"`` mode requires ASS.
Accepts a SubtitleConfig object or individual format/mode strings
for backward compatibility.
Returns:
Tuple of (file_extension, alignment) suitable for
: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"
if "ass" in fmt:
@@ -317,26 +321,39 @@ def resolve_subtitle_format(
def make_subtitle_writer(
audio_path: Path,
subtitle_format: str | None,
subtitle_mode: str,
max_words: int = 50,
subtitle: "SubtitleConfig | str | None",
subtitle_mode: str | None = None,
max_words: int | None = None,
) -> SubtitleWriter | None:
"""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.
"""
if subtitle_mode == "Disabled":
return None
from abogen.domain.config_types import SubtitleConfig
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:
return create_subtitle_writer(
audio_path.with_suffix(f".{extension}"),
extension,
subtitle_mode,
mode_str,
alignment=alignment,
max_words=max_words,
max_words=words,
)
except (ValueError, KeyError):
return None
+7 -4
View File
@@ -533,14 +533,17 @@ class ConversionThread(QThread):
)
# --- Compile normalization rules (heteronym + pronunciation) ---
from abogen.domain.config_types import PronunciationConfig
from abogen.domain.normalization import build_tts_context
self._tts_context = build_tts_context(
language=self.lang_code,
subtitle_mode=self.subtitle_mode,
pronunciation_overrides=getattr(self, "pronunciation_overrides", None),
manual_overrides=getattr(self, "manual_overrides", None),
heteronym_overrides=getattr(self, "heteronym_overrides", None),
subtitle=self.subtitle_mode,
pronunciation=PronunciationConfig(
pronunciation_overrides=getattr(self, "pronunciation_overrides", None) or [],
manual_overrides=getattr(self, "manual_overrides", None) or [],
heteronym_overrides=getattr(self, "heteronym_overrides", None) or [],
normalization_overrides=getattr(self, "normalization_overrides", None),
),
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 (
ChapterChunkConfig,
CoverConfig,
Epub3ExportConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
)
from abogen.application.conversion_ports import ConversionCancelled
from abogen.application.conversion_request import ConversionRequest
@@ -57,16 +61,6 @@ def _build_request(job: Job) -> ConversionRequest:
supertonic_total_steps=job.supertonic_total_steps,
# 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
silence_between_chapters=job.silence_between_chapters,
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,
# Metadata
metadata_tags=job.metadata_tags or {},
# Artifacts
cover_image_path=job.cover_image_path,
cover_image_mime=job.cover_image_mime,
# Pronunciation overrides (raw data)
# Grouped configs
subtitle=SubtitleConfig(
mode=_resolve_subtitle_mode(job.subtitle_mode),
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 [],
manual_overrides=job.manual_overrides or [],
heteronym_overrides=job.heteronym_overrides or [],
normalization_overrides=job.normalization_overrides or None,
),
# Feature configs
epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None,
chapter_chunk=ChapterChunkConfig(
+41 -33
View File
@@ -11,6 +11,12 @@ from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from abogen.application.conversion_config import (
CoverConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
)
from abogen.application.conversion_request import ConversionRequest
from abogen.application.conversion_models import (
ChapterPlan,
@@ -137,8 +143,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello world",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -156,8 +161,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -177,8 +181,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
events.cancelled = True
@@ -204,8 +207,7 @@ class TestConversionService:
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),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -221,8 +223,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Body text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
read_title_intro=True,
read_closing_outro=True,
metadata_tags={"title": "Test Book", "author": "Author"},
@@ -256,9 +257,10 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
pronunciation=PronunciationConfig(
normalization_overrides={"normalization_numbers": False},
),
)
events = FakeEvents()
@@ -273,9 +275,10 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
pronunciation=PronunciationConfig(
normalization_overrides={"normalization_apostrophe_mode": "llm"},
),
)
events = FakeEvents()
@@ -290,8 +293,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -314,8 +316,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
layout = resolve_output_layout(req)
@@ -332,7 +333,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
source_path=source,
voice="M1",
save_mode="save_next_to_input",
save=SaveConfig(mode="save_next_to_input"),
)
layout = resolve_output_layout(req)
@@ -346,9 +347,11 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir),
save_as_project=True,
),
original_filename="test.wav",
)
layout = resolve_output_layout(req)
@@ -388,7 +391,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
separate_chapters_format="wav",
save=SaveConfig(separate_chapters_format="wav"),
)
path = resolve_chapter_path(layout, req, "Chapter 1", 1)
@@ -407,7 +410,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
separate_chapters_format="wav",
save=SaveConfig(separate_chapters_format="wav"),
)
path = resolve_chapter_path(layout, req, "", 3)
@@ -421,7 +424,7 @@ class TestOutputLayoutService:
direct_text="Hello",
voice="M1",
output_format="m4b",
merge_chapters_at_end=False,
save=SaveConfig(merge_chapters_at_end=False),
)
assert should_merge_output(req) is True
@@ -432,7 +435,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_chapters_separately=False,
save=SaveConfig(save_chapters_separately=False),
)
assert should_merge_output(req) is True
@@ -443,8 +446,10 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save=SaveConfig(
save_chapters_separately=True,
merge_chapters_at_end=True,
),
)
assert should_merge_output(req) is True
@@ -455,8 +460,10 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save=SaveConfig(
save_chapters_separately=True,
merge_chapters_at_end=False,
),
)
assert should_merge_output(req) is False
@@ -501,11 +508,13 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir),
output_format="m4b",
save_chapters_separately=True,
merge_chapters_at_end=False,
),
output_format="m4b",
)
plan = ConversionPlan(
request=req,
@@ -546,10 +555,12 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=True,
),
)
plan = ConversionPlan(
request=req,
@@ -599,8 +610,7 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -657,8 +667,7 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -698,8 +707,7 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
silence_between_chapters=1.0,
)
plan = ConversionPlan(
+7 -11
View File
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field
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 (
synthesize_text,
SynthParams,
@@ -253,8 +254,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
[],
writer,
subtitle_mode="Sentence",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=10.0,
@@ -270,8 +270,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
tokens,
writer,
subtitle_mode="Sentence",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=2.0,
@@ -292,8 +291,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
tokens,
writer,
subtitle_mode="Line",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.LINE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=3.0,
@@ -311,8 +309,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
tokens,
writer,
subtitle_mode="Disabled",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.DISABLED, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=2.0,
@@ -366,8 +363,7 @@ class TestFullPipeline:
process_and_write_subtitles(
tokens,
subtitle_writer,
subtitle_mode="Sentence",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=stats.current_time,
+8 -16
View File
@@ -12,6 +12,7 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
from abogen.application.conversion_config import SaveConfig
from abogen.application.conversion_executor import execute_conversion
from abogen.application.conversion_models import (
ChapterPlan,
@@ -150,8 +151,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Hello world",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -198,10 +198,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=True,
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir), save_chapters_separately=True, merge_chapters_at_end=True),
)
plan = ConversionPlan(
request=req,
@@ -262,8 +259,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -315,8 +311,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -377,8 +372,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -424,8 +418,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Hello world",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -471,8 +464,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
+2 -2
View File
@@ -125,12 +125,12 @@ class TestBuildConversionPlan:
def test_output_layout(self):
"""Output layout is resolved from request."""
from abogen.application.conversion_config import SaveConfig
with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = build_conversion_plan(req)
+31 -25
View File
@@ -16,7 +16,14 @@ from pathlib import Path
from unittest.mock import MagicMock
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.normalization import TTSContext
from abogen.domain.settings_core import settings_defaults
@@ -202,23 +209,11 @@ class TestConversionRequestValidation:
def test_defaults_are_valid(self):
req = ConversionRequest()
assert req.max_subtitle_words == 50
assert req.subtitle.max_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
assert req.subtitle.mode == SubtitleMode.DISABLED
def test_speed_clamped_below_min(self):
req = ConversionRequest(speed=0.1)
@@ -256,10 +251,6 @@ class TestConversionRequestValidation:
with pytest.raises(ValueError, match="speaker_mode"):
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):
with pytest.raises(ConversionRequestError, match="speed"):
ConversionRequest(speed="fast")
@@ -276,12 +267,27 @@ class TestConversionRequestValidation:
req = ConversionRequest(
language=Language.FR,
output_format=OutputFormat.MP3,
subtitle_mode=SubtitleMode.SENTENCE,
subtitle_format=SubtitleFormat.ASS,
save_mode=SaveMode.CUSTOM_FOLDER,
subtitle=SubtitleConfig(
mode=SubtitleMode.SENTENCE,
format=SubtitleFormat.ASS,
),
save=SaveConfig(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
assert req.subtitle.mode == SubtitleMode.SENTENCE
assert req.subtitle.format == SubtitleFormat.ASS
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
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
@@ -156,16 +157,16 @@ class TestBuildTtsContext:
assert isinstance(ctx, TTSContext)
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 len(ctx.split_pattern) > 0
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"
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
def test_pronunciation_overrides_compiled(self):
@@ -178,7 +179,7 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
pronunciation_overrides=overrides,
pronunciation=PronunciationConfig(pronunciation_overrides=overrides),
)
assert ctx.pronunciation_rules is not None
assert len(ctx.pronunciation_rules) >= 1
@@ -193,7 +194,7 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
manual_overrides=overrides,
pronunciation=PronunciationConfig(manual_overrides=overrides),
)
assert ctx.pronunciation_rules is not None
assert len(ctx.pronunciation_rules) >= 1
@@ -207,8 +208,10 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
pronunciation=PronunciationConfig(
pronunciation_overrides=pronunciation,
manual_overrides=manual,
),
)
found_right = any(
r.get("replacement") == "RIGHT" for r in ctx.pronunciation_rules
@@ -229,7 +232,7 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
heteronym_overrides=overrides,
pronunciation=PronunciationConfig(heteronym_overrides=overrides),
)
assert ctx.heteronym_rules is not None
@@ -244,7 +247,10 @@ class TestBuildTtsContext:
def test_normalization_overrides_stored(self):
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
def test_speakers_used_for_pronunciation(self):