@aeonentertainment/mixer 0.5.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.5.0/aeonentertainment-mixer-0.5.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,11 +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. A slot can also take the room's echo of the mix out of its input and duck itself when
56
- its loop through the speakers starts to howl; both add no delay, and `stats()` reports what
57
- they found.
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.
58
58
  - **The backing** is any format Symphonia decodes, resampled to 48 kHz. The armed key and speed
59
59
  are rendered before `start` resolves. Live, it supports seek, pause, resume, a key change and
60
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
@@ -210,6 +217,15 @@ interface RtpReturnStats {
210
217
  /** Blocks the audio thread dropped because the network thread had not drained the ring. */
211
218
  droppedBlocks: number;
212
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
+ }
213
229
  interface SlotParamsInput {
214
230
  /** Linear amplitude. */
215
231
  gain: number;
@@ -257,7 +273,7 @@ declare const NativeEngine: typeof Engine$1;
257
273
  type NativeEngine = Engine$1;
258
274
  type NativeMixer = Mixer$1;
259
275
  type Capability = "guideVocal" | "key" | "speed" | "seek" | "shortVersion" | "pause" | "level";
260
- type IngestKind = "rtp" | "l16" | "push";
276
+ type IngestKind = "rtp" | "l16" | "push" | "capture";
261
277
  type StemKind = "instrumental" | "guideVocals";
262
278
  interface OpenEngineOptions {
263
279
  /**
@@ -291,10 +307,20 @@ interface OutputInfo {
291
307
  readonly name: string;
292
308
  } | null;
293
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
+ };
294
322
  interface MixerConfig {
295
- slots: ReadonlyArray<{
296
- kind: IngestKind;
297
- }>;
323
+ slots: ReadonlyArray<SlotConfig>;
298
324
  backing?: {
299
325
  stems: ReadonlyArray<{
300
326
  kind: StemKind;
@@ -396,7 +422,11 @@ interface SlotStats {
396
422
  bleedDb: number;
397
423
  removedDb: number;
398
424
  } | null;
399
- /** The delay the stream's earliest packets wait now; zero for a `push` slot. */
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
+ */
400
430
  playoutDelayMs: number;
401
431
  /** How often the howl guard has ducked this slot. */
402
432
  howlCatches: number;
@@ -461,25 +491,33 @@ interface MixerHost extends RenderHost {
461
491
  swapOut(mixer: Mixer): Promise<void>;
462
492
  /** Hears the engine's fault until the returned function is called. */
463
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;
464
499
  isLive(mixer: Mixer): boolean;
465
500
  anyLive(): boolean;
466
501
  underruns(): number;
467
502
  }
468
503
  /**
469
- * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports from
470
- * 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
471
507
  * made while armed are kept here and written to the engine when `start` swaps the graph in, so
472
508
  * arming the next song never touches the live one.
473
509
  */
474
510
  export declare class Mixer {
475
511
  #private;
476
- /** 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. */
477
513
  readonly slots: ReadonlyArray<{
478
514
  index: number;
479
515
  kind: IngestKind;
480
516
  port: number | null;
481
517
  }>;
482
- 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);
483
521
  setSlot(index: number, settings: Partial<SlotSettings>): void;
484
522
  /** The slot's whole chain: an effect not in `effects` is disabled. */
485
523
  setSlotEffects(index: number, effects: ReadonlyArray<SlotEffect>): void;
@@ -578,9 +616,15 @@ export declare class Engine {
578
616
  name: string;
579
617
  isDefault: boolean;
580
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
+ }>>;
581
625
  createMixer(config: MixerConfig): Promise<Mixer>;
582
626
  on<K extends keyof EngineEvents>(event: K, listener: EngineEvents[K]): () => void;
583
627
  /** Destroys the live mixer, stops the clock and joins the events thread. */
584
628
  close(): Promise<void>;
585
629
  }
586
- 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;
@@ -241,13 +265,14 @@ function defaultSlotParams() {
241
265
  };
242
266
  }
243
267
  /**
244
- * One mixing graph for one performance. Its `rtp` and `l16` slots listen on their ports from
245
- * 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
246
271
  * made while armed are kept here and written to the engine when `start` swaps the graph in, so
247
272
  * arming the next song never touches the live one.
248
273
  */
249
274
  var Mixer = class {
250
- /** 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. */
251
276
  slots;
252
277
  #host;
253
278
  #native;
@@ -256,6 +281,7 @@ var Mixer = class {
256
281
  #guides;
257
282
  #backing;
258
283
  #offFault;
284
+ #letGo;
259
285
  #phase = "armed";
260
286
  #failed = false;
261
287
  /**
@@ -267,18 +293,20 @@ var Mixer = class {
267
293
  this.#failed = true;
268
294
  this.#events.emit("error", error);
269
295
  };
270
- constructor(host, kinds, events, backing, rtpReturn) {
296
+ constructor(host, inputs, events, backing, rtpReturn) {
271
297
  this.#host = host;
272
298
  this.#events = events;
273
- this.#native = host.native.createMixer([...kinds], rtpReturn);
274
- 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) => ({
275
303
  index,
276
304
  kind,
277
305
  port: this.#native.port(index)
278
306
  }));
279
- this.#params = kinds.map(defaultSlotParams);
280
- this.#guides = kinds.map(() => null);
281
- 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, {
282
310
  progress: (fraction) => {
283
311
  if (this.#phase !== "destroyed") this.#events.emit("renderProgress", fraction);
284
312
  },
@@ -363,7 +391,8 @@ var Mixer = class {
363
391
  */
364
392
  recordSlot(index, path) {
365
393
  const slot = this.#slot(index);
366
- 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`);
367
396
  if (this.#phase === "destroyed") throw new Error("the mixer is destroyed");
368
397
  if (path === "") throw new RangeError("path must name a file");
369
398
  this.#native.recordSlot(slot, path);
@@ -485,6 +514,7 @@ var Mixer = class {
485
514
  #release() {
486
515
  this.#phase = "destroyed";
487
516
  this.#offFault();
517
+ this.#letGo();
488
518
  this.#native.release();
489
519
  }
490
520
  #command(send) {
@@ -559,6 +589,8 @@ var Engine = class {
559
589
  #events = new Emitter();
560
590
  /** Swaps in flight, oldest first; a `retired` event completes the oldest. */
561
591
  #retirements = [];
592
+ /** Each mixer's release until it is destroyed; `close` calls whatever is left. */
593
+ #held = /* @__PURE__ */ new Set();
562
594
  /** Render jobs on the worker thread, by the id their progress events carry. */
563
595
  #renders = /* @__PURE__ */ new Map();
564
596
  #live = null;
@@ -607,6 +639,10 @@ var Engine = class {
607
639
  swapIn: (mixer, graph, events, onTimeout) => this.#swapIn(mixer, graph, events, onTimeout),
608
640
  swapOut: (mixer) => this.#swapOut(mixer),
609
641
  onFault: (listener) => this.#events.on("fault", listener),
642
+ hold: (release) => {
643
+ this.#held.add(release);
644
+ return () => this.#held.delete(release);
645
+ },
610
646
  isLive: (mixer) => this.#live?.mixer === mixer,
611
647
  anyLive: () => this.#live !== null,
612
648
  underruns: () => this.#underruns
@@ -630,12 +666,17 @@ var Engine = class {
630
666
  this.#assertRunning();
631
667
  return this.#native.outputs();
632
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
+ }
633
674
  async createMixer(config) {
634
675
  this.#assertRunning();
635
676
  if (config.slots.length > this.#slots) throw new RangeError(`the engine has ${this.#slots} slots; the mixer asks for ${config.slots.length}`);
636
- const kinds = config.slots.map(({ kind }, i) => ingestKind(`slots[${i}].kind`, kind));
677
+ const slots = config.slots.map((slot, i) => slotInput(`slots[${i}]`, slot));
637
678
  const destination = config.rtpReturn === void 0 ? null : rtpReturn(config.rtpReturn);
638
- 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);
639
680
  }
640
681
  on(event, listener) {
641
682
  return this.#events.on(event, listener);
@@ -646,6 +687,7 @@ var Engine = class {
646
687
  if (this.#live && this.#state === "running") await this.#live.mixer.destroy();
647
688
  this.#state = "closed";
648
689
  this.#native.close();
690
+ for (const release of this.#held) release();
649
691
  this.#stop(/* @__PURE__ */ new Error("the engine is closed"));
650
692
  }
651
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
@@ -217,6 +224,16 @@ export interface RtpReturnStats {
217
224
  droppedBlocks: number
218
225
  }
219
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
+
220
237
  export interface SlotParamsInput {
221
238
  /** Linear amplitude. */
222
239
  gain: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeonentertainment/mixer",
3
- "version": "0.5.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"