@aeonentertainment/mixer 0.0.0-stage → 0.2.0

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 ADDED
@@ -0,0 +1,722 @@
1
+ import { createRequire } from "node:module";
2
+ import { isIP } from "node:net";
3
+ // src/acks.ts
4
+ /** How long a command may go unacknowledged before its promise rejects (docs/design.md 8.8). */
5
+ const ACK_TIMEOUT_MS = 1e3;
6
+ /** Commands in flight, each resolved by the ack that carries its id. */
7
+ var Acks = class {
8
+ #pending = /* @__PURE__ */ new Map();
9
+ /**
10
+ * `state` describes the engine for the rejection when no ack comes, and `onTimeout` hears the
11
+ * same error; a command rejected any other way never reaches it.
12
+ */
13
+ wait(id, state, onTimeout) {
14
+ return new Promise((resolve, reject) => {
15
+ const timer = setTimeout(() => {
16
+ this.#pending.delete(id);
17
+ const error = /* @__PURE__ */ new Error(`command ${id} was not acknowledged within ${ACK_TIMEOUT_MS} ms; the engine is ${state()}`);
18
+ reject(error);
19
+ onTimeout?.(error);
20
+ }, ACK_TIMEOUT_MS);
21
+ timer.unref();
22
+ this.#pending.set(id, {
23
+ resolve,
24
+ reject,
25
+ timer
26
+ });
27
+ });
28
+ }
29
+ resolve(id, positionFrames) {
30
+ const pending = this.#pending.get(id);
31
+ if (!pending) return;
32
+ clearTimeout(pending.timer);
33
+ this.#pending.delete(id);
34
+ pending.resolve(positionFrames);
35
+ }
36
+ rejectAll(error) {
37
+ for (const pending of this.#pending.values()) {
38
+ clearTimeout(pending.timer);
39
+ pending.reject(error);
40
+ }
41
+ this.#pending.clear();
42
+ }
43
+ };
44
+ // src/emitter.ts
45
+ /** Typed listeners for one object's events. `on` returns the unsubscribe. */
46
+ var Emitter = class {
47
+ #listeners = /* @__PURE__ */ new Map();
48
+ on(event, listener) {
49
+ let listeners = this.#listeners.get(event);
50
+ if (!listeners) {
51
+ listeners = /* @__PURE__ */ new Set();
52
+ this.#listeners.set(event, listeners);
53
+ }
54
+ listeners.add(listener);
55
+ return () => {
56
+ listeners.delete(listener);
57
+ };
58
+ }
59
+ emit(event, ...args) {
60
+ for (const listener of this.#listeners.get(event) ?? []) listener(...args);
61
+ }
62
+ };
63
+ // src/units.ts
64
+ const FRAMES_PER_MS = 48;
65
+ function msToFrames(ms) {
66
+ return Math.round(ms * FRAMES_PER_MS);
67
+ }
68
+ function framesToMs(frames) {
69
+ return frames / FRAMES_PER_MS;
70
+ }
71
+ // src/validate.ts
72
+ const INGEST_KINDS = [
73
+ "rtp",
74
+ "l16",
75
+ "push"
76
+ ];
77
+ /** A kind as a JavaScript caller may have spelt it, so an unknown one never crosses. */
78
+ function ingestKind(name, value) {
79
+ const kind = INGEST_KINDS.find((known) => known === value);
80
+ if (kind === void 0) throw new RangeError(`${name} must be rtp, l16 or push, got ${value}`);
81
+ return kind;
82
+ }
83
+ /**
84
+ * Where the mix goes: an IP literal, since resolving a name would block the control thread, and a
85
+ * dynamic RTP payload type, since Opus has no static one.
86
+ */
87
+ function rtpReturn(value) {
88
+ if (isIP(value.host) === 0) throw new TypeError(`rtpReturn.host must be an IP address, got ${value.host}`);
89
+ return {
90
+ host: value.host,
91
+ port: integerIn("rtpReturn.port", value.port, 1, 65535),
92
+ payloadType: integerIn("rtpReturn.payloadType", value.payloadType, 96, 127),
93
+ ssrc: integerIn("rtpReturn.ssrc", value.ssrc, 0, 4294967295)
94
+ };
95
+ }
96
+ function finiteIn(name, value, min, max) {
97
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number, got ${String(value)}`);
98
+ if (value < min || value > max) throw new RangeError(`${name} must be between ${min} and ${max}, got ${value}`);
99
+ return value;
100
+ }
101
+ function integerIn(name, value, min, max) {
102
+ if (!Number.isInteger(value)) throw new TypeError(`${name} must be an integer, got ${String(value)}`);
103
+ return finiteIn(name, value, min, max);
104
+ }
105
+ function pcm(name, value, channels) {
106
+ if (!(value instanceof Float32Array) || value.length === 0) throw new TypeError(`${name} must be a non-empty Float32Array`);
107
+ if (value.length % channels !== 0) throw new RangeError(`${name} must hold whole frames of ${channels} channels`);
108
+ return value;
109
+ }
110
+ // src/backing.ts
111
+ /** `stopAtMs` fades the stems out over this before falling silent: docs/design.md section 4. */
112
+ const STOP_FADE_MS = 3e3;
113
+ /**
114
+ * One mixer's backing path while it is armed: the render job on the worker thread and the
115
+ * settings held for start. `install` moves the rendered stems into the mixer's graph.
116
+ */
117
+ var Backing = class {
118
+ speed;
119
+ stopAtFrames;
120
+ /**
121
+ * Resolves when the render job is done. The stems are in the graph by the time anyone awaiting
122
+ * this runs, or `install` throws the render's error.
123
+ */
124
+ rendered;
125
+ #rendering = true;
126
+ #installed = false;
127
+ #fraction = 0;
128
+ #failure = null;
129
+ #stemGains;
130
+ #render;
131
+ #graph;
132
+ /**
133
+ * Checks `config` and starts its render on the worker thread. The stems move into `graph` as
134
+ * soon as the job is done, so a render that fails is reported then and not at `start`.
135
+ */
136
+ constructor(host, config, graph, report) {
137
+ this.#graph = graph;
138
+ const input = toInput(config);
139
+ this.speed = input.speed;
140
+ this.stopAtFrames = config.stopAtMs === void 0 ? void 0 : msToFrames(finiteIn("stopAtMs", config.stopAtMs, 0, Number.MAX_SAFE_INTEGER));
141
+ this.#stemGains = {
142
+ instrumental: finiteIn("stemGains.instrumental", config.stemGains.instrumental, 0, 4),
143
+ guide: finiteIn("stemGains.guideVocals", config.stemGains.guideVocals, 0, 4)
144
+ };
145
+ this.#render = host.native.startRender(input);
146
+ this.rendered = host.watchRender(this.#render.id, (fraction) => {
147
+ this.#fraction = fraction;
148
+ if (fraction < 1) report.progress(fraction);
149
+ });
150
+ this.rendered.then(() => {
151
+ this.#rendering = false;
152
+ try {
153
+ this.install();
154
+ } catch (error) {
155
+ this.#failure = error instanceof Error ? error : new Error(String(error));
156
+ report.failed(this.#failure);
157
+ return;
158
+ }
159
+ if (this.#fraction === 1) report.progress(1);
160
+ }, () => {});
161
+ }
162
+ /** Whether `install` would still wait on the worker thread. */
163
+ get rendering() {
164
+ return this.#rendering;
165
+ }
166
+ get stemGains() {
167
+ return this.#stemGains;
168
+ }
169
+ /**
170
+ * Moves the rendered stems into the graph, once; the graph keeps them through a refused swap.
171
+ * Throws the engine's message when the render failed, on every call.
172
+ */
173
+ install() {
174
+ if (this.#failure) throw this.#failure;
175
+ if (this.#installed) return;
176
+ this.#graph.installBacking(this.#render);
177
+ this.#installed = true;
178
+ }
179
+ setStemGain(kind, gain) {
180
+ const checked = finiteIn(`${kind} gain`, gain, 0, 4);
181
+ this.#stemGains = kind === "instrumental" ? {
182
+ ...this.#stemGains,
183
+ instrumental: checked
184
+ } : {
185
+ ...this.#stemGains,
186
+ guide: checked
187
+ };
188
+ return this.#stemGains;
189
+ }
190
+ };
191
+ /** Half to double speed: what a speed control offers a singer. */
192
+ const SPEED_RANGE = [.5, 2];
193
+ function toInput(config) {
194
+ let instrumental;
195
+ let guide;
196
+ for (const { kind, path } of config.stems) {
197
+ if (typeof path !== "string" || path.length === 0) throw new TypeError(`backing.stems: the ${kind} stem needs a path`);
198
+ switch (kind) {
199
+ case "instrumental":
200
+ if (instrumental !== void 0) throw new RangeError("backing.stems holds two instrumental stems");
201
+ instrumental = path;
202
+ break;
203
+ case "guideVocals":
204
+ if (guide !== void 0) throw new RangeError("backing.stems holds two guideVocals stems");
205
+ guide = path;
206
+ }
207
+ }
208
+ if (instrumental === void 0) throw new RangeError("backing.stems needs an instrumental stem");
209
+ return {
210
+ instrumental,
211
+ ...guide === void 0 ? {} : { guide },
212
+ semitones: integerIn("key", config.key, -12, 12),
213
+ speed: finiteIn("speed", config.speed, ...SPEED_RANGE)
214
+ };
215
+ }
216
+ // src/mixer.ts
217
+ function defaultSlotParams() {
218
+ return {
219
+ gain: 1,
220
+ pan: 0,
221
+ mute: false,
222
+ solo: false,
223
+ corrector: {
224
+ enabled: false,
225
+ strength: 0,
226
+ retuneMs: 0
227
+ },
228
+ reverb: {
229
+ enabled: false,
230
+ roomSize: 0,
231
+ mix: 0
232
+ },
233
+ guideOffsetMs: 0
234
+ };
235
+ }
236
+ /**
237
+ * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports from
238
+ * here until `destroy`; its return stream sends the mix from `start` until `destroy`. Settings
239
+ * made while armed are kept here and written to the engine when `start` swaps the graph in, so
240
+ * arming the next song never touches the live one.
241
+ */
242
+ var Mixer = class {
243
+ /** Each slot's kind and, for `rtp` and `l16`, the UDP port it listens on; `null` for `push`. */
244
+ slots;
245
+ #host;
246
+ #native;
247
+ #events;
248
+ #params;
249
+ #guides;
250
+ #backing;
251
+ #offFault;
252
+ #phase = "armed";
253
+ #failed = false;
254
+ /**
255
+ * The mixer can no longer play its song: docs/design.md section 5. Fires once, and a mixer
256
+ * being destroyed has no song left to lose.
257
+ */
258
+ #fail = (error) => {
259
+ if (this.#failed || this.#phase === "destroyed") return;
260
+ this.#failed = true;
261
+ this.#events.emit("error", error);
262
+ };
263
+ constructor(host, kinds, events, backing, rtpReturn) {
264
+ this.#host = host;
265
+ this.#events = events;
266
+ this.#native = host.native.createMixer([...kinds], rtpReturn);
267
+ this.slots = kinds.map((kind, index) => ({
268
+ index,
269
+ kind,
270
+ port: this.#native.port(index)
271
+ }));
272
+ this.#params = kinds.map(defaultSlotParams);
273
+ this.#guides = kinds.map(() => null);
274
+ this.#backing = backing === null ? null : new Backing(host, backing, this.#native, {
275
+ progress: (fraction) => {
276
+ if (this.#phase !== "destroyed") this.#events.emit("renderProgress", fraction);
277
+ },
278
+ failed: this.#fail
279
+ });
280
+ this.#offFault = host.onFault(this.#fail);
281
+ }
282
+ setSlot(index, settings) {
283
+ const slot = this.#slot(index);
284
+ const current = this.#paramsOf(slot);
285
+ this.#write(slot, {
286
+ ...current,
287
+ gain: settings.gain === void 0 ? current.gain : finiteIn("gain", settings.gain, 0, 4),
288
+ pan: settings.pan === void 0 ? current.pan : finiteIn("pan", settings.pan, -1, 1),
289
+ mute: settings.mute ?? current.mute,
290
+ solo: settings.solo ?? current.solo
291
+ });
292
+ }
293
+ /** The slot's whole chain: an effect not in `effects` is disabled. */
294
+ setSlotEffects(index, effects) {
295
+ const slot = this.#slot(index);
296
+ const next = {
297
+ ...this.#paramsOf(slot),
298
+ ...defaultEffects()
299
+ };
300
+ for (const effect of effects) switch (effect.kind) {
301
+ case "pitch-correction":
302
+ next.corrector = {
303
+ enabled: effect.enabled,
304
+ strength: finiteIn("strength", effect.strength, 0, 1),
305
+ retuneMs: finiteIn("retuneMs", effect.retuneMs, 0, 1e3)
306
+ };
307
+ break;
308
+ case "reverb": next.reverb = {
309
+ enabled: effect.enabled,
310
+ roomSize: finiteIn("roomSize", effect.roomSize, 0, 1),
311
+ mix: finiteIn("mix", effect.mix, 0, 1)
312
+ };
313
+ }
314
+ this.#write(slot, next);
315
+ }
316
+ setSlotGuide(index, guide, guideOffsetMs) {
317
+ const slot = this.#slot(index);
318
+ const input = guide === null ? null : toGuideInput(guide);
319
+ if (guideOffsetMs !== void 0) this.#write(slot, {
320
+ ...this.#paramsOf(slot),
321
+ guideOffsetMs: finiteIn("guideOffsetMs", guideOffsetMs, -1e3, 1e3)
322
+ });
323
+ this.#guides[slot] = input;
324
+ if (this.#host.isLive(this)) this.#host.native.setGuide(slot, input);
325
+ }
326
+ /**
327
+ * Forget the stream a slot was hearing; the next packets, or the next pushed audio, are a new
328
+ * singer. An armed mixer has heard nothing yet, so there is nothing to forget.
329
+ */
330
+ resetSlot(index) {
331
+ const slot = this.#slot(index);
332
+ if (this.#host.isLive(this)) this.#host.native.resetSlot(slot);
333
+ }
334
+ /**
335
+ * Writes every datagram that reaches an `rtp` or `l16` slot's port to a new pcap file at `path`,
336
+ * with the moment the network thread read it, until `null`, another path or `destroy`. The file
337
+ * is created here and never over an existing one, so a bad path throws. A recording holds a
338
+ * singer's voice and their phone's LAN address: it is made only when a person asks for one, and
339
+ * nothing in this package sends it anywhere. Start it after `start`, so a replay begins where
340
+ * the live slot did. docs/design.md 8.4.
341
+ */
342
+ recordSlot(index, path) {
343
+ const slot = this.#slot(index);
344
+ if (this.slots[slot]?.kind === "push") throw new Error(`slot ${slot} is a push slot; only rtp and l16 slots record`);
345
+ if (this.#phase === "destroyed") throw new Error("the mixer is destroyed");
346
+ if (path === "") throw new RangeError("path must name a file");
347
+ this.#native.recordSlot(slot, path);
348
+ }
349
+ /**
350
+ * Hands interleaved stereo PCM to a push slot, folded to mono as every slot is. Returns the
351
+ * frames taken; the rest did not fit and is dropped, since late audio is worse than lost audio.
352
+ * Throws on a slot of another kind.
353
+ */
354
+ push(index, stereo) {
355
+ const slot = this.#slot(index);
356
+ const kind = this.slots[slot]?.kind;
357
+ if (kind !== "push") throw new Error(`slot ${slot} is ${String(kind)}; only a push slot takes pushed audio`);
358
+ if (this.#phase === "destroyed") throw new Error("the mixer is destroyed");
359
+ return this.#native.push(slot, pcm("stereo", stereo, 2));
360
+ }
361
+ /**
362
+ * Waits for the backing's render and swaps the graph in; a render that failed rejects with the
363
+ * message `error` carried. With the render already done, the swap is sent before this returns
364
+ * to the caller. Nothing is written to the engine while another mixer is live, so a refused
365
+ * start leaves the song playing untouched.
366
+ */
367
+ async start() {
368
+ if (this.#phase !== "armed") throw new Error(`the mixer cannot start: it is ${this.#phase}`);
369
+ this.#phase = "starting";
370
+ let swapped;
371
+ try {
372
+ if (this.#backing?.rendering) await this.#backing.rendered;
373
+ if (this.#host.anyLive()) throw new Error("another mixer is live; destroy it first");
374
+ this.#backing?.install();
375
+ this.#params.forEach((params, slot) => {
376
+ this.#host.native.setSlotParams(slot, params);
377
+ });
378
+ if (this.#backing) this.#host.native.setStemGains(this.#backing.stemGains);
379
+ swapped = this.#host.swapIn(this, this.#native, this.#events, this.#fail);
380
+ } catch (error) {
381
+ if (this.#phase === "starting") this.#phase = "armed";
382
+ throw error;
383
+ }
384
+ this.#phase = "live";
385
+ this.#guides.forEach((guide, slot) => {
386
+ if (guide) this.#host.native.setGuide(slot, guide);
387
+ });
388
+ const started = this.#command(() => this.#host.native.start());
389
+ const stopAtFrames = this.#backing?.stopAtFrames;
390
+ const stopping = stopAtFrames === void 0 ? void 0 : this.#command(() => this.#host.native.stopAt(stopAtFrames, msToFrames(STOP_FADE_MS)));
391
+ await Promise.all([
392
+ swapped,
393
+ started,
394
+ stopping
395
+ ]);
396
+ const { latencyMs } = this.#host.output;
397
+ return {
398
+ startedAt: Date.now() + latencyMs,
399
+ backingOffsetMs: latencyMs,
400
+ speedFactor: this.#backing?.speed ?? 1
401
+ };
402
+ }
403
+ /** Resolves with the position the seek landed on, once its fade out is done. */
404
+ async seek(toMs) {
405
+ const toFrame = msToFrames(finiteIn("toMs", toMs, 0, Number.MAX_SAFE_INTEGER));
406
+ const native = this.#liveBacking();
407
+ return { positionMs: framesToMs(await this.#command(() => native.seek(toFrame))) };
408
+ }
409
+ /** Resolves once the fade out is done and the playhead holds. */
410
+ async pause() {
411
+ const native = this.#liveBacking();
412
+ await this.#command(() => native.pause());
413
+ }
414
+ async resume() {
415
+ const native = this.#liveBacking();
416
+ return { positionMs: framesToMs(await this.#command(() => native.resume())) };
417
+ }
418
+ /** The live key, on top of the armed one; its crossfade follows the ack. */
419
+ async setBackingKey(semitones) {
420
+ const checked = integerIn("semitones", semitones, -12, 12);
421
+ const native = this.#liveBacking();
422
+ await this.#command(() => native.setKey(checked));
423
+ }
424
+ /** A parameter write, held while armed like the slot settings. */
425
+ setStemGain(kind, gain) {
426
+ if (!this.#backing) throw new Error("this mixer has no backing");
427
+ const gains = this.#backing.setStemGain(kind, gain);
428
+ if (this.#host.isLive(this)) this.#host.native.setStemGains(gains);
429
+ }
430
+ on(event, listener) {
431
+ return this.#events.on(event, listener);
432
+ }
433
+ /**
434
+ * Each slot's ingest counters as the audio thread last published them, the engine's underruns,
435
+ * and the blocks the return stream dropped, `null` without one.
436
+ */
437
+ stats() {
438
+ return {
439
+ slots: this.#native.stats(),
440
+ underruns: this.#host.underruns(),
441
+ rtpReturn: this.#native.returnStats()
442
+ };
443
+ }
444
+ /**
445
+ * Closes the slots' ports, stops the return stream, swaps the silent graph in when this one is
446
+ * live, and resolves once this graph is back and dropped.
447
+ */
448
+ async destroy() {
449
+ switch (this.#phase) {
450
+ case "destroyed": return;
451
+ case "starting":
452
+ if (!this.#failed) throw new Error("the mixer is starting; await start before destroying it");
453
+ this.#release();
454
+ return;
455
+ case "armed":
456
+ this.#release();
457
+ return;
458
+ case "live":
459
+ this.#release();
460
+ await this.#host.swapOut(this);
461
+ }
462
+ }
463
+ #release() {
464
+ this.#phase = "destroyed";
465
+ this.#offFault();
466
+ this.#native.release();
467
+ }
468
+ #command(send) {
469
+ return this.#host.command(send, this.#fail);
470
+ }
471
+ #slot(index) {
472
+ return integerIn("slot", index, 0, this.slots.length - 1);
473
+ }
474
+ /** The engine, once this mixer's backing is the live one. */
475
+ #liveBacking() {
476
+ if (!this.#backing) throw new Error("this mixer has no backing");
477
+ if (!this.#host.isLive(this)) throw new Error(`the backing cannot be steered: the mixer is ${this.#phase}`);
478
+ return this.#host.native;
479
+ }
480
+ #paramsOf(slot) {
481
+ const params = this.#params[slot];
482
+ if (!params) throw new RangeError(`no slot ${slot}`);
483
+ return params;
484
+ }
485
+ #write(slot, params) {
486
+ this.#params[slot] = params;
487
+ if (this.#host.isLive(this)) this.#host.native.setSlotParams(slot, params);
488
+ }
489
+ };
490
+ function defaultEffects() {
491
+ const { corrector, reverb } = defaultSlotParams();
492
+ return {
493
+ corrector,
494
+ reverb
495
+ };
496
+ }
497
+ function toGuideInput(guide) {
498
+ return {
499
+ keyMask: integerIn("keyMask", guide.keyMask, 0, 4095),
500
+ notes: guide.notes.map((note, i) => {
501
+ const startMs = finiteIn(`notes[${i}].startMs`, note.startMs, 0, Number.MAX_SAFE_INTEGER);
502
+ return {
503
+ midi: integerIn(`notes[${i}].midi`, note.midi, 0, 127),
504
+ startFrame: msToFrames(startMs),
505
+ endFrame: msToFrames(finiteIn(`notes[${i}].endMs`, note.endMs, startMs, Number.MAX_SAFE_INTEGER))
506
+ };
507
+ })
508
+ };
509
+ }
510
+ const NativeEngine = createRequire(import.meta.url)("../native/binding/index.js").Engine;
511
+ // src/engine.ts
512
+ async function openEngine(options) {
513
+ return new Engine(options);
514
+ }
515
+ var Engine = class {
516
+ capabilities = /* @__PURE__ */ new Set([
517
+ "guideVocal",
518
+ "key",
519
+ "speed",
520
+ "seek",
521
+ "shortVersion",
522
+ "pause",
523
+ "level"
524
+ ]);
525
+ output;
526
+ /** How many slots a mixer may have, fixed at open. */
527
+ #slots;
528
+ /** Offline engines only: render blocks and return them as interleaved stereo. */
529
+ pump;
530
+ #native;
531
+ #host;
532
+ #acks = new Acks();
533
+ #events = new Emitter();
534
+ /** Swaps in flight, oldest first; a `retired` event completes the oldest. */
535
+ #retirements = [];
536
+ /** Render jobs on the worker thread, by the id their progress events carry. */
537
+ #renders = /* @__PURE__ */ new Map();
538
+ #live = null;
539
+ #underruns = 0;
540
+ #state = "running";
541
+ constructor(options) {
542
+ this.#slots = integerIn("slots", options.slots ?? 8, 1, 16);
543
+ const playoutDelayFrames = msToFrames(finiteIn("playoutDelayMs", options.playoutDelayMs ?? 15, 5, 100));
544
+ const deliver = (batch) => {
545
+ this.#deliver(batch);
546
+ };
547
+ if ("offline" in options.output) {
548
+ this.#native = new NativeEngine({
549
+ clock: "offline",
550
+ periodFrames: 128,
551
+ playoutDelayFrames
552
+ }, deliver);
553
+ this.pump = (blocks) => this.#native.pump(integerIn("blocks", blocks, 1, 1 << 20));
554
+ } else if ("timer" in options.output) this.#native = new NativeEngine({
555
+ clock: "timer",
556
+ periodFrames: 128,
557
+ playoutDelayFrames
558
+ }, deliver);
559
+ else {
560
+ const { device, periodFrames = 128 } = options.output;
561
+ if (periodFrames !== 64 && periodFrames !== 128) throw new RangeError(`periodFrames must be 64 or 128, got ${String(periodFrames)}`);
562
+ this.#native = new NativeEngine({
563
+ clock: "device",
564
+ periodFrames,
565
+ playoutDelayFrames,
566
+ ...device === void 0 ? {} : { device }
567
+ }, deliver);
568
+ }
569
+ this.output = outputInfo(this.#native.output);
570
+ this.#host = {
571
+ native: this.#native,
572
+ output: this.output,
573
+ command: (send, onTimeout) => this.#command(send, onTimeout),
574
+ watchRender: (id, onProgress) => new Promise((resolve, reject) => {
575
+ this.#renders.set(id, {
576
+ onProgress,
577
+ resolve,
578
+ reject
579
+ });
580
+ }),
581
+ swapIn: (mixer, graph, events, onTimeout) => this.#swapIn(mixer, graph, events, onTimeout),
582
+ swapOut: (mixer) => this.#swapOut(mixer),
583
+ onFault: (listener) => this.#events.on("fault", listener),
584
+ isLive: (mixer) => this.#live?.mixer === mixer,
585
+ anyLive: () => this.#live !== null,
586
+ underruns: () => this.#underruns
587
+ };
588
+ }
589
+ /** Room-lifetime reaction voices, four at once. False when a fifth is dropped while four play. */
590
+ reactions = { play: (samples, channels) => {
591
+ if (channels !== 1 && channels !== 2) throw new RangeError(`channels must be 1 or 2, got ${String(channels)}`);
592
+ pcm("samples", samples, channels);
593
+ this.#assertRunning();
594
+ return this.#native.playReaction(channels === 2 ? samples : monoToStereo(samples));
595
+ } };
596
+ async outputDevices() {
597
+ this.#assertRunning();
598
+ return this.#native.outputs();
599
+ }
600
+ async createMixer(config) {
601
+ this.#assertRunning();
602
+ if (config.slots.length > this.#slots) throw new RangeError(`the engine has ${this.#slots} slots; the mixer asks for ${config.slots.length}`);
603
+ const kinds = config.slots.map(({ kind }, i) => ingestKind(`slots[${i}].kind`, kind));
604
+ const destination = config.rtpReturn === void 0 ? null : rtpReturn(config.rtpReturn);
605
+ return new Mixer(this.#host, kinds, new Emitter(), config.backing ?? null, destination);
606
+ }
607
+ on(event, listener) {
608
+ return this.#events.on(event, listener);
609
+ }
610
+ /** Destroys the live mixer, stops the clock and joins the events thread. */
611
+ async close() {
612
+ if (this.#state === "closed") return;
613
+ if (this.#live && this.#state === "running") await this.#live.mixer.destroy();
614
+ this.#state = "closed";
615
+ this.#native.close();
616
+ this.#stop(/* @__PURE__ */ new Error("the engine is closed"));
617
+ }
618
+ #swapIn(mixer, graph, events, onTimeout) {
619
+ if (this.#live) throw new Error("another mixer is live; destroy it first");
620
+ const swapped = this.#command(() => this.#native.swap(graph), onTimeout);
621
+ this.#live = {
622
+ mixer,
623
+ events
624
+ };
625
+ this.#retirements.push(() => {});
626
+ return swapped;
627
+ }
628
+ async #swapOut(mixer) {
629
+ if (this.#live?.mixer !== mixer) return;
630
+ if (this.#state !== "running") {
631
+ this.#live = null;
632
+ return;
633
+ }
634
+ const swapped = this.#command(() => this.#native.swapSilent());
635
+ this.#live = null;
636
+ const retired = new Promise((resolve) => this.#retirements.push(resolve));
637
+ await swapped;
638
+ await retired;
639
+ }
640
+ #command(send, onTimeout) {
641
+ this.#assertRunning();
642
+ return this.#acks.wait(send(), () => this.#state, onTimeout);
643
+ }
644
+ #assertRunning() {
645
+ if (this.#state !== "running") throw new Error(this.#state === "faulted" ? "the engine has faulted; close it and open it again" : "the engine is closed");
646
+ }
647
+ #deliver(batch) {
648
+ if (this.#state === "closed") return;
649
+ for (const event of batch) switch (event.kind) {
650
+ case "ack":
651
+ this.#acks.resolve(event.id, event.positionFrames);
652
+ break;
653
+ case "level":
654
+ this.#live?.events.emit("level", event.slot, event.rmsDb, event.peakDb);
655
+ break;
656
+ case "pitch":
657
+ this.#live?.events.emit("pitch", event.slot, event.positionMs, event.hz, event.confidence);
658
+ break;
659
+ case "position":
660
+ this.#live?.events.emit("position", framesToMs(event.frames));
661
+ break;
662
+ case "backingEnded":
663
+ this.#live?.events.emit("backingEnded");
664
+ break;
665
+ case "masterLevel":
666
+ this.#events.emit("masterLevel", event.rmsDb, event.peakDb);
667
+ break;
668
+ case "underrun":
669
+ this.#underruns += 1;
670
+ break;
671
+ case "fault":
672
+ this.#fault();
673
+ break;
674
+ case "retired":
675
+ this.#retirements.shift()?.();
676
+ break;
677
+ case "renderProgress":
678
+ this.#renders.get(event.job)?.onProgress(event.fraction);
679
+ break;
680
+ case "renderFinished": {
681
+ const render = this.#renders.get(event.job);
682
+ this.#renders.delete(event.job);
683
+ render?.resolve();
684
+ break;
685
+ }
686
+ }
687
+ }
688
+ /**
689
+ * A device loss or a caught panic: the host closes and reopens; nothing here recovers. `fault`
690
+ * reaches the host's listeners and every mixer not yet destroyed, each of which emits `error`.
691
+ */
692
+ #fault() {
693
+ if (this.#state !== "running") return;
694
+ this.#state = "faulted";
695
+ const error = /* @__PURE__ */ new Error("the engine has faulted; close it and open it again");
696
+ this.#stop(error);
697
+ this.#events.emit("fault", error);
698
+ }
699
+ /** Nothing in flight will be answered: the commands and the renders waited on reject. */
700
+ #stop(error) {
701
+ this.#acks.rejectAll(error);
702
+ for (const render of this.#renders.values()) render.reject(error);
703
+ this.#renders.clear();
704
+ }
705
+ };
706
+ function outputInfo(info) {
707
+ if (info.sampleRate !== 48e3) throw new Error(`the engine runs at 48 kHz; the addon reports ${info.sampleRate}`);
708
+ return {
709
+ sampleRate: 48e3,
710
+ periodFrames: info.periodFrames,
711
+ latencyMs: info.latencyMs
712
+ };
713
+ }
714
+ function monoToStereo(mono) {
715
+ const stereo = new Float32Array(mono.length * 2);
716
+ mono.forEach((sample, i) => {
717
+ stereo[2 * i] = sample;
718
+ stereo[2 * i + 1] = sample;
719
+ });
720
+ return stereo;
721
+ }
722
+ export { Engine, Mixer, openEngine };