Skip to content

Commit 0b71611

Browse files
julianbouzasgkiagia
authored andcommitted
scripts: Add audio-group-utils.lua to group audio streams
This allows grouping audio streams that have a pw-audio-namespace ancestor process name. The grouping is done by creating a loopback filter for each group or namespace. Those loopback filters are then linked in between the actual stream and device nodes. A '--target-object' flag is also supported in the ancestor process name to define a target for the loopback stream node.
1 parent 86cdfac commit 0b71611

6 files changed

+350
-2
lines changed

src/config/wireplumber.conf

+10
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,10 @@ wireplumber.components = [
595595
name = node/software-dsp.lua, type = script/lua
596596
provides = node.software-dsp
597597
}
598+
{
599+
name = node/audio-group.lua, type = script/lua
600+
provides = node.audio-group
601+
}
598602

599603
## Linking hooks
600604
{
@@ -609,6 +613,11 @@ wireplumber.components = [
609613
name = linking/find-defined-target.lua, type = script/lua
610614
provides = hooks.linking.target.find-defined
611615
}
616+
{
617+
name = linking/find-audio-group-target.lua, type = script/lua
618+
provides = hooks.linking.target.find-audio-group
619+
requires = [ node.audio-group ]
620+
}
612621
{
613622
name = linking/find-filter-target.lua, type = script/lua
614623
provides = hooks.linking.target.find-filter
@@ -646,6 +655,7 @@ wireplumber.components = [
646655
hooks.linking.target.link ]
647656
wants = [ hooks.linking.target.find-media-role,
648657
hooks.linking.target.find-defined,
658+
hooks.linking.target.find-audio-group,
649659
hooks.linking.target.find-filter,
650660
hooks.linking.target.find-default,
651661
hooks.linking.target.find-best,

src/scripts/lib/audio-group-utils.lua

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- WirePlumber
2+
3+
-- Copyright © 2024 Collabora Ltd.
4+
-- @author Julian Bouzas <[email protected]>
5+
6+
-- SPDX-License-Identifier: MIT
7+
8+
-- Script is a Lua Module of audio group Lua utility functions
9+
10+
local module = {
11+
node_groups = {},
12+
}
13+
14+
function module.set_audio_group (stream_node, audio_group)
15+
module.node_groups [stream_node.id] = audio_group
16+
end
17+
18+
function module.get_audio_group (stream_node)
19+
return module.node_groups [stream_node.id]
20+
end
21+
22+
function module.contains_audio_group (audio_group)
23+
for k, v in pairs(module.node_groups) do
24+
if v == group then
25+
return true
26+
end
27+
end
28+
return false
29+
end
30+
31+
return module
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
-- WirePlumber
2+
--
3+
-- Copyright © 2023 Collabora Ltd.
4+
--
5+
-- SPDX-License-Identifier: MIT
6+
--
7+
-- Check if the target node is a filter target.
8+
9+
lutils = require ("linking-utils")
10+
cutils = require ("common-utils")
11+
agutils = require ("audio-group-utils")
12+
13+
log = Log.open_topic ("s-linking")
14+
15+
SimpleEventHook {
16+
name = "linking/find-audio-group-target",
17+
after = "linking/find-defined-target",
18+
interests = {
19+
EventInterest {
20+
Constraint { "event.type", "=", "select-target" },
21+
},
22+
},
23+
execute = function (event)
24+
local source, om, si, si_props, si_flags, target =
25+
lutils:unwrap_select_target_event (event)
26+
27+
-- bypass the hook if the target is already picked up
28+
if target then
29+
return
30+
end
31+
32+
local target_direction = cutils.getTargetDirection (si_props)
33+
local target_picked = nil
34+
local target_can_passthrough = false
35+
local node = nil
36+
local audio_group = nil
37+
38+
log:info (si, string.format ("handling item %d: %s (%s)", si.id,
39+
tostring (si_props ["node.name"]), tostring (si_props ["node.id"])))
40+
41+
-- Get associated node
42+
node = si:get_associated_proxy ("node")
43+
if node == nil then
44+
return
45+
end
46+
47+
-- audio group
48+
audio_group = agutils.get_audio_group (node)
49+
if audio_group == nil then
50+
return
51+
end
52+
53+
-- find the target with same audio group, if any
54+
for target in om:iterate {
55+
type = "SiLinkable",
56+
Constraint { "item.node.type", "=", "device" },
57+
Constraint { "item.node.direction", "=", target_direction },
58+
Constraint { "media.type", "=", si_props ["media.type"] },
59+
} do
60+
target_node = target:get_associated_proxy ("node")
61+
target_node_props = target_node.properties
62+
target_audio_group = target_node_props ["session.audio-group"]
63+
64+
if target_audio_group == nil then
65+
goto skip_linkable
66+
end
67+
68+
if target_audio_group ~= audio_group then
69+
goto skip_linkable
70+
end
71+
72+
local passthrough_compatible, can_passthrough =
73+
lutils.checkPassthroughCompatibility (si, target)
74+
if not passthrough_compatible then
75+
log:debug ("... passthrough is not compatible, skip linkable")
76+
goto skip_linkable
77+
end
78+
79+
target_picked = target
80+
target_can_passthrough = can_passthrough
81+
break
82+
83+
::skip_linkable::
84+
end
85+
86+
-- set target
87+
if target_picked then
88+
log:info (si,
89+
string.format ("... audio group target picked: %s (%s), can_passthrough:%s",
90+
tostring (target_picked.properties ["node.name"]),
91+
tostring (target_picked.properties ["node.id"]),
92+
tostring (target_can_passthrough)))
93+
si_flags.can_passthrough = target_can_passthrough
94+
event:set_data ("target", target_picked)
95+
end
96+
end
97+
}:register ()

src/scripts/linking/find-filter-target.lua

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ end
3333

3434
SimpleEventHook {
3535
name = "linking/find-filter-target",
36-
after = "linking/find-defined-target",
36+
after = "linking/find-audio-group-target",
3737
before = "linking/prepare-link",
3838
interests = {
3939
EventInterest {

src/scripts/linking/get-filter-from-target.lua

+7-1
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,15 @@ SimpleEventHook {
4343
return
4444
end
4545

46-
-- bypass the hook if target is defined, is a filter and is targetable
46+
-- bypass the hook if the target is an audio group
4747
local target_node = target:get_associated_proxy ("node")
4848
local target_node_props = target_node.properties
49+
local target_audio_group = target_node_props ["session.audio-group"]
50+
if target_audio_group ~= nil then
51+
return
52+
end
53+
54+
-- bypass the hook if target is defined, is a filter and is targetable
4955
local target_link_group = target_node_props ["node.link-group"]
5056
if target_link_group ~= nil and si_flags.has_defined_target then
5157
if futils.is_filter_smart (target_direction, target_link_group) and

src/scripts/node/audio-group.lua

+204
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
-- WirePlumber
2+
3+
-- Copyright © 2024 Collabora Ltd.
4+
-- @author Julian Bouzas <[email protected]>
5+
6+
-- SPDX-License-Identifier: MIT
7+
8+
-- audio-group.lua script takes pipewire audio stream nodes and groups them
9+
-- into a single unit by creating a loopback filter per group. The grouping
10+
-- is done by a common ancestor 'audio-group-namespace' process name.
11+
12+
agutils = require ("audio-group-utils")
13+
14+
PW_AUDIO_NAMESPACE = "pw-audio-namespace"
15+
16+
node_directions = {}
17+
group_loopback_modules = {}
18+
group_loopback_modules["input"] = {}
19+
group_loopback_modules["output"] = {}
20+
21+
function GetNodeDirection (id, props)
22+
if string.find (props["media.class"], "Stream/Input/Audio") then
23+
return "input"
24+
elseif string.find (props["media.class"], "Stream/Output/Audio") then
25+
return "output"
26+
end
27+
28+
return nil
29+
end
30+
31+
function GetNodeAudioGroup (pid)
32+
local group = nil
33+
local target_object = nil
34+
35+
-- We group a processes by PW_AUDIO_NAMESPACE.<pid> ancestor
36+
local curr_pid = pid
37+
while curr_pid ~= 0 do
38+
local pid_info = ProcUtils.get_proc_info (curr_pid)
39+
local arg0 = pid_info:get_arg (0)
40+
41+
-- Check if ancestor process name is PW_AUDIO_NAMESPACE
42+
if arg0 ~= nil and string.find (arg0, PW_AUDIO_NAMESPACE, 1, true) then
43+
-- Check if the PW_AUDIO_NAMESPACE has a defined target
44+
for i = 0, pid_info:get_n_args () - 1, 1 do
45+
local argn = pid_info:get_arg (i)
46+
47+
-- Ignore any args after '--'
48+
if argn == "--" then
49+
break
50+
end
51+
52+
-- Get target node id value if any
53+
if (argn == "--target-object") or (argn == "-t") then
54+
target_object = pid_info:get_arg (i + 1)
55+
break
56+
end
57+
end
58+
59+
-- We name the audio group as PW_AUDIO_NAMESPACE.<pid>
60+
group = PW_AUDIO_NAMESPACE .. "." .. tostring(curr_pid)
61+
break
62+
end
63+
64+
curr_pid = pid_info:get_parent_pid ()
65+
end
66+
67+
return group, target_object
68+
end
69+
70+
function CreateStreamLoopback (props, group, target_object, direction)
71+
local is_input = direction == "input" and true or false
72+
73+
-- Set stream properties
74+
local stream_props = {}
75+
stream_props["node.name"] = "stream.audio_group:" .. group
76+
stream_props["node.description"] = "Stream Audio Group for " .. group
77+
stream_props["media.class"] = is_input and "Stream/Input/Audio" or "Stream/Output/Audio"
78+
stream_props["node.passive"] = true
79+
stream_props["session.audio-group"] = group
80+
if target_object ~= nil then
81+
stream_props["target.object"] = tostring (target_object)
82+
end
83+
84+
-- Set device properties
85+
local device_props = {}
86+
device_props["node.name"] = "device.audio_group:" .. group
87+
device_props["node.description"] = "Device Audio Group for " .. group
88+
device_props["media.class"] = is_input and "Audio/Source" or "Audio/Sink"
89+
device_props["session.audio-group"] = group
90+
91+
-- Set loopback module args
92+
local args = Json.Object {
93+
["capture.props"] = Json.Object (is_input and stream_props or device_props),
94+
["playback.props"] = Json.Object (is_input and device_props or stream_props)
95+
}
96+
97+
-- Create module
98+
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
99+
end
100+
101+
SimpleEventHook {
102+
name = "lib/audio-group-utils/create-audio-group-loopback",
103+
interests = {
104+
-- on linkable added or removed, where linkable is adapter or plain node
105+
EventInterest {
106+
Constraint { "event.type", "=", "node-added" },
107+
Constraint { "media.class", "#", "Stream/*Audio*", type = "pw-global" },
108+
Constraint { "stream.monitor", "!", "true", type = "pw" },
109+
Constraint { "node.link-group", "-" },
110+
},
111+
},
112+
execute = function (event)
113+
local node = event:get_subject ()
114+
local source = event:get_source ()
115+
local client_om = source:call ("get-object-manager", "client")
116+
local id = node.id
117+
local bound_id = node["bound-id"]
118+
local stream_props = node.properties
119+
local stream_name = stream_props["node.name"]
120+
121+
-- Get client
122+
local client = client_om:lookup {
123+
Constraint { "bound-id", "=", stream_props["client.id"], type = "gobject"}
124+
}
125+
if client == nil then
126+
Log.info (node,
127+
"Cannot get client, not grouping audio stream ".. stream_name)
128+
return
129+
end
130+
131+
-- Get process ID
132+
local pid = tonumber (client.properties ["application.process.id"])
133+
if pid == nil then
134+
Log.info (node,
135+
"Cannot get process ID, not grouping audio stream ".. stream_name)
136+
return
137+
end
138+
139+
-- Get direction and add it to the table
140+
local direction = GetNodeDirection (bound_id, stream_props)
141+
if direction == nil then
142+
Log.info (node,
143+
"Cannot get direction, not grouping audio stream ".. stream_name)
144+
return
145+
end
146+
node_directions [id] = direction
147+
148+
-- Get group and add it to the table
149+
local group, target_object = GetNodeAudioGroup (pid)
150+
if group == nil then
151+
Log.info (node,
152+
"Cannot get audio group, not grouping audio stream " .. stream_name)
153+
return
154+
end
155+
agutils.set_audio_group (node, group)
156+
157+
-- Create group loopback module if it does not exist
158+
local m = group_loopback_modules [direction][group]
159+
if m == nil then
160+
Log.warning ("Creating " .. direction .. " loopback for audio group " .. group ..
161+
(target_object and (" with target object " .. tostring (target_object)) or ""))
162+
m = CreateStreamLoopback (stream_props, group, target_object, direction)
163+
group_loopback_modules [direction][group] = m
164+
end
165+
end
166+
}:register ()
167+
168+
169+
SimpleEventHook {
170+
name = "lib/audio-group-utils/destroy-audio-group-loopback",
171+
interests = {
172+
-- on linkable added or removed, where linkable is adapter or plain node
173+
EventInterest {
174+
Constraint { "event.type", "=", "node-removed" },
175+
Constraint { "media.class", "#", "Stream/*Audio*", type = "pw-global" },
176+
Constraint { "stream.monitor", "!", "true", type = "pw" },
177+
Constraint { "node.link-group", "-" },
178+
},
179+
},
180+
execute = function (event)
181+
local node = event:get_subject ()
182+
local id = node.id
183+
184+
-- Get node direction from table and remove it
185+
local direction = node_directions [id]
186+
if direction == nil then
187+
return
188+
end
189+
node_directions [id] = nil
190+
191+
-- Get node group from table and remove it
192+
local group = agutils.get_audio_group (node)
193+
if group == nil then
194+
return
195+
end
196+
agutils.set_audio_group (node, nil)
197+
198+
-- Destroy group loopback module if there are no more nodes with the same group
199+
if not agutils.contains_audio_group (group) then
200+
Log.info ("Destroying " .. direction .. " loopback for audio group " .. group)
201+
group_loopback_modules [direction][group] = nil
202+
end
203+
end
204+
}:register ()

0 commit comments

Comments
 (0)