diff --git a/routes/conversation.py b/routes/conversation.py index 3f68bdf..7cb9e59 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -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 diff --git a/static/js/conversation.js b/static/js/conversation.js index ed28eec..97cdd11 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -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; }