Recording, Composition & Streaming, Part 4: The Recording Session

Part 4 of Recording, Composition & Streaming, opening the black box the last two posts leaned on: the recordingSession. Part 3 showed segments parking a session dormant; this post is the whole state machine behind that. Our 3 p.m. dispatch on talkgroup 101 gets a session the instant its grant lands — but not a file. If the transmitter keys and unkeys without a word, we want nothing on disk. This is how the recorder threads that needle.

TL;DR: A recordingSession is prepared on CallStart but its files are not openedbuildSession resolves paths and builds the vocoder, and openSessionFiles runs only on the first actual write. A dead-key therefore leaves no WAV, no .raw, and no empty talkgroup folder. A post-segment dormant session carries the call’s identity with no open files, reopening lazily on the next over. And an empty OutDir flips the recorder into decode-only mode: every call still builds a vocoder and feeds live audio, it just never writes a file.

Key takeaways

  • Files open lazily. buildSession prepares paths and a vocoder; the WAV and .raw are created by openSessionFiles on the first sample or frame — so a call with no audio touches disk zero times.
  • The talkgroup directory is lazy too. It’s MkdirAll‘d just before the first file, so a followed-but-silent grant leaves no empty <system>/<tg> folder.
  • Dormant sessions carry identity. After a segment, the parked session holds only the CallStart and CallID; sessionForWrite rebuilds it under a fresh timestamp when the next write arrives.
  • Decode-only mode (empty OutDir) decodes and fans live audio with no files — how live browser audio works without a configured recordings directory.

Cheat sheet

Thing What it does Where in code
recordingSession Per-call state: files, vocoder, paths, CallID internal/voice/recorder.go
buildSession Prepare paths + vocoder; open no files internal/voice/recorder.go
openSessionFiles Lazily create the WAV / .raw on first write internal/voice/recorder.go
sessionForWrite Resolve/reopen the session for a write; CallID fence internal/voice/recorder.go
handleStart Gate the CallStart, then build + register a session internal/voice/recorder.go
decodeOnly Empty OutDir → decode + live audio, no files internal/voice/recorder.go

In this post

  • The session struct — what one call’s state actually holds.
  • Prepare, don’t openbuildSession versus openSessionFiles.
  • The write path — how sessionForWrite reopens a dormant session lazily.
  • The gates — what handleStart checks before a session ever exists.
  • Decode-only mode — the recorder as a pure live-audio decoder.

One call’s state

Everything the recorder knows about an in-flight call lives in one struct, keyed by device serial in the recorder’s sessions map:

// internal/voice/recorder.go (shape)
type recordingSession struct {
    wav         *WavWriter   // nil until the first write (or in decode-only)
    wavPath     string       // resolved at buildSession; "" for a dormant park
    raw         *os.File     // the .raw sidecar, likewise lazy
    rawPath     string
    vocoder     Vocoder      // per-protocol; nil for analog
    vocoderName string
    sampleRate  uint32       // WAV header rate (8 kHz for vocoded calls)
    lastSample  int16        // for the end-of-call fade
    rawWanted   bool         // should a .raw be opened on first frame?
    startedAt   time.Time
    callID      uint64       // Grant.CallID — the cross-call fence key
    cs          trunking.CallStart // retained so a segment roll can reopen
}

The two fields that make the whole state machine work are wav and cs. A wav of nil means “no file is open” — which is true in three distinct situations: a freshly prepared session that hasn’t been written yet, a dormant post-segment park, and a decode-only session that will never open a file. cs (the originating CallStart) is retained so a segment roll can open the next file with the same grant and talkgroup under a new timestamp. The struct is small on purpose: it is the identity of a call plus a few handles that may or may not be live.

Prepare, don’t open

handleStart builds a session; buildSession prepares it without touching disk. This is the design’s central move, and its doc comment is explicit that it “does NOT open the files”:

// internal/voice/recorder.go (shape)
func (r *Recorder) buildSession(cs trunking.CallStart, startedAt time.Time) *recordingSession {
    dir := r.directoryFor(cs)          // path only — NOT created here
    base := r.basenameFor(withStart(cs, startedAt))
    s := &recordingSession{startedAt: startedAt, cs: cs, callID: cs.Grant.CallID}

    // Instantiate the per-protocol vocoder if one is mapped, before the
    // WAV is opened (its header rate tracks the vocoder's native rate).
    if name, ok := r.vocoderForProtocol[cs.Grant.Protocol]; ok && name != "" {
        if v, err := DefaultRegistry.New(name); err == nil {
            // opt-in enhancement + startup squelch installed here
            s.vocoder, s.vocoderName = v, name
        }
    }
    s.sampleRate = r.sampleRate
    if s.vocoder != nil {
        s.sampleRate = pcmHzDefault // vocoder output is always 8 kHz
    }
    s.wavPath = filepath.Join(dir, base+".wav")
    if r.writeRaw || cs.Grant.ProVoice ||
        dmrVoiceProtocol(cs.Grant.Protocol) || tetraVoiceProtocol(cs.Grant.Protocol) {
        s.rawPath = filepath.Join(dir, base+".raw")
        s.rawWanted = true
    }
    // Files are NOT opened here — see openSessionFiles.
    return s
}

So after buildSession, a session has a vocoder, a chosen sample rate, and two resolved paths — but nothing on disk. The talkgroup directory itself isn’t even created yet. That is deliberate: a grant that is followed but never yields audio — a dead-key, an immediately-aborted encrypted call, a voice tap left off-frequency while a hunt borrows the SDR — leaves neither a header-only WAV nor an empty <system>/<talkgroup> folder behind. The WAV header rate is worth one note: a vocoded call is forced to 8 kHz regardless of recordings.sample_rate, because the vocoder’s output is always 8 kHz and appending it under a different header rate would play back garbled; recordings.sample_rate applies only to analog PCM fed via WritePCM.

The actual disk work is quarantined in openSessionFiles, called from the write path on the first sample or frame:

// internal/voice/recorder.go (shape)
func (r *Recorder) openSessionFiles(s *recordingSession) error {
    if s.wav != nil {
        return nil // idempotent: already open
    }
    if dir := filepath.Dir(s.wavPath); dir != "" {
        if err := os.MkdirAll(dir, 0o755); err != nil { // lazy dir creation
            return err
        }
    }
    wav, err := NewWavFile(s.wavPath, s.sampleRate)
    if err != nil {
        if s.vocoder != nil { s.vocoder.Close(); s.vocoder = nil }
        return err
    }
    s.wav = wav
    if s.rawWanted && s.raw == nil {
        if raw, err := os.Create(s.rawPath); err == nil {
            s.raw = raw
        }
    }
    return nil
}

TestRecorderNoAudioLeavesNoFiles and TestRecorderNoAudioLeavesNoDir in recorder_idle_test.go pin this behaviour: a CallStart with no following write produces no files and no directory.

prepared paths+vocoder CallStart first write openSessionFiles files-open WAV + .raw CallSegment dormant keeps identity next write → reopen ended · no file CallEnd with no audio written
The session state machine: prepared on CallStart, files opened lazily on the first write, parked dormant across a segment, and — when no audio ever arrives — ended without ever touching disk.

The write path reopens lazily

Every write — WritePCM and the WriteRawFrame family — first resolves the session through sessionForWrite, which is where the lazy reopen lives:

// 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 // no session (the composer can race ahead of CallStart)
    }
    if callID != 0 && s.callID != 0 && callID != s.callID {
        return nil // cross-call fence: a stale frame from a reused tap serial
    }
    if r.decodeOnly {
        return s // no files ever; the vocoder is enough to fan live audio
    }
    if s.wav == nil {
        if s.wavPath == "" {
            // A dormant park carries only cs+callID: build a fresh session
            // under a new timestamp before opening its files.
            ns := r.buildSession(s.cs, time.Now().UTC())
            if ns == nil { return nil }
            r.sessions[serial] = ns
            s = ns
        }
        if err := r.openSessionFiles(s); err != nil {
            delete(r.sessions, serial) // don't retry on every frame
            return nil
        }
    }
    return s
}

This one function reconciles all three wav == nil cases. A prepared session (has a wavPath, no files) just needs openSessionFiles. A dormant park (no wavPath at all) is first rebuilt via buildSession under a fresh time.Now() timestamp — that’s how the second over of a per-transmission call gets a new filename while keeping the same grant and CallID. A decode-only session is handed straight back with wav == nil, because it will never open a file. And the CallID fence sits right at the top: a frame whose call identity doesn’t match the open session is rejected without reopening a dormant session, so a mismatched frame can’t even spawn an empty file. TestRecorderCallIDFenceDropsStaleFrames covers that path. (The dormant recordingSession can also just be closed with no open files — TestDormantSessionCloseNoPanic and TestWavWriterNilClose in recordingsession_close_test.go guard against the nil-writer panic that would otherwise lurk there.)

The gates before a session exists

handleStart is the decision that decides whether a session is built at all. It runs a short sequence of gates, each of which can drop the CallStart before any session is registered:

// internal/voice/recorder.go (shape)
func (r *Recorder) handleStart(cs trunking.CallStart) {
    if r.recordDisabled.Load() {
        return // operator toggled recording off at runtime
    }
    if !r.decodeOnly && cs.Talkgroup != nil && !cs.Talkgroup.Record {
        return // talkgroup record=false: follow + play live, write nothing
    }
    if r.skipEncrypted && cs.Grant.Encrypted {
        return // opted out of recording encrypted calls
    }
    r.mu.Lock()
    defer r.mu.Unlock()
    // …replace any stale session on this serial…
    s := r.buildSession(cs, cs.StartedAt)
    if s == nil {
        return
    }
    r.sessions[cs.DeviceSerial] = s
}

The ordering has one subtlety worth calling out. The record=false gate is skipped when decodeOnly is set — a decode-only recorder writes nothing to disk regardless, so if it also dropped record=false talkgroups here, those talkgroups would be silent on the live feed too. The whole point of decode-only mode is live audio; dropping the call at the gate would defeat it. The recordDisabled gate, by contrast, applies to everyone and is the runtime “stop laying down WAVs” switch — and it deliberately leaves in-flight sessions alone, so flipping it mid-conversation doesn’t truncate the head of a call.

Decode-only mode

The decodeOnly flag is set once, in NewRecorder, purely from whether an output directory was configured:

// internal/voice/recorder.go (shape)
func NewRecorder(opts RecorderOptions) (*Recorder, error) {
    // An empty OutDir runs the recorder in decode-only mode: it still
    // decodes each call and feeds the live-audio tap, it just never
    // writes files. This is how live browser audio works without a
    // configured recordings dir.
    decodeOnly := opts.OutDir == ""
    if !decodeOnly {
        os.MkdirAll(opts.OutDir, 0o755)
    }
    // …vocoder map, normalize/enhance defaults, displayLoc…
}

In this mode the recorder is a pure decoder. buildSession still constructs the per-protocol vocoder, so writeRawFrame can decode each frame; sessionForWrite hands back the session with wav == nil; the decode result skips the WAV write but still fans to the decoded-PCM live tap (Part 2’s fan-out). The result is that a GopherTrunk instance with no recordings: directory configured still streams live digital audio to the browser — it decodes every call, it just persists none. This is the recorder’s contribution to the “optional means absent, not idle” principle from Part 1: with no OutDir, there is no persistence, but the live path is fully alive. TestRecorderSuppressesAllIdleRecording and the idle-suite neighbours in recorder_idle_test.go validate the “still decodes, writes nothing” contract.

CallStart recordDisabled? record=false? enc? yes → drop, no session pass buildSession vocoder + paths OutDir set → files OutDir empty → decode-only, live only
From CallStart to a session: three gates can drop the call before any state exists; passing them builds a session whose files open only when OutDir is set — an empty OutDir yields a decode-only, live-audio-only recorder.

Where this goes next

Part 5 follows the file that openSessionFiles finally creates: the crash-safe WAV. Once a session opens its WavWriter, how does GopherTrunk keep the on-disk file valid even if the daemon dies mid-call — the streaming header, the length-field patch on close, and why a half-written recording is still playable.

FAQ

Why open files lazily instead of on CallStart? Because a grant that yields no audio — a dead-key, an aborted encrypted call, a tap left off-frequency — should leave nothing behind. buildSession resolves paths and builds the vocoder but opens no files; openSessionFiles runs on the first write. No audio, no WAV, no .raw, and not even an empty talkgroup directory.

What is a dormant session? After a per-transmission segment roll, the recorder parks a session that holds only the originating CallStart and CallID with no open files. It carries the call’s identity forward so the next over reopens a fresh file under a new timestamp — and if the call ends first, it closes cleanly without leaving an empty trailing file.

What does decode-only mode do? When OutDir is empty, the recorder builds each call’s vocoder and decodes every frame but never opens a file. The decoded PCM still fans to the live-audio tap, so browser and host audio work with no recordings directory configured. It’s how you run GopherTrunk as a pure live scanner with nothing written to disk.

Does disabling recording at runtime truncate the call I’m hearing? No. The recordDisabled gate stops new sessions from opening files, but leaves in-flight sessions alone — they finish naturally on CallEnd. Flipping the switch mid-conversation won’t cut the head off the call already being recorded.

Series navigation

Part 4 of 14 · ← Part 3: Assembling a Call · Next → Part 5: The WAV on Disk