@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.mjs CHANGED
@@ -3,13 +3,20 @@ import {
3
3
  Volume as Volume2,
4
4
  getDestination as getDestination2,
5
5
  start,
6
- now as now2,
7
- getTransport,
8
- getContext
6
+ now,
7
+ getTransport as getTransport2,
8
+ getContext as getContext2
9
9
  } from "tone";
10
10
 
11
11
  // src/ToneTrack.ts
12
- import { Player, Volume, Gain, Panner, getDestination, now } from "tone";
12
+ import {
13
+ Volume,
14
+ Gain,
15
+ Panner,
16
+ getDestination,
17
+ getTransport,
18
+ getContext
19
+ } from "tone";
13
20
 
14
21
  // src/fades.ts
15
22
  var hasWarned = false;
@@ -18,7 +25,7 @@ function getUnderlyingAudioParam(signal) {
18
25
  if (!param && !hasWarned) {
19
26
  hasWarned = true;
20
27
  console.warn(
21
- "[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."
28
+ "[waveform-playlist] Unable to access Tone.js internal _param. This likely means the Tone.js version is incompatible. Mute scheduling may not work correctly."
22
29
  );
23
30
  }
24
31
  return param;
@@ -112,13 +119,20 @@ function applyFadeOut(param, startTime, duration, type = "linear", startValue =
112
119
 
113
120
  // src/ToneTrack.ts
114
121
  var ToneTrack = class {
115
- // Count of currently playing clips
116
122
  constructor(options) {
117
- this.activePlayers = 0;
123
+ this.activeSources = /* @__PURE__ */ new Set();
124
+ // Guard against ghost tick schedule callbacks. After stop/start cycles with
125
+ // loops, stale Clock._lastUpdate causes ticks from the previous cycle to fire
126
+ // Transport.schedule() callbacks at past positions (e.g., time 0 clips fire
127
+ // when starting at offset 5s). Clips before this offset are handled by
128
+ // startMidClipSources(); schedule callbacks should only create sources for
129
+ // clips at/after this offset.
130
+ this._scheduleGuardOffset = 0;
118
131
  this.track = options.track;
119
132
  this.volumeNode = new Volume(this.gainToDb(options.track.gain));
120
133
  this.panNode = new Panner(options.track.stereoPan);
121
134
  this.muteGain = new Gain(options.track.muted ? 0 : 1);
135
+ this.volumeNode.chain(this.panNode, this.muteGain);
122
136
  const destination = options.destination || getDestination();
123
137
  if (options.effects) {
124
138
  const cleanup = options.effects(this.muteGain, destination, false);
@@ -132,45 +146,112 @@ var ToneTrack = class {
132
146
  {
133
147
  buffer: options.buffer,
134
148
  startTime: 0,
135
- // Legacy: single buffer starts at timeline position 0
136
149
  duration: options.buffer.duration,
137
- // Legacy: play full buffer duration
138
150
  offset: 0,
139
151
  fadeIn: options.track.fadeIn,
140
152
  fadeOut: options.track.fadeOut,
141
153
  gain: 1
142
154
  }
143
155
  ] : []);
144
- this.clips = clipInfos.map((clipInfo) => {
145
- const player = new Player({
146
- url: clipInfo.buffer,
147
- loop: false,
148
- onstop: () => {
149
- this.activePlayers--;
150
- if (this.activePlayers === 0 && this.onStopCallback) {
151
- this.onStopCallback();
152
- }
156
+ const transport = getTransport();
157
+ const rawContext = getContext().rawContext;
158
+ const volumeNativeInput = this.volumeNode.input.input;
159
+ this.scheduledClips = clipInfos.map((clipInfo) => {
160
+ const fadeGainNode = rawContext.createGain();
161
+ fadeGainNode.gain.value = clipInfo.gain;
162
+ fadeGainNode.connect(volumeNativeInput);
163
+ const absTransportTime = this.track.startTime + clipInfo.startTime;
164
+ const scheduleId = transport.schedule((audioContextTime) => {
165
+ if (absTransportTime < this._scheduleGuardOffset) {
166
+ return;
153
167
  }
154
- });
155
- const fadeGain = new Gain(clipInfo.gain);
156
- player.connect(fadeGain);
157
- fadeGain.chain(this.volumeNode, this.panNode, this.muteGain);
158
- return {
159
- player,
160
- clipInfo,
161
- fadeGain,
162
- pausedPosition: 0,
163
- playStartTime: 0
164
- };
168
+ this.startClipSource(clipInfo, fadeGainNode, audioContextTime);
169
+ }, absTransportTime);
170
+ return { clipInfo, fadeGainNode, scheduleId };
171
+ });
172
+ }
173
+ /**
174
+ * Create and start an AudioBufferSourceNode for a clip.
175
+ * Sources are one-shot: each play or loop iteration creates a fresh one.
176
+ */
177
+ startClipSource(clipInfo, fadeGainNode, audioContextTime, bufferOffset, playDuration) {
178
+ const rawContext = getContext().rawContext;
179
+ const source = rawContext.createBufferSource();
180
+ source.buffer = clipInfo.buffer;
181
+ source.connect(fadeGainNode);
182
+ const offset = bufferOffset ?? clipInfo.offset;
183
+ const duration = playDuration ?? clipInfo.duration;
184
+ try {
185
+ source.start(audioContextTime, offset, duration);
186
+ } catch (err) {
187
+ console.warn(
188
+ `[waveform-playlist] Failed to start source on track "${this.id}" (time=${audioContextTime}, offset=${offset}, duration=${duration}):`,
189
+ err
190
+ );
191
+ source.disconnect();
192
+ return;
193
+ }
194
+ this.activeSources.add(source);
195
+ source.onended = () => {
196
+ this.activeSources.delete(source);
197
+ };
198
+ }
199
+ /**
200
+ * Set the schedule guard offset. Schedule callbacks for clips before this
201
+ * offset are suppressed (already handled by startMidClipSources).
202
+ * Must be called before transport.start() and in the loop handler.
203
+ */
204
+ setScheduleGuardOffset(offset) {
205
+ this._scheduleGuardOffset = offset;
206
+ }
207
+ /**
208
+ * Start sources for clips that span the given Transport position.
209
+ * Used for mid-playback seeking and loop boundary handling where
210
+ * Transport.schedule() callbacks have already passed.
211
+ *
212
+ * Uses strict < for absClipStart to avoid double-creation with
213
+ * schedule callbacks at exact Transport position (e.g., loopStart).
214
+ */
215
+ startMidClipSources(transportOffset, audioContextTime) {
216
+ for (const { clipInfo, fadeGainNode } of this.scheduledClips) {
217
+ const absClipStart = this.track.startTime + clipInfo.startTime;
218
+ const absClipEnd = absClipStart + clipInfo.duration;
219
+ if (absClipStart < transportOffset && absClipEnd > transportOffset) {
220
+ const elapsed = transportOffset - absClipStart;
221
+ const adjustedOffset = clipInfo.offset + elapsed;
222
+ const remainingDuration = clipInfo.duration - elapsed;
223
+ this.startClipSource(
224
+ clipInfo,
225
+ fadeGainNode,
226
+ audioContextTime,
227
+ adjustedOffset,
228
+ remainingDuration
229
+ );
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Stop all active AudioBufferSourceNodes and clear the set.
235
+ * Native AudioBufferSourceNodes ignore Transport state changes —
236
+ * they must be explicitly stopped.
237
+ */
238
+ stopAllSources() {
239
+ this.activeSources.forEach((source) => {
240
+ try {
241
+ source.stop();
242
+ } catch (err) {
243
+ console.warn(`[waveform-playlist] Error stopping source on track "${this.id}":`, err);
244
+ }
165
245
  });
246
+ this.activeSources.clear();
166
247
  }
167
248
  /**
168
- * Schedule fade envelopes for a clip at the given start time
249
+ * Schedule fade envelopes for a clip at the given AudioContext time.
250
+ * Uses native GainNode.gain (AudioParam) directly — no _param workaround needed.
169
251
  */
170
- scheduleFades(clipPlayer, clipStartTime2, clipOffset = 0) {
171
- const { clipInfo, fadeGain } = clipPlayer;
172
- const audioParam = getUnderlyingAudioParam(fadeGain.gain);
173
- if (!audioParam) return;
252
+ scheduleFades(scheduled, clipStartTime2, clipOffset = 0) {
253
+ const { clipInfo, fadeGainNode } = scheduled;
254
+ const audioParam = fadeGainNode.gain;
174
255
  audioParam.cancelScheduledValues(0);
175
256
  const skipTime = clipOffset - clipInfo.offset;
176
257
  if (clipInfo.fadeIn && skipTime < clipInfo.fadeIn.duration) {
@@ -229,6 +310,35 @@ var ToneTrack = class {
229
310
  }
230
311
  }
231
312
  }
313
+ /**
314
+ * Prepare fade envelopes for all clips based on Transport offset.
315
+ * Called before Transport.start() to schedule fades at correct AudioContext times.
316
+ */
317
+ prepareFades(when, transportOffset) {
318
+ this.scheduledClips.forEach((scheduled) => {
319
+ const absClipStart = this.track.startTime + scheduled.clipInfo.startTime;
320
+ const absClipEnd = absClipStart + scheduled.clipInfo.duration;
321
+ if (transportOffset >= absClipEnd) return;
322
+ if (transportOffset >= absClipStart) {
323
+ const clipOffset = transportOffset - absClipStart + scheduled.clipInfo.offset;
324
+ this.scheduleFades(scheduled, when, clipOffset);
325
+ } else {
326
+ const delay = absClipStart - transportOffset;
327
+ this.scheduleFades(scheduled, when + delay, scheduled.clipInfo.offset);
328
+ }
329
+ });
330
+ }
331
+ /**
332
+ * Cancel all scheduled fade automation and reset to nominal gain.
333
+ * Called on pause/stop to prevent stale fade envelopes.
334
+ */
335
+ cancelFades() {
336
+ this.scheduledClips.forEach(({ fadeGainNode, clipInfo }) => {
337
+ const audioParam = fadeGainNode.gain;
338
+ audioParam.cancelScheduledValues(0);
339
+ audioParam.setValueAtTime(clipInfo.gain, 0);
340
+ });
341
+ }
232
342
  gainToDb(gain) {
233
343
  return 20 * Math.log10(gain);
234
344
  }
@@ -250,99 +360,60 @@ var ToneTrack = class {
250
360
  setSolo(soloed) {
251
361
  this.track.soloed = soloed;
252
362
  }
253
- play(when, offset = 0, duration) {
254
- this.clips.forEach((clipPlayer) => {
255
- clipPlayer.player.stop();
256
- clipPlayer.player.disconnect();
257
- clipPlayer.player.dispose();
258
- const newPlayer = new Player({
259
- url: clipPlayer.clipInfo.buffer,
260
- loop: false,
261
- onstop: () => {
262
- this.activePlayers--;
263
- if (this.activePlayers === 0 && this.onStopCallback) {
264
- this.onStopCallback();
265
- }
266
- }
267
- });
268
- newPlayer.connect(clipPlayer.fadeGain);
269
- clipPlayer.player = newPlayer;
270
- clipPlayer.pausedPosition = 0;
271
- });
272
- this.activePlayers = 0;
273
- this.clips.forEach((clipPlayer) => {
274
- const { player, clipInfo } = clipPlayer;
275
- const playbackPosition = offset;
276
- const clipStart = clipInfo.startTime;
277
- const clipEnd = clipInfo.startTime + clipInfo.duration;
278
- if (playbackPosition < clipEnd) {
279
- this.activePlayers++;
280
- const currentTime = when ?? now();
281
- clipPlayer.playStartTime = currentTime;
282
- if (playbackPosition >= clipStart) {
283
- const clipOffset = playbackPosition - clipStart + clipInfo.offset;
284
- const remainingDuration = clipInfo.duration - (playbackPosition - clipStart);
285
- const clipDuration = duration ? Math.min(duration, remainingDuration) : remainingDuration;
286
- clipPlayer.pausedPosition = clipOffset;
287
- this.scheduleFades(clipPlayer, currentTime, clipOffset);
288
- player.start(currentTime, clipOffset, clipDuration);
289
- } else {
290
- const delay = clipStart - playbackPosition;
291
- const clipDuration = duration ? Math.min(duration - delay, clipInfo.duration) : clipInfo.duration;
292
- if (delay < (duration ?? Infinity)) {
293
- clipPlayer.pausedPosition = clipInfo.offset;
294
- this.scheduleFades(clipPlayer, currentTime + delay, clipInfo.offset);
295
- player.start(currentTime + delay, clipInfo.offset, clipDuration);
296
- } else {
297
- this.activePlayers--;
298
- }
299
- }
300
- }
301
- });
302
- }
303
- pause() {
304
- this.clips.forEach((clipPlayer) => {
305
- if (clipPlayer.player.state === "started") {
306
- const elapsed = (now() - clipPlayer.playStartTime) * clipPlayer.player.playbackRate;
307
- clipPlayer.pausedPosition = clipPlayer.pausedPosition + elapsed;
308
- }
309
- clipPlayer.player.stop();
310
- });
311
- this.activePlayers = 0;
312
- }
313
- stop(when) {
314
- const stopWhen = when ?? now();
315
- this.clips.forEach((clipPlayer) => {
316
- clipPlayer.player.stop(stopWhen);
317
- clipPlayer.pausedPosition = 0;
318
- });
319
- this.activePlayers = 0;
320
- }
321
363
  dispose() {
364
+ const transport = getTransport();
322
365
  if (this.effectsCleanup) {
323
- this.effectsCleanup();
366
+ try {
367
+ this.effectsCleanup();
368
+ } catch (err) {
369
+ console.warn(`[waveform-playlist] Error during track "${this.id}" effects cleanup:`, err);
370
+ }
324
371
  }
325
- this.clips.forEach((clipPlayer) => {
326
- clipPlayer.player.dispose();
327
- clipPlayer.fadeGain.dispose();
372
+ this.stopAllSources();
373
+ this.scheduledClips.forEach((scheduled, index) => {
374
+ try {
375
+ transport.clear(scheduled.scheduleId);
376
+ } catch (err) {
377
+ console.warn(
378
+ `[waveform-playlist] Error clearing schedule ${index} on track "${this.id}":`,
379
+ err
380
+ );
381
+ }
382
+ try {
383
+ scheduled.fadeGainNode.disconnect();
384
+ } catch (err) {
385
+ console.warn(
386
+ `[waveform-playlist] Error disconnecting fadeGain ${index} on track "${this.id}":`,
387
+ err
388
+ );
389
+ }
328
390
  });
329
- this.volumeNode.dispose();
330
- this.panNode.dispose();
331
- this.muteGain.dispose();
391
+ try {
392
+ this.volumeNode.dispose();
393
+ } catch (err) {
394
+ console.warn(`[waveform-playlist] Error disposing volumeNode on track "${this.id}":`, err);
395
+ }
396
+ try {
397
+ this.panNode.dispose();
398
+ } catch (err) {
399
+ console.warn(`[waveform-playlist] Error disposing panNode on track "${this.id}":`, err);
400
+ }
401
+ try {
402
+ this.muteGain.dispose();
403
+ } catch (err) {
404
+ console.warn(`[waveform-playlist] Error disposing muteGain on track "${this.id}":`, err);
405
+ }
332
406
  }
333
407
  get id() {
334
408
  return this.track.id;
335
409
  }
336
410
  get duration() {
337
- if (this.clips.length === 0) return 0;
338
- const lastClip = this.clips[this.clips.length - 1];
411
+ if (this.scheduledClips.length === 0) return 0;
412
+ const lastClip = this.scheduledClips[this.scheduledClips.length - 1];
339
413
  return lastClip.clipInfo.startTime + lastClip.clipInfo.duration;
340
414
  }
341
415
  get buffer() {
342
- return this.clips[0]?.clipInfo.buffer;
343
- }
344
- get isPlaying() {
345
- return this.clips.some((clipPlayer) => clipPlayer.player.state === "started");
416
+ return this.scheduledClips[0]?.clipInfo.buffer;
346
417
  }
347
418
  get muted() {
348
419
  return this.track.muted;
@@ -350,9 +421,6 @@ var ToneTrack = class {
350
421
  get startTime() {
351
422
  return this.track.startTime;
352
423
  }
353
- setOnStopCallback(callback) {
354
- this.onStopCallback = callback;
355
- }
356
424
  };
357
425
 
358
426
  // src/TonePlayout.ts
@@ -362,9 +430,11 @@ var TonePlayout = class {
362
430
  this.isInitialized = false;
363
431
  this.soloedTracks = /* @__PURE__ */ new Set();
364
432
  this.manualMuteState = /* @__PURE__ */ new Map();
365
- this.activeTracks = /* @__PURE__ */ new Map();
366
- // Map track ID to session ID
367
- this.playbackSessionId = 0;
433
+ this._completionEventId = null;
434
+ this._loopHandler = null;
435
+ this._loopEnabled = false;
436
+ this._loopStart = 0;
437
+ this._loopEnd = 0;
368
438
  this.masterVolume = new Volume2(this.gainToDb(options.masterGain ?? 1));
369
439
  if (options.effects) {
370
440
  const cleanup = options.effects(this.masterVolume, getDestination2(), false);
@@ -384,6 +454,16 @@ var TonePlayout = class {
384
454
  gainToDb(gain) {
385
455
  return 20 * Math.log10(gain);
386
456
  }
457
+ clearCompletionEvent() {
458
+ if (this._completionEventId !== null) {
459
+ try {
460
+ getTransport2().clear(this._completionEventId);
461
+ } catch (err) {
462
+ console.warn("[waveform-playlist] Error clearing Transport completion event:", err);
463
+ }
464
+ this._completionEventId = null;
465
+ }
466
+ }
387
467
  async init() {
388
468
  if (this.isInitialized) return;
389
469
  await start();
@@ -423,63 +503,75 @@ var TonePlayout = class {
423
503
  }
424
504
  play(when, offset, duration) {
425
505
  if (!this.isInitialized) {
426
- console.warn("TonePlayout not initialized. Call init() first.");
427
- return;
506
+ throw new Error("[waveform-playlist] TonePlayout not initialized. Call init() first.");
428
507
  }
429
- const startTime = when ?? now2();
430
- const playbackPosition = offset ?? 0;
431
- this.playbackSessionId++;
432
- const currentSessionId = this.playbackSessionId;
433
- this.activeTracks.clear();
434
- this.tracks.forEach((toneTrack) => {
435
- const trackStartTime = toneTrack.startTime;
436
- if (playbackPosition >= trackStartTime) {
437
- const bufferOffset = playbackPosition - trackStartTime;
438
- if (duration !== void 0) {
439
- this.activeTracks.set(toneTrack.id, currentSessionId);
440
- toneTrack.setOnStopCallback(() => {
441
- if (this.activeTracks.get(toneTrack.id) === currentSessionId) {
442
- this.activeTracks.delete(toneTrack.id);
443
- if (this.activeTracks.size === 0 && this.onPlaybackCompleteCallback) {
444
- this.onPlaybackCompleteCallback();
445
- }
446
- }
447
- });
508
+ const startTime = when ?? now();
509
+ const transport = getTransport2();
510
+ this.clearCompletionEvent();
511
+ const transportOffset = offset ?? 0;
512
+ this.tracks.forEach((track) => {
513
+ track.cancelFades();
514
+ track.prepareFades(startTime, transportOffset);
515
+ });
516
+ if (duration !== void 0) {
517
+ this._completionEventId = transport.scheduleOnce(() => {
518
+ this._completionEventId = null;
519
+ try {
520
+ this.onPlaybackCompleteCallback?.();
521
+ } catch (err) {
522
+ console.warn("[waveform-playlist] Error in playback completion callback:", err);
448
523
  }
449
- toneTrack.play(startTime, bufferOffset, duration);
524
+ }, transportOffset + duration);
525
+ }
526
+ try {
527
+ if (transport.state !== "stopped") {
528
+ transport.stop();
529
+ }
530
+ this.tracks.forEach((track) => track.stopAllSources());
531
+ transport.loopStart = this._loopStart;
532
+ transport.loopEnd = this._loopEnd;
533
+ transport.loop = this._loopEnabled;
534
+ this.tracks.forEach((track) => track.setScheduleGuardOffset(transportOffset));
535
+ if (offset !== void 0) {
536
+ transport.start(startTime, offset);
450
537
  } else {
451
- const delay = trackStartTime - playbackPosition;
452
- if (duration !== void 0) {
453
- this.activeTracks.set(toneTrack.id, currentSessionId);
454
- toneTrack.setOnStopCallback(() => {
455
- if (this.activeTracks.get(toneTrack.id) === currentSessionId) {
456
- this.activeTracks.delete(toneTrack.id);
457
- if (this.activeTracks.size === 0 && this.onPlaybackCompleteCallback) {
458
- this.onPlaybackCompleteCallback();
459
- }
460
- }
461
- });
462
- }
463
- toneTrack.play(startTime + delay, 0, duration);
538
+ transport.start(startTime);
464
539
  }
465
- });
466
- if (offset !== void 0) {
467
- getTransport().start(startTime, offset);
468
- } else {
469
- getTransport().start(startTime);
540
+ transport._clock._lastUpdate = startTime;
541
+ this.tracks.forEach((track) => {
542
+ track.startMidClipSources(transportOffset, startTime);
543
+ });
544
+ } catch (err) {
545
+ this.clearCompletionEvent();
546
+ this.tracks.forEach((track) => track.cancelFades());
547
+ console.warn(
548
+ "[waveform-playlist] Transport.start() failed. Audio playback could not begin.",
549
+ err
550
+ );
551
+ throw err;
470
552
  }
471
553
  }
472
554
  pause() {
473
- getTransport().pause();
474
- this.tracks.forEach((track) => {
475
- track.pause();
476
- });
555
+ const transport = getTransport2();
556
+ try {
557
+ transport.pause();
558
+ } catch (err) {
559
+ console.warn("[waveform-playlist] Transport.pause() failed:", err);
560
+ }
561
+ this.tracks.forEach((track) => track.stopAllSources());
562
+ this.tracks.forEach((track) => track.cancelFades());
563
+ this.clearCompletionEvent();
477
564
  }
478
565
  stop() {
479
- getTransport().stop();
480
- this.tracks.forEach((track) => {
481
- track.stop();
482
- });
566
+ const transport = getTransport2();
567
+ try {
568
+ transport.stop();
569
+ } catch (err) {
570
+ console.warn("[waveform-playlist] Transport.stop() failed:", err);
571
+ }
572
+ this.tracks.forEach((track) => track.stopAllSources());
573
+ this.tracks.forEach((track) => track.cancelFades());
574
+ this.clearCompletionEvent();
483
575
  }
484
576
  setMasterGain(gain) {
485
577
  this.masterVolume.volume.value = this.gainToDb(gain);
@@ -519,27 +611,85 @@ var TonePlayout = class {
519
611
  track.setMute(muted);
520
612
  }
521
613
  }
614
+ setLoop(enabled, loopStart, loopEnd) {
615
+ this._loopEnabled = enabled;
616
+ this._loopStart = loopStart;
617
+ this._loopEnd = loopEnd;
618
+ const transport = getTransport2();
619
+ try {
620
+ transport.loopStart = loopStart;
621
+ transport.loopEnd = loopEnd;
622
+ transport.loop = enabled;
623
+ } catch (err) {
624
+ console.warn("[waveform-playlist] Error configuring Transport loop:", err);
625
+ return;
626
+ }
627
+ if (enabled && !this._loopHandler) {
628
+ this._loopHandler = () => {
629
+ const currentTime = now();
630
+ this.tracks.forEach((track) => {
631
+ try {
632
+ track.stopAllSources();
633
+ track.cancelFades();
634
+ track.setScheduleGuardOffset(this._loopStart);
635
+ track.startMidClipSources(this._loopStart, currentTime);
636
+ track.prepareFades(currentTime, this._loopStart);
637
+ } catch (err) {
638
+ console.warn(
639
+ `[waveform-playlist] Error re-scheduling track "${track.id}" on loop:`,
640
+ err
641
+ );
642
+ }
643
+ });
644
+ };
645
+ transport.on("loop", this._loopHandler);
646
+ } else if (!enabled && this._loopHandler) {
647
+ transport.off("loop", this._loopHandler);
648
+ this._loopHandler = null;
649
+ }
650
+ }
522
651
  getCurrentTime() {
523
- return getTransport().seconds;
652
+ return getTransport2().seconds;
524
653
  }
525
654
  seekTo(time) {
526
- getTransport().seconds = time;
655
+ getTransport2().seconds = time;
527
656
  }
528
657
  dispose() {
658
+ this.clearCompletionEvent();
659
+ if (this._loopHandler) {
660
+ try {
661
+ getTransport2().off("loop", this._loopHandler);
662
+ } catch (err) {
663
+ console.warn("[waveform-playlist] Error removing Transport loop handler:", err);
664
+ }
665
+ this._loopHandler = null;
666
+ }
529
667
  this.tracks.forEach((track) => {
530
- track.dispose();
668
+ try {
669
+ track.dispose();
670
+ } catch (err) {
671
+ console.warn(`[waveform-playlist] Error disposing track "${track.id}":`, err);
672
+ }
531
673
  });
532
674
  this.tracks.clear();
533
675
  if (this.effectsCleanup) {
534
- this.effectsCleanup();
676
+ try {
677
+ this.effectsCleanup();
678
+ } catch (err) {
679
+ console.warn("[waveform-playlist] Error during master effects cleanup:", err);
680
+ }
681
+ }
682
+ try {
683
+ this.masterVolume.dispose();
684
+ } catch (err) {
685
+ console.warn("[waveform-playlist] Error disposing master volume:", err);
535
686
  }
536
- this.masterVolume.dispose();
537
687
  }
538
688
  get context() {
539
- return getContext();
689
+ return getContext2();
540
690
  }
541
691
  get sampleRate() {
542
- return getContext().sampleRate;
692
+ return getContext2().sampleRate;
543
693
  }
544
694
  setOnPlaybackComplete(callback) {
545
695
  this.onPlaybackCompleteCallback = callback;
@@ -579,14 +729,14 @@ async function closeGlobalAudioContext() {
579
729
  }
580
730
 
581
731
  // src/mediaStreamSourceManager.ts
582
- import { getContext as getContext2 } from "tone";
732
+ import { getContext as getContext3 } from "tone";
583
733
  var streamSources = /* @__PURE__ */ new Map();
584
734
  var streamCleanupHandlers = /* @__PURE__ */ new Map();
585
735
  function getMediaStreamSource(stream) {
586
736
  if (streamSources.has(stream)) {
587
737
  return streamSources.get(stream);
588
738
  }
589
- const context = getContext2();
739
+ const context = getContext3();
590
740
  const source = context.createMediaStreamSource(stream);
591
741
  streamSources.set(stream, source);
592
742
  const cleanup = () => {
@@ -618,20 +768,38 @@ import {
618
768
  clipOffsetTime,
619
769
  clipDurationTime
620
770
  } from "@waveform-playlist/core";
621
- import { now as now3 } from "tone";
771
+ import { now as now2 } from "tone";
622
772
  function createToneAdapter(options) {
623
773
  let playout = null;
624
774
  let _isPlaying = false;
625
775
  let _playoutGeneration = 0;
776
+ let _loopEnabled = false;
777
+ let _loopStart = 0;
778
+ let _loopEnd = 0;
779
+ let _audioInitialized = false;
626
780
  function buildPlayout(tracks) {
627
781
  if (playout) {
628
- playout.dispose();
782
+ try {
783
+ playout.dispose();
784
+ } catch (err) {
785
+ console.warn("[waveform-playlist] Error disposing previous playout during rebuild:", err);
786
+ }
787
+ playout = null;
629
788
  }
630
789
  _playoutGeneration++;
631
790
  const generation = _playoutGeneration;
632
791
  playout = new TonePlayout({
633
792
  effects: options?.effects
634
793
  });
794
+ if (_audioInitialized) {
795
+ playout.init().catch((err) => {
796
+ console.warn(
797
+ "[waveform-playlist] Failed to re-initialize playout after rebuild. Audio playback will require another user gesture.",
798
+ err
799
+ );
800
+ _audioInitialized = false;
801
+ });
802
+ }
635
803
  for (const track of tracks) {
636
804
  const playableClips = track.clips.filter((c) => c.audioBuffer);
637
805
  if (playableClips.length === 0) continue;
@@ -663,6 +831,7 @@ function createToneAdapter(options) {
663
831
  });
664
832
  }
665
833
  playout.applyInitialSoloState();
834
+ playout.setLoop(_loopEnabled, _loopStart, _loopEnd);
666
835
  playout.setOnPlaybackComplete(() => {
667
836
  if (generation === _playoutGeneration) {
668
837
  _isPlaying = false;
@@ -673,16 +842,21 @@ function createToneAdapter(options) {
673
842
  async init() {
674
843
  if (playout) {
675
844
  await playout.init();
845
+ _audioInitialized = true;
676
846
  }
677
847
  },
678
848
  setTracks(tracks) {
679
849
  buildPlayout(tracks);
680
850
  },
681
- async play(startTime, endTime) {
682
- if (!playout) return;
683
- await playout.init();
851
+ play(startTime, endTime) {
852
+ if (!playout) {
853
+ console.warn(
854
+ "[waveform-playlist] adapter.play() called but no playout is available. Tracks may not have been set, or the adapter was disposed."
855
+ );
856
+ return;
857
+ }
684
858
  const duration = endTime !== void 0 ? endTime - startTime : void 0;
685
- playout.play(now3(), startTime, duration);
859
+ playout.play(now2(), startTime, duration);
686
860
  _isPlaying = true;
687
861
  },
688
862
  pause() {
@@ -717,8 +891,18 @@ function createToneAdapter(options) {
717
891
  setTrackPan(trackId, pan) {
718
892
  playout?.getTrack(trackId)?.setPan(pan);
719
893
  },
894
+ setLoop(enabled, start2, end) {
895
+ _loopEnabled = enabled;
896
+ _loopStart = start2;
897
+ _loopEnd = end;
898
+ playout?.setLoop(enabled, start2, end);
899
+ },
720
900
  dispose() {
721
- playout?.dispose();
901
+ try {
902
+ playout?.dispose();
903
+ } catch (err) {
904
+ console.warn("[waveform-playlist] Error disposing playout:", err);
905
+ }
722
906
  playout = null;
723
907
  _isPlaying = false;
724
908
  }