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