mirror of
https://gitlab.freedesktop.org/pipewire/wireplumber.git
synced 2026-05-26 07:48:11 +02:00
476 lines
14 KiB
Lua
476 lines
14 KiB
Lua
-- WirePlumber
|
|
--
|
|
-- Copyright © 2021 Asymptotic Inc.
|
|
-- @author Sanchayan Maity <sanchayan@asymptotic.io>
|
|
--
|
|
-- Based on bt-profile-switch.lua in tests/examples
|
|
-- Copyright © 2021 George Kiagiadakis
|
|
--
|
|
-- Based on bluez-autoswitch in media-session
|
|
-- Copyright © 2021 Pauli Virtanen
|
|
--
|
|
-- 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.
|
|
--
|
|
-- 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.
|
|
-- 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: policy.conf
|
|
|
|
local cutils = require ("common-utils")
|
|
|
|
local defaults = {}
|
|
defaults.use_persistent_storage = true
|
|
defaults.use_headset_profile = true
|
|
defaults.app_settings = Json.Array {
|
|
"Firefox", "Chromium input", "Google Chrome input", "Brave input",
|
|
"Microsoft Edge input", "Vivaldi input", "ZOOM VoiceEngine",
|
|
"Telegram Desktop", "telegram-desktop", "linphone", "Mumble",
|
|
"WEBRTC VoiceEngine", "Skype"
|
|
}
|
|
|
|
local config = {}
|
|
config.use_persistent_storage = Settings.parse_boolean_safe (
|
|
"policy.bluetooth.use-persistent-storage", defaults.use_persistent_storage)
|
|
config.use_headset_profile = Settings.parse_boolean_safe (
|
|
"policy.bluetooth.media-role.use-headset-profile", defaults.use_headset_profile)
|
|
config.apps_setting = Settings.parse_array_safe (
|
|
"policy.bluetooth.media-role.applications", defaults.app_settings)
|
|
|
|
state = nil
|
|
headset_profiles = nil
|
|
|
|
function handlePersistantSetting (enable)
|
|
if enable and state == nil then
|
|
-- the state storage
|
|
state = config.use_persistent_storage and State ("policy-bluetooth") or nil
|
|
headset_profiles = state and state:load () or {}
|
|
else
|
|
state = nil
|
|
headset_profiles = nil
|
|
end
|
|
end
|
|
|
|
local function settingsChangedCallback (_, setting, _)
|
|
if setting == "policy.bluetooth.use-persistent-storage" then
|
|
config.use_persistent_storage = Settings.parse_boolean_safe
|
|
("policy.bluetooth.use-persistent-storage", config.use_persistent_storage)
|
|
handlePersistantSetting (config.use_persistent_storage)
|
|
elseif setting == "policy.bluetooth.media-role.use-headset-profile" then
|
|
config.use_headset_profile = Settings.parse_boolean_safe
|
|
("policy.bluetooth.media-role.use-headset-profile", config.use_headset_profile)
|
|
elseif setting == "policy.bluetooth.media-role.applications" then
|
|
local new_apps_setting = Settings.parse_array_safe
|
|
("policy.bluetooth.media-role.applications", Json.Array {})
|
|
if #new_apps_setting > 0 then
|
|
config.apps_setting = new_apps_setting
|
|
loadAppNames (config.apps_setting)
|
|
end
|
|
end
|
|
end
|
|
|
|
Settings.subscribe ("policy.bluetooth*", settingsChangedCallback)
|
|
|
|
handlePersistantSetting (config.use_persistent_storage)
|
|
|
|
local applications = {}
|
|
local profile_restore_timeout_msec = 2000
|
|
|
|
local INVALID = -1
|
|
local timeout_source = nil
|
|
local restore_timeout_source = nil
|
|
|
|
local last_profiles = {}
|
|
|
|
local active_streams = {}
|
|
local previous_streams = {}
|
|
|
|
function loadAppNames (appNames)
|
|
for i = 1, #appNames do
|
|
applications [appNames [i]] = true
|
|
end
|
|
end
|
|
|
|
loadAppNames (config.apps_setting)
|
|
|
|
devices_om = ObjectManager {
|
|
Interest {
|
|
type = "device",
|
|
Constraint { "device.api", "=", "bluez5" },
|
|
}
|
|
}
|
|
|
|
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" }
|
|
}
|
|
}
|
|
|
|
local function saveHeadsetProfile (device, profile_name)
|
|
local key = "saved-headset-profile:" .. device.properties ["device.name"]
|
|
headset_profiles [key] = profile_name
|
|
cutils.storeAfterTimeout (state, headset_profiles)
|
|
end
|
|
|
|
local function getSavedHeadsetProfile (device)
|
|
local key = "saved-headset-profile:" .. device.properties ["device.name"]
|
|
return headset_profiles [key]
|
|
end
|
|
|
|
local function saveLastProfile (device, profile_name)
|
|
last_profiles [device.properties ["device.name"]] = profile_name
|
|
end
|
|
|
|
local function getSavedLastProfile (device)
|
|
return last_profiles [device.properties ["device.name"]]
|
|
end
|
|
|
|
local function isSwitched (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.default_metadata_om:lookup ()
|
|
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")
|
|
if not profile then
|
|
goto skip_enum_profile
|
|
end
|
|
|
|
Log.debug ("Profile name: " .. profile.name .. ", priority: "
|
|
.. tostring (profile.priority) .. ", index: " .. tostring (profile.index))
|
|
if (index ~= nil and profile.index == index) or
|
|
(name ~= nil and profile.name == name) then
|
|
return profile.priority, profile.index, profile.name
|
|
end
|
|
|
|
::skip_enum_profile::
|
|
end
|
|
|
|
return INVALID, INVALID, nil
|
|
end
|
|
|
|
local function getCurrentProfile (device)
|
|
for p in device:iterate_params ("Profile") do
|
|
local profile = cutils.parseParam (p, "Profile")
|
|
if profile then
|
|
return profile.name
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
local function highestPrioProfileWithInputRoute (device)
|
|
local profile_priority = INVALID
|
|
local profile_index = INVALID
|
|
local profile_name = nil
|
|
|
|
for p in device:iterate_params ("EnumRoute") do
|
|
local route = cutils.parseParam (p, "EnumRoute")
|
|
-- Parse pod
|
|
if not route then
|
|
goto skip_enum_route
|
|
end
|
|
|
|
if route.direction ~= "Input" then
|
|
goto skip_enum_route
|
|
end
|
|
|
|
Log.debug ("Route with index: " .. tostring (route.index) .. ", direction: "
|
|
.. route.direction .. ", name: " .. route.name .. ", description: "
|
|
.. route.description .. ", priority: " .. route.priority)
|
|
if route.profiles then
|
|
for _, v in pairs (route.profiles) do
|
|
local priority, index, name = findProfile (device, v)
|
|
if priority ~= INVALID then
|
|
if profile_priority < priority then
|
|
profile_priority = priority
|
|
profile_index = index
|
|
profile_name = name
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
::skip_enum_route::
|
|
end
|
|
|
|
return profile_priority, profile_index, profile_name
|
|
end
|
|
|
|
local function hasProfileInputRoute (device, profile_index)
|
|
for p in device:iterate_params ("EnumRoute") do
|
|
local route = cutils.parseParam (p, "EnumRoute")
|
|
if route and route.direction == "Input" and route.profiles then
|
|
for _, v in pairs (route.profiles) do
|
|
if v == profile_index then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function switchProfile ()
|
|
local index
|
|
local name
|
|
|
|
if restore_timeout_source then
|
|
restore_timeout_source:destroy ()
|
|
restore_timeout_source = nil
|
|
end
|
|
|
|
for device in devices_om:iterate () do
|
|
if isSwitched (device) then
|
|
goto skip_device
|
|
end
|
|
|
|
local cur_profile_name = getCurrentProfile (device)
|
|
saveLastProfile (device, cur_profile_name)
|
|
|
|
_, 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
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
::skip_device::
|
|
end
|
|
end
|
|
|
|
local function restoreProfile ()
|
|
for device in devices_om:iterate () do
|
|
if isSwitched (device) then
|
|
local profile_name = getSavedLastProfile (device)
|
|
local cur_profile_name = getCurrentProfile (device)
|
|
|
|
saveLastProfile (device, nil)
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
end
|
|
end
|
|
end
|
|
|
|
local function triggerRestoreProfile ()
|
|
if restore_timeout_source then
|
|
return
|
|
end
|
|
if next (active_streams) ~= nil then
|
|
return
|
|
end
|
|
restore_timeout_source = Core.timeout_add (profile_restore_timeout_msec, function ()
|
|
restore_timeout_source = nil
|
|
restoreProfile ()
|
|
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.
|
|
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
|
|
|
|
-- 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
|
|
end
|
|
|
|
local function handleStream (stream)
|
|
if not config.use_headset_profile then
|
|
return
|
|
end
|
|
|
|
if checkStreamStatus (stream) then
|
|
active_streams [stream.id] = true
|
|
previous_streams [stream.id] = true
|
|
switchProfile ()
|
|
else
|
|
active_streams [stream.id] = nil
|
|
triggerRestoreProfile ()
|
|
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
|
|
handleStream (stream)
|
|
end
|
|
end
|
|
|
|
SimpleEventHook {
|
|
name = "input-stream-removed@policy-bluetooth",
|
|
type = "on-event",
|
|
priority = "node-removed-policy-bluetooth",
|
|
interests = {
|
|
EventInterest {
|
|
Constraint { "event.type", "=", "object-removed" },
|
|
Constraint { "event.subject.type", "=", "node" },
|
|
Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
|
|
},
|
|
},
|
|
execute = function (event)
|
|
stream = event:get_subject ()
|
|
active_streams[stream.id] = nil
|
|
previous_streams[stream.id] = nil
|
|
triggerRestoreProfile ()
|
|
end
|
|
}:register ()
|
|
|
|
SimpleEventHook {
|
|
name = "input-stream-changed@policy-bluetooth",
|
|
type = "on-event",
|
|
priority = "node-parms-changed-policy-bluetooth",
|
|
interests = {
|
|
EventInterest {
|
|
Constraint { "event.type", "=", "state-changed" },
|
|
Constraint { "event.subject.type", "=", "node" },
|
|
Constraint { "media.class", "#", "Stream/Input/Audio", type = "pw-global" },
|
|
-- Do not consider monitor streams
|
|
Constraint { "stream.monitor", "!", "true" }
|
|
},
|
|
EventInterest {
|
|
Constraint { "event.type", "=", "params-changed" },
|
|
Constraint { "event.subject.type", "=", "node" },
|
|
Constraint { "media.class", "#", "Stream/Input/Audio", type = "pw-global" },
|
|
-- Do not consider monitor streams
|
|
Constraint { "stream.monitor", "!", "true" }
|
|
},
|
|
},
|
|
execute = function (event)
|
|
handleStream (event:get_subject ())
|
|
end
|
|
}:register ()
|
|
|
|
SimpleEventHook {
|
|
name = "bluez-device-added@policy-bluetooth",
|
|
type = "on-event",
|
|
priority = "device-added-policy-bluetooth",
|
|
interests = {
|
|
EventInterest {
|
|
Constraint { "event.type", "=", "object-added" },
|
|
Constraint { "event.subject.type", "=", "device" },
|
|
Constraint { "device.api", "=", "bluez5" },
|
|
},
|
|
},
|
|
execute = function (event)
|
|
-- Devices are unswitched initially
|
|
device = event:get_subject ()
|
|
if isSwitched (device) then
|
|
saveLastProfile (device, nil)
|
|
end
|
|
handleAllStreams ()
|
|
end
|
|
}:register ()
|
|
|
|
SimpleEventHook {
|
|
name = "metadata-changed@policy-bluetooth",
|
|
type = "on-event",
|
|
priority = "default-metadata-changed-policy-bluetooth",
|
|
interests = {
|
|
EventInterest {
|
|
Constraint { "event.type", "=", "object-changed" },
|
|
Constraint { "event.subject.type", "=", "metadata" },
|
|
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 (config.use_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 ()
|