diff --git a/abogen/application/__init__.py b/abogen/application/__init__.py new file mode 100644 index 0000000..715408d --- /dev/null +++ b/abogen/application/__init__.py @@ -0,0 +1,8 @@ +"""Application layer for conversion flow unification. + +This package contains the application-level orchestration logic +that bridges UI adapters (PyQt, WebUI) with domain functions. + +The main entry point is ConversionService.run() which coordinates +planning, execution, and finalization of a conversion job. +""" diff --git a/abogen/application/conversion_models.py b/abogen/application/conversion_models.py new file mode 100644 index 0000000..719de0f --- /dev/null +++ b/abogen/application/conversion_models.py @@ -0,0 +1,92 @@ +"""Core models for conversion planning. + +These dataclasses represent the structured plan for a conversion job. +They are UI-agnostic and describe WHAT to convert, not HOW to do it. + +The planning flow: + ConversionRequest -> ConversionPlan -> ConversionResult + + ConversionPlan contains: + - ChapterPlan[]: chapters with their segments + - SegmentPlan[]: individual text segments with voice specs + - OutputLayout: where to write outputs + - IntroOutroSpec: optional intro/outro +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +@dataclass +class SegmentPlan: + """A single text segment with its voice specification. + + This is the unified model for: + - Regular chapter body text + - PyQt voice markers (<>) + - WebUI chunks with per-chunk voice/speaker + - Intro/outro text + - Chapter headings + """ + + text: str + voice_spec: str + kind: str = "body" # intro, heading, body, outro + speaker_id: str = "narrator" + chunk_id: Optional[str] = None + chunk_index: Optional[int] = None + level: Optional[str] = None # chunk level (paragraph, sentence, etc.) + source: str = "chapter" # chapter, voice_marker, chunk + + +@dataclass +class ChapterPlan: + """A chapter with its metadata and segments.""" + + index: int + title: str + original_title: str + body_text: str + segments: List[SegmentPlan] + voice_spec: str # default voice for this chapter + + +@dataclass +class OutputLayout: + """Resolved output paths for a conversion job.""" + + parent_dir: Path + merged_path: Optional[Path] = None + chapter_dir: Optional[Path] = None + project_root: Optional[Path] = None + audio_dir: Optional[Path] = None + subtitle_dir: Optional[Path] = None + metadata_dir: Optional[Path] = None + + +@dataclass +class IntroOutroSpec: + """Intro/outro specification with resolved text and voice.""" + + enabled: bool = False + text: str = "" + voice_spec: str = "" + kind: str = "intro" # intro or outro + + +@dataclass +class ConversionPlan: + """Complete plan for a conversion job. + + This is the output of the planning phase and input to the executor. + """ + + request: Any # ConversionRequest (forward reference to avoid circular import) + metadata: Dict[str, Any] + chapters: List[ChapterPlan] + intro: Optional[IntroOutroSpec] = None + outro: Optional[IntroOutroSpec] = None + output_layout: Optional[OutputLayout] = None diff --git a/abogen/application/conversion_ports.py b/abogen/application/conversion_ports.py new file mode 100644 index 0000000..1e7c1ea --- /dev/null +++ b/abogen/application/conversion_ports.py @@ -0,0 +1,106 @@ +"""Ports / interfaces for the conversion service. + +These protocols define how the conversion service communicates with +the outside world (UI, TTS backends, voice resolvers). + +The service ONLY depends on these interfaces, never on concrete +implementations (PyQt signals, Flask Job, etc.). +""" + +from __future__ import annotations + +from typing import Any, List, Optional, Protocol, runtime_checkable + + +class ConversionEvents(Protocol): + """UI-specific actions the conversion service delegates back to the caller. + + Implementations: + - PyQt: emits signals (log_updated, progress_updated, etc.) + - WebUI: updates Job attributes (job.add_log, job.progress, etc.) + """ + + def log(self, message: str, level: str = "info") -> None: + """Log a message to the UI.""" + ... + + def progress(self, processed: int, total: int, etr: str) -> None: + """Update progress display.""" + ... + + def check_cancelled(self) -> None: + """Check if conversion was cancelled. + + Should raise ConversionCancelled (or UI-specific exception) + if cancellation is requested. Normal return means "continue". + """ + ... + + +class PipelineProvider(Protocol): + """Provides access to TTS backends (Kokoro, SuperTonic, etc.). + + Implementations: + - PyQt: wraps self.backend (single pipeline) + - WebUI: wraps PipelinePool (multi-provider) + """ + + def get(self, provider: str, language: str, use_gpu: bool) -> Any: + """Get a TTS backend instance.""" + ... + + def dispose_all(self) -> None: + """Dispose all backend resources.""" + ... + + +@runtime_checkable +class ResolvedVoice: + """A resolved voice ready for TTS synthesis.""" + + provider: str + resolved_spec: str + voice: Any # loaded voice tensor or name + speed: float + supertonic_steps: int + + +class VoiceResolver(Protocol): + """Resolves voice specs into loaded voice objects. + + Implementations: + - PyQt: wraps load_voice_cached + VoiceCache + - WebUI: wraps resolve_voice_choice + PipelinePool + VoiceCache + """ + + def resolve(self, voice_spec: str) -> ResolvedVoice: + """Resolve a voice spec into a loaded voice.""" + ... + + +class SubtitleWriter(Protocol): + """Writes subtitle entries to a file.""" + + def open(self) -> None: + """Open the subtitle file for writing.""" + ... + + def write_entry(self, start: float, end: float, text: str) -> None: + """Write a single subtitle entry.""" + ... + + def close(self) -> None: + """Close the subtitle file.""" + ... + + +class AudioSink(Protocol): + """Writes audio data to a file.""" + + def write(self, audio: Any) -> None: + """Write audio samples to the sink.""" + ... + + def close(self) -> None: + """Close the audio file.""" + ... diff --git a/abogen/application/conversion_request.py b/abogen/application/conversion_request.py new file mode 100644 index 0000000..32a45d7 --- /dev/null +++ b/abogen/application/conversion_request.py @@ -0,0 +1,83 @@ +"""ConversionRequest — normalized input for a conversion job. + +This is NOT a WebUI Job and NOT a PyQt ConversionThread state. +It describes the TASK, not the UI. + +UI adapters are responsible for converting their respective state +into a ConversionRequest before calling ConversionService.run(). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +@dataclass +class ConversionRequest: + """Normalized request for a conversion job. + + Only contains fields that describe the conversion task itself. + UI-only fields (display, logging, user prompts) stay in adapters. + """ + + # --- Source --- + source_path: Optional[Path] = None + direct_text: Optional[str] = None + original_filename: str = "" + + # --- TTS Settings --- + language: str = "a" + tts_provider: str = "kokoro" + voice: str = "M1" + voice_profile: Optional[str] = None + speed: float = 1.0 + use_gpu: bool = True + supertonic_total_steps: int = 5 + + # --- Output Format --- + output_format: str = "wav" + subtitle_mode: str = "Disabled" + subtitle_format: str = "srt" + max_subtitle_words: int = 5 + + # --- Save Options --- + save_mode: str = "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" + save_as_project: bool = False + + # --- Timing --- + silence_between_chapters: float = 2.0 + chapter_intro_delay: float = 0.0 + + # --- Content Processing --- + replace_single_newlines: bool = False + read_title_intro: bool = False + read_closing_outro: bool = True + auto_prefix_chapter_titles: bool = True + normalize_chapter_opening_caps: bool = False + + # --- Pronunciation / Normalization --- + 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 + + # --- Chapter/Chunk Configuration --- + chapter_overrides: List[Dict[str, Any]] = field(default_factory=list) + chunks: List[Dict[str, Any]] = field(default_factory=list) + chunk_level: str = "paragraph" + speaker_mode: str = "single" + speakers: Dict[str, Any] = field(default_factory=dict) + + # --- Metadata --- + metadata_tags: Dict[str, Any] = field(default_factory=dict) + + # --- Artifacts --- + cover_image_path: Optional[Path] = None + cover_image_mime: Optional[str] = None + generate_epub3: bool = False diff --git a/abogen/application/conversion_result.py b/abogen/application/conversion_result.py new file mode 100644 index 0000000..c95d1aa --- /dev/null +++ b/abogen/application/conversion_result.py @@ -0,0 +1,47 @@ +"""ConversionResult — output of a successful conversion. + +Returned by ConversionService.run() after all synthesis and finalization. +UI adapters consume this to update their respective state (Job, signals, etc.). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + + +@dataclass +class ConversionResult: + """Output of a successful conversion job.""" + + # --- Primary outputs --- + audio_path: Optional[Path] = None + subtitle_paths: List[Path] = field(default_factory=list) + chapter_paths: List[Path] = field(default_factory=list) + + # --- Markers (for metadata/audiobookshelf) --- + chapter_markers: List[Dict[str, Any]] = field(default_factory=list) + chunk_markers: List[Dict[str, Any]] = field(default_factory=list) + + # --- Metadata --- + metadata: Dict[str, Any] = field(default_factory=dict) + + # --- Artifacts --- + artifacts: Dict[str, Path] = field(default_factory=dict) + project_root: Optional[Path] = None + epub_path: Optional[Path] = None + + # --- Stats --- + total_chapters: int = 0 + total_segments: int = 0 + total_characters: int = 0 + + +@dataclass +class ConversionError: + """Error information when conversion fails.""" + + message: str + details: Optional[str] = None + is_cancelled: bool = False