istrain-public: from-scratch build of a passive RF train detector

Public community release. The complete working system — DSP + decoders (scripts/),
web dashboard + API (dashboard/), container stack (docker/), config templates (config/) —
plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3
(PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
istrain
2026-07-24 23:19:16 -04:00
commit 559caead36
41 changed files with 6740 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""vumon — live audio activity meter for a raw S16LE mono stream (the radio listen pipe).
The VISUAL the audio-only listen lacks. Sits at the end of an rtl_fm | tee | aplay pipeline
and shows:
• a level BAR — flat when the band's quiet, jumps when a transmission breaks through
• a SPINNER while squelched / no data, so you can see it's alive (never looks frozen)
• a TIMESTAMPED line when activity starts and stops (+ duration, peak) — so you get a log
of WHEN something happened even if you stepped away from the console
RUNTIME helper. Reads a pipe; does NOT touch the dongle (so it's just a display, not radio
operation). built by the maintainer; you run it. a generic
"is this audio stream active right now" meter, useful to any listen pipeline.
Wire it into the pipe (3-way: file + speakers + meter; needs bash for the >() ):
rtl_fm -d 1002 ... - | tee /tmp/cap.s16 | tee >(aplay -r 12000 -f S16_LE -t raw -c 1) \
| python3 scripts/vumon.py --log /tmp/istrain_activity.log
watch-only (no audio): rtl_fm ... - | tee /tmp/cap.s16 | python3 scripts/vumon.py
Options:
--thresh DB activity threshold in dBFS (default -42; raise toward -25 if hiss trips it)
--log FILE append "started/ended" events here (timestamps persist)
--jsonl FILE append one JSON line per burst ({ts,dur,peak}) — structured log for a dashboard
--clips DIR on each burst, save its raw S16 audio (+~0.5s pre-roll) to DIR/eot_<ts>.s16 — for decode
"""
import sys, math, select, time, array, json, os, collections
THRESH = -42.0
LOGF = None
JSONL = None
CLIPS = None
LEVEL = None # --level FILE: write instantaneous level JSON here, for a live dashboard meter
LABEL = "" # --label NAME: band tag stamped into the level file (e.g. "eot" / "bot")
MARGIN = None # --margin DB: ADAPTIVE — fire when db exceeds the rolling noise floor by MARGIN (vs fixed --thresh)
FLOOR_A = 0.02 # noise-floor EMA rate (per chunk; only updated while quiet) — ~2s time constant @ 48k
MAXCLIP = 480000 # cap a saved clip at ~5s @ 48k s16 — EOT bursts are <1s; longer is noise, don't hoard it
PREFIX = "eot_" # --prefix NAME: clip filename prefix per band (eot_ / bot_) so each band's clips stay distinct
_a = sys.argv[1:]
while _a:
o = _a.pop(0)
if o == "--thresh": THRESH = float(_a.pop(0))
elif o == "--log": LOGF = _a.pop(0)
elif o == "--jsonl": JSONL = _a.pop(0)
elif o == "--clips": CLIPS = _a.pop(0)
elif o == "--prefix": PREFIX = _a.pop(0)
elif o == "--level": LEVEL = _a.pop(0)
elif o == "--label": LABEL = _a.pop(0)
elif o == "--margin": MARGIN = float(_a.pop(0))
else: sys.exit(f"vumon: unknown arg {o!r}")
if CLIPS:
os.makedirs(CLIPS, exist_ok=True)
CHUNK = 4096 # bytes (2048 samples @ 2B mono) — ~170ms @ 12k, ~43ms @ 48k
BARW = 24
SPIN = "|/-\\"
err = sys.stderr
def dbfs(buf):
a = array.array('h')
a.frombytes(buf[: len(buf) - (len(buf) % 2)])
if not a: return -99.0
s = sum(v * v for v in a)
rms = math.sqrt(s / len(a))
return 20 * math.log10(rms / 32768.0) if rms > 0 else -99.0
def bar(db):
frac = max(0.0, min(1.0, (db + 60) / 60)) # map -60..0 dBFS -> 0..1
n = int(frac * BARW)
return "" * n + "" * (BARW - n)
def event(msg):
err.write("\r" + " " * 64 + "\r" + msg + "\n"); err.flush()
if LOGF:
with open(LOGF, "a") as f:
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")
_last_level = 0.0
def write_level(db, active, peak, floor=None):
"""Throttled atomic write of the instantaneous level — the dashboard polls this for a live meter."""
global _last_level
now = time.time()
if now - _last_level < 0.2: # ~5 Hz is plenty for a live bar; keeps disk churn negligible
return
_last_level = now
try:
with open(LEVEL + ".tmp", "w") as f:
json.dump({"ts": round(now, 2), "db": round(db, 1), "active": bool(active),
"label": LABEL, "peak": (round(peak, 1) if active else None),
"floor": (round(floor, 1) if floor is not None else None)}, f)
os.replace(LEVEL + ".tmp", LEVEL)
except OSError:
pass
fd = sys.stdin.buffer
active = False; since = 0.0; peak = -99.0; spin = 0; last = time.time()
floor = None # rolling noise-floor estimate (adaptive mode)
clipbuf = None; preroll = collections.deque(maxlen=12) # ~0.5 s pre-roll for --clips
_mode = (f"adaptive floor+{MARGIN:.0f}dB" if MARGIN is not None else f"threshold {THRESH:.0f} dBFS")
err.write(f"vumon: watching ({_mode}"
+ (f", logging -> {LOGF}" if LOGF else "") + ") Ctrl-C to stop\n")
try:
while True:
r, _, _ = select.select([fd], [], [], 0.4)
if r:
buf = fd.read1(CHUNK)
if not buf: break
db = dbfs(buf); last = time.time()
if MARGIN is not None: # adaptive: fire on excursions above the rolling floor
if floor is None: floor = db
on = db > floor + MARGIN
if not on: floor += FLOOR_A * (db - floor) # track the floor ONLY while quiet
else:
on = db >= THRESH
if on and not active:
active = True; since = last; peak = db
event(f"{time.strftime('%H:%M:%S')} ACTIVE {db:6.1f} dBFS")
if CLIPS:
clipbuf = bytearray(b"".join(preroll)); clipbuf += buf
elif on:
peak = max(peak, db)
if CLIPS and clipbuf is not None and len(clipbuf) < MAXCLIP: clipbuf += buf
elif active:
active = False
event(f"{time.strftime('%H:%M:%S')} ended ({last - since:4.1f}s, "
f"peak {peak:6.1f} dBFS)")
if JSONL:
try:
with open(JSONL, "a") as jf:
jf.write(json.dumps({"ts": round(since, 2),
"dur": round(last - since, 2),
"peak": round(peak, 1)}) + "\n")
except OSError:
pass
if CLIPS and clipbuf is not None:
clipbuf += buf
try:
fn = os.path.join(CLIPS, PREFIX + time.strftime("%Y%m%d_%H%M%S.s16",
time.localtime(since)))
with open(fn, "wb") as cf: cf.write(clipbuf)
except OSError:
pass
clipbuf = None
elif CLIPS:
preroll.append(buf)
if LEVEL: write_level(db, active, peak, floor)
tag = "\033[1m▶ SIGNAL\033[0m" if active else " listening"
err.write(f"\r[{bar(db)}] {db:6.1f} dBFS {tag} "); err.flush()
else:
spin = (spin + 1) % 4
err.write(f"\r[{'' * BARW}] {SPIN[spin]} waiting… "
f"(quiet {time.time() - last:4.1f}s) "); err.flush()
except KeyboardInterrupt:
pass
finally:
err.write("\n")