FM radio nodejavascript.com

A local web page for a USB FM radio

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.

Get the source Run it in a container

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.

The app's single page: a large frequency readout of 98.1 MHz, a stereo signal bar, a banner saying no radio is connected, Play, Stop, Mute and Save buttons, a volume slider, a Saved stations panel, the three-pass scan panel with a 88 to 108 MHz spectrum chart, and below it the High-reception list of sixteen stations and the Low-reception list of twelve.
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.

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.

With plain docker

docker build -t fm-radio .
docker run -d --name fm-radio \
  --device /dev/radio0:/dev/radio0 \
  -p 8150:8150 \
  -e RADIO_BIND=0.0.0.0 \
  -e RADIO_DATA=/data \
  -e PULSE_SERVER=unix:/pulse/native \
  -e XDG_RUNTIME_DIR=/pulse \
  -v fm-radio-data:/data \
  -v "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native:/pulse/native" \
  --restart unless-stopped \
  fm-radio

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

Settings

VariableDefaultWhat it does
RADIO_DEVICE/dev/radio0the V4L2 radio device
RADIO_SOURCEempty 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_PORT8150the page's port
RADIO_DATA/datawhere 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.

88.1CIND-FMindie rockheld 13/14 · +28.5 dBToronto
88.9CIRV-FMmultilingualheld 14/14 · +30.5 dBToronto
89.5CIUT-FMcampus radioheld 11/14 · +32.2 dBToronto
90.3CJBC-FMpublic musicFrenchheld 14/14 · +32.9 dBToronto
92.5CKIS-FMCHRheld 12/14 · +39.8 dBToronto
94.1CBL-FMpublic musicheld 14/14 · +52.6 dBToronto
95.3CING-FMadult contemporaryheld 12/14 · +54.2 dBHamilton
96.3CFMZ-FMclassicalheld 14/14 · +36.4 dBToronto
97.3CHBM-FMclassic hitsheld 14/14 · +36.7 dBToronto
97.7CHTZ-FMactive rockheld 14/14 · +34.5 dBSt. Catharines
98.1CHFI-FMadult contemporaryheld 14/14 · +34.0 dBToronto
100.7CHIN-FMmultilingualheld 14/14 · +39.8 dBToronto
102.1CFNY-FMmodern rockheld 14/14 · +40.4 dBBrampton
102.9CKLH-FMadult hitsheld 11/14 · +37.4 dBHamilton
103.5CIDC-FMCHRheld 14/14 · +32.8 dBOrangeville
107.9CJXY-FMactive rockheld 11/14 · +34.8 dBBurlington

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.