21 Commits

Author SHA1 Message Date
joren
ac35c13630 fix: detect Midi/Bridge nodes using mode2 (port-derived direction)
Midi/Bridge nodes have media.class = "Midi/Bridge" with no Source/Sink/
Input/Output keyword, so mode stays PortMode::None. mode2 is derived from
actual port directions and correctly reflects Output for bridge nodes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:35:12 +02:00
joren
9231c10429 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>
2026-04-06 02:09:44 +02:00
joren
8b7ad6e9a8 revert: remove level meters and VU meter feature
Reverts:
- fix: move VU meter above volume controls to stop blocking mute button
- Merge feature/graph-ux-meters (region fix, toggle, segmented VU meters)
- Merge feature/level-meters (pw_stream peak metering, /api/peaks)

VU meters / level meters don't belong in a patchbay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:23:06 +02:00
joren
6fe6d05aad fix: move VU meter above volume controls to stop blocking mute button
Meter was at nd.height-13 overlapping the mute button (nd.height-17,
h=12). Moved to nd.height-35 so it sits in the dedicated extra space
above the control zone, leaving the bottom 22px fully clear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:20:01 +02:00
joren
0c2c45fb5d Merge feature/graph-ux-meters into master 2026-04-02 23:17:32 +02:00
joren
da92a53c73 feat: graph region fix, toggle button, segmented VU meters
- Fix graph escaping toolbar: switch .wrap to flexbox + .toolbar to
  position:relative so the SVG canvas sits strictly below the toolbar
- Add "Graph" toggle button in toolbar to show/hide the canvas
- Replace thin 3px meter bar with 20-segment horizontal LED bar
  (10px tall, 1.5px gaps); green 0-75%, yellow 75-90%, red 90-100%

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:17:32 +02:00
joren
a770e2efe2 Merge feature/level-meters into master 2026-04-02 19:48:05 +02:00
joren
cbc5083490 feat: real-time dB level meters per node
Backend:
- One pw_stream (INPUT, F32) per audio node; for sinks use
  stream.capture.sink=true to read the monitor port
- RT process callback computes instantaneous linear peak with no
  allocations; stored in std::atomic<float>
- Meter streams are filtered from the registry so they never appear
  in the graph or trigger recursive meter creation
- Meter streams are created on first node-ready event, destroyed on
  node removal, and cleaned up on engine close
- GET /api/peaks → {node_id: linear_peak} (polled by frontend)

Frontend:
- peaks store polled at 100 ms via setInterval; starts/stops with
  initGraph/destroyGraph
- Each node card grows 8 px and shows a 3 px meter bar at the bottom
  (green below -12 dB, yellow -12 to -3 dB, red above -3 dB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:48:02 +02:00
joren
b6d6ad970a Merge feature/profile-virtual-nodes into master 2026-04-02 19:27:59 +02:00
joren
68307556e9 feat: save and restore virtual nodes (null-sinks, loopbacks) in profiles
- Add VirtualNodeDef type; track it in PatchbayState.virtual_nodes
- createNullSink/createLoopback now register the node in the global
  virtual_nodes registry on success
- Destroying a node via context menu removes it from the registry
- saveProfile snapshots virtual_nodes into the profile
- loadProfile recreates any missing virtual nodes before applying
  connections (waits 1.5s for graph to settle after creation)
- Backward compat: virtual_nodes defaults to [] for old save files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:27:59 +02:00
joren
58d1972d19 Merge feature/profile-volume-slider-fix into master 2026-04-02 19:16:55 +02:00
joren
db48781221 fix: optimistically update nodes store when loading profile volumes
setNodeVolume/setNodeMute only send API requests; the nodes store
wasn't updated until the backend broadcast a graph change, so sliders
showed stale values. Now the store is updated immediately before
firing the API calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:16:55 +02:00
joren
7c4bf999bc Merge feature/hide-improvements into master 2026-04-02 18:44:03 +02:00
joren
31d8191672 feat: regex hide rules (full-match) + right-click Hide button
- Hide rules now use anchored regex (^rule$) so plain text like
  "Speaker" matches exactly "Speaker", not "Gaming Speaker".
  To match a substring, use e.g. ".*Speaker.*". Rules that already
  start with ^ or end with $ are used as-is.
- Add "Hide" to node right-click context menu — adds the node's
  display name as a hide rule immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:44:00 +02:00
joren
fc1e0e7798 Merge feature/profile-volumes-hide-fix into master 2026-04-02 18:39:34 +02:00
joren
a58df9cdaa fix: store volumes/mutes in profiles + fix hide rule matching
- Snapshot node volumes and mutes when saving a profile; restore
  them on load via setNodeVolume/setNodeMute for each matching node
- Fix hide rules to do exact case-insensitive match on display name
  (alias/nick) instead of regex substring match on PW name, so
  "Speaker" no longer accidentally hides "Gaming Speaker"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:39:28 +02:00
joren
503d69bf59 Merge feature/node-aliases into master 2026-03-30 20:08:21 +02:00
joren
cb87cd34ba feat: custom node display names (aliases)
Right-click any node → Rename to set a custom label. The alias is shown
in the node header and Properties dialog instead of the raw PipeWire name.
Leave the input blank (or press Reset) to revert to the PW name.

Aliases are stored in aliases: Record<string,string> inside the patchbay
state (keyed by PW node name, which is stable for hardware devices) and
persisted automatically with the rest of the patchbay config. Old save
files without an aliases key are handled gracefully via the ?? {} fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:08:16 +02:00
joren
a40e7b24e5 Merge feature/mute-and-profile-fixes into master
- fix: mute race condition + profile loading + add Update button
- fix: broadcast external volume/mute changes + fix browser stream volume
2026-03-30 12:33:06 +02:00
joren
0d3cfb5f86 fix: broadcast external volume/mute changes + fix browser stream volume
Issue 1 - Volume/mute changes from external apps (browser YT player, pulsemixer)
not reflected in the frontend:
- on_node_param and on_node_info updated node state but never called notifyChanged(),
  so the SSE broadcast was never triggered for external changes.
- Add engine_ref back-pointer to Object (set in create_proxy_for_object).
- Call notifyChanged() at the end of on_node_info and after updating Props
  in on_node_param, so any external volume/mute change immediately broadcasts.

Issue 2 - Volume slider has no audible effect on browser/app streams:
- setNodeVolume only set SPA_PROP_volume (single float). Browser streams
  (Chromium, Firefox) use SPA_PROP_channelVolumes (per-channel float array)
  and ignore the single-float property.
- Now set both SPA_PROP_volume AND SPA_PROP_channelVolumes (using the node's
  known channel count, defaulting to stereo if unknown). ALSA hardware nodes
  respond to SPA_PROP_volume; app streams respond to SPA_PROP_channelVolumes.

Note: wires auto-reconnecting is WirePlumber session policy (by design) —
WirePlumber re-links any stream that loses its connection to the default sink.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:30:56 +02:00
joren
b3c81623f1 fix: mute race condition + profile loading + add Update button
Bug 1 - Mute unmute not sticking (G560 and others):
- Root cause: on_node_info was reading volume/mute from info->props which
  contains static initial values only — NOT updated at runtime. When any
  node info event fired, it overwrote the correct runtime state with stale
  initial data, causing the unmute to revert on the next graph event.
- Fix: Subscribe nodes to SPA_PARAM_Props in addition to SPA_PARAM_Format.
  Handle SPA_PARAM_Props in on_node_param to track volume (both SPA_PROP_volume
  and SPA_PROP_channelVolumes averaged) and mute from the authoritative live
  parameter stream. Remove stale volume/mute reads from on_node_info.
- Also fix mute detection in /api/mute: check "mute":true precisely instead
  of searching for bare "true" anywhere in the body.

Bug 2 - Loading profiles does not work:
- loadProfile was only applying connections when already in "activated" mode.
  Load now always applies the profile connections immediately.

Bug 3 - No option to update an existing profile:
- Add "Update" button in profile list that overwrites the profile with current
  connections (calls saveProfile with the existing name).
- Clear the profile name input after "Save Current" succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 12:20:22 +02:00
12 changed files with 1395 additions and 74 deletions

View File

@@ -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

View File

@@ -12,10 +12,12 @@
setAutoPin, setAutoDisconnect, setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile, saveProfile, loadProfile, deleteProfile,
setNodeVolume, setNodeMute, setNodeVolume, setNodeMute,
setAlias, removeVirtualNode,
createNullSink, createLoopback, loadModule, createNullSink, createLoopback, loadModule,
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 });
@@ -29,6 +31,8 @@
let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null); 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 nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);
let showPropsDialog = $state<number | null>(null); // node ID or null let showPropsDialog = $state<number | null>(null); // node ID or null
let renameDialog = $state<{ pwName: string } | null>(null);
let renameInput = $state('');
// Filters // Filters
let showAudio = $state(true); let showAudio = $state(true);
@@ -41,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);
@@ -110,12 +115,15 @@
return pt.matrixTransform(ctm.inverse()); 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) { for (const rule of $patchbay.hide_rules) {
try { 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 { } catch {
if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true; if (dn.toLowerCase() === rule.toLowerCase()) return true;
} }
} }
return false; return false;
@@ -128,6 +136,11 @@
return nodeName; 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 // Build computed layout
let graphNodes = $derived.by(() => { let graphNodes = $derived.by(() => {
const n = $nodes; const n = $nodes;
@@ -137,7 +150,7 @@
for (const port of p) portMap.set(port.id, port); for (const port of p) portMap.set(port.id, port);
// Filter hidden nodes // 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) // Merge nodes by prefix (unless split mode)
if (!splitNodes) { if (!splitNodes) {
@@ -215,8 +228,8 @@
// Check if both endpoint nodes are visible // Check if both endpoint nodes are visible
const outNode = $nodes.find(n => n.id === outPort.node_id); const outNode = $nodes.find(n => n.id === outPort.node_id);
const inNode = $nodes.find(n => n.id === inPort.node_id); const inNode = $nodes.find(n => n.id === inPort.node_id);
if (outNode && isNodeHidden(outNode.name)) return null; if (outNode && isNodeHidden(outNode)) return null;
if (inNode && isNodeHidden(inNode.name)) return null; if (inNode && isNodeHidden(inNode)) return null;
const pinned = pb.pinned_connections.includes(link.id); const pinned = pb.pinned_connections.includes(link.id);
return { ...link, outPort, inPort, pinned }; return { ...link, outPort, inPort, pinned };
}).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>; }).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 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); } }}>
@@ -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} width={nd.width} height="22" rx="4" fill={headerBg} />
<rect x={nd.x} y={nd.y + 16} width={nd.width} height="6" 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"> <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>
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end"> <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}] [{nd.node_type}]
@@ -680,9 +694,20 @@
{#if nodeContextMenu} {#if nodeContextMenu}
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu"> <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={() => { showPropsDialog = nodeContextMenu!.nodeId; nodeContextMenu = null; }}>Properties</button>
<button onclick={() => { <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', { fetch('/api/destroy-node', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -699,7 +724,7 @@
{#if nd} {#if nd}
<div class="dialog"> <div class="dialog">
<div class="dialog-header"> <div class="dialog-header">
<span>Properties: {nd.nick || nd.name}</span> <span>Properties: {displayName(nd)}</span>
<button class="close" onclick={() => { showPropsDialog = null; }}>X</button> <button class="close" onclick={() => { showPropsDialog = null; }}>X</button>
</div> </div>
<div class="dialog-body"> <div class="dialog-body">
@@ -730,6 +755,31 @@
{/if} {/if}
{/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 --> <!-- Hide Nodes Dialog -->
{#if showHideDialog} {#if showHideDialog}
<div class="dialog"> <div class="dialog">
@@ -796,13 +846,14 @@
<div class="dialog-body"> <div class="dialog-body">
<div class="input-row"> <div class="input-row">
<input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" /> <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>
<div class="rule-list"> <div class="rule-list">
{#each Object.entries($patchbay.profiles) as [name, profile]} {#each Object.entries($patchbay.profiles) as [name, profile]}
<div class="rule-item"> <div class="rule-item">
<span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span> <span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
<button onclick={() => loadProfile(name)}>Load</button> <button onclick={() => loadProfile(name)}>Load</button>
<button onclick={() => saveProfile(name)} title="Overwrite with current connections">Update</button>
<button onclick={() => deleteProfile(name)}>Delete</button> <button onclick={() => deleteProfile(name)}>Delete</button>
</div> </div>
{/each} {/each}
@@ -840,6 +891,10 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if showMidiPanel}
<MidiMappingPanel onClose={() => { showMidiPanel = false; }} />
{/if}
</div> </div>
<style> <style>

View File

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

View File

@@ -1,5 +1,5 @@
import { writable, derived } from 'svelte/store'; import { 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'; import { subscribe, connectPorts, disconnectPorts } from './ws';
// Raw graph stores // Raw graph stores
@@ -19,6 +19,8 @@ export const patchbay = writable<PatchbayState>({
pinned_connections: [], pinned_connections: [],
hide_rules: [], hide_rules: [],
merge_rules: [], merge_rules: [],
aliases: {},
virtual_nodes: [],
}); });
// Port/node lookups // Port/node lookups
@@ -82,7 +84,7 @@ export async function initGraph() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data && data.profiles) { if (data && data.profiles) {
patchbay.set(data as PatchbayState); patchbay.set({ ...data, aliases: data.aliases ?? {}, virtual_nodes: data.virtual_nodes ?? [] } as PatchbayState);
} }
} }
} catch {} } 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 = { const profile: PatchbayProfile = {
name, name,
connections, connections,
hide_rules: [...pb.hide_rules], hide_rules: [...pb.hide_rules],
merge_rules: [...pb.merge_rules], merge_rules: [...pb.merge_rules],
volumes,
mutes,
virtual_nodes: [...pb.virtual_nodes],
midi_mappings: [...(pb.profiles[name]?.midi_mappings ?? [])],
}; };
patchbay.update(pb => ({ patchbay.update(pb => ({
@@ -336,12 +349,61 @@ export async function saveProfile(name: string) {
savePatchbayState(); 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 })); patchbay.update(pb => ({ ...pb, active_profile: name }));
const pb = get_store_value(patchbay); const pb = get_store_value(patchbay);
if (pb.activated) { const profile = pb.profiles[name];
applyPatchbay(pb); 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(); savePatchbayState();
} }
@@ -358,6 +420,20 @@ export function deleteProfile(name: string) {
savePatchbayState(); 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 // Volume control
export async function setNodeVolume(nodeId: number, volume: number) { export async function setNodeVolume(nodeId: number, volume: number) {
try { try {
@@ -392,7 +468,15 @@ export async function createNullSink(name: string): Promise<number | null> {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
const data = await res.json(); 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) { } catch (e) {
console.error('[api] create-null-sink failed:', e); console.error('[api] create-null-sink failed:', e);
return null; return null;
@@ -407,13 +491,29 @@ export async function createLoopback(name: string): Promise<number | null> {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
const data = await res.json(); 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) { } catch (e) {
console.error('[api] create-loopback failed:', e); console.error('[api] create-loopback failed:', e);
return null; 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> { export async function loadModule(module: string, args: string): Promise<number | null> {
try { try {
const res = await fetch('/api/load-module', { 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 }; export { connectPorts, disconnectPorts };

View File

@@ -57,6 +57,12 @@ export interface DisconnectMessage {
export type WsMessage = ConnectMessage | 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 // Patchbay types
export interface ConnectionRule { export interface ConnectionRule {
output_port_name: string; output_port_name: string;
@@ -68,11 +74,26 @@ 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[];
hide_rules?: string[]; hide_rules?: string[];
merge_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 { export interface PatchbayState {
@@ -85,4 +106,6 @@ export interface PatchbayState {
pinned_connections: number[]; pinned_connections: number[];
hide_rules: string[]; hide_rules: string[];
merge_rules: string[]; merge_rules: string[];
aliases: Record<string, string>; // PW node name → custom display name
virtual_nodes: VirtualNodeDef[]; // user-created virtual devices (global registry)
} }

View File

@@ -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, {

View File

@@ -89,17 +89,12 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
if (media_name && strlen(media_name) > 0) if (media_name && strlen(media_name) > 0)
nobj->node.media_name = media_name; nobj->node.media_name = media_name;
// Read volume from props // NOTE: volume/mute are intentionally NOT read from info->props here.
const char *vol_str = spa_dict_lookup(info->props, "volume"); // info->props contains static initial values and is NOT updated when
if (vol_str) { // volume/mute change at runtime. Live state comes from SPA_PARAM_Props
nobj->node.volume = pw_properties_parse_float(vol_str); // 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 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);
}
// Read additional properties // Read additional properties
const char *str; 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.changed = true;
nobj->node.ready = 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, static void on_node_param(void *data, int seq,
uint32_t id, uint32_t index, uint32_t next, uint32_t id, uint32_t index, uint32_t next,
const struct spa_pod *param) 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); auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
if (param == NULL) return; if (param == NULL) return;
if (id != SPA_PARAM_Format) return;
uint32_t media_type, media_subtype; if (id == SPA_PARAM_Format) {
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return; 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) { if (media_type == SPA_MEDIA_TYPE_audio && media_subtype == SPA_MEDIA_SUBTYPE_raw) {
struct spa_audio_info_raw info; struct spa_audio_info_raw info;
spa_zero(info); spa_zero(info);
if (spa_format_audio_raw_parse(param, &info) >= 0) { if (spa_format_audio_raw_parse(param, &info) >= 0) {
if (info.rate > 0) nobj->node.sample_rate = info.rate; if (info.rate > 0) nobj->node.sample_rate = info.rate;
if (info.channels > 0) nobj->node.channels = info.channels; 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.format = spa_type_audio_format_to_short_name((uint32_t)info.format);
nobj->node.changed = true; 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->proxy = proxy;
obj->destroy_info = destroy_info; obj->destroy_info = destroy_info;
obj->pending_seq = 0; obj->pending_seq = 0;
obj->engine_ref = engine;
pw_proxy_add_object_listener(proxy, pw_proxy_add_object_listener(proxy,
&obj->object_listener, events, obj); &obj->object_listener, events, obj);
pw_proxy_add_listener(proxy, pw_proxy_add_listener(proxy,
&obj->proxy_listener, &proxy_events, obj); &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) { if (obj->type == GraphEngine::Object::ObjNode) {
uint32_t ids[1] = { SPA_PARAM_Format }; uint32_t ids[2] = { SPA_PARAM_Format, SPA_PARAM_Props };
pw_node_subscribe_params((pw_node*)proxy, ids, 1); 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) if ((node_obj->node.mode2 & port_mode) == PortMode::None)
node_obj->node.mode2 = PortMode::Duplex; 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; node_obj->node.changed = true;
engine->addObject(id, pobj); engine->addObject(id, pobj);
@@ -556,7 +599,7 @@ static const struct pw_registry_events registry_events = {
GraphEngine::Object::Object(uint32_t id, Type type) GraphEngine::Object::Object(uint32_t id, Type type)
: id(id), type(type), proxy(nullptr), info(nullptr), : 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(proxy_listener);
spa_zero(object_listener); spa_zero(object_listener);
@@ -574,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);
@@ -690,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();
} }
// ============================================================================ // ============================================================================
@@ -708,6 +757,15 @@ void GraphEngine::notifyChanged() {
// ============================================================================ // ============================================================================
void GraphEngine::addObject(uint32_t id, Object *obj) { 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_by_id[id] = obj;
m_objects.push_back(obj); m_objects.push_back(obj);
} }
@@ -910,11 +968,19 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
NodeObj *nobj = findNode(node_id); NodeObj *nobj = findNode(node_id);
if (!nobj || !nobj->proxy) { 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); pw_thread_loop_unlock(m_pw.loop);
return false; 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]; uint8_t buf[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
struct spa_pod_frame f; 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_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
spa_pod_builder_prop(&b, SPA_PROP_volume, 0); spa_pod_builder_prop(&b, SPA_PROP_volume, 0);
spa_pod_builder_float(&b, volume); 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); 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); 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.volume = volume;
nobj->node.changed = true; nobj->node.changed = true;
pw_thread_loop_unlock(m_pw.loop); pw_thread_loop_unlock(m_pw.loop);
return true; return (res >= 0);
} }
bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) { 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); NodeObj *nobj = findNode(node_id);
if (!nobj || !nobj->proxy) { 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); pw_thread_loop_unlock(m_pw.loop);
return false; return false;
} }
@@ -954,14 +1026,17 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
spa_pod_builder_bool(&b, mute); spa_pod_builder_bool(&b, mute);
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f); 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); 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.mute = mute;
nobj->node.changed = true; nobj->node.changed = true;
pw_thread_loop_unlock(m_pw.loop); pw_thread_loop_unlock(m_pw.loop);
return true; return (res >= 0);
} }
// ============================================================================ // ============================================================================

View File

@@ -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 {
@@ -72,15 +73,16 @@ public:
// Object management (called from C callbacks) // Object management (called from C callbacks)
struct Object { struct Object {
enum Type { ObjNode, ObjPort, ObjLink }; enum Type { ObjNode, ObjPort, ObjLink };
uint32_t id; uint32_t id;
Type type; Type type;
void *proxy; void *proxy;
void *info; void *info;
void (*destroy_info)(void*); void (*destroy_info)(void*);
spa_hook proxy_listener; spa_hook proxy_listener;
spa_hook object_listener; spa_hook object_listener;
int pending_seq; int pending_seq;
spa_list pending_link; spa_list pending_link;
GraphEngine *engine_ref; // back-pointer set in create_proxy_for_object
Object(uint32_t id, Type type); Object(uint32_t id, Type type);
virtual ~Object(); virtual ~Object();
@@ -119,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

340
src/midi_mapper.cpp Normal file
View 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
View 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

View File

@@ -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");
@@ -442,7 +461,9 @@ void WebServer::setupRoutes() {
if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 || if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 ||
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); bool ok = m_engine.setNodeMute(node_id, mute);
if (ok) broadcastGraph(); if (ok) broadcastGraph();
res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json"); 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/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) {

View File

@@ -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;