@dawcore/transport 0.0.3 → 0.0.5
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 +76 -5
- package/dist/index.d.mts +126 -6
- package/dist/index.d.ts +126 -6
- package/dist/index.js +528 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +528 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -210,7 +210,9 @@ var Timer = class {
|
|
|
210
210
|
} catch (err) {
|
|
211
211
|
console.warn("[waveform-playlist] Timer tick error:", String(err));
|
|
212
212
|
}
|
|
213
|
-
this.
|
|
213
|
+
if (this._running) {
|
|
214
|
+
this._scheduleFrame();
|
|
215
|
+
}
|
|
214
216
|
});
|
|
215
217
|
}
|
|
216
218
|
};
|
|
@@ -252,28 +254,43 @@ var SampleTimeline = class {
|
|
|
252
254
|
};
|
|
253
255
|
|
|
254
256
|
// src/timeline/tempo-map.ts
|
|
257
|
+
var CURVE_EPSILON = 1e-15;
|
|
258
|
+
var CURVE_SUBDIVISIONS = 64;
|
|
259
|
+
function curveNormalizedAt(x, slope) {
|
|
260
|
+
if (slope > 0.499999 && slope < 0.500001) return x;
|
|
261
|
+
const p = Math.max(CURVE_EPSILON, Math.min(1 - CURVE_EPSILON, slope));
|
|
262
|
+
return p * p / (1 - p * 2) * (Math.pow((1 - p) / p, 2 * x) - 1);
|
|
263
|
+
}
|
|
255
264
|
var TempoMap = class {
|
|
256
265
|
constructor(ppqn = 960, initialBpm = 120) {
|
|
257
266
|
this._ppqn = ppqn;
|
|
258
|
-
this._entries = [{ tick: 0, bpm: initialBpm, secondsAtTick: 0 }];
|
|
267
|
+
this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
|
|
259
268
|
}
|
|
260
269
|
getTempo(atTick = 0) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
270
|
+
return this._getTempoAt(atTick);
|
|
271
|
+
}
|
|
272
|
+
setTempo(bpm, atTick = 0, options) {
|
|
273
|
+
const interpolation = options?.interpolation ?? "step";
|
|
274
|
+
if (typeof interpolation === "object" && interpolation.type === "curve") {
|
|
275
|
+
const s = interpolation.slope;
|
|
276
|
+
if (!Number.isFinite(s) || s <= 0 || s >= 1) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
"[waveform-playlist] TempoMap: curve slope must be between 0 and 1 (exclusive), got " + s
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
265
282
|
if (atTick === 0) {
|
|
266
|
-
this._entries[0] = { ...this._entries[0], bpm };
|
|
283
|
+
this._entries[0] = { ...this._entries[0], bpm, interpolation: "step" };
|
|
267
284
|
this._recomputeCache(0);
|
|
268
285
|
return;
|
|
269
286
|
}
|
|
270
287
|
let i = this._entries.length - 1;
|
|
271
288
|
while (i > 0 && this._entries[i].tick > atTick) i--;
|
|
272
289
|
if (this._entries[i].tick === atTick) {
|
|
273
|
-
this._entries[i] = { ...this._entries[i], bpm };
|
|
290
|
+
this._entries[i] = { ...this._entries[i], bpm, interpolation };
|
|
274
291
|
} else {
|
|
275
292
|
const secondsAtTick = this._ticksToSecondsInternal(atTick);
|
|
276
|
-
this._entries.splice(i + 1, 0, { tick: atTick, bpm, secondsAtTick });
|
|
293
|
+
this._entries.splice(i + 1, 0, { tick: atTick, bpm, interpolation, secondsAtTick });
|
|
277
294
|
i = i + 1;
|
|
278
295
|
}
|
|
279
296
|
this._recomputeCache(i);
|
|
@@ -294,6 +311,28 @@ var TempoMap = class {
|
|
|
294
311
|
}
|
|
295
312
|
const entry = this._entries[lo];
|
|
296
313
|
const secondsIntoSegment = seconds - entry.secondsAtTick;
|
|
314
|
+
const nextEntry = lo < this._entries.length - 1 ? this._entries[lo + 1] : null;
|
|
315
|
+
if (nextEntry && nextEntry.interpolation === "linear") {
|
|
316
|
+
return Math.round(
|
|
317
|
+
entry.tick + this._secondsToTicksLinear(
|
|
318
|
+
secondsIntoSegment,
|
|
319
|
+
entry.bpm,
|
|
320
|
+
nextEntry.bpm,
|
|
321
|
+
nextEntry.tick - entry.tick
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (nextEntry && typeof nextEntry.interpolation === "object") {
|
|
326
|
+
return Math.round(
|
|
327
|
+
entry.tick + this._secondsToTicksCurve(
|
|
328
|
+
secondsIntoSegment,
|
|
329
|
+
entry.bpm,
|
|
330
|
+
nextEntry.bpm,
|
|
331
|
+
nextEntry.tick - entry.tick,
|
|
332
|
+
nextEntry.interpolation.slope
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
297
336
|
const ticksPerSecond = entry.bpm / 60 * this._ppqn;
|
|
298
337
|
return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
|
|
299
338
|
}
|
|
@@ -305,15 +344,122 @@ var TempoMap = class {
|
|
|
305
344
|
}
|
|
306
345
|
clearTempos() {
|
|
307
346
|
const first = this._entries[0];
|
|
308
|
-
this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
|
|
347
|
+
this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
|
|
348
|
+
}
|
|
349
|
+
/** Get the interpolated BPM at a tick position */
|
|
350
|
+
_getTempoAt(atTick) {
|
|
351
|
+
const entryIndex = this._entryIndexAt(atTick);
|
|
352
|
+
const entry = this._entries[entryIndex];
|
|
353
|
+
const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
|
|
354
|
+
if (nextEntry && nextEntry.interpolation !== "step") {
|
|
355
|
+
const segmentTicks = nextEntry.tick - entry.tick;
|
|
356
|
+
const ticksInto = atTick - entry.tick;
|
|
357
|
+
if (segmentTicks > 0) {
|
|
358
|
+
const progress = ticksInto / segmentTicks;
|
|
359
|
+
if (nextEntry.interpolation === "linear") {
|
|
360
|
+
return entry.bpm + (nextEntry.bpm - entry.bpm) * progress;
|
|
361
|
+
}
|
|
362
|
+
const t = curveNormalizedAt(progress, nextEntry.interpolation.slope);
|
|
363
|
+
return entry.bpm + (nextEntry.bpm - entry.bpm) * t;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return entry.bpm;
|
|
309
367
|
}
|
|
310
368
|
_ticksToSecondsInternal(ticks) {
|
|
311
|
-
const
|
|
369
|
+
const entryIndex = this._entryIndexAt(ticks);
|
|
370
|
+
const entry = this._entries[entryIndex];
|
|
312
371
|
const ticksIntoSegment = ticks - entry.tick;
|
|
372
|
+
const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
|
|
373
|
+
if (nextEntry && nextEntry.interpolation === "linear") {
|
|
374
|
+
const segmentTicks = nextEntry.tick - entry.tick;
|
|
375
|
+
return entry.secondsAtTick + this._ticksToSecondsLinear(ticksIntoSegment, entry.bpm, nextEntry.bpm, segmentTicks);
|
|
376
|
+
}
|
|
377
|
+
if (nextEntry && typeof nextEntry.interpolation === "object") {
|
|
378
|
+
const segmentTicks = nextEntry.tick - entry.tick;
|
|
379
|
+
return entry.secondsAtTick + this._ticksToSecondsCurve(
|
|
380
|
+
ticksIntoSegment,
|
|
381
|
+
entry.bpm,
|
|
382
|
+
nextEntry.bpm,
|
|
383
|
+
segmentTicks,
|
|
384
|
+
nextEntry.interpolation.slope
|
|
385
|
+
);
|
|
386
|
+
}
|
|
313
387
|
const secondsPerTick = 60 / (entry.bpm * this._ppqn);
|
|
314
388
|
return entry.secondsAtTick + ticksIntoSegment * secondsPerTick;
|
|
315
389
|
}
|
|
316
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Exact integration for a linear BPM ramp using the logarithmic formula.
|
|
392
|
+
* For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
|
|
393
|
+
* seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
|
|
394
|
+
*/
|
|
395
|
+
_ticksToSecondsLinear(ticks, bpm0, bpm1, totalSegmentTicks) {
|
|
396
|
+
if (totalSegmentTicks === 0) return 0;
|
|
397
|
+
const bpmAtTick = bpm0 + (bpm1 - bpm0) * (ticks / totalSegmentTicks);
|
|
398
|
+
if (Math.abs(bpm1 - bpm0) < 1e-10) {
|
|
399
|
+
return ticks * 60 / (bpm0 * this._ppqn);
|
|
400
|
+
}
|
|
401
|
+
const deltaBpm = bpm1 - bpm0;
|
|
402
|
+
return totalSegmentTicks * 60 / (this._ppqn * deltaBpm) * Math.log(bpmAtTick / bpm0);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Inverse of _ticksToSecondsLinear: given seconds, return ticks.
|
|
406
|
+
* Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
|
|
407
|
+
* then ticks = (bpmAtTick - bpm0) * T / deltaBpm
|
|
408
|
+
*
|
|
409
|
+
* Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
|
|
410
|
+
* Math.round() in the caller (secondsToTicks). This is sufficient for all tested
|
|
411
|
+
* BPM ranges (10–300 BPM) but is not algebraically exact like the previous
|
|
412
|
+
* trapezoidal/quadratic approach was.
|
|
413
|
+
*/
|
|
414
|
+
_secondsToTicksLinear(seconds, bpm0, bpm1, totalSegmentTicks) {
|
|
415
|
+
if (totalSegmentTicks === 0 || seconds === 0) return 0;
|
|
416
|
+
if (Math.abs(bpm1 - bpm0) < 1e-10) {
|
|
417
|
+
return seconds * bpm0 * this._ppqn / 60;
|
|
418
|
+
}
|
|
419
|
+
const deltaBpm = bpm1 - bpm0;
|
|
420
|
+
const bpmAtTick = bpm0 * Math.exp(seconds * deltaBpm * this._ppqn / (60 * totalSegmentTicks));
|
|
421
|
+
return (bpmAtTick - bpm0) / deltaBpm * totalSegmentTicks;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
|
|
425
|
+
* The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
|
|
426
|
+
* We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
|
|
427
|
+
*/
|
|
428
|
+
_ticksToSecondsCurve(ticks, bpm0, bpm1, totalSegmentTicks, slope) {
|
|
429
|
+
if (totalSegmentTicks === 0 || ticks === 0) return 0;
|
|
430
|
+
const n = CURVE_SUBDIVISIONS;
|
|
431
|
+
const dt = ticks / n;
|
|
432
|
+
let seconds = 0;
|
|
433
|
+
let prevBpm = bpm0;
|
|
434
|
+
for (let i = 1; i <= n; i++) {
|
|
435
|
+
const progress = dt * i / totalSegmentTicks;
|
|
436
|
+
const curBpm = bpm0 + curveNormalizedAt(progress, slope) * (bpm1 - bpm0);
|
|
437
|
+
seconds += dt * 60 / this._ppqn * (1 / prevBpm + 1 / curBpm) / 2;
|
|
438
|
+
prevBpm = curBpm;
|
|
439
|
+
}
|
|
440
|
+
return seconds;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
|
|
444
|
+
* return ticks. Uses binary search since there's no closed-form inverse.
|
|
445
|
+
*/
|
|
446
|
+
_secondsToTicksCurve(seconds, bpm0, bpm1, totalSegmentTicks, slope) {
|
|
447
|
+
if (totalSegmentTicks === 0 || seconds === 0) return 0;
|
|
448
|
+
const iterations = Math.min(40, Math.max(1, Math.ceil(Math.log2(2 * totalSegmentTicks))));
|
|
449
|
+
let lo = 0;
|
|
450
|
+
let hi = totalSegmentTicks;
|
|
451
|
+
for (let i = 0; i < iterations; i++) {
|
|
452
|
+
const mid = (lo + hi) / 2;
|
|
453
|
+
const midSeconds = this._ticksToSecondsCurve(mid, bpm0, bpm1, totalSegmentTicks, slope);
|
|
454
|
+
if (midSeconds < seconds) {
|
|
455
|
+
lo = mid;
|
|
456
|
+
} else {
|
|
457
|
+
hi = mid;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return (lo + hi) / 2;
|
|
461
|
+
}
|
|
462
|
+
_entryIndexAt(tick) {
|
|
317
463
|
let lo = 0;
|
|
318
464
|
let hi = this._entries.length - 1;
|
|
319
465
|
while (lo < hi) {
|
|
@@ -324,16 +470,31 @@ var TempoMap = class {
|
|
|
324
470
|
hi = mid - 1;
|
|
325
471
|
}
|
|
326
472
|
}
|
|
327
|
-
return
|
|
473
|
+
return lo;
|
|
328
474
|
}
|
|
329
475
|
_recomputeCache(fromIndex) {
|
|
330
476
|
for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
|
|
331
477
|
const prev = this._entries[i - 1];
|
|
332
478
|
const tickDelta = this._entries[i].tick - prev.tick;
|
|
333
|
-
const
|
|
479
|
+
const entry = this._entries[i];
|
|
480
|
+
let segmentSeconds;
|
|
481
|
+
if (entry.interpolation === "linear") {
|
|
482
|
+
segmentSeconds = this._ticksToSecondsLinear(tickDelta, prev.bpm, entry.bpm, tickDelta);
|
|
483
|
+
} else if (typeof entry.interpolation === "object") {
|
|
484
|
+
segmentSeconds = this._ticksToSecondsCurve(
|
|
485
|
+
tickDelta,
|
|
486
|
+
prev.bpm,
|
|
487
|
+
entry.bpm,
|
|
488
|
+
tickDelta,
|
|
489
|
+
entry.interpolation.slope
|
|
490
|
+
);
|
|
491
|
+
} else {
|
|
492
|
+
const secondsPerTick = 60 / (prev.bpm * this._ppqn);
|
|
493
|
+
segmentSeconds = tickDelta * secondsPerTick;
|
|
494
|
+
}
|
|
334
495
|
this._entries[i] = {
|
|
335
|
-
...
|
|
336
|
-
secondsAtTick: prev.secondsAtTick +
|
|
496
|
+
...entry,
|
|
497
|
+
secondsAtTick: prev.secondsAtTick + segmentSeconds
|
|
337
498
|
};
|
|
338
499
|
}
|
|
339
500
|
}
|
|
@@ -891,8 +1052,145 @@ var MetronomePlayer = class {
|
|
|
891
1052
|
}
|
|
892
1053
|
};
|
|
893
1054
|
|
|
1055
|
+
// src/audio/count-in-player.ts
|
|
1056
|
+
var CountInPlayer = class {
|
|
1057
|
+
constructor(audioContext, tempoMap, destination, toAudioTime) {
|
|
1058
|
+
this._activeSources = /* @__PURE__ */ new Set();
|
|
1059
|
+
this._totalBeats = 0;
|
|
1060
|
+
this._beatsGenerated = 0;
|
|
1061
|
+
this._accentBuffer = null;
|
|
1062
|
+
this._normalBuffer = null;
|
|
1063
|
+
this._meterMap = null;
|
|
1064
|
+
this._onBeat = null;
|
|
1065
|
+
this._audioContext = audioContext;
|
|
1066
|
+
this._tempoMap = tempoMap;
|
|
1067
|
+
this._destination = destination;
|
|
1068
|
+
this._toAudioTime = toAudioTime;
|
|
1069
|
+
}
|
|
1070
|
+
configure(config) {
|
|
1071
|
+
this._totalBeats = config.totalBeats;
|
|
1072
|
+
this._beatsGenerated = 0;
|
|
1073
|
+
this._accentBuffer = config.accentBuffer;
|
|
1074
|
+
this._normalBuffer = config.normalBuffer;
|
|
1075
|
+
this._meterMap = config.meterMap;
|
|
1076
|
+
this._tempoMap = config.tempoMap;
|
|
1077
|
+
this._onBeat = config.onBeat;
|
|
1078
|
+
}
|
|
1079
|
+
generate(fromTick, toTick) {
|
|
1080
|
+
if (!this._accentBuffer || !this._normalBuffer || !this._meterMap) {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
const events = [];
|
|
1084
|
+
const meterMap = this._meterMap;
|
|
1085
|
+
let entry = meterMap.getEntryAt(fromTick);
|
|
1086
|
+
let beatSize = meterMap.ticksPerBeat(fromTick);
|
|
1087
|
+
const tickIntoSection = fromTick - entry.tick;
|
|
1088
|
+
let tick = entry.tick + Math.ceil(tickIntoSection / beatSize) * beatSize;
|
|
1089
|
+
while (tick < toTick && this._beatsGenerated < this._totalBeats) {
|
|
1090
|
+
const tickPos = tick;
|
|
1091
|
+
const currentEntry = meterMap.getEntryAt(tickPos);
|
|
1092
|
+
if (currentEntry.tick !== entry.tick) {
|
|
1093
|
+
entry = currentEntry;
|
|
1094
|
+
beatSize = meterMap.ticksPerBeat(tickPos);
|
|
1095
|
+
}
|
|
1096
|
+
this._beatsGenerated++;
|
|
1097
|
+
const isAccent = meterMap.isBarBoundary(tickPos);
|
|
1098
|
+
events.push({
|
|
1099
|
+
tick: tickPos,
|
|
1100
|
+
isAccent,
|
|
1101
|
+
buffer: isAccent ? this._accentBuffer : this._normalBuffer,
|
|
1102
|
+
beat: this._beatsGenerated,
|
|
1103
|
+
totalBeats: this._totalBeats
|
|
1104
|
+
});
|
|
1105
|
+
beatSize = meterMap.ticksPerBeat(tickPos);
|
|
1106
|
+
tick += beatSize;
|
|
1107
|
+
}
|
|
1108
|
+
return events;
|
|
1109
|
+
}
|
|
1110
|
+
consume(event) {
|
|
1111
|
+
try {
|
|
1112
|
+
const source = this._audioContext.createBufferSource();
|
|
1113
|
+
source.buffer = event.buffer;
|
|
1114
|
+
source.connect(this._destination);
|
|
1115
|
+
this._activeSources.add(source);
|
|
1116
|
+
source.addEventListener("ended", () => {
|
|
1117
|
+
this._activeSources.delete(source);
|
|
1118
|
+
try {
|
|
1119
|
+
source.disconnect();
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
console.warn(
|
|
1122
|
+
"[waveform-playlist] CountInPlayer: error disconnecting source:",
|
|
1123
|
+
String(err)
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
const transportTime = this._tempoMap.ticksToSeconds(event.tick);
|
|
1128
|
+
source.start(this._toAudioTime(transportTime));
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
console.warn(
|
|
1131
|
+
"[waveform-playlist] CountInPlayer.consume: failed to schedule beat " + event.beat + "/" + event.totalBeats + ": " + String(err)
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
this._onBeat?.(event.beat, event.totalBeats);
|
|
1135
|
+
}
|
|
1136
|
+
onPositionJump(_newTick) {
|
|
1137
|
+
}
|
|
1138
|
+
silence() {
|
|
1139
|
+
for (const source of this._activeSources) {
|
|
1140
|
+
try {
|
|
1141
|
+
source.stop();
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
console.warn(
|
|
1144
|
+
"[waveform-playlist] CountInPlayer.silence: error stopping source:",
|
|
1145
|
+
String(err)
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
source.disconnect();
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
console.warn(
|
|
1152
|
+
"[waveform-playlist] CountInPlayer.silence: error disconnecting:",
|
|
1153
|
+
String(err)
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
this._activeSources.clear();
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
// src/audio/click-sounds.ts
|
|
1162
|
+
var DEFAULT_ACCENT_FREQUENCY = 1e3;
|
|
1163
|
+
var DEFAULT_NORMAL_FREQUENCY = 800;
|
|
1164
|
+
var ACCENT_DURATION = 0.04;
|
|
1165
|
+
var NORMAL_DURATION = 0.03;
|
|
1166
|
+
function synthesizeClick(audioContext, frequency, duration) {
|
|
1167
|
+
const sampleRate = audioContext.sampleRate;
|
|
1168
|
+
const length = Math.ceil(sampleRate * duration);
|
|
1169
|
+
const buffer = audioContext.createBuffer(1, length, sampleRate);
|
|
1170
|
+
const data = buffer.getChannelData(0);
|
|
1171
|
+
for (let i = 0; i < length; i++) {
|
|
1172
|
+
const t = i / sampleRate;
|
|
1173
|
+
const envelope = Math.exp(-t * 50);
|
|
1174
|
+
data[i] = Math.sin(2 * Math.PI * frequency * t) * envelope;
|
|
1175
|
+
}
|
|
1176
|
+
return buffer;
|
|
1177
|
+
}
|
|
1178
|
+
function createDefaultClickSounds(audioContext, options) {
|
|
1179
|
+
const accentFreq = options?.accentFrequency ?? DEFAULT_ACCENT_FREQUENCY;
|
|
1180
|
+
const normalFreq = options?.normalFrequency ?? DEFAULT_NORMAL_FREQUENCY;
|
|
1181
|
+
if (accentFreq <= 0 || normalFreq <= 0) {
|
|
1182
|
+
console.warn(
|
|
1183
|
+
"[waveform-playlist] createDefaultClickSounds: frequency must be positive, got accent=" + accentFreq + " normal=" + normalFreq
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
accent: synthesizeClick(audioContext, accentFreq, ACCENT_DURATION),
|
|
1188
|
+
normal: synthesizeClick(audioContext, normalFreq, NORMAL_DURATION)
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
894
1192
|
// src/transport.ts
|
|
895
|
-
var
|
|
1193
|
+
var _Transport = class _Transport {
|
|
896
1194
|
constructor(audioContext, options = {}) {
|
|
897
1195
|
this._trackNodes = /* @__PURE__ */ new Map();
|
|
898
1196
|
this._tracks = [];
|
|
@@ -900,8 +1198,24 @@ var Transport = class _Transport {
|
|
|
900
1198
|
this._mutedTrackIds = /* @__PURE__ */ new Set();
|
|
901
1199
|
this._playing = false;
|
|
902
1200
|
this._loopEnabled = false;
|
|
1201
|
+
this._loopStartTick = 0;
|
|
903
1202
|
this._loopStartSeconds = 0;
|
|
1203
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
904
1204
|
this._listeners = /* @__PURE__ */ new Map();
|
|
1205
|
+
// --- Count-In state ---
|
|
1206
|
+
this._countInEnabled = false;
|
|
1207
|
+
this._countInBars = 1;
|
|
1208
|
+
this._countInMode = "recording-only";
|
|
1209
|
+
this._recording = false;
|
|
1210
|
+
this._countingIn = false;
|
|
1211
|
+
this._countInStartPosition = 0;
|
|
1212
|
+
this._countInDuration = 0;
|
|
1213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1214
|
+
this._countInScheduler = null;
|
|
1215
|
+
this._accentBuffer = null;
|
|
1216
|
+
this._normalBuffer = null;
|
|
1217
|
+
this._schedulerLookahead = 0.2;
|
|
1218
|
+
this._ppqn = 960;
|
|
905
1219
|
this._audioContext = audioContext;
|
|
906
1220
|
const sampleRate = options.sampleRate ?? audioContext.sampleRate;
|
|
907
1221
|
const ppqn = options.ppqn ?? 960;
|
|
@@ -909,6 +1223,8 @@ var Transport = class _Transport {
|
|
|
909
1223
|
const numerator = options.numerator ?? 4;
|
|
910
1224
|
const denominator = options.denominator ?? 4;
|
|
911
1225
|
const lookahead = options.schedulerLookahead ?? 0.2;
|
|
1226
|
+
this._ppqn = ppqn;
|
|
1227
|
+
this._schedulerLookahead = lookahead;
|
|
912
1228
|
_Transport._validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead);
|
|
913
1229
|
this._clock = new Clock(audioContext);
|
|
914
1230
|
this._sampleTimeline = new SampleTimeline(sampleRate);
|
|
@@ -923,8 +1239,16 @@ var Transport = class _Transport {
|
|
|
923
1239
|
});
|
|
924
1240
|
this._sampleTimeline.setTempoMap(this._tempoMap);
|
|
925
1241
|
this._initAudioGraph(audioContext);
|
|
1242
|
+
this._initCountIn(audioContext, options);
|
|
926
1243
|
this._timer = new Timer(() => {
|
|
927
1244
|
const time = this._clock.getTime();
|
|
1245
|
+
if (this._countingIn) {
|
|
1246
|
+
this._countInScheduler?.advance(time);
|
|
1247
|
+
if (time >= this._countInDuration) {
|
|
1248
|
+
this._finishCountIn();
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
928
1252
|
if (this._endTime !== void 0 && time >= this._endTime) {
|
|
929
1253
|
this.stop();
|
|
930
1254
|
return;
|
|
@@ -941,6 +1265,10 @@ var Transport = class _Transport {
|
|
|
941
1265
|
if (startTime !== void 0) {
|
|
942
1266
|
this._clock.seekTo(startTime);
|
|
943
1267
|
}
|
|
1268
|
+
if (this._shouldCountIn()) {
|
|
1269
|
+
this._startCountIn(endTime);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
944
1272
|
const currentTime = this._clock.getTime();
|
|
945
1273
|
this._scheduler.reset(currentTime);
|
|
946
1274
|
this._endTime = endTime;
|
|
@@ -953,6 +1281,13 @@ var Transport = class _Transport {
|
|
|
953
1281
|
}
|
|
954
1282
|
pause() {
|
|
955
1283
|
if (!this._playing) return;
|
|
1284
|
+
if (this._countingIn) {
|
|
1285
|
+
this._cancelCountIn();
|
|
1286
|
+
this._clock.stop();
|
|
1287
|
+
this._playing = false;
|
|
1288
|
+
this._emit("pause");
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
956
1291
|
this._timer.stop();
|
|
957
1292
|
this._clock.stop();
|
|
958
1293
|
this._silenceAll();
|
|
@@ -961,6 +1296,9 @@ var Transport = class _Transport {
|
|
|
961
1296
|
}
|
|
962
1297
|
stop() {
|
|
963
1298
|
const wasPlaying = this._playing;
|
|
1299
|
+
if (this._countingIn) {
|
|
1300
|
+
this._cancelCountIn();
|
|
1301
|
+
}
|
|
964
1302
|
this._timer.stop();
|
|
965
1303
|
this._clock.reset();
|
|
966
1304
|
this._scheduler.reset(0);
|
|
@@ -973,14 +1311,19 @@ var Transport = class _Transport {
|
|
|
973
1311
|
}
|
|
974
1312
|
seek(time) {
|
|
975
1313
|
const wasPlaying = this._playing;
|
|
976
|
-
|
|
1314
|
+
const wasCountingIn = this._countingIn;
|
|
1315
|
+
if (wasCountingIn) {
|
|
1316
|
+
this._cancelCountIn();
|
|
1317
|
+
this._playing = false;
|
|
1318
|
+
}
|
|
1319
|
+
if (wasPlaying && !wasCountingIn) {
|
|
977
1320
|
this._timer.stop();
|
|
978
1321
|
}
|
|
979
1322
|
this._silenceAll();
|
|
980
1323
|
this._clock.seekTo(time);
|
|
981
1324
|
this._scheduler.reset(time);
|
|
982
1325
|
this._endTime = void 0;
|
|
983
|
-
if (wasPlaying) {
|
|
1326
|
+
if (wasPlaying && !wasCountingIn) {
|
|
984
1327
|
this._clock.start();
|
|
985
1328
|
const seekTick = this._tempoMap.secondsToTicks(time);
|
|
986
1329
|
this._clipPlayer.onPositionJump(seekTick);
|
|
@@ -988,6 +1331,9 @@ var Transport = class _Transport {
|
|
|
988
1331
|
}
|
|
989
1332
|
}
|
|
990
1333
|
getCurrentTime() {
|
|
1334
|
+
if (this._countingIn) {
|
|
1335
|
+
return this._countInStartPosition;
|
|
1336
|
+
}
|
|
991
1337
|
const t = this._clock.getTime();
|
|
992
1338
|
if (this._loopEnabled && t < this._loopStartSeconds) {
|
|
993
1339
|
return this._loopStartSeconds;
|
|
@@ -1117,6 +1463,7 @@ var Transport = class _Transport {
|
|
|
1117
1463
|
return;
|
|
1118
1464
|
}
|
|
1119
1465
|
this._loopEnabled = enabled;
|
|
1466
|
+
this._loopStartTick = startTick;
|
|
1120
1467
|
this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
|
|
1121
1468
|
this._scheduler.setLoop(enabled, startTick, endTick);
|
|
1122
1469
|
this._clipPlayer.setLoop(enabled, startTick, endTick);
|
|
@@ -1145,15 +1492,19 @@ var Transport = class _Transport {
|
|
|
1145
1492
|
const startTick = this._sampleTimeline.samplesToTicks(startSample);
|
|
1146
1493
|
const endTick = this._sampleTimeline.samplesToTicks(endSample);
|
|
1147
1494
|
this._loopEnabled = enabled;
|
|
1495
|
+
this._loopStartTick = startTick;
|
|
1148
1496
|
this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
|
|
1149
1497
|
this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
|
|
1150
1498
|
this._scheduler.setLoop(enabled, startTick, endTick);
|
|
1151
1499
|
this._emit("loop");
|
|
1152
1500
|
}
|
|
1153
1501
|
// --- Tempo ---
|
|
1154
|
-
setTempo(bpm, atTick) {
|
|
1155
|
-
this._tempoMap.setTempo(bpm, atTick);
|
|
1156
|
-
this.
|
|
1502
|
+
setTempo(bpm, atTick, options) {
|
|
1503
|
+
this._tempoMap.setTempo(bpm, atTick, options);
|
|
1504
|
+
if (this._loopEnabled) {
|
|
1505
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
|
|
1506
|
+
}
|
|
1507
|
+
this._emit("tempochange", { bpm, atTick: atTick ?? 0 });
|
|
1157
1508
|
}
|
|
1158
1509
|
getTempo(atTick) {
|
|
1159
1510
|
return this._tempoMap.getTempo(atTick);
|
|
@@ -1161,22 +1512,35 @@ var Transport = class _Transport {
|
|
|
1161
1512
|
// --- Meter ---
|
|
1162
1513
|
setMeter(numerator, denominator, atTick) {
|
|
1163
1514
|
this._meterMap.setMeter(numerator, denominator, atTick);
|
|
1164
|
-
this._emit("meterchange");
|
|
1515
|
+
this._emit("meterchange", { numerator, denominator, atTick: atTick ?? 0 });
|
|
1165
1516
|
}
|
|
1166
1517
|
getMeter(atTick) {
|
|
1167
1518
|
return this._meterMap.getMeter(atTick);
|
|
1168
1519
|
}
|
|
1169
1520
|
removeMeter(atTick) {
|
|
1170
1521
|
this._meterMap.removeMeter(atTick);
|
|
1171
|
-
this.
|
|
1522
|
+
const meter = this._meterMap.getMeter(atTick);
|
|
1523
|
+
this._emit("meterchange", {
|
|
1524
|
+
numerator: meter.numerator,
|
|
1525
|
+
denominator: meter.denominator,
|
|
1526
|
+
atTick
|
|
1527
|
+
});
|
|
1172
1528
|
}
|
|
1173
1529
|
clearMeters() {
|
|
1174
1530
|
this._meterMap.clearMeters();
|
|
1175
|
-
this.
|
|
1531
|
+
const meter = this._meterMap.getMeter();
|
|
1532
|
+
this._emit("meterchange", {
|
|
1533
|
+
numerator: meter.numerator,
|
|
1534
|
+
denominator: meter.denominator,
|
|
1535
|
+
atTick: 0
|
|
1536
|
+
});
|
|
1176
1537
|
}
|
|
1177
1538
|
clearTempos() {
|
|
1178
1539
|
this._tempoMap.clearTempos();
|
|
1179
|
-
this.
|
|
1540
|
+
if (this._loopEnabled) {
|
|
1541
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
|
|
1542
|
+
}
|
|
1543
|
+
this._emit("tempochange", { bpm: this._tempoMap.getTempo(), atTick: 0 });
|
|
1180
1544
|
}
|
|
1181
1545
|
barToTick(bar) {
|
|
1182
1546
|
return this._meterMap.barToTick(bar);
|
|
@@ -1197,8 +1561,40 @@ var Transport = class _Transport {
|
|
|
1197
1561
|
this._metronomePlayer.setEnabled(enabled);
|
|
1198
1562
|
}
|
|
1199
1563
|
setMetronomeClickSounds(accent, normal) {
|
|
1564
|
+
this._accentBuffer = accent;
|
|
1565
|
+
this._normalBuffer = normal;
|
|
1200
1566
|
this._metronomePlayer.setClickSounds(accent, normal);
|
|
1201
1567
|
}
|
|
1568
|
+
setCountIn(enabled) {
|
|
1569
|
+
this._countInEnabled = enabled;
|
|
1570
|
+
}
|
|
1571
|
+
setCountInBars(bars) {
|
|
1572
|
+
const rounded = Math.round(bars);
|
|
1573
|
+
if (rounded < _Transport.MIN_COUNT_IN_BARS) {
|
|
1574
|
+
console.warn(
|
|
1575
|
+
"[waveform-playlist] Transport.setCountInBars: clamping " + bars + " to " + _Transport.MIN_COUNT_IN_BARS
|
|
1576
|
+
);
|
|
1577
|
+
this._countInBars = _Transport.MIN_COUNT_IN_BARS;
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (rounded > _Transport.MAX_COUNT_IN_BARS) {
|
|
1581
|
+
console.warn(
|
|
1582
|
+
"[waveform-playlist] Transport.setCountInBars: clamping " + bars + " to " + _Transport.MAX_COUNT_IN_BARS
|
|
1583
|
+
);
|
|
1584
|
+
this._countInBars = _Transport.MAX_COUNT_IN_BARS;
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
this._countInBars = rounded;
|
|
1588
|
+
}
|
|
1589
|
+
setCountInMode(mode) {
|
|
1590
|
+
this._countInMode = mode;
|
|
1591
|
+
}
|
|
1592
|
+
setRecording(recording) {
|
|
1593
|
+
this._recording = recording;
|
|
1594
|
+
}
|
|
1595
|
+
isCountingIn() {
|
|
1596
|
+
return this._countingIn;
|
|
1597
|
+
}
|
|
1202
1598
|
// --- Effects Hook ---
|
|
1203
1599
|
connectTrackOutput(trackId, node) {
|
|
1204
1600
|
const trackNode = this._trackNodes.get(trackId);
|
|
@@ -1287,9 +1683,94 @@ var Transport = class _Transport {
|
|
|
1287
1683
|
this._scheduler.addListener(this._clipPlayer);
|
|
1288
1684
|
this._scheduler.addListener(this._metronomePlayer);
|
|
1289
1685
|
}
|
|
1686
|
+
_initCountIn(audioContext, options) {
|
|
1687
|
+
const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
|
|
1688
|
+
this._countInPlayer = new CountInPlayer(
|
|
1689
|
+
audioContext,
|
|
1690
|
+
this._tempoMap,
|
|
1691
|
+
this._masterNode.input,
|
|
1692
|
+
toAudioTime
|
|
1693
|
+
);
|
|
1694
|
+
try {
|
|
1695
|
+
const { accent, normal } = createDefaultClickSounds(audioContext, {
|
|
1696
|
+
accentFrequency: options.accentFrequency,
|
|
1697
|
+
normalFrequency: options.normalFrequency
|
|
1698
|
+
});
|
|
1699
|
+
this._accentBuffer = accent;
|
|
1700
|
+
this._normalBuffer = normal;
|
|
1701
|
+
this._metronomePlayer.setClickSounds(accent, normal);
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
console.warn(
|
|
1704
|
+
"[waveform-playlist] Transport: failed to create default click sounds. Metronome and count-in will be silent until setMetronomeClickSounds() is called. Error: " + String(err)
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
_shouldCountIn() {
|
|
1709
|
+
if (!this._countInEnabled) return false;
|
|
1710
|
+
if (!this._accentBuffer || !this._normalBuffer) {
|
|
1711
|
+
console.warn("[waveform-playlist] Transport: count-in skipped \u2014 no click sounds loaded");
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
if (this._countInMode === "recording-only" && !this._recording) return false;
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
_startCountIn(endTime) {
|
|
1718
|
+
const currentTime = this._clock.getTime();
|
|
1719
|
+
this._countInStartPosition = currentTime;
|
|
1720
|
+
this._countingIn = true;
|
|
1721
|
+
this._playing = true;
|
|
1722
|
+
this._endTime = endTime;
|
|
1723
|
+
const playPositionTick = this._tempoMap.secondsToTicks(currentTime);
|
|
1724
|
+
const meter = this._meterMap.getMeter(playPositionTick);
|
|
1725
|
+
const totalBeats = meter.numerator * this._countInBars;
|
|
1726
|
+
const bpmAtPosition = this._tempoMap.getTempo(playPositionTick);
|
|
1727
|
+
const ticksPerBeat = this._ppqn * (4 / meter.denominator);
|
|
1728
|
+
const countInTicks = totalBeats * ticksPerBeat;
|
|
1729
|
+
const countInTempoMap = new TempoMap(this._ppqn, bpmAtPosition);
|
|
1730
|
+
const countInMeterMap = new MeterMap(this._ppqn, meter.numerator, meter.denominator);
|
|
1731
|
+
this._countInDuration = countInTempoMap.ticksToSeconds(countInTicks);
|
|
1732
|
+
this._countInScheduler = new Scheduler(countInTempoMap, {
|
|
1733
|
+
lookahead: this._schedulerLookahead
|
|
1734
|
+
});
|
|
1735
|
+
this._countInPlayer.configure({
|
|
1736
|
+
totalBeats,
|
|
1737
|
+
accentBuffer: this._accentBuffer,
|
|
1738
|
+
normalBuffer: this._normalBuffer,
|
|
1739
|
+
meterMap: countInMeterMap,
|
|
1740
|
+
tempoMap: countInTempoMap,
|
|
1741
|
+
onBeat: (beat, total) => {
|
|
1742
|
+
this._emit("countIn", { beat, totalBeats: total });
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
this._countInScheduler.addListener(this._countInPlayer);
|
|
1746
|
+
this._countInScheduler.reset(0);
|
|
1747
|
+
this._clock.seekTo(0);
|
|
1748
|
+
this._clock.start();
|
|
1749
|
+
this._timer.start();
|
|
1750
|
+
}
|
|
1751
|
+
_finishCountIn() {
|
|
1752
|
+
this._countInScheduler?.removeListener(this._countInPlayer);
|
|
1753
|
+
this._countInScheduler = null;
|
|
1754
|
+
this._countingIn = false;
|
|
1755
|
+
this._emit("countInEnd");
|
|
1756
|
+
this._clock.seekTo(this._countInStartPosition);
|
|
1757
|
+
const currentTime = this._clock.getTime();
|
|
1758
|
+
this._scheduler.reset(currentTime);
|
|
1759
|
+
const currentTick = this._tempoMap.secondsToTicks(currentTime);
|
|
1760
|
+
this._clipPlayer.onPositionJump(currentTick);
|
|
1761
|
+
this._emit("play");
|
|
1762
|
+
}
|
|
1763
|
+
_cancelCountIn() {
|
|
1764
|
+
this._countInPlayer.silence();
|
|
1765
|
+
this._countInScheduler?.removeListener(this._countInPlayer);
|
|
1766
|
+
this._countInScheduler = null;
|
|
1767
|
+
this._countingIn = false;
|
|
1768
|
+
this._timer.stop();
|
|
1769
|
+
}
|
|
1290
1770
|
_silenceAll() {
|
|
1291
1771
|
this._clipPlayer.silence();
|
|
1292
1772
|
this._metronomePlayer.silence();
|
|
1773
|
+
this._countInPlayer.silence();
|
|
1293
1774
|
}
|
|
1294
1775
|
_applyMuteState() {
|
|
1295
1776
|
const hasSolo = this._soloedTrackIds.size > 0;
|
|
@@ -1299,12 +1780,12 @@ var Transport = class _Transport {
|
|
|
1299
1780
|
node.setMute(isExplicitlyMuted || isSoloMuted);
|
|
1300
1781
|
}
|
|
1301
1782
|
}
|
|
1302
|
-
_emit(event) {
|
|
1783
|
+
_emit(event, ...args) {
|
|
1303
1784
|
const listeners = this._listeners.get(event);
|
|
1304
1785
|
if (listeners) {
|
|
1305
1786
|
for (const cb of listeners) {
|
|
1306
1787
|
try {
|
|
1307
|
-
cb();
|
|
1788
|
+
cb(...args);
|
|
1308
1789
|
} catch (err) {
|
|
1309
1790
|
console.warn(
|
|
1310
1791
|
'[waveform-playlist] Transport "' + event + '" listener threw:',
|
|
@@ -1315,6 +1796,10 @@ var Transport = class _Transport {
|
|
|
1315
1796
|
}
|
|
1316
1797
|
}
|
|
1317
1798
|
};
|
|
1799
|
+
// --- Count-In ---
|
|
1800
|
+
_Transport.MIN_COUNT_IN_BARS = 1;
|
|
1801
|
+
_Transport.MAX_COUNT_IN_BARS = 8;
|
|
1802
|
+
var Transport = _Transport;
|
|
1318
1803
|
|
|
1319
1804
|
// src/adapter.ts
|
|
1320
1805
|
var NativePlayoutAdapter = class {
|
|
@@ -1381,6 +1866,21 @@ var NativePlayoutAdapter = class {
|
|
|
1381
1866
|
setLoop(enabled, start, end) {
|
|
1382
1867
|
this._transport.setLoopSeconds(enabled, start, end);
|
|
1383
1868
|
}
|
|
1869
|
+
setCountIn(enabled) {
|
|
1870
|
+
this._transport.setCountIn(enabled);
|
|
1871
|
+
}
|
|
1872
|
+
setCountInBars(bars) {
|
|
1873
|
+
this._transport.setCountInBars(bars);
|
|
1874
|
+
}
|
|
1875
|
+
setCountInMode(mode) {
|
|
1876
|
+
this._transport.setCountInMode(mode);
|
|
1877
|
+
}
|
|
1878
|
+
setRecording(recording) {
|
|
1879
|
+
this._transport.setRecording(recording);
|
|
1880
|
+
}
|
|
1881
|
+
isCountingIn() {
|
|
1882
|
+
return this._transport.isCountingIn();
|
|
1883
|
+
}
|
|
1384
1884
|
dispose() {
|
|
1385
1885
|
this._transport.dispose();
|
|
1386
1886
|
}
|