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" ;
87import { Loader2 } from "lucide-react" ;
98import { cn } from "@/common/lib/utils" ;
109import { 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
2524interface RecordingOverlayProps {
2625 state : VoiceInputState ;
@@ -32,38 +31,9 @@ interface RecordingOverlayProps {
3231export 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