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.
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:
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.
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.
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:
solo.StartOffset = 48.0; // enters at 0:48 on the project timelinePlay, 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.
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.
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.
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.
// 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.
// 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:
reverb.Enabled = false; // stays in the chain, stops processingDon'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.
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:
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 threadRecord 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.
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.
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:
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.
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.
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.
// 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.
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 โ