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.
This commit is contained in:
Julian Bouzas 2023-11-15 12:43:34 -05:00 committed by George Kiagiadakis
parent 68c6fc2a38
commit 874a432c69
6 changed files with 284 additions and 196 deletions

View file

@ -180,7 +180,6 @@ files and are placed under ``wireplumber.conf.d/``. More on this below.
alsa_monitor.alsa.reserve = true alsa_monitor.alsa.reserve = true
alsa_monitor.alsa.midi = "true" alsa_monitor.alsa.midi = "true"
default-policy-duck.level = 0.3 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. Value can be string, int, float, boolean and can even be a JSON array.

View file

@ -6,16 +6,6 @@ wireplumber.settings = {
## Whether to use headset profile in the presence of an input stream. ## Whether to use headset profile in the presence of an input stream.
# bluetooth.autoswitch-to-headset-profile = true # 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 = { monitor.bluez.properties = {

View file

@ -11,33 +11,33 @@
-- --
-- SPDX-License-Identifier: MIT -- SPDX-License-Identifier: MIT
-- --
-- Scriupt Checks for the existence of media.role and if present switches the -- This script is charged to automatically change BT profiles on a device. If a
-- bluetooth profile accordingly. Also see bluez-autoswitch in media-session. -- client is linked to the device's loopback source node, the associated BT
-- The intended logic of the script is as follows. -- 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 -- We switch to the highest priority profile that has an Input route available.
-- speak in props, we switch to the highest priority profile that has an Input -- The reason for this is that we may have microphone enabled with non-HFP
-- route available. The reason for this is that we may have microphone enabled -- codecs eg. Faststream.
-- 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.
-- When a stream goes away if the list with which we track the streams above -- 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. -- is empty, then we revert back to the old profile.
-- settings file: bluetooth.conf -- settings file: bluetooth.conf
lutils = require ("linking-utils")
cutils = require ("common-utils") cutils = require ("common-utils")
settings = require ("settings-bluetooth") settings = require ("settings-bluetooth")
state = nil state = nil
headset_profiles = nil headset_profiles = nil
device_loopback_sources = {}
local applications = {}
local profile_restore_timeout_msec = 2000 local profile_restore_timeout_msec = 2000
local INVALID = -1 local INVALID = -1
local timeout_source = nil local timeout_source = {}
local restore_timeout_source = nil local restore_timeout_source = {}
local last_profiles = {} local last_profiles = {}
@ -55,18 +55,9 @@ function handlePersistentSetting (enable)
end end
end end
function loadAppNames (appNames)
applications = {}
for i = 1, #appNames do
applications [appNames [i]] = true
end
end
handlePersistentSetting (settings.use_persistent_storage) handlePersistentSetting (settings.use_persistent_storage)
loadAppNames (settings.autoswitch_applications)
settings:subscribe ("use-persistent-storage", handlePersistentSetting) settings:subscribe ("use-persistent-storage", handlePersistentSetting)
settings:subscribe ("autoswitch-applications", loadAppNames)
devices_om = ObjectManager { devices_om = ObjectManager {
Interest { Interest {
@ -79,8 +70,16 @@ streams_om = ObjectManager {
Interest { Interest {
type = "node", type = "node",
Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
-- Do not consider monitor streams Constraint { "stream.monitor", "!", "true", type = "pw" },
Constraint { "stream.monitor", "!", "true" } 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 return getSavedLastProfile (device) ~= nil
end 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) local function findProfile (device, index, name)
for p in device:iterate_params ("EnumProfile") do for p in device:iterate_params ("EnumProfile") do
local profile = cutils.parseParam (p, "EnumProfile") local profile = cutils.parseParam (p, "EnumProfile")
@ -203,27 +189,35 @@ local function hasProfileInputRoute (device, profile_index)
return false return false
end end
local function switchDevicesToHeadsetProfile () local function switchDeviceToHeadsetProfile (dev_id)
local index local index
local name local name
-- clear restore callback, if any -- Find the actual device
if restore_timeout_source then local device = devices_om:lookup {
restore_timeout_source:destroy () Constraint { "bound-id", "=", dev_id, type = "gobject" }
restore_timeout_source = nil }
if device == nil then
Log.info ("Device with id " .. tostring(dev_id).. " not found")
return
end end
for device in devices_om:iterate () do
if isSwitchedToHeadsetProfile (device) then if isSwitchedToHeadsetProfile (device) then
goto skip_device Log.info ("Device with id " .. tostring(dev_id).. " is already switched to HSP/HFP")
return
end end
local cur_profile_name = getCurrentProfile (device) local cur_profile_name = getCurrentProfile (device)
_, index, name = findProfile (device, nil, cur_profile_name) _, index, name = findProfile (device, nil, cur_profile_name)
if hasProfileInputRoute (device, index) then if hasProfileInputRoute (device, index) then
Log.info ("Current profile has input route, not switching") Log.info ("Current profile has input route, not switching")
goto skip_device return
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 end
local saved_headset_profile = getSavedHeadsetProfile (device) local saved_headset_profile = getSavedHeadsetProfile (device)
@ -253,14 +247,23 @@ local function switchDevicesToHeadsetProfile ()
else else
Log.warning ("Got invalid index when switching profile") Log.warning ("Got invalid index when switching profile")
end end
::skip_device::
end
end end
local function restoreProfile () local function restoreProfile (dev_id)
for device in devices_om:iterate () do -- Find the actual device
if isSwitchedToHeadsetProfile (device) then 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 profile_name = getSavedLastProfile (device)
local cur_profile_name = getCurrentProfile (device) local cur_profile_name = getCurrentProfile (device)
@ -292,48 +295,49 @@ local function restoreProfile ()
end end
end end
end 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 -- we never restore the device profiles if there are active streams
if next (active_streams) ~= nil then for _, v in pairs (active_streams) do
if v == dev_id then
return return
end end
end
restore_timeout_source = Core.timeout_add (profile_restore_timeout_msec, function () restore_timeout_source[dev_id] = nil
restore_timeout_source = nil restore_timeout_source[dev_id] = Core.timeout_add (profile_restore_timeout_msec, function ()
restoreProfile () restore_timeout_source[dev_id] = nil
restoreProfile (dev_id)
end) end)
end end
-- We consider a Stream of interest to have role Communication if it has -- We consider a Stream of interest if it is linked to a bluetooth loopback
-- media.role set to Communication in props or it is in our list of -- source filter
-- applications as these applications do not set media.role correctly or at
-- all.
local function checkStreamStatus (stream) local function checkStreamStatus (stream)
local app_name = stream.properties ["application.name"] -- check if the stream is linked to a bluetooth loopback source
local stream_role = stream.properties ["media.role"] local stream_id = tonumber(stream["bound-id"])
local peer_id = lutils.getNodePeerId (stream_id)
if not (stream_role == "Communication" or applications [app_name]) then if peer_id ~= nil then
return false local bt_node = loopback_nodes_om:lookup {
end Constraint { "bound-id", "=", peer_id, type = "gobject" }
if not isBluez5DefaultAudioSink () then }
return false if bt_node ~= nil then
end local dev_id = bt_node.properties["device.id"]
if dev_id ~= nil then
-- If a stream we previously saw stops running, we consider it -- If a stream we previously saw stops running, we consider it
-- inactive, because some applications (Teams) just cork input -- inactive, because some applications (Teams) just cork input
-- streams, but don't close them. -- streams, but don't close them.
if previous_streams [stream.id] and stream.state ~= "running" then if previous_streams [stream.id] == dev_id and
return false stream.state ~= "running" then
return nil
end end
return true return dev_id
end
end
end
return nil
end end
local function handleStream (stream) local function handleStream (stream)
@ -341,59 +345,67 @@ local function handleStream (stream)
return return
end end
if checkStreamStatus (stream) then local dev_id = checkStreamStatus (stream)
active_streams [stream.id] = true if dev_id ~= nil then
previous_streams [stream.id] = true active_streams [stream.id] = dev_id
switchDevicesToHeadsetProfile () previous_streams [stream.id] = dev_id
switchDeviceToHeadsetProfile (dev_id)
else else
dev_id = active_streams [stream.id]
active_streams [stream.id] = nil active_streams [stream.id] = nil
triggerRestoreProfile () if dev_id ~= nil then
triggerRestoreProfile (dev_id)
end
end end
end end
local function handleAllStreams () local function handleAllStreams ()
for stream in streams_om:iterate { for stream in streams_om:iterate() do
Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
Constraint { "stream.monitor", "!", "true" }
} do
handleStream (stream) handleStream (stream)
end end
end end
SimpleEventHook { SimpleEventHook {
name = "input-stream-removed@autoswitch-bluetooth-profile", name = "node-removed@autoswitch-bluetooth-profile",
interests = { interests = {
EventInterest { EventInterest {
Constraint { "event.type", "=", "node-removed" }, Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
Constraint { "bluez5.loopback", "!", "true", type = "pw" },
}, },
}, },
execute = function (event) execute = function (event)
stream = event:get_subject () local stream = event:get_subject ()
local dev_id = active_streams[stream.id]
active_streams[stream.id] = nil active_streams[stream.id] = nil
previous_streams[stream.id] = nil previous_streams[stream.id] = nil
triggerRestoreProfile () if dev_id ~= nil then
triggerRestoreProfile (dev_id)
end
end end
}:register () }:register ()
SimpleEventHook { SimpleEventHook {
name = "input-stream-changed@autoswitch-bluetooth-profile", name = "link-added@autoswitch-bluetooth-profile",
interests = { interests = {
EventInterest { EventInterest {
Constraint { "event.type", "=", "node-state-changed" }, Constraint { "event.type", "=", "link-added" },
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" }
}, },
}, },
execute = function (event) 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 end
}:register () }:register ()
@ -406,32 +418,14 @@ SimpleEventHook {
}, },
}, },
execute = function (event) execute = function (event)
local device = event:get_subject ()
-- Devices are unswitched initially -- Devices are unswitched initially
device = event:get_subject ()
saveLastProfile (device, nil) saveLastProfile (device, nil)
handleAllStreams () handleAllStreams ()
end end
}:register () }: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 () devices_om:activate ()
streams_om:activate () streams_om:activate ()
loopback_nodes_om:activate()

View file

@ -123,6 +123,20 @@ function lutils.isLinked (si_target)
return linked, exclusive return linked, exclusive
end 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) function lutils.canLink (properties, si_target)
local target_props = si_target.properties local target_props = si_target.properties

View file

@ -10,13 +10,7 @@ local settings_manager = require ("settings-manager")
local defaults = { local defaults = {
["use-persistent-storage"] = true, ["use-persistent-storage"] = true,
["autoswitch-to-headset-profile"] = 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"
}
} }
return settings_manager.new ("bluetooth.", defaults) return settings_manager.new ("bluetooth.", defaults)

View file

@ -6,6 +6,8 @@
-- SPDX-License-Identifier: MIT -- SPDX-License-Identifier: MIT
COMBINE_OFFSET = 64 COMBINE_OFFSET = 64
LOOPBACK_SOURCE_ID = 128
DEVICE_SOURCE_ID = 0
cutils = require ("common-utils") cutils = require ("common-utils")
log = Log.open_topic ("s-monitors") log = Log.open_topic ("s-monitors")
@ -230,6 +232,7 @@ end
function createNode(parent, id, type, factory, properties) function createNode(parent, id, type, factory, properties)
local dev_props = parent.properties local dev_props = parent.properties
local parent_id = parent["bound-id"]
if config.properties["bluez5.hw-offload-sco"] and factory:find("sco") then if config.properties["bluez5.hw-offload-sco"] and factory:find("sco") then
createOffloadScoNode(parent, id, type, factory, properties) createOffloadScoNode(parent, id, type, factory, properties)
@ -237,7 +240,7 @@ function createNode(parent, id, type, factory, properties)
end end
-- set the device id and spa factory name; REQUIRED, do not change -- 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 properties["factory.name"] = factory
-- set the default pause-on-idle setting -- 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) local combine = createSetNode(parent, id, type, factory, properties)
parent:store_managed_object(id + COMBINE_OFFSET, combine) parent:store_managed_object(id + COMBINE_OFFSET, combine)
else else
properties["bluez5.loopback"] = false
local node = LocalNode("adapter", properties) local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND) node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node) parent:store_managed_object(id, node)
@ -378,6 +382,99 @@ function createMonitor()
return monitor return monitor
end 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 if config.seat_monitoring then
logind_plugin = Plugin.find("logind") logind_plugin = Plugin.find("logind")
end end