feat(voice): replace silence detector with Silero VAD in ChatInputBar

Swaps useSilenceDetector for useVad. Adds no-speech guard: when the
user manually stops recording without VAD ever detecting speech, show
a toast and skip the Whisper round-trip instead of sending pure noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 17:45:21 -04:00
parent 719de12a6c
commit 0a8a755909
+43 -13
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue' import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client' import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder' import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useSilenceDetector } from '@/composables/useSilenceDetector' import { useVad } from '@/composables/useVad'
import { useStreamingTts } from '@/composables/useStreamingTts' import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio' import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode' import { useListenMode } from '@/composables/useListenMode'
@@ -138,29 +138,49 @@ function removeAttachedNote() {
attachedNote.value = null attachedNote.value = null
} }
// ── Voice (click-to-toggle + silence detection) ───────────────────────────── // ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
const transcribingVoice = ref(false) const transcribingVoice = ref(false)
const recorder = useVoiceRecorder() const recorder = useVoiceRecorder()
const silenceDetector = useSilenceDetector() // Tracks whether VAD detected speech during the current recording session.
// Used to skip the Whisper round-trip when the user manually stops without
// ever speaking — the recording is ambient noise and will transcribe to "".
const vadSawSpeech = ref(false)
const vad = useVad({
onSpeechEnd: () => {
// VAD auto-stop: speech was detected and the user has paused. Proceed
// to transcribe the MediaRecorder output.
void stopRecording(false)
},
onNoSpeech: () => {
// User manually stopped without VAD detecting speech. Toast it; the
// stopRecording path below will skip Whisper based on vadSawSpeech.
useToastStore().show('No speech detected', 'warning')
},
})
// Live mic halo. A solid red disc sits behind the mic button and scales // Live mic halo. A solid red disc sits behind the mic button and scales
// with `silenceDetector.amplitude` (0..1 RMS, already amplified for // with `vad.amplitude` (0..1 RMS, already amplified for visibility).
// visibility). The button itself stays put so the mic icon is always
// legible on top. 0.2 baseline breathes on silence; loud speech drives
// the disc out to ~2.6× the button size with a softer radial fade.
const micGlowStyle = computed(() => { const micGlowStyle = computed(() => {
if (!recorder.recording.value) return { display: 'none' } if (!recorder.recording.value) return { display: 'none' }
const pulse = 0.2 + Math.min(silenceDetector.amplitude.value, 1) * 0.8 const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
return { return {
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`, transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
opacity: String(0.45 + pulse * 0.4), opacity: String(0.45 + pulse * 0.4),
} }
}) })
// vad.speaking flips true on speech-start. Watch it once per session to
// capture that speech was detected at some point, without clearing when
// speech ends. This drives the no-speech guard in stopRecording.
watch(() => vad.speaking.value, (on) => {
if (on) vadSawSpeech.value = true
})
async function toggleVoice() { async function toggleVoice() {
if (transcribingVoice.value) return if (transcribingVoice.value) return
if (recorder.recording.value) { if (recorder.recording.value) {
await stopRecording() await stopRecording(true)
} else { } else {
await startRecording() await startRecording()
} }
@@ -172,22 +192,32 @@ async function startRecording() {
useToastStore().show('Microphone requires HTTPS or localhost', 'error') useToastStore().show('Microphone requires HTTPS or localhost', 'error')
return return
} }
vadSawSpeech.value = false
await recorder.startRecording() await recorder.startRecording()
if (recorder.error.value) { if (recorder.error.value) {
useToastStore().show(recorder.error.value, 'error') useToastStore().show(recorder.error.value, 'error')
return return
} }
if (recorder.stream.value) { if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, () => stopRecording()) await vad.start(recorder.stream.value)
} }
} }
async function stopRecording() { async function stopRecording(manual: boolean) {
silenceDetector.stop() if (manual) {
await vad.stopAndCheck()
} else {
await vad.stop()
}
if (!recorder.recording.value) return if (!recorder.recording.value) return
transcribingVoice.value = true transcribingVoice.value = true
try { try {
const blob = await recorder.stopRecording() const blob = await recorder.stopRecording()
// No-speech guard: user manually stopped without VAD seeing speech.
// Skip Whisper — the toast was already shown by onNoSpeech.
if (manual && !vadSawSpeech.value) {
return
}
// Pass last assistant message as context to reduce STT mishearings // Pass last assistant message as context to reduce STT mishearings
const msgs = store.currentConversation?.messages ?? [] const msgs = store.currentConversation?.messages ?? []
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content