Add UI
This commit is contained in:
parent
7f6df30c9e
commit
6d74ef422d
5 changed files with 160 additions and 7 deletions
|
|
@ -1197,7 +1197,7 @@ private:
|
||||||
- [x] Integrate libpipewire with `pw_thread_loop`
|
- [x] Integrate libpipewire with `pw_thread_loop`
|
||||||
- [x] Implement node/port discovery via registry callbacks
|
- [x] Implement node/port discovery via registry callbacks
|
||||||
- [x] Implement link creation/destruction
|
- [x] Implement link creation/destruction
|
||||||
- [ ] Create lock-free communication primitives (atomics, ring buffers)
|
- [x] Create lock-free communication primitives (atomics, ring buffers)
|
||||||
- [x] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
|
- [x] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
|
||||||
|
|
||||||
### Milestone 2: QtNodes Integration
|
### Milestone 2: QtNodes Integration
|
||||||
|
|
@ -1205,8 +1205,8 @@ private:
|
||||||
- [x] Integrate QtNodes library (submodule or CMake package)
|
- [x] Integrate QtNodes library (submodule or CMake package)
|
||||||
- [x] Create `AudioNodeDataModel` for PipeWire nodes
|
- [x] Create `AudioNodeDataModel` for PipeWire nodes
|
||||||
- [x] Map PipeWire ports to QtNodes handles
|
- [x] Map PipeWire ports to QtNodes handles
|
||||||
- [ ] Implement connection validation
|
- [x] Implement connection validation
|
||||||
- [ ] Create custom node widgets with embedded controls
|
- [x] Create custom node widgets with embedded controls
|
||||||
- [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
|
- [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
|
||||||
|
|
||||||
### Milestone 3: Real-Time Meters & Performance
|
### Milestone 3: Real-Time Meters & Performance
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QSizePolicy>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
#include <QtNodes/NodeStyle>
|
#include <QtNodes/NodeStyle>
|
||||||
#include <QtNodes/StyleCollection>
|
#include <QtNodes/StyleCollection>
|
||||||
|
|
@ -108,6 +113,51 @@ PipeWireGraphModel::PipeWireGraphModel(Potato::PipeWireController *controller, Q
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const
|
||||||
|
{
|
||||||
|
auto it = m_nodeWidgets.find(nodeId);
|
||||||
|
if (it != m_nodeWidgets.end()) {
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *widget = new QWidget();
|
||||||
|
auto *layout = new QHBoxLayout(widget);
|
||||||
|
layout->setContentsMargins(6, 2, 6, 2);
|
||||||
|
layout->setSpacing(6);
|
||||||
|
|
||||||
|
auto *muteButton = new QToolButton(widget);
|
||||||
|
muteButton->setText("M");
|
||||||
|
muteButton->setCheckable(true);
|
||||||
|
muteButton->setFixedSize(20, 20);
|
||||||
|
muteButton->setToolTip(QString("Mute (UI only)"));
|
||||||
|
|
||||||
|
auto *slider = new QSlider(Qt::Horizontal, widget);
|
||||||
|
slider->setRange(0, 100);
|
||||||
|
slider->setValue(100);
|
||||||
|
slider->setFixedHeight(18);
|
||||||
|
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||||
|
slider->setToolTip(QString("Volume (UI only)"));
|
||||||
|
|
||||||
|
layout->addWidget(muteButton);
|
||||||
|
layout->addWidget(slider);
|
||||||
|
|
||||||
|
widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
|
||||||
|
widget->setFixedHeight(26);
|
||||||
|
widget->setStyleSheet(
|
||||||
|
"QToolButton { background: #2b2f38; color: #dbe2ee; border: 1px solid #39404c; border-radius: 4px; font-weight: 700; }"
|
||||||
|
"QToolButton:checked { background: #3c4350; color: #ffcf7a; }"
|
||||||
|
"QSlider::groove:horizontal { height: 4px; background: #2b2f38; border-radius: 2px; }"
|
||||||
|
"QSlider::sub-page:horizontal { background: #5fcf8d; border-radius: 2px; }"
|
||||||
|
"QSlider::add-page:horizontal { background: #2b2f38; border-radius: 2px; }"
|
||||||
|
"QSlider::handle:horizontal { width: 10px; margin: -6px 0; background: #dbe2ee; border-radius: 5px; }"
|
||||||
|
);
|
||||||
|
|
||||||
|
widget->adjustSize();
|
||||||
|
|
||||||
|
m_nodeWidgets.emplace(nodeId, widget);
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
QtNodes::NodeId PipeWireGraphModel::newNodeId()
|
QtNodes::NodeId PipeWireGraphModel::newNodeId()
|
||||||
{
|
{
|
||||||
return m_nextNodeId++;
|
return m_nextNodeId++;
|
||||||
|
|
@ -210,6 +260,16 @@ bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connecti
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto &outPort = outInfo.outputPorts.at(connectionId.outPortIndex);
|
||||||
|
const auto &inPort = inInfo.inputPorts.at(connectionId.inPortIndex);
|
||||||
|
if (outPort.direction != SPA_DIRECTION_OUTPUT || inPort.direction != SPA_DIRECTION_INPUT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outInfo.mediaClass != inInfo.mediaClass) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,6 +317,10 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
|
||||||
}
|
}
|
||||||
case QtNodes::NodeRole::Size:
|
case QtNodes::NodeRole::Size:
|
||||||
{
|
{
|
||||||
|
auto sizeIt = m_nodeSizes.find(nodeId);
|
||||||
|
if (sizeIt != m_nodeSizes.end()) {
|
||||||
|
return sizeIt->second;
|
||||||
|
}
|
||||||
const int maxPorts = std::max(info.inputPorts.size(), info.outputPorts.size());
|
const int maxPorts = std::max(info.inputPorts.size(), info.outputPorts.size());
|
||||||
const int baseHeight = 50;
|
const int baseHeight = 50;
|
||||||
const int perPortHeight = 28;
|
const int perPortHeight = 28;
|
||||||
|
|
@ -274,6 +338,8 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
|
||||||
return QString("PipeWire");
|
return QString("PipeWire");
|
||||||
case QtNodes::NodeRole::Style:
|
case QtNodes::NodeRole::Style:
|
||||||
return nodeStyleVariant(info);
|
return nodeStyleVariant(info);
|
||||||
|
case QtNodes::NodeRole::Widget:
|
||||||
|
return QVariant::fromValue(nodeWidget(nodeId));
|
||||||
default:
|
default:
|
||||||
return QVariant();
|
return QVariant();
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +359,11 @@ bool PipeWireGraphModel::setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole r
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role == QtNodes::NodeRole::Size) {
|
||||||
|
m_nodeSizes[nodeId] = value.toSize();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,9 +404,6 @@ QVariant PipeWireGraphModel::portData(QtNodes::NodeId nodeId,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
|
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
|
||||||
if (portType == QtNodes::PortType::In) {
|
|
||||||
return QVariant::fromValue(QtNodes::ConnectionPolicy::One);
|
|
||||||
}
|
|
||||||
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
|
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +447,8 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
|
||||||
|
|
||||||
m_nodes.erase(nodeId);
|
m_nodes.erase(nodeId);
|
||||||
m_positions.erase(nodeId);
|
m_positions.erase(nodeId);
|
||||||
|
m_nodeSizes.erase(nodeId);
|
||||||
|
m_nodeWidgets.erase(nodeId);
|
||||||
Q_EMIT nodeDeleted(nodeId);
|
Q_EMIT nodeDeleted(nodeId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -476,6 +546,7 @@ bool PipeWireGraphModel::updatePipeWireNode(const Potato::NodeInfo &node)
|
||||||
|
|
||||||
const QtNodes::NodeId nodeId = it->second;
|
const QtNodes::NodeId nodeId = it->second;
|
||||||
m_nodes[nodeId] = node;
|
m_nodes[nodeId] = node;
|
||||||
|
m_nodeSizes.erase(nodeId);
|
||||||
Q_EMIT nodeUpdated(nodeId);
|
Q_EMIT nodeUpdated(nodeId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +574,8 @@ void PipeWireGraphModel::reset()
|
||||||
m_nodes.clear();
|
m_nodes.clear();
|
||||||
m_pwToNode.clear();
|
m_pwToNode.clear();
|
||||||
m_positions.clear();
|
m_positions.clear();
|
||||||
|
m_nodeSizes.clear();
|
||||||
|
m_nodeWidgets.clear();
|
||||||
m_nextNodeId = 1;
|
m_nextNodeId = 1;
|
||||||
Q_EMIT modelReset();
|
Q_EMIT modelReset();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
class PipeWireGraphModel : public QtNodes::AbstractGraphModel
|
class PipeWireGraphModel : public QtNodes::AbstractGraphModel
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
@ -72,6 +74,7 @@ public:
|
||||||
bool splitterSizes(QList<int> &sizes) const;
|
bool splitterSizes(QList<int> &sizes) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
|
||||||
QtNodes::ConnectionId connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const;
|
QtNodes::ConnectionId connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const;
|
||||||
bool findPortIndex(const Potato::NodeInfo &node, uint32_t portId, QtNodes::PortType type, QtNodes::PortIndex &index) const;
|
bool findPortIndex(const Potato::NodeInfo &node, uint32_t portId, QtNodes::PortType type, QtNodes::PortIndex &index) const;
|
||||||
QString portLabel(const Potato::PortInfo &port) const;
|
QString portLabel(const Potato::PortInfo &port) const;
|
||||||
|
|
@ -95,4 +98,6 @@ private:
|
||||||
bool m_hasViewState = false;
|
bool m_hasViewState = false;
|
||||||
QList<int> m_splitterSizes;
|
QList<int> m_splitterSizes;
|
||||||
bool m_hasSplitterSizes = false;
|
bool m_hasSplitterSizes = false;
|
||||||
|
mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets;
|
||||||
|
std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,59 @@
|
||||||
|
|
||||||
namespace Potato {
|
namespace Potato {
|
||||||
|
|
||||||
|
static constexpr uint32_t kMeterRingCapacityBytes = 4096;
|
||||||
|
|
||||||
|
static void writeRingValue(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, float value)
|
||||||
|
{
|
||||||
|
if (!ring || buffer.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t index = 0;
|
||||||
|
const uint32_t avail = spa_ringbuffer_get_write_index(ring, &index);
|
||||||
|
if (avail < sizeof(float)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t size = static_cast<uint32_t>(buffer.size());
|
||||||
|
uint32_t offset = index & (size - 1);
|
||||||
|
if (offset + sizeof(float) <= size) {
|
||||||
|
std::memcpy(buffer.data() + offset, &value, sizeof(float));
|
||||||
|
} else {
|
||||||
|
const uint32_t first = size - offset;
|
||||||
|
std::memcpy(buffer.data() + offset, &value, first);
|
||||||
|
std::memcpy(buffer.data(), reinterpret_cast<const uint8_t*>(&value) + first, sizeof(float) - first);
|
||||||
|
}
|
||||||
|
spa_ringbuffer_write_update(ring, index + sizeof(float));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool readRingLatest(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, float &value)
|
||||||
|
{
|
||||||
|
if (!ring || buffer.empty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t index = 0;
|
||||||
|
const uint32_t avail = spa_ringbuffer_get_read_index(ring, &index);
|
||||||
|
if (avail < sizeof(float)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t size = static_cast<uint32_t>(buffer.size());
|
||||||
|
const uint32_t latestIndex = index + (avail - sizeof(float));
|
||||||
|
uint32_t offset = latestIndex & (size - 1);
|
||||||
|
if (offset + sizeof(float) <= size) {
|
||||||
|
std::memcpy(&value, buffer.data() + offset, sizeof(float));
|
||||||
|
} else {
|
||||||
|
const uint32_t first = size - offset;
|
||||||
|
std::memcpy(&value, buffer.data() + offset, first);
|
||||||
|
std::memcpy(reinterpret_cast<uint8_t*>(&value) + first, buffer.data(), sizeof(float) - first);
|
||||||
|
}
|
||||||
|
|
||||||
|
spa_ringbuffer_read_update(ring, index + avail);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static QString toQString(const char *value)
|
static QString toQString(const char *value)
|
||||||
{
|
{
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -150,6 +203,9 @@ void meterProcess(void *data)
|
||||||
}
|
}
|
||||||
|
|
||||||
self->m_meterPeak.store(peak, std::memory_order_relaxed);
|
self->m_meterPeak.store(peak, std::memory_order_relaxed);
|
||||||
|
if (self->m_meterRingReady.load(std::memory_order_relaxed)) {
|
||||||
|
writeRingValue(&self->m_meterRing, self->m_meterRingData, peak);
|
||||||
|
}
|
||||||
pw_stream_queue_buffer(self->m_meterStream, buf);
|
pw_stream_queue_buffer(self->m_meterStream, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +273,9 @@ PipeWireController::PipeWireController(QObject *parent)
|
||||||
{
|
{
|
||||||
m_registryListener = new spa_hook;
|
m_registryListener = new spa_hook;
|
||||||
m_coreListener = new spa_hook;
|
m_coreListener = new spa_hook;
|
||||||
|
m_meterRingData.resize(kMeterRingCapacityBytes);
|
||||||
|
spa_ringbuffer_init(&m_meterRing);
|
||||||
|
m_meterRingReady.store(true, std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
PipeWireController::~PipeWireController()
|
PipeWireController::~PipeWireController()
|
||||||
|
|
@ -340,6 +399,9 @@ void PipeWireController::shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
pw_deinit();
|
pw_deinit();
|
||||||
|
|
||||||
|
m_meterRingReady.store(false, std::memory_order_relaxed);
|
||||||
|
m_meterRingData.clear();
|
||||||
|
|
||||||
m_initialized.storeRelaxed(false);
|
m_initialized.storeRelaxed(false);
|
||||||
m_connected.storeRelaxed(false);
|
m_connected.storeRelaxed(false);
|
||||||
|
|
@ -372,7 +434,14 @@ QVector<LinkInfo> PipeWireController::links() const
|
||||||
|
|
||||||
float PipeWireController::meterPeak() const
|
float PipeWireController::meterPeak() const
|
||||||
{
|
{
|
||||||
return m_meterPeak.load(std::memory_order_relaxed);
|
float peak = m_meterPeak.load(std::memory_order_relaxed);
|
||||||
|
if (m_meterRingReady.load(std::memory_order_relaxed)) {
|
||||||
|
float ringPeak = 0.0f;
|
||||||
|
if (readRingLatest(&m_meterRing, m_meterRingData, ringPeak)) {
|
||||||
|
peak = ringPeak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
}
|
}
|
||||||
|
|
||||||
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
|
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
#include <QAtomicInteger>
|
#include <QAtomicInteger>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <spa/utils/ringbuffer.h>
|
||||||
|
|
||||||
struct pw_thread_loop;
|
struct pw_thread_loop;
|
||||||
struct pw_context;
|
struct pw_context;
|
||||||
|
|
@ -93,6 +96,9 @@ private:
|
||||||
QAtomicInteger<bool> m_connected{false};
|
QAtomicInteger<bool> m_connected{false};
|
||||||
QAtomicInteger<bool> m_initialized{false};
|
QAtomicInteger<bool> m_initialized{false};
|
||||||
std::atomic<float> m_meterPeak{0.0f};
|
std::atomic<float> m_meterPeak{0.0f};
|
||||||
|
mutable spa_ringbuffer m_meterRing{};
|
||||||
|
mutable std::vector<uint8_t> m_meterRingData;
|
||||||
|
std::atomic<bool> m_meterRingReady{false};
|
||||||
|
|
||||||
mutable QMutex m_meterMutex;
|
mutable QMutex m_meterMutex;
|
||||||
QMap<uint32_t, NodeMeter*> m_nodeMeters;
|
QMap<uint32_t, NodeMeter*> m_nodeMeters;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue