Skip to content

Commit 7fd055e

Browse files
naomiaroclaude
andcommitted
feat: add useDynamicTracks hook and fix layout shift on track load
Add reusable useDynamicTracks hook for runtime track additions (drag-and-drop, file picker) with instant placeholder feedback while audio decodes in parallel. Replaces inline loading logic in NewTracksExample with the new hook. Fix layout shift when placeholder tracks transition to loaded tracks by: - Removing stale audioBuffers check from PlaylistVisualization loading guard - Computing displayDuration synchronously from tracks instead of effect-set state - Ensuring rawChannels floors at 1 to prevent zero-height track collapse - Generating empty peaks with correct channel count in WaveformPlaylistContext Hook features: - Per-track AbortController (Map<trackId, AbortController>) cancels fetches on removal and unmount - loadingIds ref tracks active decodes for accurate loadingCount on removal - errors state exposes TrackLoadError[] for user-facing failure feedback - Blob support in TrackSource union (File, Blob, string, or {src, name}) - Uses getGlobalAudioContext() from playout package Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 843bece commit 7fd055e

File tree

10 files changed

+371
-76
lines changed

10 files changed

+371
-76
lines changed

PROJECT_STRUCTURE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const clip = createClipFromSeconds({
125125
│ ├── Selection.tsx
126126
│ ├── AnnotationBox.tsx
127127
│ └── TrackControls/
128-
├── contexts/ # React contexts (theme, playlist info, playout)
128+
├── contexts/ # React contexts (theme, playlist info, playout, scroll viewport)
129129
├── utils/ # Utilities (time formatting, conversions)
130130
├── styled/ # Shared styled components
131131
│ ├── CheckboxStyles.tsx # Checkbox, label, wrapper
@@ -196,8 +196,10 @@ const clip = createClipFromSeconds({
196196
├── index.tsx # Main entry point + API exports
197197
├── WaveformPlaylistContext.tsx # Context provider (flexible API)
198198
├── MediaElementPlaylistContext.tsx # Context provider (HTMLAudioElement)
199+
├── AnnotationIntegrationContext.tsx # Optional annotation integration
199200
├── SpectrogramIntegrationContext.tsx # Optional spectrogram integration
200201
├── hooks/ # Custom hooks
202+
│ ├── useAnimationFrameLoop.ts # Shared rAF loop for providers
201203
│ ├── useAnnotationDragHandlers.ts # Annotation drag logic
202204
│ ├── useAnnotationKeyboardControls.ts # Annotation navigation & editing
203205
│ ├── useAudioEffects.ts # Audio effects management
@@ -206,13 +208,14 @@ const clip = createClipFromSeconds({
206208
│ ├── useClipSplitting.ts # Split clips at playhead
207209
│ ├── useDragSensors.ts # @dnd-kit sensor config
208210
│ ├── useDynamicEffects.ts # Master effects chain
211+
│ ├── useDynamicTracks.ts # Runtime track additions (placeholder-then-replace)
209212
│ ├── useExportWav.ts # WAV export via Tone.Offline
210-
│ ├── useIntegratedRecording.ts # Recording integration
211213
│ ├── useKeyboardShortcuts.ts # Flexible keyboard shortcut system
212214
│ ├── useMasterVolume.ts # Master volume control
213215
│ ├── usePlaybackShortcuts.ts # Default playback shortcuts
214216
│ ├── useTimeFormat.ts # Time formatting
215217
│ ├── useTrackDynamicEffects.ts # Per-track effects
218+
│ ├── useWaveformDataCache.ts # Web worker peak generation cache
216219
│ └── useZoomControls.ts # Zoom level management
217220
├── components/ # React components
218221
│ ├── PlaylistVisualization.tsx # Main waveform + track rendering
@@ -225,6 +228,8 @@ const clip = createClipFromSeconds({
225228
│ ├── effectDefinitions.ts # 20 Tone.js effect definitions
226229
│ ├── effectFactory.ts # Effect instance creation
227230
│ └── index.ts
231+
├── workers/ # Web workers
232+
│ └── peaksWorker.ts # Inline Blob worker for peak generation
228233
└── waveformDataLoader.ts # BBC waveform-data.js support
229234
```
230235
- **Build:** Vite + tsup
@@ -631,20 +636,22 @@ Business logic is extracted into reusable custom hooks that can be used by any c
631636

632637
**Hooks (in `packages/browser/src/hooks/`):**
633638

634-
- `useAudioTracks` - Track loading and management
639+
- `useAnimationFrameLoop` - Shared rAF lifecycle for both playlist providers
640+
- `useAudioTracks` - Declarative track loading (configs-driven)
635641
- `useClipDragHandlers` - Clip drag-to-move and boundary trimming
636642
- `useClipSplitting` - Split clips at playhead
637643
- `useAnnotationDragHandlers` - Annotation drag logic
638644
- `useAnnotationKeyboardControls` - Annotation navigation & editing
645+
- `useDynamicTracks` - Runtime track additions with placeholder-then-replace pattern
639646
- `useKeyboardShortcuts` - Flexible keyboard shortcut system
640647
- `usePlaybackShortcuts` - Default playback shortcuts (0 = rewind)
641648
- `useDynamicEffects` - Master effects chain with runtime parameter updates
642649
- `useTrackDynamicEffects` - Per-track effects management
643650
- `useAudioEffects` - Audio effects management
644651
- `useExportWav` - WAV export via Tone.Offline
645-
- `useIntegratedRecording` - Recording integration
646652
- `useMasterVolume` - Master volume control
647653
- `useTimeFormat` - Time formatting and format selection
654+
- `useWaveformDataCache` - Web worker peak generation and cache
648655
- `useZoomControls` - Zoom level management
649656
- `useDragSensors` - @dnd-kit sensor configuration
650657

packages/browser/src/WaveformPlaylistContext.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -578,12 +578,19 @@ export const WaveformPlaylistProvider: React.FC<WaveformPlaylistProviderProps> =
578578
}
579579
}
580580

581-
// Path C: No peaks data available yet — render empty while worker processes
581+
// Path C: No peaks data available yet — render empty while worker processes.
582+
// Use correct channel count from audioBuffer to prevent track height shift
583+
// when peaks arrive (mono mode collapses to 1 channel).
582584
if (!peaks) {
583585
if (!clip.audioBuffer && !clip.waveformData) {
584586
console.warn(`[waveform-playlist] Clip "${clip.id}" has no audio data or waveform data`);
585587
}
586-
peaks = { length: 0, data: [], bits: 16 };
588+
const numChannels = mono ? 1 : (clip.audioBuffer?.numberOfChannels ?? 1);
589+
peaks = {
590+
length: 0,
591+
data: Array.from({ length: numChannels }, () => new Int16Array(0)),
592+
bits: 16,
593+
};
587594
}
588595

589596
return {

packages/browser/src/components/PlaylistVisualization.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ export const PlaylistVisualization: React.FC<PlaylistVisualizationProps> = ({
129129
setLoopRegion,
130130
} = usePlaylistControls();
131131
const {
132-
audioBuffers,
133132
peaksDataArray,
134133
trackStates,
135134
tracks,
@@ -192,8 +191,17 @@ export const PlaylistVisualization: React.FC<PlaylistVisualizationProps> = ({
192191
setScrollContainer(element);
193192
}, [setScrollContainer]);
194193

195-
// Calculate dimensions
196-
let displayDuration = audioBuffers.length > 0 ? duration : DEFAULT_EMPTY_TRACK_DURATION;
194+
// Calculate dimensions — derive duration directly from tracks prop to prevent width
195+
// shift. The `duration` state is set in an effect and lags tracks by at least one render.
196+
const tracksMaxDuration = tracks.reduce((max, track) => {
197+
return track.clips.reduce((clipMax, clip) => {
198+
const end = (clip.startSample + clip.durationSamples) / clip.sampleRate;
199+
return Math.max(clipMax, end);
200+
}, max);
201+
}, 0);
202+
let displayDuration = tracksMaxDuration > 0
203+
? tracksMaxDuration
204+
: (duration > 0 ? duration : DEFAULT_EMPTY_TRACK_DURATION);
197205

198206
if (recordingState?.isRecording) {
199207
const recordingEndSample = recordingState.startSample + recordingState.durationSamples;
@@ -235,7 +243,7 @@ export const PlaylistVisualization: React.FC<PlaylistVisualizationProps> = ({
235243
for (let i = 0; i < peaksDataArray.length; i++) {
236244
const trackClipPeaks = peaksDataArray[i];
237245
const rawCh = trackClipPeaks.length > 0
238-
? Math.max(...trackClipPeaks.map(clip => clip.peaks.data.length))
246+
? Math.max(1, ...trackClipPeaks.map(clip => clip.peaks.data.length))
239247
: 1;
240248
const trackMode = spectrogram?.trackSpectrogramOverrides.get(tracks[i]?.id)?.renderMode ?? tracks[i]?.renderMode ?? 'waveform';
241249
const effectiveCh = trackMode === 'both' ? rawCh * 2 : rawCh;
@@ -297,9 +305,12 @@ export const PlaylistVisualization: React.FC<PlaylistVisualizationProps> = ({
297305
}
298306
};
299307

300-
// Only show loading if we have tracks WITH clips but haven't loaded their data yet
308+
// Only show loading if we have tracks WITH clips but peaks haven't been computed yet.
309+
// Don't check audioBuffers — it's set in an effect and can be stale for one or more
310+
// renders after tracks change, causing the playlist to unmount and remount (layout shift).
311+
// Placeholder tracks (clips: []) bypass this check intentionally.
301312
const hasClips = tracks.some(track => track.clips.length > 0);
302-
if (hasClips && (audioBuffers.length === 0 || peaksDataArray.length === 0)) {
313+
if (hasClips && peaksDataArray.length === 0) {
303314
return <div className={className}>Loading waveform...</div>;
304315
}
305316

@@ -449,7 +460,7 @@ export const PlaylistVisualization: React.FC<PlaylistVisualizationProps> = ({
449460
);
450461

451462
const rawChannels = trackClipPeaks.length > 0
452-
? Math.max(...trackClipPeaks.map(clip => clip.peaks.data.length))
463+
? Math.max(1, ...trackClipPeaks.map(clip => clip.peaks.data.length))
453464
: 1;
454465
const maxChannels = rawChannels;
455466

packages/browser/src/hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ export type { AnimationFrameLoopControls } from './useAnimationFrameLoop';
4444

4545
export { useWaveformDataCache } from './useWaveformDataCache';
4646
export type { UseWaveformDataCacheReturn } from './useWaveformDataCache';
47+
48+
export { useDynamicTracks } from './useDynamicTracks';
49+
export type { TrackSource, TrackLoadError, UseDynamicTracksReturn } from './useDynamicTracks';
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* useDynamicTracks — imperative hook for runtime track additions.
3+
*
4+
* Complements `useAudioTracks` (declarative, configs-driven) with an
5+
* imperative `addTracks()` API for dynamic loading (drag-and-drop, file pickers).
6+
*
7+
* Placeholder tracks appear instantly while audio decodes in parallel.
8+
*/
9+
10+
import { useState, useCallback, useRef, useEffect } from 'react';
11+
import { ClipTrack, createTrack, createClipFromSeconds } from '@waveform-playlist/core';
12+
import { getGlobalAudioContext } from '@waveform-playlist/playout';
13+
14+
/** A source that can be decoded into a track. */
15+
export type TrackSource =
16+
| File
17+
| Blob
18+
| string
19+
| { src: string; name?: string };
20+
21+
/** Info about a track that failed to load. */
22+
export interface TrackLoadError {
23+
/** Display name of the source that failed. */
24+
name: string;
25+
/** The underlying error. */
26+
error: Error;
27+
}
28+
29+
export interface UseDynamicTracksReturn {
30+
/**
31+
* Current tracks array (placeholders + loaded). Pass to WaveformPlaylistProvider.
32+
* Placeholder tracks have `clips: []` and names ending with " (loading...)".
33+
*/
34+
tracks: ClipTrack[];
35+
/** Add one or more sources — creates placeholder tracks immediately. */
36+
addTracks: (sources: TrackSource[]) => void;
37+
/** Remove a track by its id. Aborts in-flight fetch/decode if still loading. */
38+
removeTrack: (trackId: string) => void;
39+
/** Number of sources currently decoding. */
40+
loadingCount: number;
41+
/** True when any source is still decoding. */
42+
isLoading: boolean;
43+
/** Tracks that failed to load (removed from `tracks` automatically). */
44+
errors: TrackLoadError[];
45+
}
46+
47+
/** Extract a display name from a TrackSource. */
48+
function getSourceName(source: TrackSource): string {
49+
if (source instanceof File) {
50+
return source.name.replace(/\.[^/.]+$/, '');
51+
}
52+
if (source instanceof Blob) {
53+
return 'Untitled';
54+
}
55+
if (typeof source === 'string') {
56+
return source.split('/').pop()?.replace(/\.[^/.]+$/, '') ?? 'Untitled';
57+
}
58+
return source.name ?? source.src.split('/').pop()?.replace(/\.[^/.]+$/, '') ?? 'Untitled';
59+
}
60+
61+
/** Decode a TrackSource into an AudioBuffer + clean name. */
62+
async function decodeSource(
63+
source: TrackSource,
64+
audioContext: AudioContext,
65+
signal?: AbortSignal
66+
): Promise<{ audioBuffer: AudioBuffer; name: string }> {
67+
const name = getSourceName(source);
68+
69+
// File and Blob: read arrayBuffer directly (not abortable, but we check signal after)
70+
if (source instanceof Blob) {
71+
const arrayBuffer = await source.arrayBuffer();
72+
signal?.throwIfAborted();
73+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
74+
return { audioBuffer, name };
75+
}
76+
77+
const url = typeof source === 'string' ? source : source.src;
78+
const response = await fetch(url, { signal });
79+
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
80+
const arrayBuffer = await response.arrayBuffer();
81+
signal?.throwIfAborted();
82+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
83+
return { audioBuffer, name };
84+
}
85+
86+
export function useDynamicTracks(): UseDynamicTracksReturn {
87+
const [tracks, setTracks] = useState<ClipTrack[]>([]);
88+
const [loadingCount, setLoadingCount] = useState(0);
89+
const [errors, setErrors] = useState<TrackLoadError[]>([]);
90+
const cancelledRef = useRef(false);
91+
/** Track IDs currently decoding — for accurate loadingCount on removal. */
92+
const loadingIdsRef = useRef(new Set<string>());
93+
/** Per-track AbortControllers for in-flight fetches — keyed by track ID. */
94+
const abortControllersRef = useRef(new Map<string, AbortController>());
95+
96+
useEffect(() => {
97+
const controllers = abortControllersRef.current;
98+
return () => {
99+
cancelledRef.current = true;
100+
for (const controller of controllers.values()) {
101+
controller.abort();
102+
}
103+
controllers.clear();
104+
};
105+
}, []);
106+
107+
const addTracks = useCallback((sources: TrackSource[]) => {
108+
if (sources.length === 0) return;
109+
110+
const audioContext = getGlobalAudioContext();
111+
112+
// 1. Create placeholder tracks immediately
113+
const placeholders = sources.map(source => ({
114+
track: createTrack({ name: `${getSourceName(source)} (loading...)`, clips: [] }),
115+
source,
116+
}));
117+
118+
setTracks(prev => [...prev, ...placeholders.map(p => p.track)]);
119+
setLoadingCount(prev => prev + sources.length);
120+
121+
// 2. Decode each source in parallel (fire-and-forget per source)
122+
for (const { track, source } of placeholders) {
123+
loadingIdsRef.current.add(track.id);
124+
const controller = new AbortController();
125+
abortControllersRef.current.set(track.id, controller);
126+
127+
(async () => {
128+
try {
129+
const { audioBuffer, name } = await decodeSource(
130+
source, audioContext, controller.signal
131+
);
132+
const clip = createClipFromSeconds({
133+
audioBuffer,
134+
startTime: 0,
135+
duration: audioBuffer.duration,
136+
offset: 0,
137+
name,
138+
});
139+
140+
// Guard: skip state update if hook unmounted or track was removed while decoding
141+
if (!cancelledRef.current && loadingIdsRef.current.has(track.id)) {
142+
setTracks(prev => prev.map(t =>
143+
t.id === track.id ? { ...t, name, clips: [clip] } : t
144+
));
145+
}
146+
} catch (error) {
147+
if (error instanceof DOMException && error.name === 'AbortError') return;
148+
console.warn('[waveform-playlist] Error loading audio:', error);
149+
// Guard: skip state update if hook unmounted or track was removed while decoding
150+
if (!cancelledRef.current && loadingIdsRef.current.has(track.id)) {
151+
setTracks(prev => prev.filter(t => t.id !== track.id));
152+
setErrors(prev => [...prev, {
153+
name: getSourceName(source),
154+
error: error instanceof Error ? error : new Error(String(error)),
155+
}]);
156+
}
157+
} finally {
158+
abortControllersRef.current.delete(track.id);
159+
if (!cancelledRef.current && loadingIdsRef.current.delete(track.id)) {
160+
setLoadingCount(prev => prev - 1);
161+
}
162+
}
163+
})();
164+
}
165+
}, []);
166+
167+
const removeTrack = useCallback((trackId: string) => {
168+
setTracks(prev => prev.filter(t => t.id !== trackId));
169+
// Abort in-flight fetch/decode and update loading state
170+
const controller = abortControllersRef.current.get(trackId);
171+
if (controller) {
172+
controller.abort();
173+
abortControllersRef.current.delete(trackId);
174+
}
175+
if (loadingIdsRef.current.delete(trackId)) {
176+
setLoadingCount(prev => prev - 1);
177+
}
178+
}, []);
179+
180+
return {
181+
tracks,
182+
addTracks,
183+
removeTrack,
184+
loadingCount,
185+
isLoading: loadingCount > 0,
186+
errors,
187+
};
188+
}

packages/browser/src/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export {
4747
useDynamicEffects,
4848
useTrackDynamicEffects,
4949
useExportWav,
50+
useDynamicTracks,
5051
} from './hooks';
5152
export type {
5253
AudioTrackConfig,
@@ -63,6 +64,9 @@ export type {
6364
ExportOptions,
6465
ExportResult,
6566
UseExportWavReturn,
67+
TrackSource,
68+
TrackLoadError,
69+
UseDynamicTracksReturn,
6670
} from './hooks';
6771

6872
// Export effect definitions and factory

0 commit comments

Comments
 (0)