Turn-Detection: Smart Turn v3 semantisch (Review P2-11) — live verifiziert

- voice_service /turn: smart-turn-v3.2 (8MB ONNX, ~110ms warm inkl. Features); Audio muss
  LINKS gepadded werden (rechts-Padding -> konstant 'complete', live diagnostiziert) und
  der Output ist empirisch P(unfertig) — Doku sagt es andersherum, Messung gewinnt
- backend /api/voice/turn: Proxy mit fail-open (Turn-Check ist Optimierung, kein Blocker)
- useVAD: Semantik-Hold — bei 'incomplete' bis 1,8s auf Fortsetzung warten und anhaengen,
  statt mitten im Gedanken zu antworten; Deckel 30s; fail-open bei Netzfehlern
- Verifiziert: fertig=true(0.74), mitten-im-Wort=false(0.04), via :9001 ok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 11:21:45 +02:00
parent a1cea1a1ea
commit 6f7498a956
4 changed files with 153 additions and 4 deletions
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react"
import { MicVAD } from "@ricky0123/vad-web"
import { BOX_URL } from "../../config"
// Freisprech-VAD: lauscht dauerhaft am Mikro und liefert komplette Äußerungen als Blob.
// Seit Review P1-7 (2026-07-02) Silero VAD v5 (neuronal, via vad-web/onnxruntime-wasm) statt
@@ -19,6 +20,11 @@ const REDEMPTION_MS = 450 // so lange Stille -> Äußerung zu Ende (war 900
const PRE_SPEECH_PAD_MS = 320 // Vorlauf mitschneiden (erster Wortanfang nicht abschneiden)
const MIN_SPEECH_MS = 160 // kürzer = Klick/Räuspern -> verwerfen
const COOLDOWN_MS = 450 // nach Lucys Antwort kurz taub (Echo/Lautsprecher abklingen lassen)
// Semantische Turn-Detection (Smart Turn v3 auf der Box): meldet sie 'incomplete' (User denkt
// mitten im Satz nach), warten wir bis zu HOLD_MS auf die Fortsetzung und hängen sie an,
// statt mitten im Gedanken zu antworten. Hart begrenzt, damit Lucy nie ewig schweigt.
const HOLD_MS = 1800
const MAX_UTTERANCE_S = 30 // Sicherheitsdeckel fürs Zusammenhängen
// Float32-Samples (16 kHz mono) -> WAV-Blob (PCM16). Ersetzt den MediaRecorder-webm-Umweg:
// die Box muss kein Opus mehr dekodieren, Parakeet/Whisper bekommen direkt sauberes WAV.
@@ -38,6 +44,25 @@ function toWavBlob(samples: Float32Array, sampleRate = 16000): Blob {
return new Blob([buf], { type: "audio/wav" })
}
// Fragt die Box, ob die Äußerung semantisch fertig ist. Fehler => true (nie blockieren).
async function isTurnComplete(audio: Float32Array): Promise<boolean> {
try {
const fd = new FormData()
fd.append("audio", toWavBlob(audio.subarray(Math.max(0, audio.length - 8 * 16000))), "rec.wav")
const r = await fetch(`${BOX_URL}/api/voice/turn`, { method: "POST", body: fd })
if (!r.ok) return true
return (await r.json()).complete !== false
} catch {
return true
}
}
function concatAudio(a: Float32Array, b: Float32Array): Float32Array {
const out = new Float32Array(a.length + b.length)
out.set(a); out.set(b, a.length)
return out
}
export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) {
const pausedRef = useRef(paused); pausedRef.current = paused
const onUtt = useRef(onUtterance); onUtt.current = onUtterance
@@ -51,6 +76,15 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
if (!enabled) return
let cancelled = false
let vad: Awaited<ReturnType<typeof MicVAD.new>> | null = null
// Semantik-Hold: bei 'incomplete' gepufferte Äußerung, auf die die Fortsetzung wartet.
let pending: Float32Array | null = null
let holdTimer: ReturnType<typeof setTimeout> | null = null
const emit = (audio: Float32Array) => {
pending = null
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
onUtt.current(toWavBlob(audio))
}
;(async () => {
try {
@@ -69,15 +103,28 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
}),
onSpeechStart: () => {
if (pausedRef.current || performance.now() < resumeAt.current) return
// User spricht weiter, während eine 'incomplete'-Äußerung gehalten wird ->
// Timer stoppen; die Fortsetzung wird in onSpeechEnd angehängt.
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
onLst.current?.(true)
},
onSpeechEnd: (audio: Float32Array) => {
onSpeechEnd: async (audio: Float32Array) => {
onLst.current?.(false)
// Während Lucy denkt/spricht (oder direkt danach) erkannte Sprache = ihr eigenes
// Echo bzw. Nachhall -> verwerfen statt transkribieren.
if (cancelled || pausedRef.current || performance.now() < resumeAt.current) return
if (audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz
onUtt.current(toWavBlob(audio))
if (cancelled || pausedRef.current || performance.now() < resumeAt.current) { pending = null; return }
if (!pending && audio.length < MIN_SPEECH_MS * 16) return // 16 Samples/ms @16 kHz
let combined = pending ? concatAudio(pending, audio) : audio
if (combined.length > MAX_UTTERANCE_S * 16000) {
combined = combined.subarray(combined.length - MAX_UTTERANCE_S * 16000)
}
const complete = await isTurnComplete(combined)
if (cancelled) return
if (complete) { emit(combined); return }
// Mitten im Gedanken pausiert: kurz auf die Fortsetzung warten, dann notfalls doch senden.
pending = combined
if (holdTimer) clearTimeout(holdTimer)
holdTimer = setTimeout(() => { if (!cancelled && pending) emit(pending) }, HOLD_MS)
},
onVADMisfire: () => onLst.current?.(false),
})
@@ -90,6 +137,7 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
return () => {
cancelled = true
if (holdTimer) clearTimeout(holdTimer)
try { vad?.destroy() } catch { /* */ }
onLst.current?.(false)
}