From d2057b4449173401baf936f4b1251ce6446ec22a Mon Sep 17 00:00:00 2001 From: Gearhead Date: Sat, 26 Sep 2026 12:14:34 -0500 Subject: [PATCH] Addresses the /mute after boot' bug I have been chasing with this mixer plugin. this mixer plugin will allow the mpd volume to control the PipeWire sink volume directly while pinning the PipeWire stream volume to 1.0 --- NEWS | 1 + doc/plugins.rst | 3 + src/mixer/plugins/PwSinkMixerPlugin.cxx | 953 ++++++++++++++++++++ src/mixer/plugins/PwSinkMixerPlugin.hxx | 19 + src/mixer/plugins/meson.build | 6 +- src/output/Filtered.hxx | 3 +- src/output/Init.cxx | 24 +- src/output/OutputPlugin.hxx | 10 + src/output/plugins/PipeWireOutputPlugin.cxx | 43 + src/output/plugins/PipeWireOutputPlugin.hxx | 9 + 10 files changed, 1062 insertions(+), 9 deletions(-) create mode 100644 src/mixer/plugins/PwSinkMixerPlugin.cxx create mode 100644 src/mixer/plugins/PwSinkMixerPlugin.hxx diff --git a/NEWS b/NEWS index e4a6668b94..da610a6ac7 100644 --- a/NEWS +++ b/NEWS @@ -19,6 +19,7 @@ ver 0.25 (not yet released) - wasapi: detect device changes * mixer - fix mixer idle events on non-default partitions + - add pipewire config sink_volume "yes" * tags - new tag "DiscSubtitle" * player diff --git a/doc/plugins.rst b/doc/plugins.rst index 528815186b..cc52ad82f5 100644 --- a/doc/plugins.rst +++ b/doc/plugins.rst @@ -1216,6 +1216,9 @@ Connect to a `PipeWire `_ server. Requires The default is ``yes``. * - **dsd yes|no** - Enable DSD playback. This requires PipeWire 0.38. + * - **sink_volume yes|no** + - Control the sink's volume instead of the stream's per-client + gain. The default is ``no``. .. _pulse_plugin: diff --git a/src/mixer/plugins/PwSinkMixerPlugin.cxx b/src/mixer/plugins/PwSinkMixerPlugin.cxx new file mode 100644 index 0000000000..036bf7a710 --- /dev/null +++ b/src/mixer/plugins/PwSinkMixerPlugin.cxx @@ -0,0 +1,953 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +/* + * "pwsink" mixer plugin for the PipeWire output. + * + * By default, the "pipewire" ao's mixer controls MPD's own per-client + * stream volume. This plugin instead controls the sink volume itself, + * via a second, independent PipeWire connection. + * + * Volume is scaled cubically to match `wpctl` (set "mixer_volume_curve" + * to "linear" to disable). + */ + +#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 "lib/fmt/ExceptionFormatter.hxx" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr uint32_t kMaxChannels = 32; + +enum class VolumeCurve { + CUBIC, + LINEAR, +}; + +inline float +PercentToVolume(unsigned percent, VolumeCurve curve) noexcept +{ + float linear = std::clamp(percent, 0u, 100u) / 100.0f; + return curve == VolumeCurve::CUBIC ? linear * linear * linear : linear; +} + +inline unsigned +VolumeToPercent(float volume, VolumeCurve curve) noexcept +{ + if (volume < 0.0f) + volume = 0.0f; + float linear = curve == VolumeCurve::CUBIC ? std::cbrt(volume) : volume; + return static_cast(std::lround(linear * 100.0f)); +} + +/** + * Extract the "name" string field from a `{"name":"...", ...}` JSON blob + * as published in the "default.audio.sink" / "default.configured.audio.sink" + * metadata properties. Returns an empty string if not found or malformed. + */ +std::string +ParseDefaultNodeName(const char *json) noexcept +{ + if (json == nullptr) + return {}; + + struct spa_json outer; + spa_json_init(&outer, json, std::strlen(json)); + + struct spa_json obj; + if (spa_json_enter_object(&outer, &obj) <= 0) + return {}; + + char key[256]; + while (spa_json_get_string(&obj, key, sizeof(key)) > 0) { + if (std::strcmp(key, "name") == 0) { + char value[512]; + if (spa_json_get_string(&obj, value, sizeof(value)) > 0) + return value; + return {}; + } + + /* skip whatever value belongs to this key -- we don't care + about anything but "name" */ + if (spa_json_next(&obj, nullptr) <= 0) + break; + } + + return {}; +} + +constexpr Domain pw_sink_mixer_domain("pw_sink_mixer"); + +} // namespace + +/** + * RAII helper for pw_thread_loop_lock()/unlock(), so an exception thrown + * partway through setup (or from a Fmt/RuntimeError below) can never + * leave the loop locked for the next call. + */ +class PwThreadLoopLock { + struct pw_thread_loop *const loop; + +public: + explicit PwThreadLoopLock(struct pw_thread_loop *_loop) noexcept + :loop(_loop) + { + pw_thread_loop_lock(loop); + } + + ~PwThreadLoopLock() noexcept { + pw_thread_loop_unlock(loop); + } + + PwThreadLoopLock(const PwThreadLoopLock &) = delete; + PwThreadLoopLock &operator=(const PwThreadLoopLock &) = delete; +}; + +class PwSinkMixer final : public Mixer { + /** + * The associated PipeWire output. Pins its pw_stream to unity + * gain, so the sink-level volume is the only gain stage in effect. + */ + PipeWireOutput &output; + + VolumeCurve curve = VolumeCurve::CUBIC; + + /** + * Explicit "target" node.name override. Empty means use the + * default sink. + */ + std::string configured_target; + + /* --- This plugin's own, independent PipeWire connection --- */ + + struct pw_thread_loop *loop = nullptr; + struct pw_context *context = nullptr; + struct pw_core *core = nullptr; + struct spa_hook core_listener {}; + int last_sync_seq = -1; + + int core_error = 0; + + struct pw_registry *registry = nullptr; + struct spa_hook registry_listener {}; + + struct pw_proxy *metadata_proxy = nullptr; + struct spa_hook metadata_listener {}; + + struct pw_proxy *sink_proxy = nullptr; + struct spa_hook sink_node_listener {}; + uint32_t sink_global_id = SPA_ID_INVALID; + + struct pw_proxy *device_proxy = nullptr; + struct spa_hook device_listener {}; + uint32_t device_global_id = SPA_ID_INVALID; + + int32_t route_index = -1; + int32_t route_device = -1; + uint32_t route_direction = 0; + bool have_route = false; + + /** false if the resolved sink has no device.id at all (e.g. a + virtual/software-only sink) -- in that case there is no Route to + wait for, and Node-level Props is the only volume path */ + bool has_hw_route = true; + + /** {node.name, device.id} of every Audio/Sink seen so far, keyed by + global id */ + struct SinkInfo { + std::string name; + uint32_t device_id = SPA_ID_INVALID; + }; + std::map known_sinks; + + std::string target_name; + + float current_volumes[kMaxChannels] {}; + uint32_t n_current_volumes = 0; + bool have_current_volume = false; + + /** volumes as read back from the Device's active Route */ + float route_volumes[kMaxChannels] {}; + uint32_t n_route_volumes = 0; + + /** + * Cached value to return from GetVolume() right after a successful + * SetVolume() */ + int cached_volume = -1; + +public: + PwSinkMixer(PipeWireOutput &_output, + MixerListener &_listener) noexcept + :Mixer(pw_sink_mixer_plugin, _listener), + output(_output) + { + pipewire_output_set_pw_sink_mixer(output, *this); + } + + ~PwSinkMixer() noexcept override { + pipewire_output_clear_pw_sink_mixer(output, *this); + } + + /** + * Re-run the volume resync on demand. Called from + * PipeWireOutput::ParamChanged(), which runs on the output's own + * thread-loop thread -- a different thread than this mixer's + * `loop`, so the normal locked/round-tripped ForceVolumeResync() + * applies here. + */ + void RequestResync() { + if (loop == nullptr) + return; + ForceVolumeResync(); + } + + void Configure(const ConfigBlock &block) { + configured_target = block.GetBlockValue("target", ""); + if (configured_target == "default") + configured_target.clear(); + + const char *curve_name = block.GetBlockValue("mixer_volume_curve", + "cubic"); + if (std::strcmp(curve_name, "linear") == 0) + curve = VolumeCurve::LINEAR; + else if (std::strcmp(curve_name, "cubic") == 0) + curve = VolumeCurve::CUBIC; + else + throw FmtRuntimeError("invalid mixer_volume_curve: {}", + curve_name); + } + + /* virtual methods from class Mixer */ + void Open() override; + void Close() noexcept override; + + int GetVolume() override; + void SetVolume(unsigned volume) override; + +private: + void Connect(); + void Disconnect() noexcept; + + bool Roundtrip() noexcept; + + bool WaitUntilReady() noexcept; + + bool IsReady() const noexcept { + if (sink_proxy == nullptr || !have_current_volume) + return false; + if (!has_hw_route) + return true; + return device_proxy != nullptr && have_route; + } + + void MaybeBindSinkNode() noexcept; + void ApplyCurrentVolume(); + void ApplyRouteVolume(); + void ForceVolumeResync(); + +public: + static void OnCoreDone(void *data, uint32_t id, int seq) noexcept; + static void OnCoreError(void *data, uint32_t id, int seq, int res, + const char *message) noexcept; + static void OnGlobal(void *data, uint32_t id, uint32_t permissions, + const char *type, uint32_t version, + const struct spa_dict *props) noexcept; + static void OnGlobalRemove(void *data, uint32_t id) noexcept; + static int OnMetadataProperty(void *data, uint32_t subject, + const char *key, const char *type, + const char *value) noexcept; + static void OnSinkNodeParam(void *data, int seq, uint32_t id, + uint32_t index, uint32_t next, + const struct spa_pod *param) noexcept; + static void OnDeviceParam(void *data, int seq, uint32_t id, + uint32_t index, uint32_t next, + const struct spa_pod *param) noexcept; +}; + +static constexpr struct pw_core_events core_events = { + .version = PW_VERSION_CORE_EVENTS, + .done = PwSinkMixer::OnCoreDone, + .error = PwSinkMixer::OnCoreError, +}; + +static constexpr struct pw_registry_events registry_events = { + .version = PW_VERSION_REGISTRY_EVENTS, + .global = PwSinkMixer::OnGlobal, + .global_remove = PwSinkMixer::OnGlobalRemove, +}; + +static constexpr struct pw_metadata_events metadata_events = { + .version = PW_VERSION_METADATA_EVENTS, + .property = PwSinkMixer::OnMetadataProperty, +}; + +static constexpr struct pw_node_events sink_node_events = { + .version = PW_VERSION_NODE_EVENTS, + .param = PwSinkMixer::OnSinkNodeParam, +}; + +static constexpr struct pw_device_events device_events = { + .version = PW_VERSION_DEVICE_EVENTS, + .param = PwSinkMixer::OnDeviceParam, +}; + +void +PwSinkMixer::OnCoreDone(void *data, uint32_t id, int seq) noexcept +{ + auto &self = *static_cast(data); + if (id == PW_ID_CORE) { + self.last_sync_seq = seq; + pw_thread_loop_signal(self.loop, false); + } +} + +void +PwSinkMixer::OnCoreError(void *data, uint32_t id, int seq, int res, + const char *message) noexcept +{ + auto &self = *static_cast(data); + + FmtError(pw_sink_mixer_domain, + "PipeWire core error: id={} seq={} res={} ({}): {}", + id, seq, res, strerror(-res), message != nullptr ? message : ""); + + self.core_error = (res != 0) ? res : -EIO; + pw_thread_loop_signal(self.loop, false); +} + +void +PwSinkMixer::OnGlobal(void *data, uint32_t id, + [[maybe_unused]] uint32_t permissions, + const char *type, [[maybe_unused]] uint32_t version, + const struct spa_dict *props) noexcept +{ + auto &self = *static_cast(data); + + if (props == nullptr) + return; + + if (std::strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) { + const char *name = spa_dict_lookup(props, PW_KEY_METADATA_NAME); + if (name != nullptr && std::strcmp(name, "default") == 0 && + self.metadata_proxy == nullptr) { + self.metadata_proxy = static_cast( + pw_registry_bind(self.registry, id, type, + PW_VERSION_METADATA, 0)); + pw_metadata_add_listener( + reinterpret_cast(self.metadata_proxy), + &self.metadata_listener, &metadata_events, &self); + } + } else if (std::strcmp(type, PW_TYPE_INTERFACE_Node) == 0) { + const char *media_class = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS); + const char *node_name = spa_dict_lookup(props, PW_KEY_NODE_NAME); + if (media_class != nullptr && node_name != nullptr && + std::strcmp(media_class, "Audio/Sink") == 0) { + const char *device_id_str = spa_dict_lookup(props, PW_KEY_DEVICE_ID); + uint32_t device_id = SPA_ID_INVALID; + if (device_id_str != nullptr) + device_id = static_cast(std::strtoul(device_id_str, nullptr, 10)); + + FmtDebug(pw_sink_mixer_domain, + "saw sink node id={} name={} device.id={}", + id, node_name, device_id); + self.known_sinks.emplace(id, SinkInfo{node_name, device_id}); + self.MaybeBindSinkNode(); + } + } +} + +void +PwSinkMixer::OnGlobalRemove(void *data, uint32_t id) noexcept +{ + auto &self = *static_cast(data); + + self.known_sinks.erase(id); + + if (id == self.sink_global_id) { + /* the sink we were bound to disappeared (unplugged, etc.); + drop it, GetVolume()/SetVolume() will throw until Open() + is called again */ + if (self.sink_proxy != nullptr) { + pw_proxy_destroy(self.sink_proxy); + self.sink_proxy = nullptr; + } + self.sink_global_id = SPA_ID_INVALID; + self.have_current_volume = false; + } +} + +int +PwSinkMixer::OnMetadataProperty(void *data, uint32_t subject, + const char *key, [[maybe_unused]] const char *type, + const char *value) noexcept +{ + auto &self = *static_cast(data); + + if (subject != PW_ID_CORE || key == nullptr) + return 0; + + if (std::strcmp(key, "default.audio.sink") == 0 || + (self.target_name.empty() && + std::strcmp(key, "default.configured.audio.sink") == 0)) { + std::string name = ParseDefaultNodeName(value); + if (!name.empty()) { + self.target_name = name; + FmtDebug(pw_sink_mixer_domain, + "resolved default sink target_name={}", name); + self.MaybeBindSinkNode(); + } + } + + return 0; +} + +void +PwSinkMixer::OnSinkNodeParam(void *data, [[maybe_unused]] int seq, + uint32_t id, + [[maybe_unused]] uint32_t index, + [[maybe_unused]] uint32_t next, + const struct spa_pod *param) noexcept +{ + auto &self = *static_cast(data); + + if (param == nullptr || !spa_pod_is_object(param)) + return; + + const auto *obj = reinterpret_cast(param); + const struct spa_pod_prop *prop; + + SPA_POD_OBJECT_FOREACH(obj, prop) { + if (prop->key != SPA_PROP_channelVolumes) + continue; + + uint32_t n_volumes = 0; + const void *raw = spa_pod_get_array(&prop->value, &n_volumes); + if (raw == nullptr || n_volumes == 0) + return; + + n_volumes = std::min(n_volumes, kMaxChannels); + std::copy_n(static_cast(raw), n_volumes, + self.current_volumes); + self.n_current_volumes = n_volumes; + self.have_current_volume = true; + FmtDebug(pw_sink_mixer_domain, + "read back volume for sink_global_id={} n_volumes={} volumes[0]={}", + id, n_volumes, self.current_volumes[0]); + return; + } +} + +void +PwSinkMixer::OnDeviceParam(void *data, [[maybe_unused]] int seq, + [[maybe_unused]] uint32_t id, + [[maybe_unused]] uint32_t index, + [[maybe_unused]] uint32_t next, + const struct spa_pod *param) noexcept +{ + auto &self = *static_cast(data); + + if (param == nullptr || !spa_pod_is_object(param)) + return; + + const auto *obj = reinterpret_cast(param); + if (obj->body.id != SPA_PARAM_Route) + return; + + const struct spa_pod_prop *prop; + int32_t found_index = -1, found_device = -1; + uint32_t found_direction = 0; + bool have_index = false, have_direction = false; + float volumes[kMaxChannels]; + uint32_t n_volumes = 0; + bool have_volumes = false; + + SPA_POD_OBJECT_FOREACH(obj, prop) { + switch (prop->key) { + case SPA_PARAM_ROUTE_index: + if (spa_pod_get_int(&prop->value, &found_index) >= 0) + have_index = true; + break; + case SPA_PARAM_ROUTE_device: + spa_pod_get_int(&prop->value, &found_device); + break; + case SPA_PARAM_ROUTE_direction: + if (spa_pod_get_id(&prop->value, &found_direction) >= 0) + have_direction = true; + break; + case SPA_PARAM_ROUTE_props: { + if (!spa_pod_is_object(&prop->value)) + break; + const auto *props_obj = reinterpret_cast< + const struct spa_pod_object *>(&prop->value); + const struct spa_pod_prop *pp; + SPA_POD_OBJECT_FOREACH(props_obj, pp) { + if (pp->key != SPA_PROP_channelVolumes) + continue; + uint32_t n = 0; + const void *raw = spa_pod_get_array(&pp->value, &n); + if (raw != nullptr && n > 0) { + n = std::min(n, kMaxChannels); + std::copy_n(static_cast(raw), + n, volumes); + n_volumes = n; + have_volumes = true; + } + } + break; + } + default: + break; + } + } + + /* only take the first Output-direction route we see -- problematic + for devices with more than one selectable route */ + if (self.have_route || !have_index || !have_direction || + found_direction != SPA_DIRECTION_OUTPUT) + return; + + self.route_index = found_index; + self.route_device = found_device; + self.route_direction = found_direction; + self.have_route = true; + + if (have_volumes) { + std::copy_n(volumes, n_volumes, self.route_volumes); + self.n_route_volumes = n_volumes; + } + + FmtDebug(pw_sink_mixer_domain, + "resolved route index={} device={} direction={} volumes[0]={}", + self.route_index, self.route_device, self.route_direction, + have_volumes ? self.route_volumes[0] : -1.0f); +} + +void +PwSinkMixer::MaybeBindSinkNode() noexcept +{ + if (sink_proxy != nullptr) + return; + + const std::string &wanted = !configured_target.empty() + ? configured_target : target_name; + if (wanted.empty()) + return; + + for (const auto &[id, info] : known_sinks) { + if (info.name != wanted) + continue; + + FmtDebug(pw_sink_mixer_domain, + "binding sink id={} name={} device.id={}", + id, info.name, info.device_id); + + sink_global_id = id; + sink_proxy = static_cast( + pw_registry_bind(registry, id, PW_TYPE_INTERFACE_Node, + PW_VERSION_NODE, 0)); + pw_node_add_listener(reinterpret_cast(sink_proxy), + &sink_node_listener, &sink_node_events, this); + pw_node_enum_params(reinterpret_cast(sink_proxy), + 0, SPA_PARAM_Props, 0, UINT32_MAX, nullptr); + + if (info.device_id != SPA_ID_INVALID) { + device_global_id = info.device_id; + device_proxy = static_cast( + pw_registry_bind(registry, info.device_id, + PW_TYPE_INTERFACE_Device, + PW_VERSION_DEVICE, 0)); + pw_device_add_listener( + reinterpret_cast(device_proxy), + &device_listener, &device_events, this); + pw_device_enum_params( + reinterpret_cast(device_proxy), + 0, SPA_PARAM_Route, 0, UINT32_MAX, nullptr); + } else { + has_hw_route = false; + FmtDebug(pw_sink_mixer_domain, + "sink node id={} has no device.id -- no hardware " + "Route available, only software Node volume " + "will be used", id); + } + return; + } +} + +bool +PwSinkMixer::Roundtrip() noexcept +{ + int seq = pw_core_sync(core, PW_ID_CORE, 0); + while (last_sync_seq != seq) { + if (core_error != 0) + return false; + pw_thread_loop_wait(loop); + } + + return core_error == 0; +} + +bool +PwSinkMixer::WaitUntilReady() noexcept +{ + for (int i = 0; i < 20 && !IsReady(); ++i) + if (!Roundtrip()) + return false; + + return IsReady(); +} + +void +PwSinkMixer::Connect() +{ + static bool pw_initialized = false; + if (!pw_initialized) { + pw_init(nullptr, nullptr); + pw_initialized = true; + } + + core_error = 0; + + loop = pw_thread_loop_new("mpd-pwsink-mixer", nullptr); + if (loop == nullptr) + throw std::runtime_error("pw_thread_loop_new() failed"); + + { + const PwThreadLoopLock lock(loop); + + context = pw_context_new( + pw_thread_loop_get_loop(loop), + pw_properties_new( + PW_KEY_MEDIA_CATEGORY, "Manager", + PW_KEY_APP_NAME, "mpd-pwsink-mixer", + nullptr), + 0); + if (context == nullptr) + throw std::runtime_error("pw_context_new() failed"); + + if (pw_thread_loop_start(loop) < 0) + throw std::runtime_error("pw_thread_loop_start() failed"); + + core = pw_context_connect(context, nullptr, 0); + if (core == nullptr) + throw std::runtime_error("pw_context_connect() failed " + "(is PipeWire running?)"); + + pw_core_add_listener(core, &core_listener, &core_events, this); + + registry = pw_core_get_registry(core, PW_VERSION_REGISTRY, 0); + if (registry == nullptr) + throw std::runtime_error("pw_core_get_registry() failed"); + + pw_registry_add_listener(registry, ®istry_listener, + ®istry_events, this); + + if (!configured_target.empty()) + MaybeBindSinkNode(); + + if (!WaitUntilReady()) { + if (core_error != 0) + throw FmtRuntimeError( + "PipeWire core error while resolving {} " + "sink volume: {}", + configured_target.empty() + ? "default" : configured_target.c_str(), + strerror(-core_error)); + + throw FmtRuntimeError( + "timed out resolving PipeWire {} sink volume", + configured_target.empty() + ? "default" : configured_target.c_str()); + } + } +} + +void +PwSinkMixer::Disconnect() noexcept +{ + if (loop == nullptr) + return; + + { + const PwThreadLoopLock lock(loop); + + if (sink_proxy != nullptr) { + pw_proxy_destroy(sink_proxy); + sink_proxy = nullptr; + } + + if (device_proxy != nullptr) { + pw_proxy_destroy(device_proxy); + device_proxy = nullptr; + } + + if (metadata_proxy != nullptr) { + pw_proxy_destroy(metadata_proxy); + metadata_proxy = nullptr; + } + + if (registry != nullptr) { + pw_proxy_destroy(reinterpret_cast(registry)); + registry = nullptr; + } + + if (core != nullptr) { + pw_core_disconnect(core); + core = nullptr; + } + } + + pw_thread_loop_stop(loop); + + if (context != nullptr) { + pw_context_destroy(context); + context = nullptr; + } + + pw_thread_loop_destroy(loop); + loop = nullptr; + + known_sinks.clear(); + sink_global_id = SPA_ID_INVALID; + device_global_id = SPA_ID_INVALID; + have_current_volume = false; + have_route = false; + has_hw_route = true; + n_route_volumes = 0; + target_name.clear(); + core_error = 0; +} + +void +PwSinkMixer::Open() +{ + cached_volume = -1; + + Connect(); + + /* + * WirePlumber restoring its own last-known Route volume for a + * sink can silently skip the write if the value already matches + * what it has cached, leaving no gain applied. Nudge off-target + * then set the real target to force a real, detectable change. + */ + ForceVolumeResync(); + + /* pin the stream to unity so the sink-level volume is the only + gain stage in effect */ + pipewire_output_set_volume(output, 1.0f); +} + +void +PwSinkMixer::ForceVolumeResync() +{ + if (sink_proxy == nullptr) + return; + + /* seed current_volumes[] from whatever Connect() already + learned */ + uint32_t n; + if (have_route && n_route_volumes > 0) { + n = n_route_volumes; + std::copy_n(route_volumes, n, current_volumes); + } else if (have_current_volume) { + n = n_current_volumes > 0 ? n_current_volumes : 2; + } else { + /* Connect()/WaitUntilReady() should guarantee one of the + above; nothing sensible to resync otherwise */ + return; + } + n_current_volumes = n; + + constexpr float kNudge = 0.01f; + + /* remember the real target, then step to a distinctly different + value first */ + float target[kMaxChannels]; + std::copy_n(current_volumes, n, target); + + for (uint32_t i = 0; i < n; ++i) + /* nudge away from 0 */ + current_volumes[i] = target[i] < 0.5f + ? target[i] + kNudge + : target[i] - kNudge; + + ApplyCurrentVolume(); + ApplyRouteVolume(); + + /* write the real value */ + std::copy_n(target, n, current_volumes); + ApplyCurrentVolume(); + ApplyRouteVolume(); + + FmtDebug(pw_sink_mixer_domain, + "ForceVolumeResync() -> sink_global_id={} n={} v={}", + sink_global_id, n, target[0]); +} + +void +PwSinkMixer::Close() noexcept +{ + cached_volume = -1; + + Disconnect(); +} + +void +PwSinkMixer::ApplyCurrentVolume() +{ + uint32_t n = n_current_volumes > 0 ? n_current_volumes : 2; + + uint8_t buffer[512]; + struct spa_pod_builder b {}; + spa_pod_builder_init(&b, buffer, sizeof(buffer)); + + struct spa_pod_frame obj_frame, array_frame; + spa_pod_builder_push_object(&b, &obj_frame, + SPA_TYPE_OBJECT_Props, SPA_PARAM_Props); + spa_pod_builder_prop(&b, SPA_PROP_channelVolumes, 0); + spa_pod_builder_push_array(&b, &array_frame); + for (uint32_t i = 0; i < n; ++i) + spa_pod_builder_float(&b, current_volumes[i]); + spa_pod_builder_pop(&b, &array_frame); + const struct spa_pod *param = static_cast( + spa_pod_builder_pop(&b, &obj_frame)); + + const PwThreadLoopLock lock(loop); + pw_node_set_param(reinterpret_cast(sink_proxy), + SPA_PARAM_Props, 0, param); + Roundtrip(); +} + +void +PwSinkMixer::ApplyRouteVolume() +{ + if (device_proxy == nullptr || !have_route) + return; + + uint32_t n = n_current_volumes > 0 ? n_current_volumes : 2; + + uint8_t buffer[1024]; + struct spa_pod_builder b {}; + spa_pod_builder_init(&b, buffer, sizeof(buffer)); + + struct spa_pod_frame route_frame, props_frame, array_frame; + spa_pod_builder_push_object(&b, &route_frame, + SPA_TYPE_OBJECT_ParamRoute, SPA_PARAM_Route); + + spa_pod_builder_prop(&b, SPA_PARAM_ROUTE_index, 0); + spa_pod_builder_int(&b, route_index); + + spa_pod_builder_prop(&b, SPA_PARAM_ROUTE_device, 0); + spa_pod_builder_int(&b, route_device); + + spa_pod_builder_prop(&b, SPA_PARAM_ROUTE_props, 0); + spa_pod_builder_push_object(&b, &props_frame, + SPA_TYPE_OBJECT_Props, SPA_PARAM_Props); + spa_pod_builder_prop(&b, SPA_PROP_channelVolumes, 0); + spa_pod_builder_push_array(&b, &array_frame); + for (uint32_t i = 0; i < n; ++i) + spa_pod_builder_float(&b, current_volumes[i]); + spa_pod_builder_pop(&b, &array_frame); + spa_pod_builder_pop(&b, &props_frame); + + spa_pod_builder_prop(&b, SPA_PARAM_ROUTE_save, 0); + spa_pod_builder_bool(&b, true); + + const struct spa_pod *param = static_cast( + spa_pod_builder_pop(&b, &route_frame)); + + const PwThreadLoopLock lock(loop); + pw_device_set_param(reinterpret_cast(device_proxy), + SPA_PARAM_Route, 0, param); + Roundtrip(); +} + +int +PwSinkMixer::GetVolume() +{ + if (sink_proxy == nullptr) + throw std::runtime_error("PipeWire sink not connected"); + + if (cached_volume >= 0) + return cached_volume; + + if (have_route && n_route_volumes > 0) + return static_cast(VolumeToPercent(route_volumes[0], curve)); + + if (!have_current_volume) + throw std::runtime_error("PipeWire sink volume not known yet"); + + return static_cast(VolumeToPercent(current_volumes[0], curve)); +} + +void +PwSinkMixer::SetVolume(unsigned volume) +{ + if (sink_proxy == nullptr) + throw std::runtime_error("PipeWire sink not connected"); + + float v = PercentToVolume(volume, curve); + uint32_t n = n_current_volumes > 0 ? n_current_volumes : 2; + std::fill_n(current_volumes, n, v); + n_current_volumes = n; + + ApplyCurrentVolume(); + ApplyRouteVolume(); + cached_volume = static_cast(volume); + + /* ensure stream gain stays at unity on every SetVolume() call */ + pipewire_output_set_volume(output, 1.0f); +} + +static Mixer * +pw_sink_mixer_init([[maybe_unused]] EventLoop &event_loop, AudioOutput &ao, + MixerListener &listener, const ConfigBlock &block) +{ + auto &po = (PipeWireOutput &)ao; + auto *mixer = new PwSinkMixer(po, listener); + mixer->Configure(block); + return mixer; +} + +void +pw_sink_mixer_request_resync(PwSinkMixer &m) noexcept +{ + try { + m.RequestResync(); + } catch (...) { + FmtError(pw_sink_mixer_domain, + "RequestResync() failed: {}", + std::current_exception()); + } +} + +const MixerPlugin pw_sink_mixer_plugin = { + pw_sink_mixer_init, + true, +}; diff --git a/src/mixer/plugins/PwSinkMixerPlugin.hxx b/src/mixer/plugins/PwSinkMixerPlugin.hxx new file mode 100644 index 0000000000..15b3869e51 --- /dev/null +++ b/src/mixer/plugins/PwSinkMixerPlugin.hxx @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#ifndef MPD_PW_SINK_MIXER_PLUGIN_HXX +#define MPD_PW_SINK_MIXER_PLUGIN_HXX + +struct MixerPlugin; +class PwSinkMixer; + +extern const MixerPlugin pw_sink_mixer_plugin; + +/** + * Re-apply the mixer's current volume to the sink. Called from + * PipeWireOutput::ParamChanged() the first time its stream connects. + */ +void +pw_sink_mixer_request_resync(PwSinkMixer &m) noexcept; + +#endif diff --git a/src/mixer/plugins/meson.build b/src/mixer/plugins/meson.build index 6531ae8d91..cacece7ce0 100644 --- a/src/mixer/plugins/meson.build +++ b/src/mixer/plugins/meson.build @@ -19,7 +19,10 @@ if is_darwin endif if pipewire_dep.found() - mixer_plugins_sources += 'PipeWireMixerPlugin.cxx' + mixer_plugins_sources += [ + 'PipeWireMixerPlugin.cxx', + 'PwSinkMixerPlugin.cxx', + ] endif if pulse_dep.found() @@ -48,6 +51,7 @@ mixer_plugins = static_library( dependencies: [ mixer_api_dep, alsa_dep, + pipewire_dep, pulse_dep, libsndio_dep, log_dep, diff --git a/src/output/Filtered.hxx b/src/output/Filtered.hxx index ac4666c6bf..5a5eefea71 100644 --- a/src/output/Filtered.hxx +++ b/src/output/Filtered.hxx @@ -19,6 +19,7 @@ class EventLoop; class Mixer; class MixerListener; struct MixerPlugin; +struct AudioOutputPlugin; struct ConfigBlock; class AudioOutput; struct AudioOutputDefaults; @@ -132,7 +133,7 @@ private: public: void Setup(EventLoop &event_loop, const ReplayGainConfig &replay_gain_config, - const MixerPlugin *mixer_plugin, + const AudioOutputPlugin &plugin, const ConfigBlock &block, const AudioOutputDefaults &defaults); diff --git a/src/output/Init.cxx b/src/output/Init.cxx index 859ef31e38..9fd752c6f6 100644 --- a/src/output/Init.cxx +++ b/src/output/Init.cxx @@ -95,7 +95,7 @@ static Mixer * audio_output_load_mixer(EventLoop &event_loop, FilteredAudioOutput &ao, const ConfigBlock &block, const MixerType mixer_type, - const MixerPlugin *plugin, + const AudioOutputPlugin &plugin, std::unique_ptr &filter_chain) { Mixer *mixer; @@ -109,13 +109,23 @@ audio_output_load_mixer(EventLoop &event_loop, FilteredAudioOutput &ao, *ao.output, ao, block); - case MixerType::HARDWARE: - if (plugin == nullptr) + case MixerType::HARDWARE: { + /* Not truly 'hardware' in the ALSA context, but + when the pipewire backend is used, this allows + the user to select the PwSinkMixerPlugin which + drives the pipewire sink volume instead of the + stream volume */ + const MixerPlugin *hw_plugin = plugin.mixer_plugin; + if (plugin.get_hardware_mixer_plugin != nullptr) + hw_plugin = plugin.get_hardware_mixer_plugin(block); + + if (hw_plugin == nullptr) return nullptr; - return mixer_new(event_loop, *plugin, + return mixer_new(event_loop, *hw_plugin, *ao.output, ao, block); + } case MixerType::SOFTWARE: mixer = mixer_new(event_loop, software_mixer_plugin, @@ -180,7 +190,7 @@ FilteredAudioOutput::Configure(const ConfigBlock &block, inline void FilteredAudioOutput::Setup(EventLoop &event_loop, const ReplayGainConfig &replay_gain_config, - const MixerPlugin *mixer_plugin, + const AudioOutputPlugin &plugin, const ConfigBlock &block, const AudioOutputDefaults &defaults) { @@ -216,7 +226,7 @@ FilteredAudioOutput::Setup(EventLoop &event_loop, try { mixer = audio_output_load_mixer(event_loop, *this, block, mixer_type, - mixer_plugin, + plugin, prepared_filter); } catch (...) { FmtError(output_domain, @@ -293,7 +303,7 @@ audio_output_new(EventLoop &normal_event_loop, EventLoop &rt_event_loop, defaults, filter_factory); f->Setup(event_loop, replay_gain_config, - plugin->mixer_plugin, + *plugin, block, defaults); return f; } diff --git a/src/output/OutputPlugin.hxx b/src/output/OutputPlugin.hxx index 027d4636fa..c376d00405 100644 --- a/src/output/OutputPlugin.hxx +++ b/src/output/OutputPlugin.hxx @@ -42,6 +42,16 @@ struct AudioOutputPlugin { * this audio output device. */ const MixerPlugin *mixer_plugin; + + /** + * If pipewire backend is used and "mixer_type" resolves + * to #MixerType::HARDWARE for this output, allows for the use + * of either "sink_volume" or "stream_volume" for mpd volume + * control. + * This method is optional; if unset, #mixer_plugin is always + * used, exactly as previously. + */ + const MixerPlugin *(*get_hardware_mixer_plugin)(const ConfigBlock &block) = nullptr; }; static inline bool diff --git a/src/output/plugins/PipeWireOutputPlugin.cxx b/src/output/plugins/PipeWireOutputPlugin.cxx index 8001a825ca..0c1dadfce4 100644 --- a/src/output/plugins/PipeWireOutputPlugin.cxx +++ b/src/output/plugins/PipeWireOutputPlugin.cxx @@ -7,6 +7,7 @@ #include "../OutputAPI.hxx" #include "../Error.hxx" #include "mixer/plugins/PipeWireMixerPlugin.hxx" +#include "mixer/plugins/PwSinkMixerPlugin.hxx" #include "pcm/Features.h" // for ENABLE_DSD #include "pcm/Silence.hxx" #include "lib/fmt/ExceptionFormatter.hxx" @@ -86,6 +87,7 @@ class PipeWireOutput final : AudioOutput { float volume = -1; PipeWireMixer *mixer = nullptr; + PwSinkMixer *pw_sink_mixer = nullptr; unsigned channels; /** @@ -181,6 +183,15 @@ class PipeWireOutput final : AudioOutput { mixer = nullptr; } + void SetPwSinkMixer(PwSinkMixer &_mixer) noexcept { + pw_sink_mixer = &_mixer; + } + + void ClearPwSinkMixer([[maybe_unused]] PwSinkMixer &old_mixer) noexcept { + assert(pw_sink_mixer == &old_mixer); + pw_sink_mixer = nullptr; + } + private: /** * Caller must lock the #thread_loop. @@ -346,6 +357,20 @@ SetVolume(struct pw_stream &stream, unsigned channels, float volume) throw std::runtime_error("pw_stream_set_control() failed"); } +void +pipewire_output_set_pw_sink_mixer(PipeWireOutput &output, + PwSinkMixer &mixer) noexcept +{ + output.SetPwSinkMixer(mixer); +} + +void +pipewire_output_clear_pw_sink_mixer(PipeWireOutput &output, + PwSinkMixer &mixer) noexcept +{ + output.ClearPwSinkMixer(mixer); +} + void PipeWireOutput::SetVolume(float _volume) { @@ -704,6 +729,9 @@ PipeWireOutput::ParamChanged([[maybe_unused]] uint32_t id, std::current_exception()); } } + + if (pw_sink_mixer != nullptr) + pw_sink_mixer_request_resync(*pw_sink_mixer); } #if defined(ENABLE_DSD) && defined(SPA_AUDIO_DSD_FLAG_NONE) @@ -1016,11 +1044,26 @@ pipewire_output_clear_mixer(PipeWireOutput &po, PipeWireMixer &pm) noexcept po.ClearMixer(pm); } +/** + * Selects which #MixerPlugin implements "mixer_type hardware" based + * on that output's own "sink_volume" setting. Either the pipewire + * stream when "no" or pipewire sink when "yes". + */ +static const MixerPlugin * +pipewire_get_sink_mixer_plugin(const ConfigBlock &block) noexcept +{ + if (block.GetBlockValue("sink_volume", false)) + return &pw_sink_mixer_plugin; + + return &pipewire_mixer_plugin; +} + const struct AudioOutputPlugin pipewire_output_plugin = { "pipewire", nullptr, &PipeWireOutput::Create, &pipewire_mixer_plugin, + &pipewire_get_sink_mixer_plugin, }; void diff --git a/src/output/plugins/PipeWireOutputPlugin.hxx b/src/output/plugins/PipeWireOutputPlugin.hxx index 99e487ca33..7d4d7f7f6d 100644 --- a/src/output/plugins/PipeWireOutputPlugin.hxx +++ b/src/output/plugins/PipeWireOutputPlugin.hxx @@ -6,6 +6,15 @@ class PipeWireOutput; class PipeWireMixer; +class PwSinkMixer; + +void +pipewire_output_set_pw_sink_mixer(PipeWireOutput &output, + PwSinkMixer &mixer) noexcept; + +void +pipewire_output_clear_pw_sink_mixer(PipeWireOutput &output, + PwSinkMixer &mixer) noexcept; extern const struct AudioOutputPlugin pipewire_output_plugin;