diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index 9c7e8e9..c356f93 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -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>>; pub type Dictionary = Arc>>>; pub type Rng = Arc>; pub type Stack = Arc>>; -pub type CcMemory = Arc>; +pub type CcMemory = Arc>; pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]); #[derive(Clone, Debug)] diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 52c4164..a45d175 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -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)); } diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 6e34c28..140c253 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -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 { diff --git a/src/app.rs b/src/app.rs index 1eba0f0..4bb4266 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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(); diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 340691a..72c9867 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -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, cc_memory: Option, - 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)> { 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//vel//chan//dur/ + // /midi/note//vel//chan//dur//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 = 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 = 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)> { )) } "cc" => { - // /midi/cc///chan/ + // /midi/cc///chan//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)> { )) } "bend" => { - // /midi/bend//chan/ + // /midi/bend//chan//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//chan/ + // /midi/pressure//chan//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//chan/ + // /midi/program//chan//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, } } diff --git a/src/input.rs b/src/input.rs index e4f1fe1..0062a4f 100644 --- a/src/input.rs +++ b/src/input.rs @@ -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 ))); } } diff --git a/src/main.rs b/src/main.rs index 6426468..58f5d65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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), } } diff --git a/src/midi.rs b/src/midi.rs index 997d9fe..9b159ce 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -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 { } pub struct MidiState { - output_conn: Option, - input_conn: Option>, - pub selected_output: Option, - pub selected_input: Option, + output_conns: [Option; MAX_MIDI_OUTPUTS], + input_conns: [Option>; MAX_MIDI_INPUTS], + pub selected_outputs: [Option; MAX_MIDI_OUTPUTS], + pub selected_inputs: [Option; 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]); } } diff --git a/src/settings.rs b/src/settings.rs index 4e4acbd..f7c84c8 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -6,8 +6,10 @@ const APP_NAME: &str = "cagire"; #[derive(Debug, Default, Serialize, Deserialize)] pub struct MidiSettings { - pub output_device: Option, - pub input_device: Option, + #[serde(default)] + pub output_devices: Vec, + #[serde(default)] + pub input_devices: Vec, } #[derive(Debug, Default, Serialize, Deserialize)] diff --git a/src/state/options.rs b/src/state/options.rs index 886f24e..fd34b64 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -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, }; } } diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 1703194..95ae678 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -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 = 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 = 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); diff --git a/tests/forth/midi.rs b/tests/forth/midi.rs index a7ddace..7c106db 100644 --- a/tests/forth/midi.rs +++ b/tests/forth/midi.rs @@ -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)