vdeturckheim commited on
Commit
5bb7cb9
·
verified ·
1 Parent(s): 236e0f4

The detector, its normalisation, and an honest card

Browse files
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