Fix: MIDI precision
This commit is contained in:
@@ -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()))
|
||||
|
||||
@@ -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<cpal::Stream>,
|
||||
_input_stream: Option<cpal::Stream>,
|
||||
_analysis_handle: Option<AnalysisHandle>,
|
||||
midi_rx: Receiver<MidiCommand>,
|
||||
device_lost: Arc<AtomicBool>,
|
||||
stream_error_rx: crossbeam_channel::Receiver<String>,
|
||||
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);
|
||||
|
||||
@@ -282,6 +282,7 @@ pub struct AudioStreamInfo {
|
||||
pub sample_rate: f32,
|
||||
pub host_name: String,
|
||||
pub channels: u16,
|
||||
pub input_sample_rate: Option<f32>,
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
|
||||
@@ -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<TimedMidiCommand>,
|
||||
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
|
||||
link: Arc<LinkState>,
|
||||
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<ArcSwap<Sender<MidiCommand>>>) {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -261,7 +261,6 @@ impl SequencerSnapshot {
|
||||
pub struct SequencerHandle {
|
||||
pub cmd_tx: Sender<SeqCommand>,
|
||||
pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
|
||||
pub midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
|
||||
shared_state: Arc<ArcSwap<SharedSequencerState>>,
|
||||
thread: JoinHandle<()>,
|
||||
}
|
||||
@@ -278,12 +277,6 @@ impl SequencerHandle {
|
||||
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) {
|
||||
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<LiveKeyState>,
|
||||
nudge_us: Arc<AtomicI64>,
|
||||
config: SequencerConfig,
|
||||
) -> (
|
||||
SequencerHandle,
|
||||
Receiver<AudioCommand>,
|
||||
Receiver<MidiCommand>,
|
||||
) {
|
||||
midi_ports: crate::midi::MidiOutputPorts,
|
||||
) -> (SequencerHandle, Receiver<AudioCommand>) {
|
||||
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
|
||||
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 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::<TimedMidiCommand>();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<cpal::Stream>,
|
||||
pub input_stream: Option<cpal::Stream>,
|
||||
pub analysis_handle: Option<AnalysisHandle>,
|
||||
pub midi_rx: Receiver<MidiCommand>,
|
||||
pub device_lost: Arc<AtomicBool>,
|
||||
pub stream_error_rx: crossbeam_channel::Receiver<String>,
|
||||
#[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")]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
56
src/main.rs
56
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);
|
||||
|
||||
204
src/midi.rs
204
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<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
|
||||
type CcMemoryInner = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>;
|
||||
|
||||
@@ -95,11 +207,9 @@ pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
|
||||
}
|
||||
|
||||
pub struct MidiState {
|
||||
#[cfg(feature = "cli")]
|
||||
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
|
||||
#[cfg(feature = "cli")]
|
||||
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 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<usize>; 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ pub struct AudioConfig {
|
||||
pub lissajous_trails: bool,
|
||||
pub spectrum_mode: SpectrumMode,
|
||||
pub spectrum_peaks: bool,
|
||||
pub input_sample_rate: Option<f32>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Line> = 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 };
|
||||
|
||||
Reference in New Issue
Block a user