compose: opt-in trunk-recorder service + supervisor and uploadScript (node-26#45)

robotastic/trunk-recorder:5.2.1 pinned by digest, behind the
trunk-recorder compose profile, host network, /dev/bus/usb. run.sh
restarts TR when the edge-node rewrites config.json and reports state
for health; drb-upload.sh hands each finished call (wav, then json) to
recordings/tr-inbox. README carries the GPL-3.0 source note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-28 00:21:40 -04:00
co-authored by Claude Opus 5.5
parent fa3eac3b28
commit c9f162a508
5 changed files with 195 additions and 0 deletions
+17
View File
@@ -108,6 +108,23 @@ UPLOAD_SPOOL_MAX_AGE_HOURS=72
UPLOAD_RETRY_MIN_SECONDS=15
UPLOAD_RETRY_MAX_SECONDS=600
# --- Recorder backend (node-26#45) -------------------------------------------
# op25 (default): OP25 + PulseAudio recorder, one call at a time.
# trunk-recorder: Trunk Recorder records every talkgroup at once, with call
# boundaries from the decoder. Set BOTH lines below to switch; OP25 is then
# never started (it would hold the SDR), and the live browser stream (Icecast)
# goes quiet — Discord live audio keeps working.
RECORDER_BACKEND=op25
# COMPOSE_PROFILES=trunk-recorder
# Trunk Recorder SDR settings (only read with RECORDER_BACKEND=trunk-recorder).
# TR_SAMPLE_RATE=2400000
# TR_GAIN=34
# TR_CENTER_HZ= # override the computed centre frequency
# TR_DIGITAL_RECORDERS=4 # simultaneous voice channels on a trunked system
# TR_MIN_DURATION=1.0 # seconds; shorter calls are dropped by TR
# TR_SQUELCH_DB=-50 # conventional channels; absolute dB, tune per site
# TR_LIVE_PORT=9123 # local UDP port for simplestream -> Discord
# OP25 container (usually no need to change)
OP25_API_URL=http://localhost:8001
OP25_TERMINAL_URL=http://localhost:8081
+29
View File
@@ -51,6 +51,35 @@ services:
volumes:
- /dev:/dev
# Trunk Recorder (node-26#45) — the alternative recorder. Opt-in per node:
# it only starts when the node's .env has COMPOSE_PROFILES=trunk-recorder,
# and the edge-node only uses it with RECORDER_BACKEND=trunk-recorder. Set
# both or neither. Upstream image, pinned by tag AND digest (the digest is
# the multi-arch index for 5.2.1 on Docker Hub, amd64/arm64/arm/v7); bump
# both together. GPL-3.0: we ship it unmodified as a separate program —
# see trunk-recorder/README.md for the source offer.
#
# The edge-node writes ./configs/trunk-recorder/config.json; run.sh (our
# supervisor, replacing the image's CMD) restarts TR when it changes. It
# shares OP25's dongle slot, so op25 is never started on such a node.
trunk-recorder:
image: robotastic/trunk-recorder:5.2.1@sha256:c3e425858bfca16c609d8612808a83f06b0f376d950d11733d7c25b2a28f8866
profiles: ["trunk-recorder"]
restart: unless-stopped
network_mode: host
devices:
- /dev/bus/usb:/dev/bus/usb
# TR stages each transmission in tempDir (/dev/shm); Docker's 64 MB
# default is tight with several recorders running at once.
shm_size: 256m
entrypoint: ["/bin/sh", "/drb/run.sh"]
environment:
TZ: ${TZ:-UTC}
volumes:
- ./configs/trunk-recorder:/app
- ./recordings:/recordings
- ./trunk-recorder:/drb:ro
edge-node:
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:${DRB_TAG_EDGE_NODE:-latest}
build: ./drb-edge-node
+34
View File
@@ -0,0 +1,34 @@
# Trunk Recorder on a DRB node (node-26#45)
Opt-in alternative to the OP25 recorder. On a node, set both in `.env`, then
`docker compose up -d`:
RECORDER_BACKEND=trunk-recorder
COMPOSE_PROFILES=trunk-recorder
Set both back (`RECORDER_BACKEND=op25`, remove `COMPOSE_PROFILES`) to return
to OP25.
- `run.sh`: supervisor that replaces the image's CMD. It restarts TR when the
edge-node rewrites `configs/trunk-recorder/config.json`, and writes
`drb-status.json` for the dashboard health check.
- `drb-upload.sh`: TR's `uploadScript`. It copies each finished call (WAV +
JSON) into `recordings/tr-inbox`, where the edge-node uploads it through the
normal spool.
## License (GPL-3.0)
Trunk Recorder is GPL-3.0. DRB runs the unmodified upstream image
`robotastic/trunk-recorder:5.2.1` (digest pinned in `docker-compose.yml`) as a
separate program. It talks to DRB only through files, a script argument list
and a local UDP socket. That makes it mere aggregation, the same position we
already hold for OP25.
Anyone we distribute a node image or device to must still be able to get the
corresponding source. Upstream source for this exact version is at
<https://github.com/TrunkRecorder/trunk-recorder/tree/v5.2.1>. If we ever ship
the TR image ourselves (mirrored to our registry, preinstalled on a sold
device), we must also offer that source directly, in writing, for three years,
or ship it alongside. Pointing to upstream alone is not enough at that point.
Any patch to TR itself makes DRB's copy a modified version, which must then be
published under GPL-3.0.
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Trunk Recorder uploadScript (node-26#45). TR runs this after every call as
# drb-upload.sh <call.wav> <call.json> <call.m4a>
# (run_upload_script_argv, call_concluder.cc v5.2.1; the m4a only exists with
# compressWav, which DRB leaves off). TR deletes the wav/json as soon as this
# exits (audioArchive/callLog false), so it copies them into the edge-node's
# inbox first. Order matters: the WAV lands first and the JSON last, so the
# JSON's presence tells the edge-node the WAV is complete. Exit non-zero = TR
# marks the call failed.
set -u
WAV="${1:?wav path}"
JSON="${2:?json path}"
INBOX="${DRB_TR_INBOX:-/recordings/tr-inbox}"
TMP="$INBOX/.incoming"
[ -f "$WAV" ] && [ -f "$JSON" ] || { echo "drb-upload: missing $WAV or $JSON" >&2; exit 1; }
mkdir -p "$TMP" || exit 1
stem="$(basename "$JSON" .json)"
# Hard link when on the same filesystem (no copy, TR's unlink leaves ours);
# copy otherwise.
put() { ln -f "$1" "$2" 2>/dev/null || cp -f "$1" "$2"; }
put "$WAV" "$TMP/$stem.wav" || exit 1
put "$JSON" "$TMP/$stem.json" || exit 1
mv -f "$TMP/$stem.wav" "$INBOX/$stem.wav" || exit 1
mv -f "$TMP/$stem.json" "$INBOX/$stem.json" || exit 1
exit 0
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
# Supervisor for Trunk Recorder inside its container (node-26#45).
#
# The edge-node writes /app/config.json from the C2 system config but has no
# Docker access, so it cannot restart this container. This loop does it:
# - waits for config.json, then runs trunk-recorder on it
# - restarts it when config.json changes or /app/restart-request appears
# - keeps it stopped while /app/stop-request exists (dashboard Stop)
# - restarts it after a crash, backing off 5 s -> 60 s (`restarts` counts
# crashes only, not config restarts)
# - writes /app/drb-status.json every few seconds for the edge-node's health
# check (state, pid, started_at, restarts, last_exit, epoch)
set -u
APP="${DRB_TR_APP:-/app}"
CFG="$APP/config.json"
STATUS="$APP/drb-status.json"
RESTART="$APP/restart-request"
STOP="$APP/stop-request"
REC="${DRB_RECORDINGS:-/recordings}"
mkdir -p "$APP/logs" "$REC/tr-capture" "$REC/tr-inbox"
pid=""
started=0
restarts=0
last_exit=null
backoff=5
cfgsum=""
write_status() {
printf '{"state":"%s","pid":%s,"started_at":%s,"restarts":%s,"last_exit":%s,"epoch":%s}\n' \
"$1" "${pid:-null}" "$started" "$restarts" "$last_exit" "$(date +%s)" > "$STATUS.tmp" \
&& mv -f "$STATUS.tmp" "$STATUS"
}
sum() { md5sum "$CFG" 2>/dev/null | cut -d' ' -f1; }
stop_tr() {
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid"
i=0
while kill -0 "$pid" 2>/dev/null && [ "$i" -lt 30 ]; do sleep 0.5; i=$((i + 1)); done
kill -KILL "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
fi
pid=""
}
trap 'stop_tr; write_status stopped; exit 0' TERM INT
nap() { sleep "$1" & wait $!; }
while :; do
if [ -f "$STOP" ]; then
[ -n "$pid" ] && { echo "drb: stop requested"; stop_tr; }
write_status stopped; nap 2; continue
fi
if [ ! -f "$CFG" ]; then
write_status waiting_for_config; nap 5; continue
fi
if [ -z "$pid" ]; then
rm -f "$RESTART"
cfgsum="$(sum)"
echo "drb: starting trunk-recorder"
trunk-recorder --config="$CFG" &
pid=$!
started="$(date +%s)"
fi
write_status running
nap 5
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" 2>/dev/null
last_exit=$?
pid=""
restarts=$((restarts + 1))
# A run that lasted a while resets the backoff.
[ $(( $(date +%s) - started )) -gt 120 ] && backoff=5
echo "drb: trunk-recorder exited ($last_exit); restarting in ${backoff}s"
write_status crashed
nap "$backoff"
backoff=$((backoff * 2)); [ "$backoff" -gt 60 ] && backoff=60
continue
fi
if [ -f "$RESTART" ] || [ "$(sum)" != "$cfgsum" ]; then
echo "drb: config changed or restart requested — restarting trunk-recorder"
stop_tr
fi
done