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 { 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user