docs: add web voice overlay polish spec
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
# Web Voice Overlay Polish — Implementation Spec
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection.
|
||||
|
||||
**Architecture:** A new `useSilenceDetector` composable wraps the Web Audio API `AnalyserNode` and fires a callback when sustained silence is detected. `VoiceOverlay` coordinates `useVoiceRecorder` and `useSilenceDetector`, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds the Space bar handler.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, Web Audio API (`AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables, TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
||||
| Modify | `frontend/src/App.vue` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `useSilenceDetector` composable
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
||||
|
||||
### Interface
|
||||
|
||||
```ts
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options?: SilenceDetectorOptions): {
|
||||
amplitude: Readonly<Ref<number>> // 0–1, for visualization
|
||||
start(stream: MediaStream, onSilence: () => void): void
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
### Behaviour
|
||||
|
||||
- `start(stream, onSilence)`:
|
||||
1. Creates `AudioContext`
|
||||
2. `createMediaStreamSource(stream)` → connects to `AnalyserNode` (fftSize 256)
|
||||
3. Records `startedAt = Date.now()`
|
||||
4. Starts a `setInterval` at 100ms that:
|
||||
- Calls `analyser.getByteFrequencyData(dataArray)`
|
||||
- Computes RMS amplitude → maps to 0–1 range for `amplitude.value`
|
||||
- Converts to approximate dB: `db = 20 * log10(rms)` (clamp to -100 when rms === 0)
|
||||
- If `db < thresholdDb`: increments `silenceMs += 100`; else resets `silenceMs = 0`
|
||||
- If `silenceMs >= silenceDurationMs` AND `Date.now() - startedAt >= minRecordingMs`: clears interval, fires `onSilence()`
|
||||
- `stop()`: clears interval, closes `AudioContext`, resets `amplitude.value = 0`
|
||||
- Safe to call `stop()` multiple times (guard with null check)
|
||||
- `amplitude` resets to 0 after `stop()`
|
||||
|
||||
### Full implementation
|
||||
|
||||
```ts
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number
|
||||
silenceDurationMs?: number
|
||||
minRecordingMs?: number
|
||||
}
|
||||
|
||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
||||
const {
|
||||
thresholdDb = -40,
|
||||
silenceDurationMs = 1500,
|
||||
minRecordingMs = 500,
|
||||
} = options
|
||||
|
||||
const amplitude = ref(0)
|
||||
let audioCtx: AudioContext | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let silenceMs = 0
|
||||
let startedAt = 0
|
||||
|
||||
function start(stream: MediaStream, onSilence: () => void) {
|
||||
stop()
|
||||
audioCtx = new AudioContext()
|
||||
const source = audioCtx.createMediaStreamSource(stream)
|
||||
const analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
||||
silenceMs = 0
|
||||
startedAt = Date.now()
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
analyser.getByteFrequencyData(data)
|
||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
||||
amplitude.value = rms
|
||||
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
||||
if (db < thresholdDb) {
|
||||
silenceMs += 100
|
||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
||||
stop()
|
||||
onSilence()
|
||||
}
|
||||
} else {
|
||||
silenceMs = 0
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (audioCtx) {
|
||||
audioCtx.close().catch(() => {})
|
||||
audioCtx = null
|
||||
}
|
||||
amplitude.value = 0
|
||||
silenceMs = 0
|
||||
}
|
||||
|
||||
return { amplitude: readonly(amplitude), start, stop }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Write the file exactly as above
|
||||
- [ ] Verify TypeScript compiles: `cd frontend && npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/composables/useSilenceDetector.ts && git commit -m "feat: add useSilenceDetector composable"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Expose `stream` from `useVoiceRecorder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
||||
|
||||
Change the `stream` local variable to a `Ref<MediaStream | null>` and export it as readonly.
|
||||
|
||||
- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref<MediaStream | null>(null)`
|
||||
- [ ] Replace all `stream` assignments with `streamRef.value`:
|
||||
- `stream = await navigator.mediaDevices.getUserMedia(...)` → `streamRef.value = await ...`
|
||||
- `stream?.getTracks().forEach(...)` → `streamRef.value?.getTracks().forEach(...)`
|
||||
- `stream = null` → `streamRef.value = null`
|
||||
- [ ] Add `stream: readonly(streamRef)` to the return object
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/composables/useVoiceRecorder.ts && git commit -m "feat: expose stream ref from useVoiceRecorder"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire `VoiceOverlay` — silence detection + click-to-toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
||||
|
||||
### Script changes
|
||||
|
||||
- [ ] Import `useSilenceDetector` at the top of `<script setup>`
|
||||
- [ ] Add `const silenceDetector = useSilenceDetector()` after the existing composable instantiations
|
||||
- [ ] In `startPtt()`: after `phase.value = 'recording'`, add:
|
||||
```ts
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
```
|
||||
- [ ] In `stopPtt()`: add `silenceDetector.stop()` as the first line (before the guard check)
|
||||
- [ ] In `cancelAll()`: add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`
|
||||
|
||||
### Button: click-to-toggle
|
||||
|
||||
Replace the PTT mouse/touch handlers on `.voice-ptt-btn` with click-to-toggle logic:
|
||||
|
||||
- [ ] Remove `@mousedown.prevent="startPtt"` and `@mouseup.prevent="stopPtt"`
|
||||
- [ ] Remove `@touchstart.prevent="startPtt"` and `@touchend.prevent="stopPtt"`
|
||||
- [ ] Replace `@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"` with:
|
||||
```html
|
||||
@click.prevent="onBtnClick"
|
||||
```
|
||||
- [ ] Add `onBtnClick` function in script:
|
||||
```ts
|
||||
function onBtnClick() {
|
||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
||||
if (phase.value === 'recording') { stopPtt(); return }
|
||||
if (phase.value === 'idle') { startPtt() }
|
||||
}
|
||||
```
|
||||
- [ ] Update `aria-label` and `title` on the button:
|
||||
- `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'`
|
||||
|
||||
### Amplitude visualization during recording
|
||||
|
||||
Inside the button, when `phase === 'recording'`, replace the static stop icon with animated amplitude bars:
|
||||
|
||||
- [ ] Replace the recording SVG block:
|
||||
```html
|
||||
<svg v-else-if="phase === 'recording'" ...>...</svg>
|
||||
```
|
||||
with:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
### Hint label
|
||||
|
||||
- [ ] Change the idle hint from `Hold <kbd>Space</kbd> or tap` to `Tap or press <kbd>Space</kbd>`
|
||||
|
||||
### CSS for amplitude bars
|
||||
|
||||
- [ ] Add to `<style scoped>`:
|
||||
```css
|
||||
.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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Commit: `git add frontend/src/components/VoiceOverlay.vue && git commit -m "feat: click-to-toggle silence detection in VoiceOverlay"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Mount overlay and wire Space bar in `App.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
### Mount VoiceOverlay
|
||||
|
||||
- [ ] Add import at top of `<script setup>`:
|
||||
```ts
|
||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
||||
```
|
||||
- [ ] Add `<VoiceOverlay />` inside the `<template v-if="authStore.isAuthenticated">` block, just before `<ToastNotification />`:
|
||||
```html
|
||||
<VoiceOverlay />
|
||||
<ToastNotification />
|
||||
```
|
||||
|
||||
### Space bar handler
|
||||
|
||||
- [ ] In `onGlobalKeydown`, add a `Space` case inside the `switch (e.key)` block (after the existing cases), only fires when `!isInputActive()`:
|
||||
```ts
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
break
|
||||
```
|
||||
|
||||
### Shortcuts panel label
|
||||
|
||||
- [ ] Update the Space shortcut description from `Hold to speak (voice, when enabled)` to `Tap to speak (voice, when enabled)`
|
||||
|
||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
||||
- [ ] Verify full build: `npm run build`
|
||||
- [ ] Commit: `git add frontend/src/App.vue && git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut"`
|
||||
Reference in New Issue
Block a user