From 8e69c26688fcb3f0832d1fdc1e617b8334c2a129 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 18:24:36 -0700 Subject: [PATCH 01/10] Delete working now --- src/pipewire/pipewirecontroller.cpp | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 58c2079..9108939 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -609,21 +609,8 @@ bool PipeWireController::destroyLink(uint32_t linkId) unlock(); - QElapsedTimer timer; - timer.start(); - while (timer.elapsed() < 2000) { - { - QMutexLocker lock(&m_nodesMutex); - if (!m_links.contains(linkId)) { - qInfo() << "Link destroyed:" << linkId; - return true; - } - } - QThread::msleep(10); - } - - qWarning() << "Link destroy requested but ID still present" << linkId; - return false; + qInfo() << "Link destroy requested:" << linkId; + return true; } QString PipeWireController::dumpGraph() const From 7f6df30c9e9218d64f00e5d15d2689e434a6bd8d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 18:34:17 -0700 Subject: [PATCH 02/10] Add progress --- PROJECT_PLAN.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index f719b11..ea40863 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1191,34 +1191,34 @@ private: ## 11. Implementation Milestone Plan -### Milestone 1: Core PipeWire Integration ✅ +### Milestone 1: Core PipeWire Integration **Estimated Time:** 2-3 weeks -- [ ] Initialize Qt6 project with CMake -- [ ] Integrate libpipewire with `pw_thread_loop` -- [ ] Implement node/port discovery via registry callbacks -- [ ] Implement link creation/destruction +- [x] Initialize Qt6 project with CMake +- [x] Integrate libpipewire with `pw_thread_loop` +- [x] Implement node/port discovery via registry callbacks +- [x] Implement link creation/destruction - [ ] Create lock-free communication primitives (atomics, ring buffers) -- [ ] **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 **Estimated Time:** 2-3 weeks -- [ ] Integrate QtNodes library (submodule or CMake package) -- [ ] Create `AudioNodeDataModel` for PipeWire nodes -- [ ] Map PipeWire ports to QtNodes handles +- [x] Integrate QtNodes library (submodule or CMake package) +- [x] Create `AudioNodeDataModel` for PipeWire nodes +- [x] Map PipeWire ports to QtNodes handles - [ ] Implement connection validation - [ ] Create custom node widgets with embedded controls -- [ ] **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 **Estimated Time:** 1-2 weeks -- [ ] Implement `AudioLevelMeter` with optimized QGraphicsView -- [ ] Create 30Hz update timer with manual viewport control -- [ ] Integrate PipeWire audio callbacks for meter data -- [ ] Implement lock-free meter data transfer (atomics) +- [x] Implement `AudioLevelMeter` with optimized QGraphicsView +- [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 - [ ] **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 - [ ] Implement virtual sink/source creation via PipeWire adapters - [ ] Create `PresetManager` with JSON serialization @@ -1227,7 +1227,7 @@ private: - [ ] Implement auto-reconnect for device hotplug - [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart -### Milestone 5: Mixer View & Volume Control ✅ +### Milestone 5: Mixer View & Volume Control **Estimated Time:** 1-2 weeks - [ ] Design traditional mixer UI with faders - [ ] Implement volume slider with PipeWire parameter sync @@ -1236,7 +1236,7 @@ private: - [ ] Implement undo/redo for volume changes - [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets -### Milestone 6: Undo/Redo & Polish ✅ +### Milestone 6: Undo/Redo & Polish **Estimated Time:** 1-2 weeks - [ ] Integrate QUndoStack for all graph operations - [ ] Implement command classes for link, volume, node operations @@ -1245,7 +1245,7 @@ private: - [ ] Add copy/paste/duplicate functionality - [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work -### Milestone 7: Error Handling & Edge Cases ✅ +### Milestone 7: Error Handling & Edge Cases **Estimated Time:** 1-2 weeks - [ ] Implement device unplug/replug detection - [ ] Handle PipeWire service restart with auto-reconnect @@ -1254,7 +1254,7 @@ private: - [ ] Add error logging with structured JSON output - [ ] **Acceptance Criteria:** App survives device unplug and PipeWire restart without crashing -### Milestone 8: Final Polish & Release ✅ +### Milestone 8: Final Polish & Release **Estimated Time:** 1-2 weeks - [ ] Create application icon and desktop file - [ ] Implement dark/light theme support From 6d74ef422d2fc23dfc2b4a286bb807e8a883234c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 18:57:54 -0700 Subject: [PATCH 03/10] Add UI --- PROJECT_PLAN.md | 6 +-- src/gui/PipeWireGraphModel.cpp | 79 +++++++++++++++++++++++++++-- src/gui/PipeWireGraphModel.h | 5 ++ src/pipewire/pipewirecontroller.cpp | 71 +++++++++++++++++++++++++- src/pipewire/pipewirecontroller.h | 6 +++ 5 files changed, 160 insertions(+), 7 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index ea40863..42001f0 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1197,7 +1197,7 @@ private: - [x] Integrate libpipewire with `pw_thread_loop` - [x] Implement node/port discovery via registry callbacks - [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 ### Milestone 2: QtNodes Integration @@ -1205,8 +1205,8 @@ private: - [x] Integrate QtNodes library (submodule or CMake package) - [x] Create `AudioNodeDataModel` for PipeWire nodes - [x] Map PipeWire ports to QtNodes handles -- [ ] Implement connection validation -- [ ] Create custom node widgets with embedded controls +- [x] Implement connection validation +- [x] Create custom node widgets with embedded controls - [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections ### Milestone 3: Real-Time Meters & Performance diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index 06c1455..9b4e811 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -16,6 +16,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include @@ -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() { return m_nextNodeId++; @@ -210,6 +260,16 @@ bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connecti 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; } @@ -257,6 +317,10 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole } 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 baseHeight = 50; const int perPortHeight = 28; @@ -274,6 +338,8 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole return QString("PipeWire"); case QtNodes::NodeRole::Style: return nodeStyleVariant(info); + case QtNodes::NodeRole::Widget: + return QVariant::fromValue(nodeWidget(nodeId)); default: return QVariant(); } @@ -293,6 +359,11 @@ bool PipeWireGraphModel::setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole r return true; } + if (role == QtNodes::NodeRole::Size) { + m_nodeSizes[nodeId] = value.toSize(); + return true; + } + return false; } @@ -333,9 +404,6 @@ QVariant PipeWireGraphModel::portData(QtNodes::NodeId nodeId, } if (role == QtNodes::PortRole::ConnectionPolicyRole) { - if (portType == QtNodes::PortType::In) { - return QVariant::fromValue(QtNodes::ConnectionPolicy::One); - } return QVariant::fromValue(QtNodes::ConnectionPolicy::Many); } @@ -379,6 +447,8 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId) m_nodes.erase(nodeId); m_positions.erase(nodeId); + m_nodeSizes.erase(nodeId); + m_nodeWidgets.erase(nodeId); Q_EMIT nodeDeleted(nodeId); return true; } @@ -476,6 +546,7 @@ bool PipeWireGraphModel::updatePipeWireNode(const Potato::NodeInfo &node) const QtNodes::NodeId nodeId = it->second; m_nodes[nodeId] = node; + m_nodeSizes.erase(nodeId); Q_EMIT nodeUpdated(nodeId); return true; } @@ -503,6 +574,8 @@ void PipeWireGraphModel::reset() m_nodes.clear(); m_pwToNode.clear(); m_positions.clear(); + m_nodeSizes.clear(); + m_nodeWidgets.clear(); m_nextNodeId = 1; Q_EMIT modelReset(); } diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 891df31..64ef775 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -16,6 +16,8 @@ #include #include +class QWidget; + class PipeWireGraphModel : public QtNodes::AbstractGraphModel { Q_OBJECT @@ -72,6 +74,7 @@ public: bool splitterSizes(QList &sizes) const; private: + QWidget *nodeWidget(QtNodes::NodeId nodeId) 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; QString portLabel(const Potato::PortInfo &port) const; @@ -95,4 +98,6 @@ private: bool m_hasViewState = false; QList m_splitterSizes; bool m_hasSplitterSizes = false; + mutable std::unordered_map m_nodeWidgets; + std::unordered_map m_nodeSizes; }; diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 9108939..f1b305e 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -21,6 +21,59 @@ namespace Potato { +static constexpr uint32_t kMeterRingCapacityBytes = 4096; + +static void writeRingValue(spa_ringbuffer *ring, std::vector &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(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(&value) + first, sizeof(float) - first); + } + spa_ringbuffer_write_update(ring, index + sizeof(float)); +} + +static bool readRingLatest(spa_ringbuffer *ring, std::vector &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(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(&value) + first, buffer.data(), sizeof(float) - first); + } + + spa_ringbuffer_read_update(ring, index + avail); + return true; +} + static QString toQString(const char *value) { if (!value) { @@ -150,6 +203,9 @@ void meterProcess(void *data) } 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); } @@ -217,6 +273,9 @@ PipeWireController::PipeWireController(QObject *parent) { m_registryListener = 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() @@ -340,6 +399,9 @@ void PipeWireController::shutdown() } pw_deinit(); + + m_meterRingReady.store(false, std::memory_order_relaxed); + m_meterRingData.clear(); m_initialized.storeRelaxed(false); m_connected.storeRelaxed(false); @@ -372,7 +434,14 @@ QVector PipeWireController::links() 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 diff --git a/src/pipewire/pipewirecontroller.h b/src/pipewire/pipewirecontroller.h index 322f95e..1d9444d 100644 --- a/src/pipewire/pipewirecontroller.h +++ b/src/pipewire/pipewirecontroller.h @@ -6,6 +6,9 @@ #include #include #include +#include + +#include struct pw_thread_loop; struct pw_context; @@ -93,6 +96,9 @@ private: QAtomicInteger m_connected{false}; QAtomicInteger m_initialized{false}; std::atomic m_meterPeak{0.0f}; + mutable spa_ringbuffer m_meterRing{}; + mutable std::vector m_meterRingData; + std::atomic m_meterRingReady{false}; mutable QMutex m_meterMutex; QMap m_nodeMeters; From 96e1a5cbdb6577c55cffe7a9b31a7cc410f21340 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 19:11:43 -0700 Subject: [PATCH 04/10] Volume slider works --- src/gui/PipeWireGraphModel.cpp | 30 +++++++++++++++++++++--- src/pipewire/pipewirecontroller.cpp | 36 +++++++++++++++++++++++++++++ src/pipewire/pipewirecontroller.h | 1 + 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index 9b4e811..e8fcdc2 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -120,6 +120,12 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const return it->second; } + uint32_t pipewireId = 0; + auto nodeIt = m_nodes.find(nodeId); + if (nodeIt != m_nodes.end()) { + pipewireId = nodeIt->second.id; + } + auto *widget = new QWidget(); auto *layout = new QHBoxLayout(widget); layout->setContentsMargins(6, 2, 6, 2); @@ -129,14 +135,25 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const muteButton->setText("M"); muteButton->setCheckable(true); muteButton->setFixedSize(20, 20); - muteButton->setToolTip(QString("Mute (UI only)")); + muteButton->setToolTip(QString("Mute")); 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)")); + slider->setToolTip(QString("Volume")); + + const auto applyVolume = [this, pipewireId, slider, muteButton]() { + if (!m_controller || pipewireId == 0) { + return; + } + const float volume = static_cast(slider->value()) / 100.0f; + m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked()); + }; + + QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); }); + QObject::connect(muteButton, &QToolButton::toggled, widget, [applyVolume](bool) { applyVolume(); }); layout->addWidget(muteButton); layout->addWidget(slider); @@ -266,7 +283,14 @@ bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connecti return false; } - if (outInfo.mediaClass != inInfo.mediaClass) { + const auto isAudioClass = [](Potato::MediaClass mediaClass) { + return mediaClass == Potato::MediaClass::AudioSink + || mediaClass == Potato::MediaClass::AudioSource + || mediaClass == Potato::MediaClass::AudioDuplex + || mediaClass == Potato::MediaClass::Stream; + }; + + if (!isAudioClass(outInfo.mediaClass) || !isAudioClass(inInfo.mediaClass)) { return false; } diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index f1b305e..790ff45 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -444,6 +446,40 @@ float PipeWireController::meterPeak() const return peak; } +bool PipeWireController::setNodeVolume(uint32_t nodeId, float volume, bool mute) +{ + if (!m_threadLoop || !m_core || !m_registry) { + return false; + } + + if (nodeId == 0) { + return false; + } + + volume = std::clamp(volume, 0.0f, 1.0f); + + lock(); + auto *node = static_cast( + pw_registry_bind(m_registry, nodeId, PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0)); + if (!node) { + unlock(); + return false; + } + + uint8_t buffer[128]; + spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); + auto *param = reinterpret_cast(spa_pod_builder_add_object( + &builder, + SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_volume, SPA_POD_Float(volume), + SPA_PROP_mute, SPA_POD_Bool(mute))); + + pw_node_set_param(node, SPA_PARAM_Props, 0, param); + pw_proxy_destroy(reinterpret_cast(node)); + unlock(); + return true; +} + float PipeWireController::nodeMeterPeak(uint32_t nodeId) const { QMutexLocker lock(&m_meterMutex); diff --git a/src/pipewire/pipewirecontroller.h b/src/pipewire/pipewirecontroller.h index 1d9444d..06faca4 100644 --- a/src/pipewire/pipewirecontroller.h +++ b/src/pipewire/pipewirecontroller.h @@ -41,6 +41,7 @@ public: float nodeMeterPeak(uint32_t nodeId) const; void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink); void removeNodeMeter(uint32_t nodeId); + bool setNodeVolume(uint32_t nodeId, float volume, bool mute); uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId, uint32_t inputNodeId, uint32_t inputPortId); From ecfb59501aff10da332cd03790bc248c9e780c1c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 19:34:30 -0700 Subject: [PATCH 05/10] Virtual --- PROJECT_PLAN.md | 4 +- src/gui/GraphEditorWidget.cpp | 50 ++++++++++++++++++ src/gui/GraphEditorWidget.h | 8 +++ src/pipewire/pipewirecontroller.cpp | 80 ++++++++++++++++++++++++++++- src/pipewire/pipewirecontroller.h | 8 +++ 5 files changed, 146 insertions(+), 4 deletions(-) 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; From b3a1d2b7f38cbb6202b40b42f27dabded0f40759 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 21:11:20 -0700 Subject: [PATCH 06/10] Save presets --- CMakeLists.txt | 1 + PROJECT_PLAN.md | 6 +- src/gui/GraphEditorWidget.cpp | 57 ++++++ src/gui/GraphEditorWidget.h | 3 + src/gui/PipeWireGraphModel.cpp | 136 +++++++++++++ src/gui/PipeWireGraphModel.h | 10 + src/pipewire/pipewirecontroller.cpp | 2 +- src/presets/PresetManager.cpp | 293 ++++++++++++++++++++++++++++ src/presets/PresetManager.h | 38 ++++ 9 files changed, 542 insertions(+), 4 deletions(-) create mode 100644 src/presets/PresetManager.cpp create mode 100644 src/presets/PresetManager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4884b4c..16eda15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable(potato-gui src/gui/GraphEditorWidget.cpp src/gui/PipeWireGraphModel.cpp src/meters/AudioLevelMeter.cpp + src/presets/PresetManager.cpp ) target_link_libraries(potato-gui PRIVATE diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index a297859..81325c4 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1221,9 +1221,9 @@ private: ### Milestone 4: Virtual Devices & State Management **Estimated Time:** 2 weeks - [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 +- [x] Create `PresetManager` with JSON serialization +- [x] Implement preset load/save functionality +- [x] Store UI layout alongside audio graph state - [ ] Implement auto-reconnect for device hotplug - [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 056ff5e..52e3e37 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -1,5 +1,6 @@ #include "GraphEditorWidget.h" #include "meters/AudioLevelMeter.h" +#include "presets/PresetManager.h" #include #include @@ -175,6 +176,38 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi }); m_view->addAction(resetLayoutAction); + auto *savePresetAction = new QAction(QString("Save Preset..."), m_view); + connect(savePresetAction, &QAction::triggered, [this]() { + const QString filePath = QFileDialog::getSaveFileName(this, + QString("Save Preset"), + QString(), + QString("Preset Files (*.json)")); + if (filePath.isEmpty()) { + return; + } + if (!m_presetManager->savePreset(filePath)) { + qWarning() << "Failed to save preset" << filePath; + } + }); + m_view->addAction(savePresetAction); + + auto *loadPresetAction = new QAction(QString("Load Preset..."), m_view); + connect(loadPresetAction, &QAction::triggered, [this]() { + const QString filePath = QFileDialog::getOpenFileName(this, + QString("Load Preset"), + QString(), + QString("Preset Files (*.json)")); + if (filePath.isEmpty()) { + return; + } + if (!m_presetManager->loadPreset(filePath)) { + qWarning() << "Failed to load preset" << filePath; + return; + } + reloadGraphFromController(); + }); + m_view->addAction(loadPresetAction); + auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view); connect(createVirtualSinkAction, &QAction::triggered, [this]() { const int index = ++m_virtualSinkCount; @@ -233,6 +266,8 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_meterProfileTimer.start(); m_meterProfileReady = true; + + m_presetManager = new PresetManager(m_controller, m_model, this); } static bool isAudioEndpoint(const Potato::NodeInfo &node) @@ -508,6 +543,28 @@ void GraphEditorWidget::updateLayoutState() } } +void GraphEditorWidget::reloadGraphFromController() +{ + m_ignoreCreate.clear(); + m_ignoreDelete.clear(); + m_connectionToLinkId.clear(); + m_linkIdToConnection.clear(); + m_nodeLinkCounts.clear(); + m_linksById.clear(); + + m_model->reset(); + syncGraph(); + + double viewScale = 1.0; + QPointF viewCenter; + if (m_model->viewState(viewScale, viewCenter)) { + m_view->setupScale(viewScale); + m_view->centerOn(viewCenter); + } else { + m_view->zoomFitAll(); + } +} + void GraphEditorWidget::refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node) { if (m_nodeMeterRows.contains(nodeId)) { diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index a9b6600..fe2f8c7 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -18,6 +18,7 @@ class QTimer; class QScrollArea; class QVBoxLayout; class QSplitter; +class PresetManager; class GraphEditorWidget : public QWidget { @@ -46,6 +47,7 @@ private: void handleLinkRemoved(uint32_t linkId); bool isMeterNode(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const; + void reloadGraphFromController(); QString connectionKey(const QtNodes::ConnectionId &connectionId) const; bool eventFilter(QObject *object, QEvent *event) override; @@ -77,4 +79,5 @@ private: qint64 m_meterProfileNanos = 0; qint64 m_meterProfileMax = 0; int m_meterProfileFrames = 0; + PresetManager *m_presetManager = nullptr; }; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index e8fcdc2..17c5d80 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -144,12 +144,19 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); slider->setToolTip(QString("Volume")); + if (pipewireId != 0 && m_nodeVolumeState.contains(pipewireId)) { + const NodeVolumeState state = m_nodeVolumeState.value(pipewireId); + slider->setValue(static_cast(state.volume * 100.0f)); + muteButton->setChecked(state.mute); + } + const auto applyVolume = [this, pipewireId, slider, muteButton]() { if (!m_controller || pipewireId == 0) { return; } const float volume = static_cast(slider->value()) / 100.0f; m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked()); + m_nodeVolumeState.insert(pipewireId, NodeVolumeState{volume, muteButton->isChecked()}); }; QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); }); @@ -694,6 +701,135 @@ void PipeWireGraphModel::loadLayout() } } +QJsonObject PipeWireGraphModel::layoutJson() const +{ + QJsonObject root; + QJsonArray nodes; + for (auto it = m_layoutByStableId.cbegin(); it != m_layoutByStableId.cend(); ++it) { + QJsonObject item; + item["id"] = it.key(); + item["x"] = it.value().x(); + item["y"] = it.value().y(); + nodes.append(item); + } + root["nodes"] = nodes; + + QJsonObject view; + view["scale"] = m_viewScale; + view["center_x"] = m_viewCenter.x(); + view["center_y"] = m_viewCenter.y(); + root["view"] = view; + + if (m_hasSplitterSizes && !m_splitterSizes.isEmpty()) { + QJsonArray splitter; + for (const auto size : m_splitterSizes) { + splitter.append(size); + } + root["splitter"] = splitter; + } + + return root; +} + +void PipeWireGraphModel::applyLayoutJson(const QJsonObject &root) +{ + m_layoutByStableId.clear(); + m_hasViewState = false; + m_hasSplitterSizes = false; + m_splitterSizes.clear(); + + const QJsonArray nodes = root.value("nodes").toArray(); + applyLayoutData(nodes); + + const QJsonObject view = root.value("view").toObject(); + if (!view.isEmpty()) { + m_viewScale = view.value("scale").toDouble(1.0); + const double x = view.value("center_x").toDouble(0.0); + const double y = view.value("center_y").toDouble(0.0); + m_viewCenter = QPointF(x, y); + m_hasViewState = true; + } + + const QJsonArray splitter = root.value("splitter").toArray(); + if (!splitter.isEmpty()) { + QList sizes; + sizes.reserve(splitter.size()); + for (const auto &value : splitter) { + sizes.append(value.toInt()); + } + if (!sizes.isEmpty()) { + m_splitterSizes = sizes; + m_hasSplitterSizes = true; + } + } + + for (const auto &entry : m_nodes) { + const QtNodes::NodeId nodeId = entry.first; + const Potato::NodeInfo &info = entry.second; + if (!info.stableId.isEmpty() && m_layoutByStableId.contains(info.stableId)) { + const QPointF position = m_layoutByStableId.value(info.stableId); + m_positions[nodeId] = position; + Q_EMIT nodePositionUpdated(nodeId); + } + } +} + +QHash PipeWireGraphModel::volumeStates() const +{ + QHash result; + for (const auto &entry : m_nodes) { + const Potato::NodeInfo &info = entry.second; + if (info.stableId.isEmpty()) { + continue; + } + if (!m_nodeVolumeState.contains(info.id)) { + continue; + } + result.insert(info.stableId, m_nodeVolumeState.value(info.id)); + } + return result; +} + +void PipeWireGraphModel::applyVolumeStates(const QHash &states) +{ + if (!m_controller) { + return; + } + + const QVector nodes = m_controller->nodes(); + for (const auto &node : nodes) { + if (node.stableId.isEmpty() || !states.contains(node.stableId)) { + continue; + } + const NodeVolumeState state = states.value(node.stableId); + m_nodeVolumeState.insert(node.id, state); + m_controller->setNodeVolume(node.id, state.volume, state.mute); + + auto nodeIt = m_pwToNode.find(node.id); + if (nodeIt == m_pwToNode.end()) { + continue; + } + auto widgetIt = m_nodeWidgets.find(nodeIt->second); + if (widgetIt == m_nodeWidgets.end()) { + continue; + } + QWidget *widget = widgetIt->second; + if (!widget) { + continue; + } + if (auto *slider = widget->findChild()) { + slider->blockSignals(true); + slider->setValue(static_cast(state.volume * 100.0f)); + slider->blockSignals(false); + } + if (auto *button = widget->findChild()) { + button->blockSignals(true); + button->setChecked(state.mute); + button->blockSignals(false); + } + } +} + void PipeWireGraphModel::saveLayout() const { const QString path = layoutFilePath(); diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 64ef775..695e3ad 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -18,6 +18,11 @@ class QWidget; +struct NodeVolumeState { + float volume = 1.0f; + bool mute = false; +}; + class PipeWireGraphModel : public QtNodes::AbstractGraphModel { Q_OBJECT @@ -72,6 +77,10 @@ public: bool viewState(double &scale, QPointF ¢er) const; void setSplitterSizes(const QList &sizes); bool splitterSizes(QList &sizes) const; + QJsonObject layoutJson() const; + void applyLayoutJson(const QJsonObject &root); + QHash volumeStates() const; + void applyVolumeStates(const QHash &states); private: QWidget *nodeWidget(QtNodes::NodeId nodeId) const; @@ -100,4 +109,5 @@ private: bool m_hasSplitterSizes = false; mutable std::unordered_map m_nodeWidgets; std::unordered_map m_nodeSizes; + mutable QHash m_nodeVolumeState; }; diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 14bbe9b..fe46060 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -545,7 +545,7 @@ bool PipeWireController::createVirtualSink(const QString &name, const QString &d 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); + return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Source", channels, rate); } float PipeWireController::nodeMeterPeak(uint32_t nodeId) const diff --git a/src/presets/PresetManager.cpp b/src/presets/PresetManager.cpp new file mode 100644 index 0000000..fd54274 --- /dev/null +++ b/src/presets/PresetManager.cpp @@ -0,0 +1,293 @@ +#include "presets/PresetManager.h" + +#include "pipewire/pipewirecontroller.h" +#include "gui/PipeWireGraphModel.h" + +#include +#include +#include +#include + +#include + +PresetManager::PresetManager(Potato::PipeWireController *controller, + PipeWireGraphModel *model, + QObject *parent) + : QObject(parent) + , m_controller(controller) + , m_model(model) +{ +} + +bool PresetManager::savePreset(const QString &path) const +{ + if (!m_controller || !m_model || path.isEmpty()) { + return false; + } + + const QJsonObject root = buildPreset(); + const QJsonDocument doc(root); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + + file.write(doc.toJson(QJsonDocument::Indented)); + return true; +} + +bool PresetManager::loadPreset(const QString &path) +{ + if (!m_controller || !m_model || path.isEmpty()) { + return false; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) { + return false; + } + + return applyPreset(doc.object()); +} + +QJsonObject PresetManager::buildPreset() const +{ + QJsonObject root; + root["version"] = QString("1.0"); + + const QVector nodes = m_controller->nodes(); + QHash nodesById; + for (const auto &node : nodes) { + nodesById.insert(node.id, node); + } + + QJsonArray virtualDevices; + for (const auto &node : nodes) { + if (node.type != Potato::NodeType::Virtual) { + continue; + } + QJsonObject device; + device["name"] = node.name; + device["description"] = node.description; + device["stable_id"] = node.stableId; + const int channels = std::max(node.inputPorts.size(), node.outputPorts.size()); + device["channels"] = channels > 0 ? channels : 2; + device["rate"] = 48000; + device["media_class"] = mediaClassToString(node.mediaClass); + virtualDevices.append(device); + } + root["virtual_devices"] = virtualDevices; + + QJsonArray routing; + const QVector links = m_controller->links(); + for (const auto &link : links) { + if (!nodesById.contains(link.outputNodeId) || !nodesById.contains(link.inputNodeId)) { + continue; + } + const Potato::NodeInfo &outNode = nodesById.value(link.outputNodeId); + const Potato::NodeInfo &inNode = nodesById.value(link.inputNodeId); + + QString outPortName; + QString inPortName; + if (!findPortName(outNode, link.outputPortId, outPortName)) { + continue; + } + if (!findPortName(inNode, link.inputPortId, inPortName)) { + continue; + } + + QJsonObject route; + route["source"] = QString("%1:%2").arg(outNode.stableId, outPortName); + route["target"] = QString("%1:%2").arg(inNode.stableId, inPortName); + route["volume"] = 1.0; + route["muted"] = false; + routing.append(route); + } + root["routing"] = routing; + + QJsonObject volumes; + QJsonObject mutes; + const QHash volumeStates = m_model->volumeStates(); + for (auto it = volumeStates.cbegin(); it != volumeStates.cend(); ++it) { + volumes[it.key()] = it.value().volume; + mutes[it.key()] = it.value().mute; + } + root["persistent_volumes"] = volumes; + root["persistent_mutes"] = mutes; + + root["ui_layout"] = m_model->layoutJson(); + + return root; +} + +bool PresetManager::applyPreset(const QJsonObject &root) +{ + const QVector nodes = m_controller->nodes(); + QHash nodesByStableId; + for (const auto &node : nodes) { + if (!node.stableId.isEmpty()) { + nodesByStableId.insert(node.stableId, node); + } + } + + const QJsonArray virtualDevices = root.value("virtual_devices").toArray(); + for (const auto &entry : virtualDevices) { + const QJsonObject device = entry.toObject(); + const QString stableId = device.value("stable_id").toString(); + const QString name = device.value("name").toString(); + const QString description = device.value("description").toString(); + const QString mediaClassValue = device.value("media_class").toString(); + const int channels = device.value("channels").toInt(2); + const int rate = device.value("rate").toInt(48000); + + if (!stableId.isEmpty() && nodesByStableId.contains(stableId)) { + continue; + } + + const Potato::MediaClass mediaClass = mediaClassFromString(mediaClassValue); + if (mediaClass == Potato::MediaClass::AudioSource) { + m_controller->createVirtualSource(name, description, channels, rate); + } else { + m_controller->createVirtualSink(name, description, channels, rate); + } + } + + const QJsonArray routing = root.value("routing").toArray(); + for (const auto &entry : routing) { + const QJsonObject route = entry.toObject(); + const QString source = route.value("source").toString(); + const QString target = route.value("target").toString(); + + uint32_t outNodeId = 0; + uint32_t outPortId = 0; + uint32_t inNodeId = 0; + uint32_t inPortId = 0; + + if (!parsePortKey(source, outNodeId, outPortId)) { + continue; + } + if (!parsePortKey(target, inNodeId, inPortId)) { + continue; + } + + m_controller->createLink(outNodeId, outPortId, inNodeId, inPortId); + } + + QHash volumeStates; + const QJsonObject volumes = root.value("persistent_volumes").toObject(); + const QJsonObject mutes = root.value("persistent_mutes").toObject(); + for (auto it = volumes.begin(); it != volumes.end(); ++it) { + NodeVolumeState state; + state.volume = static_cast(it.value().toDouble(1.0)); + state.mute = mutes.value(it.key()).toBool(false); + volumeStates.insert(it.key(), state); + } + if (!volumeStates.isEmpty()) { + m_model->applyVolumeStates(volumeStates); + } + + const QJsonObject layout = root.value("ui_layout").toObject(); + if (!layout.isEmpty()) { + m_model->applyLayoutJson(layout); + } + + return true; +} + +bool PresetManager::findPortName(const Potato::NodeInfo &node, uint32_t portId, QString &portName) const +{ + for (const auto &port : node.inputPorts) { + if (port.id == portId) { + portName = port.name; + return true; + } + } + for (const auto &port : node.outputPorts) { + if (port.id == portId) { + portName = port.name; + return true; + } + } + return false; +} + +bool PresetManager::parsePortKey(const QString &key, uint32_t &nodeId, uint32_t &portId) const +{ + const int split = key.lastIndexOf(':'); + if (split <= 0) { + return false; + } + + const QString stableId = key.left(split); + const QString portName = key.mid(split + 1); + if (stableId.isEmpty() || portName.isEmpty()) { + return false; + } + + const QVector nodes = m_controller->nodes(); + for (const auto &node : nodes) { + if (node.stableId != stableId) { + continue; + } + for (const auto &port : node.inputPorts) { + if (port.name == portName) { + nodeId = node.id; + portId = port.id; + return true; + } + } + for (const auto &port : node.outputPorts) { + if (port.name == portName) { + nodeId = node.id; + portId = port.id; + return true; + } + } + } + + return false; +} + +QString PresetManager::portKey(const Potato::NodeInfo &node, const Potato::PortInfo &port) const +{ + return QString("%1:%2").arg(node.stableId, port.name); +} + +QString PresetManager::mediaClassToString(Potato::MediaClass mediaClass) const +{ + switch (mediaClass) { + case Potato::MediaClass::AudioSink: + return QString("Audio/Sink"); + case Potato::MediaClass::AudioSource: + return QString("Audio/Source"); + case Potato::MediaClass::AudioDuplex: + return QString("Audio/Duplex"); + case Potato::MediaClass::Stream: + return QString("Stream"); + default: + return QString(); + } +} + +Potato::MediaClass PresetManager::mediaClassFromString(const QString &value) const +{ + if (value.contains("Audio/Source")) { + return Potato::MediaClass::AudioSource; + } + if (value.contains("Audio/Duplex")) { + return Potato::MediaClass::AudioDuplex; + } + if (value.contains("Audio/Sink")) { + return Potato::MediaClass::AudioSink; + } + if (value.contains("Stream")) { + return Potato::MediaClass::Stream; + } + return Potato::MediaClass::Unknown; +} diff --git a/src/presets/PresetManager.h b/src/presets/PresetManager.h new file mode 100644 index 0000000..13cb651 --- /dev/null +++ b/src/presets/PresetManager.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "pipewire/nodeinfo.h" + +class PipeWireGraphModel; + +namespace Potato { +class PipeWireController; +} + +class PresetManager : public QObject +{ +public: + explicit PresetManager(Potato::PipeWireController *controller, + PipeWireGraphModel *model, + QObject *parent = nullptr); + + bool savePreset(const QString &path) const; + bool loadPreset(const QString &path); + +private: + QJsonObject buildPreset() const; + bool applyPreset(const QJsonObject &root); + bool findPortName(const Potato::NodeInfo &node, uint32_t portId, QString &portName) const; + bool parsePortKey(const QString &key, uint32_t &nodeId, uint32_t &portId) const; + QString portKey(const Potato::NodeInfo &node, const Potato::PortInfo &port) const; + QString mediaClassToString(Potato::MediaClass mediaClass) const; + Potato::MediaClass mediaClassFromString(const QString &value) const; + + Potato::PipeWireController *m_controller = nullptr; + PipeWireGraphModel *m_model = nullptr; +}; From f57d39af48f2a2f112a23c5465c5b083b95ef954 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 21:25:58 -0700 Subject: [PATCH 07/10] Auto reconnect --- src/pipewire/pipewirecontroller.cpp | 180 +++++++++++++++++++++++++++- src/pipewire/pipewirecontroller.h | 12 ++ 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index fe46060..c85d56c 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -156,7 +156,7 @@ void registryEventGlobal(void *data, uint32_t id, uint32_t permissions, void registryEventGlobalRemove(void *data, uint32_t id) { auto *self = static_cast(data); - + { QMutexLocker lock(&self->m_nodesMutex); if (self->m_nodes.contains(id)) { @@ -164,18 +164,27 @@ void registryEventGlobalRemove(void *data, uint32_t id) emit self->nodeRemoved(id); return; } - + if (self->m_ports.contains(id)) { self->m_ports.remove(id); return; } - + } + + bool linkRemoved = false; + { + QMutexLocker lock(&self->m_nodesMutex); if (self->m_links.contains(id)) { self->m_links.remove(id); - emit self->linkRemoved(id); - return; + linkRemoved = true; } } + + if (linkRemoved) { + emit self->linkRemoved(id); + self->handleLinkRemoval(id); + return; + } } void coreEventDone(void *data, uint32_t id, int seq) @@ -769,6 +778,11 @@ bool PipeWireController::destroyLink(uint32_t linkId) return false; } linkInfo = m_links.value(linkId); + m_userRemovedLinks.insert(linkId); + if (m_linkIntentKeys.contains(linkId)) { + const QString key = m_linkIntentKeys.take(linkId); + m_linkIntents.remove(key); + } } lock(); @@ -786,6 +800,154 @@ bool PipeWireController::destroyLink(uint32_t linkId) return true; } +void PipeWireController::rememberLinkIntent(const LinkInfo &link) +{ + QString key; + if (!buildLinkIntentKey(link, key)) { + return; + } + + QMutexLocker lock(&m_nodesMutex); + m_linkIntents.insert(key); + m_linkIntentKeys.insert(link.id, key); +} + +void PipeWireController::handleLinkRemoval(uint32_t linkId) +{ + QMutexLocker lock(&m_nodesMutex); + if (m_userRemovedLinks.contains(linkId)) { + m_userRemovedLinks.remove(linkId); + } + if (m_linkIntentKeys.contains(linkId)) { + m_linkIntentKeys.remove(linkId); + } +} + +void PipeWireController::tryRestoreLinks() +{ + QList intents; + { + QMutexLocker lock(&m_nodesMutex); + intents = m_linkIntents.values(); + } + + for (const auto &key : intents) { + uint32_t outNodeId = 0; + uint32_t outPortId = 0; + uint32_t inNodeId = 0; + uint32_t inPortId = 0; + if (!resolveLinkIntentKey(key, outNodeId, outPortId, inNodeId, inPortId)) { + continue; + } + createLink(outNodeId, outPortId, inNodeId, inPortId); + } +} + +void PipeWireController::updateLinkIntentsForNode(uint32_t nodeId) +{ + QVector links; + { + QMutexLocker lock(&m_nodesMutex); + for (auto it = m_links.cbegin(); it != m_links.cend(); ++it) { + const LinkInfo &link = it.value(); + if (link.outputNodeId == nodeId || link.inputNodeId == nodeId) { + links.append(link); + } + } + } + + for (const auto &link : links) { + rememberLinkIntent(link); + } +} + +static bool isMeterNodeName(const QString &name) +{ + return name.startsWith("Potato-Meter") || name.startsWith("Potato-Node-Meter"); +} + +bool PipeWireController::buildLinkIntentKey(const LinkInfo &link, QString &key) const +{ + QMutexLocker lock(&m_nodesMutex); + if (!m_nodes.contains(link.outputNodeId) || !m_nodes.contains(link.inputNodeId)) { + return false; + } + + const NodeInfo &outNode = m_nodes.value(link.outputNodeId); + const NodeInfo &inNode = m_nodes.value(link.inputNodeId); + if (isMeterNodeName(outNode.name) || isMeterNodeName(inNode.name)) { + return false; + } + + QString outPortName; + QString inPortName; + for (const auto &port : outNode.outputPorts) { + if (port.id == link.outputPortId) { + outPortName = port.name; + break; + } + } + for (const auto &port : inNode.inputPorts) { + if (port.id == link.inputPortId) { + inPortName = port.name; + break; + } + } + if (outPortName.isEmpty() || inPortName.isEmpty()) { + return false; + } + if (outNode.stableId.isEmpty() || inNode.stableId.isEmpty()) { + return false; + } + + key = QString("%1||%2>>%3||%4").arg(outNode.stableId, outPortName, inNode.stableId, inPortName); + return true; +} + +bool PipeWireController::resolveLinkIntentKey(const QString &key, uint32_t &outNodeId, uint32_t &outPortId, + uint32_t &inNodeId, uint32_t &inPortId) const +{ + const QStringList halves = key.split(">>"); + if (halves.size() != 2) { + return false; + } + const QStringList outParts = halves.at(0).split("||"); + const QStringList inParts = halves.at(1).split("||"); + if (outParts.size() != 2 || inParts.size() != 2) { + return false; + } + + const QString outStableId = outParts.at(0); + const QString outPortName = outParts.at(1); + const QString inStableId = inParts.at(0); + const QString inPortName = inParts.at(1); + + QMutexLocker lock(&m_nodesMutex); + for (const auto &nodeEntry : m_nodes) { + const NodeInfo &node = nodeEntry; + if (node.stableId == outStableId) { + for (const auto &port : node.outputPorts) { + if (port.name == outPortName) { + outNodeId = node.id; + outPortId = port.id; + break; + } + } + } + if (node.stableId == inStableId) { + for (const auto &port : node.inputPorts) { + if (port.name == inPortName) { + inNodeId = node.id; + inPortId = port.id; + break; + } + } + } + } + + return outNodeId != 0 && outPortId != 0 && inNodeId != 0 && inPortId != 0; +} + QString PipeWireController::dumpGraph() const { QMutexLocker lock(&m_nodesMutex); @@ -870,6 +1032,9 @@ void PipeWireController::handleNodeInfo(uint32_t id, const struct spa_dict *prop qDebug() << "Node changed:" << node.id << node.name; } } + + updateLinkIntentsForNode(id); + tryRestoreLinks(); } void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *props) @@ -922,6 +1087,9 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop emit nodeChanged(nodeSnapshot); } + updateLinkIntentsForNode(nodeId); + tryRestoreLinks(); + qDebug() << "Port added:" << id << portName << "direction:" << direction; } @@ -949,6 +1117,8 @@ void PipeWireController::handleLinkInfo(uint32_t id, const struct spa_dict *prop } emit linkAdded(link); + + rememberLinkIntent(link); qDebug() << "Link added:" << id << "from" << outputNode << ":" << outputPort << "to" << inputNode << ":" << inputPort; diff --git a/src/pipewire/pipewirecontroller.h b/src/pipewire/pipewirecontroller.h index 5cd88a1..dd07e2f 100644 --- a/src/pipewire/pipewirecontroller.h +++ b/src/pipewire/pipewirecontroller.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -82,6 +84,13 @@ private: bool createVirtualDevice(const QString &name, const QString &description, const char *factoryName, const char *mediaClass, int channels, int rate); + void rememberLinkIntent(const LinkInfo &link); + void handleLinkRemoval(uint32_t linkId); + void tryRestoreLinks(); + void updateLinkIntentsForNode(uint32_t nodeId); + bool buildLinkIntentKey(const LinkInfo &link, QString &key) const; + bool resolveLinkIntentKey(const QString &key, uint32_t &outNodeId, uint32_t &outPortId, + uint32_t &inNodeId, uint32_t &inPortId) const; void lock(); void unlock(); @@ -99,6 +108,9 @@ private: QMap m_nodes; QMap m_ports; QMap m_links; + QSet m_linkIntents; + QHash m_linkIntentKeys; + QSet m_userRemovedLinks; QAtomicInteger m_connected{false}; QAtomicInteger m_initialized{false}; From debc7f1853a2efff56db57594bd71d44fe9f62b4 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 27 Jan 2026 21:49:50 -0700 Subject: [PATCH 08/10] Mixer --- PROJECT_PLAN.md | 8 +- src/gui/GraphEditorWidget.cpp | 214 ++++++++++++++++++++++++++++++++- src/gui/GraphEditorWidget.h | 20 +++ src/gui/PipeWireGraphModel.cpp | 39 ++++++ src/gui/PipeWireGraphModel.h | 2 + 5 files changed, 276 insertions(+), 7 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 81325c4..e97b9ff 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1229,10 +1229,10 @@ private: ### Milestone 5: Mixer View & Volume Control **Estimated Time:** 1-2 weeks -- [ ] Design traditional mixer UI with faders -- [ ] Implement volume slider with PipeWire parameter sync -- [ ] Add mute buttons and solo functionality -- [ ] Create stereo/multi-channel level meters +- [x] Design traditional mixer UI with faders +- [x] Implement volume slider with PipeWire parameter sync +- [x] Add mute buttons and solo functionality +- [x] Create stereo/multi-channel level meters - [ ] Implement undo/redo for volume changes - [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 52e3e37..ab53b1e 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -69,8 +72,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_splitter->setOrientation(Qt::Horizontal); m_splitter->addWidget(m_view); - auto *meterPanel = new QWidget(m_splitter); - meterPanel->setStyleSheet("background-color: #1f2126; border-left: 1px solid #2b2f38;"); + m_sidebarTabs = new QTabWidget(m_splitter); + m_sidebarTabs->setStyleSheet( + "QTabWidget::pane { border: none; background: #1f2126; border-left: 1px solid #2b2f38; }" + "QTabBar::tab { background: #1f2126; color: #8c94a5; padding: 8px 12px; border: none; font-weight: 700; font-size: 11px; }" + "QTabBar::tab:selected { color: #dbe2ee; border-bottom: 2px solid #5fcf8d; }" + "QTabBar::tab:hover { color: #c7cfdd; }" + ); + + auto *meterPanel = new QWidget(); + meterPanel->setStyleSheet("background-color: #1f2126;"); meterPanel->setMinimumWidth(260); meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); @@ -106,7 +117,31 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi meterLayout->addStretch(); - m_splitter->addWidget(meterPanel); + m_sidebarTabs->addTab(meterPanel, "METERS"); + + m_mixerTab = new QWidget(); + m_mixerTab->setStyleSheet("background-color: #1f2126;"); + auto *mixerTabLayout = new QVBoxLayout(m_mixerTab); + mixerTabLayout->setContentsMargins(0, 0, 0, 0); + + auto *mixerScroll = new QScrollArea(m_mixerTab); + mixerScroll->setFrameShape(QFrame::NoFrame); + mixerScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + mixerScroll->setWidgetResizable(true); + + m_mixerList = new QWidget(mixerScroll); + m_mixerListLayout = new QHBoxLayout(m_mixerList); + m_mixerListLayout->setContentsMargins(20, 20, 20, 20); + m_mixerListLayout->setSpacing(12); + m_mixerListLayout->setAlignment(Qt::AlignLeft); + m_mixerList->setLayout(m_mixerListLayout); + + mixerScroll->setWidget(m_mixerList); + mixerTabLayout->addWidget(mixerScroll); + + m_sidebarTabs->addTab(m_mixerTab, "MIXER"); + + m_splitter->addWidget(m_sidebarTabs); m_splitter->setStretchFactor(0, 1); m_splitter->setStretchFactor(1, 0); @@ -288,6 +323,7 @@ void GraphEditorWidget::syncGraph() if (isAudioEndpoint(node)) { m_model->addPipeWireNode(node); refreshNodeMeter(node.id, node); + refreshMixerStrip(node.id, node); } } const QVector links = m_controller->links(); @@ -318,6 +354,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node) if (isAudioEndpoint(node)) { m_model->addPipeWireNode(node); refreshNodeMeter(node.id, node); + refreshMixerStrip(node.id, node); } } @@ -333,6 +370,8 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node) refreshNodeMeter(node.id, node); updateNodeMeterState(node.id, node); + refreshMixerStrip(node.id, node); + updateMixerState(node.id, node); } void GraphEditorWidget::onNodeRemoved(uint32_t nodeId) @@ -351,6 +390,8 @@ void GraphEditorWidget::onNodeRemoved(uint32_t nodeId) m_nodeMeters.remove(nodeId); row->deleteLater(); } + + removeMixerStrip(nodeId); } void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link) @@ -514,6 +555,12 @@ void GraphEditorWidget::updateMeter() it.value()->setLevel(nodePeak); } + for (auto it = m_mixerMeters.begin(); it != m_mixerMeters.end(); ++it) { + const uint32_t nodeId = static_cast(it.key()); + const float nodePeak = m_controller->nodeMeterPeak(nodeId); + it.value()->setLevel(nodePeak); + } + if (m_meterProfileReady) { const qint64 duration = m_meterProfileTimer.nsecsElapsed() - startNanos; m_meterProfileNanos += duration; @@ -723,3 +770,164 @@ bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event) return QWidget::eventFilter(object, event); } + +void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node) +{ + if (m_mixerStrips.contains(nodeId)) { + return; + } + + auto *strip = new QWidget(m_mixerList); + strip->setFixedWidth(90); + auto *layout = new QVBoxLayout(strip); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + auto *meter = new AudioLevelMeter(strip); + meter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + auto *fader = new QSlider(Qt::Vertical, strip); + fader->setRange(0, 100); + fader->setValue(100); + fader->setToolTip("Volume"); + fader->setStyleSheet( + "QSlider::groove:vertical { width: 4px; background: #2b2f38; border-radius: 2px; }" + "QSlider::sub-page:vertical { background: #2b2f38; border-radius: 2px; }" + "QSlider::add-page:vertical { background: #5fcf8d; border-radius: 2px; }" + "QSlider::handle:vertical { height: 10px; margin: 0 -6px; background: #dbe2ee; border-radius: 5px; }" + ); + + auto *muteBtn = new QToolButton(strip); + muteBtn->setText("M"); + muteBtn->setCheckable(true); + muteBtn->setFixedSize(24, 24); + muteBtn->setToolTip("Mute"); + muteBtn->setStyleSheet( + "QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }" + "QToolButton:checked { background: #c94f4f; color: #ffffff; }" + "QToolButton:hover { background: #3c4350; }" + ); + + auto *soloBtn = new QToolButton(strip); + soloBtn->setText("S"); + soloBtn->setCheckable(true); + soloBtn->setFixedSize(24, 24); + soloBtn->setToolTip("Solo"); + soloBtn->setStyleSheet( + "QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }" + "QToolButton:checked { background: #e0b045; color: #ffffff; }" + "QToolButton:hover { background: #3c4350; }" + ); + + auto *label = new QLabel(node.description.isEmpty() ? node.name : node.description, strip); + label->setStyleSheet("color: #8c94a5; font-size: 10px; font-weight: 600;"); + label->setAlignment(Qt::AlignCenter); + label->setWordWrap(true); + label->setMinimumHeight(48); + + layout->addWidget(meter, 1); + layout->addWidget(fader, 0, Qt::AlignHCenter); + + auto *btnLayout = new QHBoxLayout(); + btnLayout->setContentsMargins(0, 0, 0, 0); + btnLayout->setSpacing(4); + btnLayout->addWidget(muteBtn); + btnLayout->addWidget(soloBtn); + layout->addLayout(btnLayout); + + layout->addWidget(label); + + m_mixerListLayout->addWidget(strip); + + m_mixerStrips.insert(nodeId, strip); + m_mixerMeters.insert(nodeId, meter); + m_mixerFaders.insert(nodeId, fader); + m_mixerMutes.insert(nodeId, muteBtn); + m_mixerSolos.insert(nodeId, soloBtn); + m_mixerUserMute.insert(nodeId, false); + + connect(fader, &QSlider::valueChanged, [this, nodeId](int value) { + if (!m_mixerMutes.contains(nodeId)) { + return; + } + const float volume = static_cast(value) / 100.0f; + const bool userMute = m_mixerUserMute.value(nodeId, false); + const bool soloActive = !m_mixerSoloNodes.isEmpty(); + const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId)); + m_controller->setNodeVolume(nodeId, volume, effectiveMute); + if (m_model) { + m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}); + } + }); + + connect(muteBtn, &QToolButton::toggled, [this, nodeId](bool checked) { + m_mixerUserMute[nodeId] = checked; + applySoloState(); + }); + + connect(soloBtn, &QToolButton::toggled, [this, nodeId](bool checked) { + if (checked) { + m_mixerSoloNodes.insert(nodeId); + } else { + m_mixerSoloNodes.remove(nodeId); + } + applySoloState(); + }); + + updateMixerState(nodeId, node); +} + +void GraphEditorWidget::updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node) +{ + if (!m_mixerFaders.contains(nodeId) || !m_mixerMutes.contains(nodeId)) { + return; + } + if (node.stableId.isEmpty() || !m_model) { + return; + } + + NodeVolumeState state; + if (!m_model->nodeVolumeState(nodeId, state)) { + return; + } + + auto *fader = m_mixerFaders.value(nodeId); + auto *muteBtn = m_mixerMutes.value(nodeId); + fader->blockSignals(true); + muteBtn->blockSignals(true); + fader->setValue(static_cast(state.volume * 100.0f)); + muteBtn->setChecked(state.mute); + fader->blockSignals(false); + muteBtn->blockSignals(false); + m_mixerUserMute[nodeId] = state.mute; + applySoloState(); +} + +void GraphEditorWidget::removeMixerStrip(uint32_t nodeId) +{ + if (m_mixerStrips.contains(nodeId)) { + auto *strip = m_mixerStrips.take(nodeId); + m_mixerMeters.remove(nodeId); + m_mixerFaders.remove(nodeId); + m_mixerMutes.remove(nodeId); + m_mixerSolos.remove(nodeId); + m_mixerUserMute.remove(nodeId); + m_mixerSoloNodes.remove(nodeId); + strip->deleteLater(); + } +} + +void GraphEditorWidget::applySoloState() +{ + for (auto it = m_mixerFaders.begin(); it != m_mixerFaders.end(); ++it) { + const uint32_t nodeId = static_cast(it.key()); + const float volume = static_cast(it.value()->value()) / 100.0f; + const bool userMute = m_mixerUserMute.value(nodeId, false); + const bool soloActive = !m_mixerSoloNodes.isEmpty(); + const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId)); + m_controller->setNodeVolume(nodeId, volume, effectiveMute); + if (m_model) { + m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}); + } + } +} diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index fe2f8c7..69c9060 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -17,7 +17,11 @@ class QLabel; class QTimer; class QScrollArea; class QVBoxLayout; +class QHBoxLayout; +class QSlider; +class QToolButton; class QSplitter; +class QTabWidget; class PresetManager; class GraphEditorWidget : public QWidget @@ -44,6 +48,10 @@ private: void updateLayoutState(); void updateNodeMeterLabel(QLabel *label); void updateNodeMeterState(uint32_t nodeId, const Potato::NodeInfo &node); + void refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node); + void updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node); + void removeMixerStrip(uint32_t nodeId); + void applySoloState(); void handleLinkRemoved(uint32_t linkId); bool isMeterNode(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const; @@ -57,6 +65,18 @@ private: QtNodes::GraphicsView *m_view = nullptr; QSplitter *m_splitter = nullptr; + QTabWidget *m_sidebarTabs = nullptr; + QWidget *m_mixerTab = nullptr; + QWidget *m_mixerList = nullptr; + QHBoxLayout *m_mixerListLayout = nullptr; + QMap m_mixerMeters; + QMap m_mixerStrips; + QMap m_mixerFaders; + QMap m_mixerMutes; + QMap m_mixerSolos; + QMap m_mixerUserMute; + QSet m_mixerSoloNodes; + QSet m_ignoreCreate; QSet m_ignoreDelete; QSet m_ignoreLinkRemoved; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index 17c5d80..8d120ab 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -830,6 +830,45 @@ void PipeWireGraphModel::applyVolumeStates(const QHash } } +void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state) +{ + m_nodeVolumeState.insert(nodeId, state); + + auto nodeIt = m_pwToNode.find(nodeId); + if (nodeIt == m_pwToNode.end()) { + return; + } + auto widgetIt = m_nodeWidgets.find(nodeIt->second); + if (widgetIt == m_nodeWidgets.end()) { + return; + } + + QWidget *widget = widgetIt->second; + if (!widget) { + return; + } + + if (auto *slider = widget->findChild()) { + slider->blockSignals(true); + slider->setValue(static_cast(state.volume * 100.0f)); + slider->blockSignals(false); + } + if (auto *button = widget->findChild()) { + button->blockSignals(true); + button->setChecked(state.mute); + button->blockSignals(false); + } +} + +bool PipeWireGraphModel::nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const +{ + if (!m_nodeVolumeState.contains(nodeId)) { + return false; + } + state = m_nodeVolumeState.value(nodeId); + return true; +} + void PipeWireGraphModel::saveLayout() const { const QString path = layoutFilePath(); diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 695e3ad..775f83f 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -81,6 +81,8 @@ public: void applyLayoutJson(const QJsonObject &root); QHash volumeStates() const; void applyVolumeStates(const QHash &states); + void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state); + bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const; private: QWidget *nodeWidget(QtNodes::NodeId nodeId) const; From adab645c86c81616e0a341f3ea5dc359bb181632 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 06:51:10 -0700 Subject: [PATCH 09/10] Undo volume --- CMakeLists.txt | 2 +- PROJECT_PLAN.md | 2 +- src/gui/ClickSlider.h | 26 +++++ src/gui/GraphEditorWidget.cpp | 185 +++++++++++++++++++++++++++++++-- src/gui/GraphEditorWidget.h | 13 +++ src/gui/PipeWireGraphModel.cpp | 45 +++----- src/gui/PipeWireGraphModel.h | 5 +- 7 files changed, 237 insertions(+), 41 deletions(-) create mode 100644 src/gui/ClickSlider.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 16eda15..ba1572e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,7 +33,7 @@ pkg_check_modules(SPA REQUIRED libspa-0.2>=0.2) add_compile_options( -Wall -Wextra - -Wpedantic + -Wno-pedantic -Werror=return-type ) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index e97b9ff..d5a4a06 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1233,7 +1233,7 @@ private: - [x] Implement volume slider with PipeWire parameter sync - [x] Add mute buttons and solo functionality - [x] Create stereo/multi-channel level meters -- [ ] Implement undo/redo for volume changes +- [x] Implement undo/redo for volume changes - [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets ### Milestone 6: Undo/Redo & Polish diff --git a/src/gui/ClickSlider.h b/src/gui/ClickSlider.h new file mode 100644 index 0000000..7e6a461 --- /dev/null +++ b/src/gui/ClickSlider.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +class ClickSlider : public QSlider +{ +public: + using QSlider::QSlider; + +protected: + void mousePressEvent(QMouseEvent *event) override + { + if (event->button() == Qt::LeftButton) { + const int span = (orientation() == Qt::Horizontal) ? width() : height(); + const int pos = (orientation() == Qt::Horizontal) + ? static_cast(event->position().x()) + : static_cast(height() - event->position().y()); + const int value = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span); + setValue(value); + event->accept(); + } + QSlider::mousePressEvent(event); + } +}; diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index ab53b1e..1a714eb 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -15,16 +15,65 @@ #include #include #include +#include +#include #include #include #include #include #include #include +#include #include #include +#include "gui/ClickSlider.h" + +class VolumeChangeCommand : public QUndoCommand +{ +public: + VolumeChangeCommand(GraphEditorWidget *widget, + uint32_t nodeId, + const NodeVolumeState &previous, + const NodeVolumeState &next) + : m_widget(widget) + , m_nodeId(nodeId) + , m_previous(previous) + , m_next(next) + { + setText(QString("Volume Change")); + } + + void undo() override + { + if (m_widget) { + m_widget->applyVolumeState(m_nodeId, m_previous, true); + if (m_widget->m_volumeUndoStack) { + m_widget->m_undoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canUndo()); + m_widget->m_redoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canRedo()); + } + } + } + + void redo() override + { + if (m_widget) { + m_widget->applyVolumeState(m_nodeId, m_next, true); + if (m_widget->m_volumeUndoStack) { + m_widget->m_undoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canUndo()); + m_widget->m_redoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canRedo()); + } + } + } + +private: + GraphEditorWidget *m_widget = nullptr; + uint32_t m_nodeId = 0; + NodeVolumeState m_previous{}; + NodeVolumeState m_next{}; +}; + GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent) : QWidget(parent) , m_controller(controller) @@ -154,6 +203,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi this, &GraphEditorWidget::onConnectionCreated); connect(m_model, &PipeWireGraphModel::connectionDeleted, this, &GraphEditorWidget::onConnectionDeleted); + connect(m_model, &PipeWireGraphModel::nodeVolumeChanged, + this, [this](uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t) { + if (m_ignoreVolumeUndo) { + return; + } + pushVolumeCommand(nodeId, previous, current); + if (m_mixerFaders.contains(nodeId)) { + m_mixerLastState.insert(nodeId, current); + } + }); connect(m_controller, &Potato::PipeWireController::nodeAdded, this, &GraphEditorWidget::onNodeAdded); @@ -211,6 +270,28 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi }); m_view->addAction(resetLayoutAction); + m_volumeUndoStack = new QUndoStack(this); + m_undoVolumeAction = new QAction(QString("Undo Volume"), m_view); + m_redoVolumeAction = new QAction(QString("Redo Volume"), m_view); + m_undoVolumeAction->setShortcut(QKeySequence::Undo); + m_redoVolumeAction->setShortcut(QKeySequence::Redo); + m_undoVolumeAction->setEnabled(false); + m_redoVolumeAction->setEnabled(false); + connect(m_undoVolumeAction, &QAction::triggered, m_volumeUndoStack, &QUndoStack::undo); + connect(m_redoVolumeAction, &QAction::triggered, m_volumeUndoStack, &QUndoStack::redo); + m_view->addAction(m_undoVolumeAction); + m_view->addAction(m_redoVolumeAction); + connect(m_volumeUndoStack, &QUndoStack::canUndoChanged, this, [this](bool canUndo) { + if (m_undoVolumeAction) { + m_undoVolumeAction->setEnabled(canUndo); + } + }); + connect(m_volumeUndoStack, &QUndoStack::canRedoChanged, this, [this](bool canRedo) { + if (m_redoVolumeAction) { + m_redoVolumeAction->setEnabled(canRedo); + } + }); + auto *savePresetAction = new QAction(QString("Save Preset..."), m_view); connect(savePresetAction, &QAction::triggered, [this]() { const QString filePath = QFileDialog::getSaveFileName(this, @@ -786,7 +867,7 @@ void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInf auto *meter = new AudioLevelMeter(strip); meter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - auto *fader = new QSlider(Qt::Vertical, strip); + auto *fader = new ClickSlider(Qt::Vertical, strip); fader->setRange(0, 100); fader->setValue(100); fader->setToolTip("Volume"); @@ -845,24 +926,63 @@ void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInf m_mixerMutes.insert(nodeId, muteBtn); m_mixerSolos.insert(nodeId, soloBtn); m_mixerUserMute.insert(nodeId, false); + m_mixerLastState.insert(nodeId, NodeVolumeState{1.0f, false}); connect(fader, &QSlider::valueChanged, [this, nodeId](int value) { - if (!m_mixerMutes.contains(nodeId)) { + if (!m_mixerMutes.contains(nodeId) || !m_mixerFaders.contains(nodeId)) { return; } const float volume = static_cast(value) / 100.0f; const bool userMute = m_mixerUserMute.value(nodeId, false); - const bool soloActive = !m_mixerSoloNodes.isEmpty(); - const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId)); - m_controller->setNodeVolume(nodeId, volume, effectiveMute); - if (m_model) { - m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}); + if (m_mixerFaders[nodeId]->isSliderDown()) { + if (!m_mixerStartState.contains(nodeId)) { + const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute}); + m_mixerStartState.insert(nodeId, previous); + } + } else { + const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute}); + const NodeVolumeState next{volume, userMute}; + pushVolumeCommand(nodeId, previous, next); + m_mixerLastState.insert(nodeId, next); } + applySoloState(); + }); + + connect(fader, &QSlider::sliderPressed, [this, nodeId]() { + if (!m_mixerFaders.contains(nodeId)) { + return; + } + const float volume = static_cast(m_mixerFaders[nodeId]->value()) / 100.0f; + const bool userMute = m_mixerUserMute.value(nodeId, false); + const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute}); + m_mixerStartState.insert(nodeId, previous); + }); + + connect(fader, &QSlider::sliderReleased, [this, nodeId]() { + if (!m_mixerFaders.contains(nodeId)) { + return; + } + const float volume = static_cast(m_mixerFaders[nodeId]->value()) / 100.0f; + const bool userMute = m_mixerUserMute.value(nodeId, false); + const NodeVolumeState previous = m_mixerStartState.value(nodeId, m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute})); + const NodeVolumeState next{volume, userMute}; + m_mixerStartState.remove(nodeId); + pushVolumeCommand(nodeId, previous, next); + m_mixerLastState.insert(nodeId, next); }); connect(muteBtn, &QToolButton::toggled, [this, nodeId](bool checked) { + if (!m_mixerFaders.contains(nodeId)) { + return; + } + const float volume = static_cast(m_mixerFaders[nodeId]->value()) / 100.0f; + const bool previousMute = m_mixerUserMute.value(nodeId, false); + const NodeVolumeState previous{volume, previousMute}; + const NodeVolumeState next{volume, checked}; m_mixerUserMute[nodeId] = checked; applySoloState(); + pushVolumeCommand(nodeId, previous, next); + m_mixerLastState.insert(nodeId, next); }); connect(soloBtn, &QToolButton::toggled, [this, nodeId](bool checked) { @@ -900,6 +1020,7 @@ void GraphEditorWidget::updateMixerState(uint32_t nodeId, const Potato::NodeInfo fader->blockSignals(false); muteBtn->blockSignals(false); m_mixerUserMute[nodeId] = state.mute; + m_mixerLastState.insert(nodeId, state); applySoloState(); } @@ -911,8 +1032,10 @@ void GraphEditorWidget::removeMixerStrip(uint32_t nodeId) m_mixerFaders.remove(nodeId); m_mixerMutes.remove(nodeId); m_mixerSolos.remove(nodeId); - m_mixerUserMute.remove(nodeId); - m_mixerSoloNodes.remove(nodeId); + m_mixerUserMute.remove(nodeId); + m_mixerStartState.remove(nodeId); + m_mixerLastState.remove(nodeId); + m_mixerSoloNodes.remove(nodeId); strip->deleteLater(); } } @@ -927,7 +1050,49 @@ void GraphEditorWidget::applySoloState() const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId)); m_controller->setNodeVolume(nodeId, volume, effectiveMute); if (m_model) { - m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}); + m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}, false); } } } + +void GraphEditorWidget::applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer) +{ + Q_UNUSED(updateMixer) + m_ignoreVolumeUndo = true; + if (m_model) { + m_model->setNodeVolumeState(nodeId, state, false); + } + m_mixerUserMute[nodeId] = state.mute; + if (m_mixerFaders.contains(nodeId)) { + m_mixerFaders[nodeId]->blockSignals(true); + m_mixerFaders[nodeId]->setValue(static_cast(state.volume * 100.0f)); + m_mixerFaders[nodeId]->blockSignals(false); + } + if (m_mixerMutes.contains(nodeId)) { + m_mixerMutes[nodeId]->blockSignals(true); + m_mixerMutes[nodeId]->setChecked(state.mute); + m_mixerMutes[nodeId]->blockSignals(false); + } + m_mixerLastState.insert(nodeId, state); + m_mixerStartState.remove(nodeId); + applySoloState(); + m_ignoreVolumeUndo = false; +} + +void GraphEditorWidget::pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next) +{ + if (!m_volumeUndoStack || m_ignoreVolumeUndo) { + return; + } + const bool changedVolume = qAbs(previous.volume - next.volume) > 0.0001f; + if (!changedVolume && previous.mute == next.mute) { + return; + } + m_volumeUndoStack->push(new VolumeChangeCommand(this, nodeId, previous, next)); + if (m_undoVolumeAction) { + m_undoVolumeAction->setEnabled(m_volumeUndoStack->canUndo()); + } + if (m_redoVolumeAction) { + m_redoVolumeAction->setEnabled(m_volumeUndoStack->canRedo()); + } +} diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 69c9060..69f4ce5 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -23,11 +23,16 @@ class QToolButton; class QSplitter; class QTabWidget; class PresetManager; +class QUndoStack; +class VolumeChangeCommand; +class QAction; class GraphEditorWidget : public QWidget { Q_OBJECT + friend class VolumeChangeCommand; + public: explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr); @@ -52,6 +57,8 @@ private: void updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node); void removeMixerStrip(uint32_t nodeId); void applySoloState(); + void applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer); + void pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next); void handleLinkRemoved(uint32_t linkId); bool isMeterNode(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const; @@ -75,6 +82,8 @@ private: QMap m_mixerMutes; QMap m_mixerSolos; QMap m_mixerUserMute; + QMap m_mixerStartState; + QMap m_mixerLastState; QSet m_mixerSoloNodes; QSet m_ignoreCreate; @@ -100,4 +109,8 @@ private: qint64 m_meterProfileMax = 0; int m_meterProfileFrames = 0; PresetManager *m_presetManager = nullptr; + QUndoStack *m_volumeUndoStack = nullptr; + QAction *m_undoVolumeAction = nullptr; + QAction *m_redoVolumeAction = nullptr; + bool m_ignoreVolumeUndo = false; }; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index 8d120ab..6f1ea6d 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include "gui/ClickSlider.h" #include #include #include @@ -26,6 +27,7 @@ #include #include +#include #include #include @@ -137,7 +139,7 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const muteButton->setFixedSize(20, 20); muteButton->setToolTip(QString("Mute")); - auto *slider = new QSlider(Qt::Horizontal, widget); + auto *slider = new ClickSlider(Qt::Horizontal, widget); slider->setRange(0, 100); slider->setValue(100); slider->setFixedHeight(18); @@ -155,8 +157,10 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const return; } const float volume = static_cast(slider->value()) / 100.0f; - m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked()); - m_nodeVolumeState.insert(pipewireId, NodeVolumeState{volume, muteButton->isChecked()}); + const NodeVolumeState state{volume, muteButton->isChecked()}; + m_controller->setNodeVolume(pipewireId, volume, state.mute); + auto *self = const_cast(this); + self->setNodeVolumeState(pipewireId, state, true); }; QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); }); @@ -802,36 +806,14 @@ void PipeWireGraphModel::applyVolumeStates(const QHash continue; } const NodeVolumeState state = states.value(node.stableId); - m_nodeVolumeState.insert(node.id, state); + setNodeVolumeState(node.id, state, false); m_controller->setNodeVolume(node.id, state.volume, state.mute); - - auto nodeIt = m_pwToNode.find(node.id); - if (nodeIt == m_pwToNode.end()) { - continue; - } - auto widgetIt = m_nodeWidgets.find(nodeIt->second); - if (widgetIt == m_nodeWidgets.end()) { - continue; - } - QWidget *widget = widgetIt->second; - if (!widget) { - continue; - } - if (auto *slider = widget->findChild()) { - slider->blockSignals(true); - slider->setValue(static_cast(state.volume * 100.0f)); - slider->blockSignals(false); - } - if (auto *button = widget->findChild()) { - button->blockSignals(true); - button->setChecked(state.mute); - button->blockSignals(false); - } } } -void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state) +void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify) { + const NodeVolumeState previous = m_nodeVolumeState.value(nodeId, NodeVolumeState{}); m_nodeVolumeState.insert(nodeId, state); auto nodeIt = m_pwToNode.find(nodeId); @@ -858,6 +840,13 @@ void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeSta button->setChecked(state.mute); button->blockSignals(false); } + + if (notify) { + const bool changedVolume = std::abs(previous.volume - state.volume) > 0.0001f; + if (changedVolume || previous.mute != state.mute) { + Q_EMIT nodeVolumeChanged(nodeId, previous, state); + } + } } bool PipeWireGraphModel::nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 775f83f..554fc74 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -81,9 +81,12 @@ public: void applyLayoutJson(const QJsonObject &root); QHash volumeStates() const; void applyVolumeStates(const QHash &states); - void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state); + void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify = true); bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const; +signals: + void nodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t); + private: QWidget *nodeWidget(QtNodes::NodeId nodeId) const; QtNodes::ConnectionId connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const; From f681b69467cc98bcd56ec7ac57780cb1a106b055 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 28 Jan 2026 07:13:51 -0700 Subject: [PATCH 10/10] Undo/redo --- src/gui/ClickSlider.h | 1 + src/gui/GraphEditorWidget.cpp | 40 ++------------------------------- src/gui/GraphEditorWidget.h | 5 ----- src/gui/PipeWireGraphModel.cpp | 41 +++++++++++++++++++++++++++++++--- src/gui/PipeWireGraphModel.h | 2 ++ 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/src/gui/ClickSlider.h b/src/gui/ClickSlider.h index 7e6a461..03346a4 100644 --- a/src/gui/ClickSlider.h +++ b/src/gui/ClickSlider.h @@ -13,6 +13,7 @@ protected: void mousePressEvent(QMouseEvent *event) override { if (event->button() == Qt::LeftButton) { + setProperty("pressValue", value()); const int span = (orientation() == Qt::Horizontal) ? width() : height(); const int pos = (orientation() == Qt::Horizontal) ? static_cast(event->position().x()) diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 1a714eb..802661e 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -49,10 +48,6 @@ public: { if (m_widget) { m_widget->applyVolumeState(m_nodeId, m_previous, true); - if (m_widget->m_volumeUndoStack) { - m_widget->m_undoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canUndo()); - m_widget->m_redoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canRedo()); - } } } @@ -60,10 +55,6 @@ public: { if (m_widget) { m_widget->applyVolumeState(m_nodeId, m_next, true); - if (m_widget->m_volumeUndoStack) { - m_widget->m_undoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canUndo()); - m_widget->m_redoVolumeAction->setEnabled(m_widget->m_volumeUndoStack->canRedo()); - } } } @@ -270,27 +261,6 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi }); m_view->addAction(resetLayoutAction); - m_volumeUndoStack = new QUndoStack(this); - m_undoVolumeAction = new QAction(QString("Undo Volume"), m_view); - m_redoVolumeAction = new QAction(QString("Redo Volume"), m_view); - m_undoVolumeAction->setShortcut(QKeySequence::Undo); - m_redoVolumeAction->setShortcut(QKeySequence::Redo); - m_undoVolumeAction->setEnabled(false); - m_redoVolumeAction->setEnabled(false); - connect(m_undoVolumeAction, &QAction::triggered, m_volumeUndoStack, &QUndoStack::undo); - connect(m_redoVolumeAction, &QAction::triggered, m_volumeUndoStack, &QUndoStack::redo); - m_view->addAction(m_undoVolumeAction); - m_view->addAction(m_redoVolumeAction); - connect(m_volumeUndoStack, &QUndoStack::canUndoChanged, this, [this](bool canUndo) { - if (m_undoVolumeAction) { - m_undoVolumeAction->setEnabled(canUndo); - } - }); - connect(m_volumeUndoStack, &QUndoStack::canRedoChanged, this, [this](bool canRedo) { - if (m_redoVolumeAction) { - m_redoVolumeAction->setEnabled(canRedo); - } - }); auto *savePresetAction = new QAction(QString("Save Preset..."), m_view); connect(savePresetAction, &QAction::triggered, [this]() { @@ -1081,18 +1051,12 @@ void GraphEditorWidget::applyVolumeState(uint32_t nodeId, const NodeVolumeState void GraphEditorWidget::pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next) { - if (!m_volumeUndoStack || m_ignoreVolumeUndo) { + if (!m_scene || m_ignoreVolumeUndo) { return; } const bool changedVolume = qAbs(previous.volume - next.volume) > 0.0001f; if (!changedVolume && previous.mute == next.mute) { return; } - m_volumeUndoStack->push(new VolumeChangeCommand(this, nodeId, previous, next)); - if (m_undoVolumeAction) { - m_undoVolumeAction->setEnabled(m_volumeUndoStack->canUndo()); - } - if (m_redoVolumeAction) { - m_redoVolumeAction->setEnabled(m_volumeUndoStack->canRedo()); - } + m_scene->undoStack().push(new VolumeChangeCommand(this, nodeId, previous, next)); } diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index 69f4ce5..31977f4 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -23,9 +23,7 @@ class QToolButton; class QSplitter; class QTabWidget; class PresetManager; -class QUndoStack; class VolumeChangeCommand; -class QAction; class GraphEditorWidget : public QWidget { @@ -109,8 +107,5 @@ private: qint64 m_meterProfileMax = 0; int m_meterProfileFrames = 0; PresetManager *m_presetManager = nullptr; - QUndoStack *m_volumeUndoStack = nullptr; - QAction *m_undoVolumeAction = nullptr; - QAction *m_redoVolumeAction = nullptr; bool m_ignoreVolumeUndo = false; }; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index 6f1ea6d..de76004 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -157,14 +157,40 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const return; } const float volume = static_cast(slider->value()) / 100.0f; - const NodeVolumeState state{volume, muteButton->isChecked()}; - m_controller->setNodeVolume(pipewireId, volume, state.mute); + const NodeVolumeState next{volume, muteButton->isChecked()}; + const NodeVolumeState previous = m_nodeVolumeState.value(pipewireId, next); + m_controller->setNodeVolume(pipewireId, volume, next.mute); auto *self = const_cast(this); - self->setNodeVolumeState(pipewireId, state, true); + self->setNodeVolumeState(pipewireId, next, false); + if (!slider->isSliderDown() && !m_inlineStartState.contains(pipewireId)) { + self->emitNodeVolumeChanged(pipewireId, previous, next); + } }; QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); }); QObject::connect(muteButton, &QToolButton::toggled, widget, [applyVolume](bool) { applyVolume(); }); + QObject::connect(slider, &QSlider::sliderPressed, widget, [this, pipewireId, slider, muteButton]() { + if (pipewireId == 0) { + return; + } + bool ok = false; + const int pressValue = slider->property("pressValue").toInt(&ok); + const float volume = ok ? (static_cast(pressValue) / 100.0f) + : static_cast(slider->value()) / 100.0f; + const bool mute = muteButton->isChecked(); + m_inlineStartState.insert(pipewireId, NodeVolumeState{volume, mute}); + }); + QObject::connect(slider, &QSlider::sliderReleased, widget, [this, pipewireId, slider, muteButton]() { + if (pipewireId == 0) { + return; + } + const NodeVolumeState previous = m_inlineStartState.value(pipewireId, m_nodeVolumeState.value(pipewireId)); + m_inlineStartState.remove(pipewireId); + const float volume = static_cast(slider->value()) / 100.0f; + const NodeVolumeState next{volume, muteButton->isChecked()}; + auto *self = const_cast(this); + self->emitNodeVolumeChanged(pipewireId, previous, next); + }); layout->addWidget(muteButton); layout->addWidget(slider); @@ -811,6 +837,15 @@ void PipeWireGraphModel::applyVolumeStates(const QHash } } +void PipeWireGraphModel::emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t) +{ + const bool changedVolume = qAbs(previous.volume - current.volume) > 0.0001f; + if (!changedVolume && previous.mute == current.mute) { + return; + } + Q_EMIT nodeVolumeChanged(nodeId, previous, current); +} + void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify) { const NodeVolumeState previous = m_nodeVolumeState.value(nodeId, NodeVolumeState{}); diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 554fc74..b55e7b7 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -89,6 +89,7 @@ signals: private: QWidget *nodeWidget(QtNodes::NodeId nodeId) const; + void emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState ¤t); 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; QString portLabel(const Potato::PortInfo &port) const; @@ -115,4 +116,5 @@ private: mutable std::unordered_map m_nodeWidgets; std::unordered_map m_nodeSizes; mutable QHash m_nodeVolumeState; + mutable QHash m_inlineStartState; };