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:
Executable
+155
@@ -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")
|
||||
Reference in New Issue
Block a user