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.
508 lines
18 KiB
Rust
508 lines
18 KiB
Rust
//! 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.");
|
||
});
|
||
}
|