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,
synthesize_text,
)
from abogen.domain.enums import OutputFormat, SubtitleMode
from abogen.domain.normalization import TTSContext
from abogen.domain.output_paths import sanitize_filename_for_chapter
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)
use_spacy = request.subtitle_mode not in ("Disabled", "Line")
use_spacy = request.subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
# Output paths
output_layout = plan.output_layout
@@ -96,7 +97,7 @@ def execute_conversion(
# Determine if merged output is needed
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
# Resolve voices
@@ -125,7 +126,7 @@ def execute_conversion(
# Open subtitle writer if needed
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(
audio_path,
request.subtitle_format,
@@ -137,7 +138,7 @@ def execute_conversion(
stack.callback(subtitle_writer.close)
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(
tts_context=tts_context,
+7 -5
View File
@@ -13,6 +13,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from abogen.domain.enums import OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
@dataclass
class ConversionRequest:
@@ -37,17 +39,17 @@ class ConversionRequest:
supertonic_total_steps: int = 5
# --- Output Format ---
output_format: str = "wav"
subtitle_mode: str = "Disabled"
subtitle_format: str = "srt"
output_format: OutputFormat = OutputFormat.WAV
subtitle_mode: SubtitleMode = SubtitleMode.DISABLED
subtitle_format: SubtitleFormat = SubtitleFormat.SRT
max_subtitle_words: int = 50
# --- Save Options ---
save_mode: str = "save_next_to_input"
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: str = "wav"
separate_chapters_format: OutputFormat = OutputFormat.WAV
save_as_project: bool = False
# --- 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_result import ConversionResult
from abogen.domain.enums import SubtitleMode
from abogen.domain.normalization import TTSContext
from abogen.domain.split_pattern import get_split_pattern
@@ -135,7 +136,7 @@ def _prepare_tts_context(
# Compute split pattern
split_pattern = get_split_pattern(
str(request.language or "a"),
str(request.subtitle_mode or "Disabled"),
request.subtitle_mode or SubtitleMode.DISABLED,
)
# 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_request import ConversionRequest
from abogen.domain.enums import OutputFormat, SaveMode, SubtitleFormat
from abogen.domain.output_paths import (
resolve_project_layout,
resolve_unique_path,
@@ -39,7 +40,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
OutputLayout with resolved paths
"""
# 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)
elif request.source_path:
parent_dir = request.source_path.parent
@@ -55,7 +56,7 @@ def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
base_name = "output"
# 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(
parent_dir, base_name, "", allowed_extensions=allowed_exts
)
@@ -142,7 +143,7 @@ def should_merge_output(request: ConversionRequest) -> bool:
Returns:
True if merged output should be created
"""
if request.output_format.lower() == "m4b":
if request.output_format == OutputFormat.M4B:
return True
if not request.save_chapters_separately:
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.conversion_pipeline import tts_segments
from abogen.domain.enums import SubtitleMode
from abogen.domain.normalization import TTSContext
from abogen.domain.progress import calc_etr_str
from abogen.domain.subtitle_generation import process_subtitle_tokens
@@ -136,7 +137,7 @@ def run_tts_segment_loop(
params.audio_sink.write(seg.audio)
# 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)
# Update timing
+3 -1
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from abogen.domain.enums import SubtitleMode
from typing import Any, Callable, Dict, Iterator, List, Optional
import numpy as np
@@ -221,7 +223,7 @@ def emit_text_to_sinks(
# Flush subtitle 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] = []
process_subtitle_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)
from abogen.domain.enums import SaveMode
def resolve_output_directory(
*,
save_mode: str,
@@ -108,13 +111,13 @@ def resolve_output_directory(
user_output_path: Optional[Path],
user_cache_outputs: Optional[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
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
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)
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_cache_outputs or Path(".")
+5 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
"""Unified split pattern logic extracted from 3 copies."""
import re
from abogen.domain.enums import SubtitleMode
PUNCTUATION_SENTENCE = 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
# 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+"
if subtitle_mode == "Line":
if subtitle_mode == SubtitleMode.LINE:
return "\n"
elif subtitle_mode == "Sentence":
elif subtitle_mode == SubtitleMode.SENTENCE:
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
elif subtitle_mode == "Sentence + Comma":
elif subtitle_mode == SubtitleMode.SENTENCE_COMMA:
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
else:
return r"\n+"
+10 -8
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import re
from typing import List, Optional, Tuple
from abogen.domain.enums import SubtitleMode
# Punctuation constants for sentence splitting
PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ??
@@ -50,17 +52,17 @@ def process_subtitle_tokens(
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
use_spacy_for_english = (
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 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(
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
)
elif subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]:
if use_spacy_for_english and subtitle_mode != "Line":
elif subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA, SubtitleMode.LINE]:
if use_spacy_for_english and subtitle_mode != SubtitleMode.LINE:
_process_spacy_sentences(
processed_tokens, subtitle_entries, max_subtitle_words,
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]
# For "Sentence + Comma" mode, also split on commas
if subtitle_mode == "Sentence + Comma":
if subtitle_mode == SubtitleMode.SENTENCE_COMMA:
comma_positions = [
i + 1 for i, c in enumerate(full_text) if c == ","
]
@@ -242,9 +244,9 @@ def _process_regex_sentences(
) -> None:
"""Process tokens using regex for sentence boundary detection."""
# Define separator pattern based on mode
if subtitle_mode == "Line":
if subtitle_mode == SubtitleMode.LINE:
separator = r"\n"
elif subtitle_mode == "Sentence":
elif subtitle_mode == SubtitleMode.SENTENCE:
# Use punctuation without comma
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
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"