#!/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_.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")