Compare commits

..
Author SHA1 Message Date
Deniz Şafak 6bd2c109d8 flet ui (experimental) 2026-05-24 14:58:41 +03:00
141 changed files with 9413 additions and 15199 deletions
-15
View File
@@ -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
+12 -32
View File
@@ -1,9 +1,7 @@
name: CI name: pip install
run-name: CI run-name: pip install
on:
on:
push: push:
branches: [main]
paths: paths:
- '**.py' - '**.py'
- 'pyproject.toml' - 'pyproject.toml'
@@ -13,41 +11,23 @@ on:
- 'pyproject.toml' - 'pyproject.toml'
- '.github/workflows/**' - '.github/workflows/**'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test: install-and-run:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-14, windows-latest] os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.12'] python-version: ['3.12']
fail-fast: false fail-fast: false
continue-on-error: true
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install from repository
- name: Install uv run: python -m pip install .
uses: astral-sh/setup-uv@v8.3.1 #- name: Run abogen
with: # run: abogen
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
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v4
- name: Login to Github Container Registry - name: Login to Github Container Registry
# Only if we need to push an image # Only if we need to push an image
+539 -437
View File
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -1,31 +1,31 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <!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 --> <!-- 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" <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"> viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#808080;} .st0{fill:#808080;}
</style> </style>
<g> <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 <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-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-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 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 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-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 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 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 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 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 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 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 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 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 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 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 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 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"/> C339.815,267.771,320.972,313.262,281.292,329.698z"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+58
View File
@@ -63,6 +63,64 @@ SUPPORTED_INPUT_FORMATS = [
# 384 if self.lang_code in 'ab': # 384 if self.lang_code in 'ab':
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys()) 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 # Voice and sample text mapping
SAMPLE_VOICE_TEXTS = { SAMPLE_VOICE_TEXTS = {
"a": "This is a sample of the selected voice.", "a": "This is a sample of the selected voice.",
-118
View File
@@ -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
-92
View File
@@ -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
-204
View File
@@ -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
-75
View File
@@ -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()
-31
View File
@@ -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"
-136
View File
@@ -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)
-138
View File
@@ -1,138 +0,0 @@
from __future__ import annotations
import re
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()
-23
View File
@@ -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
-30
View File
@@ -1,30 +0,0 @@
"""Text normalization convenience helpers."""
from __future__ import annotations
from typing import Any, 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()
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)
-91
View File
@@ -1,91 +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 re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from abogen.text_extractor import ExtractedChapter
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
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_output_stem(name: str) -> 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)
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 == "Save to Desktop" and desktop_dir:
return desktop_dir
if save_mode == "Save next to input file":
return stored_path.parent
if save_mode == "Choose output folder" and output_folder:
return Path(output_folder)
if save_mode == "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
-261
View File
@@ -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())
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
"""Unified split pattern logic extracted from 3 copies."""
import re
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
"""
# For English, always use newline splitting only
if language in ("a", "b"):
return "\n"
# Determine spacing pattern based on language
spacing = r"\s*" if language in ("z", "j") else r"\s+"
# For CJK languages, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting.
if subtitle_mode in ("Disabled", "Line") and language in ("z", "j"):
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
if subtitle_mode == "Line":
return "\n"
elif subtitle_mode == "Sentence":
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
elif subtitle_mode == "Sentence + Comma":
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
else:
return r"\n+"
-97
View File
@@ -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()
-190
View File
@@ -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
-97
View File
@@ -1,97 +0,0 @@
from __future__ import annotations
from typing import Any, 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)
+8
View File
@@ -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"]
+32
View File
@@ -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",
]
+630
View File
@@ -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)
+365
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
"""State sub-package exports AppState and ConversionJob."""
from .app_state import AppState, ConversionJob
__all__ = ["AppState", "ConversionJob"]
+451
View File
@@ -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 = []
+38
View File
@@ -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",
]
+462
View File
@@ -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
+313
View File
@@ -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
+264
View File
@@ -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
+6
View File
@@ -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"]
+587
View File
@@ -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()
+154
View File
@@ -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.")
+305
View File
@@ -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.")
-673
View File
@@ -1,673 +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.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."""
tags = self._normalize_metadata_casefold(getattr(job, "metadata_tags", {}))
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
title = self._first_nonempty(
tags.get("title"),
tags.get("book_title"),
tags.get("name"),
tags.get("album"),
filename,
)
authors = self._split_people_field(
tags.get("authors")
or tags.get("author")
or tags.get("album_artist")
or tags.get("artist")
)
narrators = self._split_people_field(tags.get("narrators") or tags.get("narrator"))
description = self._first_nonempty(
tags.get("description"), tags.get("summary"), tags.get("comment")
)
genres = self._split_simple_list(tags.get("genre"))
keywords = self._split_simple_list(tags.get("tags") or tags.get("keywords"))
language = self._first_nonempty(tags.get("language"), tags.get("lang")) or getattr(job, "language", "") or ""
series_name = self._first_nonempty(
tags.get("series"),
tags.get("series_name"),
tags.get("seriesname"),
tags.get("series_title"),
tags.get("seriestitle"),
)
series_sequence = None
for key in ("series_index", "series_position", "series_sequence", "series_number", "seriesnumber", "book_number", "booknumber"):
raw = tags.get(key)
normalized = self._normalize_series_sequence(raw)
if normalized:
series_sequence = normalized
break
if not series_name:
series_sequence = None
data = {
"title": title,
"subtitle": tags.get("subtitle"),
"authors": authors,
"narrators": narrators,
"description": description,
"publisher": tags.get("publisher"),
"genres": genres,
"tags": keywords,
"language": language,
"publishedYear": self._extract_year(
tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")
),
"seriesName": series_name,
"seriesSequence": series_sequence,
"isbn": self._first_nonempty(tags.get("isbn"), tags.get("asin")),
}
published_date = self._first_nonempty(
tags.get("published"), tags.get("publication_date"), tags.get("date")
)
if published_date:
data["publishedDate"] = published_date
rating_text = self._first_nonempty(tags.get("rating"), tags.get("my_rating"))
if rating_text:
try:
data["rating"] = float(str(rating_text).strip())
except ValueError:
pass
rating_max_text = self._first_nonempty(tags.get("rating_max"), tags.get("rating_scale"))
if rating_max_text:
try:
data["ratingMax"] = float(str(rating_max_text).strip())
except ValueError:
pass
# Remove empty values
cleaned = {}
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(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))
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 = []
for entry in chapters:
if not isinstance(entry, Mapping):
continue
title = self._first_nonempty(entry.get("title"), entry.get("original_title"))
start = entry.get("start")
end = entry.get("end")
if title is None or not isinstance(start, (int, float)):
continue
chapter_payload = {"title": title, "start": float(start)}
if isinstance(end, (int, float)):
chapter_payload["end"] = float(end)
cleaned.append(chapter_payload)
return cleaned or None
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:
# Load from job or global config
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 _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
normalized = {}
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
@staticmethod
def _split_people_field(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
results = []
for item in raw:
results.extend(ExportService._split_people_field(item))
return results
text = str(raw or "").strip()
if not text:
return []
import re
tokens = [token.strip() for token in re.split(r"[;,/&]|\band\b", text, flags=re.IGNORECASE) if token.strip()]
seen = set()
ordered = []
for token in tokens:
key = token.casefold()
if key in seen:
continue
seen.add(key)
ordered.append(token)
return ordered
@staticmethod
def _split_simple_list(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple, set)):
results = []
for item in raw:
results.extend(ExportService._split_simple_list(item))
return results
text = str(raw or "").strip()
if not text:
return []
import re
tokens = [token.strip() for token in re.split(r"[;,\n]", text) if token.strip()]
seen = set()
ordered = []
for token in tokens:
key = token.casefold()
if key in seen:
continue
seen.add(key)
ordered.append(token)
return ordered
@staticmethod
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
@staticmethod
def _extract_year(raw: Optional[str]) -> Optional[int]:
if not raw:
return None
text = str(raw).strip()
if not text:
return None
import re
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
@staticmethod
def _normalize_series_sequence(raw: Any) -> Optional[str]:
if raw is None:
return None
if isinstance(raw, (int, float)):
if isinstance(raw, float) and (raw != raw or raw == float("inf") or raw == float("-inf")):
return None
text = str(raw)
else:
text = str(raw).strip()
if not text:
return None
candidate = text.replace(",", ".")
import re
match = re.search(r"\d+(?:\.\d+)?", 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:
cleaned = normalized.lstrip("0")
return cleaned or "0"
@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",
]
-303
View File
@@ -1,303 +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.subtitle_utils import clean_subtitle_text
class SubtitleFormat(Enum):
SRT = "srt"
ASS = "ass"
VTT = "vtt"
class SubtitleMode(Enum):
DISABLED = "Disabled"
LINE = "Line"
SENTENCE = "Sentence"
SENTENCE_COMMA = "Sentence + Comma"
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
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}")
__all__ = [
"SubtitleFormat",
"SubtitleMode",
"SubtitleAlignment",
"SubtitleConfig",
"SubtitleWriter",
"SrtWriter",
"VttWriter",
"AssWriter",
"create_subtitle_writer",
]
+15 -5
View File
@@ -2,14 +2,13 @@
from __future__ import annotations from __future__ import annotations
import atexit
import os import os
import platform import platform
import signal
import sys
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible. from abogen.utils import load_config, prevent_sleep_end
from abogen import shutdown # noqa: F401
shutdown.register_shutdown()
from abogen.utils import load_config
from abogen.webui.app import main as _run_web_ui from abogen.webui.app import main as _run_web_ui
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults). # 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": if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") 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: def main() -> None:
"""Launch the Flask-based web UI.""" """Launch the Flask-based web UI."""
+4 -5
View File
@@ -21,8 +21,7 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS from abogen.constants import COLORS, VOICES_INTERNAL
from abogen.tts_plugin.utils import get_voices
from abogen.spacy_utils import SPACY_MODELS from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker import abogen.hf_tracker
@@ -115,7 +114,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False self._voices_success = False
return return
voice_list = get_voices("kokoro") voice_list = VOICES_INTERNAL
for idx, voice in enumerate(voice_list, start=1): for idx, voice in enumerate(voice_list, start=1):
if self._cancelled: if self._cancelled:
self._voices_success = False self._voices_success = False
@@ -463,14 +462,14 @@ class PreDownloadDialog(QDialog):
try: try:
from huggingface_hub import try_to_load_from_cache 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( if not try_to_load_from_cache(
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
): ):
missing.append(voice) missing.append(voice)
except Exception: except Exception:
# If HF missing, report all as missing # If HF missing, report all as missing
return False, list(get_voices("kokoro")) return False, list(VOICES_INTERNAL)
return (len(missing) == 0), missing return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool: def _check_kokoro_model(self) -> bool:
+237 -100
View File
@@ -5,7 +5,6 @@ import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import numpy as np
import soundfile as sf import soundfile as sf
from abogen.utils import ( from abogen.utils import (
create_process, create_process,
@@ -14,6 +13,7 @@ from abogen.utils import (
) )
from abogen.constants import ( from abogen.constants import (
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SAMPLE_VOICE_TEXTS,
COLORS, COLORS,
CHAPTER_OPTIONS_COUNTDOWN, CHAPTER_OPTIONS_COUNTDOWN,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
@@ -21,18 +21,11 @@ from abogen.constants import (
SUPPORTED_SUBTITLE_FORMATS, SUPPORTED_SUBTITLE_FORMATS,
) )
from abogen.voice_formulas import get_new_voice from abogen.voice_formulas import get_new_voice
from abogen.infrastructure.subtitle_writer import _format_timestamp
from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.output_paths import (
resolve_output_directory,
build_output_path,
sanitize_output_stem,
)
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import static_ffmpeg import static_ffmpeg
import threading # for efficient waiting import threading # for efficient waiting
import subprocess import subprocess
import platform
# Configuration constants # Configuration constants
_USER_RESPONSE_TIMEOUT = ( _USER_RESPONSE_TIMEOUT = (
@@ -49,7 +42,10 @@ from abogen.subtitle_utils import (
get_sample_voice_text, get_sample_voice_text,
sanitize_name_for_os, sanitize_name_for_os,
_CHAPTER_MARKER_SEARCH_PATTERN, _CHAPTER_MARKER_SEARCH_PATTERN,
split_text_by_voice_markers _VOICE_MARKER_PATTERN,
_VOICE_MARKER_SEARCH_PATTERN,
split_text_by_voice_markers,
validate_voice_name,
) )
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
@@ -219,6 +215,40 @@ class ConversionThread(QThread):
PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、," PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、,"
PUNCTUATION_COMMAS = ",,、" PUNCTUATION_COMMAS = ",,、"
def _get_split_pattern(self, lang_code, subtitle_mode):
"""
Get the appropriate split pattern based on language and subtitle mode.
Args:
lang_code: Language code (a, b, e, f, etc.)
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
Returns:
Split pattern string
"""
# For English, always use newline splitting only
if lang_code in ["a", "b"]:
return "\n"
# Determine spacing pattern based on language
spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+"
# For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting.
if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]:
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
if subtitle_mode == "Line":
return "\n"
elif subtitle_mode == "Sentence":
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
elif subtitle_mode == "Sentence + Comma":
return r"(?<=[{}]){}|\n+".format(
self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern
)
else:
return r"\n+" # Default to line breaks
def __init__( def __init__(
self, self,
file_name, file_name,
@@ -229,7 +259,8 @@ class ConversionThread(QThread):
output_folder, output_folder,
subtitle_mode, subtitle_mode,
output_format, output_format,
backend, np_module,
kpipeline_class,
start_time, start_time,
total_char_count, total_char_count,
use_gpu=True, use_gpu=True,
@@ -239,7 +270,8 @@ class ConversionThread(QThread):
super().__init__() super().__init__()
self._chapter_options_event = threading.Event() self._chapter_options_event = threading.Event()
self._timestamp_response_event = threading.Event() self._timestamp_response_event = threading.Event()
self.backend = backend self.np = np_module
self.KPipeline = kpipeline_class
self.file_name = file_name self.file_name = file_name
self.lang_code = lang_code self.lang_code = lang_code
self.speed = speed self.speed = speed
@@ -267,7 +299,7 @@ class ConversionThread(QThread):
self.silence_duration = 2.0 # Default value, will be overridden from GUI self.silence_duration = 2.0 # Default value, will be overridden from GUI
self.use_spacy_segmentation = True # Default, will be overridden from GUI self.use_spacy_segmentation = True # Default, will be overridden from GUI
# Set split pattern based on language and subtitle mode # Set split pattern based on language and subtitle mode
self.split_pattern = get_split_pattern(lang_code, subtitle_mode) self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
self.voice_cache = {} # Cache for loaded voices self.voice_cache = {} # Cache for loaded voices
def load_voice_cached(self, voice_name, tts): def load_voice_cached(self, voice_name, tts):
@@ -458,6 +490,19 @@ class ConversionThread(QThread):
self.log_updated.emit(("\nInitializing TTS pipeline...", "grey")) self.log_updated.emit(("\nInitializing TTS pipeline...", "grey"))
# Set device based on use_gpu setting and platform
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps" # Use MPS for Apple Silicon
else:
device = "cuda" # Use CUDA for other platforms
else:
device = "cpu"
tts = self.KPipeline(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Check if the input is a subtitle file or timestamp text file # Check if the input is a subtitle file or timestamp text file
is_subtitle_file = False is_subtitle_file = False
is_timestamp_text = False is_timestamp_text = False
@@ -493,7 +538,7 @@ class ConversionThread(QThread):
# Process subtitle files separately # Process subtitle files separately
if is_subtitle_file or is_timestamp_text: if is_subtitle_file or is_timestamp_text:
self._process_subtitle_file(self.backend, base_path, is_timestamp_text) self._process_subtitle_file(tts, base_path, is_timestamp_text)
return return
if self.is_direct_text: if self.is_direct_text:
@@ -645,17 +690,15 @@ class ConversionThread(QThread):
base_path = self.display_path if self.display_path else self.file_name base_path = self.display_path if self.display_path else self.file_name
base_name = os.path.splitext(os.path.basename(base_path))[0] base_name = os.path.splitext(os.path.basename(base_path))[0]
# Sanitize base_name for folder/file creation based on OS
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
parent_dir = resolve_output_directory( if self.save_option == "Save to Desktop":
save_mode=self.save_option, parent_dir = user_desktop_dir()
stored_path=Path(base_path), elif self.save_option == "Save next to input file":
output_folder=getattr(self, "output_folder", None), parent_dir = os.path.dirname(base_path)
desktop_dir=Path(user_desktop_dir()), else:
user_output_path=None, parent_dir = self.output_folder or os.getcwd()
user_cache_outputs=Path(os.getcwd()),
)
parent_dir = str(parent_dir)
# Ensure the output folder exists, error if it doesn't # Ensure the output folder exists, error if it doesn't
if not os.path.exists(parent_dir): if not os.path.exists(parent_dir):
self.log_updated.emit( self.log_updated.emit(
@@ -725,40 +768,77 @@ class ConversionThread(QThread):
format=self.output_format, format=self.output_format,
) )
ffmpeg_proc = None ffmpeg_proc = None
elif self.output_format in ("m4b", "opus"): elif self.output_format == "m4b":
# Real-time generation using FFmpeg pipe # Real-time M4B generation using FFmpeg pipe
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
merged_out_file = None merged_out_file = None
ffmpeg_proc = None ffmpeg_proc = None
metadata_options, cover_path = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
if self.output_format == "m4b"
else ([], None)
) )
cmd = build_ffmpeg_command( # Prepare ffmpeg command for m4b output
Path(merged_out_path), cmd = [
self.output_format, "ffmpeg",
"-y",
"-thread_queue_size",
"32768",
"-f",
"f32le",
"-ar",
"24000",
"-ac",
"1",
"-i",
"pipe:0",
]
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"1",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
) )
# Insert thread queue size after ffmpeg header cmd += metadata_options
cmd.insert(2, "-thread_queue_size") cmd.append(merged_out_path)
cmd.insert(3, "32768")
if self.output_format == "m4b" and cover_path and os.path.exists(cover_path):
# Insert cover image input before the output path
output_path = cmd.pop()
cmd.extend([
"-i", cover_path,
"-map", "0:a",
"-map", "1",
"-c:v", "copy",
"-disposition:v", "attached_pic",
])
cmd.extend(metadata_options)
cmd.append(output_path)
elif self.output_format == "m4b":
output_path = cmd.pop()
cmd.extend(metadata_options)
cmd.append(output_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
elif self.output_format == "opus":
static_ffmpeg.add_paths()
cmd = [
"ffmpeg",
"-y",
"-thread_queue_size",
"32768",
"-f",
"f32le",
"-ar",
"24000",
"-ac",
"1",
"-i",
"pipe:0",
]
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
merged_out_file = None
else: else:
self.log_updated.emit( self.log_updated.emit(
(f"Unsupported output format: {self.output_format}", "red") (f"Unsupported output format: {self.output_format}", "red")
@@ -991,7 +1071,7 @@ class ConversionThread(QThread):
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments): for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
# Load voice for this segment (with caching) # Load voice for this segment (with caching)
try: try:
loaded_voice = self.load_voice_cached(voice_name, self.backend) loaded_voice = self.load_voice_cached(voice_name, tts)
if segment_idx > 0: if segment_idx > 0:
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..." voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
self.log_updated.emit((f" → Voice: {voice_display}", "grey")) self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
@@ -1000,7 +1080,7 @@ class ConversionThread(QThread):
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange") (f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
) )
if segment_idx == 0: if segment_idx == 0:
loaded_voice = self.load_voice_cached(self.voice, self.backend) loaded_voice = self.load_voice_cached(self.voice, tts)
# Determine if spaCy segmentation should be used for PRE-TTS segmentation # Determine if spaCy segmentation should be used for PRE-TTS segmentation
# Only non-English languages use spaCy for pre-segmentation # Only non-English languages use spaCy for pre-segmentation
@@ -1086,7 +1166,7 @@ class ConversionThread(QThread):
print("Using split pattern: (unprintable)") print("Using split pattern: (unprintable)")
for text_segment in text_segments: for text_segment in text_segments:
for result in self.backend( for result in tts(
text_segment, text_segment,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
@@ -1192,8 +1272,8 @@ class ConversionThread(QThread):
) )
if "ass" in subtitle_format: if "ass" in subtitle_format:
for start, end, text in new_entries: for start, end, text in new_entries:
start_time = _format_timestamp(start, ass=True) start_time = self._ass_time(start)
end_time = _format_timestamp(end, ass=True) end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode # Use karaoke effect for highlighting mode
effect = ( effect = (
"karaoke" "karaoke"
@@ -1208,7 +1288,7 @@ class ConversionThread(QThread):
for entry in new_entries: for entry in new_entries:
start, end, text = entry start, end, text = entry
merged_subtitle_file.write( merged_subtitle_file.write(
f"{merged_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n" f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
) )
merged_srt_index += 1 merged_srt_index += 1
# Per-chapter subtitle processing for both file and ffmpeg_proc # Per-chapter subtitle processing for both file and ffmpeg_proc
@@ -1226,8 +1306,8 @@ class ConversionThread(QThread):
) )
if "ass" in subtitle_format: if "ass" in subtitle_format:
for start, end, text in new_chapter_entries: for start, end, text in new_chapter_entries:
start_time = _format_timestamp(start, ass=True) start_time = self._ass_time(start)
end_time = _format_timestamp(end, ass=True) end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode # Use karaoke effect for highlighting mode
effect = ( effect = (
"karaoke" "karaoke"
@@ -1242,7 +1322,7 @@ class ConversionThread(QThread):
for entry in new_chapter_entries: for entry in new_chapter_entries:
start, end, text = entry start, end, text = entry
chapter_subtitle_file.write( chapter_subtitle_file.write(
f"{chapter_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n" f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
) )
chapter_srt_index += 1 chapter_srt_index += 1
if merge_chapters_at_end: if merge_chapters_at_end:
@@ -1288,7 +1368,7 @@ class ConversionThread(QThread):
silence_samples = int( silence_samples = int(
self.silence_duration * 24000 self.silence_duration * 24000
) # Silence duration at 24,000 Hz ) # Silence duration at 24,000 Hz
silence_audio = np.zeros(silence_samples, dtype="float32") silence_audio = self.np.zeros(silence_samples, dtype="float32")
silence_bytes = silence_audio.tobytes() silence_bytes = silence_audio.tobytes()
if merged_out_file: if merged_out_file:
@@ -1531,27 +1611,58 @@ class ConversionThread(QThread):
) )
else: else:
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
cmd = build_ffmpeg_command( cmd = [
Path(merged_out_path), "ffmpeg",
self.output_format, "-y",
) "-thread_queue_size",
cmd.insert(2, "-thread_queue_size") "32768",
cmd.insert(3, "32768") "-f",
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"-i",
"pipe:0",
]
if self.output_format == "m4b": if self.output_format == "m4b":
metadata_options, cover_path = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
) )
if cover_path and os.path.exists(cover_path): if cover_path and os.path.exists(cover_path):
output_path = cmd.pop() cmd.extend(
cmd.extend([ [
"-i", cover_path, "-i",
"-map", "0:a", cover_path,
"-map", "1", "-map",
"-c:v", "copy", "0:a",
"-disposition:v", "attached_pic", "-map",
]) "1",
cmd.append(output_path) "-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
)
cmd.extend(metadata_options) cmd.extend(metadata_options)
elif self.output_format == "opus":
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
else:
self.log_updated.emit(
(f"Unsupported output format: {self.output_format}", "red")
)
return
cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
# Always generate subtitles for subtitle input files # Always generate subtitles for subtitle input files
@@ -1596,7 +1707,7 @@ class ConversionThread(QThread):
max_end_time = max( max_end_time = max(
(end for _, end, _ in subtitles if end is not None), default=0 (end for _, end, _ in subtitles if end is not None), default=0
) )
audio_buffer = np.zeros( audio_buffer = self.np.zeros(
int(max_end_time * rate) + rate, dtype="float32" int(max_end_time * rate) + rate, dtype="float32"
) )
@@ -1660,7 +1771,7 @@ class ConversionThread(QThread):
# Generate TTS audio # Generate TTS audio
tts_results = [ tts_results = [
r r
for r in self.backend( for r in tts(
processed_text, processed_text,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
@@ -1678,11 +1789,11 @@ class ConversionThread(QThread):
# Concatenate audio and determine duration # Concatenate audio and determine duration
full_audio = ( full_audio = (
np.concatenate( self.np.concatenate(
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks] [a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
) )
if audio_chunks if audio_chunks
else np.zeros( else self.np.zeros(
int((subtitle_duration or 0) * rate), dtype="float32" int((subtitle_duration or 0) * rate), dtype="float32"
) )
) )
@@ -1716,8 +1827,8 @@ class ConversionThread(QThread):
num_stages = max( num_stages = max(
1, 1,
int( int(
np.ceil( self.np.ceil(
np.log(speed_factor) / np.log(2.0) self.np.log(speed_factor) / self.np.log(2.0)
) )
), ),
) )
@@ -1750,7 +1861,7 @@ class ConversionThread(QThread):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
full_audio = np.frombuffer( full_audio = self.np.frombuffer(
speed_proc.communicate(input=full_audio.tobytes())[0], speed_proc.communicate(input=full_audio.tobytes())[0],
dtype="float32", dtype="float32",
) )
@@ -1764,7 +1875,7 @@ class ConversionThread(QThread):
tts_results = [ tts_results = [
r r
for r in self.backend( for r in tts(
processed_text, processed_text,
voice=loaded_voice, voice=loaded_voice,
speed=new_speed, speed=new_speed,
@@ -1775,14 +1886,14 @@ class ConversionThread(QThread):
audio_chunks = [r.audio for r in tts_results] audio_chunks = [r.audio for r in tts_results]
full_audio = ( full_audio = (
np.concatenate( self.np.concatenate(
[ [
a.numpy() if hasattr(a, "numpy") else a a.numpy() if hasattr(a, "numpy") else a
for a in audio_chunks for a in audio_chunks
] ]
) )
if audio_chunks if audio_chunks
else np.zeros( else self.np.zeros(
int(subtitle_duration * rate), dtype="float32" int(subtitle_duration * rate), dtype="float32"
) )
) )
@@ -1799,10 +1910,10 @@ class ConversionThread(QThread):
# Pad or trim to subtitle duration # Pad or trim to subtitle duration
target_samples = int(subtitle_duration * rate) target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples: if len(full_audio) < target_samples:
full_audio = np.concatenate( full_audio = self.np.concatenate(
[ [
full_audio, full_audio,
np.zeros( self.np.zeros(
target_samples - len(full_audio), dtype="float32" target_samples - len(full_audio), dtype="float32"
), ),
] ]
@@ -1815,10 +1926,10 @@ class ConversionThread(QThread):
end_sample = start_sample + len(full_audio) end_sample = start_sample + len(full_audio)
if end_sample > len(audio_buffer): if end_sample > len(audio_buffer):
# Extend buffer if needed # Extend buffer if needed
audio_buffer = np.concatenate( audio_buffer = self.np.concatenate(
[ [
audio_buffer, audio_buffer,
np.zeros( self.np.zeros(
end_sample - len(audio_buffer), dtype="float32" end_sample - len(audio_buffer), dtype="float32"
), ),
] ]
@@ -1841,11 +1952,11 @@ class ConversionThread(QThread):
else processed_text.replace("\n", "\\N") else processed_text.replace("\n", "\\N")
) )
subtitle_file.write( subtitle_file.write(
f"Dialogue: 0,{_format_timestamp(start_time, ass=True)},{_format_timestamp(end_time, ass=True)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n" f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
) )
else: else:
subtitle_file.write( subtitle_file.write(
f"{srt_index}\n{_format_timestamp(start_time)} --> {_format_timestamp(end_time)}\n{processed_text}\n\n" f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n"
) )
srt_index += 1 srt_index += 1
@@ -1860,7 +1971,7 @@ class ConversionThread(QThread):
self.progress_updated.emit(percent, etr_str) self.progress_updated.emit(percent, etr_str)
# Normalize audio buffer to prevent clipping from mixed overlaps # Normalize audio buffer to prevent clipping from mixed overlaps
max_amplitude = np.abs(audio_buffer).max() max_amplitude = self.np.abs(audio_buffer).max()
if max_amplitude > 1.0: if max_amplitude > 1.0:
self.log_updated.emit( self.log_updated.emit(
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})" f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
@@ -2007,6 +2118,22 @@ class ConversionThread(QThread):
# Add these to ffmpeg command # Add these to ffmpeg command
return metadata_options, cover_path return metadata_options, cover_path
def _srt_time(self, t):
"""Helper function to format time for SRT files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - int(t)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _ass_time(self, t):
"""Helper function to format time for ASS files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
cs = int((t - int(t)) * 100) # Centiseconds for ASS format
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
def _process_subtitle_tokens( def _process_subtitle_tokens(
self, self,
tokens_with_timestamps, tokens_with_timestamps,
@@ -2284,10 +2411,6 @@ class ConversionThread(QThread):
self.cancel_requested = True self.cancel_requested = True
self.should_cancel = True self.should_cancel = True
self.waiting_for_user_input = False self.waiting_for_user_input = False
# Clear voice cache (instance and module-level)
self.voice_cache.clear()
from abogen.voice_cache import clear_voice_cache
clear_voice_cache()
# Terminate subprocess if running # Terminate subprocess if running
if self.process: if self.process:
try: try:
@@ -2317,7 +2440,8 @@ class VoicePreviewThread(QThread):
def __init__( def __init__(
self, self,
backend, np_module,
kpipeline_class,
lang_code, lang_code,
voice, voice,
speed, speed,
@@ -2325,7 +2449,8 @@ class VoicePreviewThread(QThread):
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
self.backend = backend self.np_module = np_module
self.kpipeline_class = kpipeline_class
self.lang_code = lang_code self.lang_code = lang_code
self.voice = voice self.voice = voice
self.speed = speed self.speed = speed
@@ -2359,19 +2484,31 @@ class VoicePreviewThread(QThread):
# Generate the preview and save to cache # Generate the preview and save to cache
try: try:
# Set device based on use_gpu setting and platform
if self.use_gpu:
if platform.system() == "Darwin" and platform.processor() == "arm":
device = "mps" # Use MPS for Apple Silicon
else:
device = "cuda" # Use CUDA for other platforms
else:
device = "cpu"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Enable voice formula support for preview # Enable voice formula support for preview
if "*" in self.voice: if "*" in self.voice:
loaded_voice = get_new_voice(self.backend, self.voice, self.use_gpu) loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else: else:
loaded_voice = self.voice loaded_voice = self.voice
sample_text = get_sample_voice_text(self.lang_code) sample_text = get_sample_voice_text(self.lang_code)
audio_segments = [] audio_segments = []
for result in self.backend( for result in tts(
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
): ):
audio_segments.append(result.audio) audio_segments.append(result.audio)
if audio_segments: if audio_segments:
audio = np.concatenate(audio_segments) audio = self.np_module.concatenate(audio_segments)
# Save directly to the cache path # Save directly to the cache path
sf.write(self.cache_path, audio, 24000) sf.write(self.cache_path, audio, 24000)
self.temp_wav = self.cache_path self.temp_wav = self.cache_path
+13 -33
View File
@@ -7,7 +7,6 @@ import base64
import re import re
from abogen.pyqt.queue_manager_gui import QueueManager from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem from abogen.pyqt.queued_item import QueuedItem
from abogen.domain.device import select_device as _select_device
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation import hashlib # Added for cache path generation
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -83,11 +82,11 @@ from abogen.constants import (
GITHUB_URL, GITHUB_URL,
PROGRAM_DESCRIPTION, PROGRAM_DESCRIPTION,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
COLORS, COLORS,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
) )
from abogen.tts_plugin.utils import get_voices
import threading import threading
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
@@ -1874,7 +1873,7 @@ class abogen(QWidget):
for pname in load_profiles().keys(): for pname in load_profiles().keys():
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
# re-add voices # re-add voices
for v in get_voices("kokoro"): for v in VOICES_INTERNAL:
icon = QIcon() icon = QIcon()
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png") flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
if flag_path and os.path.exists(flag_path): if flag_path and os.path.exists(flag_path):
@@ -2317,9 +2316,9 @@ class abogen(QWidget):
file_size_str = "Unknown" file_size_str = "Unknown"
# pipeline_loaded_callback remains unchanged # pipeline_loaded_callback remains unchanged
def pipeline_loaded_callback(backend, error): def pipeline_loaded_callback(np_module, kpipeline_class, error):
if 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() prevent_sleep_end()
return return
@@ -2342,7 +2341,8 @@ class abogen(QWidget):
self.selected_output_folder, self.selected_output_folder,
subtitle_mode=actual_subtitle_mode, subtitle_mode=actual_subtitle_mode,
output_format=self.selected_format, output_format=self.selected_format,
backend=backend, np_module=np_module,
kpipeline_class=kpipeline_class,
start_time=self.start_time, start_time=self.start_time,
total_char_count=self.char_count, total_char_count=self.char_count,
use_gpu=self.gpu_ok, use_gpu=self.gpu_ok,
@@ -2426,17 +2426,7 @@ class abogen(QWidget):
self.gpu_ok = gpu_ok self.gpu_ok = gpu_ok
self.update_log((gpu_msg, gpu_ok)) self.update_log((gpu_msg, gpu_ok))
self.update_log("Loading modules...") self.update_log("Loading modules...")
load_thread = LoadPipelineThread(pipeline_loaded_callback)
# Determine device based on GPU availability
if gpu_ok:
device = _select_device()
else:
device = "cpu"
lang_code = self.selected_lang or "a"
load_thread = LoadPipelineThread(
pipeline_loaded_callback, lang_code=lang_code, device=device
)
load_thread.start() load_thread.start()
threading.Thread(target=gpu_and_load, daemon=True).start() threading.Thread(target=gpu_and_load, daemon=True).start()
@@ -2873,24 +2863,18 @@ class abogen(QWidget):
) )
self.loading_movie.start() self.loading_movie.start()
# Determine device based on GPU availability def pipeline_loaded_callback(np_module, kpipeline_class, error):
if self.gpu_ok: self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
device = _select_device()
else:
device = "cpu"
lang = self.selected_lang or "a" load_thread = LoadPipelineThread(pipeline_loaded_callback)
load_thread = LoadPipelineThread(
self._on_pipeline_loaded_for_preview, lang_code=lang, device=device
)
load_thread.start() 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 # stop loading animation and restore icon on error
if error: if error:
self.loading_movie.stop() self.loading_movie.stop()
self._show_error_message_box( 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.setIcon(self.play_icon)
self.btn_preview.setEnabled(True) self.btn_preview.setEnabled(True)
@@ -2928,7 +2912,7 @@ class abogen(QWidget):
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
self.preview_thread = VoicePreviewThread( 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.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error) self.preview_thread.error.connect(self._preview_error)
@@ -3231,16 +3215,12 @@ class abogen(QWidget):
) )
box.setDefaultButton(QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() == QMessageBox.StandardButton.Yes: if box.exec() == QMessageBox.StandardButton.Yes:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
else: else:
event.ignore() event.ignore()
else: else:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
+23 -5
View File
@@ -1,10 +1,10 @@
import os import os
import sys import sys
import platform 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 # Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -94,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_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning 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): if load_config().get("disable_kokoro_internet", False):
print("INFO: Kokoro's internet access is disabled.") print("INFO: Kokoro's internet access is disabled.")
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
@@ -106,6 +105,25 @@ from abogen.constants import PROGRAM_NAME, VERSION
os.environ["MIOPEN_FIND_MODE"] = "FAST" os.environ["MIOPEN_FIND_MODE"] = "FAST"
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" 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 # Enable MPS GPU acceleration on Mac Apple Silicon
if platform.system() == "Darwin" and platform.processor() == "arm": if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
@@ -166,4 +184,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+4 -5
View File
@@ -21,8 +21,7 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS from abogen.constants import COLORS, VOICES_INTERNAL
from abogen.tts_plugin.utils import get_voices
from abogen.spacy_utils import SPACY_MODELS from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker import abogen.hf_tracker
@@ -115,7 +114,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False self._voices_success = False
return return
voice_list = get_voices("kokoro") voice_list = VOICES_INTERNAL
for idx, voice in enumerate(voice_list, start=1): for idx, voice in enumerate(voice_list, start=1):
if self._cancelled: if self._cancelled:
self._voices_success = False self._voices_success = False
@@ -463,14 +462,14 @@ class PreDownloadDialog(QDialog):
try: try:
from huggingface_hub import try_to_load_from_cache 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( if not try_to_load_from_cache(
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
): ):
missing.append(voice) missing.append(voice)
except Exception: except Exception:
# If HF missing, report all as missing # If HF missing, report all as missing
return False, list(get_voices("kokoro")) return False, list(VOICES_INTERNAL)
return (len(missing) == 0), missing return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool: def _check_kokoro_model(self) -> bool:
+3 -3
View File
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt6.QtGui import QPixmap, QIcon, QAction from PyQt6.QtGui import QPixmap, QIcon, QAction
from abogen.constants import ( from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
COLORS, COLORS,
) )
from abogen.tts_plugin.utils import get_voices
import re import re
import platform import platform
from abogen.utils import get_resource_path from abogen.utils import get_resource_path
@@ -179,7 +179,7 @@ class VoiceMixer(QWidget):
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
# Voice name label with gender icon # 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 (flag and gender)
icons_layout = QHBoxLayout() icons_layout = QHBoxLayout()
@@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
def add_voices(self, initial_state): def add_voices(self, initial_state):
first_enabled_voice = None 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 language_code = voice[0] # First character is the language code
matching_voice = next( matching_voice = next(
(item for item in initial_state if item[0] == voice), None (item for item in initial_state if item[0] == voice), None
-160
View File
@@ -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"]
+7 -7
View File
@@ -466,7 +466,7 @@ def sanitize_name_for_os(name, is_folder=True):
def validate_voice_name(voice_name): 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'. Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
Args: Args:
@@ -477,10 +477,10 @@ def validate_voice_name(voice_name):
- is_valid: True if all voices in the name/formula are valid - 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 - 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) # 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() voice_name = voice_name.strip()
# Check if it's a formula (contains *) # 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. """Split text by voice markers, returning list of (voice, text) tuples.
IMPORTANT: Returns the last voice used so it can persist across chapters. 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: Args:
text: Text potentially containing <<VOICE:name>> markers 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 - valid_count: Number of valid voice markers processed
- invalid_count: Number of invalid voice markers skipped - 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)) 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 # Find the canonical (lowercase) voice name
voice_part_lower = voice_part.strip().lower() voice_part_lower = voice_part.strip().lower()
canonical_voice = next( 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() voice_part.strip()
) )
normalized_parts.append(f"{canonical_voice}*{weight.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 # Find the canonical (lowercase) voice name
voice_name_lower = voice_name.lower() voice_name_lower = voice_name.lower()
current_voice = next( 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 voice_name
) )
valid_markers += 1 valid_markers += 1
-170
View File
@@ -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",
]
-103
View File
@@ -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().
"""
...
-95
View File
@@ -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.
"""
...
-62
View File
@@ -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
-46
View File
@@ -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
-365
View File
@@ -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
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)
-189
View File
@@ -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
-55
View File
@@ -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.
"""
...
-153
View File
@@ -1,153 +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():
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
-111
View File
@@ -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"
-235
View File
@@ -1,235 +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 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 from __future__ import annotations
import ast import ast
from dataclasses import dataclass
import logging import logging
import math
import re import re
from typing import Any, Iterable, Iterator, Optional from typing import Any, Iterable, Iterator, Optional
import numpy as np import numpy as np
logger = logging.getLogger(__name__) 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: def _ensure_float32_mono(wav: Any) -> np.ndarray:
arr = np.asarray(wav, dtype="float32") arr = np.asarray(wav, dtype="float32")
if arr.ndim == 2: if arr.ndim == 2:
# (n, 1) or (1, n) or (n, channels)
if arr.shape[0] == 1 and arr.shape[1] > 1: if arr.shape[0] == 1 and arr.shape[1] > 1:
arr = arr.reshape(-1) arr = arr.reshape(-1)
else: else:
@@ -56,6 +62,7 @@ def _split_text(
else: else:
parts = [stripped] parts = [stripped]
# Enforce max length by hard-splitting long parts.
result: list[str] = [] result: list[str] = []
for part in parts: for part in parts:
if len(part) <= max_chunk_length: if len(part) <= max_chunk_length:
@@ -64,6 +71,7 @@ def _split_text(
start = 0 start = 0
while start < len(part): while start < len(part):
end = min(len(part), start + max_chunk_length) end = min(len(part), start + max_chunk_length)
# Try to split at whitespace.
if end < len(part): if end < len(part):
ws = part.rfind(" ", start, end) ws = part.rfind(" ", start, end)
if ws > start + 40: if ws > start + 40:
@@ -82,6 +90,7 @@ _UNSUPPORTED_CHARS_RE = re.compile(
def _parse_unsupported_characters(error: BaseException) -> list[str]: def _parse_unsupported_characters(error: BaseException) -> list[str]:
"""Best-effort extraction of unsupported characters from SuperTonic errors.""" """Best-effort extraction of unsupported characters from SuperTonic errors."""
message = " ".join( message = " ".join(
str(part) for part in getattr(error, "args", ()) if part is not None str(part) for part in getattr(error, "args", ()) if part is not None
) or str(error) ) or str(error)
@@ -127,11 +136,16 @@ def _configure_supertonic_gpu() -> None:
available = ort.get_available_providers() 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 = [] providers = []
if "CUDAExecutionProvider" in available: if "CUDAExecutionProvider" in available:
providers.append("CUDAExecutionProvider") providers.append("CUDAExecutionProvider")
providers.append("CPUExecutionProvider") 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.config as supertonic_config
import supertonic.loader as supertonic_loader 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) 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: class SupertonicPipeline:
"""Minimal adapter that mimics Kokoro's pipeline iteration interface.""" """Minimal adapter that mimics Kokoro's pipeline iteration interface."""
@@ -167,6 +171,7 @@ class SupertonicPipeline:
self.total_steps = int(total_steps) self.total_steps = int(total_steps)
self.max_chunk_length = int(max_chunk_length) self.max_chunk_length = int(max_chunk_length)
# Configure GPU providers before importing TTS
_configure_supertonic_gpu() _configure_supertonic_gpu()
try: try:
@@ -202,6 +207,7 @@ class SupertonicPipeline:
removed: set[str] = set() removed: set[str] = set()
last_exc: Exception | None = None last_exc: Exception | None = None
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
for attempt in range(3): for attempt in range(3):
try: try:
wav, duration = self._tts.synthesize( wav, duration = self._tts.synthesize(
@@ -225,6 +231,7 @@ class SupertonicPipeline:
chunk_to_speak, unsupported chunk_to_speak, unsupported
).strip() ).strip()
# If we didn't change anything, don't loop forever.
if sanitized == chunk_to_speak.strip(): if sanitized == chunk_to_speak.strip():
raise raise
@@ -242,6 +249,7 @@ class SupertonicPipeline:
sorted(removed), sorted(removed),
) )
else: else:
# Exhausted retries.
assert last_exc is not None assert last_exc is not None
raise last_exc raise last_exc
@@ -250,6 +258,7 @@ class SupertonicPipeline:
audio = _ensure_float32_mono(wav) audio = _ensure_float32_mono(wav)
# If duration is present, infer the source sample rate and resample if needed.
src_rate = self.sample_rate src_rate = self.sample_rate
try: try:
dur = float(duration) dur = float(duration)
+11 -10
View File
@@ -529,20 +529,21 @@ def prevent_sleep_end():
_sleep_procs[system] = None _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): class LoadPipelineThread(Thread):
def __init__(self, callback, lang_code="a", device="cpu"): def __init__(self, callback):
super().__init__() super().__init__()
self.callback = callback self.callback = callback
self.lang_code = lang_code
self.device = device
def run(self): def run(self):
try: try:
from abogen.tts_plugin.utils import create_pipeline np_module, kpipeline_class = load_numpy_kpipeline()
self.callback(np_module, kpipeline_class, None)
backend = create_pipeline(
"kokoro", lang_code=self.lang_code, device=self.device
)
self.callback(backend, None)
except Exception as e: except Exception as e:
self.callback(None, str(e)) self.callback(None, None, str(e))
+3 -12
View File
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
pass pass
from abogen.tts_plugin.utils import get_voices from abogen.constants import VOICES_INTERNAL
_CACHE_LOCK = threading.Lock() _CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set() _CACHED_VOICES: Set[str] = set()
@@ -26,9 +26,8 @@ _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_voices("kokoro")
if not voices: if not voices:
return set(kokoro_voices) return set(VOICES_INTERNAL)
normalized: Set[str] = set() normalized: Set[str] = set()
for voice in voices: for voice in voices:
if not voice: if not voice:
@@ -36,7 +35,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
voice_id = str(voice).strip() voice_id = str(voice).strip()
if not voice_id: if not voice_id:
continue continue
if voice_id in kokoro_voices: if voice_id in VOICES_INTERNAL:
normalized.add(voice_id) normalized.add(voice_id)
return normalized return normalized
@@ -144,11 +143,3 @@ def _ensure_single_voice_asset(
hf_hub_download(resume_download=True, **common_kwargs) hf_hub_download(resume_download=True, **common_kwargs)
return True return True
def clear_voice_cache() -> None:
"""Clear the inprocess voice cache (used during shutdown)."""
with _CACHE_LOCK:
_CACHED_VOICES.clear()
global _BOOTSTRAPPED
_BOOTSTRAPPED = False
+2 -3
View File
@@ -1,7 +1,7 @@
import re import re
from typing import List, 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 # 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") raise ValueError("Empty voice formula")
terms: List[Tuple[str, float]] = [] terms: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro")
for segment in formula.split("+"): for segment in formula.split("+"):
part = segment.strip() part = segment.strip()
if not part: 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") raise ValueError("Each component must be in the form voice*weight")
voice_name, raw_weight = part.split("*", 1) voice_name, raw_weight = part.split("*", 1)
voice_name = voice_name.strip() 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}") raise ValueError(f"Unknown voice: {voice_name}")
try: try:
weight = float(raw_weight.strip()) weight = float(raw_weight.strip())
-33
View File
@@ -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.
"""
+5 -6
View File
@@ -2,7 +2,8 @@ import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple 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 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: def _normalize_supertonic_voice(value: Any) -> str:
raw = str(value or "").strip().upper() raw = str(value or "").strip().upper()
supertonic_voices = get_voices("supertonic") return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int: def _coerce_supertonic_steps(value: Any) -> int:
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
return {} return {}
provider = str(entry.get("provider") or "kokoro").strip().lower() provider = str(entry.get("provider") or "kokoro").strip().lower()
if not is_plugin_registered(provider): if provider not in {"kokoro", "supertonic"}:
provider = "kokoro" provider = "kokoro"
language = str(entry.get("language") or "a").strip().lower() or "a" 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]]: def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = [] normalized: List[Tuple[str, float]] = []
kokoro_voices = get_voices("kokoro")
for item in entries or []: for item in entries or []:
if isinstance(item, dict): if isinstance(item, dict):
voice = item.get("id") or item.get("voice") 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] voice, weight = item[0], item[1]
else: else:
continue continue
if voice not in kokoro_voices: if voice not in VOICES_INTERNAL:
continue continue
if weight is None: if weight is None:
continue continue
+12 -11
View File
@@ -2,6 +2,7 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv \ VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH PATH=/opt/venv/bin:$PATH
@@ -26,22 +27,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ 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 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) # Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
# Set USE_GPU=false to skip this for CPU-only deployments # Set USE_GPU=false to skip this for CPU-only deployments
RUN if [ "$USE_GPU" = "true" ]; then \ RUN if [ "$USE_GPU" = "true" ]; then \
uv pip install --system onnxruntime-gpu; \ pip install --no-cache-dir onnxruntime-gpu; \
fi fi
ENV ABOGEN_HOST=0.0.0.0 \ ENV ABOGEN_HOST=0.0.0.0 \
+4 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import atexit
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -7,8 +8,6 @@ from typing import Any, Optional
from flask import Flask 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 abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir
from .conversion_runner import run_conversion_job 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), "UPLOAD_FOLDER": str(uploads_dir),
"OUTPUT_FOLDER": str(outputs_dir), "OUTPUT_FOLDER": str(outputs_dir),
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads "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: if config:
base_config.update(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(books_bp, url_prefix="/find-books")
app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(api_bp, url_prefix="/api")
atexit.register(service.shutdown)
global _access_log_filter_attached global _access_log_filter_attached
if not _access_log_filter_attached: if not _access_log_filter_attached:
logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter()) logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter())
@@ -137,4 +132,4 @@ def main() -> None:
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
main() main()
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
from abogen.tts_plugin.utils import create_pipeline from abogen.utils import load_numpy_kpipeline
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) _MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
@@ -45,7 +45,8 @@ def _load_pipeline(language: str, use_gpu: bool) -> Any:
device = "cpu" device = "cpu"
if use_gpu: if use_gpu:
device = _select_device() device = _select_device()
return create_pipeline("kokoro", lang_code=language, device=device) _np, KPipeline = load_numpy_kpipeline()
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
@@ -247,8 +248,4 @@ def run_debug_tts_wavs(
"sample_rate": SAMPLE_RATE, "sample_rate": SAMPLE_RATE,
} }
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
try:
pipeline.dispose()
except Exception:
pass
return manifest return manifest
+2 -3
View File
@@ -34,7 +34,6 @@ from abogen.normalization_settings import (
) )
from abogen.llm_client import list_models, LLMClientError from abogen.llm_client import list_models, LLMClientError
from abogen.kokoro_text_normalization import normalize_for_pipeline from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.tts_plugin.utils import is_plugin_registered
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
from abogen.integrations.calibre_opds import ( from abogen.integrations.calibre_opds import (
CalibreOPDSClient, CalibreOPDSClient,
@@ -64,7 +63,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
if profile is None: if profile is None:
# Speaker Studio payload format # Speaker Studio payload format
provider = str(payload.get("provider") or "kokoro").strip().lower() provider = str(payload.get("provider") or "kokoro").strip().lower()
if not is_plugin_registered(provider): if provider not in {"kokoro", "supertonic"}:
provider = "kokoro" provider = "kokoro"
if provider == "supertonic": if provider == "supertonic":
profile = { profile = {
@@ -231,7 +230,7 @@ def api_speaker_preview() -> ResponseReturnValue:
use_gpu = settings.get("use_gpu", False) use_gpu = settings.get("use_gpu", False)
base_spec, speaker_name = split_profile_spec(voice) base_spec, speaker_name = split_profile_spec(voice)
resolved_provider = tts_provider if is_plugin_registered(tts_provider) else "" resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else ""
if speaker_name: if speaker_name:
entry = normalize_profile_entry(load_profiles().get(speaker_name)) entry = normalize_profile_entry(load_profiles().get(speaker_name))
+6 -7
View File
@@ -7,7 +7,6 @@ from flask.typing import ResponseReturnValue
from abogen.webui.service import PendingJob, JobStatus from abogen.webui.service import PendingJob, JobStatus
from abogen.webui.routes.utils.service import get_service from abogen.webui.routes.utils.service import get_service
from abogen.tts_plugin.utils import is_plugin_registered
from abogen.webui.routes.utils.settings import ( from abogen.webui.routes.utils.settings import (
load_settings, load_settings,
coerce_bool, coerce_bool,
@@ -33,7 +32,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
from abogen.utils import calculate_text_length from abogen.utils import calculate_text_length
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
from abogen.chunking import ChunkLevel, build_chunks_for_chapters from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.tts_plugin.utils import get_default_voice from abogen.constants import VOICES_INTERNAL
from abogen.speaker_configs import get_config from abogen.speaker_configs import get_config
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
from dataclasses import dataclass from dataclasses import dataclass
@@ -580,7 +579,7 @@ def apply_book_step_form(
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula). # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro). # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
provider_value = str(form.get("tts_provider") or "").strip().lower() provider_value = str(form.get("tts_provider") or "").strip().lower()
if is_plugin_registered(provider_value): if provider_value in {"kokoro", "supertonic"}:
pending.tts_provider = provider_value pending.tts_provider = provider_value
# Determine the base speaker selection (saved speaker ref or raw voice). # Determine the base speaker selection (saved speaker ref or raw voice).
@@ -617,8 +616,8 @@ def apply_book_step_form(
custom_formula = "" custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec: if not base_voice_spec and VOICES_INTERNAL:
base_voice_spec = get_default_voice("kokoro") base_voice_spec = VOICES_INTERNAL[0]
voice_choice, resolved_language, selected_profile = resolve_voice_choice( voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language, pending.language,
@@ -797,8 +796,8 @@ def build_pending_job_from_extraction(
profile_selection = inferred_profile profile_selection = inferred_profile
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip() base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
if not base_voice: if not base_voice and VOICES_INTERNAL:
base_voice = get_default_voice("kokoro") base_voice = VOICES_INTERNAL[0]
selected_speaker_config = (form.get("speaker_config") or "").strip() selected_speaker_config = (form.get("speaker_config") or "").strip()
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
+28 -15
View File
@@ -6,8 +6,6 @@ import soundfile as sf
from flask import current_app, send_file from flask import current_app, send_file
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from abogen.domain.device import select_device as _select_device
SPLIT_PATTERN = r"\n+" SPLIT_PATTERN = r"\n+"
SAMPLE_RATE = 24000 SAMPLE_RATE = 24000
@@ -16,15 +14,29 @@ _preview_pipelines: Dict[Tuple[str, str], Any] = {}
_preview_pipeline_lock = threading.Lock() _preview_pipeline_lock = threading.Lock()
def clear_preview_pipelines() -> None: def _select_device() -> str:
"""Dispose all cached preview pipelines and clear the cache.""" import platform
with _preview_pipeline_lock:
for pipeline in _preview_pipelines.values(): try:
try: import torch # type: ignore[import-not-found]
pipeline.dispose() except Exception:
except Exception: return "cpu"
pass
_preview_pipelines.clear() system = platform.system()
if system == "Darwin" and platform.processor() == "arm":
try:
if torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
try:
if torch.cuda.is_available():
return "cuda"
except Exception:
pass
return "cpu"
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]: def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
@@ -66,9 +78,10 @@ def get_preview_pipeline(language: str, device: str) -> Any:
pipeline = _preview_pipelines.get(key) pipeline = _preview_pipelines.get(key)
if pipeline is not None: if pipeline is not None:
return pipeline return pipeline
from abogen.tts_plugin.utils import create_pipeline from abogen.utils import load_numpy_kpipeline
pipeline = create_pipeline("kokoro", lang_code=language, device=device) _, KPipeline = load_numpy_kpipeline()
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
@@ -124,9 +137,9 @@ def generate_preview_audio(
normalized_text = source_text normalized_text = source_text
if provider == "supertonic": if provider == "supertonic":
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_supertonic import SupertonicPipeline
pipeline = create_pipeline("supertonic") pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
voice=voice_spec, voice=voice_spec,
+2 -2
View File
@@ -6,8 +6,8 @@ from abogen.constants import (
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
) )
from abogen.tts_plugin.utils import get_default_voice
from abogen.normalization_settings import ( from abogen.normalization_settings import (
DEFAULT_LLM_PROMPT, DEFAULT_LLM_PROMPT,
environment_llm_defaults, environment_llm_defaults,
@@ -174,7 +174,7 @@ def settings_defaults() -> Dict[str, Any]:
"subtitle_format": "srt", "subtitle_format": "srt",
"save_mode": "default_output" if has_output_override() else "save_next_to_input", "save_mode": "default_output" if has_output_override() else "save_next_to_input",
"default_speaker": "", "default_speaker": "",
"default_voice": get_default_voice("kokoro"), "default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
"supertonic_total_steps": 5, "supertonic_total_steps": 5,
"supertonic_speed": 1.0, "supertonic_speed": 1.0,
"replace_single_newlines": False, "replace_single_newlines": False,
+6 -5
View File
@@ -17,10 +17,10 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
SAMPLE_VOICE_TEXTS, SAMPLE_VOICE_TEXTS,
VOICES_INTERNAL,
) )
from abogen.tts_plugin.utils import get_voices
from abogen.speaker_configs import list_configs from abogen.speaker_configs import list_configs
from abogen.tts_plugin.utils import create_pipeline from abogen.utils import load_numpy_kpipeline
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
_preview_pipeline_lock = threading.RLock() _preview_pipeline_lock = threading.RLock()
@@ -285,7 +285,7 @@ def filter_voice_catalog(
def build_voice_catalog() -> List[Dict[str, str]]: def build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = [] catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"} gender_map = {"f": "Female", "m": "Male"}
for voice_id in get_voices("kokoro"): for voice_id in VOICES_INTERNAL:
prefix, _, rest = voice_id.partition("_") prefix, _, rest = voice_id.partition("_")
language_code = prefix[0] if prefix else "a" language_code = prefix[0] if prefix else "a"
gender_code = prefix[1] if len(prefix) > 1 else "" gender_code = prefix[1] if len(prefix) > 1 else ""
@@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]:
voice_catalog = build_voice_catalog() voice_catalog = build_voice_catalog()
return { return {
"languages": LANGUAGE_DESCRIPTIONS, "languages": LANGUAGE_DESCRIPTIONS,
"voices": get_voices("kokoro"), "voices": VOICES_INTERNAL,
"subtitle_formats": SUBTITLE_FORMATS, "subtitle_formats": SUBTITLE_FORMATS,
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
"output_formats": SUPPORTED_SOUND_FORMATS, "output_formats": SUPPORTED_SOUND_FORMATS,
@@ -741,7 +741,8 @@ def get_preview_pipeline(language: str, device: str):
pipeline = _preview_pipelines.get(key) pipeline = _preview_pipelines.get(key)
if pipeline is not None: if pipeline is not None:
return pipeline return pipeline
pipeline = create_pipeline("kokoro", lang_code=language, device=device) _, KPipeline = load_numpy_kpipeline()
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
_preview_pipelines[key] = pipeline _preview_pipelines[key] = pipeline
return pipeline return pipeline
+1 -1
View File
@@ -17,7 +17,7 @@ from abogen.speaker_configs import (
save_configs, save_configs,
delete_config, delete_config,
) )
from abogen.constants import VOICES_INTERNAL
voices_bp = Blueprint("voices", __name__) voices_bp = Blueprint("voices", __name__)
-2
View File
@@ -1609,12 +1609,10 @@ def build_service(
output_root: Optional[Path] = None, output_root: Optional[Path] = None,
uploads_root: Optional[Path] = None, uploads_root: Optional[Path] = None,
) -> ConversionService: ) -> ConversionService:
global _service_instance
output_root = output_root or default_storage_root() output_root = output_root or default_storage_root()
service = ConversionService( service = ConversionService(
output_root=output_root, output_root=output_root,
uploads_root=uploads_root, uploads_root=uploads_root,
runner=runner, runner=runner,
) )
_service_instance = service
return service return service
-55
View File
@@ -1,55 +0,0 @@
# Contributing to Abogen
We welcome contributions to Abogen!
## How to Contribute
1. Fork the repository
2. Create a branch for your feature
3. Make your changes
4. Write tests
5. Submit a pull request
## Code Standards
- Follow PEP 8 for Python
- Use TypeScript for JavaScript
- Type hints required for new Python code
- Document complex logic with comments
## Plugin Architecture
When contributing TTS engines, implement the **Plugin Architecture** contract.
See [Developer Guide](developer-guide.md#5-adding-a-new-plugin) for:
- Required exports (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
- Engine / EngineSession contracts
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.)
- Step-by-step plugin creation guide
## Testing
```bash
# All tests
pytest
# Contract tests (architectural compliance)
pytest tests/contracts/
# Behavioral regression tests
pytest tests/test_behavioral_regression.py
```
## Documentation
- Update relevant docs in `docs/` when changing architecture or APIs
- Add docstrings to all public functions/classes
- Follow existing documentation style
## Pull Request Checklist
- [ ] Tests pass (`pytest`)
- [ ] Code follows style guide (`ruff check`, `ruff format`)
- [ ] Documentation updated
- [ ] No legacy architecture references (`TTSBackend`, `register_backend`, `TTSBackendRegistry`)
- [ ] Uses new Plugin Architecture patterns
-270
View File
@@ -1,270 +0,0 @@
# TTS Plugin Architecture — Architectural Reference
This document describes the **stable architectural contracts** of the TTS Plugin Architecture. It documents invariants that only change when the architecture itself changes.
---
## 1. Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Host Application │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Plugin │ │ HostContext │ │ Plugin Discovery │ │
│ │ Manager │──│ (config_dir, │ │ (plugin directories) │ │
│ │ │ │ logger, │ │ │ │
│ │ - discover │ │ http_client)│ └────────────────────────┘ │
│ │ - validate │ └──────────────┘ │ │
│ │ - activate │ ▼ │
│ │ - dispose │ ┌─────────────────────────────────────────┐ │
│ └──────┬──────┘ │ Plugin Package │ │
│ │ │ ┌──────────────┐ ┌─────────────────┐ │ │
│ ▼ │ │ PLUGIN_ │ │ MODEL_ │ │ │
│ ┌────────────┐ │ │ MANIFEST │ │ REQUIREMENTS │ │ │
│ │ Engine │◄──┤ │ create_engine│ │ │ │ │
│ └──────┬─────┘ │ └──────────────┘ └─────────────────┘ │ │
│ │ └─────────────────────────────────────────┘ │
│ │ createSession() │
│ ▼ │
│ ┌─────────────┐ │
│ │EngineSession│ │
│ └──────┬──────┘ │
│ │ synthesize() │
│ ▼ │
│ ┌────────────────┐ │
│ │SynthesizedAudio│ │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Core Components
| Component | Responsibility |
|-----------|----------------|
| **PluginManifest** | Static metadata: id, name, version, api_version, capabilities, engine manifest |
| **EngineManifest** | Voice sources, parameters, audio formats |
| **HostContext** | Minimal host services: config_dir, logger, http_client |
| **Engine** | Stateless factory for sessions; thread-safe `createSession()` |
| **EngineSession** | Owns mutable execution state; not thread-safe |
| **PluginManager** | Discovers, validates, and manages plugin lifecycle |
| **Capabilities** | Optional interfaces: VoiceLister, PreviewGenerator, StreamingSynthesizer, CancelableSession |
---
## 2. Ownership Model
### Engine Ownership
```
PluginManager.create_engine() → Engine
```
- **PluginManager** creates and caches engines
- **Caller** receives `Engine` instance
- **Caller** must dispose all sessions **before** disposing engine
- **Engine.dispose()** releases engine resources
- After `Engine.dispose()`: all methods except `dispose()` raise `EngineError`
### Session Ownership
```
Engine.createSession() → EngineSession
```
- **Engine** creates session
- **Ownership transfers to caller** immediately
- **Caller** is responsible for `session.dispose()`
- **Engine does NOT track sessions** — no registry, no callbacks
- After `session.dispose()`: all methods except `dispose()` raise `EngineError`
### Disposal Order (Invariant)
```python
# Correct
engine = manager.create_engine("id")
session = engine.createSession()
try:
audio = session.synthesize(request)
finally:
session.dispose() # 1. Sessions FIRST
engine.dispose() # 2. Then engine
# INCORRECT — violates contract (undefined behavior)
engine.dispose()
session.synthesize(request) # EngineError
```
---
## 3. Lifecycle State Machine
```
DISCOVERY
PluginManager.discover(plugin_dirs)
→ Loads PLUGIN_MANIFEST, MODEL_REQUIREMENTS
→ Validates api_version (major must match)
→ Validates declared capabilities are implemented
MODEL_DOWNLOAD (if MODEL_REQUIREMENTS non-empty)
Host reads MODEL_REQUIREMENTS
Downloads/caches models
Resolves model_path
ACTIVATION
create_engine(context, model_path, config)
→ Atomic: succeeds fully or raises EngineError
→ Returns Engine
SESSION_CREATION
engine.createSession() → EngineSession
→ Ownership transfers to caller
→ Raises EngineError on failure
→ Never returns partial session
SYNTHESIS
session.synthesize(request)
→ Returns SynthesizedAudio
→ Raises EngineError on failure
→ Session remains usable after error
SESSION_DISPOSAL
session.dispose()
→ Idempotent, never raises
→ After: all methods raise EngineError
DEACTIVATION
engine.dispose()
→ Caller MUST dispose all sessions first
→ Idempotent, never raises
→ After: all methods raise EngineError
```
---
## 4. Protocol Contracts
### Engine (Protocol)
```python
@runtime_checkable
class Engine(Protocol):
def createSession(self) -> EngineSession:
"""Create a new session. Thread-safe. Transfers ownership."""
...
def dispose(self) -> None:
"""Release engine resources.
Caller must dispose all sessions first.
Idempotent, never raises.
After: all methods except dispose() raise EngineError."""
...
```
### EngineSession (Protocol)
```python
@runtime_checkable
class EngineSession(Protocol):
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio.
Returns SynthesizedAudio or raises EngineError.
Session remains usable after error."""
...
def dispose(self) -> None:
"""Release session resources.
Idempotent, never raises.
After: all methods except dispose() raise EngineError."""
...
```
### Capability Protocols (Optional)
- **VoiceLister**: `listVoices(source_id: str) -> list[VoiceManifest]`
- **PreviewGenerator**: `generatePreview(voice: VoiceSelection, text: str) -> SynthesizedAudio`
- **StreamingSynthesizer**: `synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]`
- **CancelableSession**: `cancel() -> None` (causes in-flight synthesize to raise `CancelledError`)
---
## 5. Error Semantics
```
EngineError (base)
├── ModelNotFoundError # Required model not found
├── ModelLoadError # Model failed to load
├── NetworkError # Network operation failed
├── InvalidInputError # Request validation failed
├── ConfigurationError # Invalid configuration
├── CancelledError # Operation cancelled via CancelableSession
└── InternalError # Unexpected internal failure
```
### When Each Is Raised
| Error | Raised By | Conditions |
|-------|-----------|------------|
| `ModelNotFoundError` | `create_engine()` | Required model not found at `model_path` |
| `ModelLoadError` | `create_engine()` | Model exists but fails to load |
| `NetworkError` | `synthesize()`, `create_engine()` | Network call fails (cloud engines) |
| `InvalidInputError` | `synthesize()` | Request validation fails (empty text, invalid voice, etc.) |
| `ConfigurationError` | `create_engine()` | Config values invalid for this engine |
| `CancelledError` | `synthesize()`, `synthesizeStream()` | `CancelableSession.cancel()` called |
| `InternalError` | Any | Unexpected internal failure (bug) |
### Dispose Contract
- `dispose()` is **idempotent** and **never raises**
- After `dispose()`: all methods except `dispose()` raise `EngineError`
- Engine: caller must dispose all sessions first; violating this is undefined behavior
---
## 6. Capabilities
| Capability | Interface | Enables |
|------------|-----------|---------|
| `voice_list` | `VoiceLister` | `listVoices(source_id)` — enumerate available voices |
| `preview` | `PreviewGenerator` | `generatePreview(voice, text)` — preview without session |
| `streaming` | `StreamingSynthesizer` | `synthesizeStream(request)` — chunked audio output |
| `cancel` | `CancelableSession` | `cancel()` — interrupt in-flight synthesis |
Plugins declare capabilities in `PluginManifest.capabilities`. Host validates at load time.
---
## 7. Contract Tests
**Location**: `tests/contracts/`
**Purpose**: Verify every plugin satisfies the architectural contracts.
**Guarantees**:
- Required exports exist (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
- `create_engine` is atomic
- `Engine.createSession()` transfers ownership, never returns partial
- `dispose()` is idempotent on Engine and EngineSession
- After `dispose()`, methods raise `EngineError`
- `synthesize()` raises typed `EngineError` subtypes, session remains usable
- Declared capabilities are actually implemented
- Plugin loader validates manifest, api_version, capabilities
**Run**: `pytest tests/contracts/ -v`
---
## 8. Behavioral Tests
**Location**: `tests/test_behavioral_regression.py`
**Purpose**: Verify user-facing behavior via public API only (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`).
**Scope**:
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
- Voice selection and listing
- Parameter handling (speed, etc.)
- Error scenarios (unknown plugin, disposal, etc.)
- Resource cleanup (dispose idempotency, no leaks)
- Pipeline utility (`create_pipeline`)
**Run**: `pytest tests/test_behavioral_regression.py -v`
---
## 9. Reference
- **Architecture Spec**: `docs/architecture-final-v2.md`
- **Amendment (lang_code)**: `docs/architecture-amendment-001.md`
- **Migration Roadmap**: `docs/migration-roadmap.md`
- **Plugin Examples**: `plugins/kokoro/`, `plugins/supertonic/`
- **Protocol Definitions**: `abogen/tts_plugin/engine.py`, `abogen/tts_plugin/capabilities.py`
-68
View File
@@ -1,68 +0,0 @@
# Getting Started
Quickstart for developers working on Abogen.
## Prerequisites
- Python 3.10+
- Node.js 20+
- npm 10+
- Git
- Docker (optional)
## Installation
```bash
# Development install with all extras
pip install -e .[dev]
# Or with uv
uv pip install -e .[dev]
```
## Running the Application
```bash
# Desktop GUI
abogen
# Web UI
abogen-web
# CLI
abogen-cli
```
## Project Structure
```
abogen/
├── pyqt/ - PyQt6 desktop GUI
├── webui/ - Flask web UI
├── tts_plugin/ - Plugin Architecture (Engine, EngineSession, Manifest)
└── plugins/ - Built-in plugins (kokoro, supertonic)
tests/
├── contracts/ - Contract compliance tests
└── ...
```
## Testing
```bash
# All tests
pytest
# Contract tests (architectural compliance)
pytest tests/contracts/
# Behavioral regression tests
pytest tests/test_behavioral_regression.py
```
## Architecture
See [Developer Guide](developer-guide.md) for Plugin Architecture details:
- Engine / EngineSession lifecycle
- Plugin contract (PLUGIN_MANIFEST, create_engine)
- Adding new plugins
- Capability interfaces
-269
View File
@@ -1,269 +0,0 @@
# Testing Guide
This document describes the testing strategy for Abogen's Plugin Architecture.
## Test Categories
### 0. Auto-Discovery Plugin Tests (`tests/plugins/`)
**Purpose**: Automatically test every plugin in `plugins/` directory without manual test creation. These tests use discovery to find all plugins and run generic tests against each one.
**What They Test**:
- **Manifest structure**: Required fields, API version format, voices field
- **Engine lifecycle**: `create_engine`, `dispose` idempotency, post-dispose behavior
- **Capability implementation**: Declared capabilities are implemented (e.g., `voice_list``VoiceLister`)
**How Auto-Discovery Works**:
```python
# tests/plugins/conftest.py
@pytest.fixture(scope="module")
def plugin_ids(plugins_dir: Path) -> list[str]:
"""Discovers all plugin directories with __init__.py"""
return [item.name for item in plugins_dir.iterdir()
if item.is_dir() and (item / "__init__.py").exists()]
```
**Test Structure**:
```
tests/plugins/
├── conftest.py # Fixtures: plugin_ids, loaded_plugin, host_context
└── test_all_plugins.py # Generic tests for every plugin
├── TestAllPluginsManifest
├── TestAllPluginsEngine
└── TestAllPluginsCapabilities
```
**Running Auto-Discovery Tests**:
```bash
# Test all plugins automatically
pytest tests/plugins/ -v
# Test specific plugin
pytest tests/plugins/ -v -k "kokoro"
# See which plugins were discovered
pytest tests/plugins/ --collect-only
```
**Adding a New Plugin**:
1. Create plugin directory: `plugins/my_plugin/`
2. Add `__init__.py` with `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`
3. Run `pytest tests/plugins/` — tests automatically discover and test your plugin!
**When to Add Plugin-Specific Tests**:
Auto-discovery tests cover generic contract validation. Create plugin-specific tests in `tests/test_<plugin>_plugin.py` for:
- Integration with real dependencies (e.g., KPipeline for Kokoro)
- Specific voice IDs and behavior
- Plugin-specific parameters and features
---
### 1. Contract Tests (`tests/contracts/`)
**Purpose**: Verify that every plugin satisfies the architectural contract. These tests ensure the Plugin Architecture's invariants are maintained.
**What They Guarantee**:
- Every plugin exports `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`
- `create_engine` is atomic (succeeds fully or raises and cleans up)
- `Engine.createSession()` returns valid `EngineSession`, transfers ownership
- `Engine.dispose()` is idempotent, never raises
- After `dispose()`, all methods raise `EngineError`
- `EngineSession.synthesize()` returns `SynthesizedAudio` or raises `EngineError` (session remains usable)
- `EngineSession.dispose()` is idempotent, never raises
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.) are correctly implemented
- Plugin Loader discovers, validates, and loads plugins correctly
- Plugin Manager creates, caches, and disposes engines correctly
- Value objects are immutable and have correct equality semantics
- Error hierarchy is preserved (`EngineError` base with subtypes)
**Why They Exist**:
- Provide **compile-time-like guarantees** for a dynamic plugin system
- Enable **safe plugin ecosystem** — host can trust any loaded plugin
- Catch **architectural violations** early (missing dispose, wrong return types, etc.)
- Document the **contract** in executable form
**What Every New Plugin Must Pass**:
```bash
pytest tests/contracts/ -v
# All tests must pass
```
**Running Contract Tests**:
```bash
# All contract tests
pytest tests/contracts/
# Specific contract
pytest tests/contracts/test_engine_contract.py
# With coverage
pytest tests/contracts/ --cov=abogen.tts_plugin
```
---
### 2. Behavioral Tests (`tests/test_behavioral_regression.py`)
**Purpose**: Verify external user-facing behavior using only public API. These tests are **not coupled to internal implementation**.
**What They Test**:
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
- Voice selection and listing
- Parameter handling (speed, etc.)
- Error scenarios (unknown plugin, disposal, etc.)
- Resource cleanup (dispose idempotency, no leaks)
- Pipeline utility (`create_pipeline`)
**Why They Test Public Behavior Only**:
- **Refactoring safety**: Internal changes don't break tests
- **Real-world usage**: Tests match how consumers actually use the API
- **Plugin agnostic**: Parametrized across Kokoro, SuperTonic, and mock plugins
- **Regression detection**: Catch behavioral regressions regardless of implementation
**What They Don't Test**:
- Internal class structure
- Private methods
- Implementation details (how audio is generated, model loading internals)
**Running Behavioral Tests**:
```bash
# All behavioral tests
pytest tests/test_behavioral_regression.py -v
# With specific plugin (if installed)
pytest tests/test_behavioral_regression.py -v -k "kokoro"
```
---
### 4. Unit Tests (`tests/`)
**Purpose**: Test individual modules in isolation.
**Examples**:
- `test_book_parser.py` — EPUB/PDF/text parsing
- `test_text_normalization.py` — Text preprocessing
- `test_chunk_helpers.py` — Text chunking logic
- `test_voice_cache.py` — Voice caching
---
### 5. Integration Tests
**Purpose**: Test cross-component interactions.
**Examples**:
- `test_kokoro_plugin.py` — Full Kokoro plugin integration
- `test_supertonic_plugin.py` — Full SuperTonic plugin integration
- `test_conversion_series.py` — End-to-end conversion pipeline
---
## Test Architecture
```
tests/
├── contracts/ # Contract tests (architectural compliance)
│ ├── conftest.py # Shared fixtures (FakeEngine, FakeSession)
│ ├── test_manifest_contract.py
│ ├── test_plugin_contract.py
│ ├── test_engine_contract.py
│ ├── test_session_contract.py
│ ├── test_capabilities_contract.py
│ ├── test_loader_contract.py
│ ├── test_plugin_manager_contract.py
│ ├── test_types_contract.py
│ ├── test_errors_contract.py
│ ├── test_host_context_contract.py
│ └── test_integration.py
├── test_behavioral_regression.py # Behavioral tests (public API)
├── test_kokoro_plugin.py # Kokoro integration
├── test_supertonic_plugin.py # SuperTonic integration
└── ... # Other unit/integration tests
```
---
## Adding Tests for a New Plugin
### Auto-Discovery Tests (Automatic!)
**No manual test creation required!** When you add a new plugin to `plugins/`:
1. Create plugin directory: `plugins/my_plugin/`
2. Add `__init__.py` with required exports:
```python
PLUGIN_MANIFEST = PluginManifest(...)
MODEL_REQUIREMENTS = [...]
def create_engine(...): ...
```
3. Run `pytest tests/plugins/` — auto-discovery tests automatically find and test your plugin!
**What's Tested Automatically**:
- Manifest structure and required fields
- API version compatibility
- Engine creation and dispose contract
- Capability implementation (if declared)
### Plugin-Specific Tests (Optional)
Create `tests/test_my_plugin_plugin.py` for:
- Integration with real backend (e.g., KPipeline for Kokoro)
- Specific voice IDs and behavior
- Plugin-specific parameters and features
### Contract Tests (Deprecated for New Plugins)
**Note**: Auto-discovery tests (`tests/plugins/`) now cover contract validation for all plugins. Manual contract tests in `tests/contracts/` are only needed for testing internal architecture components.
### Behavioral Tests (Recommended)
Add parametrized tests to `tests/test_behavioral_regression.py`:
```python
# In _plugin_ids list, add your plugin
_plugin_ids = ["kokoro", "supertonic", "my_plugin"]
_plugin_engines["my_plugin"] = _YourMockEngine
_plugin_default_voices["my_plugin"] = "voice1"
_plugin_all_voices["my_plugin"] = ["voice1", "voice2"]
```
All existing behavioral tests will automatically run against your plugin.
---
## Continuous Integration
```yaml
# .github/workflows/test.yml
- name: Contract Tests
run: pytest tests/contracts/ -v
- name: Behavioral Tests
run: pytest tests/test_behavioral_regression.py -v
- name: Unit & Integration Tests
run: pytest tests/ -v --ignore=tests/test_behavioral_regression.py
```
---
## Test Design Principles
### Contract Tests
- **No mocks** for the system under test (test real plugin loading)
- **Strict assertions** on types and behavior
- **Document architecture** in test names and docstrings
- **Fail fast** on architectural violations
### Behavioral Tests
- **Only public API** (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`)
- **Parametrized** across plugins
- **Realistic scenarios** (long text, unicode, mixed scripts)
- **No implementation coupling** (test behavior, not internals)
### General
- **Fast**: Unit tests < 1s, Contract tests < 5s, Behavioral < 30s
- **Isolated**: No shared state between tests
- **Deterministic**: Same input → same output
- **Descriptive names**: `test_<component>_<scenario>_<expected>`
-689
View File
@@ -1,689 +0,0 @@
# TTS Plugin Architecture — Final Specification
## 1. Core Domain
Zero dependencies. Pure business logic.
### 1.1 Engine
Factory for sessions. Stateless. Thread-safe for createSession().
```
interface Engine:
createSession() -> EngineSession
dispose() -> void
```
**createSession() contract**:
- Returns: EngineSession
- Raises: EngineError on failure
- Ownership: Transfers to caller
- Thread-safe: Yes
**dispose() contract**:
- Releases 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
- Idempotent: Safe to call multiple times
- Never raises: Catches and logs internally
- After dispose(): All methods except dispose() raise EngineError
### 1.2 EngineSession
Owns mutable execution state isolated from other concurrent work. NOT thread-safe.
```
interface EngineSession:
synthesize(request: SynthesisRequest) -> SynthesizedAudio
dispose() -> void
```
**synthesize() contract**:
- Returns: SynthesizedAudio
- Raises: EngineError on failure (session remains usable)
- Thread-safe: No
**dispose() contract**:
- Releases session resources
- Idempotent: Safe to call multiple times
- Never raises: Catches and logs internally
- After dispose(): All methods except dispose() raise EngineError
### 1.3 SynthesisRequest
Immutable value object.
```
SynthesisRequest:
text: string
voice: VoiceSelection
parameters: ParameterValues
format: AudioFormat
```
### 1.4 SynthesizedAudio
Immutable value object.
```
SynthesizedAudio:
data: bytes
format: AudioFormat
duration: Duration
```
### 1.5 VoiceSelection
Immutable value object. Opaque to engine.
```
VoiceSelection:
source: string
key: string
payload: any = None # Optional; required for clone/blend sources
```
### 1.6 ParameterValues
Immutable value object. Behaves like Mapping[str, Any].
```
ParameterValues:
values: Mapping[str, Any]
```
### 1.7 AudioFormat
Immutable value object.
```
AudioFormat:
mime: string
extension: string
```
### 1.8 Duration
Immutable value object.
```
Duration:
seconds: number
```
### 1.9 EngineConfig
Engine initialization settings only. No resource references.
```
EngineConfig:
device: string # "cpu", "cuda:0", etc.
# Engine-specific settings (if any)
# Unknown keys are ignored (no error)
```
---
## 2. Error Hierarchy
Typed exceptions. Engines raise EngineError or subtypes. Never raw exceptions.
```
EngineError (base)
├── ModelNotFoundError
├── ModelLoadError
├── NetworkError
├── InvalidInputError
├── ConfigurationError
├── CancelledError
└── InternalError
```
**Contract**:
- synthesize() raises EngineError on failure, session remains usable
- dispose() never raises (catches and logs internally)
- create_engine() raises EngineError on failure, cleans up partially created resources
- createSession() raises EngineError on failure, no partially initialized session returned
- cancel() causes synthesize() to raise CancelledError
---
## 3. Capability Interfaces (Optional)
Engines implement only what they support. Capabilities are additive.
### 3.1 VoiceLister
```
interface VoiceLister:
listVoices(sourceId: string) -> list[VoiceManifest]
```
### 3.2 PreviewGenerator
```
interface PreviewGenerator:
generatePreview(voice: VoiceSelection, text: string) -> SynthesizedAudio
```
### 3.3 ModelRequirements
Static at plugin level, not engine level. Host reads before creating engine.
```
MODEL_REQUIREMENTS = list[ModelManifest]
```
### 3.4 StreamingSynthesizer
Optional capability of EngineSession, not Engine.
```
interface StreamingSynthesizer:
synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]
```
**Iterator contract**:
- Yields audio chunks as they become available
- Raises CancelledError if cancel() is called during iteration
- Raises EngineError on synthesis failure
- Iterator exhaustion = synthesis complete
- Session remains usable after iterator completes
### 3.5 CancelableSession
Optional capability for engines that support cancellation.
```
interface CancelableSession:
cancel() -> void
```
**cancel() contract**:
- Cancels in-progress synthesize()
- synthesize() raises CancelledError (subtype of EngineError)
- EngineSession remains usable after cancellation (unless implementation documents otherwise)
---
## 4. Plugin Manifest
Static metadata. Immutable. No dependencies.
### 4.1 PluginManifest
```
PluginManifest:
id: string
name: string
version: string
api_version: string # semver format: MAJOR.MINOR
description: string
author: string
capabilities: list[string]
requires: RequirementManifest
engine: EngineManifest
```
**api_version contract**:
- Format: semver (MAJOR.MINOR)
- Compatibility: Host rejects plugin if major version differs
- Minor version: backward compatible, Host accepts higher minor
### 4.2 EngineManifest
```
EngineManifest:
voiceSources: list[VoiceSourceManifest]
parameters: list[ParameterManifest]
audioFormats: list[AudioFormatManifest]
```
### 4.3 VoiceSourceManifest
```
VoiceSourceManifest:
id: string
name: string
type: string # "list", "speaker_id", "clone", "blend", "generate", "none"
config: any
```
### 4.4 VoiceManifest
```
VoiceManifest:
id: string
name: string
tags: list[string]
```
### 4.5 ParameterManifest
```
ParameterManifest:
id: string
name: string
description: string
type: string # "float", "int", "string", "boolean", "enum"
default: any
min: number (optional)
max: number (optional)
step: number (optional)
options: list[EnumOption] (optional)
unit: string (optional)
group: string (optional)
```
### 4.6 AudioFormatManifest
```
AudioFormatManifest:
mime: string
extension: string
```
### 4.7 EnumOption
```
EnumOption:
value: string
label: string
```
### 4.8 RequirementManifest
```
RequirementManifest:
gpu: GpuRequirement (optional)
memory: number (optional)
internet: boolean (optional)
```
### 4.9 GpuRequirement
```
GpuRequirement:
required: boolean
type: string (optional)
memory: number (optional)
```
### 4.10 ModelManifest
```
ModelManifest:
id: string
name: string
size: string
```
---
## 5. Host Services
### 5.1 HostContext
Minimal. 3 fields maximum. No business logic.
```
HostContext:
config_dir: Path # For API keys, preferences
logger: Logger # For logging
http_client: HttpClient # For network requests
```
---
## 6. Plugin Contract
### 6.1 Plugin Exports
```python
# plugins/kokoro/__init__.py
PLUGIN_MANIFEST = PluginManifest(...)
MODEL_REQUIREMENTS = [...] # Static at plugin level
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig
) -> Engine:
"""Create engine. Atomic: succeeds fully or raises and cleans up."""
...
```
### 6.2 create_engine() Contract
- Parameters:
- context: HostContext (host services)
- model_path: Path | None (resolved model path, or None for cloud/no-model engines)
- config: EngineConfig (engine initialization settings)
- Returns: Engine
- Raises: EngineError on failure
- Atomic: Succeeds fully or cleans up and raises
- Thread-safe: Can be called from any thread
---
## 7. Object Lifecycle
### 7.1 Engine Lifecycle
```
1. DISCOVERY
Host scans plugin directories
Loads PLUGIN_MANIFEST and MODEL_REQUIREMENTS
2. MODEL DOWNLOAD (if MODEL_REQUIREMENTS non-empty)
Host reads MODEL_REQUIREMENTS
Downloads/caches required models
Resolves model_path for create_engine()
3. ACTIVATION
Host calls create_engine(context, model_path, config)
Engine created, ready to use
Raises EngineError on failure
4. SESSION CREATION
Client calls engine.createSession()
Returns EngineSession
Ownership transfers to caller
Raises EngineError on failure
No partially initialized session returned
5. SYNTHESIS
Client calls session.synthesize(request)
Returns SynthesizedAudio
Raises EngineError on failure (session remains usable)
6. SESSION DISPOSAL
Client calls session.dispose()
Releases session resources
7. DEACTIVATION
Client calls engine.dispose()
Caller must ensure all sessions are disposed first
Disposing engine while sessions are alive is undefined behavior
Releases engine resources
```
### 7.2 EngineSession Lifecycle
```
1. CREATION
Created by Engine.createSession()
Ownership transfers to caller
Raises EngineError on failure
2. USAGE
Client calls synthesize() one or more times
Each call returns SynthesizedAudio or raises EngineError
Session remains usable after synthesize() failure
If CancelableSession: cancel() causes synthesize() to raise CancelledError
If StreamingSynthesizer: iterator raises CancelledError on cancel(), EngineError on failure
3. DISPOSAL
Client calls dispose()
Releases session resources
After dispose(), all methods except dispose() raise EngineError
```
### 7.3 Ownership Rules
- Engine.createSession() transfers ownership of the returned session to the caller
- Caller is responsible for disposing all sessions before disposing the engine
- Engine does not track sessions; it has no lifecycle registry
- Disposing an engine while any session is still alive violates the API contract; behavior is undefined
- This design avoids coupling, synchronization overhead, and lifecycle registry complexity
### 7.4 Concurrent Operations
**Engine.dispose() concurrent with Engine.createSession()**:
- createSession() must either succeed with fully initialized EngineSession or raise EngineError
- Partially initialized EngineSession must never be returned
- After dispose() completes, subsequent createSession() calls must raise EngineError
**EngineSession.dispose() concurrent with EngineSession.synthesize()**:
- Not thread-safe. Caller must ensure synthesize() completes before dispose().
**EngineSession.dispose() concurrent with StreamingSynthesizer.synthesizeStream()**:
- Not thread-safe. Caller must ensure stream iteration completes before dispose().
---
## 8. Thread Safety Contract
| Component | Thread-safe | Notes |
|-----------|-------------|-------|
| Engine | Yes | createSession() can be called from any thread |
| EngineSession | No | synthesize() must be called from one thread at a time |
| HostContext | Yes | Provides shared services |
| VoiceSelection | Yes | Immutable value object |
| ParameterValues | Yes | Immutable value object |
| AudioFormat | Yes | Immutable value object |
| EngineConfig | Yes | Immutable value object |
---
## 9. dispose() Contract
**General rules**:
- Calling dispose() multiple times is safe (no-op on second call)
- dispose() never raises exceptions (catches and logs internally)
- After dispose(), all methods except dispose() raise EngineError
**Engine.dispose()**:
- Caller must ensure all sessions are disposed first
- Disposing engine while sessions are alive violates API contract; behavior is undefined
- Releases engine resources
**EngineSession.dispose()**:
- Releases session resources
---
## 10. Dependency Rules
```
Core Domain (Engine, EngineSession, Value Objects)
-> No dependencies
Plugin Manifest (PluginManifest, ModelManifest, etc.)
-> No dependencies
Host Context (HostContext)
-> Depends on: Core Domain (for types)
Plugin Implementation
-> Depends on: Core Domain, Host Context
Host
-> Depends on: Core Domain, Plugin Manifest
```
**Forbidden**:
- Core Domain -> anything else
- Plugin Manifest -> anything else
- Plugin Implementation -> Host (only receives HostContext)
- Host -> Plugin Implementation (only via create_engine function)
---
## 11. Architectural Invariants
1. Core Domain has zero dependencies
2. Plugins receive HostContext at creation, not via global state
3. Model requirements are static (plugin level), not dynamic (engine level)
4. Host validates capability implementation at load time: each capability declared in PluginManifest.capabilities must be implemented by the exported object via the corresponding interface
5. synthesize() raises typed exceptions, not returns Result
6. dispose() is idempotent and never raises
7. No global state, no service locator
8. VoiceSelection and ParameterValues are opaque to engine
9. Display information comes from VoiceManifest
10. HostContext is minimal (3 fields max)
11. EngineConfig contains only engine settings, not resource references
12. EngineSession owns mutable execution state isolated from other concurrent work
13. Engine.createSession() transfers ownership to caller
14. Caller must dispose all sessions before disposing engine
15. After dispose(), all methods except dispose() raise EngineError
16. create_engine() is atomic (all-or-nothing)
17. Garbage collection without dispose() may leak (documented)
18. Capabilities are additive (new capabilities don't break old plugins)
19. api_version enables compatibility checking
20. createSession() returns fully initialized session or raises, never partial
21. cancel() causes synthesize() to raise CancelledError
22. EngineSession remains usable after cancellation
23. Engine does not track sessions; no lifecycle registry
---
## 12. Validation Examples
### 12.1 Kokoro
```python
PLUGIN_MANIFEST = PluginManifest(
id="kokoro",
api_version="1.0",
capabilities=["voice_list", "preview", "voice_blend"],
engine=EngineManifest(
voiceSources=[
VoiceSourceManifest(id="builtin", type="list", config={"voices": [...]}),
VoiceSourceManifest(id="formula", type="blend", config={"syntax": "{a}*0.5+{b}*0.5"}),
],
parameters=[ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0)],
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
),
)
MODEL_REQUIREMENTS = []
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
model = load_kokoro(model_path)
return KokoroEngine(model, config.device)
```
### 12.2 SuperTonic
```python
PLUGIN_MANIFEST = PluginManifest(
id="supertonic",
api_version="1.0",
capabilities=["voice_list", "preview"],
engine=EngineManifest(
voiceSources=[VoiceSourceManifest(id="builtin", type="list", config={"voices": [...]})],
parameters=[
ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0),
ParameterManifest(id="steps", type="int", default=20, min=5, max=50),
],
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
),
)
MODEL_REQUIREMENTS = []
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
model = load_supertonic(model_path)
return SuperTonicEngine(model, config.device)
```
### 12.3 ElevenLabs
```python
PLUGIN_MANIFEST = PluginManifest(
id="elevenlabs",
api_version="1.0",
capabilities=["voice_list"],
requires=RequirementManifest(internet=True),
engine=EngineManifest(
voiceSources=[VoiceSourceManifest(id="cloud", type="list", config={"speakers": [...]})],
parameters=[ParameterManifest(id="stability", type="float", default=0.5, min=0.0, max=1.0)],
audioFormats=[AudioFormatManifest(mime="audio/mpeg", extension="mp3")],
),
)
MODEL_REQUIREMENTS = []
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
api_key = (context.config_dir / "elevenlabs_key").read_text()
return ElevenLabsEngine(api_key)
```
### 12.4 Piper
```python
PLUGIN_MANIFEST = PluginManifest(
id="piper",
api_version="1.0",
capabilities=[],
engine=EngineManifest(
voiceSources=[VoiceSourceManifest(id="downloadable", type="list", config={"models": [...]})],
parameters=[ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0)],
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
),
)
MODEL_REQUIREMENTS = [
ModelManifest(id="en_US-lessac-medium", name="English Lessac Medium", size="100MB"),
]
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
return PiperEngine(model_path, config.device)
```
### 12.5 XTTS (with streaming and cancellation)
```python
PLUGIN_MANIFEST = PluginManifest(
id="xtts",
api_version="1.0",
capabilities=["voice_list", "preview", "voice_clone", "streaming", "cancel"],
requires=RequirementManifest(gpu=GpuRequirement(required=True, type="cuda")),
engine=EngineManifest(
voiceSources=[
VoiceSourceManifest(id="speakers", type="speaker_id", config={"speakers": [...]}),
VoiceSourceManifest(id="clone", type="clone", config={"requiresAudio": True, "maxDuration": 30}),
],
parameters=[ParameterManifest(id="temperature", type="float", default=0.7, min=0.1, max=1.0)],
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
),
)
MODEL_REQUIREMENTS = [
ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB"),
]
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
return XTTSEngine(model_path, config.device)
```
XTTS session implements: EngineSession, StreamingSynthesizer, CancelableSession.
---
## 13. Summary of All Decisions
| Aspect | Decision |
|--------|----------|
| Engine | Factory, stateless, thread-safe for createSession() |
| EngineSession | Owns mutable execution state, not thread-safe |
| EngineSession ownership | Caller owns (transferred from createSession) |
| Engine session tracking | None; engine does not track sessions |
| StreamingSynthesizer | Optional capability of EngineSession |
| CancelableSession | Optional capability, cancel() raises CancelledError |
| dispose() | Idempotent, never raises |
| Engine.dispose() | Caller must dispose sessions first; undefined if violated |
| createSession() | Raises EngineError on failure, no partial sessions |
| create_engine() | Atomic, takes context, model_path, config |
| EngineConfig | Engine settings only, no resource references |
| model_path | Separate argument, not in EngineConfig |
| MODEL_REQUIREMENTS | Static at plugin level |
| HostContext | Minimal (3 fields) |
| Error handling | Typed exceptions (EngineError hierarchy) |
| Thread safety | Documented per component |
| Capabilities | Additive, optional interfaces |
| API versioning | api_version in manifest |
| Concurrent dispose/createSession | Fully initialized session or EngineError |
| Concurrent dispose/synthesizeStream | Not thread-safe; caller must complete iteration first |
-185
View File
@@ -1,185 +0,0 @@
"""Kokoro TTS Plugin for the TTS Plugin Architecture.
This plugin provides a Kokoro-based TTS engine that implements the
Plugin API contract. It wraps the existing Kokoro backend in the
new Engine/EngineSession architecture.
Exports:
- PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Factory function
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
ModelManifest,
ParameterManifest,
PluginManifest,
RequirementManifest,
VoiceManifest,
VoiceSourceManifest,
)
from abogen.tts_plugin.types import EngineConfig
from .engine import KokoroEngine
def _load_kpipeline() -> Any:
"""Lazy-load Kokoro dependencies."""
# Transformers 5.x moved AlbertModel out of top-level imports.
# Monkey-patch before kokoro imports it.
import transformers
if not hasattr(transformers, "AlbertModel"):
from transformers.models.albert import AlbertModel as _AlbertModel
transformers.AlbertModel = _AlbertModel
from kokoro import KPipeline # type: ignore[import-not-found]
return KPipeline
PLUGIN_MANIFEST = PluginManifest(
id="kokoro",
name="Kokoro",
version="0.9.4",
api_version="1.0",
description="Kokoro TTS engine - high quality multilingual text-to-speech",
author="Kokoro Team",
capabilities=("voice_list",),
requires=RequirementManifest(
internet=False,
),
engine=EngineManifest(
voiceSources=(
VoiceSourceManifest(
id="builtin",
name="Built-in Voices",
type="list",
config={"voices": "See listVoices()"},
),
),
parameters=(
ParameterManifest(
id="speed",
name="Speed",
description="Speech speed multiplier",
type="float",
default=1.0,
min=0.5,
max=2.0,
step=0.1,
),
),
audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"),
),
),
voices=(
VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")),
VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")),
VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")),
VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")),
VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")),
VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")),
VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
VoiceManifest(id="af_river", name="River", tags=("en", "female")),
VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")),
VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")),
VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")),
VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")),
VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")),
VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")),
VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")),
VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")),
VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")),
VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")),
VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")),
VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")),
VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")),
VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")),
VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")),
VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")),
VoiceManifest(id="bm_george", name="George", tags=("en", "male")),
VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")),
VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")),
VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")),
VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")),
VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")),
VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")),
VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")),
VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")),
VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")),
VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")),
VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")),
VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")),
VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")),
VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")),
VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")),
VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")),
VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")),
VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")),
VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")),
VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")),
VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")),
VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")),
VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")),
VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")),
),
)
MODEL_REQUIREMENTS: list[ModelManifest] = []
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create a Kokoro engine instance.
This function is the plugin entry point. 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 default.
config: Engine initialization settings (device, etc.).
Returns:
A fully initialized KokoroEngine instance.
Raises:
EngineError: On failure. Cleans up partially created resources.
"""
try:
KPipeline = _load_kpipeline()
# Determine repo_id from model_path or use default
repo_id = "hexgrad/Kokoro-82M"
if model_path is not None:
# If a specific model path is provided, use it as repo_id
repo_id = str(model_path)
pipeline = KPipeline(
lang_code=config.lang_code,
repo_id=repo_id,
device=config.device,
)
engine = KokoroEngine(pipeline)
return engine
except Exception as e:
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e
-118
View File
@@ -1,118 +0,0 @@
"""Kokoro Engine adapter for the TTS Plugin Architecture.
This module adapts the existing Kokoro backend to the new Engine/EngineSession
protocol. It wraps the KokoroBackend without modifying it.
"""
from __future__ import annotations
import logging
from typing import Any
import numpy as np
from abogen.tts_plugin.capabilities import VoiceLister
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
SynthesisRequest,
SynthesizedAudio,
)
logger = logging.getLogger(__name__)
# Sample rate for Kokoro audio
_KOKORO_SAMPLE_RATE = 24000
class KokoroSession:
"""EngineSession implementation for Kokoro.
Owns mutable execution state for synthesis.
NOT thread-safe.
"""
def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text using Kokoro."""
if self._disposed:
raise EngineError("Session disposed")
try:
voice = request.voice.key
speed = request.parameters.values.get("speed", 1.0)
split_pattern = request.parameters.values.get("split_pattern", None)
audio_parts: list[np.ndarray] = []
for segment in self._pipeline(
request.text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
):
audio = segment.audio
if hasattr(audio, "numpy"):
audio = audio.numpy()
audio_parts.append(np.asarray(audio, dtype="float32"))
if not audio_parts:
return SynthesizedAudio(
data=b"",
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.0),
)
combined = np.concatenate(audio_parts).astype("float32", copy=False)
audio_bytes = combined.tobytes()
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
return SynthesizedAudio(
data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds),
)
except EngineError:
raise
except Exception as e:
raise EngineError(f"Synthesis failed: {e}") from e
def dispose(self) -> None:
"""Release session resources. Idempotent."""
self._disposed = True
class KokoroEngine:
"""Engine implementation for Kokoro.
Factory for KokoroSession instances. Stateless and thread-safe.
"""
def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline
self._disposed = False
def createSession(self) -> KokoroSession:
"""Create a new KokoroSession."""
if self._disposed:
raise EngineError("Engine disposed")
return KokoroSession(self._pipeline)
def dispose(self) -> None:
"""Release engine resources. Idempotent."""
self._disposed = True
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available Kokoro voices. Implements VoiceLister capability.
Note: Static voices are declared in the plugin manifest.
This method is a fallback for dynamic plugins.
"""
if self._disposed:
raise EngineError("Engine disposed")
return []
-136
View File
@@ -1,136 +0,0 @@
"""SuperTonic TTS Plugin for the TTS Plugin Architecture.
This plugin provides a SuperTonic-based TTS engine that implements the
Plugin API contract. It wraps the existing SuperTonic backend in the
new Engine/EngineSession architecture.
Exports:
- PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Factory function
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
ModelManifest,
ParameterManifest,
PluginManifest,
RequirementManifest,
VoiceManifest,
VoiceSourceManifest,
)
from abogen.tts_plugin.types import EngineConfig
from .engine import SuperTonicEngine
def _load_supertonic_pipeline() -> Any:
"""Lazy-load SuperTonic dependencies and create pipeline."""
from plugins.supertonic.pipeline import SupertonicPipeline
return SupertonicPipeline(
sample_rate=24000,
auto_download=True,
total_steps=5,
)
PLUGIN_MANIFEST = PluginManifest(
id="supertonic",
name="SuperTonic",
version="0.1.0",
api_version="1.0",
description="SuperTonic TTS engine - fast high-quality text-to-speech",
author="SuperTonic Team",
capabilities=("voice_list",),
requires=RequirementManifest(
internet=False,
),
engine=EngineManifest(
voiceSources=(
VoiceSourceManifest(
id="builtin",
name="Built-in Voices",
type="list",
config={"voices": "See listVoices()"},
),
),
parameters=(
ParameterManifest(
id="speed",
name="Speed",
description="Speech speed multiplier",
type="float",
default=1.0,
min=0.7,
max=2.0,
step=0.1,
),
ParameterManifest(
id="total_steps",
name="Quality Steps",
description="Inference steps (higher = better quality, slower)",
type="int",
default=5,
min=2,
max=15,
step=1,
),
),
audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"),
),
),
voices=(
VoiceManifest(id="M1", name="Male 1", tags=("male",)),
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
VoiceManifest(id="M4", name="Male 4", tags=("male",)),
VoiceManifest(id="M5", name="Male 5", tags=("male",)),
VoiceManifest(id="F1", name="Female 1", tags=("female",)),
VoiceManifest(id="F2", name="Female 2", tags=("female",)),
VoiceManifest(id="F3", name="Female 3", tags=("female",)),
VoiceManifest(id="F4", name="Female 4", tags=("female",)),
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
),
)
MODEL_REQUIREMENTS: list[ModelManifest] = []
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create a SuperTonic engine instance.
This function is the plugin entry point. 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 default.
config: Engine initialization settings (device, etc.).
Returns:
A fully initialized SuperTonicEngine instance.
Raises:
EngineError: On failure. Cleans up partially created resources.
"""
try:
pipeline = _load_supertonic_pipeline()
engine = SuperTonicEngine(pipeline)
return engine
except Exception as e:
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
-125
View File
@@ -1,125 +0,0 @@
"""SuperTonic Engine adapter for the TTS Plugin Architecture.
This module adapts the existing SuperTonic backend to the new Engine/EngineSession
protocol. It wraps the SupertonicPipeline without modifying it.
"""
from __future__ import annotations
import io
import logging
from typing import Any
import numpy as np
from abogen.tts_plugin.capabilities import VoiceLister
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
SynthesisRequest,
SynthesizedAudio,
)
logger = logging.getLogger(__name__)
# Sample rate for SuperTonic audio
_SUPERTONIC_SAMPLE_RATE = 24000
class SuperTonicSession:
"""EngineSession implementation for SuperTonic.
Owns mutable execution state for synthesis.
NOT thread-safe.
"""
def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"""Synthesize audio from text using SuperTonic."""
if self._disposed:
raise EngineError("Session disposed")
try:
import soundfile as sf
voice = request.voice.key
speed = float(request.parameters.values.get("speed", 1.0))
total_steps = request.parameters.values.get("total_steps", None)
split_pattern = request.parameters.values.get("split_pattern", None)
if total_steps is not None:
total_steps = int(total_steps)
audio_parts: list[np.ndarray] = []
for segment in self._pipeline(
request.text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
total_steps=total_steps,
):
audio_parts.append(segment.audio)
if not audio_parts:
return SynthesizedAudio(
data=b"",
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.0),
)
combined = np.concatenate(audio_parts).astype("float32", copy=False)
buf = io.BytesIO()
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
audio_bytes = buf.getvalue()
duration_seconds = len(combined) / self._pipeline.sample_rate
return SynthesizedAudio(
data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds),
)
except EngineError:
raise
except Exception as e:
raise EngineError(f"Synthesis failed: {e}") from e
def dispose(self) -> None:
"""Release session resources. Idempotent."""
self._disposed = True
class SuperTonicEngine:
"""Engine implementation for SuperTonic.
Factory for SuperTonicSession instances. Stateless and thread-safe.
"""
def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline
self._disposed = False
def createSession(self) -> SuperTonicSession:
"""Create a new SuperTonicSession."""
if self._disposed:
raise EngineError("Engine disposed")
return SuperTonicSession(self._pipeline)
def dispose(self) -> None:
"""Release engine resources. Idempotent."""
self._disposed = True
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available SuperTonic voices. Implements VoiceLister capability.
Note: Static voice catalog is declared in plugin manifest.
This method is retained for VoiceLister interface compliance.
"""
if self._disposed:
raise EngineError("Engine disposed")
return []
+6 -3
View File
@@ -44,12 +44,14 @@ dependencies = [
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"static_ffmpeg>=2.13", "static_ffmpeg>=2.13",
"Markdown>=3.9", "Markdown>=3.9",
"Flask>=3.1.0", "Flask>=3.0.3",
"numpy>=1.24.0", "numpy>=1.24.0",
"gpustat>=1.1.1", "gpustat>=1.1.1",
"num2words>=0.5.13", "num2words>=0.5.13",
"httpx>=0.27.0", "httpx>=0.27.0",
"PyQt6>=6.5.0", "PyQt6>=6.5.0",
"flet>=0.85.1",
"msgpack>=1.0.0",
] ]
classifiers = [ classifiers = [
@@ -77,11 +79,12 @@ allow-direct-references = true
[project.gui-scripts] [project.gui-scripts]
abogen = "abogen.pyqt.main:main" abogen = "abogen.frontend.main:main"
[project.scripts] [project.scripts]
abogen-ui = "abogen.frontend.main:main"
abogen-web = "abogen.frontend.main:main_web"
abogen-cli = "abogen.webui.app:main" abogen-cli = "abogen.webui.app:main"
abogen-web = "abogen.webui.app:main"
abogen-pyqt = "abogen.pyqt.main:main" abogen-pyqt = "abogen.pyqt.main:main"
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
-5
View File
@@ -1,5 +0,0 @@
"""Contract tests for the TTS Plugin API.
This package contains reusable contract tests that any TTS plugin implementation
must satisfy. Tests use only the public API and are engine-agnostic.
"""
-231
View File
@@ -1,231 +0,0 @@
"""Shared fixtures and stubs for contract tests.
This module provides minimal stub implementations that satisfy the public API
for testing purposes. These stubs do NOT contain real business logic.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterator
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class FakeHttpClient:
"""Stub HTTP client that satisfies the HttpClient protocol."""
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
class FakeEngineSession:
"""Stub EngineSession for testing protocol compliance."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class FakeStreamingSession:
"""Stub EngineSession with StreamingSynthesizer capability."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
for i in range(3):
yield b"\x00" * 50
def dispose(self) -> None:
self._disposed = True
class FakeCancelableSession:
"""Stub EngineSession with CancelableSession capability."""
def __init__(self) -> None:
self._disposed = False
self._cancelled = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
if self._cancelled:
from abogen.tts_plugin.errors import CancelledError
raise CancelledError("Cancelled")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def cancel(self) -> None:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Session disposed")
self._cancelled = True
def dispose(self) -> None:
self._disposed = True
class FakeEngine:
"""Stub Engine for testing protocol compliance."""
def __init__(self, session_class: type = FakeEngineSession) -> None:
self._disposed = False
self._session_class = session_class
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return self._session_class()
def dispose(self) -> None:
self._disposed = True
class FakeVoiceListerEngine:
"""Stub Engine that also implements VoiceLister."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return FakeEngineSession()
def listVoices(self, sourceId: str) -> list:
from abogen.tts_plugin.manifest import VoiceManifest
return [
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
]
def dispose(self) -> None:
self._disposed = True
class FakePreviewEngine:
"""Stub Engine that also implements PreviewGenerator."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
from abogen.tts_plugin.errors import EngineError
raise EngineError("Engine disposed")
return FakeEngineSession()
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
return SynthesizedAudio(
data=b"\x00" * 50,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.5),
)
def dispose(self) -> None:
self._disposed = True
@pytest.fixture
def fake_http_client() -> FakeHttpClient:
return FakeHttpClient()
@pytest.fixture
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
return HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=fake_http_client,
)
@pytest.fixture
def fake_engine() -> FakeEngine:
return FakeEngine()
@pytest.fixture
def fake_session() -> FakeEngineSession:
return FakeEngineSession()
@pytest.fixture
def default_voice() -> VoiceSelection:
return VoiceSelection(source="builtin", key="af_nova")
@pytest.fixture
def default_format() -> AudioFormat:
return AudioFormat(mime="audio/wav", extension="wav")
@pytest.fixture
def default_request(
default_voice: VoiceSelection, default_format: AudioFormat
) -> SynthesisRequest:
return SynthesisRequest(
text="Hello, world!",
voice=default_voice,
parameters=ParameterValues(values={}),
format=default_format,
)
-120
View File
@@ -1,120 +0,0 @@
"""Base contract tests for Engine implementations.
Any new TTS plugin must inherit from these classes to verify
it satisfies the Engine/EngineSession protocol.
Usage:
from tests.contracts.engine_contract import EngineContractMixin
class TestMyEngine(EngineContractMixin):
@pytest.fixture
def engine(self):
return create_my_engine()
"""
from __future__ import annotations
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import (
AudioFormat,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class EngineContractMixin:
"""Base contract tests for Engine implementations.
Subclasses must define a module-level ``engine`` fixture returning
a fully initialized Engine instance. The tests below will use it
via pytest's standard fixture resolution.
"""
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
return SynthesisRequest(
text=text,
voice=VoiceSelection(source="builtin", key=voice or "default"),
parameters=ParameterValues(values={}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# ── Engine protocol ──────────────────────────────────────
def test_engine_satisfies_protocol(self, engine: Engine) -> None:
assert isinstance(engine, Engine)
def test_create_session_returns_session(self, engine: Engine) -> None:
session = engine.createSession()
assert isinstance(session, EngineSession)
session.dispose()
def test_create_session_returns_new_instances(self, engine: Engine) -> None:
s1 = engine.createSession()
s2 = engine.createSession()
assert s1 is not s2
s1.dispose()
s2.dispose()
def test_dispose_is_idempotent(self, engine: Engine) -> None:
engine.dispose()
engine.dispose()
def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
# ── Session protocol ─────────────────────────────────────
def test_session_satisfies_protocol(self, engine: Engine) -> None:
session = engine.createSession()
assert isinstance(session, EngineSession)
session.dispose()
engine.dispose()
def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
session = engine.createSession()
result = session.synthesize(self._req())
assert isinstance(result, SynthesizedAudio)
assert isinstance(result.data, bytes)
assert len(result.data) > 0
session.dispose()
engine.dispose()
def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
session = engine.createSession()
session.dispose()
session.dispose()
engine.dispose()
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
session = engine.createSession()
session.dispose()
with pytest.raises(EngineError):
session.synthesize(self._req())
engine.dispose()
def test_session_multiple_synthesize(self, engine: Engine) -> None:
session = engine.createSession()
r1 = session.synthesize(self._req())
r2 = session.synthesize(self._req())
assert isinstance(r1.data, bytes)
assert isinstance(r2.data, bytes)
session.dispose()
engine.dispose()
# ── Lifecycle ────────────────────────────────────────────
def test_full_lifecycle(self, engine: Engine) -> None:
s1 = engine.createSession()
s2 = engine.createSession()
s1.synthesize(self._req())
s2.synthesize(self._req())
s1.dispose()
s2.dispose()
engine.dispose()
@@ -1,183 +0,0 @@
"""Contract tests for capability interfaces.
These tests verify that capability interfaces satisfy the architectural requirements:
- VoiceLister: lists voices for a source
- PreviewGenerator: generates preview audio
- StreamingSynthesizer: yields audio chunks
- CancelableSession: cancels in-progress synthesis
"""
import pytest
from abogen.tts_plugin.capabilities import (
CancelableSession,
PreviewGenerator,
StreamingSynthesizer,
VoiceLister,
)
from abogen.tts_plugin.errors import CancelledError, EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
class TestVoiceListerProtocolContract:
"""Contract tests for VoiceLister protocol."""
def test_voice_lister_is_protocol(self) -> None:
assert hasattr(VoiceLister, "__protocol_attrs__")
def test_voice_lister_satisfied_by_engine(self) -> None:
engine = FakeVoiceListerEngine()
assert isinstance(engine, VoiceLister)
def test_list_voices_returns_list(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
assert isinstance(voices, list)
def test_list_voices_returns_voice_manifests(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
for voice in voices:
assert isinstance(voice, VoiceManifest)
def test_list_voices_has_required_fields(self) -> None:
engine = FakeVoiceListerEngine()
voices = engine.listVoices("builtin")
for voice in voices:
assert hasattr(voice, "id")
assert hasattr(voice, "name")
assert hasattr(voice, "tags")
class TestPreviewGeneratorProtocolContract:
"""Contract tests for PreviewGenerator protocol."""
def test_preview_generator_is_protocol(self) -> None:
assert hasattr(PreviewGenerator, "__protocol_attrs__")
def test_preview_generator_satisfied_by_engine(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
assert isinstance(engine, PreviewGenerator)
def test_generate_preview_returns_synthesized_audio(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello")
assert isinstance(result, SynthesizedAudio)
def test_generate_preview_has_valid_data(self) -> None:
from .conftest import FakePreviewEngine
engine = FakePreviewEngine()
voice = VoiceSelection(source="builtin", key="af_nova")
result = engine.generatePreview(voice, "Hello")
assert isinstance(result.data, bytes)
assert len(result.data) > 0
class TestStreamingSynthesizerProtocolContract:
"""Contract tests for StreamingSynthesizer protocol."""
def test_streaming_synthesizer_is_protocol(self) -> None:
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
def test_streaming_session_satisfies_protocol(self) -> None:
session = FakeStreamingSession()
assert isinstance(session, StreamingSynthesizer)
def test_synthesize_stream_yields_bytes(self) -> None:
session = FakeStreamingSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
chunks = list(session.synthesizeStream(request))
assert len(chunks) > 0
for chunk in chunks:
assert isinstance(chunk, bytes)
def test_streaming_iterator_exhaustion(self) -> None:
"""Architecture spec: Iterator exhaustion = synthesis complete."""
session = FakeStreamingSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
chunks = list(session.synthesizeStream(request))
assert len(chunks) == 3
def test_streaming_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), methods raise EngineError."""
session = FakeStreamingSession()
session.dispose()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
list(session.synthesizeStream(request))
class TestCancelableSessionProtocolContract:
"""Contract tests for CancelableSession protocol."""
def test_cancelable_session_is_protocol(self) -> None:
assert hasattr(CancelableSession, "__protocol_attrs__")
def test_cancelable_session_satisfies_protocol(self) -> None:
session = FakeCancelableSession()
assert isinstance(session, CancelableSession)
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
session = FakeCancelableSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Cancel
session.cancel()
# synthesize should raise CancelledError
with pytest.raises(CancelledError):
session.synthesize(request)
def test_cancel_after_dispose_raises(self) -> None:
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
session = FakeCancelableSession()
session.dispose()
with pytest.raises(EngineError):
session.cancel()
def test_session_usable_after_cancel(self) -> None:
"""Architecture spec: EngineSession remains usable after cancellation."""
session = FakeCancelableSession()
# Cancel
session.cancel()
# Dispose and create new session for synthesis
session.dispose()
-106
View File
@@ -1,106 +0,0 @@
"""Contract tests for Engine protocol.
These tests verify that Engine implementations satisfy the architectural requirements:
- createSession() returns EngineSession
- dispose() is idempotent
- After dispose(), createSession() raises EngineError
- Engine is thread-safe for createSession()
"""
import pytest
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from .conftest import FakeEngine, FakeEngineSession
class TestEngineProtocolContract:
"""Contract tests for the Engine protocol itself."""
def test_engine_is_protocol(self) -> None:
assert hasattr(Engine, "__protocol_attrs__")
def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_engine_satisfies_protocol(self) -> None:
engine = FakeEngine()
assert isinstance(engine, Engine)
def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession()
assert isinstance(session, EngineSession)
class TestEngineCreateSessionContract:
"""Contract tests for Engine.createSession()."""
def test_create_session_returns_engine_session(self) -> None:
engine = FakeEngine()
session = engine.createSession()
assert isinstance(session, EngineSession)
def test_create_session_returns_new_instance(self) -> None:
engine = FakeEngine()
session1 = engine.createSession()
session2 = engine.createSession()
assert session1 is not session2
def test_create_session_ownership_transfers(self) -> None:
"""Architecture spec: Ownership transfers to caller."""
engine = FakeEngine()
session = engine.createSession()
assert isinstance(session, EngineSession)
class TestEngineDisposeContract:
"""Contract tests for Engine.dispose()."""
def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent."""
engine = FakeEngine()
engine.dispose()
engine.dispose() # Should not raise
def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions."""
engine = FakeEngine()
engine.dispose() # Should not raise
def test_create_session_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
class TestEngineLifecycleContract:
"""Contract tests for Engine lifecycle."""
def test_full_lifecycle(self) -> None:
"""Test complete engine lifecycle: create -> sessions -> dispose."""
engine = FakeEngine()
# Create sessions
session1 = engine.createSession()
session2 = engine.createSession()
# Use sessions
assert isinstance(session1, EngineSession)
assert isinstance(session2, EngineSession)
# Dispose sessions
session1.dispose()
session2.dispose()
# Dispose engine
engine.dispose()
def test_engine_disposed_session_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
engine = FakeEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
-85
View File
@@ -1,85 +0,0 @@
"""Contract tests for error hierarchy.
These tests verify that the error hierarchy satisfies the architectural requirements:
- All errors inherit from EngineError
- EngineError inherits from Exception
- Each error type is properly classified
"""
import pytest
from abogen.tts_plugin.errors import (
CancelledError,
ConfigurationError,
EngineError,
InternalError,
InvalidInputError,
ModelLoadError,
ModelNotFoundError,
NetworkError,
)
class TestErrorHierarchyContract:
"""Contract tests for the error hierarchy."""
def test_engine_error_is_exception(self) -> None:
assert issubclass(EngineError, Exception)
def test_all_errors_inherit_from_engine_error(self) -> None:
error_classes = [
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
assert issubclass(error_class, EngineError), (
f"{error_class.__name__} must inherit from EngineError"
)
def test_all_errors_are_catchable(self) -> None:
error_classes = [
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for error_class in error_classes:
with pytest.raises(EngineError):
raise error_class("test message")
def test_error_message_preserved(self) -> None:
msg = "Model not found: bert-base"
with pytest.raises(ModelNotFoundError, match=msg):
raise ModelNotFoundError(msg)
def test_error_can_be_caught_as_engine_error(self) -> None:
with pytest.raises(EngineError):
raise ModelNotFoundError("test")
def test_cancelled_error_is_engine_error(self) -> None:
"""CancelledError is a subtype of EngineError per architecture spec."""
assert issubclass(CancelledError, EngineError)
def test_error_hierarchy_no_cycles(self) -> None:
"""Verify no circular inheritance."""
error_classes = [
EngineError,
ModelNotFoundError,
ModelLoadError,
NetworkError,
InvalidInputError,
ConfigurationError,
CancelledError,
InternalError,
]
for cls in error_classes:
assert cls not in cls.__bases__
@@ -1,89 +0,0 @@
"""Contract tests for HostContext.
These tests verify that HostContext satisfies the architectural requirements:
- Minimal (3 fields maximum)
- Frozen dataclass
- config_dir: Path
- logger: Logger
- http_client: HttpClient protocol
"""
import logging
from pathlib import Path
import pytest
from abogen.tts_plugin.host_context import HttpClient, HostContext
class TestHostContextContract:
"""Contract tests for HostContext dataclass."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(HostContext, "__dataclass_params__")
assert HostContext.__dataclass_params__.frozen is True
def test_required_fields(self, tmp_path: Path) -> None:
logger = logging.getLogger("test")
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
ctx = HostContext(
config_dir=tmp_path,
logger=logger,
http_client=FakeClient(),
)
assert ctx.config_dir == tmp_path
assert ctx.logger is logger
def test_immutability(self, tmp_path: Path) -> None:
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
ctx = HostContext(
config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeClient(),
)
with pytest.raises(AttributeError):
ctx.config_dir = Path("/other") # type: ignore[misc]
def test_max_three_fields(self) -> None:
"""Architecture spec: HostContext is minimal (3 fields max)."""
import dataclasses
fields = dataclasses.fields(HostContext)
assert len(fields) <= 3
class TestHttpClientProtocolContract:
"""Contract tests for HttpClient protocol."""
def test_http_client_is_protocol(self) -> None:
assert hasattr(HttpClient, "__protocol_attrs__")
def test_http_client_has_get(self) -> None:
assert hasattr(HttpClient, "get")
def test_http_client_has_post(self) -> None:
assert hasattr(HttpClient, "post")
def test_http_client_satisfied(self) -> None:
class FakeClient:
def get(self, url: str, **kwargs: object) -> object:
return None
def post(self, url: str, **kwargs: object) -> object:
return None
client = FakeClient()
assert isinstance(client, HttpClient)
-420
View File
@@ -1,420 +0,0 @@
"""Integration tests for the TTS Plugin Architecture.
These tests verify:
1. Consumer Flow: consumer plugin engine session synthesis result
2. Lifecycle: dispose, no leaks, error handling
3. Regression: old path vs new path equivalence
Tests use mock plugins to avoid requiring real TTS dependencies.
"""
import pytest
from typing import Any, Iterator
from unittest.mock import MagicMock, patch
import numpy as np
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class MockEngineSession:
"""Mock EngineSession that records calls for verification."""
def __init__(self):
self._disposed = False
self.synthesize_calls = []
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
self.synthesize_calls.append(request)
# Return fake audio
audio = np.ones(1000, dtype=np.float32) * 0.5
return SynthesizedAudio(
data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1000 / 24000),
)
def dispose(self) -> None:
self._disposed = True
class MockEngine:
"""Mock Engine that creates MockEngineSessions."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._disposed = False
self.sessions_created = []
def createSession(self) -> MockEngineSession:
if self._disposed:
raise EngineError("Engine disposed")
session = MockEngineSession()
self.sessions_created.append(session)
return session
def dispose(self) -> None:
self._disposed = True
def create_mock_plugin(create_engine_func=None):
"""Helper to create a mock plugin module."""
if create_engine_func is None:
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
manifest = PluginManifest(
id="mock_tts",
name="Mock TTS",
version="1.0.0",
api_version="1.0",
description="Mock TTS for testing",
author="Test",
capabilities=(),
requires=None,
engine=EngineManifest(
voiceSources=(),
parameters=(),
audioFormats=(),
),
)
return {
"PLUGIN_MANIFEST": manifest,
"MODEL_REQUIREMENTS": [],
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
}
def create_mock_plugin_engine(**kwargs):
"""Default mock plugin engine factory."""
return MockEngine(**kwargs)
class TestConsumerFlow:
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
def test_full_consumer_flow(self):
"""Verify complete flow from consumer to audio output."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Step 1: Consumer gets plugin
assert manager.has_plugin("mock_tts") is True
# Step 2: Plugin creates engine
engine = manager.create_engine("mock_tts")
assert engine is not None
assert isinstance(engine, MockEngine)
# Step 3: Engine creates session
session = engine.createSession()
assert session is not None
assert isinstance(session, MockEngineSession)
# Step 4: Session synthesizes
request = SynthesisRequest(
text="Hello world",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
# Step 5: Result returned
assert result is not None
assert isinstance(result, SynthesizedAudio)
assert len(result.data) > 0
assert result.format.mime == "audio/wav"
assert result.duration.seconds > 0
def test_consumer_flow_via_pipeline(self):
"""Verify flow through Pipeline utility matches direct flow."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Use Pipeline utility
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts")
# Call like old TTSBackend
segments = list(backend("Hello world", voice="default", speed=1.0))
# Verify result
assert len(segments) >= 1
segment = segments[0]
assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world"
class TestLifecycle:
"""Lifecycle Test: dispose, no leaks, error handling"""
def test_session_dispose_is_idempotent(self):
"""dispose() can be called multiple times safely."""
session = MockEngineSession()
session.dispose()
session.dispose() # Should not raise
assert session._disposed is True
def test_session_synthesize_after_dispose_raises(self):
"""synthesize() after dispose() raises EngineError."""
session = MockEngineSession()
session.dispose()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
def test_engine_dispose_is_idempotent(self):
"""Engine dispose() can be called multiple times safely."""
engine = MockEngine()
engine.dispose()
engine.dispose() # Should not raise
assert engine._disposed is True
def test_engine_create_session_after_dispose_raises(self):
"""createSession() after dispose() raises EngineError."""
engine = MockEngine()
engine.dispose()
with pytest.raises(EngineError):
engine.createSession()
def test_full_lifecycle(self):
"""Test complete lifecycle: create → use → dispose."""
engine = MockEngine()
# Create and use session
session = engine.createSession()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert len(result.data) > 0
# Dispose session
session.dispose()
assert session._disposed is True
# Dispose engine
engine.dispose()
assert engine._disposed is True
def test_no_session_leak_on_engine_dispose(self):
"""Engine can be disposed even if sessions were created."""
engine = MockEngine()
# Create multiple sessions
session1 = engine.createSession()
session2 = engine.createSession()
# Use sessions
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
session1.synthesize(request)
session2.synthesize(request)
# Dispose engine (sessions still exist but engine is disposed)
engine.dispose()
assert engine._disposed is True
# Sessions can still be used (they hold reference to pipeline)
result = session1.synthesize(request)
assert len(result.data) > 0
def test_error_handling_in_synthesis(self):
"""Error during synthesis is handled correctly."""
class FailingSession:
def synthesize(self, request):
raise EngineError("Synthesis failed")
def dispose(self):
pass
session = FailingSession()
request = SynthesisRequest(
text="test",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError, match="Synthesis failed"):
session.synthesize(request)
class TestRegression:
"""Regression Test: old path vs new path equivalence"""
def test_old_path_vs_new_path_same_result(self):
"""Both paths should produce equivalent results."""
# Setup mock plugin
manager = PluginManager()
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# New path: Plugin Manager → Engine → Session → Synthesis
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
new_backend = create_pipeline("mock_tts")
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
# Old path: Direct MockEngine (simulating old registry)
old_engine = MockEngine()
old_session = old_engine.createSession()
request = SynthesisRequest(
text="Hello world",
voice=VoiceSelection(source="builtin", key="default"),
parameters=ParameterValues(values={"speed": 1.0}),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
old_result = old_session.synthesize(request)
# Compare results
# New path returns segments, old path returns SynthesizedAudio
# But both should have valid audio data
assert len(new_segments) >= 1
assert len(old_result.data) > 0
# Both should have same format
assert new_segments[0].audio.dtype == np.float32
def test_pipeline_matches_old_interface(self):
"""Pipeline utility should match old TTSBackend interface."""
manager = PluginManager()
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
segments = list(backend(
"Hello world",
voice="af_heart",
speed=1.0,
split_pattern=r"\n+"
))
# Should return segments with graphemes and audio
assert len(segments) >= 1
segment = segments[0]
assert segment.graphemes == "Hello world"
assert isinstance(segment.audio, np.ndarray)
assert segment.audio.dtype == np.float32
assert len(segment.audio) > 0
class TestPluginManagerIntegration:
"""Integration tests for PluginManager."""
def test_plugin_manager_singleton_pattern(self):
"""Global plugin manager follows singleton pattern."""
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
manager3 = get_plugin_manager()
assert manager1 is not manager3
def test_plugin_manager_discover_plugins(self):
"""Plugin manager can discover plugins from directory."""
manager = PluginManager()
# Discover from test plugins directory
manager.discover("tests/plugins")
# Should find valid_plugin
# (This depends on test plugins existing)
plugins = manager.list_plugins()
assert isinstance(plugins, list)
def test_plugin_manager_dispose_all(self):
"""Plugin manager can dispose all cached engines."""
manager = PluginManager()
# Register mock plugin
mock_plugin = create_mock_plugin()
manager._plugins["mock_tts"] = mock_plugin
manager._loaded = True
# Create engines
engine1 = manager.get_or_create_engine("mock_tts")
engine2 = manager.get_or_create_engine("mock_tts")
# Dispose all
manager.dispose_all()
# Engines should be disposed
assert engine1._disposed is True
assert engine2._disposed is True
# Cache should be empty
assert len(manager._engines) == 0
class TestNoCompatLayer:
"""Regression: confirm the compatibility layer has been removed."""
def test_compat_module_does_not_exist(self):
"""abogen.tts_plugin.compat must not be importable."""
import importlib
with pytest.raises((ImportError, ModuleNotFoundError)):
importlib.import_module("abogen.tts_plugin.compat")
def test_consumers_use_plugin_architecture_directly(self):
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
source = inspect.getsource(mod)
assert "tts_plugin.compat" not in source, (
f"{mod.__name__} still references tts_plugin.compat"
)
-436
View File
@@ -1,436 +0,0 @@
"""Comprehensive tests for the plugin loader infrastructure.
These tests verify that the loader correctly:
- Discovers plugins in directories
- Imports plugin modules
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
- Validates api_version compatibility
- Validates capabilities
- Provides diagnostic messages for errors
- Rejects invalid plugins
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from abogen.tts_plugin.loader import (
HOST_API_VERSION,
PluginLoadError,
PluginLoadResult,
_check_api_version_compatibility,
_parse_api_version,
_validate_api_version,
_validate_capabilities,
_validate_manifest,
discover_plugins,
load_plugin,
load_plugin_from_dir,
)
from abogen.tts_plugin.manifest import (
EngineManifest,
ModelManifest,
PluginManifest,
)
# ──────────────────────────────────────────────────────────────
# Path fixtures
# ──────────────────────────────────────────────────────────────
@pytest.fixture
def plugins_dir() -> Path:
return Path(__file__).parent.parent / "plugins"
@pytest.fixture
def fake_plugin_dir(plugins_dir: Path) -> Path:
return plugins_dir / "fake_plugin"
@pytest.fixture
def missing_manifest_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_manifest"
@pytest.fixture
def invalid_api_version_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_api_version"
@pytest.fixture
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
return plugins_dir / "invalid_capabilities"
@pytest.fixture
def missing_create_engine_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_create_engine"
@pytest.fixture
def import_error_dir(plugins_dir: Path) -> Path:
return plugins_dir / "import_error"
@pytest.fixture
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
return plugins_dir / "missing_model_requirements"
# ──────────────────────────────────────────────────────────────
# Unit tests: _parse_api_version
# ──────────────────────────────────────────────────────────────
class TestParseApiVersion:
def test_valid_version(self) -> None:
assert _parse_api_version("1.0") == (1, 0)
assert _parse_api_version("2.5") == (2, 5)
assert _parse_api_version("10.20") == (10, 20)
def test_invalid_format(self) -> None:
assert _parse_api_version("1") is None
assert _parse_api_version("1.0.0") is None
assert _parse_api_version("abc") is None
assert _parse_api_version("") is None
assert _parse_api_version("1.x") is None
# ──────────────────────────────────────────────────────────────
# Unit tests: _check_api_version_compatibility
# ──────────────────────────────────────────────────────────────
class TestCheckApiVersionCompatibility:
def test_compatible_version(self) -> None:
assert _check_api_version_compatibility("1.0") is None
assert _check_api_version_compatibility("1.5") is None
def test_major_mismatch(self) -> None:
error = _check_api_version_compatibility("2.0")
assert error is not None
assert "major mismatch" in error
def test_invalid_format(self) -> None:
error = _check_api_version_compatibility("invalid")
assert error is not None
assert "Invalid api_version format" in error
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_manifest
# ──────────────────────────────────────────────────────────────
class TestValidateManifest:
def test_valid_manifest(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert errors == []
def test_missing_manifest(self) -> None:
class FakeModule:
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
def test_wrong_manifest_type(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = "not a manifest"
MODEL_REQUIREMENTS: list = []
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("PluginManifest instance" in e for e in errors)
def test_missing_model_requirements(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
def test_wrong_model_requirements_type(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS = "not a list"
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be a list" in e for e in errors)
def test_invalid_model_requirements_item(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS = ["not a model manifest"]
create_engine = lambda *a, **kw: None
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("ModelManifest instance" in e for e in errors)
def test_missing_create_engine(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("Missing create_engine" in e for e in errors)
def test_create_engine_not_callable(self) -> None:
class FakeModule:
PLUGIN_MANIFEST = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
MODEL_REQUIREMENTS: list = []
create_engine = "not callable"
errors = _validate_manifest(FakeModule(), Path("/tmp"))
assert any("must be callable" in e for e in errors)
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_capabilities
# ──────────────────────────────────────────────────────────────
class TestValidateCapabilities:
def test_valid_capabilities(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "preview"),
)
errors = _validate_capabilities(manifest)
assert errors == []
def test_unknown_capability(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=("voice_list", "unknown_cap"),
)
errors = _validate_capabilities(manifest)
assert any("unknown_cap" in e for e in errors)
def test_empty_capabilities(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
capabilities=(),
)
errors = _validate_capabilities(manifest)
assert errors == []
# ──────────────────────────────────────────────────────────────
# Unit tests: _validate_api_version
# ──────────────────────────────────────────────────────────────
class TestValidateApiVersion:
def test_compatible(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="1.0", description="Test", author="Test",
)
errors = _validate_api_version(manifest)
assert errors == []
def test_incompatible(self) -> None:
manifest = PluginManifest(
id="test", name="Test", version="1.0.0",
api_version="2.0", description="Test", author="Test",
)
errors = _validate_api_version(manifest)
assert len(errors) > 0
# ──────────────────────────────────────────────────────────────
# Integration tests: load_plugin_from_dir
# ──────────────────────────────────────────────────────────────
class TestLoadPluginFromDir:
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True
assert result.manifest is not None
assert result.manifest.id == "fake_plugin"
assert result.model_requirements is not None
assert result.create_engine is not None
assert result.module is not None
assert result.error is None
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
import logging
result = load_plugin_from_dir(fake_plugin_dir)
assert result.success is True
# Create engine using the loaded create_engine function
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
)
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_nonexistent_directory(self, tmp_path: Path) -> None:
result = load_plugin_from_dir(tmp_path / "nonexistent")
assert result.success is False
assert result.error is not None
assert "does not exist" in result.error.errors[0]
def test_missing_init_file(self, tmp_path: Path) -> None:
plugin_dir = tmp_path / "no_init"
plugin_dir.mkdir()
result = load_plugin_from_dir(plugin_dir)
assert result.success is False
assert result.error is not None
assert "__init__.py" in result.error.errors[0]
def test_import_error(self, import_error_dir: Path) -> None:
result = load_plugin_from_dir(import_error_dir)
assert result.success is False
assert result.error is not None
assert "Failed to import" in result.error.errors[0]
# ──────────────────────────────────────────────────────────────
# Integration tests: invalid plugins
# ──────────────────────────────────────────────────────────────
class TestInvalidPlugins:
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False
assert result.error is not None
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
result = load_plugin_from_dir(invalid_api_version_dir)
assert result.success is False
assert result.error is not None
assert any("major mismatch" in e for e in result.error.errors)
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
result = load_plugin_from_dir(invalid_capabilities_dir)
assert result.success is False
assert result.error is not None
assert any("Unknown capability" in e for e in result.error.errors)
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
result = load_plugin_from_dir(missing_create_engine_dir)
assert result.success is False
assert result.error is not None
assert any("Missing create_engine" in e for e in result.error.errors)
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
result = load_plugin_from_dir(missing_model_requirements_dir)
assert result.success is False
assert result.error is not None
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
# ──────────────────────────────────────────────────────────────
# Integration tests: discover_plugins
# ──────────────────────────────────────────────────────────────
class TestDiscoverPlugins:
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
# Should find multiple plugins (valid and invalid)
assert len(results) > 0
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
valid = [r for r in results if r.success]
assert len(valid) >= 1
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
results = discover_plugins([plugins_dir])
invalid = [r for r in results if not r.success]
assert len(invalid) >= 1
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
results = discover_plugins([tmp_path / "nonexistent"])
assert results == []
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
assert len(results) > 0
# ──────────────────────────────────────────────────────────────
# Diagnostic messages tests
# ──────────────────────────────────────────────────────────────
class TestDiagnosticMessages:
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert result.error.plugin_id == "missing_manifest"
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert result.error.path == missing_manifest_dir
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
result = load_plugin_from_dir(missing_manifest_dir)
assert result.error is not None
assert len(result.error.errors) > 0
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
# This plugin has multiple issues
result = load_plugin_from_dir(invalid_api_version_dir)
assert result.error is not None
# Should have at least the api_version error
assert len(result.error.errors) >= 1
# ──────────────────────────────────────────────────────────────
# No partial registration tests
# ──────────────────────────────────────────────────────────────
class TestNoPartialRegistration:
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
"""After failed load, module should not remain in sys.modules."""
result = load_plugin_from_dir(missing_manifest_dir)
assert result.success is False
# Module should not be registered
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
assert module_name not in sys.modules
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
"""After import error, module should not remain in sys.modules."""
result = load_plugin_from_dir(import_error_dir)
assert result.success is False
module_name = f"abogen.tts_plugin._loaded.import_error"
assert module_name not in sys.modules
-290
View File
@@ -1,290 +0,0 @@
"""Contract tests for plugin manifest types.
These tests verify that manifest types satisfy the architectural requirements:
- All required fields are present
- api_version follows semver format
- capabilities are properly defined
- engine manifest describes the engine correctly
"""
import re
import pytest
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
EnumOption,
GpuRequirement,
ModelManifest,
ParameterManifest,
PluginManifest,
RequirementManifest,
VoiceManifest,
VoiceSourceManifest,
)
class TestPluginManifestContract:
"""Contract tests for PluginManifest."""
def test_required_fields(self) -> None:
manifest = PluginManifest(
id="test-plugin",
name="Test Plugin",
version="1.0.0",
api_version="1.0",
description="A test plugin",
author="Test Author",
)
assert manifest.id == "test-plugin"
assert manifest.name == "Test Plugin"
assert manifest.version == "1.0.0"
assert manifest.api_version == "1.0"
assert manifest.description == "A test plugin"
assert manifest.author == "Test Author"
def test_api_version_semver_format(self) -> None:
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
valid_versions = ["1.0", "2.1", "10.5"]
for version in valid_versions:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version=version,
description="Test",
author="Test",
)
assert re.match(r"^\d+\.\d+$", manifest.api_version)
def test_capabilities_default_empty(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert manifest.capabilities == ()
def test_capabilities_tuple(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
capabilities=("voice_list", "preview"),
)
assert "voice_list" in manifest.capabilities
assert "preview" in manifest.capabilities
def test_requires_default(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert isinstance(manifest.requires, RequirementManifest)
def test_engine_default(self) -> None:
manifest = PluginManifest(
id="test",
name="Test",
version="1.0.0",
api_version="1.0",
description="Test",
author="Test",
)
assert isinstance(manifest.engine, EngineManifest)
class TestEngineManifestContract:
"""Contract tests for EngineManifest."""
def test_required_fields(self) -> None:
manifest = EngineManifest(
voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
),
parameters=(
ParameterManifest(
id="speed", name="Speed", description="Speed", type="float", default=1.0
),
),
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
)
assert len(manifest.voiceSources) == 1
assert len(manifest.parameters) == 1
assert len(manifest.audioFormats) == 1
def test_defaults_empty(self) -> None:
manifest = EngineManifest()
assert manifest.voiceSources == ()
assert manifest.parameters == ()
assert manifest.audioFormats == ()
class TestVoiceSourceManifestContract:
"""Contract tests for VoiceSourceManifest."""
def test_required_fields(self) -> None:
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
assert vs.id == "builtin"
assert vs.name == "Builtin"
assert vs.type == "list"
def test_valid_types(self) -> None:
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
for vtype in valid_types:
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
assert vs.type == vtype
def test_config_optional(self) -> None:
vs = VoiceSourceManifest(id="test", name="Test", type="list")
assert vs.config is None
def test_config_any(self) -> None:
config = {"voices": ["af_nova", "af_sky"]}
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
assert vs.config == config
class TestVoiceManifestContract:
"""Contract tests for VoiceManifest."""
def test_required_fields(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova")
assert v.id == "af_nova"
assert v.name == "Nova"
def test_tags_default_empty(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova")
assert v.tags == ()
def test_tags_tuple(self) -> None:
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
assert "en" in v.tags
assert "female" in v.tags
class TestParameterManifestContract:
"""Contract tests for ParameterManifest."""
def test_required_fields(self) -> None:
p = ParameterManifest(
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
)
assert p.id == "speed"
assert p.name == "Speed"
assert p.description == "Speech speed"
assert p.type == "float"
assert p.default == 1.0
def test_valid_types(self) -> None:
valid_types = ["float", "int", "string", "boolean", "enum"]
for ptype in valid_types:
p = ParameterManifest(
id="test", name="Test", description="Test", type=ptype, default=None
)
assert p.type == ptype
def test_optional_numeric_bounds(self) -> None:
p = ParameterManifest(
id="speed",
name="Speed",
description="Speed",
type="float",
default=1.0,
min=0.5,
max=2.0,
step=0.1,
)
assert p.min == 0.5
assert p.max == 2.0
assert p.step == 0.1
def test_enum_options(self) -> None:
options = (
EnumOption(value="low", label="Low"),
EnumOption(value="high", label="High"),
)
p = ParameterManifest(
id="quality",
name="Quality",
description="Quality",
type="enum",
default="low",
options=options,
)
assert len(p.options) == 2
assert p.options[0].value == "low"
class TestAudioFormatManifestContract:
"""Contract tests for AudioFormatManifest."""
def test_required_fields(self) -> None:
af = AudioFormatManifest(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav"
assert af.extension == "wav"
class TestEnumOptionContract:
"""Contract tests for EnumOption."""
def test_required_fields(self) -> None:
opt = EnumOption(value="low", label="Low Quality")
assert opt.value == "low"
assert opt.label == "Low Quality"
class TestRequirementManifestContract:
"""Contract tests for RequirementManifest."""
def test_defaults(self) -> None:
req = RequirementManifest()
assert req.gpu is None
assert req.memory is None
assert req.internet is None
def test_with_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
req = RequirementManifest(gpu=gpu)
assert req.gpu.required is True
assert req.gpu.type == "cuda"
assert req.gpu.memory == 8.0
def test_with_internet(self) -> None:
req = RequirementManifest(internet=True)
assert req.internet is True
class TestGpuRequirementContract:
"""Contract tests for GpuRequirement."""
def test_defaults(self) -> None:
gpu = GpuRequirement()
assert gpu.required is False
assert gpu.type is None
assert gpu.memory is None
def test_required_gpu(self) -> None:
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
assert gpu.required is True
class TestModelManifestContract:
"""Contract tests for ModelManifest."""
def test_required_fields(self) -> None:
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
assert m.id == "xtts_v2"
assert m.name == "XTTS v2"
assert m.size == "2GB"
-146
View File
@@ -1,146 +0,0 @@
"""Contract tests for plugin contract.
These tests verify that plugin modules satisfy the architectural requirements:
- Must export PLUGIN_MANIFEST: PluginManifest
- Must export MODEL_REQUIREMENTS: list[ModelManifest]
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
- create_engine() must be atomic
"""
import logging
from pathlib import Path
from typing import Any
import pytest
from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
from abogen.tts_plugin.plugin import Plugin
from abogen.tts_plugin.types import EngineConfig
from .conftest import FakeEngine
class FakePluginModule:
"""Stub plugin module that satisfies the plugin contract."""
PLUGIN_MANIFEST = PluginManifest(
id="fake-plugin",
name="Fake Plugin",
version="1.0.0",
api_version="1.0",
description="A fake plugin for testing",
author="Test Author",
capabilities=(),
engine=EngineManifest(),
)
MODEL_REQUIREMENTS: list[ModelManifest] = []
@staticmethod
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
return FakeEngine()
class TestPluginProtocolContract:
"""Contract tests for the Plugin protocol."""
def test_plugin_is_protocol(self) -> None:
assert hasattr(Plugin, "__protocol_attrs__")
class TestPluginExportsContract:
"""Contract tests for required plugin exports."""
def test_plugin_has_plugin_manifest(self) -> None:
"""Architecture spec: Plugin must export PLUGIN_MANIFEST."""
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
def test_plugin_has_model_requirements(self) -> None:
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_plugin_has_create_engine(self) -> None:
"""Architecture spec: Plugin must export create_engine."""
assert hasattr(FakePluginModule, "create_engine")
assert callable(FakePluginModule.create_engine)
def test_plugin_manifest_required_fields(self) -> None:
"""Architecture spec: PluginManifest has required fields."""
manifest = FakePluginModule.PLUGIN_MANIFEST
assert manifest.id
assert manifest.name
assert manifest.version
assert manifest.api_version
assert manifest.description
assert manifest.author
def test_plugin_manifest_capabilities_is_tuple(self) -> None:
manifest = FakePluginModule.PLUGIN_MANIFEST
assert isinstance(manifest.capabilities, tuple)
class TestCreateEngineContract:
"""Contract tests for create_engine() function."""
def test_create_engine_returns_engine(self) -> None:
"""Architecture spec: create_engine() returns Engine."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
def test_create_engine_atomic(self) -> None:
"""Architecture spec: create_engine() is atomic (all-or-nothing)."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_create_engine_with_none_model_path(self) -> None:
"""Architecture spec: model_path can be None for cloud/no-model engines."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
def test_create_engine_with_model_path(self) -> None:
"""Architecture spec: model_path is Path | None."""
ctx = HostContext(
config_dir=Path("/tmp/test"),
logger=logging.getLogger("test"),
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
)
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
assert isinstance(engine, Engine)
engine.dispose()
class TestModelRequirementsContract:
"""Contract tests for MODEL_REQUIREMENTS."""
def test_model_requirements_is_list(self) -> None:
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
def test_model_requirements_contains_model_manifests(self) -> None:
"""If non-empty, each item must be a ModelManifest."""
for req in FakePluginModule.MODEL_REQUIREMENTS:
assert isinstance(req, ModelManifest)
@@ -1,274 +0,0 @@
"""Integration tests for Plugin Manager and direct utility functions."""
import pytest
from unittest.mock import MagicMock, patch
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
from abogen.tts_plugin.utils import Pipeline, create_pipeline
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
class FakeEngine:
"""Fake Engine for testing."""
def __init__(self, **kwargs):
self.kwargs = kwargs
self._disposed = False
def createSession(self):
return FakeEngineSession()
def dispose(self):
self._disposed = True
@property
def manifest(self):
return MagicMock()
class FakeEngineSession:
"""Fake EngineSession for testing."""
def __init__(self):
self._disposed = False
def synthesize(self, request):
# Return fake audio
import numpy as np
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
audio = np.zeros(1000, dtype=np.float32)
return SynthesizedAudio(
data=audio.tobytes(),
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
)
def dispose(self):
self._disposed = True
class TestPluginManager:
"""Test PluginManager functionality."""
def test_plugin_manager_creation(self):
"""PluginManager can be created."""
manager = PluginManager()
assert manager is not None
def test_plugin_manager_list_discovers_plugins(self):
"""PluginManager discovers plugins from plugins directory."""
manager = PluginManager()
manager.discover("plugins")
plugins = manager.list_plugins()
# Should discover kokoro plugin if it exists
assert isinstance(plugins, list)
def test_plugin_manager_has_plugin_after_discover(self):
"""PluginManager reports plugins after discovery."""
manager = PluginManager()
manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists
# This is expected behavior
assert isinstance(manager._plugins, dict)
def test_plugin_manager_get_plugin_after_discover(self):
"""PluginManager returns plugin info after discovery."""
manager = PluginManager()
manager.discover("plugins")
# kokoro plugin should be discovered if plugins/kokoro exists
assert isinstance(manager._plugins, dict)
def test_plugin_manager_create_engine_not_found(self):
"""PluginManager raises KeyError for unknown plugins."""
manager = PluginManager()
with pytest.raises(KeyError, match="Plugin not found"):
manager.create_engine("nonexistent")
def test_plugin_manager_discover_with_empty_dir(self):
"""PluginManager handles missing plugins directory."""
manager = PluginManager()
manager.discover("/nonexistent/path")
plugins = manager.list_plugins()
assert plugins == []
def test_global_plugin_manager_singleton(self):
"""Global PluginManager is a singleton."""
reset_plugin_manager()
manager1 = get_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is manager2
reset_plugin_manager()
def test_reset_plugin_manager(self):
"""reset_plugin_manager clears the singleton."""
manager1 = get_plugin_manager()
reset_plugin_manager()
manager2 = get_plugin_manager()
assert manager1 is not manager2
reset_plugin_manager()
class TestPipeline:
"""Test Pipeline functionality."""
def test_pipeline_creation(self):
"""Pipeline can be created."""
engine = FakeEngine()
backend = Pipeline(engine)
assert backend is not None
def test_pipeline_callable(self):
"""Pipeline is callable like old TTSBackend."""
engine = FakeEngine()
backend = Pipeline(engine)
# Should be callable
assert callable(backend)
def test_pipeline_synthesize(self):
"""Pipeline can synthesize text."""
engine = FakeEngine()
backend = Pipeline(engine)
# Call the backend
segments = list(backend("Hello world", voice="default", speed=1.0))
# Should return at least one segment
assert len(segments) >= 1
# Segment should have graphemes and audio
segment = segments[0]
assert hasattr(segment, "graphemes")
assert hasattr(segment, "audio")
assert segment.graphemes == "Hello world"
def test_pipeline_dispose(self):
"""Pipeline can be disposed."""
engine = FakeEngine()
backend = Pipeline(engine)
# Create a session by calling
list(backend("test"))
# Dispose should not raise
backend.dispose()
# Double dispose should be safe
backend.dispose()
class TestCreatePipelineCompat:
"""Test create_pipeline utility function."""
def test_create_pipeline_returns_callable(self):
"""create_pipeline returns a callable backend."""
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
# Mock the plugin manager
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager
mock_engine = FakeEngine()
mock_manager.create_engine.return_value = mock_engine
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
assert callable(backend)
mock_manager.create_engine.assert_called_once()
call_args = mock_manager.create_engine.call_args
assert call_args.args[0] == "kokoro"
assert isinstance(call_args.kwargs["context"], HostContext)
assert call_args.kwargs["model_path"] is None
assert isinstance(call_args.kwargs["config"], EngineConfig)
assert call_args.kwargs["config"].device == "cpu"
assert call_args.kwargs["config"].lang_code == "a"
def test_create_pipeline_raises_for_unknown_plugin(self):
"""create_pipeline raises KeyError for unknown plugins."""
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
mock_get_manager.return_value = mock_manager
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
with pytest.raises(KeyError):
create_pipeline("nonexistent")
class TestPluginManagerWithFakePlugins:
"""Test PluginManager with fake plugin loading."""
def test_plugin_manager_create_engine_from_plugin(self):
"""PluginManager creates engine from loaded plugin."""
manager = PluginManager()
# Manually add a fake plugin
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Create engine
engine = manager.create_engine("fake", param="value")
assert isinstance(engine, FakeEngine)
assert engine.kwargs == {"param": "value"}
def test_plugin_manager_get_or_create_engine(self):
"""PluginManager caches engines."""
manager = PluginManager()
call_count = 0
def fake_create_engine(**kwargs):
nonlocal call_count
call_count += 1
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Get engine twice
engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake")
# Should be same instance
assert engine1 is engine2
assert call_count == 1
def test_plugin_manager_dispose_all(self):
"""PluginManager disposes all cached engines."""
manager = PluginManager()
def fake_create_engine(**kwargs):
return FakeEngine(**kwargs)
manager._plugins["fake"] = {
"manifest": MagicMock(),
"create_engine": fake_create_engine,
}
manager._loaded = True
# Create and cache engines
engine1 = manager.get_or_create_engine("fake")
engine2 = manager.get_or_create_engine("fake")
# Dispose all
manager.dispose_all()
# Engines should be disposed
assert engine1._disposed is True
assert engine2._disposed is True
# Cache should be empty
assert len(manager._engines) == 0
-135
View File
@@ -1,135 +0,0 @@
"""Contract tests for EngineSession protocol.
These tests verify that EngineSession implementations satisfy the architectural requirements:
- synthesize() returns SynthesizedAudio
- dispose() is idempotent
- After dispose(), synthesize() raises EngineError
- Session remains usable after synthesize() failure
"""
import pytest
from abogen.tts_plugin.engine import EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
from .conftest import FakeEngineSession
class TestEngineSessionProtocolContract:
"""Contract tests for the EngineSession protocol itself."""
def test_engine_session_is_protocol(self) -> None:
assert hasattr(EngineSession, "__protocol_attrs__")
def test_fake_session_satisfies_protocol(self) -> None:
session = FakeEngineSession()
assert isinstance(session, EngineSession)
class TestSessionSynthesizeContract:
"""Contract tests for EngineSession.synthesize()."""
def test_synthesize_returns_synthesized_audio(self) -> None:
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
def test_synthesize_returns_valid_audio_data(self) -> None:
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
result = session.synthesize(request)
assert isinstance(result.data, bytes)
assert len(result.data) > 0
assert isinstance(result.format, AudioFormat)
assert isinstance(result.duration, Duration)
class TestSessionDisposeContract:
"""Contract tests for EngineSession.dispose()."""
def test_dispose_is_idempotent(self) -> None:
"""Architecture spec: dispose() is idempotent."""
session = FakeEngineSession()
session.dispose()
session.dispose() # Should not raise
def test_dispose_never_raises(self) -> None:
"""Architecture spec: dispose() never raises exceptions."""
session = FakeEngineSession()
session.dispose() # Should not raise
class TestSessionAfterDisposeContract:
"""Contract tests for behavior after dispose()."""
def test_synthesize_after_dispose_raises(self) -> None:
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
session = FakeEngineSession()
session.dispose()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(EngineError):
session.synthesize(request)
class TestSessionLifecycleContract:
"""Contract tests for EngineSession lifecycle."""
def test_full_lifecycle(self) -> None:
"""Test complete session lifecycle: create -> synthesize -> dispose."""
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Synthesize
result = session.synthesize(request)
assert isinstance(result, SynthesizedAudio)
# Dispose
session.dispose()
def test_multiple_synthesize_before_dispose(self) -> None:
"""Architecture spec: Session remains usable after synthesize() failure."""
session = FakeEngineSession()
request = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
# Multiple synthesize calls
result1 = session.synthesize(request)
result2 = session.synthesize(request)
assert isinstance(result1, SynthesizedAudio)
assert isinstance(result2, SynthesizedAudio)
# Dispose
session.dispose()
-244
View File
@@ -1,244 +0,0 @@
"""Contract tests for core domain value objects.
These tests verify that value objects satisfy the architectural requirements:
- Frozen (immutable) dataclasses
- Correct field definitions
- Proper equality behavior
"""
import pytest
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
VoiceSelection,
)
class TestAudioFormatContract:
"""Contract tests for AudioFormat value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(AudioFormat, "__dataclass_params__")
assert AudioFormat.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
assert af.mime == "audio/wav"
assert af.extension == "wav"
def test_immutability(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
with pytest.raises(AttributeError):
af.mime = "audio/mpeg" # type: ignore[misc]
def test_equality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/wav", extension="wav")
assert af1 == af2
def test_inequality(self) -> None:
af1 = AudioFormat(mime="audio/wav", extension="wav")
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
assert af1 != af2
def test_hashable(self) -> None:
af = AudioFormat(mime="audio/wav", extension="wav")
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
class TestDurationContract:
"""Contract tests for Duration value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(Duration, "__dataclass_params__")
assert Duration.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
d = Duration(seconds=1.5)
assert d.seconds == 1.5
def test_immutability(self) -> None:
d = Duration(seconds=1.0)
with pytest.raises(AttributeError):
d.seconds = 2.0 # type: ignore[misc]
def test_equality(self) -> None:
d1 = Duration(seconds=1.0)
d2 = Duration(seconds=1.0)
assert d1 == d2
class TestVoiceSelectionContract:
"""Contract tests for VoiceSelection value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(VoiceSelection, "__dataclass_params__")
assert VoiceSelection.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.source == "builtin"
assert vs.key == "af_nova"
def test_payload_default_none(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
assert vs.payload is None
def test_payload_optional(self) -> None:
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
assert vs.payload == b"audio_data"
def test_immutability(self) -> None:
vs = VoiceSelection(source="builtin", key="af_nova")
with pytest.raises(AttributeError):
vs.source = "other" # type: ignore[misc]
class TestParameterValuesContract:
"""Contract tests for ParameterValues value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(ParameterValues, "__dataclass_params__")
assert ParameterValues.__dataclass_params__.frozen is True
def test_default_empty(self) -> None:
pv = ParameterValues()
assert pv.values == {}
def test_with_values(self) -> None:
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
assert pv.values["speed"] == 1.0
assert pv.values["pitch"] == 0.5
def test_immutability(self) -> None:
pv = ParameterValues(values={"speed": 1.0})
with pytest.raises(AttributeError):
pv.values = {} # type: ignore[misc]
class TestSynthesisRequestContract:
"""Contract tests for SynthesisRequest value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesisRequest, "__dataclass_params__")
assert SynthesisRequest.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
req = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
assert req.text == "Hello"
assert req.voice.source == "builtin"
assert req.format.mime == "audio/wav"
def test_immutability(self) -> None:
req = SynthesisRequest(
text="Hello",
voice=VoiceSelection(source="builtin", key="af_nova"),
parameters=ParameterValues(),
format=AudioFormat(mime="audio/wav", extension="wav"),
)
with pytest.raises(AttributeError):
req.text = "World" # type: ignore[misc]
class TestSynthesizedAudioContract:
"""Contract tests for SynthesizedAudio value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(SynthesizedAudio, "__dataclass_params__")
assert SynthesizedAudio.__dataclass_params__.frozen is True
def test_required_fields(self) -> None:
audio = SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
assert audio.data == b"\x00" * 100
assert audio.format.mime == "audio/wav"
assert audio.duration.seconds == 1.0
def test_immutability(self) -> None:
audio = SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
with pytest.raises(AttributeError):
audio.data = b"\x00" # type: ignore[misc]
class TestEngineConfigContract:
"""Contract tests for EngineConfig value object."""
def test_is_frozen_dataclass(self) -> None:
assert hasattr(EngineConfig, "__dataclass_params__")
assert EngineConfig.__dataclass_params__.frozen is True
def test_default_device(self) -> None:
config = EngineConfig()
assert config.device == "cpu"
def test_custom_device(self) -> None:
config = EngineConfig(device="cuda:0")
assert config.device == "cuda:0"
def test_default_lang_code(self) -> None:
config = EngineConfig()
assert config.lang_code == "a"
def test_custom_lang_code(self) -> None:
config = EngineConfig(lang_code="j")
assert config.lang_code == "j"
def test_immutability(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.device = "cuda:0" # type: ignore[misc]
def test_immutability_lang_code(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.lang_code = "j" # type: ignore[misc]
def test_unknown_keys_ignored_per_spec(self) -> None:
"""Architecture spec: Unknown keys are ignored (no error).
EngineConfig is frozen, so unknown keys cannot be set after creation.
This test verifies the default behavior matches the spec.
"""
config = EngineConfig()
assert config.device == "cpu"
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
"""Architecture Amendment #1: Plugins ignore unsupported fields.
EngineConfig may contain fields that are not relevant to every plugin.
Plugins MUST ignore fields they do not need, not raise on them.
"""
config = EngineConfig(device="cuda:0", lang_code="j")
assert config.device == "cuda:0"
assert config.lang_code == "j"
# A plugin that only needs device simply reads config.device
# and ignores config.lang_code — this must not raise.
def test_engine_config_contains_engine_instance_configuration(self) -> None:
"""Architecture Amendment #1: EngineConfig definition.
EngineConfig contains parameters that define how a particular
Engine instance is created and that remain constant throughout
the lifetime of that Engine.
"""
config = EngineConfig(device="cpu", lang_code="a")
# Both fields are init-time, immutable, engine-scoped.
assert config.device == "cpu"
assert config.lang_code == "a"

Some files were not shown because too many files have changed in this diff Show More