Language_Tutor / vad_handler.py
joelazo
Switch LLM to Gemini, add hands-free voice, fix truncation & loops
83a5178
Raw
History Blame Contribute Delete
7.45 kB
"""
Streaming Voice-Activity Detection (VAD)
Lets the app run hands-free: instead of clicking "Stop", the user just talks,
and this module detects when they've finished speaking so the app can
auto-transcribe and send.
It wraps silero-vad. A `StreamingVAD` instance is created per Gradio session
(stored in a `gr.State`) and fed raw microphone chunks from the Audio `.stream`
event. When it detects speech followed by a sustained pause, `add_chunk`
returns the finished utterance as audio, which the caller transcribes.
The silero model only accepts 16 kHz mono frames of 512 samples, so chunks are
resampled to 16 kHz *for detection only*. The original-rate audio is preserved
and handed to the speech-to-text provider, so transcription quality isn't
degraded by the downsample.
"""
import os
import wave
import tempfile
import uuid
from typing import Optional, Tuple
import numpy as np
import torch
# silero-vad frame requirements
SILERO_SR = 16000 # silero operates at 16 kHz
FRAME_SAMPLES = 512 # silero expects exactly 512-sample frames at 16 kHz
FRAME_MS = FRAME_SAMPLES / SILERO_SR * 1000.0 # 32 ms per frame
# Detection tuning (milliseconds). These are the knobs to adjust if auto-send
# fires too eagerly (raise SILENCE_HANG_MS) or clips the user off.
SPEECH_THRESHOLD = 0.5 # frame speech-probability above which it counts as speech
MIN_SPEECH_MS = 250 # require this much speech before a pause can trigger a send
SILENCE_HANG_MS = 900 # trailing silence that means "they've stopped talking"
PREROLL_MS = 300 # audio kept before speech onset so we don't clip the first word
MAX_UTTERANCE_MS = 30000 # safety cap: force a send if someone talks this long nonstop
# Anti-hallucination gates. Whisper invents text (e.g. "Untertitel der
# Amara.org-Community") when fed silence/noise, so we discard any "utterance"
# that isn't both confidently speech and actually loud enough to be a voice.
MIN_PEAK_PROB = 0.85 # need at least one clearly-speech frame, else it's noise
MIN_RMS = 0.01 # need real audio energy (silence/breath sits well below this)
_model = None
def _get_model():
"""Lazily load the shared silero-vad model."""
global _model
if _model is None:
torch.set_num_threads(1) # avoid oversubscription on small CPU hosts (e.g. HF Spaces)
from silero_vad import load_silero_vad
_model = load_silero_vad()
return _model
def _to_mono_float32(audio: np.ndarray) -> np.ndarray:
"""Coerce a Gradio audio chunk to mono float32 in [-1, 1].
Gradio streams ``type="numpy"`` mic audio as int16; integer dtypes are
scaled to [-1, 1]. Float input is assumed already normalized.
"""
audio = np.asarray(audio)
is_int = np.issubdtype(audio.dtype, np.integer)
if audio.ndim > 1: # (samples, channels) -> mono
audio = audio.mean(axis=1)
audio = audio.astype(np.float32)
if is_int: # int16 (or other int) -> normalize by full scale
audio = audio / 32768.0
return audio
def _resample_to_16k(audio: np.ndarray, sr: int) -> np.ndarray:
"""Linear-interpolation resample to 16 kHz (sufficient for VAD detection)."""
if sr == SILERO_SR or audio.size == 0:
return audio
new_len = int(round(audio.shape[0] * SILERO_SR / sr))
if new_len <= 0:
return np.zeros(0, dtype=np.float32)
x_old = np.linspace(0.0, 1.0, num=audio.shape[0], endpoint=False)
x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
return np.interp(x_new, x_old, audio).astype(np.float32)
class StreamingVAD:
"""Accumulates streamed mic audio and detects end-of-utterance.
One instance per session. Reusable: after it returns an utterance it resets
itself and keeps listening for the next one.
"""
def __init__(self):
self._reset()
_get_model().reset_states()
def _reset(self):
self.orig_sr: Optional[int] = None
self.orig_buffer = np.zeros(0, dtype=np.float32) # utterance audio at mic sample rate
self._frame_carry = np.zeros(0, dtype=np.float32) # leftover < one 16k frame
self.speech_started = False
self.speech_ms = 0.0
self.silence_ms = 0.0
self.peak_prob = 0.0 # highest speech probability seen this utterance
def reset(self):
"""Discard any in-progress audio and clear model state."""
self._reset()
_get_model().reset_states()
def add_chunk(self, sr: int, audio: np.ndarray) -> Optional[Tuple[np.ndarray, int]]:
"""Feed one mic chunk.
Returns ``(utterance_audio, sample_rate)`` once end-of-speech is
detected (and resets for the next utterance), otherwise ``None``.
"""
model = _get_model()
mono = _to_mono_float32(audio)
if mono.size == 0:
return None
if self.orig_sr is None:
self.orig_sr = sr
# Accumulate full audio once speech has started; before that, keep only a
# short pre-roll so leading silence doesn't bloat the buffer or the clip.
self.orig_buffer = np.concatenate([self.orig_buffer, mono])
if not self.speech_started:
preroll = int(PREROLL_MS / 1000.0 * (self.orig_sr or sr))
if self.orig_buffer.shape[0] > preroll:
self.orig_buffer = self.orig_buffer[-preroll:]
# Frame the new audio at 16 kHz and score each frame for speech.
work = np.concatenate([self._frame_carry, _resample_to_16k(mono, sr)])
n_frames = work.shape[0] // FRAME_SAMPLES
for i in range(n_frames):
frame = work[i * FRAME_SAMPLES:(i + 1) * FRAME_SAMPLES]
prob = model(torch.from_numpy(frame), SILERO_SR).item()
self.peak_prob = max(self.peak_prob, prob)
if prob >= SPEECH_THRESHOLD:
self.speech_ms += FRAME_MS
self.silence_ms = 0.0
if self.speech_ms >= MIN_SPEECH_MS:
self.speech_started = True
elif self.speech_started:
self.silence_ms += FRAME_MS
self._frame_carry = work[n_frames * FRAME_SAMPLES:]
utterance_ms = self.orig_buffer.shape[0] / (self.orig_sr or sr) * 1000.0
end_of_speech = self.speech_started and self.silence_ms >= SILENCE_HANG_MS
too_long = self.speech_started and utterance_ms >= MAX_UTTERANCE_MS
if end_of_speech or too_long:
audio = self.orig_buffer.copy()
sr_out = self.orig_sr or sr
peak = self.peak_prob
rms = float(np.sqrt(np.mean(audio ** 2))) if audio.size else 0.0
self.reset()
# Reject anything that wasn't confidently speech AND loud enough — this
# is what stops the silence -> Whisper-hallucination -> resend loop.
if peak < MIN_PEAK_PROB or rms < MIN_RMS:
return None
return (audio, sr_out)
return None
def save_wav(audio: np.ndarray, sr: int) -> str:
"""Write float32 mono audio to a unique temp WAV and return its path."""
path = os.path.join(tempfile.gettempdir(), f"utt_{uuid.uuid4().hex}.wav")
pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2")
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(pcm.tobytes())
return path