Overview
Resonance uses WaveSurfer.js to provide an interactive audio player with waveform visualization. The player supports playback controls, seeking, and real-time progress tracking.
WaveSurfer Hook
The useWaveSurfer hook manages WaveSurfer initialization and state:
import { useWaveSurfer } from '@/features/text-to-speech/hooks/use-wavesurfer' ;
function AudioPlayer ({ url } : { url : string }) {
const {
containerRef , // Attach to waveform container
isPlaying , // Playback state
isReady , // Loading state
currentTime , // Current position (seconds)
duration , // Total duration (seconds)
togglePlayPause , // Play/pause toggle
seekForward , // Skip forward
seekBackward , // Skip backward
} = useWaveSurfer ({
url ,
autoplay: false ,
onReady : () => console . log ( 'Audio ready' ),
onError : ( error ) => console . error ( 'Audio error:' , error ),
});
return (
< div >
< div ref = { containerRef } />
< button onClick = { togglePlayPause } >
{ isPlaying ? 'Pause' : 'Play' }
</ button >
</ div >
);
}
Configuration
WaveSurfer is initialized with Resonance’s design system colors:
const ws = WaveSurfer . create ({
container: containerRef . current ,
waveColor: "#96999D" , // --muted-foreground
progressColor: "#4A8A9A" , // --chart-1 (teal-cyan)
cursorColor: "#4A8A9A" , // --chart-1
cursorWidth: 2 ,
barWidth: 2 ,
barGap: 2 ,
barRadius: 2 ,
barMinHeight: 4 ,
height: "auto" , // Responsive height
normalize: true , // Normalize waveform amplitude
});
Color Customization
Design Tokens
Custom Colors
--muted-foreground: #96999D /* Unplayed waveform */
--chart-1: #4A8A9A /* Progress and cursor */
Override colors in the WaveSurfer config: waveColor : "#custom-color" ,
progressColor : "#custom-progress" ,
cursorColor : "#custom-cursor" ,
Playback Controls
Play/Pause
const { togglePlayPause , isPlaying } = useWaveSurfer ({ url });
< button onClick = { togglePlayPause } >
{ isPlaying ? (
< PauseIcon className = "size-4" />
) : (
< PlayIcon className = "size-4" />
)}
</ button >
Skip Forward/Backward
const { seekForward , seekBackward } = useWaveSurfer ({ url });
// Skip 5 seconds forward
< button onClick = {() => seekForward (5)} >
< FastForwardIcon />
</ button >
// Skip 5 seconds backward
< button onClick = {() => seekBackward (5)} >
< RewindIcon />
</ button >
Default skip duration is 5 seconds if not specified:
const seekForward = useCallback (( seconds = 5 ) => {
const ws = wavesurferRef . current ;
if ( ! ws ) return ;
const newTime = Math . min (
ws . getCurrentTime () + seconds ,
ws . getDuration ()
);
ws . seekTo ( newTime / ws . getDuration ());
}, []);
Progress Tracking
Current Time and Duration
const { currentTime , duration } = useWaveSurfer ({ url });
function formatTime ( seconds : number ) : string {
const mins = Math . floor ( seconds / 60 );
const secs = Math . floor ( seconds % 60 );
return ` ${ mins } : ${ secs . toString (). padStart ( 2 , '0' ) } ` ;
}
< div >
< span >{ formatTime ( currentTime )} </ span >
< span > / </ span >
< span >{ formatTime ( duration )} </ span >
</ div >
Progress Bar
The waveform itself acts as a visual progress indicator:
Gray waveform : Unplayed portion
Teal waveform : Played portion
Cursor line : Current playback position
Loading State
const { isReady } = useWaveSurfer ({ url });
if ( ! isReady ) {
return < Spinner />;
}
return < AudioPlayer />;
Events
WaveSurfer fires several events that the hook manages:
ws . on ( "ready" , () => {
setIsReady ( true );
setDuration ( ws . getDuration ());
// Autoplay if enabled
if ( autoplay ) {
ws . play (). catch (() => {}); // Handle browser autoplay blocks
}
onReady ?.();
});
ws . on ( "play" , () => setIsPlaying ( true ));
ws . on ( "pause" , () => setIsPlaying ( false ));
ws . on ( "finish" , () => setIsPlaying ( false ));
ws . on ( "timeupdate" , ( time ) => setCurrentTime ( time ));
ws . on ( "error" , ( error ) => {
console . error ( "WaveSurfer error:" , error );
onError ?.( new Error ( String ( error )));
});
Autoplay Handling
Browsers often block autoplay without user interaction. The hook catches NotAllowedError gracefully.
if ( autoplay ) {
ws . play (). catch (() => {
// Silent fail - browser blocked autoplay
// User must click play button
});
}
Enable Autoplay
const player = useWaveSurfer ({
url ,
autoplay: true , // Attempt autoplay (may be blocked)
});
Mobile Responsiveness
The waveform adapts to mobile screens:
import { useIsMobile } from '@/hooks/use-mobile' ;
function useWaveSurfer ({ url , autoplay , onReady , onError }) {
const isMobile = useIsMobile ();
useEffect (() => {
// Re-initialize on mobile/desktop change
// Adjusts bar width and spacing
}, [ url , autoplay , onReady , onError , isMobile ]);
}
Cleanup
WaveSurfer instances are properly cleaned up:
useEffect (() => {
const ws = WaveSurfer . create ({ /* ... */ });
wavesurferRef . current = ws ;
let destroyed = false ;
ws . load ( url ). catch (( error ) => {
if ( destroyed ) return ; // Ignore errors after cleanup
onError ?.( new Error ( String ( error )));
});
return () => {
destroyed = true ;
ws . destroy (); // Clean up WaveSurfer instance
};
}, [ url , autoplay , onReady , onError , isMobile ]);
Complete Example
Full audio player implementation:
import { useWaveSurfer } from '@/features/text-to-speech/hooks/use-wavesurfer' ;
import { Play , Pause , SkipBack , SkipForward } from 'lucide-react' ;
import { Button } from '@/components/ui/button' ;
interface AudioPlayerProps {
url : string ;
autoplay ?: boolean ;
}
export function AudioPlayer ({ url , autoplay = false } : AudioPlayerProps ) {
const {
containerRef ,
isPlaying ,
isReady ,
currentTime ,
duration ,
togglePlayPause ,
seekForward ,
seekBackward ,
} = useWaveSurfer ({
url ,
autoplay ,
onReady : () => console . log ( 'Audio loaded' ),
onError : ( error ) => console . error ( 'Playback error:' , error ),
});
const formatTime = ( seconds : number ) => {
const mins = Math . floor ( seconds / 60 );
const secs = Math . floor ( seconds % 60 );
return ` ${ mins } : ${ secs . toString (). padStart ( 2 , '0' ) } ` ;
};
if ( ! isReady ) {
return (
< div className = "flex items-center justify-center p-8" >
< div className = "animate-spin" > Loading ...</ div >
</ div >
);
}
return (
< div className = "space-y-4" >
{ /* Waveform visualization */ }
< div ref = { containerRef } className = "w-full" />
{ /* Playback controls */ }
< div className = "flex items-center justify-between" >
< div className = "flex items-center gap-2" >
< Button
size = "icon-sm"
variant = "ghost"
onClick = {() => seekBackward (5)}
title = "Skip backward 5s"
>
< SkipBack className = "size-4" />
</ Button >
< Button
size = "icon"
variant = "default"
onClick = { togglePlayPause }
title = {isPlaying ? 'Pause' : 'Play' }
>
{ isPlaying ? (
< Pause className = "size-5" />
) : (
< Play className = "size-5" />
)}
</ Button >
< Button
size = "icon-sm"
variant = "ghost"
onClick = {() => seekForward (5)}
title = "Skip forward 5s"
>
< SkipForward className = "size-4" />
</ Button >
</ div >
{ /* Time display */ }
< div className = "text-sm text-muted-foreground" >
{ formatTime ( currentTime )} / { formatTime ( duration )}
</ div >
</ div >
</ div >
);
}
Keyboard Controls
WaveSurfer doesn’t include built-in keyboard controls. Implement shortcuts using event listeners if needed.
useEffect (() => {
const handleKeyPress = ( e : KeyboardEvent ) => {
if ( e . code === 'Space' ) {
e . preventDefault ();
togglePlayPause ();
} else if ( e . code === 'ArrowLeft' ) {
e . preventDefault ();
seekBackward ( 5 );
} else if ( e . code === 'ArrowRight' ) {
e . preventDefault ();
seekForward ( 5 );
}
};
window . addEventListener ( 'keydown' , handleKeyPress );
return () => window . removeEventListener ( 'keydown' , handleKeyPress );
}, [ togglePlayPause , seekForward , seekBackward ]);
Advanced Features
Click to Seek
WaveSurfer supports clicking the waveform to seek:
// Built-in: Click anywhere on the waveform to jump to that position
WaveSurfer . create ({
normalize: true , // Scale waveform to container height
});
Normalization ensures the waveform fills the container regardless of audio volume.
Multiple Playback Speeds
const wavesurfer = wavesurferRef . current ;
if ( wavesurfer ) {
wavesurfer . setPlaybackRate ( 1.5 ); // 1.5x speed
}
Troubleshooting
Verify the URL is correct and accessible
Check CORS headers if loading cross-origin audio
Ensure audio format is supported by the browser
Browsers block autoplay without user interaction
Use autoplay: false and require manual play button
Or mute audio initially (muted: true option)
Ensure the cleanup function calls ws.destroy()
Don’t create multiple instances without destroying old ones