Spaces:
Sleeping
Sleeping
File size: 9,035 Bytes
de7fd77 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | """
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)
|