//! 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, pub cmd_rx: Receiver, pub audio_cmd_tx: Sender, pub audio_cmd_rx: Receiver, pub shared_state: Arc>, pub scope_buffer: Arc, pub spectrum_buffer: Arc, pub sample_registry: ArcSwap>>, } struct PendingNoteOff { target_sample: u64, channel: u8, note: u8, } /// NIH-plug plugin implementing sequencer, synthesis, and MIDI I/O. pub struct CagirePlugin { params: Arc, seq_state: Option, engine: Option, sample_rate: f32, prev_beat: f64, sample_pos: u64, bridge: Arc, variables: Variables, dict: Dictionary, rng: Rng, cmd_buffer: String, audio_buffer: Vec, output_channels: usize, scope_extract_buffer: Vec, fft_producer: Option>, _analysis: Option, pending_note_offs: Vec, } 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 { self.params.clone() } fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { 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, ) -> 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, ) -> 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 = 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);