Files
bass-foundry/plugins/planish/ui/src/lib.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

508 lines
18 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.
//! Planish editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use planish_core::{
AnalyzerFrame, Settings, Source, BANDS, DOWN_THRESHOLD, FALLBACK_HZ, FLOOR, GAIN_MAX, GAIN_MIN,
TOP_MAX, UP_MAX, UP_THRESHOLD,
};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// Band edge being dragged on the harmonic ruler.
dragging: Option<usize>,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
dragging: None,
}
}
}
const DEFAULT_HELP: &str =
"Band edges are harmonic numbers: band 2 holds harmonics 2–4 on every note, wherever they are in Hz.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
// Levels fall smoothly so bars stay readable between frames.
for b in 0..BANDS {
self.analyzer.levels[b] = frame.levels[b].max(old.levels[b] - 3.);
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
/// One colour per band, bottom band first.
const BAND_COLORS: [Color32; BANDS] = [
Color32::from_rgb(255, 1, 96),
Color32::from_rgb(226, 120, 64),
Color32::from_rgb(126, 167, 216),
Color32::from_rgb(184, 192, 204),
];
/// Harmonics shown on the ruler: 1 to 64 on a log scale.
const RULER_MAX: f32 = 64.;
const LEVEL_TOP: f32 = 0.;
const TOP: f32 = 330.;
fn ruler_x(rect: Rect, harmonic: f32) -> f32 {
rect.left() + rect.width() * (harmonic.max(1.).log2() / RULER_MAX.log2())
}
fn x_ruler(rect: Rect, x: f32) -> f32 {
2f32.powf((x - rect.left()) / rect.width() * RULER_MAX.log2())
}
fn level_y(rect: Rect, db: f32) -> f32 {
let t = (db - FLOOR) / (LEVEL_TOP - FLOOR);
rect.bottom() - rect.height() * t.clamp(0., 1.)
}
fn gain_y(rect: Rect, db: f32) -> f32 {
let t = (db - GAIN_MIN) / (GAIN_MAX - GAIN_MIN);
rect.bottom() - rect.height() * t.clamp(0., 1.)
}
fn edge_mut(settings: &mut Settings, edge: usize) -> &mut u32 {
match edge {
0 => &mut settings.low,
1 => &mut settings.mid,
_ => &mut settings.high,
}
}
fn edge_range(edge: usize) -> (u32, u32) {
match edge {
0 => (1, 8),
1 => (2, 16),
_ => (3, TOP_MAX),
}
}
/// Harmonic ruler with draggable band edges, then one column per band with its level,
/// thresholds and compressor gain.
fn band_display(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let size = Vec2::new(ui.available_width(), height);
let (outer, response) = ui.allocate_exact_size(size, Sense::click_and_drag());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
let ruler = Rect::from_min_max(
outer.left_top() + Vec2::new(16., 26.),
Pos2::new(outer.right() - 16., outer.top() + 62.),
);
let columns = Rect::from_min_max(
Pos2::new(outer.left() + 40., ruler.bottom() + 34.),
Pos2::new(outer.right() - 40., outer.bottom() - 34.),
);
let tops = settings.tops();
let f = state.analyzer;
let root = if f.hz > 0. { f.hz } else { FALLBACK_HZ };
// Ruler: band spans, harmonic ticks, and edges between harmonics.
let edge_at = |k: u32| ((k * (k + 1)) as f32).sqrt();
let mut from = 1f32;
for b in 0..BANDS {
let to = if b < BANDS - 1 {
edge_at(tops[b])
} else {
RULER_MAX
};
let span = Rect::from_min_max(
Pos2::new(ruler_x(ruler, from), ruler.top() + 14.),
Pos2::new(ruler_x(ruler, to), ruler.bottom()),
);
p.rect_filled(span, 1., BAND_COLORS[b].gamma_multiply(0.22));
p.text(
span.center_top() + Vec2::new(0., -2.),
egui::Align2::CENTER_BOTTOM,
format!("BAND {} · {}", b + 1, settings.band_label(b)),
FontId::proportional(10.),
BAND_COLORS[b],
);
from = to;
}
for k in 1..=RULER_MAX as u32 {
let x = ruler_x(ruler, k as f32);
let major = k.is_power_of_two() || k <= 8;
let tick = if major { 10. } else { 5. };
p.line_segment(
[
Pos2::new(x, ruler.bottom() - tick),
Pos2::new(x, ruler.bottom()),
],
Stroke::new(1.0_f32, if major { MUTED } else { DIM }),
);
if major {
p.text(
Pos2::new(x, ruler.bottom() + 3.),
egui::Align2::CENTER_TOP,
format!("{k}"),
FontId::proportional(10.),
MUTED,
);
}
}
let nearest = |x: f32| {
(0..BANDS - 1)
.min_by(|&a, &b| {
let da = (ruler_x(ruler, edge_at(tops[a])) - x).abs();
let db = (ruler_x(ruler, edge_at(tops[b])) - x).abs();
da.total_cmp(&db)
})
.unwrap_or(0)
};
let hover_edge = response
.hover_pos()
.filter(|p| p.y < ruler.bottom() + 16.)
.map(|p| nearest(p.x));
if response.drag_started() {
state.dragging = response
.interact_pointer_pos()
.filter(|p| p.y < ruler.bottom() + 16.)
.map(|p| nearest(p.x));
}
if let (Some(edge), Some(pos)) = (state.dragging, response.interact_pointer_pos()) {
let (lo, hi) = edge_range(edge);
// The edge above harmonic k sits at √(k(k+1)), so k is the harmonic below the pointer.
let k = (x_ruler(ruler, pos.x) - 0.5)
.round()
.clamp(lo as f32, hi as f32) as u32;
*edge_mut(settings, edge) = k;
// Keep the order visible while dragging: the other edges make room.
let t = settings.tops();
settings.low = t[0];
settings.mid = t[1].min(16);
settings.high = t[2];
}
if !response.dragged() {
state.dragging = None;
}
for (e, &top) in settings.tops().iter().enumerate() {
let x = ruler_x(ruler, edge_at(top));
let active = state.dragging == Some(e) || hover_edge == Some(e);
p.line_segment(
[
Pos2::new(x, ruler.top() + 10.),
Pos2::new(x, ruler.bottom() + 2.),
],
Stroke::new(
if active { 2.5_f32 } else { 1.5 },
if active { TEXT } else { STEEL },
),
);
}
// Columns: level (steel) against the thresholds, gain (accent cut, blue lift).
for db in [0., -12., -24., -36., -48., -60., -72.] {
let y = level_y(columns, db);
p.line_segment(
[Pos2::new(columns.left(), y), Pos2::new(columns.right(), y)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 8., y),
egui::Align2::LEFT_CENTER,
format!("{db:.0}"),
FontId::proportional(10.),
DIM,
);
}
// Gain scale on the right, for the bars.
for db in [24., 12., 0., -12., -24.] {
p.text(
Pos2::new(outer.right() - 8., gain_y(columns, db)),
egui::Align2::RIGHT_CENTER,
if db == 0. {
"0".into()
} else {
format!("{db:+.0}")
},
FontId::proportional(10.),
if db == 0. { MUTED } else { DIM },
);
}
let col = columns.width() / BANDS as f32;
let depth = settings.depth * 0.01;
for b in 0..BANDS {
let x0 = columns.left() + col * b as f32;
let level = Rect::from_min_max(
Pos2::new(x0 + col * 0.14, level_y(columns, f.levels[b])),
Pos2::new(x0 + col * 0.44, columns.bottom()),
);
if level.height() > 0.5 {
p.rect_filled(level, 1., STEEL.gamma_multiply(0.8));
}
for (db, amount, color) in [
(DOWN_THRESHOLD, settings.downward, ACCENT),
(UP_THRESHOLD, settings.upward, FOCUS),
] {
if amount > 0. {
let y = level_y(columns, db);
p.line_segment(
[Pos2::new(x0 + col * 0.08, y), Pos2::new(x0 + col * 0.5, y)],
Stroke::new(1.0_f32, color.gamma_multiply(0.8)),
);
}
}
// The same blend as the engine: Depth mixes the band with its compressed copy.
let wet = 10f32.powf((f.gains[b] + settings.gains[b]) / 20.);
let gain = 20. * (1. + depth * (wet - 1.)).max(1e-6).log10();
let zero = gain_y(columns, 0.);
let y = gain_y(columns, gain);
let bar = Rect::from_min_max(
Pos2::new(x0 + col * 0.56, zero.min(y)),
Pos2::new(x0 + col * 0.86, zero.max(y)),
);
p.line_segment(
[
Pos2::new(x0 + col * 0.52, zero),
Pos2::new(x0 + col * 0.9, zero),
],
Stroke::new(1.0_f32, EDGE),
);
if bar.height() >= 1. {
p.rect_filled(bar, 1., if gain < 0. { ACCENT } else { FOCUS });
}
let hz = |k: f32| k * root;
let low = if b == 0 { 1. } else { edge_at(tops[b - 1]) };
let range = if b < BANDS - 1 {
format!("{:.0}–{:.0} Hz", hz(low), hz(edge_at(tops[b])))
} else {
format!("{:.0} Hz +", hz(low))
};
p.text(
Pos2::new(x0 + col * 0.5, columns.bottom() + 6.),
egui::Align2::CENTER_TOP,
format!("{} · {gain:+.1} dB", settings.band_label(b)),
FontId::proportional(11.),
BAND_COLORS[b],
);
p.text(
Pos2::new(x0 + col * 0.5, columns.bottom() + 20.),
egui::Align2::CENTER_TOP,
range,
FontId::proportional(10.),
MUTED,
);
}
p.text(
Pos2::new(outer.right() - 8., ruler.bottom() + 18.),
egui::Align2::RIGHT_TOP,
"LEVEL dB · GAIN dB",
FontId::proportional(11.),
MUTED,
);
p.text(
outer.left_top() + Vec2::new(8., 6.),
egui::Align2::LEFT_TOP,
"HARMONIC",
FontId::proportional(11.),
MUTED,
);
p.rect_stroke(
outer,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
if let Some(e) = state.dragging.or(hover_edge) {
let k = tops[e];
state.help = format!(
"Edge between harmonics {k} and {}: {:.0} Hz on this note. Drag to move it.",
k + 1,
edge_at(k) * root,
);
} else if response.hovered() {
state.help = format!(
"Level plus Drive (steel) against the upward (blue) and downward (pink) thresholds; bar: band gain. Lift is at most {UP_MAX:.0} dB."
);
}
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "PLANISH", "HARMONIC OTT", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
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.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
band_display(ui, settings, state, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
/// Label, help text and band edges (highest harmonic of bands 1–3).
type Split = (&'static str, &'static str, [u32; 3]);
fn note_panel(
ui: &mut egui::Ui,
settings: &mut Settings,
state: &mut State,
frame: &AnalyzerFrame,
) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => (
"—".into(),
format!("no note yet · edges on {FALLBACK_HZ:.0} Hz"),
),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(6.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI",
}
))
.size(12.)
.color(MUTED),
);
ui.add_space(14.);
caption(ui, "SPLITS");
let splits: [Split; 4] = [
(
"CLASSIC",
"Fundamental alone, body 2–4, mids 5–16, top 17 and up.",
[1, 4, 16],
),
(
"SUB + BODY",
"Harmonics 1–2 together, so the octave stays locked to the sub.",
[2, 6, 24],
),
(
"TIGHT",
"Narrow low bands for close control of the first harmonics.",
[1, 2, 8],
),
(
"WIDE TOP",
"One big mid band, a top that starts high: fizz only.",
[1, 3, 32],
),
];
ui.horizontal_wrapped(|ui| {
for (label, help, [low, mid, high]) in splits {
let on = settings.tops() == [low, mid, high];
let r = ui.selectable_label(on, RichText::new(label).size(12.));
if r.clicked() {
settings.low = low;
settings.mid = mid;
settings.high = high;
}
hint(&r, state, help);
}
});
ui.add_space(10.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the level window on the real period, which is steadier on detuned patches.",
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.columns(12, |c| {
knob(&mut c[0], state, "Drive", &mut settings.drive, -24. ..=24., d.drive, " dB", 64.,
"Pushes the band levels against the thresholds: more drive, more squash. It does not change the level by itself.");
knob(&mut c[1], state, "Depth", &mut settings.depth, 0. ..=100., d.depth, " %", 64.,
"How much of the compression is applied. 0 % is a clean crossover sum.");
knob(&mut c[2], state, "Upward", &mut settings.upward, 0. ..=100., d.upward, " %", 64.,
"Lift for bands below the upward threshold: brings up quiet harmonics.");
knob(&mut c[3], state, "Downward", &mut settings.downward, 0. ..=100., d.downward, " %", 64.,
"Cut for bands above the downward threshold: holds loud harmonics down.");
knob(&mut c[4], state, "Time", &mut settings.time, 10. ..=1000., d.time, " %", 64.,
"Scales every attack and release. Lower bands are slower than higher ones.");
for b in 0..BANDS {
let name = format!("Band {}", b + 1);
let help = format!(
"Gain of band {} (harmonics {}) after its compressor, scaled by Depth.",
b + 1,
settings.band_label(b)
);
knob(&mut c[5 + b], state, &name, &mut settings.gains[b], GAIN_MIN..=GAIN_MAX,
d.gains[b], " dB", 54., &help);
}
knob(&mut c[9], state, "Smooth", &mut settings.smooth, 0. ..=100., d.smooth, " ms", 54.,
"Retune time for slides and bends. New notes always retune at once.");
knob(&mut c[10], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54.,
"Input level that counts as a note. Raise it if tails retrigger.");
knob(&mut c[11], state, "Output", &mut settings.output, -24. ..=24., d.output, " dB", 54.,
"Output trim.");
});
}