mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
refactor: config objects for feature toggles in ConversionRequest
This commit is contained in:
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
chapter_chunk=ChapterChunkConfig(
|
||||||
chunks=[
|
chunks=[
|
||||||
{"text": "Chunk 1", "speaker_id": "narrator"},
|
{"text": "Chunk 1", "speaker_id": "narrator"},
|
||||||
{"text": "Chunk 2", "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",
|
||||||
|
chapter_chunk=ChapterChunkConfig(
|
||||||
chunks=[
|
chunks=[
|
||||||
{"text": "Narrator speaks", "speaker_id": "narrator"},
|
{"text": "Narrator speaks", "speaker_id": "narrator"},
|
||||||
{"text": "Character speaks", "speaker_id": "alice", "voice": "F1"},
|
{"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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
Reference in New Issue
Block a user