@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/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._scheduleFrame();
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
- const entry = this._entryAt(atTick);
262
- return entry.bpm;
263
- }
264
- setTempo(bpm, atTick = 0) {
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 entry = this._entryAt(ticks);
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
- _entryAt(tick) {
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 this._entries[lo];
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 secondsPerTick = 60 / (prev.bpm * this._ppqn);
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
- ...this._entries[i],
336
- secondsAtTick: prev.secondsAtTick + tickDelta * secondsPerTick
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 Transport = class _Transport {
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
- if (wasPlaying) {
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._emit("tempochange");
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._emit("meterchange");
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._emit("meterchange");
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._emit("tempochange");
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
  }