Skip to content

Commit 99d2a75

Browse files
Implement issue #98: Voice recording waveform visualization
useAudioWaveform hook with Web Audio API, canvas-based waveform renderer, RecordingOverlay with timer and stop button. Four visual states: idle, recording, processing, done. 21 new tests.
1 parent e82f932 commit 99d2a75

10 files changed

Lines changed: 918 additions & 26 deletions

docs/tracker/98-voice-recording-ux.in-progress.md renamed to docs/tracker/done/98-voice-recording-ux.done.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,20 @@ Web app only. This issue adds a waveform visualization component and recording t
8585
- Stop button in waveform area triggers `stopListening`
8686
- Elapsed timer is displayed during recording
8787
- After recording stops, processing state is briefly shown before transcript preview appears
88+
89+
## Log
90+
91+
### [SWE] 2026-03-18 11:40
92+
- Implemented useAudioWaveform hook: manages getUserMedia, AudioContext, AnalyserNode, requestAnimationFrame loop, elapsed timer
93+
- Implemented AudioWaveform component: canvas-based waveform renderer with ResizeObserver for responsiveness
94+
- Implemented RecordingOverlay component: combines waveform, elapsed timer (mm:ss), stop button, and processing indicator
95+
- Updated ChatInput to integrate recording overlay: shows waveform during recording, processing state after stop, then transcript preview
96+
- Updated test setup to include ResizeObserver mock
97+
- Updated ChatInputVoice tests for new recording flow (processing state between stop and transcript)
98+
- Files created: web/src/hooks/useAudioWaveform.ts, web/src/components/AudioWaveform.tsx, web/src/components/RecordingOverlay.tsx
99+
- Files modified: web/src/components/ChatInput.tsx, web/src/test/ChatInputVoice.test.tsx, web/src/test/setup.ts
100+
- Tests added: 26 new tests (7 useAudioWaveform, 5 AudioWaveform, 4 RecordingOverlay, 10 ChatInputVoice)
101+
- Build results: 567 tests pass, 0 fail, tsc clean
102+
- All four visual states implemented: idle (no overlay), recording (waveform + timer + stop), processing (spinner + "Processing..."), done (transcript preview)
103+
- Graceful fallback when getUserMedia unavailable or permission denied (error state, no crash)
104+
- No audio processing libraries added -- only Web Audio API and Canvas API
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useRef, useEffect } from "react";
2+
3+
export interface AudioWaveformProps {
4+
waveformData: Uint8Array | null;
5+
width?: number;
6+
height?: number;
7+
color?: string;
8+
}
9+
10+
export default function AudioWaveform({
11+
waveformData,
12+
height = 48,
13+
color = "#3b82f6",
14+
}: AudioWaveformProps) {
15+
const canvasRef = useRef<HTMLCanvasElement>(null);
16+
const containerRef = useRef<HTMLDivElement>(null);
17+
18+
useEffect(() => {
19+
const canvas = canvasRef.current;
20+
const container = containerRef.current;
21+
if (!canvas || !container) return;
22+
23+
const resizeObserver = new ResizeObserver((entries) => {
24+
for (const entry of entries) {
25+
const { width } = entry.contentRect;
26+
canvas.width = width;
27+
canvas.height = height;
28+
}
29+
});
30+
31+
resizeObserver.observe(container);
32+
return () => resizeObserver.disconnect();
33+
}, [height]);
34+
35+
useEffect(() => {
36+
const canvas = canvasRef.current;
37+
if (!canvas) return;
38+
39+
const ctx = canvas.getContext("2d");
40+
if (!ctx) return;
41+
42+
ctx.clearRect(0, 0, canvas.width, canvas.height);
43+
44+
if (!waveformData || waveformData.length === 0) {
45+
// Draw flat line for silence / no data
46+
ctx.beginPath();
47+
ctx.strokeStyle = color;
48+
ctx.lineWidth = 2;
49+
ctx.moveTo(0, canvas.height / 2);
50+
ctx.lineTo(canvas.width, canvas.height / 2);
51+
ctx.stroke();
52+
return;
53+
}
54+
55+
const bufferLength = waveformData.length;
56+
const sliceWidth = canvas.width / bufferLength;
57+
58+
ctx.beginPath();
59+
ctx.strokeStyle = color;
60+
ctx.lineWidth = 2;
61+
62+
let x = 0;
63+
for (let i = 0; i < bufferLength; i++) {
64+
const v = waveformData[i] / 128.0;
65+
const y = (v * canvas.height) / 2;
66+
67+
if (i === 0) {
68+
ctx.moveTo(x, y);
69+
} else {
70+
ctx.lineTo(x, y);
71+
}
72+
x += sliceWidth;
73+
}
74+
75+
ctx.stroke();
76+
}, [waveformData, color, height]);
77+
78+
return (
79+
<div ref={containerRef} className="w-full" style={{ height }}>
80+
<canvas
81+
ref={canvasRef}
82+
height={height}
83+
style={{ width: "100%", height }}
84+
data-testid="waveform-canvas"
85+
/>
86+
</div>
87+
);
88+
}

web/src/components/ChatInput.tsx

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { useState, useCallback } from "react";
1+
import { useState, useCallback, useEffect, useRef } from "react";
22
import type { KeyboardEvent } from "react";
33
import { useVoiceInput } from "@/hooks/useVoiceInput";
4+
import { useAudioWaveform } from "@/hooks/useAudioWaveform";
45
import VoiceButton from "@/components/VoiceButton";
56
import TranscriptPreview from "@/components/TranscriptPreview";
7+
import RecordingOverlay from "@/components/RecordingOverlay";
68

79
export interface ChatInputProps {
810
onSend: (content: string) => void;
@@ -20,7 +22,41 @@ export default function ChatInput({ onSend, disabled }: ChatInputProps) {
2022
resetTranscript,
2123
} = useVoiceInput();
2224

23-
const showTranscriptPreview = !isListening && transcript.length > 0;
25+
const {
26+
start: startWaveform,
27+
stop: stopWaveform,
28+
waveformData,
29+
elapsedSeconds,
30+
} = useAudioWaveform();
31+
32+
const [isProcessing, setIsProcessing] = useState(false);
33+
const wasListeningRef = useRef(false);
34+
35+
const showTranscriptPreview =
36+
!isListening && !isProcessing && transcript.length > 0;
37+
38+
// Track transitions from listening to not-listening for processing state
39+
useEffect(() => {
40+
if (wasListeningRef.current && !isListening) {
41+
// Just stopped listening - show processing briefly
42+
setIsProcessing(true);
43+
const timer = setTimeout(() => {
44+
setIsProcessing(false);
45+
}, 1500);
46+
return () => clearTimeout(timer);
47+
}
48+
wasListeningRef.current = isListening;
49+
}, [isListening]);
50+
51+
const handleStartListening = useCallback(() => {
52+
startListening();
53+
startWaveform();
54+
}, [startListening, startWaveform]);
55+
56+
const handleStopListening = useCallback(() => {
57+
stopListening();
58+
stopWaveform();
59+
}, [stopListening, stopWaveform]);
2460

2561
const handleSend = useCallback(() => {
2662
const trimmed = text.trim();
@@ -65,22 +101,34 @@ export default function ChatInput({ onSend, disabled }: ChatInputProps) {
65101
/>
66102
</div>
67103
)}
104+
{(isListening || isProcessing) && (
105+
<div className="mb-2">
106+
<RecordingOverlay
107+
waveformData={waveformData}
108+
elapsedSeconds={elapsedSeconds}
109+
isProcessing={isProcessing}
110+
onStop={handleStopListening}
111+
/>
112+
</div>
113+
)}
68114
<div className="flex gap-2">
69-
<textarea
70-
className="flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
71-
placeholder="Type a message..."
72-
rows={1}
73-
value={text}
74-
onChange={(e) => setText(e.target.value)}
75-
onKeyDown={handleKeyDown}
76-
disabled={disabled}
77-
aria-label="Message input"
78-
/>
115+
{!isListening && !isProcessing && (
116+
<textarea
117+
className="flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
118+
placeholder="Type a message..."
119+
rows={1}
120+
value={text}
121+
onChange={(e) => setText(e.target.value)}
122+
onKeyDown={handleKeyDown}
123+
disabled={disabled}
124+
aria-label="Message input"
125+
/>
126+
)}
79127
<VoiceButton
80128
isListening={isListening}
81129
isSupported={isSupported}
82-
onStartListening={startListening}
83-
onStopListening={stopListening}
130+
onStartListening={handleStartListening}
131+
onStopListening={handleStopListening}
84132
/>
85133
<button
86134
type="button"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import AudioWaveform from "@/components/AudioWaveform";
2+
3+
export interface RecordingOverlayProps {
4+
waveformData: Uint8Array | null;
5+
elapsedSeconds: number;
6+
isProcessing: boolean;
7+
onStop: () => void;
8+
}
9+
10+
function formatTime(seconds: number): string {
11+
const mins = Math.floor(seconds / 60);
12+
const secs = seconds % 60;
13+
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
14+
}
15+
16+
export default function RecordingOverlay({
17+
waveformData,
18+
elapsedSeconds,
19+
isProcessing,
20+
onStop,
21+
}: RecordingOverlayProps) {
22+
if (isProcessing) {
23+
return (
24+
<div
25+
className="flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-3"
26+
data-testid="processing-indicator"
27+
>
28+
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
29+
<span className="text-sm text-gray-600">Processing...</span>
30+
</div>
31+
);
32+
}
33+
34+
return (
35+
<div
36+
className="flex items-center gap-3 rounded-lg border border-red-300 bg-red-50 px-3 py-2"
37+
data-testid="recording-overlay"
38+
>
39+
<span className="inline-block h-3 w-3 rounded-full bg-red-600 animate-pulse" />
40+
<div className="flex-1">
41+
<AudioWaveform waveformData={waveformData} height={48} />
42+
</div>
43+
<span
44+
className="text-sm font-mono text-gray-700 min-w-[3rem] text-right"
45+
data-testid="elapsed-timer"
46+
>
47+
{formatTime(elapsedSeconds)}
48+
</span>
49+
<button
50+
type="button"
51+
className="rounded-lg bg-red-600 px-3 py-1 text-sm font-medium text-white hover:bg-red-700"
52+
onClick={onStop}
53+
aria-label="Stop recording"
54+
>
55+
Stop
56+
</button>
57+
</div>
58+
);
59+
}

web/src/hooks/useAudioWaveform.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { useState, useCallback, useEffect, useRef } from "react";
2+
3+
export interface UseAudioWaveformReturn {
4+
start: () => Promise<void>;
5+
stop: () => void;
6+
waveformData: Uint8Array | null;
7+
isActive: boolean;
8+
elapsedSeconds: number;
9+
error: string | null;
10+
}
11+
12+
export function useAudioWaveform(): UseAudioWaveformReturn {
13+
const [isActive, setIsActive] = useState(false);
14+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
15+
const [waveformData, setWaveformData] = useState<Uint8Array | null>(null);
16+
const [error, setError] = useState<string | null>(null);
17+
18+
const audioContextRef = useRef<AudioContext | null>(null);
19+
const mediaStreamRef = useRef<MediaStream | null>(null);
20+
const analyserRef = useRef<AnalyserNode | null>(null);
21+
const animationFrameRef = useRef<number | null>(null);
22+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
23+
const isActiveRef = useRef(false);
24+
25+
const cleanup = useCallback(() => {
26+
if (animationFrameRef.current !== null) {
27+
cancelAnimationFrame(animationFrameRef.current);
28+
animationFrameRef.current = null;
29+
}
30+
if (timerRef.current !== null) {
31+
clearInterval(timerRef.current);
32+
timerRef.current = null;
33+
}
34+
if (mediaStreamRef.current) {
35+
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
36+
mediaStreamRef.current = null;
37+
}
38+
if (audioContextRef.current) {
39+
audioContextRef.current.close();
40+
audioContextRef.current = null;
41+
}
42+
analyserRef.current = null;
43+
isActiveRef.current = false;
44+
}, []);
45+
46+
const updateWaveform = useCallback(() => {
47+
if (!analyserRef.current || !isActiveRef.current) return;
48+
49+
const analyser = analyserRef.current;
50+
const dataArray = new Uint8Array(analyser.frequencyBinCount);
51+
analyser.getByteTimeDomainData(dataArray);
52+
setWaveformData(dataArray);
53+
54+
animationFrameRef.current = requestAnimationFrame(updateWaveform);
55+
}, []);
56+
57+
const start = useCallback(async () => {
58+
setError(null);
59+
60+
if (
61+
!navigator.mediaDevices ||
62+
typeof navigator.mediaDevices.getUserMedia !== "function"
63+
) {
64+
setError("getUserMedia is not supported in this browser");
65+
return;
66+
}
67+
68+
try {
69+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
70+
mediaStreamRef.current = stream;
71+
72+
const audioContext = new AudioContext();
73+
audioContextRef.current = audioContext;
74+
75+
const analyser = audioContext.createAnalyser();
76+
analyser.fftSize = 256;
77+
analyserRef.current = analyser;
78+
79+
const source = audioContext.createMediaStreamSource(stream);
80+
source.connect(analyser);
81+
82+
isActiveRef.current = true;
83+
setIsActive(true);
84+
setElapsedSeconds(0);
85+
86+
timerRef.current = setInterval(() => {
87+
setElapsedSeconds((prev) => prev + 1);
88+
}, 1000);
89+
90+
animationFrameRef.current = requestAnimationFrame(updateWaveform);
91+
} catch (err) {
92+
cleanup();
93+
const message =
94+
err instanceof Error ? err.message : "Microphone access denied";
95+
setError(message);
96+
}
97+
}, [cleanup, updateWaveform]);
98+
99+
const stop = useCallback(() => {
100+
cleanup();
101+
setIsActive(false);
102+
setWaveformData(null);
103+
}, [cleanup]);
104+
105+
useEffect(() => {
106+
return () => {
107+
cleanup();
108+
};
109+
}, [cleanup]);
110+
111+
return {
112+
start,
113+
stop,
114+
waveformData,
115+
isActive,
116+
elapsedSeconds,
117+
error,
118+
};
119+
}

0 commit comments

Comments
 (0)