← JayCorp · Radio scanner

Radio station: architecture proposal

A plan for turning our radio scanner into a station that runs on its own: spectrum activity in Grafana, ham nets recorded on a schedule, every transmission transcribed locally, check-ins and traffic logged with a human review step, and a web page to tune, listen and search. It is a proposal; nothing on this page is built yet. Written 2026-10-09.

The starting point was a brief Jay wrote. Most of it holds up. Where it doesn't match the hardware or what already runs on this machine, the section below says so, and the design follows what we measured.

What it should answer

What we start from

PieceToday
RadioSDRplay RSP1 (MSi2500 + MSi001), 10 kHz to 2 GHz, one tuner, USB 2.0. Driven through the open source libmirisdr-5 and SoapyMiri, built into ~/.local without root.
apps/sdrRuby CLIs: bin/sweep (FFT in Ruby, rtl_power CSV), bin/listen (C demodulator ext/demod, one clip per transmission, Whisper on a worker thread), bin/sensors (rtl_433). Run by hand; nothing is a service yet.
Speech to textwhisper.cpp small.en as user service whisper on 127.0.0.1:8178, CPU build, 8 threads. Measured today: an 11-second clip transcribed in 2.3 seconds, about 5 times faster than real time.
Local LLMOllama on 127.0.0.1:11434 with qwen3:4b on the GPU, already behind an adapter in apps/live (Claude is the second provider).
ObservabilityOpenTelemetry collector, Prometheus, Loki, Tempo and Grafana, all on 127.0.0.1, with every service listed in observability/services.yml.
Machine16 CPU threads, 30 GiB RAM, GTX 1650 Max-Q with 4 GiB, 850 GB free on NVMe.

Where the brief and reality differ

The brief saysWhat we foundSo the design
The RSP1 has limited bandwidth, so monitoring is sequential. Right. One tuner sees one slice of up to about 8 MHz (10 MHz on paper). We capture 2.048 MHz today. The real limit is worse than bandwidth: bin/sweep reopens the device every hop, about 1.4 s each, so 24 MHz to 1.7 GHz takes 25 minutes. Keeps the device open and retunes it, and tries wider slices. Both are measured before we rely on them.
Use the SDRplay API and soapysdr-module-sdrplay3. The vendor API is closed source, its installer needs root, and it runs a background service. The open source libmirisdr path already works here without root. Stays on libmirisdr. The vendor API is the optional second driver, behind the same SoapySDR interface.
(Implied) needs root to run. Nothing needs sudo when run by hand. Running headless as a service needs two one-time root steps: stop the kernel's msi2500 driver from claiming the radio, and a udev rule for the plugdev group. Both are in apps/sdr/README.md. Jay runs those two commands once; everything else stays a user service.
An old laptop with a stale Whisper model; only CPU int8 faster-whisper is viable; recompile whisper.cpp. whisper.cpp is already compiled locally and running small.en at about 5 times real time on CPU. A 90-minute net is mostly silence between transmissions, so it keeps up easily. Uses the running whisper service. No Python. A CUDA build is possible later, but the 4 GiB GPU is busy with Ollama, so Whisper stays on CPU.
InfluxDB as the time-series store. A full pass is about 210,000 frequency bins. As Prometheus series that is far too many labels; in InfluxDB it is another database to run, back up and watch. Grafana already reads SQL databases directly. Adds no InfluxDB. Raw bins stay in rtl_power CSV files, band totals go to Prometheus, and each detected signal becomes a row in the app's database, which Grafana charts. Reasons in "Where data lives".
Flask or FastAPI, Celery or RQ, Mosquitto. Our rule is Ruby first, one application until a measurement says otherwise. One Rails app with its default job queue (Solid Queue) and Hotwire pages. The C demodulator stays C.
Run DragonOS. DragonOS is a whole Ubuntu image. This machine already runs our sites and AI services, and the tools we need are built. Borrows ideas from DragonOS demos, not the OS.
Each net has an offset and CTCSS tone to tune. We only receive, so we listen on the repeater's output frequency. The offset is for transmitting. Keeps the CTCSS tone only as an optional label to tell two repeaters apart.
30-second windows with 2-second overlap. bin/listen already cuts one clip per transmission using the squelch. On a net each clip is usually one station's turn, which is what check-in extraction wants. Keeps per-transmission clips. whisper.cpp has Silero voice detection built in (--vad) for long clips; we turn it on only if a test on real net audio shows fewer made-up words.
Fingerprint by hashing the binned spectrum. A hash changes when any bin moves by noise, so the same transmitter almost never hashes the same twice. Groups detected signals by frequency, width and hour of the week instead.
LNA 30 to 40 dB, IF moderate. Those are vendor-API settings. Through libmirisdr we set one overall gain (-g 40 today). Picks a gain per band during the baseline week by measuring the noise floor against a known signal, and stores it with the band.
Rotate audio after 30 days; the disk dies first. 850 GB is free. Voice at 16 kHz mono is 115 MB per hour of open squelch. Rotates for privacy and tidiness, not space: 30 days for unscheduled clips, nets kept until Jay deletes them.

The example nets in the brief (146.820 and 147.180 MHz) are placeholders. We haven't checked they are real local nets; Jay's own list goes into the database.

The design

RSP1 ─USB─▶ radio daemon (Ruby, sole owner of the radio)
              │  plans each minute: scheduled net  ▶  manual tune  ▶  sweep
              │
              ├─ sweep ──▶ sweeps/*.csv.gz (raw bins) ───────────▶ web waterfall
              │        └─▶ signals table ────────────────────────▶ Grafana heatmap
              │
              ├─ listen ─▶ ext/demod (C) ─▶ clipper ─▶ voice/*.wav ─▶ Transcribe job
              │                          └─▶ live audio stream ─▶ browser
              │
              └─ OTLP metrics ─▶ otelcol ─▶ Prometheus ─▶ Grafana
                 one-line logs ─▶ journald ─▶ Loki ─────▶ Grafana

station web app (Rails 8, 127.0.0.1)
  pages:  channels · live audio · transcripts · net sessions · review queues · ops
  jobs:   Transcribe (whisper :8178) ▶ ExtractCheckins ▶ ExtractTraffic ▶ Summarize (Ollama or Claude)
  data:   PostgreSQL

Everything meets in the database and the file system, as the brief asked. If Whisper falls behind, jobs queue and the radio keeps working; if Grafana is down, nothing else notices.

One codebase, three processes

apps/sdr grows into the Rails app instead of a new app appearing next to it, so the CLIs, the C demodulator and their tests keep working as they do today. It runs as three systemd user services, each registered in observability/services.yml:

ServiceDoes
sdr-radioThe only process that touches the RSP1. Each minute it decides what the radio should do: a scheduled net wins, then a tune someone asked for in the web page, then the sweep. It reads requests from and writes its state to the database, so the web app never talks to the radio directly. It also serves the live audio stream on 127.0.0.1.
sdr-webRails pages and JSON, on 127.0.0.1 with its port from PORT.
sdr-jobsSolid Queue workers: transcription, extraction, summaries, retention.

Faster sweeps

The biggest gain on offer. Today each hop opens the radio, settles, captures and closes it. The daemon will keep one SoapySDR stream open and only retune between hops, calling SoapySDR's C API through the ffi gem rather than writing a C extension. If wider slices hold up over USB 2.0, a hop covers 6 MHz instead of 1.6 MHz. Neither is measured yet, so the first step is a timing test; if retuning takes 0.1 s the full pass drops from 25 minutes to under 2. The FFT stays in Ruby unless a profile shows it is the slow part.

Where data lives

DataStoreWhy
Raw sweep binsrtl_power CSV, gzipped, under SDR_DATA_DIROpen format that existing heatmap tools read; about 1.7 MB per full pass before compression.
Detected signalsPostgreSQL signals: time, centre, width, peak, dB over floor, bandA few thousand rows a day. Grafana's built-in PostgreSQL source draws the frequency-by-time heatmap, the novelty panel and the schedule miner from it.
Band totalsPrometheus via OTLP: noise floor, signals active, squelch-open seconds per band; hop time; Whisper queue depth and speedAbout 15 bands, so few series. Alerts and 3σ deviation checks are plain PromQL.
AudioWAV under SDR_DATA_DIR/voice, named {time}_{net}_{frequency}.wavProvenance in the name, as the brief asked.
Nets, sessions, transcripts, check-ins, traffic, reviews, FCC licencesPostgreSQL Relational, joined in pages and in Grafana.
Logsstdout, one line each, to journald and LokiThe same as every other service.

Every business table carries organization_id and rooftop_id from the first migration, with PostgreSQL row-level security, as our identity rules require. Today that is one organization and one station; it costs little now and a lot to add later.

Phases

Each phase ends with a check that would fail if it were done wrong.

0. Make it a service

Jay runs the two root steps from apps/sdr/README.md and installs PostgreSQL from apt. Check: bin/listen 162.550M runs as a user service with nobody logged in to the desktop and keeps transcribing NOAA weather radio.

1. Radio daemon and activity charts

The daemon, the faster sweep, the signals table, band metrics and a "Radio" Grafana dashboard: heatmap, activity per band, signals active in the last five minutes, signals first seen in the last 24 hours. Then a baseline week of collecting with no alerts. Check: hop time is reported before and after; every Detroit FM station and NOAA 162.550 MHz appear in signals on every pass.

2. Scheduled net recording

A nets table (seeded from a nets.yml in the brief's shape) with name, frequency, mode, optional CTCSS label and weekly schedule. At start time the daemon pauses the sweep, tunes, records clips under a new net_sessions row, streams live audio, and goes back to sweeping at the end. Two nets that overlap can't both be heard; the one with the higher priority wins and the other is marked missed. Check: a test schedule on NOAA weather radio produces a session with clips and a clean stop, and an overlapping test net is recorded as missed.

3. Transcription as jobs

The worker thread in bin/listen becomes a Transcribe job, so a restart loses nothing. Each transcript keeps whisper's raw text and is tagged with its clip, session and frequency. Check: through a full net, queue depth returns to zero within a minute of each transmission.

4. Check-ins and traffic

Check: Jay writes down by ear the check-ins of three recorded nets. Those lists, not the code, are the test. Auto-accepted check-ins must match them at 95% or better; everything else must land in review.

5. The web control room

Channel list from our repeater list (click to tune), a canvas waterfall from the latest sweep, live audio, a rolling and searchable transcript log, past net sessions with replay and re-transcribe, the two review queues, and an ops panel with Grafana panels for queue depth, hop time and disk. Live audio is an Ogg Opus stream from the daemon through ffmpeg, played by a plain <audio> element; we test it in Chrome and Safari before choosing it over a WebSocket feed like apps/live uses. Check: tuning from the page changes what plays within two seconds, and a request without a signed-in user gets nothing.

6. Patterns

Schedule mining from signals by hour of the week, Grafana alerts on transcript keywords and on band activity three standard deviations above its seven-day mean, unknown signals within 12.5 kHz of a known repeater, and a net summary from the local LLM through an adapter like apps/live/lib/llm.rb (Ollama first, Claude second). Check: NOAA's hourly patterns and a known weekly net show up in the schedule list without being told about them.

Staying lawful and private

Decisions for Jay

Provided as is, for information only, with no warranty and no liability. Not legal or professional advice. Security and radio material is for authorized, lawful use only. Full disclaimer.

Visits are logged on our own server: IP address, browser and device details, and pages viewed. We use them to understand traffic and spot attacks, and never share or sell them.