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.
- package/LICENSE +191 -0
- package/README.md +294 -0
- package/binding.gyp +122 -0
- package/examples/wav-capture.js +48 -0
- package/examples/websocket-server.js +28 -0
- package/examples/websocket-stream.js +44 -0
- package/index.js +190 -0
- package/package.json +55 -6
- package/prebuilds/darwin-arm64/node.napi.node +0 -0
- package/prebuilds/linux-arm64/node.napi.node +0 -0
- package/prebuilds/linux-x64/node.napi.node +0 -0
- package/prebuilds/win32-x64/node.napi.node +0 -0
- package/src/decibri.cc +390 -0
- package/src/mac_permission.mm +21 -0
- package/types/index.d.ts +273 -0
|
@@ -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
|
|
4
|
-
"description": "Cross-platform audio capture for Node.js
|
|
5
|
-
"
|
|
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/
|
|
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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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)
|