From 874a432c696400cca0690d8540adcd7b0dc4d4c4 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Wed, 15 Nov 2023 12:43:34 -0500 Subject: [PATCH] autoswitch-bluetooth-profile: remove applications array and use loopback filter This patch improves the bluetooth profile autoswitch so that it works with any application that wants to capture from a bluetooth device. To do so, a loopback source filter is created per connected bluetooth device. If an application wants to capture audio from such loopback source filter, the profile in the associated bluetooth device is changed to HSP/HFP. If there isn't any application connected to the loopback source filter, the profile switches back to A2DP. --- docs/rst/daemon/configuration/main.rst | 1 - .../bluetooth.conf | 10 - .../device/autoswitch-bluetooth-profile.lua | 348 +++++++++--------- src/scripts/lib/linking-utils.lua | 14 + src/scripts/lib/settings-bluetooth.lua | 8 +- src/scripts/monitors/bluez.lua | 99 ++++- 6 files changed, 284 insertions(+), 196 deletions(-) diff --git a/docs/rst/daemon/configuration/main.rst b/docs/rst/daemon/configuration/main.rst index 2b8407bd..5b09ac09 100644 --- a/docs/rst/daemon/configuration/main.rst +++ b/docs/rst/daemon/configuration/main.rst @@ -180,7 +180,6 @@ files and are placed under ``wireplumber.conf.d/``. More on this below. alsa_monitor.alsa.reserve = true alsa_monitor.alsa.midi = "true" default-policy-duck.level = 0.3 - bt-policy-media-role.applications = ["Firefox", "Chromium input"] } Value can be string, int, float, boolean and can even be a JSON array. diff --git a/src/config/wireplumber.conf.d.examples/bluetooth.conf b/src/config/wireplumber.conf.d.examples/bluetooth.conf index 8b1efe1a..c6d96ce7 100644 --- a/src/config/wireplumber.conf.d.examples/bluetooth.conf +++ b/src/config/wireplumber.conf.d.examples/bluetooth.conf @@ -6,16 +6,6 @@ wireplumber.settings = { ## Whether to use headset profile in the presence of an input stream. # bluetooth.autoswitch-to-headset-profile = true - - ## Application names correspond to application.name in stream properties. - ## Applications which do not set media.role but which should be considered - ## for role based profile switching can be specified here. - # bluetooth.autoswitch-applications = [ - # "Firefox", "Chromium input", "Google Chrome input", "Brave input", - # "Microsoft Edge input", "Vivaldi input", "ZOOM VoiceEngine", - # "Telegram Desktop", "telegram-desktop", "linphone", "Mumble", - # "WEBRTC VoiceEngine", "Skype" - # ] } monitor.bluez.properties = { diff --git a/src/scripts/device/autoswitch-bluetooth-profile.lua b/src/scripts/device/autoswitch-bluetooth-profile.lua index e9e54eae..8f859e30 100644 --- a/src/scripts/device/autoswitch-bluetooth-profile.lua +++ b/src/scripts/device/autoswitch-bluetooth-profile.lua @@ -11,33 +11,33 @@ -- -- SPDX-License-Identifier: MIT -- --- Scriupt Checks for the existence of media.role and if present switches the --- bluetooth profile accordingly. Also see bluez-autoswitch in media-session. --- The intended logic of the script is as follows. +-- This script is charged to automatically change BT profiles on a device. If a +-- client is linked to the device's loopback source node, the associated BT +-- device profile is automatically switched to HSP/HFP. If there is no clients +-- linked to the device's loopback source node, the BT device profile is +-- switched back to A2DP profile. -- --- When a stream comes in, if it has a Communication or phone role in PulseAudio --- speak in props, we switch to the highest priority profile that has an Input --- route available. The reason for this is that we may have microphone enabled --- non-HFP codecs eg. Faststream. --- We track the incoming streams with Communication role or the applications --- specified which do not set the media.role correctly perhaps. +-- We switch to the highest priority profile that has an Input route available. +-- The reason for this is that we may have microphone enabled with non-HFP +-- codecs eg. Faststream. -- When a stream goes away if the list with which we track the streams above -- is empty, then we revert back to the old profile. -- settings file: bluetooth.conf +lutils = require ("linking-utils") cutils = require ("common-utils") settings = require ("settings-bluetooth") state = nil headset_profiles = nil +device_loopback_sources = {} -local applications = {} local profile_restore_timeout_msec = 2000 local INVALID = -1 -local timeout_source = nil -local restore_timeout_source = nil +local timeout_source = {} +local restore_timeout_source = {} local last_profiles = {} @@ -55,18 +55,9 @@ function handlePersistentSetting (enable) end end -function loadAppNames (appNames) - applications = {} - for i = 1, #appNames do - applications [appNames [i]] = true - end -end - handlePersistentSetting (settings.use_persistent_storage) -loadAppNames (settings.autoswitch_applications) settings:subscribe ("use-persistent-storage", handlePersistentSetting) -settings:subscribe ("autoswitch-applications", loadAppNames) devices_om = ObjectManager { Interest { @@ -79,8 +70,16 @@ streams_om = ObjectManager { Interest { type = "node", Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, - -- Do not consider monitor streams - Constraint { "stream.monitor", "!", "true" } + Constraint { "stream.monitor", "!", "true", type = "pw" }, + Constraint { "bluez5.loopback", "!", "true", type = "pw" } + } +} + +loopback_nodes_om = ObjectManager { + Interest { + type = "node", + Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" }, + Constraint { "bluez5.loopback", "=", "true", type = "pw" }, } } @@ -107,19 +106,6 @@ local function isSwitchedToHeadsetProfile (device) return getSavedLastProfile (device) ~= nil end -local function isBluez5AudioSink (sink_name) - if sink_name and string.find (sink_name, "bluez_output.") ~= nil then - return true - end - return false -end - -local function isBluez5DefaultAudioSink () - local metadata = cutils.get_default_metadata_object () - local default_audio_sink = metadata:find (0, "default.audio.sink") - return isBluez5AudioSink (default_audio_sink) -end - local function findProfile (device, index, name) for p in device:iterate_params ("EnumProfile") do local profile = cutils.parseParam (p, "EnumProfile") @@ -203,37 +189,91 @@ local function hasProfileInputRoute (device, profile_index) return false end -local function switchDevicesToHeadsetProfile () +local function switchDeviceToHeadsetProfile (dev_id) local index local name - -- clear restore callback, if any - if restore_timeout_source then - restore_timeout_source:destroy () - restore_timeout_source = nil + -- Find the actual device + local device = devices_om:lookup { + Constraint { "bound-id", "=", dev_id, type = "gobject" } + } + if device == nil then + Log.info ("Device with id " .. tostring(dev_id).. " not found") + return end - for device in devices_om:iterate () do - if isSwitchedToHeadsetProfile (device) then - goto skip_device - end + if isSwitchedToHeadsetProfile (device) then + Log.info ("Device with id " .. tostring(dev_id).. " is already switched to HSP/HFP") + return + end - local cur_profile_name = getCurrentProfile (device) + local cur_profile_name = getCurrentProfile (device) + _, index, name = findProfile (device, nil, cur_profile_name) + if hasProfileInputRoute (device, index) then + Log.info ("Current profile has input route, not switching") + return + end - _, index, name = findProfile (device, nil, cur_profile_name) - if hasProfileInputRoute (device, index) then - Log.info ("Current profile has input route, not switching") - goto skip_device - end + -- clear restore callback, if any + if restore_timeout_source[dev_id] ~= nil then + restore_timeout_source[dev_id]:destroy () + restore_timeout_source[dev_id] = nil + end - local saved_headset_profile = getSavedHeadsetProfile (device) - index = INVALID - if saved_headset_profile then - _, index, name = findProfile (device, nil, saved_headset_profile) - end - if index == INVALID then - _, index, name = highestPrioProfileWithInputRoute (device) - end + local saved_headset_profile = getSavedHeadsetProfile (device) + index = INVALID + if saved_headset_profile then + _, index, name = findProfile (device, nil, saved_headset_profile) + end + if index == INVALID then + _, index, name = highestPrioProfileWithInputRoute (device) + end + + if index ~= INVALID then + local pod = Pod.Object { + "Spa:Pod:Object:Param:Profile", "Profile", + index = index + } + + -- store the current profile (needed when restoring) + saveLastProfile (device, cur_profile_name) + + -- switch to headset profile + Log.info ("Setting profile of '" + .. device.properties ["device.description"] + .. "' from: " .. cur_profile_name + .. " to: " .. name) + device:set_params ("Profile", pod) + else + Log.warning ("Got invalid index when switching profile") + end +end + +local function restoreProfile (dev_id) + -- Find the actual device + local device = devices_om:lookup { + Constraint { "bound-id", "=", dev_id, type = "gobject" } + } + if device == nil then + Log.info ("Device with id " .. tostring(dev_id).. " not found") + return + end + + if not isSwitchedToHeadsetProfile (device) then + Log.info ("Device with id " .. tostring(dev_id).. " is already not switched to HSP/HFP") + return + end + + local profile_name = getSavedLastProfile (device) + local cur_profile_name = getCurrentProfile (device) + + if cur_profile_name then + Log.info ("Setting saved headset profile to: " .. cur_profile_name) + saveHeadsetProfile (device, cur_profile_name) + end + + if profile_name then + local _, index, name = findProfile (device, nil, profile_name) if index ~= INVALID then local pod = Pod.Object { @@ -241,99 +281,63 @@ local function switchDevicesToHeadsetProfile () index = index } - -- store the current profile (needed when restoring) - saveLastProfile (device, cur_profile_name) + -- clear last profile as we will restore it now + saveLastProfile (device, nil) - -- switch to headset profile - Log.info ("Setting profile of '" + -- restore previous profile + Log.info ("Restoring profile of '" .. device.properties ["device.description"] .. "' from: " .. cur_profile_name .. " to: " .. name) device:set_params ("Profile", pod) else - Log.warning ("Got invalid index when switching profile") - end - - ::skip_device:: - end -end - -local function restoreProfile () - for device in devices_om:iterate () do - if isSwitchedToHeadsetProfile (device) then - local profile_name = getSavedLastProfile (device) - local cur_profile_name = getCurrentProfile (device) - - if cur_profile_name then - Log.info ("Setting saved headset profile to: " .. cur_profile_name) - saveHeadsetProfile (device, cur_profile_name) - end - - if profile_name then - local _, index, name = findProfile (device, nil, profile_name) - - if index ~= INVALID then - local pod = Pod.Object { - "Spa:Pod:Object:Param:Profile", "Profile", - index = index - } - - -- clear last profile as we will restore it now - saveLastProfile (device, nil) - - -- restore previous profile - Log.info ("Restoring profile of '" - .. device.properties ["device.description"] - .. "' from: " .. cur_profile_name - .. " to: " .. name) - device:set_params ("Profile", pod) - else - Log.warning ("Failed to restore profile") - end - end + Log.warning ("Failed to restore profile") end end end -local function triggerRestoreProfile () - if restore_timeout_source then - return - end - +local function triggerRestoreProfile (dev_id) -- we never restore the device profiles if there are active streams - if next (active_streams) ~= nil then - return + for _, v in pairs (active_streams) do + if v == dev_id then + return + end end - restore_timeout_source = Core.timeout_add (profile_restore_timeout_msec, function () - restore_timeout_source = nil - restoreProfile () + restore_timeout_source[dev_id] = nil + restore_timeout_source[dev_id] = Core.timeout_add (profile_restore_timeout_msec, function () + restore_timeout_source[dev_id] = nil + restoreProfile (dev_id) end) end --- We consider a Stream of interest to have role Communication if it has --- media.role set to Communication in props or it is in our list of --- applications as these applications do not set media.role correctly or at --- all. +-- We consider a Stream of interest if it is linked to a bluetooth loopback +-- source filter local function checkStreamStatus (stream) - local app_name = stream.properties ["application.name"] - local stream_role = stream.properties ["media.role"] + -- check if the stream is linked to a bluetooth loopback source + local stream_id = tonumber(stream["bound-id"]) + local peer_id = lutils.getNodePeerId (stream_id) + if peer_id ~= nil then + local bt_node = loopback_nodes_om:lookup { + Constraint { "bound-id", "=", peer_id, type = "gobject" } + } + if bt_node ~= nil then + local dev_id = bt_node.properties["device.id"] + if dev_id ~= nil then + -- If a stream we previously saw stops running, we consider it + -- inactive, because some applications (Teams) just cork input + -- streams, but don't close them. + if previous_streams [stream.id] == dev_id and + stream.state ~= "running" then + return nil + end - if not (stream_role == "Communication" or applications [app_name]) then - return false - end - if not isBluez5DefaultAudioSink () then - return false + return dev_id + end + end end - -- If a stream we previously saw stops running, we consider it - -- inactive, because some applications (Teams) just cork input - -- streams, but don't close them. - if previous_streams [stream.id] and stream.state ~= "running" then - return false - end - - return true + return nil end local function handleStream (stream) @@ -341,59 +345,67 @@ local function handleStream (stream) return end - if checkStreamStatus (stream) then - active_streams [stream.id] = true - previous_streams [stream.id] = true - switchDevicesToHeadsetProfile () + local dev_id = checkStreamStatus (stream) + if dev_id ~= nil then + active_streams [stream.id] = dev_id + previous_streams [stream.id] = dev_id + switchDeviceToHeadsetProfile (dev_id) else + dev_id = active_streams [stream.id] active_streams [stream.id] = nil - triggerRestoreProfile () + if dev_id ~= nil then + triggerRestoreProfile (dev_id) + end end end local function handleAllStreams () - for stream in streams_om:iterate { - Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, - Constraint { "stream.monitor", "!", "true" } - } do + for stream in streams_om:iterate() do handleStream (stream) end end SimpleEventHook { - name = "input-stream-removed@autoswitch-bluetooth-profile", + name = "node-removed@autoswitch-bluetooth-profile", interests = { EventInterest { Constraint { "event.type", "=", "node-removed" }, Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, + Constraint { "bluez5.loopback", "!", "true", type = "pw" }, }, }, execute = function (event) - stream = event:get_subject () + local stream = event:get_subject () + local dev_id = active_streams[stream.id] active_streams[stream.id] = nil previous_streams[stream.id] = nil - triggerRestoreProfile () + if dev_id ~= nil then + triggerRestoreProfile (dev_id) + end end }:register () SimpleEventHook { - name = "input-stream-changed@autoswitch-bluetooth-profile", + name = "link-added@autoswitch-bluetooth-profile", interests = { EventInterest { - Constraint { "event.type", "=", "node-state-changed" }, - Constraint { "media.class", "#", "Stream/Input/Audio", type = "pw-global" }, - -- Do not consider monitor streams - Constraint { "stream.monitor", "!", "true" } - }, - EventInterest { - Constraint { "event.type", "=", "node-params-changed" }, - Constraint { "media.class", "#", "Stream/Input/Audio", type = "pw-global" }, - -- Do not consider monitor streams - Constraint { "stream.monitor", "!", "true" } + Constraint { "event.type", "=", "link-added" }, }, }, execute = function (event) - handleStream (event:get_subject ()) + local link = event:get_subject () + local p = link.properties + for stream in streams_om:iterate () do + local in_id = tonumber(p["link.input.node"]) + local out_id = tonumber(p["link.output.node"]) + local stream_id = tonumber(stream["bound-id"]) + local bt_node = loopback_nodes_om:lookup { + Constraint { "bound-id", "=", out_id, type = "gobject" } + } + if in_id == stream_id and bt_node ~= nil then + handleStream (stream) + end + end end }:register () @@ -406,32 +418,14 @@ SimpleEventHook { }, }, execute = function (event) + local device = event:get_subject () -- Devices are unswitched initially - device = event:get_subject () saveLastProfile (device, nil) - handleAllStreams () end }:register () -SimpleEventHook { - name = "metadata-changed@autoswitch-bluetooth-profile", - interests = { - EventInterest { - Constraint { "event.type", "=", "metadata-changed" }, - Constraint { "metadata.name", "=", "default" }, - Constraint { "event.subject.key", "=", "default.audio.sink" }, - Constraint { "event.subject.id", "=", "0" }, - Constraint { "event.subject.value", "#", "*bluez_output*" }, - }, - }, - execute = function (event) - if (settings.autoswitch_to_headset_profile) then - -- If bluez sink is set as default, rescan for active input streams - handleAllStreams () - end - end -}:register () - devices_om:activate () streams_om:activate () +loopback_nodes_om:activate() + diff --git a/src/scripts/lib/linking-utils.lua b/src/scripts/lib/linking-utils.lua index 69364286..a849828d 100644 --- a/src/scripts/lib/linking-utils.lua +++ b/src/scripts/lib/linking-utils.lua @@ -123,6 +123,20 @@ function lutils.isLinked (si_target) return linked, exclusive end +function lutils.getNodePeerId (node_id) + for l in cutils.get_object_manager ("link"):iterate() do + local p = l.properties + local in_id = tonumber(p["link.input.node"]) + local out_id = tonumber(p["link.output.node"]) + if in_id == node_id then + return out_id + elseif out_id == node_id then + return in_id + end + end + return nil +end + function lutils.canLink (properties, si_target) local target_props = si_target.properties diff --git a/src/scripts/lib/settings-bluetooth.lua b/src/scripts/lib/settings-bluetooth.lua index 6568bce9..a2c1e120 100644 --- a/src/scripts/lib/settings-bluetooth.lua +++ b/src/scripts/lib/settings-bluetooth.lua @@ -10,13 +10,7 @@ local settings_manager = require ("settings-manager") local defaults = { ["use-persistent-storage"] = true, - ["autoswitch-to-headset-profile"] = true, - ["autoswitch-applications"] = { - "Firefox", "Chromium input", "Google Chrome input", "Brave input", - "Microsoft Edge input", "Vivaldi input", "ZOOM VoiceEngine", - "Telegram Desktop", "telegram-desktop", "linphone", "Mumble", - "WEBRTC VoiceEngine", "Skype" - } + ["autoswitch-to-headset-profile"] = true } return settings_manager.new ("bluetooth.", defaults) diff --git a/src/scripts/monitors/bluez.lua b/src/scripts/monitors/bluez.lua index a1d24f94..8ba5a9f7 100644 --- a/src/scripts/monitors/bluez.lua +++ b/src/scripts/monitors/bluez.lua @@ -6,6 +6,8 @@ -- SPDX-License-Identifier: MIT COMBINE_OFFSET = 64 +LOOPBACK_SOURCE_ID = 128 +DEVICE_SOURCE_ID = 0 cutils = require ("common-utils") log = Log.open_topic ("s-monitors") @@ -230,6 +232,7 @@ end function createNode(parent, id, type, factory, properties) local dev_props = parent.properties + local parent_id = parent["bound-id"] if config.properties["bluez5.hw-offload-sco"] and factory:find("sco") then createOffloadScoNode(parent, id, type, factory, properties) @@ -237,7 +240,7 @@ function createNode(parent, id, type, factory, properties) end -- set the device id and spa factory name; REQUIRED, do not change - properties["device.id"] = parent["bound-id"] + properties["device.id"] = parent_id properties["factory.name"] = factory -- set the default pause-on-idle setting @@ -286,6 +289,7 @@ function createNode(parent, id, type, factory, properties) local combine = createSetNode(parent, id, type, factory, properties) parent:store_managed_object(id + COMBINE_OFFSET, combine) else + properties["bluez5.loopback"] = false local node = LocalNode("adapter", properties) node:activate(Feature.Proxy.BOUND) parent:store_managed_object(id, node) @@ -378,6 +382,99 @@ function createMonitor() return monitor end +function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id) + local args = Json.Object { + ["capture.props"] = Json.Object { + ["node.name"] = string.format ("bluez_capture.%s", dev_name), + ["node.description"] = + string.format ("Bluetooth capture for %s", dec_desc), + ["audio.channels"] = 1, + ["audio.position"] = "[MONO]", + ["bluez5.loopback"] = true, + ["stream.dont-remix"] = true, + ["node.passive"] = true, + ["target.dont-fallback"] = true, + ["target.linger"] = true + }, + ["playback.props"] = Json.Object { + ["node.name"] = string.format ("bluez_source.%s", dev_name), + ["node.description"] = + string.format ("Bluetooth source for %s", dec_desc), + ["audio.position"] = "[MONO]", + ["media.class"] = "Audio/Source", + ["device.id"] = dev_id, + ["card.profile.device"] = DEVICE_SOURCE_ID, + ["priority.driver"] = 2010, + ["priority.session"] = 2010, + ["bluez5.loopback"] = true, + ["filter.smart"] = true, + ["filter.smart.target"] = Json.Object { + ["media.class"] = "Audio/Source", + ["device.api"] = "bluez5", + ["bluez5.loopback"] = false, + ["device.id"] = dev_id + } + } + } + return LocalModule("libpipewire-module-loopback", args:get_data(), {}) +end + +function checkProfiles (dev) + local device_id = dev["bound-id"] + local props = dev.properties + + -- Get the associated BT SpaDevice + local internal_id = tostring (props["api.bluez5.id"]) + local spa_device = monitor:get_managed_object (internal_id) + if spa_device == nil then + return + end + + -- Ignore devices that don't support both A2DP sink and HSP/HFP profiles + local has_a2dpsink_profile = false + local has_headset_profile = false + for p in dev:iterate_params("EnumProfile") do + local profile = cutils.parseParam (p, "EnumProfile") + if profile.name:find ("a2dp") and profile.name:find ("sink") then + has_a2dpsink_profile = true + elseif profile.name:find ("headset") then + has_headset_profile = true + end + end + if not has_a2dpsink_profile or not has_headset_profile then + return + end + + -- Create the loopback device if never created before + local loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID) + if loopback == nil then + local dev_name = props["api.bluez5.address"] or props["device.name"] + local dec_desc = props["device.description"] or props["device.name"] + or props["device.nick"] or props["device.alias"] or "bluetooth-device" + -- sanitize description, replace ':' with ' ' + dec_desc = dec_desc:gsub("(:)", " ") + loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id) + spa_device:store_managed_object(LOOPBACK_SOURCE_ID, loopback) + end +end + +function onDeviceParamsChanged (dev, param_name) + if param_name == "EnumProfile" then + checkProfiles (dev) + end +end + +devices_om:connect("object-added", function(_, dev) + -- Ignore all devices that are not BT devices + if dev.properties["device.api"] ~= "bluez5" then + return + end + + -- check available profiles + dev:connect ("params-changed", onDeviceParamsChanged) + checkProfiles (dev) +end) + if config.seat_monitoring then logind_plugin = Plugin.find("logind") end