Skip to main content

ADR-014: Mapping Feedback & Simulate

Status

Proposed (Pending LLM Council Review)

Context

Problem Statement

When a user interacts with a physical MIDI controller — pressing a pad, turning an encoder, moving a fader — Conductor's daemon correctly matches the trigger, executes the action (keystroke, app launch, MIDI forward, etc.), and the target application responds. However, the Conductor GUI itself provides zero visual feedback that a mapping fired. The user sees raw MIDI events scroll by in the Event Stream panel, but there is no connection between those events and the mapping that consumed them.

This creates several problems:

  1. No confirmation loop: Users don't know if the mapping they intended actually fired, or if a different mapping caught the event
  2. No outcome visibility: The action result (which app opened, what key was sent, what volume changed) is invisible in the GUI
  3. Debugging difficulty: When a mapping doesn't behave as expected, users must mentally correlate raw MIDI events with their mapping configuration
  4. Learning gap: New users cannot build an intuitive understanding of how triggers map to actions because the system appears as a black box
  5. No dry-run capability: Users cannot test a mapping without physically operating hardware, making configuration verification difficult for complex setups or when hardware is not connected

Reference Implementations

Ableton Live: Flashes MIDI indicator in the top-right when MIDI is received/sent. Highlights the mapped parameter in blue when its control is moved. Remote-control mapping overlay shows all active mappings.

ReaLearn (Helgobox): Real-time feedback panel showing source value, target value, and active mapping. "Learn source" and "learn target" modes with visual confirmation. Filter by controller, mapping group, or active state.

FabFilter Pro-Q 4: Glowing parameter highlight when MIDI-learned control is moved. Band selection indicator shows which element is being controlled.

Existing Infrastructure

Conductor's daemon already emits action events via the monitor system:

  • mapping_matched — when a rule matches an incoming event
  • action_executed — when an action completes successfully
  • action_error — when an action fails
  • mode_change — when a ModeChange action fires
  • latency — processing time measurements

The GUI receives these via start_event_monitoring / SSE stream but currently only displays raw MIDI events. The ActionExecutor::execute(Action, TriggerContext) → DispatchResult pipeline is well-structured for adding a simulate entry point.

Design Exploration

Interactive mockups were created exploring four approaches (see mapping-feedback-mockups.html):

  • Option A: Mapping Row Pulse + Activity LED — Subtle flash on the MappingRow component when its mapping fires
  • Option B: Event Stream Fired Entries — New "Fired" event type in the stream with expandable detail showing trigger, action, result, latency
  • Option C: Workspace Toast Notifications — Temporary toast overlays in the workspace area showing mapping fire summaries
  • Option D: Combined — All three feedback channels working together

Decision

D1: Adopt Combined Feedback (Option D) with Per-Channel Toggles

All three feedback channels address different user needs and should be implemented together:

ChannelPurposeBest For
Row PulseSpatial confirmation ("which mapping fired")Quick visual scan during performance
Fired EntriesDetailed inspection ("what happened")Debugging, learning, verification
Toast NotificationsOutcome awareness ("what was the result")Casual monitoring, first-time setup

Each channel is independently toggleable in settings. Default state:

  • Row Pulse: ON
  • Fired Entries: ON
  • Toast Notifications: ON for discrete triggers (note, pad, button), OFF for continuous (CC, encoder, pitch bend, aftertouch) to avoid toast spam

D2: Mapping Row Pulse Animation

When a mapping fires, its MappingRow component receives a transient fired state:

<!-- MappingRow.svelte addition -->
<script>
export let fired = false;

// Auto-clear after animation completes
$: if (fired) {
const timer = setTimeout(() => { fired = false; }, 600);
return () => clearTimeout(timer);
}
</script>

<div class="mapping-row" class:fired>
<span class="activity-led" class:pulse={fired}></span>
<!-- existing trigger → action content -->
</div>

CSS animation (respects prefers-reduced-motion):

.mapping-row.fired {
background: color-mix(in srgb, var(--accent) 8%, transparent);
transition: background 0.6s ease-out;
}

.activity-led {
width: 5px;
height: 5px;
border-radius: 50%;
background: transparent;
flex-shrink: 0;
transition: all 0.15s;
}

.activity-led.pulse {
background: var(--accent);
box-shadow: 0 0 6px var(--accent);
animation: led-pulse 0.6s ease-out;
}

@keyframes led-pulse {
0% { transform: scale(1.4); opacity: 1; }
100% { transform: scale(1); opacity: 0.3; }
}

@media (prefers-reduced-motion: reduce) {
.mapping-row.fired { transition: none; }
.activity-led.pulse { animation: none; }
}

Coalescence: For continuous controls (CC, encoder, pitch bend), pulse state is debounced at 200ms — the LED stays lit while the control is moving, then fades 600ms after the last event. This prevents flickering during smooth fader movements.

D3: Event Stream "Fired" Entries with Expandable Detail

A new event category MappingFired is added to the event stream alongside raw MIDI events:

// Event type extension
interface MappingFiredEvent {
_id: number;
type: 'mapping_fired';
trigger: {
type: string; // "note", "cc", "encoder", etc.
device: string; // Device alias or port name
channel: number;
number: number;
value: number;
};
action: {
type: string; // "keystroke", "launch", "send_midi", etc.
summary: string; // Human-readable: "⌘+C", "Launch Spotify", etc.
};
result: 'ok' | 'error';
error?: string;
latency_us: number; // Microseconds from event receipt to action completion
mode: string;
mapping_label?: string;
timestamp: number;
}

Display: Fired entries appear in the event stream with a distinct style:

  • Left border: 2px solid accent color (mapping type color)
  • Icon: instead of the standard event dot
  • Content: Fired: {trigger_summary} → {action_summary}
  • Expandable: Click to reveal detail panel with device, mode, result, latency, raw MIDI bytes

Filtering: EventFilter gains a "Fired" toggle (default ON) alongside the existing type filters. Users can view only fired events, only raw events, or both.

Expandable rows: All event rows (raw and fired) are single-line truncated by default. Clicking expands to show a detail panel. This keeps the stream scannable while providing deep inspection:

.event-row {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}

.event-row.expanded {
white-space: normal;
overflow: visible;
}

.event-detail-panel {
display: none;
padding: 6px 0 4px 13px;
font-size: 10px;
color: var(--text-dim);
flex-direction: column;
gap: 2px;
}

.event-row.expanded .event-detail-panel {
display: flex;
}

D4: Workspace Toast Notifications

Transient toast notifications appear in the workspace area (bottom-right, above the status bar) when mappings fire:

interface MappingToast {
id: string;
icon: string; // Trigger type icon
title: string; // "Note 87 → Launch"
subtitle: string; // "Spotify opened" or "⌘+C sent"
type: 'success' | 'error';
ttl: number; // Auto-dismiss in ms (default 3000)
}

Behaviour:

  • Stack up to 3 visible toasts; older ones dismiss early
  • Auto-dismiss after 3 seconds (configurable)
  • Hover pauses auto-dismiss timer
  • Click dismisses immediately
  • Error toasts show red accent and persist until dismissed
  • Continuous control toasts are suppressed by default (see D1)

Coalescence for continuous controls (when enabled): Multiple fires from the same mapping within 500ms produce a single updating toast showing the latest value, rather than a toast per event.

D5: Simulate Mapping Command

A new simulate_mapping Tauri command allows the GUI to trigger any mapping's action without physical MIDI input:

// conductor-gui/src-tauri/src/commands.rs

/// Simulate firing a mapping by executing its action directly.
///
/// Bypasses trigger matching entirely — constructs a TriggerContext
/// from the mapping's trigger definition and calls ActionExecutor::execute().
/// Emits the same action events (mapping_matched, action_executed) as a
/// real firing so all feedback channels activate.
#[tauri::command]
pub async fn simulate_mapping(
state: tauri::State<'_, DaemonHandle>,
mode_name: String,
mapping_index: usize,
options: SimulateOptions,
) -> Result<SimulateResult, String> {
// Implementation delegates to daemon
}

#[derive(Deserialize)]
pub struct SimulateOptions {
/// If true, execute the action for real (keystroke sent, app launched, etc.)
/// If false, dry-run only — emit events but don't execute the action.
pub execute: bool,

/// Override trigger value (e.g., velocity for note, value for CC).
/// Defaults to the mapping's trigger midpoint or 127 for notes.
pub value: Option<u8>,
}

#[derive(Serialize)]
pub struct SimulateResult {
pub outcome: String, // "completed", "mode_change", "error"
pub action_summary: String,
pub latency_us: u64,
pub error: Option<String>,
}

Daemon-side implementation:

// conductor-daemon/src/daemon/engine_manager.rs (new method)

pub async fn simulate_mapping(
&self,
mode_name: &str,
mapping_index: usize,
options: SimulateOptions,
) -> Result<SimulateResult, SimulateError> {
// 1. Resolve the mapping from current config
let config = self.config.load();
let mode = config.modes.iter()
.find(|m| m.name.as_deref() == Some(mode_name))
.ok_or(SimulateError::ModeNotFound)?;
let mapping = mode.mappings.get(mapping_index)
.ok_or(SimulateError::MappingNotFound)?;

// 2. Construct synthetic TriggerContext
let context = TriggerContext {
velocity: options.value.or(Some(default_value_for(&mapping.trigger))),
current_mode: Some(mode_name.to_string()),
raw_midi: synthesize_midi_bytes(&mapping.trigger, options.value),
};

// 3. Emit mapping_matched event (so Row Pulse and Fired Entry activate)
self.emit_action_event("mapping_matched", &mapping, &context).await;

// 4. Execute or dry-run
if options.execute {
let result = self.action_executor.execute(
mapping.action.clone(),
context,
).await;
self.handle_dispatch_result(&result, &mapping).await;
Ok(SimulateResult::from(result))
} else {
// Dry-run: emit action_executed with synthetic result
self.emit_action_event("action_executed", &mapping, &DryRunResult).await;
Ok(SimulateResult::dry_run(&mapping.action))
}
}

Security considerations:

  • simulate_mapping with execute: true respects the same tool risk tiers as real mapping execution
  • HardwareIO actions (SendMidi, MidiForward) require the MIDI output port to be available
  • Shell actions are sandboxed per existing security policy
  • Dry-run mode (execute: false) is always safe — it only emits events
  • The LLM chat interface exposes simulate as a Stateful-tier tool (it changes observable state when execute=true)

D6: Simulate Button on MappingRow

Each MappingRow gains a hover-revealed simulate button:

<!-- MappingRow.svelte addition -->
<button
class="mapping-sim-btn"
title="Simulate this mapping (▶)"
on:click|stopPropagation={() => dispatch('simulate', { mode, index })}
>
▶ Sim
</button>

Behavior:

  • Hidden by default, appears on row hover (opacity 0 → 1)
  • Click dispatches simulate event with { execute: true } (real execution)
  • Shift+Click dispatches with { execute: false } (dry-run, events only)
  • Button flashes green briefly on successful simulation
  • Shows error indicator (red flash) if action fails
  • stopPropagation prevents row click/selection conflicts

D7: Feedback Settings

New settings section under App Settings → Feedback:

SettingTypeDefaultDescription
feedback.rowPulsebooleantrueFlash mapping row when it fires
feedback.firedEntriesbooleantrueShow "Fired" entries in event stream
feedback.toastsbooleantrueShow toast notifications
feedback.toasts.continuousbooleanfalseShow toasts for continuous controls
feedback.toasts.ttlnumber3000Toast auto-dismiss time (ms)
feedback.toasts.maxVisiblenumber3Maximum simultaneous toasts
feedback.simulate.defaultExecutebooleantrueDefault simulate mode (true=real, false=dry-run)
feedback.coalescence.pulsenumber200Pulse debounce for continuous controls (ms)
feedback.coalescence.toastnumber500Toast coalescence window (ms)

D8: Event Store Extensions

The events.js store requires extensions:

// stores/events.js additions

// New buffer for mapping-fired events (merged into main stream)
export const firedEvents = writable([]);

// Extended filter state
export const eventFilters = writable({
// existing: note, cc, encoder, aftertouch, pitchbend, gamepad
fired: true, // Show mapping-fired entries
rawOnly: false, // Hide fired, show raw only
firedOnly: false, // Hide raw, show fired only
});

// Mapping fire state (keyed by mode+index for pulse animation)
export const mappingFireState = writable({});
// { "Live:0": { fired: true, timestamp: 1234567890 }, ... }

// Toast queue
export const toastQueue = writable([]);

Event flow: Daemon SSE stream → initEventListener() → detect mapping_matched/action_executed → update mappingFireState (triggers Row Pulse) + append to firedEvents (merged into filteredEvents) + push to toastQueue.

D9: Implementation Phases

PhaseScopeEstimateDependencies
Phase 1Fired event pipeline: daemon emits structured MappingFiredEvent, GUI receives and displays in event stream6-8hDaemon monitor extension, event store changes
Phase 2Mapping Row Pulse: activity LED, fire state store, debounced animation4-5hPhase 1 (needs fire events)
Phase 3Expandable event rows: click-to-expand for all event types, detail panel4-5hPhase 1 (detail data available)
Phase 4Toast notifications: toast store, ToastOverlay component, coalescence4-5hPhase 1 (needs fire events)
Phase 5Simulate command: Tauri command, daemon method, MappingRow button6-8hPhase 1 + Phase 2
Phase 6Settings UI, filter extensions, polish3-4hAll previous phases

Total estimate: 27-35 hours

Phases 2, 3, and 4 can run in parallel after Phase 1 completes.


Consequences

Positive

  • Immediate feedback loop: Users see exactly which mapping fired, what action ran, and whether it succeeded — closing the black-box gap
  • Debugging acceleration: Expandable fired entries with latency, device, mode, and error info make troubleshooting fast
  • Configuration confidence: Simulate allows verifying mappings without hardware, enabling setup validation before live performance
  • Learning support: Visual correlation between physical input and system response helps new users build mental models
  • Incremental adoption: Each feedback channel is independent and toggleable; users choose their preferred level of verbosity

Negative

  • Performance overhead: Row pulse animation and toast rendering add per-mapping-fire cost. Mitigated by CSS-only animations and requestAnimationFrame batching
  • Visual noise risk: High-activity setups (live performance with many encoders) could produce excessive feedback. Mitigated by coalescence, per-channel toggles, and continuous-control defaults
  • Event stream density: Fired entries interleaved with raw events could make the stream harder to scan. Mitigated by distinct styling, "Fired Only" filter, and expandable rows keeping default view compact
  • Simulate security surface: simulate_mapping with execute: true bypasses trigger matching. Mitigated by respecting existing action security boundaries and risk tiers

Risks

  • R1: Toast animation performance on low-end systems — measure with 60fps budget, fallback to opacity-only transitions
  • R2: Daemon SSE stream bandwidth with high-frequency fired events — add server-side throttling/sampling for streams exceeding 100 events/second
  • R3: Simulate could confuse target applications if triggered accidentally — consider confirmation for destructive actions (Shell commands)

References

  • Interactive mockups: mapping-feedback-mockups.html (Options A-D with simulate buttons)
  • ADR-007: LLM Integration Architecture (tool risk tiers, action events)
  • ADR-011: FabFilter + ReaLearn Mapping Patterns (MIDI mapping UX precedents)
  • ReaLearn feedback panel: https://github.com/helgoboss/helgobox
  • Action execution pipeline: conductor-daemon/src/action_executor.rs
  • Existing event types: conductor-core/src/event_types.rs
  • Existing Tauri commands: conductor-gui/src-tauri/src/commands.rs