Add contextual VAD turn-event trainer
Browse files
README.md
CHANGED
|
@@ -12,6 +12,18 @@ license: mit
|
|
| 12 |
|
| 13 |
Small CPU-friendly classifier for improving VAD events on top of an STT + LLM + TTS voice-agent pipeline.
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
## How It Works
|
| 16 |
|
| 17 |
Raw acoustic VAD is fast, but it only answers "is there speech-like audio right now?" A voice agent needs richer decisions:
|
|
|
|
| 12 |
|
| 13 |
Small CPU-friendly classifier for improving VAD events on top of an STT + LLM + TTS voice-agent pipeline.
|
| 14 |
|
| 15 |
+
## Live Playground
|
| 16 |
+
|
| 17 |
+
The Space includes a browser playground for quick event testing:
|
| 18 |
+
|
| 19 |
+
- record from the browser microphone
|
| 20 |
+
- upload an audio file
|
| 21 |
+
- optionally type the STT text you want the model to see
|
| 22 |
+
- set assistant/TTS state to test interruption behavior
|
| 23 |
+
- inspect a 100ms frame-level event timeline
|
| 24 |
+
|
| 25 |
+
The playground does not run a full STT model on Free CPU. Audio drives VAD/timing features, while the optional transcript box simulates the STT partial/stable text features used by the classifier.
|
| 26 |
+
|
| 27 |
## How It Works
|
| 28 |
|
| 29 |
Raw acoustic VAD is fast, but it only answers "is there speech-like audio right now?" A voice agent needs richer decisions:
|
app.py
CHANGED
|
@@ -6,6 +6,8 @@ from pathlib import Path
|
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
import gradio as gr
|
|
|
|
|
|
|
| 9 |
from huggingface_hub import hf_hub_download
|
| 10 |
|
| 11 |
from turn_event_model import (
|
|
@@ -26,6 +28,36 @@ LOCAL_MODEL_PATH = LOCAL_MODEL_DIR / "turn_event_model.joblib"
|
|
| 26 |
MODEL = None
|
| 27 |
STARTUP_STATUS = "Model not loaded yet."
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def try_download_model() -> bool:
|
| 31 |
global MODEL
|
|
@@ -160,12 +192,214 @@ def train_from_upload(csv_file: str | None, n_samples: float, model_repo_id: str
|
|
| 160 |
)
|
| 161 |
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
startup()
|
| 164 |
|
| 165 |
with gr.Blocks() as demo:
|
| 166 |
gr.Markdown("# Contextual VAD")
|
| 167 |
status = gr.Textbox(value=STARTUP_STATUS, label="Status", interactive=False)
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
with gr.Tab("Predict"):
|
| 170 |
with gr.Row():
|
| 171 |
with gr.Column():
|
|
|
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
from huggingface_hub import hf_hub_download
|
| 12 |
|
| 13 |
from turn_event_model import (
|
|
|
|
| 28 |
MODEL = None
|
| 29 |
STARTUP_STATUS = "Model not loaded yet."
|
| 30 |
|
| 31 |
+
BACKCHANNEL_WORDS = {
|
| 32 |
+
"yeah",
|
| 33 |
+
"yes",
|
| 34 |
+
"yep",
|
| 35 |
+
"yup",
|
| 36 |
+
"mhm",
|
| 37 |
+
"mmhm",
|
| 38 |
+
"mm-hm",
|
| 39 |
+
"uh huh",
|
| 40 |
+
"right",
|
| 41 |
+
"ok",
|
| 42 |
+
"okay",
|
| 43 |
+
"sure",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
CONTINUATION_WORDS = {
|
| 47 |
+
"and",
|
| 48 |
+
"but",
|
| 49 |
+
"because",
|
| 50 |
+
"so",
|
| 51 |
+
"or",
|
| 52 |
+
"for",
|
| 53 |
+
"with",
|
| 54 |
+
"to",
|
| 55 |
+
"um",
|
| 56 |
+
"uh",
|
| 57 |
+
"like",
|
| 58 |
+
"then",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
|
| 62 |
def try_download_model() -> bool:
|
| 63 |
global MODEL
|
|
|
|
| 192 |
)
|
| 193 |
|
| 194 |
|
| 195 |
+
def clean_words(text: str) -> list[str]:
|
| 196 |
+
return [word.strip(".,!?;:()[]{}\"'").lower() for word in text.split() if word.strip()]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def transcript_features(text: str, progress: float) -> dict[str, Any]:
|
| 200 |
+
text = (text or "").strip()
|
| 201 |
+
words = clean_words(text)
|
| 202 |
+
shown_chars = int(round(len(text) * max(0.0, min(1.0, progress))))
|
| 203 |
+
shown_words = max(0, min(len(words), int(round(len(words) * max(0.0, min(1.0, progress))))))
|
| 204 |
+
last_word = words[-1] if words else ""
|
| 205 |
+
compact = " ".join(words)
|
| 206 |
+
return {
|
| 207 |
+
"stable_chars": shown_chars,
|
| 208 |
+
"partial_chars": max(shown_chars, int(round(len(text) * min(1.0, progress + 0.18)))),
|
| 209 |
+
"stable_word_count": shown_words,
|
| 210 |
+
"words_since_pause": shown_words,
|
| 211 |
+
"stt_confidence": 0.86 if text else 0.08,
|
| 212 |
+
"ends_with_punctuation": int(text.endswith((".", "!", "?"))),
|
| 213 |
+
"ends_with_continuation": int(bool(last_word and last_word in CONTINUATION_WORDS and not text.endswith((".", "!", "?")))),
|
| 214 |
+
"backchannel_like": int(compact in BACKCHANNEL_WORDS or (len(words) <= 2 and compact in BACKCHANNEL_WORDS)),
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def audio_to_float_mono(audio: tuple[int, np.ndarray] | None) -> tuple[int, np.ndarray] | None:
|
| 219 |
+
if audio is None:
|
| 220 |
+
return None
|
| 221 |
+
sample_rate, samples = audio
|
| 222 |
+
if samples is None:
|
| 223 |
+
return None
|
| 224 |
+
arr = np.asarray(samples)
|
| 225 |
+
if arr.ndim > 1:
|
| 226 |
+
arr = arr.mean(axis=1)
|
| 227 |
+
arr = arr.astype(np.float32)
|
| 228 |
+
peak = float(np.max(np.abs(arr))) if arr.size else 0.0
|
| 229 |
+
if peak > 1.5:
|
| 230 |
+
arr = arr / max(peak, 1.0)
|
| 231 |
+
return int(sample_rate), arr
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def frame_rms(samples: np.ndarray, sample_rate: int, frame_ms: int) -> tuple[np.ndarray, int]:
|
| 235 |
+
frame_len = max(1, int(sample_rate * frame_ms / 1000))
|
| 236 |
+
n_frames = max(1, int(np.ceil(len(samples) / frame_len)))
|
| 237 |
+
padded = np.pad(samples, (0, max(0, n_frames * frame_len - len(samples))))
|
| 238 |
+
frames = padded.reshape(n_frames, frame_len)
|
| 239 |
+
rms = np.sqrt(np.mean(np.square(frames), axis=1))
|
| 240 |
+
return rms, frame_len
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def acoustic_vad_probs(rms: np.ndarray) -> np.ndarray:
|
| 244 |
+
if len(rms) == 0:
|
| 245 |
+
return np.asarray([], dtype=np.float32)
|
| 246 |
+
noise_floor = float(np.percentile(rms, 25))
|
| 247 |
+
threshold = max(0.012, noise_floor * 2.8)
|
| 248 |
+
scale = max(0.006, threshold * 0.55)
|
| 249 |
+
logits = np.clip((rms - threshold) / scale, -20, 20)
|
| 250 |
+
probs = 1.0 / (1.0 + np.exp(-logits))
|
| 251 |
+
return probs.astype(np.float32)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def analyze_playground_audio(
|
| 255 |
+
audio: tuple[int, np.ndarray] | None,
|
| 256 |
+
transcript: str,
|
| 257 |
+
assistant_speaking: bool,
|
| 258 |
+
expected_answer_type: str,
|
| 259 |
+
required_slots_filled: bool,
|
| 260 |
+
tts_echo_risk: float,
|
| 261 |
+
recent_endpoint_candidate: bool,
|
| 262 |
+
) -> tuple[pd.DataFrame, dict[str, Any]]:
|
| 263 |
+
if MODEL is None:
|
| 264 |
+
return pd.DataFrame(), {"error": "Model is not loaded yet."}
|
| 265 |
+
|
| 266 |
+
converted = audio_to_float_mono(audio)
|
| 267 |
+
if converted is None:
|
| 268 |
+
return pd.DataFrame(), {"error": "Record from the microphone or upload an audio file first."}
|
| 269 |
+
|
| 270 |
+
sample_rate, samples = converted
|
| 271 |
+
if samples.size == 0:
|
| 272 |
+
return pd.DataFrame(), {"error": "Audio was empty."}
|
| 273 |
+
|
| 274 |
+
frame_ms = 100
|
| 275 |
+
rms, _ = frame_rms(samples, sample_rate, frame_ms)
|
| 276 |
+
probs = acoustic_vad_probs(rms)
|
| 277 |
+
duration_s = len(samples) / sample_rate
|
| 278 |
+
speech_ms = 0.0
|
| 279 |
+
silence_ms = 0.0
|
| 280 |
+
endpoint_seen = bool(recent_endpoint_candidate)
|
| 281 |
+
rows: list[dict[str, Any]] = []
|
| 282 |
+
|
| 283 |
+
for idx, (energy, vad_prob) in enumerate(zip(rms, probs)):
|
| 284 |
+
t_ms = idx * frame_ms
|
| 285 |
+
progress = (t_ms + frame_ms) / max(duration_s * 1000, frame_ms)
|
| 286 |
+
vad_active = float(vad_prob) >= 0.55
|
| 287 |
+
if vad_active:
|
| 288 |
+
speech_ms += frame_ms
|
| 289 |
+
silence_ms = 0.0
|
| 290 |
+
else:
|
| 291 |
+
silence_ms += frame_ms
|
| 292 |
+
|
| 293 |
+
text_features = transcript_features(transcript, progress)
|
| 294 |
+
payload = {
|
| 295 |
+
**DEFAULT_FEATURES,
|
| 296 |
+
**text_features,
|
| 297 |
+
"vad_prob": float(vad_prob),
|
| 298 |
+
"vad_active": int(vad_active),
|
| 299 |
+
"speech_ms": speech_ms,
|
| 300 |
+
"silence_ms": silence_ms,
|
| 301 |
+
"energy": float(min(1.0, energy * 18.0)),
|
| 302 |
+
"assistant_speaking": int(assistant_speaking),
|
| 303 |
+
"tts_playback_ms": float(t_ms if assistant_speaking else 0.0),
|
| 304 |
+
"tts_echo_risk": float(tts_echo_risk if assistant_speaking else 0.0),
|
| 305 |
+
"time_since_user_started_ms": speech_ms if speech_ms > 0 else 0.0,
|
| 306 |
+
"time_since_assistant_started_ms": float(t_ms if assistant_speaking else 0.0),
|
| 307 |
+
"recent_endpoint_candidate": int(endpoint_seen),
|
| 308 |
+
"required_slots_filled": int(required_slots_filled),
|
| 309 |
+
"expected_answer_type": expected_answer_type,
|
| 310 |
+
}
|
| 311 |
+
pred = predict_event(MODEL, payload)
|
| 312 |
+
if pred["event"] == "endpoint_candidate":
|
| 313 |
+
endpoint_seen = True
|
| 314 |
+
top3 = ", ".join(
|
| 315 |
+
f"{item['event']}:{item['probability']:.2f}"
|
| 316 |
+
for item in pred["probabilities"][:3]
|
| 317 |
+
)
|
| 318 |
+
rows.append(
|
| 319 |
+
{
|
| 320 |
+
"time_s": round(t_ms / 1000, 2),
|
| 321 |
+
"event": pred["event"],
|
| 322 |
+
"confidence": round(float(pred["confidence"]), 4),
|
| 323 |
+
"vad_prob": round(float(vad_prob), 4),
|
| 324 |
+
"energy": round(float(min(1.0, energy * 18.0)), 4),
|
| 325 |
+
"speech_ms": int(speech_ms),
|
| 326 |
+
"silence_ms": int(silence_ms),
|
| 327 |
+
"top3": top3,
|
| 328 |
+
}
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
timeline = pd.DataFrame(rows)
|
| 332 |
+
event_counts = timeline["event"].value_counts().to_dict() if not timeline.empty else {}
|
| 333 |
+
changes = timeline[timeline["event"].ne(timeline["event"].shift())] if not timeline.empty else timeline
|
| 334 |
+
summary = {
|
| 335 |
+
"duration_s": round(duration_s, 2),
|
| 336 |
+
"sample_rate": sample_rate,
|
| 337 |
+
"frames": int(len(timeline)),
|
| 338 |
+
"final_event": rows[-1]["event"] if rows else None,
|
| 339 |
+
"final_confidence": rows[-1]["confidence"] if rows else None,
|
| 340 |
+
"dominant_event": max(event_counts, key=event_counts.get) if event_counts else None,
|
| 341 |
+
"event_counts": event_counts,
|
| 342 |
+
"event_changes": changes[["time_s", "event", "confidence"]].head(40).to_dict(orient="records"),
|
| 343 |
+
"note": "Audio drives VAD/timing. The optional transcript/context fields simulate the STT and dialogue-state features used by the model.",
|
| 344 |
+
}
|
| 345 |
+
return timeline, summary
|
| 346 |
+
|
| 347 |
+
|
| 348 |
startup()
|
| 349 |
|
| 350 |
with gr.Blocks() as demo:
|
| 351 |
gr.Markdown("# Contextual VAD")
|
| 352 |
status = gr.Textbox(value=STARTUP_STATUS, label="Status", interactive=False)
|
| 353 |
|
| 354 |
+
with gr.Tab("Live Playground"):
|
| 355 |
+
gr.Markdown("Record from the browser microphone or upload an audio file, then inspect the frame-level event timeline.")
|
| 356 |
+
with gr.Row():
|
| 357 |
+
with gr.Column():
|
| 358 |
+
playground_audio = gr.Audio(
|
| 359 |
+
sources=["microphone", "upload"],
|
| 360 |
+
type="numpy",
|
| 361 |
+
label="Microphone or audio file",
|
| 362 |
+
format="wav",
|
| 363 |
+
)
|
| 364 |
+
playground_transcript = gr.Textbox(
|
| 365 |
+
label="Optional STT text",
|
| 366 |
+
placeholder="Type what the user said, e.g. actually wait I need to change that",
|
| 367 |
+
lines=3,
|
| 368 |
+
)
|
| 369 |
+
with gr.Column():
|
| 370 |
+
playground_assistant_speaking = gr.Checkbox(value=False, label="Assistant/TTS is speaking")
|
| 371 |
+
playground_expected_answer_type = gr.Dropdown(
|
| 372 |
+
["yes_no", "slot_fill", "open_ended", "confirmation"],
|
| 373 |
+
value="open_ended",
|
| 374 |
+
label="Expected answer type",
|
| 375 |
+
)
|
| 376 |
+
playground_required_slots_filled = gr.Checkbox(value=False, label="Required slots filled")
|
| 377 |
+
playground_recent_endpoint = gr.Checkbox(value=False, label="Recent endpoint candidate")
|
| 378 |
+
playground_echo_risk = gr.Slider(0, 1, value=0.12, step=0.01, label="Echo risk while TTS plays")
|
| 379 |
+
|
| 380 |
+
analyze_button = gr.Button("Analyze Audio", variant="primary")
|
| 381 |
+
playground_timeline = gr.Dataframe(label="Event timeline", interactive=False)
|
| 382 |
+
playground_summary = gr.JSON(label="Summary")
|
| 383 |
+
playground_inputs = [
|
| 384 |
+
playground_audio,
|
| 385 |
+
playground_transcript,
|
| 386 |
+
playground_assistant_speaking,
|
| 387 |
+
playground_expected_answer_type,
|
| 388 |
+
playground_required_slots_filled,
|
| 389 |
+
playground_echo_risk,
|
| 390 |
+
playground_recent_endpoint,
|
| 391 |
+
]
|
| 392 |
+
analyze_button.click(
|
| 393 |
+
fn=analyze_playground_audio,
|
| 394 |
+
inputs=playground_inputs,
|
| 395 |
+
outputs=[playground_timeline, playground_summary],
|
| 396 |
+
)
|
| 397 |
+
playground_audio.change(
|
| 398 |
+
fn=analyze_playground_audio,
|
| 399 |
+
inputs=playground_inputs,
|
| 400 |
+
outputs=[playground_timeline, playground_summary],
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
with gr.Tab("Predict"):
|
| 404 |
with gr.Row():
|
| 405 |
with gr.Column():
|