nsm/crates/player/src/spectrum.rs
2026-07-13 09:24:20 +03:00

141 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::sync::Arc;
use realfft::num_complex::Complex;
use realfft::{RealFftPlanner, RealToComplex};
/// Размер окна FFT — степень двойки, разумный компромисс между частотным и временным
/// разрешением для визуализации (не для точного научного анализа).
pub const FFT_SIZE: usize = 1024;
/// Количество полос на выходе (логарифмическая шкала, как у обычных спектроанализаторов).
pub const SPECTRUM_BANDS: usize = 20;
pub struct SpectrumAnalyzer {
fft: Arc<dyn RealToComplex<f32>>,
window: Vec<f32>,
input_scratch: Vec<f32>,
output_scratch: Vec<Complex<f32>>,
}
impl SpectrumAnalyzer {
pub fn new() -> Self {
let mut planner = RealFftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_SIZE);
// окно Ханна — сглаживает края окна, уменьшает "утечку" спектра между полосами
let window: Vec<f32> = (0..FFT_SIZE)
.map(|i| {
0.5 - 0.5
* (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE as f32 - 1.0)).cos()
})
.collect();
let input_scratch = fft.make_input_vec();
let output_scratch = fft.make_output_vec();
SpectrumAnalyzer {
fft,
window,
input_scratch,
output_scratch,
}
}
/// `samples` — ровно `FFT_SIZE` моно-сэмплов (последнее окно). Возвращает `SPECTRUM_BANDS`
/// значений, сгруппированных по логарифмической шкале частот (как воспринимает слух),
/// нормализованных примерно в диапазон 0..1.
pub fn compute(&mut self, samples: &[f32], sample_rate: f32) -> [f32; SPECTRUM_BANDS] {
debug_assert_eq!(samples.len(), FFT_SIZE);
for i in 0..FFT_SIZE {
self.input_scratch[i] = samples[i] * self.window[i];
}
if self
.fft
.process(&mut self.input_scratch, &mut self.output_scratch)
.is_err()
{
return [0.0; SPECTRUM_BANDS];
}
let magnitudes: Vec<f32> = self
.output_scratch
.iter()
.map(|c| (c.re * c.re + c.im * c.im).sqrt())
.collect();
bucket_log_scale(&magnitudes, sample_rate)
}
}
impl Default for SpectrumAnalyzer {
fn default() -> Self {
Self::new()
}
}
/// Группирует линейные бины FFT в `SPECTRUM_BANDS` логарифмических полос от ~20Гц до
/// половины частоты дискретизации (или 20кГц, что меньше), и нормализует громким-сжатием
/// (sqrt) для более "живой" визуальной динамики на тихих сигналах.
fn bucket_log_scale(magnitudes: &[f32], sample_rate: f32) -> [f32; SPECTRUM_BANDS] {
let min_freq: f32 = 20.0;
let max_freq: f32 = (sample_rate / 2.0).min(20_000.0);
let log_min = min_freq.ln();
let log_max = max_freq.ln();
let bin_hz = sample_rate / FFT_SIZE as f32;
let mut bands = [0.0f32; SPECTRUM_BANDS];
for (b, band) in bands.iter_mut().enumerate() {
let f_lo = (log_min + (log_max - log_min) * b as f32 / SPECTRUM_BANDS as f32).exp();
let f_hi = (log_min + (log_max - log_min) * (b + 1) as f32 / SPECTRUM_BANDS as f32).exp();
let bin_lo = ((f_lo / bin_hz).round() as usize).max(1);
let bin_hi = ((f_hi / bin_hz).round() as usize)
.min(magnitudes.len().saturating_sub(1))
.max(bin_lo + 1);
let slice = &magnitudes[bin_lo.min(magnitudes.len())..bin_hi.min(magnitudes.len())];
let avg = if slice.is_empty() {
0.0
} else {
slice.iter().sum::<f32>() / slice.len() as f32
};
// нормализация: сырые магнитуды FFT могут быть большими числами без верхней границы
// конкретного смысла для отображения — берём корень и делим на эмпирическую константу,
// чтобб типичная громкая музыка укладывалась примерно в 0..1
*band = (avg.sqrt() / 12.0).clamp(0.0, 1.0);
}
bands
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sine_wave_peaks_in_expected_band() {
let sample_rate = 44100.0;
// тестовый тон 1000 Гц — должен дать пик в средних полосах, а не в самых низких/высоких
let freq = 1000.0f32;
let samples: Vec<f32> = (0..FFT_SIZE)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())
.collect();
let mut analyzer = SpectrumAnalyzer::new();
let bands = analyzer.compute(&samples, sample_rate);
let (peak_idx, peak_val) = bands
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap();
// не самая первая (бас) и не самая последняя (высокие) полоса
assert!(peak_idx > 2 && peak_idx < SPECTRUM_BANDS - 2, "peak at {peak_idx}");
assert!(*peak_val > 0.05, "peak too quiet: {peak_val}");
}
#[test]
fn silence_gives_near_zero_bands() {
let samples = vec![0.0f32; FFT_SIZE];
let mut analyzer = SpectrumAnalyzer::new();
let bands = analyzer.compute(&samples, 44100.0);
for b in bands {
assert!(b < 0.01, "expected near-zero for silence, got {b}");
}
}
}