Skip to main content

ADR-011: FabFilter + ReaLearn MIDI Mapping Patterns — Adoption Strategy

Status

Proposed (Pending LLM Council Review)

Context

Problem Statement

Conductor's MIDI mapping architecture has grown organically from a simple note-to-keystroke mapper into a multi-trigger, multi-action, LLM-assisted mapping engine. Two industry-leading implementations — FabFilter Pro-Q 4's MIDI Learn and Helgobox/ReaLearn's controller mapping system — represent the state of the art in MIDI controller integration. This ADR evaluates their patterns and recommends which to adopt, adapt, or skip for Conductor.

Reference Implementations

FabFilter Pro-Q 4 is a professional EQ plugin with a focused, polished MIDI Learn workflow. Its strength is UX simplicity: arm learn mode, touch a parameter, wiggle a control, done. It also introduces an "active band" abstraction — one set of physical controls maps to whichever element is currently selected, enabling a small controller to manage unlimited parameters.

Helgobox/ReaLearn is a comprehensive controller mapping system for REAPER. Its strength is architectural depth: a Source → Glue → Target pipeline with transformations at each stage, virtual controller abstractions, conditional activation, takeover modes, controller presets, and feedback (LEDs, motorized faders, displays). It's the most feature-complete open-source MIDI mapping system available.

Conductor's Current State

Conductor already has significant mapping infrastructure (see companion research document for full details):

  • Event processing pipeline: Raw MIDI → MidiEvent → ProcessedEvent → Trigger matching → Action execution
  • 13 trigger types: Note, CC, EncoderTurn, Aftertouch, PitchBend, VelocityRange, DoubleTap, LongPress, NoteChord, GamepadButton, GamepadButtonChord, GamepadAnalogStick, GamepadTrigger
  • 12+ action types: Keystroke, SendMidi, MidiForward, Text, Launch, Shell, MouseClick, VolumeControl, ModeChange, Sequence, Delay, OscSend, Plugin (WASM)
  • MidiTransform pipeline: Channel remapping, CC/note remapping, velocity scaling, inversion, curves (linear, logarithmic, exponential, LUT)
  • Multi-device support (ADR-009): Listen-first architecture with DeviceIdentity aliases and matchers
  • Device templates: Pre-configured profiles for known hardware
  • Conditional activation: TimeRange, DayOfWeek, AppRunning, AppFrontmost with AND/OR/NOT chaining
  • Mode system: Named mapping contexts within a profile, with ModeChange actions to switch
  • MIDI Learn: Capture → auto-detect → suggest with refinement card UI (GUI v2 Phase 3)
  • LLM-assisted mapping: Chat-driven config changes with diff preview and apply/reject workflow

Decision

Patterns to ADOPT

1. Soft Takeover (from ReaLearn)

What it is: When switching modes or loading presets, physical knob positions may not match the current parameter values. Without soft takeover, moving a knob causes a parameter jump from (say) 80% to 10%. Soft takeover suppresses control updates until the physical position crosses the current value, preventing audible artifacts.

Why adopt: Conductor already has modes and profiles. Users switching between modes will experience parameter jumps on any CC-mapped continuous controllers. This is a fundamental UX problem for anyone using physical knobs/faders with SendMidi or MidiForward actions.

Proposed implementation:

// New field on CompiledMapping
pub struct CompiledMapping {
// ... existing fields ...
pub takeover_mode: TakeoverMode,
pub last_sent_value: AtomicU8, // Track last value per mapping
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum TakeoverMode {
Off, // No takeover (current behavior, default)
Pickup, // Ignore until physical value matches target
LongTimeNoSee, // Gradual catchup over time
Parallel, // Relative offset from last known position
}

Config:

[[mappings]]
trigger = { type = "CC", cc = 1 }
action = { type = "SendMidi", message_type = "CC", cc = 1, port = "Synth Out" }
takeover_mode = "Pickup" # Default: "Off"

Scope: Core mapping engine + config types. No GUI changes needed initially (CLI/chat can set it). GUI can add a dropdown later.


2. Active Element / Context-Sensitive Control (from FabFilter)

What it is: FabFilter's "Active Band" mode maps a single set of physical controls to whichever EQ band is currently selected. One set of 4 knobs controls frequency, gain, Q, and type for any of 24 bands — by just clicking a different band.

Why adopt: Conductor's mode system already enables context switching, but it requires explicit ModeChange triggers (e.g., pressing a specific pad to switch). FabFilter's approach is implicit — the context follows the user's selection. For Conductor, this maps to: "CC 1 controls gain of whichever channel/parameter the user last selected in the GUI."

Proposed implementation — via "Target Variables":

Rather than a full "active element" system, Conductor can adopt this pattern through its existing LLM chat. The user says "map knob 1 to control the selected parameter" and the LLM generates a mapping with a target variable:

[[mappings]]
trigger = { type = "CC", cc = 1, device = "pads" }
action = { type = "SendMidi", message_type = "CC", cc = "$active_cc", port = "DAW" }

The $active_cc variable is updated by a GUI action or chat command ("now control filter cutoff" → sets active_cc = 74).

Scope: Phase 2+ enhancement. Requires variable store in the daemon + GUI binding. Defer detailed design to a separate ADR.


3. Source → Glue → Target Pipeline Refinement (from ReaLearn)

What it is: ReaLearn's Glue layer sits between Source and Target, providing: value range clamping (source interval + target interval), inversion, curves, step sizes, value sequences, and relative-to-absolute conversion. It transforms what comes from the controller before it reaches the target.

Why adopt (partially): Conductor already has MidiTransform with channel remapping, velocity scaling, curves, and inversion. However, these transforms are only applied to SendMidi and MidiForward actions. They're not available for Keystroke or other action types.

What to adopt:

  • Value range (source interval): Allow triggers to specify a response range, e.g., "only fire when CC value is between 64-127"
  • Step quantization: For encoders, quantize relative deltas to specific step sizes
  • Value-to-action scaling: For Keystroke actions triggered by CC, scale the velocity/value into a meaningful parameter (e.g., repeat count)

What to skip:

  • ReaLearn's full Glue pipeline with EEL scripting and Lua feedback scripts — too complex for Conductor's scope
  • Value sequences — niche DAW-specific feature
  • Feedback type system — Conductor doesn't target motorized faders or display controllers

Proposed config extension:

[[mappings]]
trigger = { type = "CC", cc = 1, value_min = 64, value_max = 127 } # Source interval
action = { type = "Keystroke", keys = ["VolumeUp"] }
transform = { scale = [64, 127, 0, 10], step = 5 } # Optional glue-like transform

4. Learn → Refine → Commit Workflow (from both)

What it is: Both FabFilter and ReaLearn use a "capture then refine" approach. FabFilter dims non-assignable parameters and highlights assignable ones. ReaLearn auto-detects CC character (knob, encoder, button) and pre-fills the mapping editor. Neither commits until explicitly confirmed.

Why adopt: Conductor already has this pattern in GUI v2 Phase 3 (RefinementCard). However, the implementation can be strengthened:

Improvements to adopt:

  • FabFilter's visual dimming: During MIDI Learn, dim the workspace to focus attention on the refinement card. Grey out non-relevant controls.
  • ReaLearn's character detection: When learning a CC, automatically classify it as absolute knob/fader, relative encoder, or momentary button based on the value pattern during capture (e.g., values that only hit 0 and 127 = button; values that increment/decrement around 64 = relative encoder).
  • ReaLearn's multi-source capture: If multiple controllers send events during learn, capture from all of them and let the user pick which device to use.

Current state: Conductor's MidiLearnSession already does pattern detection (encoder direction, double-tap, chord, long-press). The gap is in CC character classification and multi-device capture during learn.


5. Controller Presets (from ReaLearn)

What it is: ReaLearn separates "controller description" from "what the controller does." A controller preset describes all physical elements (16 pads, 8 knobs, 1 fader), their MIDI assignments, and their physical layout. Main presets then reference virtual elements ("knob 1") instead of raw MIDI ("CC 21").

Why adopt: Conductor already has Device Templates with vendor/product matching and HID LED configuration. Extending templates to include a "control element map" would enable:

  • Shareable controller profiles (community presets)
  • Device-agnostic mappings (switch controllers without remapping)
  • Better MIDI Learn suggestions (knowing that CC 21 is "Knob 3" on a specific controller)

Proposed extension to DeviceTemplate:

[template.controls]
# Named control elements for this device
pad_1 = { type = "Note", note = 36, label = "Pad 1 (bottom-left)" }
pad_2 = { type = "Note", note = 37, label = "Pad 2" }
knob_1 = { type = "CC", cc = 21, label = "Knob 1", character = "absolute" }
encoder_1 = { type = "CC", cc = 70, label = "Encoder", character = "relative" }
fader_1 = { type = "CC", cc = 1, label = "Master Fader", character = "absolute" }

Mappings can then reference by name:

[[mappings]]
trigger = { type = "CC", control = "knob_1", device = "mikro" }
action = { type = "SendMidi", message_type = "CC", cc = 74, port = "Synth" }
takeover_mode = "Pickup"

Scope: Medium effort. Extends DeviceTemplate config + mapping resolution. Does NOT require virtual sources (see "Skip" section).


Patterns to SKIP

1. Virtual Sources/Targets (ReaLearn)

What: ReaLearn's two-compartment model with virtual control elements that abstract physical controllers from mapping logic.

Why skip: This adds significant architectural complexity (controller compartment → virtual mapping → main compartment → target). Conductor's Device Template + named control elements (proposed above) achieves 80% of the benefit with 20% of the complexity. Conductor's LLM chat can resolve "map the top-left knob" to the correct CC via template metadata without a virtual indirection layer.

Revisit when: Community controller sharing becomes a primary use case.


2. Feedback System (ReaLearn — LEDs, Motorized Faders, Displays)

What: ReaLearn provides bidirectional control: physical control → DAW parameter AND DAW parameter → physical indicator (LED, motor fader position, display text). Includes Lua scripting for custom feedback formatting.

Why skip the full system: Conductor already has HID LED support for specific controllers (Maschine Mikro). A generalized feedback system targeting arbitrary MIDI controllers with motorized faders and OLED displays is a massive scope expansion. Conductor's primary use case is macro pad → computer control, not DAW control surface emulation.

What to keep: The existing HID LED feedback for supported devices. Consider a lightweight "LED follows mode" feedback (pad color changes when mode changes) as a separate feature.


3. DAW-Specific MIDI Routing (FabFilter)

What: FabFilter documents complex per-DAW MIDI routing (Pro Tools MIDI tracks, Logic AU MIDI-controlled Effects, Ableton MIDI From routing).

Why skip: Conductor is a standalone daemon, not a DAW plugin. MIDI routing is handled at the OS/driver level. Not applicable.


4. EEL/Lua Scripting in Transforms (ReaLearn)

What: ReaLearn allows arbitrary EEL and Lua scripts in the Glue layer for complex value transformations.

Why skip: Conductor already has WASM plugins for extensibility and LUT curves for arbitrary value mapping. Adding a second scripting runtime (EEL or Lua) creates maintenance burden without clear user benefit. If users need custom transforms beyond curves + scaling, WASM plugins are the right extension point.


5. Projection / Mobile Companion (ReaLearn)

What: ReaLearn can project a schematic controller view to a mobile device.

Why skip: Interesting but out of scope. Conductor's chat interface already provides a way to see and modify mappings. A mobile companion app could be a future product extension but isn't related to the mapping architecture.


6. FX Focus Linking (ReaLearn)

What: Auto-load different mapping presets based on which FX plugin is currently focused in the DAW.

Why skip: DAW-specific. Conductor's AppFrontmost condition handles the "which application is focused" case. Within-DAW FX focus is not detectable from a standalone daemon.


Patterns to DEFER

1. Conditional Activation in Controller Compartment (ReaLearn)

What: Modifier-based conditional activation within controller mappings (hold Shift button → knob 1 becomes knob 9).

Current state: Conductor has conditional activation (TimeRange, DayOfWeek, AppRunning, AppFrontmost) but these are environmental conditions, not controller-state conditions. Using a MIDI button as a "shift" modifier to change what other buttons do would require a new condition type.

Proposed (deferred):

[[conditions]]
type = "MidiHeld"
trigger = { type = "Note", note = 48 } # Hold pad 12 as shift

Why defer: Can be approximated today by using modes (pad 12 → ModeChange to "shifted" mode). A dedicated modifier system would be more elegant but is additive, not blocking.


2. Mapping Groups with Independent Enable/Disable (ReaLearn)

What: ReaLearn allows grouping mappings and enabling/disabling entire groups.

Current state: Conductor's mode system provides this — each mode is effectively a group. However, modes are mutually exclusive (one active mode at a time), while ReaLearn groups can be independently toggled.

Why defer: The mode system covers 90% of use cases. Independent group toggling is a power-user feature that can be added later without architectural changes.


Implementation Phases

Phase 1: Soft Takeover (Priority: HIGH)

Effort: ~2 days Files: conductor-core/src/config/types.rs, conductor-core/src/mapping.rs Depends on: Nothing

Add TakeoverMode enum + last_sent_value tracking to CompiledMapping. Implement Pickup mode first (simplest). Config-only, no GUI needed initially.

Phase 2: Controller Element Map in Templates (Priority: MEDIUM)

Effort: ~3 days Files: conductor-core/src/config/types.rs, conductor-gui/src-tauri/src/device_templates.rs, conductor-core/src/mapping.rs Depends on: Nothing

Extend DeviceTemplate with named control elements. Update trigger matching to resolve control field via template lookup.

Phase 3: Source Interval / Value Range on Triggers (Priority: MEDIUM)

Effort: ~1 day Files: conductor-core/src/config/types.rs, conductor-core/src/mapping.rs Depends on: Nothing

Add optional value_min/value_max to CC and Aftertouch triggers (some triggers already have this — extend to all continuous types).

Phase 4: MIDI Learn CC Character Detection (Priority: LOW)

Effort: ~2 days Files: conductor-gui/src-tauri/src/midi_learn.rs, frontend refinement helpers Depends on: Phase 2 (template element lookup for name resolution)

Improve auto-detection to classify CC as absolute/relative/momentary during capture. Use template control element metadata when available.

Phase 5: Target Variables / Active Element (Priority: LOW)

Effort: ~5 days (separate ADR recommended) Files: New variable store in daemon, config types, GUI binding Depends on: Phases 1-3 stable

Implement runtime variables that mappings can reference. This is the most complex addition and should have its own ADR.


Consequences

Positive

  • Soft takeover eliminates parameter jumps when switching modes — immediate UX improvement for hardware users
  • Controller element maps make MIDI Learn smarter and templates more useful
  • Source intervals enable finer-grained trigger control without changing the action system
  • All additions are backward-compatible (takeover_mode defaults to Off, control field is optional)

Negative

  • Soft takeover adds per-mapping state (last_sent_value) to the hot path — minor performance consideration
  • Controller element maps increase template complexity — but are optional
  • Target variables (Phase 5) add runtime state to the daemon — requires careful concurrency design

Neutral

  • No breaking changes to existing configs
  • No changes to conductor-core's public API (all additions are additive)
  • ReaLearn's full two-compartment virtual abstraction is intentionally not adopted — if needed later, the controller element map provides a migration path

References