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:
1 parent
4f1e237d66
commit
e7f3346077
12 files changed
+489
-50
No files matched your search
+149
-12
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user