diff --git a/README.md b/README.md index 00ea583..3970cad 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ combined: ```sh cargo run --release -- \ --frequency-scale cqt --whitening 0.8 --peak-isolation 0.7 \ + --spectral-contrast 0.7 --audio-duration 90 --audio-offset 0 \ --spectral-flux 0.25 --harmonics 6 --harmonic-boost 0.35 \ --trails 6 --beat-reactivity 0.5 --path circle --theme crimson ``` @@ -88,6 +89,9 @@ cargo run --release -- \ the realtime cost near one normal FFT. - `--analysis chroma` maps energy into the twelve pitch classes. - `--whitening` reveals narrow frequencies above the local spectral envelope. +- `--spectral-contrast` emphasizes peaks relative to nearby valleys. +- `--audio-duration` integrates energy over time for AE-style rolling hills; + `--audio-offset` creates a precisely delayed spectrum without sleeping. - `--peak-isolation` suppresses bands that are not local maxima. - `--spectral-flux` emphasizes note attacks and percussion transients. - `--harmonics` and `--harmonic-boost` reinforce harmonic ladders. diff --git a/benches/spectrum.rs b/benches/spectrum.rs index 99e8232..6955b0c 100644 --- a/benches/spectrum.rs +++ b/benches/spectrum.rs @@ -22,6 +22,9 @@ fn config(fft_size: usize, detector: Detector) -> SpectrumConfig { release_ms: 180.0, spatial_smoothing: 0.08, contrast: 1.65, + 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, @@ -80,6 +83,8 @@ fn sample_ingestion(criterion: &mut Criterion) { fn effects_and_transport(criterion: &mut Criterion) { let mut effects_config = config(4096, Detector::Peak); effects_config.whitening = 0.8; + effects_config.spectral_contrast = 0.8; + effects_config.audio_duration_ms = 120.0; effects_config.peak_isolation = 0.7; effects_config.spectral_flux = 0.5; effects_config.harmonics = 6; diff --git a/src/cli.rs b/src/cli.rs index f96a570..9b84a07 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -254,6 +254,18 @@ pub struct Cli { #[arg(long, default_value_t = 1.65, value_parser = parse_contrast)] pub contrast: f32, + /// Emphasize local spectral peaks and valleys, from 0 to 1. + #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] + pub spectral_contrast: f32, + + /// Integrate spectral energy over this many milliseconds. + #[arg(long, default_value_t = 0.0, value_parser = parse_milliseconds)] + pub audio_duration: f32, + + /// Analyze audio this many milliseconds behind realtime. + #[arg(long, default_value_t = 0.0, value_parser = parse_milliseconds)] + pub audio_offset: f32, + /// Local spectral whitening from 0 to 1. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub whitening: f32, @@ -406,3 +418,12 @@ fn parse_contrast(value: &str) -> Result { Err("expected a value from 0.1 to 4".into()) } } + +fn parse_milliseconds(value: &str) -> Result { + let milliseconds = value.parse::().map_err(|error| error.to_string())?; + if (0.0..=2_000.0).contains(&milliseconds) { + Ok(milliseconds) + } else { + Err("expected milliseconds from 0 to 2000".into()) + } +} diff --git a/src/main.rs b/src/main.rs index cfc0539..00b1d0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,9 @@ fn run_ui(cli: Cli, mut capture: audio::AudioReceiver) -> Result<()> { 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, diff --git a/src/spectrum.rs b/src/spectrum.rs index 294ddc6..a782908 100644 --- a/src/spectrum.rs +++ b/src/spectrum.rs @@ -17,6 +17,9 @@ pub struct SpectrumConfig { 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, @@ -33,6 +36,7 @@ pub struct Spectrum { history: Vec, history_write: usize, history_len: usize, + offset_samples: usize, window: Vec, window_sum: f32, fft: Arc>, @@ -40,6 +44,7 @@ pub struct Spectrum { fft_scratch: Vec, band_map: Vec, targets: Vec, + duration_levels: Vec, previous_targets: Vec, effect_work: Vec, spatial_work: Vec, @@ -75,10 +80,13 @@ impl Spectrum { }) .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], + 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, @@ -87,6 +95,7 @@ impl Spectrum { sample_rate: sample_rate as f32, band_map: Vec::new(), targets: Vec::new(), + duration_levels: Vec::new(), previous_targets: Vec::new(), effect_work: Vec::new(), spatial_work: Vec::new(), @@ -122,17 +131,22 @@ impl Spectrum { pub fn update(&mut self, bands: usize, elapsed: Duration, gain_db: f32) -> SpectrumFrame<'_> { self.resize_bands(bands); - let padding = self.config.fft_size - self.history_len; + 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 }; - for index in 0..self.history_len { - let history_index = oldest + index; + let window_start = + self.history_len - self.offset_samples.min(self.history_len) - window_len; + for index in 0..window_len { + let history_index = oldest + window_start + index; let history_index = if history_index >= self.history.len() { - history_index - self.history.len() + history_index % self.history.len() } else { history_index }; @@ -144,13 +158,13 @@ impl Spectrum { .process_with_scratch(&mut self.fft_buffer, &mut self.fft_scratch); self.update_targets(gain_db); + self.apply_audio_duration(dt); self.apply_spectral_effects(); spatial_smooth( &mut self.targets, &mut self.spatial_work, self.config.spatial_smoothing, ); - let dt = elapsed.as_secs_f32().min(0.25); let attack = smoothing_factor(dt, self.config.attack_ms / 1000.0); let release = smoothing_factor(dt, self.config.release_ms / 1000.0); let range = self.config.db_ceiling - self.config.db_floor; @@ -184,6 +198,7 @@ impl Spectrum { 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.effect_work.resize(bands, 0.0); self.spatial_work.resize(bands, 0.0); @@ -285,6 +300,26 @@ impl Spectrum { } 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); @@ -339,6 +374,18 @@ impl Spectrum { 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 interpolated_magnitude(fft: &[Complex32], bin: f32) -> f32 { @@ -427,6 +474,9 @@ mod tests { 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, @@ -492,6 +542,24 @@ mod tests { assert!((after_two_frames - b).abs() < 1e-6); } + #[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 silence_stays_at_floor() { let mut spectrum = Spectrum::new(config(), 48_000); @@ -504,6 +572,9 @@ mod tests { 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.harmonics = 6;