Check and Windows preview / check (anvil) (push) Successful in 2m8s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 2m13s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m34s
Check and Windows preview / check (temper) (push) Successful in 2m12s
Check and Windows preview / previews (push) Successful in 1m19s
Check and Windows preview / windows (push) Successful in 2m13s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 6s
Independent plugin release / build (push) Successful in 2m57s
Lathe places EQ bands on harmonics 1-32 of the played note, so a cut on harmonic 3 stays on harmonic 3 on every note. Temper normalises each note's level before an oversampled shaper and tunes its floor, push and bright filters to the note, so every note distorts the same. The note tracker moves from Anvil into common-dsp, together with a state-variable filter, 4x oversampler and harmonic meter. Anvil's behaviour and editor render are unchanged. docs/ideas.md collects all plugin ideas and their status.
432 lines
15 KiB
Rust
432 lines
15 KiB
Rust
//! Lathe 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 lathe_core::{AnalyzerFrame, Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
|
||
|
||
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,
|
||
/// Last point of a draw stroke in the harmonic editor: (harmonic index, dB).
|
||
stroke: Option<(usize, f32)>,
|
||
}
|
||
impl Default for State {
|
||
fn default() -> Self {
|
||
Self {
|
||
analyzer: AnalyzerFrame::default(),
|
||
gesture_active: false,
|
||
help: DEFAULT_HELP.into(),
|
||
stroke: None,
|
||
}
|
||
}
|
||
}
|
||
const DEFAULT_HELP: &str =
|
||
"Draw gains on the harmonics. They follow the note: harmonic 3 stays harmonic 3 on every pitch.";
|
||
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 windows.
|
||
for k in 0..HARMONICS {
|
||
self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.);
|
||
self.analyzer.output[k] = frame.output[k].max(old.output[k] - 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);
|
||
const LEVEL_FLOOR: f32 = -72.;
|
||
|
||
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 y_gain(rect: Rect, y: f32) -> f32 {
|
||
let t = (rect.bottom() - y) / rect.height();
|
||
// Snap near 0 dB so drawing a flat line is easy.
|
||
let db = GAIN_MIN + t * (GAIN_MAX - GAIN_MIN);
|
||
let db = if db.abs() < 0.4 { 0. } else { db };
|
||
db.clamp(GAIN_MIN, GAIN_MAX)
|
||
}
|
||
|
||
/// Bar editor of the per-harmonic gains, with the effective curve and measured levels.
|
||
fn harmonic_editor(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 levels_h = 64.;
|
||
let labels_h = 18.;
|
||
let left = 34.;
|
||
let gains = Rect::from_min_max(
|
||
outer.left_top() + Vec2::new(left, 12.),
|
||
Pos2::new(
|
||
outer.right() - 10.,
|
||
outer.bottom() - levels_h - labels_h - 8.,
|
||
),
|
||
);
|
||
let levels = Rect::from_min_max(
|
||
Pos2::new(gains.left(), gains.bottom() + labels_h + 4.),
|
||
Pos2::new(gains.right(), outer.bottom() - 6.),
|
||
);
|
||
let col = gains.width() / HARMONICS as f32;
|
||
let column = |x: f32| {
|
||
(((x - gains.left()) / col).floor() as isize).clamp(0, HARMONICS as isize - 1) as usize
|
||
};
|
||
|
||
// Interaction: drag draws, Shift-drag nudges, double-click resets a bar.
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
let k = column(pos.x);
|
||
if response.double_clicked() {
|
||
settings.gains[k] = 0.;
|
||
state.stroke = None;
|
||
} else if response.dragged() || response.drag_started() || response.clicked() {
|
||
if ui.input(|i| i.modifiers.shift) {
|
||
let dy = ui.input(|i| i.pointer.delta().y);
|
||
let step = dy * (GAIN_MAX - GAIN_MIN) / gains.height() * 0.15;
|
||
settings.gains[k] = (settings.gains[k] - step).clamp(GAIN_MIN, GAIN_MAX);
|
||
} else {
|
||
let db = y_gain(gains, pos.y);
|
||
// Fill every column the pointer skipped since the last frame.
|
||
let (from, from_db) = state.stroke.unwrap_or((k, db));
|
||
let (a, b) = (from.min(k), from.max(k));
|
||
for i in a..=b {
|
||
let t = if b == a {
|
||
1.
|
||
} else {
|
||
(i as f32 - from as f32) / (k as f32 - from as f32)
|
||
};
|
||
settings.gains[i] = from_db + (db - from_db) * t.clamp(0., 1.);
|
||
}
|
||
state.stroke = Some((k, db));
|
||
}
|
||
}
|
||
}
|
||
if !response.dragged() {
|
||
state.stroke = None;
|
||
}
|
||
|
||
// Grid: dB lines with labels.
|
||
for db in [12., 6., 0., -6., -12., -18., -24.] {
|
||
let y = gain_y(gains, db);
|
||
let zero = db == 0.;
|
||
p.line_segment(
|
||
[Pos2::new(gains.left(), y), Pos2::new(gains.right(), y)],
|
||
Stroke::new(1.0_f32, if zero { EDGE } else { GRID }),
|
||
);
|
||
p.text(
|
||
Pos2::new(outer.left() + 6., y),
|
||
egui::Align2::LEFT_CENTER,
|
||
if zero {
|
||
"0".into()
|
||
} else {
|
||
format!("{db:+.0}")
|
||
},
|
||
FontId::proportional(10.),
|
||
if zero { MUTED } else { DIM },
|
||
);
|
||
}
|
||
let zero = gain_y(gains, 0.);
|
||
let active = settings.harmonics as usize;
|
||
let hover = response.hover_pos().map(|pos| column(pos.x));
|
||
let mut curve = Vec::with_capacity(HARMONICS);
|
||
for k in 0..HARMONICS {
|
||
let x0 = gains.left() + col * k as f32;
|
||
let bar = Rect::from_min_max(
|
||
Pos2::new(x0 + col * 0.18, zero.min(gain_y(gains, settings.gains[k]))),
|
||
Pos2::new(x0 + col * 0.82, zero.max(gain_y(gains, settings.gains[k]))),
|
||
);
|
||
let on = k < active;
|
||
let fill = if !on {
|
||
Color32::from_rgb(40, 42, 47)
|
||
} else if hover == Some(k) {
|
||
Color32::from_rgb(255, 60, 130)
|
||
} else {
|
||
ACCENT
|
||
};
|
||
if bar.height() >= 1. {
|
||
p.rect_filled(bar, 1., fill);
|
||
} else {
|
||
p.line_segment(
|
||
[Pos2::new(bar.left(), zero), Pos2::new(bar.right(), zero)],
|
||
Stroke::new(2.0_f32, if on { DIM } else { GRID }),
|
||
);
|
||
}
|
||
if on {
|
||
curve.push(Pos2::new(
|
||
x0 + col * 0.5,
|
||
gain_y(gains, settings.effective(k + 1)),
|
||
));
|
||
}
|
||
let label_color = if hover == Some(k) {
|
||
TEXT
|
||
} else if on {
|
||
MUTED
|
||
} else {
|
||
DIM
|
||
};
|
||
p.text(
|
||
Pos2::new(x0 + col * 0.5, gains.bottom() + 4.),
|
||
egui::Align2::CENTER_TOP,
|
||
format!("{}", k + 1),
|
||
FontId::proportional(10.),
|
||
label_color,
|
||
);
|
||
// Measured levels: input (steel) and output (accent), side by side.
|
||
let f = &state.analyzer;
|
||
for (i, (db, color)) in [(f.input[k], STEEL), (f.output[k], ACCENT)]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
let t = ((db - LEVEL_FLOOR) / -LEVEL_FLOOR).clamp(0., 1.);
|
||
if t <= 0. {
|
||
continue;
|
||
}
|
||
let w = col * 0.3;
|
||
let x = x0 + col * 0.18 + i as f32 * (w + col * 0.04);
|
||
let r = Rect::from_min_max(
|
||
Pos2::new(x, levels.bottom() - levels.height() * t),
|
||
Pos2::new(x + w, levels.bottom()),
|
||
);
|
||
p.rect_filled(r, 0., color.gamma_multiply(if on { 0.9 } else { 0.4 }));
|
||
}
|
||
}
|
||
if curve.len() > 1 {
|
||
p.add(egui::Shape::line(curve, Stroke::new(1.5_f32, FOCUS)));
|
||
}
|
||
p.line_segment(
|
||
[
|
||
Pos2::new(levels.left(), levels.top() - 2.),
|
||
Pos2::new(levels.right(), levels.top() - 2.),
|
||
],
|
||
Stroke::new(1.0_f32, GRID),
|
||
);
|
||
p.text(
|
||
gains.right_top() + Vec2::new(-4., 2.),
|
||
egui::Align2::RIGHT_TOP,
|
||
"GAIN · EFFECTIVE",
|
||
FontId::proportional(11.),
|
||
MUTED,
|
||
);
|
||
p.text(
|
||
Pos2::new(levels.right() - 4., levels.top() + 2.),
|
||
egui::Align2::RIGHT_TOP,
|
||
"LEVEL IN · OUT",
|
||
FontId::proportional(11.),
|
||
MUTED,
|
||
);
|
||
p.rect_stroke(
|
||
outer,
|
||
2.,
|
||
Stroke::new(1.0_f32, EDGE),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
if let Some(k) = hover {
|
||
let f = &state.analyzer;
|
||
let at = if f.hz > 0. {
|
||
format!(" at {:.0} Hz", f.hz * (k + 1) as f32)
|
||
} else {
|
||
String::new()
|
||
};
|
||
state.help = format!(
|
||
"Harmonic {}{at}: drawn {:+.1} dB, effective {:+.1} dB. Drag to draw, Shift-drag fine, double-click resets.",
|
||
k + 1,
|
||
settings.gains[k],
|
||
settings.effective(k + 1),
|
||
);
|
||
}
|
||
}
|
||
|
||
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, "LATHE", "HARMONIC EQ", |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());
|
||
harmonic_editor(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));
|
||
}
|
||
const TOP: f32 = 330.;
|
||
|
||
/// Label, help text, and gain in dB for 1-based harmonic `k`.
|
||
type Preset = (&'static str, &'static str, fn(usize) -> f32);
|
||
|
||
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(), "no note yet".into()),
|
||
};
|
||
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, "SHAPES");
|
||
let presets: [Preset; 4] = [
|
||
("FLAT", "Reset every drawn gain to 0 dB.", |_| 0.),
|
||
(
|
||
"ODD ONLY",
|
||
"Remove even harmonics: hollow, square-like.",
|
||
|k| {
|
||
if k % 2 == 0 {
|
||
-24.
|
||
} else {
|
||
0.
|
||
}
|
||
},
|
||
),
|
||
(
|
||
"EVEN ONLY",
|
||
"Keep the fundamental and even harmonics: octave-like, smooth.",
|
||
|k| {
|
||
if k > 1 && k % 2 == 1 {
|
||
-24.
|
||
} else {
|
||
0.
|
||
}
|
||
},
|
||
),
|
||
(
|
||
"HOLLOW",
|
||
"Cut harmonics 2–4: scooped, vocal growl body.",
|
||
|k| {
|
||
if (2..=4).contains(&k) {
|
||
-12.
|
||
} else {
|
||
0.
|
||
}
|
||
},
|
||
),
|
||
];
|
||
ui.horizontal_wrapped(|ui| {
|
||
for (label, help, f) in presets {
|
||
let r = ui.button(RichText::new(label).size(12.));
|
||
if r.clicked() {
|
||
for k in 0..HARMONICS {
|
||
settings.gains[k] = f(k + 1);
|
||
}
|
||
}
|
||
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. Turn off for slides and pitch bends.",
|
||
);
|
||
}
|
||
|
||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||
let d = Settings::default();
|
||
ui.columns(9, |c| {
|
||
knob(&mut c[0], state, "Tilt", &mut settings.tilt, -12. ..=12., d.tilt, " dB/oct", 80.,
|
||
"Brighter or darker by harmonic number: dB per octave of harmonics, pivot on the fundamental.");
|
||
knob(&mut c[1], state, "Odd/Even", &mut settings.odd_even, -12. ..=12., d.odd_even, " dB", 80.,
|
||
"Positive: odd harmonics up, even down (square-like). Negative: the reverse.");
|
||
knob(&mut c[2], state, "Between", &mut settings.between, -24. ..=12., d.between, " dB", 80.,
|
||
"Gain between the harmonics. Cut to clean noise and beating, lift for grit.");
|
||
knob(&mut c[3], state, "Width", &mut settings.width, 5. ..=50., d.width, " %", 80.,
|
||
"Bandwidth of every band as a share of the harmonic spacing.");
|
||
let mut harmonics = settings.harmonics as f32;
|
||
knob(&mut c[4], state, "Harmonics", &mut harmonics, 8. ..=32., d.harmonics as f32, "", 54.,
|
||
"How many harmonics are shaped. Higher harmonics pass unchanged.");
|
||
settings.harmonics = harmonics.round() as u32;
|
||
knob(&mut c[5], 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[6], 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[7], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 54.,
|
||
"Blend with the delayed dry signal.");
|
||
knob(&mut c[8], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54.,
|
||
"Output trim.");
|
||
});
|
||
}
|