This commit is contained in:
2026-03-24 22:53:51 -04:00
parent fe74e79404
commit 89dc5175d9
115 changed files with 1709 additions and 437 deletions

View File

@@ -0,0 +1,154 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
-- create-item.lua script takes pipewire nodes and creates session items (a.k.a
-- linkable) objects out of them.
cutils = require ("common-utils")
log = Log.open_topic ("s-node")
items = {}
function configProperties (node)
local properties = node.properties
local media_class = properties ["media.class"] or ""
-- ensure a media.type is set
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"] = node
properties ["item.node.direction"] =
cutils.mediaClassToDirection (media_class)
properties ["item.node.type"] =
media_class:find ("^Stream/") and "stream" or "device"
properties ["item.plugged.usec"] = GLib.get_monotonic_time ()
properties ["item.features.no-dsp"] =
Settings.get_boolean ("node.features.audio.no-dsp")
properties ["item.features.monitor"] =
Settings.get_boolean ("node.features.audio.monitor-ports")
properties ["item.features.control-port"] =
Settings.get_boolean ("node.features.audio.control-port")
properties ["node.id"] = node ["bound-id"]
-- set the default media.role, if configured
-- avoid Settings.get_string(), as it will parse the default "null" value
-- as a string instead of returning nil
local default_role = Settings.get ("node.stream.default-media-role")
if default_role then
default_role = default_role:parse()
properties ["media.role"] = properties ["media.role"] or default_role
end
return properties
end
AsyncEventHook {
name = "node/create-item",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "#", "Video/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "#", "Audio/*", type = "pw-global" },
Constraint { "wireplumber.is-virtual", "-", type = "pw" },
},
},
steps = {
start = {
next = "register",
execute = function (event, transition)
local node = event:get_subject ()
local id = node.id
local item
local item_type
local media_class = node.properties ['media.class']
if string.find (media_class, "Audio") then
item_type = "si-audio-adapter"
else
item_type = "si-node"
end
log:info (node, "creating item for node -> " .. item_type)
-- create item
item = SessionItem (item_type)
items [id] = item
-- configure item
if not item:configure (configProperties (node)) then
transition:return_error ("failed to configure item for node "
.. tostring (id))
return
end
-- activate item
item:activate (Features.ALL, function (_, e)
if e then
transition:return_error ("failed to activate item: "
.. tostring (e));
else
transition:advance ()
end
end)
end,
},
register = {
next = "none",
execute = function (event, transition)
local node = event:get_subject ()
local bound_id = node ["bound-id"]
local item = items [node.id]
log:info (item, "activated item for node " .. tostring (bound_id))
item:register ()
transition:advance ()
end,
},
},
}:register ()
SimpleEventHook {
name = "node/destroy-item",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "#", "Video/*", type = "pw-global" },
},
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "#", "Audio/*", type = "pw-global" },
Constraint { "wireplumber.is-virtual", "-", type = "pw" },
},
},
execute = function (event)
local node = event:get_subject ()
local id = node.id
if items [id] then
items [id]:remove ()
items [id] = nil
end
end
}:register ()

View File

@@ -0,0 +1,111 @@
-- WirePlumber
--
-- Copyright © 2022 Collabora Ltd.
--
-- SPDX-License-Identifier: MIT
--
-- Logic to "forward" the format set on special filter nodes to their
-- virtual device peer node. This is for things like the "loopback" module,
-- which always comes in pairs of 2 nodes, one stream and one virtual device.
--
-- FIXME: this script can be further improved
lutils = require ("linking-utils")
log = Log.open_topic ("s-node")
function findAssociatedLinkGroupNode (si)
local si_props = si.properties
local link_group = si_props ["node.link-group"]
if link_group == nil then
return nil
end
local std_event_source = Plugin.find ("standard-event-source")
local om = std_event_source:call ("get-object-manager", "session-item")
-- get the associated media class
local assoc_direction = cutils.getTargetDirection (si_props)
local assoc_media_class = si_props ["media.type"] ..
(assoc_direction == "input" and "/Sink" or "/Source")
-- find the linkable with same link group and matching assoc media class
for assoc_si in om:iterate { type = "SiLinkable" } do
local assoc_props = assoc_si.properties
local assoc_link_group = assoc_props ["node.link-group"]
if assoc_link_group == link_group and
assoc_media_class == assoc_props ["media.class"] then
return assoc_si
end
end
return nil
end
function onLinkGroupPortsStateChanged (si, old_state, new_state)
local si_props = si.properties
-- only handle items with configured ports state
if new_state ~= "configured" then
return
end
log:info (si, "ports format changed on " .. si_props ["node.name"])
-- find associated device
local si_device = findAssociatedLinkGroupNode (si)
if si_device ~= nil then
local device_node_name = si_device.properties ["node.name"]
-- get the stream format
local f, m = si:get_ports_format ()
-- unregister the device
log:info (si_device, "unregistering " .. device_node_name)
si_device:remove ()
-- set new format in the device
log:info (si_device, "setting new format in " .. device_node_name)
si_device:set_ports_format (f, m, function (item, e)
if e ~= nil then
log:warning (item, "failed to configure ports in " ..
device_node_name .. ": " .. e)
end
-- register back the device
log:info (item, "registering " .. device_node_name)
item:register ()
end)
end
end
SimpleEventHook {
name = "node/filter-forward-format",
interests = {
EventInterest {
Constraint { "event.type", "=", "session-item-added" },
Constraint { "event.session-item.interface", "=", "linkable" },
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "media.class", "#", "Stream/*", type = "pw-global" },
},
},
execute = function (event)
local si = event:get_subject ()
-- Forward filters ports format to associated virtual devices if enabled
if Settings.get_boolean ("node.filter.forward-format") then
local si_props = si.properties
local link_group = si_props ["node.link-group"]
local si_flags = lutils:get_flags (si.id)
-- only listen for ports state changed on audio filter streams
if si_flags.ports_state_signal ~= true and
si_props ["item.factory.name"] == "si-audio-adapter" and
si_props ["item.node.type"] == "stream" and
link_group ~= nil then
si:connect ("adapter-ports-state-changed", onLinkGroupPortsStateChanged)
si_flags.ports_state_signal = true
log:info (si, "listening ports state changed on " .. si_props ["node.name"])
end
end
end
}:register ()

View File

@@ -0,0 +1,92 @@
-- WirePlumber
--
-- Copyright © 2022-2023 The WirePlumber project contributors
-- @author Dmitry Sharshakov <d3dx12.xx@gmail.com>
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic("s-node")
config = {}
config.rules = Conf.get_section_as_json("node.software-dsp.rules", Json.Array{})
-- TODO: port from Obj Manager to Hooks
clients_om = ObjectManager {
Interest { type = "client" }
}
filter_nodes = {}
hidden_nodes = {}
SimpleEventHook {
name = "node/dsp/create-dsp-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
},
},
execute = function(event)
local node = event:get_subject()
JsonUtils.match_rules (config.rules, node.properties, function (action, value)
if action == "create-filter" then
local props = value:parse (1)
log:debug("DSP rule found for " .. node.properties["node.name"])
if props["filter-graph"] then
log:debug("Loading filter graph for " .. node.properties["node.name"])
filter_nodes[node.id] = LocalModule("libpipewire-module-filter-chain", props["filter-graph"], {})
elseif props["filter-path"] then
log:debug("Loading filter graph for " .. node.properties["node.name"] .. " from disk")
local conf = Conf(props["filter-path"], {
["as-section"] = "node.software-dsp.graph",
["no-fragments"] = true
})
local err = conf:open()
if not err then
local args = conf:get_section_as_json("node.software-dsp.graph"):to_string()
filter_nodes[node.id] = LocalModule("libpipewire-module-filter-chain", args, {})
else
log:warning("Unable to load filter graph for " .. node.properties["node.name"])
end
end
if props["hide-parent"] then
log:debug("Setting permissions to '-' on " .. node.properties["node.name"] .. " for open clients")
for client in clients_om:iterate{ type = "client" } do
if not client["properties"]["wireplumber.daemon"] then
client:update_permissions{ [node["bound-id"]] = "-" }
end
end
hidden_nodes[node["bound-id"]] = node.id
end
end
end)
end
}:register()
SimpleEventHook {
name = "node/dsp/free-dsp-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-removed" },
},
},
execute = function(event)
local node = event:get_subject()
if filter_nodes[node.id] then
log:debug("Freeing filter on node " .. node.id)
filter_nodes[node.id] = nil
hidden_nodes[node["bound-id"]] = nil
end
end
}:register()
clients_om:connect("object-added", function (om, client)
for id, _ in pairs(hidden_nodes) do
if not client["properties"]["wireplumber.daemon"] then
client:update_permissions { [id] = "-" }
end
end
end)
clients_om:activate()

View File

@@ -0,0 +1,452 @@
-- WirePlumber
--
-- Copyright © 2021-2022 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
cutils = require ("common-utils")
log = Log.open_topic ("s-node")
config = {}
config.rules = Conf.get_section_as_json ("stream.rules", Json.Array {})
-- the state storage
state = nil
state_table = nil
-- Support for the "System Sounds" volume control in pavucontrol
rs_metadata = nil
-- hook to restore stream properties & target
restore_stream_hook = SimpleEventHook {
name = "node/restore-stream",
interests = {
-- match stream nodes
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Stream/*" },
},
-- and device nodes that are not associated with any routes
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "is-absent" },
},
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "equals", "0" },
},
},
execute = function (event)
local node = event:get_subject ()
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
local key = formKey (stream_props)
if not key then
return
end
local stored_values = getStoredStreamProps (key) or {}
-- restore node Props (volumes, channelMap, etc...)
if Settings.get_boolean ("node.stream.restore-props") and stream_props ["state.restore-props"] ~= "false"
then
local props = {
"Spa:Pod:Object:Param:Props", "Props",
volume = stored_values.volume,
mute = stored_values.mute,
channelVolumes = stored_values.channelVolumes ~= nil and
stored_values.channelVolumes or buildDefaultChannelVolumes (node),
channelMap = stored_values.channelMap,
}
-- 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.volume or (props.mute ~= nil) or props.channelVolumes or props.channelMap
then
log:info (node, "restore values from " .. key)
local param = Pod.Object (props)
log:debug (param, "setting props on " .. tostring (stream_props ["node.name"]))
node:set_param ("Props", param)
end
end
-- restore the node's link target on metadata
if Settings.get_boolean ("node.stream.restore-target") and stream_props ["state.restore-target"] ~= "false"
then
if stored_values.target then
-- check first if there is a defined target in the node's properties
-- and skip restoring if this is the case (#335)
local target_in_props =
stream_props ["target.object"] or stream_props ["node.target"]
if not target_in_props then
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local metadata_om = source:call ("get-object-manager", "metadata")
local target_node = nodes_om:lookup {
Constraint { "node.name", "=", stored_values.target, type = "pw" }
}
local metadata = metadata_om:lookup {
Constraint { "metadata.name", "=", "default" }
}
if target_node and metadata then
metadata:set (node ["bound-id"], "target.object", "Spa:Id",
target_node.properties ["object.serial"])
end
else
log:debug (node,
"Not restoring the target for " ..
tostring (stream_props ["node.name"]) ..
" because it is already set to " .. target_in_props)
end
end
end
end
}
-- store stream properties on the state file
store_stream_props_hook = SimpleEventHook {
name = "node/store-stream-props",
interests = {
-- match stream nodes
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Stream/*" },
},
-- and device nodes that are not associated with any routes
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "is-absent" },
},
EventInterest {
Constraint { "event.type", "=", "node-params-changed" },
Constraint { "event.subject.param-id", "=", "Props" },
Constraint { "media.class", "matches", "Audio/*" },
Constraint { "device.routes", "equals", "0" },
},
},
execute = function (event)
local node = event:get_subject ()
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
if Settings.get_boolean ("node.stream.restore-props") and stream_props ["state.restore-props"] ~= "false"
then
local key = formKey (stream_props)
if not key then
return
end
local stored_values = getStoredStreamProps (key) or {}
local hasChanges = false
log:info (node, "saving stream props for " ..
tostring (stream_props ["node.name"]))
for p in node:iterate_params ("Props") do
local props = cutils.parseParam (p, "Props")
if not props then
goto skip_prop
end
if props.volume ~= stored_values.volume then
stored_values.volume = props.volume
hasChanges = true
end
if props.mute ~= stored_values.mute then
stored_values.mute = props.mute
hasChanges = true
end
if props.channelVolumes then
stored_values.channelVolumes = props.channelVolumes
hasChanges = true
end
if props.channelMap then
stored_values.channelMap = props.channelMap
hasChanges = true
end
::skip_prop::
end
if hasChanges then
saveStreamProps (key, stored_values)
end
end
end
}
-- save "target.node"/"target.object" on metadata changes
store_stream_target_hook = SimpleEventHook {
name = "node/store-stream-target-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "default" },
Constraint { "event.subject.key", "c", "target.object", "target.node" },
},
},
execute = function (event)
local source = event:get_source ()
local nodes_om = source:call ("get-object-manager", "node")
local props = event:get_properties ()
local subject_id = props ["event.subject.id"]
local target_key = props ["event.subject.key"]
local target_value = props ["event.subject.value"]
local node = nodes_om:lookup {
Constraint { "bound-id", "=", subject_id, type = "gobject" }
}
if not node then
return
end
local stream_props = node.properties
stream_props = JsonUtils.match_rules_update_properties (config.rules, stream_props)
if stream_props ["state.restore-target"] == "false" then
return
end
local key = formKey (stream_props)
if not key then
return
end
local target_name = nil
if target_value and target_value ~= "-1" then
local target_node
if target_key == "target.object" then
target_node = nodes_om:lookup {
Constraint { "object.serial", "=", target_value, type = "pw-global" }
}
else
target_node = nodes_om:lookup {
Constraint { "bound-id", "=", target_value, type = "gobject" }
}
end
if target_node then
target_name = target_node.properties ["node.name"]
end
end
log:info (node, "saving stream target for " ..
tostring (stream_props ["node.name"]) .. " -> " .. tostring (target_name))
local stored_values = getStoredStreamProps (key) or {}
stored_values.target = target_name
saveStreamProps (key, stored_values)
end
}
-- populate route-settings metadata
function populateMetadata (metadata)
-- copy state into the metadata
local key = "Output/Audio:media.role:Notification"
local p = getStoredStreamProps (key)
if p then
p.channels = p.channelMap and Json.Array (p.channelMap)
p.volumes = p.channelVolumes and Json.Array (p.channelVolumes)
p.channelMap = nil
p.channelVolumes = nil
p.target = nil
-- pipewire-pulse expects the key to be
-- "restore.stream.Output/Audio.media.role:Notification"
key = string.gsub (key, ":", ".", 1);
metadata:set (0, "restore.stream." .. key, "Spa:String:JSON",
Json.Object (p):to_string ())
end
end
-- track route-settings metadata changes
route_settings_metadata_changed_hook = SimpleEventHook {
name = "node/route-settings-metadata-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "metadata-changed" },
Constraint { "metadata.name", "=", "route-settings" },
Constraint { "event.subject.key", "=",
"restore.stream.Output/Audio.media.role:Notification" },
Constraint { "event.subject.spa_type", "=", "Spa:String:JSON" },
Constraint { "event.subject.value", "is-present" },
},
},
execute = function (event)
local props = event:get_properties ()
local subject_id = props ["event.subject.id"]
local key = props ["event.subject.key"]
local value = props ["event.subject.value"]
local json = Json.Raw (value)
if json == nil or not json:is_object () then
return
end
local vparsed = json:parse ()
-- we store the key as "Output/Audio:media.role:Notification"
local key = string.sub (key, string.len ("restore.stream.") + 1)
key = string.gsub (key, "%.", ":", 1);
local stored_values = getStoredStreamProps (key) or {}
if vparsed.volume ~= nil then
stored_values.volume = vparsed.volume
end
if vparsed.mute ~= nil then
stored_values.mute = vparsed.mute
end
if vparsed.channels ~= nil then
stored_values.channelMap = vparsed.channels
end
if vparsed.volumes ~= nil then
stored_values.channelVolumes = vparsed.volumes
end
saveStreamProps (key, stored_values)
end
}
function buildDefaultChannelVolumes (node)
local node_props = node.properties
local direction = cutils.mediaClassToDirection (node_props ["media.class"] or "")
local def_vol = 1.0
local channels = 2
local res = {}
local str = node.properties["state.default-volume"]
if str ~= nil then
def_vol = tonumber (str)
elseif direction == "input" then
def_vol = Settings.get_float ("node.stream.default-capture-volume")
elseif direction == "output" then
def_vol = Settings.get_float ("node.stream.default-playback-volume")
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
log:info (node, "using default volume: " .. tostring(def_vol) ..
", channels: " .. tostring(channels))
while (#res < channels) do
table.insert(res, def_vol)
end
return res
end
function getStoredStreamProps (key)
local value = state_table [key]
if not value then
return nil
end
local json = Json.Raw (value)
if not json or not json:is_object () then
return nil
end
return json:parse ()
end
function saveStreamProps (key, p)
assert (type (p) == "table")
p.channelMap = p.channelMap and Json.Array (p.channelMap)
p.channelVolumes = p.channelVolumes and Json.Array (p.channelVolumes)
state_table [key] = Json.Object (p):to_string ()
state:save_after_timeout (state_table)
end
function formKey (properties)
local keys = {
"media.role",
"application.id",
"application.name",
"media.name",
"node.name",
}
local key_base = nil
for _, k in ipairs (keys) do
local p = properties [k]
if p then
key_base = string.format ("%s:%s:%s",
properties ["media.class"]:gsub ("^Stream/", ""), k, p)
break
end
end
return key_base
end
function toggleState (enable)
if enable and not state then
state = State ("stream-properties")
state_table = state:load ()
restore_stream_hook:register ()
store_stream_props_hook:register ()
store_stream_target_hook:register ()
route_settings_metadata_changed_hook:register ()
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))
else
populateMetadata (m)
end
end)
elseif not enable and state then
state = nil
state_table = nil
restore_stream_hook:remove ()
store_stream_props_hook:remove ()
store_stream_target_hook:remove ()
route_settings_metadata_changed_hook:remove ()
rs_metadata = nil
end
end
Settings.subscribe ("node.stream.restore-props", function ()
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))
end)
Settings.subscribe ("node.stream.restore-target", function ()
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))
end)
toggleState (Settings.get_boolean ("node.stream.restore-props") or
Settings.get_boolean ("node.stream.restore-target"))

View File

@@ -0,0 +1,65 @@
-- WirePlumber
--
-- Copyright © 2021 Collabora Ltd.
-- @author George Kiagiadakis <george.kiagiadakis@collabora.com>
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic ("s-node")
sources = {}
SimpleEventHook {
name = "node/suspend-node",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "matches", "Audio/*" },
},
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "matches", "Video/*" },
},
},
execute = function (event)
local node = event:get_subject ()
local new_state = event:get_properties ()["event.subject.new-state"]
log:debug (node, "changed state to " .. new_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 new_state == "idle" or new_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
-- but check first if the node still exists
if (node:get_active_features() & Feature.Proxy.BOUND) ~= 0 then
log:info(node, "was idle for a while; suspending ...")
node:send_command("Suspend")
end
-- 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
}:register ()