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 { 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 { 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 = 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::() / (out.len() - 4800) as f64; let signal: f64 = input .iter() .skip(4800) .map(|v| (*v as f64).powi(2)) .sum::() / (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 = 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::(&json).unwrap(), Settings::default() ); }