decibri 4.0.0 → 4.2.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.
@@ -13,10 +13,45 @@ const PACKAGE_VERSION = require('../package.json').version;
13
13
  class Speaker extends Writable {
14
14
  /**
15
15
  * @param {import('./decibri').SpeakerOptions} [options]
16
+ * @param {{ prepared: object, native: object }} [_internal] Internal: a
17
+ * pre-resolved options bundle and an already-constructed native bridge,
18
+ * passed by the async `Speaker.open()` factory. Not part of the public API.
16
19
  */
17
- constructor(options = {}) {
20
+ constructor(options = {}, _internal = undefined) {
18
21
  super({ highWaterMark: options.highWaterMark || 16384 });
19
22
 
23
+ // Validate and resolve options once. The async factory passes its already
24
+ // resolved bundle through `_internal` to avoid recomputing it.
25
+ const prepared = _internal ? _internal.prepared : Speaker._prepareOptions(options);
26
+
27
+ // ── Store config ───────────────────────────────────────────────────────
28
+
29
+ this._dtype = prepared.dtype;
30
+ this._started = false;
31
+
32
+ // ── Create or adopt native bridge ───────────────────────────────────────
33
+
34
+ if (_internal) {
35
+ // Built off the event loop by Speaker.open(); already wrapped.
36
+ this._native = _internal.native;
37
+ } else {
38
+ try {
39
+ this._native = new DecibriOutputBridge(prepared.nativeOptions);
40
+ } catch (err) {
41
+ throw wrapNativeError(err);
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Validate the constructor options and resolve them into the native options
48
+ * object plus the wrapper-side state. Throws the same `RangeError` /
49
+ * `TypeError` as the constructor on invalid input. Shared by the synchronous
50
+ * constructor and the async `open()` factory.
51
+ * @internal
52
+ * @param {import('./decibri').SpeakerOptions} options
53
+ */
54
+ static _prepareOptions(options) {
20
55
  // ── Validate options ───────────────────────────────────────────────────
21
56
 
22
57
  const sampleRate = options.sampleRate ?? 16000;
@@ -61,23 +96,41 @@ class Speaker extends Writable {
61
96
  resolvedDevice = options.device;
62
97
  }
63
98
 
64
- // ── Store config ───────────────────────────────────────────────────────
65
-
66
- this._dtype = dtype;
67
- this._started = false;
68
-
69
- // ── Create native bridge ───────────────────────────────────────────────
70
-
71
- try {
72
- this._native = new DecibriOutputBridge({
99
+ return {
100
+ dtype,
101
+ nativeOptions: {
73
102
  sampleRate,
74
103
  channels,
75
104
  format: dtype,
76
105
  device: resolvedDevice,
77
- });
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Construct a Speaker without blocking the event loop.
112
+ *
113
+ * Symmetric with `Microphone.open()` and the Python `AsyncSpeaker.open()`.
114
+ * The speaker loads no model, so the only open work is device resolution and
115
+ * the practical blocking risk is small; this factory exists chiefly so async
116
+ * callers can use one consistent construction pattern across both classes.
117
+ * The synchronous constructor remains available and unchanged.
118
+ *
119
+ * A failed open (unknown device) rejects the returned Promise with the
120
+ * matching error class rather than throwing synchronously.
121
+ *
122
+ * @param {import('./decibri').SpeakerOptions} [options]
123
+ * @returns {Promise<Speaker>}
124
+ */
125
+ static async open(options = {}) {
126
+ const prepared = Speaker._prepareOptions(options);
127
+ let native;
128
+ try {
129
+ native = await DecibriOutputBridge.openAsync(prepared.nativeOptions);
78
130
  } catch (err) {
79
131
  throw wrapNativeError(err);
80
132
  }
133
+ return new Speaker(options, { prepared, native });
81
134
  }
82
135
 
83
136
  /** @internal */
@@ -104,6 +157,56 @@ class Speaker extends Writable {
104
157
  }
105
158
  }
106
159
 
160
+ /**
161
+ * Write PCM audio without blocking the event loop.
162
+ *
163
+ * The blocking part of a write is the backpressure wait when the native
164
+ * playback queue is full; the synchronous stream path (`write()` / `pipe()`)
165
+ * performs that wait on the event loop. This method performs it on the native
166
+ * thread pool and resolves when the samples are queued. The audio stream is
167
+ * created on the first call (a fast device open) and stays on its own thread;
168
+ * only the queue handoff runs off the event loop.
169
+ *
170
+ * Additive and non-blocking: the synchronous `write()` / `pipe()` stream
171
+ * interface is unchanged. This is a direct, opt-in alternative that bypasses
172
+ * the Writable buffer, so do not interleave it with `write()` / `pipe()` on
173
+ * the same instance; pick one path per instance. Await calls sequentially to
174
+ * preserve sample order. An empty buffer resolves immediately. A failed write
175
+ * (a closed or stopped stream) rejects with the matching error class.
176
+ *
177
+ * @param {Buffer} chunk PCM samples in the configured `dtype`.
178
+ * @returns {Promise<void>}
179
+ */
180
+ async writeAsync(chunk) {
181
+ try {
182
+ await this._native.writeAsync(chunk);
183
+ } catch (err) {
184
+ throw wrapNativeError(err);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Wait for all queued audio to finish playing without blocking the event
190
+ * loop.
191
+ *
192
+ * The synchronous drain (run by `end()` / `_final`) polls for completion on
193
+ * the event loop for the full playback tail; this method runs that wait on the
194
+ * native thread pool and resolves when the buffer has drained. If nothing has
195
+ * been written yet it resolves immediately.
196
+ *
197
+ * Additive and non-blocking: the synchronous drain via `end()` is unchanged.
198
+ * Pair this with `writeAsync()` for a fully non-blocking playback path.
199
+ *
200
+ * @returns {Promise<void>}
201
+ */
202
+ async drainAsync() {
203
+ try {
204
+ await this._native.drainAsync();
205
+ } catch (err) {
206
+ throw wrapNativeError(err);
207
+ }
208
+ }
209
+
107
210
  /**
108
211
  * Immediate stop. Discards remaining buffered audio.
109
212
  */
package/src/decibri.d.ts CHANGED
@@ -124,6 +124,27 @@ export interface MicrophoneOptions extends ReadableOptions {
124
124
  export declare class Microphone extends Readable {
125
125
  constructor(options?: MicrophoneOptions);
126
126
 
127
+ /**
128
+ * Construct a Microphone without blocking the event loop on the open work.
129
+ *
130
+ * The synchronous constructor loads the Silero VAD model inline when
131
+ * `vad: 'silero'` is set, blocking the event loop for roughly 100 to 500 ms
132
+ * on a cold cache. This factory runs that load (and device resolution) on the
133
+ * native thread pool and resolves to a ready instance. The synchronous
134
+ * constructor remains available and unchanged.
135
+ *
136
+ * Options are identical to the constructor. A failed open rejects the Promise
137
+ * with the matching error: `RangeError` / `TypeError` for invalid options, or
138
+ * a `DeviceError` / `OrtError` / `OrtPathError` for native failures.
139
+ *
140
+ * @example
141
+ * ```js
142
+ * const mic = await Microphone.open({ vad: 'silero' });
143
+ * mic.on('data', (chunk) => { ... });
144
+ * ```
145
+ */
146
+ static open(options?: MicrophoneOptions): Promise<Microphone>;
147
+
127
148
  /** Stop microphone capture and end the stream. Safe to call multiple times. */
128
149
  stop(): void;
129
150
 
@@ -235,6 +256,46 @@ export interface SpeakerOptions extends WritableOptions {
235
256
  export declare class Speaker extends Writable {
236
257
  constructor(options?: SpeakerOptions);
237
258
 
259
+ /**
260
+ * Construct a Speaker without blocking the event loop. Symmetric with
261
+ * `Microphone.open()`. The speaker loads no model, so the only open work is
262
+ * device resolution; this factory is provided so async callers can use one
263
+ * consistent construction pattern across both classes. The synchronous
264
+ * constructor remains available and unchanged.
265
+ *
266
+ * A failed open (unknown device) rejects the Promise with the matching error.
267
+ *
268
+ * @example
269
+ * ```js
270
+ * const speaker = await Speaker.open({ sampleRate: 24000 });
271
+ * speaker.write(pcmBuffer);
272
+ * ```
273
+ */
274
+ static open(options?: SpeakerOptions): Promise<Speaker>;
275
+
276
+ /**
277
+ * Write PCM audio without blocking the event loop. Performs the backpressure
278
+ * wait (when the native playback queue is full) on the native thread pool and
279
+ * resolves when the samples are queued.
280
+ *
281
+ * Additive: the synchronous `write()` / `pipe()` stream interface is
282
+ * unchanged. This is a direct, opt-in alternative that bypasses the Writable
283
+ * buffer; do not interleave it with `write()` / `pipe()` on the same instance.
284
+ * Await calls sequentially to preserve sample order. An empty buffer resolves
285
+ * immediately; a closed or stopped stream rejects with the matching error.
286
+ */
287
+ writeAsync(chunk: Buffer): Promise<void>;
288
+
289
+ /**
290
+ * Wait for all queued audio to finish playing without blocking the event
291
+ * loop. Runs the drain wait on the native thread pool and resolves when the
292
+ * buffer has drained; resolves immediately if nothing was written.
293
+ *
294
+ * Additive: the synchronous drain via `end()` is unchanged. Pair with
295
+ * `writeAsync()` for a fully non-blocking playback path.
296
+ */
297
+ drainAsync(): Promise<void>;
298
+
238
299
  /** Immediate stop. Discards remaining buffered audio. */
239
300
  stop(): void;
240
301
 
package/src/decibri.js CHANGED
@@ -96,10 +96,56 @@ function computeRMS(chunk, dtype) {
96
96
  class Microphone extends Readable {
97
97
  /**
98
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.
99
104
  */
100
- constructor(options = {}) {
105
+ constructor(options = {}, _internal = undefined) {
101
106
  super({ highWaterMark: options.highWaterMark, objectMode: false });
102
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) {
103
149
  // ── Validate options ───────────────────────────────────────────────────
104
150
 
105
151
  const sampleRate = options.sampleRate ?? 16000;
@@ -189,22 +235,13 @@ class Microphone extends Readable {
189
235
  ortLibraryPath = resolveBundledOrtPath();
190
236
  }
191
237
 
192
- // ── Store config ───────────────────────────────────────────────────────
193
-
194
- this._dtype = dtype;
195
- this._vad = vadEnabled;
196
- this._vadMode = vadMode;
197
- this._vadThreshold = options.vadThreshold ?? (vadMode === 'silero' ? 0.5 : 0.01);
198
- this._vadHoldoff = options.vadHoldoff ?? 300;
199
- this._vadScore = 0;
200
- this._isSpeaking = false;
201
- this._silenceTimer = null;
202
- this._started = false;
203
-
204
- // ── Create native bridge ───────────────────────────────────────────────
205
-
206
- try {
207
- 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: {
208
245
  sampleRate,
209
246
  channels,
210
247
  framesPerBuffer,
@@ -213,10 +250,39 @@ class Microphone extends Readable {
213
250
  vadMode,
214
251
  modelPath,
215
252
  ortLibraryPath,
216
- });
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);
217
282
  } catch (err) {
218
283
  throw wrapNativeError(err);
219
284
  }
285
+ return new Microphone(options, { prepared, native });
220
286
  }
221
287
 
222
288
  /** @internal */