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:
@@ -796,13 +796,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}
|
||||
|
||||
@@ -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) {
|
||||
// Always apply connections when explicitly loading a profile
|
||||
applyPatchbay(pb);
|
||||
}
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +177,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 +192,42 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
&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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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