Trying to clena the mess opened by plugins

This commit is contained in:
2026-02-21 01:03:55 +01:00
parent 5ef988382b
commit e9bca2548c
67 changed files with 1246 additions and 69 deletions

View File

@@ -0,0 +1,30 @@
[package]
name = "cagire-plugins"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description = "Cagire as a CLAP/VST3 audio plugin"
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
cagire = { path = "../.." }
cagire-forth = { path = "../../crates/forth" }
cagire-project = { path = "../../crates/project" }
cagire-ratatui = { path = "../../crates/ratatui" }
doux = { git = "https://github.com/Bubobubobubobubo/doux", features = ["native"] }
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" }
egui_ratatui = "2.1"
soft_ratatui = { version = "0.1.3", features = ["unicodefonts"] }
ratatui = "0.30"
crossterm = "0.29"
crossbeam-channel = "0.5"
arc-swap = "1"
parking_lot = "0.12"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ringbuf = "0.4"

View File

@@ -0,0 +1,323 @@
use std::sync::atomic::{AtomicBool, AtomicI64};
use std::sync::Arc;
use std::time::Instant;
use arc_swap::ArcSwap;
use crossbeam_channel::Sender;
use egui_ratatui::RataguiBackend;
use nih_plug::prelude::*;
use nih_plug_egui::egui;
use nih_plug_egui::{create_egui_editor, EguiState};
use ratatui::Terminal;
use soft_ratatui::embedded_graphics_unicodefonts::{
mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas,
mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas,
mono_9x18_atlas, mono_9x18_bold_atlas,
};
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::app::App;
use cagire::engine::{AudioCommand, LinkState, SequencerSnapshot};
use cagire::input::{handle_key, handle_mouse, InputContext};
use cagire::model::{Dictionary, Rng, Variables};
use cagire::theme;
use cagire::views;
use crate::input_egui::{convert_egui_events, convert_egui_mouse};
use crate::params::CagireParams;
use crate::PluginBridge;
type TerminalType = Terminal<RataguiBackend<EmbeddedGraphics>>;
#[derive(Clone, Copy, PartialEq)]
enum FontChoice {
Size6x13,
Size7x13,
Size8x13,
Size9x15,
Size9x18,
Size10x20,
}
impl FontChoice {
fn from_setting(s: &str) -> Self {
match s {
"6x13" => Self::Size6x13,
"7x13" => Self::Size7x13,
"9x15" => Self::Size9x15,
"9x18" => Self::Size9x18,
"10x20" => Self::Size10x20,
_ => Self::Size8x13,
}
}
fn to_setting(self) -> &'static str {
match self {
Self::Size6x13 => "6x13",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20",
}
}
fn label(self) -> &'static str {
match self {
Self::Size6x13 => "6x13 (Compact)",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13 (Default)",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20 (Large)",
}
}
const ALL: [Self; 6] = [
Self::Size6x13,
Self::Size7x13,
Self::Size8x13,
Self::Size9x15,
Self::Size9x18,
Self::Size10x20,
];
}
fn create_terminal(font: FontChoice) -> TerminalType {
let (regular, bold, italic) = match font {
FontChoice::Size6x13 => (
mono_6x13_atlas(),
Some(mono_6x13_bold_atlas()),
Some(mono_6x13_italic_atlas()),
),
FontChoice::Size7x13 => (
mono_7x13_atlas(),
Some(mono_7x13_bold_atlas()),
Some(mono_7x13_italic_atlas()),
),
FontChoice::Size8x13 => (
mono_8x13_atlas(),
Some(mono_8x13_bold_atlas()),
Some(mono_8x13_italic_atlas()),
),
FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None),
FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None),
FontChoice::Size10x20 => (mono_10x20_atlas(), None, None),
};
let soft = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
}
struct EditorState {
app: App,
terminal: TerminalType,
link: Arc<LinkState>,
snapshot: SequencerSnapshot,
playing: Arc<AtomicBool>,
nudge_us: Arc<AtomicI64>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
bridge: Arc<PluginBridge>,
params: Arc<CagireParams>,
last_frame: Instant,
current_font: FontChoice,
zoom_factor: f32,
}
// SAFETY: EditorState is only accessed from the GUI thread via RwLock.
// The non-Send types (RefCell in App's tachyonfx effects) are never shared
// across threads — baseview's event loop guarantees single-threaded access.
unsafe impl Send for EditorState {}
unsafe impl Sync for EditorState {}
pub fn create_editor(
params: Arc<CagireParams>,
egui_state: Arc<EguiState>,
bridge: Arc<PluginBridge>,
variables: Variables,
dict: Dictionary,
rng: Rng,
) -> Option<Box<dyn Editor>> {
let egui_state_clone = Arc::clone(&egui_state);
create_egui_editor(
egui_state,
None::<EditorState>,
|ctx, _state| {
ctx.set_visuals(egui::Visuals {
panel_fill: egui::Color32::BLACK,
..egui::Visuals::dark()
});
ctx.style_mut(|style| {
style.spacing.window_margin = egui::Margin::ZERO;
});
},
move |ctx, _setter, state| {
let editor = state.get_or_insert_with(|| {
let palette =
cagire::state::ColorScheme::default().to_palette();
theme::set(cagire_ratatui::theme::build::build(&palette));
let mut app = App::new_plugin(
Arc::clone(&variables),
Arc::clone(&dict),
Arc::clone(&rng),
);
app.audio.section = cagire::state::EngineSection::Settings;
app.audio.setting_kind = cagire::state::SettingKind::Polyphony;
// Load persisted project
app.project_state.project = params.project.lock().clone();
app.mark_all_patterns_dirty();
// Init window size from egui state
let (w, h) = egui_state_clone.size();
app.ui.window_width = w;
app.ui.window_height = h;
EditorState {
app,
terminal: create_terminal(FontChoice::Size8x13),
link: Arc::new(LinkState::new(
params.tempo.value() as f64,
4.0,
)),
snapshot: SequencerSnapshot::empty(),
playing: Arc::new(AtomicBool::new(false)),
nudge_us: Arc::new(AtomicI64::new(0)),
audio_tx: Arc::new(ArcSwap::from_pointee(bridge.audio_cmd_tx.clone())),
bridge: Arc::clone(&bridge),
params: Arc::clone(&params),
last_frame: Instant::now(),
current_font: FontChoice::Size8x13,
zoom_factor: 1.0,
}
});
// Flush pattern data and queued changes to the audio thread
let had_dirty = editor.app.flush_dirty_patterns(&editor.bridge.cmd_tx);
editor.app.flush_queued_changes(&editor.bridge.cmd_tx);
// Sync project changes back to persisted params
if had_dirty {
*editor.params.project.lock() = editor.app.project_state.project.clone();
}
// Sync sample registry from the audio engine (set once after initialize)
if editor.app.audio.sample_registry.is_none() {
if let Some(reg) = editor.bridge.sample_registry.load().as_ref() {
editor.app.audio.sample_registry = Some(Arc::clone(reg));
}
}
// Read live snapshot from the audio thread
let shared = editor.bridge.shared_state.load();
editor.snapshot = SequencerSnapshot::from(shared.as_ref());
// Feed scope and spectrum data into app metrics
editor.app.metrics.scope = editor.bridge.scope_buffer.read();
(editor.app.metrics.peak_left, editor.app.metrics.peak_right) =
editor.bridge.scope_buffer.peaks();
editor.app.metrics.spectrum = editor.bridge.spectrum_buffer.read();
// Handle input
let term = editor.terminal.get_frame().area();
let widget_rect = ctx.content_rect();
for mouse in convert_egui_mouse(ctx, widget_rect, term) {
let mut input_ctx = InputContext {
app: &mut editor.app,
link: &editor.link,
snapshot: &editor.snapshot,
playing: &editor.playing,
audio_tx: &editor.audio_tx,
seq_cmd_tx: &editor.bridge.cmd_tx,
nudge_us: &editor.nudge_us,
};
handle_mouse(&mut input_ctx, mouse, term);
}
for key in convert_egui_events(ctx) {
let mut input_ctx = InputContext {
app: &mut editor.app,
link: &editor.link,
snapshot: &editor.snapshot,
playing: &editor.playing,
audio_tx: &editor.audio_tx,
seq_cmd_tx: &editor.bridge.cmd_tx,
nudge_us: &editor.nudge_us,
};
let _ = handle_key(&mut input_ctx, key);
}
cagire::state::effects::tick_effects(&mut editor.app.ui, editor.app.page);
// Sync font/zoom from App state (options page or context menu may have changed them)
let desired_font = FontChoice::from_setting(&editor.app.ui.font);
if desired_font != editor.current_font {
editor.terminal = create_terminal(desired_font);
editor.current_font = desired_font;
}
if (editor.app.ui.zoom_factor - editor.zoom_factor).abs() > 0.01 {
editor.zoom_factor = editor.app.ui.zoom_factor;
ctx.set_zoom_factor(editor.zoom_factor);
}
// Sync window size: if app state differs from egui state, request resize
let (cur_w, cur_h) = egui_state_clone.size();
if editor.app.ui.window_width != cur_w || editor.app.ui.window_height != cur_h {
egui_state_clone.set_requested_size((editor.app.ui.window_width, editor.app.ui.window_height));
}
let elapsed = editor.last_frame.elapsed();
editor.last_frame = Instant::now();
let link = &editor.link;
let app = &editor.app;
let snapshot = &editor.snapshot;
editor
.terminal
.draw(|frame| views::render(frame, app, link, snapshot, elapsed))
.expect("draw");
let current_font = editor.current_font;
let current_zoom = editor.zoom_factor;
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
ui.add(editor.terminal.backend_mut());
let response = ui.interact(
ui.max_rect(),
egui::Id::new("terminal_context"),
egui::Sense::click(),
);
response.context_menu(|ui| {
ui.menu_button("Font", |ui| {
for choice in FontChoice::ALL {
if ui.selectable_label(current_font == choice, choice.label()).clicked() {
editor.app.ui.font = choice.to_setting().to_string();
editor.terminal = create_terminal(choice);
editor.current_font = choice;
ui.close();
}
}
});
ui.menu_button("Zoom", |ui| {
for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
let selected = (current_zoom - level).abs() < 0.01;
let label = format!("{:.0}%", level * 100.0);
if ui.selectable_label(selected, label).clicked() {
editor.app.ui.zoom_factor = level;
editor.zoom_factor = level;
ctx.set_zoom_factor(level);
ui.close();
}
}
});
});
});
},
)
}

View File

@@ -0,0 +1,258 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use nih_plug_egui::egui;
use ratatui::layout::Rect;
pub fn convert_egui_mouse(
ctx: &egui::Context,
widget_rect: egui::Rect,
term: Rect,
) -> Vec<MouseEvent> {
let mut events = Vec::new();
if widget_rect.width() < 1.0
|| widget_rect.height() < 1.0
|| term.width == 0
|| term.height == 0
{
return events;
}
ctx.input(|i| {
let Some(pos) = i.pointer.latest_pos() else {
return;
};
if !widget_rect.contains(pos) {
return;
}
let col =
((pos.x - widget_rect.left()) / widget_rect.width() * term.width as f32) as u16;
let row =
((pos.y - widget_rect.top()) / widget_rect.height() * term.height as f32) as u16;
let col = col.min(term.width.saturating_sub(1));
let row = row.min(term.height.saturating_sub(1));
if i.pointer.button_clicked(egui::PointerButton::Primary) {
events.push(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
}
let scroll = i.raw_scroll_delta.y;
if scroll > 1.0 {
events.push(MouseEvent {
kind: MouseEventKind::ScrollUp,
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if scroll < -1.0 {
events.push(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: col,
row,
modifiers: KeyModifiers::empty(),
});
}
});
events
}
pub fn convert_egui_events(ctx: &egui::Context) -> Vec<KeyEvent> {
let mut events = Vec::new();
for event in &ctx.input(|i| i.events.clone()) {
if let Some(key_event) = convert_event(event) {
events.push(key_event);
}
}
events
}
fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
match event {
egui::Event::Key {
key,
pressed,
modifiers,
..
} => {
if !*pressed {
return None;
}
let mods = convert_modifiers(*modifiers);
if is_character_key(*key)
&& !mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return None;
}
let code = convert_key(*key)?;
Some(KeyEvent::new(code, mods))
}
egui::Event::Text(text) => {
if text.len() == 1 {
let c = text.chars().next()?;
if !c.is_control() {
return Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty()));
}
}
None
}
egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
_ => None,
}
}
fn convert_key(key: egui::Key) -> Option<KeyCode> {
Some(match key {
egui::Key::ArrowDown => KeyCode::Down,
egui::Key::ArrowLeft => KeyCode::Left,
egui::Key::ArrowRight => KeyCode::Right,
egui::Key::ArrowUp => KeyCode::Up,
egui::Key::Escape => KeyCode::Esc,
egui::Key::Tab => KeyCode::Tab,
egui::Key::Backspace => KeyCode::Backspace,
egui::Key::Enter => KeyCode::Enter,
egui::Key::Space => KeyCode::Char(' '),
egui::Key::Insert => KeyCode::Insert,
egui::Key::Delete => KeyCode::Delete,
egui::Key::Home => KeyCode::Home,
egui::Key::End => KeyCode::End,
egui::Key::PageUp => KeyCode::PageUp,
egui::Key::PageDown => KeyCode::PageDown,
egui::Key::F1 => KeyCode::F(1),
egui::Key::F2 => KeyCode::F(2),
egui::Key::F3 => KeyCode::F(3),
egui::Key::F4 => KeyCode::F(4),
egui::Key::F5 => KeyCode::F(5),
egui::Key::F6 => KeyCode::F(6),
egui::Key::F7 => KeyCode::F(7),
egui::Key::F8 => KeyCode::F(8),
egui::Key::F9 => KeyCode::F(9),
egui::Key::F10 => KeyCode::F(10),
egui::Key::F11 => KeyCode::F(11),
egui::Key::F12 => KeyCode::F(12),
egui::Key::A => KeyCode::Char('a'),
egui::Key::B => KeyCode::Char('b'),
egui::Key::C => KeyCode::Char('c'),
egui::Key::D => KeyCode::Char('d'),
egui::Key::E => KeyCode::Char('e'),
egui::Key::F => KeyCode::Char('f'),
egui::Key::G => KeyCode::Char('g'),
egui::Key::H => KeyCode::Char('h'),
egui::Key::I => KeyCode::Char('i'),
egui::Key::J => KeyCode::Char('j'),
egui::Key::K => KeyCode::Char('k'),
egui::Key::L => KeyCode::Char('l'),
egui::Key::M => KeyCode::Char('m'),
egui::Key::N => KeyCode::Char('n'),
egui::Key::O => KeyCode::Char('o'),
egui::Key::P => KeyCode::Char('p'),
egui::Key::Q => KeyCode::Char('q'),
egui::Key::R => KeyCode::Char('r'),
egui::Key::S => KeyCode::Char('s'),
egui::Key::T => KeyCode::Char('t'),
egui::Key::U => KeyCode::Char('u'),
egui::Key::V => KeyCode::Char('v'),
egui::Key::W => KeyCode::Char('w'),
egui::Key::X => KeyCode::Char('x'),
egui::Key::Y => KeyCode::Char('y'),
egui::Key::Z => KeyCode::Char('z'),
egui::Key::Num0 => KeyCode::Char('0'),
egui::Key::Num1 => KeyCode::Char('1'),
egui::Key::Num2 => KeyCode::Char('2'),
egui::Key::Num3 => KeyCode::Char('3'),
egui::Key::Num4 => KeyCode::Char('4'),
egui::Key::Num5 => KeyCode::Char('5'),
egui::Key::Num6 => KeyCode::Char('6'),
egui::Key::Num7 => KeyCode::Char('7'),
egui::Key::Num8 => KeyCode::Char('8'),
egui::Key::Num9 => KeyCode::Char('9'),
egui::Key::Minus => KeyCode::Char('-'),
egui::Key::Equals => KeyCode::Char('='),
egui::Key::OpenBracket => KeyCode::Char('['),
egui::Key::CloseBracket => KeyCode::Char(']'),
egui::Key::Semicolon => KeyCode::Char(';'),
egui::Key::Comma => KeyCode::Char(','),
egui::Key::Period => KeyCode::Char('.'),
egui::Key::Slash => KeyCode::Char('/'),
egui::Key::Backslash => KeyCode::Char('\\'),
egui::Key::Backtick => KeyCode::Char('`'),
egui::Key::Quote => KeyCode::Char('\''),
_ => return None,
})
}
fn convert_modifiers(mods: egui::Modifiers) -> KeyModifiers {
let mut result = KeyModifiers::empty();
if mods.shift {
result |= KeyModifiers::SHIFT;
}
if mods.ctrl || mods.command {
result |= KeyModifiers::CONTROL;
}
if mods.alt {
result |= KeyModifiers::ALT;
}
result
}
fn is_character_key(key: egui::Key) -> bool {
matches!(
key,
egui::Key::A
| egui::Key::B
| egui::Key::C
| egui::Key::D
| egui::Key::E
| egui::Key::F
| egui::Key::G
| egui::Key::H
| egui::Key::I
| egui::Key::J
| egui::Key::K
| egui::Key::L
| egui::Key::M
| egui::Key::N
| egui::Key::O
| egui::Key::P
| egui::Key::Q
| egui::Key::R
| egui::Key::S
| egui::Key::T
| egui::Key::U
| egui::Key::V
| egui::Key::W
| egui::Key::X
| egui::Key::Y
| egui::Key::Z
| egui::Key::Num0
| egui::Key::Num1
| egui::Key::Num2
| egui::Key::Num3
| egui::Key::Num4
| egui::Key::Num5
| egui::Key::Num6
| egui::Key::Num7
| egui::Key::Num8
| egui::Key::Num9
| egui::Key::Space
| egui::Key::Minus
| egui::Key::Equals
| egui::Key::OpenBracket
| egui::Key::CloseBracket
| egui::Key::Semicolon
| egui::Key::Comma
| egui::Key::Period
| egui::Key::Slash
| egui::Key::Backslash
| egui::Key::Backtick
| egui::Key::Quote
)
}

View File

@@ -0,0 +1,461 @@
mod editor;
mod input_egui;
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;
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,
}
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>,
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(),
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: &[],
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;
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,
2,
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(),
quantization: pat.quantization,
sync_mode: pat.sync_mode,
};
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);
}
}
}
// 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 stereo into our buffer
let num_samples = buffer_len * 2;
self.audio_buffer.resize(num_samples, 0.0);
self.audio_buffer.fill(0.0);
engine.process_block(&mut self.audio_buffer, &[], &[]);
// Feed scope and spectrum analysis
self.bridge.scope_buffer.write(&self.audio_buffer);
if let Some(producer) = &mut self.fft_producer {
for chunk in self.audio_buffer.chunks(2) {
let mono = (chunk[0] + chunk.get(1).copied().unwrap_or(0.0)) * 0.5;
let _ = producer.try_push(mono);
}
}
// Copy interleaved doux output → nih-plug channel slices
let mut channel_iter = buffer.iter_samples();
for frame_idx in 0..buffer_len {
if let Some(mut frame) = channel_iter.next() {
let left = self.audio_buffer[frame_idx * 2];
let right = self.audio_buffer[frame_idx * 2 + 1];
if let Some(sample) = frame.get_mut(0) {
*sample = left;
}
if let Some(sample) = frame.get_mut(1) {
*sample = right;
}
}
}
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,
];
}
impl Vst3Plugin for CagirePlugin {
const VST3_CLASS_ID: [u8; 16] = *b"CagireSovaVST3!!";
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[
Vst3SubCategory::Instrument,
Vst3SubCategory::Synth,
Vst3SubCategory::Stereo,
];
}
nih_export_clap!(CagirePlugin);
nih_export_vst3!(CagirePlugin);

View File

@@ -0,0 +1,12 @@
use cagire_plugins::CagirePlugin;
use nih_plug::prelude::*;
fn main() {
let mut args: Vec<String> = std::env::args().collect();
// Default to 44100 Hz — nih-plug defaults to 48000 which causes CoreAudio
// to deliver mismatched buffer sizes, crashing the standalone wrapper.
if !args.iter().any(|a| a == "--sample-rate" || a == "-r") {
args.extend(["--sample-rate".into(), "44100".into()]);
}
nih_export_standalone_with_args::<CagirePlugin, _>(args);
}

View File

@@ -0,0 +1,46 @@
use std::sync::Arc;
use cagire_project::Project;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use parking_lot::Mutex;
#[derive(Params)]
pub struct CagireParams {
#[persist = "editor-state"]
pub editor_state: Arc<EguiState>,
#[persist = "project"]
pub project: Arc<Mutex<Project>>,
#[id = "tempo"]
pub tempo: FloatParam,
#[id = "sync"]
pub sync_to_host: BoolParam,
#[id = "bank"]
pub bank: IntParam,
#[id = "pattern"]
pub pattern: IntParam,
}
impl Default for CagireParams {
fn default() -> Self {
Self {
editor_state: EguiState::from_size(1200, 800),
project: Arc::new(Mutex::new(Project::default())),
tempo: FloatParam::new("Tempo", 120.0, FloatRange::Linear { min: 40.0, max: 300.0 })
.with_unit(" BPM")
.with_step_size(0.1),
sync_to_host: BoolParam::new("Sync to Host", true),
bank: IntParam::new("Bank", 0, IntRange::Linear { min: 0, max: 31 }),
pattern: IntParam::new("Pattern", 0, IntRange::Linear { min: 0, max: 31 }),
}
}
}