@@ -10,16 +10,27 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
1010import type { UIMode } from "@/common/types/mode" ;
1111import 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
2435interface 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+
9396interface 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+ */
99106const 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- ) ;
0 commit comments