@dawcore/transport 0.0.2 → 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 +41 -5
- package/dist/index.d.mts +153 -69
- package/dist/index.d.ts +153 -69
- package/dist/index.js +388 -113
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +388 -113
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -49,12 +49,15 @@ var Clock = class {
|
|
|
49
49
|
|
|
50
50
|
// src/core/scheduler.ts
|
|
51
51
|
var Scheduler = class {
|
|
52
|
-
constructor(options = {}) {
|
|
52
|
+
constructor(tempoMap, options = {}) {
|
|
53
53
|
this._rightEdge = 0;
|
|
54
|
+
// integer ticks
|
|
54
55
|
this._listeners = /* @__PURE__ */ new Set();
|
|
55
56
|
this._loopEnabled = false;
|
|
56
57
|
this._loopStart = 0;
|
|
58
|
+
// integer ticks
|
|
57
59
|
this._loopEnd = 0;
|
|
60
|
+
this._tempoMap = tempoMap;
|
|
58
61
|
this._lookahead = options.lookahead ?? 0.2;
|
|
59
62
|
this._onLoop = options.onLoop;
|
|
60
63
|
}
|
|
@@ -64,25 +67,40 @@ var Scheduler = class {
|
|
|
64
67
|
removeListener(listener) {
|
|
65
68
|
this._listeners.delete(listener);
|
|
66
69
|
}
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
/** Primary API — ticks as source of truth */
|
|
71
|
+
setLoop(enabled, startTick, endTick) {
|
|
72
|
+
if (enabled && (!Number.isFinite(startTick) || !Number.isFinite(endTick))) {
|
|
69
73
|
console.warn(
|
|
70
|
-
"[waveform-playlist] Scheduler.setLoop:
|
|
74
|
+
"[waveform-playlist] Scheduler.setLoop: non-finite tick values (" + startTick + ", " + endTick + ")"
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (enabled && startTick >= endTick) {
|
|
79
|
+
console.warn(
|
|
80
|
+
"[waveform-playlist] Scheduler.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
|
|
71
81
|
);
|
|
72
82
|
return;
|
|
73
83
|
}
|
|
74
84
|
this._loopEnabled = enabled;
|
|
75
|
-
this._loopStart =
|
|
76
|
-
this._loopEnd =
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
this._loopStart = Math.round(startTick);
|
|
86
|
+
this._loopEnd = Math.round(endTick);
|
|
87
|
+
}
|
|
88
|
+
/** Convenience — converts seconds to ticks via TempoMap */
|
|
89
|
+
setLoopSeconds(enabled, startSec, endSec) {
|
|
90
|
+
const startTick = this._tempoMap.secondsToTicks(startSec);
|
|
91
|
+
const endTick = this._tempoMap.secondsToTicks(endSec);
|
|
92
|
+
this.setLoop(enabled, startTick, endTick);
|
|
93
|
+
}
|
|
94
|
+
/** Reset scheduling cursor. Takes seconds (from Clock), converts to ticks. */
|
|
95
|
+
reset(timeSeconds) {
|
|
96
|
+
this._rightEdge = this._tempoMap.secondsToTicks(timeSeconds);
|
|
97
|
+
}
|
|
98
|
+
/** Advance the scheduling window. Takes seconds (from Clock), converts to ticks. */
|
|
99
|
+
advance(currentTimeSeconds) {
|
|
100
|
+
const targetTick = this._tempoMap.secondsToTicks(currentTimeSeconds + this._lookahead);
|
|
83
101
|
if (this._loopEnabled && this._loopEnd > this._loopStart) {
|
|
84
102
|
const loopDuration = this._loopEnd - this._loopStart;
|
|
85
|
-
let remaining =
|
|
103
|
+
let remaining = targetTick - this._rightEdge;
|
|
86
104
|
while (remaining > 0) {
|
|
87
105
|
const distToEnd = this._loopEnd - this._rightEdge;
|
|
88
106
|
if (distToEnd <= 0 || distToEnd > remaining) {
|
|
@@ -95,21 +113,25 @@ var Scheduler = class {
|
|
|
95
113
|
for (const listener of this._listeners) {
|
|
96
114
|
listener.onPositionJump(this._loopStart);
|
|
97
115
|
}
|
|
98
|
-
this._onLoop?.(
|
|
116
|
+
this._onLoop?.(
|
|
117
|
+
this._tempoMap.ticksToSeconds(this._loopStart),
|
|
118
|
+
this._tempoMap.ticksToSeconds(this._loopEnd),
|
|
119
|
+
currentTimeSeconds
|
|
120
|
+
);
|
|
99
121
|
this._rightEdge = this._loopStart;
|
|
100
122
|
if (loopDuration <= 0) break;
|
|
101
123
|
}
|
|
102
124
|
return;
|
|
103
125
|
}
|
|
104
|
-
if (
|
|
105
|
-
this._generateAndConsume(this._rightEdge,
|
|
106
|
-
this._rightEdge =
|
|
126
|
+
if (targetTick > this._rightEdge) {
|
|
127
|
+
this._generateAndConsume(this._rightEdge, targetTick);
|
|
128
|
+
this._rightEdge = targetTick;
|
|
107
129
|
}
|
|
108
130
|
}
|
|
109
|
-
_generateAndConsume(
|
|
131
|
+
_generateAndConsume(fromTick, toTick) {
|
|
110
132
|
for (const listener of this._listeners) {
|
|
111
133
|
try {
|
|
112
|
-
const events = listener.generate(
|
|
134
|
+
const events = listener.generate(fromTick, toTick);
|
|
113
135
|
for (const event of events) {
|
|
114
136
|
try {
|
|
115
137
|
listener.consume(event);
|
|
@@ -159,42 +181,77 @@ var Timer = class {
|
|
|
159
181
|
// src/timeline/sample-timeline.ts
|
|
160
182
|
var SampleTimeline = class {
|
|
161
183
|
constructor(sampleRate) {
|
|
184
|
+
this._tempoMap = null;
|
|
162
185
|
this._sampleRate = sampleRate;
|
|
163
186
|
}
|
|
164
187
|
get sampleRate() {
|
|
165
188
|
return this._sampleRate;
|
|
166
189
|
}
|
|
190
|
+
setTempoMap(tempoMap) {
|
|
191
|
+
this._tempoMap = tempoMap;
|
|
192
|
+
}
|
|
167
193
|
samplesToSeconds(samples) {
|
|
168
194
|
return samples / this._sampleRate;
|
|
169
195
|
}
|
|
170
196
|
secondsToSamples(seconds) {
|
|
171
197
|
return Math.round(seconds * this._sampleRate);
|
|
172
198
|
}
|
|
199
|
+
ticksToSamples(ticks) {
|
|
200
|
+
if (!this._tempoMap) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
"[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return Math.round(this._tempoMap.ticksToSeconds(ticks) * this._sampleRate);
|
|
206
|
+
}
|
|
207
|
+
samplesToTicks(samples) {
|
|
208
|
+
if (!this._tempoMap) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return this._tempoMap.secondsToTicks(samples / this._sampleRate);
|
|
214
|
+
}
|
|
173
215
|
};
|
|
174
216
|
|
|
175
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
|
+
}
|
|
176
225
|
var TempoMap = class {
|
|
177
226
|
constructor(ppqn = 960, initialBpm = 120) {
|
|
178
227
|
this._ppqn = ppqn;
|
|
179
|
-
this._entries = [{ tick: 0, bpm: initialBpm, secondsAtTick: 0 }];
|
|
228
|
+
this._entries = [{ tick: 0, bpm: initialBpm, interpolation: "step", secondsAtTick: 0 }];
|
|
180
229
|
}
|
|
181
230
|
getTempo(atTick = 0) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|
|
186
243
|
if (atTick === 0) {
|
|
187
|
-
this._entries[0] = { ...this._entries[0], bpm };
|
|
244
|
+
this._entries[0] = { ...this._entries[0], bpm, interpolation: "step" };
|
|
188
245
|
this._recomputeCache(0);
|
|
189
246
|
return;
|
|
190
247
|
}
|
|
191
248
|
let i = this._entries.length - 1;
|
|
192
249
|
while (i > 0 && this._entries[i].tick > atTick) i--;
|
|
193
250
|
if (this._entries[i].tick === atTick) {
|
|
194
|
-
this._entries[i] = { ...this._entries[i], bpm };
|
|
251
|
+
this._entries[i] = { ...this._entries[i], bpm, interpolation };
|
|
195
252
|
} else {
|
|
196
253
|
const secondsAtTick = this._ticksToSecondsInternal(atTick);
|
|
197
|
-
this._entries.splice(i + 1, 0, { tick: atTick, bpm, secondsAtTick });
|
|
254
|
+
this._entries.splice(i + 1, 0, { tick: atTick, bpm, interpolation, secondsAtTick });
|
|
198
255
|
i = i + 1;
|
|
199
256
|
}
|
|
200
257
|
this._recomputeCache(i);
|
|
@@ -215,8 +272,30 @@ var TempoMap = class {
|
|
|
215
272
|
}
|
|
216
273
|
const entry = this._entries[lo];
|
|
217
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
|
+
}
|
|
218
297
|
const ticksPerSecond = entry.bpm / 60 * this._ppqn;
|
|
219
|
-
return entry.tick + secondsIntoSegment * ticksPerSecond;
|
|
298
|
+
return Math.round(entry.tick + secondsIntoSegment * ticksPerSecond);
|
|
220
299
|
}
|
|
221
300
|
beatsToSeconds(beats) {
|
|
222
301
|
return this.ticksToSeconds(beats * this._ppqn);
|
|
@@ -226,15 +305,122 @@ var TempoMap = class {
|
|
|
226
305
|
}
|
|
227
306
|
clearTempos() {
|
|
228
307
|
const first = this._entries[0];
|
|
229
|
-
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;
|
|
230
328
|
}
|
|
231
329
|
_ticksToSecondsInternal(ticks) {
|
|
232
|
-
const
|
|
330
|
+
const entryIndex = this._entryIndexAt(ticks);
|
|
331
|
+
const entry = this._entries[entryIndex];
|
|
233
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
|
+
}
|
|
234
348
|
const secondsPerTick = 60 / (entry.bpm * this._ppqn);
|
|
235
349
|
return entry.secondsAtTick + ticksIntoSegment * secondsPerTick;
|
|
236
350
|
}
|
|
237
|
-
|
|
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) {
|
|
238
424
|
let lo = 0;
|
|
239
425
|
let hi = this._entries.length - 1;
|
|
240
426
|
while (lo < hi) {
|
|
@@ -245,16 +431,31 @@ var TempoMap = class {
|
|
|
245
431
|
hi = mid - 1;
|
|
246
432
|
}
|
|
247
433
|
}
|
|
248
|
-
return
|
|
434
|
+
return lo;
|
|
249
435
|
}
|
|
250
436
|
_recomputeCache(fromIndex) {
|
|
251
437
|
for (let i = Math.max(1, fromIndex); i < this._entries.length; i++) {
|
|
252
438
|
const prev = this._entries[i - 1];
|
|
253
439
|
const tickDelta = this._entries[i].tick - prev.tick;
|
|
254
|
-
const
|
|
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
|
+
}
|
|
255
456
|
this._entries[i] = {
|
|
256
|
-
...
|
|
257
|
-
secondsAtTick: prev.secondsAtTick +
|
|
457
|
+
...entry,
|
|
458
|
+
secondsAtTick: prev.secondsAtTick + segmentSeconds
|
|
258
459
|
};
|
|
259
460
|
}
|
|
260
461
|
}
|
|
@@ -518,14 +719,15 @@ var TrackNode = class {
|
|
|
518
719
|
|
|
519
720
|
// src/audio/clip-player.ts
|
|
520
721
|
var ClipPlayer = class {
|
|
521
|
-
constructor(audioContext, sampleTimeline, toAudioTime) {
|
|
722
|
+
constructor(audioContext, sampleTimeline, tempoMap, toAudioTime) {
|
|
522
723
|
this._tracks = /* @__PURE__ */ new Map();
|
|
523
724
|
this._trackNodes = /* @__PURE__ */ new Map();
|
|
524
725
|
this._activeSources = /* @__PURE__ */ new Map();
|
|
525
726
|
this._loopEnabled = false;
|
|
526
|
-
this.
|
|
727
|
+
this._loopEndSamples = 0;
|
|
527
728
|
this._audioContext = audioContext;
|
|
528
729
|
this._sampleTimeline = sampleTimeline;
|
|
730
|
+
this._tempoMap = tempoMap;
|
|
529
731
|
this._toAudioTime = toAudioTime;
|
|
530
732
|
}
|
|
531
733
|
setTracks(tracks, trackNodes) {
|
|
@@ -535,41 +737,50 @@ var ClipPlayer = class {
|
|
|
535
737
|
this._tracks.set(track.id, { track, clips: track.clips });
|
|
536
738
|
}
|
|
537
739
|
}
|
|
538
|
-
|
|
740
|
+
/** Set loop region using ticks. startTick is unused — loop clamping only needs
|
|
741
|
+
* the end boundary; mid-clip restart at loopStart is handled by onPositionJump. */
|
|
742
|
+
setLoop(enabled, _startTick, endTick) {
|
|
743
|
+
this._loopEnabled = enabled;
|
|
744
|
+
this._loopEndSamples = this._sampleTimeline.ticksToSamples(endTick);
|
|
745
|
+
}
|
|
746
|
+
/** Set loop region using samples directly */
|
|
747
|
+
setLoopSamples(enabled, _startSample, endSample) {
|
|
539
748
|
this._loopEnabled = enabled;
|
|
540
|
-
this.
|
|
749
|
+
this._loopEndSamples = endSample;
|
|
541
750
|
}
|
|
542
751
|
updateTrack(trackId, track) {
|
|
543
752
|
this._tracks.set(trackId, { track, clips: track.clips });
|
|
544
753
|
this._silenceTrack(trackId);
|
|
545
754
|
}
|
|
546
|
-
generate(
|
|
755
|
+
generate(fromTick, toTick) {
|
|
547
756
|
const events = [];
|
|
757
|
+
const fromSample = this._sampleTimeline.ticksToSamples(fromTick);
|
|
758
|
+
const toSample = this._sampleTimeline.ticksToSamples(toTick);
|
|
548
759
|
for (const [trackId, state] of this._tracks) {
|
|
549
760
|
for (const clip of state.clips) {
|
|
550
761
|
if (clip.durationSamples === 0) continue;
|
|
551
762
|
if (!clip.audioBuffer) continue;
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (this._loopEnabled && clipStartTime + duration > this._loopEnd) {
|
|
561
|
-
duration = this._loopEnd - clipStartTime;
|
|
763
|
+
const clipStartSample = clip.startSample;
|
|
764
|
+
if (clipStartSample < fromSample) continue;
|
|
765
|
+
if (clipStartSample >= toSample) continue;
|
|
766
|
+
const fadeInDurationSamples = clip.fadeIn ? clip.fadeIn.duration ?? 0 : 0;
|
|
767
|
+
const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
|
|
768
|
+
let durationSamples = clip.durationSamples;
|
|
769
|
+
if (this._loopEnabled && clipStartSample + durationSamples > this._loopEndSamples) {
|
|
770
|
+
durationSamples = this._loopEndSamples - clipStartSample;
|
|
562
771
|
}
|
|
772
|
+
const clipTick = this._sampleTimeline.samplesToTicks(clipStartSample);
|
|
563
773
|
events.push({
|
|
564
774
|
trackId,
|
|
565
775
|
clipId: clip.id,
|
|
566
776
|
audioBuffer: clip.audioBuffer,
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
777
|
+
tick: clipTick,
|
|
778
|
+
startSample: clipStartSample,
|
|
779
|
+
offsetSamples: clip.offsetSamples,
|
|
780
|
+
durationSamples,
|
|
570
781
|
gain: clip.gain,
|
|
571
|
-
|
|
572
|
-
|
|
782
|
+
fadeInDurationSamples,
|
|
783
|
+
fadeOutDurationSamples
|
|
573
784
|
});
|
|
574
785
|
}
|
|
575
786
|
}
|
|
@@ -583,18 +794,25 @@ var ClipPlayer = class {
|
|
|
583
794
|
);
|
|
584
795
|
return;
|
|
585
796
|
}
|
|
586
|
-
|
|
797
|
+
const sampleRate = this._sampleTimeline.sampleRate;
|
|
798
|
+
const offsetSeconds = event.offsetSamples / sampleRate;
|
|
799
|
+
const durationSeconds = event.durationSamples / sampleRate;
|
|
800
|
+
if (offsetSeconds >= event.audioBuffer.duration) {
|
|
801
|
+
console.warn(
|
|
802
|
+
"[waveform-playlist] ClipPlayer.consume: offset (" + offsetSeconds + "s) exceeds audioBuffer.duration (" + event.audioBuffer.duration + 's) for clipId "' + event.clipId + '" \u2014 clip will not play'
|
|
803
|
+
);
|
|
587
804
|
return;
|
|
588
805
|
}
|
|
589
806
|
const source = this._audioContext.createBufferSource();
|
|
590
807
|
source.buffer = event.audioBuffer;
|
|
591
|
-
const
|
|
808
|
+
const transportSeconds = this._tempoMap.ticksToSeconds(event.tick);
|
|
809
|
+
const when = this._toAudioTime(transportSeconds);
|
|
592
810
|
const gainNode = this._audioContext.createGain();
|
|
593
811
|
gainNode.gain.value = event.gain;
|
|
594
|
-
let fadeIn = event.
|
|
595
|
-
let fadeOut = event.
|
|
596
|
-
if (fadeIn + fadeOut >
|
|
597
|
-
const ratio =
|
|
812
|
+
let fadeIn = event.fadeInDurationSamples / sampleRate;
|
|
813
|
+
let fadeOut = event.fadeOutDurationSamples / sampleRate;
|
|
814
|
+
if (fadeIn + fadeOut > durationSeconds) {
|
|
815
|
+
const ratio = durationSeconds / (fadeIn + fadeOut);
|
|
598
816
|
fadeIn *= ratio;
|
|
599
817
|
fadeOut *= ratio;
|
|
600
818
|
}
|
|
@@ -603,9 +821,9 @@ var ClipPlayer = class {
|
|
|
603
821
|
gainNode.gain.linearRampToValueAtTime(event.gain, when + fadeIn);
|
|
604
822
|
}
|
|
605
823
|
if (fadeOut > 0) {
|
|
606
|
-
const fadeOutStart = when +
|
|
824
|
+
const fadeOutStart = when + durationSeconds - fadeOut;
|
|
607
825
|
gainNode.gain.setValueAtTime(event.gain, fadeOutStart);
|
|
608
|
-
gainNode.gain.linearRampToValueAtTime(0, when +
|
|
826
|
+
gainNode.gain.linearRampToValueAtTime(0, when + durationSeconds);
|
|
609
827
|
}
|
|
610
828
|
source.connect(gainNode);
|
|
611
829
|
gainNode.connect(trackNode.input);
|
|
@@ -621,33 +839,37 @@ var ClipPlayer = class {
|
|
|
621
839
|
console.warn("[waveform-playlist] ClipPlayer: error disconnecting gain node:", String(err));
|
|
622
840
|
}
|
|
623
841
|
});
|
|
624
|
-
source.start(when,
|
|
842
|
+
source.start(when, offsetSeconds, durationSeconds);
|
|
625
843
|
}
|
|
626
|
-
onPositionJump(
|
|
844
|
+
onPositionJump(newTick) {
|
|
627
845
|
this.silence();
|
|
846
|
+
const newSample = this._sampleTimeline.ticksToSamples(newTick);
|
|
628
847
|
for (const [trackId, state] of this._tracks) {
|
|
629
848
|
for (const clip of state.clips) {
|
|
630
849
|
if (clip.durationSamples === 0) continue;
|
|
631
850
|
if (!clip.audioBuffer) continue;
|
|
632
|
-
const
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
851
|
+
const clipStartSample = clip.startSample;
|
|
852
|
+
const clipEndSample = clipStartSample + clip.durationSamples;
|
|
853
|
+
if (clipStartSample <= newSample && clipEndSample > newSample) {
|
|
854
|
+
const offsetIntoClipSamples = newSample - clipStartSample;
|
|
855
|
+
const offsetSamples = clip.offsetSamples + offsetIntoClipSamples;
|
|
856
|
+
let durationSamples = clipEndSample - newSample;
|
|
857
|
+
if (this._loopEnabled && newSample + durationSamples > this._loopEndSamples) {
|
|
858
|
+
durationSamples = this._loopEndSamples - newSample;
|
|
859
|
+
}
|
|
860
|
+
if (durationSamples <= 0) continue;
|
|
861
|
+
const fadeOutDurationSamples = clip.fadeOut ? clip.fadeOut.duration ?? 0 : 0;
|
|
641
862
|
this.consume({
|
|
642
863
|
trackId,
|
|
643
864
|
clipId: clip.id,
|
|
644
865
|
audioBuffer: clip.audioBuffer,
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
866
|
+
tick: newTick,
|
|
867
|
+
startSample: newSample,
|
|
868
|
+
offsetSamples,
|
|
869
|
+
durationSamples,
|
|
648
870
|
gain: clip.gain,
|
|
649
|
-
|
|
650
|
-
|
|
871
|
+
fadeInDurationSamples: 0,
|
|
872
|
+
fadeOutDurationSamples
|
|
651
873
|
});
|
|
652
874
|
}
|
|
653
875
|
}
|
|
@@ -720,31 +942,29 @@ var MetronomePlayer = class {
|
|
|
720
942
|
this._accentBuffer = accent;
|
|
721
943
|
this._normalBuffer = normal;
|
|
722
944
|
}
|
|
723
|
-
generate(
|
|
945
|
+
generate(fromTick, toTick) {
|
|
724
946
|
if (!this._enabled || !this._accentBuffer || !this._normalBuffer) {
|
|
725
947
|
return [];
|
|
726
948
|
}
|
|
727
949
|
const events = [];
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
let beatSize = this._meterMap.ticksPerBeat(fromTicks);
|
|
732
|
-
const tickIntoSection = fromTicks - entry.tick;
|
|
950
|
+
let entry = this._meterMap.getEntryAt(fromTick);
|
|
951
|
+
let beatSize = this._meterMap.ticksPerBeat(fromTick);
|
|
952
|
+
const tickIntoSection = fromTick - entry.tick;
|
|
733
953
|
let tick = entry.tick + Math.ceil(tickIntoSection / beatSize) * beatSize;
|
|
734
|
-
while (tick <
|
|
735
|
-
const
|
|
954
|
+
while (tick < toTick) {
|
|
955
|
+
const tickPos = tick;
|
|
956
|
+
const currentEntry = this._meterMap.getEntryAt(tickPos);
|
|
736
957
|
if (currentEntry.tick !== entry.tick) {
|
|
737
958
|
entry = currentEntry;
|
|
738
|
-
beatSize = this._meterMap.ticksPerBeat(
|
|
959
|
+
beatSize = this._meterMap.ticksPerBeat(tickPos);
|
|
739
960
|
}
|
|
740
|
-
const isAccent = this._meterMap.isBarBoundary(
|
|
741
|
-
const transportTime = this._tempoMap.ticksToSeconds(tick);
|
|
961
|
+
const isAccent = this._meterMap.isBarBoundary(tickPos);
|
|
742
962
|
events.push({
|
|
743
|
-
|
|
963
|
+
tick: tickPos,
|
|
744
964
|
isAccent,
|
|
745
965
|
buffer: isAccent ? this._accentBuffer : this._normalBuffer
|
|
746
966
|
});
|
|
747
|
-
beatSize = this._meterMap.ticksPerBeat(
|
|
967
|
+
beatSize = this._meterMap.ticksPerBeat(tickPos);
|
|
748
968
|
tick += beatSize;
|
|
749
969
|
}
|
|
750
970
|
return events;
|
|
@@ -765,10 +985,10 @@ var MetronomePlayer = class {
|
|
|
765
985
|
);
|
|
766
986
|
}
|
|
767
987
|
});
|
|
768
|
-
|
|
988
|
+
const transportTime = this._tempoMap.ticksToSeconds(event.tick);
|
|
989
|
+
source.start(this._toAudioTime(transportTime));
|
|
769
990
|
}
|
|
770
|
-
onPositionJump(
|
|
771
|
-
this.silence();
|
|
991
|
+
onPositionJump(_newTick) {
|
|
772
992
|
}
|
|
773
993
|
silence() {
|
|
774
994
|
for (const source of this._activeSources) {
|
|
@@ -801,6 +1021,9 @@ var Transport = class _Transport {
|
|
|
801
1021
|
this._soloedTrackIds = /* @__PURE__ */ new Set();
|
|
802
1022
|
this._mutedTrackIds = /* @__PURE__ */ new Set();
|
|
803
1023
|
this._playing = false;
|
|
1024
|
+
this._loopEnabled = false;
|
|
1025
|
+
this._loopStartTick = 0;
|
|
1026
|
+
this._loopStartSeconds = 0;
|
|
804
1027
|
this._listeners = /* @__PURE__ */ new Map();
|
|
805
1028
|
this._audioContext = audioContext;
|
|
806
1029
|
const sampleRate = options.sampleRate ?? audioContext.sampleRate;
|
|
@@ -811,15 +1034,17 @@ var Transport = class _Transport {
|
|
|
811
1034
|
const lookahead = options.schedulerLookahead ?? 0.2;
|
|
812
1035
|
_Transport._validateOptions(sampleRate, ppqn, tempo, numerator, denominator, lookahead);
|
|
813
1036
|
this._clock = new Clock(audioContext);
|
|
814
|
-
this._scheduler = new Scheduler({
|
|
815
|
-
lookahead,
|
|
816
|
-
onLoop: (loopStartTime) => {
|
|
817
|
-
this._clock.seekTo(loopStartTime);
|
|
818
|
-
}
|
|
819
|
-
});
|
|
820
1037
|
this._sampleTimeline = new SampleTimeline(sampleRate);
|
|
821
1038
|
this._meterMap = new MeterMap(ppqn, numerator, denominator);
|
|
822
1039
|
this._tempoMap = new TempoMap(ppqn, tempo);
|
|
1040
|
+
this._scheduler = new Scheduler(this._tempoMap, {
|
|
1041
|
+
lookahead,
|
|
1042
|
+
onLoop: (loopStartSeconds, loopEndSeconds, currentTimeSeconds) => {
|
|
1043
|
+
const timeToBoundary = loopEndSeconds - currentTimeSeconds;
|
|
1044
|
+
this._clock.seekTo(loopStartSeconds - timeToBoundary);
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
this._sampleTimeline.setTempoMap(this._tempoMap);
|
|
823
1048
|
this._initAudioGraph(audioContext);
|
|
824
1049
|
this._timer = new Timer(() => {
|
|
825
1050
|
const time = this._clock.getTime();
|
|
@@ -843,7 +1068,8 @@ var Transport = class _Transport {
|
|
|
843
1068
|
this._scheduler.reset(currentTime);
|
|
844
1069
|
this._endTime = endTime;
|
|
845
1070
|
this._clock.start();
|
|
846
|
-
this.
|
|
1071
|
+
const currentTick = this._tempoMap.secondsToTicks(currentTime);
|
|
1072
|
+
this._clipPlayer.onPositionJump(currentTick);
|
|
847
1073
|
this._timer.start();
|
|
848
1074
|
this._playing = true;
|
|
849
1075
|
this._emit("play");
|
|
@@ -879,12 +1105,17 @@ var Transport = class _Transport {
|
|
|
879
1105
|
this._endTime = void 0;
|
|
880
1106
|
if (wasPlaying) {
|
|
881
1107
|
this._clock.start();
|
|
882
|
-
this.
|
|
1108
|
+
const seekTick = this._tempoMap.secondsToTicks(time);
|
|
1109
|
+
this._clipPlayer.onPositionJump(seekTick);
|
|
883
1110
|
this._timer.start();
|
|
884
1111
|
}
|
|
885
1112
|
}
|
|
886
1113
|
getCurrentTime() {
|
|
887
|
-
|
|
1114
|
+
const t = this._clock.getTime();
|
|
1115
|
+
if (this._loopEnabled && t < this._loopStartSeconds) {
|
|
1116
|
+
return this._loopStartSeconds;
|
|
1117
|
+
}
|
|
1118
|
+
return t;
|
|
888
1119
|
}
|
|
889
1120
|
isPlaying() {
|
|
890
1121
|
return this._playing;
|
|
@@ -1000,20 +1231,56 @@ var Transport = class _Transport {
|
|
|
1000
1231
|
this._masterNode.setVolume(volume);
|
|
1001
1232
|
}
|
|
1002
1233
|
// --- Loop ---
|
|
1003
|
-
|
|
1004
|
-
|
|
1234
|
+
/** Primary loop API — ticks as source of truth */
|
|
1235
|
+
setLoop(enabled, startTick, endTick) {
|
|
1236
|
+
if (enabled && startTick >= endTick) {
|
|
1237
|
+
console.warn(
|
|
1238
|
+
"[waveform-playlist] Transport.setLoop: startTick (" + startTick + ") must be less than endTick (" + endTick + ")"
|
|
1239
|
+
);
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
this._loopEnabled = enabled;
|
|
1243
|
+
this._loopStartTick = startTick;
|
|
1244
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
|
|
1245
|
+
this._scheduler.setLoop(enabled, startTick, endTick);
|
|
1246
|
+
this._clipPlayer.setLoop(enabled, startTick, endTick);
|
|
1247
|
+
this._emit("loop");
|
|
1248
|
+
}
|
|
1249
|
+
/** Convenience — converts seconds to ticks */
|
|
1250
|
+
setLoopSeconds(enabled, startSec, endSec) {
|
|
1251
|
+
const startTick = this._tempoMap.secondsToTicks(startSec);
|
|
1252
|
+
const endTick = this._tempoMap.secondsToTicks(endSec);
|
|
1253
|
+
this.setLoop(enabled, startTick, endTick);
|
|
1254
|
+
}
|
|
1255
|
+
/** Convenience — sets loop in samples */
|
|
1256
|
+
setLoopSamples(enabled, startSample, endSample) {
|
|
1257
|
+
if (enabled && (!Number.isFinite(startSample) || !Number.isFinite(endSample))) {
|
|
1258
|
+
console.warn(
|
|
1259
|
+
"[waveform-playlist] Transport.setLoopSamples: non-finite sample values (" + startSample + ", " + endSample + ")"
|
|
1260
|
+
);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (enabled && startSample >= endSample) {
|
|
1005
1264
|
console.warn(
|
|
1006
|
-
"[waveform-playlist] Transport.
|
|
1265
|
+
"[waveform-playlist] Transport.setLoopSamples: startSample (" + startSample + ") must be less than endSample (" + endSample + ")"
|
|
1007
1266
|
);
|
|
1008
1267
|
return;
|
|
1009
1268
|
}
|
|
1010
|
-
this.
|
|
1011
|
-
this.
|
|
1269
|
+
const startTick = this._sampleTimeline.samplesToTicks(startSample);
|
|
1270
|
+
const endTick = this._sampleTimeline.samplesToTicks(endSample);
|
|
1271
|
+
this._loopEnabled = enabled;
|
|
1272
|
+
this._loopStartTick = startTick;
|
|
1273
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(startTick);
|
|
1274
|
+
this._clipPlayer.setLoopSamples(enabled, startSample, endSample);
|
|
1275
|
+
this._scheduler.setLoop(enabled, startTick, endTick);
|
|
1012
1276
|
this._emit("loop");
|
|
1013
1277
|
}
|
|
1014
1278
|
// --- Tempo ---
|
|
1015
|
-
setTempo(bpm, atTick) {
|
|
1016
|
-
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
|
+
}
|
|
1017
1284
|
this._emit("tempochange");
|
|
1018
1285
|
}
|
|
1019
1286
|
getTempo(atTick) {
|
|
@@ -1037,6 +1304,9 @@ var Transport = class _Transport {
|
|
|
1037
1304
|
}
|
|
1038
1305
|
clearTempos() {
|
|
1039
1306
|
this._tempoMap.clearTempos();
|
|
1307
|
+
if (this._loopEnabled) {
|
|
1308
|
+
this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
|
|
1309
|
+
}
|
|
1040
1310
|
this._emit("tempochange");
|
|
1041
1311
|
}
|
|
1042
1312
|
barToTick(bar) {
|
|
@@ -1132,7 +1402,12 @@ var Transport = class _Transport {
|
|
|
1132
1402
|
this._masterNode = new MasterNode(audioContext);
|
|
1133
1403
|
this._masterNode.output.connect(audioContext.destination);
|
|
1134
1404
|
const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
|
|
1135
|
-
this._clipPlayer = new ClipPlayer(
|
|
1405
|
+
this._clipPlayer = new ClipPlayer(
|
|
1406
|
+
audioContext,
|
|
1407
|
+
this._sampleTimeline,
|
|
1408
|
+
this._tempoMap,
|
|
1409
|
+
toAudioTime
|
|
1410
|
+
);
|
|
1136
1411
|
this._metronomePlayer = new MetronomePlayer(
|
|
1137
1412
|
audioContext,
|
|
1138
1413
|
this._tempoMap,
|
|
@@ -1235,7 +1510,7 @@ var NativePlayoutAdapter = class {
|
|
|
1235
1510
|
this._transport.setTrackPan(trackId, pan);
|
|
1236
1511
|
}
|
|
1237
1512
|
setLoop(enabled, start, end) {
|
|
1238
|
-
this._transport.
|
|
1513
|
+
this._transport.setLoopSeconds(enabled, start, end);
|
|
1239
1514
|
}
|
|
1240
1515
|
dispose() {
|
|
1241
1516
|
this._transport.dispose();
|