mirror of
https://gitlab.freedesktop.org/pipewire/wireplumber.git
synced 2025-12-20 04:10:03 +01:00
612 lines
18 KiB
Lua
612 lines
18 KiB
Lua
-- WirePlumber
|
|
--
|
|
-- Copyright © 2021 Collabora Ltd.
|
|
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
|
|
--
|
|
-- SPDX-License-Identifier: MIT
|
|
|
|
SPLIT_PCM_PARENT_OFFSET = 256
|
|
SPLIT_PCM_OFFSET = 512
|
|
|
|
cutils = require ("common-utils")
|
|
log = Log.open_topic ("s-monitors")
|
|
|
|
config = {}
|
|
config.reserve_device = Core.test_feature ("monitor.alsa.reserve-device")
|
|
config.properties = Conf.get_section_as_properties ("monitor.alsa.properties")
|
|
config.rules = Conf.get_section_as_json ("monitor.alsa.rules", Json.Array {})
|
|
|
|
-- unique device/node name tables
|
|
device_names_table = nil
|
|
node_names_table = nil
|
|
|
|
-- SPA ids to node names: name = id_name_table[device_id][node_id]
|
|
id_name_table = nil
|
|
|
|
|
|
function nonempty(str)
|
|
return str ~= "" and str or nil
|
|
end
|
|
|
|
function applyDefaultDeviceProperties (properties)
|
|
properties["api.alsa.use-acp"] = true
|
|
properties["api.acp.auto-profile"] = false
|
|
properties["api.acp.auto-port"] = false
|
|
properties["api.dbus.ReserveDevice1.Priority"] = -20
|
|
properties["api.alsa.split-enable"] = true
|
|
end
|
|
|
|
function createSplitPCMHWNode(dev_props, properties)
|
|
local skip_keys = {
|
|
"api.alsa.split.position", "card.profile.device", "device.profile.description",
|
|
"device.profile.name"
|
|
}
|
|
local props = {}
|
|
|
|
for k, v in pairs(properties) do
|
|
props[k] = v
|
|
end
|
|
for _, k in pairs(skip_keys) do
|
|
props[k] = nil
|
|
end
|
|
|
|
-- create the underlying hidden ALSA node
|
|
props["node.name"] = props["api.alsa.split.name"]
|
|
props["node.description"] = string.format("%s %s", dev_props["device.description"],
|
|
props["api.alsa.path"]:gsub("^[^,]*[,:]", ""))
|
|
if props["api.alsa.pcm.stream"] == "capture" then
|
|
props["media.class"] = "Audio/Source/Internal"
|
|
else
|
|
props["media.class"] = "Audio/Sink/Internal"
|
|
end
|
|
props["api.alsa.use-chmap"] = false
|
|
props["api.alsa.split.parent"] = true
|
|
props["audio.position"] = props["api.alsa.split.hw-position"]
|
|
local channels = Json.Raw (props["api.alsa.split.hw-position"]):parse ()
|
|
props["audio.channels"] = tostring(#channels)
|
|
|
|
props = JsonUtils.match_rules_update_properties (config.rules, props)
|
|
|
|
if cutils.parseBool (props ["node.disabled"]) then
|
|
log:notice ("ALSA node " .. props ["node.name"] .. " disabled")
|
|
return nil
|
|
end
|
|
|
|
return Node("adapter", props)
|
|
end
|
|
|
|
function createSplitPCMLoopback(parent, id, obj_type, factory, properties)
|
|
local skip_keys = {
|
|
-- not suitable for loopback
|
|
"audio.rate",
|
|
"clock.quantum-limit",
|
|
"factory.name",
|
|
"node.driver",
|
|
"node.pause-on-idle",
|
|
"node.want-driver",
|
|
"port.group",
|
|
"priority.driver",
|
|
"resample.disable",
|
|
"resample.prefill",
|
|
}
|
|
local args
|
|
local props = {}
|
|
|
|
props["node.virtual"] = false
|
|
|
|
for k, v in pairs(properties) do
|
|
props[k] = v
|
|
end
|
|
for _, k in pairs(skip_keys) do
|
|
props[k] = nil
|
|
end
|
|
|
|
local split_props = {
|
|
["node.name"] = properties["node.name"] .. ".split",
|
|
["node.description"] = string.format(I18n.gettext("Split %s"), properties["node.description"]),
|
|
["audio.position"] = properties["api.alsa.split.position"],
|
|
["stream.dont-remix"] = true,
|
|
["node.passive"] = true,
|
|
["node.dont-fallback"] = true,
|
|
["node.linger"] = true,
|
|
["state.restore-props"] = false,
|
|
["target.object"] = properties["api.alsa.split.name"],
|
|
}
|
|
|
|
if properties["api.alsa.pcm.stream"] == "playback" then
|
|
props["media.class"] = "Audio/Sink"
|
|
split_props["media.class"] = "Stream/Output/Audio/Internal"
|
|
args = Json.Object {
|
|
["capture.props"] = Json.Object (props),
|
|
["playback.props"] = Json.Object (split_props),
|
|
}
|
|
else
|
|
props["media.class"] = "Audio/Source"
|
|
split_props["media.class"] = "Stream/Input/Audio/Internal"
|
|
args = Json.Object {
|
|
["playback.props"] = Json.Object (props),
|
|
["capture.props"] = Json.Object (split_props),
|
|
}
|
|
end
|
|
|
|
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
|
|
end
|
|
|
|
devices_om = ObjectManager {
|
|
Interest {
|
|
type = "device",
|
|
}
|
|
}
|
|
|
|
split_nodes_om = ObjectManager {
|
|
Interest {
|
|
type = "node",
|
|
Constraint { "api.alsa.split.position", "+", type = "pw" },
|
|
}
|
|
}
|
|
|
|
split_nodes_om:connect ("object-added", function(_, node)
|
|
-- Connect ObjectConfig events to the right node
|
|
if not monitor then
|
|
return
|
|
end
|
|
|
|
local interest = Interest {
|
|
type = "device",
|
|
Constraint { "object.id", "=", node.properties["device.id"] }
|
|
}
|
|
log:info("Split PCM node found: " .. tostring (node["bound-id"]))
|
|
|
|
for device in devices_om:iterate (interest) do
|
|
local device_id = device.properties["spa.object.id"]
|
|
if not device_id then
|
|
goto next_device
|
|
end
|
|
|
|
local spa_device = monitor:get_managed_object (tonumber (device_id))
|
|
if not spa_device then
|
|
goto next_device
|
|
end
|
|
|
|
local id = node.properties["card.profile.device"]
|
|
if id ~= nil then
|
|
log:info(".. assign to device: " .. tostring (device["bound-id"]) .. " node " .. tostring (id))
|
|
spa_device:store_managed_object (id, node)
|
|
end
|
|
|
|
::next_device::
|
|
end
|
|
end)
|
|
|
|
function createNode(parent, id, obj_type, factory, properties)
|
|
local dev_props = parent.properties
|
|
local parent_id = tonumber(dev_props["spa.object.id"])
|
|
|
|
-- set the device id and spa factory name; REQUIRED, do not change
|
|
properties["device.id"] = parent["bound-id"]
|
|
properties["factory.name"] = factory
|
|
|
|
-- set the default pause-on-idle setting
|
|
properties["node.pause-on-idle"] = false
|
|
|
|
-- try to negotiate the max amount of channels
|
|
if dev_props["api.alsa.use-acp"] ~= "true" then
|
|
properties["audio.channels"] = properties["audio.channels"] or "64"
|
|
end
|
|
|
|
local dev = properties["api.alsa.pcm.device"]
|
|
or properties["alsa.device"] or "0"
|
|
local subdev = properties["api.alsa.pcm.subdevice"]
|
|
or properties["alsa.subdevice"] or "0"
|
|
local stream = properties["api.alsa.pcm.stream"] or "unknown"
|
|
local profile = properties["device.profile.name"]
|
|
or (stream .. "." .. dev .. "." .. subdev)
|
|
local profile_desc = properties["device.profile.description"]
|
|
|
|
-- set priority
|
|
if not properties["priority.driver"] then
|
|
local priority = (dev == "0") and 1000 or 744
|
|
if stream == "capture" then
|
|
priority = priority + 1000
|
|
end
|
|
|
|
priority = priority - (tonumber(dev) * 16) - tonumber(subdev)
|
|
|
|
if profile:find("^pro%-") then
|
|
priority = priority + 500
|
|
elseif profile:find("^analog%-") then
|
|
priority = priority + 9
|
|
elseif profile:find("^iec958%-") then
|
|
priority = priority + 8
|
|
end
|
|
|
|
properties["priority.driver"] = priority
|
|
properties["priority.session"] = priority
|
|
end
|
|
|
|
-- ensure the node has a media class
|
|
if not properties["media.class"] then
|
|
if stream == "capture" then
|
|
properties["media.class"] = "Audio/Source"
|
|
else
|
|
properties["media.class"] = "Audio/Sink"
|
|
end
|
|
end
|
|
|
|
-- ensure the node has a name
|
|
if not properties["node.name"] then
|
|
local name =
|
|
(stream == "capture" and "alsa_input" or "alsa_output")
|
|
.. "." ..
|
|
(dev_props["device.name"]:gsub("^alsa_card%.(.+)", "%1") or
|
|
dev_props["device.name"] or
|
|
"unnamed-device")
|
|
.. "." ..
|
|
profile
|
|
|
|
-- sanitize name
|
|
name = name:gsub("([^%w_%-%.])", "_")
|
|
|
|
properties["node.name"] = name
|
|
|
|
log:info ("Creating node " .. name)
|
|
|
|
-- deduplicate nodes with the same name
|
|
for counter = 2, 99, 1 do
|
|
if node_names_table[properties["node.name"]] ~= true then
|
|
break
|
|
end
|
|
properties["node.name"] = name .. "." .. counter
|
|
log:info ("deduplicating node name -> " .. properties["node.name"])
|
|
end
|
|
else
|
|
log:info ("Creating node " .. properties["node.name"])
|
|
end
|
|
|
|
-- and a nick
|
|
local nick = nonempty(properties["node.nick"])
|
|
or nonempty(properties["api.alsa.pcm.name"])
|
|
or nonempty(properties["alsa.name"])
|
|
or nonempty(profile_desc)
|
|
or dev_props["device.nick"]
|
|
if nick == "USB Audio" then
|
|
nick = dev_props["device.nick"]
|
|
end
|
|
-- also sanitize nick, replace ':' with ' '
|
|
properties["node.nick"] = nick:gsub("(:)", " ")
|
|
|
|
-- ensure the node has a description
|
|
if not properties["node.description"] then
|
|
local desc = nonempty(dev_props["device.description"]) or "unknown"
|
|
local name = nonempty(properties["api.alsa.pcm.name"]) or
|
|
nonempty(properties["api.alsa.pcm.id"]) or dev
|
|
|
|
if profile_desc then
|
|
desc = desc .. " " .. profile_desc
|
|
elseif subdev ~= "0" then
|
|
desc = desc .. " (" .. name .. " " .. subdev .. ")"
|
|
elseif dev ~= "0" then
|
|
desc = desc .. " (" .. name .. ")"
|
|
end
|
|
|
|
-- also sanitize description, replace ':' with ' '
|
|
properties["node.description"] = desc:gsub("(:)", " ")
|
|
end
|
|
|
|
-- add api.alsa.card.* properties for rule matching purposes
|
|
for k, v in pairs(dev_props) do
|
|
if k:find("^api%.alsa%.card%..*") then
|
|
properties[k] = v
|
|
end
|
|
end
|
|
|
|
-- add cpu.vm.name for rule matching purposes
|
|
local vm_type = Core.get_vm_type()
|
|
if nonempty(vm_type) then
|
|
properties["cpu.vm.name"] = vm_type
|
|
end
|
|
|
|
-- apply properties from rules defined in JSON .conf file
|
|
local orig_properties = {}
|
|
for k, v in pairs(properties) do
|
|
orig_properties[k] = v
|
|
end
|
|
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
|
|
|
|
if cutils.parseBool (properties ["node.disabled"]) then
|
|
log:notice ("ALSA node " .. properties["node.name"] .. " disabled")
|
|
return
|
|
end
|
|
|
|
node_names_table[properties["node.name"]] = true
|
|
id_name_table[parent_id][id] = properties["node.name"]
|
|
|
|
-- handle split HW node
|
|
if properties["api.alsa.split.position"] ~= nil then
|
|
local split_hw_node_name = string.format("%s.%s",
|
|
(stream == "capture" and "alsa_input" or "alsa_output"),
|
|
properties["api.alsa.path"]:gsub("([:,])", "_"))
|
|
properties["api.alsa.split.name"] = split_hw_node_name
|
|
orig_properties["api.alsa.split.name"] = split_hw_node_name
|
|
|
|
if not node_names_table [split_hw_node_name] then
|
|
log:info ("Create ALSA SplitPCM HW node " .. split_hw_node_name)
|
|
|
|
local node = createSplitPCMHWNode(dev_props, orig_properties)
|
|
if node ~= nil then
|
|
node:activate(Feature.Proxy.BOUND)
|
|
parent:store_managed_object(SPLIT_PCM_PARENT_OFFSET + id, node)
|
|
|
|
node_names_table[split_hw_node_name] = true
|
|
id_name_table[parent_id][SPLIT_PCM_PARENT_OFFSET + id] = split_hw_node_name
|
|
end
|
|
end
|
|
|
|
-- create split PCM node
|
|
log:info ("Create ALSA SplitPCM split node " .. properties["node.name"])
|
|
|
|
local loopback = createSplitPCMLoopback (parent, id, obj_type, factory, properties)
|
|
parent:store_managed_object(SPLIT_PCM_OFFSET + id, loopback)
|
|
parent:set_managed_pending(id)
|
|
return
|
|
end
|
|
|
|
-- create the node
|
|
local node = Node("adapter", properties)
|
|
node:activate(Feature.Proxy.BOUND, function (_, err)
|
|
if err then
|
|
log:warning ("Failed to create " .. properties ["node.name"]
|
|
.. ": " .. tostring(err))
|
|
end
|
|
end)
|
|
parent:store_managed_object(id, node)
|
|
end
|
|
|
|
function removeNode(parent, id)
|
|
local parent_id = tonumber(parent.properties["spa.object.id"])
|
|
local ids = {id, SPLIT_PCM_PARENT_OFFSET + id, SPLIT_PCM_OFFSET + id}
|
|
|
|
for _, j in pairs(ids) do
|
|
local node_name = id_name_table[parent_id][j]
|
|
|
|
parent:store_managed_object(j, nil)
|
|
|
|
if node_name ~= nil then
|
|
log:info ("Removing node " .. node_name)
|
|
node_names_table[node_name] = nil
|
|
id_name_table[parent_id][j] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
function createDevice(parent, id, factory, properties)
|
|
id_name_table[id] = {}
|
|
properties["spa.object.id"] = id
|
|
local device = SpaDevice(factory, properties)
|
|
if device then
|
|
device:connect("create-object", createNode)
|
|
device:connect("object-removed", removeNode)
|
|
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
|
|
parent:store_managed_object(id, device)
|
|
else
|
|
log:warning ("Failed to create '" .. factory .. "' device")
|
|
end
|
|
end
|
|
|
|
function removeDevice(parent, id)
|
|
if id_name_table[id] ~= nil then
|
|
for _, node_name in pairs(id_name_table[id]) do
|
|
log:info ("Release " .. node_name)
|
|
node_names_table[node_name] = nil
|
|
end
|
|
id_name_table[id] = nil
|
|
end
|
|
end
|
|
|
|
function prepareDevice(parent, id, obj_type, factory, properties)
|
|
-- ensure the device has an appropriate name
|
|
local name = "alsa_card." ..
|
|
(properties["device.name"] or
|
|
properties["device.bus-id"] or
|
|
properties["device.bus-path"] or
|
|
tostring(id)):gsub("([^%w_%-%.])", "_")
|
|
|
|
properties["device.name"] = name
|
|
|
|
-- deduplicate devices with the same name
|
|
for counter = 2, 99, 1 do
|
|
if device_names_table[properties["device.name"]] ~= true then
|
|
device_names_table[properties["device.name"]] = true
|
|
break
|
|
end
|
|
properties["device.name"] = name .. "." .. counter
|
|
end
|
|
|
|
-- ensure the device has a description
|
|
if not properties["device.description"] then
|
|
local d = nil
|
|
local f = properties["device.form-factor"]
|
|
local c = properties["device.class"]
|
|
local n = properties["api.alsa.card.name"]
|
|
|
|
if n == "Loopback" then
|
|
d = I18n.gettext("Loopback")
|
|
elseif f == "internal" then
|
|
d = I18n.gettext("Built-in Audio")
|
|
elseif c == "modem" then
|
|
d = I18n.gettext("Modem")
|
|
end
|
|
|
|
d = d or properties["device.product.name"]
|
|
or properties["api.alsa.card.name"]
|
|
or properties["alsa.card_name"]
|
|
or "Unknown device"
|
|
properties["device.description"] = d
|
|
end
|
|
|
|
-- ensure the device has a nick
|
|
properties["device.nick"] =
|
|
properties["device.nick"] or
|
|
properties["api.alsa.card.name"] or
|
|
properties["alsa.card_name"]
|
|
|
|
-- set the icon name
|
|
if not properties["device.icon-name"] then
|
|
local icon = nil
|
|
local icon_map = {
|
|
-- form factor -> icon
|
|
["microphone"] = "audio-input-microphone",
|
|
["webcam"] = "camera-web",
|
|
["handset"] = "phone",
|
|
["portable"] = "multimedia-player",
|
|
["tv"] = "video-display",
|
|
["headset"] = "audio-headset",
|
|
["headphone"] = "audio-headphones",
|
|
["speaker"] = "audio-speakers",
|
|
["hands-free"] = "audio-handsfree",
|
|
}
|
|
local f = properties["device.form-factor"]
|
|
local c = properties["device.class"]
|
|
local b = properties["device.bus"]
|
|
|
|
icon = icon_map[f] or ((c == "modem") and "modem") or "audio-card"
|
|
properties["device.icon-name"] = icon .. "-analog" .. (b and ("-" .. b) or "")
|
|
end
|
|
|
|
-- apply properties from rules defined in JSON .conf file
|
|
applyDefaultDeviceProperties (properties)
|
|
properties = JsonUtils.match_rules_update_properties (config.rules, properties)
|
|
|
|
if cutils.parseBool (properties ["device.disabled"]) then
|
|
log:notice ("ALSA card/device " .. properties ["device.name"] .. " disabled")
|
|
device_names_table [properties ["device.name"]] = nil
|
|
return
|
|
end
|
|
|
|
-- override the device factory to use ACP
|
|
if cutils.parseBool (properties ["api.alsa.use-acp"]) then
|
|
log:info("Enabling the use of ACP on " .. properties["device.name"])
|
|
factory = "api.alsa.acp.device"
|
|
end
|
|
|
|
-- use device reservation, if available
|
|
if rd_plugin and properties["api.alsa.card"] then
|
|
local rd_name = "Audio" .. properties["api.alsa.card"]
|
|
local rd = rd_plugin:call("create-reservation",
|
|
rd_name,
|
|
cutils.get_application_name (),
|
|
properties["device.name"],
|
|
properties["api.dbus.ReserveDevice1.Priority"]);
|
|
|
|
properties["api.dbus.ReserveDevice1"] = rd_name
|
|
|
|
-- unlike pipewire-media-session, this logic here keeps the device
|
|
-- acquired at all times and destroys it if someone else acquires
|
|
rd:connect("notify::state", function (rd, pspec)
|
|
local state = rd["state"]
|
|
|
|
if state == "acquired" then
|
|
-- create the device
|
|
createDevice(parent, id, factory, properties)
|
|
|
|
elseif state == "available" then
|
|
-- attempt to acquire again
|
|
rd:call("acquire")
|
|
|
|
elseif state == "busy" then
|
|
-- destroy the device
|
|
removeDevice(parent, id)
|
|
parent:store_managed_object(id, nil)
|
|
end
|
|
end)
|
|
|
|
rd:connect("release-requested", function (rd)
|
|
log:info("release requested")
|
|
parent:store_managed_object(id, nil)
|
|
rd:call("release")
|
|
end)
|
|
|
|
rd:call("acquire")
|
|
else
|
|
-- create the device
|
|
createDevice(parent, id, factory, properties)
|
|
end
|
|
end
|
|
|
|
function createMonitor ()
|
|
local m = SpaDevice("api.alsa.enum.udev", config.properties)
|
|
if m == nil then
|
|
log:notice("PipeWire's ALSA SPA plugin is missing or broken. " ..
|
|
"Sound cards will not be supported")
|
|
return nil
|
|
end
|
|
|
|
-- handle create-object to prepare device
|
|
m:connect("create-object", prepareDevice)
|
|
|
|
-- handle object-removed to destroy device reservations and recycle device name
|
|
m:connect("object-removed", function (parent, id)
|
|
removeDevice(parent, id)
|
|
|
|
local device = parent:get_managed_object(id)
|
|
if not device then
|
|
return
|
|
end
|
|
|
|
if rd_plugin then
|
|
local rd_name = device.properties["api.dbus.ReserveDevice1"]
|
|
if rd_name then
|
|
rd_plugin:call("destroy-reservation", rd_name)
|
|
end
|
|
end
|
|
device_names_table[device.properties["device.name"]] = nil
|
|
for managed_node in device:iterate_managed_objects() do
|
|
node_names_table[managed_node.properties["node.name"]] = nil
|
|
end
|
|
end)
|
|
|
|
-- reset the name tables to make sure names are recycled
|
|
device_names_table = {}
|
|
node_names_table = {}
|
|
id_name_table = {}
|
|
|
|
-- activate monitor
|
|
log:info("Activating ALSA monitor")
|
|
m:activate(Feature.SpaDevice.ENABLED)
|
|
return m
|
|
end
|
|
|
|
-- if the reserve-device plugin is enabled, at the point of script execution
|
|
-- it is expected to be connected. if it is not, assume the d-bus connection
|
|
-- has failed and continue without it
|
|
if config.reserve_device then
|
|
rd_plugin = Plugin.find("reserve-device")
|
|
end
|
|
if rd_plugin and rd_plugin:call("get-dbus")["state"] ~= "connected" then
|
|
log:notice("reserve-device plugin is not connected to D-Bus, "
|
|
.. "disabling device reservation")
|
|
rd_plugin = nil
|
|
end
|
|
|
|
-- handle rd_plugin state changes to destroy and re-create the ALSA monitor in
|
|
-- case D-Bus service is restarted
|
|
if rd_plugin then
|
|
local dbus = rd_plugin:call("get-dbus")
|
|
dbus:connect("notify::state", function (b, pspec)
|
|
local state = b["state"]
|
|
log:info ("rd-plugin state changed to " .. state)
|
|
if state == "connected" then
|
|
log:info ("Creating ALSA monitor")
|
|
monitor = createMonitor()
|
|
elseif state == "closed" then
|
|
log:info ("Destroying ALSA monitor")
|
|
monitor = nil
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- create the monitor
|
|
monitor = createMonitor()
|
|
|
|
devices_om:activate()
|
|
split_nodes_om:activate()
|