File size: 7,758 Bytes
de7fd77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""
Voice Activity Detection
=========================
Frame-level speech/silence classification, used for:

  - Endpointing     : knowing when the caller has FINISHED speaking
                      (so we don't wait for a push-to-talk button)
  - Preroll         : keeping ~300ms of audio BEFORE speech onset so the
                      first phoneme is never clipped
  - Barge-in        : detecting that the caller started speaking while the
                      agent's TTS is still playing, so we can stop it
  - Silence culling : never sending silence to Whisper (Whisper hallucinates
                      badly on silence β€” "Thank you." / "Subtitles by…")

Two backends:
  1. SileroVAD  β€” torch.hub snakers4/silero-vad. Small (~1.8MB), fast on CPU,
                  robust to background noise. Requires EXACTLY 512 samples
                  per frame at 16kHz.
  2. EnergyVAD  β€” adaptive-threshold RMS + zero-crossing rate. No download,
                  no torch.hub dependency. Degraded in noise but never fails.

Both expose the same interface:
    vad.speech_prob(frame: np.ndarray) -> float in [0, 1]
    vad.reset()
"""

import logging
import numpy as np
from typing import Optional

logger = logging.getLogger(__name__)

SAMPLE_RATE   = 16_000
FRAME_SAMPLES = 512                       # Silero requirement at 16kHz
FRAME_MS      = FRAME_SAMPLES / SAMPLE_RATE * 1000     # 32.0 ms


# ──────────────────────────────────────────────────────────────────────────────
#  Silero
# ──────────────────────────────────────────────────────────────────────────────

class SileroVAD:

    def __init__(self):
        import torch
        self.torch = torch
        logger.info("Loading Silero VAD …")
        self.model, _ = torch.hub.load(
            repo_or_dir="snakers4/silero-vad",
            model="silero_vad",
            force_reload=False,
            onnx=False,
            trust_repo=True,
        )
        self.model.eval()
        self.name = "silero"

    def speech_prob(self, frame: np.ndarray) -> float:
        """frame: float32 mono, exactly FRAME_SAMPLES long, range [-1, 1]."""
        if len(frame) != FRAME_SAMPLES:
            frame = _fit(frame, FRAME_SAMPLES)
        with self.torch.no_grad():
            t = self.torch.from_numpy(frame.astype(np.float32))
            return float(self.model(t, SAMPLE_RATE).item())

    def reset(self):
        if hasattr(self.model, "reset_states"):
            self.model.reset_states()


# ──────────────────────────────────────────────────────────────────────────────
#  Energy fallback
# ──────────────────────────────────────────────────────────────────────────────

class EnergyVAD:
    """
    Adaptive-noise-floor RMS gate with a zero-crossing-rate sanity check.

    The noise floor tracks the quietest recent frames, so it adapts to a
    caller on a noisy street or a quiet office without reconfiguration.
    """

    SEED_FRAMES  = 8         # frames used to establish the initial floor
    ABS_GATE_LO  = 0.015     # rms clearly above any plausible room noise
    ABS_GATE_HI  = 0.060     # rms unambiguously speech at normal mic gain

    def __init__(self, sensitivity: float = 3.0):
        self.sensitivity = sensitivity      # how many x above floor = speech
        self.reset()
        self.name = "energy"

    def reset(self):
        self._floor      = None
        self._floor_init = False
        self._frames     = 0

    def speech_prob(self, frame: np.ndarray) -> float:
        frame = frame.astype(np.float32)
        rms = float(np.sqrt(np.mean(frame ** 2)) + 1e-9)

        # Zero-crossing rate β€” speech sits in a middling band; pure hiss is high,
        # DC/rumble is very low.
        zcr = float(np.mean(np.abs(np.diff(np.sign(frame)))) / 2.0)

        self._frames += 1

        # ── Seeding ──────────────────────────────────────────────────────────
        # Seed from the MINIMUM, never a running average. If the caller starts
        # talking part-way through the seed window (or is already talking when
        # the stream opens), an averaged seed is dragged up to speech level and
        # the detector goes deaf for the rest of the session.
        if not self._floor_init:
            self._floor = rms if self._floor is None else min(self._floor, rms)
            if self._frames >= self.SEED_FRAMES:
                self._floor_init = True
            # Still answer using the absolute gate, so speech during the seed
            # window is not silently swallowed.
            return self._absolute_prob(rms) * self._zcr_penalty(zcr)

        # ── Asymmetric adaptation ────────────────────────────────────────────
        # Down fast (room got quiet β†’ recover in ~7 frames).
        # Up very slowly (~60s time constant), so a long unbroken utterance
        # cannot drag the floor up to its own level and mute itself.
        self._floor = max(
            0.85 * self._floor + 0.15 * rms if rms < self._floor
            else 0.9995 * self._floor + 0.0005 * rms,
            1e-6,
        )

        ratio    = rms / (self._floor * self.sensitivity)
        rel_prob = float(np.clip((ratio - 0.6) / 1.4, 0.0, 1.0))

        # Absolute gate is a floor on confidence, not a cap: if the signal is
        # loud in absolute terms it is speech regardless of what the adaptive
        # estimate believes.
        prob = max(rel_prob, self._absolute_prob(rms))
        return prob * self._zcr_penalty(zcr)

    # ── Helpers ───────────────────────────────────────────────────────────────

    def _absolute_prob(self, rms: float) -> float:
        return float(np.clip(
            (rms - self.ABS_GATE_LO) / (self.ABS_GATE_HI - self.ABS_GATE_LO),
            0.0, 1.0))

    @staticmethod
    def _zcr_penalty(zcr: float) -> float:
        """Penalise implausible zero-crossing rates (hiss, rumble, DC)."""
        return 0.4 if (zcr > 0.35 or zcr < 0.005) else 1.0


# ──────────────────────────────────────────────────────────────────────────────

def _fit(frame: np.ndarray, n: int) -> np.ndarray:
    """Pad or truncate a frame to exactly n samples."""
    if len(frame) >= n:
        return frame[:n]
    return np.pad(frame, (0, n - len(frame)))


def load_vad(prefer: str = "auto"):
    """
    prefer: 'auto' | 'silero' | 'energy'
    Never raises β€” falls back to EnergyVAD if Silero cannot be loaded
    (no network in the Space, torch.hub blocked, etc).
    """
    if prefer == "energy":
        return EnergyVAD()
    try:
        return SileroVAD()
    except Exception as e:
        logger.warning(f"Silero VAD unavailable ({e}); using energy VAD.")
        return EnergyVAD()