Files
Cagire/plugins/cagire-plugins/src/lib.rs
Raphaël Forment 11cc925faf
All checks were successful
Deploy Website / deploy (push) Has been skipped
more fixes
2026-03-01 03:33:22 +01:00

521 lines
18 KiB
Rust

//! Cagire as a CLAP/VST3 plugin via NIH-plug.
mod editor;
mod params;
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use crossbeam_channel::{bounded, Receiver, Sender};
use nih_plug::prelude::*;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::SeedableRng;
use ringbuf::traits::Producer;
use cagire::engine::{
parse_midi_command, spawn_analysis_thread, AnalysisHandle, AudioCommand, MidiCommand,
PatternSnapshot, ScopeBuffer, SeqCommand, SequencerState, SharedSequencerState, SpectrumBuffer,
StepSnapshot, TickInput,
};
use cagire::model::{Dictionary, Rng, Variables};
use params::CagireParams;
/// Channel bridge between the plugin editor and the audio/sequencer threads.
pub struct PluginBridge {
pub cmd_tx: Sender<SeqCommand>,
pub cmd_rx: Receiver<SeqCommand>,
pub audio_cmd_tx: Sender<AudioCommand>,
pub audio_cmd_rx: Receiver<AudioCommand>,
pub shared_state: Arc<ArcSwap<SharedSequencerState>>,
pub scope_buffer: Arc<ScopeBuffer>,
pub spectrum_buffer: Arc<SpectrumBuffer>,
pub sample_registry: ArcSwap<Option<Arc<doux::SampleRegistry>>>,
}
struct PendingNoteOff {
target_sample: u64,
channel: u8,
note: u8,
}
/// NIH-plug plugin implementing sequencer, synthesis, and MIDI I/O.
pub struct CagirePlugin {
params: Arc<CagireParams>,
seq_state: Option<SequencerState>,
engine: Option<doux::Engine>,
sample_rate: f32,
prev_beat: f64,
sample_pos: u64,
bridge: Arc<PluginBridge>,
variables: Variables,
dict: Dictionary,
rng: Rng,
cmd_buffer: String,
audio_buffer: Vec<f32>,
output_channels: usize,
scope_extract_buffer: Vec<f32>,
fft_producer: Option<ringbuf::HeapProd<f32>>,
_analysis: Option<AnalysisHandle>,
pending_note_offs: Vec<PendingNoteOff>,
}
impl Default for CagirePlugin {
fn default() -> Self {
let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new()));
let dict: Dictionary = Arc::new(Mutex::new(HashMap::new()));
let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
let (cmd_tx, cmd_rx) = bounded(64);
let (audio_cmd_tx, audio_cmd_rx) = bounded(64);
let bridge = Arc::new(PluginBridge {
cmd_tx,
cmd_rx,
audio_cmd_tx,
audio_cmd_rx,
shared_state: Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())),
scope_buffer: Arc::new(ScopeBuffer::default()),
spectrum_buffer: Arc::new(SpectrumBuffer::default()),
sample_registry: ArcSwap::from_pointee(None),
});
Self {
params: Arc::new(CagireParams::default()),
seq_state: None,
engine: None,
sample_rate: 44100.0,
prev_beat: -1.0,
sample_pos: 0,
bridge,
variables,
dict,
rng,
cmd_buffer: String::with_capacity(256),
audio_buffer: Vec::new(),
output_channels: 2,
scope_extract_buffer: Vec::new(),
fft_producer: None,
_analysis: None,
pending_note_offs: Vec::new(),
}
}
}
impl Plugin for CagirePlugin {
type SysExMessage = ();
type BackgroundTask = ();
const NAME: &'static str = "Cagire";
const VENDOR: &'static str = "Bubobubobubobubo";
const URL: &'static str = "https://cagire.raphaelforment.fr";
const EMAIL: &'static str = "raphael.forment@gmail.com";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: None,
main_output_channels: Some(new_nonzero_u32(2)),
aux_input_ports: &[],
aux_output_ports: &[new_nonzero_u32(2); 7],
names: PortNames {
layout: Some("Multi-output"),
main_input: None,
main_output: Some("Orbit 0"),
aux_inputs: &[],
aux_outputs: &[
"Orbit 1", "Orbit 2", "Orbit 3", "Orbit 4", "Orbit 5", "Orbit 6", "Orbit 7",
],
},
},
AudioIOLayout {
main_input_channels: None,
main_output_channels: Some(new_nonzero_u32(2)),
aux_input_ports: &[],
aux_output_ports: &[],
names: PortNames {
layout: Some("Stereo"),
main_input: None,
main_output: Some("Output"),
aux_inputs: &[],
aux_outputs: &[],
},
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::MidiCCs;
const MIDI_OUTPUT: MidiConfig = MidiConfig::MidiCCs;
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
editor::create_editor(
self.params.clone(),
self.params.editor_state.clone(),
Arc::clone(&self.bridge),
Arc::clone(&self.variables),
Arc::clone(&self.dict),
Arc::clone(&self.rng),
)
}
fn initialize(
&mut self,
audio_io_layout: &AudioIOLayout,
buffer_config: &BufferConfig,
_context: &mut impl InitContext<Self>,
) -> bool {
self.sample_rate = buffer_config.sample_rate;
self.sample_pos = 0;
self.prev_beat = -1.0;
let num_aux = audio_io_layout.aux_output_ports.len();
self.output_channels = 2 + num_aux * 2;
self.seq_state = Some(SequencerState::new(
Arc::clone(&self.variables),
Arc::clone(&self.dict),
Arc::clone(&self.rng),
None,
));
let engine = doux::Engine::new_with_channels(
self.sample_rate,
self.output_channels,
64,
);
self.bridge
.sample_registry
.store(Arc::new(Some(Arc::clone(&engine.sample_registry))));
self.engine = Some(engine);
let (fft_producer, analysis_handle) = spawn_analysis_thread(
self.sample_rate,
Arc::clone(&self.bridge.spectrum_buffer),
);
self.fft_producer = Some(fft_producer);
self._analysis = Some(analysis_handle);
// Seed sequencer with persisted project data
let project = self.params.project.lock().clone();
for (bank_idx, bank) in project.banks.iter().enumerate() {
for (pat_idx, pat) in bank.patterns.iter().enumerate() {
let has_content = pat.steps.iter().any(|s| !s.script.is_empty());
if !has_content {
continue;
}
let snapshot = PatternSnapshot {
speed: pat.speed,
length: pat.length,
steps: pat
.steps
.iter()
.take(pat.length)
.map(|s| StepSnapshot {
active: s.active,
script: s.script.clone(),
source: s.source,
})
.collect(),
sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
};
let _ = self.bridge.cmd_tx.send(SeqCommand::PatternUpdate {
bank: bank_idx,
pattern: pat_idx,
data: snapshot,
});
}
}
true
}
fn reset(&mut self) {
self.prev_beat = -1.0;
self.sample_pos = 0;
if let Some(engine) = &mut self.engine {
engine.hush();
}
}
fn process(
&mut self,
buffer: &mut Buffer,
aux: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(seq_state) = &mut self.seq_state else {
return ProcessStatus::Normal;
};
let Some(engine) = &mut self.engine else {
return ProcessStatus::Normal;
};
let transport = context.transport();
let buffer_len = buffer.samples();
let playing = transport.playing;
let tempo = transport.tempo.unwrap_or(self.params.tempo.value() as f64);
let beat = transport.pos_beats().unwrap_or(0.0);
let quantum = transport
.time_sig_numerator
.map(|n| n as f64)
.unwrap_or(4.0);
let effective_tempo = if self.params.sync_to_host.value() {
tempo
} else {
self.params.tempo.value() as f64
};
let buffer_secs = buffer_len as f64 / self.sample_rate as f64;
let lookahead_beats = if effective_tempo > 0.0 {
buffer_secs * effective_tempo / 60.0
} else {
0.0
};
let lookahead_end = beat + lookahead_beats;
let engine_time = self.sample_pos as f64 / self.sample_rate as f64;
// Drain commands from the editor
let commands: Vec<SeqCommand> = self.bridge.cmd_rx.try_iter().collect();
let input = TickInput {
commands,
playing,
beat,
lookahead_end,
tempo: effective_tempo,
quantum,
fill: false,
nudge_secs: 0.0,
current_time_us: 0,
engine_time,
mouse_x: 0.5,
mouse_y: 0.5,
mouse_down: 0.0,
};
let output = seq_state.tick(input);
// Publish snapshot for the editor
self.bridge
.shared_state
.store(Arc::new(output.shared_state));
// Drain audio commands from the editor (preview, hush, load samples, etc.)
for audio_cmd in self.bridge.audio_cmd_rx.try_iter() {
match audio_cmd {
AudioCommand::Evaluate { ref cmd, time } => {
let cmd_ref = match time {
Some(t) => {
self.cmd_buffer.clear();
use std::fmt::Write;
let _ = write!(&mut self.cmd_buffer, "{cmd}/time/{t:.6}");
self.cmd_buffer.as_str()
}
None => cmd.as_str(),
};
engine.evaluate(cmd_ref);
}
AudioCommand::Hush => engine.hush(),
AudioCommand::Panic => engine.panic(),
AudioCommand::LoadSamples(samples) => {
engine.sample_index.extend(samples);
}
AudioCommand::LoadSoundfont(path) => {
if let Err(e) = engine.load_soundfont(&path) {
eprintln!("Failed to load soundfont: {e}");
}
}
}
}
// Drain expired pending note-offs
self.pending_note_offs.retain(|off| {
if off.target_sample <= self.sample_pos {
context.send_event(NoteEvent::NoteOff {
timing: 0,
voice_id: None,
channel: off.channel,
note: off.note,
velocity: 0.0,
});
false
} else {
true
}
});
// Feed audio + MIDI commands from sequencer
for tsc in &output.audio_commands {
if tsc.cmd.starts_with("/midi/") {
if let Some((midi_cmd, dur, _delta)) = parse_midi_command(&tsc.cmd) {
match midi_cmd {
MidiCommand::NoteOn { channel, note, velocity, .. } => {
context.send_event(NoteEvent::NoteOn {
timing: 0,
voice_id: None,
channel,
note,
velocity: velocity as f32 / 127.0,
});
if let Some(dur) = dur {
self.pending_note_offs.push(PendingNoteOff {
target_sample: self.sample_pos
+ (dur * self.sample_rate as f64) as u64,
channel,
note,
});
}
}
MidiCommand::NoteOff { channel, note, .. } => {
context.send_event(NoteEvent::NoteOff {
timing: 0,
voice_id: None,
channel,
note,
velocity: 0.0,
});
}
MidiCommand::CC { channel, cc, value, .. } => {
context.send_event(NoteEvent::MidiCC {
timing: 0,
channel,
cc,
value: value as f32 / 127.0,
});
}
MidiCommand::PitchBend { channel, value, .. } => {
context.send_event(NoteEvent::MidiPitchBend {
timing: 0,
channel,
value: value as f32 / 16383.0,
});
}
MidiCommand::Pressure { channel, value, .. } => {
context.send_event(NoteEvent::MidiChannelPressure {
timing: 0,
channel,
pressure: value as f32 / 127.0,
});
}
MidiCommand::ProgramChange { channel, program, .. } => {
context.send_event(NoteEvent::MidiProgramChange {
timing: 0,
channel,
program,
});
}
MidiCommand::Clock { .. }
| MidiCommand::Start { .. }
| MidiCommand::Stop { .. }
| MidiCommand::Continue { .. } => {}
}
}
continue;
}
let cmd_ref = match tsc.time {
Some(t) => {
self.cmd_buffer.clear();
use std::fmt::Write;
let _ = write!(&mut self.cmd_buffer, "{}/time/{t:.6}", tsc.cmd);
self.cmd_buffer.as_str()
}
None => &tsc.cmd,
};
engine.evaluate(cmd_ref);
}
// Process audio block — doux writes interleaved into our buffer
let total_samples = buffer_len * self.output_channels;
self.audio_buffer.resize(total_samples, 0.0);
self.audio_buffer.fill(0.0);
engine.process_block(&mut self.audio_buffer, &[], &[]);
// Feed scope and spectrum analysis (orbit 0 only)
if self.output_channels == 2 {
self.bridge.scope_buffer.write(&self.audio_buffer);
} else {
self.scope_extract_buffer.resize(buffer_len * 2, 0.0);
for i in 0..buffer_len {
self.scope_extract_buffer[i * 2] =
self.audio_buffer[i * self.output_channels];
self.scope_extract_buffer[i * 2 + 1] =
self.audio_buffer[i * self.output_channels + 1];
}
self.bridge.scope_buffer.write(&self.scope_extract_buffer);
}
if let Some(producer) = &mut self.fft_producer {
let stride = self.output_channels;
for i in 0..buffer_len {
let left = self.audio_buffer[i * stride];
let right = self.audio_buffer[i * stride + 1];
let _ = producer.try_push((left + right) * 0.5);
}
}
// De-interleave doux output into nih-plug channel buffers
let stride = self.output_channels;
deinterleave_stereo(&self.audio_buffer, buffer.as_slice(), stride, 0, buffer_len);
for (aux_idx, aux_buf) in aux.outputs.iter_mut().enumerate() {
deinterleave_stereo(
&self.audio_buffer,
aux_buf.as_slice(),
stride,
(aux_idx + 1) * 2,
buffer_len,
);
}
self.sample_pos += buffer_len as u64;
self.prev_beat = lookahead_end;
ProcessStatus::Normal
}
}
impl ClapPlugin for CagirePlugin {
const CLAP_ID: &'static str = "com.sova.cagire";
const CLAP_DESCRIPTION: Option<&'static str> = Some("Forth-based music sequencer");
const CLAP_MANUAL_URL: Option<&'static str> = Some("https://cagire.raphaelforment.fr");
const CLAP_SUPPORT_URL: Option<&'static str> = Some("https://cagire.raphaelforment.fr");
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::Instrument,
ClapFeature::Synthesizer,
ClapFeature::Stereo,
ClapFeature::Surround,
];
}
impl Vst3Plugin for CagirePlugin {
const VST3_CLASS_ID: [u8; 16] = *b"CagireSovaVST3!!";
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[
Vst3SubCategory::Instrument,
Vst3SubCategory::Synth,
Vst3SubCategory::Stereo,
Vst3SubCategory::Surround,
];
}
fn deinterleave_stereo(
interleaved: &[f32],
out: &mut [&mut [f32]],
stride: usize,
ch_offset: usize,
len: usize,
) {
let (left, right) = out.split_at_mut(1);
for (i, (l, r)) in left[0][..len].iter_mut().zip(right[0][..len].iter_mut()).enumerate() {
*l = interleaved[i * stride + ch_offset];
*r = interleaved[i * stride + ch_offset + 1];
}
}
nih_export_clap!(CagirePlugin);
nih_export_vst3!(CagirePlugin);