decibri 3.4.2 → 4.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
@@ -4,7 +4,18 @@ const { Readable } = require('stream');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const { DecibriBridge } = require('../index.js');
7
- const { wrapNativeError } = require('./errors');
7
+ const {
8
+ wrapNativeError,
9
+ DecibriError,
10
+ DeviceError,
11
+ OrtError,
12
+ OrtPathError,
13
+ } = require('./errors');
14
+
15
+ // The npm package version, reported as `binding` by version(). Read from
16
+ // package.json so it tracks the published package and cannot drift from a
17
+ // hardcoded string.
18
+ const PACKAGE_VERSION = require('../package.json').version;
8
19
 
9
20
  // ─── Bundled ONNX Runtime path resolution ────────────────────────────────────
10
21
 
@@ -63,9 +74,9 @@ function resolveBundledOrtPath() {
63
74
 
64
75
  // ─── RMS helper ──────────────────────────────────────────────────────────────
65
76
 
66
- function computeRMS(chunk, format) {
77
+ function computeRMS(chunk, dtype) {
67
78
  let sum = 0, n;
68
- if (format === 'float32') {
79
+ if (dtype === 'float32') {
69
80
  const samples = new Float32Array(chunk.buffer, chunk.byteOffset, chunk.length / 4);
70
81
  n = samples.length;
71
82
  for (let i = 0; i < n; i++) sum += samples[i] * samples[i];
@@ -80,15 +91,61 @@ function computeRMS(chunk, format) {
80
91
  return n > 0 ? Math.sqrt(sum / n) : 0;
81
92
  }
82
93
 
83
- // ─── Decibri (Readable) ─────────────────────────────────────────────────────
94
+ // ─── Microphone (Readable) ──────────────────────────────────────────────────
84
95
 
85
- class Decibri extends Readable {
96
+ class Microphone extends Readable {
86
97
  /**
87
- * @param {import('./decibri').DecibriOptions} [options]
98
+ * @param {import('./decibri').MicrophoneOptions} [options]
99
+ * @param {{ prepared: object, native: object }} [_internal] Internal: a
100
+ * pre-resolved options bundle and an already-constructed native bridge,
101
+ * passed by the async `Microphone.open()` factory so the heavy open work
102
+ * (the Silero model load) is not repeated on the event loop. Not part of
103
+ * the public API.
88
104
  */
89
- constructor(options = {}) {
105
+ constructor(options = {}, _internal = undefined) {
90
106
  super({ highWaterMark: options.highWaterMark, objectMode: false });
91
107
 
108
+ // Validate and resolve options once. The async factory passes its already
109
+ // resolved bundle through `_internal` to avoid recomputing it.
110
+ const prepared = _internal ? _internal.prepared : Microphone._prepareOptions(options);
111
+
112
+ // ── Store config ───────────────────────────────────────────────────────
113
+
114
+ this._dtype = prepared.dtype;
115
+ this._vad = prepared.vadEnabled;
116
+ this._vadMode = prepared.vadMode;
117
+ this._vadThreshold = prepared.vadThreshold;
118
+ this._vadHoldoff = prepared.vadHoldoff;
119
+ this._vadScore = 0;
120
+ this._isSpeaking = false;
121
+ this._silenceTimer = null;
122
+ this._started = false;
123
+
124
+ // ── Create or adopt native bridge ───────────────────────────────────────
125
+
126
+ if (_internal) {
127
+ // Built off the event loop by Microphone.open(); already wrapped.
128
+ this._native = _internal.native;
129
+ } else {
130
+ try {
131
+ this._native = new DecibriBridge(prepared.nativeOptions);
132
+ } catch (err) {
133
+ throw wrapNativeError(err);
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Validate the constructor options and resolve them into the native options
140
+ * object plus the wrapper-side state. Throws the same `RangeError` /
141
+ * `TypeError` / `Error` as the constructor on invalid input. Shared by the
142
+ * synchronous constructor and the async `open()` factory so both validate
143
+ * identically. Does no native open work beyond the numeric-device bounds
144
+ * check (a fast device enumeration).
145
+ * @internal
146
+ * @param {import('./decibri').MicrophoneOptions} options
147
+ */
148
+ static _prepareOptions(options) {
92
149
  // ── Validate options ───────────────────────────────────────────────────
93
150
 
94
151
  const sampleRate = options.sampleRate ?? 16000;
@@ -106,33 +163,23 @@ class Decibri extends Readable {
106
163
  throw new RangeError('frames per buffer must be between 64 and 65536');
107
164
  }
108
165
 
109
- const format = options.format ?? 'int16';
110
- if (format !== 'int16' && format !== 'float32') {
111
- throw new TypeError("format must be 'int16' or 'float32'");
166
+ const dtype = options.dtype ?? 'int16';
167
+ if (dtype !== 'int16' && dtype !== 'float32') {
168
+ throw new TypeError("dtype must be 'int16' or 'float32'");
112
169
  }
113
170
 
114
171
  // ── Resolve device ──────────────────────────────────────────────────────
115
172
 
173
+ // Name and multi-match resolution are delegated to the core, which owns
174
+ // the renamed-vocabulary errors (MicrophoneNotFound / MultipleDevicesMatch).
175
+ // A string name and an { id } object are passed straight through to the
176
+ // native addon. Only the numeric index keeps a client-side bounds check,
177
+ // for a clean Node-side RangeError without a round-trip.
116
178
  let resolvedDevice = options.device;
117
- if (typeof options.device === 'string') {
118
- const lower = options.device.toLowerCase();
119
- const matches = DecibriBridge.devices().filter(d =>
120
- d.name.toLowerCase().includes(lower)
121
- );
122
- if (matches.length === 0) {
123
- throw new TypeError(`No audio input device found matching "${options.device}"`);
124
- }
125
- if (matches.length > 1) {
126
- const names = matches.map(d => ` [${d.index}] ${d.name}`).join('\n');
127
- throw new TypeError(
128
- `Multiple devices match "${options.device}":\n${names}\nUse a more specific name or pass the device index directly.`
129
- );
130
- }
131
- resolvedDevice = matches[0].index;
132
- } else if (typeof options.device === 'number') {
179
+ if (typeof options.device === 'number') {
133
180
  const devices = DecibriBridge.devices();
134
181
  if (options.device < 0 || options.device >= devices.length) {
135
- throw new RangeError('device index out of range. Call Decibri.devices() to list available devices');
182
+ throw new RangeError('device index out of range. Call Microphone.devices() to list available devices');
136
183
  }
137
184
  resolvedDevice = options.device;
138
185
  } else if (
@@ -151,14 +198,32 @@ class Decibri extends Readable {
151
198
 
152
199
  // ── Validate VAD options ─────────────────────────────────────────────────
153
200
 
154
- const vadMode = options.vadMode ?? 'energy';
155
- if (vadMode !== 'energy' && vadMode !== 'silero') {
156
- throw new TypeError("vadMode must be 'energy' or 'silero'");
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.
205
+ const vad = options.vad ?? false;
206
+ let vadEnabled;
207
+ let vadMode;
208
+ if (vad === false) {
209
+ vadEnabled = false;
210
+ vadMode = 'energy'; // inert placeholder; ignored while disabled
211
+ } else if (vad === true) {
212
+ throw new TypeError(
213
+ "vad: true is no longer supported. Specify the mode explicitly: vad: 'silero' or vad: 'energy'."
214
+ );
215
+ } else if (vad === 'silero' || vad === 'energy') {
216
+ vadEnabled = true;
217
+ vadMode = vad;
218
+ } else {
219
+ throw new TypeError(
220
+ `Invalid vad value: ${JSON.stringify(vad)}. Expected false, 'silero', or 'energy'.`
221
+ );
157
222
  }
158
223
 
159
224
  let modelPath = undefined;
160
225
  let ortLibraryPath = undefined;
161
- if (vadMode === 'silero' && options.vad) {
226
+ if (vadEnabled && vadMode === 'silero') {
162
227
  modelPath = options.modelPath || path.join(__dirname, '..', 'models', 'silero_vad.onnx');
163
228
  if (!fs.existsSync(modelPath)) {
164
229
  throw new Error(`Silero VAD model not found at ${modelPath}. Ensure the models/ directory is included in your installation.`);
@@ -170,33 +235,54 @@ class Decibri extends Readable {
170
235
  ortLibraryPath = resolveBundledOrtPath();
171
236
  }
172
237
 
173
- // ── Store config ───────────────────────────────────────────────────────
174
-
175
- this._format = format;
176
- this._vad = options.vad || false;
177
- this._vadMode = vadMode;
178
- this._vadThreshold = options.vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01);
179
- this._vadHoldoff = options.vadHoldoff ?? 300;
180
- this._isSpeaking = false;
181
- this._silenceTimer = null;
182
- this._started = false;
183
-
184
- // ── Create native bridge ───────────────────────────────────────────────
185
-
186
- try {
187
- this._native = new DecibriBridge({
238
+ return {
239
+ dtype,
240
+ vadEnabled,
241
+ vadMode,
242
+ vadThreshold: options.vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01),
243
+ vadHoldoff: options.vadHoldoff ?? 300,
244
+ nativeOptions: {
188
245
  sampleRate,
189
246
  channels,
190
247
  framesPerBuffer,
191
- format,
248
+ format: dtype,
192
249
  device: resolvedDevice,
193
- vadMode: (options.vad && vadMode === 'silero') ? 'silero' : 'energy',
250
+ vadMode,
194
251
  modelPath,
195
252
  ortLibraryPath,
196
- });
253
+ },
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Construct a Microphone without blocking the event loop on the open work.
259
+ *
260
+ * The synchronous `new Microphone(...)` constructor loads the Silero VAD
261
+ * model inline when `vad: 'silero'` is set, which blocks the event loop for
262
+ * roughly 100 to 500 ms on a cold cache. This static factory runs that load
263
+ * (and device resolution) on the native thread pool and resolves to a ready
264
+ * instance, so latency-sensitive callers (voice pipelines, websocket
265
+ * handlers) do not stall. The synchronous constructor remains available and
266
+ * unchanged.
267
+ *
268
+ * Mirrors the Python `AsyncMicrophone.open()` factory. Options are identical
269
+ * to the constructor. A failed open (bad model path, unknown device, ORT
270
+ * load failure) rejects the returned Promise with the matching error class
271
+ * (`RangeError` / `TypeError` for invalid options, `DeviceError` / `OrtError`
272
+ * / `OrtPathError` for native failures), rather than throwing synchronously.
273
+ *
274
+ * @param {import('./decibri').MicrophoneOptions} [options]
275
+ * @returns {Promise<Microphone>}
276
+ */
277
+ static async open(options = {}) {
278
+ const prepared = Microphone._prepareOptions(options);
279
+ let native;
280
+ try {
281
+ native = await DecibriBridge.openAsync(prepared.nativeOptions);
197
282
  } catch (err) {
198
283
  throw wrapNativeError(err);
199
284
  }
285
+ return new Microphone(options, { prepared, native });
200
286
  }
201
287
 
202
288
  /** @internal */
@@ -230,7 +316,7 @@ class Decibri extends Readable {
230
316
 
231
317
  /** @internal Energy-based VAD (RMS threshold) */
232
318
  _processVadEnergy(chunk) {
233
- const rms = computeRMS(chunk, this._format);
319
+ const rms = computeRMS(chunk, this._dtype);
234
320
  this._processVadValue(rms);
235
321
  }
236
322
 
@@ -241,6 +327,7 @@ class Decibri extends Readable {
241
327
 
242
328
  /** @internal Common speech/silence state machine */
243
329
  _processVadValue(value) {
330
+ this._vadScore = value;
244
331
  if (value >= this._vadThreshold) {
245
332
  clearTimeout(this._silenceTimer);
246
333
  this._silenceTimer = null;
@@ -277,6 +364,16 @@ class Decibri extends Readable {
277
364
  return this._native.isOpen;
278
365
  }
279
366
 
367
+ /**
368
+ * 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.
370
+ * 0 when VAD is disabled or before the first chunk is processed.
371
+ * @returns {number}
372
+ */
373
+ get vadScore() {
374
+ return this._vadScore;
375
+ }
376
+
280
377
  /**
281
378
  * List all available input devices on the system.
282
379
  * @returns {Array<{index: number, name: string, maxInputChannels: number, defaultSampleRate: number, isDefault: boolean}>}
@@ -286,15 +383,49 @@ class Decibri extends Readable {
286
383
  }
287
384
 
288
385
  /**
289
- * Version information for decibri and the audio runtime.
290
- * @returns {{ decibri: string, portaudio: string }}
386
+ * Version information for decibri, the audio backend, and this binding.
387
+ * @returns {{ decibri: string, audioBackend: string, binding: string }}
291
388
  */
292
389
  static version() {
293
- return DecibriBridge.version();
390
+ const v = DecibriBridge.version();
391
+ return { decibri: v.decibri, audioBackend: v.audioBackend, binding: PACKAGE_VERSION };
294
392
  }
295
393
  }
296
394
 
297
- const DecibriOutput = require('./decibri-output.js');
298
- Decibri.DecibriOutput = DecibriOutput;
395
+ const Speaker = require('./decibri-output.js');
396
+
397
+ /**
398
+ * List all available audio input devices.
399
+ * @returns {Array<{index: number, name: string, maxInputChannels: number, defaultSampleRate: number, isDefault: boolean}>}
400
+ */
401
+ function inputDevices() {
402
+ return Microphone.devices();
403
+ }
404
+
405
+ /**
406
+ * List all available audio output devices.
407
+ * @returns {Array<{index: number, name: string, maxOutputChannels: number, defaultSampleRate: number, isDefault: boolean}>}
408
+ */
409
+ function outputDevices() {
410
+ return Speaker.devices();
411
+ }
412
+
413
+ /**
414
+ * Version information for decibri, the audio backend, and this binding.
415
+ * @returns {{ decibri: string, audioBackend: string, binding: string }}
416
+ */
417
+ function version() {
418
+ return Microphone.version();
419
+ }
299
420
 
300
- module.exports = Decibri;
421
+ module.exports = {
422
+ Microphone,
423
+ Speaker,
424
+ inputDevices,
425
+ outputDevices,
426
+ version,
427
+ DecibriError,
428
+ DeviceError,
429
+ OrtError,
430
+ OrtPathError,
431
+ };
package/src/errors.js CHANGED
@@ -1,40 +1,143 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Wrap a native error from decibri-node as a typed JS error class.
4
+ * decibri error classes.
5
5
  *
6
- * The native binding's to_napi_error uses Status::InvalidArg for caller-input
7
- * errors and Status::GenericFailure for system errors. This wrapper inspects
8
- * err.code and matches on the frozen error message prefixes from the Rust
9
- * core (see CLAUDE.md:28-30, error messages are part of the frozen contract)
10
- * to classify as TypeError (wrong kind of value) or RangeError (value out of
11
- * allowed range). Non-InvalidArg errors are passed through unchanged.
6
+ * A shallow set of catch-root classes that mirror the catch-roots the Python
7
+ * binding exposes (see bindings/python/python/decibri/exceptions.py), so
8
+ * `instanceof DecibriError` / `instanceof DeviceError` works the same way
9
+ * across bindings. Node idiom is shallow classes plus a stable `code` string,
10
+ * not a deep subclass tree: branch on `err.code` or on `instanceof`.
12
11
  *
13
- * The shim covers all InvalidArg errors, not just a specific subset: even
14
- * though only some code paths currently reach Rust's to_napi_error, the
15
- * mapping stays complete and consistent so future paths get typed errors
16
- * without additional shim updates.
12
+ * DecibriError (base; extends Error)
13
+ * +- DeviceError (device enumeration / selection failures)
14
+ * +- OrtError (ONNX Runtime setup / inference failures)
15
+ * +- OrtPathError (a specific ORT library path could not be loaded)
17
16
  *
18
- * @param {Error} err Error thrown from a native DecibriBridge /
19
- * DecibriOutputBridge call.
20
- * @returns {Error|TypeError|RangeError} Re-wrapped error preserving code.
17
+ * Argument validation (bad sample rate, channels, frames, dtype, vad) keeps
18
+ * Node's built-in RangeError / TypeError, matching Node core; those are not
19
+ * decibri error classes by design.
20
+ */
21
+
22
+ class DecibriError extends Error {
23
+ /**
24
+ * @param {string} message
25
+ * @param {string} [code] Stable string code for `err.code` branching.
26
+ */
27
+ constructor(message, code) {
28
+ super(message);
29
+ this.name = 'DecibriError';
30
+ this.code = code;
31
+ }
32
+ }
33
+
34
+ class DeviceError extends DecibriError {
35
+ constructor(message, code) {
36
+ super(message, code);
37
+ this.name = 'DeviceError';
38
+ }
39
+ }
40
+
41
+ class OrtError extends DecibriError {
42
+ constructor(message, code) {
43
+ super(message, code);
44
+ this.name = 'OrtError';
45
+ }
46
+ }
47
+
48
+ class OrtPathError extends OrtError {
49
+ constructor(message, code) {
50
+ super(message, code);
51
+ this.name = 'OrtPathError';
52
+ }
53
+ }
54
+
55
+ // ─── Native error classification ─────────────────────────────────────────────
56
+
57
+ // The napi layer flattens every core DecibriError variant to a Status plus the
58
+ // variant's Display string (see bindings/node/src/lib.rs to_napi_error), so the
59
+ // message text is the only thing left to classify on. The prefixes below match
60
+ // the frozen core messages in crates/decibri/src/error.rs.
61
+
62
+ const DEVICE_CODES = [
63
+ ['No microphone found matching', 'MICROPHONE_NOT_FOUND'],
64
+ ['No speaker found matching', 'SPEAKER_NOT_FOUND'],
65
+ ['Multiple devices match', 'MULTIPLE_DEVICES_MATCH'],
66
+ ['No microphone found.', 'NO_MICROPHONE_FOUND'],
67
+ ['No speaker found.', 'NO_SPEAKER_FOUND'],
68
+ ['Selected device is not a valid microphone', 'NOT_AN_INPUT_DEVICE'],
69
+ ['Failed to enumerate devices', 'DEVICE_ENUMERATION_FAILED'],
70
+ ];
71
+
72
+ const ORT_CODES = [
73
+ ['decibri: failed to initialize ONNX Runtime', 'ORT_INIT_FAILED'],
74
+ ['Failed to load Silero VAD model from', 'VAD_MODEL_LOAD_FAILED'],
75
+ ['Failed to create ort session builder', 'ORT_SESSION_BUILD_FAILED'],
76
+ ['Failed to set ort threads', 'ORT_THREADS_CONFIG_FAILED'],
77
+ ['Silero VAD inference failed', 'ORT_INFERENCE_FAILED'],
78
+ ];
79
+
80
+ /**
81
+ * Wrap an error thrown from a native DecibriBridge / DecibriOutputBridge call
82
+ * into the appropriate JS error: a built-in for argument validation, or a
83
+ * decibri catch-root class (with a `code`) for device and ONNX Runtime
84
+ * failures. The message is preserved verbatim.
85
+ *
86
+ * @param {Error} err Error thrown from a native constructor.
87
+ * @returns {Error} A RangeError, TypeError, or DecibriError subclass.
21
88
  */
22
89
  function wrapNativeError(err) {
23
- if (err.code !== 'InvalidArg') return err;
90
+ const msg = (err && err.message) || String(err);
24
91
 
25
- const msg = err.message;
26
- const isRange =
92
+ // Argument validation stays as Node built-ins. `device index out of range`
93
+ // is kept here (RangeError) so the index error type is the same whether it
94
+ // is raised by the client-side bounds check or by the core.
95
+ if (
27
96
  msg.startsWith('sample rate must be between') ||
28
97
  msg.startsWith('channels must be between') ||
29
98
  msg.startsWith('frames per buffer must be between') ||
30
99
  msg.startsWith('Silero VAD only supports') ||
31
100
  msg.startsWith('VAD threshold must be between') ||
32
- msg.startsWith('device index out of range');
101
+ msg.startsWith('device index out of range')
102
+ ) {
103
+ return new RangeError(msg);
104
+ }
105
+ if (
106
+ msg.startsWith("dtype must be 'int16' or 'float32'") ||
107
+ msg.startsWith("format must be 'int16' or 'float32'")
108
+ ) {
109
+ return new TypeError(msg);
110
+ }
111
+
112
+ // Device enumeration / selection failures.
113
+ for (const [prefix, code] of DEVICE_CODES) {
114
+ if (msg.startsWith(prefix)) return new DeviceError(msg, code);
115
+ }
116
+
117
+ // ONNX Runtime: a bad library path is an OrtPathError (subclass of OrtError);
118
+ // OrtLoadFailed and OrtPathInvalid share this message prefix and the same
119
+ // user-facing meaning. Other ORT failures are OrtError.
120
+ if (msg.startsWith('decibri: failed to load ONNX Runtime from')) {
121
+ return new OrtPathError(msg, 'ORT_LOAD_FAILED');
122
+ }
123
+ for (const [prefix, code] of ORT_CODES) {
124
+ if (msg.startsWith(prefix)) return new OrtError(msg, code);
125
+ }
126
+ if (msg.startsWith('Failed to create') && msg.includes('tensor')) {
127
+ return new OrtError(msg, 'ORT_TENSOR_CREATE_FAILED');
128
+ }
129
+ if (msg.startsWith('Failed to extract') && msg.includes('tensor')) {
130
+ return new OrtError(msg, 'ORT_TENSOR_EXTRACT_FAILED');
131
+ }
33
132
 
34
- const Wrapper = isRange ? RangeError : TypeError;
35
- const wrapped = new Wrapper(msg);
36
- wrapped.code = err.code;
37
- return wrapped;
133
+ // Any other error from the native constructor is still a decibri error.
134
+ return new DecibriError(msg, 'DECIBRI_ERROR');
38
135
  }
39
136
 
40
- module.exports = { wrapNativeError };
137
+ module.exports = {
138
+ DecibriError,
139
+ DeviceError,
140
+ OrtError,
141
+ OrtPathError,
142
+ wrapNativeError,
143
+ };