fix(audio): identify nodes by PipeWire id, match media.name #22

Merged
lucas merged 1 commit from agent/issue-18-node-id-identity into main 2026-11-13 14:07:26 +01:00
Collaborator

Closes: #18

What changed

Node identity is the PipeWire bound id. AudioService cached nodes in a map keyed by node.name, which PipeWire does not guarantee to be unique. Everything downstream inherited that: insertion was first-come-wins (siblings were dropped, subscriptions and all), GetMatchingNodes returned names so identically named streams collapsed to one entry, every actuator re-resolved by name onto that one proxy, and on_node_removed erased by name, so one tab ending killed the slot while live siblings remained unreachable. The cache is now unordered_map<guint32, CachedNode>, GetMatchingNodes returns ids, and GetMute/SetVolume/SetMute/SetDefaultNode/IsDefaultNode/Get|SetConnectionState plus all listener callbacks take ids. AudioService::GetNodeName(guint32) covers the two places that genuinely need a name (the default-node API, which addresses by name, and the connection log line).

Patterns match a property bag. NodeMatcher::Matches / FirstMatchingEntry take a NodeProperties{NodeName, MediaName} and try two candidates per entry: the bare node.name, and node.name: media.name when a media.name exists. Both go through the same anchored regex, so a plain name still matches only itself. Entry ranking is unchanged and does not record which candidate matched.

Membership is re-evaluated while a node lives. Each CachedNode records the listeners that matched it; notify::properties triggers DispatchMatchChanges, which diffs the new matched set against the stored one and emits on_node_available for additions and on_node_removed for removals. A tab retitling into a strip's pattern gets that strip's sticky mute/volume immediately; one retitling out of it stops being written to, and the state it was left in stays. params-changed and ports-changed dispatch to the recorded listeners instead of re-matching, since they are not membership changes.

Also: both link handlers lost their O(n) scan over the cache (wp_link_get_linked_object_ids already yields ids), Action_Connection's self-link guard compares ids, so two different nodes sharing a name are now a valid pair, and Reset() disconnects node handlers before clearing the cache so a surviving proxy cannot call back into the service.

README's "Specifying nodes" section documents the second candidate, including the consequence that a pattern broad enough to match arbitrary text now also matches through node.name: media.name, so exclusion lists must account for it.

Verification

  • tests/NodeMatcher_tests.cpp call sites moved to property bags, and a new TestPropertyCandidates covers the candidate table from the plan (bare name wins over an irrelevant media.name; qualified pattern matches only the right title; no qualified candidate without a media.name; anchoring holds on both candidates) plus the ranking assertion that a qualified entry wins over a later bare-name entry. CI runs them.
  • The matching logic was additionally exercised locally against a throwaway harness linking src/NodeMatcher.cpp (all checks passed). This container has no WirePlumber/glaze dev packages and no root, so the full build and the hardware-dependent checks in the plan's verification section (steps 2 and 3: fan-out across several live Firefox nodes, live re-evaluation on retitling) could not be run here — they need the controller and a PipeWire session.
Closes: #18 ## What changed **Node identity is the PipeWire bound id.** `AudioService` cached nodes in a map keyed by `node.name`, which PipeWire does not guarantee to be unique. Everything downstream inherited that: insertion was first-come-wins (siblings were dropped, subscriptions and all), `GetMatchingNodes` returned names so identically named streams collapsed to one entry, every actuator re-resolved by name onto that one proxy, and `on_node_removed` erased by name, so one tab ending killed the slot while live siblings remained unreachable. The cache is now `unordered_map<guint32, CachedNode>`, `GetMatchingNodes` returns ids, and `GetMute`/`SetVolume`/`SetMute`/`SetDefaultNode`/`IsDefaultNode`/`Get|SetConnectionState` plus all listener callbacks take ids. `AudioService::GetNodeName(guint32)` covers the two places that genuinely need a name (the default-node API, which addresses by name, and the connection log line). **Patterns match a property bag.** `NodeMatcher::Matches` / `FirstMatchingEntry` take a `NodeProperties{NodeName, MediaName}` and try two candidates per entry: the bare `node.name`, and `node.name: media.name` when a `media.name` exists. Both go through the same anchored regex, so a plain name still matches only itself. Entry ranking is unchanged and does not record which candidate matched. **Membership is re-evaluated while a node lives.** Each `CachedNode` records the listeners that matched it; `notify::properties` triggers `DispatchMatchChanges`, which diffs the new matched set against the stored one and emits `on_node_available` for additions and `on_node_removed` for removals. A tab retitling into a strip's pattern gets that strip's sticky mute/volume immediately; one retitling out of it stops being written to, and the state it was left in stays. `params-changed` and `ports-changed` dispatch to the recorded listeners instead of re-matching, since they are not membership changes. Also: both link handlers lost their O(n) scan over the cache (`wp_link_get_linked_object_ids` already yields ids), `Action_Connection`'s self-link guard compares ids, so two *different* nodes sharing a name are now a valid pair, and `Reset()` disconnects node handlers before clearing the cache so a surviving proxy cannot call back into the service. README's "Specifying nodes" section documents the second candidate, including the consequence that a pattern broad enough to match arbitrary text now also matches through `node.name: media.name`, so exclusion lists must account for it. ## Verification - `tests/NodeMatcher_tests.cpp` call sites moved to property bags, and a new `TestPropertyCandidates` covers the candidate table from the plan (bare name wins over an irrelevant `media.name`; qualified pattern matches only the right title; no qualified candidate without a `media.name`; anchoring holds on both candidates) plus the ranking assertion that a qualified entry wins over a later bare-name entry. CI runs them. - The matching logic was additionally exercised locally against a throwaway harness linking `src/NodeMatcher.cpp` (all checks passed). This container has no WirePlumber/glaze dev packages and no root, so the full build and the hardware-dependent checks in the plan's verification section (steps 2 and 3: fan-out across several live Firefox nodes, live re-evaluation on retitling) could not be run here — they need the controller and a PipeWire session.
fix(audio): identify nodes by PipeWire id, match media.name
All checks were successful
Build / build (pull_request) Successful in 45s
f9b4421def
Nodes were cached and addressed by node.name, which PipeWire does not
guarantee to be unique. Every stream an application opens carries the
same name, so only the first one was cached and subscribed, every
actuator resolved back to that one proxy, and one stream ending evicted
the slot while its siblings stayed live and unreachable. A target such
as `Firefox` therefore controlled one stream at most, sometimes none,
and which one changed over time.

The cache is now keyed by the PipeWire bound id and every actuator,
listener callback and link endpoint is expressed in ids, so writes fan
out to each matching node individually.

Patterns are additionally matched against `node.name: media.name`,
which is how several streams of one application can be told apart.
Because media.name changes while a node lives, membership is
re-evaluated on property changes: a node that starts matching receives
the action's sticky state, one that stops matching is no longer written
to.

Co-Authored-By: Claude Opus 5 via omp-deck
omp-deck changed title from WIP: fix(audio): identify nodes by PipeWire id, match media.name to fix(audio): identify nodes by PipeWire id, match media.name 2026-11-13 11:58:35 +01:00
lucas approved these changes 2026-11-13 14:07:21 +01:00
lucas merged commit 2669dcdb6a into main 2026-11-13 14:07:26 +01:00
lucas deleted branch agent/issue-18-node-id-identity 2026-11-13 14:07:26 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/nanoKontroller2!22
No description provided.