mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
feat: application layer models and ports for conversion unification
- application/conversion_models.py: SegmentPlan, ChapterPlan, ConversionPlan, OutputLayout, IntroOutroSpec - application/conversion_request.py: ConversionRequest (normalized input) - application/conversion_result.py: ConversionResult, ConversionError (normalized output) - application/conversion_ports.py: protocols (ConversionEvents, PipelineProvider, VoiceResolver, SubtitleWriter, AudioSink) These are pure data models and interfaces. No implementation yet.
This commit is contained in:
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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 (<<VOICE:F1>>)
|
||||||
|
- 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
|
||||||
@@ -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."""
|
||||||
|
...
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user