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

C# โ€” Standard
// Wired straight at the engine: the rust session renders, a control tick mirrors state
var mixer = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine, bufferSizeInFrames: 512);
ParameterTypeDefaultDescription
engineIAudioEngineโ€”From OwnaudioNet.Engine!.UnderlyingEngine.
bufferSizeInFramesint512Internal 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.

C# โ€” create the mixer through the wrapper
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();
ParameterTypeDefaultDescription
engineWrapperAudioEngineWrapperโ€”Pass OwnaudioNet.Engine!. The factory extracts the underlying engine and keeps the wrapper.
bufferSizeInFramesint512Internal 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

PropertyTypeDescription
MixerIdGuidUnique identifier.
ConfigAudioConfigAudio configuration in use.
IsRunningboolTrue between a successful Start and the next Stop/Dispose.
SourceCountintNumber of active sources.
MasterVolumefloatMaster output volume (0.0 โ€“ 1.0). Settable at any time.
MasterPanfloatMaster stereo pan (-1.0 left โ€ฆ 0.0 center โ€ฆ +1.0 right). Settable at any time.
LeftPeak / RightPeakfloatReal-time peak levels (0.0 โ€“ 1.0) for VU metering.
TotalMixedFrameslongThe master clock's sample position. It follows a Seek, so it is a playhead, not a running total.
IsRecordingboolWhether WAV recording is active.
MasterClockMasterClockTimeline clock for synchronized multi-track sync.
RenderingModeClockModeThe master clock's mode. Set by the network sync helpers; Offline is inert since 4.0 โ€” see Offline rendering.
MasterChannelScopeint[]Bus channels the master chain, volume and pan act on. Empty (default) = the whole bus. See Master scope.
SessionLoadAudioStreamLoad?What the device callback the mix renders in is costing. null while the mixer does not own the device. See Callback load.

Lifecycle

C#
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 resources

Sources 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.

C#
// 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.

C#
// 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.

C#
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 front
๐Ÿ’ก

compensateInputLatency 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.

C# โ€” bounce a single source to WAV
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 this
โš ๏ธ

This 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.

MemberTypeDescription
WaveFileWriter(path, config)Creates the file and lays down the header. Sample rate and channel count come from the AudioConfig.
WriteSamples(span)voidAppends interleaved Float32 samples.
TotalFramesWrittenlongFrames written so far.
DurationdoubleCurrent 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.

C#
// 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.

C#
// 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.

C#
// 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 settled
Member of AudioStreamLoadTypeDescription
AverageLoadfloatMean share of the block period spent in the callback. 1.0 = the whole budget.
PeakLoadfloatWorst single block. This is the dropout predictor.
HasOverrunboolPeakLoad >= 1.0 โ€” a block has already overrun its period.
AverageBlock / PeakBlockTimeSpanThe same two figures as wall time.
BlockCountulongCallbacks since the last reset.
UnderrunFramesulongFrames 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

EventArgsDescription
PlaybackEndedEventArgsAll sources reached EndOfStream.
SourceErrorAudioErrorEventArgsAn error occurred in one of the sources.
StreamFaultedAudioStreamFaultEventArgsA 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.
C#
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.

Next