ADR-021: Input/Output Device Qualification
Status
Draft (For Discussion)
Context
Problem Statement
Conductor's device model treats every device as an input source. The [[devices]] config (DeviceIdentityConfig) defines an alias and matchers that bind to MIDI input ports discovered via midir::MidiInput. The daemon's MidiDeviceManager connects to input ports, receives callbacks, and tags events with a device_id. Output is handled by a completely separate subsystem — MidiOutputManager maintains its own connection cache keyed by raw port name strings, and both SendMidi and MidiForward reference output ports by literal string.
This creates three concrete problems:
-
Output actions can't use device aliases. A user writes
alias = "Mikro"and usesdevice = "Mikro"in their triggers. But when they write aSendMidiaction for LED feedback to the same physical controller, they must hardcode the raw CoreMIDI output port name (port = "Maschine Mikro MK3 Output"). If the port name changes (USB re-enumeration, different hub port), the trigger still works (matchers handle it) but the action breaks. This asymmetry is confusing and fragile. -
No "send back to source" pattern.
MidiForward { target: "IAC Bus 1" }works because IAC names are stable. But forwarding back to the originating controller's output port requires knowing the exact output port name. There's no way to express "forward to the output half of the device that sent this event" — a natural use case for bidirectional controllers. -
LED feedback config is disconnected.
LedConfig.midi(MidiLedConfig) defines channel, velocity, and colour parameters for LED control, but has no explicit output port field. It relies on implicit routing that doesn't participate in the device identity system. The[led]section and[[devices]]section are entirely decoupled. -
Device management views have no I/O context.
DeviceStatusPills,DeviceList, andDeviceSettingsViewshow "connected / disconnected" for input ports. They have no visibility into whether the same physical device also has an output port available, whether output-only devices (e.g., a synth receiving MIDI) exist in the system, or whether anySendMidi/MidiForwardactions target a specific output port. -
Signal Flow has no action destination context. The V3
MappingBranchaction column shows the action type and label (e.g., "🎵 SendMidi CC7 → IAC Bus 1") but can't resolve the target port to a known device — it's just a string. Cross-track badges (↗ cross-track) in ADR-020 identify routing between input devices, butSendMidi/MidiForwardtargets are invisible to this system.
Industry Context
Every serious MIDI routing product distinguishes input ports from output ports. The most comparable products to Conductor — ReaLearn, TouchOSC, Open Stage Control — use a paired connection model:
- ReaLearn: A "unit" has an Input port (control) and Output port (feedback/LEDs)
- Open Stage Control:
device_name:input,output— always a tuple - TouchOSC: A "connection" has Send Port + Receive Port
This is the pattern that best serves bidirectional controllers (input for mappings, output for LED feedback) while remaining simple for input-only devices (the common case in Conductor today).
What This ADR Covers
In scope:
- Extending
DeviceIdentityConfigwithinput/outputport fields andDeviceDirectionclassification - Output port auto-pairing heuristic (match by name similarity)
- Alias-based output port resolution in
SendMidiandMidiForwardactions DevicePortStatusextension with direction and output port info- GUI updates: device pills, device management views, Signal Flow action column
- Backward compatibility: existing
matchers/matchconfigs continue to work unchanged
Out of scope:
- Full MIDI output device manager (persistent output connections with reconnection) — currently
MidiOutputManagerconnects on-demand, which is sufficient - LED feedback pipeline redesign — ADR-021 enables it by providing output port resolution, but the LED state machine is a separate concern
- Multi-port device aggregation (one physical device with DAW + MIDI port pairs) — future ADR
User Mental Model
What is a "device" in Conductor?
A device in Conductor is a named MIDI endpoint that mappings and actions can refer to by alias. It represents one logical device (a controller, a synth, a MIDI interface port) and resolves to one or more OS-level MIDI ports at runtime.
A device may have:
- An input port binding — Conductor receives MIDI from it (pad presses, knob turns, button hits)
- An output port binding — Conductor sends MIDI to it (LED colours, motor positions, note sequences)
- Both — the device is bidirectional (common for controllers with LED feedback)
Three concepts, kept separate
| Concept | What it answers | Where it lives |
|---|---|---|
| Device identity | "Which logical device do I mean?" | alias field |
| Port binding | "Which OS-level MIDI port(s) does it correspond to?" | matchers / input.matchers / output.matchers |
| Direction | "Does Conductor read from it, write to it, or both?" | Derived from which port bindings are present |
An alias names a set of matched ports — it is port-based, not hardware-based. Two identical controllers must be distinguished by port name differences or USB topology.
Progressive complexity: when do you need what?
Most users only need the simple case. The I/O system is progressive — you only engage with output bindings when you actually need to send MIDI.
| Use case | Input binding? | Output binding? | Config complexity |
|---|---|---|---|
| Pad triggers hotkeys (Cmd+Space) | Yes | No | Simple: matchers only |
| Knob controls volume | Yes | No | Simple |
| Controller with LED feedback | Yes | Yes | Moderate: input + output |
| Forward MIDI to external synth | Yes (source) | Yes (target) | Moderate: two devices |
| Output-only synth/lights | No | Yes | Simple: output only |
Echo event back to source (_source) | Yes | Yes (auto-paired or explicit) | Moderate |
If you just want "pad 36 on my Mikro = Cmd+Space", you only need:
[[devices]]
alias = "Mikro"
matchers = [{ type = "NameContains", value = "Maschine Mikro MK3" }]
No output binding needed. No direction to think about. The system defaults to input-only.
You only need explicit I/O when Conductor must send MIDI back:
[[devices]]
alias = "Mikro"
input = { matchers = [{ type = "NameContains", value = "Maschine Mikro MK3 Input" }] }
output = { matchers = [{ type = "NameContains", value = "Maschine Mikro MK3 Output" }] }
This is needed because MIDI ports are directional at the OS level — your OS exposes "Maschine Mikro MK3 Input" and "Maschine Mikro MK3 Output" as separate ports. Conductor needs to know which port to send LED feedback to.
Common device roles (informational, not encoded in schema)
These roles are not config fields — they emerge naturally from which bindings are present:
- Input controller — pad/key controller triggering macros. Needs: input binding only.
- Bidirectional controller — controller with LED feedback (NI Maschine, Novation Launchpad). Needs: both input and output bindings.
- Output instrument — external synth or drum machine receiving MIDI from Conductor. Needs: output binding only.
- Routing endpoint — virtual MIDI bus (IAC Driver) or DAW port. Needs: depends on routing direction.
Decision
D1: Extend DeviceIdentityConfig with Input/Output Port Configs
The [[devices]] config gains two new optional fields — input and output — each holding a list of DeviceMatchers. The existing matchers field is retained for backward compatibility and treated as input-only.
Current struct (conductor-core/src/config/types.rs:624):
pub struct DeviceIdentityConfig {
pub alias: String,
pub matchers: Vec<DeviceMatcher>,
pub description: Option<String>,
pub enabled: bool,
}
New struct:
pub struct DeviceIdentityConfig {
pub alias: String,
/// Legacy matchers — treated as input port matchers when `input` is absent.
/// Preserved for backward compatibility with existing configs.
#[serde(default)]
pub matchers: Vec<DeviceMatcher>,
/// Explicit input port matchers (new in ADR-021).
/// When present, takes precedence over `matchers` for input port binding.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<DevicePortBinding>,
/// Explicit output port matchers (new in ADR-021).
/// When present, binds the device's output port for SendMidi/MidiForward alias resolution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<DevicePortBinding>,
pub description: Option<String>,
pub enabled: bool,
}
/// Port binding configuration — matchers that identify an input or output port.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DevicePortBinding {
/// Matchers for this port (same matcher types as existing DeviceMatcher)
pub matchers: Vec<DeviceMatcher>,
}
TOML config examples:
# Existing config — still valid, input-only, output auto-discovered
[[devices]]
alias = "Mikro"
matchers = [{ type = "NameContains", value = "Maschine Mikro MK3" }]
# New: explicit I/O pairing
[[devices]]
alias = "Mikro"
input = { matchers = [{ type = "NameContains", value = "Maschine Mikro MK3 Input" }] }
output = { matchers = [{ type = "NameContains", value = "Maschine Mikro MK3 Output" }] }
# Output-only device (synth receiving MIDI)
[[devices]]
alias = "Synth"
output = { matchers = [{ type = "ExactName", value = "USB MIDI Interface Port 2" }] }
# Input-only (same as legacy — explicit form)
[[devices]]
alias = "Gamepad"
input = { matchers = [{ type = "NameContains", value = "Xbox" }] }
Resolution precedence (normalization at config load time):
- If
inputis present → useinput.matchersfor input port matching - Else if
matchersis non-empty → promote toinput.matchersinternally (backward compat) - If
outputis present → useoutput.matchersfor output port matching - Else → attempt auto-pairing (D2)
Invariants:
matchersandinputare semantically exclusive —matchersis the legacy form ofinput. If both are present,inputtakes precedence andmatchersis ignored. The config validator emits a warning if both are specified.- At least one of
matchers,input, oroutputmust be present (a device with no port bindings is invalid). - The DeviceEditor GUI and MCP tools always write canonical
input/outputform, never legacymatchers. Legacymatchersis a config-file-level backward compatibility concern, not an API concern.
Deprecation path: matchers will be preserved indefinitely for config file backward compatibility, but is considered deprecated for new configurations. The GUI editor, MCP tools, and MIDI Learn all emit canonical input/output form when creating or updating devices.
D2: Output Port Auto-Pairing Heuristic
When a device config specifies input matchers but no explicit output, the daemon attempts to auto-discover the matching output port using a name-similarity heuristic.
Algorithm:
- Given input port name (e.g., "Maschine Mikro MK3 Input")
- Strip common suffixes: " Input", " In", " MIDI In"
- Compute base name (e.g., "Maschine Mikro MK3")
- Search available MIDI output ports for:
- Exact match: "{base} Output", "{base} Out", "{base} MIDI Out"
- Fallback: any output port containing
{base}as substring
- If exactly one match → auto-pair. If zero or multiple → no auto-pair (user must specify
outputexplicitly).
Reliability: CoreMIDI naming conventions make this reliable for ~90% of devices. NI controllers use "X Input" / "X Output". Novation uses "X MIDI In" / "X MIDI Out". Generic USB-MIDI interfaces use identical names for input and output (which matches the exact-substring fallback).
Persistence: Auto-paired output port names are not persisted to config. They are resolved at runtime during port enumeration. This avoids config drift when port names change.
Logging: Auto-pair results are logged at info level:
"Device 'Mikro': auto-paired output port 'Maschine Mikro MK3 Output'""Device 'Mikro': no matching output port found (specify [output] in config for explicit binding)"
D3: Device Direction Classification
Each device identity is classified by direction, derived from its port bindings:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeviceDirection {
/// Only receives MIDI events (controller → Conductor)
Input,
/// Only sends MIDI events (Conductor → synth/lights)
Output,
/// Both receives and sends (controller with LED feedback)
Bidirectional,
}
Derivation logic:
impl DeviceIdentityConfig {
pub fn direction(&self) -> DeviceDirection {
let has_input = self.input.is_some() || !self.matchers.is_empty();
let has_output = self.output.is_some(); // auto-pair resolved separately
match (has_input, has_output) {
(true, true) => DeviceDirection::Bidirectional,
(true, false) => DeviceDirection::Input, // may upgrade to Bidirectional after auto-pair
(false, true) => DeviceDirection::Output,
(false, false) => DeviceDirection::Input, // empty matchers = legacy auto-connect
}
}
}
After auto-pairing (D2), devices that started as Input may be reclassified to Bidirectional at runtime if an output port was discovered.
D4: Alias-Based Output Resolution in SendMidi/MidiForward
SendMidi.port and MidiForward.target gain alias resolution: when the string matches a device alias, it resolves to that device's output port name. Raw port names still work as a fallback.
Resolution order (in ActionExecutor):
- Check if
port/targetmatches a configured device alias - If match → look up that device's resolved output port name
- If no alias match → treat as raw port name (existing behavior)
Implementation — ActionExecutor gains a device_output_map: HashMap<String, String> (alias → output port name), populated by the daemon at startup and config reload:
impl ActionExecutor {
/// Resolve a port string to an actual output port name.
/// Checks device aliases first, falls back to raw port name.
fn resolve_output_port(&self, port_or_alias: &str) -> &str {
self.device_output_map
.get(port_or_alias)
.map(|s| s.as_str())
.unwrap_or(port_or_alias)
}
}
Config impact — users can now write:
# Before: fragile raw port name
[action]
type = "SendMidi"
port = "Maschine Mikro MK3 Output"
# After: stable device alias
[action]
type = "SendMidi"
port = "Mikro"
D5: Special Target "_source" for MidiForward
MidiForward gains a reserved target value "_source" meaning "send to the output port of the device that originated this event":
[action]
type = "MidiForward"
target = "_source"
Resolution: At dispatch time, ActionExecutor reads the device_id from the trigger context (available since ADR-009 Phase 2), looks up that device's output port, and sends there. If the originating device has no output port, the action fails with a descriptive error.
This enables the natural "echo back" pattern for bidirectional controllers without hardcoding port names.
D6: Extend DevicePortStatus with Direction and Output Port
The daemon's status response (DevicePortStatus in conductor-daemon/src/daemon/types.rs:512) is extended:
Current:
pub struct DevicePortStatus {
pub device_id: String,
pub port_name: String,
pub port_index: usize,
pub connected: bool,
pub enabled: bool,
pub last_event_at: Option<u64>,
pub is_configured: bool,
}
New:
pub struct DevicePortStatus {
pub device_id: String,
pub port_name: String, // renamed semantically to input_port_name
pub port_index: usize,
pub connected: bool, // input port connected
pub enabled: bool,
pub last_event_at: Option<u64>,
pub is_configured: bool,
// ADR-021 additions:
pub direction: DeviceDirection,
pub output_port_name: Option<String>,
pub output_connected: bool,
pub output_auto_paired: bool, // true if output was auto-discovered, not explicit
}
Backward compatibility: New fields have defaults (direction: Input, output_port_name: None, output_connected: false, output_auto_paired: false). Older GUI versions that don't read these fields continue to work.
D7: GUI — Device Direction Indicators
Device pills and device list views gain direction context:
DeviceStatusPills.svelte — each pill shows a small direction icon:
←Input only (default, no visual change from current behavior)→Output only (new — shown in dim accent colour)↔Bidirectional (new — shown when device has both input and output)
The direction icon is 8px, positioned between the status dot and the device name. It uses the existing device colour at reduced opacity (0.5) so it's visible but not dominant.
DeviceList.svelte / DeviceSettingsView.svelte — the device list shows:
- Direction badge (Input / Output / Bidirectional) as a tag
- Input port name and connection status
- Output port name and connection status (if present)
- "Auto-paired" indicator if output was discovered via heuristic
- Action to manually configure output port (opens config editor for
outputfield)
Output-only devices: Devices with direction: Output appear in the device list but not in event filtering (they don't generate events). They appear as action targets in Signal Flow.
D8: GUI — Signal Flow Action Target Resolution
When a MappingBranch (ADR-020) renders a SendMidi or MidiForward action, it resolves the target port to a known device and displays the device's colour and name:
Before (current): Action column shows raw text:
🎵 SendMidi CC7 → IAC Bus 1
After: Action column resolves alias or raw port to device:
🎵 SendMidi CC7 → [● IAC Bus 1]
Where ● is the device's colour dot and the name comes from the device alias (or raw port name if unrecognized). If the target device has its own DeviceGroup in Signal Flow, the cross-track badge (↗) links them visually.
Output-only devices in Signal Flow: An output-only device does not have a DeviceGroup (no input triggers). However, it appears as a resolved target in action columns of other devices' mapping branches. If the user has many SendMidi actions targeting the same output device, Signal Flow could optionally show a "target summary" — but this is deferred to a future enhancement.
_source target rendering: When MidiForward { target: "_source" } is used, the action column shows ↩ Echo → [source device], dynamically resolved per-event. In the static mockup, it shows ↩ Echo → [● same device].
D9: Backward Compatibility & Migration
Config migration rules:
| Config State | Input Resolution | Output Resolution |
|---|---|---|
matchers only (existing configs) | matchers → input port | Auto-pair (D2) or none |
input only | input.matchers → input port | Auto-pair (D2) or none |
output only | No input port (output-only device) | output.matchers → output port |
input + output | input.matchers → input port | output.matchers → output port |
matchers + output | matchers → input port | output.matchers → output port |
No breaking changes: All existing configs continue to work exactly as before. The matchers field is preserved and its semantics are unchanged. New fields are additive. The auto-pairing heuristic (D2) runs silently and only provides benefit — it never changes existing behavior.
Config file format: The TOML schema is extended, not changed. Existing [[devices]] entries are valid. New entries with input/output are additive.
D10: Validation Rules
The following constraints are enforced at config load time and in the DeviceEditor UI:
| Rule | Severity | Description |
|---|---|---|
| Alias required | Error | alias must be non-empty |
| Alias unique | Error | No two devices may share the same alias |
| At least one binding | Error | At least one of matchers, input, or output must be present |
| Non-empty matchers | Error | If input or output is present, its matchers array must be non-empty |
| Matchers + input coexistence | Warning | If both matchers and input are specified, input takes precedence; matchers is ignored |
| Regex validity | Error | NameRegex matcher values must be valid regular expressions |
| Orphan device reference | Warning | A mapping's device field or action's port/target referencing an undefined alias is a warning (not error, because ListenMode::All auto-discovers) |
_source requires output | Warning | A MidiForward { target: "_source" } action targeting a device with no output binding will fail at runtime |
D11: Runtime Lifecycle
When device configuration changes at runtime (via GUI save, daemon hot-reload, or MCP Plan/Apply):
- Re-normalization: Legacy
matcherspromoted toinput.matchersinternally - Port re-matching: All device port bindings are re-resolved against currently available MIDI ports
- Direction re-classification:
DeviceDirectionis re-derived from the new bindings - Auto-pair re-run: If a device has input but no explicit output, auto-pairing (D2) runs again
- Rule recompilation:
CompiledRuleSetis rebuilt with updated device filter indices - Active mapping continuity: Mappings referencing unchanged aliases remain active; mappings referencing deleted aliases become unresolved (runtime warning, not error)
- Output map update:
device_output_map(alias → output port name) is rebuilt for SendMidi/MidiForward resolution
Device hot-plug (physical connect/disconnect during operation):
- Input ports: daemon's
MidiDeviceManageralready handles reconnection via persistent watcher thread - Output ports: resolution is on-demand (at SendMidi execution time), so hot-plug is inherently handled — the port is looked up each time
- Direction may change: a device classified as Input-only at startup may become Bidirectional after auto-pairing succeeds on a later port scan
Failure modes:
SendMidi { port: "alias" }where alias has no resolved output port → error logged, action skipped, event continuesMidiForward { target: "_source" }where source device has no output → info-level warning logged, action skipped- Device deleted while mappings reference it → mappings remain in config, show "device not found" at runtime, no cascade delete
Specification: Component Changes
New Files
| File | Purpose |
|---|---|
conductor-core/src/config/port_binding.rs | DevicePortBinding struct, DeviceDirection enum |
conductor-daemon/src/daemon/output_resolver.rs | Auto-pairing heuristic, alias → output port resolution map |
conductor-gui/ui/src/lib/components/DirectionBadge.svelte | Small ← / → / ↔ badge component |
Modified Files
| File | Change |
|---|---|
conductor-core/src/config/types.rs | Add input, output fields to DeviceIdentityConfig. Add DeviceDirection enum. |
conductor-core/src/identity.rs | Add DevicePortBinding type. Extend DeviceMatcher to work for output ports. |
conductor-daemon/src/daemon/types.rs | Extend DevicePortStatus with direction, output_port_name, output_connected, output_auto_paired |
conductor-daemon/src/action_executor.rs | Add device_output_map, resolve_output_port() method. Wire SendMidi.port and MidiForward.target through resolution. Implement _source target. |
conductor-daemon/src/daemon/device_utils.rs | Add output port enumeration (parallel to input enumeration) |
conductor-gui/src-tauri/src/commands.rs | Extend getBindings response with direction, output port info |
conductor-gui/ui/src/lib/stores.js | Extend deviceBindingsStore binding shape with direction, output_port_name, output_connected |
conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte | Add direction icon. Handle output-only devices (no event filtering). |
conductor-gui/ui/src/lib/components/DeviceList.svelte | Add direction badge, output port display, auto-pair indicator |
conductor-gui/ui/src/lib/workspace/DeviceSettingsView.svelte | Show I/O columns, paired port display |
conductor-gui/ui/src/lib/components/signal-flow/MappingBranch.svelte | Resolve action target to device alias + colour for SendMidi/MidiForward |
Specification: Data Flow
Device Binding Resolution (Startup & Config Reload)
Config loaded
↓
For each DeviceIdentityConfig:
├── Resolve input matchers → input port (existing logic)
├── Resolve output matchers → output port (new: scan output ports with DeviceMatcher)
│ ↓ if no explicit output
│ └── Auto-pair heuristic (D2): strip suffix, search output ports
├── Classify direction (D3): Input / Output / Bidirectional
└── Populate device_output_map: alias → output_port_name
↓
ActionExecutor.device_output_map updated
↓
DevicePortStatus emitted (with direction, output_port_name, output_connected)
↓
GUI: deviceBindingsStore updated → pills, device list, signal flow re-render
SendMidi/MidiForward Output Resolution
Action dispatched: SendMidi { port: "Mikro", ... }
↓
ActionExecutor.resolve_output_port("Mikro")
↓
device_output_map.get("Mikro") → Some("Maschine Mikro MK3 Output")
↓
MidiOutputManager.connect_by_name("Maschine Mikro MK3 Output")
↓
Send MIDI bytes
_source Target Resolution
Event received from device_id "Mikro" → trigger matched → MidiForward { target: "_source" }
↓
ActionExecutor: trigger_context.device_id = "Mikro"
↓
resolve_output_port("Mikro") → "Maschine Mikro MK3 Output"
↓
Apply transform → Send to output port
Implementation Plan
Phase 1: Backend — Config & Resolution (5–7h)
-
#669 — Phase 1A: DevicePortBinding, DeviceDirection, and config extension
- Add
DevicePortBindingstruct andDeviceDirectionenum toconductor-core - Extend
DeviceIdentityConfigwithinput,outputfields - Implement direction derivation logic
- Extend
DevicePortStatuswith direction, output port fields - Tests: config parsing (all combinations), direction classification
- Add
-
#670 — Phase 1B: Output port enumeration and auto-pairing heuristic
- Implement output port enumeration in
device_utils.rs(parallel to input enumeration usingmidir::MidiOutput) - Implement auto-pairing heuristic (D2) in new
output_resolver.rs - Build
device_output_map(alias → output port name) at startup and config reload - Tests: auto-pairing (exact, suffix strip, fallback, ambiguous)
- Implement output port enumeration in
Phase 2: Backend — Action Resolution (3–4h)
- #671 — Phase 2A: Alias-based output resolution in SendMidi/MidiForward
- Add
resolve_output_port()toActionExecutor - Wire
SendMidi.portthrough resolution beforeconnect_by_name - Wire
MidiForward.targetthrough resolution - Implement
_sourcetarget: readdevice_idfrom trigger context, resolve to output port - Error handling: clear error message when alias has no output port, when
_sourcedevice has no output - Tests: alias resolution, raw port fallback,
_sourceresolution, error cases
- Add
Phase 3: GUI — Device Store & Views (4–6h)
-
#672 — Phase 3A: Extend device stores and create DirectionBadge component
- Extend Tauri
getBindingscommand response with direction, output port info - Extend
deviceBindingsStorebinding shape - Create
DirectionBadge.sveltecomponent (←/→/↔) - Add
deviceDirectionMapanddeviceOutputMapderived stores
- Extend Tauri
-
#673 — Phase 3B: Update device pills, device list, and device settings with I/O context
- Update
DeviceStatusPills.svelte: add direction icon, handle output-only devices - Update
DeviceList.svelte: direction badge, output port display, auto-pair indicator - Update
DeviceSettingsView.svelte: I/O columns, paired port info - Tests: pill rendering with direction, output-only device display
- Update
Phase 4: GUI — Signal Flow Integration (3–4h)
- #674 — Phase 4A: Signal Flow action target resolution in MappingBranch
- Add
deviceOutputMapto GUI stores (alias → output port name, from daemon status) - Update
MappingBranch.svelte(ADR-020): resolveSendMidi/MidiForwardtarget to device alias + colour in action column - Render resolved target as
[● DeviceName]with device colour dot - Render
_sourcetarget as↩ Echo → [● same device] - Wire cross-track badge: if resolved target device has a
DeviceGroup, show↗badge - Tests: target resolution rendering, unresolved fallback (raw port name), cross-track badge for output devices
- Add
Phase 5: Integration Testing & Polish (2–3h)
- #675 — Phase 5A: Integration testing and backward compatibility verification
- End-to-end: config with bidirectional device → daemon resolves output → SendMidi uses alias → GUI shows direction
- End-to-end: auto-pairing works for NI, Novation, generic USB-MIDI naming patterns
- End-to-end:
_sourceecho pattern with MidiForward - Backward compat: existing configs with only
matcherswork unchanged - Verify ADR-020 V3 components render correctly with direction-aware device data
- Verify ADR-019 filter stores handle output-only devices (excluded from event filtering)
Tracking issue: #676
Total estimated effort: 17–24 hours across 5 phases (7 sub-tasks).
Phases 1 and 2 (backend) can run in parallel with ADR-019 and ADR-020 work. Phase 3 can start after Phase 1. Phase 4 should come after ADR-020 Phase 1 (#656–#659) so it modifies V3 components, not V2.
Implementation Order — Where ADR-021 Fits
ADR-019 Phase 1 (#633–#635) Shared filter stores
↓
ADR-020 Phase 1 (#656–#659) V3 component scaffold
↕ parallel:
ADR-021 Phases 1–2 (#669–#671) Config + output resolution (no GUI dependency)
↓
ADR-021 Phase 3 (#672–#673) Device views — after backend, parallel with ADR-020 Phase 2
↓
ADR-020 Phase 2 (#660–#662) V3 interactions
↓
ADR-021 Phase 4 (#674) Signal Flow — after ADR-020 Phase 1 (modifies MappingBranch)
↓
ADR-019 Phase 5 (#641–#642) Metrics store
↓
ADR-020 Phases 3–4 (#663–#666) Compact mode + V2 removal
↓
ADR-021 Phase 5 (#675) Integration — final validation
Key insight: ADR-021's backend work (Phases 1–2) is fully independent of the GUI work in ADR-019 and ADR-020. It can start immediately and run in parallel. The GUI phases (3–4) need to land after ADR-020 Phase 1 to modify V3 components rather than V2 components that are about to be replaced.
Consequences
Positive
- Alias-stable output routing:
SendMidi { port: "Mikro" }survives USB re-enumeration, port name changes, and hub reconfigurations — the same stability that input triggers already have - Bidirectional device first-class: Controllers with LED feedback (NI Maschine, Novation Launchpad, Akai APC) can be configured once as a bidirectional device, with input and output paired under one alias
- Output-only devices visible: Synths, drum machines, and light controllers that only receive MIDI appear in the device management views, giving users a complete picture of their MIDI routing
_sourceecho pattern: The most common MidiForward use case (echo back to originating controller) becomes a single config line instead of a hardcoded port name- Signal Flow gains routing visibility: Action columns show where MIDI goes, not just where it comes from — completing the signal flow picture
- Zero breaking changes: Existing configs work unchanged. New features are opt-in via
input/outputfields.
Negative
- Auto-pairing heuristic is imperfect: ~10% of devices (especially multi-port interfaces and devices with non-standard naming) won't auto-pair. Users must specify
outputexplicitly for these. Mitigation: clear log message + GUI prompt. - Output port enumeration adds startup cost: Scanning output ports via
midir::MidiOutputadds ~100ms to startup (same as input scan). Mitigation: run in parallel with input enumeration. - Direction increases device model complexity: Every consumer of device data must now handle three states (Input/Output/Bidirectional) instead of one implicit input. Mitigation:
Inputis the default, existing code paths are unchanged unless they explicitly opt in to output awareness.
Risks
- R1: Auto-pair false positives: A device with "Pro" in its name might auto-pair with a different "Pro" device's output. Mitigation: auto-pair requires exactly one match; ambiguous matches result in no auto-pair.
- R2: Output port availability: Output ports may appear/disappear at different times than input ports (e.g., a DAW holding an output port exclusively). The
output_connectedflag handles this gracefully — the device shows as Bidirectional but with output disconnected. - R3: MidiOutputManager caching: The existing
MidiOutputManagerconnects on-demand and doesn't persistently hold output connections. Alias resolution adds a lookup step but doesn't change the connection lifecycle. If persistent output connections are needed (for LED feedback latency), that's a separate enhancement.
Dependencies
- ADR-009 (Multi-Device Architecture): Provides
DeviceIdentityConfig,DeviceMatcher,DeviceId,DevicePortStatus— all extended by this ADR - ADR-020 (V3 Structural Rendering, #667): Phase 4 (#674) modifies
MappingBranch.svelte(#657) to show resolved action targets. Must land after ADR-020 Phase 1 (#656–#659). - ADR-019 (Unified Filtering, #645): Output-only devices are excluded from event filtering (
signalFlowDeviceFilter,eventsDeviceFilter). Filter stores may optionally gain a direction facet.
Related Documents
docs/gui-v2/spec-device-management-adr-021-extension.md— GUI device editor spec (DeviceEditor.svelte CRUD, #728 GAP-D3). Covers the editor modal, add/edit/delete flows, context menu integration, backend ConfigChange variants, and MCP tool definitions.docs/gui-v2/signal-flow-v4-io-device-mockups.html— Phase 1 I/O device visualization mockup. Shows direction badges, I/O port display in device pills/lists/Signal Flow, and auto-pair indicators. This is the read-only layer that ADR-021 D6-D8 specify.docs/gui-v2/signal-flow-v4-device-management-mockup.html— Phase 2 device editor mockup (5 tabs: Device Detail, Device Editor, Add Device, Delete + Context Menu, Signal Flow + Editor overlay). Extends the Phase 1 views with CRUD capabilities.
References
conductor-core/src/config/types.rs:624—DeviceIdentityConfigstruct (to be extended)conductor-core/src/config/types.rs:870—DeviceConfiglegacy struct (backward compat reference)conductor-core/src/config/types.rs:1357—SendMidiaction withport: Stringfieldconductor-core/src/config/types.rs:1391—MidiForwardaction withtarget: Stringfieldconductor-core/src/identity.rs—DeviceId,DeviceMatcher,DeviceEventtypesconductor-core/src/midi_output.rs:52—MidiOutputManagerstruct (output connection cache)conductor-daemon/src/action_executor.rs:206—ActionExecutorwithmidi_output: MidiOutputManagerconductor-daemon/src/action_executor.rs:365—SendMididispatch (to be wired through resolution)conductor-daemon/src/action_executor.rs:374—MidiForwarddispatch (to be wired through resolution)conductor-daemon/src/daemon/types.rs:512—DevicePortStatus(to be extended)conductor-daemon/src/daemon/device_utils.rs— MIDI port enumeration (to add output scanning)conductor-daemon/src/midi_device.rs—MidiDeviceManager(input-only, for reference)conductor-gui/src-tauri/src/commands.rs— Tauri command bridge (to extend binding response)conductor-gui/ui/src/lib/stores.js:225—deviceBindingsStore(to extend shape)conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte— Device pills (to add direction icon)conductor-gui/ui/src/lib/components/DeviceList.svelte— Device list (to add I/O columns)conductor-gui/ui/src/lib/workspace/DeviceSettingsView.svelte— Device settings (to add I/O display)- Industry references: ReaLearn unit model, Open Stage Control
device:in,out, TouchOSC connection model