Carry a voice capture to another process¶
Voice capture can run somewhere other than the process holding the gateway, and this module deliberately does not say where the audio goes next. A bus, an object store, a socket: that choice is yours, and nothing here names one.
What this page gives you is the list of facts that must cross, and what goes wrong when each is missing. Nine of the ten are not the audio.
Everything below is reconstructable from this module's exported surface alone, so you can write the adapter without importing a provider. That is a promise the round-trip tests hold, not an accident you are relying on.
Before you start¶
You need a VoiceSession from VoiceReceiver.Join, and a sink. The sink is
where carriage begins:
sess, err := rx.Join(ctx, channelID, func(f chatplatform.VoiceFrame) {
speaker, known := f.Speaker()
if !known || !consent.Allows(speaker) {
return // refused before anything retains it
}
carrier.Offer(ordinal.Next(), time.Now(), f) // non-blocking
})
Consent is decided here and nowhere else. A frame that leaves this process has been retained, whether it went to a bus, a disk or a socket. Do not buffer first and filter later.
Do not block in the sink. It is called on the provider's receive path, and waiting here stalls capture. Hand the frame to a bounded queue and let a publisher drain it.
The ten things that must cross¶
Plus an eleventh, conditional on a capability; see step 11.
1. A stream identity¶
Mint it before you call Join, never reuse it, and do not change it when the
transport underneath reconnects.
Before, because frames can arrive before Join returns; the sink is installed
inside it. An identity assigned on the first frame has nothing to attach to a
capture that produced none.
2. The channel identity¶
VoiceSession does not expose it: you passed it to Join, so you have it and
the far side does not. Without it, a receiver cannot say what it is holding.
3. The capabilities¶
VoiceReceiver.Capabilities(), carried whole. These are not optional metadata:
Sequencedfalse meansSequenceandTimestampare meaningless.Attributesfalse means every frame is unattributed forever, which is different from attribution lagging at the start of a call.ReportsInterruptionsfalse means an empty interruption list carries no information at all.
A receiver that gets frames without these cannot interpret any of them.
4. The format, once, before any frame¶
VoiceSession.Format(). It is stable for the life of the session, so one send is
enough. But it can only be read once Join has returned, while frames may
already be arriving.
So buffer accepted frames until Join returns, publish the format, then release
the buffer, never by waiting inside the sink, which deadlocks a provider
that calls it during Join.
Publishing frames first and letting the header catch up is not an option: a
receiver cannot decode them, and on a carrier that can shed it may never get the
header at all. Where the pre-join VoiceReceiver.Capabilities().Format is known
to equal the session's, you may send that instead and skip the buffer.
On a publish/subscribe transport a late subscriber misses a one-shot header. Retain it or repeat it.
5. A delivery ordinal, and a wall-clock mapping¶
Number every frame in the sink, as you accept it.
This is the one people skip, and it is the one that cannot be recovered.
Frames do not self-place. RTP sequence and timestamp spaces belong to a
source, start at random offsets, and this contract does not carry the source
identifier, so two speakers' numbers are not comparable. Where Sequenced is
false there is no ordering on the frame at all.
The sink is called serially and never concurrently, so the order of those calls is a total order, and it is the only one that exists. Nothing downstream can reconstruct it.
The ordinal orders; it does not place. Ordinal distance is unrelated to elapsed
time (silence produces no frames, and two people talking produces two per
interval), so carry either an acceptance timestamp per frame, or periodic
checkpoints with stated interpolation semantics. One (ordinal, time) pair at
the start is not enough.
6. The frames¶
Rebuild each with AttributedFrame or UnattributedFrame, from Speaker,
Payload, Sequence and Timestamp. Order them by the ordinal from step 5,
never by anything on the frame itself. A carrier that reorders is fine; a
protocol that expects RTP to fix it is not.
7. The interruptions¶
VoiceSession.Interruptions(), which may be read late, because they are
self-placing, carrying At.
Keep them distinct from gaps your own carriage introduced. Merging the two tells a consumer the platform lost audio that the platform delivered.
8. Stats, recomputed on arrival¶
VoiceStats.Received means frames delivered to this session's sink. A
far-side session reporting the publisher's number would be stating something
false about itself the moment carriage lost anything.
Carry the publisher's counts as well if you like, labelled as the original observation. The difference between the two is what carriage cost.
9. A carriage-loss record¶
What you believe was lost, in ordinal ranges, and distinguish confirmed loss from possible loss, with bounds allowed to be unknown.
A broken stream or a timed-out write often establishes only that some range might not have arrived. A protocol that can express only certain loss reports none, and a consumer then certifies a capture as complete when it is not.
10. A terminal cause, fenced¶
From Done() and Err(), sent after the final frame, the final interruption
and the final stats, and naming the last ordinal it closes over.
Done is a receive barrier: once it closes, no further sink call can begin and
the counters have settled. Without the fence, the record that says "this capture
ended" races the data it is supposed to close over, and a far side cannot tell an
ended capture from one whose tail is still in flight.
The error itself does not cross. Carry whether it was clean, the identity of any
sentinel it matches, and the message text. Do not promise errors.As into a
platform type on the far side; that would require the far side to import the
provider.
11. The participants, when you have them¶
Conditional, unlike the ten above: only where the consumer declared
NeedVoiceParticipants and the provider supports it. A capture worker holding
only a VoiceReceiver cannot query them, and its silence is not a protocol
violation.
Carry the snapshot, its VoiceParticipantCapabilities, and the outcome of the
query rather than only its result. Otherwise a missing participant record
conflates three different things (the capability was not declared, it was
declared and refused, or it was lost in carriage), which is the distinction
Participants exists to preserve, undone one layer up.
Participants are not self-placing: timestamp the snapshot yourself, as you do frames. And what crosses is the room at a moment, not a record of who heard what; without a change stream a far side cannot reconstruct attendance over a multi-minute capture.
What you get, and what you do not¶
An adapter carrying all ten presents the far side a faithful account of what arrived, plus an explicit record of what carriage lost. Not "everything the sink delivered", because carriage can shed, and that stronger claim holds only if yours cannot.
It cannot reproduce a whole VoiceSession. Leave needs a control link back, or
documenting as a local unsubscribe, and CanSend is honestly false where there
is no sending path.
Where each fact rides¶
The ten are the same ten whichever destination you pick. Only the column changes.
| message bus | object store | direct stream | |
|---|---|---|---|
| shape | events, batched | segments, appended | frames or batches |
| ordering | not promised | within one object; across several it lives in the manifest | inherent, until it breaks |
| a gap is | a shed, counted | a failed write, retryable | a break, often of unknown extent |
| format, ordinal base, mapping | a header message | the manifest | the preamble |
| terminal cause | a final, fenced message | finalising the object | a close with a reason |
| late arrivals | miss a one-shot header | read the manifest | not applicable |
If you segment, one obligation applies that a batching adapter never meets:
- Cut only on a frame boundary. A recording has to concatenate back to the original stream byte-exactly to be worth re-transcribing. Rolling a segment at a byte count or a clock tick produces an archive that is silently unusable.
Deduplication is not on that list, because it is not segmenting-specific: a
bus redelivers, a stream replays after reconnect, and an object write can commit
and then fail to report it. Whatever you chose, deduplicate, on
(stream identity, ordinal), never on Sequence, which is 16 bits and wraps,
and never on the ordinal alone, which restarts per capture.