Voice-AI-Agent-Clean / test_streaming.py
Toadoum's picture
Upload 7 files
de7fd77 verified
Raw
History Blame Contribute Delete
9.04 kB
"""
Streaming ASR / VAD endpointing tests.
Uses synthetic audio and a mock transcriber so it runs with no models and
no network. Verifies the state machine, not Whisper.
"""
import sys
import logging
import numpy as np
logging.basicConfig(level=logging.WARNING)
from vad import load_vad, FRAME_MS, SAMPLE_RATE
from streaming_asr import StreamingASR, EndpointConfig, State, _is_hallucination
PASS, FAIL = "\033[92mPASS\033[0m", "\033[91mFAIL\033[0m"
results = []
def check(name, cond, detail=""):
results.append((name, cond))
print(f" [{PASS if cond else FAIL}] {name}" + (f" β€” {detail}" if detail else ""))
# ── Synthetic audio ───────────────────────────────────────────────────────────
def silence(ms, noise=0.0005):
n = int(SAMPLE_RATE * ms / 1000)
return (np.random.randn(n) * noise).astype(np.float32)
def speech(ms, amp=0.25):
"""Voice-like: 120Hz glottal pulse + formants + amplitude modulation."""
n = int(SAMPLE_RATE * ms / 1000)
t = np.arange(n) / SAMPLE_RATE
sig = (np.sin(2 * np.pi * 120 * t)
+ 0.5 * np.sin(2 * np.pi * 700 * t)
+ 0.3 * np.sin(2 * np.pi * 1220 * t))
envelope = 0.6 + 0.4 * np.sin(2 * np.pi * 4 * t) # syllable rate
sig = sig * envelope
sig += np.random.randn(n) * 0.01
return (sig / np.abs(sig).max() * amp).astype(np.float32)
def feed(sasr, audio, chunk_ms=100):
"""Feed audio in realistic small chunks, collecting all events."""
events = []
step = int(SAMPLE_RATE * chunk_ms / 1000)
for i in range(0, len(audio), step):
events.extend(sasr.accept_audio(audio[i:i + step], SAMPLE_RATE))
return events
# Mock transcriber records exactly what audio it was handed
DECODE_LOG = []
def mock_transcribe(audio, sr):
DECODE_LOG.append(len(audio) / sr * 1000) # duration in ms
return f"transcript_of_{len(audio)/sr:.2f}s"
def new_asr(**kw):
DECODE_LOG.clear()
cfg = EndpointConfig(**kw) if kw else EndpointConfig()
return StreamingASR(transcribe_fn=mock_transcribe, config=cfg,
vad_backend="energy")
# ══════════════════════════════════════════════════════════════════════════════
print("\n══ VAD discriminates speech from silence ══")
vad = load_vad("energy")
for _ in range(12): # let the floor settle
vad.speech_prob(silence(FRAME_MS)[:512])
sil_probs = [vad.speech_prob(silence(FRAME_MS)[:512]) for _ in range(20)]
sp = speech(1000)
sp_probs = [vad.speech_prob(sp[i:i+512]) for i in range(0, 512*20, 512)]
check("silence scores low", np.mean(sil_probs) < 0.3,
f"mean={np.mean(sil_probs):.2f}")
check("speech scores high", np.mean(sp_probs) > 0.6,
f"mean={np.mean(sp_probs):.2f}")
print("\n══ Basic endpointing: speech β†’ silence β†’ ONE final ══")
sasr = new_asr()
audio = np.concatenate([silence(500), speech(1200), silence(1200)])
events = feed(sasr, audio)
finals = [e for e in events if e.kind == "final"]
check("exactly one final emitted", len(finals) == 1, f"got {len(finals)}")
check("speech_start fired", any(e.kind == "speech_start" for e in events))
check("returns to IDLE after endpoint", sasr.state is State.IDLE)
print("\n══ Preroll: first phoneme is not clipped ══")
sasr = new_asr(preroll_ms=300)
audio = np.concatenate([silence(400), speech(1000), silence(1200)])
feed(sasr, audio)
check("decoded audio longer than speech alone (preroll included)",
DECODE_LOG and DECODE_LOG[-1] > 1000,
f"decoded {DECODE_LOG[-1]:.0f}ms for 1000ms of speech")
print("\n══ Hangover: a natural pause does NOT end the turn ══")
sasr = new_asr(endpoint_silence_ms=700)
# 400ms pause is shorter than the 700ms endpoint β†’ must stay ONE utterance
audio = np.concatenate([silence(400), speech(700), silence(400),
speech(700), silence(1200)])
events = feed(sasr, audio)
finals = [e for e in events if e.kind == "final"]
check("short internal pause does not split the turn", len(finals) == 1,
f"got {len(finals)} finals")
print("\n══ Long pause DOES split into two turns ══")
sasr = new_asr(endpoint_silence_ms=700)
audio = np.concatenate([silence(400), speech(700), silence(1300),
speech(700), silence(1300)])
events = feed(sasr, audio)
finals = [e for e in events if e.kind == "final"]
check("long pause produces two separate turns", len(finals) == 2,
f"got {len(finals)} finals")
print("\n══ Silence is never sent to Whisper (hallucination guard) ══")
sasr = new_asr()
feed(sasr, silence(4000))
check("no decode call on pure silence", len(DECODE_LOG) == 0,
f"{len(DECODE_LOG)} decode calls")
check("no final event on pure silence",
not any(e.kind == "final" for e in feed(sasr, silence(2000))))
print("\n══ Cough / click is discarded, not transcribed ══")
sasr = new_asr(min_speech_ms=250)
audio = np.concatenate([silence(400), speech(90), silence(1500)])
events = feed(sasr, audio)
check("sub-threshold blip produces no final",
not any(e.kind == "final" for e in events))
check("no decode call for the blip", len(DECODE_LOG) == 0,
f"{len(DECODE_LOG)} calls")
print("\n══ Live partials during a long utterance ══")
sasr = new_asr(partial_interval_ms=600)
audio = np.concatenate([silence(400), speech(3500), silence(1200)])
events = feed(sasr, audio)
partials = [e for e in events if e.kind == "partial"]
check("partials emitted while speaking", len(partials) >= 2,
f"got {len(partials)} partials")
check("final still emitted after partials",
any(e.kind == "final" for e in events))
check("partials precede the final",
events.index(next(e for e in events if e.kind == "final"))
> events.index(partials[0]) if partials else False)
print("\n══ Barge-in: caller interrupts the agent ══")
sasr = new_asr(bargein_speech_ms=220)
sasr.agent_speaking = True
events = feed(sasr, np.concatenate([silence(300), speech(900)]))
check("barge-in event raised", any(e.kind == "bargein" for e in events))
check("agent_speaking cleared", sasr.agent_speaking is False)
print("\n══ Barge-in does NOT fire on background noise ══")
sasr = new_asr(bargein_speech_ms=220)
sasr.agent_speaking = True
events = feed(sasr, silence(2000, noise=0.002))
check("no barge-in on quiet background",
not any(e.kind == "bargein" for e in events))
print("\n══ Max-duration force endpoint ══")
sasr = new_asr(max_utterance_ms=2000)
events = feed(sasr, np.concatenate([silence(300), speech(6000)]))
check("non-stop speaker is force-endpointed",
any(e.kind == "final" for e in events))
print("\n══ flush() finalizes a turn in progress (caller hung up) ══")
sasr = new_asr()
feed(sasr, np.concatenate([silence(400), speech(1000)]))
events = sasr.flush()
check("flush emits the pending final",
any(e.kind == "final" for e in events))
print("\n══ Chunk-size independence (frame alignment) ══")
for chunk_ms in (20, 33, 100, 250):
sasr = new_asr()
audio = np.concatenate([silence(400), speech(1200), silence(1200)])
events = feed(sasr, audio, chunk_ms=chunk_ms)
n = len([e for e in events if e.kind == "final"])
check(f"chunk={chunk_ms}ms β†’ 1 final", n == 1, f"got {n}")
print("\n══ Whisper hallucination filter ══")
check("'Thank you.' filtered", _is_hallucination("Thank you."))
check("'Subtitles by the Amara.org community' filtered",
_is_hallucination("Subtitles by the Amara.org community"))
check("'β™ͺ' filtered", _is_hallucination("β™ͺ"))
check("real Hausa text NOT filtered",
not _is_hallucination("Ina son duba asusuna"))
print("\n══ int16 input auto-converted ══")
sasr = new_asr()
audio_f = np.concatenate([silence(400), speech(1200), silence(1200)])
audio_i16 = (audio_f * 32767).astype(np.int16)
events = feed(sasr, audio_i16)
check("int16 stream produces a final",
any(e.kind == "final" for e in events))
print("\n══ Resampling from 8kHz (telephony) ══")
sasr = new_asr()
audio = np.concatenate([silence(400), speech(1200), silence(1200)])
audio_8k = audio[::2] # crude 8kHz
events = []
step = 800
for i in range(0, len(audio_8k), step):
events.extend(sasr.accept_audio(audio_8k[i:i + step], 8000))
check("8kHz telephony audio endpoints correctly",
any(e.kind == "final" for e in events))
# ── Summary ───────────────────────────────────────────────────────────────────
passed = sum(1 for _, ok in results if ok)
total = len(results)
print(f"\n{'='*62}\n {passed}/{total} checks passed\n{'='*62}")
sys.exit(0 if passed == total else 1)