From b3ab709a7e9fdf2b3db5916d6ba1001ca6abb162 Mon Sep 17 00:00:00 2001 From: Loic Coenen Date: Thu, 30 Apr 2026 22:31:31 +0000 Subject: [PATCH] test: add unit tests for clip state machine, recording, playback, and engine Co-authored-by: aider (deepseek/deepseek-coder) --- src/engine.rs | 426 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 426 insertions(+) diff --git a/src/engine.rs b/src/engine.rs index 72c1e01..85e224a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -193,3 +193,429 @@ impl Engine { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Helper to create a test engine + fn test_engine() -> Engine { + Engine::new(2, 44100.0) + } + + // Helper to create a test clip with some samples + fn test_clip_with_samples() -> Clip { + let mut clip = Clip::new(2, 44100.0); + clip.samples = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]; // 3 stereo frames + clip.state = ClipState::Looping; + clip + } + + // ===== ClipState tests ===== + + #[test] + fn test_clip_initial_state_is_empty() { + let clip = Clip::new(2, 44100.0); + assert_eq!(clip.state, ClipState::Empty); + } + + #[test] + fn test_clip_state_velocity_values() { + let mut clip = Clip::new(2, 44100.0); + + clip.state = ClipState::Empty; + assert_eq!(clip.state_velocity(), 0); + + clip.state = ClipState::Recording; + assert_eq!(clip.state_velocity(), 64); + + clip.state = ClipState::Looping; + assert_eq!(clip.state_velocity(), 127); + + clip.state = ClipState::Stopped; + assert_eq!(clip.state_velocity(), 32); + } + + // ===== Trigger state machine tests ===== + + #[test] + fn test_trigger_empty_starts_recording() { + let mut clip = Clip::new(2, 44100.0); + assert_eq!(clip.state, ClipState::Empty); + + let new_state = clip.trigger(); + assert_eq!(new_state, ClipState::Recording); + assert_eq!(clip.state, ClipState::Recording); + assert!(clip.samples.is_empty()); + assert_eq!(clip.write_position, 0); + assert_eq!(clip.read_position, 0); + } + + #[test] + fn test_trigger_recording_starts_looping() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Recording; + clip.samples = vec![0.1, 0.2, 0.3, 0.4]; + clip.write_position = 4; + + let new_state = clip.trigger(); + assert_eq!(new_state, ClipState::Looping); + assert_eq!(clip.state, ClipState::Looping); + assert_eq!(clip.read_position, 0); + // Samples should be preserved + assert_eq!(clip.samples.len(), 4); + } + + #[test] + fn test_trigger_looping_stops() { + let mut clip = test_clip_with_samples(); + assert_eq!(clip.state, ClipState::Looping); + + let new_state = clip.trigger(); + assert_eq!(new_state, ClipState::Stopped); + assert_eq!(clip.state, ClipState::Stopped); + // Samples should be preserved + assert!(!clip.samples.is_empty()); + } + + #[test] + fn test_trigger_stopped_starts_looping() { + let mut clip = test_clip_with_samples(); + clip.state = ClipState::Stopped; + + let new_state = clip.trigger(); + assert_eq!(new_state, ClipState::Looping); + assert_eq!(clip.state, ClipState::Looping); + assert_eq!(clip.read_position, 0); + } + + #[test] + fn test_trigger_cycle_empty_recording_looping_stopped_looping() { + let mut clip = Clip::new(2, 44100.0); + + // Empty -> Recording + assert_eq!(clip.trigger(), ClipState::Recording); + + // Recording -> Looping + assert_eq!(clip.trigger(), ClipState::Looping); + + // Looping -> Stopped + assert_eq!(clip.trigger(), ClipState::Stopped); + + // Stopped -> Looping + assert_eq!(clip.trigger(), ClipState::Looping); + + // Looping -> Stopped again + assert_eq!(clip.trigger(), ClipState::Stopped); + } + + // ===== Recording tests ===== + + #[test] + fn test_record_when_not_recording_does_nothing() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Empty; + + let input = vec![0.5, 0.6, 0.7, 0.8]; + clip.record(&input, 2); + + assert!(clip.samples.is_empty()); + assert_eq!(clip.write_position, 0); + } + + #[test] + fn test_record_appends_samples() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Recording; + + let input = vec![0.1, 0.2, 0.3, 0.4]; + clip.record(&input, 2); + + assert_eq!(clip.samples, vec![0.1, 0.2, 0.3, 0.4]); + assert_eq!(clip.write_position, 4); + } + + #[test] + fn test_record_multiple_blocks() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Recording; + + let input1 = vec![0.1, 0.2, 0.3, 0.4]; + clip.record(&input1, 2); + + let input2 = vec![0.5, 0.6, 0.7, 0.8]; + clip.record(&input2, 2); + + assert_eq!(clip.samples, vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]); + assert_eq!(clip.write_position, 8); + } + + #[test] + fn test_record_clears_on_new_recording() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Recording; + + // Record some samples + let input1 = vec![0.1, 0.2, 0.3, 0.4]; + clip.record(&input1, 2); + + // Trigger to stop recording + clip.trigger(); // Now Looping + + // Trigger again to start new recording + clip.trigger(); // Now Stopped + clip.trigger(); // Now Looping + clip.trigger(); // Now Stopped + + // Start new recording + clip.state = ClipState::Recording; + clip.samples.clear(); + clip.write_position = 0; + + let input2 = vec![0.9, 0.8, 0.7, 0.6]; + clip.record(&input2, 2); + + assert_eq!(clip.samples, vec![0.9, 0.8, 0.7, 0.6]); + assert_eq!(clip.write_position, 4); + } + + // ===== Playback tests ===== + + #[test] + fn test_play_when_not_looping_returns_zero() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Stopped; + clip.samples = vec![0.1, 0.2, 0.3, 0.4]; + + let mut output = vec![0.0; 4]; + let samples_read = clip.play(&mut output, 2); + + assert_eq!(samples_read, 0); + assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn test_play_empty_clip_returns_zero() { + let mut clip = Clip::new(2, 44100.0); + clip.state = ClipState::Looping; + + let mut output = vec![0.0; 4]; + let samples_read = clip.play(&mut output, 2); + + assert_eq!(samples_read, 0); + } + + #[test] + fn test_play_outputs_samples() { + let mut clip = test_clip_with_samples(); + + let mut output = vec![0.0; 6]; // 3 stereo frames + let samples_read = clip.play(&mut output, 3); + + assert_eq!(samples_read, 6); + assert_eq!(output, vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); + assert_eq!(clip.read_position, 3); + } + + #[test] + fn test_play_loops_when_reaching_end() { + let mut clip = test_clip_with_samples(); // 3 stereo frames + + // Read all 3 frames + let mut output1 = vec![0.0; 6]; + clip.play(&mut output1, 3); + assert_eq!(clip.read_position, 3); // At end + + // Read more - should loop back + let mut output2 = vec![0.0; 4]; // 2 stereo frames + let samples_read = clip.play(&mut output2, 2); + + assert_eq!(samples_read, 4); + assert_eq!(output2, vec![0.1, 0.2, 0.3, 0.4]); + assert_eq!(clip.read_position, 2); + } + + #[test] + fn test_play_multiple_loops() { + let mut clip = test_clip_with_samples(); // 3 stereo frames + + // Read 6 frames (2 full loops) + let mut output = vec![0.0; 12]; + let samples_read = clip.play(&mut output, 6); + + assert_eq!(samples_read, 12); + // First loop + assert_eq!(output[0..6], vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); + // Second loop + assert_eq!(output[6..12], vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]); + assert_eq!(clip.read_position, 0); // Back to start + } + + // ===== Engine tests ===== + + #[test] + fn test_engine_initial_state() { + let engine = test_engine(); + assert!(engine.clips.is_empty()); + assert_eq!(engine.num_channels, 2); + assert_eq!(engine.sample_rate, 44100.0); + } + + #[test] + fn test_engine_get_clip_creates_new_clip() { + let mut engine = test_engine(); + + let clip = engine.get_clip(60); + assert_eq!(clip.state, ClipState::Empty); + assert_eq!(clip.num_channels, 2); + assert_eq!(clip.sample_rate, 44100.0); + assert_eq!(engine.clips.len(), 1); + } + + #[test] + fn test_engine_get_clip_reuses_existing() { + let mut engine = test_engine(); + + let clip1 = engine.get_clip(60); + let clip2 = engine.get_clip(60); + + // Both should point to the same clip + assert_eq!(clip1 as *const _, clip2 as *const _); + assert_eq!(engine.clips.len(), 1); + } + + #[test] + fn test_engine_get_clip_different_notes() { + let mut engine = test_engine(); + + engine.get_clip(60); + engine.get_clip(61); + engine.get_clip(62); + + assert_eq!(engine.clips.len(), 3); + } + + #[test] + fn test_engine_process_midi_note_on_triggers_clip() { + let mut engine = test_engine(); + + // First note on should start recording + let velocity = engine.process_midi_note(60, 100, true); + assert_eq!(velocity, 0); // Empty state velocity + + let clip = engine.get_clip(60); + assert_eq!(clip.state, ClipState::Recording); + } + + #[test] + fn test_engine_process_midi_note_off_passes_through() { + let mut engine = test_engine(); + + let velocity = engine.process_midi_note(60, 100, false); + assert_eq!(velocity, 100); // Original velocity passed through + } + + #[test] + fn test_engine_process_midi_note_cycle() { + let mut engine = test_engine(); + + // Note on 1: Empty -> Recording (velocity 0) + let v1 = engine.process_midi_note(60, 100, true); + assert_eq!(v1, 0); + assert_eq!(engine.get_clip(60).state, ClipState::Recording); + + // Note on 2: Recording -> Looping (velocity 127) + let v2 = engine.process_midi_note(60, 100, true); + assert_eq!(v2, 127); + assert_eq!(engine.get_clip(60).state, ClipState::Looping); + + // Note on 3: Looping -> Stopped (velocity 32) + let v3 = engine.process_midi_note(60, 100, true); + assert_eq!(v3, 32); + assert_eq!(engine.get_clip(60).state, ClipState::Stopped); + + // Note on 4: Stopped -> Looping (velocity 127) + let v4 = engine.process_midi_note(60, 100, true); + assert_eq!(v4, 127); + assert_eq!(engine.get_clip(60).state, ClipState::Looping); + } + + #[test] + fn test_engine_process_audio_records_and_plays() { + let mut engine = test_engine(); + + // Start recording on note 60 + engine.process_midi_note(60, 100, true); + + // Process audio - should record input + let input = vec![0.1, 0.2, 0.3, 0.4]; + let mut output = vec![0.0; 4]; + engine.process_audio(&input, &mut output, 2); + + // Output should be silence (nothing playing yet) + assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]); + + // Clip should have recorded the input + let clip = engine.get_clip(60); + assert_eq!(clip.samples, vec![0.1, 0.2, 0.3, 0.4]); + + // Stop recording and start looping + engine.process_midi_note(60, 100, true); + + // Process audio - should play back the recorded clip + let input2 = vec![0.0; 4]; + let mut output2 = vec![0.0; 4]; + engine.process_audio(&input2, &mut output2, 2); + + assert_eq!(output2, vec![0.1, 0.2, 0.3, 0.4]); + } + + #[test] + fn test_engine_multiple_clips_independent() { + let mut engine = test_engine(); + + // Trigger note 60 to record + engine.process_midi_note(60, 100, true); + assert_eq!(engine.get_clip(60).state, ClipState::Recording); + + // Trigger note 61 to record + engine.process_midi_note(61, 100, true); + assert_eq!(engine.get_clip(61).state, ClipState::Recording); + + // Trigger note 60 again to loop + engine.process_midi_note(60, 100, true); + assert_eq!(engine.get_clip(60).state, ClipState::Looping); + + // Note 61 should still be recording + assert_eq!(engine.get_clip(61).state, ClipState::Recording); + } + + #[test] + fn test_engine_process_audio_multiple_looping_clips() { + let mut engine = test_engine(); + + // Create and record clip 60 + engine.process_midi_note(60, 100, true); + let input1 = vec![0.1, 0.2, 0.3, 0.4]; + engine.process_audio(&input1, &mut vec![0.0; 4], 2); + engine.process_midi_note(60, 100, true); // Now looping + + // Create and record clip 61 + engine.process_midi_note(61, 100, true); + let input2 = vec![0.5, 0.6, 0.7, 0.8]; + engine.process_audio(&input2, &mut vec![0.0; 4], 2); + engine.process_midi_note(61, 100, true); // Now looping + + // Both should be looping - output should be sum of both clips + let mut output = vec![0.0; 4]; + engine.process_audio(&vec![0.0; 4], &mut output, 2); + + // Clip 60 plays [0.1, 0.2, 0.3, 0.4] + // Clip 61 plays [0.5, 0.6, 0.7, 0.8] + // Output is sum: [0.6, 0.8, 1.0, 1.2] + assert!((output[0] - 0.6).abs() < 1e-6); + assert!((output[1] - 0.8).abs() < 1e-6); + assert!((output[2] - 1.0).abs() < 1e-6); + assert!((output[3] - 1.2).abs() < 1e-6); + } +}