use std::{sync::Arc, time::Duration}; use rustfft::{Fft, FftPlanner, num_complex::Complex32}; use crate::cli::{Analysis, Cli, Detector, FrequencyScale}; #[derive(Clone)] pub struct SpectrumConfig { pub fft_size: usize, pub min_freq: f32, pub max_freq: f32, pub frequency_scale: FrequencyScale, pub detector: Detector, pub analysis: Analysis, pub db_floor: f32, pub db_ceiling: f32, pub attack_ms: f32, pub release_ms: f32, pub spatial_smoothing: f32, pub contrast: f32, pub spectral_contrast: f32, pub audio_duration_ms: f32, pub audio_offset_ms: f32, pub whitening: f32, pub peak_isolation: f32, pub spectral_flux: f32, pub adaptive_gain: f32, pub adaptive_window_ms: f32, pub adaptive_max_boost_db: f32, pub adaptive_max_cut_db: f32, pub root_harmonics: u8, pub root_isolation: f32, pub fundamental_core: f32, pub harmonic_halo: f32, pub freeze_mix: f32, pub morph_speed: f32, pub harmonics: u8, pub harmonic_boost: f32, pub tilt: f32, pub low_shelf_hz: f32, pub low_shelf_attenuation_db: f32, pub high_shelf_hz: f32, pub high_shelf_attenuation_db: f32, pub shelf_width_octaves: f32, pub compressor_threshold_db: f32, pub compressor_ratio: f32, pub post_filter_gain_db: f32, pub peak_hold_ms: f32, pub peak_decay_db: f32, pub glow_release_ms: f32, } impl From<&Cli> for SpectrumConfig { fn from(cli: &Cli) -> Self { Self { fft_size: cli.fft_size, min_freq: cli.min_freq, max_freq: cli.max_freq, frequency_scale: cli.frequency_scale, detector: cli.detector, analysis: cli.analysis, db_floor: cli.db_floor, db_ceiling: cli.db_ceiling, attack_ms: cli.attack, release_ms: cli.release, spatial_smoothing: cli.spatial_smoothing, contrast: cli.contrast, spectral_contrast: cli.spectral_contrast, audio_duration_ms: cli.audio_duration, audio_offset_ms: cli.audio_offset, whitening: cli.whitening, peak_isolation: cli.peak_isolation, spectral_flux: cli.spectral_flux, adaptive_gain: cli.adaptive_gain, adaptive_window_ms: cli.adaptive_window, adaptive_max_boost_db: cli.adaptive_max_boost, adaptive_max_cut_db: cli.adaptive_max_cut, root_harmonics: cli.root_harmonics, root_isolation: cli.root_isolation, fundamental_core: cli.fundamental_core, harmonic_halo: cli.harmonic_halo, freeze_mix: cli.freeze_mix, morph_speed: cli.morph_speed, harmonics: cli.harmonics, harmonic_boost: cli.harmonic_boost, tilt: cli.tilt, low_shelf_hz: cli.low_shelf, low_shelf_attenuation_db: cli.low_shelf_attenuation, high_shelf_hz: cli.high_shelf, high_shelf_attenuation_db: cli.high_shelf_attenuation, shelf_width_octaves: cli.shelf_width, compressor_threshold_db: cli.compressor_threshold, compressor_ratio: cli.compressor_ratio, post_filter_gain_db: cli.post_filter_gain, peak_hold_ms: cli.peak_hold, peak_decay_db: cli.peak_decay, glow_release_ms: cli.glow_release, } } } pub struct Spectrum { config: SpectrumConfig, sample_rate: f32, history: Vec, history_write: usize, history_len: usize, offset_samples: usize, window: Vec, window_sum: f32, fft: Arc>, fft_buffer: Vec, fft_scratch: Vec, band_map: Vec, harmonic_targets: Vec, previous_phase: Vec, bin_coherence: Vec, phase_ready: bool, samples_since_update: usize, targets: Vec, duration_levels: Vec, previous_targets: Vec, adaptive_reference: Vec, effect_work: Vec, spatial_work: Vec, levels: Vec, glow_levels: Vec, tonal_levels: Vec, transient_levels: Vec, fundamental_levels: Vec, harmonic_levels: Vec, frozen_levels: Vec, freeze_active: bool, peaks: Vec, peak_holds: Vec, descriptors: SpectralDescriptors, } #[derive(Clone, Copy)] struct BandMap { first_bin: usize, last_bin: usize, center_bin: f32, center_frequency: f32, weight_db: f32, } pub struct SpectrumFrame<'a> { pub levels: &'a [f32], pub peaks: &'a [f32], pub glow_levels: &'a [f32], pub tonal_levels: &'a [f32], pub transient_levels: &'a [f32], pub fundamental_levels: &'a [f32], pub harmonic_levels: &'a [f32], pub descriptors: SpectralDescriptors, } #[derive(Clone, Copy, Debug, Default)] pub struct SpectralDescriptors { pub centroid: f32, pub rolloff: f32, pub flatness: f32, pub crest: f32, pub bass_ratio: f32, pub coherence: f32, pub onset: f32, pub fundamental_hz: f32, pub formant_bands: [usize; 3], } impl Spectrum { pub fn new(config: SpectrumConfig, sample_rate: u32) -> Self { let mut planner = FftPlanner::new(); let fft = planner.plan_fft_forward(config.fft_size); let fft_scratch = vec![Complex32::default(); fft.get_inplace_scratch_len()]; let window: Vec = (0..config.fft_size) .map(|index| { let phase = std::f32::consts::TAU * index as f32 / (config.fft_size.saturating_sub(1)) as f32; 0.5 - 0.5 * phase.cos() }) .collect(); let window_sum = window.iter().sum(); let offset_samples = (config.audio_offset_ms / 1000.0 * sample_rate as f32).round() as usize; Self { history: vec![0.0; config.fft_size + offset_samples], history_write: 0, history_len: 0, offset_samples, fft_buffer: vec![Complex32::default(); config.fft_size], fft_scratch, window, window_sum, fft, sample_rate: sample_rate as f32, band_map: Vec::new(), harmonic_targets: Vec::new(), previous_phase: vec![0.0; config.fft_size / 2 + 1], bin_coherence: vec![0.0; config.fft_size / 2 + 1], phase_ready: false, samples_since_update: 0, targets: Vec::new(), duration_levels: Vec::new(), previous_targets: Vec::new(), adaptive_reference: Vec::new(), effect_work: Vec::new(), spatial_work: Vec::new(), levels: Vec::new(), glow_levels: Vec::new(), tonal_levels: Vec::new(), transient_levels: Vec::new(), fundamental_levels: Vec::new(), harmonic_levels: Vec::new(), frozen_levels: Vec::new(), freeze_active: false, peaks: Vec::new(), peak_holds: Vec::new(), descriptors: SpectralDescriptors::default(), config, } } pub fn push(&mut self, samples: &[f32]) { self.samples_since_update = self.samples_since_update.saturating_add(samples.len()); let capacity = self.history.len(); let samples = if samples.len() >= capacity { &samples[samples.len() - capacity..] } else { samples }; if samples.len() == capacity { self.history.copy_from_slice(samples); self.history_write = 0; self.history_len = capacity; return; } let first = samples.len().min(capacity - self.history_write); self.history[self.history_write..self.history_write + first] .copy_from_slice(&samples[..first]); let remaining = samples.len() - first; self.history[..remaining].copy_from_slice(&samples[first..]); self.history_write = (self.history_write + samples.len()) % capacity; self.history_len = (self.history_len + samples.len()).min(capacity); } pub fn update(&mut self, bands: usize, elapsed: Duration, gain_db: f32) -> SpectrumFrame<'_> { self.resize_bands(bands); let dt = elapsed.as_secs_f32().min(0.25); let available = self.history_len.saturating_sub(self.offset_samples); let window_len = available.min(self.config.fft_size); let padding = self.config.fft_size - window_len; self.fft_buffer[..padding].fill(Complex32::default()); let oldest = if self.history_len == self.history.len() { self.history_write } else { 0 }; let window_start = self.history_len - self.offset_samples.min(self.history_len) - window_len; let history_start = (oldest + window_start) % self.history.len(); let first_len = window_len.min(self.history.len() - history_start); window_samples( &self.history[history_start..history_start + first_len], &self.window[padding..padding + first_len], &mut self.fft_buffer[padding..padding + first_len], ); let remaining = window_len - first_len; window_samples( &self.history[..remaining], &self.window[padding + first_len..padding + window_len], &mut self.fft_buffer[padding + first_len..padding + window_len], ); self.fft .process_with_scratch(&mut self.fft_buffer, &mut self.fft_scratch); self.update_phase_and_descriptors(); self.update_targets(gain_db); self.apply_audio_duration(dt); self.apply_adaptive_gain(dt); self.classify_tonal_and_transient(); self.apply_spectral_effects(); self.classify_fundamental_and_harmonics(); self.apply_freeze(dt); spatial_smooth( &mut self.targets, &mut self.spatial_work, self.config.spatial_smoothing, ); let attack = smoothing_factor(dt, self.config.attack_ms / 1000.0); let release = smoothing_factor(dt, self.config.release_ms / 1000.0); let glow_attack = smoothing_factor(dt, self.config.attack_ms * 2.0 / 1000.0); let glow_release = smoothing_factor(dt, self.config.glow_release_ms / 1000.0); let range = self.config.db_ceiling - self.config.db_floor; let peak_decay = self.config.peak_decay_db / range * dt; for (index, target) in self.targets.iter().copied().enumerate() { let coefficient = if target > self.levels[index] { attack } else { release }; self.levels[index] += (target - self.levels[index]) * coefficient; let glow_coefficient = if target > self.glow_levels[index] { glow_attack } else { glow_release }; self.glow_levels[index] += (target - self.glow_levels[index]) * glow_coefficient; if self.levels[index] >= self.peaks[index] { self.peaks[index] = self.levels[index]; self.peak_holds[index] = self.config.peak_hold_ms / 1000.0; } else if self.peak_holds[index] > 0.0 { self.peak_holds[index] -= dt; } else { self.peaks[index] = (self.peaks[index] - peak_decay).max(self.levels[index]); } } SpectrumFrame { levels: &self.levels, peaks: &self.peaks, glow_levels: &self.glow_levels, tonal_levels: &self.tonal_levels, transient_levels: &self.transient_levels, fundamental_levels: &self.fundamental_levels, harmonic_levels: &self.harmonic_levels, descriptors: self.descriptors, } } pub fn toggle_freeze(&mut self) { self.freeze_active = !self.freeze_active; if self.freeze_active { self.frozen_levels.copy_from_slice(&self.levels); } } pub fn freeze_active(&self) -> bool { self.freeze_active } fn resize_bands(&mut self, bands: usize) { if self.band_map.len() != bands { self.rebuild_band_map(bands); } self.targets.resize(bands, 0.0); self.duration_levels.resize(bands, 0.0); self.previous_targets.resize(bands, 0.0); self.adaptive_reference.resize(bands, 0.0); self.effect_work.resize(bands, 0.0); self.spatial_work.resize(bands, 0.0); self.levels.resize(bands, 0.0); self.glow_levels.resize(bands, 0.0); self.tonal_levels.resize(bands, 0.0); self.transient_levels.resize(bands, 0.0); self.fundamental_levels.resize(bands, 0.0); self.harmonic_levels.resize(bands, 0.0); self.frozen_levels.resize(bands, 0.0); self.peaks.resize(bands, 0.0); self.peak_holds.resize(bands, 0.0); } fn rebuild_band_map(&mut self, bands: usize) { let nyquist = self.sample_rate / 2.0; let min_freq = self.config.min_freq.min(nyquist * 0.9); let max_freq = self.config.max_freq.min(nyquist); let bin_width = self.sample_rate / self.config.fft_size as f32; self.band_map.clear(); self.band_map.reserve(bands); for band in 0..bands { let low = band_frequency( self.config.frequency_scale, min_freq, max_freq, band as f32 / bands as f32, ); let high = band_frequency( self.config.frequency_scale, min_freq, max_freq, (band + 1) as f32 / bands as f32, ); // Ceil-to-ceil boundaries assign every FFT bin to at most one band. let first_bin = ((low / bin_width).ceil() as usize).max(1); let last_bin = ((high / bin_width).ceil() as usize).min(self.config.fft_size / 2 + 1); let center = band_frequency( self.config.frequency_scale, min_freq, max_freq, (band as f32 + 0.5) / bands as f32, ); self.band_map.push(BandMap { first_bin, last_bin, center_bin: center / bin_width, center_frequency: center, weight_db: self.config.tilt * (center / min_freq).log2() + low_shelf_weight( center, self.config.low_shelf_hz, self.config.low_shelf_attenuation_db, self.config.shelf_width_octaves, ) + high_shelf_weight( center, self.config.high_shelf_hz, self.config.high_shelf_attenuation_db, self.config.shelf_width_octaves, ), }); } self.harmonic_targets.clear(); self.harmonic_targets.resize(bands * 13, usize::MAX); for source in 0..bands { let fundamental = self.band_map[source].center_frequency; for harmonic in 2..=12 { let target_frequency = fundamental * harmonic as f32; let target = self .band_map .partition_point(|band| band.center_frequency < target_frequency); if target < bands { self.harmonic_targets[source * 13 + harmonic] = target; } } } } fn update_phase_and_descriptors(&mut self) { let min_bin = (self.config.min_freq * self.config.fft_size as f32 / self.sample_rate).ceil() as usize; let max_bin = (self.config.max_freq.min(self.sample_rate / 2.0) * self.config.fft_size as f32 / self.sample_rate) .ceil() as usize; let start = min_bin.max(1); let end = max_bin.min(self.config.fft_size / 2 + 1); let hop = self.samples_since_update.min(self.config.fft_size) as f32; self.samples_since_update = 0; let mut magnitude_sum = 0.0; let mut weighted_frequency = 0.0; let mut log_sum = 0.0; let mut maximum = 0.0_f32; let mut coherence_sum = 0.0; let mut bass_sum = 0.0; for bin in start..end { let value = self.fft_buffer[bin]; let magnitude = value.norm(); let phase = value.arg(); let expected = std::f32::consts::TAU * bin as f32 * hop / self.config.fft_size as f32; let residual = wrap_phase(phase - self.previous_phase[bin] - expected); let coherence = if self.phase_ready { (residual.cos() * 0.5 + 0.5).powi(2) } else { 0.5 }; self.previous_phase[bin] = phase; self.bin_coherence[bin] = coherence; let frequency = bin as f32 * self.sample_rate / self.config.fft_size as f32; magnitude_sum += magnitude; if frequency <= 250.0 { bass_sum += magnitude; } weighted_frequency += magnitude * frequency; log_sum += magnitude.max(1e-12).ln(); maximum = maximum.max(magnitude); coherence_sum += coherence; } self.phase_ready = true; let count = end.saturating_sub(start).max(1) as f32; let mean = magnitude_sum / count; let centroid_hz = weighted_frequency / magnitude_sum.max(1e-12); let frequency_range = (self.config.max_freq - self.config.min_freq).max(1.0); self.descriptors.centroid = ((centroid_hz - self.config.min_freq) / frequency_range).clamp(0.0, 1.0); self.descriptors.flatness = ((log_sum / count).exp() / mean.max(1e-12)).clamp(0.0, 1.0); self.descriptors.crest = (maximum / mean.max(1e-12) / 12.0).clamp(0.0, 1.0); self.descriptors.bass_ratio = (bass_sum / magnitude_sum.max(1e-12)).clamp(0.0, 1.0); self.descriptors.coherence = (coherence_sum / count).clamp(0.0, 1.0); let rolloff_target = magnitude_sum * 0.85; let mut cumulative = 0.0; let mut rolloff_bin = start; for bin in start..end { cumulative += self.fft_buffer[bin].norm(); if cumulative >= rolloff_target { rolloff_bin = bin; break; } } let rolloff_hz = rolloff_bin as f32 * self.sample_rate / self.config.fft_size as f32; self.descriptors.rolloff = ((rolloff_hz - self.config.min_freq) / frequency_range).clamp(0.0, 1.0); } fn apply_adaptive_gain(&mut self, dt: f32) { if self.config.adaptive_gain <= 0.0 { self.adaptive_reference.copy_from_slice(&self.targets); return; } let coefficient = smoothing_factor(dt, self.config.adaptive_window_ms / 1000.0); let max_boost = 10.0_f32.powf(self.config.adaptive_max_boost_db / 20.0); let max_cut = 10.0_f32.powf(self.config.adaptive_max_cut_db / 20.0); for index in 0..self.targets.len() { let target = self.targets[index]; let reference = &mut self.adaptive_reference[index]; if *reference <= 0.0 { *reference = target; } else { *reference += (target - *reference) * coefficient; } let normalized = (target / (*reference + 0.08) * 0.45) .clamp(target / max_cut, (target * max_boost).min(1.0)); self.targets[index] = target + (normalized - target) * self.config.adaptive_gain; } } fn classify_tonal_and_transient(&mut self) { let mut onset = 0.0_f32; for index in 0..self.targets.len() { let band = self.band_map[index]; let coherence = if band.first_bin < band.last_bin { self.bin_coherence[band.first_bin..band.last_bin] .iter() .sum::() / (band.last_bin - band.first_bin) as f32 } else { let bin = band.center_bin.round() as usize; self.bin_coherence[bin.min(self.bin_coherence.len() - 1)] }; let delta = (self.targets[index] - self.previous_targets[index]).max(0.0); self.tonal_levels[index] = self.targets[index] * coherence; self.transient_levels[index] = (delta * 4.0 * (1.25 - coherence * 0.5)).clamp(0.0, 1.0); onset = onset.max(self.transient_levels[index]); } self.descriptors.onset = onset; } fn classify_fundamental_and_harmonics(&mut self) { self.fundamental_levels.fill(0.0); self.harmonic_levels.fill(0.0); self.descriptors.fundamental_hz = 0.0; if self.targets.is_empty() { return; } let mut best_band = 0; let mut best_score = 0.0; for source in 0..self.targets.len() { if self.band_map[source].center_frequency > 1_500.0 { break; } let mut score = self.targets[source]; for harmonic in 2..=6 { let target = self.harmonic_targets[source * 13 + harmonic]; if target != usize::MAX { score += self.targets[target] / harmonic as f32; } } if score > best_score { best_score = score; best_band = source; } } if best_score > 0.05 { self.fundamental_levels[best_band] = self.targets[best_band] * self.config.fundamental_core; self.descriptors.fundamental_hz = self.band_map[best_band].center_frequency; for harmonic in 2..=12 { let target = self.harmonic_targets[best_band * 13 + harmonic]; if target != usize::MAX { self.harmonic_levels[target] = self.targets[target] * self.config.harmonic_halo; } } } self.descriptors.formant_bands = strongest_formant_candidates(&self.targets, &self.band_map); } fn apply_freeze(&mut self, dt: f32) { if !self.freeze_active || self.config.freeze_mix <= 0.0 { return; } for index in 0..self.targets.len() { if self.config.morph_speed > 0.0 { self.frozen_levels[index] += (self.targets[index] - self.frozen_levels[index]) * (dt * self.config.morph_speed).min(1.0); } self.targets[index] = self.targets[index] * (1.0 - self.config.freeze_mix) + self.frozen_levels[index] * self.config.freeze_mix; } } fn update_targets(&mut self, gain_db: f32) { if self.config.analysis == Analysis::Chroma { self.update_chroma_targets(gain_db); return; } let range = self.config.db_ceiling - self.config.db_floor; let normalization = 2.0 / self.window_sum; for (target, band) in self.targets.iter_mut().zip(&self.band_map) { let magnitude = if band.first_bin < band.last_bin { let values = &self.fft_buffer[band.first_bin..band.last_bin]; match self.config.detector { Detector::Peak => values.iter().map(|value| value.norm()).fold(0.0, f32::max), Detector::Rms => (values.iter().map(|value| value.norm_sqr()).sum::() / values.len() as f32) .sqrt(), } } else { interpolated_magnitude(&self.fft_buffer, band.center_bin) }; let db = 20.0 * (magnitude * normalization).max(1e-9).log10() + gain_db + band.weight_db; let db = compress_db( db, self.config.compressor_threshold_db, self.config.compressor_ratio, ) + self.config.post_filter_gain_db; *target = ((db - self.config.db_floor) / range) .clamp(0.0, 1.0) .powf(self.config.contrast); } if self.config.analysis == Analysis::Fundamentals { self.apply_fundamental_salience(gain_db); } } fn apply_fundamental_salience(&mut self, gain_db: f32) { self.effect_work.copy_from_slice(&self.targets); let nyquist = self.sample_rate * 0.5; for index in 0..self.targets.len() { let base = self.effect_work[index]; let fundamental = self.band_map[index].center_frequency; let mut support = 0.0; let mut weight_sum = 0.0; for harmonic in 2..=self.config.root_harmonics { let frequency = fundamental * f32::from(harmonic); if frequency >= nyquist { break; } let weight = 1.0 / f32::from(harmonic).sqrt(); support += self.level_at_frequency(frequency, gain_db) * weight; weight_sum += weight; } let support = if weight_sum > 0.0 { support / weight_sum } else { base }; let retention = 1.0 - self.config.root_isolation * (1.0 - support); self.targets[index] = (base * retention).clamp(0.0, 1.0); } } fn level_at_frequency(&self, frequency: f32, gain_db: f32) -> f32 { let bin = frequency * self.config.fft_size as f32 / self.sample_rate; let magnitude = [-1.0, 0.0, 1.0] .into_iter() .map(|offset| interpolated_magnitude(&self.fft_buffer, bin + offset)) .fold(0.0, f32::max); let weight_db = self.config.tilt * (frequency / self.config.min_freq).log2() + low_shelf_weight( frequency, self.config.low_shelf_hz, self.config.low_shelf_attenuation_db, self.config.shelf_width_octaves, ) + high_shelf_weight( frequency, self.config.high_shelf_hz, self.config.high_shelf_attenuation_db, self.config.shelf_width_octaves, ); let normalization = 2.0 / self.window_sum; let db = 20.0 * (magnitude * normalization).max(1e-9).log10() + gain_db + weight_db; let db = compress_db( db, self.config.compressor_threshold_db, self.config.compressor_ratio, ) + self.config.post_filter_gain_db; ((db - self.config.db_floor) / (self.config.db_ceiling - self.config.db_floor)) .clamp(0.0, 1.0) .powf(self.config.contrast) } fn update_chroma_targets(&mut self, gain_db: f32) { self.targets.fill(0.0); let range = self.config.db_ceiling - self.config.db_floor; let normalization = 2.0 / self.window_sum; let min_bin = (self.config.min_freq * self.config.fft_size as f32 / self.sample_rate).ceil() as usize; let max_bin = (self.config.max_freq.min(self.sample_rate / 2.0) * self.config.fft_size as f32 / self.sample_rate) .ceil() as usize; for bin in min_bin.max(1)..max_bin.min(self.config.fft_size / 2 + 1) { let frequency = bin as f32 * self.sample_rate / self.config.fft_size as f32; let midi = 69.0 + 12.0 * (frequency / 440.0).log2(); let pitch_class = (midi.round() as i32).rem_euclid(12) as usize; if pitch_class < self.targets.len() { self.targets[pitch_class] = self.targets[pitch_class].max(self.fft_buffer[bin].norm()); } } for target in &mut self.targets { let db = 20.0 * (*target * normalization).max(1e-9).log10() + gain_db; let db = compress_db( db, self.config.compressor_threshold_db, self.config.compressor_ratio, ) + self.config.post_filter_gain_db; *target = ((db - self.config.db_floor) / range) .clamp(0.0, 1.0) .powf(self.config.contrast); } } fn apply_spectral_effects(&mut self) { if self.config.spectral_contrast > 0.0 && self.targets.len() > 2 { self.effect_work.copy_from_slice(&self.targets); for index in 0..self.targets.len() { let start = index.saturating_sub(4); let end = (index + 5).min(self.targets.len()); let mut local_min = 1.0_f32; let mut local_max = 0.0_f32; for value in &self.effect_work[start..end] { local_min = local_min.min(*value); local_max = local_max.max(*value); } let prominence = ((self.effect_work[index] - local_min) / (local_max - local_min + 0.05)) .clamp(0.0, 1.0); let contrasted = self.effect_work[index] * (0.2 + 0.8 * prominence); self.targets[index] = self.effect_work[index] + (contrasted - self.effect_work[index]) * self.config.spectral_contrast; } } if self.config.whitening > 0.0 && self.targets.len() > 1 { for index in 0..self.targets.len() { let start = index.saturating_sub(3); let end = (index + 4).min(self.targets.len()); let average = self.targets[start..end].iter().sum::() / (end - start) as f32; let whitened = (self.targets[index] / (average + 0.08) * 0.45).clamp(0.0, 1.0); self.effect_work[index] = self.targets[index] + (whitened - self.targets[index]) * self.config.whitening; } self.targets.copy_from_slice(&self.effect_work); } if self.config.peak_isolation > 0.0 && self.targets.len() > 2 { self.effect_work.copy_from_slice(&self.targets); for index in 1..self.targets.len() - 1 { if self.effect_work[index] < self.effect_work[index - 1] || self.effect_work[index] < self.effect_work[index + 1] { self.targets[index] *= 1.0 - self.config.peak_isolation; } } } if self.config.harmonics > 1 && self.config.harmonic_boost > 0.0 { self.effect_work.copy_from_slice(&self.targets); for source in 0..self.band_map.len() { for harmonic in 2..=usize::from(self.config.harmonics) { let target = self.harmonic_targets[source * 13 + harmonic]; if target != usize::MAX { let reinforcement = self.effect_work[source] * self.config.harmonic_boost / harmonic as f32; self.targets[target] = (self.targets[target] + reinforcement).min(1.0); } } } } if self.config.spectral_flux > 0.0 { for index in 0..self.targets.len() { let flux = ((self.targets[index] - self.previous_targets[index]) * 4.0).clamp(0.0, 1.0); self.effect_work[index] = self.targets[index] + (flux - self.targets[index]) * self.config.spectral_flux; } self.previous_targets.copy_from_slice(&self.targets); self.targets.copy_from_slice(&self.effect_work); } else { self.previous_targets.copy_from_slice(&self.targets); } } fn apply_audio_duration(&mut self, dt: f32) { if self.config.audio_duration_ms <= 0.0 { self.duration_levels.copy_from_slice(&self.targets); return; } let coefficient = smoothing_factor(dt, self.config.audio_duration_ms / 1000.0); for (duration, target) in self.duration_levels.iter_mut().zip(&mut self.targets) { *duration += (*target - *duration) * coefficient; *target = *duration; } } } fn window_samples(samples: &[f32], window: &[f32], output: &mut [Complex32]) { for ((output, sample), window) in output.iter_mut().zip(samples).zip(window) { *output = Complex32::new(sample * window, 0.0); } } fn interpolated_magnitude(fft: &[Complex32], bin: f32) -> f32 { let maximum = fft.len() / 2; let lower = (bin.floor() as usize).clamp(1, maximum); let upper = (lower + 1).min(maximum); let amount = bin.fract(); let low = fft[lower].norm(); low + (fft[upper].norm() - low) * amount } fn band_frequency( scale: FrequencyScale, min_frequency: f32, max_frequency: f32, position: f32, ) -> f32 { match scale { FrequencyScale::Linear => min_frequency + (max_frequency - min_frequency) * position, FrequencyScale::Log | FrequencyScale::Cqt => { min_frequency * (max_frequency / min_frequency).powf(position) } FrequencyScale::Mel => { let min = 2595.0 * (1.0 + min_frequency / 700.0).log10(); let max = 2595.0 * (1.0 + max_frequency / 700.0).log10(); 700.0 * (10.0_f32.powf((min + (max - min) * position) / 2595.0) - 1.0) } FrequencyScale::Bark => { let min = bark(min_frequency); let target = min + (bark(max_frequency) - min) * position; let mut low = min_frequency; let mut high = max_frequency; for _ in 0..12 { let middle = (low + high) * 0.5; if bark(middle) < target { low = middle; } else { high = middle; } } (low + high) * 0.5 } } } fn bark(frequency: f32) -> f32 { 13.0 * (0.00076 * frequency).atan() + 3.5 * (frequency / 7_500.0).powi(2).atan() } fn low_shelf_weight( frequency: f32, shelf_frequency: f32, attenuation_db: f32, width_octaves: f32, ) -> f32 { if shelf_frequency <= 0.0 || attenuation_db <= 0.0 { return 0.0; } let position = ((frequency / shelf_frequency).log2() / width_octaves + 0.5).clamp(0.0, 1.0); -attenuation_db * (1.0 - smoothstep(position)) } fn high_shelf_weight( frequency: f32, shelf_frequency: f32, attenuation_db: f32, width_octaves: f32, ) -> f32 { if shelf_frequency <= 0.0 || attenuation_db <= 0.0 { return 0.0; } let position = ((frequency / shelf_frequency).log2() / width_octaves + 0.5).clamp(0.0, 1.0); -attenuation_db * smoothstep(position) } fn smoothstep(value: f32) -> f32 { value * value * (3.0 - 2.0 * value) } fn compress_db(db: f32, threshold_db: f32, ratio: f32) -> f32 { if ratio <= 1.0 || db <= threshold_db { db } else { threshold_db + (db - threshold_db) / ratio } } fn wrap_phase(phase: f32) -> f32 { (phase + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI } fn strongest_formant_candidates(levels: &[f32], bands: &[BandMap]) -> [usize; 3] { let mut peaks = [usize::MAX; 3]; let mut strengths = [0.0; 3]; if levels.len() < 3 { return peaks; } for index in 1..levels.len() - 1 { if !(250.0..=4_000.0).contains(&bands[index].center_frequency) { continue; } let value = levels[index]; if value < levels[index - 1] || value < levels[index + 1] { continue; } for rank in 0..3 { if value > strengths[rank] { for shift in (rank + 1..3).rev() { strengths[shift] = strengths[shift - 1]; peaks[shift] = peaks[shift - 1]; } strengths[rank] = value; peaks[rank] = index; break; } } } peaks } fn smoothing_factor(dt: f32, time_constant: f32) -> f32 { if time_constant <= 0.0 { 1.0 } else { 1.0 - (-dt / time_constant).exp() } } fn spatial_smooth(values: &mut [f32], work: &mut [f32], amount: f32) { if values.len() < 2 || amount <= 0.0 { return; } work.copy_from_slice(values); for index in 0..values.len() { let left = work[index.saturating_sub(1)]; let right = work[(index + 1).min(work.len() - 1)]; let blurred = left * 0.25 + work[index] * 0.5 + right * 0.25; values[index] = work[index] + (blurred - work[index]) * amount; } } #[cfg(test)] mod tests { use super::*; fn config() -> SpectrumConfig { SpectrumConfig { fft_size: 4096, min_freq: 40.0, max_freq: 16_000.0, frequency_scale: FrequencyScale::Log, detector: Detector::Peak, analysis: Analysis::Spectrum, db_floor: -80.0, db_ceiling: 0.0, attack_ms: 0.0, release_ms: 0.0, spatial_smoothing: 0.0, contrast: 1.0, spectral_contrast: 0.0, audio_duration_ms: 0.0, audio_offset_ms: 0.0, whitening: 0.0, peak_isolation: 0.0, spectral_flux: 0.0, adaptive_gain: 0.0, adaptive_window_ms: 4_000.0, adaptive_max_boost_db: 12.0, adaptive_max_cut_db: 18.0, root_harmonics: 6, root_isolation: 0.65, fundamental_core: 0.0, harmonic_halo: 0.0, freeze_mix: 0.75, morph_speed: 0.0, harmonics: 0, harmonic_boost: 0.35, tilt: 0.0, low_shelf_hz: 0.0, low_shelf_attenuation_db: 0.0, high_shelf_hz: 0.0, high_shelf_attenuation_db: 0.0, shelf_width_octaves: 1.0, compressor_threshold_db: -18.0, compressor_ratio: 1.0, post_filter_gain_db: 0.0, peak_hold_ms: 0.0, peak_decay_db: 20.0, glow_release_ms: 480.0, } } #[test] fn sine_wave_lands_near_its_log_band() { let mut spectrum = Spectrum::new(config(), 48_000); let samples: Vec = (0..4096) .map(|index| (std::f32::consts::TAU * 1000.0 * index as f32 / 48_000.0).sin()) .collect(); spectrum.push(&samples); let frame = spectrum.update(48, Duration::from_millis(16), 0.0); let strongest = frame .levels .iter() .enumerate() .max_by(|a, b| a.1.total_cmp(b.1)) .unwrap() .0; let expected = ((1000.0_f32 / 40.0).ln() / (16_000.0_f32 / 40.0).ln() * 48.0) as usize; assert!( strongest.abs_diff(expected) <= 1, "{strongest} vs {expected}" ); assert!(frame.levels[strongest] > 0.8); } #[test] fn peak_detector_keeps_a_pure_tone_in_one_dominant_band() { let mut config = config(); config.min_freq = 20.0; config.max_freq = 2_000.0; config.frequency_scale = FrequencyScale::Linear; config.contrast = 1.65; let mut spectrum = Spectrum::new(config, 48_000); let frequency = 85.0 * 48_000.0 / 4096.0; let samples: Vec = (0..4096) .map(|index| (std::f32::consts::TAU * frequency * index as f32 / 48_000.0).sin()) .collect(); spectrum.push(&samples); let frame = spectrum.update(64, Duration::from_millis(16), 0.0); let maximum = frame.levels.iter().copied().fold(0.0, f32::max); let dominant_bands = frame .levels .iter() .filter(|level| **level > maximum * 0.5) .count(); assert_eq!(dominant_bands, 1, "levels: {:?}", frame.levels); } #[test] fn attack_and_release_are_frame_rate_independent() { let a = smoothing_factor(1.0 / 60.0, 0.2); let b = smoothing_factor(1.0 / 30.0, 0.2); let after_two_frames = 1.0 - (1.0 - a) * (1.0 - a); assert!((after_two_frames - b).abs() < 1e-6); } #[test] fn shelves_and_compressor_attenuate_spectral_extremes() { assert!((low_shelf_weight(40.0, 250.0, 18.0, 1.0) + 18.0).abs() < 0.01); assert!(low_shelf_weight(1_000.0, 250.0, 18.0, 1.0).abs() < 0.01); assert!(high_shelf_weight(400.0, 1_500.0, 9.0, 1.0).abs() < 0.01); assert!((high_shelf_weight(6_000.0, 1_500.0, 9.0, 1.0) + 9.0).abs() < 0.01); assert_eq!(compress_db(-30.0, -18.0, 4.0), -30.0); assert_eq!(compress_db(-6.0, -18.0, 4.0), -15.0); } #[test] fn post_filter_gain_is_not_reduced_by_compression() { let mut config = config(); config.min_freq = 500.0; config.max_freq = 1_500.0; config.compressor_threshold_db = -30.0; config.compressor_ratio = 4.0; let frequency = 85.0 * 48_000.0 / 4096.0; let samples: Vec = (0..4096) .map(|index| (std::f32::consts::TAU * frequency * index as f32 / 48_000.0).sin() * 0.5) .collect(); let mut baseline = Spectrum::new(config.clone(), 48_000); baseline.push(&samples); let baseline_peak = baseline .update(32, Duration::from_millis(16), 0.0) .levels .iter() .copied() .fold(0.0, f32::max); let mut input_boost = Spectrum::new(config.clone(), 48_000); input_boost.push(&samples); let input_peak = input_boost .update(32, Duration::from_millis(16), 12.0) .levels .iter() .copied() .fold(0.0, f32::max); config.post_filter_gain_db = 12.0; let mut post_filter_boost = Spectrum::new(config, 48_000); post_filter_boost.push(&samples); let post_filter_peak = post_filter_boost .update(32, Duration::from_millis(16), 0.0) .levels .iter() .copied() .fold(0.0, f32::max); let input_delta = input_peak - baseline_peak; let post_filter_delta = post_filter_peak - baseline_peak; assert!(post_filter_delta > input_delta * 3.5); } #[test] fn audio_offset_reads_the_delayed_window() { let mut config = config(); config.fft_size = 512; config.min_freq = 100.0; config.max_freq = 2_000.0; config.audio_offset_ms = 512.0 / 48_000.0 * 1000.0; let mut spectrum = Spectrum::new(config, 48_000); let tone: Vec = (0..512) .map(|index| (std::f32::consts::TAU * 750.0 * index as f32 / 48_000.0).sin()) .collect(); spectrum.push(&tone); spectrum.push(&[0.0; 512]); let frame = spectrum.update(32, Duration::from_millis(16), 0.0); assert!(frame.levels.iter().copied().fold(0.0, f32::max) > 0.8); } #[test] fn chroma_maps_a440_to_pitch_class_a() { let mut config = config(); config.analysis = Analysis::Chroma; config.min_freq = 50.0; config.max_freq = 2_000.0; let mut spectrum = Spectrum::new(config, 48_000); let samples: Vec = (0..4096) .map(|index| (std::f32::consts::TAU * 440.0 * index as f32 / 48_000.0).sin()) .collect(); spectrum.push(&samples); let frame = spectrum.update(12, Duration::from_millis(16), 0.0); let strongest = frame .levels .iter() .enumerate() .max_by(|a, b| a.1.total_cmp(b.1)) .unwrap() .0; assert_eq!(strongest, 9, "C=0, expected A=9"); } #[test] fn harmonic_product_identifies_a_fundamental() { let mut config = config(); config.min_freq = 40.0; config.max_freq = 2_000.0; config.frequency_scale = FrequencyScale::Linear; config.fundamental_core = 1.0; config.harmonic_halo = 1.0; let mut spectrum = Spectrum::new(config, 48_000); let samples: Vec = (0..4096) .map(|index| { let time = index as f32 / 48_000.0; (std::f32::consts::TAU * 220.0 * time).sin() * 0.7 + (std::f32::consts::TAU * 440.0 * time).sin() * 0.35 + (std::f32::consts::TAU * 660.0 * time).sin() * 0.2 }) .collect(); spectrum.push(&samples); let frame = spectrum.update(64, Duration::from_millis(16), 0.0); assert!((frame.descriptors.fundamental_hz - 220.0).abs() < 50.0); assert!(frame.fundamental_levels.iter().any(|level| *level > 0.5)); assert!(frame.harmonic_levels.iter().any(|level| *level > 0.3)); } #[test] fn fundamental_analysis_preserves_multiple_roots_and_suppresses_harmonics() { let mut config = config(); config.analysis = Analysis::Fundamentals; config.min_freq = 80.0; config.max_freq = 1_000.0; config.frequency_scale = FrequencyScale::Log; config.root_isolation = 0.8; let mut spectrum = Spectrum::new(config, 48_000); let samples: Vec = (0..4096) .map(|index| { let time = index as f32 / 48_000.0; [ (220.0, 0.45), (440.0, 0.22), (660.0, 0.12), (330.0, 0.35), (990.0, 0.1), ] .into_iter() .map(|(frequency, amplitude)| { (std::f32::consts::TAU * frequency * time).sin() * amplitude }) .sum::() }) .collect(); spectrum.push(&samples); spectrum.rebuild_band_map(96); let centers: Vec = spectrum .band_map .iter() .map(|band| band.center_frequency) .collect(); let frame = spectrum.update(96, Duration::from_millis(16), 0.0); let index_at = |frequency: f32| { centers .iter() .enumerate() .min_by(|(_, left), (_, right)| { (*left - frequency) .abs() .total_cmp(&(*right - frequency).abs()) }) .unwrap() .0 }; let root_220 = frame.levels[index_at(220.0)]; let root_330 = frame.levels[index_at(330.0)]; let harmonic_440 = frame.levels[index_at(440.0)]; assert!(root_220 > harmonic_440, "{root_220} <= {harmonic_440}"); assert!(root_330 > 0.2, "second root was lost: {root_330}"); } #[test] fn bass_ratio_distinguishes_low_and_high_tones() { let mut low = Spectrum::new(config(), 48_000); let mut high = Spectrum::new(config(), 48_000); let low_samples: Vec = (0..4096) .map(|index| (std::f32::consts::TAU * 100.0 * index as f32 / 48_000.0).sin()) .collect(); let high_samples: Vec = (0..4096) .map(|index| (std::f32::consts::TAU * 2_000.0 * index as f32 / 48_000.0).sin()) .collect(); low.push(&low_samples); high.push(&high_samples); let low_ratio = low .update(64, Duration::from_millis(16), 0.0) .descriptors .bass_ratio; let high_ratio = high .update(64, Duration::from_millis(16), 0.0) .descriptors .bass_ratio; assert!(low_ratio > high_ratio + 0.5); } #[test] fn silence_stays_at_floor() { let mut spectrum = Spectrum::new(config(), 48_000); spectrum.push(&vec![0.0; 4096]); let frame = spectrum.update(32, Duration::from_millis(16), 0.0); assert!(frame.levels.iter().all(|level| *level == 0.0)); } #[test] fn steady_state_update_does_not_allocate() { let mut config = config(); config.whitening = 0.8; config.spectral_contrast = 0.8; config.audio_duration_ms = 120.0; config.audio_offset_ms = 50.0; config.peak_isolation = 0.7; config.spectral_flux = 0.5; config.adaptive_gain = 0.8; config.fundamental_core = 0.8; config.harmonic_halo = 0.7; config.morph_speed = 0.1; config.harmonics = 6; config.low_shelf_hz = 250.0; config.low_shelf_attenuation_db = 18.0; config.high_shelf_hz = 1_500.0; config.high_shelf_attenuation_db = 9.0; config.compressor_ratio = 4.0; config.post_filter_gain_db = 9.0; let mut spectrum = Spectrum::new(config, 48_000); spectrum.push(&vec![0.0; 4096]); spectrum.update(64, Duration::from_millis(16), 0.0); spectrum.toggle_freeze(); let allocations = allocation_counter::measure(|| { for _ in 0..100 { std::hint::black_box(spectrum.update(64, Duration::from_millis(16), 0.0)); } }); assert_eq!(allocations.count_total, 0, "{allocations:?}"); } }