Skip to content

pipewire: set node.link-group on virtual and internal filter nodes - #5273

Merged
wwmm merged 1 commit into
wwmm:masterfrom
ChristianPraiss:fix/bluetooth-autoswitch-node-link-group
Aug 31, 2026
Merged

pipewire: set node.link-group on virtual and internal filter nodes#5273
wwmm merged 1 commit into
wwmm:masterfrom
ChristianPraiss:fix/bluetooth-autoswitch-node-link-group

Conversation

@ChristianPraiss

@ChristianPraiss ChristianPraiss commented Aug 31, 2026

Copy link
Copy Markdown

Ran into this chasing a bug where my Bluetooth AirPods mic never worked in Slack when routed through Easy Effects - WirePlumber's Bluetooth autoswitch (bluetooth.autoswitch-to-headset-profile) never flipped the headset into HFP mode, so the mic stayed silent no matter what.

Turned out to be a node.link-group gap. Easy Effects sets node.group on its virtual sink/source and internal filter nodes, but never sets the sibling property node.link-group. WirePlumber's autoswitch-bluetooth-profile.lua relies on node.link-group to walk through a filter chain and find the real device behind it - since it's missing here, the script can never see past Easy Effects, and the profile switch never fires.

PipeWire's own module-filter-chain.c sets node.group and node.link-group together automatically whenever either is missing, so this just brings Easy Effects' own node creation in line with that existing convention.

This alone isn't quite enough on the WirePlumber side either. Its filter-chain walk also expects media.class == Stream/Input/Audio on internal nodes, which Easy Effects doesn't set, and its cycle guard needs a small fix too. Got a patch for that on the WirePlumber side as well, and tested both together: a Bluetooth mic captured through Easy Effects now correctly switches profile and reverts afterward.

Related to #4878

Here's the wireplumber patch for reference.

diff --git a/src/scripts/device/autoswitch-bluetooth-profile.lua b/src/scripts/device/autoswitch-bluetooth-profile.lua
index 27c79dd..4763346 100644
--- a/src/scripts/device/autoswitch-bluetooth-profile.lua
+++ b/src/scripts/device/autoswitch-bluetooth-profile.lua
@@ -293,9 +293,21 @@ function triggerRestoreProfile (source, dev_id)
   end)
 end

-function getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om, visited_link_groups)
+function getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om, visited_nodes)
   local stream_id = stream["bound-id"]

+  -- Cycle guard: per visited NODE, not per link-group. A single filter chain
+  -- (e.g. EasyEffects) can have several internal nodes sharing the same
+  -- link-group in series, so guarding on the group alone stops the walk one
+  -- hop too early: https://github.com/wwmm/easyeffects/issues/4878
+  if visited_nodes == nil then
+    visited_nodes = {}
+  end
+  if visited_nodes [stream_id] then
+    return nil
+  end
+  visited_nodes [stream_id] = true
+
   -- Make sure the node is linked
   local link = link_om:lookup {
     Constraint { "link.input.node", "=", stream_id, type = "pw-global"}
@@ -321,23 +333,22 @@ function getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om
     }
     if filter_main_node ~= nil then
       local filter_link_group = filter_main_node.properties ["node.link-group"]
-      if visited_link_groups == nil then
-        visited_link_groups = {}
-      end
-      if visited_link_groups [filter_link_group] then
-        return nil
-      else
-        visited_link_groups [filter_link_group] = true
-      end
       for filter_stream_node in node_om:iterate {
-          Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
           Constraint { "stream.monitor", "!", "true", type = "pw" },
           Constraint { "bluez5.loopback", "!", "true", type = "pw" },
           Constraint { "node.link-group", "=", filter_link_group, type = "pw" }
         } do
-        local bt_node = getLinkedBluetoothLoopbackSourceNodeForStream (filter_stream_node, node_oms)
-        if bt_node ~= nil then
-          return bt_node
+        -- Accept regular capture streams as before, and also plain internal
+        -- Duplex filter nodes that never set media.class at all (e.g.
+        -- EasyEffects' per-plugin nodes): https://github.com/wwmm/easyeffects/issues/4878
+        local fsn_media_class = filter_stream_node.properties ["media.class"]
+        local fsn_media_category = filter_stream_node.properties ["media.category"]
+        if (fsn_media_class ~= nil and string.find (fsn_media_class, "Stream/Input/Audio", 1, true
+            fsn_media_category == "Duplex" then
+          local bt_node = getLinkedBluetoothLoopbackSourceNodeForStream (filter_stream_node, node_
+          if bt_node ~= nil then
+            return bt_node
+          end
         end
       end
     end

Every node here already sets node.group, but never its sibling
property node.link-group, which is what WirePlumber's Bluetooth
autoswitch (and other filter-chain-aware linking scripts) use to see
through an app's internal chain to the real device behind it. Without
it, WirePlumber can't tell that a client capturing from Easy Effects
Source is transitively using a Bluetooth headset's mic, and never
switches the profile to HFP.

PipeWire's own module-filter-chain.c sets both properties together
automatically whenever either is missing, so this just brings Easy
Effects' own node creation in line with that convention.

Related to wwmm#4878

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

@wwmm
wwmm merged commit c86e127 into wwmm:master Aug 31, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants