Ctrl K
ComunidadGuías…

test_vad_gate.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Confirmed-speech capture gate: the silero VAD rejects non-speech the energy gate
cannot (issue #54).

capture_request's energy endpointing decides *when* a command ends, but energy alone
cannot tell a sustained noise (a long cough, a fan, room rumble) from speech, so a
>=240 ms noise after a bare wake could clear the energy onset and prepend the wake-word
pre-roll as a phantom command. _confirm_speech runs the bundled silero VAD over the
post-fire audio and keeps the capture only if real speech is present.

This test loads the real model (no mock -- the model-free plumbing is proven in
test_preroll.py) and shows the separation at the default threshold: Piper-synthesized
speech is confirmed; white noise, a pure tone, and silence are rejected. It prints each
peak probability so the threshold can be hardware-tuned from real numbers.

Run:  .venv/bin/python test_vad_gate.py   (loads the silero + Piper models)
Exit code is 0 only if every check passes.
"""

from __future__ import annotations

import sys
from pathlib import Path

import numpy as np

import pipeline

TEST_DIR = Path(__file__).resolve().parent / "test_audio"
TEST_DIR.mkdir(exist_ok=True)

PASS, FAIL = "PASS", "FAIL"
results: list[tuple[str, str]] = []


def check(name: str, ok: bool, detail: str) -> bool:
    results.append((PASS if ok else FAIL, name))
    print(f"  [{PASS if ok else FAIL}] {name}: {detail}")
    return ok


def peak_prob(pcm: np.ndarray) -> float:
    """Peak per-480-chunk speech probability -- the value _confirm_speech thresholds.
    Recomputed here so the test can show the speech-vs-noise margin, not just a bool."""
    vad = pipeline._get_vad()
    vad.reset_states()
    frame = 480
    pad = (-len(pcm)) % frame
    if pad:
        pcm = np.concatenate([pcm, np.zeros(pad, dtype=np.int16)])
    peak = 0.0
    for i in range(0, len(pcm), frame):
        peak = max(peak, float(vad.predict(pcm[i : i + frame], frame_size=frame)))
    return peak


def synth_speech() -> np.ndarray:
    out = str(TEST_DIR / "vad_speech.wav")
    pipeline.speak("file an issue about the broken printer", out)
    return pipeline._load_pcm16(out)


def main() -> int:
    thr = pipeline.DEFAULTS["capture_vad_threshold"]
    print(f"=== _confirm_speech at the default threshold {thr} (real silero VAD) ===")

    speech = synth_speech()
    sp_peak = peak_prob(speech)
    check(
        "Piper speech is confirmed as speech",
        pipeline._confirm_speech(speech, thr),
        f"peak={sp_peak:.3f} >= {thr}",
    )

    # White noise, loud enough (RMS ~3.5k, well above _SILENCE_RMS) to clear the energy
    # gate and reach the VAD in the real pipeline. Seeded so the run is deterministic.
    rng = np.random.default_rng(0)
    noise = rng.integers(-6000, 6000, size=16000).astype(np.int16)
    n_peak = peak_prob(noise)
    check(
        "white noise is rejected (not speech)",
        not pipeline._confirm_speech(noise, thr),
        f"peak={n_peak:.3f} < {thr}",
    )

    # A pure tone is loud and periodic but has none of speech's formant structure.
    t = np.arange(16000)
    tone = (5000 * np.sin(2 * np.pi * 440 * t / 16000)).astype(np.int16)
    t_peak = peak_prob(tone)
    check(
        "a 440 Hz tone is rejected (not speech)",
        not pipeline._confirm_speech(tone, thr),
        f"peak={t_peak:.3f} < {thr}",
    )

    silence = np.zeros(16000, dtype=np.int16)
    s_peak = peak_prob(silence)
    check(
        "silence is rejected (not speech)",
        not pipeline._confirm_speech(silence, thr),
        f"peak={s_peak:.3f} < {thr}",
    )

    # The separation should be wide, not a coin-flip across the threshold: speech well
    # above, every non-speech case well below.
    worst_nonspeech = max(n_peak, t_peak, s_peak)
    check(
        "speech clears the loudest non-speech case by a wide margin",
        sp_peak - worst_nonspeech > 0.2,
        f"speech={sp_peak:.3f} vs worst non-speech={worst_nonspeech:.3f}",
    )

    n_pass = sum(1 for x in results if x[0] == PASS)
    n_total = len(results)
    print(f"\n=== {n_pass}/{n_total} checks passed ===")
    return 0 if n_pass == n_total else 1


if __name__ == "__main__":
    sys.exit(main())