Trying to clena the mess opened by plugins
This commit is contained in:
323
plugins/cagire-plugins/src/editor.rs
Normal file
323
plugins/cagire-plugins/src/editor.rs
Normal 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(¶ms),
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
)
|
||||
}
|
||||
258
plugins/cagire-plugins/src/input_egui.rs
Normal file
258
plugins/cagire-plugins/src/input_egui.rs
Normal 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
|
||||
)
|
||||
}
|
||||
461
plugins/cagire-plugins/src/lib.rs
Normal file
461
plugins/cagire-plugins/src/lib.rs
Normal 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);
|
||||
12
plugins/cagire-plugins/src/main.rs
Normal file
12
plugins/cagire-plugins/src/main.rs
Normal 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);
|
||||
}
|
||||
46
plugins/cagire-plugins/src/params.rs
Normal file
46
plugins/cagire-plugins/src/params.rs
Normal 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 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user