scripts: Add 'device/create-alsa-loopback.lua' script

This new script allows creating loopback filters for ALSA nodes matching a
particular route using a JSON configuration file. See the provided example
configuration file for more info.
This commit is contained in:
Julian Bouzas 2025-12-16 13:13:03 -05:00
parent 59e6f2ac01
commit 86a86208e9
3 changed files with 551 additions and 0 deletions

View file

@ -556,10 +556,15 @@ wireplumber.components = [
name = device/autoswitch-bluetooth-profile.lua, type = script/lua
provides = hooks.device.profile.autoswitch-bluetooth
}
{
name = device/create-alsa-loopback.lua, type = script/lua
provides = hooks.device.profile.create-alsa-loopback
}
{
type = virtual, provides = policy.device.profile
requires = [ hooks.device.profile.select,
hooks.device.profile.autoswitch-bluetooth,
hooks.device.profile.create-alsa-loopback,
hooks.device.profile.apply ]
wants = [ hooks.device.profile.find-voice-call,
hooks.device.profile.find-best,

View file

@ -129,3 +129,71 @@ monitor.alsa.rules = [
# }
# }
]
loopback.alsa.rules = [
## The list of ALSA loopback rules
## This rule example allows creating a loopback for the route device 3 of
## all ALSA devices.
# {
# matches = [
# {
# ## This matches all ALSA cards
# device.name = "~alsa_card.*"
# }
# ]
# actions = {
# ## MANDATORY: This action is used to create a loopback for a particular
# ## route device number. An ALSA loopback collection for this route
# ## device will also be created so that it has its own policy.
# create-loopback = {
# ## This creates a loopback for sub-device 3, which is the ALSA node
# ## that represents the route with device number 3.
# [
# ## MANDATORY: The route device this loopback will be used with
# route-device = 3
#
# ## OPTIONAL: Whether the loopback is dynamic or not. Dynamic loopbacks
# ## are only available if the current ALSA profile supports the given
# ## route device.
# dynamic = false
#
# ## OPTIONAL: Extra ALSA loopback node properties
# device-node-props = {
# card.profile.device = 3
# node.virtual = false
# priority.session = 1000
# }
#
# ## OPTIONAL: Extra stream loopback node properties
# stream-node-props = {
# node.virtual = false
# }
# ]
# }
#
# ## OPTIONAL: This action is used to collect extra nodes into the ALSA
# ## loopback collection, which can be useful if we want to include extra
# ## filter nodes.
# collect-nodes = [
# {
# matches = [
# {
# ## This matches an ALSA device filter node
# node.name = "filter-evice-node-name"
# }
# {
# ## This matches an ALSA stream filter node
# node.name = "filter-stream-node-name"
# }
# ]
# actions = {
# ## MANDATORY: This action is used to specify where to collect the
# ## matched nodes
# select-route-device = 3
# }
# }
# ]
# }
# }
]

View file

@ -0,0 +1,478 @@
-- WirePlumber
--
-- Copyright © 2025 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- This collect-global.lua script collects globals into collections.
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
config = {}
config.rules = Conf.get_section_as_json ("loopback.alsa.rules", Json.Array {})
local alsa_loopback_ids = {}
local alsa_loopbacks = {}
function getProfileLoopbackIds (profile)
local loopback_ids = {}
if type (profile.classes) == "table" and profile.classes.pod_type == "Struct" then
for _, p in ipairs (profile.classes) do
if type (p) == "table" and p.pod_type == "Struct" then
local i = 1
while true do
local k, v = p [i], p [i+1]
i = i + 2
if not k or not v then
break
end
if k == "card.profile.devices" and
type (v) == "table" and v.pod_type == "Array" then
for _, dev_id in ipairs (v) do
loopback_ids [dev_id + 1] = true
end
end
end
end
end
end
return loopback_ids
end
AsyncEventHook {
name = "device/evaluate-loopbacks",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "=", "Profile" },
Constraint { "device.api", "=", "alsa" },
},
},
steps = {
start = {
next = "none",
execute = function (event, transition)
local source = event:get_source ()
local device = event:get_subject ()
local create_loopback_info = nil
local collect_node_rules = Json.Array {}
local loopback_info = {}
-- Check if there are matching rules for this device
JsonUtils.match_rules (config.rules, device.properties, function (action, value)
if action == "create-loopback" then
create_loopback_info = value:parse ()
elseif action == "collect-nodes" then
collect_node_rules = value
end
return true
end)
if create_loopback_info == nil then
transition:advance ()
return
end
-- Populate the loopback_info table from the create_loopback_info table
for _, info in pairs(create_loopback_info) do
local id = info ["route-device"]
if id ~= nil and tonumber (id) >= 0 then
loopback_info [tonumber (id) + 1] = {}
loopback_info [tonumber (id) + 1].dynamic = info ["dynamic"] or false
loopback_info [tonumber (id) + 1].device_node_props = info ["device-node-props"] or {}
loopback_info [tonumber (id) + 1].stream_node_props = info ["stream-node-props"] or {}
else
log:warning (device,
"Found create loopback info without valid route-device property. Ignoring...")
end
end
-- Get routes information
device:enum_params ("EnumRoute", function (enum_route_it, e)
-- check for error
if e then
transition:return_error ("failed to enum routes: "
.. tostring (e));
return
end
-- Make sure the device is still valid
if (device:get_active_features() & Feature.Proxy.BOUND) == 0 then
transition:advance ()
return
end
-- Get device routes info
local routes_info = {}
for p in enum_route_it:iterate() do
local route = cutils.parseParam (p, "EnumRoute")
if not route then
goto skip_enum_route
end
for _, id in ipairs(route.devices) do
if routes_info [id + 1] == nil then
routes_info [id + 1] = {}
end
routes_info [id + 1].available = route.available
routes_info [id + 1].priority = route.priority
routes_info [id + 1].direction = route.direction
routes_info [id + 1].name = route.name
routes_info [id + 1].media_class =
route.direction == "Input" and "Audio/Source" or "Audio/Sink"
end
::skip_enum_route::
end
-- Make sure we found route info for all configured loopbacks
local all_loopback_info_found = true
for id, _ in pairs(loopback_info) do
if routes_info [id] == nil then
all_loopback_info_found = false
break
end
end
if not all_loopback_info_found then
transition:return_error (
"Could not find route info for configured ALSA loopback IDs");
return
end
-- Get the current profile
local profile = nil
for p in device:iterate_params ("Profile") do
profile = cutils.parseParam (p, "Profile")
break
end
assert (profile)
-- Get current and old loopback Ids
local new_loopback_ids = getProfileLoopbackIds (profile)
local old_loopback_ids = alsa_loopback_ids [device.id] ~= nil and
alsa_loopback_ids [device.id] or {}
alsa_loopback_ids [device.id] = new_loopback_ids
-- Create dynamic loopbacks
for id, _ in ipairs(new_loopback_ids) do
local info = loopback_info [id]
if old_loopback_ids [id] == nil and info ~= nil and info.dynamic then
local e = source:call ("create-event", "create-loopback", device, nil)
e:set_data ("loopback-id", id - 1)
e:set_data ("media-class", routes_info [id].media_class)
e:set_data ("device-node-props", info.device_node_props)
e:set_data ("stream-node-props", info.stream_node_props)
e:set_data ("collect-node-rules", collect_node_rules)
EventDispatcher.push_event (e)
end
end
-- Destroy dynamic loopbacks
for id, _ in ipairs(old_loopback_ids) do
local info = loopback_info [id]
if new_loopback_ids [id] == nil and info ~= nil and info.dynamic then
local e = source:call ("create-event", "destroy-loopback", device, nil)
e:set_data ("loopback-id", id - 1)
e:set_data ("media-class", routes_info [id].media_class)
e:set_data ("device-node-props", info.device_node_props)
e:set_data ("stream-node-props", info.stream_node_props)
e:set_data ("collect-node-rules", collect_node_rules)
EventDispatcher.push_event (e)
end
end
-- Create static loopbacks if never created before
for id, info in pairs(loopback_info) do
if not info.dynamic and
(alsa_loopbacks [device.id] == nil or alsa_loopbacks [device.id][id] == nil) then
local e = source:call ("create-event", "create-loopback", device, nil)
e:set_data ("loopback-id", id - 1)
e:set_data ("media-class", routes_info [id].media_class)
e:set_data ("device-node-props", info.device_node_props)
e:set_data ("stream-node-props", info.stream_node_props)
e:set_data ("collect-node-rules", collect_node_rules)
EventDispatcher.push_event (e)
end
end
transition:advance ()
end)
end
}
}
}:register ()
function CreateLoopback (device, device_media_class, loopback_id, conf_device_node_props, conf_stream_node_props)
local devide_id = device["bound-id"]
local device_props = device.properties
local loopback_name = device_props ["device.name"] .. "." .. tostring (loopback_id)
local stream_media_class = nil
local args = nil
-- Set stream media class
if device_media_class == "Audio/Sink" then
stream_media_class = "Stream/Output/Audio"
elseif device_media_class == "Audio/Source" then
stream_media_class = "Stream/Input/Audio"
else
log:warning (device, "Device media class '" .. device_media_class ..
"' is not valid")
return nil
end
-- Set stream props
local stream_raw_props = {
["node.name"] = string.format ("alsa_loopback_stream.%s", loopback_name),
["node.description"] = string.format ("ALSA Loopback stream for %s", loopback_name),
["media.class"] = stream_media_class,
["alsa.loopback.id"] = loopback_id,
["device.id"] = devide_id,
["node.passive"] = true,
["node.dont-fallback"] = true,
["node.linger"] = true,
}
for k, v in pairs (conf_stream_node_props) do
stream_raw_props [k] = v
end
local stream_props = Json.Object (stream_raw_props)
-- Set device props
local devices_raw_props = {
["node.name"] = string.format ("alsa_loopback_device.%s", loopback_name),
["node.description"] = string.format ("ALSA Loopback device for %s", loopback_name),
["media.class"] = device_media_class,
["alsa.loopback.id"] = loopback_id,
["device.id"] = devide_id,
}
for k, v in pairs (conf_device_node_props) do
devices_raw_props [k] = v
end
local device_props = Json.Object (devices_raw_props)
-- Set args
if device_media_class == "Audio/Sink" then
args = Json.Object {
["playback.props"] = stream_props,
["capture.props"] = device_props
}
elseif device_media_class == "Audio/Source" then
args = Json.Object {
["playback.props"] = device_props,
["capture.props"] = stream_props
}
else
return nil
end
-- Load loopback
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end
function getCollectionName (device_name, loopback_id)
return "alsa_loopback." .. device_name .. "." .. tostring (loopback_id)
end
AsyncEventHook {
name = "device/create-loopback",
interests = {
EventInterest {
Constraint { "event.type", "=", "create-loopback" },
Constraint { "device.api", "=", "alsa" },
},
},
steps = {
start = {
next = "none",
execute = function (event, transition)
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local device = event:get_subject ()
local device_name = device:get_property ("device.name")
-- Get the loopback ID
local loopback_id = event:get_data ("loopback-id")
if loopback_id == nil then
transition:return_error ("Event data does not have loopback ID");
return
end
-- Get the media class property
local media_class = event:get_data ("media-class")
if media_class == nil then
transition:return_error ("Event data does not have media class property");
return
end
-- Get the device properties
local device_node_props = event:get_data ("device-node-props")
if device_node_props == nil then
transition:return_error ("Event data does not have device properties");
return
end
-- Get the stream properties
local stream_node_props = event:get_data ("stream-node-props")
if stream_node_props == nil then
transition:return_error ("Event data does not have stream properties");
return
end
-- Get the collect node rules
local collect_node_rules = event:get_data ("collect-node-rules")
if collect_node_rules == nil then
transition:return_error ("Event data does not have collect node rules");
return
end
-- Create the collection for this loopback
local collection_name = getCollectionName (device_name, loopback_id)
CollectionManager.create_collection (collection_name, function (_, e)
-- Make sure the collection was created
if e ~= nil then
transition:return_error ("Failed to create collection '" ..
collection_name .. "': " .. tostring (e));
return
end
log:info (device, "Created ALSA loopback collection for ID " ..
tostring (loopback_id) .. ": " .. collection_name)
-- Create the loopback module
if alsa_loopbacks [device.id] == nil then
alsa_loopbacks [device.id] = {}
end
alsa_loopbacks [device.id][loopback_id] = CreateLoopback (device,
media_class, loopback_id, device_node_props, stream_node_props)
log:info (device, "Created loopback module for ID " ..
tostring (loopback_id))
-- Collect nodes to collection if any there are rules for them
for node in nodes_om:iterate () do
local node_name = node:get_property ("node.name")
JsonUtils.match_rules (collect_node_rules, node.properties, function (action, value)
if action == "select-route-device" and value:is_int () and value:parse () == loopback_id then
if not CollectionManager.collect_global (node, collection_name) then
log:warning (device, "Failed to collect node '" .. node_name ..
"' into '" .. collection_name .. "'")
else
log:info (device, "Collected node '" .. node_name .. "' into '" ..
collection_name .. "'")
end
end
end)
end
transition:advance ()
end)
end,
},
},
}:register ()
SimpleEventHook {
name = "device/destroy-loopback",
interests = {
EventInterest {
Constraint { "event.type", "=", "destroy-loopback" },
Constraint { "device.api", "=", "alsa" },
},
},
execute = function (event)
local device = event:get_subject ()
-- Get the loopback ID
local loopback_id = event:get_data ("loopback-id")
if loopback_id == nil then
log:warning (device, "Event data does not have loopback ID")
return
end
-- Destroy loopback module
if alsa_loopbacks [device.id] == nil then
alsa_loopbacks [device.id] = {}
end
alsa_loopbacks [device.id][loopback_id] = nil
log:info (device, "Destroyed loopback module for ID " ..
tostring (loopback_id))
end
}:register ()
SimpleEventHook {
name = "device/destroy-loopbacks",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-removed" },
Constraint { "device.api", "=", "alsa" },
},
},
execute = function (event)
local device = event:get_subject ()
-- Remove all loopbacks associated with this device
alsa_loopbacks [device.id] = nil
end
}:register ()
SimpleEventHook {
name = "device/collect-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "select-collection" },
Constraint { "event.subject.type", "=", "node" },
},
},
execute = function (event, transition)
local source = event:get_source ()
local node = event:get_subject ()
-- Get the node device Id
local device_id = node.properties:get_int ("device.id")
if device_id == nil then
return
end
-- Get the associated device
local devices_om = source:call ("get-object-manager", "device")
local device = devices_om:lookup {
Constraint { "bound-id", "=", device_id, type = "gobject" },
}
if device == nil then
return
end
-- Get the loopback ID
local loopback_id = node.properties:get_int ("card.profile.device")
if loopback_id == nil then
loopback_id = node.properties:get_int ("alsa.loopback.id")
if loopback_id == nil then
return
end
end
-- Don't collect the ALSA loopback device node
local link_group = node:get_property ("node.link-group")
local media_class = node:get_property ("media.class")
if link_group ~= nil and link_group:find ("^loopback") and
not media_class:find ("^Stream/") then
return
end
-- Check if there is a collection for this ALSA loopback
local device_name = device:get_property ("device.name")
local collection_name = getCollectionName (device_name, loopback_id)
if not CollectionManager.has_collection (collection_name) then
return
end
-- Collect the node into the device collection
local node_name = node:get_property ("node.name")
if not CollectionManager.collect_global (node, collection_name) then
log:warning (device, "Failed to collect node '" .. node_name ..
"' into '" .. collection_name .. "'")
return
end
log:info (device, "Collected node '" .. node_name .. "' into '" ..
collection_name .. "'")
end,
}:register ()