Skip to content

Commit a549122

Browse files
committed
refactor: clean up recording overlay code, add roundRect fallback
- Deduplicate mode-based styling with lookup tables - Extract RecordingHints component for keyboard shortcuts - Simplify VoiceInputButton color logic - Add roundRect fallback for older browser compatibility - Improve code organization and comments
1 parent e277d25 commit a549122

File tree

2 files changed

+92
-108
lines changed

2 files changed

+92
-108
lines changed

src/browser/components/ChatInput/RecordingOverlay.tsx

Lines changed: 66 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,27 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
1010
import type { UIMode } from "@/common/types/mode";
1111
import type { VoiceInputState } from "@/browser/hooks/useVoiceInput";
1212

13-
// Mode color values for the visualizer (CSS var values from globals.css)
14-
const MODE_COLORS = {
15-
plan: "hsl(210, 70%, 55%)", // Slightly lighter than --color-plan-mode for visibility
16-
exec: "hsl(268, 94%, 65%)", // Slightly lighter than --color-exec-mode for visibility
17-
} as const;
13+
/** Canvas fill colors for the waveform (slightly lighter than CSS vars for visibility) */
14+
const MODE_COLORS: Record<UIMode, string> = {
15+
plan: "hsl(210, 70%, 55%)",
16+
exec: "hsl(268, 94%, 65%)",
17+
};
18+
19+
/** Tailwind classes for recording state, keyed by mode */
20+
const RECORDING_CLASSES: Record<UIMode, string> = {
21+
plan: "cursor-pointer border-plan-mode bg-plan-mode/10",
22+
exec: "cursor-pointer border-exec-mode bg-exec-mode/10",
23+
};
1824

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
25+
const TEXT_CLASSES: Record<UIMode, string> = {
26+
plan: "text-plan-mode-light",
27+
exec: "text-exec-mode-light",
28+
};
29+
30+
// Waveform shows last 10 seconds of audio, sampled every 50ms (200 samples)
31+
const WINDOW_DURATION_MS = 10_000;
32+
const SAMPLE_INTERVAL_MS = 50;
33+
const NUM_SAMPLES = WINDOW_DURATION_MS / SAMPLE_INTERVAL_MS;
2334

2435
interface RecordingOverlayProps {
2536
state: VoiceInputState;
@@ -32,16 +43,9 @@ export const RecordingOverlay: React.FC<RecordingOverlayProps> = (props) => {
3243
const isRecording = props.state === "recording";
3344
const isTranscribing = props.state === "transcribing";
3445

35-
const modeColor = MODE_COLORS[props.mode];
36-
37-
// Border and background classes based on state
3846
const containerClasses = cn(
3947
"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",
40-
isRecording
41-
? props.mode === "plan"
42-
? "cursor-pointer border-plan-mode bg-plan-mode/10"
43-
: "cursor-pointer border-exec-mode bg-exec-mode/10"
44-
: "cursor-wait border-amber-500 bg-amber-500/10"
48+
isRecording ? RECORDING_CLASSES[props.mode] : "cursor-wait border-amber-500 bg-amber-500/10"
4549
);
4650

4751
return (
@@ -52,63 +56,66 @@ export const RecordingOverlay: React.FC<RecordingOverlayProps> = (props) => {
5256
className={containerClasses}
5357
aria-label={isRecording ? "Stop recording" : "Transcribing..."}
5458
>
55-
{/* Visualizer / Animation Area */}
5659
<div className="flex h-8 w-full items-center justify-center">
5760
{isRecording && props.mediaRecorder ? (
58-
<SlidingWaveform mediaRecorder={props.mediaRecorder} color={modeColor} height={32} />
61+
<SlidingWaveform
62+
mediaRecorder={props.mediaRecorder}
63+
color={MODE_COLORS[props.mode]}
64+
height={32}
65+
/>
5966
) : (
60-
<TranscribingAnimation />
67+
<Loader2 className="h-5 w-5 animate-spin text-amber-500" />
6168
)}
6269
</div>
6370

64-
{/* Status Text */}
6571
<span
6672
className={cn(
6773
"text-xs font-medium",
68-
isRecording
69-
? props.mode === "plan"
70-
? "text-plan-mode-light"
71-
: "text-exec-mode-light"
72-
: "text-amber-500"
74+
isRecording ? TEXT_CLASSES[props.mode] : "text-amber-500"
7375
)}
7476
>
75-
{isRecording ? (
76-
<>
77-
<span className="opacity-70">space</span> send ·{" "}
78-
<span className="opacity-70">{formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)}</span> review
79-
· <span className="opacity-70">esc</span> cancel
80-
</>
81-
) : (
82-
"Transcribing..."
83-
)}
77+
{isRecording ? <RecordingHints /> : "Transcribing..."}
8478
</span>
8579
</button>
8680
);
8781
};
8882

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-
*/
83+
/** Keyboard hint display for recording state */
84+
const RecordingHints: React.FC = () => (
85+
<>
86+
<span className="opacity-70">space</span> send ·{" "}
87+
<span className="opacity-70">{formatKeybind(KEYBINDS.TOGGLE_VOICE_INPUT)}</span> review ·{" "}
88+
<span className="opacity-70">esc</span> cancel
89+
</>
90+
);
91+
92+
// =============================================================================
93+
// SlidingWaveform - Canvas-based amplitude visualization
94+
// =============================================================================
95+
9396
interface SlidingWaveformProps {
9497
mediaRecorder: MediaRecorder;
9598
color: string;
9699
height: number;
97100
}
98101

102+
/**
103+
* Renders a sliding window of audio amplitude over time.
104+
* New samples appear on the right and scroll left as time passes.
105+
*/
99106
const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
100107
const canvasRef = useRef<HTMLCanvasElement>(null);
101108
const containerRef = useRef<HTMLDivElement>(null);
102109
const [containerWidth, setContainerWidth] = useState(600);
103110

104-
// Audio analysis refs (persist across renders)
111+
// Audio analysis state (refs to avoid re-renders)
105112
const audioContextRef = useRef<AudioContext | null>(null);
106113
const analyserRef = useRef<AnalyserNode | null>(null);
107114
const samplesRef = useRef<number[]>(new Array<number>(NUM_SAMPLES).fill(0));
108115
const animationFrameRef = useRef<number>(0);
109116
const lastSampleTimeRef = useRef<number>(0);
110117

111-
// Measure container width
118+
// Track container width for responsive canvas
112119
useLayoutEffect(() => {
113120
const container = containerRef.current;
114121
if (!container) return;
@@ -118,14 +125,13 @@ const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
118125
setContainerWidth(entry.contentRect.width);
119126
}
120127
});
121-
122128
observer.observe(container);
123129
setContainerWidth(container.offsetWidth);
124130

125131
return () => observer.disconnect();
126132
}, []);
127133

128-
// Set up audio analysis
134+
// Initialize Web Audio API analyser
129135
useEffect(() => {
130136
const stream = props.mediaRecorder.stream;
131137
if (!stream) return;
@@ -140,8 +146,6 @@ const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
140146

141147
audioContextRef.current = audioContext;
142148
analyserRef.current = analyser;
143-
144-
// Reset samples when starting
145149
samplesRef.current = new Array<number>(NUM_SAMPLES).fill(0);
146150
lastSampleTimeRef.current = performance.now();
147151

@@ -152,7 +156,7 @@ const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
152156
};
153157
}, [props.mediaRecorder]);
154158

155-
// Animation loop - sample audio and render
159+
// Animation loop: sample audio amplitude and render bars
156160
const draw = useCallback(() => {
157161
const canvas = canvasRef.current;
158162
const analyser = analyserRef.current;
@@ -161,73 +165,60 @@ const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
161165
const ctx = canvas.getContext("2d");
162166
if (!ctx) return;
163167

168+
// Sample audio at fixed intervals
164169
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) {
170+
if (now - lastSampleTimeRef.current >= SAMPLE_INTERVAL_MS) {
169171
const dataArray = new Uint8Array(analyser.frequencyBinCount);
170172
analyser.getByteTimeDomainData(dataArray);
171173

172-
// Calculate RMS amplitude (0-1 range)
174+
// Calculate RMS (root mean square) amplitude
173175
let sum = 0;
174176
for (const sample of dataArray) {
175-
const normalized = (sample - 128) / 128; // -1 to 1
177+
const normalized = (sample - 128) / 128;
176178
sum += normalized * normalized;
177179
}
178180
const rms = Math.sqrt(sum / dataArray.length);
179181

180-
// Shift samples left and add new one
181182
samplesRef.current.shift();
182183
samplesRef.current.push(rms);
183184
lastSampleTimeRef.current = now;
184185
}
185186

186-
// Clear canvas
187+
// Render bars
187188
ctx.clearRect(0, 0, canvas.width, canvas.height);
188189

189-
// Draw waveform bars - calculate to fill full width
190190
const samples = samplesRef.current;
191191
const numBars = samples.length;
192-
193-
// Calculate bar and gap sizes to fill exactly the canvas width
194-
// We want: numBars * barWidth + (numBars - 1) * gap = canvasWidth
195-
// With gap = barWidth * 0.4, we get:
196-
// numBars * barWidth + (numBars - 1) * 0.4 * barWidth = canvasWidth
197-
// barWidth * (numBars + 0.4 * numBars - 0.4) = canvasWidth
198-
// barWidth = canvasWidth / (1.4 * numBars - 0.4)
199-
const totalWidth = canvas.width;
200-
const barWidth = totalWidth / (1.4 * numBars - 0.4);
192+
// Bar sizing: bars fill full width with 40% gap ratio
193+
const barWidth = canvas.width / (1.4 * numBars - 0.4);
201194
const gap = barWidth * 0.4;
202195
const centerY = canvas.height / 2;
203196

204197
ctx.fillStyle = props.color;
205198

206199
for (let i = 0; i < numBars; i++) {
207-
const amplitude = samples[i];
208-
// Scale amplitude for visibility (boost quiet sounds)
209-
const scaledAmplitude = Math.min(1, amplitude * 3);
200+
const scaledAmplitude = Math.min(1, samples[i] * 3); // Boost for visibility
210201
const barHeight = Math.max(2, scaledAmplitude * canvas.height * 0.9);
211-
212202
const x = i * (barWidth + gap);
213203
const y = centerY - barHeight / 2;
214204

215205
ctx.beginPath();
216-
ctx.roundRect(x, y, barWidth, barHeight, 1);
206+
// roundRect fallback for older browsers (though Electron 38+ supports it)
207+
if (ctx.roundRect) {
208+
ctx.roundRect(x, y, barWidth, barHeight, 1);
209+
} else {
210+
ctx.rect(x, y, barWidth, barHeight);
211+
}
217212
ctx.fill();
218213
}
219214

220215
animationFrameRef.current = requestAnimationFrame(draw);
221216
}, [props.color]);
222217

223-
// Start/stop animation loop
218+
// Run animation loop
224219
useEffect(() => {
225220
animationFrameRef.current = requestAnimationFrame(draw);
226-
return () => {
227-
if (animationFrameRef.current) {
228-
cancelAnimationFrame(animationFrameRef.current);
229-
}
230-
};
221+
return () => cancelAnimationFrame(animationFrameRef.current);
231222
}, [draw]);
232223

233224
return (
@@ -241,12 +232,3 @@ const SlidingWaveform: React.FC<SlidingWaveformProps> = (props) => {
241232
</div>
242233
);
243234
};
244-
245-
/**
246-
* Simple pulsing animation for transcribing state
247-
*/
248-
const TranscribingAnimation: React.FC = () => (
249-
<div className="flex items-center gap-2 text-amber-500">
250-
<Loader2 className="h-5 w-5 animate-spin" />
251-
</div>
252-
);

src/browser/components/ChatInput/VoiceInputButton.tsx

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,38 +21,40 @@ interface VoiceInputButtonProps {
2121
mode: UIMode;
2222
}
2323

24-
function getRecordingColorClass(mode: UIMode): string {
25-
return mode === "plan"
26-
? "text-plan-mode-light animate-pulse"
27-
: "text-exec-mode-light animate-pulse";
28-
}
24+
/** Color classes for each voice input state */
25+
const STATE_COLORS: Record<VoiceInputState, string> = {
26+
idle: "text-muted/50 hover:text-muted",
27+
recording: "", // Set dynamically based on mode
28+
transcribing: "text-amber-500",
29+
};
2930

30-
const STATE_CONFIG: Record<VoiceInputState, { label: string; colorClass: string }> = {
31-
idle: { label: "Voice input", colorClass: "text-muted/50 hover:text-muted" },
32-
recording: { label: "Stop recording", colorClass: "" }, // handled dynamically
33-
transcribing: { label: "Transcribing...", colorClass: "text-amber-500" },
31+
const RECORDING_COLORS: Record<UIMode, string> = {
32+
plan: "text-plan-mode-light animate-pulse",
33+
exec: "text-exec-mode-light animate-pulse",
3434
};
3535

36+
function getColorClass(state: VoiceInputState, mode: UIMode): string {
37+
return state === "recording" ? RECORDING_COLORS[mode] : STATE_COLORS[state];
38+
}
39+
3640
export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
3741
if (!props.shouldShowUI) return null;
3842

3943
const needsHttps = props.requiresSecureContext;
4044
const needsApiKey = !needsHttps && !props.isApiKeySet;
41-
const isDisabledReason = needsHttps || needsApiKey;
45+
const isDisabled = needsHttps || needsApiKey;
46+
47+
const label = isDisabled
48+
? needsHttps
49+
? "Voice input (requires HTTPS)"
50+
: "Voice input (requires OpenAI API key)"
51+
: props.state === "recording"
52+
? "Stop recording"
53+
: props.state === "transcribing"
54+
? "Transcribing..."
55+
: "Voice input";
4256

43-
const stateConfig = STATE_CONFIG[props.state];
44-
const { label, colorClass } = isDisabledReason
45-
? {
46-
label: needsHttps
47-
? "Voice input (requires HTTPS)"
48-
: "Voice input (requires OpenAI API key)",
49-
colorClass: "text-muted/50",
50-
}
51-
: {
52-
label: stateConfig.label,
53-
colorClass:
54-
props.state === "recording" ? getRecordingColorClass(props.mode) : stateConfig.colorClass,
55-
};
57+
const colorClass = isDisabled ? "text-muted/50" : getColorClass(props.state, props.mode);
5658

5759
const Icon = props.state === "transcribing" ? Loader2 : Mic;
5860
const isTranscribing = props.state === "transcribing";
@@ -62,7 +64,7 @@ export const VoiceInputButton: React.FC<VoiceInputButtonProps> = (props) => {
6264
<button
6365
type="button"
6466
onClick={props.onToggle}
65-
disabled={(props.disabled ?? false) || isTranscribing || isDisabledReason}
67+
disabled={(props.disabled ?? false) || isTranscribing || isDisabled}
6668
aria-label={label}
6769
aria-pressed={props.state === "recording"}
6870
className={cn(

0 commit comments

Comments
 (0)