The Safe layer is the first managed C# abstraction over the native Rust FFI. It sits directly
on top of the raw OwnAudioNative P/Invoke bindings and converts them into a safe, typed,
and resource-managed API.
HighLevel ← developer-facing API (players, recorders, device manager)
Safe ← THIS LAYER: handles, error mapping, callback marshallers
Native ← raw LibraryImport / P/Invoke to ownaudio_ffi
Rust ← ownaudio-ffi + ownaudio-core (cpal + Symphonia)
The Safe layer does four things and nothing more:
- Manages native handle lifetimes via
SafeHandlesubclasses so the GC can clean up even ifDispose()is never explicitly called. - Maps integer error codes to typed exceptions (
OwnAudioExceptionhierarchy) so callers receive meaningful, structured errors. - Marshals audio callbacks between managed delegates and native C function pointers without per-call heap allocation.
- Validates parameters at the managed/native boundary so the native layer never receives invalid inputs.
When should you use the Safe layer directly?
- You are building custom playback or capture logic and need full control over the callback.
- The HighLevel layer does not fit your architecture.
- You only need the raw audio stream callback without a high-level state machine.
- You need low-level streaming decoding, BPM detection, or direct stream management.
Most developers should start with the HighLevel layer. If you are unsure, start there.
- Quick Start — Playback
- Quick Start — Recording
- AudioEngine
- AudioStreamConfig
- SampleFormat
- AudioDevice
- AudioOutputStream
- AudioInputStream
- Callbacks and Callback Arguments
- StreamingAudioDecoder
- BpmDetect
- Handles
- Error Handling and Exceptions
- Threading and Real-Time Rules
- Resource Management
- ABI Version Check
The example below plays a 440 Hz sine wave for 2 seconds through the default output device.
using Ownaudio.Safe;
using Ownaudio.Safe.Callbacks;
// 1. Create the engine — also verifies the ABI version
using var engine = AudioEngine.Create();
// 2. Stream configuration (44 100 Hz, stereo, float32)
var config = new AudioStreamConfig(
sampleRate: 44_100,
channels: 2,
sampleFormat: SampleFormat.F32,
bufferSizeFrames: 0); // 0 = let the platform choose
// 3. Phase accumulator for the sine wave (state preserved across callbacks)
double phase = 0.0;
const double frequency = 440.0; // A4
const double amplitude = 0.3;
// 4. Open the output stream — starts in the paused state
using var stream = engine.OpenOutputStream(
device: null, // null = system default output device
config: config,
callback: (in AudioOutputCallbackArgs args) =>
{
// This method runs on the real-time audio thread!
// DO NOT: allocate with new, lock, Console.Write, file I/O.
double step = 2.0 * Math.PI * frequency / config.SampleRate;
for (int frame = 0; frame < args.FrameCount; frame++)
{
float sample = (float)(Math.Sin(phase) * amplitude);
phase += step;
// Write the same sample to every channel (stereo)
for (int ch = 0; ch < args.Channels; ch++)
{
args.Buffer[frame * args.Channels + ch] = sample;
}
}
});
// 5. Subscribe to callback errors (optional but recommended)
stream.CallbackError += (_, ex) =>
Console.WriteLine($"Audio callback error: {ex.Message}");
// 6. Start playback — callbacks begin firing
stream.Play();
Console.WriteLine("Playing for 2 seconds...");
Thread.Sleep(2000);
// 7. Pause (the stream remains alive and can be restarted with Play())
stream.Pause();
// 8. The `using` statement calls Dispose() automaticallyusing Ownaudio.Safe;
using Ownaudio.Safe.Callbacks;
using System.Threading.Channels;
using var engine = AudioEngine.Create();
var config = new AudioStreamConfig(
sampleRate: 44_100,
channels: 1, // mono capture
sampleFormat: SampleFormat.F32);
// Channel<T> transfers data lock-free from the audio thread to a processing thread
var channel = Channel.CreateBounded<float[]>(capacity: 64);
using var stream = engine.OpenInputStream(
device: null,
config: config,
callback: (in AudioInputCallbackArgs args) =>
{
// Running on the real-time audio thread — lock-free operations only!
// args.Buffer is only valid during this callback, so copy it immediately.
var copy = args.Buffer.ToArray();
channel.Writer.TryWrite(copy); // lock-free, never blocks
});
stream.Play();
Console.WriteLine("Recording for 3 seconds...");
// Processing thread — unlimited operations allowed here
var processor = Task.Run(async () =>
{
await foreach (float[] buf in channel.Reader.ReadAllAsync())
{
// Write to WAV, run FFT, compute RMS, etc.
Console.WriteLine($" Buffer size: {buf.Length} samples");
}
});
Thread.Sleep(3000);
stream.Pause();
channel.Writer.Complete();
await processor;Namespace: Ownaudio.Safe
AudioEngine is the single entry point of the Safe layer. It manages native engine
initialisation, device enumeration, and stream creation.
// Platform default backend (WASAPI / CoreAudio / ALSA)
using var engine = AudioEngine.Create();
// Specific host API
using var engine = AudioEngine.Create(hostApi: HostApi.Wasapi);
AudioEngine.Create()verifies the ABI version as its very first step. If the loaded native binary does not match the managed code, it throwsAbiVersionMismatchExceptionimmediately — before any audio subsystem is touched.
| Value | Platform | Notes |
|---|---|---|
null (default) |
All | Platform picks the best backend automatically |
HostApi.Wasapi |
Windows | Recommended Windows backend |
HostApi.Asio |
Windows | Low-latency; requires an ASIO driver |
HostApi.CoreAudio |
macOS | Default macOS backend |
HostApi.Alsa |
Linux | Default Linux backend |
ASIO note: Only available when the native binary was compiled with ASIO support and an ASIO driver is installed (e.g. ASIO4ALL, RME, Focusrite). Without a driver, the engine throws
AsioDriverNotFoundException.
IReadOnlyList<AudioDevice> outputs = engine.EnumerateOutputDevices();
IReadOnlyList<AudioDevice> inputs = engine.EnumerateInputDevices();
foreach (AudioDevice dev in outputs)
{
Console.WriteLine(dev);
// Example: "Realtek HD Audio (in=0 out=2 @48000 Hz)"
}
// Find the system default
AudioDevice? defaultOut = outputs.FirstOrDefault(d => d.IsDefaultOutput);
// Find a specific device by name
AudioDevice? focusrite = outputs.FirstOrDefault(d => d.Name.Contains("Focusrite"));// Output stream (playback) with managed callback
AudioOutputStream outStream = engine.OpenOutputStream(device, config, outputCallback);
// Output stream driven by a native mixer (no managed callback — HighLevel path)
AudioOutputStream mixerStream = engine.OpenMixerOutputStream(mixerHandle, device, config);
// Input stream (capture) with managed callback
AudioInputStream inStream = engine.OpenInputStream(device, config, inputCallback);Streams are always created in the paused state — call .Play() to start them.
engine.Dispose();Important: Dispose all streams before disposing the engine. If the engine is destroyed first, a pending callback could access freed native memory.
Namespace: Ownaudio.Safe
Holds the parameters for opening an audio stream. Immutable — all values are validated in the constructor, so an invalid configuration can never reach the native layer.
var config = new AudioStreamConfig(
sampleRate: 48_000, // Hz; valid range: 8 000 – 192 000
channels: 2, // channel count; valid range: 1 – 32
sampleFormat: SampleFormat.F32, // default when omitted
bufferSizeFrames: 512); // frames; 0 = platform default; non-zero: 16 – 8 192| Property | Type | Description |
|---|---|---|
SampleRate |
int |
Sample rate in Hz |
Channels |
int |
Number of channels (1 = mono, 2 = stereo) |
SampleFormat |
SampleFormat |
Sample data format |
BufferSizeFrames |
int |
Buffer size in frames (0 = automatic) |
BufferSizeFrames |
Effect |
|---|---|
0 |
Platform chooses the optimal value (recommended starting point) |
64 – 256 |
Low latency (live synthesis, ASIO) — higher CPU load |
512 – 1024 |
Balance between latency and CPU usage |
2048 – 8192 |
High latency; stable on weak hardware |
// CD quality, stereo
var cdStereo = new AudioStreamConfig(44_100, 2);
// Professional studio, low latency
var studio = new AudioStreamConfig(48_000, 2, SampleFormat.F32, 256);
// Mono microphone capture
var monoCapture = new AudioStreamConfig(44_100, 1);
// High-resolution stereo
var hiRes = new AudioStreamConfig(96_000, 2);Namespace: Ownaudio.Safe
public enum SampleFormat
{
F32 = 0, // 32-bit IEEE 754 float [-1.0, +1.0] — RECOMMENDED
I16 = 1, // signed 16-bit integer [-32768, +32767]
U16 = 2, // unsigned 16-bit integer [0, 65535]
}Use F32 unless you have a specific reason not to. It covers the full [-1.0, +1.0]
range, is accurate enough for all DSP work, and all effects operate on float internally.
Namespace: Ownaudio.Safe
An immutable device descriptor returned by EnumerateOutputDevices() /
EnumerateInputDevices(). All string data is copied from the native buffer during
construction, so the object remains valid after the native list is freed.
| Property | Type | Description |
|---|---|---|
Name |
string |
OS-provided device name — pass this to OpenOutputStream / OpenInputStream |
IsDefaultInput |
bool |
True when this is the system default input device |
IsDefaultOutput |
bool |
True when this is the system default output device |
MaxInputChannels |
int |
Maximum number of input channels supported |
MaxOutputChannels |
int |
Maximum number of output channels supported |
DefaultSampleRate |
int |
Device preferred sample rate (e.g. 44 100 or 48 000) |
// Select a specific device by name
AudioDevice? myDevice = engine.EnumerateOutputDevices()
.FirstOrDefault(d => d.Name.Contains("Focusrite"));
// Open a stream on that device
using var stream = engine.OpenOutputStream(myDevice, config, callback);
// ToString() output: "Realtek HD Audio (in=0 out=2 @48000 Hz)"
Console.WriteLine(myDevice);Namespace: Ownaudio.Safe
A playback audio stream. Created exclusively by AudioEngine.OpenOutputStream() or
AudioEngine.OpenMixerOutputStream().
| Method | Description |
|---|---|
Play() |
Starts or resumes audio output. Callbacks begin firing on the audio thread. |
Pause() |
Pauses output without destroying the stream. Restartable with Play(). |
Dispose() |
Destroys the native stream and releases the callback pin. Safe to call multiple times. |
| Event | Description |
|---|---|
CallbackError |
Raised on a ThreadPool thread when the user callback throws an unhandled exception. The stream continues operating. |
using var stream = engine.OpenOutputStream(null, config, MyCallback);
stream.CallbackError += (_, ex) =>
{
// The exception was swallowed at the FFI boundary — log it here
logger.LogError(ex, "Audio callback error");
};
stream.Play();
// ... audio plays ...
stream.Pause();
// ... can be restarted at any time:
stream.Play();OpenOutputStream()
│
▼
[Paused] ─── Play() ──► [Playing]
▲ │
└──────── Pause() ───────┘
│
Dispose()
Namespace: Ownaudio.Safe
A capture audio stream. Created exclusively by AudioEngine.OpenInputStream().
The interface is identical to AudioOutputStream.
| Method | Description |
|---|---|
Play() |
Starts or resumes capture. Callbacks begin firing. |
Pause() |
Pauses capture without destroying the stream. |
Dispose() |
Destroys the native stream and releases the callback pin. |
| Event | Description |
|---|---|
CallbackError |
Raised when the capture callback throws an unhandled exception. |
public delegate void AudioOutputCallbackHandler(in AudioOutputCallbackArgs args);Pass this delegate to OpenOutputStream(). The native audio thread calls it for
every buffer cycle — this is where you generate or copy audio data into the output buffer.
| Property | Type | Description |
|---|---|---|
Buffer |
Span<float> |
Output samples to fill. Pre-zeroed before each call. |
FrameCount |
int |
Number of audio frames in this callback cycle |
Channels |
int |
Number of interleaved channels per frame |
Buffer size: Buffer.Length == FrameCount * Channels
Stereo buffer layout:
Buffer[0] = frame 0, left channel
Buffer[1] = frame 0, right channel
Buffer[2] = frame 1, left channel
Buffer[3] = frame 1, right channel
...
Buffer[frame * Channels + channelIndex] = sample
// Example: 440 Hz sine wave on stereo output
double phase = 0.0;
engine.OpenOutputStream(null, config, (in AudioOutputCallbackArgs args) =>
{
double step = 2.0 * Math.PI * 440.0 / 44_100;
for (int frame = 0; frame < args.FrameCount; frame++)
{
float s = (float)(Math.Sin(phase) * 0.3);
phase += step;
args.Buffer[frame * 2 + 0] = s; // left
args.Buffer[frame * 2 + 1] = s; // right
}
});Important:
Bufferis aSpan<float>that is only valid during the callback. Do not store or pass theargsvalue outside the callback scope.
public delegate void AudioInputCallbackHandler(in AudioInputCallbackArgs args);| Property | Type | Description |
|---|---|---|
Buffer |
ReadOnlySpan<float> |
Captured samples for this cycle (read-only) |
FrameCount |
int |
Number of audio frames |
Channels |
int |
Number of channels per frame |
// Correct pattern: copy immediately, hand off via lock-free channel
engine.OpenInputStream(null, config, (in AudioInputCallbackArgs args) =>
{
// The span expires after this callback returns — copy it first!
float[] copy = args.Buffer.ToArray();
// Pass to processing thread via lock-free channel
myChannel.Writer.TryWrite(copy);
// FORBIDDEN inside the callback: blocking I/O, lock, Console.Write, Task.Wait
});Namespace: Ownaudio.Safe
Memory-efficient streaming audio file decoder backed by the native Rust (Symphonia) engine. A dedicated native prefetch thread decodes the file incrementally into a lock-free ring buffer, bounding memory usage to the prefetch size rather than the file size.
Supported formats: WAV, MP3, FLAC, OGG/Vorbis, AAC/M4A, AIFF (via Symphonia — no external deps).
using var decoder = new StreamingAudioDecoder(
filePath: "music.flac",
targetSampleRate: 48_000, // 0 = source rate
targetChannels: 2, // 0 = source channels
prefetchSeconds: 2.0f);
// Stream metadata (after resampling/downmix if requested)
AudioStreamInfo info = decoder.StreamInfo;
Console.WriteLine($"Channels: {info.Channels}");
Console.WriteLine($"Rate: {info.SampleRate} Hz");
Console.WriteLine($"Duration: {info.Duration}"); // TimeSpan.Zero when unknown
Console.WriteLine($"Bit depth: {info.BitDepth}"); // 0 for float/compressed
// Pre-allocate a reusable buffer (single allocation outside the loop)
float[] buffer = new float[4096];
while (!decoder.IsEndOfStream)
{
int samplesRead = decoder.Read(buffer, offset: 0, count: buffer.Length);
// or: int samplesRead = decoder.Read(buffer.AsSpan());
ProcessAudio(buffer.AsSpan(0, samplesRead));
}
// Seek (non-blocking — prefetch thread performs the actual seek)
decoder.Seek(TimeSpan.FromSeconds(30));
decoder.Seek(framePosition: 0); // seek by frame index| Property | Type | Description |
|---|---|---|
StreamInfo |
AudioStreamInfo |
Decoded output metadata (channels, rate, duration, bit depth) |
IsEndOfStream |
bool |
true once file is fully decoded and prefetch buffer is drained |
| Method | Description |
|---|---|
Read(float[], int, int) |
Read decoded interleaved float samples into a buffer slice |
Read(Span<float>) |
Zero-allocation Span overload |
Seek(long framePosition) |
Non-blocking seek to output frame position |
Seek(TimeSpan position) |
Non-blocking seek to time position |
Dispose() |
Stops prefetch thread and releases native decoder |
Namespace: Ownaudio.Safe
Offline BPM (tempo) estimator backed by the native Rust engine. Replaces the former
managed SoundTouch.BpmDetect. Feed audio frames and query the estimated BPM.
using var bpmDetector = new BpmDetect(channels: 2, sampleRate: 44_100);
// Feed audio (e.g. from StreamingAudioDecoder)
float[] audio = new float[4096];
while (!decoder.IsEndOfStream)
{
int n = decoder.Read(audio, 0, audio.Length);
int frames = n / 2; // stereo: frames = samples / channels
bpmDetector.InputSamples(audio.AsSpan(0, n), frames);
}
float bpm = bpmDetector.GetBpm();
Console.WriteLine($"Estimated BPM: {bpm:F1}");
// Returns 0 when not enough data has been fed yet| Method | Description |
|---|---|
InputSamples(ReadOnlySpan<float>, int frames) |
Feed interleaved audio frames |
GetBpm() |
Return estimated BPM, or 0 if not enough data |
Dispose() |
Release native detector |
Handles manage the lifetimes of native objects. As SafeHandle subclasses, the GC can
invoke the finalizer even when Dispose() is never called, preventing native resource leaks.
| Handle Class | Wraps | Releases via |
|---|---|---|
AudioEngineHandle |
Native engine context | ownaudio_v1_engine_destroy |
AudioOutputStreamHandle |
Output stream | ownaudio_v1_output_stream_destroy |
AudioInputStreamHandle |
Input stream | ownaudio_v1_input_stream_destroy |
MixerHandle |
Multi-track mixer | ownaudio_v1_mixer_destroy |
TrackHandle |
Mixer track (non-owning) | — |
TrackSourceHandle |
Ring-buffer source | ownaudio_v1_track_source_destroy |
FileSourceHandle |
File source | ownaudio_v1_track_file_source_destroy |
MemorySourceHandle |
Memory source | ownaudio_v1_track_memory_source_destroy |
InputSourceHandle |
Input-capture source | ownaudio_v1_track_input_source_destroy |
EffectHandle |
DSP effect (non-owning) | — |
StreamingDecoderHandle |
Streaming decoder | ownaudio_v1_decoder_destroy |
BpmDetectHandle |
BPM detector | ownaudio_v1_bpm_destroy |
Handles are
internaltypes — you do not work with them directly.AudioEngine,AudioOutputStream,AudioInputStream,StreamingAudioDecoder,BpmDetect, and the HighLevel session types manage them on your behalf.
Every native call result passes through ErrorCodeMapper.ThrowIfError(), which maps
raw integer codes to typed exceptions and appends the native error message string
(obtained from ownaudio_v1_last_error_message) to the exception message.
OwnAudioException ← base for all OwnAudio errors
│ .ErrorCode : AudioEngineErrorCode
├─ DeviceException ← device errors (codes 1, 2)
├─ StreamException ← stream errors (codes 3, 4, 5)
├─ DecoderException ← decoder errors (code 20+)
├─ HostApiNotAvailableException ← requested host API unavailable (code 10)
├─ AsioDriverNotFoundException ← ASIO driver not installed (code 11)
└─ AbiVersionMismatchException ← native binary version mismatch (code 12)
| Code | Value | When it occurs |
|---|---|---|
Success |
0 | Operation succeeded |
DeviceNotFound |
1 | Requested device not found |
DeviceEnumerationFailed |
2 | OS audio subsystem enumeration failed |
UnsupportedConfig |
3 | Stream configuration not supported by the device |
StreamBuildFailed |
4 | Stream could not be opened |
StreamControlFailed |
5 | Play / Pause call failed |
NullPointer |
6 | Unexpected null pointer on the native side |
InvalidHandle |
7 | Handle is invalid or already destroyed |
InternalPanic |
8 | Rust panic caught at the FFI boundary |
InternalError |
9 | Other internal error |
HostApiNotAvailable |
10 | Host API not available on this platform |
AsioDriverNotFound |
11 | No ASIO driver installed |
AbiVersionMismatch |
12 | Native binary ABI version does not match |
try
{
using var engine = AudioEngine.Create();
using var stream = engine.OpenOutputStream(null, config, callback);
stream.Play();
// ...
}
catch (AbiVersionMismatchException ex)
{
// Critical: installed ownaudio_ffi is incompatible
Console.Error.WriteLine($"Version mismatch: {ex.Message}");
Console.Error.WriteLine("Reinstall the OwnAudioSharp NuGet package.");
}
catch (HostApiNotAvailableException ex)
{
Console.Error.WriteLine($"Host API unavailable: {ex.Message}");
}
catch (AsioDriverNotFoundException)
{
Console.Error.WriteLine("No ASIO driver found. Install ASIO4ALL or a vendor driver.");
}
catch (DeviceException ex)
{
Console.Error.WriteLine($"Device error [{ex.ErrorCode}]: {ex.Message}");
}
catch (StreamException ex)
{
Console.Error.WriteLine($"Stream error [{ex.ErrorCode}]: {ex.Message}");
}
catch (DecoderException ex)
{
Console.Error.WriteLine($"Decoder error [{ex.ErrorCode}]: {ex.Message}");
}
catch (OwnAudioException ex)
{
Console.Error.WriteLine($"Audio error [{ex.ErrorCode}]: {ex.Message}");
}Both the output and input callbacks run on the native OS audio thread, which is high-priority and subject to strict timing deadlines. Any blocking or slow operation inside the callback causes clicks, pops, or dropouts in the audio.
FORBIDDEN inside the callback:
// Heap allocation
var list = new List<float>();
float[] arr = new float[1024];
// Locking
lock (myLock) { ... }
Monitor.Enter(myLock);
// Blocking I/O
File.WriteAllBytes("rec.wav", data);
Console.WriteLine("something");
// Task blocking
myTask.Wait();
var result = myTask.Result;
// Thread sleep
Thread.Sleep(10);Allowed inside the callback:
// Stack-allocated temporary buffer
Span<float> local = stackalloc float[32];
// Lock-free data transfer to a processing thread
myChannel.Writer.TryWrite(copy); // System.Threading.Channels
// Math and synthesis
float sample = MathF.Sin(phase);
phase += step;
// Span indexing (zero allocation)
args.Buffer[i] = sample;
// Reading a pre-allocated buffer (e.g. WAV playback)
sourceSamples.AsSpan(position, count).CopyTo(args.Buffer);| Operation | Thread Safety |
|---|---|
AudioEngine.Create() and Dispose() |
Safe to call from any thread |
OpenOutputStream() / OpenInputStream() |
Do not call concurrently on the same instance |
AudioOutputStream.Play() / Pause() / Dispose() |
Do not call concurrently on the same instance |
AudioInputStream.Play() / Pause() / Dispose() |
Do not call concurrently on the same instance |
StreamingAudioDecoder.Read() |
Real-time safe (zero-allocation) |
StreamingAudioDecoder.Seek() |
Non-blocking; safe from any thread |
| Audio callback body | Lock-free and allocation-free operations only |
stream.CallbackError event |
Fires on a ThreadPool thread |
Every resource-owning type in the Safe layer implements IDisposable.
Always create instances with using declarations.
using var engine = AudioEngine.Create();
using var outStream = engine.OpenOutputStream(null, config, outputCallback);
using var inStream = engine.OpenInputStream(null, config, inputCallback);
outStream.Play();
inStream.Play();
// ... application runs ...
// At scope exit: streams are disposed in reverse declaration order,
// then the engine — this is the correct order.// 1. ALWAYS dispose streams first
outStream.Dispose();
inStream.Dispose();
// 2. Then dispose the engine
engine.Dispose();Why does order matter? If the engine is destroyed first, a pending callback could access freed native memory, causing a crash or silent data corruption.
As its very first step, AudioEngine.Create() reads the ABI version from the loaded
native binary and compares it with the constant baked into the managed assembly:
public const uint ExpectedAbiVersion = 1u;If the values differ, AbiVersionMismatchException is thrown immediately — before any
audio subsystem is initialised. This prevents silent failures caused by loading an
incompatible native binary.
Resolving an ABI mismatch: Reinstall the OwnAudioSharp NuGet package and verify that
the native binaries in the runtimes/ folder match the package version.
Copyright © 2025 Ownaudio Team
Part of the OwnAudioSharp project.
This project is developed with the following tools:
| Anthropic — Claude Code | |
| Microsoft — Visual Studio Code | |
| Microsoft — Visual Studio 2022 | |
| JetBrains — Rider |