diff --git a/src/config/config.lua b/src/config/config.lua index 274250f60225c4b0be776b6a30e295de350c6a08..8a306e3464d663e235786c4c1ceb80198be68120 100644 --- a/src/config/config.lua +++ b/src/config/config.lua @@ -86,14 +86,6 @@ function enable_audio() -- Enables device reservation via org.freedesktop.ReserveDevice1 on D-Bus load_module("reserve-device") - - -- ALSA device management via udev - load_monitor("alsa", { - use_acp = true, - use_device_reservation = true, - enable_midi = true, - enable_jack_client = false, - }) end function enable_endpoints() diff --git a/src/config/config.lua.d/30-alsa-monitor.lua b/src/config/config.lua.d/30-alsa-monitor.lua new file mode 100644 index 0000000000000000000000000000000000000000..32521771ed58b3fdb26baf1666df69945e93ca9d --- /dev/null +++ b/src/config/config.lua.d/30-alsa-monitor.lua @@ -0,0 +1,104 @@ +-- ALSA monitor config file -- + +local properties = { + -- Create a JACK device. This is not enabled by default because + -- it requires that the PipeWire JACK replacement libraries are + -- not used by the session manager, in order to be able to + -- connect to the real JACK server. + --["alsa.jack-device"] = false, + + -- Reserve devices. + --["alsa.reserve"] = true, + --["alsa.reserve.priority"] = -20, + --["alsa.reserve.application-name"] = "WirePlumber", +} + +local rules = { + -- An array of matches/actions to evaluate. + { + -- Rules for matching a device or node. It is an array of + -- properties that all need to match the regexp. If any of the + -- matches work, the actions are executed for the object. + matches = { + { + -- This matches all cards. + { "device.name", "matches", "alsa_card.*" }, + }, + }, + -- Apply properties on the matched object. + apply_properties = { + -- Use ALSA-Card-Profile devices. They use UCM or the profile + -- configuration to configure the device and mixer settings. + ["api.alsa.use-acp"] = true, + + -- Use UCM instead of profile when available. Can be + -- disabled to skip trying to use the UCM profile. + --["api.alsa.use-ucm"] = true, + + -- Don't use the hardware mixer for volume control. It + -- will only use software volume. The mixer is still used + -- to mute unused paths based on the selected port. + --["api.alsa.soft-mixer"] = false, + + -- Ignore decibel settings of the driver. Can be used to + -- work around buggy drivers that report wrong values. + --["api.alsa.ignore-dB"] = false, + + -- The profile set to use for the device. Usually this is + -- "default.conf" but can be changed with a udev rule or here. + --["device.profile-set"] = "profileset-name", + + -- The default active profile. Is by default set to "Off". + --["device.profile"] = "default profile name", + + -- Automatically select the best profile. This is the + -- highest priority available profile. This is disabled + -- here and instead implemented in the session manager + -- where it can save and load previous preferences. + ["api.acp.auto-profile"] = false, + + -- Automatically switch to the highest priority available port. + -- This is disabled here and implemented in the session manager instead. + ["api.acp.auto-port"] = false, + + -- Other properties can be set here. + --["device.nick"] = "My Device", + }, + }, + { + matches = { + { + -- Matches all sources. + { "node.name", "matches", "alsa_input.*" }, + }, + { + -- Matches all sinks. + { "node.name", "matches", "alsa_output.*" }, + }, + }, + apply_properties = { + --["node.nick"] = "My Node", + --["priority.driver"] = 100, + --["priority.session"] = 100, + --["node.pause-on-idle"] = false, + --["resample.quality"] = 4, + --["channelmix.normalize"] = false, + --["channelmix.mix-lfe"] = false, + --["audio.channels"] = 2, + --["audio.format"] = "S16LE", + --["audio.rate"] = 44100, + --["audio.position"] = "FL,FR", + --["api.alsa.period-size"] = 1024, + --["api.alsa.headroom"] = 0, + --["api.alsa.disable-mmap"] = false, + --["api.alsa.disable-batch"] = false, + } + } +} + +function enable_alsa() + load_monitor("alsa", { + properties = properties, + rules = rules, + }) +end diff --git a/src/config/config.lua.d/90-enable-audio-all.lua b/src/config/config.lua.d/90-enable-audio-all.lua index 93e75dcf8f8a0949d6a41c4383b229ab3f7c3632..b4dad7a12d27e38a98633281641acc11b737e607 100644 --- a/src/config/config.lua.d/90-enable-audio-all.lua +++ b/src/config/config.lua.d/90-enable-audio-all.lua @@ -1,2 +1,3 @@ enable_audio() +enable_alsa() enable_bluetooth() diff --git a/src/scripts/monitors/monitor-alsa.lua b/src/scripts/monitors/monitor-alsa.lua index 4324c3eb2b0038d20de99faac5e457e4bd2d7ce7..9153b6f9a845377c5580feaa84dae0cb3d00cdcf 100644 --- a/src/scripts/monitors/monitor-alsa.lua +++ b/src/scripts/monitors/monitor-alsa.lua @@ -6,33 +6,94 @@ -- SPDX-License-Identifier: MIT -- Receive script arguments from config.lua -local Config = ... - -if Config.enable_midi then - midi_bridge = Node("spa-node-factory", { - ["factory.name"] = "api.alsa.seq.bridge", - ["node.name"] = "MIDI Bridge" - }) +local config = ... + +-- ensure config.properties is not nil +config.properties = config.properties or {} + +-- preprocess rules and create Interest objects +for _, r in ipairs(config.rules or {}) do + r.interests = {} + for _, i in ipairs(r.matches) do + local interest_desc = { type = "properties" } + for _, c in ipairs(i) do + c.type = "pw" + table.insert(interest_desc, Constraint(c)) + end + local interest = Interest(interest_desc) + table.insert(r.interests, interest) + end + r.matches = nil end -if Config.enable_jack_client then - jack_device = Device("spa-device-factory", { - ["factory.name"] = "api.jack.device" - }) +-- applies properties from config.rules when asked to +function rulesApplyProperties(properties) + for _, r in ipairs(config.rules or {}) do + if r.apply_properties then + for _, interest in ipairs(r.interests) do + if interest:matches(properties) then + for k, v in pairs(r.apply_properties) do + properties[k] = v + end + end + end + end + end end -if Config.use_device_reservation then - rd_plugin = Plugin("reserve-device") +function findDuplicate(parent, id, property, value) + for i = 0, id - 1, 1 do + local obj = parent:get_managed_object(i) + if obj and obj.properties[property] == value then + return true + end + end + return false end function createNode(parent, id, type, factory, properties) local dev_props = parent.properties - 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" + + -- 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" local stream = properties["api.alsa.pcm.stream"] or "unknown" - local profile = properties["device.profile.name"] 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("^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 @@ -43,14 +104,34 @@ function createNode(parent, id, type, factory, properties) 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 + + properties["node.name"] = name + + -- deduplicate nodes with the same name + for counter = 2, 99, 1 do + if findDuplicate(parent, id, "node.name", properties["node.name"]) then + properties["node.name"] = name .. "." .. counter + else + break + end + end + end + + -- and a nick properties["node.nick"] = properties["node.nick"] or dev_props["device.nick"] - or dev_props["api.alsa.card_name"] + or dev_props["api.alsa.card.name"] or dev_props["alsa.card_name"] - properties["node.name"] = properties["node.name"] - or (dev_props["device.name"] or "unknown") .. "." .. stream .. "." .. dev .. "." .. subdev - -- ensure the node has a description if not properties["node.description"] then local desc = dev_props["device.description"] or "unknown" @@ -67,9 +148,8 @@ function createNode(parent, id, type, factory, properties) end end - -- set the device id and spa factory name; REQUIRED, do not change - properties["device.id"] = parent["bound-id"] - properties["factory.name"] = factory + -- apply properties from config.rules + rulesApplyProperties(properties) -- create the node local node = Node("adapter", properties) @@ -77,11 +157,30 @@ function createNode(parent, id, type, factory, properties) parent:store_managed_object(id, node) end -function createDevice(parent, id, type, factory, properties) - -- ensure the device has a name - if not properties["device.name"] then - local s = properties["device.bus-id"] or properties["device.bus-path"] or "unknown" - properties["device.name"] = "alsa_card." .. s +function createDevice(parent, id, factory, properties) + local device = SpaDevice(factory, properties) + device:connect("create-object", createNode) + device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND) + parent:store_managed_object(id, device) +end + +function prepareDevice(parent, id, 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)) + + properties["device.name"] = name + + -- deduplicate devices with the same name + for counter = 2, 99, 1 do + if findDuplicate(parent, id, "device.name", properties["device.name"]) then + properties["device.name"] = name .. "." .. counter + else + break + end end -- ensure the device has a description @@ -96,55 +195,58 @@ function createDevice(parent, id, type, factory, properties) d = "Modem" end - d = d or properties["device.product.name"] or "Unknown device" + 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"] + -- 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"] - if f == "microphone" then - icon = "audio-input-microphone" - elseif f == "webcam" then - icon = "camera-web" - elseif f == "handset" then - icon = "phone" - elseif f == "portable" then - icon = "multimedia-player" - elseif f == "tv" then - icon = "video-display" - elseif f == "headset" then - icon = "audio-headset" - elseif f == "headphone" then - icon = "audio-headphones" - elseif f == "speaker" then - icon = "audio-speakers" - elseif f == "hands-free" then - icon = "audio-handsfree" - elseif c == "modem" then - icon = "modem" - end - - icon = icon or "audio-card" - - if b then b = ("-" .. b) else b = "" end - properties["device.icon-name"] = icon .. "-analog" .. b + 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 config.rules + rulesApplyProperties(properties) + -- override the device factory to use ACP - if Config.use_acp then + if 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 then + 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, "WirePlumber", properties["device.name"], -20); + rd_name, + config.properties["alsa.reserve.application-name"] or "WirePlumber", + properties["device.name"], + config.properties["alsa.reserve.priority"] or -20); properties["api.dbus.ReserveDevice1"] = rd_name @@ -155,10 +257,7 @@ function createDevice(parent, id, type, factory, properties) if state == "acquired" then -- create the device - local device = SpaDevice(factory, properties) - device:connect("create-object", createNode) - device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND) - parent:store_managed_object(id, device) + createDevice(parent, id, factory, properties) elseif state == "available" then -- attempt to acquire again @@ -184,38 +283,37 @@ function createDevice(parent, id, type, factory, properties) rd:call("acquire") else -- create the device - local device = SpaDevice(factory, properties) - device:connect("create-object", createNode) - device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND) - parent:store_managed_object(id, device) + createDevice(parent, id, factory, properties) end end -monitor = SpaDevice("api.alsa.enum.udev") -monitor:connect("create-object", createDevice) +monitor = SpaDevice("api.alsa.enum.udev", config.properties) +monitor:connect("create-object", prepareDevice) -function activateMonitor() - if rd_plugin then - monitor:connect("object-removed", function (parent, id) - local device = parent:get_managed_object(id) - local rd_name = device.properties["api.dbus.ReserveDevice1"] - if rd_name then - rd_plugin:call("destroy-reservation", rd_name) - end - end) - end +-- create the JACK device (for PipeWire to act as client to a JACK server) +if config.properties["alsa.jack-device"] then + jack_device = Device("spa-device-factory", { + ["factory.name"] = "api.jack.device", + ["node.name"] = "JACK-Device", + }) +end - Log.info("Activating ALSA monitor") - monitor:activate(Feature.SpaDevice.ENABLED) +-- reservation is only disabled by explicitly setting it to false +if config.properties["alsa.reserve"] == true or + config.properties["alsa.reserve"] == nil then + rd_plugin = Plugin("reserve-device") 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 rd_plugin and rd_plugin["state"] ~= "connected" then + Log.message("reserve-device plugin is not connected to D-Bus, " + .. "disabling device reservation") rd_plugin = nil end +-- destroy device reservations when the corresponding devices are removed if rd_plugin then monitor:connect("object-removed", function (parent, id) local device = parent:get_managed_object(id)