//! Sub anchor: replaces the band below the split with a clean mono sine that follows the //! bass pitch, holds one level for every note and restarts at the same phase on each note. //! //! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so //! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts //! exactly on that attack. mod settings; use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover}; pub use settings::{Settings, Source}; use std::f32::consts::TAU; /// Detectable fundamental range. The lowest sets the lookahead (two periods). pub const MIN_HZ: f32 = TrackerConfig::SUB.min_hz; pub const MAX_HZ: f32 = TrackerConfig::SUB.max_hz; pub const SCOPE: usize = 256; const ATTACK_MS: f32 = 2.; const RELEASE_MS: f32 = 15.; const CROSSFADE_MS: f32 = 3.; #[derive(Clone, Copy, Debug, Default)] pub struct Monitor { /// Hear only the generated sub. pub solo: bool, } #[derive(Clone, Copy, Debug)] pub struct AnalyzerFrame { /// Current sub frequency in Hz, 0 when silent. pub hz: f32, /// MIDI note of the current sub, rounded. pub note: Option, pub low_db: f32, pub sub_db: f32, /// Original low band and new sub, triggered on a rising zero crossing of the sub. pub scope_low: [f32; SCOPE], pub scope_sub: [f32; SCOPE], /// Duration of the scope window in milliseconds. pub scope_ms: f32, pub latency: u32, pub sample_rate: f32, } impl Default for AnalyzerFrame { fn default() -> Self { Self { hz: 0., note: None, low_db: -120., sub_db: -120., scope_low: [0.; SCOPE], scope_sub: [0.; SCOPE], scope_ms: 60., latency: 0, sample_rate: 48000., } } } #[derive(Clone, Copy, Debug, Default)] struct Voice { phase: f32, hz: f32, target: f32, amp: f32, releasing: bool, } /// Sine voices and per-note state, driven by tracker events at output time. #[derive(Clone, Copy, Debug, Default)] struct Synth { voice: Voice, old: Voice, old_gain: f32, note_peak: f32, } impl Synth { fn event(&mut self, s: &Settings, e: NoteEvent) { match e { NoteEvent::Start { pitch, glide } => self.start(s, pitch, glide), NoteEvent::Move { pitch } => { if !s.snap { self.voice.target = midi_to_hz(pitch + 12. * s.octave as f32); } } NoteEvent::Stop => self.voice.releasing = true, } } fn start(&mut self, s: &Settings, pitch: f32, glide: bool) { let note = if s.snap { pitch.round() } else { pitch }; let hz = midi_to_hz(note + 12. * s.octave as f32); let audible = self.voice.amp > 1e-4 && !self.voice.releasing; if glide && audible { self.voice.target = hz; return; } if s.retrigger || !audible { if self.voice.amp > 1e-4 { // Restarting the phase mid-cycle would click; the old cycle fades out instead. self.old = self.voice; self.old_gain = 1.; } self.voice = Voice { phase: s.phase / 360., hz, target: hz, amp: 0., releasing: false, }; } else { self.voice.target = hz; self.voice.hz = hz; } self.note_peak = 0.; } } pub struct Engine { rate: f32, channels: usize, settings: Settings, latency: usize, delay: Vec<[f32; 2]>, delay_at: usize, crossover: [Crossover; 2], tracker: Tracker, synth: Synth, glide: f32, low_env: f32, env_coef: f32, smooth: f32, level: f32, keep: f32, output: f32, bypass: f32, meter_coef: f32, low_power: f32, sub_power: f32, scope_low: [f32; 2 * SCOPE], scope_sub: [f32; 2 * SCOPE], scope_at: usize, scope_stride: usize, scope_count: usize, } impl Engine { pub fn new(rate: f32, channels: usize) -> Result { if !(8000.0..=768000.).contains(&rate) { return Err("unsupported sample rate"); } if !(1..=2).contains(&channels) { return Err("unsupported channel count"); } let tracker = Tracker::new(rate, TrackerConfig::SUB); let latency = tracker.latency(); let settings = Settings::default(); let mut e = Self { rate, channels, settings, latency, delay: vec![[0.; 2]; latency + 1], delay_at: 0, crossover: [Crossover::new(rate, settings.split); 2], tracker, synth: Synth::default(), glide: 1., low_env: 0., env_coef: coefficient(rate, 10.), smooth: coefficient(rate, 10.), level: db_to_gain(settings.level), keep: 0., output: 1., bypass: 0., meter_coef: coefficient(rate, 150.), low_power: 0., sub_power: 0., scope_low: [0.; 2 * SCOPE], scope_sub: [0.; 2 * SCOPE], scope_at: 0, scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize, scope_count: 0, }; e.set_settings(settings)?; Ok(e) } pub fn latency_samples(&self) -> u32 { self.latency as u32 } pub fn channels(&self) -> usize { self.channels } pub fn settings(&self) -> Settings { self.settings } pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> { s.validate()?; let old = self.settings; if (s.split - old.split).abs() > 1e-3 { for x in &mut self.crossover { x.retune(self.rate, s.split); } } if self .tracker .set(s.source == Source::Midi, s.gate, s.glide > 0.) { self.synth.voice.releasing = true; } self.glide = if s.glide <= 0. { 1. } else { coefficient(self.rate, s.glide / 3.) }; self.settings = s; Ok(()) } pub fn reset(&mut self) { self.delay.fill([0.; 2]); for x in &mut self.crossover { x.reset(); } self.tracker.reset(); self.synth = Synth::default(); self.low_env = 0.; self.low_power = 0.; self.sub_power = 0.; self.scope_low = [0.; 2 * SCOPE]; self.scope_sub = [0.; 2 * SCOPE]; } /// MIDI note start at the current input sample. Used when the source is MIDI. pub fn note_on(&mut self, note: u8) { self.tracker.note_on(note); } pub fn note_off(&mut self, note: u8) { self.tracker.note_off(note); } #[inline] fn shape(&self, x: f32) -> f32 { let drive = self.settings.drive * 0.01; if drive <= 0. { return x; } let k = 1. + 6. * drive; (k * x).tanh() / k.tanh() } fn oscillator(&mut self) -> f32 { let attack = 1. / (self.rate * ATTACK_MS * 0.001); let release = 1. / (self.rate * RELEASE_MS * 0.001); let v = &mut self.synth.voice; v.amp = if v.releasing { (v.amp - release).max(0.) } else { (v.amp + attack).min(1.) }; v.hz += (v.target - v.hz) * self.glide; let mut out = 0.; if v.amp > 0. { out += (TAU * v.phase).sin() * v.amp; v.phase = (v.phase + v.hz / self.rate).fract(); } let s = &mut self.synth; if s.old_gain > 0. { let o = &mut s.old; out += (TAU * o.phase).sin() * o.amp * s.old_gain; o.phase = (o.phase + o.hz / self.rate).fract(); s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.); } self.shape(out) } pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] { let mono = if self.channels == 2 { 0.5 * (x[0] + x[1]) } else { x[0] }; let (synth, settings) = (&mut self.synth, &self.settings); self.tracker.process(mono, |e| synth.event(settings, e)); let len = self.delay.len(); self.delay[self.delay_at] = x; self.delay_at = (self.delay_at + 1) % len; // The slot after the newest write is the oldest: exactly `latency` samples ago. let y = self.delay[self.delay_at]; let s = self.settings; let (l0, h0) = self.crossover[0].process(y[0]); let (l1, h1) = if self.channels == 2 { self.crossover[1].process(y[1]) } else { (l0, h0) }; let low = 0.5 * (l0 + l1); self.low_env += (low * low - self.low_env) * self.env_coef; let env = self.low_env.sqrt(); if !self.synth.voice.releasing { self.synth.note_peak = self.synth.note_peak.max(env); } let follow = if self.synth.note_peak > 1e-6 { (env / self.synth.note_peak).min(1.) } else { 1. }; let dynamics = s.dynamics * 0.01; let shape = 1. - dynamics + dynamics * follow; self.level += (db_to_gain(s.level) - self.level) * self.smooth; self.keep += (s.keep * 0.01 - self.keep) * self.smooth; self.output += (db_to_gain(s.output) - self.output) * self.smooth; self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth; let sub = self.oscillator() * self.level * shape; self.low_power += (low * low - self.low_power) * self.meter_coef; self.sub_power += (sub * sub - self.sub_power) * self.meter_coef; self.scope_count += 1; if self.scope_count >= self.scope_stride { self.scope_count = 0; self.scope_low[self.scope_at] = low; self.scope_sub[self.scope_at] = sub; self.scope_at = (self.scope_at + 1) % (2 * SCOPE); } let wet = |l: f32, h: f32| { if monitor.solo { sub } else { h + self.keep * l + sub } }; let w = [wet(l0, h0), wet(l1, h1)]; let mut out = [0.; 2]; for c in 0..self.channels { out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass; } out } pub fn take_analyzer(&self) -> AnalyzerFrame { let mut f = AnalyzerFrame { latency: self.latency as u32, sample_rate: self.rate, low_db: 10. * self.low_power.max(1e-12).log10() + 3.01, sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01, scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000., ..AnalyzerFrame::default() }; let v = self.synth.voice; if v.amp > 0. && !v.releasing { f.hz = v.hz; f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8); } // Trigger on the latest rising zero crossing that still leaves a full window behind it. let n = 2 * SCOPE; let oldest = self.scope_at; let mut start = oldest; for back in 0..SCOPE { let i = (oldest + SCOPE - back) % n; let prev = (i + n - 1) % n; if self.scope_sub[prev] < 0. && self.scope_sub[i] >= 0. { start = i; break; } } for k in 0..SCOPE { let i = (start + k) % n; f.scope_low[k] = self.scope_low[i]; f.scope_sub[k] = self.scope_sub[i]; } f } }