Files
bass-foundry/plugins/planish/core/src/settings.rs
T
nvrl c06d193b18 feat(planish): add Planish harmonic OTT
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.
2026-11-06 12:51:49 +01:00

129 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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]),
}
}
}