A slice gets a note name only when at least 60 % of up to twelve reads across its body sit on one note; growls, pitch drops and noise get no name rather than a wrong one. An optional song key spells names with flats in flat keys (Eb2, not D#2).
313 lines
10 KiB
Rust
313 lines
10 KiB
Rust
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 a_slice_whose_pitch_moves_gets_no_note_name() {
|
||
// Half the slice on F1, half on B1: no one note holds over most of it.
|
||
let mut a = Saw::new(RATE, 43.65);
|
||
let mut b = Saw::new(RATE, 61.74);
|
||
let x: Vec<f32> = (0..48000)
|
||
.map(|i| 0.5 * if i < 26000 { a.sample() } else { b.sample() })
|
||
.collect();
|
||
let take = take_of(x, 120., 1);
|
||
assert_eq!(take.slices[0].root, None);
|
||
}
|
||
|
||
#[test]
|
||
fn song_key_spells_slice_names() {
|
||
let mut s = Settings::default();
|
||
assert_eq!(s.note_name(39), "D#2");
|
||
s.key = Some(SongKey {
|
||
root: 5,
|
||
minor: true,
|
||
});
|
||
assert_eq!(s.note_name(39), "Eb2");
|
||
assert_eq!(s.key.unwrap().name(), "F minor");
|
||
s.key = Some(SongKey {
|
||
root: 4,
|
||
minor: false,
|
||
});
|
||
assert_eq!(s.note_name(39), "D#2");
|
||
assert!(SongKey {
|
||
root: 10,
|
||
minor: false
|
||
}
|
||
.flats());
|
||
assert!(!SongKey {
|
||
root: 9,
|
||
minor: true
|
||
}
|
||
.flats());
|
||
}
|
||
|
||
#[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()
|
||
);
|
||
}
|