- C 94.2%
- JavaScript 4.9%
- Python 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Expand the README with a motivation section, architecture diagram covering the lock-free ring buffer and N-API threadsafe callback design, full API reference table, usage examples for recording/playback and device hot-swap, and an honest accounting of current constraints. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> |
||
| portaudio | ||
| src | ||
| test | ||
| .gitignore | ||
| .npmrc | ||
| binding.gyp | ||
| index.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
nodeaudio
Native Node.js bindings for PortAudio, providing real-time audio capture and playback to JavaScript applications.
Built as a C addon using N-API, with lock-free ring buffers and thread-safe callbacks to bridge PortAudio's real-time audio threads to the Node.js event loop without dropping samples or blocking the main thread.
Motivation
Doing real-time audio in Node.js is hard: audio callbacks fire on high-priority OS threads that cannot safely touch JavaScript state, and any blocking work in the callback causes audible glitches. nodeaudio solves this with a lock-free ring buffer between the PortAudio callback and the Node.js main thread, and uses N-API's thread-safe functions to marshal captured audio into JavaScript as Int16 typed arrays.
The library is designed to support live-audio workflows in Node (recording, playback, and hot-swapping the default input/output device without restarting the process).
Architecture
┌─────────────────────┐ ┌─────────────────────┐
│ PortAudio thread │ │ Node.js main loop │
│ (real-time, RT) │ │ │
│ │ │ │
│ input_callback ──▶│ ringbuf ─│──▶ "data" event │
│ │ │ │
│ output_callback ◀─│ ringbuf ◀│─── WriteToOutput │
│ │ │ │
└─────────────────────┘ └─────────────────────┘
- Lock-free ring buffers (
pa_ringbuffer) sit between the audio callback and the JS thread, so the RT callback never blocks on a mutex. - N-API threadsafe functions (
napi_create_threadsafe_function) safely dispatchdataandwriteevents from the audio thread onto the Node.js event loop. - Large writes are backgrounded.
WriteToOutputStreamreturns immediately; if the payload exceeds the ring buffer capacity, a pthread drains it into the buffer as space frees up, so JavaScript stays responsive. - Cancellation.
CancelPlaybacksets a flag that the background thread checks, so a long playback can be interrupted mid-write. - Runtime device switching. Depends on a forked PortAudio that adds
Pa_RefreshDevices, letting the app detect device changes (e.g. plugging in headphones, swapping a USB mic) without a restart.
Installation
nodeaudio ships a prebuilt PortAudio dylib/so under portaudio/bin. Build the addon with:
node-gyp rebuild
The build copies libportaudio.dylib (macOS) or libportaudio.so (Linux) into build/Release alongside the compiled nodeaudio.node and dynamically links against it via @loader_path.
To verify the addon links against the bundled PortAudio:
otool -L build/Release/nodeAudio.node # macOS
ldd build/Release/nodeAudio.node # Linux
If you rebuild PortAudio from source (see the fork), fix up the install name so the dylib resolves relative to the addon:
install_name_tool -id "@loader_path/libportaudio.dylib" libportaudio.dylib
Usage
const nodeAudio = require('@crimata/nodeaudio');
const EventEmitter = require('events');
const emitter = new EventEmitter();
const chunks = [];
emitter.on('data', (int16Arr) => {
chunks.push(int16Arr); // fires from the audio thread as PCM arrives
});
emitter.on('write', () => {
// fires while playback is draining the output buffer
});
// Wire the emitter into the addon and open default streams
nodeAudio.core.Initialize(emitter.emit.bind(emitter));
nodeAudio.core.OpenInputStream(nodeAudio.core.GetDefaultInputDevice());
nodeAudio.core.OpenOutputStream(nodeAudio.core.GetDefaultOutputDevice());
// Record for 2 seconds, then play it back
setTimeout(() => {
const pcm = nodeAudio.utils.mergeChunks(chunks);
nodeAudio.core.WriteToOutputStream(pcm.buffer);
setTimeout(() => nodeAudio.core.Terminate(), 5000);
}, 2000);
Hot-swapping devices
Because GetDefaultInputDevice / GetDefaultOutputDevice call the fork's Pa_RefreshDevices under the hood, you can poll for device changes and reopen the streams when the user plugs in new hardware:
setInterval(() => {
const input = nodeAudio.core.GetDefaultInputDevice();
if (input !== currentInput) {
nodeAudio.core.CloseInputStream();
nodeAudio.core.OpenInputStream(input);
currentInput = input;
}
}, 2000);
See test/rwSwitchStream.js for a fuller example (keyboard-toggled record/play with periodic device rescanning).
API
All core methods hang off nodeAudio.core; JS helpers live on nodeAudio.utils.
| Method | Description |
|---|---|
Initialize(emit) |
Initialize PortAudio and register a Node EventEmitter's emit for data and write events. |
Terminate() |
Abort any active streams, free ring buffers, and shut PortAudio down. |
GetDefaultInputDevice() |
Refresh the device list and return the current default input device index. |
GetDefaultOutputDevice() |
Refresh the device list and return the current default output device index. |
OpenInputStream(deviceIndex) |
Open and start a capture stream. Emits data events with Int16Array chunks. |
OpenOutputStream(deviceIndex) |
Open and start a playback stream fed by WriteToOutputStream. |
CloseInputStream() |
Close the active capture stream. |
CloseOutputStream() |
Close the active playback stream. |
WriteToOutputStream(arrayBuffer) |
Enqueue Int16 PCM for playback. Non-blocking; large writes are drained on a background pthread. |
CancelPlayback() |
Cancel an in-progress background write. |
utils.mergeChunks(chunks) |
Concatenate an array of Int16Arrays into one. |
Events
Both events are emitted on the Node.js main thread via N-API threadsafe functions.
data(int16Arr)— fired by the input callback whenever a new frame block arrives from the microphone.int16Arris a monoInt16Array(see current constraints below).write()— fired by the output callback each time playback drains frames from the ring buffer, so JS can track playback progress.
Current constraints
The addon is opinionated for the recording/playback use case it was built for. If you need something more general, these are the knobs to change:
- Sample rate is hardcoded to 16 kHz (
src/na_utils.c). - Sample format is
paInt16, mono, 1 channel. - Ring buffer is fixed at 8192 frames (
src/na_buffer.c). - Platforms:
binding.gyphas conditions for macOS and Linux only.
Layout
src/na_front.c N-API glue — declares the exported JS methods
src/na_core.c Stream lifecycle, playback thread, PortAudio calls
src/na_callbacks.c Audio-thread callbacks + threadsafe JS dispatch
src/na_buffer.c Ring buffer init/teardown
src/na_utils.c Stream parameter setup
src/pa_ringbuffer.c PortAudio's lock-free ring buffer (vendored)
portaudio/ Bundled headers + prebuilt libportaudio for linking
test/ Example scripts (record/play, device switching)
index.js JS entrypoint — re-exports the addon and utility helpers
Related
- portaudio (fork) — PortAudio fork adding
Pa_RefreshDevices, required by this package for runtime device detection.
Publishing
The registry URL and credentials are in .npmrc; API_TOKEN is a GitLab token with api scope. Publish via Yarn (yarn publish) — npm returns 400s against the private GitLab registry.