Skip to content

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:

  • Sequenced false means Sequence and Timestamp are meaningless.
  • Attributes false means every frame is unattributed forever, which is different from attribution lagging at the start of a call.
  • ReportsInterruptions false 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.

See also