Add owned-buffer TX API and precise read-position snapshots for timestamped external sources#39
Add owned-buffer TX API and precise read-position snapshots for timestamped external sources#39salanki wants to merge 3 commits into
Conversation
Adds a public method to DeviceServer that creates owned ring buffers and returns RBInput write handles to the caller. Unlike the ExternalBuffer path, owned buffers track readable_pos properly, so: - PositionReportDestination is updated on each write - Buffer occupancy metrics are accurate - unconditional_read() is false — inferno only reads validated data Also re-exports OwnedBuffer, RBInput, RBOutput, new_owned_ring_buffer from the device_server module for external consumers.
Adds a seqlock-style shared snapshot that pairs (read_position, monotonic_nanos) at the exact point FlowsTransmitter updates read_position. This gives external buffer writers (like spin2dante) a consistent observation of when and where the TX thread is reading, without the imprecision of sampling read_pos and Instant::now() separately. - ReadPositionSnapshot struct with seq/read_position/monotonic_nanos atomics - Threaded through FlowsTransmitter::start(), run(), transmit_from_owned_buffer() - Written at the TX update site with odd/even seqlock protocol - Backward compatible: existing callers pass None for the snapshot
…ract The TX thread's monotonic_nanos are relative to a reference Instant that only it knows. Previously the bridge had to guess. Now the snapshot exposes ref_instant via a Mutex<Option<Instant>>, set once at TX start. Readers reconstruct the snapshot instant as ref_instant + monotonic_nanos.
fddbdb3 to
eb78bec
Compare
f9f181f to
5b1c9d1
Compare
|
5b1c9d1 to
5ad4ffd
Compare
|
Thanks you for the review! I agree that the cleaner long-term abstraction is not “expose a TX-thread snapshot”, but rather:
For this bridge, the immediate requirement is a coherent cross-domain anchor between:
So with the APIs available today, replacing
Why that matters for my bridge: The bridge is not just trying to answer “what should be audible on the Dante network right now?” It is trying to decide “what ring position should I write this Sendspin chunk to, right now, so that a chunk with server timestamp For that anchor, I need a coherent pair:
If those are sampled separately, any gap between them becomes anchor error. That was the original problem I was trying to solve. Also, the bridge needs the position of the actual cursor Inferno is reading from, not just an ideal continuous media-clock position, because writes are scheduled into a concrete ring buffer that is consumed in packet-sized steps. In the current TX path, the read position is effectively:
and That is why I agree that a better
It may indeed make more sense to solve this in Given the current API surface, I see two options:
That would preserve the functionality needed by external timestamped writers today, while reducing the public API commitment and leaving room to replace it with a better
Let me know what is good for you. Also noted on the metadata. I kept it in to show it was not hand coded. I have removed it. |
This pair could be read from clock overlay structure or PTP_SYS_OFFSET.
I've already implemented it as a workaround for crackling in some ALSA apps, but currently it's disabled by default ( inferno/inferno_aoip/src/device_server/flows_tx.rs Lines 314 to 316 in 3f2bf14 How low latency do you need? Observing TX ringbuffer's read pointer would make sense if you need to write to this buffer just before it is transmitted, i.e. have a latency comparable to Dante packet size. But for music player app it looks like overkill. I agree that latency consistency is important, but given that Dante guarantees predictable latency (measured from PTP clock, not audio flows), media_clock-monotonic_clock difference/drift should be sufficient for this.
I think it's the way to go. Clock measurements are not transmitter-specific and belong to |
|
<0.5ms to keep everything nicely tightly in sync. I'm at 0.33 measured worst case sync difference with the current approach in my tests. p50 is much lower (0.02ms) |
But you can fill the buffer earlier that 0.5ms before the time it will be sent, right? |
This PR adds a small set of APIs to make Inferno easier to use as a transmit backend for externally scheduled, timestamped audio sources that are not driven by ALSA. These all came from developing my Sendspin to Dante bridge. I tried to modify Inferno as little as possible, and approaches tried as alternatives to some of these APIs are described in the broader text below. Please note these implementations are heavily AI assisted.
The main additions are
transmit_from_owned_buffer()for writer-controlled owned TX buffers andReadPositionSnapshot, which publishes a consistent(read_position, monotonic_time)pair from the TX thread at the exact point TX advances its read cursor. The motivation came from integrating Inferno into a protocol bridge, but the underlying need is more general: external TX clients need both a safe owned-buffer write path and a precise observation primitive for “what TX is consuming now,” and trying to reconstruct that entirely outside Inferno turned out to be much less reliable.Summary (assisted by Codex)
This PR adds a small set of APIs that make Inferno easier to use as a transmit backend for external, timestamped audio sources that are not driven by ALSA.
It introduces:
DeviceServer::transmit_from_owned_buffer()ReadPositionSnapshotfor consistent(read_position, monotonic_time)observation from the TX threadref_instantcontract so snapshot timestamps can be reconstructed correctly by external writersThese changes were developed while integrating Inferno into a protocol bridge that receives audio chunks with presentation timestamps and needs to place them accurately into Inferno’s TX ring buffers. The APIs are general enough to be useful for other non-ALSA / externally scheduled TX clients as well.
Motivation
Inferno already exposes strong low-level TX primitives, but an external timestamped source needs two things that were awkward before this PR:
1. Owned TX buffers with writer-side control
For a timestamped source, the application needs to:
readable_pos/ hole-fix behaviorThat is a better fit for Inferno-owned ring buffers than for externally wrapped buffers.
2. A precise observation point for “what the transmitter is consuming now”
The application also needs a trustworthy answer to:
A plain
Arc<AtomicUsize>forread_positionwas not enough by itself, because an external client has to pair it with its ownInstant::now(). That leaves a timing gap between:read_positionIn our case, that observer gap was large enough to make accurate cross-device alignment difficult. The fix was to move the observation into the TX thread itself and publish a consistent pair.
What this PR adds
transmit_from_owned_buffer()This is a convenience API for starting TX from newly created owned ring buffers and returning the corresponding
RBInputwrite handles to the caller.This is useful for applications that want Inferno to remain the owner of the TX buffer implementation, but need direct control over when and where samples are written.
Compared with external-buffer TX, this keeps the existing owned-buffer semantics:
readable_postrackingReadPositionSnapshotThis publishes a consistent
(read_position, monotonic_time)snapshot from the TX thread at the exact point where TX updatesread_position.The implementation uses a simple single-writer seqlock pattern:
seqwhile the writer is updatingseqwhen the snapshot is stableThis avoids the race inherent in:
read_positionInstant::now()elsewhereexplicit
ref_instantThe first version of the snapshot API exposed elapsed nanoseconds only. That turned out to leave an implicit contract between producer and consumer about the time origin.
This PR makes that contract explicit by storing a
ref_instantin the snapshot itself and reconstructing the snapshotInstantfrom:ref_instantmonotonic_nanosThat makes the API self-contained and much less error-prone for external consumers.
Why this belongs in Inferno
I explored solving this entirely at the application layer first.
The application tried several approaches:
Those approaches could improve parts of the behavior, but they all had to work around the same missing primitive: the application could not observe TX position and local monotonic time as one coherent event.
Once that observation moved into Inferno’s TX thread, the external scheduling logic became much simpler and much more stable.
So the main reason for this PR is not “support one specific project,” but:
Backward compatibility
This PR is additive:
transmit_from_external_buffer()are unchangedread_position: Arc<AtomicUsize>path remains availableFiles changed
inferno_aoip/src/device_server/mod.rsinferno_aoip/src/device_server/flows_tx.rsCommit structure
This PR intentionally keeps the changes as three small related commits:
Add transmit_from_owned_buffer() for non-ALSA TX clientsAdd ReadPositionSnapshot for precise TX timing observationExpose ref_instant in ReadPositionSnapshot for correct time-base contractNotes
If you prefer, I’m also happy to:
ReadPositionSnapshotMy view is that they fit well together because they serve the same class of external TX client.