Independent-review findings on the plugin PCM streaming path: - stream_sample_rate was checked, then re-read and int()-ed in the adapter: NaN/inf raised out of resolve_streaming_provider (the speaker then played nothing) and 0.5 became sample_rate=0. Validate once (finite, >= 1 Hz) and hand that int to _PluginPCMStreamer. - `nous` is an alias of the openai path in _get_provider but not a reserved name, so a pinned `tts.streaming.provider: nous` could stream a plugin registered as `nous` while the sync path speaks OpenAI. - Document that stream() must be thread-safe: the speaker prefetches up to 3 sentences, unlike the serialized synthesize() path.
281 lines
9.9 KiB
Python
281 lines
9.9 KiB
Python
"""Tests for TTS plugin dispatch in tools/tts_tool.py (issue #30398).
|
|
|
|
Covers the three core invariants of the plugin dispatcher:
|
|
|
|
1. Built-in provider names short-circuit — plugins NEVER win over a
|
|
built-in. Even if a plugin somehow ended up in the registry with a
|
|
built-in name (which the registry already blocks), the dispatcher
|
|
re-checks defensively.
|
|
2. Command-type providers declared under ``tts.providers.<name>: type:
|
|
command`` (PR #17843) win over a plugin with the same name. Config
|
|
is more local than plugin install.
|
|
3. Plugin dispatch fires only when the configured provider is neither
|
|
a built-in nor a command-type entry, AND a plugin is registered
|
|
under that name. Unknown names fall through.
|
|
|
|
Also exercises:
|
|
- Plugin exceptions surface to the outer error envelope (don't crash)
|
|
- Plugin returning a different path is honored
|
|
- voice_compatible: True triggers ffmpeg opus conversion path
|
|
- voice_compatible: False keeps the file as-is
|
|
|
|
The dispatcher is exercised in isolation — we don't actually call
|
|
``text_to_speech_tool`` because that would require real audio file
|
|
writes. Each test directly calls
|
|
``tools.tts_tool._dispatch_to_plugin_provider`` / the predicate
|
|
helpers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
from agent import tts_registry
|
|
from agent.tts_provider import TTSProvider
|
|
from tools import tts_tool
|
|
|
|
|
|
class _FakeTTSProvider(TTSProvider):
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
voice_compat: bool = False,
|
|
raise_exc: Optional[BaseException] = None,
|
|
return_path: Optional[str] = None,
|
|
):
|
|
self._name = name
|
|
self._voice_compat = voice_compat
|
|
self._raise_exc = raise_exc
|
|
self._return_path = return_path
|
|
# Recorded for assertions
|
|
self.last_call: Optional[dict] = None
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._name
|
|
|
|
@property
|
|
def voice_compatible(self) -> bool:
|
|
return self._voice_compat
|
|
|
|
def synthesize(self, text, output_path, **kw):
|
|
self.last_call = {
|
|
"text": text,
|
|
"output_path": output_path,
|
|
"kwargs": dict(kw),
|
|
}
|
|
if self._raise_exc is not None:
|
|
raise self._raise_exc
|
|
return self._return_path if self._return_path is not None else output_path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_registry():
|
|
tts_registry._reset_for_tests()
|
|
yield
|
|
tts_registry._reset_for_tests()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resolution invariants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuiltinAlwaysWins:
|
|
"""Built-in TTS provider names short-circuit the dispatcher.
|
|
|
|
Even with a plugin registered (which the registry would reject —
|
|
but the dispatcher is defensive), built-in names return None so
|
|
the caller's elif chain handles them natively.
|
|
"""
|
|
|
|
def test_dispatcher_short_circuits_builtin(self):
|
|
assert tts_tool.BUILTIN_TTS_PROVIDERS
|
|
for builtin in tts_tool.BUILTIN_TTS_PROVIDERS:
|
|
self._assert_short_circuits(builtin)
|
|
|
|
def _assert_short_circuits(self, builtin):
|
|
result = tts_tool._dispatch_to_plugin_provider(
|
|
text="hello",
|
|
output_path="/tmp/out.mp3",
|
|
provider=builtin,
|
|
tts_config={},
|
|
)
|
|
assert result is None, (
|
|
f"Built-in {builtin!r} must short-circuit plugin dispatch. "
|
|
"If this test fails, the dispatcher would silently let a "
|
|
"plugin with a built-in name shadow the native handler — "
|
|
"violating the precedence rule from PR #17843."
|
|
)
|
|
|
|
def test_dispatcher_short_circuits_builtin_case_insensitive(self):
|
|
for variant in ("EDGE", "Edge", " edge ", "eDgE"):
|
|
assert (
|
|
tts_tool._dispatch_to_plugin_provider(
|
|
text="hello", output_path="/tmp/x.mp3",
|
|
provider=variant, tts_config={},
|
|
) is None
|
|
)
|
|
|
|
|
|
class TestCommandProviderWins:
|
|
"""A same-name ``tts.providers.<name>: type: command`` config beats a plugin.
|
|
|
|
Locality: a user's command-provider config is more specific than
|
|
whichever plugin happens to be installed.
|
|
"""
|
|
|
|
def test_command_config_beats_plugin(self):
|
|
tts_registry.register_provider(_FakeTTSProvider(name="my-tts"))
|
|
|
|
result = tts_tool._dispatch_to_plugin_provider(
|
|
text="hello",
|
|
output_path="/tmp/out.mp3",
|
|
provider="my-tts",
|
|
tts_config={
|
|
"providers": {
|
|
"my-tts": {
|
|
"type": "command",
|
|
"command": "echo 'hi' > {output_path}",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
# Plugin path returns None → caller falls back to command
|
|
# provider dispatch (handled by the outer text_to_speech_tool
|
|
# via _resolve_command_provider_config).
|
|
assert result is None
|
|
|
|
|
|
class TestPluginDispatch:
|
|
"""Happy path: configured name matches a registered plugin, dispatcher fires."""
|
|
|
|
def test_registered_plugin_called(self):
|
|
provider = _FakeTTSProvider(name="cartesia")
|
|
tts_registry.register_provider(provider)
|
|
|
|
result = tts_tool._dispatch_to_plugin_provider(
|
|
text="hello world",
|
|
output_path="/tmp/out.mp3",
|
|
provider="cartesia",
|
|
tts_config={},
|
|
)
|
|
assert result == "/tmp/out.mp3"
|
|
assert provider.last_call is not None
|
|
assert provider.last_call["text"] == "hello world"
|
|
assert provider.last_call["output_path"] == "/tmp/out.mp3"
|
|
|
|
def test_unregistered_name_returns_none(self):
|
|
result = tts_tool._dispatch_to_plugin_provider(
|
|
text="hello",
|
|
output_path="/tmp/out.mp3",
|
|
provider="unknown-tts",
|
|
tts_config={},
|
|
)
|
|
assert result is None
|
|
|
|
|
|
def test_provider_exception_bubbles_up(self):
|
|
"""Plugin exceptions are NOT swallowed by the dispatcher — they bubble
|
|
up so the outer ``text_to_speech_tool`` try/except converts them to
|
|
the standard error envelope. Matches command-provider failure
|
|
behavior."""
|
|
provider = _FakeTTSProvider(
|
|
name="cartesia",
|
|
raise_exc=RuntimeError("network down"),
|
|
)
|
|
tts_registry.register_provider(provider)
|
|
|
|
with pytest.raises(RuntimeError, match="network down"):
|
|
tts_tool._dispatch_to_plugin_provider(
|
|
text="hi",
|
|
output_path="/tmp/out.mp3",
|
|
provider="cartesia",
|
|
tts_config={},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# voice_compatible flag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVoiceCompatibleHelper:
|
|
def test_voice_compatible_true(self):
|
|
tts_registry.register_provider(
|
|
_FakeTTSProvider(name="cartesia", voice_compat=True)
|
|
)
|
|
assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is True
|
|
|
|
|
|
def test_unregistered_provider_returns_false(self):
|
|
assert tts_tool._plugin_provider_is_voice_compatible("unknown") is False
|
|
|
|
|
|
def test_provider_property_exception_returns_false(self):
|
|
"""A buggy ``voice_compatible`` property raising must not crash the
|
|
TTS pipeline."""
|
|
|
|
class _ExplodingProvider(_FakeTTSProvider):
|
|
@property
|
|
def voice_compatible(self) -> bool:
|
|
raise RuntimeError("boom")
|
|
|
|
tts_registry.register_provider(_ExplodingProvider(name="cartesia"))
|
|
assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is False
|
|
|
|
|
|
# ── Streaming voice path (tools.tts_streaming) ──────────────────────────────
|
|
|
|
|
|
class _PCMPlugin(TTSProvider):
|
|
def __init__(self, streams_pcm=True, rate=22050, available=True):
|
|
self.streams_pcm, self.stream_sample_rate, self._available = streams_pcm, rate, available
|
|
self.calls = []
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "fake-pcm"
|
|
|
|
def is_available(self) -> bool:
|
|
return self._available
|
|
|
|
def synthesize(self, text, output_path, **kw): # pragma: no cover — streaming must not use it
|
|
raise AssertionError("synthesize() on the streaming path")
|
|
|
|
def stream(self, text, **kw):
|
|
self.calls.append(kw)
|
|
yield b"\x01\x02" * 4
|
|
|
|
|
|
@pytest.mark.parametrize("streaming", [{}, {"provider": "auto"}, {"provider": "fake-pcm"}])
|
|
def test_opted_in_plugin_streams_pcm_in_the_configured_voice(monkeypatch, streaming):
|
|
from tools import tts_streaming
|
|
monkeypatch.setattr(tts_streaming, "_try_instantiate", lambda name, cfg: None) # no built-in creds
|
|
plugin = _PCMPlugin()
|
|
tts_registry.register_provider(plugin)
|
|
cfg = {"provider": "fake-pcm", "voice": "v1", "speed": 1.25, "streaming": streaming}
|
|
streamer = tts_streaming.resolve_streaming_provider(cfg)
|
|
assert streamer.sample_rate == 22050
|
|
assert list(streamer.stream("Hi.")) == [b"\x01\x02" * 4]
|
|
# The voice/model/speed synthesize() gets on the sync path: streaming never swaps the voice.
|
|
assert plugin.calls == [{"format": "pcm", "voice": "v1", "model": None, "speed": 1.25}]
|
|
|
|
|
|
@pytest.mark.parametrize("plugin, extra_cfg", [
|
|
(_PCMPlugin(streams_pcm=False), {}),
|
|
(_PCMPlugin(rate=None), {}),
|
|
(_PCMPlugin(rate=0), {}),
|
|
(_PCMPlugin(rate=0.5), {}),
|
|
(_PCMPlugin(rate=float("nan")), {}),
|
|
(_PCMPlugin(available=False), {}),
|
|
(_PCMPlugin(), {"providers": {"fake-pcm": {"type": "command", "command": "say {input_path}"}}}),
|
|
])
|
|
def test_plugin_without_full_opt_in_keeps_per_sentence_synthesis(plugin, extra_cfg):
|
|
from tools import tts_streaming
|
|
tts_registry.register_provider(plugin)
|
|
assert tts_streaming.resolve_streaming_provider({"provider": "fake-pcm", **extra_cfg}) is None
|
|
assert plugin.calls == []
|