@waveform-playlist/playout 10.3.0 → 10.4.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/dist/index.d.mts CHANGED
@@ -21,6 +21,12 @@ interface ToneTrackOptions {
21
21
  effects?: TrackEffectsFunction;
22
22
  destination?: ToneAudioNode;
23
23
  }
24
+ /** Per-clip scheduling info and audio nodes */
25
+ interface ScheduledClip {
26
+ clipInfo: ClipInfo;
27
+ fadeGainNode: GainNode;
28
+ scheduleId: number;
29
+ }
24
30
  declare class ToneTrack {
25
31
  private scheduledClips;
26
32
  private activeSources;
@@ -51,6 +57,26 @@ declare class ToneTrack {
51
57
  * schedule callbacks at exact Transport position (e.g., loopStart).
52
58
  */
53
59
  startMidClipSources(transportOffset: number, audioContextTime: number): void;
60
+ /**
61
+ * Add a clip to this track at runtime. Creates a Transport.schedule event
62
+ * and fadeGainNode. If playing, starts the source mid-clip if needed.
63
+ */
64
+ addClip(clipInfo: ClipInfo): ScheduledClip;
65
+ /**
66
+ * Remove a scheduled clip by index. Clears the Transport event and
67
+ * disconnects the fadeGainNode.
68
+ */
69
+ removeScheduledClip(index: number): void;
70
+ /**
71
+ * Replace clips on this track. Diffs old vs new by buffer + timing —
72
+ * unchanged clips keep their active sources playing (no audible interruption).
73
+ * Changed/added/removed clips are rescheduled. Disconnecting a removed clip's
74
+ * fadeGainNode silences its source immediately (audio path broken) without
75
+ * needing to explicitly stop it.
76
+ */
77
+ replaceClips(newClips: ClipInfo[], newStartTime?: number): void;
78
+ /** Compare two clips by reference (buffer), timing, and fade properties */
79
+ private _clipsEqual;
54
80
  /**
55
81
  * Stop all active AudioBufferSourceNodes and clear the set.
56
82
  * Native AudioBufferSourceNodes ignore Transport state changes —
@@ -425,6 +451,18 @@ declare class TonePlayout {
425
451
  applyInitialSoloState(): void;
426
452
  removeTrack(trackId: string): void;
427
453
  getTrack(trackId: string): PlayableTrack | undefined;
454
+ getTrackIds(): string[];
455
+ /**
456
+ * Replace clips on a track, preserving the track's audio graph.
457
+ * Only works for ToneTrack (audio clips), not MidiToneTrack.
458
+ */
459
+ replaceTrackClips(trackId: string, newClips: ClipInfo[], newStartTime?: number): boolean;
460
+ /**
461
+ * Start mid-clip sources for a specific track at the current Transport position.
462
+ * Call after adding/updating a track during active playback so clips that span
463
+ * the current position produce audio immediately.
464
+ */
465
+ resumeTrackMidPlayback(trackId: string): void;
428
466
  play(when?: number, offset?: number, duration?: number): void;
429
467
  pause(): void;
430
468
  stop(): void;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,12 @@ interface ToneTrackOptions {
21
21
  effects?: TrackEffectsFunction;
22
22
  destination?: ToneAudioNode;
23
23
  }
24
+ /** Per-clip scheduling info and audio nodes */
25
+ interface ScheduledClip {
26
+ clipInfo: ClipInfo;
27
+ fadeGainNode: GainNode;
28
+ scheduleId: number;
29
+ }
24
30
  declare class ToneTrack {
25
31
  private scheduledClips;
26
32
  private activeSources;
@@ -51,6 +57,26 @@ declare class ToneTrack {
51
57
  * schedule callbacks at exact Transport position (e.g., loopStart).
52
58
  */
53
59
  startMidClipSources(transportOffset: number, audioContextTime: number): void;
60
+ /**
61
+ * Add a clip to this track at runtime. Creates a Transport.schedule event
62
+ * and fadeGainNode. If playing, starts the source mid-clip if needed.
63
+ */
64
+ addClip(clipInfo: ClipInfo): ScheduledClip;
65
+ /**
66
+ * Remove a scheduled clip by index. Clears the Transport event and
67
+ * disconnects the fadeGainNode.
68
+ */
69
+ removeScheduledClip(index: number): void;
70
+ /**
71
+ * Replace clips on this track. Diffs old vs new by buffer + timing —
72
+ * unchanged clips keep their active sources playing (no audible interruption).
73
+ * Changed/added/removed clips are rescheduled. Disconnecting a removed clip's
74
+ * fadeGainNode silences its source immediately (audio path broken) without
75
+ * needing to explicitly stop it.
76
+ */
77
+ replaceClips(newClips: ClipInfo[], newStartTime?: number): void;
78
+ /** Compare two clips by reference (buffer), timing, and fade properties */
79
+ private _clipsEqual;
54
80
  /**
55
81
  * Stop all active AudioBufferSourceNodes and clear the set.
56
82
  * Native AudioBufferSourceNodes ignore Transport state changes —
@@ -425,6 +451,18 @@ declare class TonePlayout {
425
451
  applyInitialSoloState(): void;
426
452
  removeTrack(trackId: string): void;
427
453
  getTrack(trackId: string): PlayableTrack | undefined;
454
+ getTrackIds(): string[];
455
+ /**
456
+ * Replace clips on a track, preserving the track's audio graph.
457
+ * Only works for ToneTrack (audio clips), not MidiToneTrack.
458
+ */
459
+ replaceTrackClips(trackId: string, newClips: ClipInfo[], newStartTime?: number): boolean;
460
+ /**
461
+ * Start mid-clip sources for a specific track at the current Transport position.
462
+ * Call after adding/updating a track during active playback so clips that span
463
+ * the current position produce audio immediately.
464
+ */
465
+ resumeTrackMidPlayback(trackId: string): void;
428
466
  play(when?: number, offset?: number, duration?: number): void;
429
467
  pause(): void;
430
468
  stop(): void;
package/dist/index.js CHANGED
@@ -179,6 +179,112 @@ var ToneTrack = class {
179
179
  }
180
180
  }
181
181
  }
182
+ /**
183
+ * Add a clip to this track at runtime. Creates a Transport.schedule event
184
+ * and fadeGainNode. If playing, starts the source mid-clip if needed.
185
+ */
186
+ addClip(clipInfo) {
187
+ const transport = (0, import_tone.getTransport)();
188
+ const rawContext = (0, import_tone.getContext)().rawContext;
189
+ const volumeNativeInput = this.volumeNode.input.input;
190
+ const fadeGainNode = rawContext.createGain();
191
+ fadeGainNode.gain.value = clipInfo.gain;
192
+ fadeGainNode.connect(volumeNativeInput);
193
+ const absTransportTime = this.track.startTime + clipInfo.startTime;
194
+ const scheduleId = transport.schedule((audioContextTime) => {
195
+ if (absTransportTime < this._scheduleGuardOffset) return;
196
+ this.startClipSource(clipInfo, fadeGainNode, audioContextTime);
197
+ }, absTransportTime);
198
+ const scheduled = { clipInfo, fadeGainNode, scheduleId };
199
+ this.scheduledClips.push(scheduled);
200
+ return scheduled;
201
+ }
202
+ /**
203
+ * Remove a scheduled clip by index. Clears the Transport event and
204
+ * disconnects the fadeGainNode.
205
+ */
206
+ removeScheduledClip(index) {
207
+ const scheduled = this.scheduledClips[index];
208
+ if (!scheduled) return;
209
+ const transport = (0, import_tone.getTransport)();
210
+ try {
211
+ transport.clear(scheduled.scheduleId);
212
+ } catch {
213
+ }
214
+ try {
215
+ scheduled.fadeGainNode.disconnect();
216
+ } catch {
217
+ }
218
+ this.scheduledClips.splice(index, 1);
219
+ }
220
+ /**
221
+ * Replace clips on this track. Diffs old vs new by buffer + timing —
222
+ * unchanged clips keep their active sources playing (no audible interruption).
223
+ * Changed/added/removed clips are rescheduled. Disconnecting a removed clip's
224
+ * fadeGainNode silences its source immediately (audio path broken) without
225
+ * needing to explicitly stop it.
226
+ */
227
+ replaceClips(newClips, newStartTime) {
228
+ if (newStartTime !== void 0) {
229
+ this.track.startTime = newStartTime;
230
+ }
231
+ const tp = (0, import_tone.getTransport)();
232
+ const kept = [];
233
+ const toAdd = [];
234
+ const matched = /* @__PURE__ */ new Set();
235
+ for (const clipInfo of newClips) {
236
+ const idx = this.scheduledClips.findIndex(
237
+ (s, i) => !matched.has(i) && this._clipsEqual(s.clipInfo, clipInfo)
238
+ );
239
+ if (idx !== -1) {
240
+ kept.push(this.scheduledClips[idx]);
241
+ matched.add(idx);
242
+ } else {
243
+ toAdd.push(clipInfo);
244
+ }
245
+ }
246
+ for (let i = 0; i < this.scheduledClips.length; i++) {
247
+ if (!matched.has(i)) {
248
+ const scheduled = this.scheduledClips[i];
249
+ try {
250
+ tp.clear(scheduled.scheduleId);
251
+ } catch {
252
+ }
253
+ try {
254
+ scheduled.fadeGainNode.disconnect();
255
+ } catch {
256
+ }
257
+ }
258
+ }
259
+ this.scheduledClips = kept;
260
+ const isPlaying = tp.state === "started";
261
+ for (const clipInfo of toAdd) {
262
+ const scheduled = this.addClip(clipInfo);
263
+ if (isPlaying) {
264
+ const context = (0, import_tone.getContext)();
265
+ const transportOffset = tp.seconds;
266
+ const audioContextTime = context.currentTime;
267
+ const lookAhead = context.lookAhead ?? 0;
268
+ const audibleOffset = Math.max(0, transportOffset - lookAhead);
269
+ const absClipStart = this.track.startTime + clipInfo.startTime;
270
+ const absClipEnd = absClipStart + clipInfo.duration;
271
+ if (absClipStart < transportOffset && absClipEnd > audibleOffset) {
272
+ const elapsed = audibleOffset - absClipStart;
273
+ this.startClipSource(
274
+ clipInfo,
275
+ scheduled.fadeGainNode,
276
+ audioContextTime,
277
+ clipInfo.offset + Math.max(0, elapsed),
278
+ clipInfo.duration - Math.max(0, elapsed)
279
+ );
280
+ }
281
+ }
282
+ }
283
+ }
284
+ /** Compare two clips by reference (buffer), timing, and fade properties */
285
+ _clipsEqual(a, b) {
286
+ return a.buffer === b.buffer && a.startTime === b.startTime && a.duration === b.duration && a.offset === b.offset && a.gain === b.gain && a.fadeIn?.duration === b.fadeIn?.duration && a.fadeIn?.type === b.fadeIn?.type && a.fadeOut?.duration === b.fadeOut?.duration && a.fadeOut?.type === b.fadeOut?.type;
287
+ }
182
288
  /**
183
289
  * Stop all active AudioBufferSourceNodes and clear the set.
184
290
  * Native AudioBufferSourceNodes ignore Transport state changes —
@@ -1031,6 +1137,34 @@ var TonePlayout = class {
1031
1137
  getTrack(trackId) {
1032
1138
  return this.tracks.get(trackId);
1033
1139
  }
1140
+ getTrackIds() {
1141
+ return [...this.tracks.keys()];
1142
+ }
1143
+ /**
1144
+ * Replace clips on a track, preserving the track's audio graph.
1145
+ * Only works for ToneTrack (audio clips), not MidiToneTrack.
1146
+ */
1147
+ replaceTrackClips(trackId, newClips, newStartTime) {
1148
+ const track = this.tracks.get(trackId);
1149
+ if (!track || !("replaceClips" in track)) return false;
1150
+ track.replaceClips(newClips, newStartTime);
1151
+ return true;
1152
+ }
1153
+ /**
1154
+ * Start mid-clip sources for a specific track at the current Transport position.
1155
+ * Call after adding/updating a track during active playback so clips that span
1156
+ * the current position produce audio immediately.
1157
+ */
1158
+ resumeTrackMidPlayback(trackId) {
1159
+ const track = this.tracks.get(trackId);
1160
+ if (!track) return;
1161
+ const transport = (0, import_tone4.getTransport)();
1162
+ if (transport.state !== "started") return;
1163
+ const context = (0, import_tone4.getContext)();
1164
+ const lookAhead = context.lookAhead ?? 0;
1165
+ const audibleOffset = Math.max(0, transport.seconds - lookAhead);
1166
+ track.startMidClipSources(audibleOffset, context.currentTime);
1167
+ }
1034
1168
  play(when, offset, duration) {
1035
1169
  if (!this.isInitialized) {
1036
1170
  throw new Error("[waveform-playlist] TonePlayout not initialized. Call init() first.");
@@ -1468,6 +1602,7 @@ function createToneAdapter(options) {
1468
1602
  let _loopStart = 0;
1469
1603
  let _loopEnd = 0;
1470
1604
  let _audioInitialized = false;
1605
+ let _pendingInit = null;
1471
1606
  function addTrackToPlayout(p, track) {
1472
1607
  const audioClips = track.clips.filter((c) => c.audioBuffer && !c.midiNotes);
1473
1608
  const midiClips = track.clips.filter((c) => c.midiNotes && c.midiNotes.length > 0);
@@ -1551,7 +1686,7 @@ function createToneAdapter(options) {
1551
1686
  try {
1552
1687
  playout.dispose();
1553
1688
  } catch (err) {
1554
- console.warn("[waveform-playlist] Error disposing previous playout during rebuild:", err);
1689
+ console.warn("[waveform-playlist] Error disposing previous playout:", err);
1555
1690
  }
1556
1691
  playout = null;
1557
1692
  }
@@ -1561,9 +1696,9 @@ function createToneAdapter(options) {
1561
1696
  effects: options?.effects
1562
1697
  });
1563
1698
  if (_audioInitialized) {
1564
- playout.init().catch((err) => {
1699
+ _pendingInit = playout.init().catch((err) => {
1565
1700
  console.warn(
1566
- "[waveform-playlist] Failed to re-initialize playout after rebuild. Audio playback will require another user gesture.",
1701
+ "[waveform-playlist] Failed to initialize playout. Audio playback will require another user gesture.",
1567
1702
  err
1568
1703
  );
1569
1704
  _audioInitialized = false;
@@ -1582,13 +1717,80 @@ function createToneAdapter(options) {
1582
1717
  }
1583
1718
  return {
1584
1719
  async init() {
1720
+ if (_pendingInit) {
1721
+ await _pendingInit;
1722
+ _pendingInit = null;
1723
+ return;
1724
+ }
1585
1725
  if (playout) {
1586
1726
  await playout.init();
1587
1727
  _audioInitialized = true;
1588
1728
  }
1589
1729
  },
1590
1730
  setTracks(tracks) {
1591
- buildPlayout(tracks);
1731
+ if (!playout) {
1732
+ buildPlayout(tracks);
1733
+ return;
1734
+ }
1735
+ const newTrackIds = new Set(tracks.map((t) => t.id));
1736
+ const oldTrackIds = new Set(playout.getTrackIds());
1737
+ for (const id of oldTrackIds) {
1738
+ if (!newTrackIds.has(id)) {
1739
+ playout.removeTrack(id);
1740
+ }
1741
+ }
1742
+ for (const track of tracks) {
1743
+ if (oldTrackIds.has(track.id)) {
1744
+ playout.removeTrack(track.id);
1745
+ playout.removeTrack(track.id + ":midi");
1746
+ }
1747
+ addTrackToPlayout(playout, track);
1748
+ }
1749
+ playout.applyInitialSoloState();
1750
+ if (_isPlaying) {
1751
+ for (const track of tracks) {
1752
+ playout.resumeTrackMidPlayback(track.id);
1753
+ playout.resumeTrackMidPlayback(track.id + ":midi");
1754
+ }
1755
+ }
1756
+ },
1757
+ updateTrack(trackId, track) {
1758
+ if (!playout) return;
1759
+ const audioClips = track.clips.filter((c) => c.audioBuffer && !c.midiNotes);
1760
+ if (audioClips.length > 0) {
1761
+ const startTime = Math.min(...audioClips.map(import_core2.clipStartTime));
1762
+ const clipInfos = audioClips.map((clip) => ({
1763
+ buffer: clip.audioBuffer,
1764
+ startTime: (0, import_core2.clipStartTime)(clip) - startTime,
1765
+ duration: (0, import_core2.clipDurationTime)(clip),
1766
+ offset: (0, import_core2.clipOffsetTime)(clip),
1767
+ fadeIn: clip.fadeIn,
1768
+ fadeOut: clip.fadeOut,
1769
+ gain: clip.gain
1770
+ }));
1771
+ const audioUpdated = playout.replaceTrackClips(trackId, clipInfos, startTime);
1772
+ const midiClips = track.clips.filter((c) => c.midiNotes && c.midiNotes.length > 0);
1773
+ if (midiClips.length > 0) {
1774
+ const midiTrackId = trackId + ":midi";
1775
+ playout.removeTrack(midiTrackId);
1776
+ addTrackToPlayout(playout, track);
1777
+ if (_isPlaying) {
1778
+ playout.resumeTrackMidPlayback(midiTrackId);
1779
+ }
1780
+ }
1781
+ if (audioUpdated) {
1782
+ playout.applyInitialSoloState();
1783
+ return;
1784
+ }
1785
+ }
1786
+ playout.removeTrack(trackId);
1787
+ playout.removeTrack(trackId + ":midi");
1788
+ addTrackToPlayout(playout, track);
1789
+ playout.applyInitialSoloState();
1790
+ if (_isPlaying) {
1791
+ playout.resumeTrackMidPlayback(trackId);
1792
+ playout.resumeTrackMidPlayback(trackId + ":midi");
1793
+ }
1592
1794
  },
1593
1795
  addTrack(track) {
1594
1796
  if (!playout) {