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 streamingIAudioDecoder; the container is detected from the bytes. - Load:
Audio.Load(path | stream, LoadOptions?)decodes everything into anAudioClip(Format,Samples,Frames).LoadOptions:SampleRateandChannels(0 = keep) convert on the way,ResamplerQuality(default Polyphase),MaxFrames(default 16 min at 48 kHz) andLimits. Without conversion the samples equalAudioClip.FromDecoderover 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?)orPlayStream(path, loop: true)streams a file from a shared decode thread and returns aStreamingVoice(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) orTranscodeOptions.Format;Transcode(stream, stream, options)needsFormat. Options addSampleRate/Channelsconversion 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)andAudio.Resample(clip, toRate); the result has exactlyround(frames x ratio)frames and is delay-compensated. Ratios from 1/64 to 64. - Loudness:
Audio.Measure(path | stream | clip, truePeak)returns the sameLoudnessResultasLoudnessAnalyzer.Audio.Normalize(path, outputPath, NormalizeOptions?)writes a copy at the target (default-14LUFS, true-peak ceiling-1dBTP; both inNormalizeOptions), measuring after any conversion and decoding twice rather than holding audio in memory.Audio.Normalize(Span<float>, format)scales in place andAudio.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 everyAudiocall that reads a file and by themixer.Playextensions) registers all codecs. A trimmed application buildsnew 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, ...)andAudio.CreateMixerkeep no codec. See the README for measured sizes.
Formats
AudioFormat(SampleRate, Channels, ChannelMask, NativeType, BitsPerSample)describes decoder output.ChannelMaskuses the WAVE_FORMAT_EXTENSIBLE speaker bits.NativeSampleTypesays how the source stored samples (PcmUnsigned8,PcmSigned16/24/32,IeeeFloat32/64, orLossy).
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) throwNotSupportedExceptionfromSeek.- 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.MaxMetadataBytesis 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 + 529samples andLengthFramesisframes * 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 = trueadds one LSB of triangular noise from a seeded generator (DitherSeed) first.WriteIntegeris exact and throwsArgumentOutOfRangeExceptionfor 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_EXTENSIBLEwith the channel mask is used for more than two channels or more than 16 bits (Extensibleoverrides); float files carry afactchunk. A seekable output gets its sizes patched byFinishand is converted to RF64 if the data outgrows RIFF (a 28-byteJUNKchunk in the header reserves the space);ForceRf64writes RF64 from the start. A non-seekable output cannot be patched: giveTotalFramesfor 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 needsTotalFrames. - FLAC (
FlacEncoderSettings): any depth from 4 to 32 bits, 1 to 8 channels, any rate up to 1048575 Hz.CompressionLevel0 to 8 (default 5) trades speed for size and stays inside the streamable subset unlessSubset = 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 ofSeekPointsslots;Finishrewrites STREAMINFO with the length, frame sizes and MD5 and fills the table. A non-seekable stream gets STREAMINFO once, withTotalFrames(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
IContainerFormatrecognises a format (Matches) and opens it. Containers that carry compressed packets expose them as anIPacketSource(TryReadPacket,SeekToFrame,LengthFrames) and look up the codec byCodecIdin the registry. Raw-PCM containers (WAV) decode directly.IAudioCodec.CreateDecoder(IPacketSource, AudioLimits)turns packets into anIAudioDecoder.AudioCodecRegistrystarts empty:new AudioCodecRegistry().AddWav()and, as they arrive, furtherAddXxx()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) andPolyphase(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.
LatencyFramesis the look-ahead in output frames: after N input frames about N * ratio - LatencyFrames output frames exist.Flushreturns the rest (call until it returns 0). - Ratio:
ResamplerOptions.MinRatio(default 0.25) andMaxRatio(default 16) boundSetRatioand size the preallocated state (polyphase memory grows as 1 / MinRatio).SetRatiois 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 insideSetRatio(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 aLoudnessResult;Measure(span, format)does the same for samples in memory.- Normalisation:
LoudnessNormalization.ComputeGain(integratedLufs, truePeakDbtp, targetLufs = -23, ceilingDbtp = -1)returns aNormalizationGain(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 isoutput.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 mostMaxBlockFrames; queued commands apply at pass boundaries. - Control (any other thread):
Play(PlayParams)returns aVoiceHandle;Stop,StopAll,SetGain(with ramp),SetPan,SetPitch,SetLoop,Crossfade; bus callsSetBusGain,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 inDroppedCommands. 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) andStreamingSource(a decoder read ahead by a decode thread, optionally looping between two decoder frames viaSeek; the render thread only reads its preallocated ring). Sources have 1 toMaxSourceChannelschannels at any sample rate. - Decode-ahead:
StreamingPumpkeeps any number ofStreamingSourcerings full from one background thread (Start) or from the host's own loop (PumpOnce).Addprimes 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.ChannelMaskandMixerOptions.OutputChannelMask; counts without a conventional layout map by index. - Resampling and pitch: a source whose rate differs from the mixer's, a
Pitchother than 1 orVariablePitchgives the voice anIResampler(the library'sAudioResamplerunlessMixerOptions.ResamplerFactorysays otherwise). Converters are pooled:PreallocatedConvertersare built at mixer construction (which also builds the shared filter tables), more are created insidePlayon the control thread up toMaxConvertedVoicesand recycled; the render thread only callsProcess. Memory per stereo converter: Polyphase 0.3 / 0.6 / 1.2 MB atConverterMinRatio1 / 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.Resamplingdefaults 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 toConverterMinRatio..ConverterMaxRatio. Polyphase ratio moves that make the converter redesign its filters shareRatioRedesignsPerPass(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 withArgumentExceptionon 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 overSmoothingMillisecondsunless an explicit ramp is given. - Ducking curve: the duck gain follows exponentials toward its goal, so it approaches it asymptotically: after
ReleaseMsx 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 (ReductionDb120) takes about as long to come back.SetDucking(target, source, DuckingSettings.None)removes a link, likeClearDucking. - Effects:
SetBusFilter(bus, FilterType, cutoffHz, q, rampFrames)/ClearBusFilterput 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/ClearVoiceFilterdo 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)/ClearBusReverbinsert an eight-line feedback-delay-network reverb behind four allpass diffusers (RoomSize,DecaySeconds= RT60,Damping,PreDelayMilliseconds,Wet,Dry); at mostMaxReverbsbuses 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.SetBusReverbthrowsInvalidOperationException(namingMaxReverbs) when the bus has no reverb and every slot is taken;falsemeans only that the command queue was full;FreeReverbSlotstells 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 delayLatencyFrames(the lookahead fromMixerOptions.LimiterLookaheadMilliseconds, fixed for the mixer's lifetime), andLimiterGainReductionDbreports 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 orMasterBus) 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 andFramesMeasured, 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 makesEnableBusMeterthrowInvalidOperationExceptionlike reverb slots (FreeBusMeters);TryEnableBusMeter(bus)returns false instead (after a false,FreeBusMeters == 0means 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 sourceStreamingSource.UnderrunEvents/UnderrunFrames.StreamingSourcealso exposesLengthFrames(the decoder's length, -1 when unknown),PlayedFramesandPositionFrames(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:
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>);AudioMixerimplements it) or aRenderCallback(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.Countersis a lock-freeDeviceCounterssnapshot: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,MutedFramesandDeviceUnderruns. A state such asRunningsays nothing about sound; these numbers do. - Events:
Event(raised on the supervisor thread) orTryGetEvent(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'sLoudnessSnapshot),CaptureBufferMilliseconds(1000) andAppleSession. - Underruns, two tallies:
Underrunsis the package's own count of times it saw the device run dry (for example a write that returned an underrun);DeviceUnderrunsis the count the device's audio API keeps itself, for backends that have one (a backend reports it throughIDeviceEngine.NotifyDeviceUnderruns(total); the total is kept across reopens). Core Audio reports none, so it stays 0 there. - Allocation probe:
RenderAllocatedBytesis 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), soDeviceStreamOptions.MeasureRenderAllocationsno longer does anything and is only kept so existing code compiles. - State, mute, hook:
IsSuspended(afterSuspend) andIsSystemSuspended(between theInterruptedandResumedevents) can be read at any time instead of mirrored from events;StateisSuspendedwhen either is true.Suspendstops the source's clock (nothing is rendered, soFramesRenderedandFramesWrittenhold); the device's clock stops once what was queued has played out (FramesPlayedcatches up withFramesWrittenon backends that drain, such as ALSA, and then holds).Mutedfeeds 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),MutedFramescounts 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 inCallbackFaults. - Ownership:
IAudioDeviceBackend.TryOpenreturns 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,DevicesChangedandUseBackend(backend)cover enumeration and replacing the backend. - Capture:
AudioInputDevice.Open/Audio.CreateInputdelivers to aCaptureCallbackor into a preallocated read buffer drained withRead(Span<float>); a full buffer drops new audio and countsOverruns. - Single-threaded hosts (manual mode): a backend whose
IsManualis 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 withoutput.Advance(frames)(device frames;output.WantedFramessays how many the backend would like), andAdvancealso 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 stateAdvancetakes 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 implementsIManualDevice(Advance(int frames), optionalWantedFrames) next toIOpenDevice; a backend that drives its stream from its own callbacks (so the stream is not anIManualDevice) callsengine.Renderitself andAdvanceruns nothing for it. - Rate conversion: when the device runs at another rate than the source (a browser context at 44.1 kHz, say) and
AllowRateConversionis 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.DeviceFormatsays what the device really runs at.FramesWritten,FramesPlayedand the buffer sizes then count device frames,FramesRenderedthe source's frames. The converter is built when the device opens, so the render path still allocates nothing. - Backends:
IAudioDeviceBackend/IOpenDevice/IDeviceEngineare the seam an operating-system backend (or an application's own) implements.IOpenDevice.Devicemay 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 asawaiting_gestureintoDetailandStateText. A backend must honour the requested rate and channel count (converting itself if the hardware differs) and run in 32-bit float.ManualBackendhas 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(anAction<ReadOnlySpan<float>>) streams every block with no capacity limit,DeviceSampleRateacts out a device at another rate, andSimulateDeviceUnderrunsa device that counts its own. - Core Audio (macOS and iOS):
CoreAudioBackenddrives 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.Listgives name, nominal rate, channel count and smallest buffer, andDevicesChangedfires on plug and unplug. The backend listens for the system default changing (DefaultChangedand 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.FramesPlayedcomes 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 trailsFramesWrittenby about one to two periods while running and equals it afterSuspend, which lets the queued audio play out first. The hardware buffer size is left to the system (it is shared with other programs) unlessPeriodFramesorBufferFramesis set;LatencyMillisecondsis not used.Exclusiveis refused with a reason. No device-side underrun signal exists, soUnderrunsstays 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 devicedefault;DeviceStreamOptions.AppleSessionsets 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-smokebuilds an app that exercises this on the iOS simulator. - ALSA (Linux):
AlsaBackendtalks to libasound (loaded on first use; without itAudioDevices.IsAvailableis 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 asnull(consumes everything) orfile:/path/out.raw,raw(writes the float stream to disk).AudioDevices.Listreadssnd_device_name_hint; ALSA has no hot-plug events, soDevicesChangednever fires and the list is read when asked for. Float is requested first, then 16-bit with round-to-nearest.FramesPlayedis what the thread wrote minussnd_pcm_delay, read after every period;Suspenddrains the queue so the position stays exact. Underruns come from-EPIPEand are recovered withsnd_pcm_recover; any other write error counts as a lost device.PeriodFrames/BufferFramesgo through the hardware-parameter negotiation (the nearest values the device allows are reported in the counters);Exclusiveis refused with a reason. Capture opens the PCM for recording (defaultis the sound server's default source), reads one period at a time on the stream's thread and hands it to the engine;Overrunscounts-EPIPEoverruns and read-buffer overflows, and for an inputFramesPlayedequals the frames delivered (the device's own buffer is reported asLatencyMilliseconds). A 16-bit capture device is scaled by 1/32768. - WASAPI (Windows):
WasapiBackenduses the MMDevice and Core Audio COM interfaces through plain v-table calls (no built-in COM interop, so it is trim and NativeAOT clean).Listreturns the active render or capture endpoints with the Windows endpoint id asId, 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 toNoDevice. 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 fromPeriodFrames, elseBufferFrames, elseLatencyMilliseconds, 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 throughIAudioClient3, which this backend does not use), and exclusive-mode buffers are limited by the driver's minimum period, typically a few milliseconds.FramesPlayedisIAudioClockposition over frequency, read after every cycle and when paused; capture counts the frames delivered. Plugging, unplugging and default-device changes arrive throughIMMNotificationClient(registered on first use of the backend) and becomeDevicesChangedand the follow-default behaviour;AUDCLNT_E_DEVICE_INVALIDATEDand 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 inLastError. - Unsafe code: the package's operating-system calls are the only unsafe code, in
Interop/; everything else, including the engine, is safe code.