·6 min read
Transcribing radio stations
I spent a weekend teaching my laptop to listen to Iranian, Russian and Chinese state radio with NVIDIA Nemotron, and to tell me when they say something worth knowing.
- weekend-project
- audio
- transcription
On this page
I'm a news-aholic. I love projects like World Monitor: one screen, every feed, a map that lights up when something happens. Also, it makes me feel like I'm in a sci-fi movie. Not giving that up.
This weekend I built my own. One that listens to state radio in Iran, Russia, and China, and tells me when they say something.
It's called radiowatch. It runs 24/7, on my laptop, with no GPU.
Btw, if you're from the CIA and reading this, I'm currently looking for a job. 🤣
The recruitment team, probably.
Why radio
State radio sometimes says things first. A strike. An explosion. A casualty count. A provincial station can mention it hours before the wire services, if they ever do.
The catch: it's audio, in Persian, Russian, or Mandarin, and it never stops. A job for a computer.
Press play to hear it, live subtitles included.
→ Newsweek says eight months after the US attack on Iran, the war has worn down the global economy and made the military goals harder to reach.
In Iran, radiowatch listens to all of IRIB: about sixty national and provincial stations. In Russia and China, just the main state networks, eight and six. Here's how one sentence on one of them becomes news.
From a stream to words
First, clean audio. ffmpeg turns every stream into 16 kHz mono, and reconnects when it drops.
12345678func (s *asrServer) OpenURL(ctx context.Context, url, lang string) (*asrStream, error) { args := []string{"-v", "error"} if strings.HasPrefix(url, "http") && !strings.Contains(url, ".m3u8") { args = append(args, "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "30") } args = append(args, "-i", url, "-ar", "16000", "-ac", "1", "-f", "s16le", "pipe:1") return s.open(lang, exec.Command("ffmpeg", args...), false)}Then NVIDIA's Nemotron transcribes it, live. Words appear a second after they're spoken. Whisper waits 30 seconds. Rude.
I use a Persian fine-tune and a multilingual model for the rest. I didn't pick them on vibes:
| Model | Word error rate | Speed (lower is faster) |
|---|---|---|
| Nemotron streaming, 1.12 s look-ahead | 12.5% | 0.067 |
| Nemotron streaming, 0.32 s look-ahead | 14.5% | 0.065 |
| Vosk small fa 0.42 | 14.9% | 0.073 |
| Whisper large-v3-turbo | 32.3% | 0.55 |
Most accurate, and ten times faster than Whisper. Easy call.
Every place mentioned lands on a map, in any script. «تنگه هرمز» becomes the Strait of Hormuz.
Turning talk into news
A transcript isn't news. Talk radio is mostly people guessing. Every five minutes, a small local model reads it and keeps only what happened:
1234567- `event_report`: the station says something HAS HAPPENED, as news.- `official_statement`: an official or institution said something.- `threat`: someone threatens a future action.- `analysis`: presenters or guests discuss scenarios, possibilities, capabilities. Words like could, might, scenario, possible, if, would = analysis.- `foreign_media_citation`: the station retells what a foreign outlet wrote.When in doubt between `event_report` and anything else, do NOT choose `event_report`."The US could strike" is a guess, not news. Now the model knows too.
Don't trust the model
Small models make things up. Every item has to quote the transcript, word for word, and radiowatch checks:
1234567891011// quoteFound reports whether the quote really comes from the transcript: most// of its words occur there, and at least one exact three-word run does (for// scripts without spaces, such as Chinese, three-character runs).func quoteFound(quote, transcript string) bool { if isCJK(transcript) { return runsFound([]rune(compact(quote)), []rune(compact(transcript)), 3) } q := strings.Fields(normFa(quote)) t := strings.Fields(normFa(transcript)) // ...at least 70% of the words, plus one exact three-word run}No quote, no news.
مقامات ایرانی هشدار دادند که هرگونه کارزار جدید منجر به واکنشی بیسابقه خواهد شد که میتواند شامل هدف قرار دادن کابلهای زیردریایی در تنگه هرمز
Here's a real one from Saturday night, on Radio Khabar:
«مقامات ایرانی هشدار دادند که هرگونه کارزار جدید منجر به واکنشی بیسابقه خواهد شد که میتواند شامل هدف قرار دادن کابلهای زیردریایی در تنگه هرمز … بشود»
"Iranian officials have warned that any new campaign will lead to an unprecedented response, which could include targeting the undersea cables in the Strait of Hormuz."
A few minutes later, radiowatch had this:
12345678910{ "title": "Iran Warns of Unprecedented Response to New Conflict", "kind": "threat", "category": "military", "importance": 3, "claimedBy": "Mohsen Rezai", "quoteVerified": true, "station": { "id": "ir:26", "name": "Radio Khabar (news)" }, "reliability": "Claim broadcast by state media; automatically transcribed and extracted; not independently verified."}The quote is right. The label is right. The speaker is wrong: Rezaei talks one sentence later. That's why the transcript always stays attached.
What it actually hears
Enough theory. I recorded these while writing this post. Radio Khabar, reading Newsweek:
«…جنگ را به اقتصاد امریکا و زندگی روزمره شهروندان منتقل کرده است. نیوزویک تأکید میکند پس از گذشت هشت ماه از حمله امریکا به ایران، جنگ اقتصاد جهانی را فرسوده، نارضایتی داخلی را افزایش داده و با انزوای دیپلماتیک دستیابی به اهداف نظامی را دشوارتر کرده است.»
“…has carried the war into the American economy and the daily lives of its citizens. Newsweek stresses that eight months after the US attack on Iran, the war has worn down the global economy, raised discontent at home and, with diplomatic isolation, made the military goals harder to reach.”
Same minute, on Vesti FM: an interview with a composer. Not everything is news, hence the filter.
«…современных композиторских техник, и здесь уже нужен коуч. Вот у них обычно бывает коуч по языку, а здесь нужен ещё коуч по исполнению современных приёмов. И, возможно, не все готовы к этому.»
“…modern composition techniques, and that’s where you need a coach. They usually have a language coach, but here you also need a coach for performing modern techniques. And maybe not everyone is ready for that.”
Earlier that night, the transcripts got more interesting. Iran, on US losses:
«بر اساس گزارش سرویس تحقیقات کنگره ارتش ایالات متحده از آغاز جنگ تاکنون هشتاد و یک فروند هواگرد را از دست داده یا دچار آسیب کرده است … بیست و یک نفر از پرسنل نظامی آمریکا در این جنگ کشته شدند»
"According to a Congressional Research Service report, since the start of the war the US military has lost or had damaged eighty-one aircraft… Twenty-one American military personnel have been killed in this war."
And on Iran's deepest nuclear site:
«محتمل ترین هدف ما کوه کلنگ است مکانی که تاسیسات در عمق بسیار زیاد در آن قرار دارند و تاکنون نیز مورد حمله واقع نشده»
"The most likely target is Kuh-e Kolang [Pickaxe Mountain, next to Natanz], a site whose facilities lie very deep and which hasn't been attacked so far."
Both are talk shows retelling a report, so both get filed as analysis. No alert.
Russia, on a shooting in Poland:
«Он был одет военный камуфляж в бронежилет и имел при себе длинноствольное оружие военного типа … министр обороны Польши косеня Каммиш, он специально опроверг предположение, что стрелявший был военнослужащим»
"He was dressed in military camouflage and a bulletproof vest and carried a military-style long-barrelled weapon… Poland's defence minister specifically denied the suggestion that the shooter was a soldier."
China, on the war:
«以色列国防部长卡词十一月八号称, 以色列对伊朗的任务尚未完成。… 伊朗伊斯兰革命卫队海军八号表示, 又有一艘游轮试图穿越霍尔穆斯海峡为经伊朗授权的航线发生爆炸, 并引发剧列火灾。»
"Israeli Defence Minister Katz said on November 8 that Israel's mission against Iran is not finished… The IRGC Navy said on the eighth that another tanker trying to cross the Strait of Hormuz on a route not authorised by Iran exploded and caught fire."
A tanker exploding in Hormuz. That's exactly what radiowatch is for. It's also full of typos, which brings me to the details.
The details that mattered
Simulcasts. IRIB plays the same show on three channels. radiowatch spots it and extracts it once:
123if len(set) > 0 && common*10 >= len(set)*6 { return r.name // same programme as another station: extract it once}Names are hard. Kosiniak-Kamysz became «косеня Каммиш». An oil tanker became a cruise ship. Katz became "Ehud Barak."
It's all state media. Every item is a claim, never a fact.
The laptop has limits. Three stations, tops. Want more, get a GPU.
Try it yourself
You need Go, uv, ffmpeg, and Ollama.
12345678go install github.com/alexisbcz/radiowatch@latestollama pull gemma3:4b # news extractionollama pull translategemma:4b # live translation
radiowatch listen -translate English # Radio Khabar, live, word by wordradiowatch listen -ch ru:vesti -translate Englishradiowatch listen -translate English -log notes.md # keep a Markdown transcriptradiowatch monitor -stations ir:26,ru:vesti,cn:cnr1listen gives you live subtitles for one station. monitor watches many, with a dashboard and feeds:
| Endpoint | What you get |
|---|---|
/ | live dashboard: items, station status, scrolling transcript |
/api/events | items as JSON, filterable by since, station, kind, breaking |
/api/events.geojson | the same items as points for a map layer |
/feed.xml, /feed/breaking.xml | RSS 2.0 |
/api/transcript?station=ir:26 | the live transcript |
Not sure where to start? ir:26 (Iran's news channel), ru:vesti, and cn:cnr1. Provincial stations break local news first.
Back to World Monitor
Full circle. The feeds plug straight into World Monitor:
- add
/feed.xmlas a custom news feed, - add
/api/events.geojsonas a map layer, - use
-webhookto push breaking items the moment radiowatch extracts them.
One rule: three IRIB channels saying the same thing is one source. Not three.
What's next
More countries. A GPU. Then world domination.
The code is on GitHub.