Files
bass-foundry/plugins/forge/core/tests/dsp.rs
T
nvrl 7b989f7685 feat(forge): add Forge tracked channel strip
One note tracker reads the input and Lathe, Temper and Knurl follow it as
modules, in any of six orders, each on or off. Every module gets the
tracker's events delayed by the latency of the modules in front of it.

- One module alone matches its standalone plugin sample for sample in
  every order.
- With Knurl's sub-octave pattern in front, Lathe in Forge follows the
  played note; a standalone Lathe after Knurl reads it an octave low.
- About 72 ms latency against about 206 ms for the three in a row.

The module pages reuse the standalone editors. Register Forge in the
catalog, CI, README, docs and website, and drop the built Ballast from
the site's idea list.
2026-11-06 16:30:23 +01:00

220 lines
7.3 KiB
Rust

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::<Settings>(&json).unwrap(), s);
assert!(serde_json::from_str::<Settings>(r#"{"gate":-40,"oops":1}"#).is_err());
}