refactor: config objects for feature toggles in ConversionRequest

This commit is contained in:
Artem Akymenko
2026-07-24 19:17:36 +03:00
parent 7d28b7eb52
commit 0ee5bb0496
10 changed files with 247 additions and 106 deletions
+87
View File
@@ -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}"
)
+1 -1
View File
@@ -302,7 +302,7 @@ def execute_conversion(
"end": stats.current_time, "end": stats.current_time,
"speaker_id": segment.speaker_id, "speaker_id": segment.speaker_id,
"voice": segment.voice_spec, "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), "characters": len(segment.text),
}) })
+10 -7
View File
@@ -144,8 +144,9 @@ def _apply_selection(
] ]
# If user specified chapters, apply overrides # If user specified chapters, apply overrides
if request.chapter_overrides: chapter_chunk = request.chapter_chunk
selected, _, diagnostics = apply_chapter_overrides(extracted, request.chapter_overrides) if chapter_chunk and chapter_chunk.chapter_overrides:
selected, _, diagnostics = apply_chapter_overrides(extracted, chapter_chunk.chapter_overrides)
if selected: if selected:
# Map back to (title, text, voice) tuples # Map back to (title, text, voice) tuples
result = [] result = []
@@ -216,9 +217,10 @@ def _build_segments(
segments = [] segments = []
# Check for chunks (WebUI style) # 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) # 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", "") chunk_text = chunk.get("normalized_text") or chunk.get("text", "")
if not chunk_text or not chunk_text.strip(): if not chunk_text or not chunk_text.strip():
continue continue
@@ -234,7 +236,7 @@ def _build_segments(
speaker_id=speaker_id, speaker_id=speaker_id,
chunk_id=chunk.get("id"), chunk_id=chunk.get("id"),
chunk_index=chunk.get("chunk_index", chunk_idx), 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", source="chunk",
) )
) )
@@ -284,8 +286,9 @@ def _resolve_chunk_voice(
"""Resolve voice for a chunk.""" """Resolve voice for a chunk."""
# Check for speaker-based voice # Check for speaker-based voice
speaker_id = chunk.get("speaker_id", "narrator") speaker_id = chunk.get("speaker_id", "narrator")
if speaker_id and speaker_id != "narrator" and request.speakers: speakers = request.chapter_chunk.speakers if request.chapter_chunk else {}
speaker_config = request.speakers.get(speaker_id, {}) if speaker_id and speaker_id != "narrator" and speakers:
speaker_config = speakers.get(speaker_id, {})
if isinstance(speaker_config, dict): if isinstance(speaker_config, dict):
voice = speaker_config.get("voice") voice = speaker_config.get("voice")
if voice: if voice:
+17 -32
View File
@@ -14,6 +14,13 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional 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 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), "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 @dataclass
class ConversionRequest: class ConversionRequest:
@@ -44,10 +45,12 @@ class ConversionRequest:
Only contains fields that describe the conversion task itself. Only contains fields that describe the conversion task itself.
UI-only fields (display, logging, user prompts) stay in adapters. 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__: Validation runs on creation via __post_init__:
- None values → replaced with field default (from declaration) - None values → replaced with field default (from declaration)
- Numeric fields → clamped to valid range - Numeric fields → clamped to valid range
- String enums → validated against allowed set
""" """
# --- Source --- # --- Source ---
@@ -89,26 +92,19 @@ class ConversionRequest:
auto_prefix_chapter_titles: bool = True auto_prefix_chapter_titles: bool = True
normalize_chapter_opening_caps: bool = False 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 ---
metadata_tags: Dict[str, Any] = field(default_factory=dict) metadata_tags: Dict[str, Any] = field(default_factory=dict)
# --- Artifacts --- # --- Artifacts ---
cover_image_path: Optional[Path] = None cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = 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: def __post_init__(self) -> None:
"""Resolve None → default, then validate and clamp.""" """Resolve None → default, then validate and clamp."""
@@ -116,7 +112,6 @@ class ConversionRequest:
if not self.tts_provider: if not self.tts_provider:
self.tts_provider = "kokoro" self.tts_provider = "kokoro"
_clamp_numerics(self) _clamp_numerics(self)
_validate_enums(self)
def _apply_none_defaults(obj: ConversionRequest) -> None: def _apply_none_defaults(obj: ConversionRequest) -> None:
@@ -144,13 +139,3 @@ def _clamp_numerics(obj: ConversionRequest) -> None:
if max_v is not None: if max_v is not None:
clamped = min(max_v, clamped) clamped = min(max_v, clamped)
setattr(obj, attr, 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}"
)
+11 -8
View File
@@ -141,17 +141,20 @@ def _prepare_tts_context(
# Merge pronunciation overrides (manual + pronunciation) # Merge pronunciation overrides (manual + pronunciation)
# Create a mock job-like object for merge_pronunciation_overrides # Create a mock job-like object for merge_pronunciation_overrides
class _MockJob: pronunciation = request.pronunciation
def __init__(self, req):
self.pronunciation_overrides = req.pronunciation_overrides
self.manual_overrides = req.manual_overrides
self.heteronym_overrides = req.heteronym_overrides
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 # Compile rules
pronunciation_rules = compile_pronunciation_rules(merged_overrides) 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: if heteronym_rules:
events.log( events.log(
@@ -168,5 +171,5 @@ def _prepare_tts_context(
split_pattern=split_pattern, split_pattern=split_pattern,
pronunciation_rules=pronunciation_rules, pronunciation_rules=pronunciation_rules,
heteronym_rules=heteronym_rules, heteronym_rules=heteronym_rules,
normalization_overrides=request.normalization_overrides, normalization_overrides=pronunciation.normalization_overrides if pronunciation else None,
) )
+29 -12
View File
@@ -17,6 +17,12 @@ import os
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional 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_request import ConversionRequest
from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice 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: if thread.output_folder:
output_folder = Path(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( return ConversionRequest(
# Source # Source
source_path=source_path, 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), read_closing_outro=getattr(thread, "read_closing_outro", True),
auto_prefix_chapter_titles=getattr(thread, "auto_prefix_chapter_titles", True), auto_prefix_chapter_titles=getattr(thread, "auto_prefix_chapter_titles", True),
normalize_chapter_opening_caps=getattr(thread, "normalize_chapter_opening_caps", False), 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
metadata_tags=getattr(thread, "metadata_tags", {}) or {}, metadata_tags=getattr(thread, "metadata_tags", {}) or {},
# Artifacts # Artifacts
cover_image_path=getattr(thread, "cover_image_path", None), cover_image_path=getattr(thread, "cover_image_path", None),
cover_image_mime=getattr(thread, "cover_image_mime", 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
) )
+52 -12
View File
@@ -17,6 +17,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional 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_request import ConversionRequest
from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice
@@ -33,6 +39,47 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest:
Returns: Returns:
ConversionRequest with all Job data mapped 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( return ConversionRequest(
# Source # Source
source_path=Path(job.stored_path) if job.stored_path else None, 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, read_closing_outro=job.read_closing_outro,
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps, 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
metadata_tags=job.metadata_tags or {}, metadata_tags=job.metadata_tags or {},
# Artifacts # Artifacts
cover_image_path=Path(job.cover_image_path) if job.cover_image_path else None, cover_image_path=Path(job.cover_image_path) if job.cover_image_path else None,
cover_image_mime=job.cover_image_mime, 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,
) )
+21 -21
View File
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_request import ConversionRequest
from abogen.application.conversion_config import ChapterChunkConfig, PronunciationConfig
from abogen.application.conversion_ports import ResolvedVoice from abogen.application.conversion_ports import ResolvedVoice
@@ -112,25 +113,22 @@ class TestWebUIAdapter:
assert req.output_folder is None assert req.output_folder is None
assert req.cover_image_path 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 from abogen.webui.conversion_adapter import build_conversion_request_from_job
chapters = [{"title": "Ch1", "voice": "F1"}] 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"}] 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) 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 from abogen.webui.conversion_adapter import build_conversion_request_from_job
job = self._make_job( job = self._make_job(
@@ -140,9 +138,10 @@ class TestWebUIAdapter:
) )
req = build_conversion_request_from_job(job) req = build_conversion_request_from_job(job)
assert req.pronunciation_overrides == ["word=pron"] assert isinstance(req.pronunciation, PronunciationConfig)
assert req.manual_overrides == ["manual=override"] assert req.pronunciation.pronunciation_overrides == ["word=pron"]
assert req.heteronym_overrides == ["read=reed"] assert req.pronunciation.manual_overrides == ["manual=override"]
assert req.pronunciation.heteronym_overrides == ["read=reed"]
def test_none_defaults_handled(self): def test_none_defaults_handled(self):
from abogen.webui.conversion_adapter import build_conversion_request_from_job 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.supertonic_total_steps == 5
assert req.max_subtitle_words == 50 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 from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
thread = self._make_thread() thread = self._make_thread()
req = build_conversion_request_from_thread(thread) req = build_conversion_request_from_thread(thread)
assert req.chapter_overrides == [] assert isinstance(req.chapter_chunk, ChapterChunkConfig)
assert req.chunks == [] assert req.chapter_chunk.chapter_overrides == []
assert req.chunk_level == "paragraph" assert req.chapter_chunk.chunks == []
assert req.speaker_mode == "single" assert req.chapter_chunk.chunk_level == "paragraph"
assert req.speakers == {} assert req.chapter_chunk.speaker_mode == "single"
assert req.chapter_chunk.speakers == {}
class TestPyQtEvents: class TestPyQtEvents:
+14 -9
View File
@@ -25,6 +25,7 @@ from abogen.application.conversion_models import (
OutputLayout, OutputLayout,
SegmentPlan, SegmentPlan,
) )
from abogen.application.conversion_config import ChapterChunkConfig
from abogen.application.conversion_planner import build_conversion_plan from abogen.application.conversion_planner import build_conversion_plan
from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_request import ConversionRequest
@@ -74,10 +75,12 @@ class TestBuildConversionPlan:
req = ConversionRequest( req = ConversionRequest(
direct_text="Some text", direct_text="Some text",
voice="M1", voice="M1",
chunks=[ chapter_chunk=ChapterChunkConfig(
{"text": "Chunk 1", "speaker_id": "narrator"}, chunks=[
{"text": "Chunk 2", "speaker_id": "narrator"}, {"text": "Chunk 1", "speaker_id": "narrator"},
], {"text": "Chunk 2", "speaker_id": "narrator"},
],
),
) )
plan = build_conversion_plan(req) plan = build_conversion_plan(req)
@@ -92,11 +95,13 @@ class TestBuildConversionPlan:
req = ConversionRequest( req = ConversionRequest(
direct_text="Text", direct_text="Text",
voice="M1", voice="M1",
chunks=[ chapter_chunk=ChapterChunkConfig(
{"text": "Narrator speaks", "speaker_id": "narrator"}, chunks=[
{"text": "Character speaks", "speaker_id": "alice", "voice": "F1"}, {"text": "Narrator speaks", "speaker_id": "narrator"},
], {"text": "Character speaks", "speaker_id": "alice", "voice": "F1"},
speakers={"alice": {"voice": "F1"}}, ],
speakers={"alice": {"voice": "F1"}},
),
) )
plan = build_conversion_plan(req) plan = build_conversion_plan(req)
+5 -4
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from abogen.application.conversion_request import ConversionRequest, ConversionRequestError 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.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.settings_core import settings_defaults from abogen.domain.settings_core import settings_defaults
@@ -248,12 +249,12 @@ class TestConversionRequestValidation:
assert req.chapter_intro_delay == 0.0 assert req.chapter_intro_delay == 0.0
def test_invalid_chunk_level_raises(self): def test_invalid_chunk_level_raises(self):
with pytest.raises(ConversionRequestError, match="chunk_level"): with pytest.raises(ValueError, match="chunk_level"):
ConversionRequest(chunk_level="invalid") ConversionRequest(chapter_chunk=ChapterChunkConfig(chunk_level="invalid"))
def test_invalid_speaker_mode_raises(self): def test_invalid_speaker_mode_raises(self):
with pytest.raises(ConversionRequestError, match="speaker_mode"): with pytest.raises(ValueError, match="speaker_mode"):
ConversionRequest(speaker_mode="invalid") ConversionRequest(chapter_chunk=ChapterChunkConfig(speaker_mode="invalid"))
def test_invalid_max_subtitle_words_type_raises(self): def test_invalid_max_subtitle_words_type_raises(self):
with pytest.raises(ConversionRequestError, match="max_subtitle_words"): with pytest.raises(ConversionRequestError, match="max_subtitle_words"):