feat(tts-plugin): complete Plugin Architecture refactor

- Normalize Pipeline public API: create_pipeline(plugin_id, *, lang_code, device)
- EngineConfig: add lang_code field per Architecture Amendment #1
- Kokoro plugin reads config.lang_code (fixes functional regression)
- Static voice catalog in PluginManifest.voices (None = dynamic/VoiceLister)
- get_voices() reads from manifest without creating Engine
- Remove dead kwargs (sample_rate, auto_download, total_steps) from SuperTonic
- Clean up unused imports and dead code in engine implementations
- Fix test expectations for VoiceLister (mock overrides)
- Add clear_preview_pipelines() for resource management
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:20 +03:00
parent 735098d7cd
commit c094b94704
49 changed files with 18052 additions and 17985 deletions
+8 -2
View File
@@ -94,12 +94,18 @@ class SynthesizedAudio:
@dataclass(frozen=True)
class EngineConfig:
"""Immutable value object for engine initialization settings.
"""Immutable configuration of an Engine instance.
Contains only engine-specific settings, no resource references.
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.
Attributes:
device: Device to use (e.g., "cpu", "cuda:0").
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
Plugins that do not require a language code ignore this field.
"""
device: str = "cpu"
lang_code: str = "a"
+4
View File
@@ -247,4 +247,8 @@ def run_debug_tts_wavs(
"sample_rate": SAMPLE_RATE,
}
(run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
try:
pipeline.dispose()
except Exception:
pass
return manifest
@@ -164,6 +164,9 @@ class TestCreatePipelineCompat:
def test_create_pipeline_returns_callable(self):
"""create_pipeline returns a callable backend."""
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.types import EngineConfig
# Mock the plugin manager
with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
mock_manager = MagicMock()
@@ -175,7 +178,14 @@ class TestCreatePipelineCompat:
backend = create_pipeline("kokoro", lang_code="a", device="cpu")
assert callable(backend)
mock_manager.create_engine.assert_called_once_with("kokoro", lang_code="a", device="cpu")
mock_manager.create_engine.assert_called_once()
call_args = mock_manager.create_engine.call_args
assert call_args.args[0] == "kokoro"
assert isinstance(call_args.kwargs["context"], HostContext)
assert call_args.kwargs["model_path"] is None
assert isinstance(call_args.kwargs["config"], EngineConfig)
assert call_args.kwargs["config"].device == "cpu"
assert call_args.kwargs["config"].lang_code == "a"
def test_create_pipeline_raises_for_unknown_plugin(self):
"""create_pipeline raises KeyError for unknown plugins."""
+37
View File
@@ -192,11 +192,24 @@ class TestEngineConfigContract:
config = EngineConfig(device="cuda:0")
assert config.device == "cuda:0"
def test_default_lang_code(self) -> None:
config = EngineConfig()
assert config.lang_code == "a"
def test_custom_lang_code(self) -> None:
config = EngineConfig(lang_code="j")
assert config.lang_code == "j"
def test_immutability(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.device = "cuda:0" # type: ignore[misc]
def test_immutability_lang_code(self) -> None:
config = EngineConfig()
with pytest.raises(AttributeError):
config.lang_code = "j" # type: ignore[misc]
def test_unknown_keys_ignored_per_spec(self) -> None:
"""Architecture spec: Unknown keys are ignored (no error).
@@ -205,3 +218,27 @@ class TestEngineConfigContract:
"""
config = EngineConfig()
assert config.device == "cpu"
def test_plugins_may_ignore_irrelevant_fields(self) -> None:
"""Architecture Amendment #1: Plugins ignore unsupported fields.
EngineConfig may contain fields that are not relevant to every plugin.
Plugins MUST ignore fields they do not need, not raise on them.
"""
config = EngineConfig(device="cuda:0", lang_code="j")
assert config.device == "cuda:0"
assert config.lang_code == "j"
# A plugin that only needs device simply reads config.device
# and ignores config.lang_code — this must not raise.
def test_engine_config_contains_engine_instance_configuration(self) -> None:
"""Architecture Amendment #1: EngineConfig definition.
EngineConfig contains parameters that define how a particular
Engine instance is created and that remain constant throughout
the lifetime of that Engine.
"""
config = EngineConfig(device="cpu", lang_code="a")
# Both fields are init-time, immutable, engine-scoped.
assert config.device == "cpu"
assert config.lang_code == "a"
+2
View File
@@ -92,6 +92,7 @@ class MockEngine:
def __init__(
self,
voice_manifests: list[VoiceManifest] | None = None,
**kwargs: Any,
) -> None:
self._disposed = False
self._voice_manifests = voice_manifests or [
@@ -930,6 +931,7 @@ class TestValueObjectsBehavioral:
config = EngineConfig()
assert config.device == "cpu"
assert config.lang_code == "a"
def test_parameter_values_defaults(self) -> None:
pv = ParameterValues()
+9 -1
View File
@@ -57,7 +57,15 @@ def _make_mock_engine() -> Any:
def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
return [MockSegment()]
return SuperTonicEngine(MockPipeline())
engine = SuperTonicEngine(MockPipeline())
# Override listVoices for testing (real engine reads from manifest)
from abogen.tts_plugin.manifest import VoiceManifest
engine.listVoices = lambda source_id: [
VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("male",)),
VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("female",)),
]
return engine
# ──────────────────────────────────────────────────────────────