Compare commits

...

84 commits

Author SHA1 Message Date
George Kiagiadakis
84429b4794 0.5.13 2025-12-23 20:48:36 +02:00
Zander Brown
58b48c0a8a m-mpris: Check variant type directly 2025-12-23 20:05:09 +02:00
Zander Brown
af7a951bd9 m-mpris: We must chain up on finalize 2025-12-23 20:05:09 +02:00
Zander Brown
ded213093d m-mpris: Only initialise the builder once 2025-12-23 20:05:09 +02:00
Zander Brown
3a6f2c1e90 m-mpris: ‘Item’s are allocated with GLib
As are the string copies they point to, don't leak them and free them
the right way.
2025-12-23 20:05:09 +02:00
Zander Brown
1846d75717 m-mpris: ‘items’ is a GHashTable, not a GObject
That did fun things to my session that did.
2025-12-23 20:00:18 +02:00
George Kiagiadakis
444bfc04d8 Revert "state-routes.lua: Add new 'bluetooth.keep-volume-on-profile-changed' setting"
This reverts commit 00c272670c.

https://gitlab.freedesktop.org/pipewire/wireplumber/-/merge_requests/739#note_3163620
2025-12-23 19:57:12 +02:00
Julian Bouzas
3887e1ca82 monitor/bluez.lua: Don't set priority.driver in loopback nodes
Loopback nodes should never be the driver as it can cause audio issues.
2025-12-23 19:54:07 +02:00
Julian Bouzas
00c272670c state-routes.lua: Add new 'bluetooth.keep-volume-on-profile-changed' setting
If enabled, this setting will use the same volume levels as the previous
profile. This is useful on some bluetooth devices if the bluetooth profile
audioswitch is enabled.
2025-12-23 19:54:07 +02:00
Julian Bouzas
da831fdc65 monitors/bluez.lua: Create sink loopback for SCO-A2DP sink nodes
If the BT profile autoswitch setting is enabled, we also want to create a sink
loopback for SCO-A2DP sink nodes. Since BT nodes are removed and created again
when the profile changes, this avoids confusing some apps making them think
that the BT profile has not changed at all, because the loopback nodes are
always present, even when switching profiles.
2025-12-23 19:54:07 +02:00
Julian Bouzas
6a9e977d26 autoswitch-bluetooth-profile.lua: Refactor and fix issues with saved profiles
This patch improves the BT profile autoswitch logic so that it is simpler and
more robust. Instead of just relying on capture clients that are linked to (or
unlinked from) the BT loopback source node to evaluate whether we have to switch
to a headset profile or not, we now also evaluate the autoswitch every time the
BT loopback source node state changes. This avoids problems with some capture
clients that pause the input stream without closing them.

Apart from this, the patch also fixes some issues with saved profiles if the
user manually switched to a headset profile when no capture streams are present.
The autoswitch logic should restore back the non-headset profile (A2DP) every
time a capture client is disconnected or the BT loopback source node stops
running.

Finally, the 'bluetooth.autoswitch-to-headset-profile' setting will now register
or remove the necessary hooks depending on whether the setting is enabled or
disabled, improving WirePlumber performance if autoswitching is disabled.
2025-12-23 19:54:07 +02:00
filmsi
80478e7548 Update Slovenian translation (sl.po) 2025-12-16 16:03:57 +02:00
Barnabás Pőcze
3fb5b775ee m-modem-manager: Unref WpCore
When getting the "core" property from a `WpObject`, a strong reference is
returned to the `WpCore` object. This has to be unref-d when not needed
anymore. `wp_modem_manager_enable()` fails to do so, so fix it.

Fixes: 2794764d5a ("m-modem-manager: add module for tracking status of voice calls")
2025-12-16 15:59:07 +02:00
lumingzh
a5538f4167 update Chinese translation 2025-12-16 10:14:56 +08:00
Julian Bouzas
bec20fc054 create-item: Only configure audio device sink nodes in MONO
This allows multi channel mixing in the graph, even if MONO is enabled.
2025-12-08 09:43:56 -05:00
George Kiagiadakis
beded0214d meson: define SPA_AUDIO_MAX_CHANNELS only on newer spa headers
Older spa headers define this without an #ifndef check,
which could lead to compilation issues.
2025-12-02 12:59:58 +02:00
George Kiagiadakis
2286152c07 ci: adapt pipewire build options based on the pw version we are building 2025-12-02 12:32:01 +02:00
George Kiagiadakis
94fe1cbfbd ci: add builds with older versions of libpipewire 2025-12-02 11:59:45 +02:00
Torkel Niklasson
6ebf81453c rescan: Optimize linking for simple stream nodes
Add immediate linking for common cases to reduce latency when new
audio/video streams are added. The full rescan mechanism is preserved
and still triggers for all session-item-added events via the
rescan-trigger hook.
2025-11-28 11:05:34 +00:00
Wim Taymans
ceed5dca7c meson: bump max channels to 128
Recompiling with the SPA_AUDIO_MAX_CHANNELS=128u defined, will create
larger audio_info structures that can hold more channels. Because
PipeWire is now using 128 channels, do the same in wireplumber so that
the generated PortConfig param can hold all channel positions.

See pipewire#4995
2025-11-26 10:35:00 +01:00
Evangelos Ribeiro Tzaras
84e4752f1a m-modem-manager: Prefer automatic cleanup
Signed-off-by: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
2025-11-25 14:41:42 +02:00
Evangelos Ribeiro Tzaras
9e390f1121 m-modem-manager: Avoid memory allocations unpacking string types
Signed-off-by: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
2025-11-25 14:41:42 +02:00
Evangelos Ribeiro Tzaras
133b82e61a m-modem-manager: Don't leak path
Fixes: 2794764d5a (m-modem-manager: add module for tracking status of voice calls)

Signed-off-by: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
2025-11-25 14:41:42 +02:00
Evangelos Ribeiro Tzaras
9045d2439a m-modem-manager: Don't leak error
And include the error message while we're at it.

Fixes: 2794764d5a (m-modem-manager: add module for tracking status of voice calls)

Signed-off-by: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
2025-11-25 14:41:42 +02:00
Evangelos Ribeiro Tzaras
278541f637 m-modem-manager: Set GDBusConnection before trying to use it
Fixes: 2794764d5a (m-modem-manager: add module for tracking status of voice calls)

Signed-off-by: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
2025-11-25 14:41:42 +02:00
Evangelos Ribeiro Tzaras
27b6027649 m-modem-manager: Use correct error clearing function
We segfault otherwise on error:

  Thread 1 "wireplumber" received signal SIGSEGV, Segmentation fault.
  g_type_check_instance_is_fundamentally_a (type_instance=type_instance@entry=0x7fffec008120,
      fundamental_type=fundamental_type@entry=0x50 [GObject]) at ../../../gobject/gtype.c:3917
  warning: 3917	../../../gobject/gtype.c: No such file or directory
  (gdb) bt
  #0  g_type_check_instance_is_fundamentally_a
      (type_instance=type_instance@entry=0x7fffec008120, fundamental_type=fundamental_type@entry=0x50 [GObject])
      at ../../../gobject/gtype.c:3917
  #1  0x00007ffff7eafe3d in g_object_unref (_object=0x7fffec008120) at ../../../gobject/gobject.c:4743
  #2  0x00007ffff4784ec1 in list_calls_done (obj=<optimized out>, res=<optimized out>, data=0x5555556ab1b0)
      at ../modules/module-modem-manager.c:209

Fixes: 2794764d5a (m-modem-manager: add module for tracking status of voice calls)

Signed-off-by: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
2025-11-25 14:41:42 +02:00
Guido Günther
6398bf1bce docs: Switch to build directory for run invocation
There's no `Makefile` in the top level build directory so
switch to the build dir.

Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-11-24 23:23:51 +01:00
Julian Bouzas
f196d10e87 linking/rescan.lua: Clean a bit the code using the new properties API 2025-11-24 19:42:07 +02:00
Julian Bouzas
5071a85997 scripts: Fix compatibility issues with new Lua Properties API
We need to explicitly use Properties() in those cases as {} construct tables.
2025-11-24 19:42:07 +02:00
Julian Bouzas
15d98f59e5 state-routes: use get_count() instead of next()
We cannot use next() as the properties are not a Lua table anymore.
2025-11-24 19:42:07 +02:00
Julian Bouzas
be6f2b2926 m-lua-scripting: Handle both Properties and Lua tables in all Lua APIs 2025-11-24 19:42:07 +02:00
Julian Bouzas
01eb206460 m-lua-scripting: Add get_property() API for pipewire objects
This can be faster if we only want to get one property.
2025-11-24 19:42:07 +02:00
Julian Bouzas
5a4ecceee6 m-lua-scripting: Add get_property() API for session items
This can be faster if we only want to get one property.
2025-11-24 19:42:07 +02:00
Julian Bouzas
a35e40c1d2 m-lua-scripting: Add WpProperties API
Similar to Pod and Json, this API allows handling WpProperties references.
2025-11-24 19:42:07 +02:00
Julian Bouzas
2712cbb5a9 pipewire-object-mixin: Copy the props instead of wrapping them
This allows users to have full control of the properties when they get them.
2025-11-24 19:42:07 +02:00
Julian Bouzas
c68eb59017 device: Copy the props instead of wrapping them before emitting create-device signal
This allows the signal handlers to have full control of the properties.
2025-11-24 19:42:07 +02:00
Julian Bouzas
c0e047c241 apply-default-node: Make sure the metadata is valid
This fixes some warnings in the Lua tests scripts from tests/scripts/scripts/*
2025-11-24 19:42:07 +02:00
Julian Bouzas
238fd3c067 event-dispatcher: Sort hooks when registering them
This avoids sorting them constantly when collecting them. If a hook has a
circular dependency, a warning will be logged and the hook won't be registered.

See #824
2025-11-24 08:01:06 -05:00
Julian Bouzas
b80a0975c7 event-dispatcher: Register hooks for defined events in a hash table
Since all the current hooks are defined specifically for a particular event
type, we can register the hooks in a hash table using the event type as key
for faster event hook collection.

Also, hooks that are not specific to a particular event type, like constraints
such as 'event.type=*', will be registered in both the undefined hook list,
and also in all the hash table defined hook lists so they are always evaluated.

Even though 'wp_event_dispatcher_new_hooks_iterator()' can still be used, it is
now marked as deprecated because it is slower. The event hook collection uses
'wp_event_dispatcher_new_hooks_for_event_type_iterator()' now because it is
much faster.

Previously, the more hooks we were registering, the slower WirePlumber would
process events as all hooks needed to be evaluated for all events constantly.
This is not the case anymore with this patch. We can register thousands of
hooks, and if only 1 of those runs for a particular event, only 1 will be
evaluated instead of all of them.

See #824
2025-11-24 08:01:00 -05:00
qaqland
7ca21699a9 wpctl: add bash completions 2025-11-17 18:00:48 +08:00
Julian Bouzas
551353482a monitor/alsa: Also include alsa.* device properties for rule matching
UCM alsa nodes don't seem to have the 'alsa.*' properties from the device
included, which make it harder to match those nodes with alsa rules. This
patch adds all the 'alsa.*' properties in the UCM node to solve this.
2025-11-13 11:09:41 -05:00
George Kiagiadakis
f0b224b210 po: update Turkish translation
Closes: #871
2025-11-10 12:45:20 +02:00
George Kiagiadakis
0dad52f774 event-hook: simplify interest matching
We never use the feature of matching the subject type, so we can make
this simpler by not specifying CHECK_ALL, which also allows the
match_full() function to return early if some constraint doesn't match
instead of checking them all and wasting time.
2025-10-31 18:52:03 +02:00
Charles
27f97f6c45 monitors/alsa: Increase headroom for VMware and VirtualBox
Ubuntu received reports of very bad audio stuttering when running
25.10 in VMware & Virtualbox on Windows 11 hosts.

ac0d8ee4a8 dropped the headroom from
8192 samples to 2048. This seems fine for QEMU+KVM, but for unknown
reasons, the system emulation on Windows with these VMware/Virtualbox
is much slower, and xruns become frequent.

See:
https://bugs.launchpad.net/ubuntu/+source/wireplumber/+bug/2127250
https://discourse.ubuntu.com/t/vm-ubuntu2025-10-on-vmware-workstation-and-sound-problem/71066/3
2025-10-28 04:49:03 +00:00
filmsi
285230af67 Update Slovenian translation 2025-10-26 08:28:03 +00:00
Guido Günther
c095ae5254 role-based-policy: Allow to set target sink for media role loopbacks
There are streams that should go to a speaker rather than a headphone
or earpiece by default. Examples are alarms and emergency alerts on
phones. Allow to set a preference via
`policy.role-based.preferred-target` which then looks up the target
via `node.name` and `node.nick`.

Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-25 16:12:01 +03:00
Guido Günther
d2a49e8bc5 docs/linking: Fix typo
Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-25 16:12:01 +03:00
Guido Günther
b2c4993ab5 doc: Fix role based policy name
The role-priority-system doesn't exist anymore

Fixes: 0d995c56 ("wireplumber.conf: improve standard policy definition")
Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-25 16:12:01 +03:00
Julian Bouzas
ae30b4f022 state-profile: Handle new 'session.dont-restore-off-profile' property
This avoids restoring the Off profile if it was saved before. The property can
be set using the 'monitor.alsa.rules' section of the configuration.
2025-10-25 15:36:00 +03:00
Guido Günther
962be34a2b docs: Update rescan-for-linking priority
`rescan-for-linking` is the lowest priority linking event but the
`rescan-for-media-role-volume` event is lower overall priority.

Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-24 12:24:25 +02:00
Guido Günther
6f5ca5a79d wireplumber.conf: Enable default volume control tracking
Track a suitable default volume for media role based policies.

Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-24 12:20:33 +02:00
Guido Günther
5ecfe9f555 scripts: Add script to find a suitable volume control
When using role based priorities a volume slider presented to the
user should adjust the volume of the currently playing role.

E.g. when a phone has an incoming call and is ringing the default volume
slider should adjust the ringing volume and once the call has been
picked up it should adjust the call volume and once the call ended it
should adjust the media volume again.

It's currently hard for e.g. desktop shells to find out what a suitable
sink for volume control is so add a script to find a suitable target for
volume control (based on media role priority) by storing it's name in
the metadata.

Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-24 12:20:33 +02:00
Guido Günther
93377a8b4f m-std-event-source: Add rescan-for-media-role-volume
This allows us to do reduce the number of default volume updates.

Signed-off-by: Guido Günther <agx@sigxcpu.org>
2025-10-24 12:18:56 +02:00
Wim Taymans
ee72196500 m-si-audio-adapter: don't overread the position array
Limit the amount of channels we read from and write to the position
array with SPA_N_ELEMENTS(). The number of channels might be larger than
what we have positions for.
2025-10-21 16:31:00 +02:00
Julian Bouzas
e30c2a7cd9 state-routes.lua: Use the returned iterator from enum_params() to update routes info
This does not really fix anything but it saves some CPU cycles as we don't need
to get the route params from the cache anymore.
2025-10-17 09:05:27 -04:00
Julian Bouzas
4239055454 m-lua-scripting: Pass returned itrator to the closure when finishing enum_param()
We should not ignore the returned iterator as it allows users to get the exact
returned params after enumerating them, which might be useful in some cases.
2025-10-17 09:05:27 -04:00
Julian Bouzas
f188ddfb34 m-lua-scripting: Add WpIterator API
We skip the fold() API as the same thing can be done just using Lua closures.
2025-10-17 09:05:22 -04:00
Julian Bouzas
fb1738932b audio-group.lua: Demote creation of audio group log to info
This was never meant to be a warning message.
2025-10-17 15:49:02 +03:00
Julian Bouzas
f8be5a76e6 proc-utils: Make sure '/proc/<pid>/*' files exist before opening them
This avoids warnings if the specific file does not exist, especially when the
process has being removed quickly.

Fixes #816
2025-10-17 15:49:02 +03:00
Julian Bouzas
5c6a72e3cf m-si-standard-link: log error message when link activation fails
This makes debugging easier if a link fails to activate.
2025-10-17 08:41:48 -04:00
lumingzh
7b78078ed2 update Chinese translation 2025-10-14 10:01:43 +08:00
Julian Bouzas
6cfaf3f70d automute-alsa-routes.lua: Don't register/remove hooks if never registered/removed before
This avoids avent dispatcher errors in the log.
2025-10-13 16:15:34 +03:00
Julian Bouzas
2942903d0e scripts: Add node/filter-graph.lua 2025-10-13 15:48:41 +03:00
Arun Raghavan
38a21ea191 monitor/alsa: Add a setting to use HDMI channel detection
This allows us to set up the device to use HDMI ELD information for
channels. Not yet documented while we experiment with different ways to
make this work.
2025-10-10 15:44:48 -07:00
George Kiagiadakis
41b310c2d5 Add AGENTS.md
This helps steer LLMs when operating on this codebase - https://agents.md/

Mostly to help me with making releases for now
2025-10-10 18:03:07 +03:00
George Kiagiadakis
499916b996 0.5.12 2025-10-10 17:43:57 +03:00
Charles
627b003a05 m-permissions-portal: Avoid race condition during shutdown
Attempts to workaround a race condition between daemon thread and
GDBus worker thread during shutdown.

Ubuntu bug: https://bugs.launchpad.net/bugs/2127049

I've not been able to get a symbolic backtrace yet or reproduce it
myself, but the behaviour points to a threading bug. Hypothesis,

Main thread (1, daemon thread) shuts down, unregistering its plugins.
One of the plugins, module-permissions-portal, is triggered to
shutdown.
It tries to clear its GDBus connection handle without disconnecting
its signal handlers.
GDBus thread (2) is in the middle of writing a message on the same
connection handle.
Once finished, it also tries to clear its handle.
The main thread has already taken the signal lock and the signal
handler table ends up in an invalid state, triggering the assert.

I believe this could happen since
wp_portal_permissionstore_plugin_disable is not disconnecting its
signal handlers before trying to clear its DBus object.

See https://bugzilla.gnome.org/show_bug.cgi?id=730296 for more
discussion about this assert in the Glib signal handling code.
2025-10-10 13:04:01 +03:00
lumingzh
385fc83f46 update Chinese translation 2025-10-02 07:58:47 +08:00
Julian Bouzas
f82247c42c config: Add new 'node.features.audio.mono' setting
This setting allows users to toggle between MONO audio or not at runtime.
2025-10-01 18:59:13 +03:00
Julian Bouzas
084b3aab89 m-si-audio-adapter: Add new 'item.features.mono' configuration property
This allows configuring the audio adapter in MONO. The property is set to FALSE
by default.
2025-10-01 18:59:13 +03:00
Julian Bouzas
71f98c40f0 create-item: Reconfigure audio adapters if 'node.features.audio.*' settings changed
Up until now, all the 'node.features.audio.*' settings did not have any effect
if changed at runtime. This patch fixes this by reconfiguring the audio adapters
every time those settings have changed.
2025-10-01 18:59:13 +03:00
Julian Bouzas
2a4aa9281c m-si-audio-adapter: Configure the node ports if the item has been re-configured
If we re-configure the adapter with different settings than the ones from the
first configuration, we also need to configure the node ports to make sure they
are updated with the new settings.

For example, this is needed for stream audio adapters that have been configured
with monitor ports, and later re-configured without monitor ports. Without this
change, since stream audio adapters never configure the node ports on activation,
the internal node will still have the monitor ports present even after disabling
them in the 2nd re-configuration.
2025-10-01 18:59:13 +03:00
Julian Bouzas
35d63a7847 scripts: Add automute-alsa-routes.lua to auto-mute ALSA routes
This script mutes available output ALSA routes if an audio node that was
previously running was removed. This is useful for cases where users might
unplug their headset accidentaly, causing undesired loud audio to play on the
Speakers.

Two new settings are added to chose whether the user wants to do this for
ALSA devices, Bluetooth devices or both. The settings are set to false by
default.

Finally, a notification is also sent to notify the user that the devices were
muted.
2025-10-01 18:04:58 +03:00
Julian Bouzas
d21ff24ea1 modules: Add notifications-api module
This allows sending Desktop notifications using D-Bus.
2025-10-01 18:04:58 +03:00
lumingzh
68bd93e1ed Update Chinese translation 2025-09-29 12:49:33 +03:00
Carlos Rafael Giani
a461d9e738 object-interest: set pw_props variable if not set and global props exist
Otherwise, object manager lookups and iterations with type "pw" may
not work properly.
2025-09-10 15:04:36 +02:00
Julian Bouzas
ebd9d2a7d5 state-routes.lua: Make sure the device is still valid after doing enum_params() 2025-09-10 15:43:57 +03:00
Pablo Correa Gómez
7eacea9da9
apply-profile: add find-calling-profile to after array 2025-09-07 10:23:26 +02:00
Pablo Correa Gómez
dcf083ef3b
find-voice-call-profile: improve logging
To make it more specific to the hook and avoid confusion with the
best-profile one.
2025-09-07 10:23:26 +02:00
Pablo Correa Gómez
a6c0bb202d
scripts: document device/find-calling-profile hook 2025-09-07 10:23:26 +02:00
Pablo Correa Gómez
97d8761914
scripts: document device/find-preferred-profile hook 2025-09-07 10:23:26 +02:00
George Kiagiadakis
df0136ce0b docs: improve copyright & author statements 2025-09-05 18:19:25 +03:00
George Kiagiadakis
a5e58536dd docs: add wpctl man page and Tools documentation section
Add comprehensive wpctl documentation that generates both HTML docs and an installable man page from a single RST source.

Closes: #825

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 18:15:08 +03:00
Yukari Chiba
1bde4f2cdf device-info-cache: use Log.warning instead of Log.critical
Log.critical does not actually exist.
When logging error, it will in fact throw an exception:

[string "device-info-cache.lua"]:36: attempt to call a nil value
(field 'critical')

Change Log.critical to Log.warning to fix it.
2025-09-05 01:40:05 +08:00
71 changed files with 3843 additions and 948 deletions

View file

@ -154,13 +154,22 @@ include:
# Fedora also ships that, but without the test plugins that we need...
- git clone --depth=1 --branch="$PIPEWIRE_HEAD"
https://gitlab.freedesktop.org/pipewire/pipewire.git
- meson "$PW_BUILD_DIR" pipewire --prefix="$PREFIX"
-Dpipewire-alsa=disabled -Dpipewire-jack=disabled
-Dalsa=disabled -Dv4l2=disabled -Djack=disabled -Dbluez5=disabled
-Dvulkan=disabled -Dgstreamer=disabled -Dlibsystemd=disabled
-Ddocs=disabled -Dman=disabled -Dexamples=disabled -Dpw-cat=disabled
-Dsdl2=disabled -Dsndfile=disabled -Dlibpulse=disabled -Davahi=disabled
-Decho-cancel-webrtc=disabled -Dsession-managers=[]
# Set build options based on PipeWire version
- |
case "$PIPEWIRE_HEAD" in
1.0|1.2|1.4)
export PIPEWIRE_BUILD_OPTIONS="-Dsystemd=disabled"
;;
*)
export PIPEWIRE_BUILD_OPTIONS="-Dlibsystemd=disabled"
;;
esac
- meson "$PW_BUILD_DIR" pipewire --prefix="$PREFIX" $PIPEWIRE_BUILD_OPTIONS
-Dpipewire-alsa=disabled -Dpipewire-jack=disabled -Dalsa=disabled
-Dv4l2=disabled -Djack=disabled -Dbluez5=disabled -Dvulkan=disabled
-Dgstreamer=disabled -Ddocs=disabled -Dman=disabled -Dexamples=disabled
-Dpw-cat=disabled -Dsdl2=disabled -Dsndfile=disabled -Dlibpulse=disabled
-Davahi=disabled -Decho-cancel-webrtc=disabled -Dsession-managers=[]
-Dvideotestsrc=enabled -Daudiotestsrc=enabled -Dtest=enabled
- ninja $NINJA_ARGS -C "$PW_BUILD_DIR" install
# misc environment only for wireplumber
@ -236,6 +245,9 @@ build_on_fedora_no_docs:
stage: build
variables:
BUILD_OPTIONS: -Dintrospection=enabled -Ddoc=disabled -Dsystem-lua=false
parallel:
matrix:
- PIPEWIRE_HEAD: ['master', '1.4', '1.2', '1.0']
build_on_ubuntu_with_gir:
extends:

37
AGENTS.md Normal file
View file

@ -0,0 +1,37 @@
## Building and Testing
- To compile the project: `meson compile -C build` (compiles everything, no target needed)
- To run tests: `meson test -C build`
- The build artifacts always live in a directory called `build` or `builddir`.
If `build` doesn't exist, use `-C builddir` in the meson commands.
## Git Workflow
- Main branch: `master`
- Always create feature branches for new work
- Use descriptive commit messages following project conventions
- Reference GitLab MR/issue numbers in commits where applicable
- Never commit build artifacts or temporary files
- Use `glab` CLI tool for GitLab interactions (MRs, issues, etc.)
## Making a release
- Each release always consists of an entry in NEWS.rst, at the top of the file, which describes
the changes between the previous release and the current one. In addition, each release is given
a unique version number, which is present:
1. on the section header of that NEWS.rst entry
2. in the project() command in meson.build
3. on the commit message of the commit that introduces the above 2 changes
4. on the git tag that marks the above commit
- In order to make a release:
- Begin by analyzing the git history and the merged MRs from GitLab between the previous release
and today. GitLab MRs that are relevant always have the new release's version number set as a
"milestone"
- Create a new entry in NEWS.rst describing the changes, in a similar style and format as the
previous entries. Consolidate the changes to larger work items and also reference the relevant
gitlab MR that corresponds to each change and/or the gitlab issues that were addressed by each
change.
- Make sure to move the "Past releases" section header up, so that the only 2 top-level sections
are the new release section and the "Past releases" section.
- Edit meson.build to change the project version to the new release number
- Do not commit anything to git. Let the user review the changes and commit manually.

104
NEWS.rst
View file

@ -1,6 +1,105 @@
WirePlumber 0.5.11
WirePlumber 0.5.13
~~~~~~~~~~~~~~~~~~
Additions & Enhancements:
- Added internal filter graph support for audio nodes, allowing users to
create audio preprocessing and postprocessing chains without exposing
filters to applications, useful for software DSP (!743)
- Added new Lua Properties API that significantly improves performance by
avoiding constant serialization between WpProperties and Lua tables,
resulting in approximately 40% faster node linking (!757)
- Added WpIterator Lua API for more efficient parameter enumeration (!746)
- Added bash completions for wpctl command (!762)
- Added script to find suitable volume control when using role-based policy,
allowing volume sliders to automatically adjust the volume of the currently
active role (e.g., ringing, call, media) (!711)
- Added experimental HDMI channel detection setting to use HDMI ELD
information for channel configuration (!749)
- Enhanced role-based policy to allow setting preferred target sinks for
media role loopbacks via ``policy.role-based.preferred-target`` (!754)
- Enhanced Bluetooth profile autoswitch logic to be more robust and handle
saved profiles correctly, including support for loopback sink nodes (!739)
- Enhanced ALSA monitor to include ``alsa.*`` device properties on nodes for
rule matching (!761)
- Optimized stream node linking for common cases to reduce latency when new
audio/video streams are added (!760)
- Improved event dispatcher performance by using hash table registration for
event hooks, eliminating performance degradation as more hooks are
registered (!765)
- Increased audio headroom for VMware and VirtualBox virtual machines (!756)
- Added setting to prevent restoring "Off" profiles via
``session.dont-restore-off-profile`` property (!753)
- Added support for 128 audio channels when compiled with a recent version of
PipeWire (pipewire#4995; CI checks in !768)
Fixes:
- Fixed memory leaks and issues in the modem manager module (!770, !764)
- Fixed MPRIS module incorrectly treating GHashTable as GObject (!759)
- Fixed warning messages when process files in ``/proc/<pid>/*`` don't exist,
particularly when processes are removed quickly (#816, !717)
- Fixed MONO audio configuration to only apply to device sink nodes, allowing
multi-channel mixing in the graph (!769)
- Fixed event dispatcher hook registration and removal to avoid spurious
errors (!747)
- Improved logging for standard-link activation failures (!744)
- Simplified event-hook interest matching for better performance (!758)
Past releases
~~~~~~~~~~~~~
WirePlumber 0.5.12
..................
Additions & Enhancements:
- Added mono audio configuration support via ``node.features.audio.mono``
setting that can be changed at runtime with wpctl (!721)
- Added automatic muting of ALSA devices when a running node is removed,
helping prevent loud audio on speakers when headsets are unplugged (!734)
- Added notifications API module for sending system notifications (!734)
- Added comprehensive wpctl man page and documentation (!735, #825)
- Enhanced object interest handling for PipeWire properties on session items (!738)
Fixes:
- Fixed race condition during shutdown in the permissions portal module that
could cause crashes in GDBus signal handling (!748)
- Added device validity check in state-routes handling to prevent issues
when devices are removed during async operations (!737, #844)
- Fixed Log.critical undefined function error in device-info-cache (!733)
- Improved device hook documentation and configuration (!736)
WirePlumber 0.5.11
..................
Additions & Enhancements:
- Added modem manager module for tracking voice call status and voice call
@ -39,9 +138,6 @@ Fixes:
- Fixed GObject introspection closure annotation
Past releases
~~~~~~~~~~~~~
WirePlumber 0.5.10
..................

View file

@ -1,11 +1,15 @@
# -- Project information -----------------------------------------------------
project = 'WirePlumber'
copyright = '2021-2024, Collabora'
author = 'Collabora'
copyright = '2020-2025, Collabora & contributors'
author = 'The WirePlumber Developers'
release = '@VERSION@'
version = '@VERSION@'
# -- General configuration ---------------------------------------------------
smartquotes = False
# -- Breathe configuration ---------------------------------------------------
extensions = [
@ -43,3 +47,9 @@ html_css_files = ['custom.css']
graphviz_output_format = "svg"
pygments_style = "friendly"
# -- Options for manual page output -----------------------------------------
man_pages = [
('tools/wpctl', 'wpctl', 'WirePlumber Control CLI', ['The WirePlumber Developers'], 1)
]

View file

@ -102,6 +102,26 @@ if build_doc
install_dir: wireplumber_doc_dir,
build_by_default: true,
)
# Generate man pages directory with sphinx
custom_target('manpages',
command: [sphinx_p,
'-q', # quiet
'-E', # rebuild from scratch
'-b', 'man', # man page builder
'-d', '@PRIVATE_DIR@', # doctrees dir
'-c', '@OUTDIR@', # conf.py dir
'@CURRENT_SOURCE_DIR@/rst', # source dir
'@OUTDIR@', # output directory
],
depend_files: [
sphinx_conf, sphinx_files,
],
output: ['wpctl.1'],
install: true,
install_dir: get_option('mandir') / 'man1',
build_by_default: true,
)
endif
# Build GObject introspection

View file

@ -140,9 +140,9 @@ Policies
for enabling devices, linking streams, granting permissions to clients,
etc, as appropriate for a desktop system.
.. describe:: policy.role-priority-system
.. describe:: policy.role-based
Enables the role priority system policy. This system creates virtual sinks
Enables the role based priority system policy. This system creates virtual sinks
that group streams based on their ``media.role`` property, and assigns a
priority to each role. Depending on the priority configuration, lower
priority roles may be corked or ducked when a higher priority role stream

View file

@ -40,7 +40,7 @@ Synopsis:
$ meson -Dsession-managers="[ 'wireplumber' ]" build
$ ninja -C build
$ make run
$ make -C build run
Run independently or without installing
---------------------------------------

View file

@ -44,6 +44,12 @@ Table of Contents
scripting/existing_scripts.rst
scripting/custom_scripts.rst
.. toctree::
:maxdepth: 2
:caption: Tools
tools/wpctl.rst
.. toctree::
:maxdepth: 2
:caption: Resources

View file

@ -8,4 +8,5 @@ subdir('design')
subdir('policies')
subdir('library')
subdir('scripting')
subdir('tools')
subdir('resources')

View file

@ -42,7 +42,7 @@ assignments:
}
After that, once the media class of a device node has been select for a
After that, once the media class of a device node has been selected for a
particular stream node, and there are more than 1 device node matching such
media class, WirePlumber will select one based on a set of priorities:

View file

@ -0,0 +1,3 @@
sphinx_files += files(
'wpctl.rst',
)

309
docs/rst/tools/wpctl.rst Normal file
View file

@ -0,0 +1,309 @@
wpctl(1)
========
SYNOPSIS
--------
**wpctl** [*COMMAND*] [*COMMAND_OPTIONS*]
DESCRIPTION
-----------
**wpctl** is a command-line control tool for WirePlumber, the PipeWire session
manager. It provides an interface to inspect, control, and configure audio and
video devices, nodes, and their properties within a PipeWire media server.
WirePlumber manages audio and video routing, device configuration, and session
policies. **wpctl** allows users to interact with these components, change
volume levels, set default devices, inspect object properties, and modify
settings.
COMMANDS
--------
status
^^^^^^
**wpctl status** [**-k**\|\ **--nick**] [**-n**\|\ **--name**]
Displays the current state of objects in PipeWire, including devices, sinks,
sources, filters, and streams. Shows a hierarchical view of the audio/video
system.
Options:
**-k**, **--nick**
Display device and node nicknames instead of descriptions
**-n**, **--name**
Display device and node names instead of descriptions
get-volume
^^^^^^^^^^
**wpctl get-volume** *ID*
Displays volume information about the specified node, including current volume
level and mute state.
Arguments:
*ID*
Node ID or special identifier (see `SPECIAL IDENTIFIERS`_)
inspect
^^^^^^^
**wpctl inspect** *ID* [**-r**\|\ **--referenced**] [**-a**\|\ **--associated**]
Displays detailed information about the specified object, including all
properties and metadata.
Arguments:
*ID*
Object ID or special identifier
Options:
**-r**, **--referenced**
Show objects that are referenced in properties
**-a**, **--associated**
Show associated objects
set-default
^^^^^^^^^^^
**wpctl set-default** *ID*
Sets the specified device node to be the default target of its kind (capture or
playback) for new streams that require auto-connection.
Arguments:
*ID*
Sink or source node ID
set-volume
^^^^^^^^^^
**wpctl set-volume** *ID* *VOL*\ [**%**]\ [**-**\|\ **+**] [**-p**\|\ **--pid**] [**-l** *LIMIT*\|\ **--limit** *LIMIT*]
Sets the volume of the specified node.
Arguments:
*ID*
Node ID, special identifier, or PID (with --pid)
*VOL*\ [**%**]\ [**-**\|\ **+**]
Volume specification:
- *VOL* - Set volume to specific value (1.0 = 100%)
- *VOL*\ **%** - Set volume to percentage (50% = 0.5)
- *VOL*\ **+** - Increase volume by value
- *VOL*\ **-** - Decrease volume by value
- *VOL*\ **%+** - Increase volume by percentage
- *VOL*\ **%-** - Decrease volume by percentage
Options:
**-p**, **--pid**
Treat ID as a process ID and affect all nodes associated with it
**-l** *LIMIT*, **--limit** *LIMIT*
Limit final volume to below this value (floating point, 1.0 = 100%)
Examples:
Set volume to 50%: ``wpctl set-volume @DEFAULT_SINK@ 0.5``
Increase volume by 10%: ``wpctl set-volume 42 10%+``
Set volume for all nodes of PID 1234: ``wpctl set-volume --pid 1234 0.8``
set-mute
^^^^^^^^
**wpctl set-mute** *ID* **1**\|\ **0**\|\ **toggle** [**-p**\|\ **--pid**]
Changes the mute state of the specified node.
Arguments:
*ID*
Node ID, special identifier, or PID (with --pid)
**1**\|\ **0**\|\ **toggle**
Mute state: 1 (mute), 0 (unmute), or toggle current state
Options:
**-p**, **--pid**
Treat ID as a process ID and affect all nodes associated with it
set-profile
^^^^^^^^^^^
**wpctl set-profile** *ID* *INDEX*
Sets the profile of the specified device to the given index.
Arguments:
*ID*
Device ID or special identifier
*INDEX*
Profile index (integer, 0 typically means 'off')
set-route
^^^^^^^^^
**wpctl set-route** *ID* *INDEX*
Sets the route of the specified device to the given index.
Arguments:
*ID*
Device node ID or special identifier
*INDEX*
Route index (integer, 0 typically means 'off')
clear-default
^^^^^^^^^^^^^
**wpctl clear-default** [*ID*]
Clears the default configured node. If no ID is specified, clears all default
nodes.
Arguments:
*ID* (optional)
Settings ID to clear (0-2 for Audio/Sink, Audio/Source, Video/Source).
If omitted, clears all defaults.
settings
^^^^^^^^
**wpctl settings** [*KEY*] [*VAL*] [**-d**\|\ **--delete**] [**-s**\|\ **--save**] [**-r**\|\ **--reset**]
Shows, changes, or removes WirePlumber settings.
Arguments:
*KEY* (optional)
Setting key name
*VAL* (optional)
Setting value (JSON format)
Options:
**-d**, **--delete**
Delete the saved setting value (no KEY means delete all)
**-s**, **--save**
Save the setting value (no KEY means save all, no VAL means current value)
**-r**, **--reset**
Reset the setting to its default value
Behavior:
- No arguments: Show all settings
- KEY only: Show specific setting value
- KEY and VAL: Set specific setting value
set-log-level
^^^^^^^^^^^^^
**wpctl set-log-level** [*ID*] *LEVEL*
Sets the log level of a client.
Arguments:
*ID* (optional)
Client ID. If omitted, applies to WirePlumber. Use 0 for PipeWire server.
*LEVEL*
Log level (e.g., ``0``, ``1``, ``2``, ``3``, ``4``, ``5``, ``E``, ``W``, ``N``, ``I``, ``D``, ``T``).
Use ``-`` to unset the log level.
SPECIAL IDENTIFIERS
-------------------
Instead of numeric IDs, **wpctl** accepts these special identifiers for
commonly used defaults:
**@DEFAULT_SINK@**, **@DEFAULT_AUDIO_SINK@**
The current default audio sink (playback device)
**@DEFAULT_SOURCE@**, **@DEFAULT_AUDIO_SOURCE@**
The current default audio source (capture device)
**@DEFAULT_VIDEO_SOURCE@**
The current default video source (camera)
These identifiers are resolved at runtime to the appropriate node IDs.
EXIT STATUS
-----------
**wpctl** returns the following exit codes:
0
Success
1
General error (e.g., invalid arguments, connection failure)
2
Could not connect to PipeWire
3
Command-specific error (e.g., object not found)
EXAMPLES
--------
Display system status::
wpctl status
Set default audio sink::
wpctl set-default 42
Set volume to 75% on default sink::
wpctl set-volume @DEFAULT_SINK@ 75%
Increase volume by 5% on a specific node::
wpctl set-volume 42 5%+
Mute the default source::
wpctl set-mute @DEFAULT_SOURCE@ 1
Toggle mute on default sink::
wpctl set-mute @DEFAULT_SINK@ toggle
Inspect a device with associated objects::
wpctl inspect --associated 30
Show all WirePlumber settings::
wpctl settings
Set a specific setting::
wpctl settings bluetooth.autoswitch true
Save all current settings::
wpctl settings --save
Set log level for WirePlumber to debug::
wpctl set-log-level D
Set log level for a specific client::
wpctl set-log-level 42 W
NOTES
-----
Object IDs can be found using the **status** command. The hierarchical display
shows IDs for devices, nodes, and other objects.
Volume values are floating-point numbers where 1.0 represents 100% volume.
Values can exceed 1.0 to introduce volume amplification.
When using the **--pid** option, **wpctl** will find all audio nodes associated
with the specified process ID and apply the operation to all of them.
SEE ALSO
--------
**pipewire**\ (1), **pw-cli**\ (1), **pw-dump**\ (1), **wireplumber**\ (1)
WirePlumber Documentation: https://pipewire.pages.freedesktop.org/wireplumber/

View file

@ -438,7 +438,7 @@ spa_device_event_object_info (void *data, uint32_t id,
g_autoptr (WpProperties) props = NULL;
type = spa_debug_type_short_name (info->type);
props = wp_properties_new_wrap_dict (info->props);
props = wp_properties_new_copy_dict (info->props);
wp_debug_object (self, "object info: id:%u type:%s factory:%s",
id, type, info->factory_name);

View file

@ -15,6 +15,161 @@
WP_DEFINE_LOCAL_LOG_TOPIC ("wp-event-dispatcher")
typedef struct _HookData HookData;
struct _HookData
{
struct spa_list link;
WpEventHook *hook;
GPtrArray *dependencies;
};
static inline HookData *
hook_data_new (WpEventHook * hook)
{
HookData *hook_data = g_new0 (HookData, 1);
spa_list_init (&hook_data->link);
hook_data->hook = g_object_ref (hook);
hook_data->dependencies = g_ptr_array_new ();
return hook_data;
}
static void
hook_data_free (HookData *self)
{
g_clear_object (&self->hook);
g_clear_pointer (&self->dependencies, g_ptr_array_unref);
g_free (self);
}
static inline void
record_dependency (struct spa_list *list, const gchar *target,
const gchar *dependency)
{
HookData *hook_data;
spa_list_for_each (hook_data, list, link) {
if (g_pattern_match_simple (target, wp_event_hook_get_name (hook_data->hook))) {
g_ptr_array_insert (hook_data->dependencies, -1, (gchar *) dependency);
break;
}
}
}
static inline gboolean
hook_exists_in (const gchar *hook_name, struct spa_list *list)
{
HookData *hook_data;
if (!spa_list_is_empty (list)) {
spa_list_for_each (hook_data, list, link) {
if (g_pattern_match_simple (hook_name, wp_event_hook_get_name (hook_data->hook))) {
return TRUE;
}
}
}
return FALSE;
}
static gboolean
sort_hooks (GPtrArray *hooks)
{
struct spa_list collected, result, remaining;
HookData *sorted_hook_data = NULL;
spa_list_init (&collected);
spa_list_init (&result);
spa_list_init (&remaining);
for (guint i = 0; i < hooks->len; i++) {
WpEventHook *hook = g_ptr_array_index (hooks, i);
HookData *hook_data = hook_data_new (hook);
/* record "after" dependencies directly */
const gchar * const * strv =
wp_event_hook_get_runs_after_hooks (hook_data->hook);
while (strv && *strv) {
g_ptr_array_insert (hook_data->dependencies, -1, (gchar *) *strv);
strv++;
}
spa_list_append (&collected, &hook_data->link);
}
if (!spa_list_is_empty (&collected)) {
HookData *hook_data;
/* convert "before" dependencies into "after" dependencies */
spa_list_for_each (hook_data, &collected, link) {
const gchar * const * strv =
wp_event_hook_get_runs_before_hooks (hook_data->hook);
while (strv && *strv) {
/* record hook_data->hook as a dependency of the *strv hook */
record_dependency (&collected, *strv,
wp_event_hook_get_name (hook_data->hook));
strv++;
}
}
/* sort */
while (!spa_list_is_empty (&collected)) {
gboolean made_progress = FALSE;
/* examine each hook to see if its dependencies are satisfied in the
result list; if yes, then append it to the result too */
spa_list_consume (hook_data, &collected, link) {
guint deps_satisfied = 0;
spa_list_remove (&hook_data->link);
for (guint i = 0; i < hook_data->dependencies->len; i++) {
const gchar *dep = g_ptr_array_index (hook_data->dependencies, i);
/* if the dependency is already in the sorted result list or if
it doesn't exist at all, we consider it satisfied */
if (hook_exists_in (dep, &result) ||
!(hook_exists_in (dep, &collected) ||
hook_exists_in (dep, &remaining))) {
deps_satisfied++;
}
}
if (deps_satisfied == hook_data->dependencies->len) {
spa_list_append (&result, &hook_data->link);
made_progress = TRUE;
} else {
spa_list_append (&remaining, &hook_data->link);
}
}
if (made_progress) {
/* run again with the remaining hooks */
spa_list_insert_list (&collected, &remaining);
spa_list_init (&remaining);
}
else if (!spa_list_is_empty (&remaining)) {
/* if we did not make any progress towards growing the result list,
it means the dependencies cannot be satisfied because of circles */
spa_list_consume (hook_data, &result, link) {
spa_list_remove (&hook_data->link);
hook_data_free (hook_data);
}
spa_list_consume (hook_data, &remaining, link) {
spa_list_remove (&hook_data->link);
hook_data_free (hook_data);
}
return FALSE;
}
}
}
/* clear hooks and add the sorted ones */
g_ptr_array_set_size (hooks, 0);
spa_list_consume (sorted_hook_data, &result, link) {
spa_list_remove (&sorted_hook_data->link);
g_ptr_array_add (hooks, g_object_ref (sorted_hook_data->hook));
hook_data_free (sorted_hook_data);
}
return TRUE;
}
typedef struct _EventData EventData;
struct _EventData
{
@ -49,7 +204,8 @@ struct _WpEventDispatcher
GObject parent;
GWeakRef core;
GPtrArray *hooks; /* registered hooks */
GHashTable *defined_hooks; /* registered hooks for defined events */
GPtrArray *undefined_hooks; /* registered hooks for undefined events */
GSource *source; /* the event loop source */
GList *events; /* the events stack */
struct spa_system *system;
@ -160,7 +316,9 @@ static void
wp_event_dispatcher_init (WpEventDispatcher * self)
{
g_weak_ref_init (&self->core, NULL);
self->hooks = g_ptr_array_new_with_free_func (g_object_unref);
self->defined_hooks = g_hash_table_new_full (g_str_hash, g_str_equal, g_free,
(GDestroyNotify)g_ptr_array_unref);
self->undefined_hooks = g_ptr_array_new_with_free_func (g_object_unref);
self->source = g_source_new (&source_funcs, sizeof (WpEventSource));
((WpEventSource *) self->source)->dispatcher = self;
@ -184,7 +342,8 @@ wp_event_dispatcher_finalize (GObject * object)
close (self->eventfd);
g_clear_pointer (&self->hooks, g_ptr_array_unref);
g_clear_pointer (&self->defined_hooks, g_hash_table_unref);
g_clear_pointer (&self->undefined_hooks, g_ptr_array_unref);
g_weak_ref_clear (&self->core);
G_OBJECT_CLASS (wp_event_dispatcher_parent_class)->finalize (object);
@ -284,6 +443,10 @@ void
wp_event_dispatcher_register_hook (WpEventDispatcher * self,
WpEventHook * hook)
{
g_autoptr (GPtrArray) event_types = NULL;
gboolean is_defined = FALSE;
const gchar *hook_name;
g_return_if_fail (WP_IS_EVENT_DISPATCHER (self));
g_return_if_fail (WP_IS_EVENT_HOOK (hook));
@ -292,7 +455,74 @@ wp_event_dispatcher_register_hook (WpEventDispatcher * self,
g_return_if_fail (already_registered_dispatcher == NULL);
wp_event_hook_set_dispatcher (hook, self);
g_ptr_array_add (self->hooks, g_object_ref (hook));
/* Register the event hook in the defined hooks table if it is defined */
hook_name = wp_event_hook_get_name (hook);
event_types = wp_event_hook_get_matching_event_types (hook);
if (event_types) {
for (guint i = 0; i < event_types->len; i++) {
const gchar *event_type = g_ptr_array_index (event_types, i);
GPtrArray *hooks;
wp_debug_object (self, "Registering hook %s for defined event type %s",
hook_name, event_type);
/* Check if the event type was registered in the hash table */
hooks = g_hash_table_lookup (self->defined_hooks, event_type);
if (hooks) {
g_ptr_array_add (hooks, g_object_ref (hook));
if (!sort_hooks (hooks))
goto sort_error;
} else {
GPtrArray *new_hooks = g_ptr_array_new_with_free_func (g_object_unref);
/* Add undefined hooks */
for (guint i = 0; i < self->undefined_hooks->len; i++) {
WpEventHook *uh = g_ptr_array_index (self->undefined_hooks, i);
g_ptr_array_add (new_hooks, g_object_ref (uh));
}
/* Add current hook */
g_ptr_array_add (new_hooks, g_object_ref (hook));
g_hash_table_insert (self->defined_hooks, g_strdup (event_type),
new_hooks);
if (!sort_hooks (new_hooks))
goto sort_error;
}
is_defined = TRUE;
}
}
/* Otherwise just register it as undefined hook */
if (!is_defined) {
GHashTableIter iter;
gpointer value;
wp_debug_object (self, "Registering hook %s for undefined event types",
hook_name);
/* Add it to the defined hooks table */
g_hash_table_iter_init (&iter, self->defined_hooks);
while (g_hash_table_iter_next (&iter, NULL, &value)) {
GPtrArray *defined_hooks = value;
g_ptr_array_add (defined_hooks, g_object_ref (hook));
if (!sort_hooks (defined_hooks))
goto sort_error;
}
/* Add it to the undefined hooks */
g_ptr_array_add (self->undefined_hooks, g_object_ref (hook));
if (!sort_hooks (self->undefined_hooks))
goto sort_error;
}
wp_info_object (self, "Registered hook %s successfully", hook_name);
return;
sort_error:
/* Unregister hook */
wp_event_dispatcher_unregister_hook (self, hook);
wp_warning_object (self,
"Could not register hook %s because of circular dependencies", hook_name);
}
/*!
@ -306,6 +536,9 @@ void
wp_event_dispatcher_unregister_hook (WpEventDispatcher * self,
WpEventHook * hook)
{
GHashTableIter iter;
gpointer value;
g_return_if_fail (WP_IS_EVENT_DISPATCHER (self));
g_return_if_fail (WP_IS_EVENT_HOOK (hook));
@ -314,11 +547,29 @@ wp_event_dispatcher_unregister_hook (WpEventDispatcher * self,
g_return_if_fail (already_registered_dispatcher == self);
wp_event_hook_set_dispatcher (hook, NULL);
g_ptr_array_remove_fast (self->hooks, hook);
/* Remove hook from defined table and undefined list */
g_hash_table_iter_init (&iter, self->defined_hooks);
while (g_hash_table_iter_next (&iter, NULL, &value)) {
GPtrArray *defined_hooks = value;
g_ptr_array_remove (defined_hooks, hook);
}
g_ptr_array_remove (self->undefined_hooks, hook);
}
static void
add_unique (GPtrArray *array, WpEventHook * hook)
{
for (guint i = 0; i < array->len; i++)
if (g_ptr_array_index (array, i) == hook)
return;
g_ptr_array_add (array, g_object_ref (hook));
}
/*!
* \brief Returns an iterator to iterate over all the registered hooks
* \deprecated Use \ref wp_event_dispatcher_new_hooks_for_event_type_iterator
* instead.
* \ingroup wpeventdispatcher
*
* \param self the event dispatcher
@ -327,7 +578,56 @@ wp_event_dispatcher_unregister_hook (WpEventDispatcher * self,
WpIterator *
wp_event_dispatcher_new_hooks_iterator (WpEventDispatcher * self)
{
GPtrArray *items =
g_ptr_array_copy (self->hooks, (GCopyFunc) g_object_ref, NULL);
GPtrArray *items = g_ptr_array_new_with_free_func (g_object_unref);
GHashTableIter iter;
gpointer value;
/* Add all defined hooks */
g_hash_table_iter_init (&iter, self->defined_hooks);
while (g_hash_table_iter_next (&iter, NULL, &value)) {
GPtrArray *hooks = value;
for (guint i = 0; i < hooks->len; i++) {
WpEventHook *hook = g_ptr_array_index (hooks, i);
add_unique (items, hook);
}
}
/* Add all undefined hooks */
for (guint i = 0; i < self->undefined_hooks->len; i++) {
WpEventHook *hook = g_ptr_array_index (self->undefined_hooks, i);
add_unique (items, hook);
}
return wp_iterator_new_ptr_array (items, WP_TYPE_EVENT_HOOK);
}
/*!
* \brief Returns an iterator to iterate over the registered hooks for a
* particular event type.
* \ingroup wpeventdispatcher
*
* \param self the event dispatcher
* \param event_type the event type
* \return (transfer full): a new iterator
* \since 0.5.13
*/
WpIterator *
wp_event_dispatcher_new_hooks_for_event_type_iterator (
WpEventDispatcher * self, const gchar *event_type)
{
GPtrArray *items;
GPtrArray *hooks;
hooks = g_hash_table_lookup (self->defined_hooks, event_type);
if (hooks) {
wp_debug_object (self, "Using %d defined hooks for event type %s",
hooks->len, event_type);
} else {
hooks = self->undefined_hooks;
wp_debug_object (self, "Using %d undefined hooks for event type %s",
hooks->len, event_type);
}
items = g_ptr_array_copy (hooks, (GCopyFunc) g_object_ref, NULL);
return wp_iterator_new_ptr_array (items, WP_TYPE_EVENT_HOOK);
}

View file

@ -41,7 +41,12 @@ void wp_event_dispatcher_unregister_hook (WpEventDispatcher * self,
WpEventHook * hook);
WP_API
WpIterator * wp_event_dispatcher_new_hooks_iterator (WpEventDispatcher * self);
WpIterator * wp_event_dispatcher_new_hooks_iterator (WpEventDispatcher * self)
G_GNUC_DEPRECATED_FOR (wp_event_dispatcher_new_hooks_for_event_type_iterator);
WP_API
WpIterator * wp_event_dispatcher_new_hooks_for_event_type_iterator (
WpEventDispatcher * self, const gchar *event_type);
G_END_DECLS

View file

@ -254,6 +254,24 @@ wp_event_hook_run (WpEventHook * self,
callback_data);
}
/*!
* \brief Gets all the matching event types for this hook if any.
*
* \ingroup wpeventhook
* \param self the event hook
* \returns (element-type gchar*) (transfer full) (nullable): the matching
* event types for this hook if any.
* \since 0.5.13
*/
GPtrArray *
wp_event_hook_get_matching_event_types (WpEventHook * self)
{
g_return_val_if_fail (WP_IS_EVENT_HOOK (self), NULL);
g_return_val_if_fail (
WP_EVENT_HOOK_GET_CLASS (self)->get_matching_event_types, NULL);
return WP_EVENT_HOOK_GET_CLASS (self)->get_matching_event_types (self);
}
/*!
* \brief Finishes the async operation that was started by wp_event_hook_run()
*
@ -321,36 +339,63 @@ wp_interest_event_hook_runs_for_event (WpEventHook * hook, WpEvent * event)
wp_interest_event_hook_get_instance_private (self);
g_autoptr (WpProperties) properties = wp_event_get_properties (event);
g_autoptr (GObject) subject = wp_event_get_subject (event);
GType gtype = subject ? G_OBJECT_TYPE (subject) : WP_TYPE_EVENT;
guint i;
WpObjectInterest *interest = NULL;
WpInterestMatch match;
const unsigned int MATCH_ALL_PROPS = (WP_INTEREST_MATCH_PW_GLOBAL_PROPERTIES |
WP_INTEREST_MATCH_PW_PROPERTIES |
WP_INTEREST_MATCH_G_PROPERTIES);
for (i = 0; i < priv->interests->len; i++) {
interest = g_ptr_array_index (priv->interests, i);
match = wp_object_interest_matches_full (interest,
WP_INTEREST_MATCH_FLAGS_CHECK_ALL,
gtype, subject, properties, properties);
/* the interest may have a GType that matches the GType of the subject
or it may have WP_TYPE_EVENT as its GType, in which case it will
match any type of subject */
if (match == WP_INTEREST_MATCH_ALL)
return TRUE;
else if (subject && (match & MATCH_ALL_PROPS) == MATCH_ALL_PROPS) {
match = wp_object_interest_matches_full (interest, 0,
WP_TYPE_EVENT, NULL, NULL, NULL);
if (match & WP_INTEREST_MATCH_GTYPE)
if (wp_object_interest_matches_full (interest,
WP_INTEREST_MATCH_FLAGS_NONE,
WP_TYPE_EVENT, subject, properties, properties) == WP_INTEREST_MATCH_ALL)
return TRUE;
}
}
return FALSE;
}
static void
add_unique (GPtrArray *array, const gchar * lookup)
{
for (guint i = 0; i < array->len; i++)
if (g_str_equal (g_ptr_array_index (array, i), lookup))
return;
g_ptr_array_add (array, g_strdup (lookup));
}
static GPtrArray *
wp_interest_event_hook_get_matching_event_types (WpEventHook * hook)
{
WpInterestEventHook *self = WP_INTEREST_EVENT_HOOK (hook);
WpInterestEventHookPrivate *priv =
wp_interest_event_hook_get_instance_private (self);
GPtrArray *res = g_ptr_array_new_with_free_func (g_free);
guint i;
for (i = 0; i < priv->interests->len; i++) {
WpObjectInterest *interest = g_ptr_array_index (priv->interests, i);
if (wp_object_interest_matches_full (interest, WP_INTEREST_MATCH_FLAGS_NONE,
WP_TYPE_EVENT, NULL, NULL, NULL) & WP_INTEREST_MATCH_GTYPE) {
g_autoptr (GPtrArray) values =
wp_object_interest_find_defined_constraint_values (interest,
WP_CONSTRAINT_TYPE_NONE, "event.type");
if (!values || values->len == 0) {
/* We always consider the hook undefined if it has at least one interest
* without a defined 'event.type' constraint */
return NULL;
} else {
for (guint j = 0; j < values->len; j++) {
GVariant *v = g_ptr_array_index (values, j);
if (g_variant_is_of_type (v, G_VARIANT_TYPE_STRING)) {
const gchar *v_str = g_variant_get_string (v, NULL);
add_unique (res, v_str);
}
}
}
}
}
return res;
}
static void
wp_interest_event_hook_class_init (WpInterestEventHookClass * klass)
{
@ -359,6 +404,8 @@ wp_interest_event_hook_class_init (WpInterestEventHookClass * klass)
object_class->finalize = wp_interest_event_hook_finalize;
hook_class->runs_for_event = wp_interest_event_hook_runs_for_event;
hook_class->get_matching_event_types =
wp_interest_event_hook_get_matching_event_types;
}
/*!

View file

@ -39,8 +39,10 @@ struct _WpEventHookClass
gboolean (*finish) (WpEventHook * self, GAsyncResult * res, GError ** error);
GPtrArray * (*get_matching_event_types) (WpEventHook *self);
/*< private >*/
WP_PADDING(5)
WP_PADDING(4)
};
WP_API
@ -67,6 +69,9 @@ void wp_event_hook_run (WpEventHook * self,
WpEvent * event, GCancellable * cancellable,
GAsyncReadyCallback callback, gpointer callback_data);
WP_API
GPtrArray * wp_event_hook_get_matching_event_types (WpEventHook * self);
WP_API
gboolean wp_event_hook_finish (WpEventHook * self, GAsyncResult * res,
GError ** error);

View file

@ -17,37 +17,11 @@
WP_DEFINE_LOCAL_LOG_TOPIC ("wp-event")
typedef struct _HookData HookData;
struct _HookData
{
struct spa_list link;
WpEventHook *hook;
GPtrArray *dependencies;
};
static inline HookData *
hook_data_new (WpEventHook * hook)
{
HookData *hook_data = g_new0 (HookData, 1);
spa_list_init (&hook_data->link);
hook_data->hook = g_object_ref (hook);
hook_data->dependencies = g_ptr_array_new ();
return hook_data;
}
static void
hook_data_free (HookData *self)
{
g_clear_object (&self->hook);
g_clear_pointer (&self->dependencies, g_ptr_array_unref);
g_free (self);
}
struct _WpEvent
{
grefcount ref;
GData *datalist;
struct spa_list hooks;
GPtrArray *hooks;
/* immutable fields */
gint priority;
@ -96,7 +70,7 @@ wp_event_new (const gchar * type, gint priority, WpProperties * properties,
WpEvent * self = g_new0 (WpEvent, 1);
g_ref_count_init (&self->ref);
g_datalist_init (&self->datalist);
spa_list_init (&self->hooks);
self->hooks = g_ptr_array_new_with_free_func (g_object_unref);
self->priority = priority;
self->properties = properties ?
@ -155,11 +129,7 @@ wp_event_get_name(WpEvent *self)
static void
wp_event_free (WpEvent * self)
{
HookData *hook_data;
spa_list_consume (hook_data, &self->hooks, link) {
spa_list_remove (&hook_data->link);
hook_data_free (hook_data);
}
g_clear_pointer (&self->hooks, g_ptr_array_unref);
g_datalist_clear (&self->datalist);
g_clear_pointer (&self->properties, wp_properties_unref);
g_clear_object (&self->source);
@ -316,33 +286,6 @@ wp_event_get_data (WpEvent * self, const gchar * key)
return g_datalist_get_data (&self->datalist, key);
}
static inline void
record_dependency (struct spa_list *list, const gchar *target,
const gchar *dependency)
{
HookData *hook_data;
spa_list_for_each (hook_data, list, link) {
if (g_pattern_match_simple (target, wp_event_hook_get_name (hook_data->hook))) {
g_ptr_array_insert (hook_data->dependencies, -1, (gchar *) dependency);
break;
}
}
}
static inline gboolean
hook_exists_in (const gchar *hook_name, struct spa_list *list)
{
HookData *hook_data;
if (!spa_list_is_empty (list)) {
spa_list_for_each (hook_data, list, link) {
if (g_pattern_match_simple (hook_name, wp_event_hook_get_name (hook_data->hook))) {
return TRUE;
}
}
}
return FALSE;
}
/*!
* \brief Collects all the hooks registered in the \a dispatcher that run for
* this \a event
@ -355,199 +298,37 @@ hook_exists_in (const gchar *hook_name, struct spa_list *list)
gboolean
wp_event_collect_hooks (WpEvent * event, WpEventDispatcher * dispatcher)
{
struct spa_list collected, result, remaining;
g_autoptr (WpIterator) all_hooks = NULL;
g_auto (GValue) value = G_VALUE_INIT;
const gchar *event_type = NULL;
g_return_val_if_fail (event != NULL, FALSE);
g_return_val_if_fail (WP_IS_EVENT_DISPATCHER (dispatcher), FALSE);
/* hooks already collected */
if (!spa_list_is_empty (&event->hooks))
return TRUE;
/* Clear all current hooks */
g_ptr_array_set_size (event->hooks, 0);
spa_list_init (&collected);
spa_list_init (&result);
spa_list_init (&remaining);
/* Get the event type */
event_type = wp_properties_get (event->properties, "event.type");
wp_debug_object (dispatcher, "Collecting hooks for event %s with type %s",
event->name, event_type);
/* collect hooks that run for this event */
all_hooks = wp_event_dispatcher_new_hooks_iterator (dispatcher);
/* Collect hooks that run for this event */
all_hooks = wp_event_dispatcher_new_hooks_for_event_type_iterator (dispatcher,
event_type);
while (wp_iterator_next (all_hooks, &value)) {
WpEventHook *hook = g_value_get_object (&value);
if (wp_event_hook_runs_for_event (hook, event)) {
HookData *hook_data = hook_data_new (hook);
/* record "after" dependencies directly */
const gchar * const * strv =
wp_event_hook_get_runs_after_hooks (hook_data->hook);
while (strv && *strv) {
g_ptr_array_insert (hook_data->dependencies, -1, (gchar *) *strv);
strv++;
}
spa_list_append (&collected, &hook_data->link);
g_ptr_array_add (event->hooks, g_object_ref (hook));
wp_debug_boxed (WP_TYPE_EVENT, event, "added "WP_OBJECT_FORMAT"(%s)",
WP_OBJECT_ARGS (hook), wp_event_hook_get_name (hook));
}
g_value_unset (&value);
}
if (!spa_list_is_empty (&collected)) {
HookData *hook_data;
/* convert "before" dependencies into "after" dependencies */
spa_list_for_each (hook_data, &collected, link) {
const gchar * const * strv =
wp_event_hook_get_runs_before_hooks (hook_data->hook);
while (strv && *strv) {
/* record hook_data->hook as a dependency of the *strv hook */
record_dependency (&collected, *strv,
wp_event_hook_get_name (hook_data->hook));
strv++;
}
}
/* sort */
while (!spa_list_is_empty (&collected)) {
gboolean made_progress = FALSE;
/* examine each hook to see if its dependencies are satisfied in the
result list; if yes, then append it to the result too */
spa_list_consume (hook_data, &collected, link) {
guint deps_satisfied = 0;
spa_list_remove (&hook_data->link);
wp_trace_boxed (WP_TYPE_EVENT, event,
"examining: %s", wp_event_hook_get_name (hook_data->hook));
for (guint i = 0; i < hook_data->dependencies->len; i++) {
const gchar *dep = g_ptr_array_index (hook_data->dependencies, i);
/* if the dependency is already in the sorted result list or if
it doesn't exist at all, we consider it satisfied */
if (hook_exists_in (dep, &result) ||
!(hook_exists_in (dep, &collected) ||
hook_exists_in (dep, &remaining))) {
deps_satisfied++;
}
wp_trace_boxed (WP_TYPE_EVENT, event, "depends: %s, satisfied: %u/%u",
dep, deps_satisfied, hook_data->dependencies->len);
}
if (deps_satisfied == hook_data->dependencies->len) {
wp_trace_boxed (WP_TYPE_EVENT, event,
"sorted: "WP_OBJECT_FORMAT"(%s)",
WP_OBJECT_ARGS (hook_data->hook),
wp_event_hook_get_name (hook_data->hook));
spa_list_append (&result, &hook_data->link);
made_progress = TRUE;
} else {
spa_list_append (&remaining, &hook_data->link);
}
}
if (made_progress) {
/* run again with the remaining hooks */
spa_list_insert_list (&collected, &remaining);
spa_list_init (&remaining);
}
else if (!spa_list_is_empty (&remaining)) {
/* if we did not make any progress towards growing the result list,
it means the dependencies cannot be satisfied because of circles */
wp_critical_boxed (WP_TYPE_EVENT, event, "detected circular "
"dependencies in the collected hooks!");
/* clean up */
spa_list_consume (hook_data, &result, link) {
spa_list_remove (&hook_data->link);
hook_data_free (hook_data);
}
spa_list_consume (hook_data, &remaining, link) {
spa_list_remove (&hook_data->link);
hook_data_free (hook_data);
}
return FALSE;
}
}
}
spa_list_insert_list (&event->hooks, &result);
return !spa_list_is_empty (&event->hooks);
return event->hooks->len > 0;
}
struct event_hooks_iterator_data
{
WpEvent *event;
HookData *cur;
};
static void
event_hooks_iterator_reset (WpIterator *it)
{
struct event_hooks_iterator_data *it_data = wp_iterator_get_user_data (it);
struct spa_list *list = &it_data->event->hooks;
if (!spa_list_is_empty (list))
it_data->cur = spa_list_first (&it_data->event->hooks, HookData, link);
}
static gboolean
event_hooks_iterator_next (WpIterator *it, GValue *item)
{
struct event_hooks_iterator_data *it_data = wp_iterator_get_user_data (it);
struct spa_list *list = &it_data->event->hooks;
if (!spa_list_is_empty (list) &&
!spa_list_is_end (it_data->cur, list, link)) {
g_value_init (item, WP_TYPE_EVENT_HOOK);
g_value_set_object (item, it_data->cur->hook);
it_data->cur = spa_list_next (it_data->cur, link);
return TRUE;
}
return FALSE;
}
static gboolean
event_hooks_iterator_fold (WpIterator *it, WpIteratorFoldFunc func, GValue *ret,
gpointer data)
{
struct event_hooks_iterator_data *it_data = wp_iterator_get_user_data (it);
struct spa_list *list = &it_data->event->hooks;
HookData *hook_data;
if (!spa_list_is_empty (list)) {
spa_list_for_each (hook_data, list, link) {
g_auto (GValue) item = G_VALUE_INIT;
g_value_init (&item, WP_TYPE_EVENT_HOOK);
g_value_set_object (&item, hook_data->hook);
if (!func (&item, ret, data))
return FALSE;
}
}
return TRUE;
}
static void
event_hooks_iterator_finalize (WpIterator *it)
{
struct event_hooks_iterator_data *it_data = wp_iterator_get_user_data (it);
wp_event_unref (it_data->event);
}
static const WpIteratorMethods event_hooks_iterator_methods = {
.version = WP_ITERATOR_METHODS_VERSION,
.reset = event_hooks_iterator_reset,
.next = event_hooks_iterator_next,
.fold = event_hooks_iterator_fold,
.finalize = event_hooks_iterator_finalize,
};
/*!
* \brief Returns an iterator that iterates over all the hooks that were
* collected by wp_event_collect_hooks()
@ -558,15 +339,8 @@ static const WpIteratorMethods event_hooks_iterator_methods = {
WpIterator *
wp_event_new_hooks_iterator (WpEvent * event)
{
WpIterator *it = NULL;
struct event_hooks_iterator_data *it_data;
GPtrArray *hooks;
hooks = g_ptr_array_copy (event->hooks, (GCopyFunc) g_object_ref, NULL);
return wp_iterator_new_ptr_array (hooks, WP_TYPE_EVENT_HOOK);
g_return_val_if_fail (event != NULL, NULL);
it = wp_iterator_new (&event_hooks_iterator_methods,
sizeof (struct event_hooks_iterator_data));
it_data = wp_iterator_get_user_data (it);
it_data->event = wp_event_ref (event);
event_hooks_iterator_reset (it);
return it;
}

View file

@ -770,6 +770,8 @@ wp_object_interest_matches_full (WpObjectInterest * self,
if (!pw_global_props && WP_IS_SESSION_ITEM (object)) {
WpSessionItem *si = (WpSessionItem *) object;
pw_global_props = props = wp_session_item_get_properties (si);
if (!pw_props)
pw_props = props;
}
}
@ -879,3 +881,51 @@ wp_object_interest_matches_full (WpObjectInterest * self,
}
return result;
}
/*!
* \brief Finds all the defined constraint values for a subject in \a self.
*
* A defined constraint value is the value of a constraint with the 'equal' or
* 'in-list' verb, because the full value must be defined with those verbs. This
* can be useful for cases where we want to enumerate interests that are
* interested in specific subjects.
*
* \ingroup wpobjectinterest
* \param self the object interest
* \param type the constraint type
* \param subject the subject that the constraint applies to
* \returns (element-type GVariant) (transfer full) (nullable): the defined
* constraint values for this object interest.
* \since 0.5.13
*/
GPtrArray *
wp_object_interest_find_defined_constraint_values (WpObjectInterest * self,
WpConstraintType type, const gchar * subject)
{
GPtrArray *res = g_ptr_array_new_with_free_func (
(GDestroyNotify)g_variant_unref);
struct constraint *c;
pw_array_for_each (c, &self->constraints) {
if ((c->type == type || WP_CONSTRAINT_TYPE_NONE == type) &&
g_str_equal (c->subject, subject)) {
switch (c->verb) {
case WP_CONSTRAINT_VERB_EQUALS:
g_ptr_array_add (res, g_variant_ref (c->value));
break;
case WP_CONSTRAINT_VERB_IN_LIST: {
GVariantIter iter;
GVariant *child;
g_variant_iter_init (&iter, c->value);
while ((child = g_variant_iter_next_value (&iter)))
g_ptr_array_add (res, child);
break;
}
default:
break;
}
}
}
return res;
}

View file

@ -130,6 +130,10 @@ WpInterestMatch wp_object_interest_matches_full (WpObjectInterest * self,
WpInterestMatchFlags flags, GType object_type, gpointer object,
WpProperties * pw_props, WpProperties * pw_global_props);
WP_API
GPtrArray * wp_object_interest_find_defined_constraint_values (
WpObjectInterest * self, WpConstraintType type, const gchar * subject);
G_DEFINE_AUTOPTR_CLEANUP_FUNC (WpObjectInterest, wp_object_interest_unref)
G_END_DECLS

View file

@ -783,7 +783,7 @@ wp_pw_object_mixin_handle_event_info (gpointer instance, gconstpointer update)
G_STRUCT_MEMBER (const struct spa_dict *, d->info, iface->props_offset);
g_clear_pointer (&d->properties, wp_properties_unref);
d->properties = wp_properties_new_wrap_dict (props);
d->properties = wp_properties_new_copy_dict (props);
g_object_notify (G_OBJECT (instance), "properties");
}

View file

@ -6,7 +6,9 @@
* SPDX-License-Identifier: MIT
*/
#include <fcntl.h>
#include <stdio.h>
#include <spa/utils/cleanup.h>
#include "log.h"
#include "proc-utils.h"
@ -145,6 +147,21 @@ wp_proc_info_get_cgroup (WpProcInfo * self)
return self->cgroup;
}
static FILE *
fdopenat (int dirfd, const char *path, int flags, const char *mode, mode_t perm)
{
int fd = openat (dirfd, path, flags, perm);
if (fd >= 0) {
FILE *f = fdopen (fd, mode);
if (f)
return f;
close (fd);
}
return NULL;
}
/*!
* \brief Gets the process information of a given PID
* \ingroup wpprocutils
@ -155,51 +172,46 @@ WpProcInfo *
wp_proc_utils_get_proc_info (pid_t pid)
{
WpProcInfo *ret = wp_proc_info_new (pid);
g_autofree gchar *status = NULL;
g_autoptr (GError) error = NULL;
gsize length = 0;
char path [64];
spa_autoclose int base_fd = -1;
FILE *file;
g_autofree gchar *line = NULL;
size_t size = 0;
snprintf (path, sizeof(path), "/proc/%d", pid);
base_fd = open (path,
O_RDONLY | O_NONBLOCK | O_DIRECTORY | O_CLOEXEC | O_NOCTTY, 0);
if (base_fd < 0) {
wp_info ("Could not open process info directory %s, skipping", path);
return ret;
}
/* Get parent PID */
{
g_autofree gchar *path = g_strdup_printf ("/proc/%d/status", pid);
if (g_file_get_contents (path, &status, &length, &error)) {
const gchar *loc = strstr (status, "\nPPid:");
if (loc) {
const gint res = sscanf (loc, "\nPPid:%d\n", &ret->parent);
if (!res || res == EOF)
wp_warning ("failed to parse status PPID for PID %d", pid);
} else {
wp_warning ("failed to find status parent PID for PID %d", pid);
}
} else {
wp_warning ("failed to get status for PID %d: %s", pid, error->message);
}
file = fdopenat (base_fd, "status",
O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOCTTY, "r", 0);
if (file) {
while (getline (&line, &size, file) > 1)
if (sscanf (line, "PPid:%d\n", &ret->parent) == 1)
break;
fclose (file);
}
/* Get cgroup */
{
g_autofree gchar *path = g_strdup_printf ("/proc/%d/cgroup", pid);
if (g_file_get_contents (path, &ret->cgroup, &length, &error)) {
if (length > 0)
ret->cgroup [length - 1] = '\0'; /* Remove EOF character */
} else {
wp_warning ("failed to get cgroup for PID %d: %s", pid, error->message);
}
file = fdopenat (base_fd, "cgroup",
O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOCTTY, "r", 0);
if (file) {
if (getline (&line, &size, file) > 1)
ret->cgroup = g_strstrip (g_strdup (line));
fclose (file);
}
/* Get args */
{
g_autofree gchar *path = g_strdup_printf ("/proc/%d/cmdline", pid);
FILE *file = fopen (path, "rb");
if (file) {
g_autofree gchar *lineptr = NULL;
size_t size = 0;
while (getdelim (&lineptr, &size, 0, file) > 1 && ret->n_args < MAX_ARGS)
ret->args[ret->n_args++] = g_strdup (lineptr);
fclose (file);
} else {
wp_warning ("failed to get cmdline for PID %d: %m", pid);
}
file = fdopenat (base_fd, "cmdline",
O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOCTTY, "r", 0);
if (file) {
while (getdelim (&line, &size, 0, file) > 1 && ret->n_args < MAX_ARGS)
ret->args[ret->n_args++] = g_strdup (line);
fclose (file);
}
return ret;

View file

@ -1,5 +1,5 @@
project('wireplumber', ['c'],
version : '0.5.11',
version : '0.5.13',
license : 'MIT',
meson_version : '>= 0.59.0',
default_options : [
@ -158,7 +158,25 @@ common_args = [
'-DG_LOG_USE_STRUCTURED',
'-DWP_USE_LOCAL_LOG_TOPIC_IN_G_LOG',
]
# Check if SPA_AUDIO_MAX_CHANNELS can be overridden
# (newer headers have #ifndef guards, older ones don't)
check_spa_max_channels_override = '''
#define SPA_AUDIO_MAX_CHANNELS 128u
#include <spa/param/audio/raw.h>
void main() { int x = SPA_AUDIO_MAX_CHANNELS; }
'''
spa_max_channels = 64
if cc.compiles(check_spa_max_channels_override,
dependencies: spa_dep,
args: ['-Werror'],
name: 'SPA_AUDIO_MAX_CHANNELS override')
common_args += ['-DSPA_AUDIO_MAX_CHANNELS=128u']
spa_max_channels = 128
endif
add_project_arguments(common_args, language: 'c')
summary({'SPA_AUDIO_MAX_CHANNELS': spa_max_channels})
i18n_conf = files()

View file

@ -58,6 +58,16 @@ shared_library(
dependencies : [wp_dep, giounix_dep],
)
shared_library(
'wireplumber-module-notifications-api',
[
'module-notifications-api.c',
],
install : true,
install_dir : wireplumber_module_dir,
dependencies : [wp_dep, giounix_dep],
)
shared_library(
'wireplumber-module-si-audio-adapter',
[

View file

@ -146,8 +146,8 @@ static int
core_get_properties (lua_State *L)
{
WpCore * core = get_wp_core (L);
g_autoptr (WpProperties) p = wp_core_get_properties (core);
wplua_properties_to_table (L, p);
WpProperties *p = wp_core_get_properties (core);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, p);
return 1;
}
@ -155,7 +155,7 @@ static int
core_get_info (lua_State *L)
{
WpCore * core = get_wp_core (L);
g_autoptr (WpProperties) p = wp_core_get_remote_properties (core);
WpProperties *p = wp_core_get_remote_properties (core);
lua_newtable (L);
lua_pushinteger (L, wp_core_get_remote_cookie (core));
@ -168,7 +168,7 @@ core_get_info (lua_State *L)
lua_setfield (L, -2, "host_name");
lua_pushstring (L, wp_core_get_remote_version (core));
lua_setfield (L, -2, "version");
wplua_properties_to_table (L, p);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, p);
lua_setfield (L, -2, "properties");
return 1;
}
@ -297,8 +297,13 @@ static int
core_update_properties (lua_State *L)
{
WpCore *core = get_wp_core(L);
luaL_checktype (L, 1, LUA_TTABLE);
wp_core_update_properties (core, wplua_table_to_properties (L, 1));
WpProperties *props = NULL;
if (lua_istable (L, 1))
props = wplua_table_to_properties (L, 1);
else
props = wp_properties_ref (wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES));
wp_core_update_properties (core, props);
return 0;
}
@ -599,6 +604,28 @@ push_wpiterator (lua_State *L, WpIterator *it)
return 2;
}
static int
iterator_reset (lua_State *L)
{
WpIterator *it = wplua_checkboxed (L, 1, WP_TYPE_ITERATOR);
wp_iterator_reset (it);
return 0;
}
static int
iterator_iterate (lua_State *L)
{
WpIterator *it = wplua_checkboxed (L, 1, WP_TYPE_ITERATOR);
return push_wpiterator (L, wp_iterator_ref (it));
}
static const luaL_Reg iterator_funcs[] = {
{ "next", iterator_next },
{ "reset", iterator_reset },
{ "iterate", iterator_iterate },
{ NULL, NULL }
};
/* Settings WpIterator */
static int
@ -837,7 +864,11 @@ object_interest_matches (lua_State *L)
matches = wp_object_interest_matches (interest, wplua_toobject (L, 2));
}
else if (lua_istable (L, 2)) {
g_autoptr (WpProperties) props = wplua_table_to_properties (L, 2);
g_autoptr (WpProperties) props = NULL;
if (lua_istable (L, 2))
props = wplua_table_to_properties (L, 2);
else
props = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
matches = wp_object_interest_matches (interest, props);
} else
luaL_argerror (L, 2, "expected GObject or table");
@ -997,10 +1028,11 @@ impl_metadata_new (lua_State *L)
const char *name = luaL_checkstring (L, 1);
WpProperties *properties = NULL;
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL) {
luaL_checktype (L, 2, LUA_TTABLE);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
properties = wp_properties_ref (wplua_checkboxed (L, 2,
WP_TYPE_PROPERTIES));
WpImplMetadata *m = wp_impl_metadata_new_full (get_wp_core (L),
name, properties);
@ -1017,10 +1049,11 @@ device_new (lua_State *L)
const char *factory = luaL_checkstring (L, 1);
WpProperties *properties = NULL;
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL) {
luaL_checktype (L, 2, LUA_TTABLE);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
properties = wp_properties_ref (wplua_checkboxed (L, 2,
WP_TYPE_PROPERTIES));
WpDevice *d = wp_device_new_from_factory (get_wp_export_core (L),
factory, properties);
@ -1037,10 +1070,11 @@ spa_device_new (lua_State *L)
const char *factory = luaL_checkstring (L, 1);
WpProperties *properties = NULL;
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL) {
luaL_checktype (L, 2, LUA_TTABLE);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
properties = wp_properties_ref (wplua_checkboxed (L, 2,
WP_TYPE_PROPERTIES));
WpSpaDevice *d = wp_spa_device_new_from_spa_factory (get_wp_export_core (L),
factory, properties);
@ -1105,10 +1139,11 @@ node_new (lua_State *L)
const char *factory = luaL_checkstring (L, 1);
WpProperties *properties = NULL;
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL) {
luaL_checktype (L, 2, LUA_TTABLE);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
properties = wp_properties_ref (
wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
WpNode *d = wp_node_new_from_factory (get_wp_export_core (L),
factory, properties);
@ -1214,10 +1249,11 @@ impl_node_new (lua_State *L)
const char *factory = luaL_checkstring (L, 1);
WpProperties *properties = NULL;
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL) {
luaL_checktype (L, 2, LUA_TTABLE);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
properties = wp_properties_ref (wplua_checkboxed (L, 2,
WP_TYPE_PROPERTIES));
WpImplNode *d = wp_impl_node_new_from_pw_factory (get_wp_export_core (L),
factory, properties);
@ -1250,10 +1286,11 @@ link_new (lua_State *L)
const char *factory = luaL_checkstring (L, 1);
WpProperties *properties = NULL;
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL) {
luaL_checktype (L, 2, LUA_TTABLE);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
properties = wp_properties_ref (wplua_checkboxed (L, 2,
WP_TYPE_PROPERTIES));
WpLink *l = wp_link_new_from_factory (get_wp_core (L), factory, properties);
if (l)
@ -1329,9 +1366,12 @@ static int
client_update_properties (lua_State *L)
{
WpClient *client = wplua_checkobject (L, 1, WP_TYPE_CLIENT);
WpProperties *properties = NULL;
luaL_checktype (L, 2, LUA_TTABLE);
WpProperties *properties = wplua_table_to_properties (L, 2);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
else
properties = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
wp_client_update_properties (client, properties);
return 0;
@ -1391,46 +1431,12 @@ static int
session_item_configure (lua_State *L)
{
WpSessionItem *si = wplua_checkobject (L, 1, WP_TYPE_SESSION_ITEM);
WpProperties *props = wp_properties_new_empty ();
WpProperties *props;
/* validate arguments */
luaL_checktype (L, 2, LUA_TTABLE);
/* build the configuration properties */
lua_pushnil (L);
while (lua_next (L, 2)) {
const gchar *key = NULL;
g_autofree gchar *var = NULL;
switch (lua_type (L, -1)) {
case LUA_TBOOLEAN:
var = g_strdup_printf ("%u", lua_toboolean (L, -1));
break;
case LUA_TNUMBER:
if (lua_isinteger (L, -1))
var = g_strdup_printf ("%lld", lua_tointeger (L, -1));
else
var = g_strdup_printf ("%f", lua_tonumber (L, -1));
break;
case LUA_TSTRING:
var = g_strdup (lua_tostring (L, -1));
break;
case LUA_TUSERDATA: {
GValue *v = lua_touserdata (L, -1);
gpointer p = g_value_peek_pointer (v);
var = g_strdup_printf ("%p", p);
break;
}
default:
luaL_error (L, "configure does not support lua type ",
lua_typename(L, lua_type(L, -1)));
break;
}
key = luaL_tolstring (L, -2, NULL);
wp_properties_set (props, key, var);
lua_pop (L, 2);
}
if (lua_istable (L, 2))
props = wplua_table_to_properties (L, 2);
else
props = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
lua_pushboolean (L, wp_session_item_configure (si, props));
return 1;
@ -1452,12 +1458,23 @@ session_item_remove (lua_State *L)
return 0;
}
static int
session_item_get_property (lua_State *L)
{
WpSessionItem *si = wplua_checkobject (L, 1, WP_TYPE_SESSION_ITEM);
const char *key = luaL_checkstring (L, 2);
const char *val = wp_session_item_get_property (si, key);
lua_pushstring (L, val);
return 1;
}
static const luaL_Reg session_item_methods[] = {
{ "get_associated_proxy", session_item_get_associated_proxy },
{ "reset", session_item_reset },
{ "configure", session_item_configure },
{ "register", session_item_register },
{ "remove", session_item_remove },
{ "get_property", session_item_get_property },
{ NULL, NULL }
};
@ -1527,19 +1544,24 @@ on_enum_params_done (WpPipewireObject * pwobj, GAsyncResult * res,
GClosure * closure)
{
g_autoptr (GError) error = NULL;
GValue val = G_VALUE_INIT;
int n_vals = 0;
GValue vals[2] = { G_VALUE_INIT, G_VALUE_INIT };
int n_vals = 1;
WpIterator *it;
it = wp_pipewire_object_enum_params_finish (pwobj, res, &error);
g_value_init (&vals[0], WP_TYPE_ITERATOR);
g_value_set_boxed (&vals[0], it);
if (!it) {
g_value_init (&val, G_TYPE_STRING);
g_value_set_string (&val, error->message);
n_vals = 1;
g_value_init (&vals[1], G_TYPE_STRING);
g_value_set_string (&vals[1], error->message);
n_vals = 2;
}
g_clear_pointer (&it, wp_iterator_unref);
g_closure_invoke (closure, NULL, n_vals, &val, NULL);
g_value_unset (&val);
g_closure_invoke (closure, NULL, n_vals, vals, NULL);
g_value_unset (&vals[0]);
g_value_unset (&vals[1]);
g_closure_invalidate (closure);
g_closure_unref (closure);
}
@ -1575,11 +1597,22 @@ pipewire_object_set_param (lua_State *L)
return 0;
}
static int
pipewire_object_get_property (lua_State *L)
{
WpPipewireObject *pwobj = wplua_checkobject (L, 1, WP_TYPE_PIPEWIRE_OBJECT);
const char *key = luaL_checkstring (L, 2);
const char *val = wp_pipewire_object_get_property (pwobj, key);
lua_pushstring (L, val);
return 1;
}
static const luaL_Reg pipewire_object_methods[] = {
{ "enum_params", pipewire_object_enum_params },
{ "iterate_params", pipewire_object_iterate_params },
{ "set_param" , pipewire_object_set_param },
{ "set_params" , pipewire_object_set_param }, /* deprecated, compat only */
{ "get_property", pipewire_object_get_property },
{ NULL, NULL }
};
@ -1606,9 +1639,14 @@ static int
state_save (lua_State *L)
{
WpState *state = wplua_checkobject (L, 1, WP_TYPE_STATE);
luaL_checktype (L, 2, LUA_TTABLE);
g_autoptr (WpProperties) props = wplua_table_to_properties (L, 2);
g_autoptr (WpProperties) props = NULL;
g_autoptr (GError) error = NULL;
if (lua_istable (L, 2))
props = wplua_table_to_properties (L, 2);
else
props = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
gboolean saved = wp_state_save (state, props, &error);
lua_pushboolean (L, saved);
lua_pushstring (L, error ? error->message : "");
@ -1619,8 +1657,13 @@ static int
state_save_after_timeout (lua_State *L)
{
WpState *state = wplua_checkobject (L, 1, WP_TYPE_STATE);
luaL_checktype (L, 2, LUA_TTABLE);
g_autoptr (WpProperties) props = wplua_table_to_properties (L, 2);
g_autoptr (WpProperties) props = NULL;
if (lua_istable (L, 2))
props = wplua_table_to_properties (L, 2);
else
props = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
wp_state_save_after_timeout (state, get_wp_core (L), props);
return 0;
}
@ -1629,8 +1672,8 @@ static int
state_load (lua_State *L)
{
WpState *state = wplua_checkobject (L, 1, WP_TYPE_STATE);
g_autoptr (WpProperties) props = wp_state_load (state);
wplua_properties_to_table (L, props);
WpProperties *props = wp_state_load (state);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, props);
return 1;
}
@ -1655,10 +1698,11 @@ impl_module_new (lua_State *L)
if (lua_type (L, 2) != LUA_TNONE && lua_type (L, 2) != LUA_TNIL)
args = luaL_checkstring (L, 2);
if (lua_type (L, 3) != LUA_TNONE && lua_type (L, 3) != LUA_TNIL) {
luaL_checktype (L, 3, LUA_TTABLE);
if (lua_istable (L, 3))
properties = wplua_table_to_properties (L, 3);
}
else if (!lua_isnone (L, 3) && !lua_isnil (L, 3))
properties = wp_properties_ref (wplua_checkboxed (L, 3,
WP_TYPE_PROPERTIES));
WpImplModule *m = wp_impl_module_load (get_wp_export_core (L),
name, args, properties);
@ -1681,9 +1725,10 @@ conf_new (lua_State *L)
WpProperties *p = NULL;
WpConf *conf = NULL;
if (lua_istable (L, 2)) {
if (lua_istable (L, 2))
p = wplua_table_to_properties (L, 2);
}
else if (!lua_isnone (L, 2) && !lua_isnil (L, 2))
p = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
conf = wp_conf_new (path, p);
if (conf) {
@ -1721,7 +1766,7 @@ conf_get_section_as_properties (lua_State *L)
const char *section = NULL;
g_autoptr (WpConf) conf = NULL;
g_autoptr (WpSpaJson) s = NULL;
g_autoptr (WpProperties) props = NULL;
WpProperties *props = NULL;
int argi = 1;
/* check if called as method on object */
@ -1736,6 +1781,8 @@ conf_get_section_as_properties (lua_State *L)
if (lua_istable (L, argi))
props = wplua_table_to_properties (L, argi);
else if (!lua_isnone (L, argi) && !lua_isnil (L, argi))
props = wp_properties_ref (wplua_checkboxed (L, argi, WP_TYPE_PROPERTIES));
else
props = wp_properties_new_empty ();
@ -1744,7 +1791,7 @@ conf_get_section_as_properties (lua_State *L)
if (s && wp_spa_json_is_object (s))
wp_properties_update_from_json (props, s);
}
wplua_properties_to_table (L, props);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, props);
return 1;
}
@ -1901,10 +1948,12 @@ json_utils_match_rules (lua_State *L)
gboolean res;
json = wplua_checkboxed (L, 1, WP_TYPE_SPA_JSON);
luaL_checktype (L, 2, LUA_TTABLE);
luaL_checktype (L, 3, LUA_TFUNCTION);
properties = wplua_table_to_properties (L, 2);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
else
properties = wp_properties_ref (wplua_checkboxed (L, 2, WP_TYPE_PROPERTIES));
res = wp_json_utils_match_rules (json, properties, json_utils_match_rules_cb,
L, &error);
@ -1920,17 +1969,21 @@ json_utils_match_rules (lua_State *L)
static int
json_utils_match_rules_update_properties (lua_State *L)
{
g_autoptr (WpProperties) properties = NULL;
WpProperties *properties = NULL;
WpSpaJson *json;
int count;
json = wplua_checkboxed (L, 1, WP_TYPE_SPA_JSON);
luaL_checktype (L, 2, LUA_TTABLE);
properties = wplua_table_to_properties (L, 2);
if (lua_istable (L, 2))
properties = wplua_table_to_properties (L, 2);
else
properties = wp_properties_ref (wplua_checkboxed (L, 2,
WP_TYPE_PROPERTIES));
count = wp_json_utils_match_rules_update_properties (json, properties);
wplua_properties_to_table (L, properties);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, properties);
lua_pushinteger (L, count);
return 2;
}
@ -2012,6 +2065,108 @@ static const luaL_Reg proc_utils_funcs[] = {
{ NULL, NULL }
};
/* Properties */
static int
properties_new (lua_State *L)
{
WpProperties *props;
if (lua_istable (L, 1))
props = wplua_table_to_properties (L, 1);
else if (!lua_isnone (L, 1) && !lua_isnil (L, 1))
props = wp_properties_ref (wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES));
else
props = wp_properties_new_empty ();
wplua_pushboxed (L, WP_TYPE_PROPERTIES, props);
return 1;
}
static int
properties_get_boolean (lua_State *L)
{
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
const char *key = luaL_checkstring (L, 2);
const char *val = wp_properties_get (props, key);
if (val)
lua_pushboolean (L, spa_atob (val));
else
lua_pushnil (L);
return 1;
}
static int
properties_get_int (lua_State *L)
{
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
const char *key = luaL_checkstring (L, 2);
const char *val = wp_properties_get (props, key);
if (val) {
gint64 int_val = 0;
if (spa_atoi64 (val, &int_val, 10))
lua_pushinteger (L, int_val);
else
lua_pushnil (L);
} else {
lua_pushnil (L);
}
return 1;
}
static int
properties_get_float (lua_State *L)
{
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
const char *key = luaL_checkstring (L, 2);
const char *val = wp_properties_get (props, key);
if (val) {
double d_val = 0;
if (spa_atod (val, &d_val))
lua_pushnumber (L, d_val);
else
lua_pushnil (L);
} else {
lua_pushnil (L);
}
return 1;
}
static int
properties_get_count (lua_State *L)
{
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
lua_pushinteger (L, wp_properties_get_count (props));
return 1;
}
static int
properties_copy (lua_State *L)
{
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
WpProperties *copy = wp_properties_copy (props);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, copy);
return 1;
}
static int
properties_parse (lua_State *L)
{
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
wplua_properties_to_table (L, props);
return 1;
}
static const luaL_Reg properties_funcs[] = {
{ "get_boolean", properties_get_boolean },
{ "get_int", properties_get_int },
{ "get_float", properties_get_float },
{ "get_count", properties_get_count },
{ "copy", properties_copy },
{ "parse", properties_parse },
{ NULL, NULL }
};
/* WpSettings */
static int
@ -2305,8 +2460,8 @@ static int
event_get_properties (lua_State *L)
{
WpEvent *event = wplua_checkboxed (L, 1, WP_TYPE_EVENT);
g_autoptr (WpProperties) props = wp_event_get_properties (event);
wplua_properties_to_table (L, props);
WpProperties *props = wp_event_get_properties (event);
wplua_pushboxed (L, WP_TYPE_PROPERTIES, props);
return 1;
}
@ -2428,10 +2583,11 @@ event_dispatcher_push_event (lua_State *L)
lua_pop (L, 1);
lua_pushliteral (L, "properties");
if (lua_gettable (L, 1) != LUA_TNIL) {
luaL_checktype (L, -1, LUA_TTABLE);
if (lua_istable (L, -1))
properties = wplua_table_to_properties (L, -1);
}
else if (!lua_isnil (L, -1) && !lua_isnone (L, -1) && !lua_isstring (L, -1))
properties = wp_properties_ref (
wplua_checkboxed (L, -1, WP_TYPE_PROPERTIES));
lua_pop (L, 1);
lua_pushliteral (L, "source");
@ -2984,6 +3140,10 @@ wp_lua_scripting_api_init (lua_State *L)
conf_new, conf_methods);
wplua_register_type_methods (L, WP_TYPE_PROC_INFO,
NULL, proc_info_funcs);
wplua_register_type_methods (L, WP_TYPE_ITERATOR,
NULL, iterator_funcs);
wplua_register_type_methods (L, WP_TYPE_PROPERTIES,
properties_new, properties_funcs);
if (!wplua_load_uri (L, URI_API, &error) ||
!wplua_pcall (L, 0, 0, &error)) {

View file

@ -217,6 +217,7 @@ SANDBOX_EXPORT = {
Conf = WpConf,
JsonUtils = JsonUtils,
ProcUtils = ProcUtils,
Properties = WpProperties_new,
SimpleEventHook = WpSimpleEventHook_new,
AsyncEventHook = WpAsyncEventHook_new,
}

View file

@ -300,40 +300,54 @@ spa_json_object_new (lua_State *L)
{
g_autoptr (WpSpaJsonBuilder) builder = wp_spa_json_builder_new_object ();
luaL_checktype (L, 1, LUA_TTABLE);
if (lua_istable (L, 1)) {
luaL_checktype (L, 1, LUA_TTABLE);
lua_pushnil (L);
while (lua_next (L, -2)) {
/* We only add table values with string keys */
if (lua_type (L, -2) == LUA_TSTRING) {
wp_spa_json_builder_add_property (builder, lua_tostring (L, -2));
lua_pushnil (L);
while (lua_next (L, -2)) {
/* We only add table values with string keys */
if (lua_type (L, -2) == LUA_TSTRING) {
wp_spa_json_builder_add_property (builder, lua_tostring (L, -2));
switch (lua_type (L, -1)) {
case LUA_TBOOLEAN:
wp_spa_json_builder_add_boolean (builder, lua_toboolean (L, -1));
break;
case LUA_TNUMBER:
if (lua_isinteger (L, -1))
wp_spa_json_builder_add_int (builder, lua_tointeger (L, -1));
else
wp_spa_json_builder_add_float (builder, lua_tonumber (L, -1));
break;
case LUA_TSTRING:
wp_spa_json_builder_add_string (builder, lua_tostring (L, -1));
break;
case LUA_TUSERDATA: {
WpSpaJson *json = wplua_checkboxed (L, -1, WP_TYPE_SPA_JSON);
wp_spa_json_builder_add_json (builder, json);
break;
switch (lua_type (L, -1)) {
case LUA_TBOOLEAN:
wp_spa_json_builder_add_boolean (builder, lua_toboolean (L, -1));
break;
case LUA_TNUMBER:
if (lua_isinteger (L, -1))
wp_spa_json_builder_add_int (builder, lua_tointeger (L, -1));
else
wp_spa_json_builder_add_float (builder, lua_tonumber (L, -1));
break;
case LUA_TSTRING:
wp_spa_json_builder_add_string (builder, lua_tostring (L, -1));
break;
case LUA_TUSERDATA: {
WpSpaJson *json = wplua_checkboxed (L, -1, WP_TYPE_SPA_JSON);
wp_spa_json_builder_add_json (builder, json);
break;
}
default:
luaL_error (L, "Json does not support lua type %s",
lua_typename(L, lua_type(L, -1)));
break;
}
default:
luaL_error (L, "Json does not support lua type %s",
lua_typename(L, lua_type(L, -1)));
break;
}
}
lua_pop (L, 1);
lua_pop (L, 1);
}
} else {
WpProperties *props = wplua_checkboxed (L, 1, WP_TYPE_PROPERTIES);
g_autoptr (WpIterator) it = NULL;
g_auto (GValue) item = G_VALUE_INIT;
for (it = wp_properties_new_iterator (props); wp_iterator_next (it, &item);
g_value_unset (&item)) {
WpPropertiesItem *pi = g_value_get_boxed (&item);
const gchar *key = wp_properties_item_get_key (pi);
const gchar *value = wp_properties_item_get_value (pi);
wp_spa_json_builder_add_property (builder, key);
wp_spa_json_builder_add_string (builder, value);
}
}
wplua_pushboxed (L, WP_TYPE_SPA_JSON, wp_spa_json_builder_end (builder));

View file

@ -29,8 +29,9 @@ _wplua_gboxed___index (lua_State *L)
GValue *obj_v = _wplua_togvalue_userdata_named (L, 1, G_TYPE_BOXED, "GBoxed");
luaL_argcheck (L, obj_v != NULL, 1,
"expected userdata storing GValue<GBoxed>");
const gchar *key = luaL_checkstring (L, 2);
const gchar *key = luaL_tolstring (L, 2, NULL);
GType type = G_VALUE_TYPE (obj_v);
GType boxed_type = type;
lua_CFunction func = NULL;
GHashTable *vtables;
@ -53,6 +54,104 @@ _wplua_gboxed___index (lua_State *L)
lua_pushcfunction (L, func);
return 1;
}
/* If WpProperties type, just return the property value for that key */
if (boxed_type == WP_TYPE_PROPERTIES) {
WpProperties * props = g_value_get_boxed (obj_v);
const gchar *val = wp_properties_get (props, key);
lua_pushstring (L, val);
return 1;
}
return 0;
}
static int
_wplua_gboxed___newindex (lua_State *L)
{
GValue *obj_v = _wplua_togvalue_userdata_named (L, 1, G_TYPE_BOXED, "GBoxed");
luaL_argcheck (L, obj_v != NULL, 1,
"expected userdata storing GValue<GBoxed>");
const gchar *key = luaL_tolstring (L, 2, NULL);
GType type = G_VALUE_TYPE (obj_v);
/* Set property value */
if (type == WP_TYPE_PROPERTIES) {
WpProperties * props = g_value_dup_boxed (obj_v);
g_autofree gchar *val = NULL;
luaL_checkany (L, 3);
switch (lua_type (L, 3)) {
case LUA_TNIL:
break;
case LUA_TUSERDATA: {
if (wplua_gvalue_userdata_type (L, 3) != G_TYPE_INVALID) {
GValue *v = lua_touserdata (L, 3);
gpointer p = g_value_peek_pointer (v);
val = g_strdup_printf ("%p", p);
break;
} else {
val = g_strdup (luaL_tolstring (L, 3, NULL));
break;
}
}
default:
val = g_strdup (luaL_tolstring (L, 3, NULL));
break;
}
props = wp_properties_ensure_unique_owner (props);
wp_properties_set (props, key, val);
g_value_take_boxed (obj_v, props);
} else {
luaL_error (L, "cannot assign property '%s' to boxed type %s",
key, g_type_name (type));
}
return 0;
}
static int
properties_iterator_next (lua_State *L)
{
WpIterator *it = wplua_checkboxed (L, 1, WP_TYPE_ITERATOR);
g_auto (GValue) item = G_VALUE_INIT;
if (wp_iterator_next (it, &item)) {
WpPropertiesItem *si = g_value_get_boxed (&item);
const gchar *k = wp_properties_item_get_key (si);
const gchar *v = wp_properties_item_get_value (si);
lua_pushstring (L, k);
lua_pushstring (L, v);
return 2;
} else {
lua_pushnil (L);
lua_pushnil (L);
return 2;
}
}
static int
push_properties_wpiterator (lua_State *L, WpIterator *it)
{
lua_pushcfunction (L, properties_iterator_next);
wplua_pushboxed (L, WP_TYPE_ITERATOR, it);
return 2;
}
static int
_wplua_gboxed___pairs (lua_State *L)
{
GValue *obj_v = _wplua_togvalue_userdata_named (L, 1, G_TYPE_BOXED, "GBoxed");
luaL_argcheck (L, obj_v != NULL, 1,
"expected userdata storing GValue<GBoxed>");
GType type = G_VALUE_TYPE (obj_v);
if (type == WP_TYPE_PROPERTIES) {
WpProperties * props = g_value_get_boxed (obj_v);
WpIterator *it = wp_properties_new_iterator (props);
return push_properties_wpiterator (L, it);
} else {
luaL_error (L, "cannot do pairs of boxed type %s", g_type_name (type));
}
return 0;
}
@ -69,6 +168,8 @@ _wplua_init_gboxed (lua_State *L)
{ "__gc", _wplua_gvalue_userdata___gc },
{ "__eq", _wplua_gboxed___eq },
{ "__index", _wplua_gboxed___index },
{ "__newindex", _wplua_gboxed___newindex },
{ "__pairs", _wplua_gboxed___pairs },
{ NULL, NULL }
};

View file

@ -14,7 +14,6 @@ WpProperties *
wplua_table_to_properties (lua_State *L, int idx)
{
WpProperties *p = wp_properties_new_empty ();
const gchar *key, *value;
int table = lua_absindex (L, idx);
if (lua_type (L, table) != LUA_TTABLE) {
@ -24,11 +23,34 @@ wplua_table_to_properties (lua_State *L, int idx)
lua_pushnil(L);
while (lua_next (L, table) != 0) {
const gchar *key = luaL_tolstring (L, -2, NULL);
g_autofree gchar *value = NULL;
/* copy key & value to convert them to string */
key = luaL_tolstring (L, -2, NULL);
value = luaL_tolstring (L, -2, NULL);
luaL_checkany (L, -2);
switch (lua_type (L, -2)) {
case LUA_TNIL:
lua_pop (L, 2);
break;
case LUA_TUSERDATA: {
if (wplua_gvalue_userdata_type(L, -2) != G_TYPE_INVALID) {
GValue *v = lua_touserdata (L, -2);
gpointer p = g_value_peek_pointer (v);
value = g_strdup_printf ("%p", p);
lua_pop (L, 2);
} else {
value = g_strdup (luaL_tolstring (L, -2, NULL));
lua_pop (L, 3);
}
break;
}
default:
value = g_strdup (luaL_tolstring (L, -2, NULL));
lua_pop (L, 3);
break;
}
wp_properties_set (p, key, value);
lua_pop (L, 3);
}
/* sort, because the lua table has a random order and it's too messy to read */
@ -313,10 +335,7 @@ wplua_gvalue_to_lua (lua_State *L, const GValue *v)
lua_pushlightuserdata (L, g_value_get_pointer (v));
break;
case G_TYPE_BOXED:
if (G_VALUE_TYPE (v) == WP_TYPE_PROPERTIES)
wplua_properties_to_table (L, g_value_get_boxed (v));
else
wplua_pushboxed (L, G_VALUE_TYPE (v), g_value_dup_boxed (v));
wplua_pushboxed (L, G_VALUE_TYPE (v), g_value_dup_boxed (v));
break;
case G_TYPE_OBJECT:
case G_TYPE_INTERFACE: {

View file

@ -103,14 +103,15 @@ static void
bind_call (GObject * obj, GAsyncResult * res, gpointer data)
{
WpModemManager *wpmm = WP_MODEM_MANAGER (data);
GError *err = NULL;
g_autoptr (GError) err = NULL;
GDBusProxy *call;
GVariant *prop;
g_autoptr (GVariant) prop = NULL;
gint init_state;
call = g_dbus_proxy_new_finish (res, &err);
if (call == NULL) {
wp_warning_object (wpmm, "Failed to get call");
g_prefix_error (&err, "Failed to get call: ");
wp_warning_object (wpmm, "%s", err->message);
return;
}
@ -122,8 +123,6 @@ bind_call (GObject * obj, GAsyncResult * res, gpointer data)
if (is_active_state (init_state))
active_calls_inc (wpmm);
g_variant_unref (prop);
}
wpmm->calls = g_list_prepend (wpmm->calls, call);
@ -165,7 +164,7 @@ on_voice_signal (GDBusProxy * iface,
g_object_get (wpmm->dbus, "connection", &conn, NULL);
if (!g_strcmp0 (signal, "CallAdded")) {
g_variant_get (params, "(o)", &path);
g_variant_get (params, "(&o)", &path);
g_dbus_proxy_new (conn,
G_DBUS_PROXY_FLAGS_NONE,
NULL,
@ -175,9 +174,8 @@ on_voice_signal (GDBusProxy * iface,
NULL,
bind_call,
wpmm);
g_free (path);
} else if (!g_strcmp0 (signal, "CallDeleted")) {
g_variant_get (params, "(o)", &path);
g_variant_get (params, "(&o)", &path);
// The user shouldn't have hundreds of calls, so just linear search.
deleted = g_list_find_custom (wpmm->calls, path, match_call_path);
@ -185,8 +183,6 @@ on_voice_signal (GDBusProxy * iface,
g_object_unref (deleted->data);
wpmm->calls = g_list_delete_link (wpmm->calls, deleted);
}
g_free (path);
}
}
@ -196,22 +192,23 @@ list_calls_done (GObject * obj,
gpointer data)
{
WpModemManager *wpmm = WP_MODEM_MANAGER (data);
GVariant *params;
GVariantIter *calls;
g_autoptr (GVariant) params = NULL;
g_autoptr (GVariantIter) calls = NULL;
gchar *path;
GError *err = NULL;
g_autoptr (GError) err = NULL;
g_autoptr (GDBusConnection) conn = NULL;
params = g_dbus_proxy_call_finish (G_DBUS_PROXY (obj), res, &err);
if (params == NULL) {
g_prefix_error (&err, "Failed to list active calls on startup: ");
wp_warning_object (wpmm, "%s", err->message);
g_clear_object (&err);
return;
}
g_object_get (wpmm->dbus, "connection", &conn, NULL);
g_variant_get (params, "(ao)", &calls);
while (g_variant_iter_loop (calls, "o", &path)) {
while (g_variant_iter_loop (calls, "&o", &path)) {
g_dbus_proxy_new (conn,
G_DBUS_PROXY_FLAGS_NONE,
NULL,
@ -222,9 +219,6 @@ list_calls_done (GObject * obj,
bind_call,
wpmm);
}
g_variant_iter_free (calls);
g_variant_unref (params);
}
static void
@ -353,7 +347,7 @@ static void
wp_modem_manager_enable (WpPlugin * self, WpTransition * transition)
{
WpModemManager *wpmm = WP_MODEM_MANAGER (self);
WpCore *core;
g_autoptr (WpCore) core = NULL;
GError *err;
g_autoptr (GDBusConnection) conn = NULL;

View file

@ -98,8 +98,10 @@ static void item_free (gpointer data)
{
Item *item = data;
free(item->desktop_entry);
free(item);
g_clear_pointer (&item->desktop_entry, g_free);
g_clear_pointer (&item->flatpak_app_id, g_free);
g_clear_pointer (&item->flatpak_instance_id, g_free);
g_free (item);
}
static Players *players_new (GDBusConnection *conn)
@ -128,7 +130,7 @@ static void players_unref (Players *players)
return;
g_mutex_clear (&players->lock);
g_clear_object (&players->items);
g_clear_pointer (&players->items, g_hash_table_unref);
g_clear_object (&players->conn);
g_clear_object (&players->cancellable);
g_free (players);
@ -236,7 +238,7 @@ static void item_desktop_entry_cb (GObject *source_object, GAsyncResult* res, gp
}
g_variant_get (result, "(v)", &value);
if (!g_str_equal(g_variant_get_type_string (value), "s")) {
if (!g_variant_is_of_type (value, G_VARIANT_TYPE_STRING)) {
wp_info ("%p: bad value for DesktopEntry for '%s'", update->players, update->bus_name);
return;
}
@ -426,6 +428,8 @@ wp_mpris_plugin_operation_finalize (GObject *object)
WpMprisPluginOperation *self = WP_MPRIS_PLUGIN_OPERATION (object);
g_clear_object (&self->conn);
G_OBJECT_CLASS (wp_mpris_plugin_operation_parent_class)->finalize (object);
}
static void
@ -529,9 +533,7 @@ wp_mpris_plugin_disable (WpPlugin * plugin)
static gpointer
wp_mpris_plugin_get_players (WpMprisPlugin *self)
{
g_auto (GVariantBuilder) b = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE_ARRAY);
g_variant_builder_init (&b, G_VARIANT_TYPE ("av"));
g_auto (GVariantBuilder) b = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE ("av"));
if (self->players) {
g_autoptr (GMutexLocker) locker = g_mutex_locker_new (&self->players->lock);

View file

@ -0,0 +1,152 @@
/* WirePlumber
*
* Copyright © 2021 Collabora Ltd.
* @author Julian Bouzas <julian.bouzas@collabora.com>
*
* SPDX-License-Identifier: MIT
*/
#include <wp/wp.h>
#include "dbus-connection-state.h"
WP_DEFINE_LOCAL_LOG_TOPIC ("m-notification")
#define DBUS_INTERFACE_NAME "org.freedesktop.Notifications"
#define DBUS_OBJECT_PATH "/org/freedesktop/Notifications"
enum
{
ACTION_GET_DBUS,
ACTION_SEND,
LAST_SIGNAL
};
static guint signals[LAST_SIGNAL] = { 0 };
struct _WpNotificationsPlugin
{
WpPlugin parent;
WpPlugin *dbus;
};
G_DECLARE_FINAL_TYPE (WpNotificationsPlugin,
wp_notifications_plugin, WP, NOTIFICATIONS_PLUGIN,
WpPlugin)
G_DEFINE_TYPE (WpNotificationsPlugin, wp_notifications_plugin,
WP_TYPE_PLUGIN)
static gpointer
wp_notifications_plugin_get_dbus (WpNotificationsPlugin *self)
{
return self->dbus ? g_object_ref (self->dbus) : NULL;
}
static void
wp_notifications_plugin_send (WpNotificationsPlugin *self,
const gchar *summary, const gchar *body_message)
{
g_autoptr (GDBusConnection) conn = NULL;
g_autoptr (GError) error = NULL;
g_autoptr (GVariant) res = NULL;
GVariantBuilder hints;
g_object_get (self->dbus, "connection", &conn, NULL);
g_return_if_fail (conn);
/* Set urgency */
g_variant_builder_init (&hints, G_VARIANT_TYPE("a{sv}"));
g_variant_builder_add (&hints, "{sv}", "urgency", g_variant_new_byte (0));
/* Notify */
res = g_dbus_connection_call_sync (conn, DBUS_INTERFACE_NAME,
DBUS_OBJECT_PATH, DBUS_INTERFACE_NAME, "Notify",
g_variant_new("(susssasa{sv}i)", "wireplumber", 0, "", summary,
body_message, NULL, &hints, -1), NULL, G_DBUS_CALL_FLAGS_NONE, -1,
NULL, &error);
if (error) {
g_autofree gchar *remote_error = g_dbus_error_get_remote_error (error);
g_dbus_error_strip_remote_error (error);
wp_warning_object (self, "Notify: %s (%s)", error->message, remote_error);
return;
}
}
static void
wp_notifications_plugin_init (WpNotificationsPlugin * self)
{
}
static void
wp_notifications_plugin_enable (WpPlugin * plugin,
WpTransition * transition)
{
WpNotificationsPlugin *self = WP_NOTIFICATIONS_PLUGIN (plugin);
g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
self->dbus = wp_plugin_find (core, "dbus-connection");
if (!self->dbus) {
wp_transition_return_error (transition, g_error_new (WP_DOMAIN_LIBRARY,
WP_LIBRARY_ERROR_INVARIANT,
"dbus-connection module must be loaded before notifications"));
return;
}
wp_object_update_features (WP_OBJECT (self), WP_PLUGIN_FEATURE_ENABLED, 0);
}
static void
wp_notifications_plugin_disable (WpPlugin * plugin)
{
WpNotificationsPlugin *self = WP_NOTIFICATIONS_PLUGIN (plugin);
g_clear_object (&self->dbus);
wp_object_update_features (WP_OBJECT (self), 0, WP_PLUGIN_FEATURE_ENABLED);
}
static void
wp_notifications_plugin_class_init (WpNotificationsPluginClass * klass)
{
WpPluginClass *plugin_class = (WpPluginClass *) klass;
plugin_class->enable = wp_notifications_plugin_enable;
plugin_class->disable = wp_notifications_plugin_disable;
/**
* WpNotificationsPlugin::get-dbus:
*
* Returns: (transfer full): the dbus object
*/
signals[ACTION_GET_DBUS] = g_signal_new_class_handler (
"get-dbus", G_TYPE_FROM_CLASS (klass),
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
(GCallback) wp_notifications_plugin_get_dbus,
NULL, NULL, NULL,
G_TYPE_OBJECT, 0);
/**
* WpNotificationsPlugin::send:
*
* @brief
* @em summary: the summary
* @em body_message: The body message
*/
signals[ACTION_SEND] = g_signal_new_class_handler (
"send", G_TYPE_FROM_CLASS (klass),
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
(GCallback) wp_notifications_plugin_send,
NULL, NULL, NULL, G_TYPE_VARIANT,
2, G_TYPE_STRING, G_TYPE_STRING);
}
WP_PLUGIN_EXPORT GObject *
wireplumber__module_init (WpCore * core, WpSpaJson * args, GError ** error)
{
return G_OBJECT (g_object_new (
wp_notifications_plugin_get_type(),
"name", "notifications-api",
"core", core,
NULL));
}

View file

@ -204,6 +204,8 @@ wp_portal_permissionstore_plugin_disable (WpPlugin * plugin)
WP_PORTAL_PERMISSIONSTORE_PLUGIN (plugin);
clear_signal (self);
if (self->dbus)
g_signal_handlers_disconnect_by_data (self->dbus, self);
g_clear_object (&self->dbus);
wp_object_update_features (WP_OBJECT (self), 0, WP_PLUGIN_FEATURE_ENABLED);

View file

@ -24,9 +24,11 @@ struct _WpSiAudioAdapter
WpSessionItem parent;
/* configuration */
gboolean reconfigured;
WpNode *node;
WpPort *port; /* only used for passthrough or convert mode */
gboolean no_format;
gboolean mono;
gboolean control_port;
gboolean monitor;
gboolean disable_dsp;
@ -104,6 +106,7 @@ si_audio_adapter_reset (WpSessionItem * item)
g_clear_object (&self->node);
g_clear_object (&self->port);
self->no_format = FALSE;
self->mono = FALSE;
self->control_port = FALSE;
self->monitor = FALSE;
self->disable_dsp = FALSE;
@ -134,10 +137,11 @@ si_audio_adapter_get_default_clock_rate (WpSiAudioAdapter * self)
static gboolean
is_unpositioned (struct spa_audio_info_raw *info)
{
uint32_t i;
uint32_t i, n_pos;
if (SPA_FLAG_IS_SET(info->flags, SPA_AUDIO_FLAG_UNPOSITIONED))
return TRUE;
for (i = 0; i < info->channels; i++)
n_pos = SPA_MIN(info->channels, SPA_N_ELEMENTS(info->position));
for (i = 0; i < n_pos; i++)
if (info->position[i] >= SPA_AUDIO_CHANNEL_START_Aux &&
info->position[i] <= SPA_AUDIO_CHANNEL_LAST_Aux)
return TRUE;
@ -145,7 +149,8 @@ is_unpositioned (struct spa_audio_info_raw *info)
}
static gboolean
si_audio_adapter_find_format (WpSiAudioAdapter * self, WpNode * node)
si_audio_adapter_find_format (WpSiAudioAdapter * self, WpNode * node,
gboolean mono)
{
g_autoptr (WpIterator) formats = NULL;
g_auto (GValue) value = G_VALUE_INIT;
@ -193,9 +198,14 @@ si_audio_adapter_find_format (WpSiAudioAdapter * self, WpNode * node)
continue;
if (position == NULL ||
!spa_pod_copy_array(position, SPA_TYPE_Id, raw_format.position, SPA_AUDIO_MAX_CHANNELS))
!spa_pod_copy_array(position, SPA_TYPE_Id, raw_format.position, SPA_N_ELEMENTS(raw_format.position)))
SPA_FLAG_SET(raw_format.flags, SPA_AUDIO_FLAG_UNPOSITIONED);
if (mono) {
raw_format.channels = 1;
raw_format.position [0] = SPA_AUDIO_CHANNEL_MONO;
}
if (self->raw_format.channels < raw_format.channels) {
self->raw_format = raw_format;
if (is_unpositioned(&raw_format))
@ -247,6 +257,8 @@ si_audio_adapter_configure (WpSessionItem * item, WpProperties *p)
WpNode *node = NULL;
const gchar *str;
self->reconfigured = self->node != NULL;
/* reset previous config */
si_audio_adapter_reset (item);
@ -264,7 +276,12 @@ si_audio_adapter_configure (WpSessionItem * item, WpProperties *p)
str = wp_properties_get (si_props, "item.features.no-format");
self->no_format = str && pw_properties_parse_bool (str);
if (!self->no_format && !si_audio_adapter_find_format (self, node)) {
str = wp_properties_get (si_props, "item.features.mono");
self->mono = str && pw_properties_parse_bool (str);
if (!self->no_format && !si_audio_adapter_find_format (self, node,
self->mono)) {
wp_notice_object (item, "no usable format found for node %d",
wp_proxy_get_bound_id (WP_PROXY (node)));
return FALSE;
@ -333,7 +350,8 @@ format_audio_raw_build (const struct spa_audio_info_raw *info)
if (!SPA_FLAG_IS_SET (info->flags, SPA_AUDIO_FLAG_UNPOSITIONED)) {
/* Build the position array spa pod */
g_autoptr (WpSpaPodBuilder) position_builder = wp_spa_pod_builder_new_array ();
for (guint i = 0; i < info->channels; i++)
guint n_pos = SPA_MIN(info->channels, SPA_N_ELEMENTS(info->position));
for (guint i = 0; i < n_pos; i++)
wp_spa_pod_builder_add_id (position_builder, info->position[i]);
/* Add the position property */
@ -611,8 +629,10 @@ si_audio_adapter_enable_active (WpSessionItem *si, WpTransition *transition)
/* If device node, enum available formats and set one of them */
if (!self->no_format && (self->is_device || self->dont_remix ||
!self->is_autoconnect || self->disable_dsp || self->is_unpositioned))
!self->is_autoconnect || self->disable_dsp || self->is_unpositioned ||
self->reconfigured)) {
si_audio_adapter_configure_node (self, transition);
}
/* Otherwise just finish activating */
else

View file

@ -195,12 +195,16 @@ on_link_activated (WpObject * proxy, GAsyncResult * res,
{
WpSiStandardLink *self = wp_transition_get_source_object (transition);
guint len = self->node_links ? self->node_links->len : 0;
g_autoptr (GError) error = NULL;
/* Count the number of failed and active links */
if (wp_object_activate_finish (proxy, res, NULL))
if (wp_object_activate_finish (proxy, res, &error)) {
self->n_active_links++;
else
} else {
self->n_failed_links++;
wp_info_object (self, "Failed to activate link %p: %s", proxy,
error->message);
}
/* Wait for all links to finish activation */
if (self->n_failed_links + self->n_active_links != len)

View file

@ -38,6 +38,7 @@ typedef enum {
typedef enum {
RESCAN_CONTEXT_LINKING,
RESCAN_CONTEXT_DEFAULT_NODES,
RESCAN_CONTEXT_MEDIA_ROLE_VOLUME,
N_RESCAN_CONTEXTS,
} RescanContext;
@ -48,6 +49,7 @@ rescan_context_get_type (void)
static const GEnumValue values[] = {
{ RESCAN_CONTEXT_LINKING, "RESCAN_CONTEXT_LINKING", "linking" },
{ RESCAN_CONTEXT_DEFAULT_NODES, "RESCAN_CONTEXT_DEFAULT_NODES", "default-nodes" },
{ RESCAN_CONTEXT_MEDIA_ROLE_VOLUME, "RESCAN_CONTEXT_MEDIA_ROLE_VOLUME", "media-role-volume" },
{ 0, NULL, NULL }
};
if (g_once_init_enter (&gtype_id)) {
@ -161,6 +163,8 @@ get_default_event_priority (const gchar *event_type)
return -490;
else if (!g_strcmp0 (event_type, "rescan-for-linking"))
return -500;
else if (!g_strcmp0 (event_type, "rescan-for-media-role-volume"))
return -510;
else if (!g_strcmp0 (event_type, "node-state-changed"))
return 50;
else if (!g_strcmp0 (event_type, "metadata-changed"))

View file

@ -63,6 +63,26 @@ msgstr ""
msgid "Default source volume"
msgstr ""
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/description
#: wireplumber.conf
msgid "Automatically mute all audio devices when active wired headphones/speakers are disconnected to prevent unintended sound output"
msgstr ""
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on wired audio disconnect"
msgstr ""
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/description
#: wireplumber.conf
msgid "Automatically mute all audio devices when active Bluetooth headphones/speakers are disconnected to prevent unintended sound output"
msgstr ""
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on Bluetooth audio disconnect"
msgstr ""
#. /wireplumber.settings.schema/linking.allow-moving-streams/description
#: wireplumber.conf
msgid "Streams may be moved by adding PipeWire metadata at runtime"
@ -103,6 +123,16 @@ msgstr ""
msgid "Ducking level"
msgstr ""
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/description
#: wireplumber.conf
msgid "Automatically detect channel count and positions for HDMI devices (experimental)"
msgstr ""
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/name
#: wireplumber.conf
msgid "Automatically detect HDMI channels (experimental)"
msgstr ""
#. /wireplumber.settings.schema/monitor.camera-discovery-timeout/description
#: wireplumber.conf
msgid "The camera discovery timeout in milliseconds"
@ -133,6 +163,16 @@ msgstr ""
msgid "Monitor ports"
msgstr ""
#. /wireplumber.settings.schema/node.features.audio.mono/description
#: wireplumber.conf
msgid "Configure all audio device sink nodes in MONO"
msgstr ""
#. /wireplumber.settings.schema/node.features.audio.mono/name
#: wireplumber.conf
msgid "Mono"
msgstr ""
#. /wireplumber.settings.schema/node.features.audio.no-dsp/description
#: wireplumber.conf
msgid "Do not convert audio to F32 format"

View file

@ -2,24 +2,22 @@
# Copyright (C) 2024 WirePlumber's COPYRIGHT HOLDER
# This file is distributed under the same license as the WirePlumber package.
#
# Martin <miles@filmsi.net>, 2024, 2025.
# Martin <miles@filmsi.net>, 2024, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: WirePlumber master\n"
"Report-Msgid-Bugs-To: https://gitlab.freedesktop.org/pipewire/wireplumber/-/"
"issues\n"
"POT-Creation-Date: 2025-08-21 03:57+0000\n"
"PO-Revision-Date: 2025-08-21 15:45+0200\n"
"Report-Msgid-Bugs-To: https://gitlab.freedesktop.org/pipewire/wireplumber/-/issues\n"
"POT-Creation-Date: 2025-12-15 16:28+0000\n"
"PO-Revision-Date: 2025-12-15 23:31+0100\n"
"Last-Translator: Martin Srebotnjak <miles@filmsi.net>\n"
"Language-Team: Slovenian GNOME Translation Team <gnome-si@googlegroups.com>\n"
"Language: sl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n"
"%100==4 ? 3 : 0);\n"
"X-Generator: Poedit 2.2.1\n"
"Plural-Forms: nplurals=4; plural=(n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0);\n"
"X-Generator: Poedit 3.8\n"
#. WirePlumber
#.
@ -49,7 +47,7 @@ msgstr "Razdeli %s"
#. also sanitize nick, replace ':' with ' '
#. ensure the node has a description
#. also sanitize description, replace ':' with ' '
#. add api.alsa.card.* properties for rule matching purposes
#. add api.alsa.card.* and alsa.* properties for rule matching purposes
#. add cpu.vm.name for rule matching purposes
#. apply properties from rules defined in JSON .conf file
#. handle split HW node
@ -75,6 +73,7 @@ msgstr "Modem"
#. form factor -> icon
#. apply properties from rules defined in JSON .conf file
#. override the device factory to use ACP
#. use HDMI channel detection if enabled in settings
#. use device reservation, if available
#. unlike pipewire-media-session, this logic here keeps the device
#. acquired at all times and destroys it if someone else acquires
@ -201,6 +200,34 @@ msgstr "Privzeta glasnost za zvočne vire"
msgid "Default source volume"
msgstr "Privzeta izvorna glasnost"
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/description
#: wireplumber.conf
msgid ""
"Automatically mute all audio devices when active wired headphones/speakers "
"are disconnected to prevent unintended sound output"
msgstr ""
"Samodejno utišaj vse zvočne naprave, ko so aktivne žične slušalke/zvočniki "
"odklopljeni, za preprečitev nenamernega zvočnega izhoda"
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on wired audio disconnect"
msgstr "Samodejna utišaj zvok pri prekinitvi žične zvokovne povezave"
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/description
#: wireplumber.conf
msgid ""
"Automatically mute all audio devices when active Bluetooth headphones/"
"speakers are disconnected to prevent unintended sound output"
msgstr ""
"Samodejno utišaj vse zvočne naprave, ko so aktivne slušalke/zvočniki "
"Bluetooth odklopljeni, da preprečite nenamerni izhod zvoka"
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on Bluetooth audio disconnect"
msgstr "Samodejni utišaj pri prekinitvi zvokovne povezave Bluetooth"
#. /wireplumber.settings.schema/linking.allow-moving-streams/description
#: wireplumber.conf
msgid "Streams may be moved by adding PipeWire metadata at runtime"
@ -247,6 +274,19 @@ msgstr ""
msgid "Ducking level"
msgstr "Stopnja umikanja"
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/description
#: wireplumber.conf
msgid ""
"Automatically detect channel count and positions for HDMI devices "
"(experimental)"
msgstr ""
"Samodejno zaznaj število kanalov in položaje za naprave HDMI (poskusno)"
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/name
#: wireplumber.conf
msgid "Automatically detect HDMI channels (experimental)"
msgstr "Samodejno zaznaj kanale HDMI (poskusno)"
#. /wireplumber.settings.schema/monitor.camera-discovery-timeout/description
#: wireplumber.conf
msgid "The camera discovery timeout in milliseconds"
@ -277,6 +317,16 @@ msgstr "Omogoči vrata nadzornih zvočnikov na zvočnih vozliščih"
msgid "Monitor ports"
msgstr "Vrata zvočnih monitorjev"
#. /wireplumber.settings.schema/node.features.audio.mono/description
#: wireplumber.conf
msgid "Configure all audio device sink nodes in MONO"
msgstr "Prilagodi vsa vozlišča zvokovnih ponorov v MONO"
#. /wireplumber.settings.schema/node.features.audio.mono/name
#: wireplumber.conf
msgid "Mono"
msgstr "Mono"
#. /wireplumber.settings.schema/node.features.audio.no-dsp/description
#: wireplumber.conf
msgid "Do not convert audio to F32 format"

400
po/tr.po
View file

@ -1,26 +1,25 @@
# Turkish translation for PipeWire.
# Copyright (C) 2014 PipeWire's COPYRIGHT HOLDER
# This file is distributed under the same license as the PipeWire package.
# Necdet Yücel <necdetyucel@gmail.com>, 2014.
# Kaan Özdinçer <kaanozdincer@gmail.com>, 2014.
# Muhammet Kara <muhammetk@gmail.com>, 2015, 2016, 2017.
# Oğuz Ersen <oguzersen@protonmail.com>, 2021.
# Turkish translation for WirePlumber.
# Copyright (C) 2025 WirePlumber's COPYRIGHT HOLDER
# This file is distributed under the same license as the WirePlumber package.
#
# Sabri Ünal <yakushabb@gmail.com>, 2025.
# Emin Tufan Çetin <etcetin@gmail.com>, 2025
#
msgid ""
msgstr ""
"Project-Id-Version: PipeWire master\n"
"Report-Msgid-Bugs-To: https://gitlab.freedesktop.org/pipewire/wireplumber/"
"issues/new\n"
"POT-Creation-Date: 2022-04-09 15:19+0300\n"
"PO-Revision-Date: 2021-12-06 21:31+0300\n"
"Last-Translator: Oğuz Ersen <oguzersen@protonmail.com>\n"
"Language-Team: Turkish <tr>\n"
"Project-Id-Version: WirePlumber master\n"
"Report-Msgid-Bugs-To: https://gitlab.freedesktop.org/pipewire/wireplumber/-/"
"issues\n"
"POT-Creation-Date: 2025-11-09 04:07+0000\n"
"PO-Revision-Date: 2025-11-09 08:00+0300\n"
"Last-Translator: Emin Tufan Çetin <etcetin@gmail.com>\n"
"Language-Team: Turkish <takim@gnome.org.tr>\n"
"Language: tr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 4.4.2\n"
"X-Generator: Poedit 3.8\n"
#. WirePlumber
#.
@ -28,13 +27,19 @@ msgstr ""
#. @author George Kiagiadakis <george.kiagiadakis@collabora.com>
#.
#. SPDX-License-Identifier: MIT
#. Receive script arguments from config.lua
#. ensure config.properties is not nil
#. preprocess rules and create Interest objects
#. applies properties from config.rules when asked to
#. unique device/node name tables
#. SPA ids to node names: name = id_name_table[device_id][node_id]
#. create the underlying hidden ALSA node
#. not suitable for loopback
#: src/scripts/monitors/alsa.lua:106
#, lua-format
msgid "Split %s"
msgstr "Bölük %s"
#. Connect ObjectConfig events to the right node
#. set the device id and spa factory name; REQUIRED, do not change
#. set the default pause-on-idle setting
#. try to negotiate the max ammount of channels
#. try to negotiate the max amount of channels
#. set priority
#. ensure the node has a media class
#. ensure the node has a name
@ -45,15 +50,360 @@ msgstr ""
#. ensure the node has a description
#. also sanitize description, replace ':' with ' '
#. add api.alsa.card.* properties for rule matching purposes
#. apply properties from config.rules
#. add cpu.vm.name for rule matching purposes
#. apply properties from rules defined in JSON .conf file
#. handle split HW node
#. create split PCM node
#. create the node
#. ensure the device has an appropriate name
#. deduplicate devices with the same name
#. ensure the device has a description
#: src/scripts/monitors/alsa.lua:222
msgid "Built-in Audio"
msgstr "Dahili Ses"
#: src/scripts/monitors/alsa.lua:438
msgid "Loopback"
msgstr "Geri Döngü"
#: src/scripts/monitors/alsa.lua:224
#: src/scripts/monitors/alsa.lua:440
msgid "Built-in Audio"
msgstr "Yerleşik Ses"
#: src/scripts/monitors/alsa.lua:442
msgid "Modem"
msgstr "Modem"
#. ensure the device has a nick
#. set the icon name
#. form factor -> icon
#. apply properties from rules defined in JSON .conf file
#. override the device factory to use ACP
#. use HDMI channel detection if enabled in settings
#. use device reservation, if available
#. unlike pipewire-media-session, this logic here keeps the device
#. acquired at all times and destroys it if someone else acquires
#. create the device
#. attempt to acquire again
#. destroy the device
#. create the device
#. handle create-object to prepare device
#. handle object-removed to destroy device reservations and recycle device name
#. reset the name tables to make sure names are recycled
#. activate monitor
#. 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
#. handle rd_plugin state changes to destroy and re-create the ALSA monitor in
#. case D-Bus service is restarted
#. create the monitor
#. WirePlumber
#.
#. Copyright © 2022 Pauli Virtanen
#. @author Pauli Virtanen
#.
#. SPDX-License-Identifier: MIT
#. unique device/node name tables
#. set the node description
#. sanitize description, replace ':' with ' '
#. set the node name
#. sanitize name
#. deduplicate nodes with the same name
#. apply properties from the rules in the configuration file
#. create the node
#. it doesn't necessarily need to be a local node,
#. the other Bluetooth parts run in the local process,
#. so it's consistent to have also this here
#. reset the name tables to make sure names are recycled
#: src/scripts/monitors/bluez-midi.lua:114
#, lua-format
msgid "BLE MIDI %d"
msgstr "BLE MIDI %d"
#. if logind support is enabled, activate
#. the monitor only when the seat is active
#. WirePlumber
#.
#. Copyright © 2023 Collabora Ltd.
#. @author Ashok Sidipotu <ashok.sidipotu@collabora.com>
#.
#. SPDX-License-Identifier: MIT
#. set the device id and spa factory name; REQUIRED, do not change
#. set the default pause-on-idle setting
#. set the node name
#. sanitize name
#. deduplicate nodes with the same name
#. set the node description
#: src/scripts/monitors/libcamera/name-node.lua:61
msgid "Built-in Front Camera"
msgstr "Yerleşik Ön Kamera"
#: src/scripts/monitors/libcamera/name-node.lua:63
msgid "Built-in Back Camera"
msgstr "Yerleşik Arka Kamera"
#. /wireplumber.settings.schema/bluetooth.autoswitch-to-headset-profile/description
#: wireplumber.conf
msgid ""
"Always show microphone for Bluetooth headsets, and switch to headset mode "
"when recording"
msgstr ""
"Bluetooth kulaklıklar için her zaman mikrofonu göster ve kayıt sırasında "
"kulaklık kipine geç"
#. /wireplumber.settings.schema/bluetooth.autoswitch-to-headset-profile/name
#: wireplumber.conf
msgid "Auto-switch to headset profile"
msgstr "Kulaklık profiline kendiliğinden geç"
#. /wireplumber.settings.schema/bluetooth.use-persistent-storage/description
#: wireplumber.conf
msgid "Remember and restore Bluetooth headset mode status"
msgstr "Bluetooth kulaklık kipi durumunu anımsa ve geri yükle"
#. /wireplumber.settings.schema/bluetooth.use-persistent-storage/name
#: wireplumber.conf
msgid "Persistent storage"
msgstr "Kalıcı depolama"
#. /wireplumber.settings.schema/device.restore-profile/description
#: wireplumber.conf
msgid "Remember and restore device profiles"
msgstr "Aygıt profillerini anımsa ve geri yükle"
#. /wireplumber.settings.schema/device.restore-profile/name
#: wireplumber.conf
msgid "Restore profile"
msgstr "Profili geri yükle"
#. /wireplumber.settings.schema/device.restore-routes/description
#: wireplumber.conf
msgid "Remember and restore device routes"
msgstr "Aygıt rotalarını anımsa ve geri yükle"
#. /wireplumber.settings.schema/device.restore-routes/name
#: wireplumber.conf
msgid "Restore routes"
msgstr "Rotaları geri yükle"
#. /wireplumber.settings.schema/device.routes.default-sink-volume/description
#: wireplumber.conf
msgid "The default volume for audio sinks"
msgstr "Ses alıcıları için öntanımlı ses düzeyi"
#. /wireplumber.settings.schema/device.routes.default-sink-volume/name
#: wireplumber.conf
msgid "Default sink volume"
msgstr "Öntanımlı alıcı ses düzeyi"
#. /wireplumber.settings.schema/device.routes.default-source-volume/description
#: wireplumber.conf
msgid "The default volume for audio sources"
msgstr "Ses kaynakları için öntanımlı ses düzeyi"
#. /wireplumber.settings.schema/device.routes.default-source-volume/name
#: wireplumber.conf
msgid "Default source volume"
msgstr "Öntanımlı kaynak ses düzeyi"
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/description
#: wireplumber.conf
msgid ""
"Automatically mute all audio devices when active wired headphones/speakers "
"are disconnected to prevent unintended sound output"
msgstr ""
"İstenmeyen ses çıktısını önlemek için etkin kablolu kulaklık/hoparlör "
"bağlantısı kesildiğinde tüm ses aygıtlarını kendiliğinden sustur"
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on wired audio disconnect"
msgstr "Kablolu ses bağlantısı kesildiğinde kendiliğinden sustur"
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/description
#: wireplumber.conf
msgid ""
"Automatically mute all audio devices when active Bluetooth headphones/"
"speakers are disconnected to prevent unintended sound output"
msgstr ""
"İstenmeyen ses çıktısını önlemek için etkin Bluetooth kulaklık/hoparlör "
"bağlantısı kesildiğinde tüm ses aygıtlarını kendiliğinden sustur"
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on Bluetooth audio disconnect"
msgstr "Bluetooth ses bağlantısı kesildiğinde kendiliğinden sustur"
#. /wireplumber.settings.schema/linking.allow-moving-streams/description
#: wireplumber.conf
msgid "Streams may be moved by adding PipeWire metadata at runtime"
msgstr "Akışlar, çalışma zamanında PipeWire üst verileri eklenerek taşınabilir"
#. /wireplumber.settings.schema/linking.allow-moving-streams/name
#: wireplumber.conf
msgid "Allow moving streams"
msgstr "Akışları taşımaya izin ver"
#. /wireplumber.settings.schema/linking.follow-default-target/description
#: wireplumber.conf
msgid "Streams connected to the default device follow when default changes"
msgstr "Öntanımlı aygıta bağlı akışlar öntanımlı değiştiğinde izler"
#. /wireplumber.settings.schema/linking.follow-default-target/name
#: wireplumber.conf
msgid "Follow default target"
msgstr "Öntanımlı hedefi izle"
#. /wireplumber.settings.schema/linking.pause-playback/description
#: wireplumber.conf
msgid "Pause media players if their target sink is removed"
msgstr "Hedef alıcıları kaldırılırsa ortam oynatıcıları duraklat"
#. /wireplumber.settings.schema/linking.pause-playback/name
#: wireplumber.conf
msgid "Pause playback if output removed"
msgstr "Çıktı kaldırılırsa oynatmayı duraklat"
#. /wireplumber.settings.schema/linking.role-based.duck-level/description
#: wireplumber.conf
msgid ""
"The volume level to apply when ducking (= reducing volume for a higher "
"priority stream to be audible) in the role-based linking policy"
msgstr ""
"Rol tabanlı bağlantı ilkesinde eğilirken (= daha öncelikli akışın "
"duyulabilmesi için ses düzeyinin azaltılması) uygulanacak ses düzeyi"
#. /wireplumber.settings.schema/linking.role-based.duck-level/name
#: wireplumber.conf
msgid "Ducking level"
msgstr "Eğilme düzeyi"
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/description
#: wireplumber.conf
msgid ""
"Automatically detect channel count and positions for HDMI devices "
"(experimental)"
msgstr ""
"HDMI aygıtları için kanal sayısını ve konumlarını kendiliğinden algıla "
"(deneysel)"
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/name
#: wireplumber.conf
msgid "Automatically detect HDMI channels (experimental)"
msgstr "HDMI kanallarını kendiliğinden algıla (deneysel)"
#. /wireplumber.settings.schema/monitor.camera-discovery-timeout/description
#: wireplumber.conf
msgid "The camera discovery timeout in milliseconds"
msgstr "Kamera keşif zaman aşımı, saniye türünden"
#. /wireplumber.settings.schema/monitor.camera-discovery-timeout/name
#: wireplumber.conf
msgid "Discovery timeout"
msgstr "Keşif zaman aşımı"
#. /wireplumber.settings.schema/node.features.audio.control-port/description
#: wireplumber.conf
msgid "Enable control ports on audio nodes"
msgstr "Ses düğümlerinde denetim bağlantı noktalarını etkinleştir"
#. /wireplumber.settings.schema/node.features.audio.control-port/name
#: wireplumber.conf
msgid "Control ports"
msgstr "Denetim bağlantı noktaları"
#. /wireplumber.settings.schema/node.features.audio.monitor-ports/description
#: wireplumber.conf
msgid "Enable monitor ports on audio nodes"
msgstr "Ses düğümlerinde izleme bağlantı noktalarını etkinleştir"
#. /wireplumber.settings.schema/node.features.audio.monitor-ports/name
#: wireplumber.conf
msgid "Monitor ports"
msgstr "İzleme bağlantı noktaları"
#. /wireplumber.settings.schema/node.features.audio.mono/description
#: wireplumber.conf
msgid "Configure all audio nodes in MONO"
msgstr "Tüm ses düğümlerini MONO olarak yapılandır"
#. /wireplumber.settings.schema/node.features.audio.mono/name
#: wireplumber.conf
msgid "Mono"
msgstr "Mono"
#. /wireplumber.settings.schema/node.features.audio.no-dsp/description
#: wireplumber.conf
msgid "Do not convert audio to F32 format"
msgstr "Sesi F32 biçimine dönüştürme"
#. /wireplumber.settings.schema/node.features.audio.no-dsp/name
#: wireplumber.conf
msgid "No DSP"
msgstr "DSP yok"
#. /wireplumber.settings.schema/node.filter.forward-format/description
#: wireplumber.conf
msgid "Forward format on filter nodes or not"
msgstr "Süzgeç düğümlerinde biçimi ilet ya da iletme"
#. /wireplumber.settings.schema/node.filter.forward-format/name
#: wireplumber.conf
msgid "Forward format"
msgstr "Biçimi ilet"
#. /wireplumber.settings.schema/node.restore-default-targets/description
#: wireplumber.conf
msgid "Remember and restore default audio/video input/output devices"
msgstr "Öntanımlı ses/video girdi/çıktı aygıtlarını anımsa ve geri yükle"
#. /wireplumber.settings.schema/node.restore-default-targets/name
#: wireplumber.conf
msgid "Restore default target"
msgstr "Öntanımlı hedefi geri yükle"
#. /wireplumber.settings.schema/node.stream.default-capture-volume/description
#: wireplumber.conf
msgid "The default volume for capture nodes"
msgstr "Yakalama düğümleri için öntanımlı ses düzeyi"
#. /wireplumber.settings.schema/node.stream.default-capture-volume/name
#: wireplumber.conf
msgid "Default capture volume"
msgstr "Öntanımlı yakalama ses düzeyi"
#. /wireplumber.settings.schema/node.stream.default-media-role/description
#: wireplumber.conf
msgid "Default media.role to assign on streams that do not specify it"
msgstr "Belirtilmeyen akışlarda atanacak öntanımlı ortam rolü"
#. /wireplumber.settings.schema/node.stream.default-media-role/name
#: wireplumber.conf
msgid "Default media role"
msgstr "Öntanımlı ortam rolü"
#. /wireplumber.settings.schema/node.stream.default-playback-volume/description
#: wireplumber.conf
msgid "The default volume for playback nodes"
msgstr "Oynatma düğümleri için öntanımlı ses düzeyi"
#. /wireplumber.settings.schema/node.stream.default-playback-volume/name
#: wireplumber.conf
msgid "Default playback volume"
msgstr "Öntanımlı oynatma ses düzeyi"
#. /wireplumber.settings.schema/node.stream.restore-props/description
#: wireplumber.conf
msgid "Remember and restore properties of streams"
msgstr "Akışların özelliklerini anımsa ve geri yükle"
#. /wireplumber.settings.schema/node.stream.restore-props/name
#: wireplumber.conf
msgid "Restore properties"
msgstr "Özellikleri geri yükle"
#. /wireplumber.settings.schema/node.stream.restore-target/description
#: wireplumber.conf
msgid "Remember and restore stream targets"
msgstr "Akış hedeflerini anımsa ve geri yükle"
#. /wireplumber.settings.schema/node.stream.restore-target/name
#: wireplumber.conf
msgid "Restore target"
msgstr "Hedefi geri yükle"

View file

@ -6,15 +6,15 @@
# Cheng-Chia Tseng <pswo10680@gmail.com>, 2010, 2012.
# Frank Hill <hxf.prc@gmail.com>, 2015.
# Mingye Wang (Arthur2e5) <arthur200126@gmail.com>, 2015.
# lumingzh <lumingzh@qq.com>, 2024.
# lumingzh <lumingzh@qq.com>, 2024-2025.
#
msgid ""
msgstr ""
"Project-Id-Version: pipewire.master-tx\n"
"Report-Msgid-Bugs-To: https://gitlab.freedesktop.org/pipewire/wireplumber/-/"
"issues\n"
"POT-Creation-Date: 2024-01-08 15:36+0000\n"
"PO-Revision-Date: 2024-10-08 08:59+0800\n"
"POT-Creation-Date: 2025-12-15 16:28+0000\n"
"PO-Revision-Date: 2025-12-16 10:10+0800\n"
"Last-Translator: lumingzh <lumingzh@qq.com>\n"
"Language-Team: Chinese (China) <i18n-zh@googlegroups.com>\n"
"Language: zh_CN\n"
@ -22,7 +22,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Launchpad-Export-Date: 2016-03-22 13:23+0000\n"
"X-Generator: Gtranslator 47.0\n"
"X-Generator: Gtranslator 49.0\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. WirePlumber
@ -32,9 +32,18 @@ msgstr ""
#.
#. SPDX-License-Identifier: MIT
#. unique device/node name tables
#. SPA ids to node names: name = id_name_table[device_id][node_id]
#. create the underlying hidden ALSA node
#. not suitable for loopback
#: src/scripts/monitors/alsa.lua:106
#, lua-format
msgid "Split %s"
msgstr "分离 %s"
#. Connect ObjectConfig events to the right node
#. set the device id and spa factory name; REQUIRED, do not change
#. set the default pause-on-idle setting
#. try to negotiate the max ammount of channels
#. try to negotiate the max amount of channels
#. set priority
#. ensure the node has a media class
#. ensure the node has a name
@ -44,22 +53,24 @@ msgstr ""
#. also sanitize nick, replace ':' with ' '
#. ensure the node has a description
#. also sanitize description, replace ':' with ' '
#. add api.alsa.card.* properties for rule matching purposes
#. add vm.type for rule matching purposes
#. add api.alsa.card.* and alsa.* properties for rule matching purposes
#. add cpu.vm.name for rule matching purposes
#. apply properties from rules defined in JSON .conf file
#. handle split HW node
#. create split PCM node
#. create the node
#. ensure the device has an appropriate name
#. deduplicate devices with the same name
#. ensure the device has a description
#: src/scripts/monitors/alsa.lua:211
#: src/scripts/monitors/alsa.lua:438
msgid "Loopback"
msgstr "回环"
#: src/scripts/monitors/alsa.lua:213
#: src/scripts/monitors/alsa.lua:440
msgid "Built-in Audio"
msgstr "内置音频"
#: src/scripts/monitors/alsa.lua:215
#: src/scripts/monitors/alsa.lua:442
msgid "Modem"
msgstr "调制解调器"
@ -68,6 +79,7 @@ msgstr "调制解调器"
#. form factor -> icon
#. apply properties from rules defined in JSON .conf file
#. override the device factory to use ACP
#. use HDMI channel detection if enabled in settings
#. use device reservation, if available
#. unlike pipewire-media-session, this logic here keeps the device
#. acquired at all times and destroys it if someone else acquires
@ -97,13 +109,13 @@ msgstr "调制解调器"
#. set the node name
#. sanitize name
#. deduplicate nodes with the same name
#. apply properties from bluetooth.conf
#. apply properties from the rules in the configuration file
#. create the node
#. it doesn't necessarily need to be a local node,
#. the other Bluetooth parts run in the local process,
#. so it's consistent to have also this here
#. reset the name tables to make sure names are recycled
#: src/scripts/monitors/bluez-midi.lua:113
#: src/scripts/monitors/bluez-midi.lua:114
#, lua-format
msgid "BLE MIDI %d"
msgstr "BLE MIDI %d"
@ -129,3 +141,267 @@ msgstr "内置前摄像头"
#: src/scripts/monitors/libcamera/name-node.lua:63
msgid "Built-in Back Camera"
msgstr "内置后摄像头"
#. /wireplumber.settings.schema/bluetooth.autoswitch-to-headset-profile/description
#: wireplumber.conf
msgid ""
"Always show microphone for Bluetooth headsets, and switch to headset mode "
"when recording"
msgstr "总是显示蓝牙耳机的麦克风,并在录制时切换到耳机模式"
#. /wireplumber.settings.schema/bluetooth.autoswitch-to-headset-profile/name
#: wireplumber.conf
msgid "Auto-switch to headset profile"
msgstr "自动切换至耳机配置"
#. /wireplumber.settings.schema/bluetooth.use-persistent-storage/description
#: wireplumber.conf
msgid "Remember and restore Bluetooth headset mode status"
msgstr "记住和恢复蓝牙耳机模式状态"
#. /wireplumber.settings.schema/bluetooth.use-persistent-storage/name
#: wireplumber.conf
msgid "Persistent storage"
msgstr "永久存储"
#. /wireplumber.settings.schema/device.restore-profile/description
#: wireplumber.conf
msgid "Remember and restore device profiles"
msgstr "记住和恢复设备配置"
#. /wireplumber.settings.schema/device.restore-profile/name
#: wireplumber.conf
msgid "Restore profile"
msgstr "恢复配置"
#. /wireplumber.settings.schema/device.restore-routes/description
#: wireplumber.conf
msgid "Remember and restore device routes"
msgstr "记住和恢复设备路由"
#. /wireplumber.settings.schema/device.restore-routes/name
#: wireplumber.conf
msgid "Restore routes"
msgstr "恢复路由"
#. /wireplumber.settings.schema/device.routes.default-sink-volume/description
#: wireplumber.conf
msgid "The default volume for audio sinks"
msgstr "音频信宿的默认音量"
#. /wireplumber.settings.schema/device.routes.default-sink-volume/name
#: wireplumber.conf
msgid "Default sink volume"
msgstr "默认信宿音量"
#. /wireplumber.settings.schema/device.routes.default-source-volume/description
#: wireplumber.conf
msgid "The default volume for audio sources"
msgstr "音频信源的默认音量"
#. /wireplumber.settings.schema/device.routes.default-source-volume/name
#: wireplumber.conf
msgid "Default source volume"
msgstr "默认信源音量"
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/description
#: wireplumber.conf
msgid ""
"Automatically mute all audio devices when active wired headphones/speakers "
"are disconnected to prevent unintended sound output"
msgstr ""
"当活动的有线耳机/扬声器断开连接时自动静音所有音频设备以防止意外的声音输出"
#. /wireplumber.settings.schema/device.routes.mute-on-alsa-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on wired audio disconnect"
msgstr "有线音频设备断开连接时自动静音"
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/description
#: wireplumber.conf
msgid ""
"Automatically mute all audio devices when active Bluetooth headphones/"
"speakers are disconnected to prevent unintended sound output"
msgstr ""
"当活动的蓝牙耳机/扬声器断开连接时自动静音所有音频设备以防止意外的声音输出"
#. /wireplumber.settings.schema/device.routes.mute-on-bluetooth-playback-removed/name
#: wireplumber.conf
msgid "Auto-mute on Bluetooth audio disconnect"
msgstr "蓝牙音频设备断开连接时自动静音"
#. /wireplumber.settings.schema/linking.allow-moving-streams/description
#: wireplumber.conf
msgid "Streams may be moved by adding PipeWire metadata at runtime"
msgstr "在运行时通过添加 PipeWire 元数据可移动媒体流"
#. /wireplumber.settings.schema/linking.allow-moving-streams/name
#: wireplumber.conf
msgid "Allow moving streams"
msgstr "允许移动媒体流"
#. /wireplumber.settings.schema/linking.follow-default-target/description
#: wireplumber.conf
msgid "Streams connected to the default device follow when default changes"
msgstr "当默认设备改变时与其连接的媒体流将跟随改变"
#. /wireplumber.settings.schema/linking.follow-default-target/name
#: wireplumber.conf
msgid "Follow default target"
msgstr "跟随默认目标"
#. /wireplumber.settings.schema/linking.pause-playback/description
#: wireplumber.conf
msgid "Pause media players if their target sink is removed"
msgstr "如果其目标信宿被移除则暂停媒体播放器"
#. /wireplumber.settings.schema/linking.pause-playback/name
#: wireplumber.conf
msgid "Pause playback if output removed"
msgstr "如果输出被移除则暂停播放"
#. /wireplumber.settings.schema/linking.role-based.duck-level/description
#: wireplumber.conf
msgid ""
"The volume level to apply when ducking (= reducing volume for a higher "
"priority stream to be audible) in the role-based linking policy"
msgstr ""
"在基于角色的链接策略中执行回避操作(= 降低音量以凸显更高优先级的媒体流)时所"
"应用的音量级别"
#. /wireplumber.settings.schema/linking.role-based.duck-level/name
#: wireplumber.conf
msgid "Ducking level"
msgstr "回避级别"
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/description
#: wireplumber.conf
msgid ""
"Automatically detect channel count and positions for HDMI devices "
"(experimental)"
msgstr "自动检测 HDMI 设备的声道数量和位置(实验性)"
#. /wireplumber.settings.schema/monitor.alsa.autodetect-hdmi-channels/name
#: wireplumber.conf
msgid "Automatically detect HDMI channels (experimental)"
msgstr "自动检测 HDMI 声道(实验性)"
#. /wireplumber.settings.schema/monitor.camera-discovery-timeout/description
#: wireplumber.conf
msgid "The camera discovery timeout in milliseconds"
msgstr "摄像头发现超时设置(毫秒)"
#. /wireplumber.settings.schema/monitor.camera-discovery-timeout/name
#: wireplumber.conf
msgid "Discovery timeout"
msgstr "发现超时"
#. /wireplumber.settings.schema/node.features.audio.control-port/description
#: wireplumber.conf
msgid "Enable control ports on audio nodes"
msgstr "启用音频节点上的控制端口"
#. /wireplumber.settings.schema/node.features.audio.control-port/name
#: wireplumber.conf
msgid "Control ports"
msgstr "控制端口"
#. /wireplumber.settings.schema/node.features.audio.monitor-ports/description
#: wireplumber.conf
msgid "Enable monitor ports on audio nodes"
msgstr "启用音频节点上的监视器端口"
#. /wireplumber.settings.schema/node.features.audio.monitor-ports/name
#: wireplumber.conf
msgid "Monitor ports"
msgstr "监视器端口"
#. /wireplumber.settings.schema/node.features.audio.mono/description
#: wireplumber.conf
msgid "Configure all audio device sink nodes in MONO"
msgstr "在单声道中配置所有音频设备信宿节点"
#. /wireplumber.settings.schema/node.features.audio.mono/name
#: wireplumber.conf
msgid "Mono"
msgstr "单声道"
#. /wireplumber.settings.schema/node.features.audio.no-dsp/description
#: wireplumber.conf
msgid "Do not convert audio to F32 format"
msgstr "不要将音频转换至 F32 格式"
#. /wireplumber.settings.schema/node.features.audio.no-dsp/name
#: wireplumber.conf
msgid "No DSP"
msgstr "无 DSP"
#. /wireplumber.settings.schema/node.filter.forward-format/description
#: wireplumber.conf
msgid "Forward format on filter nodes or not"
msgstr "是否在滤波器节点上转发格式"
#. /wireplumber.settings.schema/node.filter.forward-format/name
#: wireplumber.conf
msgid "Forward format"
msgstr "转发格式"
#. /wireplumber.settings.schema/node.restore-default-targets/description
#: wireplumber.conf
msgid "Remember and restore default audio/video input/output devices"
msgstr "记住和恢复默认音频/视频的输入/输出设备"
#. /wireplumber.settings.schema/node.restore-default-targets/name
#: wireplumber.conf
msgid "Restore default target"
msgstr "恢复默认目标"
#. /wireplumber.settings.schema/node.stream.default-capture-volume/description
#: wireplumber.conf
msgid "The default volume for capture nodes"
msgstr "捕获节点的默认音量"
#. /wireplumber.settings.schema/node.stream.default-capture-volume/name
#: wireplumber.conf
msgid "Default capture volume"
msgstr "默认捕获音量"
#. /wireplumber.settings.schema/node.stream.default-media-role/description
#: wireplumber.conf
msgid "Default media.role to assign on streams that do not specify it"
msgstr "分配给未指定媒体角色的媒体流的默认 media.role"
#. /wireplumber.settings.schema/node.stream.default-media-role/name
#: wireplumber.conf
msgid "Default media role"
msgstr "默认媒体角色"
#. /wireplumber.settings.schema/node.stream.default-playback-volume/description
#: wireplumber.conf
msgid "The default volume for playback nodes"
msgstr "播放节点的默认音量"
#. /wireplumber.settings.schema/node.stream.default-playback-volume/name
#: wireplumber.conf
msgid "Default playback volume"
msgstr "默认播放音量"
#. /wireplumber.settings.schema/node.stream.restore-props/description
#: wireplumber.conf
msgid "Remember and restore properties of streams"
msgstr "记住和恢复媒体流属性"
#. /wireplumber.settings.schema/node.stream.restore-props/name
#: wireplumber.conf
msgid "Restore properties"
msgstr "恢复属性"
#. /wireplumber.settings.schema/node.stream.restore-target/description
#: wireplumber.conf
msgid "Remember and restore stream targets"
msgstr "记住和恢复媒体流目标"
#. /wireplumber.settings.schema/node.stream.restore-target/name
#: wireplumber.conf
msgid "Restore target"
msgstr "恢复目标"

View file

@ -335,6 +335,14 @@ wireplumber.components = [
provides = api.file-monitor
}
## API to send D-Bus notifications
{
name = libwireplumber-module-notifications-api, type = module
provides = api.notifications
requires = [ support.dbus ]
}
## Provide the "default" pw_metadata
{
name = metadata.lua, type = script/lua
@ -554,12 +562,18 @@ wireplumber.components = [
name = device/apply-routes.lua, type = script/lua
provides = hooks.device.routes.apply
}
{
name = device/automute-alsa-routes.lua, type = script/lua
provides = hooks.device.routes.automute-alsa
wants = [ api.notifications ]
}
{
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 ]
hooks.device.routes.state,
hooks.device.routes.automute-alsa ]
}
## Default nodes selection hooks
@ -613,12 +627,17 @@ wireplumber.components = [
name = node/filter-forward-format.lua, type = script/lua
provides = hooks.filter.forward-format
}
{
name = node/filter-graph.lua, type = script/lua
provides = hooks.filter.graph
}
{
type = virtual, provides = policy.node
requires = [ hooks.node.create-session-item ]
wants = [ hooks.node.suspend
hooks.stream.state
hooks.filter.forward-format ]
hooks.filter.forward-format
hooks.filter.graph ]
}
{
name = node/software-dsp.lua, type = script/lua
@ -703,10 +722,21 @@ wireplumber.components = [
provides = hooks.linking.role-based.rescan
requires = [ api.mixer ]
}
{
name = node/find-media-role-default-volume.lua, type = script/lua
provides = hooks.node.role-based.default-volume
requires = [ hooks.linking.role-based.rescan ]
}
{
name = linking/find-media-role-sink-target.lua, type = script/lua
provides = hooks.linking.target.find-media-role-sink
}
{
type = virtual, provides = policy.linking.role-based
requires = [ policy.linking.standard,
hooks.linking.role-based.rescan ]
hooks.linking.role-based.rescan,
hooks.node.role-based.default-volume,
hooks.linking.target.find-media-role-sink ]
}
## Standard policy definition
@ -847,6 +877,19 @@ wireplumber.settings.schema = {
min = 0.0
max = 1.0
}
device.routes.mute-on-alsa-playback-removed = {
name = "Auto-mute on wired audio disconnect"
description = "Automatically mute all audio devices when active wired headphones/speakers are disconnected to prevent unintended sound output"
type = "bool"
default = false
}
device.routes.mute-on-bluetooth-playback-removed = {
name = "Auto-mute on Bluetooth audio disconnect"
description = "Automatically mute all audio devices when active Bluetooth headphones/speakers are disconnected to prevent unintended sound output"
type = "bool"
default = false
}
## Linking
linking.role-based.duck-level = {
@ -885,6 +928,12 @@ wireplumber.settings.schema = {
min = 0
max = 60000
}
monitor.alsa.autodetect-hdmi-channels = {
name = "Automatically detect HDMI channels (experimental)"
description = "Automatically detect channel count and positions for HDMI devices (experimental)"
type = "bool"
default = false
}
## Node
node.features.audio.no-dsp = {
@ -905,6 +954,12 @@ wireplumber.settings.schema = {
type = "bool"
default = false
}
node.features.audio.mono = {
name = "Mono"
description = "Configure all audio device sink nodes in MONO"
type = "bool"
default = false
}
node.stream.restore-props = {
name = "Restore properties"
description = "Remember and restore properties of streams"

View file

@ -0,0 +1,43 @@
node.filter-graph.rules = [
## The list of filter graph rules
## This rule example creates two filter graphs for each audio source node
# {
# matches = [
# {
# ## This matches all audio source nodes
# media.class = "Audio/Source"
# }
# ]
# actions = {
# create-filter-graph = [
# ## Multiple filter graphs can be defined here.
# ## The syntax is the same as the pipewire filter-chain conf files.
#
# ## This is an example of a bultin passthrough filter
# {
# nodes = [
# {
# type = builtin
# label = copy
# name = passthrough
# }
# ]
# }
#
# ## This is an example of a LADSPA rnnoise filter
# {
# nodes = [
# {
# type = ladspa
# name = rnnoise
# plugin = librnnoise_ladspa
# label = noise_suppressor_stereo
# }
# ]
# }
# ]
# }
# }
]

View file

@ -150,6 +150,7 @@ wireplumber.components = [
policy.role-based.priority = 100
policy.role-based.action.same-priority = "mix"
policy.role-based.action.lower-priority = "cork"
policy.role-based.preferred-target = "Speaker"
}
}
provides = loopback.sink.role.alert

View file

@ -19,5 +19,25 @@ monitor.alsa.rules = [
api.alsa.headroom = 2048
}
}
},
# VMware & VirtualBox on Windows hosts require more headroom to
# avoid stuttering.
{
matches = [
{
node.name = "~alsa_input.pci.*"
cpu.vm.name = "~^(vmware)|(oracle)$"
}
{
node.name = "~alsa_output.pci.*"
cpu.vm.name = "~^(vmware)|(oracle)$"
}
]
actions = {
update-props = {
api.alsa.period-size = 1024
api.alsa.headroom = 8192
}
}
}
]

View file

@ -24,6 +24,9 @@ SimpleEventHook {
local om = source:call ("get-object-manager", "metadata")
local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } }
if metadata == nil then
return
end
if selected_node then
local key = "default." .. def_node_type

View file

@ -46,10 +46,18 @@ Hooks
- File
- Description
* - device/find-calling-profile
- find-voice-call-profile.lua
- selects a profile based on the state of calls
* - device/find-stored-profile
- state-profile.lua
- selects the profile that has been stored in the state file (user's explicit selection)
* - device/find-preferred-profile
- find-preferred-profile.lua
- selects the profile based on device configured priorities
* - device/find-best-profile
- find-best-profile.lua
- finds the best profile for a device based on profile priorities and availability

View file

@ -11,7 +11,7 @@ log = Log.open_topic ("s-device")
AsyncEventHook {
name = "device/apply-profile",
after = { "device/find-stored-profile", "device/find-preferred-profile", "device/find-best-profile" },
after = { "device/find-calling-profile", "device/find-stored-profile", "device/find-preferred-profile", "device/find-best-profile" },
interests = {
EventInterest {
Constraint { "event.type", "=", "select-profile" },

View file

@ -0,0 +1,220 @@
-- WirePlumber
--
-- Copyright © 2025 Collabora Ltd.
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
cutils = require ("common-utils")
log = Log.open_topic ("s-automute-alsa-routes")
hooks_registered = false
function setRoute (device, route, mute)
local param = Pod.Object {
"Spa:Pod:Object:Param:Route", "Route",
index = route.index,
device = route.device,
props = Pod.Object {
"Spa:Pod:Object:Param:Props", "Route",
mute = mute
},
save = false,
}
log:info (device, "Setting mute to " .. tostring(mute) ..
" on route " .. route.name)
device:set_param("Route", param)
end
function findLowestPriorityAvailableOutputRoute (device)
local lowest_prio_r = nil
for p in device:iterate_params("Route") do
local route = cutils.parseParam (p, "Route")
if route and route.direction == "Output" and route.available ~= "no" then
if lowest_prio_r == nil or lowest_prio_r.priority > route.priority then
lowest_prio_r = route
end
end
end
return lowest_prio_r
end
function evaluateNode (node, source)
if nodes_info [node.id] == nil then
return
end
-- Get node info
local node_state = nodes_info [node.id].state
local node_api = nodes_info [node.id].api
local node_dev_id = nodes_info [node.id].dev_id
local node_cpd = nodes_info [node.id].cpd
-- Don't do anything if node was not running
if node_state ~= "running" then
return
end
-- Emite event if setting is enabled for this API
local mute_alsa = Settings.get_boolean ("device.routes.mute-on-alsa-playback-removed")
local mute_bluez = Settings.get_boolean ("device.routes.mute-on-bluetooth-playback-removed")
if (mute_alsa and node_api == "alsa") or
(mute_bluez and node_api == "bluez5") then
local e = source:call ("create-event", "mute-alsa-devices", nil, nil)
e:set_data ("device-id", node_dev_id)
e:set_data ("card-profile-device", node_cpd)
EventDispatcher.push_event (e)
end
end
mute_alsa_devices_hook = SimpleEventHook {
name = "device/mute-alsa-devices",
interests = {
EventInterest {
Constraint { "event.type", "=", "mute-alsa-devices" },
},
},
execute = function (event)
local source = event:get_source ()
local dev_id = tonumber (event:get_data ("device-id"))
local cpd = tonumber (event:get_data ("card-profile-device"))
local device_om = source:call ("get-object-manager", "device")
local send_notification = false
-- We mute all available output ALSA routes but the one associated with
-- the running node.
--
-- We also don't mute any routes if the running node is associated with
-- the lowest priority route as this is most likely to be the Speakers.
--
-- For instance, we want to mute all routes except the Headphones one
-- when unplugging a headset while playing audio, but we don't
-- want to mute the Headphones route when a headset is plugged in while
-- playing audio on the Speakers.
for device in device_om:iterate() do
local dev_bound_id = device["bound-id"]
if device.properties["device.api"] == "alsa" then
local lpr = findLowestPriorityAvailableOutputRoute (device)
if lpr == nil or lpr.device ~= cpd or dev_bound_id ~= dev_id then
for p in device:iterate_params("Route") do
local route = cutils.parseParam (p, "Route")
if route and
route.direction == "Output" and
route.available ~= "no" and
(route.device ~= cpd or dev_bound_id ~= dev_id) then
setRoute (device, route, true)
send_notification = true
end
end
end
end
end
-- Send notification if devices were muted
notifications = notifications or Plugin.find("notifications-api")
if notifications ~= nil and send_notification then
notifications:call ("send", I18n.gettext("Audio was auto-muted"),
I18n.gettext("Active playback device was disconnected"))
end
end
}
update_nodes_info_hook = SimpleEventHook {
name = "device/update-nodes-info",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "matches", "Audio/Sink" },
Constraint { "device.api", "+" },
Constraint { "device.id", "+" },
Constraint { "card.profile.device", "+", type = "pw" },
},
},
execute = function (event)
local node = event:get_subject ()
local node_id = node["bound-id"]
local device_api = node.properties ["device.api"]
local device_id = node.properties ["device.id"]
local cpd = node.properties ["card.profile.device"]
local new_state = event:get_properties ()["event.subject.new-state"]
-- Update node info
if nodes_info [node.id] == nil then
nodes_info [node.id] = {}
end
nodes_info [node.id].api = device_api
nodes_info [node.id].state = new_state
nodes_info [node.id].dev_id = device_id
nodes_info [node.id].cpd = cpd
end
}
evaluate_mute_on_device_route_changed_hook = SimpleEventHook {
name = "device/evaluate-mute-on-device-route-changed",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "=", "EnumRoute" }
},
},
execute = function (event)
local source = event:get_source ()
local device = event:get_subject ()
local node_om = source:call ("get-object-manager", "node")
-- Evaluate all nodes for this device when the EnumRoute param changed
for node in node_om:iterate {
Constraint { "media.class", "matches", "Audio/Sink", type = "pw-global" },
Constraint { "device.id", "=", device["bound-id"], type = "pw-global" },
} do
evaluateNode (node, source)
end
end
}
evaluate_mute_on_node_removed_hook = SimpleEventHook {
name = "device/evaluate-mute-on-node-removed",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-removed" },
Constraint { "media.class", "matches", "Audio/Sink" },
},
},
execute = function (event)
local source = event:get_source ()
local node = event:get_subject ()
-- Evaluate removed node
evaluateNode (node, source)
-- Clear removed node info
nodes_info [node.id] = nil
end
}
function toggleState ()
local mute_alsa = Settings.get_boolean ("device.routes.mute-on-alsa-playback-removed")
local mute_bluez = Settings.get_boolean ("device.routes.mute-on-bluetooth-playback-removed")
if (mute_alsa or mute_bluez) and not hooks_registered then
nodes_info = {}
mute_alsa_devices_hook:register ()
update_nodes_info_hook:register ()
evaluate_mute_on_device_route_changed_hook:register ()
evaluate_mute_on_node_removed_hook:register ()
hooks_registered = true
elseif not mute_alsa and not mute_bluez and hooks_registered then
mute_alsa_devices_hook:remove ()
update_nodes_info_hook:remove ()
evaluate_mute_on_device_route_changed_hook:remove ()
evaluate_mute_on_node_removed_hook:remove ()
hooks_registered = false
end
end
Settings.subscribe ("device.routes.mute-on-alsa-playback-removed", function ()
toggleState ()
end)
Settings.subscribe ("device.routes.mute-on-bluetooth-playback-removed", function ()
toggleState ()
end)
toggleState ()

View file

@ -28,43 +28,25 @@
lutils = require ("linking-utils")
cutils = require ("common-utils")
log = Log.open_topic ("s-device")
persistent_storage_hooks_registered = false
autoswitch_hooks_registered = false
state = nil
headset_profiles = nil
local PROFILE_RESTORE_TIMEOUT_MSEC = 2000
local PROFILE_SWITCH_TIMEOUT_MSEC = 500
local profile_restore_timeout_msec = 2000
local profile_switch_timeout_msec = 500
local INVALID = -1
local state = nil
local headset_profiles = {}
local non_headset_profiles = {}
local capture_stream_links = {}
local restore_timeout_source = {}
local switch_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)
function saveHeadsetProfile (device, profile_name)
function saveHeadsetProfile (device, profile_name, persistent)
local key = "saved-headset-profile:" .. device.properties ["device.name"]
headset_profiles [key] = profile_name
state:save_after_timeout (headset_profiles)
if state ~= nil and persistent then
state:save_after_timeout (headset_profiles)
end
end
function getSavedHeadsetProfile (device)
@ -72,85 +54,72 @@ function getSavedHeadsetProfile (device)
return headset_profiles [key]
end
function saveLastProfile (device, profile_name)
last_profiles [device.properties ["device.name"]] = profile_name
function saveNonHeadsetProfile (device, profile_name)
non_headset_profiles [device.properties ["device.name"]] = profile_name
end
function getSavedLastProfile (device)
return last_profiles [device.properties ["device.name"]]
end
function isSwitchedToHeadsetProfile (device)
return getSavedLastProfile (device) ~= nil
function getSavedNonHeadsetProfile (device)
return non_headset_profiles [device.properties ["device.name"]]
end
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
function getCurrentProfile (device)
for p in device:iterate_params ("Profile") do
local profile = cutils.parseParam (p, "Profile")
if profile then
return profile.name
if profile ~= nil then
if (index ~= nil and profile.index == index) or
(name ~= nil and profile.name == name) then
return profile
end
end
end
return nil
end
function highestPrioProfileWithInputRoute (device)
local profile_priority = INVALID
local profile_index = INVALID
local profile_name = nil
function getCurrentProfile (device)
for p in device:iterate_params ("Profile") do
local profile = cutils.parseParam (p, "Profile")
if profile then
return profile
end
end
return nil
end
function highestPrioProfileWithInputRoute (device)
local found_profile = 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
if route ~= nil and route.profiles ~= nil and route.direction == "Input" 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
local p = findProfile (device, v)
if p ~= nil then
if found_profile == nil or found_profile.priority < p.priority then
found_profile = p
end
end
end
end
::skip_enum_route::
end
return found_profile
end
return profile_priority, profile_index, profile_name
function highestPrioProfileWithoutInputRoute (device)
local found_profile = nil
for p in device:iterate_params ("EnumRoute") do
local route = cutils.parseParam (p, "EnumRoute")
if route ~= nil and route.profiles ~= nil and route.direction ~= "Input" then
for _, v in pairs (route.profiles) do
local p = findProfile (device, v)
if p ~= nil then
if found_profile == nil or found_profile.priority < p.priority then
found_profile = p
end
end
end
end
end
return found_profile
end
function hasProfileInputRoute (device, profile_index)
@ -177,49 +146,40 @@ function switchDeviceToHeadsetProfile (dev_id, device_om)
return
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")
-- Do not switch if the current profile is already a headset profile
local cur_profile = getCurrentProfile (device)
if cur_profile ~= nil and
hasProfileInputRoute (device, cur_profile.index) then
log:info (device,
"Current profile is already a headset profile, no need to switch")
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)
-- Get saved headset profile if any, otherwise find the highest priority one
local profile = nil
local profile_name = getSavedHeadsetProfile (device)
if profile_name ~= nil then
profile = findProfile (device, nil, profile_name)
if profile ~= nil and not hasProfileInputRoute (device, profile.index) then
saveHeadsetProfile (device, nil, false)
end
end
if index == INVALID then
priority, index, name = highestPrioProfileWithInputRoute (device)
if profile == nil then
profile = highestPrioProfileWithInputRoute (device)
end
if index ~= INVALID then
-- Switch if headset profile was found
if profile ~= nil then
local pod = Pod.Object {
"Spa:Pod:Object:Param:Profile", "Profile",
index = index
index = profile.index,
save = false
}
-- 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)
log:info (device, "Switching profile from: " .. cur_profile.name
.. " to: " .. profile.name)
device:set_params ("Profile", pod)
else
log:warning ("Got invalid index when switching profile")
log:warning ("Could not find valid headset profile, not switching")
end
end
@ -233,45 +193,40 @@ function restoreProfile (dev_id, device_om)
return
end
if not isSwitchedToHeadsetProfile (device) then
log:info ("Device with id " .. tostring(dev_id).. " is already not switched to HSP/HFP")
-- Do not restore if the current profile is already a non-headset profile
local cur_profile = getCurrentProfile (device)
if cur_profile ~= nil and
not hasProfileInputRoute (device, cur_profile.index) then
log:info (device,
"Current profile is already a non-headset profile, no need to restore")
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)
-- Get saved non-headset profile if any, otherwise find the highest priority one
local profile = nil
local profile_name = getSavedNonHeadsetProfile (device)
if profile_name ~= nil then
profile = findProfile (device, nil, profile_name)
if profile ~= nil and hasProfileInputRoute (device, profile.index) then
saveNonHeadsetProfile (device, nil)
end
end
if profile == nil then
profile = highestPrioProfileWithoutInputRoute (device)
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
-- Restore if non-headset profile was found
if profile ~= nil then
local pod = Pod.Object {
"Spa:Pod:Object:Param:Profile", "Profile",
index = profile.index,
save = false
}
log:info (device, "Restoring profile from: " .. cur_profile.name
.. " to: " .. profile.name)
device:set_params ("Profile", pod)
else
log:warning ("Could not find valid non-headset profile, not switching")
end
end
@ -280,95 +235,89 @@ function triggerSwitchDeviceToHeadsetProfile (dev_id, device_om)
if restore_timeout_source[dev_id] ~= nil then
restore_timeout_source[dev_id]:destroy ()
restore_timeout_source[dev_id] = nil
log:info ("Cancelled profile restore on device " .. tostring (dev_id))
end
if switch_timeout_source[dev_id] ~= nil then
switch_timeout_source[dev_id]:destroy ()
switch_timeout_source[dev_id] = nil
log:info ("Cancelled profile switch on device " .. tostring (dev_id))
end
-- create new switch callback
switch_timeout_source[dev_id] = Core.timeout_add (profile_switch_timeout_msec, function ()
log:info ("Triggering profile switch on device " .. tostring (dev_id))
switch_timeout_source[dev_id] = Core.timeout_add (PROFILE_SWITCH_TIMEOUT_MSEC, function ()
switch_timeout_source[dev_id] = nil
switchDeviceToHeadsetProfile (dev_id, device_om)
end)
end
function triggerRestoreProfile (dev_id, device_om)
-- 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
-- Always clear any pending restore/switch callbacks when triggering a new restore
if switch_timeout_source[dev_id] ~= nil then
switch_timeout_source[dev_id]:destroy ()
switch_timeout_source[dev_id] = nil
log:info ("Cancelled profile switch on device " .. tostring (dev_id))
end
if restore_timeout_source[dev_id] ~= nil then
restore_timeout_source[dev_id]:destroy ()
restore_timeout_source[dev_id] = nil
log:info ("Cancelled profile restore on device " .. tostring (dev_id))
end
-- create new restore callback
restore_timeout_source[dev_id] = Core.timeout_add (profile_restore_timeout_msec, function ()
log:info ("Triggering profile restore on device " .. tostring (dev_id))
restore_timeout_source[dev_id] = Core.timeout_add (PROFILE_RESTORE_TIMEOUT_MSEC, function ()
restore_timeout_source[dev_id] = nil
restoreProfile (dev_id, device_om)
end)
end
-- We consider a Stream of interest if it is linked to a bluetooth loopback
-- source filter
function checkStreamStatus (stream, node_om, visited_link_groups)
-- 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 = node_om:lookup {
Constraint { "bound-id", "=", peer_id, type = "gobject" },
Constraint { "bluez5.loopback", "=", "true", type = "pw" }
}
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
function getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om, visited_link_groups)
local stream_id = stream["bound-id"]
return dev_id
-- Make sure the node is linked
local link = link_om:lookup {
Constraint { "link.input.node", "=", stream_id, type = "pw-global"}
}
if link == nil then
return nil
end
local peer_id = link.properties["link.output.node"]
-- If the peer node is the BT loopback source node, return its Id.
-- Otherwise recursively advance in the graph if it is linked to a filter.
local bt_node = node_om:lookup {
Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" },
Constraint { "bound-id", "=", peer_id, type = "gobject" },
Constraint { "bluez5.loopback", "=", "true", type = "pw" }
}
if bt_node ~= nil then
return bt_node
else
local filter_main_node = node_om:lookup {
Constraint { "bound-id", "=", peer_id, type = "gobject" },
Constraint { "node.link-group", "+", type = "pw" }
}
if filter_main_node ~= nil then
local filter_link_group = filter_main_node.properties ["node.link-group"]
if visited_link_groups == nil then
visited_link_groups = {}
end
else
-- Check if it is linked to a filter main node, and recursively advance if so
local filter_main_node = node_om:lookup {
Constraint { "bound-id", "=", peer_id, type = "gobject" },
Constraint { "node.link-group", "+", type = "pw" }
}
if filter_main_node ~= nil then
-- Now check all stream nodes for this filter
local filter_link_group = filter_main_node.properties ["node.link-group"]
if visited_link_groups == nil then
visited_link_groups = {}
end
if visited_link_groups [filter_link_group] then
return nil
else
visited_link_groups [filter_link_group] = true
end
for filter_stream_node in node_om:iterate {
Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
Constraint { "stream.monitor", "!", "true", type = "pw" },
Constraint { "bluez5.loopback", "!", "true", type = "pw" },
Constraint { "node.link-group", "=", filter_link_group, type = "pw" }
} do
local dev_id = checkStreamStatus (filter_stream_node, node_om, visited_link_groups)
if dev_id ~= nil then
return dev_id
end
if visited_link_groups [filter_link_group] then
return nil
else
visited_link_groups [filter_link_group] = true
end
for filter_stream_node in node_om:iterate {
Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
Constraint { "stream.monitor", "!", "true", type = "pw" },
Constraint { "bluez5.loopback", "!", "true", type = "pw" },
Constraint { "node.link-group", "=", filter_link_group, type = "pw" }
} do
local filter_stream_id = filter_stream_node["bound-id"]
local bt_node = getLinkedBluetoothLoopbackSourceNodeForStream (filter_stream_id, node_om, link_om, visited_link_groups)
if bt_node ~= nil then
return bt_node
end
end
end
@ -377,60 +326,65 @@ function checkStreamStatus (stream, node_om, visited_link_groups)
return nil
end
function handleStream (stream, node_om, device_om)
if not Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") then
return
end
local dev_id = checkStreamStatus (stream, node_om)
if dev_id ~= nil then
active_streams [stream.id] = dev_id
previous_streams [stream.id] = dev_id
triggerSwitchDeviceToHeadsetProfile (dev_id, device_om)
else
dev_id = active_streams [stream.id]
active_streams [stream.id] = nil
if dev_id ~= nil then
triggerRestoreProfile (dev_id, device_om)
end
end
end
function handleAllStreams (node_om, device_om)
function isBluetoothLoopbackSourceNodeLinkedToStream (bt_node, node_om, link_om)
local bt_node_id = bt_node["bound-id"]
for stream in node_om:iterate {
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" }
} do
handleStream (stream, node_om, device_om)
local linked_bt_node = getLinkedBluetoothLoopbackSourceNodeForStream (stream, node_om, link_om)
if linked_bt_node ~= nil then
local linked_bt_node_id = linked_bt_node ["bound-id"]
if tonumber (linked_bt_node_id) == tonumber (bt_node_id) then
return true
end
end
end
return false
end
SimpleEventHook {
name = "node-removed@autoswitch-bluetooth-profile",
local evaluate_bluetooth_profiles_hook = SimpleEventHook {
name = "evaluate-bluetooth-profiles@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" },
Constraint { "event.type", "=", "evaluate-bluetooth-profiles" },
},
},
execute = function (event)
local stream = event:get_subject ()
local source = event:get_source ()
local node_om = source:call ("get-object-manager", "node")
local device_om = source:call ("get-object-manager", "device")
local link_om = source:call ("get-object-manager", "link")
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, device_om)
-- Evaluate all bluetooth loopback source nodes, and switch to headset
-- profile only if the node is running and linked to a stream that is not a
-- monitor, otherwise just restore the profile.
--
-- If the bluetooth node is linked to a stream that is a monitor, its state
-- will be 'running', so we cannot just rely on the state to know if we
-- have to switch or not, we also need to check if the node is linked to
-- a stream that is not a monitor.
for bt_node in node_om:iterate {
Constraint { "media.class", "matches", "Audio/Source" },
Constraint { "device.id", "+" },
Constraint { "bluez5.loopback", "=", "true", type = "pw" }
} do
local bt_node_state = bt_node["state"]
local bt_dev_id = bt_node.properties ["device.id"]
if bt_node_state == "running" and
isBluetoothLoopbackSourceNodeLinkedToStream (bt_node, node_om, link_om) then
triggerSwitchDeviceToHeadsetProfile (bt_dev_id, device_om)
else
triggerRestoreProfile (bt_dev_id, device_om)
end
end
end
}:register ()
}
SimpleEventHook {
local link_added_hook = SimpleEventHook {
name = "link-added@autoswitch-bluetooth-profile",
interests = {
EventInterest {
@ -438,46 +392,149 @@ SimpleEventHook {
},
},
execute = function (event)
local link = event:get_subject ()
local source = event:get_source ()
local node_om = source:call ("get-object-manager", "node")
local device_om = source:call ("get-object-manager", "device")
local link_props = link.properties
local link = event:get_subject ()
local in_stream_id = link.properties["link.input.node"]
for stream in node_om:iterate {
-- Only evaluate bluetooth profiles if a capture stream was linked
local stream = node_om:lookup {
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" }
} do
local in_id = tonumber(link_props["link.input.node"])
local stream_id = tonumber(stream["bound-id"])
if in_id == stream_id then
handleStream (stream, node_om, device_om)
end
Constraint { "bluez5.loopback", "!", "true", type = "pw" },
Constraint { "bound-id", "=", in_stream_id, type = "gobject" },
}
if stream ~= nil then
capture_stream_links [link.id] = true
source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil)
end
end
}:register ()
}
SimpleEventHook {
name = "bluez-device-added@autoswitch-bluetooth-profile",
local link_removed_hook = SimpleEventHook {
name = "link-removed@autoswitch-bluetooth-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-added" },
Constraint { "event.type", "=", "link-removed" },
},
},
execute = function (event)
local source = event:get_source ()
local link = event:get_subject ()
-- Only evaluate bluetooth profiles if a capture stream was unlinked
if capture_stream_links [link.id] then
capture_stream_links [link.id] = nil
source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil)
end
end
}
local state_changed_hook = SimpleEventHook {
name = "bluez-loopback-state-changed@autoswitch-bluetooth-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "matches", "Audio/Source" },
Constraint { "device.id", "+" },
Constraint { "bluez5.loopback", "=", "true", type = "pw" }
},
},
execute = function (event)
local source = event:get_source ()
source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil)
end
}
local node_added_hook = SimpleEventHook {
name = "bluez-loopback-added@autoswitch-bluetooth-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "matches", "Audio/Source" },
Constraint { "device.id", "+" },
Constraint { "bluez5.loopback", "=", "true", type = "pw" }
},
},
execute = function (event)
local source = event:get_source ()
source:call ("push-event", "evaluate-bluetooth-profiles", nil, nil)
end
}
local device_profile_changed_hook = SimpleEventHook {
name = "device/store-user-selected-profile",
interests = {
EventInterest {
Constraint { "event.type", "=", "device-params-changed" },
Constraint { "event.subject.param-id", "=", "Profile" },
Constraint { "device.api", "=", "bluez5" },
},
},
execute = function (event)
local device = event:get_subject ()
local source = event:get_source ()
local node_om = source:call ("get-object-manager", "node")
local device_om = source:call ("get-object-manager", "device")
-- Devices are unswitched initially
saveLastProfile (device, nil)
-- Handle all streams when BT device is added
handleAllStreams (node_om, device_om)
-- Always save the current profile when it changes
local cur_profile = getCurrentProfile (device)
if cur_profile ~= nil then
if hasProfileInputRoute (device, cur_profile.index) then
log:info (device, "Saving headset profile " .. cur_profile.name)
saveHeadsetProfile (device, cur_profile.name, cur_profile.save)
else
log:info (device, "Saving non-headset profile " .. cur_profile.name)
saveNonHeadsetProfile (device, cur_profile.name)
end
end
end
}:register ()
}
function evaluatePersistentStorage ()
if Settings.get_boolean ("bluetooth.use-persistent-storage") and
not persistent_storage_hooks_registered then
state = State ("bluetooth-autoswitch")
headset_profiles = state:load ()
persistent_storage_hooks_registered = true
elseif persistent_storage_hooks_registered then
state = nil
headset_profiles = {}
persistent_storage_hooks_registered = false
end
end
function evaluateAutoswitch ()
if Settings.get_boolean ("bluetooth.autoswitch-to-headset-profile") and
not autoswitch_hooks_registered then
capture_stream_links = {}
restore_timeout_source = {}
switch_timeout_source = {}
evaluate_bluetooth_profiles_hook:register ()
link_added_hook:register ()
link_removed_hook:register ()
state_changed_hook:register ()
node_added_hook:register ()
device_profile_changed_hook:register ()
autoswitch_hooks_registered = true
elseif autoswitch_hooks_registered then
capture_stream_links = nil
restore_timeout_source = nil
switch_timeout_source = nil
evaluate_bluetooth_profiles_hook:remove ()
link_added_hook:remove ()
link_removed_hook:remove ()
state_changed_hook:remove ()
node_added_hook:remove ()
device_profile_changed_hook:remove ()
autoswitch_hooks_registered = false
end
end
Settings.subscribe ("bluetooth.use-persistent-storage", function ()
evaluatePersistentStorage ()
end)
evaluatePersistentStorage ()
Settings.subscribe ("bluetooth.autoswitch-to-headset-profile", function ()
evaluateAutoswitch ()
end)
evaluateAutoswitch ()

View file

@ -27,7 +27,7 @@ SimpleEventHook {
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 selected_routes = event:get_data ("selected-routes") or Properties()
local dev_info = devinfo:get_device_info (device)
assert (dev_info)

View file

@ -71,7 +71,7 @@ SimpleEventHook {
if selected_profile then
log:info (device, string.format (
"Found best profile '%s' (%d) for device %s",
"Found calling profile '%s' (%d) for device %s",
selected_profile.name, selected_profile.index, dev_name))
event:set_data ("selected-profile", selected_profile)
end

View file

@ -34,7 +34,10 @@ find_stored_profile_hook = SimpleEventHook {
end
local device = event:get_subject ()
local dev_name = device.properties["device.name"]
local device_props = device.properties
local dev_name = device_props["device.name"]
local dont_restore_off_profile = cutils.parseBool (
device_props["session.dont-restore-off-profile"])
if not dev_name then
log:critical (device, "invalid device.name")
return
@ -45,7 +48,8 @@ find_stored_profile_hook = SimpleEventHook {
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
if profile.name == profile_name and profile.available ~= "no" and
(not dont_restore_off_profile or profile.index ~= 0) then
selected_profile = profile
break
end

View file

@ -36,7 +36,7 @@ find_stored_routes_hook = SimpleEventHook {
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 selected_routes = event:get_data ("selected-routes") or Properties()
local dev_info = devinfo:get_device_info (device)
assert (dev_info)
@ -108,13 +108,13 @@ apply_route_props_hook = SimpleEventHook {
},
execute = function (event)
local device = event:get_subject ()
local selected_routes = event:get_data ("selected-routes") or {}
local selected_routes = event:get_data ("selected-routes") or Properties()
local new_selected_routes = {}
local dev_info = devinfo:get_device_info (device)
assert (dev_info)
if next (selected_routes) == nil then
if selected_routes:get_count () == 0 then
log:info (device, "No routes selected to set on " .. dev_info.name)
return
end
@ -159,122 +159,125 @@ store_or_restore_routes_hook = AsyncEventHook {
},
steps = {
start = {
next = "evaluate",
next = "none",
execute = function (event, transition)
local source = event:get_source ()
local device = event:get_subject ()
-- Make sure the routes are always updated before evaluating them.
-- https://gitlab.freedesktop.org/pipewire/wireplumber/-/issues/762
local device = event:get_subject ()
device:enum_params ("EnumRoute", function (_, e)
device:enum_params ("EnumRoute", function (enum_route_it, e)
local selected_routes = {}
local push_select_routes = false
-- check for error
if e then
transition:return_error ("failed to enum routes: "
.. tostring (e));
else
return
end
-- Make sure the device is still valid
if (device:get_active_features() & Feature.Proxy.BOUND) == 0 then
transition:advance ()
end
end)
end
},
evaluate = {
next = "none",
execute = function (event, transition)
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
return
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
local dev_info = devinfo:get_device_info (device)
if not dev_info then
transition:advance ()
return
end
-- update properties
route_info.prev_active = route_info.active
route_info.active = false
route_info.save = false
local new_route_infos = {}
-- store
new_route_infos [route.index] = route_info
-- look at all the routes and update/reset cached information
for p in enum_route_it:iterate() do
-- parse pod
local route = cutils.parseParam (p, "EnumRoute")
if not route then
goto skip_enum_route
end
::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 route_infos with new prev_active, active and save changes
dev_info.route_infos = new_route_infos
new_route_infos = nil
-- update properties
route_info.prev_active = route_info.active
route_info.active = false
route_info.save = false
-- 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
-- store
new_route_infos [route.index] = route_info
::skip_enum_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_infos with new prev_active, active and save changes
dev_info.route_infos = new_route_infos
new_route_infos = nil
-- update route_info state
route_info.active = true
route_info.save = route.save
-- 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
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
-- 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
selected_routes [tostring (route.device)] =
Json.Object { index = route_info.index }:to_string ()
push_select_routes = true
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
elseif route.available ~= "no" and 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))
selected_routes [tostring (route.device)] =
Json.Object { index = route_info.index }:to_string ()
push_select_routes = true
saveRouteProps (dev_info, route)
elseif route.available ~= "no" and 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
::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
-- 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
-- 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
transition:advance ()
transition:advance ()
end)
end
}
}

View file

@ -33,7 +33,7 @@ function module.get_device_info (self, device)
if not dev_info then
local device_name = device_properties ["device.name"]
if not device_name then
Log.critical (device, "invalid device.name")
Log.warning (device, "invalid device.name")
return nil
end

View file

@ -9,7 +9,7 @@ Hooks
The hooks in this section are organized in 3 sub-categories. The first category
includes hooks that are triggered by changes in the graph. Some of them are tasked
to schedule a "rescan-for-linking" event, which is the lowest priority event and
to schedule a "rescan-for-linking" event, which is the lowest priority linking event and
its purpose is to scan through all the linkable session items and link them
to a particular target. The "rescan-for-linking" event is always scheduled to run
once for all the graph changes in a cycle. This is achieved by flagging the event

View file

@ -13,7 +13,8 @@ SimpleEventHook {
name = "linking/find-default-target",
after = { "linking/find-defined-target",
"linking/find-filter-target",
"linking/find-media-role-target" },
"linking/find-media-role-target",
"linking/find-media-role-sink-target" },
before = "linking/prepare-link",
interests = {
EventInterest {

View file

@ -0,0 +1,81 @@
-- WirePlumber
--
-- Copyright © 2025 Phosh.mobi e.V.
--
-- SPDX-License-Identifier: MIT
--
-- Pick up a preferred target node for the output stream of role-based loopbacks
lutils = require ("linking-utils")
cutils = require ("common-utils")
log = Log.open_topic ("s-linking")
SimpleEventHook {
name = "linking/find-media-role-sink-target",
after = { "linking/find-defined-target",
"linking/find-media-role-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 node_name = si_props["node.name"]
local target_direction = cutils.getTargetDirection (si_props)
local media_class = si_props["media.class"]
local link_group = si_props["node.link-group"]
local is_virtual = si_props["node.virtual"]
log:info (si, string.format ("Lookup for '%s' (%s) / '%s' / '%s'",
node_name, tostring (si_props ["node.id"]), media_class, link_group))
--- bypass the hook if the target is already set or there's no link group
if target or media_class ~= "Stream/Output/Audio" or not is_virtual or link_group == nil then
return
end
--- We link the output node but the relevant properties are on the input node
--- of the link group
local input_node = om:lookup {
type = "SiLinkable",
Constraint { "media.class", "=", "Audio/Sink" },
Constraint { "node.link-group", "=", link_group },
}
if input_node == nil then
log:warning (si, string.format("No input node for %s found", link_group))
return
end
local target_name = input_node.properties["policy.role-based.preferred-target"]
--- no preferred target
if target_name == nil then
return
end
local si_target = om:lookup {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "node.name", "=", target_name },
}
if si_target == nil then
si_target = om:lookup {
type = "SiLinkable",
Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
Constraint { "node.nick", "=", target_name },
}
end
if si_target then
log:info (si,
string.format ("... role based sink target picked: %s (%s)",
tostring (si_target.properties ["node.name"]),
tostring (si_target.properties ["node.id"])))
event:set_data ("target", si_target)
end
end
}:register ()

View file

@ -26,7 +26,7 @@ function checkFilter (si, om, handle_nonstreams)
-- always return true if this is not a filter
local node = si:get_associated_proxy ("node")
local link_group = node.properties["node.link-group"]
local link_group = node:get_property ("node.link-group")
if link_group == nil then
return true
end
@ -43,36 +43,34 @@ function checkFilter (si, om, handle_nonstreams)
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
if si:get_property ("item.node.type") ~= "stream" and
not handle_nonstreams then
return false
end
-- check filters
if not checkFilter (si, om, handle_nonstreams) then
return false, si_props
return false
end
return true, si_props
return true
end
function unhandleLinkable (si, om)
local si_id = si.id
local valid, si_props = checkLinkable (si, om, true)
if not valid then
if not checkLinkable (si, om, true) then
return
end
local si_id = si.id
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"])
local silink_props = silink.properties
local out_id = silink_props:get_int ("out.item.id")
local in_id = silink_props:get_int ("in.item.id")
if out_id == si_id or in_id == si_id then
local in_flags = lutils:get_flags (in_id)
@ -84,7 +82,7 @@ function unhandleLinkable (si, om)
out_flags.peer_id = nil
end
if cutils.parseBool (silink.properties["is.role.policy.link"]) then
if silink_props:get_boolean ("is.role.policy.link") then
lutils.clearPriorityMediaRoleLink(silink)
end
@ -113,17 +111,67 @@ SimpleEventHook {
end
}:register ()
-- Handle newly added linkable immediately without waiting for full rescan
-- Only for simple cases where we know it won't affect other parts of the graph
SimpleEventHook {
name = "linking/linkable-added-immediate",
before = "linking/rescan-trigger",
interests = {
EventInterest {
Constraint { "event.type", "=", "session-item-added" },
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")
if not checkLinkable (si, om, false) then
return
end
-- Don't handle immediately if this is a smart filter that could affect other nodes
local node = si:get_associated_proxy ("node")
local link_group = node:get_property ("node.link-group")
if link_group then
local direction = cutils.getTargetDirection (si.properties)
if futils.is_filter_smart (direction, link_group) then
-- Smart filters need full rescan to handle cascading effects
return
end
end
-- Only handle if autoconnect is enabled
local autoconnect = si:get_property ("node.autoconnect")
if autoconnect ~= "true" then
return
end
-- Check if this is a simple stream (most common case)
-- Don't handle device nodes or special nodes that might become default targets
if si:get_property ("item.node.type") ~= "stream" then
return
end
-- Push select-target event immediately for simple stream case
source:call ("push-event", "select-target", si, nil)
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
if not checkLinkable (si, om) then
goto skip_linkable
end
-- Get properties
local si_props = si.properties
-- check if we need to link this node at all
local autoconnect = cutils.parseBool (si_props ["node.autoconnect"])
local autoconnect = si_props:get_boolean ("node.autoconnect")
if not autoconnect then
log:debug (si, tostring (si_props ["node.name"]) .. " does not need to be autoconnected")
goto skip_linkable
@ -155,7 +203,7 @@ SimpleEventHook {
Constraint { "node.link-group", "+" },
} do
local node = si:get_associated_proxy ("node")
local link_group = node.properties["node.link-group"]
local link_group = node:get_property ("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
@ -235,7 +283,6 @@ SimpleEventHook {
},
execute = function (event)
local si = event:get_subject ()
local si_props = si.properties
local source = event:get_source ()
-- clear timeout source, if any

View file

@ -297,9 +297,9 @@ function createNode(parent, id, obj_type, factory, properties)
properties["node.description"] = desc:gsub("(:)", " ")
end
-- add api.alsa.card.* properties for rule matching purposes
-- add api.alsa.card.* and alsa.* properties for rule matching purposes
for k, v in pairs(dev_props) do
if k:find("^api%.alsa%.card%..*") then
if k:find("^api%.alsa%.card%..*") or k:find("^alsa%..*") then
properties[k] = v
end
end
@ -494,6 +494,11 @@ function prepareDevice(parent, id, obj_type, factory, properties)
factory = "api.alsa.acp.device"
end
-- use HDMI channel detection if enabled in settings
if Settings.get_boolean ("monitor.alsa.autodetect-hdmi-channels") then
properties["api.acp.use-eld-channels"] = true
end
-- use device reservation, if available
if rd_plugin and properties["api.alsa.card"] then
local rd_name = "Audio" .. properties["api.alsa.card"]

View file

@ -78,7 +78,7 @@ end
function createMonitor()
local monitor_props = {}
for k, v in pairs(config.properties or {}) do
for k, v in pairs(config.properties or Properties()) do
monitor_props[k] = v
end

View file

@ -7,7 +7,9 @@
COMBINE_OFFSET = 64
LOOPBACK_SOURCE_ID = 128
LOOPBACK_SINK_ID = 129
DEVICE_SOURCE_ID = 0
DEVICE_SINK_ID = 1
cutils = require ("common-utils")
log = Log.open_topic ("s-monitors")
@ -23,6 +25,8 @@ config.properties["api.bluez5.connection-info"] = true
-- Properties used for previously creating a SCO source node. key: SPA device id
sco_source_node_properties = {}
-- Properties used for previously creating a SCO or A2DP sink node. key: SPA device id
sco_a2dp_sink_node_properties = {}
devices_om = ObjectManager {
Interest {
@ -271,6 +275,16 @@ function createNode(parent, id, type, factory, properties)
name_prefix = name_prefix .. "_internal"
end
-- hide the sink node because we use the loopback sink instead
if parent:get_managed_object (LOOPBACK_SINK_ID) ~= nil and
(factory == "api.bluez5.sco.sink" or
factory == "api.bluez5.a2dp.sink") then
properties["bluez5.sink-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 = name_prefix .. "." ..
(properties["api.bluez5.address"] or dev_props["device.name"]) .. "." ..
@ -304,9 +318,12 @@ function createNode(parent, id, type, factory, properties)
parent:set_managed_pending(id)
else
log:info("Create node: " .. properties["node.name"] .. ": " .. factory .. " " .. tostring (id))
properties["bluez5.loopback"] = false
if factory == "api.bluez5.sco.source" then
properties["bluez5.loopback"] = false
sco_source_node_properties[parent_spa_id] = properties
elseif factory == "api.bluez5.sco.sink" or factory == "api.bluez5.a2dp.sink" then
properties["bluez5.sink-loopback"] = false
sco_a2dp_sink_node_properties[parent_spa_id] = properties
end
local node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
@ -318,14 +335,20 @@ function removeNode(parent, id)
local dev_props = parent.properties
local parent_spa_id = tonumber(dev_props["spa.object.id"])
local src_properties = sco_source_node_properties[parent_spa_id]
local sink_properties = sco_a2dp_sink_node_properties[parent_spa_id]
log:debug("Remove node: " .. tostring (id))
if src_properties ~= nil and id == tonumber(src_properties["spa.object.id"]) then
log:debug("Clear old SCO properties")
log:debug("Clear old SCO source properties")
sco_source_node_properties[parent_spa_id] = nil
end
if sink_properties ~= nil and id == tonumber(sink_properties["spa.object.id"]) then
log:debug("Clear old SCO-A2DP sink properties")
sco_a2dp_sink_node_properties[parent_spa_id] = nil
end
-- Clear also the device set module, if any
parent:store_managed_object(id + COMBINE_OFFSET, nil)
end
@ -400,6 +423,7 @@ end
function removeDevice(parent, id)
sco_source_node_properties[id] = nil
sco_a2dp_sink_node_properties[id] = nil
end
function createMonitor()
@ -442,7 +466,6 @@ function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id)
["device.id"] = dev_id,
["card.profile.device"] = DEVICE_SOURCE_ID,
["device.routes"] = "1",
["priority.driver"] = 2010,
["priority.session"] = 2010,
["bluez5.loopback"] = true,
["filter.smart"] = true,
@ -456,6 +479,41 @@ function CreateDeviceLoopbackSource (dev_name, dec_desc, dev_id)
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end
function CreateDeviceLoopbackSink (dev_name, dec_desc, dev_id)
local args = Json.Object {
["capture.props"] = Json.Object {
["node.name"] = string.format ("bluez_output.%s", dev_name),
["node.description"] = string.format ("%s", dec_desc),
["node.virtual"] = false,
["audio.position"] = "[FL, FR]",
["media.class"] = "Audio/Sink",
["device.id"] = dev_id,
["card.profile.device"] = DEVICE_SINK_ID,
["device.routes"] = "1",
["priority.session"] = 2010,
["bluez5.sink-loopback"] = true,
["filter.smart"] = true,
["filter.smart.target"] = Json.Object {
["bluez5.sink-loopback-target"] = true,
["bluez5.sink-loopback"] = false,
["device.id"] = dev_id
}
},
["playback.props"] = Json.Object {
["node.name"] = string.format ("bluez_playback_internal.%s", dev_name),
["media.class"] = "Stream/Output/Audio/Internal",
["node.description"] =
string.format ("Bluetooth internal playback stream for %s", dec_desc),
["bluez5.sink-loopback"] = true,
["node.passive"] = true,
["node.dont-fallback"] = true,
["node.linger"] = true,
["state.restore-props"] = false,
}
}
return LocalModule("libpipewire-module-loopback", args:get_data(), {})
end
function checkProfiles (dev)
local device_id = dev["bound-id"]
local props = dev.properties
@ -488,19 +546,19 @@ function checkProfiles (dev)
return
end
-- Create the loopback device if never created before
local loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
if loopback == nil then
-- Create the source loopback device if never created before
local source_loopback = spa_device:get_managed_object (LOOPBACK_SOURCE_ID)
if source_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"
log:info("create SCO loopback node: " .. dev_name)
log:info("create SCO source loopback node: " .. dev_name)
-- 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)
source_loopback = CreateDeviceLoopbackSource (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SOURCE_ID, source_loopback)
-- recreate any sco source node
local properties = sco_source_node_properties[device_spa_id]
@ -521,6 +579,39 @@ function checkProfiles (dev)
end
end
end
local sink_loopback = spa_device:get_managed_object (LOOPBACK_SINK_ID)
if sink_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"
log:info("create SCO-A2DP sink loopback node: " .. dev_name)
-- sanitize description, replace ':' with ' '
dec_desc = dec_desc:gsub("(:)", " ")
sink_loopback = CreateDeviceLoopbackSink (dev_name, dec_desc, device_id)
spa_device:store_managed_object(LOOPBACK_SINK_ID, sink_loopback)
-- recreate any sco-a2dp sink node
local properties = sco_a2dp_sink_node_properties[device_spa_id]
if properties ~= nil then
local node_id = tonumber(properties["spa.object.id"])
local node = spa_device:get_managed_object (node_id)
if node ~= nil then
log:info("Recreate node: " .. properties["node.name"] .. ": " ..
properties["factory.name"] .. " " .. tostring (node_id))
spa_device:store_managed_object(node_id, nil)
properties["bluez5.sink-loopback-target"] = true
properties["api.bluez5.internal"] = true
node = LocalNode("adapter", properties)
node:activate(Feature.Proxy.BOUND)
spa_device:store_managed_object(node_id, node)
end
end
end
end
function onDeviceParamsChanged (dev, param_name)

View file

@ -157,7 +157,7 @@ SimpleEventHook {
-- Create group loopback module if it does not exist
local m = group_loopback_modules [direction][group]
if m == nil then
Log.warning ("Creating " .. direction .. " loopback for audio group " .. group ..
Log.info ("Creating " .. direction .. " loopback for audio group " .. group ..
(target_object and (" with target object " .. tostring (target_object)) or ""))
m = CreateStreamLoopback (stream_props, group, target_object, direction)
group_loopback_modules [direction][group] = m

View file

@ -16,6 +16,7 @@ items = {}
function configProperties (node)
local properties = node.properties
local media_class = properties ["media.class"] or ""
local factory_name = properties ["factory.name"] or ""
-- ensure a media.type is set
if not properties ["media.type"] then
@ -39,6 +40,9 @@ function configProperties (node)
Settings.get_boolean ("node.features.audio.monitor-ports")
properties ["item.features.control-port"] =
Settings.get_boolean ("node.features.audio.control-port")
properties ["item.features.mono"] =
(factory_name == "api.alsa.pcm.sink" or factory_name == "api.bluez5.a2dp.sink") and
Settings.get_boolean ("node.features.audio.mono")
properties ["node.id"] = node ["bound-id"]
-- set the default media.role, if configured
@ -152,3 +156,50 @@ SimpleEventHook {
end
}:register ()
function reconfigureAudioAdapters ()
local ids = {}
-- Get the Id of all session items that are audio adapters
for id, item in pairs(items) do
local si_props = item.properties
if si_props ["item.factory.name"] == "si-audio-adapter" then
table.insert (ids, id)
end
end
-- Re-configure all audio adapters
for _, id in pairs (ids) do
local item = items[id]
local node = item:get_associated_proxy ("node")
log:info (item, "Started re-configuring audio adapter")
-- Remove the session item so that it is unlinked
items[id] = nil
item:remove()
-- Configure the session item
if not item:configure (configProperties (node)) then
log:warning (item, "Could not re-configure audio adapter")
goto skip_item
end
-- Activate the session item so that it is linked again
items[id] = item
item:activate (Features.ALL, function (si, e)
if e then
log:warning (si, "Could not re-activate audio adapter")
else
log:info (si, "Successfully re-activated audio adapter")
si:register ()
end
end)
::skip_item::
end
end
Settings.subscribe ("node.features.audio.*", function ()
reconfigureAudioAdapters ()
end)

View file

@ -0,0 +1,53 @@
-- WirePlumber
--
-- Copyright © 2025 The WirePlumber project contributors
-- @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT
log = Log.open_topic("s-node")
config = {}
config.rules = Conf.get_section_as_json ("node.filter-graph.rules", Json.Array{})
function setNodeFilterGraphParams (node, graph_params)
local pod = Pod.Object {
"Spa:Pod:Object:Param:Props", "Props",
params = Pod.Struct (graph_params)
}
node:set_params("Props", pod)
end
SimpleEventHook {
name = "node/create-filter-graph",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "library.name", "=", "audioconvert/libspa-audioconvert", type = "pw" },
},
},
execute = function(event)
local node = event:get_subject()
JsonUtils.match_rules (config.rules, node.properties, function (action, value)
if action == "create-filter-graph" then
local graphs = value:parse (1)
local graph_params = {}
for idx, val in ipairs (graphs) do
local index = tonumber(idx) - 1
local key = "audioconvert.filter-graph." .. tostring (index)
log:info (node, "setting node filter graph param '" .. key .. "' to: " .. val)
table.insert(graph_params, key)
table.insert(graph_params, val)
end
setNodeFilterGraphParams (node, graph_params)
end
end)
end
}:register()

View file

@ -0,0 +1,94 @@
-- WirePlumber
--
-- Copyright © 2025 Phosh.mobi e.V.
-- @author Guido Günther <agx@sigxcpu.org>
--
-- SPDX-License-Identifier: MIT
--
-- Select the media role default volume
log = Log.open_topic("s-node")
local cutils = require ("common-utils")
function findHighestPriorityRoleNode (node_om)
local best_role = nil
local best_prio = 0
local default_role = Settings.get ("node.stream.default-media-role")
if default_role then
default_role = default_role:parse()
end
for ni in node_om:iterate {
type = "node",
Constraint { "media.class", "=", "Audio/Sink" },
Constraint { "node.name", "#", "input.loopback.sink.role.*" },
} do
local ni_props = ni.properties
local roles = ni_props["device.intended-roles"]
local node_name = ni_props ["node.name"]
local prio = tonumber(ni_props ["policy.role-based.priority"])
-- Use the node that handles the default_role as fallback
-- when no node is in running state
if best_role == nil and roles and default_role then
local roles_table = Json.Raw(roles):parse()
for i, v in ipairs (roles_table) do
if default_role == v then
best_role = node_name
best_prio = prio
break
end
end
end
if ni.state == "running" then
if prio > best_prio then
best_role = node_name
best_prio = prio
end
end
end
log:info (string.format ("Volume control is on : '%s', prio %d", best_role, best_prio))
local metadata = cutils.get_default_metadata_object ()
metadata:set (0, "current.role-based.volume.control", "Spa:String:JSON",
Json.Object { ["name"] = best_role }:to_string ())
end
SimpleEventHook {
name = "node/rescan-for-media-role-volume",
interests = {
EventInterest {
Constraint { "event.type", "=", "rescan-for-media-role-volume" }
},
},
execute = function (event)
local source = event:get_source ()
local node_om = source:call ("get-object-manager", "node")
findHighestPriorityRoleNode (node_om)
end
}:register ()
-- Track best volume control for media role based priorities
SimpleEventHook {
name = "node/find-media-role-default-volume",
interests = {
EventInterest {
Constraint { "event.type", "=", "node-added" },
Constraint { "media.class", "=", "Audio/Sink" },
Constraint { "node.name", "#", "input.loopback.sink.role.*" }
},
EventInterest {
Constraint { "event.type", "=", "node-state-changed" },
Constraint { "media.class", "=", "Audio/Sink" },
Constraint { "node.name", "#", "input.loopback.sink.role.*" }
},
},
execute = function (event)
local source = event:get_source ()
local node_om = source:call ("get-object-manager", "node")
source:call ("schedule-rescan", "media-role-volume")
end
}:register ()

View file

@ -4,6 +4,11 @@ executable('wpctl',
dependencies : [gobject_dep, gio_dep, wp_dep, pipewire_dep, libintl_dep],
)
install_data('shell-completion/wpctl.bash',
install_dir: get_option('datadir') / 'bash-completion/completions',
rename: 'wpctl'
)
install_data('shell-completion/wpctl.zsh',
install_dir: get_option('datadir') / 'zsh/site-functions',
rename: '_wpctl'

View file

@ -0,0 +1,30 @@
_wpctl_pw_defaults() {
local defaults="@DEFAULT_SINK@ @DEFAULT_AUDIO_SINK@ @DEFAULT_SOURCE@
@DEFAULT_AUDIO_SOURCE@ @DEFAULT_VIDEO_SOURCE@"
COMPREPLY+=($(compgen -W "$defaults" -- "$cur"))
}
_wpctl() {
local cur prev words cword
local commands="status get-volume inspect set-default set-volume set-mute
set-profile set-route clear-default settings set-log-level"
_init_completion -n = || return
if [[ ${#COMP_WORDS[@]} -eq 2 ]]; then
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return
fi
case $prev in
get-volume | inspect | set-volume | set-mute | set-profile | set-route)
_wpctl_pw_defaults
;;
clear-default)
COMPREPLY+=($(compgen -W "0 1 2" -- "$cur"))
;;
esac
}
complete -F _wpctl wpctl

View file

@ -174,7 +174,6 @@ test_node (TestFixture *f, gconstpointer data)
props = wp_pipewire_object_get_properties (proxy);
g_assert_nonnull (props);
g_assert_true (wp_properties_peek_dict (props) == info->props);
id = wp_properties_get (props, PW_KEY_OBJECT_ID);
g_assert_nonnull (id);
g_assert_cmpint (info->id, ==, atoi(id));

View file

@ -64,3 +64,9 @@ test(
args: ['lua-api-tests', 'event-hooks.lua'],
env: common_env,
)
test(
'test-lua-properties',
script_tester,
args: ['lua-api-tests', 'properties.lua'],
env: common_env,
)

View file

@ -177,6 +177,23 @@ assert (#val == 0)
assert (val.key1 == nil)
assert (json:get_data() == "{}")
json = Json.Object (
Properties {
["key0"] = nil,
["key1"] = false,
["key2"] = 64,
["key3"] = 2.71,
["key4"] = "string",
}
)
assert (json:is_object())
val = json:parse ()
assert (val.key0 == nil)
assert (val.key1 == "false")
assert (val.key2 == "64")
assert (tonumber (val.key3) > 2.70 and tonumber (val.key3) < 2.72)
assert (val.key4 == "string")
-- Raw
json = Json.Raw ("[\"foo\", \"bar\"]")
assert (json:is_array())

View file

@ -0,0 +1,93 @@
-- create empty properties
props = Properties ()
-- set nil
props["key-nil"] = nil
assert (props["key-nil"] == nil)
-- set bool
props["key-bool"] = false
assert (props["key-bool"] == "false")
assert (props:get_boolean ("key-bool") == false)
props["key-bool"] = true
assert (props["key-bool"] == "true")
assert (props:get_boolean ("key-bool") == true)
-- set int
props["key-int"] = 4
assert (props["key-int"] == "4")
assert (props:get_int ("key-int") == 4)
-- set float
props["key-float"] = 3.14
val = props:get_float ("key-float")
assert (val > 3.13 and val < 3.15)
-- set string
props["key-string"] = "value"
assert (props["key-string"] == "value")
assert (props:get_boolean ("key-string") == false)
assert (props:get_int ("key-string") == nil)
assert (props:get_float ("key-string") == nil)
-- copy
copy = props:copy ()
assert (copy["key-nil"] == nil)
assert (copy:get_boolean ("key-bool") == true)
assert (copy:get_int ("key-int") == 4)
val = copy:get_float ("key-float")
assert (val > 3.13 and val < 3.15)
assert (copy["key-string"] == "value")
-- remove int property
props["key-int"] = nil
assert (props["key-int"] == nil)
assert (copy:get_int ("key-int") == 4)
-- create properties from table
props = Properties {
["key0"] = nil,
["key1"] = false,
["key2"] = 64,
["key3"] = 2.71,
["key4"] = "string",
}
assert (props["key0"] == nil)
assert (props:get_boolean ("key1") == false)
assert (props:get_int ("key2") == 64)
val = props:get_float ("key3")
assert (val > 2.70 and val < 2.72)
assert (props["key4"] == "string")
-- count
assert (props:get_count () == 4)
-- parse
parsed = props:parse ()
assert (parsed["key0"] == nil)
assert (parsed["key1"] == "false")
assert (tonumber (parsed["key2"]) == 64)
val = tonumber (parsed["key3"])
assert (val > 2.70 and val < 2.72)
assert (parsed["key4"] == "string")
-- pairs
values = {}
for k, v in pairs (props) do
values [k] = v
end
assert (values["key0"] == nil)
assert (values["key1"] == "false")
assert (tonumber (values["key2"]) == 64)
val = tonumber (values["key3"])
assert (val > 2.70 and val < 2.72)
assert (values["key4"] == "string")
-- Make sure the reference changes are also updated
local properties = Properties ()
properties["key"] = "value"
assert (properties["key"] == "value")
local properties2 = properties
properties2["key"] = "another-value"
assert (properties2["key"] == "another-value")
assert (properties["key"] == "another-value")