diff --git a/abogen/tts_backend.py b/abogen/tts_backend.py index dd8d471..24caafd 100644 --- a/abogen/tts_backend.py +++ b/abogen/tts_backend.py @@ -18,11 +18,13 @@ class TTSBackendMetadata: id: Unique backend identifier (e.g. ``"kokoro"``, ``"supertonic"``). name: Human-readable display name. description: Short description of the backend. + voices: Tuple of supported voice identifiers. """ id: str name: str description: str + voices: tuple[str, ...] = () class TTSBackend(Protocol): diff --git a/abogen/tts_backends/kokoro.py b/abogen/tts_backends/kokoro.py index e5fa892..1bab1a4 100644 --- a/abogen/tts_backends/kokoro.py +++ b/abogen/tts_backends/kokoro.py @@ -10,6 +10,16 @@ from typing import Any, Dict, Iterator, List, Optional 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(): """Lazy-load Kokoro dependencies.""" @@ -39,14 +49,8 @@ class KokoroBackend: self._lang_code = lang_code @property - def metadata(self): - from abogen.tts_backend import TTSBackendMetadata - - return TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Kokoro TTS engine", - ) + def metadata(self) -> TTSBackendMetadata: + return _KOKORO_METADATA def __call__( self, @@ -89,9 +93,7 @@ class KokoroBackend: def get_available_voices(self) -> List[str]: """Return known Kokoro voice identifiers.""" - from abogen.constants import VOICES_INTERNAL - - return list(VOICES_INTERNAL) + return list(self.metadata.voices) def get_supported_formats(self) -> List[str]: """Kokoro outputs raw PCM float32 audio.""" @@ -111,14 +113,9 @@ def create_kokoro_backend(**kwargs: Any) -> KokoroBackend: # --- Registration --- -from abogen.tts_backend import TTSBackendMetadata # noqa: E402 from abogen.tts_backend_registry import register_backend # noqa: E402 register_backend( - metadata=TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Kokoro TTS engine", - ), + metadata=_KOKORO_METADATA, factory=create_kokoro_backend, ) diff --git a/abogen/tts_backends/supertonic.py b/abogen/tts_backends/supertonic.py index 07dd897..eaccfc5 100644 --- a/abogen/tts_backends/supertonic.py +++ b/abogen/tts_backends/supertonic.py @@ -15,6 +15,15 @@ logger = logging.getLogger(__name__) 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 class SupertonicSegment: @@ -282,12 +291,8 @@ class SupertonicBackend: """ @property - def metadata(self) -> "TTSBackendMetadata": - return TTSBackendMetadata( - id="supertonic", - name="SuperTonic", - description="SuperTonic TTS engine", - ) + def metadata(self) -> TTSBackendMetadata: + return _SUPERTONIC_METADATA def __init__(self, **kwargs: Any) -> None: self._pipeline = SupertonicPipeline( @@ -333,7 +338,7 @@ class SupertonicBackend: def get_available_voices(self) -> List[str]: """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]: return ["wav"] @@ -379,14 +384,9 @@ def create_supertonic_backend(**kwargs: Any) -> SupertonicBackend: return SupertonicBackend(**kwargs) -from abogen.tts_backend import TTSBackendMetadata -from abogen.tts_backend_registry import register_backend +from abogen.tts_backend_registry import register_backend # noqa: E402 register_backend( - metadata=TTSBackendMetadata( - id="supertonic", - name="SuperTonic", - description="SuperTonic TTS engine", - ), + metadata=_SUPERTONIC_METADATA, factory=create_supertonic_backend, ) diff --git a/tests/test_tts_backend.py b/tests/test_tts_backend.py index c6cf4c1..54eb2c4 100644 --- a/tests/test_tts_backend.py +++ b/tests/test_tts_backend.py @@ -1,149 +1,204 @@ -from dataclasses import dataclass - -from abogen.tts_backend import TTSBackendMetadata -from abogen.tts_backend_registry import TTSBackendRegistry - - -class TestTTSBackendMetadata: - def test_is_frozen_dataclass(self): - assert dataclass(TTSBackendMetadata) - - def test_fields_are_present(self): - meta = TTSBackendMetadata( - id="test", - name="Test Backend", - description="A test backend", - ) - assert meta.id == "test" - assert meta.name == "Test Backend" - assert meta.description == "A test backend" - - def test_is_immutable(self): - import pytest - - meta = TTSBackendMetadata( - id="kokoro", - name="Kokoro", - description="Test", - ) - with pytest.raises(Exception): - meta.id = "changed" - - -class TestTTSBackendRegistry: - def test_register_and_list(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata(id="a", name="A", description="Backend A") - registry.register(metadata=meta, factory=lambda: None) - - backends = registry.list_backends() - assert len(backends) == 1 - assert backends[0].id == "a" - - def test_list_multiple(self): - registry = TTSBackendRegistry() - meta_a = TTSBackendMetadata(id="a", name="A", description="A") - meta_b = TTSBackendMetadata(id="b", name="B", description="B") - registry.register(metadata=meta_a, factory=lambda: None) - registry.register(metadata=meta_b, factory=lambda: None) - - backends = registry.list_backends() - ids = [b.id for b in backends] - assert "a" in ids - assert "b" in ids - - def test_get_metadata(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata(id="x", name="X", description="X backend") - registry.register(metadata=meta, factory=lambda: None) - - result = registry.get_metadata("x") - assert result.id == "x" - assert result.name == "X" - - def test_get_metadata_unknown_raises(self): - import pytest - - registry = TTSBackendRegistry() - with pytest.raises(KeyError, match="Unknown backend: nope"): - registry.get_metadata("nope") - - def test_create_backend(self): - registry = TTSBackendRegistry() - meta = TTSBackendMetadata(id="test", name="Test", description="Test backend") - - def factory(**kwargs): - return {"created": True, "kwargs": kwargs} - - registry.register(metadata=meta, factory=factory) - result = registry.create_backend("test", foo="bar") - - assert result == {"created": True, "kwargs": {"foo": "bar"}} - - def test_create_backend_unknown_raises(self): - import pytest - - registry = TTSBackendRegistry() - with pytest.raises(KeyError, match="Unknown backend: missing"): - registry.create_backend("missing") - - def test_register_overwrites(self): - registry = TTSBackendRegistry() - meta1 = TTSBackendMetadata(id="x", name="V1", description="First") - meta2 = TTSBackendMetadata(id="x", name="V2", description="Second") - registry.register(metadata=meta1, factory=lambda: "v1") - registry.register(metadata=meta2, factory=lambda: "v2") - - result = registry.get_metadata("x") - assert result.name == "V2" - assert registry.create_backend("x") == "v2" - - -class TestBackendRegistration: - """Tests that existing backends are auto-registered.""" - - def test_import_triggers_registration(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - backends = _registry.list_backends() - ids = [b.id for b in backends] - assert "kokoro" in ids - assert "supertonic" in ids - - def test_kokoro_metadata(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("kokoro") - assert meta.id == "kokoro" - assert meta.name == "Kokoro" - assert "Kokoro" in meta.description - - def test_supertonic_metadata(self): - import abogen.tts_backends # noqa: F401 - - from abogen.tts_backend_registry import _registry - - meta = _registry.get_metadata("supertonic") - assert meta.id == "supertonic" - assert meta.name == "SuperTonic" - assert "SuperTonic" in meta.description - - 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) +from dataclasses import dataclass + +from abogen.tts_backend import TTSBackendMetadata +from abogen.tts_backend_registry import TTSBackendRegistry + + +class TestTTSBackendMetadata: + def test_is_frozen_dataclass(self): + assert dataclass(TTSBackendMetadata) + + def test_fields_are_present(self): + meta = TTSBackendMetadata( + id="test", + name="Test Backend", + description="A test backend", + ) + assert meta.id == "test" + assert meta.name == "Test Backend" + assert meta.description == "A test backend" + + def test_voices_field_default_empty(self): + meta = TTSBackendMetadata( + id="test", + name="Test", + description="Test backend", + ) + assert meta.voices == () + + def test_voices_field_stored(self): + meta = TTSBackendMetadata( + id="test", + name="Test", + description="Test backend", + voices=("v1", "v2"), + ) + assert meta.voices == ("v1", "v2") + + def test_is_immutable(self): + import pytest + + meta = TTSBackendMetadata( + id="kokoro", + name="Kokoro", + description="Test", + ) + with pytest.raises(Exception): + meta.id = "changed" + + +class TestTTSBackendRegistry: + def test_register_and_list(self): + registry = TTSBackendRegistry() + meta = TTSBackendMetadata(id="a", name="A", description="Backend A") + registry.register(metadata=meta, factory=lambda: None) + + backends = registry.list_backends() + assert len(backends) == 1 + assert backends[0].id == "a" + + def test_list_multiple(self): + registry = TTSBackendRegistry() + meta_a = TTSBackendMetadata(id="a", name="A", description="A") + meta_b = TTSBackendMetadata(id="b", name="B", description="B") + registry.register(metadata=meta_a, factory=lambda: None) + registry.register(metadata=meta_b, factory=lambda: None) + + backends = registry.list_backends() + ids = [b.id for b in backends] + assert "a" in ids + assert "b" in ids + + def test_get_metadata(self): + registry = TTSBackendRegistry() + meta = TTSBackendMetadata(id="x", name="X", description="X backend") + registry.register(metadata=meta, factory=lambda: None) + + result = registry.get_metadata("x") + assert result.id == "x" + assert result.name == "X" + + def test_get_metadata_unknown_raises(self): + import pytest + + registry = TTSBackendRegistry() + with pytest.raises(KeyError, match="Unknown backend: nope"): + registry.get_metadata("nope") + + def test_create_backend(self): + registry = TTSBackendRegistry() + meta = TTSBackendMetadata(id="test", name="Test", description="Test backend") + + def factory(**kwargs): + return {"created": True, "kwargs": kwargs} + + registry.register(metadata=meta, factory=factory) + result = registry.create_backend("test", foo="bar") + + assert result == {"created": True, "kwargs": {"foo": "bar"}} + + def test_create_backend_unknown_raises(self): + import pytest + + registry = TTSBackendRegistry() + with pytest.raises(KeyError, match="Unknown backend: missing"): + registry.create_backend("missing") + + def test_register_overwrites(self): + registry = TTSBackendRegistry() + meta1 = TTSBackendMetadata(id="x", name="V1", description="First") + meta2 = TTSBackendMetadata(id="x", name="V2", description="Second") + registry.register(metadata=meta1, factory=lambda: "v1") + registry.register(metadata=meta2, factory=lambda: "v2") + + result = registry.get_metadata("x") + assert result.name == "V2" + assert registry.create_backend("x") == "v2" + + +class TestBackendRegistration: + """Tests that existing backends are auto-registered.""" + + def test_import_triggers_registration(self): + import abogen.tts_backends # noqa: F401 + + from abogen.tts_backend_registry import _registry + + backends = _registry.list_backends() + ids = [b.id for b in backends] + assert "kokoro" in ids + assert "supertonic" in ids + + def test_kokoro_metadata(self): + import abogen.tts_backends # noqa: F401 + + from abogen.tts_backend_registry import _registry + + meta = _registry.get_metadata("kokoro") + assert meta.id == "kokoro" + assert meta.name == "Kokoro" + assert "Kokoro" in meta.description + + def test_supertonic_metadata(self): + import abogen.tts_backends # noqa: F401 + + from abogen.tts_backend_registry import _registry + + meta = _registry.get_metadata("supertonic") + assert meta.id == "supertonic" + 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