refactor: typed Enums for format/mode fields

- SubtitleMode, OutputFormat, SaveMode, SubtitleFormat, InputFormat
- Properties: dot_ext, is_lossless, is_book, is_subtitle
- from_str/from_path class methods with normalization
- Updated domain and application layers to use Enums
- 17 new tests for enum validation and properties
This commit is contained in:
Artem Akymenko
2026-07-22 09:01:50 +00:00
parent dc5257252f
commit f6a8008f51
11 changed files with 268 additions and 31 deletions
+5 -4
View File
@@ -33,6 +33,7 @@ from abogen.domain.conversion_engine import (
process_and_write_subtitles, process_and_write_subtitles,
synthesize_text, synthesize_text,
) )
from abogen.domain.enums import OutputFormat, SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.output_paths import sanitize_filename_for_chapter from abogen.domain.output_paths import sanitize_filename_for_chapter
from abogen.infrastructure.subtitle_writer import make_subtitle_writer from abogen.infrastructure.subtitle_writer import make_subtitle_writer
@@ -87,7 +88,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 ("Disabled", "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
@@ -96,7 +97,7 @@ def execute_conversion(
# 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.merge_chapters_at_end or not request.save_chapters_separately
if request.output_format.lower() == "m4b": if request.output_format == OutputFormat.M4B:
merge_chapters = True merge_chapters = True
# Resolve voices # Resolve voices
@@ -125,7 +126,7 @@ 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 != "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_format,
@@ -137,7 +138,7 @@ def execute_conversion(
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 "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,
+7 -5
View File
@@ -13,6 +13,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from abogen.domain.enums import OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
@dataclass @dataclass
class ConversionRequest: class ConversionRequest:
@@ -37,17 +39,17 @@ class ConversionRequest:
supertonic_total_steps: int = 5 supertonic_total_steps: int = 5
# --- Output Format --- # --- Output Format ---
output_format: str = "wav" output_format: OutputFormat = OutputFormat.WAV
subtitle_mode: str = "Disabled" subtitle_mode: SubtitleMode = SubtitleMode.DISABLED
subtitle_format: str = "srt" subtitle_format: SubtitleFormat = SubtitleFormat.SRT
max_subtitle_words: int = 50 max_subtitle_words: int = 50
# --- Save Options --- # --- Save Options ---
save_mode: str = "save_next_to_input" save_mode: SaveMode = SaveMode.SAVE_NEXT_TO_INPUT
output_folder: Optional[Path] = None output_folder: Optional[Path] = None
save_chapters_separately: bool = False save_chapters_separately: bool = False
merge_chapters_at_end: bool = True merge_chapters_at_end: bool = True
separate_chapters_format: str = "wav" separate_chapters_format: OutputFormat = OutputFormat.WAV
save_as_project: bool = False save_as_project: bool = False
# --- Timing --- # --- Timing ---
+2 -1
View File
@@ -27,6 +27,7 @@ from abogen.application.conversion_ports import (
) )
from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_request import ConversionRequest
from abogen.application.conversion_result import ConversionResult from abogen.application.conversion_result import ConversionResult
from abogen.domain.enums import SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
@@ -135,7 +136,7 @@ def _prepare_tts_context(
# Compute split pattern # Compute split pattern
split_pattern = get_split_pattern( split_pattern = get_split_pattern(
str(request.language or "a"), str(request.language or "a"),
str(request.subtitle_mode or "Disabled"), request.subtitle_mode or SubtitleMode.DISABLED,
) )
# Merge pronunciation overrides (manual + pronunciation) # Merge pronunciation overrides (manual + pronunciation)
+4 -3
View File
@@ -19,6 +19,7 @@ from typing import Optional
from abogen.application.conversion_models import OutputLayout from abogen.application.conversion_models import OutputLayout
from abogen.application.conversion_request import ConversionRequest from abogen.application.conversion_request import ConversionRequest
from abogen.domain.enums import OutputFormat, SaveMode, SubtitleFormat
from abogen.domain.output_paths import ( from abogen.domain.output_paths import (
resolve_project_layout, resolve_project_layout,
resolve_unique_path, resolve_unique_path,
@@ -39,7 +40,7 @@ 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 == "custom_folder" and request.output_folder: if request.save_mode == SaveMode.CUSTOM_FOLDER and request.output_folder:
parent_dir = Path(request.output_folder) parent_dir = Path(request.output_folder)
elif request.source_path: elif request.source_path:
parent_dir = request.source_path.parent parent_dir = request.source_path.parent
@@ -55,7 +56,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
base_name = "output" base_name = "output"
# Find unique output path # Find unique output path
allowed_exts = {request.output_format, "srt", "ass", "vtt", "mp4", "m4b"} allowed_exts = {request.output_format, SubtitleFormat.SRT, SubtitleFormat.ASS, "vtt", "mp4", OutputFormat.M4B}
unique_base = resolve_unique_path( unique_base = resolve_unique_path(
parent_dir, base_name, "", allowed_extensions=allowed_exts parent_dir, base_name, "", allowed_extensions=allowed_exts
) )
@@ -142,7 +143,7 @@ def should_merge_output(request: ConversionRequest) -> bool:
Returns: Returns:
True if merged output should be created True if merged output should be created
""" """
if request.output_format.lower() == "m4b": if request.output_format == OutputFormat.M4B:
return True return True
if not request.save_chapters_separately: if not request.save_chapters_separately:
return True return True
+2 -1
View File
@@ -23,6 +23,7 @@ from typing import Any, Callable, List, Optional, Protocol
from abogen.domain.audio_sink import AudioSink from abogen.domain.audio_sink import AudioSink
from abogen.domain.conversion_pipeline import tts_segments from abogen.domain.conversion_pipeline import tts_segments
from abogen.domain.enums import SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.progress import calc_etr_str from abogen.domain.progress import calc_etr_str
from abogen.domain.subtitle_generation import process_subtitle_tokens from abogen.domain.subtitle_generation import process_subtitle_tokens
@@ -136,7 +137,7 @@ def run_tts_segment_loop(
params.audio_sink.write(seg.audio) params.audio_sink.write(seg.audio)
# Accumulate subtitle tokens (default path; skipped if on_segment handles it) # Accumulate subtitle tokens (default path; skipped if on_segment handles it)
if not on_segment and params.subtitle_mode != "Disabled" and seg.tokens: if not on_segment and params.subtitle_mode != SubtitleMode.DISABLED and seg.tokens:
accumulated_tokens.extend(seg.tokens) accumulated_tokens.extend(seg.tokens)
# Update timing # Update timing
+3 -1
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from abogen.domain.enums import SubtitleMode
from typing import Any, Callable, Dict, Iterator, List, Optional from typing import Any, Callable, Dict, Iterator, List, Optional
import numpy as np import numpy as np
@@ -221,7 +223,7 @@ def emit_text_to_sinks(
# Flush subtitle tokens # Flush subtitle tokens
if subtitle_writer and accumulated_tokens: if subtitle_writer and accumulated_tokens:
_use_spacy = subtitle_mode not in ("Disabled", "Line") _use_spacy = subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
new_entries: List[tuple] = [] new_entries: List[tuple] = []
process_subtitle_tokens( process_subtitle_tokens(
accumulated_tokens, accumulated_tokens,
+124
View File
@@ -0,0 +1,124 @@
"""Domain enums — typed constants for values tied to business logic.
Using Enum instead of bare strings ensures:
- Invalid values are caught at construction time
- IDE autocomplete and type checking work
- Adding new values is explicit (must update Enum)
"""
from __future__ import annotations
from enum import Enum
from pathlib import Path
class SubtitleMode(str, Enum):
"""Subtitle generation mode."""
DISABLED = "Disabled"
LINE = "Line"
SENTENCE = "Sentence"
SENTENCE_COMMA = "Sentence + Comma"
SENTENCE_HIGHLIGHTING = "Sentence + Highlighting"
@classmethod
def from_str(cls, value: str) -> SubtitleMode:
"""Parse from user input: case-insensitive, strips whitespace."""
normalized = value.strip()
for member in cls:
if member.value.lower() == normalized.lower():
return member
raise ValueError(f"Invalid SubtitleMode: {value!r}. Valid: {[m.value for m in cls]}")
class OutputFormat(str, Enum):
"""Audio output format."""
WAV = "wav"
MP3 = "mp3"
FLAC = "flac"
OPUS = "opus"
M4B = "m4b"
@property
def dot_ext(self) -> str:
"""File extension with dot: '.wav', '.mp3', etc."""
return f".{self.value}"
@property
def is_lossless(self) -> bool:
"""True for lossless formats."""
return self in (self.WAV, self.FLAC)
@classmethod
def from_str(cls, value: str) -> OutputFormat:
"""Parse from user input: strips dot prefix, case-insensitive."""
normalized = value.strip().lstrip(".").lower()
for member in cls:
if member.value == normalized:
return member
raise ValueError(f"Invalid OutputFormat: {value!r}. Valid: {[m.value for m in cls]}")
class SaveMode(str, Enum):
"""Where to save the output file."""
SAVE_NEXT_TO_INPUT = "save_next_to_input"
SAVE_TO_DESKTOP = "save_to_desktop"
CHOOSE_OUTPUT_FOLDER = "choose_output_folder"
DEFAULT_OUTPUT = "default_output"
CUSTOM_FOLDER = "custom_folder"
class SubtitleFormat(str, Enum):
"""Subtitle file format."""
SRT = "srt"
ASS = "ass"
@property
def dot_ext(self) -> str:
"""File extension with dot: '.srt', '.ass'."""
return f".{self.value}"
@classmethod
def from_str(cls, value: str) -> SubtitleFormat:
"""Parse from user input: strips dot prefix, case-insensitive."""
normalized = value.strip().lstrip(".").lower()
for member in cls:
if member.value == normalized:
return member
raise ValueError(f"Invalid SubtitleFormat: {value!r}. Valid: {[m.value for m in cls]}")
class InputFormat(str, Enum):
"""Input file format."""
EPUB = "epub"
PDF = "pdf"
TXT = "txt"
MD = "md"
SRT = "srt"
ASS = "ass"
VTT = "vtt"
@property
def is_book(self) -> bool:
"""True for book/document formats (epub, pdf, txt, md)."""
return self in (self.EPUB, self.PDF, self.TXT, self.MD)
@property
def is_subtitle(self) -> bool:
"""True for subtitle formats (srt, ass, vtt)."""
return self in (self.SRT, self.ASS, self.VTT)
@property
def dot_ext(self) -> str:
"""File extension with dot: '.epub', '.srt', etc."""
return f".{self.value}"
@classmethod
def from_path(cls, path: Path) -> InputFormat:
"""Detect format from file path extension."""
suffix = path.suffix.lower().lstrip(".")
if suffix == "markdown":
return cls.MD
try:
return cls(suffix)
except ValueError:
raise ValueError(f"Unsupported input format: {path.suffix!r}. Supported: {[m.value for m in cls]}")
+7 -4
View File
@@ -99,6 +99,9 @@ def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlin
chapter.text = newline_regex.sub(" ", chapter.text) chapter.text = newline_regex.sub(" ", chapter.text)
from abogen.domain.enums import SaveMode
def resolve_output_directory( def resolve_output_directory(
*, *,
save_mode: str, save_mode: str,
@@ -108,13 +111,13 @@ def resolve_output_directory(
user_output_path: Optional[Path], user_output_path: Optional[Path],
user_cache_outputs: Optional[Path], user_cache_outputs: Optional[Path],
) -> Path: ) -> Path:
if save_mode == "Save to Desktop" and desktop_dir: if save_mode in (SaveMode.SAVE_TO_DESKTOP, "Save to Desktop") and desktop_dir:
return desktop_dir return desktop_dir
if save_mode == "Save next to input file": if save_mode in (SaveMode.SAVE_NEXT_TO_INPUT, "Save next to input file"):
return stored_path.parent return stored_path.parent
if save_mode == "Choose output folder" and output_folder: if save_mode in (SaveMode.CHOOSE_OUTPUT_FOLDER, "Choose output folder") and output_folder:
return Path(output_folder) return Path(output_folder)
if save_mode == "Use default save location" and user_output_path: if save_mode in (SaveMode.DEFAULT_OUTPUT, "Use default save location") and user_output_path:
return user_output_path return user_output_path
return user_cache_outputs or Path(".") return user_cache_outputs or Path(".")
+5 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
"""Unified split pattern logic extracted from 3 copies.""" """Unified split pattern logic extracted from 3 copies."""
import re import re
from abogen.domain.enums import SubtitleMode
PUNCTUATION_SENTENCE = r".!?。!?" PUNCTUATION_SENTENCE = r".!?。!?"
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、," PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
@@ -27,14 +28,14 @@ def get_split_pattern(language: str, subtitle_mode: str) -> str:
# For CJK languages, when subtitle mode is Disabled or Line, prefer # For CJK languages, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting. # punctuation-based splitting instead of plain newline splitting.
if subtitle_mode in ("Disabled", "Line") and language in ("z", "j"): if subtitle_mode in (SubtitleMode.DISABLED, SubtitleMode.LINE) and language in ("z", "j"):
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+" return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
if subtitle_mode == "Line": if subtitle_mode == SubtitleMode.LINE:
return "\n" return "\n"
elif subtitle_mode == "Sentence": elif subtitle_mode == SubtitleMode.SENTENCE:
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+" return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
elif subtitle_mode == "Sentence + Comma": elif subtitle_mode == SubtitleMode.SENTENCE_COMMA:
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+" return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
else: else:
return r"\n+" return r"\n+"
+10 -8
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import re import re
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from abogen.domain.enums import SubtitleMode
# Punctuation constants for sentence splitting # Punctuation constants for sentence splitting
PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ?? PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ??
@@ -50,17 +52,17 @@ def process_subtitle_tokens(
# spaCy is disabled when subtitle mode is "Disabled" or "Line" # spaCy is disabled when subtitle mode is "Disabled" or "Line"
use_spacy_for_english = ( use_spacy_for_english = (
use_spacy_segmentation use_spacy_segmentation
and subtitle_mode not in ["Disabled", "Line"] and subtitle_mode not in [SubtitleMode.DISABLED, SubtitleMode.LINE]
and lang_code in ["a", "b"] and lang_code in ["a", "b"]
and subtitle_mode in ["Sentence", "Sentence + Comma"] and subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA]
) )
if subtitle_mode == "Sentence + Highlighting": if subtitle_mode == SubtitleMode.SENTENCE_HIGHLIGHTING:
_process_karaoke_highlighting( _process_karaoke_highlighting(
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
) )
elif subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]: elif subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA, SubtitleMode.LINE]:
if use_spacy_for_english and subtitle_mode != "Line": if use_spacy_for_english and subtitle_mode != SubtitleMode.LINE:
_process_spacy_sentences( _process_spacy_sentences(
processed_tokens, subtitle_entries, max_subtitle_words, processed_tokens, subtitle_entries, max_subtitle_words,
subtitle_mode, lang_code, fallback_end_time subtitle_mode, lang_code, fallback_end_time
@@ -176,7 +178,7 @@ def _process_spacy_sentences(
sentence_boundaries = [sent.end_char for sent in doc.sents] sentence_boundaries = [sent.end_char for sent in doc.sents]
# For "Sentence + Comma" mode, also split on commas # For "Sentence + Comma" mode, also split on commas
if subtitle_mode == "Sentence + Comma": if subtitle_mode == SubtitleMode.SENTENCE_COMMA:
comma_positions = [ comma_positions = [
i + 1 for i, c in enumerate(full_text) if c == "," i + 1 for i, c in enumerate(full_text) if c == ","
] ]
@@ -242,9 +244,9 @@ def _process_regex_sentences(
) -> None: ) -> None:
"""Process tokens using regex for sentence boundary detection.""" """Process tokens using regex for sentence boundary detection."""
# Define separator pattern based on mode # Define separator pattern based on mode
if subtitle_mode == "Line": if subtitle_mode == SubtitleMode.LINE:
separator = r"\n" separator = r"\n"
elif subtitle_mode == "Sentence": elif subtitle_mode == SubtitleMode.SENTENCE:
# Use punctuation without comma # Use punctuation without comma
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]" separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
else: # Sentence + Comma else: # Sentence + Comma
+99
View File
@@ -0,0 +1,99 @@
"""Tests for domain enums — validation, properties, from_str methods."""
import pytest
from pathlib import Path
from abogen.domain.enums import (
InputFormat,
OutputFormat,
SaveMode,
SubtitleFormat,
SubtitleMode,
)
class TestSubtitleMode:
def test_from_str_case_insensitive(self):
assert SubtitleMode.from_str("disabled") == SubtitleMode.DISABLED
assert SubtitleMode.from_str("SENTENCE") == SubtitleMode.SENTENCE
assert SubtitleMode.from_str("line") == SubtitleMode.LINE
def test_from_str_strips_whitespace(self):
assert SubtitleMode.from_str(" Disabled ") == SubtitleMode.DISABLED
def test_from_str_invalid(self):
with pytest.raises(ValueError, match="Invalid SubtitleMode"):
SubtitleMode.from_str("invalid")
def test_comparison_with_str(self):
assert SubtitleMode.DISABLED == "Disabled"
assert SubtitleMode.SENTENCE != "Disabled"
class TestOutputFormat:
def test_dot_ext(self):
assert OutputFormat.WAV.dot_ext == ".wav"
assert OutputFormat.M4B.dot_ext == ".m4b"
def test_is_lossless(self):
assert OutputFormat.WAV.is_lossless is True
assert OutputFormat.FLAC.is_lossless is True
assert OutputFormat.MP3.is_lossless is False
assert OutputFormat.M4B.is_lossless is False
def test_from_str_strips_dot(self):
assert OutputFormat.from_str(".wav") == OutputFormat.WAV
assert OutputFormat.from_str(".MP3") == OutputFormat.MP3
def test_from_str_case_insensitive(self):
assert OutputFormat.from_str("WAV") == OutputFormat.WAV
assert OutputFormat.from_str("opus") == OutputFormat.OPUS
def test_from_str_invalid(self):
with pytest.raises(ValueError, match="Invalid OutputFormat"):
OutputFormat.from_str("avi")
class TestSaveMode:
def test_values(self):
assert SaveMode.SAVE_NEXT_TO_INPUT == "save_next_to_input"
assert SaveMode.CUSTOM_FOLDER == "custom_folder"
class TestSubtitleFormat:
def test_dot_ext(self):
assert SubtitleFormat.SRT.dot_ext == ".srt"
assert SubtitleFormat.ASS.dot_ext == ".ass"
def test_from_str_strips_dot(self):
assert SubtitleFormat.from_str(".srt") == SubtitleFormat.SRT
assert SubtitleFormat.from_str(".ASS") == SubtitleFormat.ASS
class TestInputFormat:
def test_is_book(self):
assert InputFormat.EPUB.is_book is True
assert InputFormat.PDF.is_book is True
assert InputFormat.TXT.is_book is True
assert InputFormat.MD.is_book is True
assert InputFormat.SRT.is_book is False
def test_is_subtitle(self):
assert InputFormat.SRT.is_subtitle is True
assert InputFormat.ASS.is_subtitle is True
assert InputFormat.VTT.is_subtitle is True
assert InputFormat.EPUB.is_subtitle is False
def test_from_path(self):
assert InputFormat.from_path(Path("book.epub")) == InputFormat.EPUB
assert InputFormat.from_path(Path("sub.srt")) == InputFormat.SRT
assert InputFormat.from_path(Path("notes.MD")) == InputFormat.MD
assert InputFormat.from_path(Path("doc.markdown")) == InputFormat.MD
def test_from_path_invalid(self):
with pytest.raises(ValueError, match="Unsupported input format"):
InputFormat.from_path(Path("video.mp4"))
def test_dot_ext(self):
assert InputFormat.EPUB.dot_ext == ".epub"
assert InputFormat.SRT.dot_ext == ".srt"