duclvQ commited on
Commit
6638839
Β·
verified Β·
1 Parent(s): f7f30d2

Add full model card: metrics, labels, baseline comparison, data licensing, long-file helper

Browse files
Files changed (1) hide show
  1. README.md +314 -25
README.md CHANGED
@@ -5,50 +5,339 @@ tags:
5
  - audio
6
  - music
7
  - speech
 
 
8
  - custom-code
9
  license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- # SMAD CRNN
13
 
14
- SMAD is an audio classification model for identifying speech, music, singing,
15
- and non-vocal content in short audio segments. It is designed for lightweight
16
- audio analysis workflows where fast, practical content categorization is
17
- needed.
18
 
19
- ## Load by model id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  ```python
22
- import torch
23
  from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
24
 
25
  model_id = "duclvQ/smad"
 
 
 
 
26
 
27
- feature_extractor = AutoFeatureExtractor.from_pretrained(
28
- model_id,
29
- trust_remote_code=True,
30
- )
31
- model = AutoModelForAudioClassification.from_pretrained(
32
- model_id,
33
- trust_remote_code=True,
34
- ).eval()
35
-
36
- # `audio` must be mono float32 audio sampled at 16 kHz. For longer files, run
37
- # this over 4-second windows.
38
  inputs = feature_extractor(audio, sampling_rate=16000, return_tensors="pt")
39
  with torch.no_grad():
40
  logits = model(**inputs).logits
41
- probs = torch.softmax(logits / model.config.temperature, dim=-1)
42
 
43
- label_id = int(probs.argmax(-1)[0])
44
- label = model.config.id2label[label_id]
45
- confidence = float(probs[0, label_id])
46
  ```
47
 
48
- For arbitrary file paths, load/resample with `librosa`:
 
 
 
 
 
49
 
50
  ```python
51
- import librosa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- audio, _ = librosa.load("clip.mp3", sr=16000, mono=True)
 
 
 
 
 
 
54
  ```
 
5
  - audio
6
  - music
7
  - speech
8
+ - singing-voice-detection
9
+ - speech-music-discrimination
10
  - custom-code
11
  license: mit
12
+ language:
13
+ - en
14
+ - vi
15
+ metrics:
16
+ - accuracy
17
+ - f1
18
+ model-index:
19
+ - name: smad-crnn
20
+ results:
21
+ - task:
22
+ type: audio-classification
23
+ name: Speech / music / singing discrimination
24
+ dataset:
25
+ type: custom
26
+ name: SMAD synthetic test split (4,000 clips, source-disjoint)
27
+ metrics:
28
+ - type: accuracy
29
+ value: 0.8795
30
+ name: Accuracy
31
+ - type: f1
32
+ value: 0.8791
33
+ name: Macro F1
34
  ---
35
 
36
+ # SMAD CRNN β€” speech / music / singing discrimination
37
 
38
+ A 834k-parameter CRNN that labels a 4-second audio clip as **spoken voice over
39
+ noise**, **spoken voice over music**, **sung voice over music**, or **no voice**.
40
+ Trained from scratch on log-mel spectrograms β€” no pretrained backbone, no
41
+ fine-tuning. Runs on CPU.
42
 
43
+ **β–Ά [Try it β€” upload a file, get a colour-coded timeline](https://huggingface.co/spaces/duclvQ/smad-demo)**
44
+
45
+ The point of the taxonomy: most speech/music segmenters merge "someone is
46
+ talking over a music bed" with "someone is singing", or call singing *music* and
47
+ stop there. SMAD separates those, which is what you need for lyric/dialogue
48
+ routing, dubbing QC, or MV vs. interview classification.
49
+
50
+ ## Labels
51
+
52
+ | id | label | meaning |
53
+ |----|-------|---------|
54
+ | 0 | `speech_noise` | spoken voice over non-music background (noise, ambience, silence) |
55
+ | 1 | `speech_music` | spoken voice over a music bed |
56
+ | 2 | `singing_music` | sung voice (lyrics) over music |
57
+ | 3 | `none` | no human voice: instrumental music, noise, or silence |
58
+
59
+ Input: mono, 16 kHz, 4-second windows. Output: 4 logits; divide by
60
+ `config.temperature` (0.7095) before softmax for calibrated probabilities.
61
+
62
+ ## Quick start
63
 
64
  ```python
65
+ import librosa, torch
66
  from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
67
 
68
  model_id = "duclvQ/smad"
69
+ feature_extractor = AutoFeatureExtractor.from_pretrained(model_id, trust_remote_code=True)
70
+ model = AutoModelForAudioClassification.from_pretrained(model_id, trust_remote_code=True).eval()
71
+
72
+ audio, _ = librosa.load("clip.mp3", sr=16000, mono=True) # mono float32 @ 16 kHz
73
 
 
 
 
 
 
 
 
 
 
 
 
74
  inputs = feature_extractor(audio, sampling_rate=16000, return_tensors="pt")
75
  with torch.no_grad():
76
  logits = model(**inputs).logits
77
+ probs = torch.softmax(logits / model.config.temperature, dim=-1)[0]
78
 
79
+ label_id = int(probs.argmax())
80
+ print(model.config.id2label[label_id], float(probs[label_id]))
 
81
  ```
82
 
83
+ The feature extractor pads or truncates to exactly 4 seconds. For anything
84
+ longer, use the helper below.
85
+
86
+ ## Audio of any length: sliding window + smoothing
87
+
88
+ Copy-paste. No dependencies beyond `librosa`, `numpy`, `torch`, `transformers`.
89
 
90
  ```python
91
+ import librosa, numpy as np, torch
92
+
93
+
94
+ def analyze(path, model, feature_extractor,
95
+ hop_seconds=1.0, smooth_windows=5, batch_size=64):
96
+ """Label a file of any length. Returns merged segments:
97
+ [{"start": 0.0, "end": 12.0, "label": "singing_music", "confidence": 0.93}, ...]
98
+
99
+ hop_seconds step between windows (window itself is fixed at 4 s by training)
100
+ smooth_windows median filter width over time; 1 disables smoothing
101
+ """
102
+ sr = feature_extractor.sampling_rate
103
+ win = int(model.config.segment_seconds * sr)
104
+ hop = max(int(hop_seconds * sr), 1)
105
+
106
+ audio, _ = librosa.load(path, sr=sr, mono=True)
107
+ if len(audio) < win:
108
+ audio = np.pad(audio, (0, win - len(audio)))
109
+ starts = list(range(0, len(audio) - win + 1, hop))
110
+
111
+ probs = []
112
+ for i in range(0, len(starts), batch_size):
113
+ batch = [audio[s:s + win] for s in starts[i:i + batch_size]]
114
+ inputs = feature_extractor(batch, sampling_rate=sr, return_tensors="pt")
115
+ with torch.no_grad():
116
+ logits = model(**inputs).logits
117
+ probs.append(torch.softmax(logits / model.config.temperature, dim=-1))
118
+ probs = torch.cat(probs).numpy()
119
+
120
+ # Median filter across time: kills isolated one-window flips, which are the
121
+ # dominant error mode on real audio (a single drum fill read as `none`).
122
+ if smooth_windows > 1:
123
+ pad = smooth_windows // 2
124
+ padded = np.pad(probs, ((pad, pad), (0, 0)), mode="edge")
125
+ probs = np.stack([np.median(padded[i:i + smooth_windows], axis=0)
126
+ for i in range(len(probs))])
127
+
128
+ # Merge runs of equal labels. A run ends where the next one starts, so the
129
+ # returned segments tile the audio without overlapping -- consecutive
130
+ # windows overlap by design whenever hop_seconds < 4.
131
+ segments = []
132
+ for start, p in zip(starts, probs):
133
+ cid = int(p.argmax())
134
+ if segments and segments[-1]["_id"] == cid:
135
+ segments[-1]["_conf"].append(float(p[cid]))
136
+ else:
137
+ segments.append({"start": start / sr, "_id": cid, "_conf": [float(p[cid])]})
138
+
139
+ duration = len(audio) / sr
140
+ for cur, nxt in zip(segments, segments[1:]):
141
+ cur["end"] = nxt["start"]
142
+ segments[-1]["end"] = min(starts[-1] / sr + win / sr, duration)
143
+
144
+ return [{"start": round(s["start"], 2), "end": round(s["end"], 2),
145
+ "label": model.config.id2label[s["_id"]],
146
+ "confidence": round(float(np.mean(s["_conf"])), 4)}
147
+ for s in segments]
148
+
149
+
150
+ for seg in analyze("song.mp3", model, feature_extractor):
151
+ print(f"{seg['start']:7.1f} - {seg['end']:7.1f}s {seg['label']:<14s} {seg['confidence']:.2f}")
152
+ ```
153
+
154
+ Segment boundaries are quantised to `hop_seconds`, and a boundary is only ever
155
+ accurate to within one 4-second window β€” that window is the model's unit of
156
+ decision, not a frame-accurate onset. Lower `hop_seconds` for finer boundaries
157
+ at linear cost; raise `smooth_windows` if the timeline flickers.
158
+
159
+ ## Results
160
+
161
+ 4,000 held-out clips, 1,000 per class, source-disjoint from training (no
162
+ speaker, song, or noise file appears in both). Chance is 25%.
163
+
164
+ **Overall accuracy: 87.95%** Β· macro F1 0.879 Β· 834,728 parameters
165
+
166
+ ### Per class
167
+
168
+ | label | precision | recall | F1 | support |
169
+ |---|---|---|---|---|
170
+ | `speech_noise` | 0.950 | 0.957 | **0.954** | 1000 |
171
+ | `speech_music` | 0.951 | 0.942 | **0.946** | 1000 |
172
+ | `none` | 0.782 | 0.867 | **0.823** | 1000 |
173
+ | `singing_music` | 0.841 | 0.752 | **0.794** | 1000 |
174
+
175
+ ### Confusion matrix
176
+
177
+ Rows = true, columns = predicted.
178
+
179
+ | | `speech_noise` | `speech_music` | `singing_music` | `none` |
180
+ |---|---|---|---|---|
181
+ | **`speech_noise`** | **957** | 43 | 0 | 0 |
182
+ | **`speech_music`** | 47 | **942** | 11 | 0 |
183
+ | **`singing_music`** | 1 | 6 | **752** | 241 |
184
+ | **`none`** | 2 | 0 | 131 | **867** |
185
+
186
+ The two speech classes are close to solved. `singing_music` ↔ `none` holds 372
187
+ of the 482 total errors β€” 77%. Telling a sung voice apart from the instrumental
188
+ track under it is the open problem, not an implementation gap. If your use case
189
+ only needs "is anyone talking", the relevant number is the top-left 2Γ—2 block.
190
+
191
+ ### Accuracy by mixing difficulty
192
+
193
+ The test mixer records the SNR (or gain, for single-source clips) of each clip:
194
+
195
+ | bucket | accuracy | n |
196
+ |---|---|---|
197
+ | SNR [15,20] dB | 0.984 | 244 |
198
+ | SNR [5,10) dB | 0.962 | 547 |
199
+ | SNR [0,5) dB | 0.939 | 512 |
200
+ | SNR [10,15) dB | 0.938 | 448 |
201
+ | SNR [-5,0) dB | 0.932 | 249 |
202
+ | gain [0,5] dB (single-source) | 0.768 | 354 |
203
+ | gain [-5,0) dB (single-source) | 0.755 | 318 |
204
+ | gain [-10,-5) dB (single-source) | 0.732 | 328 |
205
+
206
+ Voice buried at negative SNR is *not* the hard case. Single-source clips are β€”
207
+ they are the `none` class, where there is no second source to key on.
208
+
209
+ ### Calibration
210
+
211
+ Trained with label smoothing 0.1 + SpecAugment, then temperature-scaled on the
212
+ validation split (T = 0.7095, fitted β€” not tuned by hand).
213
+
214
+ | metric | raw | after temperature scaling |
215
+ |---|---|---|
216
+ | expected calibration error | 0.0404 | **0.0335** |
217
+ | errors made at >0.99 confidence | 0.0% | 1.0% |
218
+ | mean confidence when correct | β€” | 0.934 |
219
+ | mean confidence when wrong | β€” | 0.753 |
220
+
221
+ Why this matters: an earlier transformer trained *without* label smoothing put
222
+ >0.99 confidence on **24.5%** of its own mistakes β€” its confidence score was
223
+ useless as a filter, which is the first thing anyone actually wants from it.
224
+ This model's score is usable as a threshold. Temperature scaling is monotonic,
225
+ so it never changes which class wins.
226
+
227
+ ## Compared with inaSpeechSegmenter
228
+
229
+ Same 1,000 clips, drawn from the test split above. The two systems don't share a
230
+ taxonomy β€” inaSpeechSegmenter tags singing as `music` **by design**, and merges
231
+ speech-over-music with speech-over-noise β€” so a naive head-to-head would be
232
+ meaningless. Two separate measurements instead:
233
+
234
+ **Task A β€” is a spoken voice present?** The question both systems are built to
235
+ answer. Neither is handicapped.
236
+
237
+ | system | accuracy | precision | recall | F1 |
238
+ |---|---|---|---|---|
239
+ | inaSpeechSegmenter | 0.896 | 0.997 | 0.794 | 0.884 |
240
+ | **SMAD** | **0.996** | 0.998 | **0.994** | **0.996** |
241
+
242
+ **Task B β€” is *any* voice present, spoken or sung?** SMAD is built for this;
243
+ inaSpeechSegmenter is built *not* to distinguish it. Reporting B is not a
244
+ criticism of their tool β€” it quantifies what the extra class buys, which is the
245
+ only reason to carry it.
246
+
247
+ | system | accuracy | precision | recall | F1 |
248
+ |---|---|---|---|---|
249
+ | inaSpeechSegmenter | 0.648 | 1.000 | 0.531 | 0.693 |
250
+ | **SMAD** | **0.908** | 0.957 | **0.919** | **0.937** |
251
+
252
+ On the 250 clips that genuinely contain singing, inaSpeechSegmenter labelled 228
253
+ `music`, 14 `noise`, 7 `noEnergy`, and **1** `speech`. SMAD got 188 right.
254
+
255
+ Size is comparable, not smaller: SMAD is 834,728 parameters (3.3 MB) against
256
+ inaSpeechSegmenter's speech/music/noise CNN at ~789k stored values (3.2 MB). The
257
+ gain here is the taxonomy and the recall, not the footprint.
258
+
259
+ *Caveat, stated plainly:* the clips come from SMAD's own synthetic mixer, so this
260
+ is home turf. inaSpeechSegmenter was trained on different data for a different
261
+ label set and never saw this distribution. Read this as "what the extra class
262
+ buys on this task", not as a general ranking.
263
+
264
+ ## Training data
265
+
266
+ 40,000 synthetic 4-second mixtures. The classes are defined by *what is mixed
267
+ together*, so segments are synthesised from clean stems rather than scraped from
268
+ labelled corpora β€” that gives frame-exact labels and direct control over mixing
269
+ SNR. Splits are disjoint at the **source** level (speaker id, song, noise file),
270
+ not the clip level.
271
+
272
+ | role | corpus | license |
273
+ |---|---|---|
274
+ | speech (EN) | [`openslr/librispeech_asr`](https://huggingface.co/datasets/openslr/librispeech_asr) `train.clean.100` | CC BY 4.0 |
275
+ | speech (VI) | Common Voice VI | CC0 1.0 |
276
+ | speech (VI) | FLEURS VI | CC BY 4.0 |
277
+ | singing + accompaniment | [`danjacobellis/musdb18HQ`](https://huggingface.co/datasets/danjacobellis/musdb18HQ) | **CC BY-NC-SA 4.0 β€” non-commercial** |
278
+ | instrumental music | [`benjamin-paine/free-music-archive-small`](https://huggingface.co/datasets/benjamin-paine/free-music-archive-small), `instrumental == Yes` | per-track Creative Commons; metadata MIT |
279
+ | noise | [`FluidInference/musan`](https://huggingface.co/datasets/FluidInference/musan) (noise subset only) | CC BY 4.0 |
280
+ | music (VI) | Wikimedia Commons Vietnamese traditional music | CC BY-SA / CC0, per file |
281
+ | real-world | crawled MV / news audio, pseudo-labelled with Silero VAD, **training split only** | no license grant β€” see below |
282
+
283
+ **Before you deploy this commercially, read this.** Two ingredients constrain it:
284
+
285
+ 1. **MUSDB18-HQ is CC BY-NC-SA 4.0 β€” non-commercial research only.** It is the
286
+ only separated-stem source in the pipeline and supplies both halves of
287
+ `singing_music`. Whether a model trained on NC-licensed audio is itself
288
+ encumbered is unsettled and jurisdiction-dependent. Get your own advice.
289
+ 2. **The crawled real-world subset carries no license grant.** It went into
290
+ *training only* β€” validation and test stay purely synthetic, so every number
291
+ above remains comparable with every number measured before and after it was
292
+ added. Each crawled file has a JSON sidecar recording source URL, video id,
293
+ uploader, license field, the search query that surfaced it, and a sha256, so
294
+ any source can be audited and removed on request.
295
+
296
+ The `license: mit` tag on this repo covers the **code and weights as published
297
+ by the author**; it does not and cannot re-license the upstream training corpora.
298
+
299
+ ## Limitations
300
+
301
+ - **87.95% is an upper bound, not an estimate.** Train and test come from the
302
+ same synthetic mixer and share its biases: no room impulse responses, one SNR
303
+ distribution, one pool of speakers and songs. Expect real-world numbers below
304
+ this. Evaluation on genuine recordings (e.g. MIR-1K) is the honest next step
305
+ and has not been done.
306
+ - `singing_music` is capped by data, not architecture: only 281 distinct sources
307
+ exist for that class project-wide, because MUSDB18-HQ has 150 tracks.
308
+ - Speech coverage is English + Vietnamese. Other languages are untested.
309
+ - The 4-second window is fixed by training. Shorter clips are zero-padded;
310
+ a 4-second window straddling a speech→music cut gets one label for both.
311
+ - Music with heavy vocal-like synths, and speech over strongly rhythmic
312
+ ambience, are the known confusions beyond the `singing_music`/`none` pair.
313
+
314
+ ## Architecture
315
+
316
+ `TinyAudioCRNN`: 4 conv blocks (32β†’64β†’128β†’128, BN + ReLU + max-pool) over an
317
+ 80-bin log-mel spectrogram, then a 1-layer BiGRU (hidden 128), then mean+max
318
+ pooling over time into a linear classifier. Per-mel-bin input standardisation
319
+ (`feat_mean` / `feat_std`) ships **inside** the weights rather than in a config
320
+ file, so inference physically cannot feed the model a different input scale than
321
+ it trained on.
322
+
323
+ Features must match training exactly: 80 mels, `n_fft=400` (25 ms),
324
+ `hop_length=160` (10 ms), `power=2.0`, `librosa.power_to_db`. The bundled feature
325
+ extractor does this for you.
326
+
327
+ Ablations at the same training recipe: transformer (683k params) 82.53%;
328
+ transformer without label smoothing + SpecAugment 80.13%. So the recipe is worth
329
+ +2.40 points and the CRNN architecture +5.42.
330
+
331
+ Selected at epoch 15 of 24 by validation accuracy (90.48%); early-stopped at 23.
332
+ ~10 minutes on an RTX A4500.
333
+
334
+ ## Citation
335
 
336
+ ```bibtex
337
+ @software{smad_crnn,
338
+ title = {SMAD: a small CRNN for speech / music / singing discrimination},
339
+ author = {duclvQ},
340
+ year = {2026},
341
+ url = {https://huggingface.co/duclvQ/smad}
342
+ }
343
  ```