somukandula commited on
Commit
92f6548
·
verified ·
1 Parent(s): daea277

Add contextual VAD turn-event trainer

Browse files
Files changed (2) hide show
  1. README.md +12 -0
  2. app.py +234 -0
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():