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, 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 = (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::(&json).unwrap(), Settings::default() ); }