nodeaudio/README.md
Andrew Gundersen f2e4bf2893 Rewrite README to document architecture, API, and usage
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>
2026-08-08 12:04:50 -05:00

150 lines
7.8 KiB
Markdown

# nodeaudio
Native Node.js bindings for [PortAudio](http://www.portaudio.com/), 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 dispatch `data` and `write` events from the audio thread onto the Node.js event loop.
- **Large writes are backgrounded**. `WriteToOutputStream` returns 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**. `CancelPlayback` sets a flag that the background thread checks, so a long playback can be interrupted mid-write.
- **Runtime device switching**. Depends on a [forked PortAudio](https://andrewgundersen.net/repos/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](https://andrewgundersen.net/repos/portaudio)), fix up the install name so the dylib resolves relative to the addon:
install_name_tool -id "@loader_path/libportaudio.dylib" libportaudio.dylib
## Usage
```js
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:
```js
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 `Int16Array`s 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. `int16Arr` is a mono `Int16Array` (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.gyp` has 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)](https://andrewgundersen.net/repos/portaudio) — 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.