use forge_core::{Engine, Settings, Source, Stage, ORDERS}; const RATE: f32 = 48000.; /// A bassline of saw notes with gaps, a different pitch each, at A1 to A2. fn bass(i: usize) -> [f32; 2] { let note = [33., 40., 36., 45., 38.][(i / 14400) % 5]; let hz = 440. * 2f64.powf((note - 69.) / 12.); let t = i as f64 / RATE as f64; let on = i % 14400 < 12000; let v = if on { (0.4 * (2. * (hz * t).fract() - 1.)) as f32 } else { 0. }; [v, 0.8 * v] } /// Module settings that change the sound, so a mismatch would show. fn shaped() -> Settings { let mut s = Settings::default(); s.lathe.gains[2] = -12.; s.lathe.tilt = -3.; s.temper.drive = 12.; s.temper.bias = 20.; s.temper.push_gain = 6.; s.temper.bright = 8.; s.knurl.apply_pattern(&[0., -9.]); s.knurl.time_jitter = 6.; s.knurl.level_jitter = 2.; s.knurl.grit = -18.; s } fn only(stage: Stage, order: [Stage; 3]) -> Settings { let mut s = shaped(); s.order = order; for st in Stage::ALL { s.set_on(st, st == stage); } s } /// One module on its own in the strip sounds exactly like its standalone plugin, in every /// position: the strip's tracker and note timing are the plugin's. #[test] fn each_module_alone_matches_its_standalone_plugin() { let n = 80000; for order in ORDERS { for stage in Stage::ALL { let s = only(stage, order); let mut forge = Engine::new(RATE, 2).unwrap(); forge.set_settings(s).unwrap(); let (alone, latency): (Vec<[f32; 2]>, u32) = match stage { Stage::Lathe => { let mut e = lathe_core::Engine::new(RATE, 2).unwrap(); e.set_settings(s.lathe).unwrap(); ( (0..n).map(|i| e.process_sample(bass(i))).collect(), e.latency_samples(), ) } Stage::Temper => { let mut e = temper_core::Engine::new(RATE, 2).unwrap(); e.set_settings(s.temper).unwrap(); ( (0..n).map(|i| e.process_sample(bass(i))).collect(), e.latency_samples(), ) } Stage::Knurl => { let mut e = knurl_core::Engine::new(RATE, 2).unwrap(); e.set_settings(s.knurl).unwrap(); ( (0..n).map(|i| e.process_sample(bass(i))).collect(), e.latency_samples(), ) } }; let strip: Vec<[f32; 2]> = (0..n).map(|i| forge.process_sample(bass(i))).collect(); let shift = (forge.latency_samples() - latency) as usize; for i in 0..n - shift { assert_eq!( strip[i + shift], alone[i], "{stage:?} in {order:?}, sample {i}" ); } } } } #[test] fn the_strip_has_one_lookahead() { let forge = Engine::new(RATE, 2).unwrap(); let lathe = lathe_core::Engine::new(RATE, 2).unwrap().latency_samples(); let temper = temper_core::Engine::new(RATE, 2).unwrap().latency_samples(); let knurl = knurl_core::Engine::new(RATE, 2).unwrap().latency_samples(); // Lathe has no latency of its own: its latency is the lookahead. let own = (temper - lathe) + (knurl - lathe); assert_eq!(forge.latency_samples(), lathe + own); let ms = |s: u32| s as f32 / RATE * 1000.; assert!( ms(forge.latency_samples()) < 75., "{}", ms(forge.latency_samples()) ); assert!(ms(lathe + temper + knurl) > 200.); // Changing the order keeps the latency. let mut f = Engine::new(RATE, 2).unwrap(); let before = f.latency_samples(); for order in ORDERS { f.set_settings(Settings { order, ..Settings::default() }) .unwrap(); assert_eq!(f.latency_samples(), before); } } /// Knurl's sub-octave pattern first, then Lathe: Lathe still follows the played note, because /// the tracker reads the strip's input. A standalone Lathe after Knurl hears the sub-octave. #[test] fn modules_after_knurl_follow_the_played_note() { let mut s = Settings { order: [Stage::Knurl, Stage::Lathe, Stage::Temper], ..Settings::default() }; s.knurl.apply_pattern(&[0., -12.]); s.temper.bypass = true; let mut forge = Engine::new(RATE, 2).unwrap(); forge.set_settings(s).unwrap(); let mut knurl = knurl_core::Engine::new(RATE, 2).unwrap(); knurl.set_settings(s.knurl).unwrap(); let mut lathe = lathe_core::Engine::new(RATE, 2).unwrap(); lathe.set_settings(s.lathe).unwrap(); // One long note at A2 (110 Hz). let hz = 110.; let note = |i: usize| { let v = (0.4 * (2. * (hz * i as f64 / RATE as f64).fract() - 1.)) as f32; [v, v] }; let (mut strip_ok, mut chain_ok, mut frames) = (0, 0, 0); for i in 0..96000 { forge.process_sample(note(i)); lathe.process_sample(knurl.process_sample(note(i))); if i > 24000 && i % 480 == 0 { frames += 1; let near = |f: f32| (f / hz as f32 - 1.).abs() < 0.02; strip_ok += usize::from(near(forge.take_analyzer().lathe.hz)); chain_ok += usize::from(near(lathe.take_analyzer().hz)); } } assert_eq!(strip_ok, frames); assert!(chain_ok < frames / 2, "{chain_ok} of {frames}"); } #[test] fn bypass_is_the_input_delayed_by_the_latency() { let mut e = Engine::new(RATE, 2).unwrap(); e.set_settings(Settings { bypass: true, ..shaped() }) .unwrap(); let latency = e.latency_samples() as usize; let out: Vec<[f32; 2]> = (0..30000).map(|i| e.process_sample(bass(i))).collect(); for (i, o) in out.iter().enumerate().skip(latency) { assert_eq!(*o, bass(i - latency)); } } #[test] fn midi_notes_drive_every_module() { let mut s = shaped(); s.source = Source::Midi; let mut e = Engine::new(RATE, 1).unwrap(); e.set_settings(s).unwrap(); for i in 0..24000 { if i == 100 { e.note_on(45); } e.process_sample(bass(i)); } let f = e.take_analyzer(); assert_eq!(f.note, Some(45)); assert_eq!(f.lathe.note, Some(45)); assert_eq!(f.temper.note, Some(45)); assert_eq!(f.knurl.note, Some(45)); assert_eq!(f.lathe.latency, e.latency_samples()); } #[test] fn settings_are_validated_and_serialise() { assert!(Settings::default().validate().is_ok()); let bad = Settings { order: [Stage::Lathe, Stage::Lathe, Stage::Knurl], ..Settings::default() }; assert!(bad.validate().is_err()); assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err()); let bad = Settings { gate: -10., ..Settings::default() }; assert!(bad.validate().is_err()); let mut bad = Settings::default(); bad.temper.drive = f32::NAN; assert!(bad.validate().is_err()); let s = Settings { order: ORDERS[4], source: Source::Midi, ..shaped() }; let json = serde_json::to_string(&s).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), s); assert!(serde_json::from_str::(r#"{"gate":-40,"oops":1}"#).is_err()); }