decibri 4.0.0 → 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/CHANGELOG.md CHANGED
@@ -11,6 +11,13 @@ For other decibri packages, see:
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [4.1.0] - 2026-05-31
15
+
16
+ ### Added
17
+
18
+ - Async factories `Microphone.open(options)` and `Speaker.open(options)`, each returning a `Promise` that resolves to a constructed instance. They perform the blocking open work (the Silero VAD model load for the microphone; device resolution for both) on the native thread pool instead of the event loop, so latency-sensitive callers do not stall during construction. A failed open rejects with the matching error (`RangeError` / `TypeError` for invalid options, `DeviceError` / `OrtError` / `OrtPathError` for native failures). The synchronous `new Microphone(...)` and `new Speaker(...)` constructors are unchanged; the factories are an additive, non-blocking alternative that mirrors the Python `AsyncMicrophone.open()` / `AsyncSpeaker.open()` surface.
19
+ - Non-blocking `Speaker.writeAsync(chunk)` and `Speaker.drainAsync()` methods, each returning a `Promise`. They run the blocking parts of playback off the event loop: `writeAsync` performs the backpressure wait when the native playback queue is full, and `drainAsync` performs the wait for queued audio to finish playing. The audio stream stays on its own thread; only the thread-safe sample channel and drain state are used off the event loop. A failed write or drain rejects with the matching error class. The synchronous `write()` / `pipe()` / `end()` Writable interface is unchanged; the async methods are an additive, direct alternative (do not interleave the two paths on one instance).
20
+
14
21
  ## [4.0.0] - 2026-05-30
15
22
 
16
23
  ### Changed
package/MIGRATION.md CHANGED
@@ -5,6 +5,23 @@ vocabulary that matches the Rust and Python packages, and tidies several option
5
5
  and return shapes. This guide lists every breaking change with before and after
6
6
  code.
7
7
 
8
+ ## New in 4.1.0 (additive, nothing to migrate)
9
+
10
+ decibri 4.1.0 is a non-breaking, additive release. Code written for 4.0.0 keeps
11
+ working unchanged; there is nothing to migrate. The release adds an opt-in
12
+ non-blocking API for event-loop-sensitive code:
13
+
14
+ - `Microphone.open(options)` and `Speaker.open(options)`: async factories that
15
+ construct an instance without blocking the event loop and resolve to a ready
16
+ instance. The synchronous `new Microphone(...)` and `new Speaker(...)`
17
+ constructors are unchanged.
18
+ - `speaker.writeAsync(chunk)` and `speaker.drainAsync()`: write and drain
19
+ without blocking the event loop. The synchronous `write()` / `pipe()` /
20
+ `end()` interface is unchanged.
21
+
22
+ See the Non-blocking API section of the README for examples. The rest of this
23
+ guide covers the 4.0.0 changes from 3.x.
24
+
8
25
  ## Named exports
9
26
 
10
27
  The package no longer has a single default export. Destructure what you need.
package/README.md CHANGED
@@ -87,6 +87,7 @@ Standard `ReadableOptions` (e.g. `highWaterMark`) are also accepted.
87
87
  | Method | Description |
88
88
  | --- | --- |
89
89
  | `mic.stop()` | Stop capture and end stream. Safe to call multiple times |
90
+ | `Microphone.open(options?)` | Construct without blocking the event loop. Returns a `Promise<Microphone>`. See [Non-blocking API](#non-blocking-api) |
90
91
  | `Microphone.devices()` | List available input devices |
91
92
  | `Microphone.version()` | Version info: `{ decibri, audioBackend, binding }` |
92
93
 
@@ -128,14 +129,47 @@ Standard `WritableOptions` (e.g. `highWaterMark`) are also accepted.
128
129
  | Method / Property | Description |
129
130
  | --- | --- |
130
131
  | `speaker.write(chunk)` | Write PCM data for playback |
132
+ | `speaker.writeAsync(chunk)` | Write without blocking the event loop. Returns a `Promise`. See [Non-blocking API](#non-blocking-api) |
131
133
  | `speaker.end()` | Signal end. Drains remaining audio, then emits `'finish'` |
134
+ | `speaker.drainAsync()` | Wait for queued audio to finish without blocking the event loop. Returns a `Promise` |
132
135
  | `speaker.stop()` | Immediate stop. Discards remaining audio |
133
136
  | `speaker.isPlaying` | `true` while audio is being output |
137
+ | `Speaker.open(options?)` | Construct without blocking the event loop. Returns a `Promise<Speaker>` |
134
138
  | `Speaker.devices()` | List available output devices |
135
139
  | `Speaker.version()` | Same as `Microphone.version()` |
136
140
 
137
141
  The module-level `outputDevices()` free function is equivalent to `Speaker.devices()`.
138
142
 
143
+ ## Non-blocking API
144
+
145
+ The synchronous constructors and `write` / `drain` do their work on the event loop, which is fine for most apps. For event-loop-sensitive code (servers, real-time voice pipelines), 4.1.0 adds async variants that perform the blocking work without stalling the event loop. They are additive: the synchronous API is unchanged, and you opt in only where you need it.
146
+
147
+ ### Non-blocking construction
148
+
149
+ `Microphone.open(options?)` and `Speaker.open(options?)` are async factories that return a Promise of a ready instance. They take the same options as the constructors. For a microphone with Silero VAD, the model load that the constructor does inline runs without blocking the event loop.
150
+
151
+ ```javascript
152
+ const { Microphone, Speaker } = require('decibri');
153
+
154
+ const mic = await Microphone.open({ sampleRate: 16000, vad: 'silero' });
155
+ const speaker = await Speaker.open({ sampleRate: 16000, channels: 1 });
156
+ ```
157
+
158
+ The synchronous `new Microphone(...)` and `new Speaker(...)` still work unchanged. A failed open rejects the Promise with the same typed error a failed constructor throws.
159
+
160
+ ### Non-blocking playback
161
+
162
+ `speaker.writeAsync(chunk)` resolves once the audio is queued, performing the backpressure wait (when the playback buffer is full) without blocking the event loop. `speaker.drainAsync()` resolves when all queued audio has finished playing, again without blocking.
163
+
164
+ ```javascript
165
+ const speaker = await Speaker.open({ sampleRate: 16000, channels: 1 });
166
+
167
+ await speaker.writeAsync(pcmBuffer);
168
+ await speaker.drainAsync(); // resolves when playback finishes
169
+ ```
170
+
171
+ These are a direct alternative to the synchronous `write()` / `pipe()` / `end()` stream interface, which is unchanged. Use one path per instance (the stream methods or the async methods, not both at once), and await calls in sequence to keep samples in order.
172
+
139
173
  ## Errors
140
174
 
141
175
  Construction errors come as typed classes you can catch:
package/index.d.ts CHANGED
@@ -3,6 +3,14 @@
3
3
  /** Native bridge class exposed to Node.js via napi-rs. */
4
4
  export declare class DecibriBridge {
5
5
  constructor(options?: DecibriOptions | undefined | null)
6
+ /**
7
+ * Construct a microphone bridge without blocking the JS event loop. The
8
+ * device resolution and Silero model load run on the libuv thread pool;
9
+ * the returned Promise resolves to a fully constructed bridge, or rejects
10
+ * with the matching error. The synchronous `new` remains available and
11
+ * unchanged.
12
+ */
13
+ static openAsync(options?: DecibriOptions | undefined | null): Promise<unknown>
6
14
  /** Start capturing audio. The callback receives `(err, chunk)` for each buffer. */
7
15
  start(callback: (err: Error | null, chunk: Buffer) => void): void
8
16
  /** Stop capturing audio. */
@@ -23,13 +31,36 @@ export declare class DecibriBridge {
23
31
  /** Native bridge class for audio output, exposed to Node.js via napi-rs. */
24
32
  export declare class DecibriOutputBridge {
25
33
  constructor(options?: DecibriOutputOptions | undefined | null)
34
+ /**
35
+ * Construct a speaker bridge without blocking the JS event loop. The device
36
+ * resolution runs on the libuv thread pool; the returned Promise resolves
37
+ * to a constructed bridge, or rejects with the matching error. The
38
+ * synchronous `new` remains available and unchanged.
39
+ */
40
+ static openAsync(options?: DecibriOutputOptions | undefined | null): Promise<unknown>
26
41
  /**
27
42
  * Write PCM data for playback. Starts the output stream on first call.
28
43
  * Empty buffers are a no-op.
29
44
  */
30
45
  write(buffer: Buffer): void
46
+ /**
47
+ * Non-blocking write: convert the samples and start the stream on the JS
48
+ * thread (a fast device open, same as the synchronous first write), then
49
+ * perform the blocking channel `send` (which stalls under backpressure when
50
+ * the queue is full) on the libuv thread pool. The returned Promise
51
+ * resolves when the samples are queued, or rejects with the matching error.
52
+ * Empty buffers resolve immediately. The synchronous `write` is unchanged.
53
+ */
54
+ writeAsync(buffer: Buffer): Promise<unknown>
31
55
  /** Graceful drain: blocks until all queued samples have been played. */
32
56
  drain(): void
57
+ /**
58
+ * Non-blocking drain: the poll loop that waits for the cpal callback to play
59
+ * everything queued runs on the libuv thread pool instead of the event loop.
60
+ * The returned Promise resolves when the buffer has drained. With no stream
61
+ * yet created it resolves immediately. The synchronous `drain` is unchanged.
62
+ */
63
+ drainAsync(): Promise<unknown>
33
64
  /** Immediate stop. Discards remaining samples. */
34
65
  stop(): void
35
66
  /** Whether audio is currently being output. */
package/index.js CHANGED
@@ -77,8 +77,8 @@ function requireNative() {
77
77
  try {
78
78
  const binding = require('@decibri/decibri-android-arm64')
79
79
  const bindingPackageVersion = require('@decibri/decibri-android-arm64/package.json').version
80
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
80
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
82
82
  }
83
83
  return binding
84
84
  } catch (e) {
@@ -93,8 +93,8 @@ function requireNative() {
93
93
  try {
94
94
  const binding = require('@decibri/decibri-android-arm-eabi')
95
95
  const bindingPackageVersion = require('@decibri/decibri-android-arm-eabi/package.json').version
96
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
96
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
98
98
  }
99
99
  return binding
100
100
  } catch (e) {
@@ -114,8 +114,8 @@ function requireNative() {
114
114
  try {
115
115
  const binding = require('@decibri/decibri-win32-x64-gnu')
116
116
  const bindingPackageVersion = require('@decibri/decibri-win32-x64-gnu/package.json').version
117
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
117
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
119
119
  }
120
120
  return binding
121
121
  } catch (e) {
@@ -130,8 +130,8 @@ function requireNative() {
130
130
  try {
131
131
  const binding = require('@decibri/decibri-win32-x64-msvc')
132
132
  const bindingPackageVersion = require('@decibri/decibri-win32-x64-msvc/package.json').version
133
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
133
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
135
135
  }
136
136
  return binding
137
137
  } catch (e) {
@@ -147,8 +147,8 @@ function requireNative() {
147
147
  try {
148
148
  const binding = require('@decibri/decibri-win32-ia32-msvc')
149
149
  const bindingPackageVersion = require('@decibri/decibri-win32-ia32-msvc/package.json').version
150
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
150
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
152
152
  }
153
153
  return binding
154
154
  } catch (e) {
@@ -163,8 +163,8 @@ function requireNative() {
163
163
  try {
164
164
  const binding = require('@decibri/decibri-win32-arm64-msvc')
165
165
  const bindingPackageVersion = require('@decibri/decibri-win32-arm64-msvc/package.json').version
166
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
166
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
168
168
  }
169
169
  return binding
170
170
  } catch (e) {
@@ -182,8 +182,8 @@ function requireNative() {
182
182
  try {
183
183
  const binding = require('@decibri/decibri-darwin-universal')
184
184
  const bindingPackageVersion = require('@decibri/decibri-darwin-universal/package.json').version
185
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
185
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
187
187
  }
188
188
  return binding
189
189
  } catch (e) {
@@ -198,8 +198,8 @@ function requireNative() {
198
198
  try {
199
199
  const binding = require('@decibri/decibri-darwin-x64')
200
200
  const bindingPackageVersion = require('@decibri/decibri-darwin-x64/package.json').version
201
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
201
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
203
203
  }
204
204
  return binding
205
205
  } catch (e) {
@@ -214,8 +214,8 @@ function requireNative() {
214
214
  try {
215
215
  const binding = require('@decibri/decibri-darwin-arm64')
216
216
  const bindingPackageVersion = require('@decibri/decibri-darwin-arm64/package.json').version
217
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
217
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
219
219
  }
220
220
  return binding
221
221
  } catch (e) {
@@ -234,8 +234,8 @@ function requireNative() {
234
234
  try {
235
235
  const binding = require('@decibri/decibri-freebsd-x64')
236
236
  const bindingPackageVersion = require('@decibri/decibri-freebsd-x64/package.json').version
237
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
237
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
239
239
  }
240
240
  return binding
241
241
  } catch (e) {
@@ -250,8 +250,8 @@ function requireNative() {
250
250
  try {
251
251
  const binding = require('@decibri/decibri-freebsd-arm64')
252
252
  const bindingPackageVersion = require('@decibri/decibri-freebsd-arm64/package.json').version
253
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
253
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
255
255
  }
256
256
  return binding
257
257
  } catch (e) {
@@ -271,8 +271,8 @@ function requireNative() {
271
271
  try {
272
272
  const binding = require('@decibri/decibri-linux-x64-musl')
273
273
  const bindingPackageVersion = require('@decibri/decibri-linux-x64-musl/package.json').version
274
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
274
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
276
276
  }
277
277
  return binding
278
278
  } catch (e) {
@@ -287,8 +287,8 @@ function requireNative() {
287
287
  try {
288
288
  const binding = require('@decibri/decibri-linux-x64-gnu')
289
289
  const bindingPackageVersion = require('@decibri/decibri-linux-x64-gnu/package.json').version
290
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
290
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
292
292
  }
293
293
  return binding
294
294
  } catch (e) {
@@ -305,8 +305,8 @@ function requireNative() {
305
305
  try {
306
306
  const binding = require('@decibri/decibri-linux-arm64-musl')
307
307
  const bindingPackageVersion = require('@decibri/decibri-linux-arm64-musl/package.json').version
308
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
308
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
310
310
  }
311
311
  return binding
312
312
  } catch (e) {
@@ -321,8 +321,8 @@ function requireNative() {
321
321
  try {
322
322
  const binding = require('@decibri/decibri-linux-arm64-gnu')
323
323
  const bindingPackageVersion = require('@decibri/decibri-linux-arm64-gnu/package.json').version
324
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
324
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
326
326
  }
327
327
  return binding
328
328
  } catch (e) {
@@ -339,8 +339,8 @@ function requireNative() {
339
339
  try {
340
340
  const binding = require('@decibri/decibri-linux-arm-musleabihf')
341
341
  const bindingPackageVersion = require('@decibri/decibri-linux-arm-musleabihf/package.json').version
342
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
342
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
344
344
  }
345
345
  return binding
346
346
  } catch (e) {
@@ -355,8 +355,8 @@ function requireNative() {
355
355
  try {
356
356
  const binding = require('@decibri/decibri-linux-arm-gnueabihf')
357
357
  const bindingPackageVersion = require('@decibri/decibri-linux-arm-gnueabihf/package.json').version
358
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
358
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
360
360
  }
361
361
  return binding
362
362
  } catch (e) {
@@ -373,8 +373,8 @@ function requireNative() {
373
373
  try {
374
374
  const binding = require('@decibri/decibri-linux-loong64-musl')
375
375
  const bindingPackageVersion = require('@decibri/decibri-linux-loong64-musl/package.json').version
376
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
376
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
378
378
  }
379
379
  return binding
380
380
  } catch (e) {
@@ -389,8 +389,8 @@ function requireNative() {
389
389
  try {
390
390
  const binding = require('@decibri/decibri-linux-loong64-gnu')
391
391
  const bindingPackageVersion = require('@decibri/decibri-linux-loong64-gnu/package.json').version
392
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
392
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
394
394
  }
395
395
  return binding
396
396
  } catch (e) {
@@ -407,8 +407,8 @@ function requireNative() {
407
407
  try {
408
408
  const binding = require('@decibri/decibri-linux-riscv64-musl')
409
409
  const bindingPackageVersion = require('@decibri/decibri-linux-riscv64-musl/package.json').version
410
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
410
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
412
412
  }
413
413
  return binding
414
414
  } catch (e) {
@@ -423,8 +423,8 @@ function requireNative() {
423
423
  try {
424
424
  const binding = require('@decibri/decibri-linux-riscv64-gnu')
425
425
  const bindingPackageVersion = require('@decibri/decibri-linux-riscv64-gnu/package.json').version
426
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
426
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
428
428
  }
429
429
  return binding
430
430
  } catch (e) {
@@ -440,8 +440,8 @@ function requireNative() {
440
440
  try {
441
441
  const binding = require('@decibri/decibri-linux-ppc64-gnu')
442
442
  const bindingPackageVersion = require('@decibri/decibri-linux-ppc64-gnu/package.json').version
443
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
443
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
445
445
  }
446
446
  return binding
447
447
  } catch (e) {
@@ -456,8 +456,8 @@ function requireNative() {
456
456
  try {
457
457
  const binding = require('@decibri/decibri-linux-s390x-gnu')
458
458
  const bindingPackageVersion = require('@decibri/decibri-linux-s390x-gnu/package.json').version
459
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
459
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
461
461
  }
462
462
  return binding
463
463
  } catch (e) {
@@ -476,8 +476,8 @@ function requireNative() {
476
476
  try {
477
477
  const binding = require('@decibri/decibri-openharmony-arm64')
478
478
  const bindingPackageVersion = require('@decibri/decibri-openharmony-arm64/package.json').version
479
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
479
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
481
481
  }
482
482
  return binding
483
483
  } catch (e) {
@@ -492,8 +492,8 @@ function requireNative() {
492
492
  try {
493
493
  const binding = require('@decibri/decibri-openharmony-x64')
494
494
  const bindingPackageVersion = require('@decibri/decibri-openharmony-x64/package.json').version
495
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
495
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
497
497
  }
498
498
  return binding
499
499
  } catch (e) {
@@ -508,8 +508,8 @@ function requireNative() {
508
508
  try {
509
509
  const binding = require('@decibri/decibri-openharmony-arm')
510
510
  const bindingPackageVersion = require('@decibri/decibri-openharmony-arm/package.json').version
511
- if (bindingPackageVersion !== '4.0.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
- throw new Error(`Native binding package version mismatch, expected 4.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
511
+ if (bindingPackageVersion !== '4.1.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
+ throw new Error(`Native binding package version mismatch, expected 4.1.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
513
513
  }
514
514
  return binding
515
515
  } catch (e) {
@@ -525,23 +525,33 @@ function requireNative() {
525
525
 
526
526
  nativeBinding = requireNative()
527
527
 
528
- if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
528
+ // NAPI_RS_FORCE_WASI is a tri-state flag:
529
+ // unset / any other value → native binding preferred, WASI is only a fallback
530
+ // 'true' → force WASI fallback even if native loaded
531
+ // 'error' → force WASI and throw if no WASI binding is found
532
+ // Treating any non-empty string as truthy (the historical behavior) meant
533
+ // NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered
534
+ // the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file.
535
+ const forceWasi =
536
+ process.env.NAPI_RS_FORCE_WASI === 'true' || process.env.NAPI_RS_FORCE_WASI === 'error'
537
+
538
+ if (!nativeBinding || forceWasi) {
529
539
  let wasiBinding = null
530
540
  let wasiBindingError = null
531
541
  try {
532
542
  wasiBinding = require('./decibri.wasi.cjs')
533
543
  nativeBinding = wasiBinding
534
544
  } catch (err) {
535
- if (process.env.NAPI_RS_FORCE_WASI) {
545
+ if (forceWasi) {
536
546
  wasiBindingError = err
537
547
  }
538
548
  }
539
- if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
549
+ if (!nativeBinding || forceWasi) {
540
550
  try {
541
551
  wasiBinding = require('@decibri/decibri-wasm32-wasi')
542
552
  nativeBinding = wasiBinding
543
553
  } catch (err) {
544
- if (process.env.NAPI_RS_FORCE_WASI) {
554
+ if (forceWasi) {
545
555
  if (!wasiBindingError) {
546
556
  wasiBindingError = err
547
557
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decibri",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Cross-platform audio capture, playback, and processing for Node.js and browsers",
5
5
  "main": "src/decibri.js",
6
6
  "types": "src/decibri.d.ts",
@@ -72,13 +72,13 @@
72
72
  "MIGRATION.md"
73
73
  ],
74
74
  "optionalDependencies": {
75
- "@decibri/decibri-win32-x64-msvc": "4.0.0",
76
- "@decibri/decibri-darwin-arm64": "4.0.0",
77
- "@decibri/decibri-linux-x64-gnu": "4.0.0",
78
- "@decibri/decibri-linux-arm64-gnu": "4.0.0"
75
+ "@decibri/decibri-win32-x64-msvc": "4.1.0",
76
+ "@decibri/decibri-darwin-arm64": "4.1.0",
77
+ "@decibri/decibri-linux-x64-gnu": "4.1.0",
78
+ "@decibri/decibri-linux-arm64-gnu": "4.1.0"
79
79
  },
80
80
  "devDependencies": {
81
- "@napi-rs/cli": "^3.0.0"
81
+ "@napi-rs/cli": "^3.7.0"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "napi build --platform --release -p decibri-node --manifest-path ../../bindings/node/Cargo.toml --js-package-name @decibri/decibri -o .",
@@ -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 */