Compare commits
19 Commits
feature/co
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b7ad6e9a8 | ||
|
|
6fe6d05aad | ||
|
|
0c2c45fb5d | ||
|
|
da92a53c73 | ||
|
|
a770e2efe2 | ||
|
|
cbc5083490 | ||
|
|
b6d6ad970a | ||
|
|
68307556e9 | ||
|
|
58d1972d19 | ||
|
|
db48781221 | ||
|
|
7c4bf999bc | ||
|
|
31d8191672 | ||
|
|
fc1e0e7798 | ||
|
|
a58df9cdaa | ||
|
|
503d69bf59 | ||
|
|
cb87cd34ba | ||
|
|
a40e7b24e5 | ||
|
|
0d3cfb5f86 | ||
|
|
b3c81623f1 |
@@ -12,6 +12,7 @@
|
|||||||
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';
|
||||||
@@ -29,6 +30,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);
|
||||||
@@ -110,12 +113,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 +134,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 +148,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 +226,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 }>;
|
||||||
@@ -593,7 +604,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 +691,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 +721,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 +752,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 +843,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}
|
||||||
|
|||||||
@@ -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 } 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,21 @@ 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],
|
||||||
};
|
};
|
||||||
|
|
||||||
patchbay.update(pb => ({
|
patchbay.update(pb => ({
|
||||||
@@ -336,12 +348,57 @@ 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);
|
||||||
savePatchbayState();
|
savePatchbayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +415,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 +463,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 +486,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', {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -73,6 +79,9 @@ export interface PatchbayProfile {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchbayState {
|
export interface PatchbayState {
|
||||||
@@ -85,4 +94,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -708,6 +751,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 +962,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 +982,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 +1006,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 +1020,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -72,15 +72,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();
|
||||||
|
|||||||
@@ -442,7 +442,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");
|
||||||
|
|||||||
Reference in New Issue
Block a user