Step 2 of 8
AudioMixer
The bus. Sources go in, one signal comes out. Namespace: OwnaudioNET.Mixing
The mixer sums every source you give it and pushes the result to the engine. It also owns everything that belongs to "the whole mix": master volume and pan, the master effect chain, peak levels for your meters, WAV recording of the output, and the master clock that keeps tracks together.
One mixer is enough for almost every application.
Creating one
// Wired straight at the engine: the rust session renders, a control tick mirrors state
var mixer = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine, bufferSizeInFrames: 512);| Parameter | Type | Default | Description |
|---|---|---|---|
engine | IAudioEngine | โ | From OwnaudioNet.Engine!.UnderlyingEngine. |
bufferSizeInFrames | int | 512 | Internal mix chunk size. Use 1024 for 20+ tracks. |
Building through the engine wrapper
The constructor wires the mixer straight at the engine. AudioMixer.Create() wires it at the AudioEngineWrapper instead, so the mixer also picks up the wrapper's lifecycle and device events โ a device being unplugged or swapped reaches the mixer instead of only the engine.
await OwnaudioNet.InitializeAsync(config);
OwnaudioNet.Start();
var mixer = AudioMixer.Create(OwnaudioNet.Engine!, bufferSizeInFrames: 512);
// Add sources and effects as usual
mixer.AddSource(source1);
mixer.AddMasterEffect(new CompressorEffect());
await host.InitializeAudioAsync(sampleRate: 48000, maxBlockSize: 512);
mixer.AddMasterEffect(host.GetProcessor()); // VST3
mixer.Start();| Parameter | Type | Default | Description |
|---|---|---|---|
engineWrapper | AudioEngineWrapper | โ | Pass OwnaudioNet.Engine!. The factory extracts the underlying engine and keeps the wrapper. |
bufferSizeInFrames | int | 512 | Internal mix chunk size. Use 1024 for 20+ tracks. |
Playback headroom is not tunable from managed code. Since 4.0 the render ring lives on the Rust side, so neither AudioMixer.Create() nor the bufferMultiplier argument changes how much slack the output has. If you hear crackling under a heavy mix, raise bufferSizeInFrames (or the device buffer in AudioConfig) instead.
Properties
| Property | Type | Description |
|---|---|---|
MixerId | Guid | Unique identifier. |
Config | AudioConfig | Audio configuration in use. |
IsRunning | bool | True between a successful Start and the next Stop/Dispose. |
SourceCount | int | Number of active sources. |
MasterVolume | float | Master output volume (0.0 โ 1.0). Settable at any time. |
MasterPan | float | Master stereo pan (-1.0 left โฆ 0.0 center โฆ +1.0 right). Settable at any time. |
LeftPeak / RightPeak | float | Real-time peak levels (0.0 โ 1.0) for VU metering. |
TotalMixedFrames | long | The master clock's sample position. It follows a Seek, so it is a playhead, not a running total. |
IsRecording | bool | Whether WAV recording is active. |
MasterClock | MasterClock | Timeline clock for synchronized multi-track sync. |
RenderingMode | ClockMode | The master clock's mode. Set by the network sync helpers; Offline is inert since 4.0 โ see Offline rendering. |
MasterChannelScope | int[] | Bus channels the master chain, volume and pan act on. Empty (default) = the whole bus. See Master scope. |
SessionLoad | AudioStreamLoad? | What the device callback the mix renders in is costing. null while the mixer does not own the device. See Callback load. |
Lifecycle
mixer.Start(); // begin audio processing
mixer.Pause(); // hold the mix where it is
mixer.Stop(); // stop every source, but they stay registered
mixer.Seek(double positionInSec); // move the master clock and every file track with it
mixer.Dispose(); // release all resourcesSources on the bus
AddSource() adds and starts โ but only onto a mixer that is already running. On a stopped mixer it merely registers the source, and Start() does not go back and start it: the managed source stays Stopped, which means it never drives the master clock and its OutputLevels stay at zero. Start the mixer first, or use the prepared path. When several tracks must begin on the same frame, add them prepared and release them together.
// Add and immediately start โ the mixer has to be running for the "start" half
mixer.Start();
mixer.AddSource(source);
// Add without starting โ launch all at once for tight sync
mixer.AddSourcePrepared(vocals);
mixer.AddSourcePrepared(backing);
mixer.StartPreparedSources(startPosition: 0.0); // atomic start
// Remove
mixer.RemoveSource(source); // by reference
mixer.RemoveSource(sourceGuid); // by ID
mixer.ClearSources(); // remove + stop all
// Query
IAudioSource[] all = mixer.GetSources();AddSource() and RemoveSource() are hot-swap operations โ safe to call while the mixer is running. They take the session lock on the caller's thread, so keep them off the audio thread and off a tight loop, but a track can come and go mid-playback without reopening the stream.
Maximum simultaneous sources per mixer: AudioConstants.MaxAudioSources = 30
Master effects
Applied to the finished mix, in the order you add them โ like pedals on a board. For an effect that should only touch one track, wrap that track in a SourceWithEffects instead. See Effects for the full list.
// Add effects in chain order
mixer.AddMasterEffect(new CompressorEffect { Ratio = 4f });
mixer.AddMasterEffect(new ReverbEffect { RoomSize = 0.5f, Mix = 0.2f });
mixer.AddMasterEffect(new LimiterEffect(sampleRate: 48000f));
// Manage
mixer.RemoveMasterEffect(effect); // returns bool
mixer.ClearMasterEffects();
IEffectProcessor[] fx = mixer.GetMasterEffects();The effect must have IsReady == true before adding. For VST3 effects, call await host.InitializeAudioAsync() first.
Recording
Records the final mixed output โ post master-effects โ to a WAV file. The capture belongs to the native session, which is built with the first source, so register your sources before you call this; on an empty mixer it throws rather than opening a file that would stay silent.
mixer.StartRecording("session.wav"); // start capturing
// ... playback ...
mixer.StopRecording(); // finalize and close the WAV file
// Recording live input? Trim the capture delay so the take starts
// where you hit record, not a few ms late.
mixer.StartRecording("take.wav", compensateInputLatency: true);
// ...
mixer.StopRecording();
int trimmed = mixer.LastRecordingLatencyOffsetFrames; // frames dropped off the frontcompensateInputLatency drops InputLatencyFrames worth of frames off the start, so a live take lines up with the moment you pressed record. Off by default (samples untouched); a no-op when input isn't running or the backend reports no latency.
Writing a WAV yourself
StartRecording captures the master bus. When you want a file from something else โ one source on its own, a generated signal, the output of your own processing โ WaveFileWriter is the same writer the mixer uses, exposed directly. It streams straight to disk as Float32 PCM (never buffering the take in memory) and patches the RIFF sizes into the header on Dispose, so the file must be closed to be readable.
using OwnaudioNET.Mixing;
var source = new FileSource("song.flac");
source.Play(); // a source that isn't playing hands back silence, not audio
using (var wav = new WaveFileWriter("bounce.wav", source.Config))
{
float[] buffer = new float[4096 * source.Config.Channels];
while (!source.IsEndOfStream)
{
int frames = source.ReadSamples(buffer, 4096);
if (frames <= 0) break;
wav.WriteSamples(buffer.AsSpan(0, frames * source.Config.Channels));
}
} // header is finalized here โ nothing plays the file before thisThis pulls the source directly, so it is not attached to a mixer and runs as fast as the disk allows โ good for a bounce, but per-source effects and the master chain are not in the path. For those, record the mixer instead.
| Member | Type | Description |
|---|---|---|
WaveFileWriter(path, config) | Creates the file and lays down the header. Sample rate and channel count come from the AudioConfig. | |
WriteSamples(span) | void | Appends interleaved Float32 samples. |
TotalFramesWritten | long | Frames written so far. |
Duration | double | Current length of the file in seconds. |
Output is always Float32 PCM (WAV format tag 3), 32 bits per sample โ not 16-bit integer. Most editors and players handle it, but a few older tools do not. The writer is not thread-safe: one writer, one thread.
Wanting the mix rather than one source? StartRecording is the way, and it runs in real time โ the session mixes in the device callback and the recorder drains what came out. There is no faster-than-realtime mix bounce; ClockMode.Offline does not provide one (see Offline rendering). The older AudioRecorder class still exists but is obsolete: it holds the whole take in managed memory.
Master scope
The master effect chain, master volume and master pan run over every bus channel by default. On a multi-channel interface that is rarely what you want: a limiter meant for the main pair would also squash the click feed on channels 3/4. MasterChannelScope confines the whole master stage to the channels you name, and everything outside it reaches the driver exactly as mixed โ a clean direct out.
// Master chain owns the main pair only; 2โ7 pass through untouched
mixer.MasterChannelScope = new[] { 0, 1 };
// SmartMasterEffect lives in OwnaudioNET.Effects.SmartMaster
mixer.AddMasterEffect(new SmartMasterEffect()); // limits 0+1, never the cue mix
// Back to the whole bus
mixer.MasterChannelScope = Array.Empty<int>();Empty is the default and means the whole bus, so nothing changes until you ask for it. The scoped channels are processed at the scope's own width, so a stereo-minded effect stays stereo on a wide bus.
VU metering
LeftPeak and RightPeak are linear peaks between 0 and 1. Poll them on a timer at about 10 Hz โ faster than the audio buffer interval gains you nothing but CPU โ and convert to dBFS yourself.
// Update at ~10Hz (100ms timer)
_vuTimer = new Timer(_ =>
{
// Master bus
float leftDb = 20f * MathF.Log10(Math.Max(mixer.LeftPeak, 1e-6f));
float rightDb = 20f * MathF.Log10(Math.Max(mixer.RightPeak, 1e-6f));
MasterLeftDb = Math.Max(leftDb, -60f);
MasterRightDb = Math.Max(rightDb, -60f);
// Per-track (if source is BaseAudioSource)
var (l, r) = source.OutputLevels;
TrackLeftDb = 20f * MathF.Log10(Math.Max(l, 1e-6f));
}, null, 0, 100);Callback load
The mixer renders inside the device callback, so nothing ever drains a ring and every underrun counter in the process legitimately reads zero. What tells you the machine is running out of time is the callback's own duration measured against the budget its frame count buys: 1.0 is where the next block starts late, and the peak is what predicts dropouts, not the average.
// Poll on a UI timer โ reading it costs a handful of atomics
var load = mixer.SessionLoad; // null while the mixer does not own the device
if (load is { } l)
{
CpuBar = l.AverageLoad; // 0.35 = a third of the block period
if (l.HasOverrun) // PeakLoad >= 1.0, a block already ran over
Log.Warning($"late block: {l}"); // ToString gives the whole picture in one line
}
mixer.ResetSessionLoad(); // zero the tallies once playback has settledMember of AudioStreamLoad | Type | Description |
|---|---|---|
AverageLoad | float | Mean share of the block period spent in the callback. 1.0 = the whole budget. |
PeakLoad | float | Worst single block. This is the dropout predictor. |
HasOverrun | bool | PeakLoad >= 1.0 โ a block has already overrun its period. |
AverageBlock / PeakBlock | TimeSpan | The same two figures as wall time. |
BlockCount | ulong | Callbacks since the last reset. |
UnderrunFrames | ulong | Frames that came out silent because a ring ran dry. Always 0 here โ the mixer has no ring. |
A rising PeakLoad is a cue to lighten the mix or raise AudioConfig.BufferSize: a bigger block buys proportionally more time per callback. AudioEngineWrapper.TotalUnderruns counts the engine's own push path โ Send and the buffered output stream โ which is idle while the mixer drives the device, so it stays at zero no matter how late the blocks are.
Events
| Event | Args | Description |
|---|---|---|
PlaybackEnded | EventArgs | All sources reached EndOfStream. |
SourceError | AudioErrorEventArgs | An error occurred in one of the sources. |
StreamFaulted | AudioStreamFaultEventArgs | A native stream died โ usually a device being unplugged. Direction says which side: the playback stream, or the shared capture every input source feeds off. Without this the stream just goes quiet and nothing tells you why. |
mixer.PlaybackEnded += (_, _) =>
{
// All tracks finished โ update UI state
};
mixer.StreamFaulted += (_, e) =>
{
if (e.Kind == AudioStreamFaultKind.DeviceNotAvailable)
Console.WriteLine($"{e.Direction} device disappeared.");
};
// Underruns are counted by the engine, not the mixer
OwnaudioNet.Engine!.BufferUnderrun += (_, e) =>
{
Console.WriteLine($"Underrun: {e.MissedFrames} frames at position {e.Position}");
};Underrun and dropout detection lived in the managed mix thread, which the native chain replaced. On a mixer the dropout signal is SessionLoad, since nothing drains a ring there; AudioEngineWrapper.BufferUnderrun and TotalUnderruns cover the push path (OwnaudioNet.Send), and StreamFaulted a dead stream on either side.