feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay
This commit is contained in:
@@ -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(() => {
|
||||
<span v-else-if="phase === 'error'" class="voice-error-label">{{ errorMsg || 'Error' }}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Cancel button (shown while busy or speaking) -->
|
||||
@@ -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'"
|
||||
>
|
||||
<!-- Idle: mic icon -->
|
||||
<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"/>
|
||||
</svg>
|
||||
<!-- Recording: waveform / stop icon -->
|
||||
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
<!-- Recording: amplitude bars -->
|
||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
||||
<span
|
||||
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 -->
|
||||
<span v-else-if="phase === 'transcribing' || phase === 'generating'" class="voice-spinner">
|
||||
<span></span><span></span><span></span>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user