ADR-008: Device Identity, Port Resolution, Rule Engine & Action Dispatch
Status
Fully Superseded by ADR-009
All components adopted by ADR-009: DeviceIdentity, DeviceMatcher, PortResolver, BindingState, disambiguation workflow, lock-free MessageProcessor (ArcSwap<CompiledRuleSet>), ActionDispatcher, MidiTransform, CompiledTrigger.
Rejected: [[device]]/[[map]] config schema (ADR-009 uses [[devices]] + [[modes.mappings]]).
See ADR-009 D17 for full disposition.
Context
Conductor is a multi-protocol input automation system that translates MIDI, HID, and OSC messages into configurable actions ranging from OS automation to forwarded/transformed MIDI output. Users define maps that bind device inputs to actions. These maps must reference devices and output targets in a way that survives hardware reconnection, USB topology changes, and cross-session persistence.
Problem Statement
-
MIDI Ports Are Volatile: Port indices and names are not stable across sessions, platforms, or USB topologies. A device at port
28:0on Linux may appear as32:0after a reboot. On Windows (WinMM), ports are enumerated by index alone — completely unstable. Even macOS CoreMIDI'sMIDIUniqueIDcan change when USB topology shifts. -
Maps Reference Physical Devices: Users configure maps saying "when my KeyStep sends CC 74, forward it as CC 1 to FM8." This requires stable device identification that outlives port reassignment, and the ability to gracefully handle devices that appear, disappear, and reappear.
-
No Unified Message Processing Pipeline: The current architecture lacks a formalised rule engine that can match incoming messages against configured maps, apply transforms, and dispatch to heterogeneous action targets (MIDI output, OS automation, OSC, HID emulation) with deterministic latency.
-
Multiple Identical Devices: Users may connect two or more units of the same controller (e.g., two Launchpad Minis). These have identical USB VID:PID, identical port names, and (on Linux/Windows) no platform-provided unique identifier. The system must differentiate them.
-
Hot-Plug Without Restart: Devices connect and disconnect during a session. Maps targeting absent devices should go dormant and automatically reactivate when the device reappears — without user intervention or daemon restart.
Requirements
R1: Define a stable, platform-agnostic device identity model that decouples map configuration from volatile port indices and names
R2: Implement a runtime port resolver that binds logical device aliases to physical ports at startup and on hot-plug events, using a prioritised chain of matching strategies
R3: Design a lock-free, latency-sensitive rule engine that evaluates incoming messages against map rules and dispatches actions without holding mutexes on the hot path
R4: Support heterogeneous action dispatch: MIDI forwarding (with transform), OS automation (keystrokes, app launch, shell), OSC output, and HID emulation
R5: Handle multiple identical devices through a combination of platform-specific identifiers, USB topology hints, and user-assisted disambiguation
R6: Provide graceful degradation when mapped devices are absent, with automatic reactivation on reconnection
R7: Expose device scanning and identity resolution via both CLI and the existing MCP tool interface (per ADR-007)
Relationship to Other ADRs
| ADR | Relationship | Notes |
|---|---|---|
| ADR-007 (LLM Integration) | Extends | MCP tools conductor_list_devices and conductor_connect_device must use the DeviceIdentity model; MIDI Learn must resolve through the port resolver |
| ADR-006 (Plugin Architecture) | Consumed by | Plugins register as action dispatch targets |
| ADR-005 (Configuration Schema) | Extends | Device identity and map definitions extend the TOML config schema |
Decision
Core Concepts
The architecture introduces three foundational abstractions:
| Concept | Purpose | Lifecycle |
|---|---|---|
| DeviceIdentity | Stable, user-configured reference to a physical device | Persisted in config |
| PortResolver | Runtime binder that matches identities to live ports | Session-scoped, reacts to hot-plug |
| MessagePipeline | Lock-free rule engine + action dispatch chain | Runs on dedicated thread per device |
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────────┐
│ CONDUCTOR MESSAGE PIPELINE │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ CONFIGURATION LAYER │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │ │
│ │ │ DeviceIdentity │ │ Map Rules │ │ Action Definitions │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ alias: "keystep"│ │ source: "keystep"│ │ type: MidiForward │ │ │
│ │ │ matchers: [ │ │ match: CC(74) │ │ target: "fm8" │ │ │
│ │ │ ExactName(..) │ │ action: Forward │ │ transform: CC(1) │ │ │
│ │ │ VidPid(..) │ │ │ │ │ │ │
│ │ │ ] │ │ │ │ │ │ │
│ │ └─────────────────┘ └─────────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Config Load / Hot-Reload │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ PORT RESOLUTION LAYER │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────────┐ │ │
│ │ │ PortResolver │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │
│ │ │ │ CoreMIDI │ │ Exact │ │ Contains │ │ USB │ │ │ │
│ │ │ │ UniqueID │───▶│ Name │───▶│ Name │───▶│ VID:PID │ │ │ │
│ │ │ │ (macOS) │ │ Match │ │ Match │ │ Match │ │ │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │
│ │ │ ▲ │ │ │ │ │ │
│ │ │ │ ▼ ▼ ▼ │ │ │
│ │ │ Platform APIs ┌──────────────────────────────────────┐ │ │ │
│ │ │ │ Binding Table │ │ │ │
│ │ │ │ alias → (port, matched_by, state) │ │ │ │
│ │ │ └──────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ Hot-Plug ◄─┤ │ Bindings │ │
│ │ Polling │ ▼ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ MESSAGE PROCESSING LAYER │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────────┐ ┌────────────────────────┐ │ │
│ │ │ MIDI In │ │ Rule Engine │ │ Action Dispatch │ │ │
│ │ │ (midir) │────▶│ (lock-free) │────▶│ │ │ │
│ │ ├────────────┤ │ │ │ ┌──────────────────┐ │ │ │
│ │ │ HID In │────▶│ 1. Classify │ │ │ MIDI Forward │ │ │ │
│ │ │ (hidapi) │ │ 2. Match Rule │ │ │ (midir output) │ │ │ │
│ │ ├────────────┤ │ 3. Transform │ │ ├──────────────────┤ │ │ │
│ │ │ OSC In │────▶│ 4. Dispatch │ │ │ OS Automation │ │ │ │
│ │ │ (rosc) │ │ │ │ │ (enigo/arboard) │ │ │ │
│ │ └────────────┘ └────────────────┘ │ ├──────────────────┤ │ │ │
│ │ │ │ OSC Output │ │ │ │
│ │ │ │ (rosc) │ │ │ │
│ │ │ ├──────────────────┤ │ │ │
│ │ │ │ HID Emulation │ │ │ │
│ │ │ │ (platform) │ │ │ │
│ │ │ ├──────────────────┤ │ │ │
│ │ │ │ Plugin Dispatch │ │ │ │
│ │ │ │ (ADR-006) │ │ │ │
│ │ │ └──────────────────┘ │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ OBSERVABILITY LAYER │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Latency │ │ Message │ │ Binding │ │ │
│ │ │ Histograms │ │ Counters │ │ State Log │ │ │
│ │ │ (per-rule) │ │ (per-device)│ │ (events) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
Component Design
1. Device Identity Model
Location: conductor-core/src/identity.rs
The DeviceIdentity is the stable reference that maps and profiles use to reference physical devices. It is never a port index. It is always serializable to TOML config and survives across sessions, reboots, and platform changes.
use serde::{Deserialize, Serialize};
/// Stable, user-configured reference to a physical device.
/// Maps and profiles reference devices by `alias`, never by port index.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceIdentity {
/// User-friendly name used in maps and profiles.
/// Must be unique within a config. Used as the key everywhere.
/// Examples: "keystep", "launchpad-left", "fm8-virtual"
pub alias: String,
/// Human-readable description (optional, for UI/docs)
pub description: Option<String>,
/// Protocol this device speaks
pub protocol: DeviceProtocol,
/// Direction: input, output, or bidirectional
pub direction: DeviceDirection,
/// Ordered list of matching strategies. Tried in order; first match wins.
/// More specific matchers should come first.
pub matchers: Vec<DeviceMatcher>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum DeviceProtocol {
Midi,
Hid,
Osc,
Virtual, // IAC Driver, loopback, etc.
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum DeviceDirection {
Input,
Output,
Bidirectional,
}
/// Matching strategy for binding a DeviceIdentity to a live port.
/// Strategies are tried in the order listed in the config.
/// More specific strategies (CoreMidiUniqueId, UsbVidPidSerial) should
/// come before less specific ones (NameContains, NameRegex).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DeviceMatcher {
/// macOS CoreMIDI unique ID. Most stable identifier on macOS.
/// Persists across reboots when the device is on the same USB port.
/// Not available on Linux or Windows.
CoreMidiUniqueId(i32),
/// USB Vendor ID + Product ID + optional serial number.
/// VID:PID is stable per product model (not per unit).
/// Serial number differentiates units but not all devices report one.
UsbIdentifier {
vendor_id: u16,
product_id: u16,
serial: Option<String>,
},
/// Exact string match on the port name reported by the OS/driver.
/// Stable within a single OS but names differ across platforms.
/// Example: "Arturia KeyStep 37" (macOS), "KeyStep 37:KeyStep 37 MIDI 1 28:0" (Linux ALSA)
ExactName(String),
/// Substring match on port name. Useful for devices that append
/// serial numbers or port indices to their name.
/// Example: "KeyStep" matches "Arturia KeyStep 37" and "KeyStep 37 MIDI 1"
NameContains(String),
/// Regex match on port name. Escape hatch for complex naming patterns.
/// Example: r"Launchpad.*MK\d+" matches any Launchpad generation.
NameRegex(String),
/// Platform-specific persistent identifier string.
/// On macOS: CoreMIDI unique ID as string.
/// On Windows (WinRT): Device instance ID.
/// On Linux: ALSA card path (e.g., "/dev/snd/midiC1D0").
PlatformId(String),
/// Manual USB topology hint: bus and device address.
/// Useful for differentiating identical devices on specific USB ports.
/// Fragile — breaks if user moves the USB cable.
UsbTopology {
bus: u8,
address: u8,
},
}
impl DeviceMatcher {
/// Specificity score for prioritising match strategies.
/// Higher = more specific = more trustworthy.
pub fn specificity(&self) -> u8 {
match self {
Self::CoreMidiUniqueId(_) => 100,
Self::UsbIdentifier { serial: Some(_), .. } => 95,
Self::PlatformId(_) => 90,
Self::UsbTopology { .. } => 85,
Self::UsbIdentifier { serial: None, .. } => 70,
Self::ExactName(_) => 60,
Self::NameContains(_) => 40,
Self::NameRegex(_) => 30,
}
}
}
Configuration (TOML):
# ~/.conductor/config.toml
[[device]]
alias = "keystep"
description = "Arturia KeyStep 37 — primary controller"
protocol = "midi"
direction = "bidirectional"
matchers = [
{ type = "usb_identifier", vendor_id = 0x1C75, product_id = 0x0206 },
{ type = "exact_name", value = "Arturia KeyStep 37" },
{ type = "name_contains", value = "KeyStep" },
]
[[device]]
alias = "fm8"
description = "NI FM8 virtual MIDI input (via IAC or loopMIDI)"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "FM8 Virtual Input" },
{ type = "name_contains", value = "FM8" },
]
[[device]]
alias = "launchpad-left"
description = "Left Launchpad Mini (stage left, USB port A)"
protocol = "midi"
direction = "input"
matchers = [
{ type = "core_midi_unique_id", value = 1847293 },
{ type = "usb_identifier", vendor_id = 0x1235, product_id = 0x0113, serial = "LP-00A1" },
{ type = "usb_topology", bus = 1, address = 3 },
{ type = "name_contains", value = "Launchpad Mini" },
]
[[device]]
alias = "launchpad-right"
description = "Right Launchpad Mini (stage right, USB port B)"
protocol = "midi"
direction = "input"
matchers = [
{ type = "core_midi_unique_id", value = 1847294 },
{ type = "usb_identifier", vendor_id = 0x1235, product_id = 0x0113, serial = "LP-00B2" },
{ type = "usb_topology", bus = 1, address = 4 },
{ type = "name_contains", value = "Launchpad Mini" },
]
2. Port Resolution Engine
Location: conductor-core/src/resolver.rs
The PortResolver is the runtime component that binds DeviceIdentity aliases to live, open port handles. It runs at startup and re-evaluates on hot-plug events.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::watch;
/// The state of a device binding
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum BindingState {
/// Device is connected and the port handle is live
Bound {
port_name: String,
port_index: usize,
matched_by: DeviceMatcher,
bound_at: Instant,
},
/// Device is configured but not currently connected.
/// Maps targeting this device are dormant.
Unbound {
last_seen: Option<Instant>,
last_port_name: Option<String>,
},
/// Multiple ports matched — requires user disambiguation.
/// Resolution is paused until the user selects one.
Ambiguous {
candidates: Vec<PortCandidate>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PortCandidate {
pub port_name: String,
pub port_index: usize,
pub matched_by: DeviceMatcher,
pub specificity: u8,
}
/// A resolved binding: alias → live port state
#[derive(Clone, Debug)]
pub struct BoundPort {
pub alias: String,
pub state: BindingState,
pub identity: DeviceIdentity,
}
/// Port resolution events for the rest of the system to react to
#[derive(Clone, Debug)]
pub enum PortEvent {
/// A device was successfully bound to a port
DeviceBound { alias: String, port_name: String },
/// A device was lost (disconnected)
DeviceLost { alias: String, last_port_name: String },
/// A device returned after being lost
DeviceReturned { alias: String, port_name: String },
/// Multiple candidates found — needs user input
AmbiguousDevice { alias: String, candidates: Vec<PortCandidate> },
/// A new unconfigured device appeared (not in any identity)
UnknownDeviceAppeared { port_name: String, port_index: usize },
}
pub struct PortResolver {
/// Configured device identities
identities: Vec<DeviceIdentity>,
/// Current binding state: alias → binding
bindings: HashMap<String, BoundPort>,
/// Channel to broadcast port events
event_tx: watch::Sender<Vec<PortEvent>>,
/// Platform-specific port enumerator
enumerator: Box<dyn PortEnumerator>,
/// Polling interval for hot-plug detection
poll_interval: Duration,
/// Snapshot of last-known port list (for diff-based hot-plug detection)
last_port_snapshot: Vec<PortInfo>,
}
/// Platform-abstracted port information
#[derive(Clone, Debug, PartialEq)]
pub struct PortInfo {
pub name: String,
pub index: usize,
pub protocol: DeviceProtocol,
pub direction: DeviceDirection,
pub platform_id: Option<String>,
pub usb_info: Option<UsbDeviceInfo>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UsbDeviceInfo {
pub vendor_id: u16,
pub product_id: u16,
pub serial: Option<String>,
pub bus: Option<u8>,
pub address: Option<u8>,
}
/// Platform-specific port enumeration
#[async_trait]
pub trait PortEnumerator: Send + Sync {
/// List all currently available ports with metadata
async fn enumerate(&self) -> Vec<PortInfo>;
/// Get platform-specific unique ID for a port (if available)
async fn get_persistent_id(&self, port: &PortInfo) -> Option<String>;
}
impl PortResolver {
/// Resolve all configured identities against currently available ports.
/// Called at startup and on each hot-plug detection cycle.
pub async fn resolve_all(&mut self) -> Vec<PortEvent> {
let available_ports = self.enumerator.enumerate().await;
let mut events = Vec::new();
// Track which ports have been claimed to prevent double-binding
let mut claimed_ports: HashMap<usize, String> = HashMap::new();
// Sort identities by highest-specificity matcher (most specific first)
let mut sorted_identities = self.identities.clone();
sorted_identities.sort_by(|a, b| {
let a_max = a.matchers.iter().map(|m| m.specificity()).max().unwrap_or(0);
let b_max = b.matchers.iter().map(|m| m.specificity()).max().unwrap_or(0);
b_max.cmp(&a_max)
});
for identity in &sorted_identities {
let candidates: Vec<PortCandidate> = identity
.matchers
.iter()
.flat_map(|matcher| {
available_ports
.iter()
.filter(|port| {
port.protocol == identity.protocol
&& !claimed_ports.contains_key(&port.index)
&& self.matches(matcher, port)
})
.map(|port| PortCandidate {
port_name: port.name.clone(),
port_index: port.index,
matched_by: matcher.clone(),
specificity: matcher.specificity(),
})
})
.collect();
// Deduplicate candidates by port_index, keeping highest specificity
let mut deduped: HashMap<usize, PortCandidate> = HashMap::new();
for candidate in candidates {
deduped
.entry(candidate.port_index)
.and_modify(|existing| {
if candidate.specificity > existing.specificity {
*existing = candidate.clone();
}
})
.or_insert(candidate);
}
let unique_candidates: Vec<PortCandidate> = deduped.into_values().collect();
let previous_state = self.bindings.get(&identity.alias).map(|b| &b.state);
let new_state = match unique_candidates.len() {
0 => {
// No match — device is absent
if matches!(previous_state, Some(BindingState::Bound { .. })) {
let last_name = match previous_state {
Some(BindingState::Bound { port_name, .. }) => port_name.clone(),
_ => "unknown".to_string(),
};
events.push(PortEvent::DeviceLost {
alias: identity.alias.clone(),
last_port_name: last_name.clone(),
});
BindingState::Unbound {
last_seen: Some(Instant::now()),
last_port_name: Some(last_name),
}
} else {
BindingState::Unbound {
last_seen: None,
last_port_name: None,
}
}
}
1 => {
// Unique match — bind it
let candidate = &unique_candidates[0];
claimed_ports.insert(candidate.port_index, identity.alias.clone());
let was_unbound = !matches!(previous_state, Some(BindingState::Bound { .. }));
if was_unbound && previous_state.is_some() {
events.push(PortEvent::DeviceReturned {
alias: identity.alias.clone(),
port_name: candidate.port_name.clone(),
});
} else if previous_state.is_none() {
events.push(PortEvent::DeviceBound {
alias: identity.alias.clone(),
port_name: candidate.port_name.clone(),
});
}
BindingState::Bound {
port_name: candidate.port_name.clone(),
port_index: candidate.port_index,
matched_by: candidate.matched_by.clone(),
bound_at: Instant::now(),
}
}
_ => {
// Ambiguous — multiple ports matched the same identity
events.push(PortEvent::AmbiguousDevice {
alias: identity.alias.clone(),
candidates: unique_candidates.clone(),
});
BindingState::Ambiguous {
candidates: unique_candidates,
}
}
};
self.bindings.insert(
identity.alias.clone(),
BoundPort {
alias: identity.alias.clone(),
state: new_state,
identity: identity.clone(),
},
);
}
// Detect unknown devices (connected but not in any identity)
for port in &available_ports {
if !claimed_ports.contains_key(&port.index) {
events.push(PortEvent::UnknownDeviceAppeared {
port_name: port.name.clone(),
port_index: port.index,
});
}
}
// Broadcast events
if !events.is_empty() {
let _ = self.event_tx.send(events.clone());
}
events
}
/// Hot-plug detection loop. Polls for port changes at the configured interval.
/// Prefer platform-native callbacks where available (CoreMIDI notifications
/// on macOS, udev on Linux) but fall back to polling universally.
pub async fn hot_plug_loop(&mut self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
loop {
tokio::select! {
_ = tokio::time::sleep(self.poll_interval) => {
let current_ports = self.enumerator.enumerate().await;
if current_ports != self.last_port_snapshot {
self.last_port_snapshot = current_ports;
self.resolve_all().await;
}
}
_ = shutdown.changed() => break,
}
}
}
fn matches(&self, matcher: &DeviceMatcher, port: &PortInfo) -> bool {
match matcher {
DeviceMatcher::ExactName(name) => port.name == *name,
DeviceMatcher::NameContains(substr) => port.name.contains(substr.as_str()),
DeviceMatcher::NameRegex(pattern) => {
regex::Regex::new(pattern)
.map(|re| re.is_match(&port.name))
.unwrap_or(false)
}
DeviceMatcher::UsbIdentifier { vendor_id, product_id, serial } => {
port.usb_info.as_ref().map_or(false, |usb| {
usb.vendor_id == *vendor_id
&& usb.product_id == *product_id
&& match serial {
Some(s) => usb.serial.as_deref() == Some(s.as_str()),
None => true, // VID:PID match is sufficient if no serial specified
}
})
}
DeviceMatcher::UsbTopology { bus, address } => {
port.usb_info.as_ref().map_or(false, |usb| {
usb.bus == Some(*bus) && usb.address == Some(*address)
})
}
DeviceMatcher::CoreMidiUniqueId(id) => {
port.platform_id.as_deref() == Some(&id.to_string())
}
DeviceMatcher::PlatformId(id) => {
port.platform_id.as_deref() == Some(id.as_str())
}
}
}
}
3. Rule Engine (Lock-Free Message Processing)
Location: conductor-core/src/engine.rs
The rule engine is the performance-critical hot path. It receives raw messages from device input threads, evaluates them against compiled rule sets, and dispatches actions. No locks are held during message evaluation.
use crossbeam::channel::{Receiver, Sender, bounded};
use std::sync::Arc;
/// A compiled, immutable rule set. Created from config at load time.
/// Swapped atomically via Arc when config changes — the hot path
/// never locks or waits.
#[derive(Clone, Debug)]
pub struct CompiledRuleSet {
/// Rules indexed by source device alias for O(1) lookup
rules_by_source: HashMap<String, Vec<CompiledRule>>,
/// Global rules (match any source)
global_rules: Vec<CompiledRule>,
/// Version counter for cache invalidation
version: u64,
}
/// A single compiled rule: pre-validated, pre-optimised for fast matching
#[derive(Clone, Debug)]
pub struct CompiledRule {
/// Unique rule ID for tracing and debugging
pub id: u64,
/// Human-readable name from config
pub name: Option<String>,
/// Pre-compiled trigger matcher
pub trigger: CompiledTrigger,
/// Pre-compiled action to dispatch
pub action: CompiledAction,
/// Optional condition (mode, profile, time-of-day, etc.)
pub condition: Option<RuleCondition>,
/// Priority for conflict resolution (higher wins)
pub priority: i32,
/// Whether to consume the message (prevent further rule matching)
pub consume: bool,
}
/// Pre-compiled trigger for fast matching without allocation
#[derive(Clone, Debug)]
pub enum CompiledTrigger {
/// Match a specific MIDI note on/off
MidiNote {
channel: Option<u8>, // None = any channel
note: u8,
velocity_min: u8, // 0 = any
velocity_max: u8, // 127 = any
event_type: NoteEventType, // NoteOn, NoteOff, Both
},
/// Match a MIDI CC message
MidiCC {
channel: Option<u8>,
cc: u8,
value_min: u8,
value_max: u8,
},
/// Match a range of MIDI notes (e.g., drum pads 36-51)
MidiNoteRange {
channel: Option<u8>,
note_min: u8,
note_max: u8,
velocity_min: u8,
velocity_max: u8,
},
/// Match MIDI program change
MidiProgramChange {
channel: Option<u8>,
program: Option<u8>, // None = any program
},
/// Match MIDI pitch bend
MidiPitchBend {
channel: Option<u8>,
},
/// Match any MIDI message (passthrough)
MidiAny,
/// Match an HID button press/release
HidButton {
usage_page: u16,
usage_id: u16,
pressed: bool,
},
/// Match an HID axis value
HidAxis {
usage_page: u16,
usage_id: u16,
value_min: i32,
value_max: i32,
},
/// Match an OSC message by address pattern
OscAddress {
pattern: String, // OSC address pattern with wildcards
},
}
#[derive(Clone, Debug)]
pub enum NoteEventType {
NoteOn,
NoteOff,
Both,
}
/// Pre-compiled action ready for dispatch
#[derive(Clone, Debug)]
pub enum CompiledAction {
/// Forward MIDI to another device with optional transform
MidiForward {
target_alias: String,
transform: Option<MidiTransform>,
},
/// Send a specific MIDI message (not forwarding — generating)
MidiSend {
target_alias: String,
message: Vec<u8>, // Raw MIDI bytes
},
/// OS automation: keystroke
Keystroke {
keys: Vec<String>,
modifiers: Vec<String>,
},
/// OS automation: launch application
Launch {
app: String,
args: Vec<String>,
},
/// OS automation: shell command
Shell {
command: String,
args: Vec<String>,
working_dir: Option<String>,
},
/// Send OSC message
OscSend {
target: String, // host:port
address: String, // OSC address
args: Vec<OscArg>, // OSC arguments
},
/// Switch Conductor mode
ModeChange {
mode: String,
},
/// Execute a sequence of actions
Sequence {
actions: Vec<CompiledAction>,
delay_between_ms: u64,
},
/// Dispatch to a plugin (ADR-006)
PluginAction {
plugin_name: String,
action_name: String,
params: serde_json::Value,
},
/// No-op (consume the message but do nothing)
Suppress,
}
/// MIDI message transform applied during forwarding
#[derive(Clone, Debug)]
pub struct MidiTransform {
/// Remap channel (None = keep original)
pub channel: Option<u8>,
/// Remap CC number (for CC messages)
pub cc: Option<u8>,
/// Remap note number (for Note messages)
pub note: Option<u8>,
/// Scale velocity: output = (input * scale) + offset, clamped to 0-127
pub velocity_scale: Option<f32>,
pub velocity_offset: Option<i8>,
/// Invert value (127 - value) — useful for fader direction correction
pub invert_value: bool,
/// Value curve: linear, logarithmic, exponential, or custom LUT
pub curve: Option<ValueCurve>,
}
#[derive(Clone, Debug)]
pub enum ValueCurve {
Linear,
Logarithmic,
Exponential,
/// Custom 128-entry lookup table for arbitrary curves
Lut(Box<[u8; 128]>),
}
/// Condition that must be true for the rule to fire
#[derive(Clone, Debug)]
pub enum RuleCondition {
/// Only fire in a specific mode
InMode(String),
/// Only fire when a specific profile is active
InProfile(String),
/// Boolean combination
And(Box<RuleCondition>, Box<RuleCondition>),
Or(Box<RuleCondition>, Box<RuleCondition>),
Not(Box<RuleCondition>),
}
Message Processing Loop (the hot path):
use std::sync::atomic::{AtomicU64, Ordering};
use arc_swap::ArcSwap;
/// The core message processor. Runs on a dedicated thread per input device.
/// Uses `arc_swap` for lock-free rule set swapping on config reload.
pub struct MessageProcessor {
/// Current compiled rule set — swapped atomically on config reload
rule_set: Arc<ArcSwap<CompiledRuleSet>>,
/// Current active mode
active_mode: Arc<ArcSwap<String>>,
/// Action dispatch channel (bounded, non-blocking send)
action_tx: Sender<ActionEnvelope>,
/// Message counter for observability
message_count: AtomicU64,
/// Rule evaluation latency tracker (lock-free ring buffer)
latency_tracker: Arc<LatencyTracker>,
}
/// An action ready for dispatch, with full provenance
#[derive(Clone, Debug)]
pub struct ActionEnvelope {
/// The action to execute
pub action: CompiledAction,
/// Which rule triggered this action
pub rule_id: u64,
/// Source device alias
pub source_alias: String,
/// Original message (for debugging/logging)
pub original_message: RawMessage,
/// Timestamp of the original message
pub timestamp: Instant,
}
impl MessageProcessor {
/// Process a single incoming message. Called from the midir/hidapi callback.
///
/// CRITICAL: This runs on the MIDI input callback thread.
/// - No allocations (rule set is pre-compiled)
/// - No locks (ArcSwap is wait-free for readers)
/// - No blocking I/O (action dispatch is via bounded channel try_send)
pub fn process(&self, source_alias: &str, message: &RawMessage) {
self.message_count.fetch_add(1, Ordering::Relaxed);
let start = Instant::now();
// Load current rule set (wait-free)
let rule_set = self.rule_set.load();
let active_mode = self.active_mode.load();
// Look up rules for this source device
let device_rules = rule_set.rules_by_source.get(source_alias);
let rule_chains = device_rules
.into_iter()
.flatten()
.chain(rule_set.global_rules.iter());
for rule in rule_chains {
// Check mode/profile condition
if let Some(ref condition) = rule.condition {
if !self.evaluate_condition(condition, &active_mode) {
continue;
}
}
// Match trigger
if self.matches_trigger(&rule.trigger, message) {
// Build action envelope
let envelope = ActionEnvelope {
action: rule.action.clone(),
rule_id: rule.id,
source_alias: source_alias.to_string(),
original_message: message.clone(),
timestamp: Instant::now(),
};
// Dispatch (non-blocking: drop if channel is full rather than block)
let _ = self.action_tx.try_send(envelope);
// If rule consumes the message, stop evaluating further rules
if rule.consume {
break;
}
}
}
// Record latency
self.latency_tracker.record(start.elapsed());
}
fn matches_trigger(&self, trigger: &CompiledTrigger, message: &RawMessage) -> bool {
match (trigger, message) {
(
CompiledTrigger::MidiNote {
channel,
note,
velocity_min,
velocity_max,
event_type,
},
RawMessage::Midi { status, data1, data2 },
) => {
let msg_type = status & 0xF0;
let msg_channel = status & 0x0F;
let type_match = match event_type {
NoteEventType::NoteOn => msg_type == 0x90 && *data2 > 0,
NoteEventType::NoteOff => msg_type == 0x80 || (msg_type == 0x90 && *data2 == 0),
NoteEventType::Both => msg_type == 0x90 || msg_type == 0x80,
};
type_match
&& channel.map_or(true, |ch| ch == msg_channel)
&& *data1 == *note
&& *data2 >= *velocity_min
&& *data2 <= *velocity_max
}
(
CompiledTrigger::MidiCC {
channel,
cc,
value_min,
value_max,
},
RawMessage::Midi { status, data1, data2 },
) => {
let msg_type = status & 0xF0;
let msg_channel = status & 0x0F;
msg_type == 0xB0
&& channel.map_or(true, |ch| ch == msg_channel)
&& *data1 == *cc
&& *data2 >= *value_min
&& *data2 <= *value_max
}
// Additional trigger matchers...
_ => false,
}
}
fn evaluate_condition(&self, condition: &RuleCondition, active_mode: &str) -> bool {
match condition {
RuleCondition::InMode(mode) => active_mode == mode,
RuleCondition::InProfile(_profile) => true, // TODO: profile resolution
RuleCondition::And(a, b) => {
self.evaluate_condition(a, active_mode)
&& self.evaluate_condition(b, active_mode)
}
RuleCondition::Or(a, b) => {
self.evaluate_condition(a, active_mode)
|| self.evaluate_condition(b, active_mode)
}
RuleCondition::Not(inner) => !self.evaluate_condition(inner, active_mode),
}
}
}
4. Action Dispatch
Location: conductor-core/src/dispatch.rs
The action dispatcher runs on a separate async task, receiving ActionEnvelope messages from the rule engine via a bounded channel. This isolates I/O latency (shell commands, network OSC) from the message processing hot path.
use tokio::task::JoinSet;
/// Action dispatcher: receives envelopes from the rule engine,
/// executes actions on the appropriate backend.
pub struct ActionDispatcher {
/// Receive channel from rule engine(s)
action_rx: Receiver<ActionEnvelope>,
/// MIDI output connections indexed by device alias
midi_outputs: Arc<RwLock<HashMap<String, MidiOutputHandle>>>,
/// OS automation backend
os_backend: Arc<dyn OsAutomationBackend>,
/// OSC output sockets
osc_sockets: Arc<RwLock<HashMap<String, OscOutputHandle>>>,
/// Plugin dispatcher (ADR-006)
plugin_dispatcher: Arc<dyn PluginDispatcher>,
/// Port resolver reference (for resolving target aliases to live ports)
resolver: Arc<RwLock<PortResolver>>,
/// Concurrent action execution pool
task_pool: JoinSet<()>,
}
/// OS automation trait — platform-specific implementations
#[async_trait]
pub trait OsAutomationBackend: Send + Sync {
async fn keystroke(&self, keys: &[String], modifiers: &[String]) -> Result<(), ActionError>;
async fn launch_app(&self, app: &str, args: &[String]) -> Result<(), ActionError>;
async fn shell_command(
&self,
command: &str,
args: &[String],
working_dir: Option<&str>,
) -> Result<(), ActionError>;
}
impl ActionDispatcher {
/// Main dispatch loop. Runs as a long-lived async task.
pub async fn run(&mut self) {
while let Ok(envelope) = self.action_rx.recv() {
let latency = envelope.timestamp.elapsed();
tracing::trace!(
rule_id = envelope.rule_id,
source = %envelope.source_alias,
latency_us = latency.as_micros(),
"Dispatching action"
);
// Spawn action execution to avoid blocking the dispatch loop
let midi_outputs = Arc::clone(&self.midi_outputs);
let os_backend = Arc::clone(&self.os_backend);
let osc_sockets = Arc::clone(&self.osc_sockets);
let plugin_dispatcher = Arc::clone(&self.plugin_dispatcher);
let resolver = Arc::clone(&self.resolver);
self.task_pool.spawn(async move {
if let Err(e) = Self::execute_action(
&envelope.action,
&midi_outputs,
&os_backend,
&osc_sockets,
&plugin_dispatcher,
&resolver,
&envelope.original_message,
)
.await
{
tracing::error!(
rule_id = envelope.rule_id,
error = %e,
"Action execution failed"
);
}
});
}
}
async fn execute_action(
action: &CompiledAction,
midi_outputs: &Arc<RwLock<HashMap<String, MidiOutputHandle>>>,
os_backend: &Arc<dyn OsAutomationBackend>,
osc_sockets: &Arc<RwLock<HashMap<String, OscOutputHandle>>>,
plugin_dispatcher: &Arc<dyn PluginDispatcher>,
resolver: &Arc<RwLock<PortResolver>>,
original: &RawMessage,
) -> Result<(), ActionError> {
match action {
CompiledAction::MidiForward {
target_alias,
transform,
} => {
let outputs = midi_outputs.read().await;
let output = outputs
.get(target_alias.as_str())
.ok_or_else(|| ActionError::TargetNotBound(target_alias.clone()))?;
let message = match transform {
Some(t) => Self::apply_midi_transform(original, t)?,
None => original.to_midi_bytes(),
};
output.send(&message)?;
Ok(())
}
CompiledAction::Keystroke { keys, modifiers } => {
os_backend.keystroke(keys, modifiers).await
}
CompiledAction::Launch { app, args } => {
os_backend.launch_app(app, args).await
}
CompiledAction::Shell {
command,
args,
working_dir,
} => {
os_backend
.shell_command(command, args, working_dir.as_deref())
.await
}
CompiledAction::OscSend {
target,
address,
args,
} => {
let sockets = osc_sockets.read().await;
let socket = sockets
.get(target.as_str())
.ok_or_else(|| ActionError::OscTargetNotFound(target.clone()))?;
socket.send(address, args)?;
Ok(())
}
CompiledAction::ModeChange { mode } => {
// TODO: emit mode change event to the engine
tracing::info!(mode = %mode, "Mode change requested");
Ok(())
}
CompiledAction::Sequence {
actions,
delay_between_ms,
} => {
for sub_action in actions {
Self::execute_action(
sub_action,
midi_outputs,
os_backend,
osc_sockets,
plugin_dispatcher,
resolver,
original,
)
.await?;
if *delay_between_ms > 0 {
tokio::time::sleep(Duration::from_millis(*delay_between_ms)).await;
}
}
Ok(())
}
CompiledAction::PluginAction {
plugin_name,
action_name,
params,
} => {
plugin_dispatcher
.dispatch(plugin_name, action_name, params)
.await
}
CompiledAction::Suppress => Ok(()),
CompiledAction::MidiSend {
target_alias,
message,
} => {
let outputs = midi_outputs.read().await;
let output = outputs
.get(target_alias.as_str())
.ok_or_else(|| ActionError::TargetNotBound(target_alias.clone()))?;
output.send(message)?;
Ok(())
}
}
}
fn apply_midi_transform(
original: &RawMessage,
transform: &MidiTransform,
) -> Result<Vec<u8>, ActionError> {
match original {
RawMessage::Midi { status, data1, data2 } => {
let msg_type = status & 0xF0;
let channel = transform.channel.unwrap_or(status & 0x0F);
let new_status = msg_type | channel;
let new_data1 = match msg_type {
0xB0 => transform.cc.unwrap_or(*data1), // CC: remap CC number
0x90 | 0x80 => transform.note.unwrap_or(*data1), // Note: remap note
_ => *data1,
};
let mut new_data2 = *data2;
// Apply velocity/value transform
if let Some(scale) = transform.velocity_scale {
let scaled = (new_data2 as f32 * scale) + transform.velocity_offset.unwrap_or(0) as f32;
new_data2 = scaled.round().clamp(0.0, 127.0) as u8;
}
if transform.invert_value {
new_data2 = 127 - new_data2;
}
// Apply curve LUT
if let Some(ValueCurve::Lut(lut)) = &transform.curve {
new_data2 = lut[new_data2 as usize];
}
Ok(vec![new_status, new_data1, new_data2])
}
_ => Err(ActionError::TransformNotApplicable),
}
}
}
5. Map Configuration (TOML Schema)
Location: ~/.conductor/config.toml (extends existing schema)
# ─── DEVICE IDENTITIES ───────────────────────────────────────────────────
[[device]]
alias = "keystep"
description = "Arturia KeyStep 37"
protocol = "midi"
direction = "bidirectional"
matchers = [
{ type = "usb_identifier", vendor_id = 0x1C75, product_id = 0x0206 },
{ type = "exact_name", value = "Arturia KeyStep 37" },
{ type = "name_contains", value = "KeyStep" },
]
[[device]]
alias = "fm8"
description = "NI FM8 virtual MIDI port"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "FM8 Virtual Input" },
]
[[device]]
alias = "ableton"
description = "Ableton Live via IAC"
protocol = "midi"
direction = "output"
matchers = [
{ type = "exact_name", value = "IAC Driver Bus 1" },
]
# ─── MAP RULES ────────────────────────────────────────────────────────────
# CC forwarding with transform
[[map]]
name = "KeyStep filter cutoff → FM8 mod wheel"
source = "keystep"
priority = 10
consume = true
[map.trigger]
type = "cc"
channel = 0
cc = 74
[map.action]
type = "midi_forward"
target = "fm8"
[map.action.transform]
cc = 1 # Remap CC 74 → CC 1 (mod wheel)
curve = "logarithmic"
# Drum pad → OS automation
[[map]]
name = "Pad 36 → focus Ableton"
source = "keystep"
condition = { type = "in_mode", mode = "daw" }
[map.trigger]
type = "note"
channel = 9
note = 36
[map.action]
type = "launch"
app = "Ableton Live 12 Suite"
# Pad range → mode-dependent actions
[[map]]
name = "Pads 37-39 → transport controls"
source = "keystep"
condition = { type = "in_mode", mode = "daw" }
[map.trigger]
type = "note_range"
channel = 9
note_min = 37
note_max = 39
[map.action]
type = "keystroke"
keys = ["space"] # Play/pause (note 37 example)
# Velocity-sensitive layers
[[map]]
name = "Soft hit → quiet note, hard hit → accent"
source = "keystep"
[map.trigger]
type = "note"
channel = 0
note = 60
velocity_min = 100
velocity_max = 127
[map.action]
type = "midi_forward"
target = "fm8"
[map.action.transform]
velocity_scale = 1.2
velocity_offset = 10
# OSC output
[[map]]
name = "CC 16 → OSC fader"
source = "keystep"
[map.trigger]
type = "cc"
cc = 16
[map.action]
type = "osc_send"
target = "127.0.0.1:9000"
address = "/fader/1"
# Action sequence
[[map]]
name = "Mode switch + visual feedback"
source = "keystep"
[map.trigger]
type = "note"
channel = 0
note = 48
[map.action]
type = "sequence"
delay_between_ms = 50
[[map.action.steps]]
type = "mode_change"
mode = "performance"
[[map.action.steps]]
type = "midi_send"
target = "keystep"
message = [0xB0, 0x00, 0x7F] # Send CC 0 = 127 (LED feedback)
6. CLI: conductor devices Subcommand
Location: conductor-cli/src/commands/devices.rs
A first-class CLI command for device discovery and identity management:
$ conductor devices scan
╔══════════════════════════════════════════════════════════════════════════════╗
║ AVAILABLE DEVICES ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ MIDI Input Ports ║
║ ───────────────────────────────────────────────────────────────────────── ║
║ [0] IAC Driver Bus 1 ║
║ Platform ID: 1738462 ║
║ Binding: → "ableton" (matched by: ExactName) ║
║ ║
║ [1] Komplete Audio 6 MK2 ║
║ Platform ID: 2847193 ║
║ USB: VID=0x17CC PID=0x1220 ║
║ Binding: (unconfigured) ║
║ ║
║ [2] Arturia KeyStep 37 ║
║ Platform ID: 9182734 ║
║ USB: VID=0x1C75 PID=0x0206 ║
║ Binding: → "keystep" (matched by: UsbIdentifier) ║
║ ║
║ MIDI Output Ports ║
║ ───────────────────────────────────────────────────────────────────────── ║
║ [0] IAC Driver Bus 1 ║
║ Binding: → "ableton" (matched by: ExactName) ║
║ ║
║ Dormant Devices (configured but not connected) ║
║ ───────────────────────────────────────────────────────────────────────── ║
║ ⏸ "fm8" — last seen: 2 hours ago on "FM8 Virtual Input" ║
║ ⏸ "launchpad-left" — never seen ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝
$ conductor devices identify "keystep"
DeviceIdentity "keystep":
Protocol: MIDI
Direction: Bidirectional
Status: BOUND → port [2] "Arturia KeyStep 37"
Matched by: UsbIdentifier(VID=0x1C75, PID=0x0206)
Matchers:
1. UsbIdentifier(VID=0x1C75, PID=0x0206) [specificity: 70] ✓ MATCHED
2. ExactName("Arturia KeyStep 37") [specificity: 60] ✓ would match
3. NameContains("KeyStep") [specificity: 40] ✓ would match
$ conductor devices generate
# Scans all ports and generates TOML identity blocks for unconfigured devices
# User can paste into config.toml
[[device]]
alias = "komplete-audio" # suggested from port name
protocol = "midi"
direction = "input"
matchers = [
{ type = "usb_identifier", vendor_id = 0x17CC, product_id = 0x1220 },
{ type = "exact_name", value = "Komplete Audio 6 MK2" },
]
7. MCP Tool Integration (per ADR-007)
The device identity and port resolution systems expose MCP tools for LLM interaction:
/// Extended MCP tools for device management (supplements ADR-007)
pub fn device_mcp_tools() -> Vec<McpTool> {
vec![
McpTool {
name: "conductor_list_devices",
description: "List all configured device identities and their current binding state",
risk_tier: ToolRiskTier::ReadOnly,
parameters: json!({}),
},
McpTool {
name: "conductor_scan_ports",
description: "Scan for all available MIDI/HID/OSC ports with full metadata \
including USB VID:PID, platform IDs, and binding status",
risk_tier: ToolRiskTier::ReadOnly,
parameters: json!({}),
},
McpTool {
name: "conductor_create_device_identity",
description: "Create a new device identity with alias and matchers. \
Returns a Plan for user approval.",
risk_tier: ToolRiskTier::ConfigChange,
parameters: json!({
"type": "object",
"properties": {
"alias": { "type": "string" },
"protocol": { "type": "string", "enum": ["midi", "hid", "osc"] },
"direction": { "type": "string", "enum": ["input", "output", "bidirectional"] },
"matchers": { "type": "array" }
},
"required": ["alias", "protocol", "direction", "matchers"]
}),
},
McpTool {
name: "conductor_resolve_device",
description: "Attempt to resolve a device alias to a live port and return \
the binding result with match details",
risk_tier: ToolRiskTier::ReadOnly,
parameters: json!({
"type": "object",
"properties": {
"alias": { "type": "string" }
},
"required": ["alias"]
}),
},
McpTool {
name: "conductor_disambiguate_device",
description: "When a device has ambiguous matches, select which physical \
port should be bound to the alias. Returns a Plan.",
risk_tier: ToolRiskTier::ConfigChange,
parameters: json!({
"type": "object",
"properties": {
"alias": { "type": "string" },
"selected_port_index": { "type": "integer" }
},
"required": ["alias", "selected_port_index"]
}),
},
]
}
Platform-Specific Considerations
| Platform | Port Name Stability | Unique ID | USB Info Access | Hot-Plug Detection |
|---|---|---|---|---|
| macOS (CoreMIDI) | Good (display names stable) | MIDIUniqueID (i32) — persists across reboots on same USB port | Via IOKit | CoreMIDI notifications (native callback) |
| Linux (ALSA) | Poor (client:port numbers change) | None built-in; ALSA card path partially stable | Via udev/sysfs | udev monitor (native callback) |
| Windows (WinMM) | Very poor (enumeration index) | None | Via SetupDI | Polling only (no native callback for MIDI) |
| Windows (WinRT) | Better (device instance IDs) | Device Instance ID string | Via WinRT DeviceInformation | DeviceWatcher (native callback) |
Implementation priority: macOS first (primary developer platform), Linux second (server/Pi use cases), Windows third (broadest user base but most complex).
Handling Multiple Identical Devices
This is the hardest problem in device identity. The strategy is layered:
| Strategy | Reliability | Platform | Notes |
|---|---|---|---|
| USB serial number | High | All | Not all devices report serial numbers. Launchpads do; some cheap controllers don't. |
| CoreMIDI UniqueID | High | macOS only | Differentiates identical units reliably if they stay on the same USB port. |
| USB topology (bus:address) | Medium | All | Works as long as user doesn't move USB cables. Fragile but deterministic. |
| Connection order + user prompt | Low | All | Fallback: "I see two Launchpad Minis. Please press a pad on the LEFT one." |
Disambiguation workflow (when two identical devices are found):
PortResolver detects 2 candidates for alias "launchpad-left"
│
▼
PortEvent::AmbiguousDevice emitted
│
┌──────────┴──────────┐
▼ ▼
GUI shows CLI shows
disambiguation "conductor devices disambiguate
modal with --alias launchpad-left"
MIDI Learn │
│ │
▼ ▼
User presses User presses
pad on target pad on target
device device
│ │
└──────────┬────────┘
▼
System records which port sent the event
Adds UsbTopology or PlatformId matcher to config
Re-resolves with unique binding
Consequences
Positive
- Stable References: Maps survive reboots, USB topology changes, and device reconnection without user intervention
- Zero-Downtime Hot-Plug: Devices can connect and disconnect freely; maps go dormant and reactivate automatically
- Sub-Millisecond Processing: Lock-free rule engine with pre-compiled rule sets ensures negligible processing latency on the MIDI callback thread
- Heterogeneous Output: A single map rule can target MIDI, OS automation, OSC, or plugins — the dispatch layer abstracts the target
- Multi-Protocol Ready: The identity model and rule engine work identically for MIDI, HID, and OSC — new protocols require only a new
PortEnumeratorimplementation and trigger types - Observable: Per-rule latency tracking, message counters, and binding state logs enable performance debugging and reliability monitoring
- LLM-Friendly: Device scanning, identity management, and map creation are exposed as MCP tools with appropriate risk tiers
Negative
- Configuration Complexity: Users must define device identities before creating maps. Mitigated by
conductor devices generateCLI command and LLM-assisted setup - Polling Overhead: Hot-plug detection requires periodic port enumeration (1-2s interval). On macOS and Linux, native callbacks reduce this, but Windows WinMM requires polling
- Identical Device Edge Case: Two identical devices without serial numbers require manual disambiguation. This is an inherent hardware limitation, not a software design flaw
- Platform Divergence: Each platform has different port enumeration APIs, USB info access methods, and hot-plug notification mechanisms. The
PortEnumeratortrait abstracts this but each platform needs a separate implementation
Neutral
- Backward Compatible: Existing configurations that use port indices can be auto-migrated to identity-based references via
conductor migrate-config - Optional Complexity: Simple setups (one device, one output) can use the
name_containsmatcher alone and skip USB identifiers entirely
Alternatives Considered
Alternative 1: Port Index Based (Current Implicit Behaviour)
Rejected: Port indices are volatile on all platforms. A map referencing "port 2" breaks when a USB hub is added or a device is unplugged and re-plugged. This is the core problem ADR-008 solves.
Alternative 2: Port Name Only (No Identity Abstraction)
Rejected: Port names differ across platforms for the same device. "Arturia KeyStep 37" on macOS becomes "KeyStep 37:KeyStep 37 MIDI 1 28:0" on Linux ALSA. Maps would not be portable. Additionally, identical devices have identical names.
Alternative 3: Centralized Device Registry (Database)
Rejected: Adds unnecessary infrastructure (SQLite or similar) for a problem that's better solved with declarative config. Device identity is inherently a configuration concern, not a runtime data concern. Config files are version-controllable, shareable, and human-readable.
Alternative 4: Auto-Discovery Only (No Manual Configuration)
Rejected: Auto-discovery cannot differentiate identical devices, assign semantic aliases, or express user intent about which device plays which role. A degree of user configuration is irreducible for multi-device setups.
Alternative 5: Shared-Mutex Rule Engine
Rejected: Holding a mutex during MIDI callback processing introduces unbounded latency. If the config reload thread holds the lock while the MIDI thread needs to evaluate a rule, the MIDI thread blocks — potentially dropping messages. The ArcSwap approach provides wait-free reads with atomic rule set replacement.
Implementation Plan
Phase 1: Device Identity & Port Resolution (v4.11.0)
-
DeviceIdentityandDeviceMatchertypes with TOML serde -
PortEnumeratortrait with macOS CoreMIDI implementation -
PortResolverwith startup resolution and binding table -
conductor devices scanCLI command -
conductor devices generateCLI command (auto-generate identity TOML) -
conductor devices identify <alias>CLI command - Hot-plug detection loop (polling-based, 1.5s interval)
- Integration with existing daemon startup flow
Phase 2: Rule Engine & Action Dispatch (v4.12.0)
-
CompiledRuleSetcompiler from TOML config -
MessageProcessorwithArcSwap-based lock-free rule evaluation -
ActionDispatcherwith MIDI forwarding (with transform) -
ActionDispatcherwith OS automation (keystroke, launch, shell) -
MidiTransformwith channel remap, CC remap, velocity scaling - Value curves: linear, logarithmic, exponential, custom LUT
- Bounded channel between rule engine and dispatcher
- Per-rule latency tracking (lock-free ring buffer)
- Config hot-reload via atomic rule set swap
Phase 3: Platform Expansion & Advanced Features (v4.13.0)
- Linux ALSA
PortEnumeratorimplementation - Linux udev-based hot-plug detection (native callback)
- Windows WinRT
PortEnumeratorimplementation -
RuleConditionevaluation (mode, profile, boolean combinators) -
CompiledAction::Sequencewith inter-step delays -
CompiledAction::OscSendwith OSC output backend - Plugin dispatch integration (ADR-006)
-
conductor devices disambiguateCLI command (MIDI Learn based)
Phase 4: MCP Tools & LLM Integration (v4.14.0)
-
conductor_list_devicesMCP tool -
conductor_scan_portsMCP tool -
conductor_create_device_identityMCP tool (Plan/Apply per ADR-007) -
conductor_resolve_deviceMCP tool -
conductor_disambiguate_deviceMCP tool - Agent skill:
conductor-device-setup/SKILL.mdupdate for identity model - Agent skill:
conductor-midi-mapping/SKILL.mdupdate for target aliases
Phase 5: Polish & Observability (v4.15.0)
-
conductor migrate-configfor upgrading port-index configs to identity-based - Prometheus/OpenTelemetry metrics export (message throughput, rule latency, binding state)
- GUI device status panel with real-time binding state
- GUI disambiguation modal with MIDI Learn integration
- Config validation: warn on overlapping matchers across identities
TDD Test Plan
Unit Tests
Device Identity
test_matcher_specificity_ordering— Verify specificity scores produce correct priority ordertest_exact_name_match— ExactName matches exact string, rejects substringstest_name_contains_match— NameContains matches substrings case-sensitivelytest_name_regex_match— NameRegex matches valid patterns, handles invalid regex gracefullytest_usb_vid_pid_match_without_serial— VID:PID matches regardless of serial when serial is Nonetest_usb_vid_pid_serial_match— VID:PID+serial requires all three to matchtest_usb_topology_match— Bus+address matches exact topologytest_identity_serialization_roundtrip— TOML serialize → deserialize produces identical identity
Port Resolution
test_single_device_unique_match— One identity, one matching port → Boundtest_single_device_no_match— One identity, no matching port → Unboundtest_ambiguous_match— One identity, two matching ports → Ambiguoustest_matcher_priority_order— First matching strategy wins (more specific first)test_claimed_port_exclusion— A port bound to one identity is excluded from otherstest_device_lost_event— Previously Bound device disappears → DeviceLost eventtest_device_returned_event— Previously Unbound device appears → DeviceReturned eventtest_unknown_device_detection— Port not matching any identity → UnknownDeviceAppearedtest_identity_sorting_by_specificity— Identities with more specific matchers are resolved firsttest_hot_plug_no_change— Unchanged port list produces no events
Rule Engine
test_midi_note_trigger_exact— Note trigger matches correct note/channel/velocitytest_midi_note_trigger_any_channel— None channel matches all channelstest_midi_cc_trigger_value_range— CC trigger respects value_min/value_max boundstest_midi_note_range_trigger— NoteRange matches notes within range, rejects outsidetest_rule_priority_ordering— Higher priority rules fire firsttest_consume_flag_stops_evaluation— Consuming rule prevents subsequent rule matchingtest_condition_in_mode— Rule only fires when active mode matches conditiontest_condition_boolean_combinators— And/Or/Not conditions evaluate correctlytest_rule_set_swap_during_processing— ArcSwap doesn't block ongoing message processing
Action Dispatch
test_midi_transform_channel_remap— Channel remap produces correct status bytetest_midi_transform_cc_remap— CC number remap produces correct data1test_midi_transform_velocity_scale— Velocity scaling with clamp to 0-127test_midi_transform_invert_value— Value inversion (127 - value)test_midi_transform_lut_curve— Custom LUT applies correct mappingtest_action_sequence_execution_order— Sequence actions execute in order with delaystest_action_dispatch_target_not_bound— Forwarding to unbound device returns ActionErrortest_action_dispatch_nonblocking— Dispatch doesn't block on slow action execution
Integration Tests
test_full_pipeline_midi_cc_forward— CC message in → rule match → MIDI forward out (end-to-end)test_full_pipeline_note_to_keystroke— Note message in → rule match → OS keystroke outtest_hot_plug_reactivates_maps— Device disconnect → maps dormant → reconnect → maps activetest_config_reload_swaps_rules— Config file change → rules recompiled → new rules active without message losstest_multiple_devices_independent_processing— Two input devices process messages on independent threads
Property Tests (proptest)
test_all_valid_midi_messages_parse— Any 3-byte MIDI message with valid status byte parses without panictest_transform_preserves_value_range— Any transform output is within 0-127 for all inputs 0-127test_resolver_deterministic— Same port list + same identities → same bindings every time
Requirements Traceability
| Requirement | Component | Test Coverage |
|---|---|---|
| R1: Stable device identity | DeviceIdentity, DeviceMatcher | test_matcher_*, test_identity_serialization_* |
| R2: Runtime port resolution | PortResolver, PortEnumerator | test_single_device_*, test_hot_plug_* |
| R3: Lock-free rule engine | MessageProcessor, ArcSwap<CompiledRuleSet> | test_rule_set_swap_*, test_rule_* |
| R4: Heterogeneous action dispatch | ActionDispatcher, CompiledAction | test_action_dispatch_*, test_full_pipeline_* |
| R5: Multiple identical devices | PortResolver ambiguity detection, UsbIdentifier serial | test_ambiguous_match, test_usb_vid_pid_serial_* |
| R6: Graceful degradation | BindingState::Unbound, PortEvent::DeviceLost/Returned | test_device_lost_*, test_hot_plug_reactivates_* |
| R7: CLI + MCP exposure | conductor devices commands, MCP tools | test_full_pipeline_* (CLI), MCP tool tests (Phase 4) |
Open Questions
-
Config Migration: What's the migration path for users with existing port-index-based configs? Auto-migrate with
conductor migrate-config, or require manual update? -
Identity Persistence of Disambiguation: When a user disambiguates two identical Launchpads via MIDI Learn, should the system automatically add a
usb_topologyorplatform_idmatcher to their config? This makes it "just work" next time but modifies user config without explicit editing. -
Virtual MIDI Ports: Should virtual ports (IAC Driver, loopMIDI, JACK) be treated differently from hardware ports? They're always available and don't hot-plug, but their names can be user-configured and may change.
-
Cross-Platform Config Sharing: If a user shares their config between macOS and Linux, matchers like
CoreMidiUniqueIdare irrelevant on Linux. Should the resolver silently skip inapplicable matchers (current design) or warn? -
Rule Engine Bounded Channel Overflow: If the action dispatch channel fills up (slow shell commands, network timeouts), the rule engine drops messages via
try_send. Should there be a configurable overflow policy (drop, log, backpressure)? -
Latency Budget: What's the acceptable end-to-end latency from MIDI input to action dispatch? Music applications typically require < 5ms. OS automation can tolerate 10-50ms. Should there be per-action-type latency budgets?
References
Crates
- midir — Cross-platform MIDI I/O for Rust
- hidapi — Cross-platform HID device access
- rosc — OSC protocol implementation
- arc-swap — Wait-free atomic pointer swap for lock-free data structures
- crossbeam — Lock-free channels and data structures
- enigo — Cross-platform keyboard/mouse automation
- notify — Cross-platform file system notification (config watch)
Platform APIs
- CoreMIDI Framework — macOS MIDI API with
MIDIUniqueIDand notification callbacks - ALSA Sequencer API — Linux MIDI with client:port addressing
- Windows MIDI Services — WinRT MIDI 2.0 API with device instance IDs
- IOKit USB — macOS USB device enumeration for VID:PID
- udev — Linux device manager for hot-plug detection
Related ADRs
- ADR-005: Configuration Schema (planned, not published — ADR-005 was reassigned to Bundle ID User Experience) — Base TOML schema this ADR extends
- ADR-006: Plugin Architecture (planned, not published — ADR-006 was reassigned to Profile Manager Security) — Plugin dispatch target for
CompiledAction::PluginAction - ADR-007: LLM Integration — MCP tools and Plan/Apply pattern this ADR integrates with
LLM Council Review
Pending — submit for review with:
conductor council review docs/adrs/ADR-008-device-identity-port-resolution-message-pipeline.md
Review Checklist
- Security: Regex matchers in
NameRegex— DoS risk from pathological patterns? Considerregexcrate timeout orfancy-regexavoidance - Performance: Lock-free claims validated under contention (multiple devices connecting simultaneously)
- Correctness: TOCTOU in disambiguation flow — device could disconnect between detection and user selection
- Completeness: HID and OSC port enumeration design (deferred to Phase 3 but architecture must accommodate)
- Testability: Property tests for matcher specificity ordering and transform value bounds