Skip to main content

max / audiofiles

Audio/DSP A+: loudness-invariant onset strength, clarify fade curves - onset_strength now uses normalised spectral flux: compare the two frames' L1-normalised magnitude spectra so the measure reflects spectral change, not level. Raw flux scaled with loudness, so a loud and a quiet take of the same percussive hit reported different onsets. The other spectral features are scale-invariant ratios (documented); magnitude_frames stays raw for MFCC. - FEATURE_VERSION 2 -> 3 (onset dim changed scale); existing libraries recompute via the normal feature backfill on next launch. - FadeCurve: per-variant docs making the "Logarithmic" label precise (concave log2(1+t) amplitude shape, not a dB/equal-loudness fade). No math/enum change, so saved configs stay compatible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 21:48 UTC
Commit: 4d1ea2fe641f38ede17330c06d08937d7308f2d7
Parent: ae29087
3 files changed, +62 insertions, -13 deletions
@@ -22,7 +22,10 @@ pub const NUM_FEATURES: usize = 35;
22 22 /// degenerate low mel bands now use their center bin instead of a constant dead
23 23 /// value (the 26 MFCC dims of the vector changed numerically). Existing libraries
24 24 /// recompute these via the normal feature backfill on next launch.
25 - pub const FEATURE_VERSION: u32 = 2;
25 + ///
26 + /// v3: `onset_strength` switched to *normalised* spectral flux (loudness-invariant),
27 + /// so that dim of the vector changed scale. Same backfill-on-next-launch path.
28 + pub const FEATURE_VERSION: u32 = 3;
26 29
27 30 // ── SampleClass enum (unchanged) ──
28 31
@@ -20,7 +20,8 @@ pub struct SpectralFeatures {
20 20 pub rolloff: f64,
21 21 /// Proportion of sign changes per sample (high = noisy/bright transients).
22 22 pub zero_crossing_rate: f64,
23 - /// Sum of positive spectral flux across frames (higher = more percussive onsets).
23 + /// Sum of positive *normalised* spectral flux across frames (loudness-invariant;
24 + /// higher = more percussive onsets).
24 25 pub onset_strength: f64,
25 26 /// Spectral standard deviation around the centroid in Hz (spread of energy).
26 27 pub bandwidth: f64,
@@ -186,19 +187,30 @@ fn compute_spectral_inner(
186 187 // second-to-last element — no separate `prev_spectrum` copy needed.
187 188 magnitude_frames.push(magnitudes);
188 189
189 - // Onset strength via spectral flux: sum of positive magnitude increases
190 - // between consecutive frames. Only positive differences are counted
191 - // (half-wave rectification) because onsets are characterised by energy
192 - // appearing, not disappearing.
190 + // Onset strength via *normalised* spectral flux: sum of positive
191 + // increases between the two frames' L1-normalised magnitude spectra
192 + // (each divided by its own total magnitude). Half-wave rectification
193 + // (`.max(0.0)`) keeps only energy appearing, not disappearing.
194 + //
195 + // Normalising per frame makes the measure loudness-invariant: raw flux
196 + // scaled with absolute magnitude, so a loud and a quiet take of the same
197 + // percussive hit reported different onset strengths. The other spectral
198 + // features (centroid/bandwidth/flatness/rolloff) are already
199 + // scale-invariant ratios, so they need no normalisation; `magnitude_frames`
200 + // keeps the raw spectra because MFCC consumes them and applies its own.
193 201 if magnitude_frames.len() >= 2 {
194 202 let curr = &magnitude_frames[magnitude_frames.len() - 1];
195 203 let prev = &magnitude_frames[magnitude_frames.len() - 2];
196 - let flux: f64 = curr
197 - .iter()
198 - .zip(prev.iter())
199 - .map(|(&curr, &prev)| (curr - prev).max(0.0))
200 - .sum();
201 - onset_diffs.push(flux);
204 + let curr_sum: f64 = curr.iter().sum();
205 + let prev_sum: f64 = prev.iter().sum();
206 + if curr_sum > 0.0 && prev_sum > 0.0 {
207 + let flux: f64 = curr
208 + .iter()
209 + .zip(prev.iter())
210 + .map(|(&c, &p)| (c / curr_sum - p / prev_sum).max(0.0))
211 + .sum();
212 + onset_diffs.push(flux);
213 + }
202 214 }
203 215 }
204 216
@@ -282,6 +294,32 @@ mod tests {
282 294 }
283 295
284 296 #[test]
297 + fn onset_strength_is_loudness_invariant() {
298 + // Two takes of the same signal differing only in amplitude must report
299 + // (nearly) the same onset strength: normalised spectral flux measures
300 + // spectral change, not level. A burst (silence → tone) gives a clear onset.
301 + let make = |amp: f32| -> Vec<f32> {
302 + (0..8192)
303 + .map(|i| {
304 + if i < 2048 {
305 + 0.0
306 + } else {
307 + amp * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44100.0).sin()
308 + }
309 + })
310 + .collect()
311 + };
312 + let quiet = compute_spectral_features(&make(0.05), 44100).onset_strength;
313 + let loud = compute_spectral_features(&make(0.9), 44100).onset_strength;
314 + assert!(quiet > 0.0, "a burst should produce a non-zero onset");
315 + let rel = (loud - quiet).abs() / loud.max(1e-9);
316 + assert!(
317 + rel < 0.05,
318 + "onset strength should be loudness-invariant: quiet={quiet}, loud={loud}, rel={rel}"
319 + );
320 + }
321 +
322 + #[test]
285 323 fn zcr_of_high_freq_is_higher() {
286 324 let low: Vec<f32> = (0..44100)
287 325 .map(|i| (2.0 * std::f32::consts::PI * 100.0 * i as f32 / 44100.0).sin())
@@ -1,10 +1,18 @@
1 1 //! Fade in/out with selectable curve shapes.
2 2
3 - /// Fade curve shape.
3 + /// Fade curve shape (the gain ramp applied across the fade region).
4 + ///
5 + /// "Logarithmic" here names the *amplitude* curve's shape (concave `log2(1+t)`),
6 + /// not a perceptual dB/equal-loudness fade — see the per-variant docs.
4 7 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5 8 pub enum FadeCurve {
9 + /// Straight gain ramp: `t`. Constant amplitude slope.
6 10 Linear,
11 + /// Concave logarithmic-amplitude ramp: `log2(1 + t)` (fast rise, then
12 + /// flattening). A logarithmic *shape* in amplitude — not a dB/equal-loudness
13 + /// fade (which would be exponential in amplitude).
7 14 Logarithmic,
15 + /// Smoothstep `3t^2 - 2t^3`: eased at both ends, steepest in the middle.
8 16 SCurve,
9 17 }
10 18