Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |