19 Commits

Author SHA1 Message Date
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
6 changed files with 287 additions and 61 deletions

View File

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

View File

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

View File

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

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)
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,8 +181,8 @@ 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;
if (id == SPA_PARAM_Format) {
uint32_t media_type, media_subtype;
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
@@ -197,6 +196,45 @@ static void on_node_param(void *data, int seq,
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();
}
}
static const struct pw_node_events node_events = {
@@ -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);
}
// ============================================================================

View File

@@ -81,6 +81,7 @@ public:
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();

View File

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