Waveform-playlist is a monorepo organized with pnpm workspaces. It's a multitrack Web Audio editor and player with canvas-based waveform visualizations.
Stack: React + Tone.js + styled-components (v5 released)
waveform-playlist/
├── packages/ # Workspace packages (modular architecture)
│ ├── annotations/ # 📦 OPTIONAL: Annotation components & hooks
│ ├── browser/ # Main React package (provider, hooks, components)
│ ├── core/ # Core types and interfaces
│ ├── loaders/ # Audio file loaders
│ ├── media-element-playout/ # Audio playback (HTMLAudioElement, no Tone.js)
│ ├── engine/ # Framework-agnostic timeline engine
│ ├── playout/ # Audio playback (Tone.js wrapper)
│ ├── recording/ # 📦 OPTIONAL: Audio recording with AudioWorklet
│ ├── spectrogram/ # 📦 OPTIONAL: FFT computation, worker rendering, color maps
│ ├── ui-components/ # Reusable React UI components
│ └── webaudio-peaks/ # Waveform peak generation
│
└── website/ # Docusaurus documentation site
├── src/
│ ├── components/examples/ # React example components (16 examples)
│ │ ├── MinimalExample.tsx
│ │ ├── StemTracksExample.tsx
│ │ ├── StereoExample.tsx
│ │ ├── EffectsExample.tsx
│ │ ├── FadesExample.tsx
│ │ ├── NewTracksExample.tsx
│ │ ├── MultiClipExample.tsx
│ │ ├── AnnotationsExample.tsx
│ │ ├── RecordingExample.tsx
│ │ ├── FlexibleApiExample.tsx
│ │ ├── StylingExample.tsx
│ │ ├── WaveformDataExample.tsx # BBC peaks demo
│ │ ├── MediaElementExample.tsx # HTMLAudioElement streaming
│ │ ├── MirSpectrogramExample.tsx # Spectrogram visualization
│ │ ├── MobileAnnotationsExample.tsx # Mobile-optimized annotations
│ │ └── MobileMultiClipExample.tsx # Mobile-optimized multi-clip
│ ├── pages/examples/ # Example page wrappers
│ ├── hooks/ # Docusaurus-specific hooks
│ │ └── useDocusaurusTheme.ts
│ └── theme/ # Theme customizations
│ └── Root.tsx # Radix Themes provider
├── static/media/audio/ # Audio and peaks files
│ ├── *.mp3 # Audio files
│ └── *-8bit.dat # BBC pre-computed peaks
└── docusaurus.config.ts # Webpack aliases for workspace packages
- Purpose: Core TypeScript interfaces and types
- Exports: AudioClip, ClipTrack, Timeline interfaces, factory functions
- Dependencies: None (pure types)
- Used by: All other packages
Important Architectural Decision: Sample-Based Representation
All clip positions and durations are stored as integer sample counts (not floating-point seconds):
interface AudioClip {
id: string;
audioBuffer: AudioBuffer;
startSample: number; // Position on timeline (samples)
durationSamples: number; // Clip duration (samples)
offsetSamples: number; // Trim start position (samples)
// ...
}Why samples instead of seconds?
- ✅ Eliminates floating-point precision errors
- ✅ Perfect pixel alignment in rendering (no 1-pixel gaps)
- ✅ Mathematically exact calculations (all integers)
- ✅ No precision loss when converting between time/samples/pixels
User-Facing API:
Users can still create clips using seconds via createClipFromSeconds() helper:
const clip = createClipFromSeconds({
audioBuffer,
startTime: 5.0, // Converted to samples internally
duration: 10.0,
offset: 2.5,
});
// Internally stored as: startSample, durationSamples, offsetSamplesLocation: packages/core/src/types/clip.ts
- Purpose: Generate waveform visualization data from audio buffers
- Exports: Peak data structures, peak generation functions
- Key concept: Converts AudioBuffer → peak data for canvas rendering
- Dependencies: Core
- Purpose: Framework-agnostic timeline engine — stateful
PlaylistEngineclass with event emitter - Architecture: Two layers — pure operations functions + stateful class
operations/clipOperations.ts— Drag constraints, boundary trim, splitoperations/viewportOperations.ts— Bounds, chunks, scroll thresholdoperations/timelineOperations.ts— Duration, zoom, seekPlaylistEngine.ts— Composes operations with state + events
- Build: tsup (not vite) —
pnpm typecheck && tsup. Outputs ESM + CJS + DTS. - Testing: vitest unit tests in
src/__tests__/. Run withnpx vitest runfrompackages/engine/. - Key Types:
PlayoutAdapter(pluggable audio backend),EngineState(state snapshot),EngineEvents(statechange, timeupdate, play/pause/stop) - State Ownership: Engine owns selection, loop, selectedTrackId, zoom, masterVolume, and tracks (for clip mutations). React subscribes to
statechangeevents. - Clip Mutations:
moveClip(),trimClip(),splitClip()update internal tracks, sync adapter viaadapter.setTracks(), and emitstatechange. The browser package's provider mirrors updated tracks back to the parent viaonTracksChange. - Dependencies: Only peer dependency is
@waveform-playlist/core - No React, no Tone.js — zero framework dependencies
- Purpose: Reusable React components for waveform UI
- Tech: React, styled-components
- Structure:
src/ ├── components/ # Public components │ ├── TimeInput.tsx │ ├── SelectionTimeInputs.tsx │ ├── Playlist.tsx │ ├── Track.tsx │ ├── Clip.tsx │ ├── ClipHeader.tsx │ ├── Playhead.tsx │ ├── Selection.tsx │ ├── AnnotationBox.tsx │ └── TrackControls/ ├── contexts/ # React contexts │ ├── ScrollViewport.tsx # Virtual scrolling: viewport state, chunk visibility │ ├── ClipViewportOrigin.tsx # Clip pixel offset for correct chunk culling │ └── ... # Theme, playlist info, playout contexts ├── utils/ # Utilities (time formatting, conversions) ├── styled/ # Shared styled components │ ├── CheckboxStyles.tsx # Checkbox, label, wrapper │ └── ButtonStyles.tsx # ControlButton with variants ├── wfpl-theme.ts # Theme interface and default theme ├── styled.d.ts # styled-components type augmentation └── index.tsx # Public API - Shared Styled Components:
- Pattern: Extract commonly duplicated styled components into
styled/directory - CheckboxStyles: Shared checkbox components used across all checkbox controls
CheckboxWrapper,StyledCheckbox,CheckboxLabel- ~60% code reduction in checkbox components
- ButtonStyles: Shared button component with color variants
ControlButton- Large control button (primary/success/info variants)- Separate from
TrackControls/Button(compact UI button)
- Benefits: DRY principle, consistent styling, easier maintenance
- Pattern: Extract commonly duplicated styled components into
- Theming System:
- Architecture: Centralized theme defined in
wfpl-theme.ts, provided viaWaveformPlaylistProvider→ styled-componentsThemeProvider→useTheme()hook - Theme Interface:
WaveformPlaylistThemedefines all visual properties- Waveform colors:
waveOutlineColor,waveFillColor,waveProgressColor - Timescale:
timeColor,timescaleBackgroundColor - Playback UI:
playheadColor,selectionColor - Clip headers:
clipHeaderBackgroundColor,clipHeaderBorderColor,clipHeaderTextColor
- Waveform colors:
- Default Theme: Exported
defaultThemeobject with sensible defaults - Type Safety:
styled.d.tsextends styled-components'DefaultThemefor autocomplete - Pattern: Components access theme via
props.theme.propertyNamein styled componentsconst Header = styled.div` background: ${props => props.theme.clipHeaderBackgroundColor}; color: ${props => props.theme.clipHeaderTextColor}; `;
- Benefits:
- ✅ Single source of truth for all visual properties
- ✅ TypeScript autocomplete for theme properties
- ✅ No prop drilling (colors from context, not props)
- ✅ Consistent theming across all components
- Location:
packages/ui-components/src/wfpl-theme.ts,packages/ui-components/src/styled.d.ts
- Architecture: Centralized theme defined in
- Key components:
Playlist- Main container componentTrack- Individual waveform trackClip- Audio clip with optional draggable headerClipHeader- Draggable title bar for clips (uses theme)ClipBoundary- Trim handles (left/right edges)Channel/SmartChannel- Waveform rendering with device pixel ratioSpectrogramChannel- Spectrogram canvas rendering (chunked)SpectrogramLabels- Frequency axis labelsFadeOverlay- Fade in/out visualizationLoopRegion- Loop region overlayTimeInput/SelectionTimeInputs- Time value inputsTimeScale/SmartScale- Timeline rulerTimeFormatSelect- Time format dropdownPlayhead- Playback position indicatorSelection- Selection overlayAudioPosition- Current time displayMasterVolumeControl- Volume sliderAutomaticScrollCheckbox- Auto-scroll toggleTrackMenu- Per-track dropdown menuTrackControls/- Mute, solo, volume, pan controls
- Virtual Scrolling:
ScrollViewportProviderwraps the scrollable container, tracks scroll position viauseSyncExternalStorewith RAF-throttled listener + ResizeObserveruseScrollViewport()returns full viewport state;useScrollViewportSelector()for fine-grained subscriptionsuseVisibleChunkIndices(totalWidth, chunkWidth, originX?)returns memoized array of visible chunk indices.originXconverts local chunk coordinates to global viewport spaceClipViewportOriginProviderwraps each clip's channels, supplying the clip's pixelleftoffset. Without it, clips not at position 0 get incorrect chunk culling- 1.5x viewport overscan buffer on each side; 100px scroll threshold skips updates that won't affect chunk visibility
- Components affected:
TimeScale,Channel,SpectrogramChannel— all use absolute positioning (left: chunkIndex * 1000px)
- Purpose: Main React package — provider, hooks, components, effects
- Structure:
src/ ├── index.tsx # Main entry point + API exports ├── WaveformPlaylistContext.tsx # Context provider (flexible API) ├── MediaElementPlaylistContext.tsx # Context provider (HTMLAudioElement) ├── AnnotationIntegrationContext.tsx # Optional annotation integration ├── SpectrogramIntegrationContext.tsx # Optional spectrogram integration ├── hooks/ # Custom hooks │ ├── useAnimationFrameLoop.ts # Shared rAF loop for providers │ ├── useAnnotationDragHandlers.ts # Annotation drag logic │ ├── useAnnotationKeyboardControls.ts # Annotation navigation & editing │ ├── useAudioEffects.ts # Audio effects management │ ├── useAudioTracks.ts # Track loading and management │ ├── useClipDragHandlers.ts # Clip drag/move/trim (delegates to engine) │ ├── useClipSplitting.ts # Split clips at playhead (delegates to engine) │ ├── useDragSensors.ts # @dnd-kit sensor config │ ├── useDynamicEffects.ts # Master effects chain │ ├── useDynamicTracks.ts # Runtime track additions (placeholder-then-replace) │ ├── useExportWav.ts # WAV export via Tone.Offline │ ├── useKeyboardShortcuts.ts # Flexible keyboard shortcut system │ ├── useLoopState.ts # Loop state (engine delegation + onEngineState) │ ├── useMasterVolume.ts # Master volume (engine delegation + onEngineState) │ ├── usePlaybackShortcuts.ts # Default playback shortcuts │ ├── useSelectedTrack.ts # Selected track ID (engine delegation + onEngineState) │ ├── useSelectionState.ts # Selection state (engine delegation + onEngineState) │ ├── useTimeFormat.ts # Time formatting │ ├── useTrackDynamicEffects.ts # Per-track effects │ ├── useWaveformDataCache.ts # Web worker peak generation cache │ └── useZoomControls.ts # Zoom state (engine delegation + onEngineState) ├── components/ # React components │ ├── PlaylistVisualization.tsx # Main waveform + track rendering │ ├── Waveform.tsx # Public waveform component │ ├── PlaybackControls.tsx # Play/Pause/Stop buttons │ ├── ZoomControls.tsx # Zoom in/out buttons │ ├── ContextualControls.tsx # Context-aware wrappers │ └── index.tsx # Component exports ├── effects/ # Audio effects system │ ├── effectDefinitions.ts # 20 Tone.js effect definitions │ ├── effectFactory.ts # Effect instance creation │ └── index.ts ├── workers/ # Web workers │ └── peaksWorker.ts # Inline Blob worker for peak generation └── waveformDataLoader.ts # BBC waveform-data.js support - Build: Vite + tsup
- Purpose: Audio playback abstraction using Tone.js + Global AudioContext management
- Key class:
TonePlayout - Global AudioContext:
- Single AudioContext shared across the entire application
- Created on first use, never closed during app lifetime
- Used by Tone.js (configured via
Tone.setContext()) - Used by recording (
useRecordinghook) - Used by monitoring (
useMicrophoneLevelhook) - Exports:
getGlobalAudioContext(),resumeGlobalAudioContext(),getGlobalAudioContextState(),closeGlobalAudioContext()
- Features:
- Play/pause/stop control
- Seeking
- Timed segment playback
- Track mixing
- Dependencies: Tone.js, Core
- Location:
packages/playout/src/audioContext.ts
- Purpose: Lightweight audio playback using HTMLAudioElement (no Tone.js dependency)
- Key class:
MediaElementPlayout - Use Cases:
- Large audio files - streams without downloading entire file
- Pre-computed peaks - use audiowaveform server-side
- Playback rate control - 0.5x to 2.0x with pitch preservation
- Single-track playback - simpler API, smaller bundle
- Features:
- Play/pause/stop control
- Seeking
- Playback rate adjustment with pitch preservation
- currentTime tracking via animation frame
- When to Use:
- Choose
MediaElementPlaylistProviderfor streaming large files with pre-computed peaks - Choose
WaveformPlaylistProvider(Tone.js) for multi-track mixing, effects, recording
- Choose
- Dependencies: None (pure HTMLAudioElement)
- Location:
packages/media-element-playout/src/ - Browser Integration:
MediaElementPlaylistProvider- Context provider for media element playbackMediaElementWaveform- Single-track waveform component- Hooks:
useMediaElementAnimation,useMediaElementControls,useMediaElementState,useMediaElementData
- Example:
website/src/pages/examples/media-element.tsx
- Purpose: Load audio files from various sources
- Exports: Audio loading utilities
- Dependencies: Core
The browser package includes utilities for loading pre-computed waveform data in BBC's waveform-data.js format:
Location: packages/browser/src/waveformDataLoader.ts
Functions:
loadWaveformData(src)- Load .dat or .json waveform file, returns WaveformData instancewaveformDataToPeaks(waveformData, channelIndex)- Convert to our Peaks format (Int8Array/Int16Array)loadPeaksFromWaveformData(src, channelIndex)- Combined load + convertgetWaveformDataMetadata(src)- Get metadata without full conversion
Generating BBC Peaks:
# Install audiowaveform (macOS)
brew install audiowaveform
# Generate 8-bit peaks (smaller files, ~11KB for 30s audio)
audiowaveform -i audio.mp3 -o peaks-8bit.dat -z 256 -b 8
# Generate 16-bit peaks (higher precision, ~22KB)
audiowaveform -i audio.mp3 -o peaks-16bit.dat -z 256 -b 16
# Generate stereo/multi-channel (Version 2 format)
audiowaveform -i audio.mp3 -o peaks-stereo.dat -z 256 --split-channelsUse Case: Progressive loading - show waveforms instantly (~44KB for 4 tracks) while audio loads in background (~1.9MB)
Example: website/src/components/examples/WaveformDataExample.tsx
- Type: Optional package (install separately)
- Purpose: Complete annotation support for time-synchronized text segments
- Tech: React, styled-components, custom hooks
- Install:
npm install @waveform-playlist/annotations - Structure:
src/ ├── components/ # React components │ ├── Annotation.tsx │ ├── AnnotationBox.tsx │ ├── AnnotationBoxesWrapper.tsx │ ├── AnnotationsTrack.tsx │ ├── AnnotationText.tsx │ ├── ContinuousPlayCheckbox.tsx │ └── LinkEndpointsCheckbox.tsx ├── hooks/ # Custom hooks │ └── useAnnotationControls.ts ├── types/ # TypeScript types │ └── index.ts ├── parsers/ # Import/export (Aeneas JSON) │ └── aeneas.ts └── index.ts # Public exports - Key Hook:
useAnnotationControls- Manages
continuousPlayandlinkEndpointsstate - Provides
updateAnnotationBoundaries()with complex logic:- Linked endpoints (boundaries move together when touching)
- Collision detection (prevents overlap)
- Cascading updates (multiple annotations adjust together)
- Manages
- Components:
- Visual: AnnotationBox, AnnotationBoxesWrapper, AnnotationsTrack
- Text: Annotation, AnnotationText
- Controls: ContinuousPlayCheckbox, LinkEndpointsCheckbox
- Peer Dependencies: React ^18.0.0, styled-components ^6.0.0
- Bundle Size Impact: ~50KB (only included if installed)
- Use Cases: Subtitle/caption editing, transcripts, audio labeling
- Documentation: See
website/docs/getting-started/installation.md
-
Type: Optional package (install separately)
-
Purpose: Audio recording support using AudioWorklet
-
Tech: React, styled-components, AudioWorklet
-
Install:
npm install @waveform-playlist/recording -
Structure:
src/ ├── components/ # React components │ ├── RecordButton.tsx │ ├── MicrophoneSelector.tsx │ ├── RecordingIndicator.tsx │ └── VUMeter.tsx ├── hooks/ # Custom hooks │ ├── useRecording.ts │ ├── useMicrophoneAccess.ts │ └── useMicrophoneLevel.ts ├── types/ # TypeScript types │ └── index.ts ├── utils/ # Utilities │ ├── peaksGenerator.ts │ └── audioBufferUtils.ts ├── worklet/ # AudioWorklet processor │ └── recording-processor.worklet.ts └── index.ts # Public exports -
Key Architecture:
- MediaStreamSource Per Hook - Each hook creates its own source from Tone's
getContext()- Avoids Firefox cross-context errors when sources/nodes are created in different modules
- Both
useRecordinganduseMicrophoneLevelcreate independent sources from same stream - See CLAUDE.md "MediaStreamSource Per Hook" for details
- Two-System Monitoring:
useMicrophoneLevel- Pre-recording monitoring using Tone.js Meter (60fps)useRecording- During-recording peak calculation in AudioWorklet (~16ms chunks)
- Test Microphone Button - Resumes AudioContext to enable pre-recording level checks
- AudioWorklet Processing - Captures audio samples in worklet thread, sends to main thread
- Duration Timer with Refs - Uses
isRecordingRef/isPausedReffor synchronous checks in animation loop- React state updates are asynchronous, can't be used in
requestAnimationFrameloops - Refs update immediately and can be checked reliably in the animation loop
- React state updates are asynchronous, can't be used in
- MediaStreamSource Per Hook - Each hook creates its own source from Tone's
-
Key Features:
- Global AudioContext - Uses shared global context (same as Tone.js playback)
- Live waveform visualization - Real-time Int16Array peaks (min/max pairs) during recording
- AudioBuffer support - WaveformTrack accepts both URLs and AudioBuffer objects
- Microphone selection - Enumerate and switch between input devices with auto-select first device
- Recording-optimized constraints - Default audio constraints prioritize raw quality and low latency (no echo cancellation, noise suppression, or auto gain; latency: 0)
- VU meter - Real-time RMS level display with peak hold
- Test Microphone - Pre-recording level monitoring before committing to record
-
Hooks:
useRecording- Complete recording lifecycle with AudioWorklet- Returns:
isRecording,isPaused,duration,peaks,audioBuffer,level,peakLevel - Methods:
startRecording(),stopRecording(),pauseRecording(),resumeRecording()
- Returns:
useMicrophoneAccess- Device enumeration and permission handling- Returns:
stream,devices,hasPermission,requestAccess(),error
- Returns:
useMicrophoneLevel- Real-time audio level monitoring with AnalyserNode- Returns:
level,peakLevel,resetPeak()
- Returns:
-
Components:
- Visual: RecordButton, RecordingIndicator (with duration timer), VUMeter
- Controls: MicrophoneSelector
-
Important Patterns:
- Refs in Animation Loops - Use refs for values checked in
requestAnimationFrame:const isRecordingRef = useRef(false); const updateDuration = () => { if (isRecordingRef.current) { // Synchronous check // ... update duration requestAnimationFrame(updateDuration); } };
- AudioWorklet Debugging - console.log in worklets doesn't appear in browser console
- Use
postMessage()to send debug data to main thread - Update UI/document.title to display values
- Use
- Worklet Deployment - Worklet files bundled automatically via tsup
- Build:
pnpm build(createsdist/worklet/recording-processor.worklet.js) - Docusaurus webpack aliases handle module resolution
- Build:
- Try-Catch for Cleanup - Wrap disconnect calls in try-catch for microphone switching:
try { source.disconnect(destination); } catch (e) { // Source may already be disconnected when stream changed }
- Refs in Animation Loops - Use refs for values checked in
-
Peer Dependencies: React ^18.0.0, styled-components ^6.0.0
-
Use Cases: Voice recording, podcast editing, audio capture, live input, microphone testing
-
Example:
website/src/components/examples/RecordingExample.tsx -
Debugging: See
CLAUDE.md→ "Debugging AudioWorklets" section
- Type: Optional package (install separately)
- Purpose: FFT-based spectrogram visualization with worker-based rendering
- Install:
npm install @waveform-playlist/spectrogram - Structure:
src/ ├── SpectrogramProvider.tsx # Provider (fills SpectrogramIntegrationContext) ├── components/ # UI components (menu items, settings modal) ├── computation/ # FFT computation logic ├── worker/ # Web Worker for off-thread rendering ├── styled.d.ts └── index.ts - Integration Pattern:
- Browser package defines
SpectrogramIntegrationContext(nullable) - Spectrogram package provides
SpectrogramProviderthat fills this context - When no provider present, all spectrogram features are skipped (zero runtime cost)
// With spectrogram: <WaveformPlaylistProvider tracks={tracks}> <SpectrogramProvider config={config} colorMap="viridis"> <Waveform /> </SpectrogramProvider> </WaveformPlaylistProvider> // Without spectrogram (no change needed): <WaveformPlaylistProvider tracks={tracks}> <Waveform /> </WaveformPlaylistProvider>
- Browser package defines
- Peer Dependencies: React, @waveform-playlist/browser
- Example:
website/src/components/examples/MirSpectrogramExample.tsx
Flexible API Pattern (Provider + Engine + Primitives):
User Interaction (React Events)
↓
WaveformPlaylistProvider (Split Contexts for Performance)
├─→ PlaybackAnimationContext (60fps updates)
│ └─→ isPlaying, currentTime, currentTimeRef
├─→ PlaylistStateContext (user interactions)
│ └─→ selection, loop, selectedTrackId, annotations, etc.
├─→ PlaylistControlsContext (stable functions)
│ └─→ play(), pause(), zoomIn(), setSelection(), etc.
└─→ PlaylistDataContext (static/infrequent updates)
└─→ duration, audioBuffers, peaksDataArray, isDraggingRef, etc.
↓
├─→ Primitive Components (subscribe to relevant contexts only)
│ ├─→ PlayButton, PauseButton, StopButton
│ ├─→ ZoomInButton, ZoomOutButton
│ ├─→ MasterVolumeControl, TimeFormatSelect
│ └─→ Waveform (with custom track controls)
│
├─→ UI Components (React)
│ └─→ Canvas Rendering (SmartChannel)
│
└─→ PlaylistEngine (state + events)
├─→ Owns: selection, loop, zoom, volume, selectedTrackId, tracks (clip mutations)
├─→ Emits: statechange → provider mirrors into React state
└─→ ToneAdapter (PlayoutAdapter interface)
└─→ TonePlayout (Tone.js)
└─→ Web Audio API
Engine State Flow:
Hook calls engine method (e.g., engine.moveClip())
↓
Engine updates internal state + syncs adapter
↓
Engine emits 'statechange' with EngineState snapshot
↓
Provider's statechange handler:
├─→ Calls each hook's onEngineState() to mirror into React state
└─→ Calls onTracksChange() for track mutations → parent updates tracks prop
Context Splitting Architecture:
The provider uses 4 separate contexts to optimize performance by isolating different update frequencies:
-
PlaybackAnimationContext - High-frequency (60fps)
- Only animation subscribers (Playhead, automatic scroll)
- Prevents re-renders in UI controls
-
PlaylistStateContext - User interactions
- State that changes on user actions
- UI components subscribe here
-
PlaylistControlsContext - Stable functions
- Doesn't cause re-renders when accessed
- All control functions
-
PlaylistDataContext - Static/infrequent
- Audio buffers, duration, sample rate
- Changes rarely after initialization
Key Files:
packages/browser/src/WaveformPlaylistContext.tsx- Context provider (flexible API)packages/browser/src/SpectrogramIntegrationContext.tsx- Optional spectrogram integrationpackages/browser/src/hooks/- Reusable business logicpackages/browser/src/components/- React componentspackages/engine/src/PlaylistEngine.ts- Stateful timeline enginepackages/engine/src/operations/- Pure clip/timeline/viewport operationspackages/ui-components/src/components/Playlist.tsx- UI containerpackages/playout/src/TonePlayout.ts- Audio playback
The WaveformPlaylistProvider uses 4 separate contexts to optimize performance by isolating different update frequencies. This prevents unnecessary re-renders when high-frequency values (like currentTime at 60fps) update.
Architecture:
// 1. High-frequency updates (60fps) - Only animation subscribers
export interface PlaybackAnimationContextValue {
isPlaying: boolean;
currentTime: number;
currentTimeRef: React.RefObject<number>;
}
// 2. User interaction state - UI components (includes engine-mirrored state)
export interface PlaylistStateContextValue {
continuousPlay: boolean;
linkEndpoints: boolean;
annotationsEditable: boolean;
isAutomaticScroll: boolean;
isLoopEnabled: boolean;
annotations: AnnotationData[];
activeAnnotationId: string | null;
selectionStart: number;
selectionEnd: number;
selectedTrackId: string | null;
loopStart: number;
loopEnd: number;
}
// 3. Control functions - Stable, don't cause re-renders
export interface PlaylistControlsContextValue {
play: (startTime?: number, playDuration?: number) => Promise<void>;
pause: () => void;
stop: () => void;
setContinuousPlay: (value: boolean) => void;
setAnnotations: (annotations: AnnotationData[]) => void;
setSelection: (start: number, end: number) => void;
setSelectedTrackId: (trackId: string | null) => void;
zoomIn: () => void;
zoomOut: () => void;
setMasterVolume: (volume: number) => void;
setLoopEnabled: (enabled: boolean) => void;
setLoopRegion: (start: number, end: number) => void;
// ... other controls
}
// 4. Static/infrequent data
export interface PlaylistDataContextValue {
duration: number;
audioBuffers: AudioBuffer[];
peaksDataArray: TrackClipPeaks[];
sampleRate: number;
playoutRef: React.RefObject<PlaylistEngine | null>;
isDraggingRef: React.MutableRefObject<boolean>;
mono: boolean;
// ... other data
}Usage Pattern:
Components subscribe only to the contexts they need:
// Animation component subscribes to high-frequency updates
export const Playhead = () => {
const { currentTime } = usePlaybackAnimation(); // 60fps updates
const { sampleRate, samplesPerPixel } = usePlaylistData(); // Static
// ... render playhead
};
// Control component subscribes to state and controls only
export const ContinuousPlayCheckbox = () => {
const { continuousPlay } = usePlaylistState(); // User interactions
const { setContinuousPlay } = usePlaylistControls(); // Stable functions
// NO re-renders during 60fps animation!
};Benefits:
- ✅ No unnecessary re-renders: Checkboxes don't re-render during animation
- ✅ Stable 60fps: Animation loop runs smoothly without UI thrashing
- ✅ Better performance: Each component updates only when relevant data changes
- ✅ Type-safe: Full TypeScript support with separate interfaces
Location: packages/browser/src/WaveformPlaylistContext.tsx
Documentation: See CLAUDE.md → "Continuous Play Toggle Fix" for detailed implementation
Business logic is extracted into reusable custom hooks that can be used by any component:
Hooks (in packages/browser/src/hooks/):
useAnimationFrameLoop- Shared rAF lifecycle for both playlist providersuseAudioTracks- Declarative track loading (configs-driven)useClipDragHandlers- Clip drag-to-move and boundary trimming (delegates to engine)useClipSplitting- Split clips at playhead (delegates to engine)useAnnotationDragHandlers- Annotation drag logicuseAnnotationKeyboardControls- Annotation navigation & editinguseDynamicTracks- Runtime track additions with placeholder-then-replace patternuseKeyboardShortcuts- Flexible keyboard shortcut systemusePlaybackShortcuts- Default playback shortcuts (0 = rewind)useDynamicEffects- Master effects chain with runtime parameter updatesuseTrackDynamicEffects- Per-track effects managementuseAudioEffects- Audio effects managementuseExportWav- WAV export via Tone.OfflineuseSelectionState- Selection start/end (engine delegation + onEngineState)useLoopState- Loop enabled/start/end (engine delegation + onEngineState)useSelectedTrack- Selected track ID (engine delegation + onEngineState)useMasterVolume- Master volume (engine delegation + onEngineState)useZoomControls- Zoom samplesPerPixel/canZoomIn/Out (engine delegation + onEngineState)useTimeFormat- Time formatting and format selectionuseWaveformDataCache- Web worker peak generation and cacheuseDragSensors- @dnd-kit sensor configuration
Users can:
- Use hooks to build custom UIs with their own components
- Compose hooks for specific functionality
- Maintain full type safety with TypeScript
- Test hooks independently from UI
See packages/browser/src/hooks/ for hook implementations.
State lives in WaveformPlaylistContext and is distributed across the 4 split contexts:
// PlaybackAnimationContext
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// PlaylistStateContext
const [continuousPlay, setContinuousPlay] = useState(false);
const [annotations, setAnnotations] = useState<AnnotationData[]>([]);
const [selectionStart, setSelectionStart] = useState(0);
const [selectionEnd, setSelectionEnd] = useState(0);
// PlaylistDataContext
const [duration, setDuration] = useState(0);
const [audioBuffers, setAudioBuffers] = useState<AudioBuffer[]>([]);Access contexts using specialized hooks:
const { isPlaying, currentTime } = usePlaybackAnimation();
const { continuousPlay, annotations } = usePlaylistState();
const { play, pause, setContinuousPlay } = usePlaylistControls();
const { duration, audioBuffers } = usePlaylistData();const playoutRef = useRef<PlaylistEngine | null>(null); // Engine ref (renamed from TonePlayout)
const currentTimeRef = useRef<number>(0); // For animation loop
const isSelectingRef = useRef(false); // For mouse interactions
const isDraggingRef = useRef(false); // Guards loadAudio during boundary trim dragsEach package builds independently:
pnpm build # Runs tsup for all packagesOutput per package:
dist/index.js(CJS)dist/index.mjs(ESM)dist/index.d.ts(Types)
# Auto-runs during pnpm build
vite buildOutputs:
packages/browser/dist/index.js(CJS)packages/browser/dist/index.mjs(ESM)
pnpm website # Start dev server (localhost:3000)
pnpm website:build # Production buildDocusaurus webpack aliases resolve workspace packages from source for live development.
User clicks Play button
↓
handlePlayClick()
↓
Check for selection?
├─ Yes → engine.play(start, end)
└─ No → engine.play(currentTime)
↓
PlaylistEngine → ToneAdapter → TonePlayout (Tone.js)
↓
Web Audio API
↓
Animation loop (requestAnimationFrame)
↓
Update currentTime state
↓
Re-render Playhead position
User drags clip
↓
useClipDragHandlers → engine.moveClip(trackId, clipId, deltaSamples)
↓
PlaylistEngine:
├─ Constrains delta (collision detection)
├─ Updates internal _tracks
├─ adapter.setTracks() (syncs TonePlayout)
├─ _tracksVersion++
└─ Emits 'statechange' with new EngineState
↓
Provider statechange handler:
└─ onTracksChange(state.tracks) → parent updates tracks prop
Examples are React components in website/src/components/examples/:
-
Component: e.g.,
AnnotationsExample.tsx- Self-contained React component
- Uses
WaveformPlaylistProviderpattern - Loads audio and annotations
-
Page Wrapper:
website/src/pages/examples/annotations.tsx- Uses
createLazyExample()for SSR compatibility - Lazy loads the example component
- Uses
-
Component Lifecycle:
- Load audio → decode → generate peaks
- Initialize TonePlayout via context
- Render waveform canvas
Purpose: Demonstrates multiple audio clips per track with gaps and different timing.
Architecture - File-Reference Data Model:
The multi-clip example uses a file-reference architecture to efficiently handle multiple clips that share the same audio file:
// Audio files - each loaded and decoded once
// Albert Kader minimal techno stems
const audioFiles = [
{ id: 'kick', src: 'media/audio/AlbertKader_Ubiquitous/01_Kick.opus' },
{ id: 'bass', src: 'media/audio/AlbertKader_Ubiquitous/08_Bass.opus' },
{ id: 'synth', src: 'media/audio/AlbertKader_Whiptails/09_Synth1.opus' },
{ id: 'loop', src: 'media/audio/AlbertKader_Whiptails/01_Loop1.opus' },
];
// Track configuration - clips reference files by ID
const trackConfigs = [
{
name: 'Kick',
clips: [
{ fileId: 'kick', startTime: 0, duration: 8, offset: 0 },
{ fileId: 'kick', startTime: 12, duration: 5, offset: 8 },
],
},
// ... more tracks
];Two-Phase Loading:
-
Step 1: Load all audio files once, store in Map by ID
const fileBuffers = new Map(loadedFiles.map(f => [f.id, f.buffer]));
-
Step 2: Create tracks by referencing loaded buffers via
fileIdconst audioBuffer = fileBuffers.get(clipConfig.fileId);
Benefits:
- ✅ Each audio file loaded only once
- ✅ Multiple clips can reference the same file without reloading
- ✅ Easy to copy/paste clip configurations
- ✅ Efficient memory usage
Location: website/src/components/examples/MultiClipExample.tsx
# Terminal 1: Docusaurus dev server (hot reload)
pnpm website
# Terminal 2: Build packages after changes (if needed)
pnpm build- Edit code in
packages/ - Docusaurus hot reloads automatically (webpack aliases resolve from source)
- For dist changes, run
pnpm buildthen hard refresh browser (Cmd+Shift+R) - Check
http://localhost:3000/waveform-playlist/examples/
pnpm-workspace.yaml- Workspace configurationpackage.json- Root package, scriptstsconfig.json- TypeScript base configpackages/*/tsup.config.ts- Build configspackages/browser/vite.config.ts- Browser bundle configwebsite/docusaurus.config.ts- Docusaurus config with webpack aliases
packages/browser/src/index.tsx- Main bundle entrypackages/ui-components/src/index.tsx- Component library exportswebsite/src/components/examples/- Example components
CLAUDE.md- AI development notes and architectural decisionsPROJECT_STRUCTURE.md- This file
The playlist now provides a flexible/headless API using React Context and primitive components, allowing complete customization of layout and controls.
Hybrid Approach: Provider + Primitives + Render Props
WaveformPlaylistProviderwraps your app and provides state via context- Primitive components (PlayButton, ZoomInButton, etc.) work anywhere inside the provider
Waveformcomponent accepts a render prop for custom track controls- Split context hooks (
usePlaylistData,usePlaylistControls, etc.) provide direct access to state/methods
- Maximum Flexibility - Place controls anywhere in your layout
- Customizable Track Controls - Use render prop to completely customize track UI
- Access to State - Build entirely custom components using the hook
- Type Safety - Full TypeScript support with auto-completion
- Good Defaults - Waveform provides sensible default track controls
- Backward Compatible - Old class-based API and WaveformPlaylistComponent still work
Option 1: Flexible API with Provider (Recommended)
import {
WaveformPlaylistProvider,
PlayButton,
StopButton,
Waveform,
MasterVolumeControl,
usePlaylistData,
usePlaylistControls,
} from '@waveform-playlist/browser';
// Custom track controls
const CustomTrackControls = ({ trackIndex }) => {
const { trackStates } = usePlaylistData();
const { setTrackMute } = usePlaylistControls();
return (
<button onClick={() => setTrackMute(trackIndex, !trackStates[trackIndex].muted)}>
{trackStates[trackIndex].muted ? 'Unmute' : 'Mute'}
</button>
);
};
// Your custom layout
function MyPlaylist() {
return (
<WaveformPlaylistProvider tracks={tracks} samplesPerPixel={1024}>
<div className="my-layout">
<div className="controls">
<PlayButton />
<StopButton />
<MasterVolumeControl />
</div>
<Waveform
renderTrackControls={(trackIndex) => (
<CustomTrackControls trackIndex={trackIndex} />
)}
/>
</div>
</WaveformPlaylistProvider>
);
}Option 2: Individual Hooks (Advanced)
import {
useTimeFormat,
} from "@waveform-playlist/browser/hooks";
const { formatTime } = useTimeFormat();See website/src/components/examples/ for 16 complete examples covering minimal setup, stem tracks, effects, fades, recording, annotations, spectrogram, and more.
website/src/components/examples/FlexibleApiExample.tsx- Flexible API example
Location: e2e/, Config: playwright.config.ts
Commands: pnpm test, pnpm test:ui, pnpm test:headed
Environment: BASE_PATH (default: /waveform-playlist), PORT (default: 3000)
| Attribute | Purpose |
|---|---|
data-clip-id |
Draggable clip headers |
data-boundary-edge |
Trim handles (left/right) |
data-clip-container |
Clip wrapper |
data-scroll-container |
Playhead click target |
Enables click-through for playhead positioning while keeping clips interactive:
ClickOverlay:pointer-events: auto(catches timeline clicks)ClipContainer:pointer-events: none(passes clicks through)ClipHeader,ClipBoundary:pointer-events: auto(re-enabled for drag/trim)
See TODO.md for roadmap and progress tracking.
Architectural patterns and conventions documented in CLAUDE.md.