← JayCorp

Radio scanner with local transcription

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 radio

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:

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.

What we built

A small Ruby app, apps/sdr, with three commands. All output is plain files: CSV, WAV and one JSON object per line.

CommandDoesWrites
bin/sweep 24M:1700MHops 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.52MTunes one analog voice channel, cuts it into one clip per transmission, transcribes each with Whisper.voice/<freq>/*.wav, voice/log.jsonl
bin/sensorsRuns rtl_433 on 433.92 MHz (or any band) to decode weather stations, tire sensors, meters and other unencrypted devices.sensors/<date>.jsonl

The voice pipeline

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.

It works

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.

What's on the air: one pass, 24 MHz to 1.7 GHz

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.

BandWhat showed up
88 to 108 MHzFM 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 MHzNOAA Weather Radio, 162.550 MHz, about 35 dB up. This is the one we transcribed.
470 to 700 MHzDigital TV channels, each a block about 6 MHz wide (near 506, 578 and 590 MHz)
700 to 800 MHzWide 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.
EverywhereNarrow 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/.

Software worth adding

ToolForStatus here
rtl_433Unencrypted ISM-band sensors Built with SoapySDR; bin/sensors
multimon-ngAPRS (144.390 MHz), EAS/SAME weather alerts, DTMFBuilt; feed it ext/demod audio
AIS-catcherShip positions on the Detroit River and Lake Erie (161.975 / 162.025 MHz)Proposed; supports SDRplay and SoapySDR
dump1090 or readsbAircraft positions on 1090 MHz (ADS-B)Proposed; needs the ncurses headers (one apt package)
trunk-recorderRecords every call on an unencrypted P25 or SmartNet trunked systemProposed; its recordings can go straight into the same Whisper step
SDRTrunkSame, with a desktop UI Proposed; wants the vendor API for the RSP1
SDR++A waterfall to look and listen by handProposed; 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.

Staying lawful

Running it all the time

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.