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

View File

@@ -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()))

View File

@@ -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);

View File

@@ -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))
}

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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(&registry));
@@ -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")]

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) {
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,
});
}

View File

@@ -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,
});
}

View File

@@ -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()

View File

@@ -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(&registry));
@@ -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);

View File

@@ -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]);
}
}

View File

@@ -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,
}
}
}

View File

@@ -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 };