More robust midi implementation
Some checks failed
Deploy Website / deploy (push) Failing after 4m58s

This commit is contained in:
2026-01-31 23:58:57 +01:00
parent 15a4300db5
commit 2100b82dad
12 changed files with 393 additions and 201 deletions

View File

@@ -4,6 +4,8 @@ use std::sync::{Arc, Mutex};
use super::ops::Op;
pub const MAX_MIDI_DEVICES: usize = 4;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SourceSpan {
pub start: usize,
@@ -42,7 +44,7 @@ pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
pub type Rng = Arc<Mutex<StdRng>>;
pub type Stack = Arc<Mutex<Vec<Value>>>;
pub type CcMemory = Arc<Mutex<[[u8; 128]; 16]>>;
pub type CcMemory = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>;
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]);
#[derive(Clone, Debug)]

View File

@@ -811,51 +811,94 @@ impl Forth {
let chan = get_int("chan")
.map(|c| (c.clamp(1, 16) - 1) as u8)
.unwrap_or(0);
let dev = get_int("dev")
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) {
let cc = cc.clamp(0, 127) as u8;
let val = val.clamp(0, 127) as u8;
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}"));
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}"));
} else if let Some(bend) = get_float("bend") {
let bend_clamped = bend.clamp(-1.0, 1.0);
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}"));
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}"));
} else if let Some(pressure) = get_int("pressure") {
let pressure = pressure.clamp(0, 127) as u8;
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}"));
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}"));
} else if let Some(program) = get_int("program") {
let program = program.clamp(0, 127) as u8;
outputs.push(format!("/midi/program/{program}/chan/{chan}"));
outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}"));
} else {
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
let velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
let dur = get_float("dur").unwrap_or(1.0);
let dur_secs = dur * ctx.step_duration();
outputs.push(format!("/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}"));
outputs.push(format!("/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}"));
}
}
Op::MidiClock => {
outputs.push("/midi/clock".to_string());
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/clock/dev/{dev}"));
}
Op::MidiStart => {
outputs.push("/midi/start".to_string());
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/start/dev/{dev}"));
}
Op::MidiStop => {
outputs.push("/midi/stop".to_string());
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/stop/dev/{dev}"));
}
Op::MidiContinue => {
outputs.push("/midi/continue".to_string());
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/continue/dev/{dev}"));
}
Op::GetMidiCC => {
let chan = stack.pop().ok_or("stack underflow")?.as_int()?;
let cc = stack.pop().ok_or("stack underflow")?.as_int()?;
let cc_clamped = (cc.clamp(0, 127)) as usize;
let chan_clamped = (chan.clamp(1, 16) - 1) as usize;
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as usize)
.unwrap_or(0);
let val = ctx
.cc_memory
.as_ref()
.and_then(|mem| mem.lock().ok())
.map(|mem| mem[chan_clamped][cc_clamped])
.map(|mem| mem[dev][chan_clamped][cc_clamped])
.unwrap_or(0);
stack.push(Value::Int(val as i64, None));
}

View File

@@ -2396,11 +2396,21 @@ pub const WORDS: &[Word] = &[
aliases: &[],
category: "MIDI",
stack: "(cc chan -- val)",
desc: "Read CC value 0-127 from MIDI input",
desc: "Read CC value 0-127 from MIDI input (uses dev param for device)",
example: "1 1 ccval",
compile: Simple,
varargs: false,
},
Word {
name: "dev",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI device slot 0-3 for output/input",
example: "1 dev 60 note m.",
compile: Param,
varargs: false,
},
];
pub(super) fn simple_op(name: &str) -> Option<Op> {

View File

@@ -118,12 +118,18 @@ impl App {
quantum: link.quantum(),
},
midi: crate::settings::MidiSettings {
output_device: self.midi.selected_output.and_then(|idx| {
crate::midi::list_midi_outputs().get(idx).map(|d| d.name.clone())
}),
input_device: self.midi.selected_input.and_then(|idx| {
crate::midi::list_midi_inputs().get(idx).map(|d| d.name.clone())
}),
output_devices: {
let outputs = crate::midi::list_midi_outputs();
self.midi.selected_outputs.iter()
.map(|opt| opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone())).unwrap_or_default())
.collect()
},
input_devices: {
let inputs = crate::midi::list_midi_inputs();
self.midi.selected_inputs.iter()
.map(|opt| opt.and_then(|idx| inputs.get(idx).map(|d| d.name.clone())).unwrap_or_default())
.collect()
},
},
};
settings.save();

View File

@@ -53,16 +53,16 @@ pub enum AudioCommand {
#[derive(Clone, Debug)]
pub enum MidiCommand {
NoteOn { channel: u8, note: u8, velocity: u8 },
NoteOff { channel: u8, note: u8 },
CC { channel: u8, cc: u8, value: u8 },
PitchBend { channel: u8, value: u16 },
Pressure { channel: u8, value: u8 },
ProgramChange { channel: u8, program: u8 },
Clock,
Start,
Stop,
Continue,
NoteOn { device: u8, channel: u8, note: u8, velocity: u8 },
NoteOff { device: u8, channel: u8, note: u8 },
CC { device: u8, channel: u8, cc: u8, value: u8 },
PitchBend { device: u8, channel: u8, value: u16 },
Pressure { device: u8, channel: u8, value: u8 },
ProgramChange { device: u8, channel: u8, program: u8 },
Clock { device: u8 },
Start { device: u8 },
Stop { device: u8 },
Continue { device: u8 },
}
pub enum SeqCommand {
@@ -484,7 +484,7 @@ pub(crate) struct SequencerState {
key_cache: KeyCache,
buf_audio_commands: Vec<TimestampedCommand>,
cc_memory: Option<CcMemory>,
active_notes: HashMap<(u8, u8), ActiveNote>,
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
}
impl SequencerState {
@@ -951,12 +951,12 @@ fn sequencer_loop(
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
match midi_tx.load().try_send(midi_cmd.clone()) {
Ok(()) => {
if let (MidiCommand::NoteOn { channel, note, .. }, Some(dur_secs)) =
if let (MidiCommand::NoteOn { device, channel, note, .. }, Some(dur_secs)) =
(&midi_cmd, dur)
{
let dur_us = (dur_secs * 1_000_000.0) as i64;
seq_state.active_notes.insert(
(*channel, *note),
(*device, *channel, *note),
ActiveNote {
off_time_us: current_time_us + dur_us,
start_time_us: current_time_us,
@@ -985,22 +985,24 @@ fn sequencer_loop(
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
if output.flush_midi_notes {
for ((channel, note), _) in seq_state.active_notes.drain() {
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { channel, note });
for ((device, channel, note), _) in seq_state.active_notes.drain() {
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { device, channel, note });
}
// Send MIDI panic (CC 123 = All Notes Off) on all 16 channels
for chan in 0..16u8 {
let _ = midi_tx
.load()
.try_send(MidiCommand::CC { channel: chan, cc: 123, value: 0 });
// Send MIDI panic (CC 123 = All Notes Off) on all 16 channels for all devices
for dev in 0..4u8 {
for chan in 0..16u8 {
let _ = midi_tx
.load()
.try_send(MidiCommand::CC { device: dev, channel: chan, cc: 123, value: 0 });
}
}
} else {
seq_state.active_notes.retain(|&(channel, note), active| {
seq_state.active_notes.retain(|&(device, channel, note), active| {
let should_release = current_time_us >= active.off_time_us;
let timed_out = (current_time_us - active.start_time_us) > MAX_NOTE_DURATION_US;
if should_release || timed_out {
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { channel, note });
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { device, channel, note });
false
} else {
true
@@ -1026,15 +1028,25 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
if parts.len() < 2 {
return None;
}
let find_param = |key: &str| -> Option<&str> {
parts.iter()
.position(|&s| s == key)
.and_then(|i| parts.get(i + 1).copied())
};
let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0);
match parts[1] {
"note" => {
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<dev>
let note: u8 = parts.get(2)?.parse().ok()?;
let vel: u8 = parts.get(4)?.parse().ok()?;
let chan: u8 = parts.get(6)?.parse().ok()?;
let dur: Option<f64> = parts.get(8).and_then(|s| s.parse().ok());
let vel: u8 = find_param("vel")?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
let dur: Option<f64> = find_param("dur").and_then(|s| s.parse().ok());
Some((
MidiCommand::NoteOn {
device,
channel: chan,
note,
velocity: vel,
@@ -1043,12 +1055,13 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
))
}
"cc" => {
// /midi/cc/<cc>/<val>/chan/<chan>
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<dev>
let cc: u8 = parts.get(2)?.parse().ok()?;
let val: u8 = parts.get(3)?.parse().ok()?;
let chan: u8 = parts.get(5)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((
MidiCommand::CC {
device,
channel: chan,
cc,
value: val,
@@ -1057,27 +1070,27 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
))
}
"bend" => {
// /midi/bend/<value>/chan/<chan>
// /midi/bend/<value>/chan/<chan>/dev/<dev>
let value: u16 = parts.get(2)?.parse().ok()?;
let chan: u8 = parts.get(4)?.parse().ok()?;
Some((MidiCommand::PitchBend { channel: chan, value }, None))
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((MidiCommand::PitchBend { device, channel: chan, value }, None))
}
"pressure" => {
// /midi/pressure/<value>/chan/<chan>
// /midi/pressure/<value>/chan/<chan>/dev/<dev>
let value: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = parts.get(4)?.parse().ok()?;
Some((MidiCommand::Pressure { channel: chan, value }, None))
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((MidiCommand::Pressure { device, channel: chan, value }, None))
}
"program" => {
// /midi/program/<value>/chan/<chan>
// /midi/program/<value>/chan/<chan>/dev/<dev>
let program: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = parts.get(4)?.parse().ok()?;
Some((MidiCommand::ProgramChange { channel: chan, program }, None))
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((MidiCommand::ProgramChange { device, channel: chan, program }, None))
}
"clock" => Some((MidiCommand::Clock, None)),
"start" => Some((MidiCommand::Start, None)),
"stop" => Some((MidiCommand::Stop, None)),
"continue" => Some((MidiCommand::Continue, None)),
"clock" => Some((MidiCommand::Clock { device }, None)),
"start" => Some((MidiCommand::Start { device }, None)),
"stop" => Some((MidiCommand::Stop { device }, None)),
"continue" => Some((MidiCommand::Continue { device }, None)),
_ => None,
}
}

View File

@@ -1270,36 +1270,88 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
ctx.link.set_quantum(ctx.link.quantum() + delta);
}
OptionsFocus::MidiOutput => {
let devices = crate::midi::list_midi_outputs();
if !devices.is_empty() {
let current = ctx.app.midi.selected_output.unwrap_or(0);
let new_idx = if key.code == KeyCode::Left {
if current == 0 { devices.len() - 1 } else { current - 1 }
} else {
(current + 1) % devices.len()
};
if ctx.app.midi.connect_output(new_idx).is_ok() {
OptionsFocus::MidiOutput0 | OptionsFocus::MidiOutput1 |
OptionsFocus::MidiOutput2 | OptionsFocus::MidiOutput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiOutput0 => 0,
OptionsFocus::MidiOutput1 => 1,
OptionsFocus::MidiOutput2 => 2,
OptionsFocus::MidiOutput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_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.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]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if key.code == KeyCode::Left {
if current_pos == 0 { total_options - 1 } else { current_pos - 1 }
} else {
(current_pos + 1) % total_options
};
if new_pos == 0 {
ctx.app.midi.disconnect_output(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output: {}",
devices[new_idx].name
"MIDI output {}: {}", slot, device.name
)));
}
}
}
OptionsFocus::MidiInput => {
let devices = crate::midi::list_midi_inputs();
if !devices.is_empty() {
let current = ctx.app.midi.selected_input.unwrap_or(0);
let new_idx = if key.code == KeyCode::Left {
if current == 0 { devices.len() - 1 } else { current - 1 }
} else {
(current + 1) % devices.len()
};
if ctx.app.midi.connect_input(new_idx).is_ok() {
OptionsFocus::MidiInput0 | OptionsFocus::MidiInput1 |
OptionsFocus::MidiInput2 | OptionsFocus::MidiInput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiInput0 => 0,
OptionsFocus::MidiInput1 => 1,
OptionsFocus::MidiInput2 => 2,
OptionsFocus::MidiInput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_inputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|| !ctx.app.midi.selected_inputs.iter().enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_inputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if key.code == KeyCode::Left {
if current_pos == 0 { total_options - 1 } else { current_pos - 1 }
} else {
(current_pos + 1) % total_options
};
if new_pos == 0 {
ctx.app.midi.disconnect_input(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input: {}",
devices[new_idx].name
"MIDI input {}: {}", slot, device.name
)));
}
}

View File

@@ -103,16 +103,20 @@ fn main() -> io::Result<()> {
theme::set(settings.display.color_scheme.to_theme());
// Load MIDI settings
if let Some(output_name) = &settings.midi.output_device {
let outputs = midi::list_midi_outputs();
if let Some(idx) = outputs.iter().position(|d| &d.name == output_name) {
let _ = app.midi.connect_output(idx);
let outputs = midi::list_midi_outputs();
let inputs = midi::list_midi_inputs();
for (slot, name) in settings.midi.output_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = outputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_output(slot, idx);
}
}
}
if let Some(input_name) = &settings.midi.input_device {
let inputs = midi::list_midi_inputs();
if let Some(idx) = inputs.iter().position(|d| &d.name == input_name) {
let _ = app.midi.connect_input(idx);
for (slot, name) in settings.midi.input_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = inputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_input(slot, idx);
}
}
}
@@ -240,28 +244,28 @@ fn main() -> io::Result<()> {
// Process pending MIDI commands
while let Ok(midi_cmd) = midi_rx.try_recv() {
match midi_cmd {
engine::MidiCommand::NoteOn { channel, note, velocity } => {
app.midi.send_note_on(channel, note, velocity);
engine::MidiCommand::NoteOn { device, channel, note, velocity } => {
app.midi.send_note_on(device, channel, note, velocity);
}
engine::MidiCommand::NoteOff { channel, note } => {
app.midi.send_note_off(channel, note);
engine::MidiCommand::NoteOff { device, channel, note } => {
app.midi.send_note_off(device, channel, note);
}
engine::MidiCommand::CC { channel, cc, value } => {
app.midi.send_cc(channel, cc, value);
engine::MidiCommand::CC { device, channel, cc, value } => {
app.midi.send_cc(device, channel, cc, value);
}
engine::MidiCommand::PitchBend { channel, value } => {
app.midi.send_pitch_bend(channel, value);
engine::MidiCommand::PitchBend { device, channel, value } => {
app.midi.send_pitch_bend(device, channel, value);
}
engine::MidiCommand::Pressure { channel, value } => {
app.midi.send_pressure(channel, value);
engine::MidiCommand::Pressure { device, channel, value } => {
app.midi.send_pressure(device, channel, value);
}
engine::MidiCommand::ProgramChange { channel, program } => {
app.midi.send_program_change(channel, program);
engine::MidiCommand::ProgramChange { device, channel, program } => {
app.midi.send_program_change(device, channel, program);
}
engine::MidiCommand::Clock => app.midi.send_realtime(0xF8),
engine::MidiCommand::Start => app.midi.send_realtime(0xFA),
engine::MidiCommand::Stop => app.midi.send_realtime(0xFC),
engine::MidiCommand::Continue => app.midi.send_realtime(0xFB),
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),
}
}

View File

@@ -4,6 +4,9 @@ use midir::{MidiInput, MidiOutput};
use crate::model::CcMemory;
pub const MAX_MIDI_OUTPUTS: usize = 4;
pub const MAX_MIDI_INPUTS: usize = 4;
#[derive(Clone, Debug)]
pub struct MidiDeviceInfo {
pub name: String,
@@ -42,10 +45,10 @@ pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
}
pub struct MidiState {
output_conn: Option<midir::MidiOutputConnection>,
input_conn: Option<midir::MidiInputConnection<CcMemory>>,
pub selected_output: Option<usize>,
pub selected_input: Option<usize>,
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
input_conns: [Option<midir::MidiInputConnection<(CcMemory, usize)>>; MAX_MIDI_INPUTS],
pub selected_outputs: [Option<usize>; MAX_MIDI_OUTPUTS],
pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS],
pub cc_memory: CcMemory,
}
@@ -58,82 +61,105 @@ impl Default for MidiState {
impl MidiState {
pub fn new() -> Self {
Self {
output_conn: None,
input_conn: None,
selected_output: None,
selected_input: None,
cc_memory: Arc::new(Mutex::new([[0u8; 128]; 16])),
output_conns: [None, None, None, None],
input_conns: [None, None, None, None],
selected_outputs: [None; MAX_MIDI_OUTPUTS],
selected_inputs: [None; MAX_MIDI_INPUTS],
cc_memory: Arc::new(Mutex::new([[[0u8; 128]; 16]; MAX_MIDI_OUTPUTS])),
}
}
pub fn connect_output(&mut self, index: usize) -> Result<(), String> {
let midi_out = MidiOutput::new("cagire-out").map_err(|e| e.to_string())?;
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 = MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?;
let ports = midi_out.ports();
let port = ports.get(index).ok_or("MIDI output port not found")?;
let port = ports.get(port_index).ok_or("MIDI output port not found")?;
let conn = midi_out
.connect(port, "cagire-midi-out")
.connect(port, &format!("cagire-midi-out-{slot}"))
.map_err(|e| e.to_string())?;
self.output_conn = Some(conn);
self.selected_output = Some(index);
self.output_conns[slot] = Some(conn);
self.selected_outputs[slot] = Some(port_index);
Ok(())
}
pub fn connect_input(&mut self, index: usize) -> Result<(), String> {
let midi_in = MidiInput::new("cagire-in").map_err(|e| e.to_string())?;
pub fn disconnect_output(&mut self, slot: usize) {
if slot < MAX_MIDI_OUTPUTS {
self.output_conns[slot] = None;
self.selected_outputs[slot] = None;
}
}
pub fn connect_input(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
if slot >= MAX_MIDI_INPUTS {
return Err("Invalid input slot".to_string());
}
let midi_in = MidiInput::new(&format!("cagire-in-{slot}")).map_err(|e| e.to_string())?;
let ports = midi_in.ports();
let port = ports.get(index).ok_or("MIDI input port not found")?;
let port = ports.get(port_index).ok_or("MIDI input port not found")?;
let cc_mem = Arc::clone(&self.cc_memory);
let conn = midi_in
.connect(
port,
"cagire-midi-in",
move |_timestamp, message, cc_mem| {
&format!("cagire-midi-in-{slot}"),
move |_timestamp, message, (cc_mem, slot)| {
if message.len() >= 3 {
let status = message[0];
let data1 = message[1] as usize;
let data2 = message[2];
// CC message: 0xBn where n is channel 0-15
if (status & 0xF0) == 0xB0 && data1 < 128 {
let channel = (status & 0x0F) as usize;
if let Ok(mut mem) = cc_mem.lock() {
mem[channel][data1] = data2;
mem[*slot][channel][data1] = data2;
}
}
}
},
cc_mem,
(cc_mem, slot),
)
.map_err(|e| e.to_string())?;
self.input_conn = Some(conn);
self.selected_input = Some(index);
self.input_conns[slot] = Some(conn);
self.selected_inputs[slot] = Some(port_index);
Ok(())
}
pub fn send_note_on(&mut self, channel: u8, note: u8, velocity: u8) {
if let Some(conn) = &mut self.output_conn {
pub fn disconnect_input(&mut self, slot: usize) {
if slot < MAX_MIDI_INPUTS {
self.input_conns[slot] = None;
self.selected_inputs[slot] = None;
}
}
pub fn send_note_on(&mut self, device: u8, channel: u8, note: u8, velocity: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0x90 | (channel & 0x0F);
let _ = conn.send(&[status, note & 0x7F, velocity & 0x7F]);
}
}
pub fn send_note_off(&mut self, channel: u8, note: u8) {
if let Some(conn) = &mut self.output_conn {
pub fn send_note_off(&mut self, device: u8, channel: u8, note: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0x80 | (channel & 0x0F);
let _ = conn.send(&[status, note & 0x7F, 0]);
}
}
pub fn send_cc(&mut self, channel: u8, cc: u8, value: u8) {
if let Some(conn) = &mut self.output_conn {
pub fn send_cc(&mut self, device: u8, channel: u8, cc: u8, value: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xB0 | (channel & 0x0F);
let _ = conn.send(&[status, cc & 0x7F, value & 0x7F]);
}
}
pub fn send_pitch_bend(&mut self, channel: u8, value: u16) {
if let Some(conn) = &mut self.output_conn {
pub fn send_pitch_bend(&mut self, device: u8, channel: u8, value: u16) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xE0 | (channel & 0x0F);
let lsb = (value & 0x7F) as u8;
let msb = ((value >> 7) & 0x7F) as u8;
@@ -141,22 +167,25 @@ impl MidiState {
}
}
pub fn send_pressure(&mut self, channel: u8, value: u8) {
if let Some(conn) = &mut self.output_conn {
pub fn send_pressure(&mut self, device: u8, channel: u8, value: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xD0 | (channel & 0x0F);
let _ = conn.send(&[status, value & 0x7F]);
}
}
pub fn send_program_change(&mut self, channel: u8, program: u8) {
if let Some(conn) = &mut self.output_conn {
pub fn send_program_change(&mut self, device: u8, channel: u8, program: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xC0 | (channel & 0x0F);
let _ = conn.send(&[status, program & 0x7F]);
}
}
pub fn send_realtime(&mut self, msg: u8) {
if let Some(conn) = &mut self.output_conn {
pub fn send_realtime(&mut self, device: u8, msg: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let _ = conn.send(&[msg]);
}
}

View File

@@ -6,8 +6,10 @@ const APP_NAME: &str = "cagire";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct MidiSettings {
pub output_device: Option<String>,
pub input_device: Option<String>,
#[serde(default)]
pub output_devices: Vec<String>,
#[serde(default)]
pub input_devices: Vec<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]

View File

@@ -11,8 +11,14 @@ pub enum OptionsFocus {
LinkEnabled,
StartStopSync,
Quantum,
MidiOutput,
MidiInput,
MidiOutput0,
MidiOutput1,
MidiOutput2,
MidiOutput3,
MidiInput0,
MidiInput1,
MidiInput2,
MidiInput3,
}
#[derive(Default)]
@@ -32,15 +38,21 @@ impl OptionsState {
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
OptionsFocus::Quantum => OptionsFocus::MidiOutput,
OptionsFocus::MidiOutput => OptionsFocus::MidiInput,
OptionsFocus::MidiInput => OptionsFocus::ColorScheme,
OptionsFocus::Quantum => OptionsFocus::MidiOutput0,
OptionsFocus::MidiOutput0 => OptionsFocus::MidiOutput1,
OptionsFocus::MidiOutput1 => OptionsFocus::MidiOutput2,
OptionsFocus::MidiOutput2 => OptionsFocus::MidiOutput3,
OptionsFocus::MidiOutput3 => OptionsFocus::MidiInput0,
OptionsFocus::MidiInput0 => OptionsFocus::MidiInput1,
OptionsFocus::MidiInput1 => OptionsFocus::MidiInput2,
OptionsFocus::MidiInput2 => OptionsFocus::MidiInput3,
OptionsFocus::MidiInput3 => OptionsFocus::ColorScheme,
};
}
pub fn prev_focus(&mut self) {
self.focus = match self.focus {
OptionsFocus::ColorScheme => OptionsFocus::MidiInput,
OptionsFocus::ColorScheme => OptionsFocus::MidiInput3,
OptionsFocus::RefreshRate => OptionsFocus::ColorScheme,
OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate,
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
@@ -50,8 +62,14 @@ impl OptionsState {
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
OptionsFocus::MidiOutput => OptionsFocus::Quantum,
OptionsFocus::MidiInput => OptionsFocus::MidiOutput,
OptionsFocus::MidiOutput0 => OptionsFocus::Quantum,
OptionsFocus::MidiOutput1 => OptionsFocus::MidiOutput0,
OptionsFocus::MidiOutput2 => OptionsFocus::MidiOutput1,
OptionsFocus::MidiOutput3 => OptionsFocus::MidiOutput2,
OptionsFocus::MidiInput0 => OptionsFocus::MidiOutput3,
OptionsFocus::MidiInput1 => OptionsFocus::MidiInput0,
OptionsFocus::MidiInput2 => OptionsFocus::MidiInput1,
OptionsFocus::MidiInput3 => OptionsFocus::MidiInput2,
};
}
}

View File

@@ -31,7 +31,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let focus = app.options.focus;
let content_width = padded.width as usize;
// Build link header with status
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
@@ -64,7 +63,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)),
]);
// Prepare values
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
let quantum_str = format!("{:.0}", link.quantum());
let tempo_str = format!("{:.1} BPM", link.tempo());
@@ -74,35 +72,45 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(theme.values.value);
// MIDI device lists
let midi_outputs = midi::list_midi_outputs();
let midi_inputs = midi::list_midi_inputs();
let midi_out_display = if let Some(idx) = app.midi.selected_output {
midi_outputs
.get(idx)
.map(|d| d.name.as_str())
.unwrap_or("(disconnected)")
} else if midi_outputs.is_empty() {
"(none found)"
} else {
"(not connected)"
let midi_out_display = |slot: usize| -> String {
if let Some(idx) = app.midi.selected_outputs[slot] {
midi_outputs
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "(disconnected)".to_string())
} else if midi_outputs.is_empty() {
"(none found)".to_string()
} else {
"(not connected)".to_string()
}
};
let midi_in_display = if let Some(idx) = app.midi.selected_input {
midi_inputs
.get(idx)
.map(|d| d.name.as_str())
.unwrap_or("(disconnected)")
} else if midi_inputs.is_empty() {
"(none found)"
} else {
"(not connected)"
let midi_in_display = |slot: usize| -> String {
if let Some(idx) = app.midi.selected_inputs[slot] {
midi_inputs
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "(disconnected)".to_string())
} else if midi_inputs.is_empty() {
"(none found)".to_string()
} else {
"(not connected)".to_string()
}
};
// Build flat list of all lines
let midi_out_0 = midi_out_display(0);
let midi_out_1 = midi_out_display(1);
let midi_out_2 = midi_out_display(2);
let midi_out_3 = midi_out_display(3);
let midi_in_0 = midi_in_display(0);
let midi_in_1 = midi_in_display(1);
let midi_in_2 = midi_in_display(2);
let midi_in_3 = midi_in_display(3);
let lines: Vec<Line> = vec![
// DISPLAY section (lines 0-8)
render_section_header("DISPLAY", &theme),
render_divider(content_width, &theme),
render_option_line(
@@ -146,9 +154,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
&theme,
),
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness, &theme),
// Blank line (line 9)
Line::from(""),
// ABLETON LINK section (lines 10-15)
link_header,
render_divider(content_width, &theme),
render_option_line(
@@ -168,27 +174,31 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
&theme,
),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
// Blank line (line 16)
Line::from(""),
// SESSION section (lines 17-20)
render_section_header("SESSION", &theme),
render_divider(content_width, &theme),
render_readonly_line("Tempo", &tempo_str, tempo_style, &theme),
render_readonly_line("Beat", &beat_str, value_style, &theme),
render_readonly_line("Phase", &phase_str, value_style, &theme),
// Blank line (line 22)
Line::from(""),
// MIDI section (lines 23-26)
render_section_header("MIDI", &theme),
render_section_header("MIDI OUTPUTS", &theme),
render_divider(content_width, &theme),
render_option_line("Output", midi_out_display, focus == OptionsFocus::MidiOutput, &theme),
render_option_line("Input", midi_in_display, focus == OptionsFocus::MidiInput, &theme),
render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme),
render_option_line("Output 1", &midi_out_1, focus == OptionsFocus::MidiOutput1, &theme),
render_option_line("Output 2", &midi_out_2, focus == OptionsFocus::MidiOutput2, &theme),
render_option_line("Output 3", &midi_out_3, focus == OptionsFocus::MidiOutput3, &theme),
Line::from(""),
render_section_header("MIDI INPUTS", &theme),
render_divider(content_width, &theme),
render_option_line("Input 0", &midi_in_0, focus == OptionsFocus::MidiInput0, &theme),
render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme),
render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme),
render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme),
];
let total_lines = lines.len();
let max_visible = padded.height as usize;
// Map focus to line index
let focus_line: usize = match focus {
OptionsFocus::ColorScheme => 2,
OptionsFocus::RefreshRate => 3,
@@ -200,11 +210,16 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
OptionsFocus::LinkEnabled => 12,
OptionsFocus::StartStopSync => 13,
OptionsFocus::Quantum => 14,
OptionsFocus::MidiOutput => 25,
OptionsFocus::MidiInput => 26,
OptionsFocus::MidiOutput0 => 25,
OptionsFocus::MidiOutput1 => 26,
OptionsFocus::MidiOutput2 => 27,
OptionsFocus::MidiOutput3 => 28,
OptionsFocus::MidiInput0 => 32,
OptionsFocus::MidiInput1 => 33,
OptionsFocus::MidiInput2 => 34,
OptionsFocus::MidiInput3 => 35,
};
// Calculate scroll offset to keep focused line visible (centered when possible)
let scroll_offset = if total_lines <= max_visible {
0
} else {
@@ -213,7 +228,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
.min(total_lines.saturating_sub(max_visible))
};
// Render visible portion
let visible_end = (scroll_offset + max_visible).min(total_lines);
let visible_lines: Vec<Line> = lines
.into_iter()
@@ -223,7 +237,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
frame.render_widget(Paragraph::new(visible_lines), padded);
// Render scroll indicators
let indicator_style = Style::new().fg(theme.ui.text_dim);
let indicator_x = padded.x + padded.width.saturating_sub(1);

View File

@@ -42,11 +42,11 @@ fn test_ccval_returns_zero_without_cc_memory() {
#[test]
fn test_ccval_reads_from_cc_memory() {
let cc_memory: CcMemory = Arc::new(Mutex::new([[0u8; 128]; 16]));
let cc_memory: CcMemory = Arc::new(Mutex::new([[[0u8; 128]; 16]; 4]));
{
let mut mem = cc_memory.lock().unwrap();
mem[0][1] = 64; // channel 1 (0-indexed), CC 1, value 64
mem[5][74] = 127; // channel 6 (0-indexed), CC 74, value 127
mem[0][0][1] = 64; // device 0, channel 1 (0-indexed), CC 1, value 64
mem[0][5][74] = 127; // device 0, channel 6 (0-indexed), CC 74, value 127
}
let f = forth();
@@ -205,25 +205,25 @@ fn test_midi_program_clamping() {
#[test]
fn test_midi_clock() {
let outputs = expect_outputs("mclock", 1);
assert_eq!(outputs[0], "/midi/clock");
assert_eq!(outputs[0], "/midi/clock/dev/0");
}
#[test]
fn test_midi_start() {
let outputs = expect_outputs("mstart", 1);
assert_eq!(outputs[0], "/midi/start");
assert_eq!(outputs[0], "/midi/start/dev/0");
}
#[test]
fn test_midi_stop() {
let outputs = expect_outputs("mstop", 1);
assert_eq!(outputs[0], "/midi/stop");
assert_eq!(outputs[0], "/midi/stop/dev/0");
}
#[test]
fn test_midi_continue() {
let outputs = expect_outputs("mcont", 1);
assert_eq!(outputs[0], "/midi/continue");
assert_eq!(outputs[0], "/midi/continue/dev/0");
}
// Test message type priority (first matching type wins)