mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
104
Commits
v1.3.1
...
7fef9c1d93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fef9c1d93 | ||
|
|
56cfd0810d | ||
|
|
7bd3177241 | ||
|
|
d5c2a81733 | ||
|
|
514e29a761 | ||
|
|
86042a3315 | ||
|
|
50d75eb2fc | ||
|
|
ae9ab70421 | ||
|
|
4364276a5b | ||
|
|
914e77de46 | ||
|
|
1d7a2aeed6 | ||
|
|
a26e02b017 | ||
|
|
c94347b33b | ||
|
|
b7a48e3204 | ||
|
|
feb38a24ec | ||
|
|
f63590932d | ||
|
|
7777e58f1d | ||
|
|
364c179bd6 | ||
|
|
60ba01557e | ||
|
|
39eac9b032 | ||
|
|
1499a3b426 | ||
|
|
013c80b92c | ||
|
|
62f42a9f79 | ||
|
|
2c4d13bf56 | ||
|
|
b7026a666d | ||
|
|
c380a58496 | ||
|
|
b8386b43f7 | ||
|
|
26e71cc2ac | ||
|
|
d8fcfb1cce | ||
|
|
c85ea9d64f | ||
|
|
f151a1ae0d | ||
|
|
096ea58d74 | ||
|
|
65cb0c75e5 | ||
|
|
780e9bd780 | ||
|
|
c094b94704 | ||
|
|
735098d7cd | ||
|
|
5d1e7165bb | ||
|
|
9150a80459 | ||
|
|
a76d338931 | ||
|
|
985e16f1f8 | ||
|
|
25d45ffd36 | ||
|
|
6284c501ed | ||
|
|
23f1efcc62 | ||
|
|
a05357bab9 | ||
|
|
d129b0abe8 | ||
|
|
6eda8516cc | ||
|
|
0f568120f4 | ||
|
|
79b3d26f66 | ||
|
|
f1cc6deae8 | ||
|
|
6f25fc06d0 | ||
|
|
32c4d533c9 | ||
|
|
146000886d | ||
|
|
31f95137dd | ||
|
|
6f02fda41c | ||
|
|
a3c3462348 | ||
|
|
79332204d3 | ||
|
|
6deec3b9b6 | ||
|
|
c4d14112d4 | ||
|
|
f4cb2c2329 | ||
|
|
783738882f | ||
|
|
e94ba5257e | ||
|
|
49d66839dc | ||
|
|
d0e316ea7b | ||
|
|
bb96ae502c | ||
|
|
a4d25accc1 | ||
|
|
66964bfd0b | ||
|
|
f8f72624f8 | ||
|
|
e7a88a513a | ||
|
|
2277f16d0a | ||
|
|
1d50429b87 | ||
|
|
29681a5fbb | ||
|
|
50fa2e5b9e | ||
|
|
5816feb6da | ||
|
|
b95df8f217 | ||
|
|
245e67284e | ||
|
|
e2557d961b | ||
|
|
9c6b3774b4 | ||
|
|
fd9fe5579a | ||
|
|
f079373821 | ||
|
|
fbb5d4e368 | ||
|
|
57fec453e2 | ||
|
|
58fe22e3d5 | ||
|
|
ab8cbc4911 | ||
|
|
5e2048072a | ||
|
|
66ed2a202d | ||
|
|
45e859dac4 | ||
|
|
56d3e414b3 | ||
|
|
b942bcb820 | ||
|
|
47efcb4420 | ||
|
|
7b3f9d8615 | ||
|
|
9833bb0843 | ||
|
|
cbc05ead42 | ||
|
|
50b4d6872a | ||
|
|
da68f38b9b | ||
|
|
9fa81fbe1e | ||
|
|
9fd9fad238 | ||
|
|
ca5c5ee62d | ||
|
|
e51be95bc1 | ||
|
|
2223f46c9e | ||
|
|
8322f7f416 | ||
|
|
2c15d2f78a | ||
|
|
cc9c2a22ba | ||
|
|
d1366b445d | ||
|
|
c224cdbb56 |
@@ -0,0 +1,15 @@
|
||||
*.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
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [jborza, jeremiahsb, mohangk]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,7 +1,9 @@
|
||||
name: pip install
|
||||
run-name: pip install
|
||||
name: CI
|
||||
run-name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**.py'
|
||||
- 'pyproject.toml'
|
||||
@@ -11,23 +13,41 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
install-and-run:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-14, windows-latest]
|
||||
python-version: ['3.12']
|
||||
fail-fast: false
|
||||
continue-on-error: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install from repository
|
||||
run: python -m pip install .
|
||||
#- name: Run abogen
|
||||
# run: abogen
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.3.1
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
cache-dependency-glob: pyproject.toml
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libegl1
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv pip install --system .[dev]
|
||||
env:
|
||||
UV_LINK_MODE: copy
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
run: pytest tests/ -v --tb=short
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
# Only if we need to push an image
|
||||
|
||||
@@ -38,3 +38,4 @@ dist/
|
||||
.old/
|
||||
test_assets/
|
||||
dev_notes/
|
||||
.claude/
|
||||
|
||||
@@ -915,7 +915,11 @@ class EpubParser(BaseBookParser):
|
||||
|
||||
if slice_html.strip():
|
||||
slice_soup = BeautifulSoup(slice_html, "html.parser")
|
||||
for tag in slice_soup.find_all(["p", "div"]):
|
||||
|
||||
# Add line breaks after block-level elements to ensure pauses in speech
|
||||
for tag in slice_soup.find_all(
|
||||
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
|
||||
):
|
||||
tag.append("\n\n")
|
||||
|
||||
for ol in slice_soup.find_all("ol"):
|
||||
|
||||
@@ -63,64 +63,6 @@ SUPPORTED_INPUT_FORMATS = [
|
||||
# 384 if self.lang_code in 'ab':
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||
|
||||
# Voice and sample text constants
|
||||
VOICES_INTERNAL = [
|
||||
"af_alloy",
|
||||
"af_aoede",
|
||||
"af_bella",
|
||||
"af_heart",
|
||||
"af_jessica",
|
||||
"af_kore",
|
||||
"af_nicole",
|
||||
"af_nova",
|
||||
"af_river",
|
||||
"af_sarah",
|
||||
"af_sky",
|
||||
"am_adam",
|
||||
"am_echo",
|
||||
"am_eric",
|
||||
"am_fenrir",
|
||||
"am_liam",
|
||||
"am_michael",
|
||||
"am_onyx",
|
||||
"am_puck",
|
||||
"am_santa",
|
||||
"bf_alice",
|
||||
"bf_emma",
|
||||
"bf_isabella",
|
||||
"bf_lily",
|
||||
"bm_daniel",
|
||||
"bm_fable",
|
||||
"bm_george",
|
||||
"bm_lewis",
|
||||
"ef_dora",
|
||||
"em_alex",
|
||||
"em_santa",
|
||||
"ff_siwis",
|
||||
"hf_alpha",
|
||||
"hf_beta",
|
||||
"hm_omega",
|
||||
"hm_psi",
|
||||
"if_sara",
|
||||
"im_nicola",
|
||||
"jf_alpha",
|
||||
"jf_gongitsune",
|
||||
"jf_nezumi",
|
||||
"jf_tebukuro",
|
||||
"jm_kumo",
|
||||
"pf_dora",
|
||||
"pm_alex",
|
||||
"pm_santa",
|
||||
"zf_xiaobei",
|
||||
"zf_xiaoni",
|
||||
"zf_xiaoxiao",
|
||||
"zf_xiaoyi",
|
||||
"zm_yunjian",
|
||||
"zm_yunxi",
|
||||
"zm_yunxia",
|
||||
"zm_yunyang",
|
||||
]
|
||||
|
||||
# Voice and sample text mapping
|
||||
SAMPLE_VOICE_TEXTS = {
|
||||
"a": "This is a sample of the selected voice.",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Backwards-compatible re-export of conversion module.
|
||||
|
||||
The PyQt-based implementation lives in abogen.pyqt.conversion.
|
||||
The web-based implementation is in abogen.webui.conversion_runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export PyQt conversion classes for backwards compatibility
|
||||
from abogen.pyqt.conversion import ( # noqa: F401
|
||||
ConversionThread,
|
||||
VoicePreviewThread,
|
||||
PlayAudioThread,
|
||||
)
|
||||
|
||||
__all__ = ["ConversionThread", "VoicePreviewThread", "PlayAudioThread"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
@@ -0,0 +1,204 @@
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,31 @@
|
||||
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"
|
||||
@@ -0,0 +1,136 @@
|
||||
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)
|
||||
@@ -0,0 +1,138 @@
|
||||
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()
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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
|
||||
@@ -0,0 +1,261 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,40 @@
|
||||
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+"
|
||||
@@ -0,0 +1,97 @@
|
||||
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()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""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
|
||||
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
@@ -0,0 +1,673 @@
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
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",
|
||||
]
|
||||
+5
-15
@@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from abogen.utils import 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()
|
||||
|
||||
from abogen.utils import load_config
|
||||
from abogen.webui.app import main as _run_web_ui
|
||||
|
||||
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults).
|
||||
@@ -27,17 +28,6 @@ os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0")
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
||||
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
def _cleanup_sleep(signum, _frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Launch the Flask-based web UI."""
|
||||
|
||||
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||
from abogen.constants import COLORS
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.spacy_utils import SPACY_MODELS
|
||||
import abogen.hf_tracker
|
||||
|
||||
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
|
||||
self._voices_success = False
|
||||
return
|
||||
|
||||
voice_list = VOICES_INTERNAL
|
||||
voice_list = get_voices("kokoro")
|
||||
for idx, voice in enumerate(voice_list, start=1):
|
||||
if self._cancelled:
|
||||
self._voices_success = False
|
||||
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
for voice in VOICES_INTERNAL:
|
||||
for voice in get_voices("kokoro"):
|
||||
if not try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||
):
|
||||
missing.append(voice)
|
||||
except Exception:
|
||||
# If HF missing, report all as missing
|
||||
return False, list(VOICES_INTERNAL)
|
||||
return False, list(get_voices("kokoro"))
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
def _check_kokoro_model(self) -> bool:
|
||||
|
||||
+198
-239
@@ -5,6 +5,7 @@ import hashlib # For generating unique cache filenames
|
||||
from platformdirs import user_desktop_dir
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from abogen.utils import (
|
||||
create_process,
|
||||
@@ -13,7 +14,6 @@ from abogen.utils import (
|
||||
)
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SAMPLE_VOICE_TEXTS,
|
||||
COLORS,
|
||||
CHAPTER_OPTIONS_COUNTDOWN,
|
||||
SUBTITLE_FORMATS,
|
||||
@@ -21,11 +21,18 @@ from abogen.constants import (
|
||||
SUPPORTED_SUBTITLE_FORMATS,
|
||||
)
|
||||
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 static_ffmpeg
|
||||
import threading # for efficient waiting
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
# Configuration constants
|
||||
_USER_RESPONSE_TIMEOUT = (
|
||||
@@ -42,6 +49,7 @@ from abogen.subtitle_utils import (
|
||||
get_sample_voice_text,
|
||||
sanitize_name_for_os,
|
||||
_CHAPTER_MARKER_SEARCH_PATTERN,
|
||||
split_text_by_voice_markers
|
||||
)
|
||||
|
||||
class CountdownDialog(QDialog):
|
||||
@@ -211,40 +219,6 @@ class ConversionThread(QThread):
|
||||
PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、,"
|
||||
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__(
|
||||
self,
|
||||
file_name,
|
||||
@@ -255,8 +229,7 @@ class ConversionThread(QThread):
|
||||
output_folder,
|
||||
subtitle_mode,
|
||||
output_format,
|
||||
np_module,
|
||||
kpipeline_class,
|
||||
backend,
|
||||
start_time,
|
||||
total_char_count,
|
||||
use_gpu=True,
|
||||
@@ -266,8 +239,7 @@ class ConversionThread(QThread):
|
||||
super().__init__()
|
||||
self._chapter_options_event = threading.Event()
|
||||
self._timestamp_response_event = threading.Event()
|
||||
self.np = np_module
|
||||
self.KPipeline = kpipeline_class
|
||||
self.backend = backend
|
||||
self.file_name = file_name
|
||||
self.lang_code = lang_code
|
||||
self.speed = speed
|
||||
@@ -295,7 +267,32 @@ class ConversionThread(QThread):
|
||||
self.silence_duration = 2.0 # Default value, 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
|
||||
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
|
||||
self.split_pattern = get_split_pattern(lang_code, subtitle_mode)
|
||||
self.voice_cache = {} # Cache for loaded voices
|
||||
|
||||
def load_voice_cached(self, voice_name, tts):
|
||||
"""Load voice with caching to avoid reloading same voice.
|
||||
|
||||
Args:
|
||||
voice_name: Voice name or formula string
|
||||
tts: TTS pipeline instance
|
||||
|
||||
Returns:
|
||||
Loaded voice tensor or voice name string
|
||||
"""
|
||||
# Check cache first
|
||||
if voice_name in self.voice_cache:
|
||||
return self.voice_cache[voice_name]
|
||||
|
||||
# Load voice
|
||||
if "*" in voice_name:
|
||||
loaded_voice = get_new_voice(tts, voice_name, self.use_gpu)
|
||||
else:
|
||||
loaded_voice = voice_name
|
||||
|
||||
# Cache it
|
||||
self.voice_cache[voice_name] = loaded_voice
|
||||
return loaded_voice
|
||||
|
||||
def _stream_audio_in_chunks(
|
||||
self, segments, process_func, progress_prefix="Processing"
|
||||
@@ -461,19 +458,6 @@ class ConversionThread(QThread):
|
||||
|
||||
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
|
||||
is_subtitle_file = False
|
||||
is_timestamp_text = False
|
||||
@@ -509,7 +493,7 @@ class ConversionThread(QThread):
|
||||
|
||||
# Process subtitle files separately
|
||||
if is_subtitle_file or is_timestamp_text:
|
||||
self._process_subtitle_file(tts, base_path, is_timestamp_text)
|
||||
self._process_subtitle_file(self.backend, base_path, is_timestamp_text)
|
||||
return
|
||||
|
||||
if self.is_direct_text:
|
||||
@@ -524,6 +508,26 @@ class ConversionThread(QThread):
|
||||
# Clean up text using utility function
|
||||
text = clean_text(text)
|
||||
|
||||
# Apply word substitutions if enabled
|
||||
if getattr(self, "word_substitutions_enabled", False):
|
||||
from abogen.word_substitution import apply_word_substitutions
|
||||
|
||||
self.log_updated.emit("Applying word substitutions...")
|
||||
|
||||
substitutions_list = getattr(self, "word_substitutions_list", "")
|
||||
case_sensitive = getattr(self, "case_sensitive_substitutions", False)
|
||||
replace_caps = getattr(self, "replace_all_caps", False)
|
||||
replace_nums = getattr(self, "replace_numerals", False)
|
||||
fix_punct = getattr(self, "fix_nonstandard_punctuation", False)
|
||||
|
||||
text = apply_word_substitutions(
|
||||
text,
|
||||
substitutions_list,
|
||||
case_sensitive,
|
||||
replace_caps,
|
||||
replace_nums,
|
||||
fix_punct,
|
||||
)
|
||||
|
||||
# --- Chapter splitting logic ---
|
||||
# Use pre-compiled pattern for better performance
|
||||
@@ -550,6 +554,42 @@ class ConversionThread(QThread):
|
||||
chapters = [("text", text)]
|
||||
total_chapters = len(chapters)
|
||||
|
||||
# --- Voice marker splitting logic ---
|
||||
# Split each chapter by voice markers, preserving voice state across chapters
|
||||
chapters_with_voices = []
|
||||
current_voice = self.voice # Start with default voice
|
||||
total_valid_markers = 0
|
||||
total_invalid_markers = 0
|
||||
|
||||
for chapter_name, chapter_text in chapters:
|
||||
# Use current_voice as the starting voice for this chapter
|
||||
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(chapter_text, current_voice)
|
||||
chapters_with_voices.append((chapter_name, voice_segments))
|
||||
|
||||
# Update current_voice so next chapter continues with this voice
|
||||
current_voice = last_voice
|
||||
|
||||
# Track total valid/invalid markers
|
||||
total_valid_markers += valid_count
|
||||
total_invalid_markers += invalid_count
|
||||
|
||||
# Log voice marker information with accurate counts
|
||||
total_markers = total_valid_markers + total_invalid_markers
|
||||
if total_markers > 0:
|
||||
if total_invalid_markers == 0:
|
||||
# All markers were valid
|
||||
self.log_updated.emit(
|
||||
(f"\nDetected {total_markers} voice marker(s) - all valid", "grey")
|
||||
)
|
||||
else:
|
||||
# Some markers were invalid
|
||||
self.log_updated.emit(
|
||||
(f"\nDetected {total_markers} voice marker(s) - {total_valid_markers} valid, {total_invalid_markers} invalid (using previous voice)", "orange")
|
||||
)
|
||||
|
||||
# Replace chapters with the new structure
|
||||
chapters = chapters_with_voices
|
||||
|
||||
# For text files with chapters, prompt user for options if not already set
|
||||
is_txt_file = not self.is_direct_text and (
|
||||
self.file_name.lower().endswith(".txt")
|
||||
@@ -605,15 +645,17 @@ class ConversionThread(QThread):
|
||||
base_path = self.display_path if self.display_path else self.file_name
|
||||
|
||||
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)
|
||||
|
||||
if self.save_option == "Save to Desktop":
|
||||
parent_dir = user_desktop_dir()
|
||||
elif self.save_option == "Save next to input file":
|
||||
parent_dir = os.path.dirname(base_path)
|
||||
else:
|
||||
parent_dir = self.output_folder or os.getcwd()
|
||||
parent_dir = resolve_output_directory(
|
||||
save_mode=self.save_option,
|
||||
stored_path=Path(base_path),
|
||||
output_folder=getattr(self, "output_folder", None),
|
||||
desktop_dir=Path(user_desktop_dir()),
|
||||
user_output_path=None,
|
||||
user_cache_outputs=Path(os.getcwd()),
|
||||
)
|
||||
parent_dir = str(parent_dir)
|
||||
# Ensure the output folder exists, error if it doesn't
|
||||
if not os.path.exists(parent_dir):
|
||||
self.log_updated.emit(
|
||||
@@ -683,77 +725,40 @@ class ConversionThread(QThread):
|
||||
format=self.output_format,
|
||||
)
|
||||
ffmpeg_proc = None
|
||||
elif self.output_format == "m4b":
|
||||
# Real-time M4B generation using FFmpeg pipe
|
||||
elif self.output_format in ("m4b", "opus"):
|
||||
# Real-time generation using FFmpeg pipe
|
||||
static_ffmpeg.add_paths()
|
||||
merged_out_file = None
|
||||
ffmpeg_proc = None
|
||||
metadata_options, cover_path = (
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
if self.output_format == "m4b"
|
||||
else ([], None)
|
||||
)
|
||||
# Prepare ffmpeg command for m4b output
|
||||
cmd = [
|
||||
"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 = build_ffmpeg_command(
|
||||
Path(merged_out_path),
|
||||
self.output_format,
|
||||
)
|
||||
cmd.extend(
|
||||
[
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-q:a",
|
||||
"2",
|
||||
"-movflags",
|
||||
"+faststart+use_metadata_tags",
|
||||
]
|
||||
)
|
||||
cmd += metadata_options
|
||||
cmd.append(merged_out_path)
|
||||
# Insert thread queue size after ffmpeg header
|
||||
cmd.insert(2, "-thread_queue_size")
|
||||
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)
|
||||
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:
|
||||
self.log_updated.emit(
|
||||
(f"Unsupported output format: {self.output_format}", "red")
|
||||
@@ -842,7 +847,7 @@ class ConversionThread(QThread):
|
||||
]
|
||||
srt_index = 1 # SRT numbering fix for chapter-only mode
|
||||
# Instead of processing the whole text, process by chapter
|
||||
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
||||
for chapter_idx, (chapter_name, voice_segments) in enumerate(chapters, 1):
|
||||
chapter_out_path = None
|
||||
chapter_out_file = None
|
||||
chapter_ffmpeg_proc = None
|
||||
@@ -862,11 +867,6 @@ class ConversionThread(QThread):
|
||||
if merge_chapters_at_end:
|
||||
chapter_time["start"] = current_time
|
||||
|
||||
# Check if the voice is a formula and load it if necessary
|
||||
if "*" in self.voice:
|
||||
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
||||
else:
|
||||
loaded_voice = self.voice
|
||||
# Prepare per-chapter output file if needed
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
# First pass: keep alphanumeric, spaces, hyphens, and underscores
|
||||
@@ -986,6 +986,22 @@ class ConversionThread(QThread):
|
||||
chapter_subtitle_path = None
|
||||
chapter_subtitle_file = None
|
||||
|
||||
|
||||
# Process each voice segment within the chapter
|
||||
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
||||
# Load voice for this segment (with caching)
|
||||
try:
|
||||
loaded_voice = self.load_voice_cached(voice_name, self.backend)
|
||||
if segment_idx > 0:
|
||||
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
||||
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
||||
except Exception:
|
||||
self.log_updated.emit(
|
||||
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
||||
)
|
||||
if segment_idx == 0:
|
||||
loaded_voice = self.load_voice_cached(self.voice, self.backend)
|
||||
|
||||
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||
# Only non-English languages use spaCy for pre-segmentation
|
||||
# English uses spaCy only for subtitle generation (post-TTS)
|
||||
@@ -1034,7 +1050,7 @@ class ConversionThread(QThread):
|
||||
from abogen.spacy_utils import segment_sentences
|
||||
|
||||
spacy_sentences = segment_sentences(
|
||||
chapter_text,
|
||||
segment_text,
|
||||
self.lang_code,
|
||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||
)
|
||||
@@ -1060,7 +1076,7 @@ class ConversionThread(QThread):
|
||||
)
|
||||
|
||||
# Process text - either as spaCy sentences or as single text
|
||||
text_segments = spacy_sentences if spacy_sentences else [chapter_text]
|
||||
text_segments = spacy_sentences if spacy_sentences else [segment_text]
|
||||
|
||||
# Print active split pattern used by the TTS engine once for this batch
|
||||
try:
|
||||
@@ -1070,7 +1086,7 @@ class ConversionThread(QThread):
|
||||
print("Using split pattern: (unprintable)")
|
||||
|
||||
for text_segment in text_segments:
|
||||
for result in tts(
|
||||
for result in self.backend(
|
||||
text_segment,
|
||||
voice=loaded_voice,
|
||||
speed=self.speed,
|
||||
@@ -1176,8 +1192,8 @@ class ConversionThread(QThread):
|
||||
)
|
||||
if "ass" in subtitle_format:
|
||||
for start, end, text in new_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
start_time = _format_timestamp(start, ass=True)
|
||||
end_time = _format_timestamp(end, ass=True)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = (
|
||||
"karaoke"
|
||||
@@ -1192,7 +1208,7 @@ class ConversionThread(QThread):
|
||||
for entry in new_entries:
|
||||
start, end, text = entry
|
||||
merged_subtitle_file.write(
|
||||
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
f"{merged_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n"
|
||||
)
|
||||
merged_srt_index += 1
|
||||
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
||||
@@ -1210,8 +1226,8 @@ class ConversionThread(QThread):
|
||||
)
|
||||
if "ass" in subtitle_format:
|
||||
for start, end, text in new_chapter_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
start_time = _format_timestamp(start, ass=True)
|
||||
end_time = _format_timestamp(end, ass=True)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = (
|
||||
"karaoke"
|
||||
@@ -1226,7 +1242,7 @@ class ConversionThread(QThread):
|
||||
for entry in new_chapter_entries:
|
||||
start, end, text = entry
|
||||
chapter_subtitle_file.write(
|
||||
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
f"{chapter_srt_index}\n{_format_timestamp(start)} --> {_format_timestamp(end)}\n{text}\n\n"
|
||||
)
|
||||
chapter_srt_index += 1
|
||||
if merge_chapters_at_end:
|
||||
@@ -1272,7 +1288,7 @@ class ConversionThread(QThread):
|
||||
silence_samples = int(
|
||||
self.silence_duration * 24000
|
||||
) # Silence duration at 24,000 Hz
|
||||
silence_audio = self.np.zeros(silence_samples, dtype="float32")
|
||||
silence_audio = np.zeros(silence_samples, dtype="float32")
|
||||
silence_bytes = silence_audio.tobytes()
|
||||
|
||||
if merged_out_file:
|
||||
@@ -1515,58 +1531,27 @@ class ConversionThread(QThread):
|
||||
)
|
||||
else:
|
||||
static_ffmpeg.add_paths()
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-thread_queue_size",
|
||||
"32768",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
str(rate),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]
|
||||
cmd = build_ffmpeg_command(
|
||||
Path(merged_out_path),
|
||||
self.output_format,
|
||||
)
|
||||
cmd.insert(2, "-thread_queue_size")
|
||||
cmd.insert(3, "32768")
|
||||
if self.output_format == "m4b":
|
||||
metadata_options, cover_path = (
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
)
|
||||
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",
|
||||
]
|
||||
)
|
||||
output_path = cmd.pop()
|
||||
cmd.extend([
|
||||
"-i", cover_path,
|
||||
"-map", "0:a",
|
||||
"-map", "1",
|
||||
"-c:v", "copy",
|
||||
"-disposition:v", "attached_pic",
|
||||
])
|
||||
cmd.append(output_path)
|
||||
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)
|
||||
|
||||
# Always generate subtitles for subtitle input files
|
||||
@@ -1611,7 +1596,7 @@ class ConversionThread(QThread):
|
||||
max_end_time = max(
|
||||
(end for _, end, _ in subtitles if end is not None), default=0
|
||||
)
|
||||
audio_buffer = self.np.zeros(
|
||||
audio_buffer = np.zeros(
|
||||
int(max_end_time * rate) + rate, dtype="float32"
|
||||
)
|
||||
|
||||
@@ -1675,7 +1660,7 @@ class ConversionThread(QThread):
|
||||
# Generate TTS audio
|
||||
tts_results = [
|
||||
r
|
||||
for r in tts(
|
||||
for r in self.backend(
|
||||
processed_text,
|
||||
voice=loaded_voice,
|
||||
speed=self.speed,
|
||||
@@ -1693,11 +1678,11 @@ class ConversionThread(QThread):
|
||||
|
||||
# Concatenate audio and determine duration
|
||||
full_audio = (
|
||||
self.np.concatenate(
|
||||
np.concatenate(
|
||||
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
|
||||
)
|
||||
if audio_chunks
|
||||
else self.np.zeros(
|
||||
else np.zeros(
|
||||
int((subtitle_duration or 0) * rate), dtype="float32"
|
||||
)
|
||||
)
|
||||
@@ -1731,8 +1716,8 @@ class ConversionThread(QThread):
|
||||
num_stages = max(
|
||||
1,
|
||||
int(
|
||||
self.np.ceil(
|
||||
self.np.log(speed_factor) / self.np.log(2.0)
|
||||
np.ceil(
|
||||
np.log(speed_factor) / np.log(2.0)
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -1765,7 +1750,7 @@ class ConversionThread(QThread):
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
full_audio = self.np.frombuffer(
|
||||
full_audio = np.frombuffer(
|
||||
speed_proc.communicate(input=full_audio.tobytes())[0],
|
||||
dtype="float32",
|
||||
)
|
||||
@@ -1779,7 +1764,7 @@ class ConversionThread(QThread):
|
||||
|
||||
tts_results = [
|
||||
r
|
||||
for r in tts(
|
||||
for r in self.backend(
|
||||
processed_text,
|
||||
voice=loaded_voice,
|
||||
speed=new_speed,
|
||||
@@ -1790,14 +1775,14 @@ class ConversionThread(QThread):
|
||||
audio_chunks = [r.audio for r in tts_results]
|
||||
|
||||
full_audio = (
|
||||
self.np.concatenate(
|
||||
np.concatenate(
|
||||
[
|
||||
a.numpy() if hasattr(a, "numpy") else a
|
||||
for a in audio_chunks
|
||||
]
|
||||
)
|
||||
if audio_chunks
|
||||
else self.np.zeros(
|
||||
else np.zeros(
|
||||
int(subtitle_duration * rate), dtype="float32"
|
||||
)
|
||||
)
|
||||
@@ -1814,10 +1799,10 @@ class ConversionThread(QThread):
|
||||
# Pad or trim to subtitle duration
|
||||
target_samples = int(subtitle_duration * rate)
|
||||
if len(full_audio) < target_samples:
|
||||
full_audio = self.np.concatenate(
|
||||
full_audio = np.concatenate(
|
||||
[
|
||||
full_audio,
|
||||
self.np.zeros(
|
||||
np.zeros(
|
||||
target_samples - len(full_audio), dtype="float32"
|
||||
),
|
||||
]
|
||||
@@ -1830,10 +1815,10 @@ class ConversionThread(QThread):
|
||||
end_sample = start_sample + len(full_audio)
|
||||
if end_sample > len(audio_buffer):
|
||||
# Extend buffer if needed
|
||||
audio_buffer = self.np.concatenate(
|
||||
audio_buffer = np.concatenate(
|
||||
[
|
||||
audio_buffer,
|
||||
self.np.zeros(
|
||||
np.zeros(
|
||||
end_sample - len(audio_buffer), dtype="float32"
|
||||
),
|
||||
]
|
||||
@@ -1856,11 +1841,11 @@ class ConversionThread(QThread):
|
||||
else processed_text.replace("\n", "\\N")
|
||||
)
|
||||
subtitle_file.write(
|
||||
f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
|
||||
f"Dialogue: 0,{_format_timestamp(start_time, ass=True)},{_format_timestamp(end_time, ass=True)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
|
||||
)
|
||||
else:
|
||||
subtitle_file.write(
|
||||
f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n"
|
||||
f"{srt_index}\n{_format_timestamp(start_time)} --> {_format_timestamp(end_time)}\n{processed_text}\n\n"
|
||||
)
|
||||
srt_index += 1
|
||||
|
||||
@@ -1875,7 +1860,7 @@ class ConversionThread(QThread):
|
||||
self.progress_updated.emit(percent, etr_str)
|
||||
|
||||
# Normalize audio buffer to prevent clipping from mixed overlaps
|
||||
max_amplitude = self.np.abs(audio_buffer).max()
|
||||
max_amplitude = np.abs(audio_buffer).max()
|
||||
if max_amplitude > 1.0:
|
||||
self.log_updated.emit(
|
||||
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
|
||||
@@ -2022,22 +2007,6 @@ class ConversionThread(QThread):
|
||||
# Add these to ffmpeg command
|
||||
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(
|
||||
self,
|
||||
tokens_with_timestamps,
|
||||
@@ -2315,6 +2284,10 @@ class ConversionThread(QThread):
|
||||
self.cancel_requested = True
|
||||
self.should_cancel = True
|
||||
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
|
||||
if self.process:
|
||||
try:
|
||||
@@ -2344,8 +2317,7 @@ class VoicePreviewThread(QThread):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
np_module,
|
||||
kpipeline_class,
|
||||
backend,
|
||||
lang_code,
|
||||
voice,
|
||||
speed,
|
||||
@@ -2353,8 +2325,7 @@ class VoicePreviewThread(QThread):
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.np_module = np_module
|
||||
self.kpipeline_class = kpipeline_class
|
||||
self.backend = backend
|
||||
self.lang_code = lang_code
|
||||
self.voice = voice
|
||||
self.speed = speed
|
||||
@@ -2388,31 +2359,19 @@ class VoicePreviewThread(QThread):
|
||||
# Generate the preview and save to cache
|
||||
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
|
||||
if "*" in self.voice:
|
||||
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
||||
loaded_voice = get_new_voice(self.backend, self.voice, self.use_gpu)
|
||||
else:
|
||||
loaded_voice = self.voice
|
||||
sample_text = get_sample_voice_text(self.lang_code)
|
||||
audio_segments = []
|
||||
for result in tts(
|
||||
for result in self.backend(
|
||||
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
|
||||
):
|
||||
audio_segments.append(result.audio)
|
||||
if audio_segments:
|
||||
audio = self.np_module.concatenate(audio_segments)
|
||||
audio = np.concatenate(audio_segments)
|
||||
# Save directly to the cache path
|
||||
sf.write(self.cache_path, audio, 24000)
|
||||
self.temp_wav = self.cache_path
|
||||
|
||||
+272
-18
@@ -7,6 +7,7 @@ import base64
|
||||
import re
|
||||
from abogen.pyqt.queue_manager_gui import QueueManager
|
||||
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 hashlib # Added for cache path generation
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -74,7 +75,7 @@ from abogen.subtitle_utils import (
|
||||
calculate_text_length,
|
||||
)
|
||||
|
||||
from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
|
||||
from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog
|
||||
from abogen.pyqt.book_handler import HandlerDialog
|
||||
from abogen.constants import (
|
||||
PROGRAM_NAME,
|
||||
@@ -82,11 +83,11 @@ from abogen.constants import (
|
||||
GITHUB_URL,
|
||||
PROGRAM_DESCRIPTION,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
VOICES_INTERNAL,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
COLORS,
|
||||
SUBTITLE_FORMATS,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
import threading
|
||||
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
||||
from abogen.voice_profiles import load_profiles
|
||||
@@ -665,6 +666,11 @@ class TextboxDialog(QDialog):
|
||||
self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker)
|
||||
button_layout.addWidget(self.insert_chapter_btn)
|
||||
|
||||
self.insert_voice_btn = QPushButton("Insert Voice Marker", self)
|
||||
self.insert_voice_btn.setToolTip("Insert a voice change marker at the cursor position")
|
||||
self.insert_voice_btn.clicked.connect(self.insert_voice_marker)
|
||||
button_layout.addWidget(self.insert_voice_btn)
|
||||
|
||||
self.cancel_button = QPushButton("Cancel", self)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
@@ -767,6 +773,23 @@ class TextboxDialog(QDialog):
|
||||
self.update_char_count()
|
||||
self.text_edit.setFocus()
|
||||
|
||||
def insert_voice_marker(self):
|
||||
"""Insert a voice marker template at cursor position."""
|
||||
cursor = self.text_edit.textCursor()
|
||||
# Use the currently selected voice as the default
|
||||
try:
|
||||
parent_window = self.parent()
|
||||
if parent_window and hasattr(parent_window, 'selected_voice'):
|
||||
default_voice = parent_window.selected_voice or "af_heart"
|
||||
else:
|
||||
default_voice = "af_heart"
|
||||
except Exception:
|
||||
default_voice = "af_heart"
|
||||
cursor.insertText(f"\n<<VOICE:{default_voice}>>\n")
|
||||
self.text_edit.setTextCursor(cursor)
|
||||
self.update_char_count()
|
||||
self.text_edit.setFocus()
|
||||
|
||||
|
||||
def migrate_subtitle_format(config):
|
||||
"""Convert old subtitle_format values to new internal keys."""
|
||||
@@ -783,6 +806,108 @@ def migrate_subtitle_format(config):
|
||||
save_config(config)
|
||||
|
||||
|
||||
class WordSubstitutionsDialog(QDialog):
|
||||
"""Dialog for configuring word substitutions and text preprocessing options."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
initial_list="",
|
||||
initial_case_sensitive=False,
|
||||
initial_caps=False,
|
||||
initial_numerals=False,
|
||||
initial_punctuation=False,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Word Substitutions Settings")
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Window
|
||||
| Qt.WindowType.WindowCloseButtonHint
|
||||
| Qt.WindowType.WindowMaximizeButtonHint
|
||||
)
|
||||
self.resize(600, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(
|
||||
"Enter word substitutions (one per line) in format: Word|NewWord\n"
|
||||
" - If nothing after |, the word will be erased completely\n"
|
||||
" - Substitutions match whole words only (e.g., \"tree\" won't match \"trees\" but will match \"tree's\")\n"
|
||||
" - By default, matching is case-insensitive (e.g., \"gonna\" matches \"Gonna\", \"GONNA\", etc.)",
|
||||
self,
|
||||
)
|
||||
instructions.setStyleSheet(
|
||||
"padding: 10px; background-color: #f0f0f0; border-radius: 5px;"
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Text edit area
|
||||
self.text_edit = QTextEdit(self)
|
||||
self.text_edit.setAcceptRichText(False)
|
||||
self.text_edit.setPlaceholderText("Word|NewWord")
|
||||
self.text_edit.setPlainText(initial_list)
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
# Checkboxes
|
||||
self.case_sensitive_checkbox = QCheckBox(
|
||||
"Case-sensitive word matching", self
|
||||
)
|
||||
self.case_sensitive_checkbox.setChecked(initial_case_sensitive)
|
||||
layout.addWidget(self.case_sensitive_checkbox)
|
||||
|
||||
self.caps_checkbox = QCheckBox("Replace ALL CAPS with lowercase", self)
|
||||
self.caps_checkbox.setChecked(initial_caps)
|
||||
layout.addWidget(self.caps_checkbox)
|
||||
|
||||
self.numerals_checkbox = QCheckBox(
|
||||
"Replace Numerals with Words (e.g., 309 \u2192 three hundred and nine)", self
|
||||
)
|
||||
self.numerals_checkbox.setChecked(initial_numerals)
|
||||
layout.addWidget(self.numerals_checkbox)
|
||||
|
||||
self.punctuation_checkbox = QCheckBox(
|
||||
"Fix Nonstandard Punctuation (curly quotes and other Unicode punctuation that may affect how words sound)",
|
||||
self,
|
||||
)
|
||||
self.punctuation_checkbox.setChecked(initial_punctuation)
|
||||
layout.addWidget(self.punctuation_checkbox)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
self.cancel_button = QPushButton("Cancel", self)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.ok_button = QPushButton("OK", self)
|
||||
self.ok_button.setDefault(True)
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def get_substitutions_list(self):
|
||||
"""Get the substitutions list as plain text."""
|
||||
return self.text_edit.toPlainText()
|
||||
|
||||
def get_case_sensitive(self):
|
||||
"""Get whether case-sensitive matching is enabled."""
|
||||
return self.case_sensitive_checkbox.isChecked()
|
||||
|
||||
def get_replace_all_caps(self):
|
||||
"""Get whether ALL CAPS replacement is enabled."""
|
||||
return self.caps_checkbox.isChecked()
|
||||
|
||||
def get_replace_numerals(self):
|
||||
"""Get whether numeral-to-word conversion is enabled."""
|
||||
return self.numerals_checkbox.isChecked()
|
||||
|
||||
def get_fix_nonstandard_punctuation(self):
|
||||
"""Get whether nonstandard punctuation fixing is enabled."""
|
||||
return self.punctuation_checkbox.isChecked()
|
||||
|
||||
|
||||
class abogen(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -833,6 +958,19 @@ class abogen(QWidget):
|
||||
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
||||
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
|
||||
# Word substitution settings
|
||||
self.word_substitutions_enabled = self.config.get(
|
||||
"word_substitutions_enabled", False
|
||||
)
|
||||
self.word_substitutions_list = self.config.get("word_substitutions_list", "")
|
||||
self.case_sensitive_substitutions = self.config.get(
|
||||
"case_sensitive_substitutions", False
|
||||
)
|
||||
self.replace_all_caps = self.config.get("replace_all_caps", False)
|
||||
self.replace_numerals = self.config.get("replace_numerals", False)
|
||||
self.fix_nonstandard_punctuation = self.config.get(
|
||||
"fix_nonstandard_punctuation", False
|
||||
)
|
||||
self._pending_close_event = None
|
||||
self.gpu_ok = False # Initialize GPU availability status
|
||||
|
||||
@@ -1071,6 +1209,35 @@ class abogen(QWidget):
|
||||
subtitle_layout.addWidget(self.subtitle_combo)
|
||||
controls_layout.addLayout(subtitle_layout)
|
||||
|
||||
# Word Substitutions section
|
||||
word_sub_layout = QHBoxLayout()
|
||||
word_sub_layout.setSpacing(7)
|
||||
word_sub_label = QLabel("Word Substitutions:", self)
|
||||
word_sub_layout.addWidget(word_sub_label)
|
||||
|
||||
self.word_sub_combo = QComboBox(self)
|
||||
self.word_sub_combo.addItems(["Disabled", "Enabled"])
|
||||
self.word_sub_combo.setStyleSheet(
|
||||
"QComboBox { min-height: 20px; padding: 6px 12px; }"
|
||||
)
|
||||
self.word_sub_combo.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.word_sub_combo.setCurrentText(
|
||||
"Enabled" if self.word_substitutions_enabled else "Disabled"
|
||||
)
|
||||
self.word_sub_combo.currentTextChanged.connect(self.on_word_sub_changed)
|
||||
word_sub_layout.addWidget(self.word_sub_combo)
|
||||
|
||||
self.btn_word_sub_settings = QPushButton("Settings", self)
|
||||
self.btn_word_sub_settings.setFixedSize(80, 36)
|
||||
self.btn_word_sub_settings.setStyleSheet("QPushButton { padding: 6px 12px; }")
|
||||
self.btn_word_sub_settings.clicked.connect(self.show_word_sub_dialog)
|
||||
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
|
||||
word_sub_layout.addWidget(self.btn_word_sub_settings)
|
||||
|
||||
controls_layout.addLayout(word_sub_layout)
|
||||
|
||||
# Output voice format
|
||||
format_layout = QHBoxLayout()
|
||||
format_layout.setSpacing(7)
|
||||
@@ -1707,7 +1874,7 @@ class abogen(QWidget):
|
||||
for pname in load_profiles().keys():
|
||||
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
||||
# re-add voices
|
||||
for v in VOICES_INTERNAL:
|
||||
for v in get_voices("kokoro"):
|
||||
icon = QIcon()
|
||||
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
||||
if flag_path and os.path.exists(flag_path):
|
||||
@@ -2015,6 +2182,21 @@ class abogen(QWidget):
|
||||
self.subtitle_speed_method = getattr(
|
||||
queued_item, "subtitle_speed_method", "tts"
|
||||
)
|
||||
# Word substitution settings
|
||||
self.word_substitutions_enabled = getattr(
|
||||
queued_item, "word_substitutions_enabled", False
|
||||
)
|
||||
self.word_substitutions_list = getattr(
|
||||
queued_item, "word_substitutions_list", ""
|
||||
)
|
||||
self.case_sensitive_substitutions = getattr(
|
||||
queued_item, "case_sensitive_substitutions", False
|
||||
)
|
||||
self.replace_all_caps = getattr(queued_item, "replace_all_caps", False)
|
||||
self.replace_numerals = getattr(queued_item, "replace_numerals", False)
|
||||
self.fix_nonstandard_punctuation = getattr(
|
||||
queued_item, "fix_nonstandard_punctuation", False
|
||||
)
|
||||
|
||||
# This ensures that if conversion.py (or utils) reads from config/disk
|
||||
# instead of using passed arguments, it sees the correct queue values.
|
||||
@@ -2023,6 +2205,13 @@ class abogen(QWidget):
|
||||
self.config["selected_format"] = self.selected_format
|
||||
self.config["use_silent_gaps"] = self.use_silent_gaps
|
||||
self.config["subtitle_speed_method"] = self.subtitle_speed_method
|
||||
# Word substitution settings
|
||||
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||
self.config["word_substitutions_list"] = self.word_substitutions_list
|
||||
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||
self.config["replace_all_caps"] = self.replace_all_caps
|
||||
self.config["replace_numerals"] = self.replace_numerals
|
||||
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||
|
||||
# Sync Voice/Profile in config
|
||||
self.config["selected_voice"] = self.selected_voice
|
||||
@@ -2128,9 +2317,9 @@ class abogen(QWidget):
|
||||
file_size_str = "Unknown"
|
||||
|
||||
# pipeline_loaded_callback remains unchanged
|
||||
def pipeline_loaded_callback(np_module, kpipeline_class, error):
|
||||
def pipeline_loaded_callback(backend, error):
|
||||
if error:
|
||||
self.update_log((f"Error loading numpy or KPipeline: {error}", "red"))
|
||||
self.update_log((f"Error loading TTS backend: {error}", "red"))
|
||||
prevent_sleep_end()
|
||||
return
|
||||
|
||||
@@ -2153,8 +2342,7 @@ class abogen(QWidget):
|
||||
self.selected_output_folder,
|
||||
subtitle_mode=actual_subtitle_mode,
|
||||
output_format=self.selected_format,
|
||||
np_module=np_module,
|
||||
kpipeline_class=kpipeline_class,
|
||||
backend=backend,
|
||||
start_time=self.start_time,
|
||||
total_char_count=self.char_count,
|
||||
use_gpu=self.gpu_ok,
|
||||
@@ -2179,6 +2367,21 @@ class abogen(QWidget):
|
||||
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
|
||||
# Pass use_spacy_segmentation setting
|
||||
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation
|
||||
# Pass word substitution settings
|
||||
self.conversion_thread.word_substitutions_enabled = (
|
||||
self.word_substitutions_enabled
|
||||
)
|
||||
self.conversion_thread.word_substitutions_list = (
|
||||
self.word_substitutions_list
|
||||
)
|
||||
self.conversion_thread.case_sensitive_substitutions = (
|
||||
self.case_sensitive_substitutions
|
||||
)
|
||||
self.conversion_thread.replace_all_caps = self.replace_all_caps
|
||||
self.conversion_thread.replace_numerals = self.replace_numerals
|
||||
self.conversion_thread.fix_nonstandard_punctuation = (
|
||||
self.fix_nonstandard_punctuation
|
||||
)
|
||||
# Pass separate_chapters_format setting
|
||||
self.conversion_thread.separate_chapters_format = (
|
||||
self.separate_chapters_format
|
||||
@@ -2223,7 +2426,17 @@ class abogen(QWidget):
|
||||
self.gpu_ok = gpu_ok
|
||||
self.update_log((gpu_msg, gpu_ok))
|
||||
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()
|
||||
|
||||
threading.Thread(target=gpu_and_load, daemon=True).start()
|
||||
@@ -2660,18 +2873,24 @@ class abogen(QWidget):
|
||||
)
|
||||
self.loading_movie.start()
|
||||
|
||||
def pipeline_loaded_callback(np_module, kpipeline_class, error):
|
||||
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
|
||||
# Determine device based on GPU availability
|
||||
if self.gpu_ok:
|
||||
device = _select_device()
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||
lang = self.selected_lang or "a"
|
||||
load_thread = LoadPipelineThread(
|
||||
self._on_pipeline_loaded_for_preview, lang_code=lang, device=device
|
||||
)
|
||||
load_thread.start()
|
||||
|
||||
def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error):
|
||||
def _on_pipeline_loaded_for_preview(self, backend, error):
|
||||
# stop loading animation and restore icon on error
|
||||
if error:
|
||||
self.loading_movie.stop()
|
||||
self._show_error_message_box(
|
||||
"Loading Error", f"Error loading numpy or KPipeline: {error}"
|
||||
"Loading Error", f"Error loading TTS backend: {error}"
|
||||
)
|
||||
self.btn_preview.setIcon(self.play_icon)
|
||||
self.btn_preview.setEnabled(True)
|
||||
@@ -2709,7 +2928,7 @@ class abogen(QWidget):
|
||||
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
||||
|
||||
self.preview_thread = VoicePreviewThread(
|
||||
np_module, kpipeline_class, lang, voice, speed, gpu_ok
|
||||
backend, lang, voice, speed, gpu_ok
|
||||
)
|
||||
self.preview_thread.finished.connect(self._play_preview_audio)
|
||||
self.preview_thread.error.connect(self._preview_error)
|
||||
@@ -2927,6 +3146,41 @@ class abogen(QWidget):
|
||||
self.config["use_gpu"] = self.use_gpu
|
||||
save_config(self.config)
|
||||
|
||||
def on_word_sub_changed(self, text):
|
||||
"""Handle word substitution dropdown change."""
|
||||
self.word_substitutions_enabled = text == "Enabled"
|
||||
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
|
||||
|
||||
# Save to config
|
||||
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||
save_config(self.config)
|
||||
|
||||
def show_word_sub_dialog(self):
|
||||
"""Show word substitutions settings dialog."""
|
||||
dialog = WordSubstitutionsDialog(
|
||||
self,
|
||||
initial_list=self.word_substitutions_list,
|
||||
initial_case_sensitive=self.case_sensitive_substitutions,
|
||||
initial_caps=self.replace_all_caps,
|
||||
initial_numerals=self.replace_numerals,
|
||||
initial_punctuation=self.fix_nonstandard_punctuation,
|
||||
)
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.word_substitutions_list = dialog.get_substitutions_list()
|
||||
self.case_sensitive_substitutions = dialog.get_case_sensitive()
|
||||
self.replace_all_caps = dialog.get_replace_all_caps()
|
||||
self.replace_numerals = dialog.get_replace_numerals()
|
||||
self.fix_nonstandard_punctuation = dialog.get_fix_nonstandard_punctuation()
|
||||
|
||||
# Save all settings to config
|
||||
self.config["word_substitutions_list"] = self.word_substitutions_list
|
||||
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||
self.config["replace_all_caps"] = self.replace_all_caps
|
||||
self.config["replace_numerals"] = self.replace_numerals
|
||||
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||
save_config(self.config)
|
||||
|
||||
def cleanup_conversion_thread(self):
|
||||
# Stop conversion thread
|
||||
if (
|
||||
@@ -2977,12 +3231,16 @@ class abogen(QWidget):
|
||||
)
|
||||
box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
if box.exec() == QMessageBox.StandardButton.Yes:
|
||||
from abogen import shutdown
|
||||
shutdown.request_shutdown()
|
||||
self.cleanup_conversion_thread()
|
||||
self.cleanup_preview_threads()
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
else:
|
||||
from abogen import shutdown
|
||||
shutdown.request_shutdown()
|
||||
self.cleanup_conversion_thread()
|
||||
self.cleanup_preview_threads()
|
||||
event.accept()
|
||||
@@ -2991,8 +3249,6 @@ class abogen(QWidget):
|
||||
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
|
||||
# Check if this is a timestamp detection (-1) or chapter detection
|
||||
if chapter_count == -1:
|
||||
from abogen.conversion import TimestampDetectionDialog
|
||||
|
||||
dialog = TimestampDetectionDialog(parent=self)
|
||||
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
@@ -3007,8 +3263,6 @@ class abogen(QWidget):
|
||||
return
|
||||
|
||||
# Normal chapter detection
|
||||
from abogen.conversion import ChapterOptionsDialog
|
||||
|
||||
dialog = ChapterOptionsDialog(chapter_count, parent=self)
|
||||
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
|
||||
+4
-22
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import atexit
|
||||
import signal
|
||||
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
|
||||
|
||||
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
|
||||
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
|
||||
if platform.system() == "Windows":
|
||||
@@ -94,6 +94,7 @@ os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
|
||||
os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
|
||||
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
|
||||
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning
|
||||
from abogen.utils import load_config
|
||||
if load_config().get("disable_kokoro_internet", False):
|
||||
print("INFO: Kokoro's internet access is disabled.")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
|
||||
@@ -105,25 +106,6 @@ from abogen.constants import PROGRAM_NAME, VERSION
|
||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||
|
||||
# Reset sleep states
|
||||
atexit.register(prevent_sleep_end)
|
||||
|
||||
|
||||
# Also handle signals (Ctrl+C, kill, etc.)
|
||||
def _cleanup_sleep(signum, frame):
|
||||
prevent_sleep_end()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||
signal.signal(signal.SIGTERM, _cleanup_sleep)
|
||||
|
||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
# Enable MPS GPU acceleration on Mac Apple Silicon
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
|
||||
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||
from abogen.constants import COLORS
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.spacy_utils import SPACY_MODELS
|
||||
import abogen.hf_tracker
|
||||
|
||||
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
|
||||
self._voices_success = False
|
||||
return
|
||||
|
||||
voice_list = VOICES_INTERNAL
|
||||
voice_list = get_voices("kokoro")
|
||||
for idx, voice in enumerate(voice_list, start=1):
|
||||
if self._cancelled:
|
||||
self._voices_success = False
|
||||
@@ -462,14 +463,14 @@ class PreDownloadDialog(QDialog):
|
||||
try:
|
||||
from huggingface_hub import try_to_load_from_cache
|
||||
|
||||
for voice in VOICES_INTERNAL:
|
||||
for voice in get_voices("kokoro"):
|
||||
if not try_to_load_from_cache(
|
||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||
):
|
||||
missing.append(voice)
|
||||
except Exception:
|
||||
# If HF missing, report all as missing
|
||||
return False, list(VOICES_INTERNAL)
|
||||
return False, list(get_voices("kokoro"))
|
||||
return (len(missing) == 0), missing
|
||||
|
||||
def _check_kokoro_model(self) -> bool:
|
||||
|
||||
@@ -35,6 +35,12 @@ OVERRIDE_FIELDS = [
|
||||
"replace_single_newlines",
|
||||
"use_silent_gaps",
|
||||
"subtitle_speed_method",
|
||||
"word_substitutions_enabled",
|
||||
"word_substitutions_list",
|
||||
"case_sensitive_substitutions",
|
||||
"replace_all_caps",
|
||||
"replace_numerals",
|
||||
"fix_nonstandard_punctuation",
|
||||
]
|
||||
|
||||
|
||||
@@ -474,6 +480,21 @@ class QueueManager(QDialog):
|
||||
attrs["subtitle_speed_method"] = getattr(
|
||||
parent, "subtitle_speed_method", "tts"
|
||||
)
|
||||
# word substitutions
|
||||
attrs["word_substitutions_enabled"] = getattr(
|
||||
parent, "word_substitutions_enabled", False
|
||||
)
|
||||
attrs["word_substitutions_list"] = getattr(
|
||||
parent, "word_substitutions_list", ""
|
||||
)
|
||||
attrs["case_sensitive_substitutions"] = getattr(
|
||||
parent, "case_sensitive_substitutions", False
|
||||
)
|
||||
attrs["replace_all_caps"] = getattr(parent, "replace_all_caps", False)
|
||||
attrs["replace_numerals"] = getattr(parent, "replace_numerals", False)
|
||||
attrs["fix_nonstandard_punctuation"] = getattr(
|
||||
parent, "fix_nonstandard_punctuation", False
|
||||
)
|
||||
# book handler options
|
||||
attrs["save_chapters_separately"] = getattr(
|
||||
parent, "save_chapters_separately", None
|
||||
|
||||
@@ -19,3 +19,10 @@ class QueuedItem:
|
||||
save_base_path: str = None
|
||||
save_chapters_separately: bool = None
|
||||
merge_chapters_at_end: bool = None
|
||||
# Word Substitution fields
|
||||
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
|
||||
|
||||
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
|
||||
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||||
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
||||
from abogen.constants import (
|
||||
VOICES_INTERNAL,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
COLORS,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
import re
|
||||
import platform
|
||||
from abogen.utils import get_resource_path
|
||||
@@ -179,7 +179,7 @@ class VoiceMixer(QWidget):
|
||||
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Voice name label with gender icon
|
||||
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
|
||||
is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f"
|
||||
|
||||
# Icons layout (flag and gender)
|
||||
icons_layout = QHBoxLayout()
|
||||
@@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
|
||||
|
||||
def add_voices(self, initial_state):
|
||||
first_enabled_voice = None
|
||||
for voice in VOICES_INTERNAL:
|
||||
for voice in get_voices("kokoro"):
|
||||
language_code = voice[0] # First character is the language code
|
||||
matching_voice = next(
|
||||
(item for item in initial_state if item[0] == voice), None
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""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"]
|
||||
+125
-2
@@ -15,6 +15,8 @@ _ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}")
|
||||
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
|
||||
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
|
||||
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>")
|
||||
_VOICE_MARKER_PATTERN = re.compile(r"<<VOICE:[^>]*>>")
|
||||
_VOICE_MARKER_SEARCH_PATTERN = re.compile(r"<<VOICE:(.*?)>>")
|
||||
_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE)
|
||||
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
||||
_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
||||
@@ -31,17 +33,19 @@ _LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]")
|
||||
|
||||
|
||||
def clean_subtitle_text(text):
|
||||
"""Remove chapter markers and metadata tags from subtitle text."""
|
||||
"""Remove chapter markers, voice markers, and metadata tags from subtitle text."""
|
||||
# Use pre-compiled patterns for better performance
|
||||
text = _METADATA_TAG_PATTERN.sub("", text)
|
||||
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
||||
text = _VOICE_MARKER_PATTERN.sub("", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def calculate_text_length(text):
|
||||
# Use pre-compiled patterns for better performance
|
||||
# Ignore chapter markers and metadata patterns in a single pass
|
||||
# Ignore chapter markers, voice markers, and metadata patterns in a single pass
|
||||
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
||||
text = _VOICE_MARKER_PATTERN.sub("", text)
|
||||
text = _METADATA_TAG_PATTERN.sub("", text)
|
||||
# Ignore newlines and leading/trailing spaces
|
||||
text = text.replace("\n", "").strip()
|
||||
@@ -459,3 +463,122 @@ def sanitize_name_for_os(name, is_folder=True):
|
||||
sanitized = sanitized[:255].rstrip(". ")
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def validate_voice_name(voice_name):
|
||||
"""Validate voice name against available voices (case-insensitive).
|
||||
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
|
||||
|
||||
Args:
|
||||
voice_name: Voice name or formula string to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, invalid_voice_name):
|
||||
- is_valid: True if all voices in the name/formula are valid
|
||||
- invalid_voice_name: The first invalid voice found, or None if all valid
|
||||
"""
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
# Create case-insensitive lookup set (done once per call)
|
||||
voice_lookup_lower = {v.lower() for v in get_voices("kokoro")}
|
||||
voice_name = voice_name.strip()
|
||||
|
||||
# Check if it's a formula (contains *)
|
||||
if "*" in voice_name:
|
||||
# Extract voice names from formula
|
||||
voices = voice_name.split("+")
|
||||
for term in voices:
|
||||
if "*" in term:
|
||||
base_voice = term.split("*")[0].strip()
|
||||
# Case-insensitive comparison
|
||||
if base_voice.lower() not in voice_lookup_lower:
|
||||
return False, base_voice
|
||||
return True, None
|
||||
else:
|
||||
# Single voice - case-insensitive comparison
|
||||
if voice_name.lower() not in voice_lookup_lower:
|
||||
return False, voice_name
|
||||
return True, None
|
||||
|
||||
|
||||
def split_text_by_voice_markers(text, default_voice):
|
||||
"""Split text by voice markers, returning list of (voice, text) tuples.
|
||||
|
||||
IMPORTANT: Returns the last voice used so it can persist across chapters.
|
||||
Voice names are normalized to lowercase to match canonical voice names.
|
||||
|
||||
Args:
|
||||
text: Text potentially containing <<VOICE:name>> markers
|
||||
default_voice: Voice to use if no markers found or before first marker
|
||||
|
||||
Returns:
|
||||
Tuple of (segments_list, last_voice_used, valid_count, invalid_count):
|
||||
- segments_list: List of (voice_name, segment_text) tuples
|
||||
- last_voice_used: The voice that should continue into next chapter
|
||||
- valid_count: Number of valid voice markers processed
|
||||
- invalid_count: Number of invalid voice markers skipped
|
||||
"""
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
||||
|
||||
if not voice_splits:
|
||||
# No voice markers, return entire text with default voice
|
||||
return [(default_voice, text)], default_voice, 0, 0
|
||||
|
||||
segments = []
|
||||
current_voice = default_voice
|
||||
valid_markers = 0
|
||||
invalid_markers = 0
|
||||
|
||||
# Text before first marker uses default voice
|
||||
first_start = voice_splits[0].start()
|
||||
if first_start > 0:
|
||||
intro_text = text[:first_start].strip()
|
||||
if intro_text:
|
||||
segments.append((current_voice, intro_text))
|
||||
|
||||
# Process each voice marker
|
||||
for idx, match in enumerate(voice_splits):
|
||||
voice_name = match.group(1).strip()
|
||||
start = match.end()
|
||||
end = voice_splits[idx + 1].start() if idx + 1 < len(voice_splits) else len(text)
|
||||
segment_text = text[start:end].strip()
|
||||
|
||||
# Validate voice name
|
||||
is_valid, invalid_voice = validate_voice_name(voice_name)
|
||||
if is_valid:
|
||||
# Normalize to lowercase to match canonical form
|
||||
# Handle both single voices and formulas
|
||||
if "*" in voice_name:
|
||||
# Normalize each voice in the formula
|
||||
normalized_parts = []
|
||||
for part in voice_name.split("+"):
|
||||
part = part.strip()
|
||||
if "*" in part:
|
||||
voice_part, weight = part.split("*", 1)
|
||||
# Find the canonical (lowercase) voice name
|
||||
voice_part_lower = voice_part.strip().lower()
|
||||
canonical_voice = next(
|
||||
(v for v in get_voices("kokoro") if v.lower() == voice_part_lower),
|
||||
voice_part.strip()
|
||||
)
|
||||
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
|
||||
current_voice = " + ".join(normalized_parts)
|
||||
else:
|
||||
# Find the canonical (lowercase) voice name
|
||||
voice_name_lower = voice_name.lower()
|
||||
current_voice = next(
|
||||
(v for v in get_voices("kokoro") if v.lower() == voice_name_lower),
|
||||
voice_name
|
||||
)
|
||||
valid_markers += 1
|
||||
else:
|
||||
# Invalid voice - stay with previous voice
|
||||
invalid_markers += 1
|
||||
|
||||
if segment_text:
|
||||
segments.append((current_voice, segment_text))
|
||||
|
||||
# Return segments, last voice, and counts
|
||||
return segments, current_voice, valid_markers, invalid_markers
|
||||
|
||||
@@ -1023,8 +1023,13 @@ class EpubExtractor:
|
||||
if not html:
|
||||
return ""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup.find_all(["p", "div"]):
|
||||
|
||||
# Add line breaks after block-level elements to ensure pauses in speech
|
||||
for tag in soup.find_all(
|
||||
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
|
||||
):
|
||||
tag.append("\n\n")
|
||||
|
||||
for ol in soup.find_all("ol"):
|
||||
start_attr = ol.get("start")
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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().
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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
|
||||
@@ -0,0 +1,365 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""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
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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)
|
||||
+10
-11
@@ -529,21 +529,20 @@ def prevent_sleep_end():
|
||||
_sleep_procs[system] = None
|
||||
|
||||
|
||||
def load_numpy_kpipeline():
|
||||
import numpy as np
|
||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||
|
||||
return np, KPipeline
|
||||
|
||||
|
||||
class LoadPipelineThread(Thread):
|
||||
def __init__(self, callback):
|
||||
def __init__(self, callback, lang_code="a", device="cpu"):
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
self.lang_code = lang_code
|
||||
self.device = device
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
np_module, kpipeline_class = load_numpy_kpipeline()
|
||||
self.callback(np_module, kpipeline_class, None)
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
backend = create_pipeline(
|
||||
"kokoro", lang_code=self.lang_code, device=self.device
|
||||
)
|
||||
self.callback(backend, None)
|
||||
except Exception as e:
|
||||
self.callback(None, None, str(e))
|
||||
self.callback(None, str(e))
|
||||
|
||||
+12
-3
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
||||
pass
|
||||
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHED_VOICES: Set[str] = set()
|
||||
@@ -26,8 +26,9 @@ _BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
if not voices:
|
||||
return set(VOICES_INTERNAL)
|
||||
return set(kokoro_voices)
|
||||
normalized: Set[str] = set()
|
||||
for voice in voices:
|
||||
if not voice:
|
||||
@@ -35,7 +36,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
voice_id = str(voice).strip()
|
||||
if not voice_id:
|
||||
continue
|
||||
if voice_id in VOICES_INTERNAL:
|
||||
if voice_id in kokoro_voices:
|
||||
normalized.add(voice_id)
|
||||
return normalized
|
||||
|
||||
@@ -143,3 +144,11 @@ def _ensure_single_voice_asset(
|
||||
|
||||
hf_hub_download(resume_download=True, **common_kwargs)
|
||||
return True
|
||||
|
||||
|
||||
def clear_voice_cache() -> None:
|
||||
"""Clear the in‑process voice cache (used during shutdown)."""
|
||||
with _CACHE_LOCK:
|
||||
_CACHED_VOICES.clear()
|
||||
global _BOOTSTRAPPED
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
# Calls parsing and loads the voice to gpu or cpu
|
||||
@@ -22,6 +22,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
raise ValueError("Empty voice formula")
|
||||
|
||||
terms: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for segment in formula.split("+"):
|
||||
part = segment.strip()
|
||||
if not part:
|
||||
@@ -30,7 +31,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
voice_name, raw_weight = part.split("*", 1)
|
||||
voice_name = voice_name.strip()
|
||||
if voice_name not in VOICES_INTERNAL:
|
||||
if voice_name not in kokoro_voices:
|
||||
raise ValueError(f"Unknown voice: {voice_name}")
|
||||
try:
|
||||
weight = float(raw_weight.strip())
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceMetadata:
|
||||
"""
|
||||
Immutable metadata describing a voice from a TTS backend.
|
||||
|
||||
This model describes a voice independently of any backend implementation.
|
||||
Backends populate these objects; the application consumes them.
|
||||
|
||||
The ``backend_id`` field is set by the backend itself (via
|
||||
``self.metadata.id``) — the application never hardcodes it.
|
||||
This ensures renaming a backend does not require touching voice definitions.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""Unique voice identifier within the backend (e.g. ``"af_alloy"``, ``"M1"``)."""
|
||||
|
||||
display_name: str
|
||||
"""Human-readable display name (e.g. ``"Alloy"``, ``"Male 1"``)."""
|
||||
|
||||
language: str
|
||||
"""Language code — backend-specific format is acceptable (e.g. ``"a"``, ``"en"``)."""
|
||||
|
||||
gender: str
|
||||
"""Gender category: ``"female"``, ``"male"``, or ``"unknown"``."""
|
||||
|
||||
backend_id: str
|
||||
"""Identifier of the backend that owns this voice (e.g. ``"kokoro"``).
|
||||
|
||||
Set automatically by the backend — never hardcoded in voice definitions.
|
||||
"""
|
||||
@@ -2,8 +2,7 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
|
||||
@@ -70,7 +69,8 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||
|
||||
def _normalize_supertonic_voice(value: Any) -> str:
|
||||
raw = str(value or "").strip().upper()
|
||||
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
|
||||
supertonic_voices = get_voices("supertonic")
|
||||
return raw if raw in supertonic_voices else "M1"
|
||||
|
||||
|
||||
def _coerce_supertonic_steps(value: Any) -> int:
|
||||
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||
if provider not in {"kokoro", "supertonic"}:
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||
@@ -135,6 +135,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||
|
||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
normalized: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for item in entries or []:
|
||||
if isinstance(item, dict):
|
||||
voice = item.get("id") or item.get("voice")
|
||||
@@ -143,7 +144,7 @@ def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||
voice, weight = item[0], item[1]
|
||||
else:
|
||||
continue
|
||||
if voice not in VOICES_INTERNAL:
|
||||
if voice not in kokoro_voices:
|
||||
continue
|
||||
if weight is None:
|
||||
continue
|
||||
|
||||
@@ -2,7 +2,6 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv \
|
||||
PATH=/opt/venv/bin:$PATH
|
||||
|
||||
@@ -27,22 +26,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY abogen ./abogen
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
RUN pip install uv \
|
||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||
uv pip install --system 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"; \
|
||||
uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||
fi \
|
||||
&& pip install --no-cache-dir . \
|
||||
&& 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 \
|
||||
&& pip install --no-cache-dir "mutagen>=1.47.0"
|
||||
&& uv pip install --system "mutagen>=1.47.0"
|
||||
|
||||
COPY abogen ./abogen
|
||||
|
||||
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
|
||||
# Set USE_GPU=false to skip this for CPU-only deployments
|
||||
RUN if [ "$USE_GPU" = "true" ]; then \
|
||||
pip install --no-cache-dir onnxruntime-gpu; \
|
||||
uv pip install --system onnxruntime-gpu; \
|
||||
fi
|
||||
|
||||
ENV ABOGEN_HOST=0.0.0.0 \
|
||||
|
||||
+8
-3
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -8,6 +7,8 @@ from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir
|
||||
|
||||
from .conversion_runner import run_conversion_job
|
||||
@@ -83,6 +84,12 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
"UPLOAD_FOLDER": str(uploads_dir),
|
||||
"OUTPUT_FOLDER": str(outputs_dir),
|
||||
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
||||
# Large books can submit four form fields per chapter. Werkzeug's
|
||||
# defaults reject those requests before the wizard route can process
|
||||
# them, even though the encoded payload is much smaller than the upload
|
||||
# limit above.
|
||||
"MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024,
|
||||
"MAX_FORM_PARTS": 10_000,
|
||||
}
|
||||
if config:
|
||||
base_config.update(config)
|
||||
@@ -113,8 +120,6 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
app.register_blueprint(books_bp, url_prefix="/find-books")
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
|
||||
atexit.register(service.shutdown)
|
||||
|
||||
global _access_log_filter_attached
|
||||
if not _access_log_filter_attached:
|
||||
logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter())
|
||||
|
||||
+173
-1621
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config
|
||||
from abogen.text_extractor import extract_from_path
|
||||
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.utils import load_numpy_kpipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
|
||||
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
||||
@@ -45,8 +45,7 @@ def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
device = _select_device()
|
||||
_np, KPipeline = load_numpy_kpipeline()
|
||||
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
return create_pipeline("kokoro", lang_code=language, device=device)
|
||||
|
||||
|
||||
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
||||
@@ -248,4 +247,8 @@ def run_debug_tts_wavs(
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
}
|
||||
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
try:
|
||||
pipeline.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
return manifest
|
||||
|
||||
@@ -34,6 +34,7 @@ from abogen.normalization_settings import (
|
||||
)
|
||||
from abogen.llm_client import list_models, LLMClientError
|
||||
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.calibre_opds import (
|
||||
CalibreOPDSClient,
|
||||
@@ -63,7 +64,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
|
||||
if profile is None:
|
||||
# Speaker Studio payload format
|
||||
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
||||
if provider not in {"kokoro", "supertonic"}:
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
if provider == "supertonic":
|
||||
profile = {
|
||||
@@ -230,7 +231,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
||||
use_gpu = settings.get("use_gpu", False)
|
||||
|
||||
base_spec, speaker_name = split_profile_spec(voice)
|
||||
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else ""
|
||||
resolved_provider = tts_provider if is_plugin_registered(tts_provider) else ""
|
||||
|
||||
if speaker_name:
|
||||
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
||||
|
||||
@@ -7,6 +7,7 @@ from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.webui.service import PendingJob, JobStatus
|
||||
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 (
|
||||
load_settings,
|
||||
coerce_bool,
|
||||
@@ -32,7 +33,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
|
||||
from abogen.utils import calculate_text_length
|
||||
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
|
||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from abogen.tts_plugin.utils import get_default_voice
|
||||
from abogen.speaker_configs import get_config
|
||||
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||
from dataclasses import dataclass
|
||||
@@ -579,7 +580,7 @@ def apply_book_step_form(
|
||||
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
||||
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
|
||||
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
||||
if provider_value in {"kokoro", "supertonic"}:
|
||||
if is_plugin_registered(provider_value):
|
||||
pending.tts_provider = provider_value
|
||||
|
||||
# Determine the base speaker selection (saved speaker ref or raw voice).
|
||||
@@ -616,8 +617,8 @@ def apply_book_step_form(
|
||||
custom_formula = ""
|
||||
|
||||
base_voice_spec = resolved_default_voice or narrator_voice_raw
|
||||
if not base_voice_spec and VOICES_INTERNAL:
|
||||
base_voice_spec = VOICES_INTERNAL[0]
|
||||
if not base_voice_spec:
|
||||
base_voice_spec = get_default_voice("kokoro")
|
||||
|
||||
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
|
||||
pending.language,
|
||||
@@ -796,8 +797,8 @@ def build_pending_job_from_extraction(
|
||||
profile_selection = inferred_profile
|
||||
|
||||
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
|
||||
if not base_voice and VOICES_INTERNAL:
|
||||
base_voice = VOICES_INTERNAL[0]
|
||||
if not base_voice:
|
||||
base_voice = get_default_voice("kokoro")
|
||||
selected_speaker_config = (form.get("speaker_config") or "").strip()
|
||||
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import soundfile as sf
|
||||
from flask import current_app, send_file
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.domain.device import select_device as _select_device
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
@@ -14,13 +16,32 @@ _preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
||||
_preview_pipeline_lock = threading.Lock()
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
def clear_preview_pipelines() -> None:
|
||||
"""Dispose all cached preview pipelines and clear the cache."""
|
||||
with _preview_pipeline_lock:
|
||||
for pipeline in _preview_pipelines.values():
|
||||
try:
|
||||
pipeline.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
_preview_pipelines.clear()
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
return "mps"
|
||||
return "cuda"
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
if preferred != "cpu":
|
||||
devices.insert(0, preferred)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for device in devices:
|
||||
try:
|
||||
return get_preview_pipeline(language, device), device != "cpu"
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
@@ -45,10 +66,9 @@ def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
_, KPipeline = load_numpy_kpipeline()
|
||||
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
@@ -104,9 +124,9 @@ def generate_preview_audio(
|
||||
normalized_text = source_text
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_supertonic import SupertonicPipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||
pipeline = create_pipeline("supertonic")
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
voice=voice_spec,
|
||||
@@ -115,15 +135,7 @@ def generate_preview_audio(
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
try:
|
||||
device = _select_device()
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
use_gpu = False
|
||||
|
||||
pipeline = get_preview_pipeline(language, device)
|
||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
@@ -131,7 +143,7 @@ def generate_preview_audio(
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
|
||||
@@ -6,8 +6,8 @@ from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_default_voice
|
||||
from abogen.normalization_settings import (
|
||||
DEFAULT_LLM_PROMPT,
|
||||
environment_llm_defaults,
|
||||
@@ -174,7 +174,7 @@ def settings_defaults() -> Dict[str, Any]:
|
||||
"subtitle_format": "srt",
|
||||
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
||||
"default_speaker": "",
|
||||
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||
"default_voice": get_default_voice("kokoro"),
|
||||
"supertonic_total_steps": 5,
|
||||
"supertonic_speed": 1.0,
|
||||
"replace_single_newlines": False,
|
||||
|
||||
@@ -17,10 +17,10 @@ from abogen.constants import (
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
SAMPLE_VOICE_TEXTS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
from abogen.speaker_configs import list_configs
|
||||
from abogen.utils import load_numpy_kpipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
||||
|
||||
_preview_pipeline_lock = threading.RLock()
|
||||
@@ -285,7 +285,7 @@ def filter_voice_catalog(
|
||||
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||
catalog: List[Dict[str, str]] = []
|
||||
gender_map = {"f": "Female", "m": "Male"}
|
||||
for voice_id in VOICES_INTERNAL:
|
||||
for voice_id in get_voices("kokoro"):
|
||||
prefix, _, rest = voice_id.partition("_")
|
||||
language_code = prefix[0] if prefix else "a"
|
||||
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||
@@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]:
|
||||
voice_catalog = build_voice_catalog()
|
||||
return {
|
||||
"languages": LANGUAGE_DESCRIPTIONS,
|
||||
"voices": VOICES_INTERNAL,
|
||||
"voices": get_voices("kokoro"),
|
||||
"subtitle_formats": SUBTITLE_FORMATS,
|
||||
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||
@@ -741,8 +741,7 @@ def get_preview_pipeline(language: str, device: str):
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
_, KPipeline = load_numpy_kpipeline()
|
||||
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from abogen.speaker_configs import (
|
||||
save_configs,
|
||||
delete_config,
|
||||
)
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
|
||||
voices_bp = Blueprint("voices", __name__)
|
||||
|
||||
|
||||
@@ -1609,10 +1609,12 @@ def build_service(
|
||||
output_root: Optional[Path] = None,
|
||||
uploads_root: Optional[Path] = None,
|
||||
) -> ConversionService:
|
||||
global _service_instance
|
||||
output_root = output_root or default_storage_root()
|
||||
service = ConversionService(
|
||||
output_root=output_root,
|
||||
uploads_root=uploads_root,
|
||||
runner=runner,
|
||||
)
|
||||
_service_instance = service
|
||||
return service
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Word substitution module for text-to-speech preprocessing.
|
||||
|
||||
This module provides functionality to:
|
||||
- Replace words/phrases with custom text
|
||||
- Convert ALL CAPS to lowercase
|
||||
- Convert numerals to words
|
||||
- Fix nonstandard punctuation for TTS compatibility
|
||||
|
||||
All substitutions preserve special markers (chapter, voice, metadata, timestamps).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from abogen.subtitle_utils import (
|
||||
_CHAPTER_MARKER_PATTERN,
|
||||
_VOICE_MARKER_PATTERN,
|
||||
_METADATA_TAG_PATTERN,
|
||||
_TIMESTAMP_ONLY_PATTERN,
|
||||
)
|
||||
|
||||
|
||||
def apply_word_substitutions(
|
||||
text,
|
||||
substitutions_list_str,
|
||||
case_sensitive=False,
|
||||
replace_all_caps=False,
|
||||
replace_numerals=False,
|
||||
fix_nonstandard_punctuation=False,
|
||||
):
|
||||
"""
|
||||
Apply word substitutions to text while preserving markers.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
substitutions_list_str: Newline-separated "Word|NewWord" pairs
|
||||
case_sensitive: If True, match words case-sensitively
|
||||
replace_all_caps: Convert ALL CAPS words to lowercase
|
||||
replace_numerals: Convert numbers to words
|
||||
fix_nonstandard_punctuation: Fix curly quotes, em/en dashes, etc.
|
||||
|
||||
Returns:
|
||||
Modified text
|
||||
"""
|
||||
# Apply nonstandard punctuation fixes FIRST (if enabled)
|
||||
if fix_nonstandard_punctuation:
|
||||
text = fix_punctuation(text)
|
||||
|
||||
# Parse substitutions list
|
||||
substitutions = parse_substitutions_list(substitutions_list_str)
|
||||
|
||||
# Split text into segments (markers vs content)
|
||||
segments = split_text_preserving_markers(text)
|
||||
|
||||
# Process each segment
|
||||
processed_segments = []
|
||||
for segment_type, segment_text in segments:
|
||||
if segment_type == "marker":
|
||||
# Preserve markers unchanged
|
||||
processed_segments.append(segment_text)
|
||||
else:
|
||||
# Apply substitutions to content
|
||||
processed_text = segment_text
|
||||
|
||||
# Apply word substitutions
|
||||
if substitutions:
|
||||
processed_text = apply_word_replacements(
|
||||
processed_text, substitutions, case_sensitive
|
||||
)
|
||||
|
||||
# Apply ALL CAPS conversion
|
||||
if replace_all_caps:
|
||||
processed_text = convert_all_caps_to_lowercase(processed_text)
|
||||
|
||||
# Apply numeral conversion
|
||||
if replace_numerals:
|
||||
processed_text = convert_numerals_to_words(processed_text)
|
||||
|
||||
processed_segments.append(processed_text)
|
||||
|
||||
return "".join(processed_segments)
|
||||
|
||||
|
||||
def parse_substitutions_list(substitutions_str):
|
||||
"""
|
||||
Parse newline-separated "Word|NewWord" format.
|
||||
|
||||
Args:
|
||||
substitutions_str: String with substitutions, one per line
|
||||
|
||||
Returns:
|
||||
List of tuples: [(word, replacement), ...]
|
||||
"""
|
||||
substitutions = []
|
||||
for line in substitutions_str.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or "|" not in line:
|
||||
continue
|
||||
|
||||
parts = line.split("|", 1)
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
replacement = parts[1].strip()
|
||||
if word: # Only add if word is not empty
|
||||
substitutions.append((word, replacement))
|
||||
|
||||
return substitutions
|
||||
|
||||
|
||||
def split_text_preserving_markers(text):
|
||||
"""
|
||||
Split text into segments alternating between markers and content.
|
||||
|
||||
Args:
|
||||
text: Input text with potential markers
|
||||
|
||||
Returns:
|
||||
List of tuples: [("marker"|"content", text), ...]
|
||||
"""
|
||||
# Combined pattern for all markers and timestamps
|
||||
marker_pattern = re.compile(
|
||||
r"(<<CHAPTER_MARKER:[^>]*>>|<<VOICE:[^>]*>>|<<METADATA_[^:]+:[^>]*>>|\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)"
|
||||
)
|
||||
|
||||
segments = []
|
||||
last_end = 0
|
||||
|
||||
for match in marker_pattern.finditer(text):
|
||||
# Content before marker
|
||||
if match.start() > last_end:
|
||||
segments.append(("content", text[last_end : match.start()]))
|
||||
|
||||
# Marker itself
|
||||
segments.append(("marker", match.group(0)))
|
||||
last_end = match.end()
|
||||
|
||||
# Remaining content after last marker
|
||||
if last_end < len(text):
|
||||
segments.append(("content", text[last_end:]))
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def apply_word_replacements(text, substitutions, case_sensitive=False):
|
||||
"""
|
||||
Apply word substitutions using whole-word matching.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
substitutions: List of (word, replacement) tuples
|
||||
case_sensitive: If True, match case-sensitively
|
||||
|
||||
Returns:
|
||||
Text with substitutions applied
|
||||
"""
|
||||
for word, replacement in substitutions:
|
||||
# Use word boundaries for exact matching
|
||||
# Escape special regex characters
|
||||
escaped_word = re.escape(word)
|
||||
pattern = re.compile(
|
||||
r"\b" + escaped_word + r"\b",
|
||||
0 if case_sensitive else re.IGNORECASE,
|
||||
)
|
||||
text = pattern.sub(replacement, text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def convert_all_caps_to_lowercase(text):
|
||||
"""
|
||||
Convert ALL CAPS words to lowercase.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Text with ALL CAPS converted to lowercase
|
||||
"""
|
||||
|
||||
def replace_caps(match):
|
||||
word = match.group(0)
|
||||
# Convert to lowercase
|
||||
return word.lower()
|
||||
|
||||
# Match words that are ALL CAPS (2+ letters)
|
||||
pattern = re.compile(r"\b[A-Z]{2,}\b")
|
||||
return pattern.sub(replace_caps, text)
|
||||
|
||||
|
||||
def convert_numerals_to_words(text):
|
||||
"""
|
||||
Convert numerals to words using num2words library.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Text with numerals converted to words
|
||||
"""
|
||||
try:
|
||||
from num2words import num2words
|
||||
except ImportError:
|
||||
# If num2words not available, return unchanged
|
||||
return text
|
||||
|
||||
def replace_number(match):
|
||||
try:
|
||||
number = int(match.group(0))
|
||||
# Convert to words in English
|
||||
return num2words(number)
|
||||
except Exception:
|
||||
# If conversion fails, return original
|
||||
return match.group(0)
|
||||
|
||||
# Match integers (but not timestamps or other patterns)
|
||||
# Negative lookbehind/ahead to avoid timestamps
|
||||
pattern = re.compile(r"(?<!\d:)\b\d+\b(?!:\d)")
|
||||
return pattern.sub(replace_number, text)
|
||||
|
||||
|
||||
def fix_punctuation(text):
|
||||
"""
|
||||
Convert nonstandard punctuation to standard equivalents.
|
||||
|
||||
This helps TTS engines pronounce words correctly by converting:
|
||||
- Curly quotes to straight quotes
|
||||
- Ellipsis to three periods
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Text with nonstandard punctuation fixed
|
||||
"""
|
||||
# Define replacements
|
||||
replacements = {
|
||||
# Curly double quotes
|
||||
"\u201c": '"', # Left double quotation mark
|
||||
"\u201d": '"', # Right double quotation mark
|
||||
"\u201e": '"', # Double low-9 quotation mark
|
||||
# Curly single quotes
|
||||
"\u2018": "'", # Left single quotation mark
|
||||
"\u2019": "'", # Right single quotation mark
|
||||
"\u201a": "'", # Single low-9 quotation mark
|
||||
"\u201b": "'", # Single high-reversed-9 quotation mark
|
||||
# Other punctuation
|
||||
"\u2026": "...", # Ellipsis
|
||||
}
|
||||
|
||||
# Apply all replacements
|
||||
for old_char, new_char in replacements.items():
|
||||
text = text.replace(old_char, new_char)
|
||||
|
||||
return text
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
@@ -0,0 +1,270 @@
|
||||
# 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`
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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
@@ -0,0 +1,269 @@
|
||||
# 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>`
|
||||
@@ -0,0 +1,689 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,185 @@
|
||||
"""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
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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 []
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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 []
|
||||
@@ -1,31 +1,25 @@
|
||||
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
|
||||
|
||||
This module provides the SuperTonicPipeline class and supporting utilities
|
||||
used by the SuperTonic plugin. It is independent of the legacy
|
||||
abogen.tts_backends module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupertonicSegment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
|
||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
||||
arr = np.asarray(wav, dtype="float32")
|
||||
if arr.ndim == 2:
|
||||
# (n, 1) or (1, n) or (n, channels)
|
||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
||||
arr = arr.reshape(-1)
|
||||
else:
|
||||
@@ -62,7 +56,6 @@ def _split_text(
|
||||
else:
|
||||
parts = [stripped]
|
||||
|
||||
# Enforce max length by hard-splitting long parts.
|
||||
result: list[str] = []
|
||||
for part in parts:
|
||||
if len(part) <= max_chunk_length:
|
||||
@@ -71,7 +64,6 @@ def _split_text(
|
||||
start = 0
|
||||
while start < len(part):
|
||||
end = min(len(part), start + max_chunk_length)
|
||||
# Try to split at whitespace.
|
||||
if end < len(part):
|
||||
ws = part.rfind(" ", start, end)
|
||||
if ws > start + 40:
|
||||
@@ -90,7 +82,6 @@ _UNSUPPORTED_CHARS_RE = re.compile(
|
||||
|
||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||
|
||||
message = " ".join(
|
||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||
) or str(error)
|
||||
@@ -136,16 +127,11 @@ def _configure_supertonic_gpu() -> None:
|
||||
|
||||
available = ort.get_available_providers()
|
||||
|
||||
# Use CUDA if available, skip TensorRT (requires extra libs not always present)
|
||||
# TensorrtExecutionProvider may be listed as available but fail at runtime
|
||||
# if TensorRT libraries (libnvinfer.so) are not installed
|
||||
providers = []
|
||||
if "CUDAExecutionProvider" in available:
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
# Patch supertonic's config and loader before TTS import
|
||||
# We must patch both because loader imports the value at module load time
|
||||
import supertonic.config as supertonic_config
|
||||
import supertonic.loader as supertonic_loader
|
||||
|
||||
@@ -156,6 +142,16 @@ def _configure_supertonic_gpu() -> None:
|
||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||
|
||||
|
||||
class SupertonicSegment:
|
||||
"""A single synthesized audio segment."""
|
||||
|
||||
__slots__ = ("graphemes", "audio")
|
||||
|
||||
def __init__(self, graphemes: str, audio: np.ndarray) -> None:
|
||||
self.graphemes = graphemes
|
||||
self.audio = audio
|
||||
|
||||
|
||||
class SupertonicPipeline:
|
||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||
|
||||
@@ -171,7 +167,6 @@ class SupertonicPipeline:
|
||||
self.total_steps = int(total_steps)
|
||||
self.max_chunk_length = int(max_chunk_length)
|
||||
|
||||
# Configure GPU providers before importing TTS
|
||||
_configure_supertonic_gpu()
|
||||
|
||||
try:
|
||||
@@ -207,7 +202,6 @@ class SupertonicPipeline:
|
||||
removed: set[str] = set()
|
||||
last_exc: Exception | None = None
|
||||
|
||||
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
||||
for attempt in range(3):
|
||||
try:
|
||||
wav, duration = self._tts.synthesize(
|
||||
@@ -231,7 +225,6 @@ class SupertonicPipeline:
|
||||
chunk_to_speak, unsupported
|
||||
).strip()
|
||||
|
||||
# If we didn't change anything, don't loop forever.
|
||||
if sanitized == chunk_to_speak.strip():
|
||||
raise
|
||||
|
||||
@@ -249,7 +242,6 @@ class SupertonicPipeline:
|
||||
sorted(removed),
|
||||
)
|
||||
else:
|
||||
# Exhausted retries.
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
@@ -258,7 +250,6 @@ class SupertonicPipeline:
|
||||
|
||||
audio = _ensure_float32_mono(wav)
|
||||
|
||||
# If duration is present, infer the source sample rate and resample if needed.
|
||||
src_rate = self.sample_rate
|
||||
try:
|
||||
dur = float(duration)
|
||||
+1
-1
@@ -44,7 +44,7 @@ dependencies = [
|
||||
"python-dotenv>=1.0.1",
|
||||
"static_ffmpeg>=2.13",
|
||||
"Markdown>=3.9",
|
||||
"Flask>=3.0.3",
|
||||
"Flask>=3.1.0",
|
||||
"numpy>=1.24.0",
|
||||
"gpustat>=1.1.1",
|
||||
"num2words>=0.5.13",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""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.
|
||||
"""
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""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__
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -0,0 +1,436 @@
|
||||
"""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
|
||||
@@ -0,0 +1,290 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""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
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,244 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Auto-discovery tests for TTS plugins.
|
||||
|
||||
This package contains generic tests that automatically run for every plugin
|
||||
in the plugins/ directory. Tests verify:
|
||||
- Manifest structure
|
||||
- Engine creation and dispose contract
|
||||
- Capability implementation
|
||||
|
||||
Plugin-specific tests remain in tests/test_<plugin>_plugin.py for
|
||||
integration with real dependencies (e.g., KPipeline for Kokoro).
|
||||
"""
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Fixtures for auto-discovery plugin tests.
|
||||
|
||||
This module provides shared fixtures for testing all plugins:
|
||||
- plugin_ids: List of all plugin IDs from plugins/ directory
|
||||
- plugin_dir: Path to a specific plugin directory (parametrized)
|
||||
- loaded_plugin: Loaded plugin data (manifest, model_requirements, create_engine)
|
||||
- host_context: Test HostContext with fake HTTP client
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
|
||||
|
||||
def _discover_plugin_ids() -> list[str]:
|
||||
"""Discover all plugin IDs from the plugins/ directory.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs (directory names with __init__.py).
|
||||
"""
|
||||
plugins_dir = Path(__file__).parent.parent.parent / "plugins"
|
||||
plugin_ids = []
|
||||
if plugins_dir.exists():
|
||||
for item in sorted(plugins_dir.iterdir()):
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
init_file = item / "__init__.py"
|
||||
if init_file.exists():
|
||||
plugin_ids.append(item.name)
|
||||
return plugin_ids
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plugins_dir() -> Path:
|
||||
"""Return the path to the plugins directory."""
|
||||
return Path(__file__).parent.parent.parent / "plugins"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plugin_ids(plugins_dir: Path) -> list[str]:
|
||||
"""Return a list of all plugin IDs from the plugins/ directory.
|
||||
|
||||
This fixture discovers all subdirectories in plugins/ that contain
|
||||
an __init__.py file (i.e., valid plugin directories).
|
||||
|
||||
Returns:
|
||||
List of plugin IDs (directory names).
|
||||
"""
|
||||
return _discover_plugin_ids()
|
||||
|
||||
|
||||
@pytest.fixture(params=_discover_plugin_ids())
|
||||
def plugin_id(request: pytest.FixtureRequest) -> str:
|
||||
"""Parametrized fixture returning each plugin ID.
|
||||
|
||||
This fixture is parametrized to run tests for each plugin.
|
||||
"""
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(plugins_dir: Path, plugin_id: str) -> Path:
|
||||
"""Return the path to a specific plugin directory.
|
||||
|
||||
Args:
|
||||
plugins_dir: Base plugins directory.
|
||||
plugin_id: The plugin ID (directory name).
|
||||
|
||||
Returns:
|
||||
Path to the plugin directory.
|
||||
"""
|
||||
return plugins_dir / plugin_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loaded_plugin(plugin_dir: Path):
|
||||
"""Load a plugin and return its load result.
|
||||
|
||||
This fixture loads the plugin using the loader, providing access to:
|
||||
- manifest: PluginManifest
|
||||
- model_requirements: tuple[ModelManifest, ...]
|
||||
- create_engine: Callable
|
||||
- module: The loaded module
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult from load_plugin_from_dir.
|
||||
|
||||
Raises:
|
||||
pytest.skip: If plugin fails to load (should not happen for valid plugins).
|
||||
"""
|
||||
from abogen.tts_plugin.loader import PluginLoadResult
|
||||
|
||||
result = load_plugin_from_dir(plugin_dir)
|
||||
if not result.success:
|
||||
pytest.fail(
|
||||
f"Plugin {plugin_dir.name} failed to load: "
|
||||
f"{result.error.errors if result.error else 'Unknown error'}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host_context(tmp_path: Path) -> HostContext:
|
||||
"""Create a test HostContext with fake HTTP client.
|
||||
|
||||
Args:
|
||||
tmp_path: Pytest tmp_path fixture for config directory.
|
||||
|
||||
Returns:
|
||||
HostContext with fake HTTP client and test logger.
|
||||
"""
|
||||
class FakeHttpClient:
|
||||
"""Fake HTTP client for testing."""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
"""Fake GET request."""
|
||||
return None
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
"""Fake POST request."""
|
||||
return None
|
||||
|
||||
return HostContext(
|
||||
config_dir=tmp_path,
|
||||
logger=logging.getLogger("test"),
|
||||
http_client=FakeHttpClient(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine_config() -> Any:
|
||||
"""Return a default EngineConfig for testing.
|
||||
|
||||
Returns:
|
||||
EngineConfig with device="cpu" for testing.
|
||||
"""
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
return EngineConfig(device="cpu")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_engine(loaded_plugin, host_context, engine_config):
|
||||
"""Create an engine instance from a loaded plugin.
|
||||
|
||||
This fixture creates an engine and ensures proper cleanup via dispose.
|
||||
|
||||
Args:
|
||||
loaded_plugin: Loaded plugin result.
|
||||
host_context: Test HostContext.
|
||||
engine_config: EngineConfig for engine creation.
|
||||
|
||||
Yields:
|
||||
Engine instance.
|
||||
"""
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
|
||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
||||
assert isinstance(engine, Engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Fake plugin for testing the plugin loader.
|
||||
|
||||
This is a minimal valid plugin that satisfies the Plugin API contract.
|
||||
It does NOT perform any real TTS synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import EngineError
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
PluginManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Minimal EngineSession implementation for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
if self._disposed:
|
||||
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 FakeEngine:
|
||||
"""Minimal Engine implementation for testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._disposed = False
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
if self._disposed:
|
||||
raise EngineError("Engine disposed")
|
||||
return FakeSession()
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
|
||||
|
||||
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(
|
||||
voiceSources=(
|
||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
||||
),
|
||||
audioFormats=(
|
||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create a fake engine instance."""
|
||||
return FakeEngine()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Invalid plugin: raises ImportError during import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This plugin intentionally raises an ImportError
|
||||
raise ImportError("Simulated import error for testing")
|
||||
|
||||
# The following code will never be reached, but is here for documentation
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="import_error",
|
||||
name="Import Error Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin that fails to import",
|
||||
author="Test Author",
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Invalid plugin: incompatible api_version (major version mismatch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
# api_version "2.0" has major version 2, but host expects major version 1
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="invalid_api_version",
|
||||
name="Invalid API Version Plugin",
|
||||
version="1.0.0",
|
||||
api_version="2.0", # Major version mismatch!
|
||||
description="Plugin with incompatible api_version",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Invalid plugin: unknown capabilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="invalid_capabilities",
|
||||
name="Invalid Capabilities Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin with unknown capabilities",
|
||||
author="Test Author",
|
||||
capabilities=("voice_list", "unknown_capability", "another_unknown"),
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list[Any] = []
|
||||
|
||||
|
||||
def create_engine(
|
||||
context: Any,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Any:
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Invalid plugin: missing create_engine function."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
|
||||
PLUGIN_MANIFEST = PluginManifest(
|
||||
id="missing_create_engine",
|
||||
name="Missing Create Engine Plugin",
|
||||
version="1.0.0",
|
||||
api_version="1.0",
|
||||
description="Plugin missing create_engine",
|
||||
author="Test Author",
|
||||
)
|
||||
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
# This plugin intentionally does NOT export create_engine
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Invalid plugin: missing PLUGIN_MANIFEST."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This plugin intentionally does NOT export PLUGIN_MANIFEST
|
||||
MODEL_REQUIREMENTS: list = []
|
||||
|
||||
|
||||
def create_engine(context, model_path, config):
|
||||
raise NotImplementedError("This plugin is invalid")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user