More robust midi implementation
This commit is contained in:
@@ -4,6 +4,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use super::ops::Op;
|
use super::ops::Op;
|
||||||
|
|
||||||
|
pub const MAX_MIDI_DEVICES: usize = 4;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct SourceSpan {
|
pub struct SourceSpan {
|
||||||
pub start: usize,
|
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 Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
|
||||||
pub type Rng = Arc<Mutex<StdRng>>;
|
pub type Rng = Arc<Mutex<StdRng>>;
|
||||||
pub type Stack = Arc<Mutex<Vec<Value>>>;
|
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)]);
|
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]);
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -811,51 +811,94 @@ impl Forth {
|
|||||||
let chan = get_int("chan")
|
let chan = get_int("chan")
|
||||||
.map(|c| (c.clamp(1, 16) - 1) as u8)
|
.map(|c| (c.clamp(1, 16) - 1) as u8)
|
||||||
.unwrap_or(0);
|
.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")) {
|
if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) {
|
||||||
let cc = cc.clamp(0, 127) as u8;
|
let cc = cc.clamp(0, 127) as u8;
|
||||||
let val = val.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") {
|
} else if let Some(bend) = get_float("bend") {
|
||||||
let bend_clamped = bend.clamp(-1.0, 1.0);
|
let bend_clamped = bend.clamp(-1.0, 1.0);
|
||||||
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
|
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") {
|
} else if let Some(pressure) = get_int("pressure") {
|
||||||
let pressure = pressure.clamp(0, 127) as u8;
|
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") {
|
} else if let Some(program) = get_int("program") {
|
||||||
let program = program.clamp(0, 127) as u8;
|
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 {
|
} else {
|
||||||
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
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 velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
|
||||||
let dur = get_float("dur").unwrap_or(1.0);
|
let dur = get_float("dur").unwrap_or(1.0);
|
||||||
let dur_secs = dur * ctx.step_duration();
|
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 => {
|
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 => {
|
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 => {
|
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 => {
|
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 => {
|
Op::GetMidiCC => {
|
||||||
let chan = stack.pop().ok_or("stack underflow")?.as_int()?;
|
let chan = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||||
let cc = 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 cc_clamped = (cc.clamp(0, 127)) as usize;
|
||||||
let chan_clamped = (chan.clamp(1, 16) - 1) 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
|
let val = ctx
|
||||||
.cc_memory
|
.cc_memory
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|mem| mem.lock().ok())
|
.and_then(|mem| mem.lock().ok())
|
||||||
.map(|mem| mem[chan_clamped][cc_clamped])
|
.map(|mem| mem[dev][chan_clamped][cc_clamped])
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stack.push(Value::Int(val as i64, None));
|
stack.push(Value::Int(val as i64, None));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2396,11 +2396,21 @@ pub const WORDS: &[Word] = &[
|
|||||||
aliases: &[],
|
aliases: &[],
|
||||||
category: "MIDI",
|
category: "MIDI",
|
||||||
stack: "(cc chan -- val)",
|
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",
|
example: "1 1 ccval",
|
||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
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> {
|
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(),
|
quantum: link.quantum(),
|
||||||
},
|
},
|
||||||
midi: crate::settings::MidiSettings {
|
midi: crate::settings::MidiSettings {
|
||||||
output_device: self.midi.selected_output.and_then(|idx| {
|
output_devices: {
|
||||||
crate::midi::list_midi_outputs().get(idx).map(|d| d.name.clone())
|
let outputs = crate::midi::list_midi_outputs();
|
||||||
}),
|
self.midi.selected_outputs.iter()
|
||||||
input_device: self.midi.selected_input.and_then(|idx| {
|
.map(|opt| opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone())).unwrap_or_default())
|
||||||
crate::midi::list_midi_inputs().get(idx).map(|d| d.name.clone())
|
.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();
|
settings.save();
|
||||||
|
|||||||
@@ -53,16 +53,16 @@ pub enum AudioCommand {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum MidiCommand {
|
pub enum MidiCommand {
|
||||||
NoteOn { channel: u8, note: u8, velocity: u8 },
|
NoteOn { device: u8, channel: u8, note: u8, velocity: u8 },
|
||||||
NoteOff { channel: u8, note: u8 },
|
NoteOff { device: u8, channel: u8, note: u8 },
|
||||||
CC { channel: u8, cc: u8, value: u8 },
|
CC { device: u8, channel: u8, cc: u8, value: u8 },
|
||||||
PitchBend { channel: u8, value: u16 },
|
PitchBend { device: u8, channel: u8, value: u16 },
|
||||||
Pressure { channel: u8, value: u8 },
|
Pressure { device: u8, channel: u8, value: u8 },
|
||||||
ProgramChange { channel: u8, program: u8 },
|
ProgramChange { device: u8, channel: u8, program: u8 },
|
||||||
Clock,
|
Clock { device: u8 },
|
||||||
Start,
|
Start { device: u8 },
|
||||||
Stop,
|
Stop { device: u8 },
|
||||||
Continue,
|
Continue { device: u8 },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum SeqCommand {
|
pub enum SeqCommand {
|
||||||
@@ -484,7 +484,7 @@ pub(crate) struct SequencerState {
|
|||||||
key_cache: KeyCache,
|
key_cache: KeyCache,
|
||||||
buf_audio_commands: Vec<TimestampedCommand>,
|
buf_audio_commands: Vec<TimestampedCommand>,
|
||||||
cc_memory: Option<CcMemory>,
|
cc_memory: Option<CcMemory>,
|
||||||
active_notes: HashMap<(u8, u8), ActiveNote>,
|
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SequencerState {
|
impl SequencerState {
|
||||||
@@ -951,12 +951,12 @@ fn sequencer_loop(
|
|||||||
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
||||||
match midi_tx.load().try_send(midi_cmd.clone()) {
|
match midi_tx.load().try_send(midi_cmd.clone()) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let (MidiCommand::NoteOn { channel, note, .. }, Some(dur_secs)) =
|
if let (MidiCommand::NoteOn { device, channel, note, .. }, Some(dur_secs)) =
|
||||||
(&midi_cmd, dur)
|
(&midi_cmd, dur)
|
||||||
{
|
{
|
||||||
let dur_us = (dur_secs * 1_000_000.0) as i64;
|
let dur_us = (dur_secs * 1_000_000.0) as i64;
|
||||||
seq_state.active_notes.insert(
|
seq_state.active_notes.insert(
|
||||||
(*channel, *note),
|
(*device, *channel, *note),
|
||||||
ActiveNote {
|
ActiveNote {
|
||||||
off_time_us: current_time_us + dur_us,
|
off_time_us: current_time_us + dur_us,
|
||||||
start_time_us: current_time_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
|
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
|
||||||
|
|
||||||
if output.flush_midi_notes {
|
if output.flush_midi_notes {
|
||||||
for ((channel, note), _) in seq_state.active_notes.drain() {
|
for ((device, channel, note), _) in seq_state.active_notes.drain() {
|
||||||
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { channel, note });
|
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 {
|
for chan in 0..16u8 {
|
||||||
let _ = midi_tx
|
let _ = midi_tx
|
||||||
.load()
|
.load()
|
||||||
.try_send(MidiCommand::CC { channel: chan, cc: 123, value: 0 });
|
.try_send(MidiCommand::CC { device: dev, channel: chan, cc: 123, value: 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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 should_release = current_time_us >= active.off_time_us;
|
||||||
let timed_out = (current_time_us - active.start_time_us) > MAX_NOTE_DURATION_US;
|
let timed_out = (current_time_us - active.start_time_us) > MAX_NOTE_DURATION_US;
|
||||||
|
|
||||||
if should_release || timed_out {
|
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
|
false
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
@@ -1026,15 +1028,25 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
|
|||||||
if parts.len() < 2 {
|
if parts.len() < 2 {
|
||||||
return None;
|
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] {
|
match parts[1] {
|
||||||
"note" => {
|
"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 note: u8 = parts.get(2)?.parse().ok()?;
|
||||||
let vel: u8 = parts.get(4)?.parse().ok()?;
|
let vel: u8 = find_param("vel")?.parse().ok()?;
|
||||||
let chan: u8 = parts.get(6)?.parse().ok()?;
|
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||||
let dur: Option<f64> = parts.get(8).and_then(|s| s.parse().ok());
|
let dur: Option<f64> = find_param("dur").and_then(|s| s.parse().ok());
|
||||||
Some((
|
Some((
|
||||||
MidiCommand::NoteOn {
|
MidiCommand::NoteOn {
|
||||||
|
device,
|
||||||
channel: chan,
|
channel: chan,
|
||||||
note,
|
note,
|
||||||
velocity: vel,
|
velocity: vel,
|
||||||
@@ -1043,12 +1055,13 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
"cc" => {
|
"cc" => {
|
||||||
// /midi/cc/<cc>/<val>/chan/<chan>
|
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<dev>
|
||||||
let cc: u8 = parts.get(2)?.parse().ok()?;
|
let cc: u8 = parts.get(2)?.parse().ok()?;
|
||||||
let val: u8 = parts.get(3)?.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((
|
Some((
|
||||||
MidiCommand::CC {
|
MidiCommand::CC {
|
||||||
|
device,
|
||||||
channel: chan,
|
channel: chan,
|
||||||
cc,
|
cc,
|
||||||
value: val,
|
value: val,
|
||||||
@@ -1057,27 +1070,27 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
"bend" => {
|
"bend" => {
|
||||||
// /midi/bend/<value>/chan/<chan>
|
// /midi/bend/<value>/chan/<chan>/dev/<dev>
|
||||||
let value: u16 = parts.get(2)?.parse().ok()?;
|
let value: u16 = parts.get(2)?.parse().ok()?;
|
||||||
let chan: u8 = parts.get(4)?.parse().ok()?;
|
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||||
Some((MidiCommand::PitchBend { channel: chan, value }, None))
|
Some((MidiCommand::PitchBend { device, channel: chan, value }, None))
|
||||||
}
|
}
|
||||||
"pressure" => {
|
"pressure" => {
|
||||||
// /midi/pressure/<value>/chan/<chan>
|
// /midi/pressure/<value>/chan/<chan>/dev/<dev>
|
||||||
let value: u8 = parts.get(2)?.parse().ok()?;
|
let value: u8 = parts.get(2)?.parse().ok()?;
|
||||||
let chan: u8 = parts.get(4)?.parse().ok()?;
|
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||||
Some((MidiCommand::Pressure { channel: chan, value }, None))
|
Some((MidiCommand::Pressure { device, channel: chan, value }, None))
|
||||||
}
|
}
|
||||||
"program" => {
|
"program" => {
|
||||||
// /midi/program/<value>/chan/<chan>
|
// /midi/program/<value>/chan/<chan>/dev/<dev>
|
||||||
let program: u8 = parts.get(2)?.parse().ok()?;
|
let program: u8 = parts.get(2)?.parse().ok()?;
|
||||||
let chan: u8 = parts.get(4)?.parse().ok()?;
|
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||||
Some((MidiCommand::ProgramChange { channel: chan, program }, None))
|
Some((MidiCommand::ProgramChange { device, channel: chan, program }, None))
|
||||||
}
|
}
|
||||||
"clock" => Some((MidiCommand::Clock, None)),
|
"clock" => Some((MidiCommand::Clock { device }, None)),
|
||||||
"start" => Some((MidiCommand::Start, None)),
|
"start" => Some((MidiCommand::Start { device }, None)),
|
||||||
"stop" => Some((MidiCommand::Stop, None)),
|
"stop" => Some((MidiCommand::Stop { device }, None)),
|
||||||
"continue" => Some((MidiCommand::Continue, None)),
|
"continue" => Some((MidiCommand::Continue { device }, None)),
|
||||||
_ => 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 };
|
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
|
||||||
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
||||||
}
|
}
|
||||||
OptionsFocus::MidiOutput => {
|
OptionsFocus::MidiOutput0 | OptionsFocus::MidiOutput1 |
|
||||||
let devices = crate::midi::list_midi_outputs();
|
OptionsFocus::MidiOutput2 | OptionsFocus::MidiOutput3 => {
|
||||||
if !devices.is_empty() {
|
let slot = match ctx.app.options.focus {
|
||||||
let current = ctx.app.midi.selected_output.unwrap_or(0);
|
OptionsFocus::MidiOutput0 => 0,
|
||||||
let new_idx = if key.code == KeyCode::Left {
|
OptionsFocus::MidiOutput1 => 1,
|
||||||
if current == 0 { devices.len() - 1 } else { current - 1 }
|
OptionsFocus::MidiOutput2 => 2,
|
||||||
} else {
|
OptionsFocus::MidiOutput3 => 3,
|
||||||
(current + 1) % devices.len()
|
_ => 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!(
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||||
"MIDI output: {}",
|
"MIDI output {slot}: disconnected"
|
||||||
devices[new_idx].name
|
)));
|
||||||
|
} 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 => {
|
OptionsFocus::MidiInput0 | OptionsFocus::MidiInput1 |
|
||||||
let devices = crate::midi::list_midi_inputs();
|
OptionsFocus::MidiInput2 | OptionsFocus::MidiInput3 => {
|
||||||
if !devices.is_empty() {
|
let slot = match ctx.app.options.focus {
|
||||||
let current = ctx.app.midi.selected_input.unwrap_or(0);
|
OptionsFocus::MidiInput0 => 0,
|
||||||
let new_idx = if key.code == KeyCode::Left {
|
OptionsFocus::MidiInput1 => 1,
|
||||||
if current == 0 { devices.len() - 1 } else { current - 1 }
|
OptionsFocus::MidiInput2 => 2,
|
||||||
} else {
|
OptionsFocus::MidiInput3 => 3,
|
||||||
(current + 1) % devices.len()
|
_ => 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!(
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||||
"MIDI input: {}",
|
"MIDI input {slot}: disconnected"
|
||||||
devices[new_idx].name
|
)));
|
||||||
|
} 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());
|
theme::set(settings.display.color_scheme.to_theme());
|
||||||
|
|
||||||
// Load MIDI settings
|
// Load MIDI settings
|
||||||
if let Some(output_name) = &settings.midi.output_device {
|
|
||||||
let outputs = midi::list_midi_outputs();
|
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();
|
let inputs = midi::list_midi_inputs();
|
||||||
if let Some(idx) = inputs.iter().position(|d| &d.name == input_name) {
|
for (slot, name) in settings.midi.output_devices.iter().enumerate() {
|
||||||
let _ = app.midi.connect_input(idx);
|
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
|
// Process pending MIDI commands
|
||||||
while let Ok(midi_cmd) = midi_rx.try_recv() {
|
while let Ok(midi_cmd) = midi_rx.try_recv() {
|
||||||
match midi_cmd {
|
match midi_cmd {
|
||||||
engine::MidiCommand::NoteOn { channel, note, velocity } => {
|
engine::MidiCommand::NoteOn { device, channel, note, velocity } => {
|
||||||
app.midi.send_note_on(channel, note, velocity);
|
app.midi.send_note_on(device, channel, note, velocity);
|
||||||
}
|
}
|
||||||
engine::MidiCommand::NoteOff { channel, note } => {
|
engine::MidiCommand::NoteOff { device, channel, note } => {
|
||||||
app.midi.send_note_off(channel, note);
|
app.midi.send_note_off(device, channel, note);
|
||||||
}
|
}
|
||||||
engine::MidiCommand::CC { channel, cc, value } => {
|
engine::MidiCommand::CC { device, channel, cc, value } => {
|
||||||
app.midi.send_cc(channel, cc, value);
|
app.midi.send_cc(device, channel, cc, value);
|
||||||
}
|
}
|
||||||
engine::MidiCommand::PitchBend { channel, value } => {
|
engine::MidiCommand::PitchBend { device, channel, value } => {
|
||||||
app.midi.send_pitch_bend(channel, value);
|
app.midi.send_pitch_bend(device, channel, value);
|
||||||
}
|
}
|
||||||
engine::MidiCommand::Pressure { channel, value } => {
|
engine::MidiCommand::Pressure { device, channel, value } => {
|
||||||
app.midi.send_pressure(channel, value);
|
app.midi.send_pressure(device, channel, value);
|
||||||
}
|
}
|
||||||
engine::MidiCommand::ProgramChange { channel, program } => {
|
engine::MidiCommand::ProgramChange { device, channel, program } => {
|
||||||
app.midi.send_program_change(channel, program);
|
app.midi.send_program_change(device, channel, program);
|
||||||
}
|
}
|
||||||
engine::MidiCommand::Clock => app.midi.send_realtime(0xF8),
|
engine::MidiCommand::Clock { device } => app.midi.send_realtime(device, 0xF8),
|
||||||
engine::MidiCommand::Start => app.midi.send_realtime(0xFA),
|
engine::MidiCommand::Start { device } => app.midi.send_realtime(device, 0xFA),
|
||||||
engine::MidiCommand::Stop => app.midi.send_realtime(0xFC),
|
engine::MidiCommand::Stop { device } => app.midi.send_realtime(device, 0xFC),
|
||||||
engine::MidiCommand::Continue => app.midi.send_realtime(0xFB),
|
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;
|
use crate::model::CcMemory;
|
||||||
|
|
||||||
|
pub const MAX_MIDI_OUTPUTS: usize = 4;
|
||||||
|
pub const MAX_MIDI_INPUTS: usize = 4;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct MidiDeviceInfo {
|
pub struct MidiDeviceInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -42,10 +45,10 @@ pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct MidiState {
|
pub struct MidiState {
|
||||||
output_conn: Option<midir::MidiOutputConnection>,
|
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
|
||||||
input_conn: Option<midir::MidiInputConnection<CcMemory>>,
|
input_conns: [Option<midir::MidiInputConnection<(CcMemory, usize)>>; MAX_MIDI_INPUTS],
|
||||||
pub selected_output: Option<usize>,
|
pub selected_outputs: [Option<usize>; MAX_MIDI_OUTPUTS],
|
||||||
pub selected_input: Option<usize>,
|
pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS],
|
||||||
pub cc_memory: CcMemory,
|
pub cc_memory: CcMemory,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,82 +61,105 @@ impl Default for MidiState {
|
|||||||
impl MidiState {
|
impl MidiState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
output_conn: None,
|
output_conns: [None, None, None, None],
|
||||||
input_conn: None,
|
input_conns: [None, None, None, None],
|
||||||
selected_output: None,
|
selected_outputs: [None; MAX_MIDI_OUTPUTS],
|
||||||
selected_input: None,
|
selected_inputs: [None; MAX_MIDI_INPUTS],
|
||||||
cc_memory: Arc::new(Mutex::new([[0u8; 128]; 16])),
|
cc_memory: Arc::new(Mutex::new([[[0u8; 128]; 16]; MAX_MIDI_OUTPUTS])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect_output(&mut self, index: usize) -> Result<(), String> {
|
pub fn connect_output(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
|
||||||
let midi_out = MidiOutput::new("cagire-out").map_err(|e| e.to_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 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
|
let conn = midi_out
|
||||||
.connect(port, "cagire-midi-out")
|
.connect(port, &format!("cagire-midi-out-{slot}"))
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
self.output_conn = Some(conn);
|
self.output_conns[slot] = Some(conn);
|
||||||
self.selected_output = Some(index);
|
self.selected_outputs[slot] = Some(port_index);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect_input(&mut self, index: usize) -> Result<(), String> {
|
pub fn disconnect_output(&mut self, slot: usize) {
|
||||||
let midi_in = MidiInput::new("cagire-in").map_err(|e| e.to_string())?;
|
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 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 cc_mem = Arc::clone(&self.cc_memory);
|
||||||
let conn = midi_in
|
let conn = midi_in
|
||||||
.connect(
|
.connect(
|
||||||
port,
|
port,
|
||||||
"cagire-midi-in",
|
&format!("cagire-midi-in-{slot}"),
|
||||||
move |_timestamp, message, cc_mem| {
|
move |_timestamp, message, (cc_mem, slot)| {
|
||||||
if message.len() >= 3 {
|
if message.len() >= 3 {
|
||||||
let status = message[0];
|
let status = message[0];
|
||||||
let data1 = message[1] as usize;
|
let data1 = message[1] as usize;
|
||||||
let data2 = message[2];
|
let data2 = message[2];
|
||||||
// CC message: 0xBn where n is channel 0-15
|
|
||||||
if (status & 0xF0) == 0xB0 && data1 < 128 {
|
if (status & 0xF0) == 0xB0 && data1 < 128 {
|
||||||
let channel = (status & 0x0F) as usize;
|
let channel = (status & 0x0F) as usize;
|
||||||
if let Ok(mut mem) = cc_mem.lock() {
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
self.input_conn = Some(conn);
|
self.input_conns[slot] = Some(conn);
|
||||||
self.selected_input = Some(index);
|
self.selected_inputs[slot] = Some(port_index);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_note_on(&mut self, channel: u8, note: u8, velocity: u8) {
|
pub fn disconnect_input(&mut self, slot: usize) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
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 status = 0x90 | (channel & 0x0F);
|
||||||
let _ = conn.send(&[status, note & 0x7F, velocity & 0x7F]);
|
let _ = conn.send(&[status, note & 0x7F, velocity & 0x7F]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_note_off(&mut self, channel: u8, note: u8) {
|
pub fn send_note_off(&mut self, device: u8, channel: u8, note: u8) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
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 status = 0x80 | (channel & 0x0F);
|
||||||
let _ = conn.send(&[status, note & 0x7F, 0]);
|
let _ = conn.send(&[status, note & 0x7F, 0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_cc(&mut self, channel: u8, cc: u8, value: u8) {
|
pub fn send_cc(&mut self, device: u8, channel: u8, cc: u8, value: u8) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
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 status = 0xB0 | (channel & 0x0F);
|
||||||
let _ = conn.send(&[status, cc & 0x7F, value & 0x7F]);
|
let _ = conn.send(&[status, cc & 0x7F, value & 0x7F]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_pitch_bend(&mut self, channel: u8, value: u16) {
|
pub fn send_pitch_bend(&mut self, device: u8, channel: u8, value: u16) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
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 status = 0xE0 | (channel & 0x0F);
|
||||||
let lsb = (value & 0x7F) as u8;
|
let lsb = (value & 0x7F) as u8;
|
||||||
let msb = ((value >> 7) & 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) {
|
pub fn send_pressure(&mut self, device: u8, channel: u8, value: u8) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
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 status = 0xD0 | (channel & 0x0F);
|
||||||
let _ = conn.send(&[status, value & 0x7F]);
|
let _ = conn.send(&[status, value & 0x7F]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_program_change(&mut self, channel: u8, program: u8) {
|
pub fn send_program_change(&mut self, device: u8, channel: u8, program: u8) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
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 status = 0xC0 | (channel & 0x0F);
|
||||||
let _ = conn.send(&[status, program & 0x7F]);
|
let _ = conn.send(&[status, program & 0x7F]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_realtime(&mut self, msg: u8) {
|
pub fn send_realtime(&mut self, device: u8, msg: u8) {
|
||||||
if let Some(conn) = &mut self.output_conn {
|
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
|
||||||
|
if let Some(conn) = &mut self.output_conns[slot] {
|
||||||
let _ = conn.send(&[msg]);
|
let _ = conn.send(&[msg]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ const APP_NAME: &str = "cagire";
|
|||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct MidiSettings {
|
pub struct MidiSettings {
|
||||||
pub output_device: Option<String>,
|
#[serde(default)]
|
||||||
pub input_device: Option<String>,
|
pub output_devices: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub input_devices: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ pub enum OptionsFocus {
|
|||||||
LinkEnabled,
|
LinkEnabled,
|
||||||
StartStopSync,
|
StartStopSync,
|
||||||
Quantum,
|
Quantum,
|
||||||
MidiOutput,
|
MidiOutput0,
|
||||||
MidiInput,
|
MidiOutput1,
|
||||||
|
MidiOutput2,
|
||||||
|
MidiOutput3,
|
||||||
|
MidiInput0,
|
||||||
|
MidiInput1,
|
||||||
|
MidiInput2,
|
||||||
|
MidiInput3,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -32,15 +38,21 @@ impl OptionsState {
|
|||||||
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
|
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
|
||||||
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
|
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
|
||||||
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
|
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
|
||||||
OptionsFocus::Quantum => OptionsFocus::MidiOutput,
|
OptionsFocus::Quantum => OptionsFocus::MidiOutput0,
|
||||||
OptionsFocus::MidiOutput => OptionsFocus::MidiInput,
|
OptionsFocus::MidiOutput0 => OptionsFocus::MidiOutput1,
|
||||||
OptionsFocus::MidiInput => OptionsFocus::ColorScheme,
|
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) {
|
pub fn prev_focus(&mut self) {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
OptionsFocus::ColorScheme => OptionsFocus::MidiInput,
|
OptionsFocus::ColorScheme => OptionsFocus::MidiInput3,
|
||||||
OptionsFocus::RefreshRate => OptionsFocus::ColorScheme,
|
OptionsFocus::RefreshRate => OptionsFocus::ColorScheme,
|
||||||
OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate,
|
OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate,
|
||||||
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
|
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
|
||||||
@@ -50,8 +62,14 @@ impl OptionsState {
|
|||||||
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
|
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
|
||||||
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
|
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
|
||||||
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
|
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
|
||||||
OptionsFocus::MidiOutput => OptionsFocus::Quantum,
|
OptionsFocus::MidiOutput0 => OptionsFocus::Quantum,
|
||||||
OptionsFocus::MidiInput => OptionsFocus::MidiOutput,
|
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 focus = app.options.focus;
|
||||||
let content_width = padded.width as usize;
|
let content_width = padded.width as usize;
|
||||||
|
|
||||||
// Build link header with status
|
|
||||||
let enabled = link.is_enabled();
|
let enabled = link.is_enabled();
|
||||||
let peers = link.peers();
|
let peers = link.peers();
|
||||||
let (status_text, status_color) = if !enabled {
|
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)),
|
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 flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
|
||||||
let quantum_str = format!("{:.0}", link.quantum());
|
let quantum_str = format!("{:.0}", link.quantum());
|
||||||
let tempo_str = format!("{:.1} BPM", link.tempo());
|
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 tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD);
|
||||||
let value_style = Style::new().fg(theme.values.value);
|
let value_style = Style::new().fg(theme.values.value);
|
||||||
|
|
||||||
// MIDI device lists
|
|
||||||
let midi_outputs = midi::list_midi_outputs();
|
let midi_outputs = midi::list_midi_outputs();
|
||||||
let midi_inputs = midi::list_midi_inputs();
|
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
|
midi_outputs
|
||||||
.get(idx)
|
.get(idx)
|
||||||
.map(|d| d.name.as_str())
|
.map(|d| d.name.clone())
|
||||||
.unwrap_or("(disconnected)")
|
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||||
} else if midi_outputs.is_empty() {
|
} else if midi_outputs.is_empty() {
|
||||||
"(none found)"
|
"(none found)".to_string()
|
||||||
} else {
|
} 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
|
midi_inputs
|
||||||
.get(idx)
|
.get(idx)
|
||||||
.map(|d| d.name.as_str())
|
.map(|d| d.name.clone())
|
||||||
.unwrap_or("(disconnected)")
|
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||||
} else if midi_inputs.is_empty() {
|
} else if midi_inputs.is_empty() {
|
||||||
"(none found)"
|
"(none found)".to_string()
|
||||||
} else {
|
} 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![
|
let lines: Vec<Line> = vec![
|
||||||
// DISPLAY section (lines 0-8)
|
|
||||||
render_section_header("DISPLAY", &theme),
|
render_section_header("DISPLAY", &theme),
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
render_option_line(
|
render_option_line(
|
||||||
@@ -146,9 +154,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
&theme,
|
&theme,
|
||||||
),
|
),
|
||||||
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness, &theme),
|
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness, &theme),
|
||||||
// Blank line (line 9)
|
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
// ABLETON LINK section (lines 10-15)
|
|
||||||
link_header,
|
link_header,
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
render_option_line(
|
render_option_line(
|
||||||
@@ -168,27 +174,31 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
&theme,
|
&theme,
|
||||||
),
|
),
|
||||||
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
|
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
|
||||||
// Blank line (line 16)
|
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
// SESSION section (lines 17-20)
|
|
||||||
render_section_header("SESSION", &theme),
|
render_section_header("SESSION", &theme),
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
render_readonly_line("Tempo", &tempo_str, tempo_style, &theme),
|
render_readonly_line("Tempo", &tempo_str, tempo_style, &theme),
|
||||||
render_readonly_line("Beat", &beat_str, value_style, &theme),
|
render_readonly_line("Beat", &beat_str, value_style, &theme),
|
||||||
render_readonly_line("Phase", &phase_str, value_style, &theme),
|
render_readonly_line("Phase", &phase_str, value_style, &theme),
|
||||||
// Blank line (line 22)
|
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
// MIDI section (lines 23-26)
|
render_section_header("MIDI OUTPUTS", &theme),
|
||||||
render_section_header("MIDI", &theme),
|
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
render_option_line("Output", midi_out_display, focus == OptionsFocus::MidiOutput, &theme),
|
render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme),
|
||||||
render_option_line("Input", midi_in_display, focus == OptionsFocus::MidiInput, &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 total_lines = lines.len();
|
||||||
let max_visible = padded.height as usize;
|
let max_visible = padded.height as usize;
|
||||||
|
|
||||||
// Map focus to line index
|
|
||||||
let focus_line: usize = match focus {
|
let focus_line: usize = match focus {
|
||||||
OptionsFocus::ColorScheme => 2,
|
OptionsFocus::ColorScheme => 2,
|
||||||
OptionsFocus::RefreshRate => 3,
|
OptionsFocus::RefreshRate => 3,
|
||||||
@@ -200,11 +210,16 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
OptionsFocus::LinkEnabled => 12,
|
OptionsFocus::LinkEnabled => 12,
|
||||||
OptionsFocus::StartStopSync => 13,
|
OptionsFocus::StartStopSync => 13,
|
||||||
OptionsFocus::Quantum => 14,
|
OptionsFocus::Quantum => 14,
|
||||||
OptionsFocus::MidiOutput => 25,
|
OptionsFocus::MidiOutput0 => 25,
|
||||||
OptionsFocus::MidiInput => 26,
|
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 {
|
let scroll_offset = if total_lines <= max_visible {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -213,7 +228,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
.min(total_lines.saturating_sub(max_visible))
|
.min(total_lines.saturating_sub(max_visible))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render visible portion
|
|
||||||
let visible_end = (scroll_offset + max_visible).min(total_lines);
|
let visible_end = (scroll_offset + max_visible).min(total_lines);
|
||||||
let visible_lines: Vec<Line> = lines
|
let visible_lines: Vec<Line> = lines
|
||||||
.into_iter()
|
.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);
|
frame.render_widget(Paragraph::new(visible_lines), padded);
|
||||||
|
|
||||||
// Render scroll indicators
|
|
||||||
let indicator_style = Style::new().fg(theme.ui.text_dim);
|
let indicator_style = Style::new().fg(theme.ui.text_dim);
|
||||||
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ fn test_ccval_returns_zero_without_cc_memory() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ccval_reads_from_cc_memory() {
|
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();
|
let mut mem = cc_memory.lock().unwrap();
|
||||||
mem[0][1] = 64; // channel 1 (0-indexed), CC 1, value 64
|
mem[0][0][1] = 64; // device 0, channel 1 (0-indexed), CC 1, value 64
|
||||||
mem[5][74] = 127; // channel 6 (0-indexed), CC 74, value 127
|
mem[0][5][74] = 127; // device 0, channel 6 (0-indexed), CC 74, value 127
|
||||||
}
|
}
|
||||||
|
|
||||||
let f = forth();
|
let f = forth();
|
||||||
@@ -205,25 +205,25 @@ fn test_midi_program_clamping() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_midi_clock() {
|
fn test_midi_clock() {
|
||||||
let outputs = expect_outputs("mclock", 1);
|
let outputs = expect_outputs("mclock", 1);
|
||||||
assert_eq!(outputs[0], "/midi/clock");
|
assert_eq!(outputs[0], "/midi/clock/dev/0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_start() {
|
fn test_midi_start() {
|
||||||
let outputs = expect_outputs("mstart", 1);
|
let outputs = expect_outputs("mstart", 1);
|
||||||
assert_eq!(outputs[0], "/midi/start");
|
assert_eq!(outputs[0], "/midi/start/dev/0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_stop() {
|
fn test_midi_stop() {
|
||||||
let outputs = expect_outputs("mstop", 1);
|
let outputs = expect_outputs("mstop", 1);
|
||||||
assert_eq!(outputs[0], "/midi/stop");
|
assert_eq!(outputs[0], "/midi/stop/dev/0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_continue() {
|
fn test_midi_continue() {
|
||||||
let outputs = expect_outputs("mcont", 1);
|
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)
|
// Test message type priority (first matching type wins)
|
||||||
|
|||||||
Reference in New Issue
Block a user