vst3_host/midi_input.rs
1//! Bind a live MIDI input device and forward its messages as [`MidiEvent`]s.
2//!
3//! Enabled by the `midi-input` feature. Wraps the [`midir`] crate so callers never touch its
4//! types directly: [`list_midi_input_ports`] enumerates the available input ports, and
5//! [`MidiInputPort`] is a name + opaque handle you pass to [`connect`] or [`bind_to_handle`].
6//!
7//! Both connection forms return a [`MidiInputConnection`] guard; dropping it closes the port and
8//! stops delivery.
9//!
10//! ## Threading
11//!
12//! The callback you pass to [`connect`] runs on a thread owned by `midir` (the OS MIDI driver
13//! thread), **not** the thread that called `connect`. Treat it like an audio callback: do not
14//! block, allocate heavily, or panic in it. [`bind_to_handle`] only calls
15//! [`AudioHandle::send_midi`], which is lock-free and non-blocking, so it is safe to use from
16//! that thread.
17//!
18//! ## Example
19//!
20//! ```no_run
21//! use vst3_host::midi_input::{self, MidiInputConnection};
22//!
23//! # fn main() -> vst3_host::Result<()> {
24//! let ports = midi_input::list_midi_input_ports()?;
25//! let Some(port) = ports.first() else {
26//! return Ok(());
27//! };
28//!
29//! // Low-level: receive parsed events on midir's thread.
30//! let _conn: MidiInputConnection = midi_input::connect(port, |event| {
31//! println!("{event:?}");
32//! })?;
33//! # Ok(())
34//! # }
35//! ```
36
37use midir::{MidiInput, MidiInputPort as RawMidiInputPort};
38
39use crate::{
40 error::{Error, Result},
41 midi::MidiEvent,
42 playback::{AudioHandle, MidiSink},
43};
44
45/// The client name `midir` advertises to the OS when enumerating or opening ports.
46const CLIENT_NAME: &str = "vst3-host";
47
48/// Map a `midir` error into the library's [`Error::MidiError`].
49fn midi_err(context: &str, err: impl std::fmt::Display) -> Error {
50 Error::MidiError(format!("{context}: {err}"))
51}
52
53/// A discovered MIDI input port: its human-readable name plus the opaque handle used to open it.
54///
55/// Obtain these from [`list_midi_input_ports`]. The handle is tied to the port as the OS reported
56/// it at enumeration time; if the device is unplugged, [`connect`] will fail.
57#[derive(Clone)]
58pub struct MidiInputPort {
59 name: String,
60 raw: RawMidiInputPort,
61}
62
63impl MidiInputPort {
64 /// The port's display name (e.g. the device or virtual-port name).
65 pub fn name(&self) -> &str {
66 &self.name
67 }
68}
69
70impl std::fmt::Debug for MidiInputPort {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.debug_struct("MidiInputPort")
73 .field("name", &self.name)
74 .finish_non_exhaustive()
75 }
76}
77
78/// An open connection to a MIDI input port.
79///
80/// Holds the underlying `midir` connection alive; **dropping it closes the port** and stops the
81/// callback. Keep it for as long as you want to receive MIDI.
82pub struct MidiInputConnection {
83 // `midir::MidiInputConnection` is generic over the callback's user-data type; we use `()` and
84 // close it via `Drop`. Boxed only so the public type isn't generic.
85 _inner: midir::MidiInputConnection<()>,
86}
87
88/// List the MIDI input ports currently available on the system.
89///
90/// Returns an empty vector when no input ports are present (not an error). Fails only if the
91/// platform MIDI subsystem cannot be initialized.
92pub fn list_midi_input_ports() -> Result<Vec<MidiInputPort>> {
93 let input = MidiInput::new(CLIENT_NAME).map_err(|e| midi_err("init MIDI input", e))?;
94 let ports = input
95 .ports()
96 .into_iter()
97 .map(|raw| {
98 let name = input
99 .port_name(&raw)
100 .unwrap_or_else(|_| "Unknown MIDI input".to_string());
101 MidiInputPort { name, raw }
102 })
103 .collect();
104 Ok(ports)
105}
106
107/// Open `port` and deliver each parseable incoming message to `callback` as a [`MidiEvent`].
108///
109/// Raw bytes are parsed with [`MidiEvent::from_midi_bytes`], which covers every channel-voice
110/// message — note on/off, control change, program change, pitch bend, and both aftertouch
111/// forms. Anything else (SysEx, system/realtime, running status, truncated data) does not
112/// parse and is silently ignored.
113///
114/// `callback` runs on a `midir`-owned thread — see the [module docs](self#threading). It must be
115/// `Send + 'static`. The returned [`MidiInputConnection`] keeps the port open until dropped.
116pub fn connect<F>(port: &MidiInputPort, mut callback: F) -> Result<MidiInputConnection>
117where
118 F: FnMut(MidiEvent) + Send + 'static,
119{
120 let input = MidiInput::new(CLIENT_NAME).map_err(|e| midi_err("init MIDI input", e))?;
121 let inner = input
122 .connect(
123 &port.raw,
124 &port.name,
125 move |_timestamp, bytes, ()| {
126 if let Some(event) = parse_midi(bytes) {
127 callback(event);
128 }
129 },
130 (),
131 )
132 .map_err(|e| midi_err("connect MIDI input", e))?;
133 Ok(MidiInputConnection { _inner: inner })
134}
135
136/// Open `port` and forward every parseable incoming message into a running [`AudioHandle`].
137///
138/// A convenience over [`connect`]: each received [`MidiEvent`] is pushed into the plugin's
139/// command ring (via the handle's [`MidiSink`]), so notes/CC played on the device reach the
140/// plugin. A `Send` sink is captured into the callback, so the connection keeps forwarding as
141/// long as the returned guard lives — independent of the `AudioHandle`'s own lifetime.
142///
143/// Events are dropped silently if the audio command ring is full (the same drop-on-full behavior
144/// as [`AudioHandle::send_midi`]). The returned [`MidiInputConnection`] keeps the port open until
145/// dropped.
146pub fn bind_to_handle(port: &MidiInputPort, handle: &AudioHandle) -> Result<MidiInputConnection> {
147 let sink: MidiSink = handle.midi_sink();
148 connect(port, move |event| {
149 sink.send_midi(event);
150 })
151}
152
153/// Parse a raw MIDI message into a [`MidiEvent`], returning `None` for messages the library does
154/// not forward. Factored out of the `midir` callback so it can be unit-tested without hardware.
155fn parse_midi(bytes: &[u8]) -> Option<MidiEvent> {
156 MidiEvent::from_midi_bytes(bytes)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::midi::MidiChannel;
163
164 #[test]
165 fn list_midi_input_ports_does_not_error() {
166 // On a machine with a MIDI subsystem, enumeration must succeed (possibly empty).
167 // Headless CI has no ALSA sequencer (`/dev/snd/seq`), so `midir` can't initialize a
168 // backend at all — that's an absent subsystem, not an enumeration bug, so accept it.
169 match list_midi_input_ports() {
170 Ok(_) => {}
171 Err(Error::MidiError(msg)) if msg.contains("could not be initialized") => {
172 eprintln!("skipping: no MIDI subsystem available ({msg})");
173 }
174 Err(e) => panic!("enumeration failed: {e:?}"),
175 }
176 }
177
178 #[test]
179 fn parse_midi_forwards_channel_voice_and_drops_the_rest() {
180 assert_eq!(
181 parse_midi(&[0x90, 60, 100]),
182 Some(MidiEvent::NoteOn {
183 channel: MidiChannel::Ch1,
184 note: 60,
185 velocity: 100,
186 })
187 );
188 assert_eq!(
189 parse_midi(&[0xB0, 1, 64]),
190 Some(MidiEvent::ControlChange {
191 channel: MidiChannel::Ch1,
192 controller: 1,
193 value: 64,
194 })
195 );
196 // Program change is forwarded (the host routes it to program selection).
197 assert_eq!(
198 parse_midi(&[0xC0, 5]),
199 Some(MidiEvent::ProgramChange {
200 channel: MidiChannel::Ch1,
201 program: 5,
202 })
203 );
204 // SysEx, empty, and other non-channel-voice messages are dropped.
205 assert_eq!(parse_midi(&[0xF0, 1, 2]), None);
206 assert_eq!(parse_midi(&[]), None);
207 }
208}