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>
This commit is contained in:
Andrew Gundersen 2026-08-08 12:04:50 -05:00
commit f2e4bf2893

151
README.md
View file

@ -1,29 +1,150 @@
# nodeaudio # nodeaudio
## Building Native Node.js bindings for [PortAudio](http://www.portaudio.com/), providing real-time audio capture and playback to JavaScript applications.
Clean and build: 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 node-gyp rebuild
There is a custom, prebuilt PortAudio library (libportaudio.dylib) in portaudio/bin that is copied over to the rpath (build/Release) before the actual build. During build, it is dynamically linked to nodeaudio.node (the main binary). This will NOT work (potentially at runtime) unless PortAudio has the correct install path. Run through the steps below if libportaudio.dylib changes: 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`.
Update the install path of PortAudio: 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 install_name_tool -id "@loader_path/libportaudio.dylib" libportaudio.dylib
To see what libraries a Mach-O exe requires: ## Usage
otool -L build/Release/nodeAudio.node ```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 ## Publishing
The publishing config is defined in .npmrc (url and credentials). API_TOKEN is a GitLab token with the api scope. 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.
As of writing, publishing will fail if there is already a package in the registry.
### !! NPM vs Yarn
I was unable to figure out how to publish to the private GitLab repo using npm (400 Bad Request). This may just be a bug in npm. For real, this threw me for a loop. I had to switch to Yarn for this reason (v1.22.17). Pulling with npm works fine though (for other repos).
### TODO
Sign on build