feat: add static voice catalog to PluginManifest

- Add  to PluginManifest
  - None = not declared (use VoiceLister fallback)
  - () = explicitly no static voices
  - Non-empty = static catalog available without Engine instantiation

- Update get_voices() to check manifest first, fall back to Engine
- Declare 54 Kokoro voices and 10 SuperTonic voices in manifests
- Remove hardcoded voice lists from engine.py files
- Engine.listVoices() now returns [] (manifest is source of truth)

- Clean up dead create_pipeline() kwargs (sample_rate, auto_download, total_steps)
  - SuperTonic plugin uses internal defaults
  - total_steps is per-request parameter via Pipeline.__call__() kwargs

- Add clear_preview_pipelines() for resource cleanup
- Fix test mocks to override listVoices()
- Update Architecture Amendment #1 doc
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:16 +03:00
parent 5d1e7165bb
commit 735098d7cd
10 changed files with 1101 additions and 979 deletions
+189 -186
View File
@@ -1,186 +1,189 @@
"""Plugin manifest types for the TTS Plugin Architecture. """Plugin manifest types for the TTS Plugin Architecture.
This module contains static metadata types that describe plugins. This module contains static metadata types that describe plugins.
These types have no dependencies and are immutable. These types have no dependencies and are immutable.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@dataclass(frozen=True) @dataclass(frozen=True)
class AudioFormatManifest: class AudioFormatManifest:
"""Manifest describing an audio format. """Manifest describing an audio format.
Attributes: Attributes:
mime: MIME type of the audio. mime: MIME type of the audio.
extension: File extension. extension: File extension.
""" """
mime: str mime: str
extension: str extension: str
@dataclass(frozen=True) @dataclass(frozen=True)
class EnumOption: class EnumOption:
"""Manifest describing an enum option for a parameter. """Manifest describing an enum option for a parameter.
Attributes: Attributes:
value: The enum value. value: The enum value.
label: Human-readable label. label: Human-readable label.
""" """
value: str value: str
label: str label: str
@dataclass(frozen=True) @dataclass(frozen=True)
class ParameterManifest: class ParameterManifest:
"""Manifest describing a synthesis parameter. """Manifest describing a synthesis parameter.
Attributes: Attributes:
id: Parameter identifier. id: Parameter identifier.
name: Human-readable name. name: Human-readable name.
description: Parameter description. description: Parameter description.
type: Parameter type ("float", "int", "string", "boolean", "enum"). type: Parameter type ("float", "int", "string", "boolean", "enum").
default: Default value. default: Default value.
min: Minimum value (optional, for numeric types). min: Minimum value (optional, for numeric types).
max: Maximum value (optional, for numeric types). max: Maximum value (optional, for numeric types).
step: Step size (optional, for numeric types). step: Step size (optional, for numeric types).
options: Available options (optional, for enum type). options: Available options (optional, for enum type).
unit: Unit of measurement (optional). unit: Unit of measurement (optional).
group: Parameter group (optional). group: Parameter group (optional).
""" """
id: str id: str
name: str name: str
description: str description: str
type: str type: str
default: Any default: Any
min: float | None = None min: float | None = None
max: float | None = None max: float | None = None
step: float | None = None step: float | None = None
options: tuple[EnumOption, ...] = field(default_factory=tuple) options: tuple[EnumOption, ...] = field(default_factory=tuple)
unit: str | None = None unit: str | None = None
group: str | None = None group: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class VoiceManifest: class VoiceManifest:
"""Manifest describing a voice. """Manifest describing a voice.
Attributes: Attributes:
id: Voice identifier. id: Voice identifier.
name: Human-readable name. name: Human-readable name.
tags: Voice tags (e.g., language, style). tags: Voice tags (e.g., language, style).
""" """
id: str id: str
name: str name: str
tags: tuple[str, ...] = field(default_factory=tuple) tags: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
class VoiceSourceManifest: class VoiceSourceManifest:
"""Manifest describing a voice source. """Manifest describing a voice source.
Attributes: Attributes:
id: Voice source identifier. id: Voice source identifier.
name: Human-readable name. name: Human-readable name.
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none"). type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
config: Source-specific configuration. config: Source-specific configuration.
""" """
id: str id: str
name: str name: str
type: str type: str
config: Any = None config: Any = None
@dataclass(frozen=True) @dataclass(frozen=True)
class EngineManifest: class EngineManifest:
"""Manifest describing engine capabilities. """Manifest describing engine capabilities.
Attributes: Attributes:
voiceSources: Available voice sources. voiceSources: Available voice sources.
parameters: Available synthesis parameters. parameters: Available synthesis parameters.
audioFormats: Supported audio formats. audioFormats: Supported audio formats.
""" """
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple) voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple) parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple) audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
class GpuRequirement: class GpuRequirement:
"""Manifest describing GPU requirements. """Manifest describing GPU requirements.
Attributes: Attributes:
required: Whether GPU is required. required: Whether GPU is required.
type: GPU type (e.g., "cuda", "rocm"). type: GPU type (e.g., "cuda", "rocm").
memory: Required GPU memory in GB. memory: Required GPU memory in GB.
""" """
required: bool = False required: bool = False
type: str | None = None type: str | None = None
memory: float | None = None memory: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class RequirementManifest: class RequirementManifest:
"""Manifest describing plugin requirements. """Manifest describing plugin requirements.
Attributes: Attributes:
gpu: GPU requirements (optional). gpu: GPU requirements (optional).
memory: Required RAM in GB (optional). memory: Required RAM in GB (optional).
internet: Whether internet is required (optional). internet: Whether internet is required (optional).
""" """
gpu: GpuRequirement | None = None gpu: GpuRequirement | None = None
memory: float | None = None memory: float | None = None
internet: bool | None = None internet: bool | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
class ModelManifest: class ModelManifest:
"""Manifest describing a model requirement. """Manifest describing a model requirement.
Attributes: Attributes:
id: Model identifier. id: Model identifier.
name: Human-readable name. name: Human-readable name.
size: Model size as string (e.g., "100MB", "2GB"). size: Model size as string (e.g., "100MB", "2GB").
""" """
id: str id: str
name: str name: str
size: str size: str
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginManifest: class PluginManifest:
"""Main manifest for a TTS plugin. """Main manifest for a TTS plugin.
Attributes: Attributes:
id: Plugin identifier (unique). id: Plugin identifier (unique).
name: Human-readable name. name: Human-readable name.
version: Plugin version. version: Plugin version.
api_version: API version (semver format: MAJOR.MINOR). api_version: API version (semver format: MAJOR.MINOR).
description: Plugin description. description: Plugin description.
author: Plugin author. author: Plugin author.
capabilities: List of capability identifiers. capabilities: List of capability identifiers.
requires: Plugin requirements. requires: Plugin requirements.
engine: Engine manifest. engine: Engine manifest.
""" voices: Optional static voice catalog. None = not declared (use VoiceLister),
empty tuple = explicitly no static voices, non-empty = static catalog.
id: str """
name: str
version: str id: str
api_version: str name: str
description: str version: str
author: str api_version: str
capabilities: tuple[str, ...] = field(default_factory=tuple) description: str
requires: RequirementManifest = field(default_factory=RequirementManifest) author: str
engine: EngineManifest = field(default_factory=EngineManifest) capabilities: tuple[str, ...] = field(default_factory=tuple)
requires: RequirementManifest = field(default_factory=RequirementManifest)
engine: EngineManifest = field(default_factory=EngineManifest)
voices: tuple[VoiceManifest, ...] | None = None
+47 -6
View File
@@ -6,7 +6,7 @@ calling the Plugin Manager directly.
from __future__ import annotations from __future__ import annotations
from typing import Any, Iterator, Optional from typing import Any, Iterator
import numpy as np import numpy as np
@@ -17,6 +17,7 @@ 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.
""" """
import logging import logging
import tempfile import tempfile
@@ -29,6 +30,13 @@ def get_voices(plugin_id: str) -> tuple[str, ...]:
if not manager.has_plugin(plugin_id): if not manager.has_plugin(plugin_id):
return () return ()
# Check manifest for static voice catalog
plugin_info = manager.get_plugin(plugin_id)
if plugin_info is not None:
manifest = plugin_info.get("manifest")
if manifest is not None and manifest.voices is not None:
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}"),
@@ -183,12 +191,45 @@ class Pipeline:
self.dispose() self.dispose()
def create_pipeline(plugin_id: str, **kwargs: Any) -> Pipeline: def create_pipeline(
plugin_id: str,
*,
lang_code: str = "a",
device: str = "cpu",
) -> Pipeline:
"""Create a callable TTS pipeline via the Plugin Architecture. """Create a callable TTS pipeline via the Plugin Architecture.
Returns a :class:`Pipeline` whose ``__call__`` interface matches the Builds a proper HostContext and EngineConfig, then delegates to the
legacy ``TTSBackend`` callable protocol. PluginManager to create the engine. Returns a :class:`Pipeline` whose
``__call__`` interface matches the legacy ``TTSBackend`` callable protocol.
Args:
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
lang_code: Language code for the engine.
device: Device to use (e.g., "cpu", "cuda:0").
Returns:
A callable Pipeline instance.
""" """
import logging
import tempfile
from pathlib import Path
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
manager = get_plugin_manager() manager = get_plugin_manager()
engine = manager.create_engine(plugin_id, **kwargs)
return Pipeline(engine, **kwargs) ctx = HostContext(
config_dir=Path(tempfile.gettempdir()),
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
http_client=type("_StubHttpClient", (), {
"get": staticmethod(lambda url, **kw: None),
"post": staticmethod(lambda url, **kw: None),
})(),
)
config = EngineConfig(device=device, lang_code=lang_code)
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
return Pipeline(engine)
+12 -13
View File
@@ -1576,9 +1576,6 @@ def run_conversion_job(job: Job) -> None:
if provider_norm == "supertonic": if provider_norm == "supertonic":
pipelines[provider_norm] = create_pipeline( pipelines[provider_norm] = create_pipeline(
"supertonic", "supertonic",
sample_rate=SAMPLE_RATE,
auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
) )
return pipelines[provider_norm] return pipelines[provider_norm]
@@ -1863,17 +1860,17 @@ def run_conversion_job(job: Job) -> None:
canceller() canceller()
graphemes_raw = getattr(segment, "graphemes", "") or "" graphemes_raw = getattr(segment, "graphemes", "") or ""
graphemes = graphemes_raw.strip() graphemes = graphemes_raw.strip()
audio = _to_float32(getattr(segment, "audio", None)) audio = _to_float32(getattr(segment, "audio", None))
if audio.size == 0: if audio.size == 0:
continue continue
local_segments += 1 local_segments += 1
if chapter_sink: if chapter_sink:
chapter_sink.write(audio) chapter_sink.write(audio)
if audio_sink: if audio_sink:
audio_sink.write(audio) audio_sink.write(audio)
duration = len(audio) / SAMPLE_RATE duration = len(audio) / SAMPLE_RATE
processed_chars += len(graphemes) processed_chars += len(graphemes)
job.processed_characters = processed_chars job.processed_characters = processed_chars
@@ -1881,11 +1878,11 @@ def run_conversion_job(job: Job) -> None:
job.progress = min(processed_chars / job.total_characters, 0.999) job.progress = min(processed_chars / job.total_characters, 0.999)
else: else:
job.progress = 0.0 if processed_chars == 0 else 0.999 job.progress = 0.0 if processed_chars == 0 else 0.999
preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]")
prefix = f"{preview_prefix} · " if preview_prefix else "" prefix = f"{preview_prefix} · " if preview_prefix else ""
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or ''}: {preview_text[:80]}") job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or ''}: {preview_text[:80]}")
if subtitle_writer and audio_sink and graphemes: if subtitle_writer and audio_sink and graphemes:
subtitle_writer.write_segment( subtitle_writer.write_segment(
index=subtitle_index, index=subtitle_index,
@@ -1894,10 +1891,10 @@ def run_conversion_job(job: Job) -> None:
end=current_time + duration, end=current_time + duration,
) )
subtitle_index += 1 subtitle_index += 1
if audio_sink: if audio_sink:
current_time += duration current_time += duration
except OverflowError as exc: except OverflowError as exc:
job.add_log( job.add_log(
f"Skipped chunk — number too large for TTS conversion: {exc}", f"Skipped chunk — number too large for TTS conversion: {exc}",
@@ -2408,6 +2405,11 @@ def run_conversion_job(job: Job) -> None:
# Explicitly release the pipeline and force garbage collection to prevent # Explicitly release the pipeline and force garbage collection to prevent
# memory accumulation in the worker process, which can lead to host lockups. # memory accumulation in the worker process, which can lead to host lockups.
for p in pipelines.values():
try:
p.dispose()
except Exception:
pass
pipelines.clear() pipelines.clear()
pipeline = None pipeline = None
gc.collect() gc.collect()
@@ -2443,9 +2445,6 @@ def _load_pipeline(job: Job):
if provider == "supertonic": if provider == "supertonic":
return create_pipeline( return create_pipeline(
"supertonic", "supertonic",
sample_rate=SAMPLE_RATE,
auto_download=True,
total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5),
) )
device = "cpu" device = "cpu"
+12 -1
View File
@@ -14,6 +14,17 @@ _preview_pipelines: Dict[Tuple[str, str], Any] = {}
_preview_pipeline_lock = threading.Lock() _preview_pipeline_lock = threading.Lock()
def clear_preview_pipelines() -> None:
"""Dispose all cached preview pipelines and clear the cache."""
with _preview_pipeline_lock:
for pipeline in _preview_pipelines.values():
try:
pipeline.dispose()
except Exception:
pass
_preview_pipelines.clear()
def _select_device() -> str: def _select_device() -> str:
import platform import platform
@@ -138,7 +149,7 @@ def generate_preview_audio(
if provider == "supertonic": if provider == "supertonic":
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
pipeline = create_pipeline("supertonic", sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps) pipeline = create_pipeline("supertonic")
segments = pipeline( segments = pipeline(
normalized_text, normalized_text,
voice=voice_spec, voice=voice_spec,
+89
View File
@@ -0,0 +1,89 @@
# Architecture Amendment #1: EngineConfig — `lang_code` field
**Date:** 2026-07-12
**Status:** Accepted
**PR:** #12 (Normalize Pipeline Public API)
## Summary
Add `lang_code: str = "a"` to `EngineConfig` and update its definition to clarify the architectural contract.
## 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.
This is a functional regression relative to the pre-Plugin Architecture behavior.
## Decision
### 1. Updated EngineConfig definition
**Before:**
```
Immutable value object for engine initialization settings.
Contains only engine-specific settings, no resource references.
```
**After:**
```
Immutable configuration of an Engine instance.
Contains parameters that define how a particular Engine instance is
created and that remain constant throughout the lifetime of that Engine.
Plugin implementations may ignore fields that are not applicable to them.
```
### 2. New field
```python
@dataclass(frozen=True)
class EngineConfig:
device: str = "cpu"
lang_code: str = "a"
```
### 3. Architectural rules
- **Fields in EngineConfig are optional unless explicitly required by a plugin.**
- **Plugins MUST ignore unsupported EngineConfig fields.**
- **All parameters that may vary between individual synthesis requests must remain in `SynthesisRequest.parameters`.**
## Rationale
Analysis of real TTS engines (Kokoro, Piper, XTTS, Coqui, StyleTTS2, Fish Speech) confirmed:
| Parameter type | Where it belongs | Example |
|---------------|-----------------|---------|
| Engine instance config (immutable) | `EngineConfig` | `device`, `lang_code` |
| 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.
## Impact on existing plugins
| Plugin | `device` | `lang_code` | Notes |
|--------|----------|-------------|-------|
| Kokoro | Reads ✓ | Reads ✓ (was hardcoded, now from config) | Regression fixed |
| SuperTonic | Ignores | Ignores | No change — no language concept |
| Future plugins | May read | May ignore | Field-ignoring rule applies |
## Contract tests added
```python
class TestEngineConfigContract:
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_immutability_lang_code(self) # frozen — cannot reassign
def test_plugins_may_ignore_irrelevant_fields(self) # field-ignoring rule
def test_engine_config_contains_engine_instance_configuration(self) # definition
```
## Files changed
| File | Change |
|------|--------|
| `abogen/tts_plugin/types.py` | Updated docstring, added `lang_code: str = "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` |
| `tests/contracts/test_types_contract.py` | 5 new contract tests |
| `tests/contracts/test_plugin_manager_contract.py` | Updated assertion for `lang_code` |
| `tests/test_behavioral_regression.py` | Updated `test_engine_config_defaults` |
+177 -120
View File
@@ -1,121 +1,178 @@
"""Kokoro TTS Plugin for the TTS Plugin Architecture. """Kokoro TTS Plugin for the TTS Plugin Architecture.
This plugin provides a Kokoro-based TTS engine that implements the This plugin provides a Kokoro-based TTS engine that implements the
Plugin API contract. It wraps the existing Kokoro backend in the Plugin API contract. It wraps the existing Kokoro backend in the
new Engine/EngineSession architecture. new Engine/EngineSession architecture.
Exports: Exports:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Factory function - create_engine: Factory function
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceSourceManifest, VoiceManifest,
VoiceSourceManifest,
) )
from abogen.tts_plugin.types import EngineConfig from abogen.tts_plugin.types import EngineConfig
from .engine import KokoroEngine from .engine import KokoroEngine
def _load_kpipeline() -> Any: def _load_kpipeline() -> Any:
"""Lazy-load Kokoro dependencies.""" """Lazy-load Kokoro dependencies."""
from kokoro import KPipeline # type: ignore[import-not-found] from kokoro import KPipeline # type: ignore[import-not-found]
return KPipeline return KPipeline
PLUGIN_MANIFEST = PluginManifest( PLUGIN_MANIFEST = PluginManifest(
id="kokoro", id="kokoro",
name="Kokoro", name="Kokoro",
version="0.9.4", version="0.9.4",
api_version="1.0", api_version="1.0",
description="Kokoro TTS engine - high quality multilingual text-to-speech", description="Kokoro TTS engine - high quality multilingual text-to-speech",
author="Kokoro Team", author="Kokoro Team",
capabilities=("voice_list",), capabilities=("voice_list",),
requires=RequirementManifest( requires=RequirementManifest(
internet=False, internet=False,
), ),
engine=EngineManifest( engine=EngineManifest(
voiceSources=( voiceSources=(
VoiceSourceManifest( VoiceSourceManifest(
id="builtin", id="builtin",
name="Built-in Voices", name="Built-in Voices",
type="list", type="list",
config={"voices": "See listVoices()"}, config={"voices": "See listVoices()"},
), ),
), ),
parameters=( parameters=(
ParameterManifest( ParameterManifest(
id="speed", id="speed",
name="Speed", name="Speed",
description="Speech speed multiplier", description="Speech speed multiplier",
type="float", type="float",
default=1.0, default=1.0,
min=0.5, min=0.5,
max=2.0, max=2.0,
step=0.1, step=0.1,
), ),
), ),
audioFormats=( audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"), AudioFormatManifest(mime="audio/wav", extension="wav"),
), ),
), ),
) voices=(
VoiceManifest(id="af_alloy", name="Alloy", tags=("en", "female")),
MODEL_REQUIREMENTS: list[ModelManifest] = [] VoiceManifest(id="af_aoede", name="Aoede", tags=("en", "female")),
VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
VoiceManifest(id="af_heart", name="Heart", tags=("en", "female")),
def create_engine( VoiceManifest(id="af_jessica", name="Jessica", tags=("en", "female")),
context: HostContext, VoiceManifest(id="af_kore", name="Kore", tags=("en", "female")),
model_path: Path | None, VoiceManifest(id="af_nicole", name="Nicole", tags=("en", "female")),
config: EngineConfig, VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
) -> Engine: VoiceManifest(id="af_river", name="River", tags=("en", "female")),
"""Create a Kokoro engine instance. VoiceManifest(id="af_sarah", name="Sarah", tags=("en", "female")),
VoiceManifest(id="af_sky", name="Sky", tags=("en", "female")),
This function is the plugin entry point. It must be atomic: VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
succeed fully or raise EngineError and clean up. VoiceManifest(id="am_echo", name="Echo", tags=("en", "male")),
VoiceManifest(id="am_eric", name="Eric", tags=("en", "male")),
Args: VoiceManifest(id="am_fenrir", name="Fenrir", tags=("en", "male")),
context: Host services (config dir, logger, http client). VoiceManifest(id="am_liam", name="Liam", tags=("en", "male")),
model_path: Resolved model path, or None for default. VoiceManifest(id="am_michael", name="Michael", tags=("en", "male")),
config: Engine initialization settings (device, etc.). VoiceManifest(id="am_onyx", name="Onyx", tags=("en", "male")),
VoiceManifest(id="am_puck", name="Puck", tags=("en", "male")),
Returns: VoiceManifest(id="am_santa", name="Santa", tags=("en", "male")),
A fully initialized KokoroEngine instance. VoiceManifest(id="bf_alice", name="Alice", tags=("en", "female")),
VoiceManifest(id="bf_emma", name="Emma", tags=("en", "female")),
Raises: VoiceManifest(id="bf_isabella", name="Isabella", tags=("en", "female")),
EngineError: On failure. Cleans up partially created resources. VoiceManifest(id="bf_lily", name="Lily", tags=("en", "female")),
""" VoiceManifest(id="bm_daniel", name="Daniel", tags=("en", "male")),
try: VoiceManifest(id="bm_fable", name="Fable", tags=("en", "male")),
KPipeline = _load_kpipeline() VoiceManifest(id="bm_george", name="George", tags=("en", "male")),
VoiceManifest(id="bm_lewis", name="Lewis", tags=("en", "male")),
# Determine repo_id from model_path or use default VoiceManifest(id="ef_dora", name="Dora", tags=("es", "female")),
repo_id = "hexgrad/Kokoro-82M" VoiceManifest(id="em_alex", name="Alex", tags=("es", "male")),
if model_path is not None: VoiceManifest(id="em_santa", name="Santa", tags=("es", "male")),
# If a specific model path is provided, use it as repo_id VoiceManifest(id="ff_siwis", name="Siwis", tags=("fr", "female")),
repo_id = str(model_path) VoiceManifest(id="hf_alpha", name="Alpha", tags=("hi", "female")),
VoiceManifest(id="hf_beta", name="Beta", tags=("hi", "female")),
pipeline = KPipeline( VoiceManifest(id="hm_omega", name="Omega", tags=("hi", "male")),
lang_code="a", # Default language code VoiceManifest(id="hm_psi", name="Psi", tags=("hi", "male")),
repo_id=repo_id, VoiceManifest(id="if_sara", name="Sara", tags=("it", "female")),
device=config.device, VoiceManifest(id="im_nicola", name="Nicola", tags=("it", "male")),
) VoiceManifest(id="jf_alpha", name="Alpha", tags=("ja", "female")),
VoiceManifest(id="jf_gongitsune", name="Gongitsune", tags=("ja", "female")),
engine = KokoroEngine(pipeline, lang_code="a") VoiceManifest(id="jf_nezumi", name="Nezumi", tags=("ja", "female")),
return engine VoiceManifest(id="jf_tebukuro", name="Tebukuro", tags=("ja", "female")),
except Exception as e: VoiceManifest(id="jm_kumo", name="Kumo", tags=("ja", "male")),
from abogen.tts_plugin.errors import EngineError as EngineErrorClass VoiceManifest(id="pf_dora", name="Dora", tags=("pt", "female")),
raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e VoiceManifest(id="pm_alex", name="Alex", tags=("pt", "male")),
VoiceManifest(id="pm_santa", name="Santa", tags=("pt", "male")),
VoiceManifest(id="zf_xiaobei", name="Xiaobei", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoni", name="Xiaoni", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoxiao", name="Xiaoxiao", tags=("zh", "female")),
VoiceManifest(id="zf_xiaoyi", name="Xiaoyi", tags=("zh", "female")),
VoiceManifest(id="zm_yunjian", name="Yunjian", tags=("zh", "male")),
VoiceManifest(id="zm_yunxi", name="Yunxi", tags=("zh", "male")),
VoiceManifest(id="zm_yunxia", name="Yunxia", tags=("zh", "female")),
VoiceManifest(id="zm_yunyang", name="Yunyang", tags=("zh", "male")),
),
)
MODEL_REQUIREMENTS: list[ModelManifest] = []
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create a Kokoro engine instance.
This function is the plugin entry point. It must be atomic:
succeed fully or raise EngineError and clean up.
Args:
context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for default.
config: Engine initialization settings (device, etc.).
Returns:
A fully initialized KokoroEngine instance.
Raises:
EngineError: On failure. Cleans up partially created resources.
"""
try:
KPipeline = _load_kpipeline()
# Determine repo_id from model_path or use default
repo_id = "hexgrad/Kokoro-82M"
if model_path is not None:
# If a specific model path is provided, use it as repo_id
repo_id = str(model_path)
pipeline = KPipeline(
lang_code=config.lang_code,
repo_id=repo_id,
device=config.device,
)
engine = KokoroEngine(pipeline)
return engine
except Exception as e:
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
raise EngineErrorClass(f"Failed to create Kokoro engine: {e}") from e
+118 -186
View File
@@ -1,186 +1,118 @@
"""Kokoro Engine adapter for the TTS Plugin Architecture. """Kokoro Engine adapter for the TTS Plugin Architecture.
This module adapts the existing Kokoro backend to the new Engine/EngineSession This module adapts the existing Kokoro backend to the new Engine/EngineSession
protocol. It wraps the KokoroBackend without modifying it. protocol. It wraps the KokoroBackend without modifying it.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Iterator from typing import Any
import numpy as np import numpy as np
from abogen.tts_plugin.capabilities import VoiceLister from abogen.tts_plugin.capabilities import VoiceLister
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError, InvalidInputError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, SynthesisRequest,
SynthesisRequest, SynthesizedAudio,
SynthesizedAudio, )
VoiceSelection,
) logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # Sample rate for Kokoro audio
_KOKORO_SAMPLE_RATE = 24000
# Kokoro voice list - source of truth
_KOKORO_VOICES = (
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jessica", class KokoroSession:
"af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", """EngineSession implementation for Kokoro.
"af_sky", "am_adam", "am_echo", "am_eric", "am_fenrir",
"am_liam", "am_michael", "am_onyx", "am_puck", "am_santa", Owns mutable execution state for synthesis.
"bf_alice", "bf_emma", "bf_isabella", "bf_lily", NOT thread-safe.
"bm_daniel", "bm_fable", "bm_george", "bm_lewis", """
"ef_dora", "em_alex", "em_santa",
"ff_siwis", "hf_alpha", "hf_beta", "hm_omega", "hm_psi", def __init__(self, pipeline: Any) -> None:
"if_sara", "im_nicola", self._pipeline = pipeline
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo", self._disposed = False
"pf_dora", "pm_alex", "pm_santa",
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi", def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang", """Synthesize audio from text using Kokoro."""
) if self._disposed:
raise EngineError("Session disposed")
# Voice display names mapping
_VOICE_DISPLAY_NAMES: dict[str, str] = { try:
"af_alloy": "Alloy", "af_aoede": "Aoede", "af_bella": "Bella", voice = request.voice.key
"af_heart": "Heart", "af_jessica": "Jessica", "af_kore": "Kore", speed = request.parameters.values.get("speed", 1.0)
"af_nicole": "Nicole", "af_nova": "Nova", "af_river": "River", split_pattern = request.parameters.values.get("split_pattern", None)
"af_sarah": "Sarah", "af_sky": "Sky", "am_adam": "Adam",
"am_echo": "Echo", "am_eric": "Eric", "am_fenrir": "Fenrir", audio_parts: list[np.ndarray] = []
"am_liam": "Liam", "am_michael": "Michael", "am_onyx": "Onyx", for segment in self._pipeline(
"am_puck": "Puck", "am_santa": "Santa", "bf_alice": "Alice", request.text,
"bf_emma": "Emma", "bf_isabella": "Isabella", "bf_lily": "Lily", voice=voice,
"bm_daniel": "Daniel", "bm_fable": "Fable", "bm_george": "George", speed=speed,
"bm_lewis": "Lewis", "ef_dora": "Dora", "em_alex": "Alex", split_pattern=split_pattern,
"em_santa": "Santa", "ff_siwis": "Siwis", "hf_alpha": "Alpha", ):
"hf_beta": "Beta", "hm_omega": "Omega", "hm_psi": "Psi", audio = segment.audio
"if_sara": "Sara", "im_nicola": "Nicola", if hasattr(audio, "numpy"):
"jf_alpha": "Alpha", "jf_gongitsune": "Gongitsune", audio = audio.numpy()
"jf_nezumi": "Nezumi", "jf_tebukuro": "Tebukuro", "jm_kumo": "Kumo", audio_parts.append(np.asarray(audio, dtype="float32"))
"pf_dora": "Dora", "pm_alex": "Alex", "pm_santa": "Santa",
"zf_xiaobei": "Xiaobei", "zf_xiaoni": "Xiaoni", if not audio_parts:
"zf_xiaoxiao": "Xiaoxiao", "zf_xiaoyi": "Xiaoyi", return SynthesizedAudio(
"zm_yunjian": "Yunjian", "zm_yunxi": "Yunxi", data=b"",
"zm_yunxia": "Yunxia", "zm_yunyang": "Yunyang", format=AudioFormat(mime="audio/wav", extension="wav"),
} duration=Duration(seconds=0.0),
)
# Sample rate for Kokoro audio
_KOKORO_SAMPLE_RATE = 24000 combined = np.concatenate(audio_parts).astype("float32", copy=False)
audio_bytes = combined.tobytes()
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
class KokoroSession:
"""EngineSession implementation for Kokoro. return SynthesizedAudio(
data=audio_bytes,
Owns mutable execution state for synthesis. format=AudioFormat(mime="audio/wav", extension="wav"),
NOT thread-safe. duration=Duration(seconds=duration_seconds),
""" )
except EngineError:
def __init__(self, pipeline: Any, lang_code: str) -> None: raise
self._pipeline = pipeline except Exception as e:
self._lang_code = lang_code raise EngineError(f"Synthesis failed: {e}") from e
self._disposed = False
def dispose(self) -> None:
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: """Release session resources. Idempotent."""
"""Synthesize audio from text using Kokoro.""" self._disposed = True
if self._disposed:
raise EngineError("Session disposed")
class KokoroEngine:
try: """Engine implementation for Kokoro.
voice = request.voice.key
speed = request.parameters.values.get("speed", 1.0) Factory for KokoroSession instances. Stateless and thread-safe.
split_pattern = request.parameters.values.get("split_pattern", None) """
audio_parts: list[np.ndarray] = [] def __init__(self, pipeline: Any) -> None:
for segment in self._pipeline( self._pipeline = pipeline
request.text, self._disposed = False
voice=voice,
speed=speed, def createSession(self) -> KokoroSession:
split_pattern=split_pattern, """Create a new KokoroSession."""
): if self._disposed:
audio = segment.audio raise EngineError("Engine disposed")
if hasattr(audio, "numpy"): return KokoroSession(self._pipeline)
audio = audio.numpy()
audio_parts.append(np.asarray(audio, dtype="float32")) def dispose(self) -> None:
"""Release engine resources. Idempotent."""
if not audio_parts: self._disposed = True
return SynthesizedAudio(
data=b"", def listVoices(self, sourceId: str) -> list[VoiceManifest]:
format=AudioFormat(mime="audio/wav", extension="wav"), """List available Kokoro voices. Implements VoiceLister capability.
duration=Duration(seconds=0.0),
) Note: Static voices are declared in the plugin manifest.
This method is a fallback for dynamic plugins.
combined = np.concatenate(audio_parts).astype("float32", copy=False) """
audio_bytes = combined.tobytes() if self._disposed:
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE raise EngineError("Engine disposed")
return []
return SynthesizedAudio(
data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds),
)
except EngineError:
raise
except Exception as e:
raise EngineError(f"Synthesis failed: {e}") from e
def dispose(self) -> None:
"""Release session resources. Idempotent."""
self._disposed = True
class KokoroEngine:
"""Engine implementation for Kokoro.
Factory for KokoroSession instances. Stateless and thread-safe.
"""
def __init__(self, pipeline: Any, lang_code: str) -> None:
self._pipeline = pipeline
self._lang_code = lang_code
self._disposed = False
def createSession(self) -> KokoroSession:
"""Create a new KokoroSession."""
if self._disposed:
raise EngineError("Engine disposed")
return KokoroSession(self._pipeline, self._lang_code)
def dispose(self) -> None:
"""Release engine resources. Idempotent."""
self._disposed = True
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available Kokoro voices. Implements VoiceLister capability."""
if self._disposed:
raise EngineError("Engine disposed")
return [
VoiceManifest(
id=voice_id,
name=_VOICE_DISPLAY_NAMES.get(voice_id, voice_id),
tags=(_get_language_tag(voice_id), _get_gender_tag(voice_id)),
)
for voice_id in _KOKORO_VOICES
]
def _get_language_tag(voice_id: str) -> str:
"""Extract language tag from voice ID."""
prefix = voice_id.split("_")[0]
lang_map = {
"af": "en-us", "am": "en-us", "bf": "en-gb", "bm": "en-gb",
"ef": "es", "em": "es", "ff": "fr", "hf": "hi", "hm": "hi",
"if": "it", "im": "it", "jf": "ja", "jm": "ja",
"pf": "pt", "pm": "pt", "zf": "zh", "zm": "zh",
}
return lang_map.get(prefix, "unknown")
def _get_gender_tag(voice_id: str) -> str:
"""Extract gender tag from voice ID."""
prefix = voice_id.split("_")[0]
if prefix.startswith("a") or prefix.startswith("b") or prefix.startswith("e"):
return "female" if prefix[1] == "f" else "male"
return "unknown"
+136 -123
View File
@@ -1,123 +1,136 @@
"""SuperTonic TTS Plugin for the TTS Plugin Architecture. """SuperTonic TTS Plugin for the TTS Plugin Architecture.
This plugin provides a SuperTonic-based TTS engine that implements the This plugin provides a SuperTonic-based TTS engine that implements the
Plugin API contract. It wraps the existing SuperTonic backend in the Plugin API contract. It wraps the existing SuperTonic backend in the
new Engine/EngineSession architecture. new Engine/EngineSession architecture.
Exports: Exports:
- PLUGIN_MANIFEST: PluginManifest - PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest] - MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Factory function - create_engine: Factory function
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from abogen.tts_plugin.engine import Engine from abogen.tts_plugin.engine import Engine
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import ( from abogen.tts_plugin.manifest import (
AudioFormatManifest, AudioFormatManifest,
EngineManifest, EngineManifest,
ModelManifest, ModelManifest,
ParameterManifest, ParameterManifest,
PluginManifest, PluginManifest,
RequirementManifest, RequirementManifest,
VoiceSourceManifest, VoiceManifest,
) VoiceSourceManifest,
from abogen.tts_plugin.types import EngineConfig )
from abogen.tts_plugin.types import EngineConfig
from .engine import SuperTonicEngine
from .engine import SuperTonicEngine
def _load_supertonic_pipeline(sample_rate: int = 24000, auto_download: bool = True, total_steps: int = 5) -> Any:
"""Lazy-load SuperTonic dependencies and create pipeline.""" def _load_supertonic_pipeline() -> Any:
from plugins.supertonic.pipeline import SupertonicPipeline """Lazy-load SuperTonic dependencies and create pipeline."""
from plugins.supertonic.pipeline import SupertonicPipeline
return SupertonicPipeline(
sample_rate=sample_rate, return SupertonicPipeline(
auto_download=auto_download, sample_rate=24000,
total_steps=total_steps, auto_download=True,
) total_steps=5,
)
PLUGIN_MANIFEST = PluginManifest(
id="supertonic", PLUGIN_MANIFEST = PluginManifest(
name="SuperTonic", id="supertonic",
version="0.1.0", name="SuperTonic",
api_version="1.0", version="0.1.0",
description="SuperTonic TTS engine - fast high-quality text-to-speech", api_version="1.0",
author="SuperTonic Team", description="SuperTonic TTS engine - fast high-quality text-to-speech",
capabilities=("voice_list",), author="SuperTonic Team",
requires=RequirementManifest( capabilities=("voice_list",),
internet=False, requires=RequirementManifest(
), internet=False,
engine=EngineManifest( ),
voiceSources=( engine=EngineManifest(
VoiceSourceManifest( voiceSources=(
id="builtin", VoiceSourceManifest(
name="Built-in Voices", id="builtin",
type="list", name="Built-in Voices",
config={"voices": "See listVoices()"}, type="list",
), config={"voices": "See listVoices()"},
), ),
parameters=( ),
ParameterManifest( parameters=(
id="speed", ParameterManifest(
name="Speed", id="speed",
description="Speech speed multiplier", name="Speed",
type="float", description="Speech speed multiplier",
default=1.0, type="float",
min=0.7, default=1.0,
max=2.0, min=0.7,
step=0.1, max=2.0,
), step=0.1,
ParameterManifest( ),
id="total_steps", ParameterManifest(
name="Quality Steps", id="total_steps",
description="Inference steps (higher = better quality, slower)", name="Quality Steps",
type="int", description="Inference steps (higher = better quality, slower)",
default=5, type="int",
min=2, default=5,
max=15, min=2,
step=1, max=15,
), step=1,
), ),
audioFormats=( ),
AudioFormatManifest(mime="audio/wav", extension="wav"), audioFormats=(
), AudioFormatManifest(mime="audio/wav", extension="wav"),
), ),
) ),
voices=(
MODEL_REQUIREMENTS: list[ModelManifest] = [] VoiceManifest(id="M1", name="Male 1", tags=("male",)),
VoiceManifest(id="M2", name="Male 2", tags=("male",)),
VoiceManifest(id="M3", name="Male 3", tags=("male",)),
def create_engine( VoiceManifest(id="M4", name="Male 4", tags=("male",)),
context: HostContext, VoiceManifest(id="M5", name="Male 5", tags=("male",)),
model_path: Path | None, VoiceManifest(id="F1", name="Female 1", tags=("female",)),
config: EngineConfig, VoiceManifest(id="F2", name="Female 2", tags=("female",)),
) -> Engine: VoiceManifest(id="F3", name="Female 3", tags=("female",)),
"""Create a SuperTonic engine instance. VoiceManifest(id="F4", name="Female 4", tags=("female",)),
VoiceManifest(id="F5", name="Female 5", tags=("female",)),
This function is the plugin entry point. It must be atomic: ),
succeed fully or raise EngineError and clean up. )
Args: MODEL_REQUIREMENTS: list[ModelManifest] = []
context: Host services (config dir, logger, http client).
model_path: Resolved model path, or None for default.
config: Engine initialization settings (device, etc.). def create_engine(
context: HostContext,
Returns: model_path: Path | None,
A fully initialized SuperTonicEngine instance. config: EngineConfig,
) -> Engine:
Raises: """Create a SuperTonic engine instance.
EngineError: On failure. Cleans up partially created resources.
""" This function is the plugin entry point. It must be atomic:
try: succeed fully or raise EngineError and clean up.
pipeline = _load_supertonic_pipeline()
engine = SuperTonicEngine(pipeline) Args:
return engine context: Host services (config dir, logger, http client).
except Exception as e: model_path: Resolved model path, or None for default.
from abogen.tts_plugin.errors import EngineError as EngineErrorClass config: Engine initialization settings (device, etc.).
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
Returns:
A fully initialized SuperTonicEngine instance.
Raises:
EngineError: On failure. Cleans up partially created resources.
"""
try:
pipeline = _load_supertonic_pipeline()
engine = SuperTonicEngine(pipeline)
return engine
except Exception as e:
from abogen.tts_plugin.errors import EngineError as EngineErrorClass
raise EngineErrorClass(f"Failed to create SuperTonic engine: {e}") from e
+125 -156
View File
@@ -1,156 +1,125 @@
"""SuperTonic Engine adapter for the TTS Plugin Architecture. """SuperTonic Engine adapter for the TTS Plugin Architecture.
This module adapts the existing SuperTonic backend to the new Engine/EngineSession This module adapts the existing SuperTonic backend to the new Engine/EngineSession
protocol. It wraps the SupertonicPipeline without modifying it. protocol. It wraps the SupertonicPipeline without modifying it.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import logging import logging
from typing import Any, Optional from typing import Any
import numpy as np import numpy as np
from abogen.tts_plugin.capabilities import VoiceLister from abogen.tts_plugin.capabilities import VoiceLister
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError, InvalidInputError from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
Duration, Duration,
ParameterValues, SynthesisRequest,
SynthesisRequest, SynthesizedAudio,
SynthesizedAudio, )
VoiceSelection,
) logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) # Sample rate for SuperTonic audio
_SUPERTONIC_SAMPLE_RATE = 24000
# SuperTonic voice list - source of truth
_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
class SuperTonicSession:
# Voice display names mapping """EngineSession implementation for SuperTonic.
_VOICE_DISPLAY_NAMES: dict[str, str] = {
"M1": "Male 1", Owns mutable execution state for synthesis.
"M2": "Male 2", NOT thread-safe.
"M3": "Male 3", """
"M4": "Male 4",
"M5": "Male 5", def __init__(self, pipeline: Any) -> None:
"F1": "Female 1", self._pipeline = pipeline
"F2": "Female 2", self._disposed = False
"F3": "Female 3",
"F4": "Female 4", def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
"F5": "Female 5", """Synthesize audio from text using SuperTonic."""
} if self._disposed:
raise EngineError("Session disposed")
# Sample rate for SuperTonic audio
_SUPERTONIC_SAMPLE_RATE = 24000 try:
import soundfile as sf
class SuperTonicSession: voice = request.voice.key
"""EngineSession implementation for SuperTonic. speed = float(request.parameters.values.get("speed", 1.0))
total_steps = request.parameters.values.get("total_steps", None)
Owns mutable execution state for synthesis. split_pattern = request.parameters.values.get("split_pattern", None)
NOT thread-safe.
""" if total_steps is not None:
total_steps = int(total_steps)
def __init__(self, pipeline: Any) -> None:
self._pipeline = pipeline audio_parts: list[np.ndarray] = []
self._disposed = False for segment in self._pipeline(
request.text,
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: voice=voice,
"""Synthesize audio from text using SuperTonic.""" speed=speed,
if self._disposed: split_pattern=split_pattern,
raise EngineError("Session disposed") total_steps=total_steps,
):
try: audio_parts.append(segment.audio)
import soundfile as sf
if not audio_parts:
voice = request.voice.key return SynthesizedAudio(
speed = float(request.parameters.values.get("speed", 1.0)) data=b"",
total_steps = request.parameters.values.get("total_steps", None) format=AudioFormat(mime="audio/wav", extension="wav"),
split_pattern = request.parameters.values.get("split_pattern", None) duration=Duration(seconds=0.0),
)
if total_steps is not None:
total_steps = int(total_steps) combined = np.concatenate(audio_parts).astype("float32", copy=False)
buf = io.BytesIO()
audio_parts: list[np.ndarray] = [] sf.write(buf, combined, self._pipeline.sample_rate, format="WAV")
for segment in self._pipeline( audio_bytes = buf.getvalue()
request.text, duration_seconds = len(combined) / self._pipeline.sample_rate
voice=voice,
speed=speed, return SynthesizedAudio(
split_pattern=split_pattern, data=audio_bytes,
total_steps=total_steps, format=AudioFormat(mime="audio/wav", extension="wav"),
): duration=Duration(seconds=duration_seconds),
audio_parts.append(segment.audio) )
except EngineError:
if not audio_parts: raise
return SynthesizedAudio( except Exception as e:
data=b"", raise EngineError(f"Synthesis failed: {e}") from e
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=0.0), def dispose(self) -> None:
) """Release session resources. Idempotent."""
self._disposed = True
combined = np.concatenate(audio_parts).astype("float32", copy=False)
buf = io.BytesIO()
sf.write(buf, combined, self._pipeline.sample_rate, format="WAV") class SuperTonicEngine:
audio_bytes = buf.getvalue() """Engine implementation for SuperTonic.
duration_seconds = len(combined) / self._pipeline.sample_rate
Factory for SuperTonicSession instances. Stateless and thread-safe.
return SynthesizedAudio( """
data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), def __init__(self, pipeline: Any) -> None:
duration=Duration(seconds=duration_seconds), self._pipeline = pipeline
) self._disposed = False
except EngineError:
raise def createSession(self) -> SuperTonicSession:
except Exception as e: """Create a new SuperTonicSession."""
raise EngineError(f"Synthesis failed: {e}") from e if self._disposed:
raise EngineError("Engine disposed")
def dispose(self) -> None: return SuperTonicSession(self._pipeline)
"""Release session resources. Idempotent."""
self._disposed = True def dispose(self) -> None:
"""Release engine resources. Idempotent."""
self._disposed = True
class SuperTonicEngine:
"""Engine implementation for SuperTonic. def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available SuperTonic voices. Implements VoiceLister capability.
Factory for SuperTonicSession instances. Stateless and thread-safe.
""" Note: Static voice catalog is declared in plugin manifest.
This method is retained for VoiceLister interface compliance.
def __init__(self, pipeline: Any) -> None: """
self._pipeline = pipeline if self._disposed:
self._disposed = False raise EngineError("Engine disposed")
return []
def createSession(self) -> SuperTonicSession:
"""Create a new SuperTonicSession."""
if self._disposed:
raise EngineError("Engine disposed")
return SuperTonicSession(self._pipeline)
def dispose(self) -> None:
"""Release engine resources. Idempotent."""
self._disposed = True
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
"""List available SuperTonic voices. Implements VoiceLister capability."""
if self._disposed:
raise EngineError("Engine disposed")
return [
VoiceManifest(
id=voice_id,
name=_VOICE_DISPLAY_NAMES.get(voice_id, voice_id),
tags=(_get_gender_tag(voice_id),),
)
for voice_id in _SUPERTONIC_VOICES
]
def _get_gender_tag(voice_id: str) -> str:
"""Extract gender tag from voice ID."""
if voice_id.startswith("M"):
return "male"
elif voice_id.startswith("F"):
return "female"
return "unknown"
+196 -188
View File
@@ -1,188 +1,196 @@
"""Tests for the Kokoro TTS Plugin. """Tests for the Kokoro TTS Plugin.
These tests verify that the Kokoro plugin: These tests verify that the Kokoro plugin:
- Loads correctly through the Plugin Loader - Loads correctly through the Plugin Loader
- Has a valid manifest - Has a valid manifest
- Creates a valid Engine - Creates a valid Engine
- Satisfies the Engine/EngineSession contract (via EngineContractMixin) - Satisfies the Engine/EngineSession contract (via EngineContractMixin)
- Implements VoiceLister capability - Implements VoiceLister capability
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from abogen.tts_plugin.engine import Engine, EngineSession from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.host_context import HostContext from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.loader import load_plugin_from_dir from abogen.tts_plugin.loader import load_plugin_from_dir
from abogen.tts_plugin.manifest import PluginManifest from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
EngineConfig, EngineConfig,
ParameterValues, ParameterValues,
SynthesisRequest, SynthesisRequest,
VoiceSelection, VoiceSelection,
) )
from tests.contracts.engine_contract import EngineContractMixin from tests.contracts.engine_contract import EngineContractMixin
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# Helpers # Helpers
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
def _kokoro_available() -> bool: def _kokoro_available() -> bool:
try: try:
from kokoro import KPipeline # type: ignore[import-not-found] from kokoro import KPipeline # type: ignore[import-not-found]
return True return True
except ImportError: except ImportError:
return False return False
def _make_mock_engine() -> Any: def _make_mock_engine() -> Any:
from plugins.kokoro.engine import KokoroEngine from plugins.kokoro.engine import KokoroEngine
class MockPipeline: class MockPipeline:
def __call__(self, text, voice, speed, split_pattern=None): def __call__(self, text, voice, speed, split_pattern=None):
class MockSegment: class MockSegment:
def __init__(self): def __init__(self):
self.audio = MockAudio() self.audio = MockAudio()
class MockAudio: class MockAudio:
def numpy(self): def numpy(self):
import numpy as np import numpy as np
return np.zeros(24000, dtype="float32") return np.zeros(24000, dtype="float32")
return [MockSegment()] return [MockSegment()]
return KokoroEngine(MockPipeline(), "a") engine = KokoroEngine(MockPipeline())
# Override listVoices for testing (real engine reads from manifest)
# ────────────────────────────────────────────────────────────── from abogen.tts_plugin.manifest import VoiceManifest
# Fixtures engine.listVoices = lambda source_id: [
# ────────────────────────────────────────────────────────────── VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("en",)),
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("es",)),
@pytest.fixture ]
def kokoro_plugin_dir() -> Path: return engine
return Path(__file__).parent.parent / "plugins" / "kokoro"
# ──────────────────────────────────────────────────────────────
@pytest.fixture # Fixtures
def host_context(tmp_path: Path) -> HostContext: # ──────────────────────────────────────────────────────────────
class FakeHttpClient:
def get(self, url: str, **kwargs: object) -> object: @pytest.fixture
return None def kokoro_plugin_dir() -> Path:
def post(self, url: str, **kwargs: object) -> object: return Path(__file__).parent.parent / "plugins" / "kokoro"
return None
return HostContext( @pytest.fixture
config_dir=tmp_path, def host_context(tmp_path: Path) -> HostContext:
logger=logging.getLogger("test"), class FakeHttpClient:
http_client=FakeHttpClient(), def get(self, url: str, **kwargs: object) -> object:
) return None
def post(self, url: str, **kwargs: object) -> object:
return None
@pytest.fixture
def engine() -> Engine: return HostContext(
return _make_mock_engine() config_dir=tmp_path,
logger=logging.getLogger("test"),
http_client=FakeHttpClient(),
# ────────────────────────────────────────────────────────────── )
# Plugin Loading Tests
# ──────────────────────────────────────────────────────────────
@pytest.fixture
class TestKokoroPluginLoading: def engine() -> Engine:
return _make_mock_engine()
def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True # ──────────────────────────────────────────────────────────────
assert result.manifest is not None # Plugin Loading Tests
assert result.create_engine is not None # ──────────────────────────────────────────────────────────────
def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None: class TestKokoroPluginLoading:
result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True def test_plugin_loads_successfully(self, kokoro_plugin_dir: Path) -> None:
manifest = result.manifest result = load_plugin_from_dir(kokoro_plugin_dir)
assert isinstance(manifest, PluginManifest) assert result.success is True
assert manifest.id == "kokoro" assert result.manifest is not None
assert manifest.name == "Kokoro" assert result.create_engine is not None
assert manifest.api_version == "1.0"
def test_plugin_has_valid_manifest(self, kokoro_plugin_dir: Path) -> None:
def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None: result = load_plugin_from_dir(kokoro_plugin_dir)
result = load_plugin_from_dir(kokoro_plugin_dir) assert result.success is True
assert result.success is True manifest = result.manifest
assert result.model_requirements is not None assert isinstance(manifest, PluginManifest)
assert isinstance(result.model_requirements, tuple) assert manifest.id == "kokoro"
assert manifest.name == "Kokoro"
def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None: assert manifest.api_version == "1.0"
result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True def test_plugin_has_model_requirements(self, kokoro_plugin_dir: Path) -> None:
assert "voice_list" in result.manifest.capabilities result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True
def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None: assert result.model_requirements is not None
result = load_plugin_from_dir(kokoro_plugin_dir) assert isinstance(result.model_requirements, tuple)
assert result.success is True
engine_manifest = result.manifest.engine def test_plugin_manifest_capabilities(self, kokoro_plugin_dir: Path) -> None:
assert len(engine_manifest.voiceSources) > 0 result = load_plugin_from_dir(kokoro_plugin_dir)
assert len(engine_manifest.audioFormats) > 0 assert result.success is True
assert len(engine_manifest.parameters) > 0 assert "voice_list" in result.manifest.capabilities
def test_plugin_manifest_engine(self, kokoro_plugin_dir: Path) -> None:
# ────────────────────────────────────────────────────────────── result = load_plugin_from_dir(kokoro_plugin_dir)
# Engine Creation (real backend, skipped if not installed) assert result.success is True
# ────────────────────────────────────────────────────────────── engine_manifest = result.manifest.engine
assert len(engine_manifest.voiceSources) > 0
class TestKokoroEngineCreation: assert len(engine_manifest.audioFormats) > 0
assert len(engine_manifest.parameters) > 0
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) # ──────────────────────────────────────────────────────────────
assert result.success is True # Engine Creation (real backend, skipped if not installed)
engine = result.create_engine(host_context, None, EngineConfig()) # ──────────────────────────────────────────────────────────────
assert isinstance(engine, Engine)
engine.dispose() class TestKokoroEngineCreation:
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed") @pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None: def test_create_engine(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
result = load_plugin_from_dir(kokoro_plugin_dir) result = load_plugin_from_dir(kokoro_plugin_dir)
assert result.success is True assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig()) engine = result.create_engine(host_context, None, EngineConfig())
assert isinstance(engine, Engine) assert isinstance(engine, Engine)
engine.dispose() engine.dispose()
@pytest.mark.skipif(not _kokoro_available(), reason="Kokoro not installed")
# ────────────────────────────────────────────────────────────── def test_engine_satisfies_protocol(self, kokoro_plugin_dir: Path, host_context: HostContext) -> None:
# Engine / Session Contract (inherited from base) result = load_plugin_from_dir(kokoro_plugin_dir)
# ────────────────────────────────────────────────────────────── assert result.success is True
engine = result.create_engine(host_context, None, EngineConfig())
class TestKokoroEngineContract(EngineContractMixin): assert isinstance(engine, Engine)
"""Every test from EngineContractMixin runs against KokoroEngine.""" engine.dispose()
@pytest.fixture
def default_voice(self) -> str: # ──────────────────────────────────────────────────────────────
return "af_nova" # Engine / Session Contract (inherited from base)
# ──────────────────────────────────────────────────────────────
# ────────────────────────────────────────────────────────────── class TestKokoroEngineContract(EngineContractMixin):
# VoiceLister Tests """Every test from EngineContractMixin runs against KokoroEngine."""
# ──────────────────────────────────────────────────────────────
@pytest.fixture
class TestKokoroVoiceLister: def default_voice(self) -> str:
return "af_nova"
def test_list_voices(self) -> None:
engine = _make_mock_engine()
voices = engine.listVoices("builtin") # ──────────────────────────────────────────────────────────────
assert len(voices) > 0 # VoiceLister Tests
assert all(hasattr(v, "id") for v in voices) # ──────────────────────────────────────────────────────────────
assert all(hasattr(v, "name") for v in voices)
engine.dispose() class TestKokoroVoiceLister:
def test_voices_have_tags(self) -> None: def test_list_voices(self) -> None:
engine = _make_mock_engine() engine = _make_mock_engine()
voices = engine.listVoices("builtin") voices = engine.listVoices("builtin")
for voice in voices: assert len(voices) > 0
assert isinstance(voice.tags, tuple) assert all(hasattr(v, "id") for v in voices)
assert len(voice.tags) > 0 assert all(hasattr(v, "name") for v in voices)
engine.dispose() engine.dispose()
def test_voices_have_tags(self) -> None:
engine = _make_mock_engine()
voices = engine.listVoices("builtin")
for voice in voices:
assert isinstance(voice.tags, tuple)
assert len(voice.tags) > 0
engine.dispose()