diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 42001f0..a297859 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1215,12 +1215,12 @@ private: - [x] Create 30Hz update timer with manual viewport control - [x] Integrate PipeWire audio callbacks for meter data - [x] Implement lock-free meter data transfer (atomics) -- [ ] Profile and optimize rendering performance +- [x] Profile and optimize rendering performance - [ ] **Acceptance Criteria:** Smooth 30Hz meters with no GUI lag, validated with profiler ### Milestone 4: Virtual Devices & State Management **Estimated Time:** 2 weeks -- [ ] Implement virtual sink/source creation via PipeWire adapters +- [x] Implement virtual sink/source creation via PipeWire adapters - [ ] Create `PresetManager` with JSON serialization - [ ] Implement preset load/save functionality - [ ] Store UI layout alongside audio graph state diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index e76dbf9..056ff5e 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -14,6 +15,8 @@ #include #include #include +#include +#include #include #include @@ -172,6 +175,28 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi }); m_view->addAction(resetLayoutAction); + auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view); + connect(createVirtualSinkAction, &QAction::triggered, [this]() { + const int index = ++m_virtualSinkCount; + const QString name = QString("Potato_Virtual_Sink_%1").arg(index); + const QString description = QString("Virtual Sink %1").arg(index); + if (!m_controller->createVirtualSink(name, description, 2, 48000)) { + qWarning() << "Failed to create virtual sink" << name; + } + }); + m_view->addAction(createVirtualSinkAction); + + auto *createVirtualSourceAction = new QAction(QString("Create Virtual Source"), m_view); + connect(createVirtualSourceAction, &QAction::triggered, [this]() { + const int index = ++m_virtualSourceCount; + const QString name = QString("Potato_Virtual_Source_%1").arg(index); + const QString description = QString("Virtual Source %1").arg(index); + if (!m_controller->createVirtualSource(name, description, 2, 48000)) { + qWarning() << "Failed to create virtual source" << name; + } + }); + m_view->addAction(createVirtualSourceAction); + syncGraph(); if (m_model->hasOverlaps()) { m_model->autoArrange(); @@ -205,6 +230,9 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_meterTimer->setTimerType(Qt::PreciseTimer); connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter); m_meterTimer->start(); + + m_meterProfileTimer.start(); + m_meterProfileReady = true; } static bool isAudioEndpoint(const Potato::NodeInfo &node) @@ -383,6 +411,7 @@ void GraphEditorWidget::onConnectionCreated(QtNodes::ConnectionId const connecti const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId); if (linkId == 0) { + m_model->deleteConnection(connectionId); return; } @@ -435,6 +464,12 @@ void GraphEditorWidget::updateMeter() return; } + if (!isVisible()) { + return; + } + + const qint64 startNanos = m_meterProfileReady ? m_meterProfileTimer.nsecsElapsed() : 0; + const float peak = m_controller->meterPeak(); m_meter->setLevel(peak); @@ -443,6 +478,21 @@ void GraphEditorWidget::updateMeter() const float nodePeak = m_controller->nodeMeterPeak(nodeId); it.value()->setLevel(nodePeak); } + + if (m_meterProfileReady) { + const qint64 duration = m_meterProfileTimer.nsecsElapsed() - startNanos; + m_meterProfileNanos += duration; + m_meterProfileMax = std::max(m_meterProfileMax, duration); + ++m_meterProfileFrames; + if (m_meterProfileFrames >= 300) { + const double avgMs = static_cast(m_meterProfileNanos) / (1000000.0 * m_meterProfileFrames); + const double maxMs = static_cast(m_meterProfileMax) / 1000000.0; + qInfo() << "Meter update avg" << avgMs << "ms max" << maxMs << "ms"; + m_meterProfileNanos = 0; + m_meterProfileMax = 0; + m_meterProfileFrames = 0; + } + } } void GraphEditorWidget::updateLayoutState() diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 0a7ecb3..a9b6600 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -9,6 +9,7 @@ #include #include #include +#include #include class AudioLevelMeter; @@ -59,6 +60,8 @@ private: QSet m_ignoreLinkRemoved; QMap m_connectionToLinkId; QMap m_linkIdToConnection; + int m_virtualSinkCount = 0; + int m_virtualSourceCount = 0; AudioLevelMeter *m_meter = nullptr; QTimer *m_meterTimer = nullptr; QScrollArea *m_meterScroll = nullptr; @@ -69,4 +72,9 @@ private: QMap m_nodeMeterLabels; QMap m_nodeLinkCounts; QMap m_linksById; + QElapsedTimer m_meterProfileTimer; + bool m_meterProfileReady = false; + qint64 m_meterProfileNanos = 0; + qint64 m_meterProfileMax = 0; + int m_meterProfileFrames = 0; }; diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 790ff45..14bbe9b 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include namespace Potato { @@ -76,6 +77,56 @@ static bool readRingLatest(spa_ringbuffer *ring, std::vector &buffer, f return true; } +bool PipeWireController::createVirtualDevice(const QString &name, + const QString &description, + const char *factoryName, + const char *mediaClass, + int channels, + int rate) +{ + if (!m_threadLoop || !m_core || name.isEmpty()) { + return false; + } + + channels = channels > 0 ? channels : 2; + rate = rate > 0 ? rate : 48000; + + const QByteArray nameBytes = name.toUtf8(); + const QByteArray descBytes = description.isEmpty() ? nameBytes : description.toUtf8(); + const QByteArray channelsBytes = QByteArray::number(channels); + const QByteArray rateBytes = QByteArray::number(rate); + + struct spa_dict_item items[] = { + { PW_KEY_FACTORY_NAME, factoryName }, + { PW_KEY_NODE_NAME, nameBytes.constData() }, + { PW_KEY_NODE_DESCRIPTION, descBytes.constData() }, + { PW_KEY_MEDIA_CLASS, mediaClass }, + { PW_KEY_AUDIO_CHANNELS, channelsBytes.constData() }, + { PW_KEY_AUDIO_RATE, rateBytes.constData() }, + { "object.linger", "true" }, + { PW_KEY_APP_NAME, "Potato-Manager" } + }; + + struct spa_dict dict = SPA_DICT_INIT(items, SPA_N_ELEMENTS(items)); + + lock(); + auto *proxy = static_cast(pw_core_create_object( + m_core, + "adapter", + PW_TYPE_INTERFACE_Node, + PW_VERSION_NODE, + &dict, + 0)); + unlock(); + + if (!proxy) { + return false; + } + + m_virtualDevices.push_back(proxy); + return true; +} + static QString toQString(const char *value) { if (!value) { @@ -382,6 +433,13 @@ void PipeWireController::shutdown() } m_nodeMeters.clear(); } + + for (auto *proxy : m_virtualDevices) { + if (proxy) { + pw_proxy_destroy(proxy); + } + } + m_virtualDevices.clear(); if (m_core) { pw_core_disconnect(m_core); @@ -480,6 +538,16 @@ bool PipeWireController::setNodeVolume(uint32_t nodeId, float volume, bool mute) return true; } +bool PipeWireController::createVirtualSink(const QString &name, const QString &description, int channels, int rate) +{ + return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Sink", channels, rate); +} + +bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate) +{ + return createVirtualDevice(name, description, "support.null-audio-source", "Audio/Source", channels, rate); +} + float PipeWireController::nodeMeterPeak(uint32_t nodeId) const { QMutexLocker lock(&m_meterMutex); @@ -829,10 +897,12 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop uint32_t nodeId = nodeIdStr ? static_cast(atoi(nodeIdStr)) : 0; PortInfo port(id, nodeId, portName, direction); + bool emitChanged = false; + NodeInfo nodeSnapshot; { QMutexLocker lock(&m_nodesMutex); m_ports.insert(id, port); - + if (nodeId != 0 && m_nodes.contains(nodeId)) { NodeInfo &node = m_nodes[nodeId]; auto &ports = (direction == PW_DIRECTION_INPUT) ? node.inputPorts : node.outputPorts; @@ -843,9 +913,15 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop } } ports.append(port); + nodeSnapshot = node; + emitChanged = true; } } - + + if (emitChanged) { + emit nodeChanged(nodeSnapshot); + } + qDebug() << "Port added:" << id << portName << "direction:" << direction; } diff --git a/src/pipewire/pipewirecontroller.h b/src/pipewire/pipewirecontroller.h index 06faca4..5cd88a1 100644 --- a/src/pipewire/pipewirecontroller.h +++ b/src/pipewire/pipewirecontroller.h @@ -15,6 +15,7 @@ struct pw_context; struct pw_core; struct pw_registry; struct pw_stream; +struct pw_proxy; struct spa_hook; struct spa_dict; namespace Potato { @@ -42,6 +43,8 @@ public: void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink); void removeNodeMeter(uint32_t nodeId); bool setNodeVolume(uint32_t nodeId, float volume, bool mute); + bool createVirtualSink(const QString &name, const QString &description, int channels, int rate); + bool createVirtualSource(const QString &name, const QString &description, int channels, int rate); uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId, uint32_t inputNodeId, uint32_t inputPortId); @@ -76,6 +79,9 @@ private: void handleLinkInfo(uint32_t id, const struct ::spa_dict *props); bool setupMeterStream(); void teardownMeterStream(); + bool createVirtualDevice(const QString &name, const QString &description, + const char *factoryName, const char *mediaClass, + int channels, int rate); void lock(); void unlock(); @@ -101,6 +107,8 @@ private: mutable std::vector m_meterRingData; std::atomic m_meterRingReady{false}; + std::vector m_virtualDevices; + mutable QMutex m_meterMutex; QMap m_nodeMeters;