diff --git a/docs/superpowers/specs/2026-04-06-web-voice-overlay-polish.md b/docs/superpowers/specs/2026-04-06-web-voice-overlay-polish.md new file mode 100644 index 0000000..633a86b --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-web-voice-overlay-polish.md @@ -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> // 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 | 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` and export it as readonly. + +- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref(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 `