docs: add web voice overlay polish implementation plan
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
# Web Voice Overlay Polish — Implementation Plan
|
||||
|
||||
> **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 in `App.vue`, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection backed by a new `useSilenceDetector` composable.
|
||||
|
||||
**Architecture:** A new `useSilenceDetector` composable uses `AudioContext` + `AnalyserNode` to monitor amplitude from a live `MediaStream` and fires a callback after sustained silence. `VoiceOverlay` coordinates recording and silence detection, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds a Space bar handler that dispatches the existing `voice:ptt-toggle` custom event.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, TypeScript, Web Audio API (`AudioContext`, `AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables.
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
**Context:** The Web Audio API lets us pipe a `MediaStream` into an `AnalyserNode` and read frequency data as a byte array every 100 ms. RMS amplitude of that array gives a 0–1 loudness value; converting to dB lets us use the same `-40 dB` threshold as the Android app. The composable must be safe to call `stop()` on multiple times and must reset amplitude to 0 after stopping so the animated bars collapse.
|
||||
|
||||
- [ ] **Step 1: Create the file with full implementation**
|
||||
|
||||
`frontend/src/composables/useSilenceDetector.ts`:
|
||||
|
||||
```ts
|
||||
import { ref, readonly } from 'vue'
|
||||
|
||||
export interface SilenceDetectorOptions {
|
||||
thresholdDb?: number // default -40
|
||||
silenceDurationMs?: number // default 1500
|
||||
minRecordingMs?: number // default 500
|
||||
}
|
||||
|
||||
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): 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(): void {
|
||||
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 }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /path/to/fabledassistant/frontend
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useSilenceDetector.ts
|
||||
git commit -m "feat: add useSilenceDetector composable with Web Audio API amplitude monitoring"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Expose `stream` ref from `useVoiceRecorder`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
||||
|
||||
**Context:** Currently `stream` is a plain `let` variable inside the closure. `VoiceOverlay` needs to pass the live `MediaStream` to `useSilenceDetector.start()` after recording begins. Exposing it as a readonly `Ref<MediaStream | null>` is the minimal change — no other callers are broken because they don't currently read `stream` from the return value.
|
||||
|
||||
The current file is at `frontend/src/composables/useVoiceRecorder.ts`. Read it before editing — the key lines to change are:
|
||||
|
||||
1. Top of function body: `let stream: MediaStream | null = null` → `const streamRef = ref<MediaStream | null>(null)`
|
||||
2. In `startRecording()`: `stream = await navigator.mediaDevices.getUserMedia({ audio: true })` → `streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })`
|
||||
3. In `startRecording()` catch block: `stream = null` if present — replace with `streamRef.value = null` (if the catch sets stream to null; if not, skip)
|
||||
4. In `mediaRecorder.onstop`: `stream?.getTracks().forEach((t) => t.stop())` → `streamRef.value?.getTracks().forEach((t) => t.stop())` then `streamRef.value = null`
|
||||
5. Return object: add `stream: readonly(streamRef)`
|
||||
|
||||
- [ ] **Step 1: Add the `ref` import if not already present**
|
||||
|
||||
The file already imports `{ ref, readonly }` from `'vue'` — confirm this. If `ref` is missing from the import, add it.
|
||||
|
||||
- [ ] **Step 2: Replace the `stream` variable declaration**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
let stream: MediaStream | null = null
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
const streamRef = ref<MediaStream | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update all usages of `stream` in `startRecording`**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `onstop` handler**
|
||||
|
||||
Find:
|
||||
```ts
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
```
|
||||
Replace with:
|
||||
```ts
|
||||
streamRef.value?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.value = null
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `stream` to the return object**
|
||||
|
||||
Find the return statement and add `stream: readonly(streamRef)`:
|
||||
```ts
|
||||
return {
|
||||
recording: readonly(recording),
|
||||
error: readonly(error),
|
||||
isSupported,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
stream: readonly(streamRef),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useVoiceRecorder.ts
|
||||
git commit -m "feat: expose live stream ref from useVoiceRecorder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `VoiceOverlay` — silence detection, click-to-toggle, amplitude bars
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
||||
|
||||
**Context:** `VoiceOverlay.vue` is a complete floating voice UI that was never mounted. It currently uses `@mousedown`/`@mouseup` for push-to-talk. This task switches it to click-to-toggle with automatic silence detection and adds animated amplitude bars during recording. Read the full file before making changes — the existing structure and style blocks must be preserved.
|
||||
|
||||
#### Script changes
|
||||
|
||||
- [ ] **Step 1: Import `useSilenceDetector`**
|
||||
|
||||
At the top of `<script setup>`, after the existing imports, add:
|
||||
```ts
|
||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Instantiate the composable**
|
||||
|
||||
After `const audio = useVoiceAudio()`, add:
|
||||
```ts
|
||||
const silenceDetector = useSilenceDetector()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `startPtt` to start silence detection**
|
||||
|
||||
Find the `startPtt` function. After `phase.value = 'recording'`, add:
|
||||
```ts
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
```
|
||||
|
||||
The complete `startPtt` after the change:
|
||||
```ts
|
||||
async function startPtt() {
|
||||
if (!voiceEnabled.value || isBusy.value) return
|
||||
audio.stop()
|
||||
errorMsg.value = ''
|
||||
open.value = true
|
||||
await recorder.startRecording()
|
||||
if (recorder.error.value) {
|
||||
phase.value = 'error'
|
||||
errorMsg.value = recorder.error.value
|
||||
return
|
||||
}
|
||||
phase.value = 'recording'
|
||||
if (recorder.stream.value) {
|
||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `stopPtt` to stop silence detection**
|
||||
|
||||
Add `silenceDetector.stop()` as the very first line of `stopPtt`:
|
||||
```ts
|
||||
async function stopPtt() {
|
||||
silenceDetector.stop()
|
||||
if (phase.value !== 'recording') return
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `cancelAll` to stop silence detection**
|
||||
|
||||
Add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`:
|
||||
```ts
|
||||
function cancelAll() {
|
||||
silenceDetector.stop()
|
||||
recorder.stopRecording().catch(() => {})
|
||||
audio.stop()
|
||||
phase.value = 'idle'
|
||||
streamContent.value = ''
|
||||
errorMsg.value = ''
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `onBtnClick` function**
|
||||
|
||||
Add this function after `cancelAll`:
|
||||
```ts
|
||||
function onBtnClick() {
|
||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
||||
if (phase.value === 'recording') { stopPtt(); return }
|
||||
if (phase.value === 'idle') { startPtt() }
|
||||
}
|
||||
```
|
||||
|
||||
#### Template changes
|
||||
|
||||
- [ ] **Step 7: Replace PTT mouse/touch handlers with `@click`**
|
||||
|
||||
On `.voice-ptt-btn`, replace:
|
||||
```html
|
||||
@mousedown.prevent="startPtt"
|
||||
@mouseup.prevent="stopPtt"
|
||||
@touchstart.prevent="startPtt"
|
||||
@touchend.prevent="stopPtt"
|
||||
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
|
||||
```
|
||||
with:
|
||||
```html
|
||||
@click.prevent="onBtnClick"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Update aria-label and title on the button**
|
||||
|
||||
Replace:
|
||||
```html
|
||||
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
|
||||
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
|
||||
```
|
||||
with:
|
||||
```html
|
||||
: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'"
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Replace the static recording icon with amplitude bars**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<!-- 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>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<!-- 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>
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Update the idle hint label**
|
||||
|
||||
Find:
|
||||
```html
|
||||
Hold <kbd>Space</kbd> or tap
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
Tap or press <kbd>Space</kbd>
|
||||
```
|
||||
|
||||
#### Style changes
|
||||
|
||||
- [ ] **Step 11: Add amplitude bar styles to `<style scoped>`**
|
||||
|
||||
Append inside the `<style scoped>` block:
|
||||
```css
|
||||
/* ─── 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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/VoiceOverlay.vue
|
||||
git commit -m "feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Mount `VoiceOverlay` and wire Space bar in `App.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue`
|
||||
|
||||
**Context:** `App.vue` has a full `onGlobalKeydown` handler and a shortcuts overlay. The Space bar is already documented there as "Hold to speak (voice, when enabled)" but the handler was never added to `onGlobalKeydown`. `VoiceOverlay` uses `Teleport to="body"` so it renders at the document root regardless of where it's placed in the template — just needs to be inside the authenticated block.
|
||||
|
||||
#### Script changes
|
||||
|
||||
- [ ] **Step 1: Add `VoiceOverlay` import**
|
||||
|
||||
In `<script setup>`, after the existing component imports (after `ToastNotification`), add:
|
||||
```ts
|
||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Space bar case to `onGlobalKeydown`**
|
||||
|
||||
The existing handler has a `switch (e.key)` block. The guard `if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return` already runs before the switch, so the Space case only fires when the user isn't typing.
|
||||
|
||||
Inside the `switch (e.key)` block, add this case after the existing `'c'` case:
|
||||
```ts
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
||||
break
|
||||
```
|
||||
|
||||
#### Template changes
|
||||
|
||||
- [ ] **Step 3: Mount `VoiceOverlay` in the authenticated template**
|
||||
|
||||
Find `<ToastNotification />` near the bottom of the authenticated template block and add `<VoiceOverlay />` directly above it:
|
||||
```html
|
||||
<VoiceOverlay />
|
||||
<ToastNotification />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update Space bar description in shortcuts panel**
|
||||
|
||||
Find:
|
||||
```html
|
||||
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
|
||||
```
|
||||
Replace with:
|
||||
```html
|
||||
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Verify full build succeeds**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: build completes with no errors.
|
||||
|
||||
- [ ] **Step 7: Manual smoke test**
|
||||
|
||||
1. Start the dev server: `npm run dev`
|
||||
2. Log in — confirm the floating mic button appears in the bottom-right corner
|
||||
3. Ensure voice is enabled in Settings → Voice
|
||||
4. Click the mic button — confirm it turns red with animated amplitude bars
|
||||
5. Speak — bars should animate with your voice
|
||||
6. Stop speaking — after ~1.5 s of silence, the button should switch to purple (transcribing), then green (speaking) as it plays back the response
|
||||
7. Click the mic while recording — confirm it stops immediately
|
||||
8. Press Space (not in an input field) — confirm it starts/stops recording
|
||||
9. Press Space in the chat input — confirm it does NOT trigger voice
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.vue
|
||||
git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut in App.vue"
|
||||
```
|
||||
Reference in New Issue
Block a user