From c60871a7fb56f66c9b4057bddc5326d4fa405164 Mon Sep 17 00:00:00 2001 From: outfoxxed Date: Tue, 27 Aug 2024 01:28:28 -0700 Subject: [PATCH] service/pipewire: set device node volumes with device object Fixes discrepancies between pulse and qs volumes, and volumes not persisting across reboot or vt switches. --- src/services/pipewire/CMakeLists.txt | 1 + src/services/pipewire/core.cpp | 4 +- src/services/pipewire/core.hpp | 5 +- src/services/pipewire/device.cpp | 192 +++++++++++++++++++++++++++ src/services/pipewire/device.hpp | 50 +++++++ src/services/pipewire/node.cpp | 184 +++++++++++++++---------- src/services/pipewire/node.hpp | 5 + src/services/pipewire/registry.cpp | 9 ++ src/services/pipewire/registry.hpp | 4 + 9 files changed, 380 insertions(+), 74 deletions(-) create mode 100644 src/services/pipewire/device.cpp create mode 100644 src/services/pipewire/device.hpp diff --git a/src/services/pipewire/CMakeLists.txt b/src/services/pipewire/CMakeLists.txt index 4fccdc0e..51c9fec8 100644 --- a/src/services/pipewire/CMakeLists.txt +++ b/src/services/pipewire/CMakeLists.txt @@ -9,6 +9,7 @@ qt_add_library(quickshell-service-pipewire STATIC node.cpp metadata.cpp link.cpp + device.cpp ) qt_add_qml_module(quickshell-service-pipewire diff --git a/src/services/pipewire/core.cpp b/src/services/pipewire/core.cpp index 4f997155..c4b31ab5 100644 --- a/src/services/pipewire/core.cpp +++ b/src/services/pipewire/core.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -68,11 +69,12 @@ bool PwCore::isValid() const { return this->core != nullptr; } -void PwCore::poll() const { +void PwCore::poll() { qCDebug(logLoop) << "Pipewire event loop received new events, iterating."; // Spin pw event loop. pw_loop_iterate(this->loop, 0); qCDebug(logLoop) << "Done iterating pipewire event loop."; + emit this->polled(); } SpaHook::SpaHook() { // NOLINT diff --git a/src/services/pipewire/core.hpp b/src/services/pipewire/core.hpp index ebf5c63e..bf7bd785 100644 --- a/src/services/pipewire/core.hpp +++ b/src/services/pipewire/core.hpp @@ -28,8 +28,11 @@ public: pw_context* context = nullptr; pw_core* core = nullptr; +signals: + void polled(); + private slots: - void poll() const; + void poll(); private: QSocketNotifier notifier; diff --git a/src/services/pipewire/device.cpp b/src/services/pipewire/device.cpp new file mode 100644 index 00000000..6adab506 --- /dev/null +++ b/src/services/pipewire/device.cpp @@ -0,0 +1,192 @@ +#include "device.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.hpp" + +namespace qs::service::pipewire { + +Q_LOGGING_CATEGORY(logDevice, "quickshell.service.pipewire.device", QtWarningMsg); + +// https://github.com/PipeWire/wireplumber/blob/895c1c7286e8809fad869059179e53ab39c807e9/modules/module-mixer-api.c#L397 +// https://github.com/PipeWire/pipewire/blob/48c2e9516585ccc791335bc7baf4af6952ec54a0/src/modules/module-protocol-pulse/pulse-server.c#L2743-L2743 + +void PwDevice::bindHooks() { + pw_device_add_listener(this->proxy(), &this->listener.hook, &PwDevice::EVENTS, this); + QObject::connect(this->registry->core, &PwCore::polled, this, &PwDevice::polled); +} + +void PwDevice::unbindHooks() { + QObject::disconnect(this->registry->core, &PwCore::polled, this, &PwDevice::polled); + this->listener.remove(); + this->stagingIndexes.clear(); + this->routeDeviceIndexes.clear(); +} + +const pw_device_events PwDevice::EVENTS = { + .version = PW_VERSION_DEVICE_EVENTS, + .info = &PwDevice::onInfo, + .param = &PwDevice::onParam, +}; + +void PwDevice::onInfo(void* data, const pw_device_info* info) { + auto* self = static_cast(data); + + if ((info->change_mask & PW_DEVICE_CHANGE_MASK_PARAMS) == PW_DEVICE_CHANGE_MASK_PARAMS) { + for (quint32 i = 0; i != info->n_params; i++) { + auto& param = info->params[i]; // NOLINT + + if (param.id == SPA_PARAM_Route) { + if ((param.flags & SPA_PARAM_INFO_READWRITE) == SPA_PARAM_INFO_READWRITE) { + qCDebug(logDevice) << "Enumerating routes param for" << self; + self->stagingIndexes.clear(); + pw_device_enum_params(self->proxy(), 0, param.id, 0, UINT32_MAX, nullptr); + } else { + qCWarning(logDevice) << "Unable to enumerate route param for" << self + << "as the param does not have read+write permissions."; + } + + break; + } + } + } +} + +void PwDevice::onParam( + void* data, + qint32 /*seq*/, + quint32 id, + quint32 /*index*/, + quint32 next, + const spa_pod* param +) { + auto* self = static_cast(data); + + if (id == SPA_PARAM_Route) { + self->addDeviceIndexPairs(param); + } +} + +void PwDevice::addDeviceIndexPairs(const spa_pod* param) { + auto parser = spa_pod_parser(); + spa_pod_parser_pod(&parser, param); + + qint32 device = 0; + qint32 index = 0; + + // clang-format off + quint32 id = SPA_PARAM_Route; + spa_pod_parser_get_object( + &parser, SPA_TYPE_OBJECT_ParamRoute, &id, + SPA_PARAM_ROUTE_device, SPA_POD_Int(&device), + SPA_PARAM_ROUTE_index, SPA_POD_Int(&index) + ); + // clang-format on + + this->stagingIndexes.insert(device, index); + // Insert into the main map as well, staging's purpose is to remove old entries. + this->routeDeviceIndexes.insert(device, index); + + qCDebug(logDevice).nospace() << "Registered device/index pair for " << this + << ": [device: " << device << ", index: " << index << ']'; +} + +void PwDevice::polled() { + // It is far more likely that the list content has not come in yet than it having no entries, + // and there isn't a way to check in the case that there *aren't* actually any entries. + if (!this->stagingIndexes.isEmpty() && this->stagingIndexes != this->routeDeviceIndexes) { + this->routeDeviceIndexes = this->stagingIndexes; + qCDebug(logDevice) << "Updated device/index pair list for" << this << "to" + << this->routeDeviceIndexes; + } +} + +bool PwDevice::setVolumes(qint32 routeDevice, const QVector& volumes) { + return this->setRouteProps(routeDevice, [this, routeDevice, &volumes](spa_pod_builder* builder) { + auto cubedVolumes = QVector(); + for (auto volume: volumes) { + cubedVolumes.push_back(volume * volume * volume); + } + + // clang-format off + auto* props = spa_pod_builder_add_object( + builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_channelVolumes, SPA_POD_Array(sizeof(float), SPA_TYPE_Float, cubedVolumes.length(), cubedVolumes.data()) + ); + // clang-format on + + qCInfo(logDevice) << "Changed volumes of" << this << "on route device" << routeDevice << "to" + << volumes; + return props; + }); +} + +bool PwDevice::setMuted(qint32 routeDevice, bool muted) { + return this->setRouteProps(routeDevice, [this, routeDevice, muted](spa_pod_builder* builder) { + // clang-format off + auto* props = spa_pod_builder_add_object( + builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_mute, SPA_POD_Bool(muted) + ); + // clang-format on + + qCInfo(logDevice) << "Changed muted state of" << this << "on route device" << routeDevice + << "to" << muted; + return props; + }); +} + +bool PwDevice::setRouteProps( + qint32 routeDevice, + const std::function& propsCallback +) { + if (this->proxy() == nullptr) { + qCCritical(logDevice) << "Tried to change device route props for" << this + << "which is not bound."; + return false; + } + + if (!this->routeDeviceIndexes.contains(routeDevice)) { + qCCritical(logDevice) << "Tried to change device route props for" << this + << "with untracked route device" << routeDevice; + return false; + } + + auto routeIndex = this->routeDeviceIndexes.value(routeDevice); + + auto buffer = std::array(); + auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + + auto* props = propsCallback(&builder); + + // clang-format off + auto* route = spa_pod_builder_add_object( + &builder, SPA_TYPE_OBJECT_ParamRoute, SPA_PARAM_Route, + SPA_PARAM_ROUTE_device, SPA_POD_Int(routeDevice), + SPA_PARAM_ROUTE_index, SPA_POD_Int(routeIndex), + SPA_PARAM_ROUTE_props, SPA_POD_PodObject(props), + SPA_PARAM_ROUTE_save, SPA_POD_Bool(true) + ); + // clang-format on + + pw_device_set_param(this->proxy(), SPA_PARAM_Route, 0, static_cast(route)); + return true; +} + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/device.hpp b/src/services/pipewire/device.hpp new file mode 100644 index 00000000..31f32f0f --- /dev/null +++ b/src/services/pipewire/device.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core.hpp" +#include "registry.hpp" + +namespace qs::service::pipewire { + +class PwDevice; + +constexpr const char TYPE_INTERFACE_Device[] = PW_TYPE_INTERFACE_Device; // NOLINT +class PwDevice: public PwBindable { + Q_OBJECT; + +public: + void bindHooks() override; + void unbindHooks() override; + + bool setVolumes(qint32 routeDevice, const QVector& volumes); + bool setMuted(qint32 routeDevice, bool muted); + +private slots: + void polled(); + +private: + static const pw_device_events EVENTS; + static void onInfo(void* data, const pw_device_info* info); + static void + onParam(void* data, qint32 seq, quint32 id, quint32 index, quint32 next, const spa_pod* param); + + QHash routeDeviceIndexes; + QHash stagingIndexes; + void addDeviceIndexPairs(const spa_pod* param); + + bool + setRouteProps(qint32 routeDevice, const std::function& propsCallback); + + SpaHook listener; +}; + +} // namespace qs::service::pipewire diff --git a/src/services/pipewire/node.cpp b/src/services/pipewire/node.cpp index 969a8b71..280c450a 100644 --- a/src/services/pipewire/node.cpp +++ b/src/services/pipewire/node.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -17,12 +18,15 @@ #include #include #include +#include #include #include #include #include #include +#include "device.hpp" + namespace qs::service::pipewire { Q_LOGGING_CATEGORY(logNode, "quickshell.service.pipewire.node", QtWarningMsg); @@ -79,17 +83,25 @@ QString PwAudioChannel::toString(Enum value) { } void PwNode::bindHooks() { + // Bind the device first as pw is in order, meaning the device should be bound before + // we want to do anything with it. + if (this->device) this->device->ref(); + pw_node_add_listener(this->proxy(), &this->listener.hook, &PwNode::EVENTS, this); } void PwNode::unbindHooks() { this->listener.remove(); + this->routeDevice = -1; this->properties.clear(); emit this->propertiesChanged(); if (this->boundData != nullptr) { this->boundData->onUnbind(); } + + // unbind after the node is unbound + if (this->device) this->device->unref(); } void PwNode::initProps(const spa_dict* props) { @@ -121,10 +133,28 @@ void PwNode::initProps(const spa_dict* props) { this->description = nodeDesc; } - if (const auto* nodeNick = spa_dict_lookup(props, "node.nick")) { + if (const auto* nodeNick = spa_dict_lookup(props, PW_KEY_NODE_NICK)) { this->nick = nodeNick; } + if (const auto* deviceId = spa_dict_lookup(props, PW_KEY_DEVICE_ID)) { + auto ok = false; + auto id = QString::fromUtf8(deviceId).toInt(&ok); + + if (!ok) { + qCCritical(logNode) << this << "has a device.id property but the value is not an integer. Id:" + << deviceId; + } else { + this->device = this->registry->devices.value(id); + + if (this->device == nullptr) { + qCCritical(logNode + ) << this + << "has a device.id property that does not corrospond to a device object. Id:" << id; + } + } + } + if (this->type == PwNodeType::Audio) { this->boundData = new PwNodeBoundAudio(this); } @@ -142,6 +172,24 @@ void PwNode::onInfo(void* data, const pw_node_info* info) { if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) != 0) { auto properties = QMap(); + if (self->device) { + if (const auto* routeDevice = spa_dict_lookup(info->props, "card.profile.device")) { + auto ok = false; + auto id = QString::fromUtf8(routeDevice).toInt(&ok); + + if (!ok) { + qCCritical(logNode + ) << self + << "has a card.profile.device property but the value is not an integer. Value:" << id; + } + + self->routeDevice = id; + } else { + qCCritical(logNode) << self << "has attached device" << self->device + << "but no card.profile.device property."; + } + } + const spa_dict_item* item = nullptr; spa_dict_for_each(item, info->props) { properties.insert(item->key, item->value); } @@ -191,29 +239,6 @@ void PwNodeBoundAudio::updateVolumeFromParam(const spa_pod* param) { const auto* volumesProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelVolumes); const auto* channelsProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelMap); - if (volumesProp == nullptr) { - qCWarning(logNode) << "Cannot update volume props of" << this->node - << "- channelVolumes was null."; - return; - } - - if (channelsProp == nullptr) { - qCWarning(logNode) << "Cannot update volume props of" << this->node << "- channelMap was null."; - return; - } - - if (spa_pod_is_array(&volumesProp->value) == 0) { - qCWarning(logNode) << "Cannot update volume props of" << this->node - << "- channelVolumes was not an array."; - return; - } - - if (spa_pod_is_array(&channelsProp->value) == 0) { - qCWarning(logNode) << "Cannot update volume props of" << this->node - << "- channelMap was not an array."; - return; - } - const auto* volumes = reinterpret_cast(&volumesProp->value); // NOLINT const auto* channels = reinterpret_cast(&channelsProp->value); // NOLINT @@ -246,13 +271,13 @@ void PwNodeBoundAudio::updateVolumeFromParam(const spa_pod* param) { if (this->mChannels != channelsVec) { this->mChannels = channelsVec; channelsChanged = true; - qCDebug(logNode) << "Got updated channels of" << this->node << '-' << this->mChannels; + qCInfo(logNode) << "Got updated channels of" << this->node << '-' << this->mChannels; } if (this->mVolumes != volumesVec) { this->mVolumes = volumesVec; volumesChanged = true; - qCDebug(logNode) << "Got updated volumes of" << this->node << '-' << this->mVolumes; + qCInfo(logNode) << "Got updated volumes of" << this->node << '-' << this->mVolumes; } if (channelsChanged) emit this->channelsChanged(); @@ -260,25 +285,21 @@ void PwNodeBoundAudio::updateVolumeFromParam(const spa_pod* param) { } void PwNodeBoundAudio::updateMutedFromParam(const spa_pod* param) { - const auto* mutedProp = spa_pod_find_prop(param, nullptr, SPA_PROP_mute); + auto parser = spa_pod_parser(); + spa_pod_parser_pod(&parser, param); - if (mutedProp == nullptr) { - qCWarning(logNode) << "Cannot update muted state of" << this->node - << "- mute property was null."; - return; - } + auto muted = false; - if (spa_pod_is_bool(&mutedProp->value) == 0) { - qCWarning(logNode) << "Cannot update muted state of" << this->node - << "- mute property was not a boolean."; - return; - } - - bool muted = false; - spa_pod_get_bool(&mutedProp->value, &muted); + // clang-format off + quint32 id = SPA_PARAM_Props; + spa_pod_parser_get_object( + &parser, SPA_TYPE_OBJECT_Props, &id, + SPA_PROP_mute, SPA_POD_Bool(&muted) + ); + // clang-format on if (muted != this->mMuted) { - qCDebug(logNode) << "Got updated mute status of" << this->node << '-' << muted; + qCInfo(logNode) << "Got updated mute status of" << this->node << '-' << muted; this->mMuted = muted; emit this->mutedChanged(); } @@ -295,26 +316,35 @@ bool PwNodeBoundAudio::isMuted() const { return this->mMuted; } void PwNodeBoundAudio::setMuted(bool muted) { if (this->node->proxy() == nullptr) { - qCWarning(logNode) << "Tried to change mute state for" << this->node << "which is not bound."; + qCCritical(logNode) << "Tried to change mute state for" << this->node << "which is not bound."; return; } if (muted == this->mMuted) return; - auto buffer = std::array(); - auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + if (this->node->device) { + if (!this->node->device->setMuted(this->node->routeDevice, muted)) { + return; + } - // is this a leak? seems like probably not? docs don't say, as usual. - // clang-format off - auto* pod = spa_pod_builder_add_object( - &builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, - SPA_PROP_mute, SPA_POD_Bool(muted) - ); - // clang-format on + qCInfo(logNode) << "Changed muted state of" << this->node << "to" << muted << "via device"; + } else { + auto buffer = std::array(); + auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + + // is this a leak? seems like probably not? docs don't say, as usual. + // clang-format off + auto* pod = spa_pod_builder_add_object( + &builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_mute, SPA_POD_Bool(muted) + ); + // clang-format on + + qCInfo(logNode) << "Changed muted state of" << this->node << "to" << muted; + pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast(pod)); + } - qCDebug(logNode) << "Changed muted state of" << this->node << "to" << muted; this->mMuted = muted; - pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast(pod)); emit this->mutedChanged(); } @@ -346,38 +376,48 @@ QVector PwNodeBoundAudio::volumes() const { return this->mVolumes; } void PwNodeBoundAudio::setVolumes(const QVector& volumes) { if (this->node->proxy() == nullptr) { - qCWarning(logNode) << "Tried to change node volumes for" << this->node << "which is not bound."; + qCCritical(logNode) << "Tried to change node volumes for" << this->node + << "which is not bound."; return; } if (volumes == this->mVolumes) return; if (volumes.length() != this->mVolumes.length()) { - qCWarning(logNode) << "Tried to change node volumes for" << this->node << "from" - << this->mVolumes << "to" << volumes - << "which has a different length than the list of channels" - << this->mChannels; + qCCritical(logNode) << "Tried to change node volumes for" << this->node << "from" + << this->mVolumes << "to" << volumes + << "which has a different length than the list of channels" + << this->mChannels; return; } - auto buffer = std::array(); - auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + if (this->node->device) { + if (!this->node->device->setVolumes(this->node->routeDevice, volumes)) { + return; + } - auto cubedVolumes = QVector(); - for (auto volume: volumes) { - cubedVolumes.push_back(volume * volume * volume); + qCInfo(logNode) << "Changed volumes of" << this->node << "to" << volumes << "via device"; + } else { + auto buffer = std::array(); + auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size()); + + auto cubedVolumes = QVector(); + for (auto volume: volumes) { + cubedVolumes.push_back(volume * volume * volume); + } + + // clang-format off + auto* pod = spa_pod_builder_add_object( + &builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_channelVolumes, SPA_POD_Array(sizeof(float), SPA_TYPE_Float, cubedVolumes.length(), cubedVolumes.data()) + ); + // clang-format on + + qCInfo(logNode) << "Changed volumes of" << this->node << "to" << volumes; + pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast(pod)); } - // clang-format off - auto* pod = spa_pod_builder_add_object( - &builder, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, - SPA_PROP_channelVolumes, SPA_POD_Array(sizeof(float), SPA_TYPE_Float, cubedVolumes.length(), cubedVolumes.data()) - ); - // clang-format on - - qCDebug(logNode) << "Changed volumes of" << this->node << "to" << volumes; this->mVolumes = volumes; - pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast(pod)); emit this->volumesChanged(); } diff --git a/src/services/pipewire/node.hpp b/src/services/pipewire/node.hpp index 75c93d0a..b8165137 100644 --- a/src/services/pipewire/node.hpp +++ b/src/services/pipewire/node.hpp @@ -18,6 +18,8 @@ namespace qs::service::pipewire { +class PwDevice; + ///! Audio channel of a pipewire node. /// See @@PwNodeAudio.channels. class PwAudioChannel: public QObject { @@ -161,6 +163,9 @@ public: PwNodeBoundData* boundData = nullptr; + PwDevice* device = nullptr; + qint32 routeDevice = -1; + signals: void propertiesChanged(); diff --git a/src/services/pipewire/registry.cpp b/src/services/pipewire/registry.cpp index 28142765..55cfb276 100644 --- a/src/services/pipewire/registry.cpp +++ b/src/services/pipewire/registry.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include "core.hpp" +#include "device.hpp" #include "link.hpp" #include "metadata.hpp" #include "node.hpp" @@ -114,6 +116,7 @@ void PwBindableObjectRef::onObjectDestroyed() { } void PwRegistry::init(PwCore& core) { + this->core = &core; this->object = pw_core_get_registry(core.core, PW_VERSION_REGISTRY, 0); pw_registry_add_listener(this->object, &this->listener.hook, &PwRegistry::EVENTS, this); } @@ -156,6 +159,12 @@ void PwRegistry::onGlobal( self->nodes.emplace(id, node); emit self->nodeAdded(node); + } else if (strcmp(type, PW_TYPE_INTERFACE_Device) == 0) { + auto* device = new PwDevice(); + device->init(self, id, permissions); + device->initProps(props); + + self->devices.emplace(id, device); } } diff --git a/src/services/pipewire/registry.hpp b/src/services/pipewire/registry.hpp index dab01af7..6ccd7148 100644 --- a/src/services/pipewire/registry.hpp +++ b/src/services/pipewire/registry.hpp @@ -21,6 +21,7 @@ class PwRegistry; class PwMetadata; class PwNode; class PwLink; +class PwDevice; class PwLinkGroup; class PwBindableObject: public QObject { @@ -120,9 +121,12 @@ public: //QHash clients; QHash metadata; QHash nodes; + QHash devices; QHash links; QVector linkGroups; + PwCore* core = nullptr; + signals: void nodeAdded(PwNode* node); void linkAdded(PwLink* link);