wireplumber/src/scripts/monitors/alsa.lua

613 lines
18 KiB
Lua
Raw Normal View History

2021-01-20 09:53:57 +02:00
-- 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")
2023-05-19 20:12:08 +03:00
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)
2021-01-20 09:53:57 +02:00
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 ammount 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"
2021-01-20 09:53:57 +02:00
local stream = properties["api.alsa.pcm.stream"] or "unknown"
local profile = properties["device.profile.name"]
or (stream .. "." .. dev .. "." .. subdev)
2021-01-20 09:53:57 +02:00
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
2021-01-20 09:53:57 +02:00
-- 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)
2021-01-20 09:53:57 +02:00
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("(:)", " ")
2021-01-20 09:53:57 +02:00
-- 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
2021-01-20 09:53:57 +02:00
if profile_desc then
desc = desc .. " " .. profile_desc
elseif subdev ~= "0" then
desc = desc .. " (" .. name .. " " .. subdev .. ")"
elseif dev ~= "0" then
desc = desc .. " (" .. name .. ")"
2021-01-20 09:53:57 +02:00
end
-- also sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
2021-01-20 09:53:57 +02:00
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
2021-01-20 09:53:57 +02:00
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
2021-01-20 09:53:57 +02:00
-- 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)
2021-01-20 09:53:57 +02:00
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
2023-05-19 20:12:08 +03:00
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
2021-01-20 09:53:57 +02:00
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"]
2021-01-20 09:53:57 +02:00
if n == "Loopback" then
d = I18n.gettext("Loopback")
elseif f == "internal" then
d = I18n.gettext("Built-in Audio")
2021-01-20 09:53:57 +02:00
elseif c == "modem" then
d = I18n.gettext("Modem")
2021-01-20 09:53:57 +02:00
end
d = d or properties["device.product.name"]
or properties["api.alsa.card.name"]
or properties["alsa.card_name"]
or "Unknown device"
2021-01-20 09:53:57 +02:00
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"]
2021-01-20 09:53:57 +02:00
-- 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",
}
2021-01-20 09:53:57 +02:00
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 "")
2021-01-20 09:53:57 +02:00
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
2021-01-20 09:53:57 +02:00
-- override the device factory to use ACP
if cutils.parseBool (properties ["api.alsa.use-acp"]) then
2023-05-19 20:12:08 +03:00
log:info("Enabling the use of ACP on " .. properties["device.name"])
2021-01-20 09:53:57 +02:00
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)
2023-05-19 20:12:08 +03:00
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
2021-01-20 09:53:57 +02:00
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
2023-05-19 20:12:08 +03:00
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
2023-05-19 20:12:08 +03:00
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
2022-05-06 09:57:37 -04:00
local dbus = rd_plugin:call("get-dbus")
dbus:connect("notify::state", function (b, pspec)
local state = b["state"]
2023-05-19 20:12:08 +03:00
log:info ("rd-plugin state changed to " .. state)
if state == "connected" then
2023-05-19 20:12:08 +03:00
log:info ("Creating ALSA monitor")
monitor = createMonitor()
elseif state == "closed" then
2023-05-19 20:12:08 +03:00
log:info ("Destroying ALSA monitor")
monitor = nil
end
end)
end
-- create the monitor
monitor = createMonitor()
devices_om:activate()
split_nodes_om:activate()