diff --git a/docs/anvil/CHANGELOG.md b/docs/anvil/CHANGELOG.md index 4c675dd..6e1cda0 100644 --- a/docs/anvil/CHANGELOG.md +++ b/docs/anvil/CHANGELOG.md @@ -1,5 +1,14 @@ # Anvil changelog +## Unreleased + +- Add Ghost harmonics: **Ghost** and **Ghost from** add the sub's harmonics 2–10 + above a chosen frequency, so phones hear the note. Anvil measures the patch's own + harmonics above the split and fills only the ones that are missing. The sub level + does not change. +- Add **Phone**, a listen mode: mono, band-limited to 400 Hz–7 kHz. Not saved. +- Show the patch's harmonics and the added ones in an inset in the scope. + ## 0.2.0 - Change the CLAP ID to `io.narl.bass-foundry.anvil`. Hosts see this as a new diff --git a/docs/anvil/README.md b/docs/anvil/README.md index 2ff13aa..b337e7f 100644 --- a/docs/anvil/README.md +++ b/docs/anvil/README.md @@ -28,12 +28,37 @@ redistributed packages. **Retrigger** on, every note starts at this phase. 5. Turn on **Solo sub** to hear only the new sub; the scope shows the original low band (grey) against the new sub (accent). +6. Turn on **Phone** to check the drop the way phones and laptop speakers play + it: mono, nothing below 400 Hz. A clean sub vanishes there. If the note + disappears with it, raise **Ghost**. + +### Ghost harmonics + +Phone speakers play almost nothing below 300–500 Hz. A clean 50 Hz sub sends all +its energy below that, so on a phone the note is gone. The ear rebuilds a note's +pitch from its harmonics, so harmonics alone carry the note. + +**Ghost** adds the sub's harmonics 2–10 above **Ghost from**, falling 1/k like a +saw. At 100 %, harmonic k sits at 2/k of the sub's level. They fade in over half +an octave above Ghost from, and never reach below Split. The sub itself does not +change, so the weight on a club system stays where Sub level puts it. + +Anvil measures each harmonic of the patch above the split and adds only what is +missing. A harmonic the patch already has meets an added one at an unrelated +phase and could cancel it, so Ghost leaves those alone. The inset in the scope +shows both, per harmonic: what the patch carries (grey) and what Ghost adds +(accent). A riddim patch usually needs little; 808s and clean sine subs need the +most. Each new note is measured from its first 60 ms; during that time every +harmonic counts as missing. + +Drive works differently: it saturates the sine itself, adds only odd harmonics, +and changes the sub's own shape. Other controls: - **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub follow the original's volume shape, for example a wobble. -- **Drive** adds upper harmonics so the sub reads on phones and laptops. +- **Drive** saturates the sine: odd harmonics and a thicker sub. - **Octave** moves the sub against the detected note. Use −1 for a bass played one octave up. - **Glide** slides between overlapping notes; 0 jumps. diff --git a/docs/anvil/assets/editor.png b/docs/anvil/assets/editor.png index 6839fd7..c122b3f 100644 Binary files a/docs/anvil/assets/editor.png and b/docs/anvil/assets/editor.png differ diff --git a/docs/ideas.md b/docs/ideas.md index 1d656d5..38fa102 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -217,22 +217,13 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla *offset audition*: a sweep of sub offsets against the kick (−30 to +30 ms, then finer) and a marker for where the hit is heard, kept apart from the low-band cancellation reading (problem 21). A sub shape from sine to rounded - triangle. A note attack counted in cycles. *Ghost harmonics*: harmonics 2–5 - of the sub sine above a cutoff you set, with weights that follow the - patch's own harmonic slope, so phones hear the note; separate from the sub - level, so the physical sub does not change (problem 19). A *small speaker* - listen button that band-limits the output to judge it. Design checks before - building Ghost: Anvil's Drive already adds harmonics without - intermodulation (one sine through tanh), but only odd ones and it changes - the fundamental; harmonics 2–5 of a 50 Hz sub (100–250 Hz) still sit under a - phone's cutoff (often 300–500 Hz), so Ghost needs a "from" frequency and - harmonics up to about 10; and generated harmonics at k·f0 add to the - patch's own harmonics at the same frequencies with unrelated phase, so they - can cancel. The paper boosts harmonics that exist and generates only missing - ones: Lathe already boosts existing ones, so Ghost should fill only - harmonics the patch lacks above the split (measure the high band per - harmonic, as `common-dsp` `meter.rs` does). Most useful on 808s and clean - subs; a riddim patch already carries its note in the mids. + triangle. A note attack counted in cycles. *Ghost harmonics* and the + *Phone* listen are done (unreleased, see [Anvil](anvil/README.md)): harmonics + 2–10 above a "from" frequency, filled only where the patch lacks them, with + the sub level unchanged (problem 19). Open for Ghost: weights that follow the + patch's own harmonic slope instead of a fixed 1/k; measuring a new note's + harmonics from the lookahead, so its first 60 ms do not count them as + missing; a listening check on an 808 over a real phone. - **Lathe**: A/B gain snapshots with one automatable Morph parameter. A keyswitch note that selects the drawn shape (EQ switch-ups without automation). A dynamic mode per harmonic. @@ -270,9 +261,9 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla 3. Planish (done, released 0.1.0), then Quench and Ballast: they fill the mix and master gap. - In parallel, from the research findings, in this order: Weld delay - spread (done, unreleased); the fatness lane, as the first Assay piece; - Anvil Ghost harmonics and small speaker listen; Braze; Anvil Kick lock - with offset audition. + spread (done, unreleased); the fatness lane, as the first Assay piece + (prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen + (done, unreleased); Braze; Anvil Kick lock with offset audition. 4. Forge, when enough modules exist. 5. Assay, one plugin for the display-only ideas. 6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer. diff --git a/plugins/anvil/core/src/lib.rs b/plugins/anvil/core/src/lib.rs index 74718b5..6268fb7 100644 --- a/plugins/anvil/core/src/lib.rs +++ b/plugins/anvil/core/src/lib.rs @@ -4,7 +4,14 @@ //! 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. mod settings; +use common_dsp::meter::HarmonicMeter; +use common_dsp::svf::{Svf, SvfCoef}; 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}; @@ -17,11 +24,25 @@ pub const SCOPE: usize = 256; const ATTACK_MS: f32 = 2.; const RELEASE_MS: f32 = 15.; const CROSSFADE_MS: f32 = 3.; +/// 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; +/// 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. + /// 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, } #[derive(Clone, Copy, Debug)] @@ -32,6 +53,12 @@ pub struct AnalyzerFrame { pub note: Option, 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, /// 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], @@ -47,6 +74,9 @@ impl Default for AnalyzerFrame { note: None, low_db: -120., sub_db: -120., + patch_harmonics: [-120.; GHOST_COUNT], + ghost_harmonics: [-120.; GHOST_COUNT], + ghost_edge: 300., scope_low: [0.; SCOPE], scope_sub: [0.; SCOPE], scope_ms: 60., @@ -72,6 +102,8 @@ struct Synth { 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) { @@ -111,6 +143,7 @@ impl Synth { self.voice.hz = hz; } self.note_peak = 0.; + self.fresh = true; } } @@ -140,6 +173,13 @@ pub struct Engine { 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], } impl Engine { @@ -179,6 +219,16 @@ impl Engine { 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], }; e.set_settings(settings)?; Ok(e) @@ -226,6 +276,48 @@ impl Engine { 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(); + } + } + /// 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) { @@ -243,7 +335,8 @@ impl Engine { let k = 1. + 6. * drive; (k * x).tanh() / k.tanh() } - fn oscillator(&mut self) -> f32 { + /// 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; @@ -253,19 +346,23 @@ impl Engine { (v.amp + attack).min(1.) }; v.hz += (v.target - v.hz) * self.glide; - let mut out = 0.; + let (mut out, mut ghost) = (0., 0.); + let v = self.synth.voice; if v.amp > 0. { out += (TAU * v.phase).sin() * v.amp; - v.phase = (v.phase + v.hz / self.rate).fract(); + ghost += self.ghost(v.phase) * v.amp; + self.synth.voice.phase = (v.phase + v.hz / self.rate).fract(); } - let s = &mut self.synth; + let s = self.synth; if s.old_gain > 0. { - let o = &mut s.old; + let o = s.old; out += (TAU * o.phase).sin() * o.amp * s.old_gain; - o.phase = (o.phase + o.hz / self.rate).fract(); + 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) + (self.shape(out), ghost) } pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] { let mono = if self.channels == 2 { @@ -288,6 +385,19 @@ impl Engine { (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 { @@ -304,21 +414,24 @@ impl Engine { 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.update_fill(); + let (sub, ghost) = self.oscillator(); + let sub = sub * self.level * shape; + let generated = sub + ghost * 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_sub[self.scope_at] = generated; self.scope_at = (self.scope_at + 1) % (2 * SCOPE); } let wet = |l: f32, h: f32| { if monitor.solo { - sub + generated } else { - h + self.keep * l + sub + h + self.keep * l + generated } }; let w = [wet(l0, h0), wet(l1, h1)]; @@ -326,6 +439,17 @@ impl Engine { 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 { @@ -335,8 +459,21 @@ impl Engine { 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(), ..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 { f.hz = v.hz; diff --git a/plugins/anvil/core/src/settings.rs b/plugins/anvil/core/src/settings.rs index 081a14a..6474339 100644 --- a/plugins/anvil/core/src/settings.rs +++ b/plugins/anvil/core/src/settings.rs @@ -22,6 +22,13 @@ pub struct Settings { pub dynamics: f32, /// Soft saturation of the sine, adding upper harmonics for small speakers. pub drive: f32, + /// Ghost harmonics: adds the sub's harmonics 2–10 above `ghost_from`, so small + /// speakers hear the note. Only harmonics the patch lacks are filled. 0 is off. + #[serde(default)] + pub ghost: f32, + /// Lowest frequency Ghost harmonics reach down to, in Hz (never below `split`). + #[serde(default = "default_ghost_from")] + pub ghost_from: f32, pub octave: i32, /// Pitch slide time between overlapping notes. pub glide: f32, @@ -38,6 +45,10 @@ pub struct Settings { pub snap: bool, pub bypass: bool, } +const GHOST_FROM: f32 = 300.; +fn default_ghost_from() -> f32 { + GHOST_FROM +} impl Default for Settings { fn default() -> Self { Self { @@ -46,6 +57,8 @@ impl Default for Settings { split: 90., dynamics: 0., drive: 0., + ghost: 0., + ghost_from: GHOST_FROM, octave: 0, glide: 0., phase: 0., @@ -65,6 +78,8 @@ impl Settings { self.split, self.dynamics, self.drive, + self.ghost, + self.ghost_from, self.glide, self.phase, self.gate, @@ -80,6 +95,8 @@ impl Settings { && (40.0..=200.).contains(&self.split) && (0.0..=100.).contains(&self.dynamics) && (0.0..=100.).contains(&self.drive) + && (0.0..=100.).contains(&self.ghost) + && (150.0..=600.).contains(&self.ghost_from) && (-2..=1).contains(&self.octave) && (0.0..=200.).contains(&self.glide) && (0.0..=360.).contains(&self.phase) diff --git a/plugins/anvil/core/tests/dsp.rs b/plugins/anvil/core/tests/dsp.rs index 205dc51..eb251ca 100644 --- a/plugins/anvil/core/tests/dsp.rs +++ b/plugins/anvil/core/tests/dsp.rs @@ -19,7 +19,7 @@ fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec { } fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec { x.iter() - .map(|&s| e.process_sample([s, s], Monitor { solo })[0]) + .map(|&s| e.process_sample([s, s], Monitor { solo, phone: false })[0]) .collect() } fn rms(x: &[f32]) -> f32 { @@ -135,7 +135,15 @@ fn midi_notes_set_pitch_and_are_delayed_by_the_latency() { if i == seconds(0.6) { e.note_off(33); } - y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]); + y.push( + e.process_sample( + [0.; 2], + Monitor { + solo: true, + phone: false, + }, + )[0], + ); } assert!(y[..1000 + l].iter().all(|v| *v == 0.)); assert!(y[1000 + l + 50].abs() > 0.); @@ -211,3 +219,119 @@ fn settings_are_validated_and_serialise() { assert!(Engine::new(1000., 2).is_err()); assert!(Engine::new(48000., 3).is_err()); } + +/// Peak amplitude of the component at `hz`, over a window of whole cycles. +fn amplitude(x: &[f32], hz: f32) -> f32 { + let w = std::f32::consts::TAU * hz / RATE; + let (re, im) = x.iter().enumerate().fold((0., 0.), |(re, im), (n, v)| { + (re + v * (w * n as f32).cos(), im - v * (w * n as f32).sin()) + }); + 2. * (re * re + im * im).sqrt() / x.len() as f32 +} +/// Sines (Hz, peak) from 0.1 s to `total` − 0.1 s, with 1 ms fades. +fn tones(list: &[(f32, f32)], total: f32) -> Vec { + let n = (total * RATE) as usize; + let (a, b, fade) = (seconds(0.1), n - seconds(0.1), seconds(0.001)); + (0..n) + .map(|i| { + if i < a || i >= b { + return 0.; + } + let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.); + let t = (i - a) as f32 / RATE; + edge * list + .iter() + .map(|&(hz, amp)| amp * (std::f32::consts::TAU * hz * t).sin()) + .sum::() + }) + .collect() +} +fn ghost(amount: f32) -> Settings { + Settings { + ghost: amount, + ghost_from: 300., + ..Settings::default() + } +} + +#[test] +fn ghost_fills_the_harmonics_a_clean_sub_lacks() { + let (mut e, l) = engine(ghost(100.)); + let x = tones(&[(55., 0.5)], 1.); + let y = render(&mut e, &x, true); + // 0.2 s holds exactly 11 cycles of 55 Hz. + let w = &y[seconds(0.5) + l..seconds(0.7) + l]; + let sub = amplitude(w, 55.); + assert!((db(sub) + 9.).abs() < 0.5, "{}", db(sub)); + for k in 2..=5 { + // Below Ghost from: nothing added, so the sub stays a clean sine there. + let a = amplitude(w, 55. * k as f32); + assert!(db(a / sub) < -60., "harmonic {k}: {}", db(a / sub)); + } + for k in 8..=10 { + let a = amplitude(w, 55. * k as f32); + let expect = 20. * (2. / k as f32).log10(); + assert!( + (db(a / sub) - expect).abs() < 1., + "harmonic {k}: {}", + db(a / sub) + ); + } + // Harmonic 6 (330 Hz) sits in the fade above 300 Hz: present, but quieter. + let six = db(amplitude(w, 330.) / sub); + assert!(six < 20. * (2f32 / 6.).log10() - 3. && six > -40., "{six}"); +} + +#[test] +fn ghost_leaves_harmonics_the_patch_already_has() { + let (mut e, l) = engine(ghost(100.)); + // The patch carries its 8th harmonic (440 Hz) louder than Ghost would add it. + let x = tones(&[(55., 0.5), (440., 0.2)], 1.); + let y = render(&mut e, &x, false); + let w = &y[seconds(0.5) + l..seconds(0.7) + l]; + let eighth = amplitude(w, 440.); + assert!((db(eighth) - db(0.2)).abs() < 0.5, "{}", db(eighth)); + // The 9th is missing from the patch, so Ghost fills it. + let ninth = amplitude(w, 495.) / amplitude(w, 55.); + assert!( + (db(ninth) - 20. * (2f32 / 9.).log10()).abs() < 1., + "{}", + db(ninth) + ); +} + +#[test] +fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() { + let x = tones(&[(55., 0.5)], 1.); + let (mut e, l) = engine(ghost(0.)); + let y = render(&mut e, &x, true); + let w = &y[seconds(0.5) + l..seconds(0.7) + l]; + for k in 2..=10 { + assert!(amplitude(w, 55. * k as f32) < 1e-4, "harmonic {k}"); + } + let phone = |amount: f32| { + let (mut e, l) = engine(ghost(amount)); + let y: Vec = x + .iter() + .map(|&s| { + e.process_sample( + [s, s], + Monitor { + solo: true, + phone: true, + }, + )[0] + }) + .collect(); + rms(&y[seconds(0.5) + l..seconds(0.7) + l]) + }; + // A clean 55 Hz sub all but vanishes on a phone; Ghost makes the note heard. + let (plain, ghosted) = (phone(0.), phone(100.)); + assert!(db(plain) < -50., "{}", db(plain)); + assert!( + db(ghosted) - db(plain) > 25., + "{} vs {}", + db(ghosted), + db(plain) + ); +} diff --git a/plugins/anvil/core/tests/realtime.rs b/plugins/anvil/core/tests/realtime.rs index bdc20ae..ddc2efa 100644 --- a/plugins/anvil/core/tests/realtime.rs +++ b/plugins/anvil/core/tests/realtime.rs @@ -34,7 +34,10 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator; #[test] fn processing_notes_settings_and_analysis_do_not_allocate_or_free() { let mut engine = Engine::new(48000., 2).unwrap(); - let mut settings = Settings::default(); + let mut settings = Settings { + ghost: 80., + ..Settings::default() + }; engine.set_settings(settings).unwrap(); CHECK.with(|v| v.set(true)); for i in 0..96000 { @@ -58,7 +61,13 @@ fn processing_notes_settings_and_analysis_do_not_allocate_or_free() { } else { 0. }; - std::hint::black_box(engine.process_sample([x, x], Monitor { solo: i % 3 == 0 })); + std::hint::black_box(engine.process_sample( + [x, x], + Monitor { + solo: i % 3 == 0, + phone: i % 5 == 0, + }, + )); if i % 1600 == 0 { std::hint::black_box(engine.take_analyzer()); } diff --git a/plugins/anvil/plugin/src/lib.rs b/plugins/anvil/plugin/src/lib.rs index 79c40c4..4e42806 100644 --- a/plugins/anvil/plugin/src/lib.rs +++ b/plugins/anvil/plugin/src/lib.rs @@ -13,10 +13,11 @@ use std::sync::{ struct Shared { analyzer: ArrayQueue, solo: AtomicBool, + phone: AtomicBool, } struct EditorData { ui: anvil_ui::State, - gestures: [bool; 9], + gestures: [bool; 11], } pub struct Anvil { params: Arc, @@ -32,6 +33,7 @@ impl Default for Anvil { shared: Arc::new(Shared { analyzer: ArrayQueue::new(2), solo: AtomicBool::new(false), + phone: AtomicBool::new(false), }), engine: None, publish_count: 0, @@ -71,10 +73,11 @@ impl Plugin for Anvil { params.editor.clone(), EditorData { ui: anvil_ui::State::default(), - gestures: [false; 9], + gestures: [false; 11], }, |ctx, data| { data.ui.solo = false; + data.ui.phone = false; anvil_ui::style(ctx); }, move |ctx, setter, data| { @@ -98,8 +101,10 @@ impl Plugin for Anvil { data.ui.gesture_active, &mut data.gestures, ); - // Solo is a monitor only: never saved and never left on after the editor closes. + // Solo and phone listen are monitors only: never saved and never left on + // after the editor closes. shared.solo.store(data.ui.solo, Ordering::Release); + shared.phone.store(data.ui.phone, Ordering::Release); if data.ui.analyzer.note.is_some() { ctx.request_repaint(); } @@ -125,6 +130,7 @@ impl Plugin for Anvil { self.publish_interval = (config.sample_rate / 30.) as usize; self.engine = Some(engine); self.shared.solo.store(false, Ordering::Release); + self.shared.phone.store(false, Ordering::Release); true } fn reset(&mut self) { @@ -143,7 +149,11 @@ impl Plugin for Anvil { return ProcessStatus::Normal; }; let _ = engine.set_settings(self.params.settings()); - let solo = self.shared.solo.load(Ordering::Acquire) && self.params.editor.is_open(); + let open = self.params.editor.is_open(); + let monitor = Monitor { + solo: self.shared.solo.load(Ordering::Acquire) && open, + phone: self.shared.phone.load(Ordering::Acquire) && open, + }; let mut next = context.next_event(); for (index, mut sample) in buffer.iter_samples().enumerate() { while let Some(event) = next { @@ -170,7 +180,7 @@ impl Plugin for Anvil { 0. }, ]; - let out = engine.process_sample(x, Monitor { solo }); + let out = engine.process_sample(x, monitor); for (c, value) in sample.into_iter().enumerate() { *value = out[c]; } diff --git a/plugins/anvil/plugin/src/params.rs b/plugins/anvil/plugin/src/params.rs index d05c70e..162f76e 100644 --- a/plugins/anvil/plugin/src/params.rs +++ b/plugins/anvil/plugin/src/params.rs @@ -28,6 +28,10 @@ pub struct AnvilParams { pub dynamics: FloatParam, #[id = "drive"] pub drive: FloatParam, + #[id = "ghost"] + pub ghost: FloatParam, + #[id = "ghost_from"] + pub ghost_from: FloatParam, #[id = "octave"] pub octave: IntParam, #[id = "glide"] @@ -66,6 +70,17 @@ impl Default for AnvilParams { .with_unit(" Hz"), dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"), drive: linear("Drive", d.drive, 0., 100., " %"), + ghost: linear("Ghost", d.ghost, 0., 100., " %"), + ghost_from: FloatParam::new( + "Ghost from", + d.ghost_from, + FloatRange::Skewed { + min: 150., + max: 600., + factor: FloatRange::skew_factor(-1.), + }, + ) + .with_unit(" Hz"), octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }), glide: linear("Glide", d.glide, 0., 200., " ms"), phase: linear("Phase", d.phase, 0., 360., "°"), @@ -89,6 +104,8 @@ impl AnvilParams { split: self.split.value(), dynamics: self.dynamics.value(), drive: self.drive.value(), + ghost: self.ghost.value(), + ghost_from: self.ghost_from.value(), octave: self.octave.value(), glide: self.glide.value(), phase: self.phase.value(), @@ -108,9 +125,9 @@ impl AnvilParams { old: &Settings, new: &Settings, dragging: bool, - open: &mut [bool; 9], + open: &mut [bool; 11], ) { - let floats: [(&FloatParam, f32, f32); 8] = [ + let floats: [(&FloatParam, f32, f32); 10] = [ (&self.level, old.level, new.level), (&self.split, old.split, new.split), (&self.dynamics, old.dynamics, new.dynamics), @@ -119,6 +136,8 @@ impl AnvilParams { (&self.phase, old.phase, new.phase), (&self.gate, old.gate, new.gate), (&self.keep, old.keep, new.keep), + (&self.ghost, old.ghost, new.ghost), + (&self.ghost_from, old.ghost_from, new.ghost_from), ]; let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| { if changed { @@ -136,7 +155,7 @@ impl AnvilParams { for (i, (p, a, b)) in floats.into_iter().enumerate() { continuous(i, a != b, &|| setter.set_parameter(p, b)); } - continuous(8, old.output != new.output, &|| { + continuous(10, old.output != new.output, &|| { setter.set_parameter(&self.output, new.output) }); let once = |changed: bool, f: &dyn Fn()| { @@ -180,6 +199,8 @@ impl AnvilParams { &self.phase, &self.gate, &self.keep, + &self.ghost, + &self.ghost_from, &self.output, ][i] } diff --git a/plugins/anvil/ui/src/lib.rs b/plugins/anvil/ui/src/lib.rs index cb12748..1ff5a1b 100644 --- a/plugins/anvil/ui/src/lib.rs +++ b/plugins/anvil/ui/src/lib.rs @@ -1,5 +1,5 @@ //! Anvil editor, shared by the CLAP wrapper and the headless renderer. -use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE}; +use anvil_core::{AnalyzerFrame, Settings, Source, GHOST_COUNT, PHONE_LOW, SCOPE}; use common_ui::*; use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2}; @@ -9,6 +9,8 @@ pub const HEIGHT: u32 = 704; pub struct State { pub solo: bool, + /// Phone-like listen. A monitor, never saved. + pub phone: bool, pub analyzer: AnalyzerFrame, /// True while a pointer button is held, so knob drags become one host gesture. pub gesture_active: bool, @@ -18,6 +20,7 @@ impl Default for State { fn default() -> Self { Self { solo: false, + phone: false, analyzer: AnalyzerFrame::default(), gesture_active: false, help: DEFAULT_HELP.into(), @@ -64,7 +67,63 @@ fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help } hint(&r, state, help); } -fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) { +/// Harmonics 2–10 of the current note: what the patch carries above the split (steel) +/// and what Ghost adds (accent), from −48 dB to 0 dB against the sub. +fn harmonics(p: &egui::Painter, rect: egui::Rect, frame: &AnalyzerFrame) { + p.rect_filled(rect, 2., Color32::from_rgba_unmultiplied(12, 13, 16, 230)); + p.rect_stroke( + rect, + 2., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); + p.text( + rect.left_top() + Vec2::new(8., 6.), + egui::Align2::LEFT_TOP, + "GHOST", + FontId::proportional(10.), + ACCENT, + ); + p.text( + rect.right_top() + Vec2::new(-8., 6.), + egui::Align2::RIGHT_TOP, + format!("from {:.0} Hz", frame.ghost_edge), + FontId::proportional(10.), + MUTED, + ); + let area = egui::Rect::from_min_max( + rect.left_top() + Vec2::new(8., 22.), + rect.right_bottom() - Vec2::new(8., 16.), + ); + let slot = area.width() / GHOST_COUNT as f32; + let height = |db: f32| ((db + 48.) / 48.).clamp(0., 1.) * area.height(); + for i in 0..GHOST_COUNT { + let x = area.left() + slot * i as f32; + let bar = |left: f32, db: f32, color: Color32| { + let h = height(db); + if h > 0.5 { + p.rect_filled( + egui::Rect::from_min_max( + Pos2::new(left, area.bottom() - h), + Pos2::new(left + slot * 0.36, area.bottom()), + ), + 1., + color, + ); + } + }; + bar(x + slot * 0.12, frame.patch_harmonics[i], STEEL); + bar(x + slot * 0.52, frame.ghost_harmonics[i], ACCENT); + p.text( + Pos2::new(x + slot * 0.5, area.bottom() + 2.), + egui::Align2::CENTER_TOP, + format!("{}", i + 2), + FontId::proportional(9.), + DIM, + ); + } +} +fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32, ghost: bool) { let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover()); let p = ui.painter_at(rect); p.rect_filled(rect, 2., SCREEN); @@ -123,6 +182,11 @@ fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) { FontId::proportional(11.), MUTED, ); + if ghost { + let size = Vec2::new(200., 92.); + let corner = rect.right_bottom() - Vec2::new(8., 8.); + harmonics(&p, egui::Rect::from_min_max(corner - size, corner), frame); + } p.rect_stroke( rect, 2., @@ -145,10 +209,18 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { } let r = ui .selectable_label(state.solo, RichText::new("SOLO SUB").size(13.)) - .on_hover_text("Hear only the generated sub."); + .on_hover_text("Hear only the generated sub and Ghost harmonics."); if r.clicked() { state.solo = !state.solo; } + let r = ui + .selectable_label(state.phone, RichText::new("PHONE").size(13.)) + .on_hover_text(format!( + "Listen as on a phone: mono, nothing below {PHONE_LOW:.0} Hz. Not saved." + )); + if r.clicked() { + state.phone = !state.phone; + } ui.separator(); for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] { let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.)); @@ -169,7 +241,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { ui.add_space(4.); panel(ui, |ui| { ui.set_width(ui.available_width()); - scope(ui, &frame, TOP); + scope(ui, &frame, TOP, settings.ghost > 0.); }); }); ui.add_space(6.); @@ -212,7 +284,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) { } fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { let defaults = Settings::default(); - ui.columns(4, |c| { + ui.columns(6, |c| { knob( &mut c[0], state, @@ -255,7 +327,30 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { defaults.drive, " %", 88., - "Adds upper harmonics so the sub reads on small speakers.", + "Saturates the sine: odd harmonics, thicker sub. Changes the sub itself.", + ); + knob( + &mut c[4], + state, + "Ghost", + &mut settings.ghost, + 0. ..=100., + defaults.ghost, + " %", + 88., + "Adds the sub's harmonics 2–10 above Ghost from, so phones hear the note. \ + Only harmonics the patch lacks are added. The sub itself stays the same.", + ); + knob( + &mut c[5], + state, + "Ghost from", + &mut settings.ghost_from, + 150. ..=600., + defaults.ghost_from, + " Hz", + 88., + "Lowest frequency Ghost adds. Phones cut off around 300–500 Hz. Never below Split.", ); }); ui.add_space(6.); diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs index 21ca8a4..7c697ee 100644 --- a/tools/editor-check/src/main.rs +++ b/tools/editor-check/src/main.rs @@ -93,6 +93,7 @@ fn main() { let mut anvil_settings = anvil_core::Settings::default(); let mut anvil_state = anvil_ui::State::default(); if anvil { + anvil_settings.ghost = 60.; let mut engine = anvil_core::Engine::new(48000., 2).unwrap(); engine.set_settings(anvil_settings).unwrap(); let mut saw = common_dsp::Saw::new(48000., 43.65);