Recipes

Complete, working answers to the things people actually build. Copy one, run it, then bend it into shape.

Nothing on this page is a fragment. Each recipe either runs on its own or slots into an app whose engine is already up โ€” and every one of them is a pattern taken from a real player built on this library.

๐Ÿงญ

New here? Quick Start gets you to sound in three minutes, and Core Concepts explains the five pieces these recipes keep referring to.

Play an audio file

The smallest thing that makes noise. A complete console program.

C# โ€” Program.cs
using OwnaudioNET;
using OwnaudioNET.Mixing;
using OwnaudioNET.Sources;

// Open the sound card: 48 kHz, stereo, 512-frame buffer
await OwnaudioNet.InitializeAsync();
OwnaudioNet.Start();

// The bus everything gets summed into
var mixer = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine);
mixer.Start();

// AddSource starts the source, so this is already playing
var track = new FileSource("song.mp3");
mixer.AddSource(track);

Console.WriteLine("Playing. Press Enter to stop.");
Console.ReadLine();

track.Dispose();
mixer.Dispose();
await OwnaudioNet.ShutdownAsync();

To wait for the track to finish instead of for a keypress, let the mixer tell you:

C#
var finished = new TaskCompletionSource();
mixer.PlaybackEnded += (_, _) => finished.TrySetResult();

mixer.AddSource(track);
await finished.Task;

One engine for the whole app

In anything bigger than a demo, the engine and the mixer belong to a single object that owns their lifetime. Everything else asks that object for the mixer.

C# โ€” AudioEngineService.cs
using Logger;
using OwnaudioNET;
using OwnaudioNET.Mixing;

public sealed class AudioEngineService : IDisposable
{
    private static readonly Lazy<AudioEngineService> _lazy = new(() => new AudioEngineService());
    public static AudioEngineService Instance => _lazy.Value;

    private AudioMixer? _mixer;
    private bool _initialized;

    public AudioMixer? Mixer => _mixer;
    public bool IsInitialized => _initialized;

    private AudioEngineService() { }

    public async Task InitializeAsync(string? outputDeviceId = null,
                                      Log.Level logLevel = Log.Level.Disabled)
    {
        if (_initialized) return;
        Log.LoggerLevel = logLevel;

        var config = OwnaudioNet.CreateDefaultConfig();
        config.EnableInput = false;
        config.OutputDeviceId = outputDeviceId;          // null = system default
        config.FallbackToDefaultOnDisconnect = true;     // survive an unplugged interface

        // bufferMultiplier buys the mix thread headroom โ€” raise it if you
        // run many tracks or VST plugins on the master bus
        await OwnaudioNet.InitializeAsync(config, bufferMultiplier: 32, logLevel: logLevel);
        OwnaudioNet.Start();

        _mixer = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine, bufferSizeInFrames: 1024);
        _mixer.Start();
        _initialized = true;
    }

    public void Dispose()
    {
        _mixer?.Stop();
        _mixer?.Dispose();
        _mixer = null;

        OwnaudioNet.Stop();
        OwnaudioNet.Shutdown();
        _initialized = false;
    }
}
๐Ÿ’ก

Call InitializeAsync() off the UI thread and only flip your "ready" flag once it returns โ€” device enumeration on Linux can take seconds, and a half-initialized engine is the number one source of mystery null references.

A multi-track player that stays in sync

Four stems, one timeline, no drift. Three details do the work: attach every source to the master clock, add them prepared so none of them starts early, and pre-buffer the decoders before the downbeat.

C#
var mixer = AudioEngineService.Instance.Mixer!;

int sr = OwnaudioNet.Engine!.Config.SampleRate;
int ch = OwnaudioNet.Engine!.Config.Channels;

string[] stems = { "drums.wav", "bass.wav", "keys.wav", "vocals.wav" };

var sources = stems
    .Select(path => new FileSource(path, bufferSizeInFrames: 8192,
                                   targetSampleRate: sr, targetChannels: ch))
    .ToArray();

mixer.Pause();
mixer.Seek(0);

foreach (var src in sources)
{
    src.Volume = 0.8f;
    src.AttachToClock(mixer.MasterClock);   // BEFORE Play โ€” this is what kills drift
    mixer.AddSourcePrepared(src);           // queued, not started
}

// Fill every decoder's ring buffer at once so the first block is never late
Parallel.ForEach(sources, s => s.PreBuffer());

mixer.StartPreparedSources(startPosition: 0.0);   // they all begin on the same frame
mixer.Start();
๐Ÿ’ก

Loading the sources at the engine's own sample rate and channel count means the decoder resamples once, up front, instead of the mixer converting on every block.

Tracks that shouldn't start at zero get a timeline offset:

C#
solo.StartOffset = 48.0;   // enters at 0:48 on the project timeline

Play, pause and stop that survive fast clicking

Transport commands run partly on background threads, so a quick Stop โ†’ Play can let the stop finish after the play has already added its sources. One semaphore removes the whole class of bug.

C#
private readonly SemaphoreSlim _transportLock = new(1, 1);
private IAudioSource[] _live = Array.Empty<IAudioSource>();

public async Task PlayAsync(IReadOnlyList<FileSource> tracks, double startSeconds)
{
    await _transportLock.WaitAsync();
    try
    {
        await Task.Run(() =>
        {
            _mixer.Pause();
            _mixer.Seek(startSeconds);

            foreach (var t in tracks)
            {
                t.AttachToClock(_mixer.MasterClock);
                _mixer.AddSourcePrepared(t);
            }

            Parallel.ForEach(tracks, t => t.PreBuffer());
            _mixer.StartPreparedSources(startSeconds);
            _mixer.Start();
        });

        _live = tracks.Cast<IAudioSource>().ToArray();
    }
    finally { _transportLock.Release(); }
}

// Pause leaves everything in the mixer, so resuming is instant
public async Task PauseAsync()
{
    await _transportLock.WaitAsync();
    try
    {
        await Task.Run(() =>
        {
            _mixer.Pause();
            foreach (var s in _live) s.Pause();
        });
    }
    finally { _transportLock.Release(); }
}

// Stop rewinds and empties the bus
public async Task StopAsync()
{
    await _transportLock.WaitAsync();
    try
    {
        await Task.Run(() =>
        {
            foreach (var s in _live)
            {
                s.Stop();
                if (s is IMasterClockSource clocked) clocked.DetachFromClock();
                _mixer.RemoveSource(s.Id);
            }
            _mixer.Seek(0.0);
        });
        _live = Array.Empty<IAudioSource>();
    }
    finally { _transportLock.Release(); }
}
๐Ÿ’ก

Resuming from pause is just Play() on the cached sources plus mixer.Start() โ€” don't rebuild the source list, or you lose the position and pay for the decode again.

A playhead and VU meters that look right

The engine reports its position once per block, which at a large buffer is only a dozen times a second. Poll that straight into a slider and the playhead visibly stutters. Interpolate with a stopwatch between reports and it glides.

C# โ€” position, ~30 Hz
private double _lastEnginePos;
private double _lastEnginePosAt;
private readonly Stopwatch _watch = Stopwatch.StartNew();

private void OnPositionTick()   // 33 ms timer
{
    double enginePos = _mixer.MasterClock.CurrentTimestamp;
    double now       = _watch.Elapsed.TotalSeconds;

    if (enginePos != _lastEnginePos)     // a fresh report landed
    {
        _lastEnginePos   = enginePos;
        _lastEnginePosAt = now;
    }

    // between reports, let wall-clock time carry the playhead forward
    PositionSeconds = _lastEnginePos + (now - _lastEnginePosAt);
}

Meters are a different beast: 10 Hz is plenty, and levels are read as linear peaks that you convert to dBFS yourself.

C# โ€” meters, ~10 Hz
private static double ToDbFs(float linear)
    => linear > 0f ? Math.Max(20.0 * Math.Log10(linear), -60.0) : -60.0;

private void OnVuTick()   // 100 ms timer
{
    // master bus
    MasterLeftDb  = ToDbFs(_mixer.LeftPeak);
    MasterRightDb = ToDbFs(_mixer.RightPeak);

    // per track โ€” every source exposes OutputLevels as a linear peak pair
    foreach (var track in Tracks)
    {
        if (track.Source is null) continue;
        (float l, float r) = track.Source.OutputLevels;

        double leftDb = ToDbFs(l), rightDb = ToDbFs(r);

        // only push a change the eye can see โ€” saves a lot of binding churn
        if (Math.Abs(leftDb  - track.LeftDb)  >= 0.5) track.LeftDb  = leftDb;
        if (Math.Abs(rightDb - track.RightDb) >= 0.5) track.RightDb = rightDb;
    }
}
โš ๏ธ

Stop both timers when playback stops and zero the meters, otherwise the last peak stays frozen on screen and looks like a stuck signal.

Tempo and pitch from a slider

Both go straight to the native track: no buffer flush, no silence gap, safe to fire on every slider tick.

C#
// tempoPercent 80โ€“120, pitchSemitones -12โ€ฆ+12
void OnTempoChanged(int tempoPercent)
{
    float ratio = tempoPercent / 100f;
    foreach (var src in _live.OfType<FileSource>())
        src.SetTempoSmooth(ratio);
}

void OnPitchChanged(int semitones)
{
    foreach (var src in _live.OfType<FileSource>())
        src.SetPitchSmooth(semitones);
}

// Once the user lets go of a clock-synced slider, reseek away any leftover drift
void OnTempoCommitted(int tempoPercent)
{
    foreach (var src in _live.OfType<FileSource>())
        src.SetTempoSynced(tempoPercent / 100f);
}
โš ๏ธ

Tempo is clamped to 0.8โ€“1.2 and pitch to ยฑ12 semitones. Changing tempo changes how long the song lasts, so if you show a total duration remember to divide it by the ratio.

Add an effect while the music is playing

A raw source has no effect chain โ€” you wrap it in a SourceWithEffects and swap the wrapper in for the original. The mixer handles the swap without a click.

C#
// One-time upgrade: raw source โ†’ source with a chain
SourceWithEffects EnsureChain(FileSource source)
{
    if (_chains.TryGetValue(source.Id, out var existing)) return existing;

    var chain = new SourceWithEffects(source);
    _mixer.RemoveSource(source.Id);   // out with the raw one
    _mixer.AddSource(chain);          // in with the wrapper
    _chains[source.Id] = chain;
    return chain;
}

var chain = EnsureChain(vocals);
chain.AddEffect(new CompressorEffect { Ratio = 4f, AttackTimeMs = 10f });
chain.AddEffect(new ReverbEffect { RoomSize = 0.6f, Mix = 0.25f });

Bypassing is cheaper than removing, and it keeps the chain order intact:

C#
reverb.Enabled = false;   // stays in the chain, stops processing
๐Ÿšซ

Don't dispose an effect the instant you remove it โ€” the audio thread may still be inside Process(). Take it out of the chain first, then dispose it a moment later.

C# โ€” safe removal
chain.RemoveEffect(reverb);

var doomed = reverb;
_ = Task.Run(async () =>
{
    await Task.Delay(100).ConfigureAwait(false);
    doomed.Dispose();
});

A VST3 plugin goes into the very same slot โ€” load it, initialize its audio, hand over its processor:

C#
var host = await VST3PluginHost.CreateAsync("/path/to/plugin.vst3");

if (!host.IsEffect) { host.Dispose(); return; }   // instruments aren't supported

if (!await host.InitializeAudioAsync(OwnaudioNet.Engine!.Config.SampleRate, maxBlockSize: 4096))
{
    host.Dispose();
    return;
}

chain.AddEffect(host.GetProcessor());
await host.OpenEditorAsync("Reverb");   // the plugin's own UI, from the UI thread

Record from the microphone

Input has to be switched on at initialization time โ€” it decides which streams the engine opens. After that a microphone is just another source.

C#
var config = OwnaudioNet.CreateDefaultConfig();
config.EnableInput = true;

await OwnaudioNet.InitializeAsync(config);
OwnaudioNet.Start();

var mixer = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine);
mixer.Start();

// Live input, monitored through the speakers
var mic = new InputSource(OwnaudioNet.Engine!, bufferSizeInFrames: 8192);
mixer.AddSource(mic);

// Capture the finished mix โ€” post master effects
mixer.StartRecording("take-01.wav", compensateInputLatency: true);

Console.WriteLine("Recording. Enter to finish.");
Console.ReadLine();

mixer.StopRecording();
๐Ÿ’ก

compensateInputLatency trims the hardware capture delay off the front of the file, so an overdub lands on the beat instead of a few milliseconds behind it. Check what was trimmed with mixer.LastRecordingLatencyOffsetFrames.

๐ŸŽง

Monitoring a mic through the same speakers it can hear will feed back. Use headphones, or keep mic.Volume = 0f and record without monitoring.

Generate audio yourself โ€” a metronome

StreamingSource asks you for samples and you fill the buffer. Derive your phase from framePosition, not from a counter of your own, and a seek automatically lands the click on the right beat.

C#
private readonly AudioConfig _config = OwnaudioNet.CreateDefaultConfig();

// ClickPattern is your own class โ€” bpm, frames per beat, click length.
// Swapped as one reference, so the callback never sees half a pattern.
private volatile ClickPattern _pattern = ClickPattern.Create(bpm: 120, beatsPerBar: 4);

private void RenderClick(Span<float> buffer, int frameCount, long framePosition)
{
    var p  = _pattern;                // one read, then work from the local
    int ch = _config.Channels;

    for (int f = 0; f < frameCount; f++)
    {
        long abs = framePosition + f;
        long intoBeat = abs % p.FramesPerBeat;

        // a short decaying blip at the top of every beat
        float s = 0f;
        if (intoBeat < p.ClickFrames)
        {
            bool downbeat = (abs / p.FramesPerBeat) % p.BeatsPerBar == 0;
            float env = 1f - (float)intoBeat / p.ClickFrames;
            s = MathF.Sin(intoBeat * (downbeat ? p.HighStep : p.LowStep)) * env * 0.5f;
        }

        for (int c = 0; c < ch; c++)
            buffer[f * ch + c] = s;
    }
}

var click = new StreamingSource(RenderClick, _config) { Volume = 0.7f };
mixer.AddSource(click);
click.Play();

// Tempo change: build a new pattern and swap it in. No restart, no gap.
_pattern = ClickPattern.Create(bpm: 140, beatsPerBar: 4);
๐Ÿšซ

No allocation inside the callback โ€” no new, no LINQ, no interpolated strings. It runs against a deadline, and a garbage collection at the wrong moment is a click in the audio.

Keep the click aligned after a seek by seeking the source too:

C#
mixer.Seek(positionSeconds);
click.Seek(positionSeconds);

Render a mix to a WAV file

Realtime mode drops audio it can't produce in time โ€” fine for playback, wrong for a bounce. Offline mode makes the mix thread wait for every source, so the result is deterministic no matter how slow the machine is.

C#
mixer.RenderingMode = ClockMode.Offline;

foreach (var src in sources)
{
    src.AttachToClock(mixer.MasterClock);
    mixer.AddSourcePrepared(src);
}

mixer.StartRecording("bounce.wav");
mixer.StartPreparedSources(0.0);
mixer.Start();

// wait for the longest source to finish
var done = new TaskCompletionSource();
mixer.PlaybackEnded += (_, _) => done.TrySetResult();
await done.Task;

mixer.Stop();
mixer.StopRecording();
๐Ÿ’ก

Master effects are printed into the file โ€” the recording is taken after the master chain, so what you hear is what you get.

Compare the signal before and after an effect

Effects work in place, so you can't read the dry signal back out of the buffer โ€” it isn't there any more. Ask the engine for a tap instead: it copies each rendered block on both sides of the chain and hands the pair back in step.

C# โ€” a live before/after spectrum
private EffectSpectrumAnalyzer? _analyzer;

void StartAnalyzing(Guid sourceId)
{
    _analyzer?.Dispose();
    _analyzer = new EffectSpectrumAnalyzer(_mixer.CreateEffectTap(sourceId), fftSize: 2048);

    _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(40) };
    _timer.Tick += (_, _) =>
    {
        if (!_analyzer.Update()) return;   // nothing new, leave the last picture up

        Redraw(_analyzer.Frequencies, _analyzer.PreMagnitudesDb, _analyzer.PostMagnitudesDb);
    };
    _timer.Start();
}

void StopAnalyzing()
{
    _timer?.Stop();
    _analyzer?.Dispose();   // stops the engine mirroring
    _analyzer = null;
}

Both spans are BinCount long (fftSize / 2) in dBFS, and Frequencies gives the centre of each bin in Hz. A bigger fftSize buys frequency detail at the cost of update rate: 2048 is about 23 Hz per bin at 48 kHz, which is fine for watching an EQ or a compressor work.

Swap CreateEffectTap(sourceId) for CreateMasterEffectTap() to watch the master chain instead โ€” same analyzer, it just sees the summed mix.

๐Ÿ’ก

The dry side is held back by the chain's own latency, so a look-ahead limiter or a VST3 doesn't smear the comparison. If you want the raw audio rather than a spectrum, use tap.Read(pre, post) directly. Effect tap reference โ†’

๐Ÿšซ

Don't poll Update() in a tight loop โ€” it drains a lock-free ring the audio thread fills, and spinning on it just burns a core. A 30โ€“60 ms timer is faster than an eye can follow anyway.

Let the user pick an output device

Enumerate, show the names, store the DeviceId. Switching device means re-initializing the engine, so do it while nothing is playing.

C#
// Fill a combo box
List<AudioDeviceInfo> outputs = await OwnaudioNet.GetOutputDevicesAsync();

foreach (var d in outputs)
    Console.WriteLine($"{d.Name}  [{d.EngineName}]{(d.IsDefault ? "  (default)" : "")}");

// Apply a choice โ€” full restart of the audio stack
await StopEverythingAsync();
await OwnaudioNet.ShutdownAsync();

var config = OwnaudioNet.CreateDefaultConfig();
config.OutputDeviceId = selected.DeviceId;
config.FallbackToDefaultOnDisconnect = true;   // don't die if it gets unplugged

await OwnaudioNet.InitializeAsync(config);
OwnaudioNet.Start();
โš ๏ธ

Opening a VST3 editor while device hot-plug monitoring is running can interfere with enumeration. Wrap it: PauseDeviceMonitoring() before, ResumeDeviceMonitoring() after.

Send tracks to separate outputs

On a multi-channel interface each source can be pinned to specific hardware channels โ€” click into the drummer's cans, the band into the mains.

C#
var config = OwnaudioNet.CreateDefaultConfig();
config.Channels = 4;                 // bus channel 0..3 โ†’ physical 0..3

await OwnaudioNet.InitializeAsync(config);
OwnaudioNet.Start();

band.RouteToChannels(0, 1);          // front of house
click.RouteToChannels(2, 3);         // in-ear monitors

mixer.AddSource(band);
mixer.AddSource(click);
๐Ÿ’ก

The mapping array must be as long as the source's own channel count. Need to change that count, use targetChannels when you construct the FileSource โ€” it up- or downmixes in the decoder. Channel routing โ†’

Where next