This commit is contained in:
Joey Yakimowich-Payne 2026-01-27 19:34:30 -07:00
commit ecfb59501a
5 changed files with 146 additions and 4 deletions

View file

@ -1215,12 +1215,12 @@ private:
- [x] Create 30Hz update timer with manual viewport control - [x] Create 30Hz update timer with manual viewport control
- [x] Integrate PipeWire audio callbacks for meter data - [x] Integrate PipeWire audio callbacks for meter data
- [x] Implement lock-free meter data transfer (atomics) - [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 - [ ] **Acceptance Criteria:** Smooth 30Hz meters with no GUI lag, validated with profiler
### Milestone 4: Virtual Devices & State Management ### Milestone 4: Virtual Devices & State Management
**Estimated Time:** 2 weeks **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 - [ ] Create `PresetManager` with JSON serialization
- [ ] Implement preset load/save functionality - [ ] Implement preset load/save functionality
- [ ] Store UI layout alongside audio graph state - [ ] Store UI layout alongside audio graph state

View file

@ -3,6 +3,7 @@
#include <QAction> #include <QAction>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDebug>
#include <QColor> #include <QColor>
#include <QFileDialog> #include <QFileDialog>
#include <QFontMetrics> #include <QFontMetrics>
@ -14,6 +15,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QScrollArea> #include <QScrollArea>
#include <QSizePolicy> #include <QSizePolicy>
#include <QElapsedTimer>
#include <algorithm>
#include <QtNodes/GraphicsViewStyle> #include <QtNodes/GraphicsViewStyle>
#include <QtNodes/NodeStyle> #include <QtNodes/NodeStyle>
@ -172,6 +175,28 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
}); });
m_view->addAction(resetLayoutAction); 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(); syncGraph();
if (m_model->hasOverlaps()) { if (m_model->hasOverlaps()) {
m_model->autoArrange(); m_model->autoArrange();
@ -205,6 +230,9 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_meterTimer->setTimerType(Qt::PreciseTimer); m_meterTimer->setTimerType(Qt::PreciseTimer);
connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter); connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter);
m_meterTimer->start(); m_meterTimer->start();
m_meterProfileTimer.start();
m_meterProfileReady = true;
} }
static bool isAudioEndpoint(const Potato::NodeInfo &node) 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); const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId);
if (linkId == 0) { if (linkId == 0) {
m_model->deleteConnection(connectionId);
return; return;
} }
@ -435,6 +464,12 @@ void GraphEditorWidget::updateMeter()
return; return;
} }
if (!isVisible()) {
return;
}
const qint64 startNanos = m_meterProfileReady ? m_meterProfileTimer.nsecsElapsed() : 0;
const float peak = m_controller->meterPeak(); const float peak = m_controller->meterPeak();
m_meter->setLevel(peak); m_meter->setLevel(peak);
@ -443,6 +478,21 @@ void GraphEditorWidget::updateMeter()
const float nodePeak = m_controller->nodeMeterPeak(nodeId); const float nodePeak = m_controller->nodeMeterPeak(nodeId);
it.value()->setLevel(nodePeak); 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<double>(m_meterProfileNanos) / (1000000.0 * m_meterProfileFrames);
const double maxMs = static_cast<double>(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() void GraphEditorWidget::updateLayoutState()

View file

@ -9,6 +9,7 @@
#include <QWidget> #include <QWidget>
#include <QSet> #include <QSet>
#include <QMap> #include <QMap>
#include <QElapsedTimer>
#include <cstdint> #include <cstdint>
class AudioLevelMeter; class AudioLevelMeter;
@ -59,6 +60,8 @@ private:
QSet<uint32_t> m_ignoreLinkRemoved; QSet<uint32_t> m_ignoreLinkRemoved;
QMap<QString, uint32_t> m_connectionToLinkId; QMap<QString, uint32_t> m_connectionToLinkId;
QMap<uint32_t, QString> m_linkIdToConnection; QMap<uint32_t, QString> m_linkIdToConnection;
int m_virtualSinkCount = 0;
int m_virtualSourceCount = 0;
AudioLevelMeter *m_meter = nullptr; AudioLevelMeter *m_meter = nullptr;
QTimer *m_meterTimer = nullptr; QTimer *m_meterTimer = nullptr;
QScrollArea *m_meterScroll = nullptr; QScrollArea *m_meterScroll = nullptr;
@ -69,4 +72,9 @@ private:
QMap<uint32_t, QLabel*> m_nodeMeterLabels; QMap<uint32_t, QLabel*> m_nodeMeterLabels;
QMap<uint32_t, int> m_nodeLinkCounts; QMap<uint32_t, int> m_nodeLinkCounts;
QMap<uint32_t, Potato::LinkInfo> m_linksById; QMap<uint32_t, Potato::LinkInfo> m_linksById;
QElapsedTimer m_meterProfileTimer;
bool m_meterProfileReady = false;
qint64 m_meterProfileNanos = 0;
qint64 m_meterProfileMax = 0;
int m_meterProfileFrames = 0;
}; };

View file

@ -19,6 +19,7 @@
#include <spa/param/audio/format-utils.h> #include <spa/param/audio/format-utils.h>
#include <spa/param/audio/raw.h> #include <spa/param/audio/raw.h>
#include <spa/utils/dict.h> #include <spa/utils/dict.h>
#include <spa/utils/defs.h>
#include <spa/utils/type-info.h> #include <spa/utils/type-info.h>
namespace Potato { namespace Potato {
@ -76,6 +77,56 @@ static bool readRingLatest(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, f
return true; 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<struct pw_proxy*>(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) static QString toQString(const char *value)
{ {
if (!value) { if (!value) {
@ -382,6 +433,13 @@ void PipeWireController::shutdown()
} }
m_nodeMeters.clear(); m_nodeMeters.clear();
} }
for (auto *proxy : m_virtualDevices) {
if (proxy) {
pw_proxy_destroy(proxy);
}
}
m_virtualDevices.clear();
if (m_core) { if (m_core) {
pw_core_disconnect(m_core); pw_core_disconnect(m_core);
@ -480,6 +538,16 @@ bool PipeWireController::setNodeVolume(uint32_t nodeId, float volume, bool mute)
return true; 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 float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
{ {
QMutexLocker lock(&m_meterMutex); 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<uint32_t>(atoi(nodeIdStr)) : 0; uint32_t nodeId = nodeIdStr ? static_cast<uint32_t>(atoi(nodeIdStr)) : 0;
PortInfo port(id, nodeId, portName, direction); PortInfo port(id, nodeId, portName, direction);
bool emitChanged = false;
NodeInfo nodeSnapshot;
{ {
QMutexLocker lock(&m_nodesMutex); QMutexLocker lock(&m_nodesMutex);
m_ports.insert(id, port); m_ports.insert(id, port);
if (nodeId != 0 && m_nodes.contains(nodeId)) { if (nodeId != 0 && m_nodes.contains(nodeId)) {
NodeInfo &node = m_nodes[nodeId]; NodeInfo &node = m_nodes[nodeId];
auto &ports = (direction == PW_DIRECTION_INPUT) ? node.inputPorts : node.outputPorts; 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); ports.append(port);
nodeSnapshot = node;
emitChanged = true;
} }
} }
if (emitChanged) {
emit nodeChanged(nodeSnapshot);
}
qDebug() << "Port added:" << id << portName << "direction:" << direction; qDebug() << "Port added:" << id << portName << "direction:" << direction;
} }

View file

@ -15,6 +15,7 @@ struct pw_context;
struct pw_core; struct pw_core;
struct pw_registry; struct pw_registry;
struct pw_stream; struct pw_stream;
struct pw_proxy;
struct spa_hook; struct spa_hook;
struct spa_dict; struct spa_dict;
namespace Potato { namespace Potato {
@ -42,6 +43,8 @@ public:
void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink); void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink);
void removeNodeMeter(uint32_t nodeId); void removeNodeMeter(uint32_t nodeId);
bool setNodeVolume(uint32_t nodeId, float volume, bool mute); 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 createLink(uint32_t outputNodeId, uint32_t outputPortId,
uint32_t inputNodeId, uint32_t inputPortId); uint32_t inputNodeId, uint32_t inputPortId);
@ -76,6 +79,9 @@ private:
void handleLinkInfo(uint32_t id, const struct ::spa_dict *props); void handleLinkInfo(uint32_t id, const struct ::spa_dict *props);
bool setupMeterStream(); bool setupMeterStream();
void teardownMeterStream(); void teardownMeterStream();
bool createVirtualDevice(const QString &name, const QString &description,
const char *factoryName, const char *mediaClass,
int channels, int rate);
void lock(); void lock();
void unlock(); void unlock();
@ -101,6 +107,8 @@ private:
mutable std::vector<uint8_t> m_meterRingData; mutable std::vector<uint8_t> m_meterRingData;
std::atomic<bool> m_meterRingReady{false}; std::atomic<bool> m_meterRingReady{false};
std::vector<struct pw_proxy*> m_virtualDevices;
mutable QMutex m_meterMutex; mutable QMutex m_meterMutex;
QMap<uint32_t, NodeMeter*> m_nodeMeters; QMap<uint32_t, NodeMeter*> m_nodeMeters;