@dawcore/transport 0.0.3 → 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/README.md CHANGED
@@ -92,6 +92,38 @@ transport.setMetronomeEnabled(true);
92
92
  transport.play();
93
93
  ```
94
94
 
95
+ ### Tempo Automation
96
+
97
+ ```typescript
98
+ const transport = new Transport(audioContext, { tempo: 100 });
99
+
100
+ // Linear ramp from 100 to 160 BPM over 8 bars
101
+ transport.setTempo(160, transport.barToTick(9), { interpolation: 'linear' });
102
+
103
+ // Query interpolated BPM at any position
104
+ transport.getTempo(transport.barToTick(5)); // 130 BPM (midway through ramp)
105
+
106
+ // Curved ramp: ease-in (slow start, fast end)
107
+ transport.clearTempos();
108
+ transport.setTempo(80);
109
+ transport.setTempo(160, transport.barToTick(9), {
110
+ interpolation: { type: 'curve', slope: 0.2 }, // concave
111
+ });
112
+
113
+ // Curved ramp: ease-out (fast start, slow end)
114
+ transport.clearTempos();
115
+ transport.setTempo(80);
116
+ transport.setTempo(160, transport.barToTick(9), {
117
+ interpolation: { type: 'curve', slope: 0.8 }, // convex
118
+ });
119
+
120
+ // Mix step and linear: jump to 80 BPM at bar 4, ramp to 140 at bar 8
121
+ transport.clearTempos();
122
+ transport.setTempo(120);
123
+ transport.setTempo(80, transport.barToTick(5)); // step (instant jump)
124
+ transport.setTempo(140, transport.barToTick(9), { interpolation: 'linear' }); // ramp
125
+ ```
126
+
95
127
  ### Effects
96
128
 
97
129
  ```typescript
@@ -151,7 +183,7 @@ new Transport(audioContext: AudioContext, options?: TransportOptions)
151
183
  - `setLoopSamples(enabled, startSample: Sample, endSample: Sample)` — Set loop region in samples (convenience)
152
184
 
153
185
  **Tempo & Meter:**
154
- - `setTempo(bpm, atTick?)` / `getTempo(atTick?)`
186
+ - `setTempo(bpm, atTick?, options?)` / `getTempo(atTick?: Tick)` — options: `{ interpolation: 'step' | 'linear' | { type: 'curve', slope } }`
155
187
  - `clearTempos()` — remove all tempo entries
156
188
  - `setMeter(numerator, denominator, atTick?: Tick)` / `getMeter(atTick?: Tick)`
157
189
  - `removeMeter(atTick: Tick)` / `clearMeters()`
package/dist/index.d.mts CHANGED
@@ -57,11 +57,20 @@ interface MeterEntry {
57
57
  /** Cached cumulative bar count from tick 0 to this entry. Derived — do not set manually. */
58
58
  readonly barAtTick: number;
59
59
  }
60
+ /** How to interpolate tempo from the previous entry to this one.
61
+ * 'step' = instant jump (default). 'linear' = linear ramp.
62
+ * { type: 'curve', slope } = Möbius-Ease curve (slope 0-1 exclusive). */
63
+ type TempoInterpolation = 'step' | 'linear' | {
64
+ type: 'curve';
65
+ slope: number;
66
+ };
60
67
  interface TempoEntry {
61
68
  /** Tick position where this tempo starts */
62
69
  tick: Tick;
63
70
  /** Beats per minute */
64
71
  bpm: number;
72
+ /** How to arrive at this BPM from the previous entry */
73
+ readonly interpolation: TempoInterpolation;
65
74
  /** Cached cumulative seconds up to this tick (for O(log n) lookup). Derived — do not set manually. */
66
75
  readonly secondsAtTick: number;
67
76
  }
@@ -95,19 +104,52 @@ declare class Clock {
95
104
  isRunning(): boolean;
96
105
  }
97
106
 
107
+ interface SetTempoOptions {
108
+ interpolation?: TempoInterpolation;
109
+ }
98
110
  declare class TempoMap {
99
111
  private _ppqn;
100
112
  private _entries;
101
113
  constructor(ppqn?: number, initialBpm?: number);
102
114
  getTempo(atTick?: Tick): number;
103
- setTempo(bpm: number, atTick?: Tick): void;
115
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
104
116
  ticksToSeconds(ticks: Tick): number;
105
117
  secondsToTicks(seconds: number): Tick;
106
118
  beatsToSeconds(beats: number): number;
107
119
  secondsToBeats(seconds: number): number;
108
120
  clearTempos(): void;
121
+ /** Get the interpolated BPM at a tick position */
122
+ private _getTempoAt;
109
123
  private _ticksToSecondsInternal;
110
- private _entryAt;
124
+ /**
125
+ * Exact integration for a linear BPM ramp using the logarithmic formula.
126
+ * For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
127
+ * seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
128
+ */
129
+ private _ticksToSecondsLinear;
130
+ /**
131
+ * Inverse of _ticksToSecondsLinear: given seconds, return ticks.
132
+ * Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
133
+ * then ticks = (bpmAtTick - bpm0) * T / deltaBpm
134
+ *
135
+ * Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
136
+ * Math.round() in the caller (secondsToTicks). This is sufficient for all tested
137
+ * BPM ranges (10–300 BPM) but is not algebraically exact like the previous
138
+ * trapezoidal/quadratic approach was.
139
+ */
140
+ private _secondsToTicksLinear;
141
+ /**
142
+ * Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
143
+ * The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
144
+ * We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
145
+ */
146
+ private _ticksToSecondsCurve;
147
+ /**
148
+ * Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
149
+ * return ticks. Uses binary search since there's no closed-form inverse.
150
+ */
151
+ private _secondsToTicksCurve;
152
+ private _entryIndexAt;
111
153
  private _recomputeCache;
112
154
  }
113
155
 
@@ -314,6 +356,7 @@ declare class Transport {
314
356
  private _playing;
315
357
  private _endTime;
316
358
  private _loopEnabled;
359
+ private _loopStartTick;
317
360
  private _loopStartSeconds;
318
361
  private _listeners;
319
362
  constructor(audioContext: AudioContext, options?: TransportOptions);
@@ -339,7 +382,7 @@ declare class Transport {
339
382
  setLoopSeconds(enabled: boolean, startSec: number, endSec: number): void;
340
383
  /** Convenience — sets loop in samples */
341
384
  setLoopSamples(enabled: boolean, startSample: Sample, endSample: Sample): void;
342
- setTempo(bpm: number, atTick?: Tick): void;
385
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
343
386
  getTempo(atTick?: Tick): number;
344
387
  setMeter(numerator: number, denominator: number, atTick?: Tick): void;
345
388
  getMeter(atTick?: Tick): MeterSignature;
@@ -391,4 +434,4 @@ declare class NativePlayoutAdapter implements PlayoutAdapter {
391
434
  dispose(): void;
392
435
  }
393
436
 
394
- export { type ClipEvent, ClipPlayer, Clock, MasterNode, type MeterEntry, MeterMap, type MeterSignature, type MetronomeEvent, MetronomePlayer, NativePlayoutAdapter, type Sample, SampleTimeline, Scheduler, type SchedulerEvent, type SchedulerListener, type SchedulerOptions, type TempoEntry, TempoMap, type Tick, Timer, TrackNode, Transport, type TransportEvents, type TransportOptions, type TransportPosition };
437
+ export { type ClipEvent, ClipPlayer, Clock, MasterNode, type MeterEntry, MeterMap, type MeterSignature, type MetronomeEvent, MetronomePlayer, NativePlayoutAdapter, type Sample, SampleTimeline, Scheduler, type SchedulerEvent, type SchedulerListener, type SchedulerOptions, type SetTempoOptions, type TempoEntry, type TempoInterpolation, TempoMap, type Tick, Timer, TrackNode, Transport, type TransportEvents, type TransportOptions, type TransportPosition };
package/dist/index.d.ts CHANGED
@@ -57,11 +57,20 @@ interface MeterEntry {
57
57
  /** Cached cumulative bar count from tick 0 to this entry. Derived — do not set manually. */
58
58
  readonly barAtTick: number;
59
59
  }
60
+ /** How to interpolate tempo from the previous entry to this one.
61
+ * 'step' = instant jump (default). 'linear' = linear ramp.
62
+ * { type: 'curve', slope } = Möbius-Ease curve (slope 0-1 exclusive). */
63
+ type TempoInterpolation = 'step' | 'linear' | {
64
+ type: 'curve';
65
+ slope: number;
66
+ };
60
67
  interface TempoEntry {
61
68
  /** Tick position where this tempo starts */
62
69
  tick: Tick;
63
70
  /** Beats per minute */
64
71
  bpm: number;
72
+ /** How to arrive at this BPM from the previous entry */
73
+ readonly interpolation: TempoInterpolation;
65
74
  /** Cached cumulative seconds up to this tick (for O(log n) lookup). Derived — do not set manually. */
66
75
  readonly secondsAtTick: number;
67
76
  }
@@ -95,19 +104,52 @@ declare class Clock {
95
104
  isRunning(): boolean;
96
105
  }
97
106
 
107
+ interface SetTempoOptions {
108
+ interpolation?: TempoInterpolation;
109
+ }
98
110
  declare class TempoMap {
99
111
  private _ppqn;
100
112
  private _entries;
101
113
  constructor(ppqn?: number, initialBpm?: number);
102
114
  getTempo(atTick?: Tick): number;
103
- setTempo(bpm: number, atTick?: Tick): void;
115
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
104
116
  ticksToSeconds(ticks: Tick): number;
105
117
  secondsToTicks(seconds: number): Tick;
106
118
  beatsToSeconds(beats: number): number;
107
119
  secondsToBeats(seconds: number): number;
108
120
  clearTempos(): void;
121
+ /** Get the interpolated BPM at a tick position */
122
+ private _getTempoAt;
109
123
  private _ticksToSecondsInternal;
110
- private _entryAt;
124
+ /**
125
+ * Exact integration for a linear BPM ramp using the logarithmic formula.
126
+ * For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
127
+ * seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
128
+ */
129
+ private _ticksToSecondsLinear;
130
+ /**
131
+ * Inverse of _ticksToSecondsLinear: given seconds, return ticks.
132
+ * Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
133
+ * then ticks = (bpmAtTick - bpm0) * T / deltaBpm
134
+ *
135
+ * Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
136
+ * Math.round() in the caller (secondsToTicks). This is sufficient for all tested
137
+ * BPM ranges (10–300 BPM) but is not algebraically exact like the previous
138
+ * trapezoidal/quadratic approach was.
139
+ */
140
+ private _secondsToTicksLinear;
141
+ /**
142
+ * Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
143
+ * The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
144
+ * We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
145
+ */
146
+ private _ticksToSecondsCurve;
147
+ /**
148
+ * Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
149
+ * return ticks. Uses binary search since there's no closed-form inverse.
150
+ */
151
+ private _secondsToTicksCurve;
152
+ private _entryIndexAt;
111
153
  private _recomputeCache;
112
154
  }
113
155
 
@@ -314,6 +356,7 @@ declare class Transport {
314
356
  private _playing;
315
357
  private _endTime;
316
358
  private _loopEnabled;
359
+ private _loopStartTick;
317
360
  private _loopStartSeconds;
318
361
  private _listeners;
319
362
  constructor(audioContext: AudioContext, options?: TransportOptions);
@@ -339,7 +382,7 @@ declare class Transport {
339
382
  setLoopSeconds(enabled: boolean, startSec: number, endSec: number): void;
340
383
  /** Convenience — sets loop in samples */
341
384
  setLoopSamples(enabled: boolean, startSample: Sample, endSample: Sample): void;
342
- setTempo(bpm: number, atTick?: Tick): void;
385
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
343
386
  getTempo(atTick?: Tick): number;
344
387
  setMeter(numerator: number, denominator: number, atTick?: Tick): void;
345
388
  getMeter(atTick?: Tick): MeterSignature;
@@ -391,4 +434,4 @@ declare class NativePlayoutAdapter implements PlayoutAdapter {
391
434
  dispose(): void;
392
435
  }
393
436
 
394
- export { type ClipEvent, ClipPlayer, Clock, MasterNode, type MeterEntry, MeterMap, type MeterSignature, type MetronomeEvent, MetronomePlayer, NativePlayoutAdapter, type Sample, SampleTimeline, Scheduler, type SchedulerEvent, type SchedulerListener, type SchedulerOptions, type TempoEntry, TempoMap, type Tick, Timer, TrackNode, Transport, type TransportEvents, type TransportOptions, type TransportPosition };
437
+ export { type ClipEvent, ClipPlayer, Clock, MasterNode, type MeterEntry, MeterMap, type MeterSignature, type MetronomeEvent, MetronomePlayer, NativePlayoutAdapter, type Sample, SampleTimeline, Scheduler, type SchedulerEvent, type SchedulerListener, type SchedulerOptions, type SetTempoOptions, type TempoEntry, type TempoInterpolation, TempoMap, type Tick, Timer, TrackNode, Transport, type TransportEvents, type TransportOptions, type TransportPosition };
package/dist/index.js CHANGED
@@ -252,28 +252,43 @@ var SampleTimeline = class {
252
252
  };
253
253
 
254
254
  // src/timeline/tempo-map.ts
255
+ var CURVE_EPSILON = 1e-15;
256
+ var CURVE_SUBDIVISIONS = 64;
257
+ function curveNormalizedAt(x, slope) {
258
+ if (slope > 0.499999 && slope < 0.500001) return x;
259
+ const p = Math.max(CURVE_EPSILON, Math.min(1 - CURVE_EPSILON, slope));
260
+ return p * p / (1 - p * 2) * (Math.pow((1 - p) / p, 2 * x) - 1);
261
+ }
255
262
  var TempoMap = class {
256
263
  constructor(ppqn = 960, initialBpm = 120) {
257
264
  this._ppqn = ppqn;
258
- this._entries = [{ tick: 0, bpm: initialBpm, secondsAtTick: 0 }];
265
+ this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
259
266
  }
260
267
  getTempo(atTick = 0) {
261
- const entry = this._entryAt(atTick);
262
- return entry.bpm;
263
- }
264
- setTempo(bpm, atTick = 0) {
268
+ return this._getTempoAt(atTick);
269
+ }
270
+ setTempo(bpm, atTick = 0, options) {
271
+ const interpolation = options?.interpolation ?? "step";
272
+ if (typeof interpolation === "object" && interpolation.type === "curve") {
273
+ const s = interpolation.slope;
274
+ if (!Number.isFinite(s) || s <= 0 || s >= 1) {
275
+ throw new Error(
276
+ "[waveform-playlist] TempoMap: curve slope must be between 0 and 1 (exclusive), got " + s
277
+ );
278
+ }
279
+ }
265
280
  if (atTick === 0) {
266
- this._entries[0] = { ...this._entries[0], bpm };
281
+ this._entries[0] = { ...this._entries[0], bpm, interpolation: "step" };
267
282
  this._recomputeCache(0);
268
283
  return;
269
284
  }
270
285
  let i = this._entries.length - 1;
271
286
  while (i > 0 && this._entries[i].tick > atTick) i--;
272
287
  if (this._entries[i].tick === atTick) {
273
- this._entries[i] = { ...this._entries[i], bpm };
288
+ this._entries[i] = { ...this._entries[i], bpm, interpolation };
274
289
  } else {
275
290
  const secondsAtTick = this._ticksToSecondsInternal(atTick);
276
- this._entries.splice(i + 1, 0, { tick: atTick, bpm, secondsAtTick });
291
+ this._entries.splice(i + 1, 0, { tick: atTick, bpm, interpolation, secondsAtTick });
277
292
  i = i + 1;
278
293
  }
279
294
  this._recomputeCache(i);
@@ -294,6 +309,28 @@ var TempoMap = class {
294
309
  }
295
310
  const entry = this._entries[lo];
296
311
  const secondsIntoSegment = seconds - entry.secondsAtTick;
312
+ const nextEntry = lo < this._entries.length - 1 ? this._entries[lo + 1] : null;
313
+ if (nextEntry && nextEntry.interpolation === "linear") {
314
+ return Math.round(
315
+ entry.tick + this._secondsToTicksLinear(
316
+ secondsIntoSegment,
317
+ entry.bpm,
318
+ nextEntry.bpm,
319
+ nextEntry.tick - entry.tick
320
+ )
321
+ );
322
+ }
323
+ if (nextEntry && typeof nextEntry.interpolation === "object") {
324
+ return Math.round(
325
+ entry.tick + this._secondsToTicksCurve(
326
+ secondsIntoSegment,
327
+ entry.bpm,
328
+ nextEntry.bpm,
329
+ nextEntry.tick - entry.tick,
330
+ nextEntry.interpolation.slope
331
+ )
332
+ );
333
+ }
297
334
  const ticksPerSecond = entry.bpm / 60 * this._ppqn;
298
335
  return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
299
336
  }
@@ -305,15 +342,122 @@ var TempoMap = class {
305
342
  }
306
343
  clearTempos() {
307
344
  const first = this._entries[0];
308
- this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
345
+ this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
346
+ }
347
+ /** Get the interpolated BPM at a tick position */
348
+ _getTempoAt(atTick) {
349
+ const entryIndex = this._entryIndexAt(atTick);
350
+ const entry = this._entries[entryIndex];
351
+ const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
352
+ if (nextEntry && nextEntry.interpolation !== "step") {
353
+ const segmentTicks = nextEntry.tick - entry.tick;
354
+ const ticksInto = atTick - entry.tick;
355
+ if (segmentTicks > 0) {
356
+ const progress = ticksInto / segmentTicks;
357
+ if (nextEntry.interpolation === "linear") {
358
+ return entry.bpm + (nextEntry.bpm - entry.bpm) * progress;
359
+ }
360
+ const t = curveNormalizedAt(progress, nextEntry.interpolation.slope);
361
+ return entry.bpm + (nextEntry.bpm - entry.bpm) * t;
362
+ }
363
+ }
364
+ return entry.bpm;
309
365
  }
310
366
  _ticksToSecondsInternal(ticks) {
311
- const entry = this._entryAt(ticks);
367
+ const entryIndex = this._entryIndexAt(ticks);
368
+ const entry = this._entries[entryIndex];
312
369
  const ticksIntoSegment = ticks - entry.tick;
370
+ const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
371
+ if (nextEntry && nextEntry.interpolation === "linear") {
372
+ const segmentTicks = nextEntry.tick - entry.tick;
373
+ return entry.secondsAtTick + this._ticksToSecondsLinear(ticksIntoSegment, entry.bpm, nextEntry.bpm, segmentTicks);
374
+ }
375
+ if (nextEntry && typeof nextEntry.interpolation === "object") {
376
+ const segmentTicks = nextEntry.tick - entry.tick;
377
+ return entry.secondsAtTick + this._ticksToSecondsCurve(
378
+ ticksIntoSegment,
379
+ entry.bpm,
380
+ nextEntry.bpm,
381
+ segmentTicks,
382
+ nextEntry.interpolation.slope
383
+ );
384
+ }
313
385
  const secondsPerTick = 60 / (entry.bpm * this._ppqn);
314
386
  return entry.secondsAtTick + ticksIntoSegment * secondsPerTick;
315
387
  }
316
- _entryAt(tick) {
388
+ /**
389
+ * Exact integration for a linear BPM ramp using the logarithmic formula.
390
+ * For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
391
+ * seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
392
+ */
393
+ _ticksToSecondsLinear(ticks, bpm0, bpm1, totalSegmentTicks) {
394
+ if (totalSegmentTicks === 0) return 0;
395
+ const bpmAtTick = bpm0 + (bpm1 - bpm0) * (ticks / totalSegmentTicks);
396
+ if (Math.abs(bpm1 - bpm0) < 1e-10) {
397
+ return ticks * 60 / (bpm0 * this._ppqn);
398
+ }
399
+ const deltaBpm = bpm1 - bpm0;
400
+ return totalSegmentTicks * 60 / (this._ppqn * deltaBpm) * Math.log(bpmAtTick / bpm0);
401
+ }
402
+ /**
403
+ * Inverse of _ticksToSecondsLinear: given seconds, return ticks.
404
+ * Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
405
+ * then ticks = (bpmAtTick - bpm0) * T / deltaBpm
406
+ *
407
+ * Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
408
+ * Math.round() in the caller (secondsToTicks). This is sufficient for all tested
409
+ * BPM ranges (10–300 BPM) but is not algebraically exact like the previous
410
+ * trapezoidal/quadratic approach was.
411
+ */
412
+ _secondsToTicksLinear(seconds, bpm0, bpm1, totalSegmentTicks) {
413
+ if (totalSegmentTicks === 0 || seconds === 0) return 0;
414
+ if (Math.abs(bpm1 - bpm0) < 1e-10) {
415
+ return seconds * bpm0 * this._ppqn / 60;
416
+ }
417
+ const deltaBpm = bpm1 - bpm0;
418
+ const bpmAtTick = bpm0 * Math.exp(seconds * deltaBpm * this._ppqn / (60 * totalSegmentTicks));
419
+ return (bpmAtTick - bpm0) / deltaBpm * totalSegmentTicks;
420
+ }
421
+ /**
422
+ * Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
423
+ * The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
424
+ * We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
425
+ */
426
+ _ticksToSecondsCurve(ticks, bpm0, bpm1, totalSegmentTicks, slope) {
427
+ if (totalSegmentTicks === 0 || ticks === 0) return 0;
428
+ const n = CURVE_SUBDIVISIONS;
429
+ const dt = ticks / n;
430
+ let seconds = 0;
431
+ let prevBpm = bpm0;
432
+ for (let i = 1; i <= n; i++) {
433
+ const progress = dt * i / totalSegmentTicks;
434
+ const curBpm = bpm0 + curveNormalizedAt(progress, slope) * (bpm1 - bpm0);
435
+ seconds += dt * 60 / this._ppqn * (1 / prevBpm + 1 / curBpm) / 2;
436
+ prevBpm = curBpm;
437
+ }
438
+ return seconds;
439
+ }
440
+ /**
441
+ * Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
442
+ * return ticks. Uses binary search since there's no closed-form inverse.
443
+ */
444
+ _secondsToTicksCurve(seconds, bpm0, bpm1, totalSegmentTicks, slope) {
445
+ if (totalSegmentTicks === 0 || seconds === 0) return 0;
446
+ const iterations = Math.min(40, Math.max(1, Math.ceil(Math.log2(2 * totalSegmentTicks))));
447
+ let lo = 0;
448
+ let hi = totalSegmentTicks;
449
+ for (let i = 0; i < iterations; i++) {
450
+ const mid = (lo + hi) / 2;
451
+ const midSeconds = this._ticksToSecondsCurve(mid, bpm0, bpm1, totalSegmentTicks, slope);
452
+ if (midSeconds < seconds) {
453
+ lo = mid;
454
+ } else {
455
+ hi = mid;
456
+ }
457
+ }
458
+ return (lo + hi) / 2;
459
+ }
460
+ _entryIndexAt(tick) {
317
461
  let lo = 0;
318
462
  let hi = this._entries.length - 1;
319
463
  while (lo < hi) {
@@ -324,16 +468,31 @@ var TempoMap = class {
324
468
  hi = mid - 1;
325
469
  }
326
470
  }
327
- return this._entries[lo];
471
+ return lo;
328
472
  }
329
473
  _recomputeCache(fromIndex) {
330
474
  for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
331
475
  const prev = this._entries[i - 1];
332
476
  const tickDelta = this._entries[i].tick - prev.tick;
333
- const secondsPerTick = 60 / (prev.bpm * this._ppqn);
477
+ const entry = this._entries[i];
478
+ let segmentSeconds;
479
+ if (entry.interpolation === "linear") {
480
+ segmentSeconds = this._ticksToSecondsLinear(tickDelta, prev.bpm, entry.bpm, tickDelta);
481
+ } else if (typeof entry.interpolation === "object") {
482
+ segmentSeconds = this._ticksToSecondsCurve(
483
+ tickDelta,
484
+ prev.bpm,
485
+ entry.bpm,
486
+ tickDelta,
487
+ entry.interpolation.slope
488
+ );
489
+ } else {
490
+ const secondsPerTick = 60 / (prev.bpm * this._ppqn);
491
+ segmentSeconds = tickDelta * secondsPerTick;
492
+ }
334
493
  this._entries[i] = {
335
- ...this._entries[i],
336
- secondsAtTick: prev.secondsAtTick + tickDelta * secondsPerTick
494
+ ...entry,
495
+ secondsAtTick: prev.secondsAtTick + segmentSeconds
337
496
  };
338
497
  }
339
498
  }
@@ -900,6 +1059,7 @@ var Transport = class _Transport {
900
1059
  this._mutedTrackIds = /* @__PURE__ */ new Set();
901
1060
  this._playing = false;
902
1061
  this._loopEnabled = false;
1062
+ this._loopStartTick = 0;
903
1063
  this._loopStartSeconds = 0;
904
1064
  this._listeners = /* @__PURE__ */ new Map();
905
1065
  this._audioContext = audioContext;
@@ -1117,6 +1277,7 @@ var Transport = class _Transport {
1117
1277
  return;
1118
1278
  }
1119
1279
  this._loopEnabled = enabled;
1280
+ this._loopStartTick = startTick;
1120
1281
  this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1121
1282
  this._scheduler.setLoop(enabled, startTick, endTick);
1122
1283
  this._clipPlayer.setLoop(enabled, startTick, endTick);
@@ -1145,14 +1306,18 @@ var Transport = class _Transport {
1145
1306
  const startTick = this._sampleTimeline.samplesToTicks(startSample);
1146
1307
  const endTick = this._sampleTimeline.samplesToTicks(endSample);
1147
1308
  this._loopEnabled = enabled;
1309
+ this._loopStartTick = startTick;
1148
1310
  this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1149
1311
  this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
1150
1312
  this._scheduler.setLoop(enabled, startTick, endTick);
1151
1313
  this._emit("loop");
1152
1314
  }
1153
1315
  // --- Tempo ---
1154
- setTempo(bpm, atTick) {
1155
- this._tempoMap.setTempo(bpm, atTick);
1316
+ setTempo(bpm, atTick, options) {
1317
+ this._tempoMap.setTempo(bpm, atTick, options);
1318
+ if (this._loopEnabled) {
1319
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1320
+ }
1156
1321
  this._emit("tempochange");
1157
1322
  }
1158
1323
  getTempo(atTick) {
@@ -1176,6 +1341,9 @@ var Transport = class _Transport {
1176
1341
  }
1177
1342
  clearTempos() {
1178
1343
  this._tempoMap.clearTempos();
1344
+ if (this._loopEnabled) {
1345
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1346
+ }
1179
1347
  this._emit("tempochange");
1180
1348
  }
1181
1349
  barToTick(bar) {