#[cfg(feature = "async")]
use crate::AsyncFrameReader;
use crate::{FrameReader, FrameReaderConfig, TapReader};
use arrayvec::ArrayVec;
use rustfft::num_complex::Complex32;
use rustfft::{Fft, FftPlanner};
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
pub const LOW_FREQUENCY_HUMAN: f32 = 20.0;
pub const LOW_FREQUENCY_SUB_BASS: f32 = 10.0;
pub const TOP_FREQUENCY_HUMAN: f32 = 20_000.0;
pub const TOP_FREQUENCY_CD: f32 = 22_050.0;
pub const TOP_FREQUENCY_48K: f32 = 24_000.0;
#[derive(Debug, Clone)]
pub enum Transform {
FourierLog(usize),
FourierLinear(usize),
FourierCustom(Vec<FrequencyBin>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum VisualizerError {
PeriodMustBePositive,
BinCountMustBePositive,
MinFrequencyMustBePositive {
min_frequency_hz: f32,
},
MaxFrequencyMustExceedMin {
min_frequency_hz: f32,
max_frequency_hz: f32,
},
CustomBinsEmpty,
CustomBinLowerEdgeMustBePositive {
index: usize,
hz_lo: f32,
},
CustomBinUpperEdgeMustExceedLower {
index: usize,
hz_lo: f32,
hz_hi: f32,
},
}
impl std::fmt::Display for VisualizerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VisualizerError::PeriodMustBePositive => {
write!(f, "VisualizerConfig.period must be > 0")
}
VisualizerError::BinCountMustBePositive => {
write!(f, "transform bin count must be > 0")
}
VisualizerError::MinFrequencyMustBePositive { min_frequency_hz } => {
write!(f, "min_frequency_hz must be > 0 (got {min_frequency_hz})")
}
VisualizerError::MaxFrequencyMustExceedMin {
min_frequency_hz,
max_frequency_hz,
} => write!(
f,
"max_frequency_hz must be > min_frequency_hz (got min={min_frequency_hz}, max={max_frequency_hz})"
),
VisualizerError::CustomBinsEmpty => {
write!(f, "Transform::FourierCustom bins must not be empty")
}
VisualizerError::CustomBinLowerEdgeMustBePositive { index, hz_lo } => write!(
f,
"custom bin at index {index} must have hz_lo > 0 (got {hz_lo})"
),
VisualizerError::CustomBinUpperEdgeMustExceedLower {
index,
hz_lo,
hz_hi,
} => write!(
f,
"custom bin at index {index} must have hz_hi > hz_lo (got hz_lo={hz_lo}, hz_hi={hz_hi})"
),
}
}
}
impl std::error::Error for VisualizerError {}
impl Default for Transform {
fn default() -> Self {
Self::FourierLog(28)
}
}
impl Transform {
pub fn validate(
&self,
min_frequency_hz: f32,
max_frequency_hz: f32,
) -> Result<(), VisualizerError> {
match self {
Transform::FourierLog(num_bins) | Transform::FourierLinear(num_bins) => {
if *num_bins == 0 {
return Err(VisualizerError::BinCountMustBePositive);
}
if min_frequency_hz <= 0.0 {
return Err(VisualizerError::MinFrequencyMustBePositive { min_frequency_hz });
}
if max_frequency_hz <= min_frequency_hz {
return Err(VisualizerError::MaxFrequencyMustExceedMin {
min_frequency_hz,
max_frequency_hz,
});
}
}
Transform::FourierCustom(bins) => {
if bins.is_empty() {
return Err(VisualizerError::CustomBinsEmpty);
}
for (idx, bin) in bins.iter().enumerate() {
if bin.hz_lo <= 0.0 {
return Err(VisualizerError::CustomBinLowerEdgeMustBePositive {
index: idx,
hz_lo: bin.hz_lo,
});
}
if bin.hz_hi <= bin.hz_lo {
return Err(VisualizerError::CustomBinUpperEdgeMustExceedLower {
index: idx,
hz_lo: bin.hz_lo,
hz_hi: bin.hz_hi,
});
}
}
}
}
Ok(())
}
pub fn frequency_bins(
&self,
min_frequency_hz: f32,
max_frequency_hz: f32,
) -> Vec<FrequencyBin> {
match self {
Transform::FourierLog(_) => {
let edges = self.compute_log_edges(min_frequency_hz, max_frequency_hz);
edges_to_frequency_bins(&edges)
}
Transform::FourierLinear(_) => {
let edges = self.compute_linear_edges(min_frequency_hz, max_frequency_hz);
edges_to_frequency_bins(&edges)
}
Transform::FourierCustom(bins) => bins.clone(),
}
}
fn configured_max_frequency_hz(&self, max_frequency_hz: f32) -> f32 {
match self {
Transform::FourierLog(_) | Transform::FourierLinear(_) => max_frequency_hz,
Transform::FourierCustom(bins) => bins.iter().map(|bin| bin.hz_hi).fold(0.0, f32::max),
}
}
fn compute_log_edges(&self, min_hz: f32, max_hz: f32) -> Vec<f32> {
let num_bands = match self {
Transform::FourierLog(n) | Transform::FourierLinear(n) => *n,
Transform::FourierCustom(_) => {
panic!("Transform::FourierCustom does not generate computed log edges")
}
};
let mut edges = Vec::with_capacity(num_bands + 1);
let ratio = max_hz / min_hz;
for idx in 0..=num_bands {
let t = idx as f32 / num_bands as f32;
edges.push(min_hz * ratio.powf(t));
}
edges
}
fn compute_linear_edges(&self, min_hz: f32, max_hz: f32) -> Vec<f32> {
let num_bands = match self {
Transform::FourierLog(n) | Transform::FourierLinear(n) => *n,
Transform::FourierCustom(_) => {
panic!("Transform::FourierCustom does not generate computed linear edges")
}
};
let mut edges = Vec::with_capacity(num_bands + 1);
let span = max_hz - min_hz;
for idx in 0..=num_bands {
let t = idx as f32 / num_bands as f32;
edges.push(min_hz + span * t);
}
edges
}
}
#[derive(Debug, Clone)]
pub struct VisualizerConfig {
pub period: Duration,
pub transform: Transform,
pub min_frequency_hz: f32,
pub max_frequency_hz: f32,
pub normalize_by_fft_size: bool,
pub emit_before_fft_window_full: bool,
}
impl Default for VisualizerConfig {
fn default() -> Self {
Self {
period: Duration::from_millis(33),
transform: Transform::default(),
min_frequency_hz: LOW_FREQUENCY_HUMAN,
max_frequency_hz: TOP_FREQUENCY_HUMAN,
normalize_by_fft_size: false,
emit_before_fft_window_full: false,
}
}
}
impl VisualizerConfig {
pub fn validate(&self) -> Result<(), VisualizerError> {
if self.period.as_nanos() == 0 {
return Err(VisualizerError::PeriodMustBePositive);
}
self.transform
.validate(self.min_frequency_hz, self.max_frequency_hz)
}
pub fn frequency_bins(&self) -> Vec<FrequencyBin> {
self.transform
.frequency_bins(self.min_frequency_hz, self.max_frequency_hz)
}
fn configured_max_frequency_hz(&self) -> f32 {
self.transform
.configured_max_frequency_hz(self.max_frequency_hz)
}
}
#[derive(Debug, Clone)]
pub struct FrequencyBin {
pub hz_lo: f32,
pub hz_hi: f32,
}
impl FrequencyBin {
pub fn new(hz_lo: f32, hz_hi: f32) -> Self {
assert!(hz_lo > 0.0, "FrequencyBin.hz_lo must be > 0");
assert!(hz_hi > hz_lo, "FrequencyBin.hz_hi must be > hz_lo");
Self { hz_lo, hz_hi }
}
}
#[derive(Debug, Clone)]
pub struct ChannelSpectrum {
pub peak: f32,
pub rms: f32,
pub bins: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct VisualizerFrame {
pub sample_rate_hz: u32,
pub channels: Vec<ChannelSpectrum>,
}
pub struct Visualizer<const C: usize = 2> {
config: VisualizerConfig,
frequency_bins: Vec<FrequencyBin>,
histories: Vec<VecDeque<f32>>,
fft_planner: FftPlanner<f32>,
fft: Arc<dyn Fft<f32>>,
fft_input: Vec<Complex32>,
fft_spectrum: Vec<Complex32>,
fft_len: usize,
last_sample_rate_hz: Option<u32>,
}
impl<const C: usize> Visualizer<C> {
pub fn new(config: VisualizerConfig) -> Result<Self, VisualizerError> {
assert!(C > 0, "Visualizer requires C > 0");
config.validate()?;
let fft_len = 1usize;
let mut fft_planner = FftPlanner::new();
let fft = fft_planner.plan_fft_forward(fft_len);
Ok(Self {
frequency_bins: config.frequency_bins(),
histories: (0..C).map(|_| VecDeque::new()).collect(),
fft_planner,
fft,
fft_input: vec![Complex32::new(0.0, 0.0); fft_len],
fft_spectrum: vec![Complex32::new(0.0, 0.0); fft_len],
fft_len,
last_sample_rate_hz: None,
config,
})
}
pub fn config(&self) -> &VisualizerConfig {
&self.config
}
pub fn frequency_bins(&self) -> &[FrequencyBin] {
&self.frequency_bins
}
pub fn run_with_frame_reader<G, F>(tap_fn: G, config: VisualizerConfig, mut callback: F) -> !
where
G: Fn() -> Option<Arc<TapReader<C>>> + Send + Sync + 'static,
F: FnMut(&[ChannelSpectrum], u32) + Send + 'static,
{
let reader_config = FrameReaderConfig {
time_per_batch: Some(config.period),
frames_per_batch: None,
..Default::default()
};
let mut reader = FrameReader::<C>::new_with_config(reader_config, tap_fn);
let mut visualizer = Visualizer::<C>::new(config)
.unwrap_or_else(|err| panic!("Visualizer config is invalid: {err}"));
reader.run(move |batch, channels, sample_rate_hz| {
if let Some(frame) = visualizer.process_batch(batch, channels, sample_rate_hz) {
callback(&frame.channels, frame.sample_rate_hz);
}
});
}
#[cfg(feature = "async")]
pub async fn run_with_frame_reader_async<G, F>(
tap_fn: G,
config: VisualizerConfig,
mut callback: F,
) -> !
where
G: Fn() -> Option<Arc<TapReader<C>>> + Send + Sync + 'static,
F: FnMut(&[ChannelSpectrum], u32) + Send + 'static,
{
let reader_config = FrameReaderConfig {
time_per_batch: Some(config.period),
frames_per_batch: None,
..Default::default()
};
let mut reader = AsyncFrameReader::<C>::new_with_config(reader_config, tap_fn);
let mut visualizer = Visualizer::<C>::new(config)
.unwrap_or_else(|err| panic!("Visualizer config is invalid: {err}"));
reader
.run(move |batch, channels, sample_rate_hz| {
if let Some(frame) = visualizer.process_batch(batch, channels, sample_rate_hz) {
callback(&frame.channels, frame.sample_rate_hz);
}
})
.await
}
pub fn process_batch(
&mut self,
batch: &[ArrayVec<f32, C>],
channels: usize,
sample_rate_hz: u32,
) -> Option<VisualizerFrame> {
if channels == 0 || channels > C || sample_rate_hz == 0 || batch.is_empty() {
return None;
}
let fft_len = derive_fft_len(self.config.period, sample_rate_hz);
let sample_rate_changed = self.last_sample_rate_hz != Some(sample_rate_hz);
if sample_rate_changed {
for history in &mut self.histories {
history.clear();
}
self.last_sample_rate_hz = Some(sample_rate_hz);
}
if fft_len != self.fft_len {
self.reconfigure_fft(fft_len);
}
let mut peak = vec![0.0_f32; channels];
let mut sum_sq = vec![0.0_f32; channels];
let mut count = vec![0usize; channels];
for frame in batch {
if frame.len() < channels {
continue;
}
for ch in 0..channels {
let sample = frame[ch];
let abs = sample.abs();
if abs > peak[ch] {
peak[ch] = abs;
}
sum_sq[ch] += sample * sample;
count[ch] += 1;
let history = &mut self.histories[ch];
if history.len() == self.fft_len {
history.pop_front();
}
history.push_back(sample);
}
}
if !self.config.emit_before_fft_window_full
&& self.histories[..channels]
.iter()
.any(|history| history.len() < self.fft_len)
{
return None;
}
let mut channel_spectra = Vec::with_capacity(channels);
for ch in 0..channels {
let magnitudes = self.compute_fft_magnitudes(ch, sample_rate_hz);
let bins = self.compute_bin_magnitudes(sample_rate_hz, &magnitudes);
let rms = if count[ch] > 0 {
(sum_sq[ch] / count[ch] as f32).sqrt()
} else {
0.0
};
channel_spectra.push(ChannelSpectrum {
peak: peak[ch],
rms,
bins,
});
}
Some(VisualizerFrame {
sample_rate_hz,
channels: channel_spectra,
})
}
fn reconfigure_fft(&mut self, fft_len: usize) {
self.fft_len = fft_len.max(1);
self.fft = self.fft_planner.plan_fft_forward(self.fft_len);
self.fft_input = vec![Complex32::new(0.0, 0.0); self.fft_len];
self.fft_spectrum = vec![Complex32::new(0.0, 0.0); self.fft_len];
for history in &mut self.histories {
while history.len() > self.fft_len {
history.pop_front();
}
}
}
fn compute_fft_magnitudes(&mut self, channel: usize, sample_rate_hz: u32) -> Vec<f32> {
let history = &self.histories[channel];
self.fft_input.fill(Complex32::new(0.0, 0.0));
let start = self.fft_len.saturating_sub(history.len());
for (i, sample) in history.iter().enumerate() {
let idx = start + i;
let windowed = *sample * hann_window(idx, self.fft_len);
self.fft_input[idx] = Complex32::new(windowed, 0.0);
}
self.fft_spectrum.copy_from_slice(&self.fft_input);
self.fft.process(&mut self.fft_spectrum);
let max_bin = (self.fft_len / 2).max(1);
let mut magnitudes = vec![0.0_f32; max_bin + 1];
for (idx, slot) in magnitudes.iter_mut().enumerate() {
let c = self.fft_spectrum[idx];
let mut mag = (c.re * c.re + c.im * c.im).sqrt();
if self.config.normalize_by_fft_size {
mag /= self.fft_len as f32;
}
*slot = mag;
}
let effective_max_hz = self
.config
.configured_max_frequency_hz()
.min(sample_rate_hz as f32 * 0.5);
if effective_max_hz <= 0.0 {
magnitudes.fill(0.0);
}
magnitudes
}
fn compute_bin_magnitudes(&self, sample_rate_hz: u32, magnitudes: &[f32]) -> Vec<f32> {
let mut bins = Vec::with_capacity(self.frequency_bins.len());
let nyquist = sample_rate_hz as f32 * 0.5;
let effective_max = self.config.configured_max_frequency_hz().min(nyquist);
let max_bin = magnitudes.len().saturating_sub(1);
for band in &self.frequency_bins {
let hz_lo = band.hz_lo;
let hz_hi = band.hz_hi;
let magnitude = if hz_lo >= effective_max || max_bin == 0 {
0.0
} else {
let analyze_hi = hz_hi.min(effective_max);
if analyze_hi <= hz_lo {
0.0
} else {
let bin_lo = hz_to_bin(hz_lo, self.fft_len, sample_rate_hz).min(max_bin);
let bin_hi = hz_to_bin(analyze_hi, self.fft_len, sample_rate_hz).min(max_bin);
if bin_hi < bin_lo {
0.0
} else {
let mut sum = 0.0_f32;
let mut n = 0usize;
for &mag in &magnitudes[bin_lo..=bin_hi] {
sum += mag;
n += 1;
}
if n == 0 { 0.0 } else { sum / n as f32 }
}
}
};
bins.push(magnitude);
}
bins
}
}
pub(crate) fn derive_fft_len(period: Duration, sample_rate_hz: u32) -> usize {
let frames =
((sample_rate_hz as u128 * period.as_nanos() + 500_000_000) / 1_000_000_000).max(1);
let target = usize::try_from(frames).unwrap_or(usize::MAX / 2).max(1);
target.next_power_of_two().max(1)
}
pub(crate) fn edges_to_frequency_bins(edges: &[f32]) -> Vec<FrequencyBin> {
edges
.windows(2)
.map(|range| FrequencyBin::new(range[0], range[1]))
.collect()
}
pub(crate) fn hz_to_bin(hz: f32, fft_len: usize, sample_rate_hz: u32) -> usize {
if sample_rate_hz == 0 {
return 0;
}
(((hz * fft_len as f32) / sample_rate_hz as f32).floor() as usize).min(fft_len / 2)
}
pub(crate) fn hann_window(index: usize, len: usize) -> f32 {
if len <= 1 {
1.0
} else {
let n = index as f32;
let denom = (len - 1) as f32;
0.5 - 0.5 * (2.0 * std::f32::consts::PI * n / denom).cos()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
#[test]
fn transform_validate_rejects_zero_bins() {
let err = Transform::FourierLog(0)
.validate(LOW_FREQUENCY_HUMAN, TOP_FREQUENCY_HUMAN)
.unwrap_err();
assert_eq!(err, VisualizerError::BinCountMustBePositive);
}
#[test]
fn transform_validate_custom_allows_overlapping_bins() {
let transform = Transform::FourierCustom(vec![
FrequencyBin::new(20.0, 100.0),
FrequencyBin::new(90.0, 200.0),
]);
assert!(
transform
.validate(LOW_FREQUENCY_HUMAN, TOP_FREQUENCY_HUMAN)
.is_ok()
);
}
#[test]
fn transform_validate_custom_accepts_valid_bins() {
let transform = Transform::FourierCustom(vec![
FrequencyBin::new(20.0, 100.0),
FrequencyBin::new(100.0, 300.0),
FrequencyBin::new(300.0, 1_000.0),
]);
assert!(
transform
.validate(LOW_FREQUENCY_HUMAN, TOP_FREQUENCY_HUMAN)
.is_ok()
);
}
#[test]
fn transform_frequency_bins_linear_have_even_spacing() {
let bins = Transform::FourierLinear(4).frequency_bins(20.0, 220.0);
assert_eq!(bins.len(), 4);
let widths: Vec<f32> = bins.iter().map(|bin| bin.hz_hi - bin.hz_lo).collect();
assert!(approx_eq(widths[0], widths[1], 1e-5));
assert!(approx_eq(widths[1], widths[2], 1e-5));
assert!(approx_eq(widths[2], widths[3], 1e-5));
assert!(approx_eq(bins[0].hz_lo, 20.0, 1e-5));
assert!(approx_eq(bins[3].hz_hi, 220.0, 1e-5));
}
#[test]
fn transform_frequency_bins_log_have_constant_ratio_edges() {
let bins = Transform::FourierLog(4).frequency_bins(10.0, 160.0);
assert_eq!(bins.len(), 4);
let edges = [
bins[0].hz_lo,
bins[0].hz_hi,
bins[1].hz_hi,
bins[2].hz_hi,
bins[3].hz_hi,
];
let r0 = edges[1] / edges[0];
let r1 = edges[2] / edges[1];
let r2 = edges[3] / edges[2];
let r3 = edges[4] / edges[3];
assert!(approx_eq(r0, r1, 1e-5));
assert!(approx_eq(r1, r2, 1e-5));
assert!(approx_eq(r2, r3, 1e-5));
assert!(approx_eq(edges[0], 10.0, 1e-5));
assert!(approx_eq(edges[4], 160.0, 1e-4));
}
#[test]
fn visualizer_config_validate_checks_period() {
let config = VisualizerConfig {
period: Duration::from_nanos(0),
..Default::default()
};
assert_eq!(
config.validate(),
Err(VisualizerError::PeriodMustBePositive)
);
}
#[test]
fn visualizer_new_returns_error_for_invalid_transform() {
let config = VisualizerConfig {
transform: Transform::FourierLog(0),
..Default::default()
};
assert!(matches!(
Visualizer::<2>::new(config),
Err(VisualizerError::BinCountMustBePositive)
));
}
}