This commit is contained in:
Joey Yakimowich-Payne 2026-01-27 17:44:09 -07:00
commit 4bf830763f
4 changed files with 285 additions and 3 deletions

View file

@ -5,10 +5,13 @@
#include <QCoreApplication>
#include <QColor>
#include <QFileDialog>
#include <QFontMetrics>
#include <QLabel>
#include <QHBoxLayout>
#include <QSplitter>
#include <QTimer>
#include <QVBoxLayout>
#include <QScrollArea>
#include <QtNodes/GraphicsViewStyle>
#include <QtNodes/NodeStyle>
@ -74,9 +77,28 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_meter = new AudioLevelMeter(meterPanel);
meterLayout->addWidget(m_meter, 1);
auto *nodesLabel = new QLabel(QString("NODE METERS"), meterPanel);
nodesLabel->setStyleSheet("color: #8c94a5; font-weight: 700; font-size: 10px; letter-spacing: 1px; border: none;");
nodesLabel->setAlignment(Qt::AlignLeft);
meterLayout->addWidget(nodesLabel);
m_meterScroll = new QScrollArea(meterPanel);
m_meterScroll->setFrameShape(QFrame::NoFrame);
m_meterScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_meterScroll->setWidgetResizable(true);
m_meterList = new QWidget(m_meterScroll);
m_meterListLayout = new QVBoxLayout(m_meterList);
m_meterListLayout->setContentsMargins(0, 0, 0, 0);
m_meterListLayout->setSpacing(10);
m_meterList->setLayout(m_meterListLayout);
m_meterScroll->setWidget(m_meterList);
meterLayout->addWidget(m_meterScroll, 2);
meterLayout->addStretch();
meterPanel->setFixedWidth(160);
meterPanel->setFixedWidth(320);
splitter->addWidget(meterPanel);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 0);
@ -175,6 +197,9 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
static bool isAudioEndpoint(const Potato::NodeInfo &node)
{
if (node.name.startsWith("Potato-Meter") || node.name.startsWith("Potato-Node-Meter")) {
return false;
}
return node.mediaClass == Potato::MediaClass::AudioSink
|| node.mediaClass == Potato::MediaClass::AudioSource
|| node.mediaClass == Potato::MediaClass::AudioDuplex
@ -187,6 +212,7 @@ void GraphEditorWidget::syncGraph()
for (const auto &node : nodes) {
if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node);
}
}
@ -215,6 +241,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node)
{
if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node);
}
}
@ -227,11 +254,21 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node)
if (!m_model->updatePipeWireNode(node)) {
m_model->addPipeWireNode(node);
}
refreshNodeMeter(node.id, node);
}
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
{
m_model->removePipeWireNode(nodeId);
m_controller->removeNodeMeter(nodeId);
if (m_nodeMeterRows.contains(nodeId)) {
QWidget *row = m_nodeMeterRows.take(nodeId);
m_nodeMeters.remove(nodeId);
row->deleteLater();
}
}
void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link)
@ -345,4 +382,58 @@ void GraphEditorWidget::updateMeter()
const float peak = m_controller->meterPeak();
m_meter->setLevel(peak);
for (auto it = m_nodeMeters.begin(); it != m_nodeMeters.end(); ++it) {
const uint32_t nodeId = static_cast<uint32_t>(it.key());
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
it.value()->setLevel(nodePeak);
}
}
void GraphEditorWidget::refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node)
{
if (m_nodeMeterRows.contains(nodeId)) {
return;
}
if (!node.name.isEmpty()) {
bool captureSink = node.mediaClass == Potato::MediaClass::AudioSink
|| node.mediaClass == Potato::MediaClass::AudioDuplex;
if (!captureSink) {
for (const auto &port : node.outputPorts) {
if (port.name.contains("monitor", Qt::CaseInsensitive)) {
captureSink = true;
break;
}
}
}
m_controller->ensureNodeMeter(nodeId, node.name, captureSink);
}
auto *row = new QWidget(m_meterList);
auto *rowLayout = new QHBoxLayout(row);
rowLayout->setContentsMargins(0, 0, 0, 0);
rowLayout->setSpacing(8);
const QString title = node.description.isEmpty() ? node.name : node.description;
auto *label = new QLabel(title, row);
label->setStyleSheet("color: #c7cfdd; font-size: 11px; border: none;");
label->setToolTip(title);
const int labelWidth = 250;
label->setFixedWidth(labelWidth);
QFontMetrics metrics(label->font());
label->setText(metrics.elidedText(title, Qt::ElideRight, labelWidth));
auto *meter = new AudioLevelMeter(row);
meter->setMinimumHeight(70);
meter->setFixedWidth(26);
rowLayout->addWidget(label, 1);
rowLayout->addWidget(meter, 0, Qt::AlignRight);
row->setLayout(rowLayout);
m_meterListLayout->addWidget(row);
m_nodeMeters.insert(nodeId, meter);
m_nodeMeterRows.insert(nodeId, row);
}

View file

@ -13,6 +13,8 @@
class AudioLevelMeter;
class QTimer;
class QScrollArea;
class QVBoxLayout;
class GraphEditorWidget : public QWidget
{
@ -30,6 +32,7 @@ private slots:
void onConnectionCreated(QtNodes::ConnectionId const connectionId);
void onConnectionDeleted(QtNodes::ConnectionId const connectionId);
void updateMeter();
void refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node);
private:
void syncGraph();
@ -47,4 +50,9 @@ private:
QMap<uint32_t, QString> m_linkIdToConnection;
AudioLevelMeter *m_meter = nullptr;
QTimer *m_meterTimer = nullptr;
QScrollArea *m_meterScroll = nullptr;
QWidget *m_meterList = nullptr;
QVBoxLayout *m_meterListLayout = nullptr;
QMap<uint32_t, AudioLevelMeter*> m_nodeMeters;
QMap<uint32_t, QWidget*> m_nodeMeterRows;
};

View file

@ -2,6 +2,7 @@
#include <QDebug>
#include <QMutexLocker>
#include <QByteArray>
#include <QString>
#include <QElapsedTimer>
#include <QThread>
#include <cstring>
@ -159,6 +160,58 @@ static const struct pw_stream_events meter_events = []() {
return events;
}();
struct NodeMeter {
uint32_t nodeId;
QString targetName;
pw_stream *stream = nullptr;
std::atomic<float> peak{0.0f};
};
static void nodeMeterProcess(void *data)
{
auto *meter = static_cast<NodeMeter*>(data);
if (!meter || !meter->stream) {
return;
}
struct pw_buffer *buf = pw_stream_dequeue_buffer(meter->stream);
if (!buf || !buf->buffer || buf->buffer->n_datas == 0) {
if (buf) {
pw_stream_queue_buffer(meter->stream, buf);
}
return;
}
struct spa_buffer *spaBuf = buf->buffer;
struct spa_data *data0 = &spaBuf->datas[0];
if (!data0->data || !data0->chunk) {
pw_stream_queue_buffer(meter->stream, buf);
return;
}
const uint32_t size = data0->chunk->size;
const float *samples = static_cast<const float*>(data0->data);
const uint32_t count = size / sizeof(float);
float peak = 0.0f;
for (uint32_t i = 0; i < count; ++i) {
const float value = std::fabs(samples[i]);
if (value > peak) {
peak = value;
}
}
meter->peak.store(peak, std::memory_order_relaxed);
pw_stream_queue_buffer(meter->stream, buf);
}
static const struct pw_stream_events node_meter_events = []() {
struct pw_stream_events events{};
events.version = PW_VERSION_STREAM_EVENTS;
events.process = nodeMeterProcess;
return events;
}();
PipeWireController::PipeWireController(QObject *parent)
: QObject(parent)
{
@ -256,6 +309,18 @@ void PipeWireController::shutdown()
}
teardownMeterStream();
{
QMutexLocker lock(&m_meterMutex);
for (auto it = m_nodeMeters.begin(); it != m_nodeMeters.end(); ++it) {
NodeMeter *meter = it.value();
if (meter && meter->stream) {
pw_stream_destroy(meter->stream);
}
delete meter;
}
m_nodeMeters.clear();
}
if (m_core) {
pw_core_disconnect(m_core);
@ -310,6 +375,115 @@ float PipeWireController::meterPeak() const
return m_meterPeak.load(std::memory_order_relaxed);
}
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
{
QMutexLocker lock(&m_meterMutex);
if (!m_nodeMeters.contains(nodeId)) {
return 0.0f;
}
NodeMeter *meter = m_nodeMeters.value(nodeId);
if (!meter) {
return 0.0f;
}
return meter->peak.load(std::memory_order_relaxed);
}
void PipeWireController::ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink)
{
if (!m_threadLoop || !m_core) {
return;
}
{
QMutexLocker lock(&m_meterMutex);
if (m_nodeMeters.contains(nodeId)) {
return;
}
}
auto *meter = new NodeMeter;
meter->nodeId = nodeId;
meter->targetName = targetName;
const QByteArray targetNameBytes = meter->targetName.toUtf8();
struct pw_properties *props = pw_properties_new(
PW_KEY_MEDIA_TYPE, "Audio",
PW_KEY_MEDIA_CATEGORY, "Capture",
PW_KEY_MEDIA_CLASS, "Stream/Input/Audio",
PW_KEY_TARGET_OBJECT, targetNameBytes.constData(),
PW_KEY_STREAM_MONITOR, "true",
nullptr);
if (captureSink) {
pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true");
}
lock();
meter->stream = pw_stream_new_simple(
pw_thread_loop_get_loop(m_threadLoop),
"Potato-Node-Meter",
props,
&node_meter_events,
meter);
if (!meter->stream) {
pw_properties_free(props);
unlock();
delete meter;
return;
}
uint8_t buffer[512];
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
spa_audio_info_raw info{};
info.format = SPA_AUDIO_FORMAT_F32;
info.rate = 48000;
info.channels = 2;
info.position[0] = SPA_AUDIO_CHANNEL_FL;
info.position[1] = SPA_AUDIO_CHANNEL_FR;
const struct spa_pod *params[1];
params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &info);
const int res = pw_stream_connect(
meter->stream,
PW_DIRECTION_INPUT,
PW_ID_ANY,
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS),
params,
1);
if (res != 0) {
pw_stream_destroy(meter->stream);
unlock();
delete meter;
return;
}
unlock();
QMutexLocker lock(&m_meterMutex);
m_nodeMeters.insert(nodeId, meter);
}
void PipeWireController::removeNodeMeter(uint32_t nodeId)
{
QMutexLocker lock(&m_meterMutex);
if (!m_nodeMeters.contains(nodeId)) {
return;
}
NodeMeter *meter = m_nodeMeters.take(nodeId);
if (meter && meter->stream) {
pw_stream_destroy(meter->stream);
}
delete meter;
}
uint32_t PipeWireController::createLink(uint32_t outputNodeId, uint32_t outputPortId,
uint32_t inputNodeId, uint32_t inputPortId)
{
@ -611,7 +785,9 @@ bool PipeWireController::setupMeterStream()
struct pw_properties *props = pw_properties_new(
PW_KEY_MEDIA_TYPE, "Audio",
PW_KEY_MEDIA_CATEGORY, "Capture",
PW_KEY_MEDIA_CLASS, "Audio/Source",
PW_KEY_MEDIA_CLASS, "Stream/Input/Audio",
PW_KEY_STREAM_CAPTURE_SINK, "true",
PW_KEY_STREAM_MONITOR, "true",
nullptr);
m_meterStream = pw_stream_new_simple(

View file

@ -14,9 +14,10 @@ struct pw_registry;
struct pw_stream;
struct spa_hook;
struct spa_dict;
namespace Potato {
struct NodeMeter;
class PipeWireController : public QObject
{
Q_OBJECT
@ -34,6 +35,9 @@ public:
NodeInfo nodeById(uint32_t id) const;
QVector<LinkInfo> links() const;
float meterPeak() const;
float nodeMeterPeak(uint32_t nodeId) const;
void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink);
void removeNodeMeter(uint32_t nodeId);
uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId,
uint32_t inputNodeId, uint32_t inputPortId);
@ -89,6 +93,9 @@ private:
QAtomicInteger<bool> m_connected{false};
QAtomicInteger<bool> m_initialized{false};
std::atomic<float> m_meterPeak{0.0f};
mutable QMutex m_meterMutex;
QMap<uint32_t, NodeMeter*> m_nodeMeters;
uint32_t m_nodeIdCounter = 0;
uint32_t m_linkIdCounter = 0;