Step 5 of 8

Timeline & Synchronization

Why two files started together drift apart, and what to do about it. Namespace: OwnaudioNET.Synchronization

Start two files at "the same time" and they will separate โ€” different decoders, different buffer boundaries, different rounding. Over a few minutes it becomes an audible flam. The master clock is the cure: one shared timeline that every attached source is continuously nudged back onto.

MasterClock

Every AudioMixer owns one, and it is the single answer to "where are we". You read it for your playhead, and seek it to move everything at once.

โš ๏ธ

Playing file tracks are what move it. The mixer's control tick takes the furthest-along FileSource or GroupSource whose managed state is Playing and puts the clock there. A source that was registered but never started does not count โ€” and a mixer carrying only StreamingSource or InputSource tracks (a click-only session, a live monitor) has no file or group track to read, so its clock sits wherever you last seeked it. Drive your own timeline in that case.

Properties

PropertyTypeDescription
CurrentTimestampdoubleCurrent position in seconds.
CurrentSamplePositionlongCurrent position in samples (lock-free read).
SampleRateintSample rate in Hz.
ChannelsintChannel count.
ModeClockModeRealtime, Offline, NetworkServer, NetworkClient. The network helpers set it; Offline is inert โ€” see below.
IsNetworkControlledboolKept for compatibility. The network sync no longer sets it โ€” a client runs on its own clock.

Methods

C#
// Seek the timeline
mixer.MasterClock.SeekTo(double timestamp);   // position in seconds
mixer.MasterClock.Reset();                    // seek to 0.0

// Advance manually. Nothing inside the library calls this โ€” it is for a clock
// you own and drive yourself, outside a mixer.
mixer.MasterClock.Advance(int frameCount);

// Convert between units
long   samples = mixer.MasterClock.TimestampToSamplePosition(5.0);  // 5 seconds โ†’ samples
double seconds = mixer.MasterClock.SamplePositionToTimestamp(240000L);

Attaching sources

FileSource implements both IMasterClockSource and ISynchronizable; a GroupSource is an IMasterClockSource as well, with the same start offset, seek and drift correction. You rarely call AttachToClock yourself: AudioMixer.AddSource and AddSourcePrepared attach every IMasterClockSource they register to the mixer's clock. What still matters is the order โ€” a source has to be attached before Play(), because attaching afterwards will not undo drift that already happened. Register first, start second.

C#
var mixer   = new AudioMixer(OwnaudioNet.Engine!.UnderlyingEngine, 1024);
var vocals  = new FileSource("vocals.wav",  targetSampleRate: 48000, targetChannels: 2);
var backing = new FileSource("backing.mp3", targetSampleRate: 48000, targetChannels: 2);

// 1. Optional: timeline offsets (backing starts 2.5 seconds into the session).
//    Set this before the source is attached โ€” attachment seeks it to clock minus offset.
backing.StartOffset = 2.5;

// 2. Register โ€” each source is attached to mixer.MasterClock right here
mixer.AddSource(vocals);
mixer.AddSource(backing);

// 3. Seek and play
mixer.MasterClock.SeekTo(0.0);
vocals.Seek(0);
backing.Seek(0);
vocals.Play();
backing.Play();

mixer.Start();
๐Ÿ’ก

If the mixer is already running when you add a source, AddSource starts it for you as well โ€” the explicit Play() calls above are only needed because mixer.Start() comes last.

Attaching by hand

Call AttachToClock directly only when the source is not going through a mixer โ€” you drive it yourself with ReadSamplesAtTime, or you want it riding a clock it was not registered against. It is idempotent: it detaches first, then seeks the source to the clock's position minus its StartOffset.

C#
vocals.AttachToClock(mixer.MasterClock);
bool onClock = vocals.IsAttachedToClock;   // true

Detach on Stop

C#
vocals.Stop();
vocals.DetachFromClock();   // allows independent playback or reuse

mixer.RemoveSource(vocals.Id);
mixer.MasterClock.SeekTo(0.0);

ISynchronizable Interface

Sources that support sample-accurate position tracking implement ISynchronizable. Use this for precise position display or external sync.

C#
if (source is ISynchronizable sync)
{
    long   samplePos = sync.SamplePosition;
    double posInSec  = samplePos / (double)OwnaudioNet.Engine!.Config.SampleRate;

    // Force hard resync (jumps buffer to match clock position)
    sync.ResyncTo(targetSamplePosition);
}

Accurate Position Display

Interpolate between timer ticks for smooth UI updates at 30+ FPS without polling the audio thread too often:

C#
private double _lastEnginePos;
private double _lastEnginePosAt;
private readonly Stopwatch _watch = Stopwatch.StartNew();

// Update at 30 Hz (33ms timer)
private void OnPositionTimer()
{
    double enginePos = mixer.MasterClock.CurrentTimestamp;
    double nowSec    = _watch.Elapsed.TotalSeconds;

    if (enginePos != _lastEnginePos)
    {
        _lastEnginePos   = enginePos;
        _lastEnginePosAt = nowSec;
    }

    // Interpolate between engine updates
    double displayPos = _lastEnginePos + (nowSec - _lastEnginePosAt);
    CurrentPositionSeconds = displayPos;
}

Drift correction

Local playback does not need drift correction any more. Since 4.0 every track is rendered by the same native session inside one device callback, off one sample counter โ€” they cannot separate, so there is nothing to pull back.

Between machines it is the network sync that keeps them together, and it does not touch the clock or seek single tracks: the client trims the tempo of all its timeline tracks by a few tenths of a percent, and seeks the whole mixer only when it is more than 80 ms off. See how it stays together.

โ„น๏ธ

FileSource.SyncTolerance, SoftSyncTolerance and SoftSyncMaxTempoAdjustment are still there so old code compiles (marked [Obsolete]), but nothing reads them: the per-source three-zone correction compared a wall-clock position that a tempo nudge could never move, and was replaced by the network sync's own controller.

โš ๏ธ

FileSource.SyncDiagnostics ([Obsolete]) still returns a SyncDiagnosticsSnapshot, but in 4.x it only reports the thresholds back: AdaptiveScale is always 1.0, RedZoneHitsInWindow always 0, so IsRelaxed is never true. There is no adaptive tolerance machinery behind it any more โ€” don't build a load indicator on it. For "is the machine keeping up", read mixer.SessionLoad instead.

Offline rendering

There isn't any โ€” not since 4.0, and this is the section people arrive at expecting one.

ClockMode.Offline is still in the enum, but 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 = ClockMode.Offline compiles, changes no behaviour, and does not make a bounce faster or more deterministic.

What you actually have is two options:

Next