diff --git a/Cargo.toml b/Cargo.toml index e69de29..6d51476 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[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"] diff --git a/src/engine.rs b/src/engine.rs index e69de29..72c1e01 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -0,0 +1,195 @@ +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, + /// 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, + /// 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); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e69de29..7998e32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -0,0 +1,131 @@ +use nih_plug::prelude::*; + +mod engine; +use engine::{Engine, ClipState}; + +/// The main plugin struct +pub struct ClipLauncher { + /// The engine managing clips + engine: Engine, + /// Pending MIDI output events + pending_midi: Vec, +} + +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, + ) -> bool { + self.engine = Engine::new( + _audio_io_layout.main_input_channels.map(|c| c.get()).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, + ) -> ProcessStatus { + // Process MIDI input events + while let Some(event) = context.next_event() { + match event { + NoteEvent::NoteOn { timing, note, velocity, channel } => { + let output_velocity = self.engine.process_midi_note(note, velocity, true); + // Queue output MIDI event + self.pending_midi.push(NoteEvent::NoteOn { + timing, + note, + velocity: output_velocity, + channel, + }); + } + NoteEvent::NoteOff { timing, note, velocity, channel } => { + let output_velocity = self.engine.process_midi_note(note, velocity, false); + self.pending_midi.push(NoteEvent::NoteOff { + timing, + note, + velocity: output_velocity, + channel, + }); + } + _ => {} + } + } + + // Process audio + for channel_samples in buffer.iter_samples() { + let (input, mut output) = channel_samples; + let num_samples = input.len(); + + // Process this block + self.engine.process_audio(input, &mut output, num_samples); + } + + // Send pending MIDI output events + for event in self.pending_midi.drain(..) { + context.send_event(event); + } + + ProcessStatus::Normal + } + + fn params(&self) -> Arc { + Arc::new(ClipLauncherParams) + } +} + +/// Empty params struct (no parameters needed for this plugin) +#[derive(Params)] +struct ClipLauncherParams; + +impl Default for ClipLauncherParams { + fn default() -> Self { + Self + } +} + +// Export the plugin +nih_export_vst3!(ClipLauncher); +nih_export_clap!(ClipLauncher);