Fix speech gate, hallucination filter, and latency

Speech gate (silence detection):
- vadHadSpeech flag: VAD auto-stop cancels without calling STT when no
  speech was detected (fixes "[STT] No speech detected → gibberish" loop)
- cancelNextBlob flag: onstop skips processBlob when VAD cancels silently
- vadLastVoiceMs: gates preview transcription on actual detected speech
  (prevents "reich" hallucination on initial silence chunks)

Hallucination filter:
- Client: HALLUCINATION_RE strips known Whisper phantoms from preview
- Server: _is_hallucination() in generate() treats "reich" / "danke" /
  "thank you" etc. as "No speech detected" → never reaches LLM

Latency:
- VAD_SILENCE_MS: 1500 → 1000 ms (sends 500 ms sooner per turn)
- VAD_MIN_REC_MS: 500 → 400 ms
- MediaRecorder timeslice: 2500 → 1500 ms (preview text updates faster)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-29 20:38:53 +02:00
parent c122089ef8
commit 13c79768b4
2 changed files with 53 additions and 9 deletions

View File

@ -33,6 +33,19 @@ from routes.stt import _transcribe_audio, _clean_stt_backend
router = APIRouter()
# ── STT hallucination filter ──────────────────────────────────────────────────
# Whisper commonly hallucinates these phrases on silence/noise.
# Treat them as "no speech detected" rather than passing them to the LLM.
_HALLUCINATIONS: frozenset[str] = frozenset([
"reich", "danke", "danke schön", "danke schoen", "vielen dank",
"thank you", "thank you.", "thanks", "thanks.", "you", "you.",
"copyright", "abonnieren", "untertitel", "subscribe", "subscribing",
])
def _is_hallucination(text: str) -> bool:
t = text.strip().lower().rstrip(".!?,;:-").strip()
return len(t) <= 2 or t in _HALLUCINATIONS
# ── Sentence-boundary helpers for pipelined TTS ───────────────────────────────
_SENT_RE = re.compile(r'(?<=[.!?])\s+')
@ -749,7 +762,7 @@ async def conversation_turn(
with contextlib.suppress(Exception):
p.unlink(missing_ok=True)
if not transcript.strip():
if not transcript.strip() or _is_hallucination(transcript):
yield sse({"type": "error", "stage": "stt", "message": "No speech detected."})
return

View File

@ -178,8 +178,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
let liveInterimText = '';
let speechRec = null;
const VAD_THRESHOLD = 0.01;
const VAD_MIN_REC_MS = 500;
const VAD_SILENCE_MS = 1500;
const VAD_MIN_REC_MS = 400;
const VAD_SILENCE_MS = 1000;
// Whisper hallucinations on silence/noise — discard these from the live preview
const HALLUCINATION_RE = /^(reich|danke\s*(schön)?|vielen\s*dank|thank\s*you|thanks|you|copyright|abonnieren|untertitel|zарегистрируйтесь)[.!?,\s]*$/i;
const origPlaceholder = textInput?.placeholder || '';
const vadToggle = $('conv-vad-toggle');
const handsFreeToggle = $('conv-handsfree-toggle');
@ -189,6 +191,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
const audioQueue = [];
let audioQueuePlaying = false;
let audioQueueDrainCb = null;
let vadHadSpeech = false; // true once RMS crossed threshold during this recording
let vadLastVoiceMs = 0; // last timestamp speech was detected (for preview gate)
let cancelNextBlob = false; // set by VAD when no speech was detected → skip STT
// ── Populate STT backends ────────────────────────────────────────────────
async function loadConvSttBackends() {
@ -421,6 +426,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
async function transcribeForPreview() {
if (previewTranscribing || !recChunks.length) return;
// Only transcribe if speech was actually detected in this recording
if (!vadHadSpeech || Date.now() - vadLastVoiceMs > 5000) return;
previewTranscribing = true;
try {
const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
@ -433,7 +440,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (r.ok) {
const d = await r.json();
const txt = (d.text || '').trim();
if (txt && mediaRecorder && mediaRecorder.state === 'recording') {
if (txt && txt.length > 3 && !HALLUCINATION_RE.test(txt)
&& mediaRecorder && mediaRecorder.state === 'recording') {
liveInterimText = txt;
if (textInput) textInput.value = txt;
}
@ -477,22 +485,36 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
liveInterimText = '';
previewTranscribing = false;
vadHadSpeech = false;
vadLastVoiceMs = 0;
cancelNextBlob = false;
recChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => {
if (e.data.size > 0) {
recChunks.push(e.data);
// Trigger Whisper preview on periodic chunks (not on the final stop chunk)
if (mediaRecorder.state === 'recording') transcribeForPreview();
// Only run preview when speech was detected (prevents "reich" on silence chunks)
if (mediaRecorder.state === 'recording' && vadHadSpeech) transcribeForPreview();
}
};
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
if (cancelNextBlob) {
// VAD fired but no speech was detected — reset without calling STT
cancelNextBlob = false;
if (textInput) { textInput.readOnly = false; textInput.classList.remove('listening'); textInput.placeholder = origPlaceholder; textInput.value = ''; }
if (sendBtn) sendBtn.disabled = false;
micBtn.classList.remove('processing');
micIcon.className = 'mdi mdi-microphone';
if (micStatus) micStatus.textContent = 'Ready';
isProcessing = false;
return;
}
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
processBlob(blob);
};
// timeslice=2500: ondataavailable fires every 2.5 s → interim Whisper preview
mediaRecorder.start(2500);
// timeslice=1500: ondataavailable fires every 1.5 s → faster interim Whisper preview
mediaRecorder.start(1500);
micBtn.classList.add('recording');
micIcon.className = 'mdi mdi-stop';
if (micStatus) micStatus.textContent = 'Recording…';
@ -549,7 +571,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
for (const s of vadBuf) rms += s * s;
rms = Math.sqrt(rms / vadBuf.length);
if (levelFill) levelFill.style.width = Math.min(100, rms * 5000) + '%';
if (rms > VAD_THRESHOLD) vadLastVoice = Date.now();
if (rms > VAD_THRESHOLD) {
vadLastVoice = Date.now();
vadLastVoiceMs = Date.now();
vadHadSpeech = true;
}
const elapsed = Date.now() - recStart;
const silence = Date.now() - vadLastVoice;
if (elapsed > VAD_MIN_REC_MS) {
@ -560,6 +586,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
: 'Recording…';
}
if (silence >= VAD_SILENCE_MS) {
if (!vadHadSpeech) {
// Silence the whole time — cancel without calling STT
cancelNextBlob = true;
if (micStatus) micStatus.textContent = 'Ready';
}
stopRecording();
return;
}