More robust midi implementation
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
18
src/app.rs
18
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();
|
||||
|
||||
@@ -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
|
||||
// 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 { channel: chan, cc: 123, value: 0 });
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
||||
96
src/input.rs
96
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()
|
||||
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,
|
||||
};
|
||||
if ctx.app.midi.connect_output(new_idx).is_ok() {
|
||||
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: {}",
|
||||
devices[new_idx].name
|
||||
"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 {}: {}", 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()
|
||||
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,
|
||||
};
|
||||
if ctx.app.midi.connect_input(new_idx).is_ok() {
|
||||
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: {}",
|
||||
devices[new_idx].name
|
||||
"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 {}: {}", slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
52
src/main.rs
52
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);
|
||||
}
|
||||
}
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
107
src/midi.rs
107
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<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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
let midi_out_display = |slot: usize| -> String {
|
||||
if let Some(idx) = app.midi.selected_outputs[slot] {
|
||||
midi_outputs
|
||||
.get(idx)
|
||||
.map(|d| d.name.as_str())
|
||||
.unwrap_or("(disconnected)")
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||
} else if midi_outputs.is_empty() {
|
||||
"(none found)"
|
||||
"(none found)".to_string()
|
||||
} else {
|
||||
"(not connected)"
|
||||
"(not connected)".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let midi_in_display = if let Some(idx) = app.midi.selected_input {
|
||||
let midi_in_display = |slot: usize| -> String {
|
||||
if let Some(idx) = app.midi.selected_inputs[slot] {
|
||||
midi_inputs
|
||||
.get(idx)
|
||||
.map(|d| d.name.as_str())
|
||||
.unwrap_or("(disconnected)")
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||
} else if midi_inputs.is_empty() {
|
||||
"(none found)"
|
||||
"(none found)".to_string()
|
||||
} else {
|
||||
"(not connected)"
|
||||
"(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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user