@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/dist/index.mjs CHANGED
@@ -215,28 +215,43 @@ var SampleTimeline = class {
215
215
  };
216
216
 
217
217
  // src/timeline/tempo-map.ts
218
+ var CURVE_EPSILON = 1e-15;
219
+ var CURVE_SUBDIVISIONS = 64;
220
+ function curveNormalizedAt(x, slope) {
221
+ if (slope > 0.499999 && slope < 0.500001) return x;
222
+ const p = Math.max(CURVE_EPSILON, Math.min(1 - CURVE_EPSILON, slope));
223
+ return p * p / (1 - p * 2) * (Math.pow((1 - p) / p, 2 * x) - 1);
224
+ }
218
225
  var TempoMap = class {
219
226
  constructor(ppqn = 960, initialBpm = 120) {
220
227
  this._ppqn = ppqn;
221
- this._entries = [{ tick: 0, bpm: initialBpm, secondsAtTick: 0 }];
228
+ this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
222
229
  }
223
230
  getTempo(atTick = 0) {
224
- const entry = this._entryAt(atTick);
225
- return entry.bpm;
226
- }
227
- setTempo(bpm, atTick = 0) {
231
+ return this._getTempoAt(atTick);
232
+ }
233
+ setTempo(bpm, atTick = 0, options) {
234
+ const interpolation = options?.interpolation ?? "step";
235
+ if (typeof interpolation === "object" && interpolation.type === "curve") {
236
+ const s = interpolation.slope;
237
+ if (!Number.isFinite(s) || s <= 0 || s >= 1) {
238
+ throw new Error(
239
+ "[waveform-playlist] TempoMap: curve slope must be between 0 and 1 (exclusive), got " + s
240
+ );
241
+ }
242
+ }
228
243
  if (atTick === 0) {
229
- this._entries[0] = { ...this._entries[0], bpm };
244
+ this._entries[0] = { ...this._entries[0], bpm, interpolation: "step" };
230
245
  this._recomputeCache(0);
231
246
  return;
232
247
  }
233
248
  let i = this._entries.length - 1;
234
249
  while (i > 0 && this._entries[i].tick > atTick) i--;
235
250
  if (this._entries[i].tick === atTick) {
236
- this._entries[i] = { ...this._entries[i], bpm };
251
+ this._entries[i] = { ...this._entries[i], bpm, interpolation };
237
252
  } else {
238
253
  const secondsAtTick = this._ticksToSecondsInternal(atTick);
239
- this._entries.splice(i + 1, 0, { tick: atTick, bpm, secondsAtTick });
254
+ this._entries.splice(i + 1, 0, { tick: atTick, bpm, interpolation, secondsAtTick });
240
255
  i = i + 1;
241
256
  }
242
257
  this._recomputeCache(i);
@@ -257,6 +272,28 @@ var TempoMap = class {
257
272
  }
258
273
  const entry = this._entries[lo];
259
274
  const secondsIntoSegment = seconds - entry.secondsAtTick;
275
+ const nextEntry = lo < this._entries.length - 1 ? this._entries[lo + 1] : null;
276
+ if (nextEntry && nextEntry.interpolation === "linear") {
277
+ return Math.round(
278
+ entry.tick + this._secondsToTicksLinear(
279
+ secondsIntoSegment,
280
+ entry.bpm,
281
+ nextEntry.bpm,
282
+ nextEntry.tick - entry.tick
283
+ )
284
+ );
285
+ }
286
+ if (nextEntry && typeof nextEntry.interpolation === "object") {
287
+ return Math.round(
288
+ entry.tick + this._secondsToTicksCurve(
289
+ secondsIntoSegment,
290
+ entry.bpm,
291
+ nextEntry.bpm,
292
+ nextEntry.tick - entry.tick,
293
+ nextEntry.interpolation.slope
294
+ )
295
+ );
296
+ }
260
297
  const ticksPerSecond = entry.bpm / 60 * this._ppqn;
261
298
  return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
262
299
  }
@@ -268,15 +305,122 @@ var TempoMap = class {
268
305
  }
269
306
  clearTempos() {
270
307
  const first = this._entries[0];
271
- this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
308
+ this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
309
+ }
310
+ /** Get the interpolated BPM at a tick position */
311
+ _getTempoAt(atTick) {
312
+ const entryIndex = this._entryIndexAt(atTick);
313
+ const entry = this._entries[entryIndex];
314
+ const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
315
+ if (nextEntry && nextEntry.interpolation !== "step") {
316
+ const segmentTicks = nextEntry.tick - entry.tick;
317
+ const ticksInto = atTick - entry.tick;
318
+ if (segmentTicks > 0) {
319
+ const progress = ticksInto / segmentTicks;
320
+ if (nextEntry.interpolation === "linear") {
321
+ return entry.bpm + (nextEntry.bpm - entry.bpm) * progress;
322
+ }
323
+ const t = curveNormalizedAt(progress, nextEntry.interpolation.slope);
324
+ return entry.bpm + (nextEntry.bpm - entry.bpm) * t;
325
+ }
326
+ }
327
+ return entry.bpm;
272
328
  }
273
329
  _ticksToSecondsInternal(ticks) {
274
- const entry = this._entryAt(ticks);
330
+ const entryIndex = this._entryIndexAt(ticks);
331
+ const entry = this._entries[entryIndex];
275
332
  const ticksIntoSegment = ticks - entry.tick;
333
+ const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
334
+ if (nextEntry && nextEntry.interpolation === "linear") {
335
+ const segmentTicks = nextEntry.tick - entry.tick;
336
+ return entry.secondsAtTick + this._ticksToSecondsLinear(ticksIntoSegment, entry.bpm, nextEntry.bpm, segmentTicks);
337
+ }
338
+ if (nextEntry && typeof nextEntry.interpolation === "object") {
339
+ const segmentTicks = nextEntry.tick - entry.tick;
340
+ return entry.secondsAtTick + this._ticksToSecondsCurve(
341
+ ticksIntoSegment,
342
+ entry.bpm,
343
+ nextEntry.bpm,
344
+ segmentTicks,
345
+ nextEntry.interpolation.slope
346
+ );
347
+ }
276
348
  const secondsPerTick = 60 / (entry.bpm * this._ppqn);
277
349
  return entry.secondsAtTick + ticksIntoSegment * secondsPerTick;
278
350
  }
279
- _entryAt(tick) {
351
+ /**
352
+ * Exact integration for a linear BPM ramp using the logarithmic formula.
353
+ * For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
354
+ * seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
355
+ */
356
+ _ticksToSecondsLinear(ticks, bpm0, bpm1, totalSegmentTicks) {
357
+ if (totalSegmentTicks === 0) return 0;
358
+ const bpmAtTick = bpm0 + (bpm1 - bpm0) * (ticks / totalSegmentTicks);
359
+ if (Math.abs(bpm1 - bpm0) < 1e-10) {
360
+ return ticks * 60 / (bpm0 * this._ppqn);
361
+ }
362
+ const deltaBpm = bpm1 - bpm0;
363
+ return totalSegmentTicks * 60 / (this._ppqn * deltaBpm) * Math.log(bpmAtTick / bpm0);
364
+ }
365
+ /**
366
+ * Inverse of _ticksToSecondsLinear: given seconds, return ticks.
367
+ * Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
368
+ * then ticks = (bpmAtTick - bpm0) * T / deltaBpm
369
+ *
370
+ * Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
371
+ * Math.round() in the caller (secondsToTicks). This is sufficient for all tested
372
+ * BPM ranges (10–300 BPM) but is not algebraically exact like the previous
373
+ * trapezoidal/quadratic approach was.
374
+ */
375
+ _secondsToTicksLinear(seconds, bpm0, bpm1, totalSegmentTicks) {
376
+ if (totalSegmentTicks === 0 || seconds === 0) return 0;
377
+ if (Math.abs(bpm1 - bpm0) < 1e-10) {
378
+ return seconds * bpm0 * this._ppqn / 60;
379
+ }
380
+ const deltaBpm = bpm1 - bpm0;
381
+ const bpmAtTick = bpm0 * Math.exp(seconds * deltaBpm * this._ppqn / (60 * totalSegmentTicks));
382
+ return (bpmAtTick - bpm0) / deltaBpm * totalSegmentTicks;
383
+ }
384
+ /**
385
+ * Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
386
+ * The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
387
+ * We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
388
+ */
389
+ _ticksToSecondsCurve(ticks, bpm0, bpm1, totalSegmentTicks, slope) {
390
+ if (totalSegmentTicks === 0 || ticks === 0) return 0;
391
+ const n = CURVE_SUBDIVISIONS;
392
+ const dt = ticks / n;
393
+ let seconds = 0;
394
+ let prevBpm = bpm0;
395
+ for (let i = 1; i <= n; i++) {
396
+ const progress = dt * i / totalSegmentTicks;
397
+ const curBpm = bpm0 + curveNormalizedAt(progress, slope) * (bpm1 - bpm0);
398
+ seconds += dt * 60 / this._ppqn * (1 / prevBpm + 1 / curBpm) / 2;
399
+ prevBpm = curBpm;
400
+ }
401
+ return seconds;
402
+ }
403
+ /**
404
+ * Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
405
+ * return ticks. Uses binary search since there's no closed-form inverse.
406
+ */
407
+ _secondsToTicksCurve(seconds, bpm0, bpm1, totalSegmentTicks, slope) {
408
+ if (totalSegmentTicks === 0 || seconds === 0) return 0;
409
+ const iterations = Math.min(40, Math.max(1, Math.ceil(Math.log2(2 * totalSegmentTicks))));
410
+ let lo = 0;
411
+ let hi = totalSegmentTicks;
412
+ for (let i = 0; i < iterations; i++) {
413
+ const mid = (lo + hi) / 2;
414
+ const midSeconds = this._ticksToSecondsCurve(mid, bpm0, bpm1, totalSegmentTicks, slope);
415
+ if (midSeconds < seconds) {
416
+ lo = mid;
417
+ } else {
418
+ hi = mid;
419
+ }
420
+ }
421
+ return (lo + hi) / 2;
422
+ }
423
+ _entryIndexAt(tick) {
280
424
  let lo = 0;
281
425
  let hi = this._entries.length - 1;
282
426
  while (lo < hi) {
@@ -287,16 +431,31 @@ var TempoMap = class {
287
431
  hi = mid - 1;
288
432
  }
289
433
  }
290
- return this._entries[lo];
434
+ return lo;
291
435
  }
292
436
  _recomputeCache(fromIndex) {
293
437
  for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
294
438
  const prev = this._entries[i - 1];
295
439
  const tickDelta = this._entries[i].tick - prev.tick;
296
- const secondsPerTick = 60 / (prev.bpm * this._ppqn);
440
+ const entry = this._entries[i];
441
+ let segmentSeconds;
442
+ if (entry.interpolation === "linear") {
443
+ segmentSeconds = this._ticksToSecondsLinear(tickDelta, prev.bpm, entry.bpm, tickDelta);
444
+ } else if (typeof entry.interpolation === "object") {
445
+ segmentSeconds = this._ticksToSecondsCurve(
446
+ tickDelta,
447
+ prev.bpm,
448
+ entry.bpm,
449
+ tickDelta,
450
+ entry.interpolation.slope
451
+ );
452
+ } else {
453
+ const secondsPerTick = 60 / (prev.bpm * this._ppqn);
454
+ segmentSeconds = tickDelta * secondsPerTick;
455
+ }
297
456
  this._entries[i] = {
298
- ...this._entries[i],
299
- secondsAtTick: prev.secondsAtTick + tickDelta * secondsPerTick
457
+ ...entry,
458
+ secondsAtTick: prev.secondsAtTick + segmentSeconds
300
459
  };
301
460
  }
302
461
  }
@@ -863,6 +1022,7 @@ var Transport = class _Transport {
863
1022
  this._mutedTrackIds = /* @__PURE__ */ new Set();
864
1023
  this._playing = false;
865
1024
  this._loopEnabled = false;
1025
+ this._loopStartTick = 0;
866
1026
  this._loopStartSeconds = 0;
867
1027
  this._listeners = /* @__PURE__ */ new Map();
868
1028
  this._audioContext = audioContext;
@@ -1080,6 +1240,7 @@ var Transport = class _Transport {
1080
1240
  return;
1081
1241
  }
1082
1242
  this._loopEnabled = enabled;
1243
+ this._loopStartTick = startTick;
1083
1244
  this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1084
1245
  this._scheduler.setLoop(enabled, startTick, endTick);
1085
1246
  this._clipPlayer.setLoop(enabled, startTick, endTick);
@@ -1108,14 +1269,18 @@ var Transport = class _Transport {
1108
1269
  const startTick = this._sampleTimeline.samplesToTicks(startSample);
1109
1270
  const endTick = this._sampleTimeline.samplesToTicks(endSample);
1110
1271
  this._loopEnabled = enabled;
1272
+ this._loopStartTick = startTick;
1111
1273
  this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
1112
1274
  this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
1113
1275
  this._scheduler.setLoop(enabled, startTick, endTick);
1114
1276
  this._emit("loop");
1115
1277
  }
1116
1278
  // --- Tempo ---
1117
- setTempo(bpm, atTick) {
1118
- this._tempoMap.setTempo(bpm, atTick);
1279
+ setTempo(bpm, atTick, options) {
1280
+ this._tempoMap.setTempo(bpm, atTick, options);
1281
+ if (this._loopEnabled) {
1282
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1283
+ }
1119
1284
  this._emit("tempochange");
1120
1285
  }
1121
1286
  getTempo(atTick) {
@@ -1139,6 +1304,9 @@ var Transport = class _Transport {
1139
1304
  }
1140
1305
  clearTempos() {
1141
1306
  this._tempoMap.clearTempos();
1307
+ if (this._loopEnabled) {
1308
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1309
+ }
1142
1310
  this._emit("tempochange");
1143
1311
  }
1144
1312
  barToTick(bar) {