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>
This commit is contained in:
joren
2026-03-30 12:20:22 +02:00
parent 3609a50dd2
commit b3c81623f1
4 changed files with 90 additions and 36 deletions

View File

@@ -796,13 +796,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}

View File

@@ -339,9 +339,8 @@ export async function saveProfile(name: string) {
export function loadProfile(name: string) { export 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) { // Always apply connections when explicitly loading a profile
applyPatchbay(pb); applyPatchbay(pb);
}
savePatchbayState(); savePatchbayState();
} }

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) 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;
@@ -172,7 +167,7 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
nobj->node.ready = true; 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, 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,8 +177,8 @@ 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;
if (id == SPA_PARAM_Format) {
uint32_t media_type, media_subtype; uint32_t media_type, media_subtype;
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return; if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
@@ -197,6 +192,42 @@ static void on_node_param(void *data, int seq,
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;
}
} }
static const struct pw_node_events node_events = { static const struct pw_node_events node_events = {
@@ -307,10 +338,10 @@ static void create_proxy_for_object(GraphEngine::Object *obj, GraphEngine *engin
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 +535,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);
@@ -708,6 +743,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,6 +954,7 @@ 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;
} }
@@ -924,14 +969,17 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) {
spa_pod_builder_float(&b, volume); spa_pod_builder_float(&b, volume);
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 +989,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 +1003,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);
} }
// ============================================================================ // ============================================================================

View File

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