Files
virtual-controller/plugin-reaper-relearn/main/src/domain/audio_hook.rs
T
Paul Lipscomb 7ecc718f5d Reorganize top-level directories with clearer naming convention
app -> app-desktop-macos, presets -> app-presets, server -> remote-server,
daw-config-reaper -> osc-config-daw, plugin-reaper-realearn -> plugin-reaper-relearn.
Updated run.py and presets.py path references accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:51:05 -04:00

712 lines
30 KiB
Rust

use crate::domain::{
classify_midi_message, AudioBlockProps, ControlEvent, ControlEventTimestamp,
DisplayAsPrettyHex, IncomingMidiMessage, InstanceId, MidiControlInput, MidiEvent,
MidiMessageClassification, MidiScanResult, MidiScanner, MidiTransformationContainer,
RealTimeProcessor, SharedRealTimeInstance, UnitId, GLOBAL_AUDIO_STATE,
};
use base::byte_pattern::{BytePattern, PatternByte};
use base::metrics_util::{measure_time, record_duration};
use base::non_blocking_lock;
use helgoboss_learn::{MidiSourceValue, RawMidiEvent, RawMidiEvents};
use helgoboss_midi::{DataEntryByteOrder, RawShortMessage, ShortMessage, ShortMessageType};
use helgobox_allocator::*;
use reaper_common_types::DurationInSeconds;
use reaper_high::{MidiInputDevice, MidiOutputDevice, Reaper};
use reaper_medium::{
MidiInputDeviceId, MidiOutputDeviceId, OnAudioBuffer, OnAudioBufferArgs, SendMidiTime,
MIDI_INPUT_FRAME_RATE,
};
use smallvec::SmallVec;
use std::fmt::{Display, Formatter};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use tinyvec::ArrayVec;
const AUDIO_HOOK_TASK_BULK_SIZE: usize = 1;
const FEEDBACK_TASK_BULK_SIZE: usize = 1000;
/// This needs to be thread-safe because if "Allow live FX multiprocessing" is active in the REAPER
/// preferences, the VST processing is executed in another thread than the audio hook!
pub type SharedRealTimeProcessor = Arc<Mutex<RealTimeProcessor>>;
pub type MidiCaptureSender = async_channel::Sender<MidiScanResult>;
#[derive(Debug)]
pub struct RequestMidiDeviceIdentityCommand {
pub output_device_id: MidiOutputDeviceId,
pub input_device_id: Option<MidiInputDeviceId>,
pub sender: async_channel::Sender<RequestMidiDeviceIdentityReply>,
}
#[derive(Debug)]
struct MidiDeviceInquiryTask {
command: RequestMidiDeviceIdentityCommand,
inquiry_sent_at: Instant,
}
#[derive(Clone, Debug)]
pub struct RequestMidiDeviceIdentityReply {
pub input_device_id: MidiInputDeviceId,
pub device_inquiry_reply: MidiDeviceInquiryReply,
}
#[derive(Clone, Debug)]
pub struct MidiDeviceInquiryReply {
pub message: ArrayVec<[u8; RawMidiEvent::MAX_LENGTH]>,
}
impl Display for MidiDeviceInquiryReply {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
DisplayAsPrettyHex(self.message.as_slice()).fmt(f)
}
}
// This kind of tasks is always processed, even after a rebirth when multiple processor syncs etc.
// have already accumulated. Because at the moment there's no way to request a full resync of all
// real-time processors from the control surface. In practice there's no danger that too many of
// those infrequent tasks accumulate so it's not an issue. Therefore the convention for now is to
// also send them when audio is not running.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum NormalAudioHookTask {
AddRealTimeInstance(InstanceId, SharedRealTimeInstance),
RemoveRealTimeInstance(InstanceId),
/// First parameter is the ID.
//
// Having the ID saves us from unnecessarily blocking the audio thread by looking into the
// processor.
AddRealTimeProcessor(UnitId, SharedRealTimeProcessor),
RemoveRealTimeProcessor(UnitId),
StartCapturingMidi(MidiCaptureSender),
StopCapturingMidi,
/// Instructs the audio hook to send a MIDI device inquiry to the given output device.
///
/// Gives up after about one second if no response received (by dropping the sender).
///
/// Gives up immediately if the output device or optional input device is not open.
RequestMidiDeviceIdentity(RequestMidiDeviceIdentityCommand),
#[cfg(feature = "playtime")]
PlaytimeClipEngineCommand(playtime_clip_engine::rt::audio_hook::PlaytimeAudioHookCommand),
}
/// A global feedback task (which is potentially sent very frequently).
#[derive(Debug)]
pub enum FeedbackAudioHookTask {
MidiDeviceFeedback(
MidiOutputDeviceId,
MidiSourceValue<'static, RawShortMessage>,
),
SendMidi(MidiOutputDeviceId, RawMidiEvents),
}
pub fn send_midi_device_feedback(
dev_id: MidiOutputDeviceId,
value: MidiSourceValue<RawShortMessage>,
) {
if let Some(events) = value.to_raw() {
MidiOutputDevice::new(dev_id).with_midi_output(|mo| {
if let Some(mo) = mo {
for event in events {
mo.send_msg(event, SendMidiTime::Instantly);
}
}
});
} else {
let shorts = value.to_short_messages(DataEntryByteOrder::MsbFirst);
if shorts[0].is_none() {
return;
}
MidiOutputDevice::new(dev_id).with_midi_output(|mo| {
if let Some(mo) = mo {
for short in shorts.iter().flatten() {
mo.send(*short, SendMidiTime::Instantly);
}
}
});
}
}
#[derive(Debug)]
pub struct RealearnAudioHook {
state: AudioHookState,
midi_device_inquiry_task: Option<MidiDeviceInquiryTask>,
real_time_instances: SmallVec<[(InstanceId, SharedRealTimeInstance); 256]>,
real_time_processors: SmallVec<[(UnitId, SharedRealTimeProcessor); 256]>,
normal_task_receiver: crossbeam_channel::Receiver<NormalAudioHookTask>,
feedback_task_receiver: crossbeam_channel::Receiver<FeedbackAudioHookTask>,
time_of_last_run: Option<Instant>,
initialized: bool,
midi_transformation_container: MidiTransformationContainer,
#[cfg(feature = "playtime")]
clip_engine_audio_hook: playtime_clip_engine::rt::audio_hook::PlaytimeAudioHook,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum AudioHookState {
Normal,
// This is not the instance-specific learning but the global one.
LearningSource {
sender: MidiCaptureSender,
midi_scanner: MidiScanner,
},
}
impl RealearnAudioHook {
pub fn new(
normal_task_receiver: crossbeam_channel::Receiver<NormalAudioHookTask>,
feedback_task_receiver: crossbeam_channel::Receiver<FeedbackAudioHookTask>,
) -> RealearnAudioHook {
Self {
state: AudioHookState::Normal,
midi_device_inquiry_task: None,
real_time_instances: Default::default(),
real_time_processors: Default::default(),
normal_task_receiver,
feedback_task_receiver,
time_of_last_run: None,
initialized: false,
midi_transformation_container: MidiTransformationContainer::new(),
#[cfg(feature = "playtime")]
clip_engine_audio_hook: playtime_clip_engine::rt::audio_hook::PlaytimeAudioHook::new(),
}
}
/// This should be called only once in the audio hardware thread, before everything else.
///
/// It does some per-thread allocation.
fn init_from_rt_thread(&mut self) {
// We have code, e.g. triggered by crossbeam_channel that requests the ID of the
// current thread. This operation needs an allocation at the first time it's executed
// on a specific thread. Let's do it here, globally exactly once. Then we can
// use assert_no_alloc() to detect real regular allocation issues.
// Please note that this doesn't have an effect if
// "Audio => Buffering => Allow live FX multiprocessing" is enabled in the REAPER prefs.
// Because then worker threads will drive ReaLearn plug-in and clips. That's not an
// issue for actual usage because the allocation is done only once per worker
// thread, right at the beginning. It's only a problem for testing with
// assert_no_alloc(). We introduced a similar thing in ColumnSource get_samples.
let thread_id = std::thread::current().id();
// The tracing library also does some allocation per thread (independent from the
// allocations that a subscriber does anyway).
tracing::info!(
"Initializing real-time logging from preview register (thread {:?})",
thread_id
);
#[cfg(feature = "playtime")]
{
self.clip_engine_audio_hook.init_from_rt_thread();
}
}
fn on_pre(&mut self, args: OnAudioBufferArgs) {
let current_time = Instant::now();
let time_of_last_run = self.time_of_last_run.replace(current_time);
// Increment counter
let block_props = AudioBlockProps::from_on_audio_buffer_args(&args);
let block_count = GLOBAL_AUDIO_STATE.advance(block_props);
let sample_count = block_count * args.len as u64;
// Call ReaLearn real-time processors (= process MIDI messages coming in from hardware devices).
// We do this here already, *before* pre-polling recording and advancing Playtime's tempo buffer (done in `on_pre_poll_1`)!
// Reason: When recording a new clip with tempo detection (= recording in silence mode), it's ideal
// if pressing a stop button on the controller *instantly* stops recording, detects the new tempo,
// applies it to the current block and starts playing the clip. Instantly = at the start of *this* block,
// without waiting until the next block. This is only possible if at the very beginning of the
// block it's already known that the stop button was pressed, before the block tempo props are determined.
//
// Playtime steps:
//
// 1. Process MIDI messages coming in from hardware devices
// 2. For each record-clip task:
// 2.1 Write incoming data to recording clip
// 2.2 Commit recording if necessary (if tempo detection enabled, also reset timeline with new tempo and no count-in)
// - Maybe we can unify manual stop and scheduled stop this way
// - One way is to process RtColumn commands generally in the audio hook.
// - Pro: No need to make "stop recording" a special command. All commands will be executed before preview registers,
// that is, before other columns have been played.
// - Con: I had the idea of a future refactoring: To do resolving/scheduling eagerly when processing
// the command instead of later when processing the slots. Since block tempo props are by definition
// not 100% decided yet when processing commands from the audio hook in this early stage, this would
// become impossible.
//
// 3. Advance tempo buffer
// 4. Play clip from start (process preview registers, which REAPER does after executing the pre-audio-hook)
let might_be_rebirth = {
if let Some(time) = time_of_last_run {
current_time.duration_since(time) > Duration::from_secs(1)
} else {
false
}
};
self.call_real_time_processors(block_props, sample_count, might_be_rebirth);
// Process ReaLearn feedback commands
self.process_feedback_commands();
// Process incoming commands, including Playtime commands
self.process_normal_commands(block_props);
// Pre-poll Playtime
#[cfg(feature = "playtime")]
{
self.clip_engine_audio_hook
.on_pre_poll(block_props.to_playtime(), args.reg);
}
// Poll real-time instances. If an instance has Playtime enabled, this also polls the real-time matrix.
// Important to do after pre-polling the Playtime audio hook, especially for one scenario:
// Leaving silence mode immediately with playing ignited clips: In this case, we do a timeline reset to zero.
// The ignited clips should start immediately, exactly from zero as soon as the timeline has been reset.
// If we called this before pre-polling Playtime audio hook, the real-time matrix would be called in the
// next audio cycle, after the timeline has already advanced on block from zero.
self.pre_poll_real_time_instances(block_props);
// Process some tasks
self.check_for_midi_device_inquiry_response();
}
fn on_post(&mut self, args: OnAudioBufferArgs) {
self.post_poll_real_time_instances();
#[cfg(not(feature = "playtime"))]
{
let _ = args;
}
#[cfg(feature = "playtime")]
{
let block_props = AudioBlockProps::from_on_audio_buffer_args(&args);
self.clip_engine_audio_hook
.on_post(block_props.to_playtime(), args.reg);
}
// Record some metrics
if let Some(time_of_last_run) = self.time_of_last_run {
record_duration("helgobox.rt.audio_hook.total", time_of_last_run.elapsed());
}
}
fn process_feedback_commands(&mut self) {
// Process global direct device feedback (since v2.8.0-pre6) - in order to
// have deterministic feedback ordering, which is important for multi-instance
// orchestration.
for task in self
.feedback_task_receiver
.try_iter()
.take(FEEDBACK_TASK_BULK_SIZE)
{
use FeedbackAudioHookTask::*;
match task {
MidiDeviceFeedback(dev_id, value) => {
send_midi_device_feedback(dev_id, value);
}
SendMidi(dev_id, raw_midi_events) => {
MidiOutputDevice::new(dev_id).with_midi_output(|mo| {
if let Some(mo) = mo {
for event in &raw_midi_events {
mo.send_msg(event, SendMidiTime::Instantly);
}
}
});
}
}
}
}
fn pre_poll_real_time_instances(&self, block_props: AudioBlockProps) {
for (_, i) in self.real_time_instances.iter() {
non_blocking_lock(i, "RealTimeInstance pre_poll").pre_poll(block_props);
}
}
fn post_poll_real_time_instances(&self) {
for (_, i) in self.real_time_instances.iter() {
non_blocking_lock(i, "RealTimeInstance post_poll").post_poll();
}
}
fn call_real_time_processors(
&mut self,
block_props: AudioBlockProps,
sample_count: u64,
might_be_rebirth: bool,
) {
match &mut self.state {
AudioHookState::Normal => {
self.call_real_time_processors_in_normal_state(
block_props,
might_be_rebirth,
sample_count,
);
}
AudioHookState::LearningSource {
sender,
midi_scanner,
} => {
for (_, p) in self.real_time_processors.iter() {
p.lock_recover()
.run_from_audio_hook_essential(might_be_rebirth);
}
for dev in Reaper::get().midi_input_devices() {
dev.with_midi_input(|mi| {
if let Some(mi) = mi {
for e in mi.get_read_buf() {
if let Some(res) = scan_midi(dev.id(), e, midi_scanner) {
let _ = sender.try_send(res);
}
}
}
});
}
if let Some(res) = midi_scanner.poll() {
// Source detected via polling. Return to normal mode.
let _ = sender.try_send(res);
}
}
};
}
fn call_real_time_processors_in_normal_state(
&mut self,
block_props: AudioBlockProps,
might_be_rebirth: bool,
sample_count: u64,
) {
// 1a. Drive real-time processors and determine used MIDI devices "on the go".
//
// Calling the real-time processor *before* processing its remove task has
// the benefit that it can still do some final work (e.g. clearing
// LEDs by sending zero feedback) before it's removed. That's also
// one of the reasons why we remove the real-time processor async by
// sending a message. It's okay if it's around for one cycle after a
// plug-in instance has unloaded (only the case if not the last instance).
//
let mut midi_dev_id_is_used = [false; MidiInputDeviceId::MAX_DEVICE_COUNT as usize];
let mut midi_devs_used_at_all = false;
let start_of_block_timestamp = ControlEventTimestamp::from_rt(
sample_count,
block_props.frame_rate,
DurationInSeconds::ZERO,
);
for (_, p) in self.real_time_processors.iter() {
// Since 1.12.0, we "drive" each plug-in instance's real-time processor
// primarily by the global audio hook. See https://github.com/helgoboss/helgobox/issues/84 why this is
// better. We also call it by the plug-in `process()` method though in order
// to be able to send MIDI to <FX output> and to
// stop doing so synchronously if the plug-in is
// gone.
let mut guard = p.lock_recover();
guard.run_from_audio_hook_all(might_be_rebirth, start_of_block_timestamp);
if guard.control_is_globally_enabled() {
if let MidiControlInput::Device(dev_id) = guard.midi_control_input() {
midi_dev_id_is_used[dev_id.get() as usize] = true;
midi_devs_used_at_all = true;
}
}
}
// 1b. Forward MIDI events from MIDI devices to ReaLearn instances and filter
// them globally if desired by the instance.
if midi_devs_used_at_all {
self.distribute_midi_events_to_processors(
block_props,
&midi_dev_id_is_used,
sample_count,
);
}
}
fn distribute_midi_events_to_processors(
&mut self,
block_props: AudioBlockProps,
midi_dev_id_is_used: &[bool; MidiInputDeviceId::MAX_DEVICE_COUNT as usize],
sample_count: u64,
) {
self.midi_transformation_container
.prepare(block_props.frame_rate);
for dev_id in 0..MidiInputDeviceId::MAX_DEVICE_COUNT {
if !midi_dev_id_is_used[dev_id as usize] {
continue;
}
let dev_id = MidiInputDeviceId::new(dev_id);
MidiInputDevice::new(dev_id).with_midi_input(|mi| {
if let Some(mi) = mi {
let event_list = mi.get_read_buf();
let mut bpos = 0;
while let Some(res) = event_list.enum_items(bpos) {
let next_bpos = res.next_bpos;
// Current control mode is checked further down the callstack. No need to
// check it here.
let our_event =
match MidiEvent::from_reaper(res.midi_event, block_props.frame_rate) {
Err(_) => continue,
Ok(e) => e,
};
let frame_offset_in_secs =
res.midi_event.frame_offset() as f64 / MIDI_INPUT_FRAME_RATE.get();
let timestamp = ControlEventTimestamp::from_rt(
sample_count,
block_props.frame_rate,
DurationInSeconds::new_panic(frame_offset_in_secs),
);
let our_event = ControlEvent::new(our_event, timestamp);
let mut filter_out_event = false;
for (_, p) in self.real_time_processors.iter() {
let mut guard = p.lock_recover();
if guard.control_is_globally_enabled()
&& guard.midi_control_input() == MidiControlInput::Device(dev_id)
&& guard.process_incoming_midi_from_audio_hook(
our_event,
&mut self.midi_transformation_container,
)
{
filter_out_event = true;
}
}
if filter_out_event {
// Take event out of input buffer. In this case, we must not adjust bpos
// because just deleting the item has the same effect.
event_list.delete_item(bpos);
} else {
// Move cursor to next position
bpos = next_bpos;
}
}
// Add transformed events *after* iterating
for event in self
.midi_transformation_container
.drain_same_device_events()
{
let reaper_event = reaper_medium::MidiEvent::from_raw_ref(event.as_ref());
event_list.add_item(reaper_event);
}
}
});
}
// Process MIDI "MIDI: Send message" to "Device input" across multiple devices
for evt in self
.midi_transformation_container
.drain_other_device_events()
{
MidiInputDevice::new(evt.input_device_id).with_midi_input(|mi| {
if let Some(mi) = mi {
let event_list = mi.get_read_buf();
let reaper_event = reaper_medium::MidiEvent::from_raw_ref(evt.event.as_ref());
event_list.add_item(reaper_event);
}
});
}
}
fn process_midi_device_inquiry_command(
&mut self,
command: RequestMidiDeviceIdentityCommand,
) -> Result<(), &'static str> {
let output_dev_id = command.output_device_id;
let output_dev = Reaper::get().midi_output_device_by_id(output_dev_id);
output_dev.with_midi_output(|output| -> Result<(), &'static str> {
let output = output.ok_or("MIDI output device not open")?;
let inquiry = RawMidiEvent::try_from_slice(0, MIDI_DEVICE_INQUIRY_REQUEST)?;
tracing::debug!(msg = "Sending MIDI device inquiry...", ?output_dev_id);
output.send_msg(inquiry, SendMidiTime::Instantly);
Ok(())
})?;
let task = MidiDeviceInquiryTask {
command,
inquiry_sent_at: Instant::now(),
};
self.midi_device_inquiry_task = Some(task);
Ok(())
}
fn check_for_midi_device_inquiry_response(&mut self) {
let Some(task) = self.midi_device_inquiry_task.as_ref() else {
// No task
return;
};
if !task.check_for_midi_device_inquiry_response() {
// Task done
self.midi_device_inquiry_task = None;
}
}
fn process_normal_commands(&mut self, block_props: AudioBlockProps) {
use NormalAudioHookTask::*;
let mut count = 0;
while let Ok(task) = self.normal_task_receiver.try_recv() {
match task {
AddRealTimeInstance(id, p) => {
self.real_time_instances.push((id, p));
}
RemoveRealTimeInstance(id) => {
if let Some(pos) = self.real_time_instances.iter().position(|(i, _)| i == &id) {
self.real_time_instances.swap_remove(pos);
}
}
AddRealTimeProcessor(id, p) => {
self.real_time_processors.push((id, p));
}
RemoveRealTimeProcessor(id) => {
if let Some(pos) = self.real_time_processors.iter().position(|(i, _)| i == &id)
{
self.real_time_processors.swap_remove(pos);
}
}
StartCapturingMidi(sender) => {
self.state = AudioHookState::LearningSource {
sender,
midi_scanner: Default::default(),
}
}
StopCapturingMidi => {
self.state = AudioHookState::Normal;
}
RequestMidiDeviceIdentity(command) => {
let _ = self.process_midi_device_inquiry_command(command);
}
#[cfg(feature = "playtime")]
PlaytimeClipEngineCommand(command) => {
let _ = self
.clip_engine_audio_hook
.on_pre_process_command(command, block_props.to_playtime());
}
}
// Don't take too much at once
count += 1;
if count == AUDIO_HOOK_TASK_BULK_SIZE {
break;
}
}
let _ = block_props;
}
}
impl OnAudioBuffer for RealearnAudioHook {
fn call(&mut self, args: OnAudioBufferArgs) {
if !self.initialized {
self.init_from_rt_thread();
self.initialized = true;
}
assert_no_alloc(|| {
let is_pre = !args.is_post;
if is_pre {
measure_time("helgobox.rt.audio_hook.pre", || {
self.on_pre(args);
});
} else {
measure_time("helgobox.rt.audio_hook.post", || {
self.on_post(args);
});
}
});
}
}
fn scan_midi(
dev_id: MidiInputDeviceId,
evt: &reaper_medium::MidiEvent,
midi_scanner: &mut MidiScanner,
) -> Option<MidiScanResult> {
let msg = IncomingMidiMessage::from_reaper(evt.message()).ok()?;
if classify_midi_message(msg) != MidiMessageClassification::Normal {
return None;
}
use IncomingMidiMessage::*;
match msg {
Short(short_msg) => midi_scanner.feed_short(short_msg, Some(dev_id)),
SysEx(bytes) => {
// It's okay here to temporarily permit allocation because crackling during learning
// is not a showstopper.
permit_alloc(|| MidiScanResult::try_from_bytes(bytes, Some(dev_id)).ok())
}
}
}
pub trait RealTimeProcessorLocker {
fn lock_recover(&self) -> MutexGuard<RealTimeProcessor>;
}
impl RealTimeProcessorLocker for SharedRealTimeProcessor {
/// This ignores poisoning, which is okay in our case because if the real-time
/// processor has panicked, we will see it in the REAPER console. No need to
/// hide that error with lots of follow-up poisoning errors! This is a kind of
/// recovery mechanism.
fn lock_recover(&self) -> MutexGuard<RealTimeProcessor> {
non_blocking_lock(self, "RealTimeProcessor")
}
}
impl MidiDeviceInquiryTask {
/// Returns `false` if task not necessary anymore.
pub fn check_for_midi_device_inquiry_response(&self) -> bool {
// Give up if waited too long for response.
if self.inquiry_sent_at.elapsed() > Duration::from_secs(1) {
tracing::debug!(msg = "Gave up waiting for MIDI device identity reply after timeout");
return false;
}
// Check MIDI devices in question for response
if let Some(id) = self.command.input_device_id {
// Check user-defined input device for possible response
let dev = Reaper::get().midi_input_device_by_id(id);
if !self.process_input_dev(dev) {
return false;
}
} else {
// Check all input devices for possible response
for dev in Reaper::get().midi_input_devices() {
if !self.process_input_dev(dev) {
return false;
}
}
}
// Return true as long as we haven't got a response yet.
true
}
/// Returns `false` if task not necessary anymore.
fn process_input_dev(&self, dev: MidiInputDevice) -> bool {
dev.with_midi_input(|mi| {
let Some(mi) = mi else {
return true;
};
for evt in mi.get_read_buf() {
let msg = evt.message();
let Ok(short) = msg.to_short_message() else {
continue;
};
if short.r#type() == ShortMessageType::SystemExclusiveStart {
let reply_pattern = &MIDI_DEVICE_INQUIRY_REPLY_PATTERN;
let reply_pattern =
reply_pattern.get_or_init(create_device_inquiry_reply_pattern);
let is_identity_reply = reply_pattern.matches(msg.as_slice());
let Ok(message) = ArrayVec::try_from(msg.as_slice()) else {
// Couldn't store the reply in the array. Shouldn't happen here because
// we set the ArrayVec's capacity to the max size of the raw event.
// So at a maximum it will be cropped.
return false;
};
if is_identity_reply {
let reply = RequestMidiDeviceIdentityReply {
input_device_id: dev.id(),
device_inquiry_reply: MidiDeviceInquiryReply { message },
};
tracing::debug!(msg = "Received MIDI device identity reply", ?reply);
let _ = self.command.sender.try_send(reply);
return false;
}
}
}
true
})
}
}
const MIDI_DEVICE_INQUIRY_REQUEST: &[u8] = &[0xF0, 0x7E, 0x7F, 0x06, 0x01, 0xF7];
static MIDI_DEVICE_INQUIRY_REPLY_PATTERN: OnceLock<BytePattern> = OnceLock::new();
fn create_device_inquiry_reply_pattern() -> BytePattern {
use Fixed as F;
use PatternByte::*;
BytePattern::new(vec![
F(0xF0),
F(0x7E),
Single,
F(0x06),
F(0x02),
Multi,
F(0xF7),
])
}