diff --git a/abogen/application/conversion_config.py b/abogen/application/conversion_config.py index 2d3ac94..ec1292f 100644 --- a/abogen/application/conversion_config.py +++ b/abogen/application/conversion_config.py @@ -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 diff --git a/abogen/application/conversion_executor.py b/abogen/application/conversion_executor.py index a2d5485..372f7bc 100644 --- a/abogen/application/conversion_executor.py +++ b/abogen/application/conversion_executor.py @@ -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, diff --git a/abogen/application/conversion_request.py b/abogen/application/conversion_request.py index a9eb1c2..2b83411 100644 --- a/abogen/application/conversion_request.py +++ b/abogen/application/conversion_request.py @@ -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 diff --git a/abogen/application/conversion_service.py b/abogen/application/conversion_service.py index da0cd3e..8103ab9 100644 --- a/abogen/application/conversion_service.py +++ b/abogen/application/conversion_service.py @@ -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 diff --git a/abogen/application/output_layout_service.py b/abogen/application/output_layout_service.py index 2589ea9..89408cd 100644 --- a/abogen/application/output_layout_service.py +++ b/abogen/application/output_layout_service.py @@ -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 diff --git a/abogen/domain/config_types.py b/abogen/domain/config_types.py new file mode 100644 index 0000000..d00c27a --- /dev/null +++ b/abogen/domain/config_types.py @@ -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 diff --git a/abogen/domain/conversion_engine.py b/abogen/domain/conversion_engine.py index 552854e..c21b80f 100644 --- a/abogen/domain/conversion_engine.py +++ b/abogen/domain/conversion_engine.py @@ -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, diff --git a/abogen/domain/normalization.py b/abogen/domain/normalization.py index 28e0d8f..99d7cab 100644 --- a/abogen/domain/normalization.py +++ b/abogen/domain/normalization.py @@ -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 {}, ) diff --git a/abogen/epub3/exporter.py b/abogen/epub3/exporter.py index edb8ed5..ce1a736 100644 --- a/abogen/epub3/exporter.py +++ b/abogen/epub3/exporter.py @@ -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, diff --git a/abogen/infrastructure/exporters.py b/abogen/infrastructure/exporters.py index a561ce3..7e43d7b 100644 --- a/abogen/infrastructure/exporters.py +++ b/abogen/infrastructure/exporters.py @@ -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) diff --git a/abogen/infrastructure/subtitle_writer.py b/abogen/infrastructure/subtitle_writer.py index bbca5b4..d283e38 100644 --- a/abogen/infrastructure/subtitle_writer.py +++ b/abogen/infrastructure/subtitle_writer.py @@ -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 diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index 3e480cf..3787c0a 100644 --- a/abogen/pyqt/conversion.py +++ b/abogen/pyqt/conversion.py @@ -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")), ) diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index 5399c44..7e0de18 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -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( diff --git a/tests/test_application_coverage.py b/tests/test_application_coverage.py index 01387d8..d044aa8 100644 --- a/tests/test_application_coverage.py +++ b/tests/test_application_coverage.py @@ -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="<>\nText A\n<>\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( diff --git a/tests/test_conversion_executor.py b/tests/test_conversion_executor.py index 89cb645..75bfb74 100644 --- a/tests/test_conversion_executor.py +++ b/tests/test_conversion_executor.py @@ -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, diff --git a/tests/test_conversion_executor_unified.py b/tests/test_conversion_executor_unified.py index 8156290..359f5bb 100644 --- a/tests/test_conversion_executor_unified.py +++ b/tests/test_conversion_executor_unified.py @@ -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, diff --git a/tests/test_conversion_planner.py b/tests/test_conversion_planner.py index 1ac5493..737e28b 100644 --- a/tests/test_conversion_planner.py +++ b/tests/test_conversion_planner.py @@ -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) diff --git a/tests/test_conversion_request.py b/tests/test_conversion_request.py index b756990..b99ca85 100644 --- a/tests/test_conversion_request.py +++ b/tests/test_conversion_request.py @@ -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 diff --git a/tests/test_domain_normalization_pipeline.py b/tests/test_domain_normalization_pipeline.py index 6ec4ea2..b67b369 100644 --- a/tests/test_domain_normalization_pipeline.py +++ b/tests/test_domain_normalization_pipeline.py @@ -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):