skywave-host-detect
Tells you when a radio station is talking over the music.
Audio in, a verdict every 80 ms out: music, or speech. It never sees words,
so nothing about it is French β but it has only ever heard one station, and that
matters more than the architecture does. Read the limitations before using it on
anything else.
620,002 parameters, 2.4 MB, 690Γ real time on one laptop core.
What it is for
A music station's metadata says which record is playing. It never says that a human has just started speaking over it. If you want to do anything at that moment β switch to a lossless copy of the record, subtitle the announcement, cut an advert, index a show β you have to detect it from the audio.
The hard part is not speech versus music. It is a voice over a bed versus a voice inside the record. Both have someone talking and music at the same time, which is why the usual features do not separate them:
| margin | |
|---|---|
| 4 Hz modulation energy (Scheirer & Slaney) | negative β hosts 0.244β0.278, music 0.171β0.282 |
| spectral flux variance | negative |
| pitch stability | negative |
| envelope dynamic range | negative |
| speaking rate from a transcript | +3.2% |
| speaker embedding | +5.5% |
| best AudioSet-derived score | β2.1% |
Every one of those was measured on the same labelled clips, not assumed.
How it was trained
By distillation, from a pipeline that cannot ship.
That pipeline segments the audio, transcribes every speech region and reasons about the words β how fast they arrive, whether they name the station, whether they repeat the track's title. It works. It also costs seconds per window, needs the language named in advance, and a station whose announcements the transcriber mishears becomes a station with no announcements.
So it was used as a teacher. It labelled 28 clean hours of FIP (Paris), and this model learned to reach the same verdicts from the sound alone. No hand labelling was involved, which is the point: the ASR you already have is a good enough teacher to bootstrap a detector for your own station in an afternoon. The recipe is in the repository.
The student then corrected the teacher on a class of error the teacher cannot see. FIP puns on its own name, and the transcriber writes "flippe" or "Philippe"; the rules reject those for naming no station, and the model catches them without needing the word.
Measured
Two four-hour stretches held out entirely, three seeds:
| announcements found | 32 or 33 of 33 |
| songs mistaken for the station | 1 of 15 |
| ordinary music wrongly flagged | 0 of 40 windows, every seed |
| boundaries | within 0.4 s of the teacher's |
The single miss is the same every seed: a 1.3-second station ident.
These are end-to-end numbers, through the segmenter as it runs, with smoothing and span merging. The bare network scores better β 27 of 28, 0.93% of music windows misread β and quoting that would be quoting a component rather than the thing built from it. That mistake was made once here already.
Limitations
It has only heard FIP. The architecture is language-agnostic; the weights are not station-agnostic. It learned one production chain, one set of voices, one way of mixing a voice over a bed. Expect it to need retraining on your station, which is what the recipe is for.
It cannot tell an announcer from a station ident. It used to answer three classes and was bad at the third. Relabelling the corpus by content rather than duration took station dressing from 1.4 minutes to 8, the training windows from 88 to 1056 β and ident recall moved from 3 of 22 to 6. Almost nothing. What separates them is what is said, and mel does not carry that. Dropping to two classes moved per-window recall from 96.6% to 98.1%.
Confidence does not predict error. A floor looks tempting and does not work: the corpus's one false positive scores 0.982, above the tenth percentile of real announcements (0.939). A 0.95 floor loses four announcements and blocks nothing.
55 minutes of speech. That is what it learned from β 19 hours of music and 55 minutes of someone talking. It works because the problem is narrow, not because the data is plentiful.
Running it
Two runtimes, and they agree to float32 epsilon β the mel matches frame for frame at 9.5e-07, and on a real announcement both produce 137 decisions with identical verdicts. That is a committed fixture and a test, not a claim.
JavaScript, which is what ships. A live stream is a pipe and three listeners:
import { spawn } from "node:child_process";
import { load } from "radio-host-detect/node";
import { DetectorStream } from "radio-host-detect";
const { session, meta } = await load();
const detector = new DetectorStream({ session, meta });
detector
.on("speech", (e) => console.log("the station is speaking", e.confidence))
.on("growing", (e) => console.log("still going", (e.end - e.start).toFixed(0), "s"));
spawn("ffmpeg", ["-v", "error", "-i", url,
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"])
.stdout.pipe(detector);
An announcement is reported while it is still being spoken and comes back as it grows, rather than once it is over. That is the point of the whole design: the detector runs about eleven seconds behind the broadcast and a listener on the same stream about thirty-four, so it is known roughly twenty seconds before anyone hears it, and waiting would spend the only advantage there is.
Python, which is what trains:
from radio_host_detect.backends.skywave import SkywaveSegmenter
for label, start, end, confidence in SkywaveSegmenter().detail("chunk.wav"):
print(label, start, end, confidence)
Or the raw graph. melspec.js and melspec.py are both here, and they are
the same function twice rather than the same idea twice β that is the part worth
copying rather than rewriting. Input is log-mel: 16 kHz mono, 400-sample window, 320 hop,
64 bands, 50β7600 Hz, normalised per band by the mean and std in
skywave-segmenter-norm.npz. The shape is (batch, 1, time, 64) and the output
(batch, 2, time/4) β one verdict per four input frames, so every 80 ms.
Decode it the way it was trained
ffmpeg -i input -f f32le -ac 1 -ar 16000 -
-ac 1 divides a stereo pair by β2, not by 2 β its matrix normalises to
preserve power. Every hour of the training corpus came through that command, and
the model reads absolute level because log-mel is normalised against fixed
constants. Averaging the channels instead is three decibels quieter, and three
decibels was measured to be the difference between finding an announcement and
reporting silence.
The transform matters too: a 400-sample frame is a 400-point DFT. Padding to 512 because it is a nicer size gives different numbers, and different numbers mean the model is looking at a picture it was never trained on.
Training data
28 hours of FIP, a French public music station, captured from its public HLS stream in August 2026. The audio is Radio France's and is not distributed. The labels, the recipe and the tooling are.
Citation
Built for Skywave, an app that plays a station's records from Apple Music and hands you back to the broadcast when the station speaks.