monitors/bluez.lua: Create sink loopback for SCO-A2DP sink nodes

If the BT profile autoswitch setting is enabled, we also want to create a sink
loopback for SCO-A2DP sink nodes. Since BT nodes are removed and created again
when the profile changes, this avoids confusing some apps making them think
that the BT profile has not changed at all, because the loopback nodes are
always present, even when switching profiles.
This commit is contained in:
Julian Bouzas 2025-10-10 09:05:58 -04:00
parent e94caea3a4
commit 94aabdb370

View file

@ -7,7 +7,9 @@
COMBINE_OFFSET = 64 COMBINE_OFFSET = 64
LOOPBACK_SOURCE_ID = 128 LOOPBACK_SOURCE_ID = 128
LOOPBACK_SINK_ID = 129
DEVICE_SOURCE_ID = 0 DEVICE_SOURCE_ID = 0
DEVICE_SINK_ID = 1
cutils = require ("common-utils") cutils = require ("common-utils")
log = Log.open_topic ("s-monitors") log = Log.open_topic ("s-monitors")
@ -23,6 +25,8 @@ config.properties["api.bluez5.connection-info"] = true
-- Properties used for previously creating a SCO source node. key: SPA device id -- Properties used for previously creating a SCO source node. key: SPA device id
sco_source_node_properties = {} sco_source_node_properties = {}
-- Properties used for previously creating a SCO or A2DP sink node. key: SPA device id
sco_a2dp_sink_node_properties = {}
devices_om = ObjectManager { devices_om = ObjectManager {
Interest { Interest {
@ -271,6 +275,16 @@ function createNode(parent, id, type, factory, properties)
name_prefix = name_prefix .. "_internal" name_prefix = name_prefix .. "_internal"
end end
-- hide the sink node because we use the loopback sink instead
if parent:get_managed_object (LOOPBACK_SINK_ID) ~= nil and
(factory == "api.bluez5.sco.sink" or
factory == "api.bluez5.a2dp.sink") then
properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
-- add 'internal' to name prefix to not be confused with loopback node
name_prefix = name_prefix .. "_internal"
end
-- set the node name -- set the node name
local name = name_prefix .. "." .. local name = name_prefix .. "." ..
(properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." ..
@ -304,9 +318,12 @@ function createNode(parent, id, type, factory, properties)
parent:set_managed_pending(id) parent:set_managed_pending(id)
else else
log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id)) log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id))
properties["bluez5.loopback"] = false
if factory == "api.bluez5.sco.source" then if factory == "api.bluez5.sco.source" then
properties["bluez5.loopback"] = false
sco_source_node_properties[parent_spa_id] = properties sco_source_node_properties[parent_spa_id] = properties
elseif factory == "api.bluez5.sco.sink" or factory == "api.bluez5.a2dp.sink" then
properties["bluez5.sink-loopback"] = false
sco_a2dp_sink_node_properties[parent_spa_id] = properties
end end
local node = LocalNode("adapter", properties) local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND) node:activate(Feature.Proxy.BOUND)
@ -318,14 +335,20 @@ function removeNode(parent, id)
local dev_props = parent.properties local dev_props = parent.properties
local parent_spa_id = tonumber(dev_props["spa.object.id"]) local parent_spa_id = tonumber(dev_props["spa.object.id"])
local src_properties = sco_source_node_properties[parent_spa_id] local src_properties = sco_source_node_properties[parent_spa_id]
local sink_properties = sco_a2dp_sink_node_properties[parent_spa_id]
log:debug("Remove node: " .. tostring (id)) log:debug("Remove node: " .. tostring (id))
if src_properties ~= nil and id == tonumber(src_properties["spa.object.id"]) then if src_properties ~= nil and id == tonumber(src_properties["spa.object.id"]) then
log:debug("Clear old SCO properties") log:debug("Clear old SCO source properties")
sco_source_node_properties[parent_spa_id] = nil sco_source_node_properties[parent_spa_id] = nil
end end
if sink_properties ~= nil and id == tonumber(sink_properties["spa.object.id"]) then
log:debug("Clear old SCO-A2DP sink properties")
sco_a2dp_sink_node_properties[parent_spa_id] = nil
end
-- Clear also the device set module, if any -- Clear also the device set module, if any
parent:store_managed_object(id + COMBINE_OFFSET, nil) parent:store_managed_object(id + COMBINE_OFFSET, nil)
end end
@ -400,6 +423,7 @@ end
function removeDevice(parent, id) function removeDevice(parent, id)
sco_source_node_properties[id] = nil sco_source_node_properties[id] = nil
sco_a2dp_sink_node_properties[id] = nil
end end
function createMonitor() function createMonitor()
@ -456,6 +480,42 @@ function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id)
return LocalModule("libpipewire-module-loopback", args:get_data(), {}) return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end end
function CreateDeviceLoopbackSink (dev_name, dec_desc, dev_id)
local args = Json.Object {
["capture.props"] = Json.Object {
["node.name"] = string.format ("bluez_output.%s", dev_name),
["node.description"] = string.format ("%s", dec_desc),
["node.virtual"] = false,
["audio.position"] = "[FL, FR]",
["media.class"] = "Audio/Sink",
["device.id"] = dev_id,
["card.profile.device"] = DEVICE_SINK_ID,
["device.routes"] = "1",
["priority.driver"] = 2010,
["priority.session"] = 2010,
["bluez5.sink-loopback"] = true,
["filter.smart"] = true,
["filter.smart.target"] = Json.Object {
["bluez5.sink-loopback-target"] = true,
["bluez5.sink-loopback"] = false,
["device.id"] = dev_id
}
},
["playback.props"] = Json.Object {
["node.name"] = string.format ("bluez_playback_internal.%s", dev_name),
["media.class"] = "Stream/Output/Audio/Internal",
["node.description"] =
string.format ("Bluetooth internal playback stream for %s", dec_desc),
["bluez5.sink-loopback"] = true,
["node.passive"] = true,
["node.dont-fallback"] = true,
["node.linger"] = true,
["state.restore-props"] = false,
}
}
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end
function checkProfiles (dev) function checkProfiles (dev)
local device_id = dev["bound-id"] local device_id = dev["bound-id"]
local props = dev.properties local props = dev.properties
@ -488,19 +548,19 @@ function checkProfiles (dev)
return return
end end
-- Create the loopback device if never created before -- Create the source loopback device if never created before
local loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID) local source_loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
if loopback == nil then if source_loopback == nil then
local dev_name = props["api.bluez5.address"] or props["device.name"] local dev_name = props["api.bluez5.address"] or props["device.name"]
local dec_desc = props["device.description"] 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" or props["device.nick"] or props["device.alias"] or "bluetooth-device"
log:info("create SCO loopback node: " .. dev_name) log:info("create SCO source loopback node: " .. dev_name)
-- sanitize description, replace ':' with ' ' -- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ") dec_desc = dec_desc:gsub("(:)", " ")
loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id) source_loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SOURCE_ID, loopback) spa_device:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback)
-- recreate any sco source node -- recreate any sco source node
local properties = sco_source_node_properties[device_spa_id] local properties = sco_source_node_properties[device_spa_id]
@ -521,6 +581,39 @@ function checkProfiles (dev)
end end
end end
end end
local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID)
if sink_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"
log:info("create SCO-A2DP sink loopback node: " .. dev_name)
-- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ")
sink_loopback = CreateDeviceLoopbackSink (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SINK_ID, sink_loopback)
-- recreate any sco-a2dp sink node
local properties = sco_a2dp_sink_node_properties[device_spa_id]
if properties ~= nil then
local node_id = tonumber(properties["spa.object.id"])
local node = spa_device:get_managed_object (node_id)
if node ~= nil then
log:info("Recreate node: " .. properties["node.name"] .. ": " ..
properties["factory.name"] .. " " .. tostring (node_id))
spa_device:store_managed_object(node_id, nil)
properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
spa_device:store_managed_object(node_id, node)
end
end
end
end end
function onDeviceParamsChanged (dev, param_name) function onDeviceParamsChanged (dev, param_name)