mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
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:
@@ -1,183 +1,183 @@
|
||||
"""Contract tests for capability interfaces.
|
||||
|
||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
||||
- VoiceLister: lists voices for a source
|
||||
- PreviewGenerator: generates preview audio
|
||||
- StreamingSynthesizer: yields audio chunks
|
||||
- CancelableSession: cancels in-progress synthesis
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
||||
|
||||
|
||||
class TestVoiceListerProtocolContract:
|
||||
"""Contract tests for VoiceLister protocol."""
|
||||
|
||||
def test_voice_lister_is_protocol(self) -> None:
|
||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
||||
|
||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
assert isinstance(engine, VoiceLister)
|
||||
|
||||
def test_list_voices_returns_list(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert isinstance(voices, list)
|
||||
|
||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice, VoiceManifest)
|
||||
|
||||
def test_list_voices_has_required_fields(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert hasattr(voice, "id")
|
||||
assert hasattr(voice, "name")
|
||||
assert hasattr(voice, "tags")
|
||||
|
||||
|
||||
class TestPreviewGeneratorProtocolContract:
|
||||
"""Contract tests for PreviewGenerator protocol."""
|
||||
|
||||
def test_preview_generator_is_protocol(self) -> None:
|
||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
||||
|
||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
assert isinstance(engine, PreviewGenerator)
|
||||
|
||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_generate_preview_has_valid_data(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
|
||||
|
||||
class TestStreamingSynthesizerProtocolContract:
|
||||
"""Contract tests for StreamingSynthesizer protocol."""
|
||||
|
||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
||||
|
||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
assert isinstance(session, StreamingSynthesizer)
|
||||
|
||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) > 0
|
||||
for chunk in chunks:
|
||||
assert isinstance(chunk, bytes)
|
||||
|
||||
def test_streaming_iterator_exhaustion(self) -> None:
|
||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) == 3
|
||||
|
||||
def test_streaming_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
||||
session = FakeStreamingSession()
|
||||
session.dispose()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(EngineError):
|
||||
list(session.synthesizeStream(request))
|
||||
|
||||
|
||||
class TestCancelableSessionProtocolContract:
|
||||
"""Contract tests for CancelableSession protocol."""
|
||||
|
||||
def test_cancelable_session_is_protocol(self) -> None:
|
||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
||||
|
||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
||||
session = FakeCancelableSession()
|
||||
assert isinstance(session, CancelableSession)
|
||||
|
||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
||||
session = FakeCancelableSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# synthesize should raise CancelledError
|
||||
with pytest.raises(CancelledError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_cancel_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
||||
session = FakeCancelableSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.cancel()
|
||||
|
||||
def test_session_usable_after_cancel(self) -> None:
|
||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
||||
session = FakeCancelableSession()
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# Dispose and create new session for synthesis
|
||||
session.dispose()
|
||||
"""Contract tests for capability interfaces.
|
||||
|
||||
These tests verify that capability interfaces satisfy the architectural requirements:
|
||||
- VoiceLister: lists voices for a source
|
||||
- PreviewGenerator: generates preview audio
|
||||
- StreamingSynthesizer: yields audio chunks
|
||||
- CancelableSession: cancels in-progress synthesis
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.errors import CancelledError, EngineError
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
|
||||
|
||||
|
||||
class TestVoiceListerProtocolContract:
|
||||
"""Contract tests for VoiceLister protocol."""
|
||||
|
||||
def test_voice_lister_is_protocol(self) -> None:
|
||||
assert hasattr(VoiceLister, "__protocol_attrs__")
|
||||
|
||||
def test_voice_lister_satisfied_by_engine(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
assert isinstance(engine, VoiceLister)
|
||||
|
||||
def test_list_voices_returns_list(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
assert isinstance(voices, list)
|
||||
|
||||
def test_list_voices_returns_voice_manifests(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert isinstance(voice, VoiceManifest)
|
||||
|
||||
def test_list_voices_has_required_fields(self) -> None:
|
||||
engine = FakeVoiceListerEngine()
|
||||
voices = engine.listVoices("builtin")
|
||||
for voice in voices:
|
||||
assert hasattr(voice, "id")
|
||||
assert hasattr(voice, "name")
|
||||
assert hasattr(voice, "tags")
|
||||
|
||||
|
||||
class TestPreviewGeneratorProtocolContract:
|
||||
"""Contract tests for PreviewGenerator protocol."""
|
||||
|
||||
def test_preview_generator_is_protocol(self) -> None:
|
||||
assert hasattr(PreviewGenerator, "__protocol_attrs__")
|
||||
|
||||
def test_preview_generator_satisfied_by_engine(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
assert isinstance(engine, PreviewGenerator)
|
||||
|
||||
def test_generate_preview_returns_synthesized_audio(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result, SynthesizedAudio)
|
||||
|
||||
def test_generate_preview_has_valid_data(self) -> None:
|
||||
from .conftest import FakePreviewEngine
|
||||
|
||||
engine = FakePreviewEngine()
|
||||
voice = VoiceSelection(source="builtin", key="af_nova")
|
||||
result = engine.generatePreview(voice, "Hello")
|
||||
assert isinstance(result.data, bytes)
|
||||
assert len(result.data) > 0
|
||||
|
||||
|
||||
class TestStreamingSynthesizerProtocolContract:
|
||||
"""Contract tests for StreamingSynthesizer protocol."""
|
||||
|
||||
def test_streaming_synthesizer_is_protocol(self) -> None:
|
||||
assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
|
||||
|
||||
def test_streaming_session_satisfies_protocol(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
assert isinstance(session, StreamingSynthesizer)
|
||||
|
||||
def test_synthesize_stream_yields_bytes(self) -> None:
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) > 0
|
||||
for chunk in chunks:
|
||||
assert isinstance(chunk, bytes)
|
||||
|
||||
def test_streaming_iterator_exhaustion(self) -> None:
|
||||
"""Architecture spec: Iterator exhaustion = synthesis complete."""
|
||||
session = FakeStreamingSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
chunks = list(session.synthesizeStream(request))
|
||||
assert len(chunks) == 3
|
||||
|
||||
def test_streaming_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: After dispose(), methods raise EngineError."""
|
||||
session = FakeStreamingSession()
|
||||
session.dispose()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
with pytest.raises(EngineError):
|
||||
list(session.synthesizeStream(request))
|
||||
|
||||
|
||||
class TestCancelableSessionProtocolContract:
|
||||
"""Contract tests for CancelableSession protocol."""
|
||||
|
||||
def test_cancelable_session_is_protocol(self) -> None:
|
||||
assert hasattr(CancelableSession, "__protocol_attrs__")
|
||||
|
||||
def test_cancelable_session_satisfies_protocol(self) -> None:
|
||||
session = FakeCancelableSession()
|
||||
assert isinstance(session, CancelableSession)
|
||||
|
||||
def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
|
||||
"""Architecture spec: cancel() causes synthesize() to raise CancelledError."""
|
||||
session = FakeCancelableSession()
|
||||
request = SynthesisRequest(
|
||||
text="Hello",
|
||||
voice=VoiceSelection(source="builtin", key="af_nova"),
|
||||
parameters=ParameterValues(),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# synthesize should raise CancelledError
|
||||
with pytest.raises(CancelledError):
|
||||
session.synthesize(request)
|
||||
|
||||
def test_cancel_after_dispose_raises(self) -> None:
|
||||
"""Architecture spec: cancel() raises EngineError if called after dispose()."""
|
||||
session = FakeCancelableSession()
|
||||
session.dispose()
|
||||
with pytest.raises(EngineError):
|
||||
session.cancel()
|
||||
|
||||
def test_session_usable_after_cancel(self) -> None:
|
||||
"""Architecture spec: EngineSession remains usable after cancellation."""
|
||||
session = FakeCancelableSession()
|
||||
|
||||
# Cancel
|
||||
session.cancel()
|
||||
|
||||
# Dispose and create new session for synthesis
|
||||
session.dispose()
|
||||
|
||||
Reference in New Issue
Block a user