A station is told from hiss by evidence, not by loudness.
Plug in an FM stick, open one page, and tune. The scan calls a channel a station
only when its 19 kHz stereo pilot is actually present — static has no pilot
at all — and it refuses to call a channel a station because the receiver merely happens to be
loud there. That is the whole idea, and it is why the list it produces is short.
Every station this scan called High reception, drawn where it was found —
16 of the 28 carriers it measured, on an indoor antenna next to a computer.
The list itself is below.
The whole app, in one picture
Everything the page does, top to bottom, with nothing cropped away: the frequency, the
transport buttons, the volume, the scan's three-pass panel with the spectrum it actually
measured, and the two lists it produced. This is the page as it stands on the machine it was
written on — no radio is plugged in, so it says exactly that, refuses to capture
anything, and shows the last scan rather than pretending.
98.1 MHz · 16 stations held High · 12 Low — the same scan the list above came from,
with the log it wrote while it ran. Open it for the full size.
Why the list is short
A scan that ranks by loudness is the easiest thing to write and the least useful: on a real
receiver the noise floor is loud too, and a strong station's skirt reaches half a megahertz
either side. So this one asks a question with a factual answer instead.
The 19 kHz pilot is a fact, not an opinion
Every FM stereo transmission carries a pilot tone at 19,000 Hz. Mono broadcast and empty
noise carry none. The test measures the pilot against the noise beside it — a locked
station reads +20 dB and up, hiss reads +1.8 dB or less — and a channel is
only called a station above 15 dB.
Pure tones never make the list
A bare tone or an interfering carrier can be loud and steady and still not be a station.
The tone is recorded as evidence, never as the reason: a channel with a carrier and no
pilot stays off the list, however strong it sounds.
Two carriers 0.2 MHz apart are measured in turn
This receiver switches between states in stretches of seconds, so carriers measured one
at a time are compared against different receivers, not against each other. Nearby
carriers are therefore measured taking turns, which is the only way to say which
of the two has the transmitter on it.
The failure it refuses to make
Unplug a USB input and Linux does not report an error — it moves the streams that pointed at
it to the default input, which is usually the built-in microphone. That was measured,
not imagined: the capture read the room, a loopback played the room back into the speakers
at rising volume, and a scan would have swept the room and called it the band.
It will not capture or play unless its named input is in the audio server's own list.
It removes any loopback still pointing at a source that has gone.
A missing radio is reported as a missing radio — never answered with the microphone.
Run it
One Python file, the standard library only, in a small image. Your host supplies three things
a container cannot: a working kernel driver for the stick, the audio server it plays through,
and that server's socket.
With docker compose
cd fm-radio
docker compose up -d --build
# open http://127.0.0.1:8150/
Anything you would edit, override from the shell:
RADIO_PORT=9000 docker compose up -d ·
RADIO_DEVICE=/dev/radio1 ·
RADIO_SOURCE=alsa_input.usb-…. docker compose down keeps your saved
stations; down -v throws them away.
RADIO_BIND=0.0.0.0 is not optional in a container — a published port
arrives from outside the container's own loopback, so an app listening on 127.0.0.1
is unreachable whatever -p says. If your audio server does not use a cookie, leave
the cookie mount out: on a PipeWire machine Docker will create a directory where the file
should be, and the server then rejects every client.
When it refuses to start
error gathering device information while adding custom device "/dev/radio0" —
Docker will not start at all when a device path is missing. Drop --device
(compose: comment the line out) to read the page without a radio.
The page loads and says there is no radio — the tuner is there but the container cannot see
it. Check the host with ls -l /dev/radio0 and pass it in.
A station is found and nothing plays — the audio socket or the cookie is wrong. Check the
host with ls -l ${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native.
Settings
Variable
Default
What it does
RADIO_DEVICE
/dev/radio0
the V4L2 radio device
RADIO_SOURCE
empty
the audio input. Empty means discover it at start-up — the first
alsa_input.usb- entry in pactl list short sources. A
microphone is never chosen.
RADIO_PORT
8150
the page's port
RADIO_DATA
/data
where saved stations and the last scan are written
RADIO_V4L2 · RADIO_PAREC · RADIO_PACTL
v4l2-ctl · parec · pactl
the tool names, if they are not on PATH
What it heard here
The sixteen carriers this scan called High reception, in frequency order, exactly as the page
lists them — frequency, call sign, city and format, with the rate the pilot held and how far it
stood above the noise. Same receiver, same antenna, indoors.
Twelve more carriers were heard but not held often enough to be called stations, and they are
listed separately as Low reception rather than mixed in. The station table inside the program —
frequency, call sign, city, format — was compiled from Wikipedia's lists of radio stations, which
is why it is credited under CC BY-SA 4.0.
Questions worth asking
Why does it find fewer stations than my car radio?
Because it only lists a channel when the pilot is really there. A car radio will play a hiss
you can sit through — this list is meant to be short and trustworthy rather than long and
partly wrong.
Does it send my listening anywhere?
No. It runs on your machine, serves one page on your own loopback address, and talks to
nothing on the internet. There is no account and no cloud. The only thing on this
page that could ever contact anyone is the analytics tag, and it loads only if you allow it.
Which radios does it work with?
Any stick the Linux kernel already exposes as a V4L2 radio device at
/dev/radio0. The container cannot load a kernel module, so the driver — for one
common stick, radio_shark with tea575x — has to be working on the
host already.
What is it written in?
One Python file and nothing else — no pip installs, no numpy, no framework. The maths it needs
(a Goertzel filter for the pilot and a small FFT for the spectrum) is written out by hand.
Can I copy it?
Yes. It is MIT licensed — copy it, change it, ship it, sell it, keeping the copyright notice.
One obligation rides alongside it: the station table came from Wikipedia and stays under
CC BY-SA 4.0.
Privacy, plainly
The program collects nothing and sends nothing. It reads your radio device, plays the
audio through your own audio server, and writes your saved stations to a file you chose.
This page is where a choice exists, and it is yours: there is no analytics script in this
document at all — the tag is created by consent.js and only after you answer yes. If
you say no, this page makes zero requests to Google, and you can change your mind at any
time from in
the footer. If you allow it, the page counts visits and nothing else: no profile is built, no
advertising identifier is read, and nothing is sold.
One thing on this page sends without asking, and here is why that is not a loophole. If
the page throws where you are, it reports the error to this site — to
/api/fault on this same address, nowhere else — so that a breakage on your machine
can be found instead of staying invisible for ever. It sets no cookie and keeps nothing: what it
has already sent is a variable that dies with the tab. It carries no visitor id, no browser
string and no referrer, and every URL in it is stripped of its query, so nothing you typed into
an address bar travels.