diff --git a/abogen/application/conversion_executor.py b/abogen/application/conversion_executor.py index 9a873c3..2f2ffec 100644 --- a/abogen/application/conversion_executor.py +++ b/abogen/application/conversion_executor.py @@ -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, diff --git a/abogen/application/conversion_request.py b/abogen/application/conversion_request.py index daea938..ca21192 100644 --- a/abogen/application/conversion_request.py +++ b/abogen/application/conversion_request.py @@ -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 --- diff --git a/abogen/application/conversion_service.py b/abogen/application/conversion_service.py index 0de680f..90e5e15 100644 --- a/abogen/application/conversion_service.py +++ b/abogen/application/conversion_service.py @@ -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) diff --git a/abogen/application/output_layout_service.py b/abogen/application/output_layout_service.py index f1abbbb..7c1640e 100644 --- a/abogen/application/output_layout_service.py +++ b/abogen/application/output_layout_service.py @@ -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 diff --git a/abogen/domain/conversion_engine.py b/abogen/domain/conversion_engine.py index a4ae71d..1ecfd72 100644 --- a/abogen/domain/conversion_engine.py +++ b/abogen/domain/conversion_engine.py @@ -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 diff --git a/abogen/domain/conversion_pipeline.py b/abogen/domain/conversion_pipeline.py index 2fbea66..a67e88a 100644 --- a/abogen/domain/conversion_pipeline.py +++ b/abogen/domain/conversion_pipeline.py @@ -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, diff --git a/abogen/domain/enums.py b/abogen/domain/enums.py new file mode 100644 index 0000000..c002f3b --- /dev/null +++ b/abogen/domain/enums.py @@ -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]}") diff --git a/abogen/domain/output_paths.py b/abogen/domain/output_paths.py index 4b79d40..e6001f4 100644 --- a/abogen/domain/output_paths.py +++ b/abogen/domain/output_paths.py @@ -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(".") diff --git a/abogen/domain/split_pattern.py b/abogen/domain/split_pattern.py index 0282780..de87224 100644 --- a/abogen/domain/split_pattern.py +++ b/abogen/domain/split_pattern.py @@ -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+" diff --git a/abogen/domain/subtitle_generation.py b/abogen/domain/subtitle_generation.py index 99500dc..cfb461f 100644 --- a/abogen/domain/subtitle_generation.py +++ b/abogen/domain/subtitle_generation.py @@ -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 diff --git a/tests/test_enums.py b/tests/test_enums.py new file mode 100644 index 0000000..2a929f3 --- /dev/null +++ b/tests/test_enums.py @@ -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"