terminals-core 0.1.0

Core runtime primitives for Terminals OS: phase dynamics, AXON wire protocol, substrate engine, and sematonic types
Documentation
//! Band Analysis — Extract frequency band energies from FFT magnitude bins.
//!
//! Maps raw FFT bins to game-relevant frequency bands:
//! - Sub-bass (20-60Hz): Kuramoto phase sync
//! - Bass (60-250Hz): Shockwaves, combat rhythm
//! - Mids (250-2kHz): Expert activation
//! - Highs (2k-20kHz): Semantic perturbation
//!
//! Also includes onset (beat) detection via energy differential.

/// Band analysis result — all values normalized to [0, 1].
#[derive(Debug, Clone, Copy, Default)]
pub struct BandAnalysis {
    pub sub_bass: f32,
    pub bass: f32,
    pub mids: f32,
    pub highs: f32,
    pub rms: f32,
    pub spectral_centroid: f32,
    /// Whether an onset (beat) was detected this frame
    pub beat: bool,
}

/// Analyze FFT magnitude bins into frequency bands.
///
/// `bins`: Raw FFT magnitude values (u8, typically 4096 or 8192 bins).
/// `sample_rate`: Audio sample rate in Hz (typically 44100 or 48000).
/// `prev_energy`: Previous frame's total energy (for onset detection).
///
/// Bin frequency: bin_index * sample_rate / (2 * num_bins)
pub fn analyze_bands(bins: &[u8], sample_rate: u32, prev_energy: f32) -> BandAnalysis {
    if bins.is_empty() || sample_rate == 0 {
        return BandAnalysis::default();
    }

    let n = bins.len();
    let bin_width = sample_rate as f32 / (2.0 * n as f32);

    // Frequency ranges (in Hz) -> bin index ranges
    let sub_bass_end = ((60.0 / bin_width) as usize).min(n);
    let bass_start = ((60.0 / bin_width) as usize).min(n);
    let bass_end = ((250.0 / bin_width) as usize).min(n);
    let mid_start = ((250.0 / bin_width) as usize).min(n);
    let mid_end = ((2000.0 / bin_width) as usize).min(n);
    let high_start = ((2000.0 / bin_width) as usize).min(n);
    let high_end = ((20000.0 / bin_width) as usize).min(n);

    // Compute energy per band (RMS of bin magnitudes)
    let sub_bass = band_energy(&bins[..sub_bass_end]);
    let bass = band_energy(&bins[bass_start..bass_end]);
    let mids = band_energy(&bins[mid_start..mid_end]);
    let highs = band_energy(&bins[high_start..high_end]);

    // Overall RMS
    let total_energy: f32 = bins.iter().map(|&b| (b as f32) * (b as f32)).sum();
    let rms = (total_energy / n as f32).sqrt() / 255.0;

    // Spectral centroid (brightness indicator)
    let mut weighted_sum = 0.0f32;
    let mut mag_sum = 0.0f32;
    for (i, &b) in bins.iter().enumerate() {
        let mag = b as f32;
        weighted_sum += mag * (i as f32 * bin_width);
        mag_sum += mag;
    }
    let centroid = if mag_sum > 0.0 {
        (weighted_sum / mag_sum / (sample_rate as f32 / 2.0)).min(1.0)
    } else {
        0.0
    };

    // Onset detection (beat): current energy significantly above previous
    let current_energy = rms;
    let beat = current_energy > 0.15 && current_energy > prev_energy * 1.4;

    BandAnalysis {
        sub_bass,
        bass,
        mids,
        highs,
        rms,
        spectral_centroid: centroid,
        beat,
    }
}

/// Compute normalized band energy from a slice of magnitude bins.
/// Returns value in [0, 1].
fn band_energy(bins: &[u8]) -> f32 {
    if bins.is_empty() {
        return 0.0;
    }
    let sum: f32 = bins.iter().map(|&b| (b as f32) * (b as f32)).sum();
    let rms = (sum / bins.len() as f32).sqrt();
    (rms / 255.0).min(1.0)
}

/// Analyze bands from f32 PCM samples directly (for when FFT is done in Rust).
/// This is a simplified energy-based analysis without full FFT.
pub fn analyze_bands_pcm(samples: &[f32], sample_rate: u32) -> BandAnalysis {
    if samples.is_empty() || sample_rate == 0 {
        return BandAnalysis::default();
    }

    // Simple RMS energy
    let rms: f32 = (samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt();

    // Without FFT, we can only estimate overall energy
    BandAnalysis {
        sub_bass: 0.0,
        bass: rms, // Rough approximation
        mids: 0.0,
        highs: 0.0,
        rms,
        spectral_centroid: 0.0,
        beat: false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_analyze_empty() {
        let result = analyze_bands(&[], 48000, 0.0);
        assert_eq!(result.rms, 0.0);
        assert!(!result.beat);
    }

    #[test]
    fn test_analyze_silence() {
        let bins = vec![0u8; 4096];
        let result = analyze_bands(&bins, 48000, 0.0);
        assert_eq!(result.sub_bass, 0.0);
        assert_eq!(result.bass, 0.0);
        assert_eq!(result.mids, 0.0);
        assert_eq!(result.highs, 0.0);
        assert_eq!(result.rms, 0.0);
        assert!(!result.beat);
    }

    #[test]
    fn test_analyze_bass_heavy() {
        let mut bins = vec![0u8; 4096];
        // Fill bass range (60-250Hz) with high values
        // At 48000Hz, bin_width = 48000 / 8192 ~= 5.86Hz
        // 60Hz = bin 10, 250Hz = bin 43
        for bin in &mut bins[10..43] {
            *bin = 200;
        }
        let result = analyze_bands(&bins, 48000, 0.0);
        assert!(result.bass > 0.5, "bass = {}", result.bass);
        assert!(result.bass > result.highs, "bass should > highs");
    }

    #[test]
    fn test_beat_detection() {
        let bins = vec![128u8; 4096]; // Loud frame
        let result = analyze_bands(&bins, 48000, 0.1); // Previous was quiet
        assert!(result.beat, "Should detect beat on energy spike");

        let result2 = analyze_bands(&bins, 48000, 0.9); // Previous was also loud
        assert!(!result2.beat, "Should not detect beat without spike");
    }

    #[test]
    fn test_band_values_in_range() {
        let bins: Vec<u8> = (0..4096).map(|i| (i % 256) as u8).collect();
        let result = analyze_bands(&bins, 48000, 0.0);
        assert!(result.sub_bass >= 0.0 && result.sub_bass <= 1.0);
        assert!(result.bass >= 0.0 && result.bass <= 1.0);
        assert!(result.mids >= 0.0 && result.mids <= 1.0);
        assert!(result.highs >= 0.0 && result.highs <= 1.0);
        assert!(result.rms >= 0.0 && result.rms <= 1.0);
        assert!(result.spectral_centroid >= 0.0 && result.spectral_centroid <= 1.0);
    }
}