Recording, Composition & Streaming, Part 9: CallComplete — The Seam to Everything Downstream

Part 9 of Recording, Composition & Streaming. Our 3 p.m. dispatch on talkgroup 101 is now a finished, loudness-normalized WAV on disk. The recorder is done with it. Everything else the scanner does with that call — upload it, index it, show it in a UI — hangs off one event the recorder publishes at exactly this moment: KindCallComplete. This post is about that seam: what finalizeLocked puts into the payload, what it pointedly leaves out, and why that single event is the reason recording and streaming never have to know each other’s types.

TL;DR: CallComplete is the one event that decouples recording from everything downstream. finalizeLocked builds it after it closes a call’s WAV, and it carries just enough to act on a finished file: the grant identity, the resolved talkgroup, start/end timestamps, the end reason, and — the two fields the engine could never know — the on-disk AudioPath and its SampleRate. The per-call audio-quality picture (VoiceStats: pitch, AGC gain, clip %) is logged at finalize but not carried on the event, and the demod figures (SignalDbFS, EVMPct, SNRDb) ride the earlier CallEnd instead. Knowing which figure lives on which event is knowing this seam.

Key takeaways

  • One event, many subscribers. The recorder publishes CallComplete and moves on; the broadcast Manager (and any future subscriber) reacts. No subsystem calls another — the seam is a struct on a bus, not a function call.
  • CallComplete carries the file; CallEnd carries the demod quality. The engine emits CallEnd with SignalDbFS / EVMPct / SNRDb the instant the call tears down; the recorder emits CallComplete later, once a real AudioPath exists. The split is deliberate.
  • finalizeLocked is the sole constructor. It’s the only place a *CallComplete is built, and it returns nil — publishing nothing — for a call that captured no usable audio, so downstream never sees a phantom file.
  • VoiceStats is a log, not a payload. The audio-layer quality summary is emitted to the operator’s log at finalize; it isn’t threaded onto the event, because its consumer is a human triaging “robotic” audio, not the uploader.

Cheat sheet

Thing Type / function Where Role
The completion event trunking.CallComplete internal/trunking/grant.go Payload of KindCallComplete — the downstream seam
Its builder finalizeLocked internal/voice/recorder.go Closes the WAV, returns *CallComplete or nil
The demod figures CallEnd.SignalDbFS / EVMPct / SNRDb internal/trunking/grant.go Ride CallEnd, feed the call log
Per-call quality log VoiceStats, logVoiceStats, voiceStatsFor internal/voice/stats.go, recorder.go Audio-layer triage summary, logged not carried
Clip-warn threshold voiceClipWarnPct internal/voice/recorder.go Escalates the quality log to WARN
Stats capability StatProvider, ErrorAware internal/voice/stats.go Interfaces a vocoder opts into

In this post

  • Why the seam exists — recording and streaming that never import each other.
  • What finalizeLocked builds — the two fields only the recorder knows.
  • The two-event split — file metadata on complete, demod quality on end.
  • The VoiceStats quality log — why it’s logged and not carried.
  • The nil case — the calls that finish but complete nothing.

Why the seam exists

Part 1 of this series made the argument in the abstract: the output half is four independent subscribers on one bus, not a call chain. CallComplete is where that argument becomes concrete. The recorder produces a file; the broadcast Manager consumes it; and the only thing connecting them is a struct carrying a path. The recorder does not import internal/broadcast; the broadcast package does not import the recorder. Either can be tested, replaced, or disabled without touching the other, because the seam between them is data.

That matters most when a downstream step is slow or absent. A Broadcastify upload that stalls for thirty seconds is contained entirely inside the Manager’s worker goroutine — the recorder published CallComplete and returned. If no broadcast section is configured, the Manager doesn’t exist and the event simply has one fewer subscriber. Recording never blocks on, or even knows about, what happens after the file is written.

finalizeLocked close WAV → *CallComplete event bus KindCallComplete broadcast Manager reads AudioPath → MP3 → upload any future subscriber same event, no recorder change (disabled → absent) one fewer subscriber feeds
One CallComplete fans out. The recorder publishes and returns; each subscriber reads the fields it needs. Adding or removing a consumer never touches the recorder.

What finalizeLocked builds

finalizeLocked is the sole constructor of a *CallComplete. It runs under the recorder’s lock at the moment a session’s WAV is closed — on a normal call end (handleEnd) or at a per-transmission segment boundary (handleSegment). Its job is to turn a live recordingSession into either a completion payload or a decision to publish nothing.

When it does build one, the payload is small and deliberately file-centric:

// internal/trunking/grant.go (shape)
type CallComplete struct {
    Grant        Grant      // full grant identity: system, protocol, TG, source, freq…
    Talkgroup    *TalkGroup // resolved label, or nil if unknown
    DeviceSerial string     // which voice SDR followed the call
    StartedAt    time.Time
    EndedAt      time.Time
    Reason       EndReason
    AudioPath    string     // the .wav the recorder wrote — only the recorder knows this
    SampleRate   uint32     // its PCM rate in Hz
}

The two fields that justify the event’s existence are AudioPath and SampleRate. Everything else — the grant, the talkgroup, the timestamps, the reason — the engine already had at CallEnd. What the engine could never know is where the finished file landed on disk and at what rate it was written, because those are decided inside the recorder (see Part 6 on naming and Part 5 on the WAV rate). CallComplete exists precisely to carry those two facts to the consumer that needs a real file, the uploader.

The build itself is the tail of finalizeLocked:

// internal/voice/recorder.go (shape)
return &trunking.CallComplete{
    Grant:        s.cs.Grant,
    Talkgroup:    s.cs.Talkgroup,
    DeviceSerial: serial,
    StartedAt:    s.startedAt,
    EndedAt:      endedAt,
    Reason:       reason,
    AudioPath:    s.wavPath,
    SampleRate:   s.sampleRate,
}

Note it copies the grant off the session (s.cs.Grant), not off the incoming CallEnd. That’s what lets the recorder’s in-call backfills — a source ID or encryption flag recovered on the traffic channel and stitched onto s.cs.Grant mid-call — reach the uploader with the identity the call actually turned out to have, rather than the grant-time snapshot.

The two-event split

The single most useful thing to internalize about this seam is which quality figures live on which event — because they are split, on purpose, across two events that fire at different times.

The demod-quality figures ride CallEnd, the event the engine publishes the instant the call tears down:

// internal/trunking/grant.go (shape)
type CallEnd struct {
    Grant        Grant
    Talkgroup    *TalkGroup
    DeviceSerial string
    StartedAt    time.Time
    EndedAt      time.Time
    Reason       EndReason
    SignalDbFS   *float64 // mean received channel power (dBFS), RSSI-style
    EVMPct       *float64 // RMS error-vector magnitude (%) over the settled decode
    SNRDb        *float64 // estimated symbol SNR (dB)
}

All three are pointers because they’re optional — the composer measures them over the settled decode (currently only the P25 Phase 1 chains feed the demod taps that populate EVMPct / SNRDb), and they’re nil on any call ended by the watchdog, a preemption, or shutdown. Critically, SignalDbFS is a channel-power figure and not SNR/EVM — they answer different questions and the struct keeps them separate.

These figures do not appear on CallComplete. The reason is the same one that motivated splitting CallEnd from CallComplete in the first place (Part 1): the demod quality is known immediately, before any file is finished, and its consumer — the call log — acts on CallEnd so it can persist that quality the moment the call ends. Making the uploader wait on CallComplete for a file it needs, while letting the logger act on CallEnd for metadata it already has, is the whole point of having two events. Duplicating the demod figures onto CallComplete would blur that line for no consumer that needs them there.

at teardown → CallEnd Grant · StartedAt · EndedAt · Reason SignalDbFS *float64 — channel power EVMPct *float64 — error-vector % SNRDb *float64 — symbol SNR → read by the call log (Part 10) after WAV closed → CallComplete Grant · Talkgroup · timestamps · Reason AudioPath string — where the .wav is SampleRate uint32 — its PCM rate (only the recorder knows these two) → read by the broadcast Manager (Part 13) later VoiceStats — pitch · AGC gain · clip% logged at finalize (logVoiceStats), carried on NEITHER event
Which figure lives where. Demod quality rides CallEnd for the logger; file metadata rides CallComplete for the uploader; the audio-layer VoiceStats summary is written to the log and travels on no event at all.

The VoiceStats quality log

There is a third body of quality information about our call, and it goes to a third place: the operator’s log. Where CallEnd’s figures describe the channel (how clean the RF and the symbols were), VoiceStats describes the audio — the layer the FEC counters can’t see:

// internal/voice/stats.go (shape)
type VoiceStats struct {
    Frames, Voiced, Unvoiced, Silent, Bad, Repeated int // frame-class counts
    MeanF0Hz, MeanL, MeanVoicedFrac                 float64 // pitch / spectral
    MeanAGCGain, MinAGCGain, MaxAGCGain             float64 // AGC behaviour
    MaxPreClipPeak, OutputRMS, CrestFactor          float64 // amplitude health
    ClipSamples, TotalSamples                       int
    // …b_0 range + FirstFrameHex for dead-key diagnostics…
}

func (s VoiceStats) ClipPct() float64 { /* 100 * ClipSamples / TotalSamples */ }

finalizeLocked calls logVoiceStats for every call that finalizes. It obtains the stats through voiceStatsFor, which type-asserts the session’s vocoder to the optional StatProvider interface — currently only the pure-Go IMBE decoder implements it, so a vocoder that doesn’t track stats simply produces no summary, and the recorder carries on. When stats exist, the line reports pitch, harmonic count, AGC gain range, peak, RMS, crest factor, and clip percentage. It’s logged at DEBUG normally, but escalates to WARN when the output is clipping past voiceClipWarnPct (0.5%) — the signature of an over-hot vocoder AGC slamming speech into the int16 rail:

// internal/voice/recorder.go (shape)
const voiceClipWarnPct = 0.5

if vs.ClipPct() > voiceClipWarnPct {
    r.log.Warn("recorder: voice audio quality — output clipping (vocoder gain too hot)", args...)
    return
}
r.log.Debug("recorder: voice audio quality", args...)

The design point for this series: VoiceStats is logged, not carried. It rides neither CallEnd nor CallComplete, because its consumer is a human triaging a “robotic” or “too loud” field report — not a subsystem making a routing decision. Threading it onto an event would put diagnostic detail on the wire that no automated subscriber reads. (The related interface, ErrorAware, lets the same decoder consume the per-frame FEC corrected-bit count from the recorder before it decodes — the input side of the same quality story.)

The nil case

finalizeLocked doesn’t always build a CallComplete. It returns nil — and the caller publishes nothing — whenever there is no real file to hand downstream. Two cases matter. A vocoder-decoded call whose every frame was idle, silent, or bad (vs.Voiced + vs.Unvoiced == 0) is a dead-key or idle carrier: finalizeLocked removes the empty outputs and returns nil. And a call that decoded no PCM at all (dataBytes == 0) returns nil too — though a digital call keeps its .raw sidecar as the only capture even when the WAV is empty.

This is the seam’s quiet correctness guarantee: because the only constructor of a CallComplete refuses to build one for a call with no usable audio, no downstream subscriber ever receives a completion event pointing at a file that isn’t worth uploading. The uploader never has to defend against a phantom path; if it got a CallComplete, there is a real, non-empty WAV at AudioPath. The publish sites make this explicit — they null-check before touching the bus:

// internal/voice/recorder.go (shape)
cc := r.finalizeLocked(s, ce.DeviceSerial, ce.EndedAt, ce.Reason)
r.mu.Unlock()
if cc != nil {
    r.normalizeIfEnabled(cc.AudioPath) // Part 8 — level before anyone reads it
    r.bus.Publish(events.Event{Kind: events.KindCallComplete, Payload: *cc})
}

That cc != nil guard is the same at both publish sites (handleEnd and handleSegment), and it’s the reason the rest of the output half can treat a CallComplete as a promise: the file exists, it’s finished, and — after the loudness step from Part 8 — it’s ready to read.

Where this goes next

Part 10 follows the other branch of the split we drew here: the call log, which acts on CallStart and CallEnd — not CallComplete — to write a searchable SQLite row carrying exactly the demod figures (SignalDbFS, EVMPct, SNRDb) this post kept off the completion event. It’s the persistence half of the same two-event design.

FAQ

What’s the difference between CallEnd and CallComplete again? CallEnd fires the instant the engine tears the call down and carries the demod figures (SignalDbFS, EVMPct, SNRDb) — no file yet. CallComplete fires later, once the recorder has closed the WAV, and carries the on-disk AudioPath and SampleRate. Consumers needing the file wait for complete; consumers needing only metadata act on end.

Which quality numbers are on CallComplete? None of the demod figures. CallComplete is file-centric: grant identity, talkgroup, timestamps, reason, AudioPath, and SampleRate. The demod quality rides CallEnd, and the audio-layer VoiceStats is written to the log rather than carried on any event.

Why isn’t VoiceStats attached to the completion event? Its consumer is a human triaging audio quality, not a subsystem making a routing decision. logVoiceStats emits it at finalize — DEBUG normally, WARN when the output clips past 0.5% — so operators can diagnose “robotic” or over-loud calls without diffing the WAV, and no event has to carry diagnostic detail nothing reads.

Does every finished call publish a CallComplete? No. finalizeLocked returns nil — and nothing is published — for a dead-key or idle-only call (it deletes the empty outputs) and for a call that decoded no PCM. So a CallComplete is a guarantee that a real, non-empty file exists at AudioPath, and downstream never receives a phantom path.

Series navigation

Part 9 of 14 · ← Part 8: Loudness at the Output Stage · Next → Part 10: The Call Log in SQLite