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:
parent
c122089ef8
commit
13c79768b4
@ -33,6 +33,19 @@ from routes.stt import _transcribe_audio, _clean_stt_backend
|
|||||||
|
|
||||||
router = APIRouter()
|
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 ───────────────────────────────
|
# ── Sentence-boundary helpers for pipelined TTS ───────────────────────────────
|
||||||
|
|
||||||
_SENT_RE = re.compile(r'(?<=[.!?])\s+')
|
_SENT_RE = re.compile(r'(?<=[.!?])\s+')
|
||||||
@ -749,7 +762,7 @@ async def conversation_turn(
|
|||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
p.unlink(missing_ok=True)
|
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."})
|
yield sse({"type": "error", "stage": "stt", "message": "No speech detected."})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@ -178,8 +178,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
let liveInterimText = '';
|
let liveInterimText = '';
|
||||||
let speechRec = null;
|
let speechRec = null;
|
||||||
const VAD_THRESHOLD = 0.01;
|
const VAD_THRESHOLD = 0.01;
|
||||||
const VAD_MIN_REC_MS = 500;
|
const VAD_MIN_REC_MS = 400;
|
||||||
const VAD_SILENCE_MS = 1500;
|
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 origPlaceholder = textInput?.placeholder || '';
|
||||||
const vadToggle = $('conv-vad-toggle');
|
const vadToggle = $('conv-vad-toggle');
|
||||||
const handsFreeToggle = $('conv-handsfree-toggle');
|
const handsFreeToggle = $('conv-handsfree-toggle');
|
||||||
@ -189,6 +191,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
const audioQueue = [];
|
const audioQueue = [];
|
||||||
let audioQueuePlaying = false;
|
let audioQueuePlaying = false;
|
||||||
let audioQueueDrainCb = null;
|
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 ────────────────────────────────────────────────
|
// ── Populate STT backends ────────────────────────────────────────────────
|
||||||
async function loadConvSttBackends() {
|
async function loadConvSttBackends() {
|
||||||
@ -421,6 +426,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
|
// ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ──
|
||||||
async function transcribeForPreview() {
|
async function transcribeForPreview() {
|
||||||
if (previewTranscribing || !recChunks.length) return;
|
if (previewTranscribing || !recChunks.length) return;
|
||||||
|
// Only transcribe if speech was actually detected in this recording
|
||||||
|
if (!vadHadSpeech || Date.now() - vadLastVoiceMs > 5000) return;
|
||||||
previewTranscribing = true;
|
previewTranscribing = true;
|
||||||
try {
|
try {
|
||||||
const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
|
const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
|
||||||
@ -433,7 +440,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
const txt = (d.text || '').trim();
|
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;
|
liveInterimText = txt;
|
||||||
if (textInput) textInput.value = txt;
|
if (textInput) textInput.value = txt;
|
||||||
}
|
}
|
||||||
@ -477,22 +485,36 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
|
|
||||||
liveInterimText = '';
|
liveInterimText = '';
|
||||||
previewTranscribing = false;
|
previewTranscribing = false;
|
||||||
|
vadHadSpeech = false;
|
||||||
|
vadLastVoiceMs = 0;
|
||||||
|
cancelNextBlob = false;
|
||||||
recChunks = [];
|
recChunks = [];
|
||||||
mediaRecorder = new MediaRecorder(stream);
|
mediaRecorder = new MediaRecorder(stream);
|
||||||
mediaRecorder.ondataavailable = e => {
|
mediaRecorder.ondataavailable = e => {
|
||||||
if (e.data.size > 0) {
|
if (e.data.size > 0) {
|
||||||
recChunks.push(e.data);
|
recChunks.push(e.data);
|
||||||
// Trigger Whisper preview on periodic chunks (not on the final stop chunk)
|
// Only run preview when speech was detected (prevents "reich" on silence chunks)
|
||||||
if (mediaRecorder.state === 'recording') transcribeForPreview();
|
if (mediaRecorder.state === 'recording' && vadHadSpeech) transcribeForPreview();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mediaRecorder.onstop = () => {
|
mediaRecorder.onstop = () => {
|
||||||
stream.getTracks().forEach(t => t.stop());
|
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' });
|
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
|
||||||
processBlob(blob);
|
processBlob(blob);
|
||||||
};
|
};
|
||||||
// timeslice=2500: ondataavailable fires every 2.5 s → interim Whisper preview
|
// timeslice=1500: ondataavailable fires every 1.5 s → faster interim Whisper preview
|
||||||
mediaRecorder.start(2500);
|
mediaRecorder.start(1500);
|
||||||
micBtn.classList.add('recording');
|
micBtn.classList.add('recording');
|
||||||
micIcon.className = 'mdi mdi-stop';
|
micIcon.className = 'mdi mdi-stop';
|
||||||
if (micStatus) micStatus.textContent = 'Recording…';
|
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;
|
for (const s of vadBuf) rms += s * s;
|
||||||
rms = Math.sqrt(rms / vadBuf.length);
|
rms = Math.sqrt(rms / vadBuf.length);
|
||||||
if (levelFill) levelFill.style.width = Math.min(100, rms * 5000) + '%';
|
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 elapsed = Date.now() - recStart;
|
||||||
const silence = Date.now() - vadLastVoice;
|
const silence = Date.now() - vadLastVoice;
|
||||||
if (elapsed > VAD_MIN_REC_MS) {
|
if (elapsed > VAD_MIN_REC_MS) {
|
||||||
@ -560,6 +586,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
|||||||
: 'Recording…';
|
: 'Recording…';
|
||||||
}
|
}
|
||||||
if (silence >= VAD_SILENCE_MS) {
|
if (silence >= VAD_SILENCE_MS) {
|
||||||
|
if (!vadHadSpeech) {
|
||||||
|
// Silence the whole time — cancel without calling STT
|
||||||
|
cancelNextBlob = true;
|
||||||
|
if (micStatus) micStatus.textContent = 'Ready';
|
||||||
|
}
|
||||||
stopRecording();
|
stopRecording();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user