@dawcore/transport 0.0.2 → 0.0.4

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
@@ -49,12 +49,15 @@ var Clock = class {
49
49
 
50
50
  // src/core/scheduler.ts
51
51
  var Scheduler = class {
52
- constructor(options = {}) {
52
+ constructor(tempoMap, options = {}) {
53
53
  this._rightEdge = 0;
54
+ // integer ticks
54
55
  this._listeners = /* @__PURE__ */ new Set();
55
56
  this._loopEnabled = false;
56
57
  this._loopStart = 0;
58
+ // integer ticks
57
59
  this._loopEnd = 0;
60
+ this._tempoMap = tempoMap;
58
61
  this._lookahead = options.lookahead ?? 0.2;
59
62
  this._onLoop = options.onLoop;
60
63
  }
@@ -64,25 +67,40 @@ var Scheduler = class {
64
67
  removeListener(listener) {
65
68
  this._listeners.delete(listener);
66
69
  }
67
- setLoop(enabled, start, end) {
68
- if (enabled && start >= end) {
70
+ /** Primary API — ticks as source of truth */
71
+ setLoop(enabled, startTick, endTick) {
72
+ if (enabled && (!Number.isFinite(startTick) || !Number.isFinite(endTick))) {
69
73
  console.warn(
70
- "[waveform-playlist] Scheduler.setLoop: start (" + start + ") must be less than end (" + end + ")"
74
+ "[waveform-playlist] Scheduler.setLoop: non-finite tick values (" + startTick + ", " + endTick + ")"
75
+ );
76
+ return;
77
+ }
78
+ if (enabled && startTick >= endTick) {
79
+ console.warn(
80
+ "[waveform-playlist] Scheduler.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
71
81
  );
72
82
  return;
73
83
  }
74
84
  this._loopEnabled = enabled;
75
- this._loopStart = start;
76
- this._loopEnd = end;
77
- }
78
- reset(time) {
79
- this._rightEdge = time;
80
- }
81
- advance(currentTime) {
82
- const targetEdge = currentTime + this._lookahead;
85
+ this._loopStart = Math.round(startTick);
86
+ this._loopEnd = Math.round(endTick);
87
+ }
88
+ /** Convenience — converts seconds to ticks via TempoMap */
89
+ setLoopSeconds(enabled, startSec, endSec) {
90
+ const startTick = this._tempoMap.secondsToTicks(startSec);
91
+ const endTick = this._tempoMap.secondsToTicks(endSec);
92
+ this.setLoop(enabled, startTick, endTick);
93
+ }
94
+ /** Reset scheduling cursor. Takes seconds (from Clock), converts to ticks. */
95
+ reset(timeSeconds) {
96
+ this._rightEdge = this._tempoMap.secondsToTicks(timeSeconds);
97
+ }
98
+ /** Advance the scheduling window. Takes seconds (from Clock), converts to ticks. */
99
+ advance(currentTimeSeconds) {
100
+ const targetTick = this._tempoMap.secondsToTicks(currentTimeSeconds + this._lookahead);
83
101
  if (this._loopEnabled && this._loopEnd > this._loopStart) {
84
102
  const loopDuration = this._loopEnd - this._loopStart;
85
- let remaining = targetEdge - this._rightEdge;
103
+ let remaining = targetTick - this._rightEdge;
86
104
  while (remaining > 0) {
87
105
  const distToEnd = this._loopEnd - this._rightEdge;
88
106
  if (distToEnd <= 0 || distToEnd > remaining) {
@@ -95,21 +113,25 @@ var Scheduler = class {
95
113
  for (const listener of this._listeners) {
96
114
  listener.onPositionJump(this._loopStart);
97
115
  }
98
- this._onLoop?.(this._loopStart);
116
+ this._onLoop?.(
117
+ this._tempoMap.ticksToSeconds(this._loopStart),
118
+ this._tempoMap.ticksToSeconds(this._loopEnd),
119
+ currentTimeSeconds
120
+ );
99
121
  this._rightEdge = this._loopStart;
100
122
  if (loopDuration <= 0) break;
101
123
  }
102
124
  return;
103
125
  }
104
- if (targetEdge > this._rightEdge) {
105
- this._generateAndConsume(this._rightEdge, targetEdge);
106
- this._rightEdge = targetEdge;
126
+ if (targetTick > this._rightEdge) {
127
+ this._generateAndConsume(this._rightEdge, targetTick);
128
+ this._rightEdge = targetTick;
107
129
  }
108
130
  }
109
- _generateAndConsume(from, to) {
131
+ _generateAndConsume(fromTick, toTick) {
110
132
  for (const listener of this._listeners) {
111
133
  try {
112
- const events = listener.generate(from, to);
134
+ const events = listener.generate(fromTick, toTick);
113
135
  for (const event of events) {
114
136
  try {
115
137
  listener.consume(event);
@@ -159,42 +181,77 @@ var Timer = class {
159
181
  // src/timeline/sample-timeline.ts
160
182
  var SampleTimeline = class {
161
183
  constructor(sampleRate) {
184
+ this._tempoMap = null;
162
185
  this._sampleRate = sampleRate;
163
186
  }
164
187
  get sampleRate() {
165
188
  return this._sampleRate;
166
189
  }
190
+ setTempoMap(tempoMap) {
191
+ this._tempoMap = tempoMap;
192
+ }
167
193
  samplesToSeconds(samples) {
168
194
  return samples / this._sampleRate;
169
195
  }
170
196
  secondsToSamples(seconds) {
171
197
  return Math.round(seconds * this._sampleRate);
172
198
  }
199
+ ticksToSamples(ticks) {
200
+ if (!this._tempoMap) {
201
+ throw new Error(
202
+ "[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
203
+ );
204
+ }
205
+ return Math.round(this._tempoMap.ticksToSeconds(ticks) * this._sampleRate);
206
+ }
207
+ samplesToTicks(samples) {
208
+ if (!this._tempoMap) {
209
+ throw new Error(
210
+ "[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
211
+ );
212
+ }
213
+ return this._tempoMap.secondsToTicks(samples / this._sampleRate);
214
+ }
173
215
  };
174
216
 
175
217
  // src/timeline/tempo-map.ts
218
+ var CURVE_EPSILON = 1e-15;
219
+ var CURVE_SUBDIVISIONS = 64;
220
+ function curveNormalizedAt(x, slope) {
221
+ if (slope > 0.499999 && slope < 0.500001) return x;
222
+ const p = Math.max(CURVE_EPSILON, Math.min(1 - CURVE_EPSILON, slope));
223
+ return p * p / (1 - p * 2) * (Math.pow((1 - p) / p, 2 * x) - 1);
224
+ }
176
225
  var TempoMap = class {
177
226
  constructor(ppqn = 960, initialBpm = 120) {
178
227
  this._ppqn = ppqn;
179
- this._entries = [{ tick: 0, bpm: initialBpm, secondsAtTick: 0 }];
228
+ this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
180
229
  }
181
230
  getTempo(atTick = 0) {
182
- const entry = this._entryAt(atTick);
183
- return entry.bpm;
184
- }
185
- setTempo(bpm, atTick = 0) {
231
+ return this._getTempoAt(atTick);
232
+ }
233
+ setTempo(bpm, atTick = 0, options) {
234
+ const interpolation = options?.interpolation ?? "step";
235
+ if (typeof interpolation === "object" && interpolation.type === "curve") {
236
+ const s = interpolation.slope;
237
+ if (!Number.isFinite(s) || s <= 0 || s >= 1) {
238
+ throw new Error(
239
+ "[waveform-playlist] TempoMap: curve slope must be between 0 and 1 (exclusive), got " + s
240
+ );
241
+ }
242
+ }
186
243
  if (atTick === 0) {
187
- this._entries[0] = { ...this._entries[0], bpm };
244
+ this._entries[0] = { ...this._entries[0], bpm, interpolation: "step" };
188
245
  this._recomputeCache(0);
189
246
  return;
190
247
  }
191
248
  let i = this._entries.length - 1;
192
249
  while (i > 0 && this._entries[i].tick > atTick) i--;
193
250
  if (this._entries[i].tick === atTick) {
194
- this._entries[i] = { ...this._entries[i], bpm };
251
+ this._entries[i] = { ...this._entries[i], bpm, interpolation };
195
252
  } else {
196
253
  const secondsAtTick = this._ticksToSecondsInternal(atTick);
197
- this._entries.splice(i + 1, 0, { tick: atTick, bpm, secondsAtTick });
254
+ this._entries.splice(i + 1, 0, { tick: atTick, bpm, interpolation, secondsAtTick });
198
255
  i = i + 1;
199
256
  }
200
257
  this._recomputeCache(i);
@@ -215,8 +272,30 @@ var TempoMap = class {
215
272
  }
216
273
  const entry = this._entries[lo];
217
274
  const secondsIntoSegment = seconds - entry.secondsAtTick;
275
+ const nextEntry = lo < this._entries.length - 1 ? this._entries[lo + 1] : null;
276
+ if (nextEntry && nextEntry.interpolation === "linear") {
277
+ return Math.round(
278
+ entry.tick + this._secondsToTicksLinear(
279
+ secondsIntoSegment,
280
+ entry.bpm,
281
+ nextEntry.bpm,
282
+ nextEntry.tick - entry.tick
283
+ )
284
+ );
285
+ }
286
+ if (nextEntry && typeof nextEntry.interpolation === "object") {
287
+ return Math.round(
288
+ entry.tick + this._secondsToTicksCurve(
289
+ secondsIntoSegment,
290
+ entry.bpm,
291
+ nextEntry.bpm,
292
+ nextEntry.tick - entry.tick,
293
+ nextEntry.interpolation.slope
294
+ )
295
+ );
296
+ }
218
297
  const ticksPerSecond = entry.bpm / 60 * this._ppqn;
219
- return entry.tick + secondsIntoSegment * ticksPerSecond;
298
+ return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
220
299
  }
221
300
  beatsToSeconds(beats) {
222
301
  return this.ticksToSeconds(beats * this._ppqn);
@@ -226,15 +305,122 @@ var TempoMap = class {
226
305
  }
227
306
  clearTempos() {
228
307
  const first = this._entries[0];
229
- this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
308
+ this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
309
+ }
310
+ /** Get the interpolated BPM at a tick position */
311
+ _getTempoAt(atTick) {
312
+ const entryIndex = this._entryIndexAt(atTick);
313
+ const entry = this._entries[entryIndex];
314
+ const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
315
+ if (nextEntry && nextEntry.interpolation !== "step") {
316
+ const segmentTicks = nextEntry.tick - entry.tick;
317
+ const ticksInto = atTick - entry.tick;
318
+ if (segmentTicks > 0) {
319
+ const progress = ticksInto / segmentTicks;
320
+ if (nextEntry.interpolation === "linear") {
321
+ return entry.bpm + (nextEntry.bpm - entry.bpm) * progress;
322
+ }
323
+ const t = curveNormalizedAt(progress, nextEntry.interpolation.slope);
324
+ return entry.bpm + (nextEntry.bpm - entry.bpm) * t;
325
+ }
326
+ }
327
+ return entry.bpm;
230
328
  }
231
329
  _ticksToSecondsInternal(ticks) {
232
- const entry = this._entryAt(ticks);
330
+ const entryIndex = this._entryIndexAt(ticks);
331
+ const entry = this._entries[entryIndex];
233
332
  const ticksIntoSegment = ticks - entry.tick;
333
+ const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
334
+ if (nextEntry && nextEntry.interpolation === "linear") {
335
+ const segmentTicks = nextEntry.tick - entry.tick;
336
+ return entry.secondsAtTick + this._ticksToSecondsLinear(ticksIntoSegment, entry.bpm, nextEntry.bpm, segmentTicks);
337
+ }
338
+ if (nextEntry && typeof nextEntry.interpolation === "object") {
339
+ const segmentTicks = nextEntry.tick - entry.tick;
340
+ return entry.secondsAtTick + this._ticksToSecondsCurve(
341
+ ticksIntoSegment,
342
+ entry.bpm,
343
+ nextEntry.bpm,
344
+ segmentTicks,
345
+ nextEntry.interpolation.slope
346
+ );
347
+ }
234
348
  const secondsPerTick = 60 / (entry.bpm * this._ppqn);
235
349
  return entry.secondsAtTick + ticksIntoSegment * secondsPerTick;
236
350
  }
237
- _entryAt(tick) {
351
+ /**
352
+ * Exact integration for a linear BPM ramp using the logarithmic formula.
353
+ * For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
354
+ * seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
355
+ */
356
+ _ticksToSecondsLinear(ticks, bpm0, bpm1, totalSegmentTicks) {
357
+ if (totalSegmentTicks === 0) return 0;
358
+ const bpmAtTick = bpm0 + (bpm1 - bpm0) * (ticks / totalSegmentTicks);
359
+ if (Math.abs(bpm1 - bpm0) < 1e-10) {
360
+ return ticks * 60 / (bpm0 * this._ppqn);
361
+ }
362
+ const deltaBpm = bpm1 - bpm0;
363
+ return totalSegmentTicks * 60 / (this._ppqn * deltaBpm) * Math.log(bpmAtTick / bpm0);
364
+ }
365
+ /**
366
+ * Inverse of _ticksToSecondsLinear: given seconds, return ticks.
367
+ * Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
368
+ * then ticks = (bpmAtTick - bpm0) * T / deltaBpm
369
+ *
370
+ * Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
371
+ * Math.round() in the caller (secondsToTicks). This is sufficient for all tested
372
+ * BPM ranges (10–300 BPM) but is not algebraically exact like the previous
373
+ * trapezoidal/quadratic approach was.
374
+ */
375
+ _secondsToTicksLinear(seconds, bpm0, bpm1, totalSegmentTicks) {
376
+ if (totalSegmentTicks === 0 || seconds === 0) return 0;
377
+ if (Math.abs(bpm1 - bpm0) < 1e-10) {
378
+ return seconds * bpm0 * this._ppqn / 60;
379
+ }
380
+ const deltaBpm = bpm1 - bpm0;
381
+ const bpmAtTick = bpm0 * Math.exp(seconds * deltaBpm * this._ppqn / (60 * totalSegmentTicks));
382
+ return (bpmAtTick - bpm0) / deltaBpm * totalSegmentTicks;
383
+ }
384
+ /**
385
+ * Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
386
+ * The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
387
+ * We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
388
+ */
389
+ _ticksToSecondsCurve(ticks, bpm0, bpm1, totalSegmentTicks, slope) {
390
+ if (totalSegmentTicks === 0 || ticks === 0) return 0;
391
+ const n = CURVE_SUBDIVISIONS;
392
+ const dt = ticks / n;
393
+ let seconds = 0;
394
+ let prevBpm = bpm0;
395
+ for (let i = 1; i <= n; i++) {
396
+ const progress = dt * i / totalSegmentTicks;
397
+ const curBpm = bpm0 + curveNormalizedAt(progress, slope) * (bpm1 - bpm0);
398
+ seconds += dt * 60 / this._ppqn * (1 / prevBpm + 1 / curBpm) / 2;
399
+ prevBpm = curBpm;
400
+ }
401
+ return seconds;
402
+ }
403
+ /**
404
+ * Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
405
+ * return ticks. Uses binary search since there's no closed-form inverse.
406
+ */
407
+ _secondsToTicksCurve(seconds, bpm0, bpm1, totalSegmentTicks, slope) {
408
+ if (totalSegmentTicks === 0 || seconds === 0) return 0;
409
+ const iterations = Math.min(40, Math.max(1, Math.ceil(Math.log2(2 * totalSegmentTicks))));
410
+ let lo = 0;
411
+ let hi = totalSegmentTicks;
412
+ for (let i = 0; i < iterations; i++) {
413
+ const mid = (lo + hi) / 2;
414
+ const midSeconds = this._ticksToSecondsCurve(mid, bpm0, bpm1, totalSegmentTicks, slope);
415
+ if (midSeconds < seconds) {
416
+ lo = mid;
417
+ } else {
418
+ hi = mid;
419
+ }
420
+ }
421
+ return (lo + hi) / 2;
422
+ }
423
+ _entryIndexAt(tick) {
238
424
  let lo = 0;
239
425
  let hi = this._entries.length - 1;
240
426
  while (lo < hi) {
@@ -245,16 +431,31 @@ var TempoMap = class {
245
431
  hi = mid - 1;
246
432
  }
247
433
  }
248
- return this._entries[lo];
434
+ return lo;
249
435
  }
250
436
  _recomputeCache(fromIndex) {
251
437
  for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
252
438
  const prev = this._entries[i - 1];
253
439
  const tickDelta = this._entries[i].tick - prev.tick;
254
- const secondsPerTick = 60 / (prev.bpm * this._ppqn);
440
+ const entry = this._entries[i];
441
+ let segmentSeconds;
442
+ if (entry.interpolation === "linear") {
443
+ segmentSeconds = this._ticksToSecondsLinear(tickDelta, prev.bpm, entry.bpm, tickDelta);
444
+ } else if (typeof entry.interpolation === "object") {
445
+ segmentSeconds = this._ticksToSecondsCurve(
446
+ tickDelta,
447
+ prev.bpm,
448
+ entry.bpm,
449
+ tickDelta,
450
+ entry.interpolation.slope
451
+ );
452
+ } else {
453
+ const secondsPerTick = 60 / (prev.bpm * this._ppqn);
454
+ segmentSeconds = tickDelta * secondsPerTick;
455
+ }
255
456
  this._entries[i] = {
256
- ...this._entries[i],
257
- secondsAtTick: prev.secondsAtTick + tickDelta * secondsPerTick
457
+ ...entry,
458
+ secondsAtTick: prev.secondsAtTick + segmentSeconds
258
459
  };
259
460
  }
260
461
  }
@@ -518,14 +719,15 @@ var TrackNode = class {
518
719
 
519
720
  // src/audio/clip-player.ts
520
721
  var ClipPlayer = class {
521
- constructor(audioContext, sampleTimeline, toAudioTime) {
722
+ constructor(audioContext, sampleTimeline, tempoMap, toAudioTime) {
522
723
  this._tracks = /* @__PURE__ */ new Map();
523
724
  this._trackNodes = /* @__PURE__ */ new Map();
524
725
  this._activeSources = /* @__PURE__ */ new Map();
525
726
  this._loopEnabled = false;
526
- this._loopEnd = 0;
727
+ this._loopEndSamples = 0;
527
728
  this._audioContext = audioContext;
528
729
  this._sampleTimeline = sampleTimeline;
730
+ this._tempoMap = tempoMap;
529
731
  this._toAudioTime = toAudioTime;
530
732
  }
531
733
  setTracks(tracks, trackNodes) {
@@ -535,41 +737,50 @@ var ClipPlayer = class {
535
737
  this._tracks.set(track.id, { track, clips: track.clips });
536
738
  }
537
739
  }
538
- setLoop(enabled, _start, end) {
740
+ /** Set loop region using ticks. startTick is unused — loop clamping only needs
741
+ * the end boundary; mid-clip restart at loopStart is handled by onPositionJump. */
742
+ setLoop(enabled, _startTick, endTick) {
743
+ this._loopEnabled = enabled;
744
+ this._loopEndSamples = this._sampleTimeline.ticksToSamples(endTick);
745
+ }
746
+ /** Set loop region using samples directly */
747
+ setLoopSamples(enabled, _startSample, endSample) {
539
748
  this._loopEnabled = enabled;
540
- this._loopEnd = end;
749
+ this._loopEndSamples = endSample;
541
750
  }
542
751
  updateTrack(trackId, track) {
543
752
  this._tracks.set(trackId, { track, clips: track.clips });
544
753
  this._silenceTrack(trackId);
545
754
  }
546
- generate(fromTime, toTime) {
755
+ generate(fromTick, toTick) {
547
756
  const events = [];
757
+ const fromSample = this._sampleTimeline.ticksToSamples(fromTick);
758
+ const toSample = this._sampleTimeline.ticksToSamples(toTick);
548
759
  for (const [trackId, state] of this._tracks) {
549
760
  for (const clip of state.clips) {
550
761
  if (clip.durationSamples === 0) continue;
551
762
  if (!clip.audioBuffer) continue;
552
- const clipStartTime = this._sampleTimeline.samplesToSeconds(clip.startSample);
553
- const clipDuration = this._sampleTimeline.samplesToSeconds(clip.durationSamples);
554
- const clipOffsetTime = this._sampleTimeline.samplesToSeconds(clip.offsetSamples);
555
- if (clipStartTime < fromTime) continue;
556
- if (clipStartTime >= toTime) continue;
557
- const fadeInDuration = clip.fadeIn ? this._sampleTimeline.samplesToSeconds(clip.fadeIn.duration ?? 0) : 0;
558
- const fadeOutDuration = clip.fadeOut ? this._sampleTimeline.samplesToSeconds(clip.fadeOut.duration ?? 0) : 0;
559
- let duration = clipDuration;
560
- if (this._loopEnabled && clipStartTime + duration > this._loopEnd) {
561
- duration = this._loopEnd - clipStartTime;
763
+ const clipStartSample = clip.startSample;
764
+ if (clipStartSample < fromSample) continue;
765
+ if (clipStartSample >= toSample) continue;
766
+ const fadeInDurationSamples = clip.fadeIn ? clip.fadeIn.duration ?? 0 : 0;
767
+ const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
768
+ let durationSamples = clip.durationSamples;
769
+ if (this._loopEnabled && clipStartSample + durationSamples > this._loopEndSamples) {
770
+ durationSamples = this._loopEndSamples - clipStartSample;
562
771
  }
772
+ const clipTick = this._sampleTimeline.samplesToTicks(clipStartSample);
563
773
  events.push({
564
774
  trackId,
565
775
  clipId: clip.id,
566
776
  audioBuffer: clip.audioBuffer,
567
- transportTime: clipStartTime,
568
- offset: clipOffsetTime,
569
- duration,
777
+ tick: clipTick,
778
+ startSample: clipStartSample,
779
+ offsetSamples: clip.offsetSamples,
780
+ durationSamples,
570
781
  gain: clip.gain,
571
- fadeInDuration,
572
- fadeOutDuration
782
+ fadeInDurationSamples,
783
+ fadeOutDurationSamples
573
784
  });
574
785
  }
575
786
  }
@@ -583,18 +794,25 @@ var ClipPlayer = class {
583
794
  );
584
795
  return;
585
796
  }
586
- if (event.offset >= event.audioBuffer.duration) {
797
+ const sampleRate = this._sampleTimeline.sampleRate;
798
+ const offsetSeconds = event.offsetSamples / sampleRate;
799
+ const durationSeconds = event.durationSamples / sampleRate;
800
+ if (offsetSeconds >= event.audioBuffer.duration) {
801
+ console.warn(
802
+ "[waveform-playlist] ClipPlayer.consume: offset (" + offsetSeconds + "s) exceeds audioBuffer.duration (" + event.audioBuffer.duration + 's) for clipId "' + event.clipId + '" \u2014 clip will not play'
803
+ );
587
804
  return;
588
805
  }
589
806
  const source = this._audioContext.createBufferSource();
590
807
  source.buffer = event.audioBuffer;
591
- const when = this._toAudioTime(event.transportTime);
808
+ const transportSeconds = this._tempoMap.ticksToSeconds(event.tick);
809
+ const when = this._toAudioTime(transportSeconds);
592
810
  const gainNode = this._audioContext.createGain();
593
811
  gainNode.gain.value = event.gain;
594
- let fadeIn = event.fadeInDuration;
595
- let fadeOut = event.fadeOutDuration;
596
- if (fadeIn + fadeOut > event.duration) {
597
- const ratio = event.duration / (fadeIn + fadeOut);
812
+ let fadeIn = event.fadeInDurationSamples / sampleRate;
813
+ let fadeOut = event.fadeOutDurationSamples / sampleRate;
814
+ if (fadeIn + fadeOut > durationSeconds) {
815
+ const ratio = durationSeconds / (fadeIn + fadeOut);
598
816
  fadeIn *= ratio;
599
817
  fadeOut *= ratio;
600
818
  }
@@ -603,9 +821,9 @@ var ClipPlayer = class {
603
821
  gainNode.gain.linearRampToValueAtTime(event.gain, when + fadeIn);
604
822
  }
605
823
  if (fadeOut > 0) {
606
- const fadeOutStart = when + event.duration - fadeOut;
824
+ const fadeOutStart = when + durationSeconds - fadeOut;
607
825
  gainNode.gain.setValueAtTime(event.gain, fadeOutStart);
608
- gainNode.gain.linearRampToValueAtTime(0, when + event.duration);
826
+ gainNode.gain.linearRampToValueAtTime(0, when + durationSeconds);
609
827
  }
610
828
  source.connect(gainNode);
611
829
  gainNode.connect(trackNode.input);
@@ -621,33 +839,37 @@ var ClipPlayer = class {
621
839
  console.warn("[waveform-playlist] ClipPlayer: error disconnecting gain node:", String(err));
622
840
  }
623
841
  });
624
- source.start(when, event.offset, event.duration);
842
+ source.start(when, offsetSeconds, durationSeconds);
625
843
  }
626
- onPositionJump(newTime) {
844
+ onPositionJump(newTick) {
627
845
  this.silence();
846
+ const newSample = this._sampleTimeline.ticksToSamples(newTick);
628
847
  for (const [trackId, state] of this._tracks) {
629
848
  for (const clip of state.clips) {
630
849
  if (clip.durationSamples === 0) continue;
631
850
  if (!clip.audioBuffer) continue;
632
- const clipStartTime = this._sampleTimeline.samplesToSeconds(clip.startSample);
633
- const clipDuration = this._sampleTimeline.samplesToSeconds(clip.durationSamples);
634
- const clipEndTime = clipStartTime + clipDuration;
635
- const clipOffsetTime = this._sampleTimeline.samplesToSeconds(clip.offsetSamples);
636
- if (clipStartTime <= newTime && clipEndTime > newTime) {
637
- const offsetIntoClip = newTime - clipStartTime;
638
- const offset = clipOffsetTime + offsetIntoClip;
639
- const duration = clipEndTime - newTime;
640
- const fadeOutDuration = clip.fadeOut ? this._sampleTimeline.samplesToSeconds(clip.fadeOut.duration ?? 0) : 0;
851
+ const clipStartSample = clip.startSample;
852
+ const clipEndSample = clipStartSample + clip.durationSamples;
853
+ if (clipStartSample <= newSample && clipEndSample > newSample) {
854
+ const offsetIntoClipSamples = newSample - clipStartSample;
855
+ const offsetSamples = clip.offsetSamples + offsetIntoClipSamples;
856
+ let durationSamples = clipEndSample - newSample;
857
+ if (this._loopEnabled && newSample + durationSamples > this._loopEndSamples) {
858
+ durationSamples = this._loopEndSamples - newSample;
859
+ }
860
+ if (durationSamples <= 0) continue;
861
+ const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
641
862
  this.consume({
642
863
  trackId,
643
864
  clipId: clip.id,
644
865
  audioBuffer: clip.audioBuffer,
645
- transportTime: newTime,
646
- offset,
647
- duration,
866
+ tick: newTick,
867
+ startSample: newSample,
868
+ offsetSamples,
869
+ durationSamples,
648
870
  gain: clip.gain,
649
- fadeInDuration: 0,
650
- fadeOutDuration
871
+ fadeInDurationSamples: 0,
872
+ fadeOutDurationSamples
651
873
  });
652
874
  }
653
875
  }
@@ -720,31 +942,29 @@ var MetronomePlayer = class {
720
942
  this._accentBuffer = accent;
721
943
  this._normalBuffer = normal;
722
944
  }
723
- generate(fromTime, toTime) {
945
+ generate(fromTick, toTick) {
724
946
  if (!this._enabled || !this._accentBuffer || !this._normalBuffer) {
725
947
  return [];
726
948
  }
727
949
  const events = [];
728
- const fromTicks = this._tempoMap.secondsToTicks(fromTime);
729
- const toTicks = this._tempoMap.secondsToTicks(toTime);
730
- let entry = this._meterMap.getEntryAt(fromTicks);
731
- let beatSize = this._meterMap.ticksPerBeat(fromTicks);
732
- const tickIntoSection = fromTicks - entry.tick;
950
+ let entry = this._meterMap.getEntryAt(fromTick);
951
+ let beatSize = this._meterMap.ticksPerBeat(fromTick);
952
+ const tickIntoSection = fromTick - entry.tick;
733
953
  let tick = entry.tick + Math.ceil(tickIntoSection / beatSize) * beatSize;
734
- while (tick < toTicks) {
735
- const currentEntry = this._meterMap.getEntryAt(tick);
954
+ while (tick < toTick) {
955
+ const tickPos = tick;
956
+ const currentEntry = this._meterMap.getEntryAt(tickPos);
736
957
  if (currentEntry.tick !== entry.tick) {
737
958
  entry = currentEntry;
738
- beatSize = this._meterMap.ticksPerBeat(tick);
959
+ beatSize = this._meterMap.ticksPerBeat(tickPos);
739
960
  }
740
- const isAccent = this._meterMap.isBarBoundary(tick);
741
- const transportTime = this._tempoMap.ticksToSeconds(tick);
961
+ const isAccent = this._meterMap.isBarBoundary(tickPos);
742
962
  events.push({
743
- transportTime,
963
+ tick: tickPos,
744
964
  isAccent,
745
965
  buffer: isAccent ? this._accentBuffer : this._normalBuffer
746
966
  });
747
- beatSize = this._meterMap.ticksPerBeat(tick);
967
+ beatSize = this._meterMap.ticksPerBeat(tickPos);
748
968
  tick += beatSize;
749
969
  }
750
970
  return events;
@@ -765,10 +985,10 @@ var MetronomePlayer = class {
765
985
  );
766
986
  }
767
987
  });
768
- source.start(this._toAudioTime(event.transportTime));
988
+ const transportTime = this._tempoMap.ticksToSeconds(event.tick);
989
+ source.start(this._toAudioTime(transportTime));
769
990
  }
770
- onPositionJump(_newTime) {
771
- this.silence();
991
+ onPositionJump(_newTick) {
772
992
  }
773
993
  silence() {
774
994
  for (const source of this._activeSources) {
@@ -801,6 +1021,9 @@ var Transport = class _Transport {
801
1021
  this._soloedTrackIds = /* @__PURE__ */ new Set();
802
1022
  this._mutedTrackIds = /* @__PURE__ */ new Set();
803
1023
  this._playing = false;
1024
+ this._loopEnabled = false;
1025
+ this._loopStartTick = 0;
1026
+ this._loopStartSeconds = 0;
804
1027
  this._listeners = /* @__PURE__ */ new Map();
805
1028
  this._audioContext = audioContext;
806
1029
  const sampleRate = options.sampleRate ?? audioContext.sampleRate;
@@ -811,15 +1034,17 @@ var Transport = class _Transport {
811
1034
  const lookahead = options.schedulerLookahead ?? 0.2;
812
1035
  _Transport._validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead);
813
1036
  this._clock = new Clock(audioContext);
814
- this._scheduler = new Scheduler({
815
- lookahead,
816
- onLoop: (loopStartTime) => {
817
- this._clock.seekTo(loopStartTime);
818
- }
819
- });
820
1037
  this._sampleTimeline = new SampleTimeline(sampleRate);
821
1038
  this._meterMap = new MeterMap(ppqn, numerator, denominator);
822
1039
  this._tempoMap = new TempoMap(ppqn, tempo);
1040
+ this._scheduler = new Scheduler(this._tempoMap, {
1041
+ lookahead,
1042
+ onLoop: (loopStartSeconds, loopEndSeconds, currentTimeSeconds) => {
1043
+ const timeToBoundary = loopEndSeconds - currentTimeSeconds;
1044
+ this._clock.seekTo(loopStartSeconds - timeToBoundary);
1045
+ }
1046
+ });
1047
+ this._sampleTimeline.setTempoMap(this._tempoMap);
823
1048
  this._initAudioGraph(audioContext);
824
1049
  this._timer = new Timer(() => {
825
1050
  const time = this._clock.getTime();
@@ -843,7 +1068,8 @@ var Transport = class _Transport {
843
1068
  this._scheduler.reset(currentTime);
844
1069
  this._endTime = endTime;
845
1070
  this._clock.start();
846
- this._clipPlayer.onPositionJump(currentTime);
1071
+ const currentTick = this._tempoMap.secondsToTicks(currentTime);
1072
+ this._clipPlayer.onPositionJump(currentTick);
847
1073
  this._timer.start();
848
1074
  this._playing = true;
849
1075
  this._emit("play");
@@ -879,12 +1105,17 @@ var Transport = class _Transport {
879
1105
  this._endTime = void 0;
880
1106
  if (wasPlaying) {
881
1107
  this._clock.start();
882
- this._clipPlayer.onPositionJump(time);
1108
+ const seekTick = this._tempoMap.secondsToTicks(time);
1109
+ this._clipPlayer.onPositionJump(seekTick);
883
1110
  this._timer.start();
884
1111
  }
885
1112
  }
886
1113
  getCurrentTime() {
887
- return this._clock.getTime();
1114
+ const t = this._clock.getTime();
1115
+ if (this._loopEnabled && t < this._loopStartSeconds) {
1116
+ return this._loopStartSeconds;
1117
+ }
1118
+ return t;
888
1119
  }
889
1120
  isPlaying() {
890
1121
  return this._playing;
@@ -1000,20 +1231,56 @@ var Transport = class _Transport {
1000
1231
  this._masterNode.setVolume(volume);
1001
1232
  }
1002
1233
  // --- Loop ---
1003
- setLoop(enabled, start, end) {
1004
- if (enabled && start >= end) {
1234
+ /** Primary loop API — ticks as source of truth */
1235
+ setLoop(enabled, startTick, endTick) {
1236
+ if (enabled && startTick >= endTick) {
1237
+ console.warn(
1238
+ "[waveform-playlist] Transport.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
1239
+ );
1240
+ return;
1241
+ }
1242
+ this._loopEnabled = enabled;
1243
+ this._loopStartTick = startTick;
1244
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1245
+ this._scheduler.setLoop(enabled, startTick, endTick);
1246
+ this._clipPlayer.setLoop(enabled, startTick, endTick);
1247
+ this._emit("loop");
1248
+ }
1249
+ /** Convenience — converts seconds to ticks */
1250
+ setLoopSeconds(enabled, startSec, endSec) {
1251
+ const startTick = this._tempoMap.secondsToTicks(startSec);
1252
+ const endTick = this._tempoMap.secondsToTicks(endSec);
1253
+ this.setLoop(enabled, startTick, endTick);
1254
+ }
1255
+ /** Convenience — sets loop in samples */
1256
+ setLoopSamples(enabled, startSample, endSample) {
1257
+ if (enabled && (!Number.isFinite(startSample) || !Number.isFinite(endSample))) {
1258
+ console.warn(
1259
+ "[waveform-playlist] Transport.setLoopSamples: non-finite sample values (" + startSample + ", " + endSample + ")"
1260
+ );
1261
+ return;
1262
+ }
1263
+ if (enabled && startSample >= endSample) {
1005
1264
  console.warn(
1006
- "[waveform-playlist] Transport.setLoop: start (" + start + ") must be less than end (" + end + ")"
1265
+ "[waveform-playlist] Transport.setLoopSamples: startSample (" + startSample + ") must be less than endSample (" + endSample + ")"
1007
1266
  );
1008
1267
  return;
1009
1268
  }
1010
- this._scheduler.setLoop(enabled, start, end);
1011
- this._clipPlayer.setLoop(enabled, start, end);
1269
+ const startTick = this._sampleTimeline.samplesToTicks(startSample);
1270
+ const endTick = this._sampleTimeline.samplesToTicks(endSample);
1271
+ this._loopEnabled = enabled;
1272
+ this._loopStartTick = startTick;
1273
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1274
+ this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
1275
+ this._scheduler.setLoop(enabled, startTick, endTick);
1012
1276
  this._emit("loop");
1013
1277
  }
1014
1278
  // --- Tempo ---
1015
- setTempo(bpm, atTick) {
1016
- this._tempoMap.setTempo(bpm, atTick);
1279
+ setTempo(bpm, atTick, options) {
1280
+ this._tempoMap.setTempo(bpm, atTick, options);
1281
+ if (this._loopEnabled) {
1282
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1283
+ }
1017
1284
  this._emit("tempochange");
1018
1285
  }
1019
1286
  getTempo(atTick) {
@@ -1037,6 +1304,9 @@ var Transport = class _Transport {
1037
1304
  }
1038
1305
  clearTempos() {
1039
1306
  this._tempoMap.clearTempos();
1307
+ if (this._loopEnabled) {
1308
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1309
+ }
1040
1310
  this._emit("tempochange");
1041
1311
  }
1042
1312
  barToTick(bar) {
@@ -1132,7 +1402,12 @@ var Transport = class _Transport {
1132
1402
  this._masterNode = new MasterNode(audioContext);
1133
1403
  this._masterNode.output.connect(audioContext.destination);
1134
1404
  const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
1135
- this._clipPlayer = new ClipPlayer(audioContext, this._sampleTimeline, toAudioTime);
1405
+ this._clipPlayer = new ClipPlayer(
1406
+ audioContext,
1407
+ this._sampleTimeline,
1408
+ this._tempoMap,
1409
+ toAudioTime
1410
+ );
1136
1411
  this._metronomePlayer = new MetronomePlayer(
1137
1412
  audioContext,
1138
1413
  this._tempoMap,
@@ -1235,7 +1510,7 @@ var NativePlayoutAdapter = class {
1235
1510
  this._transport.setTrackPan(trackId, pan);
1236
1511
  }
1237
1512
  setLoop(enabled, start, end) {
1238
- this._transport.setLoop(enabled, start, end);
1513
+ this._transport.setLoopSeconds(enabled, start, end);
1239
1514
  }
1240
1515
  dispose() {
1241
1516
  this._transport.dispose();