diff --git a/.env.example b/.env.example index 3261700..359d681 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d53de78..35ee3d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/trunk-recorder/README.md b/trunk-recorder/README.md new file mode 100644 index 0000000..2cb7766 --- /dev/null +++ b/trunk-recorder/README.md @@ -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 +. 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. diff --git a/trunk-recorder/drb-upload.sh b/trunk-recorder/drb-upload.sh new file mode 100644 index 0000000..b7ede89 --- /dev/null +++ b/trunk-recorder/drb-upload.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Trunk Recorder uploadScript (node-26#45). TR runs this after every call as +# drb-upload.sh +# (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 diff --git a/trunk-recorder/run.sh b/trunk-recorder/run.sh new file mode 100644 index 0000000..d04577e --- /dev/null +++ b/trunk-recorder/run.sh @@ -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