---
title: "A zero-allocation audio mixer for C# games"
url: https://recursiveai.org/oneaudio/articles/game-audio-mixer-csharp/
description: "Game audio in C#: a real-time mixer with buses, ducking, reverb and a limiter that allocates nothing on the audio thread, played directly or via MonoGame."
date: 2026-10-27
author: OneAudio team
---

# A zero-allocation audio mixer for C# games

Game audio in C#: a real-time mixer with buses, ducking, reverb and a limiter that allocates nothing on the audio thread, played directly or via MonoGame.

October 27, 2026 · OneAudio team

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

*Terminal · NuGet*

```
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

*Program.cs · OneAudio.Devices 1.2.1 · .NET 10*

```
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}");
```

*Output · Console · Linux*

```
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.** `RenderAllocatedBytes` is 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 `MaxReverbs` buses (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 `AudioClip` and shared by every voice that plays them. Loading at the mixer’s rate avoids a converter per voice; a voice with a `Pitch` other than 1 takes one from a pool, built on the control thread, never on the render thread.
- **Long sounds** stream: `PlayStream` decodes 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 and `UnderrunEvents` counts 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; `StolenVoices` and `DroppedPlays` count both.
- **The master limiter** from `Audio.CreateMixer` keeps 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](https://github.com/recursive-ai-oss/oneaudio-csharp/blob/v1.2.1/README.md#mixer-effects-meters) (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`](https://docs.monogame.net/api/Microsoft.Xna.Framework.Audio.DynamicSoundEffectInstance.html) plays PCM buffers your game submits, and raises `BufferNeeded` when two or fewer are queued. Rendering the mixer into it:

*Program.cs · MonoGame.Framework.DesktopGL 3.8.6 + OneAudio 1.2.1*

```
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}");
```

*Output · Console · Linux*

```
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: `BufferNeeded` fires with at most two queued, so a buffer is never refilled while it may still be waiting to play.
- In a `Game`, `FrameworkDispatcher.Update` runs 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 with `output.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](https://recursiveai.org/oneaudio/cookbook/mix-audio/), the cookbook recipe, and the [mixer section of the API overview](https://recursiveai.org/oneaudio/docs/api/#mixing): filters, reverb, limiter, bus meters and every counter.
- [Play audio in C#](https://recursiveai.org/oneaudio/articles/play-audio-csharp/): devices, generated samples and what happens without a device.
- [Decode Ogg Vorbis in C#](https://recursiveai.org/oneaudio/articles/ogg-vorbis-csharp/), with sample-exact loop points for music.
