Compare commits

...
Author SHA1 Message Date
Artem Akymenko f8f72624f8 refactor(webui): replace direct VOICES_INTERNAL/DEFAULT_SUPERTONIC_VOICES with get_metadata API
- Add get_default_voice() helper to tts_backend_registry
- Replace all VOICES_INTERNAL imports in WebUI with get_metadata().voices
- Replace all DEFAULT_SUPERTONIC_VOICES imports in conversion_runner with get_metadata().voices
- Remove unused VOICES_INTERNAL import from voices.py

Core modules (voice_profiles, voice_formulas, voice_cache) already used
get_metadata(). This completes the WebUI migration.
2026-07-08 15:42:49 +00:00
Artem Akymenko e7a88a513a ci: fix duplicate triggers, pin macos-14 to avoid migration warning 2026-07-08 16:52:26 +03:00
Artem AkymenkoandGitHub 2277f16d0a Merge pull request #184 from denizsafak/refactor/use-backend-metadata-for-voice-lists
refactor: migrate core modules to use TTSBackendMetadata.voices via registry
2026-07-08 16:51:58 +03:00
Artem Akymenko 1d50429b87 refactor: migrate core modules to use TTSBackendMetadata.voices via registry
Replace direct imports of VOICES_INTERNAL and DEFAULT_SUPERTONIC_VOICES
in voice_profiles, voice_formulas, and voice_cache with get_metadata()
from TTSBackendRegistry. Adds get_metadata() top-level function to
tts_backend_registry as symmetric counterpart to register_backend() and
create_backend().
2026-07-08 13:43:52 +00:00
Artem Akymenko 29681a5fbb ci: update actions to v7/v6, add pip caching, optimize Dockerfile layer order 2026-07-08 16:22:52 +03:00
Artem AkymenkoandGitHub 50fa2e5b9e Merge pull request #183 from denizsafak/refactor/store-supported-voices-in-backend-metadata
feat: store supported voices in TTSBackendMetadata
2026-07-08 16:02:57 +03:00
Artem Akymenko 5816feb6da feat: store supported voices in TTSBackendMetadata
Add voices field to TTSBackendMetadata so each backend's supported
voice list is part of its metadata rather than external constants.

- Add voices: tuple[str, ...] = () to TTSBackendMetadata
- Create _KOKORO_METADATA / _SUPERTONIC_METADATA as single source
  of truth for both metadata property and registry registration
- Update KokoroBackend.get_available_voices() to use self.metadata.voices
- Update SupertonicBackend.get_available_voices() to use self.metadata.voices
- Add tests for voices field, metadata voice content, and unified instance identity
2026-07-06 17:40:49 +00:00
16 changed files with 358 additions and 281 deletions
+6 -5
View File
@@ -1,7 +1,8 @@
name: pip install name: pip install
run-name: pip install run-name: pip install
on: on:
push: push:
branches: [main]
paths: paths:
- '**.py' - '**.py'
- 'pyproject.toml' - 'pyproject.toml'
@@ -15,18 +16,18 @@ jobs:
install-and-run: install-and-run:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-14, windows-latest]
python-version: ['3.12'] python-version: ['3.12']
fail-fast: false fail-fast: false
continue-on-error: true
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 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 from repository
run: python -m pip install . run: python -m pip install .
#- name: Run abogen #- name: Run abogen
+63 -63
View File
@@ -1,63 +1,63 @@
name: Build multi-arch Docker Image name: Build multi-arch Docker Image
on: on:
# Build and push # Build and push
#release: #release:
# types: [published] # types: [published]
# Build only # Build only
#push: it #push: it
# branches: [main] # branches: [main]
# TODO - enable build on pull requests if build times can be reduced # TODO - enable build on pull requests if build times can be reduced
# pull_request: # pull_request:
workflow_dispatch: workflow_dispatch:
env: env:
IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen IMAGE_REPOSITORY: ghcr.io/denizsafak/abogen
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Login to Github Container Registry - name: Login to Github Container Registry
# Only if we need to push an image # Only if we need to push an image
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }} # if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Setup for buildx # Setup for buildx
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
# Debugging information # Debugging information
- name: Docker info - name: Docker info
run: docker info run: docker info
- name: Buildx inspect - name: Buildx inspect
run: docker buildx inspect run: docker buildx inspect
# Build and (optionally) push the image # Build and (optionally) push the image
- name: Build image - name: Build image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./abogen context: ./abogen
file: ./abogen/Dockerfile file: ./abogen/Dockerfile
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x # platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
# platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46 platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
# Only push if we are publishing a release # Only push if we are publishing a release
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }} # push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
push: true push: true
# Use a 'temp' tag, that won't be pushed, for non-release builds # Use a 'temp' tag, that won't be pushed, for non-release builds
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }} tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
# Use a cache to reduce build times # Use a cache to reduce build times
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
cache-from: type=gha cache-from: type=gha
+2
View File
@@ -18,11 +18,13 @@ class TTSBackendMetadata:
id: Unique backend identifier (e.g. ``"kokoro"``, ``"supertonic"``). id: Unique backend identifier (e.g. ``"kokoro"``, ``"supertonic"``).
name: Human-readable display name. name: Human-readable display name.
description: Short description of the backend. description: Short description of the backend.
voices: Tuple of supported voice identifiers.
""" """
id: str id: str
name: str name: str
description: str description: str
voices: tuple[str, ...] = ()
class TTSBackend(Protocol): class TTSBackend(Protocol):
+19
View File
@@ -66,6 +66,25 @@ def register_backend(
_registry.register(metadata, factory) _registry.register(metadata, factory)
def get_metadata(backend_id: str) -> TTSBackendMetadata:
"""Get metadata for a specific backend by id.
Ensures all backends are registered by importing the tts_backends
package on first access.
Raises:
KeyError: If backend with given id is not registered.
"""
import abogen.tts_backends # noqa: F401 — triggers backend registration
return _registry.get_metadata(backend_id)
def get_default_voice(backend_id: str, fallback: str = "") -> str:
"""Return the first voice of a backend, or *fallback* if none."""
voices = get_metadata(backend_id).voices
return voices[0] if voices else fallback
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)
+14 -17
View File
@@ -10,6 +10,16 @@ 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
_KOKORO_METADATA = TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS engine",
voices=tuple(VOICES_INTERNAL),
)
def _load_kpipeline(): def _load_kpipeline():
"""Lazy-load Kokoro dependencies.""" """Lazy-load Kokoro dependencies."""
@@ -39,14 +49,8 @@ class KokoroBackend:
self._lang_code = lang_code self._lang_code = lang_code
@property @property
def metadata(self): def metadata(self) -> TTSBackendMetadata:
from abogen.tts_backend import TTSBackendMetadata return _KOKORO_METADATA
return TTSBackendMetadata(
id="kokoro",
name="Kokoro",
description="Kokoro TTS engine",
)
def __call__( def __call__(
self, self,
@@ -89,9 +93,7 @@ class KokoroBackend:
def get_available_voices(self) -> List[str]: def get_available_voices(self) -> List[str]:
"""Return known Kokoro voice identifiers.""" """Return known Kokoro voice identifiers."""
from abogen.constants import VOICES_INTERNAL return list(self.metadata.voices)
return list(VOICES_INTERNAL)
def get_supported_formats(self) -> List[str]: def get_supported_formats(self) -> List[str]:
"""Kokoro outputs raw PCM float32 audio.""" """Kokoro outputs raw PCM float32 audio."""
@@ -111,14 +113,9 @@ def create_kokoro_backend(**kwargs: Any) -> KokoroBackend:
# --- Registration --- # --- Registration ---
from abogen.tts_backend import TTSBackendMetadata # noqa: E402
from abogen.tts_backend_registry import register_backend # noqa: E402 from abogen.tts_backend_registry import register_backend # noqa: E402
register_backend( register_backend(
metadata=TTSBackendMetadata( metadata=_KOKORO_METADATA,
id="kokoro",
name="Kokoro",
description="Kokoro TTS engine",
),
factory=create_kokoro_backend, factory=create_kokoro_backend,
) )
+14 -14
View File
@@ -15,6 +15,15 @@ logger = logging.getLogger(__name__)
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
from abogen.tts_backend import TTSBackendMetadata
_SUPERTONIC_METADATA = TTSBackendMetadata(
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS engine",
voices=DEFAULT_SUPERTONIC_VOICES,
)
@dataclass @dataclass
class SupertonicSegment: class SupertonicSegment:
@@ -282,12 +291,8 @@ class SupertonicBackend:
""" """
@property @property
def metadata(self) -> "TTSBackendMetadata": def metadata(self) -> TTSBackendMetadata:
return TTSBackendMetadata( return _SUPERTONIC_METADATA
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS engine",
)
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
self._pipeline = SupertonicPipeline( self._pipeline = SupertonicPipeline(
@@ -333,7 +338,7 @@ class SupertonicBackend:
def get_available_voices(self) -> List[str]: def get_available_voices(self) -> List[str]:
"""Return the list of built-in SuperTonic voice identifiers.""" """Return the list of built-in SuperTonic voice identifiers."""
return list(DEFAULT_SUPERTONIC_VOICES) return list(self.metadata.voices)
def get_supported_formats(self) -> List[str]: def get_supported_formats(self) -> List[str]:
return ["wav"] return ["wav"]
@@ -379,14 +384,9 @@ def create_supertonic_backend(**kwargs: Any) -> SupertonicBackend:
return SupertonicBackend(**kwargs) return SupertonicBackend(**kwargs)
from abogen.tts_backend import TTSBackendMetadata from abogen.tts_backend_registry import register_backend # noqa: E402
from abogen.tts_backend_registry import register_backend
register_backend( register_backend(
metadata=TTSBackendMetadata( metadata=_SUPERTONIC_METADATA,
id="supertonic",
name="SuperTonic",
description="SuperTonic TTS engine",
),
factory=create_supertonic_backend, factory=create_supertonic_backend,
) )
+4 -3
View File
@@ -17,7 +17,7 @@ if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests
pass pass
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
_CACHE_LOCK = threading.Lock() _CACHE_LOCK = threading.Lock()
_CACHED_VOICES: Set[str] = set() _CACHED_VOICES: Set[str] = set()
@@ -26,8 +26,9 @@ _BOOTSTRAPPED = False
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
kokoro_voices = get_metadata("kokoro").voices
if not voices: if not voices:
return set(VOICES_INTERNAL) return set(kokoro_voices)
normalized: Set[str] = set() normalized: Set[str] = set()
for voice in voices: for voice in voices:
if not voice: if not voice:
@@ -35,7 +36,7 @@ def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
voice_id = str(voice).strip() voice_id = str(voice).strip()
if not voice_id: if not voice_id:
continue continue
if voice_id in VOICES_INTERNAL: if voice_id in kokoro_voices:
normalized.add(voice_id) normalized.add(voice_id)
return normalized return normalized
+3 -2
View File
@@ -1,7 +1,7 @@
import re import re
from typing import List, Tuple from typing import List, Tuple
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
# Calls parsing and loads the voice to gpu or cpu # Calls parsing and loads the voice to gpu or cpu
@@ -22,6 +22,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
raise ValueError("Empty voice formula") raise ValueError("Empty voice formula")
terms: List[Tuple[str, float]] = [] terms: List[Tuple[str, float]] = []
kokoro_voices = get_metadata("kokoro").voices
for segment in formula.split("+"): for segment in formula.split("+"):
part = segment.strip() part = segment.strip()
if not part: if not part:
@@ -30,7 +31,7 @@ def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
raise ValueError("Each component must be in the form voice*weight") raise ValueError("Each component must be in the form voice*weight")
voice_name, raw_weight = part.split("*", 1) voice_name, raw_weight = part.split("*", 1)
voice_name = voice_name.strip() voice_name = voice_name.strip()
if voice_name not in VOICES_INTERNAL: if voice_name not in kokoro_voices:
raise ValueError(f"Unknown voice: {voice_name}") raise ValueError(f"Unknown voice: {voice_name}")
try: try:
weight = float(raw_weight.strip()) weight = float(raw_weight.strip())
+5 -4
View File
@@ -2,8 +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.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
from abogen.tts_backends.supertonic import DEFAULT_SUPERTONIC_VOICES
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
@@ -70,7 +69,8 @@ def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
def _normalize_supertonic_voice(value: Any) -> str: def _normalize_supertonic_voice(value: Any) -> str:
raw = str(value or "").strip().upper() raw = str(value or "").strip().upper()
return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1" supertonic_voices = get_metadata("supertonic").voices
return raw if raw in supertonic_voices else "M1"
def _coerce_supertonic_steps(value: Any) -> int: def _coerce_supertonic_steps(value: Any) -> int:
@@ -135,6 +135,7 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]:
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
normalized: List[Tuple[str, float]] = [] normalized: List[Tuple[str, float]] = []
kokoro_voices = get_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")
@@ -143,7 +144,7 @@ def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
voice, weight = item[0], item[1] voice, weight = item[0], item[1]
else: else:
continue continue
if voice not in VOICES_INTERNAL: if voice not in kokoro_voices:
continue continue
if weight is None: if weight is None:
continue continue
+2 -2
View File
@@ -27,8 +27,6 @@ RUN python3 -m venv "$VIRTUAL_ENV"
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md ./ COPY pyproject.toml README.md ./
COPY abogen ./abogen
RUN pip install --upgrade pip \ RUN pip install --upgrade pip \
&& 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"; \ pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
@@ -39,6 +37,8 @@ RUN pip install --upgrade pip \
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" && pip install --no-cache-dir "mutagen>=1.47.0"
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 \
+11 -11
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.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_metadata
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 (
@@ -47,7 +47,7 @@ from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.voice_profiles import load_profiles, normalize_profile_entry from abogen.voice_profiles import load_profiles, normalize_profile_entry
from abogen.pronunciation_store import increment_usage from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError from abogen.llm_client import LLMClientError
from abogen.tts_backends.supertonic import DEFAULT_SUPERTONIC_VOICES
from .service import Job, JobStatus from .service import Job, JobStatus
@@ -68,11 +68,11 @@ def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
raw = "M1" raw = "M1"
upper = raw.upper() upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES: if upper in get_metadata("supertonic").voices:
return upper return upper
fallback_upper = fallback_raw.upper() if fallback_raw else "" fallback_upper = fallback_raw.upper() if fallback_raw else ""
if fallback_upper in DEFAULT_SUPERTONIC_VOICES: if fallback_upper in get_metadata("supertonic").voices:
return fallback_upper return fallback_upper
return "M1" return "M1"
@@ -123,7 +123,7 @@ def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
if not raw: if not raw:
return fallback return fallback
upper = raw.upper() upper = raw.upper()
if upper in DEFAULT_SUPERTONIC_VOICES: if upper in get_metadata("supertonic").voices:
return "supertonic" return "supertonic"
if "*" in raw or "+" in raw: if "*" in raw or "+" in raw:
return "kokoro" return "kokoro"
@@ -576,7 +576,7 @@ def _spec_to_voice_ids(spec: Any) -> Set[str]:
return set(extract_voice_ids(text)) return set(extract_voice_ids(text))
except ValueError: except ValueError:
return set() return set()
if text in VOICES_INTERNAL: if text in get_metadata("kokoro").voices:
return {text} return {text}
return set() return set()
@@ -640,7 +640,7 @@ def _collect_required_voice_ids(job: Job) -> Set[str]:
for key in ("resolved_voice", "voice_formula", "voice"): for key in ("resolved_voice", "voice_formula", "voice"):
voices.update(_spec_to_voice_ids(payload.get(key))) voices.update(_spec_to_voice_ids(payload.get(key)))
voices.update(VOICES_INTERNAL) voices.update(get_metadata("kokoro").voices)
return voices return voices
@@ -1803,8 +1803,8 @@ def run_conversion_job(job: Job) -> None:
fallback_key = next(iter(voice_cache.keys()), "") fallback_key = next(iter(voice_cache.keys()), "")
if fallback_key and fallback_key != "__custom_mix": if fallback_key and fallback_key != "__custom_mix":
intro_voice_spec = fallback_key.split(":", 1)[-1] intro_voice_spec = fallback_key.split(":", 1)[-1]
if not intro_voice_spec and VOICES_INTERNAL: if not intro_voice_spec:
intro_voice_spec = VOICES_INTERNAL[0] intro_voice_spec = get_default_voice("kokoro")
if intro_voice_spec: if intro_voice_spec:
intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice( intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice(
@@ -2237,8 +2237,8 @@ def run_conversion_job(job: Job) -> None:
if fallback_key and fallback_key != "__custom_mix": if fallback_key and fallback_key != "__custom_mix":
# `voice_cache` keys are internal and include provider prefixes. # `voice_cache` keys are internal and include provider prefixes.
outro_voice_spec = fallback_key.split(":", 1)[-1] outro_voice_spec = fallback_key.split(":", 1)[-1]
if not outro_voice_spec and VOICES_INTERNAL: if not outro_voice_spec:
outro_voice_spec = VOICES_INTERNAL[0] outro_voice_spec = get_default_voice("kokoro")
if outro_text and outro_voice_spec: if outro_text and outro_voice_spec:
outro_start_time = current_time outro_start_time = current_time
+5 -5
View File
@@ -32,7 +32,7 @@ from abogen.webui.routes.utils.common import split_profile_spec
from abogen.utils import calculate_text_length from abogen.utils import calculate_text_length
from abogen.voice_profiles import serialize_profiles, normalize_profile_entry from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
from abogen.chunking import ChunkLevel, build_chunks_for_chapters from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.constants import VOICES_INTERNAL from abogen.tts_backend_registry import get_default_voice
from abogen.speaker_configs import get_config from abogen.speaker_configs import get_config
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
from dataclasses import dataclass from dataclasses import dataclass
@@ -616,8 +616,8 @@ def apply_book_step_form(
custom_formula = "" custom_formula = ""
base_voice_spec = resolved_default_voice or narrator_voice_raw base_voice_spec = resolved_default_voice or narrator_voice_raw
if not base_voice_spec and VOICES_INTERNAL: if not base_voice_spec:
base_voice_spec = VOICES_INTERNAL[0] base_voice_spec = get_default_voice("kokoro")
voice_choice, resolved_language, selected_profile = resolve_voice_choice( voice_choice, resolved_language, selected_profile = resolve_voice_choice(
pending.language, pending.language,
@@ -796,8 +796,8 @@ def build_pending_job_from_extraction(
profile_selection = inferred_profile profile_selection = inferred_profile
base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip() base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
if not base_voice and VOICES_INTERNAL: if not base_voice:
base_voice = VOICES_INTERNAL[0] base_voice = get_default_voice("kokoro")
selected_speaker_config = (form.get("speaker_config") or "").strip() selected_speaker_config = (form.get("speaker_config") or "").strip()
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
+2 -2
View File
@@ -6,8 +6,8 @@ from abogen.constants import (
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
) )
from abogen.tts_backend_registry import get_default_voice
from abogen.normalization_settings import ( from abogen.normalization_settings import (
DEFAULT_LLM_PROMPT, DEFAULT_LLM_PROMPT,
environment_llm_defaults, environment_llm_defaults,
@@ -174,7 +174,7 @@ def settings_defaults() -> Dict[str, Any]:
"subtitle_format": "srt", "subtitle_format": "srt",
"save_mode": "default_output" if has_output_override() else "save_next_to_input", "save_mode": "default_output" if has_output_override() else "save_next_to_input",
"default_speaker": "", "default_speaker": "",
"default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", "default_voice": get_default_voice("kokoro"),
"supertonic_total_steps": 5, "supertonic_total_steps": 5,
"supertonic_speed": 1.0, "supertonic_speed": 1.0,
"replace_single_newlines": False, "replace_single_newlines": False,
+3 -3
View File
@@ -17,8 +17,8 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS, SUPPORTED_SOUND_FORMATS,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
SAMPLE_VOICE_TEXTS, SAMPLE_VOICE_TEXTS,
VOICES_INTERNAL,
) )
from abogen.tts_backend_registry import get_metadata
from abogen.speaker_configs import list_configs from abogen.speaker_configs import list_configs
from abogen.tts_backend_registry import create_backend from abogen.tts_backend_registry import create_backend
from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
@@ -285,7 +285,7 @@ def filter_voice_catalog(
def build_voice_catalog() -> List[Dict[str, str]]: def build_voice_catalog() -> List[Dict[str, str]]:
catalog: List[Dict[str, str]] = [] catalog: List[Dict[str, str]] = []
gender_map = {"f": "Female", "m": "Male"} gender_map = {"f": "Female", "m": "Male"}
for voice_id in VOICES_INTERNAL: for voice_id in get_metadata("kokoro").voices:
prefix, _, rest = voice_id.partition("_") prefix, _, rest = voice_id.partition("_")
language_code = prefix[0] if prefix else "a" language_code = prefix[0] if prefix else "a"
gender_code = prefix[1] if len(prefix) > 1 else "" gender_code = prefix[1] if len(prefix) > 1 else ""
@@ -590,7 +590,7 @@ def template_options() -> Dict[str, Any]:
voice_catalog = build_voice_catalog() voice_catalog = build_voice_catalog()
return { return {
"languages": LANGUAGE_DESCRIPTIONS, "languages": LANGUAGE_DESCRIPTIONS,
"voices": VOICES_INTERNAL, "voices": get_metadata("kokoro").voices,
"subtitle_formats": SUBTITLE_FORMATS, "subtitle_formats": SUBTITLE_FORMATS,
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
"output_formats": SUPPORTED_SOUND_FORMATS, "output_formats": SUPPORTED_SOUND_FORMATS,
+1 -1
View File
@@ -17,7 +17,7 @@ from abogen.speaker_configs import (
save_configs, save_configs,
delete_config, delete_config,
) )
from abogen.constants import VOICES_INTERNAL
voices_bp = Blueprint("voices", __name__) voices_bp = Blueprint("voices", __name__)
+204 -149
View File
@@ -1,149 +1,204 @@
from dataclasses import dataclass from dataclasses import dataclass
from abogen.tts_backend import TTSBackendMetadata from abogen.tts_backend import TTSBackendMetadata
from abogen.tts_backend_registry import TTSBackendRegistry from abogen.tts_backend_registry import TTSBackendRegistry
class TestTTSBackendMetadata: class TestTTSBackendMetadata:
def test_is_frozen_dataclass(self): def test_is_frozen_dataclass(self):
assert dataclass(TTSBackendMetadata) assert dataclass(TTSBackendMetadata)
def test_fields_are_present(self): def test_fields_are_present(self):
meta = TTSBackendMetadata( meta = TTSBackendMetadata(
id="test", id="test",
name="Test Backend", name="Test Backend",
description="A test backend", description="A test backend",
) )
assert meta.id == "test" assert meta.id == "test"
assert meta.name == "Test Backend" assert meta.name == "Test Backend"
assert meta.description == "A test backend" assert meta.description == "A test backend"
def test_is_immutable(self): def test_voices_field_default_empty(self):
import pytest meta = TTSBackendMetadata(
id="test",
meta = TTSBackendMetadata( name="Test",
id="kokoro", description="Test backend",
name="Kokoro", )
description="Test", assert meta.voices == ()
)
with pytest.raises(Exception): def test_voices_field_stored(self):
meta.id = "changed" meta = TTSBackendMetadata(
id="test",
name="Test",
class TestTTSBackendRegistry: description="Test backend",
def test_register_and_list(self): voices=("v1", "v2"),
registry = TTSBackendRegistry() )
meta = TTSBackendMetadata(id="a", name="A", description="Backend A") assert meta.voices == ("v1", "v2")
registry.register(metadata=meta, factory=lambda: None)
def test_is_immutable(self):
backends = registry.list_backends() import pytest
assert len(backends) == 1
assert backends[0].id == "a" meta = TTSBackendMetadata(
id="kokoro",
def test_list_multiple(self): name="Kokoro",
registry = TTSBackendRegistry() description="Test",
meta_a = TTSBackendMetadata(id="a", name="A", description="A") )
meta_b = TTSBackendMetadata(id="b", name="B", description="B") with pytest.raises(Exception):
registry.register(metadata=meta_a, factory=lambda: None) meta.id = "changed"
registry.register(metadata=meta_b, factory=lambda: None)
backends = registry.list_backends() class TestTTSBackendRegistry:
ids = [b.id for b in backends] def test_register_and_list(self):
assert "a" in ids registry = TTSBackendRegistry()
assert "b" in ids meta = TTSBackendMetadata(id="a", name="A", description="Backend A")
registry.register(metadata=meta, factory=lambda: None)
def test_get_metadata(self):
registry = TTSBackendRegistry() backends = registry.list_backends()
meta = TTSBackendMetadata(id="x", name="X", description="X backend") assert len(backends) == 1
registry.register(metadata=meta, factory=lambda: None) assert backends[0].id == "a"
result = registry.get_metadata("x") def test_list_multiple(self):
assert result.id == "x" registry = TTSBackendRegistry()
assert result.name == "X" meta_a = TTSBackendMetadata(id="a", name="A", description="A")
meta_b = TTSBackendMetadata(id="b", name="B", description="B")
def test_get_metadata_unknown_raises(self): registry.register(metadata=meta_a, factory=lambda: None)
import pytest registry.register(metadata=meta_b, factory=lambda: None)
registry = TTSBackendRegistry() backends = registry.list_backends()
with pytest.raises(KeyError, match="Unknown backend: nope"): ids = [b.id for b in backends]
registry.get_metadata("nope") assert "a" in ids
assert "b" in ids
def test_create_backend(self):
registry = TTSBackendRegistry() def test_get_metadata(self):
meta = TTSBackendMetadata(id="test", name="Test", description="Test backend") registry = TTSBackendRegistry()
meta = TTSBackendMetadata(id="x", name="X", description="X backend")
def factory(**kwargs): registry.register(metadata=meta, factory=lambda: None)
return {"created": True, "kwargs": kwargs}
result = registry.get_metadata("x")
registry.register(metadata=meta, factory=factory) assert result.id == "x"
result = registry.create_backend("test", foo="bar") assert result.name == "X"
assert result == {"created": True, "kwargs": {"foo": "bar"}} def test_get_metadata_unknown_raises(self):
import pytest
def test_create_backend_unknown_raises(self):
import pytest registry = TTSBackendRegistry()
with pytest.raises(KeyError, match="Unknown backend: nope"):
registry = TTSBackendRegistry() registry.get_metadata("nope")
with pytest.raises(KeyError, match="Unknown backend: missing"):
registry.create_backend("missing") def test_create_backend(self):
registry = TTSBackendRegistry()
def test_register_overwrites(self): meta = TTSBackendMetadata(id="test", name="Test", description="Test backend")
registry = TTSBackendRegistry()
meta1 = TTSBackendMetadata(id="x", name="V1", description="First") def factory(**kwargs):
meta2 = TTSBackendMetadata(id="x", name="V2", description="Second") return {"created": True, "kwargs": kwargs}
registry.register(metadata=meta1, factory=lambda: "v1")
registry.register(metadata=meta2, factory=lambda: "v2") registry.register(metadata=meta, factory=factory)
result = registry.create_backend("test", foo="bar")
result = registry.get_metadata("x")
assert result.name == "V2" assert result == {"created": True, "kwargs": {"foo": "bar"}}
assert registry.create_backend("x") == "v2"
def test_create_backend_unknown_raises(self):
import pytest
class TestBackendRegistration:
"""Tests that existing backends are auto-registered.""" registry = TTSBackendRegistry()
with pytest.raises(KeyError, match="Unknown backend: missing"):
def test_import_triggers_registration(self): registry.create_backend("missing")
import abogen.tts_backends # noqa: F401
def test_register_overwrites(self):
from abogen.tts_backend_registry import _registry registry = TTSBackendRegistry()
meta1 = TTSBackendMetadata(id="x", name="V1", description="First")
backends = _registry.list_backends() meta2 = TTSBackendMetadata(id="x", name="V2", description="Second")
ids = [b.id for b in backends] registry.register(metadata=meta1, factory=lambda: "v1")
assert "kokoro" in ids registry.register(metadata=meta2, factory=lambda: "v2")
assert "supertonic" in ids
result = registry.get_metadata("x")
def test_kokoro_metadata(self): assert result.name == "V2"
import abogen.tts_backends # noqa: F401 assert registry.create_backend("x") == "v2"
from abogen.tts_backend_registry import _registry
class TestBackendRegistration:
meta = _registry.get_metadata("kokoro") """Tests that existing backends are auto-registered."""
assert meta.id == "kokoro"
assert meta.name == "Kokoro" def test_import_triggers_registration(self):
assert "Kokoro" in meta.description import abogen.tts_backends # noqa: F401
def test_supertonic_metadata(self): from abogen.tts_backend_registry import _registry
import abogen.tts_backends # noqa: F401
backends = _registry.list_backends()
from abogen.tts_backend_registry import _registry ids = [b.id for b in backends]
assert "kokoro" in ids
meta = _registry.get_metadata("supertonic") assert "supertonic" in ids
assert meta.id == "supertonic"
assert meta.name == "SuperTonic" def test_kokoro_metadata(self):
assert "SuperTonic" in meta.description import abogen.tts_backends # noqa: F401
def test_kokoro_factory_callable(self): from abogen.tts_backend_registry import _registry
import abogen.tts_backends # noqa: F401
meta = _registry.get_metadata("kokoro")
from abogen.tts_backend_registry import _registry assert meta.id == "kokoro"
assert meta.name == "Kokoro"
factory = _registry._factories["kokoro"] assert "Kokoro" in meta.description
assert callable(factory)
def test_supertonic_metadata(self):
def test_supertonic_factory_callable(self): import abogen.tts_backends # noqa: F401
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("supertonic")
factory = _registry._factories["supertonic"] assert meta.id == "supertonic"
assert callable(factory) assert meta.name == "SuperTonic"
assert "SuperTonic" in meta.description
def test_kokoro_metadata_has_voices(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("kokoro")
assert isinstance(meta.voices, tuple)
assert len(meta.voices) > 0
assert all(isinstance(v, str) for v in meta.voices)
def test_supertonic_metadata_has_voices(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
meta = _registry.get_metadata("supertonic")
assert isinstance(meta.voices, tuple)
assert len(meta.voices) == 10
assert meta.voices == ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
def test_kokoro_factory_callable(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
factory = _registry._factories["kokoro"]
assert callable(factory)
def test_supertonic_factory_callable(self):
import abogen.tts_backends # noqa: F401
from abogen.tts_backend_registry import _registry
factory = _registry._factories["supertonic"]
assert callable(factory)
def test_kokoro_metadata_voices_match_registry(self):
"""Ensure the metadata property on the instance shares voices with registry."""
from abogen.tts_backends.kokoro import _KOKORO_METADATA
from abogen.tts_backend_registry import _registry
registry_meta = _registry.get_metadata("kokoro")
assert _KOKORO_METADATA is registry_meta
assert _KOKORO_METADATA.voices == registry_meta.voices
def test_supertonic_metadata_voices_match_registry(self):
"""Ensure the metadata property on the instance shares voices with registry."""
from abogen.tts_backends.supertonic import _SUPERTONIC_METADATA
from abogen.tts_backend_registry import _registry
registry_meta = _registry.get_metadata("supertonic")
assert _SUPERTONIC_METADATA is registry_meta
assert _SUPERTONIC_METADATA.voices == registry_meta.voices