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
+41
View File
@@ -0,0 +1,41 @@
# iSTRAIN — build stages
**Primary goal:** detect a train blocking *your* crossing, from passive RF alone.
**Secondary:** capture and learn — radios, trains, hardware, software. Not a toy.
This file renders in the dashboard's checklist panel; edit it to track your own build.
Full instructions: **APOCALYPSE-EDITION.md**.
## The plan · two radios, two bands
- **Radio A (a Blog V4 is ideal) → rail VHF voice 160162 MHz** — RTLSDR-Airband demodulates your
pinned road/detector channels at once (your subdivision's AAR channel is the mission).
- **Radio B (any RTL-SDR) → EOT-system UHF hop** — 452.9375 (head/BOT) ⇄ 457.9375 (EOT/tail),
retune-in-place; ±12.5 kHz DPU neighbors watched inside each dwell.
## Stage 0 · Host & dongles
- [ ] Linux host with Docker; base tools installed (rtl-sdr, ffmpeg, python3-numpy)
- [ ] DVB driver blacklisted on the host (`config/blacklist-rtl-sdr.conf`); `lsmod | grep dvb` empty
- [ ] Dongles serialized (`rtl_eeprom -s …`) so services pick them by name
- [ ] NOAA weather RX proves the antenna→dongle→software chain end to end
## Stage 1 · Your crossing
- [ ] Railroad + subdivision identified; road channel + neighbors found (cross-checked on 2 sources)
- [ ] Nearby defect-detector mileposts noted (your early-warning tripwires)
- [ ] `config/istrain.conf` edited with YOUR voice channels
## Stage 2 · Bring-up
- [ ] DSP selftests pass (`iq_hop.py --selftest`, `iq_channelize.py --selftest`)
- [ ] Stack builds and runs (`docker compose --profile radio --profile radio1001 up -d`)
- [ ] `level.json` refreshing; clips landing; dashboard live at `:3456`
- [ ] First EOT decode on a real train (unit ID + brake psi + motion) 🎉
## Stage 3 · Ears & polish
- [ ] Whisper transcription wired (comms band-pass first, VAD on — see APOCALYPSE §7)
- [ ] Squelch tuned to your noise floor (not a textbook number — APOCALYPSE §8)
- [ ] Standing carriers (fixed wayside boosters) identified and carded, not chased
## Stage 4 · If you expose it publicly
- [ ] Read APOCALYPSE §10 (security): presentation-node read-only mode, no PUSH_KEY, proxy auth
- [ ] Antenna moved high and away from your computers — the #1 range lever
## The habit that makes it work
- [ ] Keep one append-only, numbered observations log. Record findings AND retractions.
+300
View File
@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>iSTRAIN — railroad VHF watch</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>🚆 iSTRAIN <span class="sub">Lassiter Creek · <span id="ver" title="commit this dashboard is serving (* = uncommitted local changes)"></span> · railroad VHF 160162 MHz</span></h1>
<div class="hdr-right">
<div class="controls">
<button id="btn-refresh" class="btn" title="re-pull data">↻ refresh</button>
<button id="btn-reset" class="btn reset" title="full reload">⟲ hard reset</button>
</div>
<div class="hits">
<div class="stat"><div class="num" id="hits-unique"></div><div class="lab">unique</div></div>
<div class="stat"><div class="num" id="hits-total"></div><div class="lab">total hits</div></div>
</div>
</div>
</header>
<!-- live: the headline inference — isTRAIN? — mounted from panels/banner/ (above all cards) -->
<div id="istrain-banner"></div>
<main>
<!-- FIRST PANEL: the "AIS-equivalent" for trains — dispatch / yard data.
In-band source now (defect-detector & dispatcher voice on the road channel, transcribed);
ATCS/CTC (900 MHz) was evaluated and dropped (FCC-retired → encrypted PTC; our 900 was a spur). -->
<section id="dispatch" class="panel dispatch">
<h2>Dispatch / Yard <span class="tag">rail-voice captures — the "AIS" for trains</span></h2>
<div class="placeholder">
<b>Airband is watching 5 channels on 1002.</b> Captured transmissions (squelch-gated clips →
whisper transcripts) will list here newest-first: channel, time, and the milepost / axle-count /
speed read-outs from detector call-outs. ★ <b>160.980 NS Dayton District</b> is the mission channel.<br>
<span class="dim">Live clips appear in the <b>Captures</b> panel (one per transmission, playable);
transcription (whisper) still pending.</span><br>
<b>scan status:</b> <span id="airband-status">service <code>istrain-airband</code> — live status &amp; controls in the <b>Supervisor</b> panel below</span>
</div>
</section>
<!-- captures panel RESTORED 2026-07-10 — now lives below Community Feeds (novelty tier), see there. -->
<!-- live: Trains — per-train head+tail cards from decoded EOT (lifecycle + crossing inference) -->
<section id="trains" class="panel detect"></section>
<!-- activity panel SHELVED 2026-07-02 — voice ACTIVITY stays a signal in the correlate graph
(which reads heard.jsonl); the dedicated activity panel stays down. -->
<!-- live: EOT presence on 457.9375 (dongle 1001) — a burst = a train's rear unit is near -->
<section id="eot" class="panel detect"></section>
<!-- live: multi-band train-activity correlation — co-occurring activity across voice/EOT/(head-end) -->
<section id="correlate" class="panel detect"></section>
<!-- scaffold: BOT / head-end presence on 452.9375 — lights up when the 452 hop feeds /api/bot -->
<section id="bot" class="panel detect"></section>
<!-- scaffold: mid-train DPU (~900 MHz) presence — lights up when researched + hopped, /api/midtrain -->
<section id="midtrain" class="panel detect"></section>
<!-- live: signal meter — bouncing dBFS bar for the band the hop is currently on (/api/level) -->
<section id="live" class="panel"></section>
<!-- DEFECT DETECTORS — static reference from defectdetector.net (one-time import by id, not a poller) -->
<section id="detectors" class="panel detect">
<h2>Defect Detectors <span class="tag">reference — your subdivision (example)</span></h2>
<div class="placeholder">
Wayside detectors near your crossing (example: MP 148.99), all on your mission channel (example <b>160.980</b>). The
nearest <b>talks on every train</b> — its call-out is the unambiguous "train through" event.<br>
<b>CJ 146.4 ★</b><b>nearest</b>, ~2.6 mi east (New Rome OH). HBD-DED · <b>talks every train</b>
(Talk-On-Defect-Only: No) → routine call-out on <b>westbounds</b> · <a href="http://map.defectdetector.net/?id=8519">record</a><br>
<b>CJ 144.7</b> — ~4.3 mi east, high-car (over-height) — defect-only<br>
<b>CJ 157.8</b> — ~8.8 mi west, warns eastbounds — type TBD<br>
<span class="dim">Source: defectdetector.net — static records (location/type/freq/audio), pullable per detector by id. One-time import, not a live poller.</span>
</div>
</section>
<!-- COMMUNITY FEEDS — live audio (when up) -->
<section id="feeds" class="panel detect">
<h2>Community Feeds <span class="tag">live audio — when up</span></h2>
<div class="placeholder">
<a href="https://www.broadcastify.com/listen/feed/26042">Broadcastify — South&nbsp;Central&nbsp;Ohio&nbsp;Railroads</a>
— the one Franklin County rail feed; monitors our <b>160.980</b> (independently validates the channel). <span class="dim">Often offline, ~0 listeners.</span><br>
<span class="dim">Their rooftop hears more than our desk whip — "they catch it, we don't" = range, not broken. Someday: we become the local contributor.</span>
</div>
</section>
<!-- captures: RESTORED 2026-07-10 (was shelved 07-02 "return with the new antenna" — it arrived,
same night the whisper pipeline went live). The public node serves VOICED clips only (pushed
from the VM: push-to-nas _push_clips → /api/push-clip, self-pruning ~120 files); 🗣 rows carry
the whisper transcript. Novelty tier below Community Feeds. -->
<section id="captures" class="panel detect"></section>
<!-- SIGNAL FLOW — hand-maintained ASCII map: frequency -> dongle -> approach, by band.
A learning tool. Keep it current as the build iterates (LIVE/TEST/NEXT/LATER tags). -->
<section id="flow" class="panel flow">
<h2>Signal Flow <span class="tag">how it fits together — the air ▸ the dongles ▸ meaning</span></h2>
<pre class="flow-chart">
iSTRAIN — SIGNAL FLOW the air ▸ the dongles ▸ meaning
mission: is a train blocking YOUR crossing? (set your channels in config/istrain.conf)
legend: ★ TCXO (precise) [nnnn] = dongle serial ─▶ data path
updated 2026-06-28: voice live (1002); 1001 WEIGHTED hop 452/457 (clock-step-proof); EOT decoded LIVE (ID+brake+motion); HOT head-end ADDRESS cracked → head+tail join; MID standing carrier ID'd = 457.925 EOT booster, carded. ATCS dropped.
updated 2026-07-05: EOT decode is TWO-PASS — off-frequency emitters (±800 Hz, ~100 ms bursts) now decode
via adaptive tones + fuzzy sync (O-11) → dwelling trains identified w/ brake psi: held ~60 = stopped
solid · bled to 0 + head polling = CUT & standing (crossing possibly open). Booster floor subtracted
from contacts & correlate (O-9).
updated 2026-07-18: THE WHOLE RIG MOVED TO THE MILL — both dongles + capture + decode + transcription
+ this site now run as one Docker (Dockge) stack on a TrueNAS box; the old VM runs zero radio
services; data is born where it's served. Voice transcription is LIVE (not planned — see below).
────────────────────────────────────────────────────────────────────
PRIMARY ░░ RAIL VHF VOICE — 160162 MHz · AAR · NBFM, unencrypted ░░
└ defect detectors ("MP 123, no defects, 45 axles, 60 mph") + dispatcher
─▶ RTLSDR-Airband (multi-ch) ─▶ ffmpeg comms-filter ─▶ faster-whisper (small.en + VAD,
LIVE on the Mill since 07-10) ─▶ transcripts.jsonl — "a train is moving through," in words
▸ the most direct, gettable "is MY crossing blocked" signal
▸ proven 2026-06-16: same band as NOAA 162.550 — RX confirmed here
▸ CONFIRMED channel (2026-06-18): 160.980 (ch58) NS Dayton District, MP 148.99;
detectors CJ 144.7 / 157.8 announce here. Plan + sources → CHANNELS.md
▸ first intelligible speech 2026-07-10, the night the tuned antenna went in:
detector CJ at MP 146.4, transcribed (O-22)
stick: [1002 ★] Blog V4 — Airband demods the 5 pinned channels at once (160.86161.19 @ 1.024 MS/s)
antenna: tuned railroad-band vertical (upgraded 07-10 — ≈2.4× the desk whip's real content;
office placement, rooftop mast = the standing next lever)
SECONDARY ░░ EOT-SYSTEM UHF — 1001 WEIGHTED HOP (452 ⇄ 457) · 1200-baud FFSK ░░
└ a train is a chain of RF emitters front-to-back; 1001 hops to watch both ends:
▸ 457.9375 EOT/FRED — the REAR unit; DECODED live: ID · brake · MOTION (rolling/stopped) ─▶ eot.jsonl + clips
decode is TWO-PASS (07-05): exact fast path + adaptive/fuzzy escalation for the
off-frequency short-burst emitters that used to read "strong but undecodable" (O-11)
▸ 452.9375 BOT / head-end (HOT) — the FRONT unit; frame RECOVERED + addressed-unit DECODED ─▶ bot.jsonl
▸ hop: iq_hop.py — retune-in-place, BOT 10s / EOT 10s (rebalanced 07-07: the head = the join + the EARLY WARNING; was 7/14 EOT-favored before the HOT decoder existed)
▸ EOT decode LIVE 2026-06-27 — real trains identified; brake-release + motion=1 = departed (crossing cleared)
▸ HOT address CRACKED 2026-06-28 from our own air (invert · sync 0110101010 · bit-49) → head↔tail JOIN:
a HOT frame addressing a card's own EOT = confirmed complete consist. Command/type + BCH still open.
stick: [1001] generic — drifts, ppm-calibrate for a clean FSK decode; 450 band clean (no filtering)
antenna: ~6" vertical — ¼λ ≈ 6.4" @ 457, well-matched
▸ MID / DPU — ±12.5k off both centers (452.925/.950, 457.925/.950), watched in-hop → midtrain.jsonl
found ░ a STANDING carrier on 457.925 = an FCC railroad EOT SIGNAL-BOOSTER (fixed wayside repeater, not a
train); carded as a STANDING SIGNAL. A booster here ⇒ marginal head↔tail RF — likely why our range is short.
SYNTHESIS ░░ the mission is DETECT a train, not decode one signal ░░
└ a train lights several bands at once — co-firing in the same 15-min slot = a candidate,
even when each is too weak to decode: 160.980 voice + 452 BOT + 457 EOT (+ ±12.5k DPU,
booster-floor subtracted so the fixed carrier can't fake a band — O-9)
▸ correlate panel = week heatmap (one row/band, aligned bins) — read DOWN a column
▸ BOT/mid/EOT strips below = the RECENT ZOOM (1-min bins, last 6 h) — correlate's magnifier
▸ live meter = bouncing dBFS bar for whichever band the hop is on (level.json)
MEANING ░░ the AIR CURVE is the state — psi tells the story silence can't (O-10 · O-11) ░░
└ from decoded EOT frames, per train:
psi ~8590 + moving = PASSING through
psi drops to a HELD ~5565 + motion 0 = DWELLING (brakes set — possibly blocking)
psi bleeds to 0 AND HOLDS + head still polling = CUT & STANDING — crews cut the crossing
(eyes-confirmed 07-05: ~11 h split, crossing open; never read the bleed as a departure)
psi RECHARGES toward ~88 + motion 0→1 = DEPARTED (the roll-out is the event time)
sanity ░ NOAA 162.550 (Columbus) — the proven known-good RX test on this band
(the per-radio toggle is parked since the Mill move; control relay TBD)
config ░ radio→task is swappable, not hardcoded — reassign per band
dropped ░ ATCS (900 MHz): LOS-limited + FCC-retired Sep 2025 → encrypted PTC;
our 900 "carriers" were a dongle spur. Why → radio/sdr/atcs/tuning-log.md
────────────────────────────────────────────────────────────────────
why voice first: unencrypted, easy at 160 MHz, and it says a train is
moving through your crossing in plain words — the direct commute signal.
</pre>
</section>
<!-- SYSTEM MAP — hand-maintained ASCII map of the software side: the browser, the local
servers/services, the ports, and how data flows. Sibling to SIGNAL FLOW (which maps the air).
A learning tool for us and whoever comes after. Keep it current as servers/flows change. -->
<section id="stack" class="panel stack">
<h2>System Map <span class="tag">the istrain dashboard &amp; the services behind it</span></h2>
<pre class="flow-chart">
iSTRAIN — SYSTEM MAP the dashboard &amp; the services behind it
how the pieces talk · keep current as it changes
legend: ─▶ request / data ◂ produced by ▣ = a container in the Dockge stack `istrain`
updated 2026-07-18 (the Mill cutover): ONE BOX — everything below is a container on the Mill
(a TrueNAS SCALE machine running Dockge); both dongles on its USB; every log AUTHORED where
it's served (the VM-era push pipeline is retired); istrain.jhestyr.net serves this stack.
────────────────────────────────────────────────────────────────────
BROWSER ▸ istrain.jhestyr.net (Cloudflare ─▶ the Mill's ▣ web container)
└─▶ ▣ web serve.py — stdlib Python, no framework
│ (keeps a jsonl PARSE CACHE keyed on file mtime/size + a 4 s TTL on the card
│ compute — polls are ~free; the logs only change every ~20-40 s)
├ static index.html · main.js · style.css · panels/strip.js (TWO grids: aligned week/15-min
│ for correlate · RECENT1M = 6h/1-min zoom for the band strips)
│ panels/{trains,live,supervisor,correlate,bot,midtrain,eot,checklist}
├ GET /api/version ◂ dashboard/VERSION (stamped by mill/deploy.sh) ─▶ header build stamp
├ GET /api/trains ◂ eot.jsonl + bot.jsonl (cards: tail+head ✓join, ⚓parked, ◉standing,
│ air-curve states: passing/dwelling/CUT&amp;standing/departed) ─▶ Trains
├ GET /api/istrain ◂ strength-gated isTRAIN? verdict (+ dwelling-join fallback) ─▶ Banner
├ GET /api/level ◂ level.json (live dBFS) ─▶ Live meter (2×/s)
├ GET /api/heard ◂ heard.jsonl (voice detections) ─▶ Correlate (voice row)
├ GET /api/eot?hours=6 ◂ eot.jsonl ─▶ EOT zoom strip (no ?hours = week ─▶ Correlate)
├ GET /api/bot?hours=6 ◂ bot.jsonl ─▶ BOT zoom strip (ditto)
├ GET /api/midtrain?hours=6 ◂ midtrain.jsonl ─▶ Mid-train zoom strip (±12.5k DPU + booster)
├ GET /api/correlate ◂ scripts/correlate.py (recent-window why; booster-floor aware) ─▶ Correlate
├ GET /api/supervisor ◂ docker state via supervisor-probe (host cron, 1/min) + DATA-FLOW
│ liveness: hop = level.json freshness · voice = last-capture age
└ POST /api/noaa · /api/supervisor/{ctl,reset} (parked since the move — buttons decline
gracefully; a docker control relay is TBD)
DONGLES ░ USB by serial, both on the Mill through a hub · dvb driver blacklisted on the host
├ 1001 ▸ UHF hop — 452(BOT) ⇄ 457(EOT) └ 1002 ★ ▸ rail voice 160162 / NOAA
CAPTURE ░ voice + the UHF hop run at once — separate dongles, separate containers
├ ▣ airband rtl_airband v5.2.0 (rtl-sdr-blog fork, V4-capable) -c istrain.conf
│ ─▶ 5 NFM channels on 1002 ─▶ captures/*.mp3 ─▶ heard.jsonl
└ ▣ scanhop rtl_tcp + iq_hop.py: retune-in-place hop on 1001 (BOT 10s ⇄ EOT 10s, monotonic dwell — step-proof)
├ 452 ─▶ bot.jsonl + bot_clips/*.s16 (presence + clip)
├ 457 ─▶ eot.jsonl + eot_clips/*.s16 (presence + clip)
└ both ─▶ level.json (live meter + the hop's liveness signal)
DECODE ░ containers · no radio · CPU on saved clips · NEVER cull
├ ▣ eot-decode ─▶ eot-decode.py → eot_scan (TWO-PASS: exact + adaptive/fuzzy for off-freq
│ emitters, O-11) → unit·brake·motion (+dq provenance) → enrich eot.jsonl
├ ▣ bot-recover ─▶ bot-recover.py: BOT clips → recover HOT frame + DECODE addressed unit → enrich bot.jsonl
└ ▣ transcribe-worker ─▶ voice clips &gt;5 KB ─▶ ffmpeg comms-filter ─▶ the whisper stack
(faster-whisper small.en + VAD, a sibling Dockge stack, localhost) ─▶ sidecar .txt per clip;
real speech only ─▶ transcripts.jsonl (durable)
HOUSEKEEPING ░ ▣ trim — 24 h voice-audio prune (ingest-gated; heard.jsonl KEPT)
░ supervisor-probe — host cron, 1/min: docker truth into the snapshot
DATA ░ born on the Mill's ZFS datasets — jsonl logs = keep forever · clips accumulate
(keep-everything standing policy) · the VM-era corpus is archived alongside
build vs operate ░ the hands build the code; the radios are operated by services —
never fired ad hoc. deploy = mill/deploy.sh: rsync ─▶ build ─▶ up ─▶ smoke, idempotent.
history ░ the VM-capture / NAS-web era (2026-06-15 → 07-18) is preserved in MIGRATION.md;
the VM units stay on disk, disabled — the standing rollback.
────────────────────────────────────────────────────────────────────
</pre>
</section>
</main>
<!-- WITH THANKS — carried from the first istrain, extended for the rebuild -->
<section class="thanks">
<div class="th-h">📡 WITH THANKS</div>
<p>Railroad frequencies, the tools that pull signal out of the air, and the hands that built it.</p>
<p class="th-grp"><b>Frequency &amp; data communities</b><br>
<a href="https://trainweb.us/columbusrailfan/">Columbus Railfan Online</a> (Silver Rails / TrainWeb) — scanner frequencies<br>
<a href="https://www.radioreference.com/db/subcat/77377">RadioReference</a> — NS Columbus Terminal &amp; the US Railroads index<br>
<a href="https://www.railroad-frequencies.com/train/norfolk-southern-railway/ohio/">Railroad-Frequencies.com</a> — NS &amp; CSX Ohio assignments
</p>
<p class="th-grp"><b>Tools &amp; projects</b><br>
<a href="https://www.rtl-sdr.com/">RTL-SDR Blog</a> — the Blog V4 hardware and the community behind it<br>
<a href="https://osmocom.org/projects/rtl-sdr/">Osmocom rtl-sdr</a><code>rtl_test</code> / <code>rtl_eeprom</code>, the tools that drive the dongles<br>
<a href="https://github.com/szpajder/RTLSDR-Airband">RTLSDR-Airband</a> — the multi-channel voice watch (we run the <a href="https://github.com/rtl-sdr-blog/RTLSDR-Airband">rtl-sdr-blog fork</a>, V4-capable)<br>
<a href="https://github.com/SYSTRAN/faster-whisper">faster-whisper</a> (SYSTRAN) + <a href="https://github.com/openai/whisper">OpenAI Whisper</a> — the transcription engine that lets istrain <em>hear</em> (small.en + VAD)<br>
<a href="https://github.com/ggerganov/whisper.cpp">whisper.cpp</a> — lit the transcription path first, in the VM era<br>
<a href="https://ffmpeg.org/">FFmpeg</a> — the comms-filter in front of the ears, and every clip's plumbing<br>
<a href="https://github.com/jvde-github/AIS-catcher">AIS-catcher</a> — AIS decode, our known-good validation signal<br>
<a href="https://github.com/ereuter/PyEOT">PyEOT</a> (Eric Reuter) — the open-source EOT/FFSK decoder (packet parser + BCH check) that proved we can read End-of-Train telemetry; ships the demo recording we validated against<br>
<a href="https://www.sigidwiki.com/wiki/End_of_Train_Device_(EOTD)">SigidWiki — EOTD</a> &amp; <a href="https://www.rtl-sdr.com/tag/eot/">rtl-sdr.com EOT guides</a> — the 1200-baud FFSK spec and the decode trail that pointed the way<br>
<a href="https://numpy.org/">NumPy</a> — the signal-crunching behind the EOT demodulator<br>
<strong>ATCS Monitor</strong> &amp; <a href="https://rail.watch/rwmon.html">Rail Watch Monitor</a> — the 900 MHz dispatcher's board, and the community that mapped ATCS (evaluated & dropped — kept for provenance)<br>
<a href="https://www.radioreference.com/db/subcat/69805">RadioReference ATCS DB</a> &amp; <a href="https://www.sigidwiki.com/wiki/Automated_Train_Control_System_(ATCS)">SigidWiki</a> — the published channel plans that told us what we'd found<br>
<a href="https://www.docker.com/">Docker</a> + <a href="https://github.com/louislam/dockge">Dockge</a> (louislam) — the runtime every service lives in since the Mill move<br>
<a href="https://tailscale.com/">Tailscale</a> — the quiet plumbing between the boxes<br>
<a href="https://www.cloudflare.com/">Cloudflare</a> — the front door
</p>
<p class="th-grp"><b>The machine</b><br>
<a href="https://www.truenas.com/truenas-scale/">TrueNAS SCALE</a> on the Mill — the one box the whole rig runs on (since 2026-07-18): dongles, capture, decode, ears, and this site<br>
<a href="https://www.virtualbox.org/">Oracle VM VirtualBox</a> + <a href="https://linuxmint.com/">Linux Mint (LMDE)</a> — the dev seat, and the box the rig grew up inside (2026-06-15 → 07-18)
</p>
<p class="th-grp"><b>Foundation</b><br>
• built on a reusable radio-capture engine and iterated with an AI pair-programmer (<a href="https://www.anthropic.com/claude">Claude</a>, Anthropic)<br>
• a community project — the credits above are the shoulders it stands on; if your tool, frequency, or hand helped and isnt here yet, it will be.
</p>
<p class="th-grp"><b>A living list</b><br>
istrain is a community project — this list is intentionally unfinished and grows as the work brings in
more shoulders to stand on. If your tool, frequency, or hand helped and isn't here yet, it will be.
</p>
</section>
<!-- live: Supervisor — services + radios (moved below THANKS: the ops corner
lives at the bottom with the errors card; the triple-tap gate is on this panel's title) -->
<section id="supervisor" class="panel yard"></section>
<!-- ERRORS — live issues + recent events, below WITH THANKS (the owner 2026-07-06): the card to
copy/share when something looks wrong. Fed by /api/errors (supervisor audit + client JS reports). -->
<section id="errors" class="panel errors"></section>
<!-- BUILD CHECKLIST — rendered from checklist.md (single source; also a git host task list).
Keep checklist.md current; this panel mirrors it live. -->
<section id="checklist" class="panel checklist"></section>
<footer>
<span id="status">live — one box: the Mill captures, decodes &amp; presents</span> ·
<a href="https://istrain.jhestyr.net">istrain.jhestyr.net</a> · an open community project
</footer>
<script type="module" src="/main.js"></script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
// main.js — istrain dashboard. Frame chrome (hit counter, refresh / hard-reset) + mounts live panels.
// Panels follow the engine's panels/<name>/mount(el) contract (see the radio engine). Static placeholder
// panels (dispatch, detect) stay in index.html until built.
//
// Refresh policy: slow background polls + change-aware renders so the page idles at ~0% CPU (no
// compositor stutter). Manual "refresh" re-pulls now; "hard reset" kills captures + restarts all helpers (/api/supervisor/reset).
import { mountSupervisor } from '/panels/supervisor/supervisor.js';
import { mountChecklist } from '/panels/checklist/checklist.js';
// captures RESTORED 2026-07-10 — the new antenna arrived + whisper transcripts went live; the public
// node gets voiced clips pushed. activity stays SHELVED (voice activity = a correlate-graph signal).
import { mountCaptures } from '/panels/captures/captures.js';
import { mountEot } from '/panels/eot/eot.js';
import { mountCorrelate } from '/panels/correlate/correlate.js';
import { mountBot } from '/panels/bot/bot.js';
import { mountMidtrain } from '/panels/midtrain/midtrain.js';
import { mountLive } from '/panels/live/live.js';
import { mountTrains } from '/panels/trains/trains.js';
import { mountBanner } from '/panels/banner/banner.js';
import { mountErrors } from '/panels/errors/errors.js';
// --- client-side error reporter → the Errors card (rate-limited + escaped server/panel side) ---
function reportErr(msg, src, line) {
try {
fetch('/api/errors/report', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msg: String(msg).slice(0, 500), src: String(src || '').slice(0, 200), line: line || 0 }),
}).catch(() => {});
} catch { /* the reporter must never itself throw */ }
}
window.addEventListener('error', e => reportErr(e.message, e.filename, e.lineno));
window.addEventListener('unhandledrejection', e => reportErr('unhandledrejection: ' + ((e.reason && e.reason.message) || e.reason), location.pathname, 0));
// --- hit counter (change-aware; shows "—" until the server exists) ---
let lastHits = null;
async function loadHits() {
try {
const r = await fetch('/api/hits', { cache: 'no-store' });
if (!r.ok) return;
const { unique, total } = await r.json();
const key = unique + '/' + total;
if (key === lastHits) return; // no change → no DOM write
lastHits = key;
if (unique != null) document.getElementById('hits-unique').textContent = unique.toLocaleString();
if (total != null) document.getElementById('hits-total').textContent = total.toLocaleString();
} catch { /* no server yet — leave the dashes */ }
}
// --- build stamp: the commit this dashboard serves (VM: git; NAS: VERSION stamped by fetch-app.sh) ---
async function loadVersion() {
try {
const r = await fetch('/api/version', { cache: 'no-store' });
if (!r.ok) return;
const { commit } = await r.json();
if (commit) document.getElementById('ver').textContent = commit;
} catch { /* leave the dash */ }
}
// --- unique visitor: a persistent client token (robust behind the tunnel, where the real IP collapses) ---
function recordVisit() {
let vid = localStorage.getItem('istrain_vid');
if (!vid) {
vid = (window.crypto && crypto.randomUUID) ? crypto.randomUUID()
: (Date.now().toString(16) + '-' + Math.floor(Math.random() * 0xffffffff).toString(16));
localStorage.setItem('istrain_vid', vid);
}
fetch('/api/hit?vid=' + encodeURIComponent(vid), { cache: 'no-store' }).catch(() => {}).finally(loadHits);
}
// --- live panels ---
const supervisor = mountSupervisor(document.getElementById('supervisor'));
const checklist = mountChecklist(document.getElementById('checklist'));
const banner = mountBanner(document.getElementById('istrain-banner'));
const trains = mountTrains(document.getElementById('trains'));
const eot = mountEot(document.getElementById('eot'));
const correlate = mountCorrelate(document.getElementById('correlate'));
const bot = mountBot(document.getElementById('bot'));
const midtrain = mountMidtrain(document.getElementById('midtrain'));
const live = mountLive(document.getElementById('live'));
const captures = mountCaptures(document.getElementById('captures'));
const errors = mountErrors(document.getElementById('errors'));
// --- controls ---
document.getElementById('btn-refresh').onclick = () => { loadHits(); banner.refresh(); supervisor.refresh(); checklist.refresh(); trains.refresh(); eot.refresh(); correlate.refresh(); bot.refresh(); midtrain.refresh(); errors.refresh(); captures.refresh(); };
document.getElementById('btn-reset').onclick = async () => {
if (!confirm('Hard reset — kill the captures and restart all istrain helpers? Frees the dongles; device-level DVB resets still need cmd-palette.')) return;
const b = document.getElementById('btn-reset'), t = b.textContent;
b.disabled = true; b.textContent = 'resetting…';
try { await fetch('/api/supervisor/reset', { method: 'POST', headers: { 'X-Ops': '1' } }); } catch {}
setTimeout(() => { b.disabled = false; b.textContent = t; supervisor.refresh(); eot.refresh(); correlate.refresh(); }, 3500);
};
// initial pull + a slow safety poll (manual refresh drives the rest)
recordVisit(); // beacon once per load (mints/sends the visitor token), then shows counts
loadVersion(); // header build stamp — once per load
setInterval(loadHits, 600000); // 10 min
+127
View File
@@ -0,0 +1,127 @@
// strip.js — shared "week of 15-minute bins" strip, used by EVERY band panel (EOT/BOT/mid-train/voice and
// the correlation synthesis). One source of truth for binning so their columns LINE UP: bins are keyed to
// absolute wall-clock 15-min slots (floor(ts/900)), not relative-to-now — so the same 15-min slot lands in
// the same column on every panel. Shared canvas draw + one hover-tooltip format (date + time range, ET).
export const BUCKET = 900, WEEK = 7 * 86400, NB = Math.round(WEEK / BUCKET); // 672 — a week of 15-min bins
export const TZ = 'America/New_York';
// two grids (2026-07-05): the shared WEEK grid keeps every panel's columns aligned (correlate's job —
// the overview); RECENT1M is the zoom the band strips use so the most recent stretch of correlate can
// be inspected at 1-min resolution (360 bins ≈ same canvas cost as the week's 672).
export const WEEK15 = { bucket: BUCKET, nb: NB, note: 'week · 15-min bins' };
export const RECENT1M = { bucket: 60, nb: 360, note: 'last 6h · 1-min bins' };
// absolute slot alignment — rightmost bin (nb-1) is the current slot
export function binIndex(ts, now, s = WEEK15) {
if (!ts || ts <= 0) return -1;
const idx = s.nb - 1 - (Math.floor(now / s.bucket) - Math.floor(ts / s.bucket));
return (idx < 0 || idx > s.nb - 1) ? -1 : idx;
}
export function fillBins(events, now, getTs = e => e.ts, s = WEEK15) {
const b = new Int16Array(s.nb);
for (const e of events) { const i = binIndex(getTs(e), now, s); if (i >= 0) b[i]++; }
return b;
}
// wall-clock window [start,end) covered by bin idx — for tooltips
export function binTimes(idx, now, s = WEEK15) {
const start = (Math.floor(now / s.bucket) - (s.nb - 1 - idx)) * s.bucket;
return { start, end: start + s.bucket };
}
export function drawStrip(canvas, buckets, rgb, gmaxOverride) {
const w = Math.max(1, canvas.clientWidth | 0), h = (canvas.clientHeight | 0) || 32;
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
const gmax = gmaxOverride || Math.max(1, ...buckets);
const N = buckets.length; // works for either grid
for (let x = 0; x < w; x++) {
const b0 = Math.floor(x / w * N), b1 = Math.max(b0 + 1, Math.floor((x + 1) / w * N));
let v = 0;
for (let b = b0; b < b1 && b < N; b++) if (buckets[b] > v) v = buckets[b];
if (!v) continue;
const t = v / gmax, bh = Math.max(1, Math.round(t * h));
ctx.fillStyle = `rgba(${rgb},${(0.4 + 0.6 * t).toFixed(3)})`;
ctx.fillRect(x, h - bh, 1, bh);
}
}
const fmtStart = t => new Date(t * 1000).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: TZ });
const fmtEnd = t => new Date(t * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: TZ });
// Shared hover tooltip (reuses .act-tip). resolve(ev) -> {canvas, buckets, now, unit} | null.
// Default-to-LEFT of cursor (busy bins sit at the right edge, where a right popup gets clipped).
export function attachTip(el, bodyEl, resolve) {
const tip = document.createElement('div');
tip.className = 'act-tip'; tip.style.display = 'none';
el.appendChild(tip);
bodyEl.addEventListener('mousemove', ev => {
const r = resolve(ev);
if (!r) { tip.style.display = 'none'; return; }
const s = r.scale || WEEK15;
const rect = r.canvas.getBoundingClientRect();
let idx = Math.floor((ev.clientX - rect.left) / Math.max(1, rect.width) * s.nb);
idx = Math.max(0, Math.min(s.nb - 1, idx));
const { start, end } = binTimes(idx, r.now, s);
const label = r.label ? r.label(idx) : `${r.buckets[idx]} ${r.unit || 'tx'}`;
tip.textContent = `${label} · ${fmtStart(start)}${fmtEnd(end)} ET`;
tip.style.display = 'block';
const gap = 12;
let lx = ev.clientX - gap - tip.offsetWidth;
if (lx < 4) lx = ev.clientX + gap;
tip.style.left = lx + 'px';
tip.style.top = Math.max(4, ev.clientY - 30) + 'px';
});
bodyEl.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
}
// Factory for a single-band PRESENCE panel (EOT/BOT/mid-train). opts:
// {id, title, tag, freqTag, rgb, endpoint, nearSec, nearLabel, emptyHtml, scale?}
// scale defaults to the shared week grid; pass RECENT1M for the 1-min zoom (endpoint should then
// carry ?hours=6 so the server only ships the window being drawn).
export function mountBandStrip(el, o) {
const scale = o.scale || WEEK15;
el.innerHTML =
`<h2>${o.title} <span class="tag" id="${o.id}-tag">${o.tag}</span></h2><div id="${o.id}-body"></div>`;
const tagEl = el.querySelector(`#${o.id}-tag`);
const bodyEl = el.querySelector(`#${o.id}-body`);
let lastKey = null, lastBuckets = null, lastNow = 0;
const fmtT = e => new Date(e * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: TZ });
const paint = () => {
const cv = bodyEl.querySelector('canvas.eot-strip');
if (cv && lastBuckets) drawStrip(cv, lastBuckets, o.rgb);
};
attachTip(el, bodyEl, ev => {
const cv = ev.target.closest && ev.target.closest('canvas.eot-strip');
return (cv && lastBuckets) ? { canvas: cv, buckets: lastBuckets, now: lastNow, unit: 'bursts', scale } : null;
});
async function refresh() {
let d = { bursts: [], total: 0 };
try { const r = await fetch(o.endpoint, { cache: 'no-store' }); if (r.ok) d = await r.json(); }
catch { return; }
const bursts = d.bursts || [], now = Date.now() / 1000, w = bodyEl.clientWidth | 0;
const newest = bursts.length ? bursts[0].ts : 0;
const key = (d.total || 0) + '|' + newest + '|' + w + '|' + Math.floor(now / scale.bucket);
if (key === lastKey) return;
lastKey = key; lastNow = now;
lastBuckets = fillBins(bursts, now, e => e.ts, scale);
tagEl.textContent = d.total ? `${o.freqTag} · ${d.total} bursts · ${scale.note}` : o.tag;
if (!bursts.length) { bodyEl.innerHTML = `<div class="placeholder">${o.emptyHtml}</div>`; return; }
const last = newest, hr = bursts.filter(b => now - b.ts <= 3600).length, near = last && (now - last <= o.nearSec);
bodyEl.innerHTML =
`<div class="eot-head">` +
`<span class="eot-state"${near ? ` style="color:rgb(${o.rgb})"` : ''}>${near ? o.nearLabel : 'quiet'}</span>` +
`<span class="eot-last">last ${fmtT(last)} ET</span>` +
`<span class="eot-n">${hr}× last hr</span>` +
`</div><canvas class="eot-strip"></canvas>`;
requestAnimationFrame(paint);
}
refresh();
setInterval(refresh, 30000);
let rt;
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { lastKey = null; refresh(); }, 250); });
return { refresh };
}
+23
View File
@@ -0,0 +1,23 @@
{
"generated": "2026-06-18T15:36:59+00:00",
"radios": [
{
"serial": "1001",
"product": "RTL2838UHIDIR",
"manufacturer": "Realtek",
"role": "EOT/BOT \u00b7 452/457",
"driver": null,
"blocked_by_dvb": false,
"present": true
},
{
"serial": "1002",
"product": "Blog V4",
"manufacturer": "RTLSDRBlog",
"role": "rail voice \u00b7 160",
"driver": null,
"blocked_by_dvb": false,
"present": true
}
]
}
+1521
View File
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
/* istrain dashboard — frame. Color scheme carried over from the radio/marine dashboard
(the reusable radio engine). Signature look: each panel wears a colored left stripe. */
:root{
--bg:#0b1620; --panel:#13212e; --ink:#cfe3f0; --muted:#7d97a8;
--line:#243a4d; --accent:#37b3a4; --good:#2c9c6b; --bad:#e0413f;
/* per-panel left-stripe colors */
--c-dispatch:#e0b341; --c-detect:#37b3a4; --c-yard:#7fb3ff; --c-future:#5a7088;
--c-flow:#b48ce0; --c-stack:#57b6c9;
--c-captures:#e0884d; --c-activity:#b6c94d; --c-detectors:#e06b8a; --c-feeds:#8c9ee0;
}
*{box-sizing:border-box}
body{margin:0;font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)}
header{position:sticky;top:0;background:#0a131c;border-bottom:1px solid var(--line);padding:12px 16px;z-index:10;
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
header h1{margin:0;font-size:20px;font-weight:700;letter-spacing:.02em}
header h1 .sub{font-size:12px;font-weight:400;color:var(--muted);margin-left:10px}
/* hits counter — header stat block */
.hdr-right{display:flex;align-items:center;gap:16px}
.controls{display:flex;gap:8px}
.btn{border:1px solid var(--line);background:#1b2c3b;color:var(--ink);padding:7px 12px;border-radius:6px;font:600 12px/1 system-ui;cursor:pointer}
.btn:hover{filter:brightness(1.18)}
.btn:active{transform:translateY(1px)}
.btn.reset{background:#3a1414;border-color:#7a2a28;color:#ff9a98}
.hits{display:flex;gap:18px;align-items:center}
.hits .stat{text-align:right}
.hits .num{font:700 20px/1 ui-monospace,monospace;color:var(--accent);font-variant-numeric:tabular-nums}
.hits .lab{font-size:10px;letter-spacing:.10em;text-transform:uppercase;color:var(--muted);margin-top:3px}
main{display:grid;gap:14px;padding:16px;max-width:1000px;margin:0 auto}
.panel{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--c-future);
border-radius:8px;padding:14px;min-width:0} /* min-width:0 lets a grid item shrink so a wide <pre> scrolls inside it instead of widening the whole page (mobile) */
.panel h2{margin:0 0 10px;font-size:14px;color:var(--accent);letter-spacing:.04em;text-transform:uppercase}
.panel h2 .tag{font-size:11px;color:var(--muted);font-weight:400;letter-spacing:.02em;text-transform:none;margin-left:8px}
/* left-stripe colors per panel role */
.panel.dispatch{border-left-color:var(--c-dispatch)}
.panel.dispatch h2{color:var(--c-dispatch)}
.panel.detect{border-left-color:var(--c-detect)}
.panel.yard{border-left-color:var(--c-yard)}
.panel.yard h2{color:var(--c-yard)}
.panel.flow{border-left-color:var(--c-flow)}
.panel.flow h2{color:var(--c-flow)}
.panel.stack{border-left-color:var(--c-stack)}
.panel.stack h2{color:var(--c-stack)}
/* per-PANEL distinct colors — id overrides the reused role-class so each panel reads as its own thing */
.panel#captures{border-left-color:var(--c-captures)} .panel#captures h2{color:var(--c-captures)}
.panel#activity{border-left-color:var(--c-activity)} .panel#activity h2{color:var(--c-activity)}
.panel#detectors{border-left-color:var(--c-detectors)} .panel#detectors h2{color:var(--c-detectors)}
.panel#feeds{border-left-color:var(--c-feeds)} .panel#feeds h2{color:var(--c-feeds)}
/* signal-flow ASCII map — hand-maintained <pre>; scrolls sideways if it can't fit */
.flow-chart{margin:0;overflow-x:auto;-webkit-overflow-scrolling:touch;
background:#0a131c;border:1px solid var(--line);border-radius:6px;padding:14px;
color:#c4dcea;font:11px/1.42 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.placeholder{color:var(--muted);background:#0a131c;border:1px dashed var(--line);border-radius:6px;
padding:22px;text-align:center;line-height:1.7;font-size:13px}
.placeholder b{color:var(--ink)}
/* radios panel — per-dongle liveness table + status dots */
table.radios{width:100%;border-collapse:collapse;font-size:13px}
table.radios td{padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
table.radios tr:last-child td{border-bottom:none}
table.radios .sn{font-family:ui-monospace,monospace;color:var(--accent);font-weight:700;width:64px}
table.radios .role{color:var(--ink);text-transform:capitalize;width:80px}
table.radios .nm{color:var(--muted);font-size:12px}
table.radios .st{font-size:12px;text-align:right;font-variant-numeric:tabular-nums}
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle}
.dot.ready{background:#6f9bd9;box-shadow:0 0 7px #6f9bd9}
.dot.listening{background:var(--good);box-shadow:0 0 7px var(--good)}
.dot.blocked{background:#e0b341;box-shadow:0 0 7px #e0b341}
.dot.offline{background:var(--bad);box-shadow:0 0 7px var(--bad)}
/* the missing middle states (06-25 truth-by-data-flow): held-but-no-samples is the dangerous lie */
.dot.stalled{background:#e06a2e;box-shadow:0 0 8px #e06a2e}
.dot.held{background:#8a93a6;box-shadow:0 0 6px #8a93a6}
.st.ready{color:#6f9bd9} .st.listening{color:var(--good)} .st.blocked{color:#e0b341} .st.offline{color:var(--bad)}
.st.stalled{color:#e06a2e} .st.held{color:#8a93a6}
.sv-detail.stalled{color:#e06a2e;opacity:1;font-weight:600}
/* errors card — live issues + recent events (below WITH THANKS) */
.err-row{font-family:ui-monospace,monospace;font-size:12px;padding:4px 6px;border-bottom:1px solid var(--line);line-height:1.45}
.err-row:last-child{border-bottom:none}
.err-row.err-live{color:#e06a2e;font-weight:600}
.err-row.err-err{color:#d9705e}
.err-row.err-event{color:var(--muted)}
/* grid panel order (the owner's layout, 2026-06-23; supervisor moved OUT of <main> to the ops corner
below THANKS 2026-07-06): correlate → bot → mid-train → eot → voice activity → captures →
detectors → feeds → signal flow → system map. The signal panels run FRONT-to-BACK of a train
(bot=head · mid=DPU · eot=rear). Thanks + supervisor + errors + checklist follow after <main>. */
#correlate{order:-1} /* overview panel — pinned to the top, under the banner, before the train cards */
#live{order:0} #bot{order:3} #midtrain{order:4} #eot{order:5} #activity{order:6}
#captures{order:7} #detectors{order:8} #feeds{order:9} #flow{order:10} #stack{order:11}
#dispatch{order:12} /* Dispatch/Yard whisper placeholder — parked at the bottom; may use it later */
table.supervisor{width:100%;border-collapse:collapse;font-size:12.5px}
table.supervisor td{padding:4px 6px;border-bottom:1px solid rgba(255,255,255,.05);vertical-align:middle}
table.supervisor tr:last-child td{border-bottom:0}
.sv-grp td{font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);padding:8px 6px 2px;border-bottom:0}
.sv-name{font-weight:600}
.sv-detail{font-size:11.5px;opacity:.85}
.sv-since{font-size:11px;color:var(--muted);white-space:nowrap;text-align:right}
.sv-actions{text-align:right;white-space:nowrap}
.sv-ctl{background:none;border:1px solid rgba(255,255,255,.2);color:inherit;opacity:.55;cursor:pointer;font-size:11px;line-height:1;padding:2px 6px;border-radius:3px;margin-left:3px}
.sv-ctl:hover{opacity:1;border-color:var(--accent);color:var(--accent)}
/* NOAA per-radio sanity toggle — idle = quiet outline; on = live green (audio playing, hijacked) */
.noaa-btn{border:1px solid var(--line);background:#1b2c3b;color:var(--ink);
border-radius:6px;font:600 11px/1 system-ui;text-transform:none;letter-spacing:0;
cursor:pointer;white-space:nowrap}
.noaa-btn:hover{filter:brightness(1.18)}
.noaa-btn:active{transform:translateY(1px)}
.noaa-btn:disabled{opacity:.55;cursor:wait}
.noaa-btn.on{background:var(--good);border-color:var(--good);color:#eafff4;box-shadow:0 0 8px var(--good)}
.noaa-btn.mini{padding:4px 9px}
table.radios .noaa-cell{text-align:right;width:1%;white-space:nowrap}
.noaa-na{color:var(--muted);opacity:.5}
/* build checklist — rendered from checklist.md; below thanks. centered like thanks since it
sits outside <main>. checked items dim + strike; group headers rule off the sections. */
.panel.checklist{border-left-color:var(--good)}
/* every section OUTSIDE <main> (thanks / supervisor / errors / checklist) gets the SAME effective
width as the in-main panels (main = 1000px cap minus 16px side padding → 968px interior), so
the whole page reads one column — no full-window-wrapping strays. */
.thanks, .panel.checklist, #supervisor, #errors{
max-width:968px;margin:14px auto;width:calc(100% - 32px);box-sizing:border-box}
.panel.checklist h2{color:var(--good)}
.cl-intro{color:var(--muted);font-size:12px;line-height:1.5;margin-bottom:6px}
.cl-group{color:var(--ink);font-weight:700;font-size:12px;letter-spacing:.04em;text-transform:uppercase;
margin:12px 0 6px;padding-bottom:3px;border-bottom:1px solid var(--line)}
.cl-item{display:flex;gap:8px;align-items:flex-start;font-size:13px;padding:2px 0;color:var(--ink)}
.cl-item.cl-sub{margin-left:22px;font-size:12px;color:var(--muted)}
.cl-item.done{color:var(--muted)}
.cl-item.done .cl-text{text-decoration:line-through;opacity:.7}
.cl-box{flex-shrink:0;color:var(--muted);line-height:1.5}
.cl-item.done .cl-box{color:var(--good)}
.cl-text code,.cl-intro code{background:#0a131c;border:1px solid var(--line);border-radius:3px;
padding:0 4px;font-size:11px;color:var(--accent);word-break:break-word}
/* with thanks — carried from the first istrain, extended for the rebuild */
.thanks{padding:14px 16px;background:#0f1a24;border:1px solid var(--line);
border-left:4px solid var(--good);border-radius:8px;font:12px/1.65 ui-monospace,monospace;color:var(--muted)}
/* width/margin come from the shared outside-main rule above */
.thanks .th-h{color:var(--good);letter-spacing:.12em;font-weight:700;margin-bottom:8px}
.thanks p{margin:8px 0}
.thanks .th-grp b{color:var(--ink);display:inline-block;margin-bottom:2px}
.thanks a{color:#7ec8e3;text-decoration:none}
.thanks a:hover{text-decoration:underline}
.thanks strong{color:var(--ink)}
.thanks em{color:var(--ink);font-style:italic}
.thanks .dim{color:#5a7088}
footer{color:var(--muted);text-align:center;padding:16px;font-size:12px}
footer a{color:var(--accent);text-decoration:none}
/* ---- narrow / portrait (phone) ---- istrain will mostly be viewed on mobile.
Stack the header instead of fighting for one row, trim padding, and let the
radios table wrap instead of overflowing a ~360px screen. */
@media (max-width:560px){
body{font:13px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
/* header: title on its own line, sub beneath it, controls+hits on a full-width row */
header{padding:10px 12px;gap:10px}
header h1{font-size:17px;display:flex;flex-direction:column;gap:2px}
header h1 .sub{margin-left:0;font-size:11px}
.hdr-right{width:100%;justify-content:space-between;gap:10px}
.hits{gap:14px}
.hits .num{font-size:17px}
.btn{padding:7px 10px}
/* body: tighter gutters so panels get the width back */
main{padding:12px;gap:12px}
.panel{padding:12px}
.placeholder{padding:16px 12px;font-size:12.5px;line-height:1.6}
/* radios table: drop fixed cell widths, allow long product names to wrap */
table.radios{font-size:12.5px}
table.radios td{padding:6px 6px}
table.radios .sn,table.radios .role{width:auto}
table.radios .nm{word-break:break-word}
.thanks{padding:12px;font-size:11.5px}
.flow-chart{font-size:10px;padding:12px 10px}
footer{padding:14px 12px}
}
/* captures panel — rail-voice clip list (light: native <audio> preload=none, change-aware renders) */
.cap-row{display:flex;flex-direction:column;gap:4px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}
.cap-row:last-child{border-bottom:0}
.cap-meta{display:flex;justify-content:space-between;align-items:baseline;gap:10px;font-size:12.5px}
.cap-ch{font-weight:600}
.cap-time{opacity:.6;font-size:11px;white-space:nowrap}
.cap-audio{width:100%;height:30px}
.cap-text{font-size:12px;opacity:.85;font-style:italic;padding-left:2px}
.cap-right{display:flex;align-items:center;gap:8px;white-space:nowrap}
.cap-del{background:none;border:1px solid rgba(255,255,255,.18);color:inherit;opacity:.4;cursor:pointer;font-size:11px;line-height:1;padding:2px 6px;border-radius:3px}
.cap-del:hover{opacity:1;color:#e06b5c;border-color:#e06b5c}
.cap-play{background:none;border:1px solid rgba(255,255,255,.2);color:inherit;opacity:.6;cursor:pointer;font-size:10px;line-height:1;padding:2px 7px;border-radius:3px}
.cap-play:hover{opacity:1;color:var(--accent);border-color:var(--accent)}
.cap-play.on{opacity:.3;cursor:default}
/* activity panel — per-channel week strips (15-min buckets, canvas) */
.act-item{margin:0 0 9px}
.act-item:last-child{margin-bottom:0}
.act-head{display:flex;align-items:baseline;gap:8px;font-size:12.5px;line-height:1.4}
.act-head .act-ch{flex:1;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.act-head .act-n{font-variant-numeric:tabular-nums;opacity:.85}
.act-head .act-last{opacity:.55;font-size:11px;white-space:nowrap}
.act-strip{display:block;width:100%;height:32px;margin-top:3px;background:rgba(255,255,255,.045);border-radius:2px;cursor:crosshair}
.act-mission .act-ch{color:#37b3a4}
.act-tip{position:fixed;z-index:60;pointer-events:none;background:rgba(18,22,26,.97);border:1px solid rgba(255,255,255,.15);border-radius:3px;padding:3px 7px;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;color:#e8eef0;box-shadow:0 2px 10px rgba(0,0,0,.45)}
/* EOT panel — presence on 457.9375 (dongle 1001) */
.panel#eot{border-left-color:#e6aa46} .panel#eot h2{color:#e6aa46}
.panel#bot{border-left-color:#b482e6} .panel#bot h2{color:#b482e6} /* violet — head/front */
.panel#midtrain{border-left-color:#5ab4d2} .panel#midtrain h2{color:#5ab4d2} /* blue — mid-consist/DPU */
.eot-head{display:flex;align-items:baseline;gap:10px;font-size:12.5px;margin-bottom:2px}
.eot-state{font-weight:600;color:var(--muted)}
.eot-state.eot-near{color:#e6aa46}
.eot-last{color:var(--muted);font-size:11px}
.eot-n{margin-left:auto;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}
.eot-strip{display:block;width:100%;height:32px;margin-top:3px;background:rgba(255,255,255,.045);border-radius:2px}
/* correlate panel — multi-band train-activity synthesis (teal = the mission/detection panel) */
.panel#correlate{border-left-color:#37b3a4} .panel#correlate h2{color:#37b3a4}
.corr-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12.5px;margin-bottom:4px}
.corr-state{font-weight:700;letter-spacing:.02em;padding:1px 7px;border-radius:4px;color:var(--muted)}
.corr-state.corr-single{color:#e6aa46} /* amber — one band, unconfirmed */
.corr-state.corr-candidate{color:#08121a;background:#37b3a4} /* teal solid — bands co-firing */
.corr-state.corr-error{color:#e06b6b}
.corr-key{display:inline-flex;align-items:center;gap:5px;color:var(--muted);font-size:11.5px}
.corr-key::before{content:'';width:8px;height:8px;border-radius:50%;background:rgb(var(--c))}
.corr-key b{color:var(--ink);font-weight:600}
.corr-strip{display:block;width:100%;height:92px;margin-top:2px;background:rgba(255,255,255,.045);border-radius:3px} /* 4-row heatmap */
.corr-why{margin-top:5px;font-size:11px;line-height:1.55;color:var(--muted);font-family:ui-monospace,Menlo,Consolas,monospace}
.corr-why-line{white-space:pre-wrap}
/* live signal meter — compact bar, top of the grid */
.panel#live{padding:8px 12px;border-left-color:#6b7785}
.live-row{display:flex;align-items:center;gap:10px}
.live-label{font-weight:600;font-size:12px;min-width:118px;color:var(--muted)}
.live-track{position:relative;flex:1;height:14px;background:rgba(255,255,255,.06);border-radius:7px;overflow:hidden}
.live-fill{height:100%;width:0;background:#888;border-radius:7px;transition:width .12s linear,background .2s}
.live-floor{position:absolute;top:0;bottom:0;width:2px;background:rgba(255,255,255,.55);transition:left .3s;pointer-events:none} /* adaptive noise floor */
.live-db{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:74px;text-align:right}
.panel#live.live-on{border-left-color:#fff}
/* (detections panel removed 2026-06-19 — Activity absorbed it; heard.jsonl + /api/heard stay) */