use std::sync::OnceLock;
use std::sync::atomic::{AtomicU8, Ordering};
use midir::{MidiInput, MidiInputConnection, MidiInputPort};
use truce_rack_core::events::{EventBody, MidiData};
use crate::midi_queue;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum MidiChannel {
#[default]
Omni,
Only(u8),
}
impl MidiChannel {
#[must_use]
pub fn parse(spec: &str) -> Option<Self> {
let s = spec.trim().to_ascii_lowercase();
if s == "omni" || s == "all" {
return Some(Self::Omni);
}
let n: u8 = s.parse().ok()?;
(1..=16).contains(&n).then(|| Self::Only(n - 1))
}
#[must_use]
fn accepts(self, status: u8) -> bool {
match self {
Self::Omni => true,
Self::Only(c) => !(0x80..=0xEF).contains(&status) || (status & 0x0F) == c,
}
}
#[must_use]
fn encode(self) -> u8 {
match self {
Self::Omni => OMNI,
Self::Only(c) => c,
}
}
#[must_use]
fn decode(v: u8) -> Self {
if v < 16 { Self::Only(v) } else { Self::Omni }
}
}
const OMNI: u8 = 0x10;
#[derive(Clone, Debug, Default)]
pub struct MidiConfig {
pub input: Option<String>,
pub channel: MidiChannel,
}
static CONFIG: OnceLock<MidiConfig> = OnceLock::new();
static LIVE_CHANNEL: AtomicU8 = AtomicU8::new(OMNI);
pub fn set_config(config: MidiConfig) {
LIVE_CHANNEL.store(config.channel.encode(), Ordering::Relaxed);
let _ = CONFIG.set(config);
}
#[must_use]
pub fn config() -> MidiConfig {
CONFIG.get().cloned().unwrap_or_default()
}
#[must_use]
pub fn live_channel() -> MidiChannel {
MidiChannel::decode(LIVE_CHANNEL.load(Ordering::Relaxed))
}
pub fn set_live_channel(channel: MidiChannel) {
LIVE_CHANNEL.store(channel.encode(), Ordering::Relaxed);
}
pub fn list_midi() {
println!("MIDI inputs:");
let names = list_midi_devices();
if names.is_empty() {
println!(" (none)");
}
for name in names {
println!(" {name}");
}
}
#[must_use]
pub fn list_midi_devices() -> Vec<String> {
let Ok(input) = MidiInput::new("truce-rack-standalone-enum") else {
return Vec::new();
};
input
.ports()
.iter()
.filter_map(|p| input.port_name(p).ok())
.collect()
}
pub struct MidiInputThread {
_connections: Vec<MidiInputConnection<()>>,
}
impl MidiInputThread {
#[must_use]
pub fn start() -> Option<Self> {
Self::open(config().input.as_deref())
}
#[must_use]
pub fn open(input: Option<&str>) -> Option<Self> {
let probe = match MidiInput::new("truce-rack-standalone") {
Ok(m) => m,
Err(e) => {
eprintln!("[truce-rack-standalone] midi init: {e}");
return None;
}
};
let mut ports = probe.ports();
if let Some(want) = input {
let needle = want.to_ascii_lowercase();
ports.retain(|p| {
probe
.port_name(p)
.is_ok_and(|n| n.to_ascii_lowercase().contains(&needle))
});
if ports.is_empty() {
eprintln!("[truce-rack-standalone] no MIDI input matching {want:?}");
return None;
}
}
if ports.is_empty() {
return None;
}
let mut connections = Vec::with_capacity(ports.len());
for port in ports {
let input = match MidiInput::new("truce-rack-standalone") {
Ok(m) => m,
Err(e) => {
eprintln!("[truce-rack-standalone] midi reinit: {e}");
continue;
}
};
let label = input.port_name(&port).unwrap_or_else(|_| "?".into());
match open_port(input, &port) {
Ok(conn) => {
eprintln!("[truce-rack-standalone] midi in: {label}");
connections.push(conn);
}
Err(e) => eprintln!("[truce-rack-standalone] midi open '{label}': {e}"),
}
}
if connections.is_empty() {
None
} else {
Some(Self {
_connections: connections,
})
}
}
}
pub struct MidiController {
thread: Option<MidiInputThread>,
input: Option<String>,
}
impl MidiController {
#[must_use]
pub fn start() -> Self {
let input = config().input;
Self {
thread: MidiInputThread::open(input.as_deref()),
input,
}
}
#[must_use]
pub fn input(&self) -> Option<&str> {
self.input.as_deref()
}
pub fn set_input(&mut self, input: Option<String>) {
self.thread = None;
self.thread = MidiInputThread::open(input.as_deref());
self.input = input;
}
pub fn set_channel(&self, channel: MidiChannel) {
set_live_channel(channel);
}
}
fn open_port(
input: MidiInput,
port: &MidiInputPort,
) -> std::result::Result<MidiInputConnection<()>, midir::ConnectError<MidiInput>> {
input.connect(
port,
"truce-rack-standalone-in",
|_timestamp_us, bytes, ()| {
if bytes
.first()
.is_some_and(|&status| !live_channel().accepts(status))
{
return;
}
if let Some(body) = parse_midi(bytes) {
midi_queue::enqueue(body);
}
},
(),
)
}
fn parse_midi(bytes: &[u8]) -> Option<EventBody> {
if bytes.is_empty() {
return None;
}
let status = bytes[0];
let channel = status & 0x0F;
let kind = status & 0xF0;
let body = match (kind, bytes) {
(0x80, [_, note, vel]) => MidiData::NoteOff {
channel,
note: *note,
velocity: *vel,
},
(0x90, [_, note, 0]) => MidiData::NoteOff {
channel,
note: *note,
velocity: 0,
},
(0x90, [_, note, vel]) => MidiData::NoteOn {
channel,
note: *note,
velocity: *vel,
},
(0xA0, [_, note, pressure]) => MidiData::PolyAftertouch {
channel,
note: *note,
pressure: *pressure,
},
(0xB0, [_, controller, value]) => MidiData::ControlChange {
channel,
controller: *controller,
value: *value,
},
(0xC0, [_, program]) => MidiData::ProgramChange {
channel,
program: *program,
},
(0xD0, [_, pressure]) => MidiData::ChannelAftertouch {
channel,
pressure: *pressure,
},
(0xE0, [_, lsb, msb]) => MidiData::PitchBend {
channel,
value: u16::from(*msb) << 7 | u16::from(*lsb),
},
_ if bytes.len() <= 8 => {
let mut data = [0u8; 8];
data[..bytes.len()].copy_from_slice(bytes);
#[allow(clippy::cast_possible_truncation)]
MidiData::Raw {
len: bytes.len() as u8,
data,
}
}
_ => return None,
};
Some(EventBody::Midi(body))
}
#[cfg(test)]
mod tests {
use super::MidiChannel;
#[test]
fn parse_channels() {
assert_eq!(MidiChannel::parse("omni"), Some(MidiChannel::Omni));
assert_eq!(MidiChannel::parse("all"), Some(MidiChannel::Omni));
assert_eq!(MidiChannel::parse("1"), Some(MidiChannel::Only(0)));
assert_eq!(MidiChannel::parse("16"), Some(MidiChannel::Only(15)));
assert_eq!(MidiChannel::parse("0"), None);
assert_eq!(MidiChannel::parse("17"), None);
assert_eq!(MidiChannel::parse("ch1"), None);
}
#[test]
fn accepts_filters_channel_voice_only() {
let only_3 = MidiChannel::Only(2); assert!(only_3.accepts(0x92));
assert!(!only_3.accepts(0x90));
assert!(only_3.accepts(0xF8));
assert!(only_3.accepts(0xF0));
assert!(MidiChannel::Omni.accepts(0x90));
assert!(MidiChannel::Omni.accepts(0xF8));
}
#[test]
fn encode_decode_roundtrips() {
assert_eq!(MidiChannel::decode(MidiChannel::Omni.encode()), MidiChannel::Omni);
for c in 0u8..16 {
let ch = MidiChannel::Only(c);
assert_eq!(MidiChannel::decode(ch.encode()), ch);
}
}
#[test]
fn live_channel_set_get() {
super::set_live_channel(MidiChannel::Only(4));
assert_eq!(super::live_channel(), MidiChannel::Only(4));
super::set_live_channel(MidiChannel::Omni);
assert_eq!(super::live_channel(), MidiChannel::Omni);
}
#[test]
fn controller_tracks_input_selection() {
let mut controller = super::MidiController::start();
controller.set_input(Some("nonexistent-port".into()));
assert_eq!(controller.input(), Some("nonexistent-port"));
controller.set_input(None);
assert_eq!(controller.input(), None);
}
}