feat: MIDI controller mapping (per-profile CC → volume/mute)

- Add MidiMapper class: pw_stream per MIDI source node, worker thread,
  learn mode via SSE named event
- New endpoints: /api/midi-devices, /api/midi-mappings, /api/midi-learn/start/stop
- Frontend: MidiMappingPanel with learn mode, per-profile storage
- GraphEngine: support multiple onChange callbacks (addOnChange)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-04-06 02:09:44 +02:00
parent 8b7ad6e9a8
commit 9231c10429
12 changed files with 1105 additions and 14 deletions

View File

@@ -17,6 +17,7 @@
getQuantum, setQuantum,
} from '../lib/stores';
import type { Node, Port, Link } from '../lib/types';
import MidiMappingPanel from './MidiMappingPanel.svelte';
// Viewport
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
@@ -44,6 +45,7 @@
let showMergeDialog = $state(false);
let showProfileDialog = $state(false);
let showRuleDialog = $state(false);
let showMidiPanel = $state(false);
let showVirtualMenu = $state(false);
let splitNodes = $state(false);
let showNetworkDialog = $state<{ type: string } | null>(null);
@@ -481,8 +483,9 @@
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
<button class="toggle" class:active={splitNodes} onclick={() => { splitNodes = !splitNodes; }} title="Show input/output as separate nodes">Split</button>
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; showMidiPanel = false; }} title="Save/load profiles">Profiles</button>
<button onclick={() => { showMidiPanel = !showMidiPanel; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="MIDI controller mappings">MIDI</button>
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; showMidiPanel = false; }} title="Add virtual device">+ Add</button>
<span class="sep"></span>
<label class="quantum-label">Buffer:
<select class="quantum-select" onchange={(e) => { const q = Number((e.target as HTMLSelectElement).value); if (q > 0) { currentQuantum = q; setQuantum(q); } }}>
@@ -888,6 +891,10 @@
</div>
</div>
{/if}
{#if showMidiPanel}
<MidiMappingPanel onClose={() => { showMidiPanel = false; }} />
{/if}
</div>
<style>

View File

@@ -0,0 +1,408 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { nodes, patchbay, getMidiDevices, saveMidiMappings, getActiveMidiMappings, startMidiLearn, stopMidiLearn } from '../lib/stores';
import type { MidiMapping } from '../lib/types';
import { subscribeMidiLearn } from '../lib/ws';
let { onClose } = $props<{ onClose: () => void }>();
// Current mappings (local copy while panel is open)
let mappings = $state<MidiMapping[]>(getActiveMidiMappings());
// Available MIDI devices
let midiDevices = $state<{ id: number; name: string }[]>([]);
getMidiDevices().then(d => { midiDevices = d; });
// Current node list for target selector
let nodeList = $derived($nodes.filter(n => n.node_type !== 'other').map(n => n.name));
// Add mapping form
let form = $state<{
device: string;
channel: number;
cc: number;
is_note: boolean;
target_node: string;
param: 'volume' | 'mute';
min: number;
max: number;
}>({
device: '',
channel: 255,
cc: 0,
is_note: false,
target_node: '',
param: 'volume',
min: 0,
max: 1,
});
let showAddForm = $state(false);
let learning = $state(false);
let learnStatus = $state('');
// Subscribe to MIDI learn events
const unsubLearn = subscribeMidiLearn((ev) => {
if (!learning) return;
form.device = ev.device;
form.cc = ev.cc;
form.is_note = ev.is_note;
form.channel = ev.channel;
learning = false;
learnStatus = `Captured: ${ev.is_note ? 'Note' : 'CC'} ${ev.cc} on CH${ev.channel + 1} from "${ev.device}"`;
});
onDestroy(() => {
unsubLearn();
if (learning) stopMidiLearn();
});
async function doLearn() {
learnStatus = 'Listening for MIDI... (wiggle a knob or press a key)';
learning = true;
await startMidiLearn();
}
function cancelLearn() {
learning = false;
learnStatus = '';
stopMidiLearn();
}
function resetForm() {
form = { device: '', channel: 255, cc: 0, is_note: false, target_node: nodeList[0] ?? '', param: 'volume', min: 0, max: 1 };
learnStatus = '';
learning = false;
}
function addMapping() {
if (!form.target_node) return;
mappings = [...mappings, { ...form }];
showAddForm = false;
resetForm();
saveMidiMappings(mappings);
}
function removeMapping(i: number) {
mappings = mappings.filter((_, idx) => idx !== i);
saveMidiMappings(mappings);
}
function ccLabel(m: MidiMapping): string {
const ch = m.channel === 255 ? 'Any CH' : `CH${m.channel + 1}`;
const kind = m.is_note ? `Note ${m.cc}` : `CC ${m.cc}`;
const dev = m.device || 'Any device';
return `${dev} · ${ch} · ${kind}`;
}
function paramLabel(m: MidiMapping): string {
if (m.param === 'volume') return `Volume (${m.min.toFixed(2)}${m.max.toFixed(2)})`;
return 'Mute toggle';
}
</script>
<div class="panel">
<div class="panel-header">
<span>MIDI Mappings</span>
<button class="close-btn" onclick={onClose}>×</button>
</div>
<div class="panel-body">
{#if !$patchbay.active_profile}
<div class="notice">Load a profile first to save MIDI mappings.</div>
{:else}
<div class="profile-label">Profile: <strong>{$patchbay.active_profile}</strong></div>
{/if}
<!-- Mapping list -->
{#if mappings.length === 0}
<div class="empty">No mappings yet.</div>
{:else}
<div class="mapping-list">
{#each mappings as m, i}
<div class="mapping-row">
<div class="mapping-info">
<span class="mapping-src">{ccLabel(m)}</span>
<span class="mapping-arrow"></span>
<span class="mapping-dst">{m.target_node} · {paramLabel(m)}</span>
</div>
<button class="del-btn" onclick={() => removeMapping(i)}>✕</button>
</div>
{/each}
</div>
{/if}
<!-- Add mapping -->
{#if !showAddForm}
<button class="add-btn" onclick={() => { showAddForm = true; resetForm(); form.target_node = nodeList[0] ?? ''; }}>
+ Add Mapping
</button>
{:else}
<div class="add-form">
<div class="form-row">
<label>Target node</label>
<select bind:value={form.target_node}>
{#each nodeList as name}
<option value={name}>{name}</option>
{/each}
</select>
</div>
<div class="form-row">
<label>Parameter</label>
<select bind:value={form.param}>
<option value="volume">Volume</option>
<option value="mute">Mute toggle</option>
</select>
</div>
{#if form.param === 'volume'}
<div class="form-row">
<label>Range</label>
<input type="number" min="0" max="1.5" step="0.05" bind:value={form.min} style="width:60px" />
<span style="margin:0 4px"></span>
<input type="number" min="0" max="1.5" step="0.05" bind:value={form.max} style="width:60px" />
</div>
{/if}
<div class="form-row">
<label>MIDI source</label>
<div class="learn-row">
{#if !learning}
<button class="learn-btn" onclick={doLearn}>🎹 Learn...</button>
{:else}
<button class="learn-btn learning" onclick={cancelLearn}> Cancel</button>
{/if}
</div>
</div>
{#if learnStatus}
<div class="learn-status">{learnStatus}</div>
{/if}
{#if form.device || learnStatus}
<div class="form-row">
<label for="form-device">Device</label>
<select id="form-device" bind:value={form.device}>
<option value="">Any device</option>
{#each midiDevices as d}
<option value={d.name}>{d.name}</option>
{/each}
</select>
</div>
<div class="form-row">
<label for="form-channel">Channel</label>
<select id="form-channel" bind:value={form.channel}>
<option value={255}>Any</option>
{#each Array.from({length:16},(_,i)=>i) as ch}
<option value={ch}>CH{ch + 1}</option>
{/each}
</select>
</div>
<div class="form-row">
<label for="form-cc">{form.is_note ? 'Note' : 'CC'} #</label>
<input id="form-cc" type="number" min="0" max="127" bind:value={form.cc} style="width:70px" />
<label for="form-isnote" style="margin-left:8px;width:auto">
<input id="form-isnote" type="checkbox" bind:checked={form.is_note} />
Note
</label>
</div>
{/if}
<div class="form-actions">
<button class="save-btn" disabled={!form.target_node || (!learnStatus && !form.device)} onclick={addMapping}>
Save
</button>
<button class="cancel-btn" onclick={() => { showAddForm = false; cancelLearn(); }}>
Cancel
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.panel {
position: absolute;
top: 50px;
right: 8px;
width: 400px;
max-height: calc(100vh - 70px);
background: #1e1e2e;
border: 1px solid #444466;
border-radius: 6px;
display: flex;
flex-direction: column;
z-index: 100;
box-shadow: 0 4px 24px #0009;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #2a2a3e;
border-bottom: 1px solid #444466;
font-size: 13px;
font-weight: 600;
color: #ccc;
}
.close-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.close-btn:hover { color: #fff; }
.panel-body {
padding: 10px 12px;
overflow-y: auto;
flex: 1;
}
.notice, .empty {
font-size: 12px;
color: #888;
margin: 4px 0 8px;
}
.profile-label {
font-size: 11px;
color: #888;
margin-bottom: 8px;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.mapping-row {
display: flex;
align-items: center;
gap: 6px;
background: #252535;
border-radius: 4px;
padding: 5px 8px;
}
.mapping-info {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 11px;
}
.mapping-src { color: #7aadff; }
.mapping-arrow { color: #666; }
.mapping-dst { color: #aaffaa; }
.del-btn {
background: none;
border: none;
color: #ff6666;
cursor: pointer;
font-size: 12px;
padding: 0 2px;
flex-shrink: 0;
}
.del-btn:hover { color: #ff4444; }
.add-btn {
background: #2a3a4a;
border: 1px solid #4466aa;
border-radius: 4px;
color: #88aaff;
cursor: pointer;
font-size: 12px;
padding: 5px 10px;
width: 100%;
}
.add-btn:hover { background: #334455; }
.add-form {
background: #252535;
border-radius: 4px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.form-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #ccc;
}
.form-row label {
width: 90px;
flex-shrink: 0;
color: #aaa;
font-size: 11px;
}
.form-row select, .form-row input[type="number"] {
background: #1a1a2a;
border: 1px solid #444466;
border-radius: 3px;
color: #ddd;
font-size: 12px;
padding: 2px 4px;
flex: 1;
}
.learn-row { display: flex; align-items: center; gap: 6px; }
.learn-btn {
background: #2a3a4a;
border: 1px solid #4466aa;
border-radius: 4px;
color: #88aaff;
cursor: pointer;
font-size: 12px;
padding: 4px 10px;
}
.learn-btn.learning {
background: #3a2a2a;
border-color: #aa4444;
color: #ff8888;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.learn-status {
font-size: 11px;
color: #88ddaa;
padding: 3px 6px;
background: #1a2a1a;
border-radius: 3px;
}
.form-actions {
display: flex;
gap: 6px;
margin-top: 4px;
}
.save-btn {
background: #2a4a3a;
border: 1px solid #44aa66;
border-radius: 4px;
color: #88ffaa;
cursor: pointer;
font-size: 12px;
padding: 4px 14px;
}
.save-btn:disabled { opacity: 0.4; cursor: default; }
.save-btn:not(:disabled):hover { background: #335544; }
.cancel-btn {
background: none;
border: 1px solid #555;
border-radius: 4px;
color: #aaa;
cursor: pointer;
font-size: 12px;
padding: 4px 10px;
}
.cancel-btn:hover { background: #2a2a3a; }
</style>

View File

@@ -1,5 +1,5 @@
import { writable, derived } from 'svelte/store';
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef } from './types';
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef, MidiMapping } from './types';
import { subscribe, connectPorts, disconnectPorts } from './ws';
// Raw graph stores
@@ -338,6 +338,7 @@ export async function saveProfile(name: string) {
volumes,
mutes,
virtual_nodes: [...pb.virtual_nodes],
midi_mappings: [...(pb.profiles[name]?.midi_mappings ?? [])],
};
patchbay.update(pb => ({
@@ -399,6 +400,10 @@ export async function loadProfile(name: string) {
applyPatchbay(pb);
applyProfileVolumes(profile);
// Restore MIDI mappings for this profile
await saveMidiMappings(profile.midi_mappings ?? []);
savePatchbayState();
}
@@ -557,4 +562,52 @@ export async function setQuantum(quantum: number) {
}
}
// MIDI mapping API
export async function getMidiDevices(): Promise<{ id: number; name: string }[]> {
try {
const res = await fetch('/api/midi-devices');
return res.ok ? await res.json() : [];
} catch { return []; }
}
export async function saveMidiMappings(mappings: MidiMapping[]): Promise<void> {
// Also persist to the active profile
patchbay.update(pb => {
const prof = pb.active_profile ? pb.profiles[pb.active_profile] : null;
if (!prof) return pb;
return {
...pb,
profiles: {
...pb.profiles,
[pb.active_profile]: { ...prof, midi_mappings: mappings },
},
};
});
try {
await fetch('/api/midi-mappings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mappings),
});
} catch (e) {
console.error('[api] midi-mappings PUT failed:', e);
}
savePatchbayState();
}
export function getActiveMidiMappings(): MidiMapping[] {
const pb = get_store_value(patchbay);
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return [];
return pb.profiles[pb.active_profile].midi_mappings ?? [];
}
export async function startMidiLearn(): Promise<void> {
try { await fetch('/api/midi-learn/start', { method: 'POST' }); } catch {}
}
export async function stopMidiLearn(): Promise<void> {
try { await fetch('/api/midi-learn/stop', { method: 'POST' }); } catch {}
}
export { connectPorts, disconnectPorts };

View File

@@ -74,6 +74,17 @@ export interface ConnectionRule {
pinned?: boolean;
}
export interface MidiMapping {
device: string; // MIDI source node name (empty = any device)
channel: number; // 0-15; 255 = any channel
cc: number; // CC number 0-127 (or note number if is_note)
is_note: boolean; // true = note on/off controls mute
target_node: string; // PW node name
param: 'volume' | 'mute';
min: number; // CC 0 → this value (for volume)
max: number; // CC 127 → this value (for volume)
}
export interface PatchbayProfile {
name: string;
connections: ConnectionRule[];
@@ -82,6 +93,7 @@ export interface PatchbayProfile {
volumes?: Record<string, number>; // PW node name → volume (0..1)
mutes?: Record<string, boolean>; // PW node name → mute state
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
midi_mappings?: MidiMapping[]; // MIDI controller → node parameter bindings
}
export interface PatchbayState {

View File

@@ -1,9 +1,12 @@
import type { GraphMessage } from './types';
type GraphListener = (graph: GraphMessage) => void;
export type MidiLearnEvent = { device: string; channel: number; cc: number; is_note: boolean };
type MidiLearnListener = (ev: MidiLearnEvent) => void;
let es: EventSource | null = null;
let listeners: GraphListener[] = [];
let midiLearnListeners: MidiLearnListener[] = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function connect() {
@@ -30,6 +33,13 @@ function connect() {
}
};
es.addEventListener('midi_learn', (event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as MidiLearnEvent;
for (const fn of midiLearnListeners) fn(data);
} catch {}
});
es.onerror = () => {
console.log('[sse] disconnected, reconnecting in 2s...');
es?.close();
@@ -48,7 +58,7 @@ export function subscribe(fn: GraphListener): () => void {
connect();
return () => {
listeners = listeners.filter(l => l !== fn);
if (listeners.length === 0 && es) {
if (listeners.length === 0 && midiLearnListeners.length === 0 && es) {
es.close();
es = null;
if (reconnectTimer) {
@@ -59,6 +69,14 @@ export function subscribe(fn: GraphListener): () => void {
};
}
export function subscribeMidiLearn(fn: MidiLearnListener): () => void {
midiLearnListeners.push(fn);
connect();
return () => {
midiLearnListeners = midiLearnListeners.filter(l => l !== fn);
};
}
async function postCommand(endpoint: string, outputPortId: number, inputPortId: number) {
try {
await fetch(endpoint, {