VAD: Silero v5 (vad-web) statt RMS-Eigenbau — Turn-Ende 900 -> 450 ms (Review P1-7)

- useVAD.ts: MicVAD (neuronale Sprach- statt Pegel-Erkennung), redemptionMs 450,
  preSpeechPad 320 ms, WAV direkt aus Float32 (kein webm/Opus-Umweg mehr zur Box)
- boxStt.ts: Dateiname passend zum Blob-Typ (rec.wav | rec.webm)
- Assets self-hosted unter /vad/ via scripts/copy-vad-assets.mjs (npm postinstall), offlinefaehig
- Verifiziert: tsc + Build sauber, App startet, Assets werden ausgeliefert (Mikro-Test: User)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 10:49:36 +02:00
parent 403961da51
commit 45fb61000a
7 changed files with 230 additions and 90 deletions
@@ -1,8 +1,12 @@
import { useEffect, useRef } from "react"
import { MicVAD } from "@ricky0123/vad-web"
// Freisprech-VAD (Voice Activity Detection): lauscht dauerhaft am Mikro, erkennt Sprech-Beginn/-Ende
// per Energie und liefert komplette Äußerungen als Blob. RÜCKKOPPLUNGS-SCHUTZ: `paused` (Lucy denkt/spricht)
// stoppt die Erkennung -> Lucys eigene Stimme triggert das Mikro nicht; + echoCancellation + Abkling-Sperre.
// 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
// des RMS-Eigenbaus: erkennt Sprache statt Lautstärke (robust gegen Tastatur/Lüfter) und
// erlaubt dadurch ein kürzeres Turn-Ende (450 ms statt 900 ms Stille) ohne Abschneiden.
// RÜCKKOPPLUNGS-SCHUTZ bleibt: `paused` (Lucy denkt/spricht) verwirft laufende Erkennung,
// + echoCancellation + Abkling-Sperre nach Lucys Antwort.
interface VADOptions {
enabled: boolean // VAD-Modus an?
@@ -11,11 +15,28 @@ interface VADOptions {
onListening?: (active: boolean) => void // UI: gerade Sprache am Aufnehmen?
}
// Erkennung per ZEIT-DOMÄNEN-RMS mit ADAPTIVEM Rausch-Pegel (kalibriert sich aufs Mikro) — robuster
// als feste Frequenz-Schwellen. Schwellen werden relativ zum gemessenen Rauschen berechnet.
const START_FRAMES = 3 // so viele laute Frames in Folge -> Start (gegen Klick-Fehlstarts)
const SILENCE_MS = 900 // so lange Stille -> Äußerung zu Ende
const COOLDOWN_MS = 450 // nach Pause-Ende kurz taub (Echo/Lautsprecher abklingen lassen)
const REDEMPTION_MS = 450 // so lange Stille -> Äußerung zu Ende (war 900 ms beim RMS-VAD)
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)
// 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.
function toWavBlob(samples: Float32Array, sampleRate = 16000): Blob {
const buf = new ArrayBuffer(44 + samples.length * 2)
const v = new DataView(buf)
const writeStr = (off: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)) }
writeStr(0, "RIFF"); v.setUint32(4, 36 + samples.length * 2, true); writeStr(8, "WAVE")
writeStr(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true)
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true)
writeStr(36, "data"); v.setUint32(40, samples.length * 2, true)
let off = 44
for (let i = 0; i < samples.length; i++, off += 2) {
const s = Math.max(-1, Math.min(1, samples[i]))
v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
return new Blob([buf], { type: "audio/wav" })
}
export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions) {
const pausedRef = useRef(paused); pausedRef.current = paused
@@ -29,92 +50,47 @@ export function useVAD({ enabled, paused, onUtterance, onListening }: VADOptions
useEffect(() => {
if (!enabled) return
let cancelled = false
let stream: MediaStream | null = null
let ctx: AudioContext | null = null
let raf = 0
let rec: MediaRecorder | null = null
let chunks: Blob[] = []
let speaking = false
let silenceStart = 0
let voiceFrames = 0
const setListening = (v: boolean) => onLst.current?.(v)
let vad: Awaited<ReturnType<typeof MicVAD.new>> | null = null
;(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
vad = await MicVAD.new({
model: "v5",
// Assets self-hosted (electron.vite.config.ts kopiert sie nach /vad/) — offlinefähig.
baseAssetPath: "/vad/",
onnxWASMBasePath: "/vad/",
positiveSpeechThreshold: 0.5,
negativeSpeechThreshold: 0.35,
redemptionMs: REDEMPTION_MS,
preSpeechPadMs: PRE_SPEECH_PAD_MS,
minSpeechMs: MIN_SPEECH_MS,
getStream: () => navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}),
onSpeechStart: () => {
if (pausedRef.current || performance.now() < resumeAt.current) return
onLst.current?.(true)
},
onSpeechEnd: (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))
},
onVADMisfire: () => onLst.current?.(false),
})
if (cancelled) { vad.destroy(); return }
vad.start()
} catch (e) {
console.error("VAD: Mikrofon-Zugriff verweigert", e); return
console.error("VAD: Silero-Init fehlgeschlagen", e)
}
if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return }
const Ctor = window.AudioContext || (window as any).webkitAudioContext
ctx = new Ctor()
try { await ctx.resume() } catch { /* */ } // WICHTIG: sonst bleibt der Context 'suspended' -> nur Nullen
const srcNode = ctx.createMediaStreamSource(stream)
const analyser = ctx.createAnalyser()
analyser.fftSize = 1024
srcNode.connect(analyser)
const wave = new Uint8Array(analyser.fftSize)
let noiseFloor = 0.015 // adaptiver Rausch-Pegel (kalibriert sich auf das Mikro)
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
const startRec = () => {
chunks = []
rec = new MediaRecorder(stream!, { mimeType: mime })
rec.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data) }
rec.onstop = () => {
const blob = new Blob(chunks, { type: mime })
if (!cancelled && blob.size > 2400) onUtt.current(blob) // Mini-Blobs (Klicks) verwerfen
}
rec.start()
setListening(true)
}
const stopRec = (emit: boolean) => {
if (rec && rec.state !== "inactive") {
if (!emit) rec.onstop = null
rec.stop()
}
rec = null
setListening(false)
}
const tick = () => {
if (cancelled) return
analyser.getByteTimeDomainData(wave)
let sq = 0
for (let i = 0; i < wave.length; i++) { const v = (wave[i] - 128) / 128; sq += v * v }
const level = Math.sqrt(sq / wave.length) // RMS-Pegel 0..1
const now = performance.now()
// Rausch-Pegel langsam an leise Phasen angleichen (nur wenn nicht geredet wird)
if (!speaking) noiseFloor += (Math.min(level, noiseFloor * 1.6 + 0.004) - noiseFloor) * 0.05
const startTh = Math.max(0.02, noiseFloor * 2.8) // Sprache: deutlich über dem Rauschen
const stopTh = Math.max(0.012, noiseFloor * 1.7) // Stille: nahe am Rauschen
if (pausedRef.current || now < resumeAt.current) {
if (speaking) { speaking = false; voiceFrames = 0; silenceStart = 0; stopRec(false) } // verwerfen
} else if (!speaking) {
if (level > startTh) { voiceFrames++; if (voiceFrames >= START_FRAMES) { speaking = true; silenceStart = 0; startRec() } }
else voiceFrames = 0
} else {
if (level < stopTh) {
if (!silenceStart) silenceStart = now
else if (now - silenceStart > SILENCE_MS) { speaking = false; silenceStart = 0; stopRec(true) }
} else silenceStart = 0
}
raf = requestAnimationFrame(tick)
}
tick()
})()
return () => {
cancelled = true
cancelAnimationFrame(raf)
try { if (rec && rec.state !== "inactive") { rec.onstop = null; rec.stop() } } catch { /* */ }
stream?.getTracks().forEach((t) => t.stop())
try { ctx?.close() } catch { /* */ }
try { vad?.destroy() } catch { /* */ }
onLst.current?.(false)
}
}, [enabled])
@@ -1,14 +1,16 @@
// Box-STT-Adapter — Spracherkennung läuft auf der Box (MC2 /api/voice/stt -> faster-whisper).
// Box-STT-Adapter — Spracherkennung läuft auf der Box (MC2 /api/voice/stt -> Parakeet,
// whisper als Fallback). Der Dateiname muss zum Blob-Typ passen: der Sidecar wählt die
// Dekodierung nach Suffix (Silero-VAD liefert WAV, Push-to-Talk weiterhin webm/opus).
import { BOX_URL } from "../../config"
import type { SttEngine } from "../types"
export class BoxSttEngine implements SttEngine {
readonly id = "box-whisper"
readonly id = "box-stt"
constructor(private readonly baseUrl: string = BOX_URL) {}
async transcribe(audio: Blob): Promise<string> {
const fd = new FormData()
fd.append("audio", audio, "rec.webm")
fd.append("audio", audio, audio.type.includes("wav") ? "rec.wav" : "rec.webm")
const r = await fetch(`${this.baseUrl}/api/voice/stt`, { method: "POST", body: fd })
if (!r.ok) throw new Error(`STT ${r.status}`)
return (await r.json()).text?.trim() || ""