Upload 7 files
Browse files- app.py +144 -20
- pipeline.py +197 -0
- requirements.txt +37 -10
- sip.py +186 -0
- streaming_asr.py +349 -0
- test_streaming.py +217 -0
- 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
|
| 291 |
-
<div style="font-size:10px; color:#78716C;">
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
english_text = ai_pipeline.hausa_to_english(hausa_text)
|
|
|
|
| 409 |
|
| 410 |
-
|
| 411 |
english_response, conv_state, escalate = dm.respond(
|
| 412 |
english_text, hausa_text, state["conv"]
|
| 413 |
)
|
| 414 |
state["conv"] = conv_state
|
|
|
|
| 415 |
|
| 416 |
-
|
| 417 |
hausa_response = ai_pipeline.english_to_hausa(english_response)
|
|
|
|
| 418 |
|
| 419 |
-
|
| 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 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
| 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),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 503 |
sources=["microphone"],
|
| 504 |
type="numpy",
|
| 505 |
-
label="
|
| 506 |
-
streaming=
|
| 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,
|
|
|
|
| 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
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|