refactor: replace hardcoded backend ID sets with registry checks

Add TTSBackendRegistry.is_registered() and module-level
is_registered_backend() to validate backend IDs dynamically.
Replace all Category A hardcoded sets (validation-only) in
voice_profiles, api routes, conversion_runner, and form utils.
This commit is contained in:
Artem Akymenko
2026-07-08 16:33:16 +00:00
parent f4cb2c2329
commit c4d14112d4
5 changed files with 247 additions and 235 deletions
+10
View File
@@ -30,6 +30,10 @@ class TTSBackendRegistry:
self._backends[metadata.id] = metadata self._backends[metadata.id] = metadata
self._factories[metadata.id] = factory self._factories[metadata.id] = factory
def is_registered(self, backend_id: str) -> bool:
"""Return True if a backend with the given id is registered."""
return backend_id in self._backends
def list_backends(self) -> list[TTSBackendMetadata]: def list_backends(self) -> list[TTSBackendMetadata]:
"""Return metadata for all registered backends.""" """Return metadata for all registered backends."""
return list(self._backends.values()) return list(self._backends.values())
@@ -88,3 +92,9 @@ 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)
+230 -230
View File
@@ -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 -2
View File
@@ -20,7 +20,7 @@ import numpy as np
import soundfile as sf import soundfile as sf
import static_ffmpeg import static_ffmpeg
from abogen.tts_backend_registry import get_metadata from abogen.tts_backend_registry import get_metadata, is_registered_backend
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 (
@@ -1574,7 +1574,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)
+3 -2
View File
@@ -34,6 +34,7 @@ from abogen.normalization_settings import (
) )
from abogen.llm_client import list_models, LLMClientError from abogen.llm_client import list_models, LLMClientError
from abogen.kokoro_text_normalization import normalize_for_pipeline from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.tts_backend_registry import is_registered_backend
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
from abogen.integrations.calibre_opds import ( from abogen.integrations.calibre_opds import (
CalibreOPDSClient, CalibreOPDSClient,
@@ -63,7 +64,7 @@ def api_save_voice_profile() -> ResponseReturnValue:
if profile is None: if profile is None:
# Speaker Studio payload format # Speaker Studio payload format
provider = str(payload.get("provider") or "kokoro").strip().lower() provider = str(payload.get("provider") or "kokoro").strip().lower()
if provider not in {"kokoro", "supertonic"}: if not is_registered_backend(provider):
provider = "kokoro" provider = "kokoro"
if provider == "supertonic": if provider == "supertonic":
profile = { profile = {
@@ -230,7 +231,7 @@ def api_speaker_preview() -> ResponseReturnValue:
use_gpu = settings.get("use_gpu", False) use_gpu = settings.get("use_gpu", False)
base_spec, speaker_name = split_profile_spec(voice) base_spec, speaker_name = split_profile_spec(voice)
resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else "" resolved_provider = tts_provider if is_registered_backend(tts_provider) else ""
if speaker_name: if speaker_name:
entry = normalize_profile_entry(load_profiles().get(speaker_name)) entry = normalize_profile_entry(load_profiles().get(speaker_name))
+2 -1
View File
@@ -7,6 +7,7 @@ from flask.typing import ResponseReturnValue
from abogen.webui.service import PendingJob, JobStatus from abogen.webui.service import PendingJob, JobStatus
from abogen.webui.routes.utils.service import get_service from abogen.webui.routes.utils.service import get_service
from abogen.tts_backend_registry import is_registered_backend
from abogen.webui.routes.utils.settings import ( from abogen.webui.routes.utils.settings import (
load_settings, load_settings,
coerce_bool, coerce_bool,
@@ -579,7 +580,7 @@ def apply_book_step_form(
# spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula). # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
# This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro). # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
provider_value = str(form.get("tts_provider") or "").strip().lower() provider_value = str(form.get("tts_provider") or "").strip().lower()
if provider_value in {"kokoro", "supertonic"}: if is_registered_backend(provider_value):
pending.tts_provider = provider_value pending.tts_provider = provider_value
# Determine the base speaker selection (saved speaker ref or raw voice). # Determine the base speaker selection (saved speaker ref or raw voice).