diff --git a/abogen/application/conversion_request.py b/abogen/application/conversion_request.py index 3e4456e..10f03d7 100644 --- a/abogen/application/conversion_request.py +++ b/abogen/application/conversion_request.py @@ -12,12 +12,10 @@ from __future__ import annotations import dataclasses from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from abogen.application.conversion_config import ( ChapterChunkConfig, - Epub3ExportConfig, - PronunciationConfig, SubtitleInputConfig, WordSubstitutionConfig, ) @@ -45,8 +43,11 @@ 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: if the object is present, - the feature is enabled. No boolean flags needed. + 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. Validation runs on creation via __post_init__: - None values → replaced with field default (from declaration) @@ -95,18 +96,23 @@ class ConversionRequest: # --- Metadata --- metadata_tags: Dict[str, Any] = field(default_factory=dict) - # --- Voice profiles (loaded by UI, used by app for voice resolution) --- - speakers: 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 + # --- Feature toggles --- + generate_epub3: bool = False + epub3_book_id: str = "" + # --- Feature configs (None = disabled) --- word_substitution: Optional[WordSubstitutionConfig] = None subtitle_input: Optional[SubtitleInputConfig] = None - epub3_export: Optional[Epub3ExportConfig] = None - pronunciation: Optional[PronunciationConfig] = None chapter_chunk: Optional[ChapterChunkConfig] = None def __post_init__(self) -> None: diff --git a/abogen/application/conversion_service.py b/abogen/application/conversion_service.py index a8a454a..8d5f420 100644 --- a/abogen/application/conversion_service.py +++ b/abogen/application/conversion_service.py @@ -16,7 +16,7 @@ The service NEVER imports from PyQt or WebUI. from __future__ import annotations from collections import defaultdict -from typing import Dict, Optional +from typing import Dict from abogen.application.conversion_executor import execute_conversion from abogen.application.conversion_models import ConversionPlan @@ -28,9 +28,7 @@ from abogen.application.conversion_ports import ( ) from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_result import ConversionResult -from abogen.domain.enums import SubtitleMode -from abogen.domain.normalization import TTSContext -from abogen.domain.split_pattern import get_split_pattern +from abogen.domain.normalization import build_tts_context def run_conversion( @@ -65,7 +63,16 @@ def run_conversion( # Stage 1: Prepare TTS context events.log("Preparing conversion pipeline") usage_counter: Dict[str, int] = defaultdict(int) - tts_context = _prepare_tts_context(request, events, usage_counter=usage_counter) + 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, + usage_counter=usage_counter, + log_callback=lambda level, msg: events.log(msg, level=level), + ) # Stage 2: Build conversion plan events.log("Building conversion plan") @@ -129,8 +136,7 @@ def _finalize( raise RuntimeError(f"Failed to embed m4b metadata: {exc}") from exc # EPUB3 generation - epub3_config = request.epub3_export - if epub3_config and plan.extraction: + if request.generate_epub3 and plan.extraction: audio_asset = result.audio_path if not audio_asset and result.chapter_paths: audio_asset = result.chapter_paths[0] @@ -147,7 +153,7 @@ def _finalize( events.log("Generating EPUB 3 package...") epub_path = build_epub3_package( output_path=epub_output_path, - book_id=epub3_config.book_id, + book_id=request.epub3_book_id, extraction=plan.extraction, metadata_tags=result.metadata or {}, chapter_markers=result.chapter_markers or [], @@ -177,7 +183,7 @@ def _finalize( chunk_level=request.chapter_chunk.chunk_level if request.chapter_chunk else None, speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else None, speakers=request.chapter_chunk.speakers if request.chapter_chunk else None, - generate_epub3=bool(request.epub3_export), + generate_epub3=bool(request.generate_epub3), ) metadata_dir = plan.output_layout.metadata_dir @@ -200,107 +206,4 @@ def _finalize( events.log(f"Failed to record override usage: {exc}", level="debug") -def _prepare_tts_context( - request: ConversionRequest, - events: ConversionEvents, - *, - usage_counter: Optional[Dict[str, int]] = None, -) -> TTSContext: - """Prepare TTSContext with normalization settings. - This compiles pronunciation/heteronym rules and creates the - normalization context used during conversion. - - Args: - request: Conversion request with override settings - events: For logging warnings about missing features - - Returns: - TTSContext ready for text normalization - """ - from abogen.domain.normalization import ( - build_apostrophe_config, - get_runtime_settings, - ) - from abogen.normalization_settings import apply_overrides, build_llm_configuration - from abogen.domain.pronunciation import ( - compile_heteronym_sentence_rules, - compile_pronunciation_rules, - merge_pronunciation_overrides, - ) - - # Get runtime normalization settings - normalization_settings = get_runtime_settings() - - # Extract pronunciation config early (needed for normalization overrides) - pronunciation = request.pronunciation - - # Apply per-job normalization overrides (same as runners) - job_overrides = pronunciation.normalization_overrides if pronunciation else None - if job_overrides: - normalization_settings = apply_overrides(normalization_settings, job_overrides) - - # Build apostrophe config - apostrophe_config = build_apostrophe_config( - settings=normalization_settings, - ) - - # Validate LLM apostrophe mode - apostrophe_mode = str(normalization_settings.get("normalization_apostrophe_mode", "spacy")).lower() - if apostrophe_mode == "llm": - llm_config = build_llm_configuration(normalization_settings) - if not llm_config.is_configured(): - raise RuntimeError( - "LLM-based apostrophe normalization is selected, but the LLM configuration is incomplete." - ) - - # Check for num2words availability - if apostrophe_config.convert_numbers: - try: - import num2words # noqa: F401 - except ImportError: - events.log( - "Number normalization is enabled but 'num2words' library is not available. " - "Numbers will NOT be converted to words.", - level="warning", - ) - - # Compute split pattern - split_pattern = get_split_pattern( - request.language or Language.EN_US, - request.subtitle_mode or SubtitleMode.DISABLED, - ) - - # Merge pronunciation overrides (manual + pronunciation) - - class _MockJob: - def __init__(self, pron): - self.pronunciation_overrides = pron.pronunciation_overrides if pron else [] - self.manual_overrides = pron.manual_overrides if pron else [] - self.heteronym_overrides = pron.heteronym_overrides if pron else [] - - merged_overrides = merge_pronunciation_overrides(_MockJob(pronunciation)) - - # Compile rules - pronunciation_rules = compile_pronunciation_rules(merged_overrides) - heteronym_overrides = pronunciation.heteronym_overrides if pronunciation else [] - heteronym_rules = compile_heteronym_sentence_rules(heteronym_overrides) - - if heteronym_rules: - events.log( - f"Applying {len(heteronym_rules)} heteronym override(s) during conversion.", - level="debug", - ) - if pronunciation_rules: - events.log( - f"Applying {len(pronunciation_rules)} pronunciation override(s) during conversion.", - level="debug", - ) - - return TTSContext( - split_pattern=split_pattern, - pronunciation_rules=pronunciation_rules, - heteronym_rules=heteronym_rules, - normalization_overrides=pronunciation.normalization_overrides if pronunciation else None, - usage_counter=usage_counter or {}, - ) diff --git a/tests/test_application_coverage.py b/tests/test_application_coverage.py index 48888ca..56bcdac 100644 --- a/tests/test_application_coverage.py +++ b/tests/test_application_coverage.py @@ -250,16 +250,12 @@ class TestConversionService: from abogen.application.conversion_service import run_conversion with tempfile.TemporaryDirectory() as tmpdir: - from abogen.application.conversion_config import PronunciationConfig - req = ConversionRequest( direct_text="Hello", voice="M1", save_mode="custom_folder", output_folder=Path(tmpdir), - pronunciation=PronunciationConfig( - normalization_overrides={"normalization_numbers": False}, - ), + normalization_overrides={"normalization_numbers": False}, ) events = FakeEvents() pipeline = FakePipelineProvider() @@ -273,16 +269,12 @@ class TestConversionService: from abogen.application.conversion_service import run_conversion with tempfile.TemporaryDirectory() as tmpdir: - from abogen.application.conversion_config import PronunciationConfig - req = ConversionRequest( direct_text="Hello", voice="M1", save_mode="custom_folder", output_folder=Path(tmpdir), - pronunciation=PronunciationConfig( - normalization_overrides={"normalization_apostrophe_mode": "llm"}, - ), + normalization_overrides={"normalization_apostrophe_mode": "llm"}, ) events = FakeEvents() pipeline = FakePipelineProvider()