mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-22 04:30:59 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd2c109d8 |
@@ -1,15 +0,0 @@
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.cfg text eol=lf
|
||||
*.ini text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.j2 text eol=lf
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [jborza, jeremiahsb, mohangk, k0sm0naft]
|
||||
github: [jborza, jeremiahsb, mohangk]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
name: CI
|
||||
run-name: CI
|
||||
|
||||
on:
|
||||
name: pip install
|
||||
run-name: pip install
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**.py'
|
||||
- 'pyproject.toml'
|
||||
@@ -13,41 +11,23 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
install-and-run:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-14, windows-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ['3.12']
|
||||
fail-fast: false
|
||||
continue-on-error: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.3.1
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
cache-dependency-glob: pyproject.toml
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libegl1
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv pip install --system .[dev]
|
||||
env:
|
||||
UV_LINK_MODE: copy
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
run: pytest tests/ -v --tb=short
|
||||
- name: Install from repository
|
||||
run: python -m pip install .
|
||||
#- name: Run abogen
|
||||
# run: abogen
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
# Only if we need to push an image
|
||||
|
||||
@@ -39,4 +39,3 @@ dist/
|
||||
test_assets/
|
||||
dev_notes/
|
||||
.claude/
|
||||
.coverage
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""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.
|
||||
"""
|
||||
@@ -1,434 +0,0 @@
|
||||
"""Unified conversion executor.
|
||||
|
||||
Takes a ConversionPlan and ports, executes the TTS conversion,
|
||||
and returns a ConversionResult. No UI imports allowed.
|
||||
|
||||
This is Stage 6 of the conversion flow unification plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
SegmentPlan,
|
||||
)
|
||||
from abogen.application.conversion_ports import (
|
||||
AudioSink,
|
||||
ConversionEvents,
|
||||
PipelineProvider,
|
||||
SubtitleWriter,
|
||||
VoiceResolver,
|
||||
)
|
||||
from abogen.application.conversion_result import ConversionResult
|
||||
from abogen.domain.audio_sink import open_audio_sink
|
||||
from abogen.domain.conversion_engine import (
|
||||
SegmentStats,
|
||||
SynthParams,
|
||||
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
|
||||
|
||||
|
||||
def execute_conversion(
|
||||
plan: ConversionPlan,
|
||||
events: ConversionEvents,
|
||||
pipeline_provider: PipelineProvider,
|
||||
voice_resolver: VoiceResolver,
|
||||
tts_context: TTSContext,
|
||||
*,
|
||||
check_cancelled: Optional[Callable[[], None]] = None,
|
||||
) -> ConversionResult:
|
||||
"""Execute a conversion plan and return the result.
|
||||
|
||||
Args:
|
||||
plan: The conversion plan from build_conversion_plan()
|
||||
events: UI-specific callbacks (log, progress, check_cancelled)
|
||||
pipeline_provider: Provides TTS backends
|
||||
voice_resolver: Resolves voice specs into loaded voices
|
||||
tts_context: Normalization context for text processing
|
||||
check_cancelled: Optional cancellation checker (overrides events.check_cancelled)
|
||||
|
||||
Returns:
|
||||
ConversionResult with paths and markers
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If conversion is cancelled
|
||||
"""
|
||||
request = plan.request
|
||||
result = ConversionResult(metadata=plan.metadata)
|
||||
|
||||
# Determine cancellation checker
|
||||
if check_cancelled is None:
|
||||
check_cancelled = lambda: events.check_cancelled()
|
||||
|
||||
# Stats for progress tracking
|
||||
total_characters = sum(
|
||||
len(ch.body_text) for ch in plan.chapters
|
||||
)
|
||||
if plan.intro and plan.intro.enabled:
|
||||
total_characters += len(plan.intro.text)
|
||||
if plan.outro and plan.outro.enabled:
|
||||
total_characters += len(plan.outro.text)
|
||||
|
||||
stats = SegmentStats(
|
||||
processed_chars=0,
|
||||
current_time=0.0,
|
||||
etr_start_time=time.time(),
|
||||
total_characters=total_characters,
|
||||
)
|
||||
|
||||
# Compute subtitle flag once (used in every synthesize_text call)
|
||||
use_spacy = request.subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
|
||||
|
||||
# Output paths
|
||||
output_layout = plan.output_layout
|
||||
if not output_layout:
|
||||
raise ValueError("ConversionPlan must have an output_layout")
|
||||
|
||||
# Determine if merged output is needed
|
||||
merge_chapters = request.merge_chapters_at_end or not request.save_chapters_separately
|
||||
if request.output_format == OutputFormat.M4B:
|
||||
merge_chapters = True
|
||||
|
||||
# Resolve voices
|
||||
base_voice_spec = request.voice or "M1"
|
||||
base_provider, base_voice_choice, base_speed, base_steps = _resolve_voice(
|
||||
voice_resolver, base_voice_spec, request
|
||||
)
|
||||
|
||||
# Use ExitStack for resource management
|
||||
with ExitStack() as stack:
|
||||
# Open merged audio sink
|
||||
audio_sink: Optional[AudioSink] = None
|
||||
audio_path = None
|
||||
if merge_chapters:
|
||||
audio_path = output_layout.audio_dir / f"{_base_name(request)}.{request.output_format}"
|
||||
meta = plan.metadata if plan.metadata else None
|
||||
audio_sink = stack.enter_context(
|
||||
open_audio_sink(
|
||||
audio_path,
|
||||
request.output_format,
|
||||
metadata=meta,
|
||||
cancel_check=check_cancelled,
|
||||
)
|
||||
)
|
||||
result.audio_path = audio_path
|
||||
|
||||
# Open subtitle writer if needed
|
||||
subtitle_writer: Optional[SubtitleWriter] = None
|
||||
if request.subtitle_mode != SubtitleMode.DISABLED and audio_sink:
|
||||
subtitle_writer = make_subtitle_writer(
|
||||
audio_path,
|
||||
request.subtitle_format,
|
||||
request.subtitle_mode,
|
||||
max_words=request.max_subtitle_words,
|
||||
)
|
||||
if subtitle_writer:
|
||||
subtitle_writer.open()
|
||||
stack.callback(subtitle_writer.close)
|
||||
result.subtitle_paths.append(subtitle_writer.path)
|
||||
|
||||
effective_subtitle_mode = request.subtitle_mode if subtitle_writer else SubtitleMode.DISABLED
|
||||
|
||||
synth = SynthParams(
|
||||
tts_context=tts_context,
|
||||
stats=stats,
|
||||
check_cancel=check_cancelled,
|
||||
on_progress=lambda pct, etr: events.progress(pct, etr),
|
||||
audio_sink=audio_sink,
|
||||
subtitle_mode=effective_subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
lang_code=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
)
|
||||
|
||||
# Chapter directory
|
||||
chapter_dir = None
|
||||
if request.save_chapters_separately and len(plan.chapters) > 1:
|
||||
chapter_dir = output_layout.audio_dir / "chapters"
|
||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Process intro
|
||||
intro_emitted = False
|
||||
if plan.intro and plan.intro.enabled and merge_chapters:
|
||||
events.log(f"Title intro: {plan.intro.text[:80]}")
|
||||
intro_provider, intro_voice, intro_speed, intro_steps = _resolve_voice(
|
||||
voice_resolver, plan.intro.voice_spec, request
|
||||
)
|
||||
intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu)
|
||||
synthesize_text(
|
||||
text=plan.intro.text,
|
||||
params=synth,
|
||||
backend=intro_backend,
|
||||
voice=intro_voice,
|
||||
speed=intro_speed or request.speed,
|
||||
chapter_sink=None,
|
||||
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
||||
)
|
||||
intro_emitted = True
|
||||
events.log("Intro synthesized.")
|
||||
|
||||
# Chapter loop
|
||||
for chapter_idx, chapter in enumerate(plan.chapters, 1):
|
||||
check_cancelled()
|
||||
|
||||
chapter_display = f"Chapter {chapter_idx}/{len(plan.chapters)}: {chapter.title}"
|
||||
events.log(f"Processing {chapter_display}")
|
||||
|
||||
# Resolve chapter voice
|
||||
chapter_provider, chapter_voice, chapter_speed, chapter_steps = _resolve_voice(
|
||||
voice_resolver, chapter.voice_spec, request
|
||||
)
|
||||
chapter_backend = pipeline_provider.get(chapter_provider, request.language, request.use_gpu)
|
||||
|
||||
# Per-chapter sink
|
||||
chapter_sink: Optional[AudioSink] = None
|
||||
chapter_path = None
|
||||
if chapter_dir:
|
||||
chapter_filename = sanitize_filename_for_chapter(chapter.title, chapter_idx)
|
||||
chapter_path = chapter_dir / f"{chapter_filename}.{request.separate_chapters_format}"
|
||||
chapter_sink = stack.enter_context(
|
||||
open_audio_sink(
|
||||
chapter_path,
|
||||
request.separate_chapters_format,
|
||||
cancel_check=check_cancelled,
|
||||
)
|
||||
)
|
||||
result.chapter_paths.append(chapter_path)
|
||||
|
||||
# Intro delay before first chapter
|
||||
if not intro_emitted and plan.intro and plan.intro.enabled:
|
||||
# Intro will be emitted with first chapter
|
||||
intro_provider, intro_voice, intro_speed, intro_steps = _resolve_voice(
|
||||
voice_resolver, plan.intro.voice_spec, request
|
||||
)
|
||||
intro_backend = pipeline_provider.get(intro_provider, request.language, request.use_gpu)
|
||||
synthesize_text(
|
||||
text=plan.intro.text,
|
||||
params=synth,
|
||||
backend=intro_backend,
|
||||
voice=intro_voice,
|
||||
speed=intro_speed or request.speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=lambda text: events.log(f" Intro: {text[:80]}"),
|
||||
)
|
||||
intro_emitted = True
|
||||
if request.chapter_intro_delay > 0:
|
||||
_append_silence(
|
||||
request.chapter_intro_delay,
|
||||
chapter_sink=chapter_sink,
|
||||
audio_sink=audio_sink,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
# Process heading
|
||||
if chapter.title:
|
||||
heading_text = _format_heading(chapter.title, chapter_idx, request)
|
||||
if heading_text:
|
||||
synthesize_text(
|
||||
text=heading_text,
|
||||
params=synth,
|
||||
backend=chapter_backend,
|
||||
voice=chapter_voice,
|
||||
speed=chapter_speed or request.speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=lambda text: events.log(f" Title: {text[:80]}"),
|
||||
)
|
||||
if request.chapter_intro_delay > 0:
|
||||
_append_silence(
|
||||
request.chapter_intro_delay,
|
||||
chapter_sink=chapter_sink,
|
||||
audio_sink=audio_sink,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
# Process body segments
|
||||
chapter_chunk_markers: List[Dict[str, Any]] = []
|
||||
for seg_idx, segment in enumerate(chapter.segments):
|
||||
check_cancelled()
|
||||
|
||||
# Resolve segment voice (may differ from chapter voice)
|
||||
if segment.voice_spec != chapter.voice_spec:
|
||||
seg_provider, seg_voice, seg_speed, seg_steps = _resolve_voice(
|
||||
voice_resolver, segment.voice_spec, request
|
||||
)
|
||||
seg_backend = pipeline_provider.get(seg_provider, request.language, request.use_gpu)
|
||||
else:
|
||||
seg_provider = chapter_provider
|
||||
seg_voice = chapter_voice
|
||||
seg_speed = chapter_speed
|
||||
seg_backend = chapter_backend
|
||||
|
||||
seg_start_time = stats.current_time
|
||||
local_segments, accumulated_tokens = synthesize_text(
|
||||
text=segment.text,
|
||||
params=synth,
|
||||
backend=seg_backend,
|
||||
voice=seg_voice,
|
||||
speed=seg_speed or request.speed,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
||||
)
|
||||
|
||||
# Process subtitles
|
||||
if subtitle_writer and audio_sink and accumulated_tokens:
|
||||
process_and_write_subtitles(
|
||||
accumulated_tokens,
|
||||
subtitle_writer,
|
||||
subtitle_mode=request.subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
lang_code=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
fallback_end_time=stats.current_time,
|
||||
)
|
||||
|
||||
# Record chunk marker
|
||||
if segment.source in ("chunk", "voice_marker"):
|
||||
chapter_chunk_markers.append({
|
||||
"id": segment.chunk_id,
|
||||
"chapter_index": chapter_idx - 1,
|
||||
"chunk_index": segment.chunk_index or seg_idx,
|
||||
"start": seg_start_time,
|
||||
"end": stats.current_time,
|
||||
"speaker_id": segment.speaker_id,
|
||||
"voice": segment.voice_spec,
|
||||
"level": segment.level or request.chunk_level,
|
||||
"characters": len(segment.text),
|
||||
})
|
||||
|
||||
# Silence between chapters
|
||||
if chapter_idx < len(plan.chapters) and request.silence_between_chapters > 0:
|
||||
_append_silence(
|
||||
request.silence_between_chapters,
|
||||
chapter_sink=chapter_sink,
|
||||
audio_sink=audio_sink,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
# Close chapter sink
|
||||
if chapter_sink:
|
||||
chapter_sink.close()
|
||||
|
||||
# Add chapter marker
|
||||
result.chapter_markers.append({
|
||||
"chapter_index": chapter_idx - 1,
|
||||
"title": chapter.title,
|
||||
"start": stats.current_time - (stats.current_time - seg_start_time) if chapter.segments else stats.current_time,
|
||||
"end": stats.current_time,
|
||||
})
|
||||
|
||||
result.chunk_markers.extend(chapter_chunk_markers)
|
||||
|
||||
# Process outro
|
||||
if plan.outro and plan.outro.enabled and merge_chapters:
|
||||
events.log(f"Closing outro: {plan.outro.text[:80]}")
|
||||
outro_provider, outro_voice, outro_speed, outro_steps = _resolve_voice(
|
||||
voice_resolver, plan.outro.voice_spec, request
|
||||
)
|
||||
outro_backend = pipeline_provider.get(outro_provider, request.language, request.use_gpu)
|
||||
|
||||
# Silence before outro
|
||||
if request.silence_between_chapters > 0:
|
||||
_append_silence(
|
||||
request.silence_between_chapters,
|
||||
chapter_sink=None,
|
||||
audio_sink=audio_sink,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
synthesize_text(
|
||||
text=plan.outro.text,
|
||||
params=synth,
|
||||
backend=outro_backend,
|
||||
voice=outro_voice,
|
||||
speed=outro_speed or request.speed,
|
||||
chapter_sink=None,
|
||||
preview_callback=lambda text: events.log(f" {text[:80]}"),
|
||||
)
|
||||
events.log("Outro synthesized.")
|
||||
|
||||
# Set result metadata
|
||||
result.total_chapters = len(plan.chapters)
|
||||
result.total_segments = sum(len(ch.segments) for ch in plan.chapters)
|
||||
result.total_characters = total_characters
|
||||
|
||||
if output_layout.project_root:
|
||||
result.project_root = output_layout.project_root
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_voice(
|
||||
resolver: VoiceResolver,
|
||||
voice_spec: str,
|
||||
request: Any,
|
||||
) -> Tuple[str, Any, Optional[float], Optional[int]]:
|
||||
"""Resolve a voice spec and return (provider, voice, speed, steps)."""
|
||||
try:
|
||||
resolved = resolver.resolve(voice_spec)
|
||||
return (
|
||||
resolved.provider,
|
||||
resolved.voice,
|
||||
resolved.speed,
|
||||
resolved.supertonic_steps,
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to base voice
|
||||
resolved = resolver.resolve(request.voice or "M1")
|
||||
return (
|
||||
resolved.provider,
|
||||
resolved.voice,
|
||||
resolved.speed,
|
||||
resolved.supertonic_steps,
|
||||
)
|
||||
|
||||
|
||||
def _base_name(request: Any) -> str:
|
||||
"""Get base name for output file."""
|
||||
from abogen.domain.output_paths import sanitize_output_stem
|
||||
|
||||
if request.original_filename:
|
||||
return sanitize_output_stem(request.original_filename)
|
||||
return "output"
|
||||
|
||||
|
||||
def _format_heading(title: str, index: int, request: Any) -> str:
|
||||
"""Format chapter heading for TTS."""
|
||||
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
||||
|
||||
if request.auto_prefix_chapter_titles:
|
||||
return format_spoken_chapter_title(title, index, apply_prefix=True)
|
||||
return title
|
||||
|
||||
|
||||
def _append_silence(
|
||||
duration: float,
|
||||
*,
|
||||
chapter_sink: Optional[AudioSink],
|
||||
audio_sink: Optional[AudioSink],
|
||||
stats: SegmentStats,
|
||||
) -> None:
|
||||
"""Append silence to sinks."""
|
||||
from abogen.domain.audio_buffer import create_silence
|
||||
|
||||
silence = create_silence(duration)
|
||||
if silence.size == 0:
|
||||
return
|
||||
if chapter_sink:
|
||||
chapter_sink.write(silence)
|
||||
if audio_sink:
|
||||
audio_sink.write(silence)
|
||||
stats.current_time += duration
|
||||
@@ -1,95 +0,0 @@
|
||||
"""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 TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
|
||||
|
||||
@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: ConversionRequest
|
||||
metadata: Dict[str, Any]
|
||||
chapters: List[ChapterPlan]
|
||||
intro: Optional[IntroOutroSpec] = None
|
||||
outro: Optional[IntroOutroSpec] = None
|
||||
output_layout: Optional[OutputLayout] = None
|
||||
@@ -1,348 +0,0 @@
|
||||
"""Unified conversion planner.
|
||||
|
||||
Pure functions that take a ConversionRequest and produce a ConversionPlan.
|
||||
No side effects, no I/O — all complexity from both UIs in one place.
|
||||
|
||||
This is Stage 2 of the conversion flow unification plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from abogen.application.conversion_models import (
|
||||
ChapterPlan,
|
||||
ConversionPlan,
|
||||
IntroOutroSpec,
|
||||
OutputLayout,
|
||||
SegmentPlan,
|
||||
)
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.output_layout_service import resolve_output_layout
|
||||
from abogen.domain.chapter_overrides import apply_chapter_overrides
|
||||
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
||||
from abogen.domain.metadata_extraction import extract_metadata_for_file
|
||||
from abogen.domain.metadata_merge import merge_metadata
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
|
||||
|
||||
def build_conversion_plan(request: ConversionRequest) -> ConversionPlan:
|
||||
"""Build a complete conversion plan from a request.
|
||||
|
||||
This is the single entry point that both UIs will call.
|
||||
It handles all the planning logic that was previously duplicated
|
||||
in both PyQt and WebUI conversion runners.
|
||||
|
||||
Args:
|
||||
request: Normalized conversion request
|
||||
|
||||
Returns:
|
||||
ConversionPlan with all chapters, segments, and output layout
|
||||
|
||||
Raises:
|
||||
ValueError: If request is invalid (no source, no chapters, etc.)
|
||||
"""
|
||||
# 1. Extract and validate source
|
||||
source_text = _extract_source_text(request)
|
||||
if not source_text or not source_text.strip():
|
||||
raise ValueError("No text content to convert")
|
||||
|
||||
# 2. Extract metadata
|
||||
metadata = _extract_metadata(request)
|
||||
|
||||
# 3. Parse chapters
|
||||
raw_chapters = _parse_chapters(source_text, request)
|
||||
|
||||
# 4. Apply chapter selection/overrides
|
||||
selected_chapters = _apply_selection(raw_chapters, request)
|
||||
|
||||
# 5. Build segments for each chapter
|
||||
chapters = _build_chapters(selected_chapters, request)
|
||||
|
||||
# 6. Build intro/outro
|
||||
intro, outro = _build_intro_outro(metadata, request)
|
||||
|
||||
# 7. Resolve output layout
|
||||
output_layout = resolve_output_layout(request)
|
||||
|
||||
return ConversionPlan(
|
||||
request=request,
|
||||
metadata=metadata,
|
||||
chapters=chapters,
|
||||
intro=intro,
|
||||
outro=outro,
|
||||
output_layout=output_layout,
|
||||
)
|
||||
|
||||
|
||||
def _extract_source_text(request: ConversionRequest) -> Optional[str]:
|
||||
"""Extract text from request source."""
|
||||
from abogen.subtitle_utils import clean_text
|
||||
|
||||
if request.direct_text:
|
||||
return clean_text(request.direct_text)
|
||||
if request.source_path and request.source_path.exists():
|
||||
encoding = "utf-8"
|
||||
try:
|
||||
with open(request.source_path, "r", encoding=encoding, errors="replace") as f:
|
||||
text = f.read()
|
||||
except Exception:
|
||||
return None
|
||||
return clean_text(text)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_metadata(request: ConversionRequest) -> Dict[str, Any]:
|
||||
"""Extract metadata from source file."""
|
||||
if request.direct_text:
|
||||
return dict(request.metadata_tags)
|
||||
|
||||
if request.source_path and request.source_path.exists():
|
||||
try:
|
||||
extraction = extract_metadata_for_file(
|
||||
str(request.source_path), is_direct_text=False
|
||||
)
|
||||
metadata = dict(extraction.metadata) if extraction.metadata else {}
|
||||
except Exception:
|
||||
metadata = {}
|
||||
metadata = merge_metadata(metadata, request.metadata_tags)
|
||||
return metadata
|
||||
|
||||
return dict(request.metadata_tags)
|
||||
|
||||
|
||||
def _parse_chapters(
|
||||
source_text: str, request: ConversionRequest
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""Parse source text into raw chapters.
|
||||
|
||||
Returns list of (title, body_text, default_voice) tuples.
|
||||
"""
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
|
||||
# Text is already cleaned in _extract_source_text, so clean=False here
|
||||
chapters = parse_chapters_from_text(source_text, default_title="text", clean=False)
|
||||
|
||||
# Default voice from request
|
||||
default_voice = request.voice or "M1"
|
||||
|
||||
return [(title, text, default_voice) for title, text in chapters]
|
||||
|
||||
|
||||
def _apply_selection(
|
||||
raw_chapters: List[Tuple[str, str, str]], request: ConversionRequest
|
||||
) -> List[Tuple[str, str, str]]:
|
||||
"""Apply chapter selection and overrides."""
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
|
||||
# Convert to ExtractedChapter objects for auto_select_relevant_chapters
|
||||
extracted = [
|
||||
ExtractedChapter(title=title, text=text)
|
||||
for title, text, _ in raw_chapters
|
||||
]
|
||||
|
||||
# If user specified chapters, apply overrides
|
||||
if request.chapter_overrides:
|
||||
selected, _, diagnostics = apply_chapter_overrides(extracted, request.chapter_overrides)
|
||||
if selected:
|
||||
# Map back to (title, text, voice) tuples
|
||||
result = []
|
||||
for ch in selected:
|
||||
# Find matching original chapter to get voice
|
||||
voice = request.voice or "M1"
|
||||
for orig_title, orig_text, orig_voice in raw_chapters:
|
||||
if orig_title == ch.title:
|
||||
voice = orig_voice
|
||||
break
|
||||
result.append((ch.title, ch.text or "", voice))
|
||||
return result
|
||||
# If no chapters selected, fall through to auto-selection
|
||||
|
||||
# Auto-select relevant chapters
|
||||
from abogen.domain.file_type import infer_file_type
|
||||
|
||||
file_type = infer_file_type(request.source_path) if request.source_path else "text"
|
||||
result = auto_select_relevant_chapters(extracted, file_type)
|
||||
filtered = result.kept
|
||||
|
||||
if filtered:
|
||||
# Map back to (title, text, voice) tuples
|
||||
result = []
|
||||
for ch in filtered:
|
||||
voice = request.voice or "M1"
|
||||
for orig_title, orig_text, orig_voice in raw_chapters:
|
||||
if orig_title == ch.title:
|
||||
voice = orig_voice
|
||||
break
|
||||
result.append((ch.title, ch.text or "", voice))
|
||||
return result
|
||||
|
||||
# Fall back to all chapters
|
||||
return raw_chapters
|
||||
|
||||
|
||||
def _build_chapters(
|
||||
selected_chapters: List[Tuple[str, str, str]], request: ConversionRequest
|
||||
) -> List[ChapterPlan]:
|
||||
"""Build ChapterPlan with SegmentPlan for each chapter."""
|
||||
chapters = []
|
||||
|
||||
for idx, (title, body_text, default_voice) in enumerate(selected_chapters, 1):
|
||||
# Build segments for this chapter
|
||||
segments = _build_segments(body_text, default_voice, request)
|
||||
|
||||
chapter = ChapterPlan(
|
||||
index=idx,
|
||||
title=title,
|
||||
original_title=title,
|
||||
body_text=body_text,
|
||||
segments=segments,
|
||||
voice_spec=default_voice,
|
||||
)
|
||||
chapters.append(chapter)
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def _build_segments(
|
||||
body_text: str, default_voice: str, request: ConversionRequest
|
||||
) -> List[SegmentPlan]:
|
||||
"""Build SegmentPlan list for a chapter's body text.
|
||||
|
||||
Handles voice markers (PyQt) and chunks (WebUI).
|
||||
"""
|
||||
segments = []
|
||||
|
||||
# Check for chunks (WebUI style)
|
||||
if request.chunks:
|
||||
# Group chunks by chapter (simplified — assume chunks are for current chapter)
|
||||
for chunk_idx, chunk in enumerate(request.chunks):
|
||||
chunk_text = chunk.get("normalized_text") or chunk.get("text", "")
|
||||
if not chunk_text or not chunk_text.strip():
|
||||
continue
|
||||
|
||||
chunk_voice = _resolve_chunk_voice(chunk, default_voice, request)
|
||||
speaker_id = chunk.get("speaker_id", "narrator")
|
||||
|
||||
segments.append(
|
||||
SegmentPlan(
|
||||
text=chunk_text.strip(),
|
||||
voice_spec=chunk_voice,
|
||||
kind="body",
|
||||
speaker_id=speaker_id,
|
||||
chunk_id=chunk.get("id"),
|
||||
chunk_index=chunk.get("chunk_index", chunk_idx),
|
||||
level=chunk.get("level", request.chunk_level),
|
||||
source="chunk",
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
# Check for voice markers (PyQt style)
|
||||
# Detect markers even if validation fails (voice names may not be loaded yet)
|
||||
from abogen.subtitle_utils import _VOICE_MARKER_SEARCH_PATTERN
|
||||
|
||||
has_voice_markers = bool(_VOICE_MARKER_SEARCH_PATTERN.search(body_text))
|
||||
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(
|
||||
body_text, default_voice
|
||||
)
|
||||
|
||||
if has_voice_markers or (len(voice_segments) > 1):
|
||||
# Voice markers were used
|
||||
for voice_name, segment_text in voice_segments:
|
||||
if not segment_text or not segment_text.strip():
|
||||
continue
|
||||
segments.append(
|
||||
SegmentPlan(
|
||||
text=segment_text.strip(),
|
||||
voice_spec=voice_name,
|
||||
kind="body",
|
||||
source="voice_marker",
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
# No voice markers — single segment for entire body
|
||||
if body_text and body_text.strip():
|
||||
segments.append(
|
||||
SegmentPlan(
|
||||
text=body_text.strip(),
|
||||
voice_spec=default_voice,
|
||||
kind="body",
|
||||
source="chapter",
|
||||
)
|
||||
)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _resolve_chunk_voice(
|
||||
chunk: Dict[str, Any], default_voice: str, request: ConversionRequest
|
||||
) -> str:
|
||||
"""Resolve voice for a chunk."""
|
||||
# Check for speaker-based voice
|
||||
speaker_id = chunk.get("speaker_id", "narrator")
|
||||
if speaker_id and speaker_id != "narrator" and request.speakers:
|
||||
speaker_config = request.speakers.get(speaker_id, {})
|
||||
if isinstance(speaker_config, dict):
|
||||
voice = speaker_config.get("voice")
|
||||
if voice:
|
||||
return voice
|
||||
|
||||
# Check for direct voice field
|
||||
voice = chunk.get("voice")
|
||||
if voice:
|
||||
return voice
|
||||
|
||||
return default_voice
|
||||
|
||||
|
||||
def _build_intro_outro(
|
||||
metadata: Dict[str, Any], request: ConversionRequest
|
||||
) -> Tuple[Optional[IntroOutroSpec], Optional[IntroOutroSpec]]:
|
||||
"""Build intro and outro specs."""
|
||||
intro_spec = None
|
||||
outro_spec = None
|
||||
|
||||
# Intro
|
||||
if request.read_title_intro:
|
||||
resolved = resolve_intro(
|
||||
metadata,
|
||||
request.original_filename,
|
||||
True,
|
||||
request.voice or "M1",
|
||||
request.voice or "M1",
|
||||
[],
|
||||
)
|
||||
if resolved.enabled:
|
||||
intro_spec = IntroOutroSpec(
|
||||
enabled=True,
|
||||
text=resolved.text,
|
||||
voice_spec=resolved.voice_spec,
|
||||
kind="intro",
|
||||
)
|
||||
|
||||
# Outro
|
||||
if request.read_closing_outro:
|
||||
resolved = resolve_outro(
|
||||
metadata,
|
||||
request.original_filename,
|
||||
True,
|
||||
request.voice or "M1",
|
||||
request.voice or "M1",
|
||||
[],
|
||||
)
|
||||
if resolved.enabled:
|
||||
outro_spec = IntroOutroSpec(
|
||||
enabled=True,
|
||||
text=resolved.text,
|
||||
voice_spec=resolved.voice_spec,
|
||||
kind="outro",
|
||||
)
|
||||
|
||||
return intro_spec, outro_spec
|
||||
|
||||
|
||||
# Output layout resolution is now in application/output_layout_service.py
|
||||
@@ -1,112 +0,0 @@
|
||||
"""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 dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class ConversionCancelled(Exception):
|
||||
"""Raised when conversion is cancelled by user."""
|
||||
pass
|
||||
|
||||
|
||||
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."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
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."""
|
||||
...
|
||||
@@ -1,156 +0,0 @@
|
||||
"""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
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
|
||||
|
||||
|
||||
class ConversionRequestError(ValueError):
|
||||
"""Raised when ConversionRequest has invalid field values."""
|
||||
|
||||
|
||||
# Numeric field constraints: attr -> (min, max)
|
||||
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
|
||||
"max_subtitle_words": (1, 500),
|
||||
"speed": (0.5, 3.0),
|
||||
"supertonic_total_steps": (2, 15),
|
||||
"silence_between_chapters": (0.0, None),
|
||||
"chapter_intro_delay": (0.0, None),
|
||||
}
|
||||
|
||||
# Enum-like fields that must be in allowed set
|
||||
_ENUM_CONSTRAINTS: dict[str, tuple[str, ...]] = {
|
||||
"chunk_level": ("paragraph", "sentence"),
|
||||
"speaker_mode": ("single", "multi"),
|
||||
}
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Validation runs on creation via __post_init__:
|
||||
- None values → replaced with field default (from declaration)
|
||||
- Numeric fields → clamped to valid range
|
||||
- String enums → validated against allowed set
|
||||
"""
|
||||
|
||||
# --- Source ---
|
||||
source_path: Optional[Path] = None
|
||||
direct_text: Optional[str] = None
|
||||
original_filename: str = ""
|
||||
|
||||
# --- TTS Settings ---
|
||||
language: Language = Language.EN_US
|
||||
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: OutputFormat = OutputFormat.WAV
|
||||
subtitle_mode: SubtitleMode = SubtitleMode.DISABLED
|
||||
subtitle_format: SubtitleFormat = SubtitleFormat.SRT
|
||||
max_subtitle_words: int = 50
|
||||
|
||||
# --- Save Options ---
|
||||
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: OutputFormat = OutputFormat.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
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Resolve None → default, then validate and clamp."""
|
||||
_apply_none_defaults(self)
|
||||
if not self.tts_provider:
|
||||
self.tts_provider = "kokoro"
|
||||
_clamp_numerics(self)
|
||||
_validate_enums(self)
|
||||
|
||||
|
||||
def _apply_none_defaults(obj: ConversionRequest) -> None:
|
||||
"""Replace None values with field defaults from dataclass declaration."""
|
||||
for f in dataclasses.fields(obj):
|
||||
if getattr(obj, f.name) is not None:
|
||||
continue
|
||||
if f.default is not dataclasses.MISSING:
|
||||
setattr(obj, f.name, f.default)
|
||||
elif f.default_factory is not dataclasses.MISSING:
|
||||
setattr(obj, f.name, f.default_factory())
|
||||
|
||||
|
||||
def _clamp_numerics(obj: ConversionRequest) -> None:
|
||||
"""Clamp numeric fields to valid ranges."""
|
||||
for attr, (min_v, max_v) in _NUMERIC_CONSTRAINTS.items():
|
||||
val = getattr(obj, attr)
|
||||
if val is None:
|
||||
continue
|
||||
if not isinstance(val, (int, float)):
|
||||
raise ConversionRequestError(
|
||||
f"{attr} must be a number, got {type(val).__name__}"
|
||||
)
|
||||
clamped = max(min_v, float(val))
|
||||
if max_v is not None:
|
||||
clamped = min(max_v, clamped)
|
||||
setattr(obj, attr, clamped)
|
||||
|
||||
|
||||
def _validate_enums(obj: ConversionRequest) -> None:
|
||||
"""Validate string enum fields against allowed values."""
|
||||
for attr, allowed in _ENUM_CONSTRAINTS.items():
|
||||
val = getattr(obj, attr)
|
||||
if val not in allowed:
|
||||
raise ConversionRequestError(
|
||||
f"{attr} must be one of {allowed}, got {val!r}"
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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
|
||||
@@ -1,172 +0,0 @@
|
||||
"""ConversionService — main orchestrator for the conversion flow.
|
||||
|
||||
Ties together planner, executor, and finalizers into a single entry point.
|
||||
Both UIs (PyQt, WebUI) call ConversionService.run() to execute a conversion.
|
||||
|
||||
Responsibilities:
|
||||
- Prepare TTSContext (normalization settings, pronunciation rules)
|
||||
- Build ConversionPlan via planner
|
||||
- Execute conversion via executor
|
||||
- Handle lifecycle (cleanup, error handling)
|
||||
- Return ConversionResult
|
||||
|
||||
The service NEVER imports from PyQt or WebUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from abogen.application.conversion_executor import execute_conversion
|
||||
from abogen.application.conversion_models import ConversionPlan
|
||||
from abogen.application.conversion_planner import build_conversion_plan
|
||||
from abogen.application.conversion_ports import (
|
||||
ConversionEvents,
|
||||
PipelineProvider,
|
||||
VoiceResolver,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def run_conversion(
|
||||
request: ConversionRequest,
|
||||
events: ConversionEvents,
|
||||
pipeline_provider: PipelineProvider,
|
||||
voice_resolver: VoiceResolver,
|
||||
) -> ConversionResult:
|
||||
"""Execute a conversion request and return the result.
|
||||
|
||||
This is the single entry point for both UIs. It orchestrates:
|
||||
1. TTS context preparation
|
||||
2. Conversion planning
|
||||
3. Conversion execution
|
||||
4. Resource cleanup
|
||||
|
||||
Args:
|
||||
request: Normalized conversion request
|
||||
events: UI-specific callbacks (log, progress, check_cancelled)
|
||||
pipeline_provider: Provides TTS backends
|
||||
voice_resolver: Resolves voice specs into loaded voices
|
||||
|
||||
Returns:
|
||||
ConversionResult with paths and markers
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If conversion was cancelled
|
||||
ValueError: If request is invalid
|
||||
Exception: On TTS or I/O errors
|
||||
"""
|
||||
try:
|
||||
# Stage 1: Prepare TTS context
|
||||
events.log("Preparing conversion pipeline")
|
||||
tts_context = _prepare_tts_context(request, events)
|
||||
|
||||
# Stage 2: Build conversion plan
|
||||
events.log("Building conversion plan")
|
||||
plan = build_conversion_plan(request)
|
||||
|
||||
# Stage 3: Execute conversion
|
||||
events.log("Starting conversion")
|
||||
result = execute_conversion(
|
||||
plan=plan,
|
||||
events=events,
|
||||
pipeline_provider=pipeline_provider,
|
||||
voice_resolver=voice_resolver,
|
||||
tts_context=tts_context,
|
||||
)
|
||||
|
||||
# Stage 4: Finalize
|
||||
events.log("Conversion complete")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
events.log(f"Conversion failed: {e}", level="error")
|
||||
raise
|
||||
|
||||
|
||||
def _prepare_tts_context(
|
||||
request: ConversionRequest,
|
||||
events: ConversionEvents,
|
||||
) -> TTSContext:
|
||||
"""Prepare TTSContext with normalization settings.
|
||||
|
||||
This compiles pronunciation/heteronym rules and creates the
|
||||
normalization context used during conversion.
|
||||
|
||||
Args:
|
||||
request: Conversion request with override settings
|
||||
events: For logging warnings about missing features
|
||||
|
||||
Returns:
|
||||
TTSContext ready for text normalization
|
||||
"""
|
||||
from abogen.domain.normalization import (
|
||||
build_apostrophe_config,
|
||||
get_runtime_settings,
|
||||
)
|
||||
from abogen.domain.pronunciation import (
|
||||
compile_heteronym_sentence_rules,
|
||||
compile_pronunciation_rules,
|
||||
merge_pronunciation_overrides,
|
||||
)
|
||||
|
||||
# Get runtime normalization settings
|
||||
normalization_settings = get_runtime_settings()
|
||||
|
||||
# Build apostrophe config
|
||||
apostrophe_config = build_apostrophe_config(
|
||||
settings=normalization_settings,
|
||||
)
|
||||
|
||||
# Check for num2words availability
|
||||
if apostrophe_config.convert_numbers:
|
||||
try:
|
||||
import num2words # noqa: F401
|
||||
except ImportError:
|
||||
events.log(
|
||||
"Number normalization is enabled but 'num2words' library is not available. "
|
||||
"Numbers will NOT be converted to words.",
|
||||
level="warning",
|
||||
)
|
||||
|
||||
# Compute split pattern
|
||||
split_pattern = get_split_pattern(
|
||||
request.language or Language.EN_US,
|
||||
request.subtitle_mode or SubtitleMode.DISABLED,
|
||||
)
|
||||
|
||||
# Merge pronunciation overrides (manual + pronunciation)
|
||||
# Create a mock job-like object for merge_pronunciation_overrides
|
||||
class _MockJob:
|
||||
def __init__(self, req):
|
||||
self.pronunciation_overrides = req.pronunciation_overrides
|
||||
self.manual_overrides = req.manual_overrides
|
||||
self.heteronym_overrides = req.heteronym_overrides
|
||||
|
||||
merged_overrides = merge_pronunciation_overrides(_MockJob(request))
|
||||
|
||||
# Compile rules
|
||||
pronunciation_rules = compile_pronunciation_rules(merged_overrides)
|
||||
heteronym_rules = compile_heteronym_sentence_rules(request.heteronym_overrides)
|
||||
|
||||
if heteronym_rules:
|
||||
events.log(
|
||||
f"Applying {len(heteronym_rules)} heteronym override(s) during conversion.",
|
||||
level="debug",
|
||||
)
|
||||
if pronunciation_rules:
|
||||
events.log(
|
||||
f"Applying {len(pronunciation_rules)} pronunciation override(s) during conversion.",
|
||||
level="debug",
|
||||
)
|
||||
|
||||
return TTSContext(
|
||||
split_pattern=split_pattern,
|
||||
pronunciation_rules=pronunciation_rules,
|
||||
heteronym_rules=heteronym_rules,
|
||||
normalization_overrides=request.normalization_overrides,
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Output layout resolution service.
|
||||
|
||||
Determines where conversion outputs (audio, subtitles, metadata) should be written.
|
||||
Extracted from conversion_planner.py as a standalone service per plan Stage 5.
|
||||
|
||||
Responsibilities:
|
||||
- Resolve base output directory from save_mode and source_path
|
||||
- Determine base filename from original_filename
|
||||
- Find unique output path to avoid overwrites
|
||||
- Resolve project layout (audio_dir, subtitle_dir, metadata_dir)
|
||||
- Force merged output for m4b format
|
||||
- Return OutputLayout dataclass
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
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,
|
||||
sanitize_output_stem,
|
||||
)
|
||||
|
||||
|
||||
def resolve_output_layout(request: ConversionRequest) -> OutputLayout:
|
||||
"""Resolve output paths for a conversion request.
|
||||
|
||||
This is the single entry point for output path resolution,
|
||||
used by both UIs and the conversion service.
|
||||
|
||||
Args:
|
||||
request: Normalized conversion request
|
||||
|
||||
Returns:
|
||||
OutputLayout with resolved paths
|
||||
"""
|
||||
# Determine base output directory
|
||||
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
|
||||
else:
|
||||
parent_dir = Path.cwd()
|
||||
|
||||
# Determine base name
|
||||
if request.original_filename:
|
||||
base_name = sanitize_output_stem(request.original_filename)
|
||||
elif request.source_path:
|
||||
base_name = sanitize_output_stem(request.source_path.stem)
|
||||
else:
|
||||
base_name = "output"
|
||||
|
||||
# Find unique output path
|
||||
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
|
||||
)
|
||||
|
||||
# Resolve project layout
|
||||
project_root = None
|
||||
audio_dir = parent_dir
|
||||
subtitle_dir = None
|
||||
metadata_dir = None
|
||||
|
||||
if request.save_as_project:
|
||||
project_root, audio_dir, subtitle_dir, metadata_dir = resolve_project_layout(
|
||||
original_filename=request.original_filename,
|
||||
save_as_project=True,
|
||||
base_dir=parent_dir,
|
||||
)
|
||||
|
||||
return OutputLayout(
|
||||
parent_dir=parent_dir,
|
||||
project_root=project_root,
|
||||
audio_dir=audio_dir,
|
||||
subtitle_dir=subtitle_dir,
|
||||
metadata_dir=metadata_dir,
|
||||
)
|
||||
|
||||
|
||||
def resolve_merged_path(
|
||||
layout: OutputLayout,
|
||||
request: ConversionRequest,
|
||||
) -> Path:
|
||||
"""Resolve the merged output audio file path.
|
||||
|
||||
Args:
|
||||
layout: Resolved output layout
|
||||
request: Conversion request
|
||||
|
||||
Returns:
|
||||
Path to the merged output file
|
||||
"""
|
||||
base_name = sanitize_output_stem(
|
||||
request.original_filename or "output"
|
||||
)
|
||||
return layout.audio_dir / f"{base_name}.{request.output_format}"
|
||||
|
||||
|
||||
def resolve_chapter_path(
|
||||
layout: OutputLayout,
|
||||
request: ConversionRequest,
|
||||
chapter_title: str,
|
||||
chapter_index: int,
|
||||
) -> Path:
|
||||
"""Resolve the output path for a separate chapter file.
|
||||
|
||||
Args:
|
||||
layout: Resolved output layout
|
||||
request: Conversion request
|
||||
chapter_title: Chapter title for filename
|
||||
chapter_index: Chapter number (1-based)
|
||||
|
||||
Returns:
|
||||
Path to the chapter output file
|
||||
"""
|
||||
import re
|
||||
|
||||
slug = re.sub(r'[^\w\s-]', '', chapter_title.lower())
|
||||
slug = re.sub(r'[\s_]+', '_', slug).strip('_')
|
||||
if not slug:
|
||||
slug = f"chapter_{chapter_index}"
|
||||
filename = f"{chapter_index:02d}_{slug}.{request.separate_chapters_format}"
|
||||
return layout.audio_dir / "chapters" / filename
|
||||
|
||||
|
||||
def should_merge_output(request: ConversionRequest) -> bool:
|
||||
"""Determine if merged output is required.
|
||||
|
||||
Rules:
|
||||
- m4b format always forces merged output
|
||||
- If save_chapters_separately is False, merged is required
|
||||
- Otherwise, use merge_chapters_at_end setting
|
||||
|
||||
Args:
|
||||
request: Conversion request
|
||||
|
||||
Returns:
|
||||
True if merged output should be created
|
||||
"""
|
||||
if request.output_format == OutputFormat.M4B:
|
||||
return True
|
||||
if not request.save_chapters_separately:
|
||||
return True
|
||||
return request.merge_chapters_at_end
|
||||
+30
-30
@@ -1,31 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#808080;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
|
||||
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
|
||||
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
|
||||
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
|
||||
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
|
||||
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
|
||||
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
|
||||
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
|
||||
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
|
||||
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
|
||||
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
|
||||
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
|
||||
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
|
||||
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
|
||||
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
|
||||
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
|
||||
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
|
||||
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
|
||||
C339.815,267.771,320.972,313.262,281.292,329.698z"/>
|
||||
</g>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#808080;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
|
||||
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
|
||||
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
|
||||
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
|
||||
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
|
||||
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
|
||||
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
|
||||
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
|
||||
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
|
||||
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
|
||||
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
|
||||
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
|
||||
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
|
||||
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
|
||||
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
|
||||
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
|
||||
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
|
||||
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
|
||||
C339.815,267.771,320.972,313.262,281.292,329.698z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -63,6 +63,64 @@ SUPPORTED_INPUT_FORMATS = [
|
||||
# 384 if self.lang_code in 'ab':
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||
|
||||
# Voice and sample text constants
|
||||
VOICES_INTERNAL = [
|
||||
"af_alloy",
|
||||
"af_aoede",
|
||||
"af_bella",
|
||||
"af_heart",
|
||||
"af_jessica",
|
||||
"af_kore",
|
||||
"af_nicole",
|
||||
"af_nova",
|
||||
"af_river",
|
||||
"af_sarah",
|
||||
"af_sky",
|
||||
"am_adam",
|
||||
"am_echo",
|
||||
"am_eric",
|
||||
"am_fenrir",
|
||||
"am_liam",
|
||||
"am_michael",
|
||||
"am_onyx",
|
||||
"am_puck",
|
||||
"am_santa",
|
||||
"bf_alice",
|
||||
"bf_emma",
|
||||
"bf_isabella",
|
||||
"bf_lily",
|
||||
"bm_daniel",
|
||||
"bm_fable",
|
||||
"bm_george",
|
||||
"bm_lewis",
|
||||
"ef_dora",
|
||||
"em_alex",
|
||||
"em_santa",
|
||||
"ff_siwis",
|
||||
"hf_alpha",
|
||||
"hf_beta",
|
||||
"hm_omega",
|
||||
"hm_psi",
|
||||
"if_sara",
|
||||
"im_nicola",
|
||||
"jf_alpha",
|
||||
"jf_gongitsune",
|
||||
"jf_nezumi",
|
||||
"jf_tebukuro",
|
||||
"jm_kumo",
|
||||
"pf_dora",
|
||||
"pm_alex",
|
||||
"pm_santa",
|
||||
"zf_xiaobei",
|
||||
"zf_xiaoni",
|
||||
"zf_xiaoxiao",
|
||||
"zf_xiaoyi",
|
||||
"zm_yunjian",
|
||||
"zm_yunxi",
|
||||
"zm_yunxia",
|
||||
"zm_yunyang",
|
||||
]
|
||||
|
||||
# Voice and sample text mapping
|
||||
SAMPLE_VOICE_TEXTS = {
|
||||
"a": "This is a sample of the selected voice.",
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Audio buffer operations for audiobook generation.
|
||||
|
||||
This module provides core audio buffer manipulation functions including:
|
||||
- Silence generation
|
||||
- Audio mixing
|
||||
- Audio normalization
|
||||
- Audio buffer resizing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Standard sample rate used throughout the application
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
def create_silence(duration_seconds: float) -> np.ndarray:
|
||||
"""Create a silence audio buffer.
|
||||
|
||||
Args:
|
||||
duration_seconds: Duration of silence in seconds.
|
||||
|
||||
Returns:
|
||||
Numpy array of float32 zeros with length = duration_seconds * SAMPLE_RATE.
|
||||
Returns empty array if duration is <= 0.
|
||||
"""
|
||||
if duration_seconds <= 0:
|
||||
return np.array([], dtype="float32")
|
||||
|
||||
samples = int(round(duration_seconds * SAMPLE_RATE))
|
||||
if samples <= 0:
|
||||
return np.array([], dtype="float32")
|
||||
|
||||
return np.zeros(samples, dtype="float32")
|
||||
|
||||
|
||||
def mix_audio(
|
||||
target: np.ndarray,
|
||||
source: np.ndarray,
|
||||
start_sample: int,
|
||||
end_sample: Optional[int] = None,
|
||||
) -> np.ndarray:
|
||||
"""Mix source audio into target buffer at specified position.
|
||||
|
||||
This performs additive mixing (target += source). The target buffer
|
||||
is extended if necessary to accommodate the source audio.
|
||||
|
||||
Args:
|
||||
target: The target audio buffer to mix into.
|
||||
source: The source audio buffer to mix.
|
||||
start_sample: Starting sample index in target buffer.
|
||||
end_sample: Optional end sample index. If None, calculated from source length.
|
||||
|
||||
Returns:
|
||||
The target buffer (possibly extended). If target was extended, returns new array.
|
||||
"""
|
||||
if source.size == 0:
|
||||
return target
|
||||
|
||||
if end_sample is None:
|
||||
end_sample = start_sample + len(source)
|
||||
|
||||
# Extend target buffer if needed
|
||||
if end_sample > len(target):
|
||||
new_length = end_sample
|
||||
new_target = np.concatenate([
|
||||
target,
|
||||
np.zeros(new_length - len(target), dtype="float32")
|
||||
])
|
||||
target = new_target
|
||||
|
||||
# Perform the mix (additive)
|
||||
target[start_sample:end_sample] += source
|
||||
return target
|
||||
|
||||
|
||||
def normalize_audio(
|
||||
audio: np.ndarray,
|
||||
target_peak: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""Normalize audio buffer to prevent clipping.
|
||||
|
||||
If the audio exceeds the target peak (default 1.0), it is scaled down
|
||||
proportionally to prevent distortion.
|
||||
|
||||
Args:
|
||||
audio: Input audio buffer.
|
||||
target_peak: Target maximum amplitude (default 1.0).
|
||||
|
||||
Returns:
|
||||
Normalized audio buffer (new array, original is not modified).
|
||||
"""
|
||||
if audio.size == 0:
|
||||
return audio.copy()
|
||||
|
||||
max_amplitude = float(np.abs(audio).max())
|
||||
|
||||
if max_amplitude <= target_peak:
|
||||
return audio.copy()
|
||||
|
||||
# Scale down to prevent clipping
|
||||
scale_factor = target_peak / max_amplitude
|
||||
return (audio * scale_factor).astype("float32")
|
||||
|
||||
|
||||
def ensure_buffer_size(
|
||||
buffer: np.ndarray,
|
||||
min_samples: int,
|
||||
) -> np.ndarray:
|
||||
"""Ensure audio buffer is at least min_samples long.
|
||||
|
||||
If buffer is shorter, it is extended with zeros.
|
||||
|
||||
Args:
|
||||
buffer: Input audio buffer.
|
||||
min_samples: Minimum required length in samples.
|
||||
|
||||
Returns:
|
||||
Buffer of at least min_samples length (new array if extended).
|
||||
"""
|
||||
if len(buffer) >= min_samples:
|
||||
return buffer
|
||||
|
||||
new_buffer = np.zeros(min_samples, dtype="float32")
|
||||
new_buffer[:len(buffer)] = buffer
|
||||
return new_buffer
|
||||
|
||||
|
||||
def concatenate_audio(*buffers: np.ndarray) -> np.ndarray:
|
||||
"""Concatenate multiple audio buffers.
|
||||
|
||||
Args:
|
||||
*buffers: Audio buffers to concatenate.
|
||||
|
||||
Returns:
|
||||
Single concatenated audio buffer.
|
||||
"""
|
||||
non_empty = [b for b in buffers if b.size > 0]
|
||||
if not non_empty:
|
||||
return np.array([], dtype="float32")
|
||||
return np.concatenate(non_empty)
|
||||
|
||||
|
||||
def audio_duration(audio: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float:
|
||||
"""Calculate duration of audio buffer in seconds.
|
||||
|
||||
Args:
|
||||
audio: Audio buffer.
|
||||
sample_rate: Sample rate in Hz (default SAMPLE_RATE).
|
||||
|
||||
Returns:
|
||||
Duration in seconds.
|
||||
"""
|
||||
return len(audio) / sample_rate
|
||||
|
||||
|
||||
def samples_for_duration(duration_seconds: float, sample_rate: int = SAMPLE_RATE) -> int:
|
||||
"""Calculate number of samples for a given duration.
|
||||
|
||||
Args:
|
||||
duration_seconds: Duration in seconds.
|
||||
sample_rate: Sample rate in Hz (default SAMPLE_RATE).
|
||||
|
||||
Returns:
|
||||
Number of samples (rounded to nearest integer), or 0 if duration is <= 0.
|
||||
"""
|
||||
if duration_seconds <= 0:
|
||||
return 0
|
||||
return int(round(duration_seconds * sample_rate))
|
||||
|
||||
|
||||
def fit_audio_to_duration(
|
||||
audio: np.ndarray,
|
||||
target_duration: float,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
) -> np.ndarray:
|
||||
"""Pad or trim audio to match target duration.
|
||||
|
||||
Args:
|
||||
audio: Input audio buffer.
|
||||
target_duration: Desired duration in seconds.
|
||||
sample_rate: Sample rate in Hz.
|
||||
|
||||
Returns:
|
||||
Audio buffer of exact length target_duration * sample_rate.
|
||||
"""
|
||||
target_samples = int(target_duration * sample_rate)
|
||||
if len(audio) < target_samples:
|
||||
padding = np.zeros(target_samples - len(audio), dtype="float32")
|
||||
return np.concatenate([audio, padding])
|
||||
return audio[:target_samples]
|
||||
|
||||
|
||||
def ffmpeg_time_stretch(
|
||||
audio: np.ndarray,
|
||||
speed_factor: float,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
) -> np.ndarray:
|
||||
"""Time-stretch audio using FFmpeg's atempo filter.
|
||||
|
||||
Args:
|
||||
audio: Input audio buffer (float32).
|
||||
speed_factor: Speed multiplier (>1.0 = faster).
|
||||
sample_rate: Sample rate in Hz.
|
||||
|
||||
Returns:
|
||||
Time-stretched audio buffer.
|
||||
"""
|
||||
import math
|
||||
import subprocess
|
||||
|
||||
import static_ffmpeg
|
||||
|
||||
if speed_factor <= 1.0 or audio.size == 0:
|
||||
return audio
|
||||
|
||||
static_ffmpeg.add_paths()
|
||||
num_stages = max(1, int(math.ceil(math.log(speed_factor) / math.log(2.0))))
|
||||
tempo = speed_factor ** (1.0 / num_stages)
|
||||
filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
|
||||
"-i", "pipe:0",
|
||||
"-filter:a", filter_str,
|
||||
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
|
||||
"pipe:1",
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, _ = proc.communicate(input=audio.tobytes())
|
||||
return np.frombuffer(out, dtype="float32")
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Audio helper utilities.
|
||||
|
||||
Functions for building ffmpeg commands, converting audio formats,
|
||||
and applying chapter metadata to MP4 files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
def build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]:
|
||||
from abogen.infrastructure.exporters import ExportService
|
||||
|
||||
base = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]
|
||||
if fmt == "mp3":
|
||||
base += ["-c:a", "libmp3lame", "-qscale:a", "2"]
|
||||
elif fmt == "opus":
|
||||
base += ["-c:a", "libopus", "-b:a", "24000"]
|
||||
elif fmt == "m4b":
|
||||
base += ["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"]
|
||||
else:
|
||||
base += ["-c:a", "copy"]
|
||||
|
||||
if metadata:
|
||||
svc = ExportService()
|
||||
base.extend(svc._metadata_to_ffmpeg_args(metadata))
|
||||
base.append(str(path))
|
||||
return base
|
||||
|
||||
|
||||
def to_float32(audio_segment) -> np.ndarray:
|
||||
if audio_segment is None:
|
||||
return np.zeros(0, dtype="float32")
|
||||
|
||||
tensor = audio_segment
|
||||
if hasattr(tensor, "detach"):
|
||||
tensor = tensor.detach()
|
||||
if hasattr(tensor, "cpu"):
|
||||
try:
|
||||
tensor = tensor.cpu()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(tensor, "numpy"):
|
||||
return np.asarray(tensor.numpy(), dtype="float32").reshape(-1)
|
||||
return np.asarray(tensor, dtype="float32").reshape(-1)
|
||||
|
||||
|
||||
def apply_m4b_chapters_with_mutagen(
|
||||
audio_path: Path,
|
||||
chapters: List[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Apply chapter atoms to an MP4/M4B file using mutagen.
|
||||
|
||||
Returns True if chapters were written, False otherwise.
|
||||
Raises ImportError if mutagen is not installed.
|
||||
"""
|
||||
if not chapters:
|
||||
return False
|
||||
|
||||
from fractions import Fraction
|
||||
from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import]
|
||||
|
||||
mp4 = MP4(str(audio_path))
|
||||
|
||||
chapter_objects: List[MP4Chapter] = []
|
||||
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||
start_raw = entry.get("start")
|
||||
if start_raw is None:
|
||||
continue
|
||||
try:
|
||||
start_seconds = max(0.0, float(start_raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
title_value = entry.get("title")
|
||||
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||
|
||||
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||
|
||||
end_raw = entry.get("end")
|
||||
if end_raw is not None:
|
||||
try:
|
||||
end_seconds = float(end_raw)
|
||||
except (TypeError, ValueError):
|
||||
end_seconds = None
|
||||
if end_seconds is not None and end_seconds > start_seconds:
|
||||
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||
|
||||
chapter_objects.append(chapter_atom)
|
||||
|
||||
if not chapter_objects:
|
||||
return False
|
||||
|
||||
from typing import cast
|
||||
|
||||
mp4.chapters = cast(Any, chapter_objects)
|
||||
mp4.save()
|
||||
|
||||
return True
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Audio sink abstraction for unified audio output.
|
||||
|
||||
Provides a context-manager-based abstraction for writing audio data
|
||||
to various output formats (WAV, FLAC via soundfile; compressed via ffmpeg).
|
||||
|
||||
Usage:
|
||||
with open_audio_sink(path, "wav") as sink:
|
||||
sink.write(audio_data)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.domain.audio_buffer import SAMPLE_RATE
|
||||
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioSink:
|
||||
"""Represents an open audio output target."""
|
||||
|
||||
write: Callable[[np.ndarray], None]
|
||||
close: Callable[[], None]
|
||||
|
||||
def __enter__(self) -> AudioSink:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _ensure_ffmpeg() -> None:
|
||||
"""Ensure static ffmpeg binaries are on PATH."""
|
||||
import static_ffmpeg # type: ignore
|
||||
|
||||
ffmpeg_cache_root = _get_ffmpeg_cache_root()
|
||||
platform_cache = os.path.join(ffmpeg_cache_root, sys.platform)
|
||||
os.makedirs(platform_cache, exist_ok=True)
|
||||
try:
|
||||
import static_ffmpeg.run as static_ffmpeg_run # type: ignore
|
||||
|
||||
static_ffmpeg_run.LOCK_FILE = os.path.join(ffmpeg_cache_root, "lock.file")
|
||||
except Exception:
|
||||
pass
|
||||
static_ffmpeg.add_paths(weak=True, download_dir=platform_cache)
|
||||
|
||||
|
||||
def _get_ffmpeg_cache_root() -> str:
|
||||
from abogen.utils import get_internal_cache_path
|
||||
|
||||
return get_internal_cache_path("ffmpeg")
|
||||
|
||||
|
||||
def open_audio_sink(
|
||||
path: Path,
|
||||
fmt: str,
|
||||
*,
|
||||
metadata: Optional[dict[str, str]] = None,
|
||||
cancel_check: Optional[Callable[[], bool]] = None,
|
||||
extra_ffmpeg_args: Optional[list[str]] = None,
|
||||
ffmpeg_cmd: Optional[list[str]] = None,
|
||||
) -> AudioSink:
|
||||
"""Open an audio output sink for writing raw float32 PCM samples.
|
||||
|
||||
Args:
|
||||
path: Output file path.
|
||||
fmt: Output format ("wav", "flac", "mp3", "opus", "m4b").
|
||||
metadata: Optional metadata dict (ignored when ffmpeg_cmd is provided).
|
||||
cancel_check: Optional callable; if it returns True, writes are silently skipped.
|
||||
extra_ffmpeg_args: Optional extra args inserted after ffmpeg header (ignored when ffmpeg_cmd is provided).
|
||||
ffmpeg_cmd: Optional pre-built ffmpeg command list (for m4b with cover art etc.).
|
||||
|
||||
Returns:
|
||||
AudioSink with write() and close() methods.
|
||||
"""
|
||||
fmt = fmt.lower()
|
||||
|
||||
if fmt in {"wav", "flac"}:
|
||||
import soundfile as sf
|
||||
|
||||
soundfile_obj = sf.SoundFile(
|
||||
path,
|
||||
mode="w",
|
||||
samplerate=SAMPLE_RATE,
|
||||
channels=1,
|
||||
format=fmt.upper(),
|
||||
)
|
||||
|
||||
def _write_wav(data: np.ndarray) -> None:
|
||||
if cancel_check and cancel_check():
|
||||
return
|
||||
soundfile_obj.write(data)
|
||||
|
||||
def _close_wav() -> None:
|
||||
soundfile_obj.close()
|
||||
|
||||
return AudioSink(write=_write_wav, close=_close_wav)
|
||||
|
||||
# Compressed formats: pipe through ffmpeg
|
||||
_ensure_ffmpeg()
|
||||
|
||||
if ffmpeg_cmd is not None:
|
||||
cmd = list(ffmpeg_cmd)
|
||||
else:
|
||||
cmd = build_ffmpeg_command(path, fmt, metadata=metadata)
|
||||
if extra_ffmpeg_args:
|
||||
cmd[2:2] = extra_ffmpeg_args
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
def _write_compressed(data: np.ndarray) -> None:
|
||||
if (cancel_check and cancel_check()) or process.stdin is None or process.stdin.closed:
|
||||
return
|
||||
process.stdin.write(data.tobytes())
|
||||
|
||||
def _close_compressed() -> None:
|
||||
if process.stdin and not process.stdin.closed:
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
|
||||
return AudioSink(write=_write_compressed, close=_close_compressed)
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Heuristics for classifying chapters as content vs. supplements.
|
||||
|
||||
A 'supplement' is any non-story material that a listener would typically
|
||||
skip: title page, copyright, table of contents, acknowledgements, etc.
|
||||
The scoring functions return a float; higher ⇒ more likely to be a
|
||||
supplement. ``should_preselect_chapter`` turns that score into a
|
||||
boolean suitable for a web form default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
# Compiled once at module load – these are immutable.
|
||||
|
||||
_SUPPLEMENT_TITLE_PATTERNS: List[Tuple[re.Pattern[str], float]] = [
|
||||
(re.compile(r"\btitle\s+page\b"), 3.0),
|
||||
(re.compile(r"\bcopyright\b"), 2.4),
|
||||
(re.compile(r"\btable\s+of\s+contents\b"), 2.8),
|
||||
(re.compile(r"\bcontents\b"), 2.0),
|
||||
(re.compile(r"\backnowledg(e)?ments?\b"), 2.0),
|
||||
(re.compile(r"\bdedication\b"), 2.0),
|
||||
(re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4),
|
||||
(re.compile(r"\balso\s+by\b"), 2.0),
|
||||
(re.compile(r"\bpraise\s+for\b"), 2.0),
|
||||
(re.compile(r"\bcolophon\b"), 2.2),
|
||||
(re.compile(r"\bpublication\s+data\b"), 2.2),
|
||||
(re.compile(r"\btranscriber'?s?\s+note\b"), 2.2),
|
||||
(re.compile(r"\bglossary\b"), 2.2),
|
||||
(re.compile(r"\bindex\b"), 2.0),
|
||||
(re.compile(r"\bbibliograph(y|ies)\b"), 2.0),
|
||||
(re.compile(r"\breferences\b"), 1.8),
|
||||
(re.compile(r"\bappendix\b"), 1.9),
|
||||
]
|
||||
|
||||
_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [
|
||||
re.compile(r"\bchapter\b"),
|
||||
re.compile(r"\bbook\b"),
|
||||
re.compile(r"\bpart\b"),
|
||||
re.compile(r"\bsection\b"),
|
||||
re.compile(r"\bscene\b"),
|
||||
re.compile(r"\bprologue\b"),
|
||||
re.compile(r"\bepilogue\b"),
|
||||
re.compile(r"\bintroduction\b"),
|
||||
re.compile(r"\bstory\b"),
|
||||
]
|
||||
|
||||
_SUPPLEMENT_TEXT_KEYWORDS: List[Tuple[str, float]] = [
|
||||
("copyright", 1.2),
|
||||
("all rights reserved", 1.1),
|
||||
("isbn", 0.9),
|
||||
("library of congress", 1.0),
|
||||
("table of contents", 1.0),
|
||||
("dedicated to", 0.8),
|
||||
("acknowledg", 0.8),
|
||||
("printed in", 0.6),
|
||||
("permission", 0.6),
|
||||
("publisher", 0.5),
|
||||
("praise for", 0.9),
|
||||
("also by", 0.9),
|
||||
("glossary", 0.8),
|
||||
("index", 0.8),
|
||||
("newsletter", 3.2),
|
||||
("mailing list", 2.6),
|
||||
("sign-up", 2.2),
|
||||
]
|
||||
|
||||
|
||||
def supplement_score(title: str, text: str, index: int) -> float:
|
||||
"""Return a score indicating how likely *title*/*text* is a supplement.
|
||||
|
||||
Higher values ⇒ more likely to be non-story material (title page,
|
||||
copyright, acknowledgements, etc.).
|
||||
"""
|
||||
normalized_title = (title or "").lower()
|
||||
score = 0.0
|
||||
|
||||
for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS:
|
||||
if pattern.search(normalized_title):
|
||||
score += weight
|
||||
|
||||
for pattern in _CONTENT_TITLE_PATTERNS:
|
||||
if pattern.search(normalized_title):
|
||||
score -= 2.0
|
||||
|
||||
stripped_text = (text or "").strip()
|
||||
length = len(stripped_text)
|
||||
if length <= 150:
|
||||
score += 0.9
|
||||
elif length <= 400:
|
||||
score += 0.6
|
||||
elif length <= 800:
|
||||
score += 0.35
|
||||
|
||||
lowercase_text = stripped_text.lower()
|
||||
for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS:
|
||||
if keyword in lowercase_text:
|
||||
score += weight
|
||||
|
||||
if index == 0 and score > 0:
|
||||
score += 0.25
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def should_preselect_chapter(
|
||||
title: str,
|
||||
text: str,
|
||||
index: int,
|
||||
total_count: int,
|
||||
) -> bool:
|
||||
"""Return True if the chapter should be *enabled* by default in the form.
|
||||
|
||||
A single chapter is always preselected. For multi-chapter books, the
|
||||
chapter is preselected when its supplement score is below 1.9.
|
||||
"""
|
||||
if total_count <= 1:
|
||||
return True
|
||||
score = supplement_score(title, text, index)
|
||||
return score < 1.9
|
||||
|
||||
|
||||
def ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None:
|
||||
"""Mutate *chapters* in-place so that at least one has ``enabled=True``."""
|
||||
if not chapters:
|
||||
return
|
||||
if any(chapter.get("enabled") for chapter in chapters):
|
||||
return
|
||||
best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0))
|
||||
chapters[best_index]["enabled"] = True
|
||||
@@ -1,92 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
from abogen.domain.voice_utils import coerce_truthy
|
||||
|
||||
|
||||
def apply_chapter_overrides(
|
||||
extracted: List[ExtractedChapter],
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Tuple[List[ExtractedChapter], Dict[str, str], List[str]]:
|
||||
if not overrides:
|
||||
return [], {}, []
|
||||
|
||||
selected: List[ExtractedChapter] = []
|
||||
metadata_updates: Dict[str, str] = {}
|
||||
diagnostics: List[str] = []
|
||||
|
||||
for position, payload in enumerate(overrides):
|
||||
if not isinstance(payload, dict):
|
||||
diagnostics.append(
|
||||
f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}."
|
||||
)
|
||||
continue
|
||||
|
||||
enabled = coerce_truthy(payload.get("enabled", True))
|
||||
payload["enabled"] = enabled
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
metadata_payload = payload.get("metadata") or {}
|
||||
if isinstance(metadata_payload, dict):
|
||||
for key, value in metadata_payload.items():
|
||||
if value is None:
|
||||
continue
|
||||
metadata_updates[str(key)] = str(value)
|
||||
|
||||
base: Optional[ExtractedChapter] = None
|
||||
idx_candidate = payload.get("index")
|
||||
idx_normalized: Optional[int] = None
|
||||
if isinstance(idx_candidate, int):
|
||||
idx_normalized = idx_candidate
|
||||
elif isinstance(idx_candidate, str):
|
||||
try:
|
||||
idx_normalized = int(idx_candidate)
|
||||
except ValueError:
|
||||
idx_normalized = None
|
||||
if idx_normalized is not None and 0 <= idx_normalized < len(extracted):
|
||||
base = extracted[idx_normalized]
|
||||
payload["index"] = idx_normalized
|
||||
|
||||
if base is None:
|
||||
source_title = payload.get("source_title")
|
||||
if isinstance(source_title, str):
|
||||
base = next((chapter for chapter in extracted if chapter.title == source_title), None)
|
||||
|
||||
if base is None:
|
||||
candidate_title = payload.get("title")
|
||||
if isinstance(candidate_title, str):
|
||||
base = next((chapter for chapter in extracted if chapter.title == candidate_title), None)
|
||||
|
||||
text_override = payload.get("text")
|
||||
if text_override is not None:
|
||||
text_value = str(text_override)
|
||||
elif base is not None:
|
||||
text_value = base.text
|
||||
else:
|
||||
diagnostics.append(
|
||||
f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found."
|
||||
)
|
||||
continue
|
||||
|
||||
title_override = payload.get("title")
|
||||
if title_override is not None:
|
||||
title_value = str(title_override)
|
||||
elif base is not None:
|
||||
title_value = base.title
|
||||
else:
|
||||
title_value = f"Chapter {position + 1}"
|
||||
|
||||
if base and not payload.get("source_title"):
|
||||
payload["source_title"] = base.title
|
||||
|
||||
payload["title"] = title_value
|
||||
payload["text"] = text_value
|
||||
payload["characters"] = len(text_value)
|
||||
payload.setdefault("order", payload.get("order", position))
|
||||
|
||||
selected.append(ExtractedChapter(title=title_value, text=text_value))
|
||||
|
||||
return selected, metadata_updates, diagnostics
|
||||
@@ -1,204 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
|
||||
_HEADING_NUMBER_PREFIX_RE = re.compile(
|
||||
r"^\s*(?P<number>(?:\d+|[ivxlcdm]+))(?P<suffix>(?:[\s.:;-].*)?)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACRONYM_ALLOWLIST = {
|
||||
"AI", "API", "CPU", "DIY", "GPU", "HTML", "HTTP", "HTTPS", "ID",
|
||||
"JSON", "MP3", "MP4", "M4B", "NASA", "OCR", "PDF", "SQL", "TV",
|
||||
"TTS", "UK", "UN", "UFO", "OK", "URL", "USA", "US", "VR",
|
||||
}
|
||||
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
|
||||
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
|
||||
|
||||
|
||||
def simplify_heading_text(text: str) -> str:
|
||||
raw = str(text or "").strip().lower()
|
||||
if not raw:
|
||||
return ""
|
||||
simplified = _HEADING_SANITIZE_RE.sub("", raw)
|
||||
if simplified.startswith("chapter"):
|
||||
simplified = simplified[7:]
|
||||
return simplified
|
||||
|
||||
|
||||
def headings_equivalent(left: str, right: str) -> bool:
|
||||
simple_left = simplify_heading_text(left)
|
||||
simple_right = simplify_heading_text(right)
|
||||
if not simple_left or not simple_right:
|
||||
return False
|
||||
if simple_left == simple_right:
|
||||
return True
|
||||
if simple_right.startswith(simple_left):
|
||||
return True
|
||||
if simple_left.startswith(simple_right):
|
||||
return True
|
||||
if len(simple_left) > 5 and simple_left in simple_right:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def strip_duplicate_heading_line(text: str, heading: str) -> Tuple[str, bool]:
|
||||
source_text = str(text or "")
|
||||
if not source_text:
|
||||
return source_text, False
|
||||
normalized_heading = simplify_heading_text(heading)
|
||||
if not normalized_heading:
|
||||
return source_text, False
|
||||
lines = source_text.splitlines()
|
||||
new_lines: List[str] = []
|
||||
removed = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not removed and stripped:
|
||||
if headings_equivalent(stripped, heading):
|
||||
removed = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if not removed:
|
||||
return source_text, False
|
||||
while new_lines and not new_lines[0].strip():
|
||||
new_lines.pop(0)
|
||||
return "\n".join(new_lines), True
|
||||
|
||||
|
||||
def normalize_caps_word(word: str) -> str:
|
||||
upper = word.upper()
|
||||
letters = [char for char in upper if char.isalpha()]
|
||||
if not letters:
|
||||
return word
|
||||
if upper in _ACRONYM_ALLOWLIST:
|
||||
return word
|
||||
if len(letters) <= 1:
|
||||
return word
|
||||
if all(char in _ROMAN_NUMERAL_CHARS for char in letters) and len(letters) <= 7:
|
||||
return word
|
||||
|
||||
parts = re.split(r"(['\-\u2019])", word)
|
||||
normalized_parts: List[str] = []
|
||||
for part in parts:
|
||||
if part in {"'", "-", "\u2019"}:
|
||||
normalized_parts.append(part)
|
||||
continue
|
||||
if not part:
|
||||
continue
|
||||
normalized_parts.append(part[0].upper() + part[1:].lower())
|
||||
return "".join(normalized_parts) or word
|
||||
|
||||
|
||||
def normalize_chapter_opening_caps(text: str) -> Tuple[str, bool]:
|
||||
if not text:
|
||||
return text, False
|
||||
|
||||
leading_len = len(text) - len(text.lstrip())
|
||||
leading = text[:leading_len]
|
||||
working = text[leading_len:]
|
||||
if not working:
|
||||
return text, False
|
||||
|
||||
builder: List[str] = []
|
||||
pos = 0
|
||||
changed = False
|
||||
|
||||
while pos < len(working):
|
||||
char = working[pos]
|
||||
if char in "\r\n":
|
||||
builder.append(working[pos:])
|
||||
pos = len(working)
|
||||
break
|
||||
if char.isspace():
|
||||
builder.append(char)
|
||||
pos += 1
|
||||
continue
|
||||
if char.islower():
|
||||
builder.append(working[pos:])
|
||||
pos = len(working)
|
||||
break
|
||||
if not char.isalpha():
|
||||
builder.append(char)
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
match = _CAPS_WORD_RE.match(working, pos)
|
||||
if not match:
|
||||
builder.append(char)
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
word = match.group(0)
|
||||
if any(ch.islower() for ch in word):
|
||||
builder.append(working[pos:])
|
||||
pos = len(working)
|
||||
break
|
||||
|
||||
normalized = normalize_caps_word(word)
|
||||
if normalized != word:
|
||||
changed = True
|
||||
builder.append(normalized)
|
||||
pos = match.end()
|
||||
|
||||
if pos < len(working):
|
||||
builder.append(working[pos:])
|
||||
|
||||
if not changed:
|
||||
return text, False
|
||||
|
||||
return leading + "".join(builder), True
|
||||
|
||||
|
||||
def format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str:
|
||||
base = str(title or "").strip()
|
||||
if not base:
|
||||
return f"Chapter {index}" if apply_prefix else ""
|
||||
if not apply_prefix:
|
||||
return base
|
||||
lowered = base.lower()
|
||||
if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()):
|
||||
return base
|
||||
match = _HEADING_NUMBER_PREFIX_RE.match(base)
|
||||
if match:
|
||||
number = match.group("number") or ""
|
||||
suffix = match.group("suffix") or ""
|
||||
cleaned_suffix = suffix.lstrip(" .,:;-_ \t\u2013\u2014\u00b7\u2022")
|
||||
if cleaned_suffix:
|
||||
return f"Chapter {number}. {cleaned_suffix}"
|
||||
return f"Chapter {number}"
|
||||
return base
|
||||
|
||||
|
||||
def apply_chapter_text_transforms(
|
||||
text: str,
|
||||
*,
|
||||
heading_text: str,
|
||||
raw_title: str,
|
||||
strip_heading: bool,
|
||||
normalize_caps: bool,
|
||||
) -> Tuple[str, bool, bool]:
|
||||
"""Strip duplicate heading and normalize opening caps.
|
||||
|
||||
Returns ``(text, heading_removed, caps_changed)``.
|
||||
The caller is responsible for state updates (pending flags, logging,
|
||||
dict mutation, ``continue``).
|
||||
"""
|
||||
heading_removed = False
|
||||
caps_changed = False
|
||||
|
||||
if strip_heading and heading_text:
|
||||
text, heading_removed = strip_duplicate_heading_line(text, heading_text)
|
||||
if not heading_removed and raw_title:
|
||||
match = _HEADING_NUMBER_PREFIX_RE.match(raw_title)
|
||||
if match:
|
||||
number = match.group("number")
|
||||
if number:
|
||||
text, heading_removed = strip_duplicate_heading_line(text, number)
|
||||
|
||||
if normalize_caps and text:
|
||||
text, caps_changed = normalize_chapter_opening_caps(text)
|
||||
|
||||
return text, heading_removed, caps_changed
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Chunk processing utilities.
|
||||
|
||||
Functions for grouping chunks, recording override usage, and selecting
|
||||
text for TTS synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
from abogen.pronunciation_store import increment_usage
|
||||
|
||||
|
||||
def safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:
|
||||
grouped: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for entry in chunks or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
chapter_index = int(entry.get("chapter_index", 0))
|
||||
except (TypeError, ValueError):
|
||||
chapter_index = 0
|
||||
grouped[chapter_index].append(dict(entry))
|
||||
|
||||
for chapter_index, items in grouped.items():
|
||||
items.sort(key=lambda payload: safe_int(payload.get("chunk_index")))
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def record_override_usage(
|
||||
job: Any,
|
||||
usage_counter: Mapping[str, int],
|
||||
token_map: Mapping[str, str],
|
||||
) -> None:
|
||||
if not usage_counter:
|
||||
return
|
||||
|
||||
language = getattr(job, "language", "") or "a"
|
||||
for normalized, amount in usage_counter.items():
|
||||
if amount <= 0:
|
||||
continue
|
||||
token_value = token_map.get(normalized, normalized)
|
||||
try:
|
||||
increment_usage(language=language, token=token_value, amount=int(amount))
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
job.add_log(f"Failed to record usage for override {token_value}", level="warning")
|
||||
|
||||
|
||||
def chunk_text_for_tts(entry: Mapping[str, Any]) -> str:
|
||||
"""Choose the best source text for synthesis.
|
||||
|
||||
We must prefer the raw chunk text (``text`` / ``original_text``) so
|
||||
manual/pronunciation overrides can match against the original tokens
|
||||
(e.g. censored words like ``Unfu*k``). ``normalized_text`` may have
|
||||
already been run through ``normalize_for_pipeline``, which can remove
|
||||
punctuation and prevent overrides from triggering.
|
||||
"""
|
||||
|
||||
if not isinstance(entry, Mapping):
|
||||
return ""
|
||||
return str(
|
||||
entry.get("text")
|
||||
or entry.get("original_text")
|
||||
or entry.get("normalized_text")
|
||||
or ""
|
||||
).strip()
|
||||
@@ -1,226 +0,0 @@
|
||||
"""Shared TTS iteration loop used by both WebUI and PyQt conversion runners.
|
||||
|
||||
The core pattern is identical across both UIs:
|
||||
|
||||
for seg in tts_segments(text, backend, voice, speed, split_pattern, current_time):
|
||||
check_cancel()
|
||||
update_progress(seg)
|
||||
write_audio(seg, sink)
|
||||
accumulate_subtitles(seg)
|
||||
|
||||
After the loop, the caller processes accumulated subtitle tokens.
|
||||
|
||||
This module provides ``run_tts_segment_loop`` which encapsulates that
|
||||
iteration, and ``synthesize_text`` which adds normalization on top —
|
||||
the single entry point both UIs should call for text-to-speech.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
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
|
||||
|
||||
|
||||
class CancelChecker(Protocol):
|
||||
"""Returns True if conversion has been cancelled."""
|
||||
def __call__(self) -> bool: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentStats:
|
||||
"""Running statistics updated per TTS segment."""
|
||||
processed_chars: int = 0
|
||||
current_time: float = 0.0
|
||||
etr_start_time: float = field(default_factory=time.time)
|
||||
total_characters: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentInfo:
|
||||
"""Read-only info about a TTS segment, passed to on_segment callback."""
|
||||
graphemes: str
|
||||
audio: Any
|
||||
tokens: list
|
||||
duration: float
|
||||
chunk_start: float
|
||||
|
||||
|
||||
def run_tts_segment_loop(
|
||||
*,
|
||||
text: str,
|
||||
params: SynthParams,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float,
|
||||
split_pattern: str,
|
||||
chapter_sink: Optional[AudioSink] = None,
|
||||
preview_callback: Optional[Callable[[str], None]] = None,
|
||||
on_segment: Optional[Callable[[SegmentInfo], None]] = None,
|
||||
) -> tuple[int, list]:
|
||||
"""Run the core TTS segment iteration loop.
|
||||
|
||||
Args:
|
||||
text: Normalized text to synthesize.
|
||||
params: Common synthesis parameters (stats, callbacks, sinks, etc.).
|
||||
backend: TTS pipeline instance (Kokoro or Supertonic).
|
||||
voice: Voice name/id for the backend.
|
||||
speed: Speech speed multiplier.
|
||||
split_pattern: Regex pattern used by the TTS engine for sentence splitting.
|
||||
preview_callback: Called with a short preview string per segment.
|
||||
on_segment: Called with a SegmentInfo for each segment *before*
|
||||
audio is written. Useful for callers that need per-segment
|
||||
subtitle processing (e.g. PyQt dual-writer pattern).
|
||||
When provided, the default subtitle accumulation is skipped.
|
||||
|
||||
Returns:
|
||||
Tuple of (segment_count, accumulated_subtitle_tokens).
|
||||
The caller is responsible for processing subtitle tokens via
|
||||
``process_subtitle_tokens`` and writing entries to subtitle writers.
|
||||
"""
|
||||
local_segments = 0
|
||||
accumulated_tokens: list[dict] = []
|
||||
|
||||
for seg in tts_segments(
|
||||
text,
|
||||
backend=backend,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
current_time=params.stats.current_time,
|
||||
):
|
||||
if params.check_cancel():
|
||||
break
|
||||
|
||||
local_segments += 1
|
||||
params.stats.processed_chars += len(seg.graphemes)
|
||||
|
||||
# Progress
|
||||
if params.stats.total_characters:
|
||||
percent = min(int(params.stats.processed_chars / params.stats.total_characters * 100), 99)
|
||||
else:
|
||||
percent = 0 if params.stats.processed_chars == 0 else 99
|
||||
|
||||
etr_str = calc_etr_str(
|
||||
time.time() - params.stats.etr_start_time,
|
||||
params.stats.processed_chars,
|
||||
params.stats.total_characters,
|
||||
)
|
||||
params.on_progress(percent, etr_str)
|
||||
|
||||
# Preview / log
|
||||
if preview_callback:
|
||||
preview_callback(seg.graphemes or "[silence]")
|
||||
|
||||
# Per-segment callback (for callers needing segment-level access)
|
||||
if on_segment:
|
||||
info = SegmentInfo(
|
||||
graphemes=seg.graphemes,
|
||||
audio=seg.audio,
|
||||
tokens=list(seg.tokens) if seg.tokens else [],
|
||||
duration=seg.duration,
|
||||
chunk_start=getattr(seg, "chunk_start", params.stats.current_time),
|
||||
)
|
||||
on_segment(info)
|
||||
|
||||
# Write audio
|
||||
if chapter_sink:
|
||||
chapter_sink.write(seg.audio)
|
||||
if params.audio_sink:
|
||||
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 != SubtitleMode.DISABLED and seg.tokens:
|
||||
accumulated_tokens.extend(seg.tokens)
|
||||
|
||||
# Update timing
|
||||
if params.audio_sink:
|
||||
params.stats.current_time += seg.duration
|
||||
|
||||
return local_segments, accumulated_tokens
|
||||
|
||||
|
||||
def process_and_write_subtitles(
|
||||
accumulated_tokens: list[dict],
|
||||
subtitle_writer: Any,
|
||||
*,
|
||||
subtitle_mode: str,
|
||||
max_subtitle_words: int,
|
||||
lang_code: str,
|
||||
use_spacy_segmentation: bool,
|
||||
fallback_end_time: float,
|
||||
) -> None:
|
||||
"""Process accumulated subtitle tokens and write entries to a subtitle writer.
|
||||
|
||||
This is the standard subtitle post-processing step shared by both UIs.
|
||||
"""
|
||||
if not accumulated_tokens or not subtitle_writer:
|
||||
return
|
||||
new_entries: list[tuple] = []
|
||||
process_subtitle_tokens(
|
||||
accumulated_tokens,
|
||||
new_entries,
|
||||
max_subtitle_words,
|
||||
subtitle_mode,
|
||||
lang_code,
|
||||
use_spacy_segmentation=use_spacy_segmentation,
|
||||
fallback_end_time=fallback_end_time,
|
||||
)
|
||||
for start, end, text in new_entries:
|
||||
subtitle_writer.write_entry(start=start, end=end, text=text)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthParams:
|
||||
"""Common parameters for synthesize_text calls.
|
||||
|
||||
Packed once by the executor to avoid repeating identical kwargs.
|
||||
When adding new common params, change only this dataclass.
|
||||
"""
|
||||
tts_context: TTSContext
|
||||
stats: SegmentStats
|
||||
check_cancel: CancelChecker
|
||||
on_progress: Callable[[int, str], None]
|
||||
audio_sink: Optional[AudioSink] = None
|
||||
subtitle_mode: str = "Disabled"
|
||||
max_subtitle_words: int = 50
|
||||
lang_code: str = "a"
|
||||
use_spacy_segmentation: bool = False
|
||||
|
||||
|
||||
def synthesize_text(
|
||||
*,
|
||||
text: str,
|
||||
params: SynthParams,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float,
|
||||
chapter_sink: Optional[AudioSink] = None,
|
||||
preview_callback: Optional[Callable[[str], None]] = None,
|
||||
on_segment: Optional[Callable[[SegmentInfo], None]] = None,
|
||||
split_pattern_override: Optional[str] = None,
|
||||
) -> tuple[int, list]:
|
||||
"""Normalize text and run TTS — the single entry point for both UIs.
|
||||
|
||||
Combines TTSContext.normalize() + run_tts_segment_loop() into one call.
|
||||
UI-specific concerns (provider resolution, progress display) stay in the UI.
|
||||
"""
|
||||
normalized = params.tts_context.normalize(text)
|
||||
return run_tts_segment_loop(
|
||||
text=normalized,
|
||||
params=params,
|
||||
backend=backend,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern_override or params.tts_context.split_pattern,
|
||||
chapter_sink=chapter_sink,
|
||||
preview_callback=preview_callback,
|
||||
on_segment=on_segment,
|
||||
)
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Shared TTS emission pipeline.
|
||||
|
||||
Provides the core TTS emission loop used by both WebUI and PyQt conversion runners.
|
||||
The caller handles audio I/O, progress reporting, and subtitle writing.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
from abogen.domain.audio_helpers import to_float32
|
||||
from abogen.domain.normalization import prepare_text_for_tts
|
||||
from abogen.domain.tokens import FakeToken
|
||||
from abogen.domain.audio_buffer import SAMPLE_RATE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentResult:
|
||||
"""One TTS segment emitted by the pipeline."""
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
duration: float
|
||||
chunk_start: float
|
||||
tokens: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def tts_segments(
|
||||
text: str,
|
||||
*,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float,
|
||||
split_pattern: str,
|
||||
current_time: float = 0.0,
|
||||
) -> Iterator[SegmentResult]:
|
||||
"""Invoke TTS backend on (already normalized) text and yield SegmentResults.
|
||||
|
||||
Use this when you've already normalized the text yourself (e.g. after
|
||||
spaCy sentence segmentation). For raw text, use emit_text_segments() instead.
|
||||
|
||||
Args:
|
||||
text: Already-normalized text to synthesize.
|
||||
backend: TTS pipeline callable.
|
||||
voice: Resolved voice.
|
||||
speed: TTS speed multiplier.
|
||||
split_pattern: Regex pattern for sentence splitting.
|
||||
current_time: Current position in the audio timeline (seconds).
|
||||
|
||||
Yields:
|
||||
SegmentResult for each non-empty TTS segment.
|
||||
"""
|
||||
segment_iter = backend(
|
||||
text,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
)
|
||||
|
||||
chunk_start = current_time
|
||||
|
||||
for segment in segment_iter:
|
||||
graphemes_raw = getattr(segment, "graphemes", "") or ""
|
||||
graphemes = graphemes_raw.strip()
|
||||
|
||||
audio = to_float32(getattr(segment, "audio", None))
|
||||
if audio.size == 0:
|
||||
continue
|
||||
|
||||
duration = len(audio) / SAMPLE_RATE
|
||||
|
||||
tokens_list = getattr(segment, "tokens", [])
|
||||
if not tokens_list and graphemes:
|
||||
tokens_list = [FakeToken(graphemes, 0, duration)]
|
||||
|
||||
tokens = [
|
||||
{
|
||||
"start": chunk_start + (tok.start_ts or 0),
|
||||
"end": chunk_start + (tok.end_ts or 0),
|
||||
"text": tok.text,
|
||||
"whitespace": tok.whitespace,
|
||||
}
|
||||
for tok in tokens_list
|
||||
]
|
||||
|
||||
yield SegmentResult(
|
||||
graphemes=graphemes,
|
||||
audio=audio,
|
||||
duration=duration,
|
||||
chunk_start=chunk_start,
|
||||
tokens=tokens,
|
||||
)
|
||||
|
||||
chunk_start += duration
|
||||
|
||||
|
||||
def emit_text_segments(
|
||||
text: str,
|
||||
*,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float,
|
||||
split_pattern: str,
|
||||
current_time: float = 0.0,
|
||||
# normalization
|
||||
heteronym_rules: Any = None,
|
||||
pronunciation_rules: Any = None,
|
||||
normalization_overrides: Any = None,
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
) -> Iterator[SegmentResult]:
|
||||
"""Normalize text and yield SegmentResults from the TTS backend.
|
||||
|
||||
This is the innermost TTS emission loop shared by both UIs. It handles:
|
||||
1. Text normalization (heteronym + pronunciation rules)
|
||||
2. TTS backend invocation
|
||||
3. Segment iteration with token extraction
|
||||
|
||||
The caller is responsible for:
|
||||
- Writing audio to sinks
|
||||
- Accumulating tokens for subtitle processing
|
||||
- Progress tracking and cancellation
|
||||
- Error handling
|
||||
|
||||
Args:
|
||||
text: Raw text to synthesize.
|
||||
backend: TTS pipeline callable (kokoro or supertonic).
|
||||
voice: Resolved voice for TTS.
|
||||
speed: TTS speed multiplier.
|
||||
split_pattern: Regex pattern for sentence splitting.
|
||||
current_time: Current position in the audio timeline (seconds).
|
||||
heteronym_rules: Compiled heteronym rules.
|
||||
pronunciation_rules: Compiled pronunciation rules.
|
||||
normalization_overrides: User normalization overrides.
|
||||
usage_counter: Counter for normalization statistics.
|
||||
|
||||
Yields:
|
||||
SegmentResult for each non-empty TTS segment.
|
||||
"""
|
||||
source_text = str(text or "")
|
||||
normalized = prepare_text_for_tts(
|
||||
source_text,
|
||||
heteronym_rules=heteronym_rules,
|
||||
pronunciation_rules=pronunciation_rules,
|
||||
normalization_overrides=normalization_overrides,
|
||||
usage_counter=usage_counter,
|
||||
)
|
||||
|
||||
yield from tts_segments(
|
||||
normalized,
|
||||
backend=backend,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
current_time=current_time,
|
||||
)
|
||||
|
||||
|
||||
def emit_text_to_sinks(
|
||||
text: str,
|
||||
*,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float,
|
||||
split_pattern: str,
|
||||
current_time: float = 0.0,
|
||||
# sinks
|
||||
audio_sink: Any = None,
|
||||
chapter_sink: Any = None,
|
||||
# subtitle
|
||||
subtitle_writer: Any = None,
|
||||
subtitle_mode: str = "Disabled",
|
||||
subtitle_lang: str = "a",
|
||||
max_subtitle_words: int = 50,
|
||||
use_spacy_segmentation: bool = True,
|
||||
# normalization
|
||||
heteronym_rules: Any = None,
|
||||
pronunciation_rules: Any = None,
|
||||
normalization_overrides: Any = None,
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
) -> tuple[int, float, List[Dict[str, Any]]]:
|
||||
"""Emit TTS audio for text, writing to sinks and collecting subtitle tokens.
|
||||
|
||||
Convenience wrapper around emit_text_segments() that handles audio writing
|
||||
and token accumulation. Returns stats for the caller to update progress.
|
||||
|
||||
Returns:
|
||||
Tuple of (segments_emitted, new_current_time, accumulated_tokens).
|
||||
"""
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
|
||||
segments_emitted = 0
|
||||
accumulated_tokens: List[Dict[str, Any]] = []
|
||||
|
||||
for seg in emit_text_segments(
|
||||
text,
|
||||
backend=backend,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=split_pattern,
|
||||
current_time=current_time,
|
||||
heteronym_rules=heteronym_rules,
|
||||
pronunciation_rules=pronunciation_rules,
|
||||
normalization_overrides=normalization_overrides,
|
||||
usage_counter=usage_counter,
|
||||
):
|
||||
segments_emitted += 1
|
||||
|
||||
# Write audio
|
||||
if chapter_sink:
|
||||
chapter_sink.write(seg.audio)
|
||||
if audio_sink:
|
||||
audio_sink.write(seg.audio)
|
||||
|
||||
# Collect tokens
|
||||
accumulated_tokens.extend(seg.tokens)
|
||||
|
||||
# Flush subtitle tokens
|
||||
if subtitle_writer and accumulated_tokens:
|
||||
_use_spacy = subtitle_mode not in (SubtitleMode.DISABLED, SubtitleMode.LINE)
|
||||
new_entries: List[tuple] = []
|
||||
process_subtitle_tokens(
|
||||
accumulated_tokens,
|
||||
new_entries,
|
||||
max_subtitle_words,
|
||||
subtitle_mode,
|
||||
subtitle_lang,
|
||||
use_spacy_segmentation=_use_spacy,
|
||||
fallback_end_time=current_time + sum(t["end"] - t["start"] for t in accumulated_tokens if accumulated_tokens),
|
||||
)
|
||||
for start, end, text_entry in new_entries:
|
||||
subtitle_writer.write_entry(start=start, end=end, text=text_entry)
|
||||
|
||||
new_time = current_time
|
||||
if accumulated_tokens:
|
||||
new_time = max(t["end"] for t in accumulated_tokens)
|
||||
|
||||
return segments_emitted, new_time, accumulated_tokens
|
||||
@@ -1,31 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform as _platform
|
||||
|
||||
|
||||
def select_device() -> str:
|
||||
"""Return the best available compute device (``"mps"``, ``"cuda"``, or ``"cpu"``).
|
||||
|
||||
Checks ``torch`` availability at runtime so this can be called from
|
||||
any context without requiring torch at import time.
|
||||
"""
|
||||
try:
|
||||
import torch # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
system = _platform.system()
|
||||
if system == "Darwin" and _platform.processor() == "arm":
|
||||
try:
|
||||
if torch.backends.mps.is_available(): # type: ignore[union-attr]
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available(): # type: ignore[union-attr]
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
@@ -1,179 +0,0 @@
|
||||
"""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_HIGHLIGHT = "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"
|
||||
VTT = "vtt"
|
||||
|
||||
@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]}")
|
||||
|
||||
|
||||
class Language(str, Enum):
|
||||
"""TTS language code (ISO 639-1 with region where needed).
|
||||
|
||||
Each engine (Kokoro, Supertonic) maps these to its own
|
||||
internal language identifiers.
|
||||
"""
|
||||
EN_US = "en-US"
|
||||
EN_GB = "en-GB"
|
||||
ES = "es"
|
||||
FR = "fr"
|
||||
HI = "hi"
|
||||
IT = "it"
|
||||
JA = "ja"
|
||||
PT_BR = "pt-BR"
|
||||
ZH = "zh"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Human-readable language name."""
|
||||
_names = {
|
||||
"en-US": "American English",
|
||||
"en-GB": "British English",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"hi": "Hindi",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
"zh": "Mandarin Chinese",
|
||||
}
|
||||
return _names[self.value]
|
||||
|
||||
@property
|
||||
def is_cjk(self) -> bool:
|
||||
"""True for CJK languages (Chinese, Japanese)."""
|
||||
return self in (self.ZH, self.JA)
|
||||
|
||||
@property
|
||||
def supports_subtitle_tokens(self) -> bool:
|
||||
"""True if this language generates timestamped tokens for subtitles."""
|
||||
return self in (self.EN_US, self.EN_GB)
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> Language:
|
||||
"""Parse from user input: ISO code, case-insensitive."""
|
||||
if isinstance(value, Language):
|
||||
return value
|
||||
normalized = value.strip()
|
||||
for member in cls:
|
||||
if member.value.lower() == normalized.lower():
|
||||
return member
|
||||
raise ValueError(f"Invalid Language: {value!r}. Valid: {[m.value for m in cls]}")
|
||||
@@ -1,136 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
|
||||
|
||||
_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500}
|
||||
_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160}
|
||||
_STRUCTURAL_KEYWORDS = (
|
||||
"preface",
|
||||
"prologue",
|
||||
"introduction",
|
||||
"foreword",
|
||||
"epilogue",
|
||||
"afterword",
|
||||
"appendix",
|
||||
"acknowledgment",
|
||||
"acknowledgement",
|
||||
)
|
||||
_STRUCTURAL_MIN_LENGTH = 120
|
||||
_MAX_SHORT_CHAPTERS = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChapterFilterResult:
|
||||
kept: List[ExtractedChapter]
|
||||
skipped: List[Tuple[str, int]]
|
||||
|
||||
|
||||
def infer_file_type(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".epub":
|
||||
return "epub"
|
||||
if suffix in {".md", ".markdown"}:
|
||||
return "markdown"
|
||||
if suffix == ".pdf":
|
||||
return "pdf"
|
||||
if suffix == ".txt":
|
||||
return "text"
|
||||
return suffix.lstrip(".") or "text"
|
||||
|
||||
|
||||
def looks_structural(title: str) -> bool:
|
||||
lowered = title.strip().lower()
|
||||
if not lowered:
|
||||
return False
|
||||
return any(keyword in lowered for keyword in _STRUCTURAL_KEYWORDS)
|
||||
|
||||
|
||||
def chapter_label(file_type: str) -> str:
|
||||
return "chapters" if file_type.lower() in {"epub", "markdown"} else "pages"
|
||||
|
||||
|
||||
def auto_select_relevant_chapters(
|
||||
chapters: List[ExtractedChapter],
|
||||
file_type: str,
|
||||
) -> ChapterFilterResult:
|
||||
if not chapters:
|
||||
return ChapterFilterResult(kept=[], skipped=[])
|
||||
|
||||
normalized = file_type.lower()
|
||||
threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(normalized, 0)
|
||||
min_short = _MIN_SHORT_CONTENT.get(normalized, 0)
|
||||
|
||||
kept: List[ExtractedChapter] = []
|
||||
skipped: List[Tuple[str, int]] = []
|
||||
short_kept = 0
|
||||
|
||||
for chapter in chapters:
|
||||
stripped = chapter.text.strip()
|
||||
length = len(stripped)
|
||||
if length == 0:
|
||||
skipped.append((chapter.title, length))
|
||||
continue
|
||||
|
||||
keep = False
|
||||
if threshold == 0:
|
||||
keep = True
|
||||
elif length >= threshold:
|
||||
keep = True
|
||||
elif not kept:
|
||||
keep = True
|
||||
elif min_short and length >= min_short and short_kept < _MAX_SHORT_CHAPTERS:
|
||||
keep = True
|
||||
short_kept += 1
|
||||
elif looks_structural(chapter.title) and length >= _STRUCTURAL_MIN_LENGTH:
|
||||
keep = True
|
||||
|
||||
if keep:
|
||||
kept.append(chapter)
|
||||
else:
|
||||
skipped.append((chapter.title, length))
|
||||
|
||||
if kept:
|
||||
return ChapterFilterResult(kept=kept, skipped=skipped)
|
||||
|
||||
longest_idx = None
|
||||
longest_length = 0
|
||||
for idx, chapter in enumerate(chapters):
|
||||
stripped = chapter.text.strip()
|
||||
if stripped and len(stripped) > longest_length:
|
||||
longest_length = len(stripped)
|
||||
longest_idx = idx
|
||||
|
||||
if longest_idx is not None:
|
||||
longest = chapters[longest_idx]
|
||||
fallback_skipped = [
|
||||
(chapter.title, len(chapter.text.strip()))
|
||||
for idx, chapter in enumerate(chapters)
|
||||
if idx != longest_idx and chapter.text.strip()
|
||||
]
|
||||
return ChapterFilterResult(kept=[longest], skipped=fallback_skipped)
|
||||
|
||||
return ChapterFilterResult(kept=[], skipped=skipped)
|
||||
|
||||
|
||||
def update_metadata_for_chapter_count(
|
||||
metadata: Dict[str, Any], count: int, file_type: str
|
||||
) -> None:
|
||||
if not metadata or count <= 0:
|
||||
return
|
||||
|
||||
label = "Chapters" if file_type.lower() in {"epub", "markdown"} else "Pages"
|
||||
metadata["chapter_count"] = str(count)
|
||||
|
||||
pattern = re.compile(r"\(\d+\s+(Chapters?|Pages?)\)")
|
||||
replacement = f"({count} {label})"
|
||||
for key in ("album", "ALBUM"):
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
metadata[key] = pattern.sub(replacement, value)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Intro/outro text building and voice resolution for audiobook conversion.
|
||||
|
||||
Both UIs (WebUI and Desktop) need to:
|
||||
1. Build intro/outro text from book metadata
|
||||
2. Resolve which voice to use for intro/outro synthesis
|
||||
|
||||
This module provides the shared domain logic. The actual TTS synthesis
|
||||
and audio writing remain UI-specific.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from abogen.domain.title_builder import build_title_intro_text, build_outro_text
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntroOutroSpec:
|
||||
"""Resolved intro or outro specification ready for TTS synthesis."""
|
||||
text: str
|
||||
voice_spec: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
def resolve_intro(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
original_filename: str,
|
||||
read_title_intro: bool,
|
||||
base_voice_spec: str,
|
||||
job_voice: str,
|
||||
voice_cache_keys: list[str],
|
||||
) -> IntroOutroSpec:
|
||||
"""Resolve the intro specification from job settings and metadata.
|
||||
|
||||
Returns an IntroOutroSpec with text and voice_spec populated,
|
||||
or enabled=False if intro is disabled or text cannot be built.
|
||||
"""
|
||||
if not read_title_intro:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
text = build_title_intro_text(metadata, original_filename)
|
||||
if not text:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
voice_spec = resolve_fallback_voice_spec(
|
||||
base_voice_spec, job_voice, voice_cache_keys
|
||||
)
|
||||
if not voice_spec:
|
||||
return IntroOutroSpec(text=text, voice_spec="", enabled=False)
|
||||
|
||||
return IntroOutroSpec(text=text, voice_spec=voice_spec, enabled=True)
|
||||
|
||||
|
||||
def resolve_outro(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
original_filename: str,
|
||||
read_closing_outro: bool,
|
||||
base_voice_spec: str,
|
||||
job_voice: str,
|
||||
voice_cache_keys: list[str],
|
||||
) -> IntroOutroSpec:
|
||||
"""Resolve the outro specification from job settings and metadata.
|
||||
|
||||
Returns an IntroOutroSpec with text and voice_spec populated,
|
||||
or enabled=False if outro is disabled or text cannot be built.
|
||||
"""
|
||||
if not read_closing_outro:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
text = build_outro_text(metadata, original_filename)
|
||||
if not text:
|
||||
return IntroOutroSpec(text="", voice_spec="", enabled=False)
|
||||
|
||||
voice_spec = resolve_fallback_voice_spec(
|
||||
base_voice_spec, job_voice, voice_cache_keys
|
||||
)
|
||||
if not voice_spec:
|
||||
return IntroOutroSpec(text=text, voice_spec="", enabled=False)
|
||||
|
||||
return IntroOutroSpec(text=text, voice_spec=voice_spec, enabled=True)
|
||||
@@ -1,504 +0,0 @@
|
||||
"""Metadata extraction and processing utilities.
|
||||
|
||||
This module provides functions for extracting metadata from text content,
|
||||
formatting metadata tags for TTS embedding, and generating ffmpeg metadata arguments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_metadata_from_text(text: str) -> Dict[str, Optional[str]]:
|
||||
"""Extract metadata tags from text content.
|
||||
|
||||
Looks for tags in format: <<METADATA_KEY:value>>
|
||||
|
||||
Supported tags:
|
||||
- TITLE, ARTIST, ALBUM, YEAR
|
||||
- ALBUM_ARTIST, COMPOSER, GENRE
|
||||
- COVER_PATH
|
||||
|
||||
Args:
|
||||
text: Text content to search for metadata tags.
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted metadata values (None if not found).
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
patterns = {
|
||||
"title": r"<<METADATA_TITLE:([^>]*)>>",
|
||||
"artist": r"<<METADATA_ARTIST:([^>]*)>>",
|
||||
"album": r"<<METADATA_ALBUM:([^>]*)>>",
|
||||
"year": r"<<METADATA_YEAR:([^>]*)>>",
|
||||
"album_artist": r"<<METADATA_ALBUM_ARTIST:([^>]*)>>",
|
||||
"composer": r"<<METADATA_COMPOSER:([^>]*)>>",
|
||||
"genre": r"<<METADATA_GENRE:([^>]*)>>",
|
||||
"cover_path": r"<<METADATA_COVER_PATH:([^>]*)>>",
|
||||
}
|
||||
|
||||
for key, pattern in patterns.items():
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
metadata[key] = match.group(1).strip()
|
||||
else:
|
||||
metadata[key] = None
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def get_filename_from_path(
|
||||
file_path: str,
|
||||
display_path: Optional[str] = None,
|
||||
from_queue: bool = False,
|
||||
) -> str:
|
||||
"""Extract filename (without extension) from path.
|
||||
|
||||
Args:
|
||||
file_path: The file path to extract from.
|
||||
display_path: Optional display path (used if from_queue is False).
|
||||
from_queue: Whether the file is from queue.
|
||||
|
||||
Returns:
|
||||
Filename without extension.
|
||||
"""
|
||||
if from_queue:
|
||||
base_path = file_path
|
||||
else:
|
||||
base_path = display_path if display_path else file_path
|
||||
|
||||
filename = os.path.splitext(os.path.basename(base_path))[0]
|
||||
return filename
|
||||
|
||||
|
||||
def build_ffmpeg_metadata_args(
|
||||
metadata: Dict[str, Optional[str]],
|
||||
filename: str,
|
||||
) -> List[str]:
|
||||
"""Build ffmpeg metadata arguments from metadata dictionary.
|
||||
|
||||
Args:
|
||||
metadata: Dictionary with metadata keys and values.
|
||||
filename: Fallback filename for title/album if not specified.
|
||||
|
||||
Returns:
|
||||
List of ffmpeg metadata arguments.
|
||||
"""
|
||||
args = []
|
||||
|
||||
# Default values
|
||||
defaults = {
|
||||
"title": filename,
|
||||
"artist": "Unknown",
|
||||
"album": filename,
|
||||
"date": str(datetime.datetime.now().year),
|
||||
"album_artist": "Unknown",
|
||||
"composer": "Narrator",
|
||||
"genre": "Audiobook",
|
||||
}
|
||||
|
||||
# Map of metadata keys to ffmpeg metadata keys
|
||||
key_mapping = {
|
||||
"title": "title",
|
||||
"artist": "artist",
|
||||
"album": "album",
|
||||
"year": "date", # year -> date for ffmpeg
|
||||
"album_artist": "album_artist",
|
||||
"composer": "composer",
|
||||
"genre": "genre",
|
||||
}
|
||||
|
||||
for metadata_key, ffmpeg_key in key_mapping.items():
|
||||
value = metadata.get(metadata_key)
|
||||
if value is None:
|
||||
value = defaults.get(metadata_key, "")
|
||||
if value:
|
||||
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def extract_metadata_and_build_args(
|
||||
text: str,
|
||||
filename: str,
|
||||
display_path: Optional[str] = None,
|
||||
from_queue: bool = False,
|
||||
) -> Tuple[List[str], Optional[str]]:
|
||||
"""Extract metadata from text and build ffmpeg arguments.
|
||||
|
||||
Convenience function that combines extract_metadata_from_text and
|
||||
build_ffmpeg_metadata_args.
|
||||
|
||||
Args:
|
||||
text: Text content to search for metadata tags.
|
||||
filename: Fallback filename for title/album.
|
||||
display_path: Optional display path.
|
||||
from_queue: Whether the file is from queue.
|
||||
|
||||
Returns:
|
||||
Tuple of (ffmpeg_metadata_args, cover_path).
|
||||
"""
|
||||
metadata = extract_metadata_from_text(text)
|
||||
cover_path = metadata.get("cover_path")
|
||||
|
||||
# Get actual filename from path
|
||||
actual_filename = get_filename_from_path(
|
||||
file_path=filename,
|
||||
display_path=display_path,
|
||||
from_queue=from_queue,
|
||||
)
|
||||
|
||||
args = build_ffmpeg_metadata_args(metadata, actual_filename)
|
||||
return args, cover_path
|
||||
|
||||
|
||||
def read_text_for_metadata(
|
||||
file_path: str,
|
||||
is_direct_text: bool,
|
||||
direct_text: Optional[str] = None,
|
||||
encoding: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Read text content for metadata extraction.
|
||||
|
||||
Args:
|
||||
file_path: Path to file (or text if is_direct_text).
|
||||
is_direct_text: Whether file_path contains direct text.
|
||||
direct_text: Optional direct text (used if is_direct_text).
|
||||
encoding: File encoding (detected if not provided).
|
||||
|
||||
Returns:
|
||||
Text content for metadata extraction.
|
||||
"""
|
||||
if is_direct_text:
|
||||
return direct_text or file_path
|
||||
|
||||
# Read from file
|
||||
actual_path = direct_text if direct_text else file_path
|
||||
|
||||
try:
|
||||
if encoding is None:
|
||||
from abogen.utils import detect_encoding
|
||||
encoding = detect_encoding(actual_path)
|
||||
|
||||
with open(actual_path, "r", encoding=encoding, errors="replace") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def extract_metadata_for_file(
|
||||
file_path: str,
|
||||
is_direct_text: bool = False,
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""Extract metadata dict from a file or direct text.
|
||||
|
||||
Convenience function combining read_text_for_metadata + extract_metadata_from_text.
|
||||
Returns empty dict on any error.
|
||||
"""
|
||||
try:
|
||||
text = read_text_for_metadata(
|
||||
file_path=file_path,
|
||||
is_direct_text=is_direct_text,
|
||||
direct_text=file_path if is_direct_text else None,
|
||||
)
|
||||
if text:
|
||||
return extract_metadata_from_text(text) or {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def format_metadata_tags(
|
||||
metadata: Dict[str, Any],
|
||||
filename: str,
|
||||
chapter_count: int,
|
||||
file_type: str,
|
||||
cover_bytes: Optional[bytes] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Format metadata tags for insertion into TTS text.
|
||||
|
||||
Builds <<METADATA_KEY:value>> tags that are later parsed by
|
||||
extract_metadata_from_text() and fed to ffmpeg.
|
||||
|
||||
Args:
|
||||
metadata: Dict with keys like 'title', 'authors' (list),
|
||||
'publication_year', 'description', 'cover_image' (bytes).
|
||||
filename: Fallback filename (without extension) for title/album.
|
||||
chapter_count: Number of chapters/pages.
|
||||
file_type: 'epub', 'pdf', or 'markdown'.
|
||||
cover_bytes: Optional cover image bytes to save to cache.
|
||||
cache_dir: Directory for cover cache (uses default if None).
|
||||
|
||||
Returns:
|
||||
Newline-joined string of <<METADATA_KEY:value>> tags.
|
||||
"""
|
||||
title = metadata.get("title") or filename
|
||||
authors = metadata.get("authors") or ["Unknown"]
|
||||
authors_text = ", ".join(authors) if isinstance(authors, list) else str(authors)
|
||||
year = metadata.get("publication_year") or str(datetime.datetime.now().year)
|
||||
|
||||
chapter_label = "Chapters" if file_type in ("epub", "markdown") else "Pages"
|
||||
chapter_text = f"{chapter_count} {chapter_label}"
|
||||
|
||||
tags = [
|
||||
f"<<METADATA_TITLE:{title}>>",
|
||||
f"<<METADATA_ARTIST:{authors_text}>>",
|
||||
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
|
||||
f"<<METADATA_YEAR:{year}>>",
|
||||
f"<<METADATA_ALBUM_ARTIST:{authors_text}>>",
|
||||
f"<<METADATA_COMPOSER:Narrator>>",
|
||||
f"<<METADATA_GENRE:Audiobook>>",
|
||||
]
|
||||
|
||||
cover_path = _save_cover_to_cache(cover_bytes, cache_dir)
|
||||
if cover_path:
|
||||
tags.append(f"<<METADATA_COVER_PATH:{cover_path}>>")
|
||||
|
||||
return "\n".join(tags)
|
||||
|
||||
|
||||
def _save_cover_to_cache(
|
||||
cover_bytes: Optional[bytes],
|
||||
cache_dir: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Save cover image bytes to cache directory.
|
||||
|
||||
Args:
|
||||
cover_bytes: Raw image bytes (e.g. JPEG/PNG).
|
||||
cache_dir: Directory to save to. If None, returns None.
|
||||
|
||||
Returns:
|
||||
Normalized path to saved cover file, or None on failure.
|
||||
"""
|
||||
if not cover_bytes:
|
||||
return None
|
||||
if cache_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
|
||||
cover_path = os.path.normpath(cover_path)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(cover_bytes)
|
||||
return cover_path
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save cover image: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def extract_book_metadata_epub(book: Any) -> Dict[str, Any]:
|
||||
"""Extract metadata from an opened ebooklib EPUB book.
|
||||
|
||||
Args:
|
||||
book: An opened ebooklib EPUB book object.
|
||||
|
||||
Returns:
|
||||
Dict with keys: title, authors, description, publisher,
|
||||
publication_year, cover_image (bytes or None).
|
||||
"""
|
||||
import ebooklib
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"title": None,
|
||||
"authors": [],
|
||||
"description": None,
|
||||
"cover_image": None,
|
||||
"publisher": None,
|
||||
"publication_year": None,
|
||||
}
|
||||
|
||||
try:
|
||||
title_items = book.get_metadata("DC", "title")
|
||||
if title_items and len(title_items) > 0:
|
||||
metadata["title"] = title_items[0][0]
|
||||
except Exception as e:
|
||||
logger.warning("Error extracting title metadata: %s", e)
|
||||
|
||||
try:
|
||||
author_items = book.get_metadata("DC", "creator")
|
||||
if author_items:
|
||||
metadata["authors"] = [
|
||||
author[0] for author in author_items if len(author) > 0
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning("Error extracting author metadata: %s", e)
|
||||
|
||||
try:
|
||||
desc_items = book.get_metadata("DC", "description")
|
||||
if desc_items and len(desc_items) > 0:
|
||||
metadata["description"] = desc_items[0][0]
|
||||
except Exception as e:
|
||||
logger.warning("Error extracting description metadata: %s", e)
|
||||
|
||||
try:
|
||||
publisher_items = book.get_metadata("DC", "publisher")
|
||||
if publisher_items and len(publisher_items) > 0:
|
||||
metadata["publisher"] = publisher_items[0][0]
|
||||
except Exception as e:
|
||||
logger.warning("Error extracting publisher metadata: %s", e)
|
||||
|
||||
try:
|
||||
date_items = book.get_metadata("DC", "date")
|
||||
if date_items and len(date_items) > 0:
|
||||
date_str = date_items[0][0]
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(0)
|
||||
else:
|
||||
metadata["publication_year"] = date_str
|
||||
except Exception as e:
|
||||
logger.warning("Error extracting publication date metadata: %s", e)
|
||||
|
||||
for item in book.get_items_of_type(ebooklib.ITEM_COVER):
|
||||
metadata["cover_image"] = item.get_content()
|
||||
break
|
||||
|
||||
if not metadata["cover_image"]:
|
||||
for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
|
||||
if "cover" in item.get_name().lower():
|
||||
metadata["cover_image"] = item.get_content()
|
||||
break
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def extract_book_metadata_pdf(pdf_doc: Any) -> Dict[str, Any]:
|
||||
"""Extract metadata from an opened PyMuPDF document.
|
||||
|
||||
Args:
|
||||
pdf_doc: An opened fitz.Document object.
|
||||
|
||||
Returns:
|
||||
Dict with keys: title, authors, description, publisher,
|
||||
publication_year, cover_image (bytes or None).
|
||||
"""
|
||||
metadata: Dict[str, Any] = {
|
||||
"title": None,
|
||||
"authors": [],
|
||||
"description": None,
|
||||
"cover_image": None,
|
||||
"publisher": None,
|
||||
"publication_year": None,
|
||||
}
|
||||
|
||||
pdf_info = pdf_doc.metadata
|
||||
if pdf_info:
|
||||
metadata["title"] = pdf_info.get("title", None)
|
||||
author = pdf_info.get("author", None)
|
||||
if author:
|
||||
metadata["authors"] = [author]
|
||||
metadata["description"] = pdf_info.get("subject", None)
|
||||
keywords = pdf_info.get("keywords", None)
|
||||
if keywords:
|
||||
if metadata["description"]:
|
||||
metadata["description"] += f"\n\nKeywords: {keywords}"
|
||||
else:
|
||||
metadata["description"] = f"Keywords: {keywords}"
|
||||
metadata["publisher"] = pdf_info.get("creator", None)
|
||||
|
||||
if "creationDate" in pdf_info:
|
||||
date_str = pdf_info["creationDate"]
|
||||
year_match = re.search(r"D:(\d{4})", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(1)
|
||||
elif "modDate" in pdf_info:
|
||||
date_str = pdf_info["modDate"]
|
||||
year_match = re.search(r"D:(\d{4})", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(1)
|
||||
|
||||
if len(pdf_doc) > 0:
|
||||
try:
|
||||
import fitz
|
||||
pix = pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||
metadata["cover_image"] = pix.tobytes("png")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def extract_book_metadata_markdown(
|
||||
markdown_text: str,
|
||||
markdown_toc: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract metadata from markdown frontmatter and first heading.
|
||||
|
||||
Args:
|
||||
markdown_text: Raw markdown text content.
|
||||
markdown_toc: Optional table of contents list (each item has
|
||||
'level' and 'name' keys).
|
||||
|
||||
Returns:
|
||||
Dict with keys: title, authors, description, publication_year.
|
||||
cover_image is always None for markdown.
|
||||
"""
|
||||
metadata: Dict[str, Any] = {
|
||||
"title": None,
|
||||
"authors": [],
|
||||
"description": None,
|
||||
"cover_image": None,
|
||||
"publisher": None,
|
||||
"publication_year": None,
|
||||
}
|
||||
|
||||
if not markdown_text:
|
||||
return metadata
|
||||
|
||||
frontmatter_match = re.match(
|
||||
r"^---\s*\n(.*?)\n---\s*\n", markdown_text, re.DOTALL
|
||||
)
|
||||
if frontmatter_match:
|
||||
try:
|
||||
frontmatter = frontmatter_match.group(1)
|
||||
title_match = re.search(
|
||||
r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
if title_match:
|
||||
metadata["title"] = title_match.group(1).strip().strip("\"'")
|
||||
|
||||
author_match = re.search(
|
||||
r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
if author_match:
|
||||
metadata["authors"] = [
|
||||
author_match.group(1).strip().strip("\"'")
|
||||
]
|
||||
|
||||
desc_match = re.search(
|
||||
r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
if desc_match:
|
||||
metadata["description"] = (
|
||||
desc_match.group(1).strip().strip("\"'")
|
||||
)
|
||||
|
||||
date_match = re.search(
|
||||
r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
if date_match:
|
||||
date_str = date_match.group(1).strip().strip("\"'")
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(0)
|
||||
except Exception as e:
|
||||
logger.warning("Error parsing markdown frontmatter: %s", e)
|
||||
|
||||
if not metadata["title"] and markdown_toc:
|
||||
first_h1 = next(
|
||||
(h for h in markdown_toc if h.get("level") == 1), None
|
||||
)
|
||||
if first_h1:
|
||||
metadata["title"] = first_h1.get("name")
|
||||
|
||||
return metadata
|
||||
@@ -1,405 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
|
||||
_SERIES_NAME_KEYS = (
|
||||
"series",
|
||||
"series_name",
|
||||
"series_title",
|
||||
)
|
||||
_SERIES_NUMBER_KEYS = (
|
||||
"series_index",
|
||||
"series_position",
|
||||
"series_sequence",
|
||||
"book_number",
|
||||
"series_number",
|
||||
)
|
||||
_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
|
||||
|
||||
|
||||
def normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
||||
normalized: Dict[str, str] = {}
|
||||
if not values:
|
||||
return normalized
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
normalized[str(key).casefold()] = text
|
||||
return normalized
|
||||
|
||||
|
||||
def format_author_sentence(raw: Optional[str]) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
normalized = str(raw).strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
lowered = normalized.casefold()
|
||||
if lowered in {"unknown", "various"}:
|
||||
return ""
|
||||
|
||||
working = normalized.replace("&", " and ")
|
||||
segments = [segment.strip() for segment in working.split(",") if segment.strip()]
|
||||
tokens: List[str] = []
|
||||
|
||||
if segments:
|
||||
for segment in segments:
|
||||
parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()]
|
||||
if parts:
|
||||
tokens.extend(parts)
|
||||
else:
|
||||
tokens.append(segment)
|
||||
else:
|
||||
parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()]
|
||||
tokens.extend(parts or [normalized])
|
||||
|
||||
cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}]
|
||||
if not cleaned:
|
||||
return ""
|
||||
if len(cleaned) == 1:
|
||||
return f"By {cleaned[0]}"
|
||||
if len(cleaned) == 2:
|
||||
return f"By {cleaned[0]} and {cleaned[1]}"
|
||||
return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}"
|
||||
|
||||
|
||||
def ensure_sentence(text: str) -> str:
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
if cleaned[-1] in ".!?":
|
||||
return cleaned
|
||||
return f"{cleaned}."
|
||||
|
||||
|
||||
def normalize_series_number(value: Any) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
candidate = text.replace(",", ".")
|
||||
if candidate.replace(".", "", 1).isdigit():
|
||||
if "." in candidate:
|
||||
normalized = candidate.rstrip("0").rstrip(".")
|
||||
return normalized or "0"
|
||||
try:
|
||||
return str(int(candidate))
|
||||
except ValueError:
|
||||
pass
|
||||
match = _SERIES_NUMBER_RE.search(candidate)
|
||||
if not match:
|
||||
return None
|
||||
normalized = match.group(0)
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
return normalized or "0"
|
||||
try:
|
||||
return str(int(normalized))
|
||||
except ValueError:
|
||||
return normalized
|
||||
|
||||
|
||||
def extract_series_metadata(values: Mapping[str, str]) -> Tuple[Optional[str], Optional[str]]:
|
||||
series_name: Optional[str] = None
|
||||
for key in _SERIES_NAME_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw:
|
||||
cleaned = str(raw).strip()
|
||||
if cleaned:
|
||||
series_name = cleaned
|
||||
break
|
||||
|
||||
series_number: Optional[str] = None
|
||||
for key in _SERIES_NUMBER_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
normalized = normalize_series_number(raw)
|
||||
if normalized:
|
||||
series_number = normalized
|
||||
break
|
||||
|
||||
return series_name, series_number
|
||||
|
||||
|
||||
def format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str:
|
||||
if not series_name or not series_number:
|
||||
return ""
|
||||
name = series_name.strip()
|
||||
number = series_number.strip()
|
||||
if not name or not number:
|
||||
return ""
|
||||
article = "the " if not name.lower().startswith("the ") else ""
|
||||
phrase = f"Book {number} of {article}{name}"
|
||||
return re.sub(r"\s+", " ", phrase).strip()
|
||||
|
||||
|
||||
_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE)
|
||||
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
|
||||
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
|
||||
"series_index",
|
||||
"series_position",
|
||||
"series_sequence",
|
||||
"series_number",
|
||||
"seriesnumber",
|
||||
"book_number",
|
||||
"booknumber",
|
||||
)
|
||||
|
||||
|
||||
def normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {}
|
||||
if not values:
|
||||
return normalized
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
key_text = str(key).strip().lower()
|
||||
if not key_text:
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
normalized[key_text] = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
normalized[key_text] = text
|
||||
return normalized
|
||||
|
||||
|
||||
def split_people_field(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
results: List[str] = []
|
||||
for item in raw:
|
||||
results.extend(split_people_field(item))
|
||||
return results
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()]
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for token in tokens:
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ordered.append(token)
|
||||
return ordered
|
||||
|
||||
|
||||
def split_simple_list(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
results: List[str] = []
|
||||
for item in raw:
|
||||
results.extend(split_simple_list(item))
|
||||
return results
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()]
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for token in tokens:
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ordered.append(token)
|
||||
return ordered
|
||||
|
||||
|
||||
def first_nonempty(*values: Any) -> Optional[str]:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
if not items:
|
||||
continue
|
||||
value = items[0]
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def extract_year(raw: Optional[str]) -> Optional[int]:
|
||||
if not raw:
|
||||
return None
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
match = re.search(r"(19|20)\d{2}", text)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(0))
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
parsed = int(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if 0 < parsed < 3000:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def normalize_series_sequence(raw: Any) -> Optional[str]:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (int, float)):
|
||||
if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)):
|
||||
return None
|
||||
text = str(raw)
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
candidate = text.replace(",", ".")
|
||||
match = _SERIES_NUMBER_RE.search(candidate)
|
||||
if not match:
|
||||
return None
|
||||
normalized = match.group(0)
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
if not normalized:
|
||||
normalized = "0"
|
||||
return normalized
|
||||
try:
|
||||
return str(int(normalized))
|
||||
except ValueError:
|
||||
cleaned = normalized.lstrip("0")
|
||||
return cleaned or "0"
|
||||
|
||||
|
||||
def build_audiobookshelf_metadata(
|
||||
tags: Mapping[str, Any],
|
||||
*,
|
||||
language: str = "",
|
||||
filename: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
normalized = normalize_metadata_casefold(tags)
|
||||
title = first_nonempty(
|
||||
normalized.get("title"),
|
||||
normalized.get("book_title"),
|
||||
normalized.get("name"),
|
||||
normalized.get("album"),
|
||||
filename,
|
||||
)
|
||||
authors = split_people_field(
|
||||
normalized.get("authors")
|
||||
or normalized.get("author")
|
||||
or normalized.get("album_artist")
|
||||
or normalized.get("artist")
|
||||
)
|
||||
narrators = split_people_field(normalized.get("narrators") or normalized.get("narrator"))
|
||||
description = first_nonempty(
|
||||
normalized.get("description"), normalized.get("summary"), normalized.get("comment")
|
||||
)
|
||||
genres = split_simple_list(normalized.get("genre"))
|
||||
keywords = split_simple_list(normalized.get("tags") or normalized.get("keywords"))
|
||||
lang = first_nonempty(normalized.get("language"), normalized.get("lang")) or language or ""
|
||||
series_name = first_nonempty(
|
||||
normalized.get("series"),
|
||||
normalized.get("series_name"),
|
||||
normalized.get("seriesname"),
|
||||
normalized.get("series_title"),
|
||||
normalized.get("seriestitle"),
|
||||
)
|
||||
|
||||
series_sequence = None
|
||||
for key in _SERIES_SEQUENCE_TAG_KEYS:
|
||||
raw_value = normalized.get(key)
|
||||
seq = normalize_series_sequence(raw_value)
|
||||
if seq:
|
||||
series_sequence = seq
|
||||
break
|
||||
if not series_name:
|
||||
series_sequence = None
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"title": title,
|
||||
"subtitle": normalized.get("subtitle"),
|
||||
"authors": authors,
|
||||
"narrators": narrators,
|
||||
"description": description,
|
||||
"publisher": normalized.get("publisher"),
|
||||
"genres": genres,
|
||||
"tags": keywords,
|
||||
"language": lang,
|
||||
"publishedYear": extract_year(
|
||||
normalized.get("published")
|
||||
or normalized.get("publication_year")
|
||||
or normalized.get("date")
|
||||
or normalized.get("year")
|
||||
),
|
||||
"seriesName": series_name,
|
||||
"seriesSequence": series_sequence,
|
||||
"isbn": first_nonempty(normalized.get("isbn"), normalized.get("asin")),
|
||||
}
|
||||
published_date = first_nonempty(
|
||||
normalized.get("published"), normalized.get("publication_date"), normalized.get("date")
|
||||
)
|
||||
if published_date:
|
||||
data["publishedDate"] = published_date
|
||||
|
||||
rating_text = first_nonempty(normalized.get("rating"), normalized.get("my_rating"))
|
||||
if rating_text:
|
||||
try:
|
||||
data["rating"] = float(str(rating_text).strip())
|
||||
except ValueError:
|
||||
pass
|
||||
rating_max_text = first_nonempty(
|
||||
normalized.get("rating_max"), normalized.get("rating_scale")
|
||||
)
|
||||
if rating_max_text:
|
||||
try:
|
||||
data["ratingMax"] = float(str(rating_max_text).strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
cleaned: Dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
if isinstance(value, (list, tuple)) and not value:
|
||||
continue
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
|
||||
def load_audiobookshelf_chapters(
|
||||
metadata_path: Path,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
chapters = payload.get("chapters")
|
||||
if not isinstance(chapters, list):
|
||||
return None
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for entry in chapters:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
title = first_nonempty(entry.get("title"), entry.get("original_title"))
|
||||
start = entry.get("start")
|
||||
end = entry.get("end")
|
||||
if title and start is not None and end is not None:
|
||||
cleaned.append({"title": str(title), "start": start, "end": end})
|
||||
return cleaned or None
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def merge_metadata(
|
||||
extracted: Optional[Dict[str, Any]],
|
||||
overrides: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, str]:
|
||||
merged: Dict[str, str] = {}
|
||||
if extracted:
|
||||
for key, value in extracted.items():
|
||||
if value is None:
|
||||
continue
|
||||
merged[str(key)] = str(value)
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
key_str = str(key)
|
||||
if value is None:
|
||||
merged.pop(key_str, None)
|
||||
else:
|
||||
merged[key_str] = str(value)
|
||||
return merged
|
||||
@@ -1,99 +0,0 @@
|
||||
"""OPDS metadata normalization.
|
||||
|
||||
Normalizes metadata keys from various OPDS/Calibre sources into
|
||||
a canonical set of overrides for the audiobook conversion pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Mapping
|
||||
|
||||
|
||||
def normalize_opds_metadata(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize OPDS/Calibre metadata into canonical override keys.
|
||||
|
||||
Takes a metadata payload with various key aliases (e.g. 'series'/'series_name',
|
||||
'tags'/'keywords', 'authors'/'creator') and returns a dict with canonical
|
||||
keys set.
|
||||
|
||||
Args:
|
||||
metadata_payload: Raw metadata dict from OPDS/Calibre import.
|
||||
|
||||
Returns:
|
||||
Dict with canonical metadata keys (series, series_index, tags,
|
||||
description, subtitle, publisher, authors).
|
||||
"""
|
||||
metadata_overrides: Dict[str, Any] = {}
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
parts = [str(item).strip() for item in value if item is not None]
|
||||
return ", ".join(part for part in parts if part)
|
||||
return str(value).strip()
|
||||
|
||||
raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
|
||||
series_name = str(raw_series or "").strip()
|
||||
if series_name:
|
||||
metadata_overrides["series"] = series_name
|
||||
metadata_overrides.setdefault("series_name", series_name)
|
||||
|
||||
series_index_value = (
|
||||
metadata_payload.get("series_index")
|
||||
or metadata_payload.get("series_position")
|
||||
or metadata_payload.get("series_sequence")
|
||||
or metadata_payload.get("book_number")
|
||||
)
|
||||
if series_index_value is not None:
|
||||
series_index_text = str(series_index_value).strip()
|
||||
if series_index_text:
|
||||
metadata_overrides.setdefault("series_index", series_index_text)
|
||||
metadata_overrides.setdefault("series_position", series_index_text)
|
||||
metadata_overrides.setdefault("series_sequence", series_index_text)
|
||||
metadata_overrides.setdefault("book_number", series_index_text)
|
||||
|
||||
tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
|
||||
if tags_value:
|
||||
tags_text = _stringify(tags_value)
|
||||
if tags_text:
|
||||
metadata_overrides.setdefault("tags", tags_text)
|
||||
metadata_overrides.setdefault("keywords", tags_text)
|
||||
metadata_overrides.setdefault("genre", tags_text)
|
||||
|
||||
description_value = metadata_payload.get("description") or metadata_payload.get("summary")
|
||||
if description_value:
|
||||
description_text = _stringify(description_value)
|
||||
if description_text:
|
||||
metadata_overrides.setdefault("description", description_text)
|
||||
metadata_overrides.setdefault("summary", description_text)
|
||||
|
||||
subtitle_value = (
|
||||
metadata_payload.get("subtitle")
|
||||
or metadata_payload.get("sub_title")
|
||||
or metadata_payload.get("calibre_subtitle")
|
||||
)
|
||||
if subtitle_value:
|
||||
subtitle_text = _stringify(subtitle_value)
|
||||
if subtitle_text:
|
||||
metadata_overrides.setdefault("subtitle", subtitle_text)
|
||||
|
||||
publisher_value = metadata_payload.get("publisher")
|
||||
if publisher_value:
|
||||
publisher_text = _stringify(publisher_value)
|
||||
if publisher_text:
|
||||
metadata_overrides.setdefault("publisher", publisher_text)
|
||||
|
||||
authors_value = (
|
||||
metadata_payload.get("authors")
|
||||
or metadata_payload.get("author")
|
||||
or metadata_payload.get("creator")
|
||||
or metadata_payload.get("dc_creator")
|
||||
)
|
||||
if authors_value:
|
||||
authors_text = _stringify(authors_value)
|
||||
if authors_text:
|
||||
metadata_overrides.setdefault("authors", authors_text)
|
||||
metadata_overrides.setdefault("author", authors_text)
|
||||
|
||||
return metadata_overrides
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Text normalization convenience helpers.
|
||||
|
||||
Provides both the simple ``normalize_text_for_pipeline`` (apostrophe + LLM only)
|
||||
and the comprehensive ``prepare_text_for_tts`` that chains all three normalization
|
||||
stages used during conversion: heteronym rules → pronunciation rules → pipeline
|
||||
normalization. The latter is the single entry point that both the Web UI and
|
||||
PyQt Desktop GUI should use.
|
||||
|
||||
Also provides ``TTSContext`` — a dataclass bundling all pre-compiled normalization
|
||||
resources so they can be created once and passed as a single object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from abogen.kokoro_text_normalization import (
|
||||
ApostropheConfig,
|
||||
normalize_for_pipeline as _normalize_for_pipeline,
|
||||
)
|
||||
from abogen.normalization_settings import (
|
||||
build_apostrophe_config,
|
||||
get_runtime_settings,
|
||||
apply_overrides as _apply_overrides,
|
||||
)
|
||||
|
||||
_BASE_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TTSContext:
|
||||
"""Bundles pre-compiled normalization resources for TTS processing.
|
||||
|
||||
Created once per conversion job and passed to ``prepare_text_for_tts``
|
||||
instead of threading 5 separate parameters.
|
||||
"""
|
||||
|
||||
split_pattern: str = r"(?<=[.!?\-])\s+"
|
||||
pronunciation_rules: Optional[List[Dict[str, Any]]] = None
|
||||
heteronym_rules: Optional[List[Dict[str, Any]]] = None
|
||||
normalization_overrides: Optional[Mapping[str, Any]] = None
|
||||
usage_counter: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def normalize(self, text: str) -> str:
|
||||
"""Shorthand: normalize text using this context's compiled rules."""
|
||||
return prepare_text_for_tts(
|
||||
text,
|
||||
heteronym_rules=self.heteronym_rules,
|
||||
pronunciation_rules=self.pronunciation_rules,
|
||||
normalization_overrides=self.normalization_overrides,
|
||||
usage_counter=self.usage_counter,
|
||||
)
|
||||
|
||||
|
||||
def normalize_text_for_pipeline(
|
||||
text: str,
|
||||
*,
|
||||
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Normalize text using runtime settings with optional overrides."""
|
||||
runtime_settings = get_runtime_settings()
|
||||
if normalization_overrides:
|
||||
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
||||
apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_BASE_APOSTROPHE_CONFIG)
|
||||
return _normalize_for_pipeline(text, config=apostrophe_config, settings=runtime_settings)
|
||||
|
||||
|
||||
def prepare_text_for_tts(
|
||||
text: str,
|
||||
*,
|
||||
heteronym_rules: Optional[List[Dict[str, Any]]] = None,
|
||||
pronunciation_rules: Optional[List[Dict[str, Any]]] = None,
|
||||
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
"""Apply the full text normalization pipeline before TTS synthesis.
|
||||
|
||||
Chains three stages in order:
|
||||
1. Heteronym sentence rules (context-dependent pronunciation)
|
||||
2. Pronunciation rules (token-level replacements)
|
||||
3. Pipeline normalization (apostrophe handling, LLM normalization)
|
||||
|
||||
This is the **single entry point** that both the Web UI conversion runner
|
||||
and the PyQt conversion thread should call before passing text to the TTS
|
||||
backend.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
Raw text to normalize.
|
||||
heteronym_rules:
|
||||
Compiled heteronym rules from ``compile_heteronym_sentence_rules``.
|
||||
pronunciation_rules:
|
||||
Compiled pronunciation rules from ``compile_pronunciation_rules``.
|
||||
normalization_overrides:
|
||||
User-level overrides for normalization settings (apostrophe mode, etc.).
|
||||
usage_counter:
|
||||
Mutable dict that tracks how many times each pronunciation override was
|
||||
applied. Passed through to ``apply_pronunciation_rules``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Fully normalized text ready for TTS.
|
||||
"""
|
||||
from abogen.domain.pronunciation import (
|
||||
apply_heteronym_sentence_rules,
|
||||
apply_pronunciation_rules,
|
||||
)
|
||||
|
||||
result = str(text or "")
|
||||
|
||||
if heteronym_rules:
|
||||
result = apply_heteronym_sentence_rules(result, heteronym_rules)
|
||||
|
||||
if pronunciation_rules:
|
||||
result = apply_pronunciation_rules(result, pronunciation_rules, usage_counter)
|
||||
|
||||
runtime_settings = get_runtime_settings()
|
||||
if normalization_overrides:
|
||||
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
||||
apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_BASE_APOSTROPHE_CONFIG)
|
||||
|
||||
return _normalize_for_pipeline(result, config=apostrophe_config, settings=runtime_settings)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Output path resolution utilities.
|
||||
|
||||
Pure functions for resolving output directories, building file paths,
|
||||
and computing project folder layouts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
from abogen.subtitle_utils import sanitize_name_for_os
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
|
||||
|
||||
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
|
||||
|
||||
# OS-specific illegal characters for filenames
|
||||
_WINDOWS_ILLEGAL_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
_UNIX_CONTROL_CHARS_RE = re.compile(r'[\x00-\x1f]')
|
||||
_RESERVED_NAMES = frozenset(
|
||||
{"CON", "PRN", "AUX", "NUL"}
|
||||
| {f"COM{i}" for i in range(1, 10)}
|
||||
| {f"LPT{i}" for i in range(1, 10)}
|
||||
)
|
||||
|
||||
|
||||
def slugify(title: str, index: int) -> str:
|
||||
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
|
||||
if not sanitized:
|
||||
sanitized = f"chapter_{index:02d}"
|
||||
return sanitized[:80]
|
||||
|
||||
|
||||
def sanitize_filename_for_chapter(title: str, index: int, max_len: int = 80) -> str:
|
||||
"""Sanitize a chapter name for use as a filename component.
|
||||
|
||||
Combines character sanitization, OS safety, and smart truncation
|
||||
at word boundaries. Prepends zero-padded index prefix.
|
||||
|
||||
Args:
|
||||
title: Raw chapter title.
|
||||
index: 1-based chapter number for prefix.
|
||||
max_len: Maximum length of the sanitized portion (excluding prefix).
|
||||
|
||||
Returns:
|
||||
Sanitized string like "01_the_beginning".
|
||||
"""
|
||||
# Remove non-word/non-space/non-hyphen chars, then collapse spaces/hyphens
|
||||
sanitized = re.sub(r"[^\w\s\-]", "", title)
|
||||
sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_")
|
||||
|
||||
if not sanitized:
|
||||
sanitized = f"chapter_{index:02d}"
|
||||
|
||||
# OS-specific sanitization
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
sanitized = _WINDOWS_ILLEGAL_CHARS_RE.sub("_", sanitized)
|
||||
sanitized = sanitized.rstrip(". ")
|
||||
base = sanitized.split(".")[0].upper()
|
||||
if base in _RESERVED_NAMES:
|
||||
sanitized = f"_{sanitized}"
|
||||
# Linux: only NUL is truly illegal, but control chars are problematic
|
||||
sanitized = _UNIX_CONTROL_CHARS_RE.sub("_", sanitized)
|
||||
|
||||
# Smart truncation at word boundary
|
||||
if len(sanitized) > max_len:
|
||||
pos = sanitized[:max_len].rfind("_")
|
||||
sanitized = sanitized[: pos if pos > 0 else max_len].rstrip("_")
|
||||
|
||||
return f"{index:02d}_{sanitized}"
|
||||
|
||||
|
||||
def sanitize_output_stem(name: str, index: int = 0) -> str:
|
||||
base = Path(name or "").stem
|
||||
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
|
||||
return sanitized or "output"
|
||||
|
||||
|
||||
def output_timestamp_token() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
||||
sanitized = sanitize_output_stem(original_name)
|
||||
return directory / f"{sanitized}.{extension}"
|
||||
|
||||
|
||||
def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
||||
if not replace_single_newlines:
|
||||
return
|
||||
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
|
||||
for chapter in chapters:
|
||||
chapter.text = newline_regex.sub(" ", chapter.text)
|
||||
|
||||
|
||||
from abogen.domain.enums import SaveMode
|
||||
|
||||
|
||||
def resolve_output_directory(
|
||||
*,
|
||||
save_mode: str,
|
||||
stored_path: Path,
|
||||
output_folder: Optional[str],
|
||||
desktop_dir: Optional[Path],
|
||||
user_output_path: Optional[Path],
|
||||
user_cache_outputs: Optional[Path],
|
||||
) -> Path:
|
||||
if save_mode in (SaveMode.SAVE_TO_DESKTOP, "Save to Desktop") and desktop_dir:
|
||||
return desktop_dir
|
||||
if save_mode in (SaveMode.SAVE_NEXT_TO_INPUT, "Save next to input file"):
|
||||
return stored_path.parent
|
||||
if save_mode in (SaveMode.CHOOSE_OUTPUT_FOLDER, "Choose output folder") and output_folder:
|
||||
return Path(output_folder)
|
||||
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(".")
|
||||
|
||||
|
||||
def resolve_project_layout(
|
||||
*,
|
||||
original_filename: str,
|
||||
save_as_project: bool,
|
||||
base_dir: Path,
|
||||
timestamp_fn: Callable[[], str] = output_timestamp_token,
|
||||
sanitize_fn: Callable[[str, int], str] = sanitize_output_stem,
|
||||
) -> Tuple[Path, Path, Path, Optional[Path]]:
|
||||
sanitized = sanitize_fn(original_filename, 0)
|
||||
folder_name = f"{timestamp_fn()}_{sanitized}"
|
||||
project_root = base_dir / folder_name
|
||||
project_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if save_as_project:
|
||||
audio_dir = project_root / "audio"
|
||||
subtitle_dir = project_root / "subtitles"
|
||||
metadata_dir = project_root / "metadata"
|
||||
for directory in (audio_dir, subtitle_dir, metadata_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return project_root, audio_dir, subtitle_dir, metadata_dir
|
||||
|
||||
return project_root, project_root, project_root, None
|
||||
|
||||
|
||||
def resolve_unique_path(
|
||||
parent_dir: str,
|
||||
base_name: str,
|
||||
extension: str,
|
||||
allowed_extensions: Optional[set] = None,
|
||||
) -> str:
|
||||
"""Find a unique file path by appending _2, _3, etc. on collision.
|
||||
|
||||
Args:
|
||||
parent_dir: Directory to check for collisions.
|
||||
base_name: Base filename (without extension).
|
||||
extension: File extension (without dot).
|
||||
allowed_extensions: Set of extensions to check against.
|
||||
If None, checks any existing file/dir with same name.
|
||||
|
||||
Returns:
|
||||
Full path without extension (e.g. "/path/to/name_2").
|
||||
"""
|
||||
sanitized = sanitize_name_for_os(base_name, is_folder=True)
|
||||
counter = 1
|
||||
while True:
|
||||
suffix = f"_{counter}" if counter > 1 else ""
|
||||
candidate = os.path.join(parent_dir, f"{sanitized}{suffix}")
|
||||
if allowed_extensions is not None:
|
||||
file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir))
|
||||
clash = any(
|
||||
name == f"{sanitized}{suffix}"
|
||||
and ext[1:].lower() in allowed_extensions
|
||||
for name, ext in file_parts
|
||||
)
|
||||
else:
|
||||
clash = os.path.exists(candidate)
|
||||
if not clash:
|
||||
return candidate
|
||||
counter += 1
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Pipeline creation, caching and lifecycle management.
|
||||
|
||||
Provides a unified interface for creating and managing TTS pipelines
|
||||
across all UI layers (WebUI, PyQt, CLI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from abogen.domain.device import select_device
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.domain.voice_resolution import initialize_voice_cache
|
||||
from abogen.tts_plugin.utils import create_pipeline, is_plugin_registered
|
||||
|
||||
# Kokoro-specific language mapping (engine's responsibility)
|
||||
_KOKORO_LANG_MAP = {
|
||||
Language.EN_US: "a",
|
||||
Language.EN_GB: "b",
|
||||
Language.ES: "e",
|
||||
Language.FR: "f",
|
||||
Language.HI: "h",
|
||||
Language.IT: "i",
|
||||
Language.JA: "j",
|
||||
Language.PT_BR: "p",
|
||||
Language.ZH: "z",
|
||||
}
|
||||
|
||||
|
||||
def resolve_device(use_gpu: bool) -> str:
|
||||
"""Determine compute device from job and global config flags."""
|
||||
from abogen.utils import load_config
|
||||
|
||||
cfg = load_config()
|
||||
if use_gpu and cfg.get("use_gpu", True):
|
||||
return select_device()
|
||||
return "cpu"
|
||||
|
||||
|
||||
def create_pipeline_for_job(
|
||||
provider: str,
|
||||
language: str,
|
||||
use_gpu: bool,
|
||||
) -> Any:
|
||||
"""Create a TTS pipeline with proper device selection.
|
||||
|
||||
Handles provider validation, GPU decision, and plugin checks.
|
||||
"""
|
||||
provider = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
# Convert Language enum to Kokoro single-letter code
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = Language.EN_US # fallback for unknown languages
|
||||
kokoro_code = _KOKORO_LANG_MAP.get(lang, "a")
|
||||
|
||||
if provider == "supertonic":
|
||||
return create_pipeline("supertonic")
|
||||
|
||||
device = resolve_device(use_gpu)
|
||||
return create_pipeline("kokoro", lang_code=kokoro_code, device=device)
|
||||
|
||||
|
||||
def dispose_pipelines(pipelines: Dict[str, Any]) -> None:
|
||||
"""Dispose all pipelines in a dict and clear it."""
|
||||
for p in pipelines.values():
|
||||
try:
|
||||
p.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
pipelines.clear()
|
||||
|
||||
|
||||
class PipelinePool:
|
||||
"""Cache and manage TTS pipelines by provider.
|
||||
|
||||
Usage::
|
||||
|
||||
pool = PipelinePool()
|
||||
backend = pool.get("kokoro", "en", use_gpu=True)
|
||||
# ... use backend ...
|
||||
pool.dispose_all()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pipelines: Dict[str, Any] = {}
|
||||
self._voice_cache_initialized = False
|
||||
|
||||
def get(
|
||||
self,
|
||||
provider: str,
|
||||
language: str,
|
||||
use_gpu: bool,
|
||||
*,
|
||||
job: Any = None,
|
||||
) -> Any:
|
||||
"""Get or create a cached pipeline for the given provider.
|
||||
|
||||
Args:
|
||||
provider: TTS provider name ("kokoro" or "supertonic").
|
||||
language: Language code (for kokoro).
|
||||
use_gpu: Whether GPU acceleration is requested.
|
||||
job: Optional job object for voice cache initialization.
|
||||
"""
|
||||
provider = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
existing = self._pipelines.get(provider)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
pipeline = create_pipeline_for_job(provider, language, use_gpu)
|
||||
self._pipelines[provider] = pipeline
|
||||
|
||||
if provider == "kokoro" and not self._voice_cache_initialized and job is not None:
|
||||
initialize_voice_cache(job)
|
||||
self._voice_cache_initialized = True
|
||||
|
||||
return pipeline
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all cached pipelines."""
|
||||
dispose_pipelines(self._pipelines)
|
||||
self._voice_cache_initialized = False
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Progress and ETR (estimated time remaining) calculation.
|
||||
|
||||
Shared by Web UI and PyQt desktop GUI. Pure math, no UI dependencies.
|
||||
"""
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressTracker:
|
||||
"""Tracks character-based progress with ETR calculation.
|
||||
|
||||
Usage:
|
||||
tracker = ProgressTracker(total_chars=50000)
|
||||
# ... as processing occurs:
|
||||
tracker.update(chars_done=5000)
|
||||
print(tracker.etr_str) # "00:04:30"
|
||||
print(tracker.percent) # 10
|
||||
"""
|
||||
total_chars: int
|
||||
_start_time: float = field(default_factory=time.time, repr=False)
|
||||
_chars_done: int = field(default=0, repr=False)
|
||||
|
||||
def update(self, chars_done: int) -> None:
|
||||
self._chars_done = chars_done
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
if self.total_chars <= 0:
|
||||
return 0
|
||||
return min(int(self._chars_done / self.total_chars * 100), 99)
|
||||
|
||||
@property
|
||||
def etr_str(self) -> str:
|
||||
elapsed = time.time() - self._start_time
|
||||
if self._chars_done <= 0 or elapsed <= 0.5:
|
||||
return "Processing..."
|
||||
avg_time_per_char = elapsed / self._chars_done
|
||||
remaining = self.total_chars - self._chars_done
|
||||
if remaining <= 0:
|
||||
return "00:00:00"
|
||||
secs = avg_time_per_char * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def calc_etr_str(elapsed: float, done: int, total: int) -> str:
|
||||
"""Standalone ETR string calculation (matches PyQt original logic).
|
||||
|
||||
Args:
|
||||
elapsed: seconds since processing started
|
||||
done: items/characters processed so far
|
||||
total: total items/characters to process
|
||||
|
||||
Returns:
|
||||
ETR string like "01:23:45" or "Processing..."
|
||||
"""
|
||||
if done <= 0 or elapsed <= 0.5:
|
||||
return "Processing..."
|
||||
avg_time_per_item = elapsed / done
|
||||
remaining = total - done
|
||||
if remaining <= 0:
|
||||
return "00:00:00"
|
||||
secs = avg_time_per_item * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Pronunciation rule compilation and application.
|
||||
|
||||
Pure functions for compiling token-level and sentence-level pronunciation
|
||||
overrides into regex patterns, applying them to text, and merging multiple
|
||||
override sources with precedence rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from abogen.entity_analysis import normalize_token as normalize_entity_token
|
||||
from abogen.entity_analysis import normalize_manual_override_token
|
||||
|
||||
|
||||
def compile_pronunciation_rules(
|
||||
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not overrides:
|
||||
return []
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for entry in overrides:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||
if not pronunciation_value:
|
||||
continue
|
||||
|
||||
token_values: List[str] = []
|
||||
token_raw = entry.get("token")
|
||||
if token_raw:
|
||||
token_value = str(token_raw).strip()
|
||||
if token_value:
|
||||
token_values.append(token_value)
|
||||
normalized_raw = entry.get("normalized")
|
||||
if normalized_raw:
|
||||
normalized_value = str(normalized_raw).strip()
|
||||
if normalized_value:
|
||||
token_values.append(normalized_value)
|
||||
if token_raw and not token_values:
|
||||
fallback = normalize_entity_token(str(token_raw))
|
||||
if fallback:
|
||||
token_values.append(fallback)
|
||||
|
||||
if not token_values:
|
||||
continue
|
||||
|
||||
usage_normalized = str(entry.get("normalized") or "").strip()
|
||||
if not usage_normalized and token_values:
|
||||
usage_normalized = normalize_entity_token(token_values[0]) or token_values[0]
|
||||
usage_token = str(entry.get("token") or token_values[0])
|
||||
|
||||
for token_value in token_values:
|
||||
key = token_value.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
candidates.append(
|
||||
{
|
||||
"token": token_value,
|
||||
"normalized": usage_normalized,
|
||||
"replacement": pronunciation_value,
|
||||
}
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
candidates.sort(key=lambda item: len(item["token"]), reverse=True)
|
||||
compiled: List[Dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
token_value = candidate["token"]
|
||||
pronunciation_value = candidate["replacement"]
|
||||
escaped = re.escape(token_value)
|
||||
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
|
||||
compiled.append(
|
||||
{
|
||||
"pattern": pattern,
|
||||
"replacement": pronunciation_value,
|
||||
"normalized": candidate.get("normalized") or token_value,
|
||||
"token": candidate.get("token") or token_value,
|
||||
}
|
||||
)
|
||||
|
||||
return compiled
|
||||
|
||||
|
||||
def compile_heteronym_sentence_rules(
|
||||
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not overrides:
|
||||
return []
|
||||
|
||||
compiled: List[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for entry in overrides:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
sentence = str(entry.get("sentence") or "").strip()
|
||||
if not sentence:
|
||||
continue
|
||||
choice = str(entry.get("choice") or "").strip()
|
||||
if not choice:
|
||||
continue
|
||||
|
||||
replacement_sentence = ""
|
||||
options = entry.get("options")
|
||||
if isinstance(options, list):
|
||||
for opt in options:
|
||||
if not isinstance(opt, Mapping):
|
||||
continue
|
||||
if str(opt.get("key") or "").strip() == choice:
|
||||
replacement_sentence = str(opt.get("replacement_sentence") or "").strip()
|
||||
break
|
||||
if not replacement_sentence:
|
||||
continue
|
||||
|
||||
rule_key = f"{sentence}\n{choice}".casefold()
|
||||
if rule_key in seen:
|
||||
continue
|
||||
seen.add(rule_key)
|
||||
|
||||
parts = [p for p in re.split(r"\s+", sentence) if p]
|
||||
if not parts:
|
||||
continue
|
||||
pattern_text = r"\s+".join(re.escape(p) for p in parts)
|
||||
pattern = re.compile(pattern_text)
|
||||
compiled.append({"pattern": pattern, "replacement": replacement_sentence})
|
||||
|
||||
compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True)
|
||||
return compiled
|
||||
|
||||
|
||||
def apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str:
|
||||
if not text or not rules:
|
||||
return text
|
||||
result = text
|
||||
for rule in rules:
|
||||
pattern = rule["pattern"]
|
||||
replacement = rule["replacement"]
|
||||
result = pattern.sub(replacement, result)
|
||||
return result
|
||||
|
||||
|
||||
def apply_pronunciation_rules(
|
||||
text: str,
|
||||
rules: List[Dict[str, Any]],
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
if not text or not rules:
|
||||
return text
|
||||
|
||||
result = text
|
||||
for rule in rules:
|
||||
pattern = rule["pattern"]
|
||||
pronunciation_value = rule["replacement"]
|
||||
usage_key = str(rule.get("normalized") or "").strip()
|
||||
|
||||
def _replacement(match: re.Match[str]) -> str:
|
||||
suffix = match.group("possessive") or ""
|
||||
if usage_counter is not None and usage_key:
|
||||
usage_counter[usage_key] = usage_counter.get(usage_key, 0) + 1
|
||||
return pronunciation_value + suffix
|
||||
|
||||
result = pattern.sub(_replacement, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def merge_pronunciation_overrides(job: Any) -> List[Dict[str, Any]]:
|
||||
"""Return pronunciation override entries, ensuring manual overrides are included.
|
||||
|
||||
Pending jobs keep both ``manual_overrides`` and ``pronunciation_overrides``, but the
|
||||
latter can be stale if the UI didn't resync before enqueue. During conversion,
|
||||
we must merge manual overrides so they always apply (before TTS).
|
||||
|
||||
Precedence: manual overrides win over existing entries for the same normalized key.
|
||||
"""
|
||||
|
||||
collected: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
existing = getattr(job, "pronunciation_overrides", None)
|
||||
if isinstance(existing, list):
|
||||
for entry in existing:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
token_value = str(entry.get("token") or "").strip()
|
||||
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = str(entry.get("normalized") or "").strip() or normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(entry.get("voice") or "").strip() or None,
|
||||
"notes": str(entry.get("notes") or "").strip() or None,
|
||||
"context": str(entry.get("context") or "").strip() or None,
|
||||
"source": str(entry.get("source") or "pronunciation"),
|
||||
"language": getattr(job, "language", None),
|
||||
}
|
||||
|
||||
speakers = getattr(job, "speakers", None)
|
||||
if isinstance(speakers, dict):
|
||||
for payload in speakers.values():
|
||||
if not isinstance(payload, Mapping):
|
||||
continue
|
||||
token_value = str(payload.get("token") or "").strip()
|
||||
pronunciation_value = str(payload.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(
|
||||
payload.get("resolved_voice")
|
||||
or payload.get("voice")
|
||||
or getattr(job, "voice", "")
|
||||
).strip()
|
||||
or None,
|
||||
"notes": None,
|
||||
"context": None,
|
||||
"source": "speaker",
|
||||
"language": getattr(job, "language", None),
|
||||
}
|
||||
|
||||
manual = getattr(job, "manual_overrides", None)
|
||||
if isinstance(manual, list):
|
||||
for entry in manual:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
token_value = str(entry.get("token") or "").strip()
|
||||
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = str(entry.get("normalized") or "").strip() or normalize_manual_override_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(entry.get("voice") or "").strip() or None,
|
||||
"notes": str(entry.get("notes") or "").strip() or None,
|
||||
"context": str(entry.get("context") or "").strip() or None,
|
||||
"source": str(entry.get("source") or "manual"),
|
||||
"language": getattr(job, "language", None),
|
||||
}
|
||||
|
||||
return list(collected.values())
|
||||
@@ -1,580 +0,0 @@
|
||||
"""Shared settings core.
|
||||
|
||||
Defines the SETTINGS_REGISTRY — the single source of truth for all settings.
|
||||
Every setting has a key, type, default, validation rules, and UI scope.
|
||||
Both Web UI and Desktop GUI must reference this registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Mapping, Optional, Sequence
|
||||
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_default_voice
|
||||
from abogen.normalization_settings import (
|
||||
DEFAULT_LLM_PROMPT,
|
||||
environment_llm_defaults,
|
||||
)
|
||||
|
||||
|
||||
# ── Schema ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Setting:
|
||||
"""Contract for a single setting.
|
||||
|
||||
Attributes:
|
||||
key: Config dict key (e.g. "output_format").
|
||||
type_: Python type (bool, int, float, str, list).
|
||||
default: Default value or callable returning one.
|
||||
min_value: Minimum for numeric types.
|
||||
max_value: Maximum for numeric types.
|
||||
valid_values: Allowed values for str types (None = any).
|
||||
gui_only: True if only used by PyQt Desktop GUI.
|
||||
web_only: True if only used by Web UI.
|
||||
normalizer: Optional callable(value, default) -> normalized_value.
|
||||
description: Human-readable explanation.
|
||||
"""
|
||||
key: str
|
||||
type_: type
|
||||
default: Any
|
||||
min_value: float | None = None
|
||||
max_value: float | None = None
|
||||
valid_values: tuple[Any, ...] | None = None
|
||||
gui_only: bool = False
|
||||
web_only: bool = False
|
||||
normalizer: Callable | None = None
|
||||
description: str = ""
|
||||
|
||||
def coerce(self, value: Any, fallback: Any | None = None) -> Any:
|
||||
"""Coerce value to the declared type, returning fallback on failure."""
|
||||
fb = fallback if fallback is not None else self.default
|
||||
if self.type_ is bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"true", "1", "yes", "on"}
|
||||
if value is None:
|
||||
return fb
|
||||
return bool(value)
|
||||
if self.type_ is int:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fb
|
||||
if self.min_value is not None:
|
||||
v = max(int(self.min_value), v)
|
||||
if self.max_value is not None:
|
||||
v = min(int(self.max_value), v)
|
||||
return v
|
||||
if self.type_ is float:
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fb
|
||||
if self.min_value is not None:
|
||||
v = max(self.min_value, v)
|
||||
if self.max_value is not None:
|
||||
v = min(self.max_value, v)
|
||||
return v
|
||||
if self.type_ is str:
|
||||
if isinstance(value, str):
|
||||
v = value.strip()
|
||||
if self.valid_values and v not in self.valid_values:
|
||||
return fb
|
||||
return v
|
||||
return fb
|
||||
if self.type_ is list:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return list(value)
|
||||
return fb
|
||||
return value
|
||||
|
||||
|
||||
# ── Normalizers (used by Setting.normalizer) ─────────────────────────
|
||||
|
||||
def _norm_save_mode(value: Any, default: str) -> str:
|
||||
if isinstance(value, str):
|
||||
if value in SAVE_MODE_LABELS:
|
||||
return value
|
||||
if value in LEGACY_SAVE_MODE_MAP:
|
||||
return LEGACY_SAVE_MODE_MAP[value]
|
||||
return default
|
||||
|
||||
|
||||
def _norm_voice_spec(value: Any, default: str) -> str:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return default
|
||||
spec, profile_name = split_profile_spec(text)
|
||||
if profile_name:
|
||||
return f"speaker:{profile_name}"
|
||||
return spec
|
||||
return default
|
||||
|
||||
|
||||
def _norm_speaker_spec(value: Any, default: str) -> str:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return ""
|
||||
spec, profile_name = split_profile_spec(text)
|
||||
if profile_name:
|
||||
return f"speaker:{profile_name}"
|
||||
return spec
|
||||
return ""
|
||||
|
||||
|
||||
def _norm_language_list(value: Any, default: list) -> list:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
|
||||
if isinstance(value, str):
|
||||
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
|
||||
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
||||
return default
|
||||
|
||||
|
||||
def _norm_stripped_str(value: Any, default: str) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _norm_prompt(value: Any, default: str) -> str:
|
||||
candidate = str(value or "").strip()
|
||||
return candidate if candidate else default
|
||||
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────
|
||||
|
||||
def _default_output_format() -> str:
|
||||
return "wav"
|
||||
|
||||
|
||||
def _default_save_mode() -> str:
|
||||
return "default_output" if has_output_override() else "save_next_to_input"
|
||||
|
||||
|
||||
def _default_llm(key: str) -> str:
|
||||
return environment_llm_defaults().get(key, "")
|
||||
|
||||
|
||||
SETTINGS_REGISTRY: list[Setting] = [
|
||||
# ── Core output ──────────────────────────────────────────────
|
||||
Setting("output_format", str, "wav",
|
||||
valid_values=tuple(SUPPORTED_SOUND_FORMATS),
|
||||
description="Audio output format"),
|
||||
Setting("subtitle_format", str, "srt",
|
||||
valid_values=tuple(item[0] for item in SUBTITLE_FORMATS),
|
||||
description="Subtitle file format"),
|
||||
Setting("save_mode", str, _default_save_mode,
|
||||
normalizer=_norm_save_mode,
|
||||
description="Where to save output files"),
|
||||
Setting("separate_chapters_format", str, "wav",
|
||||
valid_values=("wav", "flac", "mp3", "opus"),
|
||||
description="Format for separately saved chapters"),
|
||||
Setting("chunk_level", str, "paragraph",
|
||||
valid_values=("paragraph", "sentence"),
|
||||
description="Text chunking granularity"),
|
||||
|
||||
# ── Voice ────────────────────────────────────────────────────
|
||||
Setting("default_speaker", str, "",
|
||||
normalizer=_norm_speaker_spec,
|
||||
description="Default speaker name"),
|
||||
Setting("default_voice", str, lambda: get_default_voice("kokoro"),
|
||||
normalizer=_norm_voice_spec,
|
||||
description="Default TTS voice"),
|
||||
Setting("speed", float, 1.0, min_value=0.5, max_value=3.0,
|
||||
gui_only=True,
|
||||
description="TTS speed multiplier"),
|
||||
Setting("supertonic_total_steps", int, 5, min_value=2, max_value=15,
|
||||
description="SuperTonic processing steps"),
|
||||
Setting("supertonic_speed", float, 1.0, min_value=0.7, max_value=2.0,
|
||||
description="SuperTonic speed"),
|
||||
|
||||
# ── Chapter handling ─────────────────────────────────────────
|
||||
Setting("silence_between_chapters", float, 2.0, min_value=0.0,
|
||||
description="Silence gap between chapters (seconds)"),
|
||||
Setting("chapter_intro_delay", float, 0.5, min_value=0.0,
|
||||
description="Delay after chapter heading (seconds)"),
|
||||
Setting("read_title_intro", bool, False,
|
||||
description="Read chapter title as intro"),
|
||||
Setting("read_closing_outro", bool, True,
|
||||
description="Read closing/outro text"),
|
||||
Setting("normalize_chapter_opening_caps", bool, True,
|
||||
description="Normalize chapter opening caps"),
|
||||
Setting("auto_prefix_chapter_titles", bool, True,
|
||||
description="Auto-prefix chapter titles"),
|
||||
Setting("save_chapters_separately", bool, False,
|
||||
description="Save each chapter as separate file"),
|
||||
Setting("merge_chapters_at_end", bool, True,
|
||||
description="Merge chapters into single file"),
|
||||
Setting("save_as_project", bool, False,
|
||||
description="Save as editable project"),
|
||||
Setting("generate_epub3", bool, False,
|
||||
description="Generate EPUB3 output"),
|
||||
|
||||
# ── GPU / performance ────────────────────────────────────────
|
||||
Setting("use_gpu", bool, True,
|
||||
description="Use GPU acceleration"),
|
||||
|
||||
# ── Text processing ──────────────────────────────────────────
|
||||
Setting("replace_single_newlines", bool, False,
|
||||
description="Replace single newlines with spaces"),
|
||||
Setting("max_subtitle_words", int, 50, min_value=1, max_value=500,
|
||||
description="Max words per subtitle"),
|
||||
Setting("enable_entity_recognition", bool, True,
|
||||
description="Enable entity recognition"),
|
||||
|
||||
# ── Speaker analysis ─────────────────────────────────────────
|
||||
Setting("speaker_analysis_threshold", int, 3, min_value=1, max_value=25,
|
||||
description="Speaker analysis threshold"),
|
||||
Setting("speaker_pronunciation_sentence", str, "This is {{name}} speaking.",
|
||||
description="Template for pronunciation samples"),
|
||||
Setting("speaker_random_languages", list, [],
|
||||
normalizer=_norm_language_list,
|
||||
description="Languages for random speaker assignment"),
|
||||
|
||||
# ── LLM ──────────────────────────────────────────────────────
|
||||
Setting("llm_base_url", str, lambda: _default_llm("llm_base_url"),
|
||||
normalizer=_norm_stripped_str,
|
||||
description="LLM API base URL"),
|
||||
Setting("llm_api_key", str, lambda: _default_llm("llm_api_key"),
|
||||
normalizer=_norm_stripped_str,
|
||||
description="LLM API key"),
|
||||
Setting("llm_model", str, lambda: _default_llm("llm_model"),
|
||||
normalizer=_norm_stripped_str,
|
||||
description="LLM model name"),
|
||||
Setting("llm_timeout", float, lambda: _default_llm("llm_timeout") or 30.0,
|
||||
min_value=1.0,
|
||||
description="LLM request timeout"),
|
||||
Setting("llm_prompt", str, lambda: _default_llm("llm_prompt") or DEFAULT_LLM_PROMPT,
|
||||
normalizer=_norm_prompt,
|
||||
description="LLM normalization prompt"),
|
||||
Setting("llm_context_mode", str, lambda: _default_llm("llm_context_mode") or "sentence",
|
||||
valid_values=("sentence",),
|
||||
description="LLM context mode"),
|
||||
|
||||
# ── Normalization (booleans) ─────────────────────────────────
|
||||
Setting("normalization_numbers", bool, True,
|
||||
description="Convert grouped numbers to words"),
|
||||
Setting("normalization_currency", bool, True,
|
||||
description="Convert currency symbols"),
|
||||
Setting("normalization_footnotes", bool, True,
|
||||
description="Remove footnote indicators"),
|
||||
Setting("normalization_titles", bool, True,
|
||||
description="Expand titles and suffixes"),
|
||||
Setting("normalization_terminal", bool, True,
|
||||
description="Ensure terminal punctuation"),
|
||||
Setting("normalization_phoneme_hints", bool, True,
|
||||
description="Add phoneme hints for possessives"),
|
||||
Setting("normalization_caps_quotes", bool, True,
|
||||
description="Convert ALL CAPS in quotes"),
|
||||
Setting("normalization_internet_slang", bool, False,
|
||||
description="Expand internet slang"),
|
||||
Setting("normalization_apostrophes_contractions", bool, True,
|
||||
description="Expand contractions"),
|
||||
Setting("normalization_apostrophes_plural_possessives", bool, True,
|
||||
description="Collapse plural possessives"),
|
||||
Setting("normalization_apostrophes_sibilant_possessives", bool, True,
|
||||
description="Mark sibilant possessives"),
|
||||
Setting("normalization_apostrophes_decades", bool, True,
|
||||
description="Expand decades"),
|
||||
Setting("normalization_apostrophes_leading_elisions", bool, True,
|
||||
description="Expand leading elisions"),
|
||||
Setting("normalization_contraction_aux_be", bool, True,
|
||||
description="Expand auxiliary 'be'"),
|
||||
Setting("normalization_contraction_aux_have", bool, True,
|
||||
description="Expand auxiliary 'have'"),
|
||||
Setting("normalization_contraction_modal_will", bool, True,
|
||||
description="Expand modal 'will'"),
|
||||
Setting("normalization_contraction_modal_would", bool, True,
|
||||
description="Expand modal 'would'"),
|
||||
Setting("normalization_contraction_negation_not", bool, True,
|
||||
description="Expand negation 'not'"),
|
||||
Setting("normalization_contraction_let_us", bool, True,
|
||||
description="Expand 'let's'"),
|
||||
|
||||
# ── Normalization (strings) ──────────────────────────────────
|
||||
Setting("normalization_apostrophe_mode", str, "spacy",
|
||||
valid_values=("off", "spacy", "llm"),
|
||||
description="Apostrophe handling mode"),
|
||||
Setting("normalization_numbers_year_style", str, "american",
|
||||
valid_values=("american", "off"),
|
||||
description="Year style for number normalization"),
|
||||
|
||||
# ── PyQt GUI-only ────────────────────────────────────────────
|
||||
Setting("theme", str, "system",
|
||||
gui_only=True,
|
||||
description="UI theme"),
|
||||
Setting("check_updates", bool, True,
|
||||
gui_only=True,
|
||||
description="Check for updates on startup"),
|
||||
Setting("subtitle_mode", str, "Sentence",
|
||||
gui_only=True,
|
||||
description="Subtitle display mode"),
|
||||
Setting("selected_format", str, "wav",
|
||||
gui_only=True,
|
||||
description="Last selected audio format"),
|
||||
Setting("selected_voice", str, "af_heart",
|
||||
gui_only=True,
|
||||
description="Last selected voice"),
|
||||
Setting("selected_profile_name", str, None,
|
||||
gui_only=True,
|
||||
description="Last selected profile name"),
|
||||
Setting("log_window_max_lines", int, 2000, min_value=100,
|
||||
gui_only=True,
|
||||
description="Max lines in log window"),
|
||||
Setting("use_silent_gaps", bool, True,
|
||||
gui_only=True,
|
||||
description="Use silent gaps between chunks"),
|
||||
Setting("subtitle_speed_method", str, "tts",
|
||||
gui_only=True,
|
||||
valid_values=("tts", "ffmpeg"),
|
||||
description="Speed adjustment method for subtitles"),
|
||||
Setting("use_spacy_segmentation", bool, True,
|
||||
gui_only=True,
|
||||
description="Use spaCy for sentence segmentation"),
|
||||
Setting("word_substitutions_enabled", bool, False,
|
||||
gui_only=True,
|
||||
description="Enable word substitutions"),
|
||||
Setting("word_substitutions_list", str, "",
|
||||
gui_only=True,
|
||||
description="Word substitutions list"),
|
||||
Setting("case_sensitive_substitutions", bool, False,
|
||||
gui_only=True,
|
||||
description="Case-sensitive substitutions"),
|
||||
Setting("replace_all_caps", bool, False,
|
||||
gui_only=True,
|
||||
description="Replace ALL CAPS text"),
|
||||
Setting("replace_numerals", bool, False,
|
||||
gui_only=True,
|
||||
description="Replace numerals with words"),
|
||||
Setting("fix_nonstandard_punctuation", bool, False,
|
||||
gui_only=True,
|
||||
description="Fix nonstandard punctuation"),
|
||||
Setting("queue_override_settings", bool, False,
|
||||
gui_only=True,
|
||||
description="Override settings per queue item"),
|
||||
Setting("disable_kokoro_internet", bool, False,
|
||||
description="Disable Kokoro internet access"),
|
||||
]
|
||||
|
||||
|
||||
# ── Registry helpers ─────────────────────────────────────────────────
|
||||
|
||||
_REGISTRY_BY_KEY: dict[str, Setting] = {s.key: s for s in SETTINGS_REGISTRY}
|
||||
|
||||
SETTING_KEYS: frozenset[str] = frozenset(_REGISTRY_BY_KEY.keys())
|
||||
GUI_ONLY_KEYS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.gui_only)
|
||||
WEB_ONLY_KEYS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.web_only)
|
||||
SHARED_KEYS: frozenset[str] = SETTING_KEYS - GUI_ONLY_KEYS - WEB_ONLY_KEYS
|
||||
|
||||
BOOLEAN_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is bool)
|
||||
FLOAT_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is float)
|
||||
INT_SETTINGS: frozenset[str] = frozenset(s.key for s in SETTINGS_REGISTRY if s.type_ is int)
|
||||
|
||||
# Backward-compatible aliases (used by existing code)
|
||||
_NORMALIZATION_BOOLEAN_KEYS: frozenset[str] = frozenset(
|
||||
s.key for s in SETTINGS_REGISTRY
|
||||
if s.type_ is bool and s.key.startswith("normalization_")
|
||||
)
|
||||
_NORMALIZATION_STRING_KEYS: frozenset[str] = frozenset(
|
||||
s.key for s in SETTINGS_REGISTRY
|
||||
if s.type_ is str and s.key.startswith("normalization_")
|
||||
)
|
||||
|
||||
|
||||
def get_setting(key: str) -> Setting | None:
|
||||
"""Look up a setting by key."""
|
||||
return _REGISTRY_BY_KEY.get(key)
|
||||
|
||||
|
||||
def has_output_override() -> bool:
|
||||
return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
|
||||
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────
|
||||
|
||||
def settings_defaults() -> Dict[str, Any]:
|
||||
"""Default values for all shared settings (excludes gui_only)."""
|
||||
result: Dict[str, Any] = {}
|
||||
for s in SETTINGS_REGISTRY:
|
||||
if s.gui_only:
|
||||
continue
|
||||
result[s.key] = s.default() if callable(s.default) else s.default
|
||||
return result
|
||||
|
||||
|
||||
def all_settings_defaults() -> Dict[str, Any]:
|
||||
"""Default values for ALL settings (including gui_only)."""
|
||||
result: Dict[str, Any] = {}
|
||||
for s in SETTINGS_REGISTRY:
|
||||
result[s.key] = s.default() if callable(s.default) else s.default
|
||||
return result
|
||||
|
||||
|
||||
def load_settings() -> Dict[str, Any]:
|
||||
"""Load and normalize settings from config file."""
|
||||
from abogen.utils import load_config
|
||||
defaults = settings_defaults()
|
||||
cfg = load_config() or {}
|
||||
settings: Dict[str, Any] = {}
|
||||
for key, default in defaults.items():
|
||||
raw_value = cfg.get(key, default)
|
||||
settings[key] = normalize_setting_value(key, raw_value, defaults)
|
||||
return settings
|
||||
|
||||
|
||||
# ── Normalization (delegates to Setting.coerce) ──────────────────────
|
||||
|
||||
def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
|
||||
"""Normalize a single setting value using the registry schema."""
|
||||
setting = _REGISTRY_BY_KEY.get(key)
|
||||
if setting is None:
|
||||
return value if value is not None else defaults.get(key)
|
||||
|
||||
fallback = defaults.get(key, setting.default() if callable(setting.default) else setting.default)
|
||||
|
||||
if setting.normalizer is not None:
|
||||
return setting.normalizer(value, fallback)
|
||||
|
||||
return setting.coerce(value, fallback)
|
||||
|
||||
|
||||
def validate_setting(key: str, value: Any) -> tuple[bool, str]:
|
||||
"""Validate a setting value against its schema. Returns (ok, error_message)."""
|
||||
setting = _REGISTRY_BY_KEY.get(key)
|
||||
if setting is None:
|
||||
return False, f"Unknown setting: {key}"
|
||||
if setting.type_ is str and setting.valid_values is not None:
|
||||
v = str(value or "").strip()
|
||||
if v and v not in setting.valid_values:
|
||||
return False, f"Invalid value '{v}' for {key}. Allowed: {setting.valid_values}"
|
||||
if setting.type_ is int:
|
||||
try:
|
||||
iv = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return False, f"Invalid integer value for {key}: {value!r}"
|
||||
if setting.min_value is not None and iv < setting.min_value:
|
||||
return False, f"{key} must be >= {setting.min_value}, got {iv}"
|
||||
if setting.max_value is not None and iv > setting.max_value:
|
||||
return False, f"{key} must be <= {setting.max_value}, got {iv}"
|
||||
if setting.type_ is float:
|
||||
try:
|
||||
fv = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return False, f"Invalid float value for {key}: {value!r}"
|
||||
if setting.min_value is not None and fv < setting.min_value:
|
||||
return False, f"{key} must be >= {setting.min_value}, got {fv}"
|
||||
if setting.max_value is not None and fv > setting.max_value:
|
||||
return False, f"{key} must be <= {setting.max_value}, got {fv}"
|
||||
return True, ""
|
||||
|
||||
|
||||
# ── Constants (backward-compatible) ──────────────────────────────────
|
||||
|
||||
SAVE_MODE_LABELS = {
|
||||
"save_next_to_input": "Save next to input file",
|
||||
"save_to_desktop": "Save to Desktop",
|
||||
"choose_output_folder": "Choose output folder",
|
||||
"default_output": "Use default save location",
|
||||
}
|
||||
|
||||
LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
|
||||
|
||||
CHUNK_LEVEL_OPTIONS = [
|
||||
{"value": "paragraph", "label": "Paragraphs"},
|
||||
{"value": "sentence", "label": "Sentences"},
|
||||
]
|
||||
|
||||
CHUNK_LEVEL_VALUES = frozenset(option["value"] for option in CHUNK_LEVEL_OPTIONS)
|
||||
|
||||
DEFAULT_ANALYSIS_THRESHOLD = 3
|
||||
|
||||
|
||||
# ── Coercion helpers (backward-compatible, delegate to Setting.coerce) ──
|
||||
|
||||
def coerce_bool(value: Any, default: bool) -> bool:
|
||||
return Setting("_", bool, default).coerce(value, default)
|
||||
|
||||
|
||||
def coerce_float(value: Any, default: float) -> float:
|
||||
return Setting("_", float, default).coerce(value, default)
|
||||
|
||||
|
||||
def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
|
||||
return Setting("_", int, default, min_value=minimum, max_value=maximum).coerce(value, default)
|
||||
|
||||
|
||||
def split_profile_spec(value: Any) -> tuple[str, str | None]:
|
||||
"""Split 'speaker:Name' or 'profile:Name' into (raw, name)."""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "", None
|
||||
lowered = text.lower()
|
||||
if lowered.startswith("profile:") or lowered.startswith("speaker:"):
|
||||
_, _, remainder = text.partition(":")
|
||||
name = remainder.strip()
|
||||
return "", name or None
|
||||
return text, None
|
||||
|
||||
|
||||
def normalize_save_mode(value: Any, default: str) -> str:
|
||||
return _norm_save_mode(value, default)
|
||||
|
||||
|
||||
# ── LLM helpers ──────────────────────────────────────────────────────
|
||||
|
||||
_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
||||
|
||||
|
||||
def llm_ready(settings: Mapping[str, Any]) -> bool:
|
||||
base_url = str(settings.get("llm_base_url") or "").strip()
|
||||
return bool(base_url)
|
||||
|
||||
|
||||
def render_prompt_template(template: str, context: Mapping[str, str]) -> str:
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
return context.get(key, "")
|
||||
|
||||
return _PROMPT_TOKEN_RE.sub(_replace, template)
|
||||
|
||||
|
||||
# ── Integration defaults ─────────────────────────────────────────────
|
||||
|
||||
def integration_defaults() -> Dict[str, Dict[str, Any]]:
|
||||
"""Default values for integration settings."""
|
||||
return {
|
||||
"calibre_opds": {
|
||||
"enabled": False,
|
||||
"base_url": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"verify_ssl": True,
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"enabled": False,
|
||||
"base_url": "",
|
||||
"api_token": "",
|
||||
"library_id": "",
|
||||
"collection_id": "",
|
||||
"folder_id": "",
|
||||
"verify_ssl": True,
|
||||
"send_cover": True,
|
||||
"send_chapters": True,
|
||||
"send_subtitles": False,
|
||||
"auto_send": False,
|
||||
"timeout": 30.0,
|
||||
},
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Unified split pattern logic extracted from 3 copies."""
|
||||
import re
|
||||
|
||||
from abogen.domain.enums import Language, SubtitleMode
|
||||
|
||||
PUNCTUATION_SENTENCE = r".!?。!?"
|
||||
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
|
||||
|
||||
|
||||
def get_split_pattern(language: str, subtitle_mode: str) -> str:
|
||||
"""Get the appropriate split pattern based on language and subtitle mode.
|
||||
|
||||
Args:
|
||||
language: Language code (a, b, e, f, etc.)
|
||||
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
|
||||
|
||||
Returns:
|
||||
Split pattern string
|
||||
"""
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = None # unknown language — treat as non-English, non-CJK
|
||||
try:
|
||||
mode = SubtitleMode.from_str(subtitle_mode) if not isinstance(subtitle_mode, SubtitleMode) else subtitle_mode
|
||||
except ValueError:
|
||||
mode = SubtitleMode.DISABLED
|
||||
|
||||
# For English, always use newline splitting only
|
||||
if lang in (Language.EN_US, Language.EN_GB):
|
||||
return "\n"
|
||||
|
||||
# Determine spacing pattern based on language
|
||||
spacing = r"\s*" if lang and lang.is_cjk else r"\s+"
|
||||
|
||||
# For CJK languages, when subtitle mode is Disabled or Line, prefer
|
||||
# punctuation-based splitting instead of plain newline splitting.
|
||||
if mode in (SubtitleMode.DISABLED, SubtitleMode.LINE) and lang and lang.is_cjk:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
|
||||
if mode == SubtitleMode.LINE:
|
||||
return "\n"
|
||||
elif mode == SubtitleMode.SENTENCE:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
elif mode == SubtitleMode.SENTENCE_COMMA:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
|
||||
else:
|
||||
return r"\n+"
|
||||
@@ -1,372 +0,0 @@
|
||||
"""Subtitle generation utilities for audiobook generation.
|
||||
|
||||
This module provides functions for processing TTS tokens into subtitle entries
|
||||
according to various subtitle modes (Line, Sentence, Sentence + Comma,
|
||||
Sentence + Highlighting).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from abogen.domain.enums import Language, SubtitleMode
|
||||
|
||||
|
||||
# Punctuation constants for sentence splitting
|
||||
PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ??
|
||||
PUNCTUATION_SENTENCE_COMMA = ".!?,\u3001\u061f\u3002\uff01\uff0c\uff1f" # .!?, ,. ??
|
||||
|
||||
|
||||
def process_subtitle_tokens(
|
||||
tokens_with_timestamps: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
lang_code: str,
|
||||
use_spacy_segmentation: bool = False,
|
||||
fallback_end_time: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Process TTS tokens into subtitle entries according to the subtitle mode.
|
||||
|
||||
This function modifies subtitle_entries in-place by appending new entries.
|
||||
|
||||
Args:
|
||||
tokens_with_timestamps: List of token dictionaries with 'start', 'end', 'text',
|
||||
and 'whitespace' keys.
|
||||
subtitle_entries: List to append subtitle entries to (modified in-place).
|
||||
Each entry is a tuple of (start_time, end_time, text).
|
||||
max_subtitle_words: Maximum number of words per subtitle entry.
|
||||
subtitle_mode: One of "Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||
"Sentence + Highlighting", or a string like "5" for word-count mode.
|
||||
lang_code: Language code for spaCy processing (e.g., "a" for English).
|
||||
use_spacy_segmentation: Whether to use spaCy for sentence boundary detection.
|
||||
fallback_end_time: Fallback end time for the last entry if none is available.
|
||||
"""
|
||||
if not tokens_with_timestamps:
|
||||
return
|
||||
|
||||
processed_tokens = tokens_with_timestamps
|
||||
|
||||
# For English with spaCy enabled and sentence-based modes, use spaCy for sentence boundaries
|
||||
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
||||
use_spacy_for_english = (
|
||||
use_spacy_segmentation
|
||||
and subtitle_mode not in [SubtitleMode.DISABLED, SubtitleMode.LINE]
|
||||
and lang_code in [Language.EN_US, Language.EN_GB]
|
||||
and subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA]
|
||||
)
|
||||
|
||||
if subtitle_mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||
_process_karaoke_highlighting(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
|
||||
)
|
||||
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
|
||||
)
|
||||
else:
|
||||
_process_regex_sentences(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
else:
|
||||
# Word count-based grouping (e.g., "5" for 5-word groups)
|
||||
_process_word_count(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
|
||||
|
||||
def _process_karaoke_highlighting(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens for Sentence + Highlighting mode (karaoke effect)."""
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
for token in tokens:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
|
||||
# Split sentences based on separator or word count
|
||||
if (
|
||||
re.search(separator, token["text"]) and token.get("whitespace") == " "
|
||||
) or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
# Create karaoke subtitle entry for this sentence
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Generate karaoke text with timing
|
||||
karaoke_text = ""
|
||||
for t in current_sentence:
|
||||
# Calculate duration in centiseconds
|
||||
duration = (
|
||||
t["end"] - t["start"]
|
||||
if t.get("end") is not None and t.get("start") is not None
|
||||
else 0.5
|
||||
)
|
||||
duration_cs = int(duration * 100)
|
||||
# Add karaoke effect
|
||||
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
|
||||
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, karaoke_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
# Add any remaining tokens as a sentence
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Generate karaoke text for remaining tokens
|
||||
karaoke_text = ""
|
||||
for t in current_sentence:
|
||||
duration = t["end"] - t["start"] if t.get("end") and t.get("start") else 0.5
|
||||
duration_cs = int(duration * 100)
|
||||
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
|
||||
subtitle_entries.append((start_time, end_time, karaoke_text.strip()))
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _process_spacy_sentences(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
lang_code: str,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens using spaCy for sentence boundary detection."""
|
||||
try:
|
||||
from abogen.spacy_utils import get_spacy_model
|
||||
except ImportError:
|
||||
# Fall back to regex if spaCy is not available
|
||||
_process_regex_sentences(
|
||||
tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
return
|
||||
|
||||
nlp = get_spacy_model(lang_code)
|
||||
if not nlp:
|
||||
_process_regex_sentences(
|
||||
tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
return
|
||||
|
||||
# Build full text and track character positions to token indices
|
||||
full_text = ""
|
||||
for token in tokens:
|
||||
text_part = token["text"] + (token.get("whitespace") or "")
|
||||
full_text += text_part
|
||||
|
||||
# Get sentence boundaries from spaCy
|
||||
doc = nlp(full_text)
|
||||
sentence_boundaries = [sent.end_char for sent in doc.sents]
|
||||
|
||||
# For "Sentence + Comma" mode, also split on commas
|
||||
if subtitle_mode == SubtitleMode.SENTENCE_COMMA:
|
||||
comma_positions = [
|
||||
i + 1 for i, c in enumerate(full_text) if c == ","
|
||||
]
|
||||
sentence_boundaries = sorted(
|
||||
set(sentence_boundaries + comma_positions)
|
||||
)
|
||||
|
||||
# Group tokens by sentence boundaries
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
current_char_pos = 0
|
||||
boundary_idx = 0
|
||||
|
||||
for token in tokens:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
text_len = len(token["text"]) + len(token.get("whitespace") or "")
|
||||
current_char_pos += text_len
|
||||
|
||||
# Check if we've hit a sentence boundary or max words
|
||||
at_boundary = (
|
||||
boundary_idx < len(sentence_boundaries)
|
||||
and current_char_pos >= sentence_boundaries[boundary_idx]
|
||||
)
|
||||
if at_boundary or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
sentence_text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "")
|
||||
for t in current_sentence
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
if at_boundary:
|
||||
boundary_idx += 1
|
||||
|
||||
# Add remaining tokens
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
sentence_text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "")
|
||||
for t in current_sentence
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _process_regex_sentences(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens using regex for sentence boundary detection."""
|
||||
# Define separator pattern based on mode
|
||||
if subtitle_mode == SubtitleMode.LINE:
|
||||
separator = r"\n"
|
||||
elif subtitle_mode == SubtitleMode.SENTENCE:
|
||||
# Use punctuation without comma
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
|
||||
else: # Sentence + Comma
|
||||
# Use punctuation with comma
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE_COMMA)}]"
|
||||
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
for token in tokens:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
|
||||
# Split sentences based on separator or word count
|
||||
if (
|
||||
re.search(separator, token["text"]) and token.get("whitespace") == " "
|
||||
) or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
# Create subtitle entry for this sentence
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Simplified text joining logic
|
||||
sentence_text = ""
|
||||
for t in current_sentence:
|
||||
sentence_text += t["text"] + (t.get("whitespace") or "")
|
||||
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
# Add any remaining tokens as a sentence (split multi-sentence FakeToken)
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
sentence_text = ""
|
||||
for t in current_sentence:
|
||||
sentence_text += t["text"] + (t.get("whitespace") or "")
|
||||
sentence_text = sentence_text.strip()
|
||||
|
||||
if len(current_sentence) == 1:
|
||||
parts = re.split(rf"(?<={separator})\s+", sentence_text)
|
||||
if len(parts) > 1:
|
||||
d = end_time - start_time
|
||||
for i, p in enumerate(parts):
|
||||
e = end_time if i == len(parts) - 1 else start_time + d * len(p) / len(sentence_text)
|
||||
subtitle_entries.append((start_time, e, p.strip()))
|
||||
start_time = e
|
||||
current_sentence = []
|
||||
|
||||
if current_sentence:
|
||||
subtitle_entries.append((start_time, end_time, sentence_text))
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _process_word_count(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens by counting spaces (word count mode)."""
|
||||
try:
|
||||
word_count = int(subtitle_mode.split()[0])
|
||||
word_count = min(word_count, max_subtitle_words)
|
||||
except (ValueError, IndexError):
|
||||
word_count = 1
|
||||
|
||||
current_group = []
|
||||
space_count = 0
|
||||
|
||||
for token in tokens:
|
||||
current_group.append(token)
|
||||
|
||||
# Count spaces after tokens (in the whitespace field)
|
||||
if token.get("whitespace", "") == " ":
|
||||
space_count += 1
|
||||
|
||||
# Split after counting N spaces
|
||||
if space_count >= word_count:
|
||||
text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "")
|
||||
for t in current_group
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(
|
||||
current_group[0]["start"],
|
||||
current_group[-1]["end"],
|
||||
text.strip(),
|
||||
)
|
||||
)
|
||||
current_group = []
|
||||
space_count = 0
|
||||
|
||||
# Add any remaining tokens
|
||||
if current_group:
|
||||
text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "") for t in current_group
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(current_group[0]["start"], current_group[-1]["end"], text.strip())
|
||||
)
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _apply_fallback_end_time(
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Apply fallback end time to the last entry if needed."""
|
||||
if subtitle_entries and fallback_end_time is not None:
|
||||
last_entry = subtitle_entries[-1]
|
||||
start, end, text = last_entry
|
||||
if end is None or end <= start or end <= 0:
|
||||
subtitle_entries[-1] = (start, fallback_end_time, text)
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Subtitle-to-audio processing pipeline.
|
||||
|
||||
Converts subtitle files (SRT/ASS/VTT/timestamp text) into audio by
|
||||
generating TTS for each entry and mixing into a buffer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.domain.audio_buffer import (
|
||||
create_silence,
|
||||
fit_audio_to_duration,
|
||||
ffmpeg_time_stretch,
|
||||
mix_audio,
|
||||
normalize_audio,
|
||||
SAMPLE_RATE,
|
||||
)
|
||||
from abogen.domain.audio_helpers import to_float32
|
||||
from abogen.domain.progress import calc_etr_str
|
||||
from abogen.subtitle_utils import (
|
||||
parse_ass_file,
|
||||
parse_srt_file,
|
||||
parse_vtt_file,
|
||||
parse_timestamp_text_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleEntry:
|
||||
"""A single subtitle entry with timing."""
|
||||
start: float
|
||||
end: Optional[float]
|
||||
text: str
|
||||
|
||||
|
||||
def parse_subtitle_file(
|
||||
file_path: str,
|
||||
is_timestamp_text: bool = False,
|
||||
) -> List[Tuple[float, Optional[float], str]]:
|
||||
"""Parse a subtitle file into (start, end, text) tuples.
|
||||
|
||||
Args:
|
||||
file_path: Path to subtitle file.
|
||||
is_timestamp_text: Whether to treat as timestamp text file.
|
||||
|
||||
Returns:
|
||||
List of (start_time, end_time, text) tuples.
|
||||
"""
|
||||
if is_timestamp_text:
|
||||
return parse_timestamp_text_file(file_path)
|
||||
|
||||
import os
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext == ".srt":
|
||||
return parse_srt_file(file_path)
|
||||
elif ext == ".vtt":
|
||||
return parse_vtt_file(file_path)
|
||||
else:
|
||||
return parse_ass_file(file_path)
|
||||
|
||||
|
||||
def format_time_range(
|
||||
start: float,
|
||||
end: Optional[float],
|
||||
is_auto_end: bool = False,
|
||||
) -> str:
|
||||
"""Format a time range for display in logs.
|
||||
|
||||
Args:
|
||||
start: Start time in seconds.
|
||||
end: End time in seconds, or None.
|
||||
is_auto_end: Whether end time is auto-detected.
|
||||
|
||||
Returns:
|
||||
Formatted string like "00:01:23,456 - 00:01:25,789" or "00:01:23 - AUTO".
|
||||
"""
|
||||
def _fmt(seconds: float) -> str:
|
||||
h = int(seconds // 3600)
|
||||
m = int(seconds % 3600 // 60)
|
||||
s = int(seconds % 60)
|
||||
ms = int((seconds - int(seconds)) * 1000)
|
||||
result = f"{h:02d}:{m:02d}:{s:02d}"
|
||||
if ms > 0:
|
||||
result += f",{ms:03d}"
|
||||
return result
|
||||
|
||||
if is_auto_end or end is None:
|
||||
return f"{_fmt(start)} - AUTO"
|
||||
return f"{_fmt(start)} - {_fmt(end)}"
|
||||
|
||||
|
||||
def speed_up_audio(
|
||||
audio: np.ndarray,
|
||||
speed_factor: float,
|
||||
method: str = "tts",
|
||||
*,
|
||||
backend: Any = None,
|
||||
text: str = "",
|
||||
voice: Any = None,
|
||||
base_speed: float = 1.0,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
) -> np.ndarray:
|
||||
"""Speed up audio to fit a time window.
|
||||
|
||||
Args:
|
||||
audio: Input audio buffer.
|
||||
speed_factor: Required speed multiplier.
|
||||
method: "ffmpeg" for time-stretch, "tts" for regeneration.
|
||||
backend: TTS backend (required if method="tts").
|
||||
text: Text to regenerate (required if method="tts").
|
||||
voice: Voice to use for regeneration.
|
||||
base_speed: Base speed for TTS.
|
||||
sample_rate: Sample rate.
|
||||
|
||||
Returns:
|
||||
Speed-adjusted audio buffer.
|
||||
"""
|
||||
if speed_factor <= 1.0:
|
||||
return audio
|
||||
|
||||
if method == "ffmpeg":
|
||||
logger.info("FFmpeg time-stretch: %.2fx", speed_factor)
|
||||
return ffmpeg_time_stretch(audio, speed_factor, sample_rate)
|
||||
|
||||
# TTS regeneration
|
||||
if backend is None:
|
||||
return audio
|
||||
new_speed = base_speed * speed_factor
|
||||
logger.info("Regenerating at %.2fx speed", new_speed)
|
||||
results = [
|
||||
r for r in backend(text, voice=voice, speed=new_speed, split_pattern=None)
|
||||
]
|
||||
chunks = [r.audio for r in results]
|
||||
if not chunks:
|
||||
return audio
|
||||
return np.concatenate([to_float32(c) for c in chunks])
|
||||
|
||||
|
||||
def process_subtitle_entries(
|
||||
subtitles: List[Tuple[float, Optional[float], str]],
|
||||
*,
|
||||
backend: Any,
|
||||
voice: Any,
|
||||
speed: float = 1.0,
|
||||
cancel_check: Callable[[], bool] = lambda: False,
|
||||
log_callback: Optional[Callable[[str], None]] = None,
|
||||
progress_callback: Optional[Callable[[int, str], None]] = None,
|
||||
replace_newlines: bool = True,
|
||||
use_gaps: bool = False,
|
||||
is_timestamp_text: bool = False,
|
||||
subtitle_speed_method: str = "tts",
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
) -> np.ndarray:
|
||||
"""Process subtitle entries: generate TTS for each and mix into buffer.
|
||||
|
||||
This is the core domain logic for subtitle-to-audio conversion.
|
||||
UI-specific concerns (signals, widgets) are handled via callbacks.
|
||||
|
||||
Args:
|
||||
subtitles: List of (start, end, text) tuples.
|
||||
backend: TTS pipeline callable.
|
||||
voice: Resolved voice for TTS.
|
||||
speed: TTS speed.
|
||||
cancel_check: Returns True if processing should stop.
|
||||
log_callback: Called with log messages.
|
||||
progress_callback: Called with (percent, etr_string).
|
||||
replace_newlines: Replace \\n with spaces in text.
|
||||
use_gaps: Whether to use silent gaps between subtitles.
|
||||
is_timestamp_text: Whether input is timestamp text.
|
||||
subtitle_speed_method: "ffmpeg" or "tts" for speed adjustment.
|
||||
sample_rate: Audio sample rate.
|
||||
|
||||
Returns:
|
||||
Mixed audio buffer (float32).
|
||||
"""
|
||||
if not subtitles:
|
||||
return np.array([], dtype="float32")
|
||||
|
||||
max_end = max((end for _, end, _ in subtitles if end is not None), default=0)
|
||||
buffer_samples = int(max_end * sample_rate) + sample_rate
|
||||
audio_buffer = np.zeros(buffer_samples, dtype="float32")
|
||||
etr_start = time.time()
|
||||
total = len(subtitles)
|
||||
|
||||
for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
|
||||
if cancel_check():
|
||||
break
|
||||
|
||||
processed_text = text.replace("\n", " ") if replace_newlines else text
|
||||
next_start = (
|
||||
subtitles[idx][0]
|
||||
if (use_gaps and idx < total)
|
||||
else float("inf")
|
||||
)
|
||||
subtitle_duration = None if end_time is None else end_time - start_time
|
||||
|
||||
is_auto_end = is_timestamp_text or (use_gaps and idx == total) or end_time is None
|
||||
if log_callback:
|
||||
log_callback(
|
||||
f"\n[{idx}/{total}] {format_time_range(start_time, end_time, is_auto_end)}: {processed_text}"
|
||||
)
|
||||
|
||||
# Generate TTS
|
||||
results = [
|
||||
r for r in backend(
|
||||
processed_text, voice=voice, speed=speed, split_pattern=None
|
||||
)
|
||||
if not cancel_check()
|
||||
]
|
||||
if cancel_check():
|
||||
break
|
||||
|
||||
audio_chunks = [r.audio for r in results]
|
||||
full_audio = (
|
||||
np.concatenate([to_float32(a) for a in audio_chunks])
|
||||
if audio_chunks
|
||||
else np.zeros(int((subtitle_duration or 0) * sample_rate), dtype="float32")
|
||||
)
|
||||
audio_duration = len(full_audio) / sample_rate
|
||||
|
||||
# Timing adjustment
|
||||
if is_timestamp_text:
|
||||
end_time = start_time + audio_duration
|
||||
subtitle_duration = audio_duration
|
||||
elif use_gaps:
|
||||
end_time = min(start_time + audio_duration, next_start)
|
||||
subtitle_duration = end_time - start_time
|
||||
elif subtitle_duration is None:
|
||||
subtitle_duration = audio_duration
|
||||
end_time = start_time + audio_duration
|
||||
|
||||
# Speed up if needed
|
||||
speedup_threshold = next_start - start_time if use_gaps else subtitle_duration
|
||||
if audio_duration > speedup_threshold and speedup_threshold > 0:
|
||||
speed_factor = audio_duration / speedup_threshold
|
||||
full_audio = speed_up_audio(
|
||||
full_audio, speed_factor,
|
||||
method=subtitle_speed_method,
|
||||
backend=backend, text=processed_text,
|
||||
voice=voice, base_speed=speed,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
audio_duration = len(full_audio) / sample_rate
|
||||
|
||||
# Adjust duration after speed change
|
||||
if use_gaps:
|
||||
end_time = min(start_time + audio_duration, next_start)
|
||||
subtitle_duration = end_time - start_time
|
||||
elif subtitle_duration is None:
|
||||
subtitle_duration = audio_duration
|
||||
end_time = start_time + audio_duration
|
||||
|
||||
# Pad or trim to subtitle duration
|
||||
full_audio = fit_audio_to_duration(full_audio, subtitle_duration, sample_rate)
|
||||
|
||||
# Mix into buffer
|
||||
start_sample = int(start_time * sample_rate)
|
||||
audio_buffer = mix_audio(audio_buffer, full_audio, start_sample)
|
||||
|
||||
# Progress
|
||||
if progress_callback:
|
||||
percent = min(int(idx / total * 100), 99)
|
||||
etr = calc_etr_str(time.time() - etr_start, idx, total)
|
||||
progress_callback(percent, etr)
|
||||
|
||||
# Normalize if needed
|
||||
if np.abs(audio_buffer).max() > 1.0:
|
||||
logger.info("Normalizing audio (peak: %.2f)", np.abs(audio_buffer).max())
|
||||
audio_buffer = normalize_audio(audio_buffer)
|
||||
|
||||
return audio_buffer
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Chapter parsing from raw text.
|
||||
|
||||
Provides a unified function for splitting text by chapter markers,
|
||||
used by both WebUI and PyQt conversion runners.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.subtitle_utils import clean_text
|
||||
|
||||
|
||||
_CHAPTER_MARKER_RE = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_chapters_from_text(
|
||||
text: str,
|
||||
default_title: str = "text",
|
||||
clean: bool = True,
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""Split raw text into chapters using chapter marker patterns.
|
||||
|
||||
Preserves content before the first marker as "Introduction" if present.
|
||||
Optionally applies clean_text() to each chapter segment.
|
||||
|
||||
Args:
|
||||
text: Raw text possibly containing <<CHAPTER_MARKER:Title>> markers.
|
||||
default_title: Fallback title when no markers are found.
|
||||
clean: Whether to apply clean_text() to each segment.
|
||||
|
||||
Returns:
|
||||
List of (title, text) tuples.
|
||||
"""
|
||||
matches = list(_CHAPTER_MARKER_RE.finditer(text))
|
||||
if not matches:
|
||||
cleaned = clean_text(text) if clean else text
|
||||
return [(default_title, cleaned)]
|
||||
|
||||
chapters: List[Tuple[str, str]] = []
|
||||
|
||||
# Preserve content before first marker as "Introduction"
|
||||
first_start = matches[0].start()
|
||||
if first_start > 0:
|
||||
intro_text = text[:first_start].strip()
|
||||
if intro_text:
|
||||
chapters.append(("Introduction", clean_text(intro_text) if clean else intro_text))
|
||||
|
||||
for idx, match in enumerate(matches):
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text)
|
||||
chapter_name = match.group(1).strip() or default_title
|
||||
chapter_text = text[start:end].strip()
|
||||
if clean:
|
||||
chapter_text = clean_text(chapter_text)
|
||||
chapters.append((chapter_name, chapter_text))
|
||||
|
||||
return chapters
|
||||
@@ -1,97 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from .metadata_helpers import (
|
||||
ensure_sentence,
|
||||
extract_series_metadata,
|
||||
format_author_sentence,
|
||||
format_series_sentence,
|
||||
normalize_metadata_map,
|
||||
)
|
||||
|
||||
|
||||
def build_title_intro_text(
|
||||
metadata: Optional[Mapping[str, Any]],
|
||||
fallback_basename: str,
|
||||
) -> str:
|
||||
"""Build the title introduction text from metadata."""
|
||||
normalized = normalize_metadata_map(metadata)
|
||||
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
|
||||
title = (
|
||||
normalized.get("title")
|
||||
or normalized.get("book_title")
|
||||
or normalized.get("album")
|
||||
or fallback_title
|
||||
)
|
||||
if not title:
|
||||
title = fallback_title
|
||||
subtitle = normalized.get("subtitle") or normalized.get("sub_title")
|
||||
if subtitle and title and subtitle.casefold() == title.casefold():
|
||||
subtitle = ""
|
||||
|
||||
author_value = ""
|
||||
for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"):
|
||||
value = normalized.get(candidate)
|
||||
if value:
|
||||
author_value = value
|
||||
break
|
||||
|
||||
series_name, series_number = extract_series_metadata(normalized)
|
||||
series_sentence = format_series_sentence(series_name, series_number)
|
||||
|
||||
sentences: List[str] = []
|
||||
if series_sentence:
|
||||
sentences.append(ensure_sentence(series_sentence))
|
||||
if title:
|
||||
sentences.append(ensure_sentence(title))
|
||||
if subtitle:
|
||||
sentences.append(ensure_sentence(subtitle))
|
||||
author_sentence = format_author_sentence(author_value)
|
||||
if author_sentence:
|
||||
sentences.append(ensure_sentence(author_sentence))
|
||||
return " ".join(sentences).strip()
|
||||
|
||||
|
||||
def build_outro_text(
|
||||
metadata: Optional[Mapping[str, Any]],
|
||||
fallback_basename: str,
|
||||
) -> str:
|
||||
"""Build the outro/closing text from metadata."""
|
||||
normalized = normalize_metadata_map(metadata)
|
||||
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
|
||||
title = (
|
||||
normalized.get("title")
|
||||
or normalized.get("book_title")
|
||||
or normalized.get("album")
|
||||
or fallback_title
|
||||
)
|
||||
author_value = ""
|
||||
for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"):
|
||||
value = normalized.get(candidate)
|
||||
if value:
|
||||
author_value = value
|
||||
break
|
||||
author_sentence = format_author_sentence(author_value)
|
||||
authors_fragment = (
|
||||
author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
|
||||
)
|
||||
|
||||
if title and authors_fragment:
|
||||
closing_line = f"The end of {title} from {authors_fragment}"
|
||||
elif title:
|
||||
closing_line = f"The end of {title}"
|
||||
elif authors_fragment:
|
||||
closing_line = f"The end from {authors_fragment}"
|
||||
else:
|
||||
closing_line = "The end"
|
||||
|
||||
series_name, series_number = extract_series_metadata(normalized)
|
||||
series_sentence = format_series_sentence(series_name, series_number)
|
||||
|
||||
sentences: List[str] = [ensure_sentence(closing_line)]
|
||||
if series_sentence:
|
||||
sentences.append(ensure_sentence(series_sentence))
|
||||
|
||||
return " ".join(sentence for sentence in sentences if sentence).strip()
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Shared token stubs for TTS processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FakeToken:
|
||||
"""Minimal token stub for languages without per-word token support."""
|
||||
|
||||
def __init__(self, text: str, start: float, end: float):
|
||||
self.text = text
|
||||
self.start_ts = start
|
||||
self.end_ts = end
|
||||
self.whitespace = ""
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Voice loading and caching utilities.
|
||||
|
||||
This module provides unified voice loading with caching support for both
|
||||
PyQt and WebUI interfaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
|
||||
class VoiceCache:
|
||||
"""Thread-safe voice cache for loaded voice tensors."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
def get(self, voice_spec: str) -> Optional[Any]:
|
||||
"""Get cached voice by spec."""
|
||||
return self._cache.get(voice_spec)
|
||||
|
||||
def set(self, voice_spec: str, voice: Any) -> None:
|
||||
"""Cache a loaded voice."""
|
||||
self._cache[voice_spec] = voice
|
||||
|
||||
def contains(self, voice_spec: str) -> bool:
|
||||
"""Check if voice is in cache."""
|
||||
return voice_spec in self._cache
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached voices."""
|
||||
self._cache.clear()
|
||||
|
||||
def keys(self):
|
||||
"""Return cached voice specs."""
|
||||
return self._cache.keys()
|
||||
|
||||
def __contains__(self, voice_spec: str) -> bool:
|
||||
return self.contains(voice_spec)
|
||||
|
||||
|
||||
def resolve_voice(
|
||||
voice_spec: str,
|
||||
pipeline: Any,
|
||||
use_gpu: bool,
|
||||
cache: Optional[VoiceCache] = None,
|
||||
) -> Any:
|
||||
"""Resolve voice spec to actual voice tensor or name.
|
||||
|
||||
If voice_spec contains '*' (formula), loads the voice using get_new_voice.
|
||||
Otherwise, returns the voice_spec as-is (it's a voice name).
|
||||
|
||||
Uses optional cache to avoid reloading same voice multiple times.
|
||||
|
||||
Args:
|
||||
voice_spec: Voice specification (name or formula string with '*').
|
||||
pipeline: TTS pipeline instance for loading formula voices.
|
||||
use_gpu: Whether to use GPU for voice loading.
|
||||
cache: Optional VoiceCache instance for caching loaded voices.
|
||||
|
||||
Returns:
|
||||
Loaded voice tensor (for formulas) or voice name string.
|
||||
"""
|
||||
# Check cache first
|
||||
if cache and cache.contains(voice_spec):
|
||||
return cache.get(voice_spec)
|
||||
|
||||
# Load voice
|
||||
if "*" in voice_spec:
|
||||
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
|
||||
return voice_spec
|
||||
loaded_voice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||
else:
|
||||
loaded_voice = voice_spec
|
||||
|
||||
# Cache it
|
||||
if cache:
|
||||
cache.set(voice_spec, loaded_voice)
|
||||
|
||||
return loaded_voice
|
||||
|
||||
|
||||
def load_voice_cached(
|
||||
voice_name: str,
|
||||
pipeline: Any,
|
||||
use_gpu: bool,
|
||||
cache: Any = None,
|
||||
) -> Any:
|
||||
"""Load voice with caching (compatibility wrapper for PyQt).
|
||||
|
||||
This function maintains backward compatibility with the PyQt interface
|
||||
while using the unified voice loading logic.
|
||||
|
||||
Args:
|
||||
voice_name: Voice name or formula string.
|
||||
pipeline: TTS pipeline instance.
|
||||
use_gpu: Whether to use GPU.
|
||||
cache: Optional VoiceCache or dict to use as cache.
|
||||
|
||||
Returns:
|
||||
Loaded voice tensor or voice name string.
|
||||
"""
|
||||
# Check cache (supports both VoiceCache and plain dict)
|
||||
if cache is not None:
|
||||
if isinstance(cache, VoiceCache):
|
||||
if cache.contains(voice_name):
|
||||
return cache.get(voice_name)
|
||||
elif voice_name in cache:
|
||||
return cache[voice_name]
|
||||
|
||||
# Load voice
|
||||
if "*" in voice_name:
|
||||
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
|
||||
return voice_name
|
||||
loaded_voice = get_new_voice(pipeline, voice_name, use_gpu)
|
||||
else:
|
||||
loaded_voice = voice_name
|
||||
|
||||
# Cache it
|
||||
if cache is not None:
|
||||
if isinstance(cache, VoiceCache):
|
||||
cache.set(voice_name, loaded_voice)
|
||||
else:
|
||||
cache[voice_name] = loaded_voice
|
||||
|
||||
return loaded_voice
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Voice resolution helpers.
|
||||
|
||||
Functions for resolving voice specifications, collecting required voice IDs,
|
||||
and determining the voice to use for chapters and chunks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, get_default_voice
|
||||
from abogen.voice_formulas import extract_voice_ids
|
||||
from abogen.voice_cache import ensure_voice_assets
|
||||
|
||||
|
||||
def spec_to_voice_ids(spec: Any) -> Set[str]:
|
||||
text = str(spec or "").strip()
|
||||
if not text:
|
||||
return set()
|
||||
if text == "__custom_mix":
|
||||
return set()
|
||||
if "*" in text:
|
||||
try:
|
||||
return set(extract_voice_ids(text))
|
||||
except ValueError:
|
||||
return set()
|
||||
if text in get_voices("kokoro"):
|
||||
return {text}
|
||||
return set()
|
||||
|
||||
|
||||
def job_voice_fallback(job: Any) -> str:
|
||||
base = str(getattr(job, "voice", "") or "").strip()
|
||||
if base and base != "__custom_mix":
|
||||
return base
|
||||
|
||||
speakers = getattr(job, "speakers", None)
|
||||
if isinstance(speakers, dict):
|
||||
narrator = speakers.get("narrator")
|
||||
if isinstance(narrator, dict):
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = narrator.get(key)
|
||||
candidate = str(value or "").strip()
|
||||
if candidate and candidate != "__custom_mix":
|
||||
return candidate
|
||||
for payload in speakers.values() or []:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = payload.get(key)
|
||||
candidate = str(value or "").strip()
|
||||
if candidate and candidate != "__custom_mix":
|
||||
return candidate
|
||||
|
||||
for chapter in getattr(job, "chapters", []) or []:
|
||||
if not isinstance(chapter, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
candidate = str(chapter.get(key) or "").strip()
|
||||
if candidate and candidate != "__custom_mix":
|
||||
return candidate
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def collect_required_voice_ids(job: Any) -> Set[str]:
|
||||
voices: Set[str] = set()
|
||||
voices.update(spec_to_voice_ids(job.voice))
|
||||
voices.update(spec_to_voice_ids(job_voice_fallback(job)))
|
||||
|
||||
for chapter in getattr(job, "chapters", []) or []:
|
||||
if not isinstance(chapter, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
voices.update(spec_to_voice_ids(chapter.get(key)))
|
||||
|
||||
for chunk in getattr(job, "chunks", []) or []:
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
voices.update(spec_to_voice_ids(chunk.get(key)))
|
||||
|
||||
speakers = getattr(job, "speakers", {})
|
||||
if isinstance(speakers, dict):
|
||||
for payload in speakers.values() or []:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
voices.update(spec_to_voice_ids(payload.get(key)))
|
||||
|
||||
voices.update(get_voices("kokoro"))
|
||||
return voices
|
||||
|
||||
|
||||
def initialize_voice_cache(job: Any) -> None:
|
||||
try:
|
||||
targets = collect_required_voice_ids(job)
|
||||
downloaded, errors = ensure_voice_assets(
|
||||
targets,
|
||||
on_progress=lambda message: job.add_log(message, level="debug"),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
job.add_log(f"Voice cache unavailable: {exc}", level="warning")
|
||||
return
|
||||
|
||||
if downloaded:
|
||||
job.add_log(
|
||||
f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.",
|
||||
level="info",
|
||||
)
|
||||
|
||||
for voice_id, error in errors.items():
|
||||
job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning")
|
||||
|
||||
|
||||
def chapter_voice_spec(job: Any, override: Optional[Dict[str, Any]]) -> str:
|
||||
if not override:
|
||||
return job_voice_fallback(job)
|
||||
|
||||
resolved = str(override.get("resolved_voice", "")).strip()
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
formula = str(override.get("voice_formula", "")).strip()
|
||||
if formula:
|
||||
return formula
|
||||
|
||||
voice = str(override.get("voice", "")).strip()
|
||||
if voice:
|
||||
return voice
|
||||
|
||||
return job_voice_fallback(job)
|
||||
|
||||
|
||||
def chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str:
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = chunk.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
speaker_id = chunk.get("speaker_id")
|
||||
speakers = getattr(job, "speakers", None)
|
||||
if isinstance(speakers, dict) and speaker_id in speakers:
|
||||
speaker_entry = speakers.get(speaker_id) or {}
|
||||
if isinstance(speaker_entry, dict):
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = speaker_entry.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
profile_formula = speaker_entry.get("voice_formula")
|
||||
if profile_formula:
|
||||
return str(profile_formula)
|
||||
|
||||
profile_name = chunk.get("voice_profile")
|
||||
if profile_name:
|
||||
if isinstance(speakers, dict):
|
||||
speaker_entry = speakers.get(profile_name)
|
||||
if isinstance(speaker_entry, dict):
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = speaker_entry.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
if fallback:
|
||||
return fallback
|
||||
return job_voice_fallback(job)
|
||||
|
||||
|
||||
def resolve_fallback_voice_spec(
|
||||
base_spec: str,
|
||||
job_voice: str,
|
||||
voice_cache_keys: list[str],
|
||||
provider: str = "kokoro",
|
||||
) -> str:
|
||||
"""Resolve the voice spec for intro/outro with a priority fallback chain.
|
||||
|
||||
Priority: base_spec → job_voice → first voice_cache key → default voice.
|
||||
``"__custom_mix"`` is treated as empty (it is not a usable voice spec).
|
||||
"""
|
||||
spec = base_spec or job_voice
|
||||
if spec == "__custom_mix":
|
||||
spec = job_voice or ""
|
||||
if not spec:
|
||||
for key in voice_cache_keys:
|
||||
if key and key != "__custom_mix":
|
||||
spec = key.split(":", 1)[-1]
|
||||
break
|
||||
if not spec:
|
||||
spec = get_default_voice(provider)
|
||||
return spec
|
||||
@@ -1,130 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple, Set
|
||||
|
||||
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
def infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||
"""Infer TTS provider from voice specification."""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
if raw.upper() == raw and raw.replace("_", "").isalnum():
|
||||
return "supertonic"
|
||||
if raw == "__custom_mix" or "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
if raw in get_voices("kokoro"):
|
||||
return "kokoro"
|
||||
return fallback
|
||||
|
||||
|
||||
def supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
||||
"""Normalize a voice specification for Supertonic.
|
||||
|
||||
This function only performs Supertonic-specific normalization (uppercase conversion
|
||||
and fallback handling). Backend resolution is handled by the registry.
|
||||
"""
|
||||
raw = str(spec or "").strip()
|
||||
fallback_raw = str(fallback or "").strip()
|
||||
|
||||
# Normalize to uppercase for Supertonic voice IDs
|
||||
upper = raw.upper() if raw else ""
|
||||
|
||||
# If empty or contains formula characters, use fallback
|
||||
if not upper or "*" in upper or "+" in upper:
|
||||
upper = fallback_raw.upper() if fallback_raw else ""
|
||||
|
||||
# If still empty, use default Supertonic voice
|
||||
if not upper or "*" in upper or "+" in upper:
|
||||
upper = "M1"
|
||||
|
||||
return upper
|
||||
|
||||
|
||||
def split_speaker_reference(value: Any) -> Tuple[Optional[str], str]:
|
||||
"""Parse speaker/profile reference from string.
|
||||
|
||||
Expected format: "speaker:name" or "profile:name"
|
||||
Returns (name, original) or (None, original) if not a valid reference.
|
||||
"""
|
||||
raw = str(value or "").strip()
|
||||
if not raw or ":" not in raw:
|
||||
return None, raw
|
||||
prefix, remainder = raw.split(":", 1)
|
||||
prefix = prefix.strip().lower()
|
||||
if prefix not in {"speaker", "profile"}:
|
||||
return None, raw
|
||||
name = remainder.strip()
|
||||
return (name or None), raw
|
||||
|
||||
|
||||
def formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
||||
"""Build voice formula string from kokoro entry."""
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return ""
|
||||
total = 0.0
|
||||
parts: list[tuple[str, float]] = []
|
||||
for item in voices:
|
||||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||
continue
|
||||
name = str(item[0] or "").strip()
|
||||
try:
|
||||
weight = float(item[1])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if name and weight > 0:
|
||||
parts.append((name, weight))
|
||||
total += weight
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
normalized = [(name, weight / total) for name, weight in parts]
|
||||
return " + ".join(f"{name}*{weight:.6f}" for name, weight in normalized)
|
||||
|
||||
|
||||
def coerce_truthy(value: Any, default: bool = True) -> bool:
|
||||
"""Coerce a value to boolean with default."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() not in {"false", "0", "no", "off", ""}
|
||||
if value is None:
|
||||
return default
|
||||
return bool(value)
|
||||
|
||||
|
||||
def resolve_voice_target(
|
||||
raw_spec: str,
|
||||
normalized_profiles: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
job_voice: str = "M1",
|
||||
job_tts_provider: str = "kokoro",
|
||||
job_supertonic_total_steps: int = 5,
|
||||
job_speed: float = 1.0,
|
||||
) -> Tuple[str, str, Optional[float], Optional[int]]:
|
||||
"""Resolve a raw voice spec into (provider, voice_spec, speed_override, steps_override).
|
||||
|
||||
Pure function — all dependencies are passed as parameters.
|
||||
"""
|
||||
spec = str(raw_spec or "").strip()
|
||||
speaker_name, _ = split_speaker_reference(spec)
|
||||
if speaker_name and speaker_name in normalized_profiles:
|
||||
entry = normalized_profiles[speaker_name]
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro"
|
||||
if provider == "supertonic":
|
||||
voice = str(entry.get("voice") or job_voice or "M1").strip() or "M1"
|
||||
steps = int(entry.get("total_steps") or job_supertonic_total_steps or 5)
|
||||
speed = float(entry.get("speed") or job_speed or 1.0)
|
||||
return "supertonic", supertonic_voice_from_spec(voice, job_voice), speed, steps
|
||||
formula = formula_from_kokoro_entry(entry)
|
||||
return "kokoro", formula or spec, None, None
|
||||
|
||||
fallback_provider = str(job_tts_provider or "kokoro").strip().lower() or "kokoro"
|
||||
inferred = infer_provider_from_spec(spec, fallback=fallback_provider)
|
||||
if inferred == "supertonic":
|
||||
return "supertonic", supertonic_voice_from_spec(spec, job_voice), None, None
|
||||
return "kokoro", spec, None, None
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Abogen Flet Frontend Package.
|
||||
|
||||
This package provides a unified, dual-target (desktop + web) user interface
|
||||
for the Abogen audiobook generation application, built with the Flet framework.
|
||||
"""
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Components sub-package."""
|
||||
from .widgets import (
|
||||
resolve_icon,
|
||||
build_drop_zone,
|
||||
build_log_terminal,
|
||||
log_entry,
|
||||
build_progress_row,
|
||||
build_primary_button,
|
||||
build_secondary_button,
|
||||
build_card,
|
||||
build_section_header,
|
||||
build_status_badge,
|
||||
labelled_row,
|
||||
show_snack,
|
||||
build_divider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_drop_zone",
|
||||
"resolve_icon",
|
||||
"build_log_terminal",
|
||||
"log_entry",
|
||||
"build_progress_row",
|
||||
"build_primary_button",
|
||||
"build_secondary_button",
|
||||
"build_card",
|
||||
"build_section_header",
|
||||
"build_status_badge",
|
||||
"labelled_row",
|
||||
"show_snack",
|
||||
"build_divider",
|
||||
]
|
||||
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
Reusable UI components for the Abogen Flet frontend.
|
||||
|
||||
Each function in this module returns a standalone Flet control or small
|
||||
widget tree. Components read the current palette from the page's theme
|
||||
mode and should not hold any mutable state themselves – state lives in the
|
||||
session's ``AppState`` object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_icon(icon: Any) -> Any:
|
||||
"""Convert a snake_case icon name to Flet IconData when possible."""
|
||||
if isinstance(icon, str):
|
||||
return getattr(ft.Icons, icon.upper(), icon)
|
||||
return icon
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drop-zone (file input area)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_drop_zone(
|
||||
*,
|
||||
on_pick: Callable[[], None],
|
||||
label: str = "Drag & drop your file here or click to browse",
|
||||
sub_label: str = "Supports: .txt · .epub · .pdf · .md · .srt · .ass · .vtt",
|
||||
accent: bool = False,
|
||||
error: bool = False,
|
||||
filename: Optional[str] = None,
|
||||
file_size: Optional[str] = None,
|
||||
char_count: Optional[str] = None,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.GestureDetector:
|
||||
"""
|
||||
Build an interactive file drop-zone widget.
|
||||
|
||||
The zone shows a dashed border and centred instructions by default,
|
||||
switching to an 'active' green style when a file is loaded and a red
|
||||
style when an error has occurred.
|
||||
|
||||
Args:
|
||||
on_pick: Callback invoked when the user clicks or activates the zone.
|
||||
label: Primary instruction text.
|
||||
sub_label: Secondary hint text shown beneath the label.
|
||||
accent: When True, renders the 'active/success' green style.
|
||||
error: When True, renders the 'error/red' style.
|
||||
filename: When provided, replaces the instruction text with file info.
|
||||
file_size: Human-readable file size to display alongside the filename.
|
||||
char_count: Character count to display alongside file info.
|
||||
page: The current Flet ``Page``; used to derive the active palette.
|
||||
|
||||
Returns:
|
||||
A ``ft.GestureDetector`` wrapping the visual drop-zone container.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
p = get_palette(page) if page else None
|
||||
|
||||
# Colour scheme
|
||||
if error:
|
||||
border_color = "#e84e3c" if dark else "#c0392b"
|
||||
bg_color = "#1a0a08" if dark else "#fff5f5"
|
||||
text_color = "#e84e3c" if dark else "#c0392b"
|
||||
icon_name = "error_outline"
|
||||
elif accent:
|
||||
border_color = "#42ad4a" if dark else "#2e9437"
|
||||
bg_color = "#091810" if dark else "#f0fff1"
|
||||
text_color = "#42ad4a" if dark else "#2e9437"
|
||||
icon_name = "check_circle_outline"
|
||||
else:
|
||||
border_color = "#3a4466" if dark else "#a8b4d0"
|
||||
bg_color = "#151928" if dark else "#f7f8fd"
|
||||
text_color = "#9ba3b8" if dark else "#5a6172"
|
||||
icon_name = "upload_file"
|
||||
|
||||
if filename:
|
||||
# Compact file-info display
|
||||
info_rows: List[ft.Control] = [
|
||||
ft.Row(
|
||||
[
|
||||
ft.Icon(resolve_icon("insert_drive_file"), color=text_color, size=28),
|
||||
ft.Column(
|
||||
[
|
||||
ft.Text(
|
||||
filename,
|
||||
weight=ft.FontWeight.W_600,
|
||||
size=13,
|
||||
color=text_color,
|
||||
no_wrap=False,
|
||||
max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
],
|
||||
tight=True,
|
||||
expand=True,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
spacing=SPACE_SM,
|
||||
)
|
||||
]
|
||||
if file_size or char_count:
|
||||
chips: List[ft.Control] = []
|
||||
if file_size:
|
||||
chips.append(
|
||||
ft.Text(f"📄 {file_size}", size=11, color=text_color, italic=True)
|
||||
)
|
||||
if char_count:
|
||||
chips.append(
|
||||
ft.Text(f"🔤 {char_count} chars", size=11, color=text_color, italic=True)
|
||||
)
|
||||
info_rows.append(
|
||||
ft.Row(chips, alignment=ft.MainAxisAlignment.CENTER, spacing=SPACE_MD)
|
||||
)
|
||||
content = ft.Column(
|
||||
info_rows,
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=SPACE_SM,
|
||||
)
|
||||
else:
|
||||
content = ft.Column(
|
||||
[
|
||||
ft.Icon(resolve_icon(icon_name), size=48, color=border_color, opacity=0.8),
|
||||
ft.Text(
|
||||
label,
|
||||
size=14,
|
||||
weight=ft.FontWeight.W_500,
|
||||
color=text_color,
|
||||
text_align=ft.TextAlign.CENTER,
|
||||
),
|
||||
ft.Text(
|
||||
sub_label,
|
||||
size=11,
|
||||
color=text_color,
|
||||
opacity=0.6,
|
||||
text_align=ft.TextAlign.CENTER,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=SPACE_SM,
|
||||
)
|
||||
|
||||
inner = ft.Container(
|
||||
content=content,
|
||||
border=ft.Border.all(2, border_color),
|
||||
border_radius=RADIUS_MD,
|
||||
bgcolor=bg_color,
|
||||
padding=ft.Padding.all(SPACE_LG),
|
||||
height=160,
|
||||
alignment=ft.Alignment.CENTER,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
return ft.GestureDetector(
|
||||
content=ft.Row([inner], spacing=0),
|
||||
on_tap=lambda _: on_pick(),
|
||||
mouse_cursor=ft.MouseCursor.CLICK,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log terminal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_log_terminal(
|
||||
*,
|
||||
ref: Optional[ft.Ref] = None,
|
||||
max_height: int = 260,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.Container:
|
||||
"""
|
||||
Build a scrollable, read-only log terminal widget.
|
||||
|
||||
Args:
|
||||
ref: Optional ``ft.Ref[ft.ListView]`` to bind the inner list-view so
|
||||
callers can append entries programmatically.
|
||||
max_height: Maximum pixel height before vertical scrolling activates.
|
||||
page: Current Flet ``Page`` for palette derivation.
|
||||
|
||||
Returns:
|
||||
A styled ``ft.Container`` wrapping a ``ft.ListView``.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
bg = "#0d1117" if dark else "#f8f9fc"
|
||||
text_color = "#b0b8cc" if dark else "#3d4358"
|
||||
border_color = "#252a38" if dark else "#dce0ea"
|
||||
|
||||
list_view = ft.ListView(
|
||||
expand=True,
|
||||
auto_scroll=True,
|
||||
spacing=1,
|
||||
padding=ft.Padding.all(SPACE_SM),
|
||||
)
|
||||
if ref is not None:
|
||||
ref.current = list_view
|
||||
|
||||
return ft.Container(
|
||||
content=list_view,
|
||||
bgcolor=bg,
|
||||
border=ft.Border.all(1, border_color),
|
||||
border_radius=RADIUS_SM,
|
||||
height=max_height,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
)
|
||||
|
||||
|
||||
def log_entry(message: str, level: str = "info", page: Optional[ft.Page] = None) -> ft.Text:
|
||||
"""
|
||||
Create a single log-line ``ft.Text`` widget with appropriate colour coding.
|
||||
|
||||
Args:
|
||||
message: The log message string.
|
||||
level: Severity string: ``'info'``, ``'success'``, ``'error'``,
|
||||
``'warning'``, ``'debug'``, ``'critical'``.
|
||||
page: Current Flet ``Page`` for dark/light mode detection.
|
||||
|
||||
Returns:
|
||||
A styled ``ft.Text`` control.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
palette: dict[str, str] = {
|
||||
"info": "#9ba3b8" if dark else "#5a6172",
|
||||
"success": "#42ad4a" if dark else "#2e9437",
|
||||
"error": "#e84e3c" if dark else "#c0392b",
|
||||
"warning": "#f5a623" if dark else "#d4870a",
|
||||
"debug": "#5a6172" if dark else "#9ba3b8",
|
||||
"critical": "#ff5722",
|
||||
"trace": "#4e5568" if dark else "#b0b8cc",
|
||||
}
|
||||
color = palette.get(level.lower(), palette["info"])
|
||||
return ft.Text(message, size=12, color=color, selectable=True, no_wrap=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Progress row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_progress_row(
|
||||
*,
|
||||
progress_value: float = 0.0,
|
||||
etr_text: str = "",
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.Column:
|
||||
"""
|
||||
Build a progress-bar + ETR-label column.
|
||||
|
||||
Args:
|
||||
progress_value: Float in [0.0, 1.0].
|
||||
etr_text: Pre-formatted estimated-time-remaining string.
|
||||
page: Current ``Page`` for palette derivation.
|
||||
|
||||
Returns:
|
||||
A ``ft.Column`` containing the progress bar and label.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
fill = "#5b8af5" if dark else "#3a5fc4"
|
||||
bg = "#1e2230" if dark else "#e4e8f0"
|
||||
|
||||
bar = ft.ProgressBar(
|
||||
value=progress_value,
|
||||
color=fill,
|
||||
bgcolor=bg,
|
||||
height=8,
|
||||
border_radius=ft.BorderRadius.all(4),
|
||||
expand=True,
|
||||
)
|
||||
label = ft.Text(
|
||||
etr_text,
|
||||
size=11,
|
||||
color="#9ba3b8" if dark else "#5a6172",
|
||||
text_align=ft.TextAlign.CENTER,
|
||||
)
|
||||
return ft.Column(
|
||||
[bar, label],
|
||||
spacing=SPACE_SM,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primary action button
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_primary_button(
|
||||
text: str,
|
||||
*,
|
||||
icon: Optional[str] = None,
|
||||
on_click: Optional[Callable] = None,
|
||||
disabled: bool = False,
|
||||
width: Optional[int] = None,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.ElevatedButton:
|
||||
"""
|
||||
Build a prominent, styled primary action button.
|
||||
|
||||
Args:
|
||||
text: Button label.
|
||||
icon: Optional Flet icon name (e.g. ``'play_arrow'``).
|
||||
on_click: Click callback.
|
||||
disabled: Whether the button is non-interactive.
|
||||
width: Optional fixed pixel width.
|
||||
page: Current ``Page`` for accent colour derivation.
|
||||
|
||||
Returns:
|
||||
A styled ``ft.ElevatedButton``.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
bg = "#5b8af5" if dark else "#3a5fc4"
|
||||
on_bg = "#ffffff"
|
||||
|
||||
style = ft.ButtonStyle(
|
||||
bgcolor={
|
||||
ft.ControlState.DEFAULT: bg,
|
||||
ft.ControlState.HOVERED: "#3a5fc4" if dark else "#2a4fae",
|
||||
ft.ControlState.DISABLED: "#2a2f3f" if dark else "#c0c8d8",
|
||||
},
|
||||
color={
|
||||
ft.ControlState.DEFAULT: on_bg,
|
||||
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
|
||||
},
|
||||
elevation={"default": 2, "hovered": 4},
|
||||
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
|
||||
animation_duration=150,
|
||||
)
|
||||
|
||||
return ft.ElevatedButton(
|
||||
content=text,
|
||||
icon=resolve_icon(icon),
|
||||
on_click=on_click,
|
||||
disabled=disabled,
|
||||
width=width,
|
||||
style=style,
|
||||
height=48,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secondary / ghost button
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_secondary_button(
|
||||
text: str,
|
||||
*,
|
||||
icon: Optional[str] = None,
|
||||
on_click: Optional[Callable] = None,
|
||||
disabled: bool = False,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.OutlinedButton:
|
||||
"""
|
||||
Build a secondary outlined button.
|
||||
|
||||
Args:
|
||||
text: Button label.
|
||||
icon: Optional Flet icon name.
|
||||
on_click: Click callback.
|
||||
disabled: Whether the button is non-interactive.
|
||||
page: Current ``Page`` for border colour derivation.
|
||||
|
||||
Returns:
|
||||
A styled ``ft.OutlinedButton``.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
border_clr = "#3a4466" if dark else "#a8b4d0"
|
||||
text_clr = "#e8eaf0" if dark else "#1a1d27"
|
||||
|
||||
style = ft.ButtonStyle(
|
||||
side={
|
||||
ft.ControlState.DEFAULT: ft.BorderSide(1.5, border_clr),
|
||||
ft.ControlState.HOVERED: ft.BorderSide(1.5, "#5b8af5" if dark else "#3a5fc4"),
|
||||
},
|
||||
color={
|
||||
ft.ControlState.DEFAULT: text_clr,
|
||||
ft.ControlState.HOVERED: "#5b8af5" if dark else "#3a5fc4",
|
||||
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
|
||||
},
|
||||
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
|
||||
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
|
||||
animation_duration=150,
|
||||
)
|
||||
|
||||
return ft.OutlinedButton(
|
||||
content=text,
|
||||
icon=resolve_icon(icon),
|
||||
on_click=on_click,
|
||||
disabled=disabled,
|
||||
style=style,
|
||||
height=44,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_card(
|
||||
content: ft.Control,
|
||||
*,
|
||||
padding: int = SPACE_LG,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.Container:
|
||||
"""
|
||||
Wrap a control in a styled card container.
|
||||
|
||||
Args:
|
||||
content: The child control to embed.
|
||||
padding: Internal padding in pixels.
|
||||
page: Current ``Page`` for palette derivation.
|
||||
|
||||
Returns:
|
||||
A styled ``ft.Container``.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
bg = "#181b23" if dark else "#ffffff"
|
||||
border_clr = "#2c3147" if dark else "#dce0ea"
|
||||
|
||||
return ft.Container(
|
||||
content=content,
|
||||
bgcolor=bg,
|
||||
border=ft.Border.all(1, border_clr),
|
||||
border_radius=RADIUS_MD,
|
||||
padding=ft.Padding.all(padding),
|
||||
shadow=ft.BoxShadow(
|
||||
spread_radius=0,
|
||||
blur_radius=12,
|
||||
color=ft.Colors.with_opacity(0.12 if dark else 0.06, ft.Colors.BLACK),
|
||||
offset=ft.Offset(0, 2),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_section_header(
|
||||
title: str,
|
||||
*,
|
||||
subtitle: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.Row:
|
||||
"""
|
||||
Build a consistent section header row with an optional icon.
|
||||
|
||||
Args:
|
||||
title: Section heading text.
|
||||
subtitle: Optional explanatory sub-text.
|
||||
icon: Optional Flet icon name.
|
||||
page: Current ``Page`` for palette derivation.
|
||||
|
||||
Returns:
|
||||
A ``ft.Row`` containing the icon and text column.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
title_color = "#e8eaf0" if dark else "#1a1d27"
|
||||
sub_color = "#9ba3b8" if dark else "#5a6172"
|
||||
accent = "#5b8af5" if dark else "#3a5fc4"
|
||||
|
||||
children: List[ft.Control] = []
|
||||
if icon:
|
||||
children.append(ft.Icon(resolve_icon(icon), size=20, color=accent))
|
||||
|
||||
text_parts: List[ft.Control] = [
|
||||
ft.Text(title, size=15, weight=ft.FontWeight.W_600, color=title_color)
|
||||
]
|
||||
if subtitle:
|
||||
text_parts.append(ft.Text(subtitle, size=11, color=sub_color))
|
||||
|
||||
children.append(
|
||||
ft.Column(text_parts, spacing=1, tight=True, expand=True)
|
||||
)
|
||||
|
||||
return ft.Row(children, spacing=SPACE_SM, vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status badge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_status_badge(
|
||||
label: str,
|
||||
*,
|
||||
variant: str = "info",
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.Container:
|
||||
"""
|
||||
Build a small status badge chip.
|
||||
|
||||
Args:
|
||||
label: Badge text.
|
||||
variant: Colour variant: ``'info'``, ``'success'``, ``'error'``,
|
||||
``'warning'``, ``'neutral'``.
|
||||
page: Current ``Page`` for theme derivation.
|
||||
|
||||
Returns:
|
||||
A pill-shaped ``ft.Container``.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
palette = {
|
||||
"info": ("#1a2a5e" if dark else "#dde8ff", "#5b8af5" if dark else "#3a5fc4"),
|
||||
"success": ("#0d2010" if dark else "#d4f4d7", "#42ad4a" if dark else "#2e9437"),
|
||||
"error": ("#2a0a08" if dark else "#ffe0dc", "#e84e3c" if dark else "#c0392b"),
|
||||
"warning": ("#2a1a00" if dark else "#fff4d8", "#f5a623" if dark else "#d4870a"),
|
||||
"neutral": ("#1e2230" if dark else "#edf0f5", "#9ba3b8" if dark else "#5a6172"),
|
||||
}
|
||||
bg, fg = palette.get(variant, palette["info"])
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Text(label, size=10, weight=ft.FontWeight.W_600, color=fg),
|
||||
bgcolor=bg,
|
||||
border_radius=999,
|
||||
padding=ft.Padding.symmetric(horizontal=8, vertical=3),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Labelled control row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def labelled_row(
|
||||
label: str,
|
||||
control: ft.Control,
|
||||
*,
|
||||
label_width: int = 200,
|
||||
tooltip: Optional[str] = None,
|
||||
page: Optional[ft.Page] = None,
|
||||
) -> ft.Row:
|
||||
"""
|
||||
Lay a label and a control side-by-side in a consistent row.
|
||||
|
||||
Args:
|
||||
label: Human-readable label text.
|
||||
control: The UI control placed to the right of the label.
|
||||
label_width: Fixed pixel width of the label column.
|
||||
tooltip: Optional tooltip text on the label.
|
||||
page: Current ``Page`` for palette derivation.
|
||||
|
||||
Returns:
|
||||
A ``ft.Row`` with the label pinned to a fixed width.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
lbl_color = "#9ba3b8" if dark else "#5a6172"
|
||||
|
||||
lbl = ft.Text(label, size=13, color=lbl_color, weight=ft.FontWeight.W_500, width=label_width)
|
||||
if tooltip:
|
||||
lbl.tooltip = tooltip
|
||||
|
||||
return ft.Row(
|
||||
[lbl, ft.Container(content=control, expand=True)],
|
||||
alignment=ft.MainAxisAlignment.START,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
spacing=SPACE_MD,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snack-bar helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def show_snack(
|
||||
page: ft.Page,
|
||||
message: str,
|
||||
*,
|
||||
error: bool = False,
|
||||
duration: int = 3000,
|
||||
) -> None:
|
||||
"""
|
||||
Display a brief snack-bar notification.
|
||||
|
||||
Args:
|
||||
page: The Flet ``Page`` instance.
|
||||
message: Text to display.
|
||||
error: When True, colours the bar red instead of the default accent.
|
||||
duration: Visible duration in milliseconds.
|
||||
"""
|
||||
dark = page.theme_mode == ft.ThemeMode.DARK
|
||||
bg = "#e84e3c" if error else ("#5b8af5" if dark else "#3a5fc4")
|
||||
page.snack_bar = ft.SnackBar(
|
||||
content=ft.Text(message, color="#ffffff", size=13),
|
||||
bgcolor=bg,
|
||||
duration=duration,
|
||||
show_close_icon=True,
|
||||
close_icon_color="#ffffff",
|
||||
)
|
||||
page.snack_bar.open = True
|
||||
page.update()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Divider helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_divider(page: Optional[ft.Page] = None) -> ft.Divider:
|
||||
"""
|
||||
Build a styled horizontal rule divider.
|
||||
|
||||
Args:
|
||||
page: Current ``Page`` for palette derivation.
|
||||
|
||||
Returns:
|
||||
A ``ft.Divider``.
|
||||
"""
|
||||
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||
return ft.Divider(color="#252a38" if dark else "#e8ebf2", height=1, thickness=1)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Abogen Flet Frontend – main entry point.
|
||||
|
||||
Run as desktop app:
|
||||
python -m abogen.frontend.main
|
||||
|
||||
Run as web app (binds to port 8080 by default):
|
||||
python -m abogen.frontend.main --web --port 8080
|
||||
|
||||
Architecture
|
||||
------------
|
||||
One ``ft.app()`` call launches the server. For every new browser tab (or the
|
||||
desktop window) Flet invokes ``_app_entry(page)`` in its own coroutine, which
|
||||
creates a fresh ``AppState`` and wires together the navigation rail and views.
|
||||
This guarantees complete per-session isolation in multi-user web deployments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from .state import AppState
|
||||
from .components import resolve_icon
|
||||
from .views.dashboard import DashboardView
|
||||
from .views.settings import SettingsView
|
||||
from .views.queue_view import QueueView
|
||||
from .utils.theme import make_theme, DARK, LIGHT, SPACE_SM, SPACE_MD, SPACE_LG, RADIUS_MD
|
||||
from abogen.constants import PROGRAM_NAME as APP_NAME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navigation destinations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NAV_ITEMS = [
|
||||
("Convert", "swap_horiz", "swap_horiz"),
|
||||
("Queue", "list_alt", "list_alt"),
|
||||
("Settings", "settings", "settings"),
|
||||
]
|
||||
|
||||
_ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
|
||||
|
||||
|
||||
def _build_sidebar_item(
|
||||
*,
|
||||
label: str,
|
||||
icon: str,
|
||||
selected: bool,
|
||||
palette,
|
||||
on_click,
|
||||
) -> ft.Container:
|
||||
accent = palette.accent if selected else palette.text_secondary
|
||||
bg = palette.sidebar_selected_bg if selected else palette.sidebar_bg
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Icon(resolve_icon(icon), size=20, color=accent),
|
||||
ft.Text(
|
||||
label,
|
||||
size=13,
|
||||
weight=ft.FontWeight.W_600 if selected else ft.FontWeight.W_500,
|
||||
color=accent,
|
||||
),
|
||||
],
|
||||
spacing=SPACE_MD,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
bgcolor=bg,
|
||||
border_radius=RADIUS_MD,
|
||||
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=10),
|
||||
ink=True,
|
||||
on_click=on_click,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-session entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _app_entry(page: ft.Page) -> None:
|
||||
try:
|
||||
# ── State ────────────────────────────────────────────────────────────
|
||||
state = AppState()
|
||||
state.load_from_config()
|
||||
|
||||
# ── Page basics ──────────────────────────────────────────────────────
|
||||
page.title = APP_NAME
|
||||
page.padding = 0
|
||||
page.spacing = 0
|
||||
page.bgcolor = DARK.bg_base
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.theme = make_theme(dark=True)
|
||||
page.dark_theme = make_theme(dark=True)
|
||||
page.fonts = {}
|
||||
page.window.min_width = 520
|
||||
page.window.min_height = 600
|
||||
page.update()
|
||||
|
||||
# ── Content area ref ─────────────────────────────────────────────────
|
||||
content_area = ft.Column(expand=True, spacing=0)
|
||||
sidebar_body = ft.Column(spacing=SPACE_SM)
|
||||
theme_button_host = ft.Container()
|
||||
brand_title = ft.Text(
|
||||
APP_NAME,
|
||||
size=18,
|
||||
weight=ft.FontWeight.W_700,
|
||||
color=DARK.text_primary,
|
||||
)
|
||||
brand_fallback_icon = ft.Icon(resolve_icon("speaker_notes"), size=32, color=DARK.accent)
|
||||
divider = ft.VerticalDivider(width=1, color=DARK.border)
|
||||
|
||||
# ── Views ────────────────────────────────────────────────────────────
|
||||
dashboard_view = DashboardView(page, state)
|
||||
settings_view = SettingsView(page, state)
|
||||
queue_view = QueueView(page, state)
|
||||
|
||||
views = [
|
||||
dashboard_view.build,
|
||||
queue_view.build,
|
||||
settings_view.build,
|
||||
]
|
||||
_selected_index = [0]
|
||||
|
||||
def _refresh_sidebar() -> None:
|
||||
dark = page.theme_mode == ft.ThemeMode.DARK
|
||||
pal = DARK if dark else LIGHT
|
||||
sidebar_body.controls = [
|
||||
_build_sidebar_item(
|
||||
label=label,
|
||||
icon=icon,
|
||||
selected=index == _selected_index[0],
|
||||
palette=pal,
|
||||
on_click=lambda _, i=index: _navigate(i),
|
||||
)
|
||||
for index, (label, icon, _) in enumerate(_NAV_ITEMS)
|
||||
]
|
||||
sidebar.bgcolor = pal.sidebar_bg
|
||||
divider.color = pal.border
|
||||
brand_title.color = pal.text_primary
|
||||
brand_fallback_icon.color = pal.accent
|
||||
theme_button_host.content = ft.Container(
|
||||
content=ft.Icon(
|
||||
resolve_icon("dark_mode" if dark else "light_mode"),
|
||||
size=20,
|
||||
color=pal.text_secondary,
|
||||
),
|
||||
tooltip="Toggle theme",
|
||||
border_radius=RADIUS_MD,
|
||||
padding=8,
|
||||
ink=True,
|
||||
on_click=lambda _: _toggle_theme(page, _refresh_sidebar),
|
||||
)
|
||||
|
||||
def _navigate(index: int) -> None:
|
||||
_selected_index[0] = index
|
||||
content_area.controls.clear()
|
||||
built = views[index]()
|
||||
content_area.controls.append(
|
||||
ft.Container(
|
||||
content=built,
|
||||
expand=True,
|
||||
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_LG),
|
||||
)
|
||||
)
|
||||
_refresh_sidebar()
|
||||
page.update()
|
||||
|
||||
# ── Sidebar ──────────────────────────────────────────────────────────
|
||||
pal = DARK
|
||||
sidebar = ft.Container(
|
||||
width=220,
|
||||
bgcolor=pal.sidebar_bg,
|
||||
padding=ft.Padding.all(SPACE_MD),
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Image(
|
||||
src="icon.png",
|
||||
width=36,
|
||||
height=36,
|
||||
fit=ft.BoxFit.CONTAIN,
|
||||
error_content=brand_fallback_icon,
|
||||
),
|
||||
brand_title,
|
||||
],
|
||||
spacing=SPACE_MD,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
padding=ft.Padding.only(top=SPACE_SM, bottom=SPACE_LG),
|
||||
),
|
||||
sidebar_body,
|
||||
ft.Container(expand=True),
|
||||
ft.Row([theme_button_host], alignment=ft.MainAxisAlignment.END),
|
||||
],
|
||||
expand=True,
|
||||
spacing=SPACE_SM,
|
||||
),
|
||||
)
|
||||
_refresh_sidebar()
|
||||
|
||||
# ── Page handle for pubsub (queue → dashboard) ───────────────────────
|
||||
def _handle_pubsub(topic: str) -> None:
|
||||
if topic == "start_queue":
|
||||
_navigate(0)
|
||||
|
||||
page.pubsub.subscribe(_handle_pubsub)
|
||||
|
||||
# ── Layout ────────────────────────────────────────────────────────────
|
||||
page.add(
|
||||
ft.Row(
|
||||
[
|
||||
sidebar,
|
||||
divider,
|
||||
ft.Container(content=content_area, expand=True),
|
||||
],
|
||||
expand=True,
|
||||
spacing=0,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||
)
|
||||
)
|
||||
|
||||
# Show dashboard by default
|
||||
_navigate(0)
|
||||
page.update()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"ERROR IN _app_entry: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _toggle_theme(page: ft.Page, refresh_sidebar) -> None:
|
||||
"""Switch between dark and light theme modes."""
|
||||
if page.theme_mode == ft.ThemeMode.DARK:
|
||||
page.theme_mode = ft.ThemeMode.LIGHT
|
||||
page.bgcolor = LIGHT.bg_base
|
||||
else:
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.bgcolor = DARK.bg_base
|
||||
|
||||
page.theme = make_theme(page.theme_mode == ft.ThemeMode.DARK)
|
||||
refresh_sidebar()
|
||||
page.update()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI helpers & entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_port_free(host: str, port: int) -> bool:
|
||||
import socket
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _find_free_port(host: str, start_port: int) -> int:
|
||||
import socket
|
||||
port = start_port
|
||||
while port < 65535:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((host, port))
|
||||
return port
|
||||
except OSError:
|
||||
port += 1
|
||||
return start_port
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Start the Abogen Flet frontend.
|
||||
|
||||
Parses ``--web`` and ``--port`` CLI arguments to choose desktop vs. web
|
||||
mode, then hands control to ``ft.app()``.
|
||||
"""
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("flet").setLevel(logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser(description=f"{APP_NAME} – Flet frontend")
|
||||
parser.add_argument(
|
||||
"--web", action="store_true",
|
||||
help="Run as a web server instead of a desktop window.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8080,
|
||||
help="Port for the web server (default: 8080). Ignored in desktop mode.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default="127.0.0.1",
|
||||
help="Host for the web server (default: 127.0.0.1). Use 0.0.0.0 to expose publicly.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.web:
|
||||
port_specified = "--port" in sys.argv
|
||||
target_port = args.port
|
||||
|
||||
if not port_specified:
|
||||
target_port = _find_free_port(args.host, 8080)
|
||||
if target_port != 8080:
|
||||
print(f"Port 8080 is in use. Automatically routed to free port: {target_port}")
|
||||
else:
|
||||
if not _is_port_free(args.host, target_port):
|
||||
print(f"Error: Port {target_port} is already in use on {args.host}.", file=sys.stderr)
|
||||
print("Please select a different port or omit the --port flag to find one automatically.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Starting Abogen WebUI on http://{args.host}:{target_port} ...")
|
||||
ft.app(
|
||||
target=_app_entry,
|
||||
view=ft.AppView.WEB_BROWSER,
|
||||
port=target_port,
|
||||
host=args.host,
|
||||
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||
no_cdn=True,
|
||||
web_renderer="canvaskit",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
ft.app(
|
||||
target=_app_entry,
|
||||
view=ft.AppView.FLET_APP,
|
||||
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to launch native desktop window: {e}", file=sys.stderr)
|
||||
print("Falling back to running as a web application in your default browser...", file=sys.stderr)
|
||||
target_port = _find_free_port("127.0.0.1", 8080)
|
||||
print(f"Starting Abogen WebUI on http://127.0.0.1:{target_port} ...")
|
||||
ft.app(
|
||||
target=_app_entry,
|
||||
view=ft.AppView.WEB_BROWSER,
|
||||
port=target_port,
|
||||
host="127.0.0.1",
|
||||
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||
no_cdn=True,
|
||||
web_renderer="canvaskit",
|
||||
)
|
||||
|
||||
|
||||
def main_web() -> None:
|
||||
"""
|
||||
Start the Abogen Flet frontend as a web server.
|
||||
"""
|
||||
import sys
|
||||
if "--web" not in sys.argv:
|
||||
sys.argv.insert(1, "--web")
|
||||
main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""State sub-package – exports AppState and ConversionJob."""
|
||||
from .app_state import AppState, ConversionJob
|
||||
|
||||
__all__ = ["AppState", "ConversionJob"]
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Centralized, per-session application state for the Abogen Flet frontend.
|
||||
|
||||
Each Flet page (session) gets its own instance of AppState, which guarantees
|
||||
complete isolation between simultaneous web-browser clients and the desktop
|
||||
window. The class carries every configuration variable, file buffer reference,
|
||||
and generation progress field that the rest of the UI reads or writes.
|
||||
|
||||
This module intentionally has no Flet imports so it can be unit-tested without
|
||||
a running Flet server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from abogen.utils import load_config, save_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _default_config() -> Dict[str, Any]:
|
||||
"""Load the persisted user config dict, returning an empty dict on failure."""
|
||||
try:
|
||||
return load_config() or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-session state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionJob:
|
||||
"""Lightweight descriptor of a single queued conversion job."""
|
||||
|
||||
file_path: str
|
||||
"""Absolute path to the text/epub/pdf/txt input file."""
|
||||
|
||||
display_name: str
|
||||
"""User-visible filename (may be the original epub/pdf path)."""
|
||||
|
||||
voice: str
|
||||
"""Voice formula string (e.g. 'af_heart' or 'af_heart*0.5+am_adam*0.5')."""
|
||||
|
||||
lang_code: str
|
||||
"""Single-char language prefix used by Kokoro (e.g. 'a', 'b', 'e')."""
|
||||
|
||||
speed: float = 1.0
|
||||
"""Playback speed multiplier, range 0.1 – 2.0."""
|
||||
|
||||
output_format: str = "mp3"
|
||||
"""Output audio container format."""
|
||||
|
||||
subtitle_mode: str = "Disabled"
|
||||
"""Subtitle generation mode."""
|
||||
|
||||
save_option: str = "Save next to input file"
|
||||
"""Save location strategy."""
|
||||
|
||||
output_folder: Optional[str] = None
|
||||
"""Absolute path when save_option is 'Choose output folder'."""
|
||||
|
||||
char_count: int = 0
|
||||
"""Pre-computed character count for ETR estimation."""
|
||||
|
||||
replace_single_newlines: bool = True
|
||||
save_chapters_separately: Optional[bool] = None
|
||||
merge_chapters_at_end: Optional[bool] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppState:
|
||||
"""
|
||||
Single source of truth for one Flet session.
|
||||
|
||||
Instantiated once per ``ft.app()`` call on desktop, and once per browser
|
||||
tab on web. All UI components receive a reference to this object and
|
||||
read/write it to keep themselves in sync.
|
||||
|
||||
Thread-safety: mutation from background threads should be done via the
|
||||
provided ``_lock``. The UI update callbacks (``on_log``,
|
||||
``on_progress``, etc.) are always invoked on the Flet event loop via
|
||||
``page.run_task()`` and must be set by the view layer.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Runtime identity
|
||||
# -----------------------------------------------------------------------
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Persisted user config (loaded once, written on every change)
|
||||
# -----------------------------------------------------------------------
|
||||
config: Dict[str, Any] = field(default_factory=_default_config)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# File / input state
|
||||
# -----------------------------------------------------------------------
|
||||
selected_file: Optional[str] = None
|
||||
"""Path to the processed text file (may be a temp cache copy for epub/pdf)."""
|
||||
|
||||
selected_file_type: Optional[str] = None
|
||||
"""'txt' | 'epub' | 'pdf' | 'markdown' | None"""
|
||||
|
||||
selected_book_path: Optional[str] = None
|
||||
"""Original epub/pdf path before being converted to txt."""
|
||||
|
||||
displayed_file_path: Optional[str] = None
|
||||
"""Path shown in the UI drop-zone (original book or txt file)."""
|
||||
|
||||
selected_chapters: List[str] = field(default_factory=list)
|
||||
"""Ordered list of selected chapter href tokens (or page numbers for PDFs)."""
|
||||
|
||||
save_chapters_separately: Optional[bool] = None
|
||||
merge_chapters_at_end: Optional[bool] = None
|
||||
save_as_project: bool = False
|
||||
char_count: int = 0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Voice / language
|
||||
# -----------------------------------------------------------------------
|
||||
selected_voice: str = "af_heart"
|
||||
selected_lang: str = "a"
|
||||
selected_profile_name: Optional[str] = None
|
||||
mixed_voice_state: Optional[List[Any]] = None
|
||||
"""List of [voice_id, weight] pairs when the formula mixer is in use."""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Conversion parameters
|
||||
# -----------------------------------------------------------------------
|
||||
speed: float = 1.0
|
||||
use_gpu: bool = True
|
||||
selected_format: str = "wav"
|
||||
subtitle_mode: str = "Sentence"
|
||||
subtitle_format: str = "ass_centered_narrow"
|
||||
replace_single_newlines: bool = True
|
||||
save_option: str = "Save next to input file"
|
||||
selected_output_folder: Optional[str] = None
|
||||
silence_duration: float = 2.0
|
||||
max_subtitle_words: int = 50
|
||||
separate_chapters_format: str = "wav"
|
||||
use_silent_gaps: bool = True
|
||||
subtitle_speed_method: str = "tts"
|
||||
use_spacy_segmentation: bool = True
|
||||
chunk_level: str = "paragraph"
|
||||
generate_epub3: bool = False
|
||||
|
||||
# TTS provider
|
||||
tts_provider: str = "kokoro"
|
||||
supertonic_total_steps: int = 5
|
||||
|
||||
# Chapter options
|
||||
chapter_intro_delay: float = 0.5
|
||||
read_title_intro: bool = False
|
||||
read_closing_outro: bool = True
|
||||
auto_prefix_chapter_titles: bool = True
|
||||
normalize_chapter_opening_caps: bool = True
|
||||
|
||||
# Speaker analysis
|
||||
speaker_analysis_threshold: int = 3
|
||||
|
||||
# Word substitutions
|
||||
word_substitutions_enabled: bool = False
|
||||
word_substitutions_list: str = ""
|
||||
case_sensitive_substitutions: bool = False
|
||||
replace_all_caps: bool = False
|
||||
replace_numerals: bool = False
|
||||
fix_nonstandard_punctuation: bool = False
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Conversion runtime state
|
||||
# -----------------------------------------------------------------------
|
||||
is_converting: bool = False
|
||||
is_cancelled: bool = False
|
||||
progress: float = 0.0
|
||||
"""Fractional progress 0.0 – 1.0."""
|
||||
etr_seconds: Optional[float] = None
|
||||
"""Estimated seconds remaining, or None if unknown."""
|
||||
last_output_path: Optional[str] = None
|
||||
log_lines: List[str] = field(default_factory=list)
|
||||
"""Buffered log messages, capped at LOG_MAX_LINES."""
|
||||
|
||||
LOG_MAX_LINES: int = 2000
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Queue
|
||||
# -----------------------------------------------------------------------
|
||||
queued_items: List[ConversionJob] = field(default_factory=list)
|
||||
current_queue_index: int = 0
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Callbacks (set by the view layer, not serialised)
|
||||
# -----------------------------------------------------------------------
|
||||
on_log: Optional[Callable[[str, str], None]] = field(default=None, repr=False, compare=False)
|
||||
"""Called from any thread: ``on_log(message, level)``."""
|
||||
|
||||
on_progress: Optional[Callable[[float, Optional[float]], None]] = field(
|
||||
default=None, repr=False, compare=False
|
||||
)
|
||||
"""Called from any thread: ``on_progress(fraction, etr_seconds)``."""
|
||||
|
||||
on_conversion_finished: Optional[Callable[[str, Optional[str]], None]] = field(
|
||||
default=None, repr=False, compare=False
|
||||
)
|
||||
"""Called from any thread: ``on_conversion_finished(message, output_path)``."""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Integrations
|
||||
# -----------------------------------------------------------------------
|
||||
audiobookshelf_enabled: bool = False
|
||||
audiobookshelf_base_url: str = ""
|
||||
audiobookshelf_api_token: str = ""
|
||||
audiobookshelf_library_id: str = ""
|
||||
audiobookshelf_folder_id: str = ""
|
||||
audiobookshelf_verify_ssl: bool = True
|
||||
audiobookshelf_auto_send: bool = False
|
||||
audiobookshelf_send_cover: bool = True
|
||||
audiobookshelf_send_chapters: bool = True
|
||||
audiobookshelf_send_subtitles: bool = False
|
||||
audiobookshelf_timeout: float = 30.0
|
||||
|
||||
calibre_opds_enabled: bool = False
|
||||
calibre_opds_base_url: str = ""
|
||||
calibre_opds_username: str = ""
|
||||
calibre_opds_password: str = ""
|
||||
calibre_opds_verify_ssl: bool = True
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def load_from_config(self) -> None:
|
||||
"""
|
||||
Populate all fields from the persisted JSON config file.
|
||||
|
||||
Called once at startup and whenever the settings page is saved.
|
||||
Thread-safe.
|
||||
"""
|
||||
with self._lock:
|
||||
cfg = _default_config()
|
||||
self.config = cfg
|
||||
|
||||
self.selected_voice = cfg.get("selected_voice", "af_heart")
|
||||
self.selected_lang = self.selected_voice[0] if self.selected_voice else "a"
|
||||
self.selected_profile_name = cfg.get("selected_profile_name")
|
||||
self.speed = cfg.get("speed", 1.0)
|
||||
self.use_gpu = cfg.get("use_gpu", True)
|
||||
self.selected_format = cfg.get("selected_format", "wav")
|
||||
self.subtitle_mode = cfg.get("subtitle_mode", "Sentence")
|
||||
self.subtitle_format = cfg.get("subtitle_format", "ass_centered_narrow")
|
||||
self.replace_single_newlines = cfg.get("replace_single_newlines", True)
|
||||
self.save_option = cfg.get("save_option", "Save next to input file")
|
||||
self.selected_output_folder = cfg.get("selected_output_folder")
|
||||
self.silence_duration = cfg.get("silence_duration", 2.0)
|
||||
self.max_subtitle_words = cfg.get("max_subtitle_words", 50)
|
||||
self.separate_chapters_format = cfg.get("separate_chapters_format", "wav")
|
||||
self.use_silent_gaps = cfg.get("use_silent_gaps", True)
|
||||
self.subtitle_speed_method = cfg.get("subtitle_speed_method", "tts")
|
||||
self.use_spacy_segmentation = cfg.get("use_spacy_segmentation", True)
|
||||
self.chunk_level = cfg.get("chunk_level", "paragraph")
|
||||
self.generate_epub3 = cfg.get("generate_epub3", False)
|
||||
self.tts_provider = cfg.get("tts_provider", "kokoro")
|
||||
self.supertonic_total_steps = cfg.get("supertonic_total_steps", 5)
|
||||
self.chapter_intro_delay = cfg.get("chapter_intro_delay", 0.5)
|
||||
self.read_title_intro = cfg.get("read_title_intro", False)
|
||||
self.read_closing_outro = cfg.get("read_closing_outro", True)
|
||||
self.auto_prefix_chapter_titles = cfg.get("auto_prefix_chapter_titles", True)
|
||||
self.normalize_chapter_opening_caps = cfg.get("normalize_chapter_opening_caps", True)
|
||||
self.speaker_analysis_threshold = cfg.get("speaker_analysis_threshold", 3)
|
||||
self.word_substitutions_enabled = cfg.get("word_substitutions_enabled", False)
|
||||
self.word_substitutions_list = cfg.get("word_substitutions_list", "")
|
||||
self.case_sensitive_substitutions = cfg.get("case_sensitive_substitutions", False)
|
||||
self.replace_all_caps = cfg.get("replace_all_caps", False)
|
||||
self.replace_numerals = cfg.get("replace_numerals", False)
|
||||
self.fix_nonstandard_punctuation = cfg.get("fix_nonstandard_punctuation", False)
|
||||
|
||||
# Integrations
|
||||
integrations: Dict[str, Any] = cfg.get("integrations", {})
|
||||
abs_cfg = integrations.get("audiobookshelf", {})
|
||||
self.audiobookshelf_enabled = bool(abs_cfg.get("enabled", False))
|
||||
self.audiobookshelf_base_url = str(abs_cfg.get("base_url", ""))
|
||||
self.audiobookshelf_api_token = str(abs_cfg.get("api_token", ""))
|
||||
self.audiobookshelf_library_id = str(abs_cfg.get("library_id", ""))
|
||||
self.audiobookshelf_folder_id = str(abs_cfg.get("folder_id", ""))
|
||||
self.audiobookshelf_verify_ssl = bool(abs_cfg.get("verify_ssl", True))
|
||||
self.audiobookshelf_auto_send = bool(abs_cfg.get("auto_send", False))
|
||||
self.audiobookshelf_send_cover = bool(abs_cfg.get("send_cover", True))
|
||||
self.audiobookshelf_send_chapters = bool(abs_cfg.get("send_chapters", True))
|
||||
self.audiobookshelf_send_subtitles = bool(abs_cfg.get("send_subtitles", False))
|
||||
self.audiobookshelf_timeout = float(abs_cfg.get("timeout", 30.0))
|
||||
|
||||
cal_cfg = integrations.get("calibre_opds", {})
|
||||
self.calibre_opds_enabled = bool(cal_cfg.get("enabled", False))
|
||||
self.calibre_opds_base_url = str(cal_cfg.get("base_url", ""))
|
||||
self.calibre_opds_username = str(cal_cfg.get("username", ""))
|
||||
self.calibre_opds_password = str(cal_cfg.get("password", ""))
|
||||
self.calibre_opds_verify_ssl = bool(cal_cfg.get("verify_ssl", True))
|
||||
|
||||
def persist_config(self) -> None:
|
||||
"""
|
||||
Write the current config snapshot back to disk.
|
||||
|
||||
Only the fields that map to the JSON config are written; runtime state
|
||||
(progress, log_lines, callbacks) is not persisted.
|
||||
Thread-safe.
|
||||
"""
|
||||
with self._lock:
|
||||
cfg = self.config.copy()
|
||||
cfg["selected_voice"] = self.selected_voice
|
||||
cfg["selected_profile_name"] = self.selected_profile_name
|
||||
cfg["speed"] = self.speed
|
||||
cfg["use_gpu"] = self.use_gpu
|
||||
cfg["selected_format"] = self.selected_format
|
||||
cfg["subtitle_mode"] = self.subtitle_mode
|
||||
cfg["subtitle_format"] = self.subtitle_format
|
||||
cfg["replace_single_newlines"] = self.replace_single_newlines
|
||||
cfg["save_option"] = self.save_option
|
||||
cfg["selected_output_folder"] = self.selected_output_folder
|
||||
cfg["silence_duration"] = self.silence_duration
|
||||
cfg["max_subtitle_words"] = self.max_subtitle_words
|
||||
cfg["separate_chapters_format"] = self.separate_chapters_format
|
||||
cfg["use_silent_gaps"] = self.use_silent_gaps
|
||||
cfg["subtitle_speed_method"] = self.subtitle_speed_method
|
||||
cfg["use_spacy_segmentation"] = self.use_spacy_segmentation
|
||||
cfg["chunk_level"] = self.chunk_level
|
||||
cfg["generate_epub3"] = self.generate_epub3
|
||||
cfg["tts_provider"] = self.tts_provider
|
||||
cfg["supertonic_total_steps"] = self.supertonic_total_steps
|
||||
cfg["chapter_intro_delay"] = self.chapter_intro_delay
|
||||
cfg["read_title_intro"] = self.read_title_intro
|
||||
cfg["read_closing_outro"] = self.read_closing_outro
|
||||
cfg["auto_prefix_chapter_titles"] = self.auto_prefix_chapter_titles
|
||||
cfg["normalize_chapter_opening_caps"] = self.normalize_chapter_opening_caps
|
||||
cfg["speaker_analysis_threshold"] = self.speaker_analysis_threshold
|
||||
cfg["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||
cfg["word_substitutions_list"] = self.word_substitutions_list
|
||||
cfg["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||
cfg["replace_all_caps"] = self.replace_all_caps
|
||||
cfg["replace_numerals"] = self.replace_numerals
|
||||
cfg["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||
# Integrations
|
||||
cfg.setdefault("integrations", {})
|
||||
cfg["integrations"]["audiobookshelf"] = {
|
||||
"enabled": self.audiobookshelf_enabled,
|
||||
"base_url": self.audiobookshelf_base_url,
|
||||
"api_token": self.audiobookshelf_api_token,
|
||||
"library_id": self.audiobookshelf_library_id,
|
||||
"folder_id": self.audiobookshelf_folder_id,
|
||||
"verify_ssl": self.audiobookshelf_verify_ssl,
|
||||
"auto_send": self.audiobookshelf_auto_send,
|
||||
"send_cover": self.audiobookshelf_send_cover,
|
||||
"send_chapters": self.audiobookshelf_send_chapters,
|
||||
"send_subtitles": self.audiobookshelf_send_subtitles,
|
||||
"timeout": self.audiobookshelf_timeout,
|
||||
}
|
||||
cfg["integrations"]["calibre_opds"] = {
|
||||
"enabled": self.calibre_opds_enabled,
|
||||
"base_url": self.calibre_opds_base_url,
|
||||
"username": self.calibre_opds_username,
|
||||
"password": self.calibre_opds_password,
|
||||
"verify_ssl": self.calibre_opds_verify_ssl,
|
||||
}
|
||||
self.config = cfg
|
||||
try:
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def append_log(self, message: str, level: str = "info") -> None:
|
||||
"""
|
||||
Thread-safely append a log line and trigger the UI callback.
|
||||
|
||||
Caps the internal buffer at ``LOG_MAX_LINES`` to prevent unbounded
|
||||
memory growth during very long conversion tasks.
|
||||
"""
|
||||
with self._lock:
|
||||
self.log_lines.append(f"[{level.upper()}] {message}")
|
||||
if len(self.log_lines) > self.LOG_MAX_LINES:
|
||||
# Trim oldest 10 % to amortise the cost of trimming
|
||||
trim = self.LOG_MAX_LINES // 10
|
||||
self.log_lines = self.log_lines[trim:]
|
||||
|
||||
cb = self.on_log
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(message, level)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_progress(self, fraction: float, etr: Optional[float] = None) -> None:
|
||||
"""
|
||||
Update fractional progress and ETR, then notify the UI callback.
|
||||
|
||||
Args:
|
||||
fraction: Value in [0.0, 1.0].
|
||||
etr: Estimated seconds remaining, or None.
|
||||
"""
|
||||
with self._lock:
|
||||
self.progress = max(0.0, min(1.0, fraction))
|
||||
self.etr_seconds = etr
|
||||
|
||||
cb = self.on_progress
|
||||
if cb is not None:
|
||||
try:
|
||||
cb(fraction, etr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_voice_formula(self) -> str:
|
||||
"""
|
||||
Return the effective voice formula string.
|
||||
|
||||
Uses the mixed_voice_state if the formula mixer is active, otherwise
|
||||
returns the raw selected_voice.
|
||||
"""
|
||||
if self.mixed_voice_state:
|
||||
parts = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
|
||||
return " + ".join(filter(None, parts))
|
||||
return self.selected_voice or "af_heart"
|
||||
|
||||
def reset_file_state(self) -> None:
|
||||
"""Clear all file-related fields without touching voice/settings."""
|
||||
with self._lock:
|
||||
self.selected_file = None
|
||||
self.selected_file_type = None
|
||||
self.selected_book_path = None
|
||||
self.displayed_file_path = None
|
||||
self.selected_chapters = []
|
||||
self.save_chapters_separately = None
|
||||
self.merge_chapters_at_end = None
|
||||
self.save_as_project = False
|
||||
self.char_count = 0
|
||||
|
||||
def reset_conversion_state(self) -> None:
|
||||
"""Clear all runtime conversion fields to start fresh."""
|
||||
with self._lock:
|
||||
self.is_converting = False
|
||||
self.is_cancelled = False
|
||||
self.progress = 0.0
|
||||
self.etr_seconds = None
|
||||
self.last_output_path = None
|
||||
self.log_lines = []
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Utils sub-package."""
|
||||
from .helpers import (
|
||||
human_readable_size,
|
||||
format_duration,
|
||||
format_etr,
|
||||
detect_file_type,
|
||||
is_supported_file,
|
||||
is_book_type,
|
||||
voice_lang_code,
|
||||
language_label,
|
||||
grouped_voices,
|
||||
voice_display_name,
|
||||
parse_voice_formula,
|
||||
format_number,
|
||||
safe_basename,
|
||||
output_format_label,
|
||||
subtitle_format_label,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"human_readable_size",
|
||||
"format_duration",
|
||||
"format_etr",
|
||||
"detect_file_type",
|
||||
"is_supported_file",
|
||||
"is_book_type",
|
||||
"voice_lang_code",
|
||||
"language_label",
|
||||
"grouped_voices",
|
||||
"voice_display_name",
|
||||
"parse_voice_formula",
|
||||
"format_number",
|
||||
"safe_basename",
|
||||
"output_format_label",
|
||||
"subtitle_format_label",
|
||||
"SUPPORTED_EXTENSIONS",
|
||||
]
|
||||
@@ -0,0 +1,462 @@
|
||||
"""
|
||||
Background conversion bridge for the Abogen Flet frontend.
|
||||
|
||||
This module wraps the existing ``abogen.webui.conversion_runner`` (and its
|
||||
``ConversionService`` / ``Job`` machinery) in an async-friendly interface that
|
||||
can push real-time progress and log updates back to the Flet event loop without
|
||||
blocking the UI thread.
|
||||
|
||||
Key design decisions
|
||||
--------------------
|
||||
* All heavy work is offloaded to daemon threads. The Flet page event loop
|
||||
is never blocked.
|
||||
* Progress and log callbacks are scheduled back onto the Flet page via
|
||||
``page.run_task()`` so Flet's session isolation remains intact.
|
||||
* Cancellation is cooperative: the underlying job's ``cancel_requested``
|
||||
flag is set, and the runner checks it at chunk boundaries.
|
||||
* The module is a pure adapter – it does NOT duplicate any processing logic
|
||||
from the core pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from abogen.utils import (
|
||||
get_gpu_acceleration,
|
||||
get_user_cache_path,
|
||||
get_user_output_path,
|
||||
load_numpy_kpipeline,
|
||||
prevent_sleep_end,
|
||||
prevent_sleep_start,
|
||||
)
|
||||
from abogen.webui.service import (
|
||||
ConversionService,
|
||||
Job,
|
||||
JobStatus,
|
||||
PendingJob,
|
||||
build_service,
|
||||
)
|
||||
from abogen.webui.conversion_runner import run_conversion_job
|
||||
|
||||
from ..state import AppState
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton ConversionService (shared across sessions, as in the
|
||||
# web UI – but each job carries its own output folder keyed by session).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SERVICE_LOCK = threading.Lock()
|
||||
_SERVICE: Optional[ConversionService] = None
|
||||
|
||||
|
||||
def _get_service() -> ConversionService:
|
||||
"""
|
||||
Return (creating if necessary) the module-level ConversionService.
|
||||
|
||||
The service manages the background worker thread and persistent job state.
|
||||
Thread-safe via a module-level lock.
|
||||
"""
|
||||
global _SERVICE
|
||||
with _SERVICE_LOCK:
|
||||
if _SERVICE is None:
|
||||
output_root = Path(get_user_output_path("frontend"))
|
||||
uploads_root = Path(get_user_cache_path("frontend/uploads"))
|
||||
_SERVICE = build_service(
|
||||
runner=run_conversion_job,
|
||||
output_root=output_root,
|
||||
uploads_root=uploads_root,
|
||||
)
|
||||
return _SERVICE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public conversion bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConversionBridge:
|
||||
"""
|
||||
Thin adapter between the Flet UI session and the core conversion pipeline.
|
||||
|
||||
One ``ConversionBridge`` instance is created per Flet page (session) and
|
||||
is responsible for:
|
||||
1. Accepting a conversion request from the UI.
|
||||
2. Writing the input text to a temp file if needed.
|
||||
3. Submitting the job to ``ConversionService``.
|
||||
4. Polling the job from a daemon thread and forwarding progress/logs to
|
||||
the Flet page via ``page.run_task()``.
|
||||
5. Providing a ``cancel()`` method that sets the cooperative flag.
|
||||
"""
|
||||
|
||||
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||
"""
|
||||
Initialise the bridge.
|
||||
|
||||
Args:
|
||||
page: The Flet ``Page`` for this session. Used to schedule
|
||||
UI callbacks on the correct event loop.
|
||||
state: The session's ``AppState`` instance.
|
||||
"""
|
||||
self._page = page
|
||||
self._state = state
|
||||
self._current_job: Optional[Job] = None
|
||||
self._poll_thread: Optional[threading.Thread] = None
|
||||
self._stop_poll = threading.Event()
|
||||
self._seen_log_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
input_file: str,
|
||||
voice: str,
|
||||
lang_code: str,
|
||||
speed: float,
|
||||
output_format: str,
|
||||
subtitle_mode: str,
|
||||
subtitle_format: str,
|
||||
use_gpu: bool,
|
||||
save_option: str,
|
||||
output_folder: Optional[str],
|
||||
replace_single_newlines: bool,
|
||||
char_count: int,
|
||||
chapters: Optional[List[Dict[str, Any]]] = None,
|
||||
save_chapters_separately: bool = False,
|
||||
merge_chapters_at_end: bool = True,
|
||||
separate_chapters_format: str = "wav",
|
||||
silence_between_chapters: float = 2.0,
|
||||
max_subtitle_words: int = 50,
|
||||
chapter_intro_delay: float = 0.5,
|
||||
read_title_intro: bool = False,
|
||||
read_closing_outro: bool = True,
|
||||
auto_prefix_chapter_titles: bool = True,
|
||||
normalize_chapter_opening_caps: bool = True,
|
||||
tts_provider: str = "kokoro",
|
||||
supertonic_total_steps: int = 5,
|
||||
chunk_level: str = "paragraph",
|
||||
generate_epub3: bool = False,
|
||||
word_substitutions_enabled: bool = False,
|
||||
word_substitutions_list: str = "",
|
||||
case_sensitive_substitutions: bool = False,
|
||||
replace_all_caps: bool = False,
|
||||
replace_numerals: bool = False,
|
||||
fix_nonstandard_punctuation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Submit a conversion job and begin the progress-polling loop.
|
||||
|
||||
This method returns immediately; all heavy work runs on daemon threads.
|
||||
UI callbacks (``state.on_log``, ``state.on_progress``,
|
||||
``state.on_conversion_finished``) are scheduled on the Flet event loop.
|
||||
|
||||
Args:
|
||||
input_file: Absolute path to the text/epub/pdf input file.
|
||||
voice: Kokoro voice formula string.
|
||||
lang_code: Single-char language code.
|
||||
speed: Playback speed multiplier (0.1 – 2.0).
|
||||
output_format: Audio container key (``'wav'``, ``'mp3'``, …).
|
||||
subtitle_mode: Subtitle generation mode string.
|
||||
subtitle_format: Subtitle container key (``'srt'``, ``'ass_wide'``, …).
|
||||
use_gpu: Whether to request GPU acceleration.
|
||||
save_option: Save-location strategy string.
|
||||
output_folder: Explicit output folder or None.
|
||||
replace_single_newlines: Pre-processing flag.
|
||||
char_count: Pre-computed character count for ETR estimation.
|
||||
chapters: Optional list of chapter dicts for epub/pdf.
|
||||
save_chapters_separately: Split chapters into separate files.
|
||||
merge_chapters_at_end: Merge chapter files into one after generation.
|
||||
separate_chapters_format: Format for individual chapter files.
|
||||
silence_between_chapters: Silence gap (seconds) between chapters.
|
||||
max_subtitle_words: Maximum words per subtitle block.
|
||||
chapter_intro_delay: Silence before chapter title announcement (s).
|
||||
read_title_intro: Announce book title at the start.
|
||||
read_closing_outro: Announce book title at the end.
|
||||
auto_prefix_chapter_titles: Prepend "Chapter N." to titles.
|
||||
normalize_chapter_opening_caps: Fix ALL-CAPS opening lines.
|
||||
tts_provider: ``'kokoro'`` or ``'supertonic'``.
|
||||
supertonic_total_steps: Quality steps for the Supertonic pipeline.
|
||||
chunk_level: ``'paragraph'`` or ``'sentence'`` chunking granularity.
|
||||
generate_epub3: Also produce an EPUB3 audiobook package.
|
||||
word_substitutions_enabled: Toggle word-substitution pre-processing.
|
||||
word_substitutions_list: Newline-delimited ``word|replacement`` rules.
|
||||
case_sensitive_substitutions: Case-sensitive matching for substitutions.
|
||||
replace_all_caps: Lowercase ALL-CAPS words.
|
||||
replace_numerals: Convert digits to spoken words.
|
||||
fix_nonstandard_punctuation: Normalise curly quotes etc.
|
||||
"""
|
||||
if self._state.is_converting:
|
||||
return
|
||||
|
||||
# Resolve the effective output folder
|
||||
resolved_output: Optional[Path] = self._resolve_output_folder(
|
||||
save_option=save_option,
|
||||
output_folder=output_folder,
|
||||
input_file=input_file,
|
||||
)
|
||||
|
||||
# Store the input file as a Path
|
||||
stored_path = Path(input_file)
|
||||
original_filename = stored_path.name
|
||||
|
||||
# Block signals until the job is submitted
|
||||
prevent_sleep_start()
|
||||
self._state.is_converting = True
|
||||
self._state.is_cancelled = False
|
||||
self._state.progress = 0.0
|
||||
self._state.etr_seconds = None
|
||||
self._state.log_lines = []
|
||||
self._seen_log_count = 0
|
||||
|
||||
# Enqueue the job on the service
|
||||
service = _get_service()
|
||||
job = service.enqueue(
|
||||
original_filename=original_filename,
|
||||
stored_path=stored_path,
|
||||
language=lang_code,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
tts_provider=tts_provider,
|
||||
supertonic_total_steps=supertonic_total_steps,
|
||||
use_gpu=use_gpu,
|
||||
subtitle_mode=subtitle_mode,
|
||||
output_format=output_format,
|
||||
save_mode=self._save_mode_key(save_option),
|
||||
output_folder=resolved_output,
|
||||
replace_single_newlines=replace_single_newlines,
|
||||
subtitle_format=subtitle_format,
|
||||
total_characters=char_count,
|
||||
chapters=chapters or [],
|
||||
save_chapters_separately=save_chapters_separately,
|
||||
merge_chapters_at_end=merge_chapters_at_end,
|
||||
separate_chapters_format=separate_chapters_format,
|
||||
silence_between_chapters=silence_between_chapters,
|
||||
max_subtitle_words=max_subtitle_words,
|
||||
chapter_intro_delay=chapter_intro_delay,
|
||||
read_title_intro=read_title_intro,
|
||||
read_closing_outro=read_closing_outro,
|
||||
auto_prefix_chapter_titles=auto_prefix_chapter_titles,
|
||||
normalize_chapter_opening_caps=normalize_chapter_opening_caps,
|
||||
chunk_level=chunk_level,
|
||||
generate_epub3=generate_epub3,
|
||||
)
|
||||
self._current_job = job
|
||||
|
||||
# Persist word-substitution settings to config so the runner picks them up
|
||||
self._state.word_substitutions_enabled = word_substitutions_enabled
|
||||
self._state.word_substitutions_list = word_substitutions_list
|
||||
self._state.case_sensitive_substitutions = case_sensitive_substitutions
|
||||
self._state.replace_all_caps = replace_all_caps
|
||||
self._state.replace_numerals = replace_numerals
|
||||
self._state.fix_nonstandard_punctuation = fix_nonstandard_punctuation
|
||||
self._state.persist_config()
|
||||
|
||||
# Start the poll thread
|
||||
self._stop_poll.clear()
|
||||
self._poll_thread = threading.Thread(
|
||||
target=self._poll_job_loop, daemon=True, name="abogen-poll"
|
||||
)
|
||||
self._poll_thread.start()
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""
|
||||
Request cancellation of the currently running job.
|
||||
|
||||
Sets the cooperative flag on the underlying ``Job`` object; the runner
|
||||
will stop after completing the current text chunk.
|
||||
"""
|
||||
if self._current_job is not None:
|
||||
self._state.is_cancelled = True
|
||||
try:
|
||||
_get_service().cancel(self._current_job.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _save_mode_key(option: str) -> str:
|
||||
"""
|
||||
Convert the human-readable save option to the service's internal key.
|
||||
|
||||
Args:
|
||||
option: UI-facing string (``'Save next to input file'``, …).
|
||||
|
||||
Returns:
|
||||
Service key string.
|
||||
"""
|
||||
mapping = {
|
||||
"Save next to input file": "save_next_to_input",
|
||||
"Save to Desktop": "save_to_desktop",
|
||||
"Choose output folder": "custom",
|
||||
}
|
||||
return mapping.get(option, "save_next_to_input")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_output_folder(
|
||||
save_option: str,
|
||||
output_folder: Optional[str],
|
||||
input_file: str,
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Return the output ``Path`` based on the save option, or None for
|
||||
the "next to input" strategy (the runner handles that internally).
|
||||
|
||||
Args:
|
||||
save_option: UI-facing save strategy string.
|
||||
output_folder: Explicit path when ``save_option`` is ``'Choose output folder'``.
|
||||
input_file: Path to the source file for the ``'Save to Desktop'`` strategy.
|
||||
|
||||
Returns:
|
||||
Resolved ``Path`` or ``None``.
|
||||
"""
|
||||
if save_option == "Choose output folder" and output_folder:
|
||||
p = Path(output_folder)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
if save_option == "Save to Desktop":
|
||||
desktop = Path.home() / "Desktop"
|
||||
desktop.mkdir(exist_ok=True)
|
||||
return desktop
|
||||
# "Save next to input file" – let the runner decide
|
||||
return None
|
||||
|
||||
def _poll_job_loop(self) -> None:
|
||||
"""
|
||||
Background daemon loop that polls the current Job for updates.
|
||||
|
||||
Runs until the job enters a terminal state or until ``_stop_poll``
|
||||
is set. Uses ``page.run_task()`` to schedule UI updates on the Flet
|
||||
event loop without triggering thread-safety violations.
|
||||
"""
|
||||
job = self._current_job
|
||||
if job is None:
|
||||
return
|
||||
|
||||
service = _get_service()
|
||||
POLL_INTERVAL = 0.25 # seconds
|
||||
|
||||
while not self._stop_poll.is_set():
|
||||
# Re-fetch the current job state (it's mutated in-place by the runner)
|
||||
current = service.get_job(job.id)
|
||||
if current is None:
|
||||
break
|
||||
|
||||
# Forward new log lines
|
||||
new_logs = current.logs[self._seen_log_count:]
|
||||
self._seen_log_count += len(new_logs)
|
||||
for log_entry in new_logs:
|
||||
level = getattr(log_entry, "level", "info")
|
||||
message = getattr(log_entry, "message", str(log_entry))
|
||||
self._schedule_log(message, level)
|
||||
|
||||
# Forward progress
|
||||
if current.progress is not None:
|
||||
etr = getattr(current, "estimated_time_remaining", None)
|
||||
self._schedule_progress(float(current.progress), etr)
|
||||
|
||||
# Check for terminal states
|
||||
status = current.status
|
||||
if status in (
|
||||
JobStatus.COMPLETED,
|
||||
JobStatus.FAILED,
|
||||
JobStatus.CANCELLED,
|
||||
):
|
||||
output_path: Optional[str] = None
|
||||
if current.result and current.result.audio_path:
|
||||
output_path = str(current.result.audio_path)
|
||||
if status == JobStatus.COMPLETED:
|
||||
finish_msg = "Conversion completed successfully."
|
||||
elif status == JobStatus.CANCELLED:
|
||||
finish_msg = "Cancelled"
|
||||
else:
|
||||
finish_msg = f"Conversion failed: {current.error or 'Unknown error'}"
|
||||
|
||||
self._schedule_finished(finish_msg, output_path)
|
||||
break
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
prevent_sleep_end()
|
||||
self._state.is_converting = False
|
||||
|
||||
def _schedule_log(self, message: str, level: str) -> None:
|
||||
"""Schedule a log update on the Flet event loop."""
|
||||
state = self._state
|
||||
page = self._page
|
||||
state.append_log(message, level)
|
||||
|
||||
async def _update() -> None:
|
||||
cb = state.on_log
|
||||
if cb:
|
||||
cb(message, level)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.run_task(_update)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _schedule_progress(self, fraction: float, etr: Optional[float]) -> None:
|
||||
"""Schedule a progress update on the Flet event loop."""
|
||||
state = self._state
|
||||
page = self._page
|
||||
state.progress = max(0.0, min(1.0, fraction))
|
||||
state.etr_seconds = etr
|
||||
|
||||
async def _update() -> None:
|
||||
cb = state.on_progress
|
||||
if cb:
|
||||
cb(fraction, etr)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.run_task(_update)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _schedule_finished(
|
||||
self, message: str, output_path: Optional[str]
|
||||
) -> None:
|
||||
"""Schedule a completion notification on the Flet event loop."""
|
||||
state = self._state
|
||||
page = self._page
|
||||
state.last_output_path = output_path
|
||||
self._stop_poll.set()
|
||||
|
||||
async def _update() -> None:
|
||||
state.is_converting = False
|
||||
state.progress = 1.0
|
||||
state.last_output_path = output_path
|
||||
cb = state.on_conversion_finished
|
||||
if cb:
|
||||
cb(message, output_path)
|
||||
try:
|
||||
page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
page.run_task(_update)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Frontend-specific utilities for the Abogen Flet application.
|
||||
|
||||
Contains helpers for:
|
||||
- Human-readable size / duration formatting
|
||||
- Voice formula parsing and display
|
||||
- File-type detection
|
||||
- ETR (Estimated Time Remaining) formatting
|
||||
- Path resolution that adapts to desktop vs. web context
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUPPORTED_INPUT_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
SUBTITLE_FORMATS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Size / duration helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def human_readable_size(size_bytes: int, decimal_places: int = 2) -> str:
|
||||
"""
|
||||
Convert a byte count into a human-readable string.
|
||||
|
||||
Args:
|
||||
size_bytes: Number of bytes.
|
||||
decimal_places: Significant decimal digits in the output.
|
||||
|
||||
Returns:
|
||||
A string like ``"3.14 MB"`` or ``"1.00 KB"``.
|
||||
"""
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.{decimal_places}f} {unit}"
|
||||
size_bytes /= 1024.0 # type: ignore[assignment]
|
||||
return f"{size_bytes:.{decimal_places}f} PB"
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""
|
||||
Format a duration in seconds as ``HH:MM:SS``.
|
||||
|
||||
Args:
|
||||
seconds: Non-negative floating-point duration.
|
||||
|
||||
Returns:
|
||||
A colon-delimited time string, e.g. ``"00:03:42"``.
|
||||
"""
|
||||
total = max(0, int(seconds))
|
||||
h, remainder = divmod(total, 3600)
|
||||
m, s = divmod(remainder, 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def format_etr(etr_seconds: Optional[float]) -> str:
|
||||
"""
|
||||
Format an estimated time remaining value for the UI.
|
||||
|
||||
Args:
|
||||
etr_seconds: Seconds remaining, or None when unknown.
|
||||
|
||||
Returns:
|
||||
Human-readable string such as ``"~3 min 42 sec"`` or ``"Calculating…"``.
|
||||
"""
|
||||
if etr_seconds is None:
|
||||
return "Calculating…"
|
||||
total = max(0, int(etr_seconds))
|
||||
if total < 60:
|
||||
return f"~{total} sec"
|
||||
m, s = divmod(total, 60)
|
||||
if m < 60:
|
||||
return f"~{m} min {s} sec"
|
||||
h, m = divmod(m, 60)
|
||||
return f"~{h} h {m} min"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
SUPPORTED_EXTENSIONS: Tuple[str, ...] = (
|
||||
".txt",
|
||||
".epub",
|
||||
".pdf",
|
||||
".md",
|
||||
".markdown",
|
||||
".srt",
|
||||
".ass",
|
||||
".vtt",
|
||||
)
|
||||
"""All file extensions that the drop-zone accepts."""
|
||||
|
||||
|
||||
def detect_file_type(file_path: str) -> str:
|
||||
"""
|
||||
Return a normalised file-type token for the given path.
|
||||
|
||||
Args:
|
||||
file_path: Absolute or relative path to the input file.
|
||||
|
||||
Returns:
|
||||
One of ``'txt'``, ``'epub'``, ``'pdf'``, ``'markdown'``,
|
||||
``'subtitle'``, or ``'unknown'``.
|
||||
"""
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext == ".epub":
|
||||
return "epub"
|
||||
if ext == ".pdf":
|
||||
return "pdf"
|
||||
if ext in (".md", ".markdown"):
|
||||
return "markdown"
|
||||
if ext in (".srt", ".ass", ".vtt"):
|
||||
return "subtitle"
|
||||
if ext == ".txt":
|
||||
return "txt"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_supported_file(file_path: str) -> bool:
|
||||
"""
|
||||
Return True when the file extension is in the supported set.
|
||||
|
||||
Args:
|
||||
file_path: Path whose extension is inspected.
|
||||
"""
|
||||
return Path(file_path).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
def is_book_type(file_type: str) -> bool:
|
||||
"""
|
||||
Return True for file types that contain chapters / pages.
|
||||
|
||||
Args:
|
||||
file_type: Token from ``detect_file_type()``.
|
||||
"""
|
||||
return file_type in ("epub", "pdf", "markdown")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Voice helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def voice_lang_code(voice: str) -> str:
|
||||
"""
|
||||
Extract the language code character from a Kokoro voice name.
|
||||
|
||||
The first character of every internal voice name encodes the language
|
||||
(e.g. ``'a'`` for American English, ``'b'`` for British English).
|
||||
|
||||
Args:
|
||||
voice: Raw voice string like ``'af_heart'`` or a formula.
|
||||
|
||||
Returns:
|
||||
Single lowercase character, defaulting to ``'a'`` on failure.
|
||||
"""
|
||||
if not voice:
|
||||
return "a"
|
||||
# For plain voice IDs the first char is the language
|
||||
if voice[0].isalpha() and "_" in voice[:4]:
|
||||
return voice[0].lower()
|
||||
# Formula: extract first alpha char
|
||||
match = re.search(r"\b([a-z])", voice)
|
||||
return match.group(1) if match else "a"
|
||||
|
||||
|
||||
def language_label(lang_code: str) -> str:
|
||||
"""
|
||||
Return the human-readable label for a language code.
|
||||
|
||||
Args:
|
||||
lang_code: Single-character code (``'a'``, ``'b'``, …).
|
||||
|
||||
Returns:
|
||||
Display string, e.g. ``"American English"``.
|
||||
"""
|
||||
return LANGUAGE_DESCRIPTIONS.get(lang_code, lang_code.upper())
|
||||
|
||||
|
||||
def grouped_voices() -> List[Tuple[str, List[str]]]:
|
||||
"""
|
||||
Return the internal voice list grouped by language for display.
|
||||
|
||||
Returns:
|
||||
List of ``(language_label, [voice_id, …])`` tuples.
|
||||
"""
|
||||
groups: dict[str, List[str]] = {}
|
||||
for v in VOICES_INTERNAL:
|
||||
lang = language_label(v[0])
|
||||
groups.setdefault(lang, []).append(v)
|
||||
return sorted(groups.items())
|
||||
|
||||
|
||||
def voice_display_name(voice_id: str) -> str:
|
||||
"""
|
||||
Convert a raw voice ID like ``'af_heart'`` to a prettier display name.
|
||||
|
||||
Args:
|
||||
voice_id: Raw internal voice identifier.
|
||||
|
||||
Returns:
|
||||
Formatted string, e.g. ``"af_heart"`` (unchanged; may be enhanced later).
|
||||
"""
|
||||
return voice_id
|
||||
|
||||
|
||||
def parse_voice_formula(formula: str) -> List[Tuple[str, float]]:
|
||||
"""
|
||||
Parse a Kokoro voice mix formula into a list of ``(voice_id, weight)`` tuples.
|
||||
|
||||
Example:
|
||||
``"af_heart*0.7+am_adam*0.3"`` → ``[('af_heart', 0.7), ('am_adam', 0.3)]``
|
||||
|
||||
Args:
|
||||
formula: Space- or ``+``-joined mix formula string.
|
||||
|
||||
Returns:
|
||||
Parsed list; empty if parsing fails.
|
||||
"""
|
||||
parts: List[Tuple[str, float]] = []
|
||||
for token in re.split(r"[+\s]+", formula.strip()):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
if "*" in token:
|
||||
name, _, weight_str = token.partition("*")
|
||||
try:
|
||||
parts.append((name.strip(), float(weight_str.strip())))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# Bare voice id — assume full weight
|
||||
if token in VOICES_INTERNAL:
|
||||
parts.append((token, 1.0))
|
||||
return parts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Number formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_number(n: int) -> str:
|
||||
"""
|
||||
Format an integer with thousands separators.
|
||||
|
||||
Args:
|
||||
n: Integer value.
|
||||
|
||||
Returns:
|
||||
Formatted string, e.g. ``"1,234,567"``.
|
||||
"""
|
||||
return f"{n:,}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def safe_basename(path: Optional[str]) -> str:
|
||||
"""
|
||||
Return the basename of a path, or an empty string when path is None/empty.
|
||||
|
||||
Args:
|
||||
path: Optional file-system path.
|
||||
"""
|
||||
if not path:
|
||||
return ""
|
||||
return os.path.basename(path)
|
||||
|
||||
|
||||
def output_format_label(fmt: str) -> str:
|
||||
"""
|
||||
Return a display label for an audio output format key.
|
||||
|
||||
Args:
|
||||
fmt: Lowercase format key (``'wav'``, ``'mp3'``, …).
|
||||
"""
|
||||
labels = {
|
||||
"wav": "WAV (lossless)",
|
||||
"flac": "FLAC (lossless compressed)",
|
||||
"mp3": "MP3",
|
||||
"opus": "Opus (best compression)",
|
||||
"m4b": "M4B (with chapters)",
|
||||
}
|
||||
return labels.get(fmt, fmt.upper())
|
||||
|
||||
|
||||
def subtitle_format_label(key: str) -> str:
|
||||
"""
|
||||
Return the display label for a subtitle format key.
|
||||
|
||||
Args:
|
||||
key: Internal subtitle format key (e.g. ``'ass_centered_narrow'``).
|
||||
"""
|
||||
for k, label in SUBTITLE_FORMATS:
|
||||
if k == key:
|
||||
return label
|
||||
return key
|
||||
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Design tokens and theme configuration for the Abogen Flet frontend.
|
||||
|
||||
This module defines the application's complete colour palette, typography
|
||||
scale, spacing constants, and border radii in one canonical place.
|
||||
All component modules import from here; changing a value here propagates
|
||||
instantly across the entire UI.
|
||||
|
||||
Flet's ``ft.Theme`` uses ``ColorScheme``, but for custom widgets we paint
|
||||
directly with hex colours drawn from ``LIGHT`` and ``DARK`` palettes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour palettes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Palette:
|
||||
"""A complete colour palette for one theme mode."""
|
||||
|
||||
# Backgrounds
|
||||
bg_base: str # Deepest background (window / page)
|
||||
bg_surface: str # Cards, panels, dialogs
|
||||
bg_elevated: str # Slightly raised elements (toolbar, sidebar)
|
||||
bg_input: str # Text-field / dropdown backgrounds
|
||||
|
||||
# Brand accent
|
||||
accent: str # Primary interactive colour (buttons, links)
|
||||
accent_muted: str # Hover tint over accents
|
||||
accent_on: str # Text drawn on top of accent fills
|
||||
|
||||
# Semantic
|
||||
success: str
|
||||
error: str
|
||||
warning: str
|
||||
info: str
|
||||
|
||||
# Text hierarchy
|
||||
text_primary: str
|
||||
text_secondary: str
|
||||
text_disabled: str
|
||||
text_on_accent: str
|
||||
|
||||
# Borders / dividers
|
||||
border: str
|
||||
border_focused: str
|
||||
divider: str
|
||||
|
||||
# Specific UI atoms
|
||||
drop_zone_border: str
|
||||
drop_zone_bg: str
|
||||
drop_zone_active_border: str
|
||||
drop_zone_active_bg: str
|
||||
log_bg: str
|
||||
log_text: str
|
||||
progress_bar_bg: str
|
||||
progress_bar_fill: str
|
||||
sidebar_bg: str
|
||||
sidebar_selected_bg: str
|
||||
sidebar_selected_text: str
|
||||
nav_indicator: str
|
||||
|
||||
|
||||
DARK = _Palette(
|
||||
bg_base="#0f1117",
|
||||
bg_surface="#181b23",
|
||||
bg_elevated="#1e2230",
|
||||
bg_input="#252a38",
|
||||
|
||||
accent="#5b8af5",
|
||||
accent_muted="#3a5fc4",
|
||||
accent_on="#ffffff",
|
||||
|
||||
success="#42ad4a",
|
||||
error="#e84e3c",
|
||||
warning="#f5a623",
|
||||
info="#5b8af5",
|
||||
|
||||
text_primary="#e8eaf0",
|
||||
text_secondary="#9ba3b8",
|
||||
text_disabled="#4e5568",
|
||||
text_on_accent="#ffffff",
|
||||
|
||||
border="#2c3147",
|
||||
border_focused="#5b8af5",
|
||||
divider="#252a38",
|
||||
|
||||
drop_zone_border="#3a4466",
|
||||
drop_zone_bg="#151928",
|
||||
drop_zone_active_border="#42ad4a",
|
||||
drop_zone_active_bg="#0d1f10",
|
||||
log_bg="#0d1117",
|
||||
log_text="#b0b8cc",
|
||||
progress_bar_bg="#1e2230",
|
||||
progress_bar_fill="#5b8af5",
|
||||
sidebar_bg="#13161f",
|
||||
sidebar_selected_bg="#252a38",
|
||||
sidebar_selected_text="#5b8af5",
|
||||
nav_indicator="#5b8af5",
|
||||
)
|
||||
|
||||
LIGHT = _Palette(
|
||||
bg_base="#f4f5f8",
|
||||
bg_surface="#ffffff",
|
||||
bg_elevated="#edf0f5",
|
||||
bg_input="#f0f2f7",
|
||||
|
||||
accent="#3a5fc4",
|
||||
accent_muted="#2a4fae",
|
||||
accent_on="#ffffff",
|
||||
|
||||
success="#2e9437",
|
||||
error="#c0392b",
|
||||
warning="#d4870a",
|
||||
info="#3a5fc4",
|
||||
|
||||
text_primary="#1a1d27",
|
||||
text_secondary="#5a6172",
|
||||
text_disabled="#9ba3b8",
|
||||
text_on_accent="#ffffff",
|
||||
|
||||
border="#dce0ea",
|
||||
border_focused="#3a5fc4",
|
||||
divider="#e8ebf2",
|
||||
|
||||
drop_zone_border="#a8b4d0",
|
||||
drop_zone_bg="#f7f8fd",
|
||||
drop_zone_active_border="#2e9437",
|
||||
drop_zone_active_bg="#f0fff1",
|
||||
log_bg="#f8f9fc",
|
||||
log_text="#3d4358",
|
||||
progress_bar_bg="#e4e8f0",
|
||||
progress_bar_fill="#3a5fc4",
|
||||
sidebar_bg="#eff1f5",
|
||||
sidebar_selected_bg="#dde3f2",
|
||||
sidebar_selected_text="#3a5fc4",
|
||||
nav_indicator="#3a5fc4",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typography
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FONT_FAMILY = "Inter, Segoe UI, Roboto, system-ui, sans-serif"
|
||||
FONT_SIZE_XS = 11
|
||||
FONT_SIZE_SM = 12
|
||||
FONT_SIZE_BASE = 14
|
||||
FONT_SIZE_MD = 16
|
||||
FONT_SIZE_LG = 20
|
||||
FONT_SIZE_XL = 26
|
||||
FONT_SIZE_DISPLAY = 34
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spacing scale (pixels)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SPACE_XS = 4
|
||||
SPACE_SM = 8
|
||||
SPACE_MD = 12
|
||||
SPACE_LG = 16
|
||||
SPACE_XL = 24
|
||||
SPACE_2XL = 32
|
||||
SPACE_3XL = 48
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Border radii
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RADIUS_SM = 6
|
||||
RADIUS_MD = 10
|
||||
RADIUS_LG = 16
|
||||
RADIUS_FULL = 999 # Pill-shaped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flet ColorScheme builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_color_scheme(palette: _Palette) -> ft.ColorScheme:
|
||||
"""
|
||||
Construct a ``ft.ColorScheme`` from a ``_Palette`` object.
|
||||
|
||||
Args:
|
||||
palette: The ``DARK`` or ``LIGHT`` palette.
|
||||
|
||||
Returns:
|
||||
A fully-populated Flet ``ColorScheme``.
|
||||
"""
|
||||
return ft.ColorScheme(
|
||||
primary=palette.accent,
|
||||
on_primary=palette.accent_on,
|
||||
primary_container=palette.accent_muted,
|
||||
secondary=palette.accent,
|
||||
on_secondary=palette.text_on_accent,
|
||||
surface=palette.bg_surface,
|
||||
on_surface=palette.text_primary,
|
||||
on_surface_variant=palette.text_secondary,
|
||||
error=palette.error,
|
||||
on_error=palette.text_on_accent,
|
||||
outline=palette.border,
|
||||
)
|
||||
|
||||
|
||||
def build_text_theme() -> ft.TextTheme:
|
||||
"""
|
||||
Construct a ``ft.TextTheme`` using the application's type scale.
|
||||
|
||||
Returns:
|
||||
A Flet ``TextTheme`` with consistent font-size assignments.
|
||||
"""
|
||||
return ft.TextTheme(
|
||||
display_large=ft.TextStyle(size=FONT_SIZE_DISPLAY, weight=ft.FontWeight.W_700),
|
||||
headline_large=ft.TextStyle(size=FONT_SIZE_XL, weight=ft.FontWeight.W_700),
|
||||
headline_medium=ft.TextStyle(size=FONT_SIZE_LG, weight=ft.FontWeight.W_600),
|
||||
title_large=ft.TextStyle(size=FONT_SIZE_MD, weight=ft.FontWeight.W_600),
|
||||
title_medium=ft.TextStyle(size=FONT_SIZE_BASE, weight=ft.FontWeight.W_500),
|
||||
body_large=ft.TextStyle(size=FONT_SIZE_BASE),
|
||||
body_medium=ft.TextStyle(size=FONT_SIZE_SM),
|
||||
label_large=ft.TextStyle(size=FONT_SIZE_SM, weight=ft.FontWeight.W_500),
|
||||
label_medium=ft.TextStyle(size=FONT_SIZE_XS),
|
||||
)
|
||||
|
||||
|
||||
def make_theme(dark: bool) -> ft.Theme:
|
||||
"""
|
||||
Build a complete Flet ``Theme`` for the requested mode.
|
||||
|
||||
Args:
|
||||
dark: True for dark-mode theme, False for light-mode theme.
|
||||
|
||||
Returns:
|
||||
A configured ``ft.Theme`` instance.
|
||||
"""
|
||||
palette = DARK if dark else LIGHT
|
||||
return ft.Theme(
|
||||
color_scheme=build_color_scheme(palette),
|
||||
text_theme=build_text_theme(),
|
||||
color_scheme_seed=palette.accent,
|
||||
use_material3=True,
|
||||
)
|
||||
|
||||
|
||||
def get_palette(page: ft.Page) -> _Palette:
|
||||
"""
|
||||
Return the active colour palette for the given page.
|
||||
|
||||
Args:
|
||||
page: The Flet ``Page`` instance.
|
||||
|
||||
Returns:
|
||||
``DARK`` or ``LIGHT`` depending on the page's theme mode.
|
||||
"""
|
||||
return DARK if page.theme_mode == ft.ThemeMode.DARK else LIGHT
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Views sub-package for the Abogen Flet frontend."""
|
||||
from .dashboard import DashboardView
|
||||
from .settings import SettingsView
|
||||
from .queue_view import QueueView
|
||||
|
||||
__all__ = ["DashboardView", "SettingsView", "QueueView"]
|
||||
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
Dashboard view – the primary conversion screen.
|
||||
|
||||
Hosts the file drop-zone, voice/speed/format controls, real-time log
|
||||
terminal, progress bar, and the Start/Cancel/Finish action row.
|
||||
|
||||
All heavy work is delegated to ConversionBridge which runs on daemon
|
||||
threads and schedules UI updates back onto the Flet event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..state import AppState
|
||||
from ..utils.helpers import (
|
||||
detect_file_type, human_readable_size, format_number,
|
||||
format_etr, grouped_voices, output_format_label,
|
||||
subtitle_format_label, is_book_type, voice_lang_code, SUPPORTED_EXTENSIONS
|
||||
)
|
||||
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG, SPACE_XL
|
||||
from ..utils.conversion_bridge import ConversionBridge
|
||||
from ..components import (
|
||||
build_drop_zone, build_log_terminal, log_entry,
|
||||
build_primary_button, build_secondary_button,
|
||||
build_card, build_section_header, labelled_row, show_snack,
|
||||
)
|
||||
from abogen.constants import (
|
||||
SUBTITLE_FORMATS, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.utils import get_gpu_acceleration, get_user_cache_path, calculate_text_length, clean_text
|
||||
|
||||
|
||||
class DashboardView:
|
||||
"""
|
||||
The main conversion dashboard.
|
||||
|
||||
Instantiated once per Flet session and mounted as a ``ft.Column``
|
||||
inside the page's content area.
|
||||
"""
|
||||
|
||||
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||
self._page = page
|
||||
self._state = state
|
||||
self._bridge = ConversionBridge(page, state)
|
||||
|
||||
# Internal refs
|
||||
self._log_list: Optional[ft.ListView] = None
|
||||
self._progress_bar: Optional[ft.ProgressBar] = None
|
||||
self._etr_label: Optional[ft.Text] = None
|
||||
self._drop_zone_ref: Optional[ft.GestureDetector] = None
|
||||
self._drop_zone_container: Optional[ft.Container] = None
|
||||
self._file_picker: Optional[ft.FilePicker] = None
|
||||
|
||||
# Wire state callbacks
|
||||
state.on_log = self._on_log
|
||||
state.on_progress = self._on_progress
|
||||
state.on_conversion_finished = self._on_finished
|
||||
|
||||
# Build UI refs
|
||||
self._voice_dd: Optional[ft.Dropdown] = None
|
||||
self._speed_slider: Optional[ft.Slider] = None
|
||||
self._speed_label: Optional[ft.Text] = None
|
||||
self._format_dd: Optional[ft.Dropdown] = None
|
||||
self._subtitle_dd: Optional[ft.Dropdown] = None
|
||||
self._subtitle_fmt_dd: Optional[ft.Dropdown] = None
|
||||
self._gpu_switch: Optional[ft.Switch] = None
|
||||
self._start_btn: Optional[ft.ElevatedButton] = None
|
||||
self._cancel_btn: Optional[ft.OutlinedButton] = None
|
||||
self._finish_col: Optional[ft.Column] = None
|
||||
self._controls_col: Optional[ft.Column] = None
|
||||
self._log_section: Optional[ft.Container] = None
|
||||
self._progress_col: Optional[ft.Column] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build(self) -> ft.Column:
|
||||
"""Return the complete dashboard column."""
|
||||
p = self._page
|
||||
dark = p.theme_mode == ft.ThemeMode.DARK
|
||||
pal = get_palette(p)
|
||||
if self._file_picker is None:
|
||||
self._file_picker = ft.FilePicker()
|
||||
|
||||
# --- Drop zone ---
|
||||
self._drop_zone_container = ft.Container()
|
||||
self._refresh_drop_zone()
|
||||
|
||||
# --- Voice selector ---
|
||||
voice_items = []
|
||||
for lang_label, voices in grouped_voices():
|
||||
voice_items.append(ft.dropdown.Option(key=f"__hdr_{lang_label}", text=f"── {lang_label} ──", disabled=True))
|
||||
for v in voices:
|
||||
voice_items.append(ft.dropdown.Option(key=v, text=v))
|
||||
|
||||
self._voice_dd = ft.Dropdown(
|
||||
options=voice_items,
|
||||
value=self._state.selected_voice,
|
||||
on_select=self._on_voice_changed,
|
||||
dense=True,
|
||||
expand=True,
|
||||
border_radius=RADIUS_SM,
|
||||
)
|
||||
|
||||
# --- Speed slider ---
|
||||
self._speed_label = ft.Text(f"{self._state.speed:.2f}", size=13, width=40)
|
||||
self._speed_slider = ft.Slider(
|
||||
min=0.1, max=2.0, value=self._state.speed,
|
||||
divisions=190, label="{value}",
|
||||
on_change=self._on_speed_changed,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
# --- Format ---
|
||||
self._format_dd = ft.Dropdown(
|
||||
options=[ft.dropdown.Option(key=k, text=output_format_label(k))
|
||||
for k in ("wav", "flac", "mp3", "opus", "m4b")],
|
||||
value=self._state.selected_format,
|
||||
on_select=lambda e: self._set_field("selected_format", e.control.value),
|
||||
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||
)
|
||||
|
||||
# --- Subtitle mode ---
|
||||
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
|
||||
self._subtitle_dd = ft.Dropdown(
|
||||
options=[ft.dropdown.Option(m) for m in sub_modes],
|
||||
value=self._state.subtitle_mode,
|
||||
on_select=lambda e: self._set_field("subtitle_mode", e.control.value),
|
||||
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||
)
|
||||
|
||||
# --- Subtitle format ---
|
||||
self._subtitle_fmt_dd = ft.Dropdown(
|
||||
options=[ft.dropdown.Option(key=k, text=lbl) for k, lbl in SUBTITLE_FORMATS],
|
||||
value=self._state.subtitle_format,
|
||||
on_select=lambda e: self._set_field("subtitle_format", e.control.value),
|
||||
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||
)
|
||||
|
||||
# --- GPU ---
|
||||
self._gpu_switch = ft.Switch(
|
||||
value=self._state.use_gpu, label="",
|
||||
on_change=lambda e: self._set_field("use_gpu", e.control.value),
|
||||
active_color="#5b8af5" if dark else "#3a5fc4",
|
||||
)
|
||||
|
||||
# --- Log ---
|
||||
log_lv = ft.ListView(expand=True, auto_scroll=True, spacing=1, padding=ft.Padding.all(8))
|
||||
self._log_list = log_lv
|
||||
bg_log = "#0d1117" if dark else "#f8f9fc"
|
||||
bd_log = "#252a38" if dark else "#dce0ea"
|
||||
self._log_section = ft.Container(
|
||||
content=log_lv, bgcolor=bg_log,
|
||||
border=ft.Border.all(1, bd_log),
|
||||
border_radius=RADIUS_SM, height=220,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
# --- Progress ---
|
||||
fill = "#5b8af5" if dark else "#3a5fc4"
|
||||
bg_p = "#1e2230" if dark else "#e4e8f0"
|
||||
self._progress_bar = ft.ProgressBar(
|
||||
value=0, color=fill, bgcolor=bg_p, height=8,
|
||||
border_radius=ft.BorderRadius.all(4), expand=True,
|
||||
)
|
||||
self._etr_label = ft.Text("", size=11, color=pal.text_secondary, text_align=ft.TextAlign.CENTER)
|
||||
self._progress_col = ft.Column([
|
||||
ft.Row([self._progress_bar], spacing=0),
|
||||
self._etr_label,
|
||||
], spacing=SPACE_SM, horizontal_alignment=ft.CrossAxisAlignment.CENTER, visible=False)
|
||||
|
||||
# --- Buttons ---
|
||||
self._start_btn = build_primary_button(
|
||||
"Start Conversion",
|
||||
icon="play_arrow",
|
||||
on_click=self._on_start,
|
||||
page=p,
|
||||
)
|
||||
self._cancel_btn = build_secondary_button(
|
||||
"Cancel", icon="stop",
|
||||
on_click=self._on_cancel, page=p,
|
||||
)
|
||||
self._cancel_btn.visible = False
|
||||
|
||||
# --- Finish row ---
|
||||
self._finish_col = ft.Column([
|
||||
ft.Row([
|
||||
build_secondary_button("Open File", icon="open_in_new",
|
||||
on_click=self._on_open_file, page=p),
|
||||
build_secondary_button("Go to Folder", icon="folder_open",
|
||||
on_click=self._on_go_folder, page=p),
|
||||
build_secondary_button("New Conversion", icon="refresh",
|
||||
on_click=self._on_reset, page=p),
|
||||
], wrap=True, spacing=SPACE_SM, run_spacing=SPACE_SM),
|
||||
], visible=False)
|
||||
|
||||
# --- Controls column ---
|
||||
self._controls_col = ft.Column([
|
||||
build_section_header("Voice & Speed", icon="record_voice_over", page=p),
|
||||
labelled_row("Voice", self._voice_dd, page=p),
|
||||
labelled_row("Speed", ft.Row([self._speed_slider, self._speed_label], expand=True, spacing=SPACE_SM), page=p),
|
||||
ft.Divider(height=1, color=pal.divider),
|
||||
build_section_header("Output", icon="audio_file", page=p),
|
||||
labelled_row("Format", self._format_dd, page=p),
|
||||
labelled_row("Subtitles", self._subtitle_dd, page=p),
|
||||
labelled_row("Subtitle Format", self._subtitle_fmt_dd, page=p),
|
||||
ft.Divider(height=1, color=pal.divider),
|
||||
build_section_header("Processing", icon="memory", page=p),
|
||||
labelled_row("GPU Acceleration", self._gpu_switch, page=p),
|
||||
], spacing=SPACE_MD)
|
||||
|
||||
outer = ft.Column([
|
||||
self._drop_zone_container,
|
||||
ft.Container(height=SPACE_MD),
|
||||
build_card(self._controls_col, page=p),
|
||||
ft.Container(height=SPACE_SM),
|
||||
self._log_section,
|
||||
self._progress_col,
|
||||
ft.Row([self._start_btn, self._cancel_btn], spacing=SPACE_SM, wrap=True),
|
||||
self._finish_col,
|
||||
], spacing=SPACE_MD, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
return outer
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Drop-zone management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_drop_zone(self, *, accent: bool = False, error: bool = False, err_msg: str = "") -> None:
|
||||
"""Rebuild the drop-zone widget and update its container."""
|
||||
p = self._page
|
||||
s = self._state
|
||||
fname = None; fsize = None; fchars = None
|
||||
if s.selected_file and os.path.exists(s.selected_file):
|
||||
disp = s.displayed_file_path or s.selected_file
|
||||
fname = os.path.basename(disp)
|
||||
try:
|
||||
fsize = human_readable_size(os.path.getsize(s.selected_file))
|
||||
except Exception:
|
||||
fsize = ""
|
||||
if s.char_count:
|
||||
fchars = format_number(s.char_count)
|
||||
|
||||
label = err_msg if error else "Drag & drop your file here or click to browse"
|
||||
sub = "Supports .txt · .epub · .pdf · .md · .srt · .ass · .vtt"
|
||||
|
||||
dz = build_drop_zone(
|
||||
on_pick=self._open_file_picker,
|
||||
label=label, sub_label=sub,
|
||||
accent=accent, error=error,
|
||||
filename=fname, file_size=fsize, char_count=fchars,
|
||||
page=p,
|
||||
)
|
||||
if self._drop_zone_container is not None:
|
||||
self._drop_zone_container.content = dz
|
||||
self._drop_zone_ref = dz
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File picking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _open_file_picker(self) -> None:
|
||||
"""Open the native file picker dialog."""
|
||||
self._page.run_task(self._pick_files_async)
|
||||
|
||||
async def _pick_files_async(self) -> None:
|
||||
"""Run the file picker using Flet's async service API."""
|
||||
picker = self._file_picker
|
||||
if picker is None:
|
||||
picker = ft.FilePicker()
|
||||
self._file_picker = picker
|
||||
|
||||
try:
|
||||
files = await picker.pick_files(
|
||||
dialog_title="Select Input File",
|
||||
file_type=ft.FilePickerFileType.CUSTOM,
|
||||
allowed_extensions=["txt", "epub", "pdf", "md", "markdown", "srt", "ass", "vtt"],
|
||||
allow_multiple=False,
|
||||
)
|
||||
except Exception as ex:
|
||||
self._refresh_drop_zone(error=True, err_msg="Could not open file picker.")
|
||||
show_snack(self._page, f"File picker error: {ex}", error=True)
|
||||
self._page.update()
|
||||
return
|
||||
if not files:
|
||||
return
|
||||
file_path = files[0].path
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return
|
||||
self._load_file(file_path)
|
||||
|
||||
def _load_file(self, file_path: str) -> None:
|
||||
"""Validate and load a file into the session state."""
|
||||
from pathlib import Path as _Path
|
||||
ext = _Path(file_path).suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS:
|
||||
self._state.reset_file_state()
|
||||
self._refresh_drop_zone(error=True, err_msg=f"Unsupported file type: {ext}")
|
||||
self._page.update()
|
||||
return
|
||||
|
||||
ftype = detect_file_type(file_path)
|
||||
s = self._state
|
||||
|
||||
if ftype in ("epub", "pdf", "markdown"):
|
||||
# For book types: extract text to temp cache
|
||||
self._handle_book_file(file_path, ftype)
|
||||
else:
|
||||
# Plain text / subtitle files
|
||||
s.selected_file = file_path
|
||||
s.selected_file_type = ftype
|
||||
s.displayed_file_path = file_path
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
text = f.read()
|
||||
s.char_count = calculate_text_length(clean_text(text))
|
||||
except Exception:
|
||||
s.char_count = 0
|
||||
self._refresh_drop_zone(accent=True)
|
||||
self._update_subtitle_availability()
|
||||
self._page.update()
|
||||
|
||||
def _handle_book_file(self, book_path: str, ftype: str) -> None:
|
||||
"""Extract text from epub/pdf/markdown and store as temp txt."""
|
||||
import threading as _t
|
||||
s = self._state
|
||||
|
||||
def _extract():
|
||||
try:
|
||||
from abogen.text_extractor import extract_from_path
|
||||
chapters = extract_from_path(book_path, file_type=ftype)
|
||||
combined = "\n\n".join(ch.text for ch in chapters if ch.text.strip())
|
||||
cache_dir = get_user_cache_path()
|
||||
base = os.path.splitext(os.path.basename(book_path))[0]
|
||||
fd, tmp = tempfile.mkstemp(prefix=f"{base}_", suffix=".txt", dir=cache_dir)
|
||||
os.close(fd)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(combined)
|
||||
|
||||
s.selected_file = tmp
|
||||
s.selected_file_type = ftype
|
||||
s.selected_book_path = book_path
|
||||
s.displayed_file_path = book_path
|
||||
s.char_count = calculate_text_length(clean_text(combined))
|
||||
s.selected_chapters = [f"ch_{i}" for i in range(len(chapters))]
|
||||
|
||||
self._refresh_drop_zone(accent=True)
|
||||
self._update_subtitle_availability()
|
||||
self._page.update()
|
||||
except Exception as ex:
|
||||
s.reset_file_state()
|
||||
self._refresh_drop_zone(error=True, err_msg=f"Could not parse file: {ex}")
|
||||
self._page.update()
|
||||
|
||||
_t.Thread(target=_extract, daemon=True).start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Control event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_field(self, attr: str, value) -> None:
|
||||
setattr(self._state, attr, value)
|
||||
self._state.persist_config()
|
||||
|
||||
def _on_voice_changed(self, e: ft.ControlEvent) -> None:
|
||||
v = e.control.value or "af_heart"
|
||||
self._state.selected_voice = v
|
||||
self._state.selected_lang = voice_lang_code(v)
|
||||
self._state.persist_config()
|
||||
self._update_subtitle_availability()
|
||||
self._page.update()
|
||||
|
||||
def _on_speed_changed(self, e: ft.ControlEvent) -> None:
|
||||
val = round(float(e.control.value), 2)
|
||||
self._state.speed = val
|
||||
if self._speed_label:
|
||||
self._speed_label.value = f"{val:.2f}"
|
||||
self._state.persist_config()
|
||||
self._page.update()
|
||||
|
||||
def _update_subtitle_availability(self) -> None:
|
||||
"""Enable or disable subtitle controls based on selected language."""
|
||||
lang = self._state.selected_lang
|
||||
enabled = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
if self._subtitle_dd:
|
||||
self._subtitle_dd.disabled = not enabled
|
||||
if self._subtitle_fmt_dd:
|
||||
self._subtitle_fmt_dd.disabled = not enabled
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Conversion control
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_start(self, _: ft.ControlEvent) -> None:
|
||||
"""Validate inputs and kick off conversion."""
|
||||
s = self._state
|
||||
if not s.selected_file or not os.path.exists(s.selected_file):
|
||||
self._refresh_drop_zone(error=True, err_msg="Please select an input file first.")
|
||||
self._page.update()
|
||||
return
|
||||
|
||||
# Transition UI to converting state
|
||||
self._set_converting_ui(True)
|
||||
|
||||
self._bridge.start(
|
||||
input_file=s.selected_file,
|
||||
voice=s.get_voice_formula(),
|
||||
lang_code=s.selected_lang,
|
||||
speed=s.speed,
|
||||
output_format=s.selected_format,
|
||||
subtitle_mode=s.subtitle_mode,
|
||||
subtitle_format=s.subtitle_format,
|
||||
use_gpu=s.use_gpu,
|
||||
save_option=s.save_option,
|
||||
output_folder=s.selected_output_folder,
|
||||
replace_single_newlines=s.replace_single_newlines,
|
||||
char_count=s.char_count,
|
||||
save_chapters_separately=s.save_chapters_separately or False,
|
||||
merge_chapters_at_end=True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
|
||||
separate_chapters_format=s.separate_chapters_format,
|
||||
silence_between_chapters=s.silence_duration,
|
||||
max_subtitle_words=s.max_subtitle_words,
|
||||
chapter_intro_delay=s.chapter_intro_delay,
|
||||
read_title_intro=s.read_title_intro,
|
||||
read_closing_outro=s.read_closing_outro,
|
||||
auto_prefix_chapter_titles=s.auto_prefix_chapter_titles,
|
||||
normalize_chapter_opening_caps=s.normalize_chapter_opening_caps,
|
||||
tts_provider=s.tts_provider,
|
||||
supertonic_total_steps=s.supertonic_total_steps,
|
||||
chunk_level=s.chunk_level,
|
||||
generate_epub3=s.generate_epub3,
|
||||
word_substitutions_enabled=s.word_substitutions_enabled,
|
||||
word_substitutions_list=s.word_substitutions_list,
|
||||
case_sensitive_substitutions=s.case_sensitive_substitutions,
|
||||
replace_all_caps=s.replace_all_caps,
|
||||
replace_numerals=s.replace_numerals,
|
||||
fix_nonstandard_punctuation=s.fix_nonstandard_punctuation,
|
||||
)
|
||||
|
||||
def _on_cancel(self, _: ft.ControlEvent) -> None:
|
||||
self._bridge.cancel()
|
||||
|
||||
def _set_converting_ui(self, converting: bool) -> None:
|
||||
"""Toggle UI between idle and converting states."""
|
||||
if self._start_btn:
|
||||
self._start_btn.visible = not converting
|
||||
if self._cancel_btn:
|
||||
self._cancel_btn.visible = converting
|
||||
if self._controls_col:
|
||||
self._controls_col.visible = not converting
|
||||
if self._log_section:
|
||||
self._log_section.visible = converting
|
||||
if self._log_list:
|
||||
self._log_list.controls.clear()
|
||||
if self._progress_col:
|
||||
self._progress_col.visible = converting
|
||||
if self._progress_bar:
|
||||
self._progress_bar.value = 0
|
||||
if self._etr_label:
|
||||
self._etr_label.value = "Estimating…"
|
||||
if self._finish_col:
|
||||
self._finish_col.visible = False
|
||||
self._page.update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State callbacks (called from background thread via page.run_task)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_log(self, message: str, level: str) -> None:
|
||||
if self._log_list is None:
|
||||
return
|
||||
entry = log_entry(message, level, self._page)
|
||||
self._log_list.controls.append(entry)
|
||||
# Cap log lines
|
||||
if len(self._log_list.controls) > 2000:
|
||||
self._log_list.controls = self._log_list.controls[-1800:]
|
||||
try:
|
||||
self._page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_progress(self, fraction: float, etr: Optional[float]) -> None:
|
||||
if self._progress_bar:
|
||||
self._progress_bar.value = min(fraction, 0.99)
|
||||
if self._etr_label:
|
||||
self._etr_label.value = format_etr(etr)
|
||||
try:
|
||||
self._page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_finished(self, message: str, output_path: Optional[str]) -> None:
|
||||
if self._progress_bar:
|
||||
self._progress_bar.value = 1.0
|
||||
if self._cancel_btn:
|
||||
self._cancel_btn.visible = False
|
||||
|
||||
if message == "Cancelled":
|
||||
# Restore idle state
|
||||
self._set_converting_ui(False)
|
||||
show_snack(self._page, "Conversion cancelled.", error=True)
|
||||
return
|
||||
|
||||
if "failed" in message.lower() or "error" in message.lower():
|
||||
self._log_on_log(message, "error")
|
||||
self._set_converting_ui(False)
|
||||
show_snack(self._page, f"Error: {message}", error=True)
|
||||
return
|
||||
|
||||
# Success
|
||||
if self._log_section:
|
||||
self._log_section.visible = True
|
||||
if self._progress_col:
|
||||
self._progress_col.visible = False
|
||||
if self._controls_col:
|
||||
self._controls_col.visible = False
|
||||
if self._finish_col:
|
||||
self._finish_col.visible = True
|
||||
if self._start_btn:
|
||||
self._start_btn.visible = False
|
||||
show_snack(self._page, "Conversion completed!")
|
||||
try:
|
||||
self._page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _log_on_log(self, message: str, level: str) -> None:
|
||||
self._on_log(message, level)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Finish actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_open_file(self, _: ft.ControlEvent) -> None:
|
||||
path = self._state.last_output_path
|
||||
if path and os.path.exists(path):
|
||||
import subprocess, platform
|
||||
try:
|
||||
if platform.system() == "Darwin":
|
||||
subprocess.Popen(["open", path])
|
||||
elif platform.system() == "Windows":
|
||||
os.startfile(path)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
except Exception as ex:
|
||||
show_snack(self._page, f"Cannot open file: {ex}", error=True)
|
||||
else:
|
||||
show_snack(self._page, "Output file not found.", error=True)
|
||||
|
||||
def _on_go_folder(self, _: ft.ControlEvent) -> None:
|
||||
path = self._state.last_output_path
|
||||
folder = os.path.dirname(path) if path and os.path.isfile(path) else path
|
||||
if folder and os.path.isdir(folder):
|
||||
import subprocess, platform
|
||||
try:
|
||||
if platform.system() == "Darwin":
|
||||
subprocess.Popen(["open", folder])
|
||||
elif platform.system() == "Windows":
|
||||
subprocess.Popen(["explorer", folder])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
except Exception as ex:
|
||||
show_snack(self._page, f"Cannot open folder: {ex}", error=True)
|
||||
else:
|
||||
show_snack(self._page, "Output folder not found.", error=True)
|
||||
|
||||
def _on_reset(self, _: ft.ControlEvent) -> None:
|
||||
self._state.reset_file_state()
|
||||
self._state.reset_conversion_state()
|
||||
self._refresh_drop_zone()
|
||||
self._set_converting_ui(False)
|
||||
if self._finish_col:
|
||||
self._finish_col.visible = False
|
||||
if self._controls_col:
|
||||
self._controls_col.visible = True
|
||||
if self._start_btn:
|
||||
self._start_btn.visible = True
|
||||
self._page.update()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Queue management view.
|
||||
|
||||
Displays the current conversion queue, allowing the user to reorder,
|
||||
remove, and inspect queued items before starting batch processing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..state import AppState, ConversionJob
|
||||
from ..utils.theme import get_palette, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||
from ..utils.helpers import safe_basename, output_format_label, format_number
|
||||
from ..components import (
|
||||
build_card, build_section_header, build_primary_button,
|
||||
build_secondary_button, show_snack, build_divider,
|
||||
resolve_icon,
|
||||
)
|
||||
|
||||
|
||||
class QueueView:
|
||||
"""Queue manager view."""
|
||||
|
||||
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||
self._page = page
|
||||
self._state = state
|
||||
self._list_col: Optional[ft.Column] = None
|
||||
|
||||
def build(self) -> ft.Column:
|
||||
p = self._page
|
||||
s = self._state
|
||||
pal = get_palette(p)
|
||||
dark = p.theme_mode == ft.ThemeMode.DARK
|
||||
|
||||
self._list_col = ft.Column(spacing=SPACE_SM)
|
||||
self._refresh_list()
|
||||
|
||||
header = build_section_header("Conversion Queue",
|
||||
icon="list_alt", page=p)
|
||||
|
||||
action_row = ft.Row([
|
||||
build_primary_button(
|
||||
"Start Queue",
|
||||
icon="play_arrow",
|
||||
on_click=self._on_start_queue,
|
||||
page=p,
|
||||
disabled=not s.queued_items,
|
||||
),
|
||||
build_secondary_button(
|
||||
"Clear All",
|
||||
icon="delete_sweep",
|
||||
on_click=self._on_clear_queue,
|
||||
page=p,
|
||||
),
|
||||
], spacing=SPACE_SM, wrap=True)
|
||||
|
||||
queue_card = build_card(ft.Column([
|
||||
header,
|
||||
ft.Divider(height=1, color=pal.divider),
|
||||
self._list_col,
|
||||
ft.Container(height=SPACE_SM),
|
||||
action_row,
|
||||
], spacing=SPACE_MD), page=p)
|
||||
|
||||
return ft.Column([queue_card], scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_list(self) -> None:
|
||||
if self._list_col is None:
|
||||
return
|
||||
self._list_col.controls.clear()
|
||||
s = self._state
|
||||
pal = get_palette(self._page)
|
||||
dark = self._page.theme_mode == ft.ThemeMode.DARK
|
||||
|
||||
if not s.queued_items:
|
||||
self._list_col.controls.append(
|
||||
ft.Text("No items in the queue.", size=13,
|
||||
color=pal.text_secondary,
|
||||
text_align=ft.TextAlign.CENTER)
|
||||
)
|
||||
return
|
||||
|
||||
for idx, job in enumerate(s.queued_items):
|
||||
tile = self._build_job_tile(idx, job, dark, pal)
|
||||
self._list_col.controls.append(tile)
|
||||
|
||||
try:
|
||||
self._page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _build_job_tile(self, idx: int, job: ConversionJob, dark: bool, pal) -> ft.Container:
|
||||
"""Build a single queue-item tile."""
|
||||
bg = pal.bg_elevated
|
||||
border_clr = pal.border
|
||||
accent = "#5b8af5" if dark else "#3a5fc4"
|
||||
text_primary = pal.text_primary
|
||||
text_secondary = pal.text_secondary
|
||||
|
||||
def _remove(_):
|
||||
self._state.queued_items.pop(idx)
|
||||
self._refresh_list()
|
||||
|
||||
name = safe_basename(job.display_name or job.file_path)
|
||||
details = (
|
||||
f"Voice: {job.voice} · Format: {output_format_label(job.output_format)}"
|
||||
f" · Speed: {job.speed:.2f}x · Chars: {format_number(job.char_count)}"
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Container(
|
||||
content=ft.Text(str(idx + 1), size=12, weight=ft.FontWeight.W_700,
|
||||
color=accent),
|
||||
width=32,
|
||||
),
|
||||
ft.Column([
|
||||
ft.Text(name, size=13, weight=ft.FontWeight.W_600, color=text_primary,
|
||||
no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
ft.Text(details, size=11, color=text_secondary),
|
||||
], expand=True, tight=True, spacing=2),
|
||||
ft.IconButton(
|
||||
icon=resolve_icon("delete_outline"),
|
||||
icon_color=pal.error if hasattr(pal, "error") else "#e84e3c",
|
||||
icon_size=18,
|
||||
tooltip="Remove",
|
||||
on_click=_remove,
|
||||
),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=SPACE_SM),
|
||||
bgcolor=bg,
|
||||
border=ft.Border.all(1, border_clr),
|
||||
border_radius=RADIUS_SM,
|
||||
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=SPACE_SM),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_start_queue(self, _: ft.ControlEvent) -> None:
|
||||
if not self._state.queued_items:
|
||||
show_snack(self._page, "Queue is empty.", error=True)
|
||||
return
|
||||
# Navigate to dashboard and trigger queue start
|
||||
# This is wired in main.py via the nav controller
|
||||
self._page.pubsub.send_all("start_queue")
|
||||
|
||||
def _on_clear_queue(self, _: ft.ControlEvent) -> None:
|
||||
if not self._state.queued_items:
|
||||
return
|
||||
self._state.queued_items.clear()
|
||||
self._refresh_list()
|
||||
show_snack(self._page, "Queue cleared.")
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Settings view – a categorised, scrollable settings page.
|
||||
|
||||
Groups settings into collapsible cards:
|
||||
- Output (format, save location, chapters)
|
||||
- Text processing (newlines, caps, substitutions, numerals)
|
||||
- Subtitle options
|
||||
- TTS pipeline (provider, GPU, chunking)
|
||||
- Integrations (Audiobookshelf, Calibre OPDS)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..state import AppState
|
||||
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||
from ..utils.helpers import output_format_label, subtitle_format_label, SUPPORTED_EXTENSIONS
|
||||
from ..components import (
|
||||
build_card, build_section_header, labelled_row, show_snack, build_divider,
|
||||
build_primary_button,
|
||||
)
|
||||
from abogen.constants import SUBTITLE_FORMATS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dd(options, value, on_change, **kw):
|
||||
"""Compact dropdown factory."""
|
||||
return ft.Dropdown(
|
||||
options=[ft.dropdown.Option(key=k, text=v) for k, v in options],
|
||||
value=value, on_select=on_change, dense=True,
|
||||
border_radius=RADIUS_SM, expand=True, **kw
|
||||
)
|
||||
|
||||
|
||||
def _sw(value, on_change, label=""):
|
||||
return ft.Switch(value=value, on_change=on_change, label=label)
|
||||
|
||||
|
||||
class SettingsView:
|
||||
"""The full settings panel."""
|
||||
|
||||
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||
self._page = page
|
||||
self._state = state
|
||||
|
||||
def build(self) -> ft.Column:
|
||||
p = self._page
|
||||
s = self._state
|
||||
pal = get_palette(p)
|
||||
|
||||
# ── Output card ──────────────────────────────────────────────
|
||||
format_dd = _dd(
|
||||
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus", "m4b")],
|
||||
s.selected_format,
|
||||
lambda e: self._save("selected_format", e.control.value),
|
||||
)
|
||||
save_dd = _dd(
|
||||
[
|
||||
("Save next to input file", "Save next to input file"),
|
||||
("Save to Desktop", "Save to Desktop"),
|
||||
("Choose output folder", "Choose output folder"),
|
||||
],
|
||||
s.save_option,
|
||||
lambda e: self._save("save_option", e.control.value),
|
||||
)
|
||||
chapters_sw = _sw(s.save_chapters_separately or False,
|
||||
lambda e: self._save("save_chapters_separately", e.control.value))
|
||||
merge_sw = _sw(True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
|
||||
lambda e: self._save("merge_chapters_at_end", e.control.value))
|
||||
sep_fmt_dd = _dd(
|
||||
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus")],
|
||||
s.separate_chapters_format,
|
||||
lambda e: self._save("separate_chapters_format", e.control.value),
|
||||
)
|
||||
epub3_sw = _sw(s.generate_epub3, lambda e: self._save("generate_epub3", e.control.value))
|
||||
|
||||
output_card = build_card(ft.Column([
|
||||
build_section_header("Output", icon="audio_file", page=p),
|
||||
labelled_row("Audio Format", format_dd, page=p),
|
||||
labelled_row("Save Location", save_dd, page=p),
|
||||
build_divider(p),
|
||||
labelled_row("Save Chapters Separately", chapters_sw, page=p),
|
||||
labelled_row("Merge at End", merge_sw, page=p),
|
||||
labelled_row("Chapter Format", sep_fmt_dd, page=p),
|
||||
labelled_row("Generate EPUB3", epub3_sw, page=p),
|
||||
], spacing=SPACE_MD), page=p)
|
||||
|
||||
# ── Text processing card ─────────────────────────────────────
|
||||
newlines_sw = _sw(s.replace_single_newlines,
|
||||
lambda e: self._save("replace_single_newlines", e.control.value))
|
||||
caps_sw = _sw(s.replace_all_caps, lambda e: self._save("replace_all_caps", e.control.value))
|
||||
norm_sw = _sw(s.normalize_chapter_opening_caps,
|
||||
lambda e: self._save("normalize_chapter_opening_caps", e.control.value))
|
||||
numerals_sw = _sw(s.replace_numerals, lambda e: self._save("replace_numerals", e.control.value))
|
||||
punct_sw = _sw(s.fix_nonstandard_punctuation,
|
||||
lambda e: self._save("fix_nonstandard_punctuation", e.control.value))
|
||||
wordsub_sw = _sw(s.word_substitutions_enabled,
|
||||
lambda e: self._save("word_substitutions_enabled", e.control.value))
|
||||
wordsub_tf = ft.TextField(
|
||||
value=s.word_substitutions_list,
|
||||
multiline=True, min_lines=3, max_lines=6,
|
||||
hint_text="word|replacement (one per line)",
|
||||
on_change=lambda e: self._save("word_substitutions_list", e.control.value),
|
||||
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||
)
|
||||
case_sw = _sw(s.case_sensitive_substitutions,
|
||||
lambda e: self._save("case_sensitive_substitutions", e.control.value))
|
||||
spacy_sw = _sw(s.use_spacy_segmentation,
|
||||
lambda e: self._save("use_spacy_segmentation", e.control.value))
|
||||
chunk_dd = _dd(
|
||||
[("paragraph", "Paragraph"), ("sentence", "Sentence")],
|
||||
s.chunk_level,
|
||||
lambda e: self._save("chunk_level", e.control.value),
|
||||
)
|
||||
title_intro_sw = _sw(s.read_title_intro, lambda e: self._save("read_title_intro", e.control.value))
|
||||
outro_sw = _sw(s.read_closing_outro, lambda e: self._save("read_closing_outro", e.control.value))
|
||||
prefix_sw = _sw(s.auto_prefix_chapter_titles,
|
||||
lambda e: self._save("auto_prefix_chapter_titles", e.control.value))
|
||||
|
||||
text_card = build_card(ft.Column([
|
||||
build_section_header("Text Processing", icon="text_fields", page=p),
|
||||
labelled_row("Replace Single Newlines", newlines_sw,
|
||||
tooltip="Replace single newlines with spaces before processing.", page=p),
|
||||
labelled_row("Replace ALL CAPS Words", caps_sw, page=p),
|
||||
labelled_row("Normalize Opening CAPS", norm_sw, page=p),
|
||||
labelled_row("Replace Numerals (spoken)", numerals_sw, page=p),
|
||||
labelled_row("Fix Non-standard Punctuation", punct_sw, page=p),
|
||||
build_divider(p),
|
||||
labelled_row("Word Substitutions", wordsub_sw, page=p),
|
||||
labelled_row("Case Sensitive", case_sw, page=p),
|
||||
ft.Text("Substitution rules (word|replacement, one per line):",
|
||||
size=12, color=pal.text_secondary),
|
||||
wordsub_tf,
|
||||
build_divider(p),
|
||||
build_section_header("Chapter Options", icon="library_books", page=p),
|
||||
labelled_row("Announce Book Title (intro)", title_intro_sw, page=p),
|
||||
labelled_row("Announce Book Title (outro)", outro_sw, page=p),
|
||||
labelled_row("Auto-prefix Chapter Titles", prefix_sw, page=p),
|
||||
labelled_row("Chunk Level", chunk_dd, page=p),
|
||||
labelled_row("Use spaCy Segmentation", spacy_sw, page=p),
|
||||
], spacing=SPACE_MD), page=p)
|
||||
|
||||
# ── Subtitle card ─────────────────────────────────────────────
|
||||
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
|
||||
sub_mode_dd = _dd(
|
||||
[(m, m) for m in sub_modes],
|
||||
s.subtitle_mode,
|
||||
lambda e: self._save("subtitle_mode", e.control.value),
|
||||
)
|
||||
sub_fmt_dd = _dd(
|
||||
[(k, lbl) for k, lbl in SUBTITLE_FORMATS],
|
||||
s.subtitle_format,
|
||||
lambda e: self._save("subtitle_format", e.control.value),
|
||||
)
|
||||
|
||||
def _mk_mw_slider():
|
||||
lbl = ft.Text(str(s.max_subtitle_words), size=12, width=36)
|
||||
sl = ft.Slider(
|
||||
min=1, max=200, value=s.max_subtitle_words, divisions=199, label="{value}",
|
||||
expand=True,
|
||||
on_change=lambda e: (self._save("max_subtitle_words", int(e.control.value)),
|
||||
setattr(lbl, "value", str(int(e.control.value))),
|
||||
self._page.update()),
|
||||
)
|
||||
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
|
||||
|
||||
sub_speed_dd = _dd(
|
||||
[("tts", "TTS duration"), ("silence", "Silence detection")],
|
||||
s.subtitle_speed_method,
|
||||
lambda e: self._save("subtitle_speed_method", e.control.value),
|
||||
)
|
||||
silent_gaps_sw = _sw(s.use_silent_gaps,
|
||||
lambda e: self._save("use_silent_gaps", e.control.value))
|
||||
|
||||
subtitle_card = build_card(ft.Column([
|
||||
build_section_header("Subtitles", icon="subtitles", page=p),
|
||||
labelled_row("Mode", sub_mode_dd, page=p),
|
||||
labelled_row("Format", sub_fmt_dd, page=p),
|
||||
labelled_row("Max Words / Block", _mk_mw_slider(), page=p),
|
||||
labelled_row("Speed Method", sub_speed_dd, page=p),
|
||||
labelled_row("Silent Gaps", silent_gaps_sw, page=p),
|
||||
], spacing=SPACE_MD), page=p)
|
||||
|
||||
# ── Pipeline card ─────────────────────────────────────────────
|
||||
provider_dd = _dd(
|
||||
[("kokoro", "Kokoro (default)"), ("supertonic", "Supertonic")],
|
||||
s.tts_provider,
|
||||
lambda e: self._save("tts_provider", e.control.value),
|
||||
)
|
||||
gpu_sw = _sw(s.use_gpu, lambda e: self._save("use_gpu", e.control.value),
|
||||
label="GPU acceleration (if available)")
|
||||
|
||||
def _mk_steps_slider():
|
||||
lbl = ft.Text(str(s.supertonic_total_steps), size=12, width=28)
|
||||
sl = ft.Slider(
|
||||
min=2, max=15, value=s.supertonic_total_steps, divisions=13,
|
||||
label="{value}", expand=True,
|
||||
on_change=lambda e: (self._save("supertonic_total_steps", int(e.control.value)),
|
||||
setattr(lbl, "value", str(int(e.control.value))),
|
||||
self._page.update()),
|
||||
)
|
||||
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
|
||||
|
||||
thresh_tf = ft.TextField(
|
||||
value=str(s.speaker_analysis_threshold), width=80,
|
||||
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||
on_change=lambda e: self._save_int("speaker_analysis_threshold", e.control.value, 1, 25),
|
||||
)
|
||||
silence_tf = ft.TextField(
|
||||
value=str(s.silence_duration), width=80,
|
||||
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||
on_change=lambda e: self._save_float("silence_duration", e.control.value, 0.0),
|
||||
)
|
||||
intro_tf = ft.TextField(
|
||||
value=str(s.chapter_intro_delay), width=80,
|
||||
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||
on_change=lambda e: self._save_float("chapter_intro_delay", e.control.value, 0.0),
|
||||
)
|
||||
|
||||
pipeline_card = build_card(ft.Column([
|
||||
build_section_header("TTS Pipeline", icon="settings", page=p),
|
||||
labelled_row("Provider", provider_dd, page=p),
|
||||
labelled_row("GPU Acceleration", gpu_sw, page=p),
|
||||
labelled_row("Supertonic Steps", _mk_steps_slider(), page=p),
|
||||
build_divider(p),
|
||||
labelled_row("Speaker Analysis Threshold", thresh_tf, page=p),
|
||||
labelled_row("Silence Between Chapters (s)", silence_tf, page=p),
|
||||
labelled_row("Chapter Intro Delay (s)", intro_tf, page=p),
|
||||
], spacing=SPACE_MD), page=p)
|
||||
|
||||
# ── Integration card (Audiobookshelf) ─────────────────────────
|
||||
abs_enabled_sw = _sw(s.audiobookshelf_enabled,
|
||||
lambda e: self._save("audiobookshelf_enabled", e.control.value))
|
||||
abs_url_tf = ft.TextField(value=s.audiobookshelf_base_url, hint_text="http://abs-server:13378",
|
||||
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||
on_change=lambda e: self._save("audiobookshelf_base_url", e.control.value))
|
||||
abs_token_tf = ft.TextField(value=s.audiobookshelf_api_token, password=True,
|
||||
can_reveal_password=True, expand=True,
|
||||
border_radius=RADIUS_SM, text_size=12,
|
||||
on_change=lambda e: self._save("audiobookshelf_api_token", e.control.value))
|
||||
abs_lib_tf = ft.TextField(value=s.audiobookshelf_library_id, hint_text="Library ID",
|
||||
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||
on_change=lambda e: self._save("audiobookshelf_library_id", e.control.value))
|
||||
abs_auto_sw = _sw(s.audiobookshelf_auto_send,
|
||||
lambda e: self._save("audiobookshelf_auto_send", e.control.value))
|
||||
|
||||
integ_card = build_card(ft.Column([
|
||||
build_section_header("Audiobookshelf Integration",
|
||||
icon="cloud_upload", page=p),
|
||||
labelled_row("Enabled", abs_enabled_sw, page=p),
|
||||
labelled_row("Server URL", abs_url_tf, page=p),
|
||||
labelled_row("API Token", abs_token_tf, page=p),
|
||||
labelled_row("Library ID", abs_lib_tf, page=p),
|
||||
labelled_row("Auto-upload on finish", abs_auto_sw, page=p),
|
||||
], spacing=SPACE_MD), page=p)
|
||||
|
||||
save_btn = build_primary_button(
|
||||
"Save Settings", icon="save",
|
||||
on_click=self._on_save, page=p,
|
||||
)
|
||||
|
||||
return ft.Column([
|
||||
output_card,
|
||||
ft.Container(height=SPACE_MD),
|
||||
text_card,
|
||||
ft.Container(height=SPACE_MD),
|
||||
subtitle_card,
|
||||
ft.Container(height=SPACE_MD),
|
||||
pipeline_card,
|
||||
ft.Container(height=SPACE_MD),
|
||||
integ_card,
|
||||
ft.Container(height=SPACE_LG),
|
||||
save_btn,
|
||||
ft.Container(height=SPACE_LG),
|
||||
], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save(self, attr: str, value) -> None:
|
||||
setattr(self._state, attr, value)
|
||||
|
||||
def _save_int(self, attr: str, raw: str, lo: int, hi: int) -> None:
|
||||
try:
|
||||
v = max(lo, min(hi, int(raw)))
|
||||
setattr(self._state, attr, v)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _save_float(self, attr: str, raw: str, lo: float) -> None:
|
||||
try:
|
||||
v = max(lo, float(raw))
|
||||
setattr(self._state, attr, v)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _on_save(self, _: ft.ControlEvent) -> None:
|
||||
self._state.persist_config()
|
||||
show_snack(self._page, "Settings saved.")
|
||||
@@ -19,7 +19,7 @@ def tracked_hf_hub_download(*args, **kwargs):
|
||||
try:
|
||||
local_kwargs = dict(kwargs)
|
||||
local_kwargs["local_files_only"] = True
|
||||
return hf_hub_download(*args, **local_kwargs)
|
||||
hf_hub_download(*args, **local_kwargs)
|
||||
except Exception:
|
||||
repo_id = kwargs.get("repo_id", "<unknown repo>")
|
||||
filename = kwargs.get("filename", "<unknown file>")
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Mapping, Sequence
|
||||
|
||||
import static_ffmpeg
|
||||
|
||||
from abogen.domain.metadata_helpers import (
|
||||
normalize_metadata_casefold,
|
||||
split_people_field,
|
||||
split_simple_list,
|
||||
first_nonempty,
|
||||
extract_year,
|
||||
normalize_series_sequence,
|
||||
build_audiobookshelf_metadata as _build_abs_metadata,
|
||||
load_audiobookshelf_chapters as _load_abs_chapters,
|
||||
_SERIES_SEQUENCE_TAG_KEYS,
|
||||
)
|
||||
from abogen.epub3.exporter import build_epub3_package
|
||||
from abogen.integrations.audiobookshelf import (
|
||||
AudiobookshelfClient,
|
||||
AudiobookshelfConfig,
|
||||
AudiobookshelfUploadError,
|
||||
)
|
||||
from abogen.utils import create_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportConfig:
|
||||
"""Configuration for export operations."""
|
||||
ffmpeg_path: str = "ffmpeg"
|
||||
verify_ssl: bool = True
|
||||
|
||||
|
||||
class ExportService:
|
||||
"""Unified service for audiobook exports (M4B, FFMETADATA, EPUB3, Audiobookshelf)."""
|
||||
|
||||
def __init__(self, config: Optional[ExportConfig] = None):
|
||||
self.config = config or ExportConfig()
|
||||
static_ffmpeg.add_paths()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# FFMETADATA
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def render_ffmetadata(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Render FFMETADATA content."""
|
||||
lines = [";FFMETADATA1"]
|
||||
|
||||
for key, value in (metadata or {}).items():
|
||||
if value is None:
|
||||
continue
|
||||
key_str = str(key).strip()
|
||||
if not key_str:
|
||||
continue
|
||||
lines.append(f"{key_str}={self._escape_ffmetadata_value(value)}")
|
||||
|
||||
for chapter in chapters or []:
|
||||
start = chapter.get("start")
|
||||
end = chapter.get("end")
|
||||
if start is None or end is None:
|
||||
continue
|
||||
try:
|
||||
start_ms = max(0, int(round(float(start) * 1000)))
|
||||
end_ms = int(round(float(end) * 1000))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end_ms <= start_ms:
|
||||
end_ms = start_ms + 1
|
||||
lines.append("[CHAPTER]")
|
||||
lines.append("TIMEBASE=1/1000")
|
||||
lines.append(f"START={start_ms}")
|
||||
lines.append(f"END={end_ms}")
|
||||
title = chapter.get("title")
|
||||
if title:
|
||||
lines.append(f"title={self._escape_ffmetadata_value(title)}")
|
||||
voice = chapter.get("voice")
|
||||
if voice:
|
||||
lines.append(f"voice={self._escape_ffmetadata_value(voice)}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _escape_ffmetadata_value(value: Any) -> str:
|
||||
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
|
||||
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
|
||||
return escaped
|
||||
|
||||
def write_ffmetadata_file(
|
||||
self,
|
||||
audio_path: Path,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
) -> Optional[Path]:
|
||||
"""Write FFMETADATA file to temp location."""
|
||||
content = self.render_ffmetadata(metadata, chapters)
|
||||
if content.strip() == ";FFMETADATA1":
|
||||
return None
|
||||
|
||||
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
suffix=".ffmeta",
|
||||
delete=False,
|
||||
dir=str(directory),
|
||||
) as handle:
|
||||
handle.write(content)
|
||||
return Path(handle.name)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# M4B Export
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def embed_m4b_metadata(
|
||||
self,
|
||||
audio_path: Path,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
cover_path: Optional[Path] = None,
|
||||
cover_mime: Optional[str] = None,
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> None:
|
||||
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
|
||||
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||
|
||||
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
||||
|
||||
cmd = ["ffmpeg", "-y", "-i", str(audio_path)]
|
||||
|
||||
if ffmetadata_path:
|
||||
cmd.extend(["-f", "ffmetadata", "-i", str(ffmetadata_path)])
|
||||
|
||||
if cover_path and cover_path.exists():
|
||||
cmd.extend(["-i", str(cover_path)])
|
||||
cmd.extend(["-map", "0:a"])
|
||||
cmd.extend(["-map", "1:v:0", "-c:v:0", "mjpeg", "-disposition:v:0", "attached_pic"])
|
||||
if cover_mime:
|
||||
cmd.extend(["-metadata:s:v:0", f"mimetype={cover_mime}"])
|
||||
cmd.extend(["-metadata:s:v:0", "title=Cover Art"])
|
||||
else:
|
||||
cmd.extend(["-map", "0:a"])
|
||||
|
||||
cmd.extend(["-c:a", "copy"])
|
||||
|
||||
if ffmetadata_path:
|
||||
cmd.extend(["-map_metadata", "1", "-map_chapters", "1"])
|
||||
else:
|
||||
cmd.extend(["-map_metadata", "0"])
|
||||
|
||||
if metadata_args:
|
||||
cmd.extend(metadata_args)
|
||||
|
||||
cmd.extend(["-movflags", "+faststart+use_metadata_tags"])
|
||||
|
||||
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
|
||||
if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}:
|
||||
cmd.extend(["-f", "mp4"])
|
||||
cmd.append(str(temp_output))
|
||||
|
||||
if log_callback:
|
||||
log_callback("Embedding metadata into M4B output")
|
||||
|
||||
process = create_process(cmd, text=True)
|
||||
return_code = process.wait()
|
||||
|
||||
if ffmetadata_path and ffmetadata_path.exists():
|
||||
try:
|
||||
ffmetadata_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if return_code != 0:
|
||||
if temp_output.exists():
|
||||
temp_output.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
|
||||
|
||||
temp_output.replace(audio_path)
|
||||
|
||||
if log_callback:
|
||||
log_callback("Embedded metadata and chapters into M4B output", "info")
|
||||
|
||||
# Apply chapters via Mutagen for better compatibility
|
||||
self._apply_m4b_chapters_mutagen(audio_path, chapters, log_callback)
|
||||
|
||||
@staticmethod
|
||||
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
|
||||
args = []
|
||||
for key, value in (metadata or {}).items():
|
||||
if value in (None, ""):
|
||||
continue
|
||||
key_str = str(key).strip()
|
||||
if not key_str:
|
||||
continue
|
||||
normalized_key = key_str.lower()
|
||||
if normalized_key == "year":
|
||||
ffmpeg_key = "date"
|
||||
else:
|
||||
ffmpeg_key = key_str
|
||||
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
||||
return args
|
||||
|
||||
def _apply_m4b_chapters_mutagen(
|
||||
self,
|
||||
audio_path: Path,
|
||||
chapters: List[Dict[str, Any]],
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> bool:
|
||||
"""Apply chapter atoms using Mutagen."""
|
||||
if not chapters:
|
||||
return False
|
||||
|
||||
try:
|
||||
from fractions import Fraction
|
||||
from mutagen.mp4 import MP4, MP4Chapter
|
||||
except ImportError:
|
||||
if log_callback:
|
||||
log_callback("Unable to write MP4 chapter atoms because mutagen is not installed.", "warning")
|
||||
return False
|
||||
|
||||
try:
|
||||
mp4 = MP4(str(audio_path))
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Failed to open m4b for chapter embedding: {exc}", "warning")
|
||||
return False
|
||||
|
||||
chapter_objects = []
|
||||
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||
start_raw = entry.get("start")
|
||||
if start_raw is None:
|
||||
continue
|
||||
try:
|
||||
start_seconds = max(0.0, float(start_raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
title_value = entry.get("title")
|
||||
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||
|
||||
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||
|
||||
end_raw = entry.get("end")
|
||||
if end_raw is not None:
|
||||
try:
|
||||
end_seconds = float(end_raw)
|
||||
except (TypeError, ValueError):
|
||||
end_seconds = None
|
||||
if end_seconds is not None and end_seconds > start_seconds:
|
||||
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||
|
||||
chapter_objects.append(chapter_atom)
|
||||
|
||||
if not chapter_objects:
|
||||
return False
|
||||
|
||||
try:
|
||||
mp4.chapters = chapter_objects
|
||||
mp4.save()
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Failed to persist MP4 chapter atoms: {exc}", "warning")
|
||||
return False
|
||||
|
||||
if log_callback:
|
||||
log_callback(f"Applied {len(chapter_objects)} chapter markers via mutagen", "info")
|
||||
return True
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# EPUB3 Export
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def export_epub3(
|
||||
self,
|
||||
output_path: Path,
|
||||
book_id: str,
|
||||
extraction: Any, # ExtractionResult
|
||||
metadata_tags: Dict[str, Any],
|
||||
chapter_markers: Sequence[Dict[str, Any]],
|
||||
chunk_markers: Sequence[Dict[str, Any]],
|
||||
chunks: Iterable[Dict[str, Any]],
|
||||
audio_path: Path,
|
||||
speaker_mode: str = "single",
|
||||
cover_path: Optional[Path] = None,
|
||||
cover_mime: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Export EPUB3 with media overlays."""
|
||||
return build_epub3_package(
|
||||
output_path=output_path,
|
||||
book_id=book_id,
|
||||
extraction=extraction,
|
||||
metadata_tags=metadata_tags,
|
||||
chapter_markers=chapter_markers,
|
||||
chunk_markers=chunk_markers,
|
||||
chunks=chunks,
|
||||
audio_path=audio_path,
|
||||
speaker_mode=speaker_mode,
|
||||
cover_image_path=cover_path,
|
||||
cover_image_mime=cover_mime,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Audiobookshelf Integration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
|
||||
"""Build Audiobookshelf metadata from job."""
|
||||
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
|
||||
return _build_abs_metadata(
|
||||
getattr(job, "metadata_tags", {}),
|
||||
language=getattr(job, "language", "") or "",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
def load_audiobookshelf_chapters(self, job: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Load chapters from job artifacts for Audiobookshelf."""
|
||||
metadata_ref = job.result.artifacts.get("metadata") if getattr(job, "result", None) else None
|
||||
if not metadata_ref:
|
||||
return None
|
||||
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
||||
return _load_abs_chapters(metadata_path)
|
||||
|
||||
def upload_audiobookshelf(
|
||||
self,
|
||||
job: Any,
|
||||
audio_path: Path,
|
||||
subtitle_paths: List[Path],
|
||||
chapters: List[Dict[str, Any]],
|
||||
metadata: Dict[str, Any],
|
||||
cover_path: Optional[Path] = None,
|
||||
config: Optional[AudiobookshelfConfig] = None,
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> None:
|
||||
"""Upload to Audiobookshelf."""
|
||||
if config is None:
|
||||
cfg = getattr(job, "_abs_config", None)
|
||||
if cfg is None:
|
||||
from abogen.utils import load_config
|
||||
global_cfg = load_config() or {}
|
||||
abs_cfg = global_cfg.get("audiobookshelf")
|
||||
if isinstance(abs_cfg, Mapping):
|
||||
config = AudiobookshelfConfig(
|
||||
base_url=str(abs_cfg.get("base_url") or "").strip(),
|
||||
api_token=str(abs_cfg.get("api_token") or "").strip(),
|
||||
library_id=str(abs_cfg.get("library_id") or "").strip(),
|
||||
collection_id=(str(abs_cfg.get("collection_id") or "").strip() or None),
|
||||
folder_id=str(abs_cfg.get("folder_id") or "").strip(),
|
||||
verify_ssl=self._coerce_bool(abs_cfg.get("verify_ssl"), True),
|
||||
send_cover=self._coerce_bool(abs_cfg.get("send_cover"), True),
|
||||
send_chapters=self._coerce_bool(abs_cfg.get("send_chapters"), True),
|
||||
send_subtitles=self._coerce_bool(abs_cfg.get("send_subtitles"), False),
|
||||
timeout=float(abs_cfg.get("timeout", 3600.0)),
|
||||
)
|
||||
else:
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: not configured", "warning")
|
||||
return
|
||||
|
||||
if not config.base_url or not config.api_token or not config.library_id:
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning")
|
||||
return
|
||||
if not config.folder_id:
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning")
|
||||
return
|
||||
|
||||
if not audio_path.exists():
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: audio output not found", "warning")
|
||||
return
|
||||
|
||||
existing_subtitles = [p for p in subtitle_paths if p.exists()] if config.send_subtitles else None
|
||||
chapters_to_send = chapters if config.send_chapters else None
|
||||
|
||||
client = AudiobookshelfClient(config)
|
||||
|
||||
display_title = metadata.get("title") or audio_path.stem
|
||||
try:
|
||||
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||
except AudiobookshelfUploadError as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Audiobookshelf lookup failed: {exc}", "error")
|
||||
return
|
||||
|
||||
if existing_items:
|
||||
if log_callback:
|
||||
log_callback(f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.", "info")
|
||||
try:
|
||||
client.delete_items(existing_items)
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Failed to remove existing item(s): {exc}", "warning")
|
||||
|
||||
cover_to_send = cover_path
|
||||
if config.send_cover and cover_to_send:
|
||||
if isinstance(cover_to_send, str):
|
||||
cover_to_send = Path(cover_to_send)
|
||||
if not cover_to_send.exists():
|
||||
cover_to_send = None
|
||||
|
||||
client.upload_audiobook(
|
||||
audio_path,
|
||||
metadata=metadata,
|
||||
cover_path=cover_to_send,
|
||||
chapters=chapters_to_send,
|
||||
subtitles=existing_subtitles,
|
||||
)
|
||||
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload queued.", "info")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
if value is None:
|
||||
return default
|
||||
return bool(value)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExportConfig",
|
||||
"ExportService",
|
||||
]
|
||||
@@ -1,357 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, TextIO
|
||||
|
||||
from abogen.domain.enums import SubtitleFormat, SubtitleMode
|
||||
from abogen.subtitle_utils import clean_subtitle_text
|
||||
|
||||
|
||||
class SubtitleAlignment(Enum):
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
NARROW = "narrow"
|
||||
CENTER_NARROW = "center_narrow"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleConfig:
|
||||
"""Configuration for subtitle writer."""
|
||||
format: SubtitleFormat
|
||||
mode: SubtitleMode
|
||||
alignment: SubtitleAlignment = SubtitleAlignment.LEFT
|
||||
max_words: int = 50
|
||||
highlight_color: str = "&H00FFFF00" # ASS highlight color
|
||||
|
||||
|
||||
class SubtitleWriter(ABC):
|
||||
"""Abstract base class for subtitle writers."""
|
||||
|
||||
def __init__(self, path: Path, config: SubtitleConfig):
|
||||
self.path = path
|
||||
self.config = config
|
||||
self._file: Optional[TextIO] = None
|
||||
self._index = 0
|
||||
self._opened = False
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the subtitle file and write header."""
|
||||
if self._opened:
|
||||
return
|
||||
self._file = open(self.path, "w", encoding="utf-8", errors="replace")
|
||||
self._write_header()
|
||||
self._opened = True
|
||||
|
||||
@abstractmethod
|
||||
def _write_header(self) -> None:
|
||||
pass
|
||||
|
||||
def write_entry(
|
||||
self,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Write a subtitle entry."""
|
||||
if not self._opened:
|
||||
self.open()
|
||||
|
||||
text = clean_subtitle_text(text)
|
||||
if not text:
|
||||
return
|
||||
|
||||
self._index += 1
|
||||
self._write_entry(self._index, start, end, text, voice)
|
||||
|
||||
@abstractmethod
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the subtitle file."""
|
||||
if self._file:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
self._opened = False
|
||||
|
||||
def __enter__(self) -> "SubtitleWriter":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class SrtWriter(SubtitleWriter):
|
||||
"""SRT subtitle writer."""
|
||||
|
||||
def _write_header(self) -> None:
|
||||
pass # SRT has no header
|
||||
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
start_str = self._format_time(start)
|
||||
end_str = self._format_time(end)
|
||||
|
||||
if voice:
|
||||
text = f"[{voice}] {text}"
|
||||
|
||||
self._file.write(f"{index}\n")
|
||||
self._file.write(f"{start_str} --> {end_str}\n")
|
||||
self._file.write(f"{text}\n\n")
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
millis = int((seconds - int(seconds)) * 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||
|
||||
|
||||
class VttWriter(SubtitleWriter):
|
||||
"""WebVTT subtitle writer."""
|
||||
|
||||
def _write_header(self) -> None:
|
||||
self._file.write("WEBVTT\n\n")
|
||||
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
start_str = self._format_time(start)
|
||||
end_str = self._format_time(end)
|
||||
|
||||
if voice:
|
||||
text = f"[{voice}] {text}"
|
||||
|
||||
self._file.write(f"{index}\n")
|
||||
self._file.write(f"{start_str} --> {end_str}\n")
|
||||
self._file.write(f"{text}\n\n")
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ".")
|
||||
|
||||
|
||||
class AssWriter(SubtitleWriter):
|
||||
"""ASS subtitle writer with karaoke highlighting support."""
|
||||
|
||||
def __init__(self, path: Path, config: SubtitleConfig):
|
||||
super().__init__(path, config)
|
||||
self._is_centered = config.alignment in (SubtitleAlignment.CENTER, SubtitleAlignment.CENTER_NARROW)
|
||||
self._is_narrow = config.alignment in (SubtitleAlignment.NARROW, SubtitleAlignment.CENTER_NARROW)
|
||||
|
||||
def _write_header(self) -> None:
|
||||
margin = "90" if self._is_narrow else "10"
|
||||
alignment = "5" if self._is_centered else "2"
|
||||
|
||||
self._file.write("[Script Info]\n")
|
||||
self._file.write("Title: Generated by Abogen\n")
|
||||
self._file.write("ScriptType: v4.00+\n\n")
|
||||
|
||||
# Styles
|
||||
self._file.write("[V4+ Styles]\n")
|
||||
self._file.write(
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
|
||||
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
|
||||
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
|
||||
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||
)
|
||||
|
||||
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||
# Karaoke style with highlighting
|
||||
self._file.write(
|
||||
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
|
||||
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n"
|
||||
)
|
||||
self._file.write(
|
||||
f"Style: Highlight,Arial,24,&H0000FFFF,&H00808080,&H00000000,&H00404040,"
|
||||
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
|
||||
)
|
||||
else:
|
||||
self._file.write(
|
||||
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
|
||||
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
|
||||
)
|
||||
|
||||
self._file.write("[Events]\n")
|
||||
self._file.write(
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
)
|
||||
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
start_str = self._format_time(start)
|
||||
end_str = self._format_time(end)
|
||||
|
||||
if voice:
|
||||
text = f"[{voice}] {text}"
|
||||
|
||||
style = "Default"
|
||||
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||
# Add karaoke tags for highlighting
|
||||
text = self._add_karaoke_tags(text)
|
||||
style = "Highlight"
|
||||
|
||||
alignment_tag = r"{\an5}" if self._is_centered else ""
|
||||
self._file.write(
|
||||
f"Dialogue: 0,{start_str},{end_str},{style},,0,0,0,,{alignment_tag}{text}\n"
|
||||
)
|
||||
|
||||
def _add_karaoke_tags(self, text: str) -> str:
|
||||
"""Add karaoke highlighting tags to text."""
|
||||
# Simple word-level karaoke timing
|
||||
words = text.split()
|
||||
if not words:
|
||||
return text
|
||||
|
||||
# This is a simplified version - real karaoke needs per-word timing
|
||||
# For now, just return the text with the highlight color
|
||||
return r"{\k100}" + r"{\k100}".join(words) + r"{\k0}"
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||
|
||||
|
||||
def create_subtitle_writer(
|
||||
path: Path,
|
||||
format: str,
|
||||
mode: str,
|
||||
alignment: str = "left",
|
||||
max_words: int = 50,
|
||||
) -> SubtitleWriter:
|
||||
"""Factory function to create subtitle writer."""
|
||||
fmt = SubtitleFormat(format.lower())
|
||||
mode = SubtitleMode(mode)
|
||||
align = SubtitleAlignment(alignment.lower())
|
||||
|
||||
config = SubtitleConfig(
|
||||
format=fmt,
|
||||
mode=mode,
|
||||
alignment=align,
|
||||
max_words=max_words,
|
||||
)
|
||||
|
||||
if fmt == SubtitleFormat.SRT:
|
||||
return SrtWriter(path, config)
|
||||
elif fmt == SubtitleFormat.VTT:
|
||||
return VttWriter(path, config)
|
||||
elif fmt == SubtitleFormat.ASS:
|
||||
return AssWriter(path, config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported subtitle format: {format}")
|
||||
|
||||
|
||||
def resolve_subtitle_format(
|
||||
subtitle_format: str | None,
|
||||
subtitle_mode: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve a subtitle_format setting string to (file_extension, alignment).
|
||||
|
||||
Handles the PyQt convention where format strings encode alignment
|
||||
(e.g. ``"ass_centered_narrow"`` → extension ``"ass"``, alignment
|
||||
``"center_narrow"``).
|
||||
|
||||
Also enforces that ``"Sentence + Highlighting"`` mode requires ASS.
|
||||
|
||||
Returns:
|
||||
Tuple of (file_extension, alignment) suitable for
|
||||
:func:`create_subtitle_writer`.
|
||||
"""
|
||||
fmt = (subtitle_format or "srt").lower()
|
||||
|
||||
if subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
|
||||
fmt = "ass"
|
||||
|
||||
if "ass" in fmt:
|
||||
extension = "ass"
|
||||
if "centered_narrow" in fmt:
|
||||
alignment = "center_narrow"
|
||||
elif "centered" in fmt:
|
||||
alignment = "center"
|
||||
elif "narrow" in fmt:
|
||||
alignment = "narrow"
|
||||
else:
|
||||
alignment = "left"
|
||||
else:
|
||||
extension = fmt if fmt in ("srt", "vtt") else "srt"
|
||||
alignment = "left"
|
||||
|
||||
return extension, alignment
|
||||
|
||||
|
||||
def make_subtitle_writer(
|
||||
audio_path: Path,
|
||||
subtitle_format: str | None,
|
||||
subtitle_mode: str,
|
||||
max_words: int = 50,
|
||||
) -> SubtitleWriter | None:
|
||||
"""Convenience: resolve format and create a writer, or return None if disabled.
|
||||
|
||||
Returns ``None`` when ``subtitle_mode`` is ``"Disabled"`` or the
|
||||
format is unsupported.
|
||||
"""
|
||||
if subtitle_mode == "Disabled":
|
||||
return None
|
||||
|
||||
extension, alignment = resolve_subtitle_format(subtitle_format, subtitle_mode)
|
||||
try:
|
||||
return create_subtitle_writer(
|
||||
audio_path.with_suffix(f".{extension}"),
|
||||
extension,
|
||||
subtitle_mode,
|
||||
alignment=alignment,
|
||||
max_words=max_words,
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SubtitleFormat",
|
||||
"SubtitleMode",
|
||||
"SubtitleAlignment",
|
||||
"SubtitleConfig",
|
||||
"SubtitleWriter",
|
||||
"SrtWriter",
|
||||
"VttWriter",
|
||||
"AssWriter",
|
||||
"create_subtitle_writer",
|
||||
"resolve_subtitle_format",
|
||||
"make_subtitle_writer",
|
||||
]
|
||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import re
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -10,8 +12,6 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from abogen.domain.metadata_helpers import normalize_series_sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -641,7 +641,40 @@ class AudiobookshelfClient:
|
||||
for key in preferred_keys:
|
||||
if key not in metadata:
|
||||
continue
|
||||
normalized = normalize_series_sequence(metadata.get(key))
|
||||
normalized = AudiobookshelfClient._normalize_series_sequence(metadata.get(key))
|
||||
if normalized:
|
||||
return normalized
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_series_sequence(raw: Any) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
|
||||
if isinstance(raw, (int, float)):
|
||||
if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)):
|
||||
return ""
|
||||
text = str(raw)
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
candidate = text.replace(",", ".")
|
||||
match = re.search(r"\d+(?:\.\d+)?", candidate)
|
||||
if not match:
|
||||
return ""
|
||||
|
||||
normalized = match.group(0)
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
if not normalized:
|
||||
normalized = "0"
|
||||
return normalized
|
||||
|
||||
try:
|
||||
return str(int(normalized))
|
||||
except ValueError:
|
||||
cleaned = normalized.lstrip("0")
|
||||
return cleaned or "0"
|
||||
|
||||
+15
-5
@@ -2,14 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import sys
|
||||
|
||||
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
|
||||
from abogen.utils import load_config
|
||||
from abogen.utils import load_config, prevent_sleep_end
|
||||
from abogen.webui.app import main as _run_web_ui
|
||||
|
||||
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults).
|
||||
@@ -28,6 +27,17 @@ os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0")
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
||||
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
def _cleanup_sleep(signum, _frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Launch the Flask-based web UI."""
|
||||
|
||||
@@ -21,8 +21,7 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from abogen.constants import COLORS
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||
from abogen.spacy_utils import SPACY_MODELS
|
||||
import abogen.hf_tracker
|
||||
|
||||
@@ -115,7 +114,7 @@ class PreDownloadWorker(QThread):
|
||||
self._voices_success = False
|
||||
return
|
||||
|
||||
voice_list = get_voices("kokoro")
|
||||
voice_list = VOICES_INTERNAL
|
||||
for idx, voice in enumerate(voice_list, start=1):
|
||||
if self._cancelled:
|
||||
self._voices_success = False
|
||||
@@ -463,14 +462,14 @@ class PreDownloadDialog(QDialog):
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
for voice in get_voices("kokoro"):
|
||||
for voice in VOICES_INTERNAL:
|
||||
if not try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||
):
|
||||
missing.append(voice)
|
||||
except Exception:
|
||||
# If HF missing, report all as missing
|
||||
return False, list(get_voices("kokoro"))
|
||||
return False, list(VOICES_INTERNAL)
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
def _check_kokoro_model(self) -> bool:
|
||||
|
||||
+207
-20
@@ -29,12 +29,6 @@ from abogen.utils import (
|
||||
get_resource_path,
|
||||
)
|
||||
from abogen.book_parser import get_book_parser
|
||||
from abogen.domain.metadata_extraction import (
|
||||
extract_book_metadata_epub,
|
||||
extract_book_metadata_pdf,
|
||||
extract_book_metadata_markdown,
|
||||
format_metadata_tags,
|
||||
)
|
||||
|
||||
from abogen.subtitle_utils import (
|
||||
clean_text,
|
||||
@@ -954,14 +948,169 @@ class HandlerDialog(QDialog):
|
||||
self.previewEdit.setHtml(html_content)
|
||||
|
||||
def _extract_book_metadata(self):
|
||||
metadata = {
|
||||
"title": None,
|
||||
"authors": [],
|
||||
"description": None,
|
||||
"cover_image": None,
|
||||
"publisher": None,
|
||||
"publication_year": None,
|
||||
}
|
||||
|
||||
if self.parser.file_type == "epub":
|
||||
return extract_book_metadata_epub(self.book)
|
||||
try:
|
||||
title_items = self.book.get_metadata("DC", "title")
|
||||
if title_items and len(title_items) > 0:
|
||||
metadata["title"] = title_items[0][0]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting title metadata: {e}")
|
||||
|
||||
try:
|
||||
author_items = self.book.get_metadata("DC", "creator")
|
||||
if author_items:
|
||||
metadata["authors"] = [
|
||||
author[0] for author in author_items if len(author) > 0
|
||||
]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting author metadata: {e}")
|
||||
|
||||
try:
|
||||
desc_items = self.book.get_metadata("DC", "description")
|
||||
if desc_items and len(desc_items) > 0:
|
||||
metadata["description"] = desc_items[0][0]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting description metadata: {e}")
|
||||
|
||||
try:
|
||||
publisher_items = self.book.get_metadata("DC", "publisher")
|
||||
if publisher_items and len(publisher_items) > 0:
|
||||
metadata["publisher"] = publisher_items[0][0]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting publisher metadata: {e}")
|
||||
|
||||
# Try to extract publication year
|
||||
try:
|
||||
date_items = self.book.get_metadata("DC", "date")
|
||||
if date_items and len(date_items) > 0:
|
||||
date_str = date_items[0][0]
|
||||
# Try to extract just the year from the date string
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(0)
|
||||
else:
|
||||
metadata["publication_year"] = date_str
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting publication date metadata: {e}")
|
||||
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
|
||||
metadata["cover_image"] = item.get_content()
|
||||
break
|
||||
|
||||
if not metadata["cover_image"]:
|
||||
for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
|
||||
if "cover" in item.get_name().lower():
|
||||
metadata["cover_image"] = item.get_content()
|
||||
break
|
||||
elif self.parser.file_type == "markdown":
|
||||
return extract_book_metadata_markdown(
|
||||
self.markdown_text, self.markdown_toc
|
||||
)
|
||||
# Extract metadata from markdown frontmatter or first heading
|
||||
if self.markdown_text:
|
||||
# Try to extract YAML frontmatter
|
||||
frontmatter_match = re.match(
|
||||
r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL
|
||||
)
|
||||
if frontmatter_match:
|
||||
try:
|
||||
frontmatter = frontmatter_match.group(1)
|
||||
# Simple YAML-like parsing for common fields
|
||||
title_match = re.search(
|
||||
r"^title:\s*(.+)$",
|
||||
frontmatter,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
if title_match:
|
||||
metadata["title"] = (
|
||||
title_match.group(1).strip().strip("\"'")
|
||||
)
|
||||
|
||||
author_match = re.search(
|
||||
r"^author:\s*(.+)$",
|
||||
frontmatter,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
if author_match:
|
||||
metadata["authors"] = [
|
||||
author_match.group(1).strip().strip("\"'")
|
||||
]
|
||||
|
||||
desc_match = re.search(
|
||||
r"^description:\s*(.+)$",
|
||||
frontmatter,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
if desc_match:
|
||||
metadata["description"] = (
|
||||
desc_match.group(1).strip().strip("\"'")
|
||||
)
|
||||
|
||||
date_match = re.search(
|
||||
r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE
|
||||
)
|
||||
if date_match:
|
||||
date_str = date_match.group(1).strip().strip("\"'")
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(0)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error parsing markdown frontmatter: {e}")
|
||||
|
||||
# Fallback: use first H1 header as title if no frontmatter title
|
||||
if not metadata["title"] and self.markdown_toc:
|
||||
# Find the first level 1 header
|
||||
first_h1 = next(
|
||||
(h for h in self.markdown_toc if h["level"] == 1), None
|
||||
)
|
||||
if first_h1:
|
||||
metadata["title"] = first_h1["name"]
|
||||
else:
|
||||
return extract_book_metadata_pdf(self.pdf_doc)
|
||||
pdf_info = self.pdf_doc.metadata
|
||||
if pdf_info:
|
||||
metadata["title"] = pdf_info.get("title", None)
|
||||
|
||||
author = pdf_info.get("author", None)
|
||||
if author:
|
||||
metadata["authors"] = [author]
|
||||
|
||||
metadata["description"] = pdf_info.get("subject", None)
|
||||
|
||||
keywords = pdf_info.get("keywords", None)
|
||||
if keywords:
|
||||
if metadata["description"]:
|
||||
metadata["description"] += f"\n\nKeywords: {keywords}"
|
||||
else:
|
||||
metadata["description"] = f"Keywords: {keywords}"
|
||||
|
||||
metadata["publisher"] = pdf_info.get("creator", None)
|
||||
|
||||
# Try to extract publication date from PDF metadata
|
||||
if "creationDate" in pdf_info:
|
||||
date_str = pdf_info["creationDate"]
|
||||
year_match = re.search(r"D:(\d{4})", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(1)
|
||||
elif "modDate" in pdf_info:
|
||||
date_str = pdf_info["modDate"]
|
||||
year_match = re.search(r"D:(\d{4})", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(1)
|
||||
|
||||
if len(self.pdf_doc) > 0:
|
||||
try:
|
||||
pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||
metadata["cover_image"] = pix.tobytes("png")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metadata
|
||||
|
||||
def get_selected_text(self):
|
||||
# If a background loader thread is running, wait for it to finish to
|
||||
@@ -987,21 +1136,59 @@ class HandlerDialog(QDialog):
|
||||
|
||||
def _format_metadata_tags(self):
|
||||
"""Format metadata tags for insertion at the beginning of the text"""
|
||||
import datetime
|
||||
from abogen.utils import get_user_cache_path
|
||||
|
||||
metadata = self.book_metadata
|
||||
filename = os.path.splitext(os.path.basename(self.book_path))[0]
|
||||
chapter_count = len(self.checked_chapters)
|
||||
cache_dir = get_user_cache_path()
|
||||
current_year = str(datetime.datetime.now().year)
|
||||
|
||||
return format_metadata_tags(
|
||||
self.book_metadata,
|
||||
filename,
|
||||
chapter_count,
|
||||
self.parser.file_type,
|
||||
cover_bytes=self.book_metadata.get("cover_image"),
|
||||
cache_dir=cache_dir,
|
||||
# Get values with fallbacks
|
||||
title = metadata.get("title") or filename
|
||||
authors = metadata.get("authors") or ["Unknown"]
|
||||
authors_text = ", ".join(authors)
|
||||
album_artist = authors_text or "Unknown"
|
||||
year = (
|
||||
metadata.get("publication_year") or current_year
|
||||
) # Use publication year if available
|
||||
|
||||
# Count chapters/pages
|
||||
total_chapters = len(self.checked_chapters)
|
||||
chapter_text = (
|
||||
f"{total_chapters} {'Chapters' if self.parser.file_type == 'epub' else 'Pages'}"
|
||||
)
|
||||
|
||||
# Handle cover image
|
||||
cover_tag = ""
|
||||
if metadata.get("cover_image"):
|
||||
try:
|
||||
import uuid
|
||||
|
||||
cache_dir = get_user_cache_path()
|
||||
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
|
||||
cover_path = os.path.normpath(cover_path)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(metadata["cover_image"])
|
||||
cover_tag = f"<<METADATA_COVER_PATH:{cover_path}>>"
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to save cover image: {e}")
|
||||
|
||||
# Format metadata tags
|
||||
metadata_tags = [
|
||||
f"<<METADATA_TITLE:{title}>>",
|
||||
f"<<METADATA_ARTIST:{authors_text}>>",
|
||||
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
|
||||
f"<<METADATA_YEAR:{year}>>",
|
||||
f"<<METADATA_ALBUM_ARTIST:{album_artist}>>",
|
||||
f"<<METADATA_COMPOSER:Narrator>>",
|
||||
f"<<METADATA_GENRE:Audiobook>>",
|
||||
]
|
||||
|
||||
if cover_tag:
|
||||
metadata_tags.append(cover_tag)
|
||||
|
||||
return "\n".join(metadata_tags)
|
||||
|
||||
def _get_markdown_selected_text(self):
|
||||
"""Get selected text from markdown chapters"""
|
||||
all_checked_identifiers = set()
|
||||
|
||||
+1486
-469
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
||||
"""PyQt adapter: ConversionThread -> ConversionRequest.
|
||||
|
||||
Converts a PyQt ConversionThread into a ConversionRequest that the application layer can process.
|
||||
This adapter is the bridge between the PyQt layer and the application/domain layer.
|
||||
|
||||
The adapter is responsible for:
|
||||
- Mapping ConversionThread fields to ConversionRequest fields
|
||||
- Handling UI-specific state (signals, dialogs, cancellation)
|
||||
- Providing PipelineProvider and VoiceResolver implementations
|
||||
|
||||
Subtitle file/timestamp special paths remain in ConversionThread.run() early return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice
|
||||
|
||||
|
||||
def build_conversion_request_from_thread(thread: Any) -> ConversionRequest:
|
||||
"""Convert a PyQt ConversionThread into a ConversionRequest.
|
||||
|
||||
This is the primary function that maps thread fields to ConversionRequest.
|
||||
All fields are copied — the request is independent of the thread.
|
||||
|
||||
Args:
|
||||
thread: PyQt ConversionThread instance
|
||||
|
||||
Returns:
|
||||
ConversionRequest with all thread data mapped
|
||||
"""
|
||||
# Determine source path
|
||||
source_path = None
|
||||
is_direct_text = getattr(thread, "is_direct_text", False)
|
||||
if not is_direct_text and thread.file_name:
|
||||
source_path = Path(thread.file_name)
|
||||
|
||||
# Determine original filename
|
||||
original_filename = ""
|
||||
if getattr(thread, "from_queue", False):
|
||||
base_path = getattr(thread, "save_base_path", None) or thread.file_name
|
||||
else:
|
||||
base_path = getattr(thread, "display_path", None) or thread.file_name
|
||||
|
||||
if base_path:
|
||||
original_filename = os.path.basename(base_path)
|
||||
|
||||
# Determine output folder
|
||||
output_folder = None
|
||||
if thread.output_folder:
|
||||
output_folder = Path(thread.output_folder)
|
||||
|
||||
return ConversionRequest(
|
||||
# Source
|
||||
source_path=source_path,
|
||||
direct_text=thread.file_name if is_direct_text else None,
|
||||
original_filename=original_filename,
|
||||
# TTS Settings
|
||||
language=thread.lang_code,
|
||||
tts_provider="kokoro", # PyQt uses Kokoro by default
|
||||
voice=thread.voice,
|
||||
voice_profile=getattr(thread, "voice_profile", None),
|
||||
speed=thread.speed,
|
||||
use_gpu=thread.use_gpu,
|
||||
supertonic_total_steps=getattr(thread, "supertonic_total_steps", 5),
|
||||
# Output Format
|
||||
output_format=thread.output_format,
|
||||
subtitle_mode=thread.subtitle_mode,
|
||||
subtitle_format=getattr(thread, "subtitle_format", "srt"),
|
||||
max_subtitle_words=getattr(thread, "max_subtitle_words", 50),
|
||||
# Save Options
|
||||
save_mode=thread.save_option,
|
||||
output_folder=output_folder,
|
||||
save_chapters_separately=getattr(thread, "save_chapters_separately", False),
|
||||
merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True),
|
||||
separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"),
|
||||
save_as_project=getattr(thread, "save_as_project", False),
|
||||
# Timing
|
||||
silence_between_chapters=getattr(thread, "silence_duration", 2.0),
|
||||
chapter_intro_delay=getattr(thread, "chapter_intro_delay", 0.0),
|
||||
# Content Processing
|
||||
replace_single_newlines=getattr(thread, "replace_single_newlines", False),
|
||||
read_title_intro=getattr(thread, "read_title_intro", False),
|
||||
read_closing_outro=getattr(thread, "read_closing_outro", True),
|
||||
auto_prefix_chapter_titles=getattr(thread, "auto_prefix_chapter_titles", True),
|
||||
normalize_chapter_opening_caps=getattr(thread, "normalize_chapter_opening_caps", False),
|
||||
# Pronunciation / Normalization
|
||||
pronunciation_overrides=getattr(thread, "pronunciation_overrides", []) or [],
|
||||
manual_overrides=getattr(thread, "manual_overrides", []) or [],
|
||||
heteronym_overrides=getattr(thread, "heteronym_overrides", []) or [],
|
||||
normalization_overrides=getattr(thread, "normalization_overrides", None),
|
||||
# Chapter/Chunk Configuration
|
||||
chapter_overrides=[], # PyQt doesn't use chapter overrides from GUI
|
||||
chunks=[], # PyQt doesn't use chunks from GUI
|
||||
chunk_level="paragraph",
|
||||
speaker_mode="single",
|
||||
speakers={},
|
||||
# Metadata
|
||||
metadata_tags=getattr(thread, "metadata_tags", {}) or {},
|
||||
# Artifacts
|
||||
cover_image_path=getattr(thread, "cover_image_path", None),
|
||||
cover_image_mime=getattr(thread, "cover_image_mime", None),
|
||||
generate_epub3=getattr(thread, "generate_epub3", False),
|
||||
)
|
||||
|
||||
|
||||
class PyQtEvents:
|
||||
"""PyQt implementation of ConversionEvents protocol.
|
||||
|
||||
Wraps a ConversionThread to provide logging, progress, and cancellation.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: Any):
|
||||
self._thread = thread
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message via signal."""
|
||||
self._thread.log_updated.emit((message, _level_to_color(level)))
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
"""Update progress via signal."""
|
||||
self._thread.progress_updated.emit(pct, etr)
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
"""Check if conversion was cancelled.
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If cancellation was requested
|
||||
"""
|
||||
if self._thread.cancel_requested:
|
||||
raise ConversionCancelled("Conversion cancelled by user")
|
||||
|
||||
|
||||
class PyQtPipelineProvider:
|
||||
"""PyQt implementation of PipelineProvider protocol.
|
||||
|
||||
Wraps the existing backend from ConversionThread.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: Any):
|
||||
self._backend = backend
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> Any:
|
||||
"""Get a TTS backend instance.
|
||||
|
||||
For PyQt, this returns the pre-initialized backend.
|
||||
"""
|
||||
return self._backend
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all backend resources."""
|
||||
pass # PyQt manages backend lifecycle in thread
|
||||
|
||||
|
||||
class PyQtVoiceResolver:
|
||||
"""PyQt implementation of VoiceResolver protocol.
|
||||
|
||||
Wraps load_voice_cached from the ConversionThread.
|
||||
"""
|
||||
|
||||
def __init__(self, thread: Any):
|
||||
self._thread = thread
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
"""Resolve a voice spec into a loaded voice."""
|
||||
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||
|
||||
# Use thread's load_voice_cached method
|
||||
loaded_voice = self._thread.load_voice_cached(voice_spec, self._thread.backend)
|
||||
|
||||
return ResolvedVoice(
|
||||
provider="kokoro",
|
||||
resolved_spec=voice_spec,
|
||||
voice=loaded_voice,
|
||||
speed=self._thread.speed,
|
||||
supertonic_steps=getattr(self._thread, "supertonic_total_steps", 5),
|
||||
)
|
||||
|
||||
|
||||
def _level_to_color(level: str) -> str:
|
||||
"""Map log level to PyQt color string."""
|
||||
colors = {
|
||||
"info": "grey",
|
||||
"warning": "orange",
|
||||
"error": "red",
|
||||
"debug": "grey",
|
||||
}
|
||||
return colors.get(level, "grey")
|
||||
+62
-96
@@ -7,7 +7,6 @@ import base64
|
||||
import re
|
||||
from abogen.pyqt.queue_manager_gui import QueueManager
|
||||
from abogen.pyqt.queued_item import QueuedItem
|
||||
|
||||
import abogen.hf_tracker as hf_tracker
|
||||
import hashlib # Added for cache path generation
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -83,18 +82,14 @@ from abogen.constants import (
|
||||
GITHUB_URL,
|
||||
PROGRAM_DESCRIPTION,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
VOICES_INTERNAL,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
COLORS,
|
||||
SUBTITLE_FORMATS,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
import threading
|
||||
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
||||
from abogen.voice_profiles import load_profiles
|
||||
from abogen.domain.settings_core import all_settings_defaults
|
||||
|
||||
# Module-level default cache for use outside __init__
|
||||
_DEFAULTS = all_settings_defaults()
|
||||
|
||||
# Import ctypes for Windows-specific taskbar icon
|
||||
if platform.system() == "Windows":
|
||||
@@ -842,7 +837,7 @@ class WordSubstitutionsDialog(QDialog):
|
||||
self,
|
||||
)
|
||||
instructions.setStyleSheet(
|
||||
f"padding: 10px; background-color: {COLORS['GREY_BACKGROUND']}; border-radius: 5px;"
|
||||
"padding: 10px; background-color: #f0f0f0; border-radius: 5px;"
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
@@ -916,10 +911,9 @@ class abogen(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config = load_config()
|
||||
_d = all_settings_defaults()
|
||||
self.apply_theme(self.config.get("theme", _d["theme"]))
|
||||
self.apply_theme(self.config.get("theme", "system"))
|
||||
migrate_subtitle_format(self.config)
|
||||
self.check_updates = self.config.get("check_updates", _d["check_updates"])
|
||||
self.check_updates = self.config.get("check_updates", True)
|
||||
self.save_option = self.config.get("save_option", "Save next to input file")
|
||||
self.selected_output_folder = self.config.get("selected_output_folder", None)
|
||||
self.selected_file = self.selected_file_type = self.selected_book_path = None
|
||||
@@ -927,7 +921,7 @@ class abogen(QWidget):
|
||||
None # Add new variable to track the displayed file path
|
||||
)
|
||||
# Max log lines
|
||||
self.log_window_max_lines = self.config.get("log_window_max_lines", _d["log_window_max_lines"])
|
||||
self.log_window_max_lines = self.config.get("log_window_max_lines", 2000)
|
||||
self.selected_chapters = set()
|
||||
self.last_opened_book_path = None # Track the last opened book path
|
||||
self.last_output_path = None
|
||||
@@ -942,28 +936,40 @@ class abogen(QWidget):
|
||||
self.selected_voice = None
|
||||
self.selected_lang = None
|
||||
else:
|
||||
self.selected_voice = self.config.get("selected_voice", _d["selected_voice"])
|
||||
self.selected_voice = self.config.get("selected_voice", "af_heart")
|
||||
self.selected_lang = self.selected_voice[0] if self.selected_voice else None
|
||||
self.is_converting = False
|
||||
self.subtitle_mode = self.config.get("subtitle_mode", _d["subtitle_mode"])
|
||||
self.max_subtitle_words = self.config.get("max_subtitle_words", _d["max_subtitle_words"])
|
||||
self.silence_duration = self.config.get("silence_duration", _d.get("silence_between_chapters", 2.0))
|
||||
self.selected_format = self.config.get("selected_format", _d["selected_format"])
|
||||
self.separate_chapters_format = self.config.get("separate_chapters_format", _d["separate_chapters_format"])
|
||||
self.use_gpu = self.config.get("use_gpu", _d["use_gpu"])
|
||||
self.replace_single_newlines = self.config.get("replace_single_newlines", _d.get("replace_single_newlines", True))
|
||||
self.use_silent_gaps = self.config.get("use_silent_gaps", _d["use_silent_gaps"])
|
||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", _d["subtitle_speed_method"])
|
||||
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", _d["use_spacy_segmentation"])
|
||||
self.read_title_intro = self.config.get("read_title_intro", _d.get("read_title_intro", False))
|
||||
self.read_closing_outro = self.config.get("read_closing_outro", _d.get("read_closing_outro", True))
|
||||
self.subtitle_mode = self.config.get("subtitle_mode", "Sentence")
|
||||
self.max_subtitle_words = self.config.get(
|
||||
"max_subtitle_words", 50
|
||||
) # Default max words per subtitle
|
||||
self.silence_duration = self.config.get(
|
||||
"silence_duration", 2.0
|
||||
) # Default silence duration
|
||||
self.selected_format = self.config.get("selected_format", "wav")
|
||||
self.separate_chapters_format = self.config.get(
|
||||
"separate_chapters_format", "wav"
|
||||
) # Format for individual chapter files
|
||||
self.use_gpu = self.config.get(
|
||||
"use_gpu", True # Load GPU setting with default True
|
||||
)
|
||||
self.replace_single_newlines = self.config.get("replace_single_newlines", True)
|
||||
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
||||
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
|
||||
# Word substitution settings
|
||||
self.word_substitutions_enabled = self.config.get("word_substitutions_enabled", _d["word_substitutions_enabled"])
|
||||
self.word_substitutions_list = self.config.get("word_substitutions_list", _d["word_substitutions_list"])
|
||||
self.case_sensitive_substitutions = self.config.get("case_sensitive_substitutions", _d["case_sensitive_substitutions"])
|
||||
self.replace_all_caps = self.config.get("replace_all_caps", _d["replace_all_caps"])
|
||||
self.replace_numerals = self.config.get("replace_numerals", _d["replace_numerals"])
|
||||
self.fix_nonstandard_punctuation = self.config.get("fix_nonstandard_punctuation", _d["fix_nonstandard_punctuation"])
|
||||
self.word_substitutions_enabled = self.config.get(
|
||||
"word_substitutions_enabled", False
|
||||
)
|
||||
self.word_substitutions_list = self.config.get("word_substitutions_list", "")
|
||||
self.case_sensitive_substitutions = self.config.get(
|
||||
"case_sensitive_substitutions", False
|
||||
)
|
||||
self.replace_all_caps = self.config.get("replace_all_caps", False)
|
||||
self.replace_numerals = self.config.get("replace_numerals", False)
|
||||
self.fix_nonstandard_punctuation = self.config.get(
|
||||
"fix_nonstandard_punctuation", False
|
||||
)
|
||||
self._pending_close_event = None
|
||||
self.gpu_ok = False # Initialize GPU availability status
|
||||
|
||||
@@ -991,7 +997,7 @@ class abogen(QWidget):
|
||||
self.current_queue_index = 0
|
||||
|
||||
self.initUI()
|
||||
self.speed_slider.setValue(int(self.config.get("speed", _d["speed"]) * 100))
|
||||
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
|
||||
self.update_speed_label()
|
||||
# Set initial selection: prefer profile, else voice
|
||||
idx = -1
|
||||
@@ -1867,7 +1873,7 @@ class abogen(QWidget):
|
||||
for pname in load_profiles().keys():
|
||||
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
||||
# re-add voices
|
||||
for v in get_voices("kokoro"):
|
||||
for v in VOICES_INTERNAL:
|
||||
icon = QIcon()
|
||||
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
||||
if flag_path and os.path.exists(flag_path):
|
||||
@@ -2154,7 +2160,7 @@ class abogen(QWidget):
|
||||
)
|
||||
|
||||
# CHECK GLOBAL OVERRIDE SETTING
|
||||
if not self.config.get("queue_override_settings", _DEFAULTS["queue_override_settings"]):
|
||||
if not self.config.get("queue_override_settings", False):
|
||||
self.selected_lang = queued_item.lang_code
|
||||
self.speed_slider.setValue(int(queued_item.speed * 100))
|
||||
|
||||
@@ -2228,10 +2234,11 @@ class abogen(QWidget):
|
||||
self.current_queue_index = 0 # Reset for next time
|
||||
|
||||
def get_voice_formula(self) -> str:
|
||||
from abogen.voice_formulas import pairs_to_formula
|
||||
|
||||
if self.mixed_voice_state:
|
||||
return pairs_to_formula(self.mixed_voice_state) or ""
|
||||
formula_components = [
|
||||
f"{name}*{weight}" for name, weight in self.mixed_voice_state
|
||||
]
|
||||
return " + ".join(filter(None, formula_components))
|
||||
else:
|
||||
return self.selected_voice
|
||||
|
||||
@@ -2309,9 +2316,9 @@ class abogen(QWidget):
|
||||
file_size_str = "Unknown"
|
||||
|
||||
# pipeline_loaded_callback remains unchanged
|
||||
def pipeline_loaded_callback(backend, error):
|
||||
def pipeline_loaded_callback(np_module, kpipeline_class, error):
|
||||
if error:
|
||||
self.update_log((f"Error loading TTS backend: {error}", "red"))
|
||||
self.update_log((f"Error loading numpy or KPipeline: {error}", "red"))
|
||||
prevent_sleep_end()
|
||||
return
|
||||
|
||||
@@ -2334,7 +2341,8 @@ class abogen(QWidget):
|
||||
self.selected_output_folder,
|
||||
subtitle_mode=actual_subtitle_mode,
|
||||
output_format=self.selected_format,
|
||||
backend=backend,
|
||||
np_module=np_module,
|
||||
kpipeline_class=kpipeline_class,
|
||||
start_time=self.start_time,
|
||||
total_char_count=self.char_count,
|
||||
use_gpu=self.gpu_ok,
|
||||
@@ -2395,9 +2403,6 @@ class abogen(QWidget):
|
||||
self.conversion_thread.merge_chapters_at_end = getattr(
|
||||
self, "merge_chapters_at_end", True
|
||||
)
|
||||
# Pass intro/outro settings
|
||||
self.conversion_thread.read_title_intro = self.read_title_intro
|
||||
self.conversion_thread.read_closing_outro = self.read_closing_outro
|
||||
self.conversion_thread.progress_updated.connect(self.update_progress)
|
||||
self.conversion_thread.log_updated.connect(self.update_log)
|
||||
self.conversion_thread.conversion_finished.connect(
|
||||
@@ -2421,11 +2426,7 @@ class abogen(QWidget):
|
||||
self.gpu_ok = gpu_ok
|
||||
self.update_log((gpu_msg, gpu_ok))
|
||||
self.update_log("Loading modules...")
|
||||
|
||||
lang_code = self.selected_lang or "a"
|
||||
load_thread = LoadPipelineThread(
|
||||
pipeline_loaded_callback, lang_code=lang_code, use_gpu=gpu_ok
|
||||
)
|
||||
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||
load_thread.start()
|
||||
|
||||
threading.Thread(target=gpu_and_load, daemon=True).start()
|
||||
@@ -2436,7 +2437,7 @@ class abogen(QWidget):
|
||||
return
|
||||
|
||||
# Check if override was active (this determines which settings were ACTUALLY used)
|
||||
override_active = self.config.get("queue_override_settings", _DEFAULTS["queue_override_settings"])
|
||||
override_active = self.config.get("queue_override_settings", False)
|
||||
|
||||
# If override is ON, capture the global settings that were used for processing
|
||||
if override_active:
|
||||
@@ -2862,18 +2863,18 @@ class abogen(QWidget):
|
||||
)
|
||||
self.loading_movie.start()
|
||||
|
||||
lang = self.selected_lang or "a"
|
||||
load_thread = LoadPipelineThread(
|
||||
self._on_pipeline_loaded_for_preview, lang_code=lang, use_gpu=self.gpu_ok
|
||||
)
|
||||
def pipeline_loaded_callback(np_module, kpipeline_class, error):
|
||||
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
|
||||
|
||||
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||
load_thread.start()
|
||||
|
||||
def _on_pipeline_loaded_for_preview(self, backend, error):
|
||||
def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error):
|
||||
# stop loading animation and restore icon on error
|
||||
if error:
|
||||
self.loading_movie.stop()
|
||||
self._show_error_message_box(
|
||||
"Loading Error", f"Error loading TTS backend: {error}"
|
||||
"Loading Error", f"Error loading numpy or KPipeline: {error}"
|
||||
)
|
||||
self.btn_preview.setIcon(self.play_icon)
|
||||
self.btn_preview.setEnabled(True)
|
||||
@@ -2911,7 +2912,7 @@ class abogen(QWidget):
|
||||
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
||||
|
||||
self.preview_thread = VoicePreviewThread(
|
||||
backend, lang, voice, speed, gpu_ok
|
||||
np_module, kpipeline_class, lang, voice, speed, gpu_ok
|
||||
)
|
||||
self.preview_thread.finished.connect(self._play_preview_audio)
|
||||
self.preview_thread.error.connect(self._preview_error)
|
||||
@@ -3214,16 +3215,12 @@ class abogen(QWidget):
|
||||
)
|
||||
box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
if box.exec() == QMessageBox.StandardButton.Yes:
|
||||
from abogen import shutdown
|
||||
shutdown.request_shutdown()
|
||||
self.cleanup_conversion_thread()
|
||||
self.cleanup_preview_threads()
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
else:
|
||||
from abogen import shutdown
|
||||
shutdown.request_shutdown()
|
||||
self.cleanup_conversion_thread()
|
||||
self.cleanup_preview_threads()
|
||||
event.accept()
|
||||
@@ -3412,7 +3409,7 @@ class abogen(QWidget):
|
||||
app.installEventFilter(app._dark_titlebar_event_filter)
|
||||
|
||||
# Save config if changed
|
||||
if self.config.get("theme", _DEFAULTS["theme"]) != theme:
|
||||
if self.config.get("theme", "system") != theme:
|
||||
self.config["theme"] = theme
|
||||
save_config(self.config)
|
||||
|
||||
@@ -3434,7 +3431,7 @@ class abogen(QWidget):
|
||||
]
|
||||
|
||||
# Get current theme from config, default to "system"
|
||||
current_theme = self.config.get("theme", _DEFAULTS["theme"])
|
||||
current_theme = self.config.get("theme", "system")
|
||||
for value, text in theme_options:
|
||||
theme_action = QAction(text, self)
|
||||
theme_action.setCheckable(True)
|
||||
@@ -3565,27 +3562,6 @@ class abogen(QWidget):
|
||||
# Add separator
|
||||
menu.addSeparator()
|
||||
|
||||
# Add title intro option
|
||||
self.title_intro_action = QAction("Read title intro before first chapter", self)
|
||||
self.title_intro_action.setCheckable(True)
|
||||
self.title_intro_action.setChecked(self.read_title_intro)
|
||||
self.title_intro_action.triggered.connect(
|
||||
lambda checked: self.toggle_read_title_intro(checked)
|
||||
)
|
||||
menu.addAction(self.title_intro_action)
|
||||
|
||||
# Add closing outro option
|
||||
self.closing_outro_action = QAction("Read closing outro after last chapter", self)
|
||||
self.closing_outro_action.setCheckable(True)
|
||||
self.closing_outro_action.setChecked(self.read_closing_outro)
|
||||
self.closing_outro_action.triggered.connect(
|
||||
lambda checked: self.toggle_read_closing_outro(checked)
|
||||
)
|
||||
menu.addAction(self.closing_outro_action)
|
||||
|
||||
# Add separator
|
||||
menu.addSeparator()
|
||||
|
||||
# Add "Pre-download models and voices for offline use" option
|
||||
predownload_action = QAction(
|
||||
"Pre-download models and voices for offline use", self
|
||||
@@ -3597,7 +3573,7 @@ class abogen(QWidget):
|
||||
disable_kokoro_action = QAction("Disable Kokoro's internet access", self)
|
||||
disable_kokoro_action.setCheckable(True)
|
||||
disable_kokoro_action.setChecked(
|
||||
self.config.get("disable_kokoro_internet", _DEFAULTS["disable_kokoro_internet"])
|
||||
self.config.get("disable_kokoro_internet", False)
|
||||
)
|
||||
disable_kokoro_action.triggered.connect(
|
||||
lambda checked: self.toggle_kokoro_internet_access(checked)
|
||||
@@ -3607,7 +3583,7 @@ class abogen(QWidget):
|
||||
# Add check for updates option
|
||||
check_updates_action = QAction("Check for updates at startup", self)
|
||||
check_updates_action.setCheckable(True)
|
||||
check_updates_action.setChecked(self.config.get("check_updates", _DEFAULTS["check_updates"]))
|
||||
check_updates_action.setChecked(self.config.get("check_updates", True))
|
||||
check_updates_action.triggered.connect(self.toggle_check_updates)
|
||||
menu.addAction(check_updates_action)
|
||||
|
||||
@@ -3662,16 +3638,6 @@ class abogen(QWidget):
|
||||
self.config["use_spacy_segmentation"] = enabled
|
||||
save_config(self.config)
|
||||
|
||||
def toggle_read_title_intro(self, enabled):
|
||||
self.read_title_intro = enabled
|
||||
self.config["read_title_intro"] = enabled
|
||||
save_config(self.config)
|
||||
|
||||
def toggle_read_closing_outro(self, enabled):
|
||||
self.read_closing_outro = enabled
|
||||
self.config["read_closing_outro"] = enabled
|
||||
save_config(self.config)
|
||||
|
||||
def restart_app(self):
|
||||
|
||||
import sys
|
||||
@@ -4243,7 +4209,7 @@ Categories=AudioVideo;Audio;Utility;
|
||||
"""Open a dialog to set the maximum words per subtitle"""
|
||||
from PyQt6.QtWidgets import QInputDialog
|
||||
|
||||
current_value = self.config.get("max_subtitle_words", _DEFAULTS["max_subtitle_words"])
|
||||
current_value = self.config.get("max_subtitle_words", 50)
|
||||
|
||||
value, ok = QInputDialog.getInt(
|
||||
self,
|
||||
@@ -4271,7 +4237,7 @@ Categories=AudioVideo;Audio;Utility;
|
||||
def set_silence_between_chapters(self):
|
||||
"""Open a dialog to set the silence duration between chapters"""
|
||||
|
||||
current_value = self.config.get("silence_duration", _DEFAULTS.get("silence_between_chapters", 2.0))
|
||||
current_value = self.config.get("silence_duration", 2.0)
|
||||
|
||||
dlg = QInputDialog(self)
|
||||
dlg.setWindowTitle("Silence Duration (seconds)")
|
||||
|
||||
+23
-9
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import atexit
|
||||
import signal
|
||||
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
|
||||
|
||||
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
|
||||
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
|
||||
if platform.system() == "Windows":
|
||||
@@ -46,8 +46,6 @@ except ImportError:
|
||||
print("PyQt6 not installed.")
|
||||
|
||||
|
||||
from abogen.utils import get_resource_path
|
||||
|
||||
# Pre-load "libxcb-cursor" on Linux (fixes #101)
|
||||
if platform.system() == "Linux":
|
||||
arch = platform.machine().lower()
|
||||
@@ -96,7 +94,6 @@ os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
|
||||
os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
|
||||
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
|
||||
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning
|
||||
from abogen.utils import load_config
|
||||
if load_config().get("disable_kokoro_internet", False):
|
||||
print("INFO: Kokoro's internet access is disabled.")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
|
||||
@@ -108,6 +105,25 @@ from abogen.constants import PROGRAM_NAME, VERSION
|
||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||
|
||||
# Reset sleep states
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
# Also handle signals (Ctrl+C, kill, etc.)
|
||||
def _cleanup_sleep(signum, frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
# Enable MPS GPU acceleration on Mac Apple Silicon
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
@@ -120,8 +136,6 @@ def qt_message_handler(mode, context, message):
|
||||
return # Suppress this specific message
|
||||
if "setGrabPopup called with a parent, QtWaylandClient" in message:
|
||||
return
|
||||
if "Failed to register with host portal" in message:
|
||||
return
|
||||
|
||||
if mode == QtMsgType.QtWarningMsg:
|
||||
print(f"Qt Warning: {message}")
|
||||
@@ -170,4 +184,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -21,8 +21,7 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from abogen.constants import COLORS
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||
from abogen.spacy_utils import SPACY_MODELS
|
||||
import abogen.hf_tracker
|
||||
|
||||
@@ -115,7 +114,7 @@ class PreDownloadWorker(QThread):
|
||||
self._voices_success = False
|
||||
return
|
||||
|
||||
voice_list = get_voices("kokoro")
|
||||
voice_list = VOICES_INTERNAL
|
||||
for idx, voice in enumerate(voice_list, start=1):
|
||||
if self._cancelled:
|
||||
self._voices_success = False
|
||||
@@ -463,14 +462,14 @@ class PreDownloadDialog(QDialog):
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
for voice in get_voices("kokoro"):
|
||||
for voice in VOICES_INTERNAL:
|
||||
if not try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||
):
|
||||
missing.append(voice)
|
||||
except Exception:
|
||||
# If HF missing, report all as missing
|
||||
return False, list(get_voices("kokoro"))
|
||||
return False, list(VOICES_INTERNAL)
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
def _check_kokoro_model(self) -> bool:
|
||||
|
||||
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
|
||||
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||||
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
||||
from abogen.constants import (
|
||||
VOICES_INTERNAL,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
COLORS,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
import re
|
||||
import platform
|
||||
from abogen.utils import get_resource_path
|
||||
@@ -179,7 +179,7 @@ class VoiceMixer(QWidget):
|
||||
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Voice name label with gender icon
|
||||
is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f"
|
||||
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
|
||||
|
||||
# Icons layout (flag and gender)
|
||||
icons_layout = QHBoxLayout()
|
||||
@@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
|
||||
|
||||
def add_voices(self, initial_state):
|
||||
first_enabled_voice = None
|
||||
for voice in get_voices("kokoro"):
|
||||
for voice in VOICES_INTERNAL:
|
||||
language_code = voice[0] # First character is the language code
|
||||
matching_voice = next(
|
||||
(item for item in initial_state if item[0] == voice), None
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Graceful shutdown - single module, no over-engineering."""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import gc
|
||||
import signal
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
_CLEANUP_FUNCS: list[Callable[[], None]] = []
|
||||
_EXECUTED = False
|
||||
|
||||
|
||||
def register_cleanup(fn: Callable[[], None]) -> None:
|
||||
"""Register a cleanup function to run on shutdown."""
|
||||
_CLEANUP_FUNCS.append(fn)
|
||||
|
||||
|
||||
def _run_cleanups() -> None:
|
||||
global _EXECUTED
|
||||
if _EXECUTED:
|
||||
return
|
||||
_EXECUTED = True
|
||||
for fn in _CLEANUP_FUNCS:
|
||||
try:
|
||||
fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- Register built-in cleanup functions ----
|
||||
|
||||
# 1. Restore sleep prevention
|
||||
def _restore_sleep() -> None:
|
||||
try:
|
||||
from abogen.utils import prevent_sleep_end
|
||||
prevent_sleep_end()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_restore_sleep)
|
||||
|
||||
# 2. Shutdown web UI ConversionService
|
||||
def _shutdown_conversion_service() -> None:
|
||||
try:
|
||||
from abogen.webui.service import get_service
|
||||
svc = get_service()
|
||||
if svc is not None:
|
||||
svc.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_shutdown_conversion_service)
|
||||
|
||||
# 3. Clear TTS pipelines and GPU memory
|
||||
def _cleanup_tts_pipelines() -> None:
|
||||
# Clear web UI pipeline cache
|
||||
try:
|
||||
from abogen.webui.conversion_runner import _PIPELINES
|
||||
_PIPELINES.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear PyQt conversion thread voice cache
|
||||
try:
|
||||
from abogen.pyqt.conversion import ConversionThread
|
||||
if hasattr(ConversionThread, "voice_cache"):
|
||||
ConversionThread.voice_cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
gc.collect()
|
||||
|
||||
# Release CUDA cache
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_cleanup_tts_pipelines)
|
||||
|
||||
# 4. Clear global voice cache
|
||||
def _clear_voice_cache() -> None:
|
||||
try:
|
||||
from abogen.voice_cache import clear_voice_cache
|
||||
clear_voice_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_clear_voice_cache)
|
||||
|
||||
# 5. Terminate child processes (ffmpeg, etc.)
|
||||
def _terminate_subprocesses() -> None:
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
current = psutil.Process()
|
||||
for child in current.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
gone, alive = psutil.wait_procs(current.children(recursive=True), timeout=3)
|
||||
for proc in alive:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_terminate_subprocesses)
|
||||
|
||||
|
||||
def register_shutdown() -> None:
|
||||
"""Install process-wide shutdown hooks (atexit, signals, Qt)."""
|
||||
if register_shutdown._registered:
|
||||
return
|
||||
register_shutdown._registered = True
|
||||
|
||||
atexit.register(_run_cleanups)
|
||||
|
||||
# POSIX signals
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
signal.signal(sig, _on_signal)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Qt hook
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(_run_cleanups)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
register_shutdown._registered = False
|
||||
|
||||
|
||||
def _on_signal(signum: int, _frame) -> None:
|
||||
_run_cleanups()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def request_shutdown() -> None:
|
||||
"""Programmatically trigger cleanup (e.g., from GUI closeEvent)."""
|
||||
_run_cleanups()
|
||||
|
||||
|
||||
__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"]
|
||||
+11
-36
@@ -2,36 +2,21 @@
|
||||
Lazy-loaded spaCy utilities for sentence segmentation.
|
||||
"""
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
|
||||
# Cached spaCy module and models (lazy loaded)
|
||||
_spacy = None
|
||||
_nlp_cache = {}
|
||||
|
||||
# Language code to spaCy model mapping
|
||||
SPACY_MODELS = {
|
||||
Language.EN_US: "en_core_web_sm",
|
||||
Language.EN_GB: "en_core_web_sm",
|
||||
Language.ES: "es_core_news_sm",
|
||||
Language.FR: "fr_core_news_sm",
|
||||
Language.IT: "it_core_news_sm",
|
||||
Language.PT_BR: "pt_core_news_sm",
|
||||
Language.ZH: "zh_core_web_sm",
|
||||
Language.JA: "ja_core_news_sm",
|
||||
Language.HI: "xx_sent_ud_sm",
|
||||
}
|
||||
|
||||
# Kokoro single-letter codes -> Language enum (inverse of pipeline_factory._KOKORO_LANG_MAP)
|
||||
_KOKORO_TO_LANGUAGE = {
|
||||
"a": Language.EN_US,
|
||||
"b": Language.EN_GB,
|
||||
"e": Language.ES,
|
||||
"f": Language.FR,
|
||||
"h": Language.HI,
|
||||
"i": Language.IT,
|
||||
"j": Language.JA,
|
||||
"p": Language.PT_BR,
|
||||
"z": Language.ZH,
|
||||
"a": "en_core_web_sm", # American English
|
||||
"b": "en_core_web_sm", # British English
|
||||
"e": "es_core_news_sm", # Spanish
|
||||
"f": "fr_core_news_sm", # French
|
||||
"i": "it_core_news_sm", # Italian
|
||||
"p": "pt_core_news_sm", # Brazilian Portuguese
|
||||
"z": "zh_core_web_sm", # Mandarin Chinese
|
||||
"j": "ja_core_news_sm", # Japanese
|
||||
"h": "xx_sent_ud_sm", # Hindi (multi-language model)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,9 +36,10 @@ def _load_spacy():
|
||||
def get_spacy_model(lang_code, log_callback=None):
|
||||
"""
|
||||
Get or load a spaCy model for the given language code.
|
||||
Downloads the model automatically if not available.
|
||||
|
||||
Args:
|
||||
lang_code: Language code or Language enum (e.g., "a", "en-US", Language.EN_US)
|
||||
lang_code: Language code (a, b, e, f, etc.)
|
||||
log_callback: Optional function to log messages
|
||||
|
||||
Returns:
|
||||
@@ -72,17 +58,6 @@ def get_spacy_model(lang_code, log_callback=None):
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
# Normalize to Language enum
|
||||
if not isinstance(lang_code, Language):
|
||||
if isinstance(lang_code, str) and lang_code in _KOKORO_TO_LANGUAGE:
|
||||
lang_code = _KOKORO_TO_LANGUAGE[lang_code]
|
||||
else:
|
||||
try:
|
||||
lang_code = Language.from_str(lang_code)
|
||||
except ValueError:
|
||||
log(f"\nspaCy: Unknown language '{lang_code}'...")
|
||||
return None
|
||||
|
||||
# Check if model is cached
|
||||
if lang_code in _nlp_cache:
|
||||
return _nlp_cache[lang_code]
|
||||
|
||||
@@ -466,7 +466,7 @@ def sanitize_name_for_os(name, is_folder=True):
|
||||
|
||||
|
||||
def validate_voice_name(voice_name):
|
||||
"""Validate voice name against available voices (case-insensitive).
|
||||
"""Validate voice name against VOICES_INTERNAL list (case-insensitive).
|
||||
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
|
||||
|
||||
Args:
|
||||
@@ -477,10 +477,10 @@ def validate_voice_name(voice_name):
|
||||
- is_valid: True if all voices in the name/formula are valid
|
||||
- invalid_voice_name: The first invalid voice found, or None if all valid
|
||||
"""
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
# Create case-insensitive lookup set (done once per call)
|
||||
voice_lookup_lower = {v.lower() for v in get_voices("kokoro")}
|
||||
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL}
|
||||
voice_name = voice_name.strip()
|
||||
|
||||
# Check if it's a formula (contains *)
|
||||
@@ -505,7 +505,7 @@ def split_text_by_voice_markers(text, default_voice):
|
||||
"""Split text by voice markers, returning list of (voice, text) tuples.
|
||||
|
||||
IMPORTANT: Returns the last voice used so it can persist across chapters.
|
||||
Voice names are normalized to lowercase to match canonical voice names.
|
||||
Voice names are normalized to lowercase to match VOICES_INTERNAL.
|
||||
|
||||
Args:
|
||||
text: Text potentially containing <<VOICE:name>> markers
|
||||
@@ -518,7 +518,7 @@ def split_text_by_voice_markers(text, default_voice):
|
||||
- valid_count: Number of valid voice markers processed
|
||||
- invalid_count: Number of invalid voice markers skipped
|
||||
"""
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
||||
|
||||
@@ -560,7 +560,7 @@ def split_text_by_voice_markers(text, default_voice):
|
||||
# Find the canonical (lowercase) voice name
|
||||
voice_part_lower = voice_part.strip().lower()
|
||||
canonical_voice = next(
|
||||
(v for v in get_voices("kokoro") if v.lower() == voice_part_lower),
|
||||
(v for v in VOICES_INTERNAL if v.lower() == voice_part_lower),
|
||||
voice_part.strip()
|
||||
)
|
||||
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
|
||||
@@ -569,7 +569,7 @@ def split_text_by_voice_markers(text, default_voice):
|
||||
# Find the canonical (lowercase) voice name
|
||||
voice_name_lower = voice_name.lower()
|
||||
current_voice = next(
|
||||
(v for v in get_voices("kokoro") if v.lower() == voice_name_lower),
|
||||
(v for v in VOICES_INTERNAL if v.lower() == voice_name_lower),
|
||||
voice_name
|
||||
)
|
||||
valid_markers += 1
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"""TTS Plugin Architecture - Public API.
|
||||
|
||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||
All public interfaces are fully defined but contain no business logic.
|
||||
|
||||
Public modules:
|
||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||
- errors: Error hierarchy (EngineError and subtypes)
|
||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||
- engine: Engine and EngineSession protocols
|
||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||
- host_context: HostContext dataclass
|
||||
- plugin: Plugin contract (create_engine function signature)
|
||||
- loader: Plugin discovery and loading
|
||||
- plugin_manager: Plugin management and engine creation
|
||||
- utils: Direct utility functions (get_voices, create_pipeline, etc.)
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin import (
|
||||
# Types
|
||||
AudioFormat,
|
||||
Duration,
|
||||
VoiceSelection,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
EngineConfig,
|
||||
# Errors
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
# Manifest
|
||||
PluginManifest,
|
||||
EngineManifest,
|
||||
VoiceSourceManifest,
|
||||
VoiceManifest,
|
||||
ParameterManifest,
|
||||
AudioFormatManifest,
|
||||
EnumOption,
|
||||
RequirementManifest,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
# Engine
|
||||
Engine,
|
||||
EngineSession,
|
||||
# Capabilities
|
||||
VoiceLister,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
CancelableSession,
|
||||
# Host Context
|
||||
HostContext,
|
||||
HttpClient,
|
||||
# Plugin Manager
|
||||
get_plugin_manager,
|
||||
reset_plugin_manager,
|
||||
# Utils
|
||||
get_voices,
|
||||
get_default_voice,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
create_pipeline,
|
||||
)
|
||||
"""
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
# Plugin Manager and Utils
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import (
|
||||
create_pipeline,
|
||||
get_default_voice,
|
||||
get_voices,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"AudioFormat",
|
||||
"Duration",
|
||||
"VoiceSelection",
|
||||
"ParameterValues",
|
||||
"SynthesisRequest",
|
||||
"SynthesizedAudio",
|
||||
"EngineConfig",
|
||||
# Errors
|
||||
"EngineError",
|
||||
"ModelNotFoundError",
|
||||
"ModelLoadError",
|
||||
"NetworkError",
|
||||
"InvalidInputError",
|
||||
"ConfigurationError",
|
||||
"CancelledError",
|
||||
"InternalError",
|
||||
# Manifest
|
||||
"PluginManifest",
|
||||
"EngineManifest",
|
||||
"VoiceSourceManifest",
|
||||
"VoiceManifest",
|
||||
"ParameterManifest",
|
||||
"AudioFormatManifest",
|
||||
"EnumOption",
|
||||
"RequirementManifest",
|
||||
"GpuRequirement",
|
||||
"ModelManifest",
|
||||
# Engine
|
||||
"Engine",
|
||||
"EngineSession",
|
||||
# Capabilities
|
||||
"VoiceLister",
|
||||
"PreviewGenerator",
|
||||
"StreamingSynthesizer",
|
||||
"CancelableSession",
|
||||
# Host Context
|
||||
"HostContext",
|
||||
"HttpClient",
|
||||
# Plugin Manager
|
||||
"get_plugin_manager",
|
||||
"reset_plugin_manager",
|
||||
# Utils
|
||||
"get_voices",
|
||||
"get_default_voice",
|
||||
"is_plugin_registered",
|
||||
"resolve_voice_to_plugin",
|
||||
"create_pipeline",
|
||||
]
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Capability interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines optional capability interfaces that engines can implement.
|
||||
Capabilities are additive; implementing new capabilities doesn't break old plugins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VoiceLister(Protocol):
|
||||
"""Protocol for listing available voices.
|
||||
|
||||
Engines that support voice listing should implement this interface.
|
||||
"""
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available voices for a given source.
|
||||
|
||||
Args:
|
||||
sourceId: The voice source identifier.
|
||||
|
||||
Returns:
|
||||
List of VoiceManifest describing available voices.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PreviewGenerator(Protocol):
|
||||
"""Protocol for generating voice previews.
|
||||
|
||||
Engines that support voice preview should implement this interface.
|
||||
"""
|
||||
|
||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
||||
"""Generate a preview audio for a voice.
|
||||
|
||||
Args:
|
||||
voice: Voice selection for the preview.
|
||||
text: Text to use for the preview.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the preview audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StreamingSynthesizer(Protocol):
|
||||
"""Protocol for streaming synthesis.
|
||||
|
||||
Optional capability of EngineSession, not Engine.
|
||||
Engines that support streaming synthesis should implement this interface.
|
||||
"""
|
||||
|
||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
||||
"""Synthesize audio in streaming mode.
|
||||
|
||||
Args:
|
||||
request: The synthesis request.
|
||||
|
||||
Yields:
|
||||
Audio chunks as they become available.
|
||||
|
||||
Raises:
|
||||
CancelledError: If cancel() is called during iteration.
|
||||
EngineError: On synthesis failure.
|
||||
"""
|
||||
...
|
||||
# This is a generator function; implementation will use yield
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancelableSession(Protocol):
|
||||
"""Protocol for cancellation support.
|
||||
|
||||
Optional capability for engines that support cancellation.
|
||||
cancel() causes synthesize() to raise CancelledError.
|
||||
"""
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel in-progress synthesis.
|
||||
|
||||
After cancellation, synthesize() raises CancelledError.
|
||||
The session remains usable after cancellation.
|
||||
|
||||
Raises:
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Engine interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the core Engine and EngineSession protocols.
|
||||
These are the primary interfaces that plugin implementations must satisfy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EngineSession(Protocol):
|
||||
"""Protocol for a session that owns mutable execution state.
|
||||
|
||||
An EngineSession is created by Engine.createSession() and owns
|
||||
mutable execution state isolated from other concurrent work.
|
||||
It is NOT thread-safe.
|
||||
|
||||
Lifecycle:
|
||||
1. Created by Engine.createSession()
|
||||
2. Used for synthesis via synthesize()
|
||||
3. Disposed via dispose()
|
||||
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text.
|
||||
|
||||
Args:
|
||||
request: The synthesis request containing text, voice, parameters, and format.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the synthesized audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On synthesis failure. Session remains usable after error.
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Engine(Protocol):
|
||||
"""Protocol for a TTS engine that creates sessions.
|
||||
|
||||
An Engine is a factory for EngineSession instances. It is stateless
|
||||
and thread-safe for createSession().
|
||||
|
||||
Lifecycle:
|
||||
1. Created via create_engine() (plugin contract)
|
||||
2. Sessions created via createSession()
|
||||
3. Disposed via dispose()
|
||||
|
||||
Thread Safety:
|
||||
- createSession() is thread-safe and can be called from any thread.
|
||||
- dispose() must be called after all sessions are disposed.
|
||||
- Disposing engine while sessions are alive violates API contract.
|
||||
"""
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
"""Create a new session for synthesis.
|
||||
|
||||
Returns:
|
||||
A new EngineSession instance. Ownership transfers to caller.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. No partially initialized session is returned.
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources.
|
||||
|
||||
Caller must ensure all sessions created by this engine are disposed
|
||||
before calling dispose(). Disposing an engine while any session is
|
||||
still alive violates the API contract; behavior is undefined.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Error hierarchy for the TTS Plugin Architecture.
|
||||
|
||||
This module defines typed exceptions that engines raise.
|
||||
Engines should never raise raw exceptions; they must use EngineError or its subtypes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
"""Base exception for all engine errors.
|
||||
|
||||
All engine operations that can fail should raise EngineError or one of its subtypes.
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelNotFoundError(EngineError):
|
||||
"""Raised when a required model is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelLoadError(EngineError):
|
||||
"""Raised when a model fails to load."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(EngineError):
|
||||
"""Raised when a network operation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidInputError(EngineError):
|
||||
"""Raised when invalid input is provided to the engine."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(EngineError):
|
||||
"""Raised when there is a configuration error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CancelledError(EngineError):
|
||||
"""Raised when an operation is cancelled.
|
||||
|
||||
This is raised by synthesize() when cancel() is called during synthesis.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InternalError(EngineError):
|
||||
"""Raised when an internal engine error occurs."""
|
||||
|
||||
pass
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Host context for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the HostContext dataclass that provides minimal
|
||||
host services to plugins. It is the only interface through which
|
||||
plugins can access host functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpClient(Protocol):
|
||||
"""Protocol for HTTP client provided by host.
|
||||
|
||||
Plugins can use this for network requests (e.g., API-based engines).
|
||||
"""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP GET request."""
|
||||
...
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP POST request."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostContext:
|
||||
"""Minimal host context provided to plugins.
|
||||
|
||||
Contains only essential host services. No business logic.
|
||||
|
||||
Attributes:
|
||||
config_dir: Directory for API keys, preferences, and configuration.
|
||||
logger: Logger for plugin logging.
|
||||
http_client: HTTP client for network requests.
|
||||
"""
|
||||
|
||||
config_dir: Path
|
||||
logger: logging.Logger
|
||||
http_client: HttpClient
|
||||
@@ -1,365 +0,0 @@
|
||||
"""Plugin loader infrastructure for the TTS Plugin Architecture.
|
||||
|
||||
This module provides functionality to discover, import, validate, and load
|
||||
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
|
||||
messages for errors.
|
||||
|
||||
The loader does NOT:
|
||||
- Create Engine instances (that's the plugin's create_engine() responsibility)
|
||||
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
|
||||
- Implement any TTS engine functionality
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
|
||||
|
||||
|
||||
# Host API version for compatibility checking
|
||||
HOST_API_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadError:
|
||||
"""Diagnostic information for a failed plugin load.
|
||||
|
||||
Attributes:
|
||||
plugin_id: Plugin identifier if available, otherwise directory name.
|
||||
path: Path to the plugin directory.
|
||||
errors: List of error messages describing what went wrong.
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
path: Path
|
||||
errors: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadResult:
|
||||
"""Result of loading a plugin.
|
||||
|
||||
Attributes:
|
||||
success: Whether the plugin loaded successfully.
|
||||
manifest: The plugin manifest if successful.
|
||||
model_requirements: Model requirements if successful.
|
||||
create_engine: The create_engine function if successful.
|
||||
module: The plugin module if successful.
|
||||
error: Error information if failed.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
manifest: PluginManifest | None = None
|
||||
model_requirements: tuple[ModelManifest, ...] | None = None
|
||||
create_engine: Callable[..., Any] | None = None
|
||||
module: types.ModuleType | None = None
|
||||
error: PluginLoadError | None = None
|
||||
|
||||
|
||||
def _parse_api_version(version: str) -> tuple[int, int] | None:
|
||||
"""Parse an api_version string into (major, minor) tuple.
|
||||
|
||||
Args:
|
||||
version: Version string in format "MAJOR.MINOR".
|
||||
|
||||
Returns:
|
||||
Tuple of (major, minor) or None if invalid format.
|
||||
"""
|
||||
match = re.match(r"^(\d+)\.(\d+)$", version)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def _check_api_version_compatibility(plugin_version: str) -> str | None:
|
||||
"""Check if plugin api_version is compatible with host.
|
||||
|
||||
Architecture spec:
|
||||
- Format: semver (MAJOR.MINOR)
|
||||
- Compatibility: Host rejects plugin if major version differs
|
||||
- Minor version: backward compatible, Host accepts higher minor
|
||||
|
||||
Args:
|
||||
plugin_version: Plugin's api_version string.
|
||||
|
||||
Returns:
|
||||
Error message if incompatible, None if compatible.
|
||||
"""
|
||||
plugin_ver = _parse_api_version(plugin_version)
|
||||
if plugin_ver is None:
|
||||
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
|
||||
|
||||
host_ver = _parse_api_version(HOST_API_VERSION)
|
||||
if host_ver is None:
|
||||
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
|
||||
|
||||
if plugin_ver[0] != host_ver[0]:
|
||||
return (
|
||||
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
|
||||
f"Major version must match for compatibility."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
|
||||
"""Validate that a plugin module has required exports.
|
||||
|
||||
Args:
|
||||
module: The imported plugin module.
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Check PLUGIN_MANIFEST
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if manifest is None:
|
||||
errors.append("Missing PLUGIN_MANIFEST export")
|
||||
elif not isinstance(manifest, PluginManifest):
|
||||
errors.append(
|
||||
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
|
||||
f"got {type(manifest).__name__}"
|
||||
)
|
||||
|
||||
# Check MODEL_REQUIREMENTS
|
||||
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
|
||||
if model_reqs is None:
|
||||
errors.append("Missing MODEL_REQUIREMENTS export")
|
||||
elif not isinstance(model_reqs, list):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
|
||||
)
|
||||
else:
|
||||
for i, req in enumerate(model_reqs):
|
||||
if not isinstance(req, ModelManifest):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
|
||||
f"got {type(req).__name__}"
|
||||
)
|
||||
|
||||
# Check create_engine
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
if create_engine is None:
|
||||
errors.append("Missing create_engine export")
|
||||
elif not callable(create_engine):
|
||||
errors.append(
|
||||
f"create_engine must be callable, got {type(create_engine).__name__}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate plugin capabilities.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Known capabilities (can be extended)
|
||||
known_capabilities = frozenset({
|
||||
"voice_list",
|
||||
"preview",
|
||||
"voice_clone",
|
||||
"voice_blend",
|
||||
"streaming",
|
||||
"cancel",
|
||||
})
|
||||
|
||||
for cap in manifest.capabilities:
|
||||
if cap not in known_capabilities:
|
||||
errors.append(f"Unknown capability: '{cap}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_api_version(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate api_version compatibility.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
error = _check_api_version_compatibility(manifest.api_version)
|
||||
if error:
|
||||
errors.append(error)
|
||||
return errors
|
||||
|
||||
|
||||
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
|
||||
"""Load and validate a plugin from a directory.
|
||||
|
||||
The plugin directory must contain an __init__.py that exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
plugin_id = plugin_dir.name
|
||||
errors: list[str] = []
|
||||
|
||||
# Check if directory exists
|
||||
if not plugin_dir.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Plugin directory does not exist: {plugin_dir}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Check for __init__.py
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=("Missing __init__.py in plugin directory",),
|
||||
),
|
||||
)
|
||||
|
||||
# Import the module
|
||||
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
|
||||
try:
|
||||
# Remove from cache if already imported (for testing)
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, init_file, submodule_search_locations=[]
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to create module spec for {init_file}",),
|
||||
),
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
# Clean up module from sys.modules on import failure
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to import plugin module: {e}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Validate manifest
|
||||
manifest_errors = _validate_manifest(module, plugin_dir)
|
||||
errors.extend(manifest_errors)
|
||||
|
||||
# If manifest is valid, perform additional validation
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if isinstance(manifest, PluginManifest):
|
||||
# Validate api_version
|
||||
api_errors = _validate_api_version(manifest)
|
||||
errors.extend(api_errors)
|
||||
|
||||
# Validate capabilities
|
||||
cap_errors = _validate_capabilities(manifest)
|
||||
errors.extend(cap_errors)
|
||||
|
||||
# Use manifest id if available
|
||||
plugin_id = manifest.id
|
||||
|
||||
# Check if any errors occurred
|
||||
if errors:
|
||||
# Clean up module from sys.modules
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=tuple(errors),
|
||||
),
|
||||
)
|
||||
|
||||
# Get MODEL_REQUIREMENTS
|
||||
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
|
||||
return PluginLoadResult(
|
||||
success=True,
|
||||
manifest=manifest,
|
||||
model_requirements=model_requirements,
|
||||
create_engine=create_engine,
|
||||
module=module,
|
||||
)
|
||||
|
||||
|
||||
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
|
||||
"""Discover and load plugins from multiple directories.
|
||||
|
||||
Args:
|
||||
plugin_dirs: List of directories to scan for plugins.
|
||||
|
||||
Returns:
|
||||
List of PluginLoadResult, one per plugin directory found.
|
||||
"""
|
||||
results: list[PluginLoadResult] = []
|
||||
|
||||
for plugin_dir in plugin_dirs:
|
||||
if not plugin_dir.exists():
|
||||
continue
|
||||
|
||||
# Scan for subdirectories (each is a potential plugin)
|
||||
for item in sorted(plugin_dir.iterdir()):
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
result = load_plugin_from_dir(item)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_plugin(
|
||||
plugin_dir: Path,
|
||||
) -> PluginLoadResult:
|
||||
"""Load a single plugin from a directory.
|
||||
|
||||
This is the main entry point for loading a plugin.
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
return load_plugin_from_dir(plugin_dir)
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Plugin manifest types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains static metadata types that describe plugins.
|
||||
These types have no dependencies and are immutable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormatManifest:
|
||||
"""Manifest describing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio.
|
||||
extension: File extension.
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnumOption:
|
||||
"""Manifest describing an enum option for a parameter.
|
||||
|
||||
Attributes:
|
||||
value: The enum value.
|
||||
label: Human-readable label.
|
||||
"""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterManifest:
|
||||
"""Manifest describing a synthesis parameter.
|
||||
|
||||
Attributes:
|
||||
id: Parameter identifier.
|
||||
name: Human-readable name.
|
||||
description: Parameter description.
|
||||
type: Parameter type ("float", "int", "string", "boolean", "enum").
|
||||
default: Default value.
|
||||
min: Minimum value (optional, for numeric types).
|
||||
max: Maximum value (optional, for numeric types).
|
||||
step: Step size (optional, for numeric types).
|
||||
options: Available options (optional, for enum type).
|
||||
unit: Unit of measurement (optional).
|
||||
group: Parameter group (optional).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
type: str
|
||||
default: Any
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
step: float | None = None
|
||||
options: tuple[EnumOption, ...] = field(default_factory=tuple)
|
||||
unit: str | None = None
|
||||
group: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceManifest:
|
||||
"""Manifest describing a voice.
|
||||
|
||||
Attributes:
|
||||
id: Voice identifier.
|
||||
name: Human-readable name.
|
||||
tags: Voice tags (e.g., language, style).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSourceManifest:
|
||||
"""Manifest describing a voice source.
|
||||
|
||||
Attributes:
|
||||
id: Voice source identifier.
|
||||
name: Human-readable name.
|
||||
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
|
||||
config: Source-specific configuration.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
config: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineManifest:
|
||||
"""Manifest describing engine capabilities.
|
||||
|
||||
Attributes:
|
||||
voiceSources: Available voice sources.
|
||||
parameters: Available synthesis parameters.
|
||||
audioFormats: Supported audio formats.
|
||||
"""
|
||||
|
||||
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
|
||||
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
|
||||
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpuRequirement:
|
||||
"""Manifest describing GPU requirements.
|
||||
|
||||
Attributes:
|
||||
required: Whether GPU is required.
|
||||
type: GPU type (e.g., "cuda", "rocm").
|
||||
memory: Required GPU memory in GB.
|
||||
"""
|
||||
|
||||
required: bool = False
|
||||
type: str | None = None
|
||||
memory: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequirementManifest:
|
||||
"""Manifest describing plugin requirements.
|
||||
|
||||
Attributes:
|
||||
gpu: GPU requirements (optional).
|
||||
memory: Required RAM in GB (optional).
|
||||
internet: Whether internet is required (optional).
|
||||
"""
|
||||
|
||||
gpu: GpuRequirement | None = None
|
||||
memory: float | None = None
|
||||
internet: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelManifest:
|
||||
"""Manifest describing a model requirement.
|
||||
|
||||
Attributes:
|
||||
id: Model identifier.
|
||||
name: Human-readable name.
|
||||
size: Model size as string (e.g., "100MB", "2GB").
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
size: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
"""Main manifest for a TTS plugin.
|
||||
|
||||
Attributes:
|
||||
id: Plugin identifier (unique).
|
||||
name: Human-readable name.
|
||||
version: Plugin version.
|
||||
api_version: API version (semver format: MAJOR.MINOR).
|
||||
description: Plugin description.
|
||||
author: Plugin author.
|
||||
capabilities: List of capability identifiers.
|
||||
requires: Plugin requirements.
|
||||
engine: Engine manifest.
|
||||
voices: Optional static voice catalog. None = not declared (use VoiceLister),
|
||||
empty tuple = explicitly no static voices, non-empty = static catalog.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
api_version: str
|
||||
description: str
|
||||
author: str
|
||||
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||
requires: RequirementManifest = field(default_factory=RequirementManifest)
|
||||
engine: EngineManifest = field(default_factory=EngineManifest)
|
||||
voices: tuple[VoiceManifest, ...] | None = None
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Plugin contract for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the plugin contract that all TTS plugins must implement.
|
||||
Each plugin must export:
|
||||
- PLUGIN_MANIFEST: PluginManifest instance
|
||||
- MODEL_REQUIREMENTS: list of ModelManifest instances
|
||||
- create_engine(): Factory function that creates an Engine
|
||||
|
||||
The create_engine() function is the entry point for plugin activation.
|
||||
It must be atomic: succeed fully or raise and clean up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Plugin(Protocol):
|
||||
"""Protocol defining the plugin contract.
|
||||
|
||||
Every TTS plugin must implement this protocol by exporting:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
||||
"""
|
||||
|
||||
def create_engine(
|
||||
self,
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create an engine instance.
|
||||
|
||||
This is the factory function that creates an Engine from a plugin.
|
||||
It must be atomic: succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for cloud/no-model engines.
|
||||
config: Engine initialization settings.
|
||||
|
||||
Returns:
|
||||
A fully initialized Engine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
...
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Plugin Manager
|
||||
|
||||
Provides a simple interface for consumers to access TTS engines via the
|
||||
new Plugin Architecture. Discovers, loads, and manages plugins from the
|
||||
plugins directory.
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
||||
session = engine.create_session()
|
||||
try:
|
||||
result = session.synthesize("Hello world")
|
||||
finally:
|
||||
session.dispose()
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import AudioFormat
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages TTS plugins and provides a simple interface for consumers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plugins: Dict[str, dict] = {}
|
||||
self._engines: Dict[str, Engine] = {}
|
||||
self._loaded = False
|
||||
|
||||
def discover(self, plugins_dir: str = "plugins") -> None:
|
||||
"""Discover and load all plugins from the given directory."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
|
||||
self._plugins.clear()
|
||||
self._engines.clear()
|
||||
|
||||
plugins_path = Path(plugins_dir)
|
||||
if not plugins_path.exists():
|
||||
if plugins_dir == "plugins":
|
||||
plugins_path = Path(__file__).resolve().parent.parent.parent / "plugins"
|
||||
if not plugins_path.exists():
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
for entry in plugins_path.iterdir():
|
||||
if entry.is_dir() and (entry / "__init__.py").exists():
|
||||
try:
|
||||
result = load_plugin_from_dir(entry)
|
||||
if result.success and result.manifest is not None:
|
||||
self._plugins[result.manifest.id] = {
|
||||
"manifest": result.manifest,
|
||||
"create_engine": result.create_engine,
|
||||
"module": result.module,
|
||||
}
|
||||
except Exception as e:
|
||||
# Log error but continue with other plugins
|
||||
print(f"Warning: Failed to load plugin from {entry}: {e}")
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure plugins have been discovered."""
|
||||
if not self._loaded:
|
||||
self.discover()
|
||||
|
||||
def list_plugins(self) -> List[PluginManifest]:
|
||||
"""Return manifests for all loaded plugins."""
|
||||
self._ensure_loaded()
|
||||
return [info["manifest"] for info in self._plugins.values()]
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[dict]:
|
||||
"""Get plugin info by ID."""
|
||||
self._ensure_loaded()
|
||||
return self._plugins.get(plugin_id)
|
||||
|
||||
def has_plugin(self, plugin_id: str) -> bool:
|
||||
"""Check if a plugin is loaded."""
|
||||
self._ensure_loaded()
|
||||
return plugin_id in self._plugins
|
||||
|
||||
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Create an engine instance for the given plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: The plugin identifier (e.g., "kokoro")
|
||||
**kwargs: Arguments passed to the engine constructor
|
||||
|
||||
Returns:
|
||||
An Engine instance
|
||||
|
||||
Raises:
|
||||
KeyError: If plugin_id is not found
|
||||
Exception: If engine creation fails
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if plugin_id not in self._plugins:
|
||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
create_engine_func = plugin_info["create_engine"]
|
||||
|
||||
# Create engine using the plugin's factory
|
||||
engine = create_engine_func(**kwargs)
|
||||
return engine
|
||||
|
||||
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Get an existing engine or create a new one.
|
||||
|
||||
Engines are cached by plugin_id. If you need multiple instances
|
||||
with different parameters, use create_engine() directly.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
cache_key = plugin_id
|
||||
if cache_key in self._engines:
|
||||
return self._engines[cache_key]
|
||||
|
||||
engine = self.create_engine(plugin_id, **kwargs)
|
||||
self._engines[cache_key] = engine
|
||||
return engine
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all cached engines."""
|
||||
for engine in self._engines.values():
|
||||
try:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass # dispose() should never raise
|
||||
self._engines.clear()
|
||||
|
||||
|
||||
# Global singleton
|
||||
_manager: Optional[PluginManager] = None
|
||||
|
||||
|
||||
def get_plugin_manager() -> PluginManager:
|
||||
"""Get the global PluginManager instance."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = PluginManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def reset_plugin_manager() -> None:
|
||||
"""Reset the global PluginManager (for testing)."""
|
||||
global _manager
|
||||
if _manager is not None:
|
||||
_manager.dispose_all()
|
||||
_manager = None
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Core domain types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains immutable value objects that form the core domain.
|
||||
These types have zero dependencies and are used across the plugin system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormat:
|
||||
"""Immutable value object representing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
||||
extension: File extension (e.g., "wav", "mp3").
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Duration:
|
||||
"""Immutable value object representing a time duration.
|
||||
|
||||
Attributes:
|
||||
seconds: Duration in seconds.
|
||||
"""
|
||||
|
||||
seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSelection:
|
||||
"""Immutable value object for voice selection. Opaque to engine.
|
||||
|
||||
Attributes:
|
||||
source: Voice source identifier (e.g., "builtin", "clone").
|
||||
key: Voice key within the source.
|
||||
payload: Optional payload for clone/blend sources.
|
||||
"""
|
||||
|
||||
source: str
|
||||
key: str
|
||||
payload: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterValues:
|
||||
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
||||
|
||||
Attributes:
|
||||
values: Mapping of parameter names to their values.
|
||||
"""
|
||||
|
||||
values: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesisRequest:
|
||||
"""Immutable value object for a synthesis request.
|
||||
|
||||
Attributes:
|
||||
text: Text to synthesize.
|
||||
voice: Voice selection.
|
||||
parameters: Synthesis parameters.
|
||||
format: Desired audio output format.
|
||||
"""
|
||||
|
||||
text: str
|
||||
voice: VoiceSelection
|
||||
parameters: ParameterValues
|
||||
format: AudioFormat
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesizedAudio:
|
||||
"""Immutable value object for synthesized audio result.
|
||||
|
||||
Attributes:
|
||||
data: Raw audio bytes.
|
||||
format: Audio format of the result.
|
||||
duration: Duration of the audio.
|
||||
"""
|
||||
|
||||
data: bytes
|
||||
format: AudioFormat
|
||||
duration: Duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineConfig:
|
||||
"""Immutable configuration of an Engine instance.
|
||||
|
||||
Contains parameters that define how a particular Engine instance is
|
||||
created and that remain constant throughout the lifetime of that Engine.
|
||||
|
||||
Plugin implementations may ignore fields that are not applicable to them.
|
||||
|
||||
Attributes:
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
||||
Plugins that do not require a language code ignore this field.
|
||||
"""
|
||||
|
||||
device: str = "cpu"
|
||||
lang_code: str = "a"
|
||||
@@ -1,241 +0,0 @@
|
||||
"""TTS Plugin Architecture — direct utility functions.
|
||||
|
||||
Provides helpers that replace the former compatibility adapter by
|
||||
calling the Plugin Manager directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
|
||||
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
||||
"""Return the voice-id tuple for *plugin_id*.
|
||||
|
||||
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
||||
First checks plugin manifest for static voice catalog.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
if not manager.has_plugin(plugin_id):
|
||||
return ()
|
||||
|
||||
# Check manifest for static voice catalog
|
||||
plugin_info = manager.get_plugin(plugin_id)
|
||||
if plugin_info is not None:
|
||||
manifest = plugin_info.get("manifest")
|
||||
if manifest is not None and manifest.voices is not None:
|
||||
return tuple(v.id for v in manifest.voices)
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
try:
|
||||
engine = manager.create_engine(
|
||||
plugin_id,
|
||||
context=ctx,
|
||||
model_path=None,
|
||||
config=EngineConfig(device="cpu"),
|
||||
)
|
||||
except Exception:
|
||||
return ()
|
||||
|
||||
try:
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
|
||||
if isinstance(engine, VoiceLister):
|
||||
manifests = engine.listVoices("builtin")
|
||||
return tuple(v.id for v in manifests)
|
||||
return ()
|
||||
except Exception:
|
||||
return ()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
||||
"""Return the first voice of *plugin_id*, or *fallback*."""
|
||||
voices = get_voices(plugin_id)
|
||||
return voices[0] if voices else fallback
|
||||
|
||||
|
||||
def is_plugin_registered(plugin_id: str) -> bool:
|
||||
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
||||
return get_plugin_manager().has_plugin(plugin_id)
|
||||
|
||||
|
||||
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
||||
"""Determine which plugin owns the given voice specification.
|
||||
|
||||
Resolution rules:
|
||||
1. Empty spec -> fallback
|
||||
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||
3. Exact voice-id match against loaded plugins -> plugin id
|
||||
4. Unknown voice -> fallback
|
||||
"""
|
||||
raw = str(spec or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
|
||||
if "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
|
||||
upper = raw.upper()
|
||||
manager = get_plugin_manager()
|
||||
|
||||
for manifest in manager.list_plugins():
|
||||
for voice_source in manifest.engine.voiceSources:
|
||||
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
||||
try:
|
||||
engine = manager.create_engine(manifest.id)
|
||||
try:
|
||||
if hasattr(engine, "listVoices"):
|
||||
voice_manifests = engine.listVoices(voice_source.id)
|
||||
voice_ids = [v.id.upper() for v in voice_manifests]
|
||||
if upper in voice_ids:
|
||||
return manifest.id
|
||||
finally:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Callable wrapper around Engine / EngineSession.
|
||||
|
||||
Presents the same interface that old callers expect::
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
||||
audio = segment.audio
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
||||
self._engine = engine
|
||||
self._engine_kwargs = engine_kwargs
|
||||
self._session: Any = None
|
||||
|
||||
def _ensure_session(self) -> Any:
|
||||
if self._session is None:
|
||||
self._session = self._engine.createSession()
|
||||
return self._session
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
speed: float = 1.0,
|
||||
split_pattern: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Any]:
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
session = self._ensure_session()
|
||||
|
||||
params: dict[str, Any] = {"speed": speed}
|
||||
if split_pattern is not None:
|
||||
params["split_pattern"] = split_pattern
|
||||
params.update(kwargs)
|
||||
|
||||
request = SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice),
|
||||
parameters=ParameterValues(values=params),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
result = session.synthesize(request)
|
||||
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
yield Segment(graphemes=text, audio=audio_array)
|
||||
|
||||
def load_single_voice(self, voice_name: str) -> Any:
|
||||
engine_pipeline = getattr(self._engine, '_pipeline', None)
|
||||
if engine_pipeline is not None and hasattr(engine_pipeline, 'load_single_voice'):
|
||||
return engine_pipeline.load_single_voice(voice_name)
|
||||
raise AttributeError(f"load_single_voice not available on {type(self._engine).__name__}")
|
||||
|
||||
def dispose(self) -> None:
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.dispose()
|
||||
|
||||
|
||||
def create_pipeline(
|
||||
plugin_id: str,
|
||||
*,
|
||||
lang_code: str = "a",
|
||||
device: str = "cpu",
|
||||
) -> Pipeline:
|
||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
||||
|
||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
||||
``__call__`` interface matches the callable protocol used by consumers.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||
lang_code: Language code for the engine.
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
|
||||
Returns:
|
||||
A callable Pipeline instance.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
config = EngineConfig(device=device, lang_code=lang_code)
|
||||
|
||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
||||
return Pipeline(engine)
|
||||
@@ -1,25 +1,31 @@
|
||||
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
|
||||
|
||||
This module provides the SuperTonicPipeline class and supporting utilities
|
||||
used by the SuperTonic plugin. It is independent of the legacy
|
||||
abogen.tts_backends module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupertonicSegment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
|
||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
||||
arr = np.asarray(wav, dtype="float32")
|
||||
if arr.ndim == 2:
|
||||
# (n, 1) or (1, n) or (n, channels)
|
||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
||||
arr = arr.reshape(-1)
|
||||
else:
|
||||
@@ -56,6 +62,7 @@ def _split_text(
|
||||
else:
|
||||
parts = [stripped]
|
||||
|
||||
# Enforce max length by hard-splitting long parts.
|
||||
result: list[str] = []
|
||||
for part in parts:
|
||||
if len(part) <= max_chunk_length:
|
||||
@@ -64,6 +71,7 @@ def _split_text(
|
||||
start = 0
|
||||
while start < len(part):
|
||||
end = min(len(part), start + max_chunk_length)
|
||||
# Try to split at whitespace.
|
||||
if end < len(part):
|
||||
ws = part.rfind(" ", start, end)
|
||||
if ws > start + 40:
|
||||
@@ -82,6 +90,7 @@ _UNSUPPORTED_CHARS_RE = re.compile(
|
||||
|
||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||
|
||||
message = " ".join(
|
||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||
) or str(error)
|
||||
@@ -127,11 +136,16 @@ def _configure_supertonic_gpu() -> None:
|
||||
|
||||
available = ort.get_available_providers()
|
||||
|
||||
# Use CUDA if available, skip TensorRT (requires extra libs not always present)
|
||||
# TensorrtExecutionProvider may be listed as available but fail at runtime
|
||||
# if TensorRT libraries (libnvinfer.so) are not installed
|
||||
providers = []
|
||||
if "CUDAExecutionProvider" in available:
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
# Patch supertonic's config and loader before TTS import
|
||||
# We must patch both because loader imports the value at module load time
|
||||
import supertonic.config as supertonic_config
|
||||
import supertonic.loader as supertonic_loader
|
||||
|
||||
@@ -142,16 +156,6 @@ def _configure_supertonic_gpu() -> None:
|
||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||
|
||||
|
||||
class SupertonicSegment:
|
||||
"""A single synthesized audio segment."""
|
||||
|
||||
__slots__ = ("graphemes", "audio")
|
||||
|
||||
def __init__(self, graphemes: str, audio: np.ndarray) -> None:
|
||||
self.graphemes = graphemes
|
||||
self.audio = audio
|
||||
|
||||
|
||||
class SupertonicPipeline:
|
||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||
|
||||
@@ -167,6 +171,7 @@ class SupertonicPipeline:
|
||||
self.total_steps = int(total_steps)
|
||||
self.max_chunk_length = int(max_chunk_length)
|
||||
|
||||
# Configure GPU providers before importing TTS
|
||||
_configure_supertonic_gpu()
|
||||
|
||||
try:
|
||||
@@ -202,6 +207,7 @@ class SupertonicPipeline:
|
||||
removed: set[str] = set()
|
||||
last_exc: Exception | None = None
|
||||
|
||||
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
||||
for attempt in range(3):
|
||||
try:
|
||||
wav, duration = self._tts.synthesize(
|
||||
@@ -225,6 +231,7 @@ class SupertonicPipeline:
|
||||
chunk_to_speak, unsupported
|
||||
).strip()
|
||||
|
||||
# If we didn't change anything, don't loop forever.
|
||||
if sanitized == chunk_to_speak.strip():
|
||||
raise
|
||||
|
||||
@@ -242,6 +249,7 @@ class SupertonicPipeline:
|
||||
sorted(removed),
|
||||
)
|
||||
else:
|
||||
# Exhausted retries.
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
@@ -250,6 +258,7 @@ class SupertonicPipeline:
|
||||
|
||||
audio = _ensure_float32_mono(wav)
|
||||
|
||||
# If duration is present, infer the source sample rate and resample if needed.
|
||||
src_rate = self.sample_rate
|
||||
try:
|
||||
dur = float(duration)
|
||||
+11
-10
@@ -529,20 +529,21 @@ def prevent_sleep_end():
|
||||
_sleep_procs[system] = None
|
||||
|
||||
|
||||
def load_numpy_kpipeline():
|
||||
import numpy as np
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
|
||||
return np, KPipeline
|
||||
|
||||
|
||||
class LoadPipelineThread(Thread):
|
||||
def __init__(self, callback, lang_code="a", use_gpu=True):
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
self.lang_code = lang_code
|
||||
self.use_gpu = use_gpu
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from abogen.domain.pipeline_factory import create_pipeline_for_job
|
||||
|
||||
backend = create_pipeline_for_job(
|
||||
"kokoro", language=self.lang_code, use_gpu=self.use_gpu
|
||||
)
|
||||
self.callback(backend, None)
|
||||
np_module, kpipeline_class = load_numpy_kpipeline()
|
||||
self.callback(np_module, kpipeline_class, None)
|
||||
except Exception as e:
|
||||
self.callback(None, str(e))
|
||||
self.callback(None, None, str(e))
|
||||
|
||||
+3
-12
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
||||
pass
|
||||
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHED_VOICES: Set[str] = set()
|
||||
@@ -26,9 +26,8 @@ _BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
if not voices:
|
||||
return set(kokoro_voices)
|
||||
return set(VOICES_INTERNAL)
|
||||
normalized: Set[str] = set()
|
||||
for voice in voices:
|
||||
if not voice:
|
||||
@@ -36,7 +35,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
voice_id = str(voice).strip()
|
||||
if not voice_id:
|
||||
continue
|
||||
if voice_id in kokoro_voices:
|
||||
if voice_id in VOICES_INTERNAL:
|
||||
normalized.add(voice_id)
|
||||
return normalized
|
||||
|
||||
@@ -144,11 +143,3 @@ def _ensure_single_voice_asset(
|
||||
|
||||
hf_hub_download(resume_download=True, **common_kwargs)
|
||||
return True
|
||||
|
||||
|
||||
def clear_voice_cache() -> None:
|
||||
"""Clear the in‑process voice cache (used during shutdown)."""
|
||||
with _CACHE_LOCK:
|
||||
_CACHED_VOICES.clear()
|
||||
global _BOOTSTRAPPED
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
|
||||
# Calls parsing and loads the voice to gpu or cpu
|
||||
@@ -22,7 +22,6 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
raise ValueError("Empty voice formula")
|
||||
|
||||
terms: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for segment in formula.split("+"):
|
||||
part = segment.strip()
|
||||
if not part:
|
||||
@@ -31,7 +30,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
voice_name, raw_weight = part.split("*", 1)
|
||||
voice_name = voice_name.strip()
|
||||
if voice_name not in kokoro_voices:
|
||||
if voice_name not in VOICES_INTERNAL:
|
||||
raise ValueError(f"Unknown voice: {voice_name}")
|
||||
try:
|
||||
weight = float(raw_weight.strip())
|
||||
@@ -72,33 +71,6 @@ def parse_voice_formula(pipeline, formula):
|
||||
return weighted_sum
|
||||
|
||||
|
||||
def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
|
||||
"""Build a voice formula string from (voice_name, weight) pairs.
|
||||
|
||||
Normalizes weights to sum to 1.0 and formats as "voice1*0.5+voice2*0.5".
|
||||
|
||||
Args:
|
||||
pairs: Iterable of (voice_name, weight) tuples. Zero-weight entries
|
||||
are filtered out.
|
||||
|
||||
Returns:
|
||||
Formula string, or None if no valid entries.
|
||||
"""
|
||||
voices = [(voice, float(weight)) for voice, weight in pairs if weight is not None and float(weight) > 0]
|
||||
if not voices:
|
||||
return None
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
def _format_value(value: float) -> str:
|
||||
normalized = value / total if total else 0.0
|
||||
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||
|
||||
parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
def calculate_sum_from_formula(formula):
|
||||
weights = re.findall(r"\* *([\d.]+)", formula)
|
||||
total_sum = sum(float(weight) for weight in weights)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceMetadata:
|
||||
"""
|
||||
Immutable metadata describing a voice from a TTS backend.
|
||||
|
||||
This model describes a voice independently of any backend implementation.
|
||||
Backends populate these objects; the application consumes them.
|
||||
|
||||
The ``backend_id`` field is set by the backend itself (via
|
||||
``self.metadata.id``) — the application never hardcodes it.
|
||||
This ensures renaming a backend does not require touching voice definitions.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""Unique voice identifier within the backend (e.g. ``"af_alloy"``, ``"M1"``)."""
|
||||
|
||||
display_name: str
|
||||
"""Human-readable display name (e.g. ``"Alloy"``, ``"Male 1"``)."""
|
||||
|
||||
language: str
|
||||
"""Language code — backend-specific format is acceptable (e.g. ``"a"``, ``"en"``)."""
|
||||
|
||||
gender: str
|
||||
"""Gender category: ``"female"``, ``"male"``, or ``"unknown"``."""
|
||||
|
||||
backend_id: str
|
||||
"""Identifier of the backend that owns this voice (e.g. ``"kokoro"``).
|
||||
|
||||
Set automatically by the backend — never hardcoded in voice definitions.
|
||||
"""
|
||||
@@ -2,7 +2,8 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
|
||||
@@ -69,8 +70,7 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||
|
||||
def _normalize_supertonic_voice(value: Any) -> str:
|
||||
raw = str(value or "").strip().upper()
|
||||
supertonic_voices = get_voices("supertonic")
|
||||
return raw if raw in supertonic_voices else "M1"
|
||||
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
|
||||
|
||||
|
||||
def _coerce_supertonic_steps(value: Any) -> int:
|
||||
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||
if not is_plugin_registered(provider):
|
||||
if provider not in {"kokoro", "supertonic"}:
|
||||
provider = "kokoro"
|
||||
|
||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||
@@ -135,7 +135,6 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
|
||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
normalized: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for item in entries or []:
|
||||
if isinstance(item, dict):
|
||||
voice = item.get("id") or item.get("voice")
|
||||
@@ -144,7 +143,7 @@ def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
voice, weight = item[0], item[1]
|
||||
else:
|
||||
continue
|
||||
if voice not in kokoro_voices:
|
||||
if voice not in VOICES_INTERNAL:
|
||||
continue
|
||||
if weight is None:
|
||||
continue
|
||||
|
||||
+12
-11
@@ -2,6 +2,7 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv \
|
||||
PATH=/opt/venv/bin:$PATH
|
||||
|
||||
@@ -26,22 +27,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN pip install uv \
|
||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||
uv pip install --system torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||
else \
|
||||
uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||
fi \
|
||||
&& uv pip install --system . \
|
||||
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
|
||||
&& uv pip install --system "mutagen>=1.47.0"
|
||||
|
||||
COPY abogen ./abogen
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||
else \
|
||||
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||
fi \
|
||||
&& pip install --no-cache-dir . \
|
||||
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
|
||||
&& pip install --no-cache-dir "mutagen>=1.47.0"
|
||||
|
||||
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
|
||||
# Set USE_GPU=false to skip this for CPU-only deployments
|
||||
RUN if [ "$USE_GPU" = "true" ]; then \
|
||||
uv pip install --system onnxruntime-gpu; \
|
||||
pip install --no-cache-dir onnxruntime-gpu; \
|
||||
fi
|
||||
|
||||
ENV ABOGEN_HOST=0.0.0.0 \
|
||||
|
||||
+4
-9
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -7,8 +8,6 @@ from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir
|
||||
|
||||
from .conversion_runner import run_conversion_job
|
||||
@@ -84,12 +83,6 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
"UPLOAD_FOLDER": str(uploads_dir),
|
||||
"OUTPUT_FOLDER": str(outputs_dir),
|
||||
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
||||
# Large books can submit four form fields per chapter. Werkzeug's
|
||||
# defaults reject those requests before the wizard route can process
|
||||
# them, even though the encoded payload is much smaller than the upload
|
||||
# limit above.
|
||||
"MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024,
|
||||
"MAX_FORM_PARTS": 10_000,
|
||||
}
|
||||
if config:
|
||||
base_config.update(config)
|
||||
@@ -120,6 +113,8 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
app.register_blueprint(books_bp, url_prefix="/find-books")
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
|
||||
atexit.register(service.shutdown)
|
||||
|
||||
global _access_log_filter_attached
|
||||
if not _access_log_filter_attached:
|
||||
logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter())
|
||||
@@ -137,4 +132,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""WebUI adapter: Job -> ConversionRequest.
|
||||
|
||||
Converts a WebUI Job into a ConversionRequest that the application layer can process.
|
||||
This adapter is the bridge between the WebUI layer and the application/domain layer.
|
||||
|
||||
The adapter is responsible for:
|
||||
- Mapping Job fields to ConversionRequest fields
|
||||
- Handling UI-specific state (logs, progress, cancellation)
|
||||
- Providing PipelineProvider and VoiceResolver implementations
|
||||
|
||||
All conversions happen through this adapter — the application layer
|
||||
never accesses Job directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from abogen.application.conversion_request import ConversionRequest
|
||||
from abogen.application.conversion_ports import ConversionCancelled, ResolvedVoice
|
||||
|
||||
|
||||
def build_conversion_request_from_job(job: Any) -> ConversionRequest:
|
||||
"""Convert a WebUI Job into a ConversionRequest.
|
||||
|
||||
This is the primary function that maps Job fields to ConversionRequest.
|
||||
All fields are copied — the request is independent of the Job.
|
||||
|
||||
Args:
|
||||
job: WebUI Job instance
|
||||
|
||||
Returns:
|
||||
ConversionRequest with all Job data mapped
|
||||
"""
|
||||
return ConversionRequest(
|
||||
# Source
|
||||
source_path=Path(job.stored_path) if job.stored_path else None,
|
||||
original_filename=job.original_filename,
|
||||
# TTS Settings
|
||||
language=job.language,
|
||||
tts_provider=job.tts_provider,
|
||||
voice=job.voice,
|
||||
voice_profile=job.voice_profile,
|
||||
speed=job.speed,
|
||||
use_gpu=job.use_gpu,
|
||||
supertonic_total_steps=job.supertonic_total_steps,
|
||||
# Output Format
|
||||
output_format=job.output_format,
|
||||
subtitle_mode=job.subtitle_mode,
|
||||
subtitle_format=job.subtitle_format,
|
||||
max_subtitle_words=job.max_subtitle_words,
|
||||
# Save Options
|
||||
save_mode=job.save_mode,
|
||||
output_folder=Path(job.output_folder) if job.output_folder else None,
|
||||
save_chapters_separately=job.save_chapters_separately,
|
||||
merge_chapters_at_end=job.merge_chapters_at_end,
|
||||
separate_chapters_format=job.separate_chapters_format,
|
||||
save_as_project=job.save_as_project,
|
||||
# Timing
|
||||
silence_between_chapters=job.silence_between_chapters,
|
||||
chapter_intro_delay=job.chapter_intro_delay,
|
||||
# Content Processing
|
||||
replace_single_newlines=job.replace_single_newlines,
|
||||
read_title_intro=job.read_title_intro,
|
||||
read_closing_outro=job.read_closing_outro,
|
||||
auto_prefix_chapter_titles=job.auto_prefix_chapter_titles,
|
||||
normalize_chapter_opening_caps=job.normalize_chapter_opening_caps,
|
||||
# Pronunciation / Normalization
|
||||
pronunciation_overrides=job.pronunciation_overrides or [],
|
||||
manual_overrides=job.manual_overrides or [],
|
||||
heteronym_overrides=job.heteronym_overrides or [],
|
||||
normalization_overrides=job.normalization_overrides or {},
|
||||
# Chapter/Chunk Configuration
|
||||
chapter_overrides=job.chapters or [],
|
||||
chunks=job.chunks or [],
|
||||
chunk_level=job.chunk_level,
|
||||
speaker_mode=job.speaker_mode,
|
||||
speakers=job.speakers or {},
|
||||
# Metadata
|
||||
metadata_tags=job.metadata_tags or {},
|
||||
# Artifacts
|
||||
cover_image_path=Path(job.cover_image_path) if job.cover_image_path else None,
|
||||
cover_image_mime=job.cover_image_mime,
|
||||
generate_epub3=job.generate_epub3,
|
||||
)
|
||||
|
||||
|
||||
class WebJobEvents:
|
||||
"""WebUI implementation of ConversionEvents protocol.
|
||||
|
||||
Wraps a Job to provide logging, progress, and cancellation.
|
||||
"""
|
||||
|
||||
def __init__(self, job: Any):
|
||||
self._job = job
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message to the Job."""
|
||||
self._job.add_log(message, level=level)
|
||||
|
||||
def progress(self, pct: int, etr: str) -> None:
|
||||
"""Update progress on the Job."""
|
||||
self._job.progress = pct / 100.0
|
||||
self._job.etr_str = etr
|
||||
|
||||
def check_cancelled(self) -> None:
|
||||
"""Check if the Job was cancelled.
|
||||
|
||||
Raises:
|
||||
ConversionCancelled: If cancellation was requested
|
||||
"""
|
||||
if self._job.cancel_requested:
|
||||
raise ConversionCancelled("Job cancelled by user")
|
||||
|
||||
|
||||
class WebPipelineProvider:
|
||||
"""WebUI implementation of PipelineProvider protocol.
|
||||
|
||||
Wraps PipelinePool to provide TTS backends.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline_pool: Any):
|
||||
self._pool = pipeline_pool
|
||||
|
||||
def get(self, provider: str, language: str, use_gpu: bool) -> Any:
|
||||
"""Get a TTS backend instance."""
|
||||
return self._pool.get(provider, language, use_gpu)
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all backend resources."""
|
||||
self._pool.dispose_all()
|
||||
|
||||
|
||||
class WebVoiceResolver:
|
||||
"""WebUI implementation of VoiceResolver protocol.
|
||||
|
||||
Wraps the voice resolution logic from conversion_runner.py.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolve_fn: Callable[[str], tuple[str, str, Any, Optional[float], Optional[int]]],
|
||||
):
|
||||
"""Initialize with a voice resolution function.
|
||||
|
||||
Args:
|
||||
resolve_fn: Function that takes a voice_spec and returns
|
||||
(provider, resolved_spec, voice_choice, speed, steps)
|
||||
"""
|
||||
self._resolve_fn = resolve_fn
|
||||
|
||||
def resolve(self, voice_spec: str) -> ResolvedVoice:
|
||||
"""Resolve a voice spec into a loaded voice."""
|
||||
provider, resolved_spec, voice, speed, steps = self._resolve_fn(voice_spec)
|
||||
return ResolvedVoice(
|
||||
provider=provider,
|
||||
resolved_spec=resolved_spec,
|
||||
voice=voice,
|
||||
speed=speed or 1.0,
|
||||
supertonic_steps=steps or 5,
|
||||
)
|
||||
+2017
-335
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user