Compare commits
21 Commits
feature/co
...
feature/mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac35c13630 | ||
|
|
9231c10429 | ||
|
|
8b7ad6e9a8 | ||
|
|
6fe6d05aad | ||
|
|
0c2c45fb5d | ||
|
|
da92a53c73 | ||
|
|
a770e2efe2 | ||
|
|
cbc5083490 | ||
|
|
b6d6ad970a | ||
|
|
68307556e9 | ||
|
|
58d1972d19 | ||
|
|
db48781221 | ||
|
|
7c4bf999bc | ||
|
|
31d8191672 | ||
|
|
fc1e0e7798 | ||
|
|
a58df9cdaa | ||
|
|
503d69bf59 | ||
|
|
cb87cd34ba | ||
|
|
a40e7b24e5 | ||
|
|
0d3cfb5f86 | ||
|
|
b3c81623f1 |
@@ -15,6 +15,7 @@ add_executable(pwweb
|
||||
src/main.cpp
|
||||
src/graph_engine.cpp
|
||||
src/web_server.cpp
|
||||
src/midi_mapper.cpp
|
||||
)
|
||||
|
||||
target_include_directories(pwweb PRIVATE
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
setAutoPin, setAutoDisconnect,
|
||||
saveProfile, loadProfile, deleteProfile,
|
||||
setNodeVolume, setNodeMute,
|
||||
setAlias, removeVirtualNode,
|
||||
createNullSink, createLoopback, loadModule,
|
||||
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 });
|
||||
@@ -29,6 +31,8 @@
|
||||
let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null);
|
||||
let nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);
|
||||
let showPropsDialog = $state<number | null>(null); // node ID or null
|
||||
let renameDialog = $state<{ pwName: string } | null>(null);
|
||||
let renameInput = $state('');
|
||||
|
||||
// Filters
|
||||
let showAudio = $state(true);
|
||||
@@ -41,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);
|
||||
@@ -110,12 +115,15 @@
|
||||
return pt.matrixTransform(ctm.inverse());
|
||||
}
|
||||
|
||||
function isNodeHidden(nodeName: string): boolean {
|
||||
function isNodeHidden(nd: { name: string; nick: string }): boolean {
|
||||
const dn = displayName(nd);
|
||||
for (const rule of $patchbay.hide_rules) {
|
||||
try {
|
||||
if (new RegExp(rule, 'i').test(nodeName)) return true;
|
||||
// Anchor to full match unless user already added anchors
|
||||
const anchored = (rule.startsWith('^') || rule.endsWith('$')) ? rule : `^${rule}$`;
|
||||
if (new RegExp(anchored, 'i').test(dn)) return true;
|
||||
} catch {
|
||||
if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true;
|
||||
if (dn.toLowerCase() === rule.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -128,6 +136,11 @@
|
||||
return nodeName;
|
||||
}
|
||||
|
||||
// Return custom alias, otherwise nick, otherwise PW name
|
||||
function displayName(nd: { name: string; nick: string }): string {
|
||||
return $patchbay.aliases?.[nd.name] || nd.nick || nd.name;
|
||||
}
|
||||
|
||||
// Build computed layout
|
||||
let graphNodes = $derived.by(() => {
|
||||
const n = $nodes;
|
||||
@@ -137,7 +150,7 @@
|
||||
for (const port of p) portMap.set(port.id, port);
|
||||
|
||||
// Filter hidden nodes
|
||||
let visible = n.filter(nd => !isNodeHidden(nd.name));
|
||||
let visible = n.filter(nd => !isNodeHidden(nd));
|
||||
|
||||
// Merge nodes by prefix (unless split mode)
|
||||
if (!splitNodes) {
|
||||
@@ -215,8 +228,8 @@
|
||||
// Check if both endpoint nodes are visible
|
||||
const outNode = $nodes.find(n => n.id === outPort.node_id);
|
||||
const inNode = $nodes.find(n => n.id === inPort.node_id);
|
||||
if (outNode && isNodeHidden(outNode.name)) return null;
|
||||
if (inNode && isNodeHidden(inNode.name)) return null;
|
||||
if (outNode && isNodeHidden(outNode)) return null;
|
||||
if (inNode && isNodeHidden(inNode)) return null;
|
||||
const pinned = pb.pinned_connections.includes(link.id);
|
||||
return { ...link, outPort, inPort, pinned };
|
||||
}).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>;
|
||||
@@ -470,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); } }}>
|
||||
@@ -593,7 +607,7 @@
|
||||
<rect x={nd.x} y={nd.y} width={nd.width} height="22" rx="4" fill={headerBg} />
|
||||
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" fill={headerBg} />
|
||||
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
|
||||
{nd.nick || nd.name}
|
||||
{displayName(nd)}
|
||||
</text>
|
||||
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
|
||||
[{nd.node_type}]
|
||||
@@ -680,9 +694,20 @@
|
||||
|
||||
{#if nodeContextMenu}
|
||||
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
|
||||
<div class="ctx-title">{nodeContextMenu.nodeName}</div>
|
||||
<div class="ctx-title">{$patchbay.aliases?.[nodeContextMenu.nodeName] || nodeContextMenu.nodeName}</div>
|
||||
<button onclick={() => { showPropsDialog = nodeContextMenu!.nodeId; nodeContextMenu = null; }}>Properties</button>
|
||||
<button onclick={() => {
|
||||
renameDialog = { pwName: nodeContextMenu!.nodeName };
|
||||
renameInput = $patchbay.aliases?.[nodeContextMenu!.nodeName] ?? '';
|
||||
nodeContextMenu = null;
|
||||
}}>Rename</button>
|
||||
<button onclick={() => {
|
||||
const nd = $nodes.find(n => n.id === nodeContextMenu!.nodeId);
|
||||
if (nd) addHideRule(displayName(nd));
|
||||
nodeContextMenu = null;
|
||||
}}>Hide</button>
|
||||
<button onclick={() => {
|
||||
removeVirtualNode(nodeContextMenu!.nodeName);
|
||||
fetch('/api/destroy-node', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -699,7 +724,7 @@
|
||||
{#if nd}
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<span>Properties: {nd.nick || nd.name}</span>
|
||||
<span>Properties: {displayName(nd)}</span>
|
||||
<button class="close" onclick={() => { showPropsDialog = null; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
@@ -730,6 +755,31 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Rename Node Dialog -->
|
||||
{#if renameDialog}
|
||||
<div class="dialog" style="right:auto;left:50%;top:50%;transform:translate(-50%,-50%);width:280px">
|
||||
<div class="dialog-header">
|
||||
<span>Rename Node</span>
|
||||
<button class="close" onclick={() => { renameDialog = null; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="hint" style="word-break:break-all">{renameDialog.pwName}</p>
|
||||
<div class="input-row">
|
||||
<input
|
||||
class="dlg-input"
|
||||
bind:value={renameInput}
|
||||
placeholder="Custom display name (leave blank to reset)"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { setAlias(renameDialog!.pwName, renameInput); renameDialog = null; } }}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-row" style="justify-content:flex-end;gap:6px">
|
||||
<button onclick={() => { setAlias(renameDialog!.pwName, ''); renameDialog = null; }}>Reset</button>
|
||||
<button onclick={() => { setAlias(renameDialog!.pwName, renameInput); renameDialog = null; }}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hide Nodes Dialog -->
|
||||
{#if showHideDialog}
|
||||
<div class="dialog">
|
||||
@@ -796,13 +846,14 @@
|
||||
<div class="dialog-body">
|
||||
<div class="input-row">
|
||||
<input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" />
|
||||
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); } }}>Save Current</button>
|
||||
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); newProfileName = ''; } }}>Save Current</button>
|
||||
</div>
|
||||
<div class="rule-list">
|
||||
{#each Object.entries($patchbay.profiles) as [name, profile]}
|
||||
<div class="rule-item">
|
||||
<span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
|
||||
<button onclick={() => loadProfile(name)}>Load</button>
|
||||
<button onclick={() => saveProfile(name)} title="Overwrite with current connections">Update</button>
|
||||
<button onclick={() => deleteProfile(name)}>Delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -840,6 +891,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showMidiPanel}
|
||||
<MidiMappingPanel onClose={() => { showMidiPanel = false; }} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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 type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef, MidiMapping } from './types';
|
||||
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||
|
||||
// Raw graph stores
|
||||
@@ -19,6 +19,8 @@ export const patchbay = writable<PatchbayState>({
|
||||
pinned_connections: [],
|
||||
hide_rules: [],
|
||||
merge_rules: [],
|
||||
aliases: {},
|
||||
virtual_nodes: [],
|
||||
});
|
||||
|
||||
// Port/node lookups
|
||||
@@ -82,7 +84,7 @@ export async function initGraph() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.profiles) {
|
||||
patchbay.set(data as PatchbayState);
|
||||
patchbay.set({ ...data, aliases: data.aliases ?? {}, virtual_nodes: data.virtual_nodes ?? [] } as PatchbayState);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -321,11 +323,22 @@ export async function saveProfile(name: string) {
|
||||
});
|
||||
}
|
||||
|
||||
const volumes: Record<string, number> = {};
|
||||
const mutes: Record<string, boolean> = {};
|
||||
for (const n of currentNodes) {
|
||||
volumes[n.name] = n.volume;
|
||||
mutes[n.name] = n.mute;
|
||||
}
|
||||
|
||||
const profile: PatchbayProfile = {
|
||||
name,
|
||||
connections,
|
||||
hide_rules: [...pb.hide_rules],
|
||||
merge_rules: [...pb.merge_rules],
|
||||
volumes,
|
||||
mutes,
|
||||
virtual_nodes: [...pb.virtual_nodes],
|
||||
midi_mappings: [...(pb.profiles[name]?.midi_mappings ?? [])],
|
||||
};
|
||||
|
||||
patchbay.update(pb => ({
|
||||
@@ -336,12 +349,61 @@ export async function saveProfile(name: string) {
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function loadProfile(name: string) {
|
||||
async function restoreVirtualNodes(defs: VirtualNodeDef[]) {
|
||||
if (!defs.length) return;
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const existingNames = new Set(currentNodes.map(n => n.name));
|
||||
let created = false;
|
||||
for (const vn of defs) {
|
||||
if (!existingNames.has(vn.name)) {
|
||||
if (vn.type === 'null-sink') await createNullSink(vn.name);
|
||||
else if (vn.type === 'loopback') await createLoopback(vn.name);
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
// Give the graph time to settle after creation before applying connections
|
||||
if (created) await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
|
||||
async function applyProfileVolumes(profile: PatchbayProfile) {
|
||||
if (!profile.volumes && !profile.mutes) return;
|
||||
const currentNodes = get_store_value(nodes);
|
||||
|
||||
// Optimistically update store so sliders reflect new values immediately
|
||||
nodes.update(ns => ns.map(n => {
|
||||
const vol = profile.volumes?.[n.name];
|
||||
const mute = profile.mutes?.[n.name];
|
||||
if (vol !== undefined || mute !== undefined) {
|
||||
return { ...n, ...(vol !== undefined ? { volume: vol } : {}), ...(mute !== undefined ? { mute } : {}) };
|
||||
}
|
||||
return n;
|
||||
}));
|
||||
|
||||
for (const n of currentNodes) {
|
||||
if (profile.volumes?.[n.name] !== undefined) {
|
||||
await setNodeVolume(n.id, profile.volumes[n.name]);
|
||||
}
|
||||
if (profile.mutes?.[n.name] !== undefined) {
|
||||
await setNodeMute(n.id, profile.mutes[n.name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProfile(name: string) {
|
||||
patchbay.update(pb => ({ ...pb, active_profile: name }));
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated) {
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
const profile = pb.profiles[name];
|
||||
if (!profile) { savePatchbayState(); return; }
|
||||
|
||||
// Recreate missing virtual nodes before restoring connections
|
||||
await restoreVirtualNodes(profile.virtual_nodes ?? []);
|
||||
|
||||
applyPatchbay(pb);
|
||||
applyProfileVolumes(profile);
|
||||
|
||||
// Restore MIDI mappings for this profile
|
||||
await saveMidiMappings(profile.midi_mappings ?? []);
|
||||
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
@@ -358,6 +420,20 @@ export function deleteProfile(name: string) {
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
// Node aliases (custom display names, keyed by PW node name)
|
||||
export function setAlias(pwName: string, alias: string) {
|
||||
patchbay.update(pb => {
|
||||
const aliases = { ...pb.aliases };
|
||||
if (alias.trim()) {
|
||||
aliases[pwName] = alias.trim();
|
||||
} else {
|
||||
delete aliases[pwName]; // empty string = remove alias
|
||||
}
|
||||
return { ...pb, aliases };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
// Volume control
|
||||
export async function setNodeVolume(nodeId: number, volume: number) {
|
||||
try {
|
||||
@@ -392,7 +468,15 @@ export async function createNullSink(name: string): Promise<number | null> {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.module_id > 0 ? data.module_id : null;
|
||||
const moduleId = data.module_id > 0 ? data.module_id : null;
|
||||
if (moduleId) {
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
virtual_nodes: [...pb.virtual_nodes.filter(v => v.name !== name), { type: 'null-sink', name }],
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
return moduleId;
|
||||
} catch (e) {
|
||||
console.error('[api] create-null-sink failed:', e);
|
||||
return null;
|
||||
@@ -407,13 +491,29 @@ export async function createLoopback(name: string): Promise<number | null> {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.module_id > 0 ? data.module_id : null;
|
||||
const moduleId = data.module_id > 0 ? data.module_id : null;
|
||||
if (moduleId) {
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
virtual_nodes: [...pb.virtual_nodes.filter(v => v.name !== name), { type: 'loopback', name }],
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
return moduleId;
|
||||
} catch (e) {
|
||||
console.error('[api] create-loopback failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeVirtualNode(name: string) {
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
virtual_nodes: pb.virtual_nodes.filter(v => v.name !== name),
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export async function loadModule(module: string, args: string): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch('/api/load-module', {
|
||||
@@ -462,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 };
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface DisconnectMessage {
|
||||
|
||||
export type WsMessage = ConnectMessage | DisconnectMessage;
|
||||
|
||||
// Virtual device definition (user-created null-sinks, loopbacks, etc.)
|
||||
export interface VirtualNodeDef {
|
||||
type: 'null-sink' | 'loopback';
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Patchbay types
|
||||
export interface ConnectionRule {
|
||||
output_port_name: string;
|
||||
@@ -68,11 +74,26 @@ 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[];
|
||||
hide_rules?: string[];
|
||||
merge_rules?: string[];
|
||||
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 {
|
||||
@@ -85,4 +106,6 @@ export interface PatchbayState {
|
||||
pinned_connections: number[];
|
||||
hide_rules: string[];
|
||||
merge_rules: string[];
|
||||
aliases: Record<string, string>; // PW node name → custom display name
|
||||
virtual_nodes: VirtualNodeDef[]; // user-created virtual devices (global registry)
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -89,17 +89,12 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
|
||||
if (media_name && strlen(media_name) > 0)
|
||||
nobj->node.media_name = media_name;
|
||||
|
||||
// Read volume from props
|
||||
const char *vol_str = spa_dict_lookup(info->props, "volume");
|
||||
if (vol_str) {
|
||||
nobj->node.volume = pw_properties_parse_float(vol_str);
|
||||
}
|
||||
|
||||
// Read mute from props
|
||||
const char *mute_str = spa_dict_lookup(info->props, "mute");
|
||||
if (mute_str) {
|
||||
nobj->node.mute = pw_properties_parse_bool(mute_str);
|
||||
}
|
||||
// NOTE: volume/mute are intentionally NOT read from info->props here.
|
||||
// info->props contains static initial values and is NOT updated when
|
||||
// volume/mute change at runtime. Live state comes from SPA_PARAM_Props
|
||||
// via on_node_param (subscribed below in create_proxy_for_object).
|
||||
// Reading stale props here caused on_node_info to overwrite correct
|
||||
// runtime state back to the initial value (the unmute race condition).
|
||||
|
||||
// Read additional properties
|
||||
const char *str;
|
||||
@@ -170,9 +165,13 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
|
||||
|
||||
nobj->node.changed = true;
|
||||
nobj->node.ready = true;
|
||||
|
||||
// Notify so frontend reflects property changes (sample rate, media name, etc.)
|
||||
if (obj->engine_ref)
|
||||
obj->engine_ref->notifyChanged();
|
||||
}
|
||||
|
||||
// Parse audio format param (like pw-top does)
|
||||
// Parse audio format and Props params
|
||||
static void on_node_param(void *data, int seq,
|
||||
uint32_t id, uint32_t index, uint32_t next,
|
||||
const struct spa_pod *param)
|
||||
@@ -182,20 +181,59 @@ static void on_node_param(void *data, int seq,
|
||||
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
|
||||
|
||||
if (param == NULL) return;
|
||||
if (id != SPA_PARAM_Format) return;
|
||||
|
||||
uint32_t media_type, media_subtype;
|
||||
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
|
||||
if (id == SPA_PARAM_Format) {
|
||||
uint32_t media_type, media_subtype;
|
||||
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
|
||||
|
||||
if (media_type == SPA_MEDIA_TYPE_audio && media_subtype == SPA_MEDIA_SUBTYPE_raw) {
|
||||
struct spa_audio_info_raw info;
|
||||
spa_zero(info);
|
||||
if (spa_format_audio_raw_parse(param, &info) >= 0) {
|
||||
if (info.rate > 0) nobj->node.sample_rate = info.rate;
|
||||
if (info.channels > 0) nobj->node.channels = info.channels;
|
||||
nobj->node.format = spa_type_audio_format_to_short_name((uint32_t)info.format);
|
||||
nobj->node.changed = true;
|
||||
if (media_type == SPA_MEDIA_TYPE_audio && media_subtype == SPA_MEDIA_SUBTYPE_raw) {
|
||||
struct spa_audio_info_raw info;
|
||||
spa_zero(info);
|
||||
if (spa_format_audio_raw_parse(param, &info) >= 0) {
|
||||
if (info.rate > 0) nobj->node.sample_rate = info.rate;
|
||||
if (info.channels > 0) nobj->node.channels = info.channels;
|
||||
nobj->node.format = spa_type_audio_format_to_short_name((uint32_t)info.format);
|
||||
nobj->node.changed = true;
|
||||
}
|
||||
}
|
||||
} else if (id == SPA_PARAM_Props) {
|
||||
// Parse live volume/mute state from Props params.
|
||||
// This is the authoritative source — info->props only has initial/static values.
|
||||
const struct spa_pod_object *pobj = (const struct spa_pod_object *)param;
|
||||
struct spa_pod_prop *prop;
|
||||
SPA_POD_OBJECT_FOREACH(pobj, prop) {
|
||||
switch (prop->key) {
|
||||
case SPA_PROP_volume: {
|
||||
float vol;
|
||||
if (spa_pod_get_float(&prop->value, &vol) == 0)
|
||||
nobj->node.volume = vol;
|
||||
break;
|
||||
}
|
||||
case SPA_PROP_channelVolumes: {
|
||||
// Average channel volumes for display
|
||||
float vols[32];
|
||||
uint32_t n = spa_pod_copy_array(&prop->value, SPA_TYPE_Float, vols, 32);
|
||||
if (n > 0) {
|
||||
float avg = 0;
|
||||
for (uint32_t i = 0; i < n; i++) avg += vols[i];
|
||||
nobj->node.volume = avg / n;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SPA_PROP_mute: {
|
||||
bool m;
|
||||
if (spa_pod_get_bool(&prop->value, &m) == 0)
|
||||
nobj->node.mute = m;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
nobj->node.changed = true;
|
||||
// Broadcast live volume/mute changes from any source (browser, pulsemixer, etc.)
|
||||
if (obj->engine_ref)
|
||||
obj->engine_ref->notifyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,15 +340,16 @@ static void create_proxy_for_object(GraphEngine::Object *obj, GraphEngine *engin
|
||||
obj->proxy = proxy;
|
||||
obj->destroy_info = destroy_info;
|
||||
obj->pending_seq = 0;
|
||||
obj->engine_ref = engine;
|
||||
pw_proxy_add_object_listener(proxy,
|
||||
&obj->object_listener, events, obj);
|
||||
pw_proxy_add_listener(proxy,
|
||||
&obj->proxy_listener, &proxy_events, obj);
|
||||
|
||||
// Subscribe to format params for nodes (like pw-top)
|
||||
// Subscribe to Format + Props params for nodes
|
||||
if (obj->type == GraphEngine::Object::ObjNode) {
|
||||
uint32_t ids[1] = { SPA_PARAM_Format };
|
||||
pw_node_subscribe_params((pw_node*)proxy, ids, 1);
|
||||
uint32_t ids[2] = { SPA_PARAM_Format, SPA_PARAM_Props };
|
||||
pw_node_subscribe_params((pw_node*)proxy, ids, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,7 +543,11 @@ static void on_registry_global(void *data,
|
||||
if ((node_obj->node.mode2 & port_mode) == PortMode::None)
|
||||
node_obj->node.mode2 = PortMode::Duplex;
|
||||
|
||||
node_obj->node.port_ids.push_back(id);
|
||||
// Avoid duplicate port IDs in node
|
||||
auto &pids = node_obj->node.port_ids;
|
||||
if (std::find(pids.begin(), pids.end(), id) == pids.end()) {
|
||||
pids.push_back(id);
|
||||
}
|
||||
node_obj->node.changed = true;
|
||||
|
||||
engine->addObject(id, pobj);
|
||||
@@ -556,7 +599,7 @@ static const struct pw_registry_events registry_events = {
|
||||
|
||||
GraphEngine::Object::Object(uint32_t id, Type type)
|
||||
: id(id), type(type), proxy(nullptr), info(nullptr),
|
||||
destroy_info(nullptr), pending_seq(0)
|
||||
destroy_info(nullptr), pending_seq(0), engine_ref(nullptr)
|
||||
{
|
||||
spa_zero(proxy_listener);
|
||||
spa_zero(object_listener);
|
||||
@@ -574,7 +617,7 @@ GraphEngine::Object::~Object() {
|
||||
// ============================================================================
|
||||
|
||||
GraphEngine::GraphEngine()
|
||||
: m_on_change(nullptr), m_running(false)
|
||||
: m_running(false)
|
||||
{
|
||||
m_audio_type = hashType(DEFAULT_AUDIO_TYPE);
|
||||
m_midi_type = hashType(DEFAULT_MIDI_TYPE);
|
||||
@@ -690,17 +733,23 @@ void GraphEngine::close() {
|
||||
|
||||
void GraphEngine::setOnChange(ChangeCallback cb) {
|
||||
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() {
|
||||
// Called from PipeWire thread — invoke callback outside lock
|
||||
ChangeCallback cb;
|
||||
// Called from PipeWire thread — copy callback list then invoke outside lock
|
||||
std::vector<ChangeCallback> cbs;
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -708,6 +757,15 @@ void GraphEngine::notifyChanged() {
|
||||
// ============================================================================
|
||||
|
||||
void GraphEngine::addObject(uint32_t id, Object *obj) {
|
||||
// Remove existing object with same ID if any (prevents duplicates)
|
||||
auto it = m_objects_by_id.find(id);
|
||||
if (it != m_objects_by_id.end()) {
|
||||
Object *old = it->second;
|
||||
auto vit = std::find(m_objects.begin(), m_objects.end(), old);
|
||||
if (vit != m_objects.end())
|
||||
m_objects.erase(vit);
|
||||
delete old;
|
||||
}
|
||||
m_objects_by_id[id] = obj;
|
||||
m_objects.push_back(obj);
|
||||
}
|
||||
@@ -910,11 +968,19 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
|
||||
|
||||
NodeObj *nobj = findNode(node_id);
|
||||
if (!nobj || !nobj->proxy) {
|
||||
fprintf(stderr, "pwweb: setNodeVolume: node %u not found or no proxy\n", node_id);
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build Props param with volume
|
||||
// Build Props param with both SPA_PROP_volume and SPA_PROP_channelVolumes.
|
||||
// Hardware ALSA sinks respond to SPA_PROP_volume; browser/app streams use
|
||||
// SPA_PROP_channelVolumes (per-channel). Setting both covers all node types.
|
||||
uint32_t n_ch = (nobj->node.channels > 0 && nobj->node.channels <= 32)
|
||||
? nobj->node.channels : 2;
|
||||
float ch_vols[32];
|
||||
for (uint32_t i = 0; i < n_ch; i++) ch_vols[i] = volume;
|
||||
|
||||
uint8_t buf[1024];
|
||||
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
||||
struct spa_pod_frame f;
|
||||
@@ -922,16 +988,21 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
|
||||
spa_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
|
||||
spa_pod_builder_prop(&b, SPA_PROP_volume, 0);
|
||||
spa_pod_builder_float(&b, volume);
|
||||
spa_pod_builder_prop(&b, SPA_PROP_channelVolumes, 0);
|
||||
spa_pod_builder_array(&b, sizeof(float), SPA_TYPE_Float, n_ch, ch_vols);
|
||||
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f);
|
||||
|
||||
pw_node_set_param((pw_node*)nobj->proxy,
|
||||
int res = pw_node_set_param((pw_node*)nobj->proxy,
|
||||
SPA_PARAM_Props, 0, param);
|
||||
|
||||
fprintf(stderr, "pwweb: setNodeVolume node=%u vol=%.2f res=%d name=%s\n",
|
||||
node_id, volume, res, nobj->node.name.c_str());
|
||||
|
||||
nobj->node.volume = volume;
|
||||
nobj->node.changed = true;
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return true;
|
||||
return (res >= 0);
|
||||
}
|
||||
|
||||
bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
||||
@@ -941,6 +1012,7 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
||||
|
||||
NodeObj *nobj = findNode(node_id);
|
||||
if (!nobj || !nobj->proxy) {
|
||||
fprintf(stderr, "pwweb: setNodeMute: node %u not found or no proxy\n", node_id);
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return false;
|
||||
}
|
||||
@@ -954,14 +1026,17 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
||||
spa_pod_builder_bool(&b, mute);
|
||||
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f);
|
||||
|
||||
pw_node_set_param((pw_node*)nobj->proxy,
|
||||
int res = pw_node_set_param((pw_node*)nobj->proxy,
|
||||
SPA_PARAM_Props, 0, param);
|
||||
|
||||
fprintf(stderr, "pwweb: setNodeMute node=%u mute=%d res=%d name=%s\n",
|
||||
node_id, mute, res, nobj->node.name.c_str());
|
||||
|
||||
nobj->node.mute = mute;
|
||||
nobj->node.changed = true;
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return true;
|
||||
return (res >= 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
|
||||
#include <pipewire/utils.h> // pw_thread_loop, etc.
|
||||
#include <spa/utils/list.h> // spa_list
|
||||
@@ -29,8 +29,9 @@ public:
|
||||
bool open();
|
||||
void close();
|
||||
|
||||
// Set callback invoked when graph changes
|
||||
// Set/add callbacks invoked when graph changes
|
||||
void setOnChange(ChangeCallback cb);
|
||||
void addOnChange(ChangeCallback cb);
|
||||
|
||||
// Thread-safe snapshot of the current graph state
|
||||
struct Snapshot {
|
||||
@@ -72,15 +73,16 @@ public:
|
||||
// Object management (called from C callbacks)
|
||||
struct Object {
|
||||
enum Type { ObjNode, ObjPort, ObjLink };
|
||||
uint32_t id;
|
||||
Type type;
|
||||
void *proxy;
|
||||
void *info;
|
||||
void (*destroy_info)(void*);
|
||||
spa_hook proxy_listener;
|
||||
spa_hook object_listener;
|
||||
int pending_seq;
|
||||
spa_list pending_link;
|
||||
uint32_t id;
|
||||
Type type;
|
||||
void *proxy;
|
||||
void *info;
|
||||
void (*destroy_info)(void*);
|
||||
spa_hook proxy_listener;
|
||||
spa_hook object_listener;
|
||||
int pending_seq;
|
||||
spa_list pending_link;
|
||||
GraphEngine *engine_ref; // back-pointer set in create_proxy_for_object
|
||||
|
||||
Object(uint32_t id, Type type);
|
||||
virtual ~Object();
|
||||
@@ -119,7 +121,7 @@ private:
|
||||
std::unordered_map<uint32_t, Object*> m_objects_by_id;
|
||||
std::vector<Object*> m_objects;
|
||||
|
||||
ChangeCallback m_on_change;
|
||||
std::vector<ChangeCallback> m_on_change_cbs;
|
||||
std::atomic<bool> m_running;
|
||||
|
||||
// Port type hashes
|
||||
|
||||
340
src/midi_mapper.cpp
Normal file
340
src/midi_mapper.cpp
Normal file
@@ -0,0 +1,340 @@
|
||||
#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.
|
||||
// Use mode2 (derived from actual port directions) rather than mode (from
|
||||
// media.class string) because Midi/Bridge nodes have mode=None but still
|
||||
// have real output ports and must be captured.
|
||||
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 ||
|
||||
(n.mode2 & 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)
|
||||
: 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() {
|
||||
// Serve frontend static files from ./frontend/dist
|
||||
m_http.set_mount_point("/", "./frontend/dist");
|
||||
@@ -442,7 +461,9 @@ void WebServer::setupRoutes() {
|
||||
if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 ||
|
||||
sscanf(req.body.c_str(), "{\"node_id\": %u", &node_id) == 1)
|
||||
{
|
||||
bool mute = req.body.find("true") != std::string::npos;
|
||||
// Check for "mute":true precisely — the old `find("true")` was imprecise
|
||||
bool mute = req.body.find("\"mute\":true") != std::string::npos ||
|
||||
req.body.find("\"mute\": true") != std::string::npos;
|
||||
bool ok = m_engine.setNodeMute(node_id, mute);
|
||||
if (ok) broadcastGraph();
|
||||
res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json");
|
||||
@@ -605,6 +626,137 @@ void WebServer::setupRoutes() {
|
||||
});
|
||||
|
||||
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
|
||||
m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "graph_engine.h"
|
||||
#include "midi_mapper.h"
|
||||
#include <httplib.h>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
@@ -18,6 +19,7 @@ public:
|
||||
bool start();
|
||||
void stop();
|
||||
void broadcastGraph();
|
||||
void broadcastSse(const std::string &event, const std::string &data);
|
||||
|
||||
private:
|
||||
void setupRoutes();
|
||||
@@ -27,6 +29,7 @@ private:
|
||||
void writeFile(const std::string &path, const std::string &data) const;
|
||||
|
||||
GraphEngine &m_engine;
|
||||
MidiMapper m_midi_mapper;
|
||||
int m_port;
|
||||
httplib::Server m_http;
|
||||
std::thread m_thread;
|
||||
|
||||
Reference in New Issue
Block a user