Application with multiple nodes only control a random one #18
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/nanoKontroller2#18
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Firefox often shows up three or four times in
pw-dumpand in Plasma's tray widget, depending onwhich tabs currently claim to send data. A strip targeting
Firefoxshould control every streammatching that name — they all carry the same
node.name— but it controls one at most, sometimesnone, and which one changes over time.
The cause is that the matching layer and the addressing layer disagree about what identifies a node.
AudioServicecaches nodes instd::unordered_map<std::string, WpNode*>keyed bynode.name(
includes/AudioService.h:58), and PipeWire node names are not unique:on_node_addedstores the node only when the name is absent, andconnects the
params-changed/ports-changedhandlers inside that same guard(
src/AudioService.cpp:446-450). Firefox nodes two, three and four pass themedia.classfilterand are then dropped, subscriptions and all.
GetMatchingNodesiterates the cache and returns names(
src/AudioService.cpp:88-116), so identically named streams collapse to one entry whatever theconfigured pattern is.
Action_Volume::on_midiloops overMatchedNodes(), butSetVolume/SetMute/GetMute/SetDefaultNode/IsDefaultNodeall doNodes.find(nodeName)andact on the one cached proxy (
src/AudioService.cpp:118-204).on_node_removeddoesNodes.erase(NodeName)without checking whichobject is leaving (
src/AudioService.cpp:475). One tab ending deletes the slot while three liveFirefox nodes remain, and those survivors are never re-added because
object-addedalready firedfor them. That is the "sometimes none", and which object owns the slot at any moment is decided by
whichever add happened to arrive while the slot was free — the "it changes".
So the fan-out contract the README promises ("a press mutes or unmutes every matched node") holds
only for targets whose matches happen to have distinct
node.namevalues: invisible for sinks,total for application streams.
Separately, Plasma's second label line — the tab title — has no representation in the config at all.
Every call site reads exactly one property,
wp_pipewire_object_get_property(…, "node.name")(
src/AudioService.cpp:433,:469,:490,:508), andNodeMatchermatches a single opaquestring with no notion of which field it came from.
Plan
1.
NodeMatchermatches a property bag, with a second candidate stringIn
includes/NodeMatcher.h, above the class, add the bag and the separator. Keep both here (not inAudioService.h): the unit-test target compilesNodeMatcher.cppandAppSettings.cpponly andlinks no WirePlumber (
CMakeLists.txt:69-74), so the matcher must be exercisable without aWpNode.Change both query methods to take the bag —
FirstMatchingEntry(const NodeProperties&)andMatches(const NodeProperties&); there is no string overload left, so every caller is forced tosupply properties. In
src/NodeMatcher.cpp, match each entry against two candidates, in order:props.NodeName;props.NodeName + ": " + props.MediaName, formed only whenprops.MediaNameis non-empty.Build the concatenation once before the entry loop, not per entry. Match with iterators
(
std::regex_match(sv.data(), sv.data() + sv.size(), m_Regexes[i])) so nostd::stringisallocated for the bare-name candidate. Compilation and anchoring are unchanged: both candidates are
matched against the same
^(?:pattern)$regex, so a plain name still matches only itself.Entry ranking does not change and does not record which candidate matched:
FirstMatchingEntryreturns the index of the first entry that matched on either candidate. A node whose
media.nameis absent simply has one candidate.
Update the README's "Specifying nodes" section in the same change: a pattern is matched against
node.nameand, when the node has amedia.name, againstnode.name: media.name, so a patternbroad enough to match arbitrary text (
.*, or a negative-lookahead "everything else" pattern) nowalso matches via the second candidate and exclusion lists must account for that.
No config syntax is added. Selecting a specific property per entry is deliberately left out; the bag
carries only the two fields that feed the candidates, and a third field is a one-line addition when
a syntax issue needs it.
2. Key the node cache by PipeWire bound id
In
includes/AudioService.h, replace the name-keyed map with:Add a file-local helper in
src/AudioService.cppthat builds a bag from a proxy, used by everymatch site:
PW_KEY_NODE_NAMEandPW_KEY_MEDIA_NAMEcome frompipewire-0.3/pipewire/keys.h, alreadyincluded (
src/AudioService.cpp:13) and already used forPW_KEY_MEDIA_CLASS.GetMatchingNodesreturnsstd::vector<guint32>. Its ranking is unchanged in intent — configuredentry index, then ascending bound id — but
RankedNodenow holds the id directly and the match runson
MakeNodeProperties(WP_PIPEWIRE_OBJECT(Entry.Node.get())). Update the header comment: it returnsids, not names, and
front()is still the representative.3. Address nodes by id throughout
AudioServiceChange the actuator signatures in
includes/AudioService.hand their bodies insrc/AudioService.cppfromconst std::string& nodeNametoguint32 nodeId:GetMute,SetVolume,SetMute,SetDefaultNode,IsDefaultNode,GetConnectionState(guint32 leftNode, guint32 rightNode),SetConnectionState(guint32 leftNode, guint32 rightNode, bool bConnect). Each body replacesNodes.find(nodeName)withNodes.find(nodeId)and usesItr->second.Node.get(); everywp_proxy_get_bound_id(WP_PROXY(Itr->second.get()))that onlyrecomputed the key is deleted and the parameter used instead.
Two places genuinely need the name, so add one accessor rather than caching strings:
SetDefaultNoderesolves the name itself forset-default-configured-node-name(
src/AudioService.cpp:187) and returnsfalsewhen the node has nonode.name.SetConnectionState'sLOG_DEBUG(:229) prints both ids and names.Both link handlers (
on_link_added:372-377,on_link_removed:409-414) lose their O(n) scanover the cache:
wp_link_get_linked_object_idsalready yields bound ids, so look both up withNodes.find. Dispatch only when both endpoints are cached, as today, and match withSub.Left.Matches(MakeNodeProperties(...))/Sub.Right.Matches(...)built from the two cachedproxies.
4. Node events carry ids, and membership is re-evaluated when properties change
Change the listener callbacks in
includes/Actions/IAction.hto pass ids:Document on
on_node_removedthat it also fires when a node is still alive but has stopped matchingthe listener's target, and that the state the action applied to it is deliberately left in place.
Add one private method and one handler to
AudioService:DispatchMatchChangesbuilds the bag once, walksNodeListenersto build the new matched vector,diffs it against
CachedNode::MatchedListeners(linear scans; a node is matched by a handful oflisteners at most), stores the new vector before invoking callbacks — an action may call back into
AudioServicefrom them — and then callson_node_available(node, nodeId)for additions andon_node_removed(node, nodeId)for removals.Rewrite the four node handlers:
on_node_added: keep thenode.namerequirement and themedia.classcontainsAudiofilter(
:433-439). ComputeBoundId. IfNodes.contains(BoundId), return — same double-add guard thelink path already uses (
:362). Otherwise insert aCachedNodewith an empty listener vector andconnect three handlers to the node object:
params-changed,ports-changed, andnotify::properties→on_node_properties_changed. Then callDispatchMatchChanges, whichdelivers the initial
on_node_availableto every matching listener. Extend the existingLOG_DEBUGto print the bound id alongside the name; "which Firefox is this" is the question thisbug makes unanswerable.
on_node_removed: look up by bound id. Copy the matched listener vector,g_signal_handlers_ disconnect_by_data(Object, Service)so a node that outlives the cache entry cannot call back intothe service, erase the entry, then call
on_node_removed(node, BoundId)on the copied listeners.Dispatch from the bookkeeping, not from a fresh match: the two must stay symmetric, and a node
whose properties changed concurrently must not silently skip its removal callback.
on_node_params_changedandon_node_ports_changed: dispatch toCachedNode::MatchedListenersinstead of re-matching every subscription. These are
Props/port events, not membership changes.on_node_properties_changed: resolve the service and the node, then callDispatchMatchChanges.This is the path that makes
node.name: media.namematching live — a tab retitling into astrip's pattern gets that strip's sticky mute/volume pushed onto it immediately, and one
retitling out of it stops being written to on the next press.
RegisterNodeListenerkeeps its current semantics: a listener registered after nodes are alreadycached receives no retroactive
on_node_available. Nothing needs it —main.cpp:136-148buildsevery action before
AudioService::Initinstalls the object manager, and the reconnect path(
main.cpp:54-62) callsReset()thenInit(), which replaysobject-addedfor everything.Reset()keepsNodes.clear(); theCachedNodedestructor unrefs the proxy as before.5. Actions consume ids
src/Actions/IAction.cpp:MatchedNodes()returnsstd::vector<guint32>andRepresentativeNode()returns
std::optional<guint32>; update the declarations and comments inincludes/Actions/IAction.h.src/Actions/Action_Volume.cpp,includes/Actions/Action_Volume.h): theon_midiloop and
on_node_availablecallSetVolume(Id, …).Action_Mute.cpp/.h):MatchedNodes()yields ids for theSetMuteloop;RefreshStatereads
GetMute(*Representative); the three overrides takeguint32 nodeId.Action_SetDefault.cpp/.h):SetDefaultNode(*Representative); the failure messageat
:34-35printsm_audioService->GetNodeName(*Representative).Action_Connection.cpp/.h):Pairs()returnsstd::vector<std::pair<guint32, guint32>>; the self-link guard compares ids instead of names,which also fixes the case where two different nodes sharing a name were skipped;
RefreshState,EnforceDesired,on_midiand all five overrides take ids.Init_Internal's combinedleft+right watch matcher (
:126-131) is unchanged — it is built from pattern strings, not names.With ids in hand,
MatchedNodes()for a target ofFirefoxnow yields one entry per live Firefoxstream, and each
SetVolume/SetMutelands on that specific node.Verification
Prerequisites: the project's declared toolchain and dev packages (
mise.toml[bootstrap.packages],including
libwireplumber-0.5-dev,libglaze-dev,libcxxopts-dev,libconcurrentqueue-dev,libmagicenum-dev) installed; commands run from the repository root.1. Unit tests (step 1). Extend
tests/NodeMatcher_tests.cpp; its existingMatches("name")call sites become
Matches(NodeProperties{"name", ""}). Add aTestPropertyCandidates()registeredin
main()covering, withNodeMatcher{{"Firefox"}}andNodeMatcher{{"Firefox: .*YouTube.*"}}:{"Firefox", ""}Firefox{"Firefox", "Lofi girl - YouTube"}Firefox{"Firefox", "Lofi girl - YouTube"}Firefox: .*YouTube.*{"Firefox", "Some blog"}Firefox: .*YouTube.*{"Firefox", ""}Firefox: .*{"Firefox", "x"}irefoxAlso assert
FirstMatchingEntry(NodeProperties{"Firefox", "x - YouTube"})onNodeMatcher{{"Spotify", "Firefox: .*YouTube.*", "Firefox"}}returns1, i.e. a concatenated matchranks by its entry position like any other.
2. Fan-out on duplicate names (steps 2-5, the reported bug). Needs the nanoKONTROL2 attached and
a config whose
Fader3/Mute3targetFirefox. Play audio in three Firefox tabs and confirmPipeWire really shows several identically named nodes:
Run the debug build (
LOG_DEBUGis compiled in) with./build/nanoKontroller2 -c config.json.Expected, each checked by re-running the
pw-dumpcommand above orwpctl status:Node addedline per Firefox id, not one in total;moves them. This is the exact regression that previously left the strip dead;
3. Title matching and live re-evaluation (steps 1 and 4). Temporarily point a spare fader at
Firefox: .*YouTube.*. With one YouTube tab and one non-YouTube tab both producing audio, movingthat fader must change only the YouTube node's volume. Then switch the YouTube tab to another video
whose title still contains
YouTube(node keeps matching, volume stays) and navigate the othertab to a YouTube page: without touching the fader, that node must be picked up by the strip — a
Node added-freenotify::propertiespath — and receive the strip's sticky volume/mute.Assumptions & contingencies
notify::propertiesfires on aWpNodewhen PipeWire reports a props change: verified inWirePlumber
0.5.8(lib/wp/private/pipewire-object-mixin.c:788,:961) and on upstreammaster. If the installed WirePlumber turns out not to emit it formedia.namechanges inpractice, keep every other step and drop step 4's
on_node_properties_changedwiring: matchingstill runs on every
GetMatchingNodescall, so membership stays correct at each press and fadermove, and only the sticky-state push for retitled nodes is lost.
was removed is treated as a new node, which is correct because the cache entry is erased on
removal. If a
Node addedever arrives for an id already cached, the double-add guard keeps thefirst entry, matching the existing link behaviour.
Analysis
There are two separate things in this report: a genuine defect (fan-out silently collapses to one node) and a missing capability (only one PipeWire property is ever matched). They have different causes and only the first one explains the "random/none" behaviour.
1. Why only one Firefox node is controlled, and why it moves
The matching layer and the addressing layer disagree about what identifies a node.
NodeMatchermatches strings, andAudioServicecaches nodes instd::unordered_map<std::string, WpNode*> Nodeskeyed bynode.name(includes/AudioService.h:58,src/AudioService.cpp:446). PipeWire node names are not unique — a per-stream node name likeFirefoxis shared by every stream the application opens — but the cache treats the name as a primary key. One name, one slot. Everything downstream inherits that:on_node_addedonly stores the node when the name is not present yet, and theparams-changed/ports-changedhandlers are connected inside that same guard (src/AudioService.cpp:446-450). Firefox nodes two, three and four reach the callback, pass themedia.classcontainsAudiofilter, and are then dropped on the floor — including their signal subscriptions.GetMatchingNodesiteratesNodesand returns names (src/AudioService.cpp:87-114). With all streams calledFirefoxit returns a one-element vector no matter how many streams exist. The careful ranking in that function (entry index, then bound id, "front() is the representative") only ever ranks the survivors of the collapse.Action_Volume::on_mididoes loop overMatchedNodes()and callsSetVolume(Name, …)per entry (src/Actions/Action_Volume.cpp:17-20), butSetVolume/SetMute/GetMute/SetDefaultNode/IsDefaultNodeall doNodes.find(nodeName)and act on the single cached proxy. So even the dispatch inon_node_available, which is delivered for every matching node including the uncached duplicates, writes the volume onto the one cached object instead of the node that just appeared.This is what makes it one at most. The "sometimes none" and "it changes" parts come from removal:
on_node_removeddoesNodes.erase(NodeName)(src/AudioService.cpp:475) — by name, with no check that the object leaving is the object stored under that name. A tab that stops claiming to send data evicts the cache entry even when three other Firefox nodes are still alive. Those survivors will never be re-added:object-addedalready fired for them and will not fire again. The strip then controls nothing called Firefox until some Firefox stream appears afresh.So the fan-out semantics the README promises ("A press mutes or unmutes every matched node") hold only for targets whose matched nodes happen to have distinct
node.namevalues. The multi-match work from #3 / #13 built list and regex matching on top of a name-keyed cache that predates it; the collapse is invisible for sinks (unique names) and total for application streams. The LED side has the same root:RepresentativeNode()returns a name, andAction_Mutereads its state back through the same single-slot lookup, so the LED reports a node that may no longer be the one your fader moved.2. Why only
node.nameis matchableIndependently of the above, every call site reads exactly one property:
wp_pipewire_object_get_property(…, "node.name")inon_node_added,on_node_removed,on_node_params_changedandon_node_ports_changed.application.nameandmedia.nameare never read, andNodeMatcherhas no notion of which field a pattern applies to — its API isMatches(const std::string& nodeName), a single opaque string. That is why what Plasma shows as a two-line label (application plus stream/tab title) has no representation in the config at all.Worth noting that the two issues are coupled in one direction: matching on
media.namewould let you distinguish Firefox tabs, but as long as the cache key and the whole action-facing API (SetVolume(name),GetMute(name),RepresentativeNode() -> string) are a node name, any selector that resolves to several live nodes keeps collapsing to one. Conversely,media.nameis a moving target by nature — it tracks the tab title and changes while the node lives — so it is a matching key with different stability properties thannode.name, which the current add-time-only matching does not account for (matching happens onobject-added; a node whose matched property changes later is never re-evaluated).Related
Questions
pw-dump, do the Firefox nodes differ inobject.serial/node.idonly, or does any of them carry a distinctnode.name(e.g. a separate WebRTC or decoder node)? This decides whether the collapse is total for Firefox or whether you are seeing a partial one.Firefoxas a plain entry, as a regex, or through aFriendlyNamesalias such asplayers? Entry-index ranking interacts with the collapse, and an alias list would put a second collapsing name in play.