mirror of
https://gitlab.freedesktop.org/pipewire/wireplumber.git
synced 2026-05-08 08:08:03 +02:00
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:
parent
59e6f2ac01
commit
86a86208e9
3 changed files with 551 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
]
|
||||
|
|
|
|||
478
src/scripts/device/create-alsa-loopback.lua
Normal file
478
src/scripts/device/create-alsa-loopback.lua
Normal 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 ()
|
||||
Loading…
Add table
Reference in a new issue