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
This commit is contained in:
joren
2026-03-30 12:33:06 +02:00
5 changed files with 119 additions and 47 deletions

View File

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

View File

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

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