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:
1 parent
74bc1be2d6
commit
1fa75e58fc
16 files changed
+597
-22
No files matched your search
@@ -56,6 +56,10 @@ wake_word:
|
|||||||
vad_threshold: 0.5 # silero-vad probability threshold
|
vad_threshold: 0.5 # silero-vad probability threshold
|
||||||
vad_gate: 0.0 # >0: wake only when openWakeWord's own VAD agrees (fewer false wakes from TV/music)
|
vad_gate: 0.0 # >0: wake only when openWakeWord's own VAD agrees (fewer false wakes from TV/music)
|
||||||
silence_ms: 800 # end-of-utterance silence
|
silence_ms: 800 # end-of-utterance silence
|
||||||
|
voice_gate_ratio: 3.0 # how much louder than the background a frame must be to count as
|
||||||
|
# the user talking — lets recording end while your own speakers play
|
||||||
|
# music (Silero rates music as speech). 0 = off, raise if commands
|
||||||
|
# get cut off, lower if music keeps the recorder running.
|
||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
servers_config: "mcp_servers.json"
|
servers_config: "mcp_servers.json"
|
||||||
|
|||||||
@@ -609,4 +609,5 @@ stt:
|
|||||||
hotwords: "Javis, Esszimmer, Homey" # bias Whisper towards these words
|
hotwords: "Javis, Esszimmer, Homey" # bias Whisper towards these words
|
||||||
wake_word:
|
wake_word:
|
||||||
vad_gate: 0.5 # wake only when openWakeWord's VAD agrees
|
vad_gate: 0.5 # wake only when openWakeWord's VAD agrees
|
||||||
|
voice_gate_ratio: 3.0 # end recording when the *user* stops, not the music
|
||||||
```
|
```
|
||||||
@@ -27,6 +27,11 @@ screen (GUI mode) and played back through the device's speakers.
|
|||||||
4. **Turns** — After the wake word, the utterance is recorded locally
|
4. **Turns** — After the wake word, the utterance is recorded locally
|
||||||
(Silero VAD or RMS end-pointing), uploaded as WAV to `POST /api/v1/turn`,
|
(Silero VAD or RMS end-pointing), uploaded as WAV to `POST /api/v1/turn`,
|
||||||
and the master's reply text + synthesized speech are returned and played.
|
and the master's reply text + synthesized speech are returned and played.
|
||||||
|
Recording ends when *your voice* stops: Silero rates music and TV as
|
||||||
|
speech, so a loudness gate (`wake_word.voice_gate_ratio`, pushed by the
|
||||||
|
master) only counts audio that is clearly louder than the background the
|
||||||
|
wake-word loop measured. Without it, music from this device's own speakers
|
||||||
|
kept the recorder running until the hard length cap.
|
||||||
When the master marks a reply as expecting a follow-up, the slave starts
|
When the master marks a reply as expecting a follow-up, the slave starts
|
||||||
listening immediately after playback without requiring another wake word.
|
listening immediately after playback without requiring another wake word.
|
||||||
If the master's turn fails it returns its spoken error phrase instead, so a
|
If the master's turn fails it returns its spoken error phrase instead, so a
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ def _wake_word_from(payload: dict) -> WakeWordSettings:
|
|||||||
threshold=float(ww.get("threshold", 0.5)),
|
threshold=float(ww.get("threshold", 0.5)),
|
||||||
vad_threshold=float(ww.get("vad_threshold", 0.5)),
|
vad_threshold=float(ww.get("vad_threshold", 0.5)),
|
||||||
silence_ms=int(ww.get("silence_ms", 800)),
|
silence_ms=int(ww.get("silence_ms", 800)),
|
||||||
|
# Older masters do not send this; keep the satellite's own default.
|
||||||
|
voice_gate_ratio=float(ww.get("voice_gate_ratio", 3.0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ class AudioEngine:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await self.mic.start()
|
await self.mic.start()
|
||||||
self.vad = make_vad(threshold=self.cfg.wake_word.vad_threshold)
|
self.vad = make_vad(
|
||||||
|
threshold=self.cfg.wake_word.vad_threshold,
|
||||||
|
voice_gate_ratio=self.cfg.wake_word.voice_gate_ratio,
|
||||||
|
)
|
||||||
self.wake = await self._load_wake(self.cfg.wake_word.model, self.cfg.wake_word.threshold)
|
self.wake = await self._load_wake(self.cfg.wake_word.model, self.cfg.wake_word.threshold)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# The caller retries with a fresh engine; an open stream left behind
|
# The caller retries with a fresh engine; an open stream left behind
|
||||||
@@ -261,10 +264,14 @@ class AudioEngine:
|
|||||||
self.on_state("muted")
|
self.on_state("muted")
|
||||||
return False
|
return False
|
||||||
score = self.wake.feed(chunk)
|
score = self.wake.feed(chunk)
|
||||||
|
# Everything heard before the wake word is background (music, TV,
|
||||||
|
# this device's own speakers), so the recorder's voice gate is
|
||||||
|
# already calibrated when the turn starts.
|
||||||
|
rms = self.vad.observe(chunk) if hasattr(self.vad, "observe") else (
|
||||||
|
float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_pub > 0.1:
|
if now - last_pub > 0.1:
|
||||||
last_pub = now
|
last_pub = now
|
||||||
rms = float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0
|
|
||||||
self.last_rms = rms
|
self.last_rms = rms
|
||||||
self.last_score = float(score)
|
self.last_score = float(score)
|
||||||
self.on_mic_level(rms, float(score))
|
self.on_mic_level(rms, float(score))
|
||||||
@@ -386,8 +393,8 @@ class AudioEngine:
|
|||||||
self.on_room(room)
|
self.on_room(room)
|
||||||
changed = True
|
changed = True
|
||||||
old = self.cfg.wake_word
|
old = self.cfg.wake_word
|
||||||
if (ww.model, ww.threshold, ww.vad_threshold, ww.silence_ms) != (
|
if (ww.model, ww.threshold, ww.vad_threshold, ww.silence_ms, ww.voice_gate_ratio) != (
|
||||||
old.model, old.threshold, old.vad_threshold, old.silence_ms):
|
old.model, old.threshold, old.vad_threshold, old.silence_ms, old.voice_gate_ratio):
|
||||||
log.info(f"wake-word settings updated by master: {ww}")
|
log.info(f"wake-word settings updated by master: {ww}")
|
||||||
rebuild_model = ww.model != old.model
|
rebuild_model = ww.model != old.model
|
||||||
self.cfg.wake_word = ww
|
self.cfg.wake_word = ww
|
||||||
@@ -397,7 +404,8 @@ class AudioEngine:
|
|||||||
spawn(self._swap_wake_model(ww.model, ww.threshold))
|
spawn(self._swap_wake_model(ww.model, ww.threshold))
|
||||||
else:
|
else:
|
||||||
self.wake.threshold = ww.threshold
|
self.wake.threshold = ww.threshold
|
||||||
self.vad = make_vad(threshold=ww.vad_threshold)
|
self.vad = make_vad(
|
||||||
|
threshold=ww.vad_threshold, voice_gate_ratio=ww.voice_gate_ratio)
|
||||||
changed = True
|
changed = True
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ async def record_utterance(
|
|||||||
silent_run = 0
|
silent_run = 0
|
||||||
seen_speech = False
|
seen_speech = False
|
||||||
chunks_seen = 0
|
chunks_seen = 0
|
||||||
|
background = 0.0
|
||||||
|
|
||||||
async for chunk in mic.chunks(stale_timeout=stale_timeout):
|
async for chunk in mic.chunks(stale_timeout=stale_timeout):
|
||||||
chunks_seen += 1
|
chunks_seen += 1
|
||||||
collected.append(chunk)
|
collected.append(chunk)
|
||||||
result = vad.process(chunk)
|
result = vad.process(chunk)
|
||||||
|
background = result.background
|
||||||
|
|
||||||
if result.is_speech:
|
if result.is_speech:
|
||||||
seen_speech = True
|
seen_speech = True
|
||||||
@@ -70,9 +72,10 @@ async def record_utterance(
|
|||||||
silent_run += 1
|
silent_run += 1
|
||||||
|
|
||||||
if seen_speech and silent_run >= silence_chunks_needed:
|
if seen_speech and silent_run >= silence_chunks_needed:
|
||||||
|
log.debug(f"speech ended (background rms={background:.4f})")
|
||||||
break
|
break
|
||||||
if not seen_speech and chunks_seen >= grace_chunks:
|
if not seen_speech and chunks_seen >= grace_chunks:
|
||||||
log.debug("no speech during grace window")
|
log.debug(f"no speech during grace window (background rms={background:.4f})")
|
||||||
return None
|
return None
|
||||||
if chunks_seen >= max_chunks:
|
if chunks_seen >= max_chunks:
|
||||||
log.info("max utterance length reached")
|
log.info("max utterance length reached")
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ diagnostics helper so this module has no dependency on the ``javis`` package.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Deque, Protocol
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ log = get_logger("audio.vad")
|
|||||||
class VADResult:
|
class VADResult:
|
||||||
is_speech: bool
|
is_speech: bool
|
||||||
probability: float
|
probability: float
|
||||||
|
# Loudness of this frame and the background level it was judged against.
|
||||||
|
# Both are 0.0 for VADs that do not track loudness.
|
||||||
|
rms: float = 0.0
|
||||||
|
background: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class VAD(Protocol):
|
class VAD(Protocol):
|
||||||
@@ -49,16 +54,129 @@ class NullVAD:
|
|||||||
if chunk.size == 0:
|
if chunk.size == 0:
|
||||||
return VADResult(False, 0.0)
|
return VADResult(False, 0.0)
|
||||||
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
|
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
|
||||||
return VADResult(is_speech=rms >= self.threshold, probability=rms)
|
return VADResult(is_speech=rms >= self.threshold, probability=rms, rms=rms)
|
||||||
|
|
||||||
|
|
||||||
def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
|
# A frame must be at least this loud (RMS, normalized float32) to count as
|
||||||
"""Construct a VAD, falling back to NullVAD if torch/silero fails to load."""
|
# someone talking. Without the floor the gate would collapse to ~0 in a silent
|
||||||
|
# room and every faint noise would read as speech.
|
||||||
|
_MIN_VOICE_RMS = 0.006
|
||||||
|
# Speech has short gaps, so a low percentile of the recent window still tracks
|
||||||
|
# the room even while the gate is open.
|
||||||
|
_BACKGROUND_PERCENTILE = 20.0
|
||||||
|
# Once the user is already talking the gate drops to this fraction of the onset
|
||||||
|
# ratio, so a trailing quiet syllable is not clipped off mid-word.
|
||||||
|
_RELEASE_SCALE = 0.55
|
||||||
|
|
||||||
|
|
||||||
|
def frame_rms(chunk: np.ndarray) -> float:
|
||||||
|
"""RMS loudness of one mono float32 frame (0.0 for an empty frame)."""
|
||||||
|
if chunk is None or len(chunk) == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.sqrt(np.mean(np.square(np.asarray(chunk, dtype=np.float32)))))
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechEndpointer:
|
||||||
|
"""Reports only the *near-field* talker as speech.
|
||||||
|
|
||||||
|
Silero answers "is this audio voice-like?", which music, TV and podcasts
|
||||||
|
satisfy just as well as the user does. When the satellite plays music
|
||||||
|
through its own speakers the VAD therefore never goes quiet, so a recording
|
||||||
|
never detects the end of the command and runs until the hard length cap.
|
||||||
|
|
||||||
|
This wrapper adds the missing distinction: it tracks how loud the
|
||||||
|
surrounding audio is and only reports speech while a frame is clearly
|
||||||
|
louder than that background — which is exactly what separates someone
|
||||||
|
talking into the device from steady playback. Idle audio fed through
|
||||||
|
:meth:`observe` (the wake-word loop does) means the background is already
|
||||||
|
known by the time a recording starts.
|
||||||
|
|
||||||
|
Implements the :class:`VAD` protocol, so it can be used anywhere a VAD is
|
||||||
|
expected. With ``onset_ratio <= 1.0`` the gate is disabled and the inner
|
||||||
|
VAD's verdict is passed through unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, vad: VAD, *, onset_ratio: float = 3.0,
|
||||||
|
window_s: float = 1.5, chunk_ms: int = 32) -> None:
|
||||||
|
self.vad = vad
|
||||||
|
self.sample_rate = vad.sample_rate
|
||||||
|
self.onset_ratio = max(0.0, float(onset_ratio))
|
||||||
|
self.release_ratio = max(1.0, self.onset_ratio * _RELEASE_SCALE)
|
||||||
|
window = max(4, int(window_s * 1000.0 / max(1, chunk_ms)))
|
||||||
|
self._levels: Deque[float] = deque(maxlen=window)
|
||||||
|
self._in_speech = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def threshold(self) -> float:
|
||||||
|
return self.vad.threshold
|
||||||
|
|
||||||
|
@threshold.setter
|
||||||
|
def threshold(self, value: float) -> None:
|
||||||
|
self.vad.threshold = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""False when the loudness gate is off (plain VAD behaviour)."""
|
||||||
|
return self.onset_ratio > 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def background(self) -> float:
|
||||||
|
"""Background loudness of the recent window; 0.0 until audio is seen."""
|
||||||
|
if not self._levels:
|
||||||
|
return 0.0
|
||||||
|
levels = sorted(self._levels)
|
||||||
|
idx = min(len(levels) - 1,
|
||||||
|
int(len(levels) * _BACKGROUND_PERCENTILE / 100.0))
|
||||||
|
return levels[idx]
|
||||||
|
|
||||||
|
def observe(self, chunk: np.ndarray) -> float:
|
||||||
|
"""Feed audio heard while *not* recording; returns the frame's RMS.
|
||||||
|
|
||||||
|
Anything heard outside a turn is background by definition, so the gate
|
||||||
|
is calibrated before the user even says the wake word.
|
||||||
|
"""
|
||||||
|
rms = frame_rms(chunk)
|
||||||
|
self._levels.append(rms)
|
||||||
|
self._in_speech = False
|
||||||
|
return rms
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Start a new utterance. Deliberately keeps the learned background."""
|
||||||
|
self._in_speech = False
|
||||||
|
self.vad.reset()
|
||||||
|
|
||||||
|
def process(self, chunk: np.ndarray) -> VADResult:
|
||||||
|
rms = frame_rms(chunk)
|
||||||
|
background = self.background
|
||||||
|
inner = self.vad.process(chunk)
|
||||||
|
if not self.enabled:
|
||||||
|
self._levels.append(rms)
|
||||||
|
return VADResult(inner.is_speech, inner.probability, rms, background)
|
||||||
|
ratio = self.release_ratio if self._in_speech else self.onset_ratio
|
||||||
|
gate = max(_MIN_VOICE_RMS, background * ratio)
|
||||||
|
is_voice = bool(inner.is_speech) and rms >= gate
|
||||||
|
self._in_speech = is_voice
|
||||||
|
if not is_voice:
|
||||||
|
# Only non-voice frames describe the background; letting speech in
|
||||||
|
# would let a long sentence raise the gate onto itself and cut the
|
||||||
|
# user off mid-utterance.
|
||||||
|
self._levels.append(rms)
|
||||||
|
return VADResult(is_voice, inner.probability, rms, background)
|
||||||
|
|
||||||
|
|
||||||
|
def make_vad(sample_rate: int = 16000, threshold: float = 0.5,
|
||||||
|
voice_gate_ratio: float = 3.0, chunk_ms: int = 32) -> VAD:
|
||||||
|
"""Construct the capture VAD, falling back to NullVAD if torch/silero fails.
|
||||||
|
|
||||||
|
The VAD is wrapped in a :class:`SpeechEndpointer` so that playback from the
|
||||||
|
satellite's own speakers is not mistaken for the user talking.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return SileroVAD(sample_rate=sample_rate, threshold=threshold)
|
base: VAD = SileroVAD(sample_rate=sample_rate, threshold=threshold)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.error(f"SileroVAD unavailable: {e}")
|
log.error(f"SileroVAD unavailable: {e}")
|
||||||
return NullVAD(sample_rate=sample_rate, threshold=threshold)
|
base = NullVAD(sample_rate=sample_rate, threshold=threshold)
|
||||||
|
return SpeechEndpointer(base, onset_ratio=voice_gate_ratio, chunk_ms=chunk_ms)
|
||||||
|
|
||||||
|
|
||||||
class SileroVAD:
|
class SileroVAD:
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class WakeWordSettings:
|
|||||||
threshold: float = 0.5
|
threshold: float = 0.5
|
||||||
vad_threshold: float = 0.5
|
vad_threshold: float = 0.5
|
||||||
silence_ms: int = 800
|
silence_ms: int = 800
|
||||||
|
# How much louder than the background a frame must be to count as the user
|
||||||
|
# talking, so recording ends when the user stops even while this device's
|
||||||
|
# own speakers keep playing. 0 = off.
|
||||||
|
voice_gate_ratio: float = 3.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -362,7 +362,10 @@ class Orchestrator:
|
|||||||
self.state: AgentState = AgentState.IDLE
|
self.state: AgentState = AgentState.IDLE
|
||||||
|
|
||||||
self.mic: MicStream | None = MicStream(config) if config.stt.enabled else None
|
self.mic: MicStream | None = MicStream(config) if config.stt.enabled else None
|
||||||
self.vad = make_vad(threshold=config.wake_word.vad_threshold)
|
self.vad = make_vad(
|
||||||
|
threshold=config.wake_word.vad_threshold,
|
||||||
|
voice_gate_ratio=config.wake_word.voice_gate_ratio,
|
||||||
|
)
|
||||||
# Voice input requires STT; if STT is disabled, also disable wake word.
|
# Voice input requires STT; if STT is disabled, also disable wake word.
|
||||||
self.stt = Transcriber(config) if config.stt.enabled else None
|
self.stt = Transcriber(config) if config.stt.enabled else None
|
||||||
self.wake = None
|
self.wake = None
|
||||||
@@ -952,10 +955,15 @@ class Orchestrator:
|
|||||||
last_pub = 0.0
|
last_pub = 0.0
|
||||||
async for chunk in self.mic.chunks(stale_timeout=self._WAKE_STALE_S):
|
async for chunk in self.mic.chunks(stale_timeout=self._WAKE_STALE_S):
|
||||||
score = self.wake.feed(chunk)
|
score = self.wake.feed(chunk)
|
||||||
|
# Everything heard before the wake word is background (music, TV,
|
||||||
|
# this device's own speakers), so the recorder's voice gate is
|
||||||
|
# already calibrated when the turn starts.
|
||||||
|
observe = getattr(self.vad, "observe", None)
|
||||||
|
rms = observe(chunk) if observe is not None else (
|
||||||
|
float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0)
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_pub > 0.1: # ~10 Hz UI updates
|
if now - last_pub > 0.1: # ~10 Hz UI updates
|
||||||
last_pub = now
|
last_pub = now
|
||||||
rms = float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0
|
|
||||||
await self.bus.publish("mic_level", {"rms": rms, "score": float(score)})
|
await self.bus.publish("mic_level", {"rms": rms, "score": float(score)})
|
||||||
if self.wake.triggered(score):
|
if self.wake.triggered(score):
|
||||||
log.info(f"wake! score={score:.2f}")
|
log.info(f"wake! score={score:.2f}")
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class WakeWordBlock(BaseModel):
|
|||||||
threshold: float = 0.5
|
threshold: float = 0.5
|
||||||
vad_threshold: float = 0.5
|
vad_threshold: float = 0.5
|
||||||
silence_ms: int = 800
|
silence_ms: int = 800
|
||||||
|
# How much louder than the background a frame must be to count as the user
|
||||||
|
# talking (0 = off). Older satellites simply ignore this field.
|
||||||
|
voice_gate_ratio: float = 3.0
|
||||||
|
|
||||||
|
|
||||||
class PairRequest(BaseModel):
|
class PairRequest(BaseModel):
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def _wake_word_block(config: Config) -> WakeWordBlock:
|
|||||||
threshold=ww.threshold,
|
threshold=ww.threshold,
|
||||||
vad_threshold=ww.vad_threshold,
|
vad_threshold=ww.vad_threshold,
|
||||||
silence_ms=ww.silence_ms,
|
silence_ms=ww.silence_ms,
|
||||||
|
voice_gate_ratio=ww.voice_gate_ratio,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ async def record_utterance(
|
|||||||
silent_run = 0
|
silent_run = 0
|
||||||
seen_speech = False
|
seen_speech = False
|
||||||
chunks_seen = 0
|
chunks_seen = 0
|
||||||
|
background = 0.0
|
||||||
|
|
||||||
# A vanished input device would otherwise block here forever while the
|
# A vanished input device would otherwise block here forever while the
|
||||||
# caller holds the turn lock.
|
# caller holds the turn lock.
|
||||||
@@ -109,6 +110,7 @@ async def record_utterance(
|
|||||||
chunks_seen += 1
|
chunks_seen += 1
|
||||||
collected.append(chunk)
|
collected.append(chunk)
|
||||||
result = vad.process(chunk)
|
result = vad.process(chunk)
|
||||||
|
background = result.background
|
||||||
|
|
||||||
if result.is_speech:
|
if result.is_speech:
|
||||||
seen_speech = True
|
seen_speech = True
|
||||||
@@ -117,9 +119,10 @@ async def record_utterance(
|
|||||||
silent_run += 1
|
silent_run += 1
|
||||||
|
|
||||||
if seen_speech and silent_run >= silence_chunks_needed:
|
if seen_speech and silent_run >= silence_chunks_needed:
|
||||||
|
log.debug(f"speech ended (background rms={background:.4f})")
|
||||||
break
|
break
|
||||||
if not seen_speech and chunks_seen >= grace_chunks:
|
if not seen_speech and chunks_seen >= grace_chunks:
|
||||||
log.debug("no speech during grace window")
|
log.debug(f"no speech during grace window (background rms={background:.4f})")
|
||||||
return None
|
return None
|
||||||
if chunks_seen >= max_chunks:
|
if chunks_seen >= max_chunks:
|
||||||
log.info("max utterance length reached")
|
log.info("max utterance length reached")
|
||||||
|
|||||||
+124
-6
@@ -1,8 +1,9 @@
|
|||||||
"""Voice activity detection using silero-vad (with graceful fallback)."""
|
"""Voice activity detection using silero-vad (with graceful fallback)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Deque, Protocol
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ log = get_logger("audio.vad")
|
|||||||
class VADResult:
|
class VADResult:
|
||||||
is_speech: bool
|
is_speech: bool
|
||||||
probability: float
|
probability: float
|
||||||
|
# Loudness of this frame and the background level it was judged against.
|
||||||
|
# Both are 0.0 for VADs that do not track loudness.
|
||||||
|
rms: float = 0.0
|
||||||
|
background: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class VAD(Protocol):
|
class VAD(Protocol):
|
||||||
@@ -49,13 +54,125 @@ class NullVAD:
|
|||||||
if chunk.size == 0:
|
if chunk.size == 0:
|
||||||
return VADResult(False, 0.0)
|
return VADResult(False, 0.0)
|
||||||
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
|
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
|
||||||
return VADResult(is_speech=rms >= self.threshold, probability=rms)
|
return VADResult(is_speech=rms >= self.threshold, probability=rms, rms=rms)
|
||||||
|
|
||||||
|
|
||||||
def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
|
# A frame must be at least this loud (RMS, normalized float32) to count as
|
||||||
"""Construct a VAD, falling back to NullVAD if torch/silero fails to load."""
|
# someone talking. Without the floor the gate would collapse to ~0 in a silent
|
||||||
|
# room and every faint noise would read as speech.
|
||||||
|
_MIN_VOICE_RMS = 0.006
|
||||||
|
# Speech has short gaps, so a low percentile of the recent window still tracks
|
||||||
|
# the room even while the gate is open.
|
||||||
|
_BACKGROUND_PERCENTILE = 20.0
|
||||||
|
# Once the user is already talking the gate drops to this fraction of the onset
|
||||||
|
# ratio, so a trailing quiet syllable is not clipped off mid-word.
|
||||||
|
_RELEASE_SCALE = 0.55
|
||||||
|
|
||||||
|
|
||||||
|
def frame_rms(chunk: np.ndarray) -> float:
|
||||||
|
"""RMS loudness of one mono float32 frame (0.0 for an empty frame)."""
|
||||||
|
if chunk is None or len(chunk) == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.sqrt(np.mean(np.square(np.asarray(chunk, dtype=np.float32)))))
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechEndpointer:
|
||||||
|
"""Reports only the *near-field* talker as speech.
|
||||||
|
|
||||||
|
Silero answers "is this audio voice-like?", which music, TV and podcasts
|
||||||
|
satisfy just as well as the user does. When the device plays music through
|
||||||
|
its own speakers the VAD therefore never goes quiet, so a recording never
|
||||||
|
detects the end of the command and runs until the hard length cap.
|
||||||
|
|
||||||
|
This wrapper adds the missing distinction: it tracks how loud the
|
||||||
|
surrounding audio is and only reports speech while a frame is clearly
|
||||||
|
louder than that background — which is exactly what separates someone
|
||||||
|
talking into the device from steady playback. Idle audio fed through
|
||||||
|
:meth:`observe` (the wake-word loops do) means the background is already
|
||||||
|
known by the time a recording starts.
|
||||||
|
|
||||||
|
Implements the :class:`VAD` protocol, so it can be used anywhere a VAD is
|
||||||
|
expected. With ``onset_ratio <= 1.0`` the gate is disabled and the inner
|
||||||
|
VAD's verdict is passed through unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, vad: VAD, *, onset_ratio: float = 3.0,
|
||||||
|
window_s: float = 1.5, chunk_ms: int = 32) -> None:
|
||||||
|
self.vad = vad
|
||||||
|
self.sample_rate = vad.sample_rate
|
||||||
|
self.onset_ratio = max(0.0, float(onset_ratio))
|
||||||
|
self.release_ratio = max(1.0, self.onset_ratio * _RELEASE_SCALE)
|
||||||
|
window = max(4, int(window_s * 1000.0 / max(1, chunk_ms)))
|
||||||
|
self._levels: Deque[float] = deque(maxlen=window)
|
||||||
|
self._in_speech = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def threshold(self) -> float:
|
||||||
|
return self.vad.threshold
|
||||||
|
|
||||||
|
@threshold.setter
|
||||||
|
def threshold(self, value: float) -> None:
|
||||||
|
self.vad.threshold = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
"""False when the loudness gate is off (plain VAD behaviour)."""
|
||||||
|
return self.onset_ratio > 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def background(self) -> float:
|
||||||
|
"""Background loudness of the recent window; 0.0 until audio is seen."""
|
||||||
|
if not self._levels:
|
||||||
|
return 0.0
|
||||||
|
levels = sorted(self._levels)
|
||||||
|
idx = min(len(levels) - 1,
|
||||||
|
int(len(levels) * _BACKGROUND_PERCENTILE / 100.0))
|
||||||
|
return levels[idx]
|
||||||
|
|
||||||
|
def observe(self, chunk: np.ndarray) -> float:
|
||||||
|
"""Feed audio heard while *not* recording; returns the frame's RMS.
|
||||||
|
|
||||||
|
Anything heard outside a turn is background by definition, so the gate
|
||||||
|
is calibrated before the user even says the wake word.
|
||||||
|
"""
|
||||||
|
rms = frame_rms(chunk)
|
||||||
|
self._levels.append(rms)
|
||||||
|
self._in_speech = False
|
||||||
|
return rms
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Start a new utterance. Deliberately keeps the learned background."""
|
||||||
|
self._in_speech = False
|
||||||
|
self.vad.reset()
|
||||||
|
|
||||||
|
def process(self, chunk: np.ndarray) -> VADResult:
|
||||||
|
rms = frame_rms(chunk)
|
||||||
|
background = self.background
|
||||||
|
inner = self.vad.process(chunk)
|
||||||
|
if not self.enabled:
|
||||||
|
self._levels.append(rms)
|
||||||
|
return VADResult(inner.is_speech, inner.probability, rms, background)
|
||||||
|
ratio = self.release_ratio if self._in_speech else self.onset_ratio
|
||||||
|
gate = max(_MIN_VOICE_RMS, background * ratio)
|
||||||
|
is_voice = bool(inner.is_speech) and rms >= gate
|
||||||
|
self._in_speech = is_voice
|
||||||
|
if not is_voice:
|
||||||
|
# Only non-voice frames describe the background; letting speech in
|
||||||
|
# would let a long sentence raise the gate onto itself and cut the
|
||||||
|
# user off mid-utterance.
|
||||||
|
self._levels.append(rms)
|
||||||
|
return VADResult(is_voice, inner.probability, rms, background)
|
||||||
|
|
||||||
|
|
||||||
|
def make_vad(sample_rate: int = 16000, threshold: float = 0.5,
|
||||||
|
voice_gate_ratio: float = 3.0, chunk_ms: int = 32) -> VAD:
|
||||||
|
"""Construct the capture VAD, falling back to NullVAD if torch/silero fails.
|
||||||
|
|
||||||
|
The VAD is wrapped in a :class:`SpeechEndpointer` so that playback from the
|
||||||
|
device's own speakers is not mistaken for the user talking.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return SileroVAD(sample_rate=sample_rate, threshold=threshold)
|
base: VAD = SileroVAD(sample_rate=sample_rate, threshold=threshold)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
from ..core.hardware import detect_hardware, format_remediation
|
from ..core.hardware import detect_hardware, format_remediation
|
||||||
log.error(f"SileroVAD unavailable: {e}")
|
log.error(f"SileroVAD unavailable: {e}")
|
||||||
@@ -63,7 +180,8 @@ def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
|
|||||||
log.error(format_remediation(detect_hardware(), torch_error=str(e)))
|
log.error(format_remediation(detect_hardware(), torch_error=str(e)))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return NullVAD(sample_rate=sample_rate, threshold=threshold)
|
base = NullVAD(sample_rate=sample_rate, threshold=threshold)
|
||||||
|
return SpeechEndpointer(base, onset_ratio=voice_gate_ratio, chunk_ms=chunk_ms)
|
||||||
|
|
||||||
|
|
||||||
class SileroVAD:
|
class SileroVAD:
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ class WakeWordConfig:
|
|||||||
# VAD probability is above this value (cuts false wakes from TV/music).
|
# VAD probability is above this value (cuts false wakes from TV/music).
|
||||||
# 0 = off.
|
# 0 = off.
|
||||||
vad_gate: float = 0.0
|
vad_gate: float = 0.0
|
||||||
|
# How much louder than the background a frame must be to count as the user
|
||||||
|
# talking. This is what lets a recording end when the *user* stops even
|
||||||
|
# while the device's own speakers keep playing music (which Silero happily
|
||||||
|
# classifies as speech). 0 = off (plain VAD endpointing).
|
||||||
|
voice_gate_ratio: float = 3.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -425,6 +430,8 @@ def validate(cfg: "Config") -> None:
|
|||||||
cfg.speaker_id.refine_margin, 0.0, 0.5, 0.10, "speaker_id.refine_margin")
|
cfg.speaker_id.refine_margin, 0.0, 0.5, 0.10, "speaker_id.refine_margin")
|
||||||
cfg.wake_word.vad_gate = _clamp(
|
cfg.wake_word.vad_gate = _clamp(
|
||||||
cfg.wake_word.vad_gate, 0.0, 1.0, 0.0, "wake_word.vad_gate")
|
cfg.wake_word.vad_gate, 0.0, 1.0, 0.0, "wake_word.vad_gate")
|
||||||
|
cfg.wake_word.voice_gate_ratio = _clamp(
|
||||||
|
cfg.wake_word.voice_gate_ratio, 0.0, 20.0, 3.0, "wake_word.voice_gate_ratio")
|
||||||
|
|
||||||
# Silero VAD and openWakeWord are only wired up for 16 kHz input.
|
# Silero VAD and openWakeWord are only wired up for 16 kHz input.
|
||||||
if cfg.audio.sample_rate != 16000:
|
if cfg.audio.sample_rate != 16000:
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Utterance endpointing while the device plays audio through its own speakers.
|
||||||
|
|
||||||
|
Silero rates music/TV as speech, so without a loudness gate a recording never
|
||||||
|
sees silence and runs until the hard length cap. These tests drive synthetic
|
||||||
|
frames through the real ``record_utterance`` loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from javis.audio_io.vad import SpeechEndpointer, VADResult, make_vad
|
||||||
|
|
||||||
|
CHUNK_MS = 32
|
||||||
|
SR = 16000
|
||||||
|
SAMPLES = SR * CHUNK_MS // 1000
|
||||||
|
|
||||||
|
MUSIC_RMS = 0.02
|
||||||
|
VOICE_RMS = 0.15
|
||||||
|
QUIET_RMS = 0.0005
|
||||||
|
|
||||||
|
|
||||||
|
def frame(rms: float) -> np.ndarray:
|
||||||
|
"""A constant-amplitude frame whose RMS is exactly ``rms``."""
|
||||||
|
return np.full(SAMPLES, rms, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class AlwaysSpeech:
|
||||||
|
"""Stand-in for Silero: calls everything voice-like, just as it does for
|
||||||
|
music, singing and TV dialogue."""
|
||||||
|
|
||||||
|
sample_rate = SR
|
||||||
|
threshold = 0.5
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.resets = 0
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.resets += 1
|
||||||
|
|
||||||
|
def process(self, chunk: np.ndarray) -> VADResult:
|
||||||
|
return VADResult(is_speech=True, probability=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMic:
|
||||||
|
chunk_ms = CHUNK_MS
|
||||||
|
|
||||||
|
def __init__(self, frames: list[np.ndarray]) -> None:
|
||||||
|
self._frames = frames
|
||||||
|
|
||||||
|
def drain(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def chunks(self, stale_timeout=None):
|
||||||
|
for f in self._frames:
|
||||||
|
yield f
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
def endpointer(ratio: float = 3.0) -> SpeechEndpointer:
|
||||||
|
return SpeechEndpointer(AlwaysSpeech(), onset_ratio=ratio, chunk_ms=CHUNK_MS)
|
||||||
|
|
||||||
|
|
||||||
|
def record(frames, vad, *, silence_ms=800, grace_ms=1500, max_seconds=20.0):
|
||||||
|
from javis.audio_io.capture import record_utterance
|
||||||
|
from javis.core.config import Config
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.wake_word.silence_ms = silence_ms
|
||||||
|
return asyncio.run(record_utterance(
|
||||||
|
cfg, FakeMic(frames), vad, max_seconds=max_seconds,
|
||||||
|
initial_grace_ms=grace_ms,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechEndpointerTests(unittest.TestCase):
|
||||||
|
def test_music_alone_is_not_speech_once_background_is_known(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS))
|
||||||
|
ep.reset()
|
||||||
|
self.assertFalse(ep.process(frame(MUSIC_RMS)).is_speech)
|
||||||
|
|
||||||
|
def test_voice_over_music_is_speech(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS))
|
||||||
|
ep.reset()
|
||||||
|
self.assertTrue(ep.process(frame(VOICE_RMS)).is_speech)
|
||||||
|
|
||||||
|
def test_quiet_room_still_detects_normal_speech(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(QUIET_RMS))
|
||||||
|
ep.reset()
|
||||||
|
self.assertTrue(ep.process(frame(0.05)).is_speech)
|
||||||
|
# ...but not the room tone itself.
|
||||||
|
ep.reset()
|
||||||
|
self.assertFalse(ep.process(frame(QUIET_RMS)).is_speech)
|
||||||
|
|
||||||
|
def test_long_utterance_does_not_raise_the_gate_onto_itself(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(QUIET_RMS))
|
||||||
|
ep.reset()
|
||||||
|
for _ in range(200):
|
||||||
|
self.assertTrue(ep.process(frame(0.05)).is_speech)
|
||||||
|
|
||||||
|
def test_release_is_more_forgiving_than_onset(self) -> None:
|
||||||
|
"""A trailing quiet syllable must not be clipped off mid-word."""
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS))
|
||||||
|
ep.reset()
|
||||||
|
trailing = frame(MUSIC_RMS * 2.0) # below onset (3x), above release
|
||||||
|
self.assertFalse(ep.process(trailing).is_speech)
|
||||||
|
ep.process(frame(VOICE_RMS)) # user starts talking
|
||||||
|
self.assertTrue(ep.process(trailing).is_speech)
|
||||||
|
|
||||||
|
def test_reset_keeps_the_background_but_clears_the_latch(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS))
|
||||||
|
ep.process(frame(VOICE_RMS))
|
||||||
|
before = ep.background
|
||||||
|
ep.reset()
|
||||||
|
self.assertAlmostEqual(ep.background, before)
|
||||||
|
self.assertFalse(ep.process(frame(MUSIC_RMS * 2.0)).is_speech)
|
||||||
|
|
||||||
|
def test_ratio_zero_disables_the_gate(self) -> None:
|
||||||
|
ep = endpointer(ratio=0.0)
|
||||||
|
self.assertFalse(ep.enabled)
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS))
|
||||||
|
ep.reset()
|
||||||
|
self.assertTrue(ep.process(frame(MUSIC_RMS)).is_speech)
|
||||||
|
|
||||||
|
def test_reset_resets_the_inner_vad(self) -> None:
|
||||||
|
inner = AlwaysSpeech()
|
||||||
|
ep = SpeechEndpointer(inner, chunk_ms=CHUNK_MS)
|
||||||
|
ep.reset()
|
||||||
|
self.assertEqual(inner.resets, 1)
|
||||||
|
|
||||||
|
def test_threshold_proxies_to_the_inner_vad(self) -> None:
|
||||||
|
inner = AlwaysSpeech()
|
||||||
|
ep = SpeechEndpointer(inner, chunk_ms=CHUNK_MS)
|
||||||
|
ep.threshold = 0.9
|
||||||
|
self.assertEqual(inner.threshold, 0.9)
|
||||||
|
self.assertEqual(ep.threshold, 0.9)
|
||||||
|
|
||||||
|
def test_empty_frame_is_harmless(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
self.assertEqual(ep.observe(np.zeros(0, dtype=np.float32)), 0.0)
|
||||||
|
self.assertFalse(ep.process(np.zeros(0, dtype=np.float32)).is_speech)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordUtteranceEndpointingTests(unittest.TestCase):
|
||||||
|
def test_recording_ends_when_the_user_stops_not_the_music(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS)) # wake-word loop heard the music
|
||||||
|
|
||||||
|
speech = [frame(VOICE_RMS)] * 30 # ~1.0 s of command
|
||||||
|
tail = [frame(MUSIC_RMS)] * 200 # music keeps playing
|
||||||
|
audio = record(speech + tail, ep)
|
||||||
|
|
||||||
|
self.assertIsNotNone(audio)
|
||||||
|
# 30 speech frames + 25 frames (800 ms) of "silence" = ~1.76 s, far
|
||||||
|
# short of the 20 s cap the old endpointer ran into.
|
||||||
|
self.assertLess(len(audio) / SR, 3.0)
|
||||||
|
|
||||||
|
def test_music_only_returns_none_instead_of_20s_of_audio(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(MUSIC_RMS))
|
||||||
|
audio = record([frame(MUSIC_RMS)] * 400, ep)
|
||||||
|
self.assertIsNone(audio)
|
||||||
|
|
||||||
|
def test_without_the_gate_music_runs_to_the_cap(self) -> None:
|
||||||
|
"""The behaviour this change fixes, pinned so it cannot creep back."""
|
||||||
|
ep = endpointer(ratio=0.0)
|
||||||
|
audio = record([frame(MUSIC_RMS)] * 400, ep, max_seconds=2.0)
|
||||||
|
self.assertIsNotNone(audio)
|
||||||
|
self.assertGreaterEqual(len(audio) / SR, 1.9)
|
||||||
|
|
||||||
|
def test_quiet_room_recording_is_unaffected(self) -> None:
|
||||||
|
ep = endpointer()
|
||||||
|
for _ in range(50):
|
||||||
|
ep.observe(frame(QUIET_RMS))
|
||||||
|
speech = [frame(0.05)] * 30
|
||||||
|
tail = [frame(QUIET_RMS)] * 200
|
||||||
|
audio = record(speech + tail, ep)
|
||||||
|
self.assertIsNotNone(audio)
|
||||||
|
self.assertLess(len(audio) / SR, 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
class MakeVadWiringTests(unittest.TestCase):
|
||||||
|
def test_make_vad_returns_an_endpointer(self) -> None:
|
||||||
|
vad = make_vad(voice_gate_ratio=2.5)
|
||||||
|
self.assertIsInstance(vad, SpeechEndpointer)
|
||||||
|
self.assertEqual(vad.onset_ratio, 2.5)
|
||||||
|
self.assertTrue(hasattr(vad, "observe"))
|
||||||
|
|
||||||
|
def test_config_clamps_the_ratio(self) -> None:
|
||||||
|
from javis.core.config import Config, validate
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
self.assertEqual(cfg.wake_word.voice_gate_ratio, 3.0)
|
||||||
|
cfg.wake_word.voice_gate_ratio = -1.0
|
||||||
|
validate(cfg)
|
||||||
|
self.assertEqual(cfg.wake_word.voice_gate_ratio, 3.0)
|
||||||
|
|
||||||
|
def test_master_pushes_the_ratio_to_slaves(self) -> None:
|
||||||
|
from javis.api.routes import _wake_word_block
|
||||||
|
from javis.core.config import Config
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.wake_word.voice_gate_ratio = 4.0
|
||||||
|
self.assertEqual(_wake_word_block(cfg).voice_gate_ratio, 4.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -165,9 +165,74 @@ def test_send_turn_reads_master_followup_flag():
|
|||||||
assert result.expects_followup is True
|
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():
|
def test_slave_followup_skips_wake_word_and_starts_recording():
|
||||||
from javis_slave.audio_engine import AudioEngine
|
from javis_slave.audio_engine import AudioEngine
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
eng = AudioEngine(SlaveConfig(), client=None)
|
eng = AudioEngine(SlaveConfig(), client=None)
|
||||||
eng._followup_pending = True
|
eng._followup_pending = True
|
||||||
|
|||||||
Reference in new issue
Block a user