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.
414 lines
14 KiB
Rust
414 lines
14 KiB
Rust
//! Temper editor, shared by the CLAP wrapper and the headless renderer.
|
||
use common_dsp::db_to_gain;
|
||
use common_ui::*;
|
||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||
use temper_core::{AnalyzerFrame, Curve, Settings, Source, SHOWN};
|
||
|
||
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,
|
||
}
|
||
impl Default for State {
|
||
fn default() -> Self {
|
||
Self {
|
||
analyzer: AnalyzerFrame::default(),
|
||
gesture_active: false,
|
||
help: DEFAULT_HELP.into(),
|
||
}
|
||
}
|
||
}
|
||
const DEFAULT_HELP: &str =
|
||
"Every note is driven the same: the input level is normalised per note and the tone filters follow the pitch.";
|
||
impl State {
|
||
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
|
||
let old = self.analyzer;
|
||
self.analyzer = frame;
|
||
for k in 0..SHOWN {
|
||
self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.);
|
||
self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.);
|
||
}
|
||
self.analyzer.shaper_db = frame.shaper_db.max(old.shaper_db - 1.5);
|
||
}
|
||
}
|
||
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);
|
||
/// Input peak at which a normalised note reaches the shaper before Drive.
|
||
const NOMINAL: f32 = 0.35;
|
||
|
||
fn transfer(ui: &mut egui::Ui, settings: &Settings, size: f32) {
|
||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
||
let p = ui.painter_at(rect);
|
||
p.rect_filled(rect, 2., SCREEN);
|
||
let c = rect.center();
|
||
let half = rect.width() * 0.45;
|
||
for t in [-1., -0.5, 0.5, 1.] {
|
||
p.line_segment(
|
||
[
|
||
Pos2::new(c.x + t * half, rect.top()),
|
||
Pos2::new(c.x + t * half, rect.bottom()),
|
||
],
|
||
Stroke::new(1.0_f32, GRID),
|
||
);
|
||
p.line_segment(
|
||
[
|
||
Pos2::new(rect.left(), c.y - t * half),
|
||
Pos2::new(rect.right(), c.y - t * half),
|
||
],
|
||
Stroke::new(1.0_f32, GRID),
|
||
);
|
||
}
|
||
p.line_segment(
|
||
[Pos2::new(rect.left(), c.y), Pos2::new(rect.right(), c.y)],
|
||
Stroke::new(1.0_f32, EDGE),
|
||
);
|
||
p.line_segment(
|
||
[Pos2::new(c.x, rect.top()), Pos2::new(c.x, rect.bottom())],
|
||
Stroke::new(1.0_f32, EDGE),
|
||
);
|
||
p.line_segment(
|
||
[
|
||
Pos2::new(c.x - half, c.y + half),
|
||
Pos2::new(c.x + half, c.y - half),
|
||
],
|
||
Stroke::new(1.0_f32, DIM),
|
||
);
|
||
// Input axis: a normalised note's peak at ±1; output: the shaper's ±1.
|
||
let gain = db_to_gain(settings.drive) * NOMINAL;
|
||
let bias = settings.bias * 0.008;
|
||
let offset = settings.curve.shape(bias);
|
||
let points: Vec<Pos2> = (0..=160)
|
||
.map(|i| {
|
||
let x = -1.1 + 2.2 * i as f32 / 160.;
|
||
let y = settings.curve.shape(x * gain + bias) - offset;
|
||
Pos2::new(c.x + x * half, c.y - y.clamp(-1.1, 1.1) * half)
|
||
})
|
||
.collect();
|
||
p.add(egui::Shape::line(points, Stroke::new(2.0_f32, ACCENT)));
|
||
p.text(
|
||
rect.left_top() + Vec2::new(8., 6.),
|
||
egui::Align2::LEFT_TOP,
|
||
"TRANSFER",
|
||
FontId::proportional(11.),
|
||
MUTED,
|
||
);
|
||
p.rect_stroke(
|
||
rect,
|
||
2.,
|
||
Stroke::new(1.0_f32, EDGE),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
}
|
||
|
||
fn spectrum(ui: &mut egui::Ui, frame: &AnalyzerFrame, settings: &Settings, height: f32) {
|
||
let size = Vec2::new(ui.available_width(), height);
|
||
let (outer, response) = ui.allocate_exact_size(size, Sense::hover());
|
||
let p = ui.painter_at(outer);
|
||
p.rect_filled(outer, 2., SCREEN);
|
||
let area = Rect::from_min_max(
|
||
outer.left_top() + Vec2::new(34., 24.),
|
||
outer.right_bottom() - Vec2::new(10., 24.),
|
||
);
|
||
let floor = -72.;
|
||
let y = |db: f32| area.bottom() - area.height() * ((db - floor) / -floor).clamp(0., 1.);
|
||
for db in [0., -12., -24., -36., -48., -60.] {
|
||
let yy = y(db);
|
||
p.line_segment(
|
||
[Pos2::new(area.left(), yy), Pos2::new(area.right(), yy)],
|
||
Stroke::new(1.0_f32, GRID),
|
||
);
|
||
p.text(
|
||
Pos2::new(outer.left() + 6., yy),
|
||
egui::Align2::LEFT_CENTER,
|
||
format!("{db:.0}"),
|
||
FontId::proportional(10.),
|
||
DIM,
|
||
);
|
||
}
|
||
let col = area.width() / SHOWN as f32;
|
||
for k in 0..SHOWN {
|
||
let x0 = area.left() + col * k as f32;
|
||
for (i, (db, color)) in [(frame.input[k], STEEL), (frame.output[k], ACCENT)]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
if db <= floor {
|
||
continue;
|
||
}
|
||
let w = col * 0.32;
|
||
let x = x0 + col * 0.16 + i as f32 * (w + col * 0.04);
|
||
p.rect_filled(
|
||
Rect::from_min_max(Pos2::new(x, y(db)), Pos2::new(x + w, area.bottom())),
|
||
0.,
|
||
color,
|
||
);
|
||
}
|
||
let marked = (k + 1) as u32 == settings.push_harmonic && settings.push_gain != 0.;
|
||
p.text(
|
||
Pos2::new(x0 + col * 0.5, area.bottom() + 4.),
|
||
egui::Align2::CENTER_TOP,
|
||
format!("{}", k + 1),
|
||
FontId::proportional(10.),
|
||
if marked { ACCENT } else { MUTED },
|
||
);
|
||
}
|
||
// Tracked filter positions, in harmonic numbers.
|
||
let mark = |h: f32, label: &str| {
|
||
if h > 0. && h <= SHOWN as f32 + 0.5 {
|
||
let x = area.left() + col * (h - 0.5);
|
||
p.line_segment(
|
||
[Pos2::new(x, area.top()), Pos2::new(x, area.bottom())],
|
||
Stroke::new(1.0_f32, FOCUS),
|
||
);
|
||
p.text(
|
||
Pos2::new(x + 4., area.top()),
|
||
egui::Align2::LEFT_TOP,
|
||
label,
|
||
FontId::proportional(10.),
|
||
FOCUS,
|
||
);
|
||
}
|
||
};
|
||
mark(settings.floor, "FLOOR");
|
||
mark(settings.bright, "BRIGHT");
|
||
p.text(
|
||
outer.left_top() + Vec2::new(34., 6.),
|
||
egui::Align2::LEFT_TOP,
|
||
"HARMONICS",
|
||
FontId::proportional(11.),
|
||
MUTED,
|
||
);
|
||
p.text(
|
||
outer.right_top() + Vec2::new(-10., 6.),
|
||
egui::Align2::RIGHT_TOP,
|
||
"IN · OUT",
|
||
FontId::proportional(11.),
|
||
MUTED,
|
||
);
|
||
p.rect_stroke(
|
||
outer,
|
||
2.,
|
||
Stroke::new(1.0_f32, EDGE),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
let _ = response;
|
||
}
|
||
|
||
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, "TEMPER", "TRACKED DISTORTION", |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;
|
||
}
|
||
let r = ui
|
||
.selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.))
|
||
.on_hover_text(
|
||
"Match output loudness to the input, so Drive changes character, not level.",
|
||
);
|
||
if r.clicked() {
|
||
settings.auto_gain = !settings.auto_gain;
|
||
}
|
||
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, &frame));
|
||
});
|
||
ui.add_space(4.);
|
||
panel(ui, |ui| {
|
||
ui.set_min_height(TOP);
|
||
ui.vertical(|ui| {
|
||
transfer(ui, settings, 236.);
|
||
ui.add_space(4.);
|
||
ui.horizontal(|ui| {
|
||
for curve in Curve::ALL {
|
||
let r = ui.selectable_label(
|
||
settings.curve == curve,
|
||
RichText::new(curve.name().to_uppercase()).size(12.),
|
||
);
|
||
if r.clicked() {
|
||
settings.curve = curve;
|
||
}
|
||
hint(&r, state, match curve {
|
||
Curve::Soft => "Soft: tanh. Smooth, odd harmonics, rounded top.",
|
||
Curve::Hard => "Hard: clipping with a narrow knee. Dense, square-like.",
|
||
Curve::Fold => "Fold: the wave folds back on itself. Drive moves the upper harmonics.",
|
||
Curve::Tube => "Tube: the negative half clips earlier. Adds even harmonics.",
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
ui.add_space(4.);
|
||
panel(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
spectrum(ui, &frame, settings, 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 = 290.;
|
||
|
||
fn note_panel(ui: &mut egui::Ui, settings: &Settings, 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, "INTO SHAPER");
|
||
let (rect, _) = ui.allocate_exact_size(Vec2::new(180., 10.), Sense::hover());
|
||
let p = ui.painter();
|
||
p.rect_filled(rect, 1., SCREEN);
|
||
// −24..+24 dB around the curve's knee at 0 dB.
|
||
let t = ((frame.shaper_db + 24.) / 48.).clamp(0., 1.);
|
||
let mut fill = rect;
|
||
fill.set_width(rect.width() * t);
|
||
p.rect_filled(fill, 1., ACCENT);
|
||
let knee = rect.left() + rect.width() * 0.5;
|
||
p.line_segment(
|
||
[
|
||
Pos2::new(knee, rect.top() - 2.),
|
||
Pos2::new(knee, rect.bottom() + 2.),
|
||
],
|
||
Stroke::new(1.0_f32, TEXT),
|
||
);
|
||
p.rect_stroke(
|
||
rect,
|
||
1.,
|
||
Stroke::new(1.0_f32, EDGE),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
ui.label(
|
||
RichText::new(if frame.shaper_db < -60. {
|
||
"—".to_string()
|
||
} else {
|
||
format!("{:+.1} dB over the knee", frame.shaper_db)
|
||
})
|
||
.monospace()
|
||
.size(12.)
|
||
.color(TEXT),
|
||
);
|
||
ui.add_space(8.);
|
||
readout(
|
||
ui,
|
||
"NORMALIZE GAIN",
|
||
format!("{:+.1} dB", frame.normalize_db),
|
||
STEEL,
|
||
);
|
||
readout(
|
||
ui,
|
||
"AUTO GAIN",
|
||
if settings.auto_gain {
|
||
format!("{:+.1} dB", frame.auto_db)
|
||
} else {
|
||
"off".into()
|
||
},
|
||
STEEL,
|
||
);
|
||
}
|
||
|
||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||
let d = Settings::default();
|
||
ui.columns(10, |c| {
|
||
knob(&mut c[0], state, "Drive", &mut settings.drive, 0. ..=36., d.drive, " dB", 80.,
|
||
"Gain into the curve. With Normalize at 100 % every note starts from the same level.");
|
||
knob(&mut c[1], state, "Bias", &mut settings.bias, 0. ..=100., d.bias, " %", 80.,
|
||
"Shift the curve's operating point: asymmetric clipping, even harmonics.");
|
||
knob(&mut c[2], state, "Push", &mut settings.push_gain, -12. ..=12., d.push_gain, " dB", 80.,
|
||
"Boost or cut one harmonic before the curve, so the distortion bites there on every note.");
|
||
knob(&mut c[3], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 80.,
|
||
"Blend with the delayed dry signal.");
|
||
let mut push = settings.push_harmonic as f32;
|
||
knob(&mut c[4], state, "Push at", &mut push, 1. ..=16., d.push_harmonic as f32, "", 54.,
|
||
"Harmonic number that Push boosts or cuts.");
|
||
settings.push_harmonic = push.round().clamp(1., 16.) as u32;
|
||
knob(&mut c[5], state, "Floor", &mut settings.floor, 0. ..=2., d.floor, " ×f0", 54.,
|
||
"Highpass before the curve at this multiple of the note. 0 is off. Keeps sub mud out of the distortion.");
|
||
if settings.floor < 0.25 {
|
||
settings.floor = 0.;
|
||
}
|
||
knob(&mut c[6], state, "Bright", &mut settings.bright, 0. ..=64., d.bright, " ×f0", 54.,
|
||
"Lowpass after the curve at this harmonic. 0 is off. The top end stops at the same harmonic on every note.");
|
||
if settings.bright < 4. {
|
||
settings.bright = 0.;
|
||
}
|
||
knob(&mut c[7], state, "Normalize", &mut settings.normalize, 0. ..=100., d.normalize, " %", 54.,
|
||
"How far each note's level is evened out before Drive. 100 %: quiet and loud patches distort the same.");
|
||
knob(&mut c[8], 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[9], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54.,
|
||
"Output trim.");
|
||
});
|
||
}
|