Recursive AI

API overview

All oneaudio docs

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:

C#
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.