decibri 0.0.1 → 1.0.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.
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+ // Streams raw PCM audio to a WebSocket server.
3
+ // Requires: npm install ws
4
+ // Usage: node examples/websocket-stream.js [ws://localhost:8080]
5
+
6
+ const { WebSocket } = require('ws');
7
+ // In your own project: const Decibri = require('decibri');
8
+ const Decibri = require('../index');
9
+
10
+ const url = process.argv[2] || 'ws://localhost:8080';
11
+ const ws = new WebSocket(url);
12
+ const mic = new Decibri({ sampleRate: 16000, channels: 1 });
13
+
14
+ ws.on('open', () => {
15
+ console.log(`Connected to ${url} — streaming audio. Ctrl+C to stop.`);
16
+
17
+ mic.on('data', (chunk) => {
18
+ if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
19
+ });
20
+
21
+ mic.on('error', (err) => {
22
+ console.error('Mic error:', err.message);
23
+ ws.close();
24
+ });
25
+
26
+ mic.on('backpressure', () => {
27
+ console.warn('Backpressure — consumer too slow');
28
+ });
29
+ });
30
+
31
+ ws.on('close', () => {
32
+ mic.stop();
33
+ console.log('WebSocket closed.');
34
+ });
35
+
36
+ ws.on('error', (err) => {
37
+ console.error('WebSocket error:', err.message);
38
+ mic.stop();
39
+ });
40
+
41
+ process.on('SIGINT', () => {
42
+ mic.stop();
43
+ ws.close();
44
+ });
package/index.js ADDED
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ const { Readable } = require('stream');
4
+
5
+ // Load the pre-built binary (falls back to compiled build/ output)
6
+ const binding = require('node-gyp-build')(__dirname);
7
+
8
+ const { Decibri: NativeDecibri } = binding;
9
+
10
+ // ─── RMS helper ──────────────────────────────────────────────────────────────
11
+
12
+ function computeRMS(chunk, format) {
13
+ let sum = 0, n;
14
+ if (format === 'float32') {
15
+ const samples = new Float32Array(chunk.buffer, chunk.byteOffset, chunk.length / 4);
16
+ n = samples.length;
17
+ for (let i = 0; i < n; i++) sum += samples[i] * samples[i];
18
+ } else {
19
+ const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
20
+ n = samples.length;
21
+ for (let i = 0; i < n; i++) {
22
+ const s = samples[i] / 32768;
23
+ sum += s * s;
24
+ }
25
+ }
26
+ return n > 0 ? Math.sqrt(sum / n) : 0;
27
+ }
28
+
29
+ // ─── Decibri (Readable) ─────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * A Node.js Readable stream that emits raw PCM audio from the default
33
+ * microphone as Buffer chunks.
34
+ *
35
+ * Audio format:
36
+ * - Encoding: 16-bit signed integer (Int16), little-endian
37
+ * - Sample rate: 16000 Hz (default — ideal for speech/wake-word)
38
+ * - Channels: 1 (mono) (default)
39
+ *
40
+ * @fires Decibri#backpressure - Emitted when the stream's internal buffer is
41
+ * full and the consumer is reading too slowly. The microphone cannot be
42
+ * paused, so chunks will continue to arrive; callers should drain or drop
43
+ * data as appropriate.
44
+ *
45
+ * @example
46
+ * const mic = new Decibri();
47
+ * mic.on('data', (chunk) => {
48
+ * // chunk is a Buffer of Int16 PCM samples
49
+ * });
50
+ * mic.on('error', (err) => console.error(err));
51
+ * mic.on('backpressure', () => console.warn('Consumer too slow'));
52
+ *
53
+ * // Stop after 5 seconds
54
+ * setTimeout(() => mic.stop(), 5000);
55
+ */
56
+ class Decibri extends Readable {
57
+ /**
58
+ * @param {object} [options]
59
+ * @param {number} [options.sampleRate=16000] Samples per second (1000–384000)
60
+ * @param {number} [options.channels=1] Number of input channels (1–32)
61
+ * @param {number} [options.framesPerBuffer=1600] Frames per audio callback (64–65536)
62
+ * @param {number|string} [options.device] Device index from Decibri.devices() or case-insensitive name substring
63
+ * @param {'int16'|'float32'} [options.format='int16'] Sample encoding format
64
+ * @param {boolean} [options.vad=false] Enable voice activity detection
65
+ * @param {number} [options.vadThreshold=0.01] RMS energy threshold for speech (0–1)
66
+ * @param {number} [options.vadHoldoff=300] ms of silence before 'silence' event fires
67
+ */
68
+ constructor(options = {}) {
69
+ const {
70
+ sampleRate = 16000,
71
+ channels = 1,
72
+ framesPerBuffer = 1600,
73
+ device,
74
+ format,
75
+ vad = false,
76
+ vadThreshold = 0.01,
77
+ vadHoldoff = 300,
78
+ ...streamOptions
79
+ } = options;
80
+
81
+ // Resolve device name → index
82
+ let resolvedDevice = device;
83
+ if (typeof device === 'string') {
84
+ const lower = device.toLowerCase();
85
+ const matches = NativeDecibri.devices().filter(d =>
86
+ d.name.toLowerCase().includes(lower)
87
+ );
88
+ if (matches.length === 0) {
89
+ throw new TypeError(`No audio input device found matching "${device}"`);
90
+ }
91
+ if (matches.length > 1) {
92
+ const names = matches.map(d => ` [${d.index}] ${d.name}`).join('\n');
93
+ throw new TypeError(
94
+ `Multiple devices match "${device}":\n${names}\nUse a more specific name or pass the device index directly.`
95
+ );
96
+ }
97
+ resolvedDevice = matches[0].index;
98
+ }
99
+
100
+ super({ ...streamOptions, objectMode: false });
101
+
102
+ const nativeOpts = { sampleRate, channels, framesPerBuffer };
103
+ if (resolvedDevice !== undefined) nativeOpts.device = resolvedDevice;
104
+ if (format !== undefined) nativeOpts.format = format;
105
+
106
+ this._native = new NativeDecibri(nativeOpts);
107
+ this._started = false;
108
+ this._vad = vad;
109
+ this._vadThreshold = vadThreshold;
110
+ this._vadHoldoff = vadHoldoff;
111
+ this._format = format || 'int16';
112
+ this._isSpeaking = false;
113
+ this._silenceTimer = null;
114
+ }
115
+
116
+ // Called by the stream machinery when it wants data
117
+ _read() {
118
+ if (this._started) return;
119
+ this._started = true;
120
+
121
+ this._native.start((err, chunk) => {
122
+ if (err) {
123
+ this._started = false;
124
+ this.destroy(err);
125
+ return;
126
+ }
127
+ // push returns false when the consumer is slow — we can't pause a mic,
128
+ // but we surface the backpressure warning so callers can react.
129
+ if (!this.push(chunk)) {
130
+ this.emit('backpressure');
131
+ }
132
+
133
+ if (this._vad) {
134
+ const rms = computeRMS(chunk, this._format);
135
+ if (rms >= this._vadThreshold) {
136
+ clearTimeout(this._silenceTimer);
137
+ this._silenceTimer = null;
138
+ if (!this._isSpeaking) {
139
+ this._isSpeaking = true;
140
+ this.emit('speech');
141
+ }
142
+ } else if (this._isSpeaking && !this._silenceTimer) {
143
+ this._silenceTimer = setTimeout(() => {
144
+ this._isSpeaking = false;
145
+ this._silenceTimer = null;
146
+ this.emit('silence');
147
+ }, this._vadHoldoff);
148
+ }
149
+ }
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Stop microphone capture and end the stream cleanly.
155
+ */
156
+ stop() {
157
+ if (!this._started) return;
158
+ this._started = false;
159
+ this._native.stop();
160
+ clearTimeout(this._silenceTimer);
161
+ this._silenceTimer = null;
162
+ this.push(null); // signals stream end
163
+ }
164
+
165
+ /**
166
+ * Whether the microphone is currently open.
167
+ * @returns {boolean}
168
+ */
169
+ get isOpen() {
170
+ return this._native.isOpen();
171
+ }
172
+
173
+ /**
174
+ * List all available input devices on the system.
175
+ * @returns {Array<{index: number, name: string, maxInputChannels: number, defaultSampleRate: number, isDefault: boolean}>}
176
+ */
177
+ static devices() {
178
+ return NativeDecibri.devices();
179
+ }
180
+
181
+ /**
182
+ * Version information for decibri and the bundled PortAudio.
183
+ * @returns {{ decibri: string, portaudio: string }}
184
+ */
185
+ static version() {
186
+ return NativeDecibri.version();
187
+ }
188
+ }
189
+
190
+ module.exports = Decibri;
package/package.json CHANGED
@@ -1,11 +1,60 @@
1
1
  {
2
2
  "name": "decibri",
3
- "version": "0.0.1",
4
- "description": "Cross-platform audio capture for Node.js. Placeholder - full release coming soon.",
5
- "keywords": ["audio", "microphone", "capture", "portaudio", "stream", "pcm"],
3
+ "version": "1.0.0",
4
+ "description": "Cross-platform microphone audio capture for Node.js with pre-built binaries. No build tools required.",
5
+ "main": "index.js",
6
+ "types": "types/index.d.ts",
6
7
  "license": "Apache-2.0",
7
8
  "repository": {
8
9
  "type": "git",
9
- "url": "https://github.com/analyticsinmotion/micstream.git"
10
- }
11
- }
10
+ "url": "git+https://github.com/analyticsinmotion/decibri.git"
11
+ },
12
+ "homepage": "https://decibri.dev",
13
+ "keywords": [
14
+ "microphone",
15
+ "audio",
16
+ "capture",
17
+ "stream",
18
+ "pcm",
19
+ "portaudio",
20
+ "native",
21
+ "prebuilt",
22
+ "voice-ai",
23
+ "speech-to-text",
24
+ "wake-word",
25
+ "real-time-audio"
26
+ ],
27
+ "files": [
28
+ "index.js",
29
+ "types/",
30
+ "examples/",
31
+ "binding.gyp",
32
+ "src/",
33
+ "prebuilds/",
34
+ "deps/"
35
+ ],
36
+ "scripts": {
37
+ "install": "node-gyp-build || node-gyp rebuild",
38
+ "build": "node-gyp rebuild",
39
+ "test": "node test/basic.js"
40
+ },
41
+ "dependencies": {
42
+ "node-gyp-build": "^4.6.0"
43
+ },
44
+ "devDependencies": {
45
+ "node-addon-api": "^8.6.0",
46
+ "node-gyp": "^12.0.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=18.0.0"
50
+ },
51
+ "os": [
52
+ "win32",
53
+ "darwin",
54
+ "linux"
55
+ ],
56
+ "cpu": [
57
+ "x64",
58
+ "arm64"
59
+ ]
60
+ }
package/src/decibri.cc ADDED
@@ -0,0 +1,390 @@
1
+ #include <napi.h>
2
+ #include <portaudio.h>
3
+ #include <vector>
4
+ #include <atomic>
5
+ #include <cstring>
6
+
7
+ #ifdef PA_USE_WASAPI
8
+ #include <pa_win_wasapi.h>
9
+ #endif
10
+
11
+ #ifdef PA_USE_COREAUDIO
12
+ // Declared in src/mac_permission.mm (compiled on macOS only)
13
+ extern "C" const char* CheckMicrophonePermission();
14
+ #endif
15
+
16
+ // ─── Defaults ────────────────────────────────────────────────────────────────
17
+ // Optimised for speech/wake-word: 16kHz mono 16-bit
18
+ #define DEFAULT_SAMPLE_RATE 16000
19
+ #define DEFAULT_CHANNELS 1
20
+ // 100ms of audio per chunk at 16kHz — low enough latency, not too chatty
21
+ #define DEFAULT_FRAMES_PER_BUFFER 1600
22
+ // -1 means "use the system default input device"
23
+ #define DEFAULT_DEVICE -1
24
+
25
+ // ─── Decibri class ──────────────────────────────────────────────────────────
26
+
27
+ class Decibri : public Napi::ObjectWrap<Decibri> {
28
+ public:
29
+
30
+ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
31
+ Napi::Function func = DefineClass(env, "Decibri", {
32
+ InstanceMethod("start", &Decibri::Start),
33
+ InstanceMethod("stop", &Decibri::Stop),
34
+ InstanceMethod("isOpen", &Decibri::IsOpen),
35
+ StaticMethod("devices", &Decibri::GetDevices),
36
+ StaticMethod("version", &Decibri::GetVersion),
37
+ });
38
+
39
+ Napi::FunctionReference* constructor = new Napi::FunctionReference();
40
+ *constructor = Napi::Persistent(func);
41
+ env.SetInstanceData(constructor);
42
+
43
+ exports.Set("Decibri", func);
44
+ return exports;
45
+ }
46
+
47
+ // ── Constructor ──────────────────────────────────────────────────────────
48
+
49
+ Decibri(const Napi::CallbackInfo& info)
50
+ : Napi::ObjectWrap<Decibri>(info),
51
+ stream_(nullptr),
52
+ tsfn_(),
53
+ running_(false),
54
+ sampleRate_(DEFAULT_SAMPLE_RATE),
55
+ channels_(DEFAULT_CHANNELS),
56
+ framesPerBuffer_(DEFAULT_FRAMES_PER_BUFFER),
57
+ device_(DEFAULT_DEVICE),
58
+ sampleFormat_(paInt16),
59
+ bytesPerSample_(2) {
60
+
61
+ Napi::Env env = info.Env();
62
+
63
+ // Parse optional config object
64
+ if (info.Length() > 0 && info[0].IsObject()) {
65
+ Napi::Object opts = info[0].As<Napi::Object>();
66
+
67
+ if (opts.Has("sampleRate") && opts.Get("sampleRate").IsNumber()) {
68
+ sampleRate_ = opts.Get("sampleRate").As<Napi::Number>().Int32Value();
69
+ if (sampleRate_ < 1000 || sampleRate_ > 384000) {
70
+ Napi::RangeError::New(env, "sampleRate must be between 1000 and 384000").ThrowAsJavaScriptException();
71
+ return;
72
+ }
73
+ }
74
+ if (opts.Has("channels") && opts.Get("channels").IsNumber()) {
75
+ channels_ = opts.Get("channels").As<Napi::Number>().Int32Value();
76
+ if (channels_ < 1 || channels_ > 32) {
77
+ Napi::RangeError::New(env, "channels must be between 1 and 32").ThrowAsJavaScriptException();
78
+ return;
79
+ }
80
+ }
81
+ if (opts.Has("framesPerBuffer") && opts.Get("framesPerBuffer").IsNumber()) {
82
+ framesPerBuffer_ = opts.Get("framesPerBuffer").As<Napi::Number>().Int32Value();
83
+ if (framesPerBuffer_ < 64 || framesPerBuffer_ > 65536) {
84
+ Napi::RangeError::New(env, "framesPerBuffer must be between 64 and 65536").ThrowAsJavaScriptException();
85
+ return;
86
+ }
87
+ }
88
+ if (opts.Has("device") && opts.Get("device").IsNumber()) {
89
+ device_ = opts.Get("device").As<Napi::Number>().Int32Value();
90
+ }
91
+ if (opts.Has("format") && opts.Get("format").IsString()) {
92
+ std::string fmt = opts.Get("format").As<Napi::String>().Utf8Value();
93
+ if (fmt == "float32") {
94
+ sampleFormat_ = paFloat32;
95
+ bytesPerSample_ = 4;
96
+ } else if (fmt != "int16") {
97
+ Napi::TypeError::New(env, "format must be 'int16' or 'float32'")
98
+ .ThrowAsJavaScriptException();
99
+ return;
100
+ }
101
+ }
102
+ }
103
+
104
+ PaError err = Pa_Initialize();
105
+ if (err != paNoError) {
106
+ Napi::Error::New(env, std::string("PortAudio init failed: ") + Pa_GetErrorText(err))
107
+ .ThrowAsJavaScriptException();
108
+ }
109
+ }
110
+
111
+ // ── Destructor ───────────────────────────────────────────────────────────
112
+
113
+ ~Decibri() {
114
+ if (running_.exchange(false)) {
115
+ if (stream_) {
116
+ Pa_StopStream(stream_);
117
+ Pa_CloseStream(stream_);
118
+ stream_ = nullptr;
119
+ }
120
+ }
121
+ if (tsfn_) {
122
+ tsfn_.Release();
123
+ }
124
+ Pa_Terminate();
125
+ }
126
+
127
+ private:
128
+
129
+ // ── PortAudio callback (audio thread — keep it fast) ─────────────────────
130
+
131
+ static int AudioCallback(
132
+ const void* inputBuffer,
133
+ void* outputBuffer,
134
+ unsigned long framesPerBuffer,
135
+ const PaStreamCallbackTimeInfo* timeInfo,
136
+ PaStreamCallbackFlags statusFlags,
137
+ void* userData) {
138
+
139
+ (void)outputBuffer;
140
+ (void)timeInfo;
141
+ (void)statusFlags;
142
+
143
+ Decibri* self = static_cast<Decibri*>(userData);
144
+
145
+ if (!self->running_ || inputBuffer == nullptr) {
146
+ return paContinue;
147
+ }
148
+
149
+ // Copy raw bytes regardless of sample format (int16 or float32).
150
+ // Heap-allocate; ownership passes to the JS callback lambda below.
151
+ size_t byteLen = framesPerBuffer
152
+ * static_cast<size_t>(self->channels_)
153
+ * static_cast<size_t>(self->bytesPerSample_);
154
+ const uint8_t* src = static_cast<const uint8_t*>(inputBuffer);
155
+ auto* chunk = new std::vector<uint8_t>(src, src + byteLen);
156
+
157
+ napi_status status = self->tsfn_.NonBlockingCall(
158
+ chunk,
159
+ [](Napi::Env env, Napi::Function jsCallback, std::vector<uint8_t>* data) {
160
+ Napi::Buffer<uint8_t> buf = Napi::Buffer<uint8_t>::Copy(
161
+ env, data->data(), data->size());
162
+ delete data;
163
+ jsCallback.Call({env.Null(), buf});
164
+ });
165
+
166
+ if (status != napi_ok) {
167
+ delete chunk;
168
+ }
169
+
170
+ return paContinue;
171
+ }
172
+
173
+ // ── start(callback) ──────────────────────────────────────────────────────
174
+
175
+ Napi::Value Start(const Napi::CallbackInfo& info) {
176
+ Napi::Env env = info.Env();
177
+
178
+ if (running_) {
179
+ Napi::Error::New(env, "Decibri is already running. Call stop() first.")
180
+ .ThrowAsJavaScriptException();
181
+ return env.Undefined();
182
+ }
183
+ if (info.Length() < 1 || !info[0].IsFunction()) {
184
+ Napi::TypeError::New(env, "start(callback): callback function required")
185
+ .ThrowAsJavaScriptException();
186
+ return env.Undefined();
187
+ }
188
+
189
+ Napi::Function callback = info[0].As<Napi::Function>();
190
+
191
+ tsfn_ = Napi::ThreadSafeFunction::New(
192
+ env,
193
+ callback,
194
+ "DecibriCallback",
195
+ 0, // unlimited queue depth
196
+ 1 // single producer (audio thread)
197
+ );
198
+
199
+ // Resolve the device index — use the caller-specified device if set,
200
+ // otherwise fall back to the system default input device.
201
+ PaDeviceIndex deviceIndex;
202
+ if (device_ >= 0) {
203
+ deviceIndex = static_cast<PaDeviceIndex>(device_);
204
+ if (deviceIndex >= Pa_GetDeviceCount()) {
205
+ tsfn_.Release();
206
+ Napi::RangeError::New(env,
207
+ "device index out of range — call Decibri.devices() to list available devices")
208
+ .ThrowAsJavaScriptException();
209
+ return env.Undefined();
210
+ }
211
+ } else {
212
+ deviceIndex = Pa_GetDefaultInputDevice();
213
+ if (deviceIndex == paNoDevice) {
214
+ tsfn_.Release();
215
+ Napi::Error::New(env, "No microphone found. Check system audio input settings.")
216
+ .ThrowAsJavaScriptException();
217
+ return env.Undefined();
218
+ }
219
+ }
220
+
221
+ const PaDeviceInfo* deviceInfo = Pa_GetDeviceInfo(deviceIndex);
222
+ if (deviceInfo == nullptr || deviceInfo->maxInputChannels < 1) {
223
+ tsfn_.Release();
224
+ Napi::Error::New(env, "Selected device is not a valid input device.")
225
+ .ThrowAsJavaScriptException();
226
+ return env.Undefined();
227
+ }
228
+
229
+ PaStreamParameters inputParams;
230
+ inputParams.device = deviceIndex;
231
+ inputParams.channelCount = channels_;
232
+ inputParams.sampleFormat = sampleFormat_;
233
+ inputParams.suggestedLatency = deviceInfo->defaultLowInputLatency;
234
+ #ifdef PA_USE_WASAPI
235
+ // Enable Windows Audio Session API automatic format conversion so that
236
+ // any requested sample rate is accepted even when it differs from the
237
+ // device's native mix format (e.g. 16kHz from a 48kHz webcam mic).
238
+ PaWasapiStreamInfo wasapiInfo = {};
239
+ wasapiInfo.size = sizeof(PaWasapiStreamInfo);
240
+ wasapiInfo.hostApiType = paWASAPI;
241
+ wasapiInfo.version = 1;
242
+ wasapiInfo.flags = paWinWasapiAutoConvert;
243
+ inputParams.hostApiSpecificStreamInfo = &wasapiInfo;
244
+ #else
245
+ inputParams.hostApiSpecificStreamInfo = nullptr;
246
+ #endif
247
+
248
+ #ifdef PA_USE_COREAUDIO
249
+ {
250
+ const char* permError = CheckMicrophonePermission();
251
+ if (permError != nullptr) {
252
+ tsfn_.Release();
253
+ Napi::Error::New(env, permError).ThrowAsJavaScriptException();
254
+ return env.Undefined();
255
+ }
256
+ }
257
+ #endif
258
+
259
+ PaError err = Pa_OpenStream(
260
+ &stream_,
261
+ &inputParams,
262
+ nullptr, // capture only, no output
263
+ sampleRate_,
264
+ framesPerBuffer_,
265
+ paClipOff,
266
+ AudioCallback,
267
+ this);
268
+
269
+ if (err != paNoError) {
270
+ stream_ = nullptr;
271
+ tsfn_.Release();
272
+ Napi::Error::New(env, std::string("Failed to open audio stream: ") + Pa_GetErrorText(err))
273
+ .ThrowAsJavaScriptException();
274
+ return env.Undefined();
275
+ }
276
+
277
+ err = Pa_StartStream(stream_);
278
+ if (err != paNoError) {
279
+ Pa_CloseStream(stream_);
280
+ stream_ = nullptr;
281
+ tsfn_.Release();
282
+ Napi::Error::New(env, std::string("Failed to start audio stream: ") + Pa_GetErrorText(err))
283
+ .ThrowAsJavaScriptException();
284
+ return env.Undefined();
285
+ }
286
+
287
+ running_ = true;
288
+ return env.Undefined();
289
+ }
290
+
291
+ // ── stop() ───────────────────────────────────────────────────────────────
292
+
293
+ Napi::Value Stop(const Napi::CallbackInfo& info) {
294
+ Napi::Env env = info.Env();
295
+
296
+ if (!running_.exchange(false)) {
297
+ return env.Undefined(); // already stopped — no-op
298
+ }
299
+
300
+ if (stream_) {
301
+ Pa_StopStream(stream_);
302
+ Pa_CloseStream(stream_);
303
+ stream_ = nullptr;
304
+ }
305
+
306
+ if (tsfn_) {
307
+ tsfn_.Release();
308
+ tsfn_ = Napi::ThreadSafeFunction();
309
+ }
310
+
311
+ return env.Undefined();
312
+ }
313
+
314
+ // ── isOpen() ─────────────────────────────────────────────────────────────
315
+
316
+ Napi::Value IsOpen(const Napi::CallbackInfo& info) {
317
+ return Napi::Boolean::New(info.Env(), running_.load());
318
+ }
319
+
320
+ // ── Decibri.devices() — static ───────────────────────────────────────────
321
+ //
322
+ // PortAudio v19 reference-counts Pa_Initialize / Pa_Terminate calls, so
323
+ // it is safe to call them here even when a Decibri instance is actively
324
+ // capturing. The instance's init increments the ref count to 1; this call
325
+ // bumps it to 2 then back to 1 at the end — the live stream is unaffected.
326
+
327
+ static Napi::Value GetDevices(const Napi::CallbackInfo& info) {
328
+ Napi::Env env = info.Env();
329
+
330
+ PaError err = Pa_Initialize();
331
+ if (err != paNoError) {
332
+ Napi::Error::New(env, std::string("PortAudio init failed: ") + Pa_GetErrorText(err))
333
+ .ThrowAsJavaScriptException();
334
+ return env.Null();
335
+ }
336
+
337
+ int numDevices = Pa_GetDeviceCount();
338
+ PaDeviceIndex defaultInput = Pa_GetDefaultInputDevice();
339
+ Napi::Array devices = Napi::Array::New(env);
340
+ uint32_t idx = 0;
341
+
342
+ for (int i = 0; i < numDevices; i++) {
343
+ const PaDeviceInfo* di = Pa_GetDeviceInfo(i);
344
+ if (di == nullptr || di->maxInputChannels < 1) continue;
345
+
346
+ Napi::Object device = Napi::Object::New(env);
347
+ device.Set("index", Napi::Number::New(env, i));
348
+ device.Set("name", Napi::String::New(env, di->name));
349
+ device.Set("maxInputChannels", Napi::Number::New(env, di->maxInputChannels));
350
+ device.Set("defaultSampleRate", Napi::Number::New(env, di->defaultSampleRate));
351
+ device.Set("isDefault", Napi::Boolean::New(env, i == defaultInput));
352
+ devices.Set(idx++, device);
353
+ }
354
+
355
+ Pa_Terminate();
356
+ return devices;
357
+ }
358
+
359
+ // ── Decibri.version() — static ───────────────────────────────────────────
360
+
361
+ static Napi::Value GetVersion(const Napi::CallbackInfo& info) {
362
+ Napi::Env env = info.Env();
363
+ Napi::Object v = Napi::Object::New(env);
364
+ const PaVersionInfo* vi = Pa_GetVersionInfo();
365
+ v.Set("portaudio", Napi::String::New(env, vi ? vi->versionText : Pa_GetVersionText()));
366
+ v.Set("decibri", Napi::String::New(env, DECIBRI_VERSION));
367
+ return v;
368
+ }
369
+
370
+ // ── Members ──────────────────────────────────────────────────────────────
371
+
372
+ PaStream* stream_;
373
+ Napi::ThreadSafeFunction tsfn_;
374
+ std::atomic<bool> running_;
375
+ int sampleRate_;
376
+ int channels_;
377
+ int framesPerBuffer_;
378
+ int device_;
379
+ PaSampleFormat sampleFormat_;
380
+ int bytesPerSample_;
381
+ };
382
+
383
+ // ─── Module entry point ──────────────────────────────────────────────────────
384
+
385
+ Napi::Object Init(Napi::Env env, Napi::Object exports) {
386
+ Decibri::Init(env, exports);
387
+ return exports;
388
+ }
389
+
390
+ NODE_API_MODULE(decibri, Init)