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/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 || channels > 32) {
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
- // Single vad union: false (disabled, default), 'silero', or 'energy'. The
202
- // legacy two-flag form (vad: true plus vadMode) is rejected with a
203
- // migration error. Energy and Silero are both computed in this wrapper;
204
- // the union only selects which.
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', or 'energy'.`
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
- // Internal plumbing: inject the bundled ORT dylib path into the napi
232
- // constructor. If resolution fails (unknown platform, platform package
233
- // not installed), leaves ortLibraryPath as undefined and lets Rust fall
234
- // through to ORT_DYLIB_PATH or surface a decibri-specific init error.
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: options.vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01),
243
- vadHoldoff: options.vadHoldoff ?? 300,
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
- vadMode,
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
- if (this._started) return;
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
- if (this._vadMode === 'silero') {
308
- const prob = this._native.vadProbability;
309
- this._processVadSilero(prob);
310
- } else {
311
- this._processVadEnergy(chunk);
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
- this.push(null); // signals stream end
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 last chunk in 'energy' mode.
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,