Skip to main content

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:

  1. Output actions can't use device aliases. A user writes alias = "Mikro" and uses device = "Mikro" in their triggers. But when they write a SendMidi action 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.

  2. 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.

  3. 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.

  4. Device management views have no I/O context. DeviceStatusPills, DeviceList, and DeviceSettingsView show "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 any SendMidi/MidiForward actions target a specific output port.

  5. Signal Flow has no action destination context. The V3 MappingBranch action 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, but SendMidi/MidiForward targets 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 DeviceIdentityConfig with input/output port fields and DeviceDirection classification
  • Output port auto-pairing heuristic (match by name similarity)
  • Alias-based output port resolution in SendMidi and MidiForward actions
  • DevicePortStatus extension with direction and output port info
  • GUI updates: device pills, device management views, Signal Flow action column
  • Backward compatibility: existing matchers/match configs continue to work unchanged

Out of scope:

  • Full MIDI output device manager (persistent output connections with reconnection) — currently MidiOutputManager connects 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

ConceptWhat it answersWhere 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 caseInput binding?Output binding?Config complexity
Pad triggers hotkeys (Cmd+Space)YesNoSimple: matchers only
Knob controls volumeYesNoSimple
Controller with LED feedbackYesYesModerate: input + output
Forward MIDI to external synthYes (source)Yes (target)Moderate: two devices
Output-only synth/lightsNoYesSimple: output only
Echo event back to source (_source)YesYes (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):

  1. If input is present → use input.matchers for input port matching
  2. Else if matchers is non-empty → promote to input.matchers internally (backward compat)
  3. If output is present → use output.matchers for output port matching
  4. Else → attempt auto-pairing (D2)

Invariants:

  • matchers and input are semantically exclusivematchers is the legacy form of input. If both are present, input takes precedence and matchers is ignored. The config validator emits a warning if both are specified.
  • At least one of matchers, input, or output must be present (a device with no port bindings is invalid).
  • The DeviceEditor GUI and MCP tools always write canonical input/output form, never legacy matchers. Legacy matchers is 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:

  1. Given input port name (e.g., "Maschine Mikro MK3 Input")
  2. Strip common suffixes: " Input", " In", " MIDI In"
  3. Compute base name (e.g., "Maschine Mikro MK3")
  4. Search available MIDI output ports for:
    • Exact match: "{base} Output", "{base} Out", "{base} MIDI Out"
    • Fallback: any output port containing {base} as substring
  5. If exactly one match → auto-pair. If zero or multiple → no auto-pair (user must specify output explicitly).

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):

  1. Check if port/target matches a configured device alias
  2. If match → look up that device's resolved output port name
  3. If no alias match → treat as raw port name (existing behavior)

ImplementationActionExecutor 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 output field)

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 StateInput ResolutionOutput Resolution
matchers only (existing configs)matchers → input portAuto-pair (D2) or none
input onlyinput.matchers → input portAuto-pair (D2) or none
output onlyNo input port (output-only device)output.matchers → output port
input + outputinput.matchers → input portoutput.matchers → output port
matchers + outputmatchers → input portoutput.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:

RuleSeverityDescription
Alias requiredErroralias must be non-empty
Alias uniqueErrorNo two devices may share the same alias
At least one bindingErrorAt least one of matchers, input, or output must be present
Non-empty matchersErrorIf input or output is present, its matchers array must be non-empty
Matchers + input coexistenceWarningIf both matchers and input are specified, input takes precedence; matchers is ignored
Regex validityErrorNameRegex matcher values must be valid regular expressions
Orphan device referenceWarningA 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 outputWarningA 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):

  1. Re-normalization: Legacy matchers promoted to input.matchers internally
  2. Port re-matching: All device port bindings are re-resolved against currently available MIDI ports
  3. Direction re-classification: DeviceDirection is re-derived from the new bindings
  4. Auto-pair re-run: If a device has input but no explicit output, auto-pairing (D2) runs again
  5. Rule recompilation: CompiledRuleSet is rebuilt with updated device filter indices
  6. Active mapping continuity: Mappings referencing unchanged aliases remain active; mappings referencing deleted aliases become unresolved (runtime warning, not error)
  7. 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 MidiDeviceManager already 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 continues
  • MidiForward { 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

FilePurpose
conductor-core/src/config/port_binding.rsDevicePortBinding struct, DeviceDirection enum
conductor-daemon/src/daemon/output_resolver.rsAuto-pairing heuristic, alias → output port resolution map
conductor-gui/ui/src/lib/components/DirectionBadge.svelteSmall / / badge component

Modified Files

FileChange
conductor-core/src/config/types.rsAdd input, output fields to DeviceIdentityConfig. Add DeviceDirection enum.
conductor-core/src/identity.rsAdd DevicePortBinding type. Extend DeviceMatcher to work for output ports.
conductor-daemon/src/daemon/types.rsExtend DevicePortStatus with direction, output_port_name, output_connected, output_auto_paired
conductor-daemon/src/action_executor.rsAdd device_output_map, resolve_output_port() method. Wire SendMidi.port and MidiForward.target through resolution. Implement _source target.
conductor-daemon/src/daemon/device_utils.rsAdd output port enumeration (parallel to input enumeration)
conductor-gui/src-tauri/src/commands.rsExtend getBindings response with direction, output port info
conductor-gui/ui/src/lib/stores.jsExtend deviceBindingsStore binding shape with direction, output_port_name, output_connected
conductor-gui/ui/src/lib/components/DeviceStatusPills.svelteAdd direction icon. Handle output-only devices (no event filtering).
conductor-gui/ui/src/lib/components/DeviceList.svelteAdd direction badge, output port display, auto-pair indicator
conductor-gui/ui/src/lib/workspace/DeviceSettingsView.svelteShow I/O columns, paired port display
conductor-gui/ui/src/lib/components/signal-flow/MappingBranch.svelteResolve 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

    1. Add DevicePortBinding struct and DeviceDirection enum to conductor-core
    2. Extend DeviceIdentityConfig with input, output fields
    3. Implement direction derivation logic
    4. Extend DevicePortStatus with direction, output port fields
    5. Tests: config parsing (all combinations), direction classification
  • #670 — Phase 1B: Output port enumeration and auto-pairing heuristic

    1. Implement output port enumeration in device_utils.rs (parallel to input enumeration using midir::MidiOutput)
    2. Implement auto-pairing heuristic (D2) in new output_resolver.rs
    3. Build device_output_map (alias → output port name) at startup and config reload
    4. Tests: auto-pairing (exact, suffix strip, fallback, ambiguous)

Phase 2: Backend — Action Resolution (3–4h)

  • #671 — Phase 2A: Alias-based output resolution in SendMidi/MidiForward
    1. Add resolve_output_port() to ActionExecutor
    2. Wire SendMidi.port through resolution before connect_by_name
    3. Wire MidiForward.target through resolution
    4. Implement _source target: read device_id from trigger context, resolve to output port
    5. Error handling: clear error message when alias has no output port, when _source device has no output
    6. Tests: alias resolution, raw port fallback, _source resolution, error cases

Phase 3: GUI — Device Store & Views (4–6h)

  • #672 — Phase 3A: Extend device stores and create DirectionBadge component

    1. Extend Tauri getBindings command response with direction, output port info
    2. Extend deviceBindingsStore binding shape
    3. Create DirectionBadge.svelte component ( / / )
    4. Add deviceDirectionMap and deviceOutputMap derived stores
  • #673 — Phase 3B: Update device pills, device list, and device settings with I/O context

    1. Update DeviceStatusPills.svelte: add direction icon, handle output-only devices
    2. Update DeviceList.svelte: direction badge, output port display, auto-pair indicator
    3. Update DeviceSettingsView.svelte: I/O columns, paired port info
    4. Tests: pill rendering with direction, output-only device display

Phase 4: GUI — Signal Flow Integration (3–4h)

  • #674 — Phase 4A: Signal Flow action target resolution in MappingBranch
    1. Add deviceOutputMap to GUI stores (alias → output port name, from daemon status)
    2. Update MappingBranch.svelte (ADR-020): resolve SendMidi/MidiForward target to device alias + colour in action column
    3. Render resolved target as [● DeviceName] with device colour dot
    4. Render _source target as ↩ Echo → [● same device]
    5. Wire cross-track badge: if resolved target device has a DeviceGroup, show badge
    6. Tests: target resolution rendering, unresolved fallback (raw port name), cross-track badge for output devices

Phase 5: Integration Testing & Polish (2–3h)

  • #675 — Phase 5A: Integration testing and backward compatibility verification
    1. End-to-end: config with bidirectional device → daemon resolves output → SendMidi uses alias → GUI shows direction
    2. End-to-end: auto-pairing works for NI, Novation, generic USB-MIDI naming patterns
    3. End-to-end: _source echo pattern with MidiForward
    4. Backward compat: existing configs with only matchers work unchanged
    5. Verify ADR-020 V3 components render correctly with direction-aware device data
    6. 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
  • _source echo 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/output fields.

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 output explicitly for these. Mitigation: clear log message + GUI prompt.
  • Output port enumeration adds startup cost: Scanning output ports via midir::MidiOutput adds ~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: Input is 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_connected flag handles this gracefully — the device shows as Bidirectional but with output disconnected.
  • R3: MidiOutputManager caching: The existing MidiOutputManager connects 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.
  • 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:624DeviceIdentityConfig struct (to be extended)
  • conductor-core/src/config/types.rs:870DeviceConfig legacy struct (backward compat reference)
  • conductor-core/src/config/types.rs:1357SendMidi action with port: String field
  • conductor-core/src/config/types.rs:1391MidiForward action with target: String field
  • conductor-core/src/identity.rsDeviceId, DeviceMatcher, DeviceEvent types
  • conductor-core/src/midi_output.rs:52MidiOutputManager struct (output connection cache)
  • conductor-daemon/src/action_executor.rs:206ActionExecutor with midi_output: MidiOutputManager
  • conductor-daemon/src/action_executor.rs:365SendMidi dispatch (to be wired through resolution)
  • conductor-daemon/src/action_executor.rs:374MidiForward dispatch (to be wired through resolution)
  • conductor-daemon/src/daemon/types.rs:512DevicePortStatus (to be extended)
  • conductor-daemon/src/daemon/device_utils.rs — MIDI port enumeration (to add output scanning)
  • conductor-daemon/src/midi_device.rsMidiDeviceManager (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:225deviceBindingsStore (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