feat(anvil): add Ghost harmonics and Phone listen

Ghost adds the sub's harmonics 2–10 above Ghost from, so phones hear the
note. The band above the split is measured per harmonic and only missing
harmonics are filled, so added ones cannot cancel the patch's own. The sub
level does not change. Phone monitors the output in mono, 400 Hz–7 kHz.
This commit is contained in:
nvrl committed 2026-11-06 13:46:15 +01:00
1 parent 4f1e237d66
commit e7f3346077
12 files changed
+489 -50

No files matched your search

+9
View File
@@ -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
+26 -1
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+10 -19
View File
@@ -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.
+149 -12
View File
@@ -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<u8>,
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;
+17
View File
@@ -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)
+126 -2
View File
@@ -19,7 +19,7 @@ fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
}
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
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<f32> {
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::<f32>()
})
.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<f32> = 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)
);
}
+11 -2
View File
@@ -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());
}
+15 -5
View File
@@ -13,10 +13,11 @@ use std::sync::{
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
solo: AtomicBool,
phone: AtomicBool,
}
struct EditorData {
ui: anvil_ui::State,
gestures: [bool; 9],
gestures: [bool; 11],
}
pub struct Anvil {
params: Arc<AnvilParams>,
@@ -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];
}
+24 -3
View File
@@ -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]
}
+101 -6
View File
@@ -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.);
+1
View File
@@ -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);