@aeonentertainment/mixer 0.4.0 → 0.6.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/README.md CHANGED
@@ -15,7 +15,7 @@ Each [release](https://github.com/aeonentertainment/aeon-mixer/releases) carries
15
15
  Depend on its URL, and the lockfile pins its hash:
16
16
 
17
17
  ```bash
18
- npm install https://github.com/aeonentertainment/aeon-mixer/releases/download/v0.4.0/aeonentertainment-mixer-0.4.0.tgz
18
+ npm install https://github.com/aeonentertainment/aeon-mixer/releases/download/v0.6.0/aeonentertainment-mixer-0.6.0.tgz
19
19
  ```
20
20
 
21
21
  ## Use
@@ -50,9 +50,11 @@ await engine.close();
50
50
 
51
51
  - **Slots** are the mics: 8 per engine by default, up to 16 with `openEngine({ slots })`. `rtp`
52
52
  takes Opus over UDP from a phone or mediasoup, `l16` takes raw PCM over loopback from a house
53
- mic, and `push` takes PCM the host hands to `mixer.push`. Each slot has gain, pan, mute, solo,
54
- reverb, a pitch reading, a melody guide and pitch correction toward it, whose delay `stats()`
55
- reports.
53
+ mic's sender, `capture` takes a mic on this machine by an id from `engine.inputDevices()`, and
54
+ `push` takes PCM the host hands to `mixer.push`. Each slot has gain, pan, mute, solo, reverb,
55
+ a pitch reading, a melody guide and pitch correction toward it, whose delay `stats()` reports.
56
+ A slot can also take the room's echo of the mix out of its input and duck itself when its loop
57
+ through the speakers starts to howl; both add no delay, and `stats()` reports what they found.
56
58
  - **The backing** is any format Symphonia decodes, resampled to 48 kHz. The armed key and speed
57
59
  are rendered before `start` resolves. Live, it supports seek, pause, resume, a key change and
58
60
  stem gains, and `start`, `seek` and `resume` report the position the lyric clock follows.
package/dist/index.d.mts CHANGED
@@ -13,15 +13,22 @@ declare class Engine$1 {
13
13
  */
14
14
  constructor(options: OpenOptions, onEvents: (arg: Array<NativeEvent>) => void);
15
15
  /**
16
- * Builds a mixer with one slot per kind, in order: `"rtp"`, `"l16"` or `"push"`. The rtp
17
- * and l16 slots bind their ports here and report them through `Mixer.port`; a return stream
18
- * gets its socket and send task here too.
16
+ * Builds a mixer with one slot per input, in order: `"rtp"`, `"l16"`, `"push"` or
17
+ * `"capture"`. The rtp and l16 slots bind their ports here and report them through
18
+ * `Mixer.port`, the capture slots start their inputs here, and a return stream gets its socket
19
+ * and send task here too.
19
20
  */
20
- createMixer(kinds: Array<string>, rtpReturn?: RtpReturnInput | undefined | null): Mixer$1;
21
+ createMixer(slots: Array<SlotInput>, rtpReturn?: RtpReturnInput | undefined | null): Mixer$1;
21
22
  /** The output playing now. After a move the events thread sends the same as `outputChanged`. */
22
23
  get output(): OutputInfo$1;
23
24
  /** The outputs this OS offers, the default marked. Empty on the offline and timer clocks. */
24
25
  outputs(): Array<OutputEntry>;
26
+ /**
27
+ * The inputs this OS offers, the default marked: the devices a `capture` slot may name. Empty
28
+ * on the offline clock, which opens none. A box on the timer clock plays nothing locally but
29
+ * may still have a mic plugged in.
30
+ */
31
+ inputs(): Array<OutputEntry>;
25
32
  /** Offline clock only: renders `blocks` blocks and returns them as interleaved stereo. */
26
33
  pump(blocks: number): Float32Array;
27
34
  /** Writes one slot of the snapshot and publishes the whole of it. */
@@ -61,7 +68,7 @@ declare class Engine$1 {
61
68
  declare class Mixer$1 {
62
69
  /** Moves a finished render's stems into the graph as its backing path, before `swap`. */
63
70
  installBacking(render: BackingRender): void;
64
- /** The UDP port a slot listens on, or null for a push slot. */
71
+ /** The UDP port a slot listens on, or null for a push or capture slot. */
65
72
  port(index: number): number | null;
66
73
  /**
67
74
  * Hands interleaved stereo PCM to a push slot, which folds it to mono as every slot is
@@ -73,7 +80,10 @@ declare class Mixer$1 {
73
80
  * recording it for null.
74
81
  */
75
82
  recordSlot(index: number, path?: string | undefined | null): void;
76
- /** Every slot's ingest counters and corrector delay, as the audio thread last published them. */
83
+ /**
84
+ * Every slot's ingest counters, corrector delay and echo findings, as the audio thread last
85
+ * published them.
86
+ */
77
87
  stats(): Array<SlotStatsInfo>;
78
88
  /** The return stream's counters, or null for a mixer without one. */
79
89
  returnStats(): RtpReturnStats | null;
@@ -91,11 +101,23 @@ interface BackingInput {
91
101
  /** The armed speed factor, rendered before start. */
92
102
  speed: number;
93
103
  }
104
+ interface CleanupInput {
105
+ enabled: boolean;
106
+ }
94
107
  interface CorrectorInput {
95
108
  enabled: boolean;
96
109
  strength: number;
97
110
  retuneMs: number;
98
111
  }
112
+ /** What a slot's echo canceller has found, as JavaScript reads it. */
113
+ interface EchoInfo {
114
+ /** From the master being rendered to its echo reaching the slot. */
115
+ pathMs: number;
116
+ /** How far under the master the echo arrives, over the last second. */
117
+ bleedDb: number;
118
+ /** How much quieter the slot came out than it went in, over the last second. */
119
+ removedDb: number;
120
+ }
99
121
  interface GuideInput {
100
122
  notes: Array<NoteInput>;
101
123
  /** The key's pitch classes as bits, C in bit 0. */
@@ -155,8 +177,11 @@ interface OpenOptions {
155
177
  /** A device clock's output: an id from `outputs` to pin, or none to follow the default output. */
156
178
  device?: string;
157
179
  periodFrames: number;
158
- /** The jitter buffer's playout delay for every `rtp` and `l16` slot. */
159
- playoutDelayFrames: number;
180
+ /**
181
+ * Pins the jitter buffer's playout delay for every `rtp` and `l16` slot. Without it, each
182
+ * stream chooses its own.
183
+ */
184
+ playoutDelayFrames?: number;
160
185
  }
161
186
  interface OutputDevice {
162
187
  id: string;
@@ -192,6 +217,15 @@ interface RtpReturnStats {
192
217
  /** Blocks the audio thread dropped because the network thread had not drained the ring. */
193
218
  droppedBlocks: number;
194
219
  }
220
+ /**
221
+ * One slot as `createMixer` asks for it. `device` and `channel` belong to a `capture` slot: an id
222
+ * from `inputs`, or the default input, and the device's channel from 0.
223
+ */
224
+ interface SlotInput {
225
+ kind: string;
226
+ device?: string;
227
+ channel?: number;
228
+ }
195
229
  interface SlotParamsInput {
196
230
  /** Linear amplitude. */
197
231
  gain: number;
@@ -202,6 +236,9 @@ interface SlotParamsInput {
202
236
  corrector: CorrectorInput;
203
237
  reverb: ReverbInput;
204
238
  voice: VoiceInput;
239
+ echoCancel: SwitchInput;
240
+ cleanup: CleanupInput;
241
+ howlGuard: SwitchInput;
205
242
  guideOffsetMs: number;
206
243
  }
207
244
  /** `SlotStats` as JavaScript reads it. */
@@ -209,16 +246,25 @@ interface SlotStatsInfo {
209
246
  pushed: number;
210
247
  lost: number;
211
248
  late: number;
249
+ lateAtOpening: number;
212
250
  concealed: number;
213
251
  resyncs: number;
214
252
  jitterMs: number;
215
253
  correctorDelayMs: number;
216
254
  correctorMaxDelayMs: number;
255
+ /** Null while the canceller is off or has yet to find and learn the room's echo. */
256
+ echo: EchoInfo | null;
257
+ playoutDelayMs: number;
258
+ howlCatches: number;
217
259
  }
218
260
  interface StemGainsInput {
219
261
  instrumental: number;
220
262
  guide: number;
221
263
  }
264
+ /** An effect with no knob but on and off. */
265
+ interface SwitchInput {
266
+ enabled: boolean;
267
+ }
222
268
  interface VoiceInput {
223
269
  enabled: boolean;
224
270
  formantSemitones: number;
@@ -227,7 +273,7 @@ declare const NativeEngine: typeof Engine$1;
227
273
  type NativeEngine = Engine$1;
228
274
  type NativeMixer = Mixer$1;
229
275
  type Capability = "guideVocal" | "key" | "speed" | "seek" | "shortVersion" | "pause" | "level";
230
- type IngestKind = "rtp" | "l16" | "push";
276
+ type IngestKind = "rtp" | "l16" | "push" | "capture";
231
277
  type StemKind = "instrumental" | "guideVocals";
232
278
  interface OpenEngineOptions {
233
279
  /**
@@ -245,7 +291,10 @@ interface OpenEngineOptions {
245
291
  };
246
292
  /** Fixed for the engine's life, default 8, at most 16. */
247
293
  slots?: number;
248
- /** The ingest jitter buffer, default 15. */
294
+ /**
295
+ * Pins the ingest jitter buffer to this delay. Leave it out and each slot chooses its own from
296
+ * what its stream shows, opening at 15; `stats()` reports where each one is.
297
+ */
249
298
  playoutDelayMs?: number;
250
299
  }
251
300
  interface OutputInfo {
@@ -258,10 +307,20 @@ interface OutputInfo {
258
307
  readonly name: string;
259
308
  } | null;
260
309
  }
310
+ /**
311
+ * One slot as a mixer asks for it. A `capture` slot is an input on this machine, a house mic:
312
+ * `device` is an id from `inputDevices()`, the default input when left out, and `channel` the
313
+ * device's channel from 0, default 0.
314
+ */
315
+ type SlotConfig = {
316
+ kind: Exclude<IngestKind, "capture">;
317
+ } | {
318
+ kind: "capture";
319
+ device?: string;
320
+ channel?: number;
321
+ };
261
322
  interface MixerConfig {
262
- slots: ReadonlyArray<{
263
- kind: IngestKind;
264
- }>;
323
+ slots: ReadonlyArray<SlotConfig>;
265
324
  backing?: {
266
325
  stems: ReadonlyArray<{
267
326
  kind: StemKind;
@@ -308,6 +367,24 @@ type SlotEffect = {
308
367
  kind: "voice";
309
368
  enabled: boolean;
310
369
  formantSemitones: number;
370
+ } |
371
+ /** Takes the room's echo of the mix out of the slot's input, with no added delay. */
372
+ {
373
+ kind: "echo-cancel";
374
+ enabled: boolean;
375
+ } |
376
+ /**
377
+ * A high-pass and gate ahead of the corrector and a compressor after the meter. None reads
378
+ * ahead or buffers; decision 0012 has their numbers and costs.
379
+ */
380
+ {
381
+ kind: "cleanup";
382
+ enabled: boolean;
383
+ } |
384
+ /** Ducks the slot when its loop through the speakers starts to howl, with no added delay. */
385
+ {
386
+ kind: "howl-guard";
387
+ enabled: boolean;
311
388
  };
312
389
  interface MelodyGuide {
313
390
  notes: ReadonlyArray<{
@@ -322,6 +399,8 @@ interface SlotStats {
322
399
  pushed: number;
323
400
  lost: number;
324
401
  late: number;
402
+ /** Of `late`, the packets that would also have been late had the delay stayed where it opened. */
403
+ lateAtOpening: number;
325
404
  concealed: number;
326
405
  resyncs: number;
327
406
  jitterMs: number;
@@ -332,6 +411,25 @@ interface SlotStats {
332
411
  correctorDelayMs: number;
333
412
  /** The most that delay can reach; 0 with correction off. It holds still, so a budget adds this. */
334
413
  correctorMaxDelayMs: number;
414
+ /**
415
+ * What the echo canceller found: `pathMs` from the mix being rendered to its echo reaching the
416
+ * slot, `bleedDb` how far under the mix that echo arrives, `removedDb` how much quieter the slot
417
+ * came out than it went in, both over the last second. Null while it is off, or until it has
418
+ * found the echo and learned it.
419
+ */
420
+ echo: {
421
+ pathMs: number;
422
+ bleedDb: number;
423
+ removedDb: number;
424
+ } | null;
425
+ /**
426
+ * The delay the stream's earliest packets wait now; zero for a `push` slot. A `capture` slot
427
+ * has no jitter buffer: this is how long its mic's samples wait from the input to the mix, the
428
+ * input's period included, and zero until it first plays.
429
+ */
430
+ playoutDelayMs: number;
431
+ /** How often the howl guard has ducked this slot. */
432
+ howlCatches: number;
335
433
  }
336
434
  type EngineEvents = {
337
435
  fault: (error: Error) => void;
@@ -393,25 +491,33 @@ interface MixerHost extends RenderHost {
393
491
  swapOut(mixer: Mixer): Promise<void>;
394
492
  /** Hears the engine's fault until the returned function is called. */
395
493
  onFault(listener: (error: Error) => void): () => void;
494
+ /**
495
+ * Holds `release` until the returned function is called, and calls it when the engine closes:
496
+ * a mixer's capture inputs and ports outlive no engine, whatever state the mixer is in.
497
+ */
498
+ hold(release: () => void): () => void;
396
499
  isLive(mixer: Mixer): boolean;
397
500
  anyLive(): boolean;
398
501
  underruns(): number;
399
502
  }
400
503
  /**
401
- * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports from
402
- * here until `destroy`; its return stream sends the mix from `start` until `destroy`. Settings
504
+ * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports, and its
505
+ * `capture` slots hold their inputs open, from here until `destroy`; its return stream sends the
506
+ * mix from `start` until `destroy`. Settings
403
507
  * made while armed are kept here and written to the engine when `start` swaps the graph in, so
404
508
  * arming the next song never touches the live one.
405
509
  */
406
510
  export declare class Mixer {
407
511
  #private;
408
- /** Each slot's kind and, for `rtp` and `l16`, the UDP port it listens on; `null` for `push`. */
512
+ /** Each slot's kind and, for `rtp` and `l16`, the UDP port it listens on; `null` for the rest. */
409
513
  readonly slots: ReadonlyArray<{
410
514
  index: number;
411
515
  kind: IngestKind;
412
516
  port: number | null;
413
517
  }>;
414
- constructor(host: MixerHost, kinds: ReadonlyArray<IngestKind>, events: Emitter<MixerEvents>, backing: BackingConfig | null, rtpReturn: RtpReturnInput | null);
518
+ constructor(host: MixerHost, inputs: ReadonlyArray<SlotInput & {
519
+ kind: IngestKind;
520
+ }>, events: Emitter<MixerEvents>, backing: BackingConfig | null, rtpReturn: RtpReturnInput | null);
415
521
  setSlot(index: number, settings: Partial<SlotSettings>): void;
416
522
  /** The slot's whole chain: an effect not in `effects` is disabled. */
417
523
  setSlotEffects(index: number, effects: ReadonlyArray<SlotEffect>): void;
@@ -510,9 +616,15 @@ export declare class Engine {
510
616
  name: string;
511
617
  isDefault: boolean;
512
618
  }>>;
619
+ /** The inputs a `capture` slot may name, the default marked. None on an offline engine. */
620
+ inputDevices(): Promise<ReadonlyArray<{
621
+ id: string;
622
+ name: string;
623
+ isDefault: boolean;
624
+ }>>;
513
625
  createMixer(config: MixerConfig): Promise<Mixer>;
514
626
  on<K extends keyof EngineEvents>(event: K, listener: EngineEvents[K]): () => void;
515
627
  /** Destroys the live mixer, stops the clock and joins the events thread. */
516
628
  close(): Promise<void>;
517
629
  }
518
- export type { Capability, EngineEvents, IngestKind, MelodyGuide, MixerConfig, MixerEvents, OfflineEngine, OpenEngineOptions, OutputInfo, SlotEffect, SlotSettings, SlotStats, StemKind };
630
+ export type { Capability, EngineEvents, IngestKind, MelodyGuide, MixerConfig, MixerEvents, OfflineEngine, OpenEngineOptions, OutputInfo, SlotConfig, SlotEffect, SlotSettings, SlotStats, StemKind };
package/dist/index.mjs CHANGED
@@ -72,14 +72,27 @@ function framesToMs(frames) {
72
72
  const INGEST_KINDS = [
73
73
  "rtp",
74
74
  "l16",
75
- "push"
75
+ "push",
76
+ "capture"
76
77
  ];
77
78
  /** A kind as a JavaScript caller may have spelt it, so an unknown one never crosses. */
78
79
  function ingestKind(name, value) {
79
80
  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
+ if (kind === void 0) throw new RangeError(`${name} must be rtp, l16, push or capture, got ${value}`);
81
82
  return kind;
82
83
  }
84
+ /** A slot as the addon takes it: its kind, and for a `capture` slot its device and channel. */
85
+ function slotInput(name, slot) {
86
+ const kind = ingestKind(`${name}.kind`, slot.kind);
87
+ if (slot.kind !== "capture") return { kind };
88
+ const { device, channel = 0 } = slot;
89
+ if (device !== void 0 && (typeof device !== "string" || device === "")) throw new TypeError(`${name}.device must be an id from inputDevices()`);
90
+ return {
91
+ kind,
92
+ channel: integerIn(`${name}.channel`, channel, 0, 255),
93
+ ...device === void 0 ? {} : { device }
94
+ };
95
+ }
83
96
  /**
84
97
  * Where the mix goes: an IP literal, since resolving a name would block the control thread, and a
85
98
  * dynamic RTP payload type, since Opus has no static one.
@@ -108,6 +121,20 @@ function pcm(name, value, channels) {
108
121
  return value;
109
122
  }
110
123
  // src/backing.ts
124
+ /**
125
+ * `config` checked, before anything opens: a mixer runs this ahead of its native mixer, so bad
126
+ * backing settings reject `createMixer` with no port bound and no input held.
127
+ */
128
+ function backingSettings(config) {
129
+ return {
130
+ input: toInput(config),
131
+ stopAtFrames: config.stopAtMs === void 0 ? void 0 : msToFrames(finiteIn("stopAtMs", config.stopAtMs, 0, Number.MAX_SAFE_INTEGER)),
132
+ stemGains: {
133
+ instrumental: finiteIn("stemGains.instrumental", config.stemGains.instrumental, 0, 4),
134
+ guide: finiteIn("stemGains.guideVocals", config.stemGains.guideVocals, 0, 4)
135
+ }
136
+ };
137
+ }
111
138
  /** `stopAtMs` fades the stems out over this before falling silent: docs/design.md section 4. */
112
139
  const STOP_FADE_MS = 3e3;
113
140
  /**
@@ -130,18 +157,15 @@ var Backing = class {
130
157
  #render;
131
158
  #graph;
132
159
  /**
133
- * Checks `config` and starts its render on the worker thread. The stems move into `graph` as
160
+ * Starts the render of checked `settings` on the worker thread. The stems move into `graph` as
134
161
  * soon as the job is done, so a render that fails is reported then and not at `start`.
135
162
  */
136
- constructor(host, config, graph, report) {
163
+ constructor(host, settings, graph, report) {
137
164
  this.#graph = graph;
138
- const input = toInput(config);
165
+ const { input, stopAtFrames, stemGains } = settings;
139
166
  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
- };
167
+ this.stopAtFrames = stopAtFrames;
168
+ this.#stemGains = stemGains;
145
169
  this.#render = host.native.startRender(input);
146
170
  this.rendered = host.watchRender(this.#render.id, (fraction) => {
147
171
  this.#fraction = fraction;
@@ -234,17 +258,21 @@ function defaultSlotParams() {
234
258
  enabled: false,
235
259
  formantSemitones: 0
236
260
  },
261
+ echoCancel: { enabled: false },
262
+ cleanup: { enabled: false },
263
+ howlGuard: { enabled: false },
237
264
  guideOffsetMs: 0
238
265
  };
239
266
  }
240
267
  /**
241
- * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports from
242
- * here until `destroy`; its return stream sends the mix from `start` until `destroy`. Settings
268
+ * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports, and its
269
+ * `capture` slots hold their inputs open, from here until `destroy`; its return stream sends the
270
+ * mix from `start` until `destroy`. Settings
243
271
  * made while armed are kept here and written to the engine when `start` swaps the graph in, so
244
272
  * arming the next song never touches the live one.
245
273
  */
246
274
  var Mixer = class {
247
- /** Each slot's kind and, for `rtp` and `l16`, the UDP port it listens on; `null` for `push`. */
275
+ /** Each slot's kind and, for `rtp` and `l16`, the UDP port it listens on; `null` for the rest. */
248
276
  slots;
249
277
  #host;
250
278
  #native;
@@ -253,6 +281,7 @@ var Mixer = class {
253
281
  #guides;
254
282
  #backing;
255
283
  #offFault;
284
+ #letGo;
256
285
  #phase = "armed";
257
286
  #failed = false;
258
287
  /**
@@ -264,18 +293,20 @@ var Mixer = class {
264
293
  this.#failed = true;
265
294
  this.#events.emit("error", error);
266
295
  };
267
- constructor(host, kinds, events, backing, rtpReturn) {
296
+ constructor(host, inputs, events, backing, rtpReturn) {
268
297
  this.#host = host;
269
298
  this.#events = events;
270
- this.#native = host.native.createMixer([...kinds], rtpReturn);
271
- this.slots = kinds.map((kind, index) => ({
299
+ const settings = backing === null ? null : backingSettings(backing);
300
+ this.#native = host.native.createMixer([...inputs], rtpReturn);
301
+ this.#letGo = host.hold(() => this.#release());
302
+ this.slots = inputs.map(({ kind }, index) => ({
272
303
  index,
273
304
  kind,
274
305
  port: this.#native.port(index)
275
306
  }));
276
- this.#params = kinds.map(defaultSlotParams);
277
- this.#guides = kinds.map(() => null);
278
- this.#backing = backing === null ? null : new Backing(host, backing, this.#native, {
307
+ this.#params = inputs.map(defaultSlotParams);
308
+ this.#guides = inputs.map(() => null);
309
+ this.#backing = settings === null ? null : new Backing(host, settings, this.#native, {
279
310
  progress: (fraction) => {
280
311
  if (this.#phase !== "destroyed") this.#events.emit("renderProgress", fraction);
281
312
  },
@@ -316,10 +347,19 @@ var Mixer = class {
316
347
  mix: finiteIn("mix", effect.mix, 0, 1)
317
348
  };
318
349
  break;
319
- case "voice": next.voice = {
320
- enabled: effect.enabled,
321
- formantSemitones: finiteIn("formantSemitones", effect.formantSemitones, -12, 12)
322
- };
350
+ case "voice":
351
+ next.voice = {
352
+ enabled: effect.enabled,
353
+ formantSemitones: finiteIn("formantSemitones", effect.formantSemitones, -12, 12)
354
+ };
355
+ break;
356
+ case "echo-cancel":
357
+ next.echoCancel = { enabled: effect.enabled };
358
+ break;
359
+ case "cleanup":
360
+ next.cleanup = { enabled: effect.enabled };
361
+ break;
362
+ case "howl-guard": next.howlGuard = { enabled: effect.enabled };
323
363
  }
324
364
  this.#write(slot, next);
325
365
  }
@@ -351,7 +391,8 @@ var Mixer = class {
351
391
  */
352
392
  recordSlot(index, path) {
353
393
  const slot = this.#slot(index);
354
- if (this.slots[slot]?.kind === "push") throw new Error(`slot ${slot} is a push slot; only rtp and l16 slots record`);
394
+ const kind = this.slots[slot]?.kind;
395
+ if (kind !== "rtp" && kind !== "l16") throw new Error(`slot ${slot} is a ${String(kind)} slot; only rtp and l16 slots record`);
355
396
  if (this.#phase === "destroyed") throw new Error("the mixer is destroyed");
356
397
  if (path === "") throw new RangeError("path must name a file");
357
398
  this.#native.recordSlot(slot, path);
@@ -473,6 +514,7 @@ var Mixer = class {
473
514
  #release() {
474
515
  this.#phase = "destroyed";
475
516
  this.#offFault();
517
+ this.#letGo();
476
518
  this.#native.release();
477
519
  }
478
520
  #command(send) {
@@ -498,11 +540,14 @@ var Mixer = class {
498
540
  }
499
541
  };
500
542
  function defaultEffects() {
501
- const { corrector, reverb, voice } = defaultSlotParams();
543
+ const { corrector, reverb, voice, cleanup, echoCancel, howlGuard } = defaultSlotParams();
502
544
  return {
503
545
  corrector,
504
546
  reverb,
505
- voice
547
+ voice,
548
+ cleanup,
549
+ echoCancel,
550
+ howlGuard
506
551
  };
507
552
  }
508
553
  function toGuideInput(guide) {
@@ -544,6 +589,8 @@ var Engine = class {
544
589
  #events = new Emitter();
545
590
  /** Swaps in flight, oldest first; a `retired` event completes the oldest. */
546
591
  #retirements = [];
592
+ /** Each mixer's release until it is destroyed; `close` calls whatever is left. */
593
+ #held = /* @__PURE__ */ new Set();
547
594
  /** Render jobs on the worker thread, by the id their progress events carry. */
548
595
  #renders = /* @__PURE__ */ new Map();
549
596
  #live = null;
@@ -551,7 +598,7 @@ var Engine = class {
551
598
  #state = "running";
552
599
  constructor(options) {
553
600
  this.#slots = integerIn("slots", options.slots ?? 8, 1, 16);
554
- const playoutDelayFrames = msToFrames(finiteIn("playoutDelayMs", options.playoutDelayMs ?? 15, 5, 100));
601
+ const pinned = options.playoutDelayMs === void 0 ? {} : { playoutDelayFrames: msToFrames(finiteIn("playoutDelayMs", options.playoutDelayMs, 5, 100)) };
555
602
  const deliver = (batch) => {
556
603
  this.#deliver(batch);
557
604
  };
@@ -559,13 +606,13 @@ var Engine = class {
559
606
  this.#native = new NativeEngine({
560
607
  clock: "offline",
561
608
  periodFrames: 128,
562
- playoutDelayFrames
609
+ ...pinned
563
610
  }, deliver);
564
611
  this.pump = (blocks) => this.#native.pump(integerIn("blocks", blocks, 1, 1 << 20));
565
612
  } else if ("timer" in options.output) this.#native = new NativeEngine({
566
613
  clock: "timer",
567
614
  periodFrames: 128,
568
- playoutDelayFrames
615
+ ...pinned
569
616
  }, deliver);
570
617
  else {
571
618
  const { device, periodFrames = 128 } = options.output;
@@ -573,7 +620,7 @@ var Engine = class {
573
620
  this.#native = new NativeEngine({
574
621
  clock: "device",
575
622
  periodFrames,
576
- playoutDelayFrames,
623
+ ...pinned,
577
624
  ...device === void 0 ? {} : { device }
578
625
  }, deliver);
579
626
  }
@@ -592,6 +639,10 @@ var Engine = class {
592
639
  swapIn: (mixer, graph, events, onTimeout) => this.#swapIn(mixer, graph, events, onTimeout),
593
640
  swapOut: (mixer) => this.#swapOut(mixer),
594
641
  onFault: (listener) => this.#events.on("fault", listener),
642
+ hold: (release) => {
643
+ this.#held.add(release);
644
+ return () => this.#held.delete(release);
645
+ },
595
646
  isLive: (mixer) => this.#live?.mixer === mixer,
596
647
  anyLive: () => this.#live !== null,
597
648
  underruns: () => this.#underruns
@@ -615,12 +666,17 @@ var Engine = class {
615
666
  this.#assertRunning();
616
667
  return this.#native.outputs();
617
668
  }
669
+ /** The inputs a `capture` slot may name, the default marked. None on an offline engine. */
670
+ async inputDevices() {
671
+ this.#assertRunning();
672
+ return this.#native.inputs();
673
+ }
618
674
  async createMixer(config) {
619
675
  this.#assertRunning();
620
676
  if (config.slots.length > this.#slots) throw new RangeError(`the engine has ${this.#slots} slots; the mixer asks for ${config.slots.length}`);
621
- const kinds = config.slots.map(({ kind }, i) => ingestKind(`slots[${i}].kind`, kind));
677
+ const slots = config.slots.map((slot, i) => slotInput(`slots[${i}]`, slot));
622
678
  const destination = config.rtpReturn === void 0 ? null : rtpReturn(config.rtpReturn);
623
- return new Mixer(this.#host, kinds, new Emitter(), config.backing ?? null, destination);
679
+ return new Mixer(this.#host, slots, new Emitter(), config.backing ?? null, destination);
624
680
  }
625
681
  on(event, listener) {
626
682
  return this.#events.on(event, listener);
@@ -631,6 +687,7 @@ var Engine = class {
631
687
  if (this.#live && this.#state === "running") await this.#live.mixer.destroy();
632
688
  this.#state = "closed";
633
689
  this.#native.close();
690
+ for (const release of this.#held) release();
634
691
  this.#stop(/* @__PURE__ */ new Error("the engine is closed"));
635
692
  }
636
693
  #swapIn(mixer, graph, events, onTimeout) {
@@ -25,15 +25,22 @@ export declare class Engine {
25
25
  */
26
26
  constructor(options: OpenOptions, onEvents: (arg: Array<NativeEvent>) => void)
27
27
  /**
28
- * Builds a mixer with one slot per kind, in order: `"rtp"`, `"l16"` or `"push"`. The rtp
29
- * and l16 slots bind their ports here and report them through `Mixer.port`; a return stream
30
- * gets its socket and send task here too.
28
+ * Builds a mixer with one slot per input, in order: `"rtp"`, `"l16"`, `"push"` or
29
+ * `"capture"`. The rtp and l16 slots bind their ports here and report them through
30
+ * `Mixer.port`, the capture slots start their inputs here, and a return stream gets its socket
31
+ * and send task here too.
31
32
  */
32
- createMixer(kinds: Array<string>, rtpReturn?: RtpReturnInput | undefined | null): Mixer
33
+ createMixer(slots: Array<SlotInput>, rtpReturn?: RtpReturnInput | undefined | null): Mixer
33
34
  /** The output playing now. After a move the events thread sends the same as `outputChanged`. */
34
35
  get output(): OutputInfo
35
36
  /** The outputs this OS offers, the default marked. Empty on the offline and timer clocks. */
36
37
  outputs(): Array<OutputEntry>
38
+ /**
39
+ * The inputs this OS offers, the default marked: the devices a `capture` slot may name. Empty
40
+ * on the offline clock, which opens none. A box on the timer clock plays nothing locally but
41
+ * may still have a mic plugged in.
42
+ */
43
+ inputs(): Array<OutputEntry>
37
44
  /** Offline clock only: renders `blocks` blocks and returns them as interleaved stereo. */
38
45
  pump(blocks: number): Float32Array
39
46
  /** Writes one slot of the snapshot and publishes the whole of it. */
@@ -74,7 +81,7 @@ export declare class Engine {
74
81
  export declare class Mixer {
75
82
  /** Moves a finished render's stems into the graph as its backing path, before `swap`. */
76
83
  installBacking(render: BackingRender): void
77
- /** The UDP port a slot listens on, or null for a push slot. */
84
+ /** The UDP port a slot listens on, or null for a push or capture slot. */
78
85
  port(index: number): number | null
79
86
  /**
80
87
  * Hands interleaved stereo PCM to a push slot, which folds it to mono as every slot is
@@ -86,7 +93,10 @@ export declare class Mixer {
86
93
  * recording it for null.
87
94
  */
88
95
  recordSlot(index: number, path?: string | undefined | null): void
89
- /** Every slot's ingest counters and corrector delay, as the audio thread last published them. */
96
+ /**
97
+ * Every slot's ingest counters, corrector delay and echo findings, as the audio thread last
98
+ * published them.
99
+ */
90
100
  stats(): Array<SlotStatsInfo>
91
101
  /** The return stream's counters, or null for a mixer without one. */
92
102
  returnStats(): RtpReturnStats | null
@@ -106,6 +116,10 @@ export interface BackingInput {
106
116
  speed: number
107
117
  }
108
118
 
119
+ export interface CleanupInput {
120
+ enabled: boolean
121
+ }
122
+
109
123
  /** What drives the audio thread. */
110
124
  export declare const enum ClockKind {
111
125
  /** The offline driver, pumped from JavaScript. */
@@ -122,6 +136,16 @@ export interface CorrectorInput {
122
136
  retuneMs: number
123
137
  }
124
138
 
139
+ /** What a slot's echo canceller has found, as JavaScript reads it. */
140
+ export interface EchoInfo {
141
+ /** From the master being rendered to its echo reaching the slot. */
142
+ pathMs: number
143
+ /** How far under the master the echo arrives, over the last second. */
144
+ bleedDb: number
145
+ /** How much quieter the slot came out than it went in, over the last second. */
146
+ removedDb: number
147
+ }
148
+
125
149
  export interface GuideInput {
126
150
  notes: Array<NoteInput>
127
151
  /** The key's pitch classes as bits, C in bit 0. */
@@ -153,8 +177,11 @@ export interface OpenOptions {
153
177
  /** A device clock's output: an id from `outputs` to pin, or none to follow the default output. */
154
178
  device?: string
155
179
  periodFrames: number
156
- /** The jitter buffer's playout delay for every `rtp` and `l16` slot. */
157
- playoutDelayFrames: number
180
+ /**
181
+ * Pins the jitter buffer's playout delay for every `rtp` and `l16` slot. Without it, each
182
+ * stream chooses its own.
183
+ */
184
+ playoutDelayFrames?: number
158
185
  }
159
186
 
160
187
  export interface OutputDevice {
@@ -197,6 +224,16 @@ export interface RtpReturnStats {
197
224
  droppedBlocks: number
198
225
  }
199
226
 
227
+ /**
228
+ * One slot as `createMixer` asks for it. `device` and `channel` belong to a `capture` slot: an id
229
+ * from `inputs`, or the default input, and the device's channel from 0.
230
+ */
231
+ export interface SlotInput {
232
+ kind: string
233
+ device?: string
234
+ channel?: number
235
+ }
236
+
200
237
  export interface SlotParamsInput {
201
238
  /** Linear amplitude. */
202
239
  gain: number
@@ -207,6 +244,9 @@ export interface SlotParamsInput {
207
244
  corrector: CorrectorInput
208
245
  reverb: ReverbInput
209
246
  voice: VoiceInput
247
+ echoCancel: SwitchInput
248
+ cleanup: CleanupInput
249
+ howlGuard: SwitchInput
210
250
  guideOffsetMs: number
211
251
  }
212
252
 
@@ -215,11 +255,16 @@ export interface SlotStatsInfo {
215
255
  pushed: number
216
256
  lost: number
217
257
  late: number
258
+ lateAtOpening: number
218
259
  concealed: number
219
260
  resyncs: number
220
261
  jitterMs: number
221
262
  correctorDelayMs: number
222
263
  correctorMaxDelayMs: number
264
+ /** Null while the canceller is off or has yet to find and learn the room's echo. */
265
+ echo: EchoInfo | null
266
+ playoutDelayMs: number
267
+ howlCatches: number
223
268
  }
224
269
 
225
270
  export interface StemGainsInput {
@@ -227,6 +272,11 @@ export interface StemGainsInput {
227
272
  guide: number
228
273
  }
229
274
 
275
+ /** An effect with no knob but on and off. */
276
+ export interface SwitchInput {
277
+ enabled: boolean
278
+ }
279
+
230
280
  export interface VoiceInput {
231
281
  enabled: boolean
232
282
  formantSemitones: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeonentertainment/mixer",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Low-latency LAN audio mixer for Aeon Karaoke: phone and house mics, a backing track, one mix to the room.",
5
5
  "bugs": {
6
6
  "url": "https://github.com/aeonentertainment/aeon-mixer/issues"
@@ -26,7 +26,7 @@
26
26
  "@napi-rs/cli": "^3.10.9",
27
27
  "@types/node": "^26.6.6",
28
28
  "typescript": "^7.0.2",
29
- "vite-plus": "1.4.0"
29
+ "vite-plus": "1.5.1"
30
30
  },
31
31
  "devEngines": {
32
32
  "packageManager": {