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>
This commit is contained in:
JavisandCopilot committed 2026-10-10 13:04:13 +02:00
1 parent 74bc1be2d6
commit 1fa75e58fc
16 files changed
+597 -22

No files matched your search

+66 -1
View File
@@ -165,9 +165,74 @@ def test_send_turn_reads_master_followup_flag():
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