mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1cc6deae8 | ||
|
|
6f25fc06d0 | ||
|
|
32c4d533c9 | ||
|
|
146000886d | ||
|
|
31f95137dd | ||
|
|
6f02fda41c | ||
|
|
a3c3462348 | ||
|
|
79332204d3 | ||
|
|
6deec3b9b6 | ||
|
|
c4d14112d4 | ||
|
|
f4cb2c2329 | ||
|
|
783738882f | ||
|
|
e94ba5257e | ||
|
|
49d66839dc | ||
|
|
d0e316ea7b | ||
|
|
bb96ae502c | ||
|
|
a4d25accc1 | ||
|
|
66964bfd0b | ||
|
|
da68f38b9b |
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+230
-230
@@ -1,230 +1,230 @@
|
|||||||
import json
|
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
|
||||||
|
|
||||||
|
|
||||||
def _get_profiles_path():
|
def _get_profiles_path():
|
||||||
config_path = get_user_config_path()
|
config_path = get_user_config_path()
|
||||||
config_dir = os.path.dirname(config_path)
|
config_dir = os.path.dirname(config_path)
|
||||||
return os.path.join(config_dir, "voice_profiles.json")
|
return os.path.join(config_dir, "voice_profiles.json")
|
||||||
|
|
||||||
|
|
||||||
def load_profiles():
|
def load_profiles():
|
||||||
"""Load all voice profiles from JSON file."""
|
"""Load all voice profiles from JSON file."""
|
||||||
path = _get_profiles_path()
|
path = _get_profiles_path()
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
# always expect abogen_voice_profiles wrapper
|
# always expect abogen_voice_profiles wrapper
|
||||||
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
||||||
return data["abogen_voice_profiles"]
|
return data["abogen_voice_profiles"]
|
||||||
# fallback: treat as profiles dict
|
# fallback: treat as profiles dict
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def save_profiles(profiles):
|
def save_profiles(profiles):
|
||||||
"""Save all voice profiles to JSON file."""
|
"""Save all voice profiles to JSON file."""
|
||||||
path = _get_profiles_path()
|
path = _get_profiles_path()
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
# always save with abogen_voice_profiles wrapper
|
# always save with abogen_voice_profiles wrapper
|
||||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def delete_profile(name):
|
def delete_profile(name):
|
||||||
"""Remove a profile by name."""
|
"""Remove a profile by name."""
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if name in profiles:
|
if name in profiles:
|
||||||
del profiles[name]
|
del profiles[name]
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
def duplicate_profile(src, dest):
|
def duplicate_profile(src, dest):
|
||||||
"""Duplicate an existing profile."""
|
"""Duplicate an existing profile."""
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if src in profiles and dest:
|
if src in profiles and dest:
|
||||||
profiles[dest] = profiles[src]
|
profiles[dest] = profiles[src]
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
def export_profiles(export_path):
|
def export_profiles(export_path):
|
||||||
"""Export all profiles to specified JSON file."""
|
"""Export all profiles to specified JSON file."""
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
with open(export_path, "w", encoding="utf-8") as f:
|
with open(export_path, "w", encoding="utf-8") as f:
|
||||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||||
"""Return profiles in canonical dictionary form."""
|
"""Return profiles in canonical dictionary form."""
|
||||||
return load_profiles()
|
return load_profiles()
|
||||||
|
|
||||||
|
|
||||||
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_metadata("supertonic").voices
|
supertonic_voices = get_metadata("supertonic").voices
|
||||||
return raw if raw in 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:
|
||||||
try:
|
try:
|
||||||
steps = int(value)
|
steps = int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 5
|
return 5
|
||||||
return max(2, min(15, steps))
|
return max(2, min(15, steps))
|
||||||
|
|
||||||
|
|
||||||
def _coerce_supertonic_speed(value: Any) -> float:
|
def _coerce_supertonic_speed(value: Any) -> float:
|
||||||
try:
|
try:
|
||||||
speed = float(value)
|
speed = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return 1.0
|
return 1.0
|
||||||
return max(0.7, min(2.0, speed))
|
return max(0.7, min(2.0, speed))
|
||||||
|
|
||||||
|
|
||||||
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
|
||||||
"""Normalize a stored profile entry.
|
"""Normalize a stored profile entry.
|
||||||
|
|
||||||
Backwards compatible:
|
Backwards compatible:
|
||||||
- Legacy Kokoro-only entries: {language, voices}
|
- Legacy Kokoro-only entries: {language, voices}
|
||||||
- New entries: include provider.
|
- New entries: include provider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
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"
|
||||||
|
|
||||||
if provider == "supertonic":
|
if provider == "supertonic":
|
||||||
return {
|
return {
|
||||||
"provider": "supertonic",
|
"provider": "supertonic",
|
||||||
"language": language,
|
"language": language,
|
||||||
"voice": _normalize_supertonic_voice(
|
"voice": _normalize_supertonic_voice(
|
||||||
entry.get("voice") or entry.get("voice_name") or entry.get("name")
|
entry.get("voice") or entry.get("voice_name") or entry.get("name")
|
||||||
),
|
),
|
||||||
"total_steps": _coerce_supertonic_steps(
|
"total_steps": _coerce_supertonic_steps(
|
||||||
entry.get("total_steps")
|
entry.get("total_steps")
|
||||||
or entry.get("supertonic_total_steps")
|
or entry.get("supertonic_total_steps")
|
||||||
or entry.get("quality")
|
or entry.get("quality")
|
||||||
),
|
),
|
||||||
"speed": _coerce_supertonic_speed(
|
"speed": _coerce_supertonic_speed(
|
||||||
entry.get("speed") or entry.get("supertonic_speed")
|
entry.get("speed") or entry.get("supertonic_speed")
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
voices = _normalize_voice_entries(entry.get("voices", []))
|
voices = _normalize_voice_entries(entry.get("voices", []))
|
||||||
if not voices:
|
if not voices:
|
||||||
return {}
|
return {}
|
||||||
return {
|
return {
|
||||||
"provider": "kokoro",
|
"provider": "kokoro",
|
||||||
"language": language,
|
"language": language,
|
||||||
"voices": voices,
|
"voices": voices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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_metadata("kokoro").voices
|
kokoro_voices = get_metadata("kokoro").voices
|
||||||
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")
|
||||||
weight = item.get("weight")
|
weight = item.get("weight")
|
||||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
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 kokoro_voices:
|
||||||
continue
|
continue
|
||||||
if weight is None:
|
if weight is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
weight_val = float(weight)
|
weight_val = float(weight)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
if weight_val <= 0:
|
if weight_val <= 0:
|
||||||
continue
|
continue
|
||||||
normalized.append((voice, weight_val))
|
normalized.append((voice, weight_val))
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
||||||
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
||||||
|
|
||||||
return _normalize_voice_entries(entries)
|
return _normalize_voice_entries(entries)
|
||||||
|
|
||||||
|
|
||||||
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
||||||
"""Persist a single profile after validating its data."""
|
"""Persist a single profile after validating its data."""
|
||||||
|
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("Profile name is required")
|
raise ValueError("Profile name is required")
|
||||||
|
|
||||||
normalized = _normalize_voice_entries(voices)
|
normalized = _normalize_voice_entries(voices)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ValueError("At least one voice with a weight above zero is required")
|
raise ValueError("At least one voice with a weight above zero is required")
|
||||||
|
|
||||||
if not language:
|
if not language:
|
||||||
language = "a"
|
language = "a"
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
|
||||||
def remove_profile(name: str) -> None:
|
def remove_profile(name: str) -> None:
|
||||||
delete_profile(name)
|
delete_profile(name)
|
||||||
|
|
||||||
|
|
||||||
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
||||||
"""Merge profiles from a dictionary structure and persist them.
|
"""Merge profiles from a dictionary structure and persist them.
|
||||||
|
|
||||||
Returns the list of profile names that were added or updated.
|
Returns the list of profile names that were added or updated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Invalid profile payload")
|
raise ValueError("Invalid profile payload")
|
||||||
|
|
||||||
if "abogen_voice_profiles" in data:
|
if "abogen_voice_profiles" in data:
|
||||||
data = data["abogen_voice_profiles"]
|
data = data["abogen_voice_profiles"]
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Invalid profile payload")
|
raise ValueError("Invalid profile payload")
|
||||||
|
|
||||||
current = load_profiles()
|
current = load_profiles()
|
||||||
updated: List[str] = []
|
updated: List[str] = []
|
||||||
for name, entry in data.items():
|
for name, entry in data.items():
|
||||||
normalized = normalize_profile_entry(entry)
|
normalized = normalize_profile_entry(entry)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
continue
|
continue
|
||||||
if name in current and not replace_existing:
|
if name in current and not replace_existing:
|
||||||
# skip duplicates unless explicit replacement is requested
|
# skip duplicates unless explicit replacement is requested
|
||||||
continue
|
continue
|
||||||
current[name] = normalized
|
current[name] = normalized
|
||||||
updated.append(name)
|
updated.append(name)
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
save_profiles(current)
|
save_profiles(current)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
||||||
"""Return profiles limited to the provided names for download/export."""
|
"""Return profiles limited to the provided names for download/export."""
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if names is None:
|
if names is None:
|
||||||
subset = profiles
|
subset = profiles
|
||||||
else:
|
else:
|
||||||
subset = {name: profiles[name] for name in names if name in profiles}
|
subset = {name: profiles[name] for name in names if name in profiles}
|
||||||
return {"abogen_voice_profiles": subset}
|
return {"abogen_voice_profiles": subset}
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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))
|
|
||||||
if audio.size == 0:
|
audio = _to_float32(getattr(segment, "audio", None))
|
||||||
continue
|
if audio.size == 0:
|
||||||
|
continue
|
||||||
local_segments += 1
|
|
||||||
if chapter_sink:
|
local_segments += 1
|
||||||
chapter_sink.write(audio)
|
if chapter_sink:
|
||||||
if audio_sink:
|
chapter_sink.write(audio)
|
||||||
audio_sink.write(audio)
|
if audio_sink:
|
||||||
|
audio_sink.write(audio)
|
||||||
duration = len(audio) / SAMPLE_RATE
|
|
||||||
processed_chars += len(graphemes)
|
duration = len(audio) / SAMPLE_RATE
|
||||||
job.processed_characters = processed_chars
|
processed_chars += len(graphemes)
|
||||||
if job.total_characters:
|
job.processed_characters = processed_chars
|
||||||
job.progress = min(processed_chars / job.total_characters, 0.999)
|
if job.total_characters:
|
||||||
else:
|
job.progress = min(processed_chars / job.total_characters, 0.999)
|
||||||
job.progress = 0.0 if processed_chars == 0 else 0.999
|
else:
|
||||||
|
job.progress = 0.0 if processed_chars == 0 else 0.999
|
||||||
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
|
|
||||||
prefix = f"{preview_prefix} · " if preview_prefix else ""
|
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
|
||||||
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}")
|
prefix = f"{preview_prefix} · " if preview_prefix else ""
|
||||||
|
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}")
|
||||||
if subtitle_writer and audio_sink and graphemes:
|
|
||||||
subtitle_writer.write_segment(
|
if subtitle_writer and audio_sink and graphemes:
|
||||||
index=subtitle_index,
|
subtitle_writer.write_segment(
|
||||||
text=graphemes,
|
index=subtitle_index,
|
||||||
start=current_time,
|
text=graphemes,
|
||||||
end=current_time + duration,
|
start=current_time,
|
||||||
)
|
end=current_time + duration,
|
||||||
subtitle_index += 1
|
)
|
||||||
|
subtitle_index += 1
|
||||||
if audio_sink:
|
|
||||||
current_time += duration
|
if audio_sink:
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user