//! 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. //! //! Ghost harmonics add the sub's own harmonics above a chosen frequency, so the note //! reads on speakers without sub. The band above the split is measured per harmonic and //! only the harmonics the patch lacks are filled: a harmonic the patch already has would //! meet the added one at an unrelated phase and could cancel. //! //! The sine only plays while the note is sure. While tracking is unsure, the original sub //! comes back in its place (Replace) or the added sine fades out (Enhance), so a wrong guess //! never plays a wrong sub note. //! //! Kick lock reads the kick from the sidechain. Hits are found on the undelayed sidechain, //! so with the lookahead each hit ducks the sub exactly when the delayed kick plays, and //! restarts the sub's phase at the kick plus an offset, also before the kick. The audition //! plays each offset of a sweep for a few kicks and measures how kick and sub sum in the //! low band for each one; what sums loudest is not always what sounds like one hit. mod settings; use common_dsp::meter::HarmonicMeter; use common_dsp::svf::{Svf, SvfCoef}; use common_dsp::tracker::{Fallback, NoteEvent, Tracker, TrackerConfig}; use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover}; pub use settings::{Mode, Settings, Source, KICK_OFFSET_MAX}; 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.; /// Crossfade between the sine and the original sub when tracking turns unsure or sure. const TRUST_MS: f32 = 15.; /// Highest Ghost harmonic. Harmonic 10 of a 30 Hz sub is 300 Hz. pub const GHOST_TOP: usize = 10; /// Ghost harmonics shown and generated: 2..=GHOST_TOP. pub const GHOST_COUNT: usize = GHOST_TOP - 1; /// Measurement window per note. Resolves harmonics of fundamentals down to about 35 Hz. const GHOST_WINDOW_MS: f32 = 60.; /// Harmonics fade in over this many octaves above the Ghost edge, so a gliding note /// does not switch them on with a step. const GHOST_RAMP_OCT: f32 = 0.5; /// Offsets the audition steps through, in ms. pub const AUDITION: [f32; 13] = [ -30., -25., -20., -15., -10., -5., 0., 5., 10., 15., 20., 25., 30., ]; /// Kicks each audition offset plays for. pub const AUDITION_HITS: u32 = 4; /// A kick is a rise of the fast sidechain envelope this far above the slow one... const KICK_RISE: f32 = 2.8; /// ...above this level (−45 dBFS)... const KICK_FLOOR: f32 = 0.0056; /// ...at least this long after the previous kick. const KICK_HOLD_MS: f32 = 60.; /// Kick and sub are compared below this frequency, over this long after each hit. const KICK_LOW_HZ: f32 = 150.; const SUM_MS: f32 = 60.; const DUCK_ATTACK_MS: f32 = 1.; const PENDING: usize = 16; /// Small speaker listen: a phone-like band, summed to mono. pub const PHONE_LOW: f32 = 400.; pub const PHONE_HIGH: f32 = 7000.; #[derive(Clone, Copy, Debug, Default)] pub struct Monitor { /// Hear only the generated sub and Ghost harmonics. pub solo: bool, /// Hear the output through a phone-like speaker band, in mono. pub phone: bool, /// Step the kick offset through `AUDITION`, `AUDITION_HITS` kicks each. pub audition: 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, /// Tracking is unsure: the original sub plays instead of the sine. pub neutral: bool, pub low_db: f32, pub sub_db: f32, /// Harmonics 2..=GHOST_TOP of the current note, peak dB relative to the sub: what the /// patch carries above the split, and what Ghost adds. −120 when absent. pub patch_harmonics: [f32; GHOST_COUNT], pub ghost_harmonics: [f32; GHOST_COUNT], /// Lowest frequency Ghost fills, after the split. pub ghost_edge: f32, /// Kicks found in the sidechain since the plugin started. pub kick_hits: u32, /// Kick plus sub against kick and sub apart, low band, dB, after the last hit. +3 is a /// full sum of equal levels, 0 unrelated, below 0 they cancel. NaN before a hit. pub kick_sum_db: f32, /// Offset the sub restarted with on the latest kick, in ms. pub kick_offset: f32, /// Measured sum per audition offset, dB; NaN where not measured yet. pub audition_sums: [f32; AUDITION.len()], /// 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, neutral: false, low_db: -120., sub_db: -120., patch_harmonics: [-120.; GHOST_COUNT], ghost_harmonics: [-120.; GHOST_COUNT], ghost_edge: 300., kick_hits: 0, kick_sum_db: f32::NAN, kick_offset: 0., audition_sums: [f32::NAN; AUDITION.len()], 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, /// Set when a note starts or changes pitch; the Ghost measurement starts over. fresh: bool, } 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.; self.fresh = true; } /// Restarts a sounding sub at the start phase, keeping its pitch. fn kick(&mut self, s: &Settings) { let v = self.voice; if v.amp <= 1e-4 || v.releasing { return; } self.old = v; self.old_gain = 1.; self.voice = Voice { phase: s.phase / 360., amp: 0., ..v }; } } #[derive(Clone, Copy, Debug, Default)] struct Pending { /// Output-time sample. at: u64, /// Restart the sub (true) or duck it and start measuring (false). restart: bool, offset: f32, /// Audition offset index this hit belongs to. slot: Option, } /// Kick detection, scheduling, ducking and the sum measurement. struct Kick { clock: u64, fast: f32, slow: f32, fast_coef: f32, slow_coef: f32, hold: usize, pending: [Option; PENDING], delay: Vec, delay_at: usize, low: Biquad, duck: f32, rising: bool, attack: f32, measure: usize, measure_slot: Option, energy: [f32; 3], hits: u32, sum_db: f32, offset: f32, auditioning: bool, slot: usize, slot_hits: u32, sums: [f32; AUDITION.len()], counts: [u32; AUDITION.len()], } impl Kick { fn new(rate: f32, latency: usize) -> Self { Self { clock: 0, fast: 0., slow: 0., fast_coef: coefficient(rate, 0.5), slow_coef: coefficient(rate, 30.), hold: 0, pending: [None; PENDING], delay: vec![0.; latency + 1], delay_at: 0, low: Biquad::lowpass(rate, KICK_LOW_HZ, std::f32::consts::FRAC_1_SQRT_2), duck: 0., rising: false, attack: coefficient(rate, DUCK_ATTACK_MS / 3.), measure: 0, measure_slot: None, energy: [0.; 3], hits: 0, sum_db: f32::NAN, offset: 0., auditioning: false, slot: 0, slot_hits: 0, sums: [f32::NAN; AUDITION.len()], counts: [0; AUDITION.len()], } } fn reset(&mut self) { self.fast = 0.; self.slow = 0.; self.hold = 0; self.pending = [None; PENDING]; self.delay.fill(0.); self.low.reset(); self.duck = 0.; self.rising = false; self.measure = 0; self.measure_slot = None; self.sum_db = f32::NAN; self.slot = 0; self.slot_hits = 0; self.sums = [f32::NAN; AUDITION.len()]; self.counts = [0; AUDITION.len()]; } fn schedule(&mut self, p: Pending) { if let Some(free) = self.pending.iter_mut().find(|p| p.is_none()) { *free = Some(p); } } } 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, fallback: Fallback, neutral: bool, /// 1 while the sine plays, 0 while the original sub stands in for it. trust: f32, trust_coef: 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, meter: HarmonicMeter, /// Linear peak of the patch's harmonic k (index k − 2) above the split, this note. patch: [f32; GHOST_COUNT], /// Smoothed Ghost gain per harmonic, relative to the sub's peak. fill: [f32; GHOST_COUNT], phone_coefs: [SvfCoef; 3], phone: [Svf; 3], kick: Kick, } 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., fallback: Fallback::default(), neutral: false, trust: 1., trust_coef: coefficient(rate, TRUST_MS / 3.), 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, meter: HarmonicMeter::new(rate, GHOST_WINDOW_MS), patch: [0.; GHOST_COUNT], fill: [0.; GHOST_COUNT], phone_coefs: [ // Fourth-order Butterworth high-pass, second-order low-pass. SvfCoef::highpass(rate, PHONE_LOW, 0.541), SvfCoef::highpass(rate, PHONE_LOW, 1.307), SvfCoef::lowpass(rate, PHONE_HIGH.min(0.45 * rate), 0.707), ], phone: [Svf::default(); 3], kick: Kick::new(rate, latency), }; 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.pitch.source(s.source.mode(), 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.fallback.reset(); self.neutral = false; self.trust = 1.; self.low_env = 0.; self.low_power = 0.; self.sub_power = 0.; self.scope_low = [0.; 2 * SCOPE]; self.scope_sub = [0.; 2 * SCOPE]; self.meter.reset(); self.patch = [0.; GHOST_COUNT]; self.fill = [0.; GHOST_COUNT]; for f in &mut self.phone { f.reset(); } self.kick.reset(); } /// Lowest frequency Ghost fills. fn ghost_edge(&self) -> f32 { self.settings.ghost_from.max(self.settings.split) } /// Moves each Ghost gain toward what the patch lacks at the current note. fn update_fill(&mut self) { let s = &self.settings; let v = self.synth.voice; let amount = s.ghost * 0.01; let edge = self.ghost_edge(); let sub = self.level.max(1e-6); for (i, fill) in self.fill.iter_mut().enumerate() { let k = (i + 2) as f32; let f = k * v.hz; let target = if amount > 0. && v.hz > 0. && f < 0.45 * self.rate { let ramp = ((f / edge).log2() / GHOST_RAMP_OCT).clamp(0., 1.); // A 1/k slope, like a saw; at 100 % harmonic k sits at 2/k of the sub. let want = amount * 2. / k * ramp; (want - self.patch[i] / sub).max(0.) } else { 0. }; *fill += (target - *fill) * self.smooth; } } /// Ghost harmonics for a voice at `phase`, without the voice's amplitude. #[inline] fn ghost(&self, phase: f32) -> f32 { let mut out = 0.; for (i, &g) in self.fill.iter().enumerate() { if g > 1e-5 { out += g * (TAU * ((i + 2) as f32 * phase).fract()).sin(); } } out } /// 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() } /// The shaped sub and the Ghost harmonics, before level and dynamics. fn oscillator(&mut self) -> (f32, 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, mut ghost) = (0., 0.); let v = self.synth.voice; if v.amp > 0. { out += (TAU * v.phase).sin() * v.amp; ghost += self.ghost(v.phase) * v.amp; self.synth.voice.phase = (v.phase + v.hz / self.rate).fract(); } let s = self.synth; if s.old_gain > 0. { let o = s.old; out += (TAU * o.phase).sin() * o.amp * s.old_gain; ghost += self.ghost(o.phase) * o.amp * s.old_gain; let s = &mut self.synth; s.old.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), ghost) } /// One sample without a sidechain. pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] { self.process_with_kick(x, 0., monitor) } /// Finds kicks in the undelayed sidechain and schedules their duck and restart. fn detect_kick(&mut self, side: f32, audition: bool) { let rate = self.rate; let s = self.settings; let k = &mut self.kick; if audition != k.auditioning { k.auditioning = audition; k.slot = 0; k.slot_hits = 0; k.sums = [f32::NAN; AUDITION.len()]; k.counts = [0; AUDITION.len()]; } let level = side.abs(); k.fast += (level - k.fast) * k.fast_coef; k.slow += (level - k.slow) * k.slow_coef; k.hold = k.hold.saturating_sub(1); if !s.kick_lock || k.hold > 0 || k.fast < KICK_FLOOR || k.fast < KICK_RISE * k.slow { return; } k.hold = (rate * KICK_HOLD_MS * 0.001) as usize; let (offset, slot) = if audition { let slot = k.slot; k.slot_hits += 1; if k.slot_hits >= AUDITION_HITS { k.slot_hits = 0; k.slot = (k.slot + 1) % AUDITION.len(); } (AUDITION[slot], Some(slot)) } else { (s.kick_offset, None) }; // The delayed kick leaves the plugin `latency` samples from now. let at = k.clock + self.latency as u64; let shift = (offset * 0.001 * rate).round() as i64; let duck = Pending { at, restart: false, offset, slot, }; k.schedule(duck); k.schedule(Pending { at: (at as i64 + shift).max(0) as u64, restart: true, ..duck }); } /// Runs the kick events due at this output sample. Returns the duck gain for the sub. fn kick_events(&mut self) -> f32 { let s = self.settings; let clock = self.kick.clock; for i in 0..PENDING { let Some(p) = self.kick.pending[i] else { continue; }; if p.at > clock { continue; } self.kick.pending[i] = None; if p.restart { self.synth.kick(&s); self.kick.offset = p.offset; } else { let k = &mut self.kick; k.hits += 1; k.rising = true; k.measure = (self.rate * SUM_MS * 0.001) as usize; k.measure_slot = p.slot; k.energy = [0.; 3]; } } let k = &mut self.kick; if k.rising { k.duck += (1. - k.duck) * k.attack; if k.duck > 0.98 { k.rising = false; } } else { k.duck -= k.duck * coefficient(self.rate, s.kick_release / 3.); } if !s.kick_lock { k.duck = 0.; } 1. - (1. - db_to_gain(-s.kick_duck)) * k.duck } /// Adds one sample of the delayed kick's and the sub's low band to the sum measurement. fn measure_kick(&mut self, kick: f32, sub: f32) { let k = &mut self.kick; if k.measure == 0 { return; } k.energy[0] += (kick + sub) * (kick + sub); k.energy[1] += kick * kick; k.energy[2] += sub * sub; k.measure -= 1; if k.measure > 0 { return; } let apart = k.energy[1] + k.energy[2]; if apart < 1e-9 { return; } k.sum_db = 10. * (k.energy[0] / apart).max(1e-6).log10(); if let Some(slot) = k.measure_slot { let n = k.counts[slot] as f32; let old = if n > 0. { k.sums[slot] } else { 0. }; k.sums[slot] = (old * n + k.sum_db) / (n + 1.); k.counts[slot] += 1; } } /// One sample with the kick's sidechain (mono) for Kick lock. pub fn process_with_kick(&mut self, x: [f32; 2], kick: f32, monitor: Monitor) -> [f32; 2] { self.detect_kick(kick, monitor.audition); let len = self.kick.delay.len(); self.kick.delay[self.kick.delay_at] = kick; self.kick.delay_at = (self.kick.delay_at + 1) % len; let kick_late = self.kick.delay[self.kick.delay_at]; 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)); self.neutral = self.fallback.neutral(self.tracker.state()); 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); if std::mem::take(&mut self.synth.fresh) { // A new note: measure its harmonics from its first sample. Until the first // window completes they count as missing. self.meter.reset(); self.patch = [0.; GHOST_COUNT]; } let v = self.synth.voice; let hz = if v.amp > 0. && !v.releasing { v.hz } else { 0. }; if self.meter.push(0.5 * (h0 + h1), hz) && self.meter.hz() > 0. { for (p, db) in self.patch.iter_mut().zip(&self.meter.levels()[1..]) { *p = if *db > -119. { db_to_gain(*db) } else { 0. }; } } 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 duck = self.kick_events(); self.update_fill(); let (sub, ghost) = self.oscillator(); self.trust += (f32::from(!self.neutral) - self.trust) * self.trust_coef; let gain = self.level * shape * duck * self.trust; let sub = sub * gain; let generated = sub + ghost * gain; if self.settings.kick_lock { let kick_low = self.kick.low.process(kick_late); self.measure_kick(kick_low, sub); } self.kick.clock += 1; 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] = generated; self.scope_at = (self.scope_at + 1) % (2 * SCOPE); } // Replace keeps `keep` of the original, and all of it while the sine is out. let keep = match s.mode { Mode::Replace => self.keep + (1. - self.keep) * (1. - self.trust), Mode::Enhance => 1., }; let wet = |l: f32, h: f32| { if monitor.solo { generated } else { h + keep * l + generated } }; 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; } if monitor.phone { let mut m = if self.channels == 2 { 0.5 * (out[0] + out[1]) } else { out[0] }; for (f, c) in self.phone.iter_mut().zip(&self.phone_coefs) { m = f.process(c, m); } out = [m, if self.channels == 2 { m } else { 0. }]; } 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., ghost_edge: self.ghost_edge(), kick_hits: self.kick.hits, kick_sum_db: self.kick.sum_db, kick_offset: self.kick.offset, audition_sums: self.kick.sums, neutral: self.neutral, ..AnalyzerFrame::default() }; let sub = self.level.max(1e-6); let rel = |g: f32| { if g > 1e-6 { 20. * (g / sub).log10() } else { -120. } }; for i in 0..GHOST_COUNT { f.patch_harmonics[i] = rel(self.patch[i]); f.ghost_harmonics[i] = rel(self.fill[i] * sub); } let v = self.synth.voice; if v.amp > 0. && !v.releasing && !self.neutral { 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 } }