Part 10 of The Operator’s Cookbook, a 14-part series of complete, copy-paste GopherTrunk builds — one working rig per part, antenna to browser. Part 9 sent your calls out into the world; this part is about keeping them. The archival rig treats the recordings folder as a record, not a cache: lossless FLAC everywhere a container exists, a queryable SQLite call log, IQ taps that archive the radio signal itself, vocoder-frame sidecars for cross-decoder verification — and a retention policy that keeps all of it from eating the disk. An archive you can’t budget is an outage on a timer.
TL;DR:
recordings.format: flacswitches per-call voice recordings to lossless FLAC at roughly half the size of WAV for speech, and the whole downstream chain — web playback, loudness normalize (FLAC-in/FLAC-out), MP3 uploads, the retention sweeper — reads either container by content sniffing, never by extension.baseband.record[].format: flacdoes the same for IQ taps (~30–50% smaller, mounts back as a virtual tuner), andtap: ddcrecords the channelized stream at orders of magnitude less than wideband.storage.pathis the SQLite call log behind History; theretention:block sweeps rows (call_log_days), decoder logs (log_days) and files (files_days) on aninterval, loggingretention: deleted recordings count=Nas it goes.
Key takeaways
- FLAC is first-class now, not a transcode step. Every recorder that had
a container at all grew a
flacoption — voice recordings, IQ taps, auto-record, voice IQ debug — and every reader sniffs the content, so a mixed wav/flac archive Just Works. - Archive the samples, not just the audio. A voice file answers “what
was said”; a
tap: ddcIQ file answers “what was received” and replays when a decode looks wrong. This rig keeps both. - The call log is the index; files are the payload. SQLite rows survive
after
files_dayssweeps the audio, so History can show a 30-day record while only 14 days of it stays playable — a feature, configurable per axis. - Retention math is arithmetic, not vibes. 8 kHz 16-bit voice is 16 KB/s of talk time; 2.4 MS/s wideband IQ is 9.6 MB/s of wall time. Do the multiplication before the disk does it for you.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| FLAC voice recordings | lossless, ~half of WAV for speech | recordings.format: flac |
| FLAC IQ taps | ~30–50% smaller, replays identically | baseband.record[].format: flac (also auto_record.format, voice_iq_debug.format) |
| Small IQ archives | channelized stream, not the whole band | baseband.record[].tap: ddc |
| Call log database | SQLite behind History + /api/v1/calls |
storage.path, call-log deep dive |
| Retention sweeps | rows, decoder logs, files — independent clocks | retention.call_log_days, log_days, files_days, interval |
| Vocoder-frame sidecars | DSD-FME-playable .imb/.amb per call |
recordings.mbe_files, vocoders guide |
| Naming & layout | date trees, freq-in-filename | recordings.filename_template, path_template |
In this post
- What you’re building — a self-pruning archive of audio, metadata and IQ.
- The shopping list — one real purchase: the disk.
- The config — FLAC everywhere, retention clocks, sidecars.
- The disk budget — real byte rates for every stream you can archive.
- First run — what healthy looks like — sweep lines and FLAC files.
- When it doesn’t work — disks filling, sweeps not sweeping, players balking.
What you’re building
Any working rig from Parts 1–9, with its storage story made deliberate. Per
call you keep up to four artifacts: the audio (.flac), the JSON
sidecar, optional raw vocoder frames (write_raw), and the optional
DSD-FME-playable sidecar (mbe_files: .imb for P25 Phase 1, .amb
for DMR/NXDN/P25 Phase 2). Alongside them, one SQLite database indexes every
call — the store behind the History panel — and, if you choose, standing IQ
taps archive the receiver’s actual input for replay. The deep-dive series
covers each layer’s internals
(WAV on disk,
sidecars & naming,
retention);
this recipe assembles them into one build.
The FLAC support is new and worth a paragraph of trust-building: it isn’t a
post-processing transcode — the recorder writes FLAC natively through one
shared encode core, and everything downstream identifies the container by
sniffing the fLaC marker in the file. Loudness normalization rewrites
FLAC-in/FLAC-out, the broadcast MP3 transcode from Part 9 reads it, the
/calls/{id}/audio endpoint serves it as audio/flac, and the retention
sweeper counts it. Duration and dead-key accounting use uncompressed PCM
byte counts, so call bookkeeping is container-independent. Two deliberate
exceptions stay raw: diversity_capture stays cs16 (its branch-alignment
invariant depends on it) and the hunt’s survey capture stays f32.
The shopping list
| Item | Price (rough) | Notes |
|---|---|---|
| Storage | ~$50+ | an SSD or spinning disk sized by the budget table below; on an SBC build, external — never the boot SD card |
| Everything else | $0 | any rig from Parts 1–9 |
The config
storage:
path: "../data/calls.db"
recordings:
dir: "../recordings"
format: flac # lossless, ~half the size of wav for speech
write_raw: true # .raw vocoder-frame sidecar per call
mbe_files: true # DSD-FME-playable .imb/.amb sidecars
filename_template: "{date}_{time}_{tg}_{freq}"
path_template: "{system}/{year}/{month}/{day}" # date-tree layout
retention:
files_days: 14 # audio + sidecars swept after 14 days
call_log_days: 30 # SQLite call rows kept twice as long
log_days: 30 # pager/aprs/vessel/… decoder-log tables
interval: "1h" # sweeper cadence; 0 on any *_days disables it
baseband:
record:
- serial: "00000001" # the control dongle from Part 1
dir: "../iq/"
tap: ddc # the channelized CC stream, not the whole band
format: flac
Choices worth explaining. format: flac costs a little CPU per call
and buys ~2× more days per gigabyte, with no fidelity trade — it decodes
bit-identical. path_template is taste, but a date tree keeps
directory sizes sane at archive scale. tap: ddc is the archival sweet
spot for IQ: it records the down-converted channel the decoder actually
consumed (48 kHz for the C4FM family, 144 kHz for TETRA), directly
replayable with gophertrunk replay — where a wideband tap at the full SDR
rate is for short diagnostic grabs, not standing archival. For event-driven
IQ instead of a standing tap, baseband.auto_record fires captures on
encrypted/emergency/concurrent-call triggers and takes format: flac too.
mbe_files is the cross-verification lever: each call gets a .imb
(P25 Phase 1) or .amb (DMR/NXDN/P25 Phase 2) file in DSD-FME’s native
container, so dsd-fme -f1 -w out.wav -r call.imb re-decodes your call with
an entirely independent vocoder — the workflow the
vocoders guide documents, and the
one that let us measure GopherTrunk’s AMBE+2 high-band deficit against
mbelib frame-for-frame. TETRA and ProVoice have no DSD-FME playback mode and
produce none.
The disk budget
The rates are exact; the durations are what they buy on a 1 TB disk:
| Stream | Rate | 1 TB holds |
|---|---|---|
| Voice WAV (8 kHz, 16-bit mono) | 16 KB/s of talk time | ~17,000 h of talk |
| Voice FLAC | ~8 KB/s of talk time | ~35,000 h of talk |
| DDC IQ tap, C4FM family (48 kHz cs16-in-container) | 192 KB/s wall time | ~60 days continuous (WAV); ~3–4 months FLAC |
| DDC IQ tap, TETRA (144 kHz) | 576 KB/s wall time | ~20 days (WAV); ~1 month FLAC |
| Wideband IQ tap (2.4 MS/s) | 9.6 MB/s wall time | ~29 h (WAV); ~40–60 h FLAC |
Two readings. First, voice archives are effectively free: a busy system
doing two hours of actual talk per day writes ~58 MB/day as FLAC —
files_days: 0 (never sweep audio) is legitimate on any modern disk, and
the config above sweeps files mainly to bound the IQ tap. Second,
wideband IQ is a different regime entirely — three orders of magnitude —
which is why the standing tap is ddc and wideband belongs to the
event-driven auto_record with its seconds: cap. The IQ FLAC savings are
real but signal-dependent; budget on the WAV number and treat compression as
margin — the capture-discipline
habit.
First run — what healthy looks like
Restart and let a few calls land. The recorder lines look like Part 1’s, the extension telling you the container took:
INF recorder: call started device=... wav=../recordings/Metro-P25/2026/09/12/20260912_141212_9001_857262500.flac tg=9001 vocoder=imbe
INF recorder: call ended device=... duration=6.42s reason=released
Play the call in the web History panel (served as audio/flac) and confirm
the .json, .raw and .imb/.amb siblings landed next to it. Then, each
interval, the sweeper reports — only when it deletes something:
INF retention: deleted recordings count=214
INF retention: deleted call rows count=3120
INF retention: deleted log rows table=pager_log count=87
Silence from the sweeper on a young archive is normal — nothing has aged
past a threshold yet; POST /api/v1/retention/sweep forces a pass. Finally
check the IQ tap: a growing .flac in ../iq/ that gophertrunk replay
accepts — the replay driver sniffs the fLaC marker, never the extension,
so even a misnamed file mounts correctly.
When it doesn’t work
| Symptom | Likely cause | Fix |
|---|---|---|
Disk filling despite retention: configured |
the standing tap is tap: wideband, or files_days: 0 |
Wideband is ~34 GB/h — switch the standing tap to ddc and leave wideband to auto_record with a seconds: cap |
No retention: lines ever |
all thresholds 0 (each 0 disables that sweep), or nothing is old enough yet |
Set non-zero days; force a pass via POST /api/v1/retention/sweep and watch for the deleted lines |
retention: file sweep failed / retention: rm failed |
permissions or a vanished mount under recordings.dir |
The sweeper logs the path — fix ownership/mount; on the Part 11 systemd build check ReadWritePaths |
| A player won’t open the recordings | an old external tool that predates FLAC | Everything inside GopherTrunk sniffs content and reads either; for stubborn external tooling set format: back to wav — the archive can mix containers freely |
| History shows calls with no playable audio | by design: call_log_days > files_days |
Rows outlive files on purpose; align the two values to expire together |
No .imb/.amb for some calls |
TETRA / ProVoice / analog have no DSD-FME playback mode | Expected — those protocols produce no sidecar; .raw frames still archive if write_raw is on |
| Recording rate isn’t what you set | digital voice forces vocoder-native output | recorder: forcing WAV rate to vocoder-native 8000 — recordings.sample_rate applies to analog/NBFM only |
How this recipe shapes operator practice
- Archive the evidence, not just the product. Every hard bug this
project has chased ended with “get the raw capture” — the
ddctap means the capture already exists when the bad decode happens. - Let the index outlive the payload. Cheap rows, expensive files, separate clocks.
- Verify with a second decoder. A claim about audio quality backed by a
paired
.ambis measurement, not opinion.
Variations
- Maximum-compatibility archive.
format: waveverywhere and accept 2× the bytes — every audio tool since the 1990s opens it. Mixing is fine: readers sniff per file, so switching formats mid-archive breaks nothing. - Frames-only cold storage.
write_raw+mbe_fileswith aggressivefiles_days: vocoder frames cost a few hundred bytes per second, andgophertrunk decodere-synthesizes audio from them offline. - Forensic maximalist. Add
auto_record(event-triggered wideband slices) andvoice_iq_debug(per-call channelized voice IQ,format: flac) — every questionable call arrives with its own replayable IQ attached. - Network storage. The
dirkeys take absolute paths, so a NAS mount works — but keepstorage.path(SQLite) on local disk; databases and network filesystems are old enemies.
Where this goes next
An archive this good deserves better than a laptop rebooted for OS updates.
Part 11
turns the rig into a closet appliance: a Pi or mini-PC under systemd,
Docker USB pass-through, watchdogs for dongles that fall off the bus, and
the graceful-shutdown work that made systemctl restart take milliseconds
instead of thirty silent seconds.
FAQ
Should I record scanner calls as FLAC or WAV? FLAC, unless a specific external tool you depend on can’t read it. It’s lossless — bit-identical audio at roughly half the size for speech — and GopherTrunk’s entire downstream chain reads both containers transparently.
How much disk space do SDR call recordings use? At the vocoder-native 8 kHz mono, WAV costs 16 KB per second of talk time and FLAC about half — ~29 MB per hour of actual talk. Busy systems accumulate slowly; it’s IQ recording (192 KB/s for a DDC channel tap, 9.6 MB/s for a 2.4 MS/s wideband tap) that needs a real budget.
Can I replay a FLAC IQ recording like a cs16 or WAV capture?
Yes — baseband.replay and gophertrunk replay mount a .flac IQ file as
a virtual tuner exactly like a WAV, detecting the container from the fLaC
marker rather than the extension. A DDC-tap recording replays through the
same channel-rate decode path the live daemon used.
Why are old calls still in History after retention deleted the files?
Because the sweeps are independent: files_days ages out audio on disk
while call_log_days ages out SQLite rows, and the config above
deliberately keeps rows longer. Set both to one value to make the
searchable record and the playable record expire together.
Do the .imb/.amb files replace the audio recording?
No — they’re sidecars carrying the raw vocoder frames in DSD-FME’s native
container, written alongside the normal recording when
recordings.mbe_files: true. Their job is independent verification and very
compact archival, not playback convenience. Note DSD-FME’s own -w writer
stamps an 8 kHz header on 12 kHz synthesis (plays ~1.5× slow) — an upstream
quirk documented in the vocoders guide,
not a sidecar defect.
Series navigation
Part 10 of 14 · ← Part 9: Sharing the Feed — Broadcastify, OpenMHz & Friends · Next → Part 11: The Closet Appliance — Pi, systemd & Docker