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:
- No confirmation loop: Users don't know if the mapping they intended actually fired, or if a different mapping caught the event
- No outcome visibility: The action result (which app opened, what key was sent, what volume changed) is invisible in the GUI
- Debugging difficulty: When a mapping doesn't behave as expected, users must mentally correlate raw MIDI events with their mapping configuration
- Learning gap: New users cannot build an intuitive understanding of how triggers map to actions because the system appears as a black box
- 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 eventaction_executed— when an action completes successfullyaction_error— when an action failsmode_change— when a ModeChange action fireslatency— 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:
| Channel | Purpose | Best For |
|---|---|---|
| Row Pulse | Spatial confirmation ("which mapping fired") | Quick visual scan during performance |
| Fired Entries | Detailed inspection ("what happened") | Debugging, learning, verification |
| Toast Notifications | Outcome 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_mappingwithexecute: truerespects 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
simulateevent 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
stopPropagationprevents row click/selection conflicts
D7: Feedback Settings
New settings section under App Settings → Feedback:
| Setting | Type | Default | Description |
|---|---|---|---|
feedback.rowPulse | boolean | true | Flash mapping row when it fires |
feedback.firedEntries | boolean | true | Show "Fired" entries in event stream |
feedback.toasts | boolean | true | Show toast notifications |
feedback.toasts.continuous | boolean | false | Show toasts for continuous controls |
feedback.toasts.ttl | number | 3000 | Toast auto-dismiss time (ms) |
feedback.toasts.maxVisible | number | 3 | Maximum simultaneous toasts |
feedback.simulate.defaultExecute | boolean | true | Default simulate mode (true=real, false=dry-run) |
feedback.coalescence.pulse | number | 200 | Pulse debounce for continuous controls (ms) |
feedback.coalescence.toast | number | 500 | Toast 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
| Phase | Scope | Estimate | Dependencies |
|---|---|---|---|
| Phase 1 | Fired event pipeline: daemon emits structured MappingFiredEvent, GUI receives and displays in event stream | 6-8h | Daemon monitor extension, event store changes |
| Phase 2 | Mapping Row Pulse: activity LED, fire state store, debounced animation | 4-5h | Phase 1 (needs fire events) |
| Phase 3 | Expandable event rows: click-to-expand for all event types, detail panel | 4-5h | Phase 1 (detail data available) |
| Phase 4 | Toast notifications: toast store, ToastOverlay component, coalescence | 4-5h | Phase 1 (needs fire events) |
| Phase 5 | Simulate command: Tauri command, daemon method, MappingRow button | 6-8h | Phase 1 + Phase 2 |
| Phase 6 | Settings UI, filter extensions, polish | 3-4h | All 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_mappingwithexecute: truebypasses 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