158 lines
5.9 KiB
Python
158 lines
5.9 KiB
Python
"""``POST /api/audio/tts-lease`` — desktop speech toggles as TTS warm-up/release.
|
|
|
|
The desktop's "Read replies aloud" and voice-conversation toggles call this so
|
|
the backend can pre-load the configured TTS engine when speech is about to be
|
|
needed and unload resident local models once no surface holds a lease.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli import profiles
|
|
|
|
default_home = get_hermes_home()
|
|
profiles_root = default_home / "profiles"
|
|
worker_home = profiles_root / "worker_beta"
|
|
for home in (default_home, worker_home):
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
|
(worker_home / ".env").write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
|
return {"default": default_home, "worker_beta": worker_home}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, isolated_profiles):
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("fastapi/starlette not installed")
|
|
|
|
import hermes_state
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
|
|
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
|
c = TestClient(app)
|
|
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
return c
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_leases():
|
|
from tools import tts_tool
|
|
|
|
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()
|
|
|
|
|
|
def test_active_acquires_and_warms(client, monkeypatch):
|
|
from tools import tts_tool
|
|
|
|
warmed = []
|
|
monkeypatch.setattr(
|
|
tts_tool,
|
|
"warm_tts_provider",
|
|
lambda cfg=None, provider=None: warmed.append(1) or {"provider": "piper", "warmed": True, "action": "loaded"},
|
|
)
|
|
|
|
resp = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is True
|
|
assert body["lease"] == "desktop:read-aloud"
|
|
assert body["active"] is True
|
|
assert body["leases"] == 1
|
|
assert body["action"] == "loaded"
|
|
assert warmed == [1]
|
|
assert tts_tool.tts_lease_holders() == ["desktop:read-aloud"]
|
|
|
|
|
|
def test_inactive_releases_and_unloads_when_last(client, monkeypatch):
|
|
from tools import tts_tool
|
|
|
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "piper"})
|
|
client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
|
client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": True})
|
|
tts_tool._piper_voice_cache["voice"] = object()
|
|
|
|
first = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": False}).json()
|
|
assert first["leases"] == 1
|
|
assert first["released"] == 0
|
|
assert len(tts_tool._piper_voice_cache) == 1
|
|
|
|
last = client.post("/api/audio/tts-lease", json={"lease": "desktop:conversation:abc", "active": False}).json()
|
|
assert last["leases"] == 0
|
|
assert last["released"] == 1
|
|
assert tts_tool._piper_voice_cache == {}
|
|
|
|
|
|
def test_warm_failure_is_reported_not_an_http_error(client, monkeypatch):
|
|
from tools import tts_tool
|
|
|
|
def _boom(cfg=None, provider=None):
|
|
raise RuntimeError("engine exploded")
|
|
|
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", _boom)
|
|
resp = client.post("/api/audio/tts-lease", json={"lease": "desktop:read-aloud", "active": True})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is True
|
|
assert body["action"] == "error"
|
|
assert "engine exploded" in body["error"]
|
|
|
|
|
|
def test_blank_lease_rejected(client):
|
|
resp = client.post("/api/audio/tts-lease", json={"lease": " ", "active": True})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_active_default_true(client, monkeypatch):
|
|
from tools import tts_tool
|
|
|
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", lambda cfg=None, provider=None: {"action": "noop", "warmed": False, "provider": "x"})
|
|
resp = client.post("/api/audio/tts-lease", json={"lease": "tui:x"})
|
|
assert resp.json()["active"] is True
|
|
assert tts_tool.tts_lease_holders() == ["tui:x"]
|
|
|
|
|
|
def test_acquire_resolves_provider_inside_target_profile(client, isolated_profiles, monkeypatch):
|
|
"""Warm-up must read the REQUESTING profile's tts config, like /api/audio/speak."""
|
|
import yaml
|
|
from tools import tts_tool
|
|
|
|
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
|
yaml.safe_dump({"tts": {"provider": "kittentts"}}), encoding="utf-8"
|
|
)
|
|
seen = {}
|
|
|
|
def _fake_warm(cfg=None, provider=None):
|
|
from hermes_constants import get_hermes_home
|
|
|
|
seen["home"] = str(get_hermes_home())
|
|
seen["provider"] = tts_tool._get_provider(tts_tool._load_tts_config())
|
|
return {"action": "noop", "warmed": False, "provider": seen["provider"]}
|
|
|
|
monkeypatch.setattr(tts_tool, "warm_tts_provider", _fake_warm)
|
|
resp = client.post("/api/audio/tts-lease?profile=worker_beta", json={"lease": "desktop:read-aloud", "active": True})
|
|
assert resp.status_code == 200
|
|
assert seen["home"] == str(isolated_profiles["worker_beta"])
|
|
assert seen["provider"] == "kittentts"
|
|
|
|
|
|
def test_unknown_profile_404(client):
|
|
resp = client.post("/api/audio/tts-lease?profile=ghost", json={"lease": "desktop:read-aloud", "active": True})
|
|
assert resp.status_code == 404
|