6cf880506d
Add 'wasm-unsafe-eval' to script-src and blob: to worker-src in Content-Security-Policy header. Required by onnxruntime-web to compile the Silero VAD ONNX model. Also surface VAD init errors as a toast instead of silent console log. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
import { ref, readonly } from 'vue'
|
|
import type { MicVAD } from '@ricky0123/vad-web'
|
|
|
|
export interface UseVadOptions {
|
|
onSpeechEnd: () => void
|
|
onNoSpeech: () => void
|
|
onVadError?: (message: string) => void
|
|
graceMs?: number
|
|
minRecordingMs?: number
|
|
}
|
|
|
|
/**
|
|
* Silero VAD-based speech detector.
|
|
*
|
|
* Replaces amplitude-based silence detection. Uses the Silero VAD v5 ONNX
|
|
* model via `@ricky0123/vad-web`. The package ships the model + audio
|
|
* worklet, and depends on `onnxruntime-web`'s WASM. Vite is configured
|
|
* (see `vite.config.ts`) to serve these assets from the site root, which
|
|
* is why we pass `baseAssetPath: "/"` and `onnxWASMBasePath: "/"`.
|
|
*
|
|
* `amplitude` is computed separately from a Web Audio API analyser node
|
|
* on the same stream — it drives the mic pulse animation and is not part
|
|
* of the stop decision. VAD's `onSpeechEnd` is the stop trigger.
|
|
*
|
|
* If VAD never detects speech during a session, calling `stopAndCheck()`
|
|
* fires `onNoSpeech` instead of treating the recording as transcribable.
|
|
*/
|
|
export function useVad(options: UseVadOptions) {
|
|
const {
|
|
onSpeechEnd,
|
|
onNoSpeech,
|
|
graceMs = 1500,
|
|
minRecordingMs = 500,
|
|
} = options
|
|
|
|
const speaking = ref(false)
|
|
const amplitude = ref(0)
|
|
const loaded = ref(false)
|
|
|
|
let micVad: MicVAD | null = null
|
|
let audioCtx: AudioContext | null = null
|
|
let analyserInterval: ReturnType<typeof setInterval> | null = null
|
|
let speechDetected = false
|
|
let speechStartedAt = 0
|
|
let recordingStartedAt = 0
|
|
let stopped = false
|
|
|
|
async function start(stream: MediaStream): Promise<void> {
|
|
await stop()
|
|
stopped = false
|
|
speechDetected = false
|
|
speechStartedAt = 0
|
|
recordingStartedAt = Date.now()
|
|
|
|
// Visual-only amplitude signal. This does NOT drive the stop decision.
|
|
audioCtx = new AudioContext()
|
|
await audioCtx.resume().catch(() => {})
|
|
const source = audioCtx.createMediaStreamSource(stream)
|
|
const analyser = audioCtx.createAnalyser()
|
|
analyser.fftSize = 1024
|
|
source.connect(analyser)
|
|
const samples = new Float32Array(analyser.fftSize)
|
|
|
|
analyserInterval = setInterval(() => {
|
|
analyser.getFloatTimeDomainData(samples)
|
|
let sumSq = 0
|
|
for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]
|
|
const rms = Math.sqrt(sumSq / samples.length)
|
|
amplitude.value = Math.min(rms * 5, 1)
|
|
}, 100)
|
|
|
|
try {
|
|
const { MicVAD } = await import('@ricky0123/vad-web')
|
|
micVad = await MicVAD.new({
|
|
model: 'v5',
|
|
baseAssetPath: '/',
|
|
onnxWASMBasePath: '/',
|
|
// MicVAD manages its own audio graph, so we hand it the same stream
|
|
// the MediaRecorder is using. It won't consume or alter the stream.
|
|
getStream: async () => stream,
|
|
onSpeechStart: () => {
|
|
speaking.value = true
|
|
if (!speechDetected) {
|
|
speechDetected = true
|
|
speechStartedAt = Date.now()
|
|
}
|
|
},
|
|
onSpeechEnd: () => {
|
|
speaking.value = false
|
|
if (stopped) return
|
|
const now = Date.now()
|
|
const totalElapsed = now - recordingStartedAt
|
|
const sinceSpeechStart = speechStartedAt > 0 ? now - speechStartedAt : 0
|
|
if (totalElapsed >= minRecordingMs && sinceSpeechStart >= graceMs) {
|
|
stopped = true
|
|
onSpeechEnd()
|
|
}
|
|
},
|
|
onVADMisfire: () => {
|
|
// Speech-like blip that was too short to count. Reset the local flag
|
|
// so a true "no speech" session still hits onNoSpeech.
|
|
speaking.value = false
|
|
},
|
|
})
|
|
await micVad.start()
|
|
loaded.value = true
|
|
} catch (e) {
|
|
console.error('VAD init failed:', e)
|
|
options.onVadError?.(e instanceof Error ? e.message : String(e))
|
|
}
|
|
}
|
|
|
|
async function stop(): Promise<{ hadSpeech: boolean }> {
|
|
const had = speechDetected
|
|
stopped = true
|
|
speaking.value = false
|
|
amplitude.value = 0
|
|
|
|
if (analyserInterval !== null) {
|
|
clearInterval(analyserInterval)
|
|
analyserInterval = null
|
|
}
|
|
if (audioCtx) {
|
|
await audioCtx.close().catch(() => {})
|
|
audioCtx = null
|
|
}
|
|
if (micVad) {
|
|
try {
|
|
await micVad.pause()
|
|
await micVad.destroy()
|
|
} catch {
|
|
// Already destroyed or failed — nothing to clean up further.
|
|
}
|
|
micVad = null
|
|
}
|
|
return { hadSpeech: had }
|
|
}
|
|
|
|
async function stopAndCheck(): Promise<void> {
|
|
const { hadSpeech } = await stop()
|
|
if (!hadSpeech) {
|
|
onNoSpeech()
|
|
}
|
|
}
|
|
|
|
return {
|
|
speaking: readonly(speaking),
|
|
amplitude: readonly(amplitude),
|
|
loaded: readonly(loaded),
|
|
start,
|
|
stop,
|
|
stopAndCheck,
|
|
}
|
|
}
|