From 30dfe7372d0a4a5ff48b750a368e227b1950ab67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Wed, 18 Mar 2026 02:16:05 +0100 Subject: [PATCH] Fix: MIDI precision --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/forth/src/vm.rs | 10 +- crates/forth/src/words/sound.rs | 2 +- crates/project/src/share.rs | 2 +- docs/getting-started/big_picture.md | 4 +- plugins/cagire-plugins/Cargo.toml | 2 +- src/app/persistence.rs | 2 +- src/bin/desktop/main.rs | 60 +------- src/engine/audio.rs | 12 +- src/engine/dispatcher.rs | 23 ++-- src/engine/mod.rs | 4 +- src/engine/sequencer.rs | 24 +--- src/init.rs | 9 +- src/input/engine_page.rs | 12 +- src/input/modal.rs | 2 +- src/input/sample_explorer.rs | 2 +- src/main.rs | 56 +------- src/midi.rs | 204 ++++++++++++++++------------ src/state/audio.rs | 2 + src/views/engine_view.rs | 10 +- tests/forth/sound.rs | 10 +- tests/forth/temporal.rs | 10 +- website/src/pages/docs.astro | 2 +- 24 files changed, 198 insertions(+), 272 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a07b06..44bce5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1824,8 +1824,8 @@ dependencies = [ [[package]] name = "doux" -version = "0.0.14" -source = "git+https://github.com/sova-org/doux?tag=v0.0.14#f0de4f4047adfced8fb2116edd3b33d260ba75c8" +version = "0.0.15" +source = "git+https://github.com/sova-org/doux?tag=v0.0.15#1f11f795b877d9c15f65d88eb5576e8149092b17" dependencies = [ "arc-swap", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8e93a29..eb4922f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ cagire-forth = { path = "crates/forth" } cagire-markdown = { path = "crates/markdown" } cagire-project = { path = "crates/project" } cagire-ratatui = { path = "crates/ratatui" } -doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.14", features = ["native", "soundfont"] } +doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.15", features = ["native", "soundfont"] } rusty_link = "0.4" ratatui = "0.30" crossterm = "0.29" diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 97b0f1e..027353a 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1187,7 +1187,7 @@ impl Forth { } let dur = steps * ctx.step_duration(); cmd.set_param("fit", Value::Float(dur, None)); - cmd.set_param("dur", Value::Float(dur, None)); + cmd.set_param("gate", Value::Float(steps, None)); } Op::At => { @@ -1753,7 +1753,7 @@ fn cmd_param_float(cmd: &CmdRegister, name: &str) -> Option { fn is_tempo_scaled_param(name: &str) -> bool { matches!( name, - "attack" | "decay" | "release" | "envdelay" | "hold" | "chorusdelay" + "attack" | "decay" | "release" | "envdelay" | "hold" | "chorusdelay" | "gate" ) } @@ -1769,7 +1769,7 @@ fn emit_output( let mut out = String::with_capacity(128); out.push('/'); - let has_dur = params.iter().any(|(k, _)| *k == "dur"); + let has_gate = params.iter().any(|(k, _)| *k == "gate"); let has_release = params.iter().any(|(k, _)| *k == "release"); let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime"); @@ -1806,11 +1806,11 @@ fn emit_output( let _ = write!(&mut out, "delta/{delta_ticks}"); } - if !has_dur { + if !has_gate { if !out.ends_with('/') { out.push('/'); } - let _ = write!(&mut out, "dur/{}", step_duration * 4.0); + let _ = write!(&mut out, "gate/{}", step_duration * 4.0); } if !has_release { diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index eb5e0e0..41505f6 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -131,7 +131,7 @@ pub(super) const WORDS: &[Word] = &[ aliases: &[], category: "Sample", stack: "(v.. --)", - desc: "Set duration", + desc: "Set MIDI note duration (for audio, use gate)", example: "0.5 dur", compile: Param, varargs: true, diff --git a/crates/project/src/share.rs b/crates/project/src/share.rs index 51ce2dc..ebcea51 100644 --- a/crates/project/src/share.rs +++ b/crates/project/src/share.rs @@ -164,7 +164,7 @@ mod tests { for i in 0..16 { pattern.steps[i] = Step { active: true, - script: format!("kick {i} note 0.5 dur"), + script: format!("kick {i} note 0.5 gate"), source: None, name: Some(format!("step_{i}")), }; diff --git a/docs/getting-started/big_picture.md b/docs/getting-started/big_picture.md index aa82ace..ee53321 100644 --- a/docs/getting-started/big_picture.md +++ b/docs/getting-started/big_picture.md @@ -51,7 +51,7 @@ Cagire includes a complete synthesis and sampling engine. No external software i ```forth ;; sawtooth wave + lowpass filter with envelope + chorus + reverb -100 199 freq saw sound 250 8000 0.01 0.3 0.5 0.3 env lpf 0.2 chorus 0.8 verb 2 dur . +100 199 freq saw sound 250 8000 0.01 0.3 0.5 0.3 env lpf 0.2 chorus 0.8 verb 2 gate . ``` ```forth @@ -61,7 +61,7 @@ Cagire includes a complete synthesis and sampling engine. No external software i ```forth ;; white noise + sine wave + envelope = percussion -white sine sound 100 freq 0.5 decay 2 dur . +white sine sound 100 freq 0.5 decay 2 gate . ``` ```forth diff --git a/plugins/cagire-plugins/Cargo.toml b/plugins/cagire-plugins/Cargo.toml index 2fe055f..a3615e4 100644 --- a/plugins/cagire-plugins/Cargo.toml +++ b/plugins/cagire-plugins/Cargo.toml @@ -14,7 +14,7 @@ cagire = { path = "../..", default-features = false, features = ["block-renderer cagire-forth = { path = "../../crates/forth" } cagire-project = { path = "../../crates/project" } cagire-ratatui = { path = "../../crates/ratatui" } -doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.14", features = ["native", "soundfont"] } +doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.15", features = ["native", "soundfont"] } 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" diff --git a/src/app/persistence.rs b/src/app/persistence.rs index 009f1c4..ff3722e 100644 --- a/src/app/persistence.rs +++ b/src/app/persistence.rs @@ -52,7 +52,7 @@ impl App { output_devices: { let outputs = crate::midi::list_midi_outputs(); self.midi - .selected_outputs + .selected_outputs() .iter() .map(|opt| { opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone())) diff --git a/src/bin/desktop/main.rs b/src/bin/desktop/main.rs index 650bebc..92712d1 100644 --- a/src/bin/desktop/main.rs +++ b/src/bin/desktop/main.rs @@ -19,7 +19,7 @@ use soft_ratatui::embedded_graphics_unicodefonts::{ use soft_ratatui::{EmbeddedGraphics, SoftBackend}; use cagire::engine::{ - build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, + build_stream, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer, SequencerHandle, SpectrumBuffer, }; use cagire::init::{init, InitArgs}; @@ -27,7 +27,6 @@ use cagire::input::{handle_key, handle_mouse, InputContext, InputResult}; use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState}; use cagire::settings::Settings; use cagire::views; -use crossbeam_channel::Receiver; #[derive(Parser)] #[command(name = "cagire-desktop", about = "Cagire desktop application")] @@ -160,7 +159,6 @@ struct CagireDesktop { _stream: Option, _input_stream: Option, _analysis_handle: Option, - midi_rx: Receiver, device_lost: Arc, stream_error_rx: crossbeam_channel::Receiver, current_font: FontChoice, @@ -207,7 +205,6 @@ impl CagireDesktop { _stream: b.stream, _input_stream: b.input_stream, _analysis_handle: b.analysis_handle, - midi_rx: b.midi_rx, device_lost: b.device_lost, stream_error_rx: b.stream_error_rx, current_font, @@ -237,7 +234,6 @@ 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(), @@ -288,6 +284,7 @@ impl CagireDesktop { self.app.audio.config.sample_rate = info.sample_rate; self.app.audio.config.host_name = info.host_name; self.app.audio.config.channels = info.channels; + self.app.audio.config.input_sample_rate = info.input_sample_rate; self.sample_rate_shared .store(info.sample_rate as u32, Ordering::Relaxed); self.app.audio.error = None; @@ -414,59 +411,6 @@ impl eframe::App for CagireDesktop { self.app.flush_dirty_patterns(&sequencer.cmd_tx); self.app.flush_dirty_script(&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/audio.rs b/src/engine/audio.rs index ce86dfb..0abb3ac 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -282,6 +282,7 @@ pub struct AudioStreamInfo { pub sample_rate: f32, pub host_name: String, pub channels: u16, + pub input_sample_rate: Option, } #[cfg(feature = "cli")] @@ -367,10 +368,16 @@ pub fn build_stream( dev }); - let input_channels: usize = input_device + let input_config = input_device + .as_ref() + .and_then(|dev| dev.default_input_config().ok()); + let input_channels: usize = input_config .as_ref() - .and_then(|dev| dev.default_input_config().ok()) .map_or(0, |cfg| cfg.channels() as usize); + let input_sample_rate = input_config.and_then(|cfg| { + let rate = cfg.sample_rate() as f32; + (rate != sample_rate).then_some(rate) + }); engine.input_channels = input_channels; @@ -519,6 +526,7 @@ pub fn build_stream( sample_rate, host_name, channels: effective_channels, + input_sample_rate, }; Ok((stream, input_stream, info, analysis_handle, registry)) } diff --git a/src/engine/dispatcher.rs b/src/engine/dispatcher.rs index 0ff6ba3..864a7fe 100644 --- a/src/engine/dispatcher.rs +++ b/src/engine/dispatcher.rs @@ -1,14 +1,13 @@ -use arc_swap::ArcSwap; -use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; +use crossbeam_channel::{Receiver, RecvTimeoutError}; use std::cmp::Ordering; use std::collections::BinaryHeap; -use std::sync::Arc; use std::time::Duration; use super::link::LinkState; use super::realtime::{precise_sleep_us, set_realtime_priority, warn_no_rt}; use super::sequencer::MidiCommand; use super::timing::SyncTime; +use crate::midi::{MidiOutputPorts, MAX_MIDI_OUTPUTS}; /// A MIDI command scheduled for dispatch at a specific time. #[derive(Clone)] @@ -46,13 +45,13 @@ impl Eq for TimedMidiCommand {} const SPIN_THRESHOLD_US: SyncTime = 100; -/// Dispatcher loop — handles MIDI timing only. +/// Dispatcher loop — handles MIDI timing and sends directly to MIDI ports. /// Audio commands bypass the dispatcher entirely and go straight to doux's /// sample-accurate scheduler via the audio thread channel. pub fn dispatcher_loop( cmd_rx: Receiver, - midi_tx: Arc>>, - link: Arc, + ports: MidiOutputPorts, + link: &LinkState, ) { let has_rt = set_realtime_priority(); if !has_rt { @@ -84,8 +83,8 @@ pub fn dispatcher_loop( while let Some(cmd) = queue.peek() { if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US { let cmd = queue.pop().expect("pop after peek"); - wait_until_dispatch(cmd.target_time_us, &link, has_rt); - dispatch_midi(cmd.command, &midi_tx); + wait_until_dispatch(cmd.target_time_us, link, has_rt); + dispatch_midi(cmd.command, &ports); } else { break; } @@ -106,15 +105,15 @@ fn wait_until_dispatch(target_us: SyncTime, link: &LinkState, has_rt: bool) { } } -fn dispatch_midi(cmd: MidiDispatch, midi_tx: &Arc>>) { +fn dispatch_midi(cmd: MidiDispatch, ports: &MidiOutputPorts) { match cmd { MidiDispatch::Send(midi_cmd) => { - let _ = midi_tx.load().try_send(midi_cmd); + ports.send_command(&midi_cmd); } MidiDispatch::FlushAll => { - for dev in 0..4u8 { + for dev in 0..MAX_MIDI_OUTPUTS as u8 { for chan in 0..16u8 { - let _ = midi_tx.load().try_send(MidiCommand::CC { + ports.send_command(&MidiCommand::CC { device: dev, channel: chan, cc: 123, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c8aa0b2..62850d2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -21,13 +21,13 @@ pub use audio::AudioStreamInfo; pub use link::LinkState; pub use sequencer::{ - spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand, + spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot, }; // Re-exported for the plugin crate (not used by the terminal binary). #[allow(unused_imports)] pub use sequencer::{ - parse_midi_command, SequencerState, SharedSequencerState, TickInput, TickOutput, + parse_midi_command, MidiCommand, SequencerState, SharedSequencerState, TickInput, TickOutput, TimestampedCommand, }; diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index ea21a1e..3a4a3b9 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -261,7 +261,6 @@ impl SequencerSnapshot { pub struct SequencerHandle { pub cmd_tx: Sender, pub audio_tx: Arc>>, - pub midi_tx: Arc>>, shared_state: Arc>, thread: JoinHandle<()>, } @@ -278,12 +277,6 @@ impl SequencerHandle { new_rx } - pub fn swap_midi_channel(&self) -> Receiver { - let (new_tx, new_rx) = bounded::(256); - self.midi_tx.store(Arc::new(new_tx)); - new_rx - } - pub fn shutdown(self) { let _ = self.cmd_tx.send(SeqCommand::Shutdown); if let Err(e) = self.thread.join() { @@ -356,16 +349,11 @@ pub fn spawn_sequencer( live_keys: Arc, nudge_us: Arc, config: SequencerConfig, -) -> ( - SequencerHandle, - Receiver, - Receiver, -) { + midi_ports: crate::midi::MidiOutputPorts, +) -> (SequencerHandle, Receiver) { let (cmd_tx, cmd_rx) = bounded::(64); let (audio_tx, audio_rx) = unbounded::(); - let (midi_tx, midi_rx) = bounded::(256); let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); - let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx)); // Dispatcher channel — MIDI only (unbounded to avoid blocking the scheduler) let (dispatch_tx, dispatch_rx) = unbounded::(); @@ -382,13 +370,12 @@ pub fn spawn_sequencer( #[cfg(feature = "desktop")] let mouse_down = config.mouse_down; - // Spawn dispatcher thread (MIDI only — audio goes direct to doux) + // Spawn dispatcher thread — sends MIDI directly to ports (no UI-thread hop) let dispatcher_link = Arc::clone(&link); - let dispatcher_midi_tx = Arc::clone(&midi_tx); thread::Builder::new() .name("cagire-dispatcher".into()) .spawn(move || { - dispatcher_loop(dispatch_rx, dispatcher_midi_tx, dispatcher_link); + dispatcher_loop(dispatch_rx, midi_ports, &dispatcher_link); }) .expect("Failed to spawn dispatcher thread"); @@ -424,11 +411,10 @@ pub fn spawn_sequencer( let handle = SequencerHandle { cmd_tx, audio_tx, - midi_tx, shared_state, thread, }; - (handle, audio_rx, midi_rx) + (handle, audio_rx) } struct PatternCache { diff --git a/src/init.rs b/src/init.rs index 07162c1..78fa2ba 100644 --- a/src/init.rs +++ b/src/init.rs @@ -2,13 +2,12 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; -use crossbeam_channel::Receiver; use doux::EngineMetrics; use crate::app::App; use crate::engine::{ build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioStreamConfig, - LinkState, MidiCommand, PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle, + LinkState, PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer, }; use crate::midi; @@ -42,7 +41,6 @@ pub struct Init { pub stream: Option, pub input_stream: Option, pub analysis_handle: Option, - pub midi_rx: Receiver, pub device_lost: Arc, pub stream_error_rx: crossbeam_channel::Receiver, #[cfg(feature = "desktop")] @@ -192,13 +190,14 @@ pub fn init(args: InitArgs) -> Init { mouse_down: Arc::clone(&mouse_down), }; - let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer( + let (sequencer, initial_audio_rx) = spawn_sequencer( Arc::clone(&link), Arc::clone(&playing), settings.link.quantum, Arc::clone(&app.live_keys), Arc::clone(&nudge_us), seq_config, + app.midi.output_ports.clone(), ); let device_lost = Arc::new(AtomicBool::new(false)); @@ -228,6 +227,7 @@ pub fn init(args: InitArgs) -> Init { app.audio.config.sample_rate = info.sample_rate; app.audio.config.host_name = info.host_name; app.audio.config.channels = info.channels; + app.audio.config.input_sample_rate = info.input_sample_rate; sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed); app.audio.sample_registry = Some(Arc::clone(®istry)); @@ -267,7 +267,6 @@ pub fn init(args: InitArgs) -> Init { stream, input_stream, analysis_handle, - midi_rx, device_lost, stream_error_rx, #[cfg(feature = "desktop")] diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index b56d96a..2269b74 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -48,22 +48,20 @@ pub(crate) fn cycle_link_setting(ctx: &mut InputContext, right: bool) { pub(crate) fn cycle_midi_output(ctx: &mut InputContext, right: bool) { let slot = ctx.app.audio.midi_output_slot; let all_devices = crate::midi::list_midi_outputs(); + let selected = ctx.app.midi.selected_outputs(); let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices .iter() .enumerate() .filter(|(idx, _)| { - ctx.app.midi.selected_outputs[slot] == Some(*idx) - || !ctx - .app - .midi - .selected_outputs + selected[slot] == Some(*idx) + || !selected .iter() .enumerate() .any(|(s, sel)| s != slot && *sel == Some(*idx)) }) .collect(); let total_options = available.len() + 1; - let current_pos = ctx.app.midi.selected_outputs[slot] + let current_pos = selected[slot] .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) .map(|p| p + 1) .unwrap_or(0); @@ -299,7 +297,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char('r') => ctx.dispatch(AppCommand::ResetPeakVoices), KeyCode::Char('t') if !ctx.app.plugin_mode => { let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate { - cmd: "/sound/sine/dur/0.5/decay/0.2".into(), + cmd: "/sound/sine/gate/0.5/decay/0.2".into(), tick: None, }); } diff --git a/src/input/modal.rs b/src/input/modal.rs index cf26980..6e1256c 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -790,7 +790,7 @@ fn execute_palette_entry( .audio_tx .load() .send(crate::engine::AudioCommand::Evaluate { - cmd: "/sound/sine/dur/0.5/decay/0.2".into(), + cmd: "/sound/sine/gate/0.5/decay/0.2".into(), tick: None, }); } diff --git a/src/input/sample_explorer.rs b/src/input/sample_explorer.rs index 0770ff7..136614c 100644 --- a/src/input/sample_explorer.rs +++ b/src/input/sample_explorer.rs @@ -66,7 +66,7 @@ pub(super) fn handle_sample_explorer(ctx: &mut InputContext, key: KeyEvent) -> I TreeLineKind::File => { let folder = &entry.folder; let idx = entry.index; - let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/dur/1"); + let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/gate/1"); let _ = ctx .audio_tx .load() diff --git a/src/main.rs b/src/main.rs index 9619061..8397b96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,7 +101,6 @@ fn main() -> io::Result<()> { let mut _stream = b.stream; let mut _input_stream = b.input_stream; let mut _analysis_handle = b.analysis_handle; - let mut midi_rx = b.midi_rx; let device_lost = b.device_lost; let mut stream_error_rx = b.stream_error_rx; @@ -125,7 +124,6 @@ fn main() -> io::Result<()> { _analysis_handle = None; let new_audio_rx = sequencer.swap_audio_channel(); - midi_rx = sequencer.swap_midi_channel(); let new_config = AudioStreamConfig { output_device: app.audio.config.output_device.clone(), @@ -176,6 +174,7 @@ fn main() -> io::Result<()> { app.audio.config.sample_rate = info.sample_rate; app.audio.config.host_name = info.host_name; app.audio.config.channels = info.channels; + app.audio.config.input_sample_rate = info.input_sample_rate; sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed); app.audio.error = None; app.audio.sample_registry = Some(Arc::clone(®istry)); @@ -233,59 +232,6 @@ fn main() -> io::Result<()> { } was_playing = app.playback.playing; - while let Ok(midi_cmd) = midi_rx.try_recv() { - match midi_cmd { - engine::MidiCommand::NoteOn { - device, - channel, - note, - velocity, - } => { - app.midi.send_note_on(device, channel, note, velocity); - } - engine::MidiCommand::NoteOff { - device, - channel, - note, - } => { - app.midi.send_note_off(device, channel, note); - } - engine::MidiCommand::CC { - device, - channel, - cc, - value, - } => { - app.midi.send_cc(device, channel, cc, value); - } - engine::MidiCommand::PitchBend { - device, - channel, - value, - } => { - app.midi.send_pitch_bend(device, channel, value); - } - engine::MidiCommand::Pressure { - device, - channel, - value, - } => { - app.midi.send_pressure(device, channel, value); - } - engine::MidiCommand::ProgramChange { - device, - channel, - program, - } => { - app.midi.send_program_change(device, channel, program); - } - engine::MidiCommand::Clock { device } => app.midi.send_realtime(device, 0xF8), - engine::MidiCommand::Start { device } => app.midi.send_realtime(device, 0xFA), - engine::MidiCommand::Stop { device } => app.midi.send_realtime(device, 0xFC), - engine::MidiCommand::Continue { device } => app.midi.send_realtime(device, 0xFB), - } - } - { app.metrics.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize; app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices); diff --git a/src/midi.rs b/src/midi.rs index 99f7ae9..c26f967 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -1,12 +1,124 @@ use parking_lot::Mutex; use std::sync::Arc; +use crate::engine::sequencer::MidiCommand; use crate::model::CcAccess; pub const MAX_MIDI_OUTPUTS: usize = 4; pub const MAX_MIDI_INPUTS: usize = 4; pub const MAX_MIDI_DEVICES: usize = 4; +/// Thread-safe MIDI output connections shared between the dispatcher (sending) +/// and the UI thread (connect/disconnect). The dispatcher calls `send_command` +/// directly after precise timing, eliminating the ~16ms jitter from UI polling. +#[derive(Clone)] +pub struct MidiOutputPorts { + #[cfg(feature = "cli")] + conns: Arc; MAX_MIDI_OUTPUTS]>>, + pub selected: Arc; MAX_MIDI_OUTPUTS]>>, +} + +impl MidiOutputPorts { + pub fn new() -> Self { + Self { + #[cfg(feature = "cli")] + conns: Arc::new(Mutex::new([None, None, None, None])), + selected: Arc::new(Mutex::new([None; MAX_MIDI_OUTPUTS])), + } + } + + #[cfg(feature = "cli")] + pub fn connect(&self, slot: usize, port_index: usize) -> Result<(), String> { + if slot >= MAX_MIDI_OUTPUTS { + return Err("Invalid output slot".to_string()); + } + let midi_out = + midir::MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?; + let ports = midi_out.ports(); + let port = ports.get(port_index).ok_or("MIDI output port not found")?; + let conn = midi_out + .connect(port, &format!("cagire-midi-out-{slot}")) + .map_err(|e| e.to_string())?; + self.conns.lock()[slot] = Some(conn); + self.selected.lock()[slot] = Some(port_index); + Ok(()) + } + + #[cfg(not(feature = "cli"))] + pub fn connect(&self, _slot: usize, _port_index: usize) -> Result<(), String> { + Ok(()) + } + + #[cfg(feature = "cli")] + pub fn disconnect(&self, slot: usize) { + if slot < MAX_MIDI_OUTPUTS { + self.conns.lock()[slot] = None; + self.selected.lock()[slot] = None; + } + } + + #[cfg(not(feature = "cli"))] + pub fn disconnect(&self, slot: usize) { + if slot < MAX_MIDI_OUTPUTS { + self.selected.lock()[slot] = None; + } + } + + #[cfg(feature = "cli")] + fn send_message(&self, device: u8, message: &[u8]) { + let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1); + let mut conns = self.conns.lock(); + if let Some(conn) = &mut conns[slot] { + let _ = conn.send(message); + } + } + + #[cfg(not(feature = "cli"))] + fn send_message(&self, _device: u8, _message: &[u8]) {} + + /// Send a MidiCommand directly — called by the dispatcher thread. + pub fn send_command(&self, cmd: &MidiCommand) { + match cmd { + MidiCommand::NoteOn { device, channel, note, velocity } => { + let status = 0x90 | (channel & 0x0F); + self.send_message(*device, &[status, note & 0x7F, velocity & 0x7F]); + } + MidiCommand::NoteOff { device, channel, note } => { + let status = 0x80 | (channel & 0x0F); + self.send_message(*device, &[status, note & 0x7F, 0]); + } + MidiCommand::CC { device, channel, cc, value } => { + let status = 0xB0 | (channel & 0x0F); + self.send_message(*device, &[status, cc & 0x7F, value & 0x7F]); + } + MidiCommand::PitchBend { device, channel, value } => { + let status = 0xE0 | (channel & 0x0F); + let lsb = (value & 0x7F) as u8; + let msb = ((value >> 7) & 0x7F) as u8; + self.send_message(*device, &[status, lsb, msb]); + } + MidiCommand::Pressure { device, channel, value } => { + let status = 0xD0 | (channel & 0x0F); + self.send_message(*device, &[status, value & 0x7F]); + } + MidiCommand::ProgramChange { device, channel, program } => { + let status = 0xC0 | (channel & 0x0F); + self.send_message(*device, &[status, program & 0x7F]); + } + MidiCommand::Clock { device } => self.send_message(*device, &[0xF8]), + MidiCommand::Start { device } => self.send_message(*device, &[0xFA]), + MidiCommand::Stop { device } => self.send_message(*device, &[0xFC]), + MidiCommand::Continue { device } => self.send_message(*device, &[0xFB]), + } + } +} + +impl Default for MidiOutputPorts { + fn default() -> Self { + Self::new() + } +} + /// Raw CC memory storage type type CcMemoryInner = Arc>; @@ -95,11 +207,9 @@ pub fn list_midi_inputs() -> Vec { } pub struct MidiState { - #[cfg(feature = "cli")] - output_conns: [Option; MAX_MIDI_OUTPUTS], #[cfg(feature = "cli")] input_conns: [Option>; MAX_MIDI_INPUTS], - pub selected_outputs: [Option; MAX_MIDI_OUTPUTS], + pub output_ports: MidiOutputPorts, pub selected_inputs: [Option; MAX_MIDI_INPUTS], pub cc_memory: CcMemory, } @@ -113,51 +223,24 @@ impl Default for MidiState { impl MidiState { pub fn new() -> Self { Self { - #[cfg(feature = "cli")] - output_conns: [None, None, None, None], #[cfg(feature = "cli")] input_conns: [None, None, None, None], - selected_outputs: [None; MAX_MIDI_OUTPUTS], + output_ports: MidiOutputPorts::new(), selected_inputs: [None; MAX_MIDI_INPUTS], cc_memory: CcMemory::new(), } } - #[cfg(feature = "cli")] - pub fn connect_output(&mut self, slot: usize, port_index: usize) -> Result<(), String> { - if slot >= MAX_MIDI_OUTPUTS { - return Err("Invalid output slot".to_string()); - } - let midi_out = - midir::MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?; - let ports = midi_out.ports(); - let port = ports.get(port_index).ok_or("MIDI output port not found")?; - let conn = midi_out - .connect(port, &format!("cagire-midi-out-{slot}")) - .map_err(|e| e.to_string())?; - self.output_conns[slot] = Some(conn); - self.selected_outputs[slot] = Some(port_index); - Ok(()) + pub fn connect_output(&self, slot: usize, port_index: usize) -> Result<(), String> { + self.output_ports.connect(slot, port_index) } - #[cfg(not(feature = "cli"))] - pub fn connect_output(&mut self, _slot: usize, _port_index: usize) -> Result<(), String> { - Ok(()) + pub fn disconnect_output(&self, slot: usize) { + self.output_ports.disconnect(slot); } - #[cfg(feature = "cli")] - pub fn disconnect_output(&mut self, slot: usize) { - if slot < MAX_MIDI_OUTPUTS { - self.output_conns[slot] = None; - self.selected_outputs[slot] = None; - } - } - - #[cfg(not(feature = "cli"))] - pub fn disconnect_output(&mut self, slot: usize) { - if slot < MAX_MIDI_OUTPUTS { - self.selected_outputs[slot] = None; - } + pub fn selected_outputs(&self) -> [Option; MAX_MIDI_OUTPUTS] { + *self.output_ports.selected.lock() } #[cfg(feature = "cli")] @@ -215,51 +298,4 @@ impl MidiState { self.selected_inputs[slot] = None; } } - - #[cfg(feature = "cli")] - fn send_message(&mut self, device: u8, message: &[u8]) { - let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1); - if let Some(conn) = &mut self.output_conns[slot] { - let _ = conn.send(message); - } - } - - #[cfg(not(feature = "cli"))] - fn send_message(&mut self, _device: u8, _message: &[u8]) {} - - pub fn send_note_on(&mut self, device: u8, channel: u8, note: u8, velocity: u8) { - let status = 0x90 | (channel & 0x0F); - self.send_message(device, &[status, note & 0x7F, velocity & 0x7F]); - } - - pub fn send_note_off(&mut self, device: u8, channel: u8, note: u8) { - let status = 0x80 | (channel & 0x0F); - self.send_message(device, &[status, note & 0x7F, 0]); - } - - pub fn send_cc(&mut self, device: u8, channel: u8, cc: u8, value: u8) { - let status = 0xB0 | (channel & 0x0F); - self.send_message(device, &[status, cc & 0x7F, value & 0x7F]); - } - - pub fn send_pitch_bend(&mut self, device: u8, channel: u8, value: u16) { - let status = 0xE0 | (channel & 0x0F); - let lsb = (value & 0x7F) as u8; - let msb = ((value >> 7) & 0x7F) as u8; - self.send_message(device, &[status, lsb, msb]); - } - - pub fn send_pressure(&mut self, device: u8, channel: u8, value: u8) { - let status = 0xD0 | (channel & 0x0F); - self.send_message(device, &[status, value & 0x7F]); - } - - pub fn send_program_change(&mut self, device: u8, channel: u8, program: u8) { - let status = 0xC0 | (channel & 0x0F); - self.send_message(device, &[status, program & 0x7F]); - } - - pub fn send_realtime(&mut self, device: u8, msg: u8) { - self.send_message(device, &[msg]); - } } diff --git a/src/state/audio.rs b/src/state/audio.rs index 067a33e..b7cd64a 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -127,6 +127,7 @@ pub struct AudioConfig { pub lissajous_trails: bool, pub spectrum_mode: SpectrumMode, pub spectrum_peaks: bool, + pub input_sample_rate: Option, } impl Default for AudioConfig { @@ -154,6 +155,7 @@ impl Default for AudioConfig { lissajous_trails: false, spectrum_mode: SpectrumMode::default(), spectrum_peaks: false, + input_sample_rate: None, } } } diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index ffc43f8..8366243 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -519,6 +519,14 @@ fn render_status(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { label_style, ))); + if let Some(input_sr) = app.audio.config.input_sample_rate { + let warn_style = Style::new().fg(theme.flash.error_fg); + lines.push(Line::from(Span::styled( + format!(" Input {input_sr:.0} Hz !!"), + warn_style, + ))); + } + if !app.plugin_mode { // Host lines.push(Line::from(Span::styled( @@ -946,7 +954,7 @@ fn render_midi_output(frame: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = Vec::new(); for slot in 0..4 { let is_focused = section_focused && app.audio.midi_output_slot == slot; - let display = midi_display_name(&midi_outputs, app.midi.selected_outputs[slot]); + let display = midi_display_name(&midi_outputs, app.midi.selected_outputs()[slot]); let prefix = if is_focused { "> " } else { " " }; let style = if is_focused { highlight } else { label_style }; let val_style = if is_focused { highlight } else { value_style }; diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index e01d583..017a628 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -21,9 +21,9 @@ fn with_params() { } #[test] -fn auto_dur() { +fn auto_gate() { let outputs = expect_outputs(r#""kick" snd ."#, 1); - assert!(outputs[0].contains("dur/")); + assert!(outputs[0].contains("gate/")); } #[test] @@ -94,7 +94,7 @@ fn param_only_emit() { assert!(outputs[0].contains("voice/0")); assert!(outputs[0].contains("freq/880")); assert!(!outputs[0].contains("sound/")); - assert!(outputs[0].contains("dur/")); + assert!(outputs[0].contains("gate/")); assert!(!outputs[0].contains("delaytime/")); } @@ -141,8 +141,8 @@ fn polyphonic_with_at() { #[test] fn explicit_dur_zero_is_infinite() { - let outputs = expect_outputs("880 freq 0 dur .", 1); - assert!(outputs[0].contains("dur/0")); + let outputs = expect_outputs("880 freq 0 gate .", 1); + assert!(outputs[0].contains("gate/0")); } #[test] diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index b5911bc..bec08e3 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -21,10 +21,10 @@ fn get_deltas(outputs: &[String]) -> Vec { .collect() } -fn get_durs(outputs: &[String]) -> Vec { +fn get_gates(outputs: &[String]) -> Vec { outputs .iter() - .map(|o| parse_params(o).get("dur").copied().unwrap_or(0.0)) + .map(|o| parse_params(o).get("gate").copied().unwrap_or(0.0)) .collect() } @@ -88,10 +88,10 @@ fn alternating_sounds() { } #[test] -fn dur_is_step_duration() { +fn gate_is_step_duration() { let outputs = expect_outputs(r#""kick" snd ."#, 1); - let durs = get_durs(&outputs); - assert!(approx_eq(durs[0], 0.5), "dur should be 4 * step_duration (0.5), got {}", durs[0]); + let gates = get_gates(&outputs); + assert!(approx_eq(gates[0], 0.5), "gate should be 4 * step_duration (0.5), got {}", gates[0]); } #[test] diff --git a/website/src/pages/docs.astro b/website/src/pages/docs.astro index b145971..160ef9b 100644 --- a/website/src/pages/docs.astro +++ b/website/src/pages/docs.astro @@ -13,7 +13,7 @@ const SOUNDS = new Set([ const PARAMS = new Set([ 'freq', 'note', 'gain', 'decay', 'attack', 'release', 'lpf', 'hpf', 'bpf', 'verb', 'delay', 'pan', 'orbit', 'harmonics', 'distort', 'speed', 'voice', - 'dur', 'sustain', 'delaytime', 'delayfb', 'chorus', 'phaser', 'flanger', + 'dur', 'gate', 'sustain', 'delaytime', 'delayfb', 'chorus', 'phaser', 'flanger', 'crush', 'fold', 'wrap', 'resonance', 'begin', 'end', 'velocity', 'chan', 'dev', 'ccnum', 'ccout', 'bend', 'pressure', 'program', 'tilt', 'slope', 'sub_gain', 'sub_oct', 'feedback', 'depth', 'sweep', 'comb', 'damping',