@weasel-js/audio 1.7.0 → 1.7.1

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
@@ -70,6 +70,33 @@ function createAnalyserTap(ctx, source, opts = {}) {
70
70
  };
71
71
  }
72
72
 
73
+ // src/audioTime.ts
74
+ var defaultTimers = {
75
+ setTimer: (cb, ms) => setTimeout(cb, ms),
76
+ clearTimer: (h) => clearTimeout(h)
77
+ };
78
+ function atAudioTime(ctx, timers, at, cb) {
79
+ let handle = null;
80
+ let live = true;
81
+ const check = () => {
82
+ handle = null;
83
+ if (!live) return;
84
+ const left = at - ctx.currentTime;
85
+ if (ctx.state === "running" && left > 1e-6) {
86
+ handle = timers.setTimer(check, Math.max(1, left * 1e3));
87
+ return;
88
+ }
89
+ live = false;
90
+ cb();
91
+ };
92
+ check();
93
+ return () => {
94
+ live = false;
95
+ if (handle !== null) timers.clearTimer(handle);
96
+ handle = null;
97
+ };
98
+ }
99
+
73
100
  // src/param.ts
74
101
  function writeParam(ctx, param, value, rampMs) {
75
102
  const now = ctx.currentTime;
@@ -82,15 +109,224 @@ function writeParam(ctx, param, value, rampMs) {
82
109
  }
83
110
  }
84
111
 
112
+ // src/inserts.ts
113
+ function createInsertChain(ctx, head, tail, opts = {}) {
114
+ const fadeMs = opts.fadeMs ?? 15;
115
+ const timers = {
116
+ setTimer: opts.setTimer ?? defaultTimers.setTimer,
117
+ clearTimer: opts.clearTimer ?? defaultTimers.clearTimer
118
+ };
119
+ const used = /* @__PURE__ */ new WeakSet();
120
+ const order = [];
121
+ const live = [];
122
+ const links = [];
123
+ let busy = false;
124
+ let cancel = null;
125
+ let disposed = false;
126
+ let waiters = [];
127
+ const fade = () => ctx.state === "running" ? fadeMs : 0;
128
+ const link = (from, to, gain) => {
129
+ const g = ctx.createGain();
130
+ g.gain.value = gain;
131
+ writeParam(ctx, g.gain, gain);
132
+ from.connect(g);
133
+ g.connect(to);
134
+ const l = { from, to, gain: g };
135
+ links.push(l);
136
+ return l;
137
+ };
138
+ const unlink = (l) => {
139
+ l.from.disconnect(l.gain);
140
+ l.gain.disconnect();
141
+ links.splice(links.indexOf(l), 1);
142
+ };
143
+ const find = (from, to) => links.find((l) => l.from === from && l.to === to);
144
+ const inOf = (s) => s ? s.nodes.in : tail;
145
+ const outOf = (s) => s ? s.nodes.out : head;
146
+ const setBypass = (s, ms) => {
147
+ if (!s.nodes) return;
148
+ writeParam(ctx, s.nodes.wet.gain, s.bypassed ? 0 : 1, ms);
149
+ writeParam(ctx, s.nodes.dry.gain, s.bypassed ? 1 : 0, ms);
150
+ };
151
+ const build = (s) => {
152
+ if (s.nodes) return;
153
+ const n = {
154
+ in: ctx.createGain(),
155
+ out: ctx.createGain(),
156
+ dry: ctx.createGain(),
157
+ wet: ctx.createGain()
158
+ };
159
+ n.in.connect(s.effect.input);
160
+ s.effect.output.connect(n.wet);
161
+ n.wet.connect(n.out);
162
+ n.in.connect(n.dry);
163
+ n.dry.connect(n.out);
164
+ s.nodes = n;
165
+ n.wet.gain.value = s.bypassed ? 0 : 1;
166
+ n.dry.gain.value = s.bypassed ? 1 : 0;
167
+ setBypass(s, 0);
168
+ };
169
+ const destroy = (s) => {
170
+ const n = s.nodes;
171
+ if (!n) return;
172
+ n.in.disconnect();
173
+ try {
174
+ s.effect.output.disconnect(n.wet);
175
+ } catch {
176
+ }
177
+ n.wet.disconnect();
178
+ n.dry.disconnect();
179
+ n.out.disconnect();
180
+ s.nodes = null;
181
+ };
182
+ const plan = () => {
183
+ const stale = live.find((s) => s.removed);
184
+ if (stale) return { kind: "out", slot: stale };
185
+ const wanted = order.filter((s) => live.includes(s));
186
+ for (let i2 = 0; i2 < wanted.length; i2 += 1) {
187
+ if (live[i2] !== wanted[i2]) return { kind: "out", slot: wanted[i2] };
188
+ }
189
+ const i = order.findIndex((s) => !live.includes(s));
190
+ if (i < 0) return null;
191
+ let at = 0;
192
+ for (let j = i - 1; j >= 0; j -= 1) {
193
+ const k = live.indexOf(order[j]);
194
+ if (k >= 0) {
195
+ at = k + 1;
196
+ break;
197
+ }
198
+ }
199
+ return { kind: "in", slot: order[i], at };
200
+ };
201
+ const begin = (step, ms) => {
202
+ const s = step.slot;
203
+ if (step.kind === "in") {
204
+ build(s);
205
+ const prev2 = live[step.at - 1] ?? null;
206
+ const next2 = live[step.at] ?? null;
207
+ const old = find(outOf(prev2), inOf(next2));
208
+ link(outOf(prev2), inOf(s), 1);
209
+ const incoming = link(outOf(s), inOf(next2), 0);
210
+ live.splice(step.at, 0, s);
211
+ writeParam(ctx, old.gain.gain, 0, ms);
212
+ writeParam(ctx, incoming.gain.gain, 1, ms);
213
+ return () => unlink(old);
214
+ }
215
+ const i = live.indexOf(s);
216
+ const prev = live[i - 1] ?? null;
217
+ const next = live[i + 1] ?? null;
218
+ const into = find(outOf(prev), inOf(s));
219
+ const from = find(outOf(s), inOf(next));
220
+ const direct = link(outOf(prev), inOf(next), 0);
221
+ live.splice(i, 1);
222
+ writeParam(ctx, from.gain.gain, 0, ms);
223
+ writeParam(ctx, direct.gain.gain, 1, ms);
224
+ return () => {
225
+ unlink(into);
226
+ unlink(from);
227
+ if (s.removed) {
228
+ destroy(s);
229
+ s.effect.dispose?.();
230
+ }
231
+ };
232
+ };
233
+ const pump = () => {
234
+ while (!busy && !disposed) {
235
+ const step = plan();
236
+ if (!step) {
237
+ const done = waiters;
238
+ waiters = [];
239
+ for (const w of done) w();
240
+ return;
241
+ }
242
+ const ms = fade();
243
+ const finish = begin(step, ms);
244
+ if (ms === 0) {
245
+ finish();
246
+ continue;
247
+ }
248
+ busy = true;
249
+ cancel = atAudioTime(ctx, timers, ctx.currentTime + ms / 1e3, () => {
250
+ cancel = null;
251
+ busy = false;
252
+ finish();
253
+ pump();
254
+ });
255
+ }
256
+ };
257
+ link(head, tail, 1);
258
+ const slotOf = (effect, bypassed) => {
259
+ const s = { effect, bypassed, removed: false, nodes: null, handle: null };
260
+ s.handle = {
261
+ effect,
262
+ bypass(on) {
263
+ if (s.bypassed === on) return;
264
+ s.bypassed = on;
265
+ setBypass(s, fade());
266
+ },
267
+ bypassed: () => s.bypassed,
268
+ moveTo(index) {
269
+ const from = order.indexOf(s);
270
+ if (from < 0) return;
271
+ order.splice(from, 1);
272
+ order.splice(Math.max(0, Math.min(index, order.length)), 0, s);
273
+ pump();
274
+ },
275
+ remove() {
276
+ const at = order.indexOf(s);
277
+ if (at < 0) return;
278
+ order.splice(at, 1);
279
+ s.removed = true;
280
+ pump();
281
+ },
282
+ index: () => order.indexOf(s)
283
+ };
284
+ return s;
285
+ };
286
+ const chain = {
287
+ add(effect, addOpts = {}) {
288
+ if (used.has(effect)) {
289
+ throw new Error("@weasel-js/audio: that effect is already in an insert slot");
290
+ }
291
+ used.add(effect);
292
+ const s = slotOf(effect, addOpts.bypassed ?? false);
293
+ const index = addOpts.index ?? order.length;
294
+ order.splice(Math.max(0, Math.min(index, order.length)), 0, s);
295
+ pump();
296
+ return s.handle;
297
+ },
298
+ slots: () => order.map((s) => s.handle),
299
+ clear() {
300
+ for (const s of [...order]) s.handle.remove();
301
+ },
302
+ settled() {
303
+ if (!busy && plan() === null) return Promise.resolve();
304
+ return new Promise((resolve) => {
305
+ waiters.push(resolve);
306
+ });
307
+ }
308
+ };
309
+ return {
310
+ chain,
311
+ dispose() {
312
+ disposed = true;
313
+ cancel?.();
314
+ cancel = null;
315
+ }
316
+ };
317
+ }
318
+
85
319
  // src/buses.ts
86
- function createBusGraph(ctx, names) {
320
+ function createBusGraph(ctx, names, opts = {}) {
87
321
  const master = ctx.createGain();
88
322
  master.connect(ctx.destination);
89
323
  const states = /* @__PURE__ */ new Map();
90
324
  for (const name of names) {
325
+ const input = ctx.createGain();
91
326
  const node = ctx.createGain();
92
327
  node.connect(master);
93
- states.set(name, { node, gain: 1, muted: false, soloed: false });
328
+ const inserts = createInsertChain(ctx, input, node, opts);
329
+ states.set(name, { input, node, inserts, gain: 1, muted: false, soloed: false });
94
330
  }
95
331
  const anySoloed = () => {
96
332
  for (const s of states.values()) if (s.soloed) return true;
@@ -111,8 +347,12 @@ function createBusGraph(ctx, names) {
111
347
  };
112
348
  return {
113
349
  master,
350
+ input: (name) => get(name).input,
114
351
  node: (name) => get(name).node,
115
352
  names: () => [...states.keys()],
353
+ dispose() {
354
+ for (const s of states.values()) s.inserts.dispose();
355
+ },
116
356
  bus(name) {
117
357
  const s = get(name);
118
358
  return {
@@ -131,7 +371,8 @@ function createBusGraph(ctx, names) {
131
371
  gain: () => s.gain,
132
372
  muted: () => s.muted,
133
373
  soloed: () => s.soloed,
134
- audible: () => audibleUnder(s, anySoloed())
374
+ audible: () => audibleUnder(s, anySoloed()),
375
+ inserts: s.inserts.chain
135
376
  };
136
377
  }
137
378
  };
@@ -348,6 +589,212 @@ function createTickTimer() {
348
589
  };
349
590
  }
350
591
 
592
+ // src/envelope.ts
593
+ function resolveEnvelope(env = {}) {
594
+ return {
595
+ attack: Math.max(0, env.attack ?? 5),
596
+ decay: Math.max(0, env.decay ?? 0),
597
+ sustain: Math.min(1, Math.max(0, env.sustain ?? 1)),
598
+ release: Math.max(0, env.release ?? 30)
599
+ };
600
+ }
601
+ var heldLevel = (e, t) => {
602
+ if (t < e.attack) return t / e.attack;
603
+ if (t < e.attack + e.decay) return 1 - (1 - e.sustain) * (t - e.attack) / e.decay;
604
+ return e.sustain;
605
+ };
606
+ function envelopeLevel(e, t, gate) {
607
+ if (gate === void 0 || t <= gate) return heldLevel(e, t);
608
+ if (e.release === 0) return 0;
609
+ return Math.max(0, heldLevel(e, gate) * (1 - (t - gate) / e.release));
610
+ }
611
+ function envelopePoints(e, gate) {
612
+ const end = gate ?? Infinity;
613
+ const points = [{ at: 0, value: e.attack > 0 ? 0 : 1 }];
614
+ for (const at of [e.attack, e.attack + e.decay]) {
615
+ if (at <= points[points.length - 1].at) continue;
616
+ if (at >= end) break;
617
+ points.push({ at, value: heldLevel(e, at) });
618
+ }
619
+ if (gate === void 0) return points;
620
+ if (gate > points[points.length - 1].at) points.push({ at: gate, value: heldLevel(e, gate) });
621
+ points.push({ at: gate + e.release, value: 0 });
622
+ return points;
623
+ }
624
+
625
+ // src/pitch.ts
626
+ function midiToFrequency(midi) {
627
+ return 440 * 2 ** ((midi - 69) / 12);
628
+ }
629
+ var LETTERS = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
630
+ function noteToMidi(name) {
631
+ const m = /^([A-Ga-g])([#b]*)(-?\d+)$/.exec(name.trim());
632
+ if (!m) throw new Error(`@weasel-js/audio: cannot read "${name}" as a note name`);
633
+ let semitone = LETTERS[m[1].toLowerCase()];
634
+ for (const acc of m[2]) semitone += acc === "#" ? 1 : -1;
635
+ return (Number(m[3]) + 1) * 12 + semitone;
636
+ }
637
+ function toFrequency(pitch) {
638
+ const hz = typeof pitch === "number" ? pitch : typeof pitch === "string" ? midiToFrequency(noteToMidi(pitch)) : midiToFrequency(pitch.midi);
639
+ if (!(hz > 0) || !Number.isFinite(hz)) {
640
+ throw new RangeError(`@weasel-js/audio: a pitch must be a positive frequency, got ${hz}`);
641
+ }
642
+ return hz;
643
+ }
644
+
645
+ // src/synthVoice.ts
646
+ var NOISE_SECONDS = 2;
647
+ var NOISE_RMS = 0.25;
648
+ var noiseCache = /* @__PURE__ */ new WeakMap();
649
+ function noiseSamples(color, length) {
650
+ const out = new Float32Array(length);
651
+ let s = 2654435769;
652
+ const white = () => {
653
+ s = Math.imul(s, 1664525) + 1013904223 >>> 0;
654
+ return s / 4294967296 * 2 - 1;
655
+ };
656
+ if (color === "white") {
657
+ for (let i = 0; i < length; i += 1) out[i] = white();
658
+ } else if (color === "pink") {
659
+ let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
660
+ for (let i = 0; i < length; i += 1) {
661
+ const w = white();
662
+ b0 = 0.99886 * b0 + w * 0.0555179;
663
+ b1 = 0.99332 * b1 + w * 0.0750759;
664
+ b2 = 0.969 * b2 + w * 0.153852;
665
+ b3 = 0.8665 * b3 + w * 0.3104856;
666
+ b4 = 0.55 * b4 + w * 0.5329522;
667
+ b5 = -0.7616 * b5 - w * 0.016898;
668
+ out[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362;
669
+ b6 = w * 0.115926;
670
+ }
671
+ } else {
672
+ let y = 0;
673
+ for (let i = 0; i < length; i += 1) {
674
+ y = (y + 0.02 * white()) / 1.02;
675
+ out[i] = y;
676
+ }
677
+ }
678
+ const step = (out[length - 1] - out[0]) / Math.max(1, length - 1);
679
+ let mean = 0;
680
+ for (let i = 0; i < length; i += 1) {
681
+ out[i] -= step * i;
682
+ mean += out[i];
683
+ }
684
+ mean /= length;
685
+ let sq = 0;
686
+ for (let i = 0; i < length; i += 1) {
687
+ out[i] -= mean;
688
+ sq += out[i] * out[i];
689
+ }
690
+ const scale = sq > 0 ? NOISE_RMS / Math.sqrt(sq / length) : 0;
691
+ for (let i = 0; i < length; i += 1) out[i] *= scale;
692
+ return out;
693
+ }
694
+ function noiseBuffer(ctx, color) {
695
+ let byColor = noiseCache.get(ctx);
696
+ if (!byColor) {
697
+ byColor = /* @__PURE__ */ new Map();
698
+ noiseCache.set(ctx, byColor);
699
+ }
700
+ let buffer = byColor.get(color);
701
+ if (!buffer) {
702
+ const length = Math.round(ctx.sampleRate * NOISE_SECONDS);
703
+ buffer = ctx.createBuffer(1, length, ctx.sampleRate);
704
+ buffer.getChannelData(0).set(noiseSamples(color, length));
705
+ byColor.set(color, buffer);
706
+ }
707
+ return buffer;
708
+ }
709
+ var isInharmonic = (wave) => typeof wave === "object" && !Array.isArray(wave);
710
+ function resolvePartials(wave) {
711
+ if (wave.partials.length === 0) {
712
+ throw new RangeError("@weasel-js/audio: an inharmonic wave needs at least one partial");
713
+ }
714
+ let total = 0;
715
+ for (const p of wave.partials) {
716
+ if (!(p.ratio > 0) || !Number.isFinite(p.ratio)) {
717
+ throw new RangeError(`@weasel-js/audio: partial ratio must be a positive number, got ${p.ratio}`);
718
+ }
719
+ if (p.decay !== void 0 && !(p.decay > 0)) {
720
+ throw new RangeError(`@weasel-js/audio: partial decay must be a positive number of ms, got ${p.decay}`);
721
+ }
722
+ total += Math.abs(p.gain ?? 1);
723
+ }
724
+ return wave.partials.map((p) => ({
725
+ ratio: p.ratio,
726
+ gain: total > 0 ? (p.gain ?? 1) / total : 0,
727
+ decay: p.decay
728
+ }));
729
+ }
730
+ function envelopeOnParam(param, env, gate, t0, map) {
731
+ const [first, ...rest] = envelopePoints(env, gate);
732
+ param.setValueAtTime(map(first.value), t0);
733
+ for (const p of rest) param.linearRampToValueAtTime(map(p.value), t0 + p.at / 1e3);
734
+ return {
735
+ release(at) {
736
+ const elapsed = Math.max(0, (at - t0) * 1e3);
737
+ param.cancelScheduledValues(at);
738
+ param.setValueAtTime(map(envelopeLevel(env, elapsed)), at);
739
+ param.linearRampToValueAtTime(map(0), at + env.release / 1e3);
740
+ }
741
+ };
742
+ }
743
+ function voiceFilter(ctx, spec, gate, t0) {
744
+ const node = ctx.createBiquadFilter();
745
+ node.type = spec.type ?? "lowpass";
746
+ node.Q.setValueAtTime(spec.Q ?? 1, t0);
747
+ if (spec.gain !== void 0) node.gain.setValueAtTime(spec.gain, t0);
748
+ const amount = spec.amount ?? 0;
749
+ if (!spec.envelope || amount === 0) {
750
+ node.frequency.setValueAtTime(spec.frequency, t0);
751
+ return { node, release: () => {
752
+ } };
753
+ }
754
+ const cutoff = envelopeOnParam(
755
+ node.frequency,
756
+ resolveEnvelope(spec.envelope),
757
+ gate,
758
+ t0,
759
+ (v) => spec.frequency + amount * v
760
+ );
761
+ return { node, release: cutoff.release };
762
+ }
763
+ var partialPresets = {
764
+ /** A church bell's hum, prime, tierce, quint and nominal, and above. */
765
+ bell: {
766
+ partials: [
767
+ { ratio: 0.5, gain: 0.6, decay: 1800 },
768
+ { ratio: 1, gain: 1, decay: 1200 },
769
+ { ratio: 1.183, gain: 0.7, decay: 900 },
770
+ { ratio: 1.506, gain: 0.4, decay: 700 },
771
+ { ratio: 2, gain: 0.8, decay: 600 },
772
+ { ratio: 2.514, gain: 0.3, decay: 400 },
773
+ { ratio: 2.662, gain: 0.25, decay: 350 },
774
+ { ratio: 3.011, gain: 0.2, decay: 300 }
775
+ ]
776
+ },
777
+ /** A tuned bar: its overtones sit near 4 and 10 times the fundamental and die fast. */
778
+ marimba: {
779
+ partials: [
780
+ { ratio: 1, gain: 1, decay: 350 },
781
+ { ratio: 3.984, gain: 0.35, decay: 90 },
782
+ { ratio: 9.95, gain: 0.12, decay: 30 }
783
+ ]
784
+ },
785
+ /** Struck sheet metal: modes nowhere near the harmonic series. */
786
+ metal: {
787
+ partials: [
788
+ { ratio: 1, gain: 1, decay: 160 },
789
+ { ratio: 1.71, gain: 0.53, decay: 120 },
790
+ { ratio: 2.43, gain: 0.36, decay: 94 },
791
+ { ratio: 3.19, gain: 0.27, decay: 78 },
792
+ { ratio: 4.61, gain: 0.22, decay: 66 },
793
+ { ratio: 5.87, gain: 0.18, decay: 58 }
794
+ ]
795
+ }
796
+ };
797
+
351
798
  // src/voicePool.ts
352
799
  function createVoicePool(opts) {
353
800
  if (!(opts.limit >= 1)) {
@@ -398,6 +845,25 @@ function createVoicePool(opts) {
398
845
  }
399
846
 
400
847
  // src/createAudioEngine.ts
848
+ var sourceGroup = (nodes) => ({
849
+ get onended() {
850
+ return nodes[0].onended;
851
+ },
852
+ set onended(fn) {
853
+ nodes[0].onended = fn;
854
+ },
855
+ stop(when) {
856
+ for (const n of nodes) {
857
+ try {
858
+ n.stop(when);
859
+ } catch {
860
+ }
861
+ }
862
+ },
863
+ disconnect() {
864
+ for (const n of nodes) n.disconnect();
865
+ }
866
+ });
401
867
  function createAudioEngine(opts = {}) {
402
868
  const ctx = opts.context ?? new AudioContext();
403
869
  const ownsContext = opts.context === void 0;
@@ -405,7 +871,6 @@ function createAudioEngine(opts = {}) {
405
871
  if (busNames.length === 0) {
406
872
  throw new Error("@weasel-js/audio: an engine needs at least one bus");
407
873
  }
408
- const graph = createBusGraph(ctx, busNames);
409
874
  const sounds = createSoundCache(ctx, opts.fetchFn);
410
875
  const slotsByBus = /* @__PURE__ */ new Map();
411
876
  for (const name of busNames) {
@@ -421,17 +886,22 @@ function createAudioEngine(opts = {}) {
421
886
  };
422
887
  const now = () => ctx.currentTime * 1e3;
423
888
  const tickTimer = opts.setTimer === void 0 && opts.clearTimer === void 0 ? createTickTimer() : null;
889
+ const timers = {
890
+ setTimer: tickTimer?.setTimer ?? opts.setTimer ?? defaultTimers.setTimer,
891
+ clearTimer: tickTimer?.clearTimer ?? opts.clearTimer ?? defaultTimers.clearTimer
892
+ };
424
893
  const scheduler = createScheduler({
425
894
  now,
426
895
  // One-shot, not repeating: the scheduler re-arms at the end of every pass,
427
896
  // so an interval would leave the previous one running and double the live
428
897
  // timer count per tick.
429
- setTimer: tickTimer?.setTimer ?? opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms)),
430
- clearTimer: tickTimer?.clearTimer ?? opts.clearTimer ?? ((h) => clearTimeout(h)),
898
+ setTimer: timers.setTimer,
899
+ clearTimer: timers.clearTimer,
431
900
  lookahead: opts.lookahead,
432
901
  interval: opts.tickInterval
433
902
  });
434
903
  scheduler.start();
904
+ const graph = createBusGraph(ctx, busNames, { ...timers, fadeMs: opts.insertFadeMs });
435
905
  let listener = { x: 0, y: 0 };
436
906
  let spatialOpts = {};
437
907
  let nextVoiceId = 1;
@@ -524,6 +994,10 @@ function createAudioEngine(opts = {}) {
524
994
  voice.source.disconnect();
525
995
  }
526
996
  voice.source = null;
997
+ voice.retune = null;
998
+ voice.release = null;
999
+ voice.env?.disconnect();
1000
+ voice.env = null;
527
1001
  if (voice.chain) {
528
1002
  voice.chain.gain.disconnect();
529
1003
  if (idleChains.length < idleChainLimit) idleChains.push(voice.chain);
@@ -539,6 +1013,8 @@ function createAudioEngine(opts = {}) {
539
1013
  id,
540
1014
  stop: () => {
541
1015
  },
1016
+ release: () => {
1017
+ },
542
1018
  setGain: () => {
543
1019
  },
544
1020
  setRate: () => {
@@ -551,6 +1027,207 @@ function createAudioEngine(opts = {}) {
551
1027
  },
552
1028
  isPlaying: () => false
553
1029
  });
1030
+ const launch = (common, starter) => {
1031
+ const id = nextVoiceId++;
1032
+ if (disposed) {
1033
+ warnDisposed();
1034
+ return dropped(id);
1035
+ }
1036
+ if (ctx.state !== "running") {
1037
+ warnLocked();
1038
+ return dropped(id);
1039
+ }
1040
+ const busName = common.bus ?? busNames[0];
1041
+ const slots = slotsFor(busName);
1042
+ const bookedAt = now();
1043
+ const when = common.when ?? bookedAt;
1044
+ const baseGain = common.gain ?? 1;
1045
+ const spatial = common.position ? spatialize(common.position, listener, spatialOpts) : { gain: 1, pan: common.pan ?? 0 };
1046
+ const voice = {
1047
+ id,
1048
+ slots,
1049
+ slot: null,
1050
+ token: 0,
1051
+ key: common.cancelKey,
1052
+ source: null,
1053
+ env: null,
1054
+ retune: null,
1055
+ release: null,
1056
+ releasing: false,
1057
+ chain: takeChain(graph.input(busName), baseGain * spatial.gain, spatial.pan),
1058
+ baseGain,
1059
+ spatialGain: spatial.gain,
1060
+ rate: common.rate ?? 1,
1061
+ detune: common.detune ?? 0,
1062
+ position: common.position,
1063
+ playing: true,
1064
+ cancelled: false
1065
+ };
1066
+ live.set(id, voice);
1067
+ scheduler.schedule(when, (scheduledWhen) => {
1068
+ if (voice.cancelled) return;
1069
+ if (bookedAt < staleFloor && scheduledWhen < staleFloor) {
1070
+ teardown(voice);
1071
+ return;
1072
+ }
1073
+ if (!starter.ready()) {
1074
+ teardown(voice);
1075
+ return;
1076
+ }
1077
+ const acquired = slots.pool.acquire({
1078
+ startedAt: scheduledWhen,
1079
+ gain: voice.baseGain * voice.spatialGain
1080
+ });
1081
+ voice.slot = acquired.slot;
1082
+ voice.token = acquired.token;
1083
+ slots.byToken.set(acquired.token, voice);
1084
+ if (acquired.stolen !== null) {
1085
+ const victim = slots.byToken.get(acquired.stolen);
1086
+ if (victim) teardown(victim);
1087
+ }
1088
+ const source = starter.start(voice, scheduledWhen);
1089
+ source.onended = () => {
1090
+ teardown(voice);
1091
+ common.onDone?.();
1092
+ };
1093
+ voice.source = source;
1094
+ }, common.cancelKey);
1095
+ return {
1096
+ id,
1097
+ stop(fadeMs) {
1098
+ if (fadeMs && fadeMs > 0 && voice.source && voice.chain && !voice.cancelled) {
1099
+ voice.cancelled = true;
1100
+ writeParam(ctx, voice.chain.gain.gain, 0, fadeMs);
1101
+ try {
1102
+ voice.source.stop(ctx.currentTime + fadeMs / 1e3);
1103
+ } catch {
1104
+ }
1105
+ return;
1106
+ }
1107
+ teardown(voice);
1108
+ },
1109
+ release() {
1110
+ if (voice.releasing) return;
1111
+ if (voice.release && voice.source && !voice.cancelled) {
1112
+ voice.releasing = true;
1113
+ voice.cancelled = true;
1114
+ voice.release();
1115
+ return;
1116
+ }
1117
+ teardown(voice);
1118
+ },
1119
+ setGain(value, rampMs) {
1120
+ voice.baseGain = value;
1121
+ if (!voice.chain) return;
1122
+ writeParam(ctx, voice.chain.gain.gain, value * voice.spatialGain, rampMs);
1123
+ poolGain(voice);
1124
+ },
1125
+ setRate(value) {
1126
+ voice.rate = value;
1127
+ voice.retune?.();
1128
+ },
1129
+ setDetune(cents) {
1130
+ voice.detune = cents;
1131
+ voice.retune?.();
1132
+ },
1133
+ setPan(value) {
1134
+ voice.position = void 0;
1135
+ if (voice.chain) voice.chain.panner.pan.value = value;
1136
+ },
1137
+ setPosition(p) {
1138
+ voice.position = p;
1139
+ applySpatial(voice);
1140
+ },
1141
+ isPlaying: () => voice.playing
1142
+ };
1143
+ };
1144
+ const synthStart = (voice, whenMs, patch, make) => {
1145
+ const t0 = whenMs / 1e3;
1146
+ const env = resolveEnvelope(patch.envelope);
1147
+ const gate = patch.duration;
1148
+ const shaper = ctx.createGain();
1149
+ const amp = envelopeOnParam(shaper.gain, env, gate, t0, (v) => v);
1150
+ const filter = patch.filter ? voiceFilter(ctx, patch.filter, gate, t0) : null;
1151
+ filter?.node.connect(shaper);
1152
+ shaper.connect(voice.chain.panner);
1153
+ voice.env = shaper;
1154
+ const { nodes, retune } = make(t0, filter?.node ?? shaper);
1155
+ voice.retune = retune;
1156
+ retune();
1157
+ const stopAll = (at) => {
1158
+ for (const n of nodes) n.stop(at);
1159
+ };
1160
+ voice.release = () => {
1161
+ const at = ctx.currentTime;
1162
+ if (gate !== void 0 && at * 1e3 - whenMs >= gate) return;
1163
+ amp.release(at);
1164
+ filter?.release(at);
1165
+ stopAll(at + env.release / 1e3);
1166
+ };
1167
+ if (gate !== void 0) stopAll(t0 + (gate + env.release) / 1e3);
1168
+ return nodes.length === 1 ? nodes[0] : sourceGroup(nodes);
1169
+ };
1170
+ const waves = /* @__PURE__ */ new Map();
1171
+ const applyWave = (osc, wave) => {
1172
+ if (typeof wave === "string") {
1173
+ osc.type = wave;
1174
+ return;
1175
+ }
1176
+ const key = wave.join(",");
1177
+ let periodic = waves.get(key);
1178
+ if (!periodic) {
1179
+ const imag = new Float32Array(wave.length + 1);
1180
+ imag.set(wave, 1);
1181
+ periodic = ctx.createPeriodicWave(new Float32Array(wave.length + 1), imag);
1182
+ waves.set(key, periodic);
1183
+ }
1184
+ osc.setPeriodicWave(periodic);
1185
+ };
1186
+ const mediaSources = /* @__PURE__ */ new WeakMap();
1187
+ const streaming = /* @__PURE__ */ new WeakMap();
1188
+ const openMedia = (url, crossOrigin) => {
1189
+ const el = new Audio();
1190
+ if (crossOrigin !== void 0) el.crossOrigin = crossOrigin;
1191
+ el.preload = "auto";
1192
+ el.src = url;
1193
+ return el;
1194
+ };
1195
+ const mediaSource = (el, node, into) => {
1196
+ let ended = false;
1197
+ let cancelWait = null;
1198
+ const finish = () => {
1199
+ if (ended) return;
1200
+ ended = true;
1201
+ cancelWait?.();
1202
+ el.pause();
1203
+ el.removeEventListener("ended", finish);
1204
+ el.removeEventListener("error", finish);
1205
+ queueMicrotask(() => shim.onended?.());
1206
+ };
1207
+ const shim = {
1208
+ onended: null,
1209
+ stop(when) {
1210
+ if (ended) return;
1211
+ cancelWait?.();
1212
+ cancelWait = when !== void 0 && when > ctx.currentTime ? atAudioTime(ctx, timers, when, finish) : null;
1213
+ if (!cancelWait) finish();
1214
+ },
1215
+ disconnect() {
1216
+ try {
1217
+ node.disconnect(into);
1218
+ } catch {
1219
+ }
1220
+ }
1221
+ };
1222
+ el.addEventListener("ended", finish);
1223
+ el.addEventListener("error", finish);
1224
+ el.play().catch((err) => {
1225
+ if (ended) return;
1226
+ console.warn(`@weasel-js/audio: a stream would not start \u2014 ${String(err)}`);
1227
+ finish();
1228
+ });
1229
+ return shim;
1230
+ };
554
1231
  const engine = {
555
1232
  context: ctx,
556
1233
  state: () => ctx.state,
@@ -564,112 +1241,119 @@ function createAudioEngine(opts = {}) {
564
1241
  decode: sounds.decode,
565
1242
  register: sounds.register,
566
1243
  play(sound, playOpts = {}) {
567
- const id = nextVoiceId++;
568
- if (disposed) {
569
- warnDisposed();
570
- return dropped(id);
571
- }
572
- if (ctx.state !== "running") {
573
- warnLocked();
574
- return dropped(id);
575
- }
576
- const busName = playOpts.bus ?? busNames[0];
577
- const slots = slotsFor(busName);
578
- const bookedAt = now();
579
- const when = playOpts.when ?? bookedAt;
580
- const baseGain = playOpts.gain ?? 1;
581
- const spatial = playOpts.position ? spatialize(playOpts.position, listener, spatialOpts) : { gain: 1, pan: playOpts.pan ?? 0 };
582
- const voice = {
583
- id,
584
- slots,
585
- slot: null,
586
- token: 0,
587
- key: playOpts.cancelKey,
588
- source: null,
589
- chain: takeChain(graph.node(busName), baseGain * spatial.gain, spatial.pan),
590
- baseGain,
591
- spatialGain: spatial.gain,
592
- rate: playOpts.rate ?? 1,
593
- detune: playOpts.detune ?? 0,
594
- position: playOpts.position,
595
- playing: true,
596
- cancelled: false
597
- };
598
- live.set(id, voice);
599
- scheduler.schedule(when, (scheduledWhen) => {
600
- if (voice.cancelled) return;
601
- if (bookedAt < staleFloor && scheduledWhen < staleFloor) {
602
- teardown(voice);
603
- return;
604
- }
605
- const buffer = sounds.buffer(sound);
606
- if (!buffer) {
1244
+ return launch(playOpts, {
1245
+ ready() {
1246
+ if (sounds.buffer(sound)) return true;
607
1247
  warnUnknownSound(sound.id);
608
- teardown(voice);
609
- return;
610
- }
611
- const acquired = slots.pool.acquire({
612
- startedAt: scheduledWhen,
613
- gain: voice.baseGain * voice.spatialGain
614
- });
615
- voice.slot = acquired.slot;
616
- voice.token = acquired.token;
617
- slots.byToken.set(acquired.token, voice);
618
- if (acquired.stolen !== null) {
619
- const victim = slots.byToken.get(acquired.stolen);
620
- if (victim) teardown(victim);
1248
+ return false;
1249
+ },
1250
+ start(voice, when) {
1251
+ const source = ctx.createBufferSource();
1252
+ source.buffer = sounds.buffer(sound);
1253
+ source.loop = playOpts.loop ?? false;
1254
+ voice.retune = () => {
1255
+ source.playbackRate.value = voice.rate;
1256
+ source.detune.value = voice.detune;
1257
+ };
1258
+ voice.retune();
1259
+ source.connect(voice.chain.panner);
1260
+ source.start(when / 1e3);
1261
+ return source;
621
1262
  }
622
- const source = ctx.createBufferSource();
623
- source.buffer = buffer;
624
- source.loop = playOpts.loop ?? false;
625
- source.playbackRate.value = voice.rate;
626
- source.detune.value = voice.detune;
627
- source.connect(voice.chain.panner);
628
- source.onended = () => {
629
- teardown(voice);
630
- playOpts.onDone?.();
631
- };
632
- voice.source = source;
633
- source.start(scheduledWhen / 1e3);
634
- }, playOpts.cancelKey);
635
- return {
636
- id,
637
- stop(fadeMs) {
638
- if (fadeMs && fadeMs > 0 && voice.source && voice.chain && !voice.cancelled) {
639
- voice.cancelled = true;
640
- writeParam(ctx, voice.chain.gain.gain, 0, fadeMs);
641
- try {
642
- voice.source.stop(ctx.currentTime + fadeMs / 1e3);
643
- } catch {
1263
+ });
1264
+ },
1265
+ playNote(note) {
1266
+ const from = toFrequency(note.pitch);
1267
+ const glideTo = note.glide ? toFrequency(note.glide.to) : void 0;
1268
+ const wave = note.wave ?? "sine";
1269
+ const partials = isInharmonic(wave) ? resolvePartials(wave) : null;
1270
+ return launch(note, {
1271
+ ready: () => true,
1272
+ start: (voice, whenMs) => synthStart(voice, whenMs, note, (t0, into) => {
1273
+ const tone = (ratio) => {
1274
+ const osc = ctx.createOscillator();
1275
+ osc.frequency.setValueAtTime(from * ratio, t0);
1276
+ if (glideTo !== void 0) {
1277
+ const end = t0 + (note.glide.ms ?? note.duration ?? 100) / 1e3;
1278
+ if (note.glide.curve === "linear") osc.frequency.linearRampToValueAtTime(glideTo * ratio, end);
1279
+ else osc.frequency.exponentialRampToValueAtTime(glideTo * ratio, end);
644
1280
  }
645
- return;
1281
+ return osc;
1282
+ };
1283
+ let oscs;
1284
+ if (partials) {
1285
+ oscs = partials.map((p) => {
1286
+ const osc = tone(p.ratio);
1287
+ const level = ctx.createGain();
1288
+ level.gain.setValueAtTime(p.gain, t0);
1289
+ if (p.decay !== void 0) level.gain.setTargetAtTime(0, t0, p.decay / 1e3);
1290
+ osc.connect(level);
1291
+ level.connect(into);
1292
+ return osc;
1293
+ });
1294
+ } else {
1295
+ const osc = tone(1);
1296
+ applyWave(osc, wave);
1297
+ osc.connect(into);
1298
+ oscs = [osc];
646
1299
  }
647
- teardown(voice);
648
- },
649
- setGain(value, rampMs) {
650
- voice.baseGain = value;
651
- if (!voice.chain) return;
652
- writeParam(ctx, voice.chain.gain.gain, value * voice.spatialGain, rampMs);
653
- poolGain(voice);
654
- },
655
- setRate(value) {
656
- voice.rate = value;
657
- if (voice.source) voice.source.playbackRate.value = value;
658
- },
659
- setDetune(cents) {
660
- voice.detune = cents;
661
- if (voice.source) voice.source.detune.value = cents;
662
- },
663
- setPan(value) {
664
- voice.position = void 0;
665
- if (voice.chain) voice.chain.panner.pan.value = value;
666
- },
667
- setPosition(p) {
668
- voice.position = p;
669
- applySpatial(voice);
670
- },
671
- isPlaying: () => voice.playing
672
- };
1300
+ for (const osc of oscs) osc.start(t0);
1301
+ return {
1302
+ nodes: oscs,
1303
+ retune: () => {
1304
+ for (const osc of oscs) osc.detune.value = voice.detune + 1200 * Math.log2(voice.rate);
1305
+ }
1306
+ };
1307
+ })
1308
+ });
1309
+ },
1310
+ playNoise(noise) {
1311
+ return launch(noise, {
1312
+ ready: () => true,
1313
+ start: (voice, whenMs) => synthStart(voice, whenMs, noise, (t0, into) => {
1314
+ const source = ctx.createBufferSource();
1315
+ source.buffer = noiseBuffer(ctx, noise.noise);
1316
+ source.loop = true;
1317
+ source.connect(into);
1318
+ source.start(t0, Math.random() * source.buffer.duration);
1319
+ return {
1320
+ nodes: [source],
1321
+ retune: () => {
1322
+ source.playbackRate.value = voice.rate;
1323
+ source.detune.value = voice.detune;
1324
+ }
1325
+ };
1326
+ })
1327
+ });
1328
+ },
1329
+ stream(media, streamOpts = {}) {
1330
+ const el = typeof media === "string" ? openMedia(media, streamOpts.crossOrigin) : media;
1331
+ streaming.get(el)?.stop();
1332
+ const handle = launch(streamOpts, {
1333
+ ready: () => true,
1334
+ start(voice) {
1335
+ let node = mediaSources.get(el);
1336
+ if (!node) {
1337
+ node = ctx.createMediaElementSource(el);
1338
+ mediaSources.set(el, node);
1339
+ }
1340
+ el.loop = streamOpts.loop ?? false;
1341
+ if (streamOpts.offset !== void 0) el.currentTime = streamOpts.offset / 1e3;
1342
+ voice.retune = () => {
1343
+ el.playbackRate = voice.rate;
1344
+ };
1345
+ voice.retune();
1346
+ const into = voice.chain.panner;
1347
+ node.connect(into);
1348
+ return mediaSource(el, node, into);
1349
+ }
1350
+ });
1351
+ streaming.set(el, handle);
1352
+ return handle;
1353
+ },
1354
+ schedule(when, fire, key) {
1355
+ if (disposed) return;
1356
+ scheduler.schedule(when, fire, key);
673
1357
  },
674
1358
  stopKey(key) {
675
1359
  scheduler.cancelKey(key);
@@ -716,6 +1400,7 @@ function createAudioEngine(opts = {}) {
716
1400
  taps.clear();
717
1401
  disarmGestures();
718
1402
  ctx.removeEventListener("statechange", onStateChange);
1403
+ graph.dispose();
719
1404
  graph.master.disconnect();
720
1405
  if (ownsContext) void ctx.close().catch(() => void 0);
721
1406
  }
@@ -723,6 +1408,207 @@ function createAudioEngine(opts = {}) {
723
1408
  return engine;
724
1409
  }
725
1410
 
726
- export { createAnalyserTap, createAudioEngine, createBusGraph, createScheduler, createSoundCache, createTickTimer, createVoicePool, spatialize };
1411
+ // src/effects.ts
1412
+ function createFilterEffect(ctx, opts = {}) {
1413
+ const filter = ctx.createBiquadFilter();
1414
+ filter.type = opts.type ?? "lowpass";
1415
+ if (opts.frequency !== void 0) writeParam(ctx, filter.frequency, opts.frequency);
1416
+ if (opts.Q !== void 0) writeParam(ctx, filter.Q, opts.Q);
1417
+ if (opts.gain !== void 0) writeParam(ctx, filter.gain, opts.gain);
1418
+ return { input: filter, output: filter, filter };
1419
+ }
1420
+ var checkMix = (mix) => {
1421
+ if (!(mix >= 0 && mix <= 1)) throw new RangeError(`@weasel-js/audio: mix ${mix} is outside 0..1`);
1422
+ return mix;
1423
+ };
1424
+ function wetDry(ctx, wetIn, wetOut, initial) {
1425
+ const input = ctx.createGain();
1426
+ const output = ctx.createGain();
1427
+ const dry = ctx.createGain();
1428
+ const wet = ctx.createGain();
1429
+ input.connect(dry);
1430
+ dry.connect(output);
1431
+ input.connect(wetIn);
1432
+ wetOut.connect(wet);
1433
+ wet.connect(output);
1434
+ let mix = checkMix(initial);
1435
+ const write = (rampMs) => {
1436
+ writeParam(ctx, dry.gain, 1 - mix, rampMs);
1437
+ writeParam(ctx, wet.gain, mix, rampMs);
1438
+ };
1439
+ write();
1440
+ return {
1441
+ input,
1442
+ output,
1443
+ mix: () => mix,
1444
+ setMix(value, rampMs) {
1445
+ mix = checkMix(value);
1446
+ write(rampMs);
1447
+ }
1448
+ };
1449
+ }
1450
+ function createReverbEffect(ctx, opts) {
1451
+ const convolver = ctx.createConvolver();
1452
+ convolver.normalize = opts.normalize ?? true;
1453
+ convolver.buffer = opts.impulse;
1454
+ const m = wetDry(ctx, convolver, convolver, opts.mix ?? 0.3);
1455
+ return { input: m.input, output: m.output, convolver, mix: m.mix, setMix: m.setMix };
1456
+ }
1457
+ var checkFeedback = (value) => {
1458
+ if (!(value > -1 && value < 1)) {
1459
+ throw new RangeError(`@weasel-js/audio: feedback ${value} must be inside -1..1, or the repeats grow`);
1460
+ }
1461
+ return value;
1462
+ };
1463
+ function createDelayEffect(ctx, opts = {}) {
1464
+ const time = opts.time ?? 250;
1465
+ const maxTime = opts.maxTime ?? Math.max(1e3, time);
1466
+ let fb = checkFeedback(opts.feedback ?? 0.35);
1467
+ const delay = ctx.createDelay(maxTime / 1e3);
1468
+ const loop = ctx.createGain();
1469
+ const m = wetDry(ctx, delay, delay, opts.mix ?? 0.35);
1470
+ delay.connect(loop);
1471
+ loop.connect(delay);
1472
+ writeParam(ctx, delay.delayTime, time / 1e3);
1473
+ writeParam(ctx, loop.gain, fb);
1474
+ return {
1475
+ input: m.input,
1476
+ output: m.output,
1477
+ delay,
1478
+ mix: m.mix,
1479
+ setMix: m.setMix,
1480
+ setTime(ms, rampMs) {
1481
+ if (!(ms >= 0 && ms <= maxTime)) {
1482
+ throw new RangeError(`@weasel-js/audio: delay time ${ms} is outside 0..${maxTime} ms`);
1483
+ }
1484
+ writeParam(ctx, delay.delayTime, ms / 1e3, rampMs);
1485
+ },
1486
+ feedback: () => fb,
1487
+ setFeedback(value, rampMs) {
1488
+ fb = checkFeedback(value);
1489
+ writeParam(ctx, loop.gain, fb, rampMs);
1490
+ },
1491
+ dispose() {
1492
+ delay.disconnect();
1493
+ loop.disconnect();
1494
+ }
1495
+ };
1496
+ }
1497
+ function createCompressorEffect(ctx, opts = {}) {
1498
+ const compressor = ctx.createDynamicsCompressor();
1499
+ const makeup = ctx.createGain();
1500
+ compressor.connect(makeup);
1501
+ const set = (param, value) => {
1502
+ if (value !== void 0) writeParam(ctx, param, value);
1503
+ };
1504
+ set(compressor.threshold, opts.threshold);
1505
+ set(compressor.knee, opts.knee);
1506
+ set(compressor.ratio, opts.ratio);
1507
+ set(compressor.attack, opts.attack === void 0 ? void 0 : opts.attack / 1e3);
1508
+ set(compressor.release, opts.release === void 0 ? void 0 : opts.release / 1e3);
1509
+ set(makeup.gain, opts.makeup ?? 1);
1510
+ return { input: compressor, output: makeup, compressor, makeup };
1511
+ }
1512
+
1513
+ // src/patternPlayer.ts
1514
+ var nextPlayer = 1;
1515
+ var checkTempo = (bpm) => {
1516
+ if (!(bpm > 0) || !Number.isFinite(bpm)) {
1517
+ throw new RangeError(`@weasel-js/audio: tempo must be a positive number of bpm, got ${bpm}`);
1518
+ }
1519
+ };
1520
+ function createPatternPlayer(engine, opts) {
1521
+ checkTempo(opts.tempo);
1522
+ const stepsPerBeat = opts.stepsPerBeat ?? 4;
1523
+ const loop = opts.loop ?? true;
1524
+ const key = `\0pattern:${nextPlayer++}`;
1525
+ let bpm = opts.tempo;
1526
+ let length = 1;
1527
+ let byStep = /* @__PURE__ */ new Map();
1528
+ const setEvents = (events, len) => {
1529
+ byStep = /* @__PURE__ */ new Map();
1530
+ let last = -1;
1531
+ for (const e of events) {
1532
+ const list = byStep.get(e.step);
1533
+ if (list) list.push(e);
1534
+ else byStep.set(e.step, [e]);
1535
+ last = Math.max(last, e.step);
1536
+ }
1537
+ length = len ?? Math.max(1, Math.ceil((last + 1) / stepsPerBeat)) * stepsPerBeat;
1538
+ };
1539
+ setEvents(opts.events ?? [], opts.length);
1540
+ let running = false;
1541
+ let count = 0;
1542
+ let lastAt = null;
1543
+ let nextAt = 0;
1544
+ const voices = /* @__PURE__ */ new Set();
1545
+ const stepMs = () => 6e4 / bpm / stepsPerBeat;
1546
+ const book = () => {
1547
+ engine.schedule(nextAt, fire, key);
1548
+ };
1549
+ const fire = (scheduled) => {
1550
+ if (!running) return;
1551
+ let when = scheduled;
1552
+ const behind = engine.now() - when;
1553
+ if (behind > stepMs()) {
1554
+ const skip = Math.ceil(behind / stepMs());
1555
+ count += skip;
1556
+ when += skip * stepMs();
1557
+ }
1558
+ if (!loop && count >= length) {
1559
+ running = false;
1560
+ return;
1561
+ }
1562
+ const step = count % length;
1563
+ for (const v of voices) if (!v.isPlaying()) voices.delete(v);
1564
+ for (const e of byStep.get(step) ?? []) {
1565
+ if ("sound" in e) {
1566
+ const { step: _s, sound, ...play } = e;
1567
+ voices.add(engine.play(sound, { ...play, when }));
1568
+ } else if ("noise" in e) {
1569
+ const { step: _s, length: steps = 1, ...noise } = e;
1570
+ voices.add(engine.playNoise({ ...noise, when, duration: steps * stepMs() }));
1571
+ } else {
1572
+ const { step: _s, length: steps = 1, ...note } = e;
1573
+ voices.add(engine.playNote({ ...note, when, duration: steps * stepMs() }));
1574
+ }
1575
+ }
1576
+ opts.onStep?.(step, when);
1577
+ count += 1;
1578
+ lastAt = when;
1579
+ nextAt = when + stepMs();
1580
+ book();
1581
+ };
1582
+ return {
1583
+ start(when) {
1584
+ if (running) return;
1585
+ running = true;
1586
+ count = 0;
1587
+ lastAt = null;
1588
+ nextAt = when ?? engine.now();
1589
+ book();
1590
+ },
1591
+ stop() {
1592
+ if (!running) return;
1593
+ running = false;
1594
+ engine.stopKey(key);
1595
+ for (const v of voices) v.release();
1596
+ voices.clear();
1597
+ },
1598
+ playing: () => running,
1599
+ tempo: () => bpm,
1600
+ setTempo(next) {
1601
+ checkTempo(next);
1602
+ bpm = next;
1603
+ if (!running || lastAt === null) return;
1604
+ engine.stopKey(key);
1605
+ nextAt = Math.max(lastAt + stepMs(), engine.now());
1606
+ book();
1607
+ },
1608
+ setEvents
1609
+ };
1610
+ }
1611
+
1612
+ export { createAnalyserTap, createAudioEngine, createBusGraph, createCompressorEffect, createDelayEffect, createFilterEffect, createPatternPlayer, createReverbEffect, createScheduler, createSoundCache, createTickTimer, createVoicePool, envelopeLevel, envelopePoints, midiToFrequency, noiseSamples, noteToMidi, partialPresets, resolveEnvelope, spatialize, toFrequency };
727
1613
  //# sourceMappingURL=index.js.map
728
1614
  //# sourceMappingURL=index.js.map