diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte
index 85532cb..2f9cbcd 100644
--- a/frontend/src/components/GraphCanvas.svelte
+++ b/frontend/src/components/GraphCanvas.svelte
@@ -796,13 +796,14 @@
-
+
{#each Object.entries($patchbay.profiles) as [name, profile]}
{name} ({profile.connections.length} rules)
+
{/each}
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts
index 02469d3..2247f68 100644
--- a/frontend/src/lib/stores.ts
+++ b/frontend/src/lib/stores.ts
@@ -339,9 +339,8 @@ export async function saveProfile(name: string) {
export function loadProfile(name: string) {
patchbay.update(pb => ({ ...pb, active_profile: name }));
const pb = get_store_value(patchbay);
- if (pb.activated) {
- applyPatchbay(pb);
- }
+ // Always apply connections when explicitly loading a profile
+ applyPatchbay(pb);
savePatchbayState();
}
diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp
index 17c5ecb..2b78c7c 100644
--- a/src/graph_engine.cpp
+++ b/src/graph_engine.cpp
@@ -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
(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);
}
// ============================================================================
diff --git a/src/graph_engine.h b/src/graph_engine.h
index 6d5d842..aaf2bcc 100644
--- a/src/graph_engine.h
+++ b/src/graph_engine.h
@@ -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();
diff --git a/src/web_server.cpp b/src/web_server.cpp
index 5851de2..6a13341 100644
--- a/src/web_server.cpp
+++ b/src/web_server.cpp
@@ -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");