# oneaudio docs, all pages (oneaudio 1.2.1) Every page of https://recursiveai.org/oneaudio/docs/ and https://recursiveai.org/oneaudio/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/oneaudio/llms.txt # Getting started with oneaudio Install oneaudio for .NET 10, decode MP3, Ogg, Opus, FLAC and WAV files, then resample, mix, encode, normalize loudness, and play and record on audio devices, all in C# with no native binaries. oneaudio is an audio library written in C# with no dependency beyond .NET itself. Every sample on this page was compiled and run against `RecursiveAI.OneAudio` 1.2.1 from NuGet (the device sample with `RecursiveAI.OneAudio.Devices` 1.2.1), and the output shown is from that run. ## Install oneaudio targets .NET 10 (`net10.0`), so you need the .NET 10 SDK. ```sh dotnet new console -o AudioStart cd AudioStart dotnet add package RecursiveAI.OneAudio --version 1.2.1 ``` To play or record on an audio device, add the device package too: ```sh dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1 ``` Everything is in the `OneAudio` namespace, with the codec-specific types in `OneAudio.Flac`, `OneAudio.Opus`, `OneAudio.Vorbis` and `OneAudio.Mp3`, and the meter in `OneAudio.Loudness`. The device types are in `OneAudio.Devices`. The samples read and write files in the current directory. The ones used here are a 32-second stereo test piece (`music.wav`, 44.1 kHz 16-bit, with the same audio as `music.mp3`, `music.ogg`, `music.opus` and `music.flac`), a six-second spoken line (`voice.wav`, 16 kHz mono) and a short click (`hit.wav`, 48 kHz mono). Put your own files there under those names. ## Decode a file `Audio.Load` decodes a whole file to 32-bit float samples: ```csharp using OneAudio; // Decode a whole file to 32-bit floats. The format is found from the bytes, // not the extension. AudioClip clip = Audio.Load("music.mp3"); AudioFormat format = clip.Format; double seconds = clip.Frames / (double)format.SampleRate; Console.WriteLine($"{format.SampleRate} Hz, {format.Channels} channels, " + $"{clip.Frames} frames, {seconds:F3} s"); // Samples are interleaved: frame i of channel c is at [i * channels + c]. ReadOnlySpan samples = clip.Samples.Span; float peak = 0; foreach (float s in samples) peak = Math.Max(peak, Math.Abs(s)); Console.WriteLine($"{samples.Length} samples, sample peak {20 * Math.Log10(peak):F1} dBFS"); // Convert on the way in: 48 kHz mono, whatever the source was. var options = new LoadOptions { SampleRate = 48000, Channels = 1 }; AudioClip mono48 = Audio.Load("music.ogg", options); Console.WriteLine($"music.ogg as {mono48.Format.SampleRate} Hz, " + $"{mono48.Format.Channels} channel: {mono48.Frames} frames"); ``` ```text 44100 Hz, 2 channels, 1411200 frames, 32.000 s 2822400 samples, sample peak -12.1 dBFS music.ogg as 48000 Hz, 1 channel: 1536000 frames ``` - The format is detected from the first bytes of the file, so the extension doesn’t matter. WAV and RF64, Ogg Vorbis, Ogg Opus, FLAC (native and in Ogg) and MP3 (MPEG-1, 2 and 2.5 Layer III) all come back the same way. - A frame is one sample per channel. `Samples` is interleaved, so a stereo clip of N frames holds 2N floats. Full scale is ±1, and decoded samples are never clipped. - The MP3 is exactly 32 seconds, the length of the source. A LAME or Xing/Info header tells the decoder how much padding the encoder added, and it’s trimmed. See [Decode MP3 in C#](https://recursiveai.org/oneaudio/cookbook/decode-mp3/). - `LoadOptions` converts the sample rate and channel count on the way. `Load` refuses more than 46,080,000 frames (16 minutes at 48 kHz) unless you raise `MaxFrames`. Longer files belong in a stream. To keep memory flat, open the file as a stream instead and read it in blocks: ```csharp using OneAudio; // Stream a file block by block instead of holding it in memory. using IAudioDecoder decoder = Audio.Open("music.flac"); AudioFormat format = decoder.Format; Console.WriteLine($"{format.SampleRate} Hz, {format.Channels} channels, " + $"{format.NativeType}, {decoder.LengthFrames} frames"); float[] buffer = new float[4096 * format.Channels]; // room for 4096 frames long frames = 0; int read; while ((read = decoder.Read(buffer)) > 0) // frames, not samples; 0 is the end frames += read; Console.WriteLine($"read {frames} frames"); // Seeking is sample-accurate. decoder.Seek(format.SampleRate * 10L); // 10 seconds in read = decoder.Read(buffer); Console.WriteLine($"after the seek: position {decoder.PositionFrames}, {read} frames read"); ``` ```text 44100 Hz, 2 channels, PcmSigned16, 1411200 frames read 1411200 frames after the seek: position 445096, 4096 frames read ``` `Audio.Open` returns an `IAudioDecoder`. `Read` fills the span you give it and returns the number of frames, which can be fewer than asked for; 0 means the end. Decoders allocate nothing per read, and every one of them seeks to the exact frame when the underlying stream can seek. Dispose the decoder when you’re done. More in [Decode audio from a stream](https://recursiveai.org/oneaudio/cookbook/streaming-decode/). ## Convert and resample ```csharp using OneAudio; using OneAudio.Opus; // Convert between formats. The output format comes from the extension: // .wav, .flac, or .opus/.ogg (Ogg Opus). TranscodeResult flac = Audio.Transcode("music.wav", "music-copy.flac"); long flacBytes = new FileInfo("music-copy.flac").Length; Console.WriteLine($"FLAC: {flac.Frames} frames at {flac.Input.SampleRate} Hz, {flacBytes:N0} bytes"); var opusOptions = new TranscodeOptions { Opus = new OpusFileWriterSettings { Encoder = new OpusEncoderSettings { BitrateBps = 96_000 }, }, }; TranscodeResult opus = Audio.Transcode("music.wav", "music-96k.opus", opusOptions); long opusBytes = new FileInfo("music-96k.opus").Length; Console.WriteLine($"Opus: {opus.Frames} frames, {opusBytes:N0} bytes"); // Change the rate and channel count while converting. var toMono48 = new TranscodeOptions { SampleRate = 48000, Channels = 1 }; Audio.Transcode("music.mp3", "music-48k-mono.wav", toMono48); // Resample in memory. The result has exactly round(frames x ratio) frames, // with the filter delay removed. AudioClip clip = Audio.Load("music.wav"); AudioClip at48 = Audio.Resample(clip, 48000); Console.WriteLine($"resampled {clip.Frames} frames at {clip.Format.SampleRate} Hz " + $"to {at48.Frames} at {at48.Format.SampleRate} Hz"); float[] voice = Audio.Load("voice.wav").Samples.ToArray(); // 16 kHz mono float[] voice44 = Audio.Resample(voice, channels: 1, fromRate: 16000, toRate: 44100, ResamplerQuality.PolyphaseFast); Console.WriteLine($"voice: {voice.Length} samples at 16 kHz, {voice44.Length} at 44.1 kHz"); ``` ```text FLAC: 1411200 frames at 44100 Hz, 813,193 bytes Opus: 1411200 frames, 392,386 bytes resampled 1411200 frames at 44100 Hz to 1536000 at 48000 Hz voice: 96400 samples at 16 kHz, 265703 at 44.1 kHz ``` - `Audio.Transcode` streams block by block, so memory use doesn’t depend on the length. A lossless source written to WAV or FLAC with no conversion is bit-exact: the FLAC above decodes to the same samples as `music.wav`. - `.ogg` as an output extension writes Ogg Opus, the only Ogg format oneaudio encodes. There is no MP3, Vorbis or AAC encoder. A path with another extension throws `ArgumentException`. - `Audio.Resample` returns exactly round(frames × ratio) frames with the filter’s delay removed, so the result lines up with the source. The default quality is `Polyphase` (144 taps, at least 100 dB of alias rejection). `PolyphaseFast` and `Linear` are cheaper. See [Resample audio](https://recursiveai.org/oneaudio/cookbook/resample-audio/). ## Mix `AudioMixer` mixes any number of voices on buses into a buffer you supply. In a game, the buffer goes to your audio output. Here it goes into a WAV file: ```csharp using OneAudio; // The mixer renders into a buffer you supply. Here the buffer goes into a // WAV file; in a game it goes to your audio output instead. AudioMixer mixer = Audio.CreateMixer(sampleRate: 48000, channels: 2); const int MusicBus = 0, VoiceBus = 1; mixer.SetDucking(MusicBus, VoiceBus); // music down while the voice bus is loud // A file at 44.1 kHz: the voice converts it to the mixer's rate. mixer.Play("music.wav", new PlayOptions { Bus = MusicBus, Gain = 1.5f }); // PlayParams can start a voice at an exact frame of the mixer's timeline. var voice = Audio.Load("voice.wav"); var hit = Audio.Load("hit.wav"); mixer.Play(new PlayParams(voice) { Bus = VoiceBus, StartAtFrame = 48000 * 2 }); mixer.Play(new PlayParams(hit) { Bus = VoiceBus, StartAtFrame = 48000 * 10, Pan = 0.5f }); var format = new AudioFormat(48000, 2, AudioFormat.DefaultMask(2), NativeSampleType.PcmSigned16, 16); using var wav = new WavEncoder(File.Create("mix.wav"), format, new WavEncoderSettings(), leaveOpen: false); float[] block = new float[1024 * 2]; // 1024 frames, stereo, interleaved for (int i = 0; i < 48000 * 12 / 1024; i++) { mixer.Render(block); // allocates nothing, takes no locks wav.Write(block); } wav.Finish(); Console.WriteLine($"mix.wav: {wav.FramesWritten} frames, {mixer.ActiveVoices} voice still playing"); ``` ```text mix.wav: 575488 frames, 1 voice still playing ``` - `Audio.CreateMixer` builds a mixer with 64 voices and a limiter on the master output at −1 dB. Every voice feeds a bus, and each bus feeds the master output or another bus. - `SetDucking(MusicBus, VoiceBus)` turns the music bus down while the voice bus is loud: by 12 dB by default, once the voice bus passes −30 dB. - Sources at another sample rate are converted per voice. `StartAtFrame` places a voice on the mixer’s timeline to the frame. - `Render` allocates nothing, takes no locks and never blocks, so it can run on an audio thread. In a real-time app, call `Play` and the other control methods from another thread. They go through a lock-free queue that `Render` drains. Offline, as here, one thread does both. - The voice still playing is the music, which is 32 seconds long. To hear the mix instead, hand the mixer to an audio device (next section), or pass `Render`’s buffer to an output API you already use. Buses, filters, reverb and meters are in [Mix audio files](https://recursiveai.org/oneaudio/cookbook/mix-audio/). ## Measure and normalize loudness ```csharp using OneAudio; using OneAudio.Loudness; // Integrated loudness, loudness range and true peak (BS.1770-5, EBU R 128). LoudnessResult before = Audio.Measure("music.flac"); Console.WriteLine($"music.flac: {before.IntegratedLufs:F1} LUFS, " + $"range {before.LoudnessRange:F1} LU, true peak {before.TruePeakDbtp:F1} dBTP"); // A copy at -14 LUFS with the true peak at most -1 dBTP (the defaults). NormalizeResult result = Audio.Normalize("music.flac", "music-14.flac"); Console.WriteLine($"gain {result.GainDb:+0.0;-0.0} dB, " + $"limited by the peak ceiling: {result.LimitedByPeak}"); LoudnessResult after = Audio.Measure("music-14.flac"); Console.WriteLine($"music-14.flac: {after.IntegratedLufs:F1} LUFS, " + $"true peak {after.TruePeakDbtp:F1} dBTP"); ``` ```text music.flac: -23.0 LUFS, range 0.2 LU, true peak -11.8 dBTP gain +9.0 dB, limited by the peak ceiling: False music-14.flac: -14.0 LUFS, true peak -2.8 dBTP ``` - Loudness follows ITU-R BS.1770-5 and EBU R 128: integrated loudness in LUFS, loudness range in LU, and the true peak in dBTP, found with 4× oversampling. - `Audio.Normalize` writes a copy at −14 LUFS by default, with the true peak held to −1 dBTP. If the ceiling would be crossed, the gain stops short of the target and `LimitedByPeak` is true. It decodes the file twice instead of holding it in memory. Silence is left alone. - ffmpeg’s `ebur128` filter reads the normalized file as −14.0 LUFS too. For broadcast at −23 LUFS, or to normalize audio in memory, see [Normalize loudness](https://recursiveai.org/oneaudio/cookbook/normalize-loudness/). For a live meter, see [Measure loudness](https://recursiveai.org/oneaudio/cookbook/measure-loudness/). ## Play and record The `RecursiveAI.OneAudio.Devices` package opens the sound card. `Audio.Play` hands a mixer to the default output device and starts it: ```csharp using OneAudio; using OneAudio.Devices; var mixer = Audio.CreateMixer(); // 48 kHz stereo using var output = Audio.Play(mixer); // default device, follows changes mixer.Play("hit.wav"); // every voice the mixer plays is heard Thread.Sleep(1000); DeviceCounters c = output.Counters; // what the hardware really did Console.WriteLine($"{output.BackendName} '{output.Device?.Id}' {output.State}: " + $"{c.FramesPlayed / (double)mixer.SampleRate:F1} s played, " + $"peak {20 * Math.Log10(c.PeakPlayed):F1} dBFS, " + $"{c.Underruns} underruns"); ``` ```text alsa 'default' Running: 1.0 s played, peak -5.0 dBFS, 0 underruns ``` - `Audio.Play(mixer)`, `Audio.CreateOutput(...)` and `Audio.CreateInput(...)` come with the package, as extension members on `Audio`. Each opens the device and starts it. - The backend is the operating system’s own: WASAPI on Windows, Core Audio on macOS and iOS, ALSA on Linux (PipeWire and PulseAudio through their ALSA plug-ins). This run was on Linux through PipeWire. - `Counters` come from the device’s own clock: frames written and played, the played peak, underruns, lost devices and reopens. - A device that can’t be opened isn’t an exception. The output keeps rendering silently at real-time pace, retries twice a second and says why in `LastError`. - The output follows the system default when it changes, and reopens after the device is lost. Play a file, record from the microphone and pick a device in [Play an audio file](https://recursiveai.org/oneaudio/cookbook/play-audio-file/), [Record from the microphone](https://recursiveai.org/oneaudio/cookbook/record-microphone/) and [List audio devices](https://recursiveai.org/oneaudio/cookbook/list-audio-devices/). The changelog lists which backends have been checked on real hardware. ## The packages - **Packages:** `RecursiveAI.OneAudio` 1.2.1, namespace `OneAudio`, for .NET 10 (`net10.0`). `RecursiveAI.OneAudio.Devices` 1.2.1 adds playback and recording, namespace `OneAudio.Devices`, and versions in step with the core package. - **Dependencies:** none. No native binaries and no other NuGet packages. The device package calls the operating system’s own audio libraries. - **License:** MIT or Apache-2.0, at your choice. - **NativeAOT and trimming:** supported, with no warnings. | Format | Support | |---|---| | WAV, RF64 | Read and write | | FLAC | Read (bit-exact) and write, levels 0–8 | | Ogg FLAC | Read | | Ogg Vorbis | Read | | Ogg Opus | Read and write | | MP3 | Read, gapless | WAV covers PCM at 8, 16, 24 and 32 bits, float at 32 and 64 bits, and `WAVE_FORMAT_EXTENSIBLE`. FLAC covers 4 to 32 bits and 1 to 8 channels. MP3 means MPEG-1, 2 and 2.5 Layer III. Chained Ogg files play as one stream. The one-call `Audio` class registers every codec, so an app that uses it carries all of them. To keep only the codecs you need in a published app, see [Keep only the codecs you use](https://recursiveai.org/oneaudio/cookbook/trim-codecs/). ## What oneaudio doesn’t do - **MP3, Vorbis and AAC encoding.** It writes WAV, FLAC and Ogg Opus only. - **AAC and M4A, MP3 Layers I and II, MIDI, spatial audio.** None of these are in 1.2.1. Layer I and II files throw `UnsupportedAudioFormatException`. ## Where next Cookbook recipes, each a complete program: - Decode: [MP3](https://recursiveai.org/oneaudio/cookbook/decode-mp3/), [Ogg Vorbis](https://recursiveai.org/oneaudio/cookbook/decode-ogg-vorbis/), [Opus](https://recursiveai.org/oneaudio/cookbook/decode-opus/), [from a stream or untrusted input](https://recursiveai.org/oneaudio/cookbook/streaming-decode/), and [read format and tags](https://recursiveai.org/oneaudio/cookbook/read-audio-metadata/) - Read and write [FLAC](https://recursiveai.org/oneaudio/cookbook/flac/) and [WAV](https://recursiveai.org/oneaudio/cookbook/wav/), [encode Opus](https://recursiveai.org/oneaudio/cookbook/encode-opus/), [convert between formats](https://recursiveai.org/oneaudio/cookbook/convert-audio-files/) - [Resample audio](https://recursiveai.org/oneaudio/cookbook/resample-audio/) and [mix audio files](https://recursiveai.org/oneaudio/cookbook/mix-audio/) - [Play an audio file](https://recursiveai.org/oneaudio/cookbook/play-audio-file/), [record from the microphone](https://recursiveai.org/oneaudio/cookbook/record-microphone/) and [list audio devices](https://recursiveai.org/oneaudio/cookbook/list-audio-devices/) - [Measure loudness](https://recursiveai.org/oneaudio/cookbook/measure-loudness/) and [normalize loudness](https://recursiveai.org/oneaudio/cookbook/normalize-loudness/) - [Keep only the codecs you use](https://recursiveai.org/oneaudio/cookbook/trim-codecs/), for small NativeAOT apps The reference: the [API overview](https://recursiveai.org/oneaudio/docs/api/) and the [changelog](https://recursiveai.org/oneaudio/docs/changelog/). Guides: [Decode MP3 in C#](https://recursiveai.org/oneaudio/articles/decode-mp3-csharp/), [Resample audio in C#](https://recursiveai.org/oneaudio/articles/resample-audio-csharp/), [Loudness normalization in C#](https://recursiveai.org/oneaudio/articles/loudness-normalization-csharp/), [Choosing a C# audio library](https://recursiveai.org/oneaudio/articles/csharp-audio-library/) and [Migrate from NAudio](https://recursiveai.org/oneaudio/articles/migrate-from-naudio/). # 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, 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)` returns frames of interleaved float, never clipped; 0 means end. Short reads are normal. - `int ReadInteger(Span)` 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)`, `WriteInteger(ReadOnlySpan)`, `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 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)`; `AudioMixer` implements it) or a `RenderCallback(Span, 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`) 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)`; 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>`) 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. # Changelog All notable changes to `RecursiveAI.OneAudio` are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/) once 1.0.0 is released. Until then, every `0.x` release may change the API. ## [Unreleased] ## [1.2.1] - 2026-10-27 A patch release for `RecursiveAI.OneAudio.Devices`; the core package changes only its version number. The API changes are additive. What has been checked on real hardware is unchanged from 1.2.0 (see below); the fixes here are covered by tests over the manual backend and a stream that cannot name its device. ### Fixed - A stream whose backend cannot name its device (`IOpenDevice.Device` is `null`, as in a browser audio context) was taken for a change of the default device on every poll, so the stream was reopened about once a second and counted as a default switch. `null` now means "unknown": the package compares the stream's device with the default only when both are known. A backend that sees the default change itself still says so with `NotifyDefaultChanged`, and that is honoured. The meaning of `null` is documented on `IOpenDevice.Device`. - `DeviceCounters.RenderAllocatedBytes` could read a few hundred bytes on a host that steps a manual device in short blocks. We could not reproduce it: the package's render path allocates nothing after warm-up (now tested with 480-frame blocks, with and without rate conversion), so the bytes most likely come from first-use work in the program's own source or the runtime after the eight callbacks the probe skips. The skipped count is now an option, below. ### Added - `IOpenDevice.StateText` (a default interface member returning `null`, so existing backends are unaffected): a short state word such as `awaiting_gesture`. It is exposed as `AudioOutputDevice.StateText` and `AudioInputDevice.StateText` and appended to `Detail` after the route extras. - `DeviceStreamOptions.AllocationWarmupBlocks` (default 8, the previous fixed value): how many callbacks `RenderAllocatedBytes` ignores at the start of a stream. ## [1.2.0] - 2026-10-27 Device playback and capture, in the new `RecursiveAI.OneAudio.Devices` package (same version as the core package). What has been checked on real hardware so far: ALSA playback and capture on Linux (PipeWire); Core Audio playback on macOS, including following a change of the default output; and playback on an iPad (iPadOS 17, NativeAOT) through the speaker, with the device clock, peaks and zero underruns and allocations confirmed, and a simulated audio-session interruption suspending and resuming the stream. Not yet on real hardware: WASAPI (checked by unit tests and on Windows CI runners, which have no audio device), capture from a real microphone on macOS and iOS, and a real system interruption on iOS (a call or another app taking the audio session). ### Added - `IAudioSource` (sample rate, channels, `Render`), implemented by `AudioMixer`: anything an output device can pull audio from. - A new package, `RecursiveAI.OneAudio.Devices`, versioned in step with the core package: device playback and capture. This first part is the backend-independent layer: `AudioOutputDevice` and `AudioInputDevice` (callbacks on the device thread, a limiter, an optional BS.1770 meter, default-device following, reopen after a lost device, silent real-time pacing while no device is open, events), `DeviceCounters` (frames rendered, written and played from the device's own position, played peak, underruns, losses, reopens), `AudioDevices`, the `IAudioDeviceBackend` seam, and `ManualBackend` for tests and offline runs. The one-call layer is `Audio.Play(mixer)`, `Audio.CreateOutput` and `Audio.CreateInput`, as C# 14 extension members on `Audio`. - `AlsaBackend` (Linux playback through ALSA, including PipeWire and PulseAudio through their ALSA plug-ins): enumeration, period and buffer negotiation, float or 16-bit, underrun recovery, device position from `snd_pcm_delay`, and capture (`Audio.CreateInput`, callback or `Read`). - `CoreAudioBackend` (macOS and iOS): device enumeration with default and hot-plug notifications, playback and capture through audio units with rate and channel conversion, a device-clock `FramesPlayed`, follow-the-default and device-loss handling, microphone-permission refusal with a reason, and on iOS the audio-session category, interruptions and route changes. `tools/ios-smoke` runs it in the iOS simulator. - `WasapiBackend` (Windows playback and capture through WASAPI): MMDevice enumeration, shared mode with automatic rate and channel conversion, exclusive mode with format negotiation and a stated refusal, device position from `IAudioClock`, endpoint notifications for default and hot-plug changes, lost-device handling, and the "Pro Audio" scheduler class on the device thread. All COM calls are v-table function pointers, so it is trim and NativeAOT clean. Verified so far by unit tests over hand-built COM objects and by CI on Windows; not yet on real hardware. - Single-threaded hosts: `IManualDevice` (public; `Advance(frames)`, optional `WantedFrames`) lets any backend whose `IsManual` is true be stepped by the caller, with no thread started by the package. `AudioOutputDevice.Advance` now also does the supervisor's work (reopen after a loss, default change, retry, events), so `Pump()` is optional; `AudioOutputDevice.WantedFrames` reads the stream's request. - `DeviceStreamOptions.AllowRateConversion` (default true): an output device running at another sample rate than the source gets the source converted with the polyphase resampler; `AudioOutputDevice.DeviceFormat` reports the device's format and `ManualBackend.DeviceSampleRate` acts out such a device. - `AudioOutputDevice.IsSuspended` and `IsSystemSuspended` (also on `AudioInputDevice`): readable suspension state; `Suspend` is documented to stop the source's clock and, once the queue has played out, the device's. - `AudioOutputDevice.Muted`: feeds silence without rendering the source (its clock stands still); `DeviceCounters.MutedFrames` counts it. - `DeviceCounters.DeviceUnderruns` (the device API's own count, reported through the optional `IDeviceEngine.NotifyDeviceUnderruns`) next to `Underruns`. - `RenderAllocatedBytes` is always kept (measured at the noise of a callback's time); `DeviceStreamOptions.MeasureRenderAllocations` is now a no-op. - `BlockHook` on both devices: a per-block callback with the frames handed over so far. - `ManualBackend.Sink`: every output block streamed to a callback with no capacity limit. - `AudioOutputDevice.Loudness` returns the core's `LoudnessSnapshot` (with the maxima) instead of a type of its own; `LoudnessReading` is gone. ## [1.1.0] - 2026-10-26 ### Added - A one-call layer on top of the existing API, nothing in it changes: `Audio` (and `AudioKit`, the same calls over a registry of your choosing) with `Open`, `Load` (decode to an `AudioClip`, optionally converting rate and channels), `Transcode` to WAV, FLAC or Ogg Opus, `Resample`, `Measure`, `Normalize` (samples, clips or file to file, default -14 LUFS with a -1 dBTP true-peak ceiling) and `CreateMixer`. - `mixer.Play(path)` and `mixer.PlayStream(path, loop: true)` extension methods (`AudioMixerPlayExtensions`), with `PlayOptions` and a `StreamingVoice` handle. - Options records `LoadOptions`, `TranscodeOptions`, `NormalizeOptions`, `PlayOptions`; results `TranscodeResult` and `NormalizeResult`; enum `AudioFileFormat`. - `AudioClip.Samples`: a read-only view of the decoded samples. - `tests/OneAudio.AotSize`: three tiny NativeAOT apps that compare published size with the default registry, a WAV-only `AudioKit` and the low-level API. ## [1.0.0] - 2026-10-25 The first stable release. From here on the public API follows Semantic Versioning. - Decode WAV / RF64, Ogg (chained), Vorbis, Opus, FLAC (native and Ogg) and MP3 (MPEG-1/2/2.5 Layer III), streaming with exact seeking. - Encode WAV / RF64, FLAC (levels 0 to 8) and Opus (including Ogg Opus files). - Polyphase and linear resampling; the real-time mixer with buses, effects (filters, reverb, limiter) and per-bus loudness meters; EBU R 128 / BS.1770 loudness with true peak and normalisation. - No changes to the API since 0.1.0-alpha.5. Symbol packages (`.snupkg`) are published alongside the package. ## [0.1.0-alpha.5] - 2026-10-25 ### Fixed - Ogg Vorbis: a short stream that fits on a single page, such as a brief click followed by digital silence, decoded to silence or lost its first or last samples. The one granule position on that page marks where the stream ends, but the decoder also read it as the position of the stream's first sample and cut the difference from the start. It is now taken as the end trim only. Streams whose first audio page is not the last page are unchanged. ## [0.1.0-alpha.4] - 2026-10-25 ### Added - `AudioMixer.TryEnableBusMeter(bus)`: enables a bus meter without throwing when no meter is free (or the mixer has none); `FreeBusMeters` tells "no slot" from "queue full" after a false. `AudioMixer.IsBusMetered(bus)` reports whether a bus currently holds a meter. - Results in the README: conformance, fuzzing and benchmarks against other libraries and native tools, losses included. ## [0.1.0-alpha.3] - 2026-10-24 ### Added - Per-bus loudness metering in the mixer. `MixerOptions.MaxBusMeters` (0 to 64, default 0) preallocates that many BS.1770 / EBU R 128 meters at construction; `AudioMixer.EnableBusMeter`, `DisableBusMeter` and `ResetBusMeter` turn one on, off or back to zero for any bus or for the master output (`AudioMixer.MasterBus`) through the command queue; `AudioMixer.TryGetBusLoudness` reads it from any thread without locking. The reading is a `LoudnessSnapshot` (momentary, short-term and integrated LUFS, true peak in dBTP, the two maxima, frames measured) published once per render pass, never torn. A bus is measured after its effects and gain; the master output is measured after the limiter. A mixer without enabled meters does no metering work, and the render path still allocates nothing with meters enabled and toggling. - `AudioMixer.FreeBusMeters` and `AudioMixer.FreeReverbSlots`, the number of meter and reverb slots not yet in use. ### Changed - **Behaviour change:** `AudioMixer.SetBusReverb` throws `InvalidOperationException` (naming `MixerOptions.MaxReverbs`) when the bus has no reverb and every reverb slot is taken, or the mixer has none. It used to return `false`, the same value as for a full command queue, which was easy to miss. `false` now means only that the command queue was full (and is counted in `DroppedCommands`). Retuning a bus that already has a reverb never throws. The signature is unchanged. ## [0.1.0-alpha.2] - 2026-10-24 ### Added - Decoders: FLAC (native and Ogg FLAC, bit-exact against the reference decoder on the public test corpus, exact seeking) and MP3 (MPEG-1, 2 and 2.5 Layer III with ID3v2, Xing/LAME and VBRI handling, gapless trimming and exact seeking, within the ISO/IEC 11172-4 accuracy limits). - Chained Ogg streams: consecutive links of the same format play as one stream, with exact seeking and length across links. - Loudness: `LoudnessMeter` (ITU-R BS.1770-5 and EBU R 128: momentary, short-term, integrated, loudness range, true peak), `LoudnessAnalyzer` and `LoudnessNormalization`; the EBU Tech 3341 and 3342 test signals are inside their tolerances. - Mixer effects: low-pass and high-pass filters on voices, buses and the master output, a reverb insert (`MixerOptions.MaxReverbs`), and a lookahead limiter on the master output (`MixerOptions.LimiterLookaheadMilliseconds`). All state is preallocated; the render path allocates nothing. - Streaming sources report position and length; ducking can be removed again (`DuckingSettings.None`, `ClearDucking`). - Encoders: `WavEncoder` (RIFF and RF64, extensible), `FlacEncoder` (levels 0 to 8, seek table, MD5; output decodes bit-exact), and the Opus encoder (SILK, hybrid and CELT, byte-identical to the RFC 6716 reference encoder on the tested configurations) with the Ogg Opus file writer (RFC 7845). ## [0.1.0-alpha.1] - 2026-10-24 ### Added - Decoders for WAV (PCM 8/16/24/32-bit, float, extensible), the Ogg container, Vorbis and Opus (all RFC 8251 test vectors pass), behind an explicit codec registry so unused codecs trim away. Streaming reads into caller-provided spans, with seeking. - Resampling: polyphase (two quality tiers) and linear. - The real-time mixer: voices and buses, gain, pan, sample-accurate start and loop points, crossfades, ducking, voice caps with priority, per-voice rate conversion and pitch, multichannel layouts, and a decode-ahead pump for streaming sources. The render callback is lock-free and allocates nothing. Vectorised mixing kernels with scalar reference paths. - NativeAOT and trim clean; fuzz targets for the parsers and decoders. # Decode MP3 in C# Decode MP3 files to float samples or WAV in C# with oneaudio, a managed decoder with no native code: gapless trimming, exact seeking, and MP3 bytes held in memory. ```csharp using OneAudio; // The whole file as float samples. AudioClip clip = Audio.Load("music.mp3"); Console.WriteLine($"music.mp3: {clip.Format.SampleRate} Hz, {clip.Format.Channels} ch, " + $"{clip.Frames} frames ({clip.Frames / 44100.0:F3} s)"); // The same audio encoded without the LAME/Xing header: nothing tells the // decoder how much padding the encoder added. using (IAudioDecoder plain = Audio.Open("music-noheader.mp3")) Console.WriteLine($"music-noheader.mp3: {plain.LengthFrames} frames " + $"({plain.LengthFrames / 44100.0:F3} s)"); // MP3 to WAV, streamed block by block. A lossy source is written as 16-bit // PCM unless you choose otherwise. TranscodeResult wav = Audio.Transcode("music.mp3", "music-decoded.wav"); long bytes = new FileInfo("music-decoded.wav").Length; Console.WriteLine($"music-decoded.wav: {wav.Frames} frames, {bytes:N0} bytes"); // MP3 bytes in memory, for example from a database or an HTTP response. byte[] mp3 = File.ReadAllBytes("music.mp3"); using IAudioDecoder decoder = Audio.Open(new MemoryStream(mp3)); float[] block = new float[1152 * decoder.Format.Channels]; decoder.Seek(44100 * 20); // exact: the same samples as decoding from the start int frames = decoder.Read(block); Console.WriteLine($"{decoder.GetType().Name}: read {frames} frames " + $"at {decoder.PositionFrames - frames}"); ``` ```text music.mp3: 44100 Hz, 2 ch, 1411200 frames (32.000 s) music-noheader.mp3: 1412352 frames (32.026 s) music-decoded.wav: 1411200 frames, 5,644,880 bytes Mp3Decoder: read 1152 frames at 882000 ``` ## Gapless length An MP3 encoder adds silence at the start and pads the end to a whole frame. LAME, and ffmpeg through LAME, records both amounts in a Xing/Info header with the LAME extension. oneaudio reads that header and trims both, so `music.mp3` decodes to exactly the 1,411,200 frames of the WAV it was made from. That’s what lets a loop or a track sequence play without a gap. `music-noheader.mp3` is the same audio encoded without the header. Nothing says how much padding there is, so the whole decoded signal is kept: 1,152 frames longer, with the encoder’s delay at the start. A VBRI header, or a Xing header without the LAME extension, gives only the frame count. ## Good to know - MPEG-1, MPEG-2 and MPEG-2.5 Layer III are supported, at fixed, variable or free-format bitrates, in mono, stereo, joint stereo and dual channel. Layer I and Layer II files throw `UnsupportedAudioFormatException`. - An ID3v2 tag at the start is skipped, and ID3v1, APE and Lyrics3 tags at the end are ignored. oneaudio 1.2.1 doesn’t return their contents. - Seeking is exact and gives the same samples as decoding from the start. The first seek in a file makes one pass over the frame headers. The decoder then starts up to 48 frames before the target, to rebuild the bit reservoir. - A frame that can’t be decoded, for example after a cut in the file, comes out as silence and decoding goes on. - Writing MP3 isn’t supported. To compress, write [Ogg Opus](https://recursiveai.org/oneaudio/cookbook/encode-opus/) or [FLAC](https://recursiveai.org/oneaudio/cookbook/flac/). - The decoder matches all 11 ISO/IEC 11172-4 compliance streams within the full-accuracy limits. The rest of the MP3 rules are in the [API overview](https://recursiveai.org/oneaudio/docs/api/#mp3). A longer guide, with NAudio on Linux compared: [Decode MP3 in C#](https://recursiveai.org/oneaudio/articles/decode-mp3-csharp/). # Decode Ogg Vorbis in C# Decode Ogg Vorbis files in C# with oneaudio: load or stream the samples, read the comment tags, and play chained Ogg files as one stream. ```csharp using OneAudio; using OneAudio.Vorbis; AudioClip clip = Audio.Load("music.ogg"); Console.WriteLine($"music.ogg: {clip.Format.SampleRate} Hz, " + $"{clip.Format.Channels} ch, {clip.Frames} frames"); // The concrete decoder carries the comment header. using (var vorbis = (VorbisDecoder)Audio.Open("music.ogg")) { Console.WriteLine($"vendor: {vorbis.Vendor}"); foreach (string comment in vorbis.Comments) // NAME=value Console.WriteLine($" {comment}"); } // A chained file: two Ogg streams one after the other, music and then an // announcement. using (var chained = (VorbisDecoder)Audio.Open("chained.ogg")) { Console.WriteLine($"chained.ogg: {chained.LinkCount} links, " + $"{chained.LengthFrames} frames in all"); float[] buffer = new float[4096 * chained.Format.Channels]; long total = 0; int read; while ((read = chained.Read(buffer)) > 0) total += read; Console.WriteLine($"read {total} frames"); Console.WriteLine($"comments of the last link: {string.Join(", ", chained.Comments)}"); } ``` ```text music.ogg: 44100 Hz, 2 ch, 1411200 frames vendor: Lavf62.3.100 encoder=Lavc62.11.100 libvorbis ARTIST=Test signal TITLE=Four chords chained.ogg: 2 links, 1676903 frames in all read 1676903 frames comments of the last link: encoder=Lavc62.11.100 libvorbis, TITLE=Announcement ``` ## Chained files An Ogg file can hold several streams one after another, such as a radio recording or tracks joined with `cat`. Consecutive links in the same format decode as one stream. `LengthFrames` covers every link, and seeking works across them. `LinkCount` says how many links there are, and `GetLinkFormat(link)` returns the format of each one. `Vendor` and `Comments` belong to the link being decoded, which is why the sample prints the announcement’s title after reading to the end. ## Good to know - `Audio.Open` returns a `VorbisDecoder` for Ogg Vorbis. Cast to it for `Vendor`, `Comments` and the link members. The same file through `Audio.Load` or the mixer needs no cast. - Comments are `NAME=value` strings exactly as the file stores them. Names come in any case (ffmpeg writes `encoder` in lowercase), so compare them without regard to case. See [Read audio metadata](https://recursiveai.org/oneaudio/cookbook/read-audio-metadata/). - The decoder was checked against libvorbis 1.3.7 on the Xiph test vectors: the same frame counts, and floor-1 streams within 2e-6. - oneaudio doesn’t write Vorbis. An `.ogg` output path in `Audio.Transcode` writes Ogg Opus. Streaming, exact seeking and chained files, compared with NVorbis: [Decode Ogg Vorbis in C#](https://recursiveai.org/oneaudio/articles/ogg-vorbis-csharp/). # Decode Opus in C# Decode Ogg Opus files in C# with oneaudio, convert them to your app’s sample rate, and decode raw Opus packets from a voice or network stream, with packet-loss concealment. ```csharp using OneAudio; using OneAudio.Opus; // Ogg Opus files always decode at 48 kHz, whatever rate the encoder was given. using (IAudioDecoder decoder = Audio.Open("music.opus")) { AudioFormat f = decoder.Format; Console.WriteLine($"music.opus: {decoder.GetType().Name}, {f.SampleRate} Hz, " + $"{f.Channels} ch, {decoder.LengthFrames / 48000.0:F3} s"); } // Load it at the rate your app uses. AudioClip clip = Audio.Load("music.opus", new LoadOptions { SampleRate = 44100 }); Console.WriteLine($"as 44.1 kHz: {clip.Frames} frames"); // Raw Opus packets, from a voice chat or a WebRTC stream, go through // OpusDecoder. Here they come from OpusEncoder: 20 ms of a 440 Hz tone each. var encoder = new OpusEncoder(new OpusEncoderSettings { Channels = 1, Application = OpusApplication.Voip, BitrateBps = 24_000, }); var packetDecoder = new OpusDecoder(sampleRate: 48000, channels: 1); float[] pcm = new float[960]; // 20 ms at 48 kHz float[] decoded = new float[OpusPacket.MaxSamplesPer48k]; // the longest packet byte[] packet = new byte[1500]; int bytes = 0, samples = 0; for (int p = 0; p < 50; p++) // one second { for (int i = 0; i < pcm.Length; i++) pcm[i] = 0.3f * MathF.Sin(2 * MathF.PI * 440 * (p * 960 + i) / 48000f); int length = encoder.Encode(pcm, 960, packet); bytes += length; samples += packetDecoder.Decode(packet.AsSpan(0, length), decoded); } Console.WriteLine($"50 packets, {bytes:N0} bytes, {samples} samples decoded"); // A lost packet: the decoder fills the gap. The span's length sets how much, // here one 20 ms frame. int concealed = packetDecoder.DecodePacketLoss(decoded.AsSpan(0, 960)); Console.WriteLine($"concealed {concealed} samples"); ``` ```text music.opus: OpusStreamDecoder, 48000 Hz, 2 ch, 32.000 s as 44.1 kHz: 1411200 frames 50 packets, 3,161 bytes, 48000 samples decoded concealed 960 samples ``` ## Files and packets - **Ogg Opus files** (`.opus`, and `.ogg` files holding Opus) open with `Audio.Open` or `Audio.Load` like any other format. They always decode at 48 kHz, the rate Opus works at internally, even when the encoder was given 44.1 kHz audio. The pre-skip and the output gain in the header are applied, and the last granule position trims the end, so the length is the length of the original audio. - **Raw packets** go through `OpusDecoder`. Construct it with the output rate, 48 or 24 kHz, and one or two channels. A stereo stream is downmixed to mono, or a mono stream copied to both channels, to match. `Decode` returns the samples per channel it wrote. Give it a buffer of `OpusPacket.MaxSamplesPer48k` samples per channel, enough for the longest packet (120 ms). - **A lost packet**: call `DecodePacketLoss` with a span as long as the gap, here 20 ms, instead of `Decode`. ## Good to know - The decoder passes all 12 RFC 8251 test vectors, on both the normal and the no-phase-inversion sets. - It decodes SILK, CELT and hybrid packets. Ogg Opus files can have up to eight channels (channel mapping family 1), and chained files play as one stream. - Its CELT packet-loss concealment follows its own rules rather than the reference decoder’s, and the conformance vectors don’t test it. - To write Opus, see [Encode Opus](https://recursiveai.org/oneaudio/cookbook/encode-opus/). Opus files and raw packets, compared with Concentus and OpusSharp: [Opus in C#](https://recursiveai.org/oneaudio/articles/opus-csharp/). # Decode audio from a stream Decode audio in C# from a stream that can’t seek, such as a network response or a pipe, block by block with flat memory, with limits and typed errors for untrusted input. ```csharp using System.Diagnostics; using OneAudio; // A stream that can't seek, like a network response or a pipe: here the // output of `cat`. var cat = Process.Start(new ProcessStartInfo("cat", "music.ogg") { RedirectStandardOutput = true, })!; Stream pipe = cat.StandardOutput.BaseStream; Console.WriteLine($"pipe: CanSeek {pipe.CanSeek}"); // Limits for input you don't trust. Sizes read from the file are checked // before anything is allocated. var limits = new AudioLimits { MaxChannels = 2, MaxSampleRate = 48000 }; using var cancel = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using IAudioDecoder decoder = Audio.Open(pipe, leaveOpen: false, limits, cancel.Token); int channels = decoder.Format.Channels; Console.WriteLine($"{decoder.Format.SampleRate} Hz, {channels} ch, CanSeek {decoder.CanSeek}"); // Block by block: memory use doesn't grow with the length of the file. float[] block = new float[1024 * channels]; long frames = 0; double sumSquares = 0; int read; while ((read = decoder.Read(block)) > 0) { foreach (float s in block.AsSpan(0, read * channels)) sumSquares += s * s; frames += read; } double rms = 10 * Math.Log10(sumSquares / (frames * channels)); Console.WriteLine($"{frames} frames, RMS {rms:F1} dBFS"); try { decoder.Seek(0); } catch (NotSupportedException) { Console.WriteLine("Seek on a pipe: NotSupportedException"); } // A file over the limits is refused with an exception that names the limit. try { using IAudioDecoder tooBig = Audio.Open("music.wav", new AudioLimits { MaxSampleRate = 32000 }); } catch (AudioLimitExceededException e) { Console.WriteLine(e.Message); } // Bytes that aren't audio at all. try { using IAudioDecoder notAudio = Audio.Open(new MemoryStream("not audio"u8.ToArray())); } catch (UnsupportedAudioFormatException e) { Console.WriteLine($"{e.GetType().Name}: {e.Message}"); } ``` ```text pipe: CanSeek False 44100 Hz, 2 ch, CanSeek False 1411200 frames, RMS -24.8 dBFS Seek on a pipe: NotSupportedException MaxSampleRate limit exceeded: input needs 44100, limit is 32000. UnsupportedAudioFormatException: No registered container format recognises this data. ``` ## How it works - `Audio.Open(stream)` finds the format in the first 512 bytes. A stream that can’t seek is read through a small replay buffer, so detection works on pipes and sockets too. - The decoder reads only as much of the stream as each `Read` needs. Memory doesn’t grow with the length of the file, and no decoder allocates per read. - `CanSeek` is false for a stream that can’t seek, and `Seek` throws `NotSupportedException`. `LengthFrames` is −1 when the length can’t be known in advance. - `leaveOpen: false` disposes the stream with the decoder. ## Untrusted input - `AudioLimits` caps the channel count, sample rate, packet and frame sizes, metadata, codec setup data and container elements. Every size read from the input is checked before the allocation it guards. `AudioLimits.Default` applies when you pass none. - Every error about the data is a `OneAudioException`. `AudioFormatException` means malformed data. `UnsupportedAudioFormatException`, a subclass, means valid data in a format that isn’t registered or supported. `AudioLimitExceededException` names the limit, the maximum and what the file asked for. - Apart from those, the library throws only argument exceptions, `NotSupportedException`, `ObjectDisposedException`, the stream’s own I/O exceptions and `OperationCanceledException`. - Every parser and decoder is fuzzed. See [Results](https://github.com/recursive-ai-oss/oneaudio-csharp/blob/2ced4d52857c4a762b4a66e7da2885eb30f825c0/README.md#fuzzing) in the README. For the decoder interface in full, see [Decoding](https://recursiveai.org/oneaudio/docs/api/#decoding) and [Errors and limits](https://recursiveai.org/oneaudio/docs/api/#errors-and-limits). # Read audio metadata Read the sample rate, channels, bit depth, duration and tags of WAV, MP3, Ogg Vorbis, Opus and FLAC files in C# with oneaudio, without decoding the audio. ```csharp using OneAudio; using OneAudio.Flac; using OneAudio.Vorbis; // Format, length and tags of every file in the folder, without decoding // the audio. foreach (string path in Directory.GetFiles(".").Order()) { try { using IAudioDecoder d = Audio.Open(path); AudioFormat f = d.Format; string length = d.LengthFrames >= 0 ? $"{d.LengthFrames / (double)f.SampleRate:F2} s" : "unknown length"; string name = Path.GetFileName(path); Console.WriteLine($"{name,-19}{d.GetType().Name,-18}{f.SampleRate,6} Hz " + $"{f.Channels} ch {f.NativeType,-12}{length}"); IReadOnlyList? tags = d switch { VorbisDecoder v => v.Comments, FlacDecoder fl => fl.Comments, _ => null, // Opus and MP3 tags aren't exposed in 1.2.1 }; foreach (string tag in tags ?? []) { int eq = tag.IndexOf('='); // NAME=value, in any case Console.WriteLine($"{"",19}{tag[..eq].ToUpperInvariant()}: {tag[(eq + 1)..]}"); } } catch (UnsupportedAudioFormatException) { Console.WriteLine($"{Path.GetFileName(path),-19}not an audio format oneaudio reads"); } } ``` ```text chained.ogg VorbisDecoder 44100 Hz 2 ch Lossy 38.03 s ENCODER: Lavc62.11.100 libvorbis ARTIST: Test signal TITLE: Four chords game-mix.wav WavDecoder 48000 Hz 2 ch PcmSigned16 16.00 s hit.wav WavDecoder 48000 Hz 1 ch PcmSigned16 0.40 s music-noheader.mp3 Mp3Decoder 44100 Hz 2 ch Lossy 32.03 s music.flac FlacDecoder 44100 Hz 2 ch PcmSigned16 32.00 s DATE: 2026 TITLE: Four chords ARTIST: Test signal ENCODER: Lavf62.3.100 music.mp3 Mp3Decoder 44100 Hz 2 ch Lossy 32.00 s music.ogg VorbisDecoder 44100 Hz 2 ch Lossy 32.00 s ENCODER: Lavc62.11.100 libvorbis ARTIST: Test signal TITLE: Four chords music.opus OpusStreamDecoder 48000 Hz 2 ch Lossy 32.00 s music.wav WavDecoder 44100 Hz 2 ch PcmSigned16 32.00 s voice.wav WavDecoder 16000 Hz 1 ch PcmSigned16 6.03 s ``` ## What you can read | | Format and length | Tags | |---|---|---| | WAV / RF64 | Yes | No | | FLAC | Yes, plus the true bit depth (`FlacDecoder.BitsPerSample`) and the MD5 signature | `FlacDecoder.Comments` and `Vendor` | | Ogg Vorbis | Yes | `VorbisDecoder.Comments` and `Vendor` | | Ogg Opus | Yes | Not exposed in 1.2.1 | | MP3 | Yes | Not exposed in 1.2.1: ID3 tags are skipped | - Opening a file reads its headers, not its audio. For an MP3 with no Xing, Info or VBRI header, opening makes one pass over the frame headers to find the length. - `AudioFormat.NativeType` says how the samples are stored: `PcmUnsigned8`, `PcmSigned16`, `PcmSigned24`, `PcmSigned32`, `IeeeFloat32`, `IeeeFloat64`, or `Lossy` for MP3, Vorbis and Opus. `ChannelMask` gives the speaker layout as `WAVE_FORMAT_EXTENSIBLE` speaker bits. - `chained.ogg` shows the first link’s tags until the decoder reaches the second link. See [Decode Ogg Vorbis](https://recursiveai.org/oneaudio/cookbook/decode-ogg-vorbis/#chained-files). - To write tags, pass `Comments` in `FlacEncoderSettings` or `OpusFileWriterSettings`. WAV files are written without tags. # Read and write FLAC in C# Decode FLAC files bit-exact in C# with oneaudio, verify the MD5 signature, read the tags, and encode WAV to FLAC at compression levels 0 to 8 with your own tags. ```csharp using OneAudio; using OneAudio.Flac; // Read: FlacDecoder adds the true bit depth, the tags and the MD5 check. using (var flac = (FlacDecoder)Audio.Open("music.flac")) { Console.WriteLine($"music.flac: {flac.Format.SampleRate} Hz, {flac.Format.Channels} ch, " + $"{flac.BitsPerSample}-bit, {flac.LengthFrames} frames"); Console.WriteLine($"tags: {string.Join(", ", flac.Comments)}"); // Decodes the whole file; the read position is restored afterwards. Console.WriteLine($"MD5 matches STREAMINFO: {flac.VerifyMd5()}"); // Exact integer samples, for lossless work. int[] ints = new int[4096 * flac.Format.Channels]; int frames = flac.ReadInteger(ints); int max = ints.Take(frames * 2).Max(Math.Abs); Console.WriteLine($"first {frames} frames as integers, max {max}"); } // Write: WAV to FLAC at the highest compression level, with tags. long written; using (IAudioDecoder source = Audio.Open("music.wav")) using (FileStream output = File.Create("music-level8.flac")) { written = FlacEncoder.Encode(source, output, new FlacEncoderSettings { CompressionLevel = 8, // 0 (fastest) to 8 (smallest), default 5 Comments = ["TITLE=Four chords", "ARTIST=Test signal"], }); } long flacBytes = new FileInfo("music-level8.flac").Length; long wavBytes = new FileInfo("music.wav").Length; Console.WriteLine($"music-level8.flac: {written} frames, {flacBytes:N0} bytes " + $"(WAV: {wavBytes:N0})"); // Check the round trip: the decoded integers equal the WAV's. using IAudioDecoder a = Audio.Open("music.wav"), b = Audio.Open("music-level8.flac"); int[] x = new int[8192], y = new int[8192]; bool same = true; int n; while ((n = a.ReadInteger(x)) > 0) same &= b.ReadInteger(y) == n && x.AsSpan(0, n * 2).SequenceEqual(y.AsSpan(0, n * 2)); Console.WriteLine($"bit-exact: {same}"); ``` ```text music.flac: 44100 Hz, 2 ch, 16-bit, 1411200 frames tags: DATE=2026, TITLE=Four chords, ARTIST=Test signal, encoder=Lavf62.3.100 MD5 matches STREAMINFO: True first 4096 frames as integers, max 8423 music-level8.flac: 1411200 frames, 788,714 bytes (WAV: 5,644,878) bit-exact: True ``` ## Reading - `ReadInteger` returns the stream’s exact samples. A depth that isn’t a whole number of bytes is shifted up to the container width in `Format.BitsPerSample`, so a 12-bit stream reads as 16-bit values. `FlacDecoder.BitsPerSample` is the true depth. `Read` returns floats scaled to the stream’s own depth. - `VerifyMd5` decodes the whole file and compares it with the signature in STREAMINFO. It returns null when the encoder didn’t store one, and it puts the read position back where it was. - Every frame is CRC-checked. A frame that contradicts STREAMINFO is rejected with `AudioFormatException`, after the frames before it have been delivered. - Ogg FLAC opens the same way as native FLAC. ## Writing - `CompressionLevel` runs from 0 (fastest) to 8 (smallest), and the default is 5. Output stays within FLAC’s streamable subset unless you set `Subset = false`. - On a seekable stream, `Finish` (or `Dispose`) goes back and writes the length, the MD5 signature and a seek table. On a stream that can’t seek, give `TotalFrames` if you know it. There’s no MD5 or seek table. - `BitsPerSample` 0, the default, keeps the integer depth of the input format, or writes 16 bits for float and lossy input. Float samples are rounded to that depth, and `Dither = true` adds triangular dither first. - The encoder writes native FLAC only, not Ogg FLAC. - The project tests the encoder’s output against the reference `flac -d`: it decodes bit-exact, and files are within 0.4% of the size `flac` produces at the same level. To convert a whole folder or other formats, see [Convert audio files](https://recursiveai.org/oneaudio/cookbook/convert-audio-files/). Every encoder setting is in the [API overview](https://recursiveai.org/oneaudio/docs/api/#encoding). Decoding, encoding, tags and the MD5 check: [Read and write FLAC in C#](https://recursiveai.org/oneaudio/articles/flac-csharp/). # Read and write WAV files in C# Read WAV and RF64 files in C# with oneaudio as exact integers or floats, and write 16-bit, 24-bit or 32-bit float WAV, with dither when you reduce the bit depth. ```csharp using OneAudio; // Read a WAV file: the format says how the samples are stored. using (IAudioDecoder wav = Audio.Open("music.wav")) { AudioFormat f = wav.Format; Console.WriteLine($"music.wav: {f.SampleRate} Hz, {f.Channels} ch, {f.NativeType}, " + $"{f.BitsPerSample} bits, {wav.LengthFrames} frames"); int[] ints = new int[8 * f.Channels]; wav.ReadInteger(ints); // the exact 16-bit values float[] floats = new float[8 * f.Channels]; wav.Seek(0); wav.Read(floats); // the same samples scaled to -1..1 Console.WriteLine($"sample 4: {ints[4]} as an integer, {floats[4]:F6} as a float " + $"({ints[4] / 32768.0:F6})"); } // Write floats as 24-bit PCM. AudioClip clip = Audio.Load("music.wav"); AudioFormat src = clip.Format; var format24 = new AudioFormat(src.SampleRate, src.Channels, src.ChannelMask, NativeSampleType.PcmSigned24, 24); using (var encoder = new WavEncoder(File.Create("music-24.wav"), format24, new WavEncoderSettings(), leaveOpen: false)) encoder.Write(clip.Samples.Span); // Dispose finishes the file using (IAudioDecoder check = Audio.Open("music-24.wav")) Console.WriteLine($"music-24.wav: {check.Format.NativeType}, {check.LengthFrames} frames, " + $"{new FileInfo("music-24.wav").Length:N0} bytes"); // Back to 16-bit with triangular dither, or to 32-bit float. Audio.Transcode("music-24.wav", "music-16-dither.wav", new TranscodeOptions { Wav = new WavEncoderSettings { SampleType = NativeSampleType.PcmSigned16, Dither = true }, }); Audio.Transcode("music.wav", "music-float.wav", new TranscodeOptions { Wav = new WavEncoderSettings { SampleType = NativeSampleType.IeeeFloat32 }, }); foreach (string name in new[] { "music-16-dither.wav", "music-float.wav" }) { using IAudioDecoder d = Audio.Open(name); Console.WriteLine($"{name}: {d.Format.NativeType}, {new FileInfo(name).Length:N0} bytes"); } ``` ```text music.wav: 44100 Hz, 2 ch, PcmSigned16, 16 bits, 1411200 frames sample 4: 803 as an integer, 0.024506 as a float (0.024506) music-24.wav: PcmSigned24, 1411200 frames, 8,467,304 bytes music-16-dither.wav: PcmSigned16, 5,644,880 bytes music-float.wav: IeeeFloat32, 11,289,716 bytes ``` ## Reading - PCM at 8, 16, 24 and 32 bits, IEEE float at 32 and 64 bits, `WAVE_FORMAT_EXTENSIBLE` and RF64 (WAV over 4 GiB) all open with `Audio.Open`. - `Read` gives floats with full scale at ±1. `ReadInteger` gives the stored integers exactly. 8-bit samples are re-centered to −128..127. - A WAV header whose sizes say “unknown” (0xFFFFFFFF), as written to a pipe, is read to the end of the stream. ## Writing - `WavEncoderSettings.SampleType` picks the stored type. By default it’s the format’s own type, or 16-bit PCM when the source is lossy. - Floats are scaled, rounded to nearest and clipped to the integer range. NaN becomes 0. `Dither = true` adds one LSB of triangular dither first, from a fixed seed (`DitherSeed`), so the same input gives the same file. - `WAVE_FORMAT_EXTENSIBLE` with a channel mask is used for more than two channels or more than 16 bits. Float files get a `fact` chunk. - On a seekable stream, `Finish` writes the final sizes, and a file that outgrows 4 GiB is turned into RF64. On a stream that can’t seek, give `TotalFrames` for exact sizes. `ForceRf64` writes RF64 from the start. - `WavEncoder.Encode(decoder, stream)` copies a whole decoder, and `Audio.Transcode(input, "out.wav")` does the same from a path. 16-bit, 24-bit, float and RF64 files: [Read and write WAV files in C#](https://recursiveai.org/oneaudio/articles/wav-files-csharp/). # Encode Opus in C# Encode WAV, FLAC or MP3 to Ogg Opus in C# with oneaudio, a managed Opus encoder: choose the bitrate and mode, add tags, and encode speech with the VoIP application. ```csharp using OneAudio; using OneAudio.Opus; // One call with the defaults: VBR at the encoder's automatic bitrate. Audio.Transcode("music.wav", "music-auto.opus"); Console.WriteLine($"music-auto.opus: {new FileInfo("music-auto.opus").Length:N0} bytes"); // Choose the bitrate and add tags. var settings = new OpusFileWriterSettings { Encoder = new OpusEncoderSettings { BitrateBps = 64_000, BitrateMode = OpusBitrateMode.ConstrainedVbr, }, Comments = ["TITLE=Four chords", "ARTIST=Test signal"], }; Audio.Transcode("music.flac", "music-64k.opus", new TranscodeOptions { Opus = settings }); Console.WriteLine($"music-64k.opus: {new FileInfo("music-64k.opus").Length:N0} bytes"); // Speech: the VoIP application, mono, 16 kbit/s. The writer resamples the // 16 kHz input to 48 kHz. AudioClip voice = Audio.Load("voice.wav"); var voiceSettings = new OpusFileWriterSettings { Channels = 1, InputSampleRate = voice.Format.SampleRate, Encoder = new OpusEncoderSettings { Application = OpusApplication.Voip, BitrateBps = 16_000, }, }; using (var writer = new OpusFileWriter(File.Create("voice-16k.opus"), voiceSettings, leaveOpen: false)) { writer.Write(voice.Samples.Span); // call as often as you like writer.Finish(); Console.WriteLine($"voice-16k.opus: {writer.FramesWritten} frames, pre-skip {writer.PreSkip}, " + $"{new FileInfo("voice-16k.opus").Length:N0} bytes"); } // Read them back: the decoded length is the audio written, at 48 kHz. foreach (string f in new[] { "music-64k.opus", "voice-16k.opus" }) { using IAudioDecoder d = Audio.Open(f); Console.WriteLine($"{f}: {d.Format.SampleRate} Hz, {d.Format.Channels} ch, " + $"{d.LengthFrames / 48000.0:F3} s"); } ``` ```text music-auto.opus: 404,461 bytes music-64k.opus: 260,511 bytes voice-16k.opus: 289201 frames, pre-skip 312, 11,805 bytes music-64k.opus: 48000 Hz, 2 ch, 32.000 s voice-16k.opus: 48000 Hz, 1 ch, 6.025 s ``` ## Settings - `OpusEncoderSettings.BitrateBps` takes 500 to 300,000 bits per second per channel, or `OpusEncoderSettings.AutoBitrate` (the default) or `MaxBitrate`. `BitrateMode` is `Vbr` (the default), `ConstrainedVbr` or `Cbr`. - `Application` is `Audio` for music and general sound, `Voip` for speech, or `RestrictedLowDelay`. `Complexity` runs from 0 to 10, and the default is 10. - The encoder picks SILK, hybrid or CELT for each packet, the way the reference encoder does. `Bandwidth` and `MaxBandwidth` pin the bandwidth. `InBandFec` with a `PacketLossPercent` above 0 adds forward error correction, and `Dtx` makes silence cost one byte per packet. - Input that isn’t at 48 kHz is resampled by the file writer, which is why `voice-16k.opus` has about three times as many frames as the 16 kHz source. The header records the original rate. - `OpusFileWriterSettings` takes 48 kHz stereo input unless you set `InputSampleRate` and `Channels`. `Audio.Transcode` sets both from the source. `FrameMilliseconds` sets the packet length: 2.5, 5, 10, 20 (the default), 40 or 60 ms. ## Good to know - With the same settings and 16-bit input, the packets are byte-identical to the RFC 6716 reference encoder on all 269 configurations the project tested. The same input gives the same bytes on a platform. - More than two channels are written as channel mapping family 1, up to eight channels, in WAV speaker order. - `OpusEncoder` alone encodes raw packets of 2.5 to 60 ms at 48 kHz, for a network stream. See [Decode Opus](https://recursiveai.org/oneaudio/cookbook/decode-opus/) for a round trip through it. - Encoding allocates nothing after the encoder is built. Opus files and raw packets, compared with Concentus and OpusSharp: [Opus in C#](https://recursiveai.org/oneaudio/articles/opus-csharp/). # Convert audio files Convert MP3, Ogg Vorbis, Opus, FLAC and WAV files to WAV, FLAC or Ogg Opus in C# with oneaudio, change the sample rate and channels on the way, and convert streams in memory. ```csharp using OneAudio; // Every format can be read; WAV, FLAC and Ogg Opus can be written. string[] inputs = ["music.mp3", "music.ogg", "music.opus", "music.flac", "music.wav"]; foreach (string input in inputs) { string ext = Path.GetExtension(input).TrimStart('.'); string output = $"music-from-{ext}.flac"; TranscodeResult r = Audio.Transcode(input, output); long size = new FileInfo(output).Length; Console.WriteLine($"{input,-10} -> {output,-20} {r.Frames,7} frames, " + $"{r.Input.SampleRate} Hz, {size,7:N0} bytes"); } // Convert rate and channels on the way: a 16 kHz mono line to 48 kHz stereo. var options = new TranscodeOptions { SampleRate = 48000, Channels = 2 }; TranscodeResult voice = Audio.Transcode("voice.wav", "voice.opus", options); Console.WriteLine($"voice.opus: {voice.Input.Channels} ch at {voice.Input.SampleRate} Hz, " + $"{voice.Frames} frames"); // Streams have no file name, so the format must be given. using (var input = File.OpenRead("music.ogg")) using (var output = new MemoryStream()) { Audio.Transcode(input, output, new TranscodeOptions { Format = AudioFileFormat.Wav }); Console.WriteLine($"music.ogg to WAV in memory: {output.Length:N0} bytes"); } // MP3, Vorbis and AAC can't be written. try { Audio.Transcode("music.wav", "music-out.mp3"); } catch (ArgumentException e) { Console.WriteLine(e.Message); } ``` ```text music.mp3 -> music-from-mp3.flac 1411200 frames, 44100 Hz, 890,883 bytes music.ogg -> music-from-ogg.flac 1411200 frames, 44100 Hz, 867,043 bytes music.opus -> music-from-opus.flac 1536000 frames, 48000 Hz, 998,012 bytes music.flac -> music-from-flac.flac 1411200 frames, 44100 Hz, 813,193 bytes music.wav -> music-from-wav.flac 1411200 frames, 44100 Hz, 813,193 bytes voice.opus: 2 ch at 48000 Hz, 289200 frames music.ogg to WAV in memory: 5,644,880 bytes Cannot tell the output format from the extension '.mp3'; use .wav, .flac or .opus, or set TranscodeOptions.Format. (Parameter 'path') ``` ## What can be written | From | To WAV | To FLAC | To Ogg Opus | |---|---|---|---| | Integer WAV, FLAC | Bit-exact, same depth | Bit-exact, same depth | Lossy | | Float WAV | Same float type | 16-bit by default | Lossy | | MP3, Ogg Vorbis, Ogg Opus | 16-bit PCM by default | 16-bit by default | Lossy, again | Bit-exact holds when nothing is converted. A change of rate or channels goes through floats, and converted 24 and 32-bit audio is written to FLAC at 24 bits. - The output format comes from the extension: `.wav` or `.wave`, `.flac`, and `.opus`, `.ogg` or `.oga` for Ogg Opus. Set `TranscodeOptions.Format` to choose it yourself. A stream has no extension, so it needs `Format`. - There’s no MP3, Vorbis or AAC encoder. An `.mp3` output path throws `ArgumentException`, and `.ogg` writes Opus, not Vorbis. - `SampleRate` and `Channels` in `TranscodeOptions` convert on the way. Mono becomes two or more channels as a copy on the front pair. Other changes fold speakers down: the center goes into left and right at −3 dB, and stereo to mono takes each side at −6 dB. Folded sums aren’t normalized, so loud multichannel audio can pass full scale. - The conversion streams block by block, so memory use doesn’t depend on the length. If it fails, the partial output file is deleted. - `music.opus` converts at 48 kHz, the rate Opus decodes at. Add `SampleRate = 44100` to get the source rate back. - `music-from-wav.flac` and `music-from-flac.flac` are byte-identical: the same samples and the same settings give the same bytes. The encoder settings are `TranscodeOptions.Wav`, `Flac` and `Opus`. See [Read and write WAV](https://recursiveai.org/oneaudio/cookbook/wav/), [Read and write FLAC](https://recursiveai.org/oneaudio/cookbook/flac/) and [Encode Opus](https://recursiveai.org/oneaudio/cookbook/encode-opus/). When a managed library can replace FFmpeg, with ffmpeg commands mapped to C#: [Audio in .NET without FFmpeg](https://recursiveai.org/oneaudio/articles/audio-without-ffmpeg-dotnet/). # Resample audio in C# Resample audio in C# with oneaudio, from 44.1 kHz to 48 kHz and back or any ratio up to 64, in one call or as a stream, with three quality levels and no allocation while running. ```csharp using OneAudio; // A whole clip, 44.1 kHz to 48 kHz with the default Polyphase filter. AudioClip clip = Audio.Load("music.wav"); AudioClip at48 = Audio.Resample(clip, 48000); Console.WriteLine($"{clip.Frames} frames at 44.1 kHz -> {at48.Frames} at 48 kHz"); // And back. The length is always round(frames x ratio), and the filter // delay is removed. AudioClip back = Audio.Resample(at48, 44100); Console.WriteLine($"and back: {back.Frames} frames"); // Interleaved floats you already hold, at a chosen quality. float[] voice = Audio.Load("voice.wav").Samples.ToArray(); // 16 kHz mono ResamplerQuality[] qualities = [ResamplerQuality.Polyphase, ResamplerQuality.PolyphaseFast, ResamplerQuality.Linear]; foreach (ResamplerQuality q in qualities) { float[] up = Audio.Resample(voice, channels: 1, fromRate: 16000, toRate: 48000, q); Console.WriteLine($"{q,-13} {voice.Length} -> {up.Length} samples"); } // Streaming, for live audio: feed blocks of any size. The output doesn't // depend on how the input was split. IResampler resampler = AudioResampler.Create(inputRate: 44100, outputRate: 48000, channels: 2, ResamplerQuality.Polyphase); float[] input = clip.Samples.ToArray(); float[] output = new float[resampler.MaxOutputFrames(512) * 2]; // fits 512 in long produced = 0; for (int pos = 0; pos < clip.Frames;) { int frames = (int)Math.Min(512, clip.Frames - pos); resampler.Process(input.AsSpan(pos * 2, frames * 2), output, out int used, out int written); pos += used; // input that didn't fit is offered again produced += written; } int tail; while ((tail = resampler.Flush(output)) > 0) // what the filter still holds produced += tail; Console.WriteLine($"streamed: {produced} frames, latency {resampler.LatencyFrames:F1} frames"); ``` ```text 1411200 frames at 44.1 kHz -> 1536000 at 48 kHz and back: 1411200 frames Polyphase 96400 -> 289200 samples PolyphaseFast 96400 -> 289200 samples Linear 96400 -> 289200 samples streamed: 1536001 frames, latency 78.4 frames ``` ## Choosing a quality | Quality | Filter | Use it for | |---|---|---| | `Polyphase` (default) | 144-tap Kaiser-windowed sinc, flat within 0.05 dB to 0.9 of the lower Nyquist frequency, more than 100 dB of alias rejection | Files and anything you keep | | `PolyphaseFast` | 44 taps, flat to 0.8 of the lower Nyquist frequency, about 75 dB of alias rejection | Real-time voices in a mixer | | `Linear` | Two taps | Pitch effects where speed matters more than quality | When the rate goes down, the filter’s cutoff follows the new Nyquist frequency. ## One call or a stream - `Audio.Resample` returns exactly round(frames × ratio) frames, with the filter delay removed so that output frame m is the input at time m / ratio. It accepts ratios from 1/64 to 64. - `AudioResampler.Create` returns an `IResampler` for audio that arrives in blocks. Everything is allocated when it’s created, and `Process` allocates nothing. The output doesn’t depend on how the input is split. Input that doesn’t fit in the output buffer is left unused and must be offered again, which is what `pos += used` does. - `Flush` returns the frames still held in the filter. Call it until it returns 0. The streamed total can be a frame longer than `Audio.Resample`’s, which trims to round(frames × ratio), as in this run. - `LatencyFrames` is the filter’s look-ahead, in output frames. - `SetRatio` changes the ratio while running, without a glitch. That’s how the mixer bends pitch. Keep each change to a few percent. The mixer resamples each voice whose source rate differs from its own, so audio for `AudioMixer` needs no resampling first. Every resampler detail is in the [API overview](https://recursiveai.org/oneaudio/docs/api/#resampling). A longer guide: [Resample audio in C#](https://recursiveai.org/oneaudio/articles/resample-audio-csharp/). # Mix audio files Mix music, voice and sound effects in C# with oneaudio’s real-time mixer: buses, ducking, reverb, pitch, a limiter and loudness meters, rendered to a WAV file or to your own audio output. ```csharp using OneAudio; using OneAudio.Loudness; // Two buses, a meter on the master output and a limiter in front of it. var mixer = new AudioMixer(new MixerOptions { SampleRate = 48000, Channels = 2, MaxBusMeters = 1, LimiterLookaheadMilliseconds = 1.0, }); mixer.SetLimiter(new LimiterSettings { CeilingDb = -1f }); const int Music = 0, Voice = 1; mixer.SetBusGain(Music, 1.5f); var ducking = new DuckingSettings { ReductionDb = 12f, ReleaseMs = 400f }; mixer.SetDucking(Music, Voice, ducking); var reverb = new ReverbSettings { RoomSize = 0.4f, DecaySeconds = 0.8f, Wet = 0.15f }; mixer.SetBusReverb(Voice, reverb); mixer.EnableBusMeter(AudioMixer.MasterBus); // Clips are decoded once and can be shared by any number of voices. AudioClip music = Audio.Load("music.flac"); AudioClip voice = Audio.Load("voice.wav"); AudioClip hit = Audio.Load("hit.wav"); mixer.Play(new PlayParams(music) { Bus = Music, Loop = true }); mixer.Play(new PlayParams(voice) { Bus = Voice, StartAtFrame = 48000 * 4 }); for (int i = 0; i < 4; i++) { mixer.Play(new PlayParams(hit) { Bus = Voice, StartAtFrame = 48000 * 12 + i * 12000, // frame-exact Pan = i % 2 == 0 ? -0.6f : 0.6f, Pitch = 1f + i * 0.12f, }); } // Render 16 seconds into a WAV file, reading the master meter once a second. var format = new AudioFormat(48000, 2, AudioFormat.DefaultMask(2), NativeSampleType.PcmSigned16, 16); using var wav = new WavEncoder(File.Create("game-mix.wav"), format, new WavEncoderSettings(), leaveOpen: false); float[] block = new float[480 * 2]; // 10 ms blocks for (int b = 1; b <= 1600; b++) { mixer.Render(block); wav.Write(block); if (b % 100 != 0) continue; if (mixer.TryGetBusLoudness(AudioMixer.MasterBus, out LoudnessSnapshot m)) Console.Write($"{b / 100,2}s {m.MomentaryLufs,6:F1}{(b % 400 == 0 ? "\n" : " ")}"); } mixer.TryGetBusLoudness(AudioMixer.MasterBus, out LoudnessSnapshot total); Console.WriteLine($"master: {total.IntegratedLufs:F1} LUFS integrated, " + $"true peak {total.TruePeakDbtp:F1} dBTP"); while (mixer.TryGetEvent(out MixerEvent e)) Console.WriteLine($"{e.Kind} voice {e.VoiceId}"); Console.WriteLine($"limiter: {mixer.LimiterGainReductionDb:F1} dB in the last pass, " + $"{mixer.ActiveVoices} voice active"); ``` ```text 1s -20.7 2s -20.7 3s -20.9 4s -20.8 5s -18.1 6s -16.6 7s -17.7 8s -18.3 9s -17.9 10s -23.6 11s -21.1 12s -20.8 13s -19.6 14s -22.0 15s -21.0 16s -21.0 master: -18.7 LUFS integrated, true peak -1.8 dBTP VoiceEnded voice 2 VoiceEnded voice 3 VoiceEnded voice 4 VoiceEnded voice 5 VoiceEnded voice 6 limiter: 0.0 dB in the last pass, 1 voice active ``` `game-mix.wav` is 16 seconds of looping music, an announcement from 4 seconds with reverb, and four clicks from 12 seconds, alternating left and right and rising in pitch. Rendering the same mix with and without the announcement showed the music 10 to 12 dB lower while the announcement played, and back to full level within a second of it ending. ## The mixer - **Voices** play an `AudioClip` (decoded audio in memory, shareable between voices) or a `StreamingSource` (a file decoded ahead on a background thread, for long music). `mixer.Play(path)` and `mixer.PlayStream(path, loop: true)` do the loading for you. - **Buses** sum voices. Each bus feeds the master output or another bus (`SetBusOutput`), and has its own gain, mute, filters and reverb. `MaxBuses` is 8 by default. - **Ducking**: `SetDucking(target, source, settings)` turns the target bus down while the source bus is above `ThresholdDb`, by `ReductionDb`, with `AttackMs` and `ReleaseMs`. - **Effects**: `SetBusFilter` and `SetVoiceFilter` add a low-pass or high-pass filter, 12 dB per octave. `SetBusReverb` adds a reverb, up to `MaxReverbs` buses at a time (2 by default). The master limiter keeps every sample under its ceiling. It limits sample peaks, not true peaks. - **Meters**: `MaxBusMeters` reserves loudness meters, and `TryGetBusLoudness` reads one from any thread without locking. A bus is metered after its filters, reverb and gain, but before it’s ducked into its parent, so meter the master output to see the effect of ducking. - **Events**: `TryGetEvent` reports voices that ended or were stolen, plays that were dropped and underruns. Nothing is called back on the render thread. ## In a real-time app `Render` allocates nothing, takes no locks, never blocks and never throws, so it’s safe on the audio thread. Call `Play`, `SetGain`, `SetDucking` and the rest from another thread. They go through a lock-free queue, and `Render` applies them between its processing passes of at most `MaxBlockFrames` (1,024 by default). If the queue is full, a call returns false and the command is dropped. To play the mix on the sound card, add `RecursiveAI.OneAudio.Devices` and call `Audio.Play(mixer)`: the device pulls from `Render` on its own thread. See [Play an audio file](https://recursiveai.org/oneaudio/cookbook/play-audio-file/). With an output API you already use, call `mixer.Render(buffer)` from its callback and copy the interleaved floats it fills. Every mixer option is in the [API overview](https://recursiveai.org/oneaudio/docs/api/#mixing). Buses, ducking and a zero-allocation mix for games: [Game audio in C#](https://recursiveai.org/oneaudio/articles/game-audio-mixer-csharp/). # Measure loudness (LUFS, true peak, LRA) Measure integrated loudness in LUFS, loudness range and true peak in C# with oneaudio’s ITU-R BS.1770-5 and EBU R 128 meter, for a whole file or live, block by block. The input is `game-mix.wav`, the file [Mix audio files](https://recursiveai.org/oneaudio/cookbook/mix-audio/) writes. ```csharp using OneAudio; using OneAudio.Loudness; // One call: integrated loudness, loudness range and true peak. LoudnessResult r = Audio.Measure("game-mix.wav"); Console.WriteLine($"game-mix.wav: {r.IntegratedLufs:F1} LUFS, LRA {r.LoudnessRange:F1} LU, " + $"true peak {r.TruePeakDbtp:F1} dBTP"); Console.WriteLine($"max momentary {r.MaxMomentaryLufs:F1}, max short-term {r.MaxShortTermLufs:F1}"); // Skip true peak (4x oversampling) when you only need LUFS: it's the costly part. double lufs = Audio.Measure("game-mix.wav", truePeak: false).IntegratedLufs; Console.WriteLine($"without true peak: {lufs:F1} LUFS"); // A live meter: feed blocks as they come, and read momentary (400 ms) and // short-term (3 s) loudness at any time. using IAudioDecoder decoder = Audio.Open("game-mix.wav"); var meter = new LoudnessMeter(decoder.Format, truePeak: true, loudnessRange: true); int channels = decoder.Format.Channels; float[] block = new float[4800 * channels]; // 100 ms int read, n = 0; while ((read = decoder.Read(block)) > 0) { meter.Process(block.AsSpan(0, read * channels)); if (++n % 20 == 0) // every 2 s Console.WriteLine($"{n / 10,3} s momentary {meter.MomentaryLufs,6:F1}" + $" short-term {meter.ShortTermLufs,6:F1}"); } Console.WriteLine($"integrated {meter.IntegratedLufs:F1} LUFS, range {meter.LoudnessRange:F1} LU, " + $"true peak {meter.TruePeakDbtp:F1} dBTP"); ``` ```text game-mix.wav: -18.7 LUFS, LRA 2.6 LU, true peak -1.8 dBTP max momentary -14.1, max short-term -16.7 without true peak: -18.7 LUFS 2 s momentary -20.7 short-term -21.1 4 s momentary -20.8 short-term -19.4 6 s momentary -16.6 short-term -17.3 8 s momentary -18.3 short-term -17.6 10 s momentary -23.6 short-term -19.1 12 s momentary -20.8 short-term -19.9 14 s momentary -22.0 short-term -19.1 16 s momentary -21.0 short-term -20.2 integrated -18.7 LUFS, range 2.6 LU, true peak -1.8 dBTP ``` ffmpeg’s `ebur128` filter gives the same integrated loudness (−18.7 LUFS) and true peak (−1.8 dBTP) for this file. ## The readings | Reading | What it is | |---|---| | `IntegratedLufs` | The loudness of the whole program, gated: blocks below −70 LUFS, and blocks 10 LU below the ungated level, are left out | | `MomentaryLufs`, `MaxMomentaryLufs` | Over the last 400 ms | | `ShortTermLufs`, `MaxShortTermLufs` | Over the last 3 s | | `LoudnessRange` | The spread of short-term loudness in LU, per EBU Tech 3342 | | `TruePeakDbtp` | The highest peak between samples as well as on them, found by 4× oversampling, across all channels | Before any audio, the readings are negative infinity and the range is 0. ## Good to know - The meter passes EBU Tech 3341 cases 1 to 23 and Tech 3342 cases 1 to 6 within their tolerances. - It works at any sample rate from 4 kHz up, and the K-weighting filters are designed for the actual rate. - Channels are weighted from the channel mask: 1.41 for surround and side channels, and the LFE channel is left out of the loudness but not the true peak. - The results don’t depend on how the audio is split into blocks. Everything is allocated in the constructor, so a meter can run on a real-time thread. - True peak is the expensive part. Pass `truePeak: false` when you only need LUFS. - The true-peak filter lags by 12 samples, so a peak in the last 12 frames shows only after more audio arrives. - The mixer has the same meters built in, per bus. See [Mix audio files](https://recursiveai.org/oneaudio/cookbook/mix-audio/). To change a file’s loudness, see [Normalize loudness](https://recursiveai.org/oneaudio/cookbook/normalize-loudness/). A longer guide to EBU R 128 and streaming targets: [Loudness normalization in C#](https://recursiveai.org/oneaudio/articles/loudness-normalization-csharp/). # Normalize loudness to −14 or −23 LUFS Normalize audio loudness in C# with oneaudio: write a copy at −14 LUFS, or −23 LUFS for EBU R 128 broadcast, with a true-peak ceiling, or scale samples in memory. ```csharp using OneAudio; using OneAudio.Loudness; // File to file at the default target: -14 LUFS, true peak at most -1 dBTP. NormalizeResult web = Audio.Normalize("music.wav", "music-14.wav"); Console.WriteLine($"-14 LUFS: was {web.Before.IntegratedLufs:F1} LUFS, " + $"gain {web.GainDb:+0.00;-0.00} dB, limited: {web.LimitedByPeak}"); // EBU R 128 broadcast delivery: -23 LUFS. The output format follows the extension. var broadcastOptions = new NormalizeOptions { TargetLufs = -23 }; NormalizeResult broadcast = Audio.Normalize("game-mix.wav", "game-mix-23.flac", broadcastOptions); Console.WriteLine($"-23 LUFS: was {broadcast.Before.IntegratedLufs:F1} LUFS, " + $"gain {broadcast.GainDb:+0.00;-0.00} dB"); // A target the ceiling won't allow: the gain stops where the true peak // reaches -1 dBTP. var loudOptions = new NormalizeOptions { TargetLufs = -9 }; NormalizeResult loud = Audio.Normalize("game-mix.wav", "game-mix-9.wav", loudOptions); LoudnessResult after = Audio.Measure("game-mix-9.wav"); Console.WriteLine($"-9 LUFS: gain {loud.GainDb:+0.00;-0.00} dB, limited: {loud.LimitedByPeak}, " + $"result {after.IntegratedLufs:F1} LUFS at {after.TruePeakDbtp:F1} dBTP"); // In memory: a new clip, or samples scaled in place. AudioClip clip = Audio.Load("voice.wav"); var voiceOptions = new NormalizeOptions { TargetLufs = -16 }; AudioClip level = Audio.Normalize(clip, out NormalizeResult v, voiceOptions); Console.WriteLine($"voice clip: gain {v.GainDb:+0.00;-0.00} dB, " + $"now {Audio.Measure(level).IntegratedLufs:F1} LUFS"); // Only the number: the gain to reach a target, without touching the audio. NormalizationGain g = Audio.Measure("music.flac").GainFor(targetLufs: -16); Console.WriteLine($"music.flac needs {g.GainDb:+0.00;-0.00} dB (x{g.Linear:F3}) for -16 LUFS"); ``` ```text -14 LUFS: was -23.0 LUFS, gain +8.98 dB, limited: False -23 LUFS: was -18.7 LUFS, gain -4.32 dB -9 LUFS: gain +0.76 dB, limited: True, result -17.9 LUFS at -1.0 dBTP voice clip: gain +1.60 dB, now -16.0 LUFS music.flac needs +6.98 dB (x2.234) for -16 LUFS ``` ## How it works - `Audio.Normalize` measures the integrated loudness and true peak, works out the gain that reaches the target, and writes the audio with that gain. It decodes the input twice, once to measure and once to write, so it never holds the whole file in memory. - The default target is −14 LUFS with a true-peak ceiling of −1 dBTP. `NormalizeOptions.TargetLufs` sets any target, such as −23 for EBU R 128. - If the gain would push the true peak over the ceiling, the gain is cut back to meet it, and `LimitedByPeak` is true. The target isn’t reached. In the −9 LUFS case above, `game-mix.wav` peaks at −1.8 dBTP, so it could be raised only 0.76 dB. oneaudio applies a gain and nothing else: it doesn’t compress or limit the audio to get louder. - `TruePeakCeilingDbtp = null` turns the ceiling off, and then a boost can clip when it’s written as integers. - Silence gets a gain of 0 dB. - The output format comes from the extension, as in `Audio.Transcode`. `NormalizeOptions.Output` takes the same conversion and encoder options. ## In memory - `Audio.Normalize(clip, out result, options)` returns a new clip. - `Audio.Normalize(span, format, options)` scales samples in place. - `LoudnessResult.GainFor(target, ceiling)` returns the gain without applying it. Note that its default target is −23 LUFS, where `Audio.Normalize` defaults to −14. To measure without changing anything, see [Measure loudness](https://recursiveai.org/oneaudio/cookbook/measure-loudness/). A longer guide to EBU R 128 and streaming targets: [Loudness normalization in C#](https://recursiveai.org/oneaudio/articles/loudness-normalization-csharp/). # Keep only the codecs you use Make small NativeAOT and trimmed .NET apps with oneaudio: register only the formats you read with an AudioKit, so the other decoders are trimmed away. ```csharp using OneAudio; // Audio.* registers every codec, so an app that calls Audio.Load carries them // all. An AudioKit over your own registry has the same methods and keeps only // what you add. var registry = new AudioCodecRegistry().AddWav().AddOgg().AddVorbis(); var kit = new AudioKit(registry); AudioClip hit = kit.Load("hit.wav"); AudioClip music = kit.Load("music.ogg"); Console.WriteLine($"hit.wav {hit.Frames} frames, music.ogg {music.Frames} frames"); try { kit.Load("music.mp3"); } catch (UnsupportedAudioFormatException e) { Console.WriteLine($"music.mp3: {e.Message}"); } // These never touch a registry, so they add no codec. AudioClip at48 = Audio.Resample(music, 48000); double lufs = Audio.Measure(at48).IntegratedLufs; Console.WriteLine($"resampled: {at48.Frames} frames, {lufs:F1} LUFS"); AudioMixer mixer = Audio.CreateMixer(); kit.Play(mixer, "hit.wav"); float[] block = new float[1024 * 2]; mixer.Render(block); Console.WriteLine($"mixer: {mixer.ActiveVoices} voice playing"); ``` ```text hit.wav 19200 frames, music.ogg 1411200 frames music.mp3: No registered container format recognises this data. resampled: 1536000 frames, -23.0 LUFS mixer: 1 voice playing ``` ## Why `Audio.Load`, `Audio.Open`, `Audio.Transcode`, `Audio.Measure(path)`, `Audio.Normalize(path, ...)` and `mixer.Play(path)` use `Audio.Kit`, which registers every codec. Nothing is found by reflection, so a codec you never register is never referenced, and the trimmer removes it. - `new AudioKit(registry)` has the same methods with the same defaults, over the registry you give it. `kit.Play(mixer, path)` and `kit.PlayStream(mixer, path)` stand in for the mixer extensions. - The registry starts empty. Add containers and codecs: `AddWav()`, `AddOgg()`, `AddVorbis()`, `AddOpus()`, `AddFlac()`, `AddMp3()`. Ogg Vorbis and Ogg Opus need `AddOgg()` as well as the codec, and so does Ogg FLAC. - A file in a format you didn’t register throws `UnsupportedAudioFormatException`, as `music.mp3` does above. - `Audio.Resample`, `Audio.Measure(clip)`, `Audio.Normalize` on samples or clips, and `Audio.CreateMixer` never touch a registry. - The encoders are linked only into apps that write files with `Transcode` or `Normalize`, or use an encoder directly. ## Measured Published for linux-x64 with `dotnet publish -c Release -r linux-x64 -p:PublishAot=true`, this program built with no warnings, and the binary was 1,893,616 bytes. The same program with `Audio.Kit` in place of the three-codec kit was 2,136,096 bytes. The project’s own measurements, on a smaller app, are in the [README](https://github.com/recursive-ai-oss/oneaudio-csharp/blob/2ced4d52857c4a762b4a66e7da2885eb30f825c0/README.md#trimming-and-nativeaot). When a managed library can replace FFmpeg, with ffmpeg commands mapped to C#: [Audio in .NET without FFmpeg](https://recursiveai.org/oneaudio/articles/audio-without-ffmpeg-dotnet/). # Play an audio file Play a WAV, MP3, Ogg or FLAC file on the default sound card in C#, with oneaudio’s mixer and the `RecursiveAI.OneAudio.Devices` package, and check from the device’s own counters that it played. ```sh dotnet add package RecursiveAI.OneAudio --version 1.2.1 dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1 ``` ```csharp using OneAudio; using OneAudio.Devices; // A mixer is the source; Audio.Play opens the default output and starts it. AudioMixer mixer = Audio.CreateMixer(); // 48 kHz stereo using AudioOutputDevice output = Audio.Play(mixer); AudioClip clip = Audio.Load("voice.wav"); // 16 kHz mono; the mixer converts mixer.Play(clip); long frames = clip.Frames * mixer.SampleRate / clip.Format.SampleRate; // Wait until the device's own clock says the whole clip has played. var timeout = DateTime.UtcNow.AddSeconds(15); while (output.Counters.FramesPlayed < frames && DateTime.UtcNow < timeout) Thread.Sleep(50); DeviceCounters c = output.Counters; AudioDeviceFormat device = output.DeviceFormat; double rate = mixer.SampleRate; Console.WriteLine($"{output.BackendName}: {output.Device?.Id ?? "no device"}, " + $"{output.State}"); Console.WriteLine($"device format {device.SampleRate} Hz, {device.Channels} ch, " + $"latency {c.LatencyMilliseconds:F0} ms"); Console.WriteLine($"clip {frames / rate:F2} s, " + $"device played {c.FramesPlayed / rate:F2} s, " + $"peak {20 * Math.Log10(c.PeakPlayed):F1} dBFS, " + $"underruns {c.Underruns}"); if (output.LastError is not null) Console.WriteLine($"last error: {output.LastError}"); ``` ```text alsa: default, Running device format 48000 Hz, 2 ch, latency 25 ms clip 6.03 s, device played 6.06 s, peak -7.5 dBFS, underruns 0 ``` This ran on Linux, through ALSA’s `default` device to PipeWire, with the PipeWire sink muted so the run made no sound. The stream itself ran as usual. - `Audio.Play(mixer)` opens the default output at the mixer’s rate and channel count and starts pulling from `Render` on its own thread. Any voice the mixer plays is heard, so a game keeps one output and calls `mixer.Play` for each sound. - `FramesPlayed` comes from the device’s clock, not from what was handed over, so it’s the right thing to wait on. It runs a little past the clip because the device keeps playing silence. `PeakPlayed` is the loudest sample among frames the device has finished: 0 means it only played silence. - `DeviceFormat` is what the hardware runs at. When it differs from the mixer’s rate, the package converts with the library’s polyphase resampler. - The output limiter leaves samples up to 0.9 alone and bends anything louder smoothly under 1, so a hot mix doesn’t clip. - When no device can be opened, `State` is `NoDevice`, the output renders silently at real-time pace, retries twice a second, and `LastError` says why. The program above then stops at its timeout and prints the error. - `using` disposes the output, which stops and closes the device. `AudioMixer` isn’t disposable. For a long file, stream it instead of loading it: `mixer.PlayStream("music.ogg")` decodes ahead on a background thread. To play on a device other than the default, see [List audio devices](https://recursiveai.org/oneaudio/cookbook/list-audio-devices/). MP3, WAV and Ogg playback on Windows, Linux and macOS, compared with SoundPlayer and NAudio: [Play audio in C#](https://recursiveai.org/oneaudio/articles/play-audio-csharp/). # Record from the microphone Record audio from the default microphone in C# and save it as a 16-bit WAV file, with the `RecursiveAI.OneAudio.Devices` package and oneaudio’s WAV encoder. ```sh dotnet add package RecursiveAI.OneAudio --version 1.2.1 dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1 ``` ```csharp using OneAudio; using OneAudio.Devices; // Record three seconds from the default input, 48 kHz mono, into a WAV file. var options = new DeviceStreamOptions { SampleRate = 48000, Channels = 1 }; var format = new AudioFormat(48000, 1); const long total = 3 * 48000L; using var input = Audio.CreateInput(options); // opens and starts the device var settings = new WavEncoderSettings { SampleType = NativeSampleType.PcmSigned16 }; using (var wav = new WavEncoder(File.Create("take.wav"), format, settings, leaveOpen: false)) { float[] block = new float[4800]; // 100 ms long written = 0; var timeout = DateTime.UtcNow.AddSeconds(8); while (written < total && DateTime.UtcNow < timeout) { int read = input.Read(block); // frames; 0 when nothing waits if (read == 0) { Thread.Sleep(20); continue; } int keep = (int)Math.Min(read, total - written); wav.Write(block.AsSpan(0, keep)); written += keep; } } // Dispose finishes the file Console.WriteLine($"{input.BackendName}: {input.Device?.Id ?? "no device"}, " + $"{input.State}, overruns {input.Counters.Overruns}"); using IAudioDecoder check = Audio.Open("take.wav"); AudioFormat f = check.Format; Console.WriteLine($"take.wav: {f.SampleRate} Hz, {f.Channels} ch, " + $"{check.LengthFrames / (double)f.SampleRate:F2} s, " + $"{new FileInfo("take.wav").Length:N0} bytes"); ``` ```text alsa: default, Running, overruns 0 take.wav: 48000 Hz, 1 ch, 3.00 s, 288,080 bytes ``` This ran on Linux, through ALSA’s `default` capture device to PipeWire, recording from a USB webcam microphone. - `Audio.CreateInput(options)` opens the default input and starts it. Audio arrives as 32-bit floats at the rate and channel count you ask for, converted from whatever the hardware runs at. - `Read` copies whatever has arrived, in whole frames, and returns 0 when nothing is waiting. It never blocks, so the loop sleeps briefly between reads. - Captured audio waits in a buffer of `CaptureBufferMilliseconds` (1,000 by default). If you don’t read for longer than that, new audio is dropped and counted in `Overruns`. - `WavEncoderSettings.SampleType` picks the stored type. The 288,080 bytes are 3 seconds of 16-bit mono at 48 kHz plus the header. - To process audio as it arrives instead, pass a `CaptureCallback` to `Audio.CreateInput`. It runs on the device thread, so it must not allocate, lock or block. On macOS and iOS, the program needs microphone permission. On iOS, set `DeviceStreamOptions.AppleSession` to `PlayAndRecord` and give the app a microphone usage description. On Windows, the microphone privacy setting has to allow desktop apps. A refused open says so in `LastError`. A longer walk-through with a live level meter: [Record audio in C#](https://recursiveai.org/oneaudio/articles/record-audio-csharp/). # List audio devices List the playback and recording devices in C#, find the system defaults, and open a particular device by its id, with the `RecursiveAI.OneAudio.Devices` package. ```sh dotnet add package RecursiveAI.OneAudio --version 1.2.1 dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1 ``` ```csharp using OneAudio.Devices; // The operating system's audio API: WASAPI, Core Audio or ALSA. Console.WriteLine($"backend: {AudioDevices.Backend.Name}, " + $"available: {AudioDevices.IsAvailable}"); DeviceDirection[] directions = [DeviceDirection.Output, DeviceDirection.Input]; foreach (DeviceDirection direction in directions) { IReadOnlyList devices = AudioDevices.List(direction); AudioDeviceInfo? def = AudioDevices.Default(direction); Console.WriteLine($"{direction}: {devices.Count} devices, " + $"default: {def?.Id ?? "none"}"); } // Pick a device by part of its name. Id is what DeviceStreamOptions.DeviceId takes; // it is backend specific, so match on Name and pass Id through untouched: // new DeviceStreamOptions { DeviceId = d.Id }. string wanted = args.Length > 0 ? args[0] : "USB Audio"; foreach (AudioDeviceInfo d in AudioDevices.List(DeviceDirection.Output)) { if (!d.Name.Contains(wanted, StringComparison.OrdinalIgnoreCase)) continue; Console.WriteLine($"first output matching \"{wanted}\": {d.Id}"); Console.WriteLine($" {d.Name.Replace('\n', ' ')}"); break; } ``` ```text backend: alsa, available: True Output: 90 devices, default: default Input: 45 devices, default: default first output matching "USB Audio": hw:CARD=Audio,DEV=0 USB Audio, USB Audio Direct hardware device without any conversions ``` This ran on a Linux desktop with PipeWire, a USB audio interface, a webcam and HDMI outputs. ALSA lists every PCM name it knows, plug-ins included, which is why the counts are high. On Windows and macOS the list is the endpoints you see in the system’s sound settings. - `AudioDevices.List(direction)` returns an `AudioDeviceInfo` per device: `Id`, `Name`, `IsDefault`, and, where the backend can tell, `DefaultSampleRate`, `MaxChannels` and `MinLatencyFrames`. Call it from a control thread, never from an audio callback. - `Id` is backend specific. On ALSA it’s a PCM name such as `default` or `hw:CARD=Audio,DEV=0`; on Windows it’s the endpoint id. Don’t parse it; pass it to `DeviceStreamOptions.DeviceId`. - With no `DeviceId`, a stream uses the system default and follows it when it changes. `SelectDevice(id)` moves an open output or input to another device; `SelectDevice(null)` goes back to the default. - `AudioDevices.DevicesChanged` fires when devices are plugged in or removed, on WASAPI and Core Audio. ALSA has no hot-plug events, so on Linux read the list again when you need it. - `IsAvailable` is false when the system’s audio library isn’t there, such as libasound on a server. Outputs then render silently and inputs deliver nothing. Choosing a device and playing to it: [Play audio in C#](https://recursiveai.org/oneaudio/articles/play-audio-csharp/).