@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.mjs
CHANGED
|
@@ -173,7 +173,9 @@ var Timer = class {
|
|
|
173
173
|
} catch (err) {
|
|
174
174
|
console.warn("[waveform-playlist] Timer tick error:", String(err));
|
|
175
175
|
}
|
|
176
|
-
this.
|
|
176
|
+
if (this._running) {
|
|
177
|
+
this._scheduleFrame();
|
|
178
|
+
}
|
|
177
179
|
});
|
|
178
180
|
}
|
|
179
181
|
};
|
|
@@ -215,28 +217,43 @@ var SampleTimeline = class {
|
|
|
215
217
|
};
|
|
216
218
|
|
|
217
219
|
// src/timeline/tempo-map.ts
|
|
220
|
+
var CURVE_EPSILON = 1e-15;
|
|
221
|
+
var CURVE_SUBDIVISIONS = 64;
|
|
222
|
+
function curveNormalizedAt(x, slope) {
|
|
223
|
+
if (slope > 0.499999 && slope < 0.500001) return x;
|
|
224
|
+
const p = Math.max(CURVE_EPSILON, Math.min(1 - CURVE_EPSILON, slope));
|
|
225
|
+
return p * p / (1 - p * 2) * (Math.pow((1 - p) / p, 2 * x) - 1);
|
|
226
|
+
}
|
|
218
227
|
var TempoMap = class {
|
|
219
228
|
constructor(ppqn = 960, initialBpm = 120) {
|
|
220
229
|
this._ppqn = ppqn;
|
|
221
|
-
this._entries = [{ tick: 0, bpm: initialBpm, secondsAtTick: 0 }];
|
|
230
|
+
this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
|
|
222
231
|
}
|
|
223
232
|
getTempo(atTick = 0) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
233
|
+
return this._getTempoAt(atTick);
|
|
234
|
+
}
|
|
235
|
+
setTempo(bpm, atTick = 0, options) {
|
|
236
|
+
const interpolation = options?.interpolation ?? "step";
|
|
237
|
+
if (typeof interpolation === "object" && interpolation.type === "curve") {
|
|
238
|
+
const s = interpolation.slope;
|
|
239
|
+
if (!Number.isFinite(s) || s <= 0 || s >= 1) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
"[waveform-playlist] TempoMap: curve slope must be between 0 and 1 (exclusive), got " + s
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
228
245
|
if (atTick === 0) {
|
|
229
|
-
this._entries[0] = { ...this._entries[0], bpm };
|
|
246
|
+
this._entries[0] = { ...this._entries[0], bpm, interpolation: "step" };
|
|
230
247
|
this._recomputeCache(0);
|
|
231
248
|
return;
|
|
232
249
|
}
|
|
233
250
|
let i = this._entries.length - 1;
|
|
234
251
|
while (i > 0 && this._entries[i].tick > atTick) i--;
|
|
235
252
|
if (this._entries[i].tick === atTick) {
|
|
236
|
-
this._entries[i] = { ...this._entries[i], bpm };
|
|
253
|
+
this._entries[i] = { ...this._entries[i], bpm, interpolation };
|
|
237
254
|
} else {
|
|
238
255
|
const secondsAtTick = this._ticksToSecondsInternal(atTick);
|
|
239
|
-
this._entries.splice(i + 1, 0, { tick: atTick, bpm, secondsAtTick });
|
|
256
|
+
this._entries.splice(i + 1, 0, { tick: atTick, bpm, interpolation, secondsAtTick });
|
|
240
257
|
i = i + 1;
|
|
241
258
|
}
|
|
242
259
|
this._recomputeCache(i);
|
|
@@ -257,6 +274,28 @@ var TempoMap = class {
|
|
|
257
274
|
}
|
|
258
275
|
const entry = this._entries[lo];
|
|
259
276
|
const secondsIntoSegment = seconds - entry.secondsAtTick;
|
|
277
|
+
const nextEntry = lo < this._entries.length - 1 ? this._entries[lo + 1] : null;
|
|
278
|
+
if (nextEntry && nextEntry.interpolation === "linear") {
|
|
279
|
+
return Math.round(
|
|
280
|
+
entry.tick + this._secondsToTicksLinear(
|
|
281
|
+
secondsIntoSegment,
|
|
282
|
+
entry.bpm,
|
|
283
|
+
nextEntry.bpm,
|
|
284
|
+
nextEntry.tick - entry.tick
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (nextEntry && typeof nextEntry.interpolation === "object") {
|
|
289
|
+
return Math.round(
|
|
290
|
+
entry.tick + this._secondsToTicksCurve(
|
|
291
|
+
secondsIntoSegment,
|
|
292
|
+
entry.bpm,
|
|
293
|
+
nextEntry.bpm,
|
|
294
|
+
nextEntry.tick - entry.tick,
|
|
295
|
+
nextEntry.interpolation.slope
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
}
|
|
260
299
|
const ticksPerSecond = entry.bpm / 60 * this._ppqn;
|
|
261
300
|
return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
|
|
262
301
|
}
|
|
@@ -268,15 +307,122 @@ var TempoMap = class {
|
|
|
268
307
|
}
|
|
269
308
|
clearTempos() {
|
|
270
309
|
const first = this._entries[0];
|
|
271
|
-
this._entries = [{ tick: 0, bpm: first.bpm, secondsAtTick: 0 }];
|
|
310
|
+
this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
|
|
311
|
+
}
|
|
312
|
+
/** Get the interpolated BPM at a tick position */
|
|
313
|
+
_getTempoAt(atTick) {
|
|
314
|
+
const entryIndex = this._entryIndexAt(atTick);
|
|
315
|
+
const entry = this._entries[entryIndex];
|
|
316
|
+
const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
|
|
317
|
+
if (nextEntry && nextEntry.interpolation !== "step") {
|
|
318
|
+
const segmentTicks = nextEntry.tick - entry.tick;
|
|
319
|
+
const ticksInto = atTick - entry.tick;
|
|
320
|
+
if (segmentTicks > 0) {
|
|
321
|
+
const progress = ticksInto / segmentTicks;
|
|
322
|
+
if (nextEntry.interpolation === "linear") {
|
|
323
|
+
return entry.bpm + (nextEntry.bpm - entry.bpm) * progress;
|
|
324
|
+
}
|
|
325
|
+
const t = curveNormalizedAt(progress, nextEntry.interpolation.slope);
|
|
326
|
+
return entry.bpm + (nextEntry.bpm - entry.bpm) * t;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return entry.bpm;
|
|
272
330
|
}
|
|
273
331
|
_ticksToSecondsInternal(ticks) {
|
|
274
|
-
const
|
|
332
|
+
const entryIndex = this._entryIndexAt(ticks);
|
|
333
|
+
const entry = this._entries[entryIndex];
|
|
275
334
|
const ticksIntoSegment = ticks - entry.tick;
|
|
335
|
+
const nextEntry = entryIndex < this._entries.length - 1 ? this._entries[entryIndex + 1] : null;
|
|
336
|
+
if (nextEntry && nextEntry.interpolation === "linear") {
|
|
337
|
+
const segmentTicks = nextEntry.tick - entry.tick;
|
|
338
|
+
return entry.secondsAtTick + this._ticksToSecondsLinear(ticksIntoSegment, entry.bpm, nextEntry.bpm, segmentTicks);
|
|
339
|
+
}
|
|
340
|
+
if (nextEntry && typeof nextEntry.interpolation === "object") {
|
|
341
|
+
const segmentTicks = nextEntry.tick - entry.tick;
|
|
342
|
+
return entry.secondsAtTick + this._ticksToSecondsCurve(
|
|
343
|
+
ticksIntoSegment,
|
|
344
|
+
entry.bpm,
|
|
345
|
+
nextEntry.bpm,
|
|
346
|
+
segmentTicks,
|
|
347
|
+
nextEntry.interpolation.slope
|
|
348
|
+
);
|
|
349
|
+
}
|
|
276
350
|
const secondsPerTick = 60 / (entry.bpm * this._ppqn);
|
|
277
351
|
return entry.secondsAtTick + ticksIntoSegment * secondsPerTick;
|
|
278
352
|
}
|
|
279
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Exact integration for a linear BPM ramp using the logarithmic formula.
|
|
355
|
+
* For bpm(t) = bpm0 + r*t where r = (bpm1-bpm0)/T:
|
|
356
|
+
* seconds = (T * 60) / (ppqn * (bpm1-bpm0)) * ln(bpmAtTick / bpm0)
|
|
357
|
+
*/
|
|
358
|
+
_ticksToSecondsLinear(ticks, bpm0, bpm1, totalSegmentTicks) {
|
|
359
|
+
if (totalSegmentTicks === 0) return 0;
|
|
360
|
+
const bpmAtTick = bpm0 + (bpm1 - bpm0) * (ticks / totalSegmentTicks);
|
|
361
|
+
if (Math.abs(bpm1 - bpm0) < 1e-10) {
|
|
362
|
+
return ticks * 60 / (bpm0 * this._ppqn);
|
|
363
|
+
}
|
|
364
|
+
const deltaBpm = bpm1 - bpm0;
|
|
365
|
+
return totalSegmentTicks * 60 / (this._ppqn * deltaBpm) * Math.log(bpmAtTick / bpm0);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Inverse of _ticksToSecondsLinear: given seconds, return ticks.
|
|
369
|
+
* Closed-form via exponential: bpmAtTick = bpm0 * exp(seconds * deltaBpm * ppqn / (60 * T))
|
|
370
|
+
* then ticks = (bpmAtTick - bpm0) * T / deltaBpm
|
|
371
|
+
*
|
|
372
|
+
* Note: exp(log(x)) has ~1 ULP floating-point error, so round-trips depend on
|
|
373
|
+
* Math.round() in the caller (secondsToTicks). This is sufficient for all tested
|
|
374
|
+
* BPM ranges (10–300 BPM) but is not algebraically exact like the previous
|
|
375
|
+
* trapezoidal/quadratic approach was.
|
|
376
|
+
*/
|
|
377
|
+
_secondsToTicksLinear(seconds, bpm0, bpm1, totalSegmentTicks) {
|
|
378
|
+
if (totalSegmentTicks === 0 || seconds === 0) return 0;
|
|
379
|
+
if (Math.abs(bpm1 - bpm0) < 1e-10) {
|
|
380
|
+
return seconds * bpm0 * this._ppqn / 60;
|
|
381
|
+
}
|
|
382
|
+
const deltaBpm = bpm1 - bpm0;
|
|
383
|
+
const bpmAtTick = bpm0 * Math.exp(seconds * deltaBpm * this._ppqn / (60 * totalSegmentTicks));
|
|
384
|
+
return (bpmAtTick - bpm0) / deltaBpm * totalSegmentTicks;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Subdivided trapezoidal integration for a Möbius-Ease tempo curve.
|
|
388
|
+
* The BPM at progress p is: bpm0 + curveNormalizedAt(p, slope) * (bpm1 - bpm0).
|
|
389
|
+
* We subdivide into CURVE_SUBDIVISIONS intervals and apply trapezoidal rule.
|
|
390
|
+
*/
|
|
391
|
+
_ticksToSecondsCurve(ticks, bpm0, bpm1, totalSegmentTicks, slope) {
|
|
392
|
+
if (totalSegmentTicks === 0 || ticks === 0) return 0;
|
|
393
|
+
const n = CURVE_SUBDIVISIONS;
|
|
394
|
+
const dt = ticks / n;
|
|
395
|
+
let seconds = 0;
|
|
396
|
+
let prevBpm = bpm0;
|
|
397
|
+
for (let i = 1; i <= n; i++) {
|
|
398
|
+
const progress = dt * i / totalSegmentTicks;
|
|
399
|
+
const curBpm = bpm0 + curveNormalizedAt(progress, slope) * (bpm1 - bpm0);
|
|
400
|
+
seconds += dt * 60 / this._ppqn * (1 / prevBpm + 1 / curBpm) / 2;
|
|
401
|
+
prevBpm = curBpm;
|
|
402
|
+
}
|
|
403
|
+
return seconds;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Inverse of _ticksToSecondsCurve: given seconds into a curved segment,
|
|
407
|
+
* return ticks. Uses binary search since there's no closed-form inverse.
|
|
408
|
+
*/
|
|
409
|
+
_secondsToTicksCurve(seconds, bpm0, bpm1, totalSegmentTicks, slope) {
|
|
410
|
+
if (totalSegmentTicks === 0 || seconds === 0) return 0;
|
|
411
|
+
const iterations = Math.min(40, Math.max(1, Math.ceil(Math.log2(2 * totalSegmentTicks))));
|
|
412
|
+
let lo = 0;
|
|
413
|
+
let hi = totalSegmentTicks;
|
|
414
|
+
for (let i = 0; i < iterations; i++) {
|
|
415
|
+
const mid = (lo + hi) / 2;
|
|
416
|
+
const midSeconds = this._ticksToSecondsCurve(mid, bpm0, bpm1, totalSegmentTicks, slope);
|
|
417
|
+
if (midSeconds < seconds) {
|
|
418
|
+
lo = mid;
|
|
419
|
+
} else {
|
|
420
|
+
hi = mid;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return (lo + hi) / 2;
|
|
424
|
+
}
|
|
425
|
+
_entryIndexAt(tick) {
|
|
280
426
|
let lo = 0;
|
|
281
427
|
let hi = this._entries.length - 1;
|
|
282
428
|
while (lo < hi) {
|
|
@@ -287,16 +433,31 @@ var TempoMap = class {
|
|
|
287
433
|
hi = mid - 1;
|
|
288
434
|
}
|
|
289
435
|
}
|
|
290
|
-
return
|
|
436
|
+
return lo;
|
|
291
437
|
}
|
|
292
438
|
_recomputeCache(fromIndex) {
|
|
293
439
|
for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
|
|
294
440
|
const prev = this._entries[i - 1];
|
|
295
441
|
const tickDelta = this._entries[i].tick - prev.tick;
|
|
296
|
-
const
|
|
442
|
+
const entry = this._entries[i];
|
|
443
|
+
let segmentSeconds;
|
|
444
|
+
if (entry.interpolation === "linear") {
|
|
445
|
+
segmentSeconds = this._ticksToSecondsLinear(tickDelta, prev.bpm, entry.bpm, tickDelta);
|
|
446
|
+
} else if (typeof entry.interpolation === "object") {
|
|
447
|
+
segmentSeconds = this._ticksToSecondsCurve(
|
|
448
|
+
tickDelta,
|
|
449
|
+
prev.bpm,
|
|
450
|
+
entry.bpm,
|
|
451
|
+
tickDelta,
|
|
452
|
+
entry.interpolation.slope
|
|
453
|
+
);
|
|
454
|
+
} else {
|
|
455
|
+
const secondsPerTick = 60 / (prev.bpm * this._ppqn);
|
|
456
|
+
segmentSeconds = tickDelta * secondsPerTick;
|
|
457
|
+
}
|
|
297
458
|
this._entries[i] = {
|
|
298
|
-
...
|
|
299
|
-
secondsAtTick: prev.secondsAtTick +
|
|
459
|
+
...entry,
|
|
460
|
+
secondsAtTick: prev.secondsAtTick + segmentSeconds
|
|
300
461
|
};
|
|
301
462
|
}
|
|
302
463
|
}
|
|
@@ -854,8 +1015,145 @@ var MetronomePlayer = class {
|
|
|
854
1015
|
}
|
|
855
1016
|
};
|
|
856
1017
|
|
|
1018
|
+
// src/audio/count-in-player.ts
|
|
1019
|
+
var CountInPlayer = class {
|
|
1020
|
+
constructor(audioContext, tempoMap, destination, toAudioTime) {
|
|
1021
|
+
this._activeSources = /* @__PURE__ */ new Set();
|
|
1022
|
+
this._totalBeats = 0;
|
|
1023
|
+
this._beatsGenerated = 0;
|
|
1024
|
+
this._accentBuffer = null;
|
|
1025
|
+
this._normalBuffer = null;
|
|
1026
|
+
this._meterMap = null;
|
|
1027
|
+
this._onBeat = null;
|
|
1028
|
+
this._audioContext = audioContext;
|
|
1029
|
+
this._tempoMap = tempoMap;
|
|
1030
|
+
this._destination = destination;
|
|
1031
|
+
this._toAudioTime = toAudioTime;
|
|
1032
|
+
}
|
|
1033
|
+
configure(config) {
|
|
1034
|
+
this._totalBeats = config.totalBeats;
|
|
1035
|
+
this._beatsGenerated = 0;
|
|
1036
|
+
this._accentBuffer = config.accentBuffer;
|
|
1037
|
+
this._normalBuffer = config.normalBuffer;
|
|
1038
|
+
this._meterMap = config.meterMap;
|
|
1039
|
+
this._tempoMap = config.tempoMap;
|
|
1040
|
+
this._onBeat = config.onBeat;
|
|
1041
|
+
}
|
|
1042
|
+
generate(fromTick, toTick) {
|
|
1043
|
+
if (!this._accentBuffer || !this._normalBuffer || !this._meterMap) {
|
|
1044
|
+
return [];
|
|
1045
|
+
}
|
|
1046
|
+
const events = [];
|
|
1047
|
+
const meterMap = this._meterMap;
|
|
1048
|
+
let entry = meterMap.getEntryAt(fromTick);
|
|
1049
|
+
let beatSize = meterMap.ticksPerBeat(fromTick);
|
|
1050
|
+
const tickIntoSection = fromTick - entry.tick;
|
|
1051
|
+
let tick = entry.tick + Math.ceil(tickIntoSection / beatSize) * beatSize;
|
|
1052
|
+
while (tick < toTick && this._beatsGenerated < this._totalBeats) {
|
|
1053
|
+
const tickPos = tick;
|
|
1054
|
+
const currentEntry = meterMap.getEntryAt(tickPos);
|
|
1055
|
+
if (currentEntry.tick !== entry.tick) {
|
|
1056
|
+
entry = currentEntry;
|
|
1057
|
+
beatSize = meterMap.ticksPerBeat(tickPos);
|
|
1058
|
+
}
|
|
1059
|
+
this._beatsGenerated++;
|
|
1060
|
+
const isAccent = meterMap.isBarBoundary(tickPos);
|
|
1061
|
+
events.push({
|
|
1062
|
+
tick: tickPos,
|
|
1063
|
+
isAccent,
|
|
1064
|
+
buffer: isAccent ? this._accentBuffer : this._normalBuffer,
|
|
1065
|
+
beat: this._beatsGenerated,
|
|
1066
|
+
totalBeats: this._totalBeats
|
|
1067
|
+
});
|
|
1068
|
+
beatSize = meterMap.ticksPerBeat(tickPos);
|
|
1069
|
+
tick += beatSize;
|
|
1070
|
+
}
|
|
1071
|
+
return events;
|
|
1072
|
+
}
|
|
1073
|
+
consume(event) {
|
|
1074
|
+
try {
|
|
1075
|
+
const source = this._audioContext.createBufferSource();
|
|
1076
|
+
source.buffer = event.buffer;
|
|
1077
|
+
source.connect(this._destination);
|
|
1078
|
+
this._activeSources.add(source);
|
|
1079
|
+
source.addEventListener("ended", () => {
|
|
1080
|
+
this._activeSources.delete(source);
|
|
1081
|
+
try {
|
|
1082
|
+
source.disconnect();
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.warn(
|
|
1085
|
+
"[waveform-playlist] CountInPlayer: error disconnecting source:",
|
|
1086
|
+
String(err)
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
const transportTime = this._tempoMap.ticksToSeconds(event.tick);
|
|
1091
|
+
source.start(this._toAudioTime(transportTime));
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
console.warn(
|
|
1094
|
+
"[waveform-playlist] CountInPlayer.consume: failed to schedule beat " + event.beat + "/" + event.totalBeats + ": " + String(err)
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
this._onBeat?.(event.beat, event.totalBeats);
|
|
1098
|
+
}
|
|
1099
|
+
onPositionJump(_newTick) {
|
|
1100
|
+
}
|
|
1101
|
+
silence() {
|
|
1102
|
+
for (const source of this._activeSources) {
|
|
1103
|
+
try {
|
|
1104
|
+
source.stop();
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
console.warn(
|
|
1107
|
+
"[waveform-playlist] CountInPlayer.silence: error stopping source:",
|
|
1108
|
+
String(err)
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
source.disconnect();
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
console.warn(
|
|
1115
|
+
"[waveform-playlist] CountInPlayer.silence: error disconnecting:",
|
|
1116
|
+
String(err)
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
this._activeSources.clear();
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
// src/audio/click-sounds.ts
|
|
1125
|
+
var DEFAULT_ACCENT_FREQUENCY = 1e3;
|
|
1126
|
+
var DEFAULT_NORMAL_FREQUENCY = 800;
|
|
1127
|
+
var ACCENT_DURATION = 0.04;
|
|
1128
|
+
var NORMAL_DURATION = 0.03;
|
|
1129
|
+
function synthesizeClick(audioContext, frequency, duration) {
|
|
1130
|
+
const sampleRate = audioContext.sampleRate;
|
|
1131
|
+
const length = Math.ceil(sampleRate * duration);
|
|
1132
|
+
const buffer = audioContext.createBuffer(1, length, sampleRate);
|
|
1133
|
+
const data = buffer.getChannelData(0);
|
|
1134
|
+
for (let i = 0; i < length; i++) {
|
|
1135
|
+
const t = i / sampleRate;
|
|
1136
|
+
const envelope = Math.exp(-t * 50);
|
|
1137
|
+
data[i] = Math.sin(2 * Math.PI * frequency * t) * envelope;
|
|
1138
|
+
}
|
|
1139
|
+
return buffer;
|
|
1140
|
+
}
|
|
1141
|
+
function createDefaultClickSounds(audioContext, options) {
|
|
1142
|
+
const accentFreq = options?.accentFrequency ?? DEFAULT_ACCENT_FREQUENCY;
|
|
1143
|
+
const normalFreq = options?.normalFrequency ?? DEFAULT_NORMAL_FREQUENCY;
|
|
1144
|
+
if (accentFreq <= 0 || normalFreq <= 0) {
|
|
1145
|
+
console.warn(
|
|
1146
|
+
"[waveform-playlist] createDefaultClickSounds: frequency must be positive, got accent=" + accentFreq + " normal=" + normalFreq
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
accent: synthesizeClick(audioContext, accentFreq, ACCENT_DURATION),
|
|
1151
|
+
normal: synthesizeClick(audioContext, normalFreq, NORMAL_DURATION)
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
857
1155
|
// src/transport.ts
|
|
858
|
-
var
|
|
1156
|
+
var _Transport = class _Transport {
|
|
859
1157
|
constructor(audioContext, options = {}) {
|
|
860
1158
|
this._trackNodes = /* @__PURE__ */ new Map();
|
|
861
1159
|
this._tracks = [];
|
|
@@ -863,8 +1161,24 @@ var Transport = class _Transport {
|
|
|
863
1161
|
this._mutedTrackIds = /* @__PURE__ */ new Set();
|
|
864
1162
|
this._playing = false;
|
|
865
1163
|
this._loopEnabled = false;
|
|
1164
|
+
this._loopStartTick = 0;
|
|
866
1165
|
this._loopStartSeconds = 0;
|
|
1166
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
867
1167
|
this._listeners = /* @__PURE__ */ new Map();
|
|
1168
|
+
// --- Count-In state ---
|
|
1169
|
+
this._countInEnabled = false;
|
|
1170
|
+
this._countInBars = 1;
|
|
1171
|
+
this._countInMode = "recording-only";
|
|
1172
|
+
this._recording = false;
|
|
1173
|
+
this._countingIn = false;
|
|
1174
|
+
this._countInStartPosition = 0;
|
|
1175
|
+
this._countInDuration = 0;
|
|
1176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1177
|
+
this._countInScheduler = null;
|
|
1178
|
+
this._accentBuffer = null;
|
|
1179
|
+
this._normalBuffer = null;
|
|
1180
|
+
this._schedulerLookahead = 0.2;
|
|
1181
|
+
this._ppqn = 960;
|
|
868
1182
|
this._audioContext = audioContext;
|
|
869
1183
|
const sampleRate = options.sampleRate ?? audioContext.sampleRate;
|
|
870
1184
|
const ppqn = options.ppqn ?? 960;
|
|
@@ -872,6 +1186,8 @@ var Transport = class _Transport {
|
|
|
872
1186
|
const numerator = options.numerator ?? 4;
|
|
873
1187
|
const denominator = options.denominator ?? 4;
|
|
874
1188
|
const lookahead = options.schedulerLookahead ?? 0.2;
|
|
1189
|
+
this._ppqn = ppqn;
|
|
1190
|
+
this._schedulerLookahead = lookahead;
|
|
875
1191
|
_Transport._validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead);
|
|
876
1192
|
this._clock = new Clock(audioContext);
|
|
877
1193
|
this._sampleTimeline = new SampleTimeline(sampleRate);
|
|
@@ -886,8 +1202,16 @@ var Transport = class _Transport {
|
|
|
886
1202
|
});
|
|
887
1203
|
this._sampleTimeline.setTempoMap(this._tempoMap);
|
|
888
1204
|
this._initAudioGraph(audioContext);
|
|
1205
|
+
this._initCountIn(audioContext, options);
|
|
889
1206
|
this._timer = new Timer(() => {
|
|
890
1207
|
const time = this._clock.getTime();
|
|
1208
|
+
if (this._countingIn) {
|
|
1209
|
+
this._countInScheduler?.advance(time);
|
|
1210
|
+
if (time >= this._countInDuration) {
|
|
1211
|
+
this._finishCountIn();
|
|
1212
|
+
}
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
891
1215
|
if (this._endTime !== void 0 && time >= this._endTime) {
|
|
892
1216
|
this.stop();
|
|
893
1217
|
return;
|
|
@@ -904,6 +1228,10 @@ var Transport = class _Transport {
|
|
|
904
1228
|
if (startTime !== void 0) {
|
|
905
1229
|
this._clock.seekTo(startTime);
|
|
906
1230
|
}
|
|
1231
|
+
if (this._shouldCountIn()) {
|
|
1232
|
+
this._startCountIn(endTime);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
907
1235
|
const currentTime = this._clock.getTime();
|
|
908
1236
|
this._scheduler.reset(currentTime);
|
|
909
1237
|
this._endTime = endTime;
|
|
@@ -916,6 +1244,13 @@ var Transport = class _Transport {
|
|
|
916
1244
|
}
|
|
917
1245
|
pause() {
|
|
918
1246
|
if (!this._playing) return;
|
|
1247
|
+
if (this._countingIn) {
|
|
1248
|
+
this._cancelCountIn();
|
|
1249
|
+
this._clock.stop();
|
|
1250
|
+
this._playing = false;
|
|
1251
|
+
this._emit("pause");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
919
1254
|
this._timer.stop();
|
|
920
1255
|
this._clock.stop();
|
|
921
1256
|
this._silenceAll();
|
|
@@ -924,6 +1259,9 @@ var Transport = class _Transport {
|
|
|
924
1259
|
}
|
|
925
1260
|
stop() {
|
|
926
1261
|
const wasPlaying = this._playing;
|
|
1262
|
+
if (this._countingIn) {
|
|
1263
|
+
this._cancelCountIn();
|
|
1264
|
+
}
|
|
927
1265
|
this._timer.stop();
|
|
928
1266
|
this._clock.reset();
|
|
929
1267
|
this._scheduler.reset(0);
|
|
@@ -936,14 +1274,19 @@ var Transport = class _Transport {
|
|
|
936
1274
|
}
|
|
937
1275
|
seek(time) {
|
|
938
1276
|
const wasPlaying = this._playing;
|
|
939
|
-
|
|
1277
|
+
const wasCountingIn = this._countingIn;
|
|
1278
|
+
if (wasCountingIn) {
|
|
1279
|
+
this._cancelCountIn();
|
|
1280
|
+
this._playing = false;
|
|
1281
|
+
}
|
|
1282
|
+
if (wasPlaying && !wasCountingIn) {
|
|
940
1283
|
this._timer.stop();
|
|
941
1284
|
}
|
|
942
1285
|
this._silenceAll();
|
|
943
1286
|
this._clock.seekTo(time);
|
|
944
1287
|
this._scheduler.reset(time);
|
|
945
1288
|
this._endTime = void 0;
|
|
946
|
-
if (wasPlaying) {
|
|
1289
|
+
if (wasPlaying && !wasCountingIn) {
|
|
947
1290
|
this._clock.start();
|
|
948
1291
|
const seekTick = this._tempoMap.secondsToTicks(time);
|
|
949
1292
|
this._clipPlayer.onPositionJump(seekTick);
|
|
@@ -951,6 +1294,9 @@ var Transport = class _Transport {
|
|
|
951
1294
|
}
|
|
952
1295
|
}
|
|
953
1296
|
getCurrentTime() {
|
|
1297
|
+
if (this._countingIn) {
|
|
1298
|
+
return this._countInStartPosition;
|
|
1299
|
+
}
|
|
954
1300
|
const t = this._clock.getTime();
|
|
955
1301
|
if (this._loopEnabled && t < this._loopStartSeconds) {
|
|
956
1302
|
return this._loopStartSeconds;
|
|
@@ -1080,6 +1426,7 @@ var Transport = class _Transport {
|
|
|
1080
1426
|
return;
|
|
1081
1427
|
}
|
|
1082
1428
|
this._loopEnabled = enabled;
|
|
1429
|
+
this._loopStartTick = startTick;
|
|
1083
1430
|
this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
|
|
1084
1431
|
this._scheduler.setLoop(enabled, startTick, endTick);
|
|
1085
1432
|
this._clipPlayer.setLoop(enabled, startTick, endTick);
|
|
@@ -1108,15 +1455,19 @@ var Transport = class _Transport {
|
|
|
1108
1455
|
const startTick = this._sampleTimeline.samplesToTicks(startSample);
|
|
1109
1456
|
const endTick = this._sampleTimeline.samplesToTicks(endSample);
|
|
1110
1457
|
this._loopEnabled = enabled;
|
|
1458
|
+
this._loopStartTick = startTick;
|
|
1111
1459
|
this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
|
|
1112
1460
|
this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
|
|
1113
1461
|
this._scheduler.setLoop(enabled, startTick, endTick);
|
|
1114
1462
|
this._emit("loop");
|
|
1115
1463
|
}
|
|
1116
1464
|
// --- Tempo ---
|
|
1117
|
-
setTempo(bpm, atTick) {
|
|
1118
|
-
this._tempoMap.setTempo(bpm, atTick);
|
|
1119
|
-
this.
|
|
1465
|
+
setTempo(bpm, atTick, options) {
|
|
1466
|
+
this._tempoMap.setTempo(bpm, atTick, options);
|
|
1467
|
+
if (this._loopEnabled) {
|
|
1468
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
|
|
1469
|
+
}
|
|
1470
|
+
this._emit("tempochange", { bpm, atTick: atTick ?? 0 });
|
|
1120
1471
|
}
|
|
1121
1472
|
getTempo(atTick) {
|
|
1122
1473
|
return this._tempoMap.getTempo(atTick);
|
|
@@ -1124,22 +1475,35 @@ var Transport = class _Transport {
|
|
|
1124
1475
|
// --- Meter ---
|
|
1125
1476
|
setMeter(numerator, denominator, atTick) {
|
|
1126
1477
|
this._meterMap.setMeter(numerator, denominator, atTick);
|
|
1127
|
-
this._emit("meterchange");
|
|
1478
|
+
this._emit("meterchange", { numerator, denominator, atTick: atTick ?? 0 });
|
|
1128
1479
|
}
|
|
1129
1480
|
getMeter(atTick) {
|
|
1130
1481
|
return this._meterMap.getMeter(atTick);
|
|
1131
1482
|
}
|
|
1132
1483
|
removeMeter(atTick) {
|
|
1133
1484
|
this._meterMap.removeMeter(atTick);
|
|
1134
|
-
this.
|
|
1485
|
+
const meter = this._meterMap.getMeter(atTick);
|
|
1486
|
+
this._emit("meterchange", {
|
|
1487
|
+
numerator: meter.numerator,
|
|
1488
|
+
denominator: meter.denominator,
|
|
1489
|
+
atTick
|
|
1490
|
+
});
|
|
1135
1491
|
}
|
|
1136
1492
|
clearMeters() {
|
|
1137
1493
|
this._meterMap.clearMeters();
|
|
1138
|
-
this.
|
|
1494
|
+
const meter = this._meterMap.getMeter();
|
|
1495
|
+
this._emit("meterchange", {
|
|
1496
|
+
numerator: meter.numerator,
|
|
1497
|
+
denominator: meter.denominator,
|
|
1498
|
+
atTick: 0
|
|
1499
|
+
});
|
|
1139
1500
|
}
|
|
1140
1501
|
clearTempos() {
|
|
1141
1502
|
this._tempoMap.clearTempos();
|
|
1142
|
-
this.
|
|
1503
|
+
if (this._loopEnabled) {
|
|
1504
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
|
|
1505
|
+
}
|
|
1506
|
+
this._emit("tempochange", { bpm: this._tempoMap.getTempo(), atTick: 0 });
|
|
1143
1507
|
}
|
|
1144
1508
|
barToTick(bar) {
|
|
1145
1509
|
return this._meterMap.barToTick(bar);
|
|
@@ -1160,8 +1524,40 @@ var Transport = class _Transport {
|
|
|
1160
1524
|
this._metronomePlayer.setEnabled(enabled);
|
|
1161
1525
|
}
|
|
1162
1526
|
setMetronomeClickSounds(accent, normal) {
|
|
1527
|
+
this._accentBuffer = accent;
|
|
1528
|
+
this._normalBuffer = normal;
|
|
1163
1529
|
this._metronomePlayer.setClickSounds(accent, normal);
|
|
1164
1530
|
}
|
|
1531
|
+
setCountIn(enabled) {
|
|
1532
|
+
this._countInEnabled = enabled;
|
|
1533
|
+
}
|
|
1534
|
+
setCountInBars(bars) {
|
|
1535
|
+
const rounded = Math.round(bars);
|
|
1536
|
+
if (rounded < _Transport.MIN_COUNT_IN_BARS) {
|
|
1537
|
+
console.warn(
|
|
1538
|
+
"[waveform-playlist] Transport.setCountInBars: clamping " + bars + " to " + _Transport.MIN_COUNT_IN_BARS
|
|
1539
|
+
);
|
|
1540
|
+
this._countInBars = _Transport.MIN_COUNT_IN_BARS;
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (rounded > _Transport.MAX_COUNT_IN_BARS) {
|
|
1544
|
+
console.warn(
|
|
1545
|
+
"[waveform-playlist] Transport.setCountInBars: clamping " + bars + " to " + _Transport.MAX_COUNT_IN_BARS
|
|
1546
|
+
);
|
|
1547
|
+
this._countInBars = _Transport.MAX_COUNT_IN_BARS;
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
this._countInBars = rounded;
|
|
1551
|
+
}
|
|
1552
|
+
setCountInMode(mode) {
|
|
1553
|
+
this._countInMode = mode;
|
|
1554
|
+
}
|
|
1555
|
+
setRecording(recording) {
|
|
1556
|
+
this._recording = recording;
|
|
1557
|
+
}
|
|
1558
|
+
isCountingIn() {
|
|
1559
|
+
return this._countingIn;
|
|
1560
|
+
}
|
|
1165
1561
|
// --- Effects Hook ---
|
|
1166
1562
|
connectTrackOutput(trackId, node) {
|
|
1167
1563
|
const trackNode = this._trackNodes.get(trackId);
|
|
@@ -1250,9 +1646,94 @@ var Transport = class _Transport {
|
|
|
1250
1646
|
this._scheduler.addListener(this._clipPlayer);
|
|
1251
1647
|
this._scheduler.addListener(this._metronomePlayer);
|
|
1252
1648
|
}
|
|
1649
|
+
_initCountIn(audioContext, options) {
|
|
1650
|
+
const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
|
|
1651
|
+
this._countInPlayer = new CountInPlayer(
|
|
1652
|
+
audioContext,
|
|
1653
|
+
this._tempoMap,
|
|
1654
|
+
this._masterNode.input,
|
|
1655
|
+
toAudioTime
|
|
1656
|
+
);
|
|
1657
|
+
try {
|
|
1658
|
+
const { accent, normal } = createDefaultClickSounds(audioContext, {
|
|
1659
|
+
accentFrequency: options.accentFrequency,
|
|
1660
|
+
normalFrequency: options.normalFrequency
|
|
1661
|
+
});
|
|
1662
|
+
this._accentBuffer = accent;
|
|
1663
|
+
this._normalBuffer = normal;
|
|
1664
|
+
this._metronomePlayer.setClickSounds(accent, normal);
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
console.warn(
|
|
1667
|
+
"[waveform-playlist] Transport: failed to create default click sounds. Metronome and count-in will be silent until setMetronomeClickSounds() is called. Error: " + String(err)
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
_shouldCountIn() {
|
|
1672
|
+
if (!this._countInEnabled) return false;
|
|
1673
|
+
if (!this._accentBuffer || !this._normalBuffer) {
|
|
1674
|
+
console.warn("[waveform-playlist] Transport: count-in skipped \u2014 no click sounds loaded");
|
|
1675
|
+
return false;
|
|
1676
|
+
}
|
|
1677
|
+
if (this._countInMode === "recording-only" && !this._recording) return false;
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
_startCountIn(endTime) {
|
|
1681
|
+
const currentTime = this._clock.getTime();
|
|
1682
|
+
this._countInStartPosition = currentTime;
|
|
1683
|
+
this._countingIn = true;
|
|
1684
|
+
this._playing = true;
|
|
1685
|
+
this._endTime = endTime;
|
|
1686
|
+
const playPositionTick = this._tempoMap.secondsToTicks(currentTime);
|
|
1687
|
+
const meter = this._meterMap.getMeter(playPositionTick);
|
|
1688
|
+
const totalBeats = meter.numerator * this._countInBars;
|
|
1689
|
+
const bpmAtPosition = this._tempoMap.getTempo(playPositionTick);
|
|
1690
|
+
const ticksPerBeat = this._ppqn * (4 / meter.denominator);
|
|
1691
|
+
const countInTicks = totalBeats * ticksPerBeat;
|
|
1692
|
+
const countInTempoMap = new TempoMap(this._ppqn, bpmAtPosition);
|
|
1693
|
+
const countInMeterMap = new MeterMap(this._ppqn, meter.numerator, meter.denominator);
|
|
1694
|
+
this._countInDuration = countInTempoMap.ticksToSeconds(countInTicks);
|
|
1695
|
+
this._countInScheduler = new Scheduler(countInTempoMap, {
|
|
1696
|
+
lookahead: this._schedulerLookahead
|
|
1697
|
+
});
|
|
1698
|
+
this._countInPlayer.configure({
|
|
1699
|
+
totalBeats,
|
|
1700
|
+
accentBuffer: this._accentBuffer,
|
|
1701
|
+
normalBuffer: this._normalBuffer,
|
|
1702
|
+
meterMap: countInMeterMap,
|
|
1703
|
+
tempoMap: countInTempoMap,
|
|
1704
|
+
onBeat: (beat, total) => {
|
|
1705
|
+
this._emit("countIn", { beat, totalBeats: total });
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
this._countInScheduler.addListener(this._countInPlayer);
|
|
1709
|
+
this._countInScheduler.reset(0);
|
|
1710
|
+
this._clock.seekTo(0);
|
|
1711
|
+
this._clock.start();
|
|
1712
|
+
this._timer.start();
|
|
1713
|
+
}
|
|
1714
|
+
_finishCountIn() {
|
|
1715
|
+
this._countInScheduler?.removeListener(this._countInPlayer);
|
|
1716
|
+
this._countInScheduler = null;
|
|
1717
|
+
this._countingIn = false;
|
|
1718
|
+
this._emit("countInEnd");
|
|
1719
|
+
this._clock.seekTo(this._countInStartPosition);
|
|
1720
|
+
const currentTime = this._clock.getTime();
|
|
1721
|
+
this._scheduler.reset(currentTime);
|
|
1722
|
+
const currentTick = this._tempoMap.secondsToTicks(currentTime);
|
|
1723
|
+
this._clipPlayer.onPositionJump(currentTick);
|
|
1724
|
+
this._emit("play");
|
|
1725
|
+
}
|
|
1726
|
+
_cancelCountIn() {
|
|
1727
|
+
this._countInPlayer.silence();
|
|
1728
|
+
this._countInScheduler?.removeListener(this._countInPlayer);
|
|
1729
|
+
this._countInScheduler = null;
|
|
1730
|
+
this._countingIn = false;
|
|
1731
|
+
this._timer.stop();
|
|
1732
|
+
}
|
|
1253
1733
|
_silenceAll() {
|
|
1254
1734
|
this._clipPlayer.silence();
|
|
1255
1735
|
this._metronomePlayer.silence();
|
|
1736
|
+
this._countInPlayer.silence();
|
|
1256
1737
|
}
|
|
1257
1738
|
_applyMuteState() {
|
|
1258
1739
|
const hasSolo = this._soloedTrackIds.size > 0;
|
|
@@ -1262,12 +1743,12 @@ var Transport = class _Transport {
|
|
|
1262
1743
|
node.setMute(isExplicitlyMuted || isSoloMuted);
|
|
1263
1744
|
}
|
|
1264
1745
|
}
|
|
1265
|
-
_emit(event) {
|
|
1746
|
+
_emit(event, ...args) {
|
|
1266
1747
|
const listeners = this._listeners.get(event);
|
|
1267
1748
|
if (listeners) {
|
|
1268
1749
|
for (const cb of listeners) {
|
|
1269
1750
|
try {
|
|
1270
|
-
cb();
|
|
1751
|
+
cb(...args);
|
|
1271
1752
|
} catch (err) {
|
|
1272
1753
|
console.warn(
|
|
1273
1754
|
'[waveform-playlist] Transport "' + event + '" listener threw:',
|
|
@@ -1278,6 +1759,10 @@ var Transport = class _Transport {
|
|
|
1278
1759
|
}
|
|
1279
1760
|
}
|
|
1280
1761
|
};
|
|
1762
|
+
// --- Count-In ---
|
|
1763
|
+
_Transport.MIN_COUNT_IN_BARS = 1;
|
|
1764
|
+
_Transport.MAX_COUNT_IN_BARS = 8;
|
|
1765
|
+
var Transport = _Transport;
|
|
1281
1766
|
|
|
1282
1767
|
// src/adapter.ts
|
|
1283
1768
|
var NativePlayoutAdapter = class {
|
|
@@ -1344,6 +1829,21 @@ var NativePlayoutAdapter = class {
|
|
|
1344
1829
|
setLoop(enabled, start, end) {
|
|
1345
1830
|
this._transport.setLoopSeconds(enabled, start, end);
|
|
1346
1831
|
}
|
|
1832
|
+
setCountIn(enabled) {
|
|
1833
|
+
this._transport.setCountIn(enabled);
|
|
1834
|
+
}
|
|
1835
|
+
setCountInBars(bars) {
|
|
1836
|
+
this._transport.setCountInBars(bars);
|
|
1837
|
+
}
|
|
1838
|
+
setCountInMode(mode) {
|
|
1839
|
+
this._transport.setCountInMode(mode);
|
|
1840
|
+
}
|
|
1841
|
+
setRecording(recording) {
|
|
1842
|
+
this._transport.setRecording(recording);
|
|
1843
|
+
}
|
|
1844
|
+
isCountingIn() {
|
|
1845
|
+
return this._transport.isCountingIn();
|
|
1846
|
+
}
|
|
1347
1847
|
dispose() {
|
|
1348
1848
|
this._transport.dispose();
|
|
1349
1849
|
}
|