# 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.

- URL: https://www.alexisbouchez.com/blog/transcribing-radio-stations
- Published: 2026-11-08
- Author: Alexis Bouchez
- Tags: weekend-project, audio, transcription

I'm a news-aholic. I love projects like [World Monitor](https://github.com/koala73/worldmonitor): 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](https://github.com/alexisbcz/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](https://www.linkedin.com/in/alexisbcz). 🤣

![Five men in matching sunglasses and shorts, standing around like plainclothes agents](https://www.alexisbouchez.com/blog/feds.png "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.

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.

```go title="asr.go"
func (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](https://huggingface.co/mehdi-hf/nemotron-asr-streaming-farsi) and a [multilingual model](https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b) 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*:

```markdown title="prompts/extract.md"
- `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:

```go title="extract.go"
// 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:

```json title="/api/events"
{
  "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:

Same minute, on Vesti FM: an interview with a composer. Not everything is news, hence the filter.

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:

```go title="monitor.go"
if 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`](https://docs.astral.sh/uv/), ffmpeg, and [Ollama](https://ollama.com).

```sh title="terminal"
go install github.com/alexisbcz/radiowatch@latest
ollama pull gemma3:4b             # news extraction
ollama pull translategemma:4b     # live translation

radiowatch listen -translate English               # Radio Khabar, live, word by word
radiowatch listen -ch ru:vesti -translate English
radiowatch listen -translate English -log notes.md # keep a Markdown transcript
radiowatch monitor -stations ir:26,ru:vesti,cn:cnr1
```

`listen` 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.xml` as a custom news feed,
- add `/api/events.geojson` as a map layer,
- use `-webhook` to 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](https://github.com/alexisbcz/radiowatch).
