Toadoum commited on
Commit
de7fd77
Β·
verified Β·
1 Parent(s): d402333

Upload 7 files

Browse files
Files changed (7) hide show
  1. app.py +144 -20
  2. pipeline.py +197 -0
  3. requirements.txt +37 -10
  4. sip.py +186 -0
  5. streaming_asr.py +349 -0
  6. test_streaming.py +217 -0
  7. vad.py +173 -0
app.py CHANGED
@@ -10,6 +10,7 @@ Run locally:
10
 
11
  import gradio as gr
12
  import numpy as np
 
13
  import uuid
14
  import json
15
  from datetime import datetime
@@ -24,6 +25,11 @@ ai_pipeline = HausaVoiceAIPipeline()
24
  # NLU backend chain: HF Inference API (if HF_TOKEN set) β†’ local LLM β†’ rules
25
  dm = Orchestrator(crm=CRMClient(), nlu=NLU())
26
 
 
 
 
 
 
27
  # ── Demo phrases (for visitors who don't speak Hausa) ────────────────────────
28
  DEMO_PROMPTS = [
29
  ("Compound: balance + transfer",
@@ -285,10 +291,15 @@ ARCH_HTML = """
285
 
286
  <!-- Pipeline -->
287
  <div style="display:flex; flex-direction:column; gap:8px;">
 
 
 
 
 
288
  <div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);
289
  border-radius:8px; padding:10px 20px; text-align:center;">
290
- <div style="font-size:11px; font-weight:700; color:#F59E0B;">WHISPER LARGE-V3</div>
291
- <div style="font-size:10px; color:#78716C;">Hausa ASR Β· openai/whisper-large-v3</div>
292
  </div>
293
  <div style="background:rgba(56,189,248,0.1);border:1px solid rgba(56,189,248,0.3);
294
  border-radius:8px; padding:10px 20px; text-align:center;">
@@ -344,6 +355,9 @@ def _init_state():
344
  return {
345
  "conv": dm.new_session(),
346
  "history": [], # [{role, hausa, english, time}]
 
 
 
347
  }
348
 
349
  def _render_conversation(history: list) -> str:
@@ -404,30 +418,47 @@ def process_voice(audio, text_input, state):
404
  else:
405
  return None, _render_conversation(state["history"]), "⚠ No input provided", state
406
 
407
- # 2. Hausa β†’ English
 
 
 
 
 
 
 
 
 
 
 
 
408
  english_text = ai_pipeline.hausa_to_english(hausa_text)
 
409
 
410
- # 3. Dialogue
411
  english_response, conv_state, escalate = dm.respond(
412
  english_text, hausa_text, state["conv"]
413
  )
414
  state["conv"] = conv_state
 
415
 
416
- # 4. English β†’ Hausa
417
  hausa_response = ai_pipeline.english_to_hausa(english_response)
 
418
 
419
- # 5. TTS
420
  sr, audio_out = ai_pipeline.hausa_text_to_audio(hausa_response)
 
421
 
422
- # 6. Update history
423
  now = datetime.now().strftime("%H:%M:%S")
424
- state["history"].append({
425
- "role": "user", "hausa": hausa_text,
426
- "english": english_text, "time": now
427
- })
428
- state["history"].append({
429
- "role": "agent", "hausa": hausa_response,
430
- "english": english_response, "time": now
 
 
431
  })
432
 
433
  open_tasks = [t for t in conv_state.tasks
@@ -437,15 +468,73 @@ def process_voice(audio, text_input, state):
437
  f"{sum(1 for t in conv_state.tasks if t.status == 'done')} done")
438
  if conv_state.active_task:
439
  status += f" Β· active: {conv_state.active_task.intent}"
 
 
 
440
  if escalate:
441
  status += " ⚠ ESCALATED TO HUMAN"
442
 
443
- return (sr, audio_out), _render_conversation(state["history"]), status, state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
 
445
 
446
  def reset_session(state):
447
  state = _init_state()
448
- return None, _render_conversation([]), "New session started.", state
449
 
450
 
451
  def use_demo_prompt(prompt_ha, state):
@@ -488,6 +577,12 @@ with gr.Blocks(css=CSS, title="PlotWeaver Β· Hausa Voice AI") as demo:
488
  _render_conversation([]),
489
  elem_classes=["conversation-box"]
490
  )
 
 
 
 
 
 
491
  status_box = gr.Textbox(
492
  label="Pipeline Status",
493
  value="Ready. Speak or type in Hausa.",
@@ -499,12 +594,23 @@ with gr.Blocks(css=CSS, title="PlotWeaver Β· Hausa Voice AI") as demo:
499
  with gr.Column(scale=1):
500
  gr.HTML('<div class="pw-card-title" '
501
  'style="margin-bottom:10px;">Input</div>')
502
- audio_in = gr.Audio(
 
 
503
  sources=["microphone"],
504
  type="numpy",
505
- label="Voice Input (Hausa)",
506
- streaming=False,
507
  )
 
 
 
 
 
 
 
 
 
508
  text_in = gr.Textbox(
509
  label="Text fallback (Hausa)",
510
  placeholder="Sannu, ina son sanin asusun kuΙ—in…",
@@ -534,6 +640,23 @@ with gr.Blocks(css=CSS, title="PlotWeaver Β· Hausa Voice AI") as demo:
534
  )
535
 
536
  # ── Events ─────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  submit_btn.click(
538
  fn=process_voice,
539
  inputs=[audio_in, text_in, app_state],
@@ -542,7 +665,8 @@ with gr.Blocks(css=CSS, title="PlotWeaver Β· Hausa Voice AI") as demo:
542
  reset_btn.click(
543
  fn=reset_session,
544
  inputs=[app_state],
545
- outputs=[audio_out, conversation_display, status_box, app_state],
 
546
  )
547
 
548
  # ── Tab 2 : Architecture ────────────────────────────────────────────
 
10
 
11
  import gradio as gr
12
  import numpy as np
13
+ import os
14
  import uuid
15
  import json
16
  from datetime import datetime
 
25
  # NLU backend chain: HF Inference API (if HF_TOKEN set) β†’ local LLM β†’ rules
26
  dm = Orchestrator(crm=CRMClient(), nlu=NLU())
27
 
28
+ # ── Streaming config ─────────────────────────────────────────────────────────
29
+ # Partials cost an extra Whisper decode every ~900ms. On a CPU-only Space that
30
+ # can saturate the box, so allow turning them off via env var.
31
+ SHOW_PARTIALS = os.getenv("SHOW_PARTIALS", "1") == "1"
32
+
33
  # ── Demo phrases (for visitors who don't speak Hausa) ────────────────────────
34
  DEMO_PROMPTS = [
35
  ("Compound: balance + transfer",
 
291
 
292
  <!-- Pipeline -->
293
  <div style="display:flex; flex-direction:column; gap:8px;">
294
+ <div style="background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);
295
+ border-radius:8px; padding:10px 20px; text-align:center;">
296
+ <div style="font-size:11px; font-weight:700; color:#F87171;">VAD ENDPOINTING</div>
297
+ <div style="font-size:10px; color:#78716C;">Silero VAD Β· preroll Β· hangover Β· barge-in</div>
298
+ </div>
299
  <div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);
300
  border-radius:8px; padding:10px 20px; text-align:center;">
301
+ <div style="font-size:11px; font-weight:700; color:#F59E0B;">WHISPER (DUAL)</div>
302
+ <div style="font-size:10px; color:#78716C;">small β†’ partials Β· large-v3 β†’ final</div>
303
  </div>
304
  <div style="background:rgba(56,189,248,0.1);border:1px solid rgba(56,189,248,0.3);
305
  border-radius:8px; padding:10px 20px; text-align:center;">
 
355
  return {
356
  "conv": dm.new_session(),
357
  "history": [], # [{role, hausa, english, time}]
358
+ "sasr": None, # StreamingASR β€” created on first streamed chunk
359
+ "partial": "", # live (unconfirmed) transcript
360
+ "metrics": [], # per-turn latency records
361
  }
362
 
363
  def _render_conversation(history: list) -> str:
 
418
  else:
419
  return None, _render_conversation(state["history"]), "⚠ No input provided", state
420
 
421
+ audio_out, status = _handle_turn(hausa_text, state)
422
+ return audio_out, _render_conversation(state["history"]), status, state
423
+
424
+
425
+ # ── Shared turn handler (used by both push-to-talk and streaming) ────────────
426
+
427
+ def _handle_turn(hausa_text: str, state: dict, asr_ms: float = 0.0):
428
+ """
429
+ Runs MT β†’ dialogue β†’ MT β†’ TTS for one finalized Hausa utterance.
430
+ Mutates state['history'] and state['conv']. Returns (audio_out, status).
431
+ """
432
+ import time as _time
433
+ t_mt0 = _time.perf_counter()
434
  english_text = ai_pipeline.hausa_to_english(hausa_text)
435
+ mt_in_ms = (_time.perf_counter() - t_mt0) * 1000
436
 
437
+ t_nlu0 = _time.perf_counter()
438
  english_response, conv_state, escalate = dm.respond(
439
  english_text, hausa_text, state["conv"]
440
  )
441
  state["conv"] = conv_state
442
+ nlu_ms = (_time.perf_counter() - t_nlu0) * 1000
443
 
444
+ t_mt1 = _time.perf_counter()
445
  hausa_response = ai_pipeline.english_to_hausa(english_response)
446
+ mt_out_ms = (_time.perf_counter() - t_mt1) * 1000
447
 
448
+ t_tts = _time.perf_counter()
449
  sr, audio_out = ai_pipeline.hausa_text_to_audio(hausa_response)
450
+ tts_ms = (_time.perf_counter() - t_tts) * 1000
451
 
 
452
  now = datetime.now().strftime("%H:%M:%S")
453
+ state["history"].append({"role": "user", "hausa": hausa_text,
454
+ "english": english_text, "time": now})
455
+ state["history"].append({"role": "agent", "hausa": hausa_response,
456
+ "english": english_response, "time": now})
457
+
458
+ total = asr_ms + mt_in_ms + nlu_ms + mt_out_ms + tts_ms
459
+ state["metrics"].append({
460
+ "asr": asr_ms, "mt_in": mt_in_ms, "nlu": nlu_ms,
461
+ "mt_out": mt_out_ms, "tts": tts_ms, "total": total,
462
  })
463
 
464
  open_tasks = [t for t in conv_state.tasks
 
468
  f"{sum(1 for t in conv_state.tasks if t.status == 'done')} done")
469
  if conv_state.active_task:
470
  status += f" Β· active: {conv_state.active_task.intent}"
471
+ status += (f" | ASR {asr_ms:.0f}ms Β· MT {mt_in_ms + mt_out_ms:.0f}ms Β· "
472
+ f"NLU {nlu_ms:.0f}ms Β· TTS {tts_ms:.0f}ms Β· "
473
+ f"total {total:.0f}ms")
474
  if escalate:
475
  status += " ⚠ ESCALATED TO HUMAN"
476
 
477
+ return (sr, audio_out), status
478
+
479
+
480
+ # ── Streaming handler: VAD endpointing, no button press ──────────────────────
481
+
482
+ def process_stream(stream_chunk, state):
483
+ """
484
+ Bound to gr.Audio(streaming=True).stream β€” fires every ~0.3s with a new
485
+ chunk of mic audio. The VAD decides when a turn has ended; no push-to-talk.
486
+
487
+ Returns: (audio_out, conversation_html, status, live_partial, state)
488
+ """
489
+ if state is None:
490
+ state = _init_state()
491
+ if stream_chunk is None:
492
+ return (None, _render_conversation(state["history"]),
493
+ "Listening …", state.get("partial", ""), state)
494
+
495
+ if state["sasr"] is None:
496
+ state["sasr"] = ai_pipeline.make_streaming_session(
497
+ emit_partials=SHOW_PARTIALS)
498
+
499
+ sr, chunk = stream_chunk
500
+ events = state["sasr"].accept_audio(chunk, sr)
501
+
502
+ audio_out = None
503
+ status = None
504
+
505
+ for ev in events:
506
+ if ev.kind == "speech_start":
507
+ status = "πŸŽ™ Listening β€” speech detected …"
508
+
509
+ elif ev.kind == "partial":
510
+ state["partial"] = ev.text
511
+ status = f"πŸŽ™ … ({ev.duration_ms/1000:.1f}s)"
512
+
513
+ elif ev.kind == "bargein":
514
+ # Caller interrupted the agent: stop playback immediately
515
+ audio_out = None
516
+ status = "βœ‹ Barge-in β€” you interrupted, go ahead."
517
+
518
+ elif ev.kind == "discarded":
519
+ state["partial"] = ""
520
+ status = "Listening …"
521
+
522
+ elif ev.kind == "final":
523
+ state["partial"] = ""
524
+ state["sasr"].agent_speaking = True # arm barge-in for playback
525
+ audio_out, status = _handle_turn(ev.text, state,
526
+ asr_ms=ev.latency_ms)
527
+
528
+ return (audio_out,
529
+ _render_conversation(state["history"]),
530
+ status or "Listening …",
531
+ state.get("partial", ""),
532
+ state)
533
 
534
 
535
  def reset_session(state):
536
  state = _init_state()
537
+ return None, _render_conversation([]), "New session started.", "", state
538
 
539
 
540
  def use_demo_prompt(prompt_ha, state):
 
577
  _render_conversation([]),
578
  elem_classes=["conversation-box"]
579
  )
580
+ partial_box = gr.Textbox(
581
+ label="Live transcript (unconfirmed)",
582
+ value="",
583
+ interactive=False,
584
+ lines=1,
585
+ )
586
  status_box = gr.Textbox(
587
  label="Pipeline Status",
588
  value="Ready. Speak or type in Hausa.",
 
594
  with gr.Column(scale=1):
595
  gr.HTML('<div class="pw-card-title" '
596
  'style="margin-bottom:10px;">Input</div>')
597
+
598
+ # ── Streaming mic: VAD endpoints the turn, no button ──
599
+ stream_in = gr.Audio(
600
  sources=["microphone"],
601
  type="numpy",
602
+ label="πŸ”΄ Live Mic β€” just talk, VAD ends your turn",
603
+ streaming=True,
604
  )
605
+
606
+ with gr.Accordion("Push-to-talk (fallback)", open=False):
607
+ audio_in = gr.Audio(
608
+ sources=["microphone", "upload"],
609
+ type="numpy",
610
+ label="Record or upload, then press Send",
611
+ streaming=False,
612
+ )
613
+
614
  text_in = gr.Textbox(
615
  label="Text fallback (Hausa)",
616
  placeholder="Sannu, ina son sanin asusun kuΙ—in…",
 
640
  )
641
 
642
  # ── Events ─────────────────────────────────────────────────────
643
+ # Streaming: fires continuously; VAD decides when a turn ends.
644
+ stream_in.stream(
645
+ fn=process_stream,
646
+ inputs=[stream_in, app_state],
647
+ outputs=[audio_out, conversation_display, status_box,
648
+ partial_box, app_state],
649
+ stream_every=0.3,
650
+ show_progress="hidden",
651
+ )
652
+ # Flush any in-progress utterance when the mic stops.
653
+ stream_in.stop_recording(
654
+ fn=lambda s: reset_session(s)[1:],
655
+ inputs=[app_state],
656
+ outputs=[conversation_display, status_box, partial_box,
657
+ app_state],
658
+ )
659
+
660
  submit_btn.click(
661
  fn=process_voice,
662
  inputs=[audio_in, text_in, app_state],
 
665
  reset_btn.click(
666
  fn=reset_session,
667
  inputs=[app_state],
668
+ outputs=[audio_out, conversation_display, status_box,
669
+ partial_box, app_state],
670
  )
671
 
672
  # ── Tab 2 : Architecture ────────────────────────────────────────────
pipeline.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HausaVoiceAIPipeline
3
+ ====================
4
+ Full pipeline: Audio β†’ Whisper ASR β†’ NLLB translation β†’ LLM NLU β†’ NLLB β†’ MMS-TTS β†’ Audio
5
+
6
+ Models used:
7
+ - ASR : openai/whisper-large-v3 (Hausa language support)
8
+ - MT : facebook/nllb-200-distilled-600M (hau_Latn ↔ eng_Latn)
9
+ - TTS : facebook/mms-tts-hau (Hausa VITS synthesis)
10
+ """
11
+
12
+ import os
13
+ import time
14
+ import logging
15
+ import numpy as np
16
+ import torch
17
+ from typing import Optional, Tuple
18
+
19
+ logging.basicConfig(level=logging.INFO)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ── Language codes ──────────────────────────────────────────────────────────
23
+ HAUSA_NLLB = "hau_Latn"
24
+ ENGLISH_NLLB = "eng_Latn"
25
+ FRENCH_NLLB = "fra_Latn"
26
+
27
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
28
+ logger.info(f"Pipeline running on: {DEVICE}")
29
+
30
+
31
+ class HausaVoiceAIPipeline:
32
+ """
33
+ Lazy-loading pipeline. Each component is loaded on first use to keep
34
+ the Space startup time reasonable on CPU.
35
+ """
36
+
37
+ # Dual-model ASR: large-v3 for the FINAL decode (accuracy), a small model
38
+ # for live partials (latency). Partials are thrown away and replaced by the
39
+ # final, so their error rate matters far less than their speed.
40
+ ASR_FINAL_MODEL = os.getenv("ASR_FINAL_MODEL", "openai/whisper-large-v3")
41
+ ASR_PARTIAL_MODEL = os.getenv("ASR_PARTIAL_MODEL", "openai/whisper-small")
42
+
43
+ def __init__(self, pivot_language: str = "english"):
44
+ self.pivot = pivot_language # dialogue logic runs in English
45
+ self._asr = None
46
+ self._asr_fast = None
47
+ self._nllb_model = None
48
+ self._nllb_tokenizer = None
49
+ self._tts_model = None
50
+ self._tts_tokenizer = None
51
+ self.sample_rate = 16_000 # Whisper input
52
+ self.tts_sample_rate = 16_000 # MMS output
53
+
54
+ # ── Lazy loaders ────────────────────────────────────────────────────────
55
+
56
+ def _load_asr(self):
57
+ if self._asr is not None:
58
+ return
59
+ logger.info(f"Loading {self.ASR_FINAL_MODEL} …")
60
+ from transformers import pipeline as hf_pipeline
61
+ self._asr = hf_pipeline(
62
+ "automatic-speech-recognition",
63
+ model=self.ASR_FINAL_MODEL,
64
+ generate_kwargs={"language": "hausa", "task": "transcribe"},
65
+ device=0 if DEVICE == "cuda" else -1,
66
+ chunk_length_s=30,
67
+ )
68
+
69
+ def _load_asr_fast(self):
70
+ """Small model for live partial transcripts."""
71
+ if self._asr_fast is not None:
72
+ return
73
+ if self.ASR_PARTIAL_MODEL == self.ASR_FINAL_MODEL:
74
+ self._load_asr()
75
+ self._asr_fast = self._asr
76
+ return
77
+ logger.info(f"Loading {self.ASR_PARTIAL_MODEL} (partials) …")
78
+ from transformers import pipeline as hf_pipeline
79
+ self._asr_fast = hf_pipeline(
80
+ "automatic-speech-recognition",
81
+ model=self.ASR_PARTIAL_MODEL,
82
+ generate_kwargs={"language": "hausa", "task": "transcribe"},
83
+ device=0 if DEVICE == "cuda" else -1,
84
+ chunk_length_s=30,
85
+ )
86
+
87
+ def _load_nllb(self):
88
+ if self._nllb_model is not None:
89
+ return
90
+ logger.info("Loading NLLB-200-distilled-600M …")
91
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
92
+ model_id = "facebook/nllb-200-distilled-600M"
93
+ self._nllb_tokenizer = AutoTokenizer.from_pretrained(model_id)
94
+ self._nllb_model = AutoModelForSeq2SeqLM.from_pretrained(model_id).to(DEVICE)
95
+
96
+ def _load_tts(self):
97
+ if self._tts_model is not None:
98
+ return
99
+ logger.info("Loading MMS-TTS Hausa …")
100
+ from transformers import VitsModel, AutoTokenizer
101
+ model_id = "facebook/mms-tts-hau"
102
+ self._tts_tokenizer = AutoTokenizer.from_pretrained(model_id)
103
+ self._tts_model = VitsModel.from_pretrained(model_id).to(DEVICE)
104
+ self.tts_sample_rate = self._tts_model.config.sampling_rate
105
+
106
+ # ── Public API ───────────────────────────────────────────────────────────
107
+
108
+ def transcribe(self, audio_array: np.ndarray, sample_rate: int = 16_000) -> str:
109
+ """
110
+ Whisper ASR: raw audio β†’ Hausa text.
111
+ audio_array : float32 numpy array, mono
112
+ """
113
+ self._load_asr()
114
+ t0 = time.perf_counter()
115
+ # Resample if needed (scipy-based; avoids pulling in librosa/numba)
116
+ if sample_rate != 16_000:
117
+ from streaming_asr import _resample
118
+ audio_array = _resample(audio_array, sample_rate, 16_000)
119
+ result = self._asr({"array": audio_array.astype(np.float32), "sampling_rate": 16_000})
120
+ text = result["text"].strip()
121
+ logger.info(f"ASR ({time.perf_counter()-t0:.2f}s): {text}")
122
+ return text
123
+
124
+ def transcribe_partial(self, audio_array: np.ndarray,
125
+ sample_rate: int = 16_000) -> str:
126
+ """Fast, lower-accuracy decode for live on-screen partials."""
127
+ self._load_asr_fast()
128
+ if sample_rate != 16_000:
129
+ from streaming_asr import _resample
130
+ audio_array = _resample(audio_array, sample_rate, 16_000)
131
+ result = self._asr_fast({"array": audio_array.astype(np.float32),
132
+ "sampling_rate": 16_000})
133
+ return result["text"].strip()
134
+
135
+ def make_streaming_session(self, emit_partials: bool = True,
136
+ config=None, vad_backend: str = "auto"):
137
+ """
138
+ Build a StreamingASR wired to this pipeline's two Whisper models.
139
+ Each caller/session needs its own instance (it holds audio state).
140
+ """
141
+ from streaming_asr import StreamingASR
142
+ return StreamingASR(
143
+ transcribe_fn=self.transcribe,
144
+ partial_transcribe_fn=self.transcribe_partial,
145
+ config=config,
146
+ vad_backend=vad_backend,
147
+ emit_partials=emit_partials,
148
+ )
149
+
150
+ def translate(self, text: str, src_lang: str, tgt_lang: str,
151
+ max_new_tokens: int = 256) -> str:
152
+ """NLLB translation."""
153
+ self._load_nllb()
154
+ t0 = time.perf_counter()
155
+ self._nllb_tokenizer.src_lang = src_lang
156
+ inputs = self._nllb_tokenizer(text, return_tensors="pt", truncation=True,
157
+ max_length=512).to(DEVICE)
158
+ forced_id = self._nllb_tokenizer.convert_tokens_to_ids(tgt_lang)
159
+ with torch.no_grad():
160
+ tokens = self._nllb_model.generate(
161
+ **inputs,
162
+ forced_bos_token_id=forced_id,
163
+ max_new_tokens=max_new_tokens,
164
+ )
165
+ translated = self._nllb_tokenizer.decode(tokens[0], skip_special_tokens=True)
166
+ logger.info(f"NLLB {src_lang}β†’{tgt_lang} ({time.perf_counter()-t0:.2f}s): {translated}")
167
+ return translated
168
+
169
+ def synthesize(self, text: str) -> Tuple[int, np.ndarray]:
170
+ """
171
+ MMS-TTS: Hausa text β†’ (sample_rate, audio_array int16).
172
+ """
173
+ self._load_tts()
174
+ t0 = time.perf_counter()
175
+ inputs = self._tts_tokenizer(text, return_tensors="pt").to(DEVICE)
176
+ with torch.no_grad():
177
+ output = self._tts_model(**inputs).waveform
178
+ audio = output.squeeze().cpu().numpy()
179
+ # Normalise β†’ int16
180
+ audio = (audio / np.abs(audio).max() * 32767).astype(np.int16)
181
+ logger.info(f"TTS ({time.perf_counter()-t0:.2f}s): {len(audio)/self.tts_sample_rate:.1f}s audio")
182
+ return self.tts_sample_rate, audio
183
+
184
+ # ── Full round-trip helper ───────────────────────────────────────────────
185
+
186
+ def hausa_to_english(self, hausa_text: str) -> str:
187
+ return self.translate(hausa_text, HAUSA_NLLB, ENGLISH_NLLB)
188
+
189
+ def english_to_hausa(self, english_text: str) -> str:
190
+ return self.translate(english_text, ENGLISH_NLLB, HAUSA_NLLB)
191
+
192
+ def audio_to_hausa_text(self, audio_array: np.ndarray,
193
+ sample_rate: int = 16_000) -> str:
194
+ return self.transcribe(audio_array, sample_rate)
195
+
196
+ def hausa_text_to_audio(self, hausa_text: str) -> Tuple[int, np.ndarray]:
197
+ return self.synthesize(hausa_text)
requirements.txt CHANGED
@@ -1,10 +1,37 @@
1
- # PlotWeaver Voice Agent β€” HF Space (Gradio SDK + ZeroGPU)
2
- gradio>=6.0,<7.0
3
- spaces
4
- torch>=2.2
5
- transformers>=4.44
6
- sentence-transformers>=3.0
7
- huggingface_hub>=0.24
8
- accelerate
9
- numpy
10
- scipy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── PlotWeaver Hausa Voice AI Agent ──────────────────────────────────────────
2
+ # Audited against actual imports. Nothing here is speculative.
3
+ #
4
+ # HF Spaces reinstalls this on every rebuild, so the list is deliberately small:
5
+ # librosa was removed in favour of the scipy resampler already in the codebase
6
+ # (librosa pulls numba + llvmlite and adds several minutes to cold builds).
7
+
8
+ # ── Core ML ──────────────────────────────────────────────────────────────────
9
+ torch>=2.2.0 # Whisper, NLLB, MMS, Silero VAD
10
+ transformers>=4.40.0 # >=4.40 required for VitsModel (MMS-TTS)
11
+ sentencepiece>=0.2.0 # NLLB tokenizer β€” NOT optional, NLLB fails without it
12
+ accelerate>=0.28.0 # device_map="auto" for the local NLU model
13
+ protobuf>=4.25.0 # slow→fast tokenizer conversion for NLLB
14
+
15
+ # ── Audio ────────────────────────────────────────────────────────────────────
16
+ numpy>=1.26.0,<3.0.0 # 2.x is fine; cap guards against a future 3.0 break
17
+ scipy>=1.12.0 # scipy.signal.resample β€” the only resampler used
18
+ soundfile>=0.12.1 # Gradio audio I/O backend
19
+
20
+ # ── App ──────────────────────────────────────────────────────────────────────
21
+ gradio>=4.44.0 # streaming=True + stream_every need >=4.44
22
+ requests>=2.31.0 # WhatsApp Cloud API, Zendesk, HF Inference API
23
+
24
+ # ── Optional integrations ────────────────────────────────────────────────────
25
+ # Only needed when SIP_PROVIDER=twilio with real credentials. integrations/sip.py
26
+ # imports twilio lazily inside the non-demo branches, so the demo never touches
27
+ # it and this line can be dropped to slim the image.
28
+ twilio>=9.0.0
29
+
30
+ # ── Deliberately absent ──────────────────────────────────────────────────────
31
+ # torchaudio β€” nothing imports it.
32
+ # librosa β€” resampling uses scipy.signal.resample instead.
33
+ # silero-vad β€” vad.py loads it via torch.hub and falls back to the built-in
34
+ # energy VAD if the download is blocked. No package needed.
35
+ # audioop β€” G.711 mu-law decoding is implemented in numpy in
36
+ # integrations/sip.py, because audioop was REMOVED from the
37
+ # stdlib in Python 3.13 and would break telephony on new images.
sip.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phone / SIP Integration (Twilio + Bandwidth stubs)
3
+ =====================================================
4
+ Handles inbound calls β†’ streams audio β†’ runs pipeline β†’ streams TTS back.
5
+
6
+ For a real deployment, use:
7
+ - Twilio Media Streams (WebSocket) + <Stream> TwiML verb
8
+ - Bandwidth BXML + WebSocket audio streaming
9
+ - Vonage Voice API + WebSocket
10
+
11
+ Environment variables:
12
+ TWILIO_ACCOUNT_SID = ACxxxx
13
+ TWILIO_AUTH_TOKEN = xxxx
14
+ TWILIO_PHONE_NUMBER = +1234567890
15
+ SIP_PROVIDER = twilio | bandwidth | demo
16
+ """
17
+
18
+ import os
19
+ import logging
20
+ from typing import Callable, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class TwilioAdapter:
26
+ """
27
+ Twilio Media Streams WebSocket adapter.
28
+
29
+ Flow:
30
+ 1. Inbound call β†’ Twilio webhook β†’ /voice endpoint
31
+ 2. Return TwiML with <Connect><Stream> β†’ Twilio opens WS
32
+ 3. WebSocket handler receives mulaw 8kHz chunks
33
+ 4. Chunks accumulated β†’ ASR β†’ NLU β†’ TTS β†’ send back over WS
34
+ """
35
+
36
+ def __init__(self):
37
+ self.sid = os.getenv("TWILIO_ACCOUNT_SID", "DEMO")
38
+ self.token = os.getenv("TWILIO_AUTH_TOKEN", "DEMO")
39
+ self.phone = os.getenv("TWILIO_PHONE_NUMBER", "+0000000000")
40
+ self._demo = self.sid == "DEMO"
41
+
42
+ def incoming_call_twiml(self, websocket_url: str) -> str:
43
+ """
44
+ Returns TwiML that Twilio will execute when a call arrives.
45
+ websocket_url: wss://yourserver.com/ws/audio
46
+ """
47
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
48
+ <Response>
49
+ <Say language="ha-NG">Sannu, barka da zuwa PlotWeaver. Muna jira…</Say>
50
+ <Connect>
51
+ <Stream url="{websocket_url}">
52
+ <Parameter name="language" value="hausa"/>
53
+ </Stream>
54
+ </Connect>
55
+ </Response>"""
56
+
57
+ def handle_ws_message(self, message: dict,
58
+ on_audio_chunk: Callable[[bytes], None]) -> None:
59
+ """
60
+ Called for each WebSocket message from Twilio.
61
+ Twilio sends: start, media (base64 mulaw), stop events.
62
+ """
63
+ import base64
64
+ event = message.get("event")
65
+ if event == "media":
66
+ chunk = base64.b64decode(message["media"]["payload"])
67
+ on_audio_chunk(chunk)
68
+ elif event == "stop":
69
+ logger.info(f"Call ended: {message.get('stop', {}).get('callSid')}")
70
+
71
+ def send_audio_twiml(self, call_sid: str, audio_url: str) -> dict:
72
+ """
73
+ Interrupt the current call and play synthesised audio.
74
+ Production: POST to Twilio API to update call.
75
+ """
76
+ if self._demo:
77
+ logger.info(f"[DEMO] Would play {audio_url} on call {call_sid}")
78
+ return {"status": "demo"}
79
+ from twilio.rest import Client
80
+ client = Client(self.sid, self.token)
81
+ call = client.calls(call_sid).update(
82
+ twiml=f'<Response><Play>{audio_url}</Play></Response>'
83
+ )
84
+ return {"status": call.status}
85
+
86
+ def make_outbound_call(self, to: str, message_en: str,
87
+ message_ha: str = "") -> dict:
88
+ """Outbound IVR call with TTS message."""
89
+ twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
90
+ <Response>
91
+ <Say language="ha-NG">{message_ha or message_en}</Say>
92
+ </Response>"""
93
+ if self._demo:
94
+ logger.info(f"[DEMO] Outbound to {to}: {message_en[:60]}…")
95
+ return {"status": "demo_queued", "to": to}
96
+ from twilio.rest import Client
97
+ client = Client(self.sid, self.token)
98
+ call = client.calls.create(
99
+ to=to, from_=self.phone, twiml=twiml
100
+ )
101
+ return {"sid": call.sid, "status": call.status}
102
+
103
+ @staticmethod
104
+ def mulaw_to_pcm(mulaw_bytes: bytes) -> bytes:
105
+ """
106
+ Convert 8kHz G.711 mu-law to 16-bit PCM at 16kHz for Whisper.
107
+
108
+ Implemented in numpy rather than the stdlib `audioop` module, which
109
+ was removed in Python 3.13. Keeping this dependency-free means the
110
+ telephony path works on any modern image.
111
+ """
112
+ import numpy as np
113
+
114
+ u = np.frombuffer(mulaw_bytes, dtype=np.uint8).astype(np.int32)
115
+ u = ~u & 0xFF # mu-law is stored inverted
116
+ sign = u & 0x80
117
+ exponent = (u >> 4) & 0x07
118
+ mantissa = u & 0x0F
119
+ # ITU-T G.711: t = ((mantissa << 3) + BIAS) << exponent, BIAS = 0x84
120
+ t = ((mantissa << 3) + 0x84) << exponent
121
+ pcm8k = np.where(sign != 0, 0x84 - t, t - 0x84).astype(np.int16)
122
+
123
+ # 8kHz β†’ 16kHz (linear interpolation; the band-limited content of a
124
+ # phone call makes a higher-order filter unnecessary here)
125
+ if len(pcm8k) == 0:
126
+ return b""
127
+ x = np.arange(len(pcm8k))
128
+ xi = np.arange(len(pcm8k) * 2) / 2.0 # exact 2x: 0, 0.5, 1, 1.5, …
129
+ pcm16k = np.interp(xi, x, pcm8k).astype(np.int16)
130
+ return pcm16k.tobytes()
131
+
132
+
133
+ class BandwidthAdapter:
134
+ """Bandwidth BXML + WebSocket audio streaming (stub)."""
135
+
136
+ def __init__(self):
137
+ self.account_id = os.getenv("BANDWIDTH_ACCOUNT_ID", "DEMO")
138
+ self.api_token = os.getenv("BANDWIDTH_API_TOKEN", "DEMO")
139
+ self._demo = self.account_id == "DEMO"
140
+
141
+ def incoming_call_bxml(self, websocket_url: str) -> str:
142
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
143
+ <Response>
144
+ <SpeakSentence locale="ha-NG">Sannu da zuwa PlotWeaver.</SpeakSentence>
145
+ <StartStream url="{websocket_url}" streamEventUrl="{websocket_url}/events"/>
146
+ </Response>"""
147
+
148
+ def send_tts(self, call_id: str, text: str, locale: str = "ha-NG") -> dict:
149
+ if self._demo:
150
+ logger.info(f"[DEMO] Bandwidth TTS on call {call_id}: {text[:60]}…")
151
+ return {"status": "demo"}
152
+ # Production: PATCH /calls/{callId} with BXML
153
+ raise NotImplementedError
154
+
155
+
156
+ class SIPRouter:
157
+ """
158
+ Routes a call to the correct adapter based on SIP_PROVIDER env var.
159
+ Also manages human-agent transfer via SIP REFER.
160
+ """
161
+
162
+ PROVIDERS = {"twilio": TwilioAdapter, "bandwidth": BandwidthAdapter}
163
+
164
+ def __init__(self):
165
+ provider = os.getenv("SIP_PROVIDER", "demo").lower()
166
+ if provider in self.PROVIDERS:
167
+ self.adapter = self.PROVIDERS[provider]()
168
+ else:
169
+ self.adapter = TwilioAdapter() # demo mode
170
+ logger.info(f"SIP provider: {provider}")
171
+
172
+ def transfer_to_human(self, call_sid: str,
173
+ agent_extension: str = "+0000000001") -> dict:
174
+ """
175
+ REFER / warm transfer to human agent queue.
176
+ In demo mode just logs.
177
+ """
178
+ logger.info(f"[SIP] Transferring {call_sid} β†’ agent {agent_extension}")
179
+ if isinstance(self.adapter, TwilioAdapter) and not self.adapter._demo:
180
+ from twilio.rest import Client
181
+ client = Client(self.adapter.sid, self.adapter.token)
182
+ call = client.calls(call_sid).update(
183
+ url=f"http://twimlets.com/forward?PhoneNumber={agent_extension}"
184
+ )
185
+ return {"status": call.status, "agent": agent_extension}
186
+ return {"status": "demo_transfer", "agent": agent_extension}
streaming_asr.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streaming ASR with VAD Endpointing
3
+ ====================================
4
+ Turns push-to-talk into a conversation.
5
+
6
+ The caller speaks; audio arrives in small chunks. This module decides β€” with
7
+ no button press β€” when an utterance has STARTED and when it has ENDED, emits
8
+ live partial transcripts while the caller is still talking, and detects
9
+ barge-in so the agent stops talking when interrupted.
10
+
11
+ Endpointing state machine
12
+ ─────────────────────────
13
+
14
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” speech β‰₯ min_speech_ms β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
15
+ β”‚ IDLE β”‚ ────────────────────────► β”‚ SPEAKING β”‚
16
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
17
+ β–² β”‚ silence detected
18
+ β”‚ β–Ό
19
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
20
+ β”‚ silence β‰₯ endpoint_ms β”‚ TRAILING_SIL β”‚
21
+ └──────── (emit FINAL) ────────│ (may resume) β”‚
22
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
23
+
24
+ Key behaviours:
25
+ - PREROLL : a ring buffer holds ~300ms of audio from BEFORE the speech
26
+ trigger fires, so the first phoneme is never clipped. This
27
+ is the single most common cause of "it dropped my first
28
+ word" in naive VAD implementations.
29
+ - HANGOVER : brief silences inside speech (natural pauses between words,
30
+ the gap before a plosive) do not end the turn. Only
31
+ `endpoint_silence_ms` of continuous silence does.
32
+ - PARTIALS : every `partial_interval_ms`, the audio so far is decoded
33
+ with a SMALL Whisper model for a live on-screen transcript.
34
+ The FINAL decode uses large-v3 for accuracy.
35
+ - BARGE-IN : while `agent_speaking` is set, sustained caller speech
36
+ raises a barge-in event so playback can be cut.
37
+ - MAX DURATION : a hard cap force-endpoints a caller who never pauses.
38
+
39
+ Whisper hallucinates confidently on silence ("Thank you.", "Subtitles by…"),
40
+ so utterances shorter than `min_speech_ms` of actual speech are discarded
41
+ without ever reaching the model.
42
+ """
43
+
44
+ import time
45
+ import logging
46
+ import numpy as np
47
+ from dataclasses import dataclass, field
48
+ from enum import Enum
49
+ from typing import Optional, Callable
50
+
51
+ from vad import load_vad, FRAME_SAMPLES, FRAME_MS, SAMPLE_RATE
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ class State(Enum):
57
+ IDLE = "idle"
58
+ SPEAKING = "speaking"
59
+ TRAILING_SIL = "trailing_silence"
60
+
61
+
62
+ @dataclass
63
+ class EndpointConfig:
64
+ speech_threshold: float = 0.55 # VAD prob above this = speech frame
65
+ silence_threshold: float = 0.35 # below this = silence (hysteresis gap
66
+ # prevents flapping at the boundary)
67
+ min_speech_ms: int = 250 # ignore coughs, door slams, clicks
68
+ endpoint_silence_ms: int = 700 # silence that ends a turn
69
+ preroll_ms: int = 300 # audio kept from before speech onset
70
+ max_utterance_ms: int = 20_000 # hard cap
71
+ partial_interval_ms: int = 900 # how often to emit a live partial
72
+ bargein_speech_ms: int = 220 # speech needed to interrupt the agent
73
+
74
+
75
+ @dataclass
76
+ class UtteranceEvent:
77
+ kind: str # 'partial' | 'final' | 'bargein' |
78
+ # 'speech_start' | 'discarded'
79
+ text: str = ""
80
+ audio: Optional[np.ndarray] = None
81
+ duration_ms: float = 0.0
82
+ speech_ms: float = 0.0
83
+ latency_ms: float = 0.0
84
+
85
+
86
+ class StreamingASR:
87
+ """
88
+ Feed audio with `accept_audio()`; consume the returned list of events.
89
+
90
+ Usage:
91
+ sasr = StreamingASR(transcribe_fn=pipeline.transcribe)
92
+ for chunk in mic_stream:
93
+ for ev in sasr.accept_audio(chunk, sr):
94
+ if ev.kind == "partial": show(ev.text)
95
+ if ev.kind == "final": handle(ev.text)
96
+ """
97
+
98
+ def __init__(self,
99
+ transcribe_fn: Callable[[np.ndarray, int], str],
100
+ partial_transcribe_fn: Optional[Callable] = None,
101
+ config: Optional[EndpointConfig] = None,
102
+ vad_backend: str = "auto",
103
+ emit_partials: bool = True):
104
+ self.cfg = config or EndpointConfig()
105
+ self.vad = load_vad(vad_backend)
106
+ self.transcribe_fn = transcribe_fn
107
+ # Partials can use a smaller/faster model; falls back to the main one
108
+ self.partial_transcribe_fn = partial_transcribe_fn or transcribe_fn
109
+ self.emit_partials = emit_partials
110
+
111
+ self.agent_speaking = False # set True while TTS plays (barge-in)
112
+ self._preroll_frames = max(1, int(self.cfg.preroll_ms / FRAME_MS))
113
+ self.reset()
114
+
115
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
116
+
117
+ def reset(self):
118
+ self.state = State.IDLE
119
+ self._buffer = np.zeros(0, dtype=np.float32) # leftover samples
120
+ self._preroll = [] # ring of frames
121
+ self._utterance = [] # frames of turn
122
+ self._speech_ms = 0.0
123
+ self._silence_ms = 0.0
124
+ self._utterance_ms = 0.0
125
+ self._bargein_ms = 0.0
126
+ self._last_partial_ms = 0.0
127
+ self._partial_text = ""
128
+ self.vad.reset()
129
+
130
+ # ── Main entry ────────────────────────────────────────────────────────────
131
+
132
+ def accept_audio(self, audio: np.ndarray,
133
+ sample_rate: int = SAMPLE_RATE) -> list[UtteranceEvent]:
134
+ """
135
+ audio: mono float32 in [-1,1] (int16 is auto-converted) of any length.
136
+ Returns zero or more events produced by this chunk.
137
+ """
138
+ events: list[UtteranceEvent] = []
139
+ audio = _to_float_mono(audio)
140
+ if sample_rate != SAMPLE_RATE:
141
+ audio = _resample(audio, sample_rate, SAMPLE_RATE)
142
+
143
+ self._buffer = np.concatenate([self._buffer, audio])
144
+
145
+ # Consume whole frames only; remainder stays buffered for next chunk
146
+ while len(self._buffer) >= FRAME_SAMPLES:
147
+ frame = self._buffer[:FRAME_SAMPLES]
148
+ self._buffer = self._buffer[FRAME_SAMPLES:]
149
+ ev = self._process_frame(frame)
150
+ events.extend(ev)
151
+
152
+ return events
153
+
154
+ def flush(self) -> list[UtteranceEvent]:
155
+ """Force-endpoint whatever is buffered (e.g. caller hung up)."""
156
+ if self.state in (State.SPEAKING, State.TRAILING_SIL):
157
+ return self._finalize()
158
+ return []
159
+
160
+ # ── Frame processing ──────────────────────────────────────────────────────
161
+
162
+ def _process_frame(self, frame: np.ndarray) -> list[UtteranceEvent]:
163
+ events = []
164
+ prob = self.vad.speech_prob(frame)
165
+ is_speech = prob >= self.cfg.speech_threshold
166
+ is_silence = prob <= self.cfg.silence_threshold
167
+
168
+ # ── Barge-in: caller talks over the agent ────────────────────────────
169
+ if self.agent_speaking:
170
+ self._bargein_ms = self._bargein_ms + FRAME_MS if is_speech else 0.0
171
+ if self._bargein_ms >= self.cfg.bargein_speech_ms:
172
+ self._bargein_ms = 0.0
173
+ self.agent_speaking = False
174
+ events.append(UtteranceEvent(kind="bargein"))
175
+ # fall through β€” this frame also starts the new utterance
176
+
177
+ # ── Preroll ring (only meaningful while IDLE) ────────────────────────
178
+ if self.state is State.IDLE:
179
+ self._preroll.append(frame)
180
+ if len(self._preroll) > self._preroll_frames:
181
+ self._preroll.pop(0)
182
+
183
+ # ── State machine ────────────────────────────────────────────────────
184
+ if self.state is State.IDLE:
185
+ if is_speech:
186
+ self._speech_ms += FRAME_MS
187
+ if self._speech_ms >= self.cfg.min_speech_ms:
188
+ # Commit: open the utterance with the preroll in front
189
+ self._utterance = list(self._preroll)
190
+ self._utterance_ms = len(self._utterance) * FRAME_MS
191
+ self._preroll = []
192
+ self._silence_ms = 0.0
193
+ self._last_partial_ms = 0.0
194
+ self.state = State.SPEAKING
195
+ events.append(UtteranceEvent(kind="speech_start"))
196
+ else:
197
+ self._speech_ms = 0.0
198
+ return events
199
+
200
+ # SPEAKING or TRAILING_SIL β€” always accumulate audio
201
+ self._utterance.append(frame)
202
+ self._utterance_ms += FRAME_MS
203
+
204
+ if self.state is State.SPEAKING:
205
+ if is_silence:
206
+ self.state = State.TRAILING_SIL
207
+ self._silence_ms = FRAME_MS
208
+ else:
209
+ if is_speech:
210
+ self._speech_ms += FRAME_MS
211
+ self._silence_ms = 0.0
212
+
213
+ elif self.state is State.TRAILING_SIL:
214
+ if is_speech:
215
+ # Natural pause, not an endpoint β€” resume
216
+ self.state = State.SPEAKING
217
+ self._speech_ms += FRAME_MS
218
+ self._silence_ms = 0.0
219
+ else:
220
+ self._silence_ms += FRAME_MS
221
+ if self._silence_ms >= self.cfg.endpoint_silence_ms:
222
+ return events + self._finalize()
223
+
224
+ # Hard cap on a caller who never pauses
225
+ if self._utterance_ms >= self.cfg.max_utterance_ms:
226
+ logger.info("Max utterance length reached β€” force endpoint.")
227
+ return events + self._finalize()
228
+
229
+ # ── Live partial transcript ──────────────────────────────────────────
230
+ if (self.emit_partials
231
+ and self.state is State.SPEAKING
232
+ and self._utterance_ms - self._last_partial_ms
233
+ >= self.cfg.partial_interval_ms):
234
+ self._last_partial_ms = self._utterance_ms
235
+ ev = self._emit_partial()
236
+ if ev:
237
+ events.append(ev)
238
+
239
+ return events
240
+
241
+ # ── Emission ──────────────────────────────────────────────────────────────
242
+
243
+ def _emit_partial(self) -> Optional[UtteranceEvent]:
244
+ audio = np.concatenate(self._utterance)
245
+ t0 = time.perf_counter()
246
+ try:
247
+ text = self.partial_transcribe_fn(audio, SAMPLE_RATE)
248
+ except Exception as e:
249
+ logger.warning(f"Partial decode failed: {e}")
250
+ return None
251
+ text = (text or "").strip()
252
+ if not text or text == self._partial_text:
253
+ return None
254
+ self._partial_text = text
255
+ return UtteranceEvent(
256
+ kind="partial", text=text,
257
+ duration_ms=self._utterance_ms, speech_ms=self._speech_ms,
258
+ latency_ms=(time.perf_counter() - t0) * 1000)
259
+
260
+ def _finalize(self) -> list[UtteranceEvent]:
261
+ audio = np.concatenate(self._utterance) if self._utterance else np.zeros(0)
262
+ speech_ms = self._speech_ms
263
+ total_ms = self._utterance_ms
264
+
265
+ # Reset BEFORE decoding so late-arriving audio starts a clean turn
266
+ self._utterance = []
267
+ self._preroll = []
268
+ self._speech_ms = 0.0
269
+ self._silence_ms = 0.0
270
+ self._utterance_ms = 0.0
271
+ self._partial_text = ""
272
+ self.state = State.IDLE
273
+ self.vad.reset()
274
+
275
+ # Guard: never send near-silence to Whisper (hallucination source)
276
+ if speech_ms < self.cfg.min_speech_ms or len(audio) < FRAME_SAMPLES * 4:
277
+ logger.info(f"Discarded short utterance ({speech_ms:.0f}ms speech).")
278
+ return [UtteranceEvent(kind="discarded", speech_ms=speech_ms,
279
+ duration_ms=total_ms)]
280
+
281
+ t0 = time.perf_counter()
282
+ try:
283
+ text = self.transcribe_fn(audio, SAMPLE_RATE)
284
+ except Exception as e:
285
+ logger.error(f"Final decode failed: {e}")
286
+ return [UtteranceEvent(kind="discarded", speech_ms=speech_ms,
287
+ duration_ms=total_ms)]
288
+ latency = (time.perf_counter() - t0) * 1000
289
+
290
+ text = (text or "").strip()
291
+ if not text or _is_hallucination(text):
292
+ logger.info(f"Discarded empty/hallucinated final: {text!r}")
293
+ return [UtteranceEvent(kind="discarded", speech_ms=speech_ms,
294
+ duration_ms=total_ms)]
295
+
296
+ logger.info(f"FINAL ({latency:.0f}ms, {speech_ms:.0f}ms speech): {text}")
297
+ return [UtteranceEvent(kind="final", text=text, audio=audio,
298
+ duration_ms=total_ms, speech_ms=speech_ms,
299
+ latency_ms=latency)]
300
+
301
+
302
+ # ── Hallucination filter ──────────────────────────────────────────────────────
303
+
304
+ _HALLUCINATIONS = {
305
+ "thank you.", "thanks for watching!", "thank you for watching.",
306
+ "you", ".", "...", "subtitles by the amara.org community",
307
+ "please subscribe.", "bye.", "amara.org", "sous-titrage",
308
+ "merci d'avoir regardΓ© cette vidΓ©o!", "Γ  suivre",
309
+ }
310
+
311
+
312
+ def _is_hallucination(text: str) -> bool:
313
+ t = text.strip().lower()
314
+ if t in _HALLUCINATIONS:
315
+ return True
316
+ # A "sentence" of only punctuation / music tags
317
+ if all(c in " .,!?-–—β™ͺ[]()" for c in t):
318
+ return True
319
+ return False
320
+
321
+
322
+ # ── Audio helpers ─────────────────────────────────────────────────────────────
323
+
324
+ def _to_float_mono(audio: np.ndarray) -> np.ndarray:
325
+ audio = np.asarray(audio)
326
+ if audio.ndim > 1:
327
+ audio = audio.mean(axis=1)
328
+ if audio.dtype == np.int16:
329
+ audio = audio.astype(np.float32) / 32768.0
330
+ elif audio.dtype == np.int32:
331
+ audio = audio.astype(np.float32) / 2147483648.0
332
+ else:
333
+ audio = audio.astype(np.float32)
334
+ return audio
335
+
336
+
337
+ def _resample(audio: np.ndarray, src: int, dst: int) -> np.ndarray:
338
+ if src == dst:
339
+ return audio
340
+ try:
341
+ import scipy.signal as ss
342
+ n = int(round(len(audio) * dst / src))
343
+ return ss.resample(audio, n).astype(np.float32)
344
+ except Exception:
345
+ # Linear interpolation fallback
346
+ n = int(round(len(audio) * dst / src))
347
+ xp = np.linspace(0, 1, len(audio), endpoint=False)
348
+ x = np.linspace(0, 1, n, endpoint=False)
349
+ return np.interp(x, xp, audio).astype(np.float32)
test_streaming.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streaming ASR / VAD endpointing tests.
3
+
4
+ Uses synthetic audio and a mock transcriber so it runs with no models and
5
+ no network. Verifies the state machine, not Whisper.
6
+ """
7
+
8
+ import sys
9
+ import logging
10
+ import numpy as np
11
+
12
+ logging.basicConfig(level=logging.WARNING)
13
+
14
+ from vad import load_vad, FRAME_MS, SAMPLE_RATE
15
+ from streaming_asr import StreamingASR, EndpointConfig, State, _is_hallucination
16
+
17
+ PASS, FAIL = "\033[92mPASS\033[0m", "\033[91mFAIL\033[0m"
18
+ results = []
19
+
20
+
21
+ def check(name, cond, detail=""):
22
+ results.append((name, cond))
23
+ print(f" [{PASS if cond else FAIL}] {name}" + (f" β€” {detail}" if detail else ""))
24
+
25
+
26
+ # ── Synthetic audio ───────────────────────────────────────────────────────────
27
+
28
+ def silence(ms, noise=0.0005):
29
+ n = int(SAMPLE_RATE * ms / 1000)
30
+ return (np.random.randn(n) * noise).astype(np.float32)
31
+
32
+
33
+ def speech(ms, amp=0.25):
34
+ """Voice-like: 120Hz glottal pulse + formants + amplitude modulation."""
35
+ n = int(SAMPLE_RATE * ms / 1000)
36
+ t = np.arange(n) / SAMPLE_RATE
37
+ sig = (np.sin(2 * np.pi * 120 * t)
38
+ + 0.5 * np.sin(2 * np.pi * 700 * t)
39
+ + 0.3 * np.sin(2 * np.pi * 1220 * t))
40
+ envelope = 0.6 + 0.4 * np.sin(2 * np.pi * 4 * t) # syllable rate
41
+ sig = sig * envelope
42
+ sig += np.random.randn(n) * 0.01
43
+ return (sig / np.abs(sig).max() * amp).astype(np.float32)
44
+
45
+
46
+ def feed(sasr, audio, chunk_ms=100):
47
+ """Feed audio in realistic small chunks, collecting all events."""
48
+ events = []
49
+ step = int(SAMPLE_RATE * chunk_ms / 1000)
50
+ for i in range(0, len(audio), step):
51
+ events.extend(sasr.accept_audio(audio[i:i + step], SAMPLE_RATE))
52
+ return events
53
+
54
+
55
+ # Mock transcriber records exactly what audio it was handed
56
+ DECODE_LOG = []
57
+
58
+
59
+ def mock_transcribe(audio, sr):
60
+ DECODE_LOG.append(len(audio) / sr * 1000) # duration in ms
61
+ return f"transcript_of_{len(audio)/sr:.2f}s"
62
+
63
+
64
+ def new_asr(**kw):
65
+ DECODE_LOG.clear()
66
+ cfg = EndpointConfig(**kw) if kw else EndpointConfig()
67
+ return StreamingASR(transcribe_fn=mock_transcribe, config=cfg,
68
+ vad_backend="energy")
69
+
70
+
71
+ # ══════════════════════════════════════════════════════════════════════════════
72
+
73
+ print("\n══ VAD discriminates speech from silence ══")
74
+ vad = load_vad("energy")
75
+ for _ in range(12): # let the floor settle
76
+ vad.speech_prob(silence(FRAME_MS)[:512])
77
+ sil_probs = [vad.speech_prob(silence(FRAME_MS)[:512]) for _ in range(20)]
78
+ sp = speech(1000)
79
+ sp_probs = [vad.speech_prob(sp[i:i+512]) for i in range(0, 512*20, 512)]
80
+ check("silence scores low", np.mean(sil_probs) < 0.3,
81
+ f"mean={np.mean(sil_probs):.2f}")
82
+ check("speech scores high", np.mean(sp_probs) > 0.6,
83
+ f"mean={np.mean(sp_probs):.2f}")
84
+
85
+ print("\n══ Basic endpointing: speech β†’ silence β†’ ONE final ══")
86
+ sasr = new_asr()
87
+ audio = np.concatenate([silence(500), speech(1200), silence(1200)])
88
+ events = feed(sasr, audio)
89
+ finals = [e for e in events if e.kind == "final"]
90
+ check("exactly one final emitted", len(finals) == 1, f"got {len(finals)}")
91
+ check("speech_start fired", any(e.kind == "speech_start" for e in events))
92
+ check("returns to IDLE after endpoint", sasr.state is State.IDLE)
93
+
94
+ print("\n══ Preroll: first phoneme is not clipped ══")
95
+ sasr = new_asr(preroll_ms=300)
96
+ audio = np.concatenate([silence(400), speech(1000), silence(1200)])
97
+ feed(sasr, audio)
98
+ check("decoded audio longer than speech alone (preroll included)",
99
+ DECODE_LOG and DECODE_LOG[-1] > 1000,
100
+ f"decoded {DECODE_LOG[-1]:.0f}ms for 1000ms of speech")
101
+
102
+ print("\n══ Hangover: a natural pause does NOT end the turn ══")
103
+ sasr = new_asr(endpoint_silence_ms=700)
104
+ # 400ms pause is shorter than the 700ms endpoint β†’ must stay ONE utterance
105
+ audio = np.concatenate([silence(400), speech(700), silence(400),
106
+ speech(700), silence(1200)])
107
+ events = feed(sasr, audio)
108
+ finals = [e for e in events if e.kind == "final"]
109
+ check("short internal pause does not split the turn", len(finals) == 1,
110
+ f"got {len(finals)} finals")
111
+
112
+ print("\n══ Long pause DOES split into two turns ══")
113
+ sasr = new_asr(endpoint_silence_ms=700)
114
+ audio = np.concatenate([silence(400), speech(700), silence(1300),
115
+ speech(700), silence(1300)])
116
+ events = feed(sasr, audio)
117
+ finals = [e for e in events if e.kind == "final"]
118
+ check("long pause produces two separate turns", len(finals) == 2,
119
+ f"got {len(finals)} finals")
120
+
121
+ print("\n══ Silence is never sent to Whisper (hallucination guard) ══")
122
+ sasr = new_asr()
123
+ feed(sasr, silence(4000))
124
+ check("no decode call on pure silence", len(DECODE_LOG) == 0,
125
+ f"{len(DECODE_LOG)} decode calls")
126
+ check("no final event on pure silence",
127
+ not any(e.kind == "final" for e in feed(sasr, silence(2000))))
128
+
129
+ print("\n══ Cough / click is discarded, not transcribed ══")
130
+ sasr = new_asr(min_speech_ms=250)
131
+ audio = np.concatenate([silence(400), speech(90), silence(1500)])
132
+ events = feed(sasr, audio)
133
+ check("sub-threshold blip produces no final",
134
+ not any(e.kind == "final" for e in events))
135
+ check("no decode call for the blip", len(DECODE_LOG) == 0,
136
+ f"{len(DECODE_LOG)} calls")
137
+
138
+ print("\n══ Live partials during a long utterance ══")
139
+ sasr = new_asr(partial_interval_ms=600)
140
+ audio = np.concatenate([silence(400), speech(3500), silence(1200)])
141
+ events = feed(sasr, audio)
142
+ partials = [e for e in events if e.kind == "partial"]
143
+ check("partials emitted while speaking", len(partials) >= 2,
144
+ f"got {len(partials)} partials")
145
+ check("final still emitted after partials",
146
+ any(e.kind == "final" for e in events))
147
+ check("partials precede the final",
148
+ events.index(next(e for e in events if e.kind == "final"))
149
+ > events.index(partials[0]) if partials else False)
150
+
151
+ print("\n══ Barge-in: caller interrupts the agent ══")
152
+ sasr = new_asr(bargein_speech_ms=220)
153
+ sasr.agent_speaking = True
154
+ events = feed(sasr, np.concatenate([silence(300), speech(900)]))
155
+ check("barge-in event raised", any(e.kind == "bargein" for e in events))
156
+ check("agent_speaking cleared", sasr.agent_speaking is False)
157
+
158
+ print("\n══ Barge-in does NOT fire on background noise ══")
159
+ sasr = new_asr(bargein_speech_ms=220)
160
+ sasr.agent_speaking = True
161
+ events = feed(sasr, silence(2000, noise=0.002))
162
+ check("no barge-in on quiet background",
163
+ not any(e.kind == "bargein" for e in events))
164
+
165
+ print("\n══ Max-duration force endpoint ══")
166
+ sasr = new_asr(max_utterance_ms=2000)
167
+ events = feed(sasr, np.concatenate([silence(300), speech(6000)]))
168
+ check("non-stop speaker is force-endpointed",
169
+ any(e.kind == "final" for e in events))
170
+
171
+ print("\n══ flush() finalizes a turn in progress (caller hung up) ══")
172
+ sasr = new_asr()
173
+ feed(sasr, np.concatenate([silence(400), speech(1000)]))
174
+ events = sasr.flush()
175
+ check("flush emits the pending final",
176
+ any(e.kind == "final" for e in events))
177
+
178
+ print("\n══ Chunk-size independence (frame alignment) ══")
179
+ for chunk_ms in (20, 33, 100, 250):
180
+ sasr = new_asr()
181
+ audio = np.concatenate([silence(400), speech(1200), silence(1200)])
182
+ events = feed(sasr, audio, chunk_ms=chunk_ms)
183
+ n = len([e for e in events if e.kind == "final"])
184
+ check(f"chunk={chunk_ms}ms β†’ 1 final", n == 1, f"got {n}")
185
+
186
+ print("\n══ Whisper hallucination filter ══")
187
+ check("'Thank you.' filtered", _is_hallucination("Thank you."))
188
+ check("'Subtitles by the Amara.org community' filtered",
189
+ _is_hallucination("Subtitles by the Amara.org community"))
190
+ check("'β™ͺ' filtered", _is_hallucination("β™ͺ"))
191
+ check("real Hausa text NOT filtered",
192
+ not _is_hallucination("Ina son duba asusuna"))
193
+
194
+ print("\n══ int16 input auto-converted ══")
195
+ sasr = new_asr()
196
+ audio_f = np.concatenate([silence(400), speech(1200), silence(1200)])
197
+ audio_i16 = (audio_f * 32767).astype(np.int16)
198
+ events = feed(sasr, audio_i16)
199
+ check("int16 stream produces a final",
200
+ any(e.kind == "final" for e in events))
201
+
202
+ print("\n══ Resampling from 8kHz (telephony) ══")
203
+ sasr = new_asr()
204
+ audio = np.concatenate([silence(400), speech(1200), silence(1200)])
205
+ audio_8k = audio[::2] # crude 8kHz
206
+ events = []
207
+ step = 800
208
+ for i in range(0, len(audio_8k), step):
209
+ events.extend(sasr.accept_audio(audio_8k[i:i + step], 8000))
210
+ check("8kHz telephony audio endpoints correctly",
211
+ any(e.kind == "final" for e in events))
212
+
213
+ # ── Summary ───────────────────────────────────────────────────────────────────
214
+ passed = sum(1 for _, ok in results if ok)
215
+ total = len(results)
216
+ print(f"\n{'='*62}\n {passed}/{total} checks passed\n{'='*62}")
217
+ sys.exit(0 if passed == total else 1)
vad.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Voice Activity Detection
3
+ =========================
4
+ Frame-level speech/silence classification, used for:
5
+
6
+ - Endpointing : knowing when the caller has FINISHED speaking
7
+ (so we don't wait for a push-to-talk button)
8
+ - Preroll : keeping ~300ms of audio BEFORE speech onset so the
9
+ first phoneme is never clipped
10
+ - Barge-in : detecting that the caller started speaking while the
11
+ agent's TTS is still playing, so we can stop it
12
+ - Silence culling : never sending silence to Whisper (Whisper hallucinates
13
+ badly on silence β€” "Thank you." / "Subtitles by…")
14
+
15
+ Two backends:
16
+ 1. SileroVAD β€” torch.hub snakers4/silero-vad. Small (~1.8MB), fast on CPU,
17
+ robust to background noise. Requires EXACTLY 512 samples
18
+ per frame at 16kHz.
19
+ 2. EnergyVAD β€” adaptive-threshold RMS + zero-crossing rate. No download,
20
+ no torch.hub dependency. Degraded in noise but never fails.
21
+
22
+ Both expose the same interface:
23
+ vad.speech_prob(frame: np.ndarray) -> float in [0, 1]
24
+ vad.reset()
25
+ """
26
+
27
+ import logging
28
+ import numpy as np
29
+ from typing import Optional
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ SAMPLE_RATE = 16_000
34
+ FRAME_SAMPLES = 512 # Silero requirement at 16kHz
35
+ FRAME_MS = FRAME_SAMPLES / SAMPLE_RATE * 1000 # 32.0 ms
36
+
37
+
38
+ # ──────────────────────────────────────────────────────────────────────────────
39
+ # Silero
40
+ # ──────────────────────────────────────────────────────────────────────────────
41
+
42
+ class SileroVAD:
43
+
44
+ def __init__(self):
45
+ import torch
46
+ self.torch = torch
47
+ logger.info("Loading Silero VAD …")
48
+ self.model, _ = torch.hub.load(
49
+ repo_or_dir="snakers4/silero-vad",
50
+ model="silero_vad",
51
+ force_reload=False,
52
+ onnx=False,
53
+ trust_repo=True,
54
+ )
55
+ self.model.eval()
56
+ self.name = "silero"
57
+
58
+ def speech_prob(self, frame: np.ndarray) -> float:
59
+ """frame: float32 mono, exactly FRAME_SAMPLES long, range [-1, 1]."""
60
+ if len(frame) != FRAME_SAMPLES:
61
+ frame = _fit(frame, FRAME_SAMPLES)
62
+ with self.torch.no_grad():
63
+ t = self.torch.from_numpy(frame.astype(np.float32))
64
+ return float(self.model(t, SAMPLE_RATE).item())
65
+
66
+ def reset(self):
67
+ if hasattr(self.model, "reset_states"):
68
+ self.model.reset_states()
69
+
70
+
71
+ # ──────────────────────────────────────────────────────────────────────────────
72
+ # Energy fallback
73
+ # ──────────────────────────────────────────────────────────────────────────────
74
+
75
+ class EnergyVAD:
76
+ """
77
+ Adaptive-noise-floor RMS gate with a zero-crossing-rate sanity check.
78
+
79
+ The noise floor tracks the quietest recent frames, so it adapts to a
80
+ caller on a noisy street or a quiet office without reconfiguration.
81
+ """
82
+
83
+ SEED_FRAMES = 8 # frames used to establish the initial floor
84
+ ABS_GATE_LO = 0.015 # rms clearly above any plausible room noise
85
+ ABS_GATE_HI = 0.060 # rms unambiguously speech at normal mic gain
86
+
87
+ def __init__(self, sensitivity: float = 3.0):
88
+ self.sensitivity = sensitivity # how many x above floor = speech
89
+ self.reset()
90
+ self.name = "energy"
91
+
92
+ def reset(self):
93
+ self._floor = None
94
+ self._floor_init = False
95
+ self._frames = 0
96
+
97
+ def speech_prob(self, frame: np.ndarray) -> float:
98
+ frame = frame.astype(np.float32)
99
+ rms = float(np.sqrt(np.mean(frame ** 2)) + 1e-9)
100
+
101
+ # Zero-crossing rate β€” speech sits in a middling band; pure hiss is high,
102
+ # DC/rumble is very low.
103
+ zcr = float(np.mean(np.abs(np.diff(np.sign(frame)))) / 2.0)
104
+
105
+ self._frames += 1
106
+
107
+ # ── Seeding ──────────────────────────────────────────────────────────
108
+ # Seed from the MINIMUM, never a running average. If the caller starts
109
+ # talking part-way through the seed window (or is already talking when
110
+ # the stream opens), an averaged seed is dragged up to speech level and
111
+ # the detector goes deaf for the rest of the session.
112
+ if not self._floor_init:
113
+ self._floor = rms if self._floor is None else min(self._floor, rms)
114
+ if self._frames >= self.SEED_FRAMES:
115
+ self._floor_init = True
116
+ # Still answer using the absolute gate, so speech during the seed
117
+ # window is not silently swallowed.
118
+ return self._absolute_prob(rms) * self._zcr_penalty(zcr)
119
+
120
+ # ── Asymmetric adaptation ────────────────────────────────────────────
121
+ # Down fast (room got quiet β†’ recover in ~7 frames).
122
+ # Up very slowly (~60s time constant), so a long unbroken utterance
123
+ # cannot drag the floor up to its own level and mute itself.
124
+ self._floor = max(
125
+ 0.85 * self._floor + 0.15 * rms if rms < self._floor
126
+ else 0.9995 * self._floor + 0.0005 * rms,
127
+ 1e-6,
128
+ )
129
+
130
+ ratio = rms / (self._floor * self.sensitivity)
131
+ rel_prob = float(np.clip((ratio - 0.6) / 1.4, 0.0, 1.0))
132
+
133
+ # Absolute gate is a floor on confidence, not a cap: if the signal is
134
+ # loud in absolute terms it is speech regardless of what the adaptive
135
+ # estimate believes.
136
+ prob = max(rel_prob, self._absolute_prob(rms))
137
+ return prob * self._zcr_penalty(zcr)
138
+
139
+ # ── Helpers ───────────────────────────────────────────────────────────────
140
+
141
+ def _absolute_prob(self, rms: float) -> float:
142
+ return float(np.clip(
143
+ (rms - self.ABS_GATE_LO) / (self.ABS_GATE_HI - self.ABS_GATE_LO),
144
+ 0.0, 1.0))
145
+
146
+ @staticmethod
147
+ def _zcr_penalty(zcr: float) -> float:
148
+ """Penalise implausible zero-crossing rates (hiss, rumble, DC)."""
149
+ return 0.4 if (zcr > 0.35 or zcr < 0.005) else 1.0
150
+
151
+
152
+ # ──────────────────────────────────────────────────────────────────────────────
153
+
154
+ def _fit(frame: np.ndarray, n: int) -> np.ndarray:
155
+ """Pad or truncate a frame to exactly n samples."""
156
+ if len(frame) >= n:
157
+ return frame[:n]
158
+ return np.pad(frame, (0, n - len(frame)))
159
+
160
+
161
+ def load_vad(prefer: str = "auto"):
162
+ """
163
+ prefer: 'auto' | 'silero' | 'energy'
164
+ Never raises β€” falls back to EnergyVAD if Silero cannot be loaded
165
+ (no network in the Space, torch.hub blocked, etc).
166
+ """
167
+ if prefer == "energy":
168
+ return EnergyVAD()
169
+ try:
170
+ return SileroVAD()
171
+ except Exception as e:
172
+ logger.warning(f"Silero VAD unavailable ({e}); using energy VAD.")
173
+ return EnergyVAD()