Skip to main content

terminals_core/audio/
bands.rs

1//! Band Analysis — Extract frequency band energies from FFT magnitude bins.
2//!
3//! Maps raw FFT bins to game-relevant frequency bands:
4//! - Sub-bass (20-60Hz): Kuramoto phase sync
5//! - Bass (60-250Hz): Shockwaves, combat rhythm
6//! - Mids (250-2kHz): Expert activation
7//! - Highs (2k-20kHz): Semantic perturbation
8//!
9//! Also includes onset (beat) detection via energy differential.
10
11/// Band analysis result — all values normalized to [0, 1].
12#[derive(Debug, Clone, Copy, Default)]
13pub struct BandAnalysis {
14    pub sub_bass: f32,
15    pub bass: f32,
16    pub mids: f32,
17    pub highs: f32,
18    pub rms: f32,
19    pub spectral_centroid: f32,
20    /// Whether an onset (beat) was detected this frame
21    pub beat: bool,
22}
23
24/// Analyze FFT magnitude bins into frequency bands.
25///
26/// `bins`: Raw FFT magnitude values (u8, typically 4096 or 8192 bins).
27/// `sample_rate`: Audio sample rate in Hz (typically 44100 or 48000).
28/// `prev_energy`: Previous frame's total energy (for onset detection).
29///
30/// Bin frequency: bin_index * sample_rate / (2 * num_bins)
31pub fn analyze_bands(bins: &[u8], sample_rate: u32, prev_energy: f32) -> BandAnalysis {
32    if bins.is_empty() || sample_rate == 0 {
33        return BandAnalysis::default();
34    }
35
36    let n = bins.len();
37    let bin_width = sample_rate as f32 / (2.0 * n as f32);
38
39    // Frequency ranges (in Hz) -> bin index ranges
40    let sub_bass_end = ((60.0 / bin_width) as usize).min(n);
41    let bass_start = ((60.0 / bin_width) as usize).min(n);
42    let bass_end = ((250.0 / bin_width) as usize).min(n);
43    let mid_start = ((250.0 / bin_width) as usize).min(n);
44    let mid_end = ((2000.0 / bin_width) as usize).min(n);
45    let high_start = ((2000.0 / bin_width) as usize).min(n);
46    let high_end = ((20000.0 / bin_width) as usize).min(n);
47
48    // Compute energy per band (RMS of bin magnitudes)
49    let sub_bass = band_energy(&bins[..sub_bass_end]);
50    let bass = band_energy(&bins[bass_start..bass_end]);
51    let mids = band_energy(&bins[mid_start..mid_end]);
52    let highs = band_energy(&bins[high_start..high_end]);
53
54    // Overall RMS
55    let total_energy: f32 = bins.iter().map(|&b| (b as f32) * (b as f32)).sum();
56    let rms = (total_energy / n as f32).sqrt() / 255.0;
57
58    // Spectral centroid (brightness indicator)
59    let mut weighted_sum = 0.0f32;
60    let mut mag_sum = 0.0f32;
61    for (i, &b) in bins.iter().enumerate() {
62        let mag = b as f32;
63        weighted_sum += mag * (i as f32 * bin_width);
64        mag_sum += mag;
65    }
66    let centroid = if mag_sum > 0.0 {
67        (weighted_sum / mag_sum / (sample_rate as f32 / 2.0)).min(1.0)
68    } else {
69        0.0
70    };
71
72    // Onset detection (beat): current energy significantly above previous
73    let current_energy = rms;
74    let beat = current_energy > 0.15 && current_energy > prev_energy * 1.4;
75
76    BandAnalysis {
77        sub_bass,
78        bass,
79        mids,
80        highs,
81        rms,
82        spectral_centroid: centroid,
83        beat,
84    }
85}
86
87/// Compute normalized band energy from a slice of magnitude bins.
88/// Returns value in [0, 1].
89fn band_energy(bins: &[u8]) -> f32 {
90    if bins.is_empty() {
91        return 0.0;
92    }
93    let sum: f32 = bins.iter().map(|&b| (b as f32) * (b as f32)).sum();
94    let rms = (sum / bins.len() as f32).sqrt();
95    (rms / 255.0).min(1.0)
96}
97
98/// Analyze bands from f32 PCM samples directly (for when FFT is done in Rust).
99/// This is a simplified energy-based analysis without full FFT.
100pub fn analyze_bands_pcm(samples: &[f32], sample_rate: u32) -> BandAnalysis {
101    if samples.is_empty() || sample_rate == 0 {
102        return BandAnalysis::default();
103    }
104
105    // Simple RMS energy
106    let rms: f32 = (samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt();
107
108    // Without FFT, we can only estimate overall energy
109    BandAnalysis {
110        sub_bass: 0.0,
111        bass: rms, // Rough approximation
112        mids: 0.0,
113        highs: 0.0,
114        rms,
115        spectral_centroid: 0.0,
116        beat: false,
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_analyze_empty() {
126        let result = analyze_bands(&[], 48000, 0.0);
127        assert_eq!(result.rms, 0.0);
128        assert!(!result.beat);
129    }
130
131    #[test]
132    fn test_analyze_silence() {
133        let bins = vec![0u8; 4096];
134        let result = analyze_bands(&bins, 48000, 0.0);
135        assert_eq!(result.sub_bass, 0.0);
136        assert_eq!(result.bass, 0.0);
137        assert_eq!(result.mids, 0.0);
138        assert_eq!(result.highs, 0.0);
139        assert_eq!(result.rms, 0.0);
140        assert!(!result.beat);
141    }
142
143    #[test]
144    fn test_analyze_bass_heavy() {
145        let mut bins = vec![0u8; 4096];
146        // Fill bass range (60-250Hz) with high values
147        // At 48000Hz, bin_width = 48000 / 8192 ~= 5.86Hz
148        // 60Hz = bin 10, 250Hz = bin 43
149        for bin in &mut bins[10..43] {
150            *bin = 200;
151        }
152        let result = analyze_bands(&bins, 48000, 0.0);
153        assert!(result.bass > 0.5, "bass = {}", result.bass);
154        assert!(result.bass > result.highs, "bass should > highs");
155    }
156
157    #[test]
158    fn test_beat_detection() {
159        let bins = vec![128u8; 4096]; // Loud frame
160        let result = analyze_bands(&bins, 48000, 0.1); // Previous was quiet
161        assert!(result.beat, "Should detect beat on energy spike");
162
163        let result2 = analyze_bands(&bins, 48000, 0.9); // Previous was also loud
164        assert!(!result2.beat, "Should not detect beat without spike");
165    }
166
167    #[test]
168    fn test_band_values_in_range() {
169        let bins: Vec<u8> = (0..4096).map(|i| (i % 256) as u8).collect();
170        let result = analyze_bands(&bins, 48000, 0.0);
171        assert!(result.sub_bass >= 0.0 && result.sub_bass <= 1.0);
172        assert!(result.bass >= 0.0 && result.bass <= 1.0);
173        assert!(result.mids >= 0.0 && result.mids <= 1.0);
174        assert!(result.highs >= 0.0 && result.highs <= 1.0);
175        assert!(result.rms >= 0.0 && result.rms <= 1.0);
176        assert!(result.spectral_centroid >= 0.0 && result.spectral_centroid <= 1.0);
177    }
178}