# decibri

Cross-platform audio capture, conditioning, and playback for Node.js and browsers, with voice activity detection on live and recorded audio.

## Installation

```bash
npm install decibri
```

One package for Node.js and browsers. Node.js gets a prebuilt native addon. Browsers get a JavaScript AudioWorklet implementation. Platform-specific binaries are installed automatically.

Requires Node.js >= 18. TypeScript definitions are bundled.

The package uses named exports:

```javascript
const { Microphone, Speaker, inputDevices, outputDevices, version } = require('decibri');
```

## Quick Start

### Capture audio

```javascript
const { Microphone } = require('decibri');

const mic = new Microphone({ sampleRate: 16000, channels: 1 });
mic.on('data', (chunk) => { /* Buffer of Int16 PCM samples */ });
setTimeout(() => mic.stop(), 5000);
```

### Condition and analyze a file

```javascript
const { File } = require('decibri');

// The same conditioning chain as the live microphone, over a WAV file.
const file = await File.open('clip.wav', { denoise: 'fastenhancer-t', highpass: 80 });
file.on('data', (chunk) => { /* Buffer of conditioned Int16 PCM */ });
file.on('end', () => console.log('done'));

// Whole-file speech analysis (a live stream cannot do this).
const f = await File.open('clip.wav', { vad: 'silero' });
const report = await f.analyze();
for (const s of report.segments) console.log(s.start, s.end); // seconds
```

### Play audio

```javascript
const { Speaker } = require('decibri');

const speaker = new Speaker({ sampleRate: 16000, channels: 1 });
speaker.write(pcmBuffer);
speaker.end();
```

### Browser capture

```javascript
import { Microphone } from 'decibri'; // browser entry via conditional export

const mic = new Microphone({ sampleRate: 16000 });
mic.on('data', (chunk) => { /* Int16Array of PCM samples */ });
await mic.start(); // requires user gesture in Safari
```

### Browser playback

```javascript
import { Speaker } from 'decibri'; // browser entry via conditional export

const speaker = new Speaker({ sampleRate: 16000 });
playButton.onclick = async () => {
  await speaker.write(int16Chunk); // Int16Array of PCM samples
  await speaker.drain();           // resolves when playback finishes
  speaker.stop();
};
```

### Pipe capture to playback (echo)

```javascript
const { Microphone, Speaker } = require('decibri');

const mic = new Microphone({ sampleRate: 16000, channels: 1 });
const speaker = new Speaker({ sampleRate: 16000, channels: 1 });
mic.pipe(speaker);
```

## API: Microphone (Capture)

### `new Microphone(options?)`

Creates a Readable stream that captures from the microphone.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `sampleRate` | number | 16000 | Samples per second (1000 to 384000) |
| `channels` | number | 1 | Delivered channels per frame, interleaved. `1` (the default) delivers the average of every device channel; the device's own count delivers every device channel in device order; a `channelMap` names any other selection. The device's own report is the only ceiling; no fixed maximum exists |
| `channelMap` | number[] | none | 0-based device channel indices choosing which device channels feed the delivered channels: delivered channel `j` carries device channel `channelMap[j]`. The length must equal `channels`; entries may repeat and may appear in any order, so a map selects, permutes, and duplicates |
| `framesPerBuffer` | number | 1600 | Frames per chunk (64 to 65536). At 16kHz mono, 1600 = 100ms = 3200 bytes |
| `device` | number, string, or `{ id: string }` | system default | Device index, case-insensitive name substring, or stable per-host ID |
| `dtype` | `'int16'` \| `'float32'` | `'int16'` | Sample encoding |
| `vad` | `false` \| `'silero'` \| `'energy'` \| `VadOptions` | `false` | Voice activity detection: disabled, the Silero ML model, an RMS energy threshold, or a config object `{ model, threshold, holdoffMs, source }` to tune the policy and the detector source |
| `modelPath` | string | bundled model | Path to the Silero model. Only used when `vad` is `'silero'` |
| `dcRemoval` | boolean | off | Remove a constant (DC) offset with a one-pole DC-blocking high-pass. Runs first in the chain; same-length, no added latency |
| `denoise` | `'fastenhancer-t'` | off | Single-channel speech-enhancement (denoise) model. The model ships in the package; no path or download needed |
| `highpass` | `80` \| `100` | off | High-pass cutoff in Hz (second-order Butterworth) that removes low-frequency rumble. Runs after denoise. Out-of-set values throw a `RangeError` |
| `agc` | number | off | AGC target level in dBFS, an integer in -40 to -3 (typical -18). Runs after the high-pass. Out-of-range throws a `RangeError` |
| `limiter` | number | off | Peak limiter ceiling in dBFS, a number in -3.0 to 0.0 (typical -1.0). Runs last. Out-of-range throws a `RangeError` |

Standard `ReadableOptions` (e.g. `highWaterMark`) are also accepted.

`vad: true` is not accepted; pass the mode explicitly as `vad: 'silero'` or `vad: 'energy'`. The string shorthand uses the default threshold (0.5 for `'silero'`, 0.01 for `'energy'`) and a 300 ms holdoff; to tune them pass a config object: `vad: { model: 'silero', threshold: 0.6, holdoffMs: 200 }`. The `threshold` (`0`–`1`) and `holdoffMs` fields are optional and fall back to those defaults. The optional `source` field names the 0-based DELIVERED channel the detector reads: the position within the delivered interleaved frames after any `channelMap` is applied (a `channelMap` names device channels; `source` names a delivered position). Absent, the detector reads the frame average of every delivered channel. Selecting a source changes which samples the detector reads and nothing else; the delivered audio is untouched.

Multichannel capture delivers interleaved frames: each chunk holds `framesPerBuffer * channels` samples, and each frame carries the delivered channels in order. Without a `channelMap`, a `channels` count above `1` must equal the device's own: a count above the device's report fails with a `DecibriError` carrying the code `'MICROPHONE_CHANNELS_UNSUPPORTED'`, and a count above `1` and below the device's report fails with `'CHANNEL_SELECTION_AMBIGUOUS'`, because which channels it means has no single answer; a `channelMap` names them. A map entry the device does not have fails with `'CHANNEL_MAP_OUT_OF_RANGE'`, naming the entry and the count the device reports.

### Methods

| Method | Description |
| --- | --- |
| `mic.stop()` | Stop capture and end stream. Safe to call multiple times |
| `Microphone.open(options?)` | Construct without blocking the event loop. Returns a `Promise<Microphone>`. See [Non-blocking API](#non-blocking-api) |
| `Microphone.devices()` | List available input devices |
| `Microphone.version()` | Version info: `{ decibri, audioBackend, binding }` |

The module-level `inputDevices()` and `version()` free functions are equivalent to the static methods.

### Properties

| Property | Type | Description |
| --- | --- | --- |
| `mic.isOpen` | boolean | `true` while capturing |
| `mic.vadScore` | number | Latest VAD score for the active mode (Silero probability or normalized RMS); 0 when disabled |

### Events

| Event | Payload | Description |
| --- | --- | --- |
| `'data'` | Buffer | Audio chunk (Int16 LE or Float32 LE) |
| `'backpressure'` | - | Internal buffer full, consumer too slow |
| `'speech'` | - | VAD: audio crosses threshold |
| `'silence'` | - | VAD: audio below threshold for the holdoff period |
| `'end'` | - | Stream ended |
| `'error'` | Error | An error occurred |

## API: Speaker (Playback)

### `new Speaker(options?)`

Creates a Writable stream for speaker playback.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `sampleRate` | number | 16000 | Playback sample rate (1000 to 384000) |
| `channels` | number | 1 | Output channels (1 or more, up to what the device supports) |
| `dtype` | `'int16'` \| `'float32'` | `'int16'` | Sample encoding of incoming data |
| `device` | number, string, or `{ id: string }` | system default | Output device index, case-insensitive name substring, or stable per-host ID |

Standard `WritableOptions` (e.g. `highWaterMark`) are also accepted.

| Method / Property | Description |
| --- | --- |
| `speaker.write(chunk)` | Write PCM data for playback |
| `speaker.writeAsync(chunk)` | Write without blocking the event loop. Returns a `Promise`. See [Non-blocking API](#non-blocking-api) |
| `speaker.end()` | Signal end. Drains remaining audio, then emits `'finish'` |
| `speaker.drainAsync()` | Wait for queued audio to finish without blocking the event loop. Returns a `Promise` |
| `speaker.stop()` | Immediate stop. Discards remaining audio |
| `speaker.isPlaying` | `true` while audio is being output |
| `Speaker.open(options?)` | Construct without blocking the event loop. Returns a `Promise<Speaker>` |
| `Speaker.devices()` | List available output devices |
| `Speaker.version()` | Same as `Microphone.version()` |

The module-level `outputDevices()` free function is equivalent to `Speaker.devices()`.

## Non-blocking API

The synchronous constructors and `write` / `drain` do their work on the event loop, which is fine for most apps. For event-loop-sensitive code (servers, real-time voice pipelines), 4.1.0 adds async variants that perform the blocking work without stalling the event loop. They are additive: the synchronous API is unchanged, and you opt in only where you need it.

### Non-blocking construction

`Microphone.open(options?)` and `Speaker.open(options?)` are async factories that return a Promise of a ready instance. They take the same options as the constructors. For a microphone with Silero VAD, the model load that the constructor does inline runs without blocking the event loop.

```javascript
const { Microphone, Speaker } = require('decibri');

const mic = await Microphone.open({ sampleRate: 16000, vad: 'silero' });
const speaker = await Speaker.open({ sampleRate: 16000, channels: 1 });
```

The synchronous `new Microphone(...)` and `new Speaker(...)` still work unchanged. A failed open rejects the Promise with the same typed error a failed constructor throws.

### Non-blocking playback

`speaker.writeAsync(chunk)` resolves once the audio is queued, performing the backpressure wait (when the playback buffer is full) without blocking the event loop. `speaker.drainAsync()` resolves when all queued audio has finished playing, again without blocking.

```javascript
const speaker = await Speaker.open({ sampleRate: 16000, channels: 1 });

await speaker.writeAsync(pcmBuffer);
await speaker.drainAsync(); // resolves when playback finishes
```

These are a direct alternative to the synchronous `write()` / `pipe()` / `end()` stream interface, which is unchanged. Use one path per instance (the stream methods or the async methods, not both at once), and await calls in sequence to keep samples in order.

## Errors

Construction errors come as typed classes you can catch:

```javascript
const { Microphone, DecibriError, DeviceError } = require('decibri');

try {
  new Microphone({ device: 'no such device' });
} catch (err) {
  if (err instanceof DeviceError) {
    console.log(err.code); // e.g. 'MICROPHONE_NOT_FOUND'
  }
}
```

`DeviceError`, `OrtError`, and `OrtPathError` extend `DecibriError`, which extends `Error`. Each carries a stable `code` string. Argument validation (bad `sampleRate`, `channels`, `dtype`, or `vad`) throws a built-in `RangeError` or `TypeError`.

## API: Browser

The browser API uses `getUserMedia` and `AudioWorklet`. It differs from the Node.js API because browser audio is fundamentally async.

### `new Microphone(options?)` (browser)

Same options as Node.js, plus:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `device` | string | system default | Device ID from `Microphone.devices()` (not index) |
| `echoCancellation` | boolean | true | Browser echo cancellation |
| `noiseSuppression` | boolean | true | Browser noise suppression |
| `workletUrl` | string | inline blob | Custom worklet URL for strict CSP |

The browser runs energy-mode VAD only, so its `vad` option accepts `false`, `'energy'`, or a config object `{ model: 'energy', threshold, holdoffMs, source }`. The browser `version()` returns `{ decibri }` only: the browser build has no native core, so `decibri` reports the installed package version. In Node, `version().decibri` reports the native core version.

### Key differences from Node.js

| Aspect | Node.js | Browser |
| --- | --- | --- |
| Start | Automatic on first read | `await mic.start()` |
| Base class | Readable stream | Custom Emitter |
| Data type | Buffer | Int16Array / Float32Array |
| `devices()` | Sync, returns array | Async, returns Promise |
| Sample rate | Native device rate | Resampled from native rate |
| VAD | `'silero'` or `'energy'` | `'energy'` only |

### `new Speaker(options?)` (browser)

Browser audio playback through the Web Audio API. Playback is async (Promise based) and must be started from a user gesture so the browser allows audio.

```javascript
import { Speaker } from 'decibri'; // browser entry via conditional export

const speaker = new Speaker({ sampleRate: 16000 });

playButton.onclick = async () => {
  await speaker.write(int16Chunk); // Int16Array of PCM samples
  await speaker.drain();           // resolves when playback finishes
  speaker.stop();
};
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `sampleRate` | number | 16000 | Sample rate of the audio you write (resampled to the output rate) |
| `channels` | number | 1 | Output channels (a mono stream plays on every channel) |
| `dtype` | `'int16'` \| `'float32'` | `'int16'` | Encoding of the samples you write |
| `workletUrl` | string | inline blob | Custom worklet URL for strict CSP |

- `start()` creates and resumes the audio output. Optional: `write()` starts it on the first call. Either must run in a user gesture (a click or tap); a context blocked by the autoplay policy surfaces a clear error.
- `write(chunk)` resolves when the samples are queued. It waits when the buffer is full, so awaiting it paces playback. Await calls sequentially to preserve order.
- `drain()` resolves when the queued audio has finished playing, immediately if nothing is queued.
- `stop()` halts immediately and discards anything queued.
- `isPlaying` reports whether audio is currently queued and playing.

To verify playback in real browsers, open `examples/browser-speaker-test.html` (see `examples/README.md`).

## Voice Activity Detection

### Energy mode

Lightweight RMS energy threshold. No model required.

```javascript
const mic = new Microphone({ vad: { model: 'energy', threshold: 0.01 } });
mic.on('speech', () => console.log('speaking'));
mic.on('silence', () => console.log('silent'));
```

### Silero mode

ML-based detection using the Silero VAD v6.2 model. More accurate than energy mode, especially in noisy environments.

```javascript
const mic = new Microphone({ vad: { model: 'silero', threshold: 0.5 } });
mic.on('speech', () => console.log('speaking'));
mic.on('silence', () => console.log('silent'));
```

The Silero model (~2MB) ships inside the npm package. No downloads or API keys required. Silero mode is Node.js only.

## Decibri ACE (audio conditioning)

Decibri ACE (Audio Conditioning Engine) is decibri's opt-in audio front-end for speech. It is a conditioning chain that runs on the captured audio before it reaches your `'data'` handler. Every stage is off by default, runs on-device, and needs no API key. With nothing enabled the capture path is byte-identical to plain capture.

The stages run in a fixed order. Enable any subset:

| Stage | Option | Range |
| --- | --- | --- |
| DC removal | `dcRemoval: true` | boolean |
| Denoise | `denoise: 'fastenhancer-t'` | the one bundled model |
| High-pass | `highpass: 80` or `100` | Hz |
| AGC | `agc: -18` | dBFS, -40 to -3 |
| Limiter | `limiter: -1.0` | dBFS, -3.0 to 0.0 |

```javascript
const { Microphone } = require('decibri');

const mic = new Microphone({
  sampleRate: 16000,
  denoise: 'fastenhancer-t',   // bundled speech-enhancement model
  highpass: 80,                // remove low-frequency rumble
  agc: -18,                    // target level in dBFS
  limiter: -1.0,               // peak ceiling in dBFS
  vad: { model: 'silero', threshold: 0.5 },
});

mic.on('data', (chunk) => { /* Buffer of conditioned Int16 PCM */ });
mic.on('speech', () => console.log('speech'));
mic.on('silence', () => console.log('silence'));
setTimeout(() => mic.stop(), 5000);
```

VAD reads the signal before the chain, so `vadScore` and the `'speech'` / `'silence'` events are unaffected by which conditioning stages you enable. The conditioning chain runs in the native Node.js capture path; the browser build does not include it.

## ONNX Runtime telemetry

Silero mode (`vad: 'silero'`) and the ACE `denoise` stage run on ONNX Runtime, which carries its own telemetry, separate from anything decibri does. Decibri disables it on the environment it commits when it initializes the runtime. Set `DECIBRI_ORT_TELEMETRY=1` in the environment before first use to leave it enabled; every other value, an empty value, and an absent variable leave it disabled.

Two limits apply on Windows and decibri can close neither, so decibri does not claim that no telemetry is emitted. ONNX Runtime logs one process-information event while the environment is being created, before the setting is applied, and logs it once per process, so that event is emitted whichever way the setting is left. The runtime also assigns its telemetry state from the Windows tracing session through an ETW callback, so the platform can re-enable telemetry after decibri has disabled it. On other platforms ONNX Runtime's telemetry provider does nothing.

Neither ONNX Runtime nor this setting applies to the browser build, which has no ONNX Runtime.

## API: File (offline source)

Everything a `Microphone` does to live audio, `File` does to audio you already have: the same conditioning options, the same Readable stream of conditioned chunks (finite: it ends at EOF), and the same opt-in `vad`. Because a `File` is a complete recording, it can also analyze the whole recording for speech.

- `await File.open(path, options?)`: read a file off the event loop (recommended, like `Microphone.open`). Reads WAV, AIFF, AIFF-C and FLAC, identified from the file's own bytes rather than its extension.
- `new File(path, options?)`: the same result, synchronous (blocks on disk I/O; fine for scripts).
- `File.buffer(samples, options)`: wrap a `Float32Array` of samples you already hold. `options.inputRate` is required (raw samples carry no header); a raw `Buffer` of bytes is rejected as ambiguous. `options.inputChannels` states the interleave of the samples, `1` by default, the channel counterpart of `inputRate`.
- `await file.analyze()` (also spelled `analyse()`): consume the source and resolve to a `VadReport` of per-window `scores` (`{ start, end, vadScore, isSpeech }`) and merged speech `segments` (`{ start, end }`), in seconds of file time. Requires `vad: 'silero'`; a `File` opened without `vad` rejects with `analysis requires VAD`.
- `file.vadScore`, `'speech'` / `'silence'` events: per-chunk VAD alongside the stream, with the holdoff measured in FILE time (sample positions), never wall-clock time, so processing speed does not change the reported events.

Options mirror `Microphone` (`sampleRate`, `channels`, `channelMap`, `dtype`, `vad`, `dcRemoval`, `denoise`, `highpass`, `agc`, `limiter`), with `channels` and `channelMap` read against the source's own channel count (the file's header, or `inputChannels` for `File.buffer`) where the live path reads the device's report, and the vad `source` naming a delivered channel exactly as on `Microphone`; the live-capture options (`device`, `framesPerBuffer`) do not apply. Iteration and analysis are separate single passes: construct one `File` per operation. Note: Node also has a global `File` (the web File API); import decibri's explicitly to avoid shadowing surprises.

## Device Selection

```javascript
const { Microphone } = require('decibri');

// System default
const mic = new Microphone();

// By name (case-insensitive substring match)
const mic = new Microphone({ device: 'USB' });

// By index
const devices = Microphone.devices();
const mic = new Microphone({ device: devices[1].index });

// By stable per-host ID (survives across enumerations)
const mic = new Microphone({ device: { id: devices[1].id } });

Microphone.devices();
// [
//   { index: 0, name: 'Microphone', id: '{0.0.1.00000000}.{...}', maxInputChannels: 2, defaultSampleRate: 48000, isDefault: true },
//   { index: 1, name: 'USB Headset', id: '{0.0.1.00000000}.{...}', maxInputChannels: 1, defaultSampleRate: 44100, isDefault: false }
// ]
```

## Examples

Runnable examples ship with the package. After `npm install decibri`, find them under `node_modules/decibri/examples/`. From a clone of the repo they live at `npm/decibri/examples/`.

```bash
# Capture to WAV file (no extra dependencies)
node node_modules/decibri/examples/wav-capture.js

# Stream to WebSocket (requires: npm install ws)
node node_modules/decibri/examples/websocket-server.js   # terminal 1
node node_modules/decibri/examples/websocket-stream.js   # terminal 2
```

For the browser, `examples/browser-speaker-test.html` is a page for manually verifying audio playback in each browser. See `examples/README.md` for how to serve it on desktop and mobile.

## Migrating from 3.x

decibri 4.0.0 renames the API to a microphone and speaker vocabulary and switches to named exports. See [MIGRATION.md](./MIGRATION.md) for a complete before-and-after guide.

## Platform Support

| Platform | Architecture | Audio Backend |
| --- | --- | --- |
| Windows | x64 | WASAPI |
| Windows | arm64 | WASAPI |
| macOS | arm64 | CoreAudio |
| Linux | x64 | ALSA |
| Linux | arm64 | ALSA |
| Browser | - | Web Audio API (AudioWorklet) |

## How It Works

decibri compiles a Rust audio core to a Node.js native addon and ships prebuilt binaries for each platform, so there is no build step on install. Browser support uses a JavaScript AudioWorklet implementation with the same event-driven API.

On Node.js, audio flows from the OS audio device through frame-exact buffering (which guarantees consistent chunk sizes) and into a standard Readable stream. In the browser, audio is captured and resampled in an AudioWorklet and delivered through the same `'data'` event interface.

## Documentation

- **Source code & issues**: [github.com/decibri/decibri](https://github.com/decibri/decibri)
- **Provider integrations** (OpenAI, Deepgram, AssemblyAI): [decibri.com/docs](https://decibri.com/docs)

## License

Apache-2.0. See [LICENSE](https://github.com/decibri/decibri/blob/main/LICENSE) for details.

Copyright (c) 2026 Decibri.
