diff --git a/doc/mpd.conf.5.rst b/doc/mpd.conf.5.rst
index 5508163674..340b4b2c88 100644
--- a/doc/mpd.conf.5.rst
+++ b/doc/mpd.conf.5.rst
@@ -287,8 +287,8 @@ Optional Audio Output Settings
:default: ``hardware`` (if supported) or ``none``
Specifies which mixer should be used for this audio output: the hardware
- mixer (available for ALSA, OSS and PulseAudio), the software mixer or no
- mixer (``none``).
+ mixer (available for ALSA, OSS, PulseAudio and PipeWire), the software
+ mixer or no mixer (``none``).
Files
-----
diff --git a/doc/plugins.rst b/doc/plugins.rst
index 528815186b..8c5774848f 100644
--- a/doc/plugins.rst
+++ b/doc/plugins.rst
@@ -1192,6 +1192,8 @@ The pipe plugin starts a program and writes raw PCM data into its standard input
* - **command CMD**
- This command is invoked with the shell.
+.. _pipewire_plugin:
+
pipewire
--------
@@ -1216,6 +1218,30 @@ Connect to a `PipeWire `_ server. Requires
The default is ``yes``.
* - **dsd yes|no**
- Enable DSD playback. This requires PipeWire 0.38.
+ * - **mixer_volume_curve cubic|linear**
+ - Only used together with :code:`mixer_type "hardware"`, see
+ below. Selects the volume curve applied when writing to the
+ target sink's volume. The default (``cubic``) matches the
+ curve WirePlumber and :program:`wpctl` use, so MPD volume
+ percentage matches what ``wpctl get-volume`` reports for the
+ same node. ``linear`` writes the percentage directly as a
+ linear PCM gain.
+
+By default, :code:`mixer_type "hardware"` (see :confval:`mixer_type`)
+has no special meaning for this plugin, and behaves
+identically to leaving :code:`mixer_type` unset: MPD applies a
+client-side software gain to its PipeWire stream
+(:code:`SPA_PROP_channelVolumes`).
+
+If :code:`mixer_type "hardware"` is written explicitly in this
+output's configuration block, a second, independent PipeWire
+connection is opened and drives the destination sink's volume
+directly. MPD's own stream is pinned to unity gain in this mode,
+so the sink's volume is the only gain stage in effect. Without
+a :code:`target` set, this follows whichever sink is currently
+the system default; with :code:`target` set, it follows that
+same node's volume, keeping playback routing and volume control
+pointed at one sink.
.. _pulse_plugin:
diff --git a/doc/user.rst b/doc/user.rst
index 49d106837c..ba17e9b1d6 100644
--- a/doc/user.rst
+++ b/doc/user.rst
@@ -521,11 +521,13 @@ The following table lists the audio_output options valid for all plugins:
* - **mixer_type hardware|software|null|none**
- Specifies which mixer should be used for this audio output: the
hardware mixer (available for ALSA :ref:`alsa_plugin`, OSS
- :ref:`oss_plugin` and PulseAudio :ref:`pulse_plugin`), the
- software mixer, the ":samp:`null`" mixer (allows setting the
- volume, but with no effect; this can be used as a trick to
- implement an external mixer, see :ref:`external_mixer`) or no mixer
- (:samp:`none`). By default, the hardware mixer is used for
+ :ref:`oss_plugin` and PulseAudio :ref:`pulse_plugin`; for
+ PipeWire :ref:`pipewire_plugin`, the hardware mixer is only used
+ if :code:`mixer_type "hardware"` is written explicitly -- see
+ pipewire plugin documentation), the software mixer, the ":samp:`null`"
+ mixer (allows setting the volume, but with no effect; this can be used
+ as a trick to implement an external mixer, see :ref:`external_mixer`)
+ or no mixer (:samp:`none`). By default, the hardware mixer is used for
devices which support it, and none for the others.
* - **replay_gain_handler software|mixer|none**
- Specifies how :ref:`replay_gain` is applied. The default is
diff --git a/src/mixer/plugins/PwSinkMixerPlugin.cxx b/src/mixer/plugins/PwSinkMixerPlugin.cxx
new file mode 100644
index 0000000000..7ad28e0b82
--- /dev/null
+++ b/src/mixer/plugins/PwSinkMixerPlugin.cxx
@@ -0,0 +1,1029 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright The Music Player Daemon Project
+
+/*
+ * "pwsink" mixer plugin for the PipeWire output.
+ *
+ * Rationale
+ * ---------
+ * MPD's "hardware" mixer plugin only supports outputs of type "alsa",
+ * "oss", and "pulse" -- it is not wired up for type "pipewire" outputs, so
+ * for a "pipewire" output, "hardware" normally just falls back to the
+ * output's own registered mixer plugin (PipeWireMixerPlugin.cxx), which
+ * sets SPA_PROP_channelVolumes on MPD's own pw_stream via
+ * pipewire_output_set_volume() -- a per-client software gain stage applied
+ * before the signal ever reaches the sink. It does not move any hardware
+ * mixer control, and is not shared with other PipeWire clients
+ * (shairport-sync, spotifyd, Bluetooth, etc).
+ *
+ * This plugin is substituted in for that default whenever an "ao" block
+ * with `type "pipewire"` *explicitly* sets `mixer_type "hardware"` (see
+ * the pipewire-specific special case in audio_output_load_mixer() in
+ * output/Init.cxx). It opens its own native PipeWire client connection
+ * (independent of MPD's own output stream) and drives a sink node's
+ * volume directly via the PipeWire protocol:
+ *
+ * - By default it follows the system's current default sink.
+ * - If `target ""` is set in the same block (the same config
+ * key the "pipewire" output plugin itself already uses to pin
+ * playback to a specific node via PW_KEY_TARGET_OBJECT), this plugin
+ * follows that node's volume instead of the system default -- so
+ * audio routing and volume control stay pointed at the same node
+ * without needing two separate settings.
+ *
+ * WirePlumber then applies that volume the same way it would for any
+ * other client -- via the ALSA hardware mixer control when the sink's
+ * Route reports one (route.hw-volume = true), or via PipeWire's own
+ * software volume otherwise.
+ *
+ * MPD's own pw_stream is pinned to unity gain (1.0) via
+ * pipewire_output_set_volume(), so the sink-level volume is the only gain
+ * stage in effect.
+ *
+ * If `mixer_type` is left unset entirely (or set to anything other than
+ * "hardware", e.g. "software"/"null"/"none"), this plugin is never used --
+ * an "ao" block with `type "pipewire"` and no `mixer_type` behaves exactly
+ * as before, i.e. plain MPD stream volume via PipeWireMixerPlugin.cxx.
+ *
+ * How this plugin talks to PipeWire
+ * ----------------------------------
+ * This deliberately does *not* reuse PipeWireOutput's own pw_core: that
+ * connection belongs to the realtime-ish audio thread and is scoped to
+ * MPD's own stream. Instead PwSinkMixer opens a second, independent
+ * client connection of its own (its own pw_thread_loop/pw_context/
+ * pw_core).
+ *
+ * 1. Connect and fetch the registry.
+ * 2. Find the "default" pw_metadata object (there is exactly one) and
+ * bind it; its "default.audio.sink" property is a JSON blob
+ * ({"name":""}) naming the currently-effective default
+ * sink. This is the same object/property WirePlumber itself updates
+ * and that `@DEFAULT_AUDIO_SINK@` resolves against.
+ * 3. Match that name against Audio/Sink nodes seen in the registry,
+ * bind the matching node, and request its current SPA_PROP_Props
+ * (channelVolumes) via pw_node_enum_params().
+ * 4. SetVolume() writes a new SPA_PROP_channelVolumes via
+ * pw_node_set_param(); GetVolume() reads back the last value we
+ * received via the node's "param" event.
+ *
+ * All of this is driven by repeated pw_core_sync()+pw_thread_loop_wait()
+ * round-trips issued from Open() (and MaybeReconnect()).
+ *
+ * An explicit "target" config option (a literal node.name -- the same key
+ * the "pipewire" output plugin uses for its own audio routing) skips the
+ * metadata/default resolution entirely and pins this plugin to that node
+ * too, for setups that don't want to follow the system default sink.
+ *
+ * WirePlumber/wpctl display and set volume on a cubic scale rather than
+ * linear PCM gain (this is what gives `wpctl set-volume 0.5` roughly
+ * "half loudness" rather than "half amplitude" perceptually); this is
+ * replicated here so a given MPD volume percentage matches what
+ * `wpctl get-volume` would show for the same node. Set "mixer_volume_curve"
+ * to "linear" to bypass that and write the percentage straight into
+ * channelVolumes.
+ *
+ * Limitations
+ * -----------
+ * - Only one Audio/Sink node with a matching name is tracked; if several
+ * devices somehow expose the same node.name (shouldn't normally
+ * happen) the first one seen wins.
+ * - If the default sink changes while MPD is running (device unplugged,
+ * user switches output in pavucontrol/wpctl), this plugin does not
+ * currently re-resolve automatically; Close()+Open() (or a MPD
+ * restart / "outputs" toggle) picks up the new default.
+ * - If the PipeWire core connection reports an error (e.g. the daemon
+ * is restarted while MPD is running), any in-progress round trip is
+ * unblocked and the operation fails immediately rather than hanging
+ * until WaitUntilReady()'s retry budget is exhausted; see
+ * OnCoreError()/core_error below.
+ */
+
+#include "PwSinkMixerPlugin.hxx"
+#include "mixer/Mixer.hxx"
+#include "config/Block.hxx"
+#include "lib/fmt/RuntimeError.hxx"
+#include "output/plugins/PipeWireOutputPlugin.hxx"
+#include "Log.hxx"
+#include "util/Domain.hxx"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include