Upward and downward compression in four bands whose edges are harmonic numbers of the tracked note (default 1, 2-4, 5-16, 17 and up), so the same setting squashes the same harmonics on every note. A fixed-band OTT puts harmonic 2 of F1 and of C2 in different bands. - Three Linkwitz-Riley splits at sqrt(k(k+1)) x f0 with allpass compensation; at Depth 0 the bands sum within 0.05 dB. - Band level is the RMS over one period of the note, so heavy settings do not ripple at f0: a 40 Hz sine at Drive +12 dB, Time 10 % gets no harmonics above -110 dB. - At Depth 100 % on a saw, harmonics 1-12 change by the same amount on F1, C2 and G2 (within 0.2 dB). - Depth, Drive (on the detector only), Upward, Downward, Time, a gain per band; edges Low 1-8, Mid 2-16, High 3-48 with four splits. - Pitch from audio with about 67 ms lookahead, or from MIDI. - Editor: harmonic ruler with draggable edges, level and gain per band. - Catalog, workflows, editor-check, docs, signal chain and site copy.
129 lines
4.4 KiB
Rust
129 lines
4.4 KiB
Rust
//! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent.
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Bands: the fundamental, low harmonics, mid harmonics and the top.
|
||
pub const BANDS: usize = 4;
|
||
pub const GAIN_MIN: f32 = -24.;
|
||
pub const GAIN_MAX: f32 = 24.;
|
||
/// Highest harmonic a band edge can be set to.
|
||
pub const TOP_MAX: u32 = 48;
|
||
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||
pub enum Source {
|
||
/// Pitch and note starts are detected from the incoming bass.
|
||
#[default]
|
||
Audio,
|
||
/// Notes come from MIDI note input.
|
||
Midi,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct Settings {
|
||
pub source: Source,
|
||
/// Highest harmonic in the bottom band. 1 keeps the fundamental on its own.
|
||
pub low: u32,
|
||
/// Highest harmonic in the second band.
|
||
pub mid: u32,
|
||
/// Highest harmonic in the third band; everything above is the top band.
|
||
pub high: u32,
|
||
/// Share of the compressed signal, per band, against the dry band.
|
||
pub depth: f32,
|
||
/// Scales every attack and release time.
|
||
pub time: f32,
|
||
/// Amount of upward compression: quiet bands are lifted.
|
||
pub upward: f32,
|
||
/// Amount of downward compression: loud bands are pushed down.
|
||
pub downward: f32,
|
||
/// Gain on the level the compressors see, not on the audio: moves the band levels against
|
||
/// the thresholds. More drive means more squash; at Depth 0 it changes nothing.
|
||
pub drive: f32,
|
||
/// Gain of each band after its compressor, bottom band first.
|
||
pub gains: [f32; BANDS],
|
||
/// Retune time when the pitch slides or bends.
|
||
pub smooth: f32,
|
||
/// Detection threshold in dBFS for note starts (Audio source).
|
||
pub gate: f32,
|
||
/// Round detected pitch to the nearest semitone. Off by default: the level window is one
|
||
/// period of the pitch in use, and a detuned note then gets a window that does not fit.
|
||
pub snap: bool,
|
||
pub output: f32,
|
||
pub bypass: bool,
|
||
}
|
||
impl Default for Settings {
|
||
fn default() -> Self {
|
||
Self {
|
||
source: Source::Audio,
|
||
low: 1,
|
||
mid: 4,
|
||
high: 16,
|
||
depth: 50.,
|
||
time: 100.,
|
||
upward: 100.,
|
||
downward: 100.,
|
||
drive: 0.,
|
||
gains: [0.; BANDS],
|
||
smooth: 10.,
|
||
gate: -42.,
|
||
snap: false,
|
||
output: 0.,
|
||
bypass: false,
|
||
}
|
||
}
|
||
}
|
||
impl Settings {
|
||
pub fn validate(&self) -> Result<(), &'static str> {
|
||
let finite = [
|
||
self.depth,
|
||
self.time,
|
||
self.upward,
|
||
self.downward,
|
||
self.drive,
|
||
self.smooth,
|
||
self.gate,
|
||
self.output,
|
||
]
|
||
.iter()
|
||
.chain(self.gains.iter())
|
||
.all(|v| v.is_finite());
|
||
if !finite {
|
||
return Err("non-finite setting");
|
||
}
|
||
let ok = (1..=8).contains(&self.low)
|
||
&& (2..=16).contains(&self.mid)
|
||
&& (3..=TOP_MAX).contains(&self.high)
|
||
&& self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
|
||
&& (0.0..=100.).contains(&self.depth)
|
||
&& (10.0..=1000.).contains(&self.time)
|
||
&& (0.0..=100.).contains(&self.upward)
|
||
&& (0.0..=100.).contains(&self.downward)
|
||
&& (-24.0..=24.).contains(&self.drive)
|
||
&& (0.0..=100.).contains(&self.smooth)
|
||
&& (-60.0..=-20.).contains(&self.gate)
|
||
&& (-24.0..=24.).contains(&self.output);
|
||
if ok {
|
||
Ok(())
|
||
} else {
|
||
Err("setting out of range")
|
||
}
|
||
}
|
||
/// Highest harmonic of the bottom three bands, in rising order. Host automation can set
|
||
/// the edges in any order, so a lower edge pushes the next one up instead of failing.
|
||
pub fn tops(&self) -> [u32; BANDS - 1] {
|
||
let low = self.low.clamp(1, 8);
|
||
let mid = self.mid.max(low + 1);
|
||
let high = self.high.max(mid + 1);
|
||
[low, mid, high]
|
||
}
|
||
/// Text for the harmonics of `band`, like "1", "2–4" or "17+".
|
||
pub fn band_label(&self, band: usize) -> String {
|
||
let t = self.tops();
|
||
let first = if band == 0 { 1 } else { t[band - 1] + 1 };
|
||
match band {
|
||
3 => format!("{first}+"),
|
||
_ if first == t[band] => format!("{first}"),
|
||
_ => format!("{first}–{}", t[band]),
|
||
}
|
||
}
|
||
}
|