@aeonentertainment/mixer 0.2.0 → 0.4.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.2.0/aeonentertainment-mixer-0.2.0.tgz
18
+ npm install https://github.com/aeonentertainment/aeon-mixer/releases/download/v0.4.0/aeonentertainment-mixer-0.4.0.tgz
19
19
  ```
20
20
 
21
21
  ## Use
@@ -51,7 +51,8 @@ await engine.close();
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
53
  mic, and `push` takes PCM the host hands to `mixer.push`. Each slot has gain, pan, mute, solo,
54
- reverb, a pitch reading and a melody guide.
54
+ reverb, a pitch reading, a melody guide and pitch correction toward it, whose delay `stats()`
55
+ reports.
55
56
  - **The backing** is any format Symphonia decodes, resampled to 48 kHz. The armed key and speed
56
57
  are rendered before `start` resolves. Live, it supports seek, pause, resume, a key change and
57
58
  stem gains, and `start`, `seek` and `resume` report the position the lyric clock follows.
@@ -62,6 +63,11 @@ await engine.close();
62
63
  card. It opens no device; its own thread renders a block at each deadline on the OS's
63
64
  monotonic clock, and the mix leaves on `rtpReturn`. `measure-clock` prints its timing:
64
65
  `cargo run --release -p devices --bin measure-clock` in `native/`.
66
+ - **The output** is the OS default, unless `openEngine({ output: { device } })` pins one by an id
67
+ from `engine.outputDevices()`. When the default changes, or the output goes away, the song
68
+ moves to the new output after a short gap, a pinned engine moving back when its output
69
+ returns. `engine.output` names the output playing and its latency, and `outputChanged` fires
70
+ after each move with both and the gap.
65
71
  - **The offline engine**, `openEngine({ output: { offline: true } })`, opens no device and
66
72
  renders only when its caller runs `engine.pump(blocks)`. The tests run on it.
67
73
 
package/dist/index.d.mts CHANGED
@@ -18,6 +18,7 @@ declare class Engine$1 {
18
18
  * gets its socket and send task here too.
19
19
  */
20
20
  createMixer(kinds: Array<string>, rtpReturn?: RtpReturnInput | undefined | null): Mixer$1;
21
+ /** The output playing now. After a move the events thread sends the same as `outputChanged`. */
21
22
  get output(): OutputInfo$1;
22
23
  /** The outputs this OS offers, the default marked. Empty on the offline and timer clocks. */
23
24
  outputs(): Array<OutputEntry>;
@@ -72,7 +73,7 @@ declare class Mixer$1 {
72
73
  * recording it for null.
73
74
  */
74
75
  recordSlot(index: number, path?: string | undefined | null): void;
75
- /** Every slot's ingest counters, as the audio thread last published them. */
76
+ /** Every slot's ingest counters and corrector delay, as the audio thread last published them. */
76
77
  stats(): Array<SlotStatsInfo>;
77
78
  /** The return stream's counters, or null for a mixer without one. */
78
79
  returnStats(): RtpReturnStats | null;
@@ -119,6 +120,8 @@ type NativeEvent = {
119
120
  positionMs: number;
120
121
  hz: number;
121
122
  confidence: number;
123
+ targetMidi?: number;
124
+ errorSemitones: number;
122
125
  } | {
123
126
  kind: 'position';
124
127
  frames: number;
@@ -137,6 +140,10 @@ type NativeEvent = {
137
140
  } | {
138
141
  kind: 'renderFinished';
139
142
  job: number;
143
+ } | {
144
+ kind: 'outputChanged';
145
+ output: OutputInfo$1;
146
+ gapMs: number;
140
147
  };
141
148
  interface NoteInput {
142
149
  midi: number;
@@ -145,12 +152,16 @@ interface NoteInput {
145
152
  }
146
153
  interface OpenOptions {
147
154
  clock: 'offline' | 'timer' | 'device';
148
- /** A device clock's output: an id from `outputs`, or the default output. */
155
+ /** A device clock's output: an id from `outputs` to pin, or none to follow the default output. */
149
156
  device?: string;
150
157
  periodFrames: number;
151
158
  /** The jitter buffer's playout delay for every `rtp` and `l16` slot. */
152
159
  playoutDelayFrames: number;
153
160
  }
161
+ interface OutputDevice {
162
+ id: string;
163
+ name: string;
164
+ }
154
165
  interface OutputEntry {
155
166
  id: string;
156
167
  name: string;
@@ -160,6 +171,8 @@ interface OutputInfo$1 {
160
171
  sampleRate: number;
161
172
  periodFrames: number;
162
173
  latencyMs: number;
174
+ /** The output playing now; none on the offline clock. */
175
+ device?: OutputDevice;
163
176
  }
164
177
  interface ReverbInput {
165
178
  enabled: boolean;
@@ -188,6 +201,7 @@ interface SlotParamsInput {
188
201
  solo: boolean;
189
202
  corrector: CorrectorInput;
190
203
  reverb: ReverbInput;
204
+ voice: VoiceInput;
191
205
  guideOffsetMs: number;
192
206
  }
193
207
  /** `SlotStats` as JavaScript reads it. */
@@ -198,11 +212,17 @@ interface SlotStatsInfo {
198
212
  concealed: number;
199
213
  resyncs: number;
200
214
  jitterMs: number;
215
+ correctorDelayMs: number;
216
+ correctorMaxDelayMs: number;
201
217
  }
202
218
  interface StemGainsInput {
203
219
  instrumental: number;
204
220
  guide: number;
205
221
  }
222
+ interface VoiceInput {
223
+ enabled: boolean;
224
+ formantSemitones: number;
225
+ }
206
226
  declare const NativeEngine: typeof Engine$1;
207
227
  type NativeEngine = Engine$1;
208
228
  type NativeMixer = Mixer$1;
@@ -211,8 +231,9 @@ type IngestKind = "rtp" | "l16" | "push";
211
231
  type StemKind = "instrumental" | "guideVocals";
212
232
  interface OpenEngineOptions {
213
233
  /**
214
- * A device; `timer` for a box with no sound card, whose own thread renders on the OS's clock
215
- * and whose mix leaves only on a mixer's `rtpReturn`; `offline` for tests, which pump it.
234
+ * A device, pinned by `device` or, left out, following the OS default; `timer` for a box with
235
+ * no sound card, whose own thread renders on the OS's clock and whose mix leaves only on a
236
+ * mixer's `rtpReturn`; `offline` for tests, which pump it.
216
237
  */
217
238
  output: {
218
239
  device?: string;
@@ -231,6 +252,11 @@ interface OutputInfo {
231
252
  readonly sampleRate: 48000;
232
253
  readonly periodFrames: number;
233
254
  readonly latencyMs: number;
255
+ /** The output playing now, as `outputDevices()` lists it; null on the offline clock. */
256
+ readonly device: {
257
+ readonly id: string;
258
+ readonly name: string;
259
+ } | null;
234
260
  }
235
261
  interface MixerConfig {
236
262
  slots: ReadonlyArray<{
@@ -273,6 +299,15 @@ type SlotEffect = {
273
299
  enabled: boolean;
274
300
  roomSize: number;
275
301
  mix: number;
302
+ } |
303
+ /**
304
+ * A formant move in semitones from the singer's own, -12 to 12, with no change of pitch and no
305
+ * added delay. Decision 0014 has the first voices' settings.
306
+ */
307
+ {
308
+ kind: "voice";
309
+ enabled: boolean;
310
+ formantSemitones: number;
276
311
  };
277
312
  interface MelodyGuide {
278
313
  notes: ReadonlyArray<{
@@ -290,15 +325,31 @@ interface SlotStats {
290
325
  concealed: number;
291
326
  resyncs: number;
292
327
  jitterMs: number;
328
+ /**
329
+ * How far the corrector holds this slot's voice now; 0 with correction off. It moves with the
330
+ * voice and one read is a sample of it, so show it and never add it to a budget or a clock.
331
+ */
332
+ correctorDelayMs: number;
333
+ /** The most that delay can reach; 0 with correction off. It holds still, so a budget adds this. */
334
+ correctorMaxDelayMs: number;
293
335
  }
294
336
  type EngineEvents = {
295
337
  fault: (error: Error) => void;
338
+ /** After every move to another output; `gapMs` is how long the room heard nothing. */
339
+ outputChanged: (output: OutputInfo, gapMs: number) => void;
296
340
  /** The mix after the clipper, reactions included, between songs too. */
297
341
  masterLevel: (rmsDb: number, peakDb: number) => void;
298
342
  };
299
343
  type MixerEvents = {
300
344
  level: (slot: number, rmsDb: number, peakDb: number) => void;
301
- pitch: (slot: number, positionMs: number, hz: number, confidence: number) => void;
345
+ /**
346
+ * Every 10 ms, on the guide's clock. `targetMidi` is the guide note the corrector pulls toward,
347
+ * after the backing's key shift: null with correction off, with no guide, or between notes,
348
+ * where it pulls toward the key. `errorSemitones` is the class it pulls toward less the
349
+ * reading, in the singer's own octave, so positive is flat: 0 with correction off or while
350
+ * unvoiced.
351
+ */
352
+ pitch: (slot: number, positionMs: number, hz: number, confidence: number, targetMidi: number | null, errorSemitones: number) => void;
302
353
  position: (positionMs: number) => void;
303
354
  renderProgress: (fraction: number) => void;
304
355
  backingEnded: () => void;
@@ -323,7 +374,8 @@ declare class Emitter<Events extends Record<keyof Events, Handler>> {
323
374
  }
324
375
  /** What a mixer needs from its engine. Internal to the package. */
325
376
  interface MixerHost extends RenderHost {
326
- readonly output: OutputInfo;
377
+ /** The output playing now; it changes when the engine moves. */
378
+ output(): OutputInfo;
327
379
  /**
328
380
  * Sends a command carrying an id and resolves on its ack with the backing position. `onTimeout`
329
381
  * hears the rejection when no ack comes in time.
@@ -441,10 +493,14 @@ export declare function openEngine(options: OpenEngineOptions): Promise<Engine>;
441
493
  export declare class Engine {
442
494
  #private;
443
495
  readonly capabilities: ReadonlySet<Capability>;
444
- readonly output: OutputInfo;
445
496
  /** Offline engines only: render blocks and return them as interleaved stereo. */
446
497
  readonly pump?: (blocks: number) => Float32Array;
447
498
  constructor(options: OpenEngineOptions);
499
+ /**
500
+ * The output playing now. An engine opened with no `output.device` follows the OS default, and
501
+ * one with a `device` plays there while it exists; `outputChanged` fires after every move.
502
+ */
503
+ get output(): OutputInfo;
448
504
  /** Room-lifetime reaction voices, four at once. False when a fifth is dropped while four play. */
449
505
  readonly reactions: {
450
506
  play: (samples: Float32Array, channels: 1 | 2) => boolean;
package/dist/index.mjs CHANGED
@@ -230,6 +230,10 @@ function defaultSlotParams() {
230
230
  roomSize: 0,
231
231
  mix: 0
232
232
  },
233
+ voice: {
234
+ enabled: false,
235
+ formantSemitones: 0
236
+ },
233
237
  guideOffsetMs: 0
234
238
  };
235
239
  }
@@ -305,10 +309,16 @@ var Mixer = class {
305
309
  retuneMs: finiteIn("retuneMs", effect.retuneMs, 0, 1e3)
306
310
  };
307
311
  break;
308
- case "reverb": next.reverb = {
312
+ case "reverb":
313
+ next.reverb = {
314
+ enabled: effect.enabled,
315
+ roomSize: finiteIn("roomSize", effect.roomSize, 0, 1),
316
+ mix: finiteIn("mix", effect.mix, 0, 1)
317
+ };
318
+ break;
319
+ case "voice": next.voice = {
309
320
  enabled: effect.enabled,
310
- roomSize: finiteIn("roomSize", effect.roomSize, 0, 1),
311
- mix: finiteIn("mix", effect.mix, 0, 1)
321
+ formantSemitones: finiteIn("formantSemitones", effect.formantSemitones, -12, 12)
312
322
  };
313
323
  }
314
324
  this.#write(slot, next);
@@ -393,7 +403,7 @@ var Mixer = class {
393
403
  started,
394
404
  stopping
395
405
  ]);
396
- const { latencyMs } = this.#host.output;
406
+ const { latencyMs } = this.#host.output();
397
407
  return {
398
408
  startedAt: Date.now() + latencyMs,
399
409
  backingOffsetMs: latencyMs,
@@ -488,10 +498,11 @@ var Mixer = class {
488
498
  }
489
499
  };
490
500
  function defaultEffects() {
491
- const { corrector, reverb } = defaultSlotParams();
501
+ const { corrector, reverb, voice } = defaultSlotParams();
492
502
  return {
493
503
  corrector,
494
- reverb
504
+ reverb,
505
+ voice
495
506
  };
496
507
  }
497
508
  function toGuideInput(guide) {
@@ -522,7 +533,7 @@ var Engine = class {
522
533
  "pause",
523
534
  "level"
524
535
  ]);
525
- output;
536
+ #output;
526
537
  /** How many slots a mixer may have, fixed at open. */
527
538
  #slots;
528
539
  /** Offline engines only: render blocks and return them as interleaved stereo. */
@@ -566,10 +577,10 @@ var Engine = class {
566
577
  ...device === void 0 ? {} : { device }
567
578
  }, deliver);
568
579
  }
569
- this.output = outputInfo(this.#native.output);
580
+ this.#output = outputInfo(this.#native.output);
570
581
  this.#host = {
571
582
  native: this.#native,
572
- output: this.output,
583
+ output: () => this.#output,
573
584
  command: (send, onTimeout) => this.#command(send, onTimeout),
574
585
  watchRender: (id, onProgress) => new Promise((resolve, reject) => {
575
586
  this.#renders.set(id, {
@@ -586,6 +597,13 @@ var Engine = class {
586
597
  underruns: () => this.#underruns
587
598
  };
588
599
  }
600
+ /**
601
+ * The output playing now. An engine opened with no `output.device` follows the OS default, and
602
+ * one with a `device` plays there while it exists; `outputChanged` fires after every move.
603
+ */
604
+ get output() {
605
+ return this.#output;
606
+ }
589
607
  /** Room-lifetime reaction voices, four at once. False when a fifth is dropped while four play. */
590
608
  reactions = { play: (samples, channels) => {
591
609
  if (channels !== 1 && channels !== 2) throw new RangeError(`channels must be 1 or 2, got ${String(channels)}`);
@@ -654,7 +672,7 @@ var Engine = class {
654
672
  this.#live?.events.emit("level", event.slot, event.rmsDb, event.peakDb);
655
673
  break;
656
674
  case "pitch":
657
- this.#live?.events.emit("pitch", event.slot, event.positionMs, event.hz, event.confidence);
675
+ this.#live?.events.emit("pitch", event.slot, event.positionMs, event.hz, event.confidence, event.targetMidi ?? null, event.errorSemitones);
658
676
  break;
659
677
  case "position":
660
678
  this.#live?.events.emit("position", framesToMs(event.frames));
@@ -677,6 +695,10 @@ var Engine = class {
677
695
  case "renderProgress":
678
696
  this.#renders.get(event.job)?.onProgress(event.fraction);
679
697
  break;
698
+ case "outputChanged":
699
+ this.#output = outputInfo(event.output);
700
+ this.#events.emit("outputChanged", this.#output, event.gapMs);
701
+ break;
680
702
  case "renderFinished": {
681
703
  const render = this.#renders.get(event.job);
682
704
  this.#renders.delete(event.job);
@@ -705,10 +727,15 @@ var Engine = class {
705
727
  };
706
728
  function outputInfo(info) {
707
729
  if (info.sampleRate !== 48e3) throw new Error(`the engine runs at 48 kHz; the addon reports ${info.sampleRate}`);
730
+ const { periodFrames, latencyMs, device } = info;
708
731
  return {
709
732
  sampleRate: 48e3,
710
- periodFrames: info.periodFrames,
711
- latencyMs: info.latencyMs
733
+ periodFrames,
734
+ latencyMs,
735
+ device: device === void 0 ? null : {
736
+ id: device.id,
737
+ name: device.name
738
+ }
712
739
  };
713
740
  }
714
741
  function monoToStereo(mono) {
@@ -30,6 +30,7 @@ export declare class Engine {
30
30
  * gets its socket and send task here too.
31
31
  */
32
32
  createMixer(kinds: Array<string>, rtpReturn?: RtpReturnInput | undefined | null): Mixer
33
+ /** The output playing now. After a move the events thread sends the same as `outputChanged`. */
33
34
  get output(): OutputInfo
34
35
  /** The outputs this OS offers, the default marked. Empty on the offline and timer clocks. */
35
36
  outputs(): Array<OutputEntry>
@@ -85,7 +86,7 @@ export declare class Mixer {
85
86
  * recording it for null.
86
87
  */
87
88
  recordSlot(index: number, path?: string | undefined | null): void
88
- /** Every slot's ingest counters, as the audio thread last published them. */
89
+ /** Every slot's ingest counters and corrector delay, as the audio thread last published them. */
89
90
  stats(): Array<SlotStatsInfo>
90
91
  /** The return stream's counters, or null for a mixer without one. */
91
92
  returnStats(): RtpReturnStats | null
@@ -131,7 +132,7 @@ export type NativeEvent =
131
132
  | { kind: 'ack'; id: number; positionFrames: number }
132
133
  | { kind: 'level'; slot: number; rmsDb: number; peakDb: number }
133
134
  | { kind: 'masterLevel'; rmsDb: number; peakDb: number }
134
- | { kind: 'pitch'; slot: number; positionMs: number; hz: number; confidence: number }
135
+ | { kind: 'pitch'; slot: number; positionMs: number; hz: number; confidence: number; targetMidi?: number; errorSemitones: number }
135
136
  | { kind: 'position'; frames: number }
136
137
  | { kind: 'backingEnded' }
137
138
  | { kind: 'underrun' }
@@ -139,6 +140,7 @@ export type NativeEvent =
139
140
  | { kind: 'retired' }
140
141
  | { kind: 'renderProgress'; job: number; fraction: number }
141
142
  | { kind: 'renderFinished'; job: number }
143
+ | { kind: 'outputChanged'; output: OutputInfo; gapMs: number }
142
144
 
143
145
  export interface NoteInput {
144
146
  midi: number
@@ -148,13 +150,18 @@ export interface NoteInput {
148
150
 
149
151
  export interface OpenOptions {
150
152
  clock: 'offline' | 'timer' | 'device'
151
- /** A device clock's output: an id from `outputs`, or the default output. */
153
+ /** A device clock's output: an id from `outputs` to pin, or none to follow the default output. */
152
154
  device?: string
153
155
  periodFrames: number
154
156
  /** The jitter buffer's playout delay for every `rtp` and `l16` slot. */
155
157
  playoutDelayFrames: number
156
158
  }
157
159
 
160
+ export interface OutputDevice {
161
+ id: string
162
+ name: string
163
+ }
164
+
158
165
  export interface OutputEntry {
159
166
  id: string
160
167
  name: string
@@ -165,6 +172,8 @@ export interface OutputInfo {
165
172
  sampleRate: number
166
173
  periodFrames: number
167
174
  latencyMs: number
175
+ /** The output playing now; none on the offline clock. */
176
+ device?: OutputDevice
168
177
  }
169
178
 
170
179
  export interface ReverbInput {
@@ -197,6 +206,7 @@ export interface SlotParamsInput {
197
206
  solo: boolean
198
207
  corrector: CorrectorInput
199
208
  reverb: ReverbInput
209
+ voice: VoiceInput
200
210
  guideOffsetMs: number
201
211
  }
202
212
 
@@ -208,9 +218,16 @@ export interface SlotStatsInfo {
208
218
  concealed: number
209
219
  resyncs: number
210
220
  jitterMs: number
221
+ correctorDelayMs: number
222
+ correctorMaxDelayMs: number
211
223
  }
212
224
 
213
225
  export interface StemGainsInput {
214
226
  instrumental: number
215
227
  guide: number
216
228
  }
229
+
230
+ export interface VoiceInput {
231
+ enabled: boolean
232
+ formantSemitones: number
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeonentertainment/mixer",
3
- "version": "0.2.0",
3
+ "version": "0.4.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"