---
title: "API overview"
url: https://recursiveai.org/oneaudio/docs/api/
description: "The whole oneaudio API in one page: the one-call layer, decoders, MP3 and FLAC details, encoders, Opus, codec registration, limits, resampling, loudness, the mixer, and device playback and capture."
version: "1.2.1"
source: https://github.com/recursive-ai-oss/oneaudio-csharp/blob/71a88ad0efc3995919a921e2fd31747e7b6fde1b/docs/api.md
---

# API overview

Everything is in namespace `OneAudio`. Units: a **frame** is one sample per channel; a buffer of N frames holds N x channels interleaved samples. Gains are linear unless a name says dB.

## Happy path

`Audio` (static) and `AudioKit` (the same calls over a registry you choose) cover the common jobs in one or two calls. They are built on the low-level API below and return its types (`IAudioDecoder`, `AudioClip`, `VoiceHandle`, `LoudnessResult`), so you can drop down at any point. Every default is tunable through an options record. Failures are the library's typed exceptions (`OneAudioException` family), argument exceptions, I/O exceptions and `OperationCanceledException`.

- **Open:** `Audio.Open(path | stream, limits)` returns a streaming `IAudioDecoder`; the container is detected from the bytes.
- **Load:** `Audio.Load(path | stream, LoadOptions?)` decodes everything into an `AudioClip` (`Format`, `Samples`, `Frames`). `LoadOptions`: `SampleRate` and `Channels` (0 = keep) convert on the way, `ResamplerQuality` (default Polyphase), `MaxFrames` (default 16 min at 48 kHz) and `Limits`. Without conversion the samples equal `AudioClip.FromDecoder` over the same decoder. Mono goes to stereo as a copy; other channel changes use the mixer's speaker-folding rules.
- **Play:** `mixer.Play(path, PlayOptions?)` loads the file as a clip at the mixer's rate and starts a voice (short effects); `mixer.Play(path, loop: true)`. `mixer.PlayStream(path, PlayOptions?)` or `PlayStream(path, loop: true)` streams a file from a shared decode thread and returns a `StreamingVoice` (`Voice`, `Source`) to dispose when done. `PlayOptions`: `Gain`, `Pan`, `Loop`, `LoopStartFrame`/`LoopEndFrame`, `Bus`, `Priority`, `Pitch`, `FadeInFrames`, `Limits`, `MaxClipFrames`, `StreamBufferFrames`. `Audio.CreateMixer(sampleRate = 48000, channels = 2, maxVoices = 64)` builds a mixer with a master limiter at -1 dB. Call these from a control thread, not the render callback.
- **Convert:** `Audio.Transcode(inputPath, outputPath, TranscodeOptions?)` takes the output format from the extension (`.wav`, `.flac`, `.opus`/`.ogg`) or `TranscodeOptions.Format`; `Transcode(stream, stream, options)` needs `Format`. Options add `SampleRate`/`Channels` conversion and the encoder settings (`Wav`, `Flac`, `Opus`). It streams block by block; a lossless source written as WAV or FLAC without conversion is bit-exact. A failed file conversion deletes the partial output.
- **Resample:** `Audio.Resample(samples, channels, fromRate, toRate, quality)` and `Audio.Resample(clip, toRate)`; the result has exactly `round(frames x ratio)` frames and is delay-compensated. Ratios from 1/64 to 64.
- **Loudness:** `Audio.Measure(path | stream | clip, truePeak)` returns the same `LoudnessResult` as `LoudnessAnalyzer`. `Audio.Normalize(path, outputPath, NormalizeOptions?)` writes a copy at the target (default `-14` LUFS, true-peak ceiling `-1` dBTP; both in `NormalizeOptions`), measuring after any conversion and decoding twice rather than holding audio in memory. `Audio.Normalize(Span<float>, format)` scales in place and `Audio.Normalize(clip, out result)` returns a new clip. The result says the loudness before, the gain, and whether the ceiling limited it. Silence is left alone.
- **Trimming:** `Audio.Kit` (used by every `Audio` call that reads a file and by the `mixer.Play` extensions) registers all codecs. A trimmed application builds `new AudioKit(new AudioCodecRegistry().AddWav())` and calls the same methods on it, or uses the low-level API; `Audio.Resample`, `Audio.Measure(clip)`, `Audio.Normalize(samples, ...)` and `Audio.CreateMixer` keep no codec. See the README for measured sizes.

## Formats

- `AudioFormat(SampleRate, Channels, ChannelMask, NativeType, BitsPerSample)` describes decoder output. `ChannelMask` uses the WAVE_FORMAT_EXTENSIBLE speaker bits.
- `NativeSampleType` says how the source stored samples (`PcmUnsigned8`, `PcmSigned16/24/32`, `IeeeFloat32/64`, or `Lossy`).

## Decoding

`IAudioDecoder` is a pull-model streaming decoder:

- `Format`, `LengthFrames` (-1 if unknown), `PositionFrames`.
- `int Read(Span<float>)` returns frames of interleaved float, never clipped; 0 means end. Short reads are normal.
- `int ReadInteger(Span<int>)` returns exact native integers for lossless PCM sources (`HasNativeIntegers`); 8-bit is re-centred to -128..127.
- `CanSeek` / `Seek(long frame)`: sample-accurate. Non-seekable sources (a pipe or socket) throw `NotSupportedException` from `Seek`.
- Decoders allocate nothing per read and are used from one thread at a time.

`AudioDecoder.Open(Stream, AudioCodecRegistry, AudioLimits?, leaveOpen, CancellationToken)` detects the container from the first 512 bytes and returns a decoder; `OpenFile` does the same for a path. Non-seekable streams are supported through a replay buffer.

## MP3

`new AudioCodecRegistry().AddMp3()` registers MPEG-1, MPEG-2 and MPEG-2.5 Layer III (`Mp3Format`, decoder type `Mp3Decoder`). Layers I and II raise `UnsupportedAudioFormatException`.

- **Files:** ID3v2 tags in front are skipped (larger than `AudioLimits.MaxMetadataBytes` is refused before anything is read); ID3v1, APE and Lyrics3 tags at the end are ignored. Fixed, variable and free-format bitrates; mono, stereo, joint (mid/side and intensity) and dual channel. The output channel count is the first frame's; a later mono/stereo switch is repeated or averaged to fit.
- **Gapless:** a Xing/Info header with the LAME extension (also written by ffmpeg) gives the encoder delay and padding; the decoder skips `delay + 529` samples and `LengthFrames` is `frames * samplesPerFrame - delay - padding`, which is the length of the source. The header frame is never decoded. Files without it are decoded in full and nothing is trimmed (a VBRI or Xing header without the extension only supplies the frame count).
- **Seeking:** exact and bit-identical to a sequential decode. The first seek away from the start of a seekable stream makes one pass over the frame headers (untagged files do it at open, to learn their length); decoding then resumes up to 48 frames before the target to rebuild the bit reservoir and filterbank memory. Xing and VBRI seek tables are not used.
- **Damaged data:** frames that cannot be decoded (missing reservoir after a cut, bad Huffman data) come out as silence and decoding goes on; lost sync is searched for with a two-header check.

## FLAC

`new AudioCodecRegistry().AddOgg().AddFlac()` opens `.flac` files and Ogg FLAC. `ReadInteger` returns the stream's exact samples left-shifted to the whole-byte container width in `Format.BitsPerSample` (a 12-bit stream reads as 16-bit values); `Read` scales by the stream's own depth, so full scale is +-1. The concrete `FlacDecoder` adds `BitsPerSample` (true depth), `Vendor`/`Comments`, `Md5` and `VerifyMd5()` (decodes the whole file and compares with the STREAMINFO signature; the position is restored). Frames are CRC-checked; a frame that changes the format mid-stream, or contradicts STREAMINFO (block size, frame size, sample count), is rejected with `AudioFormatException` after the earlier frames have been delivered. Chained Ogg FLAC follows the same run semantics as Vorbis and Opus (`LinkCount`, `GetLinkFormat`, `SelectRun`).

## Encoding

`WavEncoder` and `FlacEncoder` write files from interleaved frames. Both take a `Stream`, an `AudioFormat` (rate, channels, layout), a settings record and `leaveOpen`; both offer `Write(ReadOnlySpan<float>)`, `WriteInteger(ReadOnlySpan<int>)`, `Finish()`, `Dispose()` (which finishes if you have not) and a static `Encode(IAudioDecoder, Stream, settings, leaveOpen, CancellationToken)` that copies a whole decoder. Settings are checked in the constructor, nothing is allocated per write after the first call, and the same input and settings give the same bytes. Neither is thread-safe.

- **Float to integer:** a sample is scaled by 2^(bits-1), rounded to nearest (ties to even) and clipped to the type's range; NaN becomes 0. `Dither = true` adds one LSB of triangular noise from a seeded generator (`DitherSeed`) first. `WriteInteger` is exact and throws `ArgumentOutOfRangeException` for a value outside the stored depth (8-bit values are signed, -128 to 127).
- **WAV** (`WavEncoderSettings`): integer PCM 8/16/24/32 and float 32/64 (`SampleType`, defaulting to the format's own type, or 16-bit for lossy sources). `WAVE_FORMAT_EXTENSIBLE` with the channel mask is used for more than two channels or more than 16 bits (`Extensible` overrides); float files carry a `fact` chunk. A seekable output gets its sizes patched by `Finish` and is converted to RF64 if the data outgrows RIFF (a 28-byte `JUNK` chunk in the header reserves the space); `ForceRf64` writes RF64 from the start. A non-seekable output cannot be patched: give `TotalFrames` for exact sizes, otherwise the sizes are the "unknown" value 0xFFFFFFFF, which readers (ours included) take to mean "to the end of the stream"; RF64 on such a stream needs `TotalFrames`.
- **FLAC** (`FlacEncoderSettings`): any depth from 4 to 32 bits, 1 to 8 channels, any rate up to 1048575 Hz. `CompressionLevel` 0 to 8 (default 5) trades speed for size and stays inside the streamable subset unless `Subset = false`. Block size, LPC order, partition order and coefficient precision can be overridden. The output starts with STREAMINFO, a VORBIS_COMMENT block (`Comments`, `Vendor`) and, on a seekable stream, a SEEKTABLE of `SeekPoints` slots; `Finish` rewrites STREAMINFO with the length, frame sizes and MD5 and fills the table. A non-seekable stream gets STREAMINFO once, with `TotalFrames` (or 0), no MD5 and no seek table. Two-channel input is stored as left/side, right/side or mid/side when that is smaller (not for 32-bit samples). Ogg FLAC is not written.

## Containers, codecs, registration

- `IContainerFormat` recognises a format (`Matches`) and opens it. Containers that carry compressed packets expose them as an `IPacketSource` (`TryReadPacket`, `SeekToFrame`, `LengthFrames`) and look up the codec by `CodecId` in the registry. Raw-PCM containers (WAV) decode directly.
- `IAudioCodec.CreateDecoder(IPacketSource, AudioLimits)` turns packets into an `IAudioDecoder`.
- `AudioCodecRegistry` starts empty: `new AudioCodecRegistry().AddWav()` and, as they arrive, further `AddXxx()` extension methods. Unused formats are trimmed away.

## Opus encoding

`OpusEncoder` (in `OneAudio.Opus`) turns interleaved 48 kHz `float` (or `short`) PCM into Opus packets: `Encode(pcm, frameSize, packet)` takes 120, 240, 480, 960, 1920 or 2880 samples per channel (2.5 to 60 ms; 40 and 60 ms are two or three 20 ms frames in one packet) and returns the packet length; `FinalRange` is the range coder state the decoder reproduces; `LookaheadSamples` (312) is the Ogg pre-skip. `OpusEncoderSettings` holds `Channels` (1 or 2), `Application` (`Audio`, `Voip`, `RestrictedLowDelay`), `BitrateBps` (`AutoBitrate`, `MaxBitrate` or 500 to 300000 per channel), `BitrateMode` (`Vbr` default, `ConstrainedVbr`, `Cbr`), `Complexity` 0 to 10 (default 10), `Bandwidth` / `MaxBandwidth`, `Signal`, `ForceChannels`, `PacketLossPercent`, `InBandFec` and `Dtx`. Bad values throw `ArgumentOutOfRangeException`; wrong buffer sizes or frame sizes throw `ArgumentException`. Input is clipped at +-2.0 (NaN becomes silence); nothing is dithered. Encoding allocates nothing after construction and gives identical bytes for identical input on a platform.

**Modes:** like the reference encoder, `OpusEncoder` chooses SILK, hybrid (SILK plus CELT above 8 kHz) or CELT per packet from the bitrate, frame size, application and signal hint, and the bandwidth from the bitrate; switching modes sends a short redundant CELT frame and warms up the other layer. `Bandwidth` and `MaxBandwidth` pin them. `InBandFec` (with `PacketLossPercent` above 0) adds a coarse copy of the previous frame to SILK packets; `Dtx` makes silence cost a one-byte packet (the decoder conceals). Settings can be replaced between packets (`Settings = ...`; channels and application are fixed). Only 48 kHz input is taken at packet level (the file writer resamples). With identical settings and 16-bit-exact input the packets are byte-identical to the RFC 6716 reference encoder on every configuration tried (see the oracle tests); float input follows the same code path as 16-bit input. In VBR the mean rate lands within about 10% of the target on continuous material; speech with long pauses comes out lower (the same as the reference encoder).

`OpusMultistreamEncoder` codes 1 to 8 channels with channel mapping family 1 (coupled and mono streams, self-delimited framing for all but the last stream; the bitrate is split by channel count; input in Vorbis channel order).

`OpusFileWriter` writes an Ogg Opus file (RFC 7845) to a `Stream`: `new OpusFileWriter(stream, new OpusFileWriterSettings { Channels, InputSampleRate, Encoder, FrameMilliseconds, Comments })`, `Write(float[])` any number of times, `Finish()` or `Dispose()`. Input that is not 48 kHz goes through the polyphase resampler; more than two channels are taken in the standard speaker order (a WAV file's) and written as family 1. OpusHead carries the pre-skip and the original input rate, pages hold at most one second, and the last granule position trims the padding so the decoded length is exactly the number of 48 kHz samples written. `Write` allocates nothing.

## Errors and limits

`OneAudioException` is the base of everything the library says about data: `AudioFormatException` (malformed), `UnsupportedAudioFormatException` (valid but not supported / no codec registered), `AudioLimitExceededException` (names the limit, the maximum and the request). Besides these, public methods throw only argument exceptions, `NotSupportedException`, `ObjectDisposedException`, stream I/O exceptions and `OperationCanceledException`.

`AudioLimits` (immutable record, `AudioLimits.Default`) caps channels, sample rate, packet bytes, frame size, metadata bytes, codec setup bytes and container elements. Sizes read from input are checked before the allocation they guard.

## Resampling

`AudioResampler.Create(channels, quality, ratio, options?)` (or `Create(inRate, outRate, channels, quality, options?)`) returns an `IResampler` for interleaved float audio: `Channels`, `Quality`, `Ratio` / `SetRatio`, `LatencyFrames`, `MaxOutputFrames`, `Process(input, output, out used, out written)`, `Flush`, `Reset`. Everything is allocated at creation; `Process`, `SetRatio`, `Flush` and `Reset` allocate nothing. Output is identical however the input is split into blocks.

- **Modes:** `Linear` (two-tap, cheapest), `PolyphaseFast` (44-tap Kaiser-windowed sinc, flat to 0.8 of the lower Nyquist frequency, about 75 dB alias rejection) and `Polyphase` (144-tap, flat within 0.05 dB to 0.9 of the lower Nyquist frequency, 100 dB+ rejection, THD+N below -100 dB). Polyphase blends two of 512 coefficient phases per output frame; below ratio 1 the filter stretches so its cutoff follows the output Nyquist frequency.
- **Time alignment:** output frame m is the signal at input time m / ratio; filter delay is compensated. `LatencyFrames` is the look-ahead in output frames: after N input frames about N * ratio - LatencyFrames output frames exist. `Flush` returns the rest (call until it returns 0).
- **Ratio:** `ResamplerOptions.MinRatio` (default 0.25) and `MaxRatio` (default 16) bound `SetRatio` and size the preallocated state (polyphase memory grows as 1 / MinRatio). `SetRatio` is glitch-free: the read position is continuous. In polyphase modes, a ratio below 1 that moves the cutoff by more than a few percent redesigns the coefficient bank inside `SetRatio` (allocation-free, but roughly 0.3 ms of work at ratio 0.5 to 1), so bend pitch with ratio changes of a few percent per call rather than per frame.

## Loudness

`LoudnessMeter(sampleRate, channels, channelMask, truePeak, loudnessRange)` (or from an `AudioFormat`) measures per ITU-R BS.1770-5 and EBU R 128. Feed interleaved float blocks of any size to `Process`; results never depend on how the stream was split into blocks. Everything is allocated in the constructor, so a meter can run on a real-time thread (single thread at a time, no locks).

- **Readouts:** `MomentaryLufs` (400 ms), `ShortTermLufs` (3 s), `IntegratedLufs` (gated, -70 LUFS absolute and 10 LU relative), `MaxMomentaryLufs`, `MaxShortTermLufs`, `LoudnessRange` (EBU Tech 3342, LU), `TruePeak` / `TruePeakDbtp` (4x oversampling, all channels). No data yet reads as negative infinity (range: 0). `Reset()` clears everything.
- **Channels:** weights come from the channel mask (1.41 for surround and side channels, LFE left out of the loudness but not the true peak); without a mask the standard layouts are assumed, and 5 channels mean L R C Ls Rs.
- **Sample rates:** the K-weighting filters are designed for the actual rate (4 kHz and up) so that their response matches the 48 kHz table of the Recommendation. Non-finite input samples count as zero.
- **True peak:** the interpolation filter lags the input by 12 samples; a peak inside the last 12 frames fed shows up only after more audio arrives.
- **`LoudnessAnalyzer.Measure(decoder, truePeak, cancellationToken)`** reads a decoder to the end through a pooled buffer and returns a `LoudnessResult`; `Measure(span, format)` does the same for samples in memory.
- **Normalisation:** `LoudnessNormalization.ComputeGain(integratedLufs, truePeakDbtp, targetLufs = -23, ceilingDbtp = -1)` returns a `NormalizationGain` (`GainDb`, `Linear`, `LimitedByPeak`): the gain to reach the target, cut back if the true peak would pass the ceiling. Silence gives 0 dB. `LoudnessResult.GainFor(...)` is the shortcut.

## Mixing

`AudioMixer` is the real-time mixer. The host creates it with `MixerOptions` (fixed for its lifetime): `SampleRate`, `Channels`, `OutputChannelMask` (default: the conventional layout for the channel count), `MaxVoices`, `MaxBuses`, `MaxSourceChannels` (8), `MaxBlockFrames`, `CommandCapacity`, `EventCapacity`, `SmoothingMilliseconds` (10), `ResamplerFactory`, `MaxReverbs` (2) and `LimiterLookaheadMilliseconds` (0 = no limiter). The `MixerOptions` documentation also carries advice for real-time hosts (build everything at startup, avoid large allocations on busy frames, consider `GCSettings.LatencyMode = SustainedLowLatency`).

- **Render (audio thread):** `Render(Span<float> output)` fills interleaved float at the mixer's rate and channel count; the frame count is `output.Length / Channels`. No allocation, no locks, no blocking, no exceptions, no callbacks. The mix is summed, not clipped. Work is done in passes of at most `MaxBlockFrames`; queued commands apply at pass boundaries.
- **Control (any other thread):** `Play(PlayParams)` returns a `VoiceHandle`; `Stop`, `StopAll`, `SetGain` (with ramp), `SetPan`, `SetPitch`, `SetLoop`, `Crossfade`; bus calls `SetBusGain`, `SetBusMute`, `SetBusOutput`, `SetDucking`, `ClearDucking`, `SetMasterGain`. Calls write into a preallocated lock-free ring that the render thread drains. A full ring drops the command, returns false (or an invalid handle) and counts it in `DroppedCommands`. Handles are never reused, so a stale handle is ignored.
- **`PlayParams`:** `Source`, `Gain`, `Pan`, `Loop` / `LoopStartFrame` / `LoopEndFrame`, `Bus`, `Priority`, `StartAtFrame` (frame-exact on the mixer timeline), `FadeInFrames`, `Pitch`, `VariablePitch`, `Resampling`.
- **Voice cap:** when all voices are busy, a new voice replaces the lowest-priority (oldest on ties) voice if that voice's priority is not higher; otherwise the new request is dropped.
- **Sources:** `AudioClip` (decoded PCM in memory, shareable between voices) and `StreamingSource` (a decoder read ahead by a decode thread, optionally looping between two decoder frames via `Seek`; the render thread only reads its preallocated ring). Sources have 1 to `MaxSourceChannels` channels at any sample rate.
- **Decode-ahead:** `StreamingPump` keeps any number of `StreamingSource` rings full from one background thread (`Start`) or from the host's own loop (`PumpOnce`). `Add` primes the ring so a voice starts without an underrun; finished or failed sources are dropped automatically (`StreamingSource.Fault`).
- **Channel mapping:** a mono source is panned across the output's front pair (constant power); matching speakers pass straight through; missing speakers fold (centre to front pair at -3 dB, surrounds and sides to each other or to the front pair at -3 dB); LFE is dropped unless the output has one; stereo on a larger layout plays on front left/right only. Pan on a non-mono source is a balance. Layouts come from `AudioFormat.ChannelMask` and `MixerOptions.OutputChannelMask`; counts without a conventional layout map by index.
- **Resampling and pitch:** a source whose rate differs from the mixer's, a `Pitch` other than 1 or `VariablePitch` gives the voice an `IResampler` (the library's `AudioResampler` unless `MixerOptions.ResamplerFactory` says otherwise). Converters are pooled: `PreallocatedConverters` are built at mixer construction (which also builds the shared filter tables), more are created inside `Play` on the control thread up to `MaxConvertedVoices` and recycled; the render thread only calls `Process`. Memory per stereo converter: Polyphase 0.3 / 0.6 / 1.2 MB at `ConverterMinRatio` 1 / 0.5 (default) / 0.25, PolyphaseFast about a third of that, Linear 2 KB; the default mixer with its default reservations is about 2.6 MB. `PlayParams.Resampling` defaults to PolyphaseFast for plain rate conversion and Linear for pitched voices. A voice whose effective ratio is exactly 1 skips the converter. `SetPitch` (optional glide) clamps to `ConverterMinRatio`..`ConverterMaxRatio`. Polyphase ratio moves that make the converter redesign its filters share `RatioRedesignsPerPass` (default 2) across all voices, so a pitch bend on many voices cannot blow one callback's deadline. A converter's look-ahead is compensated and its tail is flushed when the source ends.
- **Buses:** every voice feeds one bus; each bus feeds the master output (default) or another bus (`SetBusOutput`), so sub-mixes sum into their parent. Routes that would form a loop (including through ducking links) are rejected with `ArgumentException` on the control side. `SetDucking(target, source, DuckingSettings)` turns the target down while the source bus is loud (threshold, depth, attack and release). Pan, bus gain, bus mute and master gain changes ramp over `SmoothingMilliseconds` unless an explicit ramp is given.
- **Ducking curve:** the duck gain follows exponentials toward its goal, so it approaches it asymptotically: after `ReleaseMs` x 4 about 1.8% of the way back is still missing (about 2% after 1 s with the default 250 ms), and a very deep duck (`ReductionDb` 120) takes about as long to come back. `SetDucking(target, source, DuckingSettings.None)` removes a link, like `ClearDucking`.
- **Effects:** `SetBusFilter(bus, FilterType, cutoffHz, q, rampFrames)` / `ClearBusFilter` put a second-order low-pass and/or high-pass (12 dB per octave, resonant, Q 0.1 to 24, default 0.707) on any bus or on the master output (`AudioMixer.MasterBus`, -1); `SetVoiceFilter` / `ClearVoiceFilter` do the same per voice. The filters are trapezoidal state-variable filters with the cutoff pre-warped, so the response follows the analog prototype (measured within 0.02 dB of the bilinear-exact curve and 0.7 dB of the analog curve below fs/10), and cutoff and Q glide (log-scale cutoff) over a ramp without zipper noise or instability. `SetBusReverb(bus, ReverbSettings, rampFrames)` / `ClearBusReverb` insert an eight-line feedback-delay-network reverb behind four allpass diffusers (`RoomSize`, `DecaySeconds` = RT60, `Damping`, `PreDelayMilliseconds`, `Wet`, `Dry`); at most `MaxReverbs` buses have one at a time, all channels are processed with decorrelated outputs, and an idle reverb costs nothing. On a bus the order is filters, reverb, bus gain. `SetBusReverb` throws `InvalidOperationException` (naming `MaxReverbs`) when the bus has no reverb and every slot is taken; `false` means only that the command queue was full; `FreeReverbSlots` tells how many remain. The master limiter is a lookahead brickwall (`SetLimiter(LimiterSettings?)`: `CeilingDb`, `ReleaseMilliseconds`): no sample magnitude exceeds the ceiling, quiet audio passes unchanged apart from the delay `LatencyFrames` (the lookahead from `MixerOptions.LimiterLookaheadMilliseconds`, fixed for the mixer's lifetime), and `LimiterGainReductionDb` reports the last pass. It limits sample peaks, not inter-sample (true) peaks. All effect state is preallocated at construction.
- **Bus meters:** `MixerOptions.MaxBusMeters` (default 0) preallocates BS.1770 meters. `EnableBusMeter(bus)` / `DisableBusMeter` / `ResetBusMeter` (bus index or `MasterBus`) go through the command queue; `TryGetBusLoudness(bus, out LoudnessSnapshot)` reads from any thread without locking: momentary, short-term and integrated LUFS, true peak (dBTP), the two maxima and `FramesMeasured`, all from one render pass. A bus is measured after its filters, reverb and gain (before ducking into its parent); the master after the limiter. Running out of meters makes `EnableBusMeter` throw `InvalidOperationException` like reverb slots (`FreeBusMeters`); `TryEnableBusMeter(bus)` returns false instead (after a false, `FreeBusMeters == 0` means no slot, otherwise the queue was full); `IsBusMetered(bus)` says whether a bus holds a meter. A full queue returns false. No cost when none is enabled.
- **Underruns:** if a streaming ring runs dry before the decoder is done, the voice renders silence for the gap. Counters are readable without locks from any thread: `AudioMixer.UnderrunEvents` / `UnderrunFrames`, and per source `StreamingSource.UnderrunEvents` / `UnderrunFrames`. `StreamingSource` also exposes `LengthFrames` (the decoder's length, -1 when unknown), `PlayedFrames` and `PositionFrames` (loop-aware), readable from any thread. Other counters: `DroppedCommands`, `DroppedPlays`, `DroppedEvents`, `StolenVoices`, `ActiveVoices`, `TimelineFrames`.
- **Events:** `TryGetEvent(out MixerEvent)` polls what the render thread noticed (`VoiceEnded`, `VoiceStolen`, `PlayDropped`, `Underrun`). Nothing is called back on the render thread.

## Devices (`RecursiveAI.OneAudio.Devices`)

Playback and capture live in a second package that references the core one; the core stays free of operating-system calls. With the package referenced and `using OneAudio;` the happy path is one call:

```csharp
using OneAudio;

var mixer = Audio.CreateMixer();                       // 48 kHz stereo
using var output = Audio.Play(mixer);                  // default device, shared mode, ~20 ms, follows default changes
mixer.Play(Audio.Load("shot.ogg"));                    // the one-call voice API of the core package
Console.WriteLine(output.Counters);                    // what the hardware really did
```

`Audio.Play(mixer)`, `Audio.CreateOutput(source or callback)` and `Audio.CreateInput(...)` are C# 14 extension members on `Audio`; each opens and starts the device. A device that cannot be opened is not an error: it keeps rendering silently at real-time pace (the game clock keeps moving), retries twice a second and says why in `LastError`.

- **Pulling audio:** `IAudioSource` (rate, channels, `Render(Span<float>)`; `AudioMixer` implements it) or a `RenderCallback(Span<float>, in RenderContext)`. It runs on the device's real-time thread, one thread at a time, and must not allocate, lock or block. A throw is caught, counted (`CallbackFaults`) and the block plays as silence. While no device is open the supervisor thread calls it at real-time pace and discards the output.
- **Counters:** `output.Counters` is a lock-free `DeviceCounters` snapshot: `FramesRendered`, `FramesWritten` (handed to the device), `FramesPlayed` (from the device's own clock or position, never more than written), `PeakWritten`, `PeakPlayed` (the peak among frames the device has finished, so 0 means it only played silence), `Underruns`, `Overruns`, `SilentFrames`, `CallbackFaults`, `DeviceLosses`, `Reopens`, `DefaultDeviceSwitches`, `BufferFrames`, `PeriodFrames`, `LatencyMilliseconds`, `MaxRenderMilliseconds`, `RenderAllocatedBytes` (always kept; see below), `EventsDropped`, `MutedFrames` and `DeviceUnderruns`. A state such as `Running` says nothing about sound; these numbers do.
- **Events:** `Event` (raised on the supervisor thread) or `TryGetEvent` (polling): `DefaultChanged`, `DeviceLost`, `DeviceRestored`, `Reopened`, `Xrun`, `Interrupted`, `Resumed`, each with the device ids and the played-frame count at the time.
- **Options:** `DeviceStreamOptions` (a record; the defaults are the happy path): `DeviceId` (null = system default), `SampleRate`/`Channels` (taken from the source when there is one), `PeriodFrames`, `BufferFrames`, `LatencyMilliseconds` (20), `Mode` (`Shared`/`Exclusive`), `FollowDefault` (true), `Limiter` (`SoftKnee`: 0.9 and below untouched, a tanh knee above, never reaching 1; `Clamp`; not-a-number plays as 0), `Metering` (BS.1770 loudness after the limiter, `Loudness`, the core's `LoudnessSnapshot`), `CaptureBufferMilliseconds` (1000) and `AppleSession`.
- **Underruns, two tallies:** `Underruns` is the package's own count of times it saw the device run dry (for example a write that returned an underrun); `DeviceUnderruns` is the count the device's audio API keeps itself, for backends that have one (a backend reports it through `IDeviceEngine.NotifyDeviceUnderruns(total)`; the total is kept across reopens). Core Audio reports none, so it stays 0 there.
- **Allocation probe:** `RenderAllocatedBytes` is always kept: two reads of the thread's allocation counter per callback, after skipping the first eight callbacks (`DeviceStreamOptions.AllocationWarmupBlocks`; the program's own first-use work counts, the package's render path allocates nothing from the first callback). Measured on the build host (x64, .NET 10, a silent source through a manual device, 256-frame callbacks, median of seven runs of 400,000 callbacks): 1104 ns per callback with the probe against 1110 ns without, a difference inside the run-to-run noise (about 30 ns), so `DeviceStreamOptions.MeasureRenderAllocations` no longer does anything and is only kept so existing code compiles.
- **State, mute, hook:** `IsSuspended` (after `Suspend`) and `IsSystemSuspended` (between the `Interrupted` and `Resumed` events) can be read at any time instead of mirrored from events; `State` is `Suspended` when either is true. `Suspend` stops the source's clock (nothing is rendered, so `FramesRendered` and `FramesWritten` hold); the device's clock stops once what was queued has played out (`FramesPlayed` catches up with `FramesWritten` on backends that drain, such as ALSA, and then holds). `Muted` feeds the device silence without asking the source, so a mixer's timeline and voices stand still and continue when unmuted; the cut is immediate (fade with the mixer's master gain first), `MutedFrames` counts it and the other counters keep saying what was played. `BlockHook` (`Action<long>`) runs on the device thread at the start of each block with the frames handed to the device so far, under the render path's rules; a throw is counted in `CallbackFaults`.
- **Ownership:** `IAudioDeviceBackend.TryOpen` returns a stream the caller owns and must dispose; the output and input devices do so on every path.
- **Control:** `Start`, `Suspend` / `Resume` (an app going to the background), `SelectDevice(id)` (null = back to the default), `Dispose`. `AudioDevices.List(direction)`, `Default(direction)`, `IsAvailable`, `DevicesChanged` and `UseBackend(backend)` cover enumeration and replacing the backend.
- **Capture:** `AudioInputDevice.Open/Audio.CreateInput` delivers to a `CaptureCallback` or into a preallocated read buffer drained with `Read(Span<float>)`; a full buffer drops new audio and counts `Overruns`.
- **Single-threaded hosts (manual mode):** a backend whose `IsManual` is true (`ManualBackend`, or your own for a browser or a game loop that owns the audio pump) gets no thread from the package at all. The host steps the device with `output.Advance(frames)` (device frames; `output.WantedFrames` says how many the backend would like), and `Advance` also does the supervisor's work when something is pending: reopen after a loss, follow a default change, retry a missing device twice a second, raise events. Handlers run on the caller's thread. In the steady state `Advance` takes no lock; with no device open it renders and discards the frames it is given instead of using a wall clock. `Pump()` does the supervisor's work without running frames, for an idle host. A custom manual stream implements `IManualDevice` (`Advance(int frames)`, optional `WantedFrames`) next to `IOpenDevice`; a backend that drives its stream from its own callbacks (so the stream is not an `IManualDevice`) calls `engine.Render` itself and `Advance` runs nothing for it.
- **Rate conversion:** when the device runs at another rate than the source (a browser context at 44.1 kHz, say) and `AllowRateConversion` is true (the default), the package converts with the library's polyphase resampler (ratios 1/4 to 16, output only); with it false such a device is refused with a reason. `DeviceFormat` says what the device really runs at. `FramesWritten`, `FramesPlayed` and the buffer sizes then count device frames, `FramesRendered` the source's frames. The converter is built when the device opens, so the render path still allocates nothing.
- **Backends:** `IAudioDeviceBackend` / `IOpenDevice` / `IDeviceEngine` are the seam an operating-system backend (or an application's own) implements. `IOpenDevice.Device` may be null when the backend cannot name its device (a browser context): that means unknown, never a default change. `IOpenDevice.StateText` (optional) carries a state word such as `awaiting_gesture` into `Detail` and `StateText`. A backend must honour the requested rate and channel count (converting itself if the hardware differs) and run in 32-bit float. `ManualBackend` has no hardware: `Advance(frames)` runs the device on the calling thread, `Pump()` lets the supervisor react, and it can act out a lost device, a default change, an xrun or an interruption, so the whole device lifecycle can be tested without sound. Its recording of the output is bit-equal to rendering the source directly, `Sink` (an `Action<ReadOnlySpan<float>>`) streams every block with no capacity limit, `DeviceSampleRate` acts out a device at another rate, and `SimulateDeviceUnderruns` a device that counts its own.
- **Core Audio (macOS and iOS):** `CoreAudioBackend` drives an audio unit: the HAL output unit bound to a chosen device on macOS (playback and capture), the remote I/O unit on iOS. macOS ids are the devices' persistent UIDs; `AudioDevices.List` gives name, nominal rate, channel count and smallest buffer, and `DevicesChanged` fires on plug and unplug. The backend listens for the system default changing (`DefaultChanged` and a reopen on the new device when the stream follows the default) and for the opened device disappearing (`DeviceLost`). Playback is converted to the hardware rate by the unit; channel counts are mapped by the backend (mono feeds the first two channels, other sources fill the leading channels, a device with fewer channels keeps the leading ones, or the average for a mono device). Capture runs at the hardware rate and is mapped and resampled (polyphase, fast profile) to the requested format. `FramesPlayed` comes from the device clock: the hardware reads a block one buffer plus the device's safety offset after the callback that filled it, so the position trails `FramesWritten` by about one to two periods while running and equals it after `Suspend`, which lets the queued audio play out first. The hardware buffer size is left to the system (it is shared with other programs) unless `PeriodFrames` or `BufferFrames` is set; `LatencyMilliseconds` is not used. `Exclusive` is refused with a reason. No device-side underrun signal exists, so `Underruns` stays 0. Opening an input fails with a reason when the user denied microphone access; when not yet asked the system asks on the first start, and a program that is not an app with a microphone usage description may only receive silence. iOS: the one route is the device `default`; `DeviceStreamOptions.AppleSession` sets the AVAudioSession category (a capture stream always uses play-and-record, routed to the speaker); interruptions (calls, alarms, another app) pause the stream and, when they end, the session is reactivated and playback resumes; route changes count as default changes (the unit follows the route itself); a media-services reset reports the device lost so the engine reopens it. `tools/ios-smoke` builds an app that exercises this on the iOS simulator.
- **ALSA (Linux):** `AlsaBackend` talks to libasound (loaded on first use; without it `AudioDevices.IsAvailable` is false and outputs run silent). Device ids are ALSA PCM names: `default` (on a PipeWire or PulseAudio desktop the sound server's own plug-in, which follows the user's output choice, converts rate and channels, and gives the device clock), `hw:CARD=...,DEV=...`, `pulse`, `pipewire`, and any name alsa-lib accepts, such as `null` (consumes everything) or `file:/path/out.raw,raw` (writes the float stream to disk). `AudioDevices.List` reads `snd_device_name_hint`; ALSA has no hot-plug events, so `DevicesChanged` never fires and the list is read when asked for. Float is requested first, then 16-bit with round-to-nearest. `FramesPlayed` is what the thread wrote minus `snd_pcm_delay`, read after every period; `Suspend` drains the queue so the position stays exact. Underruns come from `-EPIPE` and are recovered with `snd_pcm_recover`; any other write error counts as a lost device. `PeriodFrames`/`BufferFrames` go through the hardware-parameter negotiation (the nearest values the device allows are reported in the counters); `Exclusive` is refused with a reason. Capture opens the PCM for recording (`default` is the sound server's default source), reads one period at a time on the stream's thread and hands it to the engine; `Overruns` counts `-EPIPE` overruns and read-buffer overflows, and for an input `FramesPlayed` equals the frames delivered (the device's own buffer is reported as `LatencyMilliseconds`). A 16-bit capture device is scaled by 1/32768.
- **WASAPI (Windows):** `WasapiBackend` uses the MMDevice and Core Audio COM interfaces through plain v-table calls (no built-in COM interop, so it is trim and NativeAOT clean). `List` returns the active render or capture endpoints with the Windows endpoint id as `Id`, the friendly name, the system default flag and the device's shared-mode format (`DefaultSampleRate`, `MaxChannels`) and minimum period (`MinLatencyFrames`); a machine without an endpoint lists nothing and an open there falls back to `NoDevice`. **Shared** mode (default) is event driven and opens with automatic format conversion and the default sample-rate converter, so any rate and channel count is accepted and the Windows mixer converts. **Exclusive** mode (`Mode = ShareMode.Exclusive`) owns the endpoint and bypasses the mixer; it needs a format the device takes as is, so rate and channels are never converted: the backend tries float32, 32-bit, 24-bit (in 32 or packed) and 16-bit integer at exactly the requested rate and channels, in that order (integers round to nearest and clamp), aligns the buffer when the driver demands it, and otherwise refuses the open with a reason (no matching format, device in use, exclusive mode disabled in the device's settings). In exclusive event-driven mode buffer and period are equal and come from `PeriodFrames`, else `BufferFrames`, else `LatencyMilliseconds`, never below the driver's minimum period. Latency as Microsoft documents it (not measured here): the shared-mode engine period is 10 ms by default (lower only through `IAudioClient3`, which this backend does not use), and exclusive-mode buffers are limited by the driver's minimum period, typically a few milliseconds. `FramesPlayed` is `IAudioClock` position over frequency, read after every cycle and when paused; capture counts the frames delivered. Plugging, unplugging and default-device changes arrive through `IMMNotificationClient` (registered on first use of the backend) and become `DevicesChanged` and the follow-default behaviour; `AUDCLNT_E_DEVICE_INVALIDATED` and its relatives (resources invalidated, service not running) become a lost device and a reopen. The device thread joins the "Pro Audio" multimedia class scheduler task. Capturing needs the Windows microphone privacy setting to allow desktop apps; a denied open says so in `LastError`.
- **Unsafe code:** the package's operating-system calls are the only unsafe code, in `Interop/`; everything else, including the engine, is safe code.
