mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
refactor: group ConversionRequest fields into config objects
Domain config types (domain/config_types.py): - PronunciationConfig: pronunciation/heteronym/normalization overrides - SubtitleConfig: mode, format, max_words - CoverConfig: path, mime Domain functions now accept config objects: - build_tts_context(subtitle=, pronunciation=) instead of 9 individual params - make_subtitle_writer(subtitle=) instead of 3 params - process_and_write_subtitles(subtitle=) instead of 2 params - embed_m4b_metadata(cover=) instead of 2 params - build_epub3_package(cover=) instead of 2 params ConversionRequest: 18 flat fields + 8 config objects Application/config.py re-exports domain types All tests updated to new API
This commit is contained in:
@@ -5,13 +5,20 @@ If the object is None, the feature is disabled.
|
||||
|
||||
This keeps ConversionRequest clean: no boolean flags for feature toggles,
|
||||
no scattered parameters across unrelated fields.
|
||||
|
||||
Domain config types (PronunciationConfig, SubtitleConfig) live in
|
||||
domain/config_types.py — domain defines the contract, app fills them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.domain.config_types import CoverConfig, PronunciationConfig, SubtitleConfig
|
||||
from abogen.domain.enums import OutputFormat, SaveMode
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WordSubstitutionConfig:
|
||||
@@ -48,19 +55,6 @@ class Epub3ExportConfig:
|
||||
book_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PronunciationConfig:
|
||||
"""Pronunciation and normalization override settings.
|
||||
|
||||
Groups all pronunciation/heteronym/normalization overrides
|
||||
that are compiled into a TTSContext before conversion.
|
||||
"""
|
||||
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
normalization_overrides: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChapterChunkConfig:
|
||||
"""Chapter and chunk configuration.
|
||||
@@ -85,3 +79,17 @@ class ChapterChunkConfig:
|
||||
raise ValueError(
|
||||
f"speaker_mode must be one of {_VALID_SPEAKER_MODES}, got {self.speaker_mode!r}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SaveConfig:
|
||||
"""Save/output settings.
|
||||
|
||||
Groups save mode, output folder, chapter splitting, and merge options.
|
||||
"""
|
||||
mode: SaveMode = SaveMode.SAVE_NEXT_TO_INPUT
|
||||
output_folder: Optional[Path] = None
|
||||
save_chapters_separately: bool = False
|
||||
merge_chapters_at_end: bool = True
|
||||
separate_chapters_format: OutputFormat = OutputFormat.WAV
|
||||
save_as_project: bool = False
|
||||
|
||||
@@ -193,7 +193,7 @@ def execute_conversion(
|
||||
)
|
||||
|
||||
# Compute subtitle flag once (used in every synthesize_text call)
|
||||
use_spacy = request.subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
|
||||
use_spacy = request.subtitle.mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
|
||||
|
||||
# Output paths
|
||||
output_layout = plan.output_layout
|
||||
@@ -201,7 +201,7 @@ def execute_conversion(
|
||||
raise ValueError("ConversionPlan must have an output_layout")
|
||||
|
||||
# Determine if merged output is needed
|
||||
merge_chapters = request.merge_chapters_at_end or not request.save_chapters_separately
|
||||
merge_chapters = request.save.merge_chapters_at_end or not request.save.save_chapters_separately
|
||||
if request.output_format == OutputFormat.M4B:
|
||||
merge_chapters = True
|
||||
|
||||
@@ -232,19 +232,17 @@ def execute_conversion(
|
||||
|
||||
# Open subtitle writer if needed
|
||||
subtitle_writer: Optional[SubtitleWriter] = None
|
||||
if request.subtitle_mode != SubtitleMode.DISABLED and audio_sink:
|
||||
if request.subtitle.mode != SubtitleMode.DISABLED and audio_sink:
|
||||
subtitle_writer = make_subtitle_writer(
|
||||
audio_path,
|
||||
request.subtitle_format,
|
||||
request.subtitle_mode,
|
||||
max_words=request.max_subtitle_words,
|
||||
request.subtitle,
|
||||
)
|
||||
if subtitle_writer:
|
||||
subtitle_writer.open()
|
||||
stack.callback(subtitle_writer.close)
|
||||
result.subtitle_paths.append(subtitle_writer.path)
|
||||
|
||||
effective_subtitle_mode = request.subtitle_mode if subtitle_writer else SubtitleMode.DISABLED
|
||||
effective_subtitle_mode = request.subtitle.mode if subtitle_writer else SubtitleMode.DISABLED
|
||||
|
||||
synth = SynthParams(
|
||||
tts_context=tts_context,
|
||||
@@ -253,14 +251,14 @@ def execute_conversion(
|
||||
on_progress=lambda pct, etr: events.progress(pct, etr),
|
||||
audio_sink=audio_sink,
|
||||
subtitle_mode=effective_subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
max_subtitle_words=request.subtitle.max_words,
|
||||
language=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
)
|
||||
|
||||
# Chapter directory
|
||||
chapter_dir = None
|
||||
if request.save_chapters_separately and len(plan.chapters) > 1:
|
||||
if request.save.save_chapters_separately and len(plan.chapters) > 1:
|
||||
chapter_dir = output_layout.audio_dir / "chapters"
|
||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -307,11 +305,11 @@ def execute_conversion(
|
||||
chapter_path = None
|
||||
if chapter_dir:
|
||||
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
|
||||
chapter_path = chapter_dir / f"{chapter_filename}.{request.separate_chapters_format}"
|
||||
chapter_path = chapter_dir / f"{chapter_filename}.{request.save.separate_chapters_format}"
|
||||
chapter_sink = stack.enter_context(
|
||||
open_audio_sink(
|
||||
chapter_path,
|
||||
request.separate_chapters_format,
|
||||
request.save.separate_chapters_format,
|
||||
cancel_check=check_cancelled,
|
||||
)
|
||||
)
|
||||
@@ -319,19 +317,17 @@ def execute_conversion(
|
||||
|
||||
# Per-chapter subtitle writer
|
||||
chapter_subtitle_writer: Optional[SubtitleWriter] = None
|
||||
if chapter_dir and request.subtitle_mode != SubtitleMode.DISABLED and chapter_sink:
|
||||
if chapter_dir and request.subtitle.mode != SubtitleMode.DISABLED and chapter_sink:
|
||||
from abogen.infrastructure.subtitle_writer import resolve_subtitle_format
|
||||
|
||||
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
|
||||
subtitle_ext, _ = resolve_subtitle_format(
|
||||
request.subtitle_format, request.subtitle_mode
|
||||
request.subtitle
|
||||
)
|
||||
chapter_subtitle_path = chapter_dir / f"{chapter_filename}.{subtitle_ext}"
|
||||
chapter_subtitle_writer = make_subtitle_writer(
|
||||
chapter_subtitle_path,
|
||||
request.subtitle_format,
|
||||
request.subtitle_mode,
|
||||
max_words=request.max_subtitle_words,
|
||||
request.subtitle,
|
||||
)
|
||||
if chapter_subtitle_writer:
|
||||
chapter_subtitle_writer.open()
|
||||
@@ -439,7 +435,7 @@ def execute_conversion(
|
||||
spacy_segments, active_split = spacy_pre_tts_segmentation(
|
||||
seg_text,
|
||||
request.language,
|
||||
request.subtitle_mode,
|
||||
request.subtitle.mode,
|
||||
is_subtitle_input=is_subtitle_input,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
log_callback=lambda msg: events.log(msg),
|
||||
@@ -468,8 +464,7 @@ def execute_conversion(
|
||||
process_and_write_subtitles(
|
||||
accumulated_tokens,
|
||||
subtitle_writer,
|
||||
subtitle_mode=request.subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
subtitle=request.subtitle,
|
||||
language=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
fallback_end_time=stats.current_time,
|
||||
@@ -478,8 +473,7 @@ def execute_conversion(
|
||||
process_and_write_subtitles(
|
||||
accumulated_tokens,
|
||||
chapter_subtitle_writer,
|
||||
subtitle_mode=request.subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
subtitle=request.subtitle,
|
||||
language=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
fallback_end_time=stats.current_time,
|
||||
|
||||
@@ -16,11 +16,15 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.application.conversion_config import (
|
||||
ChapterChunkConfig,
|
||||
CoverConfig,
|
||||
Epub3ExportConfig,
|
||||
PronunciationConfig,
|
||||
SaveConfig,
|
||||
SubtitleConfig,
|
||||
SubtitleInputConfig,
|
||||
WordSubstitutionConfig,
|
||||
)
|
||||
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
|
||||
from abogen.domain.enums import Language, OutputFormat
|
||||
|
||||
|
||||
class ConversionRequestError(ValueError):
|
||||
@@ -29,7 +33,6 @@ class ConversionRequestError(ValueError):
|
||||
|
||||
# Numeric field constraints: attr -> (min, max)
|
||||
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
|
||||
"max_subtitle_words": (1, 500),
|
||||
"speed": (0.5, 3.0),
|
||||
"supertonic_total_steps": (2, 15),
|
||||
"silence_between_chapters": (0.0, None),
|
||||
@@ -44,11 +47,10 @@ class ConversionRequest:
|
||||
Only contains fields that describe the conversion task itself.
|
||||
UI-only fields (display, logging, user prompts) stay in adapters.
|
||||
|
||||
Feature toggles use config objects or boolean flags:
|
||||
- word_substitution, subtitle_input, chapter_chunk = config objects (None = disabled)
|
||||
- generate_epub3 = boolean flag
|
||||
|
||||
Pronunciation overrides are raw data (lists of dicts), compiled by app layer.
|
||||
Feature toggles use config objects (None = disabled):
|
||||
- word_substitution, subtitle_input, chapter_chunk, epub3_export
|
||||
- pronunciation (raw data, compiled by app layer)
|
||||
- subtitle, save, cover (grouped parameters)
|
||||
|
||||
Validation runs on creation via __post_init__:
|
||||
- None values → replaced with field default (from declaration)
|
||||
@@ -71,17 +73,6 @@ class ConversionRequest:
|
||||
|
||||
# --- Output Format ---
|
||||
output_format: OutputFormat = OutputFormat.WAV
|
||||
subtitle_mode: SubtitleMode = SubtitleMode.DISABLED
|
||||
subtitle_format: SubtitleFormat = SubtitleFormat.SRT
|
||||
max_subtitle_words: int = 50
|
||||
|
||||
# --- Save Options ---
|
||||
save_mode: SaveMode = SaveMode.SAVE_NEXT_TO_INPUT
|
||||
output_folder: Optional[Path] = None
|
||||
save_chapters_separately: bool = False
|
||||
merge_chapters_at_end: bool = True
|
||||
separate_chapters_format: OutputFormat = OutputFormat.WAV
|
||||
save_as_project: bool = False
|
||||
|
||||
# --- Timing ---
|
||||
silence_between_chapters: float = 2.0
|
||||
@@ -97,15 +88,11 @@ class ConversionRequest:
|
||||
# --- Metadata ---
|
||||
metadata_tags: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# --- Pronunciation overrides (raw data, compiled by app layer) ---
|
||||
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
normalization_overrides: Optional[Dict[str, Any]] = None
|
||||
|
||||
# --- Artifacts ---
|
||||
cover_image_path: Optional[Path] = None
|
||||
cover_image_mime: Optional[str] = None
|
||||
# --- Grouped configs ---
|
||||
subtitle: SubtitleConfig = field(default_factory=SubtitleConfig)
|
||||
save: SaveConfig = field(default_factory=SaveConfig)
|
||||
cover: CoverConfig = field(default_factory=CoverConfig)
|
||||
pronunciation: PronunciationConfig = field(default_factory=PronunciationConfig)
|
||||
|
||||
# --- Feature configs (None = disabled) ---
|
||||
epub3_export: Optional[Epub3ExportConfig] = None
|
||||
|
||||
@@ -67,11 +67,8 @@ def run_conversion(
|
||||
usage_counter: Dict[str, int] = defaultdict(int)
|
||||
tts_context = build_tts_context(
|
||||
language=request.language,
|
||||
subtitle_mode=request.subtitle_mode.value if request.subtitle_mode else "Disabled",
|
||||
pronunciation_overrides=request.pronunciation_overrides,
|
||||
manual_overrides=request.manual_overrides,
|
||||
heteronym_overrides=request.heteronym_overrides,
|
||||
normalization_overrides=request.normalization_overrides,
|
||||
subtitle=request.subtitle,
|
||||
pronunciation=request.pronunciation,
|
||||
usage_counter=usage_counter,
|
||||
log_callback=lambda level, msg: events.log(msg, level=level),
|
||||
)
|
||||
@@ -154,15 +151,13 @@ def _finalize(
|
||||
from abogen.infrastructure.exporters import ExportService
|
||||
|
||||
export_svc = ExportService()
|
||||
cover_path = request.cover_image_path if request.cover_image_path and request.cover_image_path.exists() else None
|
||||
|
||||
try:
|
||||
export_svc.embed_m4b_metadata(
|
||||
audio_path=result.audio_path,
|
||||
metadata=result.metadata or {},
|
||||
chapters=result.chapter_markers or [],
|
||||
cover_path=cover_path,
|
||||
cover_mime=request.cover_image_mime,
|
||||
cover=request.cover,
|
||||
log_callback=lambda msg, level="info": events.log(msg, level=level),
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -195,8 +190,7 @@ def _finalize(
|
||||
chunks=request.chapter_chunk.chunks if request.chapter_chunk else [],
|
||||
audio_path=audio_asset,
|
||||
speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else "single",
|
||||
cover_image_path=request.cover_image_path,
|
||||
cover_image_mime=request.cover_image_mime,
|
||||
cover=request.cover,
|
||||
)
|
||||
result.epub_path = epub_path
|
||||
result.artifacts["epub3"] = epub_path
|
||||
|
||||
@@ -39,8 +39,8 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
||||
OutputLayout with resolved paths
|
||||
"""
|
||||
# Determine base output directory
|
||||
if request.save_mode == SaveMode.CUSTOM_FOLDER and request.output_folder:
|
||||
parent_dir = Path(request.output_folder)
|
||||
if request.save.mode == SaveMode.CUSTOM_FOLDER and request.save.output_folder:
|
||||
parent_dir = Path(request.save.output_folder)
|
||||
elif request.source_path:
|
||||
parent_dir = request.source_path.parent
|
||||
else:
|
||||
@@ -66,7 +66,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
||||
subtitle_dir = None
|
||||
metadata_dir = None
|
||||
|
||||
if request.save_as_project:
|
||||
if request.save.save_as_project:
|
||||
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
|
||||
original_filename=request.original_filename,
|
||||
save_as_project=True,
|
||||
@@ -124,7 +124,7 @@ def resolve_chapter_path(
|
||||
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
|
||||
if not slug:
|
||||
slug = f"chapter_{chapter_index}"
|
||||
filename = f"{chapter_index:02d}_{slug}.{request.separate_chapters_format}"
|
||||
filename = f"{chapter_index:02d}_{slug}.{request.save.separate_chapters_format}"
|
||||
return layout.audio_dir / "chapters" / filename
|
||||
|
||||
|
||||
@@ -144,6 +144,6 @@ def should_merge_output(request: ConversionRequest) -> bool:
|
||||
"""
|
||||
if request.output_format == OutputFormat.M4B:
|
||||
return True
|
||||
if not request.save_chapters_separately:
|
||||
if not request.save.save_chapters_separately:
|
||||
return True
|
||||
return request.merge_chapters_at_end
|
||||
return request.save.merge_chapters_at_end
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Domain config types — shared contracts for domain functions.
|
||||
|
||||
These dataclasses group parameters that domain functions receive.
|
||||
Domain defines them, app layer fills them.
|
||||
|
||||
Why here (domain) and not application:
|
||||
- build_tts_context() is in domain → needs PronunciationConfig
|
||||
- make_subtitle_writer() is in infrastructure → needs SubtitleConfig
|
||||
- embed_m4b_metadata() is in infrastructure → needs CoverConfig
|
||||
- Domain should not depend on application layer (DIP)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.domain.enums import SubtitleFormat, SubtitleMode
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PronunciationConfig:
|
||||
"""Pronunciation and normalization override settings.
|
||||
|
||||
Used by build_tts_context() to compile override rules.
|
||||
"""
|
||||
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
||||
normalization_overrides: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubtitleConfig:
|
||||
"""Subtitle output settings.
|
||||
|
||||
Used by make_subtitle_writer() and process_and_write_subtitles().
|
||||
"""
|
||||
mode: SubtitleMode = SubtitleMode.DISABLED
|
||||
format: SubtitleFormat = SubtitleFormat.SRT
|
||||
max_words: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoverConfig:
|
||||
"""Cover image settings.
|
||||
|
||||
Used by embed_m4b_metadata() and build_epub3_package().
|
||||
"""
|
||||
path: Optional[Path] = None
|
||||
mime: Optional[str] = None
|
||||
@@ -151,24 +151,34 @@ def process_and_write_subtitles(
|
||||
accumulated_tokens: list[dict],
|
||||
subtitle_writer: Any,
|
||||
*,
|
||||
subtitle_mode: str,
|
||||
max_subtitle_words: int,
|
||||
subtitle: "SubtitleConfig | str",
|
||||
max_subtitle_words: int | None = None,
|
||||
language: Language,
|
||||
use_spacy_segmentation: bool,
|
||||
fallback_end_time: float,
|
||||
) -> None:
|
||||
"""Process accumulated subtitle tokens and write entries to a subtitle writer.
|
||||
|
||||
This is the standard subtitle post-processing step shared by both UIs.
|
||||
Accepts a SubtitleConfig object or a subtitle mode string
|
||||
for backward compatibility.
|
||||
"""
|
||||
from abogen.domain.config_types import SubtitleConfig
|
||||
|
||||
if isinstance(subtitle, SubtitleConfig):
|
||||
mode_str = subtitle.mode.value
|
||||
words = subtitle.max_words
|
||||
else:
|
||||
mode_str = subtitle
|
||||
words = max_subtitle_words or 50
|
||||
|
||||
if not accumulated_tokens or not subtitle_writer:
|
||||
return
|
||||
new_entries: list[tuple] = []
|
||||
process_subtitle_tokens(
|
||||
accumulated_tokens,
|
||||
new_entries,
|
||||
max_subtitle_words,
|
||||
subtitle_mode,
|
||||
words,
|
||||
mode_str,
|
||||
language,
|
||||
use_spacy_segmentation=use_spacy_segmentation,
|
||||
fallback_end_time=fallback_end_time,
|
||||
|
||||
@@ -129,12 +129,9 @@ def prepare_text_for_tts(
|
||||
def build_tts_context(
|
||||
*,
|
||||
language: Language,
|
||||
subtitle_mode: str = "Disabled",
|
||||
pronunciation_overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
manual_overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
heteronym_overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
subtitle: "SubtitleConfig | str" = "Disabled",
|
||||
pronunciation: Optional["PronunciationConfig"] = None,
|
||||
speakers: Optional[Dict[str, Any]] = None,
|
||||
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
log_callback: Optional[Callable[[str, str], None]] = None,
|
||||
) -> TTSContext:
|
||||
@@ -145,19 +142,17 @@ def build_tts_context(
|
||||
|
||||
Args:
|
||||
language: Language enum value.
|
||||
subtitle_mode: Subtitle mode string.
|
||||
pronunciation_overrides: List of pronunciation override dicts.
|
||||
manual_overrides: List of manual override dicts.
|
||||
heteronym_overrides: List of heteronym override dicts.
|
||||
subtitle: SubtitleConfig object or subtitle mode string.
|
||||
pronunciation: PronunciationConfig with override rules.
|
||||
speakers: Speaker profile mapping.
|
||||
normalization_overrides: Per-job normalization setting overrides.
|
||||
usage_counter: Mutable dict for tracking override usage.
|
||||
log_callback: Callable(level, message) for warnings.
|
||||
|
||||
Returns:
|
||||
TTSContext ready for text normalization.
|
||||
"""
|
||||
from abogen.domain.enums import Language, SubtitleMode
|
||||
from abogen.domain.config_types import PronunciationConfig, SubtitleConfig
|
||||
from abogen.domain.enums import SubtitleMode
|
||||
from abogen.domain.pronunciation import (
|
||||
compile_heteronym_sentence_rules,
|
||||
compile_pronunciation_rules,
|
||||
@@ -169,12 +164,25 @@ def build_tts_context(
|
||||
if log_callback:
|
||||
log_callback(level, msg)
|
||||
|
||||
# Resolve subtitle mode
|
||||
if isinstance(subtitle, SubtitleConfig):
|
||||
resolved_subtitle = subtitle.mode
|
||||
else:
|
||||
try:
|
||||
resolved_subtitle = SubtitleMode.from_str(subtitle) if not isinstance(subtitle, SubtitleMode) else subtitle
|
||||
except ValueError:
|
||||
resolved_subtitle = SubtitleMode.DISABLED
|
||||
|
||||
# Resolve pronunciation config
|
||||
if pronunciation is None:
|
||||
pronunciation = PronunciationConfig()
|
||||
|
||||
# Get runtime normalization settings
|
||||
runtime_settings = get_runtime_settings()
|
||||
|
||||
# Apply per-job normalization overrides
|
||||
if normalization_overrides:
|
||||
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
||||
if pronunciation.normalization_overrides:
|
||||
runtime_settings = _apply_overrides(runtime_settings, pronunciation.normalization_overrides)
|
||||
|
||||
# Build apostrophe config
|
||||
apostrophe_config = build_apostrophe_config(settings=runtime_settings)
|
||||
@@ -202,16 +210,12 @@ def build_tts_context(
|
||||
# Compute split pattern
|
||||
if not isinstance(language, Language):
|
||||
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
|
||||
try:
|
||||
mode = SubtitleMode.from_str(subtitle_mode) if not isinstance(subtitle_mode, SubtitleMode) else subtitle_mode
|
||||
except ValueError:
|
||||
mode = SubtitleMode.DISABLED
|
||||
split_pattern = get_split_pattern(language, mode)
|
||||
split_pattern = get_split_pattern(language, resolved_subtitle)
|
||||
|
||||
# Merge pronunciation overrides (accepts dict or object)
|
||||
# Merge pronunciation overrides
|
||||
source = {
|
||||
"pronunciation_overrides": pronunciation_overrides or [],
|
||||
"manual_overrides": manual_overrides or [],
|
||||
"pronunciation_overrides": pronunciation.pronunciation_overrides,
|
||||
"manual_overrides": pronunciation.manual_overrides,
|
||||
"speakers": speakers or {},
|
||||
"language": language,
|
||||
}
|
||||
@@ -219,7 +223,7 @@ def build_tts_context(
|
||||
|
||||
# Compile rules
|
||||
pronunciation_rules = compile_pronunciation_rules(merged_overrides)
|
||||
heteronym_rules = compile_heteronym_sentence_rules(heteronym_overrides or [])
|
||||
heteronym_rules = compile_heteronym_sentence_rules(pronunciation.heteronym_overrides)
|
||||
|
||||
if heteronym_rules:
|
||||
_log(
|
||||
@@ -236,6 +240,6 @@ def build_tts_context(
|
||||
split_pattern=split_pattern,
|
||||
pronunciation_rules=pronunciation_rules,
|
||||
heteronym_rules=heteronym_rules,
|
||||
normalization_overrides=normalization_overrides,
|
||||
normalization_overrides=pronunciation.normalization_overrides,
|
||||
usage_counter=usage_counter if usage_counter is not None else {},
|
||||
)
|
||||
|
||||
@@ -516,9 +516,14 @@ def build_epub3_package(
|
||||
chunks: Iterable[Dict[str, Any]],
|
||||
audio_path: Path,
|
||||
speaker_mode: str = "single",
|
||||
cover: "CoverConfig | None" = None,
|
||||
cover_image_path: Optional[Path] = None,
|
||||
cover_image_mime: Optional[str] = None,
|
||||
) -> Path:
|
||||
from abogen.domain.config_types import CoverConfig
|
||||
if isinstance(cover, CoverConfig):
|
||||
cover_image_path = cover.path
|
||||
cover_image_mime = cover.mime
|
||||
builder = EPUB3PackageBuilder(
|
||||
output_path=output_path,
|
||||
book_id=book_id,
|
||||
|
||||
@@ -132,11 +132,16 @@ class ExportService:
|
||||
audio_path: Path,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
cover: "CoverConfig | None" = None,
|
||||
cover_path: Optional[Path] = None,
|
||||
cover_mime: Optional[str] = None,
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> None:
|
||||
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
|
||||
from abogen.domain.config_types import CoverConfig
|
||||
if isinstance(cover, CoverConfig):
|
||||
cover_path = cover.path
|
||||
cover_mime = cover.mime
|
||||
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||
|
||||
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
||||
|
||||
@@ -278,24 +278,28 @@ def create_subtitle_writer(
|
||||
|
||||
|
||||
def resolve_subtitle_format(
|
||||
subtitle_format: str | None,
|
||||
subtitle_mode: str,
|
||||
subtitle: "SubtitleConfig | str | None",
|
||||
subtitle_mode: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve a subtitle_format setting string to (file_extension, alignment).
|
||||
"""Resolve a subtitle config to (file_extension, alignment).
|
||||
|
||||
Handles the PyQt convention where format strings encode alignment
|
||||
(e.g. ``"ass_centered_narrow"`` → extension ``"ass"``, alignment
|
||||
``"center_narrow"``).
|
||||
|
||||
Also enforces that ``"Sentence + Highlighting"`` mode requires ASS.
|
||||
Accepts a SubtitleConfig object or individual format/mode strings
|
||||
for backward compatibility.
|
||||
|
||||
Returns:
|
||||
Tuple of (file_extension, alignment) suitable for
|
||||
:func:`create_subtitle_writer`.
|
||||
"""
|
||||
fmt = (subtitle_format or "srt").lower()
|
||||
from abogen.domain.config_types import SubtitleConfig
|
||||
|
||||
if subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
|
||||
if isinstance(subtitle, SubtitleConfig):
|
||||
fmt = subtitle.format.value.lower()
|
||||
mode_str = subtitle.mode.value
|
||||
else:
|
||||
fmt = (subtitle or "srt").lower()
|
||||
mode_str = subtitle_mode or "Disabled"
|
||||
|
||||
if mode_str == "Sentence + Highlighting" and fmt == "srt":
|
||||
fmt = "ass"
|
||||
|
||||
if "ass" in fmt:
|
||||
@@ -317,26 +321,39 @@ def resolve_subtitle_format(
|
||||
|
||||
def make_subtitle_writer(
|
||||
audio_path: Path,
|
||||
subtitle_format: str | None,
|
||||
subtitle_mode: str,
|
||||
max_words: int = 50,
|
||||
subtitle: "SubtitleConfig | str | None",
|
||||
subtitle_mode: str | None = None,
|
||||
max_words: int | None = None,
|
||||
) -> SubtitleWriter | None:
|
||||
"""Convenience: resolve format and create a writer, or return None if disabled.
|
||||
|
||||
Returns ``None`` when ``subtitle_mode`` is ``"Disabled"`` or the
|
||||
Accepts a SubtitleConfig object or individual format/mode strings
|
||||
for backward compatibility.
|
||||
|
||||
Returns ``None`` when subtitle mode is ``"Disabled"`` or the
|
||||
format is unsupported.
|
||||
"""
|
||||
if subtitle_mode == "Disabled":
|
||||
return None
|
||||
from abogen.domain.config_types import SubtitleConfig
|
||||
|
||||
extension, alignment = resolve_subtitle_format(subtitle_format, subtitle_mode)
|
||||
if isinstance(subtitle, SubtitleConfig):
|
||||
mode_str = subtitle.mode.value
|
||||
if mode_str == "Disabled":
|
||||
return None
|
||||
words = subtitle.max_words
|
||||
else:
|
||||
mode_str = subtitle_mode or subtitle or "Disabled"
|
||||
if mode_str == "Disabled":
|
||||
return None
|
||||
words = max_words or 50
|
||||
|
||||
extension, alignment = resolve_subtitle_format(subtitle, subtitle_mode)
|
||||
try:
|
||||
return create_subtitle_writer(
|
||||
audio_path.with_suffix(f".{extension}"),
|
||||
extension,
|
||||
subtitle_mode,
|
||||
mode_str,
|
||||
alignment=alignment,
|
||||
max_words=max_words,
|
||||
max_words=words,
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
@@ -533,14 +533,17 @@ class ConversionThread(QThread):
|
||||
)
|
||||
|
||||
# --- Compile normalization rules (heteronym + pronunciation) ---
|
||||
from abogen.domain.config_types import PronunciationConfig
|
||||
from abogen.domain.normalization import build_tts_context
|
||||
self._tts_context = build_tts_context(
|
||||
language=self.lang_code,
|
||||
subtitle_mode=self.subtitle_mode,
|
||||
pronunciation_overrides=getattr(self, "pronunciation_overrides", None),
|
||||
manual_overrides=getattr(self, "manual_overrides", None),
|
||||
heteronym_overrides=getattr(self, "heteronym_overrides", None),
|
||||
normalization_overrides=getattr(self, "normalization_overrides", None),
|
||||
subtitle=self.subtitle_mode,
|
||||
pronunciation=PronunciationConfig(
|
||||
pronunciation_overrides=getattr(self, "pronunciation_overrides", None) or [],
|
||||
manual_overrides=getattr(self, "manual_overrides", None) or [],
|
||||
heteronym_overrides=getattr(self, "heteronym_overrides", None) or [],
|
||||
normalization_overrides=getattr(self, "normalization_overrides", None),
|
||||
),
|
||||
log_callback=lambda level, msg: self.log_updated.emit((msg, "grey" if level == "info" else "orange")),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ from typing import Any
|
||||
|
||||
from abogen.application.conversion_config import (
|
||||
ChapterChunkConfig,
|
||||
CoverConfig,
|
||||
Epub3ExportConfig,
|
||||
PronunciationConfig,
|
||||
SaveConfig,
|
||||
SubtitleConfig,
|
||||
)
|
||||
from abogen.application.conversion_ports import ConversionCancelled
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
@@ -57,16 +61,6 @@ def _build_request(job: Job) -> ConversionRequest:
|
||||
supertonic_total_steps=job.supertonic_total_steps,
|
||||
# Output Format
|
||||
output_format=_resolve_output_format(job.output_format),
|
||||
subtitle_mode=_resolve_subtitle_mode(job.subtitle_mode),
|
||||
subtitle_format=_resolve_subtitle_format(job.subtitle_format),
|
||||
max_subtitle_words=job.max_subtitle_words,
|
||||
# Save Options
|
||||
save_mode=_resolve_save_mode(job.save_mode),
|
||||
output_folder=job.output_folder,
|
||||
save_chapters_separately=job.save_chapters_separately,
|
||||
merge_chapters_at_end=job.merge_chapters_at_end,
|
||||
separate_chapters_format=_resolve_output_format(job.separate_chapters_format),
|
||||
save_as_project=job.save_as_project,
|
||||
# Timing
|
||||
silence_between_chapters=job.silence_between_chapters,
|
||||
chapter_intro_delay=job.chapter_intro_delay,
|
||||
@@ -78,14 +72,30 @@ def _build_request(job: Job) -> ConversionRequest:
|
||||
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
|
||||
# Metadata
|
||||
metadata_tags=job.metadata_tags or {},
|
||||
# Artifacts
|
||||
cover_image_path=job.cover_image_path,
|
||||
cover_image_mime=job.cover_image_mime,
|
||||
# Pronunciation overrides (raw data)
|
||||
pronunciation_overrides=job.pronunciation_overrides or [],
|
||||
manual_overrides=job.manual_overrides or [],
|
||||
heteronym_overrides=job.heteronym_overrides or [],
|
||||
normalization_overrides=job.normalization_overrides or None,
|
||||
# Grouped configs
|
||||
subtitle=SubtitleConfig(
|
||||
mode=_resolve_subtitle_mode(job.subtitle_mode),
|
||||
format=_resolve_subtitle_format(job.subtitle_format),
|
||||
max_words=job.max_subtitle_words,
|
||||
),
|
||||
save=SaveConfig(
|
||||
mode=_resolve_save_mode(job.save_mode),
|
||||
output_folder=job.output_folder,
|
||||
save_chapters_separately=job.save_chapters_separately,
|
||||
merge_chapters_at_end=job.merge_chapters_at_end,
|
||||
separate_chapters_format=_resolve_output_format(job.separate_chapters_format),
|
||||
save_as_project=job.save_as_project,
|
||||
),
|
||||
cover=CoverConfig(
|
||||
path=job.cover_image_path,
|
||||
mime=job.cover_image_mime,
|
||||
),
|
||||
pronunciation=PronunciationConfig(
|
||||
pronunciation_overrides=job.pronunciation_overrides or [],
|
||||
manual_overrides=job.manual_overrides or [],
|
||||
heteronym_overrides=job.heteronym_overrides or [],
|
||||
normalization_overrides=job.normalization_overrides or None,
|
||||
),
|
||||
# Feature configs
|
||||
epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None,
|
||||
chapter_chunk=ChapterChunkConfig(
|
||||
|
||||
Reference in New Issue
Block a user