quickshell/src/services/pipewire/node.cpp

506 lines
16 KiB
C++

#include "node.hpp"
#include <array>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <pipewire/core.h>
#include <pipewire/keys.h>
#include <pipewire/node.h>
#include <qcontainerfwd.h>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qobject.h>
#include <qtmetamacros.h>
#include <qtypes.h>
#include <spa/node/keys.h>
#include <spa/param/param.h>
#include <spa/param/props.h>
#include <spa/pod/builder.h>
#include <spa/pod/iter.h>
#include <spa/pod/pod.h>
#include <spa/pod/vararg.h>
#include <spa/utils/dict.h>
#include <spa/utils/keys.h>
#include <spa/utils/type.h>
#include "connection.hpp"
#include "core.hpp"
#include "device.hpp"
namespace qs::service::pipewire {
namespace {
Q_LOGGING_CATEGORY(logNode, "quickshell.service.pipewire.node", QtWarningMsg);
}
QString PwAudioChannel::toString(Enum value) {
switch (value) {
case Unknown: return "Unknown";
case NA: return "N/A";
case Mono: return "Mono";
case FrontCenter: return "Front Center";
case FrontLeft: return "Front Left";
case FrontRight: return "Front Right";
case FrontLeftCenter: return "Front Left Center";
case FrontRightCenter: return "Front Right Center";
case FrontLeftWide: return "Front Left Wide";
case FrontRightWide: return "Front Right Wide";
case FrontCenterHigh: return "Front Center High";
case FrontLeftHigh: return "Front Left High";
case FrontRightHigh: return "Front Right High";
case LowFrequencyEffects: return "Low Frequency Effects";
case LowFrequencyEffects2: return "Low Frequency Effects 2";
case LowFrequencyEffectsLeft: return "Low Frequency Effects Left";
case LowFrequencyEffectsRight: return "Low Frequency Effects Right";
case SideLeft: return "Side Left";
case SideRight: return "Side Right";
case RearCenter: return "Rear Center";
case RearLeft: return "Rear Left";
case RearRight: return "Rear Right";
case RearLeftCenter: return "Rear Left Center";
case RearRightCenter: return "Rear Right Center";
case TopCenter: return "Top Center";
case TopFrontCenter: return "Top Front Center";
case TopFrontLeft: return "Top Front Left";
case TopFrontRight: return "Top Front Right";
case TopFrontLeftCenter: return "Top Front Left Center";
case TopFrontRightCenter: return "Top Front Right Center";
case TopSideLeft: return "Top Side Left";
case TopSideRight: return "Top Side Right";
case TopRearCenter: return "Top Rear Center";
case TopRearLeft: return "Top Rear Left";
case TopRearRight: return "Top Rear Right";
case BottomCenter: return "Bottom Center";
case BottomLeftCenter: return "Bottom Left Center";
case BottomRightCenter: return "Bottom Right Center";
default:
if (value >= AuxRangeStart && value <= AuxRangeEnd) {
return QString("Aux %1").arg(value - AuxRangeStart + 1);
} else if (value >= CustomRangeStart) {
return QString("Custom %1").arg(value - CustomRangeStart + 1);
} else {
return "Unknown";
}
}
}
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() {
if (this->ready) {
this->ready = false;
emit this->readyChanged();
}
this->syncSeq = 0;
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) {
if (const auto* mediaClass = spa_dict_lookup(props, SPA_KEY_MEDIA_CLASS)) {
if (strcmp(mediaClass, "Audio/Sink") == 0) {
this->type = PwNodeType::Audio;
this->isSink = true;
this->isStream = false;
} else if (strcmp(mediaClass, "Audio/Source") == 0) {
this->type = PwNodeType::Audio;
this->isSink = false;
this->isStream = false;
} else if (strcmp(mediaClass, "Stream/Output/Audio") == 0) {
this->type = PwNodeType::Audio;
this->isSink = false;
this->isStream = true;
} else if (strcmp(mediaClass, "Stream/Input/Audio") == 0) {
this->type = PwNodeType::Audio;
this->isSink = true;
this->isStream = true;
}
}
if (const auto* nodeName = spa_dict_lookup(props, SPA_KEY_NODE_NAME)) {
this->name = nodeName;
}
if (const auto* nodeDesc = spa_dict_lookup(props, SPA_KEY_NODE_DESCRIPTION)) {
this->description = nodeDesc;
}
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);
}
}
const pw_node_events PwNode::EVENTS = {
.version = PW_VERSION_NODE_EVENTS,
.info = &PwNode::onInfo,
.param = &PwNode::onParam,
};
void PwNode::onInfo(void* data, const pw_node_info* info) {
auto* self = static_cast<PwNode*>(data);
if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) != 0) {
auto properties = QMap<QString, QString>();
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); }
self->properties = properties;
emit self->propertiesChanged();
}
if (self->boundData != nullptr) {
self->boundData->onInfo(info);
}
if (!self->ready && !self->syncSeq) {
auto* core = PwConnection::instance()->registry.core;
QObject::connect(core, &PwCore::synced, self, &PwNode::onCoreSync);
self->syncSeq = core->sync(self->id);
}
}
void PwNode::onCoreSync(quint32 id, qint32 seq) {
if (id != this->id || seq != this->syncSeq) return;
qCInfo(logNode) << "Completed initial sync for" << this;
this->ready = true;
this->syncSeq = 0;
emit this->readyChanged();
}
void PwNode::onParam(
void* data,
qint32 /*seq*/,
quint32 id,
quint32 index,
quint32 /*next*/,
const spa_pod* param
) {
auto* self = static_cast<PwNode*>(data);
if (self->boundData != nullptr) {
self->boundData->onSpaParam(id, index, param);
}
}
PwNodeBoundAudio::PwNodeBoundAudio(PwNode* node): node(node) {
if (node->device) {
QObject::connect(node->device, &PwDevice::deviceReady, this, &PwNodeBoundAudio::onDeviceReady);
}
}
void PwNodeBoundAudio::onInfo(const pw_node_info* info) {
if ((info->change_mask & PW_NODE_CHANGE_MASK_PARAMS) != 0) {
for (quint32 i = 0; i < info->n_params; i++) {
auto& param = info->params[i]; // NOLINT
if (param.id == SPA_PARAM_Props) {
if ((param.flags & SPA_PARAM_INFO_READWRITE) == SPA_PARAM_INFO_READWRITE) {
qCDebug(logNode) << "Enumerating props param for" << this->node;
pw_node_enum_params(this->node->proxy(), 0, param.id, 0, UINT32_MAX, nullptr);
} else {
qCWarning(logNode) << "Unable to enumerate props param for" << this->node
<< "as the param does not have read+write permissions.";
}
}
}
}
}
void PwNodeBoundAudio::onSpaParam(quint32 id, quint32 index, const spa_pod* param) {
if (id == SPA_PARAM_Props && index == 0) {
this->updateVolumeProps(param);
}
}
void PwNodeBoundAudio::updateVolumeProps(const spa_pod* param) {
auto volumeProps = PwVolumeProps::parseSpaPod(param);
if (volumeProps.volumes.size() != volumeProps.channels.size()) {
qCWarning(logNode) << "Cannot update volume props of" << this->node
<< "- channelVolumes and channelMap are not the same size. Sizes:"
<< volumeProps.volumes.size() << volumeProps.channels.size();
return;
}
// It is important that the lengths of channels and volumes stay in sync whenever you read them.
auto channelsChanged = false;
auto volumesChanged = false;
auto mutedChanged = false;
if (this->mChannels != volumeProps.channels) {
this->mChannels = volumeProps.channels;
channelsChanged = true;
qCInfo(logNode) << "Got updated channels of" << this->node << '-' << this->mChannels;
}
if (this->mServerVolumes != volumeProps.volumes) {
this->mServerVolumes = volumeProps.volumes;
}
if (this->mVolumes != volumeProps.volumes) {
this->mVolumes = volumeProps.volumes;
volumesChanged = true;
qCInfo(logNode) << "Got updated volumes of" << this->node << '-' << this->mVolumes;
}
if (volumeProps.mute != this->mMuted) {
this->mMuted = volumeProps.mute;
mutedChanged = true;
qCInfo(logNode) << "Got updated mute status of" << this->node << '-' << volumeProps.mute;
}
if (channelsChanged) emit this->channelsChanged();
if (volumesChanged) emit this->volumesChanged();
if (mutedChanged) emit this->mutedChanged();
}
void PwNodeBoundAudio::onUnbind() {
this->mChannels.clear();
this->mVolumes.clear();
this->mServerVolumes.clear();
this->mDeviceVolumes.clear();
this->waitingVolumes.clear();
emit this->channelsChanged();
emit this->volumesChanged();
}
bool PwNodeBoundAudio::isMuted() const { return this->mMuted; }
void PwNodeBoundAudio::setMuted(bool muted) {
if (this->node->proxy() == nullptr) {
qCCritical(logNode) << "Tried to change mute state for" << this->node << "which is not bound.";
return;
}
if (muted == this->mMuted) return;
if (this->node->device) {
qCInfo(logNode) << "Changing muted state of" << this->node << "to" << muted << "via device";
if (!this->node->device->setMuted(this->node->routeDevice, muted)) {
return;
}
} else {
auto buffer = std::array<quint8, 1024>();
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 << "via node";
pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast<spa_pod*>(pod));
}
this->mMuted = muted;
emit this->mutedChanged();
}
float PwNodeBoundAudio::averageVolume() const {
float total = 0;
for (auto volume: this->mVolumes) {
total += volume;
}
return total / static_cast<float>(this->mVolumes.size());
}
void PwNodeBoundAudio::setAverageVolume(float volume) {
auto oldAverage = this->averageVolume();
auto mul = oldAverage == 0 ? 0 : volume / oldAverage;
auto volumes = QVector<float>();
for (auto oldVolume: this->mVolumes) {
volumes.push_back(mul == 0 ? volume : oldVolume * mul);
}
this->setVolumes(volumes);
}
QVector<PwAudioChannel::Enum> PwNodeBoundAudio::channels() const { return this->mChannels; }
QVector<float> PwNodeBoundAudio::volumes() const { return this->mVolumes; }
void PwNodeBoundAudio::setVolumes(const QVector<float>& volumes) {
if (this->node->proxy() == nullptr) {
qCCritical(logNode) << "Tried to change node volumes for" << this->node
<< "which is not bound.";
return;
}
auto realVolumes = QVector<float>();
for (auto volume: volumes) {
realVolumes.push_back(volume < 0 ? 0 : volume);
}
if (realVolumes == this->mVolumes) return;
if (realVolumes.length() != this->mVolumes.length()) {
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;
}
if (this->node->device) {
if (this->node->device->waitingForDevice()) {
qCInfo(logNode) << "Waiting to change volumes of" << this->node << "to" << realVolumes
<< "via device";
this->waitingVolumes = realVolumes;
} else {
auto significantChange = this->mServerVolumes.isEmpty();
for (auto i = 0; i < this->mServerVolumes.length(); i++) {
auto serverVolume = this->mServerVolumes.value(i);
auto targetVolume = realVolumes.value(i);
if (targetVolume == 0 || abs(targetVolume - serverVolume) >= 0.0001) {
significantChange = true;
break;
}
}
if (significantChange) {
qCInfo(logNode) << "Changing volumes of" << this->node << "to" << realVolumes
<< "via device";
if (!this->node->device->setVolumes(this->node->routeDevice, realVolumes)) {
return;
}
this->mDeviceVolumes = realVolumes;
this->node->device->waitForDevice();
} else {
// Insignificant changes won't cause an info event on the device, leaving qs hung in the
// "waiting for acknowledgement" state forever.
qCInfo(logNode) << "Ignoring volume change for" << this->node << "to" << realVolumes
<< "from" << this->mServerVolumes
<< "as it is a device node and the change is too small.";
}
}
} else {
auto buffer = std::array<quint8, 1024>();
auto builder = SPA_POD_BUILDER_INIT(buffer.data(), buffer.size());
auto cubedVolumes = QVector<float>();
for (auto volume: realVolumes) {
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) << "Changing volumes of" << this->node << "to" << volumes << "via node";
pw_node_set_param(this->node->proxy(), SPA_PARAM_Props, 0, static_cast<spa_pod*>(pod));
}
this->mVolumes = realVolumes;
emit this->volumesChanged();
}
void PwNodeBoundAudio::onDeviceReady() {
if (!this->waitingVolumes.isEmpty()) {
if (this->waitingVolumes != this->mDeviceVolumes) {
qCInfo(logNode) << "Changing volumes of" << this->node << "to" << this->waitingVolumes
<< "via device (delayed)";
this->node->device->setVolumes(this->node->routeDevice, this->waitingVolumes);
this->mDeviceVolumes = this->waitingVolumes;
this->mVolumes = this->waitingVolumes;
}
this->waitingVolumes.clear();
}
}
PwVolumeProps PwVolumeProps::parseSpaPod(const spa_pod* param) {
auto props = PwVolumeProps();
const auto* volumesProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelVolumes);
const auto* channelsProp = spa_pod_find_prop(param, nullptr, SPA_PROP_channelMap);
const auto* muteProp = spa_pod_find_prop(param, nullptr, SPA_PROP_mute);
const auto* volumes = reinterpret_cast<const spa_pod_array*>(&volumesProp->value);
const auto* channels = reinterpret_cast<const spa_pod_array*>(&channelsProp->value);
spa_pod* iter = nullptr;
SPA_POD_ARRAY_FOREACH(volumes, iter) {
// Cubing behavior found in MPD source, and appears to corrospond to everyone else's measurements correctly.
auto linear = *reinterpret_cast<float*>(iter);
auto visual = std::cbrt(linear);
props.volumes.push_back(visual);
}
SPA_POD_ARRAY_FOREACH(channels, iter) {
props.channels.push_back(*reinterpret_cast<PwAudioChannel::Enum*>(iter));
}
spa_pod_get_bool(&muteProp->value, &props.mute);
return props;
}
} // namespace qs::service::pipewire