AudioStream
AudioStream owns a single cubeb stream and turns the backend’s raw frame
counter into a media-time clock. It is owned by AudioSink, which is wrapped by
AudioSinkWrapper, and that wrapper is what
MediaDecoderStateMachine reads the playback position from. So the clock
described here is what the video sink synchronises against.
Two responsibilities live here and are worth keeping apart: handing decoded
audio to the backend, on the backend’s realtime callback thread, and reporting a
media position, on the state machine’s thread. AudioClock and its
FrameHistory are the bridge between them.
The two frame cursors
A backend exposes two frame counts, and they do not mean the same thing.
The write cursor is what the data callback has handed over. It moves in one jump per callback, then sits still.
The play cursor is what the device has actually output, read through
cubeb_stream_get_position. It rises continuously, driven by the hardware clock.
The gap between them is audio handed over but not yet heard. This document calls it the unplayed audio.
frames ---------------------------------------------------------------->
P W
| |
..... played ......|........... unplayed ...........|
|<------------------------------>|
| handed to the backend, |
| not yet heard |
P = play cursor (cubeb_stream_get_position)
W = write cursor (what the data callback has handed over)
unplayed = W - P
The unplayed audio is the cushion that lets a slightly late callback still find audio queued ahead of it, so it does not reach zero during healthy playback. Its size is the device’s output latency plus the part of the last callback the backend has not yet interpolated past, so it jumps up by one callback on each callback and slides back down to the latency in between.
Two consequences matter below:
The play cursor normally stays at or below the write cursor, because it reports what the device has consumed and a device cannot consume what nobody handed it. How it is derived varies: some backends compute it as the frames handed over minus the latency they report, others return an OS or device counter. Either way it is an estimate rather than a hardware readout, and some backends extrapolate from the wall clock since the last callback, so a late callback or a device xrun can report it above the write cursor. The frame history can also account for less than the device has played, because a callback that found the queue full keeps its frames on the audio thread until a later call collects them.
The unplayed audio belongs to the position that was current when it was handed over, not to anything that happens afterwards.
The frame history
FrameHistory maps a play-cursor value to a media time. It is a piecewise
mapping rather than a single division, because silence carries no media time and
the playback rate can change mid-stream.
Member |
Unit |
Meaning |
|---|---|---|
|
frames |
Where the retained history starts |
|
microseconds |
The media time at |
|
both |
Segments from |
The first two are one anchor in two units: when the device had output
mBaseOffset frames, the media was at mBasePosition. GetPosition starts
there and walks the chunks up to the play cursor.
Per chunk, servicedFrames counts frames carrying media time and totalFrames
counts every frame output, so the difference is silence inserted on an underrun.
The clamp min(delta, servicedFrames) holds the clock still while that silence
is audible, and each chunk’s rate is the output rate in force when it was
recorded, so a rate change maps correctly on both sides of it.
The conserved identity
mBaseOffset starts at zero and folding a chunk away adds its totalFrames to
it, so this sum is invariant:
mBaseOffset + sum of every retained chunk's totalFrames == the write cursor
A query normally lands inside the retained chunks, because the play cursor stays
at or below the write cursor. The exception is a callback still stranded on the
audio thread, which leaves the history short. Keeping the identity true is the
whole job: break it and the walk runs off the end, at which point GetPosition
falls back to mBasePosition alone, which is the serviced total, so the
reported position stops following the device between callbacks and jumps
instead.
Rebasing a stream reused across a seek
When the stream is kept alive across a seek rather than destroyed, its frame
counter keeps climbing while media time has to restart, so RebaseLive moves
the anchor instead of resetting the counter.
Media time zero belongs at the write cursor, not the play cursor, because
everything below the write cursor was handed over before the seek. The anchor
cannot simply be set to the write cursor: the history stores playCursor - mBaseOffset in a uint32_t and asserts the difference is not negative, so an
anchor above the play cursor trips that assertion in a debug build and wraps in
an opt one. The anchor therefore stays at the play cursor and the unplayed audio
is carried across as a chunk that services nothing:
frames: P W
| |
|<---------- unplayed ---------->|
| one chunk, totalFrames = |
| unplayed, servicedFrames |
| = 0 |
media time: 0 .............................. 0 ------> counts up from here
totalFrames keeps the identity intact so queries stay inside the chunks, and
servicedFrames of zero means draining that audio advances no media time, so
the clock holds at the seek target until the first post-seek frame is heard.
Both come from one subtraction, W - P.
W is a counter the clock keeps itself, bumped on the audio thread, not a sum
over the retained chunks: the history is only fed when the owner thread drains
the callback queue, and nothing drains it during a seek. It is read after
synchronising with that thread, since an early read understates the gap and lets
the position lead the audio for the rest of playback.
The subtraction needs both cursors, and the audio it describes has to be audio that will still be played:
Situation |
Carried |
|---|---|
Stream kept running across the seek |
|
Backend restarted, queue discarded |
nothing |
Position query failed, no |
nothing |
|
nothing |
Backend drained since the resume |
nothing |
Carrying nothing is how the clock behaved before the carry existed. On the rows where the anchor is still a real play cursor, the identity then holds against the frames the device will play rather than against the counter, which still counts the discarded ones.
The failed-query row is weaker, and deliberately so. With no cursor to anchor at, the rebase anchors at zero, which is neither cursor, so the identity is left broken and the reported position degrades to the serviced total: it stops following the device between callbacks. That is tolerated because the query only fails on a stream that is already dead, after which the sink pins the position to the last good value and this mapping is never consulted.
A rebase also has to neutralise callback information that has not reached the history yet. The reader counts the frames of every item it takes off the queue, and a rebase records the count it accounted for, so an item landing at or below that point is dropped rather than appended. Without that, an item stranded on the audio thread during the seek would be applied afterwards and, being underrun-only, would merge into the carried chunk and extend the window that advances no media time by the whole stranded silence.
The unplayed audio may be leftover pre-seek audio, silence produced during the seek, or a mixture. Either way it was handed over before the seek and is not part of the new position.
Threading
The data callback runs on the backend’s realtime thread and must not block or allocate.
UpdateFrameHistoryis called from that callback;GetPositionfrom the state machine’s thread.On macOS the position read is deliberately lock free. Callback information reaches the reader through a single-producer single-consumer queue, applied by
AudioClock::ApplyQueuedCallbackInfo, which bothGetPositionandRebasecall before touching the history. Anything mutating the history must run on that reader thread and touch only reader-side state; the callback may still be running.Elsewhere a mutex guards the history on both sides.