From b3c81623f112b66abdd9e3b8d6444c98e57d6541 Mon Sep 17 00:00:00 2001 From: joren Date: Mon, 30 Mar 2026 12:20:22 +0200 Subject: [PATCH 1/2] fix: mute race condition + profile loading + add Update button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/GraphCanvas.svelte | 3 +- frontend/src/lib/stores.ts | 5 +- src/graph_engine.cpp | 114 +++++++++++++++------ src/web_server.cpp | 4 +- 4 files changed, 90 insertions(+), 36 deletions(-) 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..cb69f36 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; @@ -172,7 +167,7 @@ static void on_node_info(void *data, const struct pw_node_info *info) { nobj->node.ready = true; } -// 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 +177,56 @@ 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; } } @@ -307,10 +338,10 @@ static void create_proxy_for_object(GraphEngine::Object *obj, GraphEngine *engin 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 +535,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); @@ -708,6 +743,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,6 +954,7 @@ 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; } @@ -924,14 +969,17 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) { spa_pod_builder_float(&b, volume); 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 +989,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 +1003,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/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"); From 0d3cfb5f8640fe1dd318fbef6e1c49a4a432f801 Mon Sep 17 00:00:00 2001 From: joren Date: Mon, 30 Mar 2026 12:30:56 +0200 Subject: [PATCH 2/2] fix: broadcast external volume/mute changes + fix browser stream volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/graph_engine.cpp | 21 +++++++++++++++++++-- src/graph_engine.h | 19 ++++++++++--------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index cb69f36..2b78c7c 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -165,6 +165,10 @@ 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 and Props params @@ -227,6 +231,9 @@ static void on_node_param(void *data, int seq, } } nobj->node.changed = true; + // Broadcast live volume/mute changes from any source (browser, pulsemixer, etc.) + if (obj->engine_ref) + obj->engine_ref->notifyChanged(); } } @@ -333,6 +340,7 @@ 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, @@ -591,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); @@ -959,7 +967,14 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) { 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; @@ -967,6 +982,8 @@ 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); int res = pw_node_set_param((pw_node*)nobj->proxy, 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();