Files
javis/tests/test_slave_audit.py
JavisandCopilot 1fa75e58fc End recording when the user stops, not the music
Silero VAD answers "is this voice-like?", which music, TV and podcasts
satisfy just as well as the user does. With Spotify playing through the
device's own speakers the VAD never went quiet, so record_utterance never
saw the end of the command and ran to the 20 s cap on master and slave
alike -- and music alone was enough to defeat the "no speech during the
grace window" early return.

Wrap the VAD in a SpeechEndpointer that tracks the background loudness of
the recent window and only reports speech while a frame is clearly louder
than it. Both wake-word loops feed their idle audio through observe(), so
the gate is already calibrated when a turn starts, and only non-voice
frames update the background so a long sentence cannot raise the gate onto
itself. Onset/release hysteresis keeps trailing quiet syllables.

The master owns the knob (wake_word.voice_gate_ratio, 0 = off) and pushes
it to satellites in the wake-word block.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 13:04:13 +02:00

403 lines
13 KiB
Python

"""Regression tests for the javis-slave audit fixes (docs/AUDIT_SLAVE.md)."""
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
import httpx
import numpy as np
import pytest
SLAVE_SRC = Path(__file__).resolve().parents[1] / "javis-slave" / "src"
if str(SLAVE_SRC) not in sys.path:
sys.path.insert(0, str(SLAVE_SRC))
from javis_slave import config as slave_config # noqa: E402
from javis_slave.api_client import MasterClient, MasterError # noqa: E402
from javis_slave.config import SlaveConfig, ensure_control_token # noqa: E402
TOKEN = "t" * 32
@pytest.fixture
def cfg_dir(tmp_path, monkeypatch):
monkeypatch.setenv("JAVIS_SLAVE_CONFIG_DIR", str(tmp_path))
return tmp_path
# ── SLV-07: config loading ───────────────────────────────────────────────────
def test_corrupt_config_is_kept_aside(cfg_dir):
(cfg_dir / "slave_config.json").write_text("{not json", encoding="utf-8")
cfg = slave_config.load_config()
assert not cfg.paired
assert (cfg_dir / "slave_config.json.corrupt").read_text(encoding="utf-8") == "{not json"
assert not (cfg_dir / "slave_config.json").exists()
def test_config_type_mismatch_keeps_default(cfg_dir):
(cfg_dir / "slave_config.json").write_text(
json.dumps({"master_port": "abc", "input_gain": 2, "api_key": "k", "master_host": "h"}),
encoding="utf-8")
cfg = slave_config.load_config()
assert cfg.master_port == 8765
assert cfg.input_gain == 2.0 and isinstance(cfg.input_gain, float)
assert cfg.paired
def test_control_token_roundtrip(cfg_dir):
cfg = SlaveConfig()
assert ensure_control_token(cfg) is True
assert len(cfg.control_token) >= 24
assert ensure_control_token(cfg) is False
slave_config.save_config(cfg)
assert slave_config.load_config().control_token == cfg.control_token
# ── SLV-01: control API authentication ───────────────────────────────────────
def _app(token: str = TOKEN):
from javis_slave.http_server import build_http_app
cfg = SlaveConfig(control_token=token)
return build_http_app(cfg, lambda: None, lambda: "idle", lambda: None, lambda: (0.0, 0.0))
def test_control_api_requires_token_from_lan():
from fastapi.testclient import TestClient
remote = TestClient(_app(), client=("192.168.1.50", 50000))
assert remote.get("/mcp/v1/health").status_code == 200
assert remote.get("/mcp/v1/status").status_code == 401
# Rejected by the middleware before the handler runs: nothing is executed.
assert remote.post("/mcp/v1/power/cancel").status_code == 401
assert remote.get("/mcp/v1/status", headers={"Authorization": "Bearer wrong"}).status_code == 401
# The loopback header is not a credential for remote callers.
assert remote.get("/mcp/v1/status", headers={"X-Javis-Control": "1"}).status_code == 401
ok = remote.get("/mcp/v1/status", headers={"Authorization": f"Bearer {TOKEN}"})
assert ok.status_code == 200 and ok.json()["audio_state"] == "idle"
assert remote.get("/docs").status_code in (401, 404)
def test_control_api_loopback_needs_custom_header():
from fastapi.testclient import TestClient
for host in ("127.0.0.1", "::1", "::ffff:127.0.0.1"):
local = TestClient(_app(), client=(host, 50000))
# Plain loopback request (e.g. a browser form post / CSRF) is refused.
assert local.get("/mcp/v1/status").status_code == 401, host
assert local.get("/mcp/v1/status", headers={"X-Javis-Control": "1"}).status_code == 200, host
def test_empty_token_never_authorizes():
from fastapi.testclient import TestClient
remote = TestClient(_app(token=""), client=("10.0.0.2", 50000))
assert remote.get("/mcp/v1/status", headers={"Authorization": "Bearer "}).status_code == 401
# ── SLV-03: no blind retry of a non-idempotent turn after a read timeout ────
def _client_with(handler) -> MasterClient:
cfg = SlaveConfig(master_host="master.invalid", api_key="k")
client = MasterClient(cfg)
client._backoff_base = 0.0
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return client
def test_send_turn_does_not_retry_after_read_timeout():
calls = []
def handler(request):
calls.append(request.url.path)
raise httpx.ReadTimeout("slow", request=request)
async def run():
client = _client_with(handler)
try:
with pytest.raises(MasterError, match="Zeitüberschreitung"):
await client.send_turn(b"RIFF")
finally:
await client.aclose()
asyncio.run(run())
assert calls == ["/api/v1/turn"]
def test_send_turn_retries_connect_error_once():
calls = []
def handler(request):
calls.append(1)
raise httpx.ConnectError("refused", request=request)
async def run():
client = _client_with(handler)
try:
with pytest.raises(MasterError):
await client.send_turn(b"RIFF")
finally:
await client.aclose()
asyncio.run(run())
assert len(calls) == 2
def test_send_turn_reads_master_followup_flag():
def handler(request):
return httpx.Response(
200,
json={
"user_text": "Ja",
"reply_text": "Noch etwas?",
"expects_followup": True,
},
)
async def run():
client = _client_with(handler)
try:
return await client.send_turn(b"RIFF")
finally:
await client.aclose()
result = asyncio.run(run())
assert result.expects_followup is True
def test_slave_voice_gate_from_master_is_applied(monkeypatch):
"""The master owns the loudness gate, so a satellite must honour the value
it pushes (and fall back to its own default for older masters)."""
from javis_slave.api_client import _wake_word_from
from javis_slave.audio_engine import AudioEngine
from javis_slave.config import SlaveConfig, WakeWordSettings
from javis_slave import audio_engine as ae
assert _wake_word_from({"wake_word": {"voice_gate_ratio": 4.5}}).voice_gate_ratio == 4.5
assert _wake_word_from({"wake_word": {}}).voice_gate_ratio == 3.0
built = []
monkeypatch.setattr(ae, "make_vad", lambda **kw: built.append(kw) or object())
eng = AudioEngine(SlaveConfig(), client=None)
eng._apply_master_settings(None, WakeWordSettings(voice_gate_ratio=4.5))
assert built and built[-1]["voice_gate_ratio"] == 4.5
assert eng.cfg.wake_word.voice_gate_ratio == 4.5
def test_slave_endpointer_ends_recording_while_music_plays():
"""Silero calls music speech; without the loudness gate the satellite
recorded until the hard cap instead of when the user stopped talking."""
import numpy as np
from javis_slave.audio_io.capture import record_utterance
from javis_slave.audio_io.vad import SpeechEndpointer, VADResult
sr, chunk_ms = 16000, 32
n = sr * chunk_ms // 1000
class AlwaysSpeech:
sample_rate = sr
threshold = 0.5
def reset(self):
pass
def process(self, chunk):
return VADResult(True, 1.0)
class FakeMic:
chunk_ms = 32
def __init__(self, frames):
self._frames = frames
async def chunks(self, stale_timeout=None):
for f in self._frames:
yield f
await asyncio.sleep(0)
def frame(rms):
return np.full(n, rms, dtype=np.float32)
vad = SpeechEndpointer(AlwaysSpeech(), onset_ratio=3.0, chunk_ms=chunk_ms)
for _ in range(50):
vad.observe(frame(0.02)) # wake loop heard the music
frames = [frame(0.15)] * 30 + [frame(0.02)] * 300
audio = asyncio.run(record_utterance(sr, 800, FakeMic(frames), vad))
assert audio is not None
assert len(audio) / sr < 3.0
def test_slave_followup_skips_wake_word_and_starts_recording():
from javis_slave.audio_engine import AudioEngine
async def run():
eng = AudioEngine(SlaveConfig(), client=None)
eng._followup_pending = True
calls = []
async def config_poll():
await eng._stop.wait()
async def wait_for_wake():
calls.append("wake")
return True
async def handle_turn(*, is_followup=False):
calls.append(("turn", is_followup))
eng._stop.set()
async def play_chime(*args):
calls.append("chime")
eng._config_poll_loop = config_poll
eng._wait_for_wake = wait_for_wake
eng._handle_turn = handle_turn
eng._play_chime = play_chime
await eng.run()
await asyncio.sleep(0)
return calls
calls = asyncio.run(run())
assert "wake" not in calls
assert ("turn", True) in calls
# ── SLV-04 / SLV-06: audio engine ────────────────────────────────────────────
def test_engine_start_failure_releases_mic(monkeypatch):
from javis_slave import audio_engine
class FakeMic:
instances: list = []
def __init__(self, **kw):
self.stopped = False
FakeMic.instances.append(self)
async def start(self):
pass
async def stop(self):
self.stopped = True
def broken_vad(**kw):
raise RuntimeError("vad model missing")
monkeypatch.setattr(audio_engine, "MicStream", FakeMic)
monkeypatch.setattr(audio_engine, "make_vad", broken_vad)
async def run():
eng = audio_engine.AudioEngine(SlaveConfig(), client=None)
with pytest.raises(RuntimeError, match="vad model missing"):
await eng.start()
return eng
eng = asyncio.run(run())
assert FakeMic.instances and FakeMic.instances[0].stopped
assert eng.mic is None
@pytest.mark.parametrize("name", ["", ".", "..", "../evil", "sub/model", "..\\evil", "C:\\x\\model"])
def test_wake_model_name_cannot_address_a_path(name):
from javis_slave.audio_engine import AudioEngine
class NoDownload:
async def download_wake_model(self, dest): # pragma: no cover - must not be reached
raise AssertionError(f"download attempted for {dest}")
async def run():
eng = AudioEngine(SlaveConfig(), client=NoDownload())
with pytest.raises(RuntimeError, match="invalid wake word model name"):
await eng._load_wake(name, 0.5)
asyncio.run(run())
# ── SLV-05: playback writer stops before cancellation completes ─────────────
def test_write_pcm_blocks_cancel_stops_writer_thread():
from javis_slave.audio_io.stream import write_pcm_blocks
class SlowStream:
writes = 0
def write(self, block):
time.sleep(0.01)
SlowStream.writes += 1
async def run():
task = asyncio.ensure_future(
write_pcm_blocks(SlowStream(), np.zeros(16000 * 5, dtype=np.float32), 160))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
done = SlowStream.writes
await asyncio.sleep(0.05)
return done
done = asyncio.run(run())
assert 0 < done < 500
assert SlowStream.writes == done # no write after the stream may be closed
def test_write_pcm_blocks_cancel_event():
from javis_slave.audio_io.stream import write_pcm_blocks
class Stream:
def write(self, block):
pass
async def run():
ev = asyncio.Event()
ev.set()
return await write_pcm_blocks(Stream(), np.zeros(1600, dtype=np.float32), 160, ev)
assert asyncio.run(run()) is True
# ── SLV-02 / SLV-09: Windows system control ─────────────────────────────────
def test_volume_controller_uses_current_pycaw_api(monkeypatch):
import types
from javis_slave import system_control
class Endpoint:
level = 0.25
def GetMasterVolumeLevelScalar(self):
return Endpoint.level
def SetMasterVolumeLevelScalar(self, level, ctx):
Endpoint.level = level
def GetMute(self):
return 1
class Device: # pycaw >= 2024 AudioDevice: has .EndpointVolume, no .Activate
EndpointVolume = Endpoint()
fake = types.ModuleType("pycaw.pycaw")
fake.AudioUtilities = types.SimpleNamespace(GetSpeakers=lambda: Device())
fake.IAudioEndpointVolume = object
monkeypatch.setitem(sys.modules, "pycaw", types.ModuleType("pycaw"))
monkeypatch.setitem(sys.modules, "pycaw.pycaw", fake)
monkeypatch.setattr(system_control.platform, "system", lambda: "Windows")
vol = system_control.VolumeController()
assert vol.get_volume() == 0.25
assert vol.set_volume(1.7) and Endpoint.level == 1.0
assert vol.is_muted() is True
@pytest.mark.skipif(sys.platform != "win32", reason="Windows exit-code handling")
def test_windows_power_runner_checks_exit_codes():
from javis_slave.system_control import _run_win
assert _run_win(["cmd", "/c", "exit 0"]) is True
assert _run_win(["cmd", "/c", "exit 5"]) is False
assert _run_win(["cmd", "/c", "exit 1116"], ok_codes=(0, 1116)) is True