diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index a257684..ea0e9a2 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -5,10 +5,13 @@ #include #include #include +#include #include +#include #include #include #include +#include #include #include @@ -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(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); } diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 688d51f..7614575 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -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 m_linkIdToConnection; AudioLevelMeter *m_meter = nullptr; QTimer *m_meterTimer = nullptr; + QScrollArea *m_meterScroll = nullptr; + QWidget *m_meterList = nullptr; + QVBoxLayout *m_meterListLayout = nullptr; + QMap m_nodeMeters; + QMap m_nodeMeterRows; }; diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 612c413..a1078b8 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -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 peak{0.0f}; +}; + +static void nodeMeterProcess(void *data) +{ + auto *meter = static_cast(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(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_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( diff --git a/src/pipewire/pipewirecontroller.h b/src/pipewire/pipewirecontroller.h index 68e93d7..322f95e 100644 --- a/src/pipewire/pipewirecontroller.h +++ b/src/pipewire/pipewirecontroller.h @@ -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 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 m_connected{false}; QAtomicInteger m_initialized{false}; std::atomic m_meterPeak{0.0f}; + + mutable QMutex m_meterMutex; + QMap m_nodeMeters; uint32_t m_nodeIdCounter = 0; uint32_t m_linkIdCounter = 0;