Adding lots of cool stuff to dotfiles

This commit is contained in:
Antoine Phan
2024-02-29 01:00:26 -05:00
parent b5ad2b6c39
commit dd2ef8ddac
390 changed files with 35966 additions and 1 deletions

View File

@@ -0,0 +1,53 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
function rulesGetDefaultPermissions(properties)
for _, r in ipairs(config.rules or {}) do
if r.default_permissions then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
return r.default_permissions
end
end
end
end
end
clients_om = ObjectManager {
Interest { type = "client" }
}
clients_om:connect("object-added", function (om, client)
local id = client["bound-id"]
local properties = client["properties"]
local perms = rulesGetDefaultPermissions(properties)
if perms then
Log.info(client, "Granting permissions to client " .. id .. ": " .. perms)
client:update_permissions { ["any"] = perms }
end
end)
clients_om:activate()

View File

@@ -0,0 +1,141 @@
MEDIA_ROLE_NONE = 0
MEDIA_ROLE_CAMERA = 1 << 0
function hasPermission (permissions, app_id, lookup)
if permissions then
for key, values in pairs(permissions) do
if key == app_id then
for _, v in pairs(values) do
if v == lookup then
return true
end
end
end
end
end
return false
end
function parseMediaRoles (media_roles_str)
local media_roles = MEDIA_ROLE_NONE
for role in media_roles_str:gmatch('[^,%s]+') do
if role == "Camera" then
media_roles = media_roles | MEDIA_ROLE_CAMERA
end
end
return media_roles
end
function setPermissions (client, allow_client, allow_nodes)
local client_id = client["bound-id"]
Log.info(client, "Granting ALL access to client " .. client_id)
-- Update permissions on client
client:update_permissions { [client_id] = allow_client and "all" or "-" }
-- Update permissions on camera source nodes
for node in nodes_om:iterate() do
local node_id = node["bound-id"]
client:update_permissions { [node_id] = allow_nodes and "all" or "-" }
end
end
function updateClientPermissions (client, permissions)
local client_id = client["bound-id"]
local str_prop = nil
local app_id = nil
local media_roles = nil
local allowed = false
-- Make sure the client is not the portal itself
str_prop = client.properties["pipewire.access.portal.is_portal"]
if str_prop == "yes" then
Log.info (client, "client is the portal itself")
return
end
-- Make sure the client has a portal app Id
str_prop = client.properties["pipewire.access.portal.app_id"]
if str_prop == nil then
Log.info (client, "Portal managed client did not set app_id")
return
end
if str_prop == "" then
Log.info (client, "Ignoring portal check for non-sandboxed client")
setPermissions (client, true, true)
return
end
app_id = str_prop
-- Make sure the client has portal media roles
str_prop = client.properties["pipewire.access.portal.media_roles"]
if str_prop == nil then
Log.info (client, "Portal managed client did not set media_roles")
return
end
media_roles = parseMediaRoles (str_prop)
if (media_roles & MEDIA_ROLE_CAMERA) == 0 then
Log.info (client, "Ignoring portal check for clients without camera role")
return
end
-- Update permissions
allowed = hasPermission (permissions, app_id, "yes")
Log.info (client, "setting permissions: " .. tostring(allowed))
setPermissions (client, allowed, allowed)
end
-- Create portal clients object manager
clients_om = ObjectManager {
Interest {
type = "client",
Constraint { "pipewire.access", "=", "portal" },
}
}
-- Set permissions to portal clients from the permission store if loaded
pps_plugin = Plugin.find("portal-permissionstore")
if pps_plugin then
nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.role", "=", "Camera" },
Constraint { "media.class", "=", "Video/Source" },
}
}
nodes_om:activate()
clients_om:connect("object-added", function (om, client)
local new_perms = pps_plugin:call("lookup", "devices", "camera");
updateClientPermissions (client, new_perms)
end)
nodes_om:connect("object-added", function (om, node)
local new_perms = pps_plugin:call("lookup", "devices", "camera");
for client in clients_om:iterate() do
updateClientPermissions (client, new_perms)
end
end)
pps_plugin:connect("changed", function (p, table, id, deleted, permissions)
if table == "devices" or id == "camera" then
for app_id, _ in pairs(permissions) do
for client in clients_om:iterate {
Constraint { "pipewire.access.portal.app_id", "=", app_id }
} do
updateClientPermissions (client, permissions)
end
end
end
end)
else
-- Otherwise, just set all permissions to all portal clients
clients_om:connect("object-added", function (om, client)
local id = client["bound-id"]
Log.info(client, "Granting ALL access to client " .. id)
client:update_permissions { ["any"] = "all" }
end)
end
clients_om:activate()

View File

@@ -0,0 +1,129 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local config = ... or {}
items = {}
function configProperties(node)
local np = node.properties
local properties = {
["item.node"] = node,
["item.plugged.usec"] = GLib.get_monotonic_time(),
["item.features.no-dsp"] = config["audio.no-dsp"],
["item.features.monitor"] = true,
["item.features.control-port"] = false,
["node.id"] = node["bound-id"],
["client.id"] = np["client.id"],
["object.path"] = np["object.path"],
["object.serial"] = np["object.serial"],
["target.object"] = np["target.object"],
["priority.session"] = np["priority.session"],
["device.id"] = np["device.id"],
["card.profile.device"] = np["card.profile.device"],
}
for k, v in pairs(np) do
if k:find("^node") or k:find("^stream") or k:find("^media") then
properties[k] = v
end
end
local media_class = properties["media.class"] or ""
if not properties["media.type"] then
for _, i in ipairs({ "Audio", "Video", "Midi" }) do
if media_class:find(i) then
properties["media.type"] = i
break
end
end
end
properties["item.node.type"] =
media_class:find("^Stream/") and "stream" or "device"
if media_class:find("Sink") or
media_class:find("Input") or
media_class:find("Duplex") then
properties["item.node.direction"] = "input"
elseif media_class:find("Source") or media_class:find("Output") then
properties["item.node.direction"] = "output"
end
return properties
end
function addItem (node, item_type)
local id = node["bound-id"]
local item
-- create item
item = SessionItem ( item_type )
items[id] = item
-- configure item
if not item:configure(configProperties(node)) then
Log.warning(item, "failed to configure item for node " .. tostring(id))
return
end
item:register ()
-- activate item
items[id]:activate (Features.ALL, function (item, e)
if e then
Log.message(item, "failed to activate item: " .. tostring(e));
if item then
item:remove ()
end
else
Log.info(item, "activated item for node " .. tostring(id))
-- Trigger object managers to update status
item:remove ()
if item["active-features"] ~= 0 then
item:register ()
end
end
end)
end
nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
Interest {
type = "node",
Constraint { "media.class", "#", "Video/*", type = "pw-global" },
},
Interest {
type = "node",
Constraint { "media.class", "#", "Audio/*", type = "pw-global" },
Constraint { "wireplumber.is-endpoint", "-", type = "pw" },
},
}
nodes_om:connect("object-added", function (om, node)
local media_class = node.properties['media.class']
if string.find (media_class, "Audio") then
addItem (node, "si-audio-adapter")
else
addItem (node, "si-node")
end
end)
nodes_om:connect("object-removed", function (om, node)
local id = node["bound-id"]
if items[id] then
items[id]:remove ()
items[id] = nil
end
end)
nodes_om:activate()

View File

@@ -0,0 +1,93 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Frédéric Danis <frederic.danis@collabora.com>
--
-- SPDX-License-Identifier: MIT
local sink_ids = {}
local fallback_node = nil
node_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.class", "matches", "Audio/Sink", type = "pw-global" },
-- Do not consider endpoints created by WirePlumber
Constraint { "wireplumber.is-endpoint", "!", true, type = "pw" },
-- or the fallback sink itself
Constraint { "wireplumber.is-fallback", "!", true, type = "pw" },
}
}
function createFallbackSink()
if fallback_node then
return
end
Log.info("Create fallback sink")
local properties = {}
properties["node.name"] = "auto_null"
properties["node.description"] = "Dummy Output"
properties["audio.rate"] = 48000
properties["audio.channels"] = 2
properties["audio.position"] = "FL,FR"
properties["media.class"] = "Audio/Sink"
properties["factory.name"] = "support.null-audio-sink"
properties["node.virtual"] = "true"
properties["monitor.channel-volumes"] = "true"
properties["wireplumber.is-fallback"] = "true"
properties["priority.session"] = 500
fallback_node = LocalNode("adapter", properties)
fallback_node:activate(Feature.Proxy.BOUND)
end
function checkSinks()
local sink_ids_items = 0
for _ in pairs(sink_ids) do sink_ids_items = sink_ids_items + 1 end
if sink_ids_items > 0 then
if fallback_node then
Log.info("Remove fallback sink")
fallback_node = nil
end
elseif not fallback_node then
createFallbackSink()
end
end
function checkSinksAfterTimeout()
if timeout_source then
timeout_source:destroy()
end
timeout_source = Core.timeout_add(1000, function ()
checkSinks()
timeout_source = nil
end)
end
node_om:connect("object-added", function (_, node)
Log.debug("object added: " .. node.properties["object.id"] .. " " ..
tostring(node.properties["node.name"]))
sink_ids[node.properties["object.id"]] = node.properties["node.name"]
checkSinksAfterTimeout()
end)
node_om:connect("object-removed", function (_, node)
Log.debug("object removed: " .. node.properties["object.id"] .. " " ..
tostring(node.properties["node.name"]))
sink_ids[node.properties["object.id"]] = nil
checkSinksAfterTimeout()
end)
node_om:activate()
checkSinksAfterTimeout()

View File

@@ -0,0 +1,74 @@
-- WirePlumber
--
-- Copyright © 2021 Asymptotic
-- @author Arun Raghavan <arun@asymptotic.io>
--
-- SPDX-License-Identifier: MIT
--
-- Route streams of a given role (media.role property) to devices that are
-- intended for that role (device.intended-roles property)
metadata_om = ObjectManager {
Interest {
type = "metadata",
Constraint { "metadata.name", "=", "default" },
}
}
devices_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.class", "matches", "Audio/*", type = "pw-global" },
Constraint { "device.intended-roles", "is-present", type = "pw" },
}
}
streams_om = ObjectManager {
Interest {
type = "node",
Constraint { "media.class", "matches", "Stream/*/Audio", type = "pw-global" },
Constraint { "media.role", "is-present", type = "pw-global" }
}
}
local function routeUsingIntendedRole(stream, dev)
local stream_role = stream.properties["media.role"]
local is_input = stream.properties["media.class"]:find("Input") ~= nil
local is_source = dev.properties["media.class"]:find("Source") ~= nil
local dev_roles = dev.properties["device.intended-roles"]
-- Make sure the stream and device direction match
if is_input ~= is_source then
return
end
for role in dev_roles:gmatch("(%a+)") do
if role == stream_role then
Log.info(stream,
string.format("Routing stream '%s' (%d) with role '%s' to '%s' (%d)",
stream.properties["node.name"], stream["bound-id"], stream_role,
dev.properties["node.name"], dev["bound-id"])
)
local metadata = metadata_om:lookup()
metadata:set(stream["bound-id"], "target.node", "Spa:Id", dev["bound-id"])
end
end
end
streams_om:connect("object-added", function (streams_om, stream)
for dev in devices_om:iterate() do
routeUsingIntendedRole(stream, dev)
end
end)
devices_om:connect("object-added", function (devices_om, dev)
for stream in streams_om:iterate() do
routeUsingIntendedRole(stream, dev)
end
end)
metadata_om:activate()
devices_om:activate()
streams_om:activate()

View File

@@ -0,0 +1,68 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local config = ... or {}
-- ensure config.properties is not nil
config.properties = config.properties or {}
SND_PATH = "/dev/snd"
SEQ_NAME = "seq"
SND_SEQ_PATH = SND_PATH .. "/" .. SEQ_NAME
midi_node = nil
fm_plugin = nil
function CreateMidiNode ()
-- Midi properties
local props = {}
if type(config.properties["alsa.midi.node-properties"]) == "table" then
props = config.properties["alsa.midi.node-properties"]
end
props["factory.name"] = "api.alsa.seq.bridge"
props["node.name"] = props["node.name"] or "Midi-Bridge"
-- create the midi node
local node = Node("spa-node-factory", props)
node:activate(Feature.Proxy.BOUND, function (n)
Log.info ("activated Midi bridge")
end)
return node;
end
if GLib.access (SND_SEQ_PATH, "rw") then
midi_node = CreateMidiNode ()
elseif config.properties["alsa.midi.monitoring"] then
fm_plugin = Plugin.find("file-monitor-api")
end
-- Only monitor the MIDI device if file does not exist and plugin API is loaded
if midi_node == nil and fm_plugin ~= nil then
-- listen for changed events
fm_plugin:connect ("changed", function (o, file, old, evtype)
-- files attributes changed
if evtype == "attribute-changed" then
if file ~= SND_SEQ_PATH then
return
end
if midi_node == nil and GLib.access (SND_SEQ_PATH, "rw") then
midi_node = CreateMidiNode ()
fm_plugin:call ("remove-watch", SND_PATH)
end
end
-- directory is going to be unmounted
if evtype == "pre-unmount" then
fm_plugin:call ("remove-watch", SND_PATH)
end
end)
-- add watch
fm_plugin:call ("add-watch", SND_PATH, "m")
end

View File

@@ -0,0 +1,427 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local config = ... or {}
-- ensure config.properties is not nil
config.properties = config.properties or {}
-- unique device/node name tables
device_names_table = nil
node_names_table = nil
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
-- applies properties from config.rules when asked to
function rulesApplyProperties(properties)
for _, r in ipairs(config.rules or {}) do
if r.apply_properties then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
for k, v in pairs(r.apply_properties) do
properties[k] = v
end
end
end
end
end
end
function nonempty(str)
return str ~= "" and str or nil
end
function createNode(parent, id, obj_type, factory, properties)
local dev_props = parent.properties
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent["bound-id"]
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- try to negotiate the max ammount of channels
if dev_props["api.alsa.use-acp"] ~= "true" then
properties["audio.channels"] = properties["audio.channels"] or "64"
end
local dev = properties["api.alsa.pcm.device"]
or properties["alsa.device"] or "0"
local subdev = properties["api.alsa.pcm.subdevice"]
or properties["alsa.subdevice"] or "0"
local stream = properties["api.alsa.pcm.stream"] or "unknown"
local profile = properties["device.profile.name"]
or (stream .. "." .. dev .. "." .. subdev)
local profile_desc = properties["device.profile.description"]
-- set priority
if not properties["priority.driver"] then
local priority = (dev == "0") and 1000 or 744
if stream == "capture" then
priority = priority + 1000
end
priority = priority - (tonumber(dev) * 16) - tonumber(subdev)
if profile:find("^pro%-") then
priority = priority + 500
elseif profile:find("^analog%-") then
priority = priority + 9
elseif profile:find("^iec958%-") then
priority = priority + 8
end
properties["priority.driver"] = priority
properties["priority.session"] = priority
end
-- ensure the node has a media class
if not properties["media.class"] then
if stream == "capture" then
properties["media.class"] = "Audio/Source"
else
properties["media.class"] = "Audio/Sink"
end
end
-- ensure the node has a name
if not properties["node.name"] then
local name =
(stream == "capture" and "alsa_input" or "alsa_output")
.. "." ..
(dev_props["device.name"]:gsub("^alsa_card%.(.+)", "%1") or
dev_props["device.name"] or
"unnamed-device")
.. "." ..
profile
-- sanitize name
name = name:gsub("([^%w_%-%.])", "_")
properties["node.name"] = name
-- deduplicate nodes with the same name
for counter = 2, 99, 1 do
if node_names_table[properties["node.name"]] ~= true then
node_names_table[properties["node.name"]] = true
break
end
properties["node.name"] = name .. "." .. counter
end
end
-- and a nick
local nick = nonempty(properties["node.nick"])
or nonempty(properties["api.alsa.pcm.name"])
or nonempty(properties["alsa.name"])
or nonempty(profile_desc)
or dev_props["device.nick"]
if nick == "USB Audio" then
nick = dev_props["device.nick"]
end
-- also sanitize nick, replace ':' with ' '
properties["node.nick"] = nick:gsub("(:)", " ")
-- ensure the node has a description
if not properties["node.description"] then
local desc = nonempty(dev_props["device.description"]) or "unknown"
local name = nonempty(properties["api.alsa.pcm.name"]) or
nonempty(properties["api.alsa.pcm.id"]) or dev
if profile_desc then
desc = desc .. " " .. profile_desc
elseif subdev ~= "0" then
desc = desc .. " (" .. name .. " " .. subdev .. ")"
elseif dev ~= "0" then
desc = desc .. " (" .. name .. ")"
end
-- also sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
end
-- add api.alsa.card.* properties for rule matching purposes
for k, v in pairs(dev_props) do
if k:find("^api%.alsa%.card%..*") then
properties[k] = v
end
end
-- apply VM overrides
local vm_overrides = config.properties["vm.node.defaults"]
if nonempty(Core.get_vm_type()) and type(vm_overrides) == "table" then
for k, v in pairs(vm_overrides) do
properties[k] = v
end
end
-- apply properties from config.rules
rulesApplyProperties(properties)
if properties["node.disabled"] then
node_names_table [properties ["node.name"]] = nil
return
end
-- create the node
local node = Node("adapter", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
function createDevice(parent, id, factory, properties)
local device = SpaDevice(factory, properties)
if device then
device:connect("create-object", createNode)
device:connect("object-removed", function (parent, id)
local node = parent:get_managed_object(id)
if not node then
return
end
node_names_table[node.properties["node.name"]] = nil
end)
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
parent:store_managed_object(id, device)
else
Log.warning ("Failed to create '" .. factory .. "' device")
end
end
function prepareDevice(parent, id, obj_type, factory, properties)
-- ensure the device has an appropriate name
local name = "alsa_card." ..
(properties["device.name"] or
properties["device.bus-id"] or
properties["device.bus-path"] or
tostring(id)):gsub("([^%w_%-%.])", "_")
properties["device.name"] = name
-- deduplicate devices with the same name
for counter = 2, 99, 1 do
if device_names_table[properties["device.name"]] ~= true then
device_names_table[properties["device.name"]] = true
break
end
properties["device.name"] = name .. "." .. counter
end
-- ensure the device has a description
if not properties["device.description"] then
local d = nil
local f = properties["device.form-factor"]
local c = properties["device.class"]
local n = properties["api.alsa.card.name"]
if n == "Loopback" then
d = I18n.gettext("Loopback")
elseif f == "internal" then
d = I18n.gettext("Built-in Audio")
elseif c == "modem" then
d = I18n.gettext("Modem")
end
d = d or properties["device.product.name"]
or properties["api.alsa.card.name"]
or properties["alsa.card_name"]
or "Unknown device"
properties["device.description"] = d
end
-- ensure the device has a nick
properties["device.nick"] =
properties["device.nick"] or
properties["api.alsa.card.name"] or
properties["alsa.card_name"]
-- set the icon name
if not properties["device.icon-name"] then
local icon = nil
local icon_map = {
-- form factor -> icon
["microphone"] = "audio-input-microphone",
["webcam"] = "camera-web",
["handset"] = "phone",
["portable"] = "multimedia-player",
["tv"] = "video-display",
["headset"] = "audio-headset",
["headphone"] = "audio-headphones",
["speaker"] = "audio-speakers",
["hands-free"] = "audio-handsfree",
}
local f = properties["device.form-factor"]
local c = properties["device.class"]
local b = properties["device.bus"]
icon = icon_map[f] or ((c == "modem") and "modem") or "audio-card"
properties["device.icon-name"] = icon .. "-analog" .. (b and ("-" .. b) or "")
end
-- apply properties from config.rules
rulesApplyProperties(properties)
if properties["device.disabled"] then
device_names_table [properties ["device.name"]] = nil
return
end
-- override the device factory to use ACP
if properties["api.alsa.use-acp"] then
Log.info("Enabling the use of ACP on " .. properties["device.name"])
factory = "api.alsa.acp.device"
end
-- use device reservation, if available
if rd_plugin and properties["api.alsa.card"] then
local rd_name = "Audio" .. properties["api.alsa.card"]
local rd = rd_plugin:call("create-reservation",
rd_name,
config.properties["alsa.reserve.application-name"] or "WirePlumber",
properties["device.name"],
config.properties["alsa.reserve.priority"] or -20);
properties["api.dbus.ReserveDevice1"] = rd_name
-- unlike pipewire-media-session, this logic here keeps the device
-- acquired at all times and destroys it if someone else acquires
rd:connect("notify::state", function (rd, pspec)
local state = rd["state"]
if state == "acquired" then
-- create the device
createDevice(parent, id, factory, properties)
elseif state == "available" then
-- attempt to acquire again
rd:call("acquire")
elseif state == "busy" then
-- destroy the device
parent:store_managed_object(id, nil)
end
end)
rd:connect("release-requested", function (rd)
Log.info("release requested")
parent:store_managed_object(id, nil)
rd:call("release")
end)
if jack_device then
rd:connect("notify::owner-name-changed", function (rd, pspec)
if rd["state"] == "busy" and
rd["owner-application-name"] == "Jack audio server" then
-- TODO enable the jack device
else
-- TODO disable the jack device
end
end)
end
rd:call("acquire")
else
-- create the device
createDevice(parent, id, factory, properties)
end
end
function createMonitor ()
local m = SpaDevice("api.alsa.enum.udev", config.properties)
if m == nil then
Log.message("PipeWire's SPA ALSA udev plugin(\"api.alsa.enum.udev\")"
.. "missing or broken. Sound Cards cannot be enumerated")
return nil
end
-- handle create-object to prepare device
m:connect("create-object", prepareDevice)
-- handle object-removed to destroy device reservations and recycle device name
m:connect("object-removed", function (parent, id)
local device = parent:get_managed_object(id)
if not device then
return
end
if rd_plugin then
local rd_name = device.properties["api.dbus.ReserveDevice1"]
if rd_name then
rd_plugin:call("destroy-reservation", rd_name)
end
end
device_names_table[device.properties["device.name"]] = nil
for managed_node in device:iterate_managed_objects() do
node_names_table[managed_node.properties["node.name"]] = nil
end
end)
-- reset the name tables to make sure names are recycled
device_names_table = {}
node_names_table = {}
-- activate monitor
Log.info("Activating ALSA monitor")
m:activate(Feature.SpaDevice.ENABLED)
return m
end
-- create the JACK device (for PipeWire to act as client to a JACK server)
if config.properties["alsa.jack-device"] then
jack_device = Device("spa-device-factory", {
["factory.name"] = "api.jack.device",
["node.name"] = "JACK-Device",
})
jack_device:activate(Feature.Proxy.BOUND)
end
-- enable device reservation if requested
if config.properties["alsa.reserve"] then
rd_plugin = Plugin.find("reserve-device")
end
-- if the reserve-device plugin is enabled, at the point of script execution
-- it is expected to be connected. if it is not, assume the d-bus connection
-- has failed and continue without it
if rd_plugin and rd_plugin:call("get-dbus")["state"] ~= "connected" then
Log.message("reserve-device plugin is not connected to D-Bus, "
.. "disabling device reservation")
rd_plugin = nil
end
-- handle rd_plugin state changes to destroy and re-create the ALSA monitor in
-- case D-Bus service is restarted
if rd_plugin then
local dbus = rd_plugin:call("get-dbus")
dbus:connect("notify::state", function (b, pspec)
local state = b["state"]
Log.info ("rd-plugin state changed to " .. state)
if state == "connected" then
Log.info ("Creating ALSA monitor")
monitor = createMonitor()
elseif state == "closed" then
Log.info ("Destroying ALSA monitor")
monitor = nil
end
end)
end
-- create the monitor
monitor = createMonitor()

View File

@@ -0,0 +1,187 @@
-- WirePlumber
--
-- Copyright © 2022 Pauli Virtanen
-- @author Pauli Virtanen
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
-- unique device/node name tables
node_names_table = nil
id_to_name_table = nil
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
-- applies properties from config.rules when asked to
function rulesApplyProperties(properties)
for _, r in ipairs(config.rules or {}) do
if r.apply_properties then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
for k, v in pairs(r.apply_properties) do
properties[k] = v
end
end
end
end
end
end
function setLatencyOffset(node, offset_msec)
if not offset_msec then
return
end
local props = { "Spa:Pod:Object:Param:Props", "Props" }
props.latencyOffsetNsec = tonumber(offset_msec) * 1000000
local param = Pod.Object(props)
Log.debug(param, "setting latency offset on " .. tostring(node))
node:set_param("Props", param)
end
function createNode(parent, id, type, factory, properties)
properties["factory.name"] = factory
-- set the node description
local desc = properties["node.description"]
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
-- set the node name
local name =
"bluez_midi." .. properties["api.bluez5.address"]
-- sanitize name
name = name:gsub("([^%w_%-%.])", "_")
-- deduplicate nodes with the same name
properties["node.name"] = name
for counter = 2, 99, 1 do
if node_names_table[properties["node.name"]] ~= true then
node_names_table[properties["node.name"]] = true
break
end
properties["node.name"] = name .. "." .. counter
end
properties["api.glib.mainloop"] = "true"
-- apply properties from config.rules
rulesApplyProperties(properties)
local latency_offset = properties["node.latency-offset-msec"]
properties["node.latency-offset-msec"] = nil
-- create the node
-- it doesn't necessarily need to be a local node,
-- the other Bluetooth parts run in the local process,
-- so it's consistent to have also this here
local node = LocalNode("spa-node-factory", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
id_to_name_table[id] = properties["node.name"]
setLatencyOffset(node, latency_offset)
end
function createMonitor()
local monitor_props = {}
for k, v in pairs(config.properties or {}) do
monitor_props[k] = v
end
monitor_props["server"] = nil
monitor_props["api.glib.mainloop"] = "true"
local monitor = SpaDevice("api.bluez5.midi.enum", monitor_props)
if monitor then
monitor:connect("create-object", createNode)
monitor:connect("object-removed", function (parent, id)
node_names_table[id_to_name_table[id]] = nil
id_to_name_table[id] = nil
end)
else
Log.message("PipeWire's BlueZ MIDI SPA missing or broken. Bluetooth not supported.")
return nil
end
-- reset the name tables to make sure names are recycled
node_names_table = {}
id_to_name_table = {}
monitor:activate(Feature.SpaDevice.ENABLED)
return monitor
end
function createServers()
local props = config.properties or {}
if not props["servers"] then
return nil
end
local servers = {}
local i = 1
for k, v in pairs(props["servers"]) do
local node_props = {
["node.name"] = v,
["node.description"] = string.format(I18n.gettext("BLE MIDI %d"), i),
["api.bluez5.role"] = "server",
["factory.name"] = "api.bluez5.midi.node",
["api.glib.mainloop"] = "true",
}
rulesApplyProperties(node_props)
local latency_offset = node_props["node.latency-offset-msec"]
node_props["node.latency-offset-msec"] = nil
local node = LocalNode("spa-node-factory", node_props)
if node then
node:activate(Feature.Proxy.BOUND)
table.insert(servers, node)
setLatencyOffset(node, latency_offset)
else
Log.message("Failed to create BLE MIDI server.")
end
i = i + 1
end
return servers
end
logind_plugin = Plugin.find("logind")
if logind_plugin then
-- if logind support is enabled, activate
-- the monitor only when the seat is active
function startStopMonitor(seat_state)
Log.info(logind_plugin, "Seat state changed: " .. seat_state)
if seat_state == "active" then
monitor = createMonitor()
servers = createServers()
elseif monitor then
monitor:deactivate(Feature.SpaDevice.ENABLED)
monitor = nil
servers = nil
end
end
logind_plugin:connect("state-changed", function(p, s) startStopMonitor(s) end)
startStopMonitor(logind_plugin:call("get-state"))
else
monitor = createMonitor()
servers = createServers()
end

View File

@@ -0,0 +1,307 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
devices_om = ObjectManager {
Interest {
type = "device",
}
}
nodes_om = ObjectManager {
Interest {
type = "node",
Constraint { "node.name", "#", "*.bluez_*put*"},
Constraint { "device.id", "+" },
}
}
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
-- applies properties from config.rules when asked to
function rulesApplyProperties(properties)
for _, r in ipairs(config.rules or {}) do
if r.apply_properties then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
for k, v in pairs(r.apply_properties) do
properties[k] = v
end
end
end
end
end
end
function setOffloadActive(device, value)
local pod = Pod.Object {
"Spa:Pod:Object:Param:Props", "Props", bluetoothOffloadActive = value
}
device:set_params("Props", pod)
end
nodes_om:connect("object-added", function(_, node)
node:connect("state-changed", function(node, old_state, cur_state)
local interest = Interest {
type = "device",
Constraint { "object.id", "=", node.properties["device.id"]}
}
for d in devices_om:iterate (interest) do
if cur_state == "running" then
setOffloadActive(d, true)
else
setOffloadActive(d, false)
end
end
end)
end)
function createOffloadScoNode(parent, id, type, factory, properties)
local dev_props = parent.properties
local args = {
["audio.channels"] = 1,
["audio.position"] = "[MONO]",
}
local desc =
dev_props["device.description"]
or dev_props["device.name"]
or dev_props["device.nick"]
or dev_props["device.alias"]
or "bluetooth-device"
-- sanitize description, replace ':' with ' '
args["node.description"] = desc:gsub("(:)", " ")
if factory:find("sink") then
local capture_args = {
["device.id"] = parent["bound-id"],
["media.class"] = "Audio/Sink",
["node.pause-on-idle"] = false,
}
for k, v in pairs(properties) do
capture_args[k] = v
end
local name = "bluez_output" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id)
args["node.name"] = name:gsub("([^%w_%-%.])", "_")
args["capture.props"] = Json.Object(capture_args)
args["playback.props"] = Json.Object {
["node.passive"] = true,
["node.pause-on-idle"] = false,
}
elseif factory:find("source") then
local playback_args = {
["device.id"] = parent["bound-id"],
["media.class"] = "Audio/Source",
["node.pause-on-idle"] = false,
}
for k, v in pairs(properties) do
playback_args[k] = v
end
local name = "bluez_input" .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id)
args["node.name"] = name:gsub("([^%w_%-%.])", "_")
args["capture.props"] = Json.Object {
["node.passive"] = true,
["node.pause-on-idle"] = false,
}
args["playback.props"] = Json.Object(playback_args)
else
Log.warning(parent, "Unsupported factory: " .. factory)
return
end
-- Transform 'args' to a json object here
local args_json = Json.Object(args)
-- and get the final JSON as a string from the json object
local args_string = args_json:get_data()
local loopback_properties = {}
local loopback = LocalModule("libpipewire-module-loopback", args_string, loopback_properties)
parent:store_managed_object(id, loopback)
end
function createNode(parent, id, type, factory, properties)
local dev_props = parent.properties
if config.properties["bluez5.hw-offload-sco"] and factory:find("sco") then
createOffloadScoNode(parent, id, type, factory, properties)
return
end
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent["bound-id"]
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- set the node description
local desc =
dev_props["device.description"]
or dev_props["device.name"]
or dev_props["device.nick"]
or dev_props["device.alias"]
or "bluetooth-device"
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
-- set the node name
local name =
((factory:find("sink") and "bluez_output") or
(factory:find("source") and "bluez_input" or factory)) .. "." ..
(properties["api.bluez5.address"] or dev_props["device.name"]) .. "." ..
tostring(id)
-- sanitize name
properties["node.name"] = name:gsub("([^%w_%-%.])", "_")
-- set priority
if not properties["priority.driver"] then
local priority = factory:find("source") and 2010 or 1010
properties["priority.driver"] = priority
properties["priority.session"] = priority
end
-- autoconnect if it's a stream
if properties["api.bluez5.profile"] == "headset-audio-gateway" or
properties["api.bluez5.profile"] == "bap-sink" or
factory:find("a2dp.source") or factory:find("media.source") then
properties["node.autoconnect"] = true
end
-- apply properties from config.rules
rulesApplyProperties(properties)
-- create the node; bluez requires "local" nodes, i.e. ones that run in
-- the same process as the spa device, for several reasons
local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
function createDevice(parent, id, type, factory, properties)
local device = parent:get_managed_object(id)
if not device then
-- ensure a proper device name
local name =
(properties["device.name"] or
properties["api.bluez5.address"] or
properties["device.description"] or
tostring(id)):gsub("([^%w_%-%.])", "_")
if not name:find("^bluez_card%.", 1) then
name = "bluez_card." .. name
end
properties["device.name"] = name
-- set the icon name
if not properties["device.icon-name"] then
local icon = nil
local icon_map = {
-- form factor -> icon
["microphone"] = "audio-input-microphone",
["webcam"] = "camera-web",
["handset"] = "phone",
["portable"] = "multimedia-player",
["tv"] = "video-display",
["headset"] = "audio-headset",
["headphone"] = "audio-headphones",
["speaker"] = "audio-speakers",
["hands-free"] = "audio-handsfree",
}
local f = properties["device.form-factor"]
local b = properties["device.bus"]
icon = icon_map[f] or "audio-card"
properties["device.icon-name"] = icon .. (b and ("-" .. b) or "")
end
-- initial profile is to be set by policy-device-profile.lua, not spa-bluez5
properties["bluez5.profile"] = "off"
-- apply properties from config.rules
rulesApplyProperties(properties)
-- create the device
device = SpaDevice(factory, properties)
if device then
device:connect("create-object", createNode)
parent:store_managed_object(id, device)
else
Log.warning ("Failed to create '" .. factory .. "' device")
return
end
end
Log.info(parent, string.format("%d, %s (%s): %s",
id, properties["device.description"],
properties["api.bluez5.address"], properties["api.bluez5.connection"]))
-- activate the device after the bluez profiles are connected
if properties["api.bluez5.connection"] == "connected" then
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
else
device:deactivate(Features.ALL)
end
end
function createMonitor()
local monitor_props = config.properties or {}
monitor_props["api.bluez5.connection-info"] = true
local monitor = SpaDevice("api.bluez5.enum.dbus", monitor_props)
if monitor then
monitor:connect("create-object", createDevice)
else
Log.message("PipeWire's BlueZ SPA missing or broken. Bluetooth not supported.")
return nil
end
monitor:activate(Feature.SpaDevice.ENABLED)
return monitor
end
logind_plugin = Plugin.find("logind")
if logind_plugin then
-- if logind support is enabled, activate
-- the monitor only when the seat is active
function startStopMonitor(seat_state)
Log.info(logind_plugin, "Seat state changed: " .. seat_state)
if seat_state == "active" then
monitor = createMonitor()
elseif monitor then
monitor:deactivate(Feature.SpaDevice.ENABLED)
monitor = nil
end
end
logind_plugin:connect("state-changed", function(p, s) startStopMonitor(s) end)
startStopMonitor(logind_plugin:call("get-state"))
else
monitor = createMonitor()
end
nodes_om:activate()
devices_om:activate()

View File

@@ -0,0 +1,174 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
-- applies properties from config.rules when asked to
function rulesApplyProperties(properties)
for _, r in ipairs(config.rules or {}) do
if r.apply_properties then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
for k, v in pairs(r.apply_properties) do
properties[k] = v
end
end
end
end
end
end
function findDuplicate(parent, id, property, value)
for i = 0, id - 1, 1 do
local obj = parent:get_managed_object(i)
if obj and obj.properties[property] == value then
return true
end
end
return false
end
function createNode(parent, id, type, factory, properties)
local dev_props = parent.properties
local location = properties["api.libcamera.location"]
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent["bound-id"]
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- set the node name
local name =
(factory:find("sink") and "libcamera_output") or
(factory:find("source") and "libcamera_input" or factory)
.. "." ..
(dev_props["device.name"]:gsub("^libcamera_device%.(.+)", "%1") or
dev_props["device.name"] or
dev_props["device.nick"] or
dev_props["device.alias"] or
"libcamera-device")
-- sanitize name
name = name:gsub("([^%w_%-%.])", "_")
properties["node.name"] = name
-- deduplicate nodes with the same name
for counter = 2, 99, 1 do
if findDuplicate(parent, id, "node.name", properties["node.name"]) then
properties["node.name"] = name .. "." .. counter
else
break
end
end
-- set the node description
local desc = dev_props["device.description"] or "libcamera-device"
if location == "front" then
desc = I18n.gettext("Built-in Front Camera")
elseif location == "back" then
desc = I18n.gettext("Built-in Back Camera")
end
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
-- set the node nick
local nick = properties["node.nick"] or
dev_props["device.product.name"] or
dev_props["device.description"] or
dev_props["device.nick"]
properties["node.nick"] = nick:gsub("(:)", " ")
-- set priority
if not properties["priority.session"] then
local priority = 700
if location == "external" then
priority = priority + 150
elseif location == "front" then
priority = priority + 100
elseif location == "back" then
priority = priority + 50
end
properties["priority.session"] = priority
end
-- apply properties from config.rules
rulesApplyProperties(properties)
if properties ["node.disabled"] then
return
end
-- create the node
local node = Node("spa-node-factory", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
function createDevice(parent, id, type, factory, properties)
-- ensure the device has an appropriate name
local name = "libcamera_device." ..
(properties["device.name"] or
properties["device.bus-id"] or
properties["device.bus-path"] or
tostring(id)):gsub("([^%w_%-%.])", "_")
properties["device.name"] = name
-- deduplicate devices with the same name
for counter = 2, 99, 1 do
if findDuplicate(parent, id, "device.name", properties["device.name"]) then
properties["device.name"] = name .. "." .. counter
else
break
end
end
-- ensure the device has a description
properties["device.description"] =
properties["device.description"]
or properties["device.product.name"]
or "Unknown device"
-- apply properties from config.rules
rulesApplyProperties(properties)
if properties ["device.disabled"] then
return
end
-- create the device
local device = SpaDevice(factory, properties)
if device then
device:connect("create-object", createNode)
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
parent:store_managed_object(id, device)
else
Log.warning ("Failed to create '" .. factory .. "' device")
end
end
monitor = SpaDevice("api.libcamera.enum.manager", config.properties or {})
if monitor then
monitor:connect("create-object", createDevice)
monitor:activate(Feature.SpaDevice.ENABLED)
else
Log.message("PipeWire's libcamera SPA missing or broken. libcamera not supported.")
end

View File

@@ -0,0 +1,165 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
-- applies properties from config.rules when asked to
function rulesApplyProperties(properties)
for _, r in ipairs(config.rules or {}) do
if r.apply_properties then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
for k, v in pairs(r.apply_properties) do
properties[k] = v
end
end
end
end
end
end
function findDuplicate(parent, id, property, value)
for i = 0, id - 1, 1 do
local obj = parent:get_managed_object(i)
if obj and obj.properties[property] == value then
return true
end
end
return false
end
function createNode(parent, id, type, factory, properties)
local dev_props = parent.properties
-- set the device id and spa factory name; REQUIRED, do not change
properties["device.id"] = parent["bound-id"]
properties["factory.name"] = factory
-- set the default pause-on-idle setting
properties["node.pause-on-idle"] = false
-- set the node name
local name =
(factory:find("sink") and "v4l2_output") or
(factory:find("source") and "v4l2_input" or factory)
.. "." ..
(dev_props["device.name"]:gsub("^v4l2_device%.(.+)", "%1") or
dev_props["device.name"] or
dev_props["device.nick"] or
dev_props["device.alias"] or
"v4l2-device")
-- sanitize name
name = name:gsub("([^%w_%-%.])", "_")
properties["node.name"] = name
-- deduplicate nodes with the same name
for counter = 2, 99, 1 do
if findDuplicate(parent, id, "node.name", properties["node.name"]) then
properties["node.name"] = name .. "." .. counter
else
break
end
end
-- set the node description
local desc = dev_props["device.description"] or "v4l2-device"
desc = desc .. " (V4L2)"
-- sanitize description, replace ':' with ' '
properties["node.description"] = desc:gsub("(:)", " ")
-- set the node nick
local nick = properties["node.nick"] or
dev_props["device.product.name"] or
dev_props["api.v4l2.cap.card"] or
dev_props["device.description"] or
dev_props["device.nick"]
properties["node.nick"] = nick:gsub("(:)", " ")
-- set priority
if not properties["priority.session"] then
local path = properties["api.v4l2.path"] or "/dev/video100"
local dev = path:gsub("/dev/video(%d+)", "%1")
properties["priority.session"] = 1000 - (tonumber(dev) * 10)
end
-- apply properties from config.rules
rulesApplyProperties(properties)
if properties["node.disabled"] then
return
end
-- create the node
local node = Node("spa-node-factory", properties)
node:activate(Feature.Proxy.BOUND)
parent:store_managed_object(id, node)
end
function createDevice(parent, id, type, factory, properties)
-- ensure the device has an appropriate name
local name = "v4l2_device." ..
(properties["device.name"] or
properties["device.bus-id"] or
properties["device.bus-path"] or
tostring(id)):gsub("([^%w_%-%.])", "_")
properties["device.name"] = name
-- deduplicate devices with the same name
for counter = 2, 99, 1 do
if findDuplicate(parent, id, "device.name", properties["device.name"]) then
properties["device.name"] = name .. "." .. counter
else
break
end
end
-- ensure the device has a description
properties["device.description"] =
properties["device.description"]
or properties["device.product.name"]
or "Unknown device"
-- apply properties from config.rules
rulesApplyProperties(properties)
if properties["device.disabled"] then
return
end
-- create the device
local device = SpaDevice(factory, properties)
if device then
device:connect("create-object", createNode)
device:activate(Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND)
parent:store_managed_object(id, device)
else
Log.warning ("Failed to create '" .. factory .. "' device")
end
end
monitor = SpaDevice("api.v4l2.enum.udev", config.properties or {})
if monitor then
monitor:connect("create-object", createDevice)
monitor:activate(Feature.SpaDevice.ENABLED)
else
Log.message("PipeWire's V4L SPA missing or broken. Video4Linux not supported.")
end

View File

@@ -0,0 +1,398 @@
-- 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
--
-- 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.
local config = ...
local use_persistent_storage = config["use-persistent-storage"] or false
local applications = {}
local use_headset_profile = config["media-role.use-headset-profile"] or false
local profile_restore_timeout_msec = 2000
local INVALID = -1
local timeout_source = nil
local restore_timeout_source = nil
local state = use_persistent_storage and State("policy-bluetooth") or nil
local headset_profiles = state and state:load() or {}
local last_profiles = {}
local active_streams = {}
local previous_streams = {}
for _, value in ipairs(config["media-role.applications"] or {}) do
applications[value] = true
end
metadata_om = ObjectManager {
Interest {
type = "metadata",
Constraint { "metadata.name", "=", "default" },
}
}
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 parseParam(param_to_parse, id)
local param = param_to_parse:parse()
if param.pod_type == "Object" and param.object_id == id then
return param.properties
else
return nil
end
end
local function storeAfterTimeout()
if not use_persistent_storage then
return
end
if timeout_source then
timeout_source:destroy()
end
timeout_source = Core.timeout_add(1000, function ()
local saved, err = state:save(headset_profiles)
if not saved then
Log.warning(err)
end
timeout_source = nil
end)
end
local function saveHeadsetProfile(device, profile_name)
local key = "saved-headset-profile:" .. device.properties["device.name"]
headset_profiles[key] = profile_name
storeAfterTimeout()
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 = 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 = 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 = 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 = 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 = 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["bound-id"]] and stream.state ~= "running" then
return false
end
return true
end
local function handleStream(stream)
if not use_headset_profile then
return
end
if checkStreamStatus(stream) then
active_streams[stream["bound-id"]] = true
previous_streams[stream["bound-id"]] = true
switchProfile()
else
active_streams[stream["bound-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
streams_om:connect("object-added", function (_, stream)
stream:connect("state-changed", function (stream, old_state, cur_state)
handleStream(stream)
end)
stream:connect("params-changed", handleStream)
handleStream(stream)
end)
streams_om:connect("object-removed", function (_, stream)
active_streams[stream["bound-id"]] = nil
previous_streams[stream["bound-id"]] = nil
triggerRestoreProfile()
end)
devices_om:connect("object-added", function (_, device)
-- Devices are unswitched initially
if isSwitched(device) then
saveLastProfile(device, nil)
end
handleAllStreams()
end)
metadata_om:connect("object-added", function (_, metadata)
metadata:connect("changed", function (m, subject, key, t, value)
if (use_headset_profile and subject == 0 and key == "default.audio.sink"
and isBluez5AudioSink(value)) then
-- If bluez sink is set as default, rescan for active input streams
handleAllStreams()
end
end)
end)
metadata_om:activate()
devices_om:activate()
streams_om:activate()

View File

@@ -0,0 +1,187 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
local self = {}
self.config = ... or {}
self.config.persistent = self.config.persistent or {}
self.active_profiles = {}
self.default_profile_plugin = Plugin.find("default-profile")
-- Preprocess persisten profiles and create Interest objects
for _, p in ipairs(self.config.persistent or {}) do
p.interests = {}
for _, i in ipairs(p.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(p.interests, interest)
end
p.matches = nil
end
-- Checks whether a device profile is persistent or not
function isProfilePersistent(device_props, profile_name)
for _, p in ipairs(self.config.persistent or {}) do
if p.profile_names then
for _, interest in ipairs(p.interests) do
if interest:matches(device_props) then
for _, pn in ipairs(p.profile_names) do
if pn == profile_name then
return true
end
end
end
end
end
end
return false
end
function parseParam(param, id)
local parsed = param:parse()
if parsed.pod_type == "Object" and parsed.object_id == id then
return parsed.properties
else
return nil
end
end
function setDeviceProfile (device, dev_id, dev_name, profile)
if self.active_profiles[dev_id] and
self.active_profiles[dev_id].index == profile.index then
Log.info ("Profile " .. profile.name .. " is already set in " .. dev_name)
return
end
local param = Pod.Object {
"Spa:Pod:Object:Param:Profile", "Profile",
index = profile.index,
}
Log.info ("Setting profile " .. profile.name .. " on " .. dev_name)
device:set_param("Profile", param)
end
function findDefaultProfile (device)
local def_name = nil
if self.default_profile_plugin ~= nil then
def_name = self.default_profile_plugin:call ("get-profile", device)
end
if def_name == nil then
return nil
end
for p in device:iterate_params("EnumProfile") do
local profile = parseParam(p, "EnumProfile")
if profile.name == def_name then
return profile
end
end
return nil
end
function findBestProfile (device)
local off_profile = nil
local best_profile = nil
local unk_profile = nil
for p in device:iterate_params("EnumProfile") do
profile = parseParam(p, "EnumProfile")
if profile and profile.name ~= "pro-audio" then
if profile.name == "off" then
off_profile = profile
elseif profile.available == "yes" then
if best_profile == nil or profile.priority > best_profile.priority then
best_profile = profile
end
elseif profile.available ~= "no" then
if unk_profile == nil or profile.priority > unk_profile.priority then
unk_profile = profile
end
end
end
end
if best_profile ~= nil then
return best_profile
elseif unk_profile ~= nil then
return unk_profile
elseif off_profile ~= nil then
return off_profile
end
return nil
end
function handleProfiles (device, new_device)
local dev_id = device["bound-id"]
local dev_name = device.properties["device.name"]
local def_profile = findDefaultProfile (device)
-- Do not do anything if active profile is both persistent and default
if not new_device and
self.active_profiles[dev_id] ~= nil and
isProfilePersistent (device.properties, self.active_profiles[dev_id].name) and
def_profile ~= nil and
self.active_profiles[dev_id].name == def_profile.name
then
local active_profile = self.active_profiles[dev_id].name
Log.info ("Device profile " .. active_profile .. " is persistent for " .. dev_name)
return
end
if def_profile ~= nil then
if def_profile.available == "no" then
Log.info ("Default profile " .. def_profile.name .. " unavailable for " .. dev_name)
else
Log.info ("Found default profile " .. def_profile.name .. " for " .. dev_name)
setDeviceProfile (device, dev_id, dev_name, def_profile)
return
end
else
Log.info ("Default profile not found for " .. dev_name)
end
local best_profile = findBestProfile (device)
if best_profile ~= nil then
Log.info ("Found best profile " .. best_profile.name .. " for " .. dev_name)
setDeviceProfile (device, dev_id, dev_name, best_profile)
else
Log.info ("Best profile not found on " .. dev_name)
end
end
function onDeviceParamsChanged (device, param_name)
if param_name == "EnumProfile" then
handleProfiles (device, false)
end
end
self.om = ObjectManager {
Interest {
type = "device",
Constraint { "device.name", "is-present", type = "pw-global" },
}
}
self.om:connect("object-added", function (_, device)
device:connect ("params-changed", onDeviceParamsChanged)
handleProfiles (device, true)
end)
self.om:connect("object-removed", function (_, device)
local dev_id = device["bound-id"]
self.active_profiles[dev_id] = nil
end)
self.om:activate()

View File

@@ -0,0 +1,487 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on default-routes.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
-- whether to store state on the file system
use_persistent_storage = config["use-persistent-storage"] or false
-- the default volume to apply
default_volume = tonumber(config["default-volume"] or 0.4^3)
default_input_volume = tonumber(config["default-input-volume"] or 1.0)
-- table of device info
dev_infos = {}
-- the state storage
state = use_persistent_storage and State("default-routes") or nil
state_table = state and state:load() or {}
-- simple serializer {"foo", "bar"} -> "foo;bar;"
function serializeArray(a)
local str = ""
for _, v in ipairs(a) do
str = str .. tostring(v):gsub(";", "\\;") .. ";"
end
return str
end
-- simple deserializer "foo;bar;" -> {"foo", "bar"}
function parseArray(str, convert_value)
local array = {}
local val = ""
local escaped = false
for i = 1, #str do
local c = str:sub(i,i)
if c == '\\' then
escaped = true
elseif c == ';' and not escaped then
val = convert_value and convert_value(val) or val
table.insert(array, val)
val = ""
else
val = val .. tostring(c)
escaped = false
end
end
return array
end
function arrayContains(a, value)
for _, v in ipairs(a) do
if v == value then
return true
end
end
return false
end
function parseParam(param, id)
local route = param:parse()
if route.pod_type == "Object" and route.object_id == id then
return route.properties
else
return nil
end
end
function storeAfterTimeout()
if timeout_source then
timeout_source:destroy()
end
timeout_source = Core.timeout_add(1000, function ()
local saved, err = state:save(state_table)
if not saved then
Log.warning(err)
end
timeout_source = nil
end)
end
function saveProfile(dev_info, profile_name)
if not use_persistent_storage then
return
end
local routes = {}
for idx, ri in pairs(dev_info.route_infos) do
if ri.save then
table.insert(routes, ri.name)
end
end
if #routes > 0 then
local key = dev_info.name .. ":profile:" .. profile_name
state_table[key] = serializeArray(routes)
storeAfterTimeout()
end
end
function saveRouteProps(dev_info, route)
if not use_persistent_storage or not route.props then
return
end
local props = route.props.properties
local key_base = dev_info.name .. ":" ..
route.direction:lower() .. ":" ..
route.name .. ":"
state_table[key_base .. "volume"] =
props.volume and tostring(props.volume) or nil
state_table[key_base .. "mute"] =
props.mute and tostring(props.mute) or nil
state_table[key_base .. "channelVolumes"] =
props.channelVolumes and serializeArray(props.channelVolumes) or nil
state_table[key_base .. "channelMap"] =
props.channelMap and serializeArray(props.channelMap) or nil
state_table[key_base .. "latencyOffsetNsec"] =
props.latencyOffsetNsec and tostring(props.latencyOffsetNsec) or nil
state_table[key_base .. "iec958Codecs"] =
props.iec958Codecs and serializeArray(props.iec958Codecs) or nil
storeAfterTimeout()
end
function restoreRoute(device, dev_info, device_id, route)
-- default props
local props = {
"Spa:Pod:Object:Param:Props", "Route",
mute = false,
}
if route.direction == "Input" then
props.channelVolumes = { default_input_volume }
else
props.channelVolumes = { default_volume }
end
-- restore props from persistent storage
if use_persistent_storage then
local key_base = dev_info.name .. ":" ..
route.direction:lower() .. ":" ..
route.name .. ":"
local str = state_table[key_base .. "volume"]
props.volume = str and tonumber(str) or props.volume
local str = state_table[key_base .. "mute"]
props.mute = str and (str == "true") or false
local str = state_table[key_base .. "channelVolumes"]
props.channelVolumes = str and parseArray(str, tonumber) or props.channelVolumes
local str = state_table[key_base .. "channelMap"]
props.channelMap = str and parseArray(str) or props.channelMap
local str = state_table[key_base .. "latencyOffsetNsec"]
props.latencyOffsetNsec = str and math.tointeger(str) or props.latencyOffsetNsec
local str = state_table[key_base .. "iec958Codecs"]
props.iec958Codecs = str and parseArray(str) or props.iec958Codecs
end
-- convert arrays to Spa Pod
if props.channelVolumes then
table.insert(props.channelVolumes, 1, "Spa:Float")
props.channelVolumes = Pod.Array(props.channelVolumes)
end
if props.channelMap then
table.insert(props.channelMap, 1, "Spa:Enum:AudioChannel")
props.channelMap = Pod.Array(props.channelMap)
end
if props.iec958Codecs then
table.insert(props.iec958Codecs, 1, "Spa:Enum:AudioIEC958Codec")
props.iec958Codecs = Pod.Array(props.iec958Codecs)
end
-- construct Route param
local param = Pod.Object {
"Spa:Pod:Object:Param:Route", "Route",
index = route.index,
device = device_id,
props = Pod.Object(props),
save = route.save,
}
Log.debug(param, "setting route on " .. tostring(device))
device:set_param("Route", param)
route.prev_active = true
route.active = true
end
function findActiveDeviceIDs(profile)
-- parses the classes from the profile and returns the device IDs
----- sample structure, should return { 0, 8 } -----
-- classes:
-- 1: 2
-- 2:
-- 1: Audio/Source
-- 2: 1
-- 3: card.profile.devices
-- 4:
-- 1: 0
-- pod_type: Array
-- value_type: Spa:Int
-- pod_type: Struct
-- 3:
-- 1: Audio/Sink
-- 2: 1
-- 3: card.profile.devices
-- 4:
-- 1: 8
-- pod_type: Array
-- value_type: Spa:Int
-- pod_type: Struct
-- pod_type: Struct
local active_ids = {}
if type(profile.classes) == "table" and profile.classes.pod_type == "Struct" then
for _, p in ipairs(profile.classes) do
if type(p) == "table" and p.pod_type == "Struct" then
local i = 1
while true do
local k, v = p[i], p[i+1]
i = i + 2
if not k or not v then
break
end
if k == "card.profile.devices" and
type(v) == "table" and v.pod_type == "Array" then
for _, dev_id in ipairs(v) do
table.insert(active_ids, dev_id)
end
end
end
end
end
end
return active_ids
end
-- returns an array of the route names that were previously selected
-- for the given device and profile
function getStoredProfileRoutes(dev_name, profile_name)
local key = dev_name .. ":profile:" .. profile_name
local str = state_table[key]
return str and parseArray(str) or {}
end
-- find a route that was previously stored for a device_id
-- spr needs to be the array returned from getStoredProfileRoutes()
function findSavedRoute(dev_info, device_id, spr)
for idx, ri in pairs(dev_info.route_infos) do
if arrayContains(ri.devices, device_id) and
(ri.profiles == nil or arrayContains(ri.profiles, dev_info.active_profile)) and
arrayContains(spr, ri.name) then
return ri
end
end
return nil
end
-- find the best route for a given device_id, based on availability and priority
function findBestRoute(dev_info, device_id)
local best_avail = nil
local best_unk = nil
for idx, ri in pairs(dev_info.route_infos) do
if arrayContains(ri.devices, device_id) and
(ri.profiles == nil or arrayContains(ri.profiles, dev_info.active_profile)) then
if ri.available == "yes" or ri.available == "unknown" then
if ri.direction == "Output" and ri.available ~= ri.prev_available then
best_avail = ri
ri.save = true
break
elseif ri.available == "yes" then
if (best_avail == nil or ri.priority > best_avail.priority) then
best_avail = ri
end
elseif best_unk == nil or ri.priority > best_unk.priority then
best_unk = ri
end
end
end
end
return best_avail or best_unk
end
function restoreProfileRoutes(device, dev_info, profile, profile_changed)
Log.info(device, "restore routes for profile " .. profile.name)
local active_ids = findActiveDeviceIDs(profile)
local spr = getStoredProfileRoutes(dev_info.name, profile.name)
for _, device_id in ipairs(active_ids) do
Log.info(device, "restoring device " .. device_id);
local route = nil
-- restore routes selection for the newly selected profile
-- don't bother if spr is empty, there is no point
if profile_changed and #spr > 0 then
route = findSavedRoute(dev_info, device_id, spr)
if route then
-- we found a saved route
if route.available == "no" then
Log.info(device, "saved route '" .. route.name .. "' not available")
-- not available, try to find next best
route = nil
else
Log.info(device, "found saved route: " .. route.name)
-- make sure we save it again
route.save = true
end
end
end
-- we could not find a saved route, try to find a new best
if not route then
route = findBestRoute(dev_info, device_id)
if not route then
Log.info(device, "can't find best route")
else
Log.info(device, "found best route: " .. route.name)
end
end
-- restore route
if route then
restoreRoute(device, dev_info, device_id, route)
end
end
end
function findRouteInfo(dev_info, route, return_new)
local ri = dev_info.route_infos[route.index]
if not ri and return_new then
ri = {
index = route.index,
name = route.name,
direction = route.direction,
devices = route.devices or {},
profiles = route.profiles,
priority = route.priority or 0,
available = route.available or "unknown",
prev_available = route.available or "unknown",
active = false,
prev_active = false,
save = false,
}
end
return ri
end
function handleDevice(device)
local dev_info = dev_infos[device["bound-id"]]
local new_route_infos = {}
local avail_routes_changed = false
local profile = nil
-- get current profile
for p in device:iterate_params("Profile") do
profile = parseParam(p, "Profile")
end
-- look at all the routes and update/reset cached information
for p in device:iterate_params("EnumRoute") do
-- parse pod
local route = parseParam(p, "EnumRoute")
if not route then
goto skip_enum_route
end
-- find cached route information
local route_info = findRouteInfo(dev_info, route, true)
-- update properties
route_info.prev_available = route_info.available
if route_info.available ~= route.available then
Log.info(device, "route " .. route.name .. " available changed " ..
route_info.available .. " -> " .. route.available)
route_info.available = route.available
if profile and arrayContains(route.profiles, profile.index) then
avail_routes_changed = true
end
end
route_info.prev_active = route_info.active
route_info.active = false
route_info.save = false
-- store
new_route_infos[route.index] = route_info
::skip_enum_route::
end
-- replace old route_infos to lose old routes
-- that no longer exist on the device
dev_info.route_infos = new_route_infos
new_route_infos = nil
-- check for changes in the active routes
for p in device:iterate_params("Route") do
local route = parseParam(p, "Route")
if not route then
goto skip_route
end
-- get cached route info and at the same time
-- ensure that the route is also in EnumRoute
local route_info = findRouteInfo(dev_info, route, false)
if not route_info then
goto skip_route
end
-- update state
route_info.active = true
route_info.save = route.save
if not route_info.prev_active then
-- a new route is now active, restore the volume and
-- make sure we save this as a preferred route
Log.info(device, "new active route found " .. route.name)
restoreRoute(device, dev_info, route.device, route_info)
elseif route.save then
-- just save route properties
Log.info(device, "storing route props for " .. route.name)
saveRouteProps(dev_info, route)
end
::skip_route::
end
-- restore routes for profile
if profile then
local profile_changed = (dev_info.active_profile ~= profile.index)
-- if the profile changed, restore routes for that profile
-- if any of the routes of the current profile changed in availability,
-- then try to select a new "best" route for each device and ignore
-- what was stored
if profile_changed or avail_routes_changed then
dev_info.active_profile = profile.index
restoreProfileRoutes(device, dev_info, profile, profile_changed)
end
saveProfile(dev_info, profile.name)
end
end
om = ObjectManager {
Interest {
type = "device",
Constraint { "device.name", "is-present", type = "pw-global" },
}
}
om:connect("objects-changed", function (om)
local new_dev_infos = {}
for device in om:iterate() do
local dev_info = dev_infos[device["bound-id"]]
-- new device appeared
if not dev_info then
dev_info = {
name = device.properties["device.name"],
active_profile = -1,
route_infos = {},
}
dev_infos[device["bound-id"]] = dev_info
device:connect("params-changed", handleDevice)
handleDevice(device)
end
new_dev_infos[device["bound-id"]] = dev_info
end
-- replace list to get rid of dev_info for devices that no longer exist
dev_infos = new_dev_infos
end)
om:activate()

View File

@@ -0,0 +1,218 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
local config = ... or {}
config.roles = config.roles or {}
config["duck.level"] = config["duck.level"] or 0.3
function findRole(role)
if role and not config.roles[role] then
for r, p in pairs(config.roles) do
if type(p.alias) == "table" then
for i = 1, #(p.alias), 1 do
if role == p.alias[i] then
return r
end
end
end
end
end
return role
end
function priorityForRole(role)
local r = role and config.roles[role] or nil
return r and r.priority or 0
end
function getAction(dominant_role, other_role)
-- default to "mix" if the role is not configured
if not dominant_role or not config.roles[dominant_role] then
return "mix"
end
local role_config = config.roles[dominant_role]
return role_config["action." .. other_role]
or role_config["action.default"]
or "mix"
end
function restoreVolume(role, media_class)
if not mixer_api then return end
local ep = endpoints_om:lookup {
Constraint { "media.role", "=", role, type = "pw" },
Constraint { "media.class", "=", media_class, type = "pw" },
}
if ep and ep.properties["node.id"] then
Log.debug(ep, "restore role " .. role)
mixer_api:call("set-volume", ep.properties["node.id"], {
monitorVolume = 1.0,
})
end
end
function duck(role, media_class)
if not mixer_api then return end
local ep = endpoints_om:lookup {
Constraint { "media.role", "=", role, type = "pw" },
Constraint { "media.class", "=", media_class, type = "pw" },
}
if ep and ep.properties["node.id"] then
Log.debug(ep, "duck role " .. role)
mixer_api:call("set-volume", ep.properties["node.id"], {
monitorVolume = config["duck.level"],
})
end
end
function getSuspendPlaybackMetadata ()
local suspend = false
local metadata = metadata_om:lookup()
if metadata then
local value = metadata:find(0, "suspend.playback")
if value then
suspend = value == "1" and true or false
end
end
return suspend
end
function rescan()
local links = {
["Audio/Source"] = {},
["Audio/Sink"] = {},
["Video/Source"] = {},
}
Log.info("Rescan endpoint links")
-- deactivate all links if suspend playback metadata is present
local suspend = getSuspendPlaybackMetadata()
for silink in silinks_om:iterate() do
if suspend then
silink:deactivate(Feature.SessionItem.ACTIVE)
end
end
-- gather info about links
for silink in silinks_om:iterate() do
local props = silink.properties
local role = props["media.role"]
local target_class = props["target.media.class"]
local plugged = props["item.plugged.usec"]
local active =
((silink:get_active_features() & Feature.SessionItem.ACTIVE) ~= 0)
if links[target_class] then
table.insert(links[target_class], {
silink = silink,
role = findRole(role),
active = active,
priority = priorityForRole(role),
plugged = plugged and tonumber(plugged) or 0
})
end
end
local function compareLinks(l1, l2)
return (l1.priority > l2.priority) or
((l1.priority == l2.priority) and (l1.plugged > l2.plugged))
end
for media_class, v in pairs(links) do
-- sort on priority and stream creation time
table.sort(v, compareLinks)
-- apply actions
local first_link = v[1]
if first_link then
for i = 2, #v, 1 do
local action = getAction(first_link.role, v[i].role)
if action == "cork" then
if v[i].active then
v[i].silink:deactivate(Feature.SessionItem.ACTIVE)
end
elseif action == "mix" then
if not v[i].active and not suspend then
v[i].silink:activate(Feature.SessionItem.ACTIVE, pendingOperation())
end
restoreVolume(v[i].role, media_class)
elseif action == "duck" then
if not v[i].active and not suspend then
v[i].silink:activate(Feature.SessionItem.ACTIVE, pendingOperation())
end
duck(v[i].role, media_class)
else
Log.warning("Unknown action: " .. action)
end
end
if not first_link.active and not suspend then
first_link.silink:activate(Feature.SessionItem.ACTIVE, pendingOperation())
end
restoreVolume(first_link.role, media_class)
end
end
end
pending_ops = 0
pending_rescan = false
function pendingOperation()
pending_ops = pending_ops + 1
return function()
pending_ops = pending_ops - 1
if pending_ops == 0 and pending_rescan then
pending_rescan = false
rescan()
end
end
end
function maybeRescan()
if pending_ops == 0 then
rescan()
else
pending_rescan = true
end
end
silinks_om = ObjectManager {
Interest {
type = "SiLink",
Constraint { "is.policy.endpoint.client.link", "=", true },
},
}
silinks_om:connect("objects-changed", maybeRescan)
silinks_om:activate()
-- enable ducking if mixer-api is loaded
mixer_api = Plugin.find("mixer-api")
if mixer_api then
endpoints_om = ObjectManager {
Interest { type = "endpoint" },
}
endpoints_om:activate()
end
metadata_om = ObjectManager {
Interest {
type = "metadata",
Constraint { "metadata.name", "=", "default" },
}
}
metadata_om:connect("object-added", function (om, metadata)
metadata:connect("changed", function (m, subject, key, t, value)
if key == "suspend.playback" then
maybeRescan()
end
end)
end)
metadata_om:activate()

View File

@@ -0,0 +1,259 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local config = ... or {}
config.roles = config.roles or {}
local self = {}
self.scanning = false
self.pending_rescan = false
function rescan ()
for si in linkables_om:iterate() do
handleLinkable (si)
end
end
function scheduleRescan ()
if self.scanning then
self.pending_rescan = true
return
end
self.scanning = true
rescan ()
self.scanning = false
if self.pending_rescan then
self.pending_rescan = false
Core.sync(function ()
scheduleRescan ()
end)
end
end
function findRole(role, tmc)
if role and not config.roles[role] then
-- find the role with matching alias
for r, p in pairs(config.roles) do
-- default media class can be overridden in the role config data
mc = p["media.class"] or "Audio/Sink"
if (type(p.alias) == "table" and tmc == mc) then
for i = 1, #(p.alias), 1 do
if role == p.alias[i] then
return r
end
end
end
end
-- otherwise get the lowest priority role
local lowest_priority_p = nil
local lowest_priority_r = nil
for r, p in pairs(config.roles) do
mc = p["media.class"] or "Audio/Sink"
if tmc == mc and (lowest_priority_p == nil or
p.priority < lowest_priority_p.priority) then
lowest_priority_p = p
lowest_priority_r = r
end
end
return lowest_priority_r
end
return role
end
function findTargetEndpoint (node, media_class, role)
local target_class_assoc = {
["Stream/Input/Audio"] = "Audio/Source",
["Stream/Output/Audio"] = "Audio/Sink",
["Stream/Input/Video"] = "Video/Source",
}
local media_role = nil
local highest_priority = -1
local target = nil
-- get target media class
local target_media_class = target_class_assoc[media_class]
if not target_media_class then
return nil
end
-- find highest priority endpoint by role
media_role = findRole(role, target_media_class)
for si_target_ep in endpoints_om:iterate {
Constraint { "role", "=", media_role, type = "pw-global" },
Constraint { "media.class", "=", target_media_class, type = "pw-global" },
} do
local priority = tonumber(si_target_ep.properties["priority"])
if priority > highest_priority then
highest_priority = priority
target = si_target_ep
end
end
return target
end
function createLink (si, si_target_ep)
local out_item = nil
local in_item = nil
local si_props = si.properties
local target_ep_props = si_target_ep.properties
if si_props["item.node.direction"] == "output" then
-- playback
out_item = si
in_item = si_target_ep
else
-- capture
out_item = si_target_ep
in_item = si
end
Log.info (string.format("link %s <-> %s",
tostring(si_props["node.name"]),
tostring(target_ep_props["name"])))
-- create and configure link
local si_link = SessionItem ( "si-standard-link" )
if not si_link:configure {
["out.item"] = out_item,
["in.item"] = in_item,
["out.item.port.context"] = "output",
["in.item.port.context"] = "input",
["is.policy.endpoint.client.link"] = true,
["media.role"] = target_ep_props["role"],
["target.media.class"] = target_ep_props["media.class"],
["item.plugged.usec"] = si_props["item.plugged.usec"],
} then
Log.warning (si_link, "failed to configure si-standard-link")
return
end
-- register
si_link:register()
end
function checkLinkable (si)
-- only handle session items that has a node associated proxy
local node = si:get_associated_proxy ("node")
if not node or not node.properties then
return false
end
-- only handle stream session items
local media_class = node.properties["media.class"]
if not media_class or not string.find (media_class, "Stream") then
return false
end
-- Determine if we can handle item by this policy
if endpoints_om:get_n_objects () == 0 then
Log.debug (si, "item won't be handled by this policy")
return false
end
return true
end
function handleLinkable (si)
if not checkLinkable (si) then
return
end
local node = si:get_associated_proxy ("node")
local media_class = node.properties["media.class"] or ""
local media_role = node.properties["media.role"] or "Default"
Log.info (si, "handling item " .. tostring(node.properties["node.name"]) ..
" with role " .. media_role)
-- find proper target endpoint
local si_target_ep = findTargetEndpoint (node, media_class, media_role)
if not si_target_ep then
Log.info (si, "... target endpoint not found")
return
end
-- Check if item is linked to proper target, otherwise re-link
for link in links_om:iterate() do
local out_id = tonumber(link.properties["out.item.id"])
local in_id = tonumber(link.properties["in.item.id"])
if out_id == si.id or in_id == si.id then
local is_out = out_id == si.id and true or false
for peer_ep in endpoints_om:iterate() do
if peer_ep.id == (is_out and in_id or out_id) then
if peer_ep.id == si_target_ep.id then
Log.info (si, "... already linked to proper target endpoint")
return
end
-- remove old link if active, otherwise schedule rescan
if ((link:get_active_features() & Feature.SessionItem.ACTIVE) ~= 0) then
link:remove ()
Log.info (si, "... moving to new target")
else
scheduleRescan ()
Log.info (si, "... scheduled rescan")
return
end
end
end
end
end
-- create new link
createLink (si, si_target_ep)
end
function unhandleLinkable (si)
if not checkLinkable (si) then
return
end
local node = si:get_associated_proxy ("node")
Log.info (si, "unhandling item " .. tostring(node.properties["node.name"]))
-- remove any links associated with this item
for silink in links_om:iterate() do
local out_id = tonumber (silink.properties["out.item.id"])
local in_id = tonumber (silink.properties["in.item.id"])
if out_id == si.id or in_id == si.id then
silink:remove ()
Log.info (silink, "... link removed")
end
end
end
endpoints_om = ObjectManager { Interest { type = "SiEndpoint" }}
linkables_om = ObjectManager { Interest { type = "SiLinkable",
-- only handle si-audio-adapter and si-node
Constraint {
"item.factory.name", "=", "si-audio-adapter", type = "pw-global" },
Constraint {
"active-features", "!", 0, type = "gobject" },
}
}
links_om = ObjectManager { Interest { type = "SiLink",
-- only handle links created by this policy
Constraint { "is.policy.endpoint.client.link", "=", true, type = "pw-global" },
} }
linkables_om:connect("objects-changed", function (om)
scheduleRescan ()
end)
linkables_om:connect("object-removed", function (om, si)
unhandleLinkable (si)
end)
endpoints_om:activate()
linkables_om:activate()
links_om:activate()

View File

@@ -0,0 +1,234 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local config = ... or {}
-- ensure config.move and config.follow are not nil
config.move = config.move or false
config.follow = config.follow or false
local self = {}
self.scanning = false
self.pending_rescan = false
function rescan ()
-- check endpoints and register new links
for si_ep in endpoints_om:iterate() do
handleEndpoint (si_ep)
end
end
function scheduleRescan ()
if self.scanning then
self.pending_rescan = true
return
end
self.scanning = true
rescan ()
self.scanning = false
if self.pending_rescan then
self.pending_rescan = false
Core.sync(function ()
scheduleRescan ()
end)
end
end
function findTargetByDefaultNode (target_media_class)
local def_id = default_nodes:call("get-default-node", target_media_class)
if def_id ~= Id.INVALID then
for si_target in linkables_om:iterate() do
local target_node = si_target:get_associated_proxy ("node")
if target_node["bound-id"] == def_id then
return si_target
end
end
end
return nil
end
function findTargetByFirstAvailable (target_media_class)
for si_target in linkables_om:iterate() do
local target_node = si_target:get_associated_proxy ("node")
if target_node.properties["media.class"] == target_media_class then
return si_target
end
end
return nil
end
function findUndefinedTarget (si_ep)
local media_class = si_ep.properties["media.class"]
local target_class_assoc = {
["Audio/Source"] = "Audio/Source",
["Audio/Sink"] = "Audio/Sink",
["Video/Source"] = "Video/Source",
}
local target_media_class = target_class_assoc[media_class]
if not target_media_class then
return nil
end
local si_target = findTargetByDefaultNode (target_media_class)
if not si_target then
si_target = findTargetByFirstAvailable (target_media_class)
end
return si_target
end
function createLink (si_ep, si_target)
local out_item = nil
local in_item = nil
local ep_props = si_ep.properties
local target_props = si_target.properties
if target_props["item.node.direction"] == "input" then
-- playback
out_item = si_ep
in_item = si_target
else
-- capture
in_item = si_ep
out_item = si_target
end
Log.info (string.format("link %s <-> %s",
ep_props["name"],
target_props["node.name"]))
-- create and configure link
local si_link = SessionItem ( "si-standard-link" )
if not si_link:configure {
["out.item"] = out_item,
["in.item"] = in_item,
["out.item.port.context"] = "output",
["in.item.port.context"] = "input",
["passive"] = true,
["is.policy.endpoint.device.link"] = true,
} then
Log.warning (si_link, "failed to configure si-standard-link")
return
end
-- register
si_link:register ()
-- activate
si_link:activate (Feature.SessionItem.ACTIVE, function (l, e)
if e then
Log.warning (l, "failed to activate si-standard-link: " .. tostring(e))
l:remove ()
else
Log.info (l, "activated si-standard-link")
end
end)
end
function handleEndpoint (si_ep)
Log.info (si_ep, "handling endpoint " .. si_ep.properties["name"])
-- find proper target item
local si_target = findUndefinedTarget (si_ep)
if not si_target then
Log.info (si_ep, "... target item not found")
return
end
-- Check if item is linked to proper target, otherwise re-link
for link in links_om:iterate() do
local out_id = tonumber(link.properties["out.item.id"])
local in_id = tonumber(link.properties["in.item.id"])
if out_id == si_ep.id or in_id == si_ep.id then
local is_out = out_id == si_ep.id and true or false
for peer in linkables_om:iterate() do
if peer.id == (is_out and in_id or out_id) then
if peer.id == si_target.id then
Log.info (si_ep, "... already linked to proper target")
return
end
-- remove old link if active, otherwise schedule rescan
if ((link:get_active_features() & Feature.SessionItem.ACTIVE) ~= 0) then
link:remove ()
Log.info (si_ep, "... moving to new target")
else
scheduleRescan ()
Log.info (si_ep, "... scheduled rescan")
return
end
end
end
end
end
-- create new link
createLink (si_ep, si_target)
end
function unhandleLinkable (si)
si_props = si.properties
Log.info (si, string.format("unhandling item: %s (%s)",
tostring(si_props["node.name"]), tostring(si_props["node.id"])))
-- remove any links associated with this item
for silink in links_om:iterate() do
local out_id = tonumber (silink.properties["out.item.id"])
local in_id = tonumber (silink.properties["in.item.id"])
if out_id == si.id or in_id == si.id then
silink:remove ()
Log.info (silink, "... link removed")
end
end
end
default_nodes = Plugin.find("default-nodes-api")
endpoints_om = ObjectManager { Interest { type = "SiEndpoint" }}
linkables_om = ObjectManager {
Interest {
type = "SiLinkable",
-- only handle device si-audio-adapter items
Constraint { "item.factory.name", "=", "si-audio-adapter", type = "pw-global" },
Constraint { "item.node.type", "=", "device", type = "pw-global" },
Constraint { "active-features", "!", 0, type = "gobject" },
}
}
links_om = ObjectManager {
Interest {
type = "SiLink",
-- only handle links created by this policy
Constraint { "is.policy.endpoint.device.link", "=", true, type = "pw-global" },
}
}
-- listen for default node changes if config.follow is enabled
if config.follow then
default_nodes:connect("changed", function (p)
scheduleRescan ()
end)
end
linkables_om:connect("objects-changed", function (om)
scheduleRescan ()
end)
endpoints_om:connect("object-added", function (om)
scheduleRescan ()
end)
linkables_om:connect("object-removed", function (om, si)
unhandleLinkable (si)
end)
endpoints_om:activate()
linkables_om:activate()
links_om:activate()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,499 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- Based on restore-stream.c from pipewire-media-session
-- Copyright © 2020 Wim Taymans
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local config = ... or {}
config.properties = config.properties or {}
config_restore_props = config.properties["restore-props"] or false
config_restore_target = config.properties["restore-target"] or false
config_default_channel_volume = config.properties["default-channel-volume"] or 1.0
-- preprocess rules and create Interest objects
for _, r in ipairs(config.rules or {}) do
r.interests = {}
for _, i in ipairs(r.matches) do
local interest_desc = { type = "properties" }
for _, c in ipairs(i) do
c.type = "pw"
table.insert(interest_desc, Constraint(c))
end
local interest = Interest(interest_desc)
table.insert(r.interests, interest)
end
r.matches = nil
end
-- applies properties from config.rules when asked to
function rulesApplyProperties(properties)
for _, r in ipairs(config.rules or {}) do
if r.apply_properties then
for _, interest in ipairs(r.interests) do
if interest:matches(properties) then
for k, v in pairs(r.apply_properties) do
properties[k] = v
end
end
end
end
end
end
-- the state storage
state = State("restore-stream")
state_table = state:load()
-- simple serializer {"foo", "bar"} -> "foo;bar;"
function serializeArray(a)
local str = ""
for _, v in ipairs(a) do
str = str .. tostring(v):gsub(";", "\\;") .. ";"
end
return str
end
-- simple deserializer "foo;bar;" -> {"foo", "bar"}
function parseArray(str, convert_value, with_type)
local array = {}
local val = ""
local escaped = false
for i = 1, #str do
local c = str:sub(i,i)
if c == '\\' then
escaped = true
elseif c == ';' and not escaped then
val = convert_value and convert_value(val) or val
table.insert(array, val)
val = ""
else
val = val .. tostring(c)
escaped = false
end
end
if with_type then
array["pod_type"] = "Array"
end
return array
end
function parseParam(param, id)
local route = param:parse()
if route.pod_type == "Object" and route.object_id == id then
return route.properties
else
return nil
end
end
function storeAfterTimeout()
if timeout_source then
timeout_source:destroy()
end
timeout_source = Core.timeout_add(1000, function ()
local saved, err = state:save(state_table)
if not saved then
Log.warning(err)
end
timeout_source = nil
end)
end
function findSuitableKey(properties)
local keys = {
"media.role",
"application.id",
"application.name",
"media.name",
"node.name",
}
local key = nil
for _, k in ipairs(keys) do
local p = properties[k]
if p then
key = string.format("%s:%s:%s",
properties["media.class"]:gsub("^Stream/", ""), k, p)
break
end
end
return key
end
function saveTarget(subject, target_key, type, value)
if target_key ~= "target.node" and target_key ~= "target.object" then
return
end
local node = streams_om:lookup {
Constraint { "bound-id", "=", subject, type = "gobject" }
}
if not node then
return
end
local stream_props = node.properties
rulesApplyProperties(stream_props)
if stream_props["state.restore-target"] == false then
return
end
local key_base = findSuitableKey(stream_props)
if not key_base then
return
end
local target_value = value
local target_name = nil
if not target_value then
local metadata = metadata_om:lookup()
if metadata then
target_value = metadata:find(node["bound-id"], target_key)
end
end
if target_value and target_value ~= "-1" then
local target_node
if target_key == "target.object" then
target_node = allnodes_om:lookup {
Constraint { "object.serial", "=", target_value, type = "pw-global" }
}
else
target_node = allnodes_om:lookup {
Constraint { "bound-id", "=", target_value, type = "gobject" }
}
end
if target_node then
target_name = target_node.properties["node.name"]
end
end
state_table[key_base .. ":target"] = target_name
Log.info(node, "saving stream target for " ..
tostring(stream_props["node.name"]) ..
" -> " .. tostring(target_name))
storeAfterTimeout()
end
function restoreTarget(node, target_name)
local stream_props = node.properties
local target_in_props = nil
if stream_props ["target.object"] ~= nil or
stream_props ["node.target"] ~= nil then
target_in_props = stream_props ["target.object"] or
stream_props ["node.target"]
Log.debug (string.format ("%s%s%s%s",
"Not restoring the target for ",
stream_props ["node.name"],
" because it is already set to ",
target_in_props))
return
end
local target_node = allnodes_om:lookup {
Constraint { "node.name", "=", target_name, type = "pw" }
}
if target_node then
local metadata = metadata_om:lookup()
if metadata then
metadata:set(node["bound-id"], "target.node", "Spa:Id",
target_node["bound-id"])
end
end
end
function jsonTable(val, name)
local tmp = ""
local count = 0
if name then tmp = tmp .. string.format("%q", name) .. ": " end
if type(val) == "table" then
if val["pod_type"] == "Array" then
tmp = tmp .. "["
for _, v in ipairs(val) do
if count > 0 then tmp = tmp .. "," end
tmp = tmp .. jsonTable(v)
count = count + 1
end
tmp = tmp .. "]"
else
tmp = tmp .. "{"
for k, v in pairs(val) do
if count > 0 then tmp = tmp .. "," end
tmp = tmp .. jsonTable(v, k)
count = count + 1
end
tmp = tmp .. "}"
end
elseif type(val) == "number" then
tmp = tmp .. tostring(val)
elseif type(val) == "string" then
tmp = tmp .. string.format("%q", val)
elseif type(val) == "boolean" then
tmp = tmp .. (val and "true" or "false")
else
tmp = tmp .. "\"[type:" .. type(val) .. "]\""
end
return tmp
end
function moveToMetadata(key_base, metadata)
local route_table = { }
local count = 0
key = "restore.stream." .. key_base
key = string.gsub(key, ":", ".", 1);
local str = state_table[key_base .. ":volume"]
if str then
route_table["volume"] = tonumber(str)
count = count + 1;
end
local str = state_table[key_base .. ":mute"]
if str then
route_table["mute"] = str == "true"
count = count + 1;
end
local str = state_table[key_base .. ":channelVolumes"]
if str then
route_table["volumes"] = parseArray(str, tonumber, true)
count = count + 1;
end
local str = state_table[key_base .. ":channelMap"]
if str then
route_table["channels"] = parseArray(str, nil, true)
count = count + 1;
end
if count > 0 then
metadata:set(0, key, "Spa:String:JSON", jsonTable(route_table));
end
end
function saveStream(node)
local stream_props = node.properties
rulesApplyProperties(stream_props)
if config_restore_props and stream_props["state.restore-props"] ~= false then
local key_base = findSuitableKey(stream_props)
if not key_base then
return
end
Log.info(node, "saving stream props for " ..
tostring(stream_props["node.name"]))
for p in node:iterate_params("Props") do
local props = parseParam(p, "Props")
if not props then
goto skip_prop
end
if props.volume then
state_table[key_base .. ":volume"] = tostring(props.volume)
end
if props.mute ~= nil then
state_table[key_base .. ":mute"] = tostring(props.mute)
end
if props.channelVolumes then
state_table[key_base .. ":channelVolumes"] = serializeArray(props.channelVolumes)
end
if props.channelMap then
state_table[key_base .. ":channelMap"] = serializeArray(props.channelMap)
end
::skip_prop::
end
storeAfterTimeout()
end
end
function build_default_channel_volumes (node)
local def_vol = config_default_channel_volume
local channels = 2
local res = {}
local str = node.properties["state.default-channel-volume"]
if str ~= nil then
def_vol = tonumber (str)
end
for pod in node:iterate_params("Format") do
local pod_parsed = pod:parse()
if pod_parsed ~= nil then
channels = pod_parsed.properties.channels
break
end
end
while (#res < channels) do
table.insert(res, def_vol)
end
return res;
end
function restoreStream(node)
local stream_props = node.properties
rulesApplyProperties(stream_props)
local key_base = findSuitableKey(stream_props)
if not key_base then
return
end
if config_restore_props and stream_props["state.restore-props"] ~= false then
local props = { "Spa:Pod:Object:Param:Props", "Props" }
local str = state_table[key_base .. ":volume"]
props.volume = str and tonumber(str) or nil
local str = state_table[key_base .. ":mute"]
props.mute = str and (str == "true") or nil
local str = state_table[key_base .. ":channelVolumes"]
props.channelVolumes = str and parseArray(str, tonumber) or
build_default_channel_volumes (node)
local str = state_table[key_base .. ":channelMap"]
props.channelMap = str and parseArray(str) or nil
-- convert arrays to Spa Pod
if props.channelVolumes then
table.insert(props.channelVolumes, 1, "Spa:Float")
props.channelVolumes = Pod.Array(props.channelVolumes)
end
if props.channelMap then
table.insert(props.channelMap, 1, "Spa:Enum:AudioChannel")
props.channelMap = Pod.Array(props.channelMap)
end
Log.info(node, "restore values from " .. key_base)
local param = Pod.Object(props)
Log.debug(param, "setting props on " .. tostring(node))
node:set_param("Props", param)
end
if config_restore_target and stream_props["state.restore-target"] ~= false then
local str = state_table[key_base .. ":target"]
if str then
restoreTarget(node, str)
end
end
end
if config_restore_target then
metadata_om = ObjectManager {
Interest {
type = "metadata",
Constraint { "metadata.name", "=", "default" },
}
}
metadata_om:connect("object-added", function (om, metadata)
-- process existing metadata
for s, k, t, v in metadata:iterate(Id.ANY) do
saveTarget(s, k, t, v)
end
-- and watch for changes
metadata:connect("changed", function (m, subject, key, type, value)
saveTarget(subject, key, type, value)
end)
end)
metadata_om:activate()
end
function handleRouteSettings(subject, key, type, value)
if type ~= "Spa:String:JSON" then
return
end
if string.find(key, "^restore.stream.") == nil then
return
end
if value == nil then
return
end
local json = Json.Raw (value);
if json == nil or not json:is_object () then
return
end
local vparsed = json:parse()
local key_base = string.sub(key, string.len("restore.stream.") + 1)
local str;
key_base = string.gsub(key_base, "%.", ":", 1);
if vparsed.volume ~= nil then
state_table[key_base .. ":volume"] = tostring (vparsed.volume)
end
if vparsed.mute ~= nil then
state_table[key_base .. ":mute"] = tostring (vparsed.mute)
end
if vparsed.channels ~= nil then
state_table[key_base .. ":channelMap"] = serializeArray (vparsed.channels)
end
if vparsed.volumes ~= nil then
state_table[key_base .. ":channelVolumes"] = serializeArray (vparsed.volumes)
end
storeAfterTimeout()
end
rs_metadata = ImplMetadata("route-settings")
rs_metadata:activate(Features.ALL, function (m, e)
if e then
Log.warning("failed to activate route-settings metadata: " .. tostring(e))
return
end
-- copy state into the metadata
moveToMetadata("Output/Audio:media.role:Notification", m)
-- watch for changes
m:connect("changed", function (m, subject, key, type, value)
handleRouteSettings(subject, key, type, value)
end)
end)
allnodes_om = ObjectManager { Interest { type = "node" } }
allnodes_om:activate()
streams_om = ObjectManager {
-- match stream nodes
Interest {
type = "node",
Constraint { "media.class", "matches", "Stream/*", type = "pw-global" },
},
-- and device nodes that are not associated with any routes
Interest {
type = "node",
Constraint { "media.class", "matches", "Audio/*", type = "pw-global" },
Constraint { "device.routes", "is-absent", type = "pw" },
},
Interest {
type = "node",
Constraint { "media.class", "matches", "Audio/*", type = "pw-global" },
Constraint { "device.routes", "equals", "0", type = "pw" },
},
}
streams_om:connect("object-added", function (streams_om, node)
node:connect("params-changed", saveStream)
restoreStream(node)
end)
streams_om:activate()

View File

@@ -0,0 +1,36 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- Receive script arguments from config.lua
local endpoints_config = ...
function createEndpoint (factory_name, properties)
-- create endpoint
local ep = SessionItem ( factory_name )
if not ep then
Log.warning (ep, "could not create endpoint of type " .. factory_name)
return
end
-- configure endpoint
if not ep:configure(properties) then
Log.warning(ep, "failed to configure endpoint " .. properties.name)
return
end
-- activate and register endpoint
ep:activate (Features.ALL, function (item)
item:register ()
Log.info(item, "registered endpoint " .. properties.name)
end)
end
for name, properties in pairs(endpoints_config) do
properties["name"] = name
createEndpoint ("si-audio-endpoint", properties)
end

View File

@@ -0,0 +1,56 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
om = ObjectManager {
Interest { type = "node",
Constraint { "media.class", "matches", "Audio/*" }
},
Interest { type = "node",
Constraint { "media.class", "matches", "Video/*" }
},
}
sources = {}
om:connect("object-added", function (om, node)
node:connect("state-changed", function (node, old_state, cur_state)
-- Always clear the current source if any
local id = node["bound-id"]
if sources[id] then
sources[id]:destroy()
sources[id] = nil
end
-- Add a timeout source if idle for at least 5 seconds
if cur_state == "idle" or cur_state == "error" then
-- honor "session.suspend-timeout-seconds" if specified
local timeout =
tonumber(node.properties["session.suspend-timeout-seconds"]) or 5
if timeout == 0 then
return
end
-- add idle timeout; multiply by 1000, timeout_add() expects ms
sources[id] = Core.timeout_add(timeout * 1000, function()
-- Suspend the node
Log.info(node, "was idle for a while; suspending ...")
node:send_command("Suspend")
-- Unref the source
sources[id] = nil
-- false (== G_SOURCE_REMOVE) destroys the source so that this
-- function does not get fired again after 5 seconds
return false
end)
end
end)
end)
om:activate()