@dawcore/transport 0.0.1 → 0.0.3

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))) {
73
+ console.warn(
74
+ "[waveform-playlist] Scheduler.setLoop: non-finite tick values (" + startTick + ", " + endTick + ")"
75
+ );
76
+ return;
77
+ }
78
+ if (enabled && startTick >= endTick) {
69
79
  console.warn(
70
- "[waveform-playlist] Scheduler.setLoop: start (" + start + ") must be less than end (" + end + ")"
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,44 +181,36 @@ 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
  }
173
- };
174
-
175
- // src/timeline/tick-timeline.ts
176
- var TickTimeline = class {
177
- constructor(ppqn = 960) {
178
- this._ppqn = ppqn;
179
- }
180
- get ppqn() {
181
- return this._ppqn;
182
- }
183
- ticksPerBeat() {
184
- return this._ppqn;
185
- }
186
- ticksPerBar(beatsPerBar) {
187
- return this._ppqn * beatsPerBar;
188
- }
189
- toPosition(ticks, beatsPerBar) {
190
- const ticksPerBar = this.ticksPerBar(beatsPerBar);
191
- const bar = Math.floor(ticks / ticksPerBar) + 1;
192
- const remaining = ticks % ticksPerBar;
193
- const beat = Math.floor(remaining / this._ppqn) + 1;
194
- const tick = remaining % this._ppqn;
195
- return { bar, beat, tick };
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);
196
206
  }
197
- fromPosition(bar, beat, tick, beatsPerBar) {
198
- const ticksPerBar = this.ticksPerBar(beatsPerBar);
199
- return (bar - 1) * ticksPerBar + (beat - 1) * this._ppqn + tick;
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);
200
214
  }
201
215
  };
202
216
 
@@ -244,7 +258,7 @@ var TempoMap = class {
244
258
  const entry = this._entries[lo];
245
259
  const secondsIntoSegment = seconds - entry.secondsAtTick;
246
260
  const ticksPerSecond = entry.bpm / 60 * this._ppqn;
247
- return entry.tick + secondsIntoSegment * ticksPerSecond;
261
+ return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
248
262
  }
249
263
  beatsToSeconds(beats) {
250
264
  return this.ticksToSeconds(beats * this._ppqn);
@@ -252,6 +266,10 @@ var TempoMap = class {
252
266
  secondsToBeats(seconds) {
253
267
  return this.secondsToTicks(seconds) / this._ppqn;
254
268
  }
269
+ clearTempos() {
270
+ const first = this._entries[0];
271
+ this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
272
+ }
255
273
  _ticksToSecondsInternal(ticks) {
256
274
  const entry = this._entryAt(ticks);
257
275
  const ticksIntoSegment = ticks - entry.tick;
@@ -284,6 +302,179 @@ var TempoMap = class {
284
302
  }
285
303
  };
286
304
 
305
+ // src/timeline/meter-map.ts
306
+ function isPowerOf2(n) {
307
+ return n > 0 && (n & n - 1) === 0;
308
+ }
309
+ var MeterMap = class {
310
+ constructor(ppqn, numerator = 4, denominator = 4) {
311
+ this._ppqn = ppqn;
312
+ this._entries = [{ tick: 0, numerator, denominator, barAtTick: 0 }];
313
+ }
314
+ get ppqn() {
315
+ return this._ppqn;
316
+ }
317
+ getMeter(atTick = 0) {
318
+ const entry = this._entryAt(atTick);
319
+ return { numerator: entry.numerator, denominator: entry.denominator };
320
+ }
321
+ setMeter(numerator, denominator, atTick = 0) {
322
+ this._validateMeter(numerator, denominator);
323
+ if (atTick < 0) {
324
+ throw new Error("[waveform-playlist] MeterMap: atTick must be non-negative, got " + atTick);
325
+ }
326
+ if (atTick === 0) {
327
+ this._entries[0] = { ...this._entries[0], numerator, denominator };
328
+ this._resnapDownstreamEntries(0);
329
+ this._recomputeCache(0);
330
+ return;
331
+ }
332
+ const snapped = this._snapToBarBoundary(atTick);
333
+ if (snapped !== atTick) {
334
+ console.warn(
335
+ "[waveform-playlist] MeterMap.setMeter: tick " + atTick + " is not on a bar boundary, snapped to " + snapped
336
+ );
337
+ }
338
+ let i = this._entries.length - 1;
339
+ while (i > 0 && this._entries[i].tick > snapped) i--;
340
+ if (this._entries[i].tick === snapped) {
341
+ this._entries[i] = { ...this._entries[i], numerator, denominator };
342
+ } else {
343
+ const barAtTick = this._computeBarAtTick(snapped);
344
+ this._entries.splice(i + 1, 0, { tick: snapped, numerator, denominator, barAtTick });
345
+ i = i + 1;
346
+ }
347
+ this._resnapDownstreamEntries(i);
348
+ this._recomputeCache(i);
349
+ }
350
+ removeMeter(atTick) {
351
+ if (atTick === 0) {
352
+ throw new Error("[waveform-playlist] MeterMap: cannot remove meter at tick 0");
353
+ }
354
+ const idx = this._entries.findIndex((e) => e.tick === atTick);
355
+ if (idx > 0) {
356
+ this._entries.splice(idx, 1);
357
+ this._recomputeCache(idx);
358
+ } else if (idx === -1) {
359
+ console.warn("[waveform-playlist] MeterMap.removeMeter: no entry at tick " + atTick);
360
+ }
361
+ }
362
+ clearMeters() {
363
+ const first = this._entries[0];
364
+ this._entries = [{ ...first, barAtTick: 0 }];
365
+ }
366
+ ticksPerBeat(atTick = 0) {
367
+ const entry = this._entryAt(atTick);
368
+ return this._ppqn * (4 / entry.denominator);
369
+ }
370
+ ticksPerBar(atTick = 0) {
371
+ const entry = this._entryAt(atTick);
372
+ return entry.numerator * this._ppqn * (4 / entry.denominator);
373
+ }
374
+ barToTick(bar) {
375
+ if (bar < 1) {
376
+ throw new Error("[waveform-playlist] MeterMap: bar must be >= 1, got " + bar);
377
+ }
378
+ const targetBar = bar - 1;
379
+ for (let i = 0; i < this._entries.length; i++) {
380
+ const nextBar = i < this._entries.length - 1 ? this._entries[i + 1].barAtTick : Infinity;
381
+ if (targetBar < nextBar) {
382
+ const barsInto = targetBar - this._entries[i].barAtTick;
383
+ const tpb = this._ticksPerBarForEntry(this._entries[i]);
384
+ return this._entries[i].tick + barsInto * tpb;
385
+ }
386
+ }
387
+ return 0;
388
+ }
389
+ tickToBar(tick) {
390
+ const entry = this._entryAt(tick);
391
+ const ticksInto = tick - entry.tick;
392
+ const tpb = this._ticksPerBarForEntry(entry);
393
+ return entry.barAtTick + Math.floor(ticksInto / tpb) + 1;
394
+ }
395
+ isBarBoundary(tick) {
396
+ const entry = this._entryAt(tick);
397
+ const ticksInto = tick - entry.tick;
398
+ const tpb = this._ticksPerBarForEntry(entry);
399
+ return ticksInto % tpb === 0;
400
+ }
401
+ /** Internal: get the full entry at a tick (for MetronomePlayer beat grid anchoring) */
402
+ getEntryAt(tick) {
403
+ return this._entryAt(tick);
404
+ }
405
+ _entryAt(tick) {
406
+ let lo = 0;
407
+ let hi = this._entries.length - 1;
408
+ while (lo < hi) {
409
+ const mid = lo + hi + 1 >> 1;
410
+ if (this._entries[mid].tick <= tick) {
411
+ lo = mid;
412
+ } else {
413
+ hi = mid - 1;
414
+ }
415
+ }
416
+ return this._entries[lo];
417
+ }
418
+ _ticksPerBarForEntry(entry) {
419
+ return entry.numerator * this._ppqn * (4 / entry.denominator);
420
+ }
421
+ _snapToBarBoundary(atTick) {
422
+ const entry = this._entryAt(atTick);
423
+ const tpb = this._ticksPerBarForEntry(entry);
424
+ const ticksInto = atTick - entry.tick;
425
+ if (ticksInto % tpb === 0) return atTick;
426
+ return entry.tick + Math.ceil(ticksInto / tpb) * tpb;
427
+ }
428
+ _computeBarAtTick(tick) {
429
+ const entry = this._entryAt(tick);
430
+ const ticksInto = tick - entry.tick;
431
+ const tpb = this._ticksPerBarForEntry(entry);
432
+ return entry.barAtTick + ticksInto / tpb;
433
+ }
434
+ _recomputeCache(fromIndex) {
435
+ for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
436
+ const prev = this._entries[i - 1];
437
+ const tickDelta = this._entries[i].tick - prev.tick;
438
+ const tpb = this._ticksPerBarForEntry(prev);
439
+ this._entries[i] = {
440
+ ...this._entries[i],
441
+ barAtTick: prev.barAtTick + tickDelta / tpb
442
+ };
443
+ }
444
+ }
445
+ /**
446
+ * After changing a meter entry, re-snap downstream entries to bar boundaries
447
+ * of their preceding meter so barAtTick stays integer.
448
+ */
449
+ _resnapDownstreamEntries(fromIndex) {
450
+ for (let i = Math.max(1, fromIndex + 1); i < this._entries.length; i++) {
451
+ const prev = this._entries[i - 1];
452
+ const tpb = this._ticksPerBarForEntry(prev);
453
+ const tick = this._entries[i].tick;
454
+ const ticksIntoPrev = tick - prev.tick;
455
+ if (ticksIntoPrev % tpb !== 0) {
456
+ const snapped = prev.tick + Math.ceil(ticksIntoPrev / tpb) * tpb;
457
+ console.warn(
458
+ "[waveform-playlist] MeterMap: meter change moved entry from tick " + tick + " to " + snapped + " (bar boundary alignment)"
459
+ );
460
+ this._entries[i] = { ...this._entries[i], tick: snapped };
461
+ }
462
+ }
463
+ }
464
+ _validateMeter(numerator, denominator) {
465
+ if (!Number.isInteger(numerator) || numerator < 1 || numerator > 32) {
466
+ throw new Error(
467
+ "[waveform-playlist] MeterMap: numerator must be an integer 1-32, got " + numerator
468
+ );
469
+ }
470
+ if (!isPowerOf2(denominator) || denominator > 32) {
471
+ throw new Error(
472
+ "[waveform-playlist] MeterMap: denominator must be a power of 2 (1-32), got " + denominator
473
+ );
474
+ }
475
+ }
476
+ };
477
+
287
478
  // src/audio/master-node.ts
288
479
  var MasterNode = class {
289
480
  constructor(audioContext) {
@@ -369,14 +560,15 @@ var TrackNode = class {
369
560
 
370
561
  // src/audio/clip-player.ts
371
562
  var ClipPlayer = class {
372
- constructor(audioContext, sampleTimeline, toAudioTime) {
563
+ constructor(audioContext, sampleTimeline, tempoMap, toAudioTime) {
373
564
  this._tracks = /* @__PURE__ */ new Map();
374
565
  this._trackNodes = /* @__PURE__ */ new Map();
375
566
  this._activeSources = /* @__PURE__ */ new Map();
376
567
  this._loopEnabled = false;
377
- this._loopEnd = 0;
568
+ this._loopEndSamples = 0;
378
569
  this._audioContext = audioContext;
379
570
  this._sampleTimeline = sampleTimeline;
571
+ this._tempoMap = tempoMap;
380
572
  this._toAudioTime = toAudioTime;
381
573
  }
382
574
  setTracks(tracks, trackNodes) {
@@ -386,41 +578,50 @@ var ClipPlayer = class {
386
578
  this._tracks.set(track.id, { track, clips: track.clips });
387
579
  }
388
580
  }
389
- setLoop(enabled, _start, end) {
581
+ /** Set loop region using ticks. startTick is unused — loop clamping only needs
582
+ * the end boundary; mid-clip restart at loopStart is handled by onPositionJump. */
583
+ setLoop(enabled, _startTick, endTick) {
584
+ this._loopEnabled = enabled;
585
+ this._loopEndSamples = this._sampleTimeline.ticksToSamples(endTick);
586
+ }
587
+ /** Set loop region using samples directly */
588
+ setLoopSamples(enabled, _startSample, endSample) {
390
589
  this._loopEnabled = enabled;
391
- this._loopEnd = end;
590
+ this._loopEndSamples = endSample;
392
591
  }
393
592
  updateTrack(trackId, track) {
394
593
  this._tracks.set(trackId, { track, clips: track.clips });
395
594
  this._silenceTrack(trackId);
396
595
  }
397
- generate(fromTime, toTime) {
596
+ generate(fromTick, toTick) {
398
597
  const events = [];
598
+ const fromSample = this._sampleTimeline.ticksToSamples(fromTick);
599
+ const toSample = this._sampleTimeline.ticksToSamples(toTick);
399
600
  for (const [trackId, state] of this._tracks) {
400
601
  for (const clip of state.clips) {
401
602
  if (clip.durationSamples === 0) continue;
402
603
  if (!clip.audioBuffer) continue;
403
- const clipStartTime = this._sampleTimeline.samplesToSeconds(clip.startSample);
404
- const clipDuration = this._sampleTimeline.samplesToSeconds(clip.durationSamples);
405
- const clipOffsetTime = this._sampleTimeline.samplesToSeconds(clip.offsetSamples);
406
- if (clipStartTime < fromTime) continue;
407
- if (clipStartTime >= toTime) continue;
408
- const fadeInDuration = clip.fadeIn ? this._sampleTimeline.samplesToSeconds(clip.fadeIn.duration ?? 0) : 0;
409
- const fadeOutDuration = clip.fadeOut ? this._sampleTimeline.samplesToSeconds(clip.fadeOut.duration ?? 0) : 0;
410
- let duration = clipDuration;
411
- if (this._loopEnabled && clipStartTime + duration > this._loopEnd) {
412
- duration = this._loopEnd - clipStartTime;
604
+ const clipStartSample = clip.startSample;
605
+ if (clipStartSample < fromSample) continue;
606
+ if (clipStartSample >= toSample) continue;
607
+ const fadeInDurationSamples = clip.fadeIn ? clip.fadeIn.duration ?? 0 : 0;
608
+ const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
609
+ let durationSamples = clip.durationSamples;
610
+ if (this._loopEnabled && clipStartSample + durationSamples > this._loopEndSamples) {
611
+ durationSamples = this._loopEndSamples - clipStartSample;
413
612
  }
613
+ const clipTick = this._sampleTimeline.samplesToTicks(clipStartSample);
414
614
  events.push({
415
615
  trackId,
416
616
  clipId: clip.id,
417
617
  audioBuffer: clip.audioBuffer,
418
- transportTime: clipStartTime,
419
- offset: clipOffsetTime,
420
- duration,
618
+ tick: clipTick,
619
+ startSample: clipStartSample,
620
+ offsetSamples: clip.offsetSamples,
621
+ durationSamples,
421
622
  gain: clip.gain,
422
- fadeInDuration,
423
- fadeOutDuration
623
+ fadeInDurationSamples,
624
+ fadeOutDurationSamples
424
625
  });
425
626
  }
426
627
  }
@@ -434,18 +635,25 @@ var ClipPlayer = class {
434
635
  );
435
636
  return;
436
637
  }
437
- if (event.offset >= event.audioBuffer.duration) {
638
+ const sampleRate = this._sampleTimeline.sampleRate;
639
+ const offsetSeconds = event.offsetSamples / sampleRate;
640
+ const durationSeconds = event.durationSamples / sampleRate;
641
+ if (offsetSeconds >= event.audioBuffer.duration) {
642
+ console.warn(
643
+ "[waveform-playlist] ClipPlayer.consume: offset (" + offsetSeconds + "s) exceeds audioBuffer.duration (" + event.audioBuffer.duration + 's) for clipId "' + event.clipId + '" \u2014 clip will not play'
644
+ );
438
645
  return;
439
646
  }
440
647
  const source = this._audioContext.createBufferSource();
441
648
  source.buffer = event.audioBuffer;
442
- const when = this._toAudioTime(event.transportTime);
649
+ const transportSeconds = this._tempoMap.ticksToSeconds(event.tick);
650
+ const when = this._toAudioTime(transportSeconds);
443
651
  const gainNode = this._audioContext.createGain();
444
652
  gainNode.gain.value = event.gain;
445
- let fadeIn = event.fadeInDuration;
446
- let fadeOut = event.fadeOutDuration;
447
- if (fadeIn + fadeOut > event.duration) {
448
- const ratio = event.duration / (fadeIn + fadeOut);
653
+ let fadeIn = event.fadeInDurationSamples / sampleRate;
654
+ let fadeOut = event.fadeOutDurationSamples / sampleRate;
655
+ if (fadeIn + fadeOut > durationSeconds) {
656
+ const ratio = durationSeconds / (fadeIn + fadeOut);
449
657
  fadeIn *= ratio;
450
658
  fadeOut *= ratio;
451
659
  }
@@ -454,9 +662,9 @@ var ClipPlayer = class {
454
662
  gainNode.gain.linearRampToValueAtTime(event.gain, when + fadeIn);
455
663
  }
456
664
  if (fadeOut > 0) {
457
- const fadeOutStart = when + event.duration - fadeOut;
665
+ const fadeOutStart = when + durationSeconds - fadeOut;
458
666
  gainNode.gain.setValueAtTime(event.gain, fadeOutStart);
459
- gainNode.gain.linearRampToValueAtTime(0, when + event.duration);
667
+ gainNode.gain.linearRampToValueAtTime(0, when + durationSeconds);
460
668
  }
461
669
  source.connect(gainNode);
462
670
  gainNode.connect(trackNode.input);
@@ -472,33 +680,37 @@ var ClipPlayer = class {
472
680
  console.warn("[waveform-playlist] ClipPlayer: error disconnecting gain node:", String(err));
473
681
  }
474
682
  });
475
- source.start(when, event.offset, event.duration);
683
+ source.start(when, offsetSeconds, durationSeconds);
476
684
  }
477
- onPositionJump(newTime) {
685
+ onPositionJump(newTick) {
478
686
  this.silence();
687
+ const newSample = this._sampleTimeline.ticksToSamples(newTick);
479
688
  for (const [trackId, state] of this._tracks) {
480
689
  for (const clip of state.clips) {
481
690
  if (clip.durationSamples === 0) continue;
482
691
  if (!clip.audioBuffer) continue;
483
- const clipStartTime = this._sampleTimeline.samplesToSeconds(clip.startSample);
484
- const clipDuration = this._sampleTimeline.samplesToSeconds(clip.durationSamples);
485
- const clipEndTime = clipStartTime + clipDuration;
486
- const clipOffsetTime = this._sampleTimeline.samplesToSeconds(clip.offsetSamples);
487
- if (clipStartTime <= newTime && clipEndTime > newTime) {
488
- const offsetIntoClip = newTime - clipStartTime;
489
- const offset = clipOffsetTime + offsetIntoClip;
490
- const duration = clipEndTime - newTime;
491
- const fadeOutDuration = clip.fadeOut ? this._sampleTimeline.samplesToSeconds(clip.fadeOut.duration ?? 0) : 0;
692
+ const clipStartSample = clip.startSample;
693
+ const clipEndSample = clipStartSample + clip.durationSamples;
694
+ if (clipStartSample <= newSample && clipEndSample > newSample) {
695
+ const offsetIntoClipSamples = newSample - clipStartSample;
696
+ const offsetSamples = clip.offsetSamples + offsetIntoClipSamples;
697
+ let durationSamples = clipEndSample - newSample;
698
+ if (this._loopEnabled && newSample + durationSamples > this._loopEndSamples) {
699
+ durationSamples = this._loopEndSamples - newSample;
700
+ }
701
+ if (durationSamples <= 0) continue;
702
+ const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
492
703
  this.consume({
493
704
  trackId,
494
705
  clipId: clip.id,
495
706
  audioBuffer: clip.audioBuffer,
496
- transportTime: newTime,
497
- offset,
498
- duration,
707
+ tick: newTick,
708
+ startSample: newSample,
709
+ offsetSamples,
710
+ durationSamples,
499
711
  gain: clip.gain,
500
- fadeInDuration: 0,
501
- fadeOutDuration
712
+ fadeInDurationSamples: 0,
713
+ fadeOutDurationSamples
502
714
  });
503
715
  }
504
716
  }
@@ -550,15 +762,14 @@ var ClipPlayer = class {
550
762
 
551
763
  // src/audio/metronome-player.ts
552
764
  var MetronomePlayer = class {
553
- constructor(audioContext, tempoMap, tickTimeline, destination, toAudioTime) {
765
+ constructor(audioContext, tempoMap, meterMap, destination, toAudioTime) {
554
766
  this._enabled = false;
555
- this._beatsPerBar = 4;
556
767
  this._accentBuffer = null;
557
768
  this._normalBuffer = null;
558
769
  this._activeSources = /* @__PURE__ */ new Set();
559
770
  this._audioContext = audioContext;
560
771
  this._tempoMap = tempoMap;
561
- this._tickTimeline = tickTimeline;
772
+ this._meterMap = meterMap;
562
773
  this._destination = destination;
563
774
  this._toAudioTime = toAudioTime;
564
775
  }
@@ -568,31 +779,34 @@ var MetronomePlayer = class {
568
779
  this.silence();
569
780
  }
570
781
  }
571
- setBeatsPerBar(beats) {
572
- this._beatsPerBar = beats;
573
- }
574
782
  setClickSounds(accent, normal) {
575
783
  this._accentBuffer = accent;
576
784
  this._normalBuffer = normal;
577
785
  }
578
- generate(fromTime, toTime) {
786
+ generate(fromTick, toTick) {
579
787
  if (!this._enabled || !this._accentBuffer || !this._normalBuffer) {
580
788
  return [];
581
789
  }
582
790
  const events = [];
583
- const ppqn = this._tickTimeline.ppqn;
584
- const fromTicks = this._tempoMap.secondsToTicks(fromTime);
585
- const toTicks = this._tempoMap.secondsToTicks(toTime);
586
- const firstBeatTick = Math.ceil(fromTicks / ppqn) * ppqn;
587
- for (let tick = firstBeatTick; tick < toTicks; tick += ppqn) {
588
- const transportTime = this._tempoMap.ticksToSeconds(tick);
589
- const ticksPerBar = this._tickTimeline.ticksPerBar(this._beatsPerBar);
590
- const isAccent = tick % ticksPerBar === 0;
791
+ let entry = this._meterMap.getEntryAt(fromTick);
792
+ let beatSize = this._meterMap.ticksPerBeat(fromTick);
793
+ const tickIntoSection = fromTick - entry.tick;
794
+ let tick = entry.tick + Math.ceil(tickIntoSection / beatSize) * beatSize;
795
+ while (tick < toTick) {
796
+ const tickPos = tick;
797
+ const currentEntry = this._meterMap.getEntryAt(tickPos);
798
+ if (currentEntry.tick !== entry.tick) {
799
+ entry = currentEntry;
800
+ beatSize = this._meterMap.ticksPerBeat(tickPos);
801
+ }
802
+ const isAccent = this._meterMap.isBarBoundary(tickPos);
591
803
  events.push({
592
- transportTime,
804
+ tick: tickPos,
593
805
  isAccent,
594
806
  buffer: isAccent ? this._accentBuffer : this._normalBuffer
595
807
  });
808
+ beatSize = this._meterMap.ticksPerBeat(tickPos);
809
+ tick += beatSize;
596
810
  }
597
811
  return events;
598
812
  }
@@ -612,10 +826,10 @@ var MetronomePlayer = class {
612
826
  );
613
827
  }
614
828
  });
615
- source.start(this._toAudioTime(event.transportTime));
829
+ const transportTime = this._tempoMap.ticksToSeconds(event.tick);
830
+ source.start(this._toAudioTime(transportTime));
616
831
  }
617
- onPositionJump(_newTime) {
618
- this.silence();
832
+ onPositionJump(_newTick) {
619
833
  }
620
834
  silence() {
621
835
  for (const source of this._activeSources) {
@@ -648,25 +862,30 @@ var Transport = class _Transport {
648
862
  this._soloedTrackIds = /* @__PURE__ */ new Set();
649
863
  this._mutedTrackIds = /* @__PURE__ */ new Set();
650
864
  this._playing = false;
865
+ this._loopEnabled = false;
866
+ this._loopStartSeconds = 0;
651
867
  this._listeners = /* @__PURE__ */ new Map();
652
868
  this._audioContext = audioContext;
653
869
  const sampleRate = options.sampleRate ?? audioContext.sampleRate;
654
870
  const ppqn = options.ppqn ?? 960;
655
871
  const tempo = options.tempo ?? 120;
656
- const beatsPerBar = options.beatsPerBar ?? 4;
872
+ const numerator = options.numerator ?? 4;
873
+ const denominator = options.denominator ?? 4;
657
874
  const lookahead = options.schedulerLookahead ?? 0.2;
658
- _Transport._validateOptions(sampleRate, ppqn, tempo, beatsPerBar, lookahead);
875
+ _Transport._validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead);
659
876
  this._clock = new Clock(audioContext);
660
- this._scheduler = new Scheduler({
877
+ this._sampleTimeline = new SampleTimeline(sampleRate);
878
+ this._meterMap = new MeterMap(ppqn, numerator, denominator);
879
+ this._tempoMap = new TempoMap(ppqn, tempo);
880
+ this._scheduler = new Scheduler(this._tempoMap, {
661
881
  lookahead,
662
- onLoop: (loopStartTime) => {
663
- this._clock.seekTo(loopStartTime);
882
+ onLoop: (loopStartSeconds, loopEndSeconds, currentTimeSeconds) => {
883
+ const timeToBoundary = loopEndSeconds - currentTimeSeconds;
884
+ this._clock.seekTo(loopStartSeconds - timeToBoundary);
664
885
  }
665
886
  });
666
- this._sampleTimeline = new SampleTimeline(sampleRate);
667
- this._tickTimeline = new TickTimeline(ppqn);
668
- this._tempoMap = new TempoMap(ppqn, tempo);
669
- this._initAudioGraph(audioContext, beatsPerBar);
887
+ this._sampleTimeline.setTempoMap(this._tempoMap);
888
+ this._initAudioGraph(audioContext);
670
889
  this._timer = new Timer(() => {
671
890
  const time = this._clock.getTime();
672
891
  if (this._endTime !== void 0 && time >= this._endTime) {
@@ -689,7 +908,8 @@ var Transport = class _Transport {
689
908
  this._scheduler.reset(currentTime);
690
909
  this._endTime = endTime;
691
910
  this._clock.start();
692
- this._clipPlayer.onPositionJump(currentTime);
911
+ const currentTick = this._tempoMap.secondsToTicks(currentTime);
912
+ this._clipPlayer.onPositionJump(currentTick);
693
913
  this._timer.start();
694
914
  this._playing = true;
695
915
  this._emit("play");
@@ -725,12 +945,17 @@ var Transport = class _Transport {
725
945
  this._endTime = void 0;
726
946
  if (wasPlaying) {
727
947
  this._clock.start();
728
- this._clipPlayer.onPositionJump(time);
948
+ const seekTick = this._tempoMap.secondsToTicks(time);
949
+ this._clipPlayer.onPositionJump(seekTick);
729
950
  this._timer.start();
730
951
  }
731
952
  }
732
953
  getCurrentTime() {
733
- return this._clock.getTime();
954
+ const t = this._clock.getTime();
955
+ if (this._loopEnabled && t < this._loopStartSeconds) {
956
+ return this._loopStartSeconds;
957
+ }
958
+ return t;
734
959
  }
735
960
  isPlaying() {
736
961
  return this._playing;
@@ -846,27 +1071,89 @@ var Transport = class _Transport {
846
1071
  this._masterNode.setVolume(volume);
847
1072
  }
848
1073
  // --- Loop ---
849
- setLoop(enabled, start, end) {
850
- if (enabled && start >= end) {
1074
+ /** Primary loop API — ticks as source of truth */
1075
+ setLoop(enabled, startTick, endTick) {
1076
+ if (enabled && startTick >= endTick) {
1077
+ console.warn(
1078
+ "[waveform-playlist] Transport.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
1079
+ );
1080
+ return;
1081
+ }
1082
+ this._loopEnabled = enabled;
1083
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1084
+ this._scheduler.setLoop(enabled, startTick, endTick);
1085
+ this._clipPlayer.setLoop(enabled, startTick, endTick);
1086
+ this._emit("loop");
1087
+ }
1088
+ /** Convenience — converts seconds to ticks */
1089
+ setLoopSeconds(enabled, startSec, endSec) {
1090
+ const startTick = this._tempoMap.secondsToTicks(startSec);
1091
+ const endTick = this._tempoMap.secondsToTicks(endSec);
1092
+ this.setLoop(enabled, startTick, endTick);
1093
+ }
1094
+ /** Convenience — sets loop in samples */
1095
+ setLoopSamples(enabled, startSample, endSample) {
1096
+ if (enabled && (!Number.isFinite(startSample) || !Number.isFinite(endSample))) {
1097
+ console.warn(
1098
+ "[waveform-playlist] Transport.setLoopSamples: non-finite sample values (" + startSample + ", " + endSample + ")"
1099
+ );
1100
+ return;
1101
+ }
1102
+ if (enabled && startSample >= endSample) {
851
1103
  console.warn(
852
- "[waveform-playlist] Transport.setLoop: start (" + start + ") must be less than end (" + end + ")"
1104
+ "[waveform-playlist] Transport.setLoopSamples: startSample (" + startSample + ") must be less than endSample (" + endSample + ")"
853
1105
  );
854
1106
  return;
855
1107
  }
856
- this._scheduler.setLoop(enabled, start, end);
857
- this._clipPlayer.setLoop(enabled, start, end);
1108
+ const startTick = this._sampleTimeline.samplesToTicks(startSample);
1109
+ const endTick = this._sampleTimeline.samplesToTicks(endSample);
1110
+ this._loopEnabled = enabled;
1111
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1112
+ this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
1113
+ this._scheduler.setLoop(enabled, startTick, endTick);
858
1114
  this._emit("loop");
859
1115
  }
860
1116
  // --- Tempo ---
861
- setTempo(bpm) {
862
- this._tempoMap.setTempo(bpm);
1117
+ setTempo(bpm, atTick) {
1118
+ this._tempoMap.setTempo(bpm, atTick);
1119
+ this._emit("tempochange");
1120
+ }
1121
+ getTempo(atTick) {
1122
+ return this._tempoMap.getTempo(atTick);
1123
+ }
1124
+ // --- Meter ---
1125
+ setMeter(numerator, denominator, atTick) {
1126
+ this._meterMap.setMeter(numerator, denominator, atTick);
1127
+ this._emit("meterchange");
1128
+ }
1129
+ getMeter(atTick) {
1130
+ return this._meterMap.getMeter(atTick);
1131
+ }
1132
+ removeMeter(atTick) {
1133
+ this._meterMap.removeMeter(atTick);
1134
+ this._emit("meterchange");
1135
+ }
1136
+ clearMeters() {
1137
+ this._meterMap.clearMeters();
1138
+ this._emit("meterchange");
1139
+ }
1140
+ clearTempos() {
1141
+ this._tempoMap.clearTempos();
863
1142
  this._emit("tempochange");
864
1143
  }
865
- getTempo() {
866
- return this._tempoMap.getTempo();
1144
+ barToTick(bar) {
1145
+ return this._meterMap.barToTick(bar);
867
1146
  }
868
- setBeatsPerBar(beats) {
869
- this._metronomePlayer.setBeatsPerBar(beats);
1147
+ tickToBar(tick) {
1148
+ return this._meterMap.tickToBar(tick);
1149
+ }
1150
+ /** Convert transport time (seconds) to tick position, using the tempo map. */
1151
+ timeToTick(seconds) {
1152
+ return this._tempoMap.secondsToTicks(seconds);
1153
+ }
1154
+ /** Convert tick position to transport time (seconds), using the tempo map. */
1155
+ tickToTime(tick) {
1156
+ return this._tempoMap.ticksToSeconds(tick);
870
1157
  }
871
1158
  // --- Metronome ---
872
1159
  setMetronomeEnabled(enabled) {
@@ -913,7 +1200,7 @@ var Transport = class _Transport {
913
1200
  this._listeners.clear();
914
1201
  }
915
1202
  // --- Private ---
916
- static _validateOptions(sampleRate, ppqn, tempo, beatsPerBar, lookahead) {
1203
+ static _validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead) {
917
1204
  if (sampleRate <= 0) {
918
1205
  throw new Error(
919
1206
  "[waveform-playlist] Transport: sampleRate must be positive, got " + sampleRate
@@ -927,9 +1214,14 @@ var Transport = class _Transport {
927
1214
  if (tempo <= 0) {
928
1215
  throw new Error("[waveform-playlist] Transport: tempo must be positive, got " + tempo);
929
1216
  }
930
- if (beatsPerBar <= 0 || !Number.isInteger(beatsPerBar)) {
1217
+ if (!Number.isInteger(numerator) || numerator < 1 || numerator > 32) {
1218
+ throw new Error(
1219
+ "[waveform-playlist] Transport: numerator must be an integer 1-32, got " + numerator
1220
+ );
1221
+ }
1222
+ if (denominator <= 0 || (denominator & denominator - 1) !== 0 || denominator > 32) {
931
1223
  throw new Error(
932
- "[waveform-playlist] Transport: beatsPerBar must be a positive integer, got " + beatsPerBar
1224
+ "[waveform-playlist] Transport: denominator must be a power of 2 (1-32), got " + denominator
933
1225
  );
934
1226
  }
935
1227
  if (lookahead <= 0) {
@@ -938,19 +1230,23 @@ var Transport = class _Transport {
938
1230
  );
939
1231
  }
940
1232
  }
941
- _initAudioGraph(audioContext, beatsPerBar) {
1233
+ _initAudioGraph(audioContext) {
942
1234
  this._masterNode = new MasterNode(audioContext);
943
1235
  this._masterNode.output.connect(audioContext.destination);
944
1236
  const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
945
- this._clipPlayer = new ClipPlayer(audioContext, this._sampleTimeline, toAudioTime);
1237
+ this._clipPlayer = new ClipPlayer(
1238
+ audioContext,
1239
+ this._sampleTimeline,
1240
+ this._tempoMap,
1241
+ toAudioTime
1242
+ );
946
1243
  this._metronomePlayer = new MetronomePlayer(
947
1244
  audioContext,
948
1245
  this._tempoMap,
949
- this._tickTimeline,
1246
+ this._meterMap,
950
1247
  this._masterNode.input,
951
1248
  toAudioTime
952
1249
  );
953
- this._metronomePlayer.setBeatsPerBar(beatsPerBar);
954
1250
  this._scheduler.addListener(this._clipPlayer);
955
1251
  this._scheduler.addListener(this._metronomePlayer);
956
1252
  }
@@ -1046,7 +1342,7 @@ var NativePlayoutAdapter = class {
1046
1342
  this._transport.setTrackPan(trackId, pan);
1047
1343
  }
1048
1344
  setLoop(enabled, start, end) {
1049
- this._transport.setLoop(enabled, start, end);
1345
+ this._transport.setLoopSeconds(enabled, start, end);
1050
1346
  }
1051
1347
  dispose() {
1052
1348
  this._transport.dispose();
@@ -1056,12 +1352,12 @@ export {
1056
1352
  ClipPlayer,
1057
1353
  Clock,
1058
1354
  MasterNode,
1355
+ MeterMap,
1059
1356
  MetronomePlayer,
1060
1357
  NativePlayoutAdapter,
1061
1358
  SampleTimeline,
1062
1359
  Scheduler,
1063
1360
  TempoMap,
1064
- TickTimeline,
1065
1361
  Timer,
1066
1362
  TrackNode,
1067
1363
  Transport