mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
normalization_overrides=getattr(self, "normalization_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")),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
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,
|
||||
# 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(
|
||||
|
||||
@@ -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),
|
||||
normalization_overrides={"normalization_numbers": False},
|
||||
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),
|
||||
normalization_overrides={"normalization_apostrophe_mode": "llm"},
|
||||
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",
|
||||
output_folder=Path(tmpdir),
|
||||
save_as_project=True,
|
||||
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_chapters_separately=True,
|
||||
merge_chapters_at_end=True,
|
||||
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_chapters_separately=True,
|
||||
merge_chapters_at_end=False,
|
||||
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",
|
||||
output_folder=Path(tmpdir),
|
||||
save=SaveConfig(
|
||||
mode="custom_folder",
|
||||
output_folder=Path(tmpdir),
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=False,
|
||||
),
|
||||
output_format="m4b",
|
||||
save_chapters_separately=True,
|
||||
merge_chapters_at_end=False,
|
||||
)
|
||||
plan = ConversionPlan(
|
||||
request=req,
|
||||
@@ -546,10 +555,12 @@ class TestExecutorGaps:
|
||||
req = ConversionRequest(
|
||||
direct_text="Hello",
|
||||
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,
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_overrides=pronunciation,
|
||||
manual_overrides=manual,
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user