diff --git a/Cargo.toml b/Cargo.toml index e8ecfd9..6cdc9a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ required-features = ["desktop"] [features] default = [] desktop = [ + "cagire-forth/desktop", "egui", "eframe", "egui_ratatui", diff --git a/crates/forth/Cargo.toml b/crates/forth/Cargo.toml index f7beaef..0c0018d 100644 --- a/crates/forth/Cargo.toml +++ b/crates/forth/Cargo.toml @@ -3,5 +3,9 @@ name = "cagire-forth" version = "0.1.0" edition = "2021" +[features] +default = [] +desktop = [] + [dependencies] rand = "0.8" diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index c356f93..f7777b2 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -32,6 +32,12 @@ pub struct StepContext { pub fill: bool, pub nudge_secs: f64, pub cc_memory: Option, + #[cfg(feature = "desktop")] + pub mouse_x: f64, + #[cfg(feature = "desktop")] + pub mouse_y: f64, + #[cfg(feature = "desktop")] + pub mouse_down: f64, } impl StepContext { diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index a45d175..57f09a6 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -427,6 +427,12 @@ impl Forth { "speed" => Value::Float(ctx.speed, None), "stepdur" => Value::Float(ctx.step_duration(), None), "fill" => Value::Int(if ctx.fill { 1 } else { 0 }, None), + #[cfg(feature = "desktop")] + "mx" => Value::Float(ctx.mouse_x, None), + #[cfg(feature = "desktop")] + "my" => Value::Float(ctx.mouse_y, None), + #[cfg(feature = "desktop")] + "mdown" => Value::Float(ctx.mouse_down, None), _ => Value::Int(0, None), }; stack.push(val); diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 140c253..ff34ff6 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -753,6 +753,39 @@ pub const WORDS: &[Word] = &[ compile: Context("fill"), varargs: false, }, + #[cfg(feature = "desktop")] + Word { + name: "mx", + aliases: &[], + category: "Desktop", + stack: "(-- x)", + desc: "Normalized mouse X position (0-1)", + example: "mx 440 880 range freq", + compile: Context("mx"), + varargs: false, + }, + #[cfg(feature = "desktop")] + Word { + name: "my", + aliases: &[], + category: "Desktop", + stack: "(-- y)", + desc: "Normalized mouse Y position (0-1)", + example: "my 0.1 0.9 range gain", + compile: Context("my"), + varargs: false, + }, + #[cfg(feature = "desktop")] + Word { + name: "mdown", + aliases: &[], + category: "Desktop", + stack: "(-- bool)", + desc: "1 when mouse button held, 0 otherwise", + example: "mdown { \"crash\" s . } ?", + compile: Context("mdown"), + varargs: false, + }, // Music Word { name: "mtof", diff --git a/docs/midi_input.md b/docs/midi_input.md new file mode 100644 index 0000000..3f332f8 --- /dev/null +++ b/docs/midi_input.md @@ -0,0 +1,68 @@ +# MIDI Input + +Read incoming MIDI control change values with the `ccval` word. This lets you use hardware controllers to modulate parameters in your scripts. + +## Reading CC Values + +The `ccval` word takes a CC number and channel from the stack, and returns the last received value: + +```forth +1 1 ccval ;; read CC 1 (mod wheel) on channel 1 +``` + +Stack effect: `(cc chan -- val)` + +The returned value is `0`-`127`. If no message has been received for that CC/channel combination, the value is `0`. + +## Device Selection + +Use `dev` to select which input device slot to read from: + +```forth +1 dev 1 1 ccval ;; read from device slot 1 +``` + +Device defaults to `0` if not specified. + +## Practical Examples + +Map a controller knob to filter cutoff: + +```forth +74 1 ccval 127 / 200 2740 range lpf +``` + +Use mod wheel for vibrato depth: + +```forth +1 1 ccval 127 / 0 0.5 range vibdepth +``` + +Crossfade between two sounds: + +```forth +1 1 ccval 127 / ;; normalize to 0.0-1.0 +dup saw s swap gain . +1 swap - tri s swap gain . +``` + +## Scaling Values + +CC values are integers `0`-`127`. Normalize to `0.0`-`1.0` first, then use `range` to scale: + +```forth +;; normalize to 0.0-1.0 +74 1 ccval 127 / + +;; scale to custom range (e.g., 200-4000) +74 1 ccval 127 / 200 4000 range + +;; bipolar range (-1.0 to 1.0) +74 1 ccval 127 / -1 1 range +``` + +The `range` word takes a normalized value (`0.0`-`1.0`) and scales it to your target range: `(val min max -- scaled)`. + +## Latency + +CC values are sampled at the start of each step. Changes during a step take effect on the next step. For smoothest results, turn knobs slowly or use higher step rates. diff --git a/docs/midi_intro.md b/docs/midi_intro.md new file mode 100644 index 0000000..d51b052 --- /dev/null +++ b/docs/midi_intro.md @@ -0,0 +1,26 @@ +# MIDI + +Cagire speaks MIDI. You can send notes, control changes, and other messages to external synthesizers, drum machines, and DAWs. You can also read incoming control change values from MIDI controllers and use them to modulate your scripts. + +## Device Slots + +Cagire provides four input slots and four output slots, numbered `0` through `3`. Each slot can connect to one MIDI device. By default, slot `0` is used for both input and output. + +## Configuration + +Configure your MIDI devices in the **Options** view. Select input and output devices for each slot. Changes take effect immediately. + +## MIDI vs Audio + +The audio engine (`Doux`) and MIDI are independent systems. Use `.` to emit audio commands, use `m.` to emit MIDI messages. You can use both in the same script: + +```forth +saw s c4 note 0.5 gain . ;; audio +60 note 100 velocity m. ;; MIDI +``` + +MIDI is useful when you want to sequence external gear, layer Cagire with hardware synths, or integrate into a larger studio setup. The audio engine is self-contained and needs no external equipment. + +## Clock and Transport + +Cagire can send MIDI clock and transport messages to synchronize external gear. Use `mclock` to send a single clock pulse, and `mstart`, `mstop`, `mcont` for transport control. MIDI clock requires 24 pulses per quarter note, so you need to call `mclock` at the appropriate rate for your tempo. diff --git a/docs/midi_output.md b/docs/midi_output.md new file mode 100644 index 0000000..cb614ac --- /dev/null +++ b/docs/midi_output.md @@ -0,0 +1,92 @@ +# MIDI Output + +Send MIDI messages using the `m.` word. Build up parameters on the stack, then emit. The system determines message type based on which parameters you set. + +## Note Messages + +The default message type is a note. Set `note` and `velocity`, then emit: + +```forth +60 note 100 velocity m. ;; middle C, velocity 100 +c4 note 80 velocity m. ;; same pitch, lower velocity +``` + +| Parameter | Stack | Range | Description | +|-----------|-------|-------|-------------| +| `note` | `(n --)` | 0-127 | MIDI note number | +| `velocity` | `(n --)` | 0-127 | Note velocity | +| `chan` | `(n --)` | 1-16 | MIDI channel | +| `dur` | `(f --)` | steps | Note duration | +| `dev` | `(n --)` | 0-3 | Output device slot | + +Duration (`dur`) is measured in steps. If not set, the note plays until the next step. Channel defaults to `1`, device defaults to `0`. + +## Control Change + +Set both `ccnum` (controller number) and `ccout` (value) to send a CC message: + +```forth +74 ccnum 64 ccout m. ;; CC 74, value 64 +1 ccnum 127 ccout m. ;; mod wheel full +``` + +| Parameter | Stack | Range | Description | +|-----------|-------|-------|-------------| +| `ccnum` | `(n --)` | 0-127 | Controller number | +| `ccout` | `(n --)` | 0-127 | Controller value | + +## Pitch Bend + +Set `bend` to send pitch bend. The range is `-1.0` (full down) to `1.0` (full up), with `0.0` as center: + +```forth +0.5 bend m. ;; bend up halfway +-1.0 bend m. ;; full bend down +``` + +## Channel Pressure + +Set `pressure` to send channel aftertouch: + +```forth +64 pressure m. ;; medium pressure +``` + +## Program Change + +Set `program` to send a program change message: + +```forth +0 program m. ;; select program 0 +127 program m. ;; select program 127 +``` + +## Message Priority + +When multiple message types are set, only one is sent per emit. Priority order: + +1. Control Change (if `ccnum` AND `ccout` set) +2. Pitch Bend +3. Channel Pressure +4. Program Change +5. Note (default) + +To send multiple message types, use multiple emits: + +```forth +74 ccnum 100 ccout m. ;; CC first +60 note 100 velocity m. ;; then note +``` + +## Real-Time Messages + +Transport and clock messages for external synchronization: + +| Word | Description | +|------|-------------| +| `mclock` | Send MIDI clock pulse | +| `mstart` | Send MIDI start | +| `mstop` | Send MIDI stop | +| `mcont` | Send MIDI continue | + +These ignore all parameters and send immediately. diff --git a/src/app.rs b/src/app.rs index 4bb4266..0402f31 100644 --- a/src/app.rs +++ b/src/app.rs @@ -333,6 +333,12 @@ impl App { fill: false, nudge_secs: 0.0, cc_memory: None, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, }; let cmds = self.script_engine.evaluate(script, &ctx)?; @@ -384,6 +390,12 @@ impl App { fill: false, nudge_secs: 0.0, cc_memory: None, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, }; match self.script_engine.evaluate(&script, &ctx) { @@ -462,6 +474,12 @@ impl App { fill: false, nudge_secs: 0.0, cc_memory: None, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, }; if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) { diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs index 298985c..62282ff 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop.rs @@ -19,9 +19,10 @@ use soft_ratatui::{EmbeddedGraphics, SoftBackend}; use cagire::app::App; use cagire::engine::{ - build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer, - SequencerConfig, SequencerHandle, SpectrumBuffer, + build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, + ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer, }; +use crossbeam_channel::Receiver; use cagire::input::{handle_key, InputContext, InputResult}; use cagire::input_egui::convert_egui_events; use cagire::settings::Settings; @@ -144,7 +145,11 @@ struct CagireDesktop { sample_rate_shared: Arc, _stream: Option, _analysis_handle: Option, + midi_rx: Receiver, current_font: FontChoice, + mouse_x: Arc, + mouse_y: Arc, + mouse_down: Arc, } impl CagireDesktop { @@ -201,13 +206,21 @@ impl CagireDesktop { initial_samples.extend(index); } + let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); + let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits())); + let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits())); + let seq_config = SequencerConfig { audio_sample_pos: Arc::clone(&audio_sample_pos), sample_rate: Arc::clone(&sample_rate_shared), lookahead_ms: Arc::clone(&lookahead_ms), + cc_memory: Some(Arc::clone(&app.midi.cc_memory)), + mouse_x: Arc::clone(&mouse_x), + mouse_y: Arc::clone(&mouse_y), + mouse_down: Arc::clone(&mouse_down), }; - let (sequencer, initial_audio_rx) = spawn_sequencer( + let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer( Arc::clone(&link), Arc::clone(&playing), Arc::clone(&app.variables), @@ -268,7 +281,11 @@ impl CagireDesktop { sample_rate_shared, _stream: stream, _analysis_handle: analysis_handle, + midi_rx, current_font, + mouse_x, + mouse_y, + mouse_down, } } @@ -285,6 +302,7 @@ impl CagireDesktop { return; }; let new_audio_rx = sequencer.swap_audio_channel(); + self.midi_rx = sequencer.swap_midi_channel(); let new_config = AudioStreamConfig { output_device: self.app.audio.config.output_device.clone(), @@ -373,6 +391,18 @@ impl eframe::App for CagireDesktop { self.handle_audio_restart(); self.update_metrics(); + ctx.input(|i| { + if let Some(pos) = i.pointer.latest_pos() { + let screen = i.viewport_rect(); + let nx = (pos.x / screen.width()).clamp(0.0, 1.0); + let ny = (pos.y / screen.height()).clamp(0.0, 1.0); + self.mouse_x.store(nx.to_bits(), Ordering::Relaxed); + self.mouse_y.store(ny.to_bits(), Ordering::Relaxed); + } + let down = if i.pointer.primary_down() { 1.0_f32 } else { 0.0_f32 }; + self.mouse_down.store(down.to_bits(), Ordering::Relaxed); + }); + let Some(ref sequencer) = self.sequencer else { return; }; @@ -395,6 +425,33 @@ impl eframe::App for CagireDesktop { self.app.flush_queued_changes(&sequencer.cmd_tx); self.app.flush_dirty_patterns(&sequencer.cmd_tx); + while let Ok(midi_cmd) = self.midi_rx.try_recv() { + match midi_cmd { + MidiCommand::NoteOn { device, channel, note, velocity } => { + self.app.midi.send_note_on(device, channel, note, velocity); + } + MidiCommand::NoteOff { device, channel, note } => { + self.app.midi.send_note_off(device, channel, note); + } + MidiCommand::CC { device, channel, cc, value } => { + self.app.midi.send_cc(device, channel, cc, value); + } + MidiCommand::PitchBend { device, channel, value } => { + self.app.midi.send_pitch_bend(device, channel, value); + } + MidiCommand::Pressure { device, channel, value } => { + self.app.midi.send_pressure(device, channel, value); + } + MidiCommand::ProgramChange { device, channel, program } => { + self.app.midi.send_program_change(device, channel, program); + } + MidiCommand::Clock { device } => self.app.midi.send_realtime(device, 0xF8), + MidiCommand::Start { device } => self.app.midi.send_realtime(device, 0xFA), + MidiCommand::Stop { device } => self.app.midi.send_realtime(device, 0xFC), + MidiCommand::Continue { device } => self.app.midi.send_realtime(device, 0xFB), + } + } + let should_quit = self.handle_input(ctx); if should_quit { ctx.send_viewport_cmd(egui::ViewportCommand::Close); diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 72c9867..3ffb15d 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -1,6 +1,8 @@ use arc_swap::ArcSwap; use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; use std::collections::HashMap; +#[cfg(feature = "desktop")] +use std::sync::atomic::AtomicU32; use std::sync::atomic::{AtomicI64, AtomicU64}; use std::sync::Arc; use std::thread::{self, JoinHandle}; @@ -232,6 +234,12 @@ pub struct SequencerConfig { pub sample_rate: Arc, pub lookahead_ms: Arc, pub cc_memory: Option, + #[cfg(feature = "desktop")] + pub mouse_x: Arc, + #[cfg(feature = "desktop")] + pub mouse_y: Arc, + #[cfg(feature = "desktop")] + pub mouse_down: Arc, } #[allow(clippy::too_many_arguments)] @@ -257,6 +265,13 @@ pub fn spawn_sequencer( let audio_tx_for_thread = Arc::clone(&audio_tx); let midi_tx_for_thread = Arc::clone(&midi_tx); + #[cfg(feature = "desktop")] + let mouse_x = config.mouse_x; + #[cfg(feature = "desktop")] + let mouse_y = config.mouse_y; + #[cfg(feature = "desktop")] + let mouse_down = config.mouse_down; + let thread = thread::Builder::new() .name("sequencer".into()) .spawn(move || { @@ -277,6 +292,12 @@ pub fn spawn_sequencer( config.sample_rate, config.lookahead_ms, config.cc_memory, + #[cfg(feature = "desktop")] + mouse_x, + #[cfg(feature = "desktop")] + mouse_y, + #[cfg(feature = "desktop")] + mouse_down, ); }) .expect("Failed to spawn sequencer thread"); @@ -407,6 +428,12 @@ pub(crate) struct TickInput { pub current_time_us: i64, pub engine_time: f64, pub lookahead_secs: f64, + #[cfg(feature = "desktop")] + pub mouse_x: f64, + #[cfg(feature = "desktop")] + pub mouse_y: f64, + #[cfg(feature = "desktop")] + pub mouse_down: f64, } pub struct TimestampedCommand { @@ -591,6 +618,12 @@ impl SequencerState { input.current_time_us, input.engine_time, input.lookahead_secs, + #[cfg(feature = "desktop")] + input.mouse_x, + #[cfg(feature = "desktop")] + input.mouse_y, + #[cfg(feature = "desktop")] + input.mouse_down, ); let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired); @@ -684,6 +717,9 @@ impl SequencerState { _current_time_us: i64, engine_time: f64, lookahead_secs: f64, + #[cfg(feature = "desktop")] mouse_x: f64, + #[cfg(feature = "desktop")] mouse_y: f64, + #[cfg(feature = "desktop")] mouse_down: f64, ) -> StepResult { self.buf_audio_commands.clear(); let mut result = StepResult { @@ -746,6 +782,12 @@ impl SequencerState { fill, nudge_secs, cc_memory: self.cc_memory.clone(), + #[cfg(feature = "desktop")] + mouse_x, + #[cfg(feature = "desktop")] + mouse_y, + #[cfg(feature = "desktop")] + mouse_down, }; if let Some(script) = resolved_script { let mut trace = ExecutionTrace::default(); @@ -902,6 +944,9 @@ fn sequencer_loop( sample_rate: Arc, lookahead_ms: Arc, cc_memory: Option, + #[cfg(feature = "desktop")] mouse_x: Arc, + #[cfg(feature = "desktop")] mouse_y: Arc, + #[cfg(feature = "desktop")] mouse_down: Arc, ) { use std::sync::atomic::Ordering; @@ -943,6 +988,12 @@ fn sequencer_loop( current_time_us, engine_time, lookahead_secs, + #[cfg(feature = "desktop")] + mouse_x: f32::from_bits(mouse_x.load(Ordering::Relaxed)) as f64, + #[cfg(feature = "desktop")] + mouse_y: f32::from_bits(mouse_y.load(Ordering::Relaxed)) as f64, + #[cfg(feature = "desktop")] + mouse_down: f32::from_bits(mouse_down.load(Ordering::Relaxed)) as f64, }; let output = seq_state.tick(input); @@ -1141,6 +1192,12 @@ mod tests { current_time_us: 0, engine_time: 0.0, lookahead_secs: 0.0, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, } } @@ -1156,6 +1213,12 @@ mod tests { current_time_us: 0, engine_time: 0.0, lookahead_secs: 0.0, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, } } diff --git a/src/main.rs b/src/main.rs index 58f5d65..2cd25c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,11 +135,24 @@ fn main() -> io::Result<()> { initial_samples.extend(index); } + #[cfg(feature = "desktop")] + let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits())); + #[cfg(feature = "desktop")] + let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits())); + #[cfg(feature = "desktop")] + let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits())); + let seq_config = SequencerConfig { audio_sample_pos: Arc::clone(&audio_sample_pos), sample_rate: Arc::clone(&sample_rate_shared), lookahead_ms: Arc::clone(&lookahead_ms), cc_memory: Some(Arc::clone(&app.midi.cc_memory)), + #[cfg(feature = "desktop")] + mouse_x: Arc::clone(&mouse_x), + #[cfg(feature = "desktop")] + mouse_y: Arc::clone(&mouse_y), + #[cfg(feature = "desktop")] + mouse_down: Arc::clone(&mouse_down), }; let (sequencer, initial_audio_rx, mut midi_rx) = spawn_sequencer( diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 4bde9b5..6d82aba 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -59,6 +59,11 @@ const DOCS: &[DocEntry] = &[ ), Topic("Space & Time", include_str!("../../docs/engine_space.md")), Topic("Words & Sounds", include_str!("../../docs/engine_words.md")), + // MIDI + Section("MIDI"), + Topic("Introduction", include_str!("../../docs/midi_intro.md")), + Topic("MIDI Output", include_str!("../../docs/midi_output.md")), + Topic("MIDI Input", include_str!("../../docs/midi_input.md")), ]; pub fn topic_count() -> usize { diff --git a/src/views/render.rs b/src/views/render.rs index 689d1ad..9591a76 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -71,6 +71,12 @@ fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor, cach fill: false, nudge_secs: 0.0, cc_memory: None, + #[cfg(feature = "desktop")] + mouse_x: 0.5, + #[cfg(feature = "desktop")] + mouse_y: 0.5, + #[cfg(feature = "desktop")] + mouse_down: 0.0, }; match forth.evaluate(&script, &ctx) {