Compare commits

..
Author SHA1 Message Date
Artem AkymenkoandGitHub f1cc6deae8 Merge pull request #164 from yashupadhyayy1/main
Refactor segment processing with overflow error handling
2026-07-09 19:25:23 +03:00
Artem Akymenko 6f25fc06d0 ci: add UV_LINK_MODE=copy to suppress Windows hardlink warning 2026-07-09 06:58:59 +00:00
Artem Akymenko 32c4d533c9 ci: disable uv cache pruning to preserve wheel files 2026-07-09 06:06:10 +00:00
Artem Akymenko 146000886d ci: add uv cache prune to optimize cache size 2026-07-08 21:14:15 +00:00
Artem Akymenko 31f95137dd ci: replace pip with uv for faster dependency installation 2026-07-08 18:34:35 +00:00
Artem Akymenko 6f02fda41c fix(ci): set QT_QPA_PLATFORM=offscreen for headless PyQt6 tests 2026-07-08 17:36:59 +00:00
Artem Akymenko a3c3462348 fix(ci): install libegl1 on Ubuntu and normalize line endings in epub test
- Add system dependency step for libegl1 to fix PyQt6 import on headless CI
- Normalize CRLF to LF in epub exporter whitespace test for Windows CI
2026-07-08 17:16:33 +00:00
Artem AkymenkoandGitHub 79332204d3 Merge pull request #189 from denizsafak/feat/registry-voice-resolution
refactor: Move backend resolution by voice spec into registry
2026-07-08 20:03:19 +03:00
Artem Akymenko 6deec3b9b6 refactor: move backend resolution by voice spec into registry
- Add resolve_backend_for_voice() to TTSBackendRegistry
- Add module-level wrapper resolve_backend_for_voice()
- Simplify _infer_provider_from_spec() to use registry API
- Simplify _supertonic_voice_from_spec() to only normalize
- Add 11 test cases for the new method

Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (* or +) -> kokoro
3. Exact voice ID match -> backend id
4. Unknown voice -> fallback
2026-07-08 17:02:33 +00:00
Artem Akymenko c4d14112d4 refactor: replace hardcoded backend ID sets with registry checks
Add TTSBackendRegistry.is_registered() and module-level
is_registered_backend() to validate backend IDs dynamically.
Replace all Category A hardcoded sets (validation-only) in
voice_profiles, api routes, conversion_runner, and form utils.
2026-07-08 16:33:16 +00:00
Artem Akymenko f4cb2c2329 ci: add pytest, use actions/cache@v6 2026-07-08 19:26:42 +03:00
Artem AkymenkoandGitHub 783738882f Merge pull request #188 from denizsafak/refactor/move-kokoro-voices-into-backend
refactor: move VOICES_INTERNAL into KokoroBackend module
2026-07-08 19:23:33 +03:00
Artem Akymenko e94ba5257e refactor: move VOICES_INTERNAL into KokoroBackend module
Make the Kokoro voice list an internal implementation detail of the
backend instead of a shared constant. The rest of the project already
accesses voices via get_metadata('kokoro').voices.

- Move VOICES_INTERNAL from constants.py to kokoro.py as _VOICES_INTERNAL
- Update tests to use get_metadata('kokoro').voices instead of importing
  the constant directly
2026-07-08 16:19:34 +00:00
Artem AkymenkoandGitHub 49d66839dc Merge pull request #186 from denizsafak/refactor/migrate-remaining-voice-metadata-consumers
refactor: migrate remaining consumers to get_metadata API
2026-07-08 19:01:20 +03:00
Artem AkymenkoandGitHub d0e316ea7b Merge pull request #187 from denizsafak/refactor/migrate-pyqt-to-backend-metadata
refactor(pyqt): migrate from VOICES_INTERNAL to get_metadata API
2026-07-08 19:01:02 +03:00
Artem Akymenko bb96ae502c refactor: migrate remaining consumers to get_metadata API
Replace direct VOICES_INTERNAL imports with get_metadata('kokoro').voices:
- abogen/predownload_gui.py
- abogen/subtitle_utils.py
2026-07-08 15:58:51 +00:00
Artem Akymenko a4d25accc1 refactor(pyqt): migrate from VOICES_INTERNAL to get_metadata API
Replace direct VOICES_INTERNAL imports with get_metadata('kokoro').voices
from tts_backend_registry in all PyQt modules:
- abogen/pyqt/gui.py
- abogen/pyqt/predownload_gui.py
- abogen/pyqt/voice_formula_gui.py
2026-07-08 15:57:18 +00:00
Artem AkymenkoandGitHub 66964bfd0b Merge pull request #185 from denizsafak/refactor/use-backend-metadata-in-webui
refactor(webui): replace direct VOICES_INTERNAL/DEFAULT_SUPERTONIC_VOICES with get_metadata API
2026-07-08 18:49:21 +03:00
YashandGitHub da68f38b9b Refactor segment processing with overflow error handling
fix: catch OverflowError in emit_text for very large numbers

Fixes #145

When text contains a very large number (e.g. a long decimal from a binary
hash), misaki's pipeline calls num2words() which raises OverflowError and
crashes the entire job. Wrap the segment iterator in try/except so the
chunk is skipped gracefully with a warning log instead of terminating.
2026-05-22 10:07:38 +05:30
18 changed files with 583 additions and 396 deletions
+27 -8
View File
@@ -1,5 +1,6 @@
name: pip install name: CI
run-name: pip install run-name: CI
on: on:
push: push:
branches: [main] branches: [main]
@@ -12,8 +13,9 @@ on:
- 'pyproject.toml' - 'pyproject.toml'
- '.github/workflows/**' - '.github/workflows/**'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
install-and-run: test:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-14, windows-latest] os: [ubuntu-latest, macos-14, windows-latest]
@@ -23,12 +25,29 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip
- 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
-58
View File
@@ -63,64 +63,6 @@ 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.",
+5 -4
View File
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS, VOICES_INTERNAL from abogen.constants import COLORS
from abogen.tts_backend_registry import get_metadata
from abogen.spacy_utils import SPACY_MODELS from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker import abogen.hf_tracker
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False self._voices_success = False
return return
voice_list = VOICES_INTERNAL voice_list = get_metadata("kokoro").voices
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
@@ -462,14 +463,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 VOICES_INTERNAL: for voice in get_metadata("kokoro").voices:
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(VOICES_INTERNAL) return False, list(get_metadata("kokoro").voices)
return (len(missing) == 0), missing return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool: def _check_kokoro_model(self) -> bool:
+2 -2
View File
@@ -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_backend_registry import get_metadata
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 VOICES_INTERNAL: for v in get_metadata("kokoro").voices:
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):
+5 -4
View File
@@ -21,7 +21,8 @@ from PyQt6.QtWidgets import (
) )
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from abogen.constants import COLORS, VOICES_INTERNAL from abogen.constants import COLORS
from abogen.tts_backend_registry import get_metadata
from abogen.spacy_utils import SPACY_MODELS from abogen.spacy_utils import SPACY_MODELS
import abogen.hf_tracker import abogen.hf_tracker
@@ -114,7 +115,7 @@ class PreDownloadWorker(QThread):
self._voices_success = False self._voices_success = False
return return
voice_list = VOICES_INTERNAL voice_list = get_metadata("kokoro").voices
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
@@ -462,14 +463,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 VOICES_INTERNAL: for voice in get_metadata("kokoro").voices:
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(VOICES_INTERNAL) return False, list(get_metadata("kokoro").voices)
return (len(missing) == 0), missing return (len(missing) == 0), missing
def _check_kokoro_model(self) -> bool: def _check_kokoro_model(self) -> bool:
+3 -3
View File
@@ -28,11 +28,11 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt6.QtGui import QPixmap, QIcon, QAction from PyQt6.QtGui import QPixmap, QIcon, QAction
from abogen.constants import ( from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
COLORS, COLORS,
) )
from abogen.tts_backend_registry import get_metadata
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 VOICES_INTERNAL and self.voice_name[1] == "f" is_female = self.voice_name in get_metadata("kokoro").voices 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 VOICES_INTERNAL: for voice in get_metadata("kokoro").voices:
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
+7 -7
View File
@@ -466,7 +466,7 @@ def sanitize_name_for_os(name, is_folder=True):
def validate_voice_name(voice_name): def validate_voice_name(voice_name):
"""Validate voice name against VOICES_INTERNAL list (case-insensitive). """Validate voice name against available voices (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.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
# 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 VOICES_INTERNAL} voice_lookup_lower = {v.lower() for v in get_metadata("kokoro").voices}
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 VOICES_INTERNAL. Voice names are normalized to lowercase to match canonical voice names.
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.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
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 VOICES_INTERNAL if v.lower() == voice_part_lower), (v for v in get_metadata("kokoro").voices 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 VOICES_INTERNAL if v.lower() == voice_name_lower), (v for v in get_metadata("kokoro").voices if v.lower() == voice_name_lower),
voice_name voice_name
) )
valid_markers += 1 valid_markers += 1
+56
View File
@@ -30,6 +30,10 @@ class TTSBackendRegistry:
self._backends[metadata.id] = metadata self._backends[metadata.id] = metadata
self._factories[metadata.id] = factory self._factories[metadata.id] = factory
def is_registered(self, backend_id: str) -> bool:
"""Return True if a backend with the given id is registered."""
return backend_id in self._backends
def list_backends(self) -> list[TTSBackendMetadata]: def list_backends(self) -> list[TTSBackendMetadata]:
"""Return metadata for all registered backends.""" """Return metadata for all registered backends."""
return list(self._backends.values()) return list(self._backends.values())
@@ -54,6 +58,33 @@ class TTSBackendRegistry:
raise KeyError(f"Unknown backend: {backend_id}") raise KeyError(f"Unknown backend: {backend_id}")
return self._factories[backend_id](**kwargs) return self._factories[backend_id](**kwargs)
def resolve_backend_for_voice(
self,
spec: str,
fallback: str = "kokoro",
) -> str:
"""Determine which backend owns the given voice specification.
Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (contains '*' or '+') -> "kokoro"
3. Exact voice ID match against registered backends -> backend 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()
for metadata in self._backends.values():
if upper in metadata.voices:
return metadata.id
return fallback
_registry = TTSBackendRegistry() _registry = TTSBackendRegistry()
@@ -88,3 +119,28 @@ def get_default_voice(backend_id: str, fallback: str = "") -> str:
def create_backend(backend_id: str, **kwargs: Any) -> TTSBackend: def create_backend(backend_id: str, **kwargs: Any) -> TTSBackend:
"""Create a TTS backend instance by provider id.""" """Create a TTS backend instance by provider id."""
return _registry.create_backend(backend_id, **kwargs) return _registry.create_backend(backend_id, **kwargs)
def is_registered_backend(backend_id: str) -> bool:
"""Return True if *backend_id* is a registered TTS backend."""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.is_registered(backend_id)
def resolve_backend_for_voice(
spec: str,
fallback: str = "kokoro",
) -> str:
"""Determine which backend owns the given voice specification.
Ensures all backends are registered by importing the tts_backends
package on first access.
Resolution rules:
1. Empty spec -> fallback
2. Kokoro formula (contains '*' or '+') -> "kokoro"
3. Exact voice ID match against registered backends -> backend id
4. Unknown voice -> fallback
"""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.resolve_backend_for_voice(spec, fallback=fallback)
+60 -2
View File
@@ -10,14 +10,72 @@ from typing import Any, Dict, Iterator, List, Optional
import numpy as np import numpy as np
from abogen.constants import VOICES_INTERNAL
from abogen.tts_backend import TTSBackendMetadata from abogen.tts_backend import TTSBackendMetadata
# Internal voice list — source of truth for Kokoro voices.
# The rest of the project accesses voices via get_metadata("kokoro").voices.
_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",
]
_KOKORO_METADATA = TTSBackendMetadata( _KOKORO_METADATA = TTSBackendMetadata(
id="kokoro", id="kokoro",
name="Kokoro", name="Kokoro",
description="Kokoro TTS engine", description="Kokoro TTS engine",
voices=tuple(VOICES_INTERNAL), voices=tuple(_VOICES_INTERNAL),
) )
+2 -2
View File
@@ -2,7 +2,7 @@ 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_backend_registry import get_metadata from abogen.tts_backend_registry import get_metadata, is_registered_backend
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
@@ -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 provider not in {"kokoro", "supertonic"}: if not is_registered_backend(provider):
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"
+6 -7
View File
@@ -2,7 +2,6 @@ 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
@@ -27,22 +26,22 @@ RUN python3 -m venv "$VIRTUAL_ENV"
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ COPY pyproject.toml README.md ./
RUN pip install --upgrade pip \ RUN pip install uv \
&& if [ -n "$TORCH_VERSION" ]; then \ && if [ -n "$TORCH_VERSION" ]; then \
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \ uv pip install --system torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
else \ else \
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \ uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
fi \ fi \
&& pip install --no-cache-dir . \ && uv pip install --system . \
https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl \
&& pip install --no-cache-dir "mutagen>=1.47.0" && uv pip install --system "mutagen>=1.47.0"
COPY abogen ./abogen COPY abogen ./abogen
# 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 \
pip install --no-cache-dir onnxruntime-gpu; \ uv pip install --system onnxruntime-gpu; \
fi fi
ENV ABOGEN_HOST=0.0.0.0 \ ENV ABOGEN_HOST=0.0.0.0 \
+55 -56
View File
@@ -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_backend_registry import get_metadata from abogen.tts_backend_registry import get_metadata, is_registered_backend, resolve_backend_for_voice
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 (
@@ -57,25 +57,26 @@ 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()
# SuperTonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix # Normalize to uppercase for Supertonic voice IDs
# formula (contains '*' or '+'), ignore it and fall back to a safe voice. upper = raw.upper() if raw else ""
if not raw or "*" in raw or "+" in raw:
raw = fallback_raw
if not raw or "*" in raw or "+" in raw:
raw = "M1"
upper = raw.upper() # If empty or contains formula characters, use fallback
if upper in get_metadata("supertonic").voices: if not upper or "*" in upper or "+" in upper:
return upper upper = fallback_raw.upper() if fallback_raw else ""
fallback_upper = fallback_raw.upper() if fallback_raw else "" # If still empty, use default Supertonic voice
if fallback_upper in get_metadata("supertonic").voices: if not upper or "*" in upper or "+" in upper:
return fallback_upper upper = "M1"
return "M1" return upper
def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]: def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]:
@@ -119,15 +120,7 @@ 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:
raw = str(value or "").strip() return resolve_backend_for_voice(str(value or ""), fallback=fallback)
if not raw:
return fallback
upper = raw.upper()
if upper in get_metadata("supertonic").voices:
return "supertonic"
if "*" in raw or "+" in raw:
return "kokoro"
return fallback
class _JobCancelled(Exception): class _JobCancelled(Exception):
@@ -1574,7 +1567,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 provider_norm not in {"kokoro", "supertonic"}: if not is_registered_backend(provider_norm):
provider_norm = "kokoro" provider_norm = "kokoro"
existing = pipelines.get(provider_norm) existing = pipelines.get(provider_norm)
@@ -1866,45 +1859,51 @@ def run_conversion_job(job: Job) -> None:
split_pattern=split_pattern, split_pattern=split_pattern,
) )
for segment in segment_iter: try:
canceller() for segment in segment_iter:
graphemes_raw = getattr(segment, "graphemes", "") or "" canceller()
graphemes = graphemes_raw.strip() graphemes_raw = getattr(segment, "graphemes", "") or ""
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(
+3 -2
View File
@@ -34,6 +34,7 @@ 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_backend_registry import is_registered_backend
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,
@@ -63,7 +64,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 provider not in {"kokoro", "supertonic"}: if not is_registered_backend(provider):
provider = "kokoro" provider = "kokoro"
if provider == "supertonic": if provider == "supertonic":
profile = { profile = {
@@ -230,7 +231,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 tts_provider in {"kokoro", "supertonic"} else "" resolved_provider = tts_provider if is_registered_backend(tts_provider) 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))
+2 -1
View File
@@ -7,6 +7,7 @@ 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_backend_registry import is_registered_backend
from abogen.webui.routes.utils.settings import ( from abogen.webui.routes.utils.settings import (
load_settings, load_settings,
coerce_bool, coerce_bool,
@@ -579,7 +580,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 provider_value in {"kokoro", "supertonic"}: if is_registered_backend(provider_value):
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).
+2 -2
View File
@@ -1,7 +1,7 @@
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast from typing import cast
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
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(VOICES_INTERNAL) assert voices.issuperset(get_metadata("kokoro").voices)
+1 -1
View File
@@ -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 assert "Second line\n\nThird paragraph." in original_text.replace("\r\n", "\n")
def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None: def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
+110
View File
@@ -202,3 +202,113 @@ class TestBackendRegistration:
registry_meta = _registry.get_metadata("supertonic") registry_meta = _registry.get_metadata("supertonic")
assert _SUPERTONIC_METADATA is registry_meta assert _SUPERTONIC_METADATA is registry_meta
assert _SUPERTONIC_METADATA.voices == registry_meta.voices assert _SUPERTONIC_METADATA.voices == registry_meta.voices
class TestResolveBackendForVoice:
"""Tests for the resolve_backend_for_voice method."""
def test_empty_spec_returns_fallback(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("", fallback="kokoro") == "kokoro"
assert registry.resolve_backend_for_voice("", fallback="supertonic") == "supertonic"
def test_none_spec_returns_fallback(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice(None, fallback="kokoro") == "kokoro"
def test_kokoro_formula_with_star_returns_kokoro(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("af_nova*0.7") == "kokoro"
def test_kokoro_formula_with_plus_returns_kokoro(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("af_nova*0.7+am_liam*0.3") == "kokoro"
def test_kokoro_voice_id_resolves_to_kokoro(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS",
voices=("af_nova", "am_liam"),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("af_nova") == "kokoro"
assert registry.resolve_backend_for_voice("am_liam") == "kokoro"
def test_supertonic_voice_id_resolves_to_supertonic(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS",
voices=("M1", "M2", "F1", "F2"),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("M1") == "supertonic"
assert registry.resolve_backend_for_voice("F2") == "supertonic"
def test_unknown_voice_returns_fallback(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS",
voices=("af_nova",),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("unknown_voice") == "kokoro"
assert registry.resolve_backend_for_voice("unknown_voice", fallback="supertonic") == "supertonic"
def test_case_insensitive_matching(self):
registry = TTSBackendRegistry()
meta = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS",
voices=("M1", "F1"),
)
registry.register(metadata=meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("m1") == "supertonic"
assert registry.resolve_backend_for_voice("f1") == "supertonic"
def test_default_fallback_is_kokoro(self):
registry = TTSBackendRegistry()
assert registry.resolve_backend_for_voice("unknown") == "kokoro"
def test_multiple_backends_resolution(self):
registry = TTSBackendRegistry()
kokoro_meta = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS",
voices=("af_nova",),
)
supertonic_meta = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS",
voices=("M1",),
)
registry.register(metadata=kokoro_meta, factory=lambda: None)
registry.register(metadata=supertonic_meta, factory=lambda: None)
assert registry.resolve_backend_for_voice("af_nova") == "kokoro"
assert registry.resolve_backend_for_voice("M1") == "supertonic"
def test_global_wrapper_resolve_backend_for_voice(self):
from abogen.tts_backend_registry import resolve_backend_for_voice
# Test with empty spec
assert resolve_backend_for_voice("") == "kokoro"
# Test with formula
assert resolve_backend_for_voice("af_nova*0.7") == "kokoro"
# Test with a registered voice
assert resolve_backend_for_voice("af_nova") == "kokoro"
assert resolve_backend_for_voice("M1") == "supertonic"
+2 -2
View File
@@ -3,7 +3,7 @@ from typing import cast
import pytest import pytest
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
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(VOICES_INTERNAL) assert voices.issuperset(get_metadata("kokoro").voices)