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.

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support