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,
|
||||
saveProfile, loadProfile, deleteProfile,
|
||||
setNodeVolume, setNodeMute,
|
||||
setAlias, removeVirtualNode,
|
||||
createNullSink, createLoopback, loadModule,
|
||||
getQuantum, setQuantum,
|
||||
} 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 nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null);
|
||||
let showPropsDialog = $state<number | null>(null); // node ID or null
|
||||
let renameDialog = $state<{ pwName: string } | null>(null);
|
||||
let renameInput = $state('');
|
||||
|
||||
// Filters
|
||||
let showAudio = $state(true);
|
||||
@@ -110,12 +113,15 @@
|
||||
return pt.matrixTransform(ctm.inverse());
|
||||
}
|
||||
|
||||
function isNodeHidden(nodeName: string): boolean {
|
||||
function isNodeHidden(nd: { name: string; nick: string }): boolean {
|
||||
const dn = displayName(nd);
|
||||
for (const rule of $patchbay.hide_rules) {
|
||||
try {
|
||||
if (new RegExp(rule, 'i').test(nodeName)) return true;
|
||||
// Anchor to full match unless user already added anchors
|
||||
const anchored = (rule.startsWith('^') || rule.endsWith('$')) ? rule : `^${rule}$`;
|
||||
if (new RegExp(anchored, 'i').test(dn)) return true;
|
||||
} catch {
|
||||
if (nodeName.toLowerCase().includes(rule.toLowerCase())) return true;
|
||||
if (dn.toLowerCase() === rule.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -128,6 +134,11 @@
|
||||
return nodeName;
|
||||
}
|
||||
|
||||
// Return custom alias, otherwise nick, otherwise PW name
|
||||
function displayName(nd: { name: string; nick: string }): string {
|
||||
return $patchbay.aliases?.[nd.name] || nd.nick || nd.name;
|
||||
}
|
||||
|
||||
// Build computed layout
|
||||
let graphNodes = $derived.by(() => {
|
||||
const n = $nodes;
|
||||
@@ -137,7 +148,7 @@
|
||||
for (const port of p) portMap.set(port.id, port);
|
||||
|
||||
// Filter hidden nodes
|
||||
let visible = n.filter(nd => !isNodeHidden(nd.name));
|
||||
let visible = n.filter(nd => !isNodeHidden(nd));
|
||||
|
||||
// Merge nodes by prefix (unless split mode)
|
||||
if (!splitNodes) {
|
||||
@@ -215,8 +226,8 @@
|
||||
// Check if both endpoint nodes are visible
|
||||
const outNode = $nodes.find(n => n.id === outPort.node_id);
|
||||
const inNode = $nodes.find(n => n.id === inPort.node_id);
|
||||
if (outNode && isNodeHidden(outNode.name)) return null;
|
||||
if (inNode && isNodeHidden(inNode.name)) return null;
|
||||
if (outNode && isNodeHidden(outNode)) return null;
|
||||
if (inNode && isNodeHidden(inNode)) return null;
|
||||
const pinned = pb.pinned_connections.includes(link.id);
|
||||
return { ...link, outPort, inPort, pinned };
|
||||
}).filter(Boolean) as Array<Link & { outPort: Port; inPort: Port; pinned: boolean }>;
|
||||
@@ -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 + 16} width={nd.width} height="6" fill={headerBg} />
|
||||
<text x={nd.x + 6} y={nd.y + 15} font-size="10" font-family="monospace" fill="#ddd" font-weight="bold">
|
||||
{nd.nick || nd.name}
|
||||
{displayName(nd)}
|
||||
</text>
|
||||
<text x={nd.x + nd.width - 6} y={nd.y + 15} font-size="9" font-family="monospace" fill="#777" text-anchor="end">
|
||||
[{nd.node_type}]
|
||||
@@ -680,9 +691,20 @@
|
||||
|
||||
{#if nodeContextMenu}
|
||||
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
|
||||
<div class="ctx-title">{nodeContextMenu.nodeName}</div>
|
||||
<div class="ctx-title">{$patchbay.aliases?.[nodeContextMenu.nodeName] || nodeContextMenu.nodeName}</div>
|
||||
<button onclick={() => { showPropsDialog = nodeContextMenu!.nodeId; nodeContextMenu = null; }}>Properties</button>
|
||||
<button onclick={() => {
|
||||
renameDialog = { pwName: nodeContextMenu!.nodeName };
|
||||
renameInput = $patchbay.aliases?.[nodeContextMenu!.nodeName] ?? '';
|
||||
nodeContextMenu = null;
|
||||
}}>Rename</button>
|
||||
<button onclick={() => {
|
||||
const nd = $nodes.find(n => n.id === nodeContextMenu!.nodeId);
|
||||
if (nd) addHideRule(displayName(nd));
|
||||
nodeContextMenu = null;
|
||||
}}>Hide</button>
|
||||
<button onclick={() => {
|
||||
removeVirtualNode(nodeContextMenu!.nodeName);
|
||||
fetch('/api/destroy-node', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -699,7 +721,7 @@
|
||||
{#if nd}
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<span>Properties: {nd.nick || nd.name}</span>
|
||||
<span>Properties: {displayName(nd)}</span>
|
||||
<button class="close" onclick={() => { showPropsDialog = null; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
@@ -730,6 +752,31 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Rename Node Dialog -->
|
||||
{#if renameDialog}
|
||||
<div class="dialog" style="right:auto;left:50%;top:50%;transform:translate(-50%,-50%);width:280px">
|
||||
<div class="dialog-header">
|
||||
<span>Rename Node</span>
|
||||
<button class="close" onclick={() => { renameDialog = null; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="hint" style="word-break:break-all">{renameDialog.pwName}</p>
|
||||
<div class="input-row">
|
||||
<input
|
||||
class="dlg-input"
|
||||
bind:value={renameInput}
|
||||
placeholder="Custom display name (leave blank to reset)"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { setAlias(renameDialog!.pwName, renameInput); renameDialog = null; } }}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-row" style="justify-content:flex-end;gap:6px">
|
||||
<button onclick={() => { setAlias(renameDialog!.pwName, ''); renameDialog = null; }}>Reset</button>
|
||||
<button onclick={() => { setAlias(renameDialog!.pwName, renameInput); renameDialog = null; }}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hide Nodes Dialog -->
|
||||
{#if showHideDialog}
|
||||
<div class="dialog">
|
||||
@@ -796,13 +843,14 @@
|
||||
<div class="dialog-body">
|
||||
<div class="input-row">
|
||||
<input bind:value={newProfileName} placeholder="Profile name" class="dlg-input" />
|
||||
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); } }}>Save Current</button>
|
||||
<button onclick={() => { if (newProfileName.trim()) { saveProfile(newProfileName.trim()); newProfileName = ''; } }}>Save Current</button>
|
||||
</div>
|
||||
<div class="rule-list">
|
||||
{#each Object.entries($patchbay.profiles) as [name, profile]}
|
||||
<div class="rule-item">
|
||||
<span class:active-profile={name === $patchbay.active_profile}>{name} ({profile.connections.length} rules)</span>
|
||||
<button onclick={() => loadProfile(name)}>Load</button>
|
||||
<button onclick={() => saveProfile(name)} title="Overwrite with current connections">Update</button>
|
||||
<button onclick={() => deleteProfile(name)}>Delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
|
||||
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef } from './types';
|
||||
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
||||
|
||||
// Raw graph stores
|
||||
@@ -19,6 +19,8 @@ export const patchbay = writable<PatchbayState>({
|
||||
pinned_connections: [],
|
||||
hide_rules: [],
|
||||
merge_rules: [],
|
||||
aliases: {},
|
||||
virtual_nodes: [],
|
||||
});
|
||||
|
||||
// Port/node lookups
|
||||
@@ -82,7 +84,7 @@ export async function initGraph() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.profiles) {
|
||||
patchbay.set(data as PatchbayState);
|
||||
patchbay.set({ ...data, aliases: data.aliases ?? {}, virtual_nodes: data.virtual_nodes ?? [] } as PatchbayState);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -321,11 +323,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 = {
|
||||
name,
|
||||
connections,
|
||||
hide_rules: [...pb.hide_rules],
|
||||
merge_rules: [...pb.merge_rules],
|
||||
volumes,
|
||||
mutes,
|
||||
virtual_nodes: [...pb.virtual_nodes],
|
||||
};
|
||||
|
||||
patchbay.update(pb => ({
|
||||
@@ -336,12 +348,57 @@ export async function saveProfile(name: string) {
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export function loadProfile(name: string) {
|
||||
async function restoreVirtualNodes(defs: VirtualNodeDef[]) {
|
||||
if (!defs.length) return;
|
||||
const currentNodes = get_store_value(nodes);
|
||||
const existingNames = new Set(currentNodes.map(n => n.name));
|
||||
let created = false;
|
||||
for (const vn of defs) {
|
||||
if (!existingNames.has(vn.name)) {
|
||||
if (vn.type === 'null-sink') await createNullSink(vn.name);
|
||||
else if (vn.type === 'loopback') await createLoopback(vn.name);
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
// Give the graph time to settle after creation before applying connections
|
||||
if (created) await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
|
||||
async function applyProfileVolumes(profile: PatchbayProfile) {
|
||||
if (!profile.volumes && !profile.mutes) return;
|
||||
const currentNodes = get_store_value(nodes);
|
||||
|
||||
// Optimistically update store so sliders reflect new values immediately
|
||||
nodes.update(ns => ns.map(n => {
|
||||
const vol = profile.volumes?.[n.name];
|
||||
const mute = profile.mutes?.[n.name];
|
||||
if (vol !== undefined || mute !== undefined) {
|
||||
return { ...n, ...(vol !== undefined ? { volume: vol } : {}), ...(mute !== undefined ? { mute } : {}) };
|
||||
}
|
||||
return n;
|
||||
}));
|
||||
|
||||
for (const n of currentNodes) {
|
||||
if (profile.volumes?.[n.name] !== undefined) {
|
||||
await setNodeVolume(n.id, profile.volumes[n.name]);
|
||||
}
|
||||
if (profile.mutes?.[n.name] !== undefined) {
|
||||
await setNodeMute(n.id, profile.mutes[n.name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProfile(name: string) {
|
||||
patchbay.update(pb => ({ ...pb, active_profile: name }));
|
||||
const pb = get_store_value(patchbay);
|
||||
if (pb.activated) {
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
const profile = pb.profiles[name];
|
||||
if (!profile) { savePatchbayState(); return; }
|
||||
|
||||
// Recreate missing virtual nodes before restoring connections
|
||||
await restoreVirtualNodes(profile.virtual_nodes ?? []);
|
||||
|
||||
applyPatchbay(pb);
|
||||
applyProfileVolumes(profile);
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
@@ -358,6 +415,20 @@ export function deleteProfile(name: string) {
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
// Node aliases (custom display names, keyed by PW node name)
|
||||
export function setAlias(pwName: string, alias: string) {
|
||||
patchbay.update(pb => {
|
||||
const aliases = { ...pb.aliases };
|
||||
if (alias.trim()) {
|
||||
aliases[pwName] = alias.trim();
|
||||
} else {
|
||||
delete aliases[pwName]; // empty string = remove alias
|
||||
}
|
||||
return { ...pb, aliases };
|
||||
});
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
// Volume control
|
||||
export async function setNodeVolume(nodeId: number, volume: number) {
|
||||
try {
|
||||
@@ -392,7 +463,15 @@ export async function createNullSink(name: string): Promise<number | null> {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.module_id > 0 ? data.module_id : null;
|
||||
const moduleId = data.module_id > 0 ? data.module_id : null;
|
||||
if (moduleId) {
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
virtual_nodes: [...pb.virtual_nodes.filter(v => v.name !== name), { type: 'null-sink', name }],
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
return moduleId;
|
||||
} catch (e) {
|
||||
console.error('[api] create-null-sink failed:', e);
|
||||
return null;
|
||||
@@ -407,13 +486,29 @@ export async function createLoopback(name: string): Promise<number | null> {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.module_id > 0 ? data.module_id : null;
|
||||
const moduleId = data.module_id > 0 ? data.module_id : null;
|
||||
if (moduleId) {
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
virtual_nodes: [...pb.virtual_nodes.filter(v => v.name !== name), { type: 'loopback', name }],
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
return moduleId;
|
||||
} catch (e) {
|
||||
console.error('[api] create-loopback failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeVirtualNode(name: string) {
|
||||
patchbay.update(pb => ({
|
||||
...pb,
|
||||
virtual_nodes: pb.virtual_nodes.filter(v => v.name !== name),
|
||||
}));
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
export async function loadModule(module: string, args: string): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch('/api/load-module', {
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface DisconnectMessage {
|
||||
|
||||
export type WsMessage = ConnectMessage | DisconnectMessage;
|
||||
|
||||
// Virtual device definition (user-created null-sinks, loopbacks, etc.)
|
||||
export interface VirtualNodeDef {
|
||||
type: 'null-sink' | 'loopback';
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Patchbay types
|
||||
export interface ConnectionRule {
|
||||
output_port_name: string;
|
||||
@@ -73,6 +79,9 @@ export interface PatchbayProfile {
|
||||
connections: ConnectionRule[];
|
||||
hide_rules?: string[];
|
||||
merge_rules?: string[];
|
||||
volumes?: Record<string, number>; // PW node name → volume (0..1)
|
||||
mutes?: Record<string, boolean>; // PW node name → mute state
|
||||
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
|
||||
}
|
||||
|
||||
export interface PatchbayState {
|
||||
@@ -85,4 +94,6 @@ export interface PatchbayState {
|
||||
pinned_connections: number[];
|
||||
hide_rules: string[];
|
||||
merge_rules: string[];
|
||||
aliases: Record<string, string>; // PW node name → custom display name
|
||||
virtual_nodes: VirtualNodeDef[]; // user-created virtual devices (global registry)
|
||||
}
|
||||
|
||||
@@ -89,17 +89,12 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
|
||||
if (media_name && strlen(media_name) > 0)
|
||||
nobj->node.media_name = media_name;
|
||||
|
||||
// Read volume from props
|
||||
const char *vol_str = spa_dict_lookup(info->props, "volume");
|
||||
if (vol_str) {
|
||||
nobj->node.volume = pw_properties_parse_float(vol_str);
|
||||
}
|
||||
|
||||
// Read mute from props
|
||||
const char *mute_str = spa_dict_lookup(info->props, "mute");
|
||||
if (mute_str) {
|
||||
nobj->node.mute = pw_properties_parse_bool(mute_str);
|
||||
}
|
||||
// NOTE: volume/mute are intentionally NOT read from info->props here.
|
||||
// info->props contains static initial values and is NOT updated when
|
||||
// volume/mute change at runtime. Live state comes from SPA_PARAM_Props
|
||||
// via on_node_param (subscribed below in create_proxy_for_object).
|
||||
// Reading stale props here caused on_node_info to overwrite correct
|
||||
// runtime state back to the initial value (the unmute race condition).
|
||||
|
||||
// Read additional properties
|
||||
const char *str;
|
||||
@@ -170,9 +165,13 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
|
||||
|
||||
nobj->node.changed = true;
|
||||
nobj->node.ready = true;
|
||||
|
||||
// Notify so frontend reflects property changes (sample rate, media name, etc.)
|
||||
if (obj->engine_ref)
|
||||
obj->engine_ref->notifyChanged();
|
||||
}
|
||||
|
||||
// Parse audio format param (like pw-top does)
|
||||
// Parse audio format and Props params
|
||||
static void on_node_param(void *data, int seq,
|
||||
uint32_t id, uint32_t index, uint32_t next,
|
||||
const struct spa_pod *param)
|
||||
@@ -182,20 +181,59 @@ static void on_node_param(void *data, int seq,
|
||||
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
|
||||
|
||||
if (param == NULL) return;
|
||||
if (id != SPA_PARAM_Format) return;
|
||||
|
||||
uint32_t media_type, media_subtype;
|
||||
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
|
||||
if (id == SPA_PARAM_Format) {
|
||||
uint32_t media_type, media_subtype;
|
||||
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
|
||||
|
||||
if (media_type == SPA_MEDIA_TYPE_audio && media_subtype == SPA_MEDIA_SUBTYPE_raw) {
|
||||
struct spa_audio_info_raw info;
|
||||
spa_zero(info);
|
||||
if (spa_format_audio_raw_parse(param, &info) >= 0) {
|
||||
if (info.rate > 0) nobj->node.sample_rate = info.rate;
|
||||
if (info.channels > 0) nobj->node.channels = info.channels;
|
||||
nobj->node.format = spa_type_audio_format_to_short_name((uint32_t)info.format);
|
||||
nobj->node.changed = true;
|
||||
if (media_type == SPA_MEDIA_TYPE_audio && media_subtype == SPA_MEDIA_SUBTYPE_raw) {
|
||||
struct spa_audio_info_raw info;
|
||||
spa_zero(info);
|
||||
if (spa_format_audio_raw_parse(param, &info) >= 0) {
|
||||
if (info.rate > 0) nobj->node.sample_rate = info.rate;
|
||||
if (info.channels > 0) nobj->node.channels = info.channels;
|
||||
nobj->node.format = spa_type_audio_format_to_short_name((uint32_t)info.format);
|
||||
nobj->node.changed = true;
|
||||
}
|
||||
}
|
||||
} else if (id == SPA_PARAM_Props) {
|
||||
// Parse live volume/mute state from Props params.
|
||||
// This is the authoritative source — info->props only has initial/static values.
|
||||
const struct spa_pod_object *pobj = (const struct spa_pod_object *)param;
|
||||
struct spa_pod_prop *prop;
|
||||
SPA_POD_OBJECT_FOREACH(pobj, prop) {
|
||||
switch (prop->key) {
|
||||
case SPA_PROP_volume: {
|
||||
float vol;
|
||||
if (spa_pod_get_float(&prop->value, &vol) == 0)
|
||||
nobj->node.volume = vol;
|
||||
break;
|
||||
}
|
||||
case SPA_PROP_channelVolumes: {
|
||||
// Average channel volumes for display
|
||||
float vols[32];
|
||||
uint32_t n = spa_pod_copy_array(&prop->value, SPA_TYPE_Float, vols, 32);
|
||||
if (n > 0) {
|
||||
float avg = 0;
|
||||
for (uint32_t i = 0; i < n; i++) avg += vols[i];
|
||||
nobj->node.volume = avg / n;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SPA_PROP_mute: {
|
||||
bool m;
|
||||
if (spa_pod_get_bool(&prop->value, &m) == 0)
|
||||
nobj->node.mute = m;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
nobj->node.changed = true;
|
||||
// Broadcast live volume/mute changes from any source (browser, pulsemixer, etc.)
|
||||
if (obj->engine_ref)
|
||||
obj->engine_ref->notifyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,15 +340,16 @@ static void create_proxy_for_object(GraphEngine::Object *obj, GraphEngine *engin
|
||||
obj->proxy = proxy;
|
||||
obj->destroy_info = destroy_info;
|
||||
obj->pending_seq = 0;
|
||||
obj->engine_ref = engine;
|
||||
pw_proxy_add_object_listener(proxy,
|
||||
&obj->object_listener, events, obj);
|
||||
pw_proxy_add_listener(proxy,
|
||||
&obj->proxy_listener, &proxy_events, obj);
|
||||
|
||||
// Subscribe to format params for nodes (like pw-top)
|
||||
// Subscribe to Format + Props params for nodes
|
||||
if (obj->type == GraphEngine::Object::ObjNode) {
|
||||
uint32_t ids[1] = { SPA_PARAM_Format };
|
||||
pw_node_subscribe_params((pw_node*)proxy, ids, 1);
|
||||
uint32_t ids[2] = { SPA_PARAM_Format, SPA_PARAM_Props };
|
||||
pw_node_subscribe_params((pw_node*)proxy, ids, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,7 +543,11 @@ static void on_registry_global(void *data,
|
||||
if ((node_obj->node.mode2 & port_mode) == PortMode::None)
|
||||
node_obj->node.mode2 = PortMode::Duplex;
|
||||
|
||||
node_obj->node.port_ids.push_back(id);
|
||||
// Avoid duplicate port IDs in node
|
||||
auto &pids = node_obj->node.port_ids;
|
||||
if (std::find(pids.begin(), pids.end(), id) == pids.end()) {
|
||||
pids.push_back(id);
|
||||
}
|
||||
node_obj->node.changed = true;
|
||||
|
||||
engine->addObject(id, pobj);
|
||||
@@ -556,7 +599,7 @@ static const struct pw_registry_events registry_events = {
|
||||
|
||||
GraphEngine::Object::Object(uint32_t id, Type type)
|
||||
: id(id), type(type), proxy(nullptr), info(nullptr),
|
||||
destroy_info(nullptr), pending_seq(0)
|
||||
destroy_info(nullptr), pending_seq(0), engine_ref(nullptr)
|
||||
{
|
||||
spa_zero(proxy_listener);
|
||||
spa_zero(object_listener);
|
||||
@@ -708,6 +751,15 @@ void GraphEngine::notifyChanged() {
|
||||
// ============================================================================
|
||||
|
||||
void GraphEngine::addObject(uint32_t id, Object *obj) {
|
||||
// Remove existing object with same ID if any (prevents duplicates)
|
||||
auto it = m_objects_by_id.find(id);
|
||||
if (it != m_objects_by_id.end()) {
|
||||
Object *old = it->second;
|
||||
auto vit = std::find(m_objects.begin(), m_objects.end(), old);
|
||||
if (vit != m_objects.end())
|
||||
m_objects.erase(vit);
|
||||
delete old;
|
||||
}
|
||||
m_objects_by_id[id] = obj;
|
||||
m_objects.push_back(obj);
|
||||
}
|
||||
@@ -910,11 +962,19 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
|
||||
|
||||
NodeObj *nobj = findNode(node_id);
|
||||
if (!nobj || !nobj->proxy) {
|
||||
fprintf(stderr, "pwweb: setNodeVolume: node %u not found or no proxy\n", node_id);
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build Props param with volume
|
||||
// Build Props param with both SPA_PROP_volume and SPA_PROP_channelVolumes.
|
||||
// Hardware ALSA sinks respond to SPA_PROP_volume; browser/app streams use
|
||||
// SPA_PROP_channelVolumes (per-channel). Setting both covers all node types.
|
||||
uint32_t n_ch = (nobj->node.channels > 0 && nobj->node.channels <= 32)
|
||||
? nobj->node.channels : 2;
|
||||
float ch_vols[32];
|
||||
for (uint32_t i = 0; i < n_ch; i++) ch_vols[i] = volume;
|
||||
|
||||
uint8_t buf[1024];
|
||||
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
||||
struct spa_pod_frame f;
|
||||
@@ -922,16 +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_prop(&b, SPA_PROP_volume, 0);
|
||||
spa_pod_builder_float(&b, volume);
|
||||
spa_pod_builder_prop(&b, SPA_PROP_channelVolumes, 0);
|
||||
spa_pod_builder_array(&b, sizeof(float), SPA_TYPE_Float, n_ch, ch_vols);
|
||||
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f);
|
||||
|
||||
pw_node_set_param((pw_node*)nobj->proxy,
|
||||
int res = pw_node_set_param((pw_node*)nobj->proxy,
|
||||
SPA_PARAM_Props, 0, param);
|
||||
|
||||
fprintf(stderr, "pwweb: setNodeVolume node=%u vol=%.2f res=%d name=%s\n",
|
||||
node_id, volume, res, nobj->node.name.c_str());
|
||||
|
||||
nobj->node.volume = volume;
|
||||
nobj->node.changed = true;
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return true;
|
||||
return (res >= 0);
|
||||
}
|
||||
|
||||
bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
||||
@@ -941,6 +1006,7 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
||||
|
||||
NodeObj *nobj = findNode(node_id);
|
||||
if (!nobj || !nobj->proxy) {
|
||||
fprintf(stderr, "pwweb: setNodeMute: node %u not found or no proxy\n", node_id);
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return false;
|
||||
}
|
||||
@@ -954,14 +1020,17 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
||||
spa_pod_builder_bool(&b, mute);
|
||||
struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f);
|
||||
|
||||
pw_node_set_param((pw_node*)nobj->proxy,
|
||||
int res = pw_node_set_param((pw_node*)nobj->proxy,
|
||||
SPA_PARAM_Props, 0, param);
|
||||
|
||||
fprintf(stderr, "pwweb: setNodeMute node=%u mute=%d res=%d name=%s\n",
|
||||
node_id, mute, res, nobj->node.name.c_str());
|
||||
|
||||
nobj->node.mute = mute;
|
||||
nobj->node.changed = true;
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return true;
|
||||
return (res >= 0);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -72,15 +72,16 @@ public:
|
||||
// Object management (called from C callbacks)
|
||||
struct Object {
|
||||
enum Type { ObjNode, ObjPort, ObjLink };
|
||||
uint32_t id;
|
||||
Type type;
|
||||
void *proxy;
|
||||
void *info;
|
||||
void (*destroy_info)(void*);
|
||||
spa_hook proxy_listener;
|
||||
spa_hook object_listener;
|
||||
int pending_seq;
|
||||
spa_list pending_link;
|
||||
uint32_t id;
|
||||
Type type;
|
||||
void *proxy;
|
||||
void *info;
|
||||
void (*destroy_info)(void*);
|
||||
spa_hook proxy_listener;
|
||||
spa_hook object_listener;
|
||||
int pending_seq;
|
||||
spa_list pending_link;
|
||||
GraphEngine *engine_ref; // back-pointer set in create_proxy_for_object
|
||||
|
||||
Object(uint32_t id, Type type);
|
||||
virtual ~Object();
|
||||
|
||||
@@ -442,7 +442,9 @@ void WebServer::setupRoutes() {
|
||||
if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 ||
|
||||
sscanf(req.body.c_str(), "{\"node_id\": %u", &node_id) == 1)
|
||||
{
|
||||
bool mute = req.body.find("true") != std::string::npos;
|
||||
// Check for "mute":true precisely — the old `find("true")` was imprecise
|
||||
bool mute = req.body.find("\"mute\":true") != std::string::npos ||
|
||||
req.body.find("\"mute\": true") != std::string::npos;
|
||||
bool ok = m_engine.setNodeMute(node_id, mute);
|
||||
if (ok) broadcastGraph();
|
||||
res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json");
|
||||
|
||||
Reference in New Issue
Block a user