Skip to content

Commit b4e6bdc

Browse files
committed
feat: custom sliding window waveform showing last 10 seconds
Replace react-audio-visualize with custom SlidingWaveform component: - Shows amplitude history over the last 10 seconds - Samples audio every 50ms (200 samples total) - New samples appear on right, slide left over time - Uses RMS amplitude calculation for smooth visualization - Bars scale dynamically to fill container width - Remove react-audio-visualize dependency
1 parent 6ecc56c commit b4e6bdc

File tree

3 files changed

+155
-52
lines changed

3 files changed

+155
-52
lines changed

bun.lock

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
"motion": "^12.23.24",
4444
"ollama-ai-provider-v2": "^1.5.4",
4545
"openai": "^6.9.1",
46-
"react-audio-visualize": "^1.2.0",
4746
"rehype-harden": "^1.1.5",
4847
"shescape": "^2.1.6",
4948
"source-map-support": "^0.5.21",
@@ -2838,8 +2837,6 @@
28382837

28392838
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
28402839

2841-
"react-audio-visualize": ["react-audio-visualize@1.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ=="],
2842-
28432840
"react-compiler-runtime": ["react-compiler-runtime@1.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w=="],
28442841

28452842
"react-dnd": ["react-dnd@16.0.1", "", { "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", "dnd-core": "^16.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q=="],

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@
8484
"motion": "^12.23.24",
8585
"ollama-ai-provider-v2": "^1.5.4",
8686
"openai": "^6.9.1",
87-
"react-audio-visualize": "^1.2.0",
8887
"rehype-harden": "^1.1.5",
8988
"shescape": "^2.1.6",
9089
"source-map-support": "^0.5.21",

src/browser/components/ChatInput/RecordingOverlay.tsx

Lines changed: 155 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
* Replaces the chat textarea when voice input is active.
44
*/
55

6-
import React, { useRef, useState, useLayoutEffect } from "react";
7-
import { LiveAudioVisualizer } from "react-audio-visualize";
6+
import React, { useRef, useState, useLayoutEffect, useEffect, useCallback } from "react";
87
import { Loader2 } from "lucide-react";
98
import { cn } from "@/common/lib/utils";
109
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
@@ -17,10 +16,10 @@ const MODE_COLORS = {
1716
exec: "hsl(268, 94%, 65%)", // Slightly lighter than --color-exec-mode for visibility
1817
} as const;
1918

20-
// FFT size determines number of frequency bins (fftSize / 2)
21-
// Higher = more bars but less responsive, lower = fewer bars but more responsive
22-
const FFT_SIZE = 128; // 64 bars
23-
const NUM_BARS = FFT_SIZE / 2;
19+
// Sliding window config
20+
const WINDOW_DURATION_MS = 10000; // 10 seconds of history
21+
const SAMPLE_INTERVAL_MS = 50; // Sample every 50ms
22+
const NUM_SAMPLES = Math.floor(WINDOW_DURATION_MS / SAMPLE_INTERVAL_MS); // 200 samples
2423

2524
interface RecordingOverlayProps {
2625
state: VoiceInputState;
@@ -32,38 +31,9 @@ interface RecordingOverlayProps {
3231
export const RecordingOverlay: React.FC<RecordingOverlayProps> = (props) => {
3332
const isRecording = props.state === "recording";
3433
const isTranscribing = props.state === "transcribing";
35-
const containerRef = useRef<HTMLDivElement>(null);
36-
const [containerWidth, setContainerWidth] = useState(600);
37-
38-
// Measure container width for the canvas using ResizeObserver
39-
useLayoutEffect(() => {
40-
const container = containerRef.current;
41-
if (!container) return;
42-
43-
const observer = new ResizeObserver((entries) => {
44-
for (const entry of entries) {
45-
setContainerWidth(entry.contentRect.width);
46-
}
47-
});
48-
49-
observer.observe(container);
50-
// Initial measurement
51-
setContainerWidth(container.offsetWidth);
52-
53-
return () => observer.disconnect();
54-
}, []);
5534

5635
const modeColor = MODE_COLORS[props.mode];
5736

58-
// Calculate bar dimensions to fill the container width
59-
// Total width = numBars * barWidth + (numBars - 1) * gap
60-
// We want gap = barWidth / 2 for nice spacing
61-
// So: width = numBars * barWidth + (numBars - 1) * barWidth/2
62-
// = barWidth * (numBars + (numBars - 1) / 2)
63-
// = barWidth * (1.5 * numBars - 0.5)
64-
const barWidth = Math.max(2, Math.floor(containerWidth / (1.5 * NUM_BARS - 0.5)));
65-
const gap = Math.max(1, Math.floor(barWidth / 2));
66-
6737
// Border and background classes based on state
6838
const containerClasses = cn(
6939
"mb-1 flex w-full flex-col items-center justify-center gap-1 rounded-md border px-3 py-2 transition-all focus:outline-none",
@@ -83,20 +53,9 @@ export const RecordingOverlay: React.FC<RecordingOverlayProps> = (props) => {
8353
aria-label={isRecording ? "Stop recording" : "Transcribing..."}
8454
>
8555
{/* Visualizer / Animation Area */}
86-
<div ref={containerRef} className="flex h-8 w-full items-center justify-center">
56+
<div className="flex h-8 w-full items-center justify-center">
8757
{isRecording && props.mediaRecorder ? (
88-
<LiveAudioVisualizer
89-
mediaRecorder={props.mediaRecorder}
90-
width={containerWidth}
91-
height={32}
92-
barWidth={barWidth}
93-
gap={gap}
94-
barColor={modeColor}
95-
smoothingTimeConstant={0.8}
96-
fftSize={FFT_SIZE}
97-
minDecibels={-70}
98-
maxDecibels={-30}
99-
/>
58+
<SlidingWaveform mediaRecorder={props.mediaRecorder} color={modeColor} height={32} />
10059
) : (
10160
<TranscribingAnimation />
10261
)}
@@ -127,6 +86,154 @@ export const RecordingOverlay: React.FC<RecordingOverlayProps> = (props) => {
12786
);
12887
};
12988

89+
/**
90+
* Sliding window waveform - shows amplitude over the last ~10 seconds.
91+
* New samples appear on the right and slide left over time.
92+
*/
93+
interface SlidingWaveformProps {
94+
mediaRecorder: MediaRecorder;
95+
color: string;
96+
height: number;
97+
}
98+
99+
const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
100+
const canvasRef = useRef<HTMLCanvasElement>(null);
101+
const containerRef = useRef<HTMLDivElement>(null);
102+
const [containerWidth, setContainerWidth] = useState(600);
103+
104+
// Audio analysis refs (persist across renders)
105+
const audioContextRef = useRef<AudioContext | null>(null);
106+
const analyserRef = useRef<AnalyserNode | null>(null);
107+
const samplesRef = useRef<number[]>(new Array(NUM_SAMPLES).fill(0));
108+
const animationFrameRef = useRef<number>(0);
109+
const lastSampleTimeRef = useRef<number>(0);
110+
111+
// Measure container width
112+
useLayoutEffect(() => {
113+
const container = containerRef.current;
114+
if (!container) return;
115+
116+
const observer = new ResizeObserver((entries) => {
117+
for (const entry of entries) {
118+
setContainerWidth(entry.contentRect.width);
119+
}
120+
});
121+
122+
observer.observe(container);
123+
setContainerWidth(container.offsetWidth);
124+
125+
return () => observer.disconnect();
126+
}, []);
127+
128+
// Set up audio analysis
129+
useEffect(() => {
130+
const stream = props.mediaRecorder.stream;
131+
if (!stream) return;
132+
133+
const audioContext = new AudioContext();
134+
const analyser = audioContext.createAnalyser();
135+
analyser.fftSize = 256;
136+
analyser.smoothingTimeConstant = 0.3;
137+
138+
const source = audioContext.createMediaStreamSource(stream);
139+
source.connect(analyser);
140+
141+
audioContextRef.current = audioContext;
142+
analyserRef.current = analyser;
143+
144+
// Reset samples when starting
145+
samplesRef.current = new Array(NUM_SAMPLES).fill(0);
146+
lastSampleTimeRef.current = performance.now();
147+
148+
return () => {
149+
audioContext.close();
150+
audioContextRef.current = null;
151+
analyserRef.current = null;
152+
};
153+
}, [props.mediaRecorder]);
154+
155+
// Animation loop - sample audio and render
156+
const draw = useCallback(() => {
157+
const canvas = canvasRef.current;
158+
const analyser = analyserRef.current;
159+
if (!canvas || !analyser) return;
160+
161+
const ctx = canvas.getContext("2d");
162+
if (!ctx) return;
163+
164+
const now = performance.now();
165+
const timeSinceLastSample = now - lastSampleTimeRef.current;
166+
167+
// Take a new sample if enough time has passed
168+
if (timeSinceLastSample >= SAMPLE_INTERVAL_MS) {
169+
const dataArray = new Uint8Array(analyser.frequencyBinCount);
170+
analyser.getByteTimeDomainData(dataArray);
171+
172+
// Calculate RMS amplitude (0-1 range)
173+
let sum = 0;
174+
for (let i = 0; i < dataArray.length; i++) {
175+
const normalized = (dataArray[i] - 128) / 128; // -1 to 1
176+
sum += normalized * normalized;
177+
}
178+
const rms = Math.sqrt(sum / dataArray.length);
179+
180+
// Shift samples left and add new one
181+
samplesRef.current.shift();
182+
samplesRef.current.push(rms);
183+
lastSampleTimeRef.current = now;
184+
}
185+
186+
// Clear canvas
187+
ctx.clearRect(0, 0, canvas.width, canvas.height);
188+
189+
// Draw waveform bars
190+
const samples = samplesRef.current;
191+
const barWidth = Math.max(1, Math.floor(canvas.width / samples.length));
192+
const gap = Math.max(1, Math.floor(barWidth * 0.3));
193+
const effectiveBarWidth = barWidth - gap;
194+
const centerY = canvas.height / 2;
195+
196+
ctx.fillStyle = props.color;
197+
198+
for (let i = 0; i < samples.length; i++) {
199+
const amplitude = samples[i];
200+
// Scale amplitude for visibility (boost quiet sounds)
201+
const scaledAmplitude = Math.min(1, amplitude * 3);
202+
const barHeight = Math.max(2, scaledAmplitude * canvas.height * 0.9);
203+
204+
const x = i * barWidth;
205+
const y = centerY - barHeight / 2;
206+
207+
ctx.beginPath();
208+
ctx.roundRect(x, y, effectiveBarWidth, barHeight, 1);
209+
ctx.fill();
210+
}
211+
212+
animationFrameRef.current = requestAnimationFrame(draw);
213+
}, [props.color]);
214+
215+
// Start/stop animation loop
216+
useEffect(() => {
217+
animationFrameRef.current = requestAnimationFrame(draw);
218+
return () => {
219+
if (animationFrameRef.current) {
220+
cancelAnimationFrame(animationFrameRef.current);
221+
}
222+
};
223+
}, [draw]);
224+
225+
return (
226+
<div ref={containerRef} className="h-full w-full">
227+
<canvas
228+
ref={canvasRef}
229+
width={containerWidth}
230+
height={props.height}
231+
className="h-full w-full"
232+
/>
233+
</div>
234+
);
235+
};
236+
130237
/**
131238
* Simple pulsing animation for transcribing state
132239
*/

0 commit comments

Comments
 (0)