Files
bass-foundry/plugins/knurl/ui/src/lib.rs
T
nvrl 5cc3770737
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
feat(knurl): add Knurl cycle texture
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.
2026-11-06 08:49:11 +01:00

502 lines
15 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.
//! 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.",
);
});
}