Skip to main content

libflo_audio/core/
analysis.rs

1//! Audio analysis functions for flo codec
2
3use crate::core::metadata::WaveformData;
4use crate::dsp::{Complex32, DefaultPlanner, FftDirection, FftPlanner};
5use alloc::vec;
6use alloc::vec::Vec;
7use serde::{Deserialize, Serialize};
8
9pub type FloSample = f32;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SpectralFingerprint {
13    /// BLAKE3 hash of the audio content
14    pub hash: [u8; 32],
15    /// Duration in milliseconds
16    pub duration_ms: u32,
17    /// Sample rate
18    pub sample_rate: u32,
19    /// Number of channels
20    pub channels: u8,
21    /// Peak frequency ranges (8 key frequency bands)
22    pub frequency_peaks: [u8; 8],
23    /// Energy distribution across frequency bands (16 bands)
24    pub energy_profile: [u8; 16],
25    /// Average loudness (LUFS scaled to u8)
26    pub avg_loudness: u8,
27}
28
29/// Extract waveform peaks from audio samples
30///
31/// # Arguments
32/// * `samples` - Audio samples (interleaved if stereo)
33/// * `channels` - Number of audio channels (1 or 2)
34/// * `sample_rate` - Sample rate in Hz
35/// * `peaks_per_second` - Number of peak values per second
36///
37/// # Returns
38/// `WaveformData` struct containing extracted peaks
39pub fn extract_waveform_peaks(
40    samples: &[FloSample],
41    channels: u8,
42    sample_rate: u32,
43    peaks_per_second: u32,
44) -> WaveformData {
45    if samples.is_empty() || channels == 0 || peaks_per_second == 0 || sample_rate == 0 {
46        return WaveformData {
47            peaks_per_second,
48            peaks: Vec::new(),
49            channels,
50        };
51    }
52
53    let samples_per_peak = (sample_rate as f64) / (peaks_per_second as f64);
54    let total_peaks =
55        libm::ceil((samples.len() as f64) / (samples_per_peak * (channels as f64))) as usize;
56
57    let mut peaks = Vec::with_capacity(total_peaks);
58
59    for peak_idx in 0..total_peaks {
60        let start_sample = ((peak_idx as f64) * samples_per_peak) as usize;
61        let end_sample = (((peak_idx as f64) + 1.0) * samples_per_peak) as usize;
62
63        let start_sample = start_sample * (channels as usize);
64        let end_sample = (end_sample * (channels as usize)).min(samples.len());
65
66        if start_sample >= samples.len() {
67            break;
68        }
69
70        let window_samples = &samples[start_sample..end_sample];
71
72        match channels {
73            1 => {
74                // Mono: find peak in window
75                let peak = window_samples
76                    .iter()
77                    .map(|&s| s.abs())
78                    .fold(0.0f32, f32::max);
79                peaks.push(peak);
80            }
81            2 => {
82                // Stereo: find peaks for each channel
83                let (left_peak, right_peak) = window_samples
84                    .as_chunks::<2>()
85                    .0
86                    .iter()
87                    .map(|chunk| (chunk[0].abs(), chunk[1].abs()))
88                    .fold((0.0f32, 0.0f32), |(l_max, r_max), (l, r)| {
89                        (l_max.max(l), r_max.max(r))
90                    });
91
92                // Combine stereo peaks (average)
93                peaks.push((left_peak + right_peak) / 2.0);
94            }
95            _ => {
96                // Unsupported channel count: treat as mono by averaging
97                let peak = window_samples
98                    .chunks(channels as usize)
99                    .map(|chunk| chunk.iter().copied().sum::<f32>() / (chunk.len() as f32))
100                    .fold(0.0f32, f32::max);
101                peaks.push(peak);
102            }
103        }
104    }
105
106    // Normalize peaks to 0.0-1.0 range
107    let max_peak = peaks.iter().fold(0.0f32, |max, &peak| max.max(peak));
108    if max_peak > 0.0 {
109        for peak in &mut peaks {
110            *peak /= max_peak;
111        }
112    }
113
114    WaveformData {
115        peaks_per_second,
116        peaks,
117        channels,
118    }
119}
120
121/// Extract waveform peaks using RMS method for smoother visualization
122///
123/// Similar to `extract_waveform_peaks` but uses RMS instead of peak values
124/// for a smoother visual representation
125pub fn extract_waveform_rms(
126    samples: &[FloSample],
127    channels: u8,
128    sample_rate: u32,
129    peaks_per_second: u32,
130) -> WaveformData {
131    if samples.is_empty() || channels == 0 || peaks_per_second == 0 || sample_rate == 0 {
132        return WaveformData {
133            peaks_per_second,
134            peaks: Vec::new(),
135            channels,
136        };
137    }
138
139    let samples_per_peak = (sample_rate as f64) / (peaks_per_second as f64);
140    let total_peaks =
141        libm::ceil((samples.len() as f64) / (samples_per_peak * (channels as f64))) as usize;
142
143    let mut peaks = Vec::with_capacity(total_peaks);
144
145    for peak_idx in 0..total_peaks {
146        let start_sample = ((peak_idx as f64) * samples_per_peak) as usize;
147        let end_sample = (((peak_idx as f64) + 1.0) * samples_per_peak) as usize;
148
149        let start_sample = start_sample * (channels as usize);
150        let end_sample = (end_sample * (channels as usize)).min(samples.len());
151
152        if start_sample >= samples.len() {
153            break;
154        }
155
156        let window_samples = &samples[start_sample..end_sample];
157
158        match channels {
159            1 => {
160                // Mono RMS
161                let rms = libm::sqrt(
162                    window_samples.iter().map(|&s| (s * s) as f64).sum::<f64>()
163                        / (window_samples.len() as f64),
164                ) as f32;
165                peaks.push(rms);
166            }
167            2 => {
168                // Stereo RMS
169                let (left_sum, right_sum, count) = window_samples.as_chunks::<2>().0.iter().fold(
170                    (0.0f64, 0.0f64, 0usize),
171                    |(l_sum, r_sum, count), chunk| {
172                        (
173                            l_sum + ((chunk[0] * chunk[0]) as f64),
174                            r_sum + ((chunk[1] * chunk[1]) as f64),
175                            count + 1,
176                        )
177                    },
178                );
179
180                let count = count.max(1); // Avoid division by zero
181                let left_rms = libm::sqrt(left_sum / (count as f64)) as f32;
182                let right_rms = libm::sqrt(right_sum / (count as f64)) as f32;
183
184                // Combine stereo RMS
185                peaks.push((left_rms + right_rms) / 2.0);
186            }
187            _ => {
188                // Unsupported channel count: treat as mono
189                let rms = libm::sqrt(
190                    window_samples
191                        .chunks(channels as usize)
192                        .map(|chunk| {
193                            let avg = chunk.iter().copied().sum::<f32>() / (chunk.len() as f32);
194                            (avg * avg) as f64
195                        })
196                        .sum::<f64>()
197                        / ((window_samples.len() / (channels as usize)) as f64),
198                ) as f32;
199                peaks.push(rms);
200            }
201        }
202    }
203
204    // Normalize RMS peaks to 0.0-1.0 range
205    let max_peak = peaks.iter().fold(0.0f32, |max, &peak| max.max(peak));
206    if max_peak > 0.0 {
207        for peak in &mut peaks {
208            *peak /= max_peak;
209        }
210    }
211
212    WaveformData {
213        peaks_per_second,
214        peaks,
215        channels,
216    }
217}
218
219/// Extract spectral fingerprint from audio samples
220///
221/// # Arguments
222/// * `samples` - Audio samples (interleaved if stereo)
223/// * `channels` - Number of audio channels (1 or 2)
224/// * `sample_rate` - Sample rate in Hz
225/// * `fft_size` - FFT window size (must be power of 2, default: 2048).
226///   Rounded down to a power of two and clamped to the active FFT backend's
227///   maximum supported length (512 under `fft-microfft`).
228/// * `hop_size` - Hop size between frames (default: fft_size/2 for 50% overlap)
229///
230/// # Returns
231/// `SpectralFingerprint` struct containing spectral analysis
232pub fn extract_spectral_fingerprint(
233    samples: &[FloSample],
234    channels: u8,
235    sample_rate: u32,
236    fft_size: Option<usize>,
237    hop_size: Option<usize>,
238) -> SpectralFingerprint {
239    if samples.is_empty() || channels == 0 || sample_rate == 0 {
240        return SpectralFingerprint {
241            hash: [0; 32],
242            duration_ms: 0,
243            sample_rate,
244            channels,
245            frequency_peaks: [0; 8],
246            energy_profile: [0; 16],
247            avg_loudness: 0,
248        };
249    }
250
251    let mut planner = DefaultPlanner::new();
252    let max_fft_len = planner.max_supported_len();
253
254    // Round the requested FFT size down to a supported power of two.
255    let fft_size = fft_size
256        .unwrap_or(2048)
257        .next_power_of_two()
258        .clamp(4, max_fft_len);
259    let hop_size = hop_size.unwrap_or(fft_size / 2).max(1);
260
261    // Calculate duration - ensure at least 1ms for any non-zero samples
262    let samples_per_channel = samples.len() / (channels as usize);
263    let duration_ms =
264        ((((samples_per_channel as f64) / (sample_rate as f64)) * 1000.0) as u32).max(1);
265
266    // Create BLAKE3 hash of audio content + format info
267    use blake3::Hasher;
268    let mut hasher = Hasher::new();
269
270    // Include format information in hash
271    hasher.update(&channels.to_le_bytes());
272    hasher.update(&sample_rate.to_le_bytes());
273    hasher.update(&(samples.len() as u32).to_le_bytes());
274
275    // Hash samples in chunks to avoid memory issues
276    for chunk in samples.chunks(1024) {
277        // SAFETY: f32 permits any bit pattern (including NaN), and `chunk` is a
278        // correctly aligned `[f32]`, so reinterpreting it as the same number of
279        // bytes is valid; we never mutate through this view.
280        let chunk_bytes = unsafe {
281            core::slice::from_raw_parts(chunk.as_ptr() as *const u8, core::mem::size_of_val(chunk))
282        };
283        hasher.update(chunk_bytes);
284    }
285    let hash = hasher.finalize().into();
286
287    // Compact spectral analysis using a Hann-windowed short FFT swept over the
288    // whole signal at the requested hop size.
289    let fft = planner.plan_fft(fft_size, FftDirection::Forward);
290    let mut fft_buffer = vec![Complex32 { re: 0.0, im: 0.0 }; fft_size];
291
292    // Hann window reduces spectral leakage between bins.
293    let window: Vec<f32> = (0..fft_size)
294        .map(|i| {
295            0.5 - 0.5
296                * libm::cosf((2.0 * core::f32::consts::PI * i as f32) / ((fft_size - 1) as f32))
297        })
298        .collect();
299
300    let mut frequency_bands = [0.0f32; 16];
301    let mut peak_bands = [0u8; 8];
302
303    // Frame the signal into (samples_per_channel - fft_size) / hop + 1 windows.
304    if samples_per_channel >= fft_size {
305        let num_frames = (samples_per_channel - fft_size) / hop_size + 1;
306
307        for frame_idx in 0..num_frames {
308            let sample_idx = frame_idx * hop_size;
309
310            // Extract mono samples for this window
311            for i in 0..fft_size {
312                let mut sample = 0.0;
313                for ch in 0..channels {
314                    let idx = (sample_idx + i) * (channels as usize) + (ch as usize);
315                    if idx < samples.len() {
316                        sample += samples[idx];
317                    }
318                }
319                sample /= channels as f32;
320                fft_buffer[i] = Complex32 {
321                    re: sample * window[i],
322                    im: 0.0,
323                };
324            }
325
326            // Apply FFT
327            fft.process(&mut fft_buffer);
328
329            // Calculate energy in frequency bands (16 bands)
330            for band in 0..16 {
331                let start_bin = (band * fft_size) / 32;
332                let end_bin = (((band + 1) * fft_size) / 32).min(fft_size / 2);
333                let mut energy = 0.0;
334                for bin in start_bin..end_bin {
335                    energy += fft_buffer[bin].re * fft_buffer[bin].re
336                        + fft_buffer[bin].im * fft_buffer[bin].im;
337                }
338                frequency_bands[band] += libm::sqrtf(energy);
339            }
340
341            // Track peak frequencies (8 bands)
342            for band in 0..8 {
343                let start_bin = (band * fft_size) / 16;
344                let end_bin = (((band + 1) * fft_size) / 16).min(fft_size / 2);
345
346                let (peak_bin, _) = (start_bin..end_bin)
347                    .map(|bin| {
348                        (
349                            bin,
350                            libm::sqrtf(
351                                fft_buffer[bin].re * fft_buffer[bin].re
352                                    + fft_buffer[bin].im * fft_buffer[bin].im,
353                            ),
354                        )
355                    })
356                    .max_by(|a, b| a.1.total_cmp(&b.1))
357                    .unwrap_or((0, 0.0));
358
359                // Convert to scaled u8 (log scale for better distribution)
360                let peak_value = (((peak_bin as f32) / (fft_size as f32)) * 255.0) as u8;
361                peak_bands[band] = peak_bands[band].max(peak_value);
362            }
363        }
364    }
365
366    // Normalize frequency bands to u8
367    let max_energy = frequency_bands.iter().cloned().fold(0.0f32, f32::max);
368    let energy_profile = if max_energy > 0.0 {
369        frequency_bands.map(|e| ((e / max_energy) * 255.0) as u8)
370    } else {
371        [0; 16]
372    };
373
374    // Average loudness: map RMS dBFS (0..-60) onto 0..255 where 255 is 0 dBFS.
375    let rms: f32 = samples.iter().map(|&s| s * s).sum::<f32>() / (samples.len() as f32);
376    let avg_loudness_db = (20.0 * libm::log10f(rms + 1e-10)).clamp(-60.0, 0.0);
377    let avg_loudness = ((avg_loudness_db + 60.0) / 60.0 * 255.0) as u8;
378
379    SpectralFingerprint {
380        hash,
381        duration_ms,
382        sample_rate,
383        channels,
384        frequency_peaks: peak_bands,
385        energy_profile,
386        avg_loudness,
387    }
388}
389
390/// Extract dominant frequencies from spectral fingerprint
391///
392/// # Arguments
393/// * `fingerprint` - Spectral fingerprint from `extract_spectral_fingerprint`
394/// * `num_frequencies` - Number of dominant frequencies to extract per frame
395///
396/// # Returns
397/// Vector of dominant frequencies (Hz) based on peak frequency bands
398pub fn extract_dominant_frequencies(
399    fingerprint: &SpectralFingerprint,
400    num_frequencies: usize,
401) -> Vec<Vec<f64>> {
402    let num_frequencies = num_frequencies.min(8); // Max 8 bands available
403    let mut dominant_freqs = Vec::with_capacity(1);
404    let mut frame_dominants = Vec::with_capacity(num_frequencies);
405
406    // Convert frequency peaks back to actual frequencies
407    for i in 0..num_frequencies {
408        // Map u8 back to frequency range (0-255 maps to 0Hz to Nyquist)
409        let normalized_freq = (fingerprint.frequency_peaks[i] as f64) / 255.0;
410        let frequency = normalized_freq * ((fingerprint.sample_rate as f64) / 2.0);
411        frame_dominants.push(frequency);
412    }
413
414    dominant_freqs.push(frame_dominants);
415    dominant_freqs
416}
417
418/// Compute spectral similarity between two fingerprints
419///
420/// # Arguments
421/// * `fingerprint1` - First spectral fingerprint
422/// * `fingerprint2` - Second spectral fingerprint
423///
424/// # Returns
425/// Similarity score between 0.0 (completely different) and 1.0 (identical)
426pub fn spectral_similarity(
427    fingerprint1: &SpectralFingerprint,
428    fingerprint2: &SpectralFingerprint,
429) -> f32 {
430    // If hashes match, it's the same content
431    let hash_match = fingerprint1.hash == fingerprint2.hash;
432
433    if hash_match {
434        return 1.0;
435    }
436
437    // Basic format compatibility check
438    if fingerprint1.sample_rate != fingerprint2.sample_rate
439        || fingerprint1.channels != fingerprint2.channels
440    {
441        return 0.0;
442    }
443
444    // Compare energy profiles (16 bands)
445    let energy_similarity: f32 = fingerprint1
446        .energy_profile
447        .iter()
448        .zip(fingerprint2.energy_profile.iter())
449        .map(|(a, b)| 1.0 - ((*a as f32) - (*b as f32)).abs() / 255.0)
450        .sum::<f32>()
451        / 16.0;
452
453    // Compare frequency peaks (8 bands)
454    let peak_similarity: f32 = fingerprint1
455        .frequency_peaks
456        .iter()
457        .zip(fingerprint2.frequency_peaks.iter())
458        .map(|(a, b)| 1.0 - ((*a as f32) - (*b as f32)).abs() / 255.0)
459        .sum::<f32>()
460        / 8.0;
461
462    // Compare loudness
463    let loudness_similarity = 1.0
464        - ((fingerprint1.avg_loudness as f32) - (fingerprint2.avg_loudness as f32)).abs() / 255.0;
465
466    // Weighted average (energy is most important for similarity)
467    energy_similarity * 0.5 + peak_similarity * 0.3 + loudness_similarity * 0.2
468}