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