diff --git a/abogen/tts_backend_registry.py b/abogen/tts_backend_registry.py index bcf8b24..6280f1d 100644 --- a/abogen/tts_backend_registry.py +++ b/abogen/tts_backend_registry.py @@ -58,6 +58,33 @@ class TTSBackendRegistry: raise KeyError(f"Unknown backend: {backend_id}") 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() @@ -98,3 +125,22 @@ 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) diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py index bda17d9..fac13a2 100644 --- a/abogen/webui/conversion_runner.py +++ b/abogen/webui/conversion_runner.py @@ -20,7 +20,7 @@ import numpy as np import soundfile as sf import static_ffmpeg -from abogen.tts_backend_registry import get_metadata, is_registered_backend +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.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS from abogen.normalization_settings import ( @@ -57,25 +57,26 @@ SAMPLE_RATE = 24000 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() fallback_raw = str(fallback or "").strip() - # SuperTonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix - # formula (contains '*' or '+'), ignore it and fall back to a safe voice. - if not raw or "*" in raw or "+" in raw: - raw = fallback_raw - if not raw or "*" in raw or "+" in raw: - raw = "M1" + # Normalize to uppercase for Supertonic voice IDs + upper = raw.upper() if raw else "" - upper = raw.upper() - if upper in get_metadata("supertonic").voices: - return upper + # If empty or contains formula characters, use fallback + if not upper or "*" in upper or "+" in upper: + upper = fallback_raw.upper() if fallback_raw else "" - fallback_upper = fallback_raw.upper() if fallback_raw else "" - if fallback_upper in get_metadata("supertonic").voices: - return fallback_upper + # If still empty, use default Supertonic voice + if not upper or "*" in upper or "+" in upper: + upper = "M1" - return "M1" + return upper 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: - raw = str(value or "").strip() - 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 + return resolve_backend_for_voice(str(value or ""), fallback=fallback) class _JobCancelled(Exception): diff --git a/tests/test_tts_backend.py b/tests/test_tts_backend.py index 54eb2c4..bbd318d 100644 --- a/tests/test_tts_backend.py +++ b/tests/test_tts_backend.py @@ -202,3 +202,113 @@ class TestBackendRegistration: registry_meta = _registry.get_metadata("supertonic") assert _SUPERTONIC_METADATA is registry_meta 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"