diff --git a/README.md b/README.md index f58a6a2..3000a6c 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,150 @@ # 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 -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 -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 -The publishing config is defined in .npmrc (url and credentials). API_TOKEN is a GitLab token with the api scope. - -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 +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. diff --git a/binding.gyp b/binding.gyp index 62329fb..78b61cb 100644 --- a/binding.gyp +++ b/binding.gyp @@ -13,17 +13,34 @@ "include_dirs": [ "portaudio/include" ], - "libraries": [ - "libportaudio.dylib" - ], - "copies": [ - { - "destination": "build/Release/", - "files": [ - "portaudio/bin/libportaudio.dylib" + "conditions": [ + ['OS=="mac"', { + "libraries": [ + "libportaudio.dylib" + ], + "copies": [ + { + "destination": "build/Release", + "files": [ + "portaudio/bin/libportaudio.dylib" + ] + } ] - } - ], + }], + ['OS=="linux"', { + "libraries": [ + "libportaudio.so" + ], + "copies": [ + { + "destination": "build/Release", + "files": [ + "portaudio/bin/libportaudio.so" + ] + } + ] + }], + ] } ] } diff --git a/package-lock.json b/package-lock.json index 2882b75..d31f346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { - "name": "nodeaudio", - "version": "0.0.1", + "name": "@crimata/nodeaudio", + "version": "0.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "nodeaudio", - "version": "0.0.1", + "name": "@crimata/nodeaudio", + "version": "0.0.0", "dependencies": { - "bindings": "~1.2.1", + "bindings": "~1.2.1" + }, + "devDependencies": { "sleep": "^6.3.0" } }, @@ -20,12 +22,14 @@ "node_modules/nan": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==" + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "dev": true }, "node_modules/sleep": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/sleep/-/sleep-6.3.0.tgz", "integrity": "sha512-+WgYl951qdUlb1iS97UvQ01pkauoBK9ML9I/CMPg41v0Ze4EyMlTgFTDDo32iYj98IYqxIjDMRd+L71lawFfpQ==", + "dev": true, "hasInstallScript": true, "dependencies": { "nan": "^2.14.1" @@ -44,12 +48,14 @@ "nan": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==" + "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "dev": true }, "sleep": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/sleep/-/sleep-6.3.0.tgz", "integrity": "sha512-+WgYl951qdUlb1iS97UvQ01pkauoBK9ML9I/CMPg41v0Ze4EyMlTgFTDDo32iYj98IYqxIjDMRd+L71lawFfpQ==", + "dev": true, "requires": { "nan": "^2.14.1" } diff --git a/package.json b/package.json index 31d7b66..2810cd7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "NodeJS bindings for PortAudio", "main": "./index.js", "dependencies": { - "bindings": "~1.2.1", + "bindings": "~1.2.1" + }, + "devDependencies": { "sleep": "^6.3.0" }, "scripts": { diff --git a/portaudio/bin/libportaudio.so b/portaudio/bin/libportaudio.so new file mode 100755 index 0000000..98b71d4 Binary files /dev/null and b/portaudio/bin/libportaudio.so differ