@waveform-playlist/playout 7.1.3 → 8.1.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.js CHANGED
@@ -51,7 +51,7 @@ function getUnderlyingAudioParam(signal) {
51
51
  if (!param && !hasWarned) {
52
52
  hasWarned = true;
53
53
  console.warn(
54
- "[waveform-playlist] Unable to access Tone.js internal _param. This likely means the Tone.js version is incompatible. Fades and mute scheduling may not work correctly."
54
+ "[waveform-playlist] Unable to access Tone.js internal _param. This likely means the Tone.js version is incompatible. Mute scheduling may not work correctly."
55
55
  );
56
56
  }
57
57
  return param;
@@ -145,13 +145,20 @@ function applyFadeOut(param, startTime, duration, type = "linear", startValue =
145
145
 
146
146
  // src/ToneTrack.ts
147
147
  var ToneTrack = class {
148
- // Count of currently playing clips
149
148
  constructor(options) {
150
- this.activePlayers = 0;
149
+ this.activeSources = /* @__PURE__ */ new Set();
150
+ // Guard against ghost tick schedule callbacks. After stop/start cycles with
151
+ // loops, stale Clock._lastUpdate causes ticks from the previous cycle to fire
152
+ // Transport.schedule() callbacks at past positions (e.g., time 0 clips fire
153
+ // when starting at offset 5s). Clips before this offset are handled by
154
+ // startMidClipSources(); schedule callbacks should only create sources for
155
+ // clips at/after this offset.
156
+ this._scheduleGuardOffset = 0;
151
157
  this.track = options.track;
152
158
  this.volumeNode = new import_tone.Volume(this.gainToDb(options.track.gain));
153
159
  this.panNode = new import_tone.Panner(options.track.stereoPan);
154
160
  this.muteGain = new import_tone.Gain(options.track.muted ? 0 : 1);
161
+ this.volumeNode.chain(this.panNode, this.muteGain);
155
162
  const destination = options.destination || (0, import_tone.getDestination)();
156
163
  if (options.effects) {
157
164
  const cleanup = options.effects(this.muteGain, destination, false);
@@ -165,45 +172,112 @@ var ToneTrack = class {
165
172
  {
166
173
  buffer: options.buffer,
167
174
  startTime: 0,
168
- // Legacy: single buffer starts at timeline position 0
169
175
  duration: options.buffer.duration,
170
- // Legacy: play full buffer duration
171
176
  offset: 0,
172
177
  fadeIn: options.track.fadeIn,
173
178
  fadeOut: options.track.fadeOut,
174
179
  gain: 1
175
180
  }
176
181
  ] : []);
177
- this.clips = clipInfos.map((clipInfo) => {
178
- const player = new import_tone.Player({
179
- url: clipInfo.buffer,
180
- loop: false,
181
- onstop: () => {
182
- this.activePlayers--;
183
- if (this.activePlayers === 0 && this.onStopCallback) {
184
- this.onStopCallback();
185
- }
182
+ const transport = (0, import_tone.getTransport)();
183
+ const rawContext = (0, import_tone.getContext)().rawContext;
184
+ const volumeNativeInput = this.volumeNode.input.input;
185
+ this.scheduledClips = clipInfos.map((clipInfo) => {
186
+ const fadeGainNode = rawContext.createGain();
187
+ fadeGainNode.gain.value = clipInfo.gain;
188
+ fadeGainNode.connect(volumeNativeInput);
189
+ const absTransportTime = this.track.startTime + clipInfo.startTime;
190
+ const scheduleId = transport.schedule((audioContextTime) => {
191
+ if (absTransportTime < this._scheduleGuardOffset) {
192
+ return;
186
193
  }
187
- });
188
- const fadeGain = new import_tone.Gain(clipInfo.gain);
189
- player.connect(fadeGain);
190
- fadeGain.chain(this.volumeNode, this.panNode, this.muteGain);
191
- return {
192
- player,
193
- clipInfo,
194
- fadeGain,
195
- pausedPosition: 0,
196
- playStartTime: 0
197
- };
194
+ this.startClipSource(clipInfo, fadeGainNode, audioContextTime);
195
+ }, absTransportTime);
196
+ return { clipInfo, fadeGainNode, scheduleId };
198
197
  });
199
198
  }
200
199
  /**
201
- * Schedule fade envelopes for a clip at the given start time
200
+ * Create and start an AudioBufferSourceNode for a clip.
201
+ * Sources are one-shot: each play or loop iteration creates a fresh one.
202
202
  */
203
- scheduleFades(clipPlayer, clipStartTime2, clipOffset = 0) {
204
- const { clipInfo, fadeGain } = clipPlayer;
205
- const audioParam = getUnderlyingAudioParam(fadeGain.gain);
206
- if (!audioParam) return;
203
+ startClipSource(clipInfo, fadeGainNode, audioContextTime, bufferOffset, playDuration) {
204
+ const rawContext = (0, import_tone.getContext)().rawContext;
205
+ const source = rawContext.createBufferSource();
206
+ source.buffer = clipInfo.buffer;
207
+ source.connect(fadeGainNode);
208
+ const offset = bufferOffset ?? clipInfo.offset;
209
+ const duration = playDuration ?? clipInfo.duration;
210
+ try {
211
+ source.start(audioContextTime, offset, duration);
212
+ } catch (err) {
213
+ console.warn(
214
+ `[waveform-playlist] Failed to start source on track "${this.id}" (time=${audioContextTime}, offset=${offset}, duration=${duration}):`,
215
+ err
216
+ );
217
+ source.disconnect();
218
+ return;
219
+ }
220
+ this.activeSources.add(source);
221
+ source.onended = () => {
222
+ this.activeSources.delete(source);
223
+ };
224
+ }
225
+ /**
226
+ * Set the schedule guard offset. Schedule callbacks for clips before this
227
+ * offset are suppressed (already handled by startMidClipSources).
228
+ * Must be called before transport.start() and in the loop handler.
229
+ */
230
+ setScheduleGuardOffset(offset) {
231
+ this._scheduleGuardOffset = offset;
232
+ }
233
+ /**
234
+ * Start sources for clips that span the given Transport position.
235
+ * Used for mid-playback seeking and loop boundary handling where
236
+ * Transport.schedule() callbacks have already passed.
237
+ *
238
+ * Uses strict < for absClipStart to avoid double-creation with
239
+ * schedule callbacks at exact Transport position (e.g., loopStart).
240
+ */
241
+ startMidClipSources(transportOffset, audioContextTime) {
242
+ for (const { clipInfo, fadeGainNode } of this.scheduledClips) {
243
+ const absClipStart = this.track.startTime + clipInfo.startTime;
244
+ const absClipEnd = absClipStart + clipInfo.duration;
245
+ if (absClipStart < transportOffset && absClipEnd > transportOffset) {
246
+ const elapsed = transportOffset - absClipStart;
247
+ const adjustedOffset = clipInfo.offset + elapsed;
248
+ const remainingDuration = clipInfo.duration - elapsed;
249
+ this.startClipSource(
250
+ clipInfo,
251
+ fadeGainNode,
252
+ audioContextTime,
253
+ adjustedOffset,
254
+ remainingDuration
255
+ );
256
+ }
257
+ }
258
+ }
259
+ /**
260
+ * Stop all active AudioBufferSourceNodes and clear the set.
261
+ * Native AudioBufferSourceNodes ignore Transport state changes —
262
+ * they must be explicitly stopped.
263
+ */
264
+ stopAllSources() {
265
+ this.activeSources.forEach((source) => {
266
+ try {
267
+ source.stop();
268
+ } catch (err) {
269
+ console.warn(`[waveform-playlist] Error stopping source on track "${this.id}":`, err);
270
+ }
271
+ });
272
+ this.activeSources.clear();
273
+ }
274
+ /**
275
+ * Schedule fade envelopes for a clip at the given AudioContext time.
276
+ * Uses native GainNode.gain (AudioParam) directly — no _param workaround needed.
277
+ */
278
+ scheduleFades(scheduled, clipStartTime2, clipOffset = 0) {
279
+ const { clipInfo, fadeGainNode } = scheduled;
280
+ const audioParam = fadeGainNode.gain;
207
281
  audioParam.cancelScheduledValues(0);
208
282
  const skipTime = clipOffset - clipInfo.offset;
209
283
  if (clipInfo.fadeIn && skipTime < clipInfo.fadeIn.duration) {
@@ -262,6 +336,35 @@ var ToneTrack = class {
262
336
  }
263
337
  }
264
338
  }
339
+ /**
340
+ * Prepare fade envelopes for all clips based on Transport offset.
341
+ * Called before Transport.start() to schedule fades at correct AudioContext times.
342
+ */
343
+ prepareFades(when, transportOffset) {
344
+ this.scheduledClips.forEach((scheduled) => {
345
+ const absClipStart = this.track.startTime + scheduled.clipInfo.startTime;
346
+ const absClipEnd = absClipStart + scheduled.clipInfo.duration;
347
+ if (transportOffset >= absClipEnd) return;
348
+ if (transportOffset >= absClipStart) {
349
+ const clipOffset = transportOffset - absClipStart + scheduled.clipInfo.offset;
350
+ this.scheduleFades(scheduled, when, clipOffset);
351
+ } else {
352
+ const delay = absClipStart - transportOffset;
353
+ this.scheduleFades(scheduled, when + delay, scheduled.clipInfo.offset);
354
+ }
355
+ });
356
+ }
357
+ /**
358
+ * Cancel all scheduled fade automation and reset to nominal gain.
359
+ * Called on pause/stop to prevent stale fade envelopes.
360
+ */
361
+ cancelFades() {
362
+ this.scheduledClips.forEach(({ fadeGainNode, clipInfo }) => {
363
+ const audioParam = fadeGainNode.gain;
364
+ audioParam.cancelScheduledValues(0);
365
+ audioParam.setValueAtTime(clipInfo.gain, 0);
366
+ });
367
+ }
265
368
  gainToDb(gain) {
266
369
  return 20 * Math.log10(gain);
267
370
  }
@@ -283,99 +386,60 @@ var ToneTrack = class {
283
386
  setSolo(soloed) {
284
387
  this.track.soloed = soloed;
285
388
  }
286
- play(when, offset = 0, duration) {
287
- this.clips.forEach((clipPlayer) => {
288
- clipPlayer.player.stop();
289
- clipPlayer.player.disconnect();
290
- clipPlayer.player.dispose();
291
- const newPlayer = new import_tone.Player({
292
- url: clipPlayer.clipInfo.buffer,
293
- loop: false,
294
- onstop: () => {
295
- this.activePlayers--;
296
- if (this.activePlayers === 0 && this.onStopCallback) {
297
- this.onStopCallback();
298
- }
299
- }
300
- });
301
- newPlayer.connect(clipPlayer.fadeGain);
302
- clipPlayer.player = newPlayer;
303
- clipPlayer.pausedPosition = 0;
304
- });
305
- this.activePlayers = 0;
306
- this.clips.forEach((clipPlayer) => {
307
- const { player, clipInfo } = clipPlayer;
308
- const playbackPosition = offset;
309
- const clipStart = clipInfo.startTime;
310
- const clipEnd = clipInfo.startTime + clipInfo.duration;
311
- if (playbackPosition < clipEnd) {
312
- this.activePlayers++;
313
- const currentTime = when ?? (0, import_tone.now)();
314
- clipPlayer.playStartTime = currentTime;
315
- if (playbackPosition >= clipStart) {
316
- const clipOffset = playbackPosition - clipStart + clipInfo.offset;
317
- const remainingDuration = clipInfo.duration - (playbackPosition - clipStart);
318
- const clipDuration = duration ? Math.min(duration, remainingDuration) : remainingDuration;
319
- clipPlayer.pausedPosition = clipOffset;
320
- this.scheduleFades(clipPlayer, currentTime, clipOffset);
321
- player.start(currentTime, clipOffset, clipDuration);
322
- } else {
323
- const delay = clipStart - playbackPosition;
324
- const clipDuration = duration ? Math.min(duration - delay, clipInfo.duration) : clipInfo.duration;
325
- if (delay < (duration ?? Infinity)) {
326
- clipPlayer.pausedPosition = clipInfo.offset;
327
- this.scheduleFades(clipPlayer, currentTime + delay, clipInfo.offset);
328
- player.start(currentTime + delay, clipInfo.offset, clipDuration);
329
- } else {
330
- this.activePlayers--;
331
- }
332
- }
333
- }
334
- });
335
- }
336
- pause() {
337
- this.clips.forEach((clipPlayer) => {
338
- if (clipPlayer.player.state === "started") {
339
- const elapsed = ((0, import_tone.now)() - clipPlayer.playStartTime) * clipPlayer.player.playbackRate;
340
- clipPlayer.pausedPosition = clipPlayer.pausedPosition + elapsed;
341
- }
342
- clipPlayer.player.stop();
343
- });
344
- this.activePlayers = 0;
345
- }
346
- stop(when) {
347
- const stopWhen = when ?? (0, import_tone.now)();
348
- this.clips.forEach((clipPlayer) => {
349
- clipPlayer.player.stop(stopWhen);
350
- clipPlayer.pausedPosition = 0;
351
- });
352
- this.activePlayers = 0;
353
- }
354
389
  dispose() {
390
+ const transport = (0, import_tone.getTransport)();
355
391
  if (this.effectsCleanup) {
356
- this.effectsCleanup();
392
+ try {
393
+ this.effectsCleanup();
394
+ } catch (err) {
395
+ console.warn(`[waveform-playlist] Error during track "${this.id}" effects cleanup:`, err);
396
+ }
357
397
  }
358
- this.clips.forEach((clipPlayer) => {
359
- clipPlayer.player.dispose();
360
- clipPlayer.fadeGain.dispose();
398
+ this.stopAllSources();
399
+ this.scheduledClips.forEach((scheduled, index) => {
400
+ try {
401
+ transport.clear(scheduled.scheduleId);
402
+ } catch (err) {
403
+ console.warn(
404
+ `[waveform-playlist] Error clearing schedule ${index} on track "${this.id}":`,
405
+ err
406
+ );
407
+ }
408
+ try {
409
+ scheduled.fadeGainNode.disconnect();
410
+ } catch (err) {
411
+ console.warn(
412
+ `[waveform-playlist] Error disconnecting fadeGain ${index} on track "${this.id}":`,
413
+ err
414
+ );
415
+ }
361
416
  });
362
- this.volumeNode.dispose();
363
- this.panNode.dispose();
364
- this.muteGain.dispose();
417
+ try {
418
+ this.volumeNode.dispose();
419
+ } catch (err) {
420
+ console.warn(`[waveform-playlist] Error disposing volumeNode on track "${this.id}":`, err);
421
+ }
422
+ try {
423
+ this.panNode.dispose();
424
+ } catch (err) {
425
+ console.warn(`[waveform-playlist] Error disposing panNode on track "${this.id}":`, err);
426
+ }
427
+ try {
428
+ this.muteGain.dispose();
429
+ } catch (err) {
430
+ console.warn(`[waveform-playlist] Error disposing muteGain on track "${this.id}":`, err);
431
+ }
365
432
  }
366
433
  get id() {
367
434
  return this.track.id;
368
435
  }
369
436
  get duration() {
370
- if (this.clips.length === 0) return 0;
371
- const lastClip = this.clips[this.clips.length - 1];
437
+ if (this.scheduledClips.length === 0) return 0;
438
+ const lastClip = this.scheduledClips[this.scheduledClips.length - 1];
372
439
  return lastClip.clipInfo.startTime + lastClip.clipInfo.duration;
373
440
  }
374
441
  get buffer() {
375
- return this.clips[0]?.clipInfo.buffer;
376
- }
377
- get isPlaying() {
378
- return this.clips.some((clipPlayer) => clipPlayer.player.state === "started");
442
+ return this.scheduledClips[0]?.clipInfo.buffer;
379
443
  }
380
444
  get muted() {
381
445
  return this.track.muted;
@@ -383,9 +447,6 @@ var ToneTrack = class {
383
447
  get startTime() {
384
448
  return this.track.startTime;
385
449
  }
386
- setOnStopCallback(callback) {
387
- this.onStopCallback = callback;
388
- }
389
450
  };
390
451
 
391
452
  // src/TonePlayout.ts
@@ -395,9 +456,11 @@ var TonePlayout = class {
395
456
  this.isInitialized = false;
396
457
  this.soloedTracks = /* @__PURE__ */ new Set();
397
458
  this.manualMuteState = /* @__PURE__ */ new Map();
398
- this.activeTracks = /* @__PURE__ */ new Map();
399
- // Map track ID to session ID
400
- this.playbackSessionId = 0;
459
+ this._completionEventId = null;
460
+ this._loopHandler = null;
461
+ this._loopEnabled = false;
462
+ this._loopStart = 0;
463
+ this._loopEnd = 0;
401
464
  this.masterVolume = new import_tone2.Volume(this.gainToDb(options.masterGain ?? 1));
402
465
  if (options.effects) {
403
466
  const cleanup = options.effects(this.masterVolume, (0, import_tone2.getDestination)(), false);
@@ -417,6 +480,16 @@ var TonePlayout = class {
417
480
  gainToDb(gain) {
418
481
  return 20 * Math.log10(gain);
419
482
  }
483
+ clearCompletionEvent() {
484
+ if (this._completionEventId !== null) {
485
+ try {
486
+ (0, import_tone2.getTransport)().clear(this._completionEventId);
487
+ } catch (err) {
488
+ console.warn("[waveform-playlist] Error clearing Transport completion event:", err);
489
+ }
490
+ this._completionEventId = null;
491
+ }
492
+ }
420
493
  async init() {
421
494
  if (this.isInitialized) return;
422
495
  await (0, import_tone2.start)();
@@ -456,63 +529,75 @@ var TonePlayout = class {
456
529
  }
457
530
  play(when, offset, duration) {
458
531
  if (!this.isInitialized) {
459
- console.warn("TonePlayout not initialized. Call init() first.");
460
- return;
532
+ throw new Error("[waveform-playlist] TonePlayout not initialized. Call init() first.");
461
533
  }
462
534
  const startTime = when ?? (0, import_tone2.now)();
463
- const playbackPosition = offset ?? 0;
464
- this.playbackSessionId++;
465
- const currentSessionId = this.playbackSessionId;
466
- this.activeTracks.clear();
467
- this.tracks.forEach((toneTrack) => {
468
- const trackStartTime = toneTrack.startTime;
469
- if (playbackPosition >= trackStartTime) {
470
- const bufferOffset = playbackPosition - trackStartTime;
471
- if (duration !== void 0) {
472
- this.activeTracks.set(toneTrack.id, currentSessionId);
473
- toneTrack.setOnStopCallback(() => {
474
- if (this.activeTracks.get(toneTrack.id) === currentSessionId) {
475
- this.activeTracks.delete(toneTrack.id);
476
- if (this.activeTracks.size === 0 && this.onPlaybackCompleteCallback) {
477
- this.onPlaybackCompleteCallback();
478
- }
479
- }
480
- });
535
+ const transport = (0, import_tone2.getTransport)();
536
+ this.clearCompletionEvent();
537
+ const transportOffset = offset ?? 0;
538
+ this.tracks.forEach((track) => {
539
+ track.cancelFades();
540
+ track.prepareFades(startTime, transportOffset);
541
+ });
542
+ if (duration !== void 0) {
543
+ this._completionEventId = transport.scheduleOnce(() => {
544
+ this._completionEventId = null;
545
+ try {
546
+ this.onPlaybackCompleteCallback?.();
547
+ } catch (err) {
548
+ console.warn("[waveform-playlist] Error in playback completion callback:", err);
481
549
  }
482
- toneTrack.play(startTime, bufferOffset, duration);
550
+ }, transportOffset + duration);
551
+ }
552
+ try {
553
+ if (transport.state !== "stopped") {
554
+ transport.stop();
555
+ }
556
+ this.tracks.forEach((track) => track.stopAllSources());
557
+ transport.loopStart = this._loopStart;
558
+ transport.loopEnd = this._loopEnd;
559
+ transport.loop = this._loopEnabled;
560
+ this.tracks.forEach((track) => track.setScheduleGuardOffset(transportOffset));
561
+ if (offset !== void 0) {
562
+ transport.start(startTime, offset);
483
563
  } else {
484
- const delay = trackStartTime - playbackPosition;
485
- if (duration !== void 0) {
486
- this.activeTracks.set(toneTrack.id, currentSessionId);
487
- toneTrack.setOnStopCallback(() => {
488
- if (this.activeTracks.get(toneTrack.id) === currentSessionId) {
489
- this.activeTracks.delete(toneTrack.id);
490
- if (this.activeTracks.size === 0 && this.onPlaybackCompleteCallback) {
491
- this.onPlaybackCompleteCallback();
492
- }
493
- }
494
- });
495
- }
496
- toneTrack.play(startTime + delay, 0, duration);
564
+ transport.start(startTime);
497
565
  }
498
- });
499
- if (offset !== void 0) {
500
- (0, import_tone2.getTransport)().start(startTime, offset);
501
- } else {
502
- (0, import_tone2.getTransport)().start(startTime);
566
+ transport._clock._lastUpdate = startTime;
567
+ this.tracks.forEach((track) => {
568
+ track.startMidClipSources(transportOffset, startTime);
569
+ });
570
+ } catch (err) {
571
+ this.clearCompletionEvent();
572
+ this.tracks.forEach((track) => track.cancelFades());
573
+ console.warn(
574
+ "[waveform-playlist] Transport.start() failed. Audio playback could not begin.",
575
+ err
576
+ );
577
+ throw err;
503
578
  }
504
579
  }
505
580
  pause() {
506
- (0, import_tone2.getTransport)().pause();
507
- this.tracks.forEach((track) => {
508
- track.pause();
509
- });
581
+ const transport = (0, import_tone2.getTransport)();
582
+ try {
583
+ transport.pause();
584
+ } catch (err) {
585
+ console.warn("[waveform-playlist] Transport.pause() failed:", err);
586
+ }
587
+ this.tracks.forEach((track) => track.stopAllSources());
588
+ this.tracks.forEach((track) => track.cancelFades());
589
+ this.clearCompletionEvent();
510
590
  }
511
591
  stop() {
512
- (0, import_tone2.getTransport)().stop();
513
- this.tracks.forEach((track) => {
514
- track.stop();
515
- });
592
+ const transport = (0, import_tone2.getTransport)();
593
+ try {
594
+ transport.stop();
595
+ } catch (err) {
596
+ console.warn("[waveform-playlist] Transport.stop() failed:", err);
597
+ }
598
+ this.tracks.forEach((track) => track.stopAllSources());
599
+ this.tracks.forEach((track) => track.cancelFades());
600
+ this.clearCompletionEvent();
516
601
  }
517
602
  setMasterGain(gain) {
518
603
  this.masterVolume.volume.value = this.gainToDb(gain);
@@ -552,6 +637,43 @@ var TonePlayout = class {
552
637
  track.setMute(muted);
553
638
  }
554
639
  }
640
+ setLoop(enabled, loopStart, loopEnd) {
641
+ this._loopEnabled = enabled;
642
+ this._loopStart = loopStart;
643
+ this._loopEnd = loopEnd;
644
+ const transport = (0, import_tone2.getTransport)();
645
+ try {
646
+ transport.loopStart = loopStart;
647
+ transport.loopEnd = loopEnd;
648
+ transport.loop = enabled;
649
+ } catch (err) {
650
+ console.warn("[waveform-playlist] Error configuring Transport loop:", err);
651
+ return;
652
+ }
653
+ if (enabled && !this._loopHandler) {
654
+ this._loopHandler = () => {
655
+ const currentTime = (0, import_tone2.now)();
656
+ this.tracks.forEach((track) => {
657
+ try {
658
+ track.stopAllSources();
659
+ track.cancelFades();
660
+ track.setScheduleGuardOffset(this._loopStart);
661
+ track.startMidClipSources(this._loopStart, currentTime);
662
+ track.prepareFades(currentTime, this._loopStart);
663
+ } catch (err) {
664
+ console.warn(
665
+ `[waveform-playlist] Error re-scheduling track "${track.id}" on loop:`,
666
+ err
667
+ );
668
+ }
669
+ });
670
+ };
671
+ transport.on("loop", this._loopHandler);
672
+ } else if (!enabled && this._loopHandler) {
673
+ transport.off("loop", this._loopHandler);
674
+ this._loopHandler = null;
675
+ }
676
+ }
555
677
  getCurrentTime() {
556
678
  return (0, import_tone2.getTransport)().seconds;
557
679
  }
@@ -559,14 +681,35 @@ var TonePlayout = class {
559
681
  (0, import_tone2.getTransport)().seconds = time;
560
682
  }
561
683
  dispose() {
684
+ this.clearCompletionEvent();
685
+ if (this._loopHandler) {
686
+ try {
687
+ (0, import_tone2.getTransport)().off("loop", this._loopHandler);
688
+ } catch (err) {
689
+ console.warn("[waveform-playlist] Error removing Transport loop handler:", err);
690
+ }
691
+ this._loopHandler = null;
692
+ }
562
693
  this.tracks.forEach((track) => {
563
- track.dispose();
694
+ try {
695
+ track.dispose();
696
+ } catch (err) {
697
+ console.warn(`[waveform-playlist] Error disposing track "${track.id}":`, err);
698
+ }
564
699
  });
565
700
  this.tracks.clear();
566
701
  if (this.effectsCleanup) {
567
- this.effectsCleanup();
702
+ try {
703
+ this.effectsCleanup();
704
+ } catch (err) {
705
+ console.warn("[waveform-playlist] Error during master effects cleanup:", err);
706
+ }
707
+ }
708
+ try {
709
+ this.masterVolume.dispose();
710
+ } catch (err) {
711
+ console.warn("[waveform-playlist] Error disposing master volume:", err);
568
712
  }
569
- this.masterVolume.dispose();
570
713
  }
571
714
  get context() {
572
715
  return (0, import_tone2.getContext)();
@@ -651,15 +794,33 @@ function createToneAdapter(options) {
651
794
  let playout = null;
652
795
  let _isPlaying = false;
653
796
  let _playoutGeneration = 0;
797
+ let _loopEnabled = false;
798
+ let _loopStart = 0;
799
+ let _loopEnd = 0;
800
+ let _audioInitialized = false;
654
801
  function buildPlayout(tracks) {
655
802
  if (playout) {
656
- playout.dispose();
803
+ try {
804
+ playout.dispose();
805
+ } catch (err) {
806
+ console.warn("[waveform-playlist] Error disposing previous playout during rebuild:", err);
807
+ }
808
+ playout = null;
657
809
  }
658
810
  _playoutGeneration++;
659
811
  const generation = _playoutGeneration;
660
812
  playout = new TonePlayout({
661
813
  effects: options?.effects
662
814
  });
815
+ if (_audioInitialized) {
816
+ playout.init().catch((err) => {
817
+ console.warn(
818
+ "[waveform-playlist] Failed to re-initialize playout after rebuild. Audio playback will require another user gesture.",
819
+ err
820
+ );
821
+ _audioInitialized = false;
822
+ });
823
+ }
663
824
  for (const track of tracks) {
664
825
  const playableClips = track.clips.filter((c) => c.audioBuffer);
665
826
  if (playableClips.length === 0) continue;
@@ -691,6 +852,7 @@ function createToneAdapter(options) {
691
852
  });
692
853
  }
693
854
  playout.applyInitialSoloState();
855
+ playout.setLoop(_loopEnabled, _loopStart, _loopEnd);
694
856
  playout.setOnPlaybackComplete(() => {
695
857
  if (generation === _playoutGeneration) {
696
858
  _isPlaying = false;
@@ -701,14 +863,19 @@ function createToneAdapter(options) {
701
863
  async init() {
702
864
  if (playout) {
703
865
  await playout.init();
866
+ _audioInitialized = true;
704
867
  }
705
868
  },
706
869
  setTracks(tracks) {
707
870
  buildPlayout(tracks);
708
871
  },
709
- async play(startTime, endTime) {
710
- if (!playout) return;
711
- await playout.init();
872
+ play(startTime, endTime) {
873
+ if (!playout) {
874
+ console.warn(
875
+ "[waveform-playlist] adapter.play() called but no playout is available. Tracks may not have been set, or the adapter was disposed."
876
+ );
877
+ return;
878
+ }
712
879
  const duration = endTime !== void 0 ? endTime - startTime : void 0;
713
880
  playout.play((0, import_tone5.now)(), startTime, duration);
714
881
  _isPlaying = true;
@@ -745,8 +912,18 @@ function createToneAdapter(options) {
745
912
  setTrackPan(trackId, pan) {
746
913
  playout?.getTrack(trackId)?.setPan(pan);
747
914
  },
915
+ setLoop(enabled, start2, end) {
916
+ _loopEnabled = enabled;
917
+ _loopStart = start2;
918
+ _loopEnd = end;
919
+ playout?.setLoop(enabled, start2, end);
920
+ },
748
921
  dispose() {
749
- playout?.dispose();
922
+ try {
923
+ playout?.dispose();
924
+ } catch (err) {
925
+ console.warn("[waveform-playlist] Error disposing playout:", err);
926
+ }
750
927
  playout = null;
751
928
  _isPlaying = false;
752
929
  }