feat(knurl): add Knurl cycle texture
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
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.
This commit is contained in:
1 parent
f4c0a833e6
commit
5cc3770737
27 files changed
+4699
-33
No files matched your search
@@ -0,0 +1,501 @@
|
||||
//! 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.",
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user