Part 7 of Recording, Composition & Streaming, a 14-part deep dive into GopherTrunk’s output half. Parts 4–6 built the recording session, the crash-safe WAV, and the on-disk naming. This post is about the guards that keep them correct under adversarial timing: a voice tap whose serial gets reused before the old call drained, a call that turns out to be encrypted halfway through, IDs that resolve late, and an operator flipping recording off mid-conversation. None of these is visible in a happy-path recording of our 3 p.m. dispatch — they’re the machinery that makes sure the file you get is *that call’s audio and nothing else’s.*
TL;DR: Wideband voice taps reuse a small pool of device serials, so a frame from a just-ended call can arrive after the next call has claimed the same serial. GopherTrunk fences this with the CallID:
sessionForWriterejects a frame whosecallIDdoesn’t match the open session, and the liveAudioPublisherapplies the same fence onWritePCMForCall/WriteRawFrameForCall. Whenskip_encryptedis set and a call is discovered encrypted mid-stream, the recorder closes and deletes the partial and publishes noCallComplete. Late-resolving RID/ALG/KID are backfilled onto the session’s grant so the eventualCallCompleteis accurate, and recording can be toggled at runtime without truncating in-flight calls.
Key takeaways
- The CallID fence is enforced in two independent places — the recorder’s
sessionForWrite(the WAV/.rawside) and the audio publisher’swritePCM/writeRaw(the live side) — because a reused serial can bleed audio into either sink. - Encryption is handled at two moments: a grant that already signals
encryption never opens a file; encryption discovered mid-call closes and
os.Removes the partial and suppressesCallComplete. - Backfill mirrors late-resolved source ID and encryption facts onto the
session’s stored grant, so a Phase 2 compressed grant that resolves on the
traffic channel doesn’t broadcast as
encrypted=false. - Runtime toggles are graceful: disabling recording drops new
CallStarts but lets in-flight sessions finish; the per-talkgroupRecordflag gates files without silencing live audio.
Cheat sheet
| Guard | What it stops | Where in code |
|---|---|---|
| CallID fence (recorder) | Stale frame → wrong WAV/.raw |
sessionForWrite (internal/voice/recorder.go) |
| CallID fence (live) | Stale frame → wrong live stream | writePCM/writeRaw (internal/api/audio_publisher.go) |
| Mid-call encryption abort | Encrypted partial reaching uploads | handleEncryptionUpdate (internal/voice/recorder.go) |
| Grant backfill | Inaccurate CallComplete metadata |
backfillSessionGrant (internal/voice/recorder.go) |
| Runtime record gate | Losing the head of a mid-call disable | SetRecordingEnabled / recordDisabled |
Talkgroup Record gate |
Writing files for no-record talkgroups | handleStart (internal/voice/recorder.go) |
In this post
- The reused-serial problem — why a valid frame can belong to the wrong call.
- The CallID fence, enforced twice: on disk and on the live stream.
- Encryption, at grant time and mid-stream — abort, delete, suppress.
- Backfill — making the finished-call metadata match what the engine learned late.
- Runtime toggles — record on/off and per-talkgroup gating without collateral.
The reused-serial problem
A recording session is keyed by device serial — which voice SDR (or wideband
voice tap) is following the call. That key is stable and cheap, but it has a sharp
edge: wideband taps draw from a fixed pool of serials. When one call ends and
the next grant reuses the same tap, the recorder’s sessions[serial] is
re-pointed at the new call — while the previous call’s decode chain may still be
draining a few final frames. Those frames are valid audio; they just belong to a
call that’s over. Delivered naively, they’d be appended to the new call’s WAV, and
fanned to live subscribers filtered on the new call’s talkgroup — one call’s
tail bleeding into another’s stream, mislabelled.
The engine already carries the thing needed to tell them apart: Grant.CallID, a
process-monotonic identifier the voice pool assigns per call. The guard is to
stamp each session with its CallID and reject any frame that arrives claiming a
different one.
The CallID fence, on disk
Every write path funnels through sessionForWrite, which does the matching under
the recorder’s lock:
// internal/voice/recorder.go (shape)
func (r *Recorder) sessionForWrite(serial string, callID uint64) *recordingSession {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.sessions[serial]
if !ok {
return nil
}
if callID != 0 && s.callID != 0 && callID != s.callID {
return nil // stale frame from the call that previously held this serial
}
// …lazily open files / return the live session…
}
The rule is deliberately permissive at the edges: a zero on either side
matches. A zero incoming callID (an analog or synthetic call that doesn’t
stamp one) or a zero session callID preserves the old behaviour for callers that
never opted in. Only when both are non-zero and they differ is the frame
rejected — and it’s rejected without reopening a dormant session, so a
mismatched frame can’t even spawn an empty file. Digital voice chains that know
their call’s identity call WriteRawFrameForCall(serial, callID, …) in preference
to the plain form; the fence is invisible to everyone else.
TestRecorderCallIDFenceDropsStaleFrames reproduces the reuse and asserts the
stale frame lands nowhere.
The same fence, on the live stream
The WAV isn’t the only sink a stale frame can poison. The live audio publisher fans decoded PCM (and, for opted-in subscribers, raw frames) to gRPC/HTTP listeners — and it filters by talkgroup. A stale frame fanned out here would be labelled with, and leaked to subscribers filtered on, the new call’s talkgroup. So the publisher enforces a matching fence, keyed the same way, against the grant map its own bus subscription maintains:
// internal/api/audio_publisher.go (shape)
func (p *AudioPublisher) writePCM(deviceSerial string, callID uint64, samples []int16) error {
p.mu.RLock()
defer p.mu.RUnlock()
grant, haveGrant := p.grants[deviceSerial]
// The serial's live grant has moved to a different call — drop the old tail.
if haveGrant && callID != 0 && grant.CallID != 0 && callID != grant.CallID {
return nil
}
// …fan to matching subscribers…
}
writeRaw carries the identical check. The recorder hands its session’s CallID to
whichever sink implements the call-aware interface (DecodedPCMCallSink /
RawFrameCallSink); a sink that doesn’t still gets the plain WritePCM. The two
fences are independent implementations of one invariant because the two sinks
learn “the call has moved on” through different channels — the recorder from its
sessions map, the publisher from its bus-fed grants map — and either could
still hold the old identity for a beat.
TestAudioPublisher_WritePCMForCallFencesStaleCall and its raw twin drive exactly
the reuse the recorder test does, one layer out.
Encryption, at two moments
GopherTrunk can be told (recordings.skip_encrypted) to refuse encrypted calls.
Encryption is known at two very different times, so the guard has two halves.
At grant time, if the control-channel grant already flags encryption,
handleStart never opens a session — the file simply never exists:
// internal/voice/recorder.go (shape)
if r.skipEncrypted && cs.Grant.Encrypted {
r.log.Debug("recorder: skipping encrypted call", /*…*/)
return
}
Mid-stream is the harder case. P25 Phase 1 carries its Encryption Sync in
LDU2, and a Phase 2 compressed grant only resolves encryption on the traffic
channel — so a call can be recording clear-looking audio before it announces
it’s encrypted. When that arrives (a KindCallEncryption or KindCallSourceUpdate
event whose flag resolves to encrypted), handleEncryptionUpdate aborts: it drops
the session, closes the open files, and deletes them from disk, publishing no
CallComplete so no partial ever reaches the upload feeds:
// internal/voice/recorder.go (shape)
func (r *Recorder) handleEncryptionUpdate(deviceSerial string, encrypted bool) {
if !r.skipEncrypted || !encrypted {
return
}
s, ok := r.sessions[deviceSerial]
if !ok {
return
}
delete(r.sessions, deviceSerial)
if s.wav == nil {
return // dormant post-segment park — no open files
}
_ = s.close()
for _, p := range []string{s.wavPath, s.rawPath} {
if p != "" {
_ = os.Remove(p) // tolerate os.IsNotExist
}
}
// …no CallComplete published…
}
Suppressing CallComplete is the load-bearing part: as
Part 1
established, the broadcast manager only acts on CallComplete. No event, no
upload — the encrypted partial can’t leak even though it briefly existed on disk.
TestRecorderAbortsOnEncryptionSync and
TestRecorderAbortsMidCallEncryptedSourceUpdate cover both trigger events;
TestRecorderRecordsEncryptedWhenSkipDisabled confirms it’s opt-in.
CallComplete is published — so the broadcast manager, which acts only on that event, never touches the partial.Backfill: making the metadata match
Deleting the partial is one response to late-resolved encryption. But when the
operator isn’t skipping encrypted calls — or when the late fact is a source
radio ID rather than encryption — the call keeps recording, and the concern flips:
the eventual CallComplete must carry the truth. That CallComplete is built
from the session’s stored grant (a snapshot taken at CallStart), so
backfillSessionGrant mirrors the engine’s late discoveries onto it:
// internal/voice/recorder.go (shape)
func (r *Recorder) backfillSessionGrant(serial string, encrypted bool, algID uint8, keyID uint16, sourceID uint32) {
s, ok := r.sessions[serial]
if !ok {
return
}
if encrypted {
s.cs.Grant.Encrypted = true
if algID != 0 { s.cs.Grant.AlgorithmID = algID }
if keyID != 0 { s.cs.Grant.KeyID = keyID }
}
if sourceID != 0 {
s.cs.Grant.SourceID = sourceID
}
}
The update rules are conservative on purpose. Encryption is sticky-true — set,
never cleared here — so a later clear-looking update can’t un-mark a call the
engine already saw encrypted. A zero source is ignored, so a subsequent
clear-source update can’t erase a RID that was already resolved. Without this, a
Phase 2 compressed grant whose encryption resolves only on the traffic channel
would broadcast as encrypted=false even though the call log — fed from the
engine-backfilled CallEnd grant — correctly shows it encrypted (the exact split
of issue #897). TestRecorderBackfillsMidCallEncryptionIntoCallComplete pins the
two records back into agreement.
Runtime toggles without collateral
The last guards are about operator actions mid-stream not damaging calls in flight.
Runtime record on/off. SetRecordingEnabled(false) flips an atomic
recordDisabled that handleStart checks: subsequent CallStarts are dropped
silently, laying down no files. But in-flight sessions are left alone — they
finish naturally on CallEnd — so flipping the switch mid-conversation doesn’t
truncate the call that’s already recording. The gate stops new work; it never
severs existing work. TestRecorderGateBlocksNewSessions covers the block;
the atomic makes the read lock-free on the hot path.
Per-talkgroup gating. Independently, a talkgroup flagged Record = false is
followed and played live but written to no file. The check in handleStart is
careful about a subtlety: it must not drop the call outright, or the talkgroup
would go silent live too. Instead it skips only file creation, and only when the
recorder is actually persisting (!r.decodeOnly):
// internal/voice/recorder.go (shape)
if !r.decodeOnly && cs.Talkgroup != nil && !cs.Talkgroup.Record {
return // follow + play live, but write no files
}
A decode-only recorder writes nothing regardless, so it deliberately falls through
here — dropping the call would silence a talkgroup that’s supposed to be audible.
TestRecorderSkipsRecordFalseTalkgroup locks the file-skip; the live path stays
open. Two toggles, one principle: change what’s recorded going forward, never
damage what’s already in flight or what’s meant to stay audible.
Where this goes next
Part 8
moves from correctness to polish: where and when the optional loudness
normalization runs — after the WAV is finalized and before CallComplete is
published, so every downstream consumer reads leveled audio — and why that seam,
not the DSP, is where GopherTrunk places it.
FAQ
What is the CallID fence and why is it needed?
Wideband voice taps reuse a small pool of device serials, so a frame from a
just-ended call can arrive after the next call has claimed the same serial.
Each session and grant is stamped with the call’s monotonic CallID; a frame
whose CallID doesn’t match the current session (both non-zero and different) is
dropped, so one call’s draining tail can’t be written to — or streamed as — the
next call’s audio.
Why is the fence implemented in two places?
Because a stale frame can poison two independent sinks: the recorder’s WAV/.raw
files and the live audio publisher’s stream. The recorder learns a call has ended
through its sessions map; the publisher learns through its own bus-fed grants
map. Each enforces the same CallID check against its own view, so neither sink
mislabels a reused-serial frame.
What happens if a call is found to be encrypted after recording starts?
With skip_encrypted on, the recorder closes and os.Removes the in-progress WAV
and .raw, drops the session, and publishes no CallComplete. Because the
broadcast manager acts only on CallComplete, the encrypted partial that briefly
existed on disk never reaches any upload feed.
Can I turn recording off without losing the call that’s currently recording?
Yes. The runtime record toggle only blocks new CallStart events; sessions
already open finish normally on CallEnd. Likewise a talkgroup marked
Record = false is still followed and played live — only its file writing is
skipped — so gating never silences live audio.
Series navigation
Part 7 of 14 · ← Part 6: Segmentation, Naming & Raw Sidecars · Next → Part 8: The Loudness Output Stage