decibri 4.4.2 → 5.1.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 +33 -3
- package/MIGRATION.md +70 -0
- package/README.md +73 -8
- package/examples/README.md +4 -3
- package/examples/decibri.browser.js +18 -6
- package/index.d.ts +126 -0
- package/index.js +53 -52
- package/models/README.md +149 -0
- package/models/fastenhancer_t.onnx +0 -0
- package/package.json +5 -5
- package/src/browser/decibri-browser.js +33 -13
- package/src/browser/index.d.ts +30 -18
- package/src/decibri.d.ts +326 -17
- package/src/decibri.js +573 -56
- package/src/errors.js +16 -1
package/src/decibri.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { Readable } = require('stream');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
-
const { DecibriBridge } = require('../index.js');
|
|
6
|
+
const { DecibriBridge, FileHandle } = require('../index.js');
|
|
7
7
|
const {
|
|
8
8
|
wrapNativeError,
|
|
9
9
|
DecibriError,
|
|
@@ -72,25 +72,6 @@ function resolveBundledOrtPath() {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// ─── RMS helper ──────────────────────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
function computeRMS(chunk, dtype) {
|
|
78
|
-
let sum = 0, n;
|
|
79
|
-
if (dtype === 'float32') {
|
|
80
|
-
const samples = new Float32Array(chunk.buffer, chunk.byteOffset, chunk.length / 4);
|
|
81
|
-
n = samples.length;
|
|
82
|
-
for (let i = 0; i < n; i++) sum += samples[i] * samples[i];
|
|
83
|
-
} else {
|
|
84
|
-
const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
|
|
85
|
-
n = samples.length;
|
|
86
|
-
for (let i = 0; i < n; i++) {
|
|
87
|
-
const s = samples[i] / 32768;
|
|
88
|
-
sum += s * s;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return n > 0 ? Math.sqrt(sum / n) : 0;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
75
|
// ─── Microphone (Readable) ──────────────────────────────────────────────────
|
|
95
76
|
|
|
96
77
|
class Microphone extends Readable {
|
|
@@ -111,15 +92,19 @@ class Microphone extends Readable {
|
|
|
111
92
|
|
|
112
93
|
// ── Store config ───────────────────────────────────────────────────────
|
|
113
94
|
|
|
114
|
-
this._dtype = prepared.dtype;
|
|
115
95
|
this._vad = prepared.vadEnabled;
|
|
116
|
-
this._vadMode = prepared.vadMode;
|
|
117
96
|
this._vadThreshold = prepared.vadThreshold;
|
|
118
97
|
this._vadHoldoff = prepared.vadHoldoff;
|
|
119
98
|
this._vadScore = 0;
|
|
120
99
|
this._isSpeaking = false;
|
|
121
100
|
this._silenceTimer = null;
|
|
122
101
|
this._started = false;
|
|
102
|
+
// Terminal once stop() runs: blocks _read() from restarting native capture
|
|
103
|
+
// while the end-of-stream push(null) is deferred past the flushed tail.
|
|
104
|
+
this._stopped = false;
|
|
105
|
+
// Set just before the deferred push(null); the data callback drops any
|
|
106
|
+
// straggler that arrives after end-of-stream rather than pushing past EOF.
|
|
107
|
+
this._ended = false;
|
|
123
108
|
|
|
124
109
|
// ── Create or adopt native bridge ───────────────────────────────────────
|
|
125
110
|
|
|
@@ -153,10 +138,17 @@ class Microphone extends Readable {
|
|
|
153
138
|
throw new RangeError('sample rate must be between 1000 and 384000');
|
|
154
139
|
}
|
|
155
140
|
|
|
141
|
+
// Mono only: the capture path delivers a single channel. A value below 1
|
|
142
|
+
// is a plain range error; a value above 1 is rejected as multichannel
|
|
143
|
+
// (not silently downmixed) so a later move to true multichannel stays
|
|
144
|
+
// additive. The `channels` option is kept for that forward compatibility.
|
|
156
145
|
const channels = options.channels ?? 1;
|
|
157
|
-
if (channels < 1
|
|
146
|
+
if (channels < 1) {
|
|
158
147
|
throw new RangeError('channels must be between 1 and 32');
|
|
159
148
|
}
|
|
149
|
+
if (channels > 1) {
|
|
150
|
+
throw new RangeError('multichannel capture is not supported; channels must be 1 (mono)');
|
|
151
|
+
}
|
|
160
152
|
|
|
161
153
|
const framesPerBuffer = options.framesPerBuffer ?? 1600;
|
|
162
154
|
if (framesPerBuffer < 64 || framesPerBuffer > 65536) {
|
|
@@ -198,13 +190,25 @@ class Microphone extends Readable {
|
|
|
198
190
|
|
|
199
191
|
// ── Validate VAD options ─────────────────────────────────────────────────
|
|
200
192
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// the
|
|
193
|
+
// vad selects the detector and (optionally) its threshold/holdoff policy.
|
|
194
|
+
// It accepts false (disabled, default), the 'silero'/'energy' shorthand
|
|
195
|
+
// (which uses the mode's default threshold and holdoff), or a config object
|
|
196
|
+
// { model, threshold, holdoffMs } to tune the policy. The legacy two-flag
|
|
197
|
+
// form (vad: true plus vadMode) and the flat vadThreshold/vadHoldoff
|
|
198
|
+
// options are rejected with a migration error. The threshold and holdoff
|
|
199
|
+
// live JS-side (the state machine runs in this wrapper); only the mode is
|
|
200
|
+
// passed to native.
|
|
201
|
+
if (options.vadThreshold !== undefined || options.vadHoldoff !== undefined) {
|
|
202
|
+
throw new TypeError(
|
|
203
|
+
'vadThreshold and vadHoldoff are no longer supported. ' +
|
|
204
|
+
"Pass them on the vad config object: vad: { model: 'silero', threshold: 0.5, holdoffMs: 300 }."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
205
207
|
const vad = options.vad ?? false;
|
|
206
208
|
let vadEnabled;
|
|
207
209
|
let vadMode;
|
|
210
|
+
let vadThreshold;
|
|
211
|
+
let vadHoldoff;
|
|
208
212
|
if (vad === false) {
|
|
209
213
|
vadEnabled = false;
|
|
210
214
|
vadMode = 'energy'; // inert placeholder; ignored while disabled
|
|
@@ -215,23 +219,123 @@ class Microphone extends Readable {
|
|
|
215
219
|
} else if (vad === 'silero' || vad === 'energy') {
|
|
216
220
|
vadEnabled = true;
|
|
217
221
|
vadMode = vad;
|
|
222
|
+
} else if (vad !== null && typeof vad === 'object' && !Array.isArray(vad)) {
|
|
223
|
+
// Config object form: { model, threshold?, holdoffMs? }. model is required
|
|
224
|
+
// and selects the detector; threshold and holdoffMs override the mode
|
|
225
|
+
// defaults when supplied.
|
|
226
|
+
const { model, threshold, holdoffMs } = vad;
|
|
227
|
+
if (model !== 'silero' && model !== 'energy') {
|
|
228
|
+
throw new TypeError(
|
|
229
|
+
`Invalid vad model: ${JSON.stringify(model)}. Expected 'silero' or 'energy'.`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
vadEnabled = true;
|
|
233
|
+
vadMode = model;
|
|
234
|
+
if (threshold !== undefined) {
|
|
235
|
+
if (typeof threshold !== 'number' || Number.isNaN(threshold)) {
|
|
236
|
+
throw new TypeError('vad threshold must be a number');
|
|
237
|
+
}
|
|
238
|
+
if (threshold < 0 || threshold > 1) {
|
|
239
|
+
throw new RangeError('vad threshold must be between 0 and 1');
|
|
240
|
+
}
|
|
241
|
+
vadThreshold = threshold;
|
|
242
|
+
}
|
|
243
|
+
if (holdoffMs !== undefined) {
|
|
244
|
+
if (typeof holdoffMs !== 'number' || Number.isNaN(holdoffMs)) {
|
|
245
|
+
throw new TypeError('vad holdoffMs must be a number');
|
|
246
|
+
}
|
|
247
|
+
if (holdoffMs < 0) {
|
|
248
|
+
throw new RangeError('vad holdoffMs must be non-negative');
|
|
249
|
+
}
|
|
250
|
+
vadHoldoff = holdoffMs;
|
|
251
|
+
}
|
|
218
252
|
} else {
|
|
219
253
|
throw new TypeError(
|
|
220
|
-
`Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero',
|
|
254
|
+
`Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs }.`
|
|
221
255
|
);
|
|
222
256
|
}
|
|
223
257
|
|
|
224
258
|
let modelPath = undefined;
|
|
225
|
-
let ortLibraryPath = undefined;
|
|
226
259
|
if (vadEnabled && vadMode === 'silero') {
|
|
227
260
|
modelPath = options.modelPath || path.join(__dirname, '..', 'models', 'silero_vad.onnx');
|
|
228
261
|
if (!fs.existsSync(modelPath)) {
|
|
229
262
|
throw new Error(`Silero VAD model not found at ${modelPath}. Ensure the models/ directory is included in your installation.`);
|
|
230
263
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── DC removal ───────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
// A plain bool toggle for the one-pole DC-blocking high-pass, the first
|
|
269
|
+
// transform stage (before denoise). Absent or false leaves it off (the
|
|
270
|
+
// default), a byte-identical no-op. No range or closed-set check (it is just
|
|
271
|
+
// a bool); the value is threaded straight to the native config.
|
|
272
|
+
const dcRemoval = options.dcRemoval;
|
|
273
|
+
|
|
274
|
+
// ── Validate and resolve denoise ─────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
// Closed-set selector mirroring the Silero VAD shape: a model name resolves
|
|
277
|
+
// to a bundled ONNX file, absence leaves denoise off. The only accepted
|
|
278
|
+
// value is 'fastenhancer-t'; anything else is an explicit error rather than
|
|
279
|
+
// a silent miss. The bundled model file is resolved relative to the package
|
|
280
|
+
// exactly as the Silero model is.
|
|
281
|
+
const denoise = options.denoise;
|
|
282
|
+
let denoiseModelPath = undefined;
|
|
283
|
+
if (denoise !== undefined) {
|
|
284
|
+
if (denoise !== 'fastenhancer-t') {
|
|
285
|
+
throw new TypeError(
|
|
286
|
+
`Invalid denoise value: ${JSON.stringify(denoise)}. Expected 'fastenhancer-t'.`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
denoiseModelPath = path.join(__dirname, '..', 'models', 'fastenhancer_t.onnx');
|
|
290
|
+
if (!fs.existsSync(denoiseModelPath)) {
|
|
291
|
+
throw new Error(`Denoise model not found at ${denoiseModelPath}. Ensure the models/ directory is included in your installation.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Validate high-pass ───────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
// Closed growable numeric cutoff selector mirroring the denoise shape: a
|
|
298
|
+
// cutoff in Hz selects a filter, absence leaves the high-pass off. The
|
|
299
|
+
// accepted values are 80 and 100; anything else (an out-of-set cutoff or a
|
|
300
|
+
// non-number) is an explicit RangeError rather than a silent miss, matching
|
|
301
|
+
// the numeric range checks on agc and limiter. The filter is pure DSP with
|
|
302
|
+
// no bundled file, so there is nothing to resolve here, only the closed-set
|
|
303
|
+
// check.
|
|
304
|
+
const highpass = options.highpass;
|
|
305
|
+
if (highpass !== undefined && highpass !== 80 && highpass !== 100) {
|
|
306
|
+
throw new RangeError('highpass must be one of: 80, 100');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Validate AGC ─────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
// AGC target level in dBFS: a number in [-40, -3] (typical -18) drives the
|
|
312
|
+
// captured level toward the target; absence leaves it off. Mirrors the
|
|
313
|
+
// sample-rate range check, a RangeError on an out-of-range numeric value;
|
|
314
|
+
// the native backstop and the Rust core guard the same range.
|
|
315
|
+
const agc = options.agc;
|
|
316
|
+
if (agc !== undefined && (agc < -40 || agc > -3)) {
|
|
317
|
+
throw new RangeError('agc target level must be between -40 and -3');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Validate limiter ─────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
// Sample-peak ceiling in dBFS: a number in [-3.0, 0.0] (typical -1.0) holds
|
|
323
|
+
// the captured signal at or below the ceiling, catching a peak the AGC would
|
|
324
|
+
// let through; absence leaves it off. Mirrors the agc range check, a
|
|
325
|
+
// RangeError on an out-of-range numeric value; the native backstop and the
|
|
326
|
+
// Rust core guard the same range.
|
|
327
|
+
const limiter = options.limiter;
|
|
328
|
+
if (limiter !== undefined && (limiter < -3.0 || limiter > 0.0)) {
|
|
329
|
+
throw new RangeError('limiter ceiling must be between -3.0 and 0.0');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Internal plumbing: inject the bundled ORT dylib path into the napi
|
|
333
|
+
// constructor whenever an ONNX stage loads (Silero VAD or denoise). If
|
|
334
|
+
// resolution fails (unknown platform, platform package not installed), this
|
|
335
|
+
// is left undefined and Rust falls through to ORT_DYLIB_PATH or surfaces a
|
|
336
|
+
// decibri-specific init error.
|
|
337
|
+
let ortLibraryPath = undefined;
|
|
338
|
+
if ((vadEnabled && vadMode === 'silero') || denoise !== undefined) {
|
|
235
339
|
ortLibraryPath = resolveBundledOrtPath();
|
|
236
340
|
}
|
|
237
341
|
|
|
@@ -239,17 +343,27 @@ class Microphone extends Readable {
|
|
|
239
343
|
dtype,
|
|
240
344
|
vadEnabled,
|
|
241
345
|
vadMode,
|
|
242
|
-
vadThreshold:
|
|
243
|
-
vadHoldoff:
|
|
346
|
+
vadThreshold: vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01),
|
|
347
|
+
vadHoldoff: vadHoldoff ?? 300,
|
|
244
348
|
nativeOptions: {
|
|
245
349
|
sampleRate,
|
|
246
350
|
channels,
|
|
247
351
|
framesPerBuffer,
|
|
248
352
|
format: dtype,
|
|
249
353
|
device: resolvedDevice,
|
|
250
|
-
|
|
354
|
+
// Pass the mode to native only when VAD is enabled. When disabled the
|
|
355
|
+
// local vadMode is an inert 'energy' placeholder; sending it would make
|
|
356
|
+
// native compute the energy score for a microphone that did not ask for
|
|
357
|
+
// VAD. Absent means VAD off in native.
|
|
358
|
+
vadMode: vadEnabled ? vadMode : undefined,
|
|
251
359
|
modelPath,
|
|
360
|
+
dcRemoval,
|
|
361
|
+
denoise,
|
|
362
|
+
denoiseModelPath,
|
|
252
363
|
ortLibraryPath,
|
|
364
|
+
highpass,
|
|
365
|
+
agc,
|
|
366
|
+
limiter,
|
|
253
367
|
},
|
|
254
368
|
};
|
|
255
369
|
}
|
|
@@ -287,16 +401,31 @@ class Microphone extends Readable {
|
|
|
287
401
|
|
|
288
402
|
/** @internal */
|
|
289
403
|
_read() {
|
|
290
|
-
|
|
404
|
+
// Never (re)start native capture once stopped: stop() defers the
|
|
405
|
+
// end-of-stream push(null), and a _read() in that window would otherwise
|
|
406
|
+
// reopen the device.
|
|
407
|
+
if (this._started || this._stopped) return;
|
|
291
408
|
this._started = true;
|
|
292
409
|
|
|
293
410
|
this._native.start((err, chunk) => {
|
|
294
411
|
if (err) {
|
|
295
412
|
this._started = false;
|
|
413
|
+
// A start()-time failure is delivered here as the raw native error (not
|
|
414
|
+
// via wrapNativeError), so no decibri `code` is attached on the 'error'
|
|
415
|
+
// event, as with device-open failures; the dedicated MODEL_LOAD_FAILED
|
|
416
|
+
// code is reachable through wrapNativeError, not on this streaming path.
|
|
296
417
|
this.destroy(err);
|
|
297
418
|
return;
|
|
298
419
|
}
|
|
299
420
|
|
|
421
|
+
// On close the native pump flushes the buffered tail; that final data
|
|
422
|
+
// callback can run after stop() has begun ending the stream. Once the
|
|
423
|
+
// end-of-stream push(null) has been issued (or the stream was destroyed),
|
|
424
|
+
// drop any straggler rather than pushing past EOF.
|
|
425
|
+
if (this._ended || this.destroyed) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
300
429
|
// push returns false when the consumer is slow. We can't pause a mic,
|
|
301
430
|
// but we surface the backpressure warning so callers can react.
|
|
302
431
|
if (!this.push(chunk)) {
|
|
@@ -304,27 +433,15 @@ class Microphone extends Readable {
|
|
|
304
433
|
}
|
|
305
434
|
|
|
306
435
|
if (this._vad) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
436
|
+
// Both modes read the score from native: the Silero speech probability
|
|
437
|
+
// or, in energy mode, the RMS of the pre-enhancement signal. The native
|
|
438
|
+
// pump computes both on the signal before the opt-in enhancement step,
|
|
439
|
+
// so enabling enhancement does not change detection in either mode.
|
|
440
|
+
this._processVadValue(this._native.vadProbability);
|
|
313
441
|
}
|
|
314
442
|
});
|
|
315
443
|
}
|
|
316
444
|
|
|
317
|
-
/** @internal Energy-based VAD (RMS threshold) */
|
|
318
|
-
_processVadEnergy(chunk) {
|
|
319
|
-
const rms = computeRMS(chunk, this._dtype);
|
|
320
|
-
this._processVadValue(rms);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** @internal Silero ML-based VAD (reads probability from native) */
|
|
324
|
-
_processVadSilero(probability) {
|
|
325
|
-
this._processVadValue(probability);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
445
|
/** @internal Common speech/silence state machine */
|
|
329
446
|
_processVadValue(value) {
|
|
330
447
|
this._vadScore = value;
|
|
@@ -350,10 +467,23 @@ class Microphone extends Readable {
|
|
|
350
467
|
stop() {
|
|
351
468
|
if (!this._started) return;
|
|
352
469
|
this._started = false;
|
|
470
|
+
this._stopped = true;
|
|
353
471
|
this._native.stop();
|
|
354
472
|
clearTimeout(this._silenceTimer);
|
|
355
473
|
this._silenceTimer = null;
|
|
356
|
-
|
|
474
|
+
// The native pump flushes any buffered tail on close; those final data
|
|
475
|
+
// callbacks are queued on the event loop and run before a setImmediate
|
|
476
|
+
// scheduled now. Defer the end-of-stream push(null) past them so the final
|
|
477
|
+
// short chunk reaches consumers without landing after EOF
|
|
478
|
+
// (ERR_STREAM_PUSH_AFTER_EOF). `_ended` is set first so the data callback
|
|
479
|
+
// drops any straggler that somehow arrives after this point.
|
|
480
|
+
setImmediate(() => {
|
|
481
|
+
// A device error in the same tick may have destroyed the stream before
|
|
482
|
+
// this runs; don't end an already-destroyed stream.
|
|
483
|
+
if (this.destroyed) return;
|
|
484
|
+
this._ended = true;
|
|
485
|
+
this.push(null); // signals stream end
|
|
486
|
+
});
|
|
357
487
|
}
|
|
358
488
|
|
|
359
489
|
/**
|
|
@@ -366,7 +496,9 @@ class Microphone extends Readable {
|
|
|
366
496
|
|
|
367
497
|
/**
|
|
368
498
|
* Most recent VAD score for the active mode: the Silero speech probability
|
|
369
|
-
* in 'silero' mode, the normalized RMS of the
|
|
499
|
+
* in 'silero' mode, the normalized RMS of the pre-enhancement signal in
|
|
500
|
+
* 'energy' mode. Both are computed natively on the signal before any opt-in
|
|
501
|
+
* enhancement step, so enabling enhancement does not change the score.
|
|
370
502
|
* 0 when VAD is disabled or before the first chunk is processed.
|
|
371
503
|
* @returns {number}
|
|
372
504
|
*/
|
|
@@ -428,9 +560,394 @@ function version() {
|
|
|
428
560
|
return Microphone.version();
|
|
429
561
|
}
|
|
430
562
|
|
|
563
|
+
// ─── File (Readable): offline source ─────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
class File extends Readable {
|
|
566
|
+
/**
|
|
567
|
+
* Open a WAV file as an offline source, synchronously. Everything a
|
|
568
|
+
* `Microphone` does to live audio, a `File` does to audio you already
|
|
569
|
+
* have: the same conditioning options, the same stream of conditioned
|
|
570
|
+
* chunks, and (with `vad` set) the same per-chunk speech events, plus the
|
|
571
|
+
* whole-file `analyze()` a live stream cannot offer.
|
|
572
|
+
*
|
|
573
|
+
* The bare constructor reads the WAV inline, blocking the event loop on
|
|
574
|
+
* disk I/O; prefer `await File.open(path, options)` in servers and other
|
|
575
|
+
* latency-sensitive code, exactly as `Microphone.open` is preferred over
|
|
576
|
+
* `new Microphone`. Iteration and analysis are separate single passes:
|
|
577
|
+
* each consumes the source once, so use one `File` per operation.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} filePath Path to a WAV file (16-bit PCM or 32-bit float).
|
|
580
|
+
* @param {import('./decibri').FileOptions} [options]
|
|
581
|
+
* @param {{ prepared: object, native: object }} [_internal] Internal: a
|
|
582
|
+
* pre-resolved options bundle and an already-constructed native handle,
|
|
583
|
+
* passed by the async `File.open()` factory and by `File.buffer()`. Not
|
|
584
|
+
* part of the public API.
|
|
585
|
+
*/
|
|
586
|
+
constructor(filePath, options = {}, _internal = undefined) {
|
|
587
|
+
super({ highWaterMark: options.highWaterMark, objectMode: false });
|
|
588
|
+
|
|
589
|
+
const prepared = _internal ? _internal.prepared : File._prepareOptions(options);
|
|
590
|
+
|
|
591
|
+
// ── Store config ───────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
this._vad = prepared.vadEnabled;
|
|
594
|
+
this._vadMode = prepared.vadMode;
|
|
595
|
+
this._vadThreshold = prepared.vadThreshold;
|
|
596
|
+
// The speaking holdoff on a File is measured in FILE time (sample
|
|
597
|
+
// positions converted to seconds), never wall-clock time: a file
|
|
598
|
+
// processes faster than real time, so a wall-clock timer would collapse
|
|
599
|
+
// the reported speech timing. Positions advance as chunks are pulled.
|
|
600
|
+
this._vadHoldoffSeconds = prepared.vadHoldoff / 1000;
|
|
601
|
+
this._vadScore = 0;
|
|
602
|
+
this._isSpeaking = false;
|
|
603
|
+
this._silenceStartPos = null;
|
|
604
|
+
this._position = 0;
|
|
605
|
+
this._sampleRate = prepared.nativeOptions.sampleRate;
|
|
606
|
+
this._bytesPerSample = prepared.dtype === 'int16' ? 2 : 4;
|
|
607
|
+
this._ended = false;
|
|
608
|
+
|
|
609
|
+
// ── Create or adopt native handle ───────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
if (_internal) {
|
|
612
|
+
this._native = _internal.native;
|
|
613
|
+
} else {
|
|
614
|
+
if (typeof filePath !== 'string') {
|
|
615
|
+
throw new TypeError('path must be a string');
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
this._native = FileHandle.open(filePath, prepared.nativeOptions);
|
|
619
|
+
} catch (err) {
|
|
620
|
+
throw wrapNativeError(err);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Validate the constructor options and resolve them into the native options
|
|
627
|
+
* object plus the wrapper-side state. The checks and messages mirror
|
|
628
|
+
* `Microphone._prepareOptions` exactly for every shared option; the
|
|
629
|
+
* live-capture-only options (device, channels, framesPerBuffer) do not
|
|
630
|
+
* apply to an offline source.
|
|
631
|
+
* @internal
|
|
632
|
+
* @param {import('./decibri').FileOptions} options
|
|
633
|
+
*/
|
|
634
|
+
static _prepareOptions(options) {
|
|
635
|
+
const sampleRate = options.sampleRate ?? 16000;
|
|
636
|
+
if (sampleRate < 1000 || sampleRate > 384000) {
|
|
637
|
+
throw new RangeError('sample rate must be between 1000 and 384000');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const dtype = options.dtype ?? 'int16';
|
|
641
|
+
if (dtype !== 'int16' && dtype !== 'float32') {
|
|
642
|
+
throw new TypeError("dtype must be 'int16' or 'float32'");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── Validate VAD options (same acceptance as Microphone) ────────────────
|
|
646
|
+
|
|
647
|
+
const vad = options.vad ?? false;
|
|
648
|
+
let vadEnabled;
|
|
649
|
+
let vadMode;
|
|
650
|
+
let vadThreshold;
|
|
651
|
+
let vadHoldoff;
|
|
652
|
+
if (vad === false) {
|
|
653
|
+
vadEnabled = false;
|
|
654
|
+
vadMode = 'energy'; // inert placeholder; ignored while disabled
|
|
655
|
+
} else if (vad === true) {
|
|
656
|
+
throw new TypeError(
|
|
657
|
+
"vad: true is no longer supported. Specify the mode explicitly: vad: 'silero' or vad: 'energy'."
|
|
658
|
+
);
|
|
659
|
+
} else if (vad === 'silero' || vad === 'energy') {
|
|
660
|
+
vadEnabled = true;
|
|
661
|
+
vadMode = vad;
|
|
662
|
+
} else if (vad !== null && typeof vad === 'object' && !Array.isArray(vad)) {
|
|
663
|
+
const { model, threshold, holdoffMs } = vad;
|
|
664
|
+
if (model !== 'silero' && model !== 'energy') {
|
|
665
|
+
throw new TypeError(
|
|
666
|
+
`Invalid vad model: ${JSON.stringify(model)}. Expected 'silero' or 'energy'.`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
vadEnabled = true;
|
|
670
|
+
vadMode = model;
|
|
671
|
+
if (threshold !== undefined) {
|
|
672
|
+
if (typeof threshold !== 'number' || Number.isNaN(threshold)) {
|
|
673
|
+
throw new TypeError('vad threshold must be a number');
|
|
674
|
+
}
|
|
675
|
+
if (threshold < 0 || threshold > 1) {
|
|
676
|
+
throw new RangeError('vad threshold must be between 0 and 1');
|
|
677
|
+
}
|
|
678
|
+
vadThreshold = threshold;
|
|
679
|
+
}
|
|
680
|
+
if (holdoffMs !== undefined) {
|
|
681
|
+
if (typeof holdoffMs !== 'number' || Number.isNaN(holdoffMs)) {
|
|
682
|
+
throw new TypeError('vad holdoffMs must be a number');
|
|
683
|
+
}
|
|
684
|
+
if (holdoffMs < 0) {
|
|
685
|
+
throw new RangeError('vad holdoffMs must be non-negative');
|
|
686
|
+
}
|
|
687
|
+
vadHoldoff = holdoffMs;
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
throw new TypeError(
|
|
691
|
+
`Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs }.`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let modelPath = undefined;
|
|
696
|
+
if (vadEnabled && vadMode === 'silero') {
|
|
697
|
+
modelPath = options.modelPath || path.join(__dirname, '..', 'models', 'silero_vad.onnx');
|
|
698
|
+
if (!fs.existsSync(modelPath)) {
|
|
699
|
+
throw new Error(`Silero VAD model not found at ${modelPath}. Ensure the models/ directory is included in your installation.`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Conditioning options (identical checks to Microphone) ───────────────
|
|
704
|
+
|
|
705
|
+
const dcRemoval = options.dcRemoval;
|
|
706
|
+
|
|
707
|
+
const denoise = options.denoise;
|
|
708
|
+
let denoiseModelPath = undefined;
|
|
709
|
+
if (denoise !== undefined) {
|
|
710
|
+
if (denoise !== 'fastenhancer-t') {
|
|
711
|
+
throw new TypeError(
|
|
712
|
+
`Invalid denoise value: ${JSON.stringify(denoise)}. Expected 'fastenhancer-t'.`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
denoiseModelPath = path.join(__dirname, '..', 'models', 'fastenhancer_t.onnx');
|
|
716
|
+
if (!fs.existsSync(denoiseModelPath)) {
|
|
717
|
+
throw new Error(`Denoise model not found at ${denoiseModelPath}. Ensure the models/ directory is included in your installation.`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const highpass = options.highpass;
|
|
722
|
+
if (highpass !== undefined && highpass !== 80 && highpass !== 100) {
|
|
723
|
+
throw new RangeError('highpass must be one of: 80, 100');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const agc = options.agc;
|
|
727
|
+
if (agc !== undefined && (agc < -40 || agc > -3)) {
|
|
728
|
+
throw new RangeError('agc target level must be between -40 and -3');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const limiter = options.limiter;
|
|
732
|
+
if (limiter !== undefined && (limiter < -3.0 || limiter > 0.0)) {
|
|
733
|
+
throw new RangeError('limiter ceiling must be between -3.0 and 0.0');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
let ortLibraryPath = undefined;
|
|
737
|
+
if ((vadEnabled && vadMode === 'silero') || denoise !== undefined) {
|
|
738
|
+
ortLibraryPath = resolveBundledOrtPath();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
dtype,
|
|
743
|
+
vadEnabled,
|
|
744
|
+
vadMode,
|
|
745
|
+
vadThreshold: vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01),
|
|
746
|
+
vadHoldoff: vadHoldoff ?? 300,
|
|
747
|
+
nativeOptions: {
|
|
748
|
+
sampleRate,
|
|
749
|
+
format: dtype,
|
|
750
|
+
// Pass the mode to native only when VAD is enabled, exactly as the
|
|
751
|
+
// Microphone options do; absent means VAD off in native.
|
|
752
|
+
vadMode: vadEnabled ? vadMode : undefined,
|
|
753
|
+
// The whole-file analysis applies threshold and holdoff in the core
|
|
754
|
+
// (segment merging in file time), so both cross the boundary here,
|
|
755
|
+
// unlike the live path where the policy is wrapper-only.
|
|
756
|
+
vadThreshold: vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01),
|
|
757
|
+
vadHoldoffMs: vadHoldoff ?? 300,
|
|
758
|
+
modelPath,
|
|
759
|
+
dcRemoval,
|
|
760
|
+
denoise,
|
|
761
|
+
denoiseModelPath,
|
|
762
|
+
ortLibraryPath,
|
|
763
|
+
highpass,
|
|
764
|
+
agc,
|
|
765
|
+
limiter,
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Open a WAV file without blocking the event loop: the disk read, WAV
|
|
772
|
+
* parse, and chain construction run on the native thread pool. The
|
|
773
|
+
* recommended form in Node, mirroring `Microphone.open`. The synchronous
|
|
774
|
+
* `new File(path)` remains available for scripts.
|
|
775
|
+
*
|
|
776
|
+
* @param {string} filePath
|
|
777
|
+
* @param {import('./decibri').FileOptions} [options]
|
|
778
|
+
* @returns {Promise<File>}
|
|
779
|
+
*/
|
|
780
|
+
static async open(filePath, options = {}) {
|
|
781
|
+
if (typeof filePath !== 'string') {
|
|
782
|
+
throw new TypeError('path must be a string');
|
|
783
|
+
}
|
|
784
|
+
const prepared = File._prepareOptions(options);
|
|
785
|
+
let native;
|
|
786
|
+
try {
|
|
787
|
+
native = await FileHandle.openAsync(filePath, prepared.nativeOptions);
|
|
788
|
+
} catch (err) {
|
|
789
|
+
throw wrapNativeError(err);
|
|
790
|
+
}
|
|
791
|
+
return new File(filePath, options, { prepared, native });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Wrap in-memory samples as an offline source. `samples` must be a
|
|
796
|
+
* `Float32Array` of mono samples in [-1.0, 1.0]; a raw `Buffer` of PCM
|
|
797
|
+
* bytes is rejected as ambiguous (encoded bytes, int16 PCM, and f32
|
|
798
|
+
* samples are indistinguishable, and decibri's own capture output is a
|
|
799
|
+
* `Buffer`). Raw samples carry no header, so `inputRate` (their native
|
|
800
|
+
* rate) is required; `sampleRate` stays the target output rate. No I/O,
|
|
801
|
+
* so construction is synchronous.
|
|
802
|
+
*
|
|
803
|
+
* @param {Float32Array} samples
|
|
804
|
+
* @param {import('./decibri').FileBufferOptions} [options]
|
|
805
|
+
* @returns {File}
|
|
806
|
+
*/
|
|
807
|
+
static buffer(samples, options = {}) {
|
|
808
|
+
if (Buffer.isBuffer(samples)) {
|
|
809
|
+
throw new TypeError(
|
|
810
|
+
'File.buffer requires a Float32Array of samples, not a Buffer of bytes'
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (!(samples instanceof Float32Array)) {
|
|
814
|
+
throw new TypeError('File.buffer requires a Float32Array of samples');
|
|
815
|
+
}
|
|
816
|
+
const inputRate = options.inputRate;
|
|
817
|
+
if (typeof inputRate !== 'number' || Number.isNaN(inputRate)) {
|
|
818
|
+
throw new TypeError('inputRate is required for File.buffer (samples carry no header)');
|
|
819
|
+
}
|
|
820
|
+
if (inputRate < 1000 || inputRate > 384000) {
|
|
821
|
+
throw new RangeError('inputRate must be between 1000 and 384000');
|
|
822
|
+
}
|
|
823
|
+
const prepared = File._prepareOptions(options);
|
|
824
|
+
let native;
|
|
825
|
+
try {
|
|
826
|
+
native = FileHandle.buffer(samples, inputRate, prepared.nativeOptions);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
throw wrapNativeError(err);
|
|
829
|
+
}
|
|
830
|
+
return new File(null, options, { prepared, native });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** @internal */
|
|
834
|
+
_read() {
|
|
835
|
+
if (this._ended) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
let chunk;
|
|
839
|
+
try {
|
|
840
|
+
// One chunk per pull: the conditioning compute runs synchronously
|
|
841
|
+
// here, so pulling one chunk at a time keeps the event loop breathing
|
|
842
|
+
// between chunks while the stream machinery re-calls _read on demand.
|
|
843
|
+
chunk = this._native.readChunk();
|
|
844
|
+
} catch (err) {
|
|
845
|
+
this.destroy(wrapNativeError(err));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (chunk === null || chunk === undefined) {
|
|
849
|
+
this._ended = true;
|
|
850
|
+
this.push(null); // finite source: the stream ends at EOF
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (this._vad) {
|
|
854
|
+
// Both modes read the score from native, computed on the signal
|
|
855
|
+
// before the opt-in conditioning step, exactly as the live pump does.
|
|
856
|
+
this._processVadValue(this._native.vadProbability, chunk.length);
|
|
857
|
+
} else {
|
|
858
|
+
this._position += chunk.length / this._bytesPerSample / this._sampleRate;
|
|
859
|
+
}
|
|
860
|
+
this.push(chunk);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* @internal Speech/silence state machine in FILE time. The same policy as
|
|
865
|
+
* the Microphone's wall-clock machine, with the holdoff measured in
|
|
866
|
+
* seconds of audio position instead of a timer: state flips only as the
|
|
867
|
+
* file's own timeline passes the holdoff, so processing speed never
|
|
868
|
+
* changes the reported events.
|
|
869
|
+
*/
|
|
870
|
+
_processVadValue(value, chunkBytes) {
|
|
871
|
+
const chunkStart = this._position;
|
|
872
|
+
const chunkEnd = chunkStart + chunkBytes / this._bytesPerSample / this._sampleRate;
|
|
873
|
+
this._position = chunkEnd;
|
|
874
|
+
this._vadScore = value;
|
|
875
|
+
if (value >= this._vadThreshold) {
|
|
876
|
+
this._silenceStartPos = null;
|
|
877
|
+
if (!this._isSpeaking) {
|
|
878
|
+
this._isSpeaking = true;
|
|
879
|
+
this.emit('speech');
|
|
880
|
+
}
|
|
881
|
+
} else if (this._isSpeaking) {
|
|
882
|
+
if (this._silenceStartPos === null) {
|
|
883
|
+
this._silenceStartPos = chunkStart;
|
|
884
|
+
}
|
|
885
|
+
if (chunkEnd - this._silenceStartPos >= this._vadHoldoffSeconds) {
|
|
886
|
+
this._isSpeaking = false;
|
|
887
|
+
this._silenceStartPos = null;
|
|
888
|
+
this.emit('silence');
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Most recent per-chunk VAD score for the active mode: the Silero speech
|
|
895
|
+
* probability in 'silero' mode, the normalized RMS of the pre-conditioning
|
|
896
|
+
* signal in 'energy' mode. 0 when VAD is disabled or before the first
|
|
897
|
+
* chunk. The same quantity the live `Microphone.vadScore` reports.
|
|
898
|
+
* @returns {number}
|
|
899
|
+
*/
|
|
900
|
+
get vadScore() {
|
|
901
|
+
return this._vadScore;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Analyze the whole recording for speech. Runs the recording once through
|
|
906
|
+
* the conditioning pass off the event loop and resolves to a `VadReport`:
|
|
907
|
+
* per-window `scores` (`{ start, end, vadScore, isSpeech }`) and merged
|
|
908
|
+
* speech `segments` (`{ start, end }`), all in seconds of file time.
|
|
909
|
+
* Consumes the source: analysis and iteration are separate single passes.
|
|
910
|
+
*
|
|
911
|
+
* Requires VAD: a `File` opened without `vad` rejects with the core's
|
|
912
|
+
* "analysis requires VAD" error (a `RangeError`); the energy mode has no
|
|
913
|
+
* whole-file analysis and rejects likewise. Never constructs a detector
|
|
914
|
+
* silently.
|
|
915
|
+
*
|
|
916
|
+
* @returns {Promise<import('./decibri').VadReport>}
|
|
917
|
+
*/
|
|
918
|
+
async analyze() {
|
|
919
|
+
if (this._vad && this._vadMode === 'energy') {
|
|
920
|
+
throw new RangeError(
|
|
921
|
+
"analyze() requires vad: 'silero'; energy mode does not support whole-file analysis"
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
return await this._native.analyze();
|
|
926
|
+
} catch (err) {
|
|
927
|
+
throw wrapNativeError(err);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The same whole-recording analysis under the international spelling.
|
|
933
|
+
* @returns {Promise<import('./decibri').VadReport>}
|
|
934
|
+
*/
|
|
935
|
+
analyse() {
|
|
936
|
+
return this.analyze();
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Release the source. Idempotent; a closed File reads as ended.
|
|
941
|
+
*/
|
|
942
|
+
close() {
|
|
943
|
+
this._native.close();
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
431
947
|
module.exports = {
|
|
432
948
|
Microphone,
|
|
433
949
|
Speaker,
|
|
950
|
+
File,
|
|
434
951
|
inputDevices,
|
|
435
952
|
outputDevices,
|
|
436
953
|
version,
|