Records an internet radio stream in hourly segments and transcribes the speech in each hour to searchable text — skipping music so the transcriber doesn't hallucinate over songs — alongside per-hour quality checks and a best-effort schedule tag, all stored in one JSON sidecar per hour.
The station is not hardcoded — the stream URL, schedule URL, and related
settings live in config.json (see Configuration). The
repo ships config.example.json with generic placeholder URLs; copy it to
config.json and edit it to point at your station. The author runs this
against UCLA Radio, but any station that publishes
an Icecast-style MP3 stream should work.
| Script | Job |
|---|---|
config.py |
Loads config.json (station-specific settings). |
archive.py |
Records the stream into hourly .mp3 files. Recording only. |
transcribe.py |
Per-hour processing: transcript + quality checks + schedule hint, written to one .txt + .json sidecar. |
quality.py |
Shared audio quality checks (size / silence / volume / decode errors) + ffmpeg/ffprobe location + is_off_air helper. |
schedule_archive.py |
Archives the published show schedule daily and parses it into structured snapshots. |
merge_archives.py |
Reconciles two parallel-recorder archives into one canonical tree (winner-takes-all per hour, plus cross-fill splicing for partially-bad hours). |
purge_silent.py |
Tombstone-style cleanup: deletes the .mp3 for sidecars marked is_off_air, keeps .json and .txt. Default dry-run. |
music_id.py |
Best-effort song identification for music-suspected regions via an AcoustID→ACRCloud cascade. Writes a music_hint block into each sidecar. Default dry-run; --granular splits long sets into individual tracks. |
monthly_index.py |
Builds a browsable per-month HTML index from the sidecars — day by day, hour by hour, showing the scheduled show and music playlist. Pure stdlib, no network. |
- OS: Windows, Linux, or macOS (incl. Apple Silicon). The code is cross-platform; per-OS specifics are in §10.
- Python 3.11–3.13 (developed on 3.13).
- ffmpeg + ffprobe — used by
archive.pyfor recording, and bytranscribe.py/quality.pyfor the silence check and the full null-muxer decode pass that catches mid-stream frame errors. (Whisper itself decodes audio via the bundled PyAV/avlibrary, so if ffmpeg is missing transcription still works — quality just records atool_error.) - ~250 MB disk for the Whisper model (downloaded automatically on first run).
- No GPU required. Transcription runs on CPU. An NVIDIA GPU can optionally accelerate it; Intel/AMD integrated GPUs are not usable by faster-whisper. See §6 Design decisions & tradeoffs.
| OS | Command |
|---|---|
| Windows | winget install Gyan.FFmpeg |
| macOS | brew install ffmpeg |
| Debian/Ubuntu | sudo apt install ffmpeg |
If ffmpeg/ffprobe aren't on PATH, they're auto-discovered from the usual
install dirs — winget/chocolatey/scoop on Windows, /opt/homebrew/bin,
/usr/local/bin, /usr/bin, /snap/bin on Linux/macOS — or you can point at
them with the FFMPEG/FFPROBE env vars (see _find_tool in quality.py and
§10).
Uses uv for environment + package management.
Install uv first if you don't have it (pipx install uv, brew install uv, or
the standalone installer at https://docs.astral.sh/uv/getting-started/installation/).
# from the project directory
uv venv # creates .venv/
# activate the venv
# Windows (PowerShell): .\.venv\Scripts\Activate.ps1
# macOS/Linux: source .venv/bin/activate
uv pip install faster-whisperfaster-whisper pulls in everything the transcriber needs, including
onnxruntime (for the bundled Silero VAD) and av (for audio decoding). No
PyTorch or TensorFlow required.
venv binary path. Once activated, just use
python. When calling the venv directly (e.g. in a scheduled job), the interpreter lives at.venv\Scripts\python.exeon Windows and.venv/bin/pythonon Linux/macOS. Examples below use the activatedpython; the service templates in §10 use the full path.
Versions this was built/tested against (as of 2026-06):
faster-whisper 1.2.1
ctranslate2 4.7.2
onnxruntime 1.26.0
av 17.0.1
To reproduce exactly on another machine, pin these in a requirements.txt:
faster-whisper==1.2.1
(That single pin drags in compatible ctranslate2 / onnxruntime / av.)
First-run model download: the first transcription downloads the model (
small.en, ~250 MB) from Hugging Face into~/.cache/huggingface. After that it runs fully offline. On Windows you may see a harmless symlink warning — caching still works, it just uses a bit more disk.
Station-specific settings live in config.json at the project root — the
code references config keys only, never a specific station. Copy the template
and edit it:
cp config.example.json config.json # then edit the URLsThis is exactly what config.example.json contains:
{
"label": "My Radio Station",
"stream": { "url": "https://stream.example.com/listen/my_station/radio.mp3", "bitrate_kbps": 192 },
"schedule": { "url": "https://example.com/shows/", "source_type": "r34ics" },
"paths": { "archive_dir": "archive", "schedule_dir": "schedule" }
}| Key | Meaning |
|---|---|
label |
Display name, used only in log lines. |
stream.url |
The MP3 stream to record. (This /listen/<station>/ mount is the AzuraCast convention; any Icecast-style MP3 URL works — recording is framework-agnostic.) |
stream.bitrate_kbps |
Stream bitrate, used by the size quality-check to estimate the expected file size. |
schedule.url |
The page that publishes the show schedule. |
schedule.source_type |
Which schedule adapter to use. Only r34ics is implemented — it scrapes the ICS Calendar / r34ics WordPress plugin's AJAX-rendered week grid. Other systems would add a new adapter (see §9). |
paths.archive_dir / paths.schedule_dir |
Output directories. |
stream.url and schedule.url are required; everything else has a default. The
config path can be overridden with the RADIO_CONFIG env var (handy for running
multiple stations from one checkout). Algorithm settings that aren't
station-specific (Whisper model, VAD/silence thresholds, segment length) stay as
code defaults / env vars — see §8.
If you want transcription, run from the venv (where faster-whisper lives). Plain
python archive.pystill records fine without it.
Run the recorder and the transcriber as separate long-running processes. The recorder only records; the transcriber watches for completed hours. This keeps a live capture from ever needing a restart when you change transcription, and lets you stop/upgrade transcription freely.
python archive.py # process 1: record only (never needs restarting)
python transcribe.py --watch # process 2: transcribe each hour as it completes--watch scans archive/ periodically, transcribing any completed segment that
lacks a transcript, and leaves the in-progress hour alone until it's done.
A file is treated as in-progress when its mtime is recent and its size is
below the expected full-hour size; the check is applied to every candidate
so .partN rotation siblings don't fool it. Ctrl+C stops it cleanly after
the current file.
python archive.py # record forever, hourly segments (recording only)
python archive.py --test # record one 60-second sample, then exit
python archive.py --transcribe # single-process alternative: record AND transcribe
# in one process (couples their lifecycles — prefer
# the two-process setup above)In --test mode the output is a single fixed-duration capture (ffmpeg -t 60)
that lands in archive/test-YYYY-MM-DD_HH-MM-SS.mp3 at the top of the archive
directory — so test files don't mix with the hourly archive/YYYY/MM/ tree
and successive runs don't overwrite each other. The startup log line "Output:"
prints the exact path it'll use.
Output lands in archive/YYYY/MM/YYYY-MM-DD_HH-00.mp3 — files are grouped
by year and month so a multi-year archive stays browsable. The .txt and
.json sidecars sit next to each .mp3 in the same subdirectory.
Recording logs go to archiver.log. Quality results are stored per hour in
the transcript JSON (see §4), not in a separate log — to
(re-)compute quality for files lacking a transcript, just transcribe them.
Legacy flat archives still work.
transcribe.py,archive.py --transcribe, andmerge_archives.pyfind files at any depth under the archive directory (recursive glob), so an existing flatarchive/YYYY-MM-DD_HH-00.mp3layout is read transparently alongside the new nested layout. Migrating old files intoarchive/YYYY/MM/is optional and can be done with a one-shot shell script when convenient.
If archive.py (or the underlying ffmpeg) restarts mid-hour — script
upgrade, reboot, stream drop, anything — the existing in-progress
YYYY-MM-DD_HH-00.mp3 is renamed to YYYY-MM-DD_HH-00.partN.mp3 before
the new ffmpeg invocation, so the new ffmpeg writes the canonical name
fresh instead of truncating the old one. N is the smallest free integer,
so multiple restarts within the same hour stack as .part0, .part1, …
archive/2026/06/
2026-06-04_01-00.part0.mp3 # first run, 01:31–01:45
2026-06-04_01-00.part1.mp3 # second run, 01:45–01:52
2026-06-04_01-00.mp3 # third run, 01:55–02:00 (the live one)
2026-06-04_02-00.mp3 # third run continued past the hour rollover
transcribe.py recognises the .partN suffix:
schedule_hintstill gets the right hour (the suffix is stripped before the timestamp is parsed).quality.analyzeis called withpartial=True, which keeps the measured size/silence/decode values but setssize.ok = Noneso the short-file check doesn't flag it as a stream outage.- The
.txtheader showsquality: PARTIAL HOUR (NN.N MB)instead ofSMALL SEGMENT.
Each .partN.mp3 gets its own pair of sidecars, so a restart-y hour
produces multiple transcripts — no automatic merge yet; if you want one
combined file per hour, ffmpeg -f concat -c copy can splice them after
the fact (this is what merge_archives.py does for redundant-recorder
splices).
python transcribe.py FILE.mp3 [FILE2.mp3 ...] # specific files
python transcribe.py --all # every archive/*.mp3 lacking a transcript
python transcribe.py --all --force # re-transcribe everything
python transcribe.py --watch # run continuously (see above)--all and --watch both skip the in-progress hour and any file that already
has a .json sidecar, so they're idempotent and resumable — safe to run
anytime (recorder running or not); rerun and they continue where they left off.
Only --force re-transcribes existing files.
When the services are under systemd with Restart=always, you can ask them
to swap in newly-deployed code without sudo by touching a sentinel file:
touch /tmp/RADIO_ARCH_RESTART_REQUIREDWhat happens:
transcribe.py --watchnotices within ~1 second, finishes the current file (or exits immediately if idle), and exits. systemd brings it back up within a few seconds with whatever code is now on disk.archive.pynotices within ~60 seconds, schedules its exit for the next clock-hour boundary + 5 seconds so the in-progress hour is fully captured. ffmpeg gets the same graceful-shutdown signal as a manualsystemctl stop. On restart, the brief next-hour file (5 seconds of audio) is rotated to.part0.mp3and the new ffmpeg writes the canonical filename fresh — exactly the existing.partNrecovery flow.
Worst-case wait for the recorder is just under one hour (touch at HH:00:01).
Best case is seconds (touch at HH:59:59). If you need an immediate restart,
sudo systemctl restart radio-archive still works.
Mtime-based detection means the sentinel left over on disk between touches doesn't keep re-triggering: each service baselines the file's mtime at startup and reacts only to a strictly newer mtime, so a touch only triggers one restart cycle. The recorder is responsible for unlinking the sentinel when it finally exits — by then the (much faster) transcriber has already responded, so there's no race where the sentinel disappears before the recorder sees it.
| Variable | Default | Effect |
|---|---|---|
WHISPER_MODEL |
small.en |
Model size. base.en = faster/rougher; medium.en = slower/more accurate. Drop .en for non-English. |
WHISPER_COMPUTE |
int8 |
Compute type — passed through to CTranslate2. Common values: int8 (default, fast on CPU), int8_float32, float16 (CUDA), float32 (slower, slightly more accurate). |
# example: higher accuracy
WHISPER_MODEL=medium.en python transcribe.py --all # macOS/Linux
$env:WHISPER_MODEL="medium.en"; python transcribe.py --all # PowerShellFor each archive/2026/05/2026-05-24_12-00.mp3, the transcriber writes two
sidecars next to it (same subdirectory):
2026-05-24_12-00.txt — human-readable:
# Transcript: 2026-05-24_12-00.mp3
# model=small.en faster-whisper=1.2.1 talk=96% segments=49 generated=2026-05-25T22:10:08+00:00
# quality: OK
# schedule hint (per published schedule, may differ): Some Show with DJ X (12:00-13:00)
# Full quality/settings/versions/machine/schedule recorded in the .json sidecar.
[0:00:00 -> 0:00:08] were sort of their militants was kind of a fallout of...
[0:00:08 -> 0:00:13] I found a New York Times article from 1976 that was...
2026-05-24_12-00.json — structured, for search/programmatic use, with a
full provenance block so a future reader can decide whether re-transcribing
(newer model, stronger machine, different params) is worthwhile:
{
"schema_version": 3,
"file": "2026-05-24_12-00.mp3",
"generated_utc": "2026-05-25T22:18:39+00:00",
"audio_seconds": 3599.9,
"speech_seconds": 412.3,
"talk_ratio": 0.115,
"language": "en",
"language_probability": 1.0,
"transcribe_seconds": 77.2,
"realtime_factor": 46.6,
"segment_count": 87,
"quality": {
"expected_seconds": 3600,
"partial": false,
"size": { "actual_mb": 86.4, "expected_mb": 86.4, "ratio": 1.0, "ok": true },
"silence_periods": [
{ "start": 0.0, "end": 219.7, "duration": 219.7 }
],
"max_volume_db": 0.0,
"mean_volume_db": -10.7,
"decode_errors": null,
"decode_exit_code": 0,
"tool_error": null,
"thresholds": { "size_ratio_warn": 0.8, "silence_threshold": "-40dB", "silence_min_secs": 10, "stream_bitrate_kbps": 192, "off_air_peak_db": -30, "off_air_min_audio_s": 3300 },
"is_off_air": false,
"ok": false
},
"schedule_hint": {
"source": "https://example.com/shows/",
"note": "Best-effort match from the published weekly schedule; the actual broadcast may differ and is not guaranteed.",
"snapshot_date": "2026-05-26",
"snapshot_after_recording": true,
"day_of_week": "Sunday",
"listed_shows": [
{ "start": "12:00", "end": "13:00", "title": "Some Show with DJ X", "status": "confirmed" }
]
},
"provenance": {
"settings": {
"model": "small.en", "device": "cpu", "compute_type": "int8",
"vad_filter": true, "vad_parameters": { "threshold": 0.6, "...": "..." },
"beam_size": 5, "condition_on_previous_text": false
},
"versions": {
"python": "3.13.13", "faster_whisper": "1.2.1",
"ctranslate2": "4.7.2", "onnxruntime": "1.26.0", "av": "17.0.1"
},
"machine": {
"platform": "Windows-11-10.0.26200-SP0",
"processor": "Intel64 Family 6 ...", "cpu_count": 14
}
},
"segments": [
{ "start": 12.34, "end": 18.9, "text": "..." }
]
}Field notes for future evaluation:
realtime_factor= audio seconds ÷ wall-clock seconds (higher = faster). Lets you estimate the cost of re-running with a heavier model or on another machine.provenance.settings= the model + decode/VAD parameters used; compare against what a newer/bigger model would offer.provenance.versions/provenance.machine= the exact libraries and hardware, so you can tell whether a result predates a model/library upgrade.provenance.backfilled: truemarks sidecars whose provenance was reconstructed after the fact (settings/versions were unchanged, so the values are accurate; the flag just means they weren't emitted at original run time).schema_versionguards the layout — bumped if the JSON shape changes (1 = transcript + provenance; 2 = addedschedule_hint; 3 = addedquality).quality= the per-hour audio checks (computed byquality.py):size(actual vs expected bytes — catches stream outages),silence_periods(dead air ≥ threshold),max_volume_db/mean_volume_db(peak / mean amplitude in dBFS, fromvolumedetect), anddecode_errors(full ffmpeg null-muxer decode pass — every frame is decoded so mid-stream corruption is caught, not only unopenable containers;decode_exit_coderecords ffmpeg's return code).okis true only if all pass;tool_erroris set instead if ffmpeg/ffprobe was unavailable.thresholdsrecords the limits used, so a future reader knows how it was judged.is_off_air= true only when the file is positively identified as broadcast-side carrier noise:max_volume_dbbelow theoff_air_peak_dbthreshold (−30 dB by default, so inaudible to a listener),speech_seconds == 0(Silero VAD found nothing), audio duration ≥off_air_min_audio_s(a near-full hour, not a.partNpartial), and notool_error. The intent is strict: an instrumental music hour with peaks at 0 dB is not off-air; even one VAD frame keeps the file. See §12 for the purge workflow.schedule_hint= a non-authoritative guess at the show for that hour, from the archived schedule (see §9). Names are chosen to avoid implying authority:listed_shows= "what the published grid listed", not what aired.snapshot_after_recording: truewarns the only schedule snapshot available postdates the recording (so it may not reflect that week). Any lookup problem appears asschedule_hint.errorrather than failing the run.
A music-only hour writes valid sidecars with segment_count: 0 and a
(no speech detected) note, so it isn't re-processed on the next pass.
Time formats differ between the sidecars. The
.txtheader usesH:MM:SS(e.g.[0:00:08 -> 0:00:13]) for human readability; the.jsonstoresstart/endas raw seconds (rounded to 2 decimals) so downstream tooling doesn't have to parse a clock string.
process 1: archive.py process 2: transcribe.py --watch
┌───────────────────────────┐ ┌──────────────────────────────────────┐
│ ffmpeg segment muxer │ │ poll archive/ for a COMPLETED hour │
│ -c copy, clock-aligned 1h, │ .mp3 │ lacking a .json sidecar │
│ auto-reconnect │ ───────▶ │ │ │
│ archive/YYYY/MM/...mp3 │ files │ ├─ Silero VAD ─▶ speech ─▶ faster- │
└───────────────────────────┘ │ │ (drops music/silence) whisper │
records only; never needs │ ├─ quality.py: size/silence/errors │
restarting for transcription │ └─ schedule_hint (schedule_archive) │
│ │ │
│ ▼ .txt + .json (one/hour) │
└──────────────────────────────────────┘
- Uses ffmpeg's segment muxer with
-segment_atclocktimeso files break on the clock hour, and-c copyso the MP3 is saved without re-encoding (cheap, lossless passthrough). -reconnectflags make ffmpeg recover from stream drops automatically; the outer loop also restarts ffmpeg if it exits.- It does no per-hour processing — that's
transcribe.py's job. (The optional--transcribeflag runs that processing inline via a watcher thread for a single-process setup, but the recommended setup keeps them separate.)
- Silero VAD first (bundled with faster-whisper, runs via onnxruntime). It isolates speech and discards music/silence before anything reaches Whisper. This is the key step that prevents hallucinated lyrics/garbage over songs.
- faster-whisper transcribes only the speech regions and remaps timestamps back onto the original hour's timeline.
- Quality checks (
quality.py) and aschedule_hintare computed for the same hour and folded into the one JSON sidecar — so each hour has a single record, written by a single process (no write races), and skipped on restart if the.jsonalready exists (the checks are never redundantly re-run). - The model is a lazily-loaded singleton, so a
--all/--watchrun loads it once and reuses it. --watchdecouples this from recording: it pollsarchive/for completed-but-untranscribed segments (skipping the in-progress hour), so the recorder never has to restart when you change transcription. Recommended runtime;archive.py --transcriberemains as a single-process alternative.
- Shared, pure functions:
analyze(path)returns a structured dict (size / silence / decode-error results) and never raises — a missing ffmpeg/ffprobe is reported astool_error. Decode errors come from a full ffmpeg null-muxer pass (-v error -i FILE -f null -), so mid-stream frame errors are caught, not only unopenable containers. Also houses the winget-aware ffmpeg/ffprobe locator used byarchive.pyfor recording.
- Loads
config.jsononce at import (raising a clear error if it's missing or if a required URL is absent), so the other modules can read station-specific values without re-parsing.
- The shows page renders its calendar client-side via the
r34ics"ICS Calendar" plugin, so the static HTML is an empty container. The script GETs the page (for a fresh nonce + args), then POSTs toadmin-ajax.phpto get the rendered week grid — exactly what the browser does. - It saves the raw page HTML, raw calendar HTML, and a parsed JSON snapshot
per day. Raw HTML is always kept, so a future format change can be re-parsed
retroactively (
--reparse). - Standard-library only (
urllib+html.parser) — no extra dependencies.
Why faster-whisper (not openai-whisper or whisper.cpp). CTranslate2-backed, so it's fast on CPU, quantizes to int8, and needs neither PyTorch nor TensorFlow. Lightest path to a working CPU pipeline.
Why CPU + int8.
The workload is batch — one hour of audio per hour of wall-clock — so CPU is
plenty even without a discrete GPU (the dev box runs talk-heavy audio at
~10× realtime and music hours near-instantly, since VAD discards them
before transcription). faster-whisper can't use Intel Arc / AMD integrated
GPUs, so on a machine without NVIDIA hardware CPU+int8 is the only path.
On a machine with an NVIDIA GPU, set device="cuda" and
compute_type="float16" in transcribe.py for a large speedup.
Why Silero VAD instead of a speech/music classifier. The purpose-built option, inaSpeechSegmenter (true speech-vs-music labels), pulls in TensorFlow, which we wanted to avoid to keep the install lightweight. Silero VAD (bundled with faster-whisper, zero extra deps) solves the core problem: instrumental music and silence are dropped cleanly.
- Limitation: VAD detects voice activity, not music specifically. Songs
with prominent sung vocals can occasionally be sent to Whisper, producing
messy lyric-ish lines. The VAD
thresholdis set conservatively (0.6) to reduce this. If clean speech/music separation matters, add inaSpeechSegmenter (needs a TensorFlow-compatible Python) and gate Whisper on its "speech" regions.
Model choice (small.en).
English-only is faster and more accurate on English speech than the
multilingual model, and avoids language-detection wobble on music. base.en is
faster but rougher; medium.en is notably more accurate but slower. Override
with WHISPER_MODEL.
Hallucination mitigations (in transcribe.py):
vad_filter=True— the big one; no audio without detected speech reaches Whisper.condition_on_previous_text=False— stops runaway repetition loops.- Conservative VAD threshold and minimum speech/silence durations.
No music identification. This pipeline transcribes talk; it does not identify songs. That needs audio fingerprinting — open-source Chromaprint/AcoustID (weak indie/college-radio coverage) or commercial ACRCloud / AudD APIs (built for broadcast monitoring). Not implemented here.
- Install Python 3.11–3.13, ffmpeg, and uv.
uv venvand activate it.uv pip install faster-whisper(oruv pip install -r requirements.txt).- Copy the example config and edit the URLs (required — the scripts won't
import without
config.json):- macOS / Linux:
cp config.example.json config.json - Windows (PowerShell):
Copy-Item config.example.json config.jsonThen set yourstream.url/schedule.url.
- macOS / Linux:
python archive.py --test— confirms ffmpeg works and records a 60s sample.python transcribe.py <that-test-file>.mp3— first run downloads the model; confirms transcription works.- For ongoing capture + transcription, run two processes:
python archive.py(record only) andpython transcribe.py --watch(transcribe as hours complete). See §3. - (Optional)
python schedule_archive.pyto grab a schedule snapshot, then add the daily scheduler task (§9). - (Optional, NVIDIA GPU) edit
transcribe.py:device="cuda",compute_type="float16". - (Optional) if you are running a second redundant recorder, see
§11 for the merge tool. Run it from
the venv — it imports
transcribe.pyand so needs faster-whisper.
Station-specific settings live in config.json — see
Configuration. That's where the stream URL, schedule URL,
bitrate, paths, and label come from.
Algorithm defaults (not station-specific) are editable constants at the top of each file:
quality.py — DEFAULT_BITRATE_KBPS (192 fallback; the real value comes
from config.json), SIZE_RATIO_WARN (0.80), SILENCE_THRESHOLD (-40dB),
SILENCE_MIN_SECS (10), EXPECTED_SEGMENT_SECONDS (3600).
transcribe.py — MODEL_SIZE (also WHISPER_MODEL env), DEVICE,
COMPUTE_TYPE (also WHISPER_COMPUTE env), and VAD_PARAMETERS.
archive.py — SEGMENT_SECONDS (3600), RECONNECT_DELAY.
The published schedule at the configured schedule.url is archived and parsed
by schedule_archive.py, then used to tag transcripts (the schedule_hint
field — see §4). The default r34ics source adapter targets
the ICS Calendar WordPress plugin; pick whatever URL your station publishes its
schedule at and put it in config.json.
Not authoritative. Shows are sometimes changed without the schedule being updated, and the page's format has changed many times. Treat every schedule field as "what was listed", never "what aired". Field names reflect this (
schedule_hint,listed_shows) and anoterepeats the caveat in the data.
python schedule_archive.py # fetch + archive today's snapshot
python schedule_archive.py --force # re-fetch even if today's exists
python schedule_archive.py --reparse # re-parse today's saved calendar HTML
python schedule_archive.py --reparse-all # re-parse EVERY archived day (see below)
python transcribe.py --retag # refresh schedule_hint on existing
# transcripts (no re-transcription)
--forceis undo-safe. Before refetching it rotates the existingYYYY-MM-DD.page.html/.calendar.html/.jsontrio to*.bak. If the fetch fails, the previous trio is restored from.bak— so a bad re-fetch never destroys a good snapshot. On success the.bakfiles are left behind for a one-deep manual undo.
The shows page has changed format many times and will again. To keep the whole
archive regenerable forever, parsing is built as a registry of per-format
parsers in schedule_archive.py:
- Each format era is a
ScheduleParsersubclass withdetect(html)(does this parser recognize the format?) andparse(html)(extract the week grid). The current one isR34icsWeekGridParser, version2026-05_r34ics_week_grid. select_parser()picks the right parser for any HTML blob by detection, so a given day is always handled by the parser for its era. Each snapshot records theparser_versionthat produced it (and so does each transcript'sschedule_hint).- Parsers are never deleted. When the format changes:
- Write a new
ScheduleParsersubclass with adetect()specific to the new markup and aparse()for it. - Append it to the
PARSERSlist — do not touch the existing parsers. - Run
python schedule_archive.py --reparse-allto regenerate every day's JSON from the saved raw HTML; old days match old parsers, new days match the new one. python transcribe.py --retagto refresh transcript hints.
- Write a new
So in, say, 2035 with 15 historical formats, all 15 parsers coexist and
--reparse-all rebuilds the entire history correctly — because the raw HTML for
every day was archived and each parser still recognizes its own era. If no
parser matches a file, that day's snapshot just records parse_ok: false with an
error and keeps its raw HTML, waiting for a parser to be added.
A parser whose detect() raises is logged at warning level by
select_parser() and skipped, so a buggy detector for a new format is
debuggable from the logs rather than silently masking other parsers.
schedule_archive.py archives one dated snapshot per run. Schedule it once a day
with the OS scheduler:
- Windows (Task Scheduler):
(Replace
schtasks /create /tn "Radio Schedule" /tr ` "<project-dir>\.venv\Scripts\python.exe <project-dir>\schedule_archive.py" ` /sc daily /st 04:00
<project-dir>with the absolute path to your checkout, e.g.C:\Users\you\radio-archiver.) - macOS/Linux (cron):
0 4 * * * cd /path/to/proj && .venv/bin/python schedule_archive.py— cron runs with a minimalPATH; if ffmpeg isn't found, prependFFMPEG=/usr/bin/ffmpeg FFPROBE=/usr/bin/ffprobeor setPATH(see §10). For a managed setup, prefer the systemd timer there.
Because tagging uses the nearest snapshot on or before each recording, the
more days you archive, the more accurate historical tagging becomes. Early
snapshots that postdate existing recordings are flagged
snapshot_after_recording: true. After the daily job has run for a while, you
can python transcribe.py --retag to re-tag older transcripts against the
better-matched snapshots.
2026-05-26.page.html raw shows page (source of the AJAX nonce/args)
2026-05-26.calendar.html raw rendered week grid (the actual schedule HTML) — the
re-parse source of record; never deleted
2026-05-26.json parsed snapshot: parser_version + parse_ok/parse_error +
week_grid[dow] -> [ {start,end,title,status} ]
The raw .calendar.html is the durable source of truth — --reparse-all rebuilds
the .json files from it at any time. A snapshot records which parser_version
produced it; if the format changes and no parser matches, the snapshot records
parse_ok: false with an error but the raw HTML is still saved for later
re-parsing once a parser is added.
Runs on Windows, Linux, and macOS (incl. Apple Silicon). The code is cross-platform (pathlib, UTF-8 everywhere, guarded signals); the only OS-specific piece is ffmpeg/ffprobe discovery, handled below.
ffmpeg / ffprobe discovery. quality.py resolves the tools in this order:
the FFMPEG / FFPROBE env vars (full path) → PATH → well-known dirs (winget/
chocolatey/scoop on Windows; /opt/homebrew/bin, /usr/local/bin, /usr/bin,
/snap/bin on Unix).
Minimal-PATH gotcha (cron / launchd / systemd). Background schedulers run with a stripped-down
PATHthat often omits/opt/homebrew/binor/usr/local/bin, so a tool that works in your shell can be "not found" under a job. The well-known-dirs fallback covers the common cases; if your ffmpeg is elsewhere, setFFMPEG/FFPROBE(orPATH) explicitly in the job — every template below does this.
Apple Silicon / no GPU. Everything runs CPU/int8, exactly like the Intel dev
box — faster-whisper/ctranslate2/onnxruntime ship arm64 wheels. There's no CUDA
path on macOS; don't set device="cuda".
HuggingFace symlink warning on first model download is Windows-only; Linux/macOS support symlinks and won't show it.
Graceful shutdown. archive.py traps SIGINT and SIGTERM and asks ffmpeg
to stop cleanly so the in-progress hour's MP3 is closed with a proper
trailer. On POSIX this is plain SIGTERM; on Windows the recorder launches
ffmpeg with CREATE_NEW_PROCESS_GROUP and sends CTRL_BREAK_EVENT for the
same effect — so Ctrl+C on Windows now stops cleanly without truncating the
in-progress segment. If ffmpeg ignores the graceful stop, the recorder falls
back to terminate() after 10 s and kill() after another 5 s.
venv interpreter path: .venv\Scripts\python.exe (Windows) vs
.venv/bin/python (Linux/macOS) — used in the templates below.
The recorder and the --watch transcriber are long-running; the schedule fetch
is daily. Replace /home/you/radio-archiver (or /Users/you/...), the User,
and the ffmpeg path to match your box.
Linux — systemd (drop in /etc/systemd/system/, or ~/.config/systemd/user/
and use systemctl --user):
# radio-archive.service — records the stream
[Unit]
Description=Radio archiver
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=you
WorkingDirectory=/home/you/radio-archiver
Environment=FFMPEG=/usr/bin/ffmpeg FFPROBE=/usr/bin/ffprobe
ExecStart=/home/you/radio-archiver/.venv/bin/python archive.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target# radio-transcribe.service — transcribes each finished hour
[Unit]
Description=Radio transcriber (--watch)
After=network-online.target
[Service]
Type=simple
User=you
WorkingDirectory=/home/you/radio-archiver
Environment=FFMPEG=/usr/bin/ffmpeg FFPROBE=/usr/bin/ffprobe
ExecStart=/home/you/radio-archiver/.venv/bin/python transcribe.py --watch
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target# radio-schedule.service + radio-schedule.timer — daily schedule snapshot
# --- radio-schedule.service ---
[Unit]
Description=Radio schedule snapshot
[Service]
Type=oneshot
User=you
WorkingDirectory=/home/you/radio-archiver
ExecStart=/home/you/radio-archiver/.venv/bin/python schedule_archive.py
# --- radio-schedule.timer ---
[Unit]
Description=Daily radio schedule snapshot
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl enable --now radio-archive.service radio-transcribe.service
sudo systemctl enable --now radio-schedule.timer
journalctl -u radio-transcribe -f # follow logsmacOS — launchd (LaunchAgents in ~/Library/LaunchAgents/). One plist per
long-running process — use KeepAlive for the recorder and watcher; for the
daily fetch swap KeepAlive/RunAtLoad for StartCalendarInterval:
<!-- ~/Library/LaunchAgents/com.radio.transcribe.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.radio.transcribe</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/radio-archiver/.venv/bin/python</string>
<string>transcribe.py</string><string>--watch</string>
</array>
<key>WorkingDirectory</key><string>/Users/you/radio-archiver</string>
<key>EnvironmentVariables</key>
<dict>
<key>FFMPEG</key><string>/opt/homebrew/bin/ffmpeg</string>
<key>FFPROBE</key><string>/opt/homebrew/bin/ffprobe</string>
</dict>
<key>KeepAlive</key><true/>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>/Users/you/radio-archiver/transcribe.out.log</string>
<key>StandardErrorPath</key><string>/Users/you/radio-archiver/transcribe.err.log</string>
</dict></plist><!-- daily schedule fetch: same shape, but instead of KeepAlive/RunAtLoad use -->
<key>ProgramArguments</key>
<array>
<string>/Users/you/radio-archiver/.venv/bin/python</string>
<string>schedule_archive.py</string>
</array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>0</integer></dict>launchctl load ~/Library/LaunchAgents/com.radio.transcribe.plist # start
launchctl unload ~/Library/LaunchAgents/com.radio.transcribe.plist # stopMake a recorder plist the same way (...archive.plist, args archive.py).
Windows — run the two long-running processes from the venv (see §3); for the daily schedule fetch use the Task Scheduler command in §9.
For continuity across reboots / power outages / network blips, you can run two
machines recording the same stream in parallel and reconcile the results into
one canonical tree afterwards with merge_archives.py. Both recorders use
-segment_atclocktime, so 2026-05-27_16-00.mp3 on either side covers the
same wall-clock window — the filename is the merge key. The script never
mutates the source directories and writes everything into a fresh output
directory.
Run from the venv.
merge_archives.pyimportstranscribe, which needs faster-whisper, so launch it from the activated venv (or with the venv's python explicitly). Phase 2 only re-runs the transcriber when it has to regenerate the JSON for a freshly-spliced file.
The merge is two phases:
- Winner-takes-all per hour. For each hour key, the per-hour JSON
sidecar's
qualityblock is the oracle: prefer the side withquality.ok, then the highersize.ratio, then less total silence. The chosen side's.mp3/.json/.txttrio is copied to the output. - Cross-fill splice (default ON;
--no-spliceto disable). When both candidates are bad, the script picks the good portion from each side using theirsilence_periods, concatenates them losslessly with ffmpeg-c copy, and re-runs the transcriber on the merged audio so the new JSON describes what's actually there.
python merge_archives.py \
--archive-a /path/to/A/archive --archive-b /path/to/B/archive \
--archive-out /path/to/merged \
[--schedule-a /path/to/A/schedule --schedule-b /path/to/B/schedule \
--schedule-out /path/to/merged_schedule] \
[--no-splice] [--dry-run] [--force-out] [--report merge_log.json]A merge report (merge_log.json in the output dir by default) records every
per-hour decision — the chosen side, the reason, and a compact view of each
candidate — so the merge is auditable after the fact.
Schedule dirs (--schedule-*) merge in parallel with simpler rules: prefer
parse_ok, then higher event_count per date.
Some stations don't broadcast 24/7 — overnight blocks may be carrier hiss, station-tone loops, or true silence rather than programming. The recorder captures these hours indistinguishably at full bitrate (~86 MB/hour at 192 kbps), so an unattended deployment can accumulate a lot of disk usage on non-content.
quality.py measures peak / mean amplitude in dBFS via volumedetect in
the same ffmpeg pass as silencedetect. Combined with the existing Silero
VAD speech count, an hour is positively identified as off-air when ALL
of these hold:
max_volume_db < OFF_AIR_PEAK_DB(default-30 dBFS— well below any music programming, which routinely peaks at0 dBFS)speech_seconds == 0(Silero VAD detected no speech)audio_seconds >= OFF_AIR_MIN_AUDIO_S(default3300 s— a near-full hour; partial.partNfiles are explicitly excluded by theirpartialflag too)tool_errorisNone(the measurement itself is trustworthy)
The criteria are deliberately strict — instrumental music with peaks at
0 dB stays even when there's no speech, and a single VAD frame is enough
to keep a file regardless of volume. The intended false-positive rate is
zero. quality.is_off_air() is the single source of truth; the
boolean lands in each sidecar as quality.is_off_air.
The .txt header shows quality: OFF-AIR (peak X dB) for these files,
and the body line below the header says (no speech detected — off-air signal, no broadcast content) instead of the music-only-hour wording.
python purge_silent.py # dry-run, walks the whole archive
python purge_silent.py --apply # actually deletes
python purge_silent.py FILE.json [...] # check specific sidecarspurge_silent.py deletes only the .mp3 for sidecars where
quality.is_off_air == true. The .json and .txt sidecars are
kept as tombstones — they remain searchable, document what was
recorded (peak / mean volume, silence breakdown, schedule_hint), and
prevent the transcriber from re-processing the missing .mp3 on the
next --watch poll. After deletion:
- The sidecar gets a new
quality.mp3_deleted_utcfield (ISO date). - A
# off-air detected; mp3 deleted YYYY-MM-DD UTCline is appended to the.txt.
The purge is safe to run repeatedly — already-tombstoned hours are
skipped (no .mp3 to delete). Run it from cron / a systemd timer after
your B2 rotation if you want off-air hours never to even reach cloud
storage.
Older sidecars produced before this feature don't have max_volume_db
or is_off_air. Backfill them with:
python transcribe.py --retag--retag recomputes the quality block for any sidecar missing the new
volume fields, then re-evaluates is_off_air. Idempotent — running it
again on already-up-to-date sidecars is a no-op except for the schedule
hint refresh.
The transcriber gates Whisper behind Silero VAD, so music regions are deliberately skipped during transcription (it would just hallucinate lyric-ish gibberish over instrumentals). That leaves a known gap: the station plays a lot of music we don't say anything about in the sidecars.
music_id.py is a separate post-pass that fills that gap best-effort.
For each hour:
- Compute "music-suspected" regions = complement of
segments[](Whisper speech) andquality.silence_periods[]. Anything else is presumed music. - For each music region ≥ 30 s, extract a chunk via ffmpeg, compute a
Chromaprint perceptual fingerprint via
fpcalc, and look it up in the free AcoustID database. - Write a
music_hintblock into the sidecar — same naming convention asschedule_hint(best-effort, gracefully nullable, prosenotefield carrying the caveat).
sudo apt install libchromaprint-tools # provides `fpcalc`
pip install pyacoustid # in the venv
# Sign up at https://acoustid.org/ for a free API key, then:
export ACOUSTID_API_KEY='your-key-here'python music_id.py FILE.json # dry-run on a single sidecar
python music_id.py FILE.json --apply # actually look up + write
python music_id.py --all # dry-run across the archive
python music_id.py --all --apply # backfill everything
python music_id.py FILE.json --apply --force # re-run on an already-ID'd hour
python music_id.py FILE.json --granular --apply # split a long set into tracksDefault mode is dry-run: it computes the music regions and lists
them but does not call a provider or modify any sidecar. Pass --apply
to actually do the work.
--granular changes the segmentation: instead of fingerprinting each
music region once (which labels a continuous back-to-back set with a single
song), it slides a probe across regions longer than 10 minutes and splits
them into individual tracks. This costs many more API calls — dry-run first
to see the per-hour volume. A "granular": true marker is written into the
music_hint.
"music_hint": {
"source": "acoustid",
"note": "Best-effort song identification via Chromaprint/AcoustID; coverage of indie/college-radio music is uneven.",
"fpcalc_version": "fpcalc version 1.5.1",
"pyacoustid_version": "1.3.0",
"looked_up_utc": "2026-06-07T20:30:00+00:00",
"threshold": 0.85,
"segments": [
{
"start": 234.5,
"end": 437.1,
"duration": 202.6,
"lookups": [
{ "provider": "acoustid", "status": "ok", "score": 0.92 }
],
"match": {
"source": "acoustid",
"score": 0.92,
"title": "Some Song Title",
"artists": ["Some Artist"],
"release": "Album Name",
"recording_id": "<musicbrainz UUID>"
}
},
{
"start": 437.1,
"end": 612.0,
"duration": 174.9,
"lookups": [
{ "provider": "acoustid", "status": "no_match" }
],
"match": null
}
]
}The lookups[] array is the audit trail of what was attempted; match
is the chosen result (or null if nothing met the score threshold). The
schema is forward-compatible with adding a paid provider as a cascade —
when added, each segment's lookups[] array gains a second entry, no
existing fields change.
AcoustID is crowdsourced — coverage is good for mainstream music, much weaker for indie and college-radio fare. Realistic hit rate for a college station is in the 30–50% range. Non-matches are still useful data: the sidecar records that the hour had N distinct music segments totalling X minutes, even if only some were identified by title/artist.
monthly_index.py turns the JSON sidecars into a browsable static HTML page
per month — day by day, then hour by hour — so the archive can be skimmed
without any tooling. For each hour it shows two independent best-effort views:
- the scheduled show (from
schedule_hint— "what the grid listed", or "No scheduled show"), and - the playlist (from
music_hint—Artist — Title (M:SS)for an identified song,Unidentified song (M:SS)otherwise; off-air hours say so).
It reads only on-disk sidecars and makes no network calls; it's pure standard library, so it needs nothing beyond Python.
python monthly_index.py ARCHIVE_DIR # all months + a root index
python monthly_index.py ARCHIVE_DIR --month 2026-06 # just one month
python monthly_index.py ARCHIVE_DIR --title "My Station Archive"It writes index.html into each YYYY/MM directory (plus a root index.html
linking the months). mp3 links are emitted by filename, so they resolve
wherever the index sits next to the audio. Re-run it any time to refresh.
MIT — see LICENSE.