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,
|
This keeps ConversionRequest clean: no boolean flags for feature toggles,
|
||||||
no scattered parameters across unrelated fields.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
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)
|
@dataclass(frozen=True)
|
||||||
class WordSubstitutionConfig:
|
class WordSubstitutionConfig:
|
||||||
@@ -48,19 +55,6 @@ class Epub3ExportConfig:
|
|||||||
book_id: str = ""
|
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)
|
@dataclass(frozen=True)
|
||||||
class ChapterChunkConfig:
|
class ChapterChunkConfig:
|
||||||
"""Chapter and chunk configuration.
|
"""Chapter and chunk configuration.
|
||||||
@@ -85,3 +79,17 @@ class ChapterChunkConfig:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"speaker_mode must be one of {_VALID_SPEAKER_MODES}, got {self.speaker_mode!r}"
|
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)
|
# 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 paths
|
||||||
output_layout = plan.output_layout
|
output_layout = plan.output_layout
|
||||||
@@ -201,7 +201,7 @@ def execute_conversion(
|
|||||||
raise ValueError("ConversionPlan must have an output_layout")
|
raise ValueError("ConversionPlan must have an output_layout")
|
||||||
|
|
||||||
# Determine if merged output is needed
|
# 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:
|
if request.output_format == OutputFormat.M4B:
|
||||||
merge_chapters = True
|
merge_chapters = True
|
||||||
|
|
||||||
@@ -232,19 +232,17 @@ def execute_conversion(
|
|||||||
|
|
||||||
# Open subtitle writer if needed
|
# Open subtitle writer if needed
|
||||||
subtitle_writer: Optional[SubtitleWriter] = None
|
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(
|
subtitle_writer = make_subtitle_writer(
|
||||||
audio_path,
|
audio_path,
|
||||||
request.subtitle_format,
|
request.subtitle,
|
||||||
request.subtitle_mode,
|
|
||||||
max_words=request.max_subtitle_words,
|
|
||||||
)
|
)
|
||||||
if subtitle_writer:
|
if subtitle_writer:
|
||||||
subtitle_writer.open()
|
subtitle_writer.open()
|
||||||
stack.callback(subtitle_writer.close)
|
stack.callback(subtitle_writer.close)
|
||||||
result.subtitle_paths.append(subtitle_writer.path)
|
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(
|
synth = SynthParams(
|
||||||
tts_context=tts_context,
|
tts_context=tts_context,
|
||||||
@@ -253,14 +251,14 @@ def execute_conversion(
|
|||||||
on_progress=lambda pct, etr: events.progress(pct, etr),
|
on_progress=lambda pct, etr: events.progress(pct, etr),
|
||||||
audio_sink=audio_sink,
|
audio_sink=audio_sink,
|
||||||
subtitle_mode=effective_subtitle_mode,
|
subtitle_mode=effective_subtitle_mode,
|
||||||
max_subtitle_words=request.max_subtitle_words,
|
max_subtitle_words=request.subtitle.max_words,
|
||||||
language=request.language,
|
language=request.language,
|
||||||
use_spacy_segmentation=use_spacy,
|
use_spacy_segmentation=use_spacy,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Chapter directory
|
# Chapter directory
|
||||||
chapter_dir = None
|
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 = output_layout.audio_dir / "chapters"
|
||||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -307,11 +305,11 @@ def execute_conversion(
|
|||||||
chapter_path = None
|
chapter_path = None
|
||||||
if chapter_dir:
|
if chapter_dir:
|
||||||
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
|
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(
|
chapter_sink = stack.enter_context(
|
||||||
open_audio_sink(
|
open_audio_sink(
|
||||||
chapter_path,
|
chapter_path,
|
||||||
request.separate_chapters_format,
|
request.save.separate_chapters_format,
|
||||||
cancel_check=check_cancelled,
|
cancel_check=check_cancelled,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -319,19 +317,17 @@ def execute_conversion(
|
|||||||
|
|
||||||
# Per-chapter subtitle writer
|
# Per-chapter subtitle writer
|
||||||
chapter_subtitle_writer: Optional[SubtitleWriter] = None
|
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
|
from abogen.infrastructure.subtitle_writer import resolve_subtitle_format
|
||||||
|
|
||||||
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
|
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
|
||||||
subtitle_ext, _ = resolve_subtitle_format(
|
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_path = chapter_dir / f"{chapter_filename}.{subtitle_ext}"
|
||||||
chapter_subtitle_writer = make_subtitle_writer(
|
chapter_subtitle_writer = make_subtitle_writer(
|
||||||
chapter_subtitle_path,
|
chapter_subtitle_path,
|
||||||
request.subtitle_format,
|
request.subtitle,
|
||||||
request.subtitle_mode,
|
|
||||||
max_words=request.max_subtitle_words,
|
|
||||||
)
|
)
|
||||||
if chapter_subtitle_writer:
|
if chapter_subtitle_writer:
|
||||||
chapter_subtitle_writer.open()
|
chapter_subtitle_writer.open()
|
||||||
@@ -439,7 +435,7 @@ def execute_conversion(
|
|||||||
spacy_segments, active_split = spacy_pre_tts_segmentation(
|
spacy_segments, active_split = spacy_pre_tts_segmentation(
|
||||||
seg_text,
|
seg_text,
|
||||||
request.language,
|
request.language,
|
||||||
request.subtitle_mode,
|
request.subtitle.mode,
|
||||||
is_subtitle_input=is_subtitle_input,
|
is_subtitle_input=is_subtitle_input,
|
||||||
use_spacy_segmentation=use_spacy,
|
use_spacy_segmentation=use_spacy,
|
||||||
log_callback=lambda msg: events.log(msg),
|
log_callback=lambda msg: events.log(msg),
|
||||||
@@ -468,8 +464,7 @@ def execute_conversion(
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
accumulated_tokens,
|
accumulated_tokens,
|
||||||
subtitle_writer,
|
subtitle_writer,
|
||||||
subtitle_mode=request.subtitle_mode,
|
subtitle=request.subtitle,
|
||||||
max_subtitle_words=request.max_subtitle_words,
|
|
||||||
language=request.language,
|
language=request.language,
|
||||||
use_spacy_segmentation=use_spacy,
|
use_spacy_segmentation=use_spacy,
|
||||||
fallback_end_time=stats.current_time,
|
fallback_end_time=stats.current_time,
|
||||||
@@ -478,8 +473,7 @@ def execute_conversion(
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
accumulated_tokens,
|
accumulated_tokens,
|
||||||
chapter_subtitle_writer,
|
chapter_subtitle_writer,
|
||||||
subtitle_mode=request.subtitle_mode,
|
subtitle=request.subtitle,
|
||||||
max_subtitle_words=request.max_subtitle_words,
|
|
||||||
language=request.language,
|
language=request.language,
|
||||||
use_spacy_segmentation=use_spacy,
|
use_spacy_segmentation=use_spacy,
|
||||||
fallback_end_time=stats.current_time,
|
fallback_end_time=stats.current_time,
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from abogen.application.conversion_config import (
|
from abogen.application.conversion_config import (
|
||||||
ChapterChunkConfig,
|
ChapterChunkConfig,
|
||||||
|
CoverConfig,
|
||||||
Epub3ExportConfig,
|
Epub3ExportConfig,
|
||||||
|
PronunciationConfig,
|
||||||
|
SaveConfig,
|
||||||
|
SubtitleConfig,
|
||||||
SubtitleInputConfig,
|
SubtitleInputConfig,
|
||||||
WordSubstitutionConfig,
|
WordSubstitutionConfig,
|
||||||
)
|
)
|
||||||
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
|
from abogen.domain.enums import Language, OutputFormat
|
||||||
|
|
||||||
|
|
||||||
class ConversionRequestError(ValueError):
|
class ConversionRequestError(ValueError):
|
||||||
@@ -29,7 +33,6 @@ class ConversionRequestError(ValueError):
|
|||||||
|
|
||||||
# Numeric field constraints: attr -> (min, max)
|
# Numeric field constraints: attr -> (min, max)
|
||||||
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
|
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
|
||||||
"max_subtitle_words": (1, 500),
|
|
||||||
"speed": (0.5, 3.0),
|
"speed": (0.5, 3.0),
|
||||||
"supertonic_total_steps": (2, 15),
|
"supertonic_total_steps": (2, 15),
|
||||||
"silence_between_chapters": (0.0, None),
|
"silence_between_chapters": (0.0, None),
|
||||||
@@ -44,11 +47,10 @@ 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 or boolean flags:
|
Feature toggles use config objects (None = disabled):
|
||||||
- word_substitution, subtitle_input, chapter_chunk = config objects (None = disabled)
|
- word_substitution, subtitle_input, chapter_chunk, epub3_export
|
||||||
- generate_epub3 = boolean flag
|
- pronunciation (raw data, compiled by app layer)
|
||||||
|
- subtitle, save, cover (grouped parameters)
|
||||||
Pronunciation overrides are raw data (lists of dicts), compiled by app layer.
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -71,17 +73,6 @@ class ConversionRequest:
|
|||||||
|
|
||||||
# --- Output Format ---
|
# --- Output Format ---
|
||||||
output_format: OutputFormat = OutputFormat.WAV
|
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 ---
|
# --- Timing ---
|
||||||
silence_between_chapters: float = 2.0
|
silence_between_chapters: float = 2.0
|
||||||
@@ -97,15 +88,11 @@ class ConversionRequest:
|
|||||||
# --- Metadata ---
|
# --- Metadata ---
|
||||||
metadata_tags: Dict[str, Any] = field(default_factory=dict)
|
metadata_tags: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
# --- Pronunciation overrides (raw data, compiled by app layer) ---
|
# --- Grouped configs ---
|
||||||
pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
subtitle: SubtitleConfig = field(default_factory=SubtitleConfig)
|
||||||
manual_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
save: SaveConfig = field(default_factory=SaveConfig)
|
||||||
heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list)
|
cover: CoverConfig = field(default_factory=CoverConfig)
|
||||||
normalization_overrides: Optional[Dict[str, Any]] = None
|
pronunciation: PronunciationConfig = field(default_factory=PronunciationConfig)
|
||||||
|
|
||||||
# --- Artifacts ---
|
|
||||||
cover_image_path: Optional[Path] = None
|
|
||||||
cover_image_mime: Optional[str] = None
|
|
||||||
|
|
||||||
# --- Feature configs (None = disabled) ---
|
# --- Feature configs (None = disabled) ---
|
||||||
epub3_export: Optional[Epub3ExportConfig] = None
|
epub3_export: Optional[Epub3ExportConfig] = None
|
||||||
|
|||||||
@@ -67,11 +67,8 @@ def run_conversion(
|
|||||||
usage_counter: Dict[str, int] = defaultdict(int)
|
usage_counter: Dict[str, int] = defaultdict(int)
|
||||||
tts_context = build_tts_context(
|
tts_context = build_tts_context(
|
||||||
language=request.language,
|
language=request.language,
|
||||||
subtitle_mode=request.subtitle_mode.value if request.subtitle_mode else "Disabled",
|
subtitle=request.subtitle,
|
||||||
pronunciation_overrides=request.pronunciation_overrides,
|
pronunciation=request.pronunciation,
|
||||||
manual_overrides=request.manual_overrides,
|
|
||||||
heteronym_overrides=request.heteronym_overrides,
|
|
||||||
normalization_overrides=request.normalization_overrides,
|
|
||||||
usage_counter=usage_counter,
|
usage_counter=usage_counter,
|
||||||
log_callback=lambda level, msg: events.log(msg, level=level),
|
log_callback=lambda level, msg: events.log(msg, level=level),
|
||||||
)
|
)
|
||||||
@@ -154,15 +151,13 @@ def _finalize(
|
|||||||
from abogen.infrastructure.exporters import ExportService
|
from abogen.infrastructure.exporters import ExportService
|
||||||
|
|
||||||
export_svc = ExportService()
|
export_svc = ExportService()
|
||||||
cover_path = request.cover_image_path if request.cover_image_path and request.cover_image_path.exists() else None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
export_svc.embed_m4b_metadata(
|
export_svc.embed_m4b_metadata(
|
||||||
audio_path=result.audio_path,
|
audio_path=result.audio_path,
|
||||||
metadata=result.metadata or {},
|
metadata=result.metadata or {},
|
||||||
chapters=result.chapter_markers or [],
|
chapters=result.chapter_markers or [],
|
||||||
cover_path=cover_path,
|
cover=request.cover,
|
||||||
cover_mime=request.cover_image_mime,
|
|
||||||
log_callback=lambda msg, level="info": events.log(msg, level=level),
|
log_callback=lambda msg, level="info": events.log(msg, level=level),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -195,8 +190,7 @@ def _finalize(
|
|||||||
chunks=request.chapter_chunk.chunks if request.chapter_chunk else [],
|
chunks=request.chapter_chunk.chunks if request.chapter_chunk else [],
|
||||||
audio_path=audio_asset,
|
audio_path=audio_asset,
|
||||||
speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else "single",
|
speaker_mode=request.chapter_chunk.speaker_mode if request.chapter_chunk else "single",
|
||||||
cover_image_path=request.cover_image_path,
|
cover=request.cover,
|
||||||
cover_image_mime=request.cover_image_mime,
|
|
||||||
)
|
)
|
||||||
result.epub_path = epub_path
|
result.epub_path = epub_path
|
||||||
result.artifacts["epub3"] = epub_path
|
result.artifacts["epub3"] = epub_path
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
|||||||
OutputLayout with resolved paths
|
OutputLayout with resolved paths
|
||||||
"""
|
"""
|
||||||
# Determine base output directory
|
# Determine base output directory
|
||||||
if request.save_mode == SaveMode.CUSTOM_FOLDER and request.output_folder:
|
if request.save.mode == SaveMode.CUSTOM_FOLDER and request.save.output_folder:
|
||||||
parent_dir = Path(request.output_folder)
|
parent_dir = Path(request.save.output_folder)
|
||||||
elif request.source_path:
|
elif request.source_path:
|
||||||
parent_dir = request.source_path.parent
|
parent_dir = request.source_path.parent
|
||||||
else:
|
else:
|
||||||
@@ -66,7 +66,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
|||||||
subtitle_dir = None
|
subtitle_dir = None
|
||||||
metadata_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(
|
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
|
||||||
original_filename=request.original_filename,
|
original_filename=request.original_filename,
|
||||||
save_as_project=True,
|
save_as_project=True,
|
||||||
@@ -124,7 +124,7 @@ def resolve_chapter_path(
|
|||||||
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
|
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
|
||||||
if not slug:
|
if not slug:
|
||||||
slug = f"chapter_{chapter_index}"
|
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
|
return layout.audio_dir / "chapters" / filename
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +144,6 @@ def should_merge_output(request: ConversionRequest) -> bool:
|
|||||||
"""
|
"""
|
||||||
if request.output_format == OutputFormat.M4B:
|
if request.output_format == OutputFormat.M4B:
|
||||||
return True
|
return True
|
||||||
if not request.save_chapters_separately:
|
if not request.save.save_chapters_separately:
|
||||||
return True
|
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],
|
accumulated_tokens: list[dict],
|
||||||
subtitle_writer: Any,
|
subtitle_writer: Any,
|
||||||
*,
|
*,
|
||||||
subtitle_mode: str,
|
subtitle: "SubtitleConfig | str",
|
||||||
max_subtitle_words: int,
|
max_subtitle_words: int | None = None,
|
||||||
language: Language,
|
language: Language,
|
||||||
use_spacy_segmentation: bool,
|
use_spacy_segmentation: bool,
|
||||||
fallback_end_time: float,
|
fallback_end_time: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process accumulated subtitle tokens and write entries to a subtitle writer.
|
"""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:
|
if not accumulated_tokens or not subtitle_writer:
|
||||||
return
|
return
|
||||||
new_entries: list[tuple] = []
|
new_entries: list[tuple] = []
|
||||||
process_subtitle_tokens(
|
process_subtitle_tokens(
|
||||||
accumulated_tokens,
|
accumulated_tokens,
|
||||||
new_entries,
|
new_entries,
|
||||||
max_subtitle_words,
|
words,
|
||||||
subtitle_mode,
|
mode_str,
|
||||||
language,
|
language,
|
||||||
use_spacy_segmentation=use_spacy_segmentation,
|
use_spacy_segmentation=use_spacy_segmentation,
|
||||||
fallback_end_time=fallback_end_time,
|
fallback_end_time=fallback_end_time,
|
||||||
|
|||||||
@@ -129,12 +129,9 @@ def prepare_text_for_tts(
|
|||||||
def build_tts_context(
|
def build_tts_context(
|
||||||
*,
|
*,
|
||||||
language: Language,
|
language: Language,
|
||||||
subtitle_mode: str = "Disabled",
|
subtitle: "SubtitleConfig | str" = "Disabled",
|
||||||
pronunciation_overrides: Optional[List[Dict[str, Any]]] = None,
|
pronunciation: Optional["PronunciationConfig"] = None,
|
||||||
manual_overrides: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
heteronym_overrides: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
speakers: Optional[Dict[str, Any]] = None,
|
speakers: Optional[Dict[str, Any]] = None,
|
||||||
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
|
||||||
usage_counter: Optional[Dict[str, int]] = None,
|
usage_counter: Optional[Dict[str, int]] = None,
|
||||||
log_callback: Optional[Callable[[str, str], None]] = None,
|
log_callback: Optional[Callable[[str, str], None]] = None,
|
||||||
) -> TTSContext:
|
) -> TTSContext:
|
||||||
@@ -145,19 +142,17 @@ def build_tts_context(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
language: Language enum value.
|
language: Language enum value.
|
||||||
subtitle_mode: Subtitle mode string.
|
subtitle: SubtitleConfig object or subtitle mode string.
|
||||||
pronunciation_overrides: List of pronunciation override dicts.
|
pronunciation: PronunciationConfig with override rules.
|
||||||
manual_overrides: List of manual override dicts.
|
|
||||||
heteronym_overrides: List of heteronym override dicts.
|
|
||||||
speakers: Speaker profile mapping.
|
speakers: Speaker profile mapping.
|
||||||
normalization_overrides: Per-job normalization setting overrides.
|
|
||||||
usage_counter: Mutable dict for tracking override usage.
|
usage_counter: Mutable dict for tracking override usage.
|
||||||
log_callback: Callable(level, message) for warnings.
|
log_callback: Callable(level, message) for warnings.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TTSContext ready for text normalization.
|
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 (
|
from abogen.domain.pronunciation import (
|
||||||
compile_heteronym_sentence_rules,
|
compile_heteronym_sentence_rules,
|
||||||
compile_pronunciation_rules,
|
compile_pronunciation_rules,
|
||||||
@@ -169,12 +164,25 @@ def build_tts_context(
|
|||||||
if log_callback:
|
if log_callback:
|
||||||
log_callback(level, msg)
|
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
|
# Get runtime normalization settings
|
||||||
runtime_settings = get_runtime_settings()
|
runtime_settings = get_runtime_settings()
|
||||||
|
|
||||||
# Apply per-job normalization overrides
|
# Apply per-job normalization overrides
|
||||||
if normalization_overrides:
|
if pronunciation.normalization_overrides:
|
||||||
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
runtime_settings = _apply_overrides(runtime_settings, pronunciation.normalization_overrides)
|
||||||
|
|
||||||
# Build apostrophe config
|
# Build apostrophe config
|
||||||
apostrophe_config = build_apostrophe_config(settings=runtime_settings)
|
apostrophe_config = build_apostrophe_config(settings=runtime_settings)
|
||||||
@@ -202,16 +210,12 @@ def build_tts_context(
|
|||||||
# Compute split pattern
|
# Compute split pattern
|
||||||
if not isinstance(language, Language):
|
if not isinstance(language, Language):
|
||||||
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
|
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
|
||||||
try:
|
split_pattern = get_split_pattern(language, resolved_subtitle)
|
||||||
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)
|
|
||||||
|
|
||||||
# Merge pronunciation overrides (accepts dict or object)
|
# Merge pronunciation overrides
|
||||||
source = {
|
source = {
|
||||||
"pronunciation_overrides": pronunciation_overrides or [],
|
"pronunciation_overrides": pronunciation.pronunciation_overrides,
|
||||||
"manual_overrides": manual_overrides or [],
|
"manual_overrides": pronunciation.manual_overrides,
|
||||||
"speakers": speakers or {},
|
"speakers": speakers or {},
|
||||||
"language": language,
|
"language": language,
|
||||||
}
|
}
|
||||||
@@ -219,7 +223,7 @@ def build_tts_context(
|
|||||||
|
|
||||||
# Compile rules
|
# Compile rules
|
||||||
pronunciation_rules = compile_pronunciation_rules(merged_overrides)
|
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:
|
if heteronym_rules:
|
||||||
_log(
|
_log(
|
||||||
@@ -236,6 +240,6 @@ def build_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=normalization_overrides,
|
normalization_overrides=pronunciation.normalization_overrides,
|
||||||
usage_counter=usage_counter if usage_counter is not None else {},
|
usage_counter=usage_counter if usage_counter is not None else {},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -516,9 +516,14 @@ def build_epub3_package(
|
|||||||
chunks: Iterable[Dict[str, Any]],
|
chunks: Iterable[Dict[str, Any]],
|
||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
speaker_mode: str = "single",
|
speaker_mode: str = "single",
|
||||||
|
cover: "CoverConfig | None" = None,
|
||||||
cover_image_path: Optional[Path] = None,
|
cover_image_path: Optional[Path] = None,
|
||||||
cover_image_mime: Optional[str] = None,
|
cover_image_mime: Optional[str] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
from abogen.domain.config_types import CoverConfig
|
||||||
|
if isinstance(cover, CoverConfig):
|
||||||
|
cover_image_path = cover.path
|
||||||
|
cover_image_mime = cover.mime
|
||||||
builder = EPUB3PackageBuilder(
|
builder = EPUB3PackageBuilder(
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
book_id=book_id,
|
book_id=book_id,
|
||||||
|
|||||||
@@ -132,11 +132,16 @@ class ExportService:
|
|||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
metadata: Dict[str, Any],
|
metadata: Dict[str, Any],
|
||||||
chapters: List[Dict[str, Any]],
|
chapters: List[Dict[str, Any]],
|
||||||
|
cover: "CoverConfig | None" = None,
|
||||||
cover_path: Optional[Path] = None,
|
cover_path: Optional[Path] = None,
|
||||||
cover_mime: Optional[str] = None,
|
cover_mime: Optional[str] = None,
|
||||||
log_callback: Optional[callable] = None,
|
log_callback: Optional[callable] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
|
"""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)
|
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||||
|
|
||||||
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
||||||
|
|||||||
@@ -278,24 +278,28 @@ def create_subtitle_writer(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_subtitle_format(
|
def resolve_subtitle_format(
|
||||||
subtitle_format: str | None,
|
subtitle: "SubtitleConfig | str | None",
|
||||||
subtitle_mode: str,
|
subtitle_mode: str | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> 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
|
Accepts a SubtitleConfig object or individual format/mode strings
|
||||||
(e.g. ``"ass_centered_narrow"`` → extension ``"ass"``, alignment
|
for backward compatibility.
|
||||||
``"center_narrow"``).
|
|
||||||
|
|
||||||
Also enforces that ``"Sentence + Highlighting"`` mode requires ASS.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (file_extension, alignment) suitable for
|
Tuple of (file_extension, alignment) suitable for
|
||||||
:func:`create_subtitle_writer`.
|
: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"
|
fmt = "ass"
|
||||||
|
|
||||||
if "ass" in fmt:
|
if "ass" in fmt:
|
||||||
@@ -317,26 +321,39 @@ def resolve_subtitle_format(
|
|||||||
|
|
||||||
def make_subtitle_writer(
|
def make_subtitle_writer(
|
||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
subtitle_format: str | None,
|
subtitle: "SubtitleConfig | str | None",
|
||||||
subtitle_mode: str,
|
subtitle_mode: str | None = None,
|
||||||
max_words: int = 50,
|
max_words: int | None = None,
|
||||||
) -> SubtitleWriter | None:
|
) -> SubtitleWriter | None:
|
||||||
"""Convenience: resolve format and create a writer, or return None if disabled.
|
"""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.
|
format is unsupported.
|
||||||
"""
|
"""
|
||||||
if subtitle_mode == "Disabled":
|
from abogen.domain.config_types import SubtitleConfig
|
||||||
return None
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
return create_subtitle_writer(
|
return create_subtitle_writer(
|
||||||
audio_path.with_suffix(f".{extension}"),
|
audio_path.with_suffix(f".{extension}"),
|
||||||
extension,
|
extension,
|
||||||
subtitle_mode,
|
mode_str,
|
||||||
alignment=alignment,
|
alignment=alignment,
|
||||||
max_words=max_words,
|
max_words=words,
|
||||||
)
|
)
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -533,14 +533,17 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Compile normalization rules (heteronym + pronunciation) ---
|
# --- Compile normalization rules (heteronym + pronunciation) ---
|
||||||
|
from abogen.domain.config_types import PronunciationConfig
|
||||||
from abogen.domain.normalization import build_tts_context
|
from abogen.domain.normalization import build_tts_context
|
||||||
self._tts_context = build_tts_context(
|
self._tts_context = build_tts_context(
|
||||||
language=self.lang_code,
|
language=self.lang_code,
|
||||||
subtitle_mode=self.subtitle_mode,
|
subtitle=self.subtitle_mode,
|
||||||
pronunciation_overrides=getattr(self, "pronunciation_overrides", None),
|
pronunciation=PronunciationConfig(
|
||||||
manual_overrides=getattr(self, "manual_overrides", None),
|
pronunciation_overrides=getattr(self, "pronunciation_overrides", None) or [],
|
||||||
heteronym_overrides=getattr(self, "heteronym_overrides", None),
|
manual_overrides=getattr(self, "manual_overrides", None) or [],
|
||||||
normalization_overrides=getattr(self, "normalization_overrides", None),
|
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")),
|
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 (
|
from abogen.application.conversion_config import (
|
||||||
ChapterChunkConfig,
|
ChapterChunkConfig,
|
||||||
|
CoverConfig,
|
||||||
Epub3ExportConfig,
|
Epub3ExportConfig,
|
||||||
|
PronunciationConfig,
|
||||||
|
SaveConfig,
|
||||||
|
SubtitleConfig,
|
||||||
)
|
)
|
||||||
from abogen.application.conversion_ports import ConversionCancelled
|
from abogen.application.conversion_ports import ConversionCancelled
|
||||||
from abogen.application.conversion_request import ConversionRequest
|
from abogen.application.conversion_request import ConversionRequest
|
||||||
@@ -57,16 +61,6 @@ def _build_request(job: Job) -> ConversionRequest:
|
|||||||
supertonic_total_steps=job.supertonic_total_steps,
|
supertonic_total_steps=job.supertonic_total_steps,
|
||||||
# Output Format
|
# Output Format
|
||||||
output_format=_resolve_output_format(job.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
|
# Timing
|
||||||
silence_between_chapters=job.silence_between_chapters,
|
silence_between_chapters=job.silence_between_chapters,
|
||||||
chapter_intro_delay=job.chapter_intro_delay,
|
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,
|
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
|
||||||
# Metadata
|
# Metadata
|
||||||
metadata_tags=job.metadata_tags or {},
|
metadata_tags=job.metadata_tags or {},
|
||||||
# Artifacts
|
# Grouped configs
|
||||||
cover_image_path=job.cover_image_path,
|
subtitle=SubtitleConfig(
|
||||||
cover_image_mime=job.cover_image_mime,
|
mode=_resolve_subtitle_mode(job.subtitle_mode),
|
||||||
# Pronunciation overrides (raw data)
|
format=_resolve_subtitle_format(job.subtitle_format),
|
||||||
pronunciation_overrides=job.pronunciation_overrides or [],
|
max_words=job.max_subtitle_words,
|
||||||
manual_overrides=job.manual_overrides or [],
|
),
|
||||||
heteronym_overrides=job.heteronym_overrides or [],
|
save=SaveConfig(
|
||||||
normalization_overrides=job.normalization_overrides or None,
|
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
|
# Feature configs
|
||||||
epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None,
|
epub3_export=Epub3ExportConfig(book_id=job.id) if job.generate_epub3 else None,
|
||||||
chapter_chunk=ChapterChunkConfig(
|
chapter_chunk=ChapterChunkConfig(
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ from unittest.mock import MagicMock, patch
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from abogen.application.conversion_config import (
|
||||||
|
CoverConfig,
|
||||||
|
PronunciationConfig,
|
||||||
|
SaveConfig,
|
||||||
|
SubtitleConfig,
|
||||||
|
)
|
||||||
from abogen.application.conversion_request import ConversionRequest
|
from abogen.application.conversion_request import ConversionRequest
|
||||||
from abogen.application.conversion_models import (
|
from abogen.application.conversion_models import (
|
||||||
ChapterPlan,
|
ChapterPlan,
|
||||||
@@ -137,8 +143,7 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello world",
|
direct_text="Hello world",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
|
|
||||||
@@ -156,8 +161,7 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
|
|
||||||
@@ -177,8 +181,7 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
events.cancelled = True
|
events.cancelled = True
|
||||||
@@ -204,8 +207,7 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B",
|
direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
|
|
||||||
@@ -221,8 +223,7 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Body text",
|
direct_text="Body text",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
read_title_intro=True,
|
read_title_intro=True,
|
||||||
read_closing_outro=True,
|
read_closing_outro=True,
|
||||||
metadata_tags={"title": "Test Book", "author": "Author"},
|
metadata_tags={"title": "Test Book", "author": "Author"},
|
||||||
@@ -256,9 +257,10 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
pronunciation=PronunciationConfig(
|
||||||
normalization_overrides={"normalization_numbers": False},
|
normalization_overrides={"normalization_numbers": False},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
|
|
||||||
@@ -273,9 +275,10 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
pronunciation=PronunciationConfig(
|
||||||
normalization_overrides={"normalization_apostrophe_mode": "llm"},
|
normalization_overrides={"normalization_apostrophe_mode": "llm"},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
|
|
||||||
@@ -290,8 +293,7 @@ class TestConversionService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
events = FakeEvents()
|
events = FakeEvents()
|
||||||
|
|
||||||
@@ -314,8 +316,7 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
layout = resolve_output_layout(req)
|
layout = resolve_output_layout(req)
|
||||||
|
|
||||||
@@ -332,7 +333,7 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
source_path=source,
|
source_path=source,
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="save_next_to_input",
|
save=SaveConfig(mode="save_next_to_input"),
|
||||||
)
|
)
|
||||||
layout = resolve_output_layout(req)
|
layout = resolve_output_layout(req)
|
||||||
|
|
||||||
@@ -346,9 +347,11 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(
|
||||||
output_folder=Path(tmpdir),
|
mode="custom_folder",
|
||||||
save_as_project=True,
|
output_folder=Path(tmpdir),
|
||||||
|
save_as_project=True,
|
||||||
|
),
|
||||||
original_filename="test.wav",
|
original_filename="test.wav",
|
||||||
)
|
)
|
||||||
layout = resolve_output_layout(req)
|
layout = resolve_output_layout(req)
|
||||||
@@ -388,7 +391,7 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
separate_chapters_format="wav",
|
save=SaveConfig(separate_chapters_format="wav"),
|
||||||
)
|
)
|
||||||
path = resolve_chapter_path(layout, req, "Chapter 1", 1)
|
path = resolve_chapter_path(layout, req, "Chapter 1", 1)
|
||||||
|
|
||||||
@@ -407,7 +410,7 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
separate_chapters_format="wav",
|
save=SaveConfig(separate_chapters_format="wav"),
|
||||||
)
|
)
|
||||||
path = resolve_chapter_path(layout, req, "", 3)
|
path = resolve_chapter_path(layout, req, "", 3)
|
||||||
|
|
||||||
@@ -421,7 +424,7 @@ class TestOutputLayoutService:
|
|||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
output_format="m4b",
|
output_format="m4b",
|
||||||
merge_chapters_at_end=False,
|
save=SaveConfig(merge_chapters_at_end=False),
|
||||||
)
|
)
|
||||||
assert should_merge_output(req) is True
|
assert should_merge_output(req) is True
|
||||||
|
|
||||||
@@ -432,7 +435,7 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_chapters_separately=False,
|
save=SaveConfig(save_chapters_separately=False),
|
||||||
)
|
)
|
||||||
assert should_merge_output(req) is True
|
assert should_merge_output(req) is True
|
||||||
|
|
||||||
@@ -443,8 +446,10 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_chapters_separately=True,
|
save=SaveConfig(
|
||||||
merge_chapters_at_end=True,
|
save_chapters_separately=True,
|
||||||
|
merge_chapters_at_end=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert should_merge_output(req) is True
|
assert should_merge_output(req) is True
|
||||||
|
|
||||||
@@ -455,8 +460,10 @@ class TestOutputLayoutService:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_chapters_separately=True,
|
save=SaveConfig(
|
||||||
merge_chapters_at_end=False,
|
save_chapters_separately=True,
|
||||||
|
merge_chapters_at_end=False,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert should_merge_output(req) is False
|
assert should_merge_output(req) is False
|
||||||
|
|
||||||
@@ -501,11 +508,13 @@ class TestExecutorGaps:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(
|
||||||
output_folder=Path(tmpdir),
|
mode="custom_folder",
|
||||||
|
output_folder=Path(tmpdir),
|
||||||
|
save_chapters_separately=True,
|
||||||
|
merge_chapters_at_end=False,
|
||||||
|
),
|
||||||
output_format="m4b",
|
output_format="m4b",
|
||||||
save_chapters_separately=True,
|
|
||||||
merge_chapters_at_end=False,
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -546,10 +555,12 @@ class TestExecutorGaps:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(
|
||||||
output_folder=Path(tmpdir),
|
mode="custom_folder",
|
||||||
save_chapters_separately=True,
|
output_folder=Path(tmpdir),
|
||||||
merge_chapters_at_end=True,
|
save_chapters_separately=True,
|
||||||
|
merge_chapters_at_end=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -599,8 +610,7 @@ class TestExecutorGaps:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -657,8 +667,7 @@ class TestExecutorGaps:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -698,8 +707,7 @@ class TestExecutorGaps:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
silence_between_chapters=1.0,
|
silence_between_chapters=1.0,
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from abogen.domain.enums import Language
|
from abogen.domain.config_types import SubtitleConfig
|
||||||
|
from abogen.domain.enums import Language, SubtitleMode
|
||||||
from abogen.domain.conversion_engine import (
|
from abogen.domain.conversion_engine import (
|
||||||
synthesize_text,
|
synthesize_text,
|
||||||
SynthParams,
|
SynthParams,
|
||||||
@@ -253,8 +254,7 @@ class TestProcessAndWriteSubtitles:
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
[],
|
[],
|
||||||
writer,
|
writer,
|
||||||
subtitle_mode="Sentence",
|
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
|
||||||
max_subtitle_words=5,
|
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=10.0,
|
fallback_end_time=10.0,
|
||||||
@@ -270,8 +270,7 @@ class TestProcessAndWriteSubtitles:
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
tokens,
|
tokens,
|
||||||
writer,
|
writer,
|
||||||
subtitle_mode="Sentence",
|
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
|
||||||
max_subtitle_words=5,
|
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=2.0,
|
fallback_end_time=2.0,
|
||||||
@@ -292,8 +291,7 @@ class TestProcessAndWriteSubtitles:
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
tokens,
|
tokens,
|
||||||
writer,
|
writer,
|
||||||
subtitle_mode="Line",
|
subtitle=SubtitleConfig(mode=SubtitleMode.LINE, max_words=5),
|
||||||
max_subtitle_words=5,
|
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=3.0,
|
fallback_end_time=3.0,
|
||||||
@@ -311,8 +309,7 @@ class TestProcessAndWriteSubtitles:
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
tokens,
|
tokens,
|
||||||
writer,
|
writer,
|
||||||
subtitle_mode="Disabled",
|
subtitle=SubtitleConfig(mode=SubtitleMode.DISABLED, max_words=5),
|
||||||
max_subtitle_words=5,
|
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=2.0,
|
fallback_end_time=2.0,
|
||||||
@@ -366,8 +363,7 @@ class TestFullPipeline:
|
|||||||
process_and_write_subtitles(
|
process_and_write_subtitles(
|
||||||
tokens,
|
tokens,
|
||||||
subtitle_writer,
|
subtitle_writer,
|
||||||
subtitle_mode="Sentence",
|
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
|
||||||
max_subtitle_words=5,
|
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=stats.current_time,
|
fallback_end_time=stats.current_time,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from unittest.mock import MagicMock
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from abogen.application.conversion_config import SaveConfig
|
||||||
from abogen.application.conversion_executor import execute_conversion
|
from abogen.application.conversion_executor import execute_conversion
|
||||||
from abogen.application.conversion_models import (
|
from abogen.application.conversion_models import (
|
||||||
ChapterPlan,
|
ChapterPlan,
|
||||||
@@ -150,8 +151,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello world",
|
direct_text="Hello world",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -198,10 +198,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Text",
|
direct_text="Text",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir), save_chapters_separately=True, merge_chapters_at_end=True),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
save_chapters_separately=True,
|
|
||||||
merge_chapters_at_end=True,
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -262,8 +259,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Text",
|
direct_text="Text",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -315,8 +311,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Text",
|
direct_text="Text",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -377,8 +372,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Text",
|
direct_text="Text",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -424,8 +418,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello world",
|
direct_text="Hello world",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
@@ -471,8 +464,7 @@ class TestExecuteConversion:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Text",
|
direct_text="Text",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = ConversionPlan(
|
plan = ConversionPlan(
|
||||||
request=req,
|
request=req,
|
||||||
|
|||||||
@@ -125,12 +125,12 @@ class TestBuildConversionPlan:
|
|||||||
|
|
||||||
def test_output_layout(self):
|
def test_output_layout(self):
|
||||||
"""Output layout is resolved from request."""
|
"""Output layout is resolved from request."""
|
||||||
|
from abogen.application.conversion_config import SaveConfig
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
direct_text="Hello",
|
direct_text="Hello",
|
||||||
voice="M1",
|
voice="M1",
|
||||||
save_mode="custom_folder",
|
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
|
||||||
output_folder=Path(tmpdir),
|
|
||||||
)
|
)
|
||||||
plan = build_conversion_plan(req)
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ 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.application.conversion_config import (
|
||||||
|
ChapterChunkConfig,
|
||||||
|
CoverConfig,
|
||||||
|
PronunciationConfig,
|
||||||
|
SaveConfig,
|
||||||
|
SubtitleConfig,
|
||||||
|
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
|
||||||
@@ -202,23 +209,11 @@ class TestConversionRequestValidation:
|
|||||||
|
|
||||||
def test_defaults_are_valid(self):
|
def test_defaults_are_valid(self):
|
||||||
req = ConversionRequest()
|
req = ConversionRequest()
|
||||||
assert req.max_subtitle_words == 50
|
assert req.subtitle.max_words == 50
|
||||||
assert req.speed == 1.0
|
assert req.speed == 1.0
|
||||||
assert req.supertonic_total_steps == 5
|
assert req.supertonic_total_steps == 5
|
||||||
assert req.output_format == OutputFormat.WAV
|
assert req.output_format == OutputFormat.WAV
|
||||||
assert req.subtitle_mode == SubtitleMode.DISABLED
|
assert req.subtitle.mode == SubtitleMode.DISABLED
|
||||||
|
|
||||||
def test_max_subtitle_words_clamped_below_min(self):
|
|
||||||
req = ConversionRequest(max_subtitle_words=0)
|
|
||||||
assert req.max_subtitle_words == 1
|
|
||||||
|
|
||||||
def test_max_subtitle_words_clamped_above_max(self):
|
|
||||||
req = ConversionRequest(max_subtitle_words=999)
|
|
||||||
assert req.max_subtitle_words == 500
|
|
||||||
|
|
||||||
def test_max_subtitle_words_valid(self):
|
|
||||||
req = ConversionRequest(max_subtitle_words=100)
|
|
||||||
assert req.max_subtitle_words == 100
|
|
||||||
|
|
||||||
def test_speed_clamped_below_min(self):
|
def test_speed_clamped_below_min(self):
|
||||||
req = ConversionRequest(speed=0.1)
|
req = ConversionRequest(speed=0.1)
|
||||||
@@ -256,10 +251,6 @@ class TestConversionRequestValidation:
|
|||||||
with pytest.raises(ValueError, match="speaker_mode"):
|
with pytest.raises(ValueError, match="speaker_mode"):
|
||||||
ConversionRequest(chapter_chunk=ChapterChunkConfig(speaker_mode="invalid"))
|
ConversionRequest(chapter_chunk=ChapterChunkConfig(speaker_mode="invalid"))
|
||||||
|
|
||||||
def test_invalid_max_subtitle_words_type_raises(self):
|
|
||||||
with pytest.raises(ConversionRequestError, match="max_subtitle_words"):
|
|
||||||
ConversionRequest(max_subtitle_words="not_a_number")
|
|
||||||
|
|
||||||
def test_invalid_speed_type_raises(self):
|
def test_invalid_speed_type_raises(self):
|
||||||
with pytest.raises(ConversionRequestError, match="speed"):
|
with pytest.raises(ConversionRequestError, match="speed"):
|
||||||
ConversionRequest(speed="fast")
|
ConversionRequest(speed="fast")
|
||||||
@@ -276,12 +267,27 @@ class TestConversionRequestValidation:
|
|||||||
req = ConversionRequest(
|
req = ConversionRequest(
|
||||||
language=Language.FR,
|
language=Language.FR,
|
||||||
output_format=OutputFormat.MP3,
|
output_format=OutputFormat.MP3,
|
||||||
subtitle_mode=SubtitleMode.SENTENCE,
|
subtitle=SubtitleConfig(
|
||||||
subtitle_format=SubtitleFormat.ASS,
|
mode=SubtitleMode.SENTENCE,
|
||||||
save_mode=SaveMode.CUSTOM_FOLDER,
|
format=SubtitleFormat.ASS,
|
||||||
|
),
|
||||||
|
save=SaveConfig(mode=SaveMode.CUSTOM_FOLDER),
|
||||||
)
|
)
|
||||||
assert req.language == Language.FR
|
assert req.language == Language.FR
|
||||||
assert req.output_format == OutputFormat.MP3
|
assert req.output_format == OutputFormat.MP3
|
||||||
assert req.subtitle_mode == SubtitleMode.SENTENCE
|
assert req.subtitle.mode == SubtitleMode.SENTENCE
|
||||||
assert req.subtitle_format == SubtitleFormat.ASS
|
assert req.subtitle.format == SubtitleFormat.ASS
|
||||||
assert req.save_mode == SaveMode.CUSTOM_FOLDER
|
assert req.save.mode == SaveMode.CUSTOM_FOLDER
|
||||||
|
|
||||||
|
def test_config_objects_constructed(self):
|
||||||
|
req = ConversionRequest(
|
||||||
|
subtitle=SubtitleConfig(max_words=100),
|
||||||
|
cover=CoverConfig(path=Path("/tmp/cover.jpg"), mime="image/jpeg"),
|
||||||
|
pronunciation=PronunciationConfig(normalization_overrides={"key": "val"}),
|
||||||
|
save=SaveConfig(save_as_project=True),
|
||||||
|
)
|
||||||
|
assert req.subtitle.max_words == 100
|
||||||
|
assert req.cover.path == Path("/tmp/cover.jpg")
|
||||||
|
assert req.cover.mime == "image/jpeg"
|
||||||
|
assert req.pronunciation.normalization_overrides == {"key": "val"}
|
||||||
|
assert req.save.save_as_project is True
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from abogen.domain.enums import Language
|
from abogen.domain.config_types import PronunciationConfig, SubtitleConfig
|
||||||
|
from abogen.domain.enums import Language, SubtitleMode
|
||||||
from abogen.domain.normalization import prepare_text_for_tts, normalize_text_for_pipeline, build_tts_context, TTSContext
|
from abogen.domain.normalization import prepare_text_for_tts, normalize_text_for_pipeline, build_tts_context, TTSContext
|
||||||
|
|
||||||
|
|
||||||
@@ -156,16 +157,16 @@ class TestBuildTtsContext:
|
|||||||
assert isinstance(ctx, TTSContext)
|
assert isinstance(ctx, TTSContext)
|
||||||
|
|
||||||
def test_default_split_pattern(self):
|
def test_default_split_pattern(self):
|
||||||
ctx = build_tts_context(language=Language.EN_US, subtitle_mode="Disabled")
|
ctx = build_tts_context(language=Language.EN_US, subtitle="Disabled")
|
||||||
assert isinstance(ctx.split_pattern, str)
|
assert isinstance(ctx.split_pattern, str)
|
||||||
assert len(ctx.split_pattern) > 0
|
assert len(ctx.split_pattern) > 0
|
||||||
|
|
||||||
def test_english_uses_newline_split(self):
|
def test_english_uses_newline_split(self):
|
||||||
ctx = build_tts_context(language=Language.EN_US, subtitle_mode="Disabled")
|
ctx = build_tts_context(language=Language.EN_US, subtitle="Disabled")
|
||||||
assert ctx.split_pattern == "\n"
|
assert ctx.split_pattern == "\n"
|
||||||
|
|
||||||
def test_cjk_uses_punctuation_split(self):
|
def test_cjk_uses_punctuation_split(self):
|
||||||
ctx = build_tts_context(language=Language.JA, subtitle_mode="Disabled")
|
ctx = build_tts_context(language=Language.JA, subtitle="Disabled")
|
||||||
assert r"\n" in ctx.split_pattern
|
assert r"\n" in ctx.split_pattern
|
||||||
|
|
||||||
def test_pronunciation_overrides_compiled(self):
|
def test_pronunciation_overrides_compiled(self):
|
||||||
@@ -178,7 +179,7 @@ class TestBuildTtsContext:
|
|||||||
]
|
]
|
||||||
ctx = build_tts_context(
|
ctx = build_tts_context(
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
pronunciation_overrides=overrides,
|
pronunciation=PronunciationConfig(pronunciation_overrides=overrides),
|
||||||
)
|
)
|
||||||
assert ctx.pronunciation_rules is not None
|
assert ctx.pronunciation_rules is not None
|
||||||
assert len(ctx.pronunciation_rules) >= 1
|
assert len(ctx.pronunciation_rules) >= 1
|
||||||
@@ -193,7 +194,7 @@ class TestBuildTtsContext:
|
|||||||
]
|
]
|
||||||
ctx = build_tts_context(
|
ctx = build_tts_context(
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
manual_overrides=overrides,
|
pronunciation=PronunciationConfig(manual_overrides=overrides),
|
||||||
)
|
)
|
||||||
assert ctx.pronunciation_rules is not None
|
assert ctx.pronunciation_rules is not None
|
||||||
assert len(ctx.pronunciation_rules) >= 1
|
assert len(ctx.pronunciation_rules) >= 1
|
||||||
@@ -207,8 +208,10 @@ class TestBuildTtsContext:
|
|||||||
]
|
]
|
||||||
ctx = build_tts_context(
|
ctx = build_tts_context(
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
pronunciation_overrides=pronunciation,
|
pronunciation=PronunciationConfig(
|
||||||
manual_overrides=manual,
|
pronunciation_overrides=pronunciation,
|
||||||
|
manual_overrides=manual,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
found_right = any(
|
found_right = any(
|
||||||
r.get("replacement") == "RIGHT" for r in ctx.pronunciation_rules
|
r.get("replacement") == "RIGHT" for r in ctx.pronunciation_rules
|
||||||
@@ -229,7 +232,7 @@ class TestBuildTtsContext:
|
|||||||
]
|
]
|
||||||
ctx = build_tts_context(
|
ctx = build_tts_context(
|
||||||
language=Language.EN_US,
|
language=Language.EN_US,
|
||||||
heteronym_overrides=overrides,
|
pronunciation=PronunciationConfig(heteronym_overrides=overrides),
|
||||||
)
|
)
|
||||||
assert ctx.heteronym_rules is not None
|
assert ctx.heteronym_rules is not None
|
||||||
|
|
||||||
@@ -244,7 +247,10 @@ class TestBuildTtsContext:
|
|||||||
|
|
||||||
def test_normalization_overrides_stored(self):
|
def test_normalization_overrides_stored(self):
|
||||||
overrides = {"normalization_numbers": False}
|
overrides = {"normalization_numbers": False}
|
||||||
ctx = build_tts_context(language=Language.EN_US, normalization_overrides=overrides)
|
ctx = build_tts_context(
|
||||||
|
language=Language.EN_US,
|
||||||
|
pronunciation=PronunciationConfig(normalization_overrides=overrides),
|
||||||
|
)
|
||||||
assert ctx.normalization_overrides is overrides
|
assert ctx.normalization_overrides is overrides
|
||||||
|
|
||||||
def test_speakers_used_for_pronunciation(self):
|
def test_speakers_used_for_pronunciation(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user