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:
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -170,9 +165,13 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
|
|||||||
|
|
||||||
nobj->node.changed = true;
|
nobj->node.changed = true;
|
||||||
nobj->node.ready = 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,
|
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,20 +181,59 @@ 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;
|
|
||||||
|
|
||||||
uint32_t media_type, media_subtype;
|
if (id == SPA_PARAM_Format) {
|
||||||
if (spa_format_parse(param, &media_type, &media_subtype) < 0) return;
|
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) {
|
if (media_type == SPA_MEDIA_TYPE_audio && media_subtype == SPA_MEDIA_SUBTYPE_raw) {
|
||||||
struct spa_audio_info_raw info;
|
struct spa_audio_info_raw info;
|
||||||
spa_zero(info);
|
spa_zero(info);
|
||||||
if (spa_format_audio_raw_parse(param, &info) >= 0) {
|
if (spa_format_audio_raw_parse(param, &info) >= 0) {
|
||||||
if (info.rate > 0) nobj->node.sample_rate = info.rate;
|
if (info.rate > 0) nobj->node.sample_rate = info.rate;
|
||||||
if (info.channels > 0) nobj->node.channels = info.channels;
|
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.format = spa_type_audio_format_to_short_name((uint32_t)info.format);
|
||||||
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;
|
||||||
|
// 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->proxy = proxy;
|
||||||
obj->destroy_info = destroy_info;
|
obj->destroy_info = destroy_info;
|
||||||
obj->pending_seq = 0;
|
obj->pending_seq = 0;
|
||||||
|
obj->engine_ref = engine;
|
||||||
pw_proxy_add_object_listener(proxy,
|
pw_proxy_add_object_listener(proxy,
|
||||||
&obj->object_listener, events, obj);
|
&obj->object_listener, events, obj);
|
||||||
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 +543,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);
|
||||||
@@ -556,7 +599,7 @@ static const struct pw_registry_events registry_events = {
|
|||||||
|
|
||||||
GraphEngine::Object::Object(uint32_t id, Type type)
|
GraphEngine::Object::Object(uint32_t id, Type type)
|
||||||
: id(id), type(type), proxy(nullptr), info(nullptr),
|
: 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(proxy_listener);
|
||||||
spa_zero(object_listener);
|
spa_zero(object_listener);
|
||||||
@@ -708,6 +751,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,11 +962,19 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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];
|
uint8_t buf[1024];
|
||||||
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf));
|
||||||
struct spa_pod_frame f;
|
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_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props);
|
||||||
spa_pod_builder_prop(&b, SPA_PROP_volume, 0);
|
spa_pod_builder_prop(&b, SPA_PROP_volume, 0);
|
||||||
spa_pod_builder_float(&b, volume);
|
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);
|
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 +1006,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 +1020,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -72,15 +72,16 @@ public:
|
|||||||
// Object management (called from C callbacks)
|
// Object management (called from C callbacks)
|
||||||
struct Object {
|
struct Object {
|
||||||
enum Type { ObjNode, ObjPort, ObjLink };
|
enum Type { ObjNode, ObjPort, ObjLink };
|
||||||
uint32_t id;
|
uint32_t id;
|
||||||
Type type;
|
Type type;
|
||||||
void *proxy;
|
void *proxy;
|
||||||
void *info;
|
void *info;
|
||||||
void (*destroy_info)(void*);
|
void (*destroy_info)(void*);
|
||||||
spa_hook proxy_listener;
|
spa_hook proxy_listener;
|
||||||
spa_hook object_listener;
|
spa_hook object_listener;
|
||||||
int pending_seq;
|
int pending_seq;
|
||||||
spa_list pending_link;
|
spa_list pending_link;
|
||||||
|
GraphEngine *engine_ref; // back-pointer set in create_proxy_for_object
|
||||||
|
|
||||||
Object(uint32_t id, Type type);
|
Object(uint32_t id, Type type);
|
||||||
virtual ~Object();
|
virtual ~Object();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user