For game audio in C#, OneAudio’s AudioMixer plays any number of voices through buses with ducking, filters, reverb and a limiter, and its render call allocates nothing, takes no locks and never blocks, so the garbage collector never stalls your audio thread. Add RecursiveAI.OneAudio.Devices and Audio.Play(mixer) sends the mix to the speakers on Windows, Linux and macOS; or call mixer.Render(buffer) from the audio callback of the engine you already use, such as MonoGame. Every sample below was compiled and run against OneAudio 1.2.1 on .NET 10, on Linux with a real output device.
Before you start
dotnet new console -o GameAudio cd GameAudio dotnet add package RecursiveAI.OneAudio.Devices --version 1.2.1
The device package brings in RecursiveAI.OneAudio, which has the mixer and the decoders (WAV, MP3, Ogg Vorbis, Opus, FLAC). Both target .NET 10, publish with NativeAOT and are licensed MIT OR Apache-2.0. The samples use music.ogg, an 8-second loop, hit.wav, a quarter-second hit, and line.wav, two seconds of speech.
A game mix: music, effects and dialogue
using OneAudio; using OneAudio.Devices; // Buses: 0 is the default; give music, effects and dialogue their own. const int Music = 1, Sfx = 2, Dialogue = 3; AudioMixer mixer = Audio.CreateMixer(sampleRate: 48000, channels: 2, maxVoices: 64); // limiter on the master mixer.SetBusGain(Music, 0.6f); mixer.SetDucking(Music, Dialogue, new DuckingSettings { ReductionDb = 12, ReleaseMs = 400 }); // music dips under speech mixer.SetBusReverb(Sfx, new ReverbSettings { RoomSize = 0.6f, DecaySeconds = 1.2f, Wet = 0.2f }); using AudioOutputDevice output = Audio.Play(mixer); // the default output device // Load short sounds once, at the mixer's rate, and share them between voices. AudioClip hit = Audio.Load("hit.wav", new LoadOptions { SampleRate = 48000 }); // Music streams from disk and loops; effects and dialogue are one-shots. using StreamingVoice music = mixer.PlayStream("music.ogg", new PlayOptions { Bus = Music, Loop = true }); for (int i = 0; i < 12; i++) // a burst of hits, spread across the stereo field { mixer.Play(hit, new PlayOptions { Bus = Sfx, Pan = -0.8f + 0.15f * i, Pitch = 0.9f + 0.02f * i }); Thread.Sleep(120); } mixer.Play("line.wav", new PlayOptions { Bus = Dialogue }); Thread.Sleep(2500); DeviceCounters c = output.Counters; Console.WriteLine($"played {c.FramesPlayed / 48000.0:F2} s, underruns {c.Underruns}, " + $"allocated on the audio thread: {c.RenderAllocatedBytes} bytes"); Console.WriteLine($"voices now {mixer.ActiveVoices}, dropped commands {mixer.DroppedCommands}, stolen voices {mixer.StolenVoices}");
played 3.98 s, underruns 0, allocated on the audio thread: 0 bytes voices now 1, dropped commands 0, stolen voices 0
- Nothing allocated on the audio thread.
RenderAllocatedBytesis the device package’s own measurement of the thread’s allocations in every callback after the first eight. Twelve one-shots, a streaming loop, a dialogue line, ducking and reverb rendered for four seconds without a byte. - Buses group voices: every voice feeds one bus, each bus feeds the master output or another bus, and a bus has its own gain, mute, low-pass and high-pass filters and, on up to
MaxReverbsbuses (two by default), a reverb. Pan and bus gain changes are smoothed over 10 ms by default, so they don’t click. - Ducking turns one bus down while another is loud: here the music drops 12 dB under the dialogue and comes back over 400 ms. Threshold, depth, attack and release are in
DuckingSettings. - Short sounds are decoded once into an
AudioClipand shared by every voice that plays them. Loading at the mixer’s rate avoids a converter per voice; a voice with aPitchother than 1 takes one from a pool, built on the control thread, never on the render thread. - Long sounds stream:
PlayStreamdecodes ahead on one shared background thread, and the render thread only reads a preallocated ring. If the ring ever runs dry, the voice plays silence for the gap andUnderrunEventscounts it. - Voice limits: when all voices are busy, a new sound replaces the lowest-priority one (
PlayOptions.Priority), or is dropped if every playing voice outranks it;StolenVoicesandDroppedPlayscount both. - The master limiter from
Audio.CreateMixerkeeps every sample under −1 dBFS, so twelve hits at once don’t clip.
The rules of the audio thread
Everything you call from game code (Play, Stop, SetGain, SetPitch, the bus calls) writes a command into a preallocated lock-free queue that the render thread drains at the start of each pass. Nothing waits for the audio thread, and the audio thread never waits for you. If the queue is full, the call returns false and DroppedCommands counts it. Going the other way, the render thread posts events such as VoiceEnded and VoiceStolen, which you poll with mixer.TryGetEvent from the game loop; nothing is called back on the audio thread.
What it costs, from the project’s published benchmarks (BenchmarkDotNet 0.15.8, AMD Ryzen 9 7950X3D, Linux, .NET 10.0.13): one 1,024-frame render of 256 looping stereo voices took 71.0 µs, and a game-style mix of 64 voices on 4 buses with low-pass and high-pass filters on each bus, one reverb and the master limiter took 311.6 µs, 1.5% of one core for a 21.3 ms block. All 0 bytes allocated.
Inside MonoGame or another engine
If your engine already owns the audio output, keep it and let the mixer fill its buffers. MonoGame’s DynamicSoundEffectInstance plays PCM buffers your game submits, and raises BufferNeeded when two or fewer are queued. Rendering the mixer into it:
using System.Buffers.Binary; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using OneAudio; // The OneAudio mixer as the sound engine of a MonoGame game, played through DynamicSoundEffectInstance. AudioMixer mixer = Audio.CreateMixer(sampleRate: 48000, channels: 2); using StreamingVoice music = mixer.PlayStream("music.ogg", loop: true); const int Frames = 1024; // about 21 ms per buffer float[] mix = new float[Frames * 2]; byte[][] buffers = [new byte[Frames * 4], new byte[Frames * 4], new byte[Frames * 4], new byte[Frames * 4]]; int next = 0; var output = new DynamicSoundEffectInstance(48000, AudioChannels.Stereo); output.BufferNeeded += (_, _) => // raised when 2 or fewer buffers are queued { mixer.Render(mix); // no allocation, no locks byte[] pcm = buffers[next]; next = (next + 1) % buffers.Length; // never touch a buffer that may still be queued for (int i = 0; i < mix.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(2 * i), (short)(Math.Clamp(mix[i], -1f, 1f) * 32767)); output.SubmitBuffer(pcm); }; output.Play(); // In a game, Game.Update does this for you. A console test pumps it by hand. var clock = System.Diagnostics.Stopwatch.StartNew(); while (clock.Elapsed < TimeSpan.FromSeconds(3)) { FrameworkDispatcher.Update(); Thread.Sleep(5); } Console.WriteLine($"{output.State}, mixer timeline {mixer.TimelineFrames / 48000.0:F2} s, underruns {mixer.UnderrunEvents}");
Playing, mixer timeline 3.05 s, underruns 0- MonoGame takes 16-bit PCM, so the float mix is converted per sample into preallocated buffers. Four buffers rotate:
BufferNeededfires with at most two queued, so a buffer is never refilled while it may still be waiting to play. - In a
Game,FrameworkDispatcher.Updateruns as part of the game loop; the console test calls it by hand. - Any engine with a “fill this buffer” callback works the same way: call
mixer.Render(span)with the interleaved float buffer at the mixer’s rate and channel count. For a single-threaded host such as a browser, the device package’s manual mode lets the host step the output itself withoutput.Advance(frames), with no thread started by the package. - MonoGame is licensed MS-PL; OneAudio is MIT OR Apache-2.0.
Where next
- Mix audio, the cookbook recipe, and the mixer section of the API overview: filters, reverb, limiter, bus meters and every counter.
- Play audio in C#: devices, generated samples and what happens without a device.
- Decode Ogg Vorbis in C#, with sample-exact loop points for music.