Building and running
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.aider*
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "nih-plug-engine"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git" }
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
@@ -1,28 +0,0 @@
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2 -g `pkg-config --cflags jack`
|
||||
LDFLAGS = `pkg-config --libs jack`
|
||||
TARGET = jack-looper
|
||||
SRCS = main.c engine.c
|
||||
OBJS = $(SRCS:.c=.o)
|
||||
TEST_SRCS = test_engine.c
|
||||
TEST_OBJS = $(TEST_SRCS:.c=.o)
|
||||
TEST_TARGET = test_engine
|
||||
|
||||
.PHONY: all clean test
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(OBJS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
%.o: %.c
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
|
||||
test: $(TEST_TARGET)
|
||||
./$(TEST_TARGET)
|
||||
|
||||
$(TEST_TARGET): $(TEST_OBJS) engine.o
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
clean:
|
||||
rm -f $(OBJS) $(TEST_OBJS) $(TARGET) $(TEST_TARGET)
|
||||
25
Makefile
25
Makefile
@@ -1,25 +0,0 @@
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -g
|
||||
LDFLAGS = -ljack -lm
|
||||
|
||||
TARGET = jack-looper
|
||||
TEST_TARGET = test_engine
|
||||
|
||||
SRCS = engine.c main.c
|
||||
TEST_SRCS = test_engine.c engine.c
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRCS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test: $(TEST_TARGET)
|
||||
./$(TEST_TARGET)
|
||||
|
||||
$(TEST_TARGET): $(TEST_SRCS)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET) $(TEST_TARGET)
|
||||
|
||||
.PHONY: all test clean
|
||||
BIN
jack-looper
Executable file
BIN
jack-looper
Executable file
Binary file not shown.
623
src/engine.rs
623
src/engine.rs
@@ -1,623 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// State of a clip
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClipState {
|
||||
/// Empty, waiting for trigger to start recording
|
||||
Empty,
|
||||
/// Currently recording audio input
|
||||
Recording,
|
||||
/// Looping playback
|
||||
Looping,
|
||||
/// Stopped (has recorded content, not playing)
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// A clip that can be recorded or played back
|
||||
pub struct Clip {
|
||||
/// Audio samples (interleaved stereo)
|
||||
pub samples: Vec<f32>,
|
||||
/// Number of channels (1 for mono, 2 for stereo)
|
||||
pub num_channels: usize,
|
||||
/// Sample rate
|
||||
pub sample_rate: f32,
|
||||
/// Current state
|
||||
pub state: ClipState,
|
||||
/// Current write position during recording
|
||||
pub write_position: usize,
|
||||
/// Current read position during playback
|
||||
pub read_position: usize,
|
||||
}
|
||||
|
||||
impl Clip {
|
||||
pub fn new(num_channels: usize, sample_rate: f32) -> Self {
|
||||
Self {
|
||||
samples: Vec::new(),
|
||||
num_channels,
|
||||
sample_rate,
|
||||
state: ClipState::Empty,
|
||||
write_position: 0,
|
||||
read_position: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a block of audio into this clip
|
||||
pub fn record(&mut self, input: &[f32], num_samples: usize) {
|
||||
if self.state != ClipState::Recording {
|
||||
return;
|
||||
}
|
||||
// Extend the clip buffer if needed
|
||||
let needed = self.write_position + num_samples * self.num_channels;
|
||||
if self.samples.len() < needed {
|
||||
self.samples.resize(needed, 0.0);
|
||||
}
|
||||
// Copy input samples
|
||||
for (i, sample) in input.iter().enumerate().take(num_samples * self.num_channels) {
|
||||
if self.write_position + i < self.samples.len() {
|
||||
self.samples[self.write_position + i] = *sample;
|
||||
}
|
||||
}
|
||||
self.write_position += num_samples * self.num_channels;
|
||||
}
|
||||
|
||||
/// Play back a block of audio from this clip at the given position
|
||||
/// Returns the number of samples actually read (may be less than requested at end)
|
||||
pub fn play(&mut self, output: &mut [f32], num_samples: usize) -> usize {
|
||||
if self.state != ClipState::Looping || self.samples.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let channels = self.num_channels;
|
||||
let total_frames = self.samples.len() / channels;
|
||||
let mut samples_read = 0;
|
||||
|
||||
for frame in 0..num_samples {
|
||||
if self.read_position >= total_frames {
|
||||
// Loop back to start
|
||||
self.read_position = 0;
|
||||
}
|
||||
|
||||
let frame_start = self.read_position * channels;
|
||||
for ch in 0..channels {
|
||||
if frame_start + ch < self.samples.len() {
|
||||
output[frame * channels + ch] = self.samples[frame_start + ch];
|
||||
}
|
||||
}
|
||||
|
||||
self.read_position += 1;
|
||||
samples_read += 1;
|
||||
}
|
||||
|
||||
samples_read * channels
|
||||
}
|
||||
|
||||
/// Handle a trigger event for this clip
|
||||
/// Returns the new state
|
||||
pub fn trigger(&mut self) -> ClipState {
|
||||
match self.state {
|
||||
ClipState::Empty => {
|
||||
// Start recording
|
||||
self.state = ClipState::Recording;
|
||||
self.samples.clear();
|
||||
self.write_position = 0;
|
||||
self.read_position = 0;
|
||||
}
|
||||
ClipState::Recording => {
|
||||
// Stop recording, start looping
|
||||
self.state = ClipState::Looping;
|
||||
self.read_position = 0;
|
||||
}
|
||||
ClipState::Looping => {
|
||||
// Stop
|
||||
self.state = ClipState::Stopped;
|
||||
}
|
||||
ClipState::Stopped => {
|
||||
// Start looping again
|
||||
self.state = ClipState::Looping;
|
||||
self.read_position = 0;
|
||||
}
|
||||
}
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Get the velocity value representing the current state (0-127)
|
||||
pub fn state_velocity(&self) -> u8 {
|
||||
match self.state {
|
||||
ClipState::Empty => 0,
|
||||
ClipState::Recording => 64,
|
||||
ClipState::Looping => 127,
|
||||
ClipState::Stopped => 32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine that manages clips and audio/MIDI routing
|
||||
pub struct Engine {
|
||||
/// Clips indexed by note number (0-127)
|
||||
pub clips: HashMap<u8, Clip>,
|
||||
/// Number of audio channels
|
||||
pub num_channels: usize,
|
||||
/// Sample rate
|
||||
pub sample_rate: f32,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(num_channels: usize, sample_rate: f32) -> Self {
|
||||
Self {
|
||||
clips: HashMap::new(),
|
||||
num_channels,
|
||||
sample_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a clip for the given note
|
||||
pub fn get_clip(&mut self, note: u8) -> &mut Clip {
|
||||
self.clips.entry(note).or_insert_with(|| {
|
||||
Clip::new(self.num_channels, self.sample_rate)
|
||||
})
|
||||
}
|
||||
|
||||
/// Process a MIDI note on/off event
|
||||
/// Returns the output velocity to send
|
||||
pub fn process_midi_note(&mut self, note: u8, velocity: u8, is_note_on: bool) -> u8 {
|
||||
if !is_note_on {
|
||||
// Note off - pass through with original velocity
|
||||
return velocity;
|
||||
}
|
||||
|
||||
// Note on - trigger the clip
|
||||
let clip = self.get_clip(note);
|
||||
clip.trigger();
|
||||
clip.state_velocity()
|
||||
}
|
||||
|
||||
/// Process audio: record into recording clips, play back looping clips
|
||||
pub fn process_audio(&mut self, input: &[f32], output: &mut [f32], num_samples: usize) {
|
||||
// Clear output first
|
||||
for sample in output.iter_mut() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
|
||||
// Record into any clips that are recording
|
||||
for clip in self.clips.values_mut() {
|
||||
if clip.state == ClipState::Recording {
|
||||
clip.record(input, num_samples);
|
||||
}
|
||||
}
|
||||
|
||||
// Play back any clips that are looping
|
||||
for clip in self.clips.values_mut() {
|
||||
if clip.state == ClipState::Looping {
|
||||
clip.play(output, num_samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
// Get the clip once
|
||||
engine.get_clip(60);
|
||||
|
||||
// Get it again - should reuse the same clip
|
||||
let clip = engine.get_clip(60);
|
||||
|
||||
assert_eq!(clip.state, ClipState::Empty);
|
||||
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);
|
||||
}
|
||||
}
|
||||
168
src/lib.rs
168
src/lib.rs
@@ -1,168 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug::plugin::vst3::Vst3Plugin;
|
||||
use nih_plug::plugin::clap::ClapPlugin;
|
||||
use nih_plug::wrapper::clap::features::ClapFeature;
|
||||
use nih_plug::wrapper::vst3::subcategories::Vst3SubCategory;
|
||||
|
||||
mod engine;
|
||||
use engine::Engine;
|
||||
|
||||
/// The main plugin struct
|
||||
pub struct ClipLauncher {
|
||||
/// The engine managing clips
|
||||
engine: Engine,
|
||||
/// Pending MIDI output events
|
||||
pending_midi: Vec<NoteEvent<()>>,
|
||||
}
|
||||
|
||||
impl Default for ClipLauncher {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
engine: Engine::new(2, 44100.0),
|
||||
pending_midi: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ClipLauncher {
|
||||
const NAME: &'static str = "Clip Launcher";
|
||||
const VENDOR: &'static str = "Your Company";
|
||||
const URL: &'static str = "";
|
||||
const EMAIL: &'static str = "";
|
||||
const VERSION: &'static str = "0.1.0";
|
||||
|
||||
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
|
||||
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
|
||||
const MIDI_OUTPUT: MidiConfig = MidiConfig::Basic;
|
||||
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
|
||||
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
|
||||
fn initialize(
|
||||
&mut self,
|
||||
_audio_io_layout: &AudioIOLayout,
|
||||
buffer_config: &BufferConfig,
|
||||
_context: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
self.engine = Engine::new(
|
||||
_audio_io_layout.main_input_channels.map(|c| c.get() as usize).unwrap_or(2),
|
||||
buffer_config.sample_rate,
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
// Clear all clips on reset
|
||||
self.engine.clips.clear();
|
||||
self.pending_midi.clear();
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_aux: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
// Process MIDI input events
|
||||
while let Some(event) = context.next_event() {
|
||||
match event {
|
||||
NoteEvent::NoteOn { timing, note, velocity, channel, voice_id } => {
|
||||
let output_velocity = self.engine.process_midi_note(note, velocity as u8, true);
|
||||
// Queue output MIDI event
|
||||
self.pending_midi.push(NoteEvent::NoteOn {
|
||||
timing,
|
||||
note,
|
||||
velocity: output_velocity as f32,
|
||||
channel,
|
||||
voice_id,
|
||||
});
|
||||
}
|
||||
NoteEvent::NoteOff { timing, note, velocity, channel, voice_id } => {
|
||||
let output_velocity = self.engine.process_midi_note(note, velocity as u8, false);
|
||||
self.pending_midi.push(NoteEvent::NoteOff {
|
||||
timing,
|
||||
note,
|
||||
velocity: output_velocity as f32,
|
||||
channel,
|
||||
voice_id,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Process audio
|
||||
let num_channels = buffer.channels() as usize;
|
||||
let num_frames = buffer.samples() as usize;
|
||||
let input_slices = buffer.as_slice(); // &[&[f32]]
|
||||
|
||||
// Build interleaved input buffer
|
||||
let mut interleaved_input = vec![0.0; num_frames * num_channels];
|
||||
for ch in 0..num_channels {
|
||||
for frame in 0..num_frames {
|
||||
interleaved_input[frame * num_channels + ch] = input_slices[ch][frame];
|
||||
}
|
||||
}
|
||||
|
||||
// Build interleaved output buffer (will be filled by engine)
|
||||
let mut interleaved_output = vec![0.0; num_frames * num_channels];
|
||||
|
||||
self.engine.process_audio(&interleaved_input, &mut interleaved_output, num_frames);
|
||||
|
||||
// Copy back to buffer's output channels
|
||||
for (ch, channel_samples) in buffer.iter_samples().enumerate() {
|
||||
let (_, output) = channel_samples.as_slice();
|
||||
for frame in 0..num_frames {
|
||||
output[frame] = interleaved_output[frame * num_channels + ch];
|
||||
}
|
||||
}
|
||||
|
||||
// Send pending MIDI output events
|
||||
for event in self.pending_midi.drain(..) {
|
||||
context.send_event(event);
|
||||
}
|
||||
|
||||
ProcessStatus::Normal
|
||||
}
|
||||
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
Arc::new(ClipLauncherParams {
|
||||
dummy: BoolParam::new("Dummy", false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Vst3Plugin for ClipLauncher {
|
||||
const VST3_CLASS_ID: [u8; 16] = *b"ClipLauncher1234";
|
||||
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[Vst3SubCategory::Fx];
|
||||
}
|
||||
|
||||
impl ClapPlugin for ClipLauncher {
|
||||
const CLAP_ID: &'static str = "com.yourcompany.clip-launcher";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> = Some("A clip launcher plugin");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[ClapFeature::AudioEffect, ClapFeature::Stereo];
|
||||
}
|
||||
|
||||
/// Empty params struct (no parameters needed for this plugin)
|
||||
#[derive(Params)]
|
||||
struct ClipLauncherParams {
|
||||
/// Placeholder parameter to satisfy the derive macro
|
||||
#[id = "dummy"]
|
||||
dummy: BoolParam,
|
||||
}
|
||||
|
||||
// Export the plugin
|
||||
nih_export_vst3!(ClipLauncher);
|
||||
nih_export_clap!(ClipLauncher);
|
||||
BIN
test_engine
Executable file
BIN
test_engine
Executable file
Binary file not shown.
BIN
test_engine.o
Normal file
BIN
test_engine.o
Normal file
Binary file not shown.
Reference in New Issue
Block a user