decibri 5.5.0 → 5.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/src/decibri.js CHANGED
@@ -102,6 +102,7 @@ class Microphone extends Readable {
102
102
  this._vad = prepared.vadEnabled;
103
103
  this._vadThreshold = prepared.vadThreshold;
104
104
  this._vadHoldoff = prepared.vadHoldoff;
105
+ this._dtype = prepared.dtype;
105
106
  this._vadScore = 0;
106
107
  this._isSpeaking = false;
107
108
  this._silenceTimer = null;
@@ -145,16 +146,42 @@ class Microphone extends Readable {
145
146
  throw new RangeError('sample rate must be between 1000 and 384000');
146
147
  }
147
148
 
148
- // Mono only: the capture path delivers a single channel. A value below 1
149
- // is a plain range error; a value above 1 is rejected as multichannel
150
- // (not silently downmixed) so a later move to true multichannel stays
151
- // additive. The `channels` option is kept for that forward compatibility.
149
+ // The number of channels delivered, interleaved frame by frame. Bounded
150
+ // below here; bounded above by the resolved device alone, which answers
151
+ // when the stream starts. No fixed maximum exists on this path.
152
152
  const channels = options.channels ?? 1;
153
153
  if (channels < 1) {
154
154
  throw new RangeError('channels must be at least 1');
155
155
  }
156
- if (channels > 1) {
157
- throw new RangeError('multichannel capture is not supported; channels must be 1 (mono)');
156
+
157
+ // ── Validate channel map ─────────────────────────────────────────────────
158
+
159
+ // An optional list of 0-based device channel indices, one per delivered
160
+ // channel: delivered channel j carries device channel channelMap[j].
161
+ // Absence delivers the documented average of every opened channel. The
162
+ // checks here are shape-only (an array of integers that fit the channel
163
+ // count's width, with one entry per channel); whether each entry exists on
164
+ // the device is the core's check, made against the resolved device's own
165
+ // report when the stream starts, because only the device can say how many
166
+ // channels it has. No fixed maximum exists on this path.
167
+ const channelMap = options.channelMap;
168
+ if (channelMap !== undefined) {
169
+ if (!Array.isArray(channelMap)) {
170
+ throw new TypeError(
171
+ `Invalid channelMap value: ${JSON.stringify(channelMap)}. Expected an array of 0-based device channel indices, such as [0].`
172
+ );
173
+ }
174
+ for (const entry of channelMap) {
175
+ if (typeof entry !== 'number' || !Number.isInteger(entry)) {
176
+ throw new TypeError('channelMap entries must be integers');
177
+ }
178
+ if (entry < 0 || entry > 65535) {
179
+ throw new RangeError('channelMap entries must be between 0 and 65535');
180
+ }
181
+ }
182
+ if (channelMap.length !== channels) {
183
+ throw new RangeError('channelMap must have exactly one entry per channel');
184
+ }
158
185
  }
159
186
 
160
187
  const framesPerBuffer = options.framesPerBuffer ?? 1600;
@@ -202,7 +229,7 @@ class Microphone extends Readable {
202
229
  // vad selects the detector and (optionally) its threshold/holdoff policy.
203
230
  // It accepts false (disabled, default), the 'silero'/'energy' shorthand
204
231
  // (which uses the mode's default threshold and holdoff), or a config object
205
- // { model, threshold, holdoffMs } to tune the policy. The legacy two-flag
232
+ // { model, threshold, holdoffMs, source } to tune the policy. The legacy two-flag
206
233
  // form (vad: true plus vadMode) and the flat vadThreshold/vadHoldoff
207
234
  // options are rejected with a migration error. The threshold and holdoff
208
235
  // live JS-side (the state machine runs in this wrapper); only the mode is
@@ -218,6 +245,7 @@ class Microphone extends Readable {
218
245
  let vadMode;
219
246
  let vadThreshold;
220
247
  let vadHoldoff;
248
+ let vadSource;
221
249
  if (vad === false) {
222
250
  vadEnabled = false;
223
251
  vadMode = 'energy'; // inert placeholder; ignored while disabled
@@ -229,10 +257,13 @@ class Microphone extends Readable {
229
257
  vadEnabled = true;
230
258
  vadMode = vad;
231
259
  } else if (vad !== null && typeof vad === 'object' && !Array.isArray(vad)) {
232
- // Config object form: { model, threshold?, holdoffMs? }. model is required
233
- // and selects the detector; threshold and holdoffMs override the mode
234
- // defaults when supplied.
235
- const { model, threshold, holdoffMs } = vad;
260
+ // Config object form: { model, threshold?, holdoffMs?, source? }. model
261
+ // is required and selects the detector; threshold and holdoffMs override
262
+ // the mode defaults when supplied; source names the 0-based DELIVERED
263
+ // channel the detector reads (the position within the delivered
264
+ // interleaved frames, after any channelMap), absent feeding the frame
265
+ // average of every delivered channel.
266
+ const { model, threshold, holdoffMs, source } = vad;
236
267
  if (model !== 'silero' && model !== 'energy') {
237
268
  throw new TypeError(
238
269
  `Invalid vad model: ${JSON.stringify(model)}. Expected 'silero' or 'energy'.`
@@ -258,9 +289,26 @@ class Microphone extends Readable {
258
289
  }
259
290
  vadHoldoff = holdoffMs;
260
291
  }
292
+ if (source !== undefined) {
293
+ if (typeof source !== 'number' || !Number.isInteger(source)) {
294
+ throw new TypeError('vad source must be an integer');
295
+ }
296
+ if (source < 0 || source > 65535) {
297
+ throw new RangeError('vad source must be between 0 and 65535');
298
+ }
299
+ // The delivered count is the only ceiling, checked here where both
300
+ // sides are in scope; the message is the core's own for the same
301
+ // condition. No fixed maximum exists.
302
+ if (source >= channels) {
303
+ throw new RangeError(
304
+ `the detector source names delivered channel ${source}; the delivered channel count is ${channels}`
305
+ );
306
+ }
307
+ vadSource = source;
308
+ }
261
309
  } else {
262
310
  throw new TypeError(
263
- `Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs }.`
311
+ `Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs, source }.`
264
312
  );
265
313
  }
266
314
 
@@ -433,6 +481,7 @@ class Microphone extends Readable {
433
481
  nativeOptions: {
434
482
  sampleRate,
435
483
  channels,
484
+ channelMap,
436
485
  framesPerBuffer,
437
486
  format: dtype,
438
487
  device: resolvedDevice,
@@ -441,6 +490,9 @@ class Microphone extends Readable {
441
490
  // native compute the energy score for a microphone that did not ask for
442
491
  // VAD. Absent means VAD off in native.
443
492
  vadMode: vadEnabled ? vadMode : undefined,
493
+ // The delivered channel the detector reads, from the vad config
494
+ // object's source key. Absent feeds the frame average.
495
+ detectorSource: vadSource,
444
496
  modelPath,
445
497
  dcRemoval,
446
498
  denoise,
@@ -614,24 +666,27 @@ class Microphone extends Readable {
614
666
 
615
667
  /**
616
668
  * Queue far-end reference audio for the echo canceller: the audio being
617
- * played out, pushed as it is played, in played order. Accepts the same
618
- * input shapes `Speaker.write` accepts (a `Buffer`, any TypedArray, or a
619
- * `DataView` of PCM bytes in this microphone's `dtype`), at the declared
620
- * `referenceSampleRate` (the capture rate when unset), interleaved at the
621
- * declared `referenceChannels` (mono when unset). With `referenceChannels`
622
- * above 1, each frame is averaged to one mono sample before the canceller
623
- * sees it; a multichannel reference pushed without declaring the count
624
- * cancels nothing and reports no error. The declared count must match this
625
- * buffer's actual interleaving: a mismatch is not detected and raises no
626
- * error, and shows up only as `aecMetrics().delaySamples` staying `null`
627
- * with no fault reported.
669
+ * played out, pushed as it is played, in played order. Accepts a `Buffer`,
670
+ * `Uint8Array`, or `DataView` of PCM bytes in this microphone's `dtype`,
671
+ * or the typed array carrying that dtype (`Int16Array` for `'int16'`,
672
+ * `Float32Array` for `'float32'`), at the declared `referenceSampleRate`
673
+ * (the capture rate when unset), interleaved at the declared
674
+ * `referenceChannels` (mono when unset). A typed array carrying any other
675
+ * sample dtype throws a `TypeError`, whatever the capture state. With
676
+ * `referenceChannels` above 1, each frame is averaged to one mono sample
677
+ * before the canceller sees it; a multichannel reference pushed without
678
+ * declaring the count cancels nothing and reports no error. The declared
679
+ * count must match this buffer's actual interleaving: a mismatch is not
680
+ * detected and raises no error, and shows up only as
681
+ * `aecMetrics().delaySamples` staying `null` with no fault reported.
628
682
  *
629
683
  * Never blocks and never throws on a full queue: samples that do not fit
630
684
  * are discarded and counted by `aecMetrics().referenceDropped`, and the
631
685
  * span they occupied is represented as silence. Silence between played
632
686
  * audio need not be pushed; a caller that stops pushing has said nothing is
633
- * playing. A push while capture is not running, or with the `aec` option
634
- * unset, is a no-op.
687
+ * playing. A push while capture is not running is discarded and counted by
688
+ * `referenceDropped`, read once capture runs; a push with the `aec` option
689
+ * unset is a no-op.
635
690
  *
636
691
  * @param {Buffer | NodeJS.ArrayBufferView} data PCM samples in the
637
692
  * configured `dtype`.
@@ -641,8 +696,28 @@ class Microphone extends Readable {
641
696
  if (Buffer.isBuffer(data)) {
642
697
  buf = data;
643
698
  } else if (ArrayBuffer.isView(data)) {
644
- // Any TypedArray or DataView: view the same bytes, no copy, exactly as
645
- // the stream machinery normalizes a typed-array write to a Speaker.
699
+ // A typed array names its own sample dtype, so one carrying a dtype
700
+ // other than the configured one is refused rather than read as raw
701
+ // bytes. Buffer, Uint8Array, and DataView are format-agnostic byte
702
+ // carriers, exactly as bytes are on the Python surface; the accepted
703
+ // view is normalized to the same bytes, no copy, exactly as the stream
704
+ // machinery normalizes a typed-array write to a Speaker.
705
+ if (!(data instanceof Uint8Array) && !(data instanceof DataView)) {
706
+ const expected = this._dtype === 'int16' ? Int16Array : Float32Array;
707
+ if (!(data instanceof expected)) {
708
+ const mismatched = this._dtype === 'int16' ? Float32Array : Int16Array;
709
+ if (data instanceof mismatched) {
710
+ const other = this._dtype === 'int16' ? 'float32' : 'int16';
711
+ throw new TypeError(
712
+ `dtype '${this._dtype}' configured but ${mismatched.name} samples were pushed; ` +
713
+ `convert to ${expected.name} or construct Microphone with dtype: '${other}'`
714
+ );
715
+ }
716
+ throw new TypeError(
717
+ 'pushAecReference requires a Buffer, TypedArray, or DataView of PCM samples in the configured dtype'
718
+ );
719
+ }
720
+ }
646
721
  buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
647
722
  } else {
648
723
  throw new TypeError(
@@ -663,6 +738,11 @@ class Microphone extends Readable {
663
738
  * `referenceDropped` means single pushes are exceeding the reference
664
739
  * queue's bound.
665
740
  *
741
+ * The top-level engine fields report the first delivered channel's
742
+ * canceller; `channels` carries every delivered channel's report in
743
+ * delivered order, one entry per channel, so the two agree on a
744
+ * single-channel stream.
745
+ *
666
746
  * @returns {import('./decibri').AecMetrics | null}
667
747
  */
668
748
  aecMetrics() {
@@ -677,6 +757,14 @@ class Microphone extends Readable {
677
757
  referenceReanchors: m.referenceReanchors,
678
758
  referenceDropped: m.referenceDropped,
679
759
  referenceSilence: m.referenceSilence,
760
+ channels: m.channels.map((c) => ({
761
+ delaySamples: c.delaySamples ?? null,
762
+ erleDb: c.erleDb,
763
+ doubleTalk: c.doubleTalk,
764
+ referenceStarved: c.referenceStarved,
765
+ acquisitionParked: c.acquisitionParked,
766
+ referenceReanchors: c.referenceReanchors,
767
+ })),
680
768
  };
681
769
  }
682
770
 
@@ -755,6 +843,15 @@ class File extends Readable {
755
843
  constructor(filePath, options = {}, _internal = undefined) {
756
844
  super({ highWaterMark: options.highWaterMark, objectMode: false });
757
845
 
846
+ // The open path reads the source's channel count from the file's own
847
+ // header, so a caller-stated interleave has nothing to describe here;
848
+ // it is refused rather than ignored.
849
+ if (!_internal && options.inputChannels !== undefined) {
850
+ throw new TypeError(
851
+ 'inputChannels applies only to File.buffer; a file carries its channel count in its own header'
852
+ );
853
+ }
854
+
758
855
  const prepared = _internal ? _internal.prepared : File._prepareOptions(options);
759
856
 
760
857
  // ── Store config ───────────────────────────────────────────────────────
@@ -772,6 +869,9 @@ class File extends Readable {
772
869
  this._silenceStartPos = null;
773
870
  this._position = 0;
774
871
  this._sampleRate = prepared.nativeOptions.sampleRate;
872
+ // The delivered channel count: file time advances by frames, so the
873
+ // interleaved sample count divides by it before it divides by the rate.
874
+ this._channels = prepared.channels;
775
875
  this._bytesPerSample = prepared.dtype === 'int16' ? 2 : 4;
776
876
  this._ended = false;
777
877
  // Set the moment the consumer asks the stream for data, which is earlier
@@ -810,6 +910,42 @@ class File extends Readable {
810
910
  throw new RangeError('sample rate must be between 1000 and 384000');
811
911
  }
812
912
 
913
+ // The number of channels delivered, interleaved frame by frame. Bounded
914
+ // below here; bounded above by the source's own channel count alone,
915
+ // which the core reads from the header (or takes from inputChannels)
916
+ // when the source is opened. No fixed maximum exists on this path.
917
+ const channels = options.channels ?? 1;
918
+ if (channels < 1) {
919
+ throw new RangeError('channels must be at least 1');
920
+ }
921
+
922
+ // An optional list of 0-based source channel indices, one per delivered
923
+ // channel: delivered channel j carries source channel channelMap[j].
924
+ // Absence delivers the documented average of every source channel. The
925
+ // checks here are shape-only, exactly as the Microphone's: whether each
926
+ // entry exists on the source is the core's check, made against the
927
+ // source's own count, because only the opened source can say how many
928
+ // channels it has. No fixed maximum exists on this path.
929
+ const channelMap = options.channelMap;
930
+ if (channelMap !== undefined) {
931
+ if (!Array.isArray(channelMap)) {
932
+ throw new TypeError(
933
+ `Invalid channelMap value: ${JSON.stringify(channelMap)}. Expected an array of 0-based source channel indices, such as [0].`
934
+ );
935
+ }
936
+ for (const entry of channelMap) {
937
+ if (typeof entry !== 'number' || !Number.isInteger(entry)) {
938
+ throw new TypeError('channelMap entries must be integers');
939
+ }
940
+ if (entry < 0 || entry > 65535) {
941
+ throw new RangeError('channelMap entries must be between 0 and 65535');
942
+ }
943
+ }
944
+ if (channelMap.length !== channels) {
945
+ throw new RangeError('channelMap must have exactly one entry per channel');
946
+ }
947
+ }
948
+
813
949
  const dtype = options.dtype ?? 'int16';
814
950
  if (dtype !== 'int16' && dtype !== 'float32') {
815
951
  throw new TypeError("dtype must be 'int16' or 'float32'");
@@ -830,6 +966,7 @@ class File extends Readable {
830
966
  let vadMode;
831
967
  let vadThreshold;
832
968
  let vadHoldoff;
969
+ let vadSource;
833
970
  if (vad === false) {
834
971
  vadEnabled = false;
835
972
  vadMode = 'energy'; // inert placeholder; ignored while disabled
@@ -841,7 +978,7 @@ class File extends Readable {
841
978
  vadEnabled = true;
842
979
  vadMode = vad;
843
980
  } else if (vad !== null && typeof vad === 'object' && !Array.isArray(vad)) {
844
- const { model, threshold, holdoffMs } = vad;
981
+ const { model, threshold, holdoffMs, source } = vad;
845
982
  if (model !== 'silero' && model !== 'energy') {
846
983
  throw new TypeError(
847
984
  `Invalid vad model: ${JSON.stringify(model)}. Expected 'silero' or 'energy'.`
@@ -867,9 +1004,25 @@ class File extends Readable {
867
1004
  }
868
1005
  vadHoldoff = holdoffMs;
869
1006
  }
1007
+ // source names the 0-based DELIVERED channel the detector reads,
1008
+ // exactly as on Microphone; the checks and messages are the same.
1009
+ if (source !== undefined) {
1010
+ if (typeof source !== 'number' || !Number.isInteger(source)) {
1011
+ throw new TypeError('vad source must be an integer');
1012
+ }
1013
+ if (source < 0 || source > 65535) {
1014
+ throw new RangeError('vad source must be between 0 and 65535');
1015
+ }
1016
+ if (source >= channels) {
1017
+ throw new RangeError(
1018
+ `the detector source names delivered channel ${source}; the delivered channel count is ${channels}`
1019
+ );
1020
+ }
1021
+ vadSource = source;
1022
+ }
870
1023
  } else {
871
1024
  throw new TypeError(
872
- `Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs }.`
1025
+ `Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs, source }.`
873
1026
  );
874
1027
  }
875
1028
 
@@ -923,16 +1076,22 @@ class File extends Readable {
923
1076
 
924
1077
  return {
925
1078
  dtype,
1079
+ channels,
926
1080
  vadEnabled,
927
1081
  vadMode,
928
1082
  vadThreshold: vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01),
929
1083
  vadHoldoff: vadHoldoff ?? 300,
930
1084
  nativeOptions: {
931
1085
  sampleRate,
1086
+ channels,
1087
+ channelMap,
932
1088
  format: dtype,
933
1089
  // Pass the mode to native only when VAD is enabled, exactly as the
934
1090
  // Microphone options do; absent means VAD off in native.
935
1091
  vadMode: vadEnabled ? vadMode : undefined,
1092
+ // The delivered channel the detector reads, from the vad config
1093
+ // object's source key. Absent feeds the frame average.
1094
+ detectorSource: vadSource,
936
1095
  // The whole-file analysis applies threshold and holdoff in the core
937
1096
  // (segment merging in file time), so both cross the boundary here,
938
1097
  // unlike the live path where the policy is wrapper-only.
@@ -964,6 +1123,13 @@ class File extends Readable {
964
1123
  if (typeof filePath !== 'string') {
965
1124
  throw new TypeError('path must be a string');
966
1125
  }
1126
+ // The same refusal the synchronous constructor makes: a path's channel
1127
+ // count comes from its own header.
1128
+ if (options.inputChannels !== undefined) {
1129
+ throw new TypeError(
1130
+ 'inputChannels applies only to File.buffer; a file carries its channel count in its own header'
1131
+ );
1132
+ }
967
1133
  const prepared = File._prepareOptions(options);
968
1134
  let native;
969
1135
  try {
@@ -976,12 +1142,13 @@ class File extends Readable {
976
1142
 
977
1143
  /**
978
1144
  * Wrap in-memory samples as an offline source. `samples` must be a
979
- * `Float32Array` of mono samples in [-1.0, 1.0]; a raw `Buffer` of PCM
980
- * bytes is rejected as ambiguous (encoded bytes, int16 PCM, and f32
981
- * samples are indistinguishable, and decibri's own capture output is a
982
- * `Buffer`). Raw samples carry no header, so `inputRate` (their native
983
- * rate) is required; `sampleRate` stays the target output rate. No I/O,
984
- * so construction is synchronous.
1145
+ * `Float32Array` of samples in [-1.0, 1.0], frame-interleaved at
1146
+ * `inputChannels` (1, mono, by default); a raw `Buffer` of PCM bytes is
1147
+ * rejected as ambiguous (encoded bytes, int16 PCM, and f32 samples are
1148
+ * indistinguishable, and decibri's own capture output is a `Buffer`). Raw
1149
+ * samples carry no header, so `inputRate` (their native rate) is
1150
+ * required; `sampleRate` stays the target output rate. No I/O, so
1151
+ * construction is synchronous.
985
1152
  *
986
1153
  * @param {Float32Array} samples
987
1154
  * @param {import('./decibri').FileBufferOptions} [options]
@@ -1003,10 +1170,23 @@ class File extends Readable {
1003
1170
  if (inputRate < 1000 || inputRate > 384000) {
1004
1171
  throw new RangeError('inputRate must be between 1000 and 384000');
1005
1172
  }
1173
+ // The channel counterpart of inputRate: the interleave of the caller's
1174
+ // own samples. Shape-checked here; whether the samples divide into
1175
+ // whole frames at this count is the core's check.
1176
+ const inputChannels = options.inputChannels ?? 1;
1177
+ if (typeof inputChannels !== 'number' || !Number.isInteger(inputChannels)) {
1178
+ throw new TypeError('inputChannels must be an integer');
1179
+ }
1180
+ if (inputChannels < 1 || inputChannels > 65535) {
1181
+ throw new RangeError('inputChannels must be between 1 and 65535');
1182
+ }
1006
1183
  const prepared = File._prepareOptions(options);
1007
1184
  let native;
1008
1185
  try {
1009
- native = FileHandle.buffer(samples, inputRate, prepared.nativeOptions);
1186
+ native = FileHandle.buffer(samples, inputRate, {
1187
+ ...prepared.nativeOptions,
1188
+ inputChannels,
1189
+ });
1010
1190
  } catch (err) {
1011
1191
  throw wrapNativeError(err);
1012
1192
  }
@@ -1094,7 +1274,9 @@ class File extends Readable {
1094
1274
  // before the opt-in conditioning step, exactly as the live pump does.
1095
1275
  this._processVadValue(this._native.vadProbability, chunk.length);
1096
1276
  } else {
1097
- this._position += chunk.length / this._bytesPerSample / this._sampleRate;
1277
+ // Bytes to interleaved samples to frames to seconds of file time.
1278
+ this._position +=
1279
+ chunk.length / this._bytesPerSample / this._channels / this._sampleRate;
1098
1280
  }
1099
1281
  this.push(chunk);
1100
1282
  }
@@ -1108,7 +1290,9 @@ class File extends Readable {
1108
1290
  */
1109
1291
  _processVadValue(value, chunkBytes) {
1110
1292
  const chunkStart = this._position;
1111
- const chunkEnd = chunkStart + chunkBytes / this._bytesPerSample / this._sampleRate;
1293
+ // Bytes to interleaved samples to frames to seconds of file time.
1294
+ const chunkEnd =
1295
+ chunkStart + chunkBytes / this._bytesPerSample / this._channels / this._sampleRate;
1112
1296
  this._position = chunkEnd;
1113
1297
  this._vadScore = value;
1114
1298
  if (value >= this._vadThreshold) {
@@ -1301,7 +1485,9 @@ class AudioWriter extends Writable {
1301
1485
  *
1302
1486
  * Chunks are raw PCM bytes in `dtype` ('int16' little-endian by default,
1303
1487
  * matching what a `File` or `Microphone` emits; 'float32' for raw f32
1304
- * bytes). Audio is written mono: `channels` may only be 1. `sampleRate` is
1488
+ * bytes), frame-interleaved at `channels` (1, mono, by default; the
1489
+ * stream's total sample count must divide into whole frames, and each
1490
+ * container's own channel ceiling applies at the write). `sampleRate` is
1305
1491
  * required, because raw audio carries no header to read one from.
1306
1492
  *
1307
1493
  * The file is written when the stream finishes ('finish' fires after the
@@ -1324,9 +1510,12 @@ class AudioWriter extends Writable {
1324
1510
  if (sampleRate < 1000 || sampleRate > 384000) {
1325
1511
  throw new RangeError('sample rate must be between 1000 and 384000');
1326
1512
  }
1327
- const channels = options.channels;
1328
- if (channels !== undefined && channels !== 1) {
1329
- throw new RangeError('multichannel write is not supported; channels must be 1 (mono)');
1513
+ // Bounded below here; above, each container's own ceiling answers at
1514
+ // the write, with the container layer's own message. No decibri-side
1515
+ // maximum exists on this path.
1516
+ const channels = options.channels ?? 1;
1517
+ if (channels < 1) {
1518
+ throw new RangeError('channels must be at least 1');
1330
1519
  }
1331
1520
  const dtype = options.dtype ?? 'int16';
1332
1521
  if (dtype !== 'int16' && dtype !== 'float32') {
@@ -1337,6 +1526,7 @@ class AudioWriter extends Writable {
1337
1526
  this._saveOptions = File._prepareSaveOptions(options);
1338
1527
  this._filePath = filePath;
1339
1528
  this._sampleRate = sampleRate;
1529
+ this._channels = channels;
1340
1530
  this._dtype = dtype;
1341
1531
  this._chunks = [];
1342
1532
  this._report = null;
@@ -1384,13 +1574,17 @@ class AudioWriter extends Writable {
1384
1574
  samples[i] = bytes.readFloatLE(i * 4);
1385
1575
  }
1386
1576
  }
1387
- // The write is File.save on a source at the writer's own rate: the same
1388
- // encode path, so the two spellings produce the same bytes.
1577
+ // The write is File.save on a source at the writer's own rate and
1578
+ // interleave: the same encode path, so the two spellings produce the
1579
+ // same bytes. A stream that does not divide into whole frames is the
1580
+ // core's refusal, surfaced here when the stream finishes.
1389
1581
  let file;
1390
1582
  try {
1391
1583
  file = File.buffer(samples, {
1392
1584
  inputRate: this._sampleRate,
1585
+ inputChannels: this._channels,
1393
1586
  sampleRate: this._sampleRate,
1587
+ channels: this._channels,
1394
1588
  });
1395
1589
  } catch (err) {
1396
1590
  callback(err);
package/src/errors.js CHANGED
@@ -76,10 +76,12 @@ class OrtPathError extends OrtError {
76
76
  const RANGE_PREFIXES = [
77
77
  'sample rate must be between',
78
78
  'channels must be at least',
79
- 'multichannel capture is not supported',
80
79
  'frames per buffer must be between',
81
80
  'agc target level must be between',
82
81
  'limiter ceiling must be between',
82
+ 'the detector source names',
83
+ 'the channel map has',
84
+ 'the requested block size',
83
85
  'flac compression level must be between',
84
86
  'aec tailMs must be between',
85
87
  'aec referenceSampleRate must be between',
@@ -127,6 +129,12 @@ const BASE_CODES = [
127
129
  ['Failed to open audio stream', 'STREAM_OPEN_FAILED'],
128
130
  ['Failed to start audio stream', 'STREAM_START_FAILED'],
129
131
  ['the output device does not support', 'SPEAKER_CHANNELS_UNSUPPORTED'],
132
+ ['the input device does not support', 'MICROPHONE_CHANNELS_UNSUPPORTED'],
133
+ ['a channel map is required', 'CHANNEL_SELECTION_AMBIGUOUS'],
134
+ ['the channel map names', 'CHANNEL_MAP_OUT_OF_RANGE'],
135
+ ['the file does not have', 'FILE_CHANNELS_UNSUPPORTED'],
136
+ ['delivering', 'FILE_CHANNEL_SELECTION_AMBIGUOUS'],
137
+ ['the file channel map names', 'FILE_CHANNEL_MAP_OUT_OF_RANGE'],
130
138
  ['Microphone permission denied.', 'PERMISSION_DENIED'],
131
139
  ['Microphone stream is closed', 'MICROPHONE_STREAM_CLOSED'],
132
140
  ['Speaker stream is closed', 'SPEAKER_STREAM_CLOSED'],