@dawcore/transport 0.0.10 → 0.0.12

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.d.mts CHANGED
@@ -142,13 +142,26 @@ declare class TempoMap {
142
142
  private _ppqn;
143
143
  private _entries;
144
144
  constructor(ppqn?: number, initialBpm?: number);
145
+ /** A non-finite or non-positive BPM silently corrupts the secondsAtTick
146
+ * cache (Infinity, or non-monotonic values that break the binary search
147
+ * in secondsToTicks) — reject it at the boundary instead. */
148
+ private static _validateBpm;
145
149
  getTempo(atTick?: Tick): number;
150
+ /** Number of tempo entries in the map. Always >= 1 — the tick-0 entry is
151
+ * permanent. Used by Transport.setTempo to detect multi-entry maps. */
152
+ get entryCount(): number;
146
153
  setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
147
154
  ticksToSeconds(ticks: Tick): number;
148
155
  secondsToTicks(seconds: number): Tick;
149
156
  beatsToSeconds(beats: number): number;
150
157
  secondsToBeats(seconds: number): number;
151
158
  clearTempos(): void;
159
+ /** Remove the tempo entry at exactly `atTick`, if one exists. The tick-0
160
+ * entry is permanent (a map must always have a tempo) — removing it is a
161
+ * no-op, matching removeMeter's treatment of the initial meter. The
162
+ * seconds cache is recomputed from the removal point, the same partial
163
+ * update setTempo uses. */
164
+ removeTempo(atTick: Tick): void;
152
165
  /** Get the interpolated BPM at a tick position */
153
166
  private _getTempoAt;
154
167
  private _ticksToSecondsInternal;
@@ -299,17 +312,18 @@ interface ClipEvent extends SchedulerEvent {
299
312
  trackId: string;
300
313
  clipId: string;
301
314
  audioBuffer: AudioBuffer;
302
- /** Clip position on timeline (integer samples) */
315
+ /** Clip position on timeline (integer samples, at the TIMELINE sample rate) */
303
316
  startSample: Sample;
304
- /** Offset into audioBuffer (integer samples) */
317
+ /** Offset into audioBuffer (integer samples, at the BUFFER's own sample
318
+ * rate — they index the buffer, not the timeline) */
305
319
  offsetSamples: Sample;
306
- /** Duration to play (integer samples) */
320
+ /** Duration to play (integer samples, at the BUFFER's own sample rate) */
307
321
  durationSamples: Sample;
308
322
  /** Clip gain multiplier */
309
323
  gain: number;
310
- /** Fade in duration (integer samples) */
324
+ /** Fade in duration (integer samples, at the TIMELINE sample rate) */
311
325
  fadeInDurationSamples: Sample;
312
- /** Fade out duration (integer samples) */
326
+ /** Fade out duration (integer samples, at the TIMELINE sample rate) */
313
327
  fadeOutDurationSamples: Sample;
314
328
  }
315
329
  declare class ClipPlayer implements SchedulerListener<ClipEvent> {
@@ -443,13 +457,21 @@ declare class Transport {
443
457
  setLoopSeconds(enabled: boolean, startSec: number, endSec: number): void;
444
458
  /** Convenience — sets loop in samples */
445
459
  setLoopSamples(enabled: boolean, startSample: Sample, endSample: Sample): void;
446
- setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
460
+ /** Returns true when the tempo was applied, false when a defaulted (no
461
+ * atTick) write was refused because the tempo map has multiple entries —
462
+ * pass an explicit atTick to modify a multi-entry map (#407). */
463
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): boolean;
447
464
  getTempo(atTick?: Tick): number;
448
465
  setMeter(numerator: number, denominator: number, atTick?: Tick): void;
449
466
  getMeter(atTick?: Tick): MeterSignature;
450
467
  removeMeter(atTick: Tick): void;
451
468
  clearMeters(): void;
452
469
  clearTempos(): void;
470
+ /** Remove the tempo entry at exactly `atTick` (tick 0 is permanent — a
471
+ * no-op, like removeMeter's initial entry). Mirrors setTempo's loop-cache
472
+ * invalidation and event; the emitted bpm is the tempo now in force at
473
+ * the removed position. */
474
+ removeTempo(atTick: Tick): void;
453
475
  barToTick(bar: number): Tick;
454
476
  tickToBar(tick: Tick): number;
455
477
  /** Convert transport time (seconds) to tick position, using the tempo map. */
@@ -514,7 +536,7 @@ declare class NativePlayoutAdapter implements PlayoutAdapter {
514
536
  setCountInMode(mode: CountInMode): void;
515
537
  setRecording(recording: boolean): void;
516
538
  isCountingIn(): boolean;
517
- setTempo(bpm: number, atTick?: number): void;
539
+ setTempo(bpm: number, atTick?: number): boolean;
518
540
  setMeter(numerator: number, denominator: number, atTick?: number): void;
519
541
  ticksToSeconds(tick: number): number;
520
542
  secondsToTicks(seconds: number): number;
package/dist/index.d.ts CHANGED
@@ -142,13 +142,26 @@ declare class TempoMap {
142
142
  private _ppqn;
143
143
  private _entries;
144
144
  constructor(ppqn?: number, initialBpm?: number);
145
+ /** A non-finite or non-positive BPM silently corrupts the secondsAtTick
146
+ * cache (Infinity, or non-monotonic values that break the binary search
147
+ * in secondsToTicks) — reject it at the boundary instead. */
148
+ private static _validateBpm;
145
149
  getTempo(atTick?: Tick): number;
150
+ /** Number of tempo entries in the map. Always >= 1 — the tick-0 entry is
151
+ * permanent. Used by Transport.setTempo to detect multi-entry maps. */
152
+ get entryCount(): number;
146
153
  setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
147
154
  ticksToSeconds(ticks: Tick): number;
148
155
  secondsToTicks(seconds: number): Tick;
149
156
  beatsToSeconds(beats: number): number;
150
157
  secondsToBeats(seconds: number): number;
151
158
  clearTempos(): void;
159
+ /** Remove the tempo entry at exactly `atTick`, if one exists. The tick-0
160
+ * entry is permanent (a map must always have a tempo) — removing it is a
161
+ * no-op, matching removeMeter's treatment of the initial meter. The
162
+ * seconds cache is recomputed from the removal point, the same partial
163
+ * update setTempo uses. */
164
+ removeTempo(atTick: Tick): void;
152
165
  /** Get the interpolated BPM at a tick position */
153
166
  private _getTempoAt;
154
167
  private _ticksToSecondsInternal;
@@ -299,17 +312,18 @@ interface ClipEvent extends SchedulerEvent {
299
312
  trackId: string;
300
313
  clipId: string;
301
314
  audioBuffer: AudioBuffer;
302
- /** Clip position on timeline (integer samples) */
315
+ /** Clip position on timeline (integer samples, at the TIMELINE sample rate) */
303
316
  startSample: Sample;
304
- /** Offset into audioBuffer (integer samples) */
317
+ /** Offset into audioBuffer (integer samples, at the BUFFER's own sample
318
+ * rate — they index the buffer, not the timeline) */
305
319
  offsetSamples: Sample;
306
- /** Duration to play (integer samples) */
320
+ /** Duration to play (integer samples, at the BUFFER's own sample rate) */
307
321
  durationSamples: Sample;
308
322
  /** Clip gain multiplier */
309
323
  gain: number;
310
- /** Fade in duration (integer samples) */
324
+ /** Fade in duration (integer samples, at the TIMELINE sample rate) */
311
325
  fadeInDurationSamples: Sample;
312
- /** Fade out duration (integer samples) */
326
+ /** Fade out duration (integer samples, at the TIMELINE sample rate) */
313
327
  fadeOutDurationSamples: Sample;
314
328
  }
315
329
  declare class ClipPlayer implements SchedulerListener<ClipEvent> {
@@ -443,13 +457,21 @@ declare class Transport {
443
457
  setLoopSeconds(enabled: boolean, startSec: number, endSec: number): void;
444
458
  /** Convenience — sets loop in samples */
445
459
  setLoopSamples(enabled: boolean, startSample: Sample, endSample: Sample): void;
446
- setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
460
+ /** Returns true when the tempo was applied, false when a defaulted (no
461
+ * atTick) write was refused because the tempo map has multiple entries —
462
+ * pass an explicit atTick to modify a multi-entry map (#407). */
463
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): boolean;
447
464
  getTempo(atTick?: Tick): number;
448
465
  setMeter(numerator: number, denominator: number, atTick?: Tick): void;
449
466
  getMeter(atTick?: Tick): MeterSignature;
450
467
  removeMeter(atTick: Tick): void;
451
468
  clearMeters(): void;
452
469
  clearTempos(): void;
470
+ /** Remove the tempo entry at exactly `atTick` (tick 0 is permanent — a
471
+ * no-op, like removeMeter's initial entry). Mirrors setTempo's loop-cache
472
+ * invalidation and event; the emitted bpm is the tempo now in force at
473
+ * the removed position. */
474
+ removeTempo(atTick: Tick): void;
453
475
  barToTick(bar: number): Tick;
454
476
  tickToBar(tick: Tick): number;
455
477
  /** Convert transport time (seconds) to tick position, using the tempo map. */
@@ -514,7 +536,7 @@ declare class NativePlayoutAdapter implements PlayoutAdapter {
514
536
  setCountInMode(mode: CountInMode): void;
515
537
  setRecording(recording: boolean): void;
516
538
  isCountingIn(): boolean;
517
- setTempo(bpm: number, atTick?: number): void;
539
+ setTempo(bpm: number, atTick?: number): boolean;
518
540
  setMeter(numerator: number, denominator: number, atTick?: number): void;
519
541
  ticksToSeconds(tick: number): number;
520
542
  secondsToTicks(seconds: number): number;
package/dist/index.js CHANGED
@@ -249,7 +249,7 @@ var SampleTimeline = class {
249
249
  "[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
250
250
  );
251
251
  }
252
- return this._tempoMap.secondsToTicks(samples / this._sampleRate);
252
+ return Math.round(this._tempoMap.secondsToTicks(samples / this._sampleRate));
253
253
  }
254
254
  };
255
255
 
@@ -261,15 +261,32 @@ function curveNormalizedAt(x, slope) {
261
261
  const p = Math.max(CURVE_EPSILON, Math.min(1 - CURVE_EPSILON, slope));
262
262
  return p * p / (1 - p * 2) * (Math.pow((1 - p) / p, 2 * x) - 1);
263
263
  }
264
- var TempoMap = class {
264
+ var TempoMap = class _TempoMap {
265
265
  constructor(ppqn = 960, initialBpm = 120) {
266
+ _TempoMap._validateBpm(initialBpm);
266
267
  this._ppqn = ppqn;
267
268
  this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
268
269
  }
270
+ /** A non-finite or non-positive BPM silently corrupts the secondsAtTick
271
+ * cache (Infinity, or non-monotonic values that break the binary search
272
+ * in secondsToTicks) — reject it at the boundary instead. */
273
+ static _validateBpm(bpm) {
274
+ if (!Number.isFinite(bpm) || bpm <= 0) {
275
+ throw new Error(
276
+ "[waveform-playlist] TempoMap: bpm must be a finite positive number, got " + bpm
277
+ );
278
+ }
279
+ }
269
280
  getTempo(atTick = 0) {
270
281
  return this._getTempoAt(atTick);
271
282
  }
283
+ /** Number of tempo entries in the map. Always >= 1 — the tick-0 entry is
284
+ * permanent. Used by Transport.setTempo to detect multi-entry maps. */
285
+ get entryCount() {
286
+ return this._entries.length;
287
+ }
272
288
  setTempo(bpm, atTick = 0, options) {
289
+ _TempoMap._validateBpm(bpm);
273
290
  const interpolation = options?.interpolation ?? "step";
274
291
  if (typeof interpolation === "object" && interpolation.type === "curve") {
275
292
  const s = interpolation.slope;
@@ -346,6 +363,18 @@ var TempoMap = class {
346
363
  const first = this._entries[0];
347
364
  this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
348
365
  }
366
+ /** Remove the tempo entry at exactly `atTick`, if one exists. The tick-0
367
+ * entry is permanent (a map must always have a tempo) — removing it is a
368
+ * no-op, matching removeMeter's treatment of the initial meter. The
369
+ * seconds cache is recomputed from the removal point, the same partial
370
+ * update setTempo uses. */
371
+ removeTempo(atTick) {
372
+ if (atTick === 0) return;
373
+ const i = this._entries.findIndex((e) => e.tick === atTick);
374
+ if (i === -1) return;
375
+ this._entries = [...this._entries.slice(0, i), ...this._entries.slice(i + 1)];
376
+ this._recomputeCache(i);
377
+ }
349
378
  /** Get the interpolated BPM at a tick position */
350
379
  _getTempoAt(atTick) {
351
380
  const entryIndex = this._entryIndexAt(atTick);
@@ -507,6 +536,7 @@ function isPowerOf2(n) {
507
536
  var MeterMap = class {
508
537
  constructor(ppqn, numerator = 4, denominator = 4) {
509
538
  this._ppqn = ppqn;
539
+ this._validateMeter(numerator, denominator);
510
540
  this._entries = [{ tick: 0, numerator, denominator, barAtTick: 0 }];
511
541
  }
512
542
  get ppqn() {
@@ -670,6 +700,11 @@ var MeterMap = class {
670
700
  "[waveform-playlist] MeterMap: denominator must be a power of 2 (1-32), got " + denominator
671
701
  );
672
702
  }
703
+ if (!Number.isInteger(this._ppqn * 4 / denominator)) {
704
+ throw new Error(
705
+ "[waveform-playlist] MeterMap: ppqn (" + this._ppqn + ") * 4 is not divisible by denominator (" + denominator + ") \u2014 bar boundaries would fall on fractional ticks"
706
+ );
707
+ }
673
708
  }
674
709
  };
675
710
 
@@ -800,11 +835,19 @@ var ClipPlayer = class {
800
835
  const clipTick = clip.startTick !== void 0 ? clip.startTick : this._sampleTimeline.samplesToTicks(clip.startSample);
801
836
  if (clipTick < fromTick) continue;
802
837
  if (clipTick >= toTick) continue;
803
- const fadeInDurationSamples = clip.fadeIn ? clip.fadeIn.duration ?? 0 : 0;
804
- const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
838
+ const timelineRate = this._sampleTimeline.sampleRate;
839
+ const bufferRate = clip.audioBuffer.sampleRate;
840
+ const fadeInDurationSamples = clip.fadeIn ? Math.round((clip.fadeIn.duration ?? 0) * timelineRate) : 0;
841
+ const fadeOutDurationSamples = clip.fadeOut ? Math.round((clip.fadeOut.duration ?? 0) * timelineRate) : 0;
805
842
  let durationSamples = clip.durationSamples;
806
- if (this._loopEnabled && clip.startSample + durationSamples > this._loopEndSamples) {
807
- durationSamples = this._loopEndSamples - clip.startSample;
843
+ if (this._loopEnabled) {
844
+ const durationTimelineSamples = Math.round(
845
+ clip.durationSamples / bufferRate * timelineRate
846
+ );
847
+ const allowedTimelineSamples = this._loopEndSamples - clip.startSample;
848
+ if (durationTimelineSamples > allowedTimelineSamples) {
849
+ durationSamples = Math.round(allowedTimelineSamples / timelineRate * bufferRate);
850
+ }
808
851
  }
809
852
  events.push({
810
853
  trackId,
@@ -830,9 +873,9 @@ var ClipPlayer = class {
830
873
  );
831
874
  return;
832
875
  }
833
- const sampleRate = this._sampleTimeline.sampleRate;
834
- const offsetSeconds = event.offsetSamples / sampleRate;
835
- const durationSeconds = event.durationSamples / sampleRate;
876
+ const bufferRate = event.audioBuffer.sampleRate;
877
+ const offsetSeconds = event.offsetSamples / bufferRate;
878
+ const durationSeconds = event.durationSamples / bufferRate;
836
879
  if (offsetSeconds >= event.audioBuffer.duration) {
837
880
  console.warn(
838
881
  "[waveform-playlist] ClipPlayer.consume: offset (" + offsetSeconds + "s) exceeds audioBuffer.duration (" + event.audioBuffer.duration + 's) for clipId "' + event.clipId + '" \u2014 clip will not play'
@@ -845,8 +888,9 @@ var ClipPlayer = class {
845
888
  const when = this._toAudioTime(transportSeconds);
846
889
  const gainNode = this._audioContext.createGain();
847
890
  gainNode.gain.value = event.gain;
848
- let fadeIn = event.fadeInDurationSamples / sampleRate;
849
- let fadeOut = event.fadeOutDurationSamples / sampleRate;
891
+ const timelineRate = this._sampleTimeline.sampleRate;
892
+ let fadeIn = event.fadeInDurationSamples / timelineRate;
893
+ let fadeOut = event.fadeOutDurationSamples / timelineRate;
850
894
  if (fadeIn + fadeOut > durationSeconds) {
851
895
  const ratio = durationSeconds / (fadeIn + fadeOut);
852
896
  fadeIn *= ratio;
@@ -886,16 +930,21 @@ var ClipPlayer = class {
886
930
  if (!clip.audioBuffer) continue;
887
931
  const clipTick = clip.startTick !== void 0 ? clip.startTick : this._sampleTimeline.samplesToTicks(clip.startSample);
888
932
  if (clipTick >= newTick) continue;
889
- const clipEndSample = clip.startSample + clip.durationSamples;
933
+ const timelineRate = this._sampleTimeline.sampleRate;
934
+ const bufferRate = clip.audioBuffer.sampleRate;
935
+ const bufToTimeline = (n) => Math.round(n / bufferRate * timelineRate);
936
+ const timelineToBuf = (n) => Math.round(n / timelineRate * bufferRate);
937
+ const clipEndSample = clip.startSample + bufToTimeline(clip.durationSamples);
890
938
  if (clipEndSample <= newSample) continue;
891
939
  const offsetIntoClipSamples = newSample - clip.startSample;
892
- const offsetSamples = clip.offsetSamples + offsetIntoClipSamples;
893
- let durationSamples = clipEndSample - newSample;
894
- if (this._loopEnabled && newSample + durationSamples > this._loopEndSamples) {
895
- durationSamples = this._loopEndSamples - newSample;
940
+ const offsetSamples = clip.offsetSamples + timelineToBuf(offsetIntoClipSamples);
941
+ let remainingTimelineSamples = clipEndSample - newSample;
942
+ if (this._loopEnabled && newSample + remainingTimelineSamples > this._loopEndSamples) {
943
+ remainingTimelineSamples = this._loopEndSamples - newSample;
896
944
  }
897
- if (durationSamples <= 0) continue;
898
- const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
945
+ if (remainingTimelineSamples <= 0) continue;
946
+ const durationSamples = timelineToBuf(remainingTimelineSamples);
947
+ const fadeOutDurationSamples = clip.fadeOut ? Math.round((clip.fadeOut.duration ?? 0) * timelineRate) : 0;
899
948
  this.consume({
900
949
  trackId,
901
950
  clipId: clip.id,
@@ -1499,12 +1548,22 @@ var _Transport = class _Transport {
1499
1548
  this._emit("loop");
1500
1549
  }
1501
1550
  // --- Tempo ---
1551
+ /** Returns true when the tempo was applied, false when a defaulted (no
1552
+ * atTick) write was refused because the tempo map has multiple entries —
1553
+ * pass an explicit atTick to modify a multi-entry map (#407). */
1502
1554
  setTempo(bpm, atTick, options) {
1555
+ if (atTick === void 0 && this._tempoMap.entryCount > 1) {
1556
+ console.warn(
1557
+ "[waveform-playlist] Transport.setTempo: refusing defaulted tick-0 write of " + bpm + " BPM \u2014 the tempo map has " + this._tempoMap.entryCount + " entries. Pass an explicit atTick to modify a multi-entry tempo map."
1558
+ );
1559
+ return false;
1560
+ }
1503
1561
  this._tempoMap.setTempo(bpm, atTick, options);
1504
1562
  if (this._loopEnabled) {
1505
1563
  this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1506
1564
  }
1507
1565
  this._emit("tempochange", { bpm, atTick: atTick ?? 0 });
1566
+ return true;
1508
1567
  }
1509
1568
  getTempo(atTick) {
1510
1569
  return this._tempoMap.getTempo(atTick);
@@ -1542,6 +1601,17 @@ var _Transport = class _Transport {
1542
1601
  }
1543
1602
  this._emit("tempochange", { bpm: this._tempoMap.getTempo(), atTick: 0 });
1544
1603
  }
1604
+ /** Remove the tempo entry at exactly `atTick` (tick 0 is permanent — a
1605
+ * no-op, like removeMeter's initial entry). Mirrors setTempo's loop-cache
1606
+ * invalidation and event; the emitted bpm is the tempo now in force at
1607
+ * the removed position. */
1608
+ removeTempo(atTick) {
1609
+ this._tempoMap.removeTempo(atTick);
1610
+ if (this._loopEnabled) {
1611
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1612
+ }
1613
+ this._emit("tempochange", { bpm: this._tempoMap.getTempo(atTick), atTick });
1614
+ }
1545
1615
  barToTick(bar) {
1546
1616
  return this._meterMap.barToTick(bar);
1547
1617
  }
@@ -1914,7 +1984,7 @@ var NativePlayoutAdapter = class {
1914
1984
  return this._transport.isCountingIn();
1915
1985
  }
1916
1986
  setTempo(bpm, atTick) {
1917
- this._transport.setTempo(bpm, atTick !== void 0 ? atTick : void 0);
1987
+ return this._transport.setTempo(bpm, atTick !== void 0 ? atTick : void 0);
1918
1988
  }
1919
1989
  setMeter(numerator, denominator, atTick) {
1920
1990
  this._transport.setMeter(