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
This commit is contained in:
Artem Akymenko
2026-07-06 17:40:49 +00:00
parent b95df8f217
commit 5816feb6da
4 changed files with 234 additions and 180 deletions
+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):
+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,
) )
+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