We plugged a software-defined radio into our AI server, swept the spectrum to see what's on the air, and turned voice channels into searchable text with the Whisper model already running there. Everything below works without root, stays on our machine, and only receives what US law lets anyone receive. Tested on 2026-10-09.
The USB device is an SDRplay RSP1 (USB ID 1df7:2500): a Mirics MSi2500
USB bridge and MSi001 tuner covering 10 kHz to 2 GHz, with up to 10 MHz of bandwidth and a 12-bit ADC.
It is not an RTL-SDR, so the usual rtl_* tools can't open it. Two driver paths exist:
~/.local with no root. This is what we use.Linux's own msi2500 driver claims the RSP1 when it's plugged in. Our tools detach it while
they run and hand it back afterwards; that works without sudo because the desktop session gives the
logged-in user access to the device.
A small Ruby app, apps/sdr, with three commands. All output is plain files: CSV, WAV
and one JSON object per line.
| Command | Does | Writes |
|---|---|---|
bin/sweep 24M:1700M | Hops across the range, measures power in 8 kHz bins, finds the noise floor and lists the signals above it. | sweeps/*.csv (rtl_power format, so
existing heatmap tools read it) |
bin/listen 146.52M | Tunes one analog voice channel, cuts it into one clip per transmission, transcribes each with Whisper. | voice/<freq>/*.wav,
voice/log.jsonl |
bin/sensors | Runs rtl_433 on 433.92 MHz (or any band) to decode weather stations, tire sensors, meters and other unencrypted devices. | sensors/<date>.jsonl |
rx_sdr (2.048 MS/s IQ, tuned 250 kHz off the channel)
→ ext/demod (C: shift to 0 Hz, decimate to 16 kHz, FM or AM, squelch)
→ Clipper (Ruby: one clip per transmission, ends after 1.5 s of quiet)
→ whisper.cpp small.en on 127.0.0.1:8178
→ voice/log.jsonl {time, frequency, mode, seconds, file, text}
The squelch opens when the channel rises 10 dB over a noise floor it tracks by itself, so there is no level to tune per band. Transcription runs on a separate thread so the radio never waits on Whisper.
The demodulator is about 80 lines of C, and we'd rather it were Ruby. We measured first: the same loop in
Ruby took 15 seconds per 10 seconds of radio, too slow to keep up, and the demodulator that ships with
libmirisdr (miri_fm) produced only noise on narrowband FM with this RSP1. Its tests feed it
synthetic FM and AM tones and check that a 1 kHz tone comes out as 1 kHz, and that the squelch stays shut on
noise and opens on a carrier.
NOAA Weather Radio on 162.550 MHz, 74 seconds received and transcribed on the first live run:
Saturday, mostly cloudy. Highs in the upper 70s. Southeast winds 5 to 10 miles per hour with gusts up to 20 miles per hour. Saturday night, cloudy. Rain likely after midnight.
A sweep of the FM broadcast band found every Detroit station we expected (90.9, 93.1, 94.7, 95.5, 96.3, 97.1, 97.9 MHz and up), each within one 8 kHz bin of its assigned frequency, so the tuner needs no frequency correction.
One full sweep on 2026-10-09: 1,048 steps of 1.6 MHz, 8 kHz bins, about 25 minutes, indoor antenna.
Each step listens for well under a second, so it catches transmitters that are always on and mostly misses
short voice calls. That's what bin/listen is for.
| Band | What showed up |
|---|---|
| 88 to 108 MHz | FM broadcast: about 20 Detroit stations, the strongest 40 to 49 dB over the noise floor (104.3, 105.1, 97.1, 96.3 MHz) |
| 162 MHz | NOAA Weather Radio, 162.550 MHz, about 35 dB up. This is the one we transcribed. |
| 470 to 700 MHz | Digital TV channels, each a block about 6 MHz wide (near 506, 578 and 590 MHz) |
| 700 to 800 MHz | Wide LTE carriers (730 and 752 MHz) and narrow channels at 776 to 778 MHz. We measure that cellular energy is there; we don't demodulate it. |
| Everywhere | Narrow spikes at exact multiples of 24.576 MHz (98.304, 122.88, 147.456, 196.608 MHz...) and of 24 MHz (120, 1440, 1680 MHz). These come from the radio's own clock, not from the air. Ignore any 8 kHz-wide hit on one of those frequencies. |
Airband (118 to 137 MHz), the 2 m ham band and public safety didn't show clearly in a single pass because
their traffic comes in bursts. The next step is longer sweeps of just those bands (bin/sweep 118M:174M --for 1h),
then pointing bin/listen at the busiest channels. The raw CSV is in
apps/sdr/data/sweeps/.
| Tool | For | Status here |
|---|---|---|
| rtl_433 | Unencrypted ISM-band sensors | Built with SoapySDR; bin/sensors |
| multimon-ng | APRS (144.390 MHz), EAS/SAME weather alerts, DTMF | Built; feed it ext/demod audio |
| AIS-catcher | Ship positions on the Detroit River and Lake Erie (161.975 / 162.025 MHz) | Proposed; supports SDRplay and SoapySDR |
| dump1090 or readsb | Aircraft positions on 1090 MHz (ADS-B) | Proposed; needs the ncurses headers (one apt package) |
| trunk-recorder | Records every call on an unencrypted P25 or SmartNet trunked system | Proposed; its recordings can go straight into the same Whisper step |
| SDRTrunk | Same, with a desktop UI | Proposed; wants the vendor API for the RSP1 |
| SDR++ | A waterfall to look and listen by hand | Proposed; desktop app |
Michigan's statewide public safety network (MPSCS) is P25, and much of it is encrypted. Trunk-recorder records only the talkgroups that aren't; we don't try to decode the rest.
Today these are commands we run by hand; nothing is left running. To make one a service, two one-time
root steps come first: stop the kernel driver from claiming the radio, and give the plugdev
group access to it. The commands are in apps/sdr/README.md. After that, each listener is a
systemd user service registered in observability/services.yml like every other service.
One radio tunes one 2 to 8 MHz slice at a time, so a single RSP1 can run one listener or one sweep, not both. A second cheap RTL-SDR dongle would let the sweep and one voice channel run at the same time.
Next: a proposal for a full station with net recording, check-in logging and a web control room.
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.