| 99 |
99 |
|
let mut bandwidths = Vec::new();
|
| 100 |
100 |
|
let mut onset_diffs = Vec::new();
|
| 101 |
101 |
|
let mut magnitude_frames = Vec::new();
|
|
102 |
+ |
// Per-frame total magnitude, used to weight the spectrum-shape averages
|
|
103 |
+ |
// below. Pushed in lockstep with centroids/bandwidths/flatnesses/rolloffs,
|
|
104 |
+ |
// which all only push inside the `mag_sum > 0.0` arm.
|
|
105 |
+ |
let mut frame_energy = Vec::new();
|
| 102 |
106 |
|
|
| 103 |
107 |
|
// Hoisted STFT scratch, reused every frame: `windowed` is refilled before each
|
| 104 |
108 |
|
// FFT (realfft also uses it as scratch) and `spectrum` is overwritten by
|
| 138 |
142 |
|
.sum();
|
| 139 |
143 |
|
let frame_centroid = weighted_sum / mag_sum;
|
| 140 |
144 |
|
centroids.push(frame_centroid);
|
|
145 |
+ |
frame_energy.push(mag_sum);
|
| 141 |
146 |
|
|
| 142 |
147 |
|
// Spectral bandwidth: standard deviation of the spectrum around the centroid.
|
| 143 |
148 |
|
// sqrt(Sum((freq - centroid)^2 * mag) / Sum(mag)) per frame.
|
| 230 |
235 |
|
}
|
| 231 |
236 |
|
};
|
| 232 |
237 |
|
|
| 233 |
|
- |
// Centroid variance: variance of per-frame centroids (how much the spectrum evolves).
|
| 234 |
|
- |
let centroid_mean = avg(¢roids);
|
|
238 |
+ |
/// Energy-weighted mean of a per-frame spectral-shape feature.
|
|
239 |
+ |
///
|
|
240 |
+ |
/// A plain frame mean counts a near-silent frame as heavily as the loudest
|
|
241 |
+ |
/// one. That is not a small bias: a one-shot is a short loud transient
|
|
242 |
+ |
/// followed by a long near-silent tail, those tail frames are spectrally
|
|
243 |
+ |
/// bright (noise floor, dither, reverb), and there are far more of them
|
|
244 |
+ |
/// than transient frames, so the mean converges on the noise floor rather
|
|
245 |
+ |
/// than on the sound. Measured over 60 kick samples, the plain mean put 51
|
|
246 |
+ |
/// of them above 3000 Hz centroid; energy-weighted, none. Where a sample
|
|
247 |
+ |
/// has no tail at all the two agree, which is what identifies the tail as
|
|
248 |
+ |
/// the cause.
|
|
249 |
+ |
///
|
|
250 |
+ |
/// The `mag_sum > 0.0` guard on the frame loop does not help: it excludes
|
|
251 |
+ |
/// only exact digital silence, and a dithered or reverberant tail is not
|
|
252 |
+ |
/// exactly zero.
|
|
253 |
+ |
///
|
|
254 |
+ |
/// For the centroid this is not merely a nicer average. Weighting each
|
|
255 |
+ |
/// frame by its total magnitude is algebraically identical to computing one
|
|
256 |
+ |
/// centroid over the summed magnitude spectrum of the whole sample, which
|
|
257 |
+ |
/// is the quantity actually wanted.
|
|
258 |
+ |
fn weighted_avg(values: &[f64], weights: &[f64]) -> f64 {
|
|
259 |
+ |
let total: f64 = weights.iter().sum();
|
|
260 |
+ |
if values.is_empty() {
|
|
261 |
+ |
return 0.0;
|
|
262 |
+ |
}
|
|
263 |
+ |
// No energy anywhere (digital silence): fall back to the plain mean
|
|
264 |
+ |
// rather than returning 0.0, which would read as a real measurement.
|
|
265 |
+ |
if total <= 0.0 {
|
|
266 |
+ |
return values.iter().sum::<f64>() / values.len() as f64;
|
|
267 |
+ |
}
|
|
268 |
+ |
values
|
|
269 |
+ |
.iter()
|
|
270 |
+ |
.zip(weights.iter())
|
|
271 |
+ |
.map(|(v, w)| v * w)
|
|
272 |
+ |
.sum::<f64>()
|
|
273 |
+ |
/ total
|
|
274 |
+ |
}
|
|
275 |
+ |
|
|
276 |
+ |
// Centroid variance: variance of per-frame centroids (how much the spectrum
|
|
277 |
+ |
// evolves). Weighted like the mean it is taken around, otherwise a quiet
|
|
278 |
+ |
// bright tail reads as a wildly evolving spectrum.
|
|
279 |
+ |
let centroid_mean = weighted_avg(¢roids, &frame_energy);
|
| 235 |
280 |
|
let centroid_var = if centroids.len() < 2 {
|
| 236 |
281 |
|
0.0
|
| 237 |
282 |
|
} else {
|
| 238 |
|
- |
let sum_sq: f64 = centroids.iter().map(|&c| (c - centroid_mean).powi(2)).sum();
|
| 239 |
|
- |
sum_sq / centroids.len() as f64
|
|
283 |
+ |
let sq: Vec<f64> = centroids
|
|
284 |
+ |
.iter()
|
|
285 |
+ |
.map(|&c| (c - centroid_mean).powi(2))
|
|
286 |
+ |
.collect();
|
|
287 |
+ |
weighted_avg(&sq, &frame_energy)
|
| 240 |
288 |
|
};
|
| 241 |
289 |
|
|
| 242 |
290 |
|
let features = SpectralFeatures {
|
| 243 |
291 |
|
centroid: centroid_mean,
|
| 244 |
|
- |
flatness: avg(&flatnesses),
|
| 245 |
|
- |
rolloff: avg(&rolloffs),
|
|
292 |
+ |
flatness: weighted_avg(&flatnesses, &frame_energy),
|
|
293 |
+ |
rolloff: weighted_avg(&rolloffs, &frame_energy),
|
| 246 |
294 |
|
zero_crossing_rate: zcr,
|
|
295 |
+ |
// Deliberately NOT energy-weighted. Onset strength is already
|
|
296 |
+ |
// loudness-invariant by construction (per-frame L1-normalised spectra,
|
|
297 |
+ |
// see above), so weighting it by frame energy would undo that on
|
|
298 |
+ |
// purpose-built behaviour. It is a temporal-change measure rather than
|
|
299 |
+ |
// a spectral-shape one, and its failure mode under a long tail is
|
|
300 |
+ |
// dilution toward zero, not inflation.
|
| 247 |
301 |
|
onset_strength: avg(&onset_diffs),
|
| 248 |
|
- |
bandwidth: avg(&bandwidths),
|
|
302 |
+ |
bandwidth: weighted_avg(&bandwidths, &frame_energy),
|
| 249 |
303 |
|
centroid_variance: centroid_var,
|
| 250 |
304 |
|
};
|
| 251 |
305 |
|
|
| 270 |
324 |
|
mod tests {
|
| 271 |
325 |
|
use super::*;
|
| 272 |
326 |
|
|
|
327 |
+ |
#[test]
|
|
328 |
+ |
fn quiet_bright_tail_does_not_dominate_the_centroid() {
|
|
329 |
+ |
// The v5 regression. A one-shot is a short loud low-frequency transient
|
|
330 |
+ |
// followed by a long near-silent tail, and a real tail (noise floor,
|
|
331 |
+ |
// dither, reverb) is spectrally bright rather than digitally silent.
|
|
332 |
+ |
//
|
|
333 |
+ |
// Built so the two aggregations disagree loudly: 4 frames of loud 200 Hz
|
|
334 |
+ |
// tone, then 60 frames of very quiet high-frequency noise. A plain frame
|
|
335 |
+ |
// mean lands near the noise because it is outnumbered 15:1; an
|
|
336 |
+ |
// energy-weighted mean stays with the tone because that is where all the
|
|
337 |
+ |
// energy is.
|
|
338 |
+ |
let sr = 44100;
|
|
339 |
+ |
let window = 1024;
|
|
340 |
+ |
let mut samples: Vec<f32> = Vec::new();
|
|
341 |
+ |
for i in 0..window * 4 {
|
|
342 |
+ |
samples.push(0.9 * (2.0 * std::f32::consts::PI * 200.0 * i as f32 / sr as f32).sin());
|
|
343 |
+ |
}
|
|
344 |
+ |
// Deterministic pseudo-noise, alternating sign at a high rate, scaled to
|
|
345 |
+ |
// roughly -80 dB. Not silence, which the `mag_sum > 0.0` guard would
|
|
346 |
+ |
// have caught on its own.
|
|
347 |
+ |
for i in 0..window * 60 {
|
|
348 |
+ |
let s = if i % 2 == 0 { 1.0 } else { -1.0 };
|
|
349 |
+ |
samples.push(s * 0.0001);
|
|
350 |
+ |
}
|
|
351 |
+ |
|
|
352 |
+ |
let f = compute_spectral_features(&samples, sr);
|
|
353 |
+ |
|
|
354 |
+ |
// The loud content is at 200 Hz. Without weighting this test reported a
|
|
355 |
+ |
// centroid up near the Nyquist-ish noise instead.
|
|
356 |
+ |
assert!(
|
|
357 |
+ |
f.centroid < 2000.0,
|
|
358 |
+ |
"centroid {} Hz was dragged up by the quiet tail; energy-weighting is not applied",
|
|
359 |
+ |
f.centroid
|
|
360 |
+ |
);
|
|
361 |
+ |
|
|
362 |
+ |
// And the tail must genuinely be the majority of frames, or the test is
|
|
363 |
+ |
// not exercising what it claims to.
|
|
364 |
+ |
let plain_mean_would_be_high = {
|
|
365 |
+ |
let (_, frames) = compute_spectral_features_with_frames(&samples, sr);
|
|
366 |
+ |
frames.len() > 50
|
|
367 |
+ |
};
|
|
368 |
+ |
assert!(
|
|
369 |
+ |
plain_mean_would_be_high,
|
|
370 |
+ |
"test signal did not produce enough tail frames to be a real check"
|
|
371 |
+ |
);
|
|
372 |
+ |
}
|
|
373 |
+ |
|
| 273 |
374 |
|
#[test]
|
| 274 |
375 |
|
#[allow(
|
| 275 |
376 |
|
clippy::float_cmp,
|