@dawcore/transport 0.0.11 → 0.0.13

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 CHANGED
@@ -10,7 +10,7 @@ Native Web Audio transport for multi-track audio scheduling, looping, tempo, and
10
10
  - **Built-in metronome** — Beat-grid click scheduling with accent on beat 1. Default synthesized click sounds out of the box.
11
11
  - **Count-in (pre-roll)** — Configurable bars of click sounds before playback begins. Beat-by-beat events for UI countdown.
12
12
  - **Per-track signal chain** — Native GainNode (volume) → StereoPannerNode → GainNode (mute) → effects hook → master output.
13
- - **Effects plugin hook** — `connectTrackOutput(trackId, node)` inserts any `AudioNode` chain (Tone.js effects, WAM plugins, native nodes).
13
+ - **Effects plugin hook** — `connectTrackOutput(trackId, node)` and `connectMasterOutput(node)` insert any `AudioNode` chain (Tone.js effects, WAM plugins, native nodes) per-track or on the master bus.
14
14
  - **Type-safe coordinates** — Branded `Tick` and `Sample` types prevent accidentally passing seconds where ticks or samples are expected. Zero runtime cost.
15
15
  - **PlayoutAdapter bridge** — `NativePlayoutAdapter` implements the `PlayoutAdapter` interface from `@waveform-playlist/engine`.
16
16
 
@@ -55,9 +55,12 @@ import { NativePlayoutAdapter } from '@dawcore/transport';
55
55
  const audioContext = new AudioContext({ sampleRate: 48000 });
56
56
  const adapter = new NativePlayoutAdapter(audioContext);
57
57
 
58
- // Use as daw-editor's adapter factory
58
+ // Use as daw-editor's playout adapter
59
59
  const editor = document.querySelector('daw-editor');
60
- editor.adapterFactory = () => new NativePlayoutAdapter(audioContext);
60
+ editor.adapter = adapter;
61
+
62
+ // Transport-specific APIs stay available on your adapter reference
63
+ adapter.transport.setMetronomeEnabled(true);
61
64
  ```
62
65
 
63
66
  ### Metronome
@@ -156,6 +159,17 @@ transport.connectTrackOutput('vocals', reverb);
156
159
 
157
160
  // Remove effects — restores direct routing to master
158
161
  transport.disconnectTrackOutput('vocals');
162
+
163
+ // Master bus effects — inserted between master gain and destination
164
+ const compressor = audioContext.createDynamicsCompressor();
165
+ compressor.connect(audioContext.destination);
166
+
167
+ transport.connectMasterOutput(compressor);
168
+
169
+ // Remove master effects — restores direct routing to destination.
170
+ // Parallel taps on transport.masterOutputNode (analyzers, recorders)
171
+ // are unaffected by connect/disconnect.
172
+ transport.disconnectMasterOutput();
159
173
  ```
160
174
 
161
175
  ## API
@@ -205,7 +219,8 @@ new Transport(audioContext: AudioContext, options?: TransportOptions)
205
219
  - `setLoopSamples(enabled, startSample: Sample, endSample: Sample)` — Set loop region in samples (convenience)
206
220
 
207
221
  **Tempo & Meter:**
208
- - `setTempo(bpm, atTick?, options?)` / `getTempo(atTick?: Tick)` — options: `{ interpolation: 'step' | 'linear' | { type: 'curve', slope } }`
222
+ - `setTempo(bpm, atTick?, options?)` / `getTempo(atTick?: Tick)` — options: `{ interpolation: 'step' | 'linear' | { type: 'curve', slope } }`. Returns `boolean`: a defaulted `atTick` is the single-BPM convenience path and is refused (with a warning) when the tempo map has more than one entry — pass an explicit `atTick` to modify a tempo curve.
223
+ - `removeTempo(atTick: Tick)` — remove the tempo entry at a tick (the tick-0 entry cannot be removed)
209
224
  - `clearTempos()` — remove all tempo entries
210
225
  - `setMeter(numerator, denominator, atTick?: Tick)` / `getMeter(atTick?: Tick)`
211
226
  - `removeMeter(atTick: Tick)` / `clearMeters()`
@@ -224,12 +239,16 @@ new Transport(audioContext: AudioContext, options?: TransportOptions)
224
239
  - `isCountingIn()` — whether count-in is active
225
240
 
226
241
  **Effects:**
227
- - `connectTrackOutput(trackId, node)` — Insert effects chain
228
- - `disconnectTrackOutput(trackId)` — Remove effects chain
242
+ - `connectTrackOutput(trackId, node)` — Insert per-track effects chain
243
+ - `disconnectTrackOutput(trackId)` — Remove per-track effects chain
244
+ - `connectMasterOutput(node)` — Insert master bus effects chain
245
+ - `disconnectMasterOutput()` — Remove master bus effects chain
246
+ - `masterOutputNode` (getter) — Master gain node, for parallel taps (analyzers, recorders) that should survive chain connect/disconnect
229
247
 
230
248
  **Events:**
231
249
  - `on(event, callback)` / `off(event, callback)`
232
- - Events: `play`, `pause`, `stop`, `loop`, `tempochange`, `meterchange`, `countIn`, `countInEnd`
250
+ - Events: `play`, `pause`, `stop`, `seek`, `loop`, `tempochange`, `meterchange`, `countIn`, `countInEnd`
251
+ - `seek` payload: `{ seconds: number }`
233
252
  - `tempochange` payload: `{ bpm: number, atTick: Tick }`
234
253
  - `meterchange` payload: `{ numerator: number, denominator: number, atTick: Tick }`
235
254
  - `countIn` payload: `{ beat: number, totalBeats: number }`
@@ -245,15 +264,27 @@ new NativePlayoutAdapter(audioContext: AudioContext, options?: TransportOptions)
245
264
 
246
265
  Implements `PlayoutAdapter` from `@waveform-playlist/engine`. All methods delegate to the internal `Transport` instance.
247
266
 
248
- - `adapter.transport` — Direct access to the `Transport` for tempo, metronome, and effects APIs
267
+ - `adapter.transport` — Direct access to the `Transport` for tempo, metronome, count-in, and effects APIs
268
+ - `adapter.ppqn` — Tick resolution, read by the engine on construction
269
+ - `adapter.masterOutputNode` — Master gain node for parallel taps (analyzers, recorders)
270
+ - `adapter.init()` — Resumes a suspended AudioContext and waits for the hardware pipeline to warm up (Safari needs this before clips scheduled at time 0 play on time)
271
+ - `setTempo(bpm, atTick?)`, `setMeter(numerator, denominator, atTick?)`, `ticksToSeconds(tick)`, `secondsToTicks(seconds)` — tempo/meter surface the engine uses for tick-based timeline math
272
+
273
+ ## Examples
274
+
275
+ [`examples/dawcore-native/`](https://github.com/naomiaro/waveform-playlist/tree/main/examples/dawcore-native) pairs this transport with the `@dawcore/components` editor: metronome, tempo automation, mixed meter, beat-map grids, effects, and recording pages (`pnpm example:dawcore-native`).
249
276
 
250
277
  ## Architecture
251
278
 
252
- See [TRANSPORT.md](./TRANSPORT.md) for the full architecture guide.
279
+ See [TRANSPORT.md](https://github.com/naomiaro/waveform-playlist/blob/main/packages/transport/TRANSPORT.md) for the full architecture guide.
253
280
 
254
281
  ## How It Works
255
282
 
256
- See [EDUCATIONAL.md](./EDUCATIONAL.md) for an in-depth explanation of the math and timing models behind audio transport systems.
283
+ See [EDUCATIONAL.md](https://github.com/naomiaro/waveform-playlist/blob/main/packages/transport/EDUCATIONAL.md) for an in-depth explanation of the math and timing models behind audio transport systems.
284
+
285
+ ## Documentation
286
+
287
+ Full guides at [naomiaro.github.io/waveform-playlist](https://naomiaro.github.io/waveform-playlist/).
257
288
 
258
289
  ## License
259
290
 
package/dist/index.d.mts CHANGED
@@ -147,12 +147,21 @@ declare class TempoMap {
147
147
  * in secondsToTicks) — reject it at the boundary instead. */
148
148
  private static _validateBpm;
149
149
  getTempo(atTick?: Tick): number;
150
+ /** Number of tempo entries in the map. Always >= 1 — the tick-0 entry is
151
+ * permanent. Used by Transport.setTempo to detect multi-entry maps. */
152
+ get entryCount(): number;
150
153
  setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
151
154
  ticksToSeconds(ticks: Tick): number;
152
155
  secondsToTicks(seconds: number): Tick;
153
156
  beatsToSeconds(beats: number): number;
154
157
  secondsToBeats(seconds: number): number;
155
158
  clearTempos(): void;
159
+ /** Remove the tempo entry at exactly `atTick`, if one exists. The tick-0
160
+ * entry is permanent (a map must always have a tempo) — removing it is a
161
+ * no-op, matching removeMeter's treatment of the initial meter. The
162
+ * seconds cache is recomputed from the removal point, the same partial
163
+ * update setTempo uses. */
164
+ removeTempo(atTick: Tick): void;
156
165
  /** Get the interpolated BPM at a tick position */
157
166
  private _getTempoAt;
158
167
  private _ticksToSecondsInternal;
@@ -272,9 +281,22 @@ declare class MeterMap {
272
281
 
273
282
  declare class MasterNode {
274
283
  private _gainNode;
284
+ private _destination;
285
+ private _effectsInput;
275
286
  constructor(audioContext: AudioContext);
276
287
  get input(): AudioNode;
277
288
  get output(): AudioNode;
289
+ /** Connect the master output to its final destination (audioContext.destination) */
290
+ connectOutput(destination: AudioNode): void;
291
+ /**
292
+ * Insert an effects chain between the master gain and the destination.
293
+ * Only the destination edge is severed (targeted disconnect), so parallel
294
+ * taps on the master output (analyzers, recorders) keep working.
295
+ * The caller is responsible for routing the chain's output onward.
296
+ */
297
+ connectEffects(effectsInput: AudioNode): void;
298
+ /** Remove the effects chain and restore direct routing to the destination */
299
+ disconnectEffects(): void;
278
300
  setVolume(value: number): void;
279
301
  dispose(): void;
280
302
  }
@@ -384,6 +406,9 @@ interface TransportEvents {
384
406
  pause: () => void;
385
407
  stop: () => void;
386
408
  loop: () => void;
409
+ seek: (event: {
410
+ seconds: number;
411
+ }) => void;
387
412
  tempochange: (event: TempoChangeEventData) => void;
388
413
  meterchange: (event: MeterChangeEventData) => void;
389
414
  countIn: (event: CountInEventData) => void;
@@ -448,13 +473,21 @@ declare class Transport {
448
473
  setLoopSeconds(enabled: boolean, startSec: number, endSec: number): void;
449
474
  /** Convenience — sets loop in samples */
450
475
  setLoopSamples(enabled: boolean, startSample: Sample, endSample: Sample): void;
451
- setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
476
+ /** Returns true when the tempo was applied, false when a defaulted (no
477
+ * atTick) write was refused because the tempo map has multiple entries —
478
+ * pass an explicit atTick to modify a multi-entry map (#407). */
479
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): boolean;
452
480
  getTempo(atTick?: Tick): number;
453
481
  setMeter(numerator: number, denominator: number, atTick?: Tick): void;
454
482
  getMeter(atTick?: Tick): MeterSignature;
455
483
  removeMeter(atTick: Tick): void;
456
484
  clearMeters(): void;
457
485
  clearTempos(): void;
486
+ /** Remove the tempo entry at exactly `atTick` (tick 0 is permanent — a
487
+ * no-op, like removeMeter's initial entry). Mirrors setTempo's loop-cache
488
+ * invalidation and event; the emitted bpm is the tempo now in force at
489
+ * the removed position. */
490
+ removeTempo(atTick: Tick): void;
458
491
  barToTick(bar: number): Tick;
459
492
  tickToBar(tick: Tick): number;
460
493
  /** Convert transport time (seconds) to tick position, using the tempo map. */
@@ -475,6 +508,16 @@ declare class Transport {
475
508
  /** The master output AudioNode. Connect your own nodes (analyzers, recorders, etc.)
476
509
  * in parallel. The transport already routes this to audioContext.destination. */
477
510
  get masterOutputNode(): AudioNode;
511
+ /**
512
+ * Insert an effects chain on the master bus, between the master gain and
513
+ * audioContext.destination. Accepts any AudioNode chain (Tone.js effects,
514
+ * WAM plugins, native nodes). The caller is responsible for connecting the
515
+ * chain's output to audioContext.destination. Calling again replaces the
516
+ * previous chain. Parallel taps on `masterOutputNode` are unaffected.
517
+ */
518
+ connectMasterOutput(node: AudioNode): void;
519
+ /** Remove the master effects chain and restore direct routing to audioContext.destination. */
520
+ disconnectMasterOutput(): void;
478
521
  on<K extends TransportEventType>(event: K, cb: TransportEvents[K]): void;
479
522
  off<K extends TransportEventType>(event: K, cb: TransportEvents[K]): void;
480
523
  dispose(): void;
@@ -519,7 +562,7 @@ declare class NativePlayoutAdapter implements PlayoutAdapter {
519
562
  setCountInMode(mode: CountInMode): void;
520
563
  setRecording(recording: boolean): void;
521
564
  isCountingIn(): boolean;
522
- setTempo(bpm: number, atTick?: number): void;
565
+ setTempo(bpm: number, atTick?: number): boolean;
523
566
  setMeter(numerator: number, denominator: number, atTick?: number): void;
524
567
  ticksToSeconds(tick: number): number;
525
568
  secondsToTicks(seconds: number): number;
package/dist/index.d.ts CHANGED
@@ -147,12 +147,21 @@ declare class TempoMap {
147
147
  * in secondsToTicks) — reject it at the boundary instead. */
148
148
  private static _validateBpm;
149
149
  getTempo(atTick?: Tick): number;
150
+ /** Number of tempo entries in the map. Always >= 1 — the tick-0 entry is
151
+ * permanent. Used by Transport.setTempo to detect multi-entry maps. */
152
+ get entryCount(): number;
150
153
  setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
151
154
  ticksToSeconds(ticks: Tick): number;
152
155
  secondsToTicks(seconds: number): Tick;
153
156
  beatsToSeconds(beats: number): number;
154
157
  secondsToBeats(seconds: number): number;
155
158
  clearTempos(): void;
159
+ /** Remove the tempo entry at exactly `atTick`, if one exists. The tick-0
160
+ * entry is permanent (a map must always have a tempo) — removing it is a
161
+ * no-op, matching removeMeter's treatment of the initial meter. The
162
+ * seconds cache is recomputed from the removal point, the same partial
163
+ * update setTempo uses. */
164
+ removeTempo(atTick: Tick): void;
156
165
  /** Get the interpolated BPM at a tick position */
157
166
  private _getTempoAt;
158
167
  private _ticksToSecondsInternal;
@@ -272,9 +281,22 @@ declare class MeterMap {
272
281
 
273
282
  declare class MasterNode {
274
283
  private _gainNode;
284
+ private _destination;
285
+ private _effectsInput;
275
286
  constructor(audioContext: AudioContext);
276
287
  get input(): AudioNode;
277
288
  get output(): AudioNode;
289
+ /** Connect the master output to its final destination (audioContext.destination) */
290
+ connectOutput(destination: AudioNode): void;
291
+ /**
292
+ * Insert an effects chain between the master gain and the destination.
293
+ * Only the destination edge is severed (targeted disconnect), so parallel
294
+ * taps on the master output (analyzers, recorders) keep working.
295
+ * The caller is responsible for routing the chain's output onward.
296
+ */
297
+ connectEffects(effectsInput: AudioNode): void;
298
+ /** Remove the effects chain and restore direct routing to the destination */
299
+ disconnectEffects(): void;
278
300
  setVolume(value: number): void;
279
301
  dispose(): void;
280
302
  }
@@ -384,6 +406,9 @@ interface TransportEvents {
384
406
  pause: () => void;
385
407
  stop: () => void;
386
408
  loop: () => void;
409
+ seek: (event: {
410
+ seconds: number;
411
+ }) => void;
387
412
  tempochange: (event: TempoChangeEventData) => void;
388
413
  meterchange: (event: MeterChangeEventData) => void;
389
414
  countIn: (event: CountInEventData) => void;
@@ -448,13 +473,21 @@ declare class Transport {
448
473
  setLoopSeconds(enabled: boolean, startSec: number, endSec: number): void;
449
474
  /** Convenience — sets loop in samples */
450
475
  setLoopSamples(enabled: boolean, startSample: Sample, endSample: Sample): void;
451
- setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): void;
476
+ /** Returns true when the tempo was applied, false when a defaulted (no
477
+ * atTick) write was refused because the tempo map has multiple entries —
478
+ * pass an explicit atTick to modify a multi-entry map (#407). */
479
+ setTempo(bpm: number, atTick?: Tick, options?: SetTempoOptions): boolean;
452
480
  getTempo(atTick?: Tick): number;
453
481
  setMeter(numerator: number, denominator: number, atTick?: Tick): void;
454
482
  getMeter(atTick?: Tick): MeterSignature;
455
483
  removeMeter(atTick: Tick): void;
456
484
  clearMeters(): void;
457
485
  clearTempos(): void;
486
+ /** Remove the tempo entry at exactly `atTick` (tick 0 is permanent — a
487
+ * no-op, like removeMeter's initial entry). Mirrors setTempo's loop-cache
488
+ * invalidation and event; the emitted bpm is the tempo now in force at
489
+ * the removed position. */
490
+ removeTempo(atTick: Tick): void;
458
491
  barToTick(bar: number): Tick;
459
492
  tickToBar(tick: Tick): number;
460
493
  /** Convert transport time (seconds) to tick position, using the tempo map. */
@@ -475,6 +508,16 @@ declare class Transport {
475
508
  /** The master output AudioNode. Connect your own nodes (analyzers, recorders, etc.)
476
509
  * in parallel. The transport already routes this to audioContext.destination. */
477
510
  get masterOutputNode(): AudioNode;
511
+ /**
512
+ * Insert an effects chain on the master bus, between the master gain and
513
+ * audioContext.destination. Accepts any AudioNode chain (Tone.js effects,
514
+ * WAM plugins, native nodes). The caller is responsible for connecting the
515
+ * chain's output to audioContext.destination. Calling again replaces the
516
+ * previous chain. Parallel taps on `masterOutputNode` are unaffected.
517
+ */
518
+ connectMasterOutput(node: AudioNode): void;
519
+ /** Remove the master effects chain and restore direct routing to audioContext.destination. */
520
+ disconnectMasterOutput(): void;
478
521
  on<K extends TransportEventType>(event: K, cb: TransportEvents[K]): void;
479
522
  off<K extends TransportEventType>(event: K, cb: TransportEvents[K]): void;
480
523
  dispose(): void;
@@ -519,7 +562,7 @@ declare class NativePlayoutAdapter implements PlayoutAdapter {
519
562
  setCountInMode(mode: CountInMode): void;
520
563
  setRecording(recording: boolean): void;
521
564
  isCountingIn(): boolean;
522
- setTempo(bpm: number, atTick?: number): void;
565
+ setTempo(bpm: number, atTick?: number): boolean;
523
566
  setMeter(numerator: number, denominator: number, atTick?: number): void;
524
567
  ticksToSeconds(tick: number): number;
525
568
  secondsToTicks(seconds: number): number;
package/dist/index.js CHANGED
@@ -249,7 +249,7 @@ var SampleTimeline = class {
249
249
  "[waveform-playlist] SampleTimeline: tempoMap not set \u2014 call setTempoMap() first"
250
250
  );
251
251
  }
252
- return this._tempoMap.secondsToTicks(samples / this._sampleRate);
252
+ return Math.round(this._tempoMap.secondsToTicks(samples / this._sampleRate));
253
253
  }
254
254
  };
255
255
 
@@ -280,6 +280,11 @@ var TempoMap = class _TempoMap {
280
280
  getTempo(atTick = 0) {
281
281
  return this._getTempoAt(atTick);
282
282
  }
283
+ /** Number of tempo entries in the map. Always >= 1 — the tick-0 entry is
284
+ * permanent. Used by Transport.setTempo to detect multi-entry maps. */
285
+ get entryCount() {
286
+ return this._entries.length;
287
+ }
283
288
  setTempo(bpm, atTick = 0, options) {
284
289
  _TempoMap._validateBpm(bpm);
285
290
  const interpolation = options?.interpolation ?? "step";
@@ -358,6 +363,18 @@ var TempoMap = class _TempoMap {
358
363
  const first = this._entries[0];
359
364
  this._entries = [{ tick: 0, bpm: first.bpm, interpolation: "step", secondsAtTick: 0 }];
360
365
  }
366
+ /** Remove the tempo entry at exactly `atTick`, if one exists. The tick-0
367
+ * entry is permanent (a map must always have a tempo) — removing it is a
368
+ * no-op, matching removeMeter's treatment of the initial meter. The
369
+ * seconds cache is recomputed from the removal point, the same partial
370
+ * update setTempo uses. */
371
+ removeTempo(atTick) {
372
+ if (atTick === 0) return;
373
+ const i = this._entries.findIndex((e) => e.tick === atTick);
374
+ if (i === -1) return;
375
+ this._entries = [...this._entries.slice(0, i), ...this._entries.slice(i + 1)];
376
+ this._recomputeCache(i);
377
+ }
361
378
  /** Get the interpolated BPM at a tick position */
362
379
  _getTempoAt(atTick) {
363
380
  const entryIndex = this._entryIndexAt(atTick);
@@ -683,12 +700,19 @@ var MeterMap = class {
683
700
  "[waveform-playlist] MeterMap: denominator must be a power of 2 (1-32), got " + denominator
684
701
  );
685
702
  }
703
+ if (!Number.isInteger(this._ppqn * 4 / denominator)) {
704
+ throw new Error(
705
+ "[waveform-playlist] MeterMap: ppqn (" + this._ppqn + ") * 4 is not divisible by denominator (" + denominator + ") \u2014 bar boundaries would fall on fractional ticks"
706
+ );
707
+ }
686
708
  }
687
709
  };
688
710
 
689
711
  // src/audio/master-node.ts
690
712
  var MasterNode = class {
691
713
  constructor(audioContext) {
714
+ this._destination = null;
715
+ this._effectsInput = null;
692
716
  this._gainNode = audioContext.createGain();
693
717
  }
694
718
  get input() {
@@ -697,6 +721,37 @@ var MasterNode = class {
697
721
  get output() {
698
722
  return this._gainNode;
699
723
  }
724
+ /** Connect the master output to its final destination (audioContext.destination) */
725
+ connectOutput(destination) {
726
+ this._destination = destination;
727
+ this._gainNode.connect(destination);
728
+ }
729
+ /**
730
+ * Insert an effects chain between the master gain and the destination.
731
+ * Only the destination edge is severed (targeted disconnect), so parallel
732
+ * taps on the master output (analyzers, recorders) keep working.
733
+ * The caller is responsible for routing the chain's output onward.
734
+ */
735
+ connectEffects(effectsInput) {
736
+ if (this._effectsInput) {
737
+ this._gainNode.disconnect(this._effectsInput);
738
+ } else if (this._destination) {
739
+ this._gainNode.disconnect(this._destination);
740
+ }
741
+ this._gainNode.connect(effectsInput);
742
+ this._effectsInput = effectsInput;
743
+ }
744
+ /** Remove the effects chain and restore direct routing to the destination */
745
+ disconnectEffects() {
746
+ if (!this._effectsInput) {
747
+ return;
748
+ }
749
+ this._gainNode.disconnect(this._effectsInput);
750
+ if (this._destination) {
751
+ this._gainNode.connect(this._destination);
752
+ }
753
+ this._effectsInput = null;
754
+ }
700
755
  setVolume(value) {
701
756
  this._gainNode.gain.value = value;
702
757
  }
@@ -706,6 +761,8 @@ var MasterNode = class {
706
761
  } catch (err) {
707
762
  console.warn("[waveform-playlist] MasterNode.dispose: error disconnecting: " + String(err));
708
763
  }
764
+ this._destination = null;
765
+ this._effectsInput = null;
709
766
  }
710
767
  };
711
768
 
@@ -1356,6 +1413,7 @@ var _Transport = class _Transport {
1356
1413
  this._clipPlayer.onPositionJump(seekTick);
1357
1414
  this._timer.start();
1358
1415
  }
1416
+ this._emit("seek", { seconds: time });
1359
1417
  }
1360
1418
  getCurrentTime() {
1361
1419
  if (this._countingIn) {
@@ -1526,12 +1584,22 @@ var _Transport = class _Transport {
1526
1584
  this._emit("loop");
1527
1585
  }
1528
1586
  // --- Tempo ---
1587
+ /** Returns true when the tempo was applied, false when a defaulted (no
1588
+ * atTick) write was refused because the tempo map has multiple entries —
1589
+ * pass an explicit atTick to modify a multi-entry map (#407). */
1529
1590
  setTempo(bpm, atTick, options) {
1591
+ if (atTick === void 0 && this._tempoMap.entryCount > 1) {
1592
+ console.warn(
1593
+ "[waveform-playlist] Transport.setTempo: refusing defaulted tick-0 write of " + bpm + " BPM \u2014 the tempo map has " + this._tempoMap.entryCount + " entries. Pass an explicit atTick to modify a multi-entry tempo map."
1594
+ );
1595
+ return false;
1596
+ }
1530
1597
  this._tempoMap.setTempo(bpm, atTick, options);
1531
1598
  if (this._loopEnabled) {
1532
1599
  this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1533
1600
  }
1534
1601
  this._emit("tempochange", { bpm, atTick: atTick ?? 0 });
1602
+ return true;
1535
1603
  }
1536
1604
  getTempo(atTick) {
1537
1605
  return this._tempoMap.getTempo(atTick);
@@ -1569,6 +1637,17 @@ var _Transport = class _Transport {
1569
1637
  }
1570
1638
  this._emit("tempochange", { bpm: this._tempoMap.getTempo(), atTick: 0 });
1571
1639
  }
1640
+ /** Remove the tempo entry at exactly `atTick` (tick 0 is permanent — a
1641
+ * no-op, like removeMeter's initial entry). Mirrors setTempo's loop-cache
1642
+ * invalidation and event; the emitted bpm is the tempo now in force at
1643
+ * the removed position. */
1644
+ removeTempo(atTick) {
1645
+ this._tempoMap.removeTempo(atTick);
1646
+ if (this._loopEnabled) {
1647
+ this._loopStartSeconds = this._tempoMap.ticksToSeconds(this._loopStartTick);
1648
+ }
1649
+ this._emit("tempochange", { bpm: this._tempoMap.getTempo(atTick), atTick });
1650
+ }
1572
1651
  barToTick(bar) {
1573
1652
  return this._meterMap.barToTick(bar);
1574
1653
  }
@@ -1644,6 +1723,20 @@ var _Transport = class _Transport {
1644
1723
  get masterOutputNode() {
1645
1724
  return this._masterNode.output;
1646
1725
  }
1726
+ /**
1727
+ * Insert an effects chain on the master bus, between the master gain and
1728
+ * audioContext.destination. Accepts any AudioNode chain (Tone.js effects,
1729
+ * WAM plugins, native nodes). The caller is responsible for connecting the
1730
+ * chain's output to audioContext.destination. Calling again replaces the
1731
+ * previous chain. Parallel taps on `masterOutputNode` are unaffected.
1732
+ */
1733
+ connectMasterOutput(node) {
1734
+ this._masterNode.connectEffects(node);
1735
+ }
1736
+ /** Remove the master effects chain and restore direct routing to audioContext.destination. */
1737
+ disconnectMasterOutput() {
1738
+ this._masterNode.disconnectEffects();
1739
+ }
1647
1740
  // --- Events ---
1648
1741
  on(event, cb) {
1649
1742
  if (!this._listeners.has(event)) {
@@ -1697,7 +1790,7 @@ var _Transport = class _Transport {
1697
1790
  }
1698
1791
  _initAudioGraph(audioContext) {
1699
1792
  this._masterNode = new MasterNode(audioContext);
1700
- this._masterNode.output.connect(audioContext.destination);
1793
+ this._masterNode.connectOutput(audioContext.destination);
1701
1794
  const toAudioTime = (transportTime) => this._clock.toAudioTime(transportTime);
1702
1795
  this._clipPlayer = new ClipPlayer(
1703
1796
  audioContext,
@@ -1941,7 +2034,7 @@ var NativePlayoutAdapter = class {
1941
2034
  return this._transport.isCountingIn();
1942
2035
  }
1943
2036
  setTempo(bpm, atTick) {
1944
- this._transport.setTempo(bpm, atTick !== void 0 ? atTick : void 0);
2037
+ return this._transport.setTempo(bpm, atTick !== void 0 ? atTick : void 0);
1945
2038
  }
1946
2039
  setMeter(numerator, denominator, atTick) {
1947
2040
  this._transport.setMeter(