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();

// Mixer first: its Dispose stops and drops every source it still holds
mixer.Dispose();
track.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

        await OwnaudioNet.InitializeAsync(config, 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. One detail does the work: add every source prepared, so none of them starts early. Registering a source is also what attaches it to the mixer's master clock โ€” AddSource and AddSourcePrepared both do it, so there is no AttachToClock call to remember. The native file tracks run their own prefetch, so there is nothing to warm up from managed code.

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;
    mixer.AddSourcePrepared(src);   // queued, not started โ€” and attached to the master clock here
}

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

Put several clips on one track

A lane of a DAW: a guitar part recorded as five takes, a vocal cut into phrases. As separate FileSources they would each need their own effect chain and fader, and a reverb would stop dead at every cut. A GroupSource sums them into one track instead โ€” one chain, one fader, one tempo, and the tail rings over the edges.

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

var guitar = new GroupSource(sr, ch);

// Short takes decode into memory here, so keep it off the UI thread
await Task.Run(() =>
{
    guitar.AddClip("gtr_take1.wav",  0.0);
    guitar.AddClip("gtr_take2.wav", 16.0);
    guitar.AddClip("gtr_solo.wav",  48.0);
});

var guitarFx = new SourceWithEffects(guitar);
guitarFx.AddEffect(new ReverbEffect());

mixer.AddSourcePrepared(guitarFx);   // rides the master clock with the other stems

Editing works while it plays. A dragged clip is heard from the next block, a removed one goes silent, and a clip added after the lane already finished makes it play on:

C#
SourceClip solo = guitar.Clips[2];
solo.StartSeconds = 52.0;          // moved on the lane

guitar.RemoveClip(guitar.Clips[0]);
โš ๏ธ

Create the lane at the mixer's sample rate โ€” the mixer refuses a GroupSource at any other rate with an ArgumentException. Files up to 30 seconds load into memory, longer ones stream from disk; pass memoryMaxSeconds to the constructor to move the line.

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)
                _mixer.AddSourcePrepared(t);

            _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 โ€” OutputLevels is a linear peak pair. It sits on the concrete source
    // (BaseAudioSource / SourceWithEffects), not on IAudioSource, and it only moves
    // while that source is actually playing
    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);

    // The wrapper takes the source's place on the bus, so it has to take its patch too โ€”
    // leave this out and the track jumps back to the identity route on 1/2
    chain.OutputRoute = source.OutputRoute;
    chain.OutputChannelMapping = source.OutputChannelMapping;

    _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, AttackTime = 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

Removal is synchronous all the way down: RemoveEffect reconciles the native chain on the calling thread and returns only once the native twin is gone, so disposing right after is safe.

C# โ€” removal
chain.RemoveEffect(reverb);
reverb.Dispose();
๐Ÿ’ก

What you must not do is dispose an effect that is still in a chain. Take it out first โ€” the order above, not the other way round.

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#
// Mirror the live engine, and take the width from EffectiveOutputChannels: a StreamingSource
// feeds a bare native track, which runs at the bus width. CreateDefaultConfig() would hand you
// 48 kHz stereo whatever the device actually opened with, and the frames come out interleaved wrong.
private static AudioConfig ClickConfig()
{
    var engine = OwnaudioNet.Engine!.Config;

    return new AudioConfig
    {
        SampleRate = engine.SampleRate,
        Channels   = engine.EffectiveOutputChannels,
        BufferSize = engine.BufferSize
    };
}

private readonly AudioConfig _config = ClickConfig();

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

The mixer records what it is playing: the native session mixes in the device callback and copies the master output into a ring that a background thread drains to disk. That makes a bounce a realtime pass โ€” a four-minute song takes four minutes โ€” and it means the sources have to be registered before you ask for the recording, because the native session (and with it the capture) only exists once there is something on the bus.

C#
foreach (var src in sources)
    mixer.AddSourcePrepared(src);   // registering also attaches the source to mixer.MasterClock

mixer.StartRecording("bounce.wav");   // needs a session: sources first, or this throws
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.

โš ๏ธ

ClockMode.Offline is still in the enum, but since 4.0 nothing reads it: the managed mix thread it used to steer is gone, and the rust session always renders against the device clock. Setting mixer.RenderingMode does not make a bounce faster or more deterministic. For a faster-than-realtime export, pull the source yourself โ€” that is the next recipe.

Write one source to a WAV file

The recipe above prints the whole mix. When you only want one source โ€” stem export, a rendered click track, the output of your own DSP โ€” skip the mixer entirely and pull the source yourself. WaveFileWriter is the same writer the mixer records through, and it streams to disk rather than holding the take in memory.

C#
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("stem.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 patched on Dispose โ€” the file is unreadable until this runs

source.Dispose();
๐Ÿ’ก

The loop runs as fast as the disk allows, so a five-minute song takes seconds. Nothing is attached to a mixer, which also means the master chain is not in the path โ€” if you want that, record the mixer instead.

โš ๏ธ

Output is Float32 PCM (WAV format tag 3), not 16-bit integer. Almost everything reads it; a few old tools do not. Want a generated signal on disk instead of a file? Feed the writer from your own buffer โ€” it only wants interleaved floats.

Send the click to its own outputs

On a multi-channel interface the drummer's cue does not belong in the front-of-house mix. Route each source onto the channels you want, and keep the master limiter off the cue feed.

C#
using Ownaudio.Core;                  // AudioConfig
using OwnaudioNET.Effects.SmartMaster;

// OutputChannels widens the mix bus; Channels stays the stereo the sources decode to
await OwnaudioNet.InitializeAsync(new AudioConfig { OutputChannels = 8, SampleRate = 48000 });
OwnaudioNet.Start();

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

var music = new FileSource("backing.flac");
music.DecodeChannels = 2;                       // stereo chain on an 8-channel bus
music.RouteToChannels(0, 1);                    // front of house

var click = new FileSource("click.wav", targetChannels: 1);
click.RouteTo(new[] { -1, -1, 0, 0 });          // one mono click onto 3 AND 4

// The limiter owns the main pair only โ€” 3/4 reach the interface as mixed
mixer.MasterChannelScope = new[] { 0, 1 };
mixer.AddMasterEffect(new SmartMasterEffect());

// Start the mixer first: AddSource only starts a source on a running mixer, and a source
// the managed side never saw start does not drive the master clock
mixer.Start();
mixer.AddSource(music);
mixer.AddSource(click);

// Re-patch while it plays: the change lands on the next control tick
click.RouteTo(new[] { -1, -1, -1, -1, 0, 0 });
๐Ÿ’ก

Draw your patch bay from OwnaudioNet.Engine!.ActualOutputChannels, not from AudioConfig โ€” a device that could not serve the requested width was adapted, and only the engine knows to what.

๐Ÿ’ก

Two mappings, two shapes. RouteToChannels is source-indexed, so its array has to be exactly as long as the source's own channel count (Config.Channels, which for a FileSource is its targetChannels) โ€” anything else throws. RouteTo is destination-indexed (-1 = nothing on that bus channel), and it is the only one that can fan a single source channel out to several outputs. Channel routing โ†’

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. Both need a native session behind them, so open a tap only once the source is on the bus; asked for too early they throw rather than hand back a dead tap.

๐Ÿ’ก

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.

Where next