Fix: MIDI precision

This commit is contained in:
2026-03-18 02:16:05 +01:00
parent faf541e536
commit 30dfe7372d
24 changed files with 198 additions and 272 deletions

4
Cargo.lock generated
View File

@@ -1824,8 +1824,8 @@ dependencies = [
[[package]] [[package]]
name = "doux" name = "doux"
version = "0.0.14" version = "0.0.15"
source = "git+https://github.com/sova-org/doux?tag=v0.0.14#f0de4f4047adfced8fb2116edd3b33d260ba75c8" source = "git+https://github.com/sova-org/doux?tag=v0.0.15#1f11f795b877d9c15f65d88eb5576e8149092b17"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"clap", "clap",

View File

@@ -52,7 +52,7 @@ cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" } cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" } cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" } 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" rusty_link = "0.4"
ratatui = "0.30" ratatui = "0.30"
crossterm = "0.29" crossterm = "0.29"

View File

@@ -1187,7 +1187,7 @@ impl Forth {
} }
let dur = steps * ctx.step_duration(); let dur = steps * ctx.step_duration();
cmd.set_param("fit", Value::Float(dur, None)); 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 => { Op::At => {
@@ -1753,7 +1753,7 @@ fn cmd_param_float(cmd: &CmdRegister, name: &str) -> Option<f64> {
fn is_tempo_scaled_param(name: &str) -> bool { fn is_tempo_scaled_param(name: &str) -> bool {
matches!( matches!(
name, 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); let mut out = String::with_capacity(128);
out.push('/'); 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 has_release = params.iter().any(|(k, _)| *k == "release");
let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime"); let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime");
@@ -1806,11 +1806,11 @@ fn emit_output(
let _ = write!(&mut out, "delta/{delta_ticks}"); let _ = write!(&mut out, "delta/{delta_ticks}");
} }
if !has_dur { if !has_gate {
if !out.ends_with('/') { if !out.ends_with('/') {
out.push('/'); out.push('/');
} }
let _ = write!(&mut out, "dur/{}", step_duration * 4.0); let _ = write!(&mut out, "gate/{}", step_duration * 4.0);
} }
if !has_release { if !has_release {

View File

@@ -131,7 +131,7 @@ pub(super) const WORDS: &[Word] = &[
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(v.. --)", stack: "(v.. --)",
desc: "Set duration", desc: "Set MIDI note duration (for audio, use gate)",
example: "0.5 dur", example: "0.5 dur",
compile: Param, compile: Param,
varargs: true, varargs: true,

View File

@@ -164,7 +164,7 @@ mod tests {
for i in 0..16 { for i in 0..16 {
pattern.steps[i] = Step { pattern.steps[i] = Step {
active: true, active: true,
script: format!("kick {i} note 0.5 dur"), script: format!("kick {i} note 0.5 gate"),
source: None, source: None,
name: Some(format!("step_{i}")), name: Some(format!("step_{i}")),
}; };

View File

@@ -51,7 +51,7 @@ Cagire includes a complete synthesis and sampling engine. No external software i
```forth ```forth
;; sawtooth wave + lowpass filter with envelope + chorus + reverb ;; 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 ```forth
@@ -61,7 +61,7 @@ Cagire includes a complete synthesis and sampling engine. No external software i
```forth ```forth
;; white noise + sine wave + envelope = percussion ;; 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 ```forth

View File

@@ -14,7 +14,7 @@ cagire = { path = "../..", default-features = false, features = ["block-renderer
cagire-forth = { path = "../../crates/forth" } cagire-forth = { path = "../../crates/forth" }
cagire-project = { path = "../../crates/project" } cagire-project = { path = "../../crates/project" }
cagire-ratatui = { path = "../../crates/ratatui" } 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 = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" }
egui_ratatui = "2.1" egui_ratatui = "2.1"

View File

@@ -52,7 +52,7 @@ impl App {
output_devices: { output_devices: {
let outputs = crate::midi::list_midi_outputs(); let outputs = crate::midi::list_midi_outputs();
self.midi self.midi
.selected_outputs .selected_outputs()
.iter() .iter()
.map(|opt| { .map(|opt| {
opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone())) opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone()))

View File

@@ -19,7 +19,7 @@ use soft_ratatui::embedded_graphics_unicodefonts::{
use soft_ratatui::{EmbeddedGraphics, SoftBackend}; use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::engine::{ use cagire::engine::{
build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, build_stream, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer,
SequencerHandle, SpectrumBuffer, SequencerHandle, SpectrumBuffer,
}; };
use cagire::init::{init, InitArgs}; 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::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState};
use cagire::settings::Settings; use cagire::settings::Settings;
use cagire::views; use cagire::views;
use crossbeam_channel::Receiver;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "cagire-desktop", about = "Cagire desktop application")] #[command(name = "cagire-desktop", about = "Cagire desktop application")]
@@ -160,7 +159,6 @@ struct CagireDesktop {
_stream: Option<cpal::Stream>, _stream: Option<cpal::Stream>,
_input_stream: Option<cpal::Stream>, _input_stream: Option<cpal::Stream>,
_analysis_handle: Option<AnalysisHandle>, _analysis_handle: Option<AnalysisHandle>,
midi_rx: Receiver<MidiCommand>,
device_lost: Arc<AtomicBool>, device_lost: Arc<AtomicBool>,
stream_error_rx: crossbeam_channel::Receiver<String>, stream_error_rx: crossbeam_channel::Receiver<String>,
current_font: FontChoice, current_font: FontChoice,
@@ -207,7 +205,6 @@ impl CagireDesktop {
_stream: b.stream, _stream: b.stream,
_input_stream: b.input_stream, _input_stream: b.input_stream,
_analysis_handle: b.analysis_handle, _analysis_handle: b.analysis_handle,
midi_rx: b.midi_rx,
device_lost: b.device_lost, device_lost: b.device_lost,
stream_error_rx: b.stream_error_rx, stream_error_rx: b.stream_error_rx,
current_font, current_font,
@@ -237,7 +234,6 @@ impl CagireDesktop {
return; return;
}; };
let new_audio_rx = sequencer.swap_audio_channel(); let new_audio_rx = sequencer.swap_audio_channel();
self.midi_rx = sequencer.swap_midi_channel();
let new_config = AudioStreamConfig { let new_config = AudioStreamConfig {
output_device: self.app.audio.config.output_device.clone(), 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.sample_rate = info.sample_rate;
self.app.audio.config.host_name = info.host_name; self.app.audio.config.host_name = info.host_name;
self.app.audio.config.channels = info.channels; self.app.audio.config.channels = info.channels;
self.app.audio.config.input_sample_rate = info.input_sample_rate;
self.sample_rate_shared self.sample_rate_shared
.store(info.sample_rate as u32, Ordering::Relaxed); .store(info.sample_rate as u32, Ordering::Relaxed);
self.app.audio.error = None; 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_patterns(&sequencer.cmd_tx);
self.app.flush_dirty_script(&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); let should_quit = self.handle_input(ctx);
if should_quit { if should_quit {
ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.send_viewport_cmd(egui::ViewportCommand::Close);

View File

@@ -282,6 +282,7 @@ pub struct AudioStreamInfo {
pub sample_rate: f32, pub sample_rate: f32,
pub host_name: String, pub host_name: String,
pub channels: u16, pub channels: u16,
pub input_sample_rate: Option<f32>,
} }
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
@@ -367,10 +368,16 @@ pub fn build_stream(
dev 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() .as_ref()
.and_then(|dev| dev.default_input_config().ok())
.map_or(0, |cfg| cfg.channels() as usize); .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; engine.input_channels = input_channels;
@@ -519,6 +526,7 @@ pub fn build_stream(
sample_rate, sample_rate,
host_name, host_name,
channels: effective_channels, channels: effective_channels,
input_sample_rate,
}; };
Ok((stream, input_stream, info, analysis_handle, registry)) Ok((stream, input_stream, info, analysis_handle, registry))
} }

View File

@@ -1,14 +1,13 @@
use arc_swap::ArcSwap; use crossbeam_channel::{Receiver, RecvTimeoutError};
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::BinaryHeap; use std::collections::BinaryHeap;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use super::link::LinkState; use super::link::LinkState;
use super::realtime::{precise_sleep_us, set_realtime_priority, warn_no_rt}; use super::realtime::{precise_sleep_us, set_realtime_priority, warn_no_rt};
use super::sequencer::MidiCommand; use super::sequencer::MidiCommand;
use super::timing::SyncTime; use super::timing::SyncTime;
use crate::midi::{MidiOutputPorts, MAX_MIDI_OUTPUTS};
/// A MIDI command scheduled for dispatch at a specific time. /// A MIDI command scheduled for dispatch at a specific time.
#[derive(Clone)] #[derive(Clone)]
@@ -46,13 +45,13 @@ impl Eq for TimedMidiCommand {}
const SPIN_THRESHOLD_US: SyncTime = 100; 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 /// Audio commands bypass the dispatcher entirely and go straight to doux's
/// sample-accurate scheduler via the audio thread channel. /// sample-accurate scheduler via the audio thread channel.
pub fn dispatcher_loop( pub fn dispatcher_loop(
cmd_rx: Receiver<TimedMidiCommand>, cmd_rx: Receiver<TimedMidiCommand>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>, ports: MidiOutputPorts,
link: Arc<LinkState>, link: &LinkState,
) { ) {
let has_rt = set_realtime_priority(); let has_rt = set_realtime_priority();
if !has_rt { if !has_rt {
@@ -84,8 +83,8 @@ pub fn dispatcher_loop(
while let Some(cmd) = queue.peek() { while let Some(cmd) = queue.peek() {
if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US { if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US {
let cmd = queue.pop().expect("pop after peek"); let cmd = queue.pop().expect("pop after peek");
wait_until_dispatch(cmd.target_time_us, &link, has_rt); wait_until_dispatch(cmd.target_time_us, link, has_rt);
dispatch_midi(cmd.command, &midi_tx); dispatch_midi(cmd.command, &ports);
} else { } else {
break; 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<ArcSwap<Sender<MidiCommand>>>) { fn dispatch_midi(cmd: MidiDispatch, ports: &MidiOutputPorts) {
match cmd { match cmd {
MidiDispatch::Send(midi_cmd) => { MidiDispatch::Send(midi_cmd) => {
let _ = midi_tx.load().try_send(midi_cmd); ports.send_command(&midi_cmd);
} }
MidiDispatch::FlushAll => { MidiDispatch::FlushAll => {
for dev in 0..4u8 { for dev in 0..MAX_MIDI_OUTPUTS as u8 {
for chan in 0..16u8 { for chan in 0..16u8 {
let _ = midi_tx.load().try_send(MidiCommand::CC { ports.send_command(&MidiCommand::CC {
device: dev, device: dev,
channel: chan, channel: chan,
cc: 123, cc: 123,

View File

@@ -21,13 +21,13 @@ pub use audio::AudioStreamInfo;
pub use link::LinkState; pub use link::LinkState;
pub use sequencer::{ pub use sequencer::{
spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand, spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand,
SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot, SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot,
}; };
// Re-exported for the plugin crate (not used by the terminal binary). // Re-exported for the plugin crate (not used by the terminal binary).
#[allow(unused_imports)] #[allow(unused_imports)]
pub use sequencer::{ pub use sequencer::{
parse_midi_command, SequencerState, SharedSequencerState, TickInput, TickOutput, parse_midi_command, MidiCommand, SequencerState, SharedSequencerState, TickInput, TickOutput,
TimestampedCommand, TimestampedCommand,
}; };

View File

@@ -261,7 +261,6 @@ impl SequencerSnapshot {
pub struct SequencerHandle { pub struct SequencerHandle {
pub cmd_tx: Sender<SeqCommand>, pub cmd_tx: Sender<SeqCommand>,
pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>, pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
pub midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
shared_state: Arc<ArcSwap<SharedSequencerState>>, shared_state: Arc<ArcSwap<SharedSequencerState>>,
thread: JoinHandle<()>, thread: JoinHandle<()>,
} }
@@ -278,12 +277,6 @@ impl SequencerHandle {
new_rx new_rx
} }
pub fn swap_midi_channel(&self) -> Receiver<MidiCommand> {
let (new_tx, new_rx) = bounded::<MidiCommand>(256);
self.midi_tx.store(Arc::new(new_tx));
new_rx
}
pub fn shutdown(self) { pub fn shutdown(self) {
let _ = self.cmd_tx.send(SeqCommand::Shutdown); let _ = self.cmd_tx.send(SeqCommand::Shutdown);
if let Err(e) = self.thread.join() { if let Err(e) = self.thread.join() {
@@ -356,16 +349,11 @@ pub fn spawn_sequencer(
live_keys: Arc<LiveKeyState>, live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>, nudge_us: Arc<AtomicI64>,
config: SequencerConfig, config: SequencerConfig,
) -> ( midi_ports: crate::midi::MidiOutputPorts,
SequencerHandle, ) -> (SequencerHandle, Receiver<AudioCommand>) {
Receiver<AudioCommand>,
Receiver<MidiCommand>,
) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64); let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = unbounded::<AudioCommand>(); let (audio_tx, audio_rx) = unbounded::<AudioCommand>();
let (midi_tx, midi_rx) = bounded::<MidiCommand>(256);
let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); 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) // Dispatcher channel — MIDI only (unbounded to avoid blocking the scheduler)
let (dispatch_tx, dispatch_rx) = unbounded::<TimedMidiCommand>(); let (dispatch_tx, dispatch_rx) = unbounded::<TimedMidiCommand>();
@@ -382,13 +370,12 @@ pub fn spawn_sequencer(
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
let mouse_down = config.mouse_down; 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_link = Arc::clone(&link);
let dispatcher_midi_tx = Arc::clone(&midi_tx);
thread::Builder::new() thread::Builder::new()
.name("cagire-dispatcher".into()) .name("cagire-dispatcher".into())
.spawn(move || { .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"); .expect("Failed to spawn dispatcher thread");
@@ -424,11 +411,10 @@ pub fn spawn_sequencer(
let handle = SequencerHandle { let handle = SequencerHandle {
cmd_tx, cmd_tx,
audio_tx, audio_tx,
midi_tx,
shared_state, shared_state,
thread, thread,
}; };
(handle, audio_rx, midi_rx) (handle, audio_rx)
} }
struct PatternCache { struct PatternCache {

View File

@@ -2,13 +2,12 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use crossbeam_channel::Receiver;
use doux::EngineMetrics; use doux::EngineMetrics;
use crate::app::App; use crate::app::App;
use crate::engine::{ use crate::engine::{
build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioStreamConfig, build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioStreamConfig,
LinkState, MidiCommand, PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle, LinkState, PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle,
SpectrumBuffer, SpectrumBuffer,
}; };
use crate::midi; use crate::midi;
@@ -42,7 +41,6 @@ pub struct Init {
pub stream: Option<cpal::Stream>, pub stream: Option<cpal::Stream>,
pub input_stream: Option<cpal::Stream>, pub input_stream: Option<cpal::Stream>,
pub analysis_handle: Option<AnalysisHandle>, pub analysis_handle: Option<AnalysisHandle>,
pub midi_rx: Receiver<MidiCommand>,
pub device_lost: Arc<AtomicBool>, pub device_lost: Arc<AtomicBool>,
pub stream_error_rx: crossbeam_channel::Receiver<String>, pub stream_error_rx: crossbeam_channel::Receiver<String>,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
@@ -192,13 +190,14 @@ pub fn init(args: InitArgs) -> Init {
mouse_down: Arc::clone(&mouse_down), 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(&link),
Arc::clone(&playing), Arc::clone(&playing),
settings.link.quantum, settings.link.quantum,
Arc::clone(&app.live_keys), Arc::clone(&app.live_keys),
Arc::clone(&nudge_us), Arc::clone(&nudge_us),
seq_config, seq_config,
app.midi.output_ports.clone(),
); );
let device_lost = Arc::new(AtomicBool::new(false)); 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.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name; app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels; 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); sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.sample_registry = Some(Arc::clone(&registry)); app.audio.sample_registry = Some(Arc::clone(&registry));
@@ -267,7 +267,6 @@ pub fn init(args: InitArgs) -> Init {
stream, stream,
input_stream, input_stream,
analysis_handle, analysis_handle,
midi_rx,
device_lost, device_lost,
stream_error_rx, stream_error_rx,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]

View File

@@ -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) { pub(crate) fn cycle_midi_output(ctx: &mut InputContext, right: bool) {
let slot = ctx.app.audio.midi_output_slot; let slot = ctx.app.audio.midi_output_slot;
let all_devices = crate::midi::list_midi_outputs(); let all_devices = crate::midi::list_midi_outputs();
let selected = ctx.app.midi.selected_outputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter() .iter()
.enumerate() .enumerate()
.filter(|(idx, _)| { .filter(|(idx, _)| {
ctx.app.midi.selected_outputs[slot] == Some(*idx) selected[slot] == Some(*idx)
|| !ctx || !selected
.app
.midi
.selected_outputs
.iter() .iter()
.enumerate() .enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx)) .any(|(s, sel)| s != slot && *sel == Some(*idx))
}) })
.collect(); .collect();
let total_options = available.len() + 1; 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)) .and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1) .map(|p| p + 1)
.unwrap_or(0); .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('r') => ctx.dispatch(AppCommand::ResetPeakVoices),
KeyCode::Char('t') if !ctx.app.plugin_mode => { KeyCode::Char('t') if !ctx.app.plugin_mode => {
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate { 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, tick: None,
}); });
} }

View File

@@ -790,7 +790,7 @@ fn execute_palette_entry(
.audio_tx .audio_tx
.load() .load()
.send(crate::engine::AudioCommand::Evaluate { .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, tick: None,
}); });
} }

View File

@@ -66,7 +66,7 @@ pub(super) fn handle_sample_explorer(ctx: &mut InputContext, key: KeyEvent) -> I
TreeLineKind::File => { TreeLineKind::File => {
let folder = &entry.folder; let folder = &entry.folder;
let idx = entry.index; 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 let _ = ctx
.audio_tx .audio_tx
.load() .load()

View File

@@ -101,7 +101,6 @@ fn main() -> io::Result<()> {
let mut _stream = b.stream; let mut _stream = b.stream;
let mut _input_stream = b.input_stream; let mut _input_stream = b.input_stream;
let mut _analysis_handle = b.analysis_handle; let mut _analysis_handle = b.analysis_handle;
let mut midi_rx = b.midi_rx;
let device_lost = b.device_lost; let device_lost = b.device_lost;
let mut stream_error_rx = b.stream_error_rx; let mut stream_error_rx = b.stream_error_rx;
@@ -125,7 +124,6 @@ fn main() -> io::Result<()> {
_analysis_handle = None; _analysis_handle = None;
let new_audio_rx = sequencer.swap_audio_channel(); let new_audio_rx = sequencer.swap_audio_channel();
midi_rx = sequencer.swap_midi_channel();
let new_config = AudioStreamConfig { let new_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(), 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.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name; app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels; 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); sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.error = None; app.audio.error = None;
app.audio.sample_registry = Some(Arc::clone(&registry)); app.audio.sample_registry = Some(Arc::clone(&registry));
@@ -233,59 +232,6 @@ fn main() -> io::Result<()> {
} }
was_playing = app.playback.playing; 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.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize;
app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices); app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices);

View File

@@ -1,12 +1,124 @@
use parking_lot::Mutex; use parking_lot::Mutex;
use std::sync::Arc; use std::sync::Arc;
use crate::engine::sequencer::MidiCommand;
use crate::model::CcAccess; use crate::model::CcAccess;
pub const MAX_MIDI_OUTPUTS: usize = 4; pub const MAX_MIDI_OUTPUTS: usize = 4;
pub const MAX_MIDI_INPUTS: usize = 4; pub const MAX_MIDI_INPUTS: usize = 4;
pub const MAX_MIDI_DEVICES: 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<Mutex<[Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS]>>,
pub selected: Arc<Mutex<[Option<usize>; 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 /// Raw CC memory storage type
type CcMemoryInner = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>; type CcMemoryInner = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>;
@@ -95,11 +207,9 @@ pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
} }
pub struct MidiState { pub struct MidiState {
#[cfg(feature = "cli")]
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
input_conns: [Option<midir::MidiInputConnection<(CcMemoryInner, usize)>>; MAX_MIDI_INPUTS], input_conns: [Option<midir::MidiInputConnection<(CcMemoryInner, usize)>>; MAX_MIDI_INPUTS],
pub selected_outputs: [Option<usize>; MAX_MIDI_OUTPUTS], pub output_ports: MidiOutputPorts,
pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS], pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS],
pub cc_memory: CcMemory, pub cc_memory: CcMemory,
} }
@@ -113,51 +223,24 @@ impl Default for MidiState {
impl MidiState { impl MidiState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
#[cfg(feature = "cli")]
output_conns: [None, None, None, None],
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
input_conns: [None, None, None, None], input_conns: [None, None, None, None],
selected_outputs: [None; MAX_MIDI_OUTPUTS], output_ports: MidiOutputPorts::new(),
selected_inputs: [None; MAX_MIDI_INPUTS], selected_inputs: [None; MAX_MIDI_INPUTS],
cc_memory: CcMemory::new(), cc_memory: CcMemory::new(),
} }
} }
#[cfg(feature = "cli")] pub fn connect_output(&self, slot: usize, port_index: usize) -> Result<(), String> {
pub fn connect_output(&mut self, slot: usize, port_index: usize) -> Result<(), String> { self.output_ports.connect(slot, port_index)
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(())
} }
#[cfg(not(feature = "cli"))] pub fn disconnect_output(&self, slot: usize) {
pub fn connect_output(&mut self, _slot: usize, _port_index: usize) -> Result<(), String> { self.output_ports.disconnect(slot);
Ok(())
} }
#[cfg(feature = "cli")] pub fn selected_outputs(&self) -> [Option<usize>; MAX_MIDI_OUTPUTS] {
pub fn disconnect_output(&mut self, slot: usize) { *self.output_ports.selected.lock()
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;
}
} }
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
@@ -215,51 +298,4 @@ impl MidiState {
self.selected_inputs[slot] = None; 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]);
}
} }

View File

@@ -127,6 +127,7 @@ pub struct AudioConfig {
pub lissajous_trails: bool, pub lissajous_trails: bool,
pub spectrum_mode: SpectrumMode, pub spectrum_mode: SpectrumMode,
pub spectrum_peaks: bool, pub spectrum_peaks: bool,
pub input_sample_rate: Option<f32>,
} }
impl Default for AudioConfig { impl Default for AudioConfig {
@@ -154,6 +155,7 @@ impl Default for AudioConfig {
lissajous_trails: false, lissajous_trails: false,
spectrum_mode: SpectrumMode::default(), spectrum_mode: SpectrumMode::default(),
spectrum_peaks: false, spectrum_peaks: false,
input_sample_rate: None,
} }
} }
} }

View File

@@ -519,6 +519,14 @@ fn render_status(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
label_style, 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 { if !app.plugin_mode {
// Host // Host
lines.push(Line::from(Span::styled( 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<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
for slot in 0..4 { for slot in 0..4 {
let is_focused = section_focused && app.audio.midi_output_slot == slot; 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 prefix = if is_focused { "> " } else { " " };
let style = if is_focused { highlight } else { label_style }; let style = if is_focused { highlight } else { label_style };
let val_style = if is_focused { highlight } else { value_style }; let val_style = if is_focused { highlight } else { value_style };

View File

@@ -21,9 +21,9 @@ fn with_params() {
} }
#[test] #[test]
fn auto_dur() { fn auto_gate() {
let outputs = expect_outputs(r#""kick" snd ."#, 1); let outputs = expect_outputs(r#""kick" snd ."#, 1);
assert!(outputs[0].contains("dur/")); assert!(outputs[0].contains("gate/"));
} }
#[test] #[test]
@@ -94,7 +94,7 @@ fn param_only_emit() {
assert!(outputs[0].contains("voice/0")); assert!(outputs[0].contains("voice/0"));
assert!(outputs[0].contains("freq/880")); assert!(outputs[0].contains("freq/880"));
assert!(!outputs[0].contains("sound/")); assert!(!outputs[0].contains("sound/"));
assert!(outputs[0].contains("dur/")); assert!(outputs[0].contains("gate/"));
assert!(!outputs[0].contains("delaytime/")); assert!(!outputs[0].contains("delaytime/"));
} }
@@ -141,8 +141,8 @@ fn polyphonic_with_at() {
#[test] #[test]
fn explicit_dur_zero_is_infinite() { fn explicit_dur_zero_is_infinite() {
let outputs = expect_outputs("880 freq 0 dur .", 1); let outputs = expect_outputs("880 freq 0 gate .", 1);
assert!(outputs[0].contains("dur/0")); assert!(outputs[0].contains("gate/0"));
} }
#[test] #[test]

View File

@@ -21,10 +21,10 @@ fn get_deltas(outputs: &[String]) -> Vec<f64> {
.collect() .collect()
} }
fn get_durs(outputs: &[String]) -> Vec<f64> { fn get_gates(outputs: &[String]) -> Vec<f64> {
outputs outputs
.iter() .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() .collect()
} }
@@ -88,10 +88,10 @@ fn alternating_sounds() {
} }
#[test] #[test]
fn dur_is_step_duration() { fn gate_is_step_duration() {
let outputs = expect_outputs(r#""kick" snd ."#, 1); let outputs = expect_outputs(r#""kick" snd ."#, 1);
let durs = get_durs(&outputs); let gates = get_gates(&outputs);
assert!(approx_eq(durs[0], 0.5), "dur should be 4 * step_duration (0.5), got {}", durs[0]); assert!(approx_eq(gates[0], 0.5), "gate should be 4 * step_duration (0.5), got {}", gates[0]);
} }
#[test] #[test]

View File

@@ -13,7 +13,7 @@ const SOUNDS = new Set([
const PARAMS = new Set([ const PARAMS = new Set([
'freq', 'note', 'gain', 'decay', 'attack', 'release', 'lpf', 'hpf', 'bpf', 'freq', 'note', 'gain', 'decay', 'attack', 'release', 'lpf', 'hpf', 'bpf',
'verb', 'delay', 'pan', 'orbit', 'harmonics', 'distort', 'speed', 'voice', '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', 'crush', 'fold', 'wrap', 'resonance', 'begin', 'end', 'velocity', 'chan',
'dev', 'ccnum', 'ccout', 'bend', 'pressure', 'program', 'tilt', 'slope', 'dev', 'ccnum', 'ccout', 'bend', 'pressure', 'program', 'tilt', 'slope',
'sub_gain', 'sub_oct', 'feedback', 'depth', 'sweep', 'comb', 'damping', 'sub_gain', 'sub_oct', 'feedback', 'depth', 'sweep', 'comb', 'damping',