//! The 35-feature vector: assembly and versioning. //! //! [`FeatureInput`] collects the cheap DSP measurements (9 spectral/waveform //! scalars) plus MFCC means and variances (26) and flattens them into the vector //! persisted to `sample_features`. That vector is the foundation every tag layer //! reads: user-authored rules, exemplar k-NN, the distilled head, and `.afcl` //! bundles. //! //! This module used to also hold `SampleClass` and the priority-ordered threshold //! tree that produced one. Both are gone (see `docs/ml_classifier.md`): the tree //! measured 33.4% strict accuracy with two of its seven drum classes unreachable //! by any rule, and the measurements in the wiki note `af-browse-axes` found the //! reason it could not be tuned into working. Instrument identity is not in these //! features. Coarse family structure is, and one unfitted threshold reads it at //! 92.4%, so browsing moved to continuous measured axes and instrument names to //! filename rules. Nothing computes a single categorical label any more. use super::mfcc::MfccFeatures; use super::spectral::SpectralFeatures; /// Number of features in the feature vector. pub const NUM_FEATURES: usize = 35; /// Version stamp for the feature-extraction layout/params. Bump whenever the /// 35-feature vector's composition changes so persisted vectors (and shared /// `.afcl` bundles) can be invalidated/recomputed rather than silently mixed. /// /// v2: MFCCs switched to power-spectrum input + orthonormal DCT-II scaling, and /// degenerate low mel bands now use their center bin instead of a constant dead /// value (the 26 MFCC dims of the vector changed numerically). Existing libraries /// recompute these via the normal feature backfill on next launch. /// /// v3: `onset_strength` switched to *normalised* spectral flux (loudness-invariant), /// so that dim of the vector changed scale. Same backfill-on-next-launch path. /// /// v4: mel filterbank bin edges are now forced strictly increasing, so adjacent /// low-frequency filters no longer collapse onto the same center bin (which had /// fed duplicated energies into the DCT). The MFCC dims changed numerically; /// backfill-on-next-launch as before. /// /// v5: the spectral centroid, flatness, rolloff, bandwidth and centroid variance /// are now energy-weighted across frames rather than plain frame means. The old /// mean counted a near-silent frame as heavily as the loudest one, so a one-shot's /// long quiet tail (spectrally bright: noise floor, dither, reverb) dominated the /// result. Measured over 60 kicks, the plain mean put 51 above a 3000 Hz centroid /// and the weighted mean none. Five dims of the vector changed scale; /// backfill-on-next-launch as before. pub const FEATURE_VERSION: u32 = 5; /// Input bundle for the feature vector: every measurement that lands in it. pub struct FeatureInput { pub duration: f64, pub centroid: f64, pub flatness: f64, pub zcr: f64, pub onset_strength: f64, pub bandwidth: f64, pub centroid_variance: f64, pub crest_factor: f64, pub attack_time: f64, pub mfcc_means: [f64; 13], pub mfcc_variances: [f64; 13], } impl FeatureInput { /// Build from spectral features + waveform measurements (no MFCCs). pub fn new( features: &SpectralFeatures, duration: f64, crest_factor: f64, attack_time: f64, ) -> Self { Self::with_mfccs( features, duration, crest_factor, attack_time, &MfccFeatures::default(), ) } /// Build from spectral features + waveform measurements + MFCCs. pub fn with_mfccs( features: &SpectralFeatures, duration: f64, crest_factor: f64, attack_time: f64, mfccs: &MfccFeatures, ) -> Self { Self { duration, centroid: features.centroid, flatness: features.flatness, zcr: features.zero_crossing_rate, onset_strength: features.onset_strength, bandwidth: features.bandwidth, centroid_variance: features.centroid_variance, crest_factor, attack_time, mfcc_means: mfccs.means, mfcc_variances: mfccs.variances, } } /// Flatten to the persisted 35-element vector. /// /// Layout: [0-8] scalar features, [9-21] MFCC means, [22-34] MFCC variances. /// The layout is what `FEATURE_VERSION` stamps, so changing it needs a bump. pub fn to_feature_array(&self) -> [f64; NUM_FEATURES] { let mut arr = [0.0; NUM_FEATURES]; arr[0] = self.duration; arr[1] = self.centroid; arr[2] = self.flatness; arr[3] = self.zcr; arr[4] = self.onset_strength; arr[5] = self.bandwidth; arr[6] = self.centroid_variance; arr[7] = self.crest_factor; arr[8] = self.attack_time; arr[9..22].copy_from_slice(&self.mfcc_means); arr[22..35].copy_from_slice(&self.mfcc_variances); arr } } #[cfg(test)] mod tests { use super::*; #[test] #[allow( clippy::float_cmp, reason = "exact equality on deterministic test values" )] fn feature_array_layout() { let input = FeatureInput { duration: 1.0, centroid: 2.0, flatness: 3.0, zcr: 4.0, onset_strength: 5.0, bandwidth: 6.0, centroid_variance: 7.0, crest_factor: 8.0, attack_time: 9.0, mfcc_means: [10.0; 13], mfcc_variances: [20.0; 13], }; let arr = input.to_feature_array(); assert_eq!(arr[0], 1.0); // duration assert_eq!(arr[1], 2.0); // centroid assert_eq!(arr[8], 9.0); // attack_time assert_eq!(arr[9], 10.0); // mfcc_mean[0] assert_eq!(arr[21], 10.0); // mfcc_mean[12] assert_eq!(arr[22], 20.0); // mfcc_var[0] assert_eq!(arr[34], 20.0); // mfcc_var[12] } #[test] #[allow( clippy::float_cmp, reason = "exact equality on deterministic test values" )] fn new_defaults_mfccs_to_zero() { // `new` is the no-MFCC path (spectral stage ran, MFCC stage did not). The // 26 MFCC dims must be present and zeroed rather than absent, so the // vector is always NUM_FEATURES long and stays k-NN-safe. let features = SpectralFeatures { centroid: 600.0, flatness: 0.15, rolloff: 1200.0, zero_crossing_rate: 0.04, onset_strength: 50.0, ..Default::default() }; let arr = FeatureInput::new(&features, 0.3, 5.0, 0.003).to_feature_array(); assert_eq!(arr.len(), NUM_FEATURES); assert!(arr[9..].iter().all(|v| *v == 0.0)); assert_eq!(arr[1], 600.0); } }