diff --git a/README.md b/README.md index 37ff3c7..abf7d90 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,10 @@ cargo run --release -- \ psychoacoustic spacing. CQT mode uses constant-Q-style FFT reduction to keep the realtime cost near one normal FFT. - `--analysis chroma` maps energy into the twelve pitch classes. +- `--analysis fundamentals` keeps the normal frequency-bar layout but scores + each bar as a possible root using same-frame harmonic support. Use + `--root-harmonics` to set the highest overtone consulted and + `--root-isolation` to suppress candidates without that support. - `--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; diff --git a/benches/spectrum.rs b/benches/spectrum.rs index 8139392..0efde7d 100644 --- a/benches/spectrum.rs +++ b/benches/spectrum.rs @@ -35,6 +35,8 @@ fn config(fft_size: usize, detector: Detector) -> SpectrumConfig { 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, @@ -101,6 +103,17 @@ fn sample_ingestion(criterion: &mut Criterion) { } fn effects_and_transport(criterion: &mut Criterion) { + let mut roots_config = config(4096, Detector::Peak); + roots_config.analysis = Analysis::Fundamentals; + let mut roots = Spectrum::new(roots_config, 48_000); + roots.push(&signal(4096)); + criterion.bench_function("fundamental_salience/4096/64", |bencher| { + bencher.iter(|| { + let frame = roots.update(64, Duration::from_micros(16_667), 6.0); + black_box(frame.levels[32]); + }); + }); + let mut effects_config = config(4096, Detector::Peak); effects_config.whitening = 0.8; effects_config.spectral_contrast = 0.8; diff --git a/profiles.ini.example b/profiles.ini.example index 3cd0561..7566d58 100644 --- a/profiles.ini.example +++ b/profiles.ini.example @@ -5,6 +5,9 @@ display = bars bar-style = peak-only theme = mono-dark +analysis = fundamentals +root-harmonics = 6 +root-isolation = 0.7 bands = 40 fft-size = 2048 fps = 120 diff --git a/src/cli.rs b/src/cli.rs index 6025838..1bec789 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -206,6 +206,8 @@ impl FrequencyScale { pub enum Analysis { /// Normal frequency spectrum. Spectrum, + /// Harmonic root-candidate strengths arranged like a spectrum. + Fundamentals, /// Twelve musical pitch classes, C through B. Chroma, } @@ -214,6 +216,7 @@ impl Analysis { pub fn label(self) -> &'static str { match self { Self::Spectrum => "SPECTRUM", + Self::Fundamentals => "ROOTS", Self::Chroma => "CHROMA", } } @@ -426,6 +429,14 @@ pub struct Cli { #[arg(long, default_value_t = 18.0, value_parser = parse_attenuation)] pub adaptive_max_cut: f32, + /// Highest harmonic used to score root candidates in fundamentals analysis. + #[arg(long, default_value_t = 6, value_parser = clap::value_parser!(u8).range(2..=12))] + pub root_harmonics: u8, + + /// Suppress frequencies without harmonic support in fundamentals analysis. + #[arg(long, default_value_t = 0.65, value_parser = parse_unit)] + pub root_isolation: f32, + /// Strength of phase-coherent tonal body rendering. #[arg(long, default_value_t = 0.0, value_parser = parse_unit)] pub tonal_body: f32, diff --git a/src/main.rs b/src/main.rs index 4d6f675..4164a35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,6 +178,8 @@ fn run_ui(cli: Cli, mut capture: audio::AudioReceiver) -> Result<()> { 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, diff --git a/src/spectrum.rs b/src/spectrum.rs index 08ce795..5092820 100644 --- a/src/spectrum.rs +++ b/src/spectrum.rs @@ -28,6 +28,8 @@ pub struct SpectrumConfig { 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, @@ -573,6 +575,67 @@ impl Spectrum { .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) { @@ -865,6 +928,8 @@ mod tests { 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, @@ -1061,6 +1126,60 @@ mod tests { 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);