Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""TTS engine lifecycle driven by speech-output toggles (issue #100881).
|
||||
|
||||
Local engines load lazily on first synthesis, so the first spoken reply after
|
||||
"read replies aloud" / voice conversation turns on pays the model load as dead
|
||||
air. The toggles now hold *leases*: acquiring warms the configured provider
|
||||
into the SAME cache slot synthesis reads; releasing the last lease unloads
|
||||
resident local models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import tts_tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_lifecycle(monkeypatch):
|
||||
tts_tool._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||
cache.clear()
|
||||
yield
|
||||
tts_tool._reset_tts_leases_for_tests()
|
||||
for cache in tts_tool._LOCAL_TTS_MODEL_CACHES.values():
|
||||
cache.clear()
|
||||
|
||||
|
||||
class _FakePiperVoice:
|
||||
loads = 0
|
||||
synthesized: list = []
|
||||
|
||||
@classmethod
|
||||
def load(cls, model_path, use_cuda=False):
|
||||
cls.loads += 1
|
||||
inst = cls()
|
||||
inst.model_path = model_path
|
||||
return inst
|
||||
|
||||
def synthesize_wav(self, text, wav_file, syn_config=None):
|
||||
type(self).synthesized.append(text)
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(16000)
|
||||
wav_file.writeframes(b"\x00\x00" * 160)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_piper(monkeypatch, tmp_path):
|
||||
_FakePiperVoice.loads = 0
|
||||
_FakePiperVoice.synthesized = []
|
||||
monkeypatch.setattr(tts_tool, "_import_piper", lambda: _FakePiperVoice)
|
||||
# Pretend the voice is already on disk so no download subprocess runs.
|
||||
voices_dir = tmp_path / "voices"
|
||||
voices_dir.mkdir()
|
||||
(voices_dir / "en_US-test-medium.onnx").write_bytes(b"onnx")
|
||||
(voices_dir / "en_US-test-medium.onnx.json").write_text("{}")
|
||||
cfg = {"provider": "piper", "piper": {"voice": "en_US-test-medium", "voices_dir": str(voices_dir)}}
|
||||
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# warm_tts_provider: warm-up populates the exact slot synthesis reads
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_warm_loads_piper_into_synthesis_cache(fake_piper, tmp_path):
|
||||
result = tts_tool.warm_tts_provider(fake_piper)
|
||||
|
||||
assert result["warmed"] is True
|
||||
assert result["action"] == "loaded"
|
||||
assert result["provider"] == "piper"
|
||||
assert _FakePiperVoice.loads == 1
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
|
||||
# The load that would have happened on the first reply is already done:
|
||||
# synthesis reuses the warmed instance without loading again.
|
||||
out = tts_tool._generate_piper_tts("hello", str(tmp_path / "out.wav"), fake_piper)
|
||||
assert out.endswith(".wav")
|
||||
assert _FakePiperVoice.loads == 1
|
||||
assert _FakePiperVoice.synthesized == ["hello"]
|
||||
|
||||
|
||||
def test_warm_twice_is_a_cache_hit(fake_piper):
|
||||
tts_tool.warm_tts_provider(fake_piper)
|
||||
second = tts_tool.warm_tts_provider(fake_piper)
|
||||
|
||||
assert second["action"] == "cached"
|
||||
assert _FakePiperVoice.loads == 1
|
||||
|
||||
|
||||
def test_warm_reads_configured_provider_when_none_given(fake_piper):
|
||||
result = tts_tool.warm_tts_provider()
|
||||
assert result["provider"] == "piper"
|
||||
assert result["action"] == "loaded"
|
||||
|
||||
|
||||
def test_warm_never_raises_on_engine_failure(monkeypatch):
|
||||
def _boom():
|
||||
raise ImportError("No module named 'piper'")
|
||||
|
||||
monkeypatch.setattr(tts_tool, "_import_piper", _boom)
|
||||
result = tts_tool.warm_tts_provider({"provider": "piper"})
|
||||
|
||||
assert result["warmed"] is False
|
||||
assert result["action"] == "error"
|
||||
assert "piper" in result["error"]
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
|
||||
|
||||
def test_warm_is_noop_for_cloud_provider_without_lazy_sdk(monkeypatch):
|
||||
result = tts_tool.warm_tts_provider({"provider": "openai"})
|
||||
assert result == {"provider": "openai", "warmed": False, "action": "noop"}
|
||||
|
||||
|
||||
def test_warm_lazy_sdk_provider_reports_cached_when_installed(monkeypatch):
|
||||
import types
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
is_available=lambda feature: feature == "tts.edge",
|
||||
ensure=lambda *a, **k: pytest.fail("ensure must not run when the SDK is present"),
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "tools.lazy_deps", fake)
|
||||
result = tts_tool.warm_tts_provider({"provider": "edge"})
|
||||
assert result["warmed"] is True
|
||||
assert result["action"] == "cached"
|
||||
|
||||
|
||||
def test_warm_lazy_sdk_provider_installs_when_missing(monkeypatch):
|
||||
import types
|
||||
|
||||
calls = []
|
||||
fake = types.SimpleNamespace(
|
||||
is_available=lambda feature: False,
|
||||
ensure=lambda feature, prompt: calls.append((feature, prompt)),
|
||||
)
|
||||
monkeypatch.setitem(__import__("sys").modules, "tools.lazy_deps", fake)
|
||||
result = tts_tool.warm_tts_provider({"provider": "edge"})
|
||||
assert result["action"] == "installed"
|
||||
assert calls == [("tts.edge", False)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# release_tts_provider
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_release_drops_every_local_cache(fake_piper):
|
||||
tts_tool.warm_tts_provider(fake_piper)
|
||||
tts_tool._kittentts_model_cache["m"] = object()
|
||||
|
||||
assert tts_tool.release_tts_provider() == {"released": 2}
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
assert tts_tool._kittentts_model_cache == {}
|
||||
|
||||
|
||||
def test_release_scoped_to_one_provider(fake_piper):
|
||||
tts_tool.warm_tts_provider(fake_piper)
|
||||
tts_tool._kittentts_model_cache["m"] = object()
|
||||
|
||||
assert tts_tool.release_tts_provider("kittentts") == {"released": 1}
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
|
||||
|
||||
def test_release_with_nothing_resident_is_zero():
|
||||
assert tts_tool.release_tts_provider() == {"released": 0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Leases: warm on acquire, unload only when the LAST holder releases
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_acquire_warms_and_counts(fake_piper):
|
||||
result = tts_tool.acquire_tts_lease("desktop:read-aloud")
|
||||
assert result["leases"] == 1
|
||||
assert result["action"] == "loaded"
|
||||
assert tts_tool.tts_lease_holders() == ["desktop:read-aloud"]
|
||||
|
||||
|
||||
def test_last_release_unloads_but_earlier_release_does_not(fake_piper):
|
||||
tts_tool.acquire_tts_lease("desktop:read-aloud")
|
||||
tts_tool.acquire_tts_lease("tui:voice-tts")
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
|
||||
# One surface turning speech off must not pull the model from under the
|
||||
# other surface that still speaks through this process.
|
||||
first = tts_tool.release_tts_lease("desktop:read-aloud")
|
||||
assert first == {"leases": 1, "released": 0}
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
|
||||
last = tts_tool.release_tts_lease("tui:voice-tts")
|
||||
assert last == {"leases": 0, "released": 1}
|
||||
assert tts_tool._piper_voice_cache == {}
|
||||
|
||||
|
||||
def test_reacquire_is_idempotent_and_reheals_cache(fake_piper):
|
||||
tts_tool.acquire_tts_lease("cli:voice-tts")
|
||||
tts_tool.release_tts_provider() # something else dropped the model
|
||||
result = tts_tool.acquire_tts_lease("cli:voice-tts")
|
||||
|
||||
assert result["leases"] == 1
|
||||
assert result["action"] == "loaded"
|
||||
assert _FakePiperVoice.loads == 2
|
||||
|
||||
|
||||
def test_release_unknown_lease_is_noop(fake_piper):
|
||||
tts_tool.acquire_tts_lease("a")
|
||||
assert tts_tool.release_tts_lease("never-acquired") == {"leases": 1, "released": 0}
|
||||
assert len(tts_tool._piper_voice_cache) == 1
|
||||
|
||||
|
||||
def test_acquire_failure_still_registers_lease(monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("engine missing")
|
||||
|
||||
monkeypatch.setattr(tts_tool, "_import_piper", _boom)
|
||||
result = tts_tool.acquire_tts_lease("desktop:conversation", {"provider": "piper"})
|
||||
assert result["action"] == "error"
|
||||
assert result["leases"] == 1
|
||||
assert tts_tool.tts_lease_holders() == ["desktop:conversation"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Registry invariant: every local engine cache is release-able
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_local_warmer_has_a_registered_cache():
|
||||
warmers = tts_tool._local_tts_warmers()
|
||||
assert set(warmers) == set(tts_tool._LOCAL_TTS_MODEL_CACHES)
|
||||
assert tts_tool._LOCAL_TTS_MODEL_CACHES["piper"] is tts_tool._piper_voice_cache
|
||||
assert tts_tool._LOCAL_TTS_MODEL_CACHES["kittentts"] is tts_tool._kittentts_model_cache
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# User-declared providers get the same signal (plugin warm()/release(),
|
||||
# command warm_command/release_command) so a local TTS server can preload
|
||||
# and unload on the speech toggles.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plugin_provider_warm_and_release_follow_the_lease(monkeypatch):
|
||||
from agent import tts_provider, tts_registry
|
||||
|
||||
calls: list = []
|
||||
|
||||
class _ServerBacked(tts_provider.TTSProvider):
|
||||
@property
|
||||
def name(self):
|
||||
return "my-server"
|
||||
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
return output_path
|
||||
|
||||
def warm(self):
|
||||
calls.append("warm")
|
||||
|
||||
def release(self):
|
||||
calls.append("release")
|
||||
|
||||
tts_registry._reset_for_tests()
|
||||
tts_registry.register_provider(_ServerBacked())
|
||||
cfg = {"provider": "my-server"}
|
||||
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: cfg)
|
||||
monkeypatch.setattr("hermes_cli.plugins._ensure_plugins_discovered", lambda force=False: None)
|
||||
try:
|
||||
assert tts_tool.acquire_tts_lease("desktop:read-aloud", cfg)["action"] == "warmed"
|
||||
tts_tool.acquire_tts_lease("tui:voice-tts", cfg)
|
||||
tts_tool.release_tts_lease("desktop:read-aloud")
|
||||
assert calls == ["warm", "warm"] # still one holder — no release yet
|
||||
tts_tool.release_tts_lease("tui:voice-tts")
|
||||
assert calls == ["warm", "warm", "release"]
|
||||
finally:
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
|
||||
def test_command_provider_runs_warm_and_release_commands(monkeypatch):
|
||||
ran: list = []
|
||||
done = threading.Event()
|
||||
|
||||
def _fake_run(command, timeout, env_passthrough=None):
|
||||
ran.append(command)
|
||||
done.set()
|
||||
|
||||
monkeypatch.setattr(tts_tool, "_run_command_tts", _fake_run)
|
||||
cfg = {
|
||||
"provider": "srv",
|
||||
"providers": {"srv": {
|
||||
"command": "srv say {input_path} {output_path}",
|
||||
"warm_command": "curl -s localhost:5002/load?model={model}",
|
||||
"release_command": "curl -s localhost:5002/unload",
|
||||
"model": "kokoro v1",
|
||||
}},
|
||||
}
|
||||
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: cfg)
|
||||
|
||||
assert tts_tool.acquire_tts_lease("desktop:read-aloud", cfg)["action"] == "warmed"
|
||||
assert done.wait(5)
|
||||
done.clear()
|
||||
tts_tool.release_tts_lease("desktop:read-aloud")
|
||||
assert done.wait(5)
|
||||
assert ran == ["curl -s localhost:5002/load?model='kokoro v1'", "curl -s localhost:5002/unload"]
|
||||
Reference in New Issue
Block a user