<!-- markdownlint-disable MD024 -->

# Decibri npm Package Changelog

Changes to the decibri npm package, published to npmjs.com. Tags use the `npm-v*` pattern (e.g., `npm-v3.4.2`).

For other decibri packages, see:

- Rust core: [crates/decibri/CHANGELOG.md](../../crates/decibri/CHANGELOG.md)
- Python package: [bindings/python/CHANGELOG.md](../../bindings/python/CHANGELOG.md)

## [5.7.0] - 2026-08-21

### Added

- Prebuilt binaries for Windows ARM64, published as the fifth platform package `@decibri/decibri-win32-arm64-msvc` and selected automatically on `win32` / `arm64`. It bundles ONNX Runtime 1.28.1 and ships the same `THIRD-PARTY-NOTICES.md` as the other platform packages. Native ARM64 Node.js previously failed at load with the napi `Cannot find native binding` message; x64 Node.js under emulation is unchanged.

### Changed

- The bundled ONNX Runtime is 1.28.1 in every platform package, and the native addon is built against `ort` 2.0.0-rc.13 (`api-28`). An `ORT_DYLIB_PATH` override must point at ONNX Runtime 1.28 or newer.

### Fixed

- A bundled or `ORT_DYLIB_PATH` ONNX Runtime that cannot be used (not a loadable library, or older than 1.28) rejects with `ORT_LOAD_FAILED` on every silero or denoise attempt; a second attempt after such a failure no longer aborts the process.

## [5.6.0] - 2026-08-16

### Breaking changes

- **`Microphone` opens the device at its native channel count, and decibri performs the collapse to the delivered mono.** The device was opened at the configured count of 1, so on a multichannel device the operating system collapsed the channels before decibri saw them, and it did so differently on each platform: the Windows audio engine applies an unpublished mixing matrix, macOS's default channel map selects device channel zero rather than mixing, and on Linux the result depends on which PCM the device name resolved to (a `plug` device routes under a named policy, a raw `hw` device refuses the open). The capture stream now receives every device channel and decibri averages them into the delivered audio, the same documented average `File` applies to a multichannel container. The delivered audio changes for any device whose native count is above one: on macOS it changes from device channel zero to the average of every channel; on Windows the engine's stereo mix is replaced by the average, which is close to it but not guaranteed identical; above two channels every platform's mix differs from a plain average. A device already delivering one channel is unaffected, byte for byte. The `channels` option now names the DELIVERED count, exactly as `sampleRate` names the delivered rate.
- **The browser `Microphone` receives every channel the browser grants, and decibri performs the collapse to the delivered mono.** The capture requested a single channel from `getUserMedia` and read the first channel of the granted track, so on a track granted with more than one channel the delivered audio was the first channel alone. The capture now requests the 32-channel count the Web Audio specification requires an implementation to support, with ideal semantics, reads the granted track's own channel count, and averages every granted channel into the single delivered channel, the same documented average the Node entry delivers. The delivered audio changes for any track granted with more than one channel: the first granted channel is replaced by the average of every granted channel. A track granted with a single channel is unchanged, byte for byte.
- **`Microphone` accepts a `channels` count above 1, and a request for one is honoured rather than refused.** The constructor threw `RangeError: multichannel capture is not supported; channels must be 1 (mono)` for every count above 1, so the request failed before the device was touched. It now constructs, opens, and emits chunks carrying that many interleaved channels. Code that used the throw as a guard, catching it to fall back to a single-channel request, no longer has one: it receives interleaved multichannel audio where it received an error, and a consumer that reads a `'data'` chunk as a run of mono samples reads interleaved frames instead. Pass `channels: 1` to keep the previous delivery, which is unchanged byte for byte.
- **The browser `Microphone` accepts a `channels` count above 1, and delivers that many channels.** It threw `RangeError: multichannel capture is not supported; channels must be 1 (mono)` for every count above 1. It now delivers the requested channels interleaved, from the granted track, exactly as the Node entry does, so the two entries accept and refuse the same inputs with the same classes and messages. Code that relied on the throw loses the same guard the Node entry's callers lose. `channels: 1` is unchanged, byte for byte, and so is every other browser `Microphone` option.
- **The `Invalid vad value:` `TypeError` names `source` among the config object's keys.** The message reads `Invalid vad value: <value>. Expected false, 'silero', 'energy', or a config object { model, threshold, holdoffMs, source }.`, and the browser entry's copy names the same key list with its own accepted set of `false`, `'energy'`, and the config object. Code matching the full message text exactly stops matching and has to be updated to the new text; code matching the `Invalid vad value:` prefix is unaffected. The class, the inputs that throw it, the accepted `vad` values, and every other message are unchanged.
- **`pushAecReference` refuses a typed array whose sample dtype is not the microphone's own.** Any `ArrayBufferView` was read as raw bytes in the configured `dtype`, so a `Float32Array` pushed on an `'int16'` capture fed the canceller a byte reinterpretation of its samples, with nothing said. A `Float32Array` on an `'int16'` capture and an `Int16Array` on a `'float32'` capture now throw a `TypeError` naming both dtypes and both remedies (`dtype 'int16' configured but Float32Array samples were pushed; convert to Int16Array or construct Microphone with dtype: 'float32'`, and the mirror for the other direction), and every other sample-dtype view (`Float64Array`, `Int32Array`, and the rest) throws the existing `pushAecReference requires a Buffer, TypedArray, or DataView of PCM samples in the configured dtype` message, whatever the capture state, the refusal the Python surface raises for the same condition. A call site that relied on byte reinterpretation passes its bytes as a `Buffer`, `Uint8Array`, or `DataView` instead, which remain format-agnostic byte carriers, read exactly as before. `Int16Array` on `'int16'` and `Float32Array` on `'float32'` remain accepted unchanged.
- **`aecMetrics().referenceDropped` counts reference samples pushed while capture is not running.** A push made between construction (or `await Microphone.open()`) and the first consumer engaging the stream, or after `stop()`, was discarded with no counter moved, so the reference for audio played at startup vanished with nothing to show for it. Those samples now add to `referenceDropped`, readable once capture runs. A `referenceDropped` of zero no longer certifies only that every push fit the queue's bound; it also certifies that none arrived while capture was down, so code alerting on a nonzero figure now fires for pre-start pushes as well. The push itself still never blocks and never throws on any capture state, a push with `aec` unset is still an uncounted no-op, `aecMetrics()` still reads `null` while capture is not running, and the Python surface's counters are unchanged.
- **`AudioWriter` accepts a `channels` count above 1, and writes that many interleaved channels.** The constructor threw `RangeError: multichannel write is not supported; channels must be 1 (mono)` for every count above 1; that message is no longer thrown, and code that relied on the throw as a guard no longer has one. The incoming bytes are read as interleaved frames at the declared count and the file's header carries it; the stream's total sample count must divide into whole frames, refused when the stream finishes otherwise. Each container's own channel ceiling applies at the write, reported as a `DecibriError` with code `AUDIO_FORMAT_UNSUPPORTED` carrying the container layer's own text: a FLAC frame carries at most 8 channels, and a WAV format chunk's `nBlockAlign` field holds at most 32767 channels at 16-bit samples. decibri enforces no ceiling of its own. `channels: 1` (still the default) writes the identical file it wrote before, byte for byte, and `channels: 0` throws `RangeError: channels must be at least 1`. `sampleRate`, `dtype`, `format`, `compression` and the `report` are unchanged.

### Added

- `channelMap` on the `Microphone` options: an optional array of 0-based device channel indices selecting which device channels feed the delivered channels, so `channelMap: [1]` delivers the device's second channel alone. The length must equal `channels`, and entries may repeat and may appear in any order, so a map both selects and permutes, and may name more delivered channels than the device has; a length that does not match, or a malformed array, throws a `RangeError` or `TypeError` from the constructor. Absent delivers the average of every opened channel, the previous behaviour. The same shape as CoreAudio AUHAL's channel map (an array of device channel indices, one entry per client channel), not miniaudio's `channelMap`, which names a spatial layout. Entries are checked against the resolved device's own report when the stream starts; the device's report is the only ceiling, and no fixed maximum exists.
- Error code `CHANNEL_MAP_OUT_OF_RANGE` on `DecibriError`, emitted on the `'error'` event when the channel map names a device channel the device does not have. The message names the offending entry and the count the device reports, the figure `MicrophoneInfo.maxInputChannels` carries.
- `channelMap` on the browser `Microphone` options, mirroring the Node entry: an optional array of 0-based device channel indices selecting which granted channels feed the delivered channels, so `channelMap: [1]` delivers the granted track's second channel alone. The length must equal `channels`, and entries may repeat and may appear in any order, so a map both selects and permutes; a length that does not match, or a malformed array, throws the same `RangeError` or `TypeError` as the Node entry, with the same message, from the constructor. Absent delivers the average of every granted channel. Entries are checked against the granted track's own report: where the browser reports the granted channel count, `start()` rejects with an `Error` whose message names the entry and the granted count, the same message the Node entry carries for the same condition; where it does not, the same `Error` is emitted on the `'error'` event and the capture stops as soon as the audio graph reports its true channel count. The granted report is the only ceiling, and no fixed maximum exists.
- Multichannel capture on the `Microphone`: `channels` names how many interleaved channels each `'data'` chunk carries, bounded below by the constructor and above by the resolved device alone, with no fixed maximum. With a `channelMap`, delivered channel `j` carries the device channel the map names. Without one, two derivations are accepted: 1 delivers the average of every device channel, and the device's own count delivers every device channel in device order. A chunk's samples are interleaved frames of the delivered width, so a chunk holds `framesPerBuffer * channels` samples.
- Multichannel capture on the browser `Microphone`, the same rules read against the granted track instead of a device: a `channelMap` names granted channels, an unmapped count of 1 averages every granted channel, and an unmapped count equal to the grant delivers every granted channel in granted order. Above one delivered channel `vadScore` is the RMS of the per-frame average of the delivered channels rather than of the samples as they lie, which is what the Node entry's detector reads for the same capture; at one delivered channel it is unchanged.
- Error code `MICROPHONE_CHANNELS_UNSUPPORTED` on `DecibriError`, emitted on the `'error'` event when the delivered count exceeds the device's own. The message names both figures, the second being the one `MicrophoneInfo.maxInputChannels` carries. The browser entry rejects `start()` with the same message.
- Error code `CHANNEL_SELECTION_AMBIGUOUS` on `DecibriError`, emitted on the `'error'` event when the delivered count is above 1 and below the device's own with no `channelMap` set. The message names both figures. Set a `channelMap` naming which channels to deliver. The browser entry rejects `start()` with the same message.
- Echo cancellation on every delivered channel: with `aec` set and `channels` above 1, one canceller engine runs per delivered channel, each fed the same pushed reference and each finding its own channel's echo delay. `pushAecReference` is unchanged: one push serves every channel. The processing and memory cost scale with the delivered count, and a capture that outruns the machine shows up as `overrunCount` climbing; no capacity ceiling exists. A single-channel capture with the canceller is unchanged byte for byte.
- `channels` on the object `aecMetrics()` returns: every delivered channel's canceller report in delivered order, one entry per channel, each carrying `delaySamples`, `erleDb`, `doubleTalk`, `referenceStarved`, `acquisitionParked` and `referenceReanchors`. The top-level fields keep their names and report the first delivered channel's engine (the queue counters `referenceDropped` and `referenceSilence` describe the shared queue), so a single-channel stream reads as before with a one-entry array alongside. `delaySamples` is each engine's alignment offset from the reference frontier, not a room measurement, and `erleDb` is not a quality ranking across channels: it rises with echo distance, so a far microphone reports a higher figure while removing less echo in absolute terms.
- `channels` and `channelMap` on the `File` options, the `Microphone`'s channel vocabulary on the offline source, with the source's own channel count (the file's header, or `File.buffer`'s `inputChannels`) standing where the device's report stands. `channels: 1` (the default) delivers the documented average of every source channel, the previous behaviour, byte for byte; a count equal to the source's own delivers every source channel in source order, interleaved frame by frame in the emitted chunks. `channelMap` is an array of 0-based source channel indices, one entry per delivered channel; entries may repeat and may appear in any order, so a map both selects and permutes, and may name more delivered channels than the source has. Checked at construction, where the source's count is known; the source's count is the only ceiling, and no fixed maximum exists.
- Error codes `FILE_CHANNELS_UNSUPPORTED`, `FILE_CHANNEL_SELECTION_AMBIGUOUS` and `FILE_CHANNEL_MAP_OUT_OF_RANGE` on `DecibriError`, thrown from the `File` constructors: an unmapped delivered count above the source's own, an unmapped count above 1 and below the source's own (set a `channelMap` naming which source channels to deliver), and a map entry the source does not have. Each message names the figures in question. A map length that differs from `channels` keeps the shared `RangeError`.
- `inputChannels` on the `File.buffer` options, the channel counterpart of `inputRate`: the interleave of the caller's own samples, 1 (mono) by default, up to 65535. A sample count that is not a whole number of frames at the declared count throws the same `RangeError` the live block-size refusal carries. The option applies to `File.buffer` alone and throws a `TypeError` on the open path, where the file's own header answers.
- `File.save` writes the delivered channel count: the saved file carries the same interleaved layout the stream emits, in every container, with each container's own ceiling reported exactly as the `AudioWriter` entry above describes.
- `source` on the `vad` config object, on the `Microphone` and `File` options alike: the 0-based DELIVERED channel the detector reads, the position within the delivered interleaved frames after any `channelMap` is applied (a `channelMap` names device or source channels; `source` names the delivered position, so a channel a map delivers at two positions is named by position). Absent feeds the detector the frame average of every delivered channel, the previous behaviour. The value must be an integer (`TypeError: vad source must be an integer` otherwise), in 0 to 65535 (`RangeError: vad source must be between 0 and 65535` otherwise), and below the delivered channel count, refused from the constructor with a `RangeError` whose message is `the detector source names delivered channel <i>; the delivered channel count is <n>`; the delivered count is the only ceiling, and no fixed maximum exists. Selecting a source changes which samples the detector reads and nothing else: the emitted audio, `threshold`, `holdoffMs`, and the `'speech'` / `'silence'` mechanics are untouched.
- `source` on the browser `Microphone`'s `vad` config object, mirroring the Node entry: `vadScore` becomes the RMS of the named delivered channel's samples rather than of the per-frame average, and the same values are refused with the same classes and messages from the constructor.

### Changed

- The capture queue's fixed 64-buffer bound now holds buffers at the device's native channel width, so its memory scales with the device's channel count: roughly 123 KB per channel at a 48 kHz native rate with a typical 10 millisecond driver period.

### Removed

- The internal classification row for the message `multichannel capture is not supported; channels must be 1 (mono)`, which nothing produces. The message classified to a built-in `RangeError` with no `code`, so no thrown class, code, message or option changes.

## [5.5.0] - 2026-08-10

### Breaking changes

- **The `RangeError` thrown for a `channels` count below 1 carries the message `channels must be at least 1`.** It carried `channels must be between 1 and 32`, naming an upper bound that neither `Microphone` nor `Speaker` enforces. Code that matches the message text exactly stops matching and has to be updated to the new text. Nothing else about the error moves: it is still a `RangeError`, thrown for the same input, `channels: 0` on either constructor or `open()`.
- **`Speaker` accepts a `channels` count above 32.** It threw `RangeError: channels must be between 1 and 32` from the constructor. It now accepts any count above zero, offers the count to the device when playback starts, and a device that cannot serve it emits a `DecibriError` with code `SPEAKER_CHANNELS_UNSUPPORTED` on the `'error'` event, or rejects `writeAsync()` with it. Code that relied on the constructor throwing to reject an unsupported output channel count has to handle the failure asynchronously instead, on `'error'` or the rejection. `new Speaker({ channels: 0 })` still throws a `RangeError` from the constructor, and a count above 16383 still carries `STREAM_OPEN_FAILED` naming that limit. How many channels a device serves depends on the `sampleRate` asked for as well as the count.
- **An output channel count above the device's reported figure carries `SPEAKER_CHANNELS_UNSUPPORTED`.** It carried `STREAM_OPEN_FAILED`. Code branching on `STREAM_OPEN_FAILED` to detect an unsupported output channel count has to match `SPEAKER_CHANNELS_UNSUPPORTED` as well, and cannot drop `STREAM_OPEN_FAILED`, because a device that reports no figure at all keeps `STREAM_OPEN_FAILED` for the same condition. `STREAM_OPEN_FAILED` is otherwise unchanged and remains the code for an output open that failed for any other reason.
- **The browser `Microphone` rejects a `channels` count above 1.** It accepted 1 to 32 and delivered the first channel only, with nothing to indicate the rest were dropped. A count above 1 now throws `RangeError: multichannel capture is not supported; channels must be 1 (mono)`, and a count below 1 throws `RangeError: channels must be at least 1` where it threw a `TypeError`. Code passing a count above 1 to the browser entry has to pass 1 and mix down its own sources, or read the single channel it was already receiving. `channels: 1` is unchanged, as is every other browser `Microphone` option. Both class and message now match the Node entry's for the same values, so the two entries reject the same input the same way.
- The browser `Speaker` is unchanged. It still accepts 1 to 32 channels, the range the Web Audio specification requires an implementation to support, and keeps its own `channels must be between 1 and 32, got <n>` message, which that range does enforce.

### Added

- Error code `SPEAKER_CHANNELS_UNSUPPORTED` on `DecibriError`, for an output device that cannot serve the requested `channels`. The message names the count asked for, the count the device reports (the figure `SpeakerInfo.maxOutputChannels` carries) and the platform's own message.
- `referenceChannels` on the `aec` option object: the channel count of the far-end reference pushed through `pushAecReference`. Default 1 (mono). With a count above 1 the pushed samples are read as interleaved frames and each frame is averaged to one mono sample before the canceller sees it. The collapse is opt-in: a caller pushing a multichannel reference must declare the count, and an undeclared multichannel push keeps its current behaviour, cancelling nothing and reporting no error. The declared count must match the pushed buffer: a mismatch is not detected and raises no error, and shows up only as `aecMetrics().delaySamples` staying `null` with no fault reported. A count below 1 throws a `RangeError`; the only ceiling is the option's own 16-bit carrier. A mono reference against playback through more than one loudspeaker has a cancellation ceiling: the canceller models one room response applied to the channel average, so a placement where the per-loudspeaker echo paths differ leaves a residual that adaptation does not remove.
- Each of the four platform packages (`@decibri/decibri-win32-x64-msvc`, `@decibri/decibri-darwin-arm64`, `@decibri/decibri-linux-x64-gnu`, `@decibri/decibri-linux-arm64-gnu`) now includes `THIRD-PARTY-NOTICES.md`, the third-party license notices for the ONNX Runtime dynamic libraries the package carries and for the third-party material incorporated into them, with a source-availability statement for the MPL-2.0-licensed Eigen code they contain.
- `models/THIRD-PARTY-NOTICES.md`, carrying the origin, version and license text for the two bundled ONNX models together with the training-data attribution the denoise checkpoint requires. It ships beside the weights it covers. `models/README.md` alongside it documents each model's tensor interface and points at the notice.

### Changed

- ONNX Runtime telemetry is disabled on the environment decibri commits when it initializes the runtime, where it was left at ONNX Runtime's own default of enabled. This covers every path that reaches the runtime: `vad: 'silero'` and the ACE `denoise` stage. 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. 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. The browser build has no ONNX Runtime and is unaffected. No option, event, method or error text changes.

### Fixed

- The bundled Silero VAD model is documented as v6.2, the version that ships. The `model` field on `VadOptions`, the `vad` option on `MicrophoneOptions`, the README and the notice beside the model named v5. Which model file ships is unchanged.
- The Silero VAD tensor specification in `models/README.md` names the tensors the model exposes: `input`, `state` and `sr` in, `output` and `stateN` out. It described a four-input form carrying separate `h` and `c` LSTM tensors.

## [5.4.0] - 2026-08-04

### Added

- `File.save(path, options)` writes the conditioned recording to disk as 16-bit PCM mono at `sampleRate`, in WAV, AIFF or FLAC, off the event loop. The container comes from the path's extension (`.wav`, `.aiff`, `.aif`, `.aifc` or `.flac`) or from `options.format`: decibri reads a file by its content and writes one by its name, and an extension it does not recognise rejects rather than defaulting. `options.compression` sets the FLAC compression level, 0 to 8 with 5 the default. Resolves to a `SaveReport`: `clippedSamples` counts finite samples outside full scale clamped to `[-1.0, 1.0]`, and `nonFiniteSamples` counts non-finite samples that never reach the file (NaN is written as silence, an infinity as full scale). Saving consumes the source and rejects with `FILE_ENGAGED` once the stream is engaged, exactly as `analyze()` does.
- `AudioWriter`, a Writable file sink for PCM audio. Pipe a `File`, or any stream of int16 or float32 PCM bytes, into it and the whole stream is written as one audio file when it finishes, with the same containers, encoding, clamp and non-finite handling as `File.save`, so the two spellings produce the same bytes. Takes `sampleRate` (required), `dtype`, and the `File.save` options; `report` carries the `SaveReport` after `'finish'`.
- Error code `FILE_WRITE_FAILED` on `DecibriError`, for an encoded file the filesystem refuses to write. The message names the path and the underlying I/O failure.
- `File` reads AIFF, AIFF-C and FLAC as well as WAV, and reads WAV in mu-law, A-law, 8-bit and 24-bit as well as the 16-bit PCM and 32-bit float it already read. The container is identified from the file's own bytes, so the path's extension does not decide how a file is read. No option changes: the same `new File(path)` and `File.open(path)` open more files.
- Error code `AUDIO_FORMAT_UNSUPPORTED` on `DecibriError`, for a file in a container or an encoding decibri cannot decode. The message names the container tag, codec or sample width in question.
- Error code `AUDIO_FILE_MALFORMED` on `DecibriError`, for a file whose bytes are not what its format requires where they sit. The message names the byte offset and what was expected there.
- Error code `AUDIO_FILE_TRUNCATED` on `DecibriError`, for a file that ends before the audio it declares. The message names what was needed against what was available.
- `LICENSE`, the full Apache 2.0 text, in the published package.

### Changed

- **BREAKING: a WAV whose declared data length is not a whole number of frames now fails to open**, throwing a `DecibriError` with code `AUDIO_FILE_TRUNCATED`. It previously opened and delivered audio a fraction of a frame short of its own declaration, with nothing to indicate it. A file shorter than its declared length, which is what an interrupted download leaves behind, already failed to open and is unaffected.
- A file declaring no channels carries `AUDIO_FORMAT_UNSUPPORTED` where it carried `WAV_INVALID`.

### Removed

- **BREAKING: the `WAV_INVALID` error code.** `AUDIO_FORMAT_UNSUPPORTED`, `AUDIO_FILE_MALFORMED` and `AUDIO_FILE_TRUNCATED` replace it, splitting what was one failure into the three the caller acts on differently. Code branching on `err.code === 'WAV_INVALID'` must be updated; `catch (e) { if (e instanceof DecibriError) ... }` catches all three unchanged.

## [5.3.0] - 2026-07-31

### Added

- `aec` option on `Microphone`: acoustic echo cancellation on the capture path. The short form names the model (`aec: 'tau'`); the object form takes `{ model, tailMs, suppression, referenceSampleRate }`. It runs before the detector tap, so `vadScore` and the `speech` / `silence` events read the echo-removed signal, and it requires `sampleRate` in 8000 to 48000. Native capture only: the browser entry keeps the platform's own `echoCancellation` constraint.
- `Microphone.pushAecReference(data)`, which queues the far-end audio the canceller cancels against: the same input shapes `Speaker.write` accepts, mono, in played order, at the declared `referenceSampleRate`. It never blocks and never throws on a full queue. Push the reference as it plays rather than ahead of it: the canceller acquires its delay from the reference already standing in front of the consumer's first read, so a push that runs far enough ahead of the capture it echoes into leaves the delay unacquired for the rest of the session, and the capture is then delivered uncancelled with no error reported and `aecMetrics().delaySamples` staying `null`. It does not recover on its own, and how large a lead is too large depends on the rest of the capture chain. The queue holds two seconds at the declared rate, and a push beyond that is dropped and counted in `aecMetrics().referenceDropped`.
- `Microphone.aecMetrics()`, the canceller's transport and cancellation metrics merged with the reference queue's counters, or `null` while echo cancellation is off or capture is not running.

Echo cancellation joins automatic gain control as a stage that can drive captured samples above full scale when the limiter is off, because it subtracts its estimate of the echo from the capture and exceeds the capture wherever that estimate is wrong in phase. The limiter runs after it and bounds the output to its ceiling; the `int16` sample format clamps, so an over-scale sample arrives as full scale rather than wrapping, and a `float32` consumer without the limiter should clamp its own output.

## [5.2.5] - 2026-07-28

### Changed

- The packaged addon is rebuilt from the current core, and the bundle regenerate command in `examples/README.md` reproduces the committed file.

## [5.2.4] - 2026-07-28

### Added

- `'RESAMPLE_AFTER_FLUSH'`, the `code` on the `DecibriError` reported when the resample chain is fed audio after it was flushed. Defensive: decibri stops feeding a flushed chain, so the condition is not reachable through the public surface.
- `'RESAMPLE_FAILED'`, the `code` on the `DecibriError` reported when the resampler returns an error decibri does not recognise. The message forwards the resampler's own text. Defensive: every error the pinned resampler release defines has its own code, so the condition is not reachable through the public surface.

### Changed

- Capture at a device rate other than the requested `sampleRate`, and `File` iteration over a source at another rate, resample faster.

### Fixed

- The resample stage no longer contributes a tail at close when it processed no audio. A capture that ends before a single buffer arrives, and a `File` over an empty source, each emitted that tail as a short run of silent `data` when the input rate and the requested `sampleRate` differed.
- The denoise stage no longer contributes a tail at close when it processed no audio. With `denoise` set, a capture that ends before a single buffer arrives, and a `File` over an empty source, each emitted 512 samples of silence (32 ms at 16 kHz) that the stream never captured, indistinguishable in a `data` event from recorded audio.
- A conditioned capture no longer emits `data` after the stream has closed. A capture whose device rate differed from the requested `sampleRate`, or that had an enhancement step enabled, could emit a final burst of samples that do not follow the recording, at up to full scale, when capture ended on a device error or on `stop()`. An unconditioned capture is unaffected.

## [5.2.3] - 2026-07-27

### Added

- `underrunCount`: a read-only accessor on `Speaker` exposing the core stream's silence-fill counter, in samples. 0 while the producer keeps the queue fed; a rising value means playback is papering over gaps with silence.
- `File.sampleRate` and `File.inputRate`: read-only accessors reporting the rate every delivered chunk carries and the source's own rate, taken from the WAV header or from the `inputRate` passed to `File.buffer`. They differ when the source was resampled, which is the only way a caller learns that it was. Both read for the life of the `File`, including after the source is consumed or closed.

### Changed

- **The browser build rejects a non-numeric `vad` `threshold` or `holdoffMs`, which it accepted before.** A string, or any other non-number, was stored as given; every later score comparison against it evaluated false, so the detector never fired and nothing reported why. A non-numeric `holdoffMs` reached the silence timer directly. Both now raise a `TypeError`, with the Node build's messages: `vad threshold must be a number` and `vad holdoffMs must be a number`.
- **The browser build raises `RangeError` where it raised `TypeError` for an out-of-range `threshold`, `holdoffMs` and `sampleRate`, and carries the Node build's messages.** The messages are now `vad threshold must be between 0 and 1`, `vad holdoffMs must be non-negative` and `sample rate must be between 1000 and 384000`; they previously read `threshold must be between 0 and 1, got <value>`, `holdoffMs must be >= 0, got <value>` and `sample rate must be between 1000 and 384000, got <value>`. Code that catches `TypeError` for these three options, or that matches the old message text, needs updating.

- Every error that reaches a consumer is a `DecibriError` carrying a `code`. The `Microphone` `'error'` event and the `Speaker` `write()` and `end()` paths previously delivered the raw native error, which reported `name: 'Error'` and the native status string `'GenericFailure'` in `code` for a permission denial, a device loss and a failed open alike, and did not satisfy `instanceof DecibriError`.
- A playback device that fails mid-stream is reported as `code: 'DEVICE_FAILED'` on the `Speaker` `'error'` event, or as a rejection from `writeAsync()` and `drainAsync()`, instead of the generic closed-stream error. It surfaces on the next write or drain, so a producer that has stopped writing is not told; `isPlaying` goes false immediately either way.
- `end()` reports a device that failed while the queued tail was still playing, instead of resolving silently. The stream is still stopped and the device released on that path.

- Every failure the core classifies as a `DecibriError` carries its own `code`. Ten failures that reported the generic `'DECIBRI_ERROR'` before now carry a distinct code: `'ALREADY_RUNNING'`, `'STREAM_OPEN_FAILED'`, `'STREAM_START_FAILED'`, `'PERMISSION_DENIED'`, `'MICROPHONE_STREAM_CLOSED'`, `'SPEAKER_STREAM_CLOSED'`, `'RESAMPLE_CONFIG_INVALID'`, `'FILE_READ_FAILED'`, `'WAV_INVALID'` and `'FORK_AFTER_ORT_INIT'`. Every code that existed before keeps its value, and `instanceof DecibriError` is unaffected for these ten. `'DECIBRI_ERROR'` remains the code for a failure decibri has not classified.
- A bad `agc` target, a bad `limiter` ceiling, and a multichannel `channels` value now raise `RangeError` rather than a `DecibriError` with `code: 'DECIBRI_ERROR'`, matching what the wrapper's own checks already raise for the same inputs.

### Fixed

- A capture failure raised as `start()` is called (a failed device open, a denied permission) reaches the `'error'` event as a `DecibriError`, instead of escaping as a raw native error.
- A missing Silero or denoise model file throws an `OrtError` carrying `code: 'VAD_MODEL_LOAD_FAILED'` or `'MODEL_LOAD_FAILED'`, instead of a plain `Error` with no `code` that was not a `DecibriError`. The message is unchanged.
- `Microphone.devices()` and `Speaker.devices()` report an enumeration failure as a `DeviceError` with `code: 'DEVICE_ENUMERATION_FAILED'`, instead of letting the raw native error escape. The same applies to the enumeration performed when a numeric `device` index is resolved.
- A conditioning failure during capture (a denoise or VAD stage that errors mid-stream) reaches the `'error'` event with its own code, instead of ending the stream with no event.
- `File` opens WAV files using the WAVE_FORMAT_EXTENSIBLE container when their samples are 16-bit PCM or 32-bit float; they were rejected as an unsupported encoding.
- `File` opens WAV files whose `data` chunk precedes their format chunk; they were rejected as missing the format chunk.

## [5.2.2] - 2026-07-25

### Added

- The refused-analysis failure carries the dedicated `code` `'FILE_ENGAGED'` on a `DecibriError`.

### Changed

- `File.analyze()` and `File.analyse()` reject once the stream has been engaged, instead of resolving to a report of the part not yet read timed from the start of the recording. Any route that starts the flow counts: `resume()`, a `'data'` or `'readable'` listener, `read()`, `pipe()`, and async iteration. Construct a second `File` to both stream a recording and analyze it.
- A fully streamed `File` rejects an analysis with `'FILE_ENGAGED'` where it previously rejected with `'FILE_CONSUMED'`.

### Fixed

- A `File.analyze()` rejected before the pass begins leaves the `File` usable, so correcting the call and streaming the recording still works. A `File` analyzed without `vad` was previously consumed by the rejected call. A failure during the pass, such as the detector failing to load, still consumes the source.
- Analysing a `File` whose stream has been engaged no longer raises from the stream's own read machinery, where an `'error'` event with no listener terminates the process. The rejection now arrives from the `analyze()` call itself.

## [5.2.1] - 2026-07-24

### Changed

- Documented what `version().decibri` reports: the native core version in Node, and the installed package version in the browser build, which has no native core.
- The consumed-`File` failure now carries the dedicated `code` `'FILE_CONSUMED'` instead of the generic `'DECIBRI_ERROR'`. The `DecibriError` class and the message are unchanged; branch on `err.code` to handle it specifically.
- Flat `vadThreshold` and `vadHoldoff` options on `File` now raise the same migration error the `Microphone` raises, instead of being silently ignored. Pass them on the `vad` config object: `vad: { model: 'silero', threshold: 0.5, holdoffMs: 300 }`.

### Fixed

- Reusing a `File` after `analyze()` now surfaces `File already consumed` on a later iteration, instead of delivering an empty stream. A `File` that ran out of audio, or that you closed, still ends quietly. A `'data'` listener with no `'error'` listener now takes an uncaught exception where it previously saw a quiet `'end'`.

## [5.2.0] - 2026-07-18

### Changed

- Picks up the decibri Rust core 5.2.0 (which adds an underrun sample counter on its `SpeakerStream`) and a refreshed native dependency set (napi 3.9.0, tokio 1.52.3, serde_json 1.0.150) in the shipped platform binaries. No change to the Node.js API surface.

## [5.1.0] - 2026-07-17

### Added

- `File`, an offline source: the same conditioning options as `Microphone` over a WAV file (`new File(path)` synchronously, `await File.open(path)` off the event loop) or a `Float32Array` of samples (`File.buffer(samples, { inputRate })`; a raw `Buffer` of bytes is rejected as ambiguous), delivered as a finite Readable stream of conditioned chunks.
- Whole-file speech analysis: `file.analyze()` (also spelled `file.analyse()`) resolves to a `VadReport` with per-window `scores` (`{ start, end, vadScore, isSpeech }`) and merged speech `segments` (`{ start, end }`), all in seconds of file time. Requires `vad: 'silero'`; a `File` opened without `vad` rejects with `analysis requires VAD`.
- Per-chunk VAD on files: `vadScore` and the `speech` / `silence` events alongside the stream, with the speaking holdoff measured in file time (sample positions) rather than wall-clock time, so processing speed never changes the reported events.

## [5.0.0] - 2026-06-24

### Added

- A device or driver failure during streaming now surfaces as a `DecibriError` with the dedicated `code` `'DEVICE_FAILED'`, and a non-ORT ONNX backend failure as `'ONNX_BACKEND_FAILED'`, instead of the generic `'DECIBRI_ERROR'`. Both are catchable by branching on `err.code`; the message text is unchanged.
- `dcRemoval`: an opt-in `Microphone` option that removes a constant (DC) offset from the captured audio with a one-pole DC-blocking high-pass. Set `true` to enable it; omit it or set `false` to leave it off (the default), which keeps the capture path byte-identical. Pure DSP: no bundled file or download is needed. It runs first in the chain, before denoise, and is same-length with no added latency, so `vadScore` and the `speech` / `silence` events are unaffected.
- `highpass`: an opt-in `Microphone` option that applies a high-pass filter to the captured audio, removing low-frequency rumble below the voice band. The accepted values are the cutoff in Hz, `80` (an 80 Hz second-order Butterworth high-pass) or `100` (a 100 Hz one); omit it to leave the high-pass off (the default), which keeps the capture path full-range. Pure DSP: no bundled file or download is needed. It runs after denoise in the chain and adds no latency, so `vadScore` and the `speech` / `silence` events are unaffected. An out-of-set value throws a `RangeError`. The closed cutoff set is designed to grow (further cutoffs are additive).
- `agc`: an opt-in `Microphone` option that applies automatic gain control to the captured audio, driving the running level toward a target with a smoothed, rate-limited gain. The value is a target level in dBFS (an integer in -40 to -3, typical -18); omit it to leave AGC off (the default), which keeps the level untouched. Pure DSP: no bundled file or download is needed. It runs after the high-pass step and adds no latency, so `vadScore` and the `speech` / `silence` events are unaffected. The opening is delivered at its natural level and reaches the target within tens of milliseconds (no opening window of wrong gain). An out-of-range value throws a `RangeError`.
- `limiter`: an opt-in `Microphone` option that applies a peak limiter to the captured audio, holding the signal at or below a sample-peak ceiling, the safety net that catches a transient the AGC's gain would let through. The value is a ceiling in dBFS (a number in -3.0 to 0.0, typical -1.0); omit it to leave the limiter off (the default), which keeps the level untouched. Pure DSP: no bundled file or download is needed. It runs last in the chain, after the AGC step, and adds no latency, so `vadScore` and the `speech` / `silence` events are unaffected. No output sample ever exceeds the ceiling, even on an instantaneous transient. An out-of-range value throws a `RangeError`.
- `denoise`: an opt-in `Microphone` option that runs a bundled single-channel speech-enhancement model over the captured audio. The only accepted value is `'fastenhancer-t'`; omit it to leave denoise off (the default), which keeps the capture path unchanged. The model ships with the package, so no path or download is needed. The delivered `'data'` chunks carry the enhanced audio; VAD reads the pre-enhancement signal, so `vadScore` and the `speech` / `silence` events are unaffected. An unknown model name throws a `TypeError`. A denoise model-load failure raises a dedicated `MODEL_LOAD_FAILED` `OrtError` via `wrapNativeError`; note that a failure surfaced from `start()` is delivered on the `'error'` event as the raw native error without the decibri `code` attached (as with device-open failures), so match it by message there.

### Changed

- A microphone whose native sample rate differs from the configured `sampleRate` is now resampled to the requested rate in the engine, so capture delivers audio at exactly the configured rate on every device. The device is opened at its native rate and decibri resamples (anti-aliased polyphase) to the target; a device already at the requested rate is unchanged (no resample). Chunk size and the reported rate are unchanged (still `framesPerBuffer * outputChannels * bytesPerSample` at the configured rate). Previously the requested rate was handed to the platform audio backend, which delivered it only when the device or OS could. Breaking for consumers whose device's native rate differs from the configured rate: the delivered samples are now decibri-resampled.
- Microphone capture is now mono only, narrowing a capability that shipped in 4.x. decibri 4.x delivered interleaved multichannel audio when a microphone was opened with `channels` greater than `1`; 5.0 captures mono, and the `channels` option accepts only `1` (the default). A value greater than `1` now throws a `RangeError` (`multichannel capture is not supported; channels must be 1 (mono)`), a clear error rather than a silent substitution of mono. The `channels` option is retained and the emitted `'data'` chunks keep their channel-general shape (`framesPerBuffer * bytesPerSample`, mono today), so multichannel can return later as an additive change rather than a further break: a future release may accept a value greater than `1` by delivering true interleaved multichannel. The intended longer-term multichannel direction is array ingest (consuming several channels internally for processing such as beamforming or array noise reduction while still delivering one conditioned stream), which is distinct from raw multichannel delivery. The default single-channel capture is unchanged. Breaking for consumers that opened a microphone with more than one channel: pass `channels: 1` or omit it.
- Capture now emits a final, possibly-shorter `'data'` chunk at stream close (on `stop()`), carrying the buffered tail, before `'end'`. Steady-state chunks are unchanged (still exactly `framesPerBuffer * channels * bytesPerSample`); only the last chunk before `'end'` may be shorter, and no captured audio is dropped. Internally the binding now re-blocks through the Rust core instead of a binding-side accumulator, and `stop()` defers the stream's end-of-stream signal by one tick so the flushed tail is delivered before `'end'` rather than dropped.
- The `vad` option now accepts a config object `{ model, threshold, holdoffMs }` (a `VadOptions`) alongside the `'silero'` / `'energy'` shorthand, and the flat `vadThreshold` / `vadHoldoff` options are removed. The shorthand is unchanged and keeps the default threshold (0.5 for `'silero'`, 0.01 for `'energy'`) and 300 ms holdoff; only code that tuned the threshold or holdoff migrates, by passing them on the object (`vad: { model: 'silero', threshold: 0.6, holdoffMs: 200 }`). An out-of-range threshold or a negative holdoff throws a `RangeError`, an unknown model a `TypeError`. Passing the removed `vadThreshold` or `vadHoldoff` throws a `TypeError`. The same object form applies to the browser build, where `model` is `'energy'`. Breaking for consumers that set `vadThreshold` or `vadHoldoff`; detection, the `vadScore` getter, and the `speech` / `silence` events are unchanged.

### Fixed

- Energy-mode VAD (`vad: 'energy'`) now reads the signal before the opt-in capture enhancement, so enabling an enhancement step (`denoise`, `highpass`, `agc`, `limiter`) no longer changes `vadScore` or the `speech` / `silence` events, matching the guarantee Silero mode already had. The energy RMS is computed natively on the pre-enhancement signal; previously it was computed on the delivered (post-enhancement) audio, so an enhancement step shifted the score (`agc` most of all, since it drives the delivered level toward its target, which could defeat energy endpointing). Energy detection with no enhancement enabled is unchanged.
- Resampled capture (a device whose native rate differs from the configured `sampleRate`) no longer drops the resampler's group-delay tail at stream close: the final, possibly-shorter `'data'` chunk(s) before `'end'` now carry it, so the complete resampled signal is delivered. A device already at the requested rate (no resample) is unchanged.
- On macOS, the microphone-permission error message now reads "System Settings > Privacy & Security" (the modern macOS wording) instead of the pre-Ventura "System Preferences > Security & Privacy".

## [4.4.2] - 2026-06-12

### Fixed

- Multichannel capture is now downmixed to mono before the Silero VAD, so `vadScore` and the `speech` / `silence` events are correct on devices opened with more than one channel. Previously the interleaved multichannel audio reached the VAD as if it were mono, so it scored garbled input. Only affected explicit multichannel capture with VAD enabled (the default is one channel); emitted audio is unchanged.

### Added

- `overrunCount`: a read-only accessor on `Microphone` exposing the core stream's dropped-buffer counter. 0 while the consumer keeps pace; a rising value means audio is being dropped to bound memory.

## [4.4.1] - 2026-06-11

### Fixed

- Restarting a `DecibriBridge` (stop then start) no longer silently loses Silero VAD: the VAD is reclaimed across the cycle and `vadProbability` is reset on stop.
- A device or driver error during capture now raises the `'error'` event instead of freezing silently with `isOpen` still true. The capture pump uses a timed receive, and `isOpen` reflects the real device state.
- Picks up the Rust core `drain()` drop fix, so a pending `drainAsync()` whose speaker is dropped without `stop()` no longer leaks a libuv worker thread or leaves the promise unsettled.

## [4.4.0] - 2026-06-10

### Changed

- `end()` on the Node `Speaker` now flushes then stops: it plays out the queued audio (drain) and then stops the stream, so `isPlaying` is false after `finish`. This keeps `end()` terminal now that the Rust core makes `drain()` a non-terminal, repeatable flush. As a result, `drainAsync()` is repeatable too (a later `drainAsync()` waits for its own audio instead of returning immediately). `stop()` releases the audio device via the updated core (the binding already dropped the native stream on stop, so no behavior change there). The rest of the API is unchanged.

## [4.3.0] - 2026-06-07

### Fixed

- The Silero VAD never detected speech due to a missing 64-sample audio context required by Silero v5. VAD probabilities now reflect real speech activity. If you consume VAD output, expect meaningful probabilities where previously everything sat near zero.

## [4.2.0] - 2026-05-31

### Added

- Browser `Speaker` for audio playback through the Web Audio API: `start()`, async `write(chunk)`, async `drain()`, `stop()`, and an `isPlaying` getter, with `int16` and `float32` input and resampling from the source rate to the output rate. Playback is started from a user gesture, as browsers require. This adds playback to the browser build alongside the existing browser `Microphone` capture. The release is browser-only and additive: the Node.js API and behavior are unchanged.

## [4.1.0] - 2026-05-31

### Added

- Async factories `Microphone.open(options)` and `Speaker.open(options)`, each returning a `Promise` that resolves to a constructed instance. They perform the blocking open work (the Silero VAD model load for the microphone; device resolution for both) on the native thread pool instead of the event loop, so latency-sensitive callers do not stall during construction. A failed open rejects with the matching error (`RangeError` / `TypeError` for invalid options, `DeviceError` / `OrtError` / `OrtPathError` for native failures). The synchronous `new Microphone(...)` and `new Speaker(...)` constructors are unchanged; the factories are an additive, non-blocking alternative that mirrors the Python `AsyncMicrophone.open()` / `AsyncSpeaker.open()` surface.
- Non-blocking `Speaker.writeAsync(chunk)` and `Speaker.drainAsync()` methods, each returning a `Promise`. They run the blocking parts of playback off the event loop: `writeAsync` performs the backpressure wait when the native playback queue is full, and `drainAsync` performs the wait for queued audio to finish playing. The audio stream stays on its own thread; only the thread-safe sample channel and drain state are used off the event loop. A failed write or drain rejects with the matching error class. The synchronous `write()` / `pipe()` / `end()` Writable interface is unchanged; the async methods are an additive, direct alternative (do not interleave the two paths on one instance).

## [4.0.0] - 2026-05-30

### Changed

- BREAKING: the package now uses named exports. Import with `const { Microphone, Speaker, inputDevices, outputDevices, version } = require('decibri')` instead of the previous single default export.
- BREAKING: the capture class `Decibri` is now `Microphone`, and the playback class `DecibriOutput` is now `Speaker`. The browser capture class is `Microphone` too.
- BREAKING: the sample-encoding option `format` is now `dtype` (the `'int16'` and `'float32'` values are unchanged).
- BREAKING: the device-info types are now `MicrophoneInfo` and `SpeakerInfo` (were `DeviceInfo` and `OutputDeviceInfo`).
- BREAKING: voice activity detection is now a single `vad` option accepting `false`, `'silero'`, or `'energy'`. The `vad: true` plus `vadMode` pair is no longer accepted; pass the mode directly as `vad: 'silero'` or `vad: 'energy'`. The browser runs energy mode only.
- BREAKING: `version()` now returns `{ decibri, audioBackend, binding }` (was `{ decibri, portaudio }`). `audioBackend` replaces the inaccurate `portaudio` name, and `binding` reports the npm package version.

### Added

- Error classes `DecibriError`, `DeviceError`, `OrtError`, and `OrtPathError`, each with a `code` property, for `instanceof` and `err.code` handling.
- `vadScore` getter on the capture class: the Silero probability in `'silero'` mode, the normalized RMS in `'energy'` mode, 0 when disabled.
- Module-level `inputDevices()`, `outputDevices()`, and `version()` free functions, alongside the static `Microphone.devices()` and `Speaker.devices()` methods.

## [3.4.2] - 2026-05-24

### Fixed

- npm package now ships its Node.js-specific README to npmjs.com. The publish workflow previously copied the root README into the npm package directory at publish time, which meant the npmjs.com page for `decibri` showed a generic multi-language overview instead of the Node-focused documentation at `npm/decibri/README.md`. Removing the copy step lets the proper README ship.

## [3.4.1] - 2026-05-23

### Fixed

- Speaker example in `crates/decibri/README.md`: defined `pcm_int16_bytes` as `Vec<u8>` of 48000 zero bytes (1 second of int16 silence at 24kHz mono) so the example has a real value to send. Previously the example referenced `pcm_int16_bytes` without defining it, raising `error[E0425]: cannot find value 'pcm_int16_bytes' in this scope` on copy-paste.
- `npm/decibri/README.md`: replaced the accidental Rust crate README copy with a Node.js and browser focused README. Users on npmjs.com landing on the decibri package were previously shown Rust documentation (`cargo add decibri`, Rust feature flags, etc.) instead of Node.js installation and API documentation. The new README documents the actual Node.js API surface (`Decibri` capture stream, `DecibriOutput` playback stream, events, browser conditional export, VAD modes, device selection by index / name / stable per-host ID).
- `npm/decibri/examples/`: moved three runnable examples (`wav-capture.js`, `websocket-server.js`, `websocket-stream.js`) from the top-level `examples/` directory into the npm package directory, rewrote imports to use `require('decibri')`, and added `examples/` to `package.json` `files` so the examples ship in the published tarball. The Examples section in `npm/decibri/README.md` was previously removed in error during an audit pass that searched only `npm/decibri/examples/` rather than the top-level `examples/` where the files actually lived; this restoration corrects that and ensures `npm install decibri` users actually receive the example files.

## [3.4.0] - 2026-05-02

### Added

- **`OnnxSession` trait abstraction in Rust core.** New internal `pub(crate) trait OnnxSession` inside `crates/decibri/src/onnx.rs` abstracts ONNX Runtime usage behind a backend-agnostic interface. `SileroVad` consumes the trait through `Box<dyn OnnxSession>`. The ORT-backed implementation is the only impl in 3.x.
- `DecibriError::OnnxBackendFailed { backend: &'static str, source: Box<dyn std::error::Error + Send + Sync> }` variant. Reserved on the `#[non_exhaustive]` enum. Additive; existing 8 ORT variants unchanged. `is_ort_path_error` continues to return false on the new variant.
- **`DecibriError::ForkAfterOrtInit { init_pid: u32, current_pid: u32 }` variant + runtime fork detection.** Linux-only failure-mode hardening: a process that forks after a successful `SileroVad::new` previously inherited `static ORT_INIT` flagged as set while the underlying ORT runtime state (allocators, thread pools) was unsafe to reuse, producing silent wrong probabilities, segfaults, or hangs in the child. 3.4.0 stamps the initializing pid into a paired `static ORT_INIT_PID: OnceLock<u32>` inside the same `init_ort_once` success path; a `pub(crate) fn check_pid_for_ort()` runs at the entry of `SileroVad::process()` and returns `Err(ForkAfterOrtInit { init_pid, current_pid })` on pid mismatch. The `Display` message embeds both pids and the two remediation options ("Use `multiprocessing.set_start_method('spawn')` or construct `Microphone(vad='silero')` inside each child process"). Single-check coverage at the outer entry point applies to every inference call without per-window overhead. macOS and Windows are unaffected by fork semantics. Additive on the `#[non_exhaustive]` `DecibriError` enum; mapped through to npm via the napi `_ =>` catch-all (`Status::GenericFailure` carrying `e.to_string()`) and to Python via an explicit `ForkAfterOrtInit(DecibriError)` subclass in the wheel.

### Internal

- `crates/decibri` 3.x public API stays byte-identical (`SileroVad`, `VadConfig`, `VadResult`, `DecibriError` keep their 3.3.x signatures). npm binding, Python binding, browser shim are unchanged.
- `vad::init_ort_once` visibility raised from private to `pub(crate)` so the `onnx` module's inline ORT-backed test can reuse the same process-global init path that `vad` tests use.
- `static ORT_INIT_PID: OnceLock<u32>` paired with the existing `ORT_INIT`, set inside the `OnceLock::get_or_init` callback so the pid stamp is paired with the successful ORT init rather than a speculative pre-init value. `pub(crate) fn check_pid_for_ort()` exposes the comparison to inference call sites within the crate. Linux-only `test_fork_safety.py` tests in the Python wheel pin the behavior end-to-end (gated `skipif sys.platform != "linux"`); they run on CI's `ubuntu-latest` job and skip on other hosts.

## [3.3.2] - 2026-04-26

### Changed

- **`crates/decibri/build.rs` rewrite to use Cargo.toml as primary source.** The previous build script (introduced in 3.3.1 to source the cpal version from a single point of truth) read the workspace `Cargo.lock` via a path traversal that worked in workspace builds but panicked in `cargo publish` verify because the published tarball is flat (`decibri-X.Y.Z/Cargo.toml` and `decibri-X.Y.Z/Cargo.lock` are siblings, not in workspace structure). The traversal landed at `target/Cargo.lock` (nonexistent) and aborted the build. **3.3.1's crates.io publish failed on this defect; 3.3.1 shipped to npm but not to crates.io.** 3.3.2 fixes the build.rs to read `CARGO_MANIFEST_DIR / Cargo.toml` directly, with belt-and-suspenders fallbacks: env-var override (`DECIBRI_CPAL_VERSION`), workspace `Cargo.toml` fallback for `{ workspace = true }` inherit form, hardcoded `"0.17"` constant fallback (with `cargo:warning=`) for unforeseen build contexts. Cargo.toml is unambiguously present in every build context because cargo guarantees `CARGO_MANIFEST_DIR` always points at the manifest's directory.
- **No functional change to user-visible API or error messages.** All 5 message refinements from 3.3.1 (`SampleRateOutOfRange`, `FramesPerBufferOutOfRange`, `AlreadyRunning`, `OrtInitFailed`, `OrtLoadFailed` / `OrtPathInvalid`, `PermissionDenied`) are preserved unchanged.
- **`decibri::CPAL_VERSION` byte-identity preserved across all four cargo-emitted dep forms.** Verified 2026-04-26 by walking through `find_cpal_in_dependencies` + `truncate_to_major_minor` against on-disk Cargo.toml content: Form 1 (`cpal = "0.17"` workspace.dependencies) -> `"0.17"`; Form 2 (`cpal = { version = "0.17", optional = true }` hypothetical inline-table) -> `"0.17"`; Form 3 (`cpal = { workspace = true, optional = true }` source crate) -> workspace fallback -> `"0.17"`; Form 4 (`[dependencies.cpal] version = "0.17"` published normalized) -> `"0.17"`. All four forms produce identical output to the v3.3.1 build.rs's Cargo.lock-resolved truncation because the workspace pin is at major.minor granularity already (`cpal = "0.17"` in `[workspace.dependencies]`).
- **`release-dryrun.yml` extended to exercise `cargo publish -p decibri --dry-run`.** The previous dryrun workflow ran the npm-side build matrix and `verify_pack` packaging gate but had no crates.io publish path coverage. The new step catches build.rs failures and any other publish-time issues that affect the Rust crate publish but not the npm packaging. Closes the procedural gap that allowed 3.3.1's defect to ship through CI green.

### Migration notes

- **Direct Rust crate consumers** of decibri: 3.3.1 was never published to crates.io. crates.io's decibri version history is 3.3.0 -> 3.3.2; 3.3.1 is skipped entirely on this registry. **npm consumers** see the standard 3.3.0 -> 3.3.1 -> 3.3.2 progression (3.3.1 shipped successfully on npm; only the cargo publish step in `release.yml` failed). The asymmetry is documented here for archaeological clarity.
- All 3.3.1 message refinements (per `[3.3.1]` entry above) are preserved in 3.3.2. Consumer-side migration from 3.3.0 to 3.3.2 is the same as 3.3.0 to 3.3.1 from the user-visible API perspective.
- **No npm migration required** for 3.3.1 -> 3.3.2. 3.3.2 publishes a new wheel set with the same user-visible behavior; bump-and-rebuild is sufficient.

## [3.3.1] - 2026-04-25

### Changed

- **Audience-neutral error message pass.** Five `DecibriError` `Display` strings refined to remove cross-binding and platform-specific awkwardness. No variant identity, layout, or count change; no API-surface change. Strict patch release.
  - `SampleRateOutOfRange`: `"sampleRate must be between 1000 and 384000"` -> `"sample rate must be between 1000 and 384000"`. The previous camelCase form was Node-API-targeting and matched no other field-name convention in the rest of the Rust crate (snake_case fields throughout, natural-language rustdoc voice).
  - `FramesPerBufferOutOfRange`: `"framesPerBuffer must be between 64 and 65536"` -> `"frames per buffer must be between 64 and 65536"`. Same rationale.
  - `AlreadyRunning`: `"Decibri is already running. Call stop() first."` -> `"audio stream is already running. Call stop() first."`. Hardcoded class name was misleading when raised from `DecibriOutput`; "audio stream" matches the existing `error.rs` vocabulary ("capture stream", "output stream", "audio stream").
  - `OrtInitFailed`: `"Either pass ort_library_path in VadConfig, ..."` -> `"Either pass ort_library_path when constructing the VAD, ..."`. Drops Rust-internal `VadConfig` type reference; phrasing now correct for Node, Python, and direct-Rust crate consumers alike.
  - `OrtLoadFailed` and `OrtPathInvalid`: `"the bundled ORT may be missing from your platform package"` -> `"the bundled ONNX Runtime may be missing from your installation"`. Drops npm-internal "platform package" phrasing; "installation" works for npm platform packages, Python wheels, and direct-Rust crate use.
  - `PermissionDenied`: macOS-specific `"Enable in System Preferences > Security & Privacy."` replaced with attribute-gated per-platform guidance. macOS hint extended to specifically reference `> Microphone`. Windows hint references the modern `Settings > Privacy & Security > Microphone` UX. Linux hint references PulseAudio / PipeWire (the user-facing audio control layer over cpal's ALSA backend).
- Lockstep updates to `bindings/node/src/lib.rs` (one duplicate `AlreadyRunning` message in the napi `start()` pre-running check), `npm/decibri/src/errors.js` (two prefix-match strings in the typed-error shim), `npm/decibri/src/decibri.js` (two thrown messages in client-side validation; line 101's `channels` message is already natural-language and unchanged), `npm/decibri/src/decibri-output.js` (one thrown message), `npm/decibri/src/browser/decibri-browser.js` (two thrown messages in browser-side validation), `tests/test-ci.js`, `tests/test-api.js`, `tests/test-output.js` (15 hardcoded message-substring assertions).

### Migration notes

Error message wording on shipped `DecibriError` variants has historically been stable across the 3.x line. 3.3.1 explicitly refines five messages to remove audience-leak issues: Node-API-targeted camelCase parameter names, class-name hardcoding in `AlreadyRunning`, a Rust-internal type reference (`VadConfig`) in `OrtInitFailed`, npm-internal phrasing ("platform package") in `OrtLoadFailed` / `OrtPathInvalid`, and macOS-only platform guidance in `PermissionDenied`. 3.3.1 is the consolidation point.

- **Direct Rust crate consumers** asserting on `DecibriError::Display` strings should update assertions for the five refined messages. Type-level matching on `DecibriError` variants is unaffected; only string-text assertions need updating.
- **Node consumers** using `e.message.includes(prefix)` or `e.message.startsWith(prefix)` patterns should update for `sampleRate` -> `sample rate`, `framesPerBuffer` -> `frames per buffer`, and the `AlreadyRunning` message text. Type-level matching on `RangeError` / `TypeError` is unaffected.
- **Python consumers**: the in-development Python wheel consumes `decibri@3.3.1`, so the messages it sees are the corrected forms.

## [3.3.0] - 2026-04-23

Groundwork release for upcoming Python bindings. Adds a stable-ID form for audio device selection (`DeviceSelector::Id`), fixes a long-standing direction bug in `DecibriError::DeviceNotFound` when resolving output devices, exposes both in the Node binding, and extends the reference documentation with a Cargo feature flag guide plus additional crate-level rustdoc. No Node.js or browser API break. Direct Rust crate consumers pattern-matching on `DeviceSelector` or struct-literal-constructing `DeviceInfo` / `OutputDeviceInfo` need to update for the new `#[non_exhaustive]` attributes (see Migration notes below).

### Changed

- `DeviceSelector`, `DeviceInfo`, and `OutputDeviceInfo` are now `#[non_exhaustive]`. External Rust consumers pattern-matching on `DeviceSelector` must add a `_ =>` catch-all arm; consumers constructing `DeviceInfo` or `OutputDeviceInfo` via struct literal from outside the crate must switch to reading fields off instances returned by `enumerate_input_devices` / `enumerate_output_devices`. Field names and display strings are unchanged. This future-proofs the API: subsequent variant or field additions are source-compatible for consumers who include the catch-all.

### Added

- `DeviceSelector::Id(String)` for selecting audio devices by stable per-host identifier (WASAPI endpoint ID on Windows, CoreAudio UID on macOS, ALSA pcm_id on Linux). Unlike `DeviceSelector::Name` (case-insensitive substring) and `DeviceSelector::Index` (positional), `Id` survives across enumerations: display names can shift when other devices are plugged in but per-host IDs do not.
- `id: String` field on `DeviceInfo` and `OutputDeviceInfo`, populated from `cpal::DeviceId`'s `Display` output. Empty string if cpal cannot produce a stable ID for a given device (rare; some host backends cannot assign IDs to every enumerated device). Obtain the ID from these fields and pass to `DeviceSelector::Id`.
- `DecibriError::OutputDeviceNotFound(String)` variant, the output-device equivalent of `DeviceNotFound`. See Fixed below for the motivating bug.
- Node binding accepts `device: { id: string }` as a third form alongside `device: <number>` (index) and `device: <string>` (name substring). The JS wrapper passes it through to Rust unchanged; Rust resolves via cpal's `DeviceId`.
- `DeviceInfoJs.id` and `OutputDeviceInfoJs.id` fields on the Node binding types, mirroring the Rust `DeviceInfo.id` / `OutputDeviceInfo.id` additions. Visible in the auto-regenerated `npm/decibri/index.d.ts` and in the hand-authored `npm/decibri/src/decibri.d.ts`.
- `npm/decibri/src/errors.js` helper that re-wraps plain `Error` instances thrown from the native boundary as `TypeError` or `RangeError`, matching the JS wrapper's existing validation error classes. Brought in by `decibri.js` and `decibri-output.js` constructors to align Rust-originated errors with the JS wrapper's error class contract. Triggered only by code paths that reach Rust's `to_napi_error` (currently only `device: { id: ... }` selection); all other validation paths continue to throw from the JS wrapper directly with no behavior change.
- `docs/features.md`: comprehensive Cargo feature reference covering ORT distribution mode tradeoffs, execution-provider features, binding-author guidance, and feature compatibility constraints. Targeted at Rust crate consumers and FFI binding authors; lib.rs rustdoc now links to it for deep-dive reference.

### Fixed

- `DecibriError::DeviceNotFound`'s display string hardcoded "No audio input device found matching..." regardless of whether the lookup was against input or output devices. Direct Rust consumers and the new `device: { id: ... }` Node path now receive the correct direction via `DecibriError::OutputDeviceNotFound` for output-device misses. No change visible through the Node binding for existing name- and index-based lookups: those are intercepted by the JS wrapper and always threw direction-correct messages from JS before reaching Rust.

### Internal

- `DeviceDirection` trait gains a `not_found_error(String) -> DecibriError` method so `resolve_device_generic`'s `Name` and `Id` arms produce direction-correct errors via the `Input` / `Output` impls.
- Unit tests for `Arc<Mutex<CaptureStream>>` confirming the wrapping is `Send + Sync` (compile-time assertion) and serializes concurrent access across two threads (runtime test with `Barrier`). Documents the wrapping strategy the Python binding will apply to share `!Sync` capture streams across Python threads.
- Crate-level rustdoc additions in `lib.rs`: a section on ORT error construction FFI side effects (the `ortsys![CreateStatus]` dylib-load trigger that motivates the `OrtPathInvalid` split from `OrtLoadFailed`) and a section on fork safety (guidance for Python `multiprocessing` consumers to use `spawn` start method).
- `lib.rs` rustdoc "Feature flags" section cross-references `docs/features.md` for consumers wanting the deep-dive reference.
- Em-dash cleanup across 19 code locations in `lib.rs`, `capture.rs`, `output.rs`, `vad.rs`, `error.rs`, `vad_integration.rs`, and `vad_ort_load_failure.rs`. Per CLAUDE.md, the codebase forbids em dashes; these were pre-existing violations.
- CLAUDE.md corrections: validation-gate commands updated to the canonical set (`cargo clippy --workspace -- -D warnings`, `cargo fmt --all -- --check`, `cargo test-decibri`); stale `## [3.0.0] - Unreleased` reference replaced with a template placeholder.
- `ort` crate version unchanged at `2.0.0-rc.12`.
- Bundled ONNX Runtime version unchanged at `1.24.4`.
- No Node.js API signatures, event names, or error messages changed.
- TypeScript declaration files in both `npm/decibri/index.d.ts` (auto-regenerated) and `npm/decibri/src/decibri.d.ts` (hand-authored) updated for the new `id` field and extended `device` option type.

### Migration notes for direct Rust crate consumers

- Exhaustive matches on `DeviceSelector` will stop compiling. Add a `_ =>` catch-all arm. Display strings and existing variant names are unchanged; code using `to_string()` or only constructing variants (not matching them) continues to work unaffected.
- Struct literal construction of `DeviceInfo` and `OutputDeviceInfo` from outside the `decibri` crate will stop compiling (added `#[non_exhaustive]`, added `id: String` field). External consumers should read these structs from `enumerate_input_devices()` / `enumerate_output_devices()` rather than constructing them directly.
- Consumers matching specifically on `DecibriError::DeviceNotFound` for output-device misses should now also match `DecibriError::OutputDeviceNotFound`. The convenience predicate `DecibriError::is_ort_path_error` remains unchanged and already groups only ORT-path variants.
- MSRV unchanged at rustc 1.88.

## [3.2.0] - 2026-04-22

Refactor release. Public Node.js and browser APIs are unchanged. Direct
Rust crate consumers get a structured `DecibriError` taxonomy with full
error-chain preservation, a new stable FFI-ready stream-reading API on
`CaptureStream`, a declared minimum-supported-Rust-version, and a Windows
hang fix in VAD initialization. See migration notes below.

### Changed

- `DecibriError` is now `#[non_exhaustive]` and the `Other(String)`
  catch-all variant has been removed. All previous `Other(...)` failures
  now have dedicated typed variants: `DeviceEnumerationFailed`,
  `CaptureStreamClosed`, `OutputStreamClosed`, `VadSampleRateUnsupported`,
  `VadThresholdOutOfRange`, `OrtInitFailed`, `OrtLoadFailed`,
  `OrtPathInvalid`, `OrtSessionBuildFailed`, `OrtThreadsConfigFailed`,
  `VadModelLoadFailed`, `OrtInferenceFailed`, `OrtTensorCreateFailed`,
  `OrtTensorExtractFailed`. Path-carrying variants use `PathBuf`; ORT
  variants carry `#[source] ort::Error` so `error.source()` walks the
  error chain. Display strings (and therefore `error.message` in Node
  and `str(exception)` in future Python bindings) are byte-identical
  to 3.1.0.
- `VadConfig` now has a public `validate()` method returning
  `Result<usize, DecibriError>` where the `usize` is the Silero VAD
  `window_size` for the validated sample rate. Called automatically by
  `SileroVad::new`; can be called explicitly to fail-fast before paying
  ORT-initialization cost.
- Device enumeration and resolution logic in `crates/decibri/src/device.rs`
  consolidated into a shared direction-generic implementation (input and
  output share code paths via an internal `DeviceDirection` trait). No
  public API change.
- Workspace minimum-supported Rust version (MSRV) declared at rustc 1.88,
  forced by `ort 2.0.0-rc.12` which requires `edition = "2024"`.

### Added

- `CaptureStream::try_next_chunk()`: non-blocking read, returns
  `Result<Option<AudioChunk>, DecibriError>` with a three-state return
  (`Some` chunk / `None` if no data yet / `Err(CaptureStreamClosed)` if
  terminal). Declared stable across 3.x as part of decibri's canonical
  FFI-consumer surface.
- `CaptureStream::next_chunk(timeout: Option<Duration>)`: blocking read
  with optional timeout, same three-state return shape. Declared stable
  across 3.x. Concurrent `stop()` unblocks a waiter within approximately
  20 ms via internal polling.
- `DecibriError::is_ort_path_error()`: helper that returns true for both
  `OrtLoadFailed` and `OrtPathInvalid`. Consumers handling path-level ORT
  failures should match this rather than enumerating both variants
  manually. The split between the two variants is a mechanical necessity
  (constructing `ort::Error` under `ort-load-dynamic` triggers an ORT C
  API call and would reintroduce the Windows hang).
- Crate-level rustdoc on `decibri`'s `lib.rs` covering capabilities,
  feature flags, ORT distribution modes, an end-to-end capture-plus-VAD
  example, the process-global ORT initialization constraint, thread-safety
  summary, and the 3.x FFI-surface stability contract.
- Rust integration tests for the VAD / ORT pipeline in
  `crates/decibri/tests/vad_integration.rs` (happy path, model-not-found,
  config validation, end-to-end silence inference) and
  `crates/decibri/tests/vad_ort_load_failure.rs` (load-failure path
  isolation, feature-gated to `ort-load-dynamic`). All CI-safe; no audio
  hardware required.
- Unit tests for `try_next_chunk` / `next_chunk` semantics (7 tests
  covering empty-queue, chunk-available, buffered-flush-before-closed,
  timeout, blocking-until-arrival, and polling-interval-correctness after
  concurrent `stop()`).
- Pre-publish packaging gate in `.github/workflows/release.yml`:
  ports the `verify_pack` function from `release-dryrun.yml` to run
  `npm pack --dry-run` against all 5 packages before any `npm publish`
  step. Closes the dryrun-skip honor-system gap: release-dryrun.yml
  previously caught packaging bugs but only if it was actually run before
  tagging.

### Fixed

- Windows hang in VAD initialization: passing a nonexistent or
  directory path as `VadConfig::ort_library_path` (or via the Node
  binding's `ortLibraryPath`) caused `ort::init_from` to hang
  indefinitely on Windows against pyke/ort 2.0.0-rc.12 with
  onnxruntime 1.24.4. `init_ort_once` now performs a filesystem-level
  `Path::is_file()` pre-check before handing the path to ORT, returning
  `DecibriError::OrtPathInvalid` immediately for any path that fails the
  check. The pre-check never touches ORT symbols, so it cannot itself
  trigger the dylib load it is designed to prevent.

### Migration notes for direct Rust crate consumers

- Matches against `DecibriError::Other(msg)` will stop compiling. Replace
  with matches against the specific new variants. The `error.message`
  text is unchanged; code using `.to_string()` rather than pattern
  matching continues to work unaffected.
- `DecibriError` is now `#[non_exhaustive]`: match expressions against
  it must include a `_ =>` catch-all arm. New variants added in future
  releases are non-breaking under this constraint.
- Consumers handling "ORT path failed" should prefer
  `err.is_ort_path_error()` or match both `OrtLoadFailed { .. }` and
  `OrtPathInvalid { .. }`. The two variants represent the same
  conceptual failure mode split for FFI-side-effect reasons.
- MSRV raised to rustc 1.88 (from effectively-unpinned in 3.1.x). This
  is forced by `ort 2.0.0-rc.12` declaring `edition = "2024"`. Projects
  on older rustc cannot build decibri 3.2.0 directly; stay on 3.1.x or
  upgrade the toolchain.
- `VadConfig::validate()` was new in 3.2.0 (no 3.1.x public signature
  to break) and has the final form `Result<usize, DecibriError>`. If you
  only need pass/fail, call `.is_ok()` or `.map(|_| ())`.

Node.js and browser consumers: no API or behaviour change. `error.message`
text from the native addon is byte-identical to 3.1.0 (verified against
the 38-assertion CI suite).

### Internal

- `ort` crate version unchanged at `2.0.0-rc.12`.
- Bundled ONNX Runtime version unchanged at `1.24.4`.
- Node binding error mapping at `bindings/node/src/lib.rs::to_napi_error`
  explicitly enumerates every variant (compiler-enforced exhaustive
  during the refactor by temporarily removing `#[non_exhaustive]` and
  the `_ =>` arm; both restored). New variants added upstream fall
  through to `GenericFailure` at runtime rather than failing to compile.
- `CaptureStream._stream` field type changed from `cpal::Stream` to
  `Option<cpal::Stream>` purely to enable unit-test construction without
  a real audio device. Production always stores `Some(stream)`; drop
  semantics are identical.
- docs.rs metadata added to target all 4 production platforms (Linux x64/ARM64,
  macOS ARM64, Windows x64) for full platform-specific rustdoc rendering.
  Aligns with the docs.rs change effective 2026-05-01 (which builds fewer
  targets by default).

## [3.1.0] - 2026-04-22

Internal rearchitecture: ONNX Runtime is now loaded dynamically at runtime
instead of embedded statically at build time. Public Node.js and browser
APIs are unchanged. Direct Rust crate consumers see a behaviour change;
see migration notes below.

### Changed

- ORT integration switched from `ort/download-binaries` to `ort/load-dynamic`.
  npm platform packages now bundle the ONNX Runtime shared library alongside
  the native addon. No change to `npm install decibri` workflow or to
  Decibri construction.
- Silero VAD now loads ONNX Runtime dynamically from the bundled shared
  library inside the installed platform package. Path resolution is
  automatic; the `ORT_DYLIB_PATH` environment variable is honoured as a
  developer escape hatch when set before Node.js starts.
- Bundled ONNX Runtime pinned to 1.24.4 (matches `ort 2.0.0-rc.12`'s
  `api-24` ABI target).
- Native addon size reduced from ~20 MB (3.0.x) to ~817 KB on Windows x64.
  ORT runtime now shipped separately as a ~13.5 MB bundled dylib inside
  the platform package. Net platform package size roughly unchanged, with
  better separation of concerns.
- Error message text in `decibri-*` error variants has been normalized
  for style consistency (em-dashes replaced with sentence splits). The
  error message prefixes (e.g., `"device index out of range"`) are
  unchanged, so consumers matching those prefixes are unaffected.
  Consumers matching full error message strings may need to update.

### Added

- Cargo features on the `decibri` crate for direct Rust consumers:
  `ort-load-dynamic` (default), `ort-download-binaries` (opt-in, restores
  3.0.x zero-config build behaviour), and execution-provider passthroughs
  `coreml`, `cuda`, `directml`, `rocm` (off by default).
- Rust unit tests for device enumeration (`is_default` correctness under
  duplicate display names).
- Release pipeline hardening: version-match preflight, `curl --retry` on
  ORT downloads, macOS code-signature verification, Windows DLL imports
  inspection, stage-and-verify packaging validation in release-dryrun.
- Upstream dependency monitoring for Microsoft's ONNX Runtime releases
  (notification-only; guards against ABI mismatch upgrades).

### Fixed

- Issue #14: when two audio devices share a display name (e.g. two USB
  microphones both reporting "Microphone"), both were previously marked
  `is_default: true`. Now uses cpal 0.17's `Device::id()` for stable
  per-host device identity (WASAPI endpoint ID, CoreAudio UID, ALSA
  PCM ID). Fix applies to both input and output device enumeration.

### Migration notes for direct Rust crate consumers

If you depend on `decibri` directly via `cargo add decibri` and use the
`vad` feature:

- **Option 1 (recommended for zero-config builds):** pin with
  `--features ort-download-binaries` on the dependency, which restores
  the 3.0.x behaviour (ORT downloaded at build time, embedded statically).
- **Option 2 (recommended for production deployments):** keep default
  features and either set `ORT_DYLIB_PATH=/path/to/libonnxruntime.so`
  before first use, or call `ort::init_from(path).commit()` at startup,
  or pass `ort_library_path` on `VadConfig` when constructing `SileroVad`.

Known limitation: ONNX Runtime is initialized once per process. Multiple
`Decibri`/`SileroVad` instances constructed with different `ort_library_path`
values will silently use the first-constructed instance's path. Pick one
path and use it consistently.

Direct consumers using only `capture`, `output`, `denoise`, or `gain`
features (no `vad`) are unaffected.

### Internal

- `ort` crate version unchanged at `2.0.0-rc.12`.
- `tls-native` ORT feature removed (was only required for `download-binaries`'
  HTTPS fetch).

## [3.0.0] - 2026-04-11

Complete rewrite from C++ (PortAudio) to Rust (cpal). One unified package for Node.js and browsers. Version jumps from 1.0.0 to 3.0.0 because this release replaces both `decibri` (v1) and `decibri-web` (v0.1.1). v2.x was never published.

### Changed

- Complete rewrite from C++ (PortAudio) to Rust (cpal)
- Native addon built with napi-rs (replaces node-gyp / prebuildify)
- JS API unchanged: drop-in replacement for v1.x consumers (verified against the in-house consumer surface)

### Added

- Audio output: `DecibriOutput` class (`Writable` stream, speaker playback)
- Browser support: unified package with conditional exports, AudioWorklet capture
- Silero VAD: ML-based voice activity detection via `vadMode: 'silero'`
- Full duplex: `mic.pipe(speaker)` for simultaneous capture and playback
- `format: 'float32'` output support alongside `'int16'`
- Output device enumeration: `DecibriOutput.devices()`
- TypeScript declarations for all APIs (Node.js capture, Node.js output, browser)
- `crates.io` publication as a Rust crate

### Removed

- PortAudio dependency (replaced by cpal)
- node-gyp / prebuildify build system (replaced by napi-rs)
- Source build fallback (Rust binaries are self-contained)

### Deprecated

- `decibri-web` npm package (use `decibri` with the browser conditional export instead)

## [1.0.0] - 2025-06-15

Initial release. C++ native addon wrapping PortAudio with pre-built binaries.

- Microphone capture as a Node.js `Readable` stream
- Pre-built binaries for Windows x64, macOS ARM64, Linux x64, Linux ARM64
- Energy-based voice activity detection (`vad`, `vadThreshold`, `vadHoldoff`)
- Device enumeration and selection by index or name
- Int16 PCM output (little-endian)
- Source build fallback via node-gyp

[3.2.0]: https://github.com/decibri/decibri/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/decibri/decibri/compare/v3.0.0...v3.1.0
[3.0.0]: https://github.com/decibri/decibri/compare/v1.0.0...v3.0.0
