mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd2c109d8 |
@@ -1,15 +0,0 @@
|
|||||||
*.py text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
*.toml text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
*.txt text eol=lf
|
|
||||||
*.html text eol=lf
|
|
||||||
*.css text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.cfg text eol=lf
|
|
||||||
*.ini text eol=lf
|
|
||||||
*.svg text eol=lf
|
|
||||||
*.j2 text eol=lf
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
name: CI
|
name: pip install
|
||||||
run-name: CI
|
run-name: pip install
|
||||||
|
on:
|
||||||
on:
|
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- '**.py'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
@@ -13,41 +11,23 @@ on:
|
|||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
install-and-run:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-14, windows-latest]
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
python-version: ['3.12']
|
python-version: ['3.12']
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
continue-on-error: true
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install from repository
|
||||||
- name: Install uv
|
run: python -m pip install .
|
||||||
uses: astral-sh/setup-uv@v8.3.1
|
#- name: Run abogen
|
||||||
with:
|
# run: abogen
|
||||||
enable-cache: true
|
|
||||||
prune-cache: false
|
|
||||||
cache-dependency-glob: pyproject.toml
|
|
||||||
|
|
||||||
- name: Install system dependencies (Ubuntu)
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libegl1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv pip install --system .[dev]
|
|
||||||
env:
|
|
||||||
UV_LINK_MODE: copy
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
QT_QPA_PLATFORM: offscreen
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to Github Container Registry
|
- name: Login to Github Container Registry
|
||||||
# Only if we need to push an image
|
# Only if we need to push an image
|
||||||
|
|||||||
+30
-30
@@ -1,31 +1,31 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
viewBox="0 0 512 512" xml:space="preserve">
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.st0{fill:#808080;}
|
.st0{fill:#808080;}
|
||||||
</style>
|
</style>
|
||||||
<g>
|
<g>
|
||||||
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
|
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
|
||||||
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
|
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
|
||||||
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
|
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
|
||||||
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
|
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
|
||||||
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
|
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
|
||||||
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
|
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
|
||||||
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
|
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
|
||||||
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
|
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
|
||||||
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
|
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
|
||||||
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
|
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
|
||||||
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
|
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
|
||||||
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
|
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
|
||||||
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
|
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
|
||||||
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
|
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
|
||||||
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
|
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
|
||||||
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
|
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
|
||||||
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
|
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
|
||||||
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
|
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
|
||||||
C339.815,267.771,320.972,313.262,281.292,329.698z"/>
|
C339.815,267.771,320.972,313.262,281.292,329.698z"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -63,6 +63,64 @@ SUPPORTED_INPUT_FORMATS = [
|
|||||||
# 384 if self.lang_code in 'ab':
|
# 384 if self.lang_code in 'ab':
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||||
|
|
||||||
|
# Voice and sample text constants
|
||||||
|
VOICES_INTERNAL = [
|
||||||
|
"af_alloy",
|
||||||
|
"af_aoede",
|
||||||
|
"af_bella",
|
||||||
|
"af_heart",
|
||||||
|
"af_jessica",
|
||||||
|
"af_kore",
|
||||||
|
"af_nicole",
|
||||||
|
"af_nova",
|
||||||
|
"af_river",
|
||||||
|
"af_sarah",
|
||||||
|
"af_sky",
|
||||||
|
"am_adam",
|
||||||
|
"am_echo",
|
||||||
|
"am_eric",
|
||||||
|
"am_fenrir",
|
||||||
|
"am_liam",
|
||||||
|
"am_michael",
|
||||||
|
"am_onyx",
|
||||||
|
"am_puck",
|
||||||
|
"am_santa",
|
||||||
|
"bf_alice",
|
||||||
|
"bf_emma",
|
||||||
|
"bf_isabella",
|
||||||
|
"bf_lily",
|
||||||
|
"bm_daniel",
|
||||||
|
"bm_fable",
|
||||||
|
"bm_george",
|
||||||
|
"bm_lewis",
|
||||||
|
"ef_dora",
|
||||||
|
"em_alex",
|
||||||
|
"em_santa",
|
||||||
|
"ff_siwis",
|
||||||
|
"hf_alpha",
|
||||||
|
"hf_beta",
|
||||||
|
"hm_omega",
|
||||||
|
"hm_psi",
|
||||||
|
"if_sara",
|
||||||
|
"im_nicola",
|
||||||
|
"jf_alpha",
|
||||||
|
"jf_gongitsune",
|
||||||
|
"jf_nezumi",
|
||||||
|
"jf_tebukuro",
|
||||||
|
"jm_kumo",
|
||||||
|
"pf_dora",
|
||||||
|
"pm_alex",
|
||||||
|
"pm_santa",
|
||||||
|
"zf_xiaobei",
|
||||||
|
"zf_xiaoni",
|
||||||
|
"zf_xiaoxiao",
|
||||||
|
"zf_xiaoyi",
|
||||||
|
"zm_yunjian",
|
||||||
|
"zm_yunxi",
|
||||||
|
"zm_yunxia",
|
||||||
|
"zm_yunyang",
|
||||||
|
]
|
||||||
|
|
||||||
# Voice and sample text mapping
|
# Voice and sample text mapping
|
||||||
SAMPLE_VOICE_TEXTS = {
|
SAMPLE_VOICE_TEXTS = {
|
||||||
"a": "This is a sample of the selected voice.",
|
"a": "This is a sample of the selected voice.",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Abogen Flet Frontend Package.
|
||||||
|
|
||||||
|
This package provides a unified, dual-target (desktop + web) user interface
|
||||||
|
for the Abogen audiobook generation application, built with the Flet framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Components sub-package."""
|
||||||
|
from .widgets import (
|
||||||
|
resolve_icon,
|
||||||
|
build_drop_zone,
|
||||||
|
build_log_terminal,
|
||||||
|
log_entry,
|
||||||
|
build_progress_row,
|
||||||
|
build_primary_button,
|
||||||
|
build_secondary_button,
|
||||||
|
build_card,
|
||||||
|
build_section_header,
|
||||||
|
build_status_badge,
|
||||||
|
labelled_row,
|
||||||
|
show_snack,
|
||||||
|
build_divider,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_drop_zone",
|
||||||
|
"resolve_icon",
|
||||||
|
"build_log_terminal",
|
||||||
|
"log_entry",
|
||||||
|
"build_progress_row",
|
||||||
|
"build_primary_button",
|
||||||
|
"build_secondary_button",
|
||||||
|
"build_card",
|
||||||
|
"build_section_header",
|
||||||
|
"build_status_badge",
|
||||||
|
"labelled_row",
|
||||||
|
"show_snack",
|
||||||
|
"build_divider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"""
|
||||||
|
Reusable UI components for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
Each function in this module returns a standalone Flet control or small
|
||||||
|
widget tree. Components read the current palette from the page's theme
|
||||||
|
mode and should not hold any mutable state themselves – state lives in the
|
||||||
|
session's ``AppState`` object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, List, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_icon(icon: Any) -> Any:
|
||||||
|
"""Convert a snake_case icon name to Flet IconData when possible."""
|
||||||
|
if isinstance(icon, str):
|
||||||
|
return getattr(ft.Icons, icon.upper(), icon)
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Drop-zone (file input area)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_drop_zone(
|
||||||
|
*,
|
||||||
|
on_pick: Callable[[], None],
|
||||||
|
label: str = "Drag & drop your file here or click to browse",
|
||||||
|
sub_label: str = "Supports: .txt · .epub · .pdf · .md · .srt · .ass · .vtt",
|
||||||
|
accent: bool = False,
|
||||||
|
error: bool = False,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
file_size: Optional[str] = None,
|
||||||
|
char_count: Optional[str] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.GestureDetector:
|
||||||
|
"""
|
||||||
|
Build an interactive file drop-zone widget.
|
||||||
|
|
||||||
|
The zone shows a dashed border and centred instructions by default,
|
||||||
|
switching to an 'active' green style when a file is loaded and a red
|
||||||
|
style when an error has occurred.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_pick: Callback invoked when the user clicks or activates the zone.
|
||||||
|
label: Primary instruction text.
|
||||||
|
sub_label: Secondary hint text shown beneath the label.
|
||||||
|
accent: When True, renders the 'active/success' green style.
|
||||||
|
error: When True, renders the 'error/red' style.
|
||||||
|
filename: When provided, replaces the instruction text with file info.
|
||||||
|
file_size: Human-readable file size to display alongside the filename.
|
||||||
|
char_count: Character count to display alongside file info.
|
||||||
|
page: The current Flet ``Page``; used to derive the active palette.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.GestureDetector`` wrapping the visual drop-zone container.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
p = get_palette(page) if page else None
|
||||||
|
|
||||||
|
# Colour scheme
|
||||||
|
if error:
|
||||||
|
border_color = "#e84e3c" if dark else "#c0392b"
|
||||||
|
bg_color = "#1a0a08" if dark else "#fff5f5"
|
||||||
|
text_color = "#e84e3c" if dark else "#c0392b"
|
||||||
|
icon_name = "error_outline"
|
||||||
|
elif accent:
|
||||||
|
border_color = "#42ad4a" if dark else "#2e9437"
|
||||||
|
bg_color = "#091810" if dark else "#f0fff1"
|
||||||
|
text_color = "#42ad4a" if dark else "#2e9437"
|
||||||
|
icon_name = "check_circle_outline"
|
||||||
|
else:
|
||||||
|
border_color = "#3a4466" if dark else "#a8b4d0"
|
||||||
|
bg_color = "#151928" if dark else "#f7f8fd"
|
||||||
|
text_color = "#9ba3b8" if dark else "#5a6172"
|
||||||
|
icon_name = "upload_file"
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
# Compact file-info display
|
||||||
|
info_rows: List[ft.Control] = [
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
ft.Icon(resolve_icon("insert_drive_file"), color=text_color, size=28),
|
||||||
|
ft.Column(
|
||||||
|
[
|
||||||
|
ft.Text(
|
||||||
|
filename,
|
||||||
|
weight=ft.FontWeight.W_600,
|
||||||
|
size=13,
|
||||||
|
color=text_color,
|
||||||
|
no_wrap=False,
|
||||||
|
max_lines=2,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tight=True,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if file_size or char_count:
|
||||||
|
chips: List[ft.Control] = []
|
||||||
|
if file_size:
|
||||||
|
chips.append(
|
||||||
|
ft.Text(f"📄 {file_size}", size=11, color=text_color, italic=True)
|
||||||
|
)
|
||||||
|
if char_count:
|
||||||
|
chips.append(
|
||||||
|
ft.Text(f"🔤 {char_count} chars", size=11, color=text_color, italic=True)
|
||||||
|
)
|
||||||
|
info_rows.append(
|
||||||
|
ft.Row(chips, alignment=ft.MainAxisAlignment.CENTER, spacing=SPACE_MD)
|
||||||
|
)
|
||||||
|
content = ft.Column(
|
||||||
|
info_rows,
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = ft.Column(
|
||||||
|
[
|
||||||
|
ft.Icon(resolve_icon(icon_name), size=48, color=border_color, opacity=0.8),
|
||||||
|
ft.Text(
|
||||||
|
label,
|
||||||
|
size=14,
|
||||||
|
weight=ft.FontWeight.W_500,
|
||||||
|
color=text_color,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
sub_label,
|
||||||
|
size=11,
|
||||||
|
color=text_color,
|
||||||
|
opacity=0.6,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
inner = ft.Container(
|
||||||
|
content=content,
|
||||||
|
border=ft.Border.all(2, border_color),
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
bgcolor=bg_color,
|
||||||
|
padding=ft.Padding.all(SPACE_LG),
|
||||||
|
height=160,
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.GestureDetector(
|
||||||
|
content=ft.Row([inner], spacing=0),
|
||||||
|
on_tap=lambda _: on_pick(),
|
||||||
|
mouse_cursor=ft.MouseCursor.CLICK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log terminal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_terminal(
|
||||||
|
*,
|
||||||
|
ref: Optional[ft.Ref] = None,
|
||||||
|
max_height: int = 260,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Container:
|
||||||
|
"""
|
||||||
|
Build a scrollable, read-only log terminal widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ref: Optional ``ft.Ref[ft.ListView]`` to bind the inner list-view so
|
||||||
|
callers can append entries programmatically.
|
||||||
|
max_height: Maximum pixel height before vertical scrolling activates.
|
||||||
|
page: Current Flet ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.Container`` wrapping a ``ft.ListView``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#0d1117" if dark else "#f8f9fc"
|
||||||
|
text_color = "#b0b8cc" if dark else "#3d4358"
|
||||||
|
border_color = "#252a38" if dark else "#dce0ea"
|
||||||
|
|
||||||
|
list_view = ft.ListView(
|
||||||
|
expand=True,
|
||||||
|
auto_scroll=True,
|
||||||
|
spacing=1,
|
||||||
|
padding=ft.Padding.all(SPACE_SM),
|
||||||
|
)
|
||||||
|
if ref is not None:
|
||||||
|
ref.current = list_view
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=list_view,
|
||||||
|
bgcolor=bg,
|
||||||
|
border=ft.Border.all(1, border_color),
|
||||||
|
border_radius=RADIUS_SM,
|
||||||
|
height=max_height,
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_entry(message: str, level: str = "info", page: Optional[ft.Page] = None) -> ft.Text:
|
||||||
|
"""
|
||||||
|
Create a single log-line ``ft.Text`` widget with appropriate colour coding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The log message string.
|
||||||
|
level: Severity string: ``'info'``, ``'success'``, ``'error'``,
|
||||||
|
``'warning'``, ``'debug'``, ``'critical'``.
|
||||||
|
page: Current Flet ``Page`` for dark/light mode detection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.Text`` control.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
palette: dict[str, str] = {
|
||||||
|
"info": "#9ba3b8" if dark else "#5a6172",
|
||||||
|
"success": "#42ad4a" if dark else "#2e9437",
|
||||||
|
"error": "#e84e3c" if dark else "#c0392b",
|
||||||
|
"warning": "#f5a623" if dark else "#d4870a",
|
||||||
|
"debug": "#5a6172" if dark else "#9ba3b8",
|
||||||
|
"critical": "#ff5722",
|
||||||
|
"trace": "#4e5568" if dark else "#b0b8cc",
|
||||||
|
}
|
||||||
|
color = palette.get(level.lower(), palette["info"])
|
||||||
|
return ft.Text(message, size=12, color=color, selectable=True, no_wrap=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Progress row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_progress_row(
|
||||||
|
*,
|
||||||
|
progress_value: float = 0.0,
|
||||||
|
etr_text: str = "",
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Column:
|
||||||
|
"""
|
||||||
|
Build a progress-bar + ETR-label column.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_value: Float in [0.0, 1.0].
|
||||||
|
etr_text: Pre-formatted estimated-time-remaining string.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Column`` containing the progress bar and label.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
fill = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
bg = "#1e2230" if dark else "#e4e8f0"
|
||||||
|
|
||||||
|
bar = ft.ProgressBar(
|
||||||
|
value=progress_value,
|
||||||
|
color=fill,
|
||||||
|
bgcolor=bg,
|
||||||
|
height=8,
|
||||||
|
border_radius=ft.BorderRadius.all(4),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
label = ft.Text(
|
||||||
|
etr_text,
|
||||||
|
size=11,
|
||||||
|
color="#9ba3b8" if dark else "#5a6172",
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
)
|
||||||
|
return ft.Column(
|
||||||
|
[bar, label],
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Primary action button
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_primary_button(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
on_click: Optional[Callable] = None,
|
||||||
|
disabled: bool = False,
|
||||||
|
width: Optional[int] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.ElevatedButton:
|
||||||
|
"""
|
||||||
|
Build a prominent, styled primary action button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Button label.
|
||||||
|
icon: Optional Flet icon name (e.g. ``'play_arrow'``).
|
||||||
|
on_click: Click callback.
|
||||||
|
disabled: Whether the button is non-interactive.
|
||||||
|
width: Optional fixed pixel width.
|
||||||
|
page: Current ``Page`` for accent colour derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.ElevatedButton``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
on_bg = "#ffffff"
|
||||||
|
|
||||||
|
style = ft.ButtonStyle(
|
||||||
|
bgcolor={
|
||||||
|
ft.ControlState.DEFAULT: bg,
|
||||||
|
ft.ControlState.HOVERED: "#3a5fc4" if dark else "#2a4fae",
|
||||||
|
ft.ControlState.DISABLED: "#2a2f3f" if dark else "#c0c8d8",
|
||||||
|
},
|
||||||
|
color={
|
||||||
|
ft.ControlState.DEFAULT: on_bg,
|
||||||
|
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
|
||||||
|
},
|
||||||
|
elevation={"default": 2, "hovered": 4},
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
|
||||||
|
animation_duration=150,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.ElevatedButton(
|
||||||
|
content=text,
|
||||||
|
icon=resolve_icon(icon),
|
||||||
|
on_click=on_click,
|
||||||
|
disabled=disabled,
|
||||||
|
width=width,
|
||||||
|
style=style,
|
||||||
|
height=48,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secondary / ghost button
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_secondary_button(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
on_click: Optional[Callable] = None,
|
||||||
|
disabled: bool = False,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.OutlinedButton:
|
||||||
|
"""
|
||||||
|
Build a secondary outlined button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Button label.
|
||||||
|
icon: Optional Flet icon name.
|
||||||
|
on_click: Click callback.
|
||||||
|
disabled: Whether the button is non-interactive.
|
||||||
|
page: Current ``Page`` for border colour derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.OutlinedButton``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
border_clr = "#3a4466" if dark else "#a8b4d0"
|
||||||
|
text_clr = "#e8eaf0" if dark else "#1a1d27"
|
||||||
|
|
||||||
|
style = ft.ButtonStyle(
|
||||||
|
side={
|
||||||
|
ft.ControlState.DEFAULT: ft.BorderSide(1.5, border_clr),
|
||||||
|
ft.ControlState.HOVERED: ft.BorderSide(1.5, "#5b8af5" if dark else "#3a5fc4"),
|
||||||
|
},
|
||||||
|
color={
|
||||||
|
ft.ControlState.DEFAULT: text_clr,
|
||||||
|
ft.ControlState.HOVERED: "#5b8af5" if dark else "#3a5fc4",
|
||||||
|
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
|
||||||
|
},
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
|
||||||
|
animation_duration=150,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.OutlinedButton(
|
||||||
|
content=text,
|
||||||
|
icon=resolve_icon(icon),
|
||||||
|
on_click=on_click,
|
||||||
|
disabled=disabled,
|
||||||
|
style=style,
|
||||||
|
height=44,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_card(
|
||||||
|
content: ft.Control,
|
||||||
|
*,
|
||||||
|
padding: int = SPACE_LG,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Container:
|
||||||
|
"""
|
||||||
|
Wrap a control in a styled card container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The child control to embed.
|
||||||
|
padding: Internal padding in pixels.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.Container``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#181b23" if dark else "#ffffff"
|
||||||
|
border_clr = "#2c3147" if dark else "#dce0ea"
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=content,
|
||||||
|
bgcolor=bg,
|
||||||
|
border=ft.Border.all(1, border_clr),
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
padding=ft.Padding.all(padding),
|
||||||
|
shadow=ft.BoxShadow(
|
||||||
|
spread_radius=0,
|
||||||
|
blur_radius=12,
|
||||||
|
color=ft.Colors.with_opacity(0.12 if dark else 0.06, ft.Colors.BLACK),
|
||||||
|
offset=ft.Offset(0, 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section header
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_section_header(
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
subtitle: Optional[str] = None,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Row:
|
||||||
|
"""
|
||||||
|
Build a consistent section header row with an optional icon.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Section heading text.
|
||||||
|
subtitle: Optional explanatory sub-text.
|
||||||
|
icon: Optional Flet icon name.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Row`` containing the icon and text column.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
title_color = "#e8eaf0" if dark else "#1a1d27"
|
||||||
|
sub_color = "#9ba3b8" if dark else "#5a6172"
|
||||||
|
accent = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
|
||||||
|
children: List[ft.Control] = []
|
||||||
|
if icon:
|
||||||
|
children.append(ft.Icon(resolve_icon(icon), size=20, color=accent))
|
||||||
|
|
||||||
|
text_parts: List[ft.Control] = [
|
||||||
|
ft.Text(title, size=15, weight=ft.FontWeight.W_600, color=title_color)
|
||||||
|
]
|
||||||
|
if subtitle:
|
||||||
|
text_parts.append(ft.Text(subtitle, size=11, color=sub_color))
|
||||||
|
|
||||||
|
children.append(
|
||||||
|
ft.Column(text_parts, spacing=1, tight=True, expand=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Row(children, spacing=SPACE_SM, vertical_alignment=ft.CrossAxisAlignment.START)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status badge
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_badge(
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
variant: str = "info",
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Container:
|
||||||
|
"""
|
||||||
|
Build a small status badge chip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Badge text.
|
||||||
|
variant: Colour variant: ``'info'``, ``'success'``, ``'error'``,
|
||||||
|
``'warning'``, ``'neutral'``.
|
||||||
|
page: Current ``Page`` for theme derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A pill-shaped ``ft.Container``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
palette = {
|
||||||
|
"info": ("#1a2a5e" if dark else "#dde8ff", "#5b8af5" if dark else "#3a5fc4"),
|
||||||
|
"success": ("#0d2010" if dark else "#d4f4d7", "#42ad4a" if dark else "#2e9437"),
|
||||||
|
"error": ("#2a0a08" if dark else "#ffe0dc", "#e84e3c" if dark else "#c0392b"),
|
||||||
|
"warning": ("#2a1a00" if dark else "#fff4d8", "#f5a623" if dark else "#d4870a"),
|
||||||
|
"neutral": ("#1e2230" if dark else "#edf0f5", "#9ba3b8" if dark else "#5a6172"),
|
||||||
|
}
|
||||||
|
bg, fg = palette.get(variant, palette["info"])
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Text(label, size=10, weight=ft.FontWeight.W_600, color=fg),
|
||||||
|
bgcolor=bg,
|
||||||
|
border_radius=999,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=8, vertical=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Labelled control row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def labelled_row(
|
||||||
|
label: str,
|
||||||
|
control: ft.Control,
|
||||||
|
*,
|
||||||
|
label_width: int = 200,
|
||||||
|
tooltip: Optional[str] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Row:
|
||||||
|
"""
|
||||||
|
Lay a label and a control side-by-side in a consistent row.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Human-readable label text.
|
||||||
|
control: The UI control placed to the right of the label.
|
||||||
|
label_width: Fixed pixel width of the label column.
|
||||||
|
tooltip: Optional tooltip text on the label.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Row`` with the label pinned to a fixed width.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
lbl_color = "#9ba3b8" if dark else "#5a6172"
|
||||||
|
|
||||||
|
lbl = ft.Text(label, size=13, color=lbl_color, weight=ft.FontWeight.W_500, width=label_width)
|
||||||
|
if tooltip:
|
||||||
|
lbl.tooltip = tooltip
|
||||||
|
|
||||||
|
return ft.Row(
|
||||||
|
[lbl, ft.Container(content=control, expand=True)],
|
||||||
|
alignment=ft.MainAxisAlignment.START,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_MD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snack-bar helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_snack(
|
||||||
|
page: ft.Page,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
error: bool = False,
|
||||||
|
duration: int = 3000,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Display a brief snack-bar notification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: The Flet ``Page`` instance.
|
||||||
|
message: Text to display.
|
||||||
|
error: When True, colours the bar red instead of the default accent.
|
||||||
|
duration: Visible duration in milliseconds.
|
||||||
|
"""
|
||||||
|
dark = page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#e84e3c" if error else ("#5b8af5" if dark else "#3a5fc4")
|
||||||
|
page.snack_bar = ft.SnackBar(
|
||||||
|
content=ft.Text(message, color="#ffffff", size=13),
|
||||||
|
bgcolor=bg,
|
||||||
|
duration=duration,
|
||||||
|
show_close_icon=True,
|
||||||
|
close_icon_color="#ffffff",
|
||||||
|
)
|
||||||
|
page.snack_bar.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Divider helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_divider(page: Optional[ft.Page] = None) -> ft.Divider:
|
||||||
|
"""
|
||||||
|
Build a styled horizontal rule divider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Divider``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
return ft.Divider(color="#252a38" if dark else "#e8ebf2", height=1, thickness=1)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
Abogen Flet Frontend – main entry point.
|
||||||
|
|
||||||
|
Run as desktop app:
|
||||||
|
python -m abogen.frontend.main
|
||||||
|
|
||||||
|
Run as web app (binds to port 8080 by default):
|
||||||
|
python -m abogen.frontend.main --web --port 8080
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
------------
|
||||||
|
One ``ft.app()`` call launches the server. For every new browser tab (or the
|
||||||
|
desktop window) Flet invokes ``_app_entry(page)`` in its own coroutine, which
|
||||||
|
creates a fresh ``AppState`` and wires together the navigation rail and views.
|
||||||
|
This guarantees complete per-session isolation in multi-user web deployments.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from .state import AppState
|
||||||
|
from .components import resolve_icon
|
||||||
|
from .views.dashboard import DashboardView
|
||||||
|
from .views.settings import SettingsView
|
||||||
|
from .views.queue_view import QueueView
|
||||||
|
from .utils.theme import make_theme, DARK, LIGHT, SPACE_SM, SPACE_MD, SPACE_LG, RADIUS_MD
|
||||||
|
from abogen.constants import PROGRAM_NAME as APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Navigation destinations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_NAV_ITEMS = [
|
||||||
|
("Convert", "swap_horiz", "swap_horiz"),
|
||||||
|
("Queue", "list_alt", "list_alt"),
|
||||||
|
("Settings", "settings", "settings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sidebar_item(
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
icon: str,
|
||||||
|
selected: bool,
|
||||||
|
palette,
|
||||||
|
on_click,
|
||||||
|
) -> ft.Container:
|
||||||
|
accent = palette.accent if selected else palette.text_secondary
|
||||||
|
bg = palette.sidebar_selected_bg if selected else palette.sidebar_bg
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Icon(resolve_icon(icon), size=20, color=accent),
|
||||||
|
ft.Text(
|
||||||
|
label,
|
||||||
|
size=13,
|
||||||
|
weight=ft.FontWeight.W_600 if selected else ft.FontWeight.W_500,
|
||||||
|
color=accent,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=SPACE_MD,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
bgcolor=bg,
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=10),
|
||||||
|
ink=True,
|
||||||
|
on_click=on_click,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-session entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _app_entry(page: ft.Page) -> None:
|
||||||
|
try:
|
||||||
|
# ── State ────────────────────────────────────────────────────────────
|
||||||
|
state = AppState()
|
||||||
|
state.load_from_config()
|
||||||
|
|
||||||
|
# ── Page basics ──────────────────────────────────────────────────────
|
||||||
|
page.title = APP_NAME
|
||||||
|
page.padding = 0
|
||||||
|
page.spacing = 0
|
||||||
|
page.bgcolor = DARK.bg_base
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
page.theme = make_theme(dark=True)
|
||||||
|
page.dark_theme = make_theme(dark=True)
|
||||||
|
page.fonts = {}
|
||||||
|
page.window.min_width = 520
|
||||||
|
page.window.min_height = 600
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
# ── Content area ref ─────────────────────────────────────────────────
|
||||||
|
content_area = ft.Column(expand=True, spacing=0)
|
||||||
|
sidebar_body = ft.Column(spacing=SPACE_SM)
|
||||||
|
theme_button_host = ft.Container()
|
||||||
|
brand_title = ft.Text(
|
||||||
|
APP_NAME,
|
||||||
|
size=18,
|
||||||
|
weight=ft.FontWeight.W_700,
|
||||||
|
color=DARK.text_primary,
|
||||||
|
)
|
||||||
|
brand_fallback_icon = ft.Icon(resolve_icon("speaker_notes"), size=32, color=DARK.accent)
|
||||||
|
divider = ft.VerticalDivider(width=1, color=DARK.border)
|
||||||
|
|
||||||
|
# ── Views ────────────────────────────────────────────────────────────
|
||||||
|
dashboard_view = DashboardView(page, state)
|
||||||
|
settings_view = SettingsView(page, state)
|
||||||
|
queue_view = QueueView(page, state)
|
||||||
|
|
||||||
|
views = [
|
||||||
|
dashboard_view.build,
|
||||||
|
queue_view.build,
|
||||||
|
settings_view.build,
|
||||||
|
]
|
||||||
|
_selected_index = [0]
|
||||||
|
|
||||||
|
def _refresh_sidebar() -> None:
|
||||||
|
dark = page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
pal = DARK if dark else LIGHT
|
||||||
|
sidebar_body.controls = [
|
||||||
|
_build_sidebar_item(
|
||||||
|
label=label,
|
||||||
|
icon=icon,
|
||||||
|
selected=index == _selected_index[0],
|
||||||
|
palette=pal,
|
||||||
|
on_click=lambda _, i=index: _navigate(i),
|
||||||
|
)
|
||||||
|
for index, (label, icon, _) in enumerate(_NAV_ITEMS)
|
||||||
|
]
|
||||||
|
sidebar.bgcolor = pal.sidebar_bg
|
||||||
|
divider.color = pal.border
|
||||||
|
brand_title.color = pal.text_primary
|
||||||
|
brand_fallback_icon.color = pal.accent
|
||||||
|
theme_button_host.content = ft.Container(
|
||||||
|
content=ft.Icon(
|
||||||
|
resolve_icon("dark_mode" if dark else "light_mode"),
|
||||||
|
size=20,
|
||||||
|
color=pal.text_secondary,
|
||||||
|
),
|
||||||
|
tooltip="Toggle theme",
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
padding=8,
|
||||||
|
ink=True,
|
||||||
|
on_click=lambda _: _toggle_theme(page, _refresh_sidebar),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _navigate(index: int) -> None:
|
||||||
|
_selected_index[0] = index
|
||||||
|
content_area.controls.clear()
|
||||||
|
built = views[index]()
|
||||||
|
content_area.controls.append(
|
||||||
|
ft.Container(
|
||||||
|
content=built,
|
||||||
|
expand=True,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_LG),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_refresh_sidebar()
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
# ── Sidebar ──────────────────────────────────────────────────────────
|
||||||
|
pal = DARK
|
||||||
|
sidebar = ft.Container(
|
||||||
|
width=220,
|
||||||
|
bgcolor=pal.sidebar_bg,
|
||||||
|
padding=ft.Padding.all(SPACE_MD),
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Image(
|
||||||
|
src="icon.png",
|
||||||
|
width=36,
|
||||||
|
height=36,
|
||||||
|
fit=ft.BoxFit.CONTAIN,
|
||||||
|
error_content=brand_fallback_icon,
|
||||||
|
),
|
||||||
|
brand_title,
|
||||||
|
],
|
||||||
|
spacing=SPACE_MD,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
padding=ft.Padding.only(top=SPACE_SM, bottom=SPACE_LG),
|
||||||
|
),
|
||||||
|
sidebar_body,
|
||||||
|
ft.Container(expand=True),
|
||||||
|
ft.Row([theme_button_host], alignment=ft.MainAxisAlignment.END),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_refresh_sidebar()
|
||||||
|
|
||||||
|
# ── Page handle for pubsub (queue → dashboard) ───────────────────────
|
||||||
|
def _handle_pubsub(topic: str) -> None:
|
||||||
|
if topic == "start_queue":
|
||||||
|
_navigate(0)
|
||||||
|
|
||||||
|
page.pubsub.subscribe(_handle_pubsub)
|
||||||
|
|
||||||
|
# ── Layout ────────────────────────────────────────────────────────────
|
||||||
|
page.add(
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
sidebar,
|
||||||
|
divider,
|
||||||
|
ft.Container(content=content_area, expand=True),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show dashboard by default
|
||||||
|
_navigate(0)
|
||||||
|
page.update()
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"ERROR IN _app_entry: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_theme(page: ft.Page, refresh_sidebar) -> None:
|
||||||
|
"""Switch between dark and light theme modes."""
|
||||||
|
if page.theme_mode == ft.ThemeMode.DARK:
|
||||||
|
page.theme_mode = ft.ThemeMode.LIGHT
|
||||||
|
page.bgcolor = LIGHT.bg_base
|
||||||
|
else:
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
page.bgcolor = DARK.bg_base
|
||||||
|
|
||||||
|
page.theme = make_theme(page.theme_mode == ft.ThemeMode.DARK)
|
||||||
|
refresh_sidebar()
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI helpers & entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _is_port_free(host: str, port: int) -> bool:
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((host, port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port(host: str, start_port: int) -> int:
|
||||||
|
import socket
|
||||||
|
port = start_port
|
||||||
|
while port < 65535:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((host, port))
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
port += 1
|
||||||
|
return start_port
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""
|
||||||
|
Start the Abogen Flet frontend.
|
||||||
|
|
||||||
|
Parses ``--web`` and ``--port`` CLI arguments to choose desktop vs. web
|
||||||
|
mode, then hands control to ``ft.app()``.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logging.getLogger("flet").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=f"{APP_NAME} – Flet frontend")
|
||||||
|
parser.add_argument(
|
||||||
|
"--web", action="store_true",
|
||||||
|
help="Run as a web server instead of a desktop window.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=8080,
|
||||||
|
help="Port for the web server (default: 8080). Ignored in desktop mode.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", default="127.0.0.1",
|
||||||
|
help="Host for the web server (default: 127.0.0.1). Use 0.0.0.0 to expose publicly.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.web:
|
||||||
|
port_specified = "--port" in sys.argv
|
||||||
|
target_port = args.port
|
||||||
|
|
||||||
|
if not port_specified:
|
||||||
|
target_port = _find_free_port(args.host, 8080)
|
||||||
|
if target_port != 8080:
|
||||||
|
print(f"Port 8080 is in use. Automatically routed to free port: {target_port}")
|
||||||
|
else:
|
||||||
|
if not _is_port_free(args.host, target_port):
|
||||||
|
print(f"Error: Port {target_port} is already in use on {args.host}.", file=sys.stderr)
|
||||||
|
print("Please select a different port or omit the --port flag to find one automatically.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Starting Abogen WebUI on http://{args.host}:{target_port} ...")
|
||||||
|
ft.app(
|
||||||
|
target=_app_entry,
|
||||||
|
view=ft.AppView.WEB_BROWSER,
|
||||||
|
port=target_port,
|
||||||
|
host=args.host,
|
||||||
|
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||||
|
no_cdn=True,
|
||||||
|
web_renderer="canvaskit",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ft.app(
|
||||||
|
target=_app_entry,
|
||||||
|
view=ft.AppView.FLET_APP,
|
||||||
|
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to launch native desktop window: {e}", file=sys.stderr)
|
||||||
|
print("Falling back to running as a web application in your default browser...", file=sys.stderr)
|
||||||
|
target_port = _find_free_port("127.0.0.1", 8080)
|
||||||
|
print(f"Starting Abogen WebUI on http://127.0.0.1:{target_port} ...")
|
||||||
|
ft.app(
|
||||||
|
target=_app_entry,
|
||||||
|
view=ft.AppView.WEB_BROWSER,
|
||||||
|
port=target_port,
|
||||||
|
host="127.0.0.1",
|
||||||
|
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||||
|
no_cdn=True,
|
||||||
|
web_renderer="canvaskit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main_web() -> None:
|
||||||
|
"""
|
||||||
|
Start the Abogen Flet frontend as a web server.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
if "--web" not in sys.argv:
|
||||||
|
sys.argv.insert(1, "--web")
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""State sub-package – exports AppState and ConversionJob."""
|
||||||
|
from .app_state import AppState, ConversionJob
|
||||||
|
|
||||||
|
__all__ = ["AppState", "ConversionJob"]
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""
|
||||||
|
Centralized, per-session application state for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
Each Flet page (session) gets its own instance of AppState, which guarantees
|
||||||
|
complete isolation between simultaneous web-browser clients and the desktop
|
||||||
|
window. The class carries every configuration variable, file buffer reference,
|
||||||
|
and generation progress field that the rest of the UI reads or writes.
|
||||||
|
|
||||||
|
This module intentionally has no Flet imports so it can be unit-tested without
|
||||||
|
a running Flet server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from abogen.utils import load_config, save_config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config() -> Dict[str, Any]:
|
||||||
|
"""Load the persisted user config dict, returning an empty dict on failure."""
|
||||||
|
try:
|
||||||
|
return load_config() or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-session state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversionJob:
|
||||||
|
"""Lightweight descriptor of a single queued conversion job."""
|
||||||
|
|
||||||
|
file_path: str
|
||||||
|
"""Absolute path to the text/epub/pdf/txt input file."""
|
||||||
|
|
||||||
|
display_name: str
|
||||||
|
"""User-visible filename (may be the original epub/pdf path)."""
|
||||||
|
|
||||||
|
voice: str
|
||||||
|
"""Voice formula string (e.g. 'af_heart' or 'af_heart*0.5+am_adam*0.5')."""
|
||||||
|
|
||||||
|
lang_code: str
|
||||||
|
"""Single-char language prefix used by Kokoro (e.g. 'a', 'b', 'e')."""
|
||||||
|
|
||||||
|
speed: float = 1.0
|
||||||
|
"""Playback speed multiplier, range 0.1 – 2.0."""
|
||||||
|
|
||||||
|
output_format: str = "mp3"
|
||||||
|
"""Output audio container format."""
|
||||||
|
|
||||||
|
subtitle_mode: str = "Disabled"
|
||||||
|
"""Subtitle generation mode."""
|
||||||
|
|
||||||
|
save_option: str = "Save next to input file"
|
||||||
|
"""Save location strategy."""
|
||||||
|
|
||||||
|
output_folder: Optional[str] = None
|
||||||
|
"""Absolute path when save_option is 'Choose output folder'."""
|
||||||
|
|
||||||
|
char_count: int = 0
|
||||||
|
"""Pre-computed character count for ETR estimation."""
|
||||||
|
|
||||||
|
replace_single_newlines: bool = True
|
||||||
|
save_chapters_separately: Optional[bool] = None
|
||||||
|
merge_chapters_at_end: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppState:
|
||||||
|
"""
|
||||||
|
Single source of truth for one Flet session.
|
||||||
|
|
||||||
|
Instantiated once per ``ft.app()`` call on desktop, and once per browser
|
||||||
|
tab on web. All UI components receive a reference to this object and
|
||||||
|
read/write it to keep themselves in sync.
|
||||||
|
|
||||||
|
Thread-safety: mutation from background threads should be done via the
|
||||||
|
provided ``_lock``. The UI update callbacks (``on_log``,
|
||||||
|
``on_progress``, etc.) are always invoked on the Flet event loop via
|
||||||
|
``page.run_task()`` and must be set by the view layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Runtime identity
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Persisted user config (loaded once, written on every change)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
config: Dict[str, Any] = field(default_factory=_default_config)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# File / input state
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
selected_file: Optional[str] = None
|
||||||
|
"""Path to the processed text file (may be a temp cache copy for epub/pdf)."""
|
||||||
|
|
||||||
|
selected_file_type: Optional[str] = None
|
||||||
|
"""'txt' | 'epub' | 'pdf' | 'markdown' | None"""
|
||||||
|
|
||||||
|
selected_book_path: Optional[str] = None
|
||||||
|
"""Original epub/pdf path before being converted to txt."""
|
||||||
|
|
||||||
|
displayed_file_path: Optional[str] = None
|
||||||
|
"""Path shown in the UI drop-zone (original book or txt file)."""
|
||||||
|
|
||||||
|
selected_chapters: List[str] = field(default_factory=list)
|
||||||
|
"""Ordered list of selected chapter href tokens (or page numbers for PDFs)."""
|
||||||
|
|
||||||
|
save_chapters_separately: Optional[bool] = None
|
||||||
|
merge_chapters_at_end: Optional[bool] = None
|
||||||
|
save_as_project: bool = False
|
||||||
|
char_count: int = 0
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Voice / language
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
selected_voice: str = "af_heart"
|
||||||
|
selected_lang: str = "a"
|
||||||
|
selected_profile_name: Optional[str] = None
|
||||||
|
mixed_voice_state: Optional[List[Any]] = None
|
||||||
|
"""List of [voice_id, weight] pairs when the formula mixer is in use."""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Conversion parameters
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
speed: float = 1.0
|
||||||
|
use_gpu: bool = True
|
||||||
|
selected_format: str = "wav"
|
||||||
|
subtitle_mode: str = "Sentence"
|
||||||
|
subtitle_format: str = "ass_centered_narrow"
|
||||||
|
replace_single_newlines: bool = True
|
||||||
|
save_option: str = "Save next to input file"
|
||||||
|
selected_output_folder: Optional[str] = None
|
||||||
|
silence_duration: float = 2.0
|
||||||
|
max_subtitle_words: int = 50
|
||||||
|
separate_chapters_format: str = "wav"
|
||||||
|
use_silent_gaps: bool = True
|
||||||
|
subtitle_speed_method: str = "tts"
|
||||||
|
use_spacy_segmentation: bool = True
|
||||||
|
chunk_level: str = "paragraph"
|
||||||
|
generate_epub3: bool = False
|
||||||
|
|
||||||
|
# TTS provider
|
||||||
|
tts_provider: str = "kokoro"
|
||||||
|
supertonic_total_steps: int = 5
|
||||||
|
|
||||||
|
# Chapter options
|
||||||
|
chapter_intro_delay: float = 0.5
|
||||||
|
read_title_intro: bool = False
|
||||||
|
read_closing_outro: bool = True
|
||||||
|
auto_prefix_chapter_titles: bool = True
|
||||||
|
normalize_chapter_opening_caps: bool = True
|
||||||
|
|
||||||
|
# Speaker analysis
|
||||||
|
speaker_analysis_threshold: int = 3
|
||||||
|
|
||||||
|
# Word substitutions
|
||||||
|
word_substitutions_enabled: bool = False
|
||||||
|
word_substitutions_list: str = ""
|
||||||
|
case_sensitive_substitutions: bool = False
|
||||||
|
replace_all_caps: bool = False
|
||||||
|
replace_numerals: bool = False
|
||||||
|
fix_nonstandard_punctuation: bool = False
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Conversion runtime state
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
is_converting: bool = False
|
||||||
|
is_cancelled: bool = False
|
||||||
|
progress: float = 0.0
|
||||||
|
"""Fractional progress 0.0 – 1.0."""
|
||||||
|
etr_seconds: Optional[float] = None
|
||||||
|
"""Estimated seconds remaining, or None if unknown."""
|
||||||
|
last_output_path: Optional[str] = None
|
||||||
|
log_lines: List[str] = field(default_factory=list)
|
||||||
|
"""Buffered log messages, capped at LOG_MAX_LINES."""
|
||||||
|
|
||||||
|
LOG_MAX_LINES: int = 2000
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Queue
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
queued_items: List[ConversionJob] = field(default_factory=list)
|
||||||
|
current_queue_index: int = 0
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Callbacks (set by the view layer, not serialised)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
on_log: Optional[Callable[[str, str], None]] = field(default=None, repr=False, compare=False)
|
||||||
|
"""Called from any thread: ``on_log(message, level)``."""
|
||||||
|
|
||||||
|
on_progress: Optional[Callable[[float, Optional[float]], None]] = field(
|
||||||
|
default=None, repr=False, compare=False
|
||||||
|
)
|
||||||
|
"""Called from any thread: ``on_progress(fraction, etr_seconds)``."""
|
||||||
|
|
||||||
|
on_conversion_finished: Optional[Callable[[str, Optional[str]], None]] = field(
|
||||||
|
default=None, repr=False, compare=False
|
||||||
|
)
|
||||||
|
"""Called from any thread: ``on_conversion_finished(message, output_path)``."""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Integrations
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
audiobookshelf_enabled: bool = False
|
||||||
|
audiobookshelf_base_url: str = ""
|
||||||
|
audiobookshelf_api_token: str = ""
|
||||||
|
audiobookshelf_library_id: str = ""
|
||||||
|
audiobookshelf_folder_id: str = ""
|
||||||
|
audiobookshelf_verify_ssl: bool = True
|
||||||
|
audiobookshelf_auto_send: bool = False
|
||||||
|
audiobookshelf_send_cover: bool = True
|
||||||
|
audiobookshelf_send_chapters: bool = True
|
||||||
|
audiobookshelf_send_subtitles: bool = False
|
||||||
|
audiobookshelf_timeout: float = 30.0
|
||||||
|
|
||||||
|
calibre_opds_enabled: bool = False
|
||||||
|
calibre_opds_base_url: str = ""
|
||||||
|
calibre_opds_username: str = ""
|
||||||
|
calibre_opds_password: str = ""
|
||||||
|
calibre_opds_verify_ssl: bool = True
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Public helpers
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_from_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Populate all fields from the persisted JSON config file.
|
||||||
|
|
||||||
|
Called once at startup and whenever the settings page is saved.
|
||||||
|
Thread-safe.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
cfg = _default_config()
|
||||||
|
self.config = cfg
|
||||||
|
|
||||||
|
self.selected_voice = cfg.get("selected_voice", "af_heart")
|
||||||
|
self.selected_lang = self.selected_voice[0] if self.selected_voice else "a"
|
||||||
|
self.selected_profile_name = cfg.get("selected_profile_name")
|
||||||
|
self.speed = cfg.get("speed", 1.0)
|
||||||
|
self.use_gpu = cfg.get("use_gpu", True)
|
||||||
|
self.selected_format = cfg.get("selected_format", "wav")
|
||||||
|
self.subtitle_mode = cfg.get("subtitle_mode", "Sentence")
|
||||||
|
self.subtitle_format = cfg.get("subtitle_format", "ass_centered_narrow")
|
||||||
|
self.replace_single_newlines = cfg.get("replace_single_newlines", True)
|
||||||
|
self.save_option = cfg.get("save_option", "Save next to input file")
|
||||||
|
self.selected_output_folder = cfg.get("selected_output_folder")
|
||||||
|
self.silence_duration = cfg.get("silence_duration", 2.0)
|
||||||
|
self.max_subtitle_words = cfg.get("max_subtitle_words", 50)
|
||||||
|
self.separate_chapters_format = cfg.get("separate_chapters_format", "wav")
|
||||||
|
self.use_silent_gaps = cfg.get("use_silent_gaps", True)
|
||||||
|
self.subtitle_speed_method = cfg.get("subtitle_speed_method", "tts")
|
||||||
|
self.use_spacy_segmentation = cfg.get("use_spacy_segmentation", True)
|
||||||
|
self.chunk_level = cfg.get("chunk_level", "paragraph")
|
||||||
|
self.generate_epub3 = cfg.get("generate_epub3", False)
|
||||||
|
self.tts_provider = cfg.get("tts_provider", "kokoro")
|
||||||
|
self.supertonic_total_steps = cfg.get("supertonic_total_steps", 5)
|
||||||
|
self.chapter_intro_delay = cfg.get("chapter_intro_delay", 0.5)
|
||||||
|
self.read_title_intro = cfg.get("read_title_intro", False)
|
||||||
|
self.read_closing_outro = cfg.get("read_closing_outro", True)
|
||||||
|
self.auto_prefix_chapter_titles = cfg.get("auto_prefix_chapter_titles", True)
|
||||||
|
self.normalize_chapter_opening_caps = cfg.get("normalize_chapter_opening_caps", True)
|
||||||
|
self.speaker_analysis_threshold = cfg.get("speaker_analysis_threshold", 3)
|
||||||
|
self.word_substitutions_enabled = cfg.get("word_substitutions_enabled", False)
|
||||||
|
self.word_substitutions_list = cfg.get("word_substitutions_list", "")
|
||||||
|
self.case_sensitive_substitutions = cfg.get("case_sensitive_substitutions", False)
|
||||||
|
self.replace_all_caps = cfg.get("replace_all_caps", False)
|
||||||
|
self.replace_numerals = cfg.get("replace_numerals", False)
|
||||||
|
self.fix_nonstandard_punctuation = cfg.get("fix_nonstandard_punctuation", False)
|
||||||
|
|
||||||
|
# Integrations
|
||||||
|
integrations: Dict[str, Any] = cfg.get("integrations", {})
|
||||||
|
abs_cfg = integrations.get("audiobookshelf", {})
|
||||||
|
self.audiobookshelf_enabled = bool(abs_cfg.get("enabled", False))
|
||||||
|
self.audiobookshelf_base_url = str(abs_cfg.get("base_url", ""))
|
||||||
|
self.audiobookshelf_api_token = str(abs_cfg.get("api_token", ""))
|
||||||
|
self.audiobookshelf_library_id = str(abs_cfg.get("library_id", ""))
|
||||||
|
self.audiobookshelf_folder_id = str(abs_cfg.get("folder_id", ""))
|
||||||
|
self.audiobookshelf_verify_ssl = bool(abs_cfg.get("verify_ssl", True))
|
||||||
|
self.audiobookshelf_auto_send = bool(abs_cfg.get("auto_send", False))
|
||||||
|
self.audiobookshelf_send_cover = bool(abs_cfg.get("send_cover", True))
|
||||||
|
self.audiobookshelf_send_chapters = bool(abs_cfg.get("send_chapters", True))
|
||||||
|
self.audiobookshelf_send_subtitles = bool(abs_cfg.get("send_subtitles", False))
|
||||||
|
self.audiobookshelf_timeout = float(abs_cfg.get("timeout", 30.0))
|
||||||
|
|
||||||
|
cal_cfg = integrations.get("calibre_opds", {})
|
||||||
|
self.calibre_opds_enabled = bool(cal_cfg.get("enabled", False))
|
||||||
|
self.calibre_opds_base_url = str(cal_cfg.get("base_url", ""))
|
||||||
|
self.calibre_opds_username = str(cal_cfg.get("username", ""))
|
||||||
|
self.calibre_opds_password = str(cal_cfg.get("password", ""))
|
||||||
|
self.calibre_opds_verify_ssl = bool(cal_cfg.get("verify_ssl", True))
|
||||||
|
|
||||||
|
def persist_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Write the current config snapshot back to disk.
|
||||||
|
|
||||||
|
Only the fields that map to the JSON config are written; runtime state
|
||||||
|
(progress, log_lines, callbacks) is not persisted.
|
||||||
|
Thread-safe.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
cfg = self.config.copy()
|
||||||
|
cfg["selected_voice"] = self.selected_voice
|
||||||
|
cfg["selected_profile_name"] = self.selected_profile_name
|
||||||
|
cfg["speed"] = self.speed
|
||||||
|
cfg["use_gpu"] = self.use_gpu
|
||||||
|
cfg["selected_format"] = self.selected_format
|
||||||
|
cfg["subtitle_mode"] = self.subtitle_mode
|
||||||
|
cfg["subtitle_format"] = self.subtitle_format
|
||||||
|
cfg["replace_single_newlines"] = self.replace_single_newlines
|
||||||
|
cfg["save_option"] = self.save_option
|
||||||
|
cfg["selected_output_folder"] = self.selected_output_folder
|
||||||
|
cfg["silence_duration"] = self.silence_duration
|
||||||
|
cfg["max_subtitle_words"] = self.max_subtitle_words
|
||||||
|
cfg["separate_chapters_format"] = self.separate_chapters_format
|
||||||
|
cfg["use_silent_gaps"] = self.use_silent_gaps
|
||||||
|
cfg["subtitle_speed_method"] = self.subtitle_speed_method
|
||||||
|
cfg["use_spacy_segmentation"] = self.use_spacy_segmentation
|
||||||
|
cfg["chunk_level"] = self.chunk_level
|
||||||
|
cfg["generate_epub3"] = self.generate_epub3
|
||||||
|
cfg["tts_provider"] = self.tts_provider
|
||||||
|
cfg["supertonic_total_steps"] = self.supertonic_total_steps
|
||||||
|
cfg["chapter_intro_delay"] = self.chapter_intro_delay
|
||||||
|
cfg["read_title_intro"] = self.read_title_intro
|
||||||
|
cfg["read_closing_outro"] = self.read_closing_outro
|
||||||
|
cfg["auto_prefix_chapter_titles"] = self.auto_prefix_chapter_titles
|
||||||
|
cfg["normalize_chapter_opening_caps"] = self.normalize_chapter_opening_caps
|
||||||
|
cfg["speaker_analysis_threshold"] = self.speaker_analysis_threshold
|
||||||
|
cfg["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||||
|
cfg["word_substitutions_list"] = self.word_substitutions_list
|
||||||
|
cfg["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||||
|
cfg["replace_all_caps"] = self.replace_all_caps
|
||||||
|
cfg["replace_numerals"] = self.replace_numerals
|
||||||
|
cfg["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||||
|
# Integrations
|
||||||
|
cfg.setdefault("integrations", {})
|
||||||
|
cfg["integrations"]["audiobookshelf"] = {
|
||||||
|
"enabled": self.audiobookshelf_enabled,
|
||||||
|
"base_url": self.audiobookshelf_base_url,
|
||||||
|
"api_token": self.audiobookshelf_api_token,
|
||||||
|
"library_id": self.audiobookshelf_library_id,
|
||||||
|
"folder_id": self.audiobookshelf_folder_id,
|
||||||
|
"verify_ssl": self.audiobookshelf_verify_ssl,
|
||||||
|
"auto_send": self.audiobookshelf_auto_send,
|
||||||
|
"send_cover": self.audiobookshelf_send_cover,
|
||||||
|
"send_chapters": self.audiobookshelf_send_chapters,
|
||||||
|
"send_subtitles": self.audiobookshelf_send_subtitles,
|
||||||
|
"timeout": self.audiobookshelf_timeout,
|
||||||
|
}
|
||||||
|
cfg["integrations"]["calibre_opds"] = {
|
||||||
|
"enabled": self.calibre_opds_enabled,
|
||||||
|
"base_url": self.calibre_opds_base_url,
|
||||||
|
"username": self.calibre_opds_username,
|
||||||
|
"password": self.calibre_opds_password,
|
||||||
|
"verify_ssl": self.calibre_opds_verify_ssl,
|
||||||
|
}
|
||||||
|
self.config = cfg
|
||||||
|
try:
|
||||||
|
save_config(cfg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def append_log(self, message: str, level: str = "info") -> None:
|
||||||
|
"""
|
||||||
|
Thread-safely append a log line and trigger the UI callback.
|
||||||
|
|
||||||
|
Caps the internal buffer at ``LOG_MAX_LINES`` to prevent unbounded
|
||||||
|
memory growth during very long conversion tasks.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.log_lines.append(f"[{level.upper()}] {message}")
|
||||||
|
if len(self.log_lines) > self.LOG_MAX_LINES:
|
||||||
|
# Trim oldest 10 % to amortise the cost of trimming
|
||||||
|
trim = self.LOG_MAX_LINES // 10
|
||||||
|
self.log_lines = self.log_lines[trim:]
|
||||||
|
|
||||||
|
cb = self.on_log
|
||||||
|
if cb is not None:
|
||||||
|
try:
|
||||||
|
cb(message, level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_progress(self, fraction: float, etr: Optional[float] = None) -> None:
|
||||||
|
"""
|
||||||
|
Update fractional progress and ETR, then notify the UI callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fraction: Value in [0.0, 1.0].
|
||||||
|
etr: Estimated seconds remaining, or None.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.progress = max(0.0, min(1.0, fraction))
|
||||||
|
self.etr_seconds = etr
|
||||||
|
|
||||||
|
cb = self.on_progress
|
||||||
|
if cb is not None:
|
||||||
|
try:
|
||||||
|
cb(fraction, etr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_voice_formula(self) -> str:
|
||||||
|
"""
|
||||||
|
Return the effective voice formula string.
|
||||||
|
|
||||||
|
Uses the mixed_voice_state if the formula mixer is active, otherwise
|
||||||
|
returns the raw selected_voice.
|
||||||
|
"""
|
||||||
|
if self.mixed_voice_state:
|
||||||
|
parts = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
|
||||||
|
return " + ".join(filter(None, parts))
|
||||||
|
return self.selected_voice or "af_heart"
|
||||||
|
|
||||||
|
def reset_file_state(self) -> None:
|
||||||
|
"""Clear all file-related fields without touching voice/settings."""
|
||||||
|
with self._lock:
|
||||||
|
self.selected_file = None
|
||||||
|
self.selected_file_type = None
|
||||||
|
self.selected_book_path = None
|
||||||
|
self.displayed_file_path = None
|
||||||
|
self.selected_chapters = []
|
||||||
|
self.save_chapters_separately = None
|
||||||
|
self.merge_chapters_at_end = None
|
||||||
|
self.save_as_project = False
|
||||||
|
self.char_count = 0
|
||||||
|
|
||||||
|
def reset_conversion_state(self) -> None:
|
||||||
|
"""Clear all runtime conversion fields to start fresh."""
|
||||||
|
with self._lock:
|
||||||
|
self.is_converting = False
|
||||||
|
self.is_cancelled = False
|
||||||
|
self.progress = 0.0
|
||||||
|
self.etr_seconds = None
|
||||||
|
self.last_output_path = None
|
||||||
|
self.log_lines = []
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Utils sub-package."""
|
||||||
|
from .helpers import (
|
||||||
|
human_readable_size,
|
||||||
|
format_duration,
|
||||||
|
format_etr,
|
||||||
|
detect_file_type,
|
||||||
|
is_supported_file,
|
||||||
|
is_book_type,
|
||||||
|
voice_lang_code,
|
||||||
|
language_label,
|
||||||
|
grouped_voices,
|
||||||
|
voice_display_name,
|
||||||
|
parse_voice_formula,
|
||||||
|
format_number,
|
||||||
|
safe_basename,
|
||||||
|
output_format_label,
|
||||||
|
subtitle_format_label,
|
||||||
|
SUPPORTED_EXTENSIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"human_readable_size",
|
||||||
|
"format_duration",
|
||||||
|
"format_etr",
|
||||||
|
"detect_file_type",
|
||||||
|
"is_supported_file",
|
||||||
|
"is_book_type",
|
||||||
|
"voice_lang_code",
|
||||||
|
"language_label",
|
||||||
|
"grouped_voices",
|
||||||
|
"voice_display_name",
|
||||||
|
"parse_voice_formula",
|
||||||
|
"format_number",
|
||||||
|
"safe_basename",
|
||||||
|
"output_format_label",
|
||||||
|
"subtitle_format_label",
|
||||||
|
"SUPPORTED_EXTENSIONS",
|
||||||
|
]
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
Background conversion bridge for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
This module wraps the existing ``abogen.webui.conversion_runner`` (and its
|
||||||
|
``ConversionService`` / ``Job`` machinery) in an async-friendly interface that
|
||||||
|
can push real-time progress and log updates back to the Flet event loop without
|
||||||
|
blocking the UI thread.
|
||||||
|
|
||||||
|
Key design decisions
|
||||||
|
--------------------
|
||||||
|
* All heavy work is offloaded to daemon threads. The Flet page event loop
|
||||||
|
is never blocked.
|
||||||
|
* Progress and log callbacks are scheduled back onto the Flet page via
|
||||||
|
``page.run_task()`` so Flet's session isolation remains intact.
|
||||||
|
* Cancellation is cooperative: the underlying job's ``cancel_requested``
|
||||||
|
flag is set, and the runner checks it at chunk boundaries.
|
||||||
|
* The module is a pure adapter – it does NOT duplicate any processing logic
|
||||||
|
from the core pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from abogen.utils import (
|
||||||
|
get_gpu_acceleration,
|
||||||
|
get_user_cache_path,
|
||||||
|
get_user_output_path,
|
||||||
|
load_numpy_kpipeline,
|
||||||
|
prevent_sleep_end,
|
||||||
|
prevent_sleep_start,
|
||||||
|
)
|
||||||
|
from abogen.webui.service import (
|
||||||
|
ConversionService,
|
||||||
|
Job,
|
||||||
|
JobStatus,
|
||||||
|
PendingJob,
|
||||||
|
build_service,
|
||||||
|
)
|
||||||
|
from abogen.webui.conversion_runner import run_conversion_job
|
||||||
|
|
||||||
|
from ..state import AppState
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level singleton ConversionService (shared across sessions, as in the
|
||||||
|
# web UI – but each job carries its own output folder keyed by session).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SERVICE_LOCK = threading.Lock()
|
||||||
|
_SERVICE: Optional[ConversionService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service() -> ConversionService:
|
||||||
|
"""
|
||||||
|
Return (creating if necessary) the module-level ConversionService.
|
||||||
|
|
||||||
|
The service manages the background worker thread and persistent job state.
|
||||||
|
Thread-safe via a module-level lock.
|
||||||
|
"""
|
||||||
|
global _SERVICE
|
||||||
|
with _SERVICE_LOCK:
|
||||||
|
if _SERVICE is None:
|
||||||
|
output_root = Path(get_user_output_path("frontend"))
|
||||||
|
uploads_root = Path(get_user_cache_path("frontend/uploads"))
|
||||||
|
_SERVICE = build_service(
|
||||||
|
runner=run_conversion_job,
|
||||||
|
output_root=output_root,
|
||||||
|
uploads_root=uploads_root,
|
||||||
|
)
|
||||||
|
return _SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public conversion bridge
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionBridge:
|
||||||
|
"""
|
||||||
|
Thin adapter between the Flet UI session and the core conversion pipeline.
|
||||||
|
|
||||||
|
One ``ConversionBridge`` instance is created per Flet page (session) and
|
||||||
|
is responsible for:
|
||||||
|
1. Accepting a conversion request from the UI.
|
||||||
|
2. Writing the input text to a temp file if needed.
|
||||||
|
3. Submitting the job to ``ConversionService``.
|
||||||
|
4. Polling the job from a daemon thread and forwarding progress/logs to
|
||||||
|
the Flet page via ``page.run_task()``.
|
||||||
|
5. Providing a ``cancel()`` method that sets the cooperative flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
"""
|
||||||
|
Initialise the bridge.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: The Flet ``Page`` for this session. Used to schedule
|
||||||
|
UI callbacks on the correct event loop.
|
||||||
|
state: The session's ``AppState`` instance.
|
||||||
|
"""
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
self._current_job: Optional[Job] = None
|
||||||
|
self._poll_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_poll = threading.Event()
|
||||||
|
self._seen_log_count = 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
input_file: str,
|
||||||
|
voice: str,
|
||||||
|
lang_code: str,
|
||||||
|
speed: float,
|
||||||
|
output_format: str,
|
||||||
|
subtitle_mode: str,
|
||||||
|
subtitle_format: str,
|
||||||
|
use_gpu: bool,
|
||||||
|
save_option: str,
|
||||||
|
output_folder: Optional[str],
|
||||||
|
replace_single_newlines: bool,
|
||||||
|
char_count: int,
|
||||||
|
chapters: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
save_chapters_separately: bool = False,
|
||||||
|
merge_chapters_at_end: bool = True,
|
||||||
|
separate_chapters_format: str = "wav",
|
||||||
|
silence_between_chapters: float = 2.0,
|
||||||
|
max_subtitle_words: int = 50,
|
||||||
|
chapter_intro_delay: float = 0.5,
|
||||||
|
read_title_intro: bool = False,
|
||||||
|
read_closing_outro: bool = True,
|
||||||
|
auto_prefix_chapter_titles: bool = True,
|
||||||
|
normalize_chapter_opening_caps: bool = True,
|
||||||
|
tts_provider: str = "kokoro",
|
||||||
|
supertonic_total_steps: int = 5,
|
||||||
|
chunk_level: str = "paragraph",
|
||||||
|
generate_epub3: bool = False,
|
||||||
|
word_substitutions_enabled: bool = False,
|
||||||
|
word_substitutions_list: str = "",
|
||||||
|
case_sensitive_substitutions: bool = False,
|
||||||
|
replace_all_caps: bool = False,
|
||||||
|
replace_numerals: bool = False,
|
||||||
|
fix_nonstandard_punctuation: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Submit a conversion job and begin the progress-polling loop.
|
||||||
|
|
||||||
|
This method returns immediately; all heavy work runs on daemon threads.
|
||||||
|
UI callbacks (``state.on_log``, ``state.on_progress``,
|
||||||
|
``state.on_conversion_finished``) are scheduled on the Flet event loop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Absolute path to the text/epub/pdf input file.
|
||||||
|
voice: Kokoro voice formula string.
|
||||||
|
lang_code: Single-char language code.
|
||||||
|
speed: Playback speed multiplier (0.1 – 2.0).
|
||||||
|
output_format: Audio container key (``'wav'``, ``'mp3'``, …).
|
||||||
|
subtitle_mode: Subtitle generation mode string.
|
||||||
|
subtitle_format: Subtitle container key (``'srt'``, ``'ass_wide'``, …).
|
||||||
|
use_gpu: Whether to request GPU acceleration.
|
||||||
|
save_option: Save-location strategy string.
|
||||||
|
output_folder: Explicit output folder or None.
|
||||||
|
replace_single_newlines: Pre-processing flag.
|
||||||
|
char_count: Pre-computed character count for ETR estimation.
|
||||||
|
chapters: Optional list of chapter dicts for epub/pdf.
|
||||||
|
save_chapters_separately: Split chapters into separate files.
|
||||||
|
merge_chapters_at_end: Merge chapter files into one after generation.
|
||||||
|
separate_chapters_format: Format for individual chapter files.
|
||||||
|
silence_between_chapters: Silence gap (seconds) between chapters.
|
||||||
|
max_subtitle_words: Maximum words per subtitle block.
|
||||||
|
chapter_intro_delay: Silence before chapter title announcement (s).
|
||||||
|
read_title_intro: Announce book title at the start.
|
||||||
|
read_closing_outro: Announce book title at the end.
|
||||||
|
auto_prefix_chapter_titles: Prepend "Chapter N." to titles.
|
||||||
|
normalize_chapter_opening_caps: Fix ALL-CAPS opening lines.
|
||||||
|
tts_provider: ``'kokoro'`` or ``'supertonic'``.
|
||||||
|
supertonic_total_steps: Quality steps for the Supertonic pipeline.
|
||||||
|
chunk_level: ``'paragraph'`` or ``'sentence'`` chunking granularity.
|
||||||
|
generate_epub3: Also produce an EPUB3 audiobook package.
|
||||||
|
word_substitutions_enabled: Toggle word-substitution pre-processing.
|
||||||
|
word_substitutions_list: Newline-delimited ``word|replacement`` rules.
|
||||||
|
case_sensitive_substitutions: Case-sensitive matching for substitutions.
|
||||||
|
replace_all_caps: Lowercase ALL-CAPS words.
|
||||||
|
replace_numerals: Convert digits to spoken words.
|
||||||
|
fix_nonstandard_punctuation: Normalise curly quotes etc.
|
||||||
|
"""
|
||||||
|
if self._state.is_converting:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve the effective output folder
|
||||||
|
resolved_output: Optional[Path] = self._resolve_output_folder(
|
||||||
|
save_option=save_option,
|
||||||
|
output_folder=output_folder,
|
||||||
|
input_file=input_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the input file as a Path
|
||||||
|
stored_path = Path(input_file)
|
||||||
|
original_filename = stored_path.name
|
||||||
|
|
||||||
|
# Block signals until the job is submitted
|
||||||
|
prevent_sleep_start()
|
||||||
|
self._state.is_converting = True
|
||||||
|
self._state.is_cancelled = False
|
||||||
|
self._state.progress = 0.0
|
||||||
|
self._state.etr_seconds = None
|
||||||
|
self._state.log_lines = []
|
||||||
|
self._seen_log_count = 0
|
||||||
|
|
||||||
|
# Enqueue the job on the service
|
||||||
|
service = _get_service()
|
||||||
|
job = service.enqueue(
|
||||||
|
original_filename=original_filename,
|
||||||
|
stored_path=stored_path,
|
||||||
|
language=lang_code,
|
||||||
|
voice=voice,
|
||||||
|
speed=speed,
|
||||||
|
tts_provider=tts_provider,
|
||||||
|
supertonic_total_steps=supertonic_total_steps,
|
||||||
|
use_gpu=use_gpu,
|
||||||
|
subtitle_mode=subtitle_mode,
|
||||||
|
output_format=output_format,
|
||||||
|
save_mode=self._save_mode_key(save_option),
|
||||||
|
output_folder=resolved_output,
|
||||||
|
replace_single_newlines=replace_single_newlines,
|
||||||
|
subtitle_format=subtitle_format,
|
||||||
|
total_characters=char_count,
|
||||||
|
chapters=chapters or [],
|
||||||
|
save_chapters_separately=save_chapters_separately,
|
||||||
|
merge_chapters_at_end=merge_chapters_at_end,
|
||||||
|
separate_chapters_format=separate_chapters_format,
|
||||||
|
silence_between_chapters=silence_between_chapters,
|
||||||
|
max_subtitle_words=max_subtitle_words,
|
||||||
|
chapter_intro_delay=chapter_intro_delay,
|
||||||
|
read_title_intro=read_title_intro,
|
||||||
|
read_closing_outro=read_closing_outro,
|
||||||
|
auto_prefix_chapter_titles=auto_prefix_chapter_titles,
|
||||||
|
normalize_chapter_opening_caps=normalize_chapter_opening_caps,
|
||||||
|
chunk_level=chunk_level,
|
||||||
|
generate_epub3=generate_epub3,
|
||||||
|
)
|
||||||
|
self._current_job = job
|
||||||
|
|
||||||
|
# Persist word-substitution settings to config so the runner picks them up
|
||||||
|
self._state.word_substitutions_enabled = word_substitutions_enabled
|
||||||
|
self._state.word_substitutions_list = word_substitutions_list
|
||||||
|
self._state.case_sensitive_substitutions = case_sensitive_substitutions
|
||||||
|
self._state.replace_all_caps = replace_all_caps
|
||||||
|
self._state.replace_numerals = replace_numerals
|
||||||
|
self._state.fix_nonstandard_punctuation = fix_nonstandard_punctuation
|
||||||
|
self._state.persist_config()
|
||||||
|
|
||||||
|
# Start the poll thread
|
||||||
|
self._stop_poll.clear()
|
||||||
|
self._poll_thread = threading.Thread(
|
||||||
|
target=self._poll_job_loop, daemon=True, name="abogen-poll"
|
||||||
|
)
|
||||||
|
self._poll_thread.start()
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""
|
||||||
|
Request cancellation of the currently running job.
|
||||||
|
|
||||||
|
Sets the cooperative flag on the underlying ``Job`` object; the runner
|
||||||
|
will stop after completing the current text chunk.
|
||||||
|
"""
|
||||||
|
if self._current_job is not None:
|
||||||
|
self._state.is_cancelled = True
|
||||||
|
try:
|
||||||
|
_get_service().cancel(self._current_job.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_mode_key(option: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert the human-readable save option to the service's internal key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option: UI-facing string (``'Save next to input file'``, …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service key string.
|
||||||
|
"""
|
||||||
|
mapping = {
|
||||||
|
"Save next to input file": "save_next_to_input",
|
||||||
|
"Save to Desktop": "save_to_desktop",
|
||||||
|
"Choose output folder": "custom",
|
||||||
|
}
|
||||||
|
return mapping.get(option, "save_next_to_input")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_output_folder(
|
||||||
|
save_option: str,
|
||||||
|
output_folder: Optional[str],
|
||||||
|
input_file: str,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Return the output ``Path`` based on the save option, or None for
|
||||||
|
the "next to input" strategy (the runner handles that internally).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
save_option: UI-facing save strategy string.
|
||||||
|
output_folder: Explicit path when ``save_option`` is ``'Choose output folder'``.
|
||||||
|
input_file: Path to the source file for the ``'Save to Desktop'`` strategy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resolved ``Path`` or ``None``.
|
||||||
|
"""
|
||||||
|
if save_option == "Choose output folder" and output_folder:
|
||||||
|
p = Path(output_folder)
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
if save_option == "Save to Desktop":
|
||||||
|
desktop = Path.home() / "Desktop"
|
||||||
|
desktop.mkdir(exist_ok=True)
|
||||||
|
return desktop
|
||||||
|
# "Save next to input file" – let the runner decide
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _poll_job_loop(self) -> None:
|
||||||
|
"""
|
||||||
|
Background daemon loop that polls the current Job for updates.
|
||||||
|
|
||||||
|
Runs until the job enters a terminal state or until ``_stop_poll``
|
||||||
|
is set. Uses ``page.run_task()`` to schedule UI updates on the Flet
|
||||||
|
event loop without triggering thread-safety violations.
|
||||||
|
"""
|
||||||
|
job = self._current_job
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
service = _get_service()
|
||||||
|
POLL_INTERVAL = 0.25 # seconds
|
||||||
|
|
||||||
|
while not self._stop_poll.is_set():
|
||||||
|
# Re-fetch the current job state (it's mutated in-place by the runner)
|
||||||
|
current = service.get_job(job.id)
|
||||||
|
if current is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Forward new log lines
|
||||||
|
new_logs = current.logs[self._seen_log_count:]
|
||||||
|
self._seen_log_count += len(new_logs)
|
||||||
|
for log_entry in new_logs:
|
||||||
|
level = getattr(log_entry, "level", "info")
|
||||||
|
message = getattr(log_entry, "message", str(log_entry))
|
||||||
|
self._schedule_log(message, level)
|
||||||
|
|
||||||
|
# Forward progress
|
||||||
|
if current.progress is not None:
|
||||||
|
etr = getattr(current, "estimated_time_remaining", None)
|
||||||
|
self._schedule_progress(float(current.progress), etr)
|
||||||
|
|
||||||
|
# Check for terminal states
|
||||||
|
status = current.status
|
||||||
|
if status in (
|
||||||
|
JobStatus.COMPLETED,
|
||||||
|
JobStatus.FAILED,
|
||||||
|
JobStatus.CANCELLED,
|
||||||
|
):
|
||||||
|
output_path: Optional[str] = None
|
||||||
|
if current.result and current.result.audio_path:
|
||||||
|
output_path = str(current.result.audio_path)
|
||||||
|
if status == JobStatus.COMPLETED:
|
||||||
|
finish_msg = "Conversion completed successfully."
|
||||||
|
elif status == JobStatus.CANCELLED:
|
||||||
|
finish_msg = "Cancelled"
|
||||||
|
else:
|
||||||
|
finish_msg = f"Conversion failed: {current.error or 'Unknown error'}"
|
||||||
|
|
||||||
|
self._schedule_finished(finish_msg, output_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
prevent_sleep_end()
|
||||||
|
self._state.is_converting = False
|
||||||
|
|
||||||
|
def _schedule_log(self, message: str, level: str) -> None:
|
||||||
|
"""Schedule a log update on the Flet event loop."""
|
||||||
|
state = self._state
|
||||||
|
page = self._page
|
||||||
|
state.append_log(message, level)
|
||||||
|
|
||||||
|
async def _update() -> None:
|
||||||
|
cb = state.on_log
|
||||||
|
if cb:
|
||||||
|
cb(message, level)
|
||||||
|
try:
|
||||||
|
page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.run_task(_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _schedule_progress(self, fraction: float, etr: Optional[float]) -> None:
|
||||||
|
"""Schedule a progress update on the Flet event loop."""
|
||||||
|
state = self._state
|
||||||
|
page = self._page
|
||||||
|
state.progress = max(0.0, min(1.0, fraction))
|
||||||
|
state.etr_seconds = etr
|
||||||
|
|
||||||
|
async def _update() -> None:
|
||||||
|
cb = state.on_progress
|
||||||
|
if cb:
|
||||||
|
cb(fraction, etr)
|
||||||
|
try:
|
||||||
|
page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.run_task(_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _schedule_finished(
|
||||||
|
self, message: str, output_path: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
"""Schedule a completion notification on the Flet event loop."""
|
||||||
|
state = self._state
|
||||||
|
page = self._page
|
||||||
|
state.last_output_path = output_path
|
||||||
|
self._stop_poll.set()
|
||||||
|
|
||||||
|
async def _update() -> None:
|
||||||
|
state.is_converting = False
|
||||||
|
state.progress = 1.0
|
||||||
|
state.last_output_path = output_path
|
||||||
|
cb = state.on_conversion_finished
|
||||||
|
if cb:
|
||||||
|
cb(message, output_path)
|
||||||
|
try:
|
||||||
|
page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.run_task(_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Frontend-specific utilities for the Abogen Flet application.
|
||||||
|
|
||||||
|
Contains helpers for:
|
||||||
|
- Human-readable size / duration formatting
|
||||||
|
- Voice formula parsing and display
|
||||||
|
- File-type detection
|
||||||
|
- ETR (Estimated Time Remaining) formatting
|
||||||
|
- Path resolution that adapts to desktop vs. web context
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from abogen.constants import (
|
||||||
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
SUPPORTED_INPUT_FORMATS,
|
||||||
|
SUPPORTED_SOUND_FORMATS,
|
||||||
|
SUBTITLE_FORMATS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Size / duration helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def human_readable_size(size_bytes: int, decimal_places: int = 2) -> str:
|
||||||
|
"""
|
||||||
|
Convert a byte count into a human-readable string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size_bytes: Number of bytes.
|
||||||
|
decimal_places: Significant decimal digits in the output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string like ``"3.14 MB"`` or ``"1.00 KB"``.
|
||||||
|
"""
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if size_bytes < 1024.0:
|
||||||
|
return f"{size_bytes:.{decimal_places}f} {unit}"
|
||||||
|
size_bytes /= 1024.0 # type: ignore[assignment]
|
||||||
|
return f"{size_bytes:.{decimal_places}f} PB"
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
"""
|
||||||
|
Format a duration in seconds as ``HH:MM:SS``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Non-negative floating-point duration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A colon-delimited time string, e.g. ``"00:03:42"``.
|
||||||
|
"""
|
||||||
|
total = max(0, int(seconds))
|
||||||
|
h, remainder = divmod(total, 3600)
|
||||||
|
m, s = divmod(remainder, 60)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_etr(etr_seconds: Optional[float]) -> str:
|
||||||
|
"""
|
||||||
|
Format an estimated time remaining value for the UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
etr_seconds: Seconds remaining, or None when unknown.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable string such as ``"~3 min 42 sec"`` or ``"Calculating…"``.
|
||||||
|
"""
|
||||||
|
if etr_seconds is None:
|
||||||
|
return "Calculating…"
|
||||||
|
total = max(0, int(etr_seconds))
|
||||||
|
if total < 60:
|
||||||
|
return f"~{total} sec"
|
||||||
|
m, s = divmod(total, 60)
|
||||||
|
if m < 60:
|
||||||
|
return f"~{m} min {s} sec"
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
return f"~{h} h {m} min"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS: Tuple[str, ...] = (
|
||||||
|
".txt",
|
||||||
|
".epub",
|
||||||
|
".pdf",
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".srt",
|
||||||
|
".ass",
|
||||||
|
".vtt",
|
||||||
|
)
|
||||||
|
"""All file extensions that the drop-zone accepts."""
|
||||||
|
|
||||||
|
|
||||||
|
def detect_file_type(file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Return a normalised file-type token for the given path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute or relative path to the input file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of ``'txt'``, ``'epub'``, ``'pdf'``, ``'markdown'``,
|
||||||
|
``'subtitle'``, or ``'unknown'``.
|
||||||
|
"""
|
||||||
|
ext = Path(file_path).suffix.lower()
|
||||||
|
if ext == ".epub":
|
||||||
|
return "epub"
|
||||||
|
if ext == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
if ext in (".md", ".markdown"):
|
||||||
|
return "markdown"
|
||||||
|
if ext in (".srt", ".ass", ".vtt"):
|
||||||
|
return "subtitle"
|
||||||
|
if ext == ".txt":
|
||||||
|
return "txt"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_file(file_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True when the file extension is in the supported set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path whose extension is inspected.
|
||||||
|
"""
|
||||||
|
return Path(file_path).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def is_book_type(file_type: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True for file types that contain chapters / pages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_type: Token from ``detect_file_type()``.
|
||||||
|
"""
|
||||||
|
return file_type in ("epub", "pdf", "markdown")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Voice helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def voice_lang_code(voice: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract the language code character from a Kokoro voice name.
|
||||||
|
|
||||||
|
The first character of every internal voice name encodes the language
|
||||||
|
(e.g. ``'a'`` for American English, ``'b'`` for British English).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice: Raw voice string like ``'af_heart'`` or a formula.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Single lowercase character, defaulting to ``'a'`` on failure.
|
||||||
|
"""
|
||||||
|
if not voice:
|
||||||
|
return "a"
|
||||||
|
# For plain voice IDs the first char is the language
|
||||||
|
if voice[0].isalpha() and "_" in voice[:4]:
|
||||||
|
return voice[0].lower()
|
||||||
|
# Formula: extract first alpha char
|
||||||
|
match = re.search(r"\b([a-z])", voice)
|
||||||
|
return match.group(1) if match else "a"
|
||||||
|
|
||||||
|
|
||||||
|
def language_label(lang_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the human-readable label for a language code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang_code: Single-character code (``'a'``, ``'b'``, …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Display string, e.g. ``"American English"``.
|
||||||
|
"""
|
||||||
|
return LANGUAGE_DESCRIPTIONS.get(lang_code, lang_code.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def grouped_voices() -> List[Tuple[str, List[str]]]:
|
||||||
|
"""
|
||||||
|
Return the internal voice list grouped by language for display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ``(language_label, [voice_id, …])`` tuples.
|
||||||
|
"""
|
||||||
|
groups: dict[str, List[str]] = {}
|
||||||
|
for v in VOICES_INTERNAL:
|
||||||
|
lang = language_label(v[0])
|
||||||
|
groups.setdefault(lang, []).append(v)
|
||||||
|
return sorted(groups.items())
|
||||||
|
|
||||||
|
|
||||||
|
def voice_display_name(voice_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a raw voice ID like ``'af_heart'`` to a prettier display name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_id: Raw internal voice identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string, e.g. ``"af_heart"`` (unchanged; may be enhanced later).
|
||||||
|
"""
|
||||||
|
return voice_id
|
||||||
|
|
||||||
|
|
||||||
|
def parse_voice_formula(formula: str) -> List[Tuple[str, float]]:
|
||||||
|
"""
|
||||||
|
Parse a Kokoro voice mix formula into a list of ``(voice_id, weight)`` tuples.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
``"af_heart*0.7+am_adam*0.3"`` → ``[('af_heart', 0.7), ('am_adam', 0.3)]``
|
||||||
|
|
||||||
|
Args:
|
||||||
|
formula: Space- or ``+``-joined mix formula string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed list; empty if parsing fails.
|
||||||
|
"""
|
||||||
|
parts: List[Tuple[str, float]] = []
|
||||||
|
for token in re.split(r"[+\s]+", formula.strip()):
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if "*" in token:
|
||||||
|
name, _, weight_str = token.partition("*")
|
||||||
|
try:
|
||||||
|
parts.append((name.strip(), float(weight_str.strip())))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Bare voice id — assume full weight
|
||||||
|
if token in VOICES_INTERNAL:
|
||||||
|
parts.append((token, 1.0))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Number formatting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def format_number(n: int) -> str:
|
||||||
|
"""
|
||||||
|
Format an integer with thousands separators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n: Integer value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string, e.g. ``"1,234,567"``.
|
||||||
|
"""
|
||||||
|
return f"{n:,}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def safe_basename(path: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
Return the basename of a path, or an empty string when path is None/empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Optional file-system path.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def output_format_label(fmt: str) -> str:
|
||||||
|
"""
|
||||||
|
Return a display label for an audio output format key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fmt: Lowercase format key (``'wav'``, ``'mp3'``, …).
|
||||||
|
"""
|
||||||
|
labels = {
|
||||||
|
"wav": "WAV (lossless)",
|
||||||
|
"flac": "FLAC (lossless compressed)",
|
||||||
|
"mp3": "MP3",
|
||||||
|
"opus": "Opus (best compression)",
|
||||||
|
"m4b": "M4B (with chapters)",
|
||||||
|
}
|
||||||
|
return labels.get(fmt, fmt.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def subtitle_format_label(key: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the display label for a subtitle format key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Internal subtitle format key (e.g. ``'ass_centered_narrow'``).
|
||||||
|
"""
|
||||||
|
for k, label in SUBTITLE_FORMATS:
|
||||||
|
if k == key:
|
||||||
|
return label
|
||||||
|
return key
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
Design tokens and theme configuration for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
This module defines the application's complete colour palette, typography
|
||||||
|
scale, spacing constants, and border radii in one canonical place.
|
||||||
|
All component modules import from here; changing a value here propagates
|
||||||
|
instantly across the entire UI.
|
||||||
|
|
||||||
|
Flet's ``ft.Theme`` uses ``ColorScheme``, but for custom widgets we paint
|
||||||
|
directly with hex colours drawn from ``LIGHT`` and ``DARK`` palettes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour palettes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Palette:
|
||||||
|
"""A complete colour palette for one theme mode."""
|
||||||
|
|
||||||
|
# Backgrounds
|
||||||
|
bg_base: str # Deepest background (window / page)
|
||||||
|
bg_surface: str # Cards, panels, dialogs
|
||||||
|
bg_elevated: str # Slightly raised elements (toolbar, sidebar)
|
||||||
|
bg_input: str # Text-field / dropdown backgrounds
|
||||||
|
|
||||||
|
# Brand accent
|
||||||
|
accent: str # Primary interactive colour (buttons, links)
|
||||||
|
accent_muted: str # Hover tint over accents
|
||||||
|
accent_on: str # Text drawn on top of accent fills
|
||||||
|
|
||||||
|
# Semantic
|
||||||
|
success: str
|
||||||
|
error: str
|
||||||
|
warning: str
|
||||||
|
info: str
|
||||||
|
|
||||||
|
# Text hierarchy
|
||||||
|
text_primary: str
|
||||||
|
text_secondary: str
|
||||||
|
text_disabled: str
|
||||||
|
text_on_accent: str
|
||||||
|
|
||||||
|
# Borders / dividers
|
||||||
|
border: str
|
||||||
|
border_focused: str
|
||||||
|
divider: str
|
||||||
|
|
||||||
|
# Specific UI atoms
|
||||||
|
drop_zone_border: str
|
||||||
|
drop_zone_bg: str
|
||||||
|
drop_zone_active_border: str
|
||||||
|
drop_zone_active_bg: str
|
||||||
|
log_bg: str
|
||||||
|
log_text: str
|
||||||
|
progress_bar_bg: str
|
||||||
|
progress_bar_fill: str
|
||||||
|
sidebar_bg: str
|
||||||
|
sidebar_selected_bg: str
|
||||||
|
sidebar_selected_text: str
|
||||||
|
nav_indicator: str
|
||||||
|
|
||||||
|
|
||||||
|
DARK = _Palette(
|
||||||
|
bg_base="#0f1117",
|
||||||
|
bg_surface="#181b23",
|
||||||
|
bg_elevated="#1e2230",
|
||||||
|
bg_input="#252a38",
|
||||||
|
|
||||||
|
accent="#5b8af5",
|
||||||
|
accent_muted="#3a5fc4",
|
||||||
|
accent_on="#ffffff",
|
||||||
|
|
||||||
|
success="#42ad4a",
|
||||||
|
error="#e84e3c",
|
||||||
|
warning="#f5a623",
|
||||||
|
info="#5b8af5",
|
||||||
|
|
||||||
|
text_primary="#e8eaf0",
|
||||||
|
text_secondary="#9ba3b8",
|
||||||
|
text_disabled="#4e5568",
|
||||||
|
text_on_accent="#ffffff",
|
||||||
|
|
||||||
|
border="#2c3147",
|
||||||
|
border_focused="#5b8af5",
|
||||||
|
divider="#252a38",
|
||||||
|
|
||||||
|
drop_zone_border="#3a4466",
|
||||||
|
drop_zone_bg="#151928",
|
||||||
|
drop_zone_active_border="#42ad4a",
|
||||||
|
drop_zone_active_bg="#0d1f10",
|
||||||
|
log_bg="#0d1117",
|
||||||
|
log_text="#b0b8cc",
|
||||||
|
progress_bar_bg="#1e2230",
|
||||||
|
progress_bar_fill="#5b8af5",
|
||||||
|
sidebar_bg="#13161f",
|
||||||
|
sidebar_selected_bg="#252a38",
|
||||||
|
sidebar_selected_text="#5b8af5",
|
||||||
|
nav_indicator="#5b8af5",
|
||||||
|
)
|
||||||
|
|
||||||
|
LIGHT = _Palette(
|
||||||
|
bg_base="#f4f5f8",
|
||||||
|
bg_surface="#ffffff",
|
||||||
|
bg_elevated="#edf0f5",
|
||||||
|
bg_input="#f0f2f7",
|
||||||
|
|
||||||
|
accent="#3a5fc4",
|
||||||
|
accent_muted="#2a4fae",
|
||||||
|
accent_on="#ffffff",
|
||||||
|
|
||||||
|
success="#2e9437",
|
||||||
|
error="#c0392b",
|
||||||
|
warning="#d4870a",
|
||||||
|
info="#3a5fc4",
|
||||||
|
|
||||||
|
text_primary="#1a1d27",
|
||||||
|
text_secondary="#5a6172",
|
||||||
|
text_disabled="#9ba3b8",
|
||||||
|
text_on_accent="#ffffff",
|
||||||
|
|
||||||
|
border="#dce0ea",
|
||||||
|
border_focused="#3a5fc4",
|
||||||
|
divider="#e8ebf2",
|
||||||
|
|
||||||
|
drop_zone_border="#a8b4d0",
|
||||||
|
drop_zone_bg="#f7f8fd",
|
||||||
|
drop_zone_active_border="#2e9437",
|
||||||
|
drop_zone_active_bg="#f0fff1",
|
||||||
|
log_bg="#f8f9fc",
|
||||||
|
log_text="#3d4358",
|
||||||
|
progress_bar_bg="#e4e8f0",
|
||||||
|
progress_bar_fill="#3a5fc4",
|
||||||
|
sidebar_bg="#eff1f5",
|
||||||
|
sidebar_selected_bg="#dde3f2",
|
||||||
|
sidebar_selected_text="#3a5fc4",
|
||||||
|
nav_indicator="#3a5fc4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typography
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FONT_FAMILY = "Inter, Segoe UI, Roboto, system-ui, sans-serif"
|
||||||
|
FONT_SIZE_XS = 11
|
||||||
|
FONT_SIZE_SM = 12
|
||||||
|
FONT_SIZE_BASE = 14
|
||||||
|
FONT_SIZE_MD = 16
|
||||||
|
FONT_SIZE_LG = 20
|
||||||
|
FONT_SIZE_XL = 26
|
||||||
|
FONT_SIZE_DISPLAY = 34
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spacing scale (pixels)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SPACE_XS = 4
|
||||||
|
SPACE_SM = 8
|
||||||
|
SPACE_MD = 12
|
||||||
|
SPACE_LG = 16
|
||||||
|
SPACE_XL = 24
|
||||||
|
SPACE_2XL = 32
|
||||||
|
SPACE_3XL = 48
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Border radii
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RADIUS_SM = 6
|
||||||
|
RADIUS_MD = 10
|
||||||
|
RADIUS_LG = 16
|
||||||
|
RADIUS_FULL = 999 # Pill-shaped
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flet ColorScheme builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_color_scheme(palette: _Palette) -> ft.ColorScheme:
|
||||||
|
"""
|
||||||
|
Construct a ``ft.ColorScheme`` from a ``_Palette`` object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
palette: The ``DARK`` or ``LIGHT`` palette.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully-populated Flet ``ColorScheme``.
|
||||||
|
"""
|
||||||
|
return ft.ColorScheme(
|
||||||
|
primary=palette.accent,
|
||||||
|
on_primary=palette.accent_on,
|
||||||
|
primary_container=palette.accent_muted,
|
||||||
|
secondary=palette.accent,
|
||||||
|
on_secondary=palette.text_on_accent,
|
||||||
|
surface=palette.bg_surface,
|
||||||
|
on_surface=palette.text_primary,
|
||||||
|
on_surface_variant=palette.text_secondary,
|
||||||
|
error=palette.error,
|
||||||
|
on_error=palette.text_on_accent,
|
||||||
|
outline=palette.border,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_text_theme() -> ft.TextTheme:
|
||||||
|
"""
|
||||||
|
Construct a ``ft.TextTheme`` using the application's type scale.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A Flet ``TextTheme`` with consistent font-size assignments.
|
||||||
|
"""
|
||||||
|
return ft.TextTheme(
|
||||||
|
display_large=ft.TextStyle(size=FONT_SIZE_DISPLAY, weight=ft.FontWeight.W_700),
|
||||||
|
headline_large=ft.TextStyle(size=FONT_SIZE_XL, weight=ft.FontWeight.W_700),
|
||||||
|
headline_medium=ft.TextStyle(size=FONT_SIZE_LG, weight=ft.FontWeight.W_600),
|
||||||
|
title_large=ft.TextStyle(size=FONT_SIZE_MD, weight=ft.FontWeight.W_600),
|
||||||
|
title_medium=ft.TextStyle(size=FONT_SIZE_BASE, weight=ft.FontWeight.W_500),
|
||||||
|
body_large=ft.TextStyle(size=FONT_SIZE_BASE),
|
||||||
|
body_medium=ft.TextStyle(size=FONT_SIZE_SM),
|
||||||
|
label_large=ft.TextStyle(size=FONT_SIZE_SM, weight=ft.FontWeight.W_500),
|
||||||
|
label_medium=ft.TextStyle(size=FONT_SIZE_XS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_theme(dark: bool) -> ft.Theme:
|
||||||
|
"""
|
||||||
|
Build a complete Flet ``Theme`` for the requested mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dark: True for dark-mode theme, False for light-mode theme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured ``ft.Theme`` instance.
|
||||||
|
"""
|
||||||
|
palette = DARK if dark else LIGHT
|
||||||
|
return ft.Theme(
|
||||||
|
color_scheme=build_color_scheme(palette),
|
||||||
|
text_theme=build_text_theme(),
|
||||||
|
color_scheme_seed=palette.accent,
|
||||||
|
use_material3=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_palette(page: ft.Page) -> _Palette:
|
||||||
|
"""
|
||||||
|
Return the active colour palette for the given page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: The Flet ``Page`` instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``DARK`` or ``LIGHT`` depending on the page's theme mode.
|
||||||
|
"""
|
||||||
|
return DARK if page.theme_mode == ft.ThemeMode.DARK else LIGHT
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Views sub-package for the Abogen Flet frontend."""
|
||||||
|
from .dashboard import DashboardView
|
||||||
|
from .settings import SettingsView
|
||||||
|
from .queue_view import QueueView
|
||||||
|
|
||||||
|
__all__ = ["DashboardView", "SettingsView", "QueueView"]
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
"""
|
||||||
|
Dashboard view – the primary conversion screen.
|
||||||
|
|
||||||
|
Hosts the file drop-zone, voice/speed/format controls, real-time log
|
||||||
|
terminal, progress bar, and the Start/Cancel/Finish action row.
|
||||||
|
|
||||||
|
All heavy work is delegated to ConversionBridge which runs on daemon
|
||||||
|
threads and schedules UI updates back onto the Flet event loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..state import AppState
|
||||||
|
from ..utils.helpers import (
|
||||||
|
detect_file_type, human_readable_size, format_number,
|
||||||
|
format_etr, grouped_voices, output_format_label,
|
||||||
|
subtitle_format_label, is_book_type, voice_lang_code, SUPPORTED_EXTENSIONS
|
||||||
|
)
|
||||||
|
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG, SPACE_XL
|
||||||
|
from ..utils.conversion_bridge import ConversionBridge
|
||||||
|
from ..components import (
|
||||||
|
build_drop_zone, build_log_terminal, log_entry,
|
||||||
|
build_primary_button, build_secondary_button,
|
||||||
|
build_card, build_section_header, labelled_row, show_snack,
|
||||||
|
)
|
||||||
|
from abogen.constants import (
|
||||||
|
SUBTITLE_FORMATS, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
|
LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL,
|
||||||
|
)
|
||||||
|
from abogen.utils import get_gpu_acceleration, get_user_cache_path, calculate_text_length, clean_text
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView:
|
||||||
|
"""
|
||||||
|
The main conversion dashboard.
|
||||||
|
|
||||||
|
Instantiated once per Flet session and mounted as a ``ft.Column``
|
||||||
|
inside the page's content area.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
self._bridge = ConversionBridge(page, state)
|
||||||
|
|
||||||
|
# Internal refs
|
||||||
|
self._log_list: Optional[ft.ListView] = None
|
||||||
|
self._progress_bar: Optional[ft.ProgressBar] = None
|
||||||
|
self._etr_label: Optional[ft.Text] = None
|
||||||
|
self._drop_zone_ref: Optional[ft.GestureDetector] = None
|
||||||
|
self._drop_zone_container: Optional[ft.Container] = None
|
||||||
|
self._file_picker: Optional[ft.FilePicker] = None
|
||||||
|
|
||||||
|
# Wire state callbacks
|
||||||
|
state.on_log = self._on_log
|
||||||
|
state.on_progress = self._on_progress
|
||||||
|
state.on_conversion_finished = self._on_finished
|
||||||
|
|
||||||
|
# Build UI refs
|
||||||
|
self._voice_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._speed_slider: Optional[ft.Slider] = None
|
||||||
|
self._speed_label: Optional[ft.Text] = None
|
||||||
|
self._format_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._subtitle_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._subtitle_fmt_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._gpu_switch: Optional[ft.Switch] = None
|
||||||
|
self._start_btn: Optional[ft.ElevatedButton] = None
|
||||||
|
self._cancel_btn: Optional[ft.OutlinedButton] = None
|
||||||
|
self._finish_col: Optional[ft.Column] = None
|
||||||
|
self._controls_col: Optional[ft.Column] = None
|
||||||
|
self._log_section: Optional[ft.Container] = None
|
||||||
|
self._progress_col: Optional[ft.Column] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Build
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build(self) -> ft.Column:
|
||||||
|
"""Return the complete dashboard column."""
|
||||||
|
p = self._page
|
||||||
|
dark = p.theme_mode == ft.ThemeMode.DARK
|
||||||
|
pal = get_palette(p)
|
||||||
|
if self._file_picker is None:
|
||||||
|
self._file_picker = ft.FilePicker()
|
||||||
|
|
||||||
|
# --- Drop zone ---
|
||||||
|
self._drop_zone_container = ft.Container()
|
||||||
|
self._refresh_drop_zone()
|
||||||
|
|
||||||
|
# --- Voice selector ---
|
||||||
|
voice_items = []
|
||||||
|
for lang_label, voices in grouped_voices():
|
||||||
|
voice_items.append(ft.dropdown.Option(key=f"__hdr_{lang_label}", text=f"── {lang_label} ──", disabled=True))
|
||||||
|
for v in voices:
|
||||||
|
voice_items.append(ft.dropdown.Option(key=v, text=v))
|
||||||
|
|
||||||
|
self._voice_dd = ft.Dropdown(
|
||||||
|
options=voice_items,
|
||||||
|
value=self._state.selected_voice,
|
||||||
|
on_select=self._on_voice_changed,
|
||||||
|
dense=True,
|
||||||
|
expand=True,
|
||||||
|
border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Speed slider ---
|
||||||
|
self._speed_label = ft.Text(f"{self._state.speed:.2f}", size=13, width=40)
|
||||||
|
self._speed_slider = ft.Slider(
|
||||||
|
min=0.1, max=2.0, value=self._state.speed,
|
||||||
|
divisions=190, label="{value}",
|
||||||
|
on_change=self._on_speed_changed,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Format ---
|
||||||
|
self._format_dd = ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(key=k, text=output_format_label(k))
|
||||||
|
for k in ("wav", "flac", "mp3", "opus", "m4b")],
|
||||||
|
value=self._state.selected_format,
|
||||||
|
on_select=lambda e: self._set_field("selected_format", e.control.value),
|
||||||
|
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Subtitle mode ---
|
||||||
|
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||||
|
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
|
||||||
|
self._subtitle_dd = ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(m) for m in sub_modes],
|
||||||
|
value=self._state.subtitle_mode,
|
||||||
|
on_select=lambda e: self._set_field("subtitle_mode", e.control.value),
|
||||||
|
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Subtitle format ---
|
||||||
|
self._subtitle_fmt_dd = ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(key=k, text=lbl) for k, lbl in SUBTITLE_FORMATS],
|
||||||
|
value=self._state.subtitle_format,
|
||||||
|
on_select=lambda e: self._set_field("subtitle_format", e.control.value),
|
||||||
|
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- GPU ---
|
||||||
|
self._gpu_switch = ft.Switch(
|
||||||
|
value=self._state.use_gpu, label="",
|
||||||
|
on_change=lambda e: self._set_field("use_gpu", e.control.value),
|
||||||
|
active_color="#5b8af5" if dark else "#3a5fc4",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Log ---
|
||||||
|
log_lv = ft.ListView(expand=True, auto_scroll=True, spacing=1, padding=ft.Padding.all(8))
|
||||||
|
self._log_list = log_lv
|
||||||
|
bg_log = "#0d1117" if dark else "#f8f9fc"
|
||||||
|
bd_log = "#252a38" if dark else "#dce0ea"
|
||||||
|
self._log_section = ft.Container(
|
||||||
|
content=log_lv, bgcolor=bg_log,
|
||||||
|
border=ft.Border.all(1, bd_log),
|
||||||
|
border_radius=RADIUS_SM, height=220,
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Progress ---
|
||||||
|
fill = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
bg_p = "#1e2230" if dark else "#e4e8f0"
|
||||||
|
self._progress_bar = ft.ProgressBar(
|
||||||
|
value=0, color=fill, bgcolor=bg_p, height=8,
|
||||||
|
border_radius=ft.BorderRadius.all(4), expand=True,
|
||||||
|
)
|
||||||
|
self._etr_label = ft.Text("", size=11, color=pal.text_secondary, text_align=ft.TextAlign.CENTER)
|
||||||
|
self._progress_col = ft.Column([
|
||||||
|
ft.Row([self._progress_bar], spacing=0),
|
||||||
|
self._etr_label,
|
||||||
|
], spacing=SPACE_SM, horizontal_alignment=ft.CrossAxisAlignment.CENTER, visible=False)
|
||||||
|
|
||||||
|
# --- Buttons ---
|
||||||
|
self._start_btn = build_primary_button(
|
||||||
|
"Start Conversion",
|
||||||
|
icon="play_arrow",
|
||||||
|
on_click=self._on_start,
|
||||||
|
page=p,
|
||||||
|
)
|
||||||
|
self._cancel_btn = build_secondary_button(
|
||||||
|
"Cancel", icon="stop",
|
||||||
|
on_click=self._on_cancel, page=p,
|
||||||
|
)
|
||||||
|
self._cancel_btn.visible = False
|
||||||
|
|
||||||
|
# --- Finish row ---
|
||||||
|
self._finish_col = ft.Column([
|
||||||
|
ft.Row([
|
||||||
|
build_secondary_button("Open File", icon="open_in_new",
|
||||||
|
on_click=self._on_open_file, page=p),
|
||||||
|
build_secondary_button("Go to Folder", icon="folder_open",
|
||||||
|
on_click=self._on_go_folder, page=p),
|
||||||
|
build_secondary_button("New Conversion", icon="refresh",
|
||||||
|
on_click=self._on_reset, page=p),
|
||||||
|
], wrap=True, spacing=SPACE_SM, run_spacing=SPACE_SM),
|
||||||
|
], visible=False)
|
||||||
|
|
||||||
|
# --- Controls column ---
|
||||||
|
self._controls_col = ft.Column([
|
||||||
|
build_section_header("Voice & Speed", icon="record_voice_over", page=p),
|
||||||
|
labelled_row("Voice", self._voice_dd, page=p),
|
||||||
|
labelled_row("Speed", ft.Row([self._speed_slider, self._speed_label], expand=True, spacing=SPACE_SM), page=p),
|
||||||
|
ft.Divider(height=1, color=pal.divider),
|
||||||
|
build_section_header("Output", icon="audio_file", page=p),
|
||||||
|
labelled_row("Format", self._format_dd, page=p),
|
||||||
|
labelled_row("Subtitles", self._subtitle_dd, page=p),
|
||||||
|
labelled_row("Subtitle Format", self._subtitle_fmt_dd, page=p),
|
||||||
|
ft.Divider(height=1, color=pal.divider),
|
||||||
|
build_section_header("Processing", icon="memory", page=p),
|
||||||
|
labelled_row("GPU Acceleration", self._gpu_switch, page=p),
|
||||||
|
], spacing=SPACE_MD)
|
||||||
|
|
||||||
|
outer = ft.Column([
|
||||||
|
self._drop_zone_container,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
build_card(self._controls_col, page=p),
|
||||||
|
ft.Container(height=SPACE_SM),
|
||||||
|
self._log_section,
|
||||||
|
self._progress_col,
|
||||||
|
ft.Row([self._start_btn, self._cancel_btn], spacing=SPACE_SM, wrap=True),
|
||||||
|
self._finish_col,
|
||||||
|
], spacing=SPACE_MD, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||||
|
|
||||||
|
return outer
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Drop-zone management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_drop_zone(self, *, accent: bool = False, error: bool = False, err_msg: str = "") -> None:
|
||||||
|
"""Rebuild the drop-zone widget and update its container."""
|
||||||
|
p = self._page
|
||||||
|
s = self._state
|
||||||
|
fname = None; fsize = None; fchars = None
|
||||||
|
if s.selected_file and os.path.exists(s.selected_file):
|
||||||
|
disp = s.displayed_file_path or s.selected_file
|
||||||
|
fname = os.path.basename(disp)
|
||||||
|
try:
|
||||||
|
fsize = human_readable_size(os.path.getsize(s.selected_file))
|
||||||
|
except Exception:
|
||||||
|
fsize = ""
|
||||||
|
if s.char_count:
|
||||||
|
fchars = format_number(s.char_count)
|
||||||
|
|
||||||
|
label = err_msg if error else "Drag & drop your file here or click to browse"
|
||||||
|
sub = "Supports .txt · .epub · .pdf · .md · .srt · .ass · .vtt"
|
||||||
|
|
||||||
|
dz = build_drop_zone(
|
||||||
|
on_pick=self._open_file_picker,
|
||||||
|
label=label, sub_label=sub,
|
||||||
|
accent=accent, error=error,
|
||||||
|
filename=fname, file_size=fsize, char_count=fchars,
|
||||||
|
page=p,
|
||||||
|
)
|
||||||
|
if self._drop_zone_container is not None:
|
||||||
|
self._drop_zone_container.content = dz
|
||||||
|
self._drop_zone_ref = dz
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# File picking
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_file_picker(self) -> None:
|
||||||
|
"""Open the native file picker dialog."""
|
||||||
|
self._page.run_task(self._pick_files_async)
|
||||||
|
|
||||||
|
async def _pick_files_async(self) -> None:
|
||||||
|
"""Run the file picker using Flet's async service API."""
|
||||||
|
picker = self._file_picker
|
||||||
|
if picker is None:
|
||||||
|
picker = ft.FilePicker()
|
||||||
|
self._file_picker = picker
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = await picker.pick_files(
|
||||||
|
dialog_title="Select Input File",
|
||||||
|
file_type=ft.FilePickerFileType.CUSTOM,
|
||||||
|
allowed_extensions=["txt", "epub", "pdf", "md", "markdown", "srt", "ass", "vtt"],
|
||||||
|
allow_multiple=False,
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
self._refresh_drop_zone(error=True, err_msg="Could not open file picker.")
|
||||||
|
show_snack(self._page, f"File picker error: {ex}", error=True)
|
||||||
|
self._page.update()
|
||||||
|
return
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
file_path = files[0].path
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
return
|
||||||
|
self._load_file(file_path)
|
||||||
|
|
||||||
|
def _load_file(self, file_path: str) -> None:
|
||||||
|
"""Validate and load a file into the session state."""
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
ext = _Path(file_path).suffix.lower()
|
||||||
|
if ext not in SUPPORTED_EXTENSIONS:
|
||||||
|
self._state.reset_file_state()
|
||||||
|
self._refresh_drop_zone(error=True, err_msg=f"Unsupported file type: {ext}")
|
||||||
|
self._page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
ftype = detect_file_type(file_path)
|
||||||
|
s = self._state
|
||||||
|
|
||||||
|
if ftype in ("epub", "pdf", "markdown"):
|
||||||
|
# For book types: extract text to temp cache
|
||||||
|
self._handle_book_file(file_path, ftype)
|
||||||
|
else:
|
||||||
|
# Plain text / subtitle files
|
||||||
|
s.selected_file = file_path
|
||||||
|
s.selected_file_type = ftype
|
||||||
|
s.displayed_file_path = file_path
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
text = f.read()
|
||||||
|
s.char_count = calculate_text_length(clean_text(text))
|
||||||
|
except Exception:
|
||||||
|
s.char_count = 0
|
||||||
|
self._refresh_drop_zone(accent=True)
|
||||||
|
self._update_subtitle_availability()
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _handle_book_file(self, book_path: str, ftype: str) -> None:
|
||||||
|
"""Extract text from epub/pdf/markdown and store as temp txt."""
|
||||||
|
import threading as _t
|
||||||
|
s = self._state
|
||||||
|
|
||||||
|
def _extract():
|
||||||
|
try:
|
||||||
|
from abogen.text_extractor import extract_from_path
|
||||||
|
chapters = extract_from_path(book_path, file_type=ftype)
|
||||||
|
combined = "\n\n".join(ch.text for ch in chapters if ch.text.strip())
|
||||||
|
cache_dir = get_user_cache_path()
|
||||||
|
base = os.path.splitext(os.path.basename(book_path))[0]
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=f"{base}_", suffix=".txt", dir=cache_dir)
|
||||||
|
os.close(fd)
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(combined)
|
||||||
|
|
||||||
|
s.selected_file = tmp
|
||||||
|
s.selected_file_type = ftype
|
||||||
|
s.selected_book_path = book_path
|
||||||
|
s.displayed_file_path = book_path
|
||||||
|
s.char_count = calculate_text_length(clean_text(combined))
|
||||||
|
s.selected_chapters = [f"ch_{i}" for i in range(len(chapters))]
|
||||||
|
|
||||||
|
self._refresh_drop_zone(accent=True)
|
||||||
|
self._update_subtitle_availability()
|
||||||
|
self._page.update()
|
||||||
|
except Exception as ex:
|
||||||
|
s.reset_file_state()
|
||||||
|
self._refresh_drop_zone(error=True, err_msg=f"Could not parse file: {ex}")
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
_t.Thread(target=_extract, daemon=True).start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Control event handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_field(self, attr: str, value) -> None:
|
||||||
|
setattr(self._state, attr, value)
|
||||||
|
self._state.persist_config()
|
||||||
|
|
||||||
|
def _on_voice_changed(self, e: ft.ControlEvent) -> None:
|
||||||
|
v = e.control.value or "af_heart"
|
||||||
|
self._state.selected_voice = v
|
||||||
|
self._state.selected_lang = voice_lang_code(v)
|
||||||
|
self._state.persist_config()
|
||||||
|
self._update_subtitle_availability()
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _on_speed_changed(self, e: ft.ControlEvent) -> None:
|
||||||
|
val = round(float(e.control.value), 2)
|
||||||
|
self._state.speed = val
|
||||||
|
if self._speed_label:
|
||||||
|
self._speed_label.value = f"{val:.2f}"
|
||||||
|
self._state.persist_config()
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _update_subtitle_availability(self) -> None:
|
||||||
|
"""Enable or disable subtitle controls based on selected language."""
|
||||||
|
lang = self._state.selected_lang
|
||||||
|
enabled = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||||
|
if self._subtitle_dd:
|
||||||
|
self._subtitle_dd.disabled = not enabled
|
||||||
|
if self._subtitle_fmt_dd:
|
||||||
|
self._subtitle_fmt_dd.disabled = not enabled
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conversion control
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_start(self, _: ft.ControlEvent) -> None:
|
||||||
|
"""Validate inputs and kick off conversion."""
|
||||||
|
s = self._state
|
||||||
|
if not s.selected_file or not os.path.exists(s.selected_file):
|
||||||
|
self._refresh_drop_zone(error=True, err_msg="Please select an input file first.")
|
||||||
|
self._page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transition UI to converting state
|
||||||
|
self._set_converting_ui(True)
|
||||||
|
|
||||||
|
self._bridge.start(
|
||||||
|
input_file=s.selected_file,
|
||||||
|
voice=s.get_voice_formula(),
|
||||||
|
lang_code=s.selected_lang,
|
||||||
|
speed=s.speed,
|
||||||
|
output_format=s.selected_format,
|
||||||
|
subtitle_mode=s.subtitle_mode,
|
||||||
|
subtitle_format=s.subtitle_format,
|
||||||
|
use_gpu=s.use_gpu,
|
||||||
|
save_option=s.save_option,
|
||||||
|
output_folder=s.selected_output_folder,
|
||||||
|
replace_single_newlines=s.replace_single_newlines,
|
||||||
|
char_count=s.char_count,
|
||||||
|
save_chapters_separately=s.save_chapters_separately or False,
|
||||||
|
merge_chapters_at_end=True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
|
||||||
|
separate_chapters_format=s.separate_chapters_format,
|
||||||
|
silence_between_chapters=s.silence_duration,
|
||||||
|
max_subtitle_words=s.max_subtitle_words,
|
||||||
|
chapter_intro_delay=s.chapter_intro_delay,
|
||||||
|
read_title_intro=s.read_title_intro,
|
||||||
|
read_closing_outro=s.read_closing_outro,
|
||||||
|
auto_prefix_chapter_titles=s.auto_prefix_chapter_titles,
|
||||||
|
normalize_chapter_opening_caps=s.normalize_chapter_opening_caps,
|
||||||
|
tts_provider=s.tts_provider,
|
||||||
|
supertonic_total_steps=s.supertonic_total_steps,
|
||||||
|
chunk_level=s.chunk_level,
|
||||||
|
generate_epub3=s.generate_epub3,
|
||||||
|
word_substitutions_enabled=s.word_substitutions_enabled,
|
||||||
|
word_substitutions_list=s.word_substitutions_list,
|
||||||
|
case_sensitive_substitutions=s.case_sensitive_substitutions,
|
||||||
|
replace_all_caps=s.replace_all_caps,
|
||||||
|
replace_numerals=s.replace_numerals,
|
||||||
|
fix_nonstandard_punctuation=s.fix_nonstandard_punctuation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cancel(self, _: ft.ControlEvent) -> None:
|
||||||
|
self._bridge.cancel()
|
||||||
|
|
||||||
|
def _set_converting_ui(self, converting: bool) -> None:
|
||||||
|
"""Toggle UI between idle and converting states."""
|
||||||
|
if self._start_btn:
|
||||||
|
self._start_btn.visible = not converting
|
||||||
|
if self._cancel_btn:
|
||||||
|
self._cancel_btn.visible = converting
|
||||||
|
if self._controls_col:
|
||||||
|
self._controls_col.visible = not converting
|
||||||
|
if self._log_section:
|
||||||
|
self._log_section.visible = converting
|
||||||
|
if self._log_list:
|
||||||
|
self._log_list.controls.clear()
|
||||||
|
if self._progress_col:
|
||||||
|
self._progress_col.visible = converting
|
||||||
|
if self._progress_bar:
|
||||||
|
self._progress_bar.value = 0
|
||||||
|
if self._etr_label:
|
||||||
|
self._etr_label.value = "Estimating…"
|
||||||
|
if self._finish_col:
|
||||||
|
self._finish_col.visible = False
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# State callbacks (called from background thread via page.run_task)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_log(self, message: str, level: str) -> None:
|
||||||
|
if self._log_list is None:
|
||||||
|
return
|
||||||
|
entry = log_entry(message, level, self._page)
|
||||||
|
self._log_list.controls.append(entry)
|
||||||
|
# Cap log lines
|
||||||
|
if len(self._log_list.controls) > 2000:
|
||||||
|
self._log_list.controls = self._log_list.controls[-1800:]
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_progress(self, fraction: float, etr: Optional[float]) -> None:
|
||||||
|
if self._progress_bar:
|
||||||
|
self._progress_bar.value = min(fraction, 0.99)
|
||||||
|
if self._etr_label:
|
||||||
|
self._etr_label.value = format_etr(etr)
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_finished(self, message: str, output_path: Optional[str]) -> None:
|
||||||
|
if self._progress_bar:
|
||||||
|
self._progress_bar.value = 1.0
|
||||||
|
if self._cancel_btn:
|
||||||
|
self._cancel_btn.visible = False
|
||||||
|
|
||||||
|
if message == "Cancelled":
|
||||||
|
# Restore idle state
|
||||||
|
self._set_converting_ui(False)
|
||||||
|
show_snack(self._page, "Conversion cancelled.", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "failed" in message.lower() or "error" in message.lower():
|
||||||
|
self._log_on_log(message, "error")
|
||||||
|
self._set_converting_ui(False)
|
||||||
|
show_snack(self._page, f"Error: {message}", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Success
|
||||||
|
if self._log_section:
|
||||||
|
self._log_section.visible = True
|
||||||
|
if self._progress_col:
|
||||||
|
self._progress_col.visible = False
|
||||||
|
if self._controls_col:
|
||||||
|
self._controls_col.visible = False
|
||||||
|
if self._finish_col:
|
||||||
|
self._finish_col.visible = True
|
||||||
|
if self._start_btn:
|
||||||
|
self._start_btn.visible = False
|
||||||
|
show_snack(self._page, "Conversion completed!")
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _log_on_log(self, message: str, level: str) -> None:
|
||||||
|
self._on_log(message, level)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Finish actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_open_file(self, _: ft.ControlEvent) -> None:
|
||||||
|
path = self._state.last_output_path
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
import subprocess, platform
|
||||||
|
try:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
subprocess.Popen(["open", path])
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
os.startfile(path)
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", path])
|
||||||
|
except Exception as ex:
|
||||||
|
show_snack(self._page, f"Cannot open file: {ex}", error=True)
|
||||||
|
else:
|
||||||
|
show_snack(self._page, "Output file not found.", error=True)
|
||||||
|
|
||||||
|
def _on_go_folder(self, _: ft.ControlEvent) -> None:
|
||||||
|
path = self._state.last_output_path
|
||||||
|
folder = os.path.dirname(path) if path and os.path.isfile(path) else path
|
||||||
|
if folder and os.path.isdir(folder):
|
||||||
|
import subprocess, platform
|
||||||
|
try:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
subprocess.Popen(["open", folder])
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
subprocess.Popen(["explorer", folder])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", folder])
|
||||||
|
except Exception as ex:
|
||||||
|
show_snack(self._page, f"Cannot open folder: {ex}", error=True)
|
||||||
|
else:
|
||||||
|
show_snack(self._page, "Output folder not found.", error=True)
|
||||||
|
|
||||||
|
def _on_reset(self, _: ft.ControlEvent) -> None:
|
||||||
|
self._state.reset_file_state()
|
||||||
|
self._state.reset_conversion_state()
|
||||||
|
self._refresh_drop_zone()
|
||||||
|
self._set_converting_ui(False)
|
||||||
|
if self._finish_col:
|
||||||
|
self._finish_col.visible = False
|
||||||
|
if self._controls_col:
|
||||||
|
self._controls_col.visible = True
|
||||||
|
if self._start_btn:
|
||||||
|
self._start_btn.visible = True
|
||||||
|
self._page.update()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Queue management view.
|
||||||
|
|
||||||
|
Displays the current conversion queue, allowing the user to reorder,
|
||||||
|
remove, and inspect queued items before starting batch processing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..state import AppState, ConversionJob
|
||||||
|
from ..utils.theme import get_palette, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||||
|
from ..utils.helpers import safe_basename, output_format_label, format_number
|
||||||
|
from ..components import (
|
||||||
|
build_card, build_section_header, build_primary_button,
|
||||||
|
build_secondary_button, show_snack, build_divider,
|
||||||
|
resolve_icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueView:
|
||||||
|
"""Queue manager view."""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
self._list_col: Optional[ft.Column] = None
|
||||||
|
|
||||||
|
def build(self) -> ft.Column:
|
||||||
|
p = self._page
|
||||||
|
s = self._state
|
||||||
|
pal = get_palette(p)
|
||||||
|
dark = p.theme_mode == ft.ThemeMode.DARK
|
||||||
|
|
||||||
|
self._list_col = ft.Column(spacing=SPACE_SM)
|
||||||
|
self._refresh_list()
|
||||||
|
|
||||||
|
header = build_section_header("Conversion Queue",
|
||||||
|
icon="list_alt", page=p)
|
||||||
|
|
||||||
|
action_row = ft.Row([
|
||||||
|
build_primary_button(
|
||||||
|
"Start Queue",
|
||||||
|
icon="play_arrow",
|
||||||
|
on_click=self._on_start_queue,
|
||||||
|
page=p,
|
||||||
|
disabled=not s.queued_items,
|
||||||
|
),
|
||||||
|
build_secondary_button(
|
||||||
|
"Clear All",
|
||||||
|
icon="delete_sweep",
|
||||||
|
on_click=self._on_clear_queue,
|
||||||
|
page=p,
|
||||||
|
),
|
||||||
|
], spacing=SPACE_SM, wrap=True)
|
||||||
|
|
||||||
|
queue_card = build_card(ft.Column([
|
||||||
|
header,
|
||||||
|
ft.Divider(height=1, color=pal.divider),
|
||||||
|
self._list_col,
|
||||||
|
ft.Container(height=SPACE_SM),
|
||||||
|
action_row,
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
return ft.Column([queue_card], scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_list(self) -> None:
|
||||||
|
if self._list_col is None:
|
||||||
|
return
|
||||||
|
self._list_col.controls.clear()
|
||||||
|
s = self._state
|
||||||
|
pal = get_palette(self._page)
|
||||||
|
dark = self._page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
|
||||||
|
if not s.queued_items:
|
||||||
|
self._list_col.controls.append(
|
||||||
|
ft.Text("No items in the queue.", size=13,
|
||||||
|
color=pal.text_secondary,
|
||||||
|
text_align=ft.TextAlign.CENTER)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx, job in enumerate(s.queued_items):
|
||||||
|
tile = self._build_job_tile(idx, job, dark, pal)
|
||||||
|
self._list_col.controls.append(tile)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _build_job_tile(self, idx: int, job: ConversionJob, dark: bool, pal) -> ft.Container:
|
||||||
|
"""Build a single queue-item tile."""
|
||||||
|
bg = pal.bg_elevated
|
||||||
|
border_clr = pal.border
|
||||||
|
accent = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
text_primary = pal.text_primary
|
||||||
|
text_secondary = pal.text_secondary
|
||||||
|
|
||||||
|
def _remove(_):
|
||||||
|
self._state.queued_items.pop(idx)
|
||||||
|
self._refresh_list()
|
||||||
|
|
||||||
|
name = safe_basename(job.display_name or job.file_path)
|
||||||
|
details = (
|
||||||
|
f"Voice: {job.voice} · Format: {output_format_label(job.output_format)}"
|
||||||
|
f" · Speed: {job.speed:.2f}x · Chars: {format_number(job.char_count)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row([
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text(str(idx + 1), size=12, weight=ft.FontWeight.W_700,
|
||||||
|
color=accent),
|
||||||
|
width=32,
|
||||||
|
),
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(name, size=13, weight=ft.FontWeight.W_600, color=text_primary,
|
||||||
|
no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(details, size=11, color=text_secondary),
|
||||||
|
], expand=True, tight=True, spacing=2),
|
||||||
|
ft.IconButton(
|
||||||
|
icon=resolve_icon("delete_outline"),
|
||||||
|
icon_color=pal.error if hasattr(pal, "error") else "#e84e3c",
|
||||||
|
icon_size=18,
|
||||||
|
tooltip="Remove",
|
||||||
|
on_click=_remove,
|
||||||
|
),
|
||||||
|
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=SPACE_SM),
|
||||||
|
bgcolor=bg,
|
||||||
|
border=ft.Border.all(1, border_clr),
|
||||||
|
border_radius=RADIUS_SM,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=SPACE_SM),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_start_queue(self, _: ft.ControlEvent) -> None:
|
||||||
|
if not self._state.queued_items:
|
||||||
|
show_snack(self._page, "Queue is empty.", error=True)
|
||||||
|
return
|
||||||
|
# Navigate to dashboard and trigger queue start
|
||||||
|
# This is wired in main.py via the nav controller
|
||||||
|
self._page.pubsub.send_all("start_queue")
|
||||||
|
|
||||||
|
def _on_clear_queue(self, _: ft.ControlEvent) -> None:
|
||||||
|
if not self._state.queued_items:
|
||||||
|
return
|
||||||
|
self._state.queued_items.clear()
|
||||||
|
self._refresh_list()
|
||||||
|
show_snack(self._page, "Queue cleared.")
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Settings view – a categorised, scrollable settings page.
|
||||||
|
|
||||||
|
Groups settings into collapsible cards:
|
||||||
|
- Output (format, save location, chapters)
|
||||||
|
- Text processing (newlines, caps, substitutions, numerals)
|
||||||
|
- Subtitle options
|
||||||
|
- TTS pipeline (provider, GPU, chunking)
|
||||||
|
- Integrations (Audiobookshelf, Calibre OPDS)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..state import AppState
|
||||||
|
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||||
|
from ..utils.helpers import output_format_label, subtitle_format_label, SUPPORTED_EXTENSIONS
|
||||||
|
from ..components import (
|
||||||
|
build_card, build_section_header, labelled_row, show_snack, build_divider,
|
||||||
|
build_primary_button,
|
||||||
|
)
|
||||||
|
from abogen.constants import SUBTITLE_FORMATS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _dd(options, value, on_change, **kw):
|
||||||
|
"""Compact dropdown factory."""
|
||||||
|
return ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(key=k, text=v) for k, v in options],
|
||||||
|
value=value, on_select=on_change, dense=True,
|
||||||
|
border_radius=RADIUS_SM, expand=True, **kw
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sw(value, on_change, label=""):
|
||||||
|
return ft.Switch(value=value, on_change=on_change, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsView:
|
||||||
|
"""The full settings panel."""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
def build(self) -> ft.Column:
|
||||||
|
p = self._page
|
||||||
|
s = self._state
|
||||||
|
pal = get_palette(p)
|
||||||
|
|
||||||
|
# ── Output card ──────────────────────────────────────────────
|
||||||
|
format_dd = _dd(
|
||||||
|
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus", "m4b")],
|
||||||
|
s.selected_format,
|
||||||
|
lambda e: self._save("selected_format", e.control.value),
|
||||||
|
)
|
||||||
|
save_dd = _dd(
|
||||||
|
[
|
||||||
|
("Save next to input file", "Save next to input file"),
|
||||||
|
("Save to Desktop", "Save to Desktop"),
|
||||||
|
("Choose output folder", "Choose output folder"),
|
||||||
|
],
|
||||||
|
s.save_option,
|
||||||
|
lambda e: self._save("save_option", e.control.value),
|
||||||
|
)
|
||||||
|
chapters_sw = _sw(s.save_chapters_separately or False,
|
||||||
|
lambda e: self._save("save_chapters_separately", e.control.value))
|
||||||
|
merge_sw = _sw(True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
|
||||||
|
lambda e: self._save("merge_chapters_at_end", e.control.value))
|
||||||
|
sep_fmt_dd = _dd(
|
||||||
|
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus")],
|
||||||
|
s.separate_chapters_format,
|
||||||
|
lambda e: self._save("separate_chapters_format", e.control.value),
|
||||||
|
)
|
||||||
|
epub3_sw = _sw(s.generate_epub3, lambda e: self._save("generate_epub3", e.control.value))
|
||||||
|
|
||||||
|
output_card = build_card(ft.Column([
|
||||||
|
build_section_header("Output", icon="audio_file", page=p),
|
||||||
|
labelled_row("Audio Format", format_dd, page=p),
|
||||||
|
labelled_row("Save Location", save_dd, page=p),
|
||||||
|
build_divider(p),
|
||||||
|
labelled_row("Save Chapters Separately", chapters_sw, page=p),
|
||||||
|
labelled_row("Merge at End", merge_sw, page=p),
|
||||||
|
labelled_row("Chapter Format", sep_fmt_dd, page=p),
|
||||||
|
labelled_row("Generate EPUB3", epub3_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Text processing card ─────────────────────────────────────
|
||||||
|
newlines_sw = _sw(s.replace_single_newlines,
|
||||||
|
lambda e: self._save("replace_single_newlines", e.control.value))
|
||||||
|
caps_sw = _sw(s.replace_all_caps, lambda e: self._save("replace_all_caps", e.control.value))
|
||||||
|
norm_sw = _sw(s.normalize_chapter_opening_caps,
|
||||||
|
lambda e: self._save("normalize_chapter_opening_caps", e.control.value))
|
||||||
|
numerals_sw = _sw(s.replace_numerals, lambda e: self._save("replace_numerals", e.control.value))
|
||||||
|
punct_sw = _sw(s.fix_nonstandard_punctuation,
|
||||||
|
lambda e: self._save("fix_nonstandard_punctuation", e.control.value))
|
||||||
|
wordsub_sw = _sw(s.word_substitutions_enabled,
|
||||||
|
lambda e: self._save("word_substitutions_enabled", e.control.value))
|
||||||
|
wordsub_tf = ft.TextField(
|
||||||
|
value=s.word_substitutions_list,
|
||||||
|
multiline=True, min_lines=3, max_lines=6,
|
||||||
|
hint_text="word|replacement (one per line)",
|
||||||
|
on_change=lambda e: self._save("word_substitutions_list", e.control.value),
|
||||||
|
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||||
|
)
|
||||||
|
case_sw = _sw(s.case_sensitive_substitutions,
|
||||||
|
lambda e: self._save("case_sensitive_substitutions", e.control.value))
|
||||||
|
spacy_sw = _sw(s.use_spacy_segmentation,
|
||||||
|
lambda e: self._save("use_spacy_segmentation", e.control.value))
|
||||||
|
chunk_dd = _dd(
|
||||||
|
[("paragraph", "Paragraph"), ("sentence", "Sentence")],
|
||||||
|
s.chunk_level,
|
||||||
|
lambda e: self._save("chunk_level", e.control.value),
|
||||||
|
)
|
||||||
|
title_intro_sw = _sw(s.read_title_intro, lambda e: self._save("read_title_intro", e.control.value))
|
||||||
|
outro_sw = _sw(s.read_closing_outro, lambda e: self._save("read_closing_outro", e.control.value))
|
||||||
|
prefix_sw = _sw(s.auto_prefix_chapter_titles,
|
||||||
|
lambda e: self._save("auto_prefix_chapter_titles", e.control.value))
|
||||||
|
|
||||||
|
text_card = build_card(ft.Column([
|
||||||
|
build_section_header("Text Processing", icon="text_fields", page=p),
|
||||||
|
labelled_row("Replace Single Newlines", newlines_sw,
|
||||||
|
tooltip="Replace single newlines with spaces before processing.", page=p),
|
||||||
|
labelled_row("Replace ALL CAPS Words", caps_sw, page=p),
|
||||||
|
labelled_row("Normalize Opening CAPS", norm_sw, page=p),
|
||||||
|
labelled_row("Replace Numerals (spoken)", numerals_sw, page=p),
|
||||||
|
labelled_row("Fix Non-standard Punctuation", punct_sw, page=p),
|
||||||
|
build_divider(p),
|
||||||
|
labelled_row("Word Substitutions", wordsub_sw, page=p),
|
||||||
|
labelled_row("Case Sensitive", case_sw, page=p),
|
||||||
|
ft.Text("Substitution rules (word|replacement, one per line):",
|
||||||
|
size=12, color=pal.text_secondary),
|
||||||
|
wordsub_tf,
|
||||||
|
build_divider(p),
|
||||||
|
build_section_header("Chapter Options", icon="library_books", page=p),
|
||||||
|
labelled_row("Announce Book Title (intro)", title_intro_sw, page=p),
|
||||||
|
labelled_row("Announce Book Title (outro)", outro_sw, page=p),
|
||||||
|
labelled_row("Auto-prefix Chapter Titles", prefix_sw, page=p),
|
||||||
|
labelled_row("Chunk Level", chunk_dd, page=p),
|
||||||
|
labelled_row("Use spaCy Segmentation", spacy_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Subtitle card ─────────────────────────────────────────────
|
||||||
|
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||||
|
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
|
||||||
|
sub_mode_dd = _dd(
|
||||||
|
[(m, m) for m in sub_modes],
|
||||||
|
s.subtitle_mode,
|
||||||
|
lambda e: self._save("subtitle_mode", e.control.value),
|
||||||
|
)
|
||||||
|
sub_fmt_dd = _dd(
|
||||||
|
[(k, lbl) for k, lbl in SUBTITLE_FORMATS],
|
||||||
|
s.subtitle_format,
|
||||||
|
lambda e: self._save("subtitle_format", e.control.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_mw_slider():
|
||||||
|
lbl = ft.Text(str(s.max_subtitle_words), size=12, width=36)
|
||||||
|
sl = ft.Slider(
|
||||||
|
min=1, max=200, value=s.max_subtitle_words, divisions=199, label="{value}",
|
||||||
|
expand=True,
|
||||||
|
on_change=lambda e: (self._save("max_subtitle_words", int(e.control.value)),
|
||||||
|
setattr(lbl, "value", str(int(e.control.value))),
|
||||||
|
self._page.update()),
|
||||||
|
)
|
||||||
|
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
|
||||||
|
|
||||||
|
sub_speed_dd = _dd(
|
||||||
|
[("tts", "TTS duration"), ("silence", "Silence detection")],
|
||||||
|
s.subtitle_speed_method,
|
||||||
|
lambda e: self._save("subtitle_speed_method", e.control.value),
|
||||||
|
)
|
||||||
|
silent_gaps_sw = _sw(s.use_silent_gaps,
|
||||||
|
lambda e: self._save("use_silent_gaps", e.control.value))
|
||||||
|
|
||||||
|
subtitle_card = build_card(ft.Column([
|
||||||
|
build_section_header("Subtitles", icon="subtitles", page=p),
|
||||||
|
labelled_row("Mode", sub_mode_dd, page=p),
|
||||||
|
labelled_row("Format", sub_fmt_dd, page=p),
|
||||||
|
labelled_row("Max Words / Block", _mk_mw_slider(), page=p),
|
||||||
|
labelled_row("Speed Method", sub_speed_dd, page=p),
|
||||||
|
labelled_row("Silent Gaps", silent_gaps_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Pipeline card ─────────────────────────────────────────────
|
||||||
|
provider_dd = _dd(
|
||||||
|
[("kokoro", "Kokoro (default)"), ("supertonic", "Supertonic")],
|
||||||
|
s.tts_provider,
|
||||||
|
lambda e: self._save("tts_provider", e.control.value),
|
||||||
|
)
|
||||||
|
gpu_sw = _sw(s.use_gpu, lambda e: self._save("use_gpu", e.control.value),
|
||||||
|
label="GPU acceleration (if available)")
|
||||||
|
|
||||||
|
def _mk_steps_slider():
|
||||||
|
lbl = ft.Text(str(s.supertonic_total_steps), size=12, width=28)
|
||||||
|
sl = ft.Slider(
|
||||||
|
min=2, max=15, value=s.supertonic_total_steps, divisions=13,
|
||||||
|
label="{value}", expand=True,
|
||||||
|
on_change=lambda e: (self._save("supertonic_total_steps", int(e.control.value)),
|
||||||
|
setattr(lbl, "value", str(int(e.control.value))),
|
||||||
|
self._page.update()),
|
||||||
|
)
|
||||||
|
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
|
||||||
|
|
||||||
|
thresh_tf = ft.TextField(
|
||||||
|
value=str(s.speaker_analysis_threshold), width=80,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||||
|
on_change=lambda e: self._save_int("speaker_analysis_threshold", e.control.value, 1, 25),
|
||||||
|
)
|
||||||
|
silence_tf = ft.TextField(
|
||||||
|
value=str(s.silence_duration), width=80,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||||
|
on_change=lambda e: self._save_float("silence_duration", e.control.value, 0.0),
|
||||||
|
)
|
||||||
|
intro_tf = ft.TextField(
|
||||||
|
value=str(s.chapter_intro_delay), width=80,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||||
|
on_change=lambda e: self._save_float("chapter_intro_delay", e.control.value, 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline_card = build_card(ft.Column([
|
||||||
|
build_section_header("TTS Pipeline", icon="settings", page=p),
|
||||||
|
labelled_row("Provider", provider_dd, page=p),
|
||||||
|
labelled_row("GPU Acceleration", gpu_sw, page=p),
|
||||||
|
labelled_row("Supertonic Steps", _mk_steps_slider(), page=p),
|
||||||
|
build_divider(p),
|
||||||
|
labelled_row("Speaker Analysis Threshold", thresh_tf, page=p),
|
||||||
|
labelled_row("Silence Between Chapters (s)", silence_tf, page=p),
|
||||||
|
labelled_row("Chapter Intro Delay (s)", intro_tf, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Integration card (Audiobookshelf) ─────────────────────────
|
||||||
|
abs_enabled_sw = _sw(s.audiobookshelf_enabled,
|
||||||
|
lambda e: self._save("audiobookshelf_enabled", e.control.value))
|
||||||
|
abs_url_tf = ft.TextField(value=s.audiobookshelf_base_url, hint_text="http://abs-server:13378",
|
||||||
|
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||||
|
on_change=lambda e: self._save("audiobookshelf_base_url", e.control.value))
|
||||||
|
abs_token_tf = ft.TextField(value=s.audiobookshelf_api_token, password=True,
|
||||||
|
can_reveal_password=True, expand=True,
|
||||||
|
border_radius=RADIUS_SM, text_size=12,
|
||||||
|
on_change=lambda e: self._save("audiobookshelf_api_token", e.control.value))
|
||||||
|
abs_lib_tf = ft.TextField(value=s.audiobookshelf_library_id, hint_text="Library ID",
|
||||||
|
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||||
|
on_change=lambda e: self._save("audiobookshelf_library_id", e.control.value))
|
||||||
|
abs_auto_sw = _sw(s.audiobookshelf_auto_send,
|
||||||
|
lambda e: self._save("audiobookshelf_auto_send", e.control.value))
|
||||||
|
|
||||||
|
integ_card = build_card(ft.Column([
|
||||||
|
build_section_header("Audiobookshelf Integration",
|
||||||
|
icon="cloud_upload", page=p),
|
||||||
|
labelled_row("Enabled", abs_enabled_sw, page=p),
|
||||||
|
labelled_row("Server URL", abs_url_tf, page=p),
|
||||||
|
labelled_row("API Token", abs_token_tf, page=p),
|
||||||
|
labelled_row("Library ID", abs_lib_tf, page=p),
|
||||||
|
labelled_row("Auto-upload on finish", abs_auto_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
save_btn = build_primary_button(
|
||||||
|
"Save Settings", icon="save",
|
||||||
|
on_click=self._on_save, page=p,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Column([
|
||||||
|
output_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
text_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
subtitle_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
pipeline_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
integ_card,
|
||||||
|
ft.Container(height=SPACE_LG),
|
||||||
|
save_btn,
|
||||||
|
ft.Container(height=SPACE_LG),
|
||||||
|
], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _save(self, attr: str, value) -> None:
|
||||||
|
setattr(self._state, attr, value)
|
||||||
|
|
||||||
|
def _save_int(self, attr: str, raw: str, lo: int, hi: int) -> None:
|
||||||
|
try:
|
||||||
|
v = max(lo, min(hi, int(raw)))
|
||||||
|
setattr(self._state, attr, v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _save_float(self, attr: str, raw: str, lo: float) -> None:
|
||||||
|
try:
|
||||||
|
v = max(lo, float(raw))
|
||||||
|
setattr(self._state, attr, v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_save(self, _: ft.ControlEvent) -> None:
|
||||||
|
self._state.persist_config()
|
||||||
|
show_snack(self._page, "Settings saved.")
|
||||||
@@ -21,8 +21,7 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from abogen.constants import COLORS
|
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||||
from abogen.tts_plugin.utils import get_voices
|
|
||||||
from abogen.spacy_utils import SPACY_MODELS
|
from abogen.spacy_utils import SPACY_MODELS
|
||||||
import abogen.hf_tracker
|
import abogen.hf_tracker
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ class PreDownloadWorker(QThread):
|
|||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
return
|
return
|
||||||
|
|
||||||
voice_list = get_voices("kokoro")
|
voice_list = VOICES_INTERNAL
|
||||||
for idx, voice in enumerate(voice_list, start=1):
|
for idx, voice in enumerate(voice_list, start=1):
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
@@ -463,14 +462,14 @@ class PreDownloadDialog(QDialog):
|
|||||||
try:
|
try:
|
||||||
from huggingface_hub import try_to_load_from_cache
|
from huggingface_hub import try_to_load_from_cache
|
||||||
|
|
||||||
for voice in get_voices("kokoro"):
|
for voice in VOICES_INTERNAL:
|
||||||
if not try_to_load_from_cache(
|
if not try_to_load_from_cache(
|
||||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||||
):
|
):
|
||||||
missing.append(voice)
|
missing.append(voice)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If HF missing, report all as missing
|
# If HF missing, report all as missing
|
||||||
return False, list(get_voices("kokoro"))
|
return False, list(VOICES_INTERNAL)
|
||||||
return (len(missing) == 0), missing
|
return (len(missing) == 0), missing
|
||||||
|
|
||||||
def _check_kokoro_model(self) -> bool:
|
def _check_kokoro_model(self) -> bool:
|
||||||
|
|||||||
+56
-28
@@ -5,7 +5,6 @@ import hashlib # For generating unique cache filenames
|
|||||||
from platformdirs import user_desktop_dir
|
from platformdirs import user_desktop_dir
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
|
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||||
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
|
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
|
||||||
import numpy as np
|
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
from abogen.utils import (
|
from abogen.utils import (
|
||||||
create_process,
|
create_process,
|
||||||
@@ -260,7 +259,8 @@ class ConversionThread(QThread):
|
|||||||
output_folder,
|
output_folder,
|
||||||
subtitle_mode,
|
subtitle_mode,
|
||||||
output_format,
|
output_format,
|
||||||
backend,
|
np_module,
|
||||||
|
kpipeline_class,
|
||||||
start_time,
|
start_time,
|
||||||
total_char_count,
|
total_char_count,
|
||||||
use_gpu=True,
|
use_gpu=True,
|
||||||
@@ -270,7 +270,8 @@ class ConversionThread(QThread):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._chapter_options_event = threading.Event()
|
self._chapter_options_event = threading.Event()
|
||||||
self._timestamp_response_event = threading.Event()
|
self._timestamp_response_event = threading.Event()
|
||||||
self.backend = backend
|
self.np = np_module
|
||||||
|
self.KPipeline = kpipeline_class
|
||||||
self.file_name = file_name
|
self.file_name = file_name
|
||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
@@ -489,6 +490,19 @@ class ConversionThread(QThread):
|
|||||||
|
|
||||||
self.log_updated.emit(("\nInitializing TTS pipeline...", "grey"))
|
self.log_updated.emit(("\nInitializing TTS pipeline...", "grey"))
|
||||||
|
|
||||||
|
# Set device based on use_gpu setting and platform
|
||||||
|
if self.use_gpu:
|
||||||
|
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||||
|
device = "mps" # Use MPS for Apple Silicon
|
||||||
|
else:
|
||||||
|
device = "cuda" # Use CUDA for other platforms
|
||||||
|
else:
|
||||||
|
device = "cpu"
|
||||||
|
|
||||||
|
tts = self.KPipeline(
|
||||||
|
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||||
|
)
|
||||||
|
|
||||||
# Check if the input is a subtitle file or timestamp text file
|
# Check if the input is a subtitle file or timestamp text file
|
||||||
is_subtitle_file = False
|
is_subtitle_file = False
|
||||||
is_timestamp_text = False
|
is_timestamp_text = False
|
||||||
@@ -524,7 +538,7 @@ class ConversionThread(QThread):
|
|||||||
|
|
||||||
# Process subtitle files separately
|
# Process subtitle files separately
|
||||||
if is_subtitle_file or is_timestamp_text:
|
if is_subtitle_file or is_timestamp_text:
|
||||||
self._process_subtitle_file(self.backend, base_path, is_timestamp_text)
|
self._process_subtitle_file(tts, base_path, is_timestamp_text)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.is_direct_text:
|
if self.is_direct_text:
|
||||||
@@ -1057,7 +1071,7 @@ class ConversionThread(QThread):
|
|||||||
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
||||||
# Load voice for this segment (with caching)
|
# Load voice for this segment (with caching)
|
||||||
try:
|
try:
|
||||||
loaded_voice = self.load_voice_cached(voice_name, self.backend)
|
loaded_voice = self.load_voice_cached(voice_name, tts)
|
||||||
if segment_idx > 0:
|
if segment_idx > 0:
|
||||||
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
||||||
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
||||||
@@ -1066,7 +1080,7 @@ class ConversionThread(QThread):
|
|||||||
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
||||||
)
|
)
|
||||||
if segment_idx == 0:
|
if segment_idx == 0:
|
||||||
loaded_voice = self.load_voice_cached(self.voice, self.backend)
|
loaded_voice = self.load_voice_cached(self.voice, tts)
|
||||||
|
|
||||||
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||||
# Only non-English languages use spaCy for pre-segmentation
|
# Only non-English languages use spaCy for pre-segmentation
|
||||||
@@ -1152,7 +1166,7 @@ class ConversionThread(QThread):
|
|||||||
print("Using split pattern: (unprintable)")
|
print("Using split pattern: (unprintable)")
|
||||||
|
|
||||||
for text_segment in text_segments:
|
for text_segment in text_segments:
|
||||||
for result in self.backend(
|
for result in tts(
|
||||||
text_segment,
|
text_segment,
|
||||||
voice=loaded_voice,
|
voice=loaded_voice,
|
||||||
speed=self.speed,
|
speed=self.speed,
|
||||||
@@ -1354,7 +1368,7 @@ class ConversionThread(QThread):
|
|||||||
silence_samples = int(
|
silence_samples = int(
|
||||||
self.silence_duration * 24000
|
self.silence_duration * 24000
|
||||||
) # Silence duration at 24,000 Hz
|
) # Silence duration at 24,000 Hz
|
||||||
silence_audio = np.zeros(silence_samples, dtype="float32")
|
silence_audio = self.np.zeros(silence_samples, dtype="float32")
|
||||||
silence_bytes = silence_audio.tobytes()
|
silence_bytes = silence_audio.tobytes()
|
||||||
|
|
||||||
if merged_out_file:
|
if merged_out_file:
|
||||||
@@ -1693,7 +1707,7 @@ class ConversionThread(QThread):
|
|||||||
max_end_time = max(
|
max_end_time = max(
|
||||||
(end for _, end, _ in subtitles if end is not None), default=0
|
(end for _, end, _ in subtitles if end is not None), default=0
|
||||||
)
|
)
|
||||||
audio_buffer = np.zeros(
|
audio_buffer = self.np.zeros(
|
||||||
int(max_end_time * rate) + rate, dtype="float32"
|
int(max_end_time * rate) + rate, dtype="float32"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1757,7 +1771,7 @@ class ConversionThread(QThread):
|
|||||||
# Generate TTS audio
|
# Generate TTS audio
|
||||||
tts_results = [
|
tts_results = [
|
||||||
r
|
r
|
||||||
for r in self.backend(
|
for r in tts(
|
||||||
processed_text,
|
processed_text,
|
||||||
voice=loaded_voice,
|
voice=loaded_voice,
|
||||||
speed=self.speed,
|
speed=self.speed,
|
||||||
@@ -1775,11 +1789,11 @@ class ConversionThread(QThread):
|
|||||||
|
|
||||||
# Concatenate audio and determine duration
|
# Concatenate audio and determine duration
|
||||||
full_audio = (
|
full_audio = (
|
||||||
np.concatenate(
|
self.np.concatenate(
|
||||||
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
|
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
|
||||||
)
|
)
|
||||||
if audio_chunks
|
if audio_chunks
|
||||||
else np.zeros(
|
else self.np.zeros(
|
||||||
int((subtitle_duration or 0) * rate), dtype="float32"
|
int((subtitle_duration or 0) * rate), dtype="float32"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1813,8 +1827,8 @@ class ConversionThread(QThread):
|
|||||||
num_stages = max(
|
num_stages = max(
|
||||||
1,
|
1,
|
||||||
int(
|
int(
|
||||||
np.ceil(
|
self.np.ceil(
|
||||||
np.log(speed_factor) / np.log(2.0)
|
self.np.log(speed_factor) / self.np.log(2.0)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1847,7 +1861,7 @@ class ConversionThread(QThread):
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
full_audio = np.frombuffer(
|
full_audio = self.np.frombuffer(
|
||||||
speed_proc.communicate(input=full_audio.tobytes())[0],
|
speed_proc.communicate(input=full_audio.tobytes())[0],
|
||||||
dtype="float32",
|
dtype="float32",
|
||||||
)
|
)
|
||||||
@@ -1861,7 +1875,7 @@ class ConversionThread(QThread):
|
|||||||
|
|
||||||
tts_results = [
|
tts_results = [
|
||||||
r
|
r
|
||||||
for r in self.backend(
|
for r in tts(
|
||||||
processed_text,
|
processed_text,
|
||||||
voice=loaded_voice,
|
voice=loaded_voice,
|
||||||
speed=new_speed,
|
speed=new_speed,
|
||||||
@@ -1872,14 +1886,14 @@ class ConversionThread(QThread):
|
|||||||
audio_chunks = [r.audio for r in tts_results]
|
audio_chunks = [r.audio for r in tts_results]
|
||||||
|
|
||||||
full_audio = (
|
full_audio = (
|
||||||
np.concatenate(
|
self.np.concatenate(
|
||||||
[
|
[
|
||||||
a.numpy() if hasattr(a, "numpy") else a
|
a.numpy() if hasattr(a, "numpy") else a
|
||||||
for a in audio_chunks
|
for a in audio_chunks
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if audio_chunks
|
if audio_chunks
|
||||||
else np.zeros(
|
else self.np.zeros(
|
||||||
int(subtitle_duration * rate), dtype="float32"
|
int(subtitle_duration * rate), dtype="float32"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1896,10 +1910,10 @@ class ConversionThread(QThread):
|
|||||||
# Pad or trim to subtitle duration
|
# Pad or trim to subtitle duration
|
||||||
target_samples = int(subtitle_duration * rate)
|
target_samples = int(subtitle_duration * rate)
|
||||||
if len(full_audio) < target_samples:
|
if len(full_audio) < target_samples:
|
||||||
full_audio = np.concatenate(
|
full_audio = self.np.concatenate(
|
||||||
[
|
[
|
||||||
full_audio,
|
full_audio,
|
||||||
np.zeros(
|
self.np.zeros(
|
||||||
target_samples - len(full_audio), dtype="float32"
|
target_samples - len(full_audio), dtype="float32"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1912,10 +1926,10 @@ class ConversionThread(QThread):
|
|||||||
end_sample = start_sample + len(full_audio)
|
end_sample = start_sample + len(full_audio)
|
||||||
if end_sample > len(audio_buffer):
|
if end_sample > len(audio_buffer):
|
||||||
# Extend buffer if needed
|
# Extend buffer if needed
|
||||||
audio_buffer = np.concatenate(
|
audio_buffer = self.np.concatenate(
|
||||||
[
|
[
|
||||||
audio_buffer,
|
audio_buffer,
|
||||||
np.zeros(
|
self.np.zeros(
|
||||||
end_sample - len(audio_buffer), dtype="float32"
|
end_sample - len(audio_buffer), dtype="float32"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1957,7 +1971,7 @@ class ConversionThread(QThread):
|
|||||||
self.progress_updated.emit(percent, etr_str)
|
self.progress_updated.emit(percent, etr_str)
|
||||||
|
|
||||||
# Normalize audio buffer to prevent clipping from mixed overlaps
|
# Normalize audio buffer to prevent clipping from mixed overlaps
|
||||||
max_amplitude = np.abs(audio_buffer).max()
|
max_amplitude = self.np.abs(audio_buffer).max()
|
||||||
if max_amplitude > 1.0:
|
if max_amplitude > 1.0:
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
|
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
|
||||||
@@ -2426,7 +2440,8 @@ class VoicePreviewThread(QThread):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
backend,
|
np_module,
|
||||||
|
kpipeline_class,
|
||||||
lang_code,
|
lang_code,
|
||||||
voice,
|
voice,
|
||||||
speed,
|
speed,
|
||||||
@@ -2434,7 +2449,8 @@ class VoicePreviewThread(QThread):
|
|||||||
parent=None,
|
parent=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.backend = backend
|
self.np_module = np_module
|
||||||
|
self.kpipeline_class = kpipeline_class
|
||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
@@ -2468,19 +2484,31 @@ class VoicePreviewThread(QThread):
|
|||||||
# Generate the preview and save to cache
|
# Generate the preview and save to cache
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
# Set device based on use_gpu setting and platform
|
||||||
|
if self.use_gpu:
|
||||||
|
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||||
|
device = "mps" # Use MPS for Apple Silicon
|
||||||
|
else:
|
||||||
|
device = "cuda" # Use CUDA for other platforms
|
||||||
|
else:
|
||||||
|
device = "cpu"
|
||||||
|
|
||||||
|
tts = self.kpipeline_class(
|
||||||
|
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||||
|
)
|
||||||
# Enable voice formula support for preview
|
# Enable voice formula support for preview
|
||||||
if "*" in self.voice:
|
if "*" in self.voice:
|
||||||
loaded_voice = get_new_voice(self.backend, self.voice, self.use_gpu)
|
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
||||||
else:
|
else:
|
||||||
loaded_voice = self.voice
|
loaded_voice = self.voice
|
||||||
sample_text = get_sample_voice_text(self.lang_code)
|
sample_text = get_sample_voice_text(self.lang_code)
|
||||||
audio_segments = []
|
audio_segments = []
|
||||||
for result in self.backend(
|
for result in tts(
|
||||||
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
|
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
|
||||||
):
|
):
|
||||||
audio_segments.append(result.audio)
|
audio_segments.append(result.audio)
|
||||||
if audio_segments:
|
if audio_segments:
|
||||||
audio = np.concatenate(audio_segments)
|
audio = self.np_module.concatenate(audio_segments)
|
||||||
# Save directly to the cache path
|
# Save directly to the cache path
|
||||||
sf.write(self.cache_path, audio, 24000)
|
sf.write(self.cache_path, audio, 24000)
|
||||||
self.temp_wav = self.cache_path
|
self.temp_wav = self.cache_path
|
||||||
|
|||||||
+13
-34
@@ -82,11 +82,11 @@ from abogen.constants import (
|
|||||||
GITHUB_URL,
|
GITHUB_URL,
|
||||||
PROGRAM_DESCRIPTION,
|
PROGRAM_DESCRIPTION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
COLORS,
|
COLORS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
)
|
)
|
||||||
from abogen.tts_plugin.utils import get_voices
|
|
||||||
import threading
|
import threading
|
||||||
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
|
||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
@@ -1873,7 +1873,7 @@ class abogen(QWidget):
|
|||||||
for pname in load_profiles().keys():
|
for pname in load_profiles().keys():
|
||||||
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
|
||||||
# re-add voices
|
# re-add voices
|
||||||
for v in get_voices("kokoro"):
|
for v in VOICES_INTERNAL:
|
||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
|
||||||
if flag_path and os.path.exists(flag_path):
|
if flag_path and os.path.exists(flag_path):
|
||||||
@@ -2316,9 +2316,9 @@ class abogen(QWidget):
|
|||||||
file_size_str = "Unknown"
|
file_size_str = "Unknown"
|
||||||
|
|
||||||
# pipeline_loaded_callback remains unchanged
|
# pipeline_loaded_callback remains unchanged
|
||||||
def pipeline_loaded_callback(backend, error):
|
def pipeline_loaded_callback(np_module, kpipeline_class, error):
|
||||||
if error:
|
if error:
|
||||||
self.update_log((f"Error loading TTS backend: {error}", "red"))
|
self.update_log((f"Error loading numpy or KPipeline: {error}", "red"))
|
||||||
prevent_sleep_end()
|
prevent_sleep_end()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2341,7 +2341,8 @@ class abogen(QWidget):
|
|||||||
self.selected_output_folder,
|
self.selected_output_folder,
|
||||||
subtitle_mode=actual_subtitle_mode,
|
subtitle_mode=actual_subtitle_mode,
|
||||||
output_format=self.selected_format,
|
output_format=self.selected_format,
|
||||||
backend=backend,
|
np_module=np_module,
|
||||||
|
kpipeline_class=kpipeline_class,
|
||||||
start_time=self.start_time,
|
start_time=self.start_time,
|
||||||
total_char_count=self.char_count,
|
total_char_count=self.char_count,
|
||||||
use_gpu=self.gpu_ok,
|
use_gpu=self.gpu_ok,
|
||||||
@@ -2425,20 +2426,7 @@ class abogen(QWidget):
|
|||||||
self.gpu_ok = gpu_ok
|
self.gpu_ok = gpu_ok
|
||||||
self.update_log((gpu_msg, gpu_ok))
|
self.update_log((gpu_msg, gpu_ok))
|
||||||
self.update_log("Loading modules...")
|
self.update_log("Loading modules...")
|
||||||
|
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||||
# Determine device based on GPU availability
|
|
||||||
if gpu_ok:
|
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
|
||||||
device = "mps"
|
|
||||||
else:
|
|
||||||
device = "cuda"
|
|
||||||
else:
|
|
||||||
device = "cpu"
|
|
||||||
|
|
||||||
lang_code = self.selected_lang or "a"
|
|
||||||
load_thread = LoadPipelineThread(
|
|
||||||
pipeline_loaded_callback, lang_code=lang_code, device=device
|
|
||||||
)
|
|
||||||
load_thread.start()
|
load_thread.start()
|
||||||
|
|
||||||
threading.Thread(target=gpu_and_load, daemon=True).start()
|
threading.Thread(target=gpu_and_load, daemon=True).start()
|
||||||
@@ -2875,27 +2863,18 @@ class abogen(QWidget):
|
|||||||
)
|
)
|
||||||
self.loading_movie.start()
|
self.loading_movie.start()
|
||||||
|
|
||||||
# Determine device based on GPU availability
|
def pipeline_loaded_callback(np_module, kpipeline_class, error):
|
||||||
if self.gpu_ok:
|
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
|
||||||
device = "mps"
|
|
||||||
else:
|
|
||||||
device = "cuda"
|
|
||||||
else:
|
|
||||||
device = "cpu"
|
|
||||||
|
|
||||||
lang = self.selected_lang or "a"
|
load_thread = LoadPipelineThread(pipeline_loaded_callback)
|
||||||
load_thread = LoadPipelineThread(
|
|
||||||
self._on_pipeline_loaded_for_preview, lang_code=lang, device=device
|
|
||||||
)
|
|
||||||
load_thread.start()
|
load_thread.start()
|
||||||
|
|
||||||
def _on_pipeline_loaded_for_preview(self, backend, error):
|
def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error):
|
||||||
# stop loading animation and restore icon on error
|
# stop loading animation and restore icon on error
|
||||||
if error:
|
if error:
|
||||||
self.loading_movie.stop()
|
self.loading_movie.stop()
|
||||||
self._show_error_message_box(
|
self._show_error_message_box(
|
||||||
"Loading Error", f"Error loading TTS backend: {error}"
|
"Loading Error", f"Error loading numpy or KPipeline: {error}"
|
||||||
)
|
)
|
||||||
self.btn_preview.setIcon(self.play_icon)
|
self.btn_preview.setIcon(self.play_icon)
|
||||||
self.btn_preview.setEnabled(True)
|
self.btn_preview.setEnabled(True)
|
||||||
@@ -2933,7 +2912,7 @@ class abogen(QWidget):
|
|||||||
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
||||||
|
|
||||||
self.preview_thread = VoicePreviewThread(
|
self.preview_thread = VoicePreviewThread(
|
||||||
backend, lang, voice, speed, gpu_ok
|
np_module, kpipeline_class, lang, voice, speed, gpu_ok
|
||||||
)
|
)
|
||||||
self.preview_thread.finished.connect(self._play_preview_audio)
|
self.preview_thread.finished.connect(self._play_preview_audio)
|
||||||
self.preview_thread.error.connect(self._preview_error)
|
self.preview_thread.error.connect(self._preview_error)
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from abogen.constants import COLORS
|
from abogen.constants import COLORS, VOICES_INTERNAL
|
||||||
from abogen.tts_plugin.utils import get_voices
|
|
||||||
from abogen.spacy_utils import SPACY_MODELS
|
from abogen.spacy_utils import SPACY_MODELS
|
||||||
import abogen.hf_tracker
|
import abogen.hf_tracker
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ class PreDownloadWorker(QThread):
|
|||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
return
|
return
|
||||||
|
|
||||||
voice_list = get_voices("kokoro")
|
voice_list = VOICES_INTERNAL
|
||||||
for idx, voice in enumerate(voice_list, start=1):
|
for idx, voice in enumerate(voice_list, start=1):
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
self._voices_success = False
|
self._voices_success = False
|
||||||
@@ -463,14 +462,14 @@ class PreDownloadDialog(QDialog):
|
|||||||
try:
|
try:
|
||||||
from huggingface_hub import try_to_load_from_cache
|
from huggingface_hub import try_to_load_from_cache
|
||||||
|
|
||||||
for voice in get_voices("kokoro"):
|
for voice in VOICES_INTERNAL:
|
||||||
if not try_to_load_from_cache(
|
if not try_to_load_from_cache(
|
||||||
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt"
|
||||||
):
|
):
|
||||||
missing.append(voice)
|
missing.append(voice)
|
||||||
except Exception:
|
except Exception:
|
||||||
# If HF missing, report all as missing
|
# If HF missing, report all as missing
|
||||||
return False, list(get_voices("kokoro"))
|
return False, list(VOICES_INTERNAL)
|
||||||
return (len(missing) == 0), missing
|
return (len(missing) == 0), missing
|
||||||
|
|
||||||
def _check_kokoro_model(self) -> bool:
|
def _check_kokoro_model(self) -> bool:
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
|
|||||||
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||||||
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
||||||
from abogen.constants import (
|
from abogen.constants import (
|
||||||
|
VOICES_INTERNAL,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
COLORS,
|
COLORS,
|
||||||
)
|
)
|
||||||
from abogen.tts_plugin.utils import get_voices
|
|
||||||
import re
|
import re
|
||||||
import platform
|
import platform
|
||||||
from abogen.utils import get_resource_path
|
from abogen.utils import get_resource_path
|
||||||
@@ -179,7 +179,7 @@ class VoiceMixer(QWidget):
|
|||||||
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
|
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
# Voice name label with gender icon
|
# Voice name label with gender icon
|
||||||
is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f"
|
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
|
||||||
|
|
||||||
# Icons layout (flag and gender)
|
# Icons layout (flag and gender)
|
||||||
icons_layout = QHBoxLayout()
|
icons_layout = QHBoxLayout()
|
||||||
@@ -772,7 +772,7 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
|
|
||||||
def add_voices(self, initial_state):
|
def add_voices(self, initial_state):
|
||||||
first_enabled_voice = None
|
first_enabled_voice = None
|
||||||
for voice in get_voices("kokoro"):
|
for voice in VOICES_INTERNAL:
|
||||||
language_code = voice[0] # First character is the language code
|
language_code = voice[0] # First character is the language code
|
||||||
matching_voice = next(
|
matching_voice = next(
|
||||||
(item for item in initial_state if item[0] == voice), None
|
(item for item in initial_state if item[0] == voice), None
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ def sanitize_name_for_os(name, is_folder=True):
|
|||||||
|
|
||||||
|
|
||||||
def validate_voice_name(voice_name):
|
def validate_voice_name(voice_name):
|
||||||
"""Validate voice name against available voices (case-insensitive).
|
"""Validate voice name against VOICES_INTERNAL list (case-insensitive).
|
||||||
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
|
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -477,10 +477,10 @@ def validate_voice_name(voice_name):
|
|||||||
- is_valid: True if all voices in the name/formula are valid
|
- is_valid: True if all voices in the name/formula are valid
|
||||||
- invalid_voice_name: The first invalid voice found, or None if all valid
|
- invalid_voice_name: The first invalid voice found, or None if all valid
|
||||||
"""
|
"""
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
# Create case-insensitive lookup set (done once per call)
|
# Create case-insensitive lookup set (done once per call)
|
||||||
voice_lookup_lower = {v.lower() for v in get_voices("kokoro")}
|
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL}
|
||||||
voice_name = voice_name.strip()
|
voice_name = voice_name.strip()
|
||||||
|
|
||||||
# Check if it's a formula (contains *)
|
# Check if it's a formula (contains *)
|
||||||
@@ -505,7 +505,7 @@ def split_text_by_voice_markers(text, default_voice):
|
|||||||
"""Split text by voice markers, returning list of (voice, text) tuples.
|
"""Split text by voice markers, returning list of (voice, text) tuples.
|
||||||
|
|
||||||
IMPORTANT: Returns the last voice used so it can persist across chapters.
|
IMPORTANT: Returns the last voice used so it can persist across chapters.
|
||||||
Voice names are normalized to lowercase to match canonical voice names.
|
Voice names are normalized to lowercase to match VOICES_INTERNAL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text potentially containing <<VOICE:name>> markers
|
text: Text potentially containing <<VOICE:name>> markers
|
||||||
@@ -518,7 +518,7 @@ def split_text_by_voice_markers(text, default_voice):
|
|||||||
- valid_count: Number of valid voice markers processed
|
- valid_count: Number of valid voice markers processed
|
||||||
- invalid_count: Number of invalid voice markers skipped
|
- invalid_count: Number of invalid voice markers skipped
|
||||||
"""
|
"""
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
|
||||||
|
|
||||||
@@ -560,7 +560,7 @@ def split_text_by_voice_markers(text, default_voice):
|
|||||||
# Find the canonical (lowercase) voice name
|
# Find the canonical (lowercase) voice name
|
||||||
voice_part_lower = voice_part.strip().lower()
|
voice_part_lower = voice_part.strip().lower()
|
||||||
canonical_voice = next(
|
canonical_voice = next(
|
||||||
(v for v in get_voices("kokoro") if v.lower() == voice_part_lower),
|
(v for v in VOICES_INTERNAL if v.lower() == voice_part_lower),
|
||||||
voice_part.strip()
|
voice_part.strip()
|
||||||
)
|
)
|
||||||
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
|
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
|
||||||
@@ -569,7 +569,7 @@ def split_text_by_voice_markers(text, default_voice):
|
|||||||
# Find the canonical (lowercase) voice name
|
# Find the canonical (lowercase) voice name
|
||||||
voice_name_lower = voice_name.lower()
|
voice_name_lower = voice_name.lower()
|
||||||
current_voice = next(
|
current_voice = next(
|
||||||
(v for v in get_voices("kokoro") if v.lower() == voice_name_lower),
|
(v for v in VOICES_INTERNAL if v.lower() == voice_name_lower),
|
||||||
voice_name
|
voice_name
|
||||||
)
|
)
|
||||||
valid_markers += 1
|
valid_markers += 1
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
"""TTS Plugin Architecture - Public API.
|
|
||||||
|
|
||||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
|
||||||
All public interfaces are fully defined but contain no business logic.
|
|
||||||
|
|
||||||
Public modules:
|
|
||||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
|
||||||
- errors: Error hierarchy (EngineError and subtypes)
|
|
||||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
|
||||||
- engine: Engine and EngineSession protocols
|
|
||||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
|
||||||
- host_context: HostContext dataclass
|
|
||||||
- plugin: Plugin contract (create_engine function signature)
|
|
||||||
- loader: Plugin discovery and loading
|
|
||||||
- plugin_manager: Plugin management and engine creation
|
|
||||||
- utils: Direct utility functions (get_voices, create_pipeline, etc.)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from abogen.tts_plugin import (
|
|
||||||
# Types
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
VoiceSelection,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
EngineConfig,
|
|
||||||
# Errors
|
|
||||||
EngineError,
|
|
||||||
ModelNotFoundError,
|
|
||||||
ModelLoadError,
|
|
||||||
NetworkError,
|
|
||||||
InvalidInputError,
|
|
||||||
ConfigurationError,
|
|
||||||
CancelledError,
|
|
||||||
InternalError,
|
|
||||||
# Manifest
|
|
||||||
PluginManifest,
|
|
||||||
EngineManifest,
|
|
||||||
VoiceSourceManifest,
|
|
||||||
VoiceManifest,
|
|
||||||
ParameterManifest,
|
|
||||||
AudioFormatManifest,
|
|
||||||
EnumOption,
|
|
||||||
RequirementManifest,
|
|
||||||
GpuRequirement,
|
|
||||||
ModelManifest,
|
|
||||||
# Engine
|
|
||||||
Engine,
|
|
||||||
EngineSession,
|
|
||||||
# Capabilities
|
|
||||||
VoiceLister,
|
|
||||||
PreviewGenerator,
|
|
||||||
StreamingSynthesizer,
|
|
||||||
CancelableSession,
|
|
||||||
# Host Context
|
|
||||||
HostContext,
|
|
||||||
HttpClient,
|
|
||||||
# Plugin Manager
|
|
||||||
get_plugin_manager,
|
|
||||||
reset_plugin_manager,
|
|
||||||
# Utils
|
|
||||||
get_voices,
|
|
||||||
get_default_voice,
|
|
||||||
is_plugin_registered,
|
|
||||||
resolve_voice_to_plugin,
|
|
||||||
create_pipeline,
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abogen.tts_plugin.capabilities import (
|
|
||||||
CancelableSession,
|
|
||||||
PreviewGenerator,
|
|
||||||
StreamingSynthesizer,
|
|
||||||
VoiceLister,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import (
|
|
||||||
CancelledError,
|
|
||||||
ConfigurationError,
|
|
||||||
EngineError,
|
|
||||||
InternalError,
|
|
||||||
InvalidInputError,
|
|
||||||
ModelLoadError,
|
|
||||||
ModelNotFoundError,
|
|
||||||
NetworkError,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
|
||||||
from abogen.tts_plugin.manifest import (
|
|
||||||
AudioFormatManifest,
|
|
||||||
EngineManifest,
|
|
||||||
EnumOption,
|
|
||||||
GpuRequirement,
|
|
||||||
ModelManifest,
|
|
||||||
ParameterManifest,
|
|
||||||
PluginManifest,
|
|
||||||
RequirementManifest,
|
|
||||||
VoiceManifest,
|
|
||||||
VoiceSourceManifest,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
EngineConfig,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Plugin Manager and Utils
|
|
||||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
|
||||||
from abogen.tts_plugin.utils import (
|
|
||||||
create_pipeline,
|
|
||||||
get_default_voice,
|
|
||||||
get_voices,
|
|
||||||
is_plugin_registered,
|
|
||||||
resolve_voice_to_plugin,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Types
|
|
||||||
"AudioFormat",
|
|
||||||
"Duration",
|
|
||||||
"VoiceSelection",
|
|
||||||
"ParameterValues",
|
|
||||||
"SynthesisRequest",
|
|
||||||
"SynthesizedAudio",
|
|
||||||
"EngineConfig",
|
|
||||||
# Errors
|
|
||||||
"EngineError",
|
|
||||||
"ModelNotFoundError",
|
|
||||||
"ModelLoadError",
|
|
||||||
"NetworkError",
|
|
||||||
"InvalidInputError",
|
|
||||||
"ConfigurationError",
|
|
||||||
"CancelledError",
|
|
||||||
"InternalError",
|
|
||||||
# Manifest
|
|
||||||
"PluginManifest",
|
|
||||||
"EngineManifest",
|
|
||||||
"VoiceSourceManifest",
|
|
||||||
"VoiceManifest",
|
|
||||||
"ParameterManifest",
|
|
||||||
"AudioFormatManifest",
|
|
||||||
"EnumOption",
|
|
||||||
"RequirementManifest",
|
|
||||||
"GpuRequirement",
|
|
||||||
"ModelManifest",
|
|
||||||
# Engine
|
|
||||||
"Engine",
|
|
||||||
"EngineSession",
|
|
||||||
# Capabilities
|
|
||||||
"VoiceLister",
|
|
||||||
"PreviewGenerator",
|
|
||||||
"StreamingSynthesizer",
|
|
||||||
"CancelableSession",
|
|
||||||
# Host Context
|
|
||||||
"HostContext",
|
|
||||||
"HttpClient",
|
|
||||||
# Plugin Manager
|
|
||||||
"get_plugin_manager",
|
|
||||||
"reset_plugin_manager",
|
|
||||||
# Utils
|
|
||||||
"get_voices",
|
|
||||||
"get_default_voice",
|
|
||||||
"is_plugin_registered",
|
|
||||||
"resolve_voice_to_plugin",
|
|
||||||
"create_pipeline",
|
|
||||||
]
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""Capability interfaces for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module defines optional capability interfaces that engines can implement.
|
|
||||||
Capabilities are additive; implementing new capabilities doesn't break old plugins.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Iterator, Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class VoiceLister(Protocol):
|
|
||||||
"""Protocol for listing available voices.
|
|
||||||
|
|
||||||
Engines that support voice listing should implement this interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
|
||||||
"""List available voices for a given source.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sourceId: The voice source identifier.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of VoiceManifest describing available voices.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: On failure.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class PreviewGenerator(Protocol):
|
|
||||||
"""Protocol for generating voice previews.
|
|
||||||
|
|
||||||
Engines that support voice preview should implement this interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
|
||||||
"""Generate a preview audio for a voice.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
voice: Voice selection for the preview.
|
|
||||||
text: Text to use for the preview.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SynthesizedAudio with the preview audio data.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: On failure.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class StreamingSynthesizer(Protocol):
|
|
||||||
"""Protocol for streaming synthesis.
|
|
||||||
|
|
||||||
Optional capability of EngineSession, not Engine.
|
|
||||||
Engines that support streaming synthesis should implement this interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
|
||||||
"""Synthesize audio in streaming mode.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The synthesis request.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
Audio chunks as they become available.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
CancelledError: If cancel() is called during iteration.
|
|
||||||
EngineError: On synthesis failure.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
# This is a generator function; implementation will use yield
|
|
||||||
yield b"" # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class CancelableSession(Protocol):
|
|
||||||
"""Protocol for cancellation support.
|
|
||||||
|
|
||||||
Optional capability for engines that support cancellation.
|
|
||||||
cancel() causes synthesize() to raise CancelledError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
"""Cancel in-progress synthesis.
|
|
||||||
|
|
||||||
After cancellation, synthesize() raises CancelledError.
|
|
||||||
The session remains usable after cancellation.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: If called after dispose().
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Engine interfaces for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module defines the core Engine and EngineSession protocols.
|
|
||||||
These are the primary interfaces that plugin implementations must satisfy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class EngineSession(Protocol):
|
|
||||||
"""Protocol for a session that owns mutable execution state.
|
|
||||||
|
|
||||||
An EngineSession is created by Engine.createSession() and owns
|
|
||||||
mutable execution state isolated from other concurrent work.
|
|
||||||
It is NOT thread-safe.
|
|
||||||
|
|
||||||
Lifecycle:
|
|
||||||
1. Created by Engine.createSession()
|
|
||||||
2. Used for synthesis via synthesize()
|
|
||||||
3. Disposed via dispose()
|
|
||||||
|
|
||||||
After dispose(), all methods except dispose() raise EngineError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
"""Synthesize audio from text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The synthesis request containing text, voice, parameters, and format.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SynthesizedAudio with the synthesized audio data.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: On synthesis failure. Session remains usable after error.
|
|
||||||
EngineError: If called after dispose().
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release session resources.
|
|
||||||
|
|
||||||
This method is idempotent and safe to call multiple times.
|
|
||||||
It never raises exceptions (catches and logs internally).
|
|
||||||
After dispose(), all methods except dispose() raise EngineError.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class Engine(Protocol):
|
|
||||||
"""Protocol for a TTS engine that creates sessions.
|
|
||||||
|
|
||||||
An Engine is a factory for EngineSession instances. It is stateless
|
|
||||||
and thread-safe for createSession().
|
|
||||||
|
|
||||||
Lifecycle:
|
|
||||||
1. Created via create_engine() (plugin contract)
|
|
||||||
2. Sessions created via createSession()
|
|
||||||
3. Disposed via dispose()
|
|
||||||
|
|
||||||
Thread Safety:
|
|
||||||
- createSession() is thread-safe and can be called from any thread.
|
|
||||||
- dispose() must be called after all sessions are disposed.
|
|
||||||
- Disposing engine while sessions are alive violates API contract.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def createSession(self) -> EngineSession:
|
|
||||||
"""Create a new session for synthesis.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A new EngineSession instance. Ownership transfers to caller.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: On failure. No partially initialized session is returned.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release engine resources.
|
|
||||||
|
|
||||||
Caller must ensure all sessions created by this engine are disposed
|
|
||||||
before calling dispose(). Disposing an engine while any session is
|
|
||||||
still alive violates the API contract; behavior is undefined.
|
|
||||||
|
|
||||||
This method is idempotent and safe to call multiple times.
|
|
||||||
It never raises exceptions (catches and logs internally).
|
|
||||||
After dispose(), all methods except dispose() raise EngineError.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Error hierarchy for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module defines typed exceptions that engines raise.
|
|
||||||
Engines should never raise raw exceptions; they must use EngineError or its subtypes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class EngineError(Exception):
|
|
||||||
"""Base exception for all engine errors.
|
|
||||||
|
|
||||||
All engine operations that can fail should raise EngineError or one of its subtypes.
|
|
||||||
After dispose(), all methods except dispose() raise EngineError.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ModelNotFoundError(EngineError):
|
|
||||||
"""Raised when a required model is not found."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ModelLoadError(EngineError):
|
|
||||||
"""Raised when a model fails to load."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkError(EngineError):
|
|
||||||
"""Raised when a network operation fails."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidInputError(EngineError):
|
|
||||||
"""Raised when invalid input is provided to the engine."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(EngineError):
|
|
||||||
"""Raised when there is a configuration error."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CancelledError(EngineError):
|
|
||||||
"""Raised when an operation is cancelled.
|
|
||||||
|
|
||||||
This is raised by synthesize() when cancel() is called during synthesis.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InternalError(EngineError):
|
|
||||||
"""Raised when an internal engine error occurs."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Host context for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module defines the HostContext dataclass that provides minimal
|
|
||||||
host services to plugins. It is the only interface through which
|
|
||||||
plugins can access host functionality.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class HttpClient(Protocol):
|
|
||||||
"""Protocol for HTTP client provided by host.
|
|
||||||
|
|
||||||
Plugins can use this for network requests (e.g., API-based engines).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
"""Perform an HTTP GET request."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
"""Perform an HTTP POST request."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HostContext:
|
|
||||||
"""Minimal host context provided to plugins.
|
|
||||||
|
|
||||||
Contains only essential host services. No business logic.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
config_dir: Directory for API keys, preferences, and configuration.
|
|
||||||
logger: Logger for plugin logging.
|
|
||||||
http_client: HTTP client for network requests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
config_dir: Path
|
|
||||||
logger: logging.Logger
|
|
||||||
http_client: HttpClient
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
"""Plugin loader infrastructure for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module provides functionality to discover, import, validate, and load
|
|
||||||
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
|
|
||||||
messages for errors.
|
|
||||||
|
|
||||||
The loader does NOT:
|
|
||||||
- Create Engine instances (that's the plugin's create_engine() responsibility)
|
|
||||||
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
|
|
||||||
- Implement any TTS engine functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
|
|
||||||
|
|
||||||
|
|
||||||
# Host API version for compatibility checking
|
|
||||||
HOST_API_VERSION = "1.0"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PluginLoadError:
|
|
||||||
"""Diagnostic information for a failed plugin load.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
plugin_id: Plugin identifier if available, otherwise directory name.
|
|
||||||
path: Path to the plugin directory.
|
|
||||||
errors: List of error messages describing what went wrong.
|
|
||||||
"""
|
|
||||||
|
|
||||||
plugin_id: str
|
|
||||||
path: Path
|
|
||||||
errors: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PluginLoadResult:
|
|
||||||
"""Result of loading a plugin.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
success: Whether the plugin loaded successfully.
|
|
||||||
manifest: The plugin manifest if successful.
|
|
||||||
model_requirements: Model requirements if successful.
|
|
||||||
create_engine: The create_engine function if successful.
|
|
||||||
module: The plugin module if successful.
|
|
||||||
error: Error information if failed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
success: bool
|
|
||||||
manifest: PluginManifest | None = None
|
|
||||||
model_requirements: tuple[ModelManifest, ...] | None = None
|
|
||||||
create_engine: Callable[..., Any] | None = None
|
|
||||||
module: types.ModuleType | None = None
|
|
||||||
error: PluginLoadError | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_api_version(version: str) -> tuple[int, int] | None:
|
|
||||||
"""Parse an api_version string into (major, minor) tuple.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
version: Version string in format "MAJOR.MINOR".
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (major, minor) or None if invalid format.
|
|
||||||
"""
|
|
||||||
match = re.match(r"^(\d+)\.(\d+)$", version)
|
|
||||||
if match:
|
|
||||||
return int(match.group(1)), int(match.group(2))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_api_version_compatibility(plugin_version: str) -> str | None:
|
|
||||||
"""Check if plugin api_version is compatible with host.
|
|
||||||
|
|
||||||
Architecture spec:
|
|
||||||
- Format: semver (MAJOR.MINOR)
|
|
||||||
- Compatibility: Host rejects plugin if major version differs
|
|
||||||
- Minor version: backward compatible, Host accepts higher minor
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_version: Plugin's api_version string.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Error message if incompatible, None if compatible.
|
|
||||||
"""
|
|
||||||
plugin_ver = _parse_api_version(plugin_version)
|
|
||||||
if plugin_ver is None:
|
|
||||||
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
|
|
||||||
|
|
||||||
host_ver = _parse_api_version(HOST_API_VERSION)
|
|
||||||
if host_ver is None:
|
|
||||||
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
|
|
||||||
|
|
||||||
if plugin_ver[0] != host_ver[0]:
|
|
||||||
return (
|
|
||||||
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
|
|
||||||
f"Major version must match for compatibility."
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
|
|
||||||
"""Validate that a plugin module has required exports.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
module: The imported plugin module.
|
|
||||||
plugin_dir: Path to the plugin directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of error messages (empty if valid).
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
# Check PLUGIN_MANIFEST
|
|
||||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
|
||||||
if manifest is None:
|
|
||||||
errors.append("Missing PLUGIN_MANIFEST export")
|
|
||||||
elif not isinstance(manifest, PluginManifest):
|
|
||||||
errors.append(
|
|
||||||
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
|
|
||||||
f"got {type(manifest).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check MODEL_REQUIREMENTS
|
|
||||||
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
|
|
||||||
if model_reqs is None:
|
|
||||||
errors.append("Missing MODEL_REQUIREMENTS export")
|
|
||||||
elif not isinstance(model_reqs, list):
|
|
||||||
errors.append(
|
|
||||||
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for i, req in enumerate(model_reqs):
|
|
||||||
if not isinstance(req, ModelManifest):
|
|
||||||
errors.append(
|
|
||||||
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
|
|
||||||
f"got {type(req).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check create_engine
|
|
||||||
create_engine = getattr(module, "create_engine", None)
|
|
||||||
if create_engine is None:
|
|
||||||
errors.append("Missing create_engine export")
|
|
||||||
elif not callable(create_engine):
|
|
||||||
errors.append(
|
|
||||||
f"create_engine must be callable, got {type(create_engine).__name__}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
|
|
||||||
"""Validate plugin capabilities.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
manifest: The plugin manifest to validate.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of error messages (empty if valid).
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
# Known capabilities (can be extended)
|
|
||||||
known_capabilities = frozenset({
|
|
||||||
"voice_list",
|
|
||||||
"preview",
|
|
||||||
"voice_clone",
|
|
||||||
"voice_blend",
|
|
||||||
"streaming",
|
|
||||||
"cancel",
|
|
||||||
})
|
|
||||||
|
|
||||||
for cap in manifest.capabilities:
|
|
||||||
if cap not in known_capabilities:
|
|
||||||
errors.append(f"Unknown capability: '{cap}'")
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_api_version(manifest: PluginManifest) -> list[str]:
|
|
||||||
"""Validate api_version compatibility.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
manifest: The plugin manifest to validate.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of error messages (empty if valid).
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
error = _check_api_version_compatibility(manifest.api_version)
|
|
||||||
if error:
|
|
||||||
errors.append(error)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
|
|
||||||
"""Load and validate a plugin from a directory.
|
|
||||||
|
|
||||||
The plugin directory must contain an __init__.py that exports:
|
|
||||||
- PLUGIN_MANIFEST: PluginManifest
|
|
||||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
|
||||||
- create_engine: Callable
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_dir: Path to the plugin directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PluginLoadResult with success status and either plugin data or error info.
|
|
||||||
"""
|
|
||||||
plugin_id = plugin_dir.name
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
# Check if directory exists
|
|
||||||
if not plugin_dir.exists():
|
|
||||||
return PluginLoadResult(
|
|
||||||
success=False,
|
|
||||||
error=PluginLoadError(
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
path=plugin_dir,
|
|
||||||
errors=(f"Plugin directory does not exist: {plugin_dir}",),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for __init__.py
|
|
||||||
init_file = plugin_dir / "__init__.py"
|
|
||||||
if not init_file.exists():
|
|
||||||
return PluginLoadResult(
|
|
||||||
success=False,
|
|
||||||
error=PluginLoadError(
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
path=plugin_dir,
|
|
||||||
errors=("Missing __init__.py in plugin directory",),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import the module
|
|
||||||
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
|
|
||||||
try:
|
|
||||||
# Remove from cache if already imported (for testing)
|
|
||||||
if module_name in sys.modules:
|
|
||||||
del sys.modules[module_name]
|
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
module_name, init_file, submodule_search_locations=[]
|
|
||||||
)
|
|
||||||
if spec is None or spec.loader is None:
|
|
||||||
return PluginLoadResult(
|
|
||||||
success=False,
|
|
||||||
error=PluginLoadError(
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
path=plugin_dir,
|
|
||||||
errors=(f"Failed to create module spec for {init_file}",),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[module_name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
except Exception as e:
|
|
||||||
# Clean up module from sys.modules on import failure
|
|
||||||
if module_name in sys.modules:
|
|
||||||
del sys.modules[module_name]
|
|
||||||
return PluginLoadResult(
|
|
||||||
success=False,
|
|
||||||
error=PluginLoadError(
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
path=plugin_dir,
|
|
||||||
errors=(f"Failed to import plugin module: {e}",),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate manifest
|
|
||||||
manifest_errors = _validate_manifest(module, plugin_dir)
|
|
||||||
errors.extend(manifest_errors)
|
|
||||||
|
|
||||||
# If manifest is valid, perform additional validation
|
|
||||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
|
||||||
if isinstance(manifest, PluginManifest):
|
|
||||||
# Validate api_version
|
|
||||||
api_errors = _validate_api_version(manifest)
|
|
||||||
errors.extend(api_errors)
|
|
||||||
|
|
||||||
# Validate capabilities
|
|
||||||
cap_errors = _validate_capabilities(manifest)
|
|
||||||
errors.extend(cap_errors)
|
|
||||||
|
|
||||||
# Use manifest id if available
|
|
||||||
plugin_id = manifest.id
|
|
||||||
|
|
||||||
# Check if any errors occurred
|
|
||||||
if errors:
|
|
||||||
# Clean up module from sys.modules
|
|
||||||
if module_name in sys.modules:
|
|
||||||
del sys.modules[module_name]
|
|
||||||
|
|
||||||
return PluginLoadResult(
|
|
||||||
success=False,
|
|
||||||
error=PluginLoadError(
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
path=plugin_dir,
|
|
||||||
errors=tuple(errors),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get MODEL_REQUIREMENTS
|
|
||||||
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
|
|
||||||
create_engine = getattr(module, "create_engine", None)
|
|
||||||
|
|
||||||
return PluginLoadResult(
|
|
||||||
success=True,
|
|
||||||
manifest=manifest,
|
|
||||||
model_requirements=model_requirements,
|
|
||||||
create_engine=create_engine,
|
|
||||||
module=module,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
|
|
||||||
"""Discover and load plugins from multiple directories.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_dirs: List of directories to scan for plugins.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of PluginLoadResult, one per plugin directory found.
|
|
||||||
"""
|
|
||||||
results: list[PluginLoadResult] = []
|
|
||||||
|
|
||||||
for plugin_dir in plugin_dirs:
|
|
||||||
if not plugin_dir.exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Scan for subdirectories (each is a potential plugin)
|
|
||||||
for item in sorted(plugin_dir.iterdir()):
|
|
||||||
if item.is_dir() and not item.name.startswith("."):
|
|
||||||
result = load_plugin_from_dir(item)
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def load_plugin(
|
|
||||||
plugin_dir: Path,
|
|
||||||
) -> PluginLoadResult:
|
|
||||||
"""Load a single plugin from a directory.
|
|
||||||
|
|
||||||
This is the main entry point for loading a plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_dir: Path to the plugin directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PluginLoadResult with success status and either plugin data or error info.
|
|
||||||
"""
|
|
||||||
return load_plugin_from_dir(plugin_dir)
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
"""Plugin manifest types for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module contains static metadata types that describe plugins.
|
|
||||||
These types have no dependencies and are immutable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AudioFormatManifest:
|
|
||||||
"""Manifest describing an audio format.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
mime: MIME type of the audio.
|
|
||||||
extension: File extension.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mime: str
|
|
||||||
extension: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EnumOption:
|
|
||||||
"""Manifest describing an enum option for a parameter.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
value: The enum value.
|
|
||||||
label: Human-readable label.
|
|
||||||
"""
|
|
||||||
|
|
||||||
value: str
|
|
||||||
label: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ParameterManifest:
|
|
||||||
"""Manifest describing a synthesis parameter.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Parameter identifier.
|
|
||||||
name: Human-readable name.
|
|
||||||
description: Parameter description.
|
|
||||||
type: Parameter type ("float", "int", "string", "boolean", "enum").
|
|
||||||
default: Default value.
|
|
||||||
min: Minimum value (optional, for numeric types).
|
|
||||||
max: Maximum value (optional, for numeric types).
|
|
||||||
step: Step size (optional, for numeric types).
|
|
||||||
options: Available options (optional, for enum type).
|
|
||||||
unit: Unit of measurement (optional).
|
|
||||||
group: Parameter group (optional).
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
type: str
|
|
||||||
default: Any
|
|
||||||
min: float | None = None
|
|
||||||
max: float | None = None
|
|
||||||
step: float | None = None
|
|
||||||
options: tuple[EnumOption, ...] = field(default_factory=tuple)
|
|
||||||
unit: str | None = None
|
|
||||||
group: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VoiceManifest:
|
|
||||||
"""Manifest describing a voice.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Voice identifier.
|
|
||||||
name: Human-readable name.
|
|
||||||
tags: Voice tags (e.g., language, style).
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VoiceSourceManifest:
|
|
||||||
"""Manifest describing a voice source.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Voice source identifier.
|
|
||||||
name: Human-readable name.
|
|
||||||
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
|
|
||||||
config: Source-specific configuration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
config: Any = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EngineManifest:
|
|
||||||
"""Manifest describing engine capabilities.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
voiceSources: Available voice sources.
|
|
||||||
parameters: Available synthesis parameters.
|
|
||||||
audioFormats: Supported audio formats.
|
|
||||||
"""
|
|
||||||
|
|
||||||
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
|
|
||||||
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
|
|
||||||
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GpuRequirement:
|
|
||||||
"""Manifest describing GPU requirements.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
required: Whether GPU is required.
|
|
||||||
type: GPU type (e.g., "cuda", "rocm").
|
|
||||||
memory: Required GPU memory in GB.
|
|
||||||
"""
|
|
||||||
|
|
||||||
required: bool = False
|
|
||||||
type: str | None = None
|
|
||||||
memory: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RequirementManifest:
|
|
||||||
"""Manifest describing plugin requirements.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
gpu: GPU requirements (optional).
|
|
||||||
memory: Required RAM in GB (optional).
|
|
||||||
internet: Whether internet is required (optional).
|
|
||||||
"""
|
|
||||||
|
|
||||||
gpu: GpuRequirement | None = None
|
|
||||||
memory: float | None = None
|
|
||||||
internet: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ModelManifest:
|
|
||||||
"""Manifest describing a model requirement.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Model identifier.
|
|
||||||
name: Human-readable name.
|
|
||||||
size: Model size as string (e.g., "100MB", "2GB").
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
size: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PluginManifest:
|
|
||||||
"""Main manifest for a TTS plugin.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Plugin identifier (unique).
|
|
||||||
name: Human-readable name.
|
|
||||||
version: Plugin version.
|
|
||||||
api_version: API version (semver format: MAJOR.MINOR).
|
|
||||||
description: Plugin description.
|
|
||||||
author: Plugin author.
|
|
||||||
capabilities: List of capability identifiers.
|
|
||||||
requires: Plugin requirements.
|
|
||||||
engine: Engine manifest.
|
|
||||||
voices: Optional static voice catalog. None = not declared (use VoiceLister),
|
|
||||||
empty tuple = explicitly no static voices, non-empty = static catalog.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
version: str
|
|
||||||
api_version: str
|
|
||||||
description: str
|
|
||||||
author: str
|
|
||||||
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
|
||||||
requires: RequirementManifest = field(default_factory=RequirementManifest)
|
|
||||||
engine: EngineManifest = field(default_factory=EngineManifest)
|
|
||||||
voices: tuple[VoiceManifest, ...] | None = None
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Plugin contract for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module defines the plugin contract that all TTS plugins must implement.
|
|
||||||
Each plugin must export:
|
|
||||||
- PLUGIN_MANIFEST: PluginManifest instance
|
|
||||||
- MODEL_REQUIREMENTS: list of ModelManifest instances
|
|
||||||
- create_engine(): Factory function that creates an Engine
|
|
||||||
|
|
||||||
The create_engine() function is the entry point for plugin activation.
|
|
||||||
It must be atomic: succeed fully or raise and clean up.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class Plugin(Protocol):
|
|
||||||
"""Protocol defining the plugin contract.
|
|
||||||
|
|
||||||
Every TTS plugin must implement this protocol by exporting:
|
|
||||||
- PLUGIN_MANIFEST: PluginManifest
|
|
||||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
|
||||||
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
|
||||||
"""
|
|
||||||
|
|
||||||
def create_engine(
|
|
||||||
self,
|
|
||||||
context: HostContext,
|
|
||||||
model_path: Path | None,
|
|
||||||
config: EngineConfig,
|
|
||||||
) -> Engine:
|
|
||||||
"""Create an engine instance.
|
|
||||||
|
|
||||||
This is the factory function that creates an Engine from a plugin.
|
|
||||||
It must be atomic: succeed fully or raise EngineError and clean up.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Host services (config dir, logger, http client).
|
|
||||||
model_path: Resolved model path, or None for cloud/no-model engines.
|
|
||||||
config: Engine initialization settings.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A fully initialized Engine instance.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: On failure. Cleans up partially created resources.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"""Plugin Manager
|
|
||||||
|
|
||||||
Provides a simple interface for consumers to access TTS engines via the
|
|
||||||
new Plugin Architecture. Discovers, loads, and manages plugins from the
|
|
||||||
plugins directory.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
|
||||||
|
|
||||||
manager = get_plugin_manager()
|
|
||||||
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
|
||||||
session = engine.create_session()
|
|
||||||
try:
|
|
||||||
result = session.synthesize("Hello world")
|
|
||||||
finally:
|
|
||||||
session.dispose()
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Type
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.manifest import PluginManifest
|
|
||||||
from abogen.tts_plugin.types import AudioFormat
|
|
||||||
|
|
||||||
|
|
||||||
class PluginManager:
|
|
||||||
"""Manages TTS plugins and provides a simple interface for consumers."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._plugins: Dict[str, dict] = {}
|
|
||||||
self._engines: Dict[str, Engine] = {}
|
|
||||||
self._loaded = False
|
|
||||||
|
|
||||||
def discover(self, plugins_dir: str = "plugins") -> None:
|
|
||||||
"""Discover and load all plugins from the given directory."""
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
|
||||||
|
|
||||||
self._plugins.clear()
|
|
||||||
self._engines.clear()
|
|
||||||
|
|
||||||
plugins_path = Path(plugins_dir)
|
|
||||||
if not plugins_path.exists():
|
|
||||||
self._loaded = True
|
|
||||||
return
|
|
||||||
|
|
||||||
for entry in plugins_path.iterdir():
|
|
||||||
if entry.is_dir() and (entry / "__init__.py").exists():
|
|
||||||
try:
|
|
||||||
result = load_plugin_from_dir(entry)
|
|
||||||
if result.success and result.manifest is not None:
|
|
||||||
self._plugins[result.manifest.id] = {
|
|
||||||
"manifest": result.manifest,
|
|
||||||
"create_engine": result.create_engine,
|
|
||||||
"module": result.module,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but continue with other plugins
|
|
||||||
print(f"Warning: Failed to load plugin from {entry}: {e}")
|
|
||||||
|
|
||||||
self._loaded = True
|
|
||||||
|
|
||||||
def _ensure_loaded(self) -> None:
|
|
||||||
"""Ensure plugins have been discovered."""
|
|
||||||
if not self._loaded:
|
|
||||||
self.discover()
|
|
||||||
|
|
||||||
def list_plugins(self) -> List[PluginManifest]:
|
|
||||||
"""Return manifests for all loaded plugins."""
|
|
||||||
self._ensure_loaded()
|
|
||||||
return [info["manifest"] for info in self._plugins.values()]
|
|
||||||
|
|
||||||
def get_plugin(self, plugin_id: str) -> Optional[dict]:
|
|
||||||
"""Get plugin info by ID."""
|
|
||||||
self._ensure_loaded()
|
|
||||||
return self._plugins.get(plugin_id)
|
|
||||||
|
|
||||||
def has_plugin(self, plugin_id: str) -> bool:
|
|
||||||
"""Check if a plugin is loaded."""
|
|
||||||
self._ensure_loaded()
|
|
||||||
return plugin_id in self._plugins
|
|
||||||
|
|
||||||
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
|
||||||
"""Create an engine instance for the given plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_id: The plugin identifier (e.g., "kokoro")
|
|
||||||
**kwargs: Arguments passed to the engine constructor
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An Engine instance
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If plugin_id is not found
|
|
||||||
Exception: If engine creation fails
|
|
||||||
"""
|
|
||||||
self._ensure_loaded()
|
|
||||||
|
|
||||||
if plugin_id not in self._plugins:
|
|
||||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
|
||||||
|
|
||||||
plugin_info = self._plugins[plugin_id]
|
|
||||||
create_engine_func = plugin_info["create_engine"]
|
|
||||||
|
|
||||||
# Create engine using the plugin's factory
|
|
||||||
engine = create_engine_func(**kwargs)
|
|
||||||
return engine
|
|
||||||
|
|
||||||
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
|
||||||
"""Get an existing engine or create a new one.
|
|
||||||
|
|
||||||
Engines are cached by plugin_id. If you need multiple instances
|
|
||||||
with different parameters, use create_engine() directly.
|
|
||||||
"""
|
|
||||||
self._ensure_loaded()
|
|
||||||
|
|
||||||
cache_key = plugin_id
|
|
||||||
if cache_key in self._engines:
|
|
||||||
return self._engines[cache_key]
|
|
||||||
|
|
||||||
engine = self.create_engine(plugin_id, **kwargs)
|
|
||||||
self._engines[cache_key] = engine
|
|
||||||
return engine
|
|
||||||
|
|
||||||
def dispose_all(self) -> None:
|
|
||||||
"""Dispose all cached engines."""
|
|
||||||
for engine in self._engines.values():
|
|
||||||
try:
|
|
||||||
engine.dispose()
|
|
||||||
except Exception:
|
|
||||||
pass # dispose() should never raise
|
|
||||||
self._engines.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# Global singleton
|
|
||||||
_manager: Optional[PluginManager] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_manager() -> PluginManager:
|
|
||||||
"""Get the global PluginManager instance."""
|
|
||||||
global _manager
|
|
||||||
if _manager is None:
|
|
||||||
_manager = PluginManager()
|
|
||||||
return _manager
|
|
||||||
|
|
||||||
|
|
||||||
def reset_plugin_manager() -> None:
|
|
||||||
"""Reset the global PluginManager (for testing)."""
|
|
||||||
global _manager
|
|
||||||
if _manager is not None:
|
|
||||||
_manager.dispose_all()
|
|
||||||
_manager = None
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""Core domain types for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module contains immutable value objects that form the core domain.
|
|
||||||
These types have zero dependencies and are used across the plugin system.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AudioFormat:
|
|
||||||
"""Immutable value object representing an audio format.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
|
||||||
extension: File extension (e.g., "wav", "mp3").
|
|
||||||
"""
|
|
||||||
|
|
||||||
mime: str
|
|
||||||
extension: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Duration:
|
|
||||||
"""Immutable value object representing a time duration.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
seconds: Duration in seconds.
|
|
||||||
"""
|
|
||||||
|
|
||||||
seconds: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VoiceSelection:
|
|
||||||
"""Immutable value object for voice selection. Opaque to engine.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
source: Voice source identifier (e.g., "builtin", "clone").
|
|
||||||
key: Voice key within the source.
|
|
||||||
payload: Optional payload for clone/blend sources.
|
|
||||||
"""
|
|
||||||
|
|
||||||
source: str
|
|
||||||
key: str
|
|
||||||
payload: Any = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ParameterValues:
|
|
||||||
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
values: Mapping of parameter names to their values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
values: Mapping[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SynthesisRequest:
|
|
||||||
"""Immutable value object for a synthesis request.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
text: Text to synthesize.
|
|
||||||
voice: Voice selection.
|
|
||||||
parameters: Synthesis parameters.
|
|
||||||
format: Desired audio output format.
|
|
||||||
"""
|
|
||||||
|
|
||||||
text: str
|
|
||||||
voice: VoiceSelection
|
|
||||||
parameters: ParameterValues
|
|
||||||
format: AudioFormat
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SynthesizedAudio:
|
|
||||||
"""Immutable value object for synthesized audio result.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
data: Raw audio bytes.
|
|
||||||
format: Audio format of the result.
|
|
||||||
duration: Duration of the audio.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data: bytes
|
|
||||||
format: AudioFormat
|
|
||||||
duration: Duration
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EngineConfig:
|
|
||||||
"""Immutable configuration of an Engine instance.
|
|
||||||
|
|
||||||
Contains parameters that define how a particular Engine instance is
|
|
||||||
created and that remain constant throughout the lifetime of that Engine.
|
|
||||||
|
|
||||||
Plugin implementations may ignore fields that are not applicable to them.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
device: Device to use (e.g., "cpu", "cuda:0").
|
|
||||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
|
||||||
Plugins that do not require a language code ignore this field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
device: str = "cpu"
|
|
||||||
lang_code: str = "a"
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""TTS Plugin Architecture — direct utility functions.
|
|
||||||
|
|
||||||
Provides helpers that replace the former compatibility adapter by
|
|
||||||
calling the Plugin Manager directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Iterator
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
|
||||||
|
|
||||||
|
|
||||||
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
|
||||||
"""Return the voice-id tuple for *plugin_id*.
|
|
||||||
|
|
||||||
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
|
||||||
First checks plugin manifest for static voice catalog.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
manager = get_plugin_manager()
|
|
||||||
if not manager.has_plugin(plugin_id):
|
|
||||||
return ()
|
|
||||||
|
|
||||||
# Check manifest for static voice catalog
|
|
||||||
plugin_info = manager.get_plugin(plugin_id)
|
|
||||||
if plugin_info is not None:
|
|
||||||
manifest = plugin_info.get("manifest")
|
|
||||||
if manifest is not None and manifest.voices is not None:
|
|
||||||
return tuple(v.id for v in manifest.voices)
|
|
||||||
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path(tempfile.gettempdir()),
|
|
||||||
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
|
||||||
http_client=type("_StubHttpClient", (), {
|
|
||||||
"get": staticmethod(lambda url, **kw: None),
|
|
||||||
"post": staticmethod(lambda url, **kw: None),
|
|
||||||
})(),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
engine = manager.create_engine(
|
|
||||||
plugin_id,
|
|
||||||
context=ctx,
|
|
||||||
model_path=None,
|
|
||||||
config=EngineConfig(device="cpu"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return ()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from abogen.tts_plugin.capabilities import VoiceLister
|
|
||||||
|
|
||||||
if isinstance(engine, VoiceLister):
|
|
||||||
manifests = engine.listVoices("builtin")
|
|
||||||
return tuple(v.id for v in manifests)
|
|
||||||
return ()
|
|
||||||
except Exception:
|
|
||||||
return ()
|
|
||||||
finally:
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
|
||||||
"""Return the first voice of *plugin_id*, or *fallback*."""
|
|
||||||
voices = get_voices(plugin_id)
|
|
||||||
return voices[0] if voices else fallback
|
|
||||||
|
|
||||||
|
|
||||||
def is_plugin_registered(plugin_id: str) -> bool:
|
|
||||||
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
|
||||||
return get_plugin_manager().has_plugin(plugin_id)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
|
||||||
"""Determine which plugin owns the given voice specification.
|
|
||||||
|
|
||||||
Resolution rules:
|
|
||||||
1. Empty spec -> fallback
|
|
||||||
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
|
||||||
3. Exact voice-id match against loaded plugins -> plugin id
|
|
||||||
4. Unknown voice -> fallback
|
|
||||||
"""
|
|
||||||
raw = str(spec or "").strip()
|
|
||||||
if not raw:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
if "*" in raw or "+" in raw:
|
|
||||||
return "kokoro"
|
|
||||||
|
|
||||||
upper = raw.upper()
|
|
||||||
manager = get_plugin_manager()
|
|
||||||
|
|
||||||
for manifest in manager.list_plugins():
|
|
||||||
for voice_source in manifest.engine.voiceSources:
|
|
||||||
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
|
||||||
try:
|
|
||||||
engine = manager.create_engine(manifest.id)
|
|
||||||
try:
|
|
||||||
if hasattr(engine, "listVoices"):
|
|
||||||
voice_manifests = engine.listVoices(voice_source.id)
|
|
||||||
voice_ids = [v.id.upper() for v in voice_manifests]
|
|
||||||
if upper in voice_ids:
|
|
||||||
return manifest.id
|
|
||||||
finally:
|
|
||||||
engine.dispose()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
class Pipeline:
|
|
||||||
"""Callable wrapper around Engine / EngineSession.
|
|
||||||
|
|
||||||
Presents the same interface that old callers expect::
|
|
||||||
|
|
||||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
|
||||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
|
||||||
audio = segment.audio
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
|
||||||
self._engine = engine
|
|
||||||
self._engine_kwargs = engine_kwargs
|
|
||||||
self._session: Any = None
|
|
||||||
|
|
||||||
def _ensure_session(self) -> Any:
|
|
||||||
if self._session is None:
|
|
||||||
self._session = self._engine.createSession()
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
voice: str = "default",
|
|
||||||
speed: float = 1.0,
|
|
||||||
split_pattern: str | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> Iterator[Any]:
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
session = self._ensure_session()
|
|
||||||
|
|
||||||
params: dict[str, Any] = {"speed": speed}
|
|
||||||
if split_pattern is not None:
|
|
||||||
params["split_pattern"] = split_pattern
|
|
||||||
params.update(kwargs)
|
|
||||||
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text=text,
|
|
||||||
voice=VoiceSelection(source="builtin", key=voice),
|
|
||||||
parameters=ParameterValues(values=params),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = session.synthesize(request)
|
|
||||||
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Segment:
|
|
||||||
graphemes: str
|
|
||||||
audio: np.ndarray
|
|
||||||
|
|
||||||
yield Segment(graphemes=text, audio=audio_array)
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
if self._session is not None:
|
|
||||||
try:
|
|
||||||
self._session.dispose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
self.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def create_pipeline(
|
|
||||||
plugin_id: str,
|
|
||||||
*,
|
|
||||||
lang_code: str = "a",
|
|
||||||
device: str = "cpu",
|
|
||||||
) -> Pipeline:
|
|
||||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
|
||||||
|
|
||||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
|
||||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
|
||||||
``__call__`` interface matches the callable protocol used by consumers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
|
||||||
lang_code: Language code for the engine.
|
|
||||||
device: Device to use (e.g., "cpu", "cuda:0").
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A callable Pipeline instance.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
manager = get_plugin_manager()
|
|
||||||
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path(tempfile.gettempdir()),
|
|
||||||
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
|
||||||
http_client=type("_StubHttpClient", (), {
|
|
||||||
"get": staticmethod(lambda url, **kw: None),
|
|
||||||
"post": staticmethod(lambda url, **kw: None),
|
|
||||||
})(),
|
|
||||||
)
|
|
||||||
|
|
||||||
config = EngineConfig(device=device, lang_code=lang_code)
|
|
||||||
|
|
||||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
|
||||||
return Pipeline(engine)
|
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
|
|
||||||
|
|
||||||
This module provides the SuperTonicPipeline class and supporting utilities
|
|
||||||
used by the SuperTonic plugin. It is independent of the legacy
|
|
||||||
abogen.tts_backends module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable, Iterator, Optional
|
from typing import Any, Iterable, Iterator, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SupertonicSegment:
|
||||||
|
graphemes: str
|
||||||
|
audio: np.ndarray
|
||||||
|
|
||||||
|
|
||||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
||||||
arr = np.asarray(wav, dtype="float32")
|
arr = np.asarray(wav, dtype="float32")
|
||||||
if arr.ndim == 2:
|
if arr.ndim == 2:
|
||||||
|
# (n, 1) or (1, n) or (n, channels)
|
||||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
||||||
arr = arr.reshape(-1)
|
arr = arr.reshape(-1)
|
||||||
else:
|
else:
|
||||||
@@ -56,6 +62,7 @@ def _split_text(
|
|||||||
else:
|
else:
|
||||||
parts = [stripped]
|
parts = [stripped]
|
||||||
|
|
||||||
|
# Enforce max length by hard-splitting long parts.
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if len(part) <= max_chunk_length:
|
if len(part) <= max_chunk_length:
|
||||||
@@ -64,6 +71,7 @@ def _split_text(
|
|||||||
start = 0
|
start = 0
|
||||||
while start < len(part):
|
while start < len(part):
|
||||||
end = min(len(part), start + max_chunk_length)
|
end = min(len(part), start + max_chunk_length)
|
||||||
|
# Try to split at whitespace.
|
||||||
if end < len(part):
|
if end < len(part):
|
||||||
ws = part.rfind(" ", start, end)
|
ws = part.rfind(" ", start, end)
|
||||||
if ws > start + 40:
|
if ws > start + 40:
|
||||||
@@ -82,6 +90,7 @@ _UNSUPPORTED_CHARS_RE = re.compile(
|
|||||||
|
|
||||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
||||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
||||||
|
|
||||||
message = " ".join(
|
message = " ".join(
|
||||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
str(part) for part in getattr(error, "args", ()) if part is not None
|
||||||
) or str(error)
|
) or str(error)
|
||||||
@@ -127,11 +136,16 @@ def _configure_supertonic_gpu() -> None:
|
|||||||
|
|
||||||
available = ort.get_available_providers()
|
available = ort.get_available_providers()
|
||||||
|
|
||||||
|
# Use CUDA if available, skip TensorRT (requires extra libs not always present)
|
||||||
|
# TensorrtExecutionProvider may be listed as available but fail at runtime
|
||||||
|
# if TensorRT libraries (libnvinfer.so) are not installed
|
||||||
providers = []
|
providers = []
|
||||||
if "CUDAExecutionProvider" in available:
|
if "CUDAExecutionProvider" in available:
|
||||||
providers.append("CUDAExecutionProvider")
|
providers.append("CUDAExecutionProvider")
|
||||||
providers.append("CPUExecutionProvider")
|
providers.append("CPUExecutionProvider")
|
||||||
|
|
||||||
|
# Patch supertonic's config and loader before TTS import
|
||||||
|
# We must patch both because loader imports the value at module load time
|
||||||
import supertonic.config as supertonic_config
|
import supertonic.config as supertonic_config
|
||||||
import supertonic.loader as supertonic_loader
|
import supertonic.loader as supertonic_loader
|
||||||
|
|
||||||
@@ -142,16 +156,6 @@ def _configure_supertonic_gpu() -> None:
|
|||||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
class SupertonicSegment:
|
|
||||||
"""A single synthesized audio segment."""
|
|
||||||
|
|
||||||
__slots__ = ("graphemes", "audio")
|
|
||||||
|
|
||||||
def __init__(self, graphemes: str, audio: np.ndarray) -> None:
|
|
||||||
self.graphemes = graphemes
|
|
||||||
self.audio = audio
|
|
||||||
|
|
||||||
|
|
||||||
class SupertonicPipeline:
|
class SupertonicPipeline:
|
||||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
||||||
|
|
||||||
@@ -167,6 +171,7 @@ class SupertonicPipeline:
|
|||||||
self.total_steps = int(total_steps)
|
self.total_steps = int(total_steps)
|
||||||
self.max_chunk_length = int(max_chunk_length)
|
self.max_chunk_length = int(max_chunk_length)
|
||||||
|
|
||||||
|
# Configure GPU providers before importing TTS
|
||||||
_configure_supertonic_gpu()
|
_configure_supertonic_gpu()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -202,6 +207,7 @@ class SupertonicPipeline:
|
|||||||
removed: set[str] = set()
|
removed: set[str] = set()
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
|
|
||||||
|
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
wav, duration = self._tts.synthesize(
|
wav, duration = self._tts.synthesize(
|
||||||
@@ -225,6 +231,7 @@ class SupertonicPipeline:
|
|||||||
chunk_to_speak, unsupported
|
chunk_to_speak, unsupported
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
|
# If we didn't change anything, don't loop forever.
|
||||||
if sanitized == chunk_to_speak.strip():
|
if sanitized == chunk_to_speak.strip():
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -242,6 +249,7 @@ class SupertonicPipeline:
|
|||||||
sorted(removed),
|
sorted(removed),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Exhausted retries.
|
||||||
assert last_exc is not None
|
assert last_exc is not None
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
@@ -250,6 +258,7 @@ class SupertonicPipeline:
|
|||||||
|
|
||||||
audio = _ensure_float32_mono(wav)
|
audio = _ensure_float32_mono(wav)
|
||||||
|
|
||||||
|
# If duration is present, infer the source sample rate and resample if needed.
|
||||||
src_rate = self.sample_rate
|
src_rate = self.sample_rate
|
||||||
try:
|
try:
|
||||||
dur = float(duration)
|
dur = float(duration)
|
||||||
+11
-10
@@ -529,20 +529,21 @@ def prevent_sleep_end():
|
|||||||
_sleep_procs[system] = None
|
_sleep_procs[system] = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_numpy_kpipeline():
|
||||||
|
import numpy as np
|
||||||
|
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
return np, KPipeline
|
||||||
|
|
||||||
|
|
||||||
class LoadPipelineThread(Thread):
|
class LoadPipelineThread(Thread):
|
||||||
def __init__(self, callback, lang_code="a", device="cpu"):
|
def __init__(self, callback):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.lang_code = lang_code
|
|
||||||
self.device = device
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
from abogen.tts_plugin.utils import create_pipeline
|
np_module, kpipeline_class = load_numpy_kpipeline()
|
||||||
|
self.callback(np_module, kpipeline_class, None)
|
||||||
backend = create_pipeline(
|
|
||||||
"kokoro", lang_code=self.lang_code, device=self.device
|
|
||||||
)
|
|
||||||
self.callback(backend, None)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.callback(None, str(e))
|
self.callback(None, None, str(e))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
_CACHE_LOCK = threading.Lock()
|
_CACHE_LOCK = threading.Lock()
|
||||||
_CACHED_VOICES: Set[str] = set()
|
_CACHED_VOICES: Set[str] = set()
|
||||||
@@ -26,9 +26,8 @@ _BOOTSTRAPPED = False
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||||
kokoro_voices = get_voices("kokoro")
|
|
||||||
if not voices:
|
if not voices:
|
||||||
return set(kokoro_voices)
|
return set(VOICES_INTERNAL)
|
||||||
normalized: Set[str] = set()
|
normalized: Set[str] = set()
|
||||||
for voice in voices:
|
for voice in voices:
|
||||||
if not voice:
|
if not voice:
|
||||||
@@ -36,7 +35,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
|||||||
voice_id = str(voice).strip()
|
voice_id = str(voice).strip()
|
||||||
if not voice_id:
|
if not voice_id:
|
||||||
continue
|
continue
|
||||||
if voice_id in kokoro_voices:
|
if voice_id in VOICES_INTERNAL:
|
||||||
normalized.add(voice_id)
|
normalized.add(voice_id)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
|
|
||||||
# Calls parsing and loads the voice to gpu or cpu
|
# Calls parsing and loads the voice to gpu or cpu
|
||||||
@@ -22,7 +22,6 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
|||||||
raise ValueError("Empty voice formula")
|
raise ValueError("Empty voice formula")
|
||||||
|
|
||||||
terms: List[Tuple[str, float]] = []
|
terms: List[Tuple[str, float]] = []
|
||||||
kokoro_voices = get_voices("kokoro")
|
|
||||||
for segment in formula.split("+"):
|
for segment in formula.split("+"):
|
||||||
part = segment.strip()
|
part = segment.strip()
|
||||||
if not part:
|
if not part:
|
||||||
@@ -31,7 +30,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
|||||||
raise ValueError("Each component must be in the form voice*weight")
|
raise ValueError("Each component must be in the form voice*weight")
|
||||||
voice_name, raw_weight = part.split("*", 1)
|
voice_name, raw_weight = part.split("*", 1)
|
||||||
voice_name = voice_name.strip()
|
voice_name = voice_name.strip()
|
||||||
if voice_name not in kokoro_voices:
|
if voice_name not in VOICES_INTERNAL:
|
||||||
raise ValueError(f"Unknown voice: {voice_name}")
|
raise ValueError(f"Unknown voice: {voice_name}")
|
||||||
try:
|
try:
|
||||||
weight = float(raw_weight.strip())
|
weight = float(raw_weight.strip())
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VoiceMetadata:
|
|
||||||
"""
|
|
||||||
Immutable metadata describing a voice from a TTS backend.
|
|
||||||
|
|
||||||
This model describes a voice independently of any backend implementation.
|
|
||||||
Backends populate these objects; the application consumes them.
|
|
||||||
|
|
||||||
The ``backend_id`` field is set by the backend itself (via
|
|
||||||
``self.metadata.id``) — the application never hardcodes it.
|
|
||||||
This ensures renaming a backend does not require touching voice definitions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
"""Unique voice identifier within the backend (e.g. ``"af_alloy"``, ``"M1"``)."""
|
|
||||||
|
|
||||||
display_name: str
|
|
||||||
"""Human-readable display name (e.g. ``"Alloy"``, ``"Male 1"``)."""
|
|
||||||
|
|
||||||
language: str
|
|
||||||
"""Language code — backend-specific format is acceptable (e.g. ``"a"``, ``"en"``)."""
|
|
||||||
|
|
||||||
gender: str
|
|
||||||
"""Gender category: ``"female"``, ``"male"``, or ``"unknown"``."""
|
|
||||||
|
|
||||||
backend_id: str
|
|
||||||
"""Identifier of the backend that owns this voice (e.g. ``"kokoro"``).
|
|
||||||
|
|
||||||
Set automatically by the backend — never hardcoded in voice definitions.
|
|
||||||
"""
|
|
||||||
@@ -2,7 +2,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Iterable, List, Tuple
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||||
from abogen.utils import get_user_config_path
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +70,7 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
|||||||
|
|
||||||
def _normalize_supertonic_voice(value: Any) -> str:
|
def _normalize_supertonic_voice(value: Any) -> str:
|
||||||
raw = str(value or "").strip().upper()
|
raw = str(value or "").strip().upper()
|
||||||
supertonic_voices = get_voices("supertonic")
|
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1"
|
||||||
return raw if raw in supertonic_voices else "M1"
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_supertonic_steps(value: Any) -> int:
|
def _coerce_supertonic_steps(value: Any) -> int:
|
||||||
@@ -101,7 +101,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
provider = str(entry.get("provider") or "kokoro").strip().lower()
|
||||||
if not is_plugin_registered(provider):
|
if provider not in {"kokoro", "supertonic"}:
|
||||||
provider = "kokoro"
|
provider = "kokoro"
|
||||||
|
|
||||||
language = str(entry.get("language") or "a").strip().lower() or "a"
|
language = str(entry.get("language") or "a").strip().lower() or "a"
|
||||||
@@ -135,7 +135,6 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||||
normalized: List[Tuple[str, float]] = []
|
normalized: List[Tuple[str, float]] = []
|
||||||
kokoro_voices = get_voices("kokoro")
|
|
||||||
for item in entries or []:
|
for item in entries or []:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
voice = item.get("id") or item.get("voice")
|
voice = item.get("id") or item.get("voice")
|
||||||
@@ -144,7 +143,7 @@ def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
|||||||
voice, weight = item[0], item[1]
|
voice, weight = item[0], item[1]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
if voice not in kokoro_voices:
|
if voice not in VOICES_INTERNAL:
|
||||||
continue
|
continue
|
||||||
if weight is None:
|
if weight is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+12
-11
@@ -2,6 +2,7 @@ FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
VIRTUAL_ENV=/opt/venv \
|
VIRTUAL_ENV=/opt/venv \
|
||||||
PATH=/opt/venv/bin:$PATH
|
PATH=/opt/venv/bin:$PATH
|
||||||
|
|
||||||
@@ -26,22 +27,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
RUN pip install uv \
|
|
||||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
|
||||||
uv pip install --system torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
|
||||||
else \
|
|
||||||
uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
|
||||||
fi \
|
|
||||||
&& uv pip install --system . \
|
|
||||||
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
|
|
||||||
&& uv pip install --system "mutagen>=1.47.0"
|
|
||||||
|
|
||||||
COPY abogen ./abogen
|
COPY abogen ./abogen
|
||||||
|
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||||
|
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||||
|
else \
|
||||||
|
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||||
|
fi \
|
||||||
|
&& pip install --no-cache-dir . \
|
||||||
|
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
|
||||||
|
&& pip install --no-cache-dir "mutagen>=1.47.0"
|
||||||
|
|
||||||
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
|
# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime)
|
||||||
# Set USE_GPU=false to skip this for CPU-only deployments
|
# Set USE_GPU=false to skip this for CPU-only deployments
|
||||||
RUN if [ "$USE_GPU" = "true" ]; then \
|
RUN if [ "$USE_GPU" = "true" ]; then \
|
||||||
uv pip install --system onnxruntime-gpu; \
|
pip install --no-cache-dir onnxruntime-gpu; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ENV ABOGEN_HOST=0.0.0.0 \
|
ENV ABOGEN_HOST=0.0.0.0 \
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import numpy as np
|
|||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered, resolve_voice_to_plugin
|
from abogen.constants import VOICES_INTERNAL
|
||||||
from abogen.epub3.exporter import build_epub3_package
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
|
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
@@ -39,14 +39,14 @@ from abogen.utils import (
|
|||||||
get_user_cache_path,
|
get_user_cache_path,
|
||||||
get_user_output_path,
|
get_user_output_path,
|
||||||
load_config,
|
load_config,
|
||||||
|
load_numpy_kpipeline,
|
||||||
)
|
)
|
||||||
from abogen.tts_plugin.utils import create_pipeline
|
|
||||||
from abogen.voice_cache import ensure_voice_assets
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
||||||
from abogen.voice_profiles import load_profiles, normalize_profile_entry
|
from abogen.voice_profiles import load_profiles, normalize_profile_entry
|
||||||
from abogen.pronunciation_store import increment_usage
|
from abogen.pronunciation_store import increment_usage
|
||||||
from abogen.llm_client import LLMClientError
|
from abogen.llm_client import LLMClientError
|
||||||
|
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline
|
||||||
|
|
||||||
from .service import Job, JobStatus
|
from .service import Job, JobStatus
|
||||||
|
|
||||||
@@ -56,26 +56,25 @@ SAMPLE_RATE = 24000
|
|||||||
|
|
||||||
|
|
||||||
def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
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()
|
raw = str(spec or "").strip()
|
||||||
fallback_raw = str(fallback or "").strip()
|
fallback_raw = str(fallback or "").strip()
|
||||||
|
|
||||||
# Normalize to uppercase for Supertonic voice IDs
|
# SuperTonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix
|
||||||
upper = raw.upper() if raw else ""
|
# formula (contains '*' or '+'), ignore it and fall back to a safe voice.
|
||||||
|
if not raw or "*" in raw or "+" in raw:
|
||||||
|
raw = fallback_raw
|
||||||
|
if not raw or "*" in raw or "+" in raw:
|
||||||
|
raw = "M1"
|
||||||
|
|
||||||
# If empty or contains formula characters, use fallback
|
upper = raw.upper()
|
||||||
if not upper or "*" in upper or "+" in upper:
|
if upper in DEFAULT_SUPERTONIC_VOICES:
|
||||||
upper = fallback_raw.upper() if fallback_raw else ""
|
return upper
|
||||||
|
|
||||||
# If still empty, use default Supertonic voice
|
fallback_upper = fallback_raw.upper() if fallback_raw else ""
|
||||||
if not upper or "*" in upper or "+" in upper:
|
if fallback_upper in DEFAULT_SUPERTONIC_VOICES:
|
||||||
upper = "M1"
|
return fallback_upper
|
||||||
|
|
||||||
return upper
|
return "M1"
|
||||||
|
|
||||||
|
|
||||||
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
|
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
|
||||||
@@ -119,7 +118,15 @@ def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||||
return resolve_voice_to_plugin(str(value or ""), fallback=fallback)
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return fallback
|
||||||
|
upper = raw.upper()
|
||||||
|
if upper in DEFAULT_SUPERTONIC_VOICES:
|
||||||
|
return "supertonic"
|
||||||
|
if "*" in raw or "+" in raw:
|
||||||
|
return "kokoro"
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
class _JobCancelled(Exception):
|
class _JobCancelled(Exception):
|
||||||
@@ -568,7 +575,7 @@ def _spec_to_voice_ids(spec: Any) -> Set[str]:
|
|||||||
return set(extract_voice_ids(text))
|
return set(extract_voice_ids(text))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return set()
|
return set()
|
||||||
if text in get_voices("kokoro"):
|
if text in VOICES_INTERNAL:
|
||||||
return {text}
|
return {text}
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
@@ -632,7 +639,7 @@ def _collect_required_voice_ids(job: Job) -> Set[str]:
|
|||||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||||
voices.update(_spec_to_voice_ids(payload.get(key)))
|
voices.update(_spec_to_voice_ids(payload.get(key)))
|
||||||
|
|
||||||
voices.update(get_voices("kokoro"))
|
voices.update(VOICES_INTERNAL)
|
||||||
return voices
|
return voices
|
||||||
|
|
||||||
|
|
||||||
@@ -1566,7 +1573,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
def get_pipeline(provider: str) -> Any:
|
def get_pipeline(provider: str) -> Any:
|
||||||
nonlocal kokoro_cache_ready
|
nonlocal kokoro_cache_ready
|
||||||
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
|
provider_norm = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||||
if not is_plugin_registered(provider_norm):
|
if provider_norm not in {"kokoro", "supertonic"}:
|
||||||
provider_norm = "kokoro"
|
provider_norm = "kokoro"
|
||||||
|
|
||||||
existing = pipelines.get(provider_norm)
|
existing = pipelines.get(provider_norm)
|
||||||
@@ -1574,8 +1581,10 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
return existing
|
return existing
|
||||||
|
|
||||||
if provider_norm == "supertonic":
|
if provider_norm == "supertonic":
|
||||||
pipelines[provider_norm] = create_pipeline(
|
pipelines[provider_norm] = SupertonicPipeline(
|
||||||
"supertonic",
|
sample_rate=SAMPLE_RATE,
|
||||||
|
auto_download=True,
|
||||||
|
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
|
||||||
)
|
)
|
||||||
return pipelines[provider_norm]
|
return pipelines[provider_norm]
|
||||||
|
|
||||||
@@ -1585,12 +1594,16 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
device = "cpu"
|
device = "cpu"
|
||||||
if not disable_gpu:
|
if not disable_gpu:
|
||||||
device = _select_device()
|
device = _select_device()
|
||||||
# Create KPipeline instance directly (uses new Plugin Architecture)
|
_np, KPipeline = load_numpy_kpipeline()
|
||||||
pipelines[provider_norm] = create_pipeline(
|
# Try to initialize with the selected device; fall back to CPU if CUDA fails
|
||||||
"kokoro",
|
try:
|
||||||
lang_code=job.language,
|
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
device=device
|
except RuntimeError as e:
|
||||||
)
|
if "CUDA" in str(e) and device != "cpu":
|
||||||
|
job.add_log(f"CUDA initialization failed, falling back to CPU: {e}", level="warning")
|
||||||
|
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device="cpu")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
if not kokoro_cache_ready:
|
if not kokoro_cache_ready:
|
||||||
_initialize_voice_cache(job)
|
_initialize_voice_cache(job)
|
||||||
kokoro_cache_ready = True
|
kokoro_cache_ready = True
|
||||||
@@ -1631,8 +1644,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
return provider, resolved, cached, speed, steps
|
return provider, resolved, cached, speed, steps
|
||||||
|
|
||||||
if provider == "kokoro":
|
if provider == "kokoro":
|
||||||
kokoro_backend = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
choice = _resolve_voice(kokoro_backend, resolved, job.use_gpu)
|
choice = _resolve_voice(kokoro_pipeline, resolved, job.use_gpu)
|
||||||
else:
|
else:
|
||||||
choice = resolved
|
choice = resolved
|
||||||
|
|
||||||
@@ -1761,8 +1774,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
voice_cache: Dict[str, Any] = {}
|
voice_cache: Dict[str, Any] = {}
|
||||||
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
|
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
|
||||||
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
|
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
|
||||||
kokoro_backend = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu)
|
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu)
|
||||||
processed_chars = 0
|
processed_chars = 0
|
||||||
subtitle_index = 1
|
subtitle_index = 1
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
@@ -1792,8 +1805,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
fallback_key = next(iter(voice_cache.keys()), "")
|
fallback_key = next(iter(voice_cache.keys()), "")
|
||||||
if fallback_key and fallback_key != "__custom_mix":
|
if fallback_key and fallback_key != "__custom_mix":
|
||||||
intro_voice_spec = fallback_key.split(":", 1)[-1]
|
intro_voice_spec = fallback_key.split(":", 1)[-1]
|
||||||
if not intro_voice_spec:
|
if not intro_voice_spec and VOICES_INTERNAL:
|
||||||
intro_voice_spec = get_default_voice("kokoro")
|
intro_voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
if intro_voice_spec:
|
if intro_voice_spec:
|
||||||
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
|
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
|
||||||
@@ -1847,59 +1860,53 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
kokoro_backend = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
segment_iter = kokoro_backend(
|
segment_iter = kokoro_pipeline(
|
||||||
normalized,
|
normalized,
|
||||||
voice=voice_choice,
|
voice=voice_choice,
|
||||||
speed=float(speed_override if speed_override is not None else job.speed),
|
speed=float(speed_override if speed_override is not None else job.speed),
|
||||||
split_pattern=split_pattern,
|
split_pattern=split_pattern,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
for segment in segment_iter:
|
||||||
for segment in segment_iter:
|
canceller()
|
||||||
canceller()
|
graphemes_raw = getattr(segment, "graphemes", "") or ""
|
||||||
graphemes_raw = getattr(segment, "graphemes", "") or ""
|
graphemes = graphemes_raw.strip()
|
||||||
graphemes = graphemes_raw.strip()
|
|
||||||
|
|
||||||
audio = _to_float32(getattr(segment, "audio", None))
|
audio = _to_float32(getattr(segment, "audio", None))
|
||||||
if audio.size == 0:
|
if audio.size == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
local_segments += 1
|
local_segments += 1
|
||||||
if chapter_sink:
|
if chapter_sink:
|
||||||
chapter_sink.write(audio)
|
chapter_sink.write(audio)
|
||||||
if audio_sink:
|
if audio_sink:
|
||||||
audio_sink.write(audio)
|
audio_sink.write(audio)
|
||||||
|
|
||||||
duration = len(audio) / SAMPLE_RATE
|
duration = len(audio) / SAMPLE_RATE
|
||||||
processed_chars += len(graphemes)
|
processed_chars += len(graphemes)
|
||||||
job.processed_characters = processed_chars
|
job.processed_characters = processed_chars
|
||||||
if job.total_characters:
|
if job.total_characters:
|
||||||
job.progress = min(processed_chars / job.total_characters, 0.999)
|
job.progress = min(processed_chars / job.total_characters, 0.999)
|
||||||
else:
|
else:
|
||||||
job.progress = 0.0 if processed_chars == 0 else 0.999
|
job.progress = 0.0 if processed_chars == 0 else 0.999
|
||||||
|
|
||||||
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
|
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
|
||||||
prefix = f"{preview_prefix} · " if preview_prefix else ""
|
prefix = f"{preview_prefix} · " if preview_prefix else ""
|
||||||
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}")
|
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}")
|
||||||
|
|
||||||
if subtitle_writer and audio_sink and graphemes:
|
if subtitle_writer and audio_sink and graphemes:
|
||||||
subtitle_writer.write_segment(
|
subtitle_writer.write_segment(
|
||||||
index=subtitle_index,
|
index=subtitle_index,
|
||||||
text=graphemes,
|
text=graphemes,
|
||||||
start=current_time,
|
start=current_time,
|
||||||
end=current_time + duration,
|
end=current_time + duration,
|
||||||
)
|
)
|
||||||
subtitle_index += 1
|
subtitle_index += 1
|
||||||
|
|
||||||
if audio_sink:
|
if audio_sink:
|
||||||
current_time += duration
|
current_time += duration
|
||||||
|
|
||||||
except OverflowError as exc:
|
|
||||||
job.add_log(
|
|
||||||
f"Skipped chunk — number too large for TTS conversion: {exc}",
|
|
||||||
level="warning",
|
|
||||||
)
|
|
||||||
return local_segments
|
return local_segments
|
||||||
|
|
||||||
def append_silence(
|
def append_silence(
|
||||||
@@ -1943,8 +1950,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
if chapter_provider == "kokoro":
|
if chapter_provider == "kokoro":
|
||||||
voice_choice = voice_cache.get(chapter_cache_key)
|
voice_choice = voice_cache.get(chapter_cache_key)
|
||||||
if voice_choice is None:
|
if voice_choice is None:
|
||||||
kokoro_backend = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
voice_choice = _resolve_voice(kokoro_backend, chapter_voice_resolved, job.use_gpu)
|
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu)
|
||||||
voice_cache[chapter_cache_key] = voice_choice
|
voice_cache[chapter_cache_key] = voice_choice
|
||||||
else:
|
else:
|
||||||
voice_choice = chapter_voice_resolved
|
voice_choice = chapter_voice_resolved
|
||||||
@@ -2088,9 +2095,9 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
if chunk_provider == "kokoro":
|
if chunk_provider == "kokoro":
|
||||||
chunk_voice_choice = voice_cache.get(chunk_cache_key)
|
chunk_voice_choice = voice_cache.get(chunk_cache_key)
|
||||||
if chunk_voice_choice is None:
|
if chunk_voice_choice is None:
|
||||||
kokoro_backend = get_pipeline("kokoro")
|
kokoro_pipeline = get_pipeline("kokoro")
|
||||||
chunk_voice_choice = _resolve_voice(
|
chunk_voice_choice = _resolve_voice(
|
||||||
kokoro_backend,
|
kokoro_pipeline,
|
||||||
chunk_voice_resolved,
|
chunk_voice_resolved,
|
||||||
job.use_gpu,
|
job.use_gpu,
|
||||||
)
|
)
|
||||||
@@ -2232,8 +2239,8 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
if fallback_key and fallback_key != "__custom_mix":
|
if fallback_key and fallback_key != "__custom_mix":
|
||||||
# `voice_cache` keys are internal and include provider prefixes.
|
# `voice_cache` keys are internal and include provider prefixes.
|
||||||
outro_voice_spec = fallback_key.split(":", 1)[-1]
|
outro_voice_spec = fallback_key.split(":", 1)[-1]
|
||||||
if not outro_voice_spec:
|
if not outro_voice_spec and VOICES_INTERNAL:
|
||||||
outro_voice_spec = get_default_voice("kokoro")
|
outro_voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
if outro_text and outro_voice_spec:
|
if outro_text and outro_voice_spec:
|
||||||
outro_start_time = current_time
|
outro_start_time = current_time
|
||||||
@@ -2405,11 +2412,6 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
|
|
||||||
# Explicitly release the pipeline and force garbage collection to prevent
|
# Explicitly release the pipeline and force garbage collection to prevent
|
||||||
# memory accumulation in the worker process, which can lead to host lockups.
|
# memory accumulation in the worker process, which can lead to host lockups.
|
||||||
for p in pipelines.values():
|
|
||||||
try:
|
|
||||||
p.dispose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
pipelines.clear()
|
pipelines.clear()
|
||||||
pipeline = None
|
pipeline = None
|
||||||
gc.collect()
|
gc.collect()
|
||||||
@@ -2443,14 +2445,17 @@ def _load_pipeline(job: Job):
|
|||||||
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
|
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
|
||||||
provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower()
|
provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower()
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
return create_pipeline(
|
return SupertonicPipeline(
|
||||||
"supertonic",
|
sample_rate=SAMPLE_RATE,
|
||||||
|
auto_download=True,
|
||||||
|
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
|
||||||
)
|
)
|
||||||
|
|
||||||
device = "cpu"
|
device = "cpu"
|
||||||
if not disable_gpu:
|
if not disable_gpu:
|
||||||
device = _select_device()
|
device = _select_device()
|
||||||
return create_pipeline("kokoro", lang_code=job.language, device=device)
|
_np, KPipeline = load_numpy_kpipeline()
|
||||||
|
return KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
|
|
||||||
|
|
||||||
def _select_device() -> str:
|
def _select_device() -> str:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from abogen.normalization_settings import build_apostrophe_config
|
|||||||
from abogen.text_extractor import extract_from_path
|
from abogen.text_extractor import extract_from_path
|
||||||
from abogen.voice_cache import ensure_voice_assets
|
from abogen.voice_cache import ensure_voice_assets
|
||||||
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
|
from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
|
||||||
from abogen.tts_plugin.utils import create_pipeline
|
from abogen.utils import load_numpy_kpipeline
|
||||||
|
|
||||||
|
|
||||||
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P<code>[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
|
||||||
@@ -45,7 +45,8 @@ def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
|||||||
device = "cpu"
|
device = "cpu"
|
||||||
if use_gpu:
|
if use_gpu:
|
||||||
device = _select_device()
|
device = _select_device()
|
||||||
return create_pipeline("kokoro", lang_code=language, device=device)
|
_np, KPipeline = load_numpy_kpipeline()
|
||||||
|
return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
|
|
||||||
|
|
||||||
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
||||||
@@ -247,8 +248,4 @@ def run_debug_tts_wavs(
|
|||||||
"sample_rate": SAMPLE_RATE,
|
"sample_rate": SAMPLE_RATE,
|
||||||
}
|
}
|
||||||
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||||
try:
|
|
||||||
pipeline.dispose()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ from abogen.normalization_settings import (
|
|||||||
)
|
)
|
||||||
from abogen.llm_client import list_models, LLMClientError
|
from abogen.llm_client import list_models, LLMClientError
|
||||||
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||||
from abogen.tts_plugin.utils import is_plugin_registered
|
|
||||||
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
|
||||||
from abogen.integrations.calibre_opds import (
|
from abogen.integrations.calibre_opds import (
|
||||||
CalibreOPDSClient,
|
CalibreOPDSClient,
|
||||||
@@ -64,7 +63,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
|
|||||||
if profile is None:
|
if profile is None:
|
||||||
# Speaker Studio payload format
|
# Speaker Studio payload format
|
||||||
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
provider = str(payload.get("provider") or "kokoro").strip().lower()
|
||||||
if not is_plugin_registered(provider):
|
if provider not in {"kokoro", "supertonic"}:
|
||||||
provider = "kokoro"
|
provider = "kokoro"
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
profile = {
|
profile = {
|
||||||
@@ -231,7 +230,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
|||||||
use_gpu = settings.get("use_gpu", False)
|
use_gpu = settings.get("use_gpu", False)
|
||||||
|
|
||||||
base_spec, speaker_name = split_profile_spec(voice)
|
base_spec, speaker_name = split_profile_spec(voice)
|
||||||
resolved_provider = tts_provider if is_plugin_registered(tts_provider) else ""
|
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else ""
|
||||||
|
|
||||||
if speaker_name:
|
if speaker_name:
|
||||||
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
entry = normalize_profile_entry(load_profiles().get(speaker_name))
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from flask.typing import ResponseReturnValue
|
|||||||
|
|
||||||
from abogen.webui.service import PendingJob, JobStatus
|
from abogen.webui.service import PendingJob, JobStatus
|
||||||
from abogen.webui.routes.utils.service import get_service
|
from abogen.webui.routes.utils.service import get_service
|
||||||
from abogen.tts_plugin.utils import is_plugin_registered
|
|
||||||
from abogen.webui.routes.utils.settings import (
|
from abogen.webui.routes.utils.settings import (
|
||||||
load_settings,
|
load_settings,
|
||||||
coerce_bool,
|
coerce_bool,
|
||||||
@@ -33,7 +32,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
|
|||||||
from abogen.utils import calculate_text_length
|
from abogen.utils import calculate_text_length
|
||||||
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
|
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
|
||||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||||
from abogen.tts_plugin.utils import get_default_voice
|
from abogen.constants import VOICES_INTERNAL
|
||||||
from abogen.speaker_configs import get_config
|
from abogen.speaker_configs import get_config
|
||||||
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -580,7 +579,7 @@ def apply_book_step_form(
|
|||||||
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
|
||||||
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
|
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
|
||||||
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
provider_value = str(form.get("tts_provider") or "").strip().lower()
|
||||||
if is_plugin_registered(provider_value):
|
if provider_value in {"kokoro", "supertonic"}:
|
||||||
pending.tts_provider = provider_value
|
pending.tts_provider = provider_value
|
||||||
|
|
||||||
# Determine the base speaker selection (saved speaker ref or raw voice).
|
# Determine the base speaker selection (saved speaker ref or raw voice).
|
||||||
@@ -617,8 +616,8 @@ def apply_book_step_form(
|
|||||||
custom_formula = ""
|
custom_formula = ""
|
||||||
|
|
||||||
base_voice_spec = resolved_default_voice or narrator_voice_raw
|
base_voice_spec = resolved_default_voice or narrator_voice_raw
|
||||||
if not base_voice_spec:
|
if not base_voice_spec and VOICES_INTERNAL:
|
||||||
base_voice_spec = get_default_voice("kokoro")
|
base_voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
|
voice_choice, resolved_language, selected_profile = resolve_voice_choice(
|
||||||
pending.language,
|
pending.language,
|
||||||
@@ -797,8 +796,8 @@ def build_pending_job_from_extraction(
|
|||||||
profile_selection = inferred_profile
|
profile_selection = inferred_profile
|
||||||
|
|
||||||
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
|
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
|
||||||
if not base_voice:
|
if not base_voice and VOICES_INTERNAL:
|
||||||
base_voice = get_default_voice("kokoro")
|
base_voice = VOICES_INTERNAL[0]
|
||||||
selected_speaker_config = (form.get("speaker_config") or "").strip()
|
selected_speaker_config = (form.get("speaker_config") or "").strip()
|
||||||
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,6 @@ _preview_pipelines: Dict[Tuple[str, str], Any] = {}
|
|||||||
_preview_pipeline_lock = threading.Lock()
|
_preview_pipeline_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def _select_device() -> str:
|
def _select_device() -> str:
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
@@ -89,9 +78,10 @@ def get_preview_pipeline(language: str, device: str) -> Any:
|
|||||||
pipeline = _preview_pipelines.get(key)
|
pipeline = _preview_pipelines.get(key)
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
return pipeline
|
return pipeline
|
||||||
from abogen.tts_plugin.utils import create_pipeline
|
from abogen.utils import load_numpy_kpipeline
|
||||||
|
|
||||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
_, KPipeline = load_numpy_kpipeline()
|
||||||
|
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
_preview_pipelines[key] = pipeline
|
_preview_pipelines[key] = pipeline
|
||||||
return pipeline
|
return pipeline
|
||||||
|
|
||||||
@@ -147,9 +137,9 @@ def generate_preview_audio(
|
|||||||
normalized_text = source_text
|
normalized_text = source_text
|
||||||
|
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
from abogen.tts_plugin.utils import create_pipeline
|
from abogen.tts_supertonic import SupertonicPipeline
|
||||||
|
|
||||||
pipeline = create_pipeline("supertonic")
|
pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps)
|
||||||
segments = pipeline(
|
segments = pipeline(
|
||||||
normalized_text,
|
normalized_text,
|
||||||
voice=voice_spec,
|
voice=voice_spec,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from abogen.constants import (
|
|||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
SUBTITLE_FORMATS,
|
SUBTITLE_FORMATS,
|
||||||
SUPPORTED_SOUND_FORMATS,
|
SUPPORTED_SOUND_FORMATS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
)
|
)
|
||||||
from abogen.tts_plugin.utils import get_default_voice
|
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
DEFAULT_LLM_PROMPT,
|
DEFAULT_LLM_PROMPT,
|
||||||
environment_llm_defaults,
|
environment_llm_defaults,
|
||||||
@@ -174,7 +174,7 @@ def settings_defaults() -> Dict[str, Any]:
|
|||||||
"subtitle_format": "srt",
|
"subtitle_format": "srt",
|
||||||
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
"save_mode": "default_output" if has_output_override() else "save_next_to_input",
|
||||||
"default_speaker": "",
|
"default_speaker": "",
|
||||||
"default_voice": get_default_voice("kokoro"),
|
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "",
|
||||||
"supertonic_total_steps": 5,
|
"supertonic_total_steps": 5,
|
||||||
"supertonic_speed": 1.0,
|
"supertonic_speed": 1.0,
|
||||||
"replace_single_newlines": False,
|
"replace_single_newlines": False,
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ from abogen.constants import (
|
|||||||
SUPPORTED_SOUND_FORMATS,
|
SUPPORTED_SOUND_FORMATS,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
SAMPLE_VOICE_TEXTS,
|
SAMPLE_VOICE_TEXTS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
)
|
)
|
||||||
from abogen.tts_plugin.utils import get_voices
|
|
||||||
from abogen.speaker_configs import list_configs
|
from abogen.speaker_configs import list_configs
|
||||||
from abogen.tts_plugin.utils import create_pipeline
|
from abogen.utils import load_numpy_kpipeline
|
||||||
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
|
||||||
|
|
||||||
_preview_pipeline_lock = threading.RLock()
|
_preview_pipeline_lock = threading.RLock()
|
||||||
@@ -285,7 +285,7 @@ def filter_voice_catalog(
|
|||||||
def build_voice_catalog() -> List[Dict[str, str]]:
|
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||||
catalog: List[Dict[str, str]] = []
|
catalog: List[Dict[str, str]] = []
|
||||||
gender_map = {"f": "Female", "m": "Male"}
|
gender_map = {"f": "Female", "m": "Male"}
|
||||||
for voice_id in get_voices("kokoro"):
|
for voice_id in VOICES_INTERNAL:
|
||||||
prefix, _, rest = voice_id.partition("_")
|
prefix, _, rest = voice_id.partition("_")
|
||||||
language_code = prefix[0] if prefix else "a"
|
language_code = prefix[0] if prefix else "a"
|
||||||
gender_code = prefix[1] if len(prefix) > 1 else ""
|
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||||
@@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]:
|
|||||||
voice_catalog = build_voice_catalog()
|
voice_catalog = build_voice_catalog()
|
||||||
return {
|
return {
|
||||||
"languages": LANGUAGE_DESCRIPTIONS,
|
"languages": LANGUAGE_DESCRIPTIONS,
|
||||||
"voices": get_voices("kokoro"),
|
"voices": VOICES_INTERNAL,
|
||||||
"subtitle_formats": SUBTITLE_FORMATS,
|
"subtitle_formats": SUBTITLE_FORMATS,
|
||||||
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||||
@@ -741,7 +741,8 @@ def get_preview_pipeline(language: str, device: str):
|
|||||||
pipeline = _preview_pipelines.get(key)
|
pipeline = _preview_pipelines.get(key)
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
return pipeline
|
return pipeline
|
||||||
pipeline = create_pipeline("kokoro", lang_code=language, device=device)
|
_, KPipeline = load_numpy_kpipeline()
|
||||||
|
pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||||
_preview_pipelines[key] = pipeline
|
_preview_pipelines[key] = pipeline
|
||||||
return pipeline
|
return pipeline
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from abogen.speaker_configs import (
|
|||||||
save_configs,
|
save_configs,
|
||||||
delete_config,
|
delete_config,
|
||||||
)
|
)
|
||||||
|
from abogen.constants import VOICES_INTERNAL
|
||||||
|
|
||||||
voices_bp = Blueprint("voices", __name__)
|
voices_bp = Blueprint("voices", __name__)
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# Contributing to Abogen
|
|
||||||
|
|
||||||
We welcome contributions to Abogen!
|
|
||||||
|
|
||||||
## How to Contribute
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a branch for your feature
|
|
||||||
3. Make your changes
|
|
||||||
4. Write tests
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## Code Standards
|
|
||||||
|
|
||||||
- Follow PEP 8 for Python
|
|
||||||
- Use TypeScript for JavaScript
|
|
||||||
- Type hints required for new Python code
|
|
||||||
- Document complex logic with comments
|
|
||||||
|
|
||||||
## Plugin Architecture
|
|
||||||
|
|
||||||
When contributing TTS engines, implement the **Plugin Architecture** contract.
|
|
||||||
|
|
||||||
See [Developer Guide](developer-guide.md#5-adding-a-new-plugin) for:
|
|
||||||
- Required exports (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
|
|
||||||
- Engine / EngineSession contracts
|
|
||||||
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.)
|
|
||||||
- Step-by-step plugin creation guide
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Contract tests (architectural compliance)
|
|
||||||
pytest tests/contracts/
|
|
||||||
|
|
||||||
# Behavioral regression tests
|
|
||||||
pytest tests/test_behavioral_regression.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- Update relevant docs in `docs/` when changing architecture or APIs
|
|
||||||
- Add docstrings to all public functions/classes
|
|
||||||
- Follow existing documentation style
|
|
||||||
|
|
||||||
## Pull Request Checklist
|
|
||||||
|
|
||||||
- [ ] Tests pass (`pytest`)
|
|
||||||
- [ ] Code follows style guide (`ruff check`, `ruff format`)
|
|
||||||
- [ ] Documentation updated
|
|
||||||
- [ ] No legacy architecture references (`TTSBackend`, `register_backend`, `TTSBackendRegistry`)
|
|
||||||
- [ ] Uses new Plugin Architecture patterns
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
# TTS Plugin Architecture — Architectural Reference
|
|
||||||
|
|
||||||
This document describes the **stable architectural contracts** of the TTS Plugin Architecture. It documents invariants that only change when the architecture itself changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Host Application │
|
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
|
||||||
│ │ Plugin │ │ HostContext │ │ Plugin Discovery │ │
|
|
||||||
│ │ Manager │──│ (config_dir, │ │ (plugin directories) │ │
|
|
||||||
│ │ │ │ logger, │ │ │ │
|
|
||||||
│ │ - discover │ │ http_client)│ └────────────────────────┘ │
|
|
||||||
│ │ - validate │ └──────────────┘ │ │
|
|
||||||
│ │ - activate │ ▼ │
|
|
||||||
│ │ - dispose │ ┌─────────────────────────────────────────┐ │
|
|
||||||
│ └──────┬──────┘ │ Plugin Package │ │
|
|
||||||
│ │ │ ┌──────────────┐ ┌─────────────────┐ │ │
|
|
||||||
│ ▼ │ │ PLUGIN_ │ │ MODEL_ │ │ │
|
|
||||||
│ ┌────────────┐ │ │ MANIFEST │ │ REQUIREMENTS │ │ │
|
|
||||||
│ │ Engine │◄──┤ │ create_engine│ │ │ │ │
|
|
||||||
│ └──────┬─────┘ │ └──────────────┘ └─────────────────┘ │ │
|
|
||||||
│ │ └─────────────────────────────────────────┘ │
|
|
||||||
│ │ createSession() │
|
|
||||||
│ ▼ │
|
|
||||||
│ ┌─────────────┐ │
|
|
||||||
│ │EngineSession│ │
|
|
||||||
│ └──────┬──────┘ │
|
|
||||||
│ │ synthesize() │
|
|
||||||
│ ▼ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │SynthesizedAudio│ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
| Component | Responsibility |
|
|
||||||
|-----------|----------------|
|
|
||||||
| **PluginManifest** | Static metadata: id, name, version, api_version, capabilities, engine manifest |
|
|
||||||
| **EngineManifest** | Voice sources, parameters, audio formats |
|
|
||||||
| **HostContext** | Minimal host services: config_dir, logger, http_client |
|
|
||||||
| **Engine** | Stateless factory for sessions; thread-safe `createSession()` |
|
|
||||||
| **EngineSession** | Owns mutable execution state; not thread-safe |
|
|
||||||
| **PluginManager** | Discovers, validates, and manages plugin lifecycle |
|
|
||||||
| **Capabilities** | Optional interfaces: VoiceLister, PreviewGenerator, StreamingSynthesizer, CancelableSession |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Ownership Model
|
|
||||||
|
|
||||||
### Engine Ownership
|
|
||||||
```
|
|
||||||
PluginManager.create_engine() → Engine
|
|
||||||
```
|
|
||||||
- **PluginManager** creates and caches engines
|
|
||||||
- **Caller** receives `Engine` instance
|
|
||||||
- **Caller** must dispose all sessions **before** disposing engine
|
|
||||||
- **Engine.dispose()** releases engine resources
|
|
||||||
- After `Engine.dispose()`: all methods except `dispose()` raise `EngineError`
|
|
||||||
|
|
||||||
### Session Ownership
|
|
||||||
```
|
|
||||||
Engine.createSession() → EngineSession
|
|
||||||
```
|
|
||||||
- **Engine** creates session
|
|
||||||
- **Ownership transfers to caller** immediately
|
|
||||||
- **Caller** is responsible for `session.dispose()`
|
|
||||||
- **Engine does NOT track sessions** — no registry, no callbacks
|
|
||||||
- After `session.dispose()`: all methods except `dispose()` raise `EngineError`
|
|
||||||
|
|
||||||
### Disposal Order (Invariant)
|
|
||||||
```python
|
|
||||||
# Correct
|
|
||||||
engine = manager.create_engine("id")
|
|
||||||
session = engine.createSession()
|
|
||||||
try:
|
|
||||||
audio = session.synthesize(request)
|
|
||||||
finally:
|
|
||||||
session.dispose() # 1. Sessions FIRST
|
|
||||||
engine.dispose() # 2. Then engine
|
|
||||||
|
|
||||||
# INCORRECT — violates contract (undefined behavior)
|
|
||||||
engine.dispose()
|
|
||||||
session.synthesize(request) # EngineError
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Lifecycle State Machine
|
|
||||||
|
|
||||||
```
|
|
||||||
DISCOVERY
|
|
||||||
PluginManager.discover(plugin_dirs)
|
|
||||||
→ Loads PLUGIN_MANIFEST, MODEL_REQUIREMENTS
|
|
||||||
→ Validates api_version (major must match)
|
|
||||||
→ Validates declared capabilities are implemented
|
|
||||||
|
|
||||||
MODEL_DOWNLOAD (if MODEL_REQUIREMENTS non-empty)
|
|
||||||
Host reads MODEL_REQUIREMENTS
|
|
||||||
Downloads/caches models
|
|
||||||
Resolves model_path
|
|
||||||
|
|
||||||
ACTIVATION
|
|
||||||
create_engine(context, model_path, config)
|
|
||||||
→ Atomic: succeeds fully or raises EngineError
|
|
||||||
→ Returns Engine
|
|
||||||
|
|
||||||
SESSION_CREATION
|
|
||||||
engine.createSession() → EngineSession
|
|
||||||
→ Ownership transfers to caller
|
|
||||||
→ Raises EngineError on failure
|
|
||||||
→ Never returns partial session
|
|
||||||
|
|
||||||
SYNTHESIS
|
|
||||||
session.synthesize(request)
|
|
||||||
→ Returns SynthesizedAudio
|
|
||||||
→ Raises EngineError on failure
|
|
||||||
→ Session remains usable after error
|
|
||||||
|
|
||||||
SESSION_DISPOSAL
|
|
||||||
session.dispose()
|
|
||||||
→ Idempotent, never raises
|
|
||||||
→ After: all methods raise EngineError
|
|
||||||
|
|
||||||
DEACTIVATION
|
|
||||||
engine.dispose()
|
|
||||||
→ Caller MUST dispose all sessions first
|
|
||||||
→ Idempotent, never raises
|
|
||||||
→ After: all methods raise EngineError
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Protocol Contracts
|
|
||||||
|
|
||||||
### Engine (Protocol)
|
|
||||||
```python
|
|
||||||
@runtime_checkable
|
|
||||||
class Engine(Protocol):
|
|
||||||
def createSession(self) -> EngineSession:
|
|
||||||
"""Create a new session. Thread-safe. Transfers ownership."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release engine resources.
|
|
||||||
Caller must dispose all sessions first.
|
|
||||||
Idempotent, never raises.
|
|
||||||
After: all methods except dispose() raise EngineError."""
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### EngineSession (Protocol)
|
|
||||||
```python
|
|
||||||
@runtime_checkable
|
|
||||||
class EngineSession(Protocol):
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
"""Synthesize audio.
|
|
||||||
Returns SynthesizedAudio or raises EngineError.
|
|
||||||
Session remains usable after error."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release session resources.
|
|
||||||
Idempotent, never raises.
|
|
||||||
After: all methods except dispose() raise EngineError."""
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Capability Protocols (Optional)
|
|
||||||
- **VoiceLister**: `listVoices(source_id: str) -> list[VoiceManifest]`
|
|
||||||
- **PreviewGenerator**: `generatePreview(voice: VoiceSelection, text: str) -> SynthesizedAudio`
|
|
||||||
- **StreamingSynthesizer**: `synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]`
|
|
||||||
- **CancelableSession**: `cancel() -> None` (causes in-flight synthesize to raise `CancelledError`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Error Semantics
|
|
||||||
|
|
||||||
```
|
|
||||||
EngineError (base)
|
|
||||||
├── ModelNotFoundError # Required model not found
|
|
||||||
├── ModelLoadError # Model failed to load
|
|
||||||
├── NetworkError # Network operation failed
|
|
||||||
├── InvalidInputError # Request validation failed
|
|
||||||
├── ConfigurationError # Invalid configuration
|
|
||||||
├── CancelledError # Operation cancelled via CancelableSession
|
|
||||||
└── InternalError # Unexpected internal failure
|
|
||||||
```
|
|
||||||
|
|
||||||
### When Each Is Raised
|
|
||||||
| Error | Raised By | Conditions |
|
|
||||||
|-------|-----------|------------|
|
|
||||||
| `ModelNotFoundError` | `create_engine()` | Required model not found at `model_path` |
|
|
||||||
| `ModelLoadError` | `create_engine()` | Model exists but fails to load |
|
|
||||||
| `NetworkError` | `synthesize()`, `create_engine()` | Network call fails (cloud engines) |
|
|
||||||
| `InvalidInputError` | `synthesize()` | Request validation fails (empty text, invalid voice, etc.) |
|
|
||||||
| `ConfigurationError` | `create_engine()` | Config values invalid for this engine |
|
|
||||||
| `CancelledError` | `synthesize()`, `synthesizeStream()` | `CancelableSession.cancel()` called |
|
|
||||||
| `InternalError` | Any | Unexpected internal failure (bug) |
|
|
||||||
|
|
||||||
### Dispose Contract
|
|
||||||
- `dispose()` is **idempotent** and **never raises**
|
|
||||||
- After `dispose()`: all methods except `dispose()` raise `EngineError`
|
|
||||||
- Engine: caller must dispose all sessions first; violating this is undefined behavior
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Capabilities
|
|
||||||
|
|
||||||
| Capability | Interface | Enables |
|
|
||||||
|------------|-----------|---------|
|
|
||||||
| `voice_list` | `VoiceLister` | `listVoices(source_id)` — enumerate available voices |
|
|
||||||
| `preview` | `PreviewGenerator` | `generatePreview(voice, text)` — preview without session |
|
|
||||||
| `streaming` | `StreamingSynthesizer` | `synthesizeStream(request)` — chunked audio output |
|
|
||||||
| `cancel` | `CancelableSession` | `cancel()` — interrupt in-flight synthesis |
|
|
||||||
|
|
||||||
Plugins declare capabilities in `PluginManifest.capabilities`. Host validates at load time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Contract Tests
|
|
||||||
|
|
||||||
**Location**: `tests/contracts/`
|
|
||||||
|
|
||||||
**Purpose**: Verify every plugin satisfies the architectural contracts.
|
|
||||||
|
|
||||||
**Guarantees**:
|
|
||||||
- Required exports exist (`PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`)
|
|
||||||
- `create_engine` is atomic
|
|
||||||
- `Engine.createSession()` transfers ownership, never returns partial
|
|
||||||
- `dispose()` is idempotent on Engine and EngineSession
|
|
||||||
- After `dispose()`, methods raise `EngineError`
|
|
||||||
- `synthesize()` raises typed `EngineError` subtypes, session remains usable
|
|
||||||
- Declared capabilities are actually implemented
|
|
||||||
- Plugin loader validates manifest, api_version, capabilities
|
|
||||||
|
|
||||||
**Run**: `pytest tests/contracts/ -v`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Behavioral Tests
|
|
||||||
|
|
||||||
**Location**: `tests/test_behavioral_regression.py`
|
|
||||||
|
|
||||||
**Purpose**: Verify user-facing behavior via public API only (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`).
|
|
||||||
|
|
||||||
**Scope**:
|
|
||||||
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
|
|
||||||
- Voice selection and listing
|
|
||||||
- Parameter handling (speed, etc.)
|
|
||||||
- Error scenarios (unknown plugin, disposal, etc.)
|
|
||||||
- Resource cleanup (dispose idempotency, no leaks)
|
|
||||||
- Pipeline utility (`create_pipeline`)
|
|
||||||
|
|
||||||
**Run**: `pytest tests/test_behavioral_regression.py -v`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Reference
|
|
||||||
|
|
||||||
- **Architecture Spec**: `docs/architecture-final-v2.md`
|
|
||||||
- **Amendment (lang_code)**: `docs/architecture-amendment-001.md`
|
|
||||||
- **Migration Roadmap**: `docs/migration-roadmap.md`
|
|
||||||
- **Plugin Examples**: `plugins/kokoro/`, `plugins/supertonic/`
|
|
||||||
- **Protocol Definitions**: `abogen/tts_plugin/engine.py`, `abogen/tts_plugin/capabilities.py`
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Getting Started
|
|
||||||
|
|
||||||
Quickstart for developers working on Abogen.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Python 3.10+
|
|
||||||
- Node.js 20+
|
|
||||||
- npm 10+
|
|
||||||
- Git
|
|
||||||
- Docker (optional)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development install with all extras
|
|
||||||
pip install -e .[dev]
|
|
||||||
|
|
||||||
# Or with uv
|
|
||||||
uv pip install -e .[dev]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Desktop GUI
|
|
||||||
abogen
|
|
||||||
|
|
||||||
# Web UI
|
|
||||||
abogen-web
|
|
||||||
|
|
||||||
# CLI
|
|
||||||
abogen-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
abogen/
|
|
||||||
├── pyqt/ - PyQt6 desktop GUI
|
|
||||||
├── webui/ - Flask web UI
|
|
||||||
├── tts_plugin/ - Plugin Architecture (Engine, EngineSession, Manifest)
|
|
||||||
└── plugins/ - Built-in plugins (kokoro, supertonic)
|
|
||||||
tests/
|
|
||||||
├── contracts/ - Contract compliance tests
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# Contract tests (architectural compliance)
|
|
||||||
pytest tests/contracts/
|
|
||||||
|
|
||||||
# Behavioral regression tests
|
|
||||||
pytest tests/test_behavioral_regression.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
See [Developer Guide](developer-guide.md) for Plugin Architecture details:
|
|
||||||
- Engine / EngineSession lifecycle
|
|
||||||
- Plugin contract (PLUGIN_MANIFEST, create_engine)
|
|
||||||
- Adding new plugins
|
|
||||||
- Capability interfaces
|
|
||||||
-269
@@ -1,269 +0,0 @@
|
|||||||
# Testing Guide
|
|
||||||
|
|
||||||
This document describes the testing strategy for Abogen's Plugin Architecture.
|
|
||||||
|
|
||||||
## Test Categories
|
|
||||||
|
|
||||||
### 0. Auto-Discovery Plugin Tests (`tests/plugins/`)
|
|
||||||
|
|
||||||
**Purpose**: Automatically test every plugin in `plugins/` directory without manual test creation. These tests use discovery to find all plugins and run generic tests against each one.
|
|
||||||
|
|
||||||
**What They Test**:
|
|
||||||
- **Manifest structure**: Required fields, API version format, voices field
|
|
||||||
- **Engine lifecycle**: `create_engine`, `dispose` idempotency, post-dispose behavior
|
|
||||||
- **Capability implementation**: Declared capabilities are implemented (e.g., `voice_list` → `VoiceLister`)
|
|
||||||
|
|
||||||
**How Auto-Discovery Works**:
|
|
||||||
```python
|
|
||||||
# tests/plugins/conftest.py
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def plugin_ids(plugins_dir: Path) -> list[str]:
|
|
||||||
"""Discovers all plugin directories with __init__.py"""
|
|
||||||
return [item.name for item in plugins_dir.iterdir()
|
|
||||||
if item.is_dir() and (item / "__init__.py").exists()]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Structure**:
|
|
||||||
```
|
|
||||||
tests/plugins/
|
|
||||||
├── conftest.py # Fixtures: plugin_ids, loaded_plugin, host_context
|
|
||||||
└── test_all_plugins.py # Generic tests for every plugin
|
|
||||||
├── TestAllPluginsManifest
|
|
||||||
├── TestAllPluginsEngine
|
|
||||||
└── TestAllPluginsCapabilities
|
|
||||||
```
|
|
||||||
|
|
||||||
**Running Auto-Discovery Tests**:
|
|
||||||
```bash
|
|
||||||
# Test all plugins automatically
|
|
||||||
pytest tests/plugins/ -v
|
|
||||||
|
|
||||||
# Test specific plugin
|
|
||||||
pytest tests/plugins/ -v -k "kokoro"
|
|
||||||
|
|
||||||
# See which plugins were discovered
|
|
||||||
pytest tests/plugins/ --collect-only
|
|
||||||
```
|
|
||||||
|
|
||||||
**Adding a New Plugin**:
|
|
||||||
1. Create plugin directory: `plugins/my_plugin/`
|
|
||||||
2. Add `__init__.py` with `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`
|
|
||||||
3. Run `pytest tests/plugins/` — tests automatically discover and test your plugin!
|
|
||||||
|
|
||||||
**When to Add Plugin-Specific Tests**:
|
|
||||||
Auto-discovery tests cover generic contract validation. Create plugin-specific tests in `tests/test_<plugin>_plugin.py` for:
|
|
||||||
- Integration with real dependencies (e.g., KPipeline for Kokoro)
|
|
||||||
- Specific voice IDs and behavior
|
|
||||||
- Plugin-specific parameters and features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. Contract Tests (`tests/contracts/`)
|
|
||||||
|
|
||||||
**Purpose**: Verify that every plugin satisfies the architectural contract. These tests ensure the Plugin Architecture's invariants are maintained.
|
|
||||||
|
|
||||||
**What They Guarantee**:
|
|
||||||
- Every plugin exports `PLUGIN_MANIFEST`, `MODEL_REQUIREMENTS`, `create_engine`
|
|
||||||
- `create_engine` is atomic (succeeds fully or raises and cleans up)
|
|
||||||
- `Engine.createSession()` returns valid `EngineSession`, transfers ownership
|
|
||||||
- `Engine.dispose()` is idempotent, never raises
|
|
||||||
- After `dispose()`, all methods raise `EngineError`
|
|
||||||
- `EngineSession.synthesize()` returns `SynthesizedAudio` or raises `EngineError` (session remains usable)
|
|
||||||
- `EngineSession.dispose()` is idempotent, never raises
|
|
||||||
- Capability interfaces (`VoiceLister`, `PreviewGenerator`, etc.) are correctly implemented
|
|
||||||
- Plugin Loader discovers, validates, and loads plugins correctly
|
|
||||||
- Plugin Manager creates, caches, and disposes engines correctly
|
|
||||||
- Value objects are immutable and have correct equality semantics
|
|
||||||
- Error hierarchy is preserved (`EngineError` base with subtypes)
|
|
||||||
|
|
||||||
**Why They Exist**:
|
|
||||||
- Provide **compile-time-like guarantees** for a dynamic plugin system
|
|
||||||
- Enable **safe plugin ecosystem** — host can trust any loaded plugin
|
|
||||||
- Catch **architectural violations** early (missing dispose, wrong return types, etc.)
|
|
||||||
- Document the **contract** in executable form
|
|
||||||
|
|
||||||
**What Every New Plugin Must Pass**:
|
|
||||||
```bash
|
|
||||||
pytest tests/contracts/ -v
|
|
||||||
# All tests must pass
|
|
||||||
```
|
|
||||||
|
|
||||||
**Running Contract Tests**:
|
|
||||||
```bash
|
|
||||||
# All contract tests
|
|
||||||
pytest tests/contracts/
|
|
||||||
|
|
||||||
# Specific contract
|
|
||||||
pytest tests/contracts/test_engine_contract.py
|
|
||||||
|
|
||||||
# With coverage
|
|
||||||
pytest tests/contracts/ --cov=abogen.tts_plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Behavioral Tests (`tests/test_behavioral_regression.py`)
|
|
||||||
|
|
||||||
**Purpose**: Verify external user-facing behavior using only public API. These tests are **not coupled to internal implementation**.
|
|
||||||
|
|
||||||
**What They Test**:
|
|
||||||
- Synthesis with various inputs (short, long, empty, unicode, mixed scripts)
|
|
||||||
- Voice selection and listing
|
|
||||||
- Parameter handling (speed, etc.)
|
|
||||||
- Error scenarios (unknown plugin, disposal, etc.)
|
|
||||||
- Resource cleanup (dispose idempotency, no leaks)
|
|
||||||
- Pipeline utility (`create_pipeline`)
|
|
||||||
|
|
||||||
**Why They Test Public Behavior Only**:
|
|
||||||
- **Refactoring safety**: Internal changes don't break tests
|
|
||||||
- **Real-world usage**: Tests match how consumers actually use the API
|
|
||||||
- **Plugin agnostic**: Parametrized across Kokoro, SuperTonic, and mock plugins
|
|
||||||
- **Regression detection**: Catch behavioral regressions regardless of implementation
|
|
||||||
|
|
||||||
**What They Don't Test**:
|
|
||||||
- Internal class structure
|
|
||||||
- Private methods
|
|
||||||
- Implementation details (how audio is generated, model loading internals)
|
|
||||||
|
|
||||||
**Running Behavioral Tests**:
|
|
||||||
```bash
|
|
||||||
# All behavioral tests
|
|
||||||
pytest tests/test_behavioral_regression.py -v
|
|
||||||
|
|
||||||
# With specific plugin (if installed)
|
|
||||||
pytest tests/test_behavioral_regression.py -v -k "kokoro"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Unit Tests (`tests/`)
|
|
||||||
|
|
||||||
**Purpose**: Test individual modules in isolation.
|
|
||||||
|
|
||||||
**Examples**:
|
|
||||||
- `test_book_parser.py` — EPUB/PDF/text parsing
|
|
||||||
- `test_text_normalization.py` — Text preprocessing
|
|
||||||
- `test_chunk_helpers.py` — Text chunking logic
|
|
||||||
- `test_voice_cache.py` — Voice caching
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Integration Tests
|
|
||||||
|
|
||||||
**Purpose**: Test cross-component interactions.
|
|
||||||
|
|
||||||
**Examples**:
|
|
||||||
- `test_kokoro_plugin.py` — Full Kokoro plugin integration
|
|
||||||
- `test_supertonic_plugin.py` — Full SuperTonic plugin integration
|
|
||||||
- `test_conversion_series.py` — End-to-end conversion pipeline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── contracts/ # Contract tests (architectural compliance)
|
|
||||||
│ ├── conftest.py # Shared fixtures (FakeEngine, FakeSession)
|
|
||||||
│ ├── test_manifest_contract.py
|
|
||||||
│ ├── test_plugin_contract.py
|
|
||||||
│ ├── test_engine_contract.py
|
|
||||||
│ ├── test_session_contract.py
|
|
||||||
│ ├── test_capabilities_contract.py
|
|
||||||
│ ├── test_loader_contract.py
|
|
||||||
│ ├── test_plugin_manager_contract.py
|
|
||||||
│ ├── test_types_contract.py
|
|
||||||
│ ├── test_errors_contract.py
|
|
||||||
│ ├── test_host_context_contract.py
|
|
||||||
│ └── test_integration.py
|
|
||||||
├── test_behavioral_regression.py # Behavioral tests (public API)
|
|
||||||
├── test_kokoro_plugin.py # Kokoro integration
|
|
||||||
├── test_supertonic_plugin.py # SuperTonic integration
|
|
||||||
└── ... # Other unit/integration tests
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding Tests for a New Plugin
|
|
||||||
|
|
||||||
### Auto-Discovery Tests (Automatic!)
|
|
||||||
|
|
||||||
**No manual test creation required!** When you add a new plugin to `plugins/`:
|
|
||||||
|
|
||||||
1. Create plugin directory: `plugins/my_plugin/`
|
|
||||||
2. Add `__init__.py` with required exports:
|
|
||||||
```python
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(...)
|
|
||||||
MODEL_REQUIREMENTS = [...]
|
|
||||||
def create_engine(...): ...
|
|
||||||
```
|
|
||||||
3. Run `pytest tests/plugins/` — auto-discovery tests automatically find and test your plugin!
|
|
||||||
|
|
||||||
**What's Tested Automatically**:
|
|
||||||
- Manifest structure and required fields
|
|
||||||
- API version compatibility
|
|
||||||
- Engine creation and dispose contract
|
|
||||||
- Capability implementation (if declared)
|
|
||||||
|
|
||||||
### Plugin-Specific Tests (Optional)
|
|
||||||
|
|
||||||
Create `tests/test_my_plugin_plugin.py` for:
|
|
||||||
- Integration with real backend (e.g., KPipeline for Kokoro)
|
|
||||||
- Specific voice IDs and behavior
|
|
||||||
- Plugin-specific parameters and features
|
|
||||||
|
|
||||||
### Contract Tests (Deprecated for New Plugins)
|
|
||||||
|
|
||||||
**Note**: Auto-discovery tests (`tests/plugins/`) now cover contract validation for all plugins. Manual contract tests in `tests/contracts/` are only needed for testing internal architecture components.
|
|
||||||
|
|
||||||
### Behavioral Tests (Recommended)
|
|
||||||
|
|
||||||
Add parametrized tests to `tests/test_behavioral_regression.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In _plugin_ids list, add your plugin
|
|
||||||
_plugin_ids = ["kokoro", "supertonic", "my_plugin"]
|
|
||||||
_plugin_engines["my_plugin"] = _YourMockEngine
|
|
||||||
_plugin_default_voices["my_plugin"] = "voice1"
|
|
||||||
_plugin_all_voices["my_plugin"] = ["voice1", "voice2"]
|
|
||||||
```
|
|
||||||
|
|
||||||
All existing behavioral tests will automatically run against your plugin.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Continuous Integration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/test.yml
|
|
||||||
- name: Contract Tests
|
|
||||||
run: pytest tests/contracts/ -v
|
|
||||||
|
|
||||||
- name: Behavioral Tests
|
|
||||||
run: pytest tests/test_behavioral_regression.py -v
|
|
||||||
|
|
||||||
- name: Unit & Integration Tests
|
|
||||||
run: pytest tests/ -v --ignore=tests/test_behavioral_regression.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Design Principles
|
|
||||||
|
|
||||||
### Contract Tests
|
|
||||||
- **No mocks** for the system under test (test real plugin loading)
|
|
||||||
- **Strict assertions** on types and behavior
|
|
||||||
- **Document architecture** in test names and docstrings
|
|
||||||
- **Fail fast** on architectural violations
|
|
||||||
|
|
||||||
### Behavioral Tests
|
|
||||||
- **Only public API** (`create_pipeline`, `Engine`, `EngineSession`, `PluginManager`)
|
|
||||||
- **Parametrized** across plugins
|
|
||||||
- **Realistic scenarios** (long text, unicode, mixed scripts)
|
|
||||||
- **No implementation coupling** (test behavior, not internals)
|
|
||||||
|
|
||||||
### General
|
|
||||||
- **Fast**: Unit tests < 1s, Contract tests < 5s, Behavioral < 30s
|
|
||||||
- **Isolated**: No shared state between tests
|
|
||||||
- **Deterministic**: Same input → same output
|
|
||||||
- **Descriptive names**: `test_<component>_<scenario>_<expected>`
|
|
||||||
@@ -1,689 +0,0 @@
|
|||||||
# TTS Plugin Architecture — Final Specification
|
|
||||||
|
|
||||||
## 1. Core Domain
|
|
||||||
|
|
||||||
Zero dependencies. Pure business logic.
|
|
||||||
|
|
||||||
### 1.1 Engine
|
|
||||||
|
|
||||||
Factory for sessions. Stateless. Thread-safe for createSession().
|
|
||||||
|
|
||||||
```
|
|
||||||
interface Engine:
|
|
||||||
createSession() -> EngineSession
|
|
||||||
dispose() -> void
|
|
||||||
```
|
|
||||||
|
|
||||||
**createSession() contract**:
|
|
||||||
- Returns: EngineSession
|
|
||||||
- Raises: EngineError on failure
|
|
||||||
- Ownership: Transfers to caller
|
|
||||||
- Thread-safe: Yes
|
|
||||||
|
|
||||||
**dispose() contract**:
|
|
||||||
- Releases engine resources
|
|
||||||
- Caller must ensure all sessions created by this engine are disposed before calling dispose()
|
|
||||||
- Disposing an engine while any session is still alive violates the API contract; behavior is undefined
|
|
||||||
- Idempotent: Safe to call multiple times
|
|
||||||
- Never raises: Catches and logs internally
|
|
||||||
- After dispose(): All methods except dispose() raise EngineError
|
|
||||||
|
|
||||||
### 1.2 EngineSession
|
|
||||||
|
|
||||||
Owns mutable execution state isolated from other concurrent work. NOT thread-safe.
|
|
||||||
|
|
||||||
```
|
|
||||||
interface EngineSession:
|
|
||||||
synthesize(request: SynthesisRequest) -> SynthesizedAudio
|
|
||||||
dispose() -> void
|
|
||||||
```
|
|
||||||
|
|
||||||
**synthesize() contract**:
|
|
||||||
- Returns: SynthesizedAudio
|
|
||||||
- Raises: EngineError on failure (session remains usable)
|
|
||||||
- Thread-safe: No
|
|
||||||
|
|
||||||
**dispose() contract**:
|
|
||||||
- Releases session resources
|
|
||||||
- Idempotent: Safe to call multiple times
|
|
||||||
- Never raises: Catches and logs internally
|
|
||||||
- After dispose(): All methods except dispose() raise EngineError
|
|
||||||
|
|
||||||
### 1.3 SynthesisRequest
|
|
||||||
|
|
||||||
Immutable value object.
|
|
||||||
|
|
||||||
```
|
|
||||||
SynthesisRequest:
|
|
||||||
text: string
|
|
||||||
voice: VoiceSelection
|
|
||||||
parameters: ParameterValues
|
|
||||||
format: AudioFormat
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 SynthesizedAudio
|
|
||||||
|
|
||||||
Immutable value object.
|
|
||||||
|
|
||||||
```
|
|
||||||
SynthesizedAudio:
|
|
||||||
data: bytes
|
|
||||||
format: AudioFormat
|
|
||||||
duration: Duration
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.5 VoiceSelection
|
|
||||||
|
|
||||||
Immutable value object. Opaque to engine.
|
|
||||||
|
|
||||||
```
|
|
||||||
VoiceSelection:
|
|
||||||
source: string
|
|
||||||
key: string
|
|
||||||
payload: any = None # Optional; required for clone/blend sources
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.6 ParameterValues
|
|
||||||
|
|
||||||
Immutable value object. Behaves like Mapping[str, Any].
|
|
||||||
|
|
||||||
```
|
|
||||||
ParameterValues:
|
|
||||||
values: Mapping[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.7 AudioFormat
|
|
||||||
|
|
||||||
Immutable value object.
|
|
||||||
|
|
||||||
```
|
|
||||||
AudioFormat:
|
|
||||||
mime: string
|
|
||||||
extension: string
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.8 Duration
|
|
||||||
|
|
||||||
Immutable value object.
|
|
||||||
|
|
||||||
```
|
|
||||||
Duration:
|
|
||||||
seconds: number
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.9 EngineConfig
|
|
||||||
|
|
||||||
Engine initialization settings only. No resource references.
|
|
||||||
|
|
||||||
```
|
|
||||||
EngineConfig:
|
|
||||||
device: string # "cpu", "cuda:0", etc.
|
|
||||||
# Engine-specific settings (if any)
|
|
||||||
# Unknown keys are ignored (no error)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Error Hierarchy
|
|
||||||
|
|
||||||
Typed exceptions. Engines raise EngineError or subtypes. Never raw exceptions.
|
|
||||||
|
|
||||||
```
|
|
||||||
EngineError (base)
|
|
||||||
├── ModelNotFoundError
|
|
||||||
├── ModelLoadError
|
|
||||||
├── NetworkError
|
|
||||||
├── InvalidInputError
|
|
||||||
├── ConfigurationError
|
|
||||||
├── CancelledError
|
|
||||||
└── InternalError
|
|
||||||
```
|
|
||||||
|
|
||||||
**Contract**:
|
|
||||||
- synthesize() raises EngineError on failure, session remains usable
|
|
||||||
- dispose() never raises (catches and logs internally)
|
|
||||||
- create_engine() raises EngineError on failure, cleans up partially created resources
|
|
||||||
- createSession() raises EngineError on failure, no partially initialized session returned
|
|
||||||
- cancel() causes synthesize() to raise CancelledError
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Capability Interfaces (Optional)
|
|
||||||
|
|
||||||
Engines implement only what they support. Capabilities are additive.
|
|
||||||
|
|
||||||
### 3.1 VoiceLister
|
|
||||||
|
|
||||||
```
|
|
||||||
interface VoiceLister:
|
|
||||||
listVoices(sourceId: string) -> list[VoiceManifest]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 PreviewGenerator
|
|
||||||
|
|
||||||
```
|
|
||||||
interface PreviewGenerator:
|
|
||||||
generatePreview(voice: VoiceSelection, text: string) -> SynthesizedAudio
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 ModelRequirements
|
|
||||||
|
|
||||||
Static at plugin level, not engine level. Host reads before creating engine.
|
|
||||||
|
|
||||||
```
|
|
||||||
MODEL_REQUIREMENTS = list[ModelManifest]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 StreamingSynthesizer
|
|
||||||
|
|
||||||
Optional capability of EngineSession, not Engine.
|
|
||||||
|
|
||||||
```
|
|
||||||
interface StreamingSynthesizer:
|
|
||||||
synthesizeStream(request: SynthesisRequest) -> Iterator[bytes]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Iterator contract**:
|
|
||||||
- Yields audio chunks as they become available
|
|
||||||
- Raises CancelledError if cancel() is called during iteration
|
|
||||||
- Raises EngineError on synthesis failure
|
|
||||||
- Iterator exhaustion = synthesis complete
|
|
||||||
- Session remains usable after iterator completes
|
|
||||||
|
|
||||||
### 3.5 CancelableSession
|
|
||||||
|
|
||||||
Optional capability for engines that support cancellation.
|
|
||||||
|
|
||||||
```
|
|
||||||
interface CancelableSession:
|
|
||||||
cancel() -> void
|
|
||||||
```
|
|
||||||
|
|
||||||
**cancel() contract**:
|
|
||||||
- Cancels in-progress synthesize()
|
|
||||||
- synthesize() raises CancelledError (subtype of EngineError)
|
|
||||||
- EngineSession remains usable after cancellation (unless implementation documents otherwise)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Plugin Manifest
|
|
||||||
|
|
||||||
Static metadata. Immutable. No dependencies.
|
|
||||||
|
|
||||||
### 4.1 PluginManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
PluginManifest:
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
version: string
|
|
||||||
api_version: string # semver format: MAJOR.MINOR
|
|
||||||
description: string
|
|
||||||
author: string
|
|
||||||
capabilities: list[string]
|
|
||||||
requires: RequirementManifest
|
|
||||||
engine: EngineManifest
|
|
||||||
```
|
|
||||||
|
|
||||||
**api_version contract**:
|
|
||||||
- Format: semver (MAJOR.MINOR)
|
|
||||||
- Compatibility: Host rejects plugin if major version differs
|
|
||||||
- Minor version: backward compatible, Host accepts higher minor
|
|
||||||
|
|
||||||
### 4.2 EngineManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
EngineManifest:
|
|
||||||
voiceSources: list[VoiceSourceManifest]
|
|
||||||
parameters: list[ParameterManifest]
|
|
||||||
audioFormats: list[AudioFormatManifest]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 VoiceSourceManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
VoiceSourceManifest:
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
type: string # "list", "speaker_id", "clone", "blend", "generate", "none"
|
|
||||||
config: any
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 VoiceManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
VoiceManifest:
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
tags: list[string]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 ParameterManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
ParameterManifest:
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
type: string # "float", "int", "string", "boolean", "enum"
|
|
||||||
default: any
|
|
||||||
min: number (optional)
|
|
||||||
max: number (optional)
|
|
||||||
step: number (optional)
|
|
||||||
options: list[EnumOption] (optional)
|
|
||||||
unit: string (optional)
|
|
||||||
group: string (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 AudioFormatManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
AudioFormatManifest:
|
|
||||||
mime: string
|
|
||||||
extension: string
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 EnumOption
|
|
||||||
|
|
||||||
```
|
|
||||||
EnumOption:
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.8 RequirementManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
RequirementManifest:
|
|
||||||
gpu: GpuRequirement (optional)
|
|
||||||
memory: number (optional)
|
|
||||||
internet: boolean (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.9 GpuRequirement
|
|
||||||
|
|
||||||
```
|
|
||||||
GpuRequirement:
|
|
||||||
required: boolean
|
|
||||||
type: string (optional)
|
|
||||||
memory: number (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.10 ModelManifest
|
|
||||||
|
|
||||||
```
|
|
||||||
ModelManifest:
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
size: string
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Host Services
|
|
||||||
|
|
||||||
### 5.1 HostContext
|
|
||||||
|
|
||||||
Minimal. 3 fields maximum. No business logic.
|
|
||||||
|
|
||||||
```
|
|
||||||
HostContext:
|
|
||||||
config_dir: Path # For API keys, preferences
|
|
||||||
logger: Logger # For logging
|
|
||||||
http_client: HttpClient # For network requests
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Plugin Contract
|
|
||||||
|
|
||||||
### 6.1 Plugin Exports
|
|
||||||
|
|
||||||
```python
|
|
||||||
# plugins/kokoro/__init__.py
|
|
||||||
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(...)
|
|
||||||
MODEL_REQUIREMENTS = [...] # Static at plugin level
|
|
||||||
|
|
||||||
def create_engine(
|
|
||||||
context: HostContext,
|
|
||||||
model_path: Path | None,
|
|
||||||
config: EngineConfig
|
|
||||||
) -> Engine:
|
|
||||||
"""Create engine. Atomic: succeeds fully or raises and cleans up."""
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 create_engine() Contract
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- context: HostContext (host services)
|
|
||||||
- model_path: Path | None (resolved model path, or None for cloud/no-model engines)
|
|
||||||
- config: EngineConfig (engine initialization settings)
|
|
||||||
- Returns: Engine
|
|
||||||
- Raises: EngineError on failure
|
|
||||||
- Atomic: Succeeds fully or cleans up and raises
|
|
||||||
- Thread-safe: Can be called from any thread
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Object Lifecycle
|
|
||||||
|
|
||||||
### 7.1 Engine Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
1. DISCOVERY
|
|
||||||
Host scans plugin directories
|
|
||||||
Loads PLUGIN_MANIFEST and MODEL_REQUIREMENTS
|
|
||||||
|
|
||||||
2. MODEL DOWNLOAD (if MODEL_REQUIREMENTS non-empty)
|
|
||||||
Host reads MODEL_REQUIREMENTS
|
|
||||||
Downloads/caches required models
|
|
||||||
Resolves model_path for create_engine()
|
|
||||||
|
|
||||||
3. ACTIVATION
|
|
||||||
Host calls create_engine(context, model_path, config)
|
|
||||||
Engine created, ready to use
|
|
||||||
Raises EngineError on failure
|
|
||||||
|
|
||||||
4. SESSION CREATION
|
|
||||||
Client calls engine.createSession()
|
|
||||||
Returns EngineSession
|
|
||||||
Ownership transfers to caller
|
|
||||||
Raises EngineError on failure
|
|
||||||
No partially initialized session returned
|
|
||||||
|
|
||||||
5. SYNTHESIS
|
|
||||||
Client calls session.synthesize(request)
|
|
||||||
Returns SynthesizedAudio
|
|
||||||
Raises EngineError on failure (session remains usable)
|
|
||||||
|
|
||||||
6. SESSION DISPOSAL
|
|
||||||
Client calls session.dispose()
|
|
||||||
Releases session resources
|
|
||||||
|
|
||||||
7. DEACTIVATION
|
|
||||||
Client calls engine.dispose()
|
|
||||||
Caller must ensure all sessions are disposed first
|
|
||||||
Disposing engine while sessions are alive is undefined behavior
|
|
||||||
Releases engine resources
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 EngineSession Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
1. CREATION
|
|
||||||
Created by Engine.createSession()
|
|
||||||
Ownership transfers to caller
|
|
||||||
Raises EngineError on failure
|
|
||||||
|
|
||||||
2. USAGE
|
|
||||||
Client calls synthesize() one or more times
|
|
||||||
Each call returns SynthesizedAudio or raises EngineError
|
|
||||||
Session remains usable after synthesize() failure
|
|
||||||
If CancelableSession: cancel() causes synthesize() to raise CancelledError
|
|
||||||
If StreamingSynthesizer: iterator raises CancelledError on cancel(), EngineError on failure
|
|
||||||
|
|
||||||
3. DISPOSAL
|
|
||||||
Client calls dispose()
|
|
||||||
Releases session resources
|
|
||||||
After dispose(), all methods except dispose() raise EngineError
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 Ownership Rules
|
|
||||||
|
|
||||||
- Engine.createSession() transfers ownership of the returned session to the caller
|
|
||||||
- Caller is responsible for disposing all sessions before disposing the engine
|
|
||||||
- Engine does not track sessions; it has no lifecycle registry
|
|
||||||
- Disposing an engine while any session is still alive violates the API contract; behavior is undefined
|
|
||||||
- This design avoids coupling, synchronization overhead, and lifecycle registry complexity
|
|
||||||
|
|
||||||
### 7.4 Concurrent Operations
|
|
||||||
|
|
||||||
**Engine.dispose() concurrent with Engine.createSession()**:
|
|
||||||
- createSession() must either succeed with fully initialized EngineSession or raise EngineError
|
|
||||||
- Partially initialized EngineSession must never be returned
|
|
||||||
- After dispose() completes, subsequent createSession() calls must raise EngineError
|
|
||||||
|
|
||||||
**EngineSession.dispose() concurrent with EngineSession.synthesize()**:
|
|
||||||
- Not thread-safe. Caller must ensure synthesize() completes before dispose().
|
|
||||||
|
|
||||||
**EngineSession.dispose() concurrent with StreamingSynthesizer.synthesizeStream()**:
|
|
||||||
- Not thread-safe. Caller must ensure stream iteration completes before dispose().
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Thread Safety Contract
|
|
||||||
|
|
||||||
| Component | Thread-safe | Notes |
|
|
||||||
|-----------|-------------|-------|
|
|
||||||
| Engine | Yes | createSession() can be called from any thread |
|
|
||||||
| EngineSession | No | synthesize() must be called from one thread at a time |
|
|
||||||
| HostContext | Yes | Provides shared services |
|
|
||||||
| VoiceSelection | Yes | Immutable value object |
|
|
||||||
| ParameterValues | Yes | Immutable value object |
|
|
||||||
| AudioFormat | Yes | Immutable value object |
|
|
||||||
| EngineConfig | Yes | Immutable value object |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. dispose() Contract
|
|
||||||
|
|
||||||
**General rules**:
|
|
||||||
- Calling dispose() multiple times is safe (no-op on second call)
|
|
||||||
- dispose() never raises exceptions (catches and logs internally)
|
|
||||||
- After dispose(), all methods except dispose() raise EngineError
|
|
||||||
|
|
||||||
**Engine.dispose()**:
|
|
||||||
- Caller must ensure all sessions are disposed first
|
|
||||||
- Disposing engine while sessions are alive violates API contract; behavior is undefined
|
|
||||||
- Releases engine resources
|
|
||||||
|
|
||||||
**EngineSession.dispose()**:
|
|
||||||
- Releases session resources
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Dependency Rules
|
|
||||||
|
|
||||||
```
|
|
||||||
Core Domain (Engine, EngineSession, Value Objects)
|
|
||||||
-> No dependencies
|
|
||||||
|
|
||||||
Plugin Manifest (PluginManifest, ModelManifest, etc.)
|
|
||||||
-> No dependencies
|
|
||||||
|
|
||||||
Host Context (HostContext)
|
|
||||||
-> Depends on: Core Domain (for types)
|
|
||||||
|
|
||||||
Plugin Implementation
|
|
||||||
-> Depends on: Core Domain, Host Context
|
|
||||||
|
|
||||||
Host
|
|
||||||
-> Depends on: Core Domain, Plugin Manifest
|
|
||||||
```
|
|
||||||
|
|
||||||
**Forbidden**:
|
|
||||||
- Core Domain -> anything else
|
|
||||||
- Plugin Manifest -> anything else
|
|
||||||
- Plugin Implementation -> Host (only receives HostContext)
|
|
||||||
- Host -> Plugin Implementation (only via create_engine function)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Architectural Invariants
|
|
||||||
|
|
||||||
1. Core Domain has zero dependencies
|
|
||||||
2. Plugins receive HostContext at creation, not via global state
|
|
||||||
3. Model requirements are static (plugin level), not dynamic (engine level)
|
|
||||||
4. Host validates capability implementation at load time: each capability declared in PluginManifest.capabilities must be implemented by the exported object via the corresponding interface
|
|
||||||
5. synthesize() raises typed exceptions, not returns Result
|
|
||||||
6. dispose() is idempotent and never raises
|
|
||||||
7. No global state, no service locator
|
|
||||||
8. VoiceSelection and ParameterValues are opaque to engine
|
|
||||||
9. Display information comes from VoiceManifest
|
|
||||||
10. HostContext is minimal (3 fields max)
|
|
||||||
11. EngineConfig contains only engine settings, not resource references
|
|
||||||
12. EngineSession owns mutable execution state isolated from other concurrent work
|
|
||||||
13. Engine.createSession() transfers ownership to caller
|
|
||||||
14. Caller must dispose all sessions before disposing engine
|
|
||||||
15. After dispose(), all methods except dispose() raise EngineError
|
|
||||||
16. create_engine() is atomic (all-or-nothing)
|
|
||||||
17. Garbage collection without dispose() may leak (documented)
|
|
||||||
18. Capabilities are additive (new capabilities don't break old plugins)
|
|
||||||
19. api_version enables compatibility checking
|
|
||||||
20. createSession() returns fully initialized session or raises, never partial
|
|
||||||
21. cancel() causes synthesize() to raise CancelledError
|
|
||||||
22. EngineSession remains usable after cancellation
|
|
||||||
23. Engine does not track sessions; no lifecycle registry
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Validation Examples
|
|
||||||
|
|
||||||
### 12.1 Kokoro
|
|
||||||
|
|
||||||
```python
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="kokoro",
|
|
||||||
api_version="1.0",
|
|
||||||
capabilities=["voice_list", "preview", "voice_blend"],
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=[
|
|
||||||
VoiceSourceManifest(id="builtin", type="list", config={"voices": [...]}),
|
|
||||||
VoiceSourceManifest(id="formula", type="blend", config={"syntax": "{a}*0.5+{b}*0.5"}),
|
|
||||||
],
|
|
||||||
parameters=[ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0)],
|
|
||||||
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS = []
|
|
||||||
|
|
||||||
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
|
|
||||||
model = load_kokoro(model_path)
|
|
||||||
return KokoroEngine(model, config.device)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12.2 SuperTonic
|
|
||||||
|
|
||||||
```python
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="supertonic",
|
|
||||||
api_version="1.0",
|
|
||||||
capabilities=["voice_list", "preview"],
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=[VoiceSourceManifest(id="builtin", type="list", config={"voices": [...]})],
|
|
||||||
parameters=[
|
|
||||||
ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0),
|
|
||||||
ParameterManifest(id="steps", type="int", default=20, min=5, max=50),
|
|
||||||
],
|
|
||||||
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS = []
|
|
||||||
|
|
||||||
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
|
|
||||||
model = load_supertonic(model_path)
|
|
||||||
return SuperTonicEngine(model, config.device)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12.3 ElevenLabs
|
|
||||||
|
|
||||||
```python
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="elevenlabs",
|
|
||||||
api_version="1.0",
|
|
||||||
capabilities=["voice_list"],
|
|
||||||
requires=RequirementManifest(internet=True),
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=[VoiceSourceManifest(id="cloud", type="list", config={"speakers": [...]})],
|
|
||||||
parameters=[ParameterManifest(id="stability", type="float", default=0.5, min=0.0, max=1.0)],
|
|
||||||
audioFormats=[AudioFormatManifest(mime="audio/mpeg", extension="mp3")],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS = []
|
|
||||||
|
|
||||||
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
|
|
||||||
api_key = (context.config_dir / "elevenlabs_key").read_text()
|
|
||||||
return ElevenLabsEngine(api_key)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12.4 Piper
|
|
||||||
|
|
||||||
```python
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="piper",
|
|
||||||
api_version="1.0",
|
|
||||||
capabilities=[],
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=[VoiceSourceManifest(id="downloadable", type="list", config={"models": [...]})],
|
|
||||||
parameters=[ParameterManifest(id="speed", type="float", default=1.0, min=0.5, max=2.0)],
|
|
||||||
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS = [
|
|
||||||
ModelManifest(id="en_US-lessac-medium", name="English Lessac Medium", size="100MB"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
|
|
||||||
return PiperEngine(model_path, config.device)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12.5 XTTS (with streaming and cancellation)
|
|
||||||
|
|
||||||
```python
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="xtts",
|
|
||||||
api_version="1.0",
|
|
||||||
capabilities=["voice_list", "preview", "voice_clone", "streaming", "cancel"],
|
|
||||||
requires=RequirementManifest(gpu=GpuRequirement(required=True, type="cuda")),
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=[
|
|
||||||
VoiceSourceManifest(id="speakers", type="speaker_id", config={"speakers": [...]}),
|
|
||||||
VoiceSourceManifest(id="clone", type="clone", config={"requiresAudio": True, "maxDuration": 30}),
|
|
||||||
],
|
|
||||||
parameters=[ParameterManifest(id="temperature", type="float", default=0.7, min=0.1, max=1.0)],
|
|
||||||
audioFormats=[AudioFormatManifest(mime="audio/wav", extension="wav")],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS = [
|
|
||||||
ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def create_engine(context: HostContext, model_path: Path | None, config: EngineConfig) -> Engine:
|
|
||||||
return XTTSEngine(model_path, config.device)
|
|
||||||
```
|
|
||||||
|
|
||||||
XTTS session implements: EngineSession, StreamingSynthesizer, CancelableSession.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Summary of All Decisions
|
|
||||||
|
|
||||||
| Aspect | Decision |
|
|
||||||
|--------|----------|
|
|
||||||
| Engine | Factory, stateless, thread-safe for createSession() |
|
|
||||||
| EngineSession | Owns mutable execution state, not thread-safe |
|
|
||||||
| EngineSession ownership | Caller owns (transferred from createSession) |
|
|
||||||
| Engine session tracking | None; engine does not track sessions |
|
|
||||||
| StreamingSynthesizer | Optional capability of EngineSession |
|
|
||||||
| CancelableSession | Optional capability, cancel() raises CancelledError |
|
|
||||||
| dispose() | Idempotent, never raises |
|
|
||||||
| Engine.dispose() | Caller must dispose sessions first; undefined if violated |
|
|
||||||
| createSession() | Raises EngineError on failure, no partial sessions |
|
|
||||||
| create_engine() | Atomic, takes context, model_path, config |
|
|
||||||
| EngineConfig | Engine settings only, no resource references |
|
|
||||||
| model_path | Separate argument, not in EngineConfig |
|
|
||||||
| MODEL_REQUIREMENTS | Static at plugin level |
|
|
||||||
| HostContext | Minimal (3 fields) |
|
|
||||||
| Error handling | Typed exceptions (EngineError hierarchy) |
|
|
||||||
| Thread safety | Documented per component |
|
|
||||||
| Capabilities | Additive, optional interfaces |
|
|
||||||
| API versioning | api_version in manifest |
|
|
||||||
| Concurrent dispose/createSession | Fully initialized session or EngineError |
|
|
||||||
| Concurrent dispose/synthesizeStream | Not thread-safe; caller must complete iteration first |
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"""Kokoro TTS Plugin for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This plugin provides a Kokoro-based TTS engine that implements the
|
|
||||||
Plugin API contract. It wraps the existing Kokoro backend in the
|
|
||||||
new Engine/EngineSession architecture.
|
|
||||||
|
|
||||||
Exports:
|
|
||||||
- PLUGIN_MANIFEST: PluginManifest
|
|
||||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
|
||||||
- create_engine: Factory function
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.manifest import (
|
|
||||||
AudioFormatManifest,
|
|
||||||
EngineManifest,
|
|
||||||
ModelManifest,
|
|
||||||
ParameterManifest,
|
|
||||||
PluginManifest,
|
|
||||||
RequirementManifest,
|
|
||||||
VoiceManifest,
|
|
||||||
VoiceSourceManifest,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
from .engine import KokoroEngine
|
|
||||||
|
|
||||||
|
|
||||||
def _load_kpipeline() -> Any:
|
|
||||||
"""Lazy-load Kokoro dependencies."""
|
|
||||||
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
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Kokoro Engine adapter for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module adapts the existing Kokoro backend to the new Engine/EngineSession
|
|
||||||
protocol. It wraps the KokoroBackend without modifying it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from abogen.tts_plugin.capabilities import VoiceLister
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Sample rate for Kokoro audio
|
|
||||||
_KOKORO_SAMPLE_RATE = 24000
|
|
||||||
|
|
||||||
|
|
||||||
class KokoroSession:
|
|
||||||
"""EngineSession implementation for Kokoro.
|
|
||||||
|
|
||||||
Owns mutable execution state for synthesis.
|
|
||||||
NOT thread-safe.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, pipeline: Any) -> None:
|
|
||||||
self._pipeline = pipeline
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
"""Synthesize audio from text using Kokoro."""
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
|
|
||||||
try:
|
|
||||||
voice = request.voice.key
|
|
||||||
speed = request.parameters.values.get("speed", 1.0)
|
|
||||||
split_pattern = request.parameters.values.get("split_pattern", None)
|
|
||||||
|
|
||||||
audio_parts: list[np.ndarray] = []
|
|
||||||
for segment in self._pipeline(
|
|
||||||
request.text,
|
|
||||||
voice=voice,
|
|
||||||
speed=speed,
|
|
||||||
split_pattern=split_pattern,
|
|
||||||
):
|
|
||||||
audio = segment.audio
|
|
||||||
if hasattr(audio, "numpy"):
|
|
||||||
audio = audio.numpy()
|
|
||||||
audio_parts.append(np.asarray(audio, dtype="float32"))
|
|
||||||
|
|
||||||
if not audio_parts:
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=b"",
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=0.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
|
||||||
audio_bytes = combined.tobytes()
|
|
||||||
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
|
|
||||||
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=audio_bytes,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=duration_seconds),
|
|
||||||
)
|
|
||||||
except EngineError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise EngineError(f"Synthesis failed: {e}") from e
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release session resources. Idempotent."""
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class KokoroEngine:
|
|
||||||
"""Engine implementation for Kokoro.
|
|
||||||
|
|
||||||
Factory for KokoroSession instances. Stateless and thread-safe.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, pipeline: Any) -> None:
|
|
||||||
self._pipeline = pipeline
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def createSession(self) -> KokoroSession:
|
|
||||||
"""Create a new KokoroSession."""
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return KokoroSession(self._pipeline)
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release engine resources. Idempotent."""
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
|
||||||
"""List available Kokoro voices. Implements VoiceLister capability.
|
|
||||||
|
|
||||||
Note: Static voices are declared in the plugin manifest.
|
|
||||||
This method is a fallback for dynamic plugins.
|
|
||||||
"""
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return []
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""SuperTonic TTS Plugin for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This plugin provides a SuperTonic-based TTS engine that implements the
|
|
||||||
Plugin API contract. It wraps the existing SuperTonic backend in the
|
|
||||||
new Engine/EngineSession architecture.
|
|
||||||
|
|
||||||
Exports:
|
|
||||||
- PLUGIN_MANIFEST: PluginManifest
|
|
||||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
|
||||||
- create_engine: Factory function
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.manifest import (
|
|
||||||
AudioFormatManifest,
|
|
||||||
EngineManifest,
|
|
||||||
ModelManifest,
|
|
||||||
ParameterManifest,
|
|
||||||
PluginManifest,
|
|
||||||
RequirementManifest,
|
|
||||||
VoiceManifest,
|
|
||||||
VoiceSourceManifest,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
from .engine import SuperTonicEngine
|
|
||||||
|
|
||||||
|
|
||||||
def _load_supertonic_pipeline() -> Any:
|
|
||||||
"""Lazy-load SuperTonic dependencies and create pipeline."""
|
|
||||||
from plugins.supertonic.pipeline import SupertonicPipeline
|
|
||||||
|
|
||||||
return SupertonicPipeline(
|
|
||||||
sample_rate=24000,
|
|
||||||
auto_download=True,
|
|
||||||
total_steps=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="supertonic",
|
|
||||||
name="SuperTonic",
|
|
||||||
version="0.1.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="SuperTonic TTS engine - fast high-quality text-to-speech",
|
|
||||||
author="SuperTonic Team",
|
|
||||||
capabilities=("voice_list",),
|
|
||||||
requires=RequirementManifest(
|
|
||||||
internet=False,
|
|
||||||
),
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=(
|
|
||||||
VoiceSourceManifest(
|
|
||||||
id="builtin",
|
|
||||||
name="Built-in Voices",
|
|
||||||
type="list",
|
|
||||||
config={"voices": "See listVoices()"},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
parameters=(
|
|
||||||
ParameterManifest(
|
|
||||||
id="speed",
|
|
||||||
name="Speed",
|
|
||||||
description="Speech speed multiplier",
|
|
||||||
type="float",
|
|
||||||
default=1.0,
|
|
||||||
min=0.7,
|
|
||||||
max=2.0,
|
|
||||||
step=0.1,
|
|
||||||
),
|
|
||||||
ParameterManifest(
|
|
||||||
id="total_steps",
|
|
||||||
name="Quality Steps",
|
|
||||||
description="Inference steps (higher = better quality, slower)",
|
|
||||||
type="int",
|
|
||||||
default=5,
|
|
||||||
min=2,
|
|
||||||
max=15,
|
|
||||||
step=1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
audioFormats=(
|
|
||||||
AudioFormatManifest(mime="audio/wav", extension="wav"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
voices=(
|
|
||||||
VoiceManifest(id="M1", name="Male 1", tags=("male",)),
|
|
||||||
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
|
|
||||||
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
|
|
||||||
VoiceManifest(id="M4", name="Male 4", tags=("male",)),
|
|
||||||
VoiceManifest(id="M5", name="Male 5", tags=("male",)),
|
|
||||||
VoiceManifest(id="F1", name="Female 1", tags=("female",)),
|
|
||||||
VoiceManifest(id="F2", name="Female 2", tags=("female",)),
|
|
||||||
VoiceManifest(id="F3", name="Female 3", tags=("female",)),
|
|
||||||
VoiceManifest(id="F4", name="Female 4", tags=("female",)),
|
|
||||||
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
|
||||||
|
|
||||||
|
|
||||||
def create_engine(
|
|
||||||
context: HostContext,
|
|
||||||
model_path: Path | None,
|
|
||||||
config: EngineConfig,
|
|
||||||
) -> Engine:
|
|
||||||
"""Create a SuperTonic engine instance.
|
|
||||||
|
|
||||||
This function is the plugin entry point. It must be atomic:
|
|
||||||
succeed fully or raise EngineError and clean up.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Host services (config dir, logger, http client).
|
|
||||||
model_path: Resolved model path, or None for default.
|
|
||||||
config: Engine initialization settings (device, etc.).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A fully initialized SuperTonicEngine instance.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
EngineError: On failure. Cleans up partially created resources.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
pipeline = _load_supertonic_pipeline()
|
|
||||||
engine = SuperTonicEngine(pipeline)
|
|
||||||
return engine
|
|
||||||
except Exception as e:
|
|
||||||
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
|
|
||||||
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""SuperTonic Engine adapter for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
This module adapts the existing SuperTonic backend to the new Engine/EngineSession
|
|
||||||
protocol. It wraps the SupertonicPipeline without modifying it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from abogen.tts_plugin.capabilities import VoiceLister
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Sample rate for SuperTonic audio
|
|
||||||
_SUPERTONIC_SAMPLE_RATE = 24000
|
|
||||||
|
|
||||||
|
|
||||||
class SuperTonicSession:
|
|
||||||
"""EngineSession implementation for SuperTonic.
|
|
||||||
|
|
||||||
Owns mutable execution state for synthesis.
|
|
||||||
NOT thread-safe.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, pipeline: Any) -> None:
|
|
||||||
self._pipeline = pipeline
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
"""Synthesize audio from text using SuperTonic."""
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import soundfile as sf
|
|
||||||
|
|
||||||
voice = request.voice.key
|
|
||||||
speed = float(request.parameters.values.get("speed", 1.0))
|
|
||||||
total_steps = request.parameters.values.get("total_steps", None)
|
|
||||||
split_pattern = request.parameters.values.get("split_pattern", None)
|
|
||||||
|
|
||||||
if total_steps is not None:
|
|
||||||
total_steps = int(total_steps)
|
|
||||||
|
|
||||||
audio_parts: list[np.ndarray] = []
|
|
||||||
for segment in self._pipeline(
|
|
||||||
request.text,
|
|
||||||
voice=voice,
|
|
||||||
speed=speed,
|
|
||||||
split_pattern=split_pattern,
|
|
||||||
total_steps=total_steps,
|
|
||||||
):
|
|
||||||
audio_parts.append(segment.audio)
|
|
||||||
|
|
||||||
if not audio_parts:
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=b"",
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=0.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
|
|
||||||
audio_bytes = buf.getvalue()
|
|
||||||
duration_seconds = len(combined) / self._pipeline.sample_rate
|
|
||||||
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=audio_bytes,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=duration_seconds),
|
|
||||||
)
|
|
||||||
except EngineError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise EngineError(f"Synthesis failed: {e}") from e
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release session resources. Idempotent."""
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class SuperTonicEngine:
|
|
||||||
"""Engine implementation for SuperTonic.
|
|
||||||
|
|
||||||
Factory for SuperTonicSession instances. Stateless and thread-safe.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, pipeline: Any) -> None:
|
|
||||||
self._pipeline = pipeline
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def createSession(self) -> SuperTonicSession:
|
|
||||||
"""Create a new SuperTonicSession."""
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return SuperTonicSession(self._pipeline)
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
"""Release engine resources. Idempotent."""
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
|
||||||
"""List available SuperTonic voices. Implements VoiceLister capability.
|
|
||||||
|
|
||||||
Note: Static voice catalog is declared in plugin manifest.
|
|
||||||
This method is retained for VoiceLister interface compliance.
|
|
||||||
"""
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return []
|
|
||||||
+5
-2
@@ -50,6 +50,8 @@ dependencies = [
|
|||||||
"num2words>=0.5.13",
|
"num2words>=0.5.13",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"PyQt6>=6.5.0",
|
"PyQt6>=6.5.0",
|
||||||
|
"flet>=0.85.1",
|
||||||
|
"msgpack>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -77,11 +79,12 @@ allow-direct-references = true
|
|||||||
|
|
||||||
|
|
||||||
[project.gui-scripts]
|
[project.gui-scripts]
|
||||||
abogen = "abogen.pyqt.main:main"
|
abogen = "abogen.frontend.main:main"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
abogen-ui = "abogen.frontend.main:main"
|
||||||
|
abogen-web = "abogen.frontend.main:main_web"
|
||||||
abogen-cli = "abogen.webui.app:main"
|
abogen-cli = "abogen.webui.app:main"
|
||||||
abogen-web = "abogen.webui.app:main"
|
|
||||||
abogen-pyqt = "abogen.pyqt.main:main"
|
abogen-pyqt = "abogen.pyqt.main:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Contract tests for the TTS Plugin API.
|
|
||||||
|
|
||||||
This package contains reusable contract tests that any TTS plugin implementation
|
|
||||||
must satisfy. Tests use only the public API and are engine-agnostic.
|
|
||||||
"""
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"""Shared fixtures and stubs for contract tests.
|
|
||||||
|
|
||||||
This module provides minimal stub implementations that satisfy the public API
|
|
||||||
for testing purposes. These stubs do NOT contain real business logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
EngineConfig,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeHttpClient:
|
|
||||||
"""Stub HTTP client that satisfies the HttpClient protocol."""
|
|
||||||
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class FakeEngineSession:
|
|
||||||
"""Stub EngineSession for testing protocol compliance."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=b"\x00" * 100,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=1.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeStreamingSession:
|
|
||||||
"""Stub EngineSession with StreamingSynthesizer capability."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=b"\x00" * 100,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=1.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
for i in range(3):
|
|
||||||
yield b"\x00" * 50
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeCancelableSession:
|
|
||||||
"""Stub EngineSession with CancelableSession capability."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._disposed = False
|
|
||||||
self._cancelled = False
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
if self._cancelled:
|
|
||||||
from abogen.tts_plugin.errors import CancelledError
|
|
||||||
|
|
||||||
raise CancelledError("Cancelled")
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=b"\x00" * 100,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=1.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
self._cancelled = True
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeEngine:
|
|
||||||
"""Stub Engine for testing protocol compliance."""
|
|
||||||
|
|
||||||
def __init__(self, session_class: type = FakeEngineSession) -> None:
|
|
||||||
self._disposed = False
|
|
||||||
self._session_class = session_class
|
|
||||||
|
|
||||||
def createSession(self) -> EngineSession:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return self._session_class()
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakeVoiceListerEngine:
|
|
||||||
"""Stub Engine that also implements VoiceLister."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def createSession(self) -> EngineSession:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return FakeEngineSession()
|
|
||||||
|
|
||||||
def listVoices(self, sourceId: str) -> list:
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
|
|
||||||
return [
|
|
||||||
VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
|
|
||||||
VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
|
|
||||||
]
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class FakePreviewEngine:
|
|
||||||
"""Stub Engine that also implements PreviewGenerator."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def createSession(self) -> EngineSession:
|
|
||||||
if self._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return FakeEngineSession()
|
|
||||||
|
|
||||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=b"\x00" * 50,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=0.5),
|
|
||||||
)
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_http_client() -> FakeHttpClient:
|
|
||||||
return FakeHttpClient()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
|
|
||||||
return HostContext(
|
|
||||||
config_dir=tmp_path,
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=fake_http_client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_engine() -> FakeEngine:
|
|
||||||
return FakeEngine()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_session() -> FakeEngineSession:
|
|
||||||
return FakeEngineSession()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_voice() -> VoiceSelection:
|
|
||||||
return VoiceSelection(source="builtin", key="af_nova")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_format() -> AudioFormat:
|
|
||||||
return AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_request(
|
|
||||||
default_voice: VoiceSelection, default_format: AudioFormat
|
|
||||||
) -> SynthesisRequest:
|
|
||||||
return SynthesisRequest(
|
|
||||||
text="Hello, world!",
|
|
||||||
voice=default_voice,
|
|
||||||
parameters=ParameterValues(values={}),
|
|
||||||
format=default_format,
|
|
||||||
)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Base contract tests for Engine implementations.
|
|
||||||
|
|
||||||
Any new TTS plugin must inherit from these classes to verify
|
|
||||||
it satisfies the Engine/EngineSession protocol.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from tests.contracts.engine_contract import EngineContractMixin
|
|
||||||
|
|
||||||
class TestMyEngine(EngineContractMixin):
|
|
||||||
@pytest.fixture
|
|
||||||
def engine(self):
|
|
||||||
return create_my_engine()
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EngineContractMixin:
|
|
||||||
"""Base contract tests for Engine implementations.
|
|
||||||
|
|
||||||
Subclasses must define a module-level ``engine`` fixture returning
|
|
||||||
a fully initialized Engine instance. The tests below will use it
|
|
||||||
via pytest's standard fixture resolution.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
|
|
||||||
return SynthesisRequest(
|
|
||||||
text=text,
|
|
||||||
voice=VoiceSelection(source="builtin", key=voice or "default"),
|
|
||||||
parameters=ParameterValues(values={}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Engine protocol ──────────────────────────────────────
|
|
||||||
|
|
||||||
def test_engine_satisfies_protocol(self, engine: Engine) -> None:
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
|
|
||||||
def test_create_session_returns_session(self, engine: Engine) -> None:
|
|
||||||
session = engine.createSession()
|
|
||||||
assert isinstance(session, EngineSession)
|
|
||||||
session.dispose()
|
|
||||||
|
|
||||||
def test_create_session_returns_new_instances(self, engine: Engine) -> None:
|
|
||||||
s1 = engine.createSession()
|
|
||||||
s2 = engine.createSession()
|
|
||||||
assert s1 is not s2
|
|
||||||
s1.dispose()
|
|
||||||
s2.dispose()
|
|
||||||
|
|
||||||
def test_dispose_is_idempotent(self, engine: Engine) -> None:
|
|
||||||
engine.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
|
|
||||||
engine.dispose()
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
engine.createSession()
|
|
||||||
|
|
||||||
# ── Session protocol ─────────────────────────────────────
|
|
||||||
|
|
||||||
def test_session_satisfies_protocol(self, engine: Engine) -> None:
|
|
||||||
session = engine.createSession()
|
|
||||||
assert isinstance(session, EngineSession)
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
|
|
||||||
session = engine.createSession()
|
|
||||||
result = session.synthesize(self._req())
|
|
||||||
assert isinstance(result, SynthesizedAudio)
|
|
||||||
assert isinstance(result.data, bytes)
|
|
||||||
assert len(result.data) > 0
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
|
|
||||||
session = engine.createSession()
|
|
||||||
session.dispose()
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
|
|
||||||
session = engine.createSession()
|
|
||||||
session.dispose()
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
session.synthesize(self._req())
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_session_multiple_synthesize(self, engine: Engine) -> None:
|
|
||||||
session = engine.createSession()
|
|
||||||
r1 = session.synthesize(self._req())
|
|
||||||
r2 = session.synthesize(self._req())
|
|
||||||
assert isinstance(r1.data, bytes)
|
|
||||||
assert isinstance(r2.data, bytes)
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_full_lifecycle(self, engine: Engine) -> None:
|
|
||||||
s1 = engine.createSession()
|
|
||||||
s2 = engine.createSession()
|
|
||||||
s1.synthesize(self._req())
|
|
||||||
s2.synthesize(self._req())
|
|
||||||
s1.dispose()
|
|
||||||
s2.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""Contract tests for capability interfaces.
|
|
||||||
|
|
||||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
|
||||||
- VoiceLister: lists voices for a source
|
|
||||||
- PreviewGenerator: generates preview audio
|
|
||||||
- StreamingSynthesizer: yields audio chunks
|
|
||||||
- CancelableSession: cancels in-progress synthesis
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.capabilities import (
|
|
||||||
CancelableSession,
|
|
||||||
PreviewGenerator,
|
|
||||||
StreamingSynthesizer,
|
|
||||||
VoiceLister,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceListerProtocolContract:
|
|
||||||
"""Contract tests for VoiceLister protocol."""
|
|
||||||
|
|
||||||
def test_voice_lister_is_protocol(self) -> None:
|
|
||||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
|
||||||
engine = FakeVoiceListerEngine()
|
|
||||||
assert isinstance(engine, VoiceLister)
|
|
||||||
|
|
||||||
def test_list_voices_returns_list(self) -> None:
|
|
||||||
engine = FakeVoiceListerEngine()
|
|
||||||
voices = engine.listVoices("builtin")
|
|
||||||
assert isinstance(voices, list)
|
|
||||||
|
|
||||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
|
||||||
engine = FakeVoiceListerEngine()
|
|
||||||
voices = engine.listVoices("builtin")
|
|
||||||
for voice in voices:
|
|
||||||
assert isinstance(voice, VoiceManifest)
|
|
||||||
|
|
||||||
def test_list_voices_has_required_fields(self) -> None:
|
|
||||||
engine = FakeVoiceListerEngine()
|
|
||||||
voices = engine.listVoices("builtin")
|
|
||||||
for voice in voices:
|
|
||||||
assert hasattr(voice, "id")
|
|
||||||
assert hasattr(voice, "name")
|
|
||||||
assert hasattr(voice, "tags")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreviewGeneratorProtocolContract:
|
|
||||||
"""Contract tests for PreviewGenerator protocol."""
|
|
||||||
|
|
||||||
def test_preview_generator_is_protocol(self) -> None:
|
|
||||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
|
||||||
from .conftest import FakePreviewEngine
|
|
||||||
|
|
||||||
engine = FakePreviewEngine()
|
|
||||||
assert isinstance(engine, PreviewGenerator)
|
|
||||||
|
|
||||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
|
||||||
from .conftest import FakePreviewEngine
|
|
||||||
|
|
||||||
engine = FakePreviewEngine()
|
|
||||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
|
||||||
result = engine.generatePreview(voice, "Hello")
|
|
||||||
assert isinstance(result, SynthesizedAudio)
|
|
||||||
|
|
||||||
def test_generate_preview_has_valid_data(self) -> None:
|
|
||||||
from .conftest import FakePreviewEngine
|
|
||||||
|
|
||||||
engine = FakePreviewEngine()
|
|
||||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
|
||||||
result = engine.generatePreview(voice, "Hello")
|
|
||||||
assert isinstance(result.data, bytes)
|
|
||||||
assert len(result.data) > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamingSynthesizerProtocolContract:
|
|
||||||
"""Contract tests for StreamingSynthesizer protocol."""
|
|
||||||
|
|
||||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
|
||||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
|
||||||
session = FakeStreamingSession()
|
|
||||||
assert isinstance(session, StreamingSynthesizer)
|
|
||||||
|
|
||||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
|
||||||
session = FakeStreamingSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
chunks = list(session.synthesizeStream(request))
|
|
||||||
assert len(chunks) > 0
|
|
||||||
for chunk in chunks:
|
|
||||||
assert isinstance(chunk, bytes)
|
|
||||||
|
|
||||||
def test_streaming_iterator_exhaustion(self) -> None:
|
|
||||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
|
||||||
session = FakeStreamingSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
chunks = list(session.synthesizeStream(request))
|
|
||||||
assert len(chunks) == 3
|
|
||||||
|
|
||||||
def test_streaming_after_dispose_raises(self) -> None:
|
|
||||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
|
||||||
session = FakeStreamingSession()
|
|
||||||
session.dispose()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
list(session.synthesizeStream(request))
|
|
||||||
|
|
||||||
|
|
||||||
class TestCancelableSessionProtocolContract:
|
|
||||||
"""Contract tests for CancelableSession protocol."""
|
|
||||||
|
|
||||||
def test_cancelable_session_is_protocol(self) -> None:
|
|
||||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
|
||||||
session = FakeCancelableSession()
|
|
||||||
assert isinstance(session, CancelableSession)
|
|
||||||
|
|
||||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
|
||||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
|
||||||
session = FakeCancelableSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cancel
|
|
||||||
session.cancel()
|
|
||||||
|
|
||||||
# synthesize should raise CancelledError
|
|
||||||
with pytest.raises(CancelledError):
|
|
||||||
session.synthesize(request)
|
|
||||||
|
|
||||||
def test_cancel_after_dispose_raises(self) -> None:
|
|
||||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
|
||||||
session = FakeCancelableSession()
|
|
||||||
session.dispose()
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
session.cancel()
|
|
||||||
|
|
||||||
def test_session_usable_after_cancel(self) -> None:
|
|
||||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
|
||||||
session = FakeCancelableSession()
|
|
||||||
|
|
||||||
# Cancel
|
|
||||||
session.cancel()
|
|
||||||
|
|
||||||
# Dispose and create new session for synthesis
|
|
||||||
session.dispose()
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Contract tests for Engine protocol.
|
|
||||||
|
|
||||||
These tests verify that Engine implementations satisfy the architectural requirements:
|
|
||||||
- createSession() returns EngineSession
|
|
||||||
- dispose() is idempotent
|
|
||||||
- After dispose(), createSession() raises EngineError
|
|
||||||
- Engine is thread-safe for createSession()
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
|
|
||||||
from .conftest import FakeEngine, FakeEngineSession
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineProtocolContract:
|
|
||||||
"""Contract tests for the Engine protocol itself."""
|
|
||||||
|
|
||||||
def test_engine_is_protocol(self) -> None:
|
|
||||||
assert hasattr(Engine, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_engine_session_is_protocol(self) -> None:
|
|
||||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_fake_engine_satisfies_protocol(self) -> None:
|
|
||||||
engine = FakeEngine()
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
|
|
||||||
def test_fake_session_satisfies_protocol(self) -> None:
|
|
||||||
session = FakeEngineSession()
|
|
||||||
assert isinstance(session, EngineSession)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineCreateSessionContract:
|
|
||||||
"""Contract tests for Engine.createSession()."""
|
|
||||||
|
|
||||||
def test_create_session_returns_engine_session(self) -> None:
|
|
||||||
engine = FakeEngine()
|
|
||||||
session = engine.createSession()
|
|
||||||
assert isinstance(session, EngineSession)
|
|
||||||
|
|
||||||
def test_create_session_returns_new_instance(self) -> None:
|
|
||||||
engine = FakeEngine()
|
|
||||||
session1 = engine.createSession()
|
|
||||||
session2 = engine.createSession()
|
|
||||||
assert session1 is not session2
|
|
||||||
|
|
||||||
def test_create_session_ownership_transfers(self) -> None:
|
|
||||||
"""Architecture spec: Ownership transfers to caller."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
session = engine.createSession()
|
|
||||||
assert isinstance(session, EngineSession)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineDisposeContract:
|
|
||||||
"""Contract tests for Engine.dispose()."""
|
|
||||||
|
|
||||||
def test_dispose_is_idempotent(self) -> None:
|
|
||||||
"""Architecture spec: dispose() is idempotent."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
engine.dispose()
|
|
||||||
engine.dispose() # Should not raise
|
|
||||||
|
|
||||||
def test_dispose_never_raises(self) -> None:
|
|
||||||
"""Architecture spec: dispose() never raises exceptions."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
engine.dispose() # Should not raise
|
|
||||||
|
|
||||||
def test_create_session_after_dispose_raises(self) -> None:
|
|
||||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
engine.dispose()
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
engine.createSession()
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineLifecycleContract:
|
|
||||||
"""Contract tests for Engine lifecycle."""
|
|
||||||
|
|
||||||
def test_full_lifecycle(self) -> None:
|
|
||||||
"""Test complete engine lifecycle: create -> sessions -> dispose."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
|
|
||||||
# Create sessions
|
|
||||||
session1 = engine.createSession()
|
|
||||||
session2 = engine.createSession()
|
|
||||||
|
|
||||||
# Use sessions
|
|
||||||
assert isinstance(session1, EngineSession)
|
|
||||||
assert isinstance(session2, EngineSession)
|
|
||||||
|
|
||||||
# Dispose sessions
|
|
||||||
session1.dispose()
|
|
||||||
session2.dispose()
|
|
||||||
|
|
||||||
# Dispose engine
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_engine_disposed_session_raises(self) -> None:
|
|
||||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
engine.dispose()
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
engine.createSession()
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""Contract tests for error hierarchy.
|
|
||||||
|
|
||||||
These tests verify that the error hierarchy satisfies the architectural requirements:
|
|
||||||
- All errors inherit from EngineError
|
|
||||||
- EngineError inherits from Exception
|
|
||||||
- Each error type is properly classified
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.errors import (
|
|
||||||
CancelledError,
|
|
||||||
ConfigurationError,
|
|
||||||
EngineError,
|
|
||||||
InternalError,
|
|
||||||
InvalidInputError,
|
|
||||||
ModelLoadError,
|
|
||||||
ModelNotFoundError,
|
|
||||||
NetworkError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorHierarchyContract:
|
|
||||||
"""Contract tests for the error hierarchy."""
|
|
||||||
|
|
||||||
def test_engine_error_is_exception(self) -> None:
|
|
||||||
assert issubclass(EngineError, Exception)
|
|
||||||
|
|
||||||
def test_all_errors_inherit_from_engine_error(self) -> None:
|
|
||||||
error_classes = [
|
|
||||||
ModelNotFoundError,
|
|
||||||
ModelLoadError,
|
|
||||||
NetworkError,
|
|
||||||
InvalidInputError,
|
|
||||||
ConfigurationError,
|
|
||||||
CancelledError,
|
|
||||||
InternalError,
|
|
||||||
]
|
|
||||||
for error_class in error_classes:
|
|
||||||
assert issubclass(error_class, EngineError), (
|
|
||||||
f"{error_class.__name__} must inherit from EngineError"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_all_errors_are_catchable(self) -> None:
|
|
||||||
error_classes = [
|
|
||||||
EngineError,
|
|
||||||
ModelNotFoundError,
|
|
||||||
ModelLoadError,
|
|
||||||
NetworkError,
|
|
||||||
InvalidInputError,
|
|
||||||
ConfigurationError,
|
|
||||||
CancelledError,
|
|
||||||
InternalError,
|
|
||||||
]
|
|
||||||
for error_class in error_classes:
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
raise error_class("test message")
|
|
||||||
|
|
||||||
def test_error_message_preserved(self) -> None:
|
|
||||||
msg = "Model not found: bert-base"
|
|
||||||
with pytest.raises(ModelNotFoundError, match=msg):
|
|
||||||
raise ModelNotFoundError(msg)
|
|
||||||
|
|
||||||
def test_error_can_be_caught_as_engine_error(self) -> None:
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
raise ModelNotFoundError("test")
|
|
||||||
|
|
||||||
def test_cancelled_error_is_engine_error(self) -> None:
|
|
||||||
"""CancelledError is a subtype of EngineError per architecture spec."""
|
|
||||||
assert issubclass(CancelledError, EngineError)
|
|
||||||
|
|
||||||
def test_error_hierarchy_no_cycles(self) -> None:
|
|
||||||
"""Verify no circular inheritance."""
|
|
||||||
error_classes = [
|
|
||||||
EngineError,
|
|
||||||
ModelNotFoundError,
|
|
||||||
ModelLoadError,
|
|
||||||
NetworkError,
|
|
||||||
InvalidInputError,
|
|
||||||
ConfigurationError,
|
|
||||||
CancelledError,
|
|
||||||
InternalError,
|
|
||||||
]
|
|
||||||
for cls in error_classes:
|
|
||||||
assert cls not in cls.__bases__
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Contract tests for HostContext.
|
|
||||||
|
|
||||||
These tests verify that HostContext satisfies the architectural requirements:
|
|
||||||
- Minimal (3 fields maximum)
|
|
||||||
- Frozen dataclass
|
|
||||||
- config_dir: Path
|
|
||||||
- logger: Logger
|
|
||||||
- http_client: HttpClient protocol
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
|
||||||
|
|
||||||
|
|
||||||
class TestHostContextContract:
|
|
||||||
"""Contract tests for HostContext dataclass."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(HostContext, "__dataclass_params__")
|
|
||||||
assert HostContext.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_required_fields(self, tmp_path: Path) -> None:
|
|
||||||
logger = logging.getLogger("test")
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=tmp_path,
|
|
||||||
logger=logger,
|
|
||||||
http_client=FakeClient(),
|
|
||||||
)
|
|
||||||
assert ctx.config_dir == tmp_path
|
|
||||||
assert ctx.logger is logger
|
|
||||||
|
|
||||||
def test_immutability(self, tmp_path: Path) -> None:
|
|
||||||
class FakeClient:
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=tmp_path,
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=FakeClient(),
|
|
||||||
)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
ctx.config_dir = Path("/other") # type: ignore[misc]
|
|
||||||
|
|
||||||
def test_max_three_fields(self) -> None:
|
|
||||||
"""Architecture spec: HostContext is minimal (3 fields max)."""
|
|
||||||
import dataclasses
|
|
||||||
|
|
||||||
fields = dataclasses.fields(HostContext)
|
|
||||||
assert len(fields) <= 3
|
|
||||||
|
|
||||||
|
|
||||||
class TestHttpClientProtocolContract:
|
|
||||||
"""Contract tests for HttpClient protocol."""
|
|
||||||
|
|
||||||
def test_http_client_is_protocol(self) -> None:
|
|
||||||
assert hasattr(HttpClient, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_http_client_has_get(self) -> None:
|
|
||||||
assert hasattr(HttpClient, "get")
|
|
||||||
|
|
||||||
def test_http_client_has_post(self) -> None:
|
|
||||||
assert hasattr(HttpClient, "post")
|
|
||||||
|
|
||||||
def test_http_client_satisfied(self) -> None:
|
|
||||||
class FakeClient:
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
client = FakeClient()
|
|
||||||
assert isinstance(client, HttpClient)
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
"""Integration tests for the TTS Plugin Architecture.
|
|
||||||
|
|
||||||
These tests verify:
|
|
||||||
1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
|
|
||||||
2. Lifecycle: dispose, no leaks, error handling
|
|
||||||
3. Regression: old path vs new path equivalence
|
|
||||||
|
|
||||||
Tests use mock plugins to avoid requiring real TTS dependencies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from typing import Any, Iterator
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
|
||||||
from abogen.tts_plugin.utils import Pipeline, create_pipeline
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MockEngineSession:
|
|
||||||
"""Mock EngineSession that records calls for verification."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._disposed = False
|
|
||||||
self.synthesize_calls = []
|
|
||||||
|
|
||||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Session disposed")
|
|
||||||
|
|
||||||
self.synthesize_calls.append(request)
|
|
||||||
|
|
||||||
# Return fake audio
|
|
||||||
audio = np.ones(1000, dtype=np.float32) * 0.5
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=audio.tobytes(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=1000 / 24000),
|
|
||||||
)
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class MockEngine:
|
|
||||||
"""Mock Engine that creates MockEngineSessions."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.kwargs = kwargs
|
|
||||||
self._disposed = False
|
|
||||||
self.sessions_created = []
|
|
||||||
|
|
||||||
def createSession(self) -> MockEngineSession:
|
|
||||||
if self._disposed:
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
session = MockEngineSession()
|
|
||||||
self.sessions_created.append(session)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def dispose(self) -> None:
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
def create_mock_plugin(create_engine_func=None):
|
|
||||||
"""Helper to create a mock plugin module."""
|
|
||||||
if create_engine_func is None:
|
|
||||||
create_engine_func = lambda **kwargs: MockEngine(**kwargs)
|
|
||||||
|
|
||||||
from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
|
|
||||||
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="mock_tts",
|
|
||||||
name="Mock TTS",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="Mock TTS for testing",
|
|
||||||
author="Test",
|
|
||||||
capabilities=(),
|
|
||||||
requires=None,
|
|
||||||
engine=EngineManifest(
|
|
||||||
voiceSources=(),
|
|
||||||
parameters=(),
|
|
||||||
audioFormats=(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"PLUGIN_MANIFEST": manifest,
|
|
||||||
"MODEL_REQUIREMENTS": [],
|
|
||||||
"create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_mock_plugin_engine(**kwargs):
|
|
||||||
"""Default mock plugin engine factory."""
|
|
||||||
return MockEngine(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConsumerFlow:
|
|
||||||
"""Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
|
|
||||||
|
|
||||||
def test_full_consumer_flow(self):
|
|
||||||
"""Verify complete flow from consumer to audio output."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
# Register mock plugin
|
|
||||||
mock_plugin = create_mock_plugin()
|
|
||||||
manager._plugins["mock_tts"] = mock_plugin
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# Step 1: Consumer gets plugin
|
|
||||||
assert manager.has_plugin("mock_tts") is True
|
|
||||||
|
|
||||||
# Step 2: Plugin creates engine
|
|
||||||
engine = manager.create_engine("mock_tts")
|
|
||||||
assert engine is not None
|
|
||||||
assert isinstance(engine, MockEngine)
|
|
||||||
|
|
||||||
# Step 3: Engine creates session
|
|
||||||
session = engine.createSession()
|
|
||||||
assert session is not None
|
|
||||||
assert isinstance(session, MockEngineSession)
|
|
||||||
|
|
||||||
# Step 4: Session synthesizes
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello world",
|
|
||||||
voice=VoiceSelection(source="builtin", key="default"),
|
|
||||||
parameters=ParameterValues(values={"speed": 1.0}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
|
|
||||||
# Step 5: Result returned
|
|
||||||
assert result is not None
|
|
||||||
assert isinstance(result, SynthesizedAudio)
|
|
||||||
assert len(result.data) > 0
|
|
||||||
assert result.format.mime == "audio/wav"
|
|
||||||
assert result.duration.seconds > 0
|
|
||||||
|
|
||||||
def test_consumer_flow_via_pipeline(self):
|
|
||||||
"""Verify flow through Pipeline utility matches direct flow."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
# Register mock plugin
|
|
||||||
mock_plugin = create_mock_plugin()
|
|
||||||
manager._plugins["mock_tts"] = mock_plugin
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# Use Pipeline utility
|
|
||||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
|
||||||
backend = create_pipeline("mock_tts")
|
|
||||||
|
|
||||||
# Call like old TTSBackend
|
|
||||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
|
||||||
|
|
||||||
# Verify result
|
|
||||||
assert len(segments) >= 1
|
|
||||||
segment = segments[0]
|
|
||||||
assert hasattr(segment, "graphemes")
|
|
||||||
assert hasattr(segment, "audio")
|
|
||||||
assert segment.graphemes == "Hello world"
|
|
||||||
|
|
||||||
|
|
||||||
class TestLifecycle:
|
|
||||||
"""Lifecycle Test: dispose, no leaks, error handling"""
|
|
||||||
|
|
||||||
def test_session_dispose_is_idempotent(self):
|
|
||||||
"""dispose() can be called multiple times safely."""
|
|
||||||
session = MockEngineSession()
|
|
||||||
|
|
||||||
session.dispose()
|
|
||||||
session.dispose() # Should not raise
|
|
||||||
assert session._disposed is True
|
|
||||||
|
|
||||||
def test_session_synthesize_after_dispose_raises(self):
|
|
||||||
"""synthesize() after dispose() raises EngineError."""
|
|
||||||
session = MockEngineSession()
|
|
||||||
session.dispose()
|
|
||||||
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="test",
|
|
||||||
voice=VoiceSelection(source="builtin", key="default"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
session.synthesize(request)
|
|
||||||
|
|
||||||
def test_engine_dispose_is_idempotent(self):
|
|
||||||
"""Engine dispose() can be called multiple times safely."""
|
|
||||||
engine = MockEngine()
|
|
||||||
|
|
||||||
engine.dispose()
|
|
||||||
engine.dispose() # Should not raise
|
|
||||||
assert engine._disposed is True
|
|
||||||
|
|
||||||
def test_engine_create_session_after_dispose_raises(self):
|
|
||||||
"""createSession() after dispose() raises EngineError."""
|
|
||||||
engine = MockEngine()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
engine.createSession()
|
|
||||||
|
|
||||||
def test_full_lifecycle(self):
|
|
||||||
"""Test complete lifecycle: create → use → dispose."""
|
|
||||||
engine = MockEngine()
|
|
||||||
|
|
||||||
# Create and use session
|
|
||||||
session = engine.createSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="test",
|
|
||||||
voice=VoiceSelection(source="builtin", key="default"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert len(result.data) > 0
|
|
||||||
|
|
||||||
# Dispose session
|
|
||||||
session.dispose()
|
|
||||||
assert session._disposed is True
|
|
||||||
|
|
||||||
# Dispose engine
|
|
||||||
engine.dispose()
|
|
||||||
assert engine._disposed is True
|
|
||||||
|
|
||||||
def test_no_session_leak_on_engine_dispose(self):
|
|
||||||
"""Engine can be disposed even if sessions were created."""
|
|
||||||
engine = MockEngine()
|
|
||||||
|
|
||||||
# Create multiple sessions
|
|
||||||
session1 = engine.createSession()
|
|
||||||
session2 = engine.createSession()
|
|
||||||
|
|
||||||
# Use sessions
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="test",
|
|
||||||
voice=VoiceSelection(source="builtin", key="default"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
session1.synthesize(request)
|
|
||||||
session2.synthesize(request)
|
|
||||||
|
|
||||||
# Dispose engine (sessions still exist but engine is disposed)
|
|
||||||
engine.dispose()
|
|
||||||
assert engine._disposed is True
|
|
||||||
|
|
||||||
# Sessions can still be used (they hold reference to pipeline)
|
|
||||||
result = session1.synthesize(request)
|
|
||||||
assert len(result.data) > 0
|
|
||||||
|
|
||||||
def test_error_handling_in_synthesis(self):
|
|
||||||
"""Error during synthesis is handled correctly."""
|
|
||||||
class FailingSession:
|
|
||||||
def synthesize(self, request):
|
|
||||||
raise EngineError("Synthesis failed")
|
|
||||||
|
|
||||||
def dispose(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
session = FailingSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="test",
|
|
||||||
voice=VoiceSelection(source="builtin", key="default"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(EngineError, match="Synthesis failed"):
|
|
||||||
session.synthesize(request)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegression:
|
|
||||||
"""Regression Test: old path vs new path equivalence"""
|
|
||||||
|
|
||||||
def test_old_path_vs_new_path_same_result(self):
|
|
||||||
"""Both paths should produce equivalent results."""
|
|
||||||
# Setup mock plugin
|
|
||||||
manager = PluginManager()
|
|
||||||
mock_plugin = create_mock_plugin()
|
|
||||||
manager._plugins["mock_tts"] = mock_plugin
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# New path: Plugin Manager → Engine → Session → Synthesis
|
|
||||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
|
||||||
new_backend = create_pipeline("mock_tts")
|
|
||||||
new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
|
|
||||||
|
|
||||||
# Old path: Direct MockEngine (simulating old registry)
|
|
||||||
old_engine = MockEngine()
|
|
||||||
old_session = old_engine.createSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello world",
|
|
||||||
voice=VoiceSelection(source="builtin", key="default"),
|
|
||||||
parameters=ParameterValues(values={"speed": 1.0}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
old_result = old_session.synthesize(request)
|
|
||||||
|
|
||||||
# Compare results
|
|
||||||
# New path returns segments, old path returns SynthesizedAudio
|
|
||||||
# But both should have valid audio data
|
|
||||||
assert len(new_segments) >= 1
|
|
||||||
assert len(old_result.data) > 0
|
|
||||||
|
|
||||||
# Both should have same format
|
|
||||||
assert new_segments[0].audio.dtype == np.float32
|
|
||||||
|
|
||||||
def test_pipeline_matches_old_interface(self):
|
|
||||||
"""Pipeline utility should match old TTSBackend interface."""
|
|
||||||
manager = PluginManager()
|
|
||||||
mock_plugin = create_mock_plugin()
|
|
||||||
manager._plugins["mock_tts"] = mock_plugin
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
|
|
||||||
backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
|
|
||||||
|
|
||||||
# Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
|
|
||||||
segments = list(backend(
|
|
||||||
"Hello world",
|
|
||||||
voice="af_heart",
|
|
||||||
speed=1.0,
|
|
||||||
split_pattern=r"\n+"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Should return segments with graphemes and audio
|
|
||||||
assert len(segments) >= 1
|
|
||||||
segment = segments[0]
|
|
||||||
assert segment.graphemes == "Hello world"
|
|
||||||
assert isinstance(segment.audio, np.ndarray)
|
|
||||||
assert segment.audio.dtype == np.float32
|
|
||||||
assert len(segment.audio) > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginManagerIntegration:
|
|
||||||
"""Integration tests for PluginManager."""
|
|
||||||
|
|
||||||
def test_plugin_manager_singleton_pattern(self):
|
|
||||||
"""Global plugin manager follows singleton pattern."""
|
|
||||||
reset_plugin_manager()
|
|
||||||
|
|
||||||
manager1 = get_plugin_manager()
|
|
||||||
manager2 = get_plugin_manager()
|
|
||||||
|
|
||||||
assert manager1 is manager2
|
|
||||||
|
|
||||||
reset_plugin_manager()
|
|
||||||
|
|
||||||
manager3 = get_plugin_manager()
|
|
||||||
assert manager1 is not manager3
|
|
||||||
|
|
||||||
def test_plugin_manager_discover_plugins(self):
|
|
||||||
"""Plugin manager can discover plugins from directory."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
# Discover from test plugins directory
|
|
||||||
manager.discover("tests/plugins")
|
|
||||||
|
|
||||||
# Should find valid_plugin
|
|
||||||
# (This depends on test plugins existing)
|
|
||||||
plugins = manager.list_plugins()
|
|
||||||
assert isinstance(plugins, list)
|
|
||||||
|
|
||||||
def test_plugin_manager_dispose_all(self):
|
|
||||||
"""Plugin manager can dispose all cached engines."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
# Register mock plugin
|
|
||||||
mock_plugin = create_mock_plugin()
|
|
||||||
manager._plugins["mock_tts"] = mock_plugin
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# Create engines
|
|
||||||
engine1 = manager.get_or_create_engine("mock_tts")
|
|
||||||
engine2 = manager.get_or_create_engine("mock_tts")
|
|
||||||
|
|
||||||
# Dispose all
|
|
||||||
manager.dispose_all()
|
|
||||||
|
|
||||||
# Engines should be disposed
|
|
||||||
assert engine1._disposed is True
|
|
||||||
assert engine2._disposed is True
|
|
||||||
|
|
||||||
# Cache should be empty
|
|
||||||
assert len(manager._engines) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoCompatLayer:
|
|
||||||
"""Regression: confirm the compatibility layer has been removed."""
|
|
||||||
|
|
||||||
def test_compat_module_does_not_exist(self):
|
|
||||||
"""abogen.tts_plugin.compat must not be importable."""
|
|
||||||
import importlib
|
|
||||||
with pytest.raises((ImportError, ModuleNotFoundError)):
|
|
||||||
importlib.import_module("abogen.tts_plugin.compat")
|
|
||||||
|
|
||||||
def test_consumers_use_plugin_architecture_directly(self):
|
|
||||||
"""Key consumers import from abogen.tts_plugin.utils, not compat."""
|
|
||||||
import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
|
|
||||||
|
|
||||||
for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
|
|
||||||
source = inspect.getsource(mod)
|
|
||||||
assert "tts_plugin.compat" not in source, (
|
|
||||||
f"{mod.__name__} still references tts_plugin.compat"
|
|
||||||
)
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
"""Comprehensive tests for the plugin loader infrastructure.
|
|
||||||
|
|
||||||
These tests verify that the loader correctly:
|
|
||||||
- Discovers plugins in directories
|
|
||||||
- Imports plugin modules
|
|
||||||
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
|
|
||||||
- Validates api_version compatibility
|
|
||||||
- Validates capabilities
|
|
||||||
- Provides diagnostic messages for errors
|
|
||||||
- Rejects invalid plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.loader import (
|
|
||||||
HOST_API_VERSION,
|
|
||||||
PluginLoadError,
|
|
||||||
PluginLoadResult,
|
|
||||||
_check_api_version_compatibility,
|
|
||||||
_parse_api_version,
|
|
||||||
_validate_api_version,
|
|
||||||
_validate_capabilities,
|
|
||||||
_validate_manifest,
|
|
||||||
discover_plugins,
|
|
||||||
load_plugin,
|
|
||||||
load_plugin_from_dir,
|
|
||||||
)
|
|
||||||
from abogen.tts_plugin.manifest import (
|
|
||||||
EngineManifest,
|
|
||||||
ModelManifest,
|
|
||||||
PluginManifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Path fixtures
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def plugins_dir() -> Path:
|
|
||||||
return Path(__file__).parent.parent / "plugins"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_plugin_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "fake_plugin"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def missing_manifest_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "missing_manifest"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def invalid_api_version_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "invalid_api_version"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "invalid_capabilities"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def missing_create_engine_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "missing_create_engine"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def import_error_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "import_error"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
|
|
||||||
return plugins_dir / "missing_model_requirements"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Unit tests: _parse_api_version
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestParseApiVersion:
|
|
||||||
def test_valid_version(self) -> None:
|
|
||||||
assert _parse_api_version("1.0") == (1, 0)
|
|
||||||
assert _parse_api_version("2.5") == (2, 5)
|
|
||||||
assert _parse_api_version("10.20") == (10, 20)
|
|
||||||
|
|
||||||
def test_invalid_format(self) -> None:
|
|
||||||
assert _parse_api_version("1") is None
|
|
||||||
assert _parse_api_version("1.0.0") is None
|
|
||||||
assert _parse_api_version("abc") is None
|
|
||||||
assert _parse_api_version("") is None
|
|
||||||
assert _parse_api_version("1.x") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Unit tests: _check_api_version_compatibility
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestCheckApiVersionCompatibility:
|
|
||||||
def test_compatible_version(self) -> None:
|
|
||||||
assert _check_api_version_compatibility("1.0") is None
|
|
||||||
assert _check_api_version_compatibility("1.5") is None
|
|
||||||
|
|
||||||
def test_major_mismatch(self) -> None:
|
|
||||||
error = _check_api_version_compatibility("2.0")
|
|
||||||
assert error is not None
|
|
||||||
assert "major mismatch" in error
|
|
||||||
|
|
||||||
def test_invalid_format(self) -> None:
|
|
||||||
error = _check_api_version_compatibility("invalid")
|
|
||||||
assert error is not None
|
|
||||||
assert "Invalid api_version format" in error
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Unit tests: _validate_manifest
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestValidateManifest:
|
|
||||||
def test_valid_manifest(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
MODEL_REQUIREMENTS: list = []
|
|
||||||
create_engine = lambda *a, **kw: None
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
def test_missing_manifest(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
MODEL_REQUIREMENTS: list = []
|
|
||||||
create_engine = lambda *a, **kw: None
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
|
|
||||||
|
|
||||||
def test_wrong_manifest_type(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = "not a manifest"
|
|
||||||
MODEL_REQUIREMENTS: list = []
|
|
||||||
create_engine = lambda *a, **kw: None
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("PluginManifest instance" in e for e in errors)
|
|
||||||
|
|
||||||
def test_missing_model_requirements(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
create_engine = lambda *a, **kw: None
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
|
|
||||||
|
|
||||||
def test_wrong_model_requirements_type(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
MODEL_REQUIREMENTS = "not a list"
|
|
||||||
create_engine = lambda *a, **kw: None
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("must be a list" in e for e in errors)
|
|
||||||
|
|
||||||
def test_invalid_model_requirements_item(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
MODEL_REQUIREMENTS = ["not a model manifest"]
|
|
||||||
create_engine = lambda *a, **kw: None
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("ModelManifest instance" in e for e in errors)
|
|
||||||
|
|
||||||
def test_missing_create_engine(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
MODEL_REQUIREMENTS: list = []
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("Missing create_engine" in e for e in errors)
|
|
||||||
|
|
||||||
def test_create_engine_not_callable(self) -> None:
|
|
||||||
class FakeModule:
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
MODEL_REQUIREMENTS: list = []
|
|
||||||
create_engine = "not callable"
|
|
||||||
|
|
||||||
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
||||||
assert any("must be callable" in e for e in errors)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Unit tests: _validate_capabilities
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestValidateCapabilities:
|
|
||||||
def test_valid_capabilities(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
capabilities=("voice_list", "preview"),
|
|
||||||
)
|
|
||||||
errors = _validate_capabilities(manifest)
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
def test_unknown_capability(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
capabilities=("voice_list", "unknown_cap"),
|
|
||||||
)
|
|
||||||
errors = _validate_capabilities(manifest)
|
|
||||||
assert any("unknown_cap" in e for e in errors)
|
|
||||||
|
|
||||||
def test_empty_capabilities(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
capabilities=(),
|
|
||||||
)
|
|
||||||
errors = _validate_capabilities(manifest)
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Unit tests: _validate_api_version
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestValidateApiVersion:
|
|
||||||
def test_compatible(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="1.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
errors = _validate_api_version(manifest)
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
def test_incompatible(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test", name="Test", version="1.0.0",
|
|
||||||
api_version="2.0", description="Test", author="Test",
|
|
||||||
)
|
|
||||||
errors = _validate_api_version(manifest)
|
|
||||||
assert len(errors) > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Integration tests: load_plugin_from_dir
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestLoadPluginFromDir:
|
|
||||||
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(fake_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert result.manifest is not None
|
|
||||||
assert result.manifest.id == "fake_plugin"
|
|
||||||
assert result.model_requirements is not None
|
|
||||||
assert result.create_engine is not None
|
|
||||||
assert result.module is not None
|
|
||||||
assert result.error is None
|
|
||||||
|
|
||||||
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
|
|
||||||
from abogen.tts_plugin.engine import Engine
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
import logging
|
|
||||||
|
|
||||||
result = load_plugin_from_dir(fake_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
|
|
||||||
# Create engine using the loaded create_engine function
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path("/tmp/test"),
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
|
|
||||||
)
|
|
||||||
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_nonexistent_directory(self, tmp_path: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(tmp_path / "nonexistent")
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert "does not exist" in result.error.errors[0]
|
|
||||||
|
|
||||||
def test_missing_init_file(self, tmp_path: Path) -> None:
|
|
||||||
plugin_dir = tmp_path / "no_init"
|
|
||||||
plugin_dir.mkdir()
|
|
||||||
result = load_plugin_from_dir(plugin_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert "__init__.py" in result.error.errors[0]
|
|
||||||
|
|
||||||
def test_import_error(self, import_error_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(import_error_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert "Failed to import" in result.error.errors[0]
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Integration tests: invalid plugins
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestInvalidPlugins:
|
|
||||||
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(missing_manifest_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
|
|
||||||
|
|
||||||
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert any("major mismatch" in e for e in result.error.errors)
|
|
||||||
|
|
||||||
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(invalid_capabilities_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert any("Unknown capability" in e for e in result.error.errors)
|
|
||||||
|
|
||||||
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(missing_create_engine_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert any("Missing create_engine" in e for e in result.error.errors)
|
|
||||||
|
|
||||||
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(missing_model_requirements_dir)
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Integration tests: discover_plugins
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestDiscoverPlugins:
|
|
||||||
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
|
|
||||||
results = discover_plugins([plugins_dir])
|
|
||||||
# Should find multiple plugins (valid and invalid)
|
|
||||||
assert len(results) > 0
|
|
||||||
|
|
||||||
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
|
|
||||||
results = discover_plugins([plugins_dir])
|
|
||||||
valid = [r for r in results if r.success]
|
|
||||||
assert len(valid) >= 1
|
|
||||||
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
|
|
||||||
|
|
||||||
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
|
|
||||||
results = discover_plugins([plugins_dir])
|
|
||||||
invalid = [r for r in results if not r.success]
|
|
||||||
assert len(invalid) >= 1
|
|
||||||
|
|
||||||
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
|
|
||||||
results = discover_plugins([tmp_path / "nonexistent"])
|
|
||||||
assert results == []
|
|
||||||
|
|
||||||
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
|
|
||||||
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
|
|
||||||
assert len(results) > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Diagnostic messages tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestDiagnosticMessages:
|
|
||||||
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(missing_manifest_dir)
|
|
||||||
assert result.error is not None
|
|
||||||
assert result.error.plugin_id == "missing_manifest"
|
|
||||||
|
|
||||||
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(missing_manifest_dir)
|
|
||||||
assert result.error is not None
|
|
||||||
assert result.error.path == missing_manifest_dir
|
|
||||||
|
|
||||||
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(missing_manifest_dir)
|
|
||||||
assert result.error is not None
|
|
||||||
assert len(result.error.errors) > 0
|
|
||||||
|
|
||||||
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
|
|
||||||
# This plugin has multiple issues
|
|
||||||
result = load_plugin_from_dir(invalid_api_version_dir)
|
|
||||||
assert result.error is not None
|
|
||||||
# Should have at least the api_version error
|
|
||||||
assert len(result.error.errors) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# No partial registration tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestNoPartialRegistration:
|
|
||||||
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
|
|
||||||
"""After failed load, module should not remain in sys.modules."""
|
|
||||||
result = load_plugin_from_dir(missing_manifest_dir)
|
|
||||||
assert result.success is False
|
|
||||||
# Module should not be registered
|
|
||||||
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
|
|
||||||
assert module_name not in sys.modules
|
|
||||||
|
|
||||||
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
|
|
||||||
"""After import error, module should not remain in sys.modules."""
|
|
||||||
result = load_plugin_from_dir(import_error_dir)
|
|
||||||
assert result.success is False
|
|
||||||
module_name = f"abogen.tts_plugin._loaded.import_error"
|
|
||||||
assert module_name not in sys.modules
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""Contract tests for plugin manifest types.
|
|
||||||
|
|
||||||
These tests verify that manifest types satisfy the architectural requirements:
|
|
||||||
- All required fields are present
|
|
||||||
- api_version follows semver format
|
|
||||||
- capabilities are properly defined
|
|
||||||
- engine manifest describes the engine correctly
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.manifest import (
|
|
||||||
AudioFormatManifest,
|
|
||||||
EngineManifest,
|
|
||||||
EnumOption,
|
|
||||||
GpuRequirement,
|
|
||||||
ModelManifest,
|
|
||||||
ParameterManifest,
|
|
||||||
PluginManifest,
|
|
||||||
RequirementManifest,
|
|
||||||
VoiceManifest,
|
|
||||||
VoiceSourceManifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginManifestContract:
|
|
||||||
"""Contract tests for PluginManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test-plugin",
|
|
||||||
name="Test Plugin",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="A test plugin",
|
|
||||||
author="Test Author",
|
|
||||||
)
|
|
||||||
assert manifest.id == "test-plugin"
|
|
||||||
assert manifest.name == "Test Plugin"
|
|
||||||
assert manifest.version == "1.0.0"
|
|
||||||
assert manifest.api_version == "1.0"
|
|
||||||
assert manifest.description == "A test plugin"
|
|
||||||
assert manifest.author == "Test Author"
|
|
||||||
|
|
||||||
def test_api_version_semver_format(self) -> None:
|
|
||||||
"""Architecture spec: api_version format is semver (MAJOR.MINOR)."""
|
|
||||||
valid_versions = ["1.0", "2.1", "10.5"]
|
|
||||||
for version in valid_versions:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test",
|
|
||||||
name="Test",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version=version,
|
|
||||||
description="Test",
|
|
||||||
author="Test",
|
|
||||||
)
|
|
||||||
assert re.match(r"^\d+\.\d+$", manifest.api_version)
|
|
||||||
|
|
||||||
def test_capabilities_default_empty(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test",
|
|
||||||
name="Test",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="Test",
|
|
||||||
author="Test",
|
|
||||||
)
|
|
||||||
assert manifest.capabilities == ()
|
|
||||||
|
|
||||||
def test_capabilities_tuple(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test",
|
|
||||||
name="Test",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="Test",
|
|
||||||
author="Test",
|
|
||||||
capabilities=("voice_list", "preview"),
|
|
||||||
)
|
|
||||||
assert "voice_list" in manifest.capabilities
|
|
||||||
assert "preview" in manifest.capabilities
|
|
||||||
|
|
||||||
def test_requires_default(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test",
|
|
||||||
name="Test",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="Test",
|
|
||||||
author="Test",
|
|
||||||
)
|
|
||||||
assert isinstance(manifest.requires, RequirementManifest)
|
|
||||||
|
|
||||||
def test_engine_default(self) -> None:
|
|
||||||
manifest = PluginManifest(
|
|
||||||
id="test",
|
|
||||||
name="Test",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="Test",
|
|
||||||
author="Test",
|
|
||||||
)
|
|
||||||
assert isinstance(manifest.engine, EngineManifest)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineManifestContract:
|
|
||||||
"""Contract tests for EngineManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
manifest = EngineManifest(
|
|
||||||
voiceSources=(
|
|
||||||
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
|
|
||||||
),
|
|
||||||
parameters=(
|
|
||||||
ParameterManifest(
|
|
||||||
id="speed", name="Speed", description="Speed", type="float", default=1.0
|
|
||||||
),
|
|
||||||
),
|
|
||||||
audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
|
|
||||||
)
|
|
||||||
assert len(manifest.voiceSources) == 1
|
|
||||||
assert len(manifest.parameters) == 1
|
|
||||||
assert len(manifest.audioFormats) == 1
|
|
||||||
|
|
||||||
def test_defaults_empty(self) -> None:
|
|
||||||
manifest = EngineManifest()
|
|
||||||
assert manifest.voiceSources == ()
|
|
||||||
assert manifest.parameters == ()
|
|
||||||
assert manifest.audioFormats == ()
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceSourceManifestContract:
|
|
||||||
"""Contract tests for VoiceSourceManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
|
|
||||||
assert vs.id == "builtin"
|
|
||||||
assert vs.name == "Builtin"
|
|
||||||
assert vs.type == "list"
|
|
||||||
|
|
||||||
def test_valid_types(self) -> None:
|
|
||||||
valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
|
|
||||||
for vtype in valid_types:
|
|
||||||
vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
|
|
||||||
assert vs.type == vtype
|
|
||||||
|
|
||||||
def test_config_optional(self) -> None:
|
|
||||||
vs = VoiceSourceManifest(id="test", name="Test", type="list")
|
|
||||||
assert vs.config is None
|
|
||||||
|
|
||||||
def test_config_any(self) -> None:
|
|
||||||
config = {"voices": ["af_nova", "af_sky"]}
|
|
||||||
vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
|
|
||||||
assert vs.config == config
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceManifestContract:
|
|
||||||
"""Contract tests for VoiceManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
v = VoiceManifest(id="af_nova", name="Nova")
|
|
||||||
assert v.id == "af_nova"
|
|
||||||
assert v.name == "Nova"
|
|
||||||
|
|
||||||
def test_tags_default_empty(self) -> None:
|
|
||||||
v = VoiceManifest(id="af_nova", name="Nova")
|
|
||||||
assert v.tags == ()
|
|
||||||
|
|
||||||
def test_tags_tuple(self) -> None:
|
|
||||||
v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
|
|
||||||
assert "en" in v.tags
|
|
||||||
assert "female" in v.tags
|
|
||||||
|
|
||||||
|
|
||||||
class TestParameterManifestContract:
|
|
||||||
"""Contract tests for ParameterManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
p = ParameterManifest(
|
|
||||||
id="speed", name="Speed", description="Speech speed", type="float", default=1.0
|
|
||||||
)
|
|
||||||
assert p.id == "speed"
|
|
||||||
assert p.name == "Speed"
|
|
||||||
assert p.description == "Speech speed"
|
|
||||||
assert p.type == "float"
|
|
||||||
assert p.default == 1.0
|
|
||||||
|
|
||||||
def test_valid_types(self) -> None:
|
|
||||||
valid_types = ["float", "int", "string", "boolean", "enum"]
|
|
||||||
for ptype in valid_types:
|
|
||||||
p = ParameterManifest(
|
|
||||||
id="test", name="Test", description="Test", type=ptype, default=None
|
|
||||||
)
|
|
||||||
assert p.type == ptype
|
|
||||||
|
|
||||||
def test_optional_numeric_bounds(self) -> None:
|
|
||||||
p = ParameterManifest(
|
|
||||||
id="speed",
|
|
||||||
name="Speed",
|
|
||||||
description="Speed",
|
|
||||||
type="float",
|
|
||||||
default=1.0,
|
|
||||||
min=0.5,
|
|
||||||
max=2.0,
|
|
||||||
step=0.1,
|
|
||||||
)
|
|
||||||
assert p.min == 0.5
|
|
||||||
assert p.max == 2.0
|
|
||||||
assert p.step == 0.1
|
|
||||||
|
|
||||||
def test_enum_options(self) -> None:
|
|
||||||
options = (
|
|
||||||
EnumOption(value="low", label="Low"),
|
|
||||||
EnumOption(value="high", label="High"),
|
|
||||||
)
|
|
||||||
p = ParameterManifest(
|
|
||||||
id="quality",
|
|
||||||
name="Quality",
|
|
||||||
description="Quality",
|
|
||||||
type="enum",
|
|
||||||
default="low",
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
assert len(p.options) == 2
|
|
||||||
assert p.options[0].value == "low"
|
|
||||||
|
|
||||||
|
|
||||||
class TestAudioFormatManifestContract:
|
|
||||||
"""Contract tests for AudioFormatManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
af = AudioFormatManifest(mime="audio/wav", extension="wav")
|
|
||||||
assert af.mime == "audio/wav"
|
|
||||||
assert af.extension == "wav"
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnumOptionContract:
|
|
||||||
"""Contract tests for EnumOption."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
opt = EnumOption(value="low", label="Low Quality")
|
|
||||||
assert opt.value == "low"
|
|
||||||
assert opt.label == "Low Quality"
|
|
||||||
|
|
||||||
|
|
||||||
class TestRequirementManifestContract:
|
|
||||||
"""Contract tests for RequirementManifest."""
|
|
||||||
|
|
||||||
def test_defaults(self) -> None:
|
|
||||||
req = RequirementManifest()
|
|
||||||
assert req.gpu is None
|
|
||||||
assert req.memory is None
|
|
||||||
assert req.internet is None
|
|
||||||
|
|
||||||
def test_with_gpu(self) -> None:
|
|
||||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
|
||||||
req = RequirementManifest(gpu=gpu)
|
|
||||||
assert req.gpu.required is True
|
|
||||||
assert req.gpu.type == "cuda"
|
|
||||||
assert req.gpu.memory == 8.0
|
|
||||||
|
|
||||||
def test_with_internet(self) -> None:
|
|
||||||
req = RequirementManifest(internet=True)
|
|
||||||
assert req.internet is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestGpuRequirementContract:
|
|
||||||
"""Contract tests for GpuRequirement."""
|
|
||||||
|
|
||||||
def test_defaults(self) -> None:
|
|
||||||
gpu = GpuRequirement()
|
|
||||||
assert gpu.required is False
|
|
||||||
assert gpu.type is None
|
|
||||||
assert gpu.memory is None
|
|
||||||
|
|
||||||
def test_required_gpu(self) -> None:
|
|
||||||
gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
|
|
||||||
assert gpu.required is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestModelManifestContract:
|
|
||||||
"""Contract tests for ModelManifest."""
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
|
|
||||||
assert m.id == "xtts_v2"
|
|
||||||
assert m.name == "XTTS v2"
|
|
||||||
assert m.size == "2GB"
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Contract tests for plugin contract.
|
|
||||||
|
|
||||||
These tests verify that plugin modules satisfy the architectural requirements:
|
|
||||||
- Must export PLUGIN_MANIFEST: PluginManifest
|
|
||||||
- Must export MODEL_REQUIREMENTS: list[ModelManifest]
|
|
||||||
- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
|
||||||
- create_engine() must be atomic
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
|
|
||||||
from abogen.tts_plugin.plugin import Plugin
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
from .conftest import FakeEngine
|
|
||||||
|
|
||||||
|
|
||||||
class FakePluginModule:
|
|
||||||
"""Stub plugin module that satisfies the plugin contract."""
|
|
||||||
|
|
||||||
PLUGIN_MANIFEST = PluginManifest(
|
|
||||||
id="fake-plugin",
|
|
||||||
name="Fake Plugin",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="A fake plugin for testing",
|
|
||||||
author="Test Author",
|
|
||||||
capabilities=(),
|
|
||||||
engine=EngineManifest(),
|
|
||||||
)
|
|
||||||
|
|
||||||
MODEL_REQUIREMENTS: list[ModelManifest] = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_engine(
|
|
||||||
context: HostContext,
|
|
||||||
model_path: Path | None,
|
|
||||||
config: EngineConfig,
|
|
||||||
) -> Engine:
|
|
||||||
return FakeEngine()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginProtocolContract:
|
|
||||||
"""Contract tests for the Plugin protocol."""
|
|
||||||
|
|
||||||
def test_plugin_is_protocol(self) -> None:
|
|
||||||
assert hasattr(Plugin, "__protocol_attrs__")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginExportsContract:
|
|
||||||
"""Contract tests for required plugin exports."""
|
|
||||||
|
|
||||||
def test_plugin_has_plugin_manifest(self) -> None:
|
|
||||||
"""Architecture spec: Plugin must export PLUGIN_MANIFEST."""
|
|
||||||
assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
|
|
||||||
assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
|
|
||||||
|
|
||||||
def test_plugin_has_model_requirements(self) -> None:
|
|
||||||
"""Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
|
|
||||||
assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
|
|
||||||
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
|
|
||||||
|
|
||||||
def test_plugin_has_create_engine(self) -> None:
|
|
||||||
"""Architecture spec: Plugin must export create_engine."""
|
|
||||||
assert hasattr(FakePluginModule, "create_engine")
|
|
||||||
assert callable(FakePluginModule.create_engine)
|
|
||||||
|
|
||||||
def test_plugin_manifest_required_fields(self) -> None:
|
|
||||||
"""Architecture spec: PluginManifest has required fields."""
|
|
||||||
manifest = FakePluginModule.PLUGIN_MANIFEST
|
|
||||||
assert manifest.id
|
|
||||||
assert manifest.name
|
|
||||||
assert manifest.version
|
|
||||||
assert manifest.api_version
|
|
||||||
assert manifest.description
|
|
||||||
assert manifest.author
|
|
||||||
|
|
||||||
def test_plugin_manifest_capabilities_is_tuple(self) -> None:
|
|
||||||
manifest = FakePluginModule.PLUGIN_MANIFEST
|
|
||||||
assert isinstance(manifest.capabilities, tuple)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateEngineContract:
|
|
||||||
"""Contract tests for create_engine() function."""
|
|
||||||
|
|
||||||
def test_create_engine_returns_engine(self) -> None:
|
|
||||||
"""Architecture spec: create_engine() returns Engine."""
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path("/tmp/test"),
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
|
||||||
)
|
|
||||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
|
|
||||||
def test_create_engine_atomic(self) -> None:
|
|
||||||
"""Architecture spec: create_engine() is atomic (all-or-nothing)."""
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path("/tmp/test"),
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
|
||||||
)
|
|
||||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_create_engine_with_none_model_path(self) -> None:
|
|
||||||
"""Architecture spec: model_path can be None for cloud/no-model engines."""
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path("/tmp/test"),
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
|
||||||
)
|
|
||||||
engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_create_engine_with_model_path(self) -> None:
|
|
||||||
"""Architecture spec: model_path is Path | None."""
|
|
||||||
ctx = HostContext(
|
|
||||||
config_dir=Path("/tmp/test"),
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
|
|
||||||
)
|
|
||||||
engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestModelRequirementsContract:
|
|
||||||
"""Contract tests for MODEL_REQUIREMENTS."""
|
|
||||||
|
|
||||||
def test_model_requirements_is_list(self) -> None:
|
|
||||||
assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
|
|
||||||
|
|
||||||
def test_model_requirements_contains_model_manifests(self) -> None:
|
|
||||||
"""If non-empty, each item must be a ModelManifest."""
|
|
||||||
for req in FakePluginModule.MODEL_REQUIREMENTS:
|
|
||||||
assert isinstance(req, ModelManifest)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
"""Integration tests for Plugin Manager and direct utility functions."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
|
|
||||||
from abogen.tts_plugin.utils import Pipeline, create_pipeline
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
|
|
||||||
|
|
||||||
|
|
||||||
class FakeEngine:
|
|
||||||
"""Fake Engine for testing."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.kwargs = kwargs
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def createSession(self):
|
|
||||||
return FakeEngineSession()
|
|
||||||
|
|
||||||
def dispose(self):
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def manifest(self):
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
|
|
||||||
class FakeEngineSession:
|
|
||||||
"""Fake EngineSession for testing."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._disposed = False
|
|
||||||
|
|
||||||
def synthesize(self, request):
|
|
||||||
# Return fake audio
|
|
||||||
import numpy as np
|
|
||||||
from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
|
|
||||||
|
|
||||||
audio = np.zeros(1000, dtype=np.float32)
|
|
||||||
return SynthesizedAudio(
|
|
||||||
data=audio.tobytes(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
|
|
||||||
)
|
|
||||||
|
|
||||||
def dispose(self):
|
|
||||||
self._disposed = True
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginManager:
|
|
||||||
"""Test PluginManager functionality."""
|
|
||||||
|
|
||||||
def test_plugin_manager_creation(self):
|
|
||||||
"""PluginManager can be created."""
|
|
||||||
manager = PluginManager()
|
|
||||||
assert manager is not None
|
|
||||||
|
|
||||||
def test_plugin_manager_list_discovers_plugins(self):
|
|
||||||
"""PluginManager discovers plugins from plugins directory."""
|
|
||||||
manager = PluginManager()
|
|
||||||
manager.discover("plugins")
|
|
||||||
plugins = manager.list_plugins()
|
|
||||||
# Should discover kokoro plugin if it exists
|
|
||||||
assert isinstance(plugins, list)
|
|
||||||
|
|
||||||
def test_plugin_manager_has_plugin_after_discover(self):
|
|
||||||
"""PluginManager reports plugins after discovery."""
|
|
||||||
manager = PluginManager()
|
|
||||||
manager.discover("plugins")
|
|
||||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
|
||||||
# This is expected behavior
|
|
||||||
assert isinstance(manager._plugins, dict)
|
|
||||||
|
|
||||||
def test_plugin_manager_get_plugin_after_discover(self):
|
|
||||||
"""PluginManager returns plugin info after discovery."""
|
|
||||||
manager = PluginManager()
|
|
||||||
manager.discover("plugins")
|
|
||||||
# kokoro plugin should be discovered if plugins/kokoro exists
|
|
||||||
assert isinstance(manager._plugins, dict)
|
|
||||||
|
|
||||||
def test_plugin_manager_create_engine_not_found(self):
|
|
||||||
"""PluginManager raises KeyError for unknown plugins."""
|
|
||||||
manager = PluginManager()
|
|
||||||
with pytest.raises(KeyError, match="Plugin not found"):
|
|
||||||
manager.create_engine("nonexistent")
|
|
||||||
|
|
||||||
def test_plugin_manager_discover_with_empty_dir(self):
|
|
||||||
"""PluginManager handles missing plugins directory."""
|
|
||||||
manager = PluginManager()
|
|
||||||
manager.discover("/nonexistent/path")
|
|
||||||
plugins = manager.list_plugins()
|
|
||||||
assert plugins == []
|
|
||||||
|
|
||||||
def test_global_plugin_manager_singleton(self):
|
|
||||||
"""Global PluginManager is a singleton."""
|
|
||||||
reset_plugin_manager()
|
|
||||||
manager1 = get_plugin_manager()
|
|
||||||
manager2 = get_plugin_manager()
|
|
||||||
assert manager1 is manager2
|
|
||||||
reset_plugin_manager()
|
|
||||||
|
|
||||||
def test_reset_plugin_manager(self):
|
|
||||||
"""reset_plugin_manager clears the singleton."""
|
|
||||||
manager1 = get_plugin_manager()
|
|
||||||
reset_plugin_manager()
|
|
||||||
manager2 = get_plugin_manager()
|
|
||||||
assert manager1 is not manager2
|
|
||||||
reset_plugin_manager()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPipeline:
|
|
||||||
"""Test Pipeline functionality."""
|
|
||||||
|
|
||||||
def test_pipeline_creation(self):
|
|
||||||
"""Pipeline can be created."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
backend = Pipeline(engine)
|
|
||||||
assert backend is not None
|
|
||||||
|
|
||||||
def test_pipeline_callable(self):
|
|
||||||
"""Pipeline is callable like old TTSBackend."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
backend = Pipeline(engine)
|
|
||||||
|
|
||||||
# Should be callable
|
|
||||||
assert callable(backend)
|
|
||||||
|
|
||||||
def test_pipeline_synthesize(self):
|
|
||||||
"""Pipeline can synthesize text."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
backend = Pipeline(engine)
|
|
||||||
|
|
||||||
# Call the backend
|
|
||||||
segments = list(backend("Hello world", voice="default", speed=1.0))
|
|
||||||
|
|
||||||
# Should return at least one segment
|
|
||||||
assert len(segments) >= 1
|
|
||||||
|
|
||||||
# Segment should have graphemes and audio
|
|
||||||
segment = segments[0]
|
|
||||||
assert hasattr(segment, "graphemes")
|
|
||||||
assert hasattr(segment, "audio")
|
|
||||||
assert segment.graphemes == "Hello world"
|
|
||||||
|
|
||||||
def test_pipeline_dispose(self):
|
|
||||||
"""Pipeline can be disposed."""
|
|
||||||
engine = FakeEngine()
|
|
||||||
backend = Pipeline(engine)
|
|
||||||
|
|
||||||
# Create a session by calling
|
|
||||||
list(backend("test"))
|
|
||||||
|
|
||||||
# Dispose should not raise
|
|
||||||
backend.dispose()
|
|
||||||
|
|
||||||
# Double dispose should be safe
|
|
||||||
backend.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreatePipelineCompat:
|
|
||||||
"""Test create_pipeline utility function."""
|
|
||||||
|
|
||||||
def test_create_pipeline_returns_callable(self):
|
|
||||||
"""create_pipeline returns a callable backend."""
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
|
||||||
|
|
||||||
# Mock the plugin manager
|
|
||||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
|
||||||
mock_manager = MagicMock()
|
|
||||||
mock_get_manager.return_value = mock_manager
|
|
||||||
|
|
||||||
mock_engine = FakeEngine()
|
|
||||||
mock_manager.create_engine.return_value = mock_engine
|
|
||||||
|
|
||||||
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
|
|
||||||
|
|
||||||
assert callable(backend)
|
|
||||||
mock_manager.create_engine.assert_called_once()
|
|
||||||
call_args = mock_manager.create_engine.call_args
|
|
||||||
assert call_args.args[0] == "kokoro"
|
|
||||||
assert isinstance(call_args.kwargs["context"], HostContext)
|
|
||||||
assert call_args.kwargs["model_path"] is None
|
|
||||||
assert isinstance(call_args.kwargs["config"], EngineConfig)
|
|
||||||
assert call_args.kwargs["config"].device == "cpu"
|
|
||||||
assert call_args.kwargs["config"].lang_code == "a"
|
|
||||||
|
|
||||||
def test_create_pipeline_raises_for_unknown_plugin(self):
|
|
||||||
"""create_pipeline raises KeyError for unknown plugins."""
|
|
||||||
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
|
|
||||||
mock_manager = MagicMock()
|
|
||||||
mock_get_manager.return_value = mock_manager
|
|
||||||
mock_manager.create_engine.side_effect = KeyError("Plugin not found")
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
create_pipeline("nonexistent")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginManagerWithFakePlugins:
|
|
||||||
"""Test PluginManager with fake plugin loading."""
|
|
||||||
|
|
||||||
def test_plugin_manager_create_engine_from_plugin(self):
|
|
||||||
"""PluginManager creates engine from loaded plugin."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
# Manually add a fake plugin
|
|
||||||
def fake_create_engine(**kwargs):
|
|
||||||
return FakeEngine(**kwargs)
|
|
||||||
|
|
||||||
manager._plugins["fake"] = {
|
|
||||||
"manifest": MagicMock(),
|
|
||||||
"create_engine": fake_create_engine,
|
|
||||||
}
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# Create engine
|
|
||||||
engine = manager.create_engine("fake", param="value")
|
|
||||||
|
|
||||||
assert isinstance(engine, FakeEngine)
|
|
||||||
assert engine.kwargs == {"param": "value"}
|
|
||||||
|
|
||||||
def test_plugin_manager_get_or_create_engine(self):
|
|
||||||
"""PluginManager caches engines."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def fake_create_engine(**kwargs):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
return FakeEngine(**kwargs)
|
|
||||||
|
|
||||||
manager._plugins["fake"] = {
|
|
||||||
"manifest": MagicMock(),
|
|
||||||
"create_engine": fake_create_engine,
|
|
||||||
}
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# Get engine twice
|
|
||||||
engine1 = manager.get_or_create_engine("fake")
|
|
||||||
engine2 = manager.get_or_create_engine("fake")
|
|
||||||
|
|
||||||
# Should be same instance
|
|
||||||
assert engine1 is engine2
|
|
||||||
assert call_count == 1
|
|
||||||
|
|
||||||
def test_plugin_manager_dispose_all(self):
|
|
||||||
"""PluginManager disposes all cached engines."""
|
|
||||||
manager = PluginManager()
|
|
||||||
|
|
||||||
def fake_create_engine(**kwargs):
|
|
||||||
return FakeEngine(**kwargs)
|
|
||||||
|
|
||||||
manager._plugins["fake"] = {
|
|
||||||
"manifest": MagicMock(),
|
|
||||||
"create_engine": fake_create_engine,
|
|
||||||
}
|
|
||||||
manager._loaded = True
|
|
||||||
|
|
||||||
# Create and cache engines
|
|
||||||
engine1 = manager.get_or_create_engine("fake")
|
|
||||||
engine2 = manager.get_or_create_engine("fake")
|
|
||||||
|
|
||||||
# Dispose all
|
|
||||||
manager.dispose_all()
|
|
||||||
|
|
||||||
# Engines should be disposed
|
|
||||||
assert engine1._disposed is True
|
|
||||||
assert engine2._disposed is True
|
|
||||||
|
|
||||||
# Cache should be empty
|
|
||||||
assert len(manager._engines) == 0
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"""Contract tests for EngineSession protocol.
|
|
||||||
|
|
||||||
These tests verify that EngineSession implementations satisfy the architectural requirements:
|
|
||||||
- synthesize() returns SynthesizedAudio
|
|
||||||
- dispose() is idempotent
|
|
||||||
- After dispose(), synthesize() raises EngineError
|
|
||||||
- Session remains usable after synthesize() failure
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .conftest import FakeEngineSession
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineSessionProtocolContract:
|
|
||||||
"""Contract tests for the EngineSession protocol itself."""
|
|
||||||
|
|
||||||
def test_engine_session_is_protocol(self) -> None:
|
|
||||||
assert hasattr(EngineSession, "__protocol_attrs__")
|
|
||||||
|
|
||||||
def test_fake_session_satisfies_protocol(self) -> None:
|
|
||||||
session = FakeEngineSession()
|
|
||||||
assert isinstance(session, EngineSession)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionSynthesizeContract:
|
|
||||||
"""Contract tests for EngineSession.synthesize()."""
|
|
||||||
|
|
||||||
def test_synthesize_returns_synthesized_audio(self) -> None:
|
|
||||||
session = FakeEngineSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert isinstance(result, SynthesizedAudio)
|
|
||||||
|
|
||||||
def test_synthesize_returns_valid_audio_data(self) -> None:
|
|
||||||
session = FakeEngineSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert isinstance(result.data, bytes)
|
|
||||||
assert len(result.data) > 0
|
|
||||||
assert isinstance(result.format, AudioFormat)
|
|
||||||
assert isinstance(result.duration, Duration)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionDisposeContract:
|
|
||||||
"""Contract tests for EngineSession.dispose()."""
|
|
||||||
|
|
||||||
def test_dispose_is_idempotent(self) -> None:
|
|
||||||
"""Architecture spec: dispose() is idempotent."""
|
|
||||||
session = FakeEngineSession()
|
|
||||||
session.dispose()
|
|
||||||
session.dispose() # Should not raise
|
|
||||||
|
|
||||||
def test_dispose_never_raises(self) -> None:
|
|
||||||
"""Architecture spec: dispose() never raises exceptions."""
|
|
||||||
session = FakeEngineSession()
|
|
||||||
session.dispose() # Should not raise
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionAfterDisposeContract:
|
|
||||||
"""Contract tests for behavior after dispose()."""
|
|
||||||
|
|
||||||
def test_synthesize_after_dispose_raises(self) -> None:
|
|
||||||
"""Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
|
|
||||||
session = FakeEngineSession()
|
|
||||||
session.dispose()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
session.synthesize(request)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionLifecycleContract:
|
|
||||||
"""Contract tests for EngineSession lifecycle."""
|
|
||||||
|
|
||||||
def test_full_lifecycle(self) -> None:
|
|
||||||
"""Test complete session lifecycle: create -> synthesize -> dispose."""
|
|
||||||
session = FakeEngineSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Synthesize
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert isinstance(result, SynthesizedAudio)
|
|
||||||
|
|
||||||
# Dispose
|
|
||||||
session.dispose()
|
|
||||||
|
|
||||||
def test_multiple_synthesize_before_dispose(self) -> None:
|
|
||||||
"""Architecture spec: Session remains usable after synthesize() failure."""
|
|
||||||
session = FakeEngineSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Multiple synthesize calls
|
|
||||||
result1 = session.synthesize(request)
|
|
||||||
result2 = session.synthesize(request)
|
|
||||||
assert isinstance(result1, SynthesizedAudio)
|
|
||||||
assert isinstance(result2, SynthesizedAudio)
|
|
||||||
|
|
||||||
# Dispose
|
|
||||||
session.dispose()
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
"""Contract tests for core domain value objects.
|
|
||||||
|
|
||||||
These tests verify that value objects satisfy the architectural requirements:
|
|
||||||
- Frozen (immutable) dataclasses
|
|
||||||
- Correct field definitions
|
|
||||||
- Proper equality behavior
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
Duration,
|
|
||||||
EngineConfig,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
SynthesizedAudio,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAudioFormatContract:
|
|
||||||
"""Contract tests for AudioFormat value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(AudioFormat, "__dataclass_params__")
|
|
||||||
assert AudioFormat.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
assert af.mime == "audio/wav"
|
|
||||||
assert af.extension == "wav"
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
af.mime = "audio/mpeg" # type: ignore[misc]
|
|
||||||
|
|
||||||
def test_equality(self) -> None:
|
|
||||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
af2 = AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
assert af1 == af2
|
|
||||||
|
|
||||||
def test_inequality(self) -> None:
|
|
||||||
af1 = AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
|
|
||||||
assert af1 != af2
|
|
||||||
|
|
||||||
def test_hashable(self) -> None:
|
|
||||||
af = AudioFormat(mime="audio/wav", extension="wav")
|
|
||||||
assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestDurationContract:
|
|
||||||
"""Contract tests for Duration value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(Duration, "__dataclass_params__")
|
|
||||||
assert Duration.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
d = Duration(seconds=1.5)
|
|
||||||
assert d.seconds == 1.5
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
d = Duration(seconds=1.0)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
d.seconds = 2.0 # type: ignore[misc]
|
|
||||||
|
|
||||||
def test_equality(self) -> None:
|
|
||||||
d1 = Duration(seconds=1.0)
|
|
||||||
d2 = Duration(seconds=1.0)
|
|
||||||
assert d1 == d2
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceSelectionContract:
|
|
||||||
"""Contract tests for VoiceSelection value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(VoiceSelection, "__dataclass_params__")
|
|
||||||
assert VoiceSelection.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
|
||||||
assert vs.source == "builtin"
|
|
||||||
assert vs.key == "af_nova"
|
|
||||||
|
|
||||||
def test_payload_default_none(self) -> None:
|
|
||||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
|
||||||
assert vs.payload is None
|
|
||||||
|
|
||||||
def test_payload_optional(self) -> None:
|
|
||||||
vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
|
|
||||||
assert vs.payload == b"audio_data"
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
vs = VoiceSelection(source="builtin", key="af_nova")
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
vs.source = "other" # type: ignore[misc]
|
|
||||||
|
|
||||||
|
|
||||||
class TestParameterValuesContract:
|
|
||||||
"""Contract tests for ParameterValues value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(ParameterValues, "__dataclass_params__")
|
|
||||||
assert ParameterValues.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_default_empty(self) -> None:
|
|
||||||
pv = ParameterValues()
|
|
||||||
assert pv.values == {}
|
|
||||||
|
|
||||||
def test_with_values(self) -> None:
|
|
||||||
pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
|
|
||||||
assert pv.values["speed"] == 1.0
|
|
||||||
assert pv.values["pitch"] == 0.5
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
pv = ParameterValues(values={"speed": 1.0})
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
pv.values = {} # type: ignore[misc]
|
|
||||||
|
|
||||||
|
|
||||||
class TestSynthesisRequestContract:
|
|
||||||
"""Contract tests for SynthesisRequest value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(SynthesisRequest, "__dataclass_params__")
|
|
||||||
assert SynthesisRequest.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
req = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
assert req.text == "Hello"
|
|
||||||
assert req.voice.source == "builtin"
|
|
||||||
assert req.format.mime == "audio/wav"
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
req = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
|
||||||
parameters=ParameterValues(),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
req.text = "World" # type: ignore[misc]
|
|
||||||
|
|
||||||
|
|
||||||
class TestSynthesizedAudioContract:
|
|
||||||
"""Contract tests for SynthesizedAudio value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(SynthesizedAudio, "__dataclass_params__")
|
|
||||||
assert SynthesizedAudio.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_required_fields(self) -> None:
|
|
||||||
audio = SynthesizedAudio(
|
|
||||||
data=b"\x00" * 100,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=1.0),
|
|
||||||
)
|
|
||||||
assert audio.data == b"\x00" * 100
|
|
||||||
assert audio.format.mime == "audio/wav"
|
|
||||||
assert audio.duration.seconds == 1.0
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
audio = SynthesizedAudio(
|
|
||||||
data=b"\x00" * 100,
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
duration=Duration(seconds=1.0),
|
|
||||||
)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
audio.data = b"\x00" # type: ignore[misc]
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngineConfigContract:
|
|
||||||
"""Contract tests for EngineConfig value object."""
|
|
||||||
|
|
||||||
def test_is_frozen_dataclass(self) -> None:
|
|
||||||
assert hasattr(EngineConfig, "__dataclass_params__")
|
|
||||||
assert EngineConfig.__dataclass_params__.frozen is True
|
|
||||||
|
|
||||||
def test_default_device(self) -> None:
|
|
||||||
config = EngineConfig()
|
|
||||||
assert config.device == "cpu"
|
|
||||||
|
|
||||||
def test_custom_device(self) -> None:
|
|
||||||
config = EngineConfig(device="cuda:0")
|
|
||||||
assert config.device == "cuda:0"
|
|
||||||
|
|
||||||
def test_default_lang_code(self) -> None:
|
|
||||||
config = EngineConfig()
|
|
||||||
assert config.lang_code == "a"
|
|
||||||
|
|
||||||
def test_custom_lang_code(self) -> None:
|
|
||||||
config = EngineConfig(lang_code="j")
|
|
||||||
assert config.lang_code == "j"
|
|
||||||
|
|
||||||
def test_immutability(self) -> None:
|
|
||||||
config = EngineConfig()
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
config.device = "cuda:0" # type: ignore[misc]
|
|
||||||
|
|
||||||
def test_immutability_lang_code(self) -> None:
|
|
||||||
config = EngineConfig()
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
config.lang_code = "j" # type: ignore[misc]
|
|
||||||
|
|
||||||
def test_unknown_keys_ignored_per_spec(self) -> None:
|
|
||||||
"""Architecture spec: Unknown keys are ignored (no error).
|
|
||||||
|
|
||||||
EngineConfig is frozen, so unknown keys cannot be set after creation.
|
|
||||||
This test verifies the default behavior matches the spec.
|
|
||||||
"""
|
|
||||||
config = EngineConfig()
|
|
||||||
assert config.device == "cpu"
|
|
||||||
|
|
||||||
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
|
|
||||||
"""Architecture Amendment #1: Plugins ignore unsupported fields.
|
|
||||||
|
|
||||||
EngineConfig may contain fields that are not relevant to every plugin.
|
|
||||||
Plugins MUST ignore fields they do not need, not raise on them.
|
|
||||||
"""
|
|
||||||
config = EngineConfig(device="cuda:0", lang_code="j")
|
|
||||||
assert config.device == "cuda:0"
|
|
||||||
assert config.lang_code == "j"
|
|
||||||
# A plugin that only needs device simply reads config.device
|
|
||||||
# and ignores config.lang_code — this must not raise.
|
|
||||||
|
|
||||||
def test_engine_config_contains_engine_instance_configuration(self) -> None:
|
|
||||||
"""Architecture Amendment #1: EngineConfig definition.
|
|
||||||
|
|
||||||
EngineConfig contains parameters that define how a particular
|
|
||||||
Engine instance is created and that remain constant throughout
|
|
||||||
the lifetime of that Engine.
|
|
||||||
"""
|
|
||||||
config = EngineConfig(device="cpu", lang_code="a")
|
|
||||||
# Both fields are init-time, immutable, engine-scoped.
|
|
||||||
assert config.device == "cpu"
|
|
||||||
assert config.lang_code == "a"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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).
|
|
||||||
"""
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Invalid plugin: missing MODEL_REQUIREMENTS."""
|
|
||||||
|
|
||||||
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="missing_model_requirements",
|
|
||||||
name="Missing Model Requirements Plugin",
|
|
||||||
version="1.0.0",
|
|
||||||
api_version="1.0",
|
|
||||||
description="Plugin missing MODEL_REQUIREMENTS",
|
|
||||||
author="Test Author",
|
|
||||||
)
|
|
||||||
|
|
||||||
# This plugin intentionally does NOT export MODEL_REQUIREMENTS
|
|
||||||
|
|
||||||
|
|
||||||
def create_engine(
|
|
||||||
context: Any,
|
|
||||||
model_path: Path | None,
|
|
||||||
config: EngineConfig,
|
|
||||||
) -> Any:
|
|
||||||
raise NotImplementedError("This plugin is invalid")
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""Auto-discovery tests for all TTS plugins.
|
|
||||||
|
|
||||||
These tests automatically run for every plugin in the plugins/ directory.
|
|
||||||
Tests are grouped into three categories:
|
|
||||||
|
|
||||||
1. TestAllPluginsManifest - Validates manifest structure
|
|
||||||
- Required fields (id, name, version, api_version, etc.)
|
|
||||||
- API version format (semver MAJOR.MINOR)
|
|
||||||
- Voices field (optional)
|
|
||||||
|
|
||||||
2. TestAllPluginsEngine - Validates engine lifecycle
|
|
||||||
- create_engine returns valid Engine
|
|
||||||
- dispose is idempotent
|
|
||||||
- dispose → createSession raises EngineError
|
|
||||||
|
|
||||||
3. TestAllPluginsCapabilities - Validates capability implementation
|
|
||||||
- Declared capabilities are implemented
|
|
||||||
- voice_list → VoiceLister interface
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
from abogen.tts_plugin.manifest import PluginManifest
|
|
||||||
from abogen.tts_plugin.types import EngineConfig, VoiceSelection, AudioFormat, ParameterValues, SynthesisRequest
|
|
||||||
|
|
||||||
|
|
||||||
class TestAllPluginsManifest:
|
|
||||||
"""Test that all plugins have valid manifest structure."""
|
|
||||||
|
|
||||||
def test_manifest_has_required_fields(self, loaded_plugin):
|
|
||||||
"""Verify manifest has all required fields."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
assert manifest is not None
|
|
||||||
|
|
||||||
# Required fields
|
|
||||||
assert manifest.id, "Plugin id must not be empty"
|
|
||||||
assert manifest.name, "Plugin name must not be empty"
|
|
||||||
assert manifest.version, "Plugin version must not be empty"
|
|
||||||
assert manifest.api_version, "Plugin api_version must not be empty"
|
|
||||||
assert manifest.description, "Plugin description must not be empty"
|
|
||||||
assert manifest.author, "Plugin author must not be empty"
|
|
||||||
|
|
||||||
def test_manifest_id_matches_directory(self, loaded_plugin, plugin_dir):
|
|
||||||
"""Verify manifest id matches the plugin directory name."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
assert manifest.id == plugin_dir.name, \
|
|
||||||
f"Manifest id '{manifest.id}' must match directory name '{plugin_dir.name}'"
|
|
||||||
|
|
||||||
def test_api_version_format(self, loaded_plugin):
|
|
||||||
"""Verify api_version follows semver format (MAJOR.MINOR)."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
api_version = manifest.api_version
|
|
||||||
|
|
||||||
# Must match MAJOR.MINOR format
|
|
||||||
pattern = r"^\d+\.\d+$"
|
|
||||||
assert re.match(pattern, api_version), \
|
|
||||||
f"api_version '{api_version}' must be in format MAJOR.MINOR (e.g., 1.0)"
|
|
||||||
|
|
||||||
def test_api_version_compatibility(self, loaded_plugin):
|
|
||||||
"""Verify api_version major version matches host API version."""
|
|
||||||
from abogen.tts_plugin.loader import HOST_API_VERSION
|
|
||||||
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
plugin_ver = manifest.api_version
|
|
||||||
host_ver = HOST_API_VERSION
|
|
||||||
|
|
||||||
# Extract major versions
|
|
||||||
plugin_major = int(plugin_ver.split(".")[0])
|
|
||||||
host_major = int(host_ver.split(".")[0])
|
|
||||||
|
|
||||||
assert plugin_major == host_major, \
|
|
||||||
f"API version major mismatch: plugin={plugin_major}, host={host_major}"
|
|
||||||
|
|
||||||
def test_voices_field_is_optional(self, loaded_plugin):
|
|
||||||
"""Verify voices field is optional (can be None or tuple)."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
voices = manifest.voices
|
|
||||||
|
|
||||||
# voices can be None (not declared) or tuple (empty or with voices)
|
|
||||||
assert voices is None or isinstance(voices, tuple), \
|
|
||||||
f"voices must be None or tuple, got {type(voices).__name__}"
|
|
||||||
|
|
||||||
def test_capabilities_is_tuple(self, loaded_plugin):
|
|
||||||
"""Verify capabilities is a tuple."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
assert isinstance(manifest.capabilities, tuple), \
|
|
||||||
f"capabilities must be a tuple, got {type(manifest.capabilities).__name__}"
|
|
||||||
|
|
||||||
def test_engine_manifest_exists(self, loaded_plugin):
|
|
||||||
"""Verify engine manifest exists and has required fields."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
engine_manifest = manifest.engine
|
|
||||||
|
|
||||||
assert engine_manifest is not None
|
|
||||||
assert isinstance(engine_manifest.voiceSources, tuple)
|
|
||||||
assert isinstance(engine_manifest.parameters, tuple)
|
|
||||||
assert isinstance(engine_manifest.audioFormats, tuple)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAllPluginsEngine:
|
|
||||||
"""Test that all plugins satisfy the Engine contract."""
|
|
||||||
|
|
||||||
def test_create_engine_returns_engine(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify create_engine returns an Engine instance."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
assert isinstance(engine, Engine), \
|
|
||||||
f"create_engine must return Engine instance, got {type(engine).__name__}"
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify dispose can be called multiple times without error."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
|
|
||||||
# First dispose
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
# Second dispose should not raise
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_create_session_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify createSession raises EngineError after dispose."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
engine.createSession()
|
|
||||||
|
|
||||||
def test_dispose_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify dispose after dispose does not raise (idempotent)."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
# Should not raise
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_create_session_returns_valid_session(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify createSession returns an EngineSession instance."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
session = engine.createSession()
|
|
||||||
|
|
||||||
assert isinstance(session, EngineSession), \
|
|
||||||
f"createSession must return EngineSession instance, got {type(session).__name__}"
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_session_dispose_is_idempotent(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify session dispose can be called multiple times."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
session = engine.createSession()
|
|
||||||
|
|
||||||
# First dispose
|
|
||||||
session.dispose()
|
|
||||||
|
|
||||||
# Second dispose should not raise
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_session_synthesize_after_dispose_raises(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify session.synthesize raises EngineError after dispose."""
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
session = engine.createSession()
|
|
||||||
session.dispose()
|
|
||||||
|
|
||||||
# Create a minimal request (won't actually synthesize, just test error)
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="test",
|
|
||||||
voice=VoiceSelection(source="test", key="test"),
|
|
||||||
parameters=ParameterValues(values={}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
session.synthesize(request)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAllPluginsCapabilities:
|
|
||||||
"""Test that all plugins implement their declared capabilities."""
|
|
||||||
|
|
||||||
def test_voice_list_capability(self, loaded_plugin, host_context, engine_config):
|
|
||||||
"""Verify plugins with 'voice_list' capability implement VoiceLister."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
|
|
||||||
if "voice_list" not in manifest.capabilities:
|
|
||||||
pytest.skip("Plugin does not declare 'voice_list' capability")
|
|
||||||
|
|
||||||
engine = loaded_plugin.create_engine(host_context, None, engine_config)
|
|
||||||
|
|
||||||
# Check if listVoices method exists
|
|
||||||
assert hasattr(engine, "listVoices"), \
|
|
||||||
"Plugin with 'voice_list' capability must have listVoices method"
|
|
||||||
|
|
||||||
# listVoices should be callable
|
|
||||||
assert callable(engine.listVoices), "listVoices must be callable"
|
|
||||||
|
|
||||||
# listVoices should return a list/tuple of voices
|
|
||||||
# Get first voice source from manifest
|
|
||||||
if manifest.engine.voiceSources:
|
|
||||||
source_id = manifest.engine.voiceSources[0].id
|
|
||||||
voices = engine.listVoices(source_id)
|
|
||||||
|
|
||||||
assert isinstance(voices, (list, tuple)), \
|
|
||||||
f"listVoices must return list or tuple, got {type(voices).__name__}"
|
|
||||||
|
|
||||||
# Each voice should have id, name, tags
|
|
||||||
for voice in voices:
|
|
||||||
assert hasattr(voice, "id"), "Voice must have 'id' attribute"
|
|
||||||
assert hasattr(voice, "name"), "Voice must have 'name' attribute"
|
|
||||||
assert hasattr(voice, "tags"), "Voice must have 'tags' attribute"
|
|
||||||
assert isinstance(voice.tags, tuple), "Voice tags must be a tuple"
|
|
||||||
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_known_capabilities_are_valid(self, loaded_plugin):
|
|
||||||
"""Verify all declared capabilities are known."""
|
|
||||||
manifest = loaded_plugin.manifest
|
|
||||||
|
|
||||||
known_capabilities = frozenset({
|
|
||||||
"voice_list",
|
|
||||||
"preview",
|
|
||||||
"voice_clone",
|
|
||||||
"voice_blend",
|
|
||||||
"streaming",
|
|
||||||
"cancel",
|
|
||||||
})
|
|
||||||
|
|
||||||
for cap in manifest.capabilities:
|
|
||||||
assert cap in known_capabilities, \
|
|
||||||
f"Unknown capability: '{cap}'. Known capabilities: {known_capabilities}"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.constants import VOICES_INTERNAL
|
||||||
from abogen.webui.conversion_runner import (
|
from abogen.webui.conversion_runner import (
|
||||||
_chapter_voice_spec,
|
_chapter_voice_spec,
|
||||||
_chunk_voice_spec,
|
_chunk_voice_spec,
|
||||||
@@ -49,4 +49,4 @@ def test_voice_collection_includes_formula_components():
|
|||||||
voices = _collect_required_voice_ids(job)
|
voices = _collect_required_voice_ids(job)
|
||||||
|
|
||||||
assert {"af_nova", "am_liam"}.issubset(voices)
|
assert {"af_nova", "am_liam"}.issubset(voices)
|
||||||
assert voices.issuperset(get_voices("kokoro"))
|
assert voices.issuperset(VOICES_INTERNAL)
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
assert match is not None
|
assert match is not None
|
||||||
original_text = html.unescape(match.group(1))
|
original_text = html.unescape(match.group(1))
|
||||||
assert "Second line\n\nThird paragraph." in original_text.replace("\r\n", "\n")
|
assert "Second line\n\nThird paragraph." in original_text
|
||||||
|
|
||||||
|
|
||||||
def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
|
def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Tests for the Kokoro TTS Plugin.
|
|
||||||
|
|
||||||
These tests verify that the Kokoro plugin:
|
|
||||||
- Loads correctly through the Plugin Loader
|
|
||||||
- Has a valid manifest
|
|
||||||
- Creates a valid Engine
|
|
||||||
- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
|
|
||||||
- Implements VoiceLister capability
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
|
||||||
from abogen.tts_plugin.manifest import PluginManifest
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
EngineConfig,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
from tests.contracts.engine_contract import EngineContractMixin
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Helpers
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _kokoro_available() -> bool:
|
|
||||||
try:
|
|
||||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_engine() -> Any:
|
|
||||||
from plugins.kokoro.engine import KokoroEngine
|
|
||||||
|
|
||||||
class MockPipeline:
|
|
||||||
def __call__(self, text, voice, speed, split_pattern=None):
|
|
||||||
class MockSegment:
|
|
||||||
def __init__(self):
|
|
||||||
self.audio = MockAudio()
|
|
||||||
class MockAudio:
|
|
||||||
def numpy(self):
|
|
||||||
import numpy as np
|
|
||||||
return np.zeros(24000, dtype="float32")
|
|
||||||
return [MockSegment()]
|
|
||||||
|
|
||||||
engine = KokoroEngine(MockPipeline())
|
|
||||||
|
|
||||||
# Override listVoices for testing (real engine reads from manifest)
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
engine.listVoices = lambda source_id: [
|
|
||||||
VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)),
|
|
||||||
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)),
|
|
||||||
]
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Fixtures
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def kokoro_plugin_dir() -> Path:
|
|
||||||
return Path(__file__).parent.parent / "plugins" / "kokoro"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def host_context(tmp_path: Path) -> HostContext:
|
|
||||||
class FakeHttpClient:
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return HostContext(
|
|
||||||
config_dir=tmp_path,
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=FakeHttpClient(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def engine() -> Engine:
|
|
||||||
return _make_mock_engine()
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Plugin Loading Tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestKokoroPluginLoading:
|
|
||||||
|
|
||||||
def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert result.manifest is not None
|
|
||||||
assert result.create_engine is not None
|
|
||||||
|
|
||||||
def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
manifest = result.manifest
|
|
||||||
assert isinstance(manifest, PluginManifest)
|
|
||||||
assert manifest.id == "kokoro"
|
|
||||||
assert manifest.name == "Kokoro"
|
|
||||||
assert manifest.api_version == "1.0"
|
|
||||||
|
|
||||||
def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert result.model_requirements is not None
|
|
||||||
assert isinstance(result.model_requirements, tuple)
|
|
||||||
|
|
||||||
def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert "voice_list" in result.manifest.capabilities
|
|
||||||
|
|
||||||
def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
engine_manifest = result.manifest.engine
|
|
||||||
assert len(engine_manifest.voiceSources) > 0
|
|
||||||
assert len(engine_manifest.audioFormats) > 0
|
|
||||||
assert len(engine_manifest.parameters) > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Engine Creation (real backend, skipped if not installed)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestKokoroEngineCreation:
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
|
|
||||||
def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
engine = result.create_engine(host_context, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
|
|
||||||
def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
|
|
||||||
result = load_plugin_from_dir(kokoro_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
engine = result.create_engine(host_context, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Engine / Session Contract (inherited from base)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestKokoroEngineContract(EngineContractMixin):
|
|
||||||
"""Every test from EngineContractMixin runs against KokoroEngine."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_voice(self) -> str:
|
|
||||||
return "af_nova"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# VoiceLister Tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestKokoroVoiceLister:
|
|
||||||
|
|
||||||
def test_list_voices(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
voices = engine.listVoices("builtin")
|
|
||||||
assert len(voices) > 0
|
|
||||||
assert all(hasattr(v, "id") for v in voices)
|
|
||||||
assert all(hasattr(v, "name") for v in voices)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_voices_have_tags(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
voices = engine.listVoices("builtin")
|
|
||||||
for voice in voices:
|
|
||||||
assert isinstance(voice.tags, tuple)
|
|
||||||
assert len(voice.tags) > 0
|
|
||||||
engine.dispose()
|
|
||||||
@@ -19,7 +19,7 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
|
|||||||
|
|
||||||
# And stub the kokoro pipeline path so generate_preview_audio won't proceed.
|
# And stub the kokoro pipeline path so generate_preview_audio won't proceed.
|
||||||
# We'll instead validate by calling the override logic through generate_preview_audio
|
# We'll instead validate by calling the override logic through generate_preview_audio
|
||||||
# with provider=supertonic and stub create_backend to capture input.
|
# with provider=supertonic and stub SupertonicPipeline to capture input.
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
class DummyPipeline:
|
class DummyPipeline:
|
||||||
@@ -30,16 +30,11 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
|
|||||||
captured["text"] = text
|
captured["text"] = text
|
||||||
return iter(())
|
return iter(())
|
||||||
|
|
||||||
from abogen.tts_plugin import utils
|
monkeypatch.setitem(
|
||||||
|
__import__("sys").modules,
|
||||||
original_create_pipeline = utils.create_pipeline
|
"abogen.tts_supertonic",
|
||||||
|
type("M", (), {"SupertonicPipeline": DummyPipeline}),
|
||||||
def _mock_create_pipeline(backend_id, **kwargs):
|
)
|
||||||
if backend_id == "supertonic":
|
|
||||||
return DummyPipeline(**kwargs)
|
|
||||||
return original_create_pipeline(backend_id, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(utils, "create_pipeline", _mock_create_pipeline)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preview.generate_preview_audio(
|
preview.generate_preview_audio(
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
"""Tests for the SuperTonic TTS Plugin.
|
|
||||||
|
|
||||||
These tests verify that the SuperTonic plugin:
|
|
||||||
- Loads correctly through the Plugin Loader
|
|
||||||
- Has a valid manifest
|
|
||||||
- Creates a valid Engine
|
|
||||||
- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
|
|
||||||
- Implements VoiceLister capability
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
|
||||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
|
||||||
from abogen.tts_plugin.manifest import PluginManifest
|
|
||||||
from abogen.tts_plugin.types import (
|
|
||||||
AudioFormat,
|
|
||||||
EngineConfig,
|
|
||||||
ParameterValues,
|
|
||||||
SynthesisRequest,
|
|
||||||
VoiceSelection,
|
|
||||||
)
|
|
||||||
|
|
||||||
from tests.contracts.engine_contract import EngineContractMixin
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Helpers
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _supertonic_available() -> bool:
|
|
||||||
try:
|
|
||||||
from supertonic import TTS # type: ignore[import-not-found]
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_engine() -> Any:
|
|
||||||
from plugins.supertonic.engine import SuperTonicEngine
|
|
||||||
|
|
||||||
class MockSegment:
|
|
||||||
def __init__(self):
|
|
||||||
import numpy as np
|
|
||||||
self.audio = np.zeros(24000, dtype="float32")
|
|
||||||
|
|
||||||
class MockPipeline:
|
|
||||||
sample_rate = 24000
|
|
||||||
|
|
||||||
def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
|
|
||||||
return [MockSegment()]
|
|
||||||
|
|
||||||
engine = SuperTonicEngine(MockPipeline())
|
|
||||||
|
|
||||||
# Override listVoices for testing (real engine reads from manifest)
|
|
||||||
from abogen.tts_plugin.manifest import VoiceManifest
|
|
||||||
original_list_voices = engine.listVoices
|
|
||||||
|
|
||||||
def mock_list_voices(source_id):
|
|
||||||
if engine._disposed:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
raise EngineError("Engine disposed")
|
|
||||||
return [
|
|
||||||
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",)),
|
|
||||||
]
|
|
||||||
|
|
||||||
engine.listVoices = mock_list_voices
|
|
||||||
return engine
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Fixtures
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def supertonic_plugin_dir() -> Path:
|
|
||||||
return Path(__file__).parent.parent / "plugins" / "supertonic"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def host_context(tmp_path: Path) -> HostContext:
|
|
||||||
class FakeHttpClient:
|
|
||||||
def get(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
def post(self, url: str, **kwargs: object) -> object:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return HostContext(
|
|
||||||
config_dir=tmp_path,
|
|
||||||
logger=logging.getLogger("test"),
|
|
||||||
http_client=FakeHttpClient(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def engine() -> Engine:
|
|
||||||
return _make_mock_engine()
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Plugin Loading Tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestSuperTonicPluginLoading:
|
|
||||||
|
|
||||||
def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert result.manifest is not None
|
|
||||||
assert result.create_engine is not None
|
|
||||||
|
|
||||||
def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
manifest = result.manifest
|
|
||||||
assert isinstance(manifest, PluginManifest)
|
|
||||||
assert manifest.id == "supertonic"
|
|
||||||
assert manifest.name == "SuperTonic"
|
|
||||||
assert manifest.api_version == "1.0"
|
|
||||||
|
|
||||||
def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert result.model_requirements is not None
|
|
||||||
assert isinstance(result.model_requirements, tuple)
|
|
||||||
|
|
||||||
def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
assert "voice_list" in result.manifest.capabilities
|
|
||||||
|
|
||||||
def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
engine_manifest = result.manifest.engine
|
|
||||||
assert len(engine_manifest.voiceSources) > 0
|
|
||||||
assert len(engine_manifest.audioFormats) > 0
|
|
||||||
assert len(engine_manifest.parameters) > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Engine Creation (real backend, skipped if not installed)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestSuperTonicEngineCreation:
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
|
|
||||||
def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
engine = result.create_engine(host_context, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
|
|
||||||
def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
|
|
||||||
result = load_plugin_from_dir(supertonic_plugin_dir)
|
|
||||||
assert result.success is True
|
|
||||||
engine = result.create_engine(host_context, None, EngineConfig())
|
|
||||||
assert isinstance(engine, Engine)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Engine / Session Contract (inherited from base)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestSuperTonicEngineContract(EngineContractMixin):
|
|
||||||
"""Every test from EngineContractMixin runs against SuperTonicEngine."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_voice(self) -> str:
|
|
||||||
return "M1"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# VoiceLister Tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestSuperTonicVoiceLister:
|
|
||||||
|
|
||||||
def test_list_voices(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
voices = engine.listVoices("builtin")
|
|
||||||
assert len(voices) == 10
|
|
||||||
assert all(hasattr(v, "id") for v in voices)
|
|
||||||
assert all(hasattr(v, "name") for v in voices)
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_voices_have_tags(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
for voice in engine.listVoices("builtin"):
|
|
||||||
assert isinstance(voice.tags, tuple)
|
|
||||||
assert len(voice.tags) > 0
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_male_voices_have_male_tag(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
for v in engine.listVoices("builtin"):
|
|
||||||
if v.id.startswith("M"):
|
|
||||||
assert "male" in v.tags
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_female_voices_have_female_tag(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
for v in engine.listVoices("builtin"):
|
|
||||||
if v.id.startswith("F"):
|
|
||||||
assert "female" in v.tags
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_list_voices_after_dispose_raises(self) -> None:
|
|
||||||
from abogen.tts_plugin.errors import EngineError
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
engine.dispose()
|
|
||||||
with pytest.raises(EngineError):
|
|
||||||
engine.listVoices("builtin")
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# SuperTonic-specific parameter tests
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestSuperTonicParameters:
|
|
||||||
|
|
||||||
def test_speed_parameter(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
session = engine.createSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="M1"),
|
|
||||||
parameters=ParameterValues(values={"speed": 1.5}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert isinstance(result.data, bytes)
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_total_steps_parameter(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
session = engine.createSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="M1"),
|
|
||||||
parameters=ParameterValues(values={"total_steps": 10}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert isinstance(result.data, bytes)
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
def test_default_parameters(self) -> None:
|
|
||||||
engine = _make_mock_engine()
|
|
||||||
session = engine.createSession()
|
|
||||||
request = SynthesisRequest(
|
|
||||||
text="Hello",
|
|
||||||
voice=VoiceSelection(source="builtin", key="M1"),
|
|
||||||
parameters=ParameterValues(values={}),
|
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
|
||||||
)
|
|
||||||
result = session.synthesize(request)
|
|
||||||
assert isinstance(result.data, bytes)
|
|
||||||
session.dispose()
|
|
||||||
engine.dispose()
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from abogen.tts_supertonic import SupertonicPipeline
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyTTS:
|
||||||
|
def get_voice_style(self, voice_name: str):
|
||||||
|
return {"voice": voice_name}
|
||||||
|
|
||||||
|
def synthesize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
voice_style,
|
||||||
|
total_steps: int,
|
||||||
|
speed: float,
|
||||||
|
max_chunk_length: int,
|
||||||
|
silence_duration: float,
|
||||||
|
verbose: bool,
|
||||||
|
):
|
||||||
|
if "•" in text:
|
||||||
|
raise ValueError("Found 1 unsupported character(s): ['•']")
|
||||||
|
# Return 50ms of audio at 24kHz.
|
||||||
|
sr = 24000
|
||||||
|
audio = np.zeros(int(0.05 * sr), dtype="float32")
|
||||||
|
return audio, 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
|
||||||
|
# Avoid importing/initializing real supertonic by manually constructing the pipeline.
|
||||||
|
pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
|
||||||
|
pipeline.sample_rate = 24000
|
||||||
|
pipeline.total_steps = 5
|
||||||
|
pipeline.max_chunk_length = 1000
|
||||||
|
pipeline._tts = _DummyTTS()
|
||||||
|
|
||||||
|
segs = list(pipeline("Hello • world", voice="M1", speed=1.0))
|
||||||
|
assert len(segs) == 1
|
||||||
|
assert segs[0].graphemes == "Hello world" or segs[0].graphemes == "Hello world"
|
||||||
|
assert isinstance(segs[0].audio, np.ndarray)
|
||||||
|
assert segs[0].audio.dtype == np.float32
|
||||||
|
assert segs[0].audio.size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters():
|
||||||
|
pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
|
||||||
|
pipeline.sample_rate = 24000
|
||||||
|
pipeline.total_steps = 5
|
||||||
|
pipeline.max_chunk_length = 1000
|
||||||
|
pipeline._tts = _DummyTTS()
|
||||||
|
|
||||||
|
segs = list(pipeline("•", voice="M1", speed=1.0))
|
||||||
|
assert segs == []
|
||||||
@@ -3,7 +3,7 @@ from typing import cast
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices
|
from abogen.constants import VOICES_INTERNAL
|
||||||
from abogen.voice_cache import (
|
from abogen.voice_cache import (
|
||||||
LocalEntryNotFoundError,
|
LocalEntryNotFoundError,
|
||||||
_CACHED_VOICES,
|
_CACHED_VOICES,
|
||||||
@@ -66,4 +66,4 @@ def test_collect_required_voice_ids_includes_all():
|
|||||||
voices = _collect_required_voice_ids(cast(Job, job))
|
voices = _collect_required_voice_ids(cast(Job, job))
|
||||||
|
|
||||||
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
|
assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
|
||||||
assert voices.issuperset(get_voices("kokoro"))
|
assert voices.issuperset(VOICES_INTERNAL)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec
|
||||||
|
from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
|
||||||
|
# This can happen when a previously-saved Kokoro mix formula is present
|
||||||
|
# but the active provider is SuperTonic (no Kokoro pipeline object).
|
||||||
|
formula = "af_heart*0.5+af_sky*0.5"
|
||||||
|
resolved = _resolve_voice(None, formula, use_gpu=False)
|
||||||
|
assert resolved == formula
|
||||||
|
|
||||||
|
|
||||||
|
def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
|
||||||
|
# When a stale Kokoro mix formula is present, SuperTonic should not receive it.
|
||||||
|
chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
|
||||||
|
assert chosen in DEFAULT_SUPERTONIC_VOICES
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.voice_metadata import VoiceMetadata
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceMetadataCreation:
|
|
||||||
def test_create_with_all_fields(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
assert voice.id == "af_alloy"
|
|
||||||
assert voice.display_name == "Alloy"
|
|
||||||
assert voice.language == "a"
|
|
||||||
assert voice.gender == "female"
|
|
||||||
assert voice.backend_id == "kokoro"
|
|
||||||
|
|
||||||
def test_create_supertonic_voice(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="M1",
|
|
||||||
display_name="Male 1",
|
|
||||||
language="en",
|
|
||||||
gender="male",
|
|
||||||
backend_id="supertonic",
|
|
||||||
)
|
|
||||||
assert voice.id == "M1"
|
|
||||||
assert voice.backend_id == "supertonic"
|
|
||||||
|
|
||||||
def test_create_with_unknown_gender(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="custom_voice",
|
|
||||||
display_name="Custom",
|
|
||||||
language="en",
|
|
||||||
gender="unknown",
|
|
||||||
backend_id="custom_backend",
|
|
||||||
)
|
|
||||||
assert voice.gender == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceMetadataImmutability:
|
|
||||||
def test_frozen_dataclass(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
voice.id = "new_id"
|
|
||||||
|
|
||||||
def test_cannot_modify_display_name(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
voice.display_name = "New Name"
|
|
||||||
|
|
||||||
def test_cannot_modify_backend_id(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
voice.backend_id = "new_backend"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceMetadataEquality:
|
|
||||||
def test_equal_voices_are_equal(self):
|
|
||||||
voice1 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
voice2 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
assert voice1 == voice2
|
|
||||||
|
|
||||||
def test_different_voices_are_not_equal(self):
|
|
||||||
voice1 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
voice2 = VoiceMetadata(
|
|
||||||
id="am_adam",
|
|
||||||
display_name="Adam",
|
|
||||||
language="a",
|
|
||||||
gender="male",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
assert voice1 != voice2
|
|
||||||
|
|
||||||
def test_different_backend_id_not_equal(self):
|
|
||||||
voice1 = VoiceMetadata(
|
|
||||||
id="custom",
|
|
||||||
display_name="Custom",
|
|
||||||
language="en",
|
|
||||||
gender="unknown",
|
|
||||||
backend_id="backend_a",
|
|
||||||
)
|
|
||||||
voice2 = VoiceMetadata(
|
|
||||||
id="custom",
|
|
||||||
display_name="Custom",
|
|
||||||
language="en",
|
|
||||||
gender="unknown",
|
|
||||||
backend_id="backend_b",
|
|
||||||
)
|
|
||||||
assert voice1 != voice2
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceMetadataHashing:
|
|
||||||
def test_hashable(self):
|
|
||||||
voice = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
assert hash(voice) is not None
|
|
||||||
|
|
||||||
def test_equal_voices_same_hash(self):
|
|
||||||
voice1 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
voice2 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
assert hash(voice1) == hash(voice2)
|
|
||||||
|
|
||||||
def test_usable_in_set(self):
|
|
||||||
voice1 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
voice2 = VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
voice3 = VoiceMetadata(
|
|
||||||
id="am_adam",
|
|
||||||
display_name="Adam",
|
|
||||||
language="a",
|
|
||||||
gender="male",
|
|
||||||
backend_id="kokoro",
|
|
||||||
)
|
|
||||||
voice_set = {voice1, voice2, voice3}
|
|
||||||
assert len(voice_set) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestVoiceMetadataUseCases:
|
|
||||||
def test_backend_populates_backend_id(self):
|
|
||||||
"""Simulate how a backend would populate backend_id automatically."""
|
|
||||||
|
|
||||||
class MockBackend:
|
|
||||||
def __init__(self):
|
|
||||||
self._backend_id = "kokoro"
|
|
||||||
|
|
||||||
def get_voices(self):
|
|
||||||
return [
|
|
||||||
VoiceMetadata(
|
|
||||||
id="af_alloy",
|
|
||||||
display_name="Alloy",
|
|
||||||
language="a",
|
|
||||||
gender="female",
|
|
||||||
backend_id=self._backend_id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
backend = MockBackend()
|
|
||||||
voices = backend.get_voices()
|
|
||||||
assert voices[0].backend_id == "kokoro"
|
|
||||||
|
|
||||||
def test_filter_by_language(self):
|
|
||||||
voices = [
|
|
||||||
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
|
|
||||||
VoiceMetadata(id="jf_alpha", display_name="Alpha", language="j", gender="female", backend_id="kokoro"),
|
|
||||||
VoiceMetadata(id="am_adam", display_name="Adam", language="a", gender="male", backend_id="kokoro"),
|
|
||||||
]
|
|
||||||
english_voices = [v for v in voices if v.language == "a"]
|
|
||||||
assert len(english_voices) == 2
|
|
||||||
|
|
||||||
def test_filter_by_gender(self):
|
|
||||||
voices = [
|
|
||||||
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
|
|
||||||
VoiceMetadata(id="am_adam", display_name="Adam", language="a", gender="male", backend_id="kokoro"),
|
|
||||||
VoiceMetadata(id="am_puck", display_name="Puck", language="a", gender="male", backend_id="kokoro"),
|
|
||||||
]
|
|
||||||
male_voices = [v for v in voices if v.gender == "male"]
|
|
||||||
assert len(male_voices) == 2
|
|
||||||
|
|
||||||
def test_filter_by_backend(self):
|
|
||||||
voices = [
|
|
||||||
VoiceMetadata(id="af_alloy", display_name="Alloy", language="a", gender="female", backend_id="kokoro"),
|
|
||||||
VoiceMetadata(id="M1", display_name="Male 1", language="en", gender="male", backend_id="supertonic"),
|
|
||||||
]
|
|
||||||
kokoro_voices = [v for v in voices if v.backend_id == "kokoro"]
|
|
||||||
assert len(kokoro_voices) == 1
|
|
||||||
assert kokoro_voices[0].id == "af_alloy"
|
|
||||||
Reference in New Issue
Block a user