mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
refactor(cleanup): remove Legacy TTS Architecture
Delete legacy backend infrastructure: - abogen/tts_backend.py (TTSBackend protocol, TTSBackendMetadata) - abogen/tts_backend_registry.py (TTSBackendRegistry, global singleton, register_backend) - abogen/tts_backends/ (kokoro.py, supertonic.py, __init__.py) Delete legacy tests: - tests/test_tts_backend.py - tests/test_kokoro_backend.py - tests/test_voice_formula_resolution.py - tests/test_tts_supertonic_unsupported_chars.py Production code now uses only Plugin Architecture via create_pipeline(). All contract, behavioral, and integration tests pass. 2 pre-existing failures in test_supertonic_plugin.py (mock engine mismatch).
This commit is contained in:
@@ -1,89 +0,0 @@
|
|||||||
"""
|
|
||||||
TTS Backend Interface
|
|
||||||
|
|
||||||
This module defines the protocol for TTS backends and the
|
|
||||||
metadata model that describes a backend implementation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol, List, Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TTSBackendMetadata:
|
|
||||||
"""
|
|
||||||
Immutable metadata describing a TTS backend implementation.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Protocol for TTS backends.
|
|
||||||
|
|
||||||
All TTS backends must implement this interface to be compatible
|
|
||||||
with the application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def metadata(self) -> TTSBackendMetadata:
|
|
||||||
...
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
"""
|
|
||||||
Initialize the TTS backend.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
**kwargs: Backend-specific configuration parameters
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def synthesize(self, text: str, **kwargs) -> bytes:
|
|
||||||
"""
|
|
||||||
Synthesize speech from text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Text to synthesize
|
|
||||||
**kwargs: Additional parameters for synthesis
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Audio data as bytes
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_available_voices(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get list of available voices.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of voice identifiers
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_supported_formats(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get list of supported audio formats.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of supported audio formats
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get backend information.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with backend information
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""
|
|
||||||
TTS Backend Registry
|
|
||||||
|
|
||||||
Provides a global registry for TTS backend factories.
|
|
||||||
Backends register themselves with metadata and a factory callable.
|
|
||||||
The registry is universal and does not know about backend constructors.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Callable, Any
|
|
||||||
|
|
||||||
from abogen.tts_backend import TTSBackend, TTSBackendMetadata
|
|
||||||
|
|
||||||
|
|
||||||
class TTSBackendRegistry:
|
|
||||||
"""Registry of TTS backend factories.
|
|
||||||
|
|
||||||
Stores metadata and factory callables for registered backends.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._backends: dict[str, TTSBackendMetadata] = {}
|
|
||||||
self._factories: dict[str, Callable[..., TTSBackend]] = {}
|
|
||||||
|
|
||||||
def register(
|
|
||||||
self,
|
|
||||||
metadata: TTSBackendMetadata,
|
|
||||||
factory: Callable[..., TTSBackend],
|
|
||||||
) -> None:
|
|
||||||
"""Register a backend with its metadata and factory callable."""
|
|
||||||
self._backends[metadata.id] = metadata
|
|
||||||
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]:
|
|
||||||
"""Return metadata for all registered backends."""
|
|
||||||
return list(self._backends.values())
|
|
||||||
|
|
||||||
def get_metadata(self, backend_id: str) -> TTSBackendMetadata:
|
|
||||||
"""Get metadata for a specific backend.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If backend with given id is not registered.
|
|
||||||
"""
|
|
||||||
if backend_id not in self._backends:
|
|
||||||
raise KeyError(f"Unknown backend: {backend_id}")
|
|
||||||
return self._backends[backend_id]
|
|
||||||
|
|
||||||
def create_backend(self, backend_id: str, **kwargs: Any) -> TTSBackend:
|
|
||||||
"""Create a backend instance by id.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If backend with given id is not registered.
|
|
||||||
"""
|
|
||||||
if backend_id not in self._factories:
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def register_backend(
|
|
||||||
metadata: TTSBackendMetadata,
|
|
||||||
factory: Callable[..., TTSBackend],
|
|
||||||
) -> None:
|
|
||||||
"""Register a TTS backend in the global registry."""
|
|
||||||
_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:
|
|
||||||
"""Create a TTS backend instance by provider id."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"""TTS backends package.
|
|
||||||
|
|
||||||
Backend modules are auto-discovered and imported here.
|
|
||||||
Each backend module registers itself with the global registry
|
|
||||||
when imported.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import pkgutil
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_backends():
|
|
||||||
"""Import all modules in this package to trigger their registration."""
|
|
||||||
package = __name__
|
|
||||||
for _importer, modname, _ispkg in pkgutil.iter_modules(path=__path__):
|
|
||||||
importlib.import_module(f"{package}.{modname}")
|
|
||||||
|
|
||||||
|
|
||||||
_discover_backends()
|
|
||||||
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
"""
|
|
||||||
Kokoro TTS Backend
|
|
||||||
|
|
||||||
Encapsulates the Kokoro KPipeline as a TTSBackend implementation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Iterator, List, Optional
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from abogen.tts_backend import TTSBackendMetadata
|
|
||||||
|
|
||||||
# Internal voice list — source of truth for Kokoro voices.
|
|
||||||
# The rest of the project accesses voices via get_metadata("kokoro").voices.
|
|
||||||
_VOICES_INTERNAL = [
|
|
||||||
"af_alloy",
|
|
||||||
"af_aoede",
|
|
||||||
"af_bella",
|
|
||||||
"af_heart",
|
|
||||||
"af_jessica",
|
|
||||||
"af_kore",
|
|
||||||
"af_nicole",
|
|
||||||
"af_nova",
|
|
||||||
"af_river",
|
|
||||||
"af_sarah",
|
|
||||||
"af_sky",
|
|
||||||
"am_adam",
|
|
||||||
"am_echo",
|
|
||||||
"am_eric",
|
|
||||||
"am_fenrir",
|
|
||||||
"am_liam",
|
|
||||||
"am_michael",
|
|
||||||
"am_onyx",
|
|
||||||
"am_puck",
|
|
||||||
"am_santa",
|
|
||||||
"bf_alice",
|
|
||||||
"bf_emma",
|
|
||||||
"bf_isabella",
|
|
||||||
"bf_lily",
|
|
||||||
"bm_daniel",
|
|
||||||
"bm_fable",
|
|
||||||
"bm_george",
|
|
||||||
"bm_lewis",
|
|
||||||
"ef_dora",
|
|
||||||
"em_alex",
|
|
||||||
"em_santa",
|
|
||||||
"ff_siwis",
|
|
||||||
"hf_alpha",
|
|
||||||
"hf_beta",
|
|
||||||
"hm_omega",
|
|
||||||
"hm_psi",
|
|
||||||
"if_sara",
|
|
||||||
"im_nicola",
|
|
||||||
"jf_alpha",
|
|
||||||
"jf_gongitsune",
|
|
||||||
"jf_nezumi",
|
|
||||||
"jf_tebukuro",
|
|
||||||
"jm_kumo",
|
|
||||||
"pf_dora",
|
|
||||||
"pm_alex",
|
|
||||||
"pm_santa",
|
|
||||||
"zf_xiaobei",
|
|
||||||
"zf_xiaoni",
|
|
||||||
"zf_xiaoxiao",
|
|
||||||
"zf_xiaoyi",
|
|
||||||
"zm_yunjian",
|
|
||||||
"zm_yunxi",
|
|
||||||
"zm_yunxia",
|
|
||||||
"zm_yunyang",
|
|
||||||
]
|
|
||||||
|
|
||||||
_KOKORO_METADATA = TTSBackendMetadata(
|
|
||||||
id="kokoro",
|
|
||||||
name="Kokoro",
|
|
||||||
description="Kokoro TTS engine",
|
|
||||||
voices=tuple(_VOICES_INTERNAL),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_kpipeline():
|
|
||||||
"""Lazy-load Kokoro dependencies."""
|
|
||||||
from kokoro import KPipeline # type: ignore[import-not-found]
|
|
||||||
|
|
||||||
return KPipeline
|
|
||||||
|
|
||||||
|
|
||||||
class KokoroBackend:
|
|
||||||
"""TTSBackend implementation wrapping the Kokoro KPipeline.
|
|
||||||
|
|
||||||
All interaction with KPipeline is encapsulated here.
|
|
||||||
The rest of the project depends only on this class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
|
||||||
lang_code = kwargs["lang_code"]
|
|
||||||
repo_id = kwargs.get("repo_id", "hexgrad/Kokoro-82M")
|
|
||||||
device = kwargs.get("device", "cpu")
|
|
||||||
|
|
||||||
KPipeline = _load_kpipeline()
|
|
||||||
self._pipeline = KPipeline(
|
|
||||||
lang_code=lang_code,
|
|
||||||
repo_id=repo_id,
|
|
||||||
device=device,
|
|
||||||
)
|
|
||||||
self._lang_code = lang_code
|
|
||||||
|
|
||||||
@property
|
|
||||||
def metadata(self) -> TTSBackendMetadata:
|
|
||||||
return _KOKORO_METADATA
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
voice: Any,
|
|
||||||
speed: float = 1.0,
|
|
||||||
split_pattern: Optional[str] = None,
|
|
||||||
) -> Iterator[Any]:
|
|
||||||
"""Delegate to KPipeline's __call__."""
|
|
||||||
return self._pipeline(
|
|
||||||
text,
|
|
||||||
voice=voice,
|
|
||||||
speed=speed,
|
|
||||||
split_pattern=split_pattern,
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_single_voice(self, voice_name: str) -> Any:
|
|
||||||
"""Load a single voice tensor. Used by voice formula system."""
|
|
||||||
return self._pipeline.load_single_voice(voice_name)
|
|
||||||
|
|
||||||
def synthesize(self, text: str, **kwargs: Any) -> bytes:
|
|
||||||
"""Synthesize speech from text. Returns raw audio bytes."""
|
|
||||||
voice = kwargs.get("voice", "")
|
|
||||||
speed = kwargs.get("speed", 1.0)
|
|
||||||
split_pattern = kwargs.get("split_pattern", None)
|
|
||||||
|
|
||||||
audio_parts: list[np.ndarray] = []
|
|
||||||
for segment in self(text, voice=voice, speed=speed, split_pattern=split_pattern):
|
|
||||||
audio = segment.audio
|
|
||||||
if hasattr(audio, "numpy"):
|
|
||||||
audio = audio.numpy()
|
|
||||||
audio_parts.append(np.asarray(audio, dtype="float32"))
|
|
||||||
|
|
||||||
if not audio_parts:
|
|
||||||
return b""
|
|
||||||
|
|
||||||
combined = np.concatenate(audio_parts).astype("float32", copy=False)
|
|
||||||
return combined.tobytes()
|
|
||||||
|
|
||||||
def get_available_voices(self) -> List[str]:
|
|
||||||
"""Return known Kokoro voice identifiers."""
|
|
||||||
return list(self.metadata.voices)
|
|
||||||
|
|
||||||
def get_supported_formats(self) -> List[str]:
|
|
||||||
"""Kokoro outputs raw PCM float32 audio."""
|
|
||||||
return ["pcm_float32"]
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": "kokoro",
|
|
||||||
"name": "Kokoro",
|
|
||||||
"lang_code": self._lang_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_kokoro_backend(**kwargs: Any) -> KokoroBackend:
|
|
||||||
"""Factory callable registered with TTSBackendRegistry."""
|
|
||||||
return KokoroBackend(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Registration ---
|
|
||||||
from abogen.tts_backend_registry import register_backend # noqa: E402
|
|
||||||
|
|
||||||
register_backend(
|
|
||||||
metadata=_KOKORO_METADATA,
|
|
||||||
factory=create_kokoro_backend,
|
|
||||||
)
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
graphemes: str
|
|
||||||
audio: np.ndarray
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_float32_mono(wav: Any) -> np.ndarray:
|
|
||||||
arr = np.asarray(wav, dtype="float32")
|
|
||||||
if arr.ndim == 2:
|
|
||||||
# (n, 1) or (1, n) or (n, channels)
|
|
||||||
if arr.shape[0] == 1 and arr.shape[1] > 1:
|
|
||||||
arr = arr.reshape(-1)
|
|
||||||
else:
|
|
||||||
arr = arr[:, 0]
|
|
||||||
return arr.reshape(-1)
|
|
||||||
|
|
||||||
|
|
||||||
def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
|
|
||||||
if src_rate == dst_rate:
|
|
||||||
return audio
|
|
||||||
if audio.size == 0:
|
|
||||||
return audio
|
|
||||||
ratio = dst_rate / float(src_rate)
|
|
||||||
new_len = int(round(audio.size * ratio))
|
|
||||||
if new_len <= 1:
|
|
||||||
return np.zeros(0, dtype="float32")
|
|
||||||
x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
|
|
||||||
x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
|
|
||||||
return np.interp(x_new, x_old, audio).astype("float32", copy=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_text(
|
|
||||||
text: str, *, split_pattern: Optional[str], max_chunk_length: int
|
|
||||||
) -> list[str]:
|
|
||||||
stripped = (text or "").strip()
|
|
||||||
if not stripped:
|
|
||||||
return []
|
|
||||||
parts: list[str]
|
|
||||||
if split_pattern:
|
|
||||||
try:
|
|
||||||
parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
|
|
||||||
except re.error:
|
|
||||||
parts = [stripped]
|
|
||||||
else:
|
|
||||||
parts = [stripped]
|
|
||||||
|
|
||||||
# Enforce max length by hard-splitting long parts.
|
|
||||||
result: list[str] = []
|
|
||||||
for part in parts:
|
|
||||||
if len(part) <= max_chunk_length:
|
|
||||||
result.append(part)
|
|
||||||
continue
|
|
||||||
start = 0
|
|
||||||
while start < len(part):
|
|
||||||
end = min(len(part), start + max_chunk_length)
|
|
||||||
# Try to split at whitespace.
|
|
||||||
if end < len(part):
|
|
||||||
ws = part.rfind(" ", start, end)
|
|
||||||
if ws > start + 40:
|
|
||||||
end = ws
|
|
||||||
chunk = part[start:end].strip()
|
|
||||||
if chunk:
|
|
||||||
result.append(chunk)
|
|
||||||
start = end
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
_UNSUPPORTED_CHARS_RE = re.compile(
|
|
||||||
r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_unsupported_characters(error: BaseException) -> list[str]:
|
|
||||||
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
|
|
||||||
|
|
||||||
message = " ".join(
|
|
||||||
str(part) for part in getattr(error, "args", ()) if part is not None
|
|
||||||
) or str(error)
|
|
||||||
match = _UNSUPPORTED_CHARS_RE.search(message)
|
|
||||||
if not match:
|
|
||||||
return []
|
|
||||||
|
|
||||||
raw = match.group(1)
|
|
||||||
try:
|
|
||||||
value = ast.literal_eval(raw)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
out: list[str] = []
|
|
||||||
for item in value:
|
|
||||||
if item is None:
|
|
||||||
continue
|
|
||||||
s = str(item)
|
|
||||||
if s:
|
|
||||||
out.append(s)
|
|
||||||
return out
|
|
||||||
|
|
||||||
if isinstance(value, str) and value:
|
|
||||||
return [value]
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
|
|
||||||
result = text
|
|
||||||
for item in unsupported:
|
|
||||||
if not item:
|
|
||||||
continue
|
|
||||||
result = result.replace(item, "")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_supertonic_gpu() -> None:
|
|
||||||
"""Patch supertonic's config to enable GPU acceleration if available."""
|
|
||||||
try:
|
|
||||||
import onnxruntime as ort
|
|
||||||
|
|
||||||
available = ort.get_available_providers()
|
|
||||||
|
|
||||||
# Use CUDA if available, skip TensorRT (requires extra libs not always present)
|
|
||||||
# TensorrtExecutionProvider may be listed as available but fail at runtime
|
|
||||||
# if TensorRT libraries (libnvinfer.so) are not installed
|
|
||||||
providers = []
|
|
||||||
if "CUDAExecutionProvider" in available:
|
|
||||||
providers.append("CUDAExecutionProvider")
|
|
||||||
providers.append("CPUExecutionProvider")
|
|
||||||
|
|
||||||
# Patch supertonic's config and loader before TTS import
|
|
||||||
# We must patch both because loader imports the value at module load time
|
|
||||||
import supertonic.config as supertonic_config
|
|
||||||
import supertonic.loader as supertonic_loader
|
|
||||||
|
|
||||||
supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
|
|
||||||
supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
|
|
||||||
logger.info("Supertonic ONNX providers configured: %s", providers)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Could not configure supertonic GPU providers: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
class SupertonicPipeline:
|
|
||||||
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
sample_rate: int,
|
|
||||||
auto_download: bool = True,
|
|
||||||
total_steps: int = 5,
|
|
||||||
max_chunk_length: int = 300,
|
|
||||||
) -> None:
|
|
||||||
self.sample_rate = int(sample_rate)
|
|
||||||
self.total_steps = int(total_steps)
|
|
||||||
self.max_chunk_length = int(max_chunk_length)
|
|
||||||
|
|
||||||
# Configure GPU providers before importing TTS
|
|
||||||
_configure_supertonic_gpu()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from supertonic import TTS # type: ignore[import-not-found]
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
raise RuntimeError(
|
|
||||||
"Supertonic is not installed. Install it with `pip install supertonic`."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
self._tts = TTS(auto_download=auto_download)
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
voice: str,
|
|
||||||
speed: float,
|
|
||||||
split_pattern: Optional[str] = None,
|
|
||||||
total_steps: Optional[int] = None,
|
|
||||||
) -> Iterator[SupertonicSegment]:
|
|
||||||
voice_name = (voice or "").strip() or "M1"
|
|
||||||
steps = int(total_steps) if total_steps is not None else self.total_steps
|
|
||||||
steps = max(2, min(15, steps))
|
|
||||||
speed_value = float(speed) if speed is not None else 1.0
|
|
||||||
speed_value = max(0.7, min(2.0, speed_value))
|
|
||||||
|
|
||||||
style = self._tts.get_voice_style(voice_name=voice_name)
|
|
||||||
chunks = _split_text(
|
|
||||||
text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
|
|
||||||
)
|
|
||||||
for chunk in chunks:
|
|
||||||
chunk_to_speak = chunk
|
|
||||||
removed: set[str] = set()
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
|
|
||||||
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
wav, duration = self._tts.synthesize(
|
|
||||||
text=chunk_to_speak,
|
|
||||||
voice_style=style,
|
|
||||||
total_steps=steps,
|
|
||||||
speed=speed_value,
|
|
||||||
max_chunk_length=self.max_chunk_length,
|
|
||||||
silence_duration=0.0,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except ValueError as exc:
|
|
||||||
last_exc = exc
|
|
||||||
unsupported = _parse_unsupported_characters(exc)
|
|
||||||
if not unsupported:
|
|
||||||
raise
|
|
||||||
|
|
||||||
removed.update(unsupported)
|
|
||||||
sanitized = _remove_unsupported_characters(
|
|
||||||
chunk_to_speak, unsupported
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
# If we didn't change anything, don't loop forever.
|
|
||||||
if sanitized == chunk_to_speak.strip():
|
|
||||||
raise
|
|
||||||
|
|
||||||
chunk_to_speak = sanitized
|
|
||||||
if not chunk_to_speak:
|
|
||||||
logger.warning(
|
|
||||||
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
|
|
||||||
sorted(removed),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
if attempt == 0:
|
|
||||||
logger.warning(
|
|
||||||
"SuperTonic: removed unsupported characters %s and retried.",
|
|
||||||
sorted(removed),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Exhausted retries.
|
|
||||||
assert last_exc is not None
|
|
||||||
raise last_exc
|
|
||||||
|
|
||||||
if not chunk_to_speak:
|
|
||||||
continue
|
|
||||||
|
|
||||||
audio = _ensure_float32_mono(wav)
|
|
||||||
|
|
||||||
# If duration is present, infer the source sample rate and resample if needed.
|
|
||||||
src_rate = self.sample_rate
|
|
||||||
try:
|
|
||||||
dur = float(duration)
|
|
||||||
if dur > 0 and audio.size > 0:
|
|
||||||
inferred = int(round(audio.size / dur))
|
|
||||||
if 8000 <= inferred <= 96000:
|
|
||||||
src_rate = inferred
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if src_rate != self.sample_rate:
|
|
||||||
audio = _resample_linear(audio, src_rate, self.sample_rate)
|
|
||||||
|
|
||||||
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
|
|
||||||
|
|
||||||
|
|
||||||
class SupertonicBackend:
|
|
||||||
"""Supertonic TTS backend implementing the TTSBackend protocol.
|
|
||||||
|
|
||||||
Encapsulates ``SupertonicPipeline`` as an internal implementation detail.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def metadata(self) -> TTSBackendMetadata:
|
|
||||||
return _SUPERTONIC_METADATA
|
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
|
||||||
self._pipeline = SupertonicPipeline(
|
|
||||||
sample_rate=kwargs.get("sample_rate", 24000),
|
|
||||||
auto_download=kwargs.get("auto_download", True),
|
|
||||||
total_steps=kwargs.get("total_steps", 5),
|
|
||||||
)
|
|
||||||
|
|
||||||
def synthesize(self, text: str, **kwargs: Any) -> bytes:
|
|
||||||
"""Synthesize speech and return raw audio bytes (WAV).
|
|
||||||
|
|
||||||
Delegates to the internal :class:`SupertonicPipeline` and concatenates
|
|
||||||
all produced segments into a single byte buffer.
|
|
||||||
"""
|
|
||||||
import io
|
|
||||||
|
|
||||||
import soundfile as sf
|
|
||||||
|
|
||||||
voice = kwargs.get("voice", "M1")
|
|
||||||
speed = float(kwargs.get("speed", 1.0))
|
|
||||||
split_pattern = kwargs.get("split_pattern")
|
|
||||||
total_steps = kwargs.get("total_steps")
|
|
||||||
|
|
||||||
segments = self._pipeline(
|
|
||||||
text,
|
|
||||||
voice=voice,
|
|
||||||
speed=speed,
|
|
||||||
split_pattern=split_pattern,
|
|
||||||
total_steps=total_steps,
|
|
||||||
)
|
|
||||||
|
|
||||||
audio_parts: list[np.ndarray] = []
|
|
||||||
for seg in segments:
|
|
||||||
audio_parts.append(seg.audio)
|
|
||||||
|
|
||||||
if not audio_parts:
|
|
||||||
return b""
|
|
||||||
|
|
||||||
combined = np.concatenate(audio_parts)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
def get_available_voices(self) -> List[str]:
|
|
||||||
"""Return the list of built-in SuperTonic voice identifiers."""
|
|
||||||
return list(self.metadata.voices)
|
|
||||||
|
|
||||||
def get_supported_formats(self) -> List[str]:
|
|
||||||
return ["wav"]
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"sample_rate": self._pipeline.sample_rate,
|
|
||||||
"total_steps": self._pipeline.total_steps,
|
|
||||||
"max_chunk_length": self._pipeline.max_chunk_length,
|
|
||||||
"voices": list(DEFAULT_SUPERTONIC_VOICES),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
voice: str,
|
|
||||||
speed: float,
|
|
||||||
split_pattern: Optional[str] = None,
|
|
||||||
total_steps: Optional[int] = None,
|
|
||||||
) -> Iterator[SupertonicSegment]:
|
|
||||||
"""Backward-compatible call interface, delegates to the pipeline."""
|
|
||||||
return self._pipeline(
|
|
||||||
text,
|
|
||||||
voice=voice,
|
|
||||||
speed=speed,
|
|
||||||
split_pattern=split_pattern,
|
|
||||||
total_steps=total_steps,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_supertonic_backend(**kwargs: Any) -> SupertonicBackend:
|
|
||||||
"""Create a SuperTonic TTS backend instance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sample_rate: Audio sample rate. Defaults to 24000.
|
|
||||||
auto_download: Auto-download models. Defaults to True.
|
|
||||||
total_steps: Inference steps. Defaults to 5.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SupertonicBackend instance.
|
|
||||||
"""
|
|
||||||
return SupertonicBackend(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
from abogen.tts_backend_registry import register_backend # noqa: E402
|
|
||||||
|
|
||||||
register_backend(
|
|
||||||
metadata=_SUPERTONIC_METADATA,
|
|
||||||
factory=create_supertonic_backend,
|
|
||||||
)
|
|
||||||
+111
-111
@@ -1,111 +1,111 @@
|
|||||||
"""Core domain types for the TTS Plugin Architecture.
|
"""Core domain types for the TTS Plugin Architecture.
|
||||||
|
|
||||||
This module contains immutable value objects that form the core domain.
|
This module contains immutable value objects that form the core domain.
|
||||||
These types have zero dependencies and are used across the plugin system.
|
These types have zero dependencies and are used across the plugin system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AudioFormat:
|
class AudioFormat:
|
||||||
"""Immutable value object representing an audio format.
|
"""Immutable value object representing an audio format.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
||||||
extension: File extension (e.g., "wav", "mp3").
|
extension: File extension (e.g., "wav", "mp3").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mime: str
|
mime: str
|
||||||
extension: str
|
extension: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Duration:
|
class Duration:
|
||||||
"""Immutable value object representing a time duration.
|
"""Immutable value object representing a time duration.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
seconds: Duration in seconds.
|
seconds: Duration in seconds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
seconds: float
|
seconds: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class VoiceSelection:
|
class VoiceSelection:
|
||||||
"""Immutable value object for voice selection. Opaque to engine.
|
"""Immutable value object for voice selection. Opaque to engine.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
source: Voice source identifier (e.g., "builtin", "clone").
|
source: Voice source identifier (e.g., "builtin", "clone").
|
||||||
key: Voice key within the source.
|
key: Voice key within the source.
|
||||||
payload: Optional payload for clone/blend sources.
|
payload: Optional payload for clone/blend sources.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
source: str
|
source: str
|
||||||
key: str
|
key: str
|
||||||
payload: Any = None
|
payload: Any = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ParameterValues:
|
class ParameterValues:
|
||||||
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
values: Mapping of parameter names to their values.
|
values: Mapping of parameter names to their values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
values: Mapping[str, Any] = field(default_factory=dict)
|
values: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SynthesisRequest:
|
class SynthesisRequest:
|
||||||
"""Immutable value object for a synthesis request.
|
"""Immutable value object for a synthesis request.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
text: Text to synthesize.
|
text: Text to synthesize.
|
||||||
voice: Voice selection.
|
voice: Voice selection.
|
||||||
parameters: Synthesis parameters.
|
parameters: Synthesis parameters.
|
||||||
format: Desired audio output format.
|
format: Desired audio output format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
voice: VoiceSelection
|
voice: VoiceSelection
|
||||||
parameters: ParameterValues
|
parameters: ParameterValues
|
||||||
format: AudioFormat
|
format: AudioFormat
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SynthesizedAudio:
|
class SynthesizedAudio:
|
||||||
"""Immutable value object for synthesized audio result.
|
"""Immutable value object for synthesized audio result.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
data: Raw audio bytes.
|
data: Raw audio bytes.
|
||||||
format: Audio format of the result.
|
format: Audio format of the result.
|
||||||
duration: Duration of the audio.
|
duration: Duration of the audio.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
data: bytes
|
data: bytes
|
||||||
format: AudioFormat
|
format: AudioFormat
|
||||||
duration: Duration
|
duration: Duration
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EngineConfig:
|
class EngineConfig:
|
||||||
"""Immutable configuration of an Engine instance.
|
"""Immutable configuration of an Engine instance.
|
||||||
|
|
||||||
Contains parameters that define how a particular Engine instance is
|
Contains parameters that define how a particular Engine instance is
|
||||||
created and that remain constant throughout the lifetime of that Engine.
|
created and that remain constant throughout the lifetime of that Engine.
|
||||||
|
|
||||||
Plugin implementations may ignore fields that are not applicable to them.
|
Plugin implementations may ignore fields that are not applicable to them.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
device: Device to use (e.g., "cpu", "cuda:0").
|
device: Device to use (e.g., "cpu", "cuda:0").
|
||||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
||||||
Plugins that do not require a language code ignore this field.
|
Plugins that do not require a language code ignore this field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device: str = "cpu"
|
device: str = "cpu"
|
||||||
lang_code: str = "a"
|
lang_code: str = "a"
|
||||||
|
|||||||
+235
-235
@@ -1,235 +1,235 @@
|
|||||||
"""TTS Plugin Architecture — direct utility functions.
|
"""TTS Plugin Architecture — direct utility functions.
|
||||||
|
|
||||||
Provides helpers that replace the former compatibility adapter by
|
Provides helpers that replace the former compatibility adapter by
|
||||||
calling the Plugin Manager directly.
|
calling the Plugin Manager directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||||
|
|
||||||
|
|
||||||
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
||||||
"""Return the voice-id tuple for *plugin_id*.
|
"""Return the voice-id tuple for *plugin_id*.
|
||||||
|
|
||||||
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
||||||
First checks plugin manifest for static voice catalog.
|
First checks plugin manifest for static voice catalog.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
from abogen.tts_plugin.host_context import HostContext
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
from abogen.tts_plugin.types import EngineConfig
|
||||||
|
|
||||||
manager = get_plugin_manager()
|
manager = get_plugin_manager()
|
||||||
if not manager.has_plugin(plugin_id):
|
if not manager.has_plugin(plugin_id):
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
# Check manifest for static voice catalog
|
# Check manifest for static voice catalog
|
||||||
plugin_info = manager.get_plugin(plugin_id)
|
plugin_info = manager.get_plugin(plugin_id)
|
||||||
if plugin_info is not None:
|
if plugin_info is not None:
|
||||||
manifest = plugin_info.get("manifest")
|
manifest = plugin_info.get("manifest")
|
||||||
if manifest is not None and manifest.voices is not None:
|
if manifest is not None and manifest.voices is not None:
|
||||||
return tuple(v.id for v in manifest.voices)
|
return tuple(v.id for v in manifest.voices)
|
||||||
|
|
||||||
ctx = HostContext(
|
ctx = HostContext(
|
||||||
config_dir=Path(tempfile.gettempdir()),
|
config_dir=Path(tempfile.gettempdir()),
|
||||||
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
||||||
http_client=type("_StubHttpClient", (), {
|
http_client=type("_StubHttpClient", (), {
|
||||||
"get": staticmethod(lambda url, **kw: None),
|
"get": staticmethod(lambda url, **kw: None),
|
||||||
"post": staticmethod(lambda url, **kw: None),
|
"post": staticmethod(lambda url, **kw: None),
|
||||||
})(),
|
})(),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
engine = manager.create_engine(
|
engine = manager.create_engine(
|
||||||
plugin_id,
|
plugin_id,
|
||||||
context=ctx,
|
context=ctx,
|
||||||
model_path=None,
|
model_path=None,
|
||||||
config=EngineConfig(device="cpu"),
|
config=EngineConfig(device="cpu"),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from abogen.tts_plugin.capabilities import VoiceLister
|
from abogen.tts_plugin.capabilities import VoiceLister
|
||||||
|
|
||||||
if isinstance(engine, VoiceLister):
|
if isinstance(engine, VoiceLister):
|
||||||
manifests = engine.listVoices("builtin")
|
manifests = engine.listVoices("builtin")
|
||||||
return tuple(v.id for v in manifests)
|
return tuple(v.id for v in manifests)
|
||||||
return ()
|
return ()
|
||||||
except Exception:
|
except Exception:
|
||||||
return ()
|
return ()
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
||||||
"""Return the first voice of *plugin_id*, or *fallback*."""
|
"""Return the first voice of *plugin_id*, or *fallback*."""
|
||||||
voices = get_voices(plugin_id)
|
voices = get_voices(plugin_id)
|
||||||
return voices[0] if voices else fallback
|
return voices[0] if voices else fallback
|
||||||
|
|
||||||
|
|
||||||
def is_plugin_registered(plugin_id: str) -> bool:
|
def is_plugin_registered(plugin_id: str) -> bool:
|
||||||
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
||||||
return get_plugin_manager().has_plugin(plugin_id)
|
return get_plugin_manager().has_plugin(plugin_id)
|
||||||
|
|
||||||
|
|
||||||
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
||||||
"""Determine which plugin owns the given voice specification.
|
"""Determine which plugin owns the given voice specification.
|
||||||
|
|
||||||
Resolution rules:
|
Resolution rules:
|
||||||
1. Empty spec -> fallback
|
1. Empty spec -> fallback
|
||||||
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||||
3. Exact voice-id match against loaded plugins -> plugin id
|
3. Exact voice-id match against loaded plugins -> plugin id
|
||||||
4. Unknown voice -> fallback
|
4. Unknown voice -> fallback
|
||||||
"""
|
"""
|
||||||
raw = str(spec or "").strip()
|
raw = str(spec or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
if "*" in raw or "+" in raw:
|
if "*" in raw or "+" in raw:
|
||||||
return "kokoro"
|
return "kokoro"
|
||||||
|
|
||||||
upper = raw.upper()
|
upper = raw.upper()
|
||||||
manager = get_plugin_manager()
|
manager = get_plugin_manager()
|
||||||
|
|
||||||
for manifest in manager.list_plugins():
|
for manifest in manager.list_plugins():
|
||||||
for voice_source in manifest.engine.voiceSources:
|
for voice_source in manifest.engine.voiceSources:
|
||||||
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
||||||
try:
|
try:
|
||||||
engine = manager.create_engine(manifest.id)
|
engine = manager.create_engine(manifest.id)
|
||||||
try:
|
try:
|
||||||
if hasattr(engine, "listVoices"):
|
if hasattr(engine, "listVoices"):
|
||||||
voice_manifests = engine.listVoices(voice_source.id)
|
voice_manifests = engine.listVoices(voice_source.id)
|
||||||
voice_ids = [v.id.upper() for v in voice_manifests]
|
voice_ids = [v.id.upper() for v in voice_manifests]
|
||||||
if upper in voice_ids:
|
if upper in voice_ids:
|
||||||
return manifest.id
|
return manifest.id
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
class Pipeline:
|
class Pipeline:
|
||||||
"""Callable wrapper around Engine / EngineSession.
|
"""Callable wrapper around Engine / EngineSession.
|
||||||
|
|
||||||
Presents the same interface that old callers expect::
|
Presents the same interface that old callers expect::
|
||||||
|
|
||||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
||||||
audio = segment.audio
|
audio = segment.audio
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
||||||
self._engine = engine
|
self._engine = engine
|
||||||
self._engine_kwargs = engine_kwargs
|
self._engine_kwargs = engine_kwargs
|
||||||
self._session: Any = None
|
self._session: Any = None
|
||||||
|
|
||||||
def _ensure_session(self) -> Any:
|
def _ensure_session(self) -> Any:
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
self._session = self._engine.createSession()
|
self._session = self._engine.createSession()
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
voice: str = "default",
|
voice: str = "default",
|
||||||
speed: float = 1.0,
|
speed: float = 1.0,
|
||||||
split_pattern: str | None = None,
|
split_pattern: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Iterator[Any]:
|
) -> Iterator[Any]:
|
||||||
from abogen.tts_plugin.types import (
|
from abogen.tts_plugin.types import (
|
||||||
AudioFormat,
|
AudioFormat,
|
||||||
ParameterValues,
|
ParameterValues,
|
||||||
SynthesisRequest,
|
SynthesisRequest,
|
||||||
VoiceSelection,
|
VoiceSelection,
|
||||||
)
|
)
|
||||||
|
|
||||||
session = self._ensure_session()
|
session = self._ensure_session()
|
||||||
|
|
||||||
params: dict[str, Any] = {"speed": speed}
|
params: dict[str, Any] = {"speed": speed}
|
||||||
if split_pattern is not None:
|
if split_pattern is not None:
|
||||||
params["split_pattern"] = split_pattern
|
params["split_pattern"] = split_pattern
|
||||||
params.update(kwargs)
|
params.update(kwargs)
|
||||||
|
|
||||||
request = SynthesisRequest(
|
request = SynthesisRequest(
|
||||||
text=text,
|
text=text,
|
||||||
voice=VoiceSelection(source="builtin", key=voice),
|
voice=VoiceSelection(source="builtin", key=voice),
|
||||||
parameters=ParameterValues(values=params),
|
parameters=ParameterValues(values=params),
|
||||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = session.synthesize(request)
|
result = session.synthesize(request)
|
||||||
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Segment:
|
class Segment:
|
||||||
graphemes: str
|
graphemes: str
|
||||||
audio: np.ndarray
|
audio: np.ndarray
|
||||||
|
|
||||||
yield Segment(graphemes=text, audio=audio_array)
|
yield Segment(graphemes=text, audio=audio_array)
|
||||||
|
|
||||||
def dispose(self) -> None:
|
def dispose(self) -> None:
|
||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
try:
|
try:
|
||||||
self._session.dispose()
|
self._session.dispose()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._session = None
|
self._session = None
|
||||||
|
|
||||||
def __del__(self) -> None:
|
def __del__(self) -> None:
|
||||||
self.dispose()
|
self.dispose()
|
||||||
|
|
||||||
|
|
||||||
def create_pipeline(
|
def create_pipeline(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
*,
|
*,
|
||||||
lang_code: str = "a",
|
lang_code: str = "a",
|
||||||
device: str = "cpu",
|
device: str = "cpu",
|
||||||
) -> Pipeline:
|
) -> Pipeline:
|
||||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
"""Create a callable TTS pipeline via the Plugin Architecture.
|
||||||
|
|
||||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
Builds a proper HostContext and EngineConfig, then delegates to the
|
||||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
||||||
``__call__`` interface matches the legacy ``TTSBackend`` callable protocol.
|
``__call__`` interface matches the legacy ``TTSBackend`` callable protocol.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||||
lang_code: Language code for the engine.
|
lang_code: Language code for the engine.
|
||||||
device: Device to use (e.g., "cpu", "cuda:0").
|
device: Device to use (e.g., "cpu", "cuda:0").
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A callable Pipeline instance.
|
A callable Pipeline instance.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from abogen.tts_plugin.host_context import HostContext
|
from abogen.tts_plugin.host_context import HostContext
|
||||||
from abogen.tts_plugin.types import EngineConfig
|
from abogen.tts_plugin.types import EngineConfig
|
||||||
|
|
||||||
manager = get_plugin_manager()
|
manager = get_plugin_manager()
|
||||||
|
|
||||||
ctx = HostContext(
|
ctx = HostContext(
|
||||||
config_dir=Path(tempfile.gettempdir()),
|
config_dir=Path(tempfile.gettempdir()),
|
||||||
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
||||||
http_client=type("_StubHttpClient", (), {
|
http_client=type("_StubHttpClient", (), {
|
||||||
"get": staticmethod(lambda url, **kw: None),
|
"get": staticmethod(lambda url, **kw: None),
|
||||||
"post": staticmethod(lambda url, **kw: None),
|
"post": staticmethod(lambda url, **kw: None),
|
||||||
})(),
|
})(),
|
||||||
)
|
)
|
||||||
|
|
||||||
config = EngineConfig(device=device, lang_code=lang_code)
|
config = EngineConfig(device=device, lang_code=lang_code)
|
||||||
|
|
||||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
||||||
return Pipeline(engine)
|
return Pipeline(engine)
|
||||||
|
|||||||
@@ -1,89 +1,89 @@
|
|||||||
# Architecture Amendment #1: EngineConfig — `lang_code` field
|
# Architecture Amendment #1: EngineConfig — `lang_code` field
|
||||||
|
|
||||||
**Date:** 2026-07-12
|
**Date:** 2026-07-12
|
||||||
**Status:** Accepted
|
**Status:** Accepted
|
||||||
**PR:** #12 (Normalize Pipeline Public API)
|
**PR:** #12 (Normalize Pipeline Public API)
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Add `lang_code: str = "a"` to `EngineConfig` and update its definition to clarify the architectural contract.
|
Add `lang_code: str = "a"` to `EngineConfig` and update its definition to clarify the architectural contract.
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
During migration from the old `KokoroBackend` to the Plugin Architecture, the `lang_code` parameter became a dead argument. The old backend read it from `**kwargs` and passed it to `KPipeline(lang_code=...)`. The new `KokoroPlugin.create_engine()` hardcodes `lang_code="a"`, ignoring the config entirely. Callers continued passing `lang_code` to `create_pipeline()`, unaware it had no effect.
|
During migration from the old `KokoroBackend` to the Plugin Architecture, the `lang_code` parameter became a dead argument. The old backend read it from `**kwargs` and passed it to `KPipeline(lang_code=...)`. The new `KokoroPlugin.create_engine()` hardcodes `lang_code="a"`, ignoring the config entirely. Callers continued passing `lang_code` to `create_pipeline()`, unaware it had no effect.
|
||||||
|
|
||||||
This is a functional regression relative to the pre-Plugin Architecture behavior.
|
This is a functional regression relative to the pre-Plugin Architecture behavior.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
### 1. Updated EngineConfig definition
|
### 1. Updated EngineConfig definition
|
||||||
|
|
||||||
**Before:**
|
**Before:**
|
||||||
```
|
```
|
||||||
Immutable value object for engine initialization settings.
|
Immutable value object for engine initialization settings.
|
||||||
Contains only engine-specific settings, no resource references.
|
Contains only engine-specific settings, no resource references.
|
||||||
```
|
```
|
||||||
|
|
||||||
**After:**
|
**After:**
|
||||||
```
|
```
|
||||||
Immutable configuration of an Engine instance.
|
Immutable configuration of an Engine instance.
|
||||||
Contains parameters that define how a particular Engine instance is
|
Contains parameters that define how a particular Engine instance is
|
||||||
created and that remain constant throughout the lifetime of that Engine.
|
created and that remain constant throughout the lifetime of that Engine.
|
||||||
Plugin implementations may ignore fields that are not applicable to them.
|
Plugin implementations may ignore fields that are not applicable to them.
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. New field
|
### 2. New field
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EngineConfig:
|
class EngineConfig:
|
||||||
device: str = "cpu"
|
device: str = "cpu"
|
||||||
lang_code: str = "a"
|
lang_code: str = "a"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Architectural rules
|
### 3. Architectural rules
|
||||||
|
|
||||||
- **Fields in EngineConfig are optional unless explicitly required by a plugin.**
|
- **Fields in EngineConfig are optional unless explicitly required by a plugin.**
|
||||||
- **Plugins MUST ignore unsupported EngineConfig fields.**
|
- **Plugins MUST ignore unsupported EngineConfig fields.**
|
||||||
- **All parameters that may vary between individual synthesis requests must remain in `SynthesisRequest.parameters`.**
|
- **All parameters that may vary between individual synthesis requests must remain in `SynthesisRequest.parameters`.**
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Analysis of real TTS engines (Kokoro, Piper, XTTS, Coqui, StyleTTS2, Fish Speech) confirmed:
|
Analysis of real TTS engines (Kokoro, Piper, XTTS, Coqui, StyleTTS2, Fish Speech) confirmed:
|
||||||
|
|
||||||
| Parameter type | Where it belongs | Example |
|
| Parameter type | Where it belongs | Example |
|
||||||
|---------------|-----------------|---------|
|
|---------------|-----------------|---------|
|
||||||
| Engine instance config (immutable) | `EngineConfig` | `device`, `lang_code` |
|
| Engine instance config (immutable) | `EngineConfig` | `device`, `lang_code` |
|
||||||
| Synthesis parameters (per-request) | `SynthesisRequest.parameters` | `speed`, `split_pattern`, `total_steps` |
|
| Synthesis parameters (per-request) | `SynthesisRequest.parameters` | `speed`, `split_pattern`, `total_steps` |
|
||||||
|
|
||||||
`lang_code` determines the engine's behavior at creation time and cannot be changed during the engine's lifetime. It is not a synthesis parameter.
|
`lang_code` determines the engine's behavior at creation time and cannot be changed during the engine's lifetime. It is not a synthesis parameter.
|
||||||
|
|
||||||
## Impact on existing plugins
|
## Impact on existing plugins
|
||||||
|
|
||||||
| Plugin | `device` | `lang_code` | Notes |
|
| Plugin | `device` | `lang_code` | Notes |
|
||||||
|--------|----------|-------------|-------|
|
|--------|----------|-------------|-------|
|
||||||
| Kokoro | Reads ✓ | Reads ✓ (was hardcoded, now from config) | Regression fixed |
|
| Kokoro | Reads ✓ | Reads ✓ (was hardcoded, now from config) | Regression fixed |
|
||||||
| SuperTonic | Ignores | Ignores | No change — no language concept |
|
| SuperTonic | Ignores | Ignores | No change — no language concept |
|
||||||
| Future plugins | May read | May ignore | Field-ignoring rule applies |
|
| Future plugins | May read | May ignore | Field-ignoring rule applies |
|
||||||
|
|
||||||
## Contract tests added
|
## Contract tests added
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class TestEngineConfigContract:
|
class TestEngineConfigContract:
|
||||||
def test_default_lang_code(self) # EngineConfig().lang_code == "a"
|
def test_default_lang_code(self) # EngineConfig().lang_code == "a"
|
||||||
def test_custom_lang_code(self) # EngineConfig(lang_code="j").lang_code == "j"
|
def test_custom_lang_code(self) # EngineConfig(lang_code="j").lang_code == "j"
|
||||||
def test_immutability_lang_code(self) # frozen — cannot reassign
|
def test_immutability_lang_code(self) # frozen — cannot reassign
|
||||||
def test_plugins_may_ignore_irrelevant_fields(self) # field-ignoring rule
|
def test_plugins_may_ignore_irrelevant_fields(self) # field-ignoring rule
|
||||||
def test_engine_config_contains_engine_instance_configuration(self) # definition
|
def test_engine_config_contains_engine_instance_configuration(self) # definition
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files changed
|
## Files changed
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `abogen/tts_plugin/types.py` | Updated docstring, added `lang_code: str = "a"` |
|
| `abogen/tts_plugin/types.py` | Updated docstring, added `lang_code: str = "a"` |
|
||||||
| `plugins/kokoro/__init__.py` | Reads `config.lang_code` instead of hardcoded `"a"` |
|
| `plugins/kokoro/__init__.py` | Reads `config.lang_code` instead of hardcoded `"a"` |
|
||||||
| `abogen/tts_plugin/utils.py` | `create_pipeline()` passes `lang_code` to `EngineConfig` |
|
| `abogen/tts_plugin/utils.py` | `create_pipeline()` passes `lang_code` to `EngineConfig` |
|
||||||
| `tests/contracts/test_types_contract.py` | 5 new contract tests |
|
| `tests/contracts/test_types_contract.py` | 5 new contract tests |
|
||||||
| `tests/contracts/test_plugin_manager_contract.py` | Updated assertion for `lang_code` |
|
| `tests/contracts/test_plugin_manager_contract.py` | Updated assertion for `lang_code` |
|
||||||
| `tests/test_behavioral_regression.py` | Updated `test_engine_config_defaults` |
|
| `tests/test_behavioral_regression.py` | Updated `test_engine_config_defaults` |
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
"""Tests for KokoroBackend class."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Iterator, List
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from abogen.tts_backend import TTSBackendMetadata
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeSegment:
|
|
||||||
graphemes: str
|
|
||||||
audio: Any # np.ndarray or torch-like tensor
|
|
||||||
|
|
||||||
|
|
||||||
class _FakePipeline:
|
|
||||||
"""Minimal mock for kokoro.KPipeline."""
|
|
||||||
|
|
||||||
def __init__(self, *, lang_code: str, repo_id: str, device: str):
|
|
||||||
self.lang_code = lang_code
|
|
||||||
self.repo_id = repo_id
|
|
||||||
self.device = device
|
|
||||||
self._voices: dict[str, np.ndarray] = {}
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
voice: Any = "",
|
|
||||||
speed: float = 1.0,
|
|
||||||
split_pattern: str | None = None,
|
|
||||||
) -> Iterator[_FakeSegment]:
|
|
||||||
yield _FakeSegment(graphemes=text, audio=np.zeros(100, dtype="float32"))
|
|
||||||
|
|
||||||
def load_single_voice(self, name: str) -> np.ndarray:
|
|
||||||
if name not in self._voices:
|
|
||||||
self._voices[name] = np.ones((1, 256), dtype="float32") * 0.5
|
|
||||||
return self._voices[name]
|
|
||||||
|
|
||||||
|
|
||||||
def _make_backend(**kwargs: Any):
|
|
||||||
"""Create KokoroBackend with mocked KPipeline."""
|
|
||||||
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
|
|
||||||
load.return_value = _FakePipeline
|
|
||||||
from abogen.tts_backends.kokoro import KokoroBackend
|
|
||||||
|
|
||||||
return KokoroBackend(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestKokoroBackendMetadata:
|
|
||||||
def test_metadata_returns_tts_backend_metadata(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
meta = backend.metadata
|
|
||||||
assert isinstance(meta, TTSBackendMetadata)
|
|
||||||
|
|
||||||
def test_metadata_fields(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
meta = backend.metadata
|
|
||||||
assert meta.id == "kokoro"
|
|
||||||
assert meta.name == "Kokoro"
|
|
||||||
assert "Kokoro" in meta.description
|
|
||||||
|
|
||||||
|
|
||||||
class TestKokoroBackendInit:
|
|
||||||
def test_stores_lang_code(self):
|
|
||||||
backend = _make_backend(lang_code="b")
|
|
||||||
assert backend._lang_code == "b"
|
|
||||||
|
|
||||||
def test_default_repo_id(self):
|
|
||||||
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
|
|
||||||
load.return_value = _FakePipeline
|
|
||||||
from abogen.tts_backends.kokoro import KokoroBackend
|
|
||||||
|
|
||||||
b = KokoroBackend(lang_code="a")
|
|
||||||
assert b._pipeline.repo_id == "hexgrad/Kokoro-82M"
|
|
||||||
|
|
||||||
def test_custom_repo_id(self):
|
|
||||||
backend = _make_backend(lang_code="a", repo_id="custom/repo")
|
|
||||||
assert backend._pipeline.repo_id == "custom/repo"
|
|
||||||
|
|
||||||
def test_default_device(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
assert backend._pipeline.device == "cpu"
|
|
||||||
|
|
||||||
def test_custom_device(self):
|
|
||||||
backend = _make_backend(lang_code="a", device="cuda")
|
|
||||||
assert backend._pipeline.device == "cuda"
|
|
||||||
|
|
||||||
|
|
||||||
class TestKokoroBackendCall:
|
|
||||||
def test_call_delegates_to_pipeline(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
results = list(backend("hello", voice="af_heart", speed=1.2, split_pattern=r"\n"))
|
|
||||||
assert len(results) == 1
|
|
||||||
assert results[0].graphemes == "hello"
|
|
||||||
|
|
||||||
def test_call_returns_iterator(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
result = backend("test", voice="af_heart")
|
|
||||||
assert hasattr(result, "__iter__")
|
|
||||||
|
|
||||||
def test_call_with_voice_tensor(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
voice_tensor = np.ones((1, 256), dtype="float32")
|
|
||||||
results = list(backend("test", voice=voice_tensor))
|
|
||||||
assert len(results) == 1
|
|
||||||
|
|
||||||
def test_call_default_speed(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
# Should not raise with default speed
|
|
||||||
list(backend("text", voice="af_heart"))
|
|
||||||
|
|
||||||
def test_call_default_split_pattern_is_none(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
# split_pattern defaults to None
|
|
||||||
list(backend("text", voice="af_heart"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadSingleVoice:
|
|
||||||
def test_load_single_voice_delegates(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
tensor = backend.load_single_voice("af_heart")
|
|
||||||
assert isinstance(tensor, np.ndarray)
|
|
||||||
assert tensor.shape == (1, 256)
|
|
||||||
|
|
||||||
def test_load_single_voice_caches(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
t1 = backend.load_single_voice("af_heart")
|
|
||||||
t2 = backend.load_single_voice("af_heart")
|
|
||||||
assert t1 is t2 # same object
|
|
||||||
|
|
||||||
|
|
||||||
class TestSynthesize:
|
|
||||||
def test_synthesize_returns_bytes(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
result = backend.synthesize("hello", voice="af_heart")
|
|
||||||
assert isinstance(result, bytes)
|
|
||||||
|
|
||||||
def test_synthesize_nonempty(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
result = backend.synthesize("hello", voice="af_heart")
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_synthesize_with_speed(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
result = backend.synthesize("hello", voice="af_heart", speed=1.5)
|
|
||||||
assert isinstance(result, bytes)
|
|
||||||
|
|
||||||
def test_synthesize_empty_text(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
# Empty text produces no segments
|
|
||||||
result = backend.synthesize("", voice="af_heart")
|
|
||||||
assert isinstance(result, bytes)
|
|
||||||
|
|
||||||
|
|
||||||
class TestProtocolMethods:
|
|
||||||
def test_get_available_voices(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
voices = backend.get_available_voices()
|
|
||||||
assert isinstance(voices, list)
|
|
||||||
assert len(voices) > 0
|
|
||||||
assert all(isinstance(v, str) for v in voices)
|
|
||||||
|
|
||||||
def test_get_supported_formats(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
formats = backend.get_supported_formats()
|
|
||||||
assert "pcm_float32" in formats
|
|
||||||
|
|
||||||
def test_get_info(self):
|
|
||||||
backend = _make_backend(lang_code="a")
|
|
||||||
info = backend.get_info()
|
|
||||||
assert info["id"] == "kokoro"
|
|
||||||
assert info["name"] == "Kokoro"
|
|
||||||
assert info["lang_code"] == "a"
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegistration:
|
|
||||||
def test_factory_creates_kokoro_backend(self):
|
|
||||||
from abogen.tts_backends.kokoro import create_kokoro_backend, KokoroBackend
|
|
||||||
|
|
||||||
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
|
|
||||||
load.return_value = _FakePipeline
|
|
||||||
backend = create_kokoro_backend(lang_code="a")
|
|
||||||
assert isinstance(backend, KokoroBackend)
|
|
||||||
|
|
||||||
def test_registry_has_kokoro(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"
|
|
||||||
|
|
||||||
def test_registry_factory_returns_kokoro_backend(self):
|
|
||||||
import abogen.tts_backends # noqa: F401
|
|
||||||
from abogen.tts_backend_registry import _registry
|
|
||||||
from abogen.tts_backends.kokoro import KokoroBackend
|
|
||||||
|
|
||||||
factory = _registry._factories["kokoro"]
|
|
||||||
with patch("abogen.tts_backends.kokoro._load_kpipeline") as load:
|
|
||||||
load.return_value = _FakePipeline
|
|
||||||
backend = factory(lang_code="a")
|
|
||||||
assert isinstance(backend, KokoroBackend)
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
|
|
||||||
from abogen.tts_backends.supertonic import SupertonicBackend, SupertonicPipeline
|
|
||||||
|
|
||||||
|
|
||||||
class _DummyTTS:
|
|
||||||
def get_voice_style(self, voice_name: str):
|
|
||||||
return {"voice": voice_name}
|
|
||||||
|
|
||||||
def synthesize(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
text: str,
|
|
||||||
voice_style,
|
|
||||||
total_steps: int,
|
|
||||||
speed: float,
|
|
||||||
max_chunk_length: int,
|
|
||||||
silence_duration: float,
|
|
||||||
verbose: bool,
|
|
||||||
):
|
|
||||||
if "•" in text:
|
|
||||||
raise ValueError("Found 1 unsupported character(s): ['•']")
|
|
||||||
# Return 50ms of audio at 24kHz.
|
|
||||||
sr = 24000
|
|
||||||
audio = np.zeros(int(0.05 * sr), dtype="float32")
|
|
||||||
return audio, 0.05
|
|
||||||
|
|
||||||
|
|
||||||
def _make_pipeline() -> SupertonicPipeline:
|
|
||||||
pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
|
|
||||||
pipeline.sample_rate = 24000
|
|
||||||
pipeline.total_steps = 5
|
|
||||||
pipeline.max_chunk_length = 1000
|
|
||||||
pipeline._tts = _DummyTTS()
|
|
||||||
return pipeline
|
|
||||||
|
|
||||||
|
|
||||||
def _make_backend() -> SupertonicBackend:
|
|
||||||
backend = SupertonicBackend.__new__(SupertonicBackend)
|
|
||||||
backend._pipeline = _make_pipeline()
|
|
||||||
return backend
|
|
||||||
|
|
||||||
|
|
||||||
def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
|
|
||||||
pipeline = _make_pipeline()
|
|
||||||
|
|
||||||
segs = list(pipeline("Hello • world", voice="M1", speed=1.0))
|
|
||||||
assert len(segs) == 1
|
|
||||||
assert segs[0].graphemes == "Hello world" or segs[0].graphemes == "Hello world"
|
|
||||||
assert isinstance(segs[0].audio, np.ndarray)
|
|
||||||
assert segs[0].audio.dtype == np.float32
|
|
||||||
assert segs[0].audio.size > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters():
|
|
||||||
pipeline = _make_pipeline()
|
|
||||||
|
|
||||||
segs = list(pipeline("•", voice="M1", speed=1.0))
|
|
||||||
assert segs == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- SupertonicBackend tests ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_metadata():
|
|
||||||
backend = _make_backend()
|
|
||||||
meta = backend.metadata
|
|
||||||
assert meta.id == "supertonic"
|
|
||||||
assert meta.name == "SuperTonic"
|
|
||||||
assert "SuperTonic" in meta.description
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_get_available_voices():
|
|
||||||
backend = _make_backend()
|
|
||||||
voices = backend.get_available_voices()
|
|
||||||
assert isinstance(voices, list)
|
|
||||||
assert "M1" in voices
|
|
||||||
assert "F1" in voices
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_get_supported_formats():
|
|
||||||
backend = _make_backend()
|
|
||||||
formats = backend.get_supported_formats()
|
|
||||||
assert "wav" in formats
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_get_info():
|
|
||||||
backend = _make_backend()
|
|
||||||
info = backend.get_info()
|
|
||||||
assert info["sample_rate"] == 24000
|
|
||||||
assert info["total_steps"] == 5
|
|
||||||
assert isinstance(info["voices"], list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_call_delegates_to_pipeline():
|
|
||||||
backend = _make_backend()
|
|
||||||
segs = list(backend("Hello • world", voice="M1", speed=1.0))
|
|
||||||
assert len(segs) == 1
|
|
||||||
assert segs[0].audio.size > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_synthesize_returns_wav_bytes():
|
|
||||||
backend = _make_backend()
|
|
||||||
wav_bytes = backend.synthesize("Hello world", voice="M1", speed=1.0)
|
|
||||||
assert isinstance(wav_bytes, bytes)
|
|
||||||
assert len(wav_bytes) > 0
|
|
||||||
# WAV magic number
|
|
||||||
assert wav_bytes[:4] == b"RIFF"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec
|
|
||||||
from abogen.tts_backends.supertonic import DEFAULT_SUPERTONIC_VOICES
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
|
|
||||||
# This can happen when a previously-saved Kokoro mix formula is present
|
|
||||||
# but the active provider is SuperTonic (no Kokoro pipeline object).
|
|
||||||
formula = "af_heart*0.5+af_sky*0.5"
|
|
||||||
resolved = _resolve_voice(None, formula, use_gpu=False)
|
|
||||||
assert resolved == formula
|
|
||||||
|
|
||||||
|
|
||||||
def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
|
|
||||||
# When a stale Kokoro mix formula is present, SuperTonic should not receive it.
|
|
||||||
chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
|
|
||||||
assert chosen in DEFAULT_SUPERTONIC_VOICES
|
|
||||||
Reference in New Issue
Block a user