@waveform-playlist/browser 15.2.0 → 15.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @waveform-playlist/browser
2
+
3
+ React components, hooks, and context providers for building a multitrack Web Audio editor and player — canvas waveform rendering, clip-based editing, and playback controls on top of Tone.js or a native `HTMLAudioElement`.
4
+
5
+ ## Features
6
+
7
+ - **Multitrack editor** — `<Waveform />` renders canvas waveforms, per-track controls (mute/solo/volume/pan), a time ruler, and a playhead for any number of tracks
8
+ - **Clip-based editing** — drag-to-move, boundary trimming with collision detection, splitting at the playhead, snap-to-grid (beats/bars or timescale), undo/redo
9
+ - **Split context providers** — `WaveformPlaylistProvider` (multitrack, Tone.js-backed) and `MediaElementPlaylistProvider` (single-track, `<audio>`-backed with pitch-preserving playback rate)
10
+ - **Four focused context hooks** — `usePlaybackAnimation`, `usePlaylistState`, `usePlaylistControls`, `usePlaylistData` isolate 60fps playhead updates from low-frequency state so unrelated components don't re-render on every animation frame
11
+ - **Audio effects** — 20 Tone.js effects (reverb, delay, modulation, filter, distortion, dynamics, spatial) with runtime parameter control, master and per-track chains, plus WAM 2.0 plugin hosting
12
+ - **Recording** — pairs with `@waveform-playlist/recording` for mic input, overdubbing, and live waveform preview; punch-in takes replace overlapped content, and `setRecordingActive(active, armedTrackId)` suppresses the end-of-timeline auto-stop and transiently mutes the recorded-over track for the session
13
+ - **Annotations & spectrogram** — optional integration contexts for `@waveform-playlist/annotations` and `@waveform-playlist/spectrogram`
14
+ - **WAV export** — render the full mix (including effects) offline to a downloadable WAV file
15
+ - **MediaElement player mode** — a lightweight single-track player with pitch-preserving speed control, for podcast/audiobook-style use cases that don't need multitrack mixing
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @waveform-playlist/browser tone @dnd-kit/react
21
+ ```
22
+
23
+ `@waveform-playlist/browser` requires these peer dependencies:
24
+
25
+ | Package | Purpose |
26
+ |---------|---------|
27
+ | `react` / `react-dom` | ^18.0.0 or ^19.0.0 |
28
+ | `styled-components` | ^6.0.0 — CSS-in-JS styling |
29
+ | `tone` | ^15.0.0 — Web Audio engine (optional if you supply your own `PlayoutAdapter`) |
30
+ | `@dnd-kit/react` | ^0.3.0 — drag-and-drop (includes `@dnd-kit/dom` and `@dnd-kit/abstract`) |
31
+ | `@waveform-playlist/playout` | Tone.js playout engine (optional peer, dynamically imported by default) |
32
+ | `@waveform-playlist/media-element-playout` | engine for `MediaElementPlaylistProvider` (optional peer) |
33
+ | `@waveform-playlist/annotations` / `@waveform-playlist/recording` / `@dawcore/wam` | optional, install only if you use those features |
34
+
35
+ **Engine-free core:** the package's main entry has no static dependency on `tone` or `@waveform-playlist/playout` — a `MediaElementPlaylistProvider`-only consumer, or one supplying a custom `createAdapter`, never resolves either. Tone-coupled exports (`useAudioTracks`, `useDynamicTracks`, effects hooks, `useExportWav`, `useOutputMeter`, `useMasterAnalyser`) live at the **`@waveform-playlist/browser/tone`** subpath:
36
+
37
+ ```typescript
38
+ import { useAudioTracks, useDynamicEffects, useExportWav } from '@waveform-playlist/browser/tone';
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```tsx
44
+ import {
45
+ WaveformPlaylistProvider,
46
+ Waveform,
47
+ PlayButton,
48
+ PauseButton,
49
+ StopButton,
50
+ } from '@waveform-playlist/browser';
51
+ import { useAudioTracks } from '@waveform-playlist/browser/tone';
52
+
53
+ function MyPlaylist() {
54
+ const { tracks, loading, error } = useAudioTracks([
55
+ { src: '/audio/vocals.mp3', name: 'Vocals' },
56
+ { src: '/audio/guitar.mp3', name: 'Guitar' },
57
+ ]);
58
+
59
+ if (loading) return <div>Loading audio...</div>;
60
+ if (error) return <div>Error: {error}</div>;
61
+
62
+ return (
63
+ <WaveformPlaylistProvider
64
+ tracks={tracks}
65
+ samplesPerPixel={1024}
66
+ waveHeight={128}
67
+ timescale
68
+ controls={{ show: true, width: 200 }}
69
+ >
70
+ <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
71
+ <PlayButton />
72
+ <PauseButton />
73
+ <StopButton />
74
+ </div>
75
+ <Waveform />
76
+ </WaveformPlaylistProvider>
77
+ );
78
+ }
79
+ ```
80
+
81
+ Prefer a lightweight single-track player instead? Use `MediaElementPlaylistProvider` with `@waveform-playlist/media-element-playout` — no Tone.js, no AudioBuffer decoding, and pitch-preserving playback rate out of the box.
82
+
83
+ ## Context Hooks
84
+
85
+ `WaveformPlaylistProvider` splits its context into four hooks so components only re-render for the data they actually read:
86
+
87
+ | Hook | Exposes |
88
+ |------|---------|
89
+ | `usePlaybackAnimation` | `isPlaying`, `currentTime`, animation refs, `getPlaybackTime()`, `registerFrameCallback()` for 60fps-driven UI (playhead, progress bars) |
90
+ | `usePlaylistState` | Low-frequency state — selection, loop region, annotations, `selectedTrackId`, `canUndo`/`canRedo` |
91
+ | `usePlaylistControls` | Actions — `play`, `pause`, `stop`, `seekTo`, track mute/solo/volume/pan, zoom, undo/redo, `setRecordingActive` (recording-session suppression + armed-track mute) |
92
+ | `usePlaylistData` | Derived/config data — `tracks`, `duration`, `sampleRate`, `peaksDataArray`, `trackStates`, `isReady` |
93
+
94
+ `usePlaylistDataOptional` is the same as `usePlaylistData` but returns `null` outside a provider (for components that render both inside and outside one). `MediaElementPlaylistProvider` has the equivalent `useMediaElementAnimation`, `useMediaElementState`, `useMediaElementControls`, `useMediaElementData`.
95
+
96
+ ## Examples & Documentation
97
+
98
+ - [naomiaro.github.io/waveform-playlist](https://naomiaro.github.io/waveform-playlist/) — guides, API reference, and live examples
99
+ - [Basic Usage guide](https://naomiaro.github.io/waveform-playlist/docs/react/getting-started/basic-usage) — walkthrough of the provider pattern
100
+ - [`examples/media-element-player`](https://github.com/naomiaro/waveform-playlist/tree/main/examples/media-element-player) — `MediaElementPlaylistProvider` starter (`pnpm example:media-element`)
101
+
102
+ ## License
103
+
104
+ MIT
package/dist/index.d.mts CHANGED
@@ -94,8 +94,12 @@ interface PlaylistStateContextValue {
94
94
  selectedTrackId: string | null;
95
95
  loopStart: number;
96
96
  loopEnd: number;
97
- /** Whether playback continues past the end of loaded audio */
97
+ /** Whether playback continues past the end of loaded audio instead of
98
+ * auto-stopping (implies the fillViewport layout) */
98
99
  indefinitePlayback: boolean;
100
+ /** Whether the timeline visually fills the scroll container even when the
101
+ * audio is shorter (layout only — no effect on playback) */
102
+ fillViewport: boolean;
99
103
  /** Whether undo is available */
100
104
  canUndo: boolean;
101
105
  /** Whether redo is available */
@@ -132,6 +136,16 @@ interface PlaylistControlsContextValue {
132
136
  clearLoopRegion: () => void;
133
137
  undo: () => void;
134
138
  redo: () => void;
139
+ /** Mark a recording session active/inactive. While active: (1) the
140
+ * end-of-audio auto-stop is suppressed so overdub playback runs past the
141
+ * end of existing material, and (2) with an `armedTrackId`, that track's
142
+ * existing content is transiently muted — punch-in recording replaces
143
+ * whatever the take overlaps (#579), so the doomed material must not play
144
+ * under the overdub; its previous mute state is restored when the session
145
+ * ends (audio-only, the UI mute control is untouched). Wired automatically
146
+ * from the Waveform / PlaylistVisualization `recordingState` prop; overdub
147
+ * flows should also call it eagerly (before `play()`). */
148
+ setRecordingActive: (active: boolean, armedTrackId?: string | null) => void;
135
149
  }
136
150
  interface PlaylistDataContextValue {
137
151
  duration: number;
@@ -222,9 +236,17 @@ interface WaveformPlaylistProviderProps {
222
236
  * Use this during progressive loading to avoid rebuilding the engine for
223
237
  * each track — flip to false when all tracks are ready for a single build. */
224
238
  deferEngineRebuild?: boolean;
225
- /** Disable automatic stop when the cursor reaches the end of the longest
226
- * track. Useful for DAW-style recording beyond existing audio. */
239
+ /** Disable the automatic stop when the cursor reaches the end of the
240
+ * longest track the transport rolls until an explicit stop/pause
241
+ * (DAW-style). Implies the fillViewport layout. Recording sessions
242
+ * suppress the auto-stop automatically, so most recording UIs don't
243
+ * need this. */
227
244
  indefinitePlayback?: boolean;
245
+ /** Extend the timeline (background + timescale) to fill the visible scroll
246
+ * container even when the audio is shorter. Layout only. Recording UIs
247
+ * typically want this so the empty timeline doesn't collapse to the audio
248
+ * width. */
249
+ fillViewport?: boolean;
228
250
  /** Desired AudioContext sample rate. Creates a cross-browser AudioContext at
229
251
  * this rate via standardized-audio-context. Pre-computed peaks (.dat files)
230
252
  * render instantly when they match. On mismatch, falls back to worker. */
package/dist/index.d.ts CHANGED
@@ -94,8 +94,12 @@ interface PlaylistStateContextValue {
94
94
  selectedTrackId: string | null;
95
95
  loopStart: number;
96
96
  loopEnd: number;
97
- /** Whether playback continues past the end of loaded audio */
97
+ /** Whether playback continues past the end of loaded audio instead of
98
+ * auto-stopping (implies the fillViewport layout) */
98
99
  indefinitePlayback: boolean;
100
+ /** Whether the timeline visually fills the scroll container even when the
101
+ * audio is shorter (layout only — no effect on playback) */
102
+ fillViewport: boolean;
99
103
  /** Whether undo is available */
100
104
  canUndo: boolean;
101
105
  /** Whether redo is available */
@@ -132,6 +136,16 @@ interface PlaylistControlsContextValue {
132
136
  clearLoopRegion: () => void;
133
137
  undo: () => void;
134
138
  redo: () => void;
139
+ /** Mark a recording session active/inactive. While active: (1) the
140
+ * end-of-audio auto-stop is suppressed so overdub playback runs past the
141
+ * end of existing material, and (2) with an `armedTrackId`, that track's
142
+ * existing content is transiently muted — punch-in recording replaces
143
+ * whatever the take overlaps (#579), so the doomed material must not play
144
+ * under the overdub; its previous mute state is restored when the session
145
+ * ends (audio-only, the UI mute control is untouched). Wired automatically
146
+ * from the Waveform / PlaylistVisualization `recordingState` prop; overdub
147
+ * flows should also call it eagerly (before `play()`). */
148
+ setRecordingActive: (active: boolean, armedTrackId?: string | null) => void;
135
149
  }
136
150
  interface PlaylistDataContextValue {
137
151
  duration: number;
@@ -222,9 +236,17 @@ interface WaveformPlaylistProviderProps {
222
236
  * Use this during progressive loading to avoid rebuilding the engine for
223
237
  * each track — flip to false when all tracks are ready for a single build. */
224
238
  deferEngineRebuild?: boolean;
225
- /** Disable automatic stop when the cursor reaches the end of the longest
226
- * track. Useful for DAW-style recording beyond existing audio. */
239
+ /** Disable the automatic stop when the cursor reaches the end of the
240
+ * longest track the transport rolls until an explicit stop/pause
241
+ * (DAW-style). Implies the fillViewport layout. Recording sessions
242
+ * suppress the auto-stop automatically, so most recording UIs don't
243
+ * need this. */
227
244
  indefinitePlayback?: boolean;
245
+ /** Extend the timeline (background + timescale) to fill the visible scroll
246
+ * container even when the audio is shorter. Layout only. Recording UIs
247
+ * typically want this so the empty timeline doesn't collapse to the audio
248
+ * width. */
249
+ fillViewport?: boolean;
228
250
  /** Desired AudioContext sample rate. Creates a cross-browser AudioContext at
229
251
  * this rate via standardized-audio-context. Pre-computed peaks (.dat files)
230
252
  * render instantly when they match. On mismatch, falls back to worker. */
package/dist/index.js CHANGED
@@ -1881,14 +1881,13 @@ var WaveformPlaylistProvider = ({
1881
1881
  soundFontCache,
1882
1882
  deferEngineRebuild = false,
1883
1883
  indefinitePlayback = false,
1884
+ fillViewport = false,
1884
1885
  sampleRate: sampleRateProp,
1885
1886
  createAdapter,
1886
1887
  children
1887
1888
  }) => {
1888
1889
  var _a, _b, _c, _d;
1889
1890
  const progressBarWidth = progressBarWidthProp != null ? progressBarWidthProp : barWidth + barGap;
1890
- const indefinitePlaybackRef = (0, import_react17.useRef)(indefinitePlayback);
1891
- indefinitePlaybackRef.current = indefinitePlayback;
1892
1891
  const stableZoomLevels = (0, import_react17.useMemo)(
1893
1892
  () => zoomLevels,
1894
1893
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1926,6 +1925,28 @@ var WaveformPlaylistProvider = ({
1926
1925
  const engineRef = (0, import_react17.useRef)(null);
1927
1926
  const adapterRef = (0, import_react17.useRef)(null);
1928
1927
  const audioInitializedRef = (0, import_react17.useRef)(false);
1928
+ const indefinitePlaybackRef = (0, import_react17.useRef)(indefinitePlayback);
1929
+ indefinitePlaybackRef.current = indefinitePlayback;
1930
+ const recordingActiveRef = (0, import_react17.useRef)(false);
1931
+ const armedTrackMuteRef = (0, import_react17.useRef)(null);
1932
+ const setRecordingActive = (0, import_react17.useCallback)((active, armedTrackId) => {
1933
+ var _a2, _b2, _c2;
1934
+ recordingActiveRef.current = active;
1935
+ const engine = engineRef.current;
1936
+ if (!engine) return;
1937
+ if (active && armedTrackId) {
1938
+ if (((_a2 = armedTrackMuteRef.current) == null ? void 0 : _a2.trackId) === armedTrackId) return;
1939
+ if (armedTrackMuteRef.current) {
1940
+ engine.setTrackMute(armedTrackMuteRef.current.trackId, armedTrackMuteRef.current.prevMuted);
1941
+ }
1942
+ const prevMuted = (_c2 = (_b2 = engine.getState().tracks.find((t) => t.id === armedTrackId)) == null ? void 0 : _b2.muted) != null ? _c2 : false;
1943
+ armedTrackMuteRef.current = { trackId: armedTrackId, prevMuted };
1944
+ engine.setTrackMute(armedTrackId, true);
1945
+ } else if (!active && armedTrackMuteRef.current) {
1946
+ engine.setTrackMute(armedTrackMuteRef.current.trackId, armedTrackMuteRef.current.prevMuted);
1947
+ armedTrackMuteRef.current = null;
1948
+ }
1949
+ }, []);
1929
1950
  const isPlayingRef = (0, import_react17.useRef)(false);
1930
1951
  isPlayingRef.current = isPlaying;
1931
1952
  const playStartPositionRef = (0, import_react17.useRef)(0);
@@ -2517,7 +2538,7 @@ var WaveformPlaylistProvider = ({
2517
2538
  playbackEndTimeRef.current = null;
2518
2539
  return;
2519
2540
  }
2520
- if (time >= duration && !indefinitePlaybackRef.current) {
2541
+ if (time >= duration && !indefinitePlaybackRef.current && !recordingActiveRef.current) {
2521
2542
  if (engineRef.current) {
2522
2543
  engineRef.current.stop();
2523
2544
  }
@@ -2788,6 +2809,7 @@ var WaveformPlaylistProvider = ({
2788
2809
  loopStart,
2789
2810
  loopEnd,
2790
2811
  indefinitePlayback,
2812
+ fillViewport,
2791
2813
  canUndo,
2792
2814
  canRedo
2793
2815
  }),
@@ -2805,6 +2827,7 @@ var WaveformPlaylistProvider = ({
2805
2827
  loopStart,
2806
2828
  loopEnd,
2807
2829
  indefinitePlayback,
2830
+ fillViewport,
2808
2831
  canUndo,
2809
2832
  canRedo
2810
2833
  ]
@@ -2860,7 +2883,9 @@ var WaveformPlaylistProvider = ({
2860
2883
  clearLoopRegion,
2861
2884
  // Undo/redo
2862
2885
  undo,
2863
- redo
2886
+ redo,
2887
+ // Recording
2888
+ setRecordingActive
2864
2889
  }),
2865
2890
  [
2866
2891
  play,
@@ -2892,7 +2917,8 @@ var WaveformPlaylistProvider = ({
2892
2917
  setLoopRegionFromSelection,
2893
2918
  clearLoopRegion,
2894
2919
  undo,
2895
- redo
2920
+ redo,
2921
+ setRecordingActive
2896
2922
  ]
2897
2923
  );
2898
2924
  const dataValue = (0, import_react17.useMemo)(
@@ -4021,7 +4047,7 @@ var PlaylistVisualization = ({
4021
4047
  onRemoveTrack,
4022
4048
  recordingState
4023
4049
  }) => {
4024
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
4050
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m;
4025
4051
  const theme = (0, import_ui_components8.useTheme)();
4026
4052
  const { isPlaying, getLookAhead, getOutputLatency } = usePlaybackAnimation();
4027
4053
  const {
@@ -4036,7 +4062,8 @@ var PlaylistVisualization = ({
4036
4062
  loopStart,
4037
4063
  loopEnd,
4038
4064
  isLoopEnabled,
4039
- indefinitePlayback
4065
+ indefinitePlayback,
4066
+ fillViewport
4040
4067
  } = usePlaylistState();
4041
4068
  const annotationIntegration = (0, import_react26.useContext)(AnnotationIntegrationContext);
4042
4069
  const {
@@ -4051,7 +4078,8 @@ var PlaylistVisualization = ({
4051
4078
  setScrollContainer,
4052
4079
  setSelectedTrackId,
4053
4080
  setCurrentTime,
4054
- setLoopRegion
4081
+ setLoopRegion,
4082
+ setRecordingActive
4055
4083
  } = usePlaylistControls();
4056
4084
  const {
4057
4085
  peaksDataArray,
@@ -4070,6 +4098,13 @@ var PlaylistVisualization = ({
4070
4098
  mono
4071
4099
  } = usePlaylistData();
4072
4100
  const spectrogram = (0, import_react26.useContext)(SpectrogramIntegrationContext);
4101
+ const recordingActive = !!(recordingState == null ? void 0 : recordingState.isRecording);
4102
+ const armedTrackId = (_a = recordingState == null ? void 0 : recordingState.trackId) != null ? _a : null;
4103
+ (0, import_react26.useEffect)(() => {
4104
+ if (!recordingActive) return void 0;
4105
+ setRecordingActive(true, armedTrackId);
4106
+ return () => setRecordingActive(false);
4107
+ }, [recordingActive, armedTrackId, setRecordingActive]);
4073
4108
  const perTrackSpectrogramHelpers = (0, import_react26.useMemo)(() => {
4074
4109
  if (!spectrogram)
4075
4110
  return /* @__PURE__ */ new Map();
@@ -4107,8 +4142,8 @@ var PlaylistVisualization = ({
4107
4142
  }, max);
4108
4143
  }, 0);
4109
4144
  let displayDuration = tracksMaxDuration > 0 ? tracksMaxDuration : duration > 0 ? duration : DEFAULT_EMPTY_TRACK_DURATION;
4110
- if (indefinitePlayback) {
4111
- const containerWidth = (_b = (_a = scrollContainerRef.current) == null ? void 0 : _a.clientWidth) != null ? _b : 0;
4145
+ if (indefinitePlayback || fillViewport) {
4146
+ const containerWidth = (_c = (_b = scrollContainerRef.current) == null ? void 0 : _b.clientWidth) != null ? _c : 0;
4112
4147
  const minContainerDuration = containerWidth * samplesPerPixel / sampleRate;
4113
4148
  displayDuration = Math.max(displayDuration, minContainerDuration);
4114
4149
  }
@@ -4566,8 +4601,8 @@ var PlaylistVisualization = ({
4566
4601
  {
4567
4602
  open: settingsModalTrackId !== null,
4568
4603
  onClose: () => setSettingsModalTrackId(null),
4569
- config: settingsModalTrackId !== null ? (_g = (_f = (_e = (_c = spectrogram.trackSpectrogramOverrides.get(settingsModalTrackId)) == null ? void 0 : _c.config) != null ? _e : (_d = tracks.find((t) => t.id === settingsModalTrackId)) == null ? void 0 : _d.spectrogramConfig) != null ? _f : spectrogram.spectrogramConfig) != null ? _g : {} : {},
4570
- colorMap: settingsModalTrackId !== null ? (_l = (_k = (_j = (_h = spectrogram.trackSpectrogramOverrides.get(settingsModalTrackId)) == null ? void 0 : _h.colorMap) != null ? _j : (_i = tracks.find((t) => t.id === settingsModalTrackId)) == null ? void 0 : _i.spectrogramColorMap) != null ? _k : spectrogram.spectrogramColorMap) != null ? _l : "viridis" : "viridis",
4604
+ config: settingsModalTrackId !== null ? (_h = (_g = (_f = (_d = spectrogram.trackSpectrogramOverrides.get(settingsModalTrackId)) == null ? void 0 : _d.config) != null ? _f : (_e = tracks.find((t) => t.id === settingsModalTrackId)) == null ? void 0 : _e.spectrogramConfig) != null ? _g : spectrogram.spectrogramConfig) != null ? _h : {} : {},
4605
+ colorMap: settingsModalTrackId !== null ? (_m = (_l = (_k = (_i = spectrogram.trackSpectrogramOverrides.get(settingsModalTrackId)) == null ? void 0 : _i.colorMap) != null ? _k : (_j = tracks.find((t) => t.id === settingsModalTrackId)) == null ? void 0 : _j.spectrogramColorMap) != null ? _l : spectrogram.spectrogramColorMap) != null ? _m : "viridis" : "viridis",
4571
4606
  onApply: (newConfig, newColorMap) => {
4572
4607
  if (settingsModalTrackId !== null) {
4573
4608
  spectrogram.setTrackSpectrogramConfig(settingsModalTrackId, newConfig, newColorMap);