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.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.

View file

@ -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 = {

View file

@ -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,27 +189,35 @@ 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
Log.info ("Device with id " .. tostring(dev_id).. " is already switched to HSP/HFP")
return
end
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")
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
local saved_headset_profile = getSavedHeadsetProfile (device)
@ -253,14 +247,23 @@ local function switchDevicesToHeadsetProfile ()
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 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)
@ -292,48 +295,49 @@ local function restoreProfile ()
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
if next (active_streams) ~= nil then
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"]
if not (stream_role == "Communication" or applications [app_name]) then
return false
end
if not isBluez5DefaultAudioSink () then
return false
end
-- 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] and stream.state ~= "running" then
return false
if previous_streams [stream.id] == dev_id and
stream.state ~= "running" then
return nil
end
return true
return dev_id
end
end
end
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()

View file

@ -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

View file

@ -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)

View file

@ -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