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