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:
@@ -15,6 +15,7 @@ add_executable(pwweb
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/graph_engine.cpp
|
src/graph_engine.cpp
|
||||||
src/web_server.cpp
|
src/web_server.cpp
|
||||||
|
src/midi_mapper.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(pwweb PRIVATE
|
target_include_directories(pwweb PRIVATE
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
getQuantum, setQuantum,
|
getQuantum, setQuantum,
|
||||||
} from '../lib/stores';
|
} from '../lib/stores';
|
||||||
import type { Node, Port, Link } from '../lib/types';
|
import type { Node, Port, Link } from '../lib/types';
|
||||||
|
import MidiMappingPanel from './MidiMappingPanel.svelte';
|
||||||
|
|
||||||
// Viewport
|
// Viewport
|
||||||
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
|
let viewBox = $state({ x: -100, y: -40, w: 1200, h: 700 });
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
let showMergeDialog = $state(false);
|
let showMergeDialog = $state(false);
|
||||||
let showProfileDialog = $state(false);
|
let showProfileDialog = $state(false);
|
||||||
let showRuleDialog = $state(false);
|
let showRuleDialog = $state(false);
|
||||||
|
let showMidiPanel = $state(false);
|
||||||
let showVirtualMenu = $state(false);
|
let showVirtualMenu = $state(false);
|
||||||
let splitNodes = $state(false);
|
let splitNodes = $state(false);
|
||||||
let showNetworkDialog = $state<{ type: string } | null>(null);
|
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 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 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={() => { 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={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; showMidiPanel = 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={() => { 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>
|
<span class="sep"></span>
|
||||||
<label class="quantum-label">Buffer:
|
<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); } }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showMidiPanel}
|
||||||
|
<MidiMappingPanel onClose={() => { showMidiPanel = false; }} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
408
frontend/src/components/MidiMappingPanel.svelte
Normal file
408
frontend/src/components/MidiMappingPanel.svelte
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
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';
|
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||||
|
|
||||||
// Raw graph stores
|
// Raw graph stores
|
||||||
@@ -338,6 +338,7 @@ export async function saveProfile(name: string) {
|
|||||||
volumes,
|
volumes,
|
||||||
mutes,
|
mutes,
|
||||||
virtual_nodes: [...pb.virtual_nodes],
|
virtual_nodes: [...pb.virtual_nodes],
|
||||||
|
midi_mappings: [...(pb.profiles[name]?.midi_mappings ?? [])],
|
||||||
};
|
};
|
||||||
|
|
||||||
patchbay.update(pb => ({
|
patchbay.update(pb => ({
|
||||||
@@ -399,6 +400,10 @@ export async function loadProfile(name: string) {
|
|||||||
|
|
||||||
applyPatchbay(pb);
|
applyPatchbay(pb);
|
||||||
applyProfileVolumes(profile);
|
applyProfileVolumes(profile);
|
||||||
|
|
||||||
|
// Restore MIDI mappings for this profile
|
||||||
|
await saveMidiMappings(profile.midi_mappings ?? []);
|
||||||
|
|
||||||
savePatchbayState();
|
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 };
|
export { connectPorts, disconnectPorts };
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export interface ConnectionRule {
|
|||||||
pinned?: boolean;
|
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 {
|
export interface PatchbayProfile {
|
||||||
name: string;
|
name: string;
|
||||||
connections: ConnectionRule[];
|
connections: ConnectionRule[];
|
||||||
@@ -82,6 +93,7 @@ export interface PatchbayProfile {
|
|||||||
volumes?: Record<string, number>; // PW node name → volume (0..1)
|
volumes?: Record<string, number>; // PW node name → volume (0..1)
|
||||||
mutes?: Record<string, boolean>; // PW node name → mute state
|
mutes?: Record<string, boolean>; // PW node name → mute state
|
||||||
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
|
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
|
||||||
|
midi_mappings?: MidiMapping[]; // MIDI controller → node parameter bindings
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchbayState {
|
export interface PatchbayState {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { GraphMessage } from './types';
|
import type { GraphMessage } from './types';
|
||||||
|
|
||||||
type GraphListener = (graph: GraphMessage) => void;
|
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 es: EventSource | null = null;
|
||||||
let listeners: GraphListener[] = [];
|
let listeners: GraphListener[] = [];
|
||||||
|
let midiLearnListeners: MidiLearnListener[] = [];
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function connect() {
|
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 = () => {
|
es.onerror = () => {
|
||||||
console.log('[sse] disconnected, reconnecting in 2s...');
|
console.log('[sse] disconnected, reconnecting in 2s...');
|
||||||
es?.close();
|
es?.close();
|
||||||
@@ -48,7 +58,7 @@ export function subscribe(fn: GraphListener): () => void {
|
|||||||
connect();
|
connect();
|
||||||
return () => {
|
return () => {
|
||||||
listeners = listeners.filter(l => l !== fn);
|
listeners = listeners.filter(l => l !== fn);
|
||||||
if (listeners.length === 0 && es) {
|
if (listeners.length === 0 && midiLearnListeners.length === 0 && es) {
|
||||||
es.close();
|
es.close();
|
||||||
es = null;
|
es = null;
|
||||||
if (reconnectTimer) {
|
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) {
|
async function postCommand(endpoint: string, outputPortId: number, inputPortId: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ GraphEngine::Object::~Object() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
GraphEngine::GraphEngine()
|
GraphEngine::GraphEngine()
|
||||||
: m_on_change(nullptr), m_running(false)
|
: m_running(false)
|
||||||
{
|
{
|
||||||
m_audio_type = hashType(DEFAULT_AUDIO_TYPE);
|
m_audio_type = hashType(DEFAULT_AUDIO_TYPE);
|
||||||
m_midi_type = hashType(DEFAULT_MIDI_TYPE);
|
m_midi_type = hashType(DEFAULT_MIDI_TYPE);
|
||||||
@@ -733,17 +733,23 @@ void GraphEngine::close() {
|
|||||||
|
|
||||||
void GraphEngine::setOnChange(ChangeCallback cb) {
|
void GraphEngine::setOnChange(ChangeCallback cb) {
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
m_on_change = std::move(cb);
|
m_on_change_cbs.clear();
|
||||||
|
if (cb) m_on_change_cbs.push_back(std::move(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
void GraphEngine::addOnChange(ChangeCallback cb) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
if (cb) m_on_change_cbs.push_back(std::move(cb));
|
||||||
}
|
}
|
||||||
|
|
||||||
void GraphEngine::notifyChanged() {
|
void GraphEngine::notifyChanged() {
|
||||||
// Called from PipeWire thread — invoke callback outside lock
|
// Called from PipeWire thread — copy callback list then invoke outside lock
|
||||||
ChangeCallback cb;
|
std::vector<ChangeCallback> cbs;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
cb = m_on_change;
|
cbs = m_on_change_cbs;
|
||||||
}
|
}
|
||||||
if (cb) cb();
|
for (auto &cb : cbs) cb();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <thread>
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
#include <pipewire/utils.h> // pw_thread_loop, etc.
|
#include <pipewire/utils.h> // pw_thread_loop, etc.
|
||||||
#include <spa/utils/list.h> // spa_list
|
#include <spa/utils/list.h> // spa_list
|
||||||
@@ -29,8 +29,9 @@ public:
|
|||||||
bool open();
|
bool open();
|
||||||
void close();
|
void close();
|
||||||
|
|
||||||
// Set callback invoked when graph changes
|
// Set/add callbacks invoked when graph changes
|
||||||
void setOnChange(ChangeCallback cb);
|
void setOnChange(ChangeCallback cb);
|
||||||
|
void addOnChange(ChangeCallback cb);
|
||||||
|
|
||||||
// Thread-safe snapshot of the current graph state
|
// Thread-safe snapshot of the current graph state
|
||||||
struct Snapshot {
|
struct Snapshot {
|
||||||
@@ -120,7 +121,7 @@ private:
|
|||||||
std::unordered_map<uint32_t, Object*> m_objects_by_id;
|
std::unordered_map<uint32_t, Object*> m_objects_by_id;
|
||||||
std::vector<Object*> m_objects;
|
std::vector<Object*> m_objects;
|
||||||
|
|
||||||
ChangeCallback m_on_change;
|
std::vector<ChangeCallback> m_on_change_cbs;
|
||||||
std::atomic<bool> m_running;
|
std::atomic<bool> m_running;
|
||||||
|
|
||||||
// Port type hashes
|
// Port type hashes
|
||||||
|
|||||||
336
src/midi_mapper.cpp
Normal file
336
src/midi_mapper.cpp
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
#include "midi_mapper.h"
|
||||||
|
|
||||||
|
#include <pipewire/pipewire.h>
|
||||||
|
#include <spa/pod/builder.h>
|
||||||
|
#include <spa/pod/iter.h>
|
||||||
|
#include <spa/pod/pod.h>
|
||||||
|
#include <spa/control/control.h>
|
||||||
|
#include <spa/param/format.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
using namespace pwgraph;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PipeWire stream callbacks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
static void on_midi_process(void *userdata) {
|
||||||
|
auto *ms = static_cast<MidiMapper::MidiStream *>(userdata);
|
||||||
|
if (!ms || !ms->stream || !ms->mapper) return;
|
||||||
|
|
||||||
|
struct pw_buffer *pw_buf = pw_stream_dequeue_buffer(ms->stream);
|
||||||
|
if (!pw_buf) return;
|
||||||
|
|
||||||
|
struct spa_buffer *buf = pw_buf->buffer;
|
||||||
|
if (!buf || buf->n_datas == 0 || !buf->datas[0].data) {
|
||||||
|
pw_stream_queue_buffer(ms->stream, pw_buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct spa_data *d = &buf->datas[0];
|
||||||
|
uint32_t offset = d->chunk ? d->chunk->offset : 0;
|
||||||
|
uint32_t size = d->chunk ? d->chunk->size : 0;
|
||||||
|
if (size == 0) {
|
||||||
|
pw_stream_queue_buffer(ms->stream, pw_buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void *data = static_cast<uint8_t *>(d->data) + offset;
|
||||||
|
auto *pod = static_cast<struct spa_pod *>(data);
|
||||||
|
|
||||||
|
if (spa_pod_is_sequence(pod)) {
|
||||||
|
struct spa_pod_control *c;
|
||||||
|
SPA_POD_SEQUENCE_FOREACH(reinterpret_cast<struct spa_pod_sequence *>(pod), c) {
|
||||||
|
if (c->type != SPA_CONTROL_Midi) continue;
|
||||||
|
|
||||||
|
auto *midi = static_cast<uint8_t *>(SPA_POD_BODY(&c->value));
|
||||||
|
uint32_t msize = SPA_POD_BODY_SIZE(&c->value);
|
||||||
|
if (msize < 2) continue;
|
||||||
|
|
||||||
|
uint8_t status = midi[0] & 0xF0u;
|
||||||
|
uint8_t channel = midi[0] & 0x0Fu;
|
||||||
|
uint8_t data1 = midi[1];
|
||||||
|
uint8_t data2 = msize >= 3 ? midi[2] : 0;
|
||||||
|
|
||||||
|
// Only pass CC (0xB0) and Note On/Off (0x90/0x80)
|
||||||
|
if (status != 0x90u && status != 0x80u && status != 0xB0u) continue;
|
||||||
|
|
||||||
|
ms->mapper->pushEvent(ms->node_name, channel, status, data1, data2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_stream_queue_buffer(ms->stream, pw_buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const struct pw_stream_events s_midi_stream_events = {
|
||||||
|
PW_VERSION_STREAM_EVENTS,
|
||||||
|
.process = on_midi_process,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MidiMapper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
MidiMapper::MidiMapper(GraphEngine &engine, BroadcastFn broadcast_fn)
|
||||||
|
: m_engine(engine), m_broadcast_fn(std::move(broadcast_fn))
|
||||||
|
{
|
||||||
|
m_worker_thread = std::thread([this]() { workerLoop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
MidiMapper::~MidiMapper() {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_queue_mutex);
|
||||||
|
m_worker_stop = true;
|
||||||
|
m_queue_cv.notify_all();
|
||||||
|
}
|
||||||
|
if (m_worker_thread.joinable())
|
||||||
|
m_worker_thread.join();
|
||||||
|
|
||||||
|
destroyAllStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::setMappings(std::vector<MidiMapping> mappings) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mappings_mutex);
|
||||||
|
m_mappings = std::move(mappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<MidiMapping> MidiMapper::getMappings() const {
|
||||||
|
std::lock_guard<std::mutex> lock(m_mappings_mutex);
|
||||||
|
return m_mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::startLearn() {
|
||||||
|
m_learning.store(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::stopLearn() {
|
||||||
|
m_learning.store(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stream management (must be called from non-PW thread)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void MidiMapper::refresh() {
|
||||||
|
auto snap = m_engine.snapshot();
|
||||||
|
auto &pw = m_engine.pwData();
|
||||||
|
|
||||||
|
// Identify MIDI source nodes (Output or Duplex mode, Midi type)
|
||||||
|
std::vector<std::pair<uint32_t, std::string>> midi_sources;
|
||||||
|
for (auto &n : snap.nodes) {
|
||||||
|
if (!n.ready) continue;
|
||||||
|
bool is_midi = (n.node_type & NodeType::Midi) != NodeType::None;
|
||||||
|
bool is_source = (n.mode & PortMode::Output) != PortMode::None;
|
||||||
|
if (is_midi && is_source)
|
||||||
|
midi_sources.emplace_back(n.id, n.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_lock(pw.loop);
|
||||||
|
|
||||||
|
// Create streams for new nodes
|
||||||
|
for (auto &[id, name] : midi_sources) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_streams_mutex);
|
||||||
|
if (m_streams.find(id) == m_streams.end())
|
||||||
|
createStream(id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy streams for nodes no longer present
|
||||||
|
std::vector<uint32_t> to_remove;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_streams_mutex);
|
||||||
|
for (auto &[id, _] : m_streams) {
|
||||||
|
bool found = false;
|
||||||
|
for (auto &[sid, _2] : midi_sources) {
|
||||||
|
if (sid == id) { found = true; break; }
|
||||||
|
}
|
||||||
|
if (!found) to_remove.push_back(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uint32_t id : to_remove)
|
||||||
|
destroyStream(id);
|
||||||
|
|
||||||
|
pw_thread_loop_unlock(pw.loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::createStream(uint32_t node_id, const std::string &name) {
|
||||||
|
// Called with PW loop locked
|
||||||
|
auto &pw = m_engine.pwData();
|
||||||
|
|
||||||
|
auto *ms = new MidiStream();
|
||||||
|
ms->node_id = node_id;
|
||||||
|
ms->node_name = name;
|
||||||
|
ms->mapper = this;
|
||||||
|
spa_zero(ms->listener);
|
||||||
|
|
||||||
|
struct pw_properties *props = pw_properties_new(
|
||||||
|
PW_KEY_MEDIA_TYPE, "Midi",
|
||||||
|
PW_KEY_MEDIA_CATEGORY, "Capture",
|
||||||
|
PW_KEY_APP_NAME, "pwweb",
|
||||||
|
PW_KEY_NODE_NAME, "pwweb-midi-in",
|
||||||
|
PW_KEY_TARGET_OBJECT, name.c_str(),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
ms->stream = pw_stream_new(pw.core, "pwweb-midi-in", props);
|
||||||
|
pw_properties_free(props);
|
||||||
|
if (!ms->stream) {
|
||||||
|
delete ms;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_stream_add_listener(ms->stream, &ms->listener,
|
||||||
|
&s_midi_stream_events, ms);
|
||||||
|
|
||||||
|
uint8_t buf[256];
|
||||||
|
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
||||||
|
const struct spa_pod *params[1];
|
||||||
|
params[0] = static_cast<const struct spa_pod *>(spa_pod_builder_add_object(&b,
|
||||||
|
SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
|
||||||
|
SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_application),
|
||||||
|
SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_control)));
|
||||||
|
|
||||||
|
pw_stream_connect(ms->stream,
|
||||||
|
PW_DIRECTION_INPUT,
|
||||||
|
PW_ID_ANY,
|
||||||
|
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT |
|
||||||
|
PW_STREAM_FLAG_MAP_BUFFERS),
|
||||||
|
params, 1);
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_streams_mutex);
|
||||||
|
m_streams[node_id] = ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(stderr, "pwweb: MIDI stream created for node %u (%s)\n", node_id, name.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::destroyStream(uint32_t node_id) {
|
||||||
|
// Called with PW loop locked
|
||||||
|
MidiStream *ms = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_streams_mutex);
|
||||||
|
auto it = m_streams.find(node_id);
|
||||||
|
if (it == m_streams.end()) return;
|
||||||
|
ms = it->second;
|
||||||
|
m_streams.erase(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ms->stream) {
|
||||||
|
spa_hook_remove(&ms->listener);
|
||||||
|
pw_stream_destroy(ms->stream);
|
||||||
|
ms->stream = nullptr;
|
||||||
|
}
|
||||||
|
delete ms;
|
||||||
|
fprintf(stderr, "pwweb: MIDI stream destroyed for node %u\n", node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::destroyAllStreams() {
|
||||||
|
auto &pw = m_engine.pwData();
|
||||||
|
if (!pw.loop) return;
|
||||||
|
|
||||||
|
pw_thread_loop_lock(pw.loop);
|
||||||
|
|
||||||
|
std::vector<uint32_t> ids;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_streams_mutex);
|
||||||
|
for (auto &[id, _] : m_streams)
|
||||||
|
ids.push_back(id);
|
||||||
|
}
|
||||||
|
for (uint32_t id : ids)
|
||||||
|
destroyStream(id);
|
||||||
|
|
||||||
|
pw_thread_loop_unlock(pw.loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event queue (called from PW process callback — no locks beyond mutex)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void MidiMapper::pushEvent(const std::string &device, uint8_t channel,
|
||||||
|
uint8_t status, uint8_t data1, uint8_t data2) {
|
||||||
|
std::lock_guard<std::mutex> lock(m_queue_mutex);
|
||||||
|
m_event_queue.push({device, channel, status, data1, data2});
|
||||||
|
m_queue_cv.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Worker thread
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void MidiMapper::workerLoop() {
|
||||||
|
while (true) {
|
||||||
|
MidiEvent ev;
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(m_queue_mutex);
|
||||||
|
m_queue_cv.wait(lock, [this] {
|
||||||
|
return !m_event_queue.empty() || m_worker_stop;
|
||||||
|
});
|
||||||
|
if (m_worker_stop && m_event_queue.empty()) break;
|
||||||
|
ev = m_event_queue.front();
|
||||||
|
m_event_queue.pop();
|
||||||
|
}
|
||||||
|
handleEvent(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MidiMapper::handleEvent(const MidiEvent &ev) {
|
||||||
|
bool is_note = (ev.status == 0x90u || ev.status == 0x80u);
|
||||||
|
bool is_cc = (ev.status == 0xB0u);
|
||||||
|
|
||||||
|
// Learn mode: broadcast the first event and stop learning
|
||||||
|
if (m_learning.exchange(false)) {
|
||||||
|
if (m_broadcast_fn)
|
||||||
|
m_broadcast_fn(ev.device, ev.channel, ev.data1, is_note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mappings
|
||||||
|
std::vector<MidiMapping> mappings;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mappings_mutex);
|
||||||
|
mappings = m_mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto &m : mappings) {
|
||||||
|
// Check device
|
||||||
|
if (!m.device.empty() && m.device != ev.device) continue;
|
||||||
|
// Check channel
|
||||||
|
if (m.channel != 0xFFu && m.channel != ev.channel) continue;
|
||||||
|
|
||||||
|
if (m.param == "volume" && is_cc && m.cc == ev.data1) {
|
||||||
|
// Scale CC value (0-127) to volume range
|
||||||
|
float t = ev.data2 / 127.0f;
|
||||||
|
float vol = m.min_val + t * (m.max_val - m.min_val);
|
||||||
|
if (vol < 0.0f) vol = 0.0f;
|
||||||
|
if (vol > 1.5f) vol = 1.5f;
|
||||||
|
|
||||||
|
// Find target node ID and apply
|
||||||
|
auto snap = m_engine.snapshot();
|
||||||
|
for (auto &n : snap.nodes) {
|
||||||
|
if (n.name == m.target_node) {
|
||||||
|
m_engine.setNodeVolume(n.id, vol);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (m.param == "mute") {
|
||||||
|
bool trigger = false;
|
||||||
|
if (is_cc && m.cc == ev.data1) {
|
||||||
|
// Toggle on CC value > 63 (or any non-zero for simple toggle)
|
||||||
|
trigger = (ev.data2 > 0);
|
||||||
|
} else if (is_note && m.cc == ev.data1 && ev.status == 0x90u && ev.data2 > 0) {
|
||||||
|
trigger = true;
|
||||||
|
}
|
||||||
|
if (!trigger) continue;
|
||||||
|
|
||||||
|
auto snap = m_engine.snapshot();
|
||||||
|
for (auto &n : snap.nodes) {
|
||||||
|
if (n.name == m.target_node) {
|
||||||
|
m_engine.setNodeMute(n.id, !n.mute);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/midi_mapper.h
Normal file
96
src/midi_mapper.h
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "graph_engine.h"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <atomic>
|
||||||
|
#include <thread>
|
||||||
|
#include <queue>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
struct pw_stream;
|
||||||
|
struct spa_hook;
|
||||||
|
|
||||||
|
namespace pwgraph {
|
||||||
|
|
||||||
|
struct MidiMapping {
|
||||||
|
std::string device; // MIDI source node name (empty = any device)
|
||||||
|
uint8_t channel; // 0-15; 0xFF = any channel
|
||||||
|
uint8_t cc; // CC number 0-127
|
||||||
|
bool is_note; // true = note on/off controls mute toggle
|
||||||
|
std::string target_node; // PW node name
|
||||||
|
std::string param; // "volume" or "mute"
|
||||||
|
float min_val; // CC 0 → this value (default 0.0)
|
||||||
|
float max_val; // CC 127 → this value (default 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
class MidiMapper {
|
||||||
|
public:
|
||||||
|
// Called when a MIDI learn event is captured
|
||||||
|
using BroadcastFn = std::function<void(
|
||||||
|
const std::string &device, uint8_t channel, uint8_t cc, bool is_note)>;
|
||||||
|
|
||||||
|
MidiMapper(GraphEngine &engine, BroadcastFn broadcast_fn);
|
||||||
|
~MidiMapper();
|
||||||
|
|
||||||
|
void setMappings(std::vector<MidiMapping> mappings);
|
||||||
|
std::vector<MidiMapping> getMappings() const;
|
||||||
|
|
||||||
|
// Enter/exit learn mode
|
||||||
|
void startLearn();
|
||||||
|
void stopLearn();
|
||||||
|
bool isLearning() const { return m_learning.load(); }
|
||||||
|
|
||||||
|
// Re-sync MIDI streams with current graph nodes.
|
||||||
|
// Must be called from a non-PW thread (it acquires the PW loop lock).
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
// Internal: called from pw process callback (non-blocking)
|
||||||
|
struct MidiStream {
|
||||||
|
uint32_t node_id = 0;
|
||||||
|
std::string node_name;
|
||||||
|
pw_stream *stream = nullptr;
|
||||||
|
spa_hook listener;
|
||||||
|
MidiMapper *mapper = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
void pushEvent(const std::string &device, uint8_t channel,
|
||||||
|
uint8_t status, uint8_t data1, uint8_t data2);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct MidiEvent {
|
||||||
|
std::string device;
|
||||||
|
uint8_t channel, status, data1, data2;
|
||||||
|
};
|
||||||
|
|
||||||
|
GraphEngine &m_engine;
|
||||||
|
BroadcastFn m_broadcast_fn;
|
||||||
|
|
||||||
|
mutable std::mutex m_mappings_mutex;
|
||||||
|
std::vector<MidiMapping> m_mappings;
|
||||||
|
|
||||||
|
mutable std::mutex m_streams_mutex;
|
||||||
|
std::unordered_map<uint32_t, MidiStream *> m_streams; // keyed by PW node ID
|
||||||
|
|
||||||
|
std::atomic<bool> m_learning{false};
|
||||||
|
|
||||||
|
// Worker thread
|
||||||
|
std::mutex m_queue_mutex;
|
||||||
|
std::condition_variable m_queue_cv;
|
||||||
|
std::queue<MidiEvent> m_event_queue;
|
||||||
|
bool m_worker_stop = false;
|
||||||
|
std::thread m_worker_thread;
|
||||||
|
|
||||||
|
void workerLoop();
|
||||||
|
void handleEvent(const MidiEvent &ev);
|
||||||
|
|
||||||
|
void createStream(uint32_t node_id, const std::string &name);
|
||||||
|
void destroyStream(uint32_t node_id); // must be called with PW loop locked
|
||||||
|
void destroyAllStreams();
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pwgraph
|
||||||
@@ -174,7 +174,15 @@ std::string WebServer::buildGraphJson() const {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
WebServer::WebServer(GraphEngine &engine, int port)
|
WebServer::WebServer(GraphEngine &engine, int port)
|
||||||
: m_engine(engine), m_port(port), m_running(false)
|
: m_engine(engine),
|
||||||
|
m_midi_mapper(engine, [this](const std::string &dev, uint8_t ch, uint8_t cc, bool is_note) {
|
||||||
|
char buf[512];
|
||||||
|
snprintf(buf, sizeof(buf),
|
||||||
|
"{\"device\":\"%s\",\"channel\":%d,\"cc\":%d,\"is_note\":%s}",
|
||||||
|
dev.c_str(), (int)ch, (int)cc, is_note ? "true" : "false");
|
||||||
|
broadcastSse("midi_learn", buf);
|
||||||
|
}),
|
||||||
|
m_port(port), m_running(false)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +238,17 @@ void WebServer::broadcastGraph() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WebServer::broadcastSse(const std::string &event, const std::string &data) {
|
||||||
|
if (!m_running) return;
|
||||||
|
std::string msg = "event: " + event + "\ndata: " + data + "\n\n";
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||||
|
for (auto it = m_sse_clients.begin(); it != m_sse_clients.end(); ) {
|
||||||
|
if ((*it)->write(msg.c_str(), msg.size())) ++it;
|
||||||
|
else it = m_sse_clients.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void WebServer::setupRoutes() {
|
void WebServer::setupRoutes() {
|
||||||
// Serve frontend static files from ./frontend/dist
|
// Serve frontend static files from ./frontend/dist
|
||||||
m_http.set_mount_point("/", "./frontend/dist");
|
m_http.set_mount_point("/", "./frontend/dist");
|
||||||
@@ -607,6 +626,137 @@ void WebServer::setupRoutes() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
m_http.Options("/api/quantum", cors_handler);
|
m_http.Options("/api/quantum", cors_handler);
|
||||||
|
m_http.Options("/api/midi-mappings", cors_handler);
|
||||||
|
m_http.Options("/api/midi-learn/start", cors_handler);
|
||||||
|
m_http.Options("/api/midi-learn/stop", cors_handler);
|
||||||
|
|
||||||
|
// MIDI devices: GET /api/midi-devices
|
||||||
|
m_http.Get("/api/midi-devices", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
auto snap = m_engine.snapshot();
|
||||||
|
std::ostringstream json;
|
||||||
|
json << "[";
|
||||||
|
bool first = true;
|
||||||
|
for (auto &n : snap.nodes) {
|
||||||
|
if (!n.ready) continue;
|
||||||
|
bool is_midi = ((uint8_t)n.node_type & (uint8_t)NodeType::Midi) != 0;
|
||||||
|
bool is_source = ((uint8_t)n.mode & (uint8_t)PortMode::Output) != 0;
|
||||||
|
if (!is_midi || !is_source) continue;
|
||||||
|
if (!first) json << ",";
|
||||||
|
first = false;
|
||||||
|
json << "{\"id\":" << n.id << ",\"name\":\"" << escapeJson(n.name) << "\"}";
|
||||||
|
}
|
||||||
|
json << "]";
|
||||||
|
res.set_content(json.str(), "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// MIDI mappings: GET /api/midi-mappings
|
||||||
|
m_http.Get("/api/midi-mappings", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
std::string path = dataDir() + "/midi-mappings.json";
|
||||||
|
res.set_content(readFile(path), "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// MIDI mappings: PUT /api/midi-mappings (body = full profile patchbay.json mappings blob)
|
||||||
|
m_http.Put("/api/midi-mappings", [this](const httplib::Request &req, httplib::Response &res) {
|
||||||
|
std::string path = dataDir() + "/midi-mappings.json";
|
||||||
|
writeFile(path, req.body);
|
||||||
|
|
||||||
|
// Parse and apply mappings
|
||||||
|
// Simple JSON parser: array of {device, channel, cc, is_note, target_node, param, min, max}
|
||||||
|
std::vector<MidiMapping> mappings;
|
||||||
|
const std::string &body = req.body;
|
||||||
|
size_t pos = 0;
|
||||||
|
auto extractStr = [&](const std::string &key, size_t from) -> std::string {
|
||||||
|
size_t p = body.find("\"" + key + "\"", from);
|
||||||
|
if (p == std::string::npos || p > from + 300) return "";
|
||||||
|
size_t s = body.find('"', p + key.size() + 2);
|
||||||
|
if (s == std::string::npos) return "";
|
||||||
|
size_t e = body.find('"', s + 1);
|
||||||
|
return e != std::string::npos ? body.substr(s + 1, e - s - 1) : "";
|
||||||
|
};
|
||||||
|
auto extractNum = [&](const std::string &key, size_t from) -> int {
|
||||||
|
size_t p = body.find("\"" + key + "\"", from);
|
||||||
|
if (p == std::string::npos || p > from + 300) return -1;
|
||||||
|
size_t s = body.find(':', p + key.size() + 2);
|
||||||
|
if (s == std::string::npos) return -1;
|
||||||
|
s++;
|
||||||
|
while (s < body.size() && body[s] == ' ') s++;
|
||||||
|
int v = 0;
|
||||||
|
bool neg = false;
|
||||||
|
if (s < body.size() && body[s] == '-') { neg = true; s++; }
|
||||||
|
while (s < body.size() && isdigit((unsigned char)body[s]))
|
||||||
|
v = v * 10 + (body[s++] - '0');
|
||||||
|
return neg ? -v : v;
|
||||||
|
};
|
||||||
|
auto extractFloat = [&](const std::string &key, size_t from) -> float {
|
||||||
|
size_t p = body.find("\"" + key + "\"", from);
|
||||||
|
if (p == std::string::npos || p > from + 300) return -1.0f;
|
||||||
|
size_t s = body.find(':', p + key.size() + 2);
|
||||||
|
if (s == std::string::npos) return -1.0f;
|
||||||
|
s++;
|
||||||
|
while (s < body.size() && body[s] == ' ') s++;
|
||||||
|
return strtof(body.c_str() + s, nullptr);
|
||||||
|
};
|
||||||
|
auto extractBool = [&](const std::string &key, size_t from) -> bool {
|
||||||
|
size_t p = body.find("\"" + key + "\"", from);
|
||||||
|
if (p == std::string::npos || p > from + 300) return false;
|
||||||
|
size_t s = body.find(':', p + key.size() + 2);
|
||||||
|
if (s == std::string::npos) return false;
|
||||||
|
s++;
|
||||||
|
while (s < body.size() && body[s] == ' ') s++;
|
||||||
|
return body.compare(s, 4, "true") == 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
pos = body.find('{', pos);
|
||||||
|
if (pos == std::string::npos) break;
|
||||||
|
size_t end = body.find('}', pos);
|
||||||
|
if (end == std::string::npos) break;
|
||||||
|
|
||||||
|
std::string target = extractStr("target_node", pos);
|
||||||
|
std::string param = extractStr("param", pos);
|
||||||
|
if (target.empty() || param.empty()) { pos = end + 1; continue; }
|
||||||
|
|
||||||
|
MidiMapping mm;
|
||||||
|
mm.device = extractStr("device", pos);
|
||||||
|
int ch = extractNum("channel", pos);
|
||||||
|
mm.channel = (ch < 0) ? 0xFFu : (uint8_t)ch;
|
||||||
|
int cc = extractNum("cc", pos);
|
||||||
|
mm.cc = (cc < 0) ? 0u : (uint8_t)cc;
|
||||||
|
mm.is_note = extractBool("is_note", pos);
|
||||||
|
mm.target_node = target;
|
||||||
|
mm.param = param;
|
||||||
|
float mn = extractFloat("min", pos);
|
||||||
|
float mx = extractFloat("max", pos);
|
||||||
|
mm.min_val = (mn < 0.0f) ? 0.0f : mn;
|
||||||
|
mm.max_val = (mx < 0.0f) ? 1.0f : mx;
|
||||||
|
mappings.push_back(mm);
|
||||||
|
|
||||||
|
pos = end + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_midi_mapper.setMappings(mappings);
|
||||||
|
m_midi_mapper.refresh(); // sync streams with current MIDI nodes
|
||||||
|
|
||||||
|
res.set_content("{\"ok\":true}", "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// MIDI learn: POST /api/midi-learn/start
|
||||||
|
m_http.Post("/api/midi-learn/start", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
m_midi_mapper.refresh(); // ensure streams are up to date
|
||||||
|
m_midi_mapper.startLearn();
|
||||||
|
res.set_content("{\"ok\":true}", "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// MIDI learn: POST /api/midi-learn/stop
|
||||||
|
m_http.Post("/api/midi-learn/stop", [this](const httplib::Request &, httplib::Response &res) {
|
||||||
|
m_midi_mapper.stopLearn();
|
||||||
|
res.set_content("{\"ok\":true}", "application/json");
|
||||||
|
res.set_header("Access-Control-Allow-Origin", "*");
|
||||||
|
});
|
||||||
|
|
||||||
// Get current quantum: GET /api/quantum
|
// Get current quantum: GET /api/quantum
|
||||||
m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) {
|
m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "graph_engine.h"
|
#include "graph_engine.h"
|
||||||
|
#include "midi_mapper.h"
|
||||||
#include <httplib.h>
|
#include <httplib.h>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -18,6 +19,7 @@ public:
|
|||||||
bool start();
|
bool start();
|
||||||
void stop();
|
void stop();
|
||||||
void broadcastGraph();
|
void broadcastGraph();
|
||||||
|
void broadcastSse(const std::string &event, const std::string &data);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setupRoutes();
|
void setupRoutes();
|
||||||
@@ -27,6 +29,7 @@ private:
|
|||||||
void writeFile(const std::string &path, const std::string &data) const;
|
void writeFile(const std::string &path, const std::string &data) const;
|
||||||
|
|
||||||
GraphEngine &m_engine;
|
GraphEngine &m_engine;
|
||||||
|
MidiMapper m_midi_mapper;
|
||||||
int m_port;
|
int m_port;
|
||||||
httplib::Server m_http;
|
httplib::Server m_http;
|
||||||
std::thread m_thread;
|
std::thread m_thread;
|
||||||
|
|||||||
Reference in New Issue
Block a user