The detector, its normalisation, and an honest card
Browse files- README.md +130 -0
- melspec.py +76 -0
- skywave-segmenter-norm.npz +3 -0
- skywave-segmenter.json +17 -0
- skywave-segmenter.onnx +3 -0
README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
tags:
|
| 4 |
+
- audio-classification
|
| 5 |
+
- voice-activity-detection
|
| 6 |
+
- radio
|
| 7 |
+
- onnx
|
| 8 |
+
library_name: onnx
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# skywave-host-detect
|
| 12 |
+
|
| 13 |
+
Tells you when a radio station is talking over the music.
|
| 14 |
+
|
| 15 |
+
Audio in, a verdict every 80 ms out: `music`, or `speech`. It never sees words,
|
| 16 |
+
so nothing about it is French — but it has only ever heard one station, and that
|
| 17 |
+
matters more than the architecture does. Read the limitations before using it on
|
| 18 |
+
anything else.
|
| 19 |
+
|
| 20 |
+
620,002 parameters, 2.4 MB, 690× real time on one laptop core.
|
| 21 |
+
|
| 22 |
+
## What it is for
|
| 23 |
+
|
| 24 |
+
A music station's metadata says which record is playing. It never says that a
|
| 25 |
+
human has just started speaking over it. If you want to do anything at that
|
| 26 |
+
moment — switch to a lossless copy of the record, subtitle the announcement,
|
| 27 |
+
cut an advert, index a show — you have to detect it from the audio.
|
| 28 |
+
|
| 29 |
+
The hard part is not speech versus music. It is a **voice over a bed** versus a
|
| 30 |
+
**voice inside the record**. Both have someone talking and music at the same
|
| 31 |
+
time, which is why the usual features do not separate them:
|
| 32 |
+
|
| 33 |
+
| | margin |
|
| 34 |
+
|---|---|
|
| 35 |
+
| 4 Hz modulation energy (Scheirer & Slaney) | negative — hosts 0.244–0.278, music 0.171–0.282 |
|
| 36 |
+
| spectral flux variance | negative |
|
| 37 |
+
| pitch stability | negative |
|
| 38 |
+
| envelope dynamic range | negative |
|
| 39 |
+
| speaking rate from a transcript | +3.2% |
|
| 40 |
+
| speaker embedding | +5.5% |
|
| 41 |
+
| best AudioSet-derived score | −2.1% |
|
| 42 |
+
|
| 43 |
+
Every one of those was measured on the same labelled clips, not assumed.
|
| 44 |
+
|
| 45 |
+
## How it was trained
|
| 46 |
+
|
| 47 |
+
By distillation, from a pipeline that cannot ship.
|
| 48 |
+
|
| 49 |
+
That pipeline segments the audio, transcribes every speech region and reasons
|
| 50 |
+
about the words — how fast they arrive, whether they name the station, whether
|
| 51 |
+
they repeat the track's title. It works. It also costs seconds per window, needs
|
| 52 |
+
the language named in advance, and a station whose announcements the transcriber
|
| 53 |
+
mishears becomes a station with no announcements.
|
| 54 |
+
|
| 55 |
+
So it was used as a teacher. It labelled 28 clean hours of FIP (Paris), and this
|
| 56 |
+
model learned to reach the same verdicts from the sound alone. No hand labelling
|
| 57 |
+
was involved, which is the point: **the ASR you already have is a good enough
|
| 58 |
+
teacher to bootstrap a detector for your own station in an afternoon.** The
|
| 59 |
+
recipe is in the repository.
|
| 60 |
+
|
| 61 |
+
The student then corrected the teacher on a class of error the teacher cannot
|
| 62 |
+
see. FIP puns on its own name, and the transcriber writes "flippe" or "Philippe";
|
| 63 |
+
the rules reject those for naming no station, and the model catches them without
|
| 64 |
+
needing the word.
|
| 65 |
+
|
| 66 |
+
## Measured
|
| 67 |
+
|
| 68 |
+
Two four-hour stretches held out entirely, three seeds:
|
| 69 |
+
|
| 70 |
+
| | |
|
| 71 |
+
|---|---|
|
| 72 |
+
| announcements found | 32 or 33 of 33 |
|
| 73 |
+
| songs mistaken for the station | 1 of 15 |
|
| 74 |
+
| ordinary music wrongly flagged | 0 of 40 windows, every seed |
|
| 75 |
+
| boundaries | within 0.4 s of the teacher's |
|
| 76 |
+
|
| 77 |
+
The single miss is the same every seed: a 1.3-second station ident.
|
| 78 |
+
|
| 79 |
+
These are **end-to-end** numbers, through the segmenter as it runs, with
|
| 80 |
+
smoothing and span merging. The bare network scores better — 27 of 28, 0.93% of
|
| 81 |
+
music windows misread — and quoting that would be quoting a component rather
|
| 82 |
+
than the thing built from it. That mistake was made once here already.
|
| 83 |
+
|
| 84 |
+
## Limitations
|
| 85 |
+
|
| 86 |
+
**It has only heard FIP.** The architecture is language-agnostic; the weights
|
| 87 |
+
are not station-agnostic. It learned one production chain, one set of voices,
|
| 88 |
+
one way of mixing a voice over a bed. Expect it to need retraining on your
|
| 89 |
+
station, which is what the recipe is for.
|
| 90 |
+
|
| 91 |
+
**It cannot tell an announcer from a station ident.** It used to answer three
|
| 92 |
+
classes and was bad at the third. Relabelling the corpus by content rather than
|
| 93 |
+
duration took station dressing from 1.4 minutes to 8, the training windows from
|
| 94 |
+
88 to 1056 — and ident recall moved from 3 of 22 to 6. Almost nothing. What
|
| 95 |
+
separates them is *what is said*, and mel does not carry that. Dropping to two
|
| 96 |
+
classes moved per-window recall from 96.6% to 98.1%.
|
| 97 |
+
|
| 98 |
+
**Confidence does not predict error.** A floor looks tempting and does not work:
|
| 99 |
+
the corpus's one false positive scores 0.982, above the tenth percentile of real
|
| 100 |
+
announcements (0.939). A 0.95 floor loses four announcements and blocks nothing.
|
| 101 |
+
|
| 102 |
+
**55 minutes of speech.** That is what it learned from — 19 hours of music and
|
| 103 |
+
55 minutes of someone talking. It works because the problem is narrow, not
|
| 104 |
+
because the data is plentiful.
|
| 105 |
+
|
| 106 |
+
## Running it
|
| 107 |
+
|
| 108 |
+
```python
|
| 109 |
+
from radio_host_detect.backends.skywave import SkywaveSegmenter
|
| 110 |
+
|
| 111 |
+
for label, start, end, confidence in SkywaveSegmenter().detail("chunk.wav"):
|
| 112 |
+
print(label, start, end, confidence)
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Input is log-mel: 16 kHz mono, 400-sample window, 320 hop, 64 bands, 50–7600 Hz.
|
| 116 |
+
The shape is `(batch, 1, time, 64)` and the output `(batch, 2, time/4)` — one
|
| 117 |
+
verdict per four input frames, so every 80 ms. `melspec.py` computes it in plain
|
| 118 |
+
numpy so training and inference cannot drift apart.
|
| 119 |
+
|
| 120 |
+
## Training data
|
| 121 |
+
|
| 122 |
+
28 hours of FIP, a French public music station, captured from its public HLS
|
| 123 |
+
stream in August 2026. The audio is Radio France's and is not distributed. The
|
| 124 |
+
labels, the recipe and the tooling are.
|
| 125 |
+
|
| 126 |
+
## Citation
|
| 127 |
+
|
| 128 |
+
Built for [Skywave](https://github.com/vdeturckheim/radio-host-detect), an app
|
| 129 |
+
that plays a station's records from Apple Music and hands you back to the
|
| 130 |
+
broadcast when the station speaks.
|
melspec.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Log-mel spectrograms, in one place.
|
| 2 |
+
|
| 3 |
+
Training and inference must compute this identically. When they do not, the
|
| 4 |
+
model sees a slightly different picture in production than it was taught on and
|
| 5 |
+
degrades in a way that looks like a bad model rather than a bad pipeline — so
|
| 6 |
+
there is one implementation and both sides import it.
|
| 7 |
+
|
| 8 |
+
Plain numpy, on purpose. The alternative is librosa, which pulls in a large
|
| 9 |
+
dependency tree to compute forty lines of arithmetic, and this has to install
|
| 10 |
+
cleanly on a small droplet.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import subprocess
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
SR = 16_000
|
| 20 |
+
N_FFT = 400 # 25 ms
|
| 21 |
+
HOP = 320 # 20 ms, so 50 frames a second
|
| 22 |
+
N_MELS = 64
|
| 23 |
+
FMIN, FMAX = 50.0, 7600.0
|
| 24 |
+
FPS = SR / HOP
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _filterbank(sr=SR, n_fft=N_FFT, n_mels=N_MELS, fmin=FMIN, fmax=FMAX):
|
| 28 |
+
def to_mel(f):
|
| 29 |
+
return 2595.0 * np.log10(1.0 + f / 700.0)
|
| 30 |
+
|
| 31 |
+
def to_hz(m):
|
| 32 |
+
return 700.0 * (10.0 ** (m / 2595.0) - 1.0)
|
| 33 |
+
|
| 34 |
+
points = to_hz(np.linspace(to_mel(fmin), to_mel(fmax), n_mels + 2))
|
| 35 |
+
bins = np.floor((n_fft + 1) * points / sr).astype(int)
|
| 36 |
+
bank = np.zeros((n_mels, n_fft // 2 + 1), dtype=np.float32)
|
| 37 |
+
for i in range(n_mels):
|
| 38 |
+
left, centre, right = bins[i], bins[i + 1], bins[i + 2]
|
| 39 |
+
centre = max(centre, left + 1)
|
| 40 |
+
right = max(right, centre + 1)
|
| 41 |
+
bank[i, left:centre] = np.linspace(0, 1, centre - left, endpoint=False)
|
| 42 |
+
bank[i, centre:right] = np.linspace(1, 0, right - centre, endpoint=False)
|
| 43 |
+
return bank
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
BANK = _filterbank()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def logmel(samples: np.ndarray) -> np.ndarray:
|
| 50 |
+
"""(frames, 64) log-mel from mono float samples at 16 kHz."""
|
| 51 |
+
samples = np.ascontiguousarray(samples, dtype=np.float32)
|
| 52 |
+
if len(samples) < N_FFT:
|
| 53 |
+
return np.zeros((0, N_MELS), dtype=np.float32)
|
| 54 |
+
frames = 1 + (len(samples) - N_FFT) // HOP
|
| 55 |
+
window = np.hanning(N_FFT).astype(np.float32)
|
| 56 |
+
strided = np.lib.stride_tricks.as_strided(
|
| 57 |
+
samples, shape=(frames, N_FFT),
|
| 58 |
+
strides=(samples.strides[0] * HOP, samples.strides[0]))
|
| 59 |
+
spectrum = np.fft.rfft(strided * window, axis=1)
|
| 60 |
+
power = (spectrum.real ** 2 + spectrum.imag ** 2).astype(np.float32)
|
| 61 |
+
# The float32 matmul raises divide-by-zero, overflow and invalid on macOS
|
| 62 |
+
# regardless of the values — Accelerate's vectorised kernel sets the FP
|
| 63 |
+
# status flags from lanes past the end of the data. Checked on real audio:
|
| 64 |
+
# the largest power in a chunk is around 27 and every output is finite.
|
| 65 |
+
with np.errstate(all="ignore"):
|
| 66 |
+
energy = power @ BANK.T
|
| 67 |
+
return np.log(energy + 1e-6).astype(np.float32)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def decode(path: str) -> np.ndarray:
|
| 71 |
+
"""Any audio file to mono float32 at 16 kHz, via ffmpeg."""
|
| 72 |
+
raw = subprocess.run(
|
| 73 |
+
["ffmpeg", "-v", "error", "-i", str(path), "-f", "f32le",
|
| 74 |
+
"-ac", "1", "-ar", str(SR), "-"],
|
| 75 |
+
capture_output=True, check=True).stdout
|
| 76 |
+
return np.frombuffer(raw, dtype=np.float32)
|
skywave-segmenter-norm.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1fb448256ab233da2ea0b495048d7ed0b4035324195b24ceac26f5bc09865e91
|
| 3 |
+
size 1012
|
skywave-segmenter.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"classes": [
|
| 3 |
+
"music",
|
| 4 |
+
"speech"
|
| 5 |
+
],
|
| 6 |
+
"mode": "stream",
|
| 7 |
+
"stride": 4,
|
| 8 |
+
"sampleRate": 16000,
|
| 9 |
+
"nFft": 400,
|
| 10 |
+
"hop": 320,
|
| 11 |
+
"nMels": 64,
|
| 12 |
+
"heldOut": [
|
| 13 |
+
"h20",
|
| 14 |
+
"h12"
|
| 15 |
+
],
|
| 16 |
+
"balancedRecall": 0.9983
|
| 17 |
+
}
|
skywave-segmenter.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9a2654a39e5622da13ac774cd25d7c0ad19d5acdf02e37facdb8561a6a499c68
|
| 3 |
+
size 2478851
|