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/CHANGELOG.md +40 -1
- package/MIGRATION.md +8 -12
- package/README.md +12 -9
- package/examples/decibri.browser.js +93 -12
- package/index.d.ts +94 -15
- package/index.js +52 -52
- package/package.json +5 -5
- package/src/browser/decibri-browser.js +174 -25
- package/src/browser/index.d.ts +58 -7
- package/src/browser/worklet-inline.js +2 -2
- package/src/browser/worklet-processor.js +116 -32
- package/src/decibri.d.ts +183 -22
- package/src/decibri.js +238 -44
- package/src/errors.js +9 -1
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
233
|
-
// and selects the detector; threshold and holdoffMs override
|
|
234
|
-
// defaults when supplied
|
|
235
|
-
|
|
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
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
* `
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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
|
|
634
|
-
*
|
|
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
|
-
//
|
|
645
|
-
// the
|
|
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
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
|
1388
|
-
// encode path, so the two spellings produce the
|
|
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'],
|