453 lines
14 KiB
Lua
453 lines
14 KiB
Lua
-- 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"))
|