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.
233 lines
7.3 KiB
Rust
233 lines
7.3 KiB
Rust
use common_dsp::{midi_to_hz, Saw};
|
||
use knurl_core::{Engine, Seed, Settings, Source, GRIT_OFF, PATTERNS};
|
||
|
||
const RATE: f32 = 48000.;
|
||
|
||
/// A tanh saw bass at `note`, as the prototype used.
|
||
fn bass(note: u8, n: usize) -> Vec<f32> {
|
||
let mut saw = Saw::new(RATE, midi_to_hz(note as f32));
|
||
(0..n).map(|_| (2.2 * saw.sample()).tanh() * 0.5).collect()
|
||
}
|
||
fn plain() -> Settings {
|
||
let mut s = Settings {
|
||
source: Source::Midi,
|
||
level_jitter: 0.,
|
||
time_jitter: 0.,
|
||
grit: GRIT_OFF,
|
||
..Settings::default()
|
||
};
|
||
s.apply_pattern(PATTERNS[0].2);
|
||
s
|
||
}
|
||
/// Runs one held note from sample 0 and returns the output, aligned to the input.
|
||
fn run(settings: Settings, note: u8, input: &[f32]) -> Vec<f32> {
|
||
let mut engine = Engine::new(RATE, 1).unwrap();
|
||
engine.set_settings(settings).unwrap();
|
||
let latency = engine.latency_samples() as usize;
|
||
engine.note_on(note);
|
||
let mut out: Vec<f32> = input
|
||
.iter()
|
||
.chain(std::iter::repeat_n(&0., latency))
|
||
.map(|&x| engine.process_sample([x, 0.])[0])
|
||
.collect();
|
||
out.drain(..latency);
|
||
out
|
||
}
|
||
/// Level of the component at `hz` (single-bin DFT with a Hann window), dB.
|
||
fn level(x: &[f32], hz: f32) -> f64 {
|
||
let n = x.len();
|
||
let (mut re, mut im) = (0f64, 0f64);
|
||
for (i, &v) in x.iter().enumerate() {
|
||
let w = 0.5 - 0.5 * (std::f64::consts::TAU * i as f64 / n as f64).cos();
|
||
let a = std::f64::consts::TAU * hz as f64 * i as f64 / RATE as f64;
|
||
re += w * v as f64 * a.cos();
|
||
im -= w * v as f64 * a.sin();
|
||
}
|
||
10. * (re * re + im * im).max(1e-30).log10()
|
||
}
|
||
fn subharmonic(out: &[f32], note: u8, divisor: f32) -> f64 {
|
||
let f = midi_to_hz(note as f32);
|
||
let seg = &out[4800..out.len() - 4800];
|
||
level(seg, f / divisor) - level(seg, f)
|
||
}
|
||
fn max_step(x: &[f32]) -> f32 {
|
||
x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0., f32::max)
|
||
}
|
||
|
||
#[test]
|
||
fn everything_off_is_the_delayed_input() {
|
||
let input = bass(29, 24000);
|
||
let out = run(plain(), 29, &input);
|
||
assert_eq!(out, input);
|
||
}
|
||
|
||
#[test]
|
||
fn patterns_make_tuned_subharmonics_without_clicks() {
|
||
let input = bass(29, 48000);
|
||
let base = run(plain(), 29, &input);
|
||
assert!(subharmonic(&base, 29, 2.) < -50.);
|
||
for (pattern, divisor, expected) in [(1, 2., -8.2), (2, 3., -10.7)] {
|
||
let mut s = plain();
|
||
s.apply_pattern(PATTERNS[pattern].2);
|
||
let out = run(s, 29, &input);
|
||
let sub = subharmonic(&out, 29, divisor);
|
||
assert!(
|
||
(sub - expected).abs() < 0.5,
|
||
"{}: {sub}",
|
||
PATTERNS[pattern].0
|
||
);
|
||
assert!(
|
||
max_step(&out) <= 1.05 * max_step(&input),
|
||
"{}",
|
||
PATTERNS[pattern].0
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_texture_is_the_same_on_every_note() {
|
||
let mut s = plain();
|
||
s.apply_pattern(PATTERNS[1].2);
|
||
let low = run(s, 29, &bass(29, 48000));
|
||
let high = run(s, 36, &bass(36, 48000));
|
||
let (a, b) = (subharmonic(&low, 29, 2.), subharmonic(&high, 36, 2.));
|
||
assert!((a - b).abs() < 0.3, "F1 {a} C2 {b}");
|
||
}
|
||
|
||
#[test]
|
||
fn same_seed_repeats_every_note_and_free_does_not() {
|
||
for (seed, identical) in [(Seed::Same, true), (Seed::Free, false)] {
|
||
let s = Settings {
|
||
source: Source::Midi,
|
||
seed,
|
||
..Settings::default()
|
||
};
|
||
let mut engine = Engine::new(RATE, 2).unwrap();
|
||
engine.set_settings(s).unwrap();
|
||
let latency = engine.latency_samples() as usize;
|
||
let note = bass(29, 12000);
|
||
let mut out = Vec::new();
|
||
let starts = [0, 36000];
|
||
for i in 0..72000 + latency {
|
||
if starts.contains(&i) {
|
||
engine.note_on(29);
|
||
}
|
||
if i == starts[0] + 12000 || i == starts[1] + 12000 {
|
||
engine.note_off(29);
|
||
}
|
||
let k = starts
|
||
.iter()
|
||
.filter(|&&s| i >= s)
|
||
.map(|&s| i - s)
|
||
.min()
|
||
.unwrap();
|
||
let x = note.get(k).copied().unwrap_or(0.);
|
||
out.push(engine.process_sample([x, x])[0]);
|
||
}
|
||
let first = &out[latency..latency + 12000];
|
||
let second = &out[starts[1] + latency..starts[1] + latency + 12000];
|
||
let largest = first
|
||
.iter()
|
||
.zip(second)
|
||
.map(|(a, b)| (a - b).abs())
|
||
.fold(0f32, f32::max);
|
||
if identical {
|
||
assert!(largest < 1e-4, "{seed:?}: {largest}");
|
||
} else {
|
||
assert!(largest > 1e-2, "{seed:?}: {largest}");
|
||
}
|
||
assert!(out.iter().all(|v| v.is_finite()));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn grit_follows_the_input_level_and_stops_with_the_note() {
|
||
let mut s = plain();
|
||
s.grit = -6.;
|
||
let input = bass(29, 24000);
|
||
let out = run(s, 29, &input);
|
||
let noise: f64 = out
|
||
.iter()
|
||
.zip(&input)
|
||
.skip(4800)
|
||
.map(|(o, i)| ((o - i) as f64).powi(2))
|
||
.sum::<f64>()
|
||
/ (out.len() - 4800) as f64;
|
||
let signal: f64 = input
|
||
.iter()
|
||
.skip(4800)
|
||
.map(|v| (*v as f64).powi(2))
|
||
.sum::<f64>()
|
||
/ (out.len() - 4800) as f64;
|
||
let ratio = 10. * (noise / signal).log10();
|
||
// −6 dB bursts over 12 % of each cycle; the sin² window keeps 3/8 of the power:
|
||
// −6 + 10·log10(0.12 · 0.375) = −19.5 dB.
|
||
assert!((ratio + 19.5).abs() < 1.5, "{ratio}");
|
||
|
||
// Silence in, silence out: no grit without a note, and no NaN.
|
||
let mut engine = Engine::new(RATE, 2).unwrap();
|
||
engine.set_settings(Settings::default()).unwrap();
|
||
for _ in 0..48000 {
|
||
let out = engine.process_sample([0., 0.]);
|
||
assert_eq!(out, [0., 0.]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn audio_source_finds_the_note_and_reports_latency() {
|
||
let mut s = plain();
|
||
s.source = Source::Audio;
|
||
s.apply_pattern(PATTERNS[1].2);
|
||
let mut engine = Engine::new(RATE, 1).unwrap();
|
||
engine.set_settings(s).unwrap();
|
||
let latency = engine.latency_samples() as usize;
|
||
// About 67 ms of lookahead plus 4.2 ms of jitter headroom.
|
||
assert!((latency as f32 / RATE - 0.071).abs() < 0.005, "{latency}");
|
||
let input = bass(29, 48000);
|
||
let mut out: Vec<f32> = input
|
||
.iter()
|
||
.map(|&x| engine.process_sample([x, 0.])[0])
|
||
.collect();
|
||
out.drain(..latency);
|
||
let frame = engine.take_analyzer();
|
||
assert_eq!(frame.note, Some(29));
|
||
assert!(frame.scope_valid);
|
||
let sub = subharmonic(&out[..out.len() - 4800], 29, 2.);
|
||
assert!(sub > -14., "{sub}");
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_settings_are_rejected() {
|
||
let mut engine = Engine::new(RATE, 2).unwrap();
|
||
for bad in [
|
||
Settings {
|
||
steps: 0,
|
||
..Settings::default()
|
||
},
|
||
Settings {
|
||
edge: 60.,
|
||
..Settings::default()
|
||
},
|
||
Settings {
|
||
grit: f32::NAN,
|
||
..Settings::default()
|
||
},
|
||
Settings {
|
||
time_jitter: 11.,
|
||
..Settings::default()
|
||
},
|
||
] {
|
||
assert!(engine.set_settings(bad).is_err());
|
||
}
|
||
let mut s = Settings::default();
|
||
s.pattern[3] = -30.;
|
||
assert!(engine.set_settings(s).is_err());
|
||
assert!(Engine::new(1000., 2).is_err());
|
||
assert!(Engine::new(48000., 3).is_err());
|
||
let json = serde_json::to_string(&Settings::default()).unwrap();
|
||
assert_eq!(
|
||
serde_json::from_str::<Settings>(&json).unwrap(),
|
||
Settings::default()
|
||
);
|
||
}
|