Files
bass-foundry/plugins/crucible/core/tests/core.rs
T
nvrl 6d868a63c4 feat(crucible): keep every captured take
With Keep takes on (default), each capture is written to
<folder>/takes/ as a WAV named by time, tempo and length, so a take
is not lost when the next capture replaces it. Files are never
overwritten: a clash gets a numbered suffix. Saving runs off the UI
thread and the status line shows the path or the error.
2026-11-06 12:24:10 +01:00

274 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use common_dsp::{wav, Saw};
use crucible_core::*;
const RATE: f32 = 48000.;
fn playing(tempo: f64, beats: f64) -> Transport {
Transport {
playing: true,
tempo,
beats,
numerator: 4,
denominator: 4,
}
}
/// Records `frames` of a ramp (sample value = frame index) in blocks, with transport.
fn record(ring: &Ring, rec: &mut Recorder, from: u64, frames: u64, tempo: f64, beats0: f64) {
let block = 480u64;
let mut f = 0;
while f < frames {
let beats = beats0 + f as f64 / RATE as f64 * tempo / 60.;
rec.block(ring, Some(playing(tempo, beats)));
for i in 0..block.min(frames - f) {
let v = (from + f + i) as f32;
rec.push(ring, [v, -v]);
}
rec.publish(ring);
f += block;
}
}
#[test]
fn ring_copy_is_exact_across_the_wrap_and_detects_overwrites() {
let ring = Ring::new(RATE, 1.);
let mut rec = Recorder::new();
record(&ring, &mut rec, 0, 70000, 120., 0.);
let got = ring.copy(60000, 70000).unwrap();
for (k, f) in got.chunks(2).enumerate() {
assert_eq!(f[0], (60000 + k) as f32);
assert_eq!(f[1], -((60000 + k) as f32));
}
assert!(ring.copy(10000, 20000).is_err());
assert!(ring.copy(69000, 71000).is_err());
}
#[test]
fn capture_ends_on_the_last_bar_line() {
let ring = Ring::new(RATE, HISTORY);
let mut rec = Recorder::new();
// 120 BPM: one beat = 24000 frames, one 4/4 bar = 96000 frames. Stop mid bar 4.
record(&ring, &mut rec, 0, 96000 * 3 + 30000, 120., 0.);
let take = capture(&ring, 2).unwrap();
assert!(take.synced);
assert_eq!(take.frames(), 2 * 96000);
assert_eq!(take.audio[0], 96000.);
assert_eq!(take.audio[take.audio.len() - 2], (3 * 96000 - 1) as f32);
assert!(capture(&ring, 4).is_err(), "only three full bars exist");
}
#[test]
fn capture_follows_tempo_changes() {
let ring = Ring::new(RATE, HISTORY);
let mut rec = Recorder::new();
// 4 beats at 120 BPM (96000 frames), then 60 BPM (48000 frames per beat).
record(&ring, &mut rec, 0, 96000, 120., 0.);
record(&ring, &mut rec, 96000, 5 * 48000 + 1000, 60., 4.);
let take = capture(&ring, 2).unwrap();
// Bars 1–2 end at beat 8 = 96000 + 4 × 48000.
assert_eq!(take.frames(), 96000 + 4 * 48000);
assert_eq!(take.audio[0], 0.);
assert_eq!(take.segments.len(), 2);
assert!((take.frame_at(4.) - 96000.).abs() < 1.);
assert!((take.frame_at(6.) - (96000. + 2. * 48000.)).abs() < 1.);
}
#[test]
fn stopped_transport_captures_the_newest_audio() {
let ring = Ring::new(RATE, HISTORY);
let mut rec = Recorder::new();
for _ in 0..500 {
rec.block(
&ring,
Some(Transport {
playing: false,
..playing(150., 0.)
}),
);
for _ in 0..480 {
rec.push(&ring, [0.1, 0.1]);
}
rec.publish(&ring);
}
let take = capture(&ring, 1).unwrap();
assert!(!take.synced);
// One bar at 150 BPM = 1.6 s.
assert_eq!(take.frames(), 76800);
}
fn take_of(mono: Vec<f32>, tempo: f64, bars: u32) -> Take {
let audio = mono.iter().flat_map(|v| [*v, *v]).collect();
Take::from_audio(
RATE,
audio,
bars,
playing(tempo, 0.),
vec![Segment {
frame: 0.,
quarters: 0.,
tempo,
}],
true,
)
}
#[test]
fn grid_slices_straight_and_triplet() {
let mut take = take_of(vec![0.; 96000], 120., 1);
let mut s = Settings {
division: Division::Sixteenth,
..Settings::default()
};
take.slice(&s);
assert_eq!(take.slices.len(), 16);
assert_eq!(take.cuts[1], 6000);
s.division = Division::EighthTriplet;
take.slice(&s);
assert_eq!(take.slices.len(), 12);
assert_eq!(take.cuts[1], 8000);
}
#[test]
fn transients_find_hits_within_two_milliseconds() {
let mut x = vec![0.; 96000];
let hits = [4800usize, 20000, 31111, 60000, 80000];
for &h in &hits {
for i in 0..2000 {
x[h + i] = (i as f32 * 0.3).sin() * (-(i as f32) / 400.).exp() * 0.8;
}
}
let found = transients(&x, RATE, 50.);
assert_eq!(found.len(), hits.len(), "{found:?}");
for (f, h) in found.iter().zip(hits) {
assert!(f.abs_diff(h) < 96, "{f} vs {h}");
}
let mut take = take_of(x, 120., 1);
take.slice(&Settings {
mode: Mode::Transient,
..Settings::default()
});
assert_eq!(take.slices.len(), hits.len() + 1);
}
#[test]
fn manual_cuts_add_and_remove() {
let mut take = take_of(vec![0.; 48000], 120., 1);
assert!(take.add_cut(12000));
assert!(!take.add_cut(12010), "too close");
assert_eq!(take.slices.len(), 2);
take.slices[1].enabled = false;
assert!(take.add_cut(30000));
assert!(!take.slices[1].enabled, "choice survives a re-cut");
assert!(take.remove_cut(29000));
assert_eq!(take.cuts, vec![0, 12000, 48000]);
}
#[test]
fn render_trims_fades_and_normalises() {
let mut x = vec![0.; 4800];
x[1000..]
.iter_mut()
.enumerate()
.for_each(|(i, v)| *v = 0.25 * (i as f32 * 0.05).sin().signum());
let take = take_of(x, 120., 1);
let out = render(&take, &take.slices[0], &Settings::default());
let frames = out.len() / 2;
// Leading silence gone except a 1 ms pre-roll.
assert_eq!(frames, 3800 + 48);
assert_eq!(out[0], 0.);
assert_eq!(out[out.len() - 1], 0.);
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!((20. * peak.log10() + 0.3).abs() < 0.01);
}
#[test]
fn root_key_is_detected_per_slice() {
let mut saw = Saw::new(RATE, 43.65);
let take = take_of((0..48000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
assert_eq!(take.slices[0].root, Some(29));
let silent = take_of(vec![0.; 48000], 120., 1);
assert_eq!(silent.slices[0].root, None);
}
#[test]
fn wavetable_frames_start_on_rising_zero_crossings_and_match() {
let mut saw = Saw::new(RATE, 110.);
let x: Vec<f32> = (0..192000).map(|_| saw.sample() * 0.5).collect();
let table = wavetable(&x, RATE).unwrap();
let frames = table.len() / TABLE;
assert_eq!(frames, TABLE_FRAMES);
for f in table.chunks(TABLE) {
assert!(f[0].abs() < 0.02 && f[1] > f[0], "{} {}", f[0], f[1]);
let a = &table[..TABLE];
let dot: f32 = a.iter().zip(f).map(|(p, q)| p * q).sum();
let na: f32 = a.iter().map(|v| v * v).sum();
let nb: f32 = f.iter().map(|v| v * v).sum();
assert!(dot / (na * nb).sqrt() > 0.99);
}
assert!(wavetable(&vec![0.; 48000], RATE).is_none());
}
#[test]
fn export_writes_loop_slices_and_wavetable() {
let dir = std::env::temp_dir().join(format!("crucible-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut saw = Saw::new(RATE, 55.);
let mut take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
let settings = Settings {
division: Division::Quarter,
wavetable: true,
..Settings::default()
};
take.slice(&settings);
take.slices[3].enabled = false;
let done = export(&take, &settings, Some(0), &dir, 1_700_000_000).unwrap();
assert_eq!(done.files, 1 + 3 + 1);
assert!(done.folder.ends_with("2023-11-14_221320_120bpm"));
let read = |name: &str| wav::decode(&std::fs::read(done.folder.join(name)).unwrap()).unwrap();
assert_eq!(read("loop.wav").samples.len(), 192000);
assert_eq!(read("s01_A1.wav").root, Some(33));
assert_eq!(read("wavetable.wav").frame, Some(2048));
assert!(!done.folder.join("s04_A1.wav").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_take_writes_the_loop_and_never_overwrites() {
let dir = std::env::temp_dir().join(format!("crucible-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut saw = Saw::new(RATE, 55.);
let take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
let first = save_take(&take, &dir, 1_700_000_000).unwrap();
let second = save_take(&take, &dir, 1_700_000_000).unwrap();
assert!(first.ends_with("takes/2023-11-14_221320_120bpm_1bar.wav"));
assert!(second.ends_with("takes/2023-11-14_221320_120bpm_1bar_2.wav"));
let w = wav::decode(&std::fs::read(&first).unwrap()).unwrap();
assert_eq!(w.samples, take.audio);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn audition_plays_and_returns_clips_for_freeing() {
let q = Queues::default();
let mut p = Player::default();
q.play(std::sync::Arc::from(vec![0.5f32, -0.5, 0.25, -0.25]));
p.block(&q);
assert_eq!(p.next(&q), [0.5, -0.5]);
assert_eq!(p.next(&q), [0.25, -0.25]);
assert_eq!(p.next(&q), [0., 0.]);
assert!(!p.playing());
q.drain();
}
#[test]
fn settings_survive_json_and_are_sanitised() {
let mut s: Settings =
serde_json::from_str(r#"{"bars":3,"declick":99,"sensitivity":-5}"#).unwrap();
s.sanitize();
assert_eq!((s.bars, s.declick, s.sensitivity), (4, 20., 0.));
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}