From 86a86208e9af8c539f787764f46020c8580935ef Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 16 Dec 2025 13:13:03 -0500 Subject: [PATCH] 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. --- src/config/wireplumber.conf | 5 + .../wireplumber.conf.d.examples/alsa.conf | 68 +++ src/scripts/device/create-alsa-loopback.lua | 478 ++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 src/scripts/device/create-alsa-loopback.lua diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index ac3d422b..db4d5f17 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -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, diff --git a/src/config/wireplumber.conf.d.examples/alsa.conf b/src/config/wireplumber.conf.d.examples/alsa.conf index 4eed6d66..d2930da1 100644 --- a/src/config/wireplumber.conf.d.examples/alsa.conf +++ b/src/config/wireplumber.conf.d.examples/alsa.conf @@ -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 + # } + # } + # ] + # } + # } +] diff --git a/src/scripts/device/create-alsa-loopback.lua b/src/scripts/device/create-alsa-loopback.lua new file mode 100644 index 00000000..bc3066fa --- /dev/null +++ b/src/scripts/device/create-alsa-loopback.lua @@ -0,0 +1,478 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- @author Julian Bouzas +-- +-- 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 ()