@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.js CHANGED
@@ -23,12 +23,12 @@ __export(index_exports, {
23
23
  ClipPlayer: () => ClipPlayer,
24
24
  Clock: () => Clock,
25
25
  MasterNode: () => MasterNode,
26
+ MeterMap: () => MeterMap,
26
27
  MetronomePlayer: () => MetronomePlayer,
27
28
  NativePlayoutAdapter: () => NativePlayoutAdapter,
28
29
  SampleTimeline: () => SampleTimeline,
29
30
  Scheduler: () => Scheduler,
30
31
  TempoMap: () => TempoMap,
31
- TickTimeline: () => TickTimeline,
32
32
  Timer: () => Timer,
33
33
  TrackNode: () => TrackNode,
34
34
  Transport: () => Transport
@@ -86,12 +86,15 @@ var Clock = class {
86
86
 
87
87
  // src/core/scheduler.ts
88
88
  var Scheduler = class {
89
- constructor(options = {}) {
89
+ constructor(tempoMap, options = {}) {
90
90
  this._rightEdge = 0;
91
+ // integer ticks
91
92
  this._listeners = /* @__PURE__ */ new Set();
92
93
  this._loopEnabled = false;
93
94
  this._loopStart = 0;
95
+ // integer ticks
94
96
  this._loopEnd = 0;
97
+ this._tempoMap = tempoMap;
95
98
  this._lookahead = options.lookahead ?? 0.2;
96
99
  this._onLoop = options.onLoop;
97
100
  }
@@ -101,25 +104,40 @@ var Scheduler = class {
101
104
  removeListener(listener) {
102
105
  this._listeners.delete(listener);
103
106
  }
104
- setLoop(enabled, start, end) {
105
- if (enabled && start >= end) {
107
+ /** Primary API — ticks as source of truth */
108
+ setLoop(enabled, startTick, endTick) {
109
+ if (enabled && (!Number.isFinite(startTick) || !Number.isFinite(endTick))) {
110
+ console.warn(
111
+ "[waveform-playlist] Scheduler.setLoop: non-finite tick values (" + startTick + ", " + endTick + ")"
112
+ );
113
+ return;
114
+ }
115
+ if (enabled && startTick >= endTick) {
106
116
  console.warn(
107
- "[waveform-playlist] Scheduler.setLoop: start (" + start + ") must be less than end (" + end + ")"
117
+ "[waveform-playlist] Scheduler.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
108
118
  );
109
119
  return;
110
120
  }
111
121
  this._loopEnabled = enabled;
112
- this._loopStart = start;
113
- this._loopEnd = end;
114
- }
115
- reset(time) {
116
- this._rightEdge = time;
117
- }
118
- advance(currentTime) {
119
- const targetEdge = currentTime + this._lookahead;
122
+ this._loopStart = Math.round(startTick);
123
+ this._loopEnd = Math.round(endTick);
124
+ }
125
+ /** Convenience — converts seconds to ticks via TempoMap */
126
+ setLoopSeconds(enabled, startSec, endSec) {
127
+ const startTick = this._tempoMap.secondsToTicks(startSec);
128
+ const endTick = this._tempoMap.secondsToTicks(endSec);
129
+ this.setLoop(enabled, startTick, endTick);
130
+ }
131
+ /** Reset scheduling cursor. Takes seconds (from Clock), converts to ticks. */
132
+ reset(timeSeconds) {
133
+ this._rightEdge = this._tempoMap.secondsToTicks(timeSeconds);
134
+ }
135
+ /** Advance the scheduling window. Takes seconds (from Clock), converts to ticks. */
136
+ advance(currentTimeSeconds) {
137
+ const targetTick = this._tempoMap.secondsToTicks(currentTimeSeconds + this._lookahead);
120
138
  if (this._loopEnabled && this._loopEnd > this._loopStart) {
121
139
  const loopDuration = this._loopEnd - this._loopStart;
122
- let remaining = targetEdge - this._rightEdge;
140
+ let remaining = targetTick - this._rightEdge;
123
141
  while (remaining > 0) {
124
142
  const distToEnd = this._loopEnd - this._rightEdge;
125
143
  if (distToEnd <= 0 || distToEnd > remaining) {
@@ -132,21 +150,25 @@ var Scheduler = class {
132
150
  for (const listener of this._listeners) {
133
151
  listener.onPositionJump(this._loopStart);
134
152
  }
135
- this._onLoop?.(this._loopStart);
153
+ this._onLoop?.(
154
+ this._tempoMap.ticksToSeconds(this._loopStart),
155
+ this._tempoMap.ticksToSeconds(this._loopEnd),
156
+ currentTimeSeconds
157
+ );
136
158
  this._rightEdge = this._loopStart;
137
159
  if (loopDuration <= 0) break;
138
160
  }
139
161
  return;
140
162
  }
141
- if (targetEdge > this._rightEdge) {
142
- this._generateAndConsume(this._rightEdge, targetEdge);
143
- this._rightEdge = targetEdge;
163
+ if (targetTick > this._rightEdge) {
164
+ this._generateAndConsume(this._rightEdge, targetTick);
165
+ this._rightEdge = targetTick;
144
166
  }
145
167
  }
146
- _generateAndConsume(from, to) {
168
+ _generateAndConsume(fromTick, toTick) {
147
169
  for (const listener of this._listeners) {
148
170
  try {
149
- const events = listener.generate(from, to);
171
+ const events = listener.generate(fromTick, toTick);
150
172
  for (const event of events) {
151
173
  try {
152
174
  listener.consume(event);
@@ -196,44 +218,36 @@ var Timer = class {
196
218
  // src/timeline/sample-timeline.ts
197
219
  var SampleTimeline = class {
198
220
  constructor(sampleRate) {
221
+ this._tempoMap = null;
199
222
  this._sampleRate = sampleRate;
200
223
  }
201
224
  get sampleRate() {
202
225
  return this._sampleRate;
203
226
  }
227
+ setTempoMap(tempoMap) {
228
+ this._tempoMap = tempoMap;
229
+ }
204
230
  samplesToSeconds(samples) {
205
231
  return samples / this._sampleRate;
206
232
  }
207
233
  secondsToSamples(seconds) {
208
234
  return Math.round(seconds * this._sampleRate);
209
235
  }
210
- };
211
-
212
- // src/timeline/tick-timeline.ts
213
- var TickTimeline = class {
214
- constructor(ppqn = 960) {
215
- this._ppqn = ppqn;
216
- }
217
- get ppqn() {
218
- return this._ppqn;
219
- }
220
- ticksPerBeat() {
221
- return this._ppqn;
222
- }
223
- ticksPerBar(beatsPerBar) {
224
- return this._ppqn * beatsPerBar;
225
- }
226
- toPosition(ticks, beatsPerBar) {
227
- const ticksPerBar = this.ticksPerBar(beatsPerBar);
228
- const bar = Math.floor(ticks / ticksPerBar) + 1;
229
- const remaining = ticks % ticksPerBar;
230
- const beat = Math.floor(remaining / this._ppqn) + 1;
231
- const tick = remaining % this._ppqn;
232
- return { bar, beat, tick };
236
+ ticksToSamples(ticks) {
237
+ if (!this._tempoMap) {
238
+ throw new Error(
239
+ "[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
240
+ );
241
+ }
242
+ return Math.round(this._tempoMap.ticksToSeconds(ticks) * this._sampleRate);
233
243
  }
234
- fromPosition(bar, beat, tick, beatsPerBar) {
235
- const ticksPerBar = this.ticksPerBar(beatsPerBar);
236
- return (bar - 1) * ticksPerBar + (beat - 1) * this._ppqn + tick;
244
+ samplesToTicks(samples) {
245
+ if (!this._tempoMap) {
246
+ throw new Error(
247
+ "[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
248
+ );
249
+ }
250
+ return this._tempoMap.secondsToTicks(samples / this._sampleRate);
237
251
  }
238
252
  };
239
253
 
@@ -281,7 +295,7 @@ var TempoMap = class {
281
295
  const entry = this._entries[lo];
282
296
  const secondsIntoSegment = seconds - entry.secondsAtTick;
283
297
  const ticksPerSecond = entry.bpm / 60 * this._ppqn;
284
- return entry.tick + secondsIntoSegment * ticksPerSecond;
298
+ return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
285
299
  }
286
300
  beatsToSeconds(beats) {
287
301
  return this.ticksToSeconds(beats * this._ppqn);
@@ -289,6 +303,10 @@ var TempoMap = class {
289
303
  secondsToBeats(seconds) {
290
304
  return this.secondsToTicks(seconds) / this._ppqn;
291
305
  }
306
+ clearTempos() {
307
+ const first = this._entries[0];
308
+ this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
309
+ }
292
310
  _ticksToSecondsInternal(ticks) {
293
311
  const entry = this._entryAt(ticks);
294
312
  const ticksIntoSegment = ticks - entry.tick;
@@ -321,6 +339,179 @@ var TempoMap = class {
321
339
  }
322
340
  };
323
341
 
342
+ // src/timeline/meter-map.ts
343
+ function isPowerOf2(n) {
344
+ return n > 0 && (n & n - 1) === 0;
345
+ }
346
+ var MeterMap = class {
347
+ constructor(ppqn, numerator = 4, denominator = 4) {
348
+ this._ppqn = ppqn;
349
+ this._entries = [{ tick: 0, numerator, denominator, barAtTick: 0 }];
350
+ }
351
+ get ppqn() {
352
+ return this._ppqn;
353
+ }
354
+ getMeter(atTick = 0) {
355
+ const entry = this._entryAt(atTick);
356
+ return { numerator: entry.numerator, denominator: entry.denominator };
357
+ }
358
+ setMeter(numerator, denominator, atTick = 0) {
359
+ this._validateMeter(numerator, denominator);
360
+ if (atTick < 0) {
361
+ throw new Error("[waveform-playlist] MeterMap: atTick must be non-negative, got " + atTick);
362
+ }
363
+ if (atTick === 0) {
364
+ this._entries[0] = { ...this._entries[0], numerator, denominator };
365
+ this._resnapDownstreamEntries(0);
366
+ this._recomputeCache(0);
367
+ return;
368
+ }
369
+ const snapped = this._snapToBarBoundary(atTick);
370
+ if (snapped !== atTick) {
371
+ console.warn(
372
+ "[waveform-playlist] MeterMap.setMeter: tick " + atTick + " is not on a bar boundary, snapped to " + snapped
373
+ );
374
+ }
375
+ let i = this._entries.length - 1;
376
+ while (i > 0 && this._entries[i].tick > snapped) i--;
377
+ if (this._entries[i].tick === snapped) {
378
+ this._entries[i] = { ...this._entries[i], numerator, denominator };
379
+ } else {
380
+ const barAtTick = this._computeBarAtTick(snapped);
381
+ this._entries.splice(i + 1, 0, { tick: snapped, numerator, denominator, barAtTick });
382
+ i = i + 1;
383
+ }
384
+ this._resnapDownstreamEntries(i);
385
+ this._recomputeCache(i);
386
+ }
387
+ removeMeter(atTick) {
388
+ if (atTick === 0) {
389
+ throw new Error("[waveform-playlist] MeterMap: cannot remove meter at tick 0");
390
+ }
391
+ const idx = this._entries.findIndex((e) => e.tick === atTick);
392
+ if (idx > 0) {
393
+ this._entries.splice(idx, 1);
394
+ this._recomputeCache(idx);
395
+ } else if (idx === -1) {
396
+ console.warn("[waveform-playlist] MeterMap.removeMeter: no entry at tick " + atTick);
397
+ }
398
+ }
399
+ clearMeters() {
400
+ const first = this._entries[0];
401
+ this._entries = [{ ...first, barAtTick: 0 }];
402
+ }
403
+ ticksPerBeat(atTick = 0) {
404
+ const entry = this._entryAt(atTick);
405
+ return this._ppqn * (4 / entry.denominator);
406
+ }
407
+ ticksPerBar(atTick = 0) {
408
+ const entry = this._entryAt(atTick);
409
+ return entry.numerator * this._ppqn * (4 / entry.denominator);
410
+ }
411
+ barToTick(bar) {
412
+ if (bar < 1) {
413
+ throw new Error("[waveform-playlist] MeterMap: bar must be >= 1, got " + bar);
414
+ }
415
+ const targetBar = bar - 1;
416
+ for (let i = 0; i < this._entries.length; i++) {
417
+ const nextBar = i < this._entries.length - 1 ? this._entries[i + 1].barAtTick : Infinity;
418
+ if (targetBar < nextBar) {
419
+ const barsInto = targetBar - this._entries[i].barAtTick;
420
+ const tpb = this._ticksPerBarForEntry(this._entries[i]);
421
+ return this._entries[i].tick + barsInto * tpb;
422
+ }
423
+ }
424
+ return 0;
425
+ }
426
+ tickToBar(tick) {
427
+ const entry = this._entryAt(tick);
428
+ const ticksInto = tick - entry.tick;
429
+ const tpb = this._ticksPerBarForEntry(entry);
430
+ return entry.barAtTick + Math.floor(ticksInto / tpb) + 1;
431
+ }
432
+ isBarBoundary(tick) {
433
+ const entry = this._entryAt(tick);
434
+ const ticksInto = tick - entry.tick;
435
+ const tpb = this._ticksPerBarForEntry(entry);
436
+ return ticksInto % tpb === 0;
437
+ }
438
+ /** Internal: get the full entry at a tick (for MetronomePlayer beat grid anchoring) */
439
+ getEntryAt(tick) {
440
+ return this._entryAt(tick);
441
+ }
442
+ _entryAt(tick) {
443
+ let lo = 0;
444
+ let hi = this._entries.length - 1;
445
+ while (lo < hi) {
446
+ const mid = lo + hi + 1 >> 1;
447
+ if (this._entries[mid].tick <= tick) {
448
+ lo = mid;
449
+ } else {
450
+ hi = mid - 1;
451
+ }
452
+ }
453
+ return this._entries[lo];
454
+ }
455
+ _ticksPerBarForEntry(entry) {
456
+ return entry.numerator * this._ppqn * (4 / entry.denominator);
457
+ }
458
+ _snapToBarBoundary(atTick) {
459
+ const entry = this._entryAt(atTick);
460
+ const tpb = this._ticksPerBarForEntry(entry);
461
+ const ticksInto = atTick - entry.tick;
462
+ if (ticksInto % tpb === 0) return atTick;
463
+ return entry.tick + Math.ceil(ticksInto / tpb) * tpb;
464
+ }
465
+ _computeBarAtTick(tick) {
466
+ const entry = this._entryAt(tick);
467
+ const ticksInto = tick - entry.tick;
468
+ const tpb = this._ticksPerBarForEntry(entry);
469
+ return entry.barAtTick + ticksInto / tpb;
470
+ }
471
+ _recomputeCache(fromIndex) {
472
+ for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
473
+ const prev = this._entries[i - 1];
474
+ const tickDelta = this._entries[i].tick - prev.tick;
475
+ const tpb = this._ticksPerBarForEntry(prev);
476
+ this._entries[i] = {
477
+ ...this._entries[i],
478
+ barAtTick: prev.barAtTick + tickDelta / tpb
479
+ };
480
+ }
481
+ }
482
+ /**
483
+ * After changing a meter entry, re-snap downstream entries to bar boundaries
484
+ * of their preceding meter so barAtTick stays integer.
485
+ */
486
+ _resnapDownstreamEntries(fromIndex) {
487
+ for (let i = Math.max(1, fromIndex + 1); i < this._entries.length; i++) {
488
+ const prev = this._entries[i - 1];
489
+ const tpb = this._ticksPerBarForEntry(prev);
490
+ const tick = this._entries[i].tick;
491
+ const ticksIntoPrev = tick - prev.tick;
492
+ if (ticksIntoPrev % tpb !== 0) {
493
+ const snapped = prev.tick + Math.ceil(ticksIntoPrev / tpb) * tpb;
494
+ console.warn(
495
+ "[waveform-playlist] MeterMap: meter change moved entry from tick " + tick + " to " + snapped + " (bar boundary alignment)"
496
+ );
497
+ this._entries[i] = { ...this._entries[i], tick: snapped };
498
+ }
499
+ }
500
+ }
501
+ _validateMeter(numerator, denominator) {
502
+ if (!Number.isInteger(numerator) || numerator < 1 || numerator > 32) {
503
+ throw new Error(
504
+ "[waveform-playlist] MeterMap: numerator must be an integer 1-32, got " + numerator
505
+ );
506
+ }
507
+ if (!isPowerOf2(denominator) || denominator > 32) {
508
+ throw new Error(
509
+ "[waveform-playlist] MeterMap: denominator must be a power of 2 (1-32), got " + denominator
510
+ );
511
+ }
512
+ }
513
+ };
514
+
324
515
  // src/audio/master-node.ts
325
516
  var MasterNode = class {
326
517
  constructor(audioContext) {
@@ -406,14 +597,15 @@ var TrackNode = class {
406
597
 
407
598
  // src/audio/clip-player.ts
408
599
  var ClipPlayer = class {
409
- constructor(audioContext, sampleTimeline, toAudioTime) {
600
+ constructor(audioContext, sampleTimeline, tempoMap, toAudioTime) {
410
601
  this._tracks = /* @__PURE__ */ new Map();
411
602
  this._trackNodes = /* @__PURE__ */ new Map();
412
603
  this._activeSources = /* @__PURE__ */ new Map();
413
604
  this._loopEnabled = false;
414
- this._loopEnd = 0;
605
+ this._loopEndSamples = 0;
415
606
  this._audioContext = audioContext;
416
607
  this._sampleTimeline = sampleTimeline;
608
+ this._tempoMap = tempoMap;
417
609
  this._toAudioTime = toAudioTime;
418
610
  }
419
611
  setTracks(tracks, trackNodes) {
@@ -423,41 +615,50 @@ var ClipPlayer = class {
423
615
  this._tracks.set(track.id, { track, clips: track.clips });
424
616
  }
425
617
  }
426
- setLoop(enabled, _start, end) {
618
+ /** Set loop region using ticks. startTick is unused — loop clamping only needs
619
+ * the end boundary; mid-clip restart at loopStart is handled by onPositionJump. */
620
+ setLoop(enabled, _startTick, endTick) {
621
+ this._loopEnabled = enabled;
622
+ this._loopEndSamples = this._sampleTimeline.ticksToSamples(endTick);
623
+ }
624
+ /** Set loop region using samples directly */
625
+ setLoopSamples(enabled, _startSample, endSample) {
427
626
  this._loopEnabled = enabled;
428
- this._loopEnd = end;
627
+ this._loopEndSamples = endSample;
429
628
  }
430
629
  updateTrack(trackId, track) {
431
630
  this._tracks.set(trackId, { track, clips: track.clips });
432
631
  this._silenceTrack(trackId);
433
632
  }
434
- generate(fromTime, toTime) {
633
+ generate(fromTick, toTick) {
435
634
  const events = [];
635
+ const fromSample = this._sampleTimeline.ticksToSamples(fromTick);
636
+ const toSample = this._sampleTimeline.ticksToSamples(toTick);
436
637
  for (const [trackId, state] of this._tracks) {
437
638
  for (const clip of state.clips) {
438
639
  if (clip.durationSamples === 0) continue;
439
640
  if (!clip.audioBuffer) continue;
440
- const clipStartTime = this._sampleTimeline.samplesToSeconds(clip.startSample);
441
- const clipDuration = this._sampleTimeline.samplesToSeconds(clip.durationSamples);
442
- const clipOffsetTime = this._sampleTimeline.samplesToSeconds(clip.offsetSamples);
443
- if (clipStartTime < fromTime) continue;
444
- if (clipStartTime >= toTime) continue;
445
- const fadeInDuration = clip.fadeIn ? this._sampleTimeline.samplesToSeconds(clip.fadeIn.duration ?? 0) : 0;
446
- const fadeOutDuration = clip.fadeOut ? this._sampleTimeline.samplesToSeconds(clip.fadeOut.duration ?? 0) : 0;
447
- let duration = clipDuration;
448
- if (this._loopEnabled && clipStartTime + duration > this._loopEnd) {
449
- duration = this._loopEnd - clipStartTime;
641
+ const clipStartSample = clip.startSample;
642
+ if (clipStartSample < fromSample) continue;
643
+ if (clipStartSample >= toSample) continue;
644
+ const fadeInDurationSamples = clip.fadeIn ? clip.fadeIn.duration ?? 0 : 0;
645
+ const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
646
+ let durationSamples = clip.durationSamples;
647
+ if (this._loopEnabled && clipStartSample + durationSamples > this._loopEndSamples) {
648
+ durationSamples = this._loopEndSamples - clipStartSample;
450
649
  }
650
+ const clipTick = this._sampleTimeline.samplesToTicks(clipStartSample);
451
651
  events.push({
452
652
  trackId,
453
653
  clipId: clip.id,
454
654
  audioBuffer: clip.audioBuffer,
455
- transportTime: clipStartTime,
456
- offset: clipOffsetTime,
457
- duration,
655
+ tick: clipTick,
656
+ startSample: clipStartSample,
657
+ offsetSamples: clip.offsetSamples,
658
+ durationSamples,
458
659
  gain: clip.gain,
459
- fadeInDuration,
460
- fadeOutDuration
660
+ fadeInDurationSamples,
661
+ fadeOutDurationSamples
461
662
  });
462
663
  }
463
664
  }
@@ -471,18 +672,25 @@ var ClipPlayer = class {
471
672
  );
472
673
  return;
473
674
  }
474
- if (event.offset >= event.audioBuffer.duration) {
675
+ const sampleRate = this._sampleTimeline.sampleRate;
676
+ const offsetSeconds = event.offsetSamples / sampleRate;
677
+ const durationSeconds = event.durationSamples / sampleRate;
678
+ if (offsetSeconds >= event.audioBuffer.duration) {
679
+ console.warn(
680
+ "[waveform-playlist] ClipPlayer.consume: offset (" + offsetSeconds + "s) exceeds audioBuffer.duration (" + event.audioBuffer.duration + 's) for clipId "' + event.clipId + '" \u2014 clip will not play'
681
+ );
475
682
  return;
476
683
  }
477
684
  const source = this._audioContext.createBufferSource();
478
685
  source.buffer = event.audioBuffer;
479
- const when = this._toAudioTime(event.transportTime);
686
+ const transportSeconds = this._tempoMap.ticksToSeconds(event.tick);
687
+ const when = this._toAudioTime(transportSeconds);
480
688
  const gainNode = this._audioContext.createGain();
481
689
  gainNode.gain.value = event.gain;
482
- let fadeIn = event.fadeInDuration;
483
- let fadeOut = event.fadeOutDuration;
484
- if (fadeIn + fadeOut > event.duration) {
485
- const ratio = event.duration / (fadeIn + fadeOut);
690
+ let fadeIn = event.fadeInDurationSamples / sampleRate;
691
+ let fadeOut = event.fadeOutDurationSamples / sampleRate;
692
+ if (fadeIn + fadeOut > durationSeconds) {
693
+ const ratio = durationSeconds / (fadeIn + fadeOut);
486
694
  fadeIn *= ratio;
487
695
  fadeOut *= ratio;
488
696
  }
@@ -491,9 +699,9 @@ var ClipPlayer = class {
491
699
  gainNode.gain.linearRampToValueAtTime(event.gain, when + fadeIn);
492
700
  }
493
701
  if (fadeOut > 0) {
494
- const fadeOutStart = when + event.duration - fadeOut;
702
+ const fadeOutStart = when + durationSeconds - fadeOut;
495
703
  gainNode.gain.setValueAtTime(event.gain, fadeOutStart);
496
- gainNode.gain.linearRampToValueAtTime(0, when + event.duration);
704
+ gainNode.gain.linearRampToValueAtTime(0, when + durationSeconds);
497
705
  }
498
706
  source.connect(gainNode);
499
707
  gainNode.connect(trackNode.input);
@@ -509,33 +717,37 @@ var ClipPlayer = class {
509
717
  console.warn("[waveform-playlist] ClipPlayer: error disconnecting gain node:", String(err));
510
718
  }
511
719
  });
512
- source.start(when, event.offset, event.duration);
720
+ source.start(when, offsetSeconds, durationSeconds);
513
721
  }
514
- onPositionJump(newTime) {
722
+ onPositionJump(newTick) {
515
723
  this.silence();
724
+ const newSample = this._sampleTimeline.ticksToSamples(newTick);
516
725
  for (const [trackId, state] of this._tracks) {
517
726
  for (const clip of state.clips) {
518
727
  if (clip.durationSamples === 0) continue;
519
728
  if (!clip.audioBuffer) continue;
520
- const clipStartTime = this._sampleTimeline.samplesToSeconds(clip.startSample);
521
- const clipDuration = this._sampleTimeline.samplesToSeconds(clip.durationSamples);
522
- const clipEndTime = clipStartTime + clipDuration;
523
- const clipOffsetTime = this._sampleTimeline.samplesToSeconds(clip.offsetSamples);
524
- if (clipStartTime <= newTime && clipEndTime > newTime) {
525
- const offsetIntoClip = newTime - clipStartTime;
526
- const offset = clipOffsetTime + offsetIntoClip;
527
- const duration = clipEndTime - newTime;
528
- const fadeOutDuration = clip.fadeOut ? this._sampleTimeline.samplesToSeconds(clip.fadeOut.duration ?? 0) : 0;
729
+ const clipStartSample = clip.startSample;
730
+ const clipEndSample = clipStartSample + clip.durationSamples;
731
+ if (clipStartSample <= newSample && clipEndSample > newSample) {
732
+ const offsetIntoClipSamples = newSample - clipStartSample;
733
+ const offsetSamples = clip.offsetSamples + offsetIntoClipSamples;
734
+ let durationSamples = clipEndSample - newSample;
735
+ if (this._loopEnabled && newSample + durationSamples > this._loopEndSamples) {
736
+ durationSamples = this._loopEndSamples - newSample;
737
+ }
738
+ if (durationSamples <= 0) continue;
739
+ const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
529
740
  this.consume({
530
741
  trackId,
531
742
  clipId: clip.id,
532
743
  audioBuffer: clip.audioBuffer,
533
- transportTime: newTime,
534
- offset,
535
- duration,
744
+ tick: newTick,
745
+ startSample: newSample,
746
+ offsetSamples,
747
+ durationSamples,
536
748
  gain: clip.gain,
537
- fadeInDuration: 0,
538
- fadeOutDuration
749
+ fadeInDurationSamples: 0,
750
+ fadeOutDurationSamples
539
751
  });
540
752
  }
541
753
  }
@@ -587,15 +799,14 @@ var ClipPlayer = class {
587
799
 
588
800
  // src/audio/metronome-player.ts
589
801
  var MetronomePlayer = class {
590
- constructor(audioContext, tempoMap, tickTimeline, destination, toAudioTime) {
802
+ constructor(audioContext, tempoMap, meterMap, destination, toAudioTime) {
591
803
  this._enabled = false;
592
- this._beatsPerBar = 4;
593
804
  this._accentBuffer = null;
594
805
  this._normalBuffer = null;
595
806
  this._activeSources = /* @__PURE__ */ new Set();
596
807
  this._audioContext = audioContext;
597
808
  this._tempoMap = tempoMap;
598
- this._tickTimeline = tickTimeline;
809
+ this._meterMap = meterMap;
599
810
  this._destination = destination;
600
811
  this._toAudioTime = toAudioTime;
601
812
  }
@@ -605,31 +816,34 @@ var MetronomePlayer = class {
605
816
  this.silence();
606
817
  }
607
818
  }
608
- setBeatsPerBar(beats) {
609
- this._beatsPerBar = beats;
610
- }
611
819
  setClickSounds(accent, normal) {
612
820
  this._accentBuffer = accent;
613
821
  this._normalBuffer = normal;
614
822
  }
615
- generate(fromTime, toTime) {
823
+ generate(fromTick, toTick) {
616
824
  if (!this._enabled || !this._accentBuffer || !this._normalBuffer) {
617
825
  return [];
618
826
  }
619
827
  const events = [];
620
- const ppqn = this._tickTimeline.ppqn;
621
- const fromTicks = this._tempoMap.secondsToTicks(fromTime);
622
- const toTicks = this._tempoMap.secondsToTicks(toTime);
623
- const firstBeatTick = Math.ceil(fromTicks / ppqn) * ppqn;
624
- for (let tick = firstBeatTick; tick < toTicks; tick += ppqn) {
625
- const transportTime = this._tempoMap.ticksToSeconds(tick);
626
- const ticksPerBar = this._tickTimeline.ticksPerBar(this._beatsPerBar);
627
- const isAccent = tick % ticksPerBar === 0;
828
+ let entry = this._meterMap.getEntryAt(fromTick);
829
+ let beatSize = this._meterMap.ticksPerBeat(fromTick);
830
+ const tickIntoSection = fromTick - entry.tick;
831
+ let tick = entry.tick + Math.ceil(tickIntoSection / beatSize) * beatSize;
832
+ while (tick < toTick) {
833
+ const tickPos = tick;
834
+ const currentEntry = this._meterMap.getEntryAt(tickPos);
835
+ if (currentEntry.tick !== entry.tick) {
836
+ entry = currentEntry;
837
+ beatSize = this._meterMap.ticksPerBeat(tickPos);
838
+ }
839
+ const isAccent = this._meterMap.isBarBoundary(tickPos);
628
840
  events.push({
629
- transportTime,
841
+ tick: tickPos,
630
842
  isAccent,
631
843
  buffer: isAccent ? this._accentBuffer : this._normalBuffer
632
844
  });
845
+ beatSize = this._meterMap.ticksPerBeat(tickPos);
846
+ tick += beatSize;
633
847
  }
634
848
  return events;
635
849
  }
@@ -649,10 +863,10 @@ var MetronomePlayer = class {
649
863
  );
650
864
  }
651
865
  });
652
- source.start(this._toAudioTime(event.transportTime));
866
+ const transportTime = this._tempoMap.ticksToSeconds(event.tick);
867
+ source.start(this._toAudioTime(transportTime));
653
868
  }
654
- onPositionJump(_newTime) {
655
- this.silence();
869
+ onPositionJump(_newTick) {
656
870
  }
657
871
  silence() {
658
872
  for (const source of this._activeSources) {
@@ -685,25 +899,30 @@ var Transport = class _Transport {
685
899
  this._soloedTrackIds = /* @__PURE__ */ new Set();
686
900
  this._mutedTrackIds = /* @__PURE__ */ new Set();
687
901
  this._playing = false;
902
+ this._loopEnabled = false;
903
+ this._loopStartSeconds = 0;
688
904
  this._listeners = /* @__PURE__ */ new Map();
689
905
  this._audioContext = audioContext;
690
906
  const sampleRate = options.sampleRate ?? audioContext.sampleRate;
691
907
  const ppqn = options.ppqn ?? 960;
692
908
  const tempo = options.tempo ?? 120;
693
- const beatsPerBar = options.beatsPerBar ?? 4;
909
+ const numerator = options.numerator ?? 4;
910
+ const denominator = options.denominator ?? 4;
694
911
  const lookahead = options.schedulerLookahead ?? 0.2;
695
- _Transport._validateOptions(sampleRate, ppqn, tempo, beatsPerBar, lookahead);
912
+ _Transport._validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead);
696
913
  this._clock = new Clock(audioContext);
697
- this._scheduler = new Scheduler({
914
+ this._sampleTimeline = new SampleTimeline(sampleRate);
915
+ this._meterMap = new MeterMap(ppqn, numerator, denominator);
916
+ this._tempoMap = new TempoMap(ppqn, tempo);
917
+ this._scheduler = new Scheduler(this._tempoMap, {
698
918
  lookahead,
699
- onLoop: (loopStartTime) => {
700
- this._clock.seekTo(loopStartTime);
919
+ onLoop: (loopStartSeconds, loopEndSeconds, currentTimeSeconds) => {
920
+ const timeToBoundary = loopEndSeconds - currentTimeSeconds;
921
+ this._clock.seekTo(loopStartSeconds - timeToBoundary);
701
922
  }
702
923
  });
703
- this._sampleTimeline = new SampleTimeline(sampleRate);
704
- this._tickTimeline = new TickTimeline(ppqn);
705
- this._tempoMap = new TempoMap(ppqn, tempo);
706
- this._initAudioGraph(audioContext, beatsPerBar);
924
+ this._sampleTimeline.setTempoMap(this._tempoMap);
925
+ this._initAudioGraph(audioContext);
707
926
  this._timer = new Timer(() => {
708
927
  const time = this._clock.getTime();
709
928
  if (this._endTime !== void 0 && time >= this._endTime) {
@@ -726,7 +945,8 @@ var Transport = class _Transport {
726
945
  this._scheduler.reset(currentTime);
727
946
  this._endTime = endTime;
728
947
  this._clock.start();
729
- this._clipPlayer.onPositionJump(currentTime);
948
+ const currentTick = this._tempoMap.secondsToTicks(currentTime);
949
+ this._clipPlayer.onPositionJump(currentTick);
730
950
  this._timer.start();
731
951
  this._playing = true;
732
952
  this._emit("play");
@@ -762,12 +982,17 @@ var Transport = class _Transport {
762
982
  this._endTime = void 0;
763
983
  if (wasPlaying) {
764
984
  this._clock.start();
765
- this._clipPlayer.onPositionJump(time);
985
+ const seekTick = this._tempoMap.secondsToTicks(time);
986
+ this._clipPlayer.onPositionJump(seekTick);
766
987
  this._timer.start();
767
988
  }
768
989
  }
769
990
  getCurrentTime() {
770
- return this._clock.getTime();
991
+ const t = this._clock.getTime();
992
+ if (this._loopEnabled && t < this._loopStartSeconds) {
993
+ return this._loopStartSeconds;
994
+ }
995
+ return t;
771
996
  }
772
997
  isPlaying() {
773
998
  return this._playing;
@@ -883,27 +1108,89 @@ var Transport = class _Transport {
883
1108
  this._masterNode.setVolume(volume);
884
1109
  }
885
1110
  // --- Loop ---
886
- setLoop(enabled, start, end) {
887
- if (enabled && start >= end) {
1111
+ /** Primary loop API — ticks as source of truth */
1112
+ setLoop(enabled, startTick, endTick) {
1113
+ if (enabled && startTick >= endTick) {
1114
+ console.warn(
1115
+ "[waveform-playlist] Transport.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
1116
+ );
1117
+ return;
1118
+ }
1119
+ this._loopEnabled = enabled;
1120
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1121
+ this._scheduler.setLoop(enabled, startTick, endTick);
1122
+ this._clipPlayer.setLoop(enabled, startTick, endTick);
1123
+ this._emit("loop");
1124
+ }
1125
+ /** Convenience — converts seconds to ticks */
1126
+ setLoopSeconds(enabled, startSec, endSec) {
1127
+ const startTick = this._tempoMap.secondsToTicks(startSec);
1128
+ const endTick = this._tempoMap.secondsToTicks(endSec);
1129
+ this.setLoop(enabled, startTick, endTick);
1130
+ }
1131
+ /** Convenience — sets loop in samples */
1132
+ setLoopSamples(enabled, startSample, endSample) {
1133
+ if (enabled && (!Number.isFinite(startSample) || !Number.isFinite(endSample))) {
1134
+ console.warn(
1135
+ "[waveform-playlist] Transport.setLoopSamples: non-finite sample values (" + startSample + ", " + endSample + ")"
1136
+ );
1137
+ return;
1138
+ }
1139
+ if (enabled && startSample >= endSample) {
888
1140
  console.warn(
889
- "[waveform-playlist] Transport.setLoop: start (" + start + ") must be less than end (" + end + ")"
1141
+ "[waveform-playlist] Transport.setLoopSamples: startSample (" + startSample + ") must be less than endSample (" + endSample + ")"
890
1142
  );
891
1143
  return;
892
1144
  }
893
- this._scheduler.setLoop(enabled, start, end);
894
- this._clipPlayer.setLoop(enabled, start, end);
1145
+ const startTick = this._sampleTimeline.samplesToTicks(startSample);
1146
+ const endTick = this._sampleTimeline.samplesToTicks(endSample);
1147
+ this._loopEnabled = enabled;
1148
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1149
+ this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
1150
+ this._scheduler.setLoop(enabled, startTick, endTick);
895
1151
  this._emit("loop");
896
1152
  }
897
1153
  // --- Tempo ---
898
- setTempo(bpm) {
899
- this._tempoMap.setTempo(bpm);
1154
+ setTempo(bpm, atTick) {
1155
+ this._tempoMap.setTempo(bpm, atTick);
1156
+ this._emit("tempochange");
1157
+ }
1158
+ getTempo(atTick) {
1159
+ return this._tempoMap.getTempo(atTick);
1160
+ }
1161
+ // --- Meter ---
1162
+ setMeter(numerator, denominator, atTick) {
1163
+ this._meterMap.setMeter(numerator, denominator, atTick);
1164
+ this._emit("meterchange");
1165
+ }
1166
+ getMeter(atTick) {
1167
+ return this._meterMap.getMeter(atTick);
1168
+ }
1169
+ removeMeter(atTick) {
1170
+ this._meterMap.removeMeter(atTick);
1171
+ this._emit("meterchange");
1172
+ }
1173
+ clearMeters() {
1174
+ this._meterMap.clearMeters();
1175
+ this._emit("meterchange");
1176
+ }
1177
+ clearTempos() {
1178
+ this._tempoMap.clearTempos();
900
1179
  this._emit("tempochange");
901
1180
  }
902
- getTempo() {
903
- return this._tempoMap.getTempo();
1181
+ barToTick(bar) {
1182
+ return this._meterMap.barToTick(bar);
904
1183
  }
905
- setBeatsPerBar(beats) {
906
- this._metronomePlayer.setBeatsPerBar(beats);
1184
+ tickToBar(tick) {
1185
+ return this._meterMap.tickToBar(tick);
1186
+ }
1187
+ /** Convert transport time (seconds) to tick position, using the tempo map. */
1188
+ timeToTick(seconds) {
1189
+ return this._tempoMap.secondsToTicks(seconds);
1190
+ }
1191
+ /** Convert tick position to transport time (seconds), using the tempo map. */
1192
+ tickToTime(tick) {
1193
+ return this._tempoMap.ticksToSeconds(tick);
907
1194
  }
908
1195
  // --- Metronome ---
909
1196
  setMetronomeEnabled(enabled) {
@@ -950,7 +1237,7 @@ var Transport = class _Transport {
950
1237
  this._listeners.clear();
951
1238
  }
952
1239
  // --- Private ---
953
- static _validateOptions(sampleRate, ppqn, tempo, beatsPerBar, lookahead) {
1240
+ static _validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead) {
954
1241
  if (sampleRate <= 0) {
955
1242
  throw new Error(
956
1243
  "[waveform-playlist] Transport: sampleRate must be positive, got " + sampleRate
@@ -964,9 +1251,14 @@ var Transport = class _Transport {
964
1251
  if (tempo <= 0) {
965
1252
  throw new Error("[waveform-playlist] Transport: tempo must be positive, got " + tempo);
966
1253
  }
967
- if (beatsPerBar <= 0 || !Number.isInteger(beatsPerBar)) {
1254
+ if (!Number.isInteger(numerator) || numerator < 1 || numerator > 32) {
1255
+ throw new Error(
1256
+ "[waveform-playlist] Transport: numerator must be an integer 1-32, got " + numerator
1257
+ );
1258
+ }
1259
+ if (denominator <= 0 || (denominator & denominator - 1) !== 0 || denominator > 32) {
968
1260
  throw new Error(
969
- "[waveform-playlist] Transport: beatsPerBar must be a positive integer, got " + beatsPerBar
1261
+ "[waveform-playlist] Transport: denominator must be a power of 2 (1-32), got " + denominator
970
1262
  );
971
1263
  }
972
1264
  if (lookahead <= 0) {
@@ -975,19 +1267,23 @@ var Transport = class _Transport {
975
1267
  );
976
1268
  }
977
1269
  }
978
- _initAudioGraph(audioContext, beatsPerBar) {
1270
+ _initAudioGraph(audioContext) {
979
1271
  this._masterNode = new MasterNode(audioContext);
980
1272
  this._masterNode.output.connect(audioContext.destination);
981
1273
  const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
982
- this._clipPlayer = new ClipPlayer(audioContext, this._sampleTimeline, toAudioTime);
1274
+ this._clipPlayer = new ClipPlayer(
1275
+ audioContext,
1276
+ this._sampleTimeline,
1277
+ this._tempoMap,
1278
+ toAudioTime
1279
+ );
983
1280
  this._metronomePlayer = new MetronomePlayer(
984
1281
  audioContext,
985
1282
  this._tempoMap,
986
- this._tickTimeline,
1283
+ this._meterMap,
987
1284
  this._masterNode.input,
988
1285
  toAudioTime
989
1286
  );
990
- this._metronomePlayer.setBeatsPerBar(beatsPerBar);
991
1287
  this._scheduler.addListener(this._clipPlayer);
992
1288
  this._scheduler.addListener(this._metronomePlayer);
993
1289
  }
@@ -1083,7 +1379,7 @@ var NativePlayoutAdapter = class {
1083
1379
  this._transport.setTrackPan(trackId, pan);
1084
1380
  }
1085
1381
  setLoop(enabled, start, end) {
1086
- this._transport.setLoop(enabled, start, end);
1382
+ this._transport.setLoopSeconds(enabled, start, end);
1087
1383
  }
1088
1384
  dispose() {
1089
1385
  this._transport.dispose();
@@ -1094,12 +1390,12 @@ var NativePlayoutAdapter = class {
1094
1390
  ClipPlayer,
1095
1391
  Clock,
1096
1392
  MasterNode,
1393
+ MeterMap,
1097
1394
  MetronomePlayer,
1098
1395
  NativePlayoutAdapter,
1099
1396
  SampleTimeline,
1100
1397
  Scheduler,
1101
1398
  TempoMap,
1102
- TickTimeline,
1103
1399
  Timer,
1104
1400
  TrackNode,
1105
1401
  Transport