diff --git a/.config/wireplumber/bluetooth.conf b/.config/wireplumber/bluetooth.conf deleted file mode 100644 index 31392f5..0000000 --- a/.config/wireplumber/bluetooth.conf +++ /dev/null @@ -1,88 +0,0 @@ -# WirePlumber daemon context configuration # - -context.properties = { - ## Properties to configure the PipeWire context and some modules - - application.name = "WirePlumber Bluetooth" - log.level = 2 - wireplumber.script-engine = lua-scripting - wireplumber.export-core = false - - #mem.mlock-all = false - #support.dbus = true -} - -context.spa-libs = { - # = - # - # Used to find spa factory names. It maps an spa factory name - # regular expression to a library name that should contain - # that factory. - # - api.bluez5.* = bluez5/libspa-bluez5 - audio.convert.* = audioconvert/libspa-audioconvert - support.* = support/libspa-support -} - -context.modules = [ - #{ name = - # [ args = { = ... } ] - # [ flags = [ [ ifexists ] [ nofail ] ] - #} - # - # PipeWire modules to load. - # If ifexists is given, the module is ignored when it is not found. - # If nofail is given, module initialization failures are ignored. - # - - # Uses RTKit to boost the data thread priority. - { name = libpipewire-module-rt - args = { - nice.level = -11 - #rt.prio = 88 - #rt.time.soft = -1 - #rt.time.hard = -1 - } - flags = [ ifexists nofail ] - } - - # The native communication protocol. - { name = libpipewire-module-protocol-native } - - # Allows creating nodes that run in the context of the - # client. Is used by all clients that want to provide - # data to PipeWire. - { name = libpipewire-module-client-node } - - # Allows creating devices that run in the context of the - # client. Is used by the session manager. - { name = libpipewire-module-client-device } - - # Makes a factory for wrapping nodes in an adapter with a - # converter and resampler. - { name = libpipewire-module-adapter } - - # Allows applications to create metadata objects. It creates - # a factory for Metadata objects. - { name = libpipewire-module-metadata } - - # Provides factories to make session manager objects. - { name = libpipewire-module-session-manager } - - # Provides factories to make SPA node objects. - { name = libpipewire-module-spa-node-factory } -] - -wireplumber.components = [ - #{ name = , type = } - # - # WirePlumber components to load - # - - # The lua scripting engine - { name = libwireplumber-module-lua-scripting, type = module } - - # The lua configuration file - # Other components are loaded from there - { name = bluetooth.lua, type = config/lua } -] diff --git a/.config/wireplumber/bluetooth.lua.d/00-functions.lua b/.config/wireplumber/bluetooth.lua.d/00-functions.lua deleted file mode 100644 index 4278e6f..0000000 --- a/.config/wireplumber/bluetooth.lua.d/00-functions.lua +++ /dev/null @@ -1,36 +0,0 @@ -components = {} - -function load_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a } - end -end - -function load_optional_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a, optional = true } - end -end - -function load_pw_module(m) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libpipewire-module-" .. m, type = "pw_module" } - end -end - -function load_script(s, a) - if not components[s] then - components[s] = { s, type = "script/lua", args = a } - end -end - -function load_monitor(s, a) - load_script("monitors/" .. s .. ".lua", a) -end - -function load_access(s, a) - load_script("access/access-" .. s .. ".lua", a) -end diff --git a/.config/wireplumber/bluetooth.lua.d/30-bluez-midi-monitor.lua b/.config/wireplumber/bluetooth.lua.d/30-bluez-midi-monitor.lua deleted file mode 100644 index 839ec10..0000000 --- a/.config/wireplumber/bluetooth.lua.d/30-bluez-midi-monitor.lua +++ /dev/null @@ -1,18 +0,0 @@ -bluez_midi_monitor = {} -bluez_midi_monitor.properties = {} -bluez_midi_monitor.rules = {} - -function bluez_midi_monitor.enable() - if bluez_midi_monitor.enabled == false then - return - end - - load_monitor("bluez-midi", { - properties = bluez_midi_monitor.properties, - rules = bluez_midi_monitor.rules, - }) - - if bluez_midi_monitor.properties["with-logind"] then - load_optional_module("logind") - end -end diff --git a/.config/wireplumber/bluetooth.lua.d/30-bluez-monitor.lua b/.config/wireplumber/bluetooth.lua.d/30-bluez-monitor.lua deleted file mode 100644 index a870aa5..0000000 --- a/.config/wireplumber/bluetooth.lua.d/30-bluez-monitor.lua +++ /dev/null @@ -1,18 +0,0 @@ -bluez_monitor = {} -bluez_monitor.properties = {} -bluez_monitor.rules = {} - -function bluez_monitor.enable() - if bluez_monitor.enabled == false then - return - end - - load_monitor("bluez", { - properties = bluez_monitor.properties, - rules = bluez_monitor.rules, - }) - - if bluez_monitor.properties["with-logind"] then - load_optional_module("logind") - end -end diff --git a/.config/wireplumber/bluetooth.lua.d/50-bluez-config.lua b/.config/wireplumber/bluetooth.lua.d/50-bluez-config.lua deleted file mode 100644 index 41ad209..0000000 --- a/.config/wireplumber/bluetooth.lua.d/50-bluez-config.lua +++ /dev/null @@ -1,140 +0,0 @@ -bluez_monitor.enabled = true - -bluez_monitor.properties = { - -- These features do not work on all headsets, so they are enabled - -- by default based on the hardware database. They can also be - -- forced on/off for all devices by the following options: - - -- ["bluez5.enable-sbc-xq"] = true, - -- ["bluez5.enable-msbc"] = true, - -- ["bluez5.enable-hw-volume"] = true, - - -- See bluez-hardware.conf for the hardware database. - - -- Enabled headset roles (default: [ hfp_hf hfp_ag ]), this - -- property only applies to native backend. Currently some headsets - -- (Sony WH-1000XM3) are not working with both hsp_ag and hfp_ag - -- enabled, disable either hsp_ag or hfp_ag to work around it. - -- - -- Supported headset roles: hsp_hs (HSP Headset), - -- hsp_ag (HSP Audio Gateway), - -- hfp_hf (HFP Hands-Free), - -- hfp_ag (HFP Audio Gateway) - --["bluez5.headset-roles"] = "[ hsp_hs hsp_ag hfp_hf hfp_ag ]", - - -- Enabled A2DP codecs (default: all). - --["bluez5.codecs"] = "[ sbc sbc_xq aac ldac aptx aptx_hd aptx_ll aptx_ll_duplex faststream faststream_duplex ]", - - -- HFP/HSP backend (default: native). - -- Available values: any, none, hsphfpd, ofono, native - --["bluez5.hfphsp-backend"] = "native", - - -- HFP/HSP native backend modem (default: none). - -- Available values: none, any or the modem device string as found in - -- 'Device' property of org.freedesktop.ModemManager1.Modem interface - --["bluez5.hfphsp-backend-native-modem"] = "none", - - -- HFP/HSP hardware offload SCO support (default: false). - --["bluez5.hw-offload-sco"] = false, - - -- Properties for the A2DP codec configuration - --["bluez5.default.rate"] = 48000, - --["bluez5.default.channels"] = 2, - - -- Register dummy AVRCP player, required for AVRCP volume function. - -- Disable if you are running mpris-proxy or equivalent. - --["bluez5.dummy-avrcp-player"] = true, - - -- Opus Pro Audio mode settings - --["bluez5.a2dp.opus.pro.channels"] = 3, -- no. channels - --["bluez5.a2dp.opus.pro.coupled-streams"] = 1, -- no. joint stereo pairs, see RFC 7845 Sec. 5.1.1 - --["bluez5.a2dp.opus.pro.locations"] = "FL,FR,LFE", -- audio locations - --["bluez5.a2dp.opus.pro.max-bitrate"] = 600000, - --["bluez5.a2dp.opus.pro.frame-dms"] = 50, -- frame duration in 1/10 ms: 25, 50, 100, 200, 400 - --["bluez5.a2dp.opus.pro.bidi.channels"] = 1, -- same settings for the return direction - --["bluez5.a2dp.opus.pro.bidi.coupled-streams"] = 0, - --["bluez5.a2dp.opus.pro.bidi.locations"] = "FC", - --["bluez5.a2dp.opus.pro.bidi.max-bitrate"] = 160000, - --["bluez5.a2dp.opus.pro.bidi.frame-dms"] = 400, - - -- Enable the logind module, which arbitrates which user will be allowed - -- to have bluetooth audio enabled at any given time (particularly useful - -- if you are using GDM as a display manager, as the gdm user also launches - -- pipewire and wireplumber). - -- This requires access to the D-Bus user session; disable if you are running - -- a system-wide instance of wireplumber. - ["with-logind"] = false, -} - -bluez_monitor.rules = { - -- An array of matches/actions to evaluate. - { - -- Rules for matching a device or node. It is an array of - -- properties that all need to match the regexp. If any of the - -- matches work, the actions are executed for the object. - matches = { - { - -- This matches all cards. - { "device.name", "matches", "bluez_card.*" }, - }, - }, - -- Apply properties on the matched object. - apply_properties = { - -- Auto-connect device profiles on start up or when only partial - -- profiles have connected. Disabled by default if the property - -- is not specified. - --["bluez5.auto-connect"] = "[ hfp_hf hsp_hs a2dp_sink hfp_ag hsp_ag a2dp_source ]", - ["bluez5.auto-connect"] = "[ hfp_hf hsp_hs a2dp_sink ]", - - -- Hardware volume control (default: [ hfp_ag hsp_ag a2dp_source ]) - --["bluez5.hw-volume"] = "[ hfp_hf hsp_hs a2dp_sink hfp_ag hsp_ag a2dp_source ]", - - -- LDAC encoding quality - -- Available values: auto (Adaptive Bitrate, default) - -- hq (High Quality, 990/909kbps) - -- sq (Standard Quality, 660/606kbps) - -- mq (Mobile use Quality, 330/303kbps) - --["bluez5.a2dp.ldac.quality"] = "auto", - - -- AAC variable bitrate mode - -- Available values: 0 (cbr, default), 1-5 (quality level) - --["bluez5.a2dp.aac.bitratemode"] = 0, - - -- Profile connected first - -- Available values: a2dp-sink (default), headset-head-unit - --["device.profile"] = "a2dp-sink", - - -- Opus Pro Audio encoding mode: audio, voip, lowdelay - --["bluez5.a2dp.opus.pro.application"] = "audio", - --["bluez5.a2dp.opus.pro.bidi.application"] = "audio", - }, - }, - { - matches = { - { - -- Matches all sources. - { "node.name", "matches", "bluez_input.*" }, - }, - { - -- Matches all sinks. - { "node.name", "matches", "bluez_output.*" }, - }, - }, - apply_properties = { - --["node.nick"] = "My Node", - --["priority.driver"] = 100, - --["priority.session"] = 100, - --["node.pause-on-idle"] = false, - --["resample.quality"] = 4, - --["channelmix.normalize"] = false, - --["channelmix.mix-lfe"] = false, - --["session.suspend-timeout-seconds"] = 5, -- 0 disables suspend - --["monitor.channel-volumes"] = false, - - -- Media source role, "input" or "playback" - -- Defaults to "playback", playing stream to speakers - -- Set to "input" to use as an input for apps - --["bluez5.media-source-role"] = "input", - }, - }, -} diff --git a/.config/wireplumber/bluetooth.lua.d/50-bluez-midi-config.lua b/.config/wireplumber/bluetooth.lua.d/50-bluez-midi-config.lua deleted file mode 100644 index 880e15b..0000000 --- a/.config/wireplumber/bluetooth.lua.d/50-bluez-midi-config.lua +++ /dev/null @@ -1,42 +0,0 @@ --- BLE MIDI is currently disabled by default, because it conflicts with --- the SELinux policy on Fedora 37 and potentially other systems using --- SELinux. For a workaround, see --- https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/spa/plugins/bluez5/README-MIDI.md -bluez_midi_monitor.enabled = false - -bluez_midi_monitor.properties = { - -- Enable the logind module, which arbitrates which user will be allowed - -- to have bluetooth audio enabled at any given time (particularly useful - -- if you are using GDM as a display manager, as the gdm user also launches - -- pipewire and wireplumber). - -- This requires access to the D-Bus user session; disable if you are running - -- a system-wide instance of wireplumber. - ["with-logind"] = true, - - -- List of MIDI server node names. Each node name given will create a new instance - -- of a BLE MIDI service. Typical BLE MIDI instruments have on service instance, - -- so adding more than one here may confuse some clients. The node property matching - -- rules below apply also to these servers. - --["servers"] = { "bluez_midi.server" }, -} - -bluez_midi_monitor.rules = { - -- An array of matches/actions to evaluate. - { - matches = { - { - -- Matches all nodes. - { "node.name", "matches", "bluez_midi.*" }, - }, - }, - apply_properties = { - --["node.nick"] = "My Node", - --["priority.driver"] = 100, - --["priority.session"] = 100, - --["node.pause-on-idle"] = false, - --["session.suspend-timeout-seconds"] = 5, -- 0 disables suspend - --["monitor.channel-volumes"] = false, - --["node.latency-offset-msec"] = -10, -- delay (<0) input to reduce jitter - }, - }, -} diff --git a/.config/wireplumber/bluetooth.lua.d/90-enable-all.lua b/.config/wireplumber/bluetooth.lua.d/90-enable-all.lua deleted file mode 100644 index efa6bf5..0000000 --- a/.config/wireplumber/bluetooth.lua.d/90-enable-all.lua +++ /dev/null @@ -1,2 +0,0 @@ -bluez_monitor.enable() -bluez_midi_monitor.enable() diff --git a/.config/wireplumber/common/00-functions.lua b/.config/wireplumber/common/00-functions.lua deleted file mode 100644 index 4278e6f..0000000 --- a/.config/wireplumber/common/00-functions.lua +++ /dev/null @@ -1,36 +0,0 @@ -components = {} - -function load_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a } - end -end - -function load_optional_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a, optional = true } - end -end - -function load_pw_module(m) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libpipewire-module-" .. m, type = "pw_module" } - end -end - -function load_script(s, a) - if not components[s] then - components[s] = { s, type = "script/lua", args = a } - end -end - -function load_monitor(s, a) - load_script("monitors/" .. s .. ".lua", a) -end - -function load_access(s, a) - load_script("access/access-" .. s .. ".lua", a) -end diff --git a/.config/wireplumber/main.conf b/.config/wireplumber/main.conf deleted file mode 100644 index e822f02..0000000 --- a/.config/wireplumber/main.conf +++ /dev/null @@ -1,74 +0,0 @@ -# WirePlumber daemon context configuration # - -context.properties = { - ## Properties to configure the PipeWire context and some modules - - #application.name = WirePlumber - log.level = 2 - wireplumber.script-engine = lua-scripting - - #mem.mlock-all = false - #support.dbus = true -} - -context.spa-libs = { - # = - # - # Used to find spa factory names. It maps an spa factory name - # regular expression to a library name that should contain - # that factory. - # - api.alsa.* = alsa/libspa-alsa - api.v4l2.* = v4l2/libspa-v4l2 - audio.convert.* = audioconvert/libspa-audioconvert - support.* = support/libspa-support -} - -context.modules = [ - #{ name = - # [ args = { = ... } ] - # [ flags = [ [ ifexists ] [ nofail ] ] - #} - # - # PipeWire modules to load. - # If ifexists is given, the module is ignored when it is not found. - # If nofail is given, module initialization failures are ignored. - # - - # The native communication protocol. - { name = libpipewire-module-protocol-native } - - # Allows creating nodes that run in the context of the - # client. Is used by all clients that want to provide - # data to PipeWire. - { name = libpipewire-module-client-node } - - # Allows creating devices that run in the context of the - # client. Is used by the session manager. - { name = libpipewire-module-client-device } - - # Makes a factory for wrapping nodes in an adapter with a - # converter and resampler. - { name = libpipewire-module-adapter } - - # Allows applications to create metadata objects. It creates - # a factory for Metadata objects. - { name = libpipewire-module-metadata } - - # Provides factories to make session manager objects. - { name = libpipewire-module-session-manager } -] - -wireplumber.components = [ - #{ name = , type = } - # - # WirePlumber components to load - # - - # The lua scripting engine - { name = libwireplumber-module-lua-scripting, type = module } - - # The lua configuration file - # Other components are loaded from there - { name = main.lua, type = config/lua } -] diff --git a/.config/wireplumber/main.lua.d/00-functions.lua b/.config/wireplumber/main.lua.d/00-functions.lua deleted file mode 100644 index 4278e6f..0000000 --- a/.config/wireplumber/main.lua.d/00-functions.lua +++ /dev/null @@ -1,36 +0,0 @@ -components = {} - -function load_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a } - end -end - -function load_optional_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a, optional = true } - end -end - -function load_pw_module(m) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libpipewire-module-" .. m, type = "pw_module" } - end -end - -function load_script(s, a) - if not components[s] then - components[s] = { s, type = "script/lua", args = a } - end -end - -function load_monitor(s, a) - load_script("monitors/" .. s .. ".lua", a) -end - -function load_access(s, a) - load_script("access/access-" .. s .. ".lua", a) -end diff --git a/.config/wireplumber/main.lua.d/20-default-access.lua b/.config/wireplumber/main.lua.d/20-default-access.lua deleted file mode 100644 index 0a7eb95..0000000 --- a/.config/wireplumber/main.lua.d/20-default-access.lua +++ /dev/null @@ -1,19 +0,0 @@ -default_access = {} -default_access.properties = {} -default_access.rules = {} - -function default_access.enable() - if default_access.enabled == false then - return - end - - load_access("default", { - rules = default_access.rules - }) - - if default_access.properties["enable-flatpak-portal"] then - -- Enables portal permissions via org.freedesktop.impl.portal.PermissionStore - load_module("portal-permissionstore") - load_access("portal") - end -end diff --git a/.config/wireplumber/main.lua.d/30-alsa-monitor.lua b/.config/wireplumber/main.lua.d/30-alsa-monitor.lua deleted file mode 100644 index 8e45e43..0000000 --- a/.config/wireplumber/main.lua.d/30-alsa-monitor.lua +++ /dev/null @@ -1,29 +0,0 @@ -alsa_monitor = {} -alsa_monitor.properties = {} -alsa_monitor.rules = {} - -function alsa_monitor.enable() - if alsa_monitor.enabled == false then - return - end - - -- The "reserve-device" module needs to be loaded for reservation to work - if alsa_monitor.properties["alsa.reserve"] then - load_module("reserve-device") - end - - load_monitor("alsa", { - properties = alsa_monitor.properties, - rules = alsa_monitor.rules, - }) - - if alsa_monitor.properties["alsa.midi"] then - load_monitor("alsa-midi", { - properties = alsa_monitor.properties, - }) - -- The "file-monitor-api" module needs to be loaded for MIDI device monitoring - if alsa_monitor.properties["alsa.midi.monitoring"] then - load_module("file-monitor-api") - end - end -end diff --git a/.config/wireplumber/main.lua.d/30-libcamera-monitor.lua b/.config/wireplumber/main.lua.d/30-libcamera-monitor.lua deleted file mode 100644 index cd820a8..0000000 --- a/.config/wireplumber/main.lua.d/30-libcamera-monitor.lua +++ /dev/null @@ -1,14 +0,0 @@ -libcamera_monitor = {} -libcamera_monitor.properties = {} -libcamera_monitor.rules = {} - -function libcamera_monitor.enable() - if libcamera_monitor.enabled == false then - return - end - - load_monitor("libcamera", { - properties = libcamera_monitor.properties, - rules = libcamera_monitor.rules, - }) -end diff --git a/.config/wireplumber/main.lua.d/30-v4l2-monitor.lua b/.config/wireplumber/main.lua.d/30-v4l2-monitor.lua deleted file mode 100644 index 3fbdc9e..0000000 --- a/.config/wireplumber/main.lua.d/30-v4l2-monitor.lua +++ /dev/null @@ -1,14 +0,0 @@ -v4l2_monitor = {} -v4l2_monitor.properties = {} -v4l2_monitor.rules = {} - -function v4l2_monitor.enable() - if v4l2_monitor.enabled == false then - return - end - - load_monitor("v4l2", { - properties = v4l2_monitor.properties, - rules = v4l2_monitor.rules, - }) -end diff --git a/.config/wireplumber/main.lua.d/40-device-defaults.lua b/.config/wireplumber/main.lua.d/40-device-defaults.lua deleted file mode 100644 index 54e6b93..0000000 --- a/.config/wireplumber/main.lua.d/40-device-defaults.lua +++ /dev/null @@ -1,63 +0,0 @@ -device_defaults = {} -device_defaults.enabled = true - -device_defaults.properties = { - -- 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, - - -- the default volumes to apply to ACP device nodes, in the linear scale - --["default-volume"] = 0.064, - --["default-input-volume"] = 1.0, - - -- Whether to auto-switch to echo cancel sink and source nodes or not - ["auto-echo-cancel"] = true, - - -- Sets the default echo-cancel-sink node name to automatically switch to - ["echo-cancel-sink-name"] = "echo-cancel-sink", - - -- Sets the default echo-cancel-source node name to automatically switch to - ["echo-cancel-source-name"] = "echo-cancel-source", -} - --- Sets persistent device profiles that should never change when wireplumber is --- running, even if a new profile with higher priority becomes available -device_defaults.persistent_profiles = { - { - matches = { - { - -- Matches all devices - { "device.name", "matches", "*" }, - }, - }, - profile_names = { - "off", - "pro-audio" - } - }, -} - -function device_defaults.enable() - if device_defaults.enabled == false then - return - end - - -- Selects appropriate default nodes and enables saving and restoring them - load_module("default-nodes", device_defaults.properties) - - -- Selects appropriate profile for devices - load_script("policy-device-profile.lua", { - persistent = device_defaults.persistent_profiles - }) - - -- Selects appropriate device 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("policy-device-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 -end diff --git a/.config/wireplumber/main.lua.d/40-stream-defaults.lua b/.config/wireplumber/main.lua.d/40-stream-defaults.lua deleted file mode 100644 index b869099..0000000 --- a/.config/wireplumber/main.lua.d/40-stream-defaults.lua +++ /dev/null @@ -1,42 +0,0 @@ -stream_defaults = {} -stream_defaults.enabled = true - -stream_defaults.properties = { - -- whether to restore the last stream properties or not - ["restore-props"] = true, - - -- whether to restore the last stream target or not - ["restore-target"] = true, - - -- the default channel volume for new streams whose props were never saved - -- previously. This is only used if "restore-props" is set to true. - ["default-channel-volume"] = 1.0, -} - -stream_defaults.rules = { - -- Rules to override settings per node - -- { - -- matches = { - -- { - -- { "application.name", "matches", "pw-play" }, - -- }, - -- }, - -- apply_properties = { - -- ["state.restore-props"] = false, - -- ["state.restore-target"] = false, - -- ["state.default-channel-volume"] = 0.5, - -- }, - -- }, -} - -function stream_defaults.enable() - if stream_defaults.enabled == false then - return - end - - -- Save and restore stream-specific properties - load_script("restore-stream.lua", { - properties = stream_defaults.properties, - rules = stream_defaults.rules, - }) -end diff --git a/.config/wireplumber/main.lua.d/50-alsa-config.lua b/.config/wireplumber/main.lua.d/50-alsa-config.lua deleted file mode 100644 index 61a0aef..0000000 --- a/.config/wireplumber/main.lua.d/50-alsa-config.lua +++ /dev/null @@ -1,155 +0,0 @@ -alsa_monitor.enabled = true - -alsa_monitor.properties = { - -- Create a JACK device. This is not enabled by default because - -- it requires that the PipeWire JACK replacement libraries are - -- not used by the session manager, in order to be able to - -- connect to the real JACK server. - --["alsa.jack-device"] = false, - - -- Reserve devices via org.freedesktop.ReserveDevice1 on D-Bus - -- Disable if you are running a system-wide instance, which - -- doesn't have access to the D-Bus user session - ["alsa.reserve"] = true, - --["alsa.reserve.priority"] = -20, - --["alsa.reserve.application-name"] = "WirePlumber", - - -- Enables MIDI functionality - ["alsa.midi"] = true, - - -- Enables monitoring of alsa MIDI devices - ["alsa.midi.monitoring"] = true, - - -- MIDI bridge node properties - ["alsa.midi.node-properties"] = { - -- Name set for the node with ALSA MIDI ports - ["node.name"] = "Midi-Bridge", - -- Removes longname/number from MIDI port names - --["api.alsa.disable-longname"] = true, - }, - - -- These properties override node defaults when running in a virtual machine. - -- The rules below still override those. - ["vm.node.defaults"] = { - ["api.alsa.period-size"] = 256, - ["api.alsa.headroom"] = 8192, - }, -} - -alsa_monitor.rules = { - -- An array of matches/actions to evaluate. - -- - -- If you want to disable some devices or nodes, you can apply properties per device as the following example. - -- The name can be found by running pw-cli ls Device, or pw-cli dump Device - --{ - -- matches = { - -- { - -- { "device.name", "matches", "name_of_some_disabled_card" }, - -- }, - -- }, - -- apply_properties = { - -- ["device.disabled"] = true, - -- }, - --} - { - -- Rules for matching a device or node. It is an array of - -- properties that all need to match the regexp. If any of the - -- matches work, the actions are executed for the object. - matches = { - { - -- This matches all cards. - { "device.name", "matches", "alsa_card.*" }, - }, - }, - -- Apply properties on the matched object. - apply_properties = { - -- Use ALSA-Card-Profile devices. They use UCM or the profile - -- configuration to configure the device and mixer settings. - ["api.alsa.use-acp"] = true, - - -- Use UCM instead of profile when available. Can be - -- disabled to skip trying to use the UCM profile. - --["api.alsa.use-ucm"] = true, - - -- Don't use the hardware mixer for volume control. It - -- will only use software volume. The mixer is still used - -- to mute unused paths based on the selected port. - --["api.alsa.soft-mixer"] = false, - - -- Ignore decibel settings of the driver. Can be used to - -- work around buggy drivers that report wrong values. - --["api.alsa.ignore-dB"] = false, - - -- The profile set to use for the device. Usually this is - -- "default.conf" but can be changed with a udev rule or here. - --["device.profile-set"] = "profileset-name", - - -- The default active profile. Is by default set to "Off". - --["device.profile"] = "default profile name", - - -- Automatically select the best profile. This is the - -- highest priority available profile. This is disabled - -- here and instead implemented in the session manager - -- where it can save and load previous preferences. - ["api.acp.auto-profile"] = false, - - -- Automatically switch to the highest priority available port. - -- This is disabled here and implemented in the session manager instead. - ["api.acp.auto-port"] = false, - - -- Other properties can be set here. - --["device.nick"] = "My Device", - }, - }, - { - matches = { - { - -- Matches all sources. - { "node.name", "matches", "alsa_input.*" }, - }, - { - -- Matches all sinks. - { "node.name", "matches", "alsa_output.*" }, - }, - }, - apply_properties = { - --["node.nick"] = "My Node", - --["node.description"] = "My Node Description", - --["priority.driver"] = 100, - --["priority.session"] = 100, - --["node.pause-on-idle"] = false, - --["monitor.channel-volumes"] = false - --["resample.quality"] = 4, - --["resample.disable"] = false, - --["channelmix.normalize"] = false, - --["channelmix.mix-lfe"] = false, - --["channelmix.upmix"] = true, - --["channelmix.upmix-method"] = "psd", -- "none" or "simple" - --["channelmix.lfe-cutoff"] = 150, - --["channelmix.fc-cutoff"] = 12000, - --["channelmix.rear-delay"] = 12.0, - --["channelmix.stereo-widen"] = 0.0, - --["channelmix.hilbert-taps"] = 0, - --["channelmix.disable"] = false, - --["dither.noise"] = 0, - --["dither.method"] = "none", -- "rectangular", "triangular" or "shaped5" - --["audio.channels"] = 2, - --["audio.format"] = "S16LE", - --["audio.rate"] = 44100, - --["audio.allowed-rates"] = "32000,96000", - --["audio.position"] = "FL,FR", - --["api.alsa.period-size"] = 1024, - --["api.alsa.period-num"] = 2, - --["api.alsa.headroom"] = 0, - --["api.alsa.start-delay"] = 0, - --["api.alsa.disable-mmap"] = false, - --["api.alsa.disable-batch"] = false, - --["api.alsa.use-chmap"] = false, - --["api.alsa.multirate"] = true, - --["latency.internal.rate"] = 0 - --["latency.internal.ns"] = 0 - --["clock.name"] = "api.alsa.0" - --["session.suspend-timeout-seconds"] = 5, -- 0 disables suspend - }, - }, -} diff --git a/.config/wireplumber/main.lua.d/50-default-access-config.lua b/.config/wireplumber/main.lua.d/50-default-access-config.lua deleted file mode 100644 index 4ad3d57..0000000 --- a/.config/wireplumber/main.lua.d/50-default-access-config.lua +++ /dev/null @@ -1,36 +0,0 @@ -default_access.enabled = true - -default_access.properties = { - -- Enable the use of the flatpak portal integration. - -- Disable if you are running a system-wide instance, which - -- doesn't have access to the D-Bus user session - ["enable-flatpak-portal"] = true, -} - -default_access.rules = { - { - matches = { - { - { "pipewire.access", "=", "flatpak" }, - { "media.category", "=", "Manager" }, - }, - }, - default_permissions = "all", - }, - { - matches = { - { - { "pipewire.access", "=", "flatpak" }, - }, - }, - default_permissions = "rx", - }, - { - matches = { - { - { "pipewire.access", "=", "restricted" }, - }, - }, - default_permissions = "rx", - }, -} diff --git a/.config/wireplumber/main.lua.d/50-libcamera-config.lua b/.config/wireplumber/main.lua.d/50-libcamera-config.lua deleted file mode 100644 index d63fed1..0000000 --- a/.config/wireplumber/main.lua.d/50-libcamera-config.lua +++ /dev/null @@ -1,38 +0,0 @@ -libcamera_monitor.enabled = true - -libcamera_monitor.rules = { - -- An array of matches/actions to evaluate. - { - -- Rules for matching a device or node. It is an array of - -- properties that all need to match the regexp. If any of the - -- matches work, the actions are executed for the object. - matches = { - { - -- This matches all cards. - { "device.name", "matches", "libcamera_device.*" }, - }, - }, - -- Apply properties on the matched object. - apply_properties = { - -- ["device.nick"] = "My Device", - }, - }, - { - matches = { - { - -- Matches all sources. - { "node.name", "matches", "libcamera_input.*" }, - }, - { - -- Matches all sinks. - { "node.name", "matches", "libcamera_output.*" }, - }, - }, - apply_properties = { - --["node.nick"] = "My Node", - --["priority.driver"] = 100, - --["priority.session"] = 100, - --["node.pause-on-idle"] = false, - }, - }, -} diff --git a/.config/wireplumber/main.lua.d/50-v4l2-config.lua b/.config/wireplumber/main.lua.d/50-v4l2-config.lua deleted file mode 100644 index 36e9f42..0000000 --- a/.config/wireplumber/main.lua.d/50-v4l2-config.lua +++ /dev/null @@ -1,38 +0,0 @@ -v4l2_monitor.enabled = true - -v4l2_monitor.rules = { - -- An array of matches/actions to evaluate. - { - -- Rules for matching a device or node. It is an array of - -- properties that all need to match the regexp. If any of the - -- matches work, the actions are executed for the object. - matches = { - { - -- This matches all cards. - { "device.name", "matches", "v4l2_device.*" }, - }, - }, - -- Apply properties on the matched object. - apply_properties = { - -- ["device.nick"] = "My Device", - }, - }, - { - matches = { - { - -- Matches all sources. - { "node.name", "matches", "v4l2_input.*" }, - }, - { - -- Matches all sinks. - { "node.name", "matches", "v4l2_output.*" }, - }, - }, - apply_properties = { - --["node.nick"] = "My Node", - --["priority.driver"] = 100, - --["priority.session"] = 100, - --["node.pause-on-idle"] = false, - }, - }, -} diff --git a/.config/wireplumber/main.lua.d/90-enable-all.lua b/.config/wireplumber/main.lua.d/90-enable-all.lua deleted file mode 100644 index 1aa194d..0000000 --- a/.config/wireplumber/main.lua.d/90-enable-all.lua +++ /dev/null @@ -1,23 +0,0 @@ --- Provide the "default" pw_metadata, which stores --- dynamic properties of pipewire objects in RAM -load_module("metadata") - --- Default client access policy -default_access.enable() - --- Load devices -alsa_monitor.enable() -v4l2_monitor.enable() -libcamera_monitor.enable() - --- Track/store/restore user choices about devices -device_defaults.enable() - --- Track/store/restore user choices about streams -stream_defaults.enable() - --- Link nodes by stream role and device intended role -load_script("intended-roles.lua") - --- Automatically suspends idle nodes after 3 seconds -load_script("suspend-node.lua") diff --git a/.config/wireplumber/policy.conf b/.config/wireplumber/policy.conf deleted file mode 100644 index 42f7148..0000000 --- a/.config/wireplumber/policy.conf +++ /dev/null @@ -1,73 +0,0 @@ -# WirePlumber daemon context configuration # - -context.properties = { - ## Properties to configure the PipeWire context and some modules - - application.name = "WirePlumber Policy" - log.level = 2 - wireplumber.script-engine = lua-scripting - wireplumber.export-core = false - - #mem.mlock-all = false - #support.dbus = true -} - -context.spa-libs = { - # = - # - # Used to find spa factory names. It maps an spa factory name - # regular expression to a library name that should contain - # that factory. - # - audio.convert.* = audioconvert/libspa-audioconvert - support.* = support/libspa-support -} - -context.modules = [ - #{ name = - # [ args = { = ... } ] - # [ flags = [ [ ifexists ] [ nofail ] ] - #} - # - # PipeWire modules to load. - # If ifexists is given, the module is ignored when it is not found. - # If nofail is given, module initialization failures are ignored. - # - - # The native communication protocol. - { name = libpipewire-module-protocol-native } - - # Allows creating nodes that run in the context of the - # client. Is used by all clients that want to provide - # data to PipeWire. - { name = libpipewire-module-client-node } - - # Allows creating devices that run in the context of the - # client. Is used by the session manager. - { name = libpipewire-module-client-device } - - # Makes a factory for wrapping nodes in an adapter with a - # converter and resampler. - { name = libpipewire-module-adapter } - - # Allows applications to create metadata objects. It creates - # a factory for Metadata objects. - { name = libpipewire-module-metadata } - - # Provides factories to make session manager objects. - { name = libpipewire-module-session-manager } -] - -wireplumber.components = [ - #{ name = , type = } - # - # WirePlumber components to load - # - - # The lua scripting engine - { name = libwireplumber-module-lua-scripting, type = module } - - # The lua configuration file - # Other components are loaded from there - { name = policy.lua, type = config/lua } -] diff --git a/.config/wireplumber/policy.lua.d/00-functions.lua b/.config/wireplumber/policy.lua.d/00-functions.lua deleted file mode 100644 index 4278e6f..0000000 --- a/.config/wireplumber/policy.lua.d/00-functions.lua +++ /dev/null @@ -1,36 +0,0 @@ -components = {} - -function load_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a } - end -end - -function load_optional_module(m, a) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libwireplumber-module-" .. m, type = "module", args = a, optional = true } - end -end - -function load_pw_module(m) - assert(type(m) == "string", "module name is mandatory, bail out"); - if not components[m] then - components[m] = { "libpipewire-module-" .. m, type = "pw_module" } - end -end - -function load_script(s, a) - if not components[s] then - components[s] = { s, type = "script/lua", args = a } - end -end - -function load_monitor(s, a) - load_script("monitors/" .. s .. ".lua", a) -end - -function load_access(s, a) - load_script("access/access-" .. s .. ".lua", a) -end diff --git a/.config/wireplumber/policy.lua.d/10-default-policy.lua b/.config/wireplumber/policy.lua.d/10-default-policy.lua deleted file mode 100644 index 1b11e08..0000000 --- a/.config/wireplumber/policy.lua.d/10-default-policy.lua +++ /dev/null @@ -1,81 +0,0 @@ -default_policy = {} -default_policy.enabled = true -default_policy.properties = {} -default_policy.endpoints = {} - -default_policy.policy = { - ["move"] = true, -- moves session items when metadata target.node changes - ["follow"] = true, -- moves session items to the default device when it has changed - - -- Whether to forward the ports format of filter stream nodes to their - -- associated filter device nodes. This is needed for application to stream - -- surround audio if echo-cancel is enabled. - ["filter.forward-format"] = false, - - -- Set to 'true' to disable channel splitting & merging on nodes and enable - -- passthrough of audio in the same format as the format of the device. - -- Note that this breaks JACK support; it is generally not recommended - ["audio.no-dsp"] = false, - - -- how much to lower the volume of lower priority streams when ducking - -- note that this is a linear volume modifier (not cubic as in pulseaudio) - ["duck.level"] = 0.3, -} - -bluetooth_policy = {} - -bluetooth_policy.policy = { - -- Whether to store state on the filesystem. - ["use-persistent-storage"] = true, - - -- Whether to use headset profile in the presence of an input stream. - ["media-role.use-headset-profile"] = true, - - -- Application names correspond to application.name in stream properties. - -- Applications which do not set media.role but which should be considered - -- for role based profile switching can be specified here. - ["media-role.applications"] = { - "Firefox", "Chromium input", "Google Chrome input", "Brave input", - "Microsoft Edge input", "Vivaldi input", "ZOOM VoiceEngine", - "Telegram Desktop", "telegram-desktop", "linphone", "Mumble", - "WEBRTC VoiceEngine", "Skype", "Firefox Developer Edition", - }, -} - -function default_policy.enable() - if default_policy.enabled == false then - return - end - - -- Session item factories, building blocks for the session management graph - -- Do not disable these unless you really know what you are doing - load_module("si-node") - load_module("si-audio-adapter") - load_module("si-standard-link") - load_module("si-audio-endpoint") - - -- API to access default nodes from scripts - load_module("default-nodes-api") - - -- API to access mixer controls, needed for volume ducking - load_module("mixer-api") - - -- Create endpoints statically at startup - load_script("static-endpoints.lua", default_policy.endpoints) - - -- Create items for nodes that appear in the graph - load_script("create-item.lua", default_policy.policy) - - -- Link nodes to each other to make media flow in the graph - load_script("policy-node.lua", default_policy.policy) - - -- Link client nodes with endpoints to make media flow in the graph - load_script("policy-endpoint-client.lua", default_policy.policy) - load_script("policy-endpoint-client-links.lua", default_policy.policy) - - -- Link endpoints with device nodes to make media flow in the graph - load_script("policy-endpoint-device.lua", default_policy.policy) - - -- Switch bluetooth profile based on media.role - load_script("policy-bluetooth.lua", bluetooth_policy.policy) -end diff --git a/.config/wireplumber/policy.lua.d/50-endpoints-config.lua b/.config/wireplumber/policy.lua.d/50-endpoints-config.lua deleted file mode 100644 index 2865694..0000000 --- a/.config/wireplumber/policy.lua.d/50-endpoints-config.lua +++ /dev/null @@ -1,95 +0,0 @@ --- uncomment to enable role-based endpoints --- this is not yet ready for desktop use --- ---[[ - -default_policy.policy.roles = { - ["Capture"] = { - ["alias"] = { "Multimedia", "Music", "Voice", "Capture" }, - ["priority"] = 25, - ["action.default"] = "cork", - ["action.capture"] = "mix", - ["media.class"] = "Audio/Source", - }, - ["Multimedia"] = { - ["alias"] = { "Movie", "Music", "Game" }, - ["priority"] = 25, - ["action.default"] = "cork", - }, - ["Speech-Low"] = { - ["priority"] = 30, - ["action.default"] = "cork", - ["action.Speech-Low"] = "mix", - }, - ["Custom-Low"] = { - ["priority"] = 35, - ["action.default"] = "cork", - ["action.Custom-Low"] = "mix", - }, - ["Navigation"] = { - ["priority"] = 50, - ["action.default"] = "duck", - ["action.Navigation"] = "mix", - }, - ["Speech-High"] = { - ["priority"] = 60, - ["action.default"] = "cork", - ["action.Speech-High"] = "mix", - }, - ["Custom-High"] = { - ["priority"] = 65, - ["action.default"] = "cork", - ["action.Custom-High"] = "mix", - }, - ["Communication"] = { - ["priority"] = 75, - ["action.default"] = "cork", - ["action.Communication"] = "mix", - }, - ["Emergency"] = { - ["alias"] = { "Alert" }, - ["priority"] = 99, - ["action.default"] = "cork", - ["action.Emergency"] = "mix", - }, -} - -default_policy.endpoints = { - ["endpoint.capture"] = { - ["media.class"] = "Audio/Source", - ["role"] = "Capture", - }, - ["endpoint.multimedia"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Multimedia", - }, - ["endpoint.speech_low"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Speech-Low", - }, - ["endpoint.custom_low"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Custom-Low", - }, - ["endpoint.navigation"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Navigation", - }, - ["endpoint.speech_high"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Speech-High", - }, - ["endpoint.custom_high"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Custom-High", - }, - ["endpoint.communication"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Communication", - }, - ["endpoint.emergency"] = { - ["media.class"] = "Audio/Sink", - ["role"] = "Emergency", - }, -} -]]-- diff --git a/.config/wireplumber/policy.lua.d/90-enable-all.lua b/.config/wireplumber/policy.lua.d/90-enable-all.lua deleted file mode 100644 index c20e23a..0000000 --- a/.config/wireplumber/policy.lua.d/90-enable-all.lua +++ /dev/null @@ -1 +0,0 @@ -default_policy.enable() diff --git a/.config/wireplumber/scripts/access/access-default.lua b/.config/wireplumber/scripts/access/access-default.lua deleted file mode 100644 index 0fac87b..0000000 --- a/.config/wireplumber/scripts/access/access-default.lua +++ /dev/null @@ -1,53 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author George Kiagiadakis --- --- 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() diff --git a/.config/wireplumber/scripts/client/access-default.lua b/.config/wireplumber/scripts/client/access-default.lua new file mode 100644 index 0000000..70b3a2b --- /dev/null +++ b/.config/wireplumber/scripts/client/access-default.lua @@ -0,0 +1,89 @@ +-- WirePlumber +-- +-- Copyright © 2021 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-client") + +config = {} +config.rules = Conf.get_section_as_json ("access.rules") + +function getAccess (properties) + local access = properties["pipewire.access"] + local client_access = properties["pipewire.client.access"] + local is_flatpak = properties["pipewire.sec.flatpak"] + + if is_flatpak then + client_access = "flatpak" + end + + if client_access == nil then + return access + elseif access == "unrestricted" or access == "default" then + if client_access ~= "unrestricted" then + return client_access + end + end + + return access +end + +function getDefaultPermissions (properties) + local access = properties["access"] + local media_category = properties["media.category"] + + if access == "flatpak" and media_category == "Manager" then + return "all", "flatpak-manager" + elseif access == "flatpak" or access == "restricted" then + return "rx", access + elseif access == "default" then + return "all", access + end + + return nil, nil +end + +function getPermissions (properties) + if config.rules then + local mprops, matched = JsonUtils.match_rules_update_properties ( + config.rules, properties) + if (matched > 0 and mprops["default_permissions"]) then + return mprops["default_permissions"], mprops["access"] + end + end + + return nil, nil +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 access = getAccess (properties) + + properties["access"] = access + + local perms, effective_access = getPermissions (properties) + if perms == nil then + perms, effective_access = getDefaultPermissions (properties) + end + if effective_access == nil then + effective_access = access + end + + if perms ~= nil then + log:info(client, "Granting permissions to client " .. id .. " (access " .. + effective_access .. "): " .. perms) + client:update_permissions { ["any"] = perms } + client:update_properties { ["pipewire.access.effective"] = effective_access } + else + log:debug(client, "No rule for client " .. id .. " (access " .. access .. ")") + end +end) + +clients_om:activate() diff --git a/.config/wireplumber/scripts/access/access-portal.lua b/.config/wireplumber/scripts/client/access-portal.lua similarity index 87% rename from .config/wireplumber/scripts/access/access-portal.lua rename to .config/wireplumber/scripts/client/access-portal.lua index e87a157..72d3545 100644 --- a/.config/wireplumber/scripts/access/access-portal.lua +++ b/.config/wireplumber/scripts/client/access-portal.lua @@ -1,6 +1,8 @@ MEDIA_ROLE_NONE = 0 MEDIA_ROLE_CAMERA = 1 << 0 +log = Log.open_topic ("s-client") + function hasPermission (permissions, app_id, lookup) if permissions then for key, values in pairs(permissions) do @@ -28,7 +30,7 @@ end function setPermissions (client, allow_client, allow_nodes) local client_id = client["bound-id"] - Log.info(client, "Granting ALL access to client " .. client_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 "-" } @@ -50,18 +52,18 @@ function updateClientPermissions (client, permissions) -- 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") + 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") + 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") + log:info (client, "Ignoring portal check for non-sandboxed client") setPermissions (client, true, true) return end @@ -70,19 +72,19 @@ function updateClientPermissions (client, permissions) -- 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") + 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") + 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)) + log:info (client, "setting permissions: " .. tostring(allowed)) setPermissions (client, allowed, allowed) end @@ -133,7 +135,7 @@ 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) + log:info(client, "Granting ALL access to client " .. id) client:update_permissions { ["any"] = "all" } end) end diff --git a/.config/wireplumber/scripts/client/access-snap.lua b/.config/wireplumber/scripts/client/access-snap.lua new file mode 100644 index 0000000..ae0016c --- /dev/null +++ b/.config/wireplumber/scripts/client/access-snap.lua @@ -0,0 +1,87 @@ +-- Manage snap audio permissions +-- +-- Copyright © 2023 Canonical Ltd. +-- @author Sergio Costas Rodriguez +-- +-- SPDX-License-Identifier: MIT + +function removeClientPermissionsForOtherClients (client) + -- Remove access to any other clients, but allow all the process of the + -- same snap to access their elements + local client_id = client.properties["pipewire.snap.id"] + for snap_client in clients_snap:iterate() do + local snap_client_id = snap_client.properties["pipewire.snap.id"] + if snap_client_id ~= client_id then + client:update_permissions { [snap_client["bound-id"]] = "-" } + end + end + for no_snap_client in clients_no_snap:iterate() do + client:update_permissions { [no_snap_client["bound-id"]] = "-" } + end +end + +function updateClientPermissions (client) + -- Remove access to Audio/Sources and Audio/Sinks based on snap permissions + for node in nodes_om:iterate() do + local node_id = node["bound-id"] + local property = "pipewire.snap.audio.playback" + + if node.properties["media.class"] == "Audio/Source" then + property = "pipewire.snap.audio.record" + end + + if client.properties[property] ~= "true" then + client:update_permissions { [node_id] = "-" } + end + end +end + +clients_snap = ObjectManager { + Interest { + type = "client", + Constraint { "pipewire.snap.id", "+", type = "pw"}, + } +} + +clients_no_snap = ObjectManager { + Interest { + type = "client", + Constraint { "pipewire.snap.id", "-", type = "pw"}, + } +} + +nodes_om = ObjectManager { + Interest { + type = "node", + Constraint { "media.class", "matches", "Audio/*"} + } +} + +clients_snap:connect("object-added", function (om, client) + -- If a new snap client is added, adjust its permissions + updateClientPermissions (client) + removeClientPermissionsForOtherClients (client) +end) + +clients_no_snap:connect("object-added", function (om, client) + -- If a new, non-snap client is added, + -- remove access to it from other snaps + client_id = client["bound-id"] + for snap_client in clients_snap:iterate() do + if client.properties["pipewire.snap.id"] ~= nil then + snap_client:update_permissions { [client_id] = "-" } + end + end +end) + +nodes_om:connect("object-added", function (om, node) + -- If a new Audio/Sink or Audio/Source node is added, + -- adjust the permissions in the snap clients + for client in clients_snap:iterate() do + updateClientPermissions (client) + end +end) + +clients_snap:activate() +clients_no_snap:activate() +nodes_om:activate() \ No newline at end of file diff --git a/.config/wireplumber/scripts/create-item.lua b/.config/wireplumber/scripts/create-item.lua deleted file mode 100644 index 3b2cc83..0000000 --- a/.config/wireplumber/scripts/create-item.lua +++ /dev/null @@ -1,129 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author Julian Bouzas --- --- 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() diff --git a/.config/wireplumber/scripts/default-nodes/apply-default-node.lua b/.config/wireplumber/scripts/default-nodes/apply-default-node.lua new file mode 100644 index 0000000..258037f --- /dev/null +++ b/.config/wireplumber/scripts/default-nodes/apply-default-node.lua @@ -0,0 +1,39 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-default-nodes") + +SimpleEventHook { + name = "default-nodes/apply-default-node", + after = { "default-nodes/find-best-default-node", + "default-nodes/find-selected-default-node", + "default-nodes/find-stored-default-node" }, + interests = { + EventInterest { + Constraint { "event.type", "=", "select-default-node" }, + }, + }, + execute = function (event) + local source = event:get_source () + local props = event:get_properties () + local def_node_type = props ["default-node.type"] + local selected_node = event:get_data ("selected-node") + + local om = source:call ("get-object-manager", "metadata") + local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } } + + if selected_node then + local key = "default." .. def_node_type + + log:info ("set default node for " .. key .. " " .. selected_node) + + metadata:set (0, key, "Spa:String:JSON", + Json.Object { ["name"] = selected_node }:to_string ()) + else + metadata:set (0, "default." .. def_node_type, nil, nil) + end + end +}:register () diff --git a/.config/wireplumber/scripts/default-nodes/find-best-default-node.lua b/.config/wireplumber/scripts/default-nodes/find-best-default-node.lua new file mode 100644 index 0000000..fe97a2b --- /dev/null +++ b/.config/wireplumber/scripts/default-nodes/find-best-default-node.lua @@ -0,0 +1,41 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +log = Log.open_topic ("s-default-nodes") + +nutils = require ("node-utils") + +SimpleEventHook { + name = "default-nodes/find-best-default-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-default-node" }, + }, + }, + execute = function (event) + local available_nodes = event:get_data ("available-nodes") + local selected_prio = event:get_data ("selected-node-priority") or 0 + local selected_node = event:get_data ("selected-node") + + available_nodes = available_nodes and available_nodes:parse () + if not available_nodes then + return + end + + for _, node_props in ipairs (available_nodes) do + -- Highest priority node wins + local priority = nutils.get_session_priority (node_props) + + if priority > selected_prio or selected_node == nil then + selected_prio = priority + selected_node = node_props ["node.name"] + end + end + + event:set_data ("selected-node-priority", selected_prio) + event:set_data ("selected-node", selected_node) + end +}:register () diff --git a/.config/wireplumber/scripts/default-nodes/find-selected-default-node.lua b/.config/wireplumber/scripts/default-nodes/find-selected-default-node.lua new file mode 100644 index 0000000..294cde5 --- /dev/null +++ b/.config/wireplumber/scripts/default-nodes/find-selected-default-node.lua @@ -0,0 +1,70 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +-- hook to make sure the user prefered device(default.configured.*) in other +-- words currently selected device is given higher priority + +-- state-default-nodes.lua also does find out the default node out of the user +-- preferences(current and past), however it doesnt give any higher priority to +-- the currently selected device. + +log = Log.open_topic ("s-default-nodes") + +nutils = require ("node-utils") + +SimpleEventHook { + name = "default-nodes/find-selected-default-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-default-node" }, + }, + }, + execute = function (event) + local available_nodes = event:get_data ("available-nodes") + + available_nodes = available_nodes and available_nodes:parse () + if not available_nodes then + return + end + + local selected_prio = event:get_data ("selected-node-priority") or 0 + local selected_node = event:get_data ("selected-node") + + local source = event:get_source () + local props = event:get_properties () + local def_node_type = props ["default-node.type"] + local metadata_om = source:call ("get-object-manager", "metadata") + local metadata = metadata_om:lookup { Constraint { "metadata.name", "=", "default" } } + local obj = metadata:find (0, "default.configured." .. def_node_type) + + if not obj then + return + end + + local json = Json.Raw (obj) + local current_configured_node = json:parse ().name + + for _, node_props in ipairs (available_nodes) do + local name = node_props ["node.name"] + local priority = nutils.get_session_priority (node_props) + + if current_configured_node == name then + priority = 30000 + priority + + if priority > selected_prio then + + selected_prio = priority + selected_node = name + + event:set_data ("selected-node-priority", selected_prio) + event:set_data ("selected-node", selected_node) + end + + break + end + end + end +}:register () diff --git a/.config/wireplumber/scripts/default-nodes/rescan.lua b/.config/wireplumber/scripts/default-nodes/rescan.lua new file mode 100644 index 0000000..61cda88 --- /dev/null +++ b/.config/wireplumber/scripts/default-nodes/rescan.lua @@ -0,0 +1,179 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +-- looks for changes in user-preferences and devices added/removed and schedules +-- rescan and pushes "select-default-node" event for each of the media_classes + +log = Log.open_topic ("s-default-nodes") + +-- looks for changes in user-preferences and devices added/removed and schedules +-- rescan +SimpleEventHook { + name = "default-nodes/rescan-trigger", + interests = { + EventInterest { + Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, + Constraint { "event.session-item.interface", "=", "linkable" }, + Constraint { "media.class", "#", "Audio/*" }, + }, + EventInterest { + Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, + Constraint { "event.session-item.interface", "=", "linkable" }, + Constraint { "media.class", "#", "Video/*" }, + }, + EventInterest { + Constraint { "event.type", "=", "metadata-changed" }, + Constraint { "metadata.name", "=", "default" }, + Constraint { "event.subject.key", "c", "default.configured.audio.sink", + "default.configured.audio.source", "default.configured.video.source" + }, + }, + EventInterest { + Constraint { "event.type", "=", "device-params-changed"}, + Constraint { "event.subject.param-id", "c", "Route", "EnumRoute"}, + }, + }, + execute = function (event) + local source = event:get_source () + source:call ("schedule-rescan", "default-nodes") + end +}:register () + +-- pushes "select-default-node" event for each of the media_classes +SimpleEventHook { + name = "default-nodes/rescan", + interests = { + EventInterest { + Constraint { "event.type", "=", "rescan-for-default-nodes" }, + }, + }, + execute = function (event) + local source = event:get_source () + local si_om = source:call ("get-object-manager", "session-item") + local devices_om = source:call ("get-object-manager", "device") + + log:trace ("re-evaluating default nodes") + + -- Audio Sink + pushSelectDefaultNodeEvent (source, si_om, devices_om, "audio.sink", "in", { + "Audio/Sink", "Audio/Duplex" + }) + + -- Audio Source + pushSelectDefaultNodeEvent (source, si_om, devices_om, "audio.source", "out", { + "Audio/Source", "Audio/Source/Virtual", "Audio/Duplex", "Audio/Sink" + }) + + -- Video Source + pushSelectDefaultNodeEvent (source, si_om, devices_om, "video.source", "out", { + "Video/Source", "Video/Source/Virtual" + }) + end +}:register () + +function pushSelectDefaultNodeEvent (source, si_om, devices_om, def_node_type, + port_direction, media_classes) + local nodes = + collectAvailableNodes (si_om, devices_om, port_direction, media_classes) + local event = source:call ("create-event", "select-default-node", nil, { + ["default-node.type"] = def_node_type, + }) + event:set_data ("available-nodes", Json.Array (nodes)) + EventDispatcher.push_event (event) +end + +-- Return an array table where each element is another table containing all the +-- node properties of all the nodes that can be selected for a given media class +-- set and direction +function collectAvailableNodes (si_om, devices_om, port_direction, media_classes) + local collected = {} + + for linkable in si_om:iterate { + type = "SiLinkable", + Constraint { "media.class", "c", table.unpack (media_classes) }, + } do + local linkable_props = linkable.properties + local node = linkable:get_associated_proxy ("node") + + -- check that the node has ports in the requested direction + if not node:lookup_port { + Constraint { "port.direction", "=", port_direction } + } then + goto next_linkable + end + + -- check that the node has available routes, + -- if it is associated to a real device + if not nodeHasAvailableRoutes (node, devices_om) then + goto next_linkable + end + + table.insert (collected, Json.Object (node.properties)) + + ::next_linkable:: + end + + return collected +end + +-- If the node has an associated device, verify that it has an available +-- route. Some UCM profiles expose all paths (headphones, HDMI, etc) as nodes, +-- even though they may not be connected... See #145 +function nodeHasAvailableRoutes (node, devices_om) + local properties = node.properties + local device_id = properties ["device.id"] + local cpd = properties ["card.profile.device"] + + if not device_id or not cpd then + return true + end + + -- Get the device + local device = devices_om:lookup { + Constraint { "bound-id", "=", device_id, type = "gobject" } + } + if not device then + return true + end + + -- Check if the current device route supports the node card device profile + for r in device:iterate_params ("Route") do + local route = r:parse () + local route_props = route.properties + if route_props.device == tonumber (cpd) then + if route_props.available == "no" then + return false + else + return true + end + end + end + + -- Check if available routes support the node card device profile + local found = 0 + for r in device:iterate_params ("EnumRoute") do + local route = r:parse () + local route_props = route.properties + if type (route_props.devices) == "table" then + for _, i in ipairs (route_props.devices) do + if i == tonumber (cpd) then + found = found + 1 + if route_props.available ~= "no" then + return true + end + end + end + end + end + + -- The node is part of a profile without routes so we assume it + -- is available. This can happen for Pro Audio profiles + if found == 0 then + return true + end + + return false +end diff --git a/.config/wireplumber/scripts/default-nodes/state-default-nodes.lua b/.config/wireplumber/scripts/default-nodes/state-default-nodes.lua new file mode 100644 index 0000000..c571d0e --- /dev/null +++ b/.config/wireplumber/scripts/default-nodes/state-default-nodes.lua @@ -0,0 +1,199 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +-- the script states the default nodes from the user preferences, it has hooks +-- which stores the user preferences(it stores not just the current preference +-- but all the previous preferences) in to the state file, retrives them from +-- state file during the bootup, finally it has a hook which finds a default +-- node out of the user preferences + +log = Log.open_topic ("s-default-nodes") + +nutils = require ("node-utils") + +-- the state storage +state = nil +state_table = nil + +find_stored_default_node_hook = SimpleEventHook { + name = "default-nodes/find-stored-default-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-default-node" }, + }, + }, + execute = function (event) + local props = event:get_properties () + local available_nodes = event:get_data ("available-nodes") + local selected_prio = event:get_data ("selected-node-priority") or 0 + local selected_node = event:get_data ("selected-node") + + available_nodes = available_nodes and available_nodes:parse () + if not available_nodes then + return + end + + local stored = collectStored (props ["default-node.type"]) + + -- Check if any of the available nodes matches any of the configured + for _, node_props in ipairs (available_nodes) do + local name = node_props ["node.name"] + + for i, v in ipairs (stored) do + if name == v then + local priority = nutils.get_session_priority (node_props) + priority = priority + 20001 - i + + if priority > selected_prio then + selected_prio = priority + selected_node = name + end + + break + end + end + end + + if selected_node then + event:set_data ("selected-node-priority", selected_prio) + event:set_data ("selected-node", selected_node) + end + end +} + +store_configured_default_nodes_hook = SimpleEventHook { + name = "default-nodes/store-configured-default-nodes", + interests = { + EventInterest { + Constraint { "event.type", "=", "metadata-changed" }, + Constraint { "metadata.name", "=", "default" }, + Constraint { "event.subject.key", "c", "default.configured.audio.sink", + "default.configured.audio.source", "default.configured.video.source" + }, + }, + }, + execute = function (event) + local props = event:get_properties () + -- get the part after "default.configured." (= 19 chars) + local def_node_type = props ["event.subject.key"]:sub (20) + local new_value = props ["event.subject.value"] + local new_stored = {} + + if new_value then + new_value = Json.Raw (new_value):parse () ["name"] + end + + if new_value then + local stored = collectStored (def_node_type) + local pos = #stored + 1 + + -- find if the curent configured value is already in the stack + for i, v in ipairs (stored) do + if v == new_value then + pos = i + break + end + end + + -- insert at the top and shift the remaining to fill the gap + new_stored [1] = new_value + if pos > 1 then + table.move (stored, 1, pos-1, 2, new_stored) + end + if pos < #stored then + table.move (stored, pos+1, #stored, pos+1, new_stored) + end + end + + updateStored (def_node_type, new_stored) + end +} + +-- set initial values +metadata_added_hook = SimpleEventHook { + name = "default-nodes/metadata-added", + interests = { + EventInterest { + Constraint { "event.type", "=", "metadata-added" }, + Constraint { "metadata.name", "=", "default" }, + }, + }, + execute = function (event) + local types = { "audio.sink", "audio.source", "video.source" } + local source = event:get_source () + local om = source:call ("get-object-manager", "metadata") + local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } } + + for _, t in ipairs (types) do + local v = state_table ["default.configured." .. t] + if v then + metadata:set (0, "default.configured." .. t, "Spa:String:JSON", + Json.Object { ["name"] = v }:to_string ()) + end + end + end +} + +-- Collect all the previously configured node names from the state file +function collectStored (def_node_type) + local stored = {} + local key_base = "default.configured." .. def_node_type + local key = key_base + + local index = 0 + repeat + local v = state_table [key] + table.insert (stored, v) + key = key_base .. "." .. tostring (index) + index = index + 1 + until v == nil + + return stored +end + +-- Store the given node names in the state file +function updateStored (def_node_type, stored) + local key_base = "default.configured." .. def_node_type + local key = key_base + + local index = 0 + for _, v in ipairs (stored) do + state_table [key] = v + key = key_base .. "." .. tostring (index) + index = index + 1 + end + + -- erase the rest, if any + repeat + local v = state_table [key] + state_table [key] = nil + key = key_base .. "." .. tostring (index) + index = index + 1 + until v == nil + + state:save_after_timeout (state_table) +end + +function toggleState (enable) + if enable and not state then + state = State ("default-nodes") + state_table = state:load () + find_stored_default_node_hook:register () + store_configured_default_nodes_hook:register () + metadata_added_hook:register () + elseif not enable and state then + state = nil + state_table = nil + find_stored_default_node_hook:remove () + store_configured_default_nodes_hook:remove () + metadata_added_hook:remove () + end +end + +Settings.subscribe ("node.restore-default-targets", function () + toggleState (Settings.get_boolean ("node.restore-default-targets")) +end) +toggleState (Settings.get_boolean ("node.restore-default-targets")) diff --git a/.config/wireplumber/scripts/device/apply-profile.lua b/.config/wireplumber/scripts/device/apply-profile.lua new file mode 100644 index 0000000..0956ef4 --- /dev/null +++ b/.config/wireplumber/scripts/device/apply-profile.lua @@ -0,0 +1,59 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +-- apply the selected profile to the device + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +AsyncEventHook { + name = "device/apply-profile", + after = { "device/find-stored-profile", "device/find-preferred-profile", "device/find-best-profile" }, + interests = { + EventInterest { + Constraint { "event.type", "=", "select-profile" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local device = event:get_subject () + local profile = event:get_data ("selected-profile") + local dev_name = device.properties ["device.name"] or "" + + if not profile then + log:info (device, "No profile found to set on " .. dev_name) + transition:advance () + return + end + + for p in device:iterate_params ("Profile") do + local active_profile = cutils.parseParam (p, "Profile") + if active_profile.index == tonumber(profile.index) then + log:info (device, "Profile " .. profile.name .. " is already set on " .. dev_name) + transition:advance () + return + end + end + + local param = Pod.Object { + "Spa:Pod:Object:Param:Profile", "Profile", + index = tonumber(profile.index), + } + log:info (device, "Setting profile " .. profile.name .. " on " .. dev_name) + device:set_param ("Profile", param) + + -- FIXME: add cancellability + -- sync on the pipewire connection to ensure that the param + -- has been configured on the remote device object + Core.sync (function () + transition:advance () + end) + end + }, + } +}:register() diff --git a/.config/wireplumber/scripts/device/apply-routes.lua b/.config/wireplumber/scripts/device/apply-routes.lua new file mode 100644 index 0000000..ce71a56 --- /dev/null +++ b/.config/wireplumber/scripts/device/apply-routes.lua @@ -0,0 +1,107 @@ +-- WirePlumber +-- +-- Copyright © 2021-2022 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- Based on default-routes.c from pipewire-media-session +-- Copyright © 2020 Wim Taymans +-- +-- SPDX-License-Identifier: MIT +-- +-- Set the Route param as part of the "select-routes" event run + +devinfo = require ("device-info-cache") +log = Log.open_topic ("s-device") + +AsyncEventHook { + name = "device/apply-routes", + after = { "device/find-stored-routes", + "device/find-best-routes", + "device/apply-route-props" }, + interests = { + EventInterest { + Constraint { "event.type", "=", "select-routes" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local device = event:get_subject () + local selected_routes = event:get_data ("selected-routes") + + local dev_info = devinfo:get_device_info (device) + assert (dev_info) + + if not selected_routes then + log:info (device, "No routes selected to set on " .. dev_info.name) + transition:advance () + return + end + + for device_id, route in pairs (selected_routes) do + -- JSON to lua table + route = Json.Raw (route):parse () + + -- steal the props + local props = route.props or {} + + -- replace with the full route info + local route_info = devinfo.find_route_info (dev_info, route) + if not route_info then + goto skip_route + end + + -- ensure default values + local is_input = (route_info.direction == "Input") + props.mute = props.mute or false + props.channelVolumes = props.channelVolumes or + { is_input and Settings.get_float ("device.routes.default-source-volume") + or Settings.get_float ("device.routes.default-sink-volume") } + + -- prefix the props with correct IDs to create a Pod.Object + table.insert (props, 1, "Spa:Pod:Object:Param:Props") + table.insert (props, 2, "Route") + + -- 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_info.index, + device = device_id, + props = Pod.Object (props), + save = route_info.save, + } + + log:debug (param, + string.format ("setting route(%s) on for device(%s)(%s)", + route_info.name, dev_info.name, tostring (device))) + + device:set_param ("Route", param) + + ::skip_route:: + end + + -- FIXME: add cancellability + -- sync on the pipewire connection to ensure that the params + -- have been configured on the remote device object + Core.sync (function () + transition:advance () + end) + end + }, + } +}:register() diff --git a/.config/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua b/.config/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua new file mode 100644 index 0000000..9f63b4a --- /dev/null +++ b/.config/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua @@ -0,0 +1,472 @@ +-- WirePlumber +-- +-- Copyright © 2021 Asymptotic Inc. +-- @author Sanchayan Maity +-- +-- 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 +-- +-- This script is charged to automatically change BT profiles on a device. If a +-- client is linked to the device's loopback source node, the associated BT +-- device profile is automatically switched to HSP/HFP. If there is no clients +-- linked to the device's loopback source node, the BT device profile is +-- switched back to A2DP profile. +-- +-- We switch to the highest priority profile that has an Input route available. +-- The reason for this is that we may have microphone enabled with non-HFP +-- codecs eg. Faststream. +-- 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. + +-- settings file: bluetooth.conf + +lutils = require ("linking-utils") +cutils = require ("common-utils") + +state = nil +headset_profiles = nil +device_loopback_sources = {} + +local profile_restore_timeout_msec = 2000 + +local INVALID = -1 +local timeout_source = {} +local restore_timeout_source = {} + +local last_profiles = {} + +local active_streams = {} +local previous_streams = {} + +function handlePersistentSetting (enable) + if enable and state == nil then + -- the state storage + state = Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") + and State ("bluetooth-autoswitch") or nil + headset_profiles = state and state:load () or {} + else + state = nil + headset_profiles = nil + end +end + +handlePersistentSetting (Settings.get_boolean ("bluetooth.use-persistent-storage")) +Settings.subscribe ("bluetooth.use-persistent-storage", function () + handlePersistentSetting (Settings.get_boolean ("bluetooth.use-persistent-storage")) +end) + +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" }, + Constraint { "node.link-group", "-", type = "pw" }, + Constraint { "stream.monitor", "!", "true", type = "pw" }, + Constraint { "bluez5.loopback", "!", "true", type = "pw" } + } +} + +filter_nodes_om = ObjectManager { + Interest { + type = "node", + Constraint { "node.link-group", "+", type = "pw" }, + Constraint { "stream.monitor", "!", "true", type = "pw" }, + Constraint { "bluez5.loopback", "!", "true", type = "pw" }, + } +} + +loopback_nodes_om = ObjectManager { + Interest { + type = "node", + Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" }, + Constraint { "node.link-group", "+", type = "pw" }, + Constraint { "stream.monitor", "!", "true", type = "pw" }, + Constraint { "bluez5.loopback", "=", "true", type = "pw" }, + } +} + +local function saveHeadsetProfile (device, profile_name) + local key = "saved-headset-profile:" .. device.properties ["device.name"] + headset_profiles [key] = profile_name + state:save_after_timeout (headset_profiles) +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 isSwitchedToHeadsetProfile (device) + return getSavedLastProfile (device) ~= nil +end + +local function findProfile (device, index, name) + for p in device:iterate_params ("EnumProfile") do + local profile = cutils.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 = cutils.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 = cutils.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 = cutils.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 switchDeviceToHeadsetProfile (dev_id) + -- Find the actual device + local device = devices_om:lookup { + Constraint { "bound-id", "=", dev_id, type = "gobject" } + } + if device == nil then + Log.info ("Device with id " .. tostring(dev_id).. " not found") + return + end + + -- clear restore callback, if any + if restore_timeout_source[dev_id] ~= nil then + restore_timeout_source[dev_id]:destroy () + restore_timeout_source[dev_id] = nil + end + + local cur_profile_name = getCurrentProfile (device) + local priority, index, name = findProfile (device, nil, cur_profile_name) + if hasProfileInputRoute (device, index) then + Log.info ("Current profile has input route, not switching") + return + end + + if isSwitchedToHeadsetProfile (device) then + Log.info ("Device with id " .. tostring(dev_id).. " is already switched to HSP/HFP") + return + end + + local saved_headset_profile = getSavedHeadsetProfile (device) + + index = INVALID + if saved_headset_profile then + priority, index, name = findProfile (device, nil, saved_headset_profile) + if index ~= INVALID and not hasProfileInputRoute (device, index) then + index = INVALID + saveHeadsetProfile (device, nil) + end + end + if index == INVALID then + priority, index, name = highestPrioProfileWithInputRoute (device) + end + + if index ~= INVALID then + local pod = Pod.Object { + "Spa:Pod:Object:Param:Profile", "Profile", + index = index + } + + -- store the current profile (needed when restoring) + saveLastProfile (device, cur_profile_name) + + -- switch to headset profile + 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 +end + +local function restoreProfile (dev_id) + -- Find the actual device + local device = devices_om:lookup { + Constraint { "bound-id", "=", dev_id, type = "gobject" } + } + if device == nil then + Log.info ("Device with id " .. tostring(dev_id).. " not found") + return + end + + if not isSwitchedToHeadsetProfile (device) then + Log.info ("Device with id " .. tostring(dev_id).. " is already not switched to HSP/HFP") + return + end + + local profile_name = getSavedLastProfile (device) + local cur_profile_name = getCurrentProfile (device) + local priority, index, name + + if cur_profile_name then + priority, index, name = findProfile (device, nil, cur_profile_name) + + if index ~= INVALID and hasProfileInputRoute (device, index) then + Log.info ("Setting saved headset profile to: " .. cur_profile_name) + saveHeadsetProfile (device, cur_profile_name) + end + end + + if profile_name then + priority, index, name = findProfile (device, nil, profile_name) + + if index ~= INVALID then + local pod = Pod.Object { + "Spa:Pod:Object:Param:Profile", "Profile", + index = index + } + + -- clear last profile as we will restore it now + saveLastProfile (device, nil) + + -- restore previous profile + 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 + +local function triggerRestoreProfile (dev_id) + -- we never restore the device profiles if there are active streams + for _, v in pairs (active_streams) do + if v == dev_id then + return + end + end + + -- clear restore callback, if any + if restore_timeout_source[dev_id] ~= nil then + restore_timeout_source[dev_id]:destroy () + restore_timeout_source[dev_id] = nil + end + + -- create new restore callback + restore_timeout_source[dev_id] = Core.timeout_add (profile_restore_timeout_msec, function () + restore_timeout_source[dev_id] = nil + restoreProfile (dev_id) + end) +end + +-- We consider a Stream of interest if it is linked to a bluetooth loopback +-- source filter +local function checkStreamStatus (stream) + -- check if the stream is linked to a bluetooth loopback source + local stream_id = tonumber(stream["bound-id"]) + local peer_id = lutils.getNodePeerId (stream_id) + if peer_id ~= nil then + local bt_node = loopback_nodes_om:lookup { + Constraint { "bound-id", "=", peer_id, type = "gobject" } + } + if bt_node ~= nil then + local dev_id = bt_node.properties["device.id"] + if dev_id ~= nil then + -- 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.id] == dev_id and + stream.state ~= "running" then + return nil + end + + return dev_id + end + else + -- Check if it is linked to a filter main node, and recursively advance if so + local filter_main_node = filter_nodes_om:lookup { + Constraint { "bound-id", "=", peer_id, type = "gobject" } + } + if filter_main_node ~= nil then + -- Now check the all stream nodes for this filter + local filter_link_group = filter_main_node.properties ["node.link-group"] + for filter_stream_node in filter_nodes_om:iterate { + Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, + Constraint { "node.link-group", "=", filter_link_group, type = "pw" } + } do + local dev_id = checkStreamStatus (filter_stream_node) + if dev_id ~= nil then + return dev_id + end + end + end + end + end + + return nil +end + +local function handleStream (stream) + if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then + return + end + + local dev_id = checkStreamStatus (stream) + if dev_id ~= nil then + active_streams [stream.id] = dev_id + previous_streams [stream.id] = dev_id + switchDeviceToHeadsetProfile (dev_id) + else + dev_id = active_streams [stream.id] + active_streams [stream.id] = nil + if dev_id ~= nil then + triggerRestoreProfile (dev_id) + end + end +end + +local function handleAllStreams () + for stream in streams_om:iterate() do + handleStream (stream) + end +end + +SimpleEventHook { + name = "node-removed@autoswitch-bluetooth-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "node-removed" }, + Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" }, + Constraint { "bluez5.loopback", "!", "true", type = "pw" }, + }, + }, + execute = function (event) + local stream = event:get_subject () + local dev_id = active_streams[stream.id] + active_streams[stream.id] = nil + previous_streams[stream.id] = nil + if dev_id ~= nil then + triggerRestoreProfile (dev_id) + end + end +}:register () + +SimpleEventHook { + name = "link-added@autoswitch-bluetooth-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "link-added" }, + }, + }, + execute = function (event) + local link = event:get_subject () + local p = link.properties + for stream in streams_om:iterate () do + local in_id = tonumber(p["link.input.node"]) + local stream_id = tonumber(stream["bound-id"]) + if in_id == stream_id then + handleStream (stream) + end + end + end +}:register () + +SimpleEventHook { + name = "bluez-device-added@autoswitch-bluetooth-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-added" }, + Constraint { "device.api", "=", "bluez5" }, + }, + }, + execute = function (event) + local device = event:get_subject () + -- Devices are unswitched initially + saveLastProfile (device, nil) + handleAllStreams () + end +}:register () + +devices_om:activate () +streams_om:activate () +filter_nodes_om:activate () +loopback_nodes_om:activate() + diff --git a/.config/wireplumber/scripts/device/find-best-profile.lua b/.config/wireplumber/scripts/device/find-best-profile.lua new file mode 100644 index 0000000..a9e40c7 --- /dev/null +++ b/.config/wireplumber/scripts/device/find-best-profile.lua @@ -0,0 +1,75 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Find the best profile for a device based on profile priorities and +-- availability + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +SimpleEventHook { + name = "device/find-best-profile", + after = "device/find-preferred-profile", + before = "device/apply-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-profile" }, + }, + }, + execute = function (event) + local selected_profile = event:get_data ("selected-profile") + + -- skip hook if profile is already selected + if selected_profile then + return + end + + local device = event:get_subject () + local dev_name = device.properties["device.name"] or "" + local off_profile = nil + local best_profile = nil + local unk_profile = nil + -- Takes absolute priority if available or unknown + local profile_prop = device.properties["device.profile"] + + + for p in device:iterate_params ("EnumProfile") do + profile = cutils.parseParam (p, "EnumProfile") + if profile and profile.name == profile_prop and profile.available ~= "no" then + selected_profile = profile + goto profile_set + elseif 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 + selected_profile = best_profile + elseif unk_profile ~= nil then + selected_profile = unk_profile + elseif off_profile ~= nil then + selected_profile = off_profile + end + +::profile_set:: + if selected_profile then + log:info (device, string.format ( + "Found best profile '%s' (%d) for device '%s'", + selected_profile.name, selected_profile.index, dev_name)) + event:set_data ("selected-profile", selected_profile) + end + end +}:register() diff --git a/.config/wireplumber/scripts/device/find-best-routes.lua b/.config/wireplumber/scripts/device/find-best-routes.lua new file mode 100644 index 0000000..c7eb8fb --- /dev/null +++ b/.config/wireplumber/scripts/device/find-best-routes.lua @@ -0,0 +1,77 @@ +-- WirePlumber +-- +-- Copyright © 2021-2022 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- Based on default-routes.c from pipewire-media-session +-- Copyright © 2020 Wim Taymans +-- +-- SPDX-License-Identifier: MIT +-- +-- find the best route for a given device_id, based on availability and priority + +cutils = require ("common-utils") +devinfo = require ("device-info-cache") +log = Log.open_topic ("s-device") + +SimpleEventHook { + name = "device/find-best-routes", + after = "device/find-stored-routes", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-routes" }, + Constraint { "profile.active-device-ids", "is-present" }, + }, + }, + execute = function (event) + local device = event:get_subject () + local event_properties = event:get_properties () + local active_ids = event_properties ["profile.active-device-ids"] + local selected_routes = event:get_data ("selected-routes") or {} + + local dev_info = devinfo:get_device_info (device) + assert (dev_info) + + -- active IDs are exchanged in JSON format + active_ids = Json.Raw (active_ids):parse () + + for _, device_id in ipairs (active_ids) do + -- if a previous hook already selected a route for this device_id, skip it + if selected_routes [tostring (device_id)] then + goto next_device_id + end + + local best_avail = nil + local best_unk = nil + for _, ri in pairs (dev_info.route_infos) do + if cutils.arrayContains (ri.devices, device_id) and + (ri.profiles == nil or cutils.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 + + local route = best_avail or best_unk + if route then + selected_routes [tostring (device_id)] = + Json.Object { index = route.index }:to_string () + end + + ::next_device_id:: + end + + -- save the selected routes for the apply-routes hook + event:set_data ("selected-routes", selected_routes) + end +}:register () diff --git a/.config/wireplumber/scripts/device/find-preferred-profile.lua b/.config/wireplumber/scripts/device/find-preferred-profile.lua new file mode 100644 index 0000000..77751c3 --- /dev/null +++ b/.config/wireplumber/scripts/device/find-preferred-profile.lua @@ -0,0 +1,68 @@ +-- WirePlumber +-- +-- Copyright © 2024 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the user preferred profile for a device, based on the priorities +-- defined in the "device.profile.priority.rules" section of the configuration. + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +config = {} +config.rules = Conf.get_section_as_json ("device.profile.priority.rules", Json.Array {}) + +SimpleEventHook { + name = "device/find-preferred-profile", + after = "device/find-stored-profile", + before = "device/find-best-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-profile" }, + }, + }, + execute = function (event) + local selected_profile = event:get_data ("selected-profile") + + -- skip hook if the profile is already selected for this device. + if selected_profile then + return + end + + local device = event:get_subject () + local props = JsonUtils.match_rules_update_properties ( + config.rules, device.properties) + local p_array = props["priorities"] + + -- skip hook if the profile priorities are NOT defined for this device. + if not p_array then + return nil + end + + local p_json = Json.Raw(p_array) + local priorities = p_json:parse() + + for _, priority_profile in ipairs(priorities) do + for p in device:iterate_params("EnumProfile") do + local device_profile = cutils.parseParam(p, "EnumProfile") + if device_profile and device_profile.name == priority_profile then + selected_profile = device_profile + goto profile_set + end + end + end + +::profile_set:: + if selected_profile then + log:info (device, string.format ( + "Found preferred profile '%s' (%d) for device '%s'", + selected_profile.name, selected_profile.index, device_name)) + event:set_data ("selected-profile", selected_profile) + else + log:info (device, "Profiles listed in 'device.profile.priority.rules'" + .. " do not match the available ones of device: " .. device_name) + end + + end +}:register() diff --git a/.config/wireplumber/scripts/device/select-profile.lua b/.config/wireplumber/scripts/device/select-profile.lua new file mode 100644 index 0000000..da870ae --- /dev/null +++ b/.config/wireplumber/scripts/device/select-profile.lua @@ -0,0 +1,28 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +-- look for new devices and raise select-profile event. + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +SimpleEventHook { + name = "device/select-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-added" }, + }, + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "EnumProfile" }, + }, + }, + execute = function (event) + local source = event:get_source () + local device = event:get_subject () + source:call ("push-event", "select-profile", device, nil) + end +}:register() diff --git a/.config/wireplumber/scripts/device/select-routes.lua b/.config/wireplumber/scripts/device/select-routes.lua new file mode 100644 index 0000000..ecd3df5 --- /dev/null +++ b/.config/wireplumber/scripts/device/select-routes.lua @@ -0,0 +1,160 @@ +-- WirePlumber +-- +-- Copyright © 2021-2022 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- Based on default-routes.c from pipewire-media-session +-- Copyright © 2020 Wim Taymans +-- +-- SPDX-License-Identifier: MIT +-- +-- Update the device info cache with the latest information from EnumRoute(all +-- the device routes) and trigger a "select-routes" event to select new routes +-- for the given device configuration, if it has changed + +cutils = require ("common-utils") +devinfo = require ("device-info-cache") +log = Log.open_topic ("s-device") + +SimpleEventHook { + name = "device/select-route", + after = "device/select-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-added" }, + }, + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "c", "EnumRoute" }, + }, + }, + execute = function (event) + local source = event:get_source () + local device = event:get_subject () + + local dev_info = devinfo:get_device_info (device) + if not dev_info then + return + end + + local new_route_infos = {} + local avail_routes_changed = false + local profile = nil + + -- get current profile + for p in device:iterate_params ("Profile") do + profile = cutils.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 = cutils.parseParam (p, "EnumRoute") + if not route then + goto skip_enum_route + end + + -- find cached route information + local route_info = devinfo.find_route_info (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 cutils.arrayContains (route.profiles, profile.index) then + avail_routes_changed = true + end + end + + -- 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 + + -- restore routes for profile + if profile then + local profile_changed = (dev_info.active_profile ~= profile.index) + 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 + log:info (device, + string.format ("restore routes for profile(%s) of device(%s)", + profile.name, dev_info.name)) + + -- find the active device IDs for which to select routes + local active_ids = findActiveDeviceIDs (profile) + active_ids = Json.Array (active_ids):to_string () + + -- push select-routes event and let the hooks select the appropriate routes + local props = { + ["profile.changed"] = profile_changed, + ["profile.name"] = profile.name, + ["profile.active-device-ids"] = active_ids, + } + source:call ("push-event", "select-routes", device, props) + end + end + end +}:register() + +-- These device ids are like routes(speaker, mic, headset etc) or sub-devices or +-- paths with in the pipewire devices/soundcards. +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 diff --git a/.config/wireplumber/scripts/device/state-profile.lua b/.config/wireplumber/scripts/device/state-profile.lua new file mode 100644 index 0000000..72e243f --- /dev/null +++ b/.config/wireplumber/scripts/device/state-profile.lua @@ -0,0 +1,145 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- This file contains all the logic related to saving device profiles +-- to a state file and restoring them later on. + +-- A devices profile needs to be selected for any new device. the script selects +-- the device profile from the user preferences, as well as store the user +-- selected device profile to state file + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +-- the state storage +state = nil +state_table = nil + +find_stored_profile_hook = SimpleEventHook { + name = "device/find-stored-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-profile" }, + }, + }, + execute = function (event) + local selected_profile = event:get_data ("selected-profile") + + -- skip hook if profile is already selected + if selected_profile then + return + end + + local device = event:get_subject () + local dev_name = device.properties["device.name"] + if not dev_name then + log:critical (device, "invalid device.name") + return + end + + local profile_name = state_table[dev_name] + + if profile_name then + for p in device:iterate_params ("EnumProfile") do + local profile = cutils.parseParam (p, "EnumProfile") + if profile.name == profile_name and profile.available ~= "no" then + selected_profile = profile + break + end + end + end + + if selected_profile then + log:info (device, string.format ( + "Found stored profile '%s' (%d) for device '%s'", + selected_profile.name, selected_profile.index, dev_name)) + event:set_data ("selected-profile", selected_profile) + end + end +} + +store_user_selected_profile_hook = SimpleEventHook { + name = "device/store-user-selected-profile", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "Profile" }, + }, + }, + execute = function (event) + local device = event:get_subject () + + for p in device:iterate_params ("Profile") do + local profile = cutils.parseParam (p, "Profile") + if profile.save then + -- store only if this was a user-generated action (save == true) + updateStoredProfile (device, profile) + end + end + end +} + +function updateStoredProfile (device, profile) + local dev_name = device.properties["device.name"] + local index = nil + + if not dev_name then + log:critical (device, "invalid device.name") + return + end + + log:debug (device, string.format ( + "update stored profile to '%s' (%d) for device '%s'", + profile.name, profile.index, dev_name)) + + -- check if the new profile is the same as the current one + if state_table[dev_name] == profile.name then + log:debug (device, " ... profile is already stored") + return + end + + -- find the full profile from EnumProfile, making also sure that the + -- user / client application has actually set an existing profile + for p in device:iterate_params ("EnumProfile") do + local enum_profile = cutils.parseParam (p, "EnumProfile") + if enum_profile.name == profile.name then + index = enum_profile.index + end + end + + if not index then + log:info (device, string.format ( + "profile '%s' (%d) is not valid on device '%s'", + profile.name, profile.index, dev_name)) + return + end + + state_table[dev_name] = profile.name + state:save_after_timeout (state_table) + + log:info (device, string.format ( + "stored profile '%s' (%d) for device '%s'", + profile.name, index, dev_name)) +end + +function toggleState (enable) + if enable and not state then + state = State ("default-profile") + state_table = state:load () + find_stored_profile_hook:register () + store_user_selected_profile_hook:register () + elseif not enable and state then + state = nil + state_table = nil + find_stored_profile_hook:remove () + store_user_selected_profile_hook:remove () + end +end + +Settings.subscribe ("device.restore-profile", function () + toggleState (Settings.get_boolean ("device.restore-profile")) +end) +toggleState (Settings.get_boolean ("device.restore-profile")) diff --git a/.config/wireplumber/scripts/device/state-routes.lua b/.config/wireplumber/scripts/device/state-routes.lua new file mode 100644 index 0000000..1f57c5b --- /dev/null +++ b/.config/wireplumber/scripts/device/state-routes.lua @@ -0,0 +1,345 @@ +-- WirePlumber +-- +-- Copyright © 2021-2022 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- Based on default-routes.c from pipewire-media-session +-- Copyright © 2020 Wim Taymans +-- +-- SPDX-License-Identifier: MIT +-- +-- This file contains all the logic related to saving device routes and their +-- properties to a state file and restoring both the routes selection and +-- the properties of routes later on. +-- + +cutils = require ("common-utils") +devinfo = require ("device-info-cache") +log = Log.open_topic ("s-device") + +-- the state storage +state = nil +state_table = nil + +-- hook to restore routes selection for a newly selected profile +find_stored_routes_hook = SimpleEventHook { + name = "device/find-stored-routes", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-routes" }, + Constraint { "profile.changed", "=", "true" }, + Constraint { "profile.active-device-ids", "is-present" }, + }, + }, + execute = function (event) + local device = event:get_subject () + local event_properties = event:get_properties () + local profile_name = event_properties ["profile.name"] + local active_ids = event_properties ["profile.active-device-ids"] + local selected_routes = event:get_data ("selected-routes") or {} + + local dev_info = devinfo:get_device_info (device) + assert (dev_info) + + -- get the stored routes for this profile + -- skip the hook if there are no stored routes, there is no point + local spr = getStoredProfileRoutes (dev_info, profile_name) + if #spr == 0 then + return + end + + -- active IDs are exchanged in JSON format + active_ids = Json.Raw (active_ids):parse () + + for _, device_id in ipairs (active_ids) do + -- if a previous hook already selected a route for this device_id, skip it + if selected_routes [tostring (device_id)] then + goto next_device_id + end + + log:info (device, "restoring route for device ID " .. tostring (device_id)); + + local route_info = nil + + -- find a route that was previously stored for a device_id + for _, ri in pairs (dev_info.route_infos) do + if cutils.arrayContains (ri.devices, tonumber (device_id)) and + (ri.profiles == nil or cutils.arrayContains (ri.profiles, dev_info.active_profile)) and + cutils.arrayContains (spr, ri.name) then + route_info = ri + break + end + end + + if route_info then + -- we found a stored route + if route_info.available == "no" then + log:info (device, "stored route '" .. route_info.name .. "' not available") + -- not available, try to find next best + route_info = nil + else + log:info (device, "found stored route: " .. route_info.name) + -- make sure we save it again + route_info.save = true + end + end + + if route_info then + selected_routes [tostring (device_id)] = + Json.Object { index = route_info.index }:to_string () + end + + ::next_device_id:: + end + + -- save the selected routes for the apply-routes hook + event:set_data ("selected-routes", selected_routes) + end +} + +-- extract the "selected-routes" event data and augment it to include +-- the route properties, as they were stored in the state file; +-- this is the last step before applying the routes +apply_route_props_hook = SimpleEventHook { + name = "device/apply-route-props", + after = { "device/find-stored-routes", "device/find-best-routes" }, + interests = { + EventInterest { + Constraint { "event.type", "=", "select-routes" }, + }, + }, + execute = function (event) + local device = event:get_subject () + local selected_routes = event:get_data ("selected-routes") or {} + local new_selected_routes = {} + + local dev_info = devinfo:get_device_info (device) + assert (dev_info) + + if next (selected_routes) == nil then + log:info (device, "No routes selected to set on " .. dev_info.name) + return + end + + for device_id, route in pairs (selected_routes) do + -- JSON to lua table + route = Json.Raw (route):parse () + + local route_info = devinfo.find_route_info (dev_info, route, false) + local props = getStoredRouteProps (dev_info, route_info) + + -- convert arrays to Json + if props.channelVolumes then + props.channelVolumes = Json.Array (props.channelVolumes) + end + if props.channelMap then + props.channelMap = Json.Array (props.channelMap) + end + if props.iec958Codecs then + props.iec958Codecs = Json.Array (props.iec958Codecs) + end + + local json = Json.Object { + index = route_info.index, + props = Json.Object (props), + } + new_selected_routes [device_id] = json:to_string () + end + + -- save the selected routes for the apply-routes hook + event:set_data ("selected-routes", new_selected_routes) + end +} + +store_or_restore_routes_hook = SimpleEventHook { + name = "device/store-or-restore-routes", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "Route" }, + }, + }, + execute = function (event) + local device = event:get_subject () + local source = event:get_source () + local selected_routes = {} + local push_select_routes = false + + local dev_info = devinfo:get_device_info (device) + if not dev_info then + return + end + + local new_route_infos = {} + + -- look at all the routes and update/reset cached information + for p in device:iterate_params ("EnumRoute") do + -- parse pod + local route = cutils.parseParam (p, "EnumRoute") + if not route then + goto skip_enum_route + end + + -- find cached route information + local route_info = devinfo.find_route_info (dev_info, route, true) + if not route_info then + goto skip_enum_route + end + + -- update properties + 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 + + -- update route_infos with new prev_active, active and save changes + 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 = cutils.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 = devinfo.find_route_info (dev_info, route, false) + if not route_info then + goto skip_route + end + + -- update route_info 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, + string.format ("new active route(%s) found of device(%s)", + route.name, dev_info.name)) + route_info.prev_active = true + route_info.active = true + + selected_routes [tostring (route.device)] = + Json.Object { index = route_info.index }:to_string () + push_select_routes = true + + elseif route.save and route.props then + -- just save route properties + log:info (device, + string.format ("storing route(%s) props of device(%s)", + route.name, dev_info.name)) + + saveRouteProps (dev_info, route) + end + + ::skip_route:: + end + + -- save selected routes for the active profile + for p in device:iterate_params ("Profile") do + local profile = cutils.parseParam (p, "Profile") + saveProfileRoutes (dev_info, profile.name) + end + + -- push a select-routes event to re-apply the routes with new properties + if push_select_routes then + local e = source:call ("create-event", "select-routes", device, nil) + e:set_data ("selected-routes", selected_routes) + EventDispatcher.push_event (e) + end + end +} + +function saveRouteProps (dev_info, route) + local props = route.props.properties + local key = dev_info.name .. ":" .. + route.direction:lower () .. ":" .. + route.name + + state_table [key] = Json.Object { + volume = props.volume, + mute = props.mute, + channelVolumes = props.channelVolumes and Json.Array (props.channelVolumes), + channelMap = props.channelMap and Json.Array (props.channelMap), + latencyOffsetNsec = props.latencyOffsetNsec, + iec958Codecs = props.iec958Codecs and Json.Array (props.iec958Codecs), + }:to_string () + + state:save_after_timeout (state_table) +end + +function getStoredRouteProps (dev_info, route) + local key = dev_info.name .. ":" .. + route.direction:lower () .. ":" .. + route.name + local value = state_table [key] + if value then + local json = Json.Raw (value) + if json and json:is_object () then + return json:parse () + end + end + return {} +end + +-- stores an array with the route names that are selected +-- for the given device and profile +function saveProfileRoutes (dev_info, profile_name) + -- select only routes with save == true + 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] = Json.Array (routes):to_string() + state:save_after_timeout (state_table) + end +end + +-- returns an array of the route names that were previously selected +-- for the given device and profile +function getStoredProfileRoutes (dev_info, profile_name) + local key = dev_info.name .. ":profile:" .. profile_name + local value = state_table [key] + if value then + local json = Json.Raw (value) + if json and json:is_array () then + return json:parse () + end + end + return {} +end + +function toggleState (enable) + if enable and not state then + state = State ("default-routes") + state_table = state:load () + find_stored_routes_hook:register () + apply_route_props_hook:register () + store_or_restore_routes_hook:register () + elseif not enable and state then + state = nil + state_table = nil + find_stored_routes_hook:remove () + apply_route_props_hook:remove () + store_or_restore_routes_hook:remove () + end +end + +Settings.subscribe ("device.restore-routes", function () + toggleState (Settings.get_boolean ("device.restore-routes")) +end) +toggleState (Settings.get_boolean ("device.restore-routes")) diff --git a/.config/wireplumber/scripts/fallback-sink.lua b/.config/wireplumber/scripts/fallback-sink.lua index d7c3cfa..cb8edc6 100644 --- a/.config/wireplumber/scripts/fallback-sink.lua +++ b/.config/wireplumber/scripts/fallback-sink.lua @@ -5,15 +5,15 @@ -- -- SPDX-License-Identifier: MIT -local sink_ids = {} -local fallback_node = nil +sink_ids = {} +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" }, + -- Do not consider virtual items created by WirePlumber + Constraint { "wireplumber.is-virtual", "!", true, type = "pw" }, -- or the fallback sink itself Constraint { "wireplumber.is-fallback", "!", true, type = "pw" }, } diff --git a/.config/wireplumber/scripts/intended-roles.lua b/.config/wireplumber/scripts/intended-roles.lua deleted file mode 100644 index f0d472b..0000000 --- a/.config/wireplumber/scripts/intended-roles.lua +++ /dev/null @@ -1,74 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Asymptotic --- @author Arun Raghavan --- --- 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() diff --git a/.config/wireplumber/scripts/lib/common-utils.lua b/.config/wireplumber/scripts/lib/common-utils.lua new file mode 100644 index 0000000..02fee30 --- /dev/null +++ b/.config/wireplumber/scripts/lib/common-utils.lua @@ -0,0 +1,94 @@ +-- WirePlumber + +-- Copyright © 2022 Collabora Ltd. +-- @author Ashok Sidipotu + +-- SPDX-License-Identifier: MIT + +-- Script is a Lua Module of common Lua utility functions + +local cutils = {} + +function cutils.parseBool (var) + return var and (var:lower () == "true" or var == "1") +end + +function cutils.parseParam (param, id) + local props = param:parse () + if props.pod_type == "Object" and props.object_id == id then + return props.properties + else + return nil + end +end + +function cutils.mediaClassToDirection (media_class) + if media_class:find ("Sink") or + media_class:find ("Input") or + media_class:find ("Duplex") then + return "input" + elseif media_class:find ("Source") or media_class:find ("Output") then + return "output" + else + return nil + end +end + +function cutils.getTargetDirection (properties) + local target_direction = nil + + if properties ["item.node.direction"] == "output" or + (properties ["item.node.direction"] == "input" and + cutils.parseBool (properties ["stream.capture.sink"])) then + target_direction = "input" + else + target_direction = "output" + end + return target_direction +end + +local default_nodes = Plugin.find ("default-nodes-api") + +function cutils.getDefaultNode (properties, target_direction) + local target_media_class = + properties ["media.type"] .. + (target_direction == "input" and "/Sink" or "/Source") + + if not default_nodes then + default_nodes = Plugin.find ("default-nodes-api") + end + + return default_nodes:call ("get-default-node", target_media_class) +end + +cutils.source_plugin = nil +cutils.object_managers = {} + +function cutils.get_object_manager (name) + cutils.source_plugin = cutils.source_plugin or + Plugin.find ("standard-event-source") + cutils.object_managers [name] = cutils.object_managers [name] or + cutils.source_plugin:call ("get-object-manager", name) + return cutils.object_managers [name] +end + +function cutils.get_default_metadata_object () + return cutils.get_object_manager ("metadata"):lookup { + Constraint { "metadata.name", "=", "default" }, + } +end + +function cutils.arrayContains (a, value) + for _, v in ipairs (a) do + if v == value then + return true + end + end + return false +end + +function cutils.get_application_name () + return Core.get_properties()["application.name"] or "WirePlumber" +end + +return cutils diff --git a/.config/wireplumber/scripts/lib/device-info-cache.lua b/.config/wireplumber/scripts/lib/device-info-cache.lua new file mode 100644 index 0000000..6e4cf10 --- /dev/null +++ b/.config/wireplumber/scripts/lib/device-info-cache.lua @@ -0,0 +1,74 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +local module = { + -- table of device info + dev_infos = {}, +} + +SimpleEventHook { + name = "lib/device-info-cache/cleanup", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-removed" }, + }, + }, + execute = function (event) + local props = event:get_properties () + local device_id = props ["object.serial"] + Log.trace ("cleaning up dev_info for object.serial = " .. device_id) + module.dev_infos [device_id] = nil + end +}:register() + +function module.get_device_info (self, device) + local device_properties = device.properties + local device_id = device_properties ["object.serial"] + local dev_info = self.dev_infos [device_id] + + -- new device + if not dev_info then + local device_name = device_properties ["device.name"] + if not device_name then + Log.critical (device, "invalid device.name") + return nil + end + + Log.trace (device, string.format ( + "create dev_info for '%s', object.serial = %s", device_name, device_id)) + + dev_info = { + name = device_name, + active_profile = -1, + route_infos = {}, + } + self.dev_infos [device_id] = dev_info + end + + return dev_info +end + +function module.find_route_info (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 + +return module diff --git a/.config/wireplumber/scripts/lib/filter-utils.lua b/.config/wireplumber/scripts/lib/filter-utils.lua new file mode 100644 index 0000000..3a63846 --- /dev/null +++ b/.config/wireplumber/scripts/lib/filter-utils.lua @@ -0,0 +1,501 @@ +-- WirePlumber + +-- Copyright © 2023 Collabora Ltd. +-- @author Julian Bouzas + +-- SPDX-License-Identifier: MIT + +-- Script is a Lua Module of filter Lua utility functions + +local cutils = require ("common-utils") + +local module = { + metadata = nil, + filters = {}, +} + +local function getFilterSmart (metadata, node) + -- Check metadata + if metadata ~= nil then + local id = node["bound-id"] + local value_str = metadata:find (id, "filter.smart") + if value_str ~= nil then + local json = Json.Raw (value_str) + if json:is_boolean() then + return json:parse() + end + end + end + + -- Check node properties + local prop_str = node.properties ["filter.smart"] + if prop_str ~= nil then + return cutils.parseBool (prop_str) + end + + -- Otherwise consider the filter not smart by default + return false +end + +local function getFilterSmartName (metadata, node) + -- Check metadata + if metadata ~= nil then + local id = node["bound-id"] + local value_str = metadata:find (id, "filter.smart.name") + if value_str ~= nil then + local json = Json.Raw (value_str) + if json:is_string() then + return json:parse() + end + end + end + + -- Check node properties + local prop_str = node.properties ["filter.smart.name"] + if prop_str ~= nil then + return prop_str + end + + -- Otherwise use link group as name + return node.properties ["node.link-group"] +end + +local function getFilterSmartDisabled (metadata, node) + -- Check metadata + if metadata ~= nil then + local id = node["bound-id"] + local value_str = metadata:find (id, "filter.smart.disabled") + if value_str ~= nil then + local json = Json.Raw (value_str) + if json:is_boolean() then + return json:parse() + end + end + end + + -- Check node properties + local prop_str = node.properties ["filter.smart.disabled"] + if prop_str ~= nil then + return cutils.parseBool (prop_str) + end + + -- Otherwise consider the filter not disabled by default + return false +end + +local function getFilterSmartTargetable (metadata, node) + -- Check metadata + if metadata ~= nil then + local id = node["bound-id"] + local value_str = metadata:find (id, "filter.smart.targetable") + if value_str ~= nil then + local json = Json.Raw (value_str) + if json:is_boolean() then + return json:parse() + end + end + end + + -- Check node properties + local prop_str = node.properties ["filter.smart.targetable"] + if prop_str ~= nil then + return cutils.parseBool (prop_str) + end + + -- Otherwise consider the filter not targetable by default + return false +end + +local function getFilterSmartTarget (metadata, node, om) + -- Check metadata and fallback to properties + local id = node["bound-id"] + local value_str = nil + if metadata ~= nil then + value_str = metadata:find (id, "filter.smart.target") + end + if value_str == nil then + value_str = node.properties ["filter.smart.target"] + if value_str == nil then + return nil + end + end + + -- Parse match rules + local match_rules_json = Json.Raw (value_str) + if not match_rules_json:is_object () then + return nil + end + local match_rules = match_rules_json:parse () + + -- Find target + local target = nil + for si_target in om:iterate { type = "SiLinkable" } do + local n_target = si_target:get_associated_proxy ("node") + if n_target == nil then + goto skip_target + end + + -- Target nodes cannot be smart filters + if n_target.properties ["node.link-group"] ~= nil and + getFilterSmart (metadata, n_target) then + goto skip_target + end + + -- Make sure the target node properties match all rules + for key, val in pairs(match_rules) do + if n_target.properties[key] ~= tostring (val) then + goto skip_target + end + end + + -- Target found + target = si_target + break; + + ::skip_target:: + end + + return target +end + +local function getFilterSmartTargetless (metadata, node) + local id = node["bound-id"] + local value_str = nil + if metadata ~= nil then + value_str = metadata:find (id, "filter.smart.target") + end + if value_str == nil then + value_str = node.properties ["filter.smart.target"] + end + + return value_str == nil +end + +local function getFilterSmartBefore (metadata, node) + -- Check metadata and fallback to properties + local id = node["bound-id"] + local value_str = nil + if metadata ~= nil then + value_str = metadata:find (id, "filter.smart.before") + end + if value_str == nil then + value_str = node.properties ["filter.smart.before"] + if value_str == nil then + return nil + end + end + + -- Parse + local before_json = Json.Raw (value_str) + if not before_json:is_array() then + return nil + end + return before_json:parse () +end + +local function getFilterSmartAfter (metadata, node) + -- Check metadata and fallback to properties + local id = node["bound-id"] + local value_str = nil + if metadata ~= nil then + value_str = metadata:find (id, "filter.smart.after") + end + if value_str == nil then + value_str = node.properties ["filter.smart.after"] + if value_str == nil then + return nil + end + end + + -- Parse + local after_json = Json.Raw (value_str) + if not after_json:is_array() then + return nil + end + return after_json:parse () +end + +local function insertFilterSorted (curr_filters, filter) + local before_filters = {} + local after_filters = {} + local new_filters = {} + + -- Check if the current filters need to be inserted before or after + for i, v in ipairs(curr_filters) do + local insert_before = true + local insert_after = false + + if v.before ~= nil then + for j, b in ipairs(v.before) do + if filter.name == b then + insert_after = false + break + end + end + end + + if v.after ~= nil then + for j, b in ipairs(v.after) do + if filter.name == b then + insert_before = false + break + end + end + end + + if filter.before ~= nil then + for j, b in ipairs(filter.before) do + if v.name == b then + insert_after = true + end + end + end + + if filter.after ~= nil then + for j, b in ipairs(filter.after) do + if v.name == b then + insert_before = true + end + end + end + + if insert_before then + if insert_after then + Log.warning ("cyclic before/after found in filters " .. v.name .. " and " .. filter.name) + end + table.insert (before_filters, v) + else + table.insert (after_filters, v) + end + + end + + -- Add the filters to the new table stored + for i, v in ipairs(before_filters) do + table.insert (new_filters, v) + end + table.insert (new_filters, filter) + for i, v in ipairs(after_filters) do + table.insert (new_filters, v) + end + + return new_filters +end + +local function rescanFilters (om, metadata_om) + local metadata = + metadata_om:lookup { Constraint { "metadata.name", "=", "filters" } } + + -- Always clear all filters data on rescan + module.filters = {} + + Log.info ("rescanning filters...") + + for si in om:iterate { type = "SiLinkable" } do + local filter = {} + + local n = si:get_associated_proxy ("node") + if n == nil then + goto skip_linkable + end + + -- Only handle nodes with link group (filters) + filter.link_group = n.properties ["node.link-group"] + if filter.link_group == nil then + goto skip_linkable + end + + -- Only handle the main filter nodes + filter.media_class = n.properties ["media.class"] + if string.find (filter.media_class, "Stream") then + goto skip_linkable + end + + -- Filter direction + if string.find (filter.media_class, "Audio/Sink") or + string.find (filter.media_class, "Video/Sink") then + filter.direction = "input" + else + filter.direction = "output" + end + + -- Filter media type + filter.media_type = si.properties["media.type"] + + -- Get filter properties + filter.smart = getFilterSmart (metadata, n) + filter.name = getFilterSmartName (metadata, n) + filter.disabled = getFilterSmartDisabled (metadata, n) + filter.targetable = getFilterSmartTargetable (metadata, n) + filter.target = getFilterSmartTarget (metadata, n, om) + filter.targetless = getFilterSmartTargetless (metadata, n) + filter.before = getFilterSmartBefore (metadata, n) + filter.after = getFilterSmartAfter (metadata, n) + + -- Add the main and stream session items + filter.main_si = si + filter.stream_si = om:lookup { + type = "SiLinkable", + Constraint { "node.link-group", "=", filter.link_group }, + Constraint { "media.class", "#", "Stream/*", type = "pw-global" } + } + + -- Add the filter to the list sorted by before and after + module.filters = insertFilterSorted (module.filters, filter) + + ::skip_linkable:: + end + +end + +SimpleEventHook { + name = "lib/filter-utils/rescan", + before = "linking/rescan", + interests = { + EventInterest { + Constraint { "event.type", "=", "rescan-for-linking" }, + }, + }, + execute = function (event) + local source = event:get_source () + local om = source:call ("get-object-manager", "session-item") + local metadata_om = source:call ("get-object-manager", "metadata") + + rescanFilters (om, metadata_om) + end +}:register () + +function module.is_filter_smart (direction, link_group) + -- Make sure direction and link_group is valid + if direction == nil or link_group == nil then + return false + end + + for i, v in ipairs(module.filters) do + if v.direction == direction and v.link_group == link_group then + return v.smart + end + end + + return false +end + +function module.is_filter_disabled (direction, link_group) + -- Make sure direction and link_group is valid + if direction == nil or link_group == nil then + return false + end + + for i, v in ipairs(module.filters) do + if v.direction == direction and v.link_group == link_group then + return v.disabled + end + end + + return false +end + +function module.is_filter_targetable (direction, link_group) + -- Make sure direction and link_group is valid + if direction == nil or link_group == nil then + return false + end + + for i, v in ipairs(module.filters) do + if v.direction == direction and v.link_group == link_group then + return v.targetable + end + end + + return false +end + +function module.get_filter_target (direction, link_group) + -- Make sure direction and link_group are valid + if direction == nil or link_group == nil then + return nil + end + + -- Find the current filter + local filter = nil + local index = nil + for i, v in ipairs(module.filters) do + if v.direction == direction and + v.link_group == link_group and + not v.disabled and + v.smart then + filter = v + index = i + break + end + end + if filter == nil then + return nil + end + + -- Return the next filter with matching target + for i, v in ipairs(module.filters) do + if v.direction == direction and + v.media_type == filter.media_type and + v.name ~= filter.name and + v.link_group ~= link_group and + not v.disabled and + v.smart and + ((v.target == nil and filter.target == nil) or + (v.target ~= nil and filter.target ~= nil and v.target.id == filter.target.id)) and + i > index then + return v.main_si + end + end + + -- Otherwise return the filter destination target + return filter.target +end + +function module.get_filter_from_target (direction, media_type, si_target) + local target = si_target + + -- Make sure direction and media_type are valid + if direction == nil or media_type == nil then + return nil + end + + -- If si_target is a filter, find it and use its target + if si_target then + local target_node = si_target:get_associated_proxy ("node") + local target_link_group = target_node.properties ["node.link-group"] + if target_link_group ~= nil then + local filter = nil + for i, v in ipairs(module.filters) do + if v.direction == direction and + v.media_type == media_type and + v.link_group == target_link_group and + not v.disabled and + v.smart then + filter = v + break + end + end + if filter == nil then + return nil + end + target = filter.target + end + end + + -- Find the first filter matching target + for i, v in ipairs(module.filters) do + if v.direction == direction and + v.media_type == media_type and + not v.disabled and + v.smart and + ((v.target ~= nil and target ~= nil and v.target.id == target.id) or + (target == nil and v.targetless)) then + return v.main_si + end + end + + return nil +end + +return module diff --git a/.config/wireplumber/scripts/lib/linking-utils.lua b/.config/wireplumber/scripts/lib/linking-utils.lua new file mode 100644 index 0000000..cfc924c --- /dev/null +++ b/.config/wireplumber/scripts/lib/linking-utils.lua @@ -0,0 +1,431 @@ +-- WirePlumber + +-- Copyright © 2022 Collabora Ltd. +-- @author Ashok Sidipotu + +-- SPDX-License-Identifier: MIT + +-- Script is a Lua Module of linking Lua utility functions + +local cutils = require ("common-utils") + +local lutils = { + si_flags = {}, + priority_media_role_link = {}, +} + +function lutils.get_flags (self, si_id) + if not self.si_flags [si_id] then + self.si_flags [si_id] = {} + end + + return self.si_flags [si_id] +end + +function lutils.clear_flags (self, si_id) + self.si_flags [si_id] = nil +end + +function getprio (link) + return tonumber (link.properties ["policy.role-based.priority"]) or 0 +end + +function getplugged (link) + return tonumber (link.properties ["item.plugged.usec"]) or 0 +end + +function lutils.getAction (pmrl, link) + local props = pmrl.properties + + if getprio (pmrl) == getprio (link) then + return props ["policy.role-based.action.same-priority"] or "mix" + else + return props ["policy.role-based.action.lower-priority"] or "mix" + end +end + +-- if the link happens to be priority one, clear it and find the next +-- priority. +function lutils.clearPriorityMediaRoleLink (link) + local lprops = link.properties + local lmc = lprops ["target.media.class"] + + pmrl = lutils.getPriorityMediaRoleLink (lmc) + + -- only proceed if the link happens to be priority one. + if pmrl ~= link then + return + end + + local prio_link = nil + local prio = 0 + local plugged = 0 + for l in cutils.get_object_manager ("session-item"):iterate { + type = "SiLink", + Constraint { "item.factory.name", "=", "si-standard-link", type = "pw-global" }, + Constraint { "is.role.policy.link", "=", true }, + Constraint { "target.media.class", "=", lmc }, + } do + local props = l.properties + + -- dont consider this link as it is about to be removed. + if pmrl == l then + goto continue + end + + if getprio (link) > prio or + (getprio (link) == prio and getplugged (link) > plugged) then + prio = getprio (l) + plugged = getplugged (l) + prio_link = l + end + ::continue:: + end + + if prio_link then + setPriorityMediaRoleLink (lmc, prio_link) + else + setPriorityMediaRoleLink (lmc, nil) + end +end + +-- record priority media role link +function lutils.updatePriorityMediaRoleLink (link) + local lprops = link.properties + local mc = lprops ["target.media.class"] + + if not lutils.priority_media_role_link [mc] then + setPriorityMediaRoleLink (mc, link) + return + end + + pmrl = lutils.getPriorityMediaRoleLink (mc) + + if getprio (link) > getprio (pmrl) or + (getprio (link) == getprio (pmrl) and getplugged (link) >= getplugged (pmrl)) then + setPriorityMediaRoleLink (mc, link) + end +end + +function lutils.getPriorityMediaRoleLink (lmc) + return lutils.priority_media_role_link [lmc] +end + +function setPriorityMediaRoleLink (lmc, link) + lutils.priority_media_role_link [lmc] = link + if link then + Log.debug ( + string.format ("update priority link(%d) media role(\"%s\") priority(%d)", + link.id, link.properties ["media.role"], getprio (link))) + else + Log.debug ("clear priority media role") + end +end + +function lutils.is_role_policy_target (si_props, target_props) + -- role-based policy links are those that link to targets with + -- policy.role-based.target = true, unless the stream is a monitor + -- (usually pavucontrol) or the stream is linking to the monitor ports + -- of a sink (both are "input") + return Core.test_feature ("hooks.linking.role-based.rescan") + and cutils.parseBool (target_props["policy.role-based.target"]) + and not cutils.parseBool (si_props ["stream.monitor"]) + and si_props["item.node.direction"] ~= target_props["item.node.direction"] +end + +function lutils.unwrap_select_target_event (self, event) + local source = event:get_source () + local si = event:get_subject () + local target = event:get_data ("target") + local om = source:call ("get-object-manager", "session-item") + local si_id = si.id + + return source, om, si, si.properties, self:get_flags (si_id), target +end + +function lutils.canPassthrough (si, si_target) + local props = si.properties + local tprops = si_target.properties + -- both nodes must support encoded formats + if not cutils.parseBool (props ["item.node.supports-encoded-fmts"]) + or not cutils.parseBool (tprops ["item.node.supports-encoded-fmts"]) then + return false + end + + -- make sure that the nodes have at least one common non-raw format + local n1 = si:get_associated_proxy ("node") + local n2 = si_target:get_associated_proxy ("node") + for p1 in n1:iterate_params ("EnumFormat") do + local p1p = p1:parse () + if p1p.properties.mediaSubtype ~= "raw" then + for p2 in n2:iterate_params ("EnumFormat") do + if p1:filter (p2) then + return true + end + end + end + end + return false +end + +function lutils.checkFollowDefault (si, si_target) + -- If it got linked to the default target that is defined by node + -- props but not metadata, start ignoring the node prop from now on. + -- This is what Pulseaudio does. + -- + -- Pulseaudio skips here filter streams (i->origin_sink and + -- o->destination_source set in PA). Pipewire does not have a flag + -- explicitly for this, but we can use presence of node.link-group. + local si_props = si.properties + local target_props = si_target.properties + local reconnect = not cutils.parseBool (si_props ["node.dont-reconnect"]) + local is_filter = (si_props ["node.link-group"] ~= nil) + + if reconnect and not is_filter then + local def_id = cutils.getDefaultNode (si_props, + cutils.getTargetDirection (si_props)) + + if target_props ["node.id"] == tostring (def_id) then + local metadata = cutils.get_default_metadata_object () + -- Set target.node, for backward compatibility + metadata:set (tonumber + (si_props ["node.id"]), "target.node", "Spa:Id", "-1") + Log.info (si, "... set metadata to follow default") + end + end +end + +function lutils.lookupLink (si_id, si_target_id) + local link = cutils.get_object_manager ("session-item"):lookup { + type = "SiLink", + Constraint { "out.item.id", "=", si_id }, + Constraint { "in.item.id", "=", si_target_id } + } + if not link then + link = cutils.get_object_manager ("session-item"):lookup { + type = "SiLink", + Constraint { "in.item.id", "=", si_id }, + Constraint { "out.item.id", "=", si_target_id } + } + end + return link +end + +function lutils.isLinked (si_target) + local target_id = si_target.id + local linked = false + local exclusive = false + + for l in cutils.get_object_manager ("session-item"):iterate { + type = "SiLink", + } do + local p = l.properties + local out_id = tonumber (p ["out.item.id"]) + local in_id = tonumber (p ["in.item.id"]) + linked = (out_id == target_id) or (in_id == target_id) + if linked then + exclusive = cutils.parseBool (p ["exclusive"]) or cutils.parseBool (p ["passthrough"]) + break + end + end + return linked, exclusive +end + +function lutils.getNodePeerId (node_id) + for l in cutils.get_object_manager ("link"):iterate() do + local p = l.properties + local in_id = tonumber(p["link.input.node"]) + local out_id = tonumber(p["link.output.node"]) + if in_id == node_id then + return out_id + elseif out_id == node_id then + return in_id + end + end + return nil +end + +function lutils.canLink (properties, si_target) + local target_props = si_target.properties + + -- nodes must have the same media type + if properties ["media.type"] ~= target_props ["media.type"] then + return false + end + + local function isMonitor(properties) + return properties ["item.node.direction"] == "input" and + cutils.parseBool (properties ["item.features.monitor"]) and + not cutils.parseBool (properties ["item.features.no-dsp"]) and + properties ["item.factory.name"] == "si-audio-adapter" + end + + -- nodes must have opposite direction, or otherwise they must be both input + -- and the target must have a monitor (so the target will be used as a source) + if properties ["item.node.direction"] == target_props ["item.node.direction"] + and not isMonitor (target_props) then + return false + end + + -- check link group + local function canLinkGroupCheck(link_group, si_target, hops) + local target_props = si_target.properties + local target_link_group = target_props ["node.link-group"] + + if hops == 8 then + return false + end + + -- allow linking if target has no link-group property + if not target_link_group then + return true + end + + -- do not allow linking if target has the same link-group + if link_group == target_link_group then + return false + end + + -- make sure target is not linked with another node with same link group + -- start by locating other nodes in the target's link-group, in opposite direction + for n in cutils.get_object_manager ("session-item"):iterate { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "id", "!", si_target.id, type = "gobject" }, + Constraint { "item.node.direction", "!", target_props ["item.node.direction"] }, + Constraint { "node.link-group", "=", target_link_group }, + } do + -- iterate their peers and return false if one of them cannot link + for silink in cutils.get_object_manager ("session-item"):iterate { + type = "SiLink", + } do + local out_id = tonumber (silink.properties ["out.item.id"]) + local in_id = tonumber (silink.properties ["in.item.id"]) + if out_id == n.id or in_id == n.id then + local peer_id = (out_id == n.id) and in_id or out_id + local peer = cutils.get_object_manager ("session-item"):lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "id", "=", peer_id, type = "gobject" }, + } + if peer and not canLinkGroupCheck (link_group, peer, hops + 1) then + return false + end + end + end + end + return true + end + + local link_group = properties ["node.link-group"] + if link_group then + return canLinkGroupCheck (link_group, si_target, 0) + end + return true +end + +function lutils.findDefaultLinkable (si) + local si_props = si.properties + local target_direction = cutils.getTargetDirection (si_props) + local def_node_id = cutils.getDefaultNode (si_props, target_direction) + return cutils.get_object_manager ("session-item"):lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "node.id", "=", tostring (def_node_id) } + } +end + +function lutils.checkPassthroughCompatibility (si, si_target) + local si_must_passthrough = + cutils.parseBool (si.properties ["item.node.encoded-only"]) + local si_target_must_passthrough = + cutils.parseBool (si_target.properties ["item.node.encoded-only"]) + local can_passthrough = lutils.canPassthrough (si, si_target) + if (si_must_passthrough or si_target_must_passthrough) + and not can_passthrough then + return false, can_passthrough + end + return true, can_passthrough +end + +-- Does the target device have any active/available paths/routes to +-- the physical device(spkr/mic/cam)? +function lutils.haveAvailableRoutes (si_props) + local card_profile_device = si_props ["card.profile.device"] + local device_id = si_props ["device.id"] + local device = device_id and cutils.get_object_manager ("device"):lookup { + Constraint { "bound-id", "=", device_id, type = "gobject" }, + } + + if not card_profile_device or not device then + return true + end + + local found = 0 + local avail = 0 + + -- First check "SPA_PARAM_Route" if there are any active devices + -- in an active profile. + for p in device:iterate_params ("Route") do + local route = cutils.parseParam (p, "Route") + if not route then + goto skip_route + end + + if (route.device ~= tonumber (card_profile_device)) then + goto skip_route + end + + if (route.available == "no") then + return false + end + + do return true end + + ::skip_route:: + end + + -- Second check "SPA_PARAM_EnumRoute" if there is any route that + -- is available if not active. + for p in device:iterate_params ("EnumRoute") do + local route = cutils.parseParam (p, "EnumRoute") + if not route then + goto skip_enum_route + end + + if not cutils.arrayContains + (route.devices, tonumber (card_profile_device)) then + goto skip_enum_route + end + found = found + 1; + if (route.available ~= "no") then + avail = avail + 1 + end + ::skip_enum_route:: + end + + if found == 0 then + return true + end + if avail > 0 then + return true + end + + return false +end + +function lutils.sendClientError (event, node, code, message) + local source = event:get_source () + local client_id = node.properties ["client.id"] + if client_id then + local clients_om = source:call ("get-object-manager", "client") + local client = clients_om:lookup { + Constraint { "bound-id", "=", client_id, type = "gobject" } + } + if client then + client:send_error (node ["bound-id"], code, message) + end + end +end + +return lutils diff --git a/.config/wireplumber/scripts/lib/monitor-utils.lua b/.config/wireplumber/scripts/lib/monitor-utils.lua new file mode 100644 index 0000000..455469d --- /dev/null +++ b/.config/wireplumber/scripts/lib/monitor-utils.lua @@ -0,0 +1,197 @@ +-- WirePlumber + +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu + +-- SPDX-License-Identifier: MIT + +-- Script is a Lua Module of monitor Lua utility functions + +log = Log.open_topic ("s-monitors-utils") + +local mutils = { + cam_data = {} +} + +-- finds out if any of the managed objects(nodes of a device or devices of +-- device enumerator) has duplicate values +function mutils.find_duplicate (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 get_cam_data(self, dev_id) + if not self.cam_data[dev_id] then + self.cam_data[dev_id] = {} + self.cam_data[dev_id]["libcamera"] = {} + self.cam_data[dev_id]["v4l2"] = {} + end + return self.cam_data[dev_id] +end + +function parse_devids_get_cam_data(self, devids) + local dev_ids_json = Json.Raw(devids) + local dev_ids_table = {} + + if dev_ids_json:is_array() then + dev_ids_table = dev_ids_json:parse() + else + -- to maintain the backward compatibility with earlier pipewire versions. + for dev_id_str in devids:gmatch("%S+") do + local dev_id = tonumber(dev_id_str) + if dev_id then + table.insert(dev_ids_table, dev_id) + end + end + end + + local dev_num = nil + -- `device.devids` is a json array of device numbers + for _, dev_id_str in ipairs(dev_ids_table) do + local dev_id = tonumber(dev_id_str) + if not dev_id then + log:notice ("invalid device number") + return + end + + log:debug ("Working on device " .. dev_id) + local dev_cam_data = get_cam_data (self, dev_id) + if not dev_num then + dev_num = dev_id + if #dev_ids_table > 1 then + -- libcam node can some times use more tha one V4L2 devices, in this + -- case, return the first device id and mark rest of the them as peers + -- to the first one. + log:debug ("Device " .. dev_id .. " uses multi V4L2 devices") + dev_cam_data.uses_multi_v4l2_devices = true + end + else + log:debug ("Device " .. dev_id .. " is peer to " .. dev_num) + dev_cam_data.peer_id = dev_num + end + end + + if dev_num then + return self.cam_data[dev_num], dev_num + end +end + +function mutils.clear_cam_data (self, dev_num) + local dev_cam_data = self.cam_data[dev_num] + if not dev_cam_data then + return + end + + if dev_cam_data.uses_multi_v4l2_devices then + for dev_id, cam_data_ in pairs(self.cam_data) do + if cam_data_.peer_id == dev_num then + log:debug("clear " .. dev_id .. " it is peer to " .. dev_num) + self.cam_data[dev_id] = nil + end + end + end + + self.cam_data[dev_num] = nil +end + +function mutils.create_cam_node(self, dev_num) + local api = nil + local cam_data = get_cam_data (self, dev_num) + + if cam_data["v4l2"].enum_status and cam_data["libcamera"].enum_status then + if cam_data.uses_multi_v4l2_devices then + api = "libcamera" + elseif cam_data.peer_id ~= nil then + -- no need to create node for peer + log:notice ("timer expired for peer device " .. dev_num) + return + elseif cam_data.is_device_uvc then + api = "v4l2" + else + api = "libcamera" + end + else + api = cam_data["v4l2"].enum_status and "v4l2" or "libcamera" + end + + log:info (string.format ("create \"%s\" node for device:%s", api, + cam_data.dev_path)) + + source = source or Plugin.find ("standard-event-source") + local e = source:call ("create-event", "create-" .. api .. "-device-node", + cam_data[api].parent, nil) + e:set_data ("factory", cam_data[api].factory) + e:set_data ("node-properties", cam_data[api].properties) + e:set_data ("node-sub-id", cam_data[api].id) + + EventDispatcher.push_event (e) + + self:clear_cam_data (dev_num) +end + +-- arbitrates between v4l2 and libcamera on who gets to create the device node +-- for a device, logic is based on the device number of the device given by both +-- the parties. +function mutils.register_cam_node (self, parent, id, factory, properties) + local api = properties["device.api"] + local dev_ids = properties["device.devids"] + log:debug(api .. " reported " .. dev_ids) + + local cam_data, dev_num = parse_devids_get_cam_data(self, dev_ids) + + if not cam_data then + log:notice (string.format ("device numbers invalid for %s device:%s", + api, properties["device.name"])) + return false + end + + -- only v4l2 can give this info + if properties["api.v4l2.cap.driver"] == "uvcvideo" then + log:debug ("Device " .. dev_num .. " is a UVC device") + cam_data.is_device_uvc = true + end + + -- only v4l2 can give this info + if properties["api.v4l2.path"] then + cam_data.dev_path = properties["api.v4l2.path"] + end + + local cam_api_data = cam_data[api] + cam_api_data.enum_status = true + + -- cache info, it comes handy when creating node + cam_api_data.parent = parent + cam_api_data.id = id + cam_api_data.name = properties["device.name"] + cam_api_data.factory = factory + cam_api_data.properties = properties + + local other_api = api == "v4l2" and "libcamera" or "v4l2" + if cam_api_data.enum_status and not cam_data[other_api].enum_status then + log:trace (string.format ("\"%s\" armed a timer for %d", api, dev_num)) + cam_data.source = Core.timeout_add ( + Settings.get_int ("monitor.camera-discovery-timeout"), function() + log:trace (string.format ("\"%s\" armed timer expired for %d", api, dev_num)) + self:create_cam_node (dev_num) + cam_data.source = nil + end) + elseif cam_data.source then + log:trace (string.format ("\"%s\" disarmed timer for %d", api, dev_num)) + cam_data.source:destroy () + cam_data.source = nil + self:create_cam_node (dev_num) + else + log:notice (string.format ("\"%s\" calling after timer expiry for %d:%s%s", + api, dev_num, cam_data.dev_path, + (cam_data.is_device_uvc and "(uvc)" or ""))) + end + + return true +end + +return mutils diff --git a/.config/wireplumber/scripts/lib/node-utils.lua b/.config/wireplumber/scripts/lib/node-utils.lua new file mode 100644 index 0000000..38b6e6b --- /dev/null +++ b/.config/wireplumber/scripts/lib/node-utils.lua @@ -0,0 +1,18 @@ +-- WirePlumber +-- +-- Copyright © 2024 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT + +local module = {} + +function module.get_session_priority (node_props) + local priority = node_props ["priority.session"] + -- fallback to driver priority if session priority is not set + if not priority then + priority = node_props ["priority.driver"] + end + return math.tointeger (priority) or 0 +end + +return module diff --git a/.config/wireplumber/scripts/linking/find-best-target.lua b/.config/wireplumber/scripts/linking/find-best-target.lua new file mode 100644 index 0000000..adaa63c --- /dev/null +++ b/.config/wireplumber/scripts/linking/find-best-target.lua @@ -0,0 +1,115 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Traverse through all the possible targets to pick up target node. + +lutils = require ("linking-utils") +cutils = require ("common-utils") +futils = require ("filter-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/find-best-target", + after = { "linking/find-defined-target", + "linking/find-filter-target", + "linking/find-media-role-target", + "linking/find-default-target" }, + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- bypass the hook if the target is already picked up + if target then + return + end + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + local target_priority = 0 + local target_plugged = 0 + + log:info (si, string.format ("handling item: %s (%s)", + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]))) + + for target in om:iterate { + type = "SiLinkable", + Constraint { "item.node.type", "=", "device" }, + Constraint { "item.node.direction", "=", target_direction }, + Constraint { "media.type", "=", si_props ["media.type"] }, + } do + local target_props = target.properties + local target_node_id = target_props ["node.id"] + local si_target_node = target:get_associated_proxy ("node") + local si_target_link_group = si_target_node.properties ["node.link-group"] + local priority = tonumber (target_props ["priority.session"]) or 0 + + log:debug (string.format ("Looking at: %s (%s)", + tostring (target_props ["node.name"]), + tostring (target_node_id))) + + -- Skip smart filters as best target + if si_target_link_group ~= nil and + futils.is_filter_smart (target_direction, si_target_link_group) then + Log.debug ("... ignoring smart filter as best target") + goto skip_linkable + end + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, skip linkable") + goto skip_linkable + end + + if not lutils.haveAvailableRoutes (target_props) then + log:debug ("... does not have routes, skip linkable") + goto skip_linkable + end + + local passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if not passthrough_compatible then + log:debug ("... passthrough is not compatible, skip linkable") + goto skip_linkable + end + + local plugged = tonumber (target_props ["item.plugged.usec"]) or 0 + + log:debug ("... priority:" .. tostring (priority) .. ", plugged:" .. tostring (plugged)) + + -- (target_picked == NULL) --> make sure atleast one target is picked. + -- (priority > target_priority) --> pick the highest priority linkable(node) + -- target. + -- (priority == target_priority and plugged > target_plugged) --> pick the + -- latest connected/plugged(in time) linkable(node) target. + if (target_picked == nil or + priority > target_priority or + (priority == target_priority and plugged > target_plugged)) then + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + target_priority = priority + target_plugged = plugged + end + ::skip_linkable:: + end + + if target_picked then + log:info (si, + string.format ("... best target picked: %s (%s), can_passthrough:%s", + tostring (target_picked.properties ["node.name"]), + tostring (target_picked.properties ["node.id"]), + tostring (target_can_passthrough))) + si_flags.can_passthrough = target_can_passthrough + event:set_data ("target", target_picked) + end + end +}:register () diff --git a/.config/wireplumber/scripts/linking/find-default-target.lua b/.config/wireplumber/scripts/linking/find-default-target.lua new file mode 100644 index 0000000..f3c455f --- /dev/null +++ b/.config/wireplumber/scripts/linking/find-default-target.lua @@ -0,0 +1,58 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Check if default nodes can be picked up as target node. + +lutils = require ("linking-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/find-default-target", + after = { "linking/find-defined-target", + "linking/find-filter-target", + "linking/find-media-role-target" }, + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- bypass the hook if the target is already picked up + if target then + return + end + + local target_picked = false + + log:info (si, string.format ("handling item: %s (%s)", + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]))) + + target = lutils.findDefaultLinkable (si) + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true; + end + end + + if target_picked then + log:info (si, + string.format ("... default target picked: %s (%s), can_passthrough:%s", + tostring (target.properties ["node.name"]), + tostring (target.properties ["node.id"]), + tostring (can_passthrough))) + si_flags.can_passthrough = can_passthrough + event:set_data ("target", target) + end + end +}:register () diff --git a/.config/wireplumber/scripts/linking/find-defined-target.lua b/.config/wireplumber/scripts/linking/find-defined-target.lua new file mode 100644 index 0000000..f19124f --- /dev/null +++ b/.config/wireplumber/scripts/linking/find-defined-target.lua @@ -0,0 +1,131 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Check if the target node is defined explicitly. +-- This defination can be done in two ways. +-- 1. "node.target"/"target.object" in the node properties +-- 2. "target.node"/"target.object" in the default metadata + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/find-defined-target", + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- bypass the hook if the target is already picked up + if target then + return + end + + log:info (si, string.format ("handling item %d: %s (%s)", si.id, + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]))) + + local metadata = Settings.get_boolean ("linking.allow-moving-streams") and + cutils.get_default_metadata_object () + local dont_fallback = cutils.parseBool (si_props ["node.dont-fallback"]) + local dont_move = cutils.parseBool (si_props ["node.dont-move"]) + local target_key + local target_value = nil + local node_defined = false + local target_picked = nil + + if si_props ["target.object"] ~= nil then + target_value = si_props ["target.object"] + target_key = "object.serial" + node_defined = true + elseif si_props ["node.target"] ~= nil then + target_value = si_props ["node.target"] + target_key = "node.id" + node_defined = true + end + + if metadata and not dont_move then + local id = metadata:find (si_props ["node.id"], "target.object") + if id ~= nil then + target_value = id + target_key = "object.serial" + node_defined = false + else + id = metadata:find (si_props ["node.id"], "target.node") + if id ~= nil then + target_value = id + target_key = "node.id" + node_defined = false + end + end + end + + if target_value == "-1" then + target_picked = false + target = nil + elseif target_value and tonumber (target_value) then + target = om:lookup { + type = "SiLinkable", + Constraint { target_key, "=", target_value }, + } + if target and lutils.canLink (si_props, target) then + target_picked = true + end + elseif target_value then + for lnkbl in om:iterate { type = "SiLinkable" } do + local target_props = lnkbl.properties + if (target_props ["node.name"] == target_value or + target_props ["object.path"] == target_value) and + target_props ["item.node.direction"] == cutils.getTargetDirection (si_props) and + lutils.canLink (si_props, lnkbl) then + target_picked = true + target = lnkbl + break + end + end + end + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if not passthrough_compatible then + target = nil + end + end + + si_flags.has_defined_target = false + if target_picked and target then + log:info (si, + string.format ("... defined target picked: %s (%s), can_passthrough:%s", + tostring (target.properties ["node.name"]), + tostring (target.properties ["node.id"]), + tostring (can_passthrough))) + si_flags.has_node_defined_target = node_defined + si_flags.can_passthrough = can_passthrough + si_flags.has_defined_target = true + event:set_data ("target", target) + elseif target_value and dont_fallback then + -- send error to client and destroy node if linger is not set + local linger = cutils.parseBool (si_props ["node.linger"]) + if not linger then + local node = si:get_associated_proxy ("node") + lutils.sendClientError (event, node, -2, "defined target not found") + node:request_destroy () + log:info(si, "... destroyed node as defined target was not found") + else + log:info(si, "... waiting for defined target as dont-fallback is set") + end + event:stop_processing () + end + + end +}:register () diff --git a/.config/wireplumber/scripts/linking/find-filter-target.lua b/.config/wireplumber/scripts/linking/find-filter-target.lua new file mode 100644 index 0000000..02d5c2b --- /dev/null +++ b/.config/wireplumber/scripts/linking/find-filter-target.lua @@ -0,0 +1,92 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Check if the target node is a filter target. + +lutils = require ("linking-utils") +cutils = require ("common-utils") +futils = require ("filter-utils") +log = Log.open_topic ("s-linking") + +function findFilterTarget (si, om) + local node = si:get_associated_proxy ("node") + local link_group = node.properties ["node.link-group"] + local target_id = -1 + + -- return nil if session item is not a filter node + if link_group == nil then + return nil, false + end + + -- return nil if filter is not smart + local direction = cutils.getTargetDirection (si.properties) + if not futils.is_filter_smart (direction, link_group) then + return nil, false + end + + -- get the filter target + return futils.get_filter_target (direction, link_group), true +end + +SimpleEventHook { + name = "linking/find-filter-target", + after = "linking/find-defined-target", + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- bypass the hook if the target is already picked up + if target then + return + end + + local dont_fallback = cutils.parseBool (si_props ["node.dont-fallback"]) + local target_picked = false + local allow_fallback + + log:info (si, string.format ("handling item %d: %s (%s)", si.id, + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]))) + + target, is_smart_filter = findFilterTarget (si, om) + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true + end + end + + if target_picked and target then + log:info (si, + string.format ("... filter target picked: %s (%s), can_passthrough:%s", + tostring (target.properties ["node.name"]), + tostring (target.properties ["node.id"]), + tostring (can_passthrough))) + si_flags.can_passthrough = can_passthrough + event:set_data ("target", target) + elseif is_smart_filter and dont_fallback then + -- send error to client and destroy node if linger is not set + local linger = cutils.parseBool (si_props ["node.linger"]) + if not linger then + local node = si:get_associated_proxy ("node") + lutils.sendClientError (event, node, -2, "smart filter defined target not found") + node:request_destroy () + log:info(si, "... destroyed node as smart filter defined target was not found") + else + log:info(si, "... waiting for smart filter defined target as dont-fallback is set") + end + event:stop_processing () + end + end +}:register () diff --git a/.config/wireplumber/scripts/linking/find-media-role-target.lua b/.config/wireplumber/scripts/linking/find-media-role-target.lua new file mode 100644 index 0000000..d654b0c --- /dev/null +++ b/.config/wireplumber/scripts/linking/find-media-role-target.lua @@ -0,0 +1,70 @@ +-- WirePlumber +-- +-- Copyright © 2024 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Select the media role target + +cutils = require("common-utils") +lutils = require("linking-utils") +log = Log.open_topic("s-linking") + +SimpleEventHook { + name = "linking/find-media-role-target", + after = { "linking/find-defined-target", + "linking/find-filter-target" }, + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local _, om, si, si_props, _, target = + lutils:unwrap_select_target_event (event) + + local target_direction = cutils.getTargetDirection (si_props) + local media_role = si_props["media.role"] + + -- bypass the hook if the target is already picked up or if the role is not + -- defined + if target or media_role == nil then + return + end + + log:info (si, string.format ("handling item %d: %s (%s) role (%s)", si.id, + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]), media_role)) + + for si_target in om:iterate { + type = "SiLinkable", + Constraint { "item.node.direction", "=", target_direction }, + Constraint { "device.intended-roles", "+" }, + Constraint { "media.type", "=", si_props["media.type"] }, + } do + + local roles_json = si_target.properties["device.intended-roles"] + local roles_table = Json.Raw(roles_json):parse() + + for _, target_role in ipairs(roles_table) do + if target_role == media_role then + target = si_target + break + end + end + + if target then + break + end + end + + -- set target + if target ~= nil then + log:info(si, + string.format("... media role target picked: %s (%s)", + tostring(target.properties["node.name"]), + tostring(target.properties["node.id"]))) + event:set_data("target", target) + end + end +}:register() diff --git a/.config/wireplumber/scripts/linking/find-user-target.lua.example b/.config/wireplumber/scripts/linking/find-user-target.lua.example new file mode 100644 index 0000000..723913c --- /dev/null +++ b/.config/wireplumber/scripts/linking/find-user-target.lua.example @@ -0,0 +1,37 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- example of a user injectible hook to link a node to a custom target + +lutils = require ("linking-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/sample-find-user-target", + before = "linking/find-defined-target", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- bypass the hook if the target is already picked up + if target then + return + end + + log:info (si, "in find-user-target") + + -- implement logic here to find a suitable target + + -- store the found target on the event, + -- the next hooks will take care of linking + event:set_data ("target", target) + end +}:register () diff --git a/.config/wireplumber/scripts/linking/get-filter-from-target.lua b/.config/wireplumber/scripts/linking/get-filter-from-target.lua new file mode 100644 index 0000000..f216ec1 --- /dev/null +++ b/.config/wireplumber/scripts/linking/get-filter-from-target.lua @@ -0,0 +1,92 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Check if the target node is a filter target. + +lutils = require ("linking-utils") +cutils = require ("common-utils") +futils = require ("filter-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/get-filter-from-target", + after = { "linking/find-defined-target", + "linking/find-filter-target", + "linking/find-media-role-target", + "linking/find-default-target", + "linking/find-best-target" }, + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- bypass the hook if the target was not found or if it is a role-based policy target + if target == nil or lutils.is_role_policy_target (si_props, target.properties) then + return + end + + -- bypass the hook if the session item is a smart filter + local node = si:get_associated_proxy ("node") + local node_props = node.properties + local link_group = node_props ["node.link-group"] + local target_direction = cutils.getTargetDirection (si.properties) + if link_group ~= nil and + futils.is_filter_smart (target_direction, link_group) then + return + end + + -- bypass the hook if target is defined, is a filter and is targetable + local target_node = target:get_associated_proxy ("node") + local target_node_props = target_node.properties + local target_link_group = target_node_props ["node.link-group"] + if target_link_group ~= nil and si_flags.has_defined_target then + if futils.is_filter_smart (target_direction, target_link_group) and + not futils.is_filter_disabled (target_direction, target_link_group) and + futils.is_filter_targetable (target_direction, target_link_group) then + return + end + end + + -- Get the filter from the given target if it exists, otherwise get the + -- default filter, but only if target was not defined + local media_type = si_props["media.type"] + local filter_target = futils.get_filter_from_target (target_direction, media_type, target) + if filter_target ~= nil then + target = filter_target + log:info (si, "... got filter for given target") + elseif filter_target == nil and not si_flags.has_defined_target then + filter_target = futils.get_filter_from_target (target_direction, media_type, nil) + if filter_target ~= nil then + target = filter_target + log:info (si, "... got default filter for given target") + end + end + + local can_passthrough, passthrough_compatible + if target ~= nil then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true; + end + end + + if target_picked then + log:info (si, + string.format ("... target picked: %s (%s), can_passthrough:%s", + tostring (target.properties ["node.name"]), + tostring (target.properties ["node.id"]), + tostring (can_passthrough))) + si_flags.can_passthrough = can_passthrough + event:set_data ("target", target) + end + end +}:register () diff --git a/.config/wireplumber/scripts/linking/link-target.lua b/.config/wireplumber/scripts/linking/link-target.lua new file mode 100644 index 0000000..683bf78 --- /dev/null +++ b/.config/wireplumber/scripts/linking/link-target.lua @@ -0,0 +1,157 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Links a session item to the target that has been previously selected. +-- This is meant to be the last hook in the select-target chain. + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +AsyncEventHook { + name = "linking/link-target", + after = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + if not target then + -- bypass the hook, nothing to link to. + transition:advance () + return + end + + local target_props = target.properties + local out_item = nil + local in_item = nil + local si_link = nil + local passthrough = si_flags.can_passthrough + + log:info (si, string.format ("handling item %d: %s (%s)", si.id, + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]))) + + local exclusive = cutils.parseBool (si_props ["node.exclusive"]) + + -- break rescan if tried more than 5 times with same target + if si_flags.failed_peer_id ~= nil and + si_flags.failed_peer_id == target.id and + si_flags.failed_count ~= nil and + si_flags.failed_count > 5 then + transition:return_error ("tried to link on last rescan, not retrying " + .. tostring (si_link)) + return + end + + if si_props["item.node.direction"] == "output" then + -- playback + out_item = si + in_item = target + else + -- capture + in_item = si + out_item = target + end + + local is_role_policy_link = lutils.is_role_policy_target (si_props, target_props) + + log:info (si, + string.format ("link %s <-> %s passthrough:%s, exclusive:%s, media role link:%s", + tostring (si_props ["node.name"]), + tostring (target_props ["node.name"]), + tostring (passthrough), + tostring (exclusive), + tostring (is_role_policy_link))) + + -- create and configure link + si_link = SessionItem ("si-standard-link") + if not si_link:configure { + ["out.item"] = out_item, + ["in.item"] = in_item, + ["passthrough"] = passthrough, + ["exclusive"] = exclusive, + ["out.item.port.context"] = "output", + ["in.item.port.context"] = "input", + ["media.role"] = si_props["media.role"], + ["target.media.class"] = target_props["media.class"], + ["policy.role-based.priority"] = target_props["policy.role-based.priority"], + ["policy.role-based.action.same-priority"] = target_props["policy.role-based.action.same-priority"], + ["policy.role-based.action.lower-priority"] = target_props["policy.role-based.action.lower-priority"], + ["is.role.policy.link"] = is_role_policy_link, + ["main.item.id"] = si.id, + ["target.item.id"] = target.id, + } then + transition:return_error ("failed to configure si-standard-link " + .. tostring (si_link)) + return + end + + local ids = {si.id, target.id} + si_link:connect("link-error", function (_, error_msg) + for _, id in ipairs (ids) do + local si = om:lookup { + Constraint { "id", "=", id, type = "gobject" }, + } + if si then + local node = si:get_associated_proxy ("node") + lutils.sendClientError(event, node, -32, error_msg) + end + end + end) + + -- register + si_flags.was_handled = true + si_flags.peer_id = target.id + si_flags.failed_peer_id = target.id + if si_flags.failed_count ~= nil then + si_flags.failed_count = si_flags.failed_count + 1 + else + si_flags.failed_count = 1 + end + si_link:register () + + log:debug (si_link, "registered link between " + .. tostring (si) .. " and " .. tostring (target)) + + -- only activate non role-based policy links because their activation is + -- handled by rescan-media-role-links.lua + if not is_role_policy_link then + si_link:activate (Feature.SessionItem.ACTIVE, function (l, e) + if e then + transition:return_error (tostring (l) .. " link failed: " + .. tostring (e)) + if si_flags ~= nil then + si_flags.peer_id = nil + end + l:remove () + else + si_flags.failed_peer_id = nil + if si_flags.peer_id == nil then + si_flags.peer_id = target.id + end + si_flags.failed_count = 0 + + log:debug (l, "activated link between " + .. tostring (si) .. " and " .. tostring (target)) + + transition:advance () + end + end) + else + lutils.updatePriorityMediaRoleLink(si_link) + transition:advance () + end + end, + }, + }, +}:register () diff --git a/.config/wireplumber/scripts/linking/prepare-link.lua b/.config/wireplumber/scripts/linking/prepare-link.lua new file mode 100644 index 0000000..69da939 --- /dev/null +++ b/.config/wireplumber/scripts/linking/prepare-link.lua @@ -0,0 +1,125 @@ +-- WirePlumber +-- +-- Copyright © 2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- remove the existing link if needed, check the properties of target, which +-- indicate it is not available for linking. If no target is available, send +-- down an error to the corresponding client. + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + }, + }, + execute = function (event) + local source, _, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + local si_id = si.id + local reconnect = not cutils.parseBool (si_props ["node.dont-reconnect"]) + local exclusive = cutils.parseBool (si_props ["node.exclusive"]) + local si_must_passthrough = cutils.parseBool (si_props ["item.node.encoded-only"]) + + log:info (si, string.format ("handling item %d: %s (%s)", si_id, + tostring (si_props ["node.name"]), tostring (si_props ["node.id"]))) + + -- Check if item is linked to proper target, otherwise re-link + if si_flags.peer_id then + if target and si_flags.peer_id == target.id then + log:info (si, "... already linked to proper target") + + -- Check this also here, in case in default targets changed + if Settings.get_boolean ("linking.follow-default-target") and + si_flags.has_node_defined_target then + lutils.checkFollowDefault (si, target) + end + + target = nil + goto done + end + + local link = lutils.lookupLink (si_id, si_flags.peer_id) + if reconnect then + if link ~= nil then + -- remove old link + if ((link:get_active_features () & Feature.SessionItem.ACTIVE) == 0) + then + -- remove also not yet activated links: they might never become + -- active, and we need not wait for it to become active + log:warning (link, "Link was not activated before removing") + end + si_flags.peer_id = nil + link:remove () + log:info (si, "... moving to new target") + end + else + if link ~= nil then + log:info (si, "... dont-reconnect, not moving") + goto done + end + end + end + + -- if the stream has dont-reconnect and was already linked before, + -- don't link it to a new target + if not reconnect and si_flags.was_handled then + target = nil + goto done + end + + -- check target's availability + if target then + local target_is_linked, target_is_exclusive = lutils.isLinked (target) + if target_is_exclusive then + log:info (si, "... target is linked exclusively") + target = nil + end + + if target_is_linked then + if exclusive or si_must_passthrough then + log:info (si, "... target is already linked, cannot link exclusively") + target = nil + else + -- disable passthrough, we can live without it + si_flags.can_passthrough = false + end + end + end + + if not target then + log:info (si, "... target not found, reconnect:" .. tostring (reconnect)) + + local node = si:get_associated_proxy ("node") + if reconnect and si_flags.was_handled then + log:info (si, "... waiting reconnect") + return + end + + local linger = cutils.parseBool (si_props ["node.linger"]) + + if linger then + log:info (si, "... node linger") + return + end + + lutils.sendClientError (event, node, -2, + reconnect and "no target node available" or "target not found") + + if not reconnect then + log:info (si, "... destroy node") + node:request_destroy () + end + end + + ::done:: + event:set_data ("target", target) + end +}:register () diff --git a/.config/wireplumber/scripts/linking/rescan-media-role-links.lua b/.config/wireplumber/scripts/linking/rescan-media-role-links.lua new file mode 100644 index 0000000..34ef4e0 --- /dev/null +++ b/.config/wireplumber/scripts/linking/rescan-media-role-links.lua @@ -0,0 +1,203 @@ +-- WirePlumber +-- +-- Copyright © 2024 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +lutils = require("linking-utils") +cutils = require("common-utils") +log = Log.open_topic("s-linking") + +function restoreVolume (om, link) + setVolume(om, link, 1.0) +end + +function duckVolume (om, link) + setVolume(om, link, Settings.get_float("linking.role-based.duck-level")) +end + +function setVolume (om, link, level) + local lprops = link.properties + local media_role_si_id = nil + local dir = lprops ["item.node.direction"] + + if dir == "output" then + media_role_si_id = lprops ["out.item.id"] + else + media_role_si_id = lprops ["in.item.id"] + end + + local media_role_lnkbl = om:lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "id", "=", media_role_si_id, type = "gobject" }, + } + + -- apply volume control on the stream node of the loopback module, instead of + -- the sink/source node as it simplyfies the volume ducking and + -- restoration. + local media_role_other_lnkbl = om:lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "node.link-group", "=", media_role_lnkbl.properties ["node.link-group"] }, + Constraint { "id", "!", media_role_lnkbl.id, type = "gobject" }, + } + + if media_role_other_lnkbl then + local n = media_role_other_lnkbl:get_associated_proxy("node") + if n then + log:info(string.format(".. %s volume of media role node \"%s(%d)\" to %f", + level < 1.0 and "duck" or "restore", n.properties ["node.name"], + n ["bound-id"], level)) + + local props = { + "Spa:Pod:Object:Param:Props", + "Props", + volume = level, + } + + local param = Pod.Object(props) + n:set_param("Props", param) + end + end +end + +function getSuspendPlaybackFromMetadata (om) + local suspend = false + local metadata = om:lookup { + type = "metadata", + Constraint { "metadata.name", "=", "default" }, + } + 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 + +AsyncEventHook { + name = "linking/rescan-media-role-links", + interests = { + EventInterest { + -- on media client link added and removed + Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, + Constraint { "event.session-item.interface", "=", "link" }, + Constraint { "is.role.policy.link", "=", true }, + }, + EventInterest { + -- on default metadata suspend.playback changed + Constraint { "event.type", "=", "metadata-changed" }, + Constraint { "metadata.name", "=", "default" }, + Constraint { "event.subject.key", "=", "suspend.playback" }, + } + }, + steps = { + start = { + next = "none", + execute = function(event, transition) + local source, om, _, si_props, _, _ = + lutils:unwrap_select_target_event(event) + + local metadata_om = source:call("get-object-manager", "metadata") + local suspend = getSuspendPlaybackFromMetadata(metadata_om) + local pending_activations = 0 + local mc = si_props ["target.media.class"] + local pmrl_active = nil + pmrl = lutils.getPriorityMediaRoleLink(mc) + + log:debug("Rescanning media role links...") + + local function onMediaRoleLinkActivated (l, e) + local si_id = tonumber(l.properties ["main.item.id"]) + local target_id = tonumber(l.properties ["target.item.id"]) + local si_flags = lutils:get_flags(si_id) + + if e then + log:warning(l, "failed to activate media role link: " .. e) + if si_flags ~= nil then + si_flags.peer_id = nil + end + l:remove() + else + log:info(l, "media role link activated successfully") + si_flags.failed_peer_id = nil + if si_flags.peer_id == nil then + si_flags.peer_id = target_id + end + si_flags.failed_count = 0 + end + + -- advance only when all pending activations are completed + pending_activations = pending_activations - 1 + if pending_activations <= 0 then + log:info("All media role links activated") + transition:advance() + end + end + + for link in om:iterate { + type = "SiLink", + Constraint { "is.role.policy.link", "=", true }, + Constraint { "target.media.class", "=", mc }, + } do + -- deactivate all links if suspend playback metadata is present + if suspend then + link:deactivate(Feature.SessionItem.ACTIVE) + end + + local active = ((link:get_active_features() & Feature.SessionItem.ACTIVE) ~= 0) + + log:debug(string.format(" .. looking at link(%d) active %s pmrl %s", link.id, tostring(active), + tostring(link == pmrl))) + + if link == pmrl then + pmrl_active = active + restoreVolume(om, pmrl) + goto continue + end + + local action = lutils.getAction(pmrl, link) + + log:debug(string.format(" .. apply action(%s) on link(%d)", action, link.id, tostring(active))) + + if action == "cork" then + if active then + link:deactivate(Feature.SessionItem.ACTIVE) + end + elseif action == "mix" then + if not active and not suspend then + pending_activations = pending_activations + 1 + link:activate(Feature.SessionItem.ACTIVE, onMediaRoleLinkActivated) + end + restoreVolume(om, link) + elseif action == "duck" then + if not active and not suspend then + pending_activations = pending_activations + 1 + link:activate(Feature.SessionItem.ACTIVE, onMediaRoleLinkActivated) + end + duckVolume(om, link) + else + log:warning("Unknown action: " .. action) + end + + ::continue:: + end + + if pmrl and not pmrl_active then + pending_activations = pending_activations + 1 + pmrl:activate(Feature.SessionItem.ACTIVE, onMediaRoleLinkActivated) + restoreVolume(om, pmrl) + end + + -- just advance transition if no pending activations are needed + if pending_activations <= 0 then + log:debug("All media role links rescanned") + transition:advance() + end + end, + }, + }, +}:register() diff --git a/.config/wireplumber/scripts/linking/rescan.lua b/.config/wireplumber/scripts/linking/rescan.lua new file mode 100644 index 0000000..a49ad29 --- /dev/null +++ b/.config/wireplumber/scripts/linking/rescan.lua @@ -0,0 +1,235 @@ +-- WirePlumber +-- +-- Copyright © 2020-2022 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Handle new linkables and trigger rescanning of the graph. +-- Rescan the graph by pushing new select-target events for +-- all linkables that need to be linked +-- Cleanup links when the linkables they are associated with are removed. +-- Also, cleanup flags attached to linkables. + +lutils = require ("linking-utils") +cutils = require ("common-utils") +futils = require ("filter-utils") +log = Log.open_topic ("s-linking") +handles = {} + +function checkFilter (si, om, handle_nonstreams) + -- always handle filters if handle_nonstreams is true, even if it is disabled + if handle_nonstreams then + return true + end + + -- always return true if this is not a filter + local node = si:get_associated_proxy ("node") + local link_group = node.properties["node.link-group"] + if link_group == nil then + return true + end + + local direction = cutils.getTargetDirection (si.properties) + + -- always handle filters that are not smart + if not futils.is_filter_smart (direction, link_group) then + return true + end + + -- dont handle smart filters that are disabled + return not futils.is_filter_disabled (direction, link_group) +end + +function checkLinkable (si, om, handle_nonstreams) + local si_props = si.properties + + -- For the rest of them, only handle stream session items + if not si_props or (si_props ["item.node.type"] ~= "stream" + and not handle_nonstreams) then + return false, si_props + end + + -- check filters + if not checkFilter (si, om, handle_nonstreams) then + return false, si_props + end + + return true, si_props +end + +function unhandleLinkable (si, om) + local si_id = si.id + local valid, si_props = checkLinkable (si, om, true) + if not valid then + return + end + + log:info (si, string.format ("unhandling item %d", si_id)) + + -- iterate over all the links in the graph and + -- remove any links associated with this item + for silink in om:iterate { type = "SiLink" } 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 + local in_flags = lutils:get_flags (in_id) + local out_flags = lutils:get_flags (out_id) + + if out_id == si_id and in_flags.peer_id == out_id then + in_flags.peer_id = nil + elseif in_id == si_id and out_flags.peer_id == in_id then + out_flags.peer_id = nil + end + + if cutils.parseBool (silink.properties["is.role.policy.link"]) then + lutils.clearPriorityMediaRoleLink(silink) + end + + silink:remove () + log:info (silink, "... link removed") + end + end + + lutils:clear_flags (si_id) +end + +SimpleEventHook { + name = "linking/linkable-removed", + interests = { + EventInterest { + Constraint { "event.type", "=", "session-item-removed" }, + Constraint { "event.session-item.interface", "=", "linkable" }, + }, + }, + execute = function (event) + local si = event:get_subject () + local source = event:get_source () + local om = source:call ("get-object-manager", "session-item") + + unhandleLinkable (si, om) + end +}:register () + +function handleLinkables (source) + local om = source:call ("get-object-manager", "session-item") + + for si in om:iterate { type = "SiLinkable" } do + local valid, si_props = checkLinkable (si, om) + if not valid then + goto skip_linkable + end + + -- check if we need to link this node at all + local autoconnect = cutils.parseBool (si_props ["node.autoconnect"]) + if not autoconnect then + log:debug (si, tostring (si_props ["node.name"]) .. " does not need to be autoconnected") + goto skip_linkable + end + + -- push event to find target and link + source:call ("push-event", "select-target", si, nil) + + ::skip_linkable:: + end +end + +SimpleEventHook { + name = "linking/rescan", + interests = { + EventInterest { + Constraint { "event.type", "=", "rescan-for-linking" }, + }, + }, + execute = function (event) + local source = event:get_source () + local om = source:call ("get-object-manager", "session-item") + + log:info ("rescanning...") + + -- always unlink all filters that are smart and disabled + for si in om:iterate { + type = "SiLinkable", + Constraint { "node.link-group", "+" }, + } do + local node = si:get_associated_proxy ("node") + local link_group = node.properties["node.link-group"] + local direction = cutils.getTargetDirection (si.properties) + if futils.is_filter_smart (direction, link_group) and + futils.is_filter_disabled (direction, link_group) then + unhandleLinkable (si, om) + end + end + + handleLinkables (source) + end +}:register () + +SimpleEventHook { + name = "linking/rescan-trigger", + interests = { + -- on linkable added or removed, where linkable is adapter or plain node + EventInterest { + Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, + Constraint { "event.session-item.interface", "=", "linkable" }, + }, + -- on device Routes changed + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "c", "Route", "EnumRoute" }, + }, + -- on any "default" target changed + EventInterest { + Constraint { "event.type", "=", "metadata-changed" }, + Constraint { "metadata.name", "=", "default" }, + Constraint { "event.subject.key", "c", "default.audio.source", + "default.audio.sink", "default.video.source" }, + }, + }, + execute = function (event) + local source = event:get_source () + source:call ("schedule-rescan", "linking") + end +}:register () + +SimpleEventHook { + name = "linking/rescan-trigger-on-filters-metadata-changed", + interests = { + EventInterest { + Constraint { "event.type", "=", "metadata-changed" }, + Constraint { "metadata.name", "=", "filters" }, + }, + }, + execute = function (event) + local source = event:get_source () + source:call ("schedule-rescan", "linking") + end +}:register () + +function handleMoveSetting (enable) + if (not handles.move_hook) and (enable == true) then + handles.move_hook = SimpleEventHook { + name = "linking/rescan-trigger-on-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 () + source:call ("schedule-rescan", "linking") + end + } + handles.move_hook:register() + elseif (handles.move_hook) and (enable == false) then + handles.move_hook:remove () + handles.move_hook = nil + end +end + +Settings.subscribe ("linking.allow-moving-streams", function () + handleMoveSetting (Settings.get_boolean ("linking.allow-moving-streams")) +end) +handleMoveSetting (Settings.get_boolean ("linking.allow-moving-streams")) diff --git a/.config/wireplumber/scripts/metadata.lua b/.config/wireplumber/scripts/metadata.lua new file mode 100644 index 0000000..ca1ba5d --- /dev/null +++ b/.config/wireplumber/scripts/metadata.lua @@ -0,0 +1,28 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Provides the default metadata object + +Script.async_activation = true + +-- note that args is a WpSpaJson +local args = ... +args = args:parse(1) + +local metadata_name = args["metadata.name"] + +log = Log.open_topic ("s-metadata") +log:info ("creating metadata object: " .. metadata_name) + +impl_metadata = ImplMetadata (metadata_name) +impl_metadata:activate (Features.ALL, function (m, e) + if e then + Script:finish_activation_with_error ( + "failed to activate the ".. metadata_name .." metadata: " .. tostring (e)) + else + Script:finish_activation () + end +end) diff --git a/.config/wireplumber/scripts/monitors/alsa-midi.lua b/.config/wireplumber/scripts/monitors/alsa-midi.lua index 6fdf34d..120b118 100644 --- a/.config/wireplumber/scripts/monitors/alsa-midi.lua +++ b/.config/wireplumber/scripts/monitors/alsa-midi.lua @@ -5,11 +5,25 @@ -- -- SPDX-License-Identifier: MIT --- Receive script arguments from config.lua -local config = ... or {} +cutils = require ("common-utils") +log = Log.open_topic ("s-monitors") --- ensure config.properties is not nil -config.properties = config.properties or {} +defaults = {} +defaults.node_properties = { -- Midi bridge node properties + ["factory.name"] = "api.alsa.seq.bridge", + + -- Name set for the node with ALSA MIDI ports + ["node.name"] = "Midi-Bridge", + + -- Set priorities so that it can be used as a fallback driver (see pipewire#3562) + ["priority.session"] = "100", + ["priority.driver"] = "1", +} + +config = {} +config.monitoring = Core.test_feature ("monitor.alsa-midi.monitoring") +config.node_properties = Conf.get_section_as_properties ( + "monitor.alsa-midi.properties", defaults.node_properties) SND_PATH = "/dev/snd" SEQ_NAME = "seq" @@ -19,18 +33,10 @@ 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) + local node = Node("spa-node-factory", config.node_properties) node:activate(Feature.Proxy.BOUND, function (n) - Log.info ("activated Midi bridge") + log:info ("activated Midi bridge") end) return node; @@ -38,7 +44,7 @@ end if GLib.access (SND_SEQ_PATH, "rw") then midi_node = CreateMidiNode () -elseif config.properties["alsa.midi.monitoring"] then +elseif config.monitoring then fm_plugin = Plugin.find("file-monitor-api") end diff --git a/.config/wireplumber/scripts/monitors/alsa.lua b/.config/wireplumber/scripts/monitors/alsa.lua index d56c9f0..15d20f4 100644 --- a/.config/wireplumber/scripts/monitors/alsa.lua +++ b/.config/wireplumber/scripts/monitors/alsa.lua @@ -5,50 +5,28 @@ -- -- SPDX-License-Identifier: MIT --- Receive script arguments from config.lua -local config = ... or {} +cutils = require ("common-utils") +log = Log.open_topic ("s-monitors") --- ensure config.properties is not nil -config.properties = config.properties or {} +config = {} +config.reserve_device = Core.test_feature ("monitor.alsa.reserve-device") +config.properties = Conf.get_section_as_properties ("monitor.alsa.properties") +config.rules = Conf.get_section_as_json ("monitor.alsa.rules", Json.Array {}) -- 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 applyDefaultDeviceProperties (properties) + properties["api.alsa.use-acp"] = true + properties["api.acp.auto-port"] = false + properties["api.dbus.ReserveDevice1.Priority"] = -20 +end + function createNode(parent, id, obj_type, factory, properties) local dev_props = parent.properties @@ -119,6 +97,8 @@ function createNode(parent, id, obj_type, factory, properties) properties["node.name"] = name + log:info ("Creating node " .. name) + -- deduplicate nodes with the same name for counter = 2, 99, 1 do if node_names_table[properties["node.name"]] ~= true then @@ -126,6 +106,7 @@ function createNode(parent, id, obj_type, factory, properties) break end properties["node.name"] = name .. "." .. counter + log:info ("deduplicating node name -> " .. properties["node.name"]) end end @@ -166,17 +147,17 @@ function createNode(parent, id, obj_type, factory, properties) 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 + -- add cpu.vm.name for rule matching purposes + local vm_type = Core.get_vm_type() + if nonempty(vm_type) then + properties["cpu.vm.name"] = vm_type end - -- apply properties from config.rules - rulesApplyProperties(properties) - if properties["node.disabled"] then + -- apply properties from rules defined in JSON .conf file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + if cutils.parseBool (properties ["node.disabled"]) then + log:notice ("ALSA node " .. properties["node.name"] .. " disabled") node_names_table [properties ["node.name"]] = nil return end @@ -197,12 +178,14 @@ function createDevice(parent, id, factory, properties) return end - node_names_table[node.properties["node.name"]] = nil + local node_name = node.properties["node.name"] + log:info ("Removing node " .. node_name) + node_names_table[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") + log:warning ("Failed to create '" .. factory .. "' device") end end @@ -276,16 +259,19 @@ function prepareDevice(parent, id, obj_type, factory, properties) properties["device.icon-name"] = icon .. "-analog" .. (b and ("-" .. b) or "") end - -- apply properties from config.rules - rulesApplyProperties(properties) - if properties["device.disabled"] then + -- apply properties from rules defined in JSON .conf file + applyDefaultDeviceProperties (properties) + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + if cutils.parseBool (properties ["device.disabled"]) then + log:notice ("ALSA card/device " .. properties ["device.name"] .. " disabled") 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"]) + if cutils.parseBool (properties ["api.alsa.use-acp"]) then + log:info("Enabling the use of ACP on " .. properties["device.name"]) factory = "api.alsa.acp.device" end @@ -294,9 +280,9 @@ function prepareDevice(parent, id, obj_type, factory, properties) 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", + cutils.get_application_name (), properties["device.name"], - config.properties["alsa.reserve.priority"] or -20); + properties["api.dbus.ReserveDevice1.Priority"]); properties["api.dbus.ReserveDevice1"] = rd_name @@ -320,22 +306,11 @@ function prepareDevice(parent, id, obj_type, factory, properties) end) rd:connect("release-requested", function (rd) - Log.info("release requested") + 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 @@ -346,8 +321,8 @@ 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") + log:notice("PipeWire's ALSA SPA plugin is missing or broken. " .. + "Sound cards will not be supported") return nil end @@ -378,30 +353,19 @@ function createMonitor () node_names_table = {} -- activate monitor - Log.info("Activating ALSA 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 config.reserve_device then + rd_plugin = Plugin.find("reserve-device") +end if rd_plugin and rd_plugin:call("get-dbus")["state"] ~= "connected" then - Log.message("reserve-device plugin is not connected to D-Bus, " + log:notice("reserve-device plugin is not connected to D-Bus, " .. "disabling device reservation") rd_plugin = nil end @@ -412,12 +376,12 @@ 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) + log:info ("rd-plugin state changed to " .. state) if state == "connected" then - Log.info ("Creating ALSA monitor") + log:info ("Creating ALSA monitor") monitor = createMonitor() elseif state == "closed" then - Log.info ("Destroying ALSA monitor") + log:info ("Destroying ALSA monitor") monitor = nil end end) diff --git a/.config/wireplumber/scripts/monitors/bluez-midi.lua b/.config/wireplumber/scripts/monitors/bluez-midi.lua index 32b886e..ad055ff 100644 --- a/.config/wireplumber/scripts/monitors/bluez-midi.lua +++ b/.config/wireplumber/scripts/monitors/bluez-midi.lua @@ -5,42 +5,22 @@ -- -- SPDX-License-Identifier: MIT -local config = ... or {} +cutils = require ("common-utils") +log = Log.open_topic ("s-monitors") + +defaults = {} +defaults.servers = { "bluez_midi.server" } + +config = {} +config.seat_monitoring = Core.test_feature ("monitor.bluez.seat-monitoring") +config.properties = Conf.get_section_as_properties ("monitor.bluez-midi.properties") +config.servers = Conf.get_section_as_array ("monitor.bluez-midi.servers", defaults.servers) +config.rules = Conf.get_section_as_json ("monitor.bluez-midi.rules", Json.Array {}) -- 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 @@ -50,7 +30,7 @@ function setLatencyOffset(node, offset_msec) props.latencyOffsetNsec = tonumber(offset_msec) * 1000000 local param = Pod.Object(props) - Log.debug(param, "setting latency offset on " .. tostring(node)) + log:debug(param, "setting latency offset on " .. tostring(node)) node:set_param("Props", param) end @@ -79,8 +59,8 @@ function createNode(parent, id, type, factory, properties) properties["api.glib.mainloop"] = "true" - -- apply properties from config.rules - rulesApplyProperties(properties) + -- apply properties from the rules in the configuration file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) local latency_offset = properties["node.latency-offset-msec"] properties["node.latency-offset-msec"] = nil @@ -101,7 +81,6 @@ function createMonitor() for k, v in pairs(config.properties or {}) do monitor_props[k] = v end - monitor_props["server"] = nil monitor_props["api.glib.mainloop"] = "true" @@ -113,7 +92,7 @@ function createMonitor() id_to_name_table[id] = nil end) else - Log.message("PipeWire's BlueZ MIDI SPA missing or broken. Bluetooth not supported.") + log:notice("PipeWire's BlueZ MIDI SPA missing or broken. Bluetooth not supported.") return nil end @@ -126,16 +105,10 @@ function createMonitor() 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 + for k, v in pairs(config.servers) do local node_props = { ["node.name"] = v, ["node.description"] = string.format(I18n.gettext("BLE MIDI %d"), i), @@ -143,7 +116,7 @@ function createServers() ["factory.name"] = "api.bluez5.midi.node", ["api.glib.mainloop"] = "true", } - rulesApplyProperties(node_props) + node_props = JsonUtils.match_rules_update_properties (config.rules, node_props) local latency_offset = node_props["node.latency-offset-msec"] node_props["node.latency-offset-msec"] = nil @@ -154,7 +127,7 @@ function createServers() table.insert(servers, node) setLatencyOffset(node, latency_offset) else - Log.message("Failed to create BLE MIDI server.") + log:notice("Failed to create BLE MIDI server.") end i = i + 1 end @@ -162,12 +135,14 @@ function createServers() return servers end -logind_plugin = Plugin.find("logind") +if config.seat_monitoring then + logind_plugin = Plugin.find("logind") +end 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) + log:info(logind_plugin, "Seat state changed: " .. seat_state) if seat_state == "active" then monitor = createMonitor() diff --git a/.config/wireplumber/scripts/monitors/bluez.lua b/.config/wireplumber/scripts/monitors/bluez.lua index 141e425..e8da095 100644 --- a/.config/wireplumber/scripts/monitors/bluez.lua +++ b/.config/wireplumber/scripts/monitors/bluez.lua @@ -5,7 +5,20 @@ -- -- SPDX-License-Identifier: MIT -local config = ... or {} +COMBINE_OFFSET = 64 +LOOPBACK_SOURCE_ID = 128 +DEVICE_SOURCE_ID = 0 + +cutils = require ("common-utils") +log = Log.open_topic ("s-monitors") + +config = {} +config.seat_monitoring = Core.test_feature ("monitor.bluez.seat-monitoring") +config.properties = Conf.get_section_as_properties ("monitor.bluez.properties") +config.rules = Conf.get_section_as_json ("monitor.bluez.rules", Json.Array {}) + +-- This is not a setting, it must always be enabled +config.properties["api.bluez5.connection-info"] = true devices_om = ObjectManager { Interest { @@ -21,36 +34,6 @@ nodes_om = ObjectManager { } } --- 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 @@ -126,7 +109,7 @@ function createOffloadScoNode(parent, id, type, factory, properties) } args["playback.props"] = Json.Object(playback_args) else - Log.warning(parent, "Unsupported factory: " .. factory) + log:warning(parent, "Unsupported factory: " .. factory) return end @@ -142,16 +125,120 @@ function createOffloadScoNode(parent, id, type, factory, properties) parent:store_managed_object(id, loopback) end +device_set_nodes_om = ObjectManager { + Interest { + type = "node", + Constraint { "api.bluez5.set.leader", "+", type = "pw" }, + } +} + +device_set_nodes_om:connect ("object-added", function(_, node) + -- Connect ObjectConfig events to the right node + if not monitor then + return + end + + local interest = Interest { + type = "device", + Constraint { "object.id", "=", node.properties["device.id"] } + } + log:info("Device set node found: " .. tostring (node["bound-id"])) + for device in devices_om:iterate (interest) do + local device_id = device.properties["api.bluez5.id"] + if not device_id then + goto next_device + end + + local spa_device = monitor:get_managed_object (tonumber (device_id)) + if not spa_device then + goto next_device + end + + local id = node.properties["card.profile.device"] + if id ~= nil then + log:info(".. assign to device: " .. tostring (device["bound-id"]) .. " node " .. tostring (id)) + spa_device:store_managed_object (id, node) + + -- set routes again to update volumes etc. + for route in device:iterate_params ("Route") do + device:set_param ("Route", route) + end + end + + ::next_device:: + end +end) + +function createSetNode(parent, id, type, factory, properties) + local args = {} + local target_class + local stream_class + local rules = {} + local members_json = Json.Raw (properties["api.bluez5.set.members"]) + local channels_json = Json.Raw (properties["api.bluez5.set.channels"]) + local members = members_json:parse () + local channels = channels_json:parse () + + if properties["media.class"] == "Audio/Sink" then + args["combine.mode"] = "sink" + target_class = "Audio/Sink/Internal" + stream_class = "Stream/Output/Audio/Internal" + else + args["combine.mode"] = "source" + target_class = "Audio/Source/Internal" + stream_class = "Stream/Input/Audio/Internal" + end + + log:info("Device set: " .. properties["node.name"]) + + for _, member in pairs(members) do + log:info("Device set member:" .. member["object.path"]) + table.insert(rules, + Json.Object { + ["matches"] = Json.Array { + Json.Object { + ["object.path"] = member["object.path"], + ["media.class"] = target_class, + }, + }, + ["actions"] = Json.Object { + ["create-stream"] = Json.Object { + ["media.class"] = stream_class, + ["audio.position"] = Json.Array (member["channels"]), + } + }, + } + ) + end + + properties["node.virtual"] = false + properties["device.api"] = "bluez5" + properties["api.bluez5.set.members"] = nil + properties["api.bluez5.set.channels"] = nil + properties["api.bluez5.set.leader"] = true + properties["audio.position"] = Json.Array (channels) + args["combine.props"] = Json.Object (properties) + args["stream.props"] = Json.Object {} + args["stream.rules"] = Json.Array (rules) + + local args_json = Json.Object(args) + local args_string = args_json:get_data() + local combine_properties = {} + log:info("Device set node: " .. args_string) + return LocalModule("libpipewire-module-combine-stream", args_string, combine_properties) +end + function createNode(parent, id, type, factory, properties) local dev_props = parent.properties + local parent_id = parent["bound-id"] - if config.properties["bluez5.hw-offload-sco"] and factory:find("sco") then + if cutils.parseBool (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["device.id"] = parent_id properties["factory.name"] = factory -- set the default pause-on-idle setting @@ -167,10 +254,21 @@ function createNode(parent, id, type, factory, properties) -- sanitize description, replace ':' with ' ' properties["node.description"] = desc:gsub("(:)", " ") + local name_prefix = ((factory:find("sink") and "bluez_output") or + (factory:find("source") and "bluez_input" or factory)) + + -- hide the source node because we use the loopback source instead + if parent:get_managed_object (LOOPBACK_SOURCE_ID) ~= nil and + (factory == "api.bluez5.sco.source" or + (factory == "api.bluez5.a2dp.source" and cutils.parseBool (properties["api.bluez5.a2dp-duplex"]))) then + properties["bluez5.loopback-target"] = true + properties["api.bluez5.internal"] = true + -- add 'internal' to name prefix to not be confused with loopback node + name_prefix = name_prefix .. "_internal" + end + -- set the node name - local name = - ((factory:find("sink") and "bluez_output") or - (factory:find("source") and "bluez_input" or factory)) .. "." .. + local name = name_prefix .. "." .. (properties["api.bluez5.address"] or dev_props["device.name"]) .. "." .. tostring(id) -- sanitize name @@ -190,14 +288,26 @@ function createNode(parent, id, type, factory, properties) properties["node.autoconnect"] = true end - -- apply properties from config.rules - rulesApplyProperties(properties) + -- apply properties from the rules in the configuration file + properties = JsonUtils.match_rules_update_properties (config.rules, 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) + + if properties["api.bluez5.set.leader"] then + local combine = createSetNode(parent, id, type, factory, properties) + parent:store_managed_object(id + COMBINE_OFFSET, combine) + else + properties["bluez5.loopback"] = false + local node = LocalNode("adapter", properties) + node:activate(Feature.Proxy.BOUND) + parent:store_managed_object(id, node) + end +end + +function removeNode(parent, id) + -- Clear also the device set module, if any + parent:store_managed_object(id + COMBINE_OFFSET, nil) end function createDevice(parent, id, type, factory, properties) @@ -239,22 +349,24 @@ function createDevice(parent, id, type, factory, properties) -- initial profile is to be set by policy-device-profile.lua, not spa-bluez5 properties["bluez5.profile"] = "off" + properties["api.bluez5.id"] = id - -- apply properties from config.rules - rulesApplyProperties(properties) + -- apply properties from the rules in the configuration file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) -- create the device device = SpaDevice(factory, properties) if device then device:connect("create-object", createNode) + device:connect("object-removed", removeNode) parent:store_managed_object(id, device) else - Log.warning ("Failed to create '" .. factory .. "' device") + log:warning ("Failed to create '" .. factory .. "' device") return end end - Log.info(parent, string.format("%d, %s (%s): %s", + log:info(parent, string.format("%d, %s (%s): %s", id, properties["device.description"], properties["api.bluez5.address"], properties["api.bluez5.connection"])) @@ -267,14 +379,12 @@ function createDevice(parent, id, type, factory, properties) 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) + local monitor = SpaDevice("api.bluez5.enum.dbus", config.properties) if monitor then monitor:connect("create-object", createDevice) else - Log.message("PipeWire's BlueZ SPA missing or broken. Bluetooth not supported.") + log:notice("PipeWire's BlueZ SPA plugin is missing or broken. " .. + "Bluetooth devices will not be supported.") return nil end monitor:activate(Feature.SpaDevice.ENABLED) @@ -282,12 +392,111 @@ function createMonitor() return monitor end -logind_plugin = Plugin.find("logind") +function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id) + local args = Json.Object { + ["capture.props"] = Json.Object { + ["node.name"] = string.format ("bluez_capture_internal.%s", dev_name), + ["media.class"] = "Stream/Input/Audio/Internal", + ["node.description"] = + string.format ("Bluetooth internal capture stream for %s", dec_desc), + ["audio.channels"] = 1, + ["audio.position"] = "[MONO]", + ["bluez5.loopback"] = true, + ["stream.dont-remix"] = true, + ["node.passive"] = true, + ["node.dont-fallback"] = true, + ["node.linger"] = true + }, + ["playback.props"] = Json.Object { + ["node.name"] = string.format ("bluez_input.%s", dev_name), + ["node.description"] = string.format ("%s", dec_desc), + ["audio.position"] = "[MONO]", + ["media.class"] = "Audio/Source", + ["device.id"] = dev_id, + ["card.profile.device"] = DEVICE_SOURCE_ID, + ["priority.driver"] = 2010, + ["priority.session"] = 2010, + ["bluez5.loopback"] = true, + ["filter.smart"] = true, + ["filter.smart.target"] = Json.Object { + ["bluez5.loopback-target"] = true, + ["bluez5.loopback"] = false, + ["device.id"] = dev_id + } + } + } + return LocalModule("libpipewire-module-loopback", args:get_data(), {}) +end + +function checkProfiles (dev) + local device_id = dev["bound-id"] + local props = dev.properties + + -- Don't create loopback source device if autoswitch is disabled + if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then + return + end + + -- Get the associated BT SpaDevice + local internal_id = tostring (props["api.bluez5.id"]) + local spa_device = monitor:get_managed_object (internal_id) + if spa_device == nil then + return + end + + -- Ignore devices that don't support both A2DP sink and HSP/HFP profiles + local has_a2dpsink_profile = false + local has_headset_profile = false + for p in dev:iterate_params("EnumProfile") do + local profile = cutils.parseParam (p, "EnumProfile") + if profile.name:find ("a2dp") and profile.name:find ("sink") then + has_a2dpsink_profile = true + elseif profile.name:find ("headset") then + has_headset_profile = true + end + end + if not has_a2dpsink_profile or not has_headset_profile then + return + end + + -- Create the loopback device if never created before + local loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID) + if loopback == nil then + local dev_name = props["api.bluez5.address"] or props["device.name"] + local dec_desc = props["device.description"] or props["device.name"] + or props["device.nick"] or props["device.alias"] or "bluetooth-device" + -- sanitize description, replace ':' with ' ' + dec_desc = dec_desc:gsub("(:)", " ") + loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id) + spa_device:store_managed_object(LOOPBACK_SOURCE_ID, loopback) + end +end + +function onDeviceParamsChanged (dev, param_name) + if param_name == "EnumProfile" then + checkProfiles (dev) + end +end + +devices_om:connect("object-added", function(_, dev) + -- Ignore all devices that are not BT devices + if dev.properties["device.api"] ~= "bluez5" then + return + end + + -- check available profiles + dev:connect ("params-changed", onDeviceParamsChanged) + checkProfiles (dev) +end) + +if config.seat_monitoring then + logind_plugin = Plugin.find("logind") +end 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) + log:info(logind_plugin, "Seat state changed: " .. seat_state) if seat_state == "active" then monitor = createMonitor() @@ -305,3 +514,4 @@ end nodes_om:activate() devices_om:activate() +device_set_nodes_om:activate() diff --git a/.config/wireplumber/scripts/monitors/libcamera.lua b/.config/wireplumber/scripts/monitors/libcamera.lua deleted file mode 100644 index 3f411f2..0000000 --- a/.config/wireplumber/scripts/monitors/libcamera.lua +++ /dev/null @@ -1,174 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author George Kiagiadakis --- --- 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 diff --git a/.config/wireplumber/scripts/monitors/libcamera/create-device.lua b/.config/wireplumber/scripts/monitors/libcamera/create-device.lua new file mode 100644 index 0000000..8842b7f --- /dev/null +++ b/.config/wireplumber/scripts/monitors/libcamera/create-device.lua @@ -0,0 +1,61 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-libcamera") + +config = {} +config.rules = Conf.get_section_as_json ("monitor.libcamera.rules", Json.Array {}) + +function createLibcamNode (parent, id, type, factory, properties) + local registered = mutils:register_cam_node (parent, id, factory, properties) + if not registered then + source = source or Plugin.find ("standard-event-source") + local e = source:call ("create-event", "create-libcamera-device-node", + parent, nil) + e:set_data ("factory", factory) + e:set_data ("node-properties", properties) + e:set_data ("node-sub-id", id) + + EventDispatcher.push_event (e) + end +end + +SimpleEventHook { + name = "monitor/libcamera/create-device", + after = "monitor/libcamera/name-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-libcamera-device" }, + }, + }, + execute = function(event) + local properties = event:get_data ("device-properties") + local factory = event:get_data ("factory") + local parent = event:get_subject () + local id = event:get_data ("device-sub-id") + + -- apply properties from rules defined in JSON .conf file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + if cutils.parseBool (properties ["device.disabled"]) then + log:notice ("libcam device " .. properties["device.name"] .. " disabled") + return + end + local device = SpaDevice (factory, properties) + + if device then + device:connect ("create-object", createLibcamNode) + device:activate (Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND) + parent:store_managed_object (id, device) + else + log:warning ("Failed to create '" .. factory .. "' device") + end + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/libcamera/create-node.lua b/.config/wireplumber/scripts/monitors/libcamera/create-node.lua new file mode 100644 index 0000000..706cb8d --- /dev/null +++ b/.config/wireplumber/scripts/monitors/libcamera/create-node.lua @@ -0,0 +1,41 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-libcamera") + +config = {} +config.rules = Conf.get_section_as_json ("monitor.libcamera.rules", Json.Array {}) + +SimpleEventHook { + name = "monitor/libcamera/create-node", + after = "monitor/libcamera/name-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-libcamera-device-node" }, + }, + }, + execute = function(event) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local id = event:get_data ("node-sub-id") + + -- apply properties from rules defined in JSON .conf file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + if cutils.parseBool (properties["node.disabled"]) then + log:notice ("libcam node" .. properties ["node.name"] .. " disabled") + return + end + -- create the node + local node = Node ("spa-node-factory", properties) + node:activate (Feature.Proxy.BOUND) + parent:store_managed_object (id, node) + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/libcamera/enumerate-device.lua b/.config/wireplumber/scripts/monitors/libcamera/enumerate-device.lua new file mode 100644 index 0000000..e6e8361 --- /dev/null +++ b/.config/wireplumber/scripts/monitors/libcamera/enumerate-device.lua @@ -0,0 +1,32 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +log = Log.open_topic ("s-monitors-libcamera") + +config = {} +config.properties = Conf.get_section_as_properties ("monitor.libcamera.properties") + +function createCamDevice (parent, id, type, factory, properties) + source = source or Plugin.find ("standard-event-source") + + local e = source:call ("create-event", "create-libcamera-device", parent, nil) + e:set_data ("device-properties", properties) + e:set_data ("factory", factory) + e:set_data ("device-sub-id", id) + + EventDispatcher.push_event (e) +end + +monitor = SpaDevice ("api.libcamera.enum.manager", config.properties) +if monitor then + monitor:connect ("create-object", createCamDevice) + monitor:activate (Feature.SpaDevice.ENABLED) +else + log:notice ("PipeWire's libcamera SPA plugin is missing or broken. " .. + "Some camera types may not be supported.") +end diff --git a/.config/wireplumber/scripts/monitors/libcamera/name-device.lua b/.config/wireplumber/scripts/monitors/libcamera/name-device.lua new file mode 100644 index 0000000..91b8699 --- /dev/null +++ b/.config/wireplumber/scripts/monitors/libcamera/name-device.lua @@ -0,0 +1,49 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-libcamera") + +SimpleEventHook { + name = "monitor/libcamera/name-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-libcamera-device" }, + }, + }, + execute = function(event) + local parent = event:get_subject () + local properties = event:get_data ("device-properties") + local id = event:get_data ("device-sub-id") + + 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 mutils.find_duplicate (parent, id, "device.name", properties["node.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" + + event:set_data ("device-properties", properties) + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/libcamera/name-node.lua b/.config/wireplumber/scripts/monitors/libcamera/name-node.lua new file mode 100644 index 0000000..fd61efb --- /dev/null +++ b/.config/wireplumber/scripts/monitors/libcamera/name-node.lua @@ -0,0 +1,90 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-libcamera") + +SimpleEventHook { + name = "monitor/libcamera/name-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-libcamera-device-node" }, + }, + }, + execute = function(event) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local dev_props = parent.properties + local factory = event:get_data ("factory") + local id = event:get_data ("node-sub-id") + 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 mutils.find_duplicate (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 + + event:set_data ("node-properties", properties) + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/v4l2.lua b/.config/wireplumber/scripts/monitors/v4l2.lua deleted file mode 100644 index 609d7e7..0000000 --- a/.config/wireplumber/scripts/monitors/v4l2.lua +++ /dev/null @@ -1,165 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author George Kiagiadakis --- --- 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 diff --git a/.config/wireplumber/scripts/monitors/v4l2/create-device.lua b/.config/wireplumber/scripts/monitors/v4l2/create-device.lua new file mode 100644 index 0000000..167d962 --- /dev/null +++ b/.config/wireplumber/scripts/monitors/v4l2/create-device.lua @@ -0,0 +1,61 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-v4l2") + +config = {} +config.rules = Conf.get_section_as_json ("monitor.v4l2.rules", Json.Array {}) + +function createV4l2camNode (parent, id, type, factory, properties) + local registered = mutils:register_cam_node (parent, id, factory, properties) + if not registered then + source = source or Plugin.find ("standard-event-source") + local e = source:call ("create-event", "create-v4l2-device-node", + parent, nil) + e:set_data ("factory", factory) + e:set_data ("node-properties", properties) + e:set_data ("node-sub-id", id) + + EventDispatcher.push_event (e) + end +end + +SimpleEventHook { + name = "monitor/v4l2/create-device", + after = "monitor/v4l2/name-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-v4l2-device" }, + }, + }, + execute = function(event) + local properties = event:get_data ("device-properties") + local factory = event:get_data ("factory") + local parent = event:get_subject () + local id = event:get_data ("device-sub-id") + + -- apply properties from rules defined in JSON .conf file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + if cutils.parseBool (properties ["device.disabled"]) then + log:notice ("V4L2 device " .. properties["device.name"] .. " disabled") + return + end + local device = SpaDevice (factory, properties) + + if device then + device:connect ("create-object", createV4l2camNode) + device:activate (Feature.SpaDevice.ENABLED | Feature.Proxy.BOUND) + parent:store_managed_object (id, device) + else + log:warning ("Failed to create '" .. factory .. "' device") + end + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/v4l2/create-node.lua b/.config/wireplumber/scripts/monitors/v4l2/create-node.lua new file mode 100644 index 0000000..9ba365b --- /dev/null +++ b/.config/wireplumber/scripts/monitors/v4l2/create-node.lua @@ -0,0 +1,42 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-v4l2") + +config = {} +config.rules = Conf.get_section_as_json ("monitor.v4l2.rules", Json.Array {}) + +SimpleEventHook { + name = "monitor/v4l2/create-node", + after = "monitor/v4l2/name-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-v4l2-device-node" }, + }, + }, + execute = function(event) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local id = event:get_data ("node-sub-id") + local factory = event:get_data ("factory") + + -- apply properties from rules defined in JSON .conf file + properties = JsonUtils.match_rules_update_properties (config.rules, properties) + + if cutils.parseBool (properties ["node.disabled"]) then + log:notice ("V4L2 node" .. properties ["node.name"] .. " disabled") + return + end + -- create the node + local node = Node ("spa-node-factory", properties) + node:activate (Feature.Proxy.BOUND) + parent:store_managed_object (id, node) + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/v4l2/enumerate-device.lua b/.config/wireplumber/scripts/monitors/v4l2/enumerate-device.lua new file mode 100644 index 0000000..87564d1 --- /dev/null +++ b/.config/wireplumber/scripts/monitors/v4l2/enumerate-device.lua @@ -0,0 +1,32 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +cutils = require ("common-utils") +log = Log.open_topic ("s-monitors-v4l2") + +config = {} +config.properties = Conf.get_section_as_properties ("monitor.v4l2.properties") + +function createCamDevice (parent, id, type, factory, properties) + source = source or Plugin.find ("standard-event-source") + + local e = source:call ("create-event", "create-v4l2-device", parent, nil) + e:set_data ("device-properties", properties) + e:set_data ("factory", factory) + e:set_data ("device-sub-id", id) + + EventDispatcher.push_event (e) +end + +monitor = SpaDevice ("api.v4l2.enum.udev", config.properties) +if monitor then + monitor:connect ("create-object", createCamDevice) + monitor:activate (Feature.SpaDevice.ENABLED) +else + log:notice ("PipeWire's V4L2 SPA plugin is missing or broken. " .. + "Some camera types may not be supported.") +end diff --git a/.config/wireplumber/scripts/monitors/v4l2/name-device.lua b/.config/wireplumber/scripts/monitors/v4l2/name-device.lua new file mode 100644 index 0000000..5188850 --- /dev/null +++ b/.config/wireplumber/scripts/monitors/v4l2/name-device.lua @@ -0,0 +1,49 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-v4l2") + +SimpleEventHook { + name = "monitor/v4l2/name-device", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-v4l2-device" }, + }, + }, + execute = function(event) + local properties = event:get_data ("device-properties") + local parent = event:get_subject () + local id = event:get_data ("device-sub-id") + + 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 mutils.find_duplicate (parent, id, "device.name", properties["node.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" + + event:set_data ("device-properties", properties) + end +}:register () diff --git a/.config/wireplumber/scripts/monitors/v4l2/name-node.lua b/.config/wireplumber/scripts/monitors/v4l2/name-node.lua new file mode 100644 index 0000000..e136a64 --- /dev/null +++ b/.config/wireplumber/scripts/monitors/v4l2/name-node.lua @@ -0,0 +1,80 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Ashok Sidipotu +-- +-- SPDX-License-Identifier: MIT + +mutils = require ("monitor-utils") + +log = Log.open_topic ("s-monitors-v4l2") + +SimpleEventHook { + name = "monitor/v4l2/name-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-v4l2-device-node" }, + }, + }, + execute = function(event) + local properties = event:get_data ("node-properties") + local parent = event:get_subject () + local dev_props = parent.properties + local factory = event:get_data ("factory") + local id = event:get_data ("node-sub-id") + + -- 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 mutils.find_duplicate (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 + + event:set_data ("node-properties", properties) + end +}:register () diff --git a/.config/wireplumber/scripts/node/create-item.lua b/.config/wireplumber/scripts/node/create-item.lua new file mode 100644 index 0000000..813f238 --- /dev/null +++ b/.config/wireplumber/scripts/node/create-item.lua @@ -0,0 +1,154 @@ +-- WirePlumber +-- +-- Copyright © 2021 Collabora Ltd. +-- @author Julian Bouzas +-- +-- 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 () diff --git a/.config/wireplumber/scripts/node/filter-forward-format.lua b/.config/wireplumber/scripts/node/filter-forward-format.lua new file mode 100644 index 0000000..04b6198 --- /dev/null +++ b/.config/wireplumber/scripts/node/filter-forward-format.lua @@ -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 () diff --git a/.config/wireplumber/scripts/node/software-dsp.lua b/.config/wireplumber/scripts/node/software-dsp.lua new file mode 100644 index 0000000..3c5bf72 --- /dev/null +++ b/.config/wireplumber/scripts/node/software-dsp.lua @@ -0,0 +1,92 @@ +-- WirePlumber +-- +-- Copyright © 2022-2023 The WirePlumber project contributors +-- @author Dmitry Sharshakov +-- +-- 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() diff --git a/.config/wireplumber/scripts/node/state-stream.lua b/.config/wireplumber/scripts/node/state-stream.lua new file mode 100644 index 0000000..e2fc7ef --- /dev/null +++ b/.config/wireplumber/scripts/node/state-stream.lua @@ -0,0 +1,452 @@ +-- WirePlumber +-- +-- Copyright © 2021-2022 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- 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")) diff --git a/.config/wireplumber/scripts/suspend-node.lua b/.config/wireplumber/scripts/node/suspend-node.lua similarity index 52% rename from .config/wireplumber/scripts/suspend-node.lua rename to .config/wireplumber/scripts/node/suspend-node.lua index b68d3d0..1f29be8 100644 --- a/.config/wireplumber/scripts/suspend-node.lua +++ b/.config/wireplumber/scripts/node/suspend-node.lua @@ -5,19 +5,28 @@ -- -- SPDX-License-Identifier: MIT -om = ObjectManager { - Interest { type = "node", - Constraint { "media.class", "matches", "Audio/*" } - }, - Interest { type = "node", - Constraint { "media.class", "matches", "Video/*" } - }, -} +log = Log.open_topic ("s-node") sources = {} -om:connect("object-added", function (om, node) - node:connect("state-changed", function (node, old_state, cur_state) +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 @@ -26,7 +35,7 @@ om:connect("object-added", function (om, node) end -- Add a timeout source if idle for at least 5 seconds - if cur_state == "idle" or cur_state == "error" then + 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 @@ -38,8 +47,11 @@ om:connect("object-added", function (om, node) -- 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") + -- 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 @@ -49,8 +61,5 @@ om:connect("object-added", function (om, node) return false end) end - - end) -end) - -om:activate() + end +}:register () diff --git a/.config/wireplumber/scripts/policy-bluetooth.lua b/.config/wireplumber/scripts/policy-bluetooth.lua deleted file mode 100644 index f8f69a1..0000000 --- a/.config/wireplumber/scripts/policy-bluetooth.lua +++ /dev/null @@ -1,398 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Asymptotic Inc. --- @author Sanchayan Maity --- --- 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() diff --git a/.config/wireplumber/scripts/policy-device-profile.lua b/.config/wireplumber/scripts/policy-device-profile.lua deleted file mode 100644 index b591cb7..0000000 --- a/.config/wireplumber/scripts/policy-device-profile.lua +++ /dev/null @@ -1,187 +0,0 @@ --- WirePlumber --- --- Copyright © 2022 Collabora Ltd. --- @author Julian Bouzas --- --- 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() diff --git a/.config/wireplumber/scripts/policy-device-routes.lua b/.config/wireplumber/scripts/policy-device-routes.lua deleted file mode 100644 index c21c1cc..0000000 --- a/.config/wireplumber/scripts/policy-device-routes.lua +++ /dev/null @@ -1,487 +0,0 @@ --- 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 = ... 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() diff --git a/.config/wireplumber/scripts/policy-endpoint-client-links.lua b/.config/wireplumber/scripts/policy-endpoint-client-links.lua deleted file mode 100644 index eaa1c08..0000000 --- a/.config/wireplumber/scripts/policy-endpoint-client-links.lua +++ /dev/null @@ -1,218 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author George Kiagiadakis --- --- 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() diff --git a/.config/wireplumber/scripts/policy-endpoint-client.lua b/.config/wireplumber/scripts/policy-endpoint-client.lua deleted file mode 100644 index ed4202f..0000000 --- a/.config/wireplumber/scripts/policy-endpoint-client.lua +++ /dev/null @@ -1,259 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author Julian Bouzas --- --- 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() diff --git a/.config/wireplumber/scripts/policy-endpoint-device.lua b/.config/wireplumber/scripts/policy-endpoint-device.lua deleted file mode 100644 index 0ba39b0..0000000 --- a/.config/wireplumber/scripts/policy-endpoint-device.lua +++ /dev/null @@ -1,234 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author Julian Bouzas --- --- 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() diff --git a/.config/wireplumber/scripts/policy-node.lua b/.config/wireplumber/scripts/policy-node.lua deleted file mode 100644 index 25d7136..0000000 --- a/.config/wireplumber/scripts/policy-node.lua +++ /dev/null @@ -1,1008 +0,0 @@ --- WirePlumber --- --- Copyright © 2020 Collabora Ltd. --- @author Julian Bouzas --- --- 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 -config.filter_forward_format = config["filter.forward-format"] or false - -local self = {} -self.scanning = false -self.pending_rescan = false -self.events_skipped = false -self.pending_error_timer = nil - -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 parseBool(var) - return var and (var:lower() == "true" or var == "1") -end - -function createLink (si, si_target, passthrough, exclusive) - local out_item = nil - local in_item = nil - local si_props = si.properties - local target_props = si_target.properties - local si_id = si.id - - -- break rescan if tried more than 5 times with same target - if si_flags[si_id].failed_peer_id ~= nil and - si_flags[si_id].failed_peer_id == si_target.id and - si_flags[si_id].failed_count ~= nil and - si_flags[si_id].failed_count > 5 then - Log.warning (si, "tried to link on last rescan, not retrying") - return - end - - if si_props["item.node.direction"] == "output" then - -- playback - out_item = si - in_item = si_target - else - -- capture - in_item = si - out_item = si_target - end - - local passive = parseBool(si_props["node.passive"]) or - parseBool(target_props["node.passive"]) - - Log.info ( - string.format("link %s <-> %s passive:%s, passthrough:%s, exclusive:%s", - tostring(si_props["node.name"]), - tostring(target_props["node.name"]), - tostring(passive), tostring(passthrough), tostring(exclusive))) - - -- create and configure link - local si_link = SessionItem ( "si-standard-link" ) - if not si_link:configure { - ["out.item"] = out_item, - ["in.item"] = in_item, - ["passive"] = passive, - ["passthrough"] = passthrough, - ["exclusive"] = exclusive, - ["out.item.port.context"] = "output", - ["in.item.port.context"] = "input", - ["is.policy.item.link"] = true, - } then - Log.warning (si_link, "failed to configure si-standard-link") - return - end - - si_link:connect("link-error", function (_, error_msg) - local ids = {si_id} - if si_flags[si_id] ~= nil then - table.insert (ids, si_flags[si_id].peer_id) - end - - for _, id in ipairs (ids) do - local si = linkables_om:lookup { - Constraint { "id", "=", id, type = "gobject" }, - } - if si then - local node = si:get_associated_proxy ("node") - local client_id = node.properties["client.id"] - if client_id then - local client = clients_om:lookup { - Constraint { "bound-id", "=", client_id, type = "gobject" } - } - if client then - Log.info (node, "sending client error: " .. error_msg) - client:send_error (node["bound-id"], -32, error_msg) - end - end - end - end - end) - - -- register - si_flags[si_id].peer_id = si_target.id - si_flags[si_id].failed_peer_id = si_target.id - if si_flags[si_id].failed_count ~= nil then - si_flags[si_id].failed_count = si_flags[si_id].failed_count + 1 - else - si_flags[si_id].failed_count = 1 - end - si_link:register () - - -- activate - si_link:activate (Feature.SessionItem.ACTIVE, function (l, e) - if e then - Log.info (l, "failed to activate si-standard-link: " .. tostring(e)) - if si_flags[si_id] ~= nil then - si_flags[si_id].peer_id = nil - end - l:remove () - else - if si_flags[si_id] ~= nil then - si_flags[si_id].failed_peer_id = nil - si_flags[si_id].failed_count = 0 - end - Log.info (l, "activated si-standard-link") - end - scheduleRescan() - end) -end - -function isLinked(si_target) - local target_id = si_target.id - local linked = false - local exclusive = false - - for l in links_om:iterate() do - local p = l.properties - local out_id = tonumber(p["out.item.id"]) - local in_id = tonumber(p["in.item.id"]) - linked = (out_id == target_id) or (in_id == target_id) - if linked then - exclusive = parseBool(p["exclusive"]) or parseBool(p["passthrough"]) - break - end - end - return linked, exclusive -end - -function canPassthrough (si, si_target) - -- both nodes must support encoded formats - if not parseBool(si.properties["item.node.supports-encoded-fmts"]) - or not parseBool(si_target.properties["item.node.supports-encoded-fmts"]) then - return false - end - - -- make sure that the nodes have at least one common non-raw format - local n1 = si:get_associated_proxy ("node") - local n2 = si_target:get_associated_proxy ("node") - for p1 in n1:iterate_params("EnumFormat") do - local p1p = p1:parse() - if p1p.properties.mediaSubtype ~= "raw" then - for p2 in n2:iterate_params("EnumFormat") do - if p1:filter(p2) then - return true - end - end - end - end - return false -end - -function canLink (properties, si_target) - local target_properties = si_target.properties - - -- nodes must have the same media type - if properties["media.type"] ~= target_properties["media.type"] then - return false - end - - -- nodes must have opposite direction, or otherwise they must be both input - -- and the target must have a monitor (so the target will be used as a source) - local function isMonitor(properties) - return properties["item.node.direction"] == "input" and - parseBool(properties["item.features.monitor"]) and - not parseBool(properties["item.features.no-dsp"]) and - properties["item.factory.name"] == "si-audio-adapter" - end - - if properties["item.node.direction"] == target_properties["item.node.direction"] - and not isMonitor(target_properties) then - return false - end - - -- check link group - local function canLinkGroupCheck (link_group, si_target, hops) - local target_props = si_target.properties - local target_link_group = target_props["node.link-group"] - - if hops == 8 then - return false - end - - -- allow linking if target has no link-group property - if not target_link_group then - return true - end - - -- do not allow linking if target has the same link-group - if link_group == target_link_group then - return false - end - - -- make sure target is not linked with another node with same link group - -- start by locating other nodes in the target's link-group, in opposite direction - for n in linkables_om:iterate { - Constraint { "id", "!", si_target.id, type = "gobject" }, - Constraint { "item.node.direction", "!", target_props["item.node.direction"] }, - Constraint { "node.link-group", "=", target_link_group }, - } do - -- iterate their peers and return false if one of them cannot link - 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 == n.id or in_id == n.id then - local peer_id = (out_id == n.id) and in_id or out_id - local peer = linkables_om:lookup { - Constraint { "id", "=", peer_id, type = "gobject" }, - } - if peer and not canLinkGroupCheck (link_group, peer, hops + 1) then - return false - end - end - end - end - return true - end - - local link_group = properties["node.link-group"] - if link_group then - return canLinkGroupCheck (link_group, si_target, 0) - end - return true -end - -function getTargetDirection(properties) - local target_direction = nil - if properties["item.node.direction"] == "output" or - (properties["item.node.direction"] == "input" and - parseBool(properties["stream.capture.sink"])) then - target_direction = "input" - else - target_direction = "output" - end - return target_direction -end - -function getDefaultNode(properties, target_direction) - local target_media_class = - properties["media.type"] .. - (target_direction == "input" and "/Sink" or "/Source") - return default_nodes:call("get-default-node", target_media_class) -end - --- Try to locate a valid target node that was explicitly requsted by the --- client(node.target) or by the user(target.node) --- Use the target.node metadata, if config.move is enabled, --- then use the node.target property that was set on the node --- `properties` must be the properties dictionary of the session item --- that is currently being handled -function findDefinedTarget (properties) - local metadata = config.move and metadata_om:lookup() - local target_direction = getTargetDirection(properties) - local target_key - local target_value - local node_defined = false - - if properties["target.object"] ~= nil then - target_value = properties["target.object"] - target_key = "object.serial" - node_defined = true - elseif properties["node.target"] ~= nil then - target_value = properties["node.target"] - target_key = "node.id" - node_defined = true - end - - if metadata then - local id = metadata:find(properties["node.id"], "target.object") - if id ~= nil then - target_value = id - target_key = "object.serial" - node_defined = false - else - id = metadata:find(properties["node.id"], "target.node") - if id ~= nil then - target_value = id - target_key = "node.id" - node_defined = false - end - end - end - - if target_value == "-1" then - return nil, false, node_defined - end - - if target_value and tonumber(target_value) then - local si_target = linkables_om:lookup { - Constraint { target_key, "=", target_value }, - } - if si_target and canLink (properties, si_target) then - return si_target, true, node_defined - end - end - - if target_value then - for si_target in linkables_om:iterate() do - local target_props = si_target.properties - if (target_props["node.name"] == target_value or - target_props["object.path"] == target_value) and - target_props["item.node.direction"] == target_direction and - canLink (properties, si_target) then - return si_target, true, node_defined - end - end - end - return nil, (target_value ~= nil), node_defined -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 arrayContains(a, value) - for _, v in ipairs(a) do - if v == value then - return true - end - end - return false -end - - --- Does the target device have any active/available paths/routes to --- the physical device(spkr/mic/cam)? -function haveAvailableRoutes (si_props) - local card_profile_device = si_props["card.profile.device"] - local device_id = si_props["device.id"] - local device = device_id and devices_om:lookup { - Constraint { "bound-id", "=", device_id, type = "gobject"}, - } - - if not card_profile_device or not device then - return true - end - - local found = 0 - local avail = 0 - - -- First check "SPA_PARAM_Route" if there are any active devices - -- in an active profile. - for p in device:iterate_params("Route") do - local route = parseParam(p, "Route") - if not route then - goto skip_route - end - - if (route.device ~= tonumber(card_profile_device)) then - goto skip_route - end - - if (route.available == "no") then - return false - end - - do return true end - - ::skip_route:: - end - - -- Second check "SPA_PARAM_EnumRoute" if there is any route that - -- is available if not active. - for p in device:iterate_params("EnumRoute") do - local route = parseParam(p, "EnumRoute") - if not route then - goto skip_enum_route - end - - if not arrayContains(route.devices, tonumber(card_profile_device)) then - goto skip_enum_route - end - found = found + 1; - if (route.available ~= "no") then - avail = avail +1 - end - ::skip_enum_route:: - end - - if found == 0 then - return true - end - if avail > 0 then - return true - end - - return false - -end - -function findDefaultLinkable (si) - local si_props = si.properties - local target_direction = getTargetDirection(si_props) - local def_node_id = getDefaultNode(si_props, target_direction) - return linkables_om:lookup { - Constraint { "node.id", "=", tostring(def_node_id) } - } -end - -function checkPassthroughCompatibility (si, si_target) - local si_must_passthrough = parseBool(si.properties["item.node.encoded-only"]) - local si_target_must_passthrough = parseBool(si_target.properties["item.node.encoded-only"]) - local can_passthrough = canPassthrough(si, si_target) - if (si_must_passthrough or si_target_must_passthrough) - and not can_passthrough then - return false, can_passthrough - end - return true, can_passthrough -end - -function findBestLinkable (si) - local si_props = si.properties - local target_direction = getTargetDirection(si_props) - local target_picked = nil - local target_can_passthrough = false - local target_priority = 0 - local target_plugged = 0 - - for si_target in linkables_om:iterate { - Constraint { "item.node.type", "=", "device" }, - Constraint { "item.node.direction", "=", target_direction }, - Constraint { "media.type", "=", si_props["media.type"] }, - } do - local si_target_props = si_target.properties - local si_target_node_id = si_target_props["node.id"] - local priority = tonumber(si_target_props["priority.session"]) or 0 - - Log.debug(string.format("Looking at: %s (%s)", - tostring(si_target_props["node.name"]), - tostring(si_target_node_id))) - - if not canLink (si_props, si_target) then - Log.debug("... cannot link, skip linkable") - goto skip_linkable - end - - if not haveAvailableRoutes(si_target_props) then - Log.debug("... does not have routes, skip linkable") - goto skip_linkable - end - - local passthrough_compatible, can_passthrough = - checkPassthroughCompatibility (si, si_target) - if not passthrough_compatible then - Log.debug("... passthrough is not compatible, skip linkable") - goto skip_linkable - end - - local plugged = tonumber(si_target_props["item.plugged.usec"]) or 0 - - Log.debug("... priority:"..tostring(priority)..", plugged:"..tostring(plugged)) - - -- (target_picked == NULL) --> make sure atleast one target is picked. - -- (priority > target_priority) --> pick the highest priority linkable(node) - -- target. - -- (priority == target_priority and plugged > target_plugged) --> pick the - -- latest connected/plugged(in time) linkable(node) target. - if (target_picked == nil or - priority > target_priority or - (priority == target_priority and plugged > target_plugged)) then - Log.debug("... picked") - target_picked = si_target - target_can_passthrough = can_passthrough - target_priority = priority - target_plugged = plugged - end - ::skip_linkable:: - end - - if target_picked then - Log.info(string.format("... best target picked: %s (%s), can_passthrough:%s", - tostring(target_picked.properties["node.name"]), - tostring(target_picked.properties["node.id"]), - tostring(target_can_passthrough))) - return target_picked, target_can_passthrough - else - return nil, nil - end - -end - -function findUndefinedTarget (si) - -- Just find the best linkable if default nodes module is not loaded - if default_nodes == nil then - return findBestLinkable (si) - end - - -- Otherwise find the default linkable. If the default linkable is not - -- compatible, we find the best one instead. We return nil if the default - -- linkable does not exist. - local si_target = findDefaultLinkable (si) - if si_target then - local passthrough_compatible, can_passthrough = - checkPassthroughCompatibility (si, si_target) - if canLink (si.properties, si_target) and passthrough_compatible then - Log.info(string.format("... default target picked: %s (%s), can_passthrough:%s", - tostring(si_target.properties["node.name"]), - tostring(si_target.properties["node.id"]), - tostring(can_passthrough))) - return si_target, can_passthrough - else - return findBestLinkable (si) - end - end - return nil, nil -end - -function lookupLink (si_id, si_target_id) - local link = links_om:lookup { - Constraint { "out.item.id", "=", si_id }, - Constraint { "in.item.id", "=", si_target_id } - } - if not link then - link = links_om:lookup { - Constraint { "in.item.id", "=", si_id }, - Constraint { "out.item.id", "=", si_target_id } - } - end - return link -end - -function checkLinkable(si, handle_nonstreams) - -- only handle stream session items - local si_props = si.properties - if not si_props or (si_props["item.node.type"] ~= "stream" - and not handle_nonstreams) then - return false - end - - -- Determine if we can handle item by this policy - if endpoints_om:get_n_objects () > 0 and - si_props["item.factory.name"] == "si-audio-adapter" then - return false - end - - return true, si_props -end - -si_flags = {} - -function checkPending () - local pending_linkables = pending_linkables_om:get_n_objects () - - -- We cannot process linkables if some of them are pending activation, - -- because linkables do not appear in the same order as nodes, - -- and we cannot resolve target node references until all linkables - -- have appeared. - - if self.pending_error_timer then - self.pending_error_timer:destroy () - self.pending_error_timer = nil - end - - if pending_linkables ~= 0 then - -- Wait for linkables to get it sync - Log.debug(string.format("pending %d linkable not ready", - pending_linkables)) - self.events_skipped = true - - -- To make bugs in activation easier to debug, emit an error message - -- if they occur. policy-node should never be suspended for 20sec. - self.pending_error_timer = Core.timeout_add(20000, function() - self.pending_error_timer = nil - if pending_linkables ~= 0 then - Log.message(string.format("%d pending linkable(s) not activated in 20sec. " - .. "This should never happen.", pending_linkables)) - end - end) - - return true - elseif self.events_skipped then - Log.debug("pending linkables ready") - self.events_skipped = false - scheduleRescan () - return true - end - - return false -end - -function checkFollowDefault (si, si_target, has_node_defined_target) - -- If it got linked to the default target that is defined by node - -- props but not metadata, start ignoring the node prop from now on. - -- This is what Pulseaudio does. - -- - -- Pulseaudio skips here filter streams (i->origin_sink and - -- o->destination_source set in PA). Pipewire does not have a flag - -- explicitly for this, but we can use presence of node.link-group. - if not has_node_defined_target then - return - end - - local si_props = si.properties - local target_props = si_target.properties - local reconnect = not parseBool(si_props["node.dont-reconnect"]) - local is_filter = (si_props["node.link-group"] ~= nil) - - if config.follow and default_nodes ~= nil and reconnect and not is_filter then - local def_id = getDefaultNode(si_props, getTargetDirection(si_props)) - - if target_props["node.id"] == tostring(def_id) then - local metadata = metadata_om:lookup() - -- Set target.node, for backward compatibility - metadata:set(tonumber(si_props["node.id"]), "target.node", "Spa:Id", "-1") - Log.info (si, "... set metadata to follow default") - end - end -end - -function handleLinkable (si) - if checkPending () then - return - end - - local valid, si_props = checkLinkable(si) - if not valid then - return - end - - -- check if we need to link this node at all - local autoconnect = parseBool(si_props["node.autoconnect"]) - if not autoconnect then - Log.debug (si, tostring(si_props["node.name"]) .. " does not need to be autoconnected") - return - end - - Log.info (si, string.format("handling item: %s (%s)", - tostring(si_props["node.name"]), tostring(si_props["node.id"]))) - - ensureSiFlags(si) - - -- get other important node properties - local reconnect = not parseBool(si_props["node.dont-reconnect"]) - local exclusive = parseBool(si_props["node.exclusive"]) - local si_must_passthrough = parseBool(si_props["item.node.encoded-only"]) - - -- find defined target - local si_target, has_defined_target, has_node_defined_target - = findDefinedTarget(si_props) - local can_passthrough = si_target and canPassthrough(si, si_target) - - if si_target and si_must_passthrough and not can_passthrough then - si_target = nil - end - - -- if the client has seen a target that we haven't yet prepared, schedule - -- a rescan one more time and hope for the best - local si_id = si.id - if has_defined_target - and not si_target - and not si_flags[si_id].was_handled - and not si_flags[si_id].done_waiting then - Log.info (si, "... waiting for target") - si_flags[si_id].done_waiting = true - scheduleRescan() - return - end - - -- find fallback target - if not si_target and (reconnect or not has_defined_target) then - si_target, can_passthrough = findUndefinedTarget(si) - end - - -- Check if item is linked to proper target, otherwise re-link - if si_flags[si_id].peer_id then - if si_target and si_flags[si_id].peer_id == si_target.id then - Log.debug (si, "... already linked to proper target") - -- Check this also here, in case in default targets changed - checkFollowDefault (si, si_target, has_node_defined_target) - return - end - local link = lookupLink (si_id, si_flags[si_id].peer_id) - if reconnect then - if link ~= nil then - -- remove old link - if ((link:get_active_features() & Feature.SessionItem.ACTIVE) == 0) then - -- Link not yet activated. We don't want to remove it now, as that - -- may cause problems. Instead, give up for now. A rescan is scheduled - -- once the link activates. - Log.info (link, "Link to be moved was not activated, will wait for it.") - return - end - si_flags[si_id].peer_id = nil - link:remove () - Log.info (si, "... moving to new target") - end - else - if link ~= nil then - Log.info (si, "... dont-reconnect, not moving") - return - end - end - end - - -- if the stream has dont-reconnect and was already linked before, - -- don't link it to a new target - if not reconnect and si_flags[si.id].was_handled then - si_target = nil - end - - -- check target's availability - if si_target then - local target_is_linked, target_is_exclusive = isLinked(si_target) - if target_is_exclusive then - Log.info(si, "... target is linked exclusively") - si_target = nil - end - - if target_is_linked then - if exclusive or si_must_passthrough then - Log.info(si, "... target is already linked, cannot link exclusively") - si_target = nil - else - -- disable passthrough, we can live without it - can_passthrough = false - end - end - end - - if not si_target then - Log.info (si, "... target not found, reconnect:" .. tostring(reconnect)) - - local node = si:get_associated_proxy ("node") - if not reconnect then - Log.info (si, "... destroy node") - node:request_destroy() - elseif si_flags[si.id].was_handled then - Log.info (si, "... waiting reconnect") - return - end - - local client_id = node.properties["client.id"] - if client_id then - local client = clients_om:lookup { - Constraint { "bound-id", "=", client_id, type = "gobject" } - } - if client then - client:send_error(node["bound-id"], -2, "no node available") - end - end - else - createLink (si, si_target, can_passthrough, exclusive) - si_flags[si.id].was_handled = true - - checkFollowDefault (si, si_target, has_node_defined_target) - end -end - -function unhandleLinkable (si) - local valid, si_props = checkLinkable(si, true) - if not valid then - return - end - - 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 - if out_id == si.id and - si_flags[in_id] and si_flags[in_id].peer_id == out_id then - si_flags[in_id].peer_id = nil - elseif in_id == si.id and - si_flags[out_id] and si_flags[out_id].peer_id == in_id then - si_flags[out_id].peer_id = nil - end - silink:remove () - Log.info (silink, "... link removed") - end - end - - si_flags[si.id] = nil -end - -default_nodes = Plugin.find("default-nodes-api") - -metadata_om = ObjectManager { - Interest { - type = "metadata", - Constraint { "metadata.name", "=", "default" }, - } -} - -endpoints_om = ObjectManager { Interest { type = "SiEndpoint" } } - -clients_om = ObjectManager { Interest { type = "client" } } - -devices_om = ObjectManager { Interest { type = "device" } } - -linkables_om = ObjectManager { - Interest { - type = "SiLinkable", - -- only handle si-audio-adapter and si-node - Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, - Constraint { "active-features", "!", 0, type = "gobject" }, - } -} - -pending_linkables_om = ObjectManager { - Interest { - type = "SiLinkable", - -- only handle si-audio-adapter and si-node - Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, - Constraint { "active-features", "=", 0, type = "gobject" }, - } -} - -links_om = ObjectManager { - Interest { - type = "SiLink", - -- only handle links created by this policy - Constraint { "is.policy.item.link", "=", true }, - } -} - --- listen for default node changes if config.follow is enabled -if config.follow and default_nodes ~= nil then - default_nodes:connect("changed", function () - scheduleRescan () - end) -end - --- listen for target.node metadata changes if config.move is enabled -if config.move then - metadata_om:connect("object-added", function (om, metadata) - metadata:connect("changed", function (m, subject, key, t, value) - if key == "target.node" or key == "target.object" then - scheduleRescan () - end - end) - end) -end - -function findAssociatedLinkGroupNode (si) - local si_props = si.properties - local node = si:get_associated_proxy ("node") - local link_group = node.properties["node.link-group"] - if link_group == nil then - return nil - end - - -- get the associated media class - local assoc_direction = 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 linkables_om:iterate() do - local assoc_node = assoc_si:get_associated_proxy ("node") - local assoc_link_group = assoc_node.properties["node.link-group"] - if assoc_link_group == link_group and - assoc_media_class == assoc_node.properties["media.class"] then - return assoc_si - end - end - - return nil -end - -function onLinkGroupPortsStateChanged (si, old_state, new_state) - local new_str = tostring(new_state) - local si_props = si.properties - - -- only handle items with configured ports state - if new_str ~= "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 - -function ensureSiFlags (si) - -- prepare flags table - if not si_flags[si.id] then - si_flags[si.id] = {} - end -end - -function checkFiltersPortsState (si) - local si_props = si.properties - local node = si:get_associated_proxy ("node") - local link_group = node.properties["node.link-group"] - - ensureSiFlags(si) - - -- only listen for ports state changed on audio filter streams - if si_flags[si.id].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[si.id].ports_state_signal = true - Log.info (si, "listening ports state changed on " .. si_props["node.name"]) - end -end - -linkables_om:connect("object-added", function (om, si) - local si_props = si.properties - - -- Forward filters ports format to associated virtual devices if enabled - if config.filter_forward_format then - checkFiltersPortsState (si) - end - - if si_props["item.node.type"] ~= "stream" then - scheduleRescan () - else - handleLinkable (si) - end -end) - -linkables_om:connect("object-removed", function (om, si) - unhandleLinkable (si) - scheduleRescan () -end) - -devices_om:connect("object-added", function (om, device) - device:connect("params-changed", function (d, param_name) - scheduleRescan () - end) -end) - -metadata_om:activate() -endpoints_om:activate() -clients_om:activate() -linkables_om:activate() -pending_linkables_om:activate() -links_om:activate() -devices_om:activate() diff --git a/.config/wireplumber/scripts/restore-stream.lua b/.config/wireplumber/scripts/restore-stream.lua deleted file mode 100644 index 06867d4..0000000 --- a/.config/wireplumber/scripts/restore-stream.lua +++ /dev/null @@ -1,499 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author George Kiagiadakis --- --- 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() diff --git a/.config/wireplumber/scripts/sm-objects.lua b/.config/wireplumber/scripts/sm-objects.lua new file mode 100644 index 0000000..c22b29d --- /dev/null +++ b/.config/wireplumber/scripts/sm-objects.lua @@ -0,0 +1,103 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author George Kiagiadakis +-- +-- SPDX-License-Identifier: MIT +-- +-- The script exposes a metadata object named "sm-objects" that clients can +-- use to load objects into the WirePlumber daemon process. The objects are +-- loaded as soon as the metadata is set and are destroyed when the metadata +-- is cleared. +-- +-- To load an object, a client needs to set a metadata entry with: +-- +-- * subject: +-- The ID of the owner of the object; you can use 0 here, but the +-- idea is to be able to restrict which clients can change and/or +-- delete these objects by using IDs of other objects appropriately +-- +-- * key: "" +-- This is the name that will be used to identify the object. +-- If an object with the same name already exists, it will be destroyed. +-- Note that the keys are unique per subject, so you can have multiple +-- objects with the same name as long as they are owned by different subjects. +-- +-- * type: "Spa:String:JSON" +-- +-- * value: "{ type = , +-- name = , +-- args = { ...object arguments... } }" +-- The object type can be one of the following: +-- - "pw-module": loads a pipewire module: `name` and `args` are interpreted +-- just like a module entry in pipewire.conf +-- - "metadata": loads a metadata object with `metadata.name` = `name` +-- and any additional properties provided in `args` +-- + +on_demand_objects = {} + +object_constructors = { + ["pw-module"] = LocalModule, + ["metadata"] = function (name, args) + local m = ImplMetadata (name, args) + m:activate (Features.ALL, function (m, e) + if e then + Log.warning ("failed to activate on-demand metadata `" .. name .. "`: " .. tostring (e)) + end + end) + return m + end +} + +function handle_metadata_changed (m, subject, key, type, value) + -- destroy all objects when metadata is cleared + if not key then + on_demand_objects = {} + return + end + + local object_id = key .. "@" .. tostring(subject) + + -- destroy existing object instance, if needed + if on_demand_objects[object_id] then + Log.debug("destroy on-demand object: " .. object_id) + on_demand_objects[object_id] = nil + end + + if value then + local json = Json.Raw(value) + if not json:is_object() then + Log.warning("loading '".. object_id .. "' failed: expected JSON object, got: '" .. value .. "'") + return + end + + local obj = json:parse(1) + if not obj.type then + Log.warning("loading '".. object_id .. "' failed: no object type specified") + return + end + if not obj.name then + Log.warning("loading '".. object_id .. "' failed: no object name specified") + return + end + + local constructor = object_constructors[obj.type] + if not constructor then + Log.warning("loading '".. object_id .. "' failed: unknown object type: " .. obj.type) + return + end + + Log.info("load on-demand object: " .. object_id .. " -> " .. obj.name) + on_demand_objects[object_id] = constructor(obj.name, obj.args) + end +end + +objects_metadata = ImplMetadata ("sm-objects") +objects_metadata:activate (Features.ALL, function (m, e) + if e then + Log.warning ("failed to activate the sm-objects metadata: " .. tostring (e)) + else + m:connect("changed", handle_metadata_changed) + end +end) diff --git a/.config/wireplumber/scripts/static-endpoints.lua b/.config/wireplumber/scripts/static-endpoints.lua deleted file mode 100644 index 5f9a975..0000000 --- a/.config/wireplumber/scripts/static-endpoints.lua +++ /dev/null @@ -1,36 +0,0 @@ --- WirePlumber --- --- Copyright © 2021 Collabora Ltd. --- @author Julian Bouzas --- --- 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 diff --git a/.config/wireplumber/wireplumber.conf b/.config/wireplumber/wireplumber.conf new file mode 100644 index 0000000..ad83094 --- /dev/null +++ b/.config/wireplumber/wireplumber.conf @@ -0,0 +1,806 @@ +## The WirePlumber configuration + +context.spa-libs = { + ## SPA factory name to library mappings + ## Used to find SPA factory names. It maps a SPA factory name regular + ## expression to a library name that should contain that factory. + ## + ## Syntax: + ## = + + api.alsa.* = alsa/libspa-alsa + api.bluez5.* = bluez5/libspa-bluez5 + api.v4l2.* = v4l2/libspa-v4l2 + api.libcamera.* = libcamera/libspa-libcamera + audio.convert.* = audioconvert/libspa-audioconvert + support.* = support/libspa-support +} + +context.modules = [ + ## PipeWire modules to load. + ## These modules are loaded before a connection to pipewire is attempted. + ## This section should be kept minimal and load only the modules that are + ## necessary for the protocol to work. + ## + ## If ifexists is given, the module is ignored when it is not found. + ## If nofail is given, module initialization failures are ignored. + ## + ## Syntax: + ## { + ## name = + ## [ args = { = ... } ] + ## [ flags = [ ifexists | nofail ] ] + ## } + + # Uses RTKit to boost the data thread priority. Also allows clamping + # of utilisation when using the Completely Fair Scheduler on Linux. + { + name = libpipewire-module-rt + args = { + nice.level = -11 + # rt.prio = 88 + # rt.time.soft = -1 + # rt.time.hard = -1 + # uclamp.min = 0 + # uclamp.max = 1024 + } + flags = [ ifexists, nofail ] + } + + ## The native communication protocol. + { name = libpipewire-module-protocol-native } + + ## Support for metadata objects + { name = libpipewire-module-metadata } +] + +wireplumber.profiles = { + ## Syntax: + ## = { + ## # optional is the default + ## = [ required | optional | disabled ] + ## ... + ## } + + # The default profile + main = { + check.no-media-session = required + metadata.sm-settings = required + support.settings = required + support.log-settings = required + metadata.sm-objects = required + hardware.audio = required + hardware.bluetooth = required + hardware.video-capture = required + policy.standard = required + } + + # Profile for video-only use cases (camera & screen sharing) + video-only = { + check.no-media-session = required + metadata.sm-settings = required + support.settings = required + support.log-settings = required + metadata.sm-objects = required + hardware.audio = disabled + hardware.bluetooth = disabled + hardware.video-capture = required + policy.standard = required + } +} + +wireplumber.components = [ + ## WirePlumber components to load. + ## These components are loaded after a connection to pipewire is established. + ## type is mandatory; rest of the tags are optional + ## + ## Syntax: + ## { + ## name = + ## type = + ## arguments = { } + ## + ## # Feature that this component provides + ## provides = + ## + ## # List of features that must be provided before this component is loaded + ## requires = [ ] + ## + ## # List of features that would offer additional functionality if provided + ## # but are not strictly required + ## wants = [ ] + ## } + + ## Check to avoid loading together with media-session + { + name = ensure-no-media-session, type = built-in + provides = check.no-media-session + } + + ## Makes a secondary connection to PipeWire for exporting objects + { + name = export-core, type = built-in + provides = support.export-core + } + + ## Enables creating local nodes that are exported to pipewire + ## This is needed for LocalNode() / WpImplNode + ## This should be used with the export-core to avoid protocol deadlocks, + ## unless you know what you are doing + { + name = libpipewire-module-client-node, type = pw-module + provides = pw.client-node + wants = [ support.export-core ] + } + + ## Enables creating local devices that are exported to pipewire + ## This is needed for SpaDevice() / WpSpaDevice + ## This should be used with the export-core to avoid protocol deadlocks, + ## unless you know what you are doing + { + name = libpipewire-module-client-device, type = pw-module + provides = pw.client-device + wants = [ support.export-core ] + } + + # Provides a node factory to create SPA nodes + # You need this to use LocalNode("spa-node-factory", ...) + { + name = libpipewire-module-spa-node-factory, type = pw-module + provides = pw.node-factory.spa + requires = [ pw.client-node ] + } + + ## Provides a node factory to create SPA nodes wrapped in an adapter + ## You need this to use LocalNode("adapter", ...) + { + name = libpipewire-module-adapter, type = pw-module + provides = pw.node-factory.adapter + requires = [ pw.client-node ] + } + + ## Provides the "sm-settings" metadata object + { + name = libwireplumber-module-settings, type = module + arguments = { metadata.name = sm-settings } + provides = metadata.sm-settings + } + + ## Activates a global WpSettings instance, providing settings from + ## the sm-settings metadata object + { + name = settings-instance, type = built-in + arguments = { metadata.name = sm-settings } + provides = support.settings + } + + ## Log level settings + { + name = libwireplumber-module-log-settings, type = module + provides = support.log-settings + } + + ## The lua scripting engine + { + name = libwireplumber-module-lua-scripting, type = module + provides = support.lua-scripting + } + + ## Module listening for pipewire objects to push events + { + name = libwireplumber-module-standard-event-source, type = module + provides = support.standard-event-source + } + + ## The shared D-Bus connection + { + name = libwireplumber-module-dbus-connection, type = module + provides = support.dbus + } + + ## Module managing the portal permissions + { + name = libwireplumber-module-portal-permissionstore, type = module + provides = support.portal-permissionstore + requires = [ support.dbus ] + } + + ## Needed for device reservation to work + { + name = libwireplumber-module-reserve-device, type = module + provides = support.reserve-device + requires = [ support.dbus ] + } + + ## logind integration to enable certain functionality only on the active seat + { + name = libwireplumber-module-logind, type = module + provides = support.logind + } + + ## Session item factories + { + name = libwireplumber-module-si-node, type = module + provides = si.node + } + { + name = libwireplumber-module-si-audio-adapter, type = module + provides = si.audio-adapter + } + { + name = libwireplumber-module-si-standard-link, type = module + provides = si.standard-link + } + + ## API to access default nodes from scripts + { + name = libwireplumber-module-default-nodes-api, type = module + provides = api.default-nodes + } + + ## API to access mixer controls + { + name = libwireplumber-module-mixer-api, type = module + provides = api.mixer + } + + ## API to get notified about file changes + { + name = libwireplumber-module-file-monitor-api, type = module + provides = api.file-monitor + } + + ## Provide the "default" pw_metadata + { + name = metadata.lua, type = script/lua + arguments = { metadata.name = default } + provides = metadata.default + } + + ## Provide the "filters" pw_metadata + { + name = metadata.lua, type = script/lua + arguments = { metadata.name = filters } + provides = metadata.filters + } + + ## Provide the "sm-objects" pw_metadata, supporting dynamic loadable objects + { + name = sm-objects.lua, type = script/lua + provides = metadata.sm-objects + } + + ## Device monitors' optional features + { + type = virtual, provides = monitor.alsa.reserve-device, + requires = [ support.reserve-device ] + } + { + type = virtual, provides = monitor.alsa-midi.monitoring, + requires = [ api.file-monitor ] + } + { + type = virtual, provides = monitor.bluez.seat-monitoring, + requires = [ support.logind ] + } + + ## Device monitors + { + name = monitors/alsa.lua, type = script/lua + provides = monitor.alsa + requires = [ support.export-core, pw.client-device ] + wants = [ monitor.alsa.reserve-device ] + } + { + name = monitors/bluez.lua, type = script/lua + provides = monitor.bluez + requires = [ support.export-core, + pw.client-device, + pw.client-node, + pw.node-factory.adapter ] + wants = [ monitor.bluez.seat-monitoring ] + } + { + name = monitors/bluez-midi.lua, type = script/lua + provides = monitor.bluez-midi + requires = [ support.export-core, + pw.client-device, + pw.client-node, + pw.node-factory.spa ] + wants = [ monitor.bluez.seat-monitoring ] + } + { + name = monitors/alsa-midi.lua, type = script/lua + provides = monitor.alsa-midi + wants = [ monitor.alsa-midi.monitoring ] + } + ## v4l2 monitor hooks + { + name = monitors/v4l2/name-device.lua, type = script/lua + provides = hooks.monitor.v4l2-name-device + requires = [ support.export-core, + support.standard-event-source ] + } + { + name = monitors/v4l2/create-device.lua, type = script/lua + provides = hooks.monitor.v4l2-create-device + requires = [ support.export-core, + pw.client-device, + support.standard-event-source ] + } + { + name = monitors/v4l2/name-node.lua, type = script/lua + provides = hooks.monitor.v4l2-name-node + requires = [ support.export-core, + support.standard-event-source ] + } + { + name = monitors/v4l2/create-node.lua, type = script/lua + provides = hooks.monitor.v4l2-create-node + requires = [ support.export-core, + support.standard-event-source ] + } + { + type = virtual, provides = monitor.v4l2.hooks + wants = [ hooks.monitor.v4l2-name-device, + hooks.monitor.v4l2-create-device, + hooks.monitor.v4l2-name-node, + hooks.monitor.v4l2-create-node ] + } + # enumerate-device.lua needs rest of the monitor hooks to be loaded first. + { + name = monitors/v4l2/enumerate-device.lua, type = script/lua + provides = hooks.monitor.v4l2-enumerate-device + requires = [ support.export-core, + pw.client-device, + support.standard-event-source, + monitor.v4l2.hooks ] + } + { + type = virtual, provides = monitor.v4l2 + wants = [ hooks.monitor.v4l2-enumerate-device, + monitor.v4l2.hooks ] + } + + ## libcamera monitor hooks + { + name = monitors/libcamera/name-device.lua, type = script/lua + provides = hooks.monitor.libcamera-name-device + requires = [ support.export-core, + support.standard-event-source ] + } + { + name = monitors/libcamera/create-device.lua, type = script/lua + provides = hooks.monitor.libcamera-create-device + requires = [ support.export-core, + pw.client-device, + support.standard-event-source ] + } + { + name = monitors/libcamera/name-node.lua, type = script/lua + provides = hooks.monitor.libcamera-name-node + requires = [ support.export-core, + support.standard-event-source ] + } + { + name = monitors/libcamera/create-node.lua, type = script/lua + provides = hooks.monitor.libcamera-create-node + requires = [ support.export-core, + support.standard-event-source ] + } + { + type = virtual, provides = monitor.libcamera.hooks + wants = [ hooks.monitor.libcamera-name-device, + hooks.monitor.libcamera-create-device, + hooks.monitor.libcamera-name-node, + hooks.monitor.libcamera-create-node ] + } + # enumerate-device.lua needs rest of the monitor hooks to be loaded first. + { + name = monitors/libcamera/enumerate-device.lua, type = script/lua + provides = hooks.monitor.libcamera-enumerate-device + requires = [ support.export-core, + pw.client-device, + support.standard-event-source, + monitor.libcamera.hooks ] + } + { + type = virtual, provides = monitor.libcamera + wants = [ hooks.monitor.libcamera-enumerate-device, + monitor.libcamera.hooks ] + } + + ## Client access configuration hooks + { + name = client/access-default.lua, type = script/lua + provides = script.client.access-default + } + { + name = client/access-portal.lua, type = script/lua + provides = script.client.access-portal + requires = [ support.portal-permissionstore ] + } + { + name = client/access-snap.lua, type = script/lua + provides = script.client.access-snap + } + { + type = virtual, provides = policy.client.access + wants = [ script.client.access-default, + script.client.access-portal, + script.client.access-snap ] + } + + ## Device profile selection hooks + { + name = device/select-profile.lua, type = script/lua + provides = hooks.device.profile.select + } + { + name = device/find-preferred-profile.lua, type = script/lua + provides = hooks.device.profile.find-preferred + } + { + name = device/find-best-profile.lua, type = script/lua + provides = hooks.device.profile.find-best + } + { + name = device/state-profile.lua, type = script/lua + provides = hooks.device.profile.state + } + { + name = device/apply-profile.lua, type = script/lua + provides = hooks.device.profile.apply + } + { + name = device/autoswitch-bluetooth-profile.lua, type = script/lua + provides = hooks.device.profile.autoswitch-bluetooth + } + { + type = virtual, provides = policy.device.profile + requires = [ hooks.device.profile.select, + hooks.device.profile.autoswitch-bluetooth, + hooks.device.profile.apply ] + wants = [ hooks.device.profile.find-best, hooks.device.profile.find-preferred, + hooks.device.profile.state ] + } + + # Device route selection hooks + { + name = device/select-routes.lua, type = script/lua + provides = hooks.device.routes.select + } + { + name = device/find-best-routes.lua, type = script/lua + provides = hooks.device.routes.find-best + } + { + name = device/state-routes.lua, type = script/lua + provides = hooks.device.routes.state + } + { + name = device/apply-routes.lua, type = script/lua + provides = hooks.device.routes.apply + } + { + type = virtual, provides = policy.device.routes + requires = [ hooks.device.routes.select, + hooks.device.routes.apply ] + wants = [ hooks.device.routes.find-best, + hooks.device.routes.state ] + } + + ## Default nodes selection hooks + { + name = default-nodes/rescan.lua, type = script/lua + provides = hooks.default-nodes.rescan + } + { + name = default-nodes/find-selected-default-node.lua, type = script/lua + provides = hooks.default-nodes.find-selected + requires = [ metadata.default ] + } + { + name = default-nodes/find-best-default-node.lua, type = script/lua + provides = hooks.default-nodes.find-best + } + { + name = default-nodes/state-default-nodes.lua, type = script/lua + provides = hooks.default-nodes.state + requires = [ metadata.default ] + } + { + name = default-nodes/apply-default-node.lua, type = script/lua, + provides = hooks.default-nodes.apply + requires = [ metadata.default ] + } + { + type = virtual, provides = policy.default-nodes + requires = [ hooks.default-nodes.rescan, + hooks.default-nodes.apply ] + wants = [ hooks.default-nodes.find-selected, + hooks.default-nodes.find-best, + hooks.default-nodes.state ] + } + + ## Node configuration hooks + { + name = node/create-item.lua, type = script/lua + provides = hooks.node.create-session-item + requires = [ si.audio-adapter, si.node ] + } + { + name = node/suspend-node.lua, type = script/lua + provides = hooks.node.suspend + } + { + name = node/state-stream.lua, type = script/lua + provides = hooks.stream.state + } + { + name = node/filter-forward-format.lua, type = script/lua + provides = hooks.filter.forward-format + } + { + type = virtual, provides = policy.node + requires = [ hooks.node.create-session-item ] + wants = [ hooks.node.suspend + hooks.stream.state + hooks.filter.forward-format ] + } + { + name = node/software-dsp.lua, type = script/lua + provides = node.software-dsp + } + + ## Linking hooks + { + name = linking/rescan.lua, type = script/lua + provides = hooks.linking.rescan + } + { + name = linking/find-media-role-target.lua, type = script/lua + provides = hooks.linking.target.find-media-role + } + { + name = linking/find-defined-target.lua, type = script/lua + provides = hooks.linking.target.find-defined + } + { + name = linking/find-filter-target.lua, type = script/lua + provides = hooks.linking.target.find-filter + requires = [ metadata.filters ] + } + { + name = linking/find-default-target.lua, type = script/lua + provides = hooks.linking.target.find-default + requires = [ api.default-nodes ] + } + { + name = linking/find-best-target.lua, type = script/lua + provides = hooks.linking.target.find-best + requires = [ metadata.filters ] + } + { + name = linking/get-filter-from-target.lua, type = script/lua + provides = hooks.linking.target.get-filter-from + requires = [ metadata.filters ] + } + { + name = linking/prepare-link.lua, type = script/lua + provides = hooks.linking.target.prepare-link + requires = [ api.default-nodes ] + } + { + name = linking/link-target.lua, type = script/lua + provides = hooks.linking.target.link + requires = [ si.standard-link ] + } + { + type = virtual, provides = policy.linking.standard + requires = [ hooks.linking.rescan, + hooks.linking.target.prepare-link, + hooks.linking.target.link ] + wants = [ hooks.linking.target.find-media-role, + hooks.linking.target.find-defined, + hooks.linking.target.find-filter, + hooks.linking.target.find-default, + hooks.linking.target.find-best, + hooks.linking.target.get-filter-from ] + } + + ## Linking: Role-based priority system + { + name = linking/rescan-media-role-links.lua, type = script/lua + provides = hooks.linking.role-based.rescan + requires = [ api.mixer ] + } + { + type = virtual, provides = policy.linking.role-based + requires = [ policy.linking.standard, + hooks.linking.role-based.rescan ] + } + + ## Standard policy definition + { + type = virtual, provides = policy.standard + requires = [ policy.client.access + policy.device.profile + policy.device.routes + policy.default-nodes + policy.linking.standard + policy.linking.role-based + policy.node + support.standard-event-source ] + } + + ## Load targets + { + type = virtual, provides = hardware.audio + wants = [ monitor.alsa, monitor.alsa-midi ] + } + { + type = virtual, provides = hardware.bluetooth + wants = [ monitor.bluez, monitor.bluez-midi ] + } + { + type = virtual, provides = hardware.video-capture + wants = [ monitor.v4l2, monitor.libcamera ] + } +] + +wireplumber.components.rules = [ + ## Rules to apply on top of wireplumber.components + ## Syntax: + ## { + ## matches = [ + ## { + ## [ = ... ] + ## } + ## ... + ## ] + ## actions = { + ## = { + ## [ = ... ] + ## } + ## ... + ## } + ## } + + { + matches = [ + { + type = "script/lua" + } + ] + actions = { + merge = { + requires = [ support.lua-scripting ] + } + } + } +] + +wireplumber.settings.schema = { + ## Bluetooth + bluetooth.use-persistent-storage = { + description = "Whether to use persistent BT storage or not" + type = "bool" + default = true + } + bluetooth.autoswitch-to-headset-profile = { + description = "Whether to autoswitch to BT headset profile or not" + type = "bool" + default = true + } + + ## Device + device.restore-profile = { + description = "Whether to restore device profile or not" + type = "bool" + default = true + } + device.restore-routes = { + description = "Whether to restore device routes or not" + type = "bool" + default = true + } + device.routes.default-sink-volume = { + description = "The default volume for sink devices" + type = "float" + default = 0.064 + min = 0.0 + max = 1.0 + } + device.routes.default-source-volume = { + description = "The default volume for source devices" + type = "float" + default = 1.0 + min = 0.0 + max = 1.0 + } + + ## Linking + linking.role-based.duck-level = { + description = "The volume level to apply when ducking (= reducing volume for a higher priority stream to be audible) in the role-based linking policy" + type = "float" + default = 0.3 + min = 0.0 + max = 1.0 + } + linking.allow-moving-streams = { + description = "Whether to allow metadata to move streams at runtime or not" + type = "bool" + default = true + } + linking.follow-default-target = { + description = "Whether to allow streams follow the default device or not" + type = "bool" + default = true + } + + ## Monitor + monitor.camera-discovery-timeout = { + description = "The camera discovery timeout in milliseconds" + type = "int" + default = 1000 + min = 0 + max = 60000 + } + + ## Node + node.features.audio.no-dsp = { + description = "Whether to never convert audio to F32 format or not" + type = "bool" + default = false + } + node.features.audio.monitor-ports = { + description = "Whether to enable monitor ports on audio nodes or not" + type = "bool" + default = true + } + node.features.audio.control-port = { + description = "Whether to enable control ports on audio nodes or not" + type = "bool" + default = false + } + node.stream.restore-props = { + description = "Whether to restore properties on stream nodes or not" + type = "bool" + default = true + } + node.stream.restore-target = { + description = "Whether to restore target on stream nodes or not" + type = "bool" + default = true + } + node.stream.default-playback-volume = { + description = "The default volume for playback nodes" + type = "float" + default = 1.0 + min = 0.0 + max = 1.0 + } + node.stream.default-capture-volume = { + description = "The default volume for capture nodes" + type = "float" + default = 1.0 + min = 0.0 + max = 1.0 + } + node.stream.default-media-role = { + description = "A media.role to assign on streams that have none specified" + type = "string" + default = null + } + node.filter.forward-format = { + description = "Whether to forward format on filter nodes or not" + type = "bool" + default = false + } + node.restore-default-targets = { + description = "Whether to restore default targets or not" + type = "bool" + default = true + } +} diff --git a/.config/wireplumber/wireplumber.conf.d/alsa-vm.conf b/.config/wireplumber/wireplumber.conf.d/alsa-vm.conf new file mode 100644 index 0000000..c7b9693 --- /dev/null +++ b/.config/wireplumber/wireplumber.conf.d/alsa-vm.conf @@ -0,0 +1,23 @@ +# ALSA node property overrides for virtual machine hardware + +monitor.alsa.rules = [ + # Generic PCI cards on any VM type + { + matches = [ + { + node.name = "~alsa_input.pci.*" + cpu.vm.name = "~.*" + } + { + node.name = "~alsa_output.pci.*" + cpu.vm.name = "~.*" + } + ] + actions = { + update-props = { + api.alsa.period-size = 1024 + api.alsa.headroom = 2048 + } + } + } +]