The Operator's Cookbook, Part 9: Sharing the Feed — Broadcastify, OpenMHz & Friends

Part 9 of The Operator’s Cookbook, a 14-part series of complete, copy-paste GopherTrunk builds — one working rig per part, antenna to browser. Part 8 put the radios far away and piped their IQ home over the network. This part points the pipe the other direction: your rig has been quietly recording clean calls for eight parts, and now other people get to hear them. One broadcast: block turns the box into an upload station for Broadcastify Calls, RdioScanner, OpenMHz and an Icecast live mount — all at once, from the same recordings — plus JSON webhooks for anything you want to build yourself.

TL;DR: Everything outbound lives under one broadcast: key in config.yaml, verified against config.example.yaml: broadcastify:, rdioscanner:, openmhz:, icecast:, webhook: and grant_webhook: are each a list of feeds, and every completed call fans out to every feed that accepts its system. GopherTrunk transcodes each call to MP3 in pure Go (8 kHz mono lands in the MPEG-2.5 family at 32 kbps) and retries uploads with exponential backoff. The healthy log line is broadcast: call streamed backend=… system=… tg=… attempt=1; the two you troubleshoot are broadcast: upload failed and broadcast: upload queue full, dropping call. Loudness for the distributed copy is recordings.normalize.apply_to: distributed — the on-disk file stays untouched.

Key takeaways

  • One rig, many feeds, zero extra hardware. The broadcast manager is a fan-out: each backend is a list entry, each entry filters by systems:, and a call uploads to every feed that wants it. Running Broadcastify and OpenMHz and your own webhook is three blocks, not three rigs.
  • Per-call and live are different animals. Broadcastify Calls, RdioScanner and OpenMHz take one MP3 per completed call; Icecast is a continuous source connection GopherTrunk keeps fed with calls back-to-back, padded with silence between them.
  • Uploads fail loudly and drop safely. Every send gets retries with backoff and a 60-second per-attempt timeout; a wedged backend fills a queue that drops calls rather than stall the recorder. Your recordings are never hostage to someone else’s server.
  • API keys are credentials — treat them like it. Every backend key sits in config.yaml, which is exactly why that file must never land in a public repo or a support paste. The hygiene section below is short and non-negotiable.

Cheat sheet

Concern What it does Where it lives
Global gates drop short calls, size the upload pool broadcast.min_duration_ms, broadcast.workers
Broadcastify Calls metadata POST → one-time URL → MP3 PUT broadcast.broadcastify[]: api_key, system_id
RdioScanner multipart POST to your own instance broadcast.rdioscanner[]: url, api_key, system_id
OpenMHz multipart POST to api.openmhz.com broadcast.openmhz[]: api_key, short_name
Icecast / ShoutCast live source mount, calls + silence broadcast.icecast[]: host, port, mount, password
Webhooks per-call JSON, per-grant JSON broadcast.webhook[], broadcast.grant_webhook[]
Loudness of the MP3 normalize the distributed copy only recordings.normalize.apply_to: distributed, loudness deep dive

In this post

  • What you’re building — one rig feeding aggregators, a live stream, and your own endpoints.
  • The shopping list — accounts and keys, not hardware.
  • The config — every backend block, every key verified.
  • First run — what healthy looks like — the upload log lines that matter.
  • When it doesn’t work — symptom → cause → fix for silent and missing uploads.
  • Variations — webhooks-only rigs, encrypted-call policy, multi-system routing.

What you’re building

The finished rig is any working build from Parts 1–8 with its outputs made public: every completed call is transcoded to MP3 and pushed to the call aggregators you’ve signed up for, a live Icecast mount plays your system like a classic scanner feed, and a JSON webhook mirrors call metadata into whatever you run — a Discord relay, a Grafana pipeline, a database. The architecture is covered in depth on the deep-dive side (the broadcast manager and the aggregator backends); this recipe is the operator’s view of the same machinery.

Two design facts shape everything below. First, the manager subscribes to completed calls — the recorder finishes a file, then upload workers (broadcast.workers, default 2) fan it out, so a slow aggregator can never back up into the decode path. Second, each feed carries an optional systems: list matching your trunking.systems[].name values, so a multi-system rig from Part 7 can send the county P25 to Broadcastify and keep the business DMR private.

recorder completed call broadcast manager min_duration_ms · systems filter tg stream: false honored MP3 encode (32 kbps) ± loudness normalize (distributed) workers · retry w/ backoff Broadcastify Calls · RdioScanner · OpenMHz Icecast live mount calls back-to-back, silence between webhook: per-call JSON (± base64 MP3) grant_webhook: per-grant JSON, no audio fed by the CC decoder, not the recorder CC decoder every grant one recording, encoded once, delivered everywhere — and dropped, never blocking, when a backend wedges
The fan-out: per-call MP3s to the aggregators and webhooks, a paced live stream to Icecast, and grants pushed straight off the control channel.

The shopping list

No hardware this time — the shopping is accounts and keys:

Item Cost What you need from it
Broadcastify Calls account free to provide a feed a Calls API key and your node’s system ID
OpenMHz system free an API key and your system’s short_name
RdioScanner instance free, self-hosted its URL and an API key you mint in its admin
Icecast server free, self-hosted (or a host you have a source login on) host, port, mount, source password
Your own endpoint whatever you build a URL that accepts JSON POSTs

Pick any subset — data-only rigs skip straight to the webhook variation. And a word before signing up anywhere: streaming public-safety audio is subject to local law and the aggregators’ own rules; that homework is yours.

The config

Append to a working rig from any earlier part. Every key below exists in config.example.yaml:

broadcast:
  min_duration_ms: 1500     # skip squelch crackle & failed decodes
  workers: 0                # 0 = default upload pool (2)

  broadcastify:
    - enabled: true
      name: "bcfy-metro"
      api_key: "YOUR_BROADCASTIFY_CALLS_API_KEY"
      system_id: 12345
      systems: ["Metro-P25"]        # omit to stream every system

  rdioscanner:
    - enabled: true
      name: "local-rdio"
      url: "https://scanner.example.org"
      api_key: "YOUR_RDIOSCANNER_API_KEY"
      system_id: 1

  openmhz:
    - enabled: true
      name: "openmhz-metro"
      api_key: "YOUR_OPENMHZ_API_KEY"
      short_name: "metro911"

  icecast:
    - enabled: true
      name: "live-feed"
      host: "stream.example.org"
      port: 8000
      mount: "/gophertrunk"
      username: "source"
      password: "YOUR_ICECAST_SOURCE_PASSWORD"
      stream_name: "GopherTrunk Live"

  webhook:
    - enabled: true
      name: "analytics"
      url: "https://example.org/gophertrunk/calls"
      auth_header: "Bearer YOUR_TOKEN"
      include_audio: false          # true embeds the base64 MP3

  grant_webhook:
    - enabled: true
      name: "grant-log"
      url: "https://example.org/gophertrunk/grants"
      auth_header: "Bearer YOUR_TOKEN"

recordings:
  normalize:
    enabled: true
    apply_to: distributed   # normalize the MP3 only; disk file untouched
    target_lufs: -16.0
    true_peak_dbtp: -1.5
    max_boost_db: 12.0

Decisions worth explaining. min_duration_ms is your feed-quality knob: sub-second files are almost always dead keys, and aggregators judge feeds by signal-to-noise in the human sense too. apply_to: distributed runs the EBU R128 loudness normalize on the in-memory MP3 while leaving your archive faithful — the right split for a rig that’s also the Part 10 archival build; the loudness deep dive explains the −16 LUFS target. grant_webhook is not a duplicate of webhook: it fires per control-channel grant the moment it decodes, carries no audio, and exists so consumers can retire a separate grant-log dependency (issue #915). The per-call webhook payload carries system, talkgroup, source RID, call type, frequency, P25 site identity, encryption and emergency flags, and start/stop timestamps.

Two quieter filters also apply: a talkgroup with stream: false in your talkgroup CSV is excluded from all feeds (Part 13 covers the roster columns), and recordings.skip_encrypted: true suppresses the completed-call backends for encrypted calls — keep it false if your webhook consumer wants encrypted-call metadata (issue #897).

Key hygiene

Every string above that says YOUR_… is a credential. Three rules: keep config.yaml out of version control and out of support pastes (the daemon’s diagnostics never print it, but you might); on a shared box, make it readable only by the daemon’s user — the systemd recipe in Part 11 installs it mode 0640; and when a key does leak, rotate it at the provider first and in the file second. The API bearer token has a token_file indirection for exactly this reason (auth posture); broadcast keys live inline, so the file itself is the secret.

First run — what healthy looks like

Restart the daemon and key up the system (or wait). Per completed call, per feed, the line you want is:

INF broadcast: call streamed backend=bcfy-metro system=Metro-P25 tg=9001 attempt=1

attempt=1 means it worked first try. The Icecast source announces itself once at startup and again after any reconnect:

INF broadcast: icecast source connected backend=live-feed mount=/gophertrunk

Then verify at the far end: your call appears on the Broadcastify Calls node / OpenMHz system page within seconds, RdioScanner shows it in its own UI, and the Icecast mount plays your calls with silence between them — that’s the pacing design, not a fault. A transient failure looks like this and is self-healing:

WRN broadcast: upload failed backend=openmhz-metro system=Metro-P25 tg=9001 attempt=1 of 4 err=...
INF broadcast: call streamed backend=openmhz-metro system=Metro-P25 tg=9001 attempt=2

Only ERR broadcast: giving up on call means a call was lost to that feed — and even then only to that feed; the recording on disk and every other backend are unaffected.

When it doesn’t work

Symptom Likely cause Fix
broadcast: upload failed … HTTP 401/403 on every call wrong or revoked API key Re-paste the key from the provider’s dashboard; check you didn’t swap two feeds’ keys
broadcastify: metadata response rejected wrong system_id, or the talkgroup isn’t in Broadcastify’s DB for that node Broadcastify validates metadata before issuing the upload URL — fix the node config on their side, then the ID here
Uploads succeed but play as silence historically, MP3 encoder bugs — three of them Fixed and regression-pinned; the forensic story is the silent-MP3 postmortem. If you see it on a current build, file an issue with the paired WAV
Some talkgroups never appear on any feed stream: false in the talkgroup CSV, or the call is shorter than min_duration_ms Check the roster column and the duration gate before suspecting the backend
One system uploads, another doesn’t systems: filter mismatch The list must match trunking.systems[].name exactly — it’s a name, not a pattern
broadcast: upload queue full, dropping call a backend is wedged or the network is down, and the queue chose dropping over stalling Find which backend logs upload failed, fix or disable it; raise workers only if all backends are merely slow
Encrypted calls missing from your webhook recordings.skip_encrypted: true ends the call before the recorder lifecycle completes Set it false to deliver them (issue #897)
broadcast: icecast source disconnected repeating wrong mount/password, or the server dropped the source The backend reconnects on a fixed backoff forever; fix the credentials server-side and watch for source connected

One habit transfers from the decode side of this series: read the log line before theorizing. Every failure above names its backend and its attempt count — the difference between “OpenMHz rejected the key” and “my ISP dropped for a minute” is right there in attempt=N of M.

Variations

  • Data-only rig. Enable only webhook and grant_webhook: full call and grant metadata to your own systems, zero audio leaves the box. The grant-webhook deep dive shows consumer patterns.
  • Split routing. Multiple entries per backend are legal — two Broadcastify feeds with different system_ids and disjoint systems: lists route a two-county rig to two nodes from one process.
  • Louder everywhere. apply_to: both normalizes the archive and the MP3; recording normalizes only disk. Distributed-only is the recommended start because it’s reversible.
  • Live-first rig. Icecast alone, min_duration_ms: 0, and you’ve built a classic streaming scanner; add audio.enabled: true if the box should also play to local speakers.

Where this goes next

Everything you just shared also lands on your own disk, and disks fill. Part 10 builds the archival rig: first-class FLAC recordings at half the size, IQ taps that archive the radio signal too, the SQLite call log, and the retention math that says how many days actually fit.

FAQ

How do I stream GopherTrunk calls to Broadcastify? Sign up to provide a Broadcastify Calls feed, take the Calls API key and system ID they assign, and paste both into a broadcast.broadcastify entry as above. GopherTrunk does the two-step Calls protocol itself — a metadata POST that returns a one-time upload URL, then an MP3 PUT — so there’s no external uploader script to run.

What bitrate and format are the uploads? Per-call MP3, encoded in pure Go from the finished recording. At the vocoder-native 8 kHz mono that recordings use, the encoder selects the MPEG-2.5 family at 32 kbps CBR — small enough that a full day of busy-system calls is tens of megabytes of upload.

Can I upload the same system to Broadcastify and OpenMHz at once? Yes — that’s the default. Every enabled feed whose systems: list accepts the call gets its own upload from one shared MP3 encode, and backends fail independently: an OpenMHz outage never costs you a Broadcastify call.

Why do my uploads sound quieter than other feeds? Faithful decode is conservative, and aggregator players do no gain riding. Enable recordings.normalize with apply_to: distributed for −16 LUFS loudness-normalized MP3s while keeping the on-disk archive untouched — or recordings.enhance from Part 1’s table if you want the whole chain louder.

Does the Icecast feed go silent between calls? It plays encoded silence, which is correct source behavior — the mount stays up, listeners stay connected, and the next call starts immediately. If the mount itself drops, look for icecast source disconnected lines; the backend redials on a fixed backoff until the server takes the source again.

Series navigation

Part 9 of 14 · ← Part 8: Radios Far Away — SoapyRemote, rtl_tcp & ka9q · Next → Part 10: The Archival Rig — FLAC, Retention & the Call Log