feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay

This commit is contained in:
2026-04-06 20:01:44 -04:00
parent ac7dde472f
commit 853dc810ff
+43 -13
View File
@@ -12,6 +12,7 @@
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder' import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useVoiceAudio } from '@/composables/useVoiceAudio' import { useVoiceAudio } from '@/composables/useVoiceAudio'
import { useSilenceDetector } from '@/composables/useSilenceDetector'
import { apiPost, apiSSEStream, getVoiceStatus, transcribeAudio, synthesiseSpeech } from '@/api/client' import { apiPost, apiSSEStream, getVoiceStatus, transcribeAudio, synthesiseSpeech } from '@/api/client'
// ─── Voice service availability ────────────────────────────────────────────── // ─── Voice service availability ──────────────────────────────────────────────
@@ -64,6 +65,7 @@ const isBusy = computed(() => phase.value !== 'idle' && phase.value !== 'error')
// ─── Composables ───────────────────────────────────────────────────────────── // ─── Composables ─────────────────────────────────────────────────────────────
const recorder = useVoiceRecorder() const recorder = useVoiceRecorder()
const audio = useVoiceAudio() const audio = useVoiceAudio()
const silenceDetector = useSilenceDetector()
// ─── Core PTT flow ──────────────────────────────────────────────────────────── // ─── Core PTT flow ────────────────────────────────────────────────────────────
async function startPtt() { async function startPtt() {
@@ -79,9 +81,13 @@ async function startPtt() {
return return
} }
phase.value = 'recording' phase.value = 'recording'
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
} }
async function stopPtt() { async function stopPtt() {
silenceDetector.stop()
if (phase.value !== 'recording') return if (phase.value !== 'recording') return
phase.value = 'transcribing' phase.value = 'transcribing'
@@ -187,7 +193,14 @@ async function stopPtt() {
assistantMessageId // consumed; suppress lint 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() { function cancelAll() {
silenceDetector.stop()
recorder.stopRecording().catch(() => {}) recorder.stopRecording().catch(() => {})
audio.stop() audio.stop()
phase.value = 'idle' phase.value = 'idle'
@@ -263,7 +276,7 @@ onUnmounted(() => {
<span v-else-if="phase === 'error'" class="voice-error-label">{{ errorMsg || 'Error' }}</span> <span v-else-if="phase === 'error'" class="voice-error-label">{{ errorMsg || 'Error' }}</span>
</div> </div>
<div v-else-if="!open || !messages.length" class="voice-status-label voice-hint"> <div v-else-if="!open || !messages.length" class="voice-status-label voice-hint">
Hold <kbd>Space</kbd> or tap Tap or press <kbd>Space</kbd>
</div> </div>
<!-- Cancel button (shown while busy or speaking) --> <!-- Cancel button (shown while busy or speaking) -->
@@ -288,23 +301,24 @@ onUnmounted(() => {
'voice-ptt--speaking': phase === 'speaking' || audio.playing.value, 'voice-ptt--speaking': phase === 'speaking' || audio.playing.value,
'voice-ptt--error': phase === 'error', 'voice-ptt--error': phase === 'error',
}" }"
@mousedown.prevent="startPtt" @click.prevent="onBtnClick"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
:disabled="phase === 'transcribing' || phase === 'generating'" :disabled="phase === 'transcribing' || phase === 'generating'"
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'" :aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'" :title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
> >
<!-- Idle: mic icon --> <!-- Idle: mic icon -->
<svg v-if="phase === 'idle' || phase === 'error'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor"> <svg v-if="phase === 'idle' || phase === 'error'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg> </svg>
<!-- Recording: waveform / stop icon --> <!-- Recording: amplitude bars -->
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor"> <span v-else-if="phase === 'recording'" class="voice-amp-bars">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/> <span
</svg> v-for="n in 3"
:key="n"
class="voice-amp-bar"
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
></span>
</span>
<!-- Busy: spinner dots --> <!-- Busy: spinner dots -->
<span v-else-if="phase === 'transcribing' || phase === 'generating'" class="voice-spinner"> <span v-else-if="phase === 'transcribing' || phase === 'generating'" class="voice-spinner">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -525,7 +539,23 @@ onUnmounted(() => {
40% { transform: scale(1); opacity: 1; } 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-enter-active,
.panel-slide-leave-active { .panel-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease;