diff --git a/abogen/application/conversion_config.py b/abogen/application/conversion_config.py new file mode 100644 index 0000000..2d3ac94 --- /dev/null +++ b/abogen/application/conversion_config.py @@ -0,0 +1,87 @@ +"""Feature config objects for ConversionRequest. + +Each config object groups parameters for a specific feature. +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. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass(frozen=True) +class WordSubstitutionConfig: + """Word substitution settings. + + When present on ConversionRequest, word substitution is applied + to the source text before chapter parsing. + """ + substitutions_list: str = "" + case_sensitive: bool = False + replace_caps: bool = False + replace_numerals: bool = False + fix_punctuation: bool = False + + +@dataclass(frozen=True) +class SubtitleInputConfig: + """Subtitle file input settings. + + When present on ConversionRequest, the source is treated as a + subtitle file (.srt/.ass/.vtt) or timestamp text, and the + subtitle-to-audio pipeline is used instead of normal text conversion. + """ + is_timestamp_text: bool = False + + +@dataclass(frozen=True) +class Epub3ExportConfig: + """EPUB3 export settings. + + When present on ConversionRequest, an EPUB3 package with + synchronized audio narration is generated after conversion. + """ + 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. + + Groups chapter overrides, chunk data, and speaker settings + used by the planner to build segments. + """ + chapter_overrides: List[Dict[str, Any]] = field(default_factory=list) + chunks: List[Dict[str, Any]] = field(default_factory=list) + chunk_level: str = "paragraph" + speaker_mode: str = "single" + speakers: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _VALID_CHUNK_LEVELS = ("paragraph", "sentence") + _VALID_SPEAKER_MODES = ("single", "multi") + if self.chunk_level not in _VALID_CHUNK_LEVELS: + raise ValueError( + f"chunk_level must be one of {_VALID_CHUNK_LEVELS}, got {self.chunk_level!r}" + ) + if self.speaker_mode not in _VALID_SPEAKER_MODES: + raise ValueError( + f"speaker_mode must be one of {_VALID_SPEAKER_MODES}, got {self.speaker_mode!r}" + ) diff --git a/abogen/application/conversion_executor.py b/abogen/application/conversion_executor.py index 2f2ffec..055afc0 100644 --- a/abogen/application/conversion_executor.py +++ b/abogen/application/conversion_executor.py @@ -302,7 +302,7 @@ def execute_conversion( "end": stats.current_time, "speaker_id": segment.speaker_id, "voice": segment.voice_spec, - "level": segment.level or request.chunk_level, + "level": segment.level or (request.chapter_chunk.chunk_level if request.chapter_chunk else "paragraph"), "characters": len(segment.text), }) diff --git a/abogen/application/conversion_planner.py b/abogen/application/conversion_planner.py index f90eb98..7fb7a1b 100644 --- a/abogen/application/conversion_planner.py +++ b/abogen/application/conversion_planner.py @@ -144,8 +144,9 @@ def _apply_selection( ] # If user specified chapters, apply overrides - if request.chapter_overrides: - selected, _, diagnostics = apply_chapter_overrides(extracted, request.chapter_overrides) + chapter_chunk = request.chapter_chunk + if chapter_chunk and chapter_chunk.chapter_overrides: + selected, _, diagnostics = apply_chapter_overrides(extracted, chapter_chunk.chapter_overrides) if selected: # Map back to (title, text, voice) tuples result = [] @@ -216,9 +217,10 @@ def _build_segments( segments = [] # Check for chunks (WebUI style) - if request.chunks: + chapter_chunk = request.chapter_chunk + if chapter_chunk and chapter_chunk.chunks: # Group chunks by chapter (simplified — assume chunks are for current chapter) - for chunk_idx, chunk in enumerate(request.chunks): + for chunk_idx, chunk in enumerate(chapter_chunk.chunks): chunk_text = chunk.get("normalized_text") or chunk.get("text", "") if not chunk_text or not chunk_text.strip(): continue @@ -234,7 +236,7 @@ def _build_segments( speaker_id=speaker_id, chunk_id=chunk.get("id"), chunk_index=chunk.get("chunk_index", chunk_idx), - level=chunk.get("level", request.chunk_level), + level=chunk.get("level", chapter_chunk.chunk_level), source="chunk", ) ) @@ -284,8 +286,9 @@ def _resolve_chunk_voice( """Resolve voice for a chunk.""" # Check for speaker-based voice speaker_id = chunk.get("speaker_id", "narrator") - if speaker_id and speaker_id != "narrator" and request.speakers: - speaker_config = request.speakers.get(speaker_id, {}) + speakers = request.chapter_chunk.speakers if request.chapter_chunk else {} + if speaker_id and speaker_id != "narrator" and speakers: + speaker_config = speakers.get(speaker_id, {}) if isinstance(speaker_config, dict): voice = speaker_config.get("voice") if voice: diff --git a/abogen/application/conversion_request.py b/abogen/application/conversion_request.py index d7dd826..409e75b 100644 --- a/abogen/application/conversion_request.py +++ b/abogen/application/conversion_request.py @@ -14,6 +14,13 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional +from abogen.application.conversion_config import ( + ChapterChunkConfig, + Epub3ExportConfig, + PronunciationConfig, + SubtitleInputConfig, + WordSubstitutionConfig, +) from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode @@ -30,12 +37,6 @@ _NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = { "chapter_intro_delay": (0.0, None), } -# Enum-like fields that must be in allowed set -_ENUM_CONSTRAINTS: dict[str, tuple[str, ...]] = { - "chunk_level": ("paragraph", "sentence"), - "speaker_mode": ("single", "multi"), -} - @dataclass class ConversionRequest: @@ -44,10 +45,12 @@ 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. + Validation runs on creation via __post_init__: - None values → replaced with field default (from declaration) - Numeric fields → clamped to valid range - - String enums → validated against allowed set """ # --- Source --- @@ -89,26 +92,19 @@ class ConversionRequest: auto_prefix_chapter_titles: bool = True normalize_chapter_opening_caps: bool = False - # --- Pronunciation / Normalization --- - 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 - - # --- Chapter/Chunk Configuration --- - chapter_overrides: List[Dict[str, Any]] = field(default_factory=list) - chunks: List[Dict[str, Any]] = field(default_factory=list) - chunk_level: str = "paragraph" - speaker_mode: str = "single" - speakers: Dict[str, Any] = field(default_factory=dict) - # --- Metadata --- metadata_tags: Dict[str, Any] = field(default_factory=dict) # --- Artifacts --- cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None - generate_epub3: bool = False + + # --- 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: """Resolve None → default, then validate and clamp.""" @@ -116,7 +112,6 @@ class ConversionRequest: if not self.tts_provider: self.tts_provider = "kokoro" _clamp_numerics(self) - _validate_enums(self) def _apply_none_defaults(obj: ConversionRequest) -> None: @@ -144,13 +139,3 @@ def _clamp_numerics(obj: ConversionRequest) -> None: if max_v is not None: clamped = min(max_v, clamped) setattr(obj, attr, clamped) - - -def _validate_enums(obj: ConversionRequest) -> None: - """Validate string enum fields against allowed values.""" - for attr, allowed in _ENUM_CONSTRAINTS.items(): - val = getattr(obj, attr) - if val not in allowed: - raise ConversionRequestError( - f"{attr} must be one of {allowed}, got {val!r}" - ) diff --git a/abogen/application/conversion_service.py b/abogen/application/conversion_service.py index c4d8b89..54461d9 100644 --- a/abogen/application/conversion_service.py +++ b/abogen/application/conversion_service.py @@ -141,17 +141,20 @@ def _prepare_tts_context( # Merge pronunciation overrides (manual + pronunciation) # Create a mock job-like object for merge_pronunciation_overrides - class _MockJob: - def __init__(self, req): - self.pronunciation_overrides = req.pronunciation_overrides - self.manual_overrides = req.manual_overrides - self.heteronym_overrides = req.heteronym_overrides + pronunciation = request.pronunciation - merged_overrides = merge_pronunciation_overrides(_MockJob(request)) + 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_rules = compile_heteronym_sentence_rules(request.heteronym_overrides) + heteronym_overrides = pronunciation.heteronym_overrides if pronunciation else [] + heteronym_rules = compile_heteronym_sentence_rules(heteronym_overrides) if heteronym_rules: events.log( @@ -168,5 +171,5 @@ def _prepare_tts_context( split_pattern=split_pattern, pronunciation_rules=pronunciation_rules, heteronym_rules=heteronym_rules, - normalization_overrides=request.normalization_overrides, + normalization_overrides=pronunciation.normalization_overrides if pronunciation else None, ) diff --git a/abogen/pyqt/conversion_adapter.py b/abogen/pyqt/conversion_adapter.py index e1adfd8..73a7bfc 100644 --- a/abogen/pyqt/conversion_adapter.py +++ b/abogen/pyqt/conversion_adapter.py @@ -17,6 +17,12 @@ import os from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from abogen.application.conversion_config import ( + ChapterChunkConfig, + Epub3ExportConfig, + PronunciationConfig, + WordSubstitutionConfig, +) from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice @@ -54,6 +60,25 @@ def build_conversion_request_from_thread(thread: Any) -> ConversionRequest: if thread.output_folder: output_folder = Path(thread.output_folder) + # Build pronunciation config + pronunciation = None + pron_overrides = getattr(thread, "pronunciation_overrides", []) or [] + manual_overrides = getattr(thread, "manual_overrides", []) or [] + heteronym_overrides = getattr(thread, "heteronym_overrides", []) or [] + norm_overrides = getattr(thread, "normalization_overrides", None) + if pron_overrides or manual_overrides or heteronym_overrides or norm_overrides: + pronunciation = PronunciationConfig( + pronunciation_overrides=pron_overrides, + manual_overrides=manual_overrides, + heteronym_overrides=heteronym_overrides, + normalization_overrides=norm_overrides, + ) + + # Build epub3 config + epub3_export = None + if getattr(thread, "generate_epub3", False): + epub3_export = Epub3ExportConfig() + return ConversionRequest( # Source source_path=source_path, @@ -88,23 +113,15 @@ def build_conversion_request_from_thread(thread: Any) -> ConversionRequest: read_closing_outro=getattr(thread, "read_closing_outro", True), auto_prefix_chapter_titles=getattr(thread, "auto_prefix_chapter_titles", True), normalize_chapter_opening_caps=getattr(thread, "normalize_chapter_opening_caps", False), - # Pronunciation / Normalization - pronunciation_overrides=getattr(thread, "pronunciation_overrides", []) or [], - manual_overrides=getattr(thread, "manual_overrides", []) or [], - heteronym_overrides=getattr(thread, "heteronym_overrides", []) or [], - normalization_overrides=getattr(thread, "normalization_overrides", None), - # Chapter/Chunk Configuration - chapter_overrides=[], # PyQt doesn't use chapter overrides from GUI - chunks=[], # PyQt doesn't use chunks from GUI - chunk_level="paragraph", - speaker_mode="single", - speakers={}, # Metadata metadata_tags=getattr(thread, "metadata_tags", {}) or {}, # Artifacts cover_image_path=getattr(thread, "cover_image_path", None), cover_image_mime=getattr(thread, "cover_image_mime", None), - generate_epub3=getattr(thread, "generate_epub3", False), + # Feature configs + pronunciation=pronunciation, + epub3_export=epub3_export, + chapter_chunk=ChapterChunkConfig(), # PyQt doesn't use chapter overrides from GUI ) diff --git a/abogen/webui/conversion_adapter.py b/abogen/webui/conversion_adapter.py index ab8ed5b..d5ec8a4 100644 --- a/abogen/webui/conversion_adapter.py +++ b/abogen/webui/conversion_adapter.py @@ -17,6 +17,12 @@ from __future__ import annotations from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from abogen.application.conversion_config import ( + ChapterChunkConfig, + Epub3ExportConfig, + PronunciationConfig, + WordSubstitutionConfig, +) from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice @@ -33,6 +39,47 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest: Returns: ConversionRequest with all Job data mapped """ + # Build word substitution config + word_substitution = None + if getattr(job, "word_substitutions_enabled", False): + word_substitution = WordSubstitutionConfig( + substitutions_list=getattr(job, "word_substitutions_list", ""), + case_sensitive=getattr(job, "case_sensitive_substitutions", False), + replace_caps=getattr(job, "replace_all_caps", False), + replace_numerals=getattr(job, "replace_numerals", False), + fix_punctuation=getattr(job, "fix_nonstandard_punctuation", False), + ) + + # Build pronunciation config + pronunciation = None + pron_overrides = job.pronunciation_overrides or [] + manual_overrides = job.manual_overrides or [] + heteronym_overrides = job.heteronym_overrides or [] + norm_overrides = job.normalization_overrides or None + if pron_overrides or manual_overrides or heteronym_overrides or norm_overrides: + pronunciation = PronunciationConfig( + pronunciation_overrides=pron_overrides, + manual_overrides=manual_overrides, + heteronym_overrides=heteronym_overrides, + normalization_overrides=norm_overrides, + ) + + # Build chapter/chunk config + chapter_chunk = ChapterChunkConfig( + chapter_overrides=job.chapters or [], + chunks=job.chunks or [], + chunk_level=job.chunk_level, + speaker_mode=job.speaker_mode, + speakers=job.speakers or {}, + ) + + # Build epub3 config + epub3_export = None + if getattr(job, "generate_epub3", False): + epub3_export = Epub3ExportConfig( + book_id=getattr(job, "id", ""), + ) + return ConversionRequest( # Source source_path=Path(job.stored_path) if job.stored_path else None, @@ -66,23 +113,16 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest: read_closing_outro=job.read_closing_outro, auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, normalize_chapter_opening_caps=job.normalize_chapter_opening_caps, - # Pronunciation / Normalization - pronunciation_overrides=job.pronunciation_overrides or [], - manual_overrides=job.manual_overrides or [], - heteronym_overrides=job.heteronym_overrides or [], - normalization_overrides=job.normalization_overrides or {}, - # Chapter/Chunk Configuration - chapter_overrides=job.chapters or [], - chunks=job.chunks or [], - chunk_level=job.chunk_level, - speaker_mode=job.speaker_mode, - speakers=job.speakers or {}, # Metadata metadata_tags=job.metadata_tags or {}, # Artifacts cover_image_path=Path(job.cover_image_path) if job.cover_image_path else None, cover_image_mime=job.cover_image_mime, - generate_epub3=job.generate_epub3, + # Feature configs + word_substitution=word_substitution, + pronunciation=pronunciation, + chapter_chunk=chapter_chunk, + epub3_export=epub3_export, ) diff --git a/tests/test_conversion_adapters.py b/tests/test_conversion_adapters.py index 7e066a1..53b58e7 100644 --- a/tests/test_conversion_adapters.py +++ b/tests/test_conversion_adapters.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch import pytest from abogen.application.conversion_request import ConversionRequest +from abogen.application.conversion_config import ChapterChunkConfig, PronunciationConfig from abogen.application.conversion_ports import ResolvedVoice @@ -112,25 +113,22 @@ class TestWebUIAdapter: assert req.output_folder is None assert req.cover_image_path is None - def test_chapter_overrides_mapped(self): + def test_chapter_chunk_mapped(self): from abogen.webui.conversion_adapter import build_conversion_request_from_job chapters = [{"title": "Ch1", "voice": "F1"}] - job = self._make_job(chapters=chapters) - req = build_conversion_request_from_job(job) - - assert req.chapter_overrides == chapters - - def test_chunks_mapped(self): - from abogen.webui.conversion_adapter import build_conversion_request_from_job - chunks = [{"text": "Hello", "speaker": "A"}] - job = self._make_job(chunks=chunks) + job = self._make_job(chapters=chapters, chunks=chunks) req = build_conversion_request_from_job(job) - assert req.chunks == chunks + assert isinstance(req.chapter_chunk, ChapterChunkConfig) + assert req.chapter_chunk.chapter_overrides == chapters + assert req.chapter_chunk.chunks == chunks + assert req.chapter_chunk.chunk_level == "paragraph" + assert req.chapter_chunk.speaker_mode == "single" + assert req.chapter_chunk.speakers == {} - def test_pronunciation_overrides_mapped(self): + def test_pronunciation_mapped(self): from abogen.webui.conversion_adapter import build_conversion_request_from_job job = self._make_job( @@ -140,9 +138,10 @@ class TestWebUIAdapter: ) req = build_conversion_request_from_job(job) - assert req.pronunciation_overrides == ["word=pron"] - assert req.manual_overrides == ["manual=override"] - assert req.heteronym_overrides == ["read=reed"] + assert isinstance(req.pronunciation, PronunciationConfig) + assert req.pronunciation.pronunciation_overrides == ["word=pron"] + assert req.pronunciation.manual_overrides == ["manual=override"] + assert req.pronunciation.heteronym_overrides == ["read=reed"] def test_none_defaults_handled(self): from abogen.webui.conversion_adapter import build_conversion_request_from_job @@ -399,17 +398,18 @@ class TestPyQtAdapter: assert req.supertonic_total_steps == 5 assert req.max_subtitle_words == 50 - def test_chapter_chunks_not_mapped(self): + def test_chapter_chunk_default(self): from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread thread = self._make_thread() req = build_conversion_request_from_thread(thread) - assert req.chapter_overrides == [] - assert req.chunks == [] - assert req.chunk_level == "paragraph" - assert req.speaker_mode == "single" - assert req.speakers == {} + assert isinstance(req.chapter_chunk, ChapterChunkConfig) + assert req.chapter_chunk.chapter_overrides == [] + assert req.chapter_chunk.chunks == [] + assert req.chapter_chunk.chunk_level == "paragraph" + assert req.chapter_chunk.speaker_mode == "single" + assert req.chapter_chunk.speakers == {} class TestPyQtEvents: diff --git a/tests/test_conversion_planner.py b/tests/test_conversion_planner.py index 4373190..275875c 100644 --- a/tests/test_conversion_planner.py +++ b/tests/test_conversion_planner.py @@ -25,6 +25,7 @@ from abogen.application.conversion_models import ( OutputLayout, SegmentPlan, ) +from abogen.application.conversion_config import ChapterChunkConfig from abogen.application.conversion_planner import build_conversion_plan from abogen.application.conversion_request import ConversionRequest @@ -74,10 +75,12 @@ class TestBuildConversionPlan: req = ConversionRequest( direct_text="Some text", voice="M1", - chunks=[ - {"text": "Chunk 1", "speaker_id": "narrator"}, - {"text": "Chunk 2", "speaker_id": "narrator"}, - ], + chapter_chunk=ChapterChunkConfig( + chunks=[ + {"text": "Chunk 1", "speaker_id": "narrator"}, + {"text": "Chunk 2", "speaker_id": "narrator"}, + ], + ), ) plan = build_conversion_plan(req) @@ -92,11 +95,13 @@ class TestBuildConversionPlan: req = ConversionRequest( direct_text="Text", voice="M1", - chunks=[ - {"text": "Narrator speaks", "speaker_id": "narrator"}, - {"text": "Character speaks", "speaker_id": "alice", "voice": "F1"}, - ], - speakers={"alice": {"voice": "F1"}}, + chapter_chunk=ChapterChunkConfig( + chunks=[ + {"text": "Narrator speaks", "speaker_id": "narrator"}, + {"text": "Character speaks", "speaker_id": "alice", "voice": "F1"}, + ], + speakers={"alice": {"voice": "F1"}}, + ), ) plan = build_conversion_plan(req) diff --git a/tests/test_conversion_request.py b/tests/test_conversion_request.py index 5244cac..981e02e 100644 --- a/tests/test_conversion_request.py +++ b/tests/test_conversion_request.py @@ -16,6 +16,7 @@ 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.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode from abogen.domain.normalization import TTSContext from abogen.domain.settings_core import settings_defaults @@ -248,12 +249,12 @@ class TestConversionRequestValidation: assert req.chapter_intro_delay == 0.0 def test_invalid_chunk_level_raises(self): - with pytest.raises(ConversionRequestError, match="chunk_level"): - ConversionRequest(chunk_level="invalid") + with pytest.raises(ValueError, match="chunk_level"): + ConversionRequest(chapter_chunk=ChapterChunkConfig(chunk_level="invalid")) def test_invalid_speaker_mode_raises(self): - with pytest.raises(ConversionRequestError, match="speaker_mode"): - ConversionRequest(speaker_mode="invalid") + 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"):