Step 3 of 8
Audio Sources
Everything that makes a sound. Namespace: OwnaudioNET.Sources
There are five kinds of source and one wrapper. They all implement IAudioSource, so learning one teaches you the rest.
| Type | Reach for it when⦠|
|---|---|
FileSource | You have a file. Streamed from disk, seekable, tempo- and pitch-shiftable. |
SampleSource | The audio is already a float[] in memory β short clips, one-shots. |
GroupSource | Several files belong on one track β a DAW lane with its clips, sharing one tempo, effect chain and fader. |
StreamingSource | You want to generate audio β a synth, a metronome, a network stream. |
InputSource | The audio is arriving live from a mic or line input. |
SourceWithEffects | Not a source β a wrapper that gives any of the above its own effect chain. |
What they all share
| Property | Type | Default | Description |
|---|---|---|---|
Id | Guid | auto | Unique identifier for this source. |
State | AudioState | Stopped | Current playback state. |
Volume | float | 1.0 | Track volume (0.0 β 20.0). Values above 1.0 amplify. |
Pan | float | 0.0 | Stereo pan (-1.0 left β¦ 0.0 center β¦ +1.0 right), equal-power. |
Loop | bool | false | Loop when reaching end of stream. |
Position | double | β | Current playback position in seconds (read-only). |
Duration | double | β | Total duration in seconds (read-only). |
IsEndOfStream | bool | β | Whether the source has reached its end. |
Tempo | float | 1.0 | Playback speed multiplier, clamped to 0.8β1.2. Goes straight to the native track β no buffer clear, safe to set from a slider. FileSource & GroupSource Wired only on FileSource and GroupSource; SampleSource/StreamingSource/InputSource store the value but do not apply it (backward-compatibility surface). |
PitchShift | float | 0.0 | Pitch shift in semitones, clamped to -12 β¦ +12. Goes straight to the native track β no buffer clear, safe to set from a slider. FileSource & GroupSource Wired only on FileSource and GroupSource; other sources store the value but do not apply it (backward-compatibility surface). |
OutputLevels | (float left, float right) | β | Real-time output levels for VU metering (0.0β1.0). |
OutputChannelMapping | int[]? | null | Hardware channel routing. See Channel Routing. |
source.Play();
source.Pause();
source.Stop();
source.Seek(double positionInSeconds); // returns bool
source.RouteToChannels(params int[] channels); // fluent helper
source.Dispose();Common Events
| Event | Args | Description |
|---|---|---|
StateChanged | AudioStateChangedEventArgs | Playback state changed. |
Error | AudioErrorEventArgs | An error occurred during playback. |
PositionChanged | EventArgs | Position changed significantly (throttled >50ms). |
FileSource
Plays audio from a file with background decoding, circular buffer, real-time pitch/tempo, and master clock synchronization. Decoding runs entirely in the native Rust engine. Supported formats out of the box: MP3, FLAC, WAV (PCM/ADPCM), AAC, ALAC, MP4/M4A, OGG/Vorbis and AIFF β no external codecs required. Exotic formats can additionally be handled by FFmpeg 8+ as an optional legacy fallback when it is installed on the system.
Constructor
var source = new FileSource(
filePath: "track.mp3",
bufferSizeInFrames: 8192, // internal circular buffer (default: 8192)
targetSampleRate: 48000, // auto-resampling; 0 = use file's rate
targetChannels: 2 // auto-channel conversion; 0 = use file's channels
);FileSource-Specific Properties
| Property | Type | Default | Description |
|---|---|---|---|
StartOffset | double | 0.0 | Timeline start position in seconds for master clock sync. |
IsAttachedToClock | bool | false | Whether attached to a master clock (read-only). |
SyncTolerance | double | 0.005 | Obsolete. No effect since the network sync got its own controller; kept so old code compiles. See drift correction. |
SoftSyncTolerance | double | 0.025 | Obsolete, no effect, kept for compatibility. |
SoftSyncMaxTempoAdjustment | double | 0.02 | Obsolete, no effect, kept for compatibility. |
Tempo & Pitch Control
Setting .Tempo / .PitchShift and calling SetTempoSmooth() / SetPitchSmooth() do the same thing: the value is handed to the native track, with no buffer clear and no silence gap. The smooth variants only read better at slider call sites. Under a master clock use SetTempoSynced() once the slider has settled β it reseeks to the current position so leftover old-tempo audio cannot leave a permanent drift.
// Direct set β mirrored onto the native track right away
source.Tempo = 1.1f; // 10% faster (range: 0.8 β 1.2)
source.PitchShift = 2.0f; // 2 semitones up (range: -12.0 β +12.0)
// Same thing, named for slider call sites
source.SetTempoSmooth(1.1f);
source.SetPitchSmooth(2.0f);
// Clock-synced tracks: call this once the slider settled, it reseeks away the drift
source.SetTempoSynced(1.1f);Waveform & raw samples
Three reads that open a decoder of their own, so playback is untouched and any of them may be called while the track is running.
// A waveform for a 2000 pixel wide display β one decoder pass, one 2000-float array
float[] peaks = source.GetPeaks(2000);
// Raw samples, whole file or a slice of it
float[] all = source.GetFloatAudioData(TimeSpan.Zero);
float[] chunk = source.GetFloatAudioData(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10));
// Same slice as bytes, in the file's own sample format
byte[] bytes = source.GetByteAudioData(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10));GetPeaks buckets the file into as many points as the display asks for and keeps the loudest sample of each, sign and all β so the array is a signed envelope you can draw straight, not an absolute one. Nothing file-sized is allocated along the way. A container that will not say how long it is halves its own bucket resolution as the scan runs, which is the one case where the result comes back shorter than points; size your drawing loop off peaks.Length rather than the number you asked for.
Do not build a waveform out of GetFloatAudioData. Reading it whole holds the entire file in memory β a five minute stereo song is over eighty megabytes β and walking it in windows opens a decoder per window. GetPeaks exists for exactly this and costs one pass.
Complete Example
int sr = OwnaudioNet.Engine!.Config.SampleRate;
int ch = OwnaudioNet.Engine!.Config.Channels;
var source = new FileSource("backing.mp3",
bufferSizeInFrames: 4096,
targetSampleRate: sr,
targetChannels: ch);
source.Volume = 0.8f;
source.Pan = -0.3f; // slightly left
source.Tempo = GlobalTempo / 100f; // e.g. 100 β 1.0f
source.PitchShift = 0;
source.StartOffset = 0.0;
source.StateChanged += (_, e) =>
{
if (e.NewState == AudioState.EndOfStream)
Console.WriteLine("Track finished.");
};
mixer.AddSource(source);SampleSource
Plays audio from a pre-loaded float[] array. Ideal for sound effects, short clips, or synthesized audio.
// Static sample
float[] samples = LoadSamplesFromFile("click.wav");
var click = new SampleSource(samples, OwnaudioNet.CreateDefaultConfig());
mixer.AddSource(click);
click.Play();
// Dynamic β submit samples in real time
var synth = new SampleSource(bufferSizeInFrames: 2048, OwnaudioNet.CreateDefaultConfig());
synth.AllowDynamicUpdate = true;
// Submit new samples as they are generated
synth.SubmitSamples(newSamples); // ReadOnlySpan<float>
synth.Clear(); // flush bufferGroupSource
Several audio files laid out on one timeline and played as a single source β a DAW lane with its clips. The clips are summed in the native engine into one track, so they share one tempo and pitch stage, one effect chain, one volume, pan and route. An effect tail β a reverb, a delay β rings on across a clip edge instead of stopping with it, and a gap between two clips costs nothing.
Constructor
var lane = new GroupSource(
sampleRate: 48000, // every clip decodes to this β must be the mixer's rate
channels: 2, // every clip decodes to this width
memoryMaxSeconds: 30.0 // files up to this long load into memory, longer ones stream
);Clips
// Loads the file and places it at 0 s / 12.5 s on the lane's content timeline
SourceClip verse = lane.AddClip("verse.wav", 0.0);
SourceClip chorus = lane.AddClip("chorus.wav", 12.5);
// Move it β heard from the next render block, playing or not
chorus.StartSeconds = 16.0;
// Take it off and release its audio
lane.RemoveClip(verse);AddClip decodes a file up to memoryMaxSeconds into memory right there, so call it off the UI thread. Longer files are only probed and stream from disk while they play, each with a short pre-roll so they are buffered by the time the cursor reaches them. A negative start lands at zero. The file is loaded once: taking the source off the mixer and adding it back β the usual stop/play cycle β places the same audio again without decoding anything.
| Member | Type | Description |
|---|---|---|
Clips | IReadOnlyList<SourceClip> | The clips on the lane, in the order they were added. A snapshot β read it once, not every frame. |
Duration | double | End of the last clip in content seconds, zero while the lane is empty. |
Position | double | Content position in seconds. |
Channels | int | Width every clip decodes to. |
StartOffset | double | Timeline start position in seconds for master clock sync, as on FileSource. |
SourceClip.StartSeconds | double | Where the clip sits on the lane. Setting it moves the clip. |
SourceClip.Duration / EndSeconds | double | Clip length, and where it ends on the lane. |
SourceClip.IsInMemory | bool | Decoded into memory, otherwise streamed from disk. |
SourceClip.IsOnGroup | bool | False once the clip was removed. |
Behaviour
- End of stream comes when the cursor runs past the last clip end. Adding or moving a clip ahead of the cursor gives the lane something to play again;
Seekreturnsfalsefor a position past the end. - Master clock: the lane follows the shared timeline the way a
FileSourcedoes β start offset, seek, tempo-aware position and network drift correction are the same. - Effects: wrap it in a
SourceWithEffects; the chain processes the summed lane, clip edges included. Loopis not applied β a lane plays once to its last clip end.
The sample rate has to match the mixer's. The clips are decoded at the lane's rate when they are added and the native group only takes them at the session rate, so AddSource / AddSourcePrepared throw ArgumentException for a mismatch instead of attaching a lane that would play nothing. Create it with OwnaudioNet.Engine!.Config.SampleRate.
A GroupSource plays on the native Rust chain only. In managed mode ReadSamples returns silence.
Complete Example
int sr = OwnaudioNet.Engine!.Config.SampleRate;
int ch = OwnaudioNet.Engine!.Config.Channels;
var lane = new GroupSource(sr, ch);
await Task.Run(() =>
{
lane.AddClip("intro.wav", 0.0);
lane.AddClip("verse.wav", 8.0);
lane.AddClip("outro.wav", 40.0);
});
var fx = new SourceWithEffects(lane);
fx.AddEffect(new ReverbEffect()); // the tail rings on over every clip edge
fx.Volume = 0.8f;
fx.Tempo = 1.05f;
lane.StateChanged += (_, e) =>
{
if (e.NewState == AudioState.EndOfStream)
Console.WriteLine("Lane finished.");
};
mixer.AddSource(fx);
fx.Play();StreamingSource
An endless source whose audio is produced by your own callback. Where SampleSource serves a fixed buffer, StreamingSource generates audio continuously β ideal for synthesizers, metronomes, test tones, procedural audio, or streaming from a network buffer. A parameter change takes effect within the look-ahead window (~120 ms by default, adjustable) without reloading or restarting anything.
Constructor
var source = new StreamingSource(
render: MyGenerator, // AudioRenderCallback
config: OwnaudioNet.CreateDefaultConfig()
);The Render Callback
public delegate void AudioRenderCallback(
Span<float> buffer, // interleaved destination, exactly frameCount Γ Config.Channels long
int frameCount, // frames to produce
long framePosition // absolute frame index of the first requested frame
);framePosition counts from the start of the timeline and is reset by Seek() and Stop(). Generators that must stay locked to a grid β a metronome, an LFO β should derive their phase from it rather than from an internal counter, so a seek repositions them exactly.
The callback runs on the source's own pump thread, never on the audio thread, so taking a lock is safe. Keep it allocation-free so the feed stays ahead of playback.
Behaviour
| Member | Value | Notes |
|---|---|---|
Duration | double.PositiveInfinity | A generator has no end. |
IsEndOfStream | false | Never runs out of audio. |
Position | double | Seconds since the last seek target. |
Seek(seconds) | true | Drops the queued look-ahead and moves the render cursor. Negatives clamp to 0. |
Stop() | β | Stops and rewinds the render cursor to frame 0. |
The callback is not invoked until Play() is called, so constructing a source is cheap. While paused or stopped the pump thread sleeps on a wake handle and costs no CPU.
Look-ahead and latency
The pump keeps a little rendered audio queued ahead of playback. That queue is what makes a parameter change cheap β nothing reloads, the new value simply reaches the output once the audio rendered with the old one has played β but it is also the delay between setting a value and hearing it. The default 0.12 s is invisible on anything scheduled, a metronome or a sequenced part. A generator being played β a synth or a VST instrument fed from a MIDI keyboard β hears every millisecond of it, and there it is worth turning down.
| Member | Value | Notes |
|---|---|---|
LookAheadSeconds | double | Read/write, any thread, while playing. The value you read back is rounded to whole frames. |
DefaultLookAheadSeconds | 0.12 | What a new source starts on. |
MinLookAheadSeconds | 0.005 | The floor. Under it the feed starves before it fills, which is dropouts rather than lower latency. |
MaxLookAheadSeconds | 1.0 | The ceiling. Anything outside the two is clamped, not rejected. |
var synth = new StreamingSource(RenderSynth, config);
// Live from a keyboard: trade CPU for latency
synth.LookAheadSeconds = 0.02; // ~20 ms
// Long backing pad, nobody plays it: let the pump idle
synth.LookAheadSeconds = 0.25;
double now = synth.LookAheadSeconds; // what it actually settled onA shorter window costs CPU: the pump tops the feed up in the same chunks, just more often, and it naps proportionally shorter on a full feed instead of sitting at a fixed poll. Going the other way is nearly free, which is why the default is where it is.
Lowering the value does not flush what is already queued β that would click. The shorter window is in force from the next chunk the pump renders, so you hear it once the old queue has drained, within one look-ahead of the assignment.
This is only the source's share of the delay. The device buffer (BufferSize Γ· SampleRate) is added on top, and the driver's own latency after that β see What latency can I expect? for the whole chain.
Complete Example β sine generator
var config = OwnaudioNet.CreateDefaultConfig();
float frequency = 440f; // live-adjustable from the UI thread
void RenderSine(Span<float> buffer, int frameCount, long framePosition)
{
int ch = config.Channels;
double step = 2.0 * Math.PI * frequency / config.SampleRate;
for (int f = 0; f < frameCount; f++)
{
// phase comes from the absolute position β a seek stays in phase
float sample = (float)Math.Sin((framePosition + f) * step) * 0.2f;
for (int c = 0; c < ch; c++)
buffer[f * ch + c] = sample;
}
}
var tone = new StreamingSource(RenderSine, config);
tone.Volume = 0.8f;
mixer.AddSource(tone);
tone.Play();
frequency = 880f; // takes effect within LookAheadSeconds, no restart neededStreamingSource works with SourceWithEffects and Channel Routing exactly like every other source.
InputSource
Captures audio from an input device (microphone, line-in) and routes it into the mixer.
// Enable input in AudioConfig first
var config = OwnaudioNet.CreateDefaultConfig();
config.EnableInput = true;
await OwnaudioNet.InitializeAsync(config);
OwnaudioNet.Start();
var mixer = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine);
mixer.Start();
var mic = new InputSource(OwnaudioNet.Engine!, bufferSizeInFrames: 8192);
mixer.AddSource(mic);
mic.Play();
// Monitor input peak levels
var (leftLevel, rightLevel) = mic.GetInputLevels();SourceWithEffects
Wraps any IAudioSource to add a per-track effect chain. Effects run in the order they are added.
var source = new FileSource("vocal.wav");
var trackFx = new SourceWithEffects(source);
// Build effect chain
trackFx.AddEffect(new CompressorEffect { Threshold = -12f, Ratio = 4f });
trackFx.AddEffect(new ReverbEffect { RoomSize = 0.4f, Mix = 0.2f });
trackFx.AddEffect(new EqualizerEffect());
// Add the wrapper to the mixer β NOT the raw source
mixer.AddSource(trackFx);
// Manage the chain
trackFx.RemoveEffect(effect); // returns bool
trackFx.ClearEffects();
int count = trackFx.EffectCount;
IEffectProcessor[] fx = trackFx.GetEffects();If you add effects to a source that is already in the mixer, remove the raw source and add the SourceWithEffects wrapper: mixer.RemoveSource(source.Id); mixer.AddSource(trackFx); β and copy the patch over with it (trackFx.OutputRoute = source.OutputRoute;, same for OutputChannelMapping). The wrapper is a different source as far as the mixer is concerned, so without that the track falls back to the identity route on channels 1/2.
Channel Routing
Route individual sources onto specific channels of a multi-channel output. There are two per-source forms β OutputChannelMapping, below, and the destination-indexed OutputRoute that can fan one channel out to several outputs. Both place the source onto the channels of the mixer bus, whose width is AudioConfig.EffectiveOutputChannels (that is OutputChannels when you set it, otherwise Channels), and the bus channels reach the interface in order: bus 0 β physical 0, 1 β 1, and so on.
// Open a 4-channel output β the 4 bus channels drive physical channels 0β3 in order
var config = new AudioConfig
{
Channels = 4
};
// Route music to channels 0+1, metronome to channels 2+3
var music = new FileSource("music.mp3");
var metronome = new FileSource("click.wav");
music.OutputChannelMapping = new[] { 0, 1 };
metronome.OutputChannelMapping = new[] { 2, 3 };
// Fluent style
music.RouteToChannels(0, 1);
metronome.RouteToChannels(2, 3);
mixer.AddSource(music);
mixer.AddSource(metronome);The OutputChannelMapping array length must equal the source's Config.Channels. Channels are zero-indexed.
Channel Conversion (Upmix / Downmix)
OutputChannelMapping maps source channels 1-to-1 to output channels β it does not change the channel count of the source itself. To split a mono source across two channels, or sum a stereo source to mono, use the targetChannels parameter on FileSource. The decoder performs the conversion before mixing, and Config.Channels reflects the converted count.
| Scenario | How | Result |
|---|---|---|
| Mono file β stereo output | targetChannels: 2 | L and R are identical (duplication) |
| Stereo file β mono output | targetChannels: 1 | (L + R) Γ 0.5 β equal power sum |
// Stereo file loaded as mono β decoder sums L+R to a single channel
var source = new FileSource("stereo.wav", targetChannels: 1);
// source.Config.Channels == 1
source.OutputChannelMapping = new[] { 0 }; // route the mono signal to output channel 0
mixer.AddSource(source);// Mono file loaded as stereo β decoder duplicates the signal to both channels
var source = new FileSource("mono.wav", targetChannels: 2);
// source.Config.Channels == 2
source.RouteToChannels(2, 3); // spread to output channels 2+3
mixer.AddSource(source);Fan-out routing with OutputRoute
OutputChannelMapping is source-indexed: it answers "where does my channel i go?", so one source channel can only ever reach one output. OutputRoute turns the question around β it is destination-indexed, answering "which of my channels does bus channel dst take?" β and because two destinations may name the same source channel, one signal can land on several outputs at once.
var click = new FileSource("click.wav", targetChannels: 1);
// bus 0 β nothing, bus 1 β nothing, bus 2 β src 0, bus 3 β src 0
click.OutputRoute = new OutputRoute(new[] { -1, -1, 0, 0 });
// Fluent, with a per-destination gain β the drummer's cue at half level
click.RouteTo(new[] { -1, -1, 0, 0 }, new[] { 1f, 1f, 0.5f, 0.5f });
mixer.AddSource(click);| Member | Type | Notes |
|---|---|---|
SourceForChannel | int[] | One entry per bus channel. -1 means that channel gets nothing from this source. |
Gains | float[]? | Linear gain per bus channel, or null for unity. Same length as the map. |
OutputRoute.Identity(n) | OutputRoute | Straight i β i over the first n channels. |
Both forms coexist: set OutputRoute and it wins, leave it null and OutputChannelMapping stays in charge. Either can be changed while audio is running β the mixer picks it up on its next control tick, without reopening a stream, which is what makes live re-patching safe on ASIO. At most 16 channels are routable.
A route the engine cannot render is rejected, not quietly trimmed: more than 16 bus channels, a source channel the track has not got (a 2 on a stereo source), or a non-finite gain. The track keeps the routing it had and the mixer logs which source was refused and why β so if a route seems to have no effect, turn the log on and read the line.
Per-source decode width
By default a source is decoded at the mixer's bus width, so an 8-channel bus meant an 8-channel decode, time-stretch and effect chain for every source β four times the work for a stereo song. FileSource.DecodeChannels unties the two: the source is processed at its own width and only the summation into the bus is wide.
var song = new FileSource("song.flac");
song.DecodeChannels = 2; // stereo chain on an 8-channel bus
song.OutputRoute = new OutputRoute(new[] { 0, 1, -1, -1, -1, -1, -1, -1 });
mixer.AddSource(song); // set it before adding, it is read on attachLeave DecodeChannels null and nothing changes β the source follows the bus exactly as before. Where the signal lands is OutputRoute's job, not this one.
Picking physical input channels
Every live InputSource feeds off a single shared capture stream, so several live inputs can run at once β on ASIO that matters, because a driver takes one client. CaptureChannels says which physical inputs a given source takes, and its length is the source's own width.
var vocal = new InputSource(OwnaudioNet.Engine!);
vocal.CaptureChannels = new[] { 4 }; // mono, off physical input 5
var guitar = new InputSource(OwnaudioNet.Engine!);
guitar.CaptureChannels = new[] { 0, 1 }; // stereo, off inputs 1+2
mixer.AddSource(vocal);
mixer.AddSource(guitar);
// How many inputs are really there? Ask the engine, not the config.
int available = OwnaudioNet.Engine!.ActualInputChannels;null keeps the old behaviour: the first N physical inputs, repeating the last one so a mono microphone still fills a stereo track. Changing it while running re-taps the source on the next control tick β no stream is reopened.
Because that one stream feeds everything, losing it takes every live input silent at once. The mixer reports it through StreamFaulted with Direction == AudioStreamDirection.Input, so an unplugged interface is an event and a log line rather than a room full of dead microphones.
Next
Setting OutputChannelMapping to an array whose length does not match Config.Channels throws ArgumentException. Always set targetChannels first if you need a different channel count.