Check and Windows preview / check (anvil) (push) Successful in 2m1s
Check and Windows preview / check (crucible) (push) Successful in 1m31s
Check and Windows preview / check (knurl) (push) Successful in 2m4s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m54s
Check and Windows preview / check (rivet) (push) Successful in 1m30s
Check and Windows preview / check (temper) (push) Successful in 1m31s
Check and Windows preview / check (weld) (push) Successful in 2m31s
Check and Windows preview / previews (push) Successful in 1m26s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m26s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 8s
Knurl follows the played note and treats every cycle of the wave on its own: a repeating level pattern over 1-16 cycles makes subharmonics in tune with every note, random level and timing per cycle add rattle, and a noise burst per cycle, highpassed at a multiple of f0, adds grit. Amounts are counted in cycles, so the texture is the same on F1 and on C2. - Cycle clock from the shared note tracker, restarted at each note; the audio is delayed by the lookahead plus 4.2 ms of timing-jitter headroom. - Per-cycle randomness from a hash of the cycle number: Same every note or Free. - Patterns Off, Double, Triple, Fry (default), Gallop, Stutter or drawn; grit switchable per step. Pattern persisted as knurl-pattern. - Editor: last eight cycles overlaid, pattern bars with the playing step. - Tests match the prototype: Double gives f0/2 at -8.19 dB, Triple f0/3 at -10.68 dB, equal on F1 and C2, no added clicks, bit-exact when off, no allocation on the audio thread.
502 lines
15 KiB
Rust
502 lines
15 KiB
Rust
//! Knurl 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 knurl_core::{
|
||
AnalyzerFrame, Seed, Settings, Source, GRIT_OFF, PATTERNS, SCOPE_CYCLES, SCOPE_POINTS, STEPS,
|
||
STEP_MIN,
|
||
};
|
||
|
||
pub use common_ui::style;
|
||
pub const WIDTH: u32 = 1040;
|
||
pub const HEIGHT: u32 = 650;
|
||
|
||
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
|
||
const TOP: f32 = 316.;
|
||
|
||
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 cycle of the note gets its own level, timing and grit. Counted in cycles, so it fits every note.";
|
||
impl State {
|
||
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
|
||
let old = self.analyzer;
|
||
self.analyzer = frame;
|
||
self.analyzer.input_db = frame.input_db.max(old.input_db - 3.);
|
||
self.analyzer.output_db = frame.output_db.max(old.output_db - 3.);
|
||
// Keep the last scope between notes.
|
||
if !frame.scope_valid && old.scope_valid {
|
||
self.analyzer.scope = old.scope;
|
||
self.analyzer.scope_valid = true;
|
||
}
|
||
}
|
||
}
|
||
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,
|
||
help: &str,
|
||
) {
|
||
ui.vertical_centered(|ui| {
|
||
let r = rotary(ui, name, value, range, default, unit, 58., ACCENT);
|
||
hint(&r, state, help);
|
||
});
|
||
}
|
||
|
||
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, "KNURL", "CYCLE TEXTURE", |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.);
|
||
ui.horizontal_top(|ui| {
|
||
panel(ui, |ui| {
|
||
ui.set_width(210.);
|
||
ui.set_min_height(TOP);
|
||
ui.vertical(|ui| note_panel(ui, settings, state));
|
||
});
|
||
ui.add_space(4.);
|
||
panel(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.vertical(|ui| {
|
||
let frame = state.analyzer;
|
||
scope(ui, &frame, 140.);
|
||
ui.add_space(4.);
|
||
pattern_editor(ui, settings, state, TOP - 144.);
|
||
});
|
||
});
|
||
});
|
||
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));
|
||
}
|
||
|
||
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||
let frame = state.analyzer;
|
||
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).size(13.).color(MUTED));
|
||
if frame.hz > 0. {
|
||
ui.label(
|
||
RichText::new(format!("one cycle = {:.1} ms", 1000. / frame.hz))
|
||
.size(12.)
|
||
.color(DIM),
|
||
);
|
||
}
|
||
ui.add_space(10.);
|
||
meter(ui, "IN", frame.input_db, STEEL, 110.);
|
||
meter(ui, "OUT", frame.output_db, ACCENT, 110.);
|
||
ui.add_space(10.);
|
||
caption(ui, "RANDOM");
|
||
ui.horizontal(|ui| {
|
||
for (seed, label, help) in [
|
||
(
|
||
Seed::Same,
|
||
"SAME EVERY NOTE",
|
||
"Every note gets the same random sequence: identical notes sound identical.",
|
||
),
|
||
(
|
||
Seed::Free,
|
||
"FREE",
|
||
"The random sequences keep running, so every note differs.",
|
||
),
|
||
] {
|
||
let r = ui.selectable_label(settings.seed == seed, RichText::new(label).size(12.));
|
||
if r.clicked() {
|
||
settings.seed = seed;
|
||
}
|
||
hint(&r, state, help);
|
||
}
|
||
});
|
||
ui.add_space(6.);
|
||
let r = ui.checkbox(
|
||
&mut settings.snap,
|
||
RichText::new("Snap to semitone").size(12.),
|
||
);
|
||
hint(
|
||
&r,
|
||
state,
|
||
"Round the detected pitch. Turn it off for slides.",
|
||
);
|
||
}
|
||
|
||
/// The last cycles of the output, overlaid; the newest is brightest.
|
||
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
|
||
let (outer, _) =
|
||
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
|
||
let p = ui.painter_at(outer);
|
||
p.rect_filled(outer, 2., SCREEN);
|
||
p.text(
|
||
outer.left_top() + Vec2::new(8., 5.),
|
||
egui::Align2::LEFT_TOP,
|
||
format!("LAST {SCOPE_CYCLES} CYCLES, OVERLAID"),
|
||
FontId::proportional(10.),
|
||
MUTED,
|
||
);
|
||
let plot = Rect::from_min_max(
|
||
outer.left_top() + Vec2::new(10., 20.),
|
||
outer.right_bottom() - Vec2::new(10., 8.),
|
||
);
|
||
for t in [0.25f32, 0.5, 0.75] {
|
||
let x = plot.left() + plot.width() * t;
|
||
p.line_segment(
|
||
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
|
||
Stroke::new(1.0_f32, GRID),
|
||
);
|
||
}
|
||
p.line_segment(
|
||
[
|
||
Pos2::new(plot.left(), plot.center().y),
|
||
Pos2::new(plot.right(), plot.center().y),
|
||
],
|
||
Stroke::new(1.0_f32, EDGE),
|
||
);
|
||
if !frame.scope_valid {
|
||
p.text(
|
||
plot.center(),
|
||
egui::Align2::CENTER_CENTER,
|
||
"play a note",
|
||
FontId::proportional(12.),
|
||
DIM,
|
||
);
|
||
return;
|
||
}
|
||
let peak = frame
|
||
.scope
|
||
.iter()
|
||
.flatten()
|
||
.fold(1e-6f32, |m, v| m.max(v.abs()));
|
||
// The newest cycle is still being written; draw the complete ones.
|
||
for (age, cycle) in frame.scope[..SCOPE_CYCLES - 1].iter().enumerate() {
|
||
let alpha = 0.2 + 0.8 * age as f32 / (SCOPE_CYCLES - 2) as f32;
|
||
let color = if age == SCOPE_CYCLES - 2 {
|
||
ACCENT
|
||
} else {
|
||
STEEL.gamma_multiply(alpha * 0.6)
|
||
};
|
||
let points: Vec<Pos2> = cycle
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, v)| {
|
||
Pos2::new(
|
||
plot.left() + plot.width() * i as f32 / (SCOPE_POINTS - 1) as f32,
|
||
plot.center().y - plot.height() * 0.48 * v / peak,
|
||
)
|
||
})
|
||
.collect();
|
||
p.add(egui::Shape::line(
|
||
points,
|
||
Stroke::new(
|
||
if age == SCOPE_CYCLES - 2 {
|
||
2.0_f32
|
||
} else {
|
||
1.0
|
||
},
|
||
color,
|
||
),
|
||
));
|
||
}
|
||
}
|
||
|
||
fn step_y(rect: Rect, db: f32) -> f32 {
|
||
rect.top() + rect.height() * (db / STEP_MIN).clamp(0., 1.)
|
||
}
|
||
|
||
/// One bar per step: drag to set its level, double-click to reset; the square below a bar
|
||
/// switches its grit burst.
|
||
fn pattern_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
|
||
let (outer, response) = ui.allocate_exact_size(
|
||
Vec2::new(ui.available_width(), height),
|
||
Sense::click_and_drag(),
|
||
);
|
||
let p = ui.painter_at(outer);
|
||
p.rect_filled(outer, 2., SCREEN);
|
||
p.text(
|
||
outer.left_top() + Vec2::new(8., 5.),
|
||
egui::Align2::LEFT_TOP,
|
||
"PATTERN: LEVEL PER CYCLE",
|
||
FontId::proportional(10.),
|
||
MUTED,
|
||
);
|
||
let bars = Rect::from_min_max(
|
||
outer.left_top() + Vec2::new(34., 22.),
|
||
Pos2::new(outer.right() - 10., outer.bottom() - 26.),
|
||
);
|
||
let toggles_y = outer.bottom() - 16.;
|
||
let col = bars.width() / STEPS as f32;
|
||
let column =
|
||
|x: f32| (((x - bars.left()) / col).floor() as isize).clamp(0, STEPS as isize - 1) as usize;
|
||
if response.hovered() {
|
||
state.help = "Drag a bar to set the level of that cycle; double-click resets it. The squares switch grit per step.".into();
|
||
}
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
let k = column(pos.x);
|
||
if pos.y > bars.bottom() + 2. {
|
||
if response.clicked() {
|
||
settings.grit_steps[k] = !settings.grit_steps[k];
|
||
}
|
||
} else if response.double_clicked() {
|
||
settings.pattern[k] = 0.;
|
||
} else if response.dragged() || response.clicked() {
|
||
let t = ((pos.y - bars.top()) / bars.height()).clamp(0., 1.);
|
||
let db = t * STEP_MIN;
|
||
settings.pattern[k] = if db > -0.5 { 0. } else { db.round() };
|
||
if k >= settings.steps {
|
||
settings.steps = k + 1;
|
||
}
|
||
}
|
||
}
|
||
for db in [0f32, -6., -12., -18., -24.] {
|
||
let y = step_y(bars, db);
|
||
p.line_segment(
|
||
[Pos2::new(bars.left(), y), Pos2::new(bars.right(), y)],
|
||
Stroke::new(1.0_f32, GRID),
|
||
);
|
||
p.text(
|
||
Pos2::new(outer.left() + 6., y),
|
||
egui::Align2::LEFT_CENTER,
|
||
if db == 0. {
|
||
"0".into()
|
||
} else {
|
||
format!("{db:.0}")
|
||
},
|
||
FontId::proportional(10.),
|
||
DIM,
|
||
);
|
||
}
|
||
let current = state.analyzer.step;
|
||
let playing = state.analyzer.note.is_some();
|
||
for k in 0..STEPS {
|
||
let on = k < settings.steps;
|
||
let x0 = bars.left() + k as f32 * col + 3.;
|
||
let x1 = x0 + col - 6.;
|
||
let top = step_y(bars, settings.pattern[k]);
|
||
let color = if !on {
|
||
DIM.gamma_multiply(0.5)
|
||
} else if playing && k == current {
|
||
TEXT
|
||
} else {
|
||
ACCENT
|
||
};
|
||
p.rect_filled(
|
||
Rect::from_min_max(
|
||
Pos2::new(x0, top.min(bars.bottom() - 2.)),
|
||
Pos2::new(x1, bars.bottom()),
|
||
),
|
||
1.,
|
||
color.gamma_multiply(if on { 0.85 } else { 0.4 }),
|
||
);
|
||
let grit = settings.grit_steps[k] && settings.grit > GRIT_OFF;
|
||
let square = Rect::from_center_size(Pos2::new((x0 + x1) / 2., toggles_y), Vec2::splat(9.));
|
||
if settings.grit_steps[k] {
|
||
p.rect_filled(square, 1., if grit && on { FOCUS } else { DIM });
|
||
} else {
|
||
p.rect_stroke(
|
||
square,
|
||
1.,
|
||
Stroke::new(1.0_f32, DIM),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
}
|
||
}
|
||
p.text(
|
||
Pos2::new(outer.left() + 6., toggles_y),
|
||
egui::Align2::LEFT_CENTER,
|
||
"grit",
|
||
FontId::proportional(10.),
|
||
DIM,
|
||
);
|
||
}
|
||
|
||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||
let d = Settings::default();
|
||
ui.horizontal(|ui| {
|
||
caption(ui, "PATTERN");
|
||
for (name, help, gains) in PATTERNS {
|
||
let selected = settings.steps == gains.len()
|
||
&& settings.pattern[..gains.len()]
|
||
.iter()
|
||
.zip(gains)
|
||
.all(|(a, b)| a == b);
|
||
let r = ui.selectable_label(selected, RichText::new(name.to_uppercase()).size(13.));
|
||
if r.clicked() {
|
||
settings.apply_pattern(gains);
|
||
}
|
||
hint(&r, state, help);
|
||
}
|
||
ui.add_space(24.);
|
||
caption(ui, "STEPS");
|
||
let minus = ui.button(RichText::new("−").size(14.));
|
||
if minus.clicked() && settings.steps > 1 {
|
||
settings.steps -= 1;
|
||
}
|
||
ui.label(
|
||
RichText::new(format!("{:>2}", settings.steps))
|
||
.monospace()
|
||
.size(14.)
|
||
.color(TEXT),
|
||
);
|
||
let plus = ui.button(RichText::new("+").size(14.));
|
||
if plus.clicked() && settings.steps < STEPS {
|
||
settings.steps += 1;
|
||
}
|
||
let help = "Pattern length in cycles. 2 makes a sub-octave, 3 an octave and a fifth down.";
|
||
hint(&minus, state, help);
|
||
hint(&plus, state, help);
|
||
});
|
||
ui.add_space(4.);
|
||
ui.columns(10, |c| {
|
||
knob(
|
||
&mut c[0],
|
||
state,
|
||
"Edge",
|
||
&mut settings.edge,
|
||
5. ..=50.,
|
||
d.edge,
|
||
" %",
|
||
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
|
||
);
|
||
knob(
|
||
&mut c[1],
|
||
state,
|
||
"Level",
|
||
&mut settings.level_jitter,
|
||
0. ..=6.,
|
||
d.level_jitter,
|
||
" dB",
|
||
"Random level per cycle: a rough, living surface.",
|
||
);
|
||
knob(
|
||
&mut c[2],
|
||
state,
|
||
"Time",
|
||
&mut settings.time_jitter,
|
||
0. ..=10.,
|
||
d.time_jitter,
|
||
" %",
|
||
"Random timing per cycle, in percent of a period: rattle and scrape.",
|
||
);
|
||
knob(
|
||
&mut c[3],
|
||
state,
|
||
"Grit",
|
||
&mut settings.grit,
|
||
GRIT_OFF..=0.,
|
||
d.grit,
|
||
" dB",
|
||
"Level of the noise burst in each cycle, relative to the input. Fully left is off.",
|
||
);
|
||
knob(
|
||
&mut c[4],
|
||
state,
|
||
"Tone",
|
||
&mut settings.tone,
|
||
4. ..=64.,
|
||
d.tone,
|
||
" ×f0",
|
||
"Lowest frequency of the grit, in harmonics of the note.",
|
||
);
|
||
knob(
|
||
&mut c[5],
|
||
state,
|
||
"Position",
|
||
&mut settings.position,
|
||
0. ..=100.,
|
||
d.position,
|
||
" %",
|
||
"Where in the cycle the grit burst sits.",
|
||
);
|
||
knob(
|
||
&mut c[6],
|
||
state,
|
||
"Length",
|
||
&mut settings.length,
|
||
2. ..=50.,
|
||
d.length,
|
||
" %",
|
||
"Length of the grit burst, as a share of a cycle.",
|
||
);
|
||
knob(
|
||
&mut c[7],
|
||
state,
|
||
"Gate",
|
||
&mut settings.gate,
|
||
-60. ..=-20.,
|
||
d.gate,
|
||
" dB",
|
||
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
|
||
);
|
||
knob(
|
||
&mut c[8],
|
||
state,
|
||
"Mix",
|
||
&mut settings.mix,
|
||
0. ..=100.,
|
||
d.mix,
|
||
" %",
|
||
"Blend with the delayed dry signal.",
|
||
);
|
||
knob(
|
||
&mut c[9],
|
||
state,
|
||
"Output",
|
||
&mut settings.output,
|
||
-12. ..=12.,
|
||
d.output,
|
||
" dB",
|
||
"Output trim.",
|
||
);
|
||
});
|
||
}
|