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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user