From ee87161d291ceec8a01b088698fa9b7ab71de8f1 Mon Sep 17 00:00:00 2001 From: George Kiagiadakis Date: Thu, 3 Jun 2021 16:26:28 +0300 Subject: [PATCH] default-routes: re-implement the default-routes module in lua using the logic from the default-routes of pipewire-media-session Fixes: #28, #30 Related: !156 --- src/config/main.lua.d/40-device-defaults.lua | 14 +- src/scripts/default-routes.lua | 447 +++++++++++++++++++ 2 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 src/scripts/default-routes.lua diff --git a/src/config/main.lua.d/40-device-defaults.lua b/src/config/main.lua.d/40-device-defaults.lua index 5a470a46..09d21e38 100644 --- a/src/config/main.lua.d/40-device-defaults.lua +++ b/src/config/main.lua.d/40-device-defaults.lua @@ -1,18 +1,22 @@ device_defaults = {} device_defaults.properties = { - -- store preferences to the file system and restore them at startup + -- store preferences to the file system and restore them at startup; + -- when set to false, default nodes and routes are selected based on + -- their priorities and any runtime changes do not persist after restart ["use-persistent-storage"] = true, } function device_defaults.enable() - -- Enables saving and restoring default nodes + -- Selects appropriate default nodes and enables saving and restoring them load_module("default-nodes", device_defaults.properties) - if device_defaults.properties["use-persistent-storage"] then - -- Automatically save and restore default routes - load_module("default-routes") + -- Selects appropriate default routes ("ports" in pulseaudio terminology) + -- and enables saving and restoring them together with + -- their properties (per-route/port volume levels, channel maps, etc) + load_script("default-routes.lua", device_defaults.properties) + if device_defaults.properties["use-persistent-storage"] then -- Enables functionality to save and restore default device profiles load_module("default-profile") end diff --git a/src/scripts/default-routes.lua b/src/scripts/default-routes.lua new file mode 100644 index 00000000..0e41589c --- /dev/null +++ b/src/scripts/default-routes.lua @@ -0,0 +1,447 @@ +-- WirePlumber +-- +-- Copyright © 2021 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- Based on default-routes.c from pipewire-media-session +-- Copyright © 2020 Wim Taymans +-- +-- SPDX-License-Identifier: MIT + +local config = ... + +-- whether to store state on the file system +use_persistent_storage = config["use-persistent-storage"] or false + +-- table of device info +dev_infos = {} + +-- the state storage +state_name = "default-routes" +state = use_persistent_storage and State(state_name) or nil +state_table = state and state:load(state_name) or {} + +-- simple serializer {"foo", "bar"} -> "foo;bar;" +function serializeArray(a) + local str = "" + for _, v in ipairs(a) do + str = str .. tostring(v) .. ";" + end + return str +end + +-- simple deserializer "foo;bar;" -> {"foo", "bar"} +function parseArray(str, convert_value) + local array = {} + local pos = 1 + while true do + local next = str:find(";", pos, true) + if next then + local val = str:sub(pos, next-1) + val = convert_value and convert_value(val) or val + table.insert(array, val) + pos = next + 1 + else + break + 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 () + state:save(state_name, state_table) + 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 + + storeAfterTimeout() +end + +function restoreRoute(device, dev_info, device_id, route) + -- default props + local props = { + "Spa:Pod:Object:Param:Props", "Route", + channelVolumes = { 0.4 }, + mute = false, + } + + -- 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 + 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 + + -- 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 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) then + if ri.available == "yes" and + (best_avail == nil or ri.priority > best_avail.priority) then + best_avail = ri + elseif ri.available ~= "no" and + (best_unk == nil or ri.priority > best_unk.priority) then + best_unk = ri + 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 {}, + 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 + + -- 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 + avail_routes_changed = true + 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 + + -- now get the profile and restore routes for it + for p in device:iterate_params("Profile") do + local profile = parseParam(p, "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 +end + +om = ObjectManager { + Interest { type = "device" }, +} + +om:connect("objects-changed", function (om) + local new_dev_infos = {} + for device in om:iterate() do + if device.properties["device.name"] then + 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 + new_dev_infos[device["bound-id"]] = dev_info + + device:connect("params-changed", handleDevice) + handleDevice(device) + end + end + end + -- replace list to get rid of dev_info for devices that no longer exist + dev_infos = new_dev_infos +end) + +om:activate()