feat: implement clip launcher plugin with MIDI-triggered recording and looping
Co-authored-by: aider (deepseek/deepseek-coder) <aider@aider.chat>
This commit is contained in:
10
Cargo.toml
10
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"]
|
||||||
|
|||||||
195
src/engine.rs
195
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
131
src/lib.rs
131
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<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()).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 } => {
|
||||||
|
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<dyn Params> {
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user