diff --git a/frontend/src/components/VoiceOverlay.vue b/frontend/src/components/VoiceOverlay.vue index 470a648..447aebb 100644 --- a/frontend/src/components/VoiceOverlay.vue +++ b/frontend/src/components/VoiceOverlay.vue @@ -12,6 +12,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue' import { useVoiceRecorder } from '@/composables/useVoiceRecorder' import { useVoiceAudio } from '@/composables/useVoiceAudio' +import { useSilenceDetector } from '@/composables/useSilenceDetector' import { apiPost, apiSSEStream, getVoiceStatus, transcribeAudio, synthesiseSpeech } from '@/api/client' // ─── Voice service availability ────────────────────────────────────────────── @@ -64,6 +65,7 @@ const isBusy = computed(() => phase.value !== 'idle' && phase.value !== 'error') // ─── Composables ───────────────────────────────────────────────────────────── const recorder = useVoiceRecorder() const audio = useVoiceAudio() +const silenceDetector = useSilenceDetector() // ─── Core PTT flow ──────────────────────────────────────────────────────────── async function startPtt() { @@ -79,9 +81,13 @@ async function startPtt() { return } phase.value = 'recording' + if (recorder.stream.value) { + silenceDetector.start(recorder.stream.value, stopPtt) + } } async function stopPtt() { + silenceDetector.stop() if (phase.value !== 'recording') return phase.value = 'transcribing' @@ -187,7 +193,14 @@ async function stopPtt() { assistantMessageId // consumed; suppress lint } +function onBtnClick() { + if (phase.value === 'error') { phase.value = 'idle'; return } + if (phase.value === 'recording') { stopPtt(); return } + if (phase.value === 'idle') { startPtt() } +} + function cancelAll() { + silenceDetector.stop() recorder.stopRecording().catch(() => {}) audio.stop() phase.value = 'idle' @@ -263,7 +276,7 @@ onUnmounted(() => { {{ errorMsg || 'Error' }}
- Hold Space or tap + Tap or press Space
@@ -288,23 +301,24 @@ onUnmounted(() => { 'voice-ptt--speaking': phase === 'speaking' || audio.playing.value, 'voice-ptt--error': phase === 'error', }" - @mousedown.prevent="startPtt" - @mouseup.prevent="stopPtt" - @touchstart.prevent="startPtt" - @touchend.prevent="stopPtt" - @click.prevent="phase === 'error' ? (phase = 'idle') : undefined" + @click.prevent="onBtnClick" :disabled="phase === 'transcribing' || phase === 'generating'" - :aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'" - :title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'" + :aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'" + :title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'" > - - - - + + + + @@ -525,7 +539,23 @@ onUnmounted(() => { 40% { transform: scale(1); opacity: 1; } } -/* ─── Transition ─────────────────────────────────────────────────────────── */ +/* ─── Amplitude bars (recording state) ──────────────────────────���───────── */ +.voice-amp-bars { + display: flex; + gap: 3px; + align-items: center; + height: 22px; +} +.voice-amp-bar { + width: 4px; + height: 18px; + background: #fff; + border-radius: 2px; + transform-origin: center; + transition: transform 0.08s ease; +} + +/* ─── Transition ───────────────────────���──────────────────────���──────────── */ .panel-slide-enter-active, .panel-slide-leave-active { transition: opacity 0.2s ease, transform 0.2s ease;