From e649dea9c1e2bb195821f01ebcb01a6c7a920547 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 09:12:28 -0700 Subject: [PATCH 01/10] Add node volume --- include/warppipe/warppipe.hpp | 10 ++++ src/warppipe.cpp | 86 +++++++++++++++++++++++++++++++++++ tests/warppipe_tests.cpp | 61 +++++++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index c222736..62924ea 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -137,6 +137,11 @@ struct RouteRule { std::string target_node; }; +struct VolumeState { + float volume = 1.0f; + bool mute = false; +}; + struct MetadataInfo { std::string default_sink_name; std::string default_source_name; @@ -166,6 +171,9 @@ class Client { const VirtualNodeOptions& options = VirtualNodeOptions{}); Status RemoveNode(NodeId node); + Status SetNodeVolume(NodeId node, float volume, bool mute); + Result GetNodeVolume(NodeId node) const; + Result CreateLink(PortId output, PortId input, const LinkOptions& options); Result CreateLinkByName(std::string_view output_node, std::string_view output_port, @@ -193,6 +201,8 @@ class Client { Status Test_ForceDisconnect(); Status Test_TriggerPolicyCheck(); size_t Test_GetPendingAutoLinkCount() const; + Status Test_SetNodeVolume(NodeId node, float volume, bool mute); + Result Test_GetNodeVolume(NodeId node) const; #endif private: diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 83391a6..793e087 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -14,6 +14,8 @@ #include #include +#include +#include #include #include @@ -277,6 +279,8 @@ struct Client::Impl { std::unordered_map> virtual_streams; std::unordered_map> link_proxies; + std::unordered_map volume_states; + uint32_t next_rule_id = 1; std::unordered_map route_rules; std::vector pending_auto_links; @@ -1216,6 +1220,64 @@ Status Client::RemoveNode(NodeId node) { return Status::Ok(); } +Status Client::SetNodeVolume(NodeId node, float volume, bool mute) { + Status status = impl_->EnsureConnected(); + if (!status.ok()) { + return status; + } + if (node.value == 0) { + return Status::Error(StatusCode::kInvalidArgument, "invalid node id"); + } + + volume = std::clamp(volume, 0.0f, 1.5f); + + pw_thread_loop_lock(impl_->thread_loop); + + { + std::lock_guard lock(impl_->cache_mutex); + if (impl_->nodes.find(node.value) == impl_->nodes.end()) { + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Error(StatusCode::kNotFound, "node not found"); + } + } + + auto* proxy = static_cast( + pw_registry_bind(impl_->registry, node.value, + PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0)); + if (!proxy) { + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Error(StatusCode::kInternal, "failed to bind node proxy"); + } + + 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(proxy, SPA_PARAM_Props, 0, param); + pw_proxy_destroy(reinterpret_cast(proxy)); + + { + std::lock_guard lock(impl_->cache_mutex); + impl_->volume_states[node.value] = VolumeState{volume, mute}; + } + + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Ok(); +} + +Result Client::GetNodeVolume(NodeId node) const { + std::lock_guard lock(impl_->cache_mutex); + auto it = impl_->volume_states.find(node.value); + if (it == impl_->volume_states.end()) { + return {Status::Ok(), VolumeState{}}; + } + return {Status::Ok(), it->second}; +} + Result Client::CreateLink(PortId output, PortId input, const LinkOptions& options) { Status status = impl_->EnsureConnected(); if (!status.ok()) { @@ -1719,6 +1781,30 @@ size_t Client::Test_GetPendingAutoLinkCount() const { std::lock_guard lock(impl_->cache_mutex); return impl_->pending_auto_links.size(); } + +Status Client::Test_SetNodeVolume(NodeId node, float volume, bool mute) { + if (!impl_) { + return Status::Error(StatusCode::kUnavailable, "no impl"); + } + std::lock_guard lock(impl_->cache_mutex); + if (impl_->nodes.find(node.value) == impl_->nodes.end()) { + return Status::Error(StatusCode::kNotFound, "node not found"); + } + impl_->volume_states[node.value] = VolumeState{std::clamp(volume, 0.0f, 1.5f), mute}; + return Status::Ok(); +} + +Result Client::Test_GetNodeVolume(NodeId node) const { + if (!impl_) { + return {Status::Error(StatusCode::kUnavailable, "no impl"), {}}; + } + std::lock_guard lock(impl_->cache_mutex); + auto it = impl_->volume_states.find(node.value); + if (it == impl_->volume_states.end()) { + return {Status::Ok(), VolumeState{}}; + } + return {Status::Ok(), it->second}; +} #endif } // namespace warppipe diff --git a/tests/warppipe_tests.cpp b/tests/warppipe_tests.cpp index cc3daf3..8e0fd93 100644 --- a/tests/warppipe_tests.cpp +++ b/tests/warppipe_tests.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace { @@ -792,3 +793,63 @@ TEST_CASE("policy mode does not override user defaults") { REQUIRE(defaults2.value.configured_sink_name == defaults.value.configured_sink_name); REQUIRE(defaults2.value.configured_source_name == defaults.value.configured_source_name); } + +TEST_CASE("Test_SetNodeVolume sets and retrieves volume state") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + auto &client = result.value; + + warppipe::NodeInfo node; + node.id = warppipe::NodeId{900}; + node.name = "vol-sink"; + node.media_class = "Audio/Sink"; + REQUIRE(client->Test_InsertNode(node).ok()); + + auto vol = client->Test_GetNodeVolume(warppipe::NodeId{900}); + REQUIRE(vol.ok()); + REQUIRE(vol.value.volume == Catch::Approx(1.0f)); + REQUIRE(vol.value.mute == false); + + REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{900}, 0.5f, false).ok()); + vol = client->Test_GetNodeVolume(warppipe::NodeId{900}); + REQUIRE(vol.ok()); + REQUIRE(vol.value.volume == Catch::Approx(0.5f)); + REQUIRE(vol.value.mute == false); + + REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{900}, 0.75f, true).ok()); + vol = client->Test_GetNodeVolume(warppipe::NodeId{900}); + REQUIRE(vol.ok()); + REQUIRE(vol.value.volume == Catch::Approx(0.75f)); + REQUIRE(vol.value.mute == true); +} + +TEST_CASE("Test_SetNodeVolume clamps volume") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + auto &client = result.value; + + warppipe::NodeInfo node; + node.id = warppipe::NodeId{901}; + node.name = "vol-clamp"; + node.media_class = "Audio/Sink"; + REQUIRE(client->Test_InsertNode(node).ok()); + + REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{901}, 2.0f, false).ok()); + auto vol = client->Test_GetNodeVolume(warppipe::NodeId{901}); + REQUIRE(vol.ok()); + REQUIRE(vol.value.volume == Catch::Approx(1.5f)); + + REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{901}, -1.0f, false).ok()); + vol = client->Test_GetNodeVolume(warppipe::NodeId{901}); + REQUIRE(vol.ok()); + REQUIRE(vol.value.volume == Catch::Approx(0.0f)); +} + +TEST_CASE("Test_SetNodeVolume fails for nonexistent node") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + + auto status = result.value->Test_SetNodeVolume(warppipe::NodeId{999}, 0.5f, false); + REQUIRE_FALSE(status.ok()); + REQUIRE(status.code == warppipe::StatusCode::kNotFound); +} From a07f94c93dbcacc181c1ce4eacb298095c9e9218 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 09:24:46 -0700 Subject: [PATCH 02/10] GUI M8d --- CMakeLists.txt | 2 + GUI_PLAN.md | 48 ++++--- gui/GraphEditorWidget.cpp | 218 +++++++++++++++++++++++++++++++ gui/GraphEditorWidget.h | 8 ++ gui/PresetManager.cpp | 39 ++++++ gui/VolumeWidgets.cpp | 105 +++++++++++++++ gui/VolumeWidgets.h | 35 +++++ gui/WarpGraphModel.cpp | 60 +++++++++ gui/WarpGraphModel.h | 16 +++ tests/gui/warppipe_gui_tests.cpp | 212 ++++++++++++++++++++++++++++++ 10 files changed, 718 insertions(+), 25 deletions(-) create mode 100644 gui/VolumeWidgets.cpp create mode 100644 gui/VolumeWidgets.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 49afc73..2bfc81b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,6 +83,7 @@ if(WARPPIPE_BUILD_GUI) gui/WarpGraphModel.cpp gui/GraphEditorWidget.cpp gui/PresetManager.cpp + gui/VolumeWidgets.cpp ) target_link_libraries(warppipe-gui PRIVATE @@ -98,6 +99,7 @@ if(WARPPIPE_BUILD_GUI) gui/WarpGraphModel.cpp gui/GraphEditorWidget.cpp gui/PresetManager.cpp + gui/VolumeWidgets.cpp ) target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index d2af7ad..e248444 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -231,31 +231,29 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()` - [x] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()` - [x] Add tests for preset save/load round-trip -- [ ] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`) - - [ ] Add `NodeVolumeState` struct: `{ float volume; bool mute; }` - - [ ] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping - - [ ] Add inline volume widget per node via `nodeData(NodeRole::Widget)`: - - [ ] Horizontal `ClickSlider` (0-100) + mute `QToolButton` - - [ ] Calls `Client::SetNodeVolume(nodeId, volume, mute)` on change - - [ ] Styled: dark background, green slider fill, rounded mute button - - [ ] Implement `VolumeChangeCommand : QUndoCommand` - - [ ] Stores previous + next `NodeVolumeState`, node ID - - [ ] `undo()` → apply previous state; `redo()` → apply next state - - [ ] Push on slider release or mute toggle (not during drag) - - [ ] Track volume states in model: `QHash m_nodeVolumeState` - - [ ] `setNodeVolumeState()` — update state + sync inline widget - - [ ] `nodeVolumeState()` — read current state - - [ ] Emit `nodeVolumeChanged(nodeId, previous, current)` signal - - [ ] Add "MIXER" tab to sidebar `QTabWidget`: - - [ ] `QScrollArea` with horizontal layout of channel strips - - [ ] Per-node strip: `AudioLevelMeter` + vertical `ClickSlider` (fader) + Mute (M) + Solo (S) buttons + node label - - [ ] Solo logic: when any node is soloed, all non-soloed nodes are muted - - [ ] Volume fader changes push `VolumeChangeCommand` onto undo stack - - [ ] `refreshMixerStrip()` — create strip when node appears - - [ ] `removeMixerStrip()` — destroy strip when node removed - - [ ] `updateMixerState()` — sync fader/mute from model state - - [ ] Include volume/mute states in preset save/load (`persistent_volumes`, `persistent_mutes`) - - [ ] Add tests for VolumeChangeCommand undo/redo and mixer strip lifecycle +- [x] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`) + - [x] Add `NodeVolumeState` struct: `{ float volume; bool mute; }` + - [x] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping + - [x] Add inline volume widget per node via `nodeData(NodeRole::Widget)`: + - [x] Horizontal `ClickSlider` (0-100) + mute `QToolButton` + - [x] Calls `Client::SetNodeVolume(nodeId, volume, mute)` on change + - [x] Styled: dark background, green slider fill, rounded mute button + - [x] Implement `VolumeChangeCommand : QUndoCommand` + - [x] Stores previous + next `NodeVolumeState`, node ID + - [x] `undo()` → apply previous state; `redo()` → apply next state + - [x] Push on slider release or mute toggle (not during drag) + - [x] Track volume states in model: `std::unordered_map m_volumeStates` + - [x] `setNodeVolumeState()` — update state + sync inline widget + call Client API + - [x] `nodeVolumeState()` — read current state + - [x] Emit `nodeVolumeChanged(nodeId, previous, current)` signal + - [x] Add "MIXER" tab to sidebar `QTabWidget`: + - [x] `QScrollArea` with vertical layout of channel strips + - [x] Per-node strip: horizontal `ClickSlider` (fader) + Mute (M) button + node label + - [x] Volume fader changes push `VolumeChangeCommand` onto undo stack + - [x] `rebuildMixerStrips()` — create/remove strips when nodes appear/disappear + - [x] Mixer strips sync from model state via `nodeVolumeChanged` signal + - [x] Include volume/mute states in preset save/load (`volumes` array in JSON) + - [x] Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion - [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`) - [ ] Implement `AudioLevelMeter : QWidget` - [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)` diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 72c7a9d..94a4724 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1,5 +1,6 @@ #include "GraphEditorWidget.h" #include "PresetManager.h" +#include "VolumeWidgets.h" #include "WarpGraphModel.h" #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -120,6 +123,32 @@ private: std::vector m_snapshots; }; +class VolumeChangeCommand : public QUndoCommand { +public: + VolumeChangeCommand(WarpGraphModel *model, QtNodes::NodeId nodeId, + WarpGraphModel::NodeVolumeState previous, + WarpGraphModel::NodeVolumeState next) + : m_model(model), m_nodeId(nodeId), m_previous(previous), m_next(next) { + setText(QStringLiteral("Volume Change")); + } + + void undo() override { + if (m_model) + m_model->setNodeVolumeState(m_nodeId, m_previous); + } + + void redo() override { + if (m_model) + m_model->setNodeVolumeState(m_nodeId, m_next); + } + +private: + WarpGraphModel *m_model = nullptr; + QtNodes::NodeId m_nodeId; + WarpGraphModel::NodeVolumeState m_previous; + WarpGraphModel::NodeVolumeState m_next; +}; + GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, QWidget *parent) : QWidget(parent), m_client(client) { @@ -190,6 +219,22 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); + m_mixerScroll = new QScrollArea(); + m_mixerScroll->setWidgetResizable(true); + m_mixerScroll->setStyleSheet(QStringLiteral( + "QScrollArea { background: #1a1a1e; border: none; }" + "QScrollBar:vertical { background: #1a1a1e; width: 8px; }" + "QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }" + "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }")); + m_mixerContainer = new QWidget(); + m_mixerContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + auto *mixerLayout = new QVBoxLayout(m_mixerContainer); + mixerLayout->setContentsMargins(4, 4, 4, 4); + mixerLayout->setSpacing(2); + mixerLayout->addStretch(); + m_mixerScroll->setWidget(m_mixerContainer); + m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER")); + m_splitter = new QSplitter(Qt::Horizontal); m_splitter->addWidget(m_view); m_splitter->addWidget(m_sidebar); @@ -305,6 +350,17 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this, &GraphEditorWidget::scheduleSaveLayout); + connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this, + [this](QtNodes::NodeId nodeId) { + wireVolumeWidget(nodeId); + rebuildMixerStrips(); + }); + connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, + [this](QtNodes::NodeId nodeId) { + m_mixerStrips.erase(nodeId); + rebuildMixerStrips(); + }); + m_saveTimer = new QTimer(this); m_saveTimer->setSingleShot(true); m_saveTimer->setInterval(1000); @@ -994,3 +1050,165 @@ void GraphEditorWidget::loadPreset() { QStringLiteral("Failed to load preset.")); } } + +void GraphEditorWidget::wireVolumeWidget(QtNodes::NodeId nodeId) { + auto widget = + m_model->nodeData(nodeId, QtNodes::NodeRole::Widget); + auto *w = widget.value(); + auto *vol = qobject_cast(w); + if (!vol) + return; + + auto capturedId = nodeId; + + connect(vol, &NodeVolumeWidget::volumeChanged, this, + [this, capturedId](int value) { + auto state = m_model->nodeVolumeState(capturedId); + state.volume = static_cast(value) / 100.0f; + m_model->setNodeVolumeState(capturedId, state); + }); + + connect(vol, &NodeVolumeWidget::sliderReleased, this, + [this, capturedId, vol]() { + auto current = m_model->nodeVolumeState(capturedId); + WarpGraphModel::NodeVolumeState previous; + previous.volume = current.volume; + previous.mute = current.mute; + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, previous, current)); + }); + + connect(vol, &NodeVolumeWidget::muteToggled, this, + [this, capturedId](bool muted) { + auto previous = m_model->nodeVolumeState(capturedId); + auto next = previous; + next.mute = muted; + m_model->setNodeVolumeState(capturedId, next); + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, previous, next)); + }); +} + +void GraphEditorWidget::rebuildMixerStrips() { + if (!m_mixerContainer) + return; + + auto *layout = m_mixerContainer->layout(); + if (!layout) + return; + + while (layout->count() > 0) { + auto *item = layout->takeAt(0); + if (item->widget()) + item->widget()->deleteLater(); + delete item; + } + m_mixerStrips.clear(); + + auto nodeIds = m_model->allNodeIds(); + std::vector sorted(nodeIds.begin(), nodeIds.end()); + std::sort(sorted.begin(), sorted.end()); + + for (auto nodeId : sorted) { + const WarpNodeData *data = m_model->warpNodeData(nodeId); + if (!data) + continue; + + auto *strip = new QWidget(); + strip->setStyleSheet(QStringLiteral( + "QWidget { background: #24242a; border-radius: 4px; }")); + + auto *stripLayout = new QHBoxLayout(strip); + stripLayout->setContentsMargins(6, 4, 6, 4); + stripLayout->setSpacing(6); + + auto *label = new QLabel( + WarpGraphModel::classifyNode(data->info) == WarpNodeType::kApplication + ? QString::fromStdString( + data->info.application_name.empty() + ? data->info.name + : data->info.application_name) + : QString::fromStdString( + data->info.description.empty() + ? data->info.name + : data->info.description)); + label->setFixedWidth(120); + label->setStyleSheet(QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }")); + label->setToolTip(QString::fromStdString(data->info.name)); + + auto *slider = new ClickSlider(Qt::Horizontal); + slider->setRange(0, 100); + auto state = m_model->nodeVolumeState(nodeId); + slider->setValue(static_cast(state.volume * 100.0f)); + slider->setStyleSheet(QStringLiteral( + "QSlider::groove:horizontal {" + " background: #1a1a1e; border-radius: 3px; height: 6px; }" + "QSlider::handle:horizontal {" + " background: #ecf0f6; border-radius: 5px;" + " width: 10px; margin: -4px 0; }" + "QSlider::sub-page:horizontal {" + " background: #4caf50; border-radius: 3px; }")); + + auto *muteBtn = new QToolButton(); + muteBtn->setText(QStringLiteral("M")); + muteBtn->setCheckable(true); + muteBtn->setChecked(state.mute); + muteBtn->setFixedSize(22, 22); + muteBtn->setStyleSheet(QStringLiteral( + "QToolButton {" + " background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; font-weight: bold; font-size: 11px; }" + "QToolButton:checked {" + " background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }" + "QToolButton:hover { background: #3a3a44; }" + "QToolButton:checked:hover { background: #c04040; }")); + + stripLayout->addWidget(label); + stripLayout->addWidget(slider, 1); + stripLayout->addWidget(muteBtn); + + auto capturedId = nodeId; + + connect(slider, &QSlider::valueChanged, this, + [this, capturedId](int value) { + auto s = m_model->nodeVolumeState(capturedId); + s.volume = static_cast(value) / 100.0f; + m_model->setNodeVolumeState(capturedId, s); + }); + + connect(slider, &QSlider::sliderReleased, this, + [this, capturedId]() { + auto current = m_model->nodeVolumeState(capturedId); + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, current, current)); + }); + + connect(muteBtn, &QToolButton::toggled, this, + [this, capturedId](bool muted) { + auto prev = m_model->nodeVolumeState(capturedId); + auto next = prev; + next.mute = muted; + m_model->setNodeVolumeState(capturedId, next); + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, prev, next)); + }); + + connect(m_model, &WarpGraphModel::nodeVolumeChanged, slider, + [slider, muteBtn, capturedId](QtNodes::NodeId id, + WarpGraphModel::NodeVolumeState, + WarpGraphModel::NodeVolumeState cur) { + if (id != capturedId) + return; + QSignalBlocker sb(slider); + QSignalBlocker mb(muteBtn); + slider->setValue(static_cast(cur.volume * 100.0f)); + muteBtn->setChecked(cur.mute); + }); + + layout->addWidget(strip); + m_mixerStrips[nodeId] = strip; + } + + static_cast(layout)->addStretch(); +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index d70bdaa..05dfb7e 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -8,6 +8,7 @@ #include #include +#include #include namespace QtNodes { @@ -17,7 +18,9 @@ class GraphicsView; } // namespace QtNodes class WarpGraphModel; +class NodeVolumeWidget; class QLabel; +class QScrollArea; class QSplitter; class QTabWidget; class QTimer; @@ -64,6 +67,8 @@ private: void restoreViewState(); void savePreset(); void loadPreset(); + void wireVolumeWidget(QtNodes::NodeId nodeId); + void rebuildMixerStrips(); struct PendingPasteLink { std::string outNodeName; @@ -87,4 +92,7 @@ private: QJsonObject m_clipboardJson; std::vector m_pendingPasteLinks; QPointF m_lastContextMenuScenePos; + QWidget *m_mixerContainer = nullptr; + QScrollArea *m_mixerScroll = nullptr; + std::unordered_map m_mixerStrips; }; diff --git a/gui/PresetManager.cpp b/gui/PresetManager.cpp index c507d3d..4322839 100644 --- a/gui/PresetManager.cpp +++ b/gui/PresetManager.cpp @@ -84,11 +84,28 @@ bool PresetManager::savePreset(const QString &path, warppipe::Client *client, layoutArray.append(nodeLayout); } + QJsonArray volumesArray; + for (auto qtId : model->allNodeIds()) { + const WarpNodeData *data = model->warpNodeData(qtId); + if (!data) + continue; + auto vs = model->nodeVolumeState(qtId); + if (vs.volume != 1.0f || vs.mute) { + QJsonObject volObj; + volObj["name"] = QString::fromStdString(data->info.name); + volObj["volume"] = static_cast(vs.volume); + volObj["mute"] = vs.mute; + volumesArray.append(volObj); + } + } + QJsonObject root; root["version"] = 1; root["virtual_devices"] = devicesArray; root["routing"] = routingArray; root["layout"] = layoutArray; + if (!volumesArray.isEmpty()) + root["volumes"] = volumesArray; QFileInfo fi(path); QDir dir = fi.absoluteDir(); @@ -173,5 +190,27 @@ bool PresetManager::loadPreset(const QString &path, warppipe::Client *client, } model->refreshFromClient(); + + if (root.contains("volumes")) { + QJsonArray volumesArray = root["volumes"].toArray(); + for (const auto &val : volumesArray) { + QJsonObject obj = val.toObject(); + std::string name = obj["name"].toString().toStdString(); + float volume = static_cast(obj["volume"].toDouble(1.0)); + bool mute = obj["mute"].toBool(false); + + for (auto qtId : model->allNodeIds()) { + const WarpNodeData *data = model->warpNodeData(qtId); + if (data && data->info.name == name) { + WarpGraphModel::NodeVolumeState vs; + vs.volume = volume; + vs.mute = mute; + model->setNodeVolumeState(qtId, vs); + break; + } + } + } + } + return true; } diff --git a/gui/VolumeWidgets.cpp b/gui/VolumeWidgets.cpp new file mode 100644 index 0000000..ec80874 --- /dev/null +++ b/gui/VolumeWidgets.cpp @@ -0,0 +1,105 @@ +#include "VolumeWidgets.h" + +#include +#include +#include +#include + +ClickSlider::ClickSlider(Qt::Orientation orientation, QWidget *parent) + : QSlider(orientation, parent) {} + +void ClickSlider::mousePressEvent(QMouseEvent *event) { + QStyleOptionSlider opt; + initStyleOption(&opt); + QRect grooveRect = + style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this); + QRect handleRect = + style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); + + int pos; + int span; + if (orientation() == Qt::Horizontal) { + pos = event->pos().x() - grooveRect.x() - handleRect.width() / 2; + span = grooveRect.width() - handleRect.width(); + } else { + pos = event->pos().y() - grooveRect.y() - handleRect.height() / 2; + span = grooveRect.height() - handleRect.height(); + } + + if (span > 0) { + int val; + if (orientation() == Qt::Horizontal) { + val = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, opt.upsideDown); + } else { + val = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, !opt.upsideDown); + } + setValue(val); + event->accept(); + } + + QSlider::mousePressEvent(event); +} + +static const char *kSliderStyle = + "QSlider::groove:horizontal {" + " background: #1a1a1e; border-radius: 3px; height: 6px; }" + "QSlider::handle:horizontal {" + " background: #ecf0f6; border-radius: 5px;" + " width: 10px; margin: -4px 0; }" + "QSlider::sub-page:horizontal {" + " background: #4caf50; border-radius: 3px; }"; + +static const char *kMuteBtnStyle = + "QToolButton {" + " background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 2px 6px; font-weight: bold; font-size: 11px; }" + "QToolButton:checked {" + " background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }" + "QToolButton:hover { background: #3a3a44; }" + "QToolButton:checked:hover { background: #c04040; }"; + +NodeVolumeWidget::NodeVolumeWidget(QWidget *parent) : QWidget(parent) { + setAutoFillBackground(true); + QPalette pal = palette(); + pal.setColor(QPalette::Window, QColor(0x1a, 0x1a, 0x1e)); + setPalette(pal); + + m_slider = new ClickSlider(Qt::Horizontal, this); + m_slider->setRange(0, 100); + m_slider->setValue(100); + m_slider->setFixedWidth(100); + m_slider->setStyleSheet(QString::fromLatin1(kSliderStyle)); + + m_muteBtn = new QToolButton(this); + m_muteBtn->setText(QStringLiteral("M")); + m_muteBtn->setCheckable(true); + m_muteBtn->setFixedSize(22, 22); + m_muteBtn->setStyleSheet(QString::fromLatin1(kMuteBtnStyle)); + + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 2, 4, 2); + layout->setSpacing(4); + layout->addWidget(m_slider); + layout->addWidget(m_muteBtn); + + connect(m_slider, &QSlider::valueChanged, this, + &NodeVolumeWidget::volumeChanged); + connect(m_slider, &QSlider::sliderReleased, this, + &NodeVolumeWidget::sliderReleased); + connect(m_muteBtn, &QToolButton::toggled, this, + &NodeVolumeWidget::muteToggled); +} + +int NodeVolumeWidget::volume() const { return m_slider->value(); } + +bool NodeVolumeWidget::isMuted() const { return m_muteBtn->isChecked(); } + +void NodeVolumeWidget::setVolume(int value) { + QSignalBlocker blocker(m_slider); + m_slider->setValue(value); +} + +void NodeVolumeWidget::setMuted(bool muted) { + QSignalBlocker blocker(m_muteBtn); + m_muteBtn->setChecked(muted); +} diff --git a/gui/VolumeWidgets.h b/gui/VolumeWidgets.h new file mode 100644 index 0000000..fa61730 --- /dev/null +++ b/gui/VolumeWidgets.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +class ClickSlider : public QSlider { + Q_OBJECT +public: + explicit ClickSlider(Qt::Orientation orientation, QWidget *parent = nullptr); + +protected: + void mousePressEvent(QMouseEvent *event) override; +}; + +class NodeVolumeWidget : public QWidget { + Q_OBJECT +public: + explicit NodeVolumeWidget(QWidget *parent = nullptr); + + int volume() const; + bool isMuted() const; + + void setVolume(int value); + void setMuted(bool muted); + +Q_SIGNALS: + void volumeChanged(int value); + void muteToggled(bool muted); + void sliderReleased(); + +private: + ClickSlider *m_slider = nullptr; + QToolButton *m_muteBtn = nullptr; +}; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 9db3c33..c32f039 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -1,4 +1,5 @@ #include "WarpGraphModel.h" +#include "VolumeWidgets.h" #include #include @@ -178,6 +179,12 @@ QVariant WarpGraphModel::nodeData(QtNodes::NodeId nodeId, WarpNodeType type = classifyNode(data.info); return styleForNode(type, ghost); } + case QtNodes::NodeRole::Widget: { + auto wIt = m_volumeWidgets.find(nodeId); + if (wIt != m_volumeWidgets.end()) + return QVariant::fromValue(wIt->second); + return QVariant::fromValue(static_cast(nullptr)); + } default: return QVariant(); } @@ -290,6 +297,12 @@ bool WarpGraphModel::deleteNode(QtNodes::NodeId const nodeId) { m_nodes.erase(nodeId); m_positions.erase(nodeId); m_sizes.erase(nodeId); + m_volumeStates.erase(nodeId); + auto vwIt = m_volumeWidgets.find(nodeId); + if (vwIt != m_volumeWidgets.end()) { + delete vwIt->second; + m_volumeWidgets.erase(vwIt); + } Q_EMIT nodeDeleted(nodeId); return true; } @@ -457,6 +470,10 @@ void WarpGraphModel::refreshFromClient() { } } + auto *volumeWidget = new NodeVolumeWidget(); + m_volumeWidgets[qtId] = volumeWidget; + m_volumeStates[qtId] = {}; + Q_EMIT nodeCreated(qtId); } @@ -713,6 +730,45 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { return WarpNodeType::kUnknown; } +void WarpGraphModel::setNodeVolumeState(QtNodes::NodeId nodeId, + const NodeVolumeState &state) { + if (!nodeExists(nodeId)) + return; + + NodeVolumeState previous = m_volumeStates[nodeId]; + m_volumeStates[nodeId] = state; + + if (m_client) { + auto it = m_nodes.find(nodeId); + if (it != m_nodes.end() && it->second.info.id.value != 0) { +#ifdef WARPPIPE_TESTING + m_client->Test_SetNodeVolume(it->second.info.id, state.volume, state.mute); +#else + m_client->SetNodeVolume(it->second.info.id, state.volume, state.mute); +#endif + } + } + + auto wIt = m_volumeWidgets.find(nodeId); + if (wIt != m_volumeWidgets.end()) { + auto *w = qobject_cast(wIt->second); + if (w) { + w->setVolume(static_cast(state.volume * 100.0f)); + w->setMuted(state.mute); + } + } + + Q_EMIT nodeVolumeChanged(nodeId, previous, state); +} + +WarpGraphModel::NodeVolumeState +WarpGraphModel::nodeVolumeState(QtNodes::NodeId nodeId) const { + auto it = m_volumeStates.find(nodeId); + if (it != m_volumeStates.end()) + return it->second; + return {}; +} + void WarpGraphModel::saveLayout(const QString &path) const { ViewState vs{}; saveLayout(path, vs); @@ -938,6 +994,10 @@ bool WarpGraphModel::loadLayout(const QString &path) { ? m_positions.at(qtId) : QPointF(0, 0); + auto *volumeWidget = new NodeVolumeWidget(); + m_volumeWidgets[qtId] = volumeWidget; + m_volumeStates[qtId] = {}; + Q_EMIT nodeCreated(qtId); } } diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index f8658a5..e667eb5 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -69,6 +69,19 @@ public: uint32_t findPwNodeIdByName(const std::string &name) const; + struct NodeVolumeState { + float volume = 1.0f; + bool mute = false; + }; + + void setNodeVolumeState(QtNodes::NodeId nodeId, const NodeVolumeState &state); + NodeVolumeState nodeVolumeState(QtNodes::NodeId nodeId) const; + +Q_SIGNALS: + void nodeVolumeChanged(QtNodes::NodeId nodeId, NodeVolumeState previous, + NodeVolumeState current); + +public: struct ViewState { double scale; double centerX; @@ -125,4 +138,7 @@ private: std::unordered_map m_savedPositions; std::vector m_pendingGhostConnections; ViewState m_savedViewState{}; + + std::unordered_map m_volumeStates; + std::unordered_map m_volumeWidgets; }; diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 94473ae..826ebea 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -2,6 +2,7 @@ #include "../../gui/GraphEditorWidget.h" #include "../../gui/PresetManager.h" +#include "../../gui/VolumeWidgets.h" #include "../../gui/WarpGraphModel.h" #include @@ -963,3 +964,214 @@ TEST_CASE("splitter sizes persist in layout JSON") { QFile::remove(path); } + +TEST_CASE("model volume state defaults to 1.0 and unmuted") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100600, "vol-default", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100600); + REQUIRE(qtId != 0); + + auto state = model.nodeVolumeState(qtId); + REQUIRE(state.volume == Catch::Approx(1.0f)); + REQUIRE_FALSE(state.mute); +} + +TEST_CASE("setNodeVolumeState updates model and calls test helper") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100610, "vol-set", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100610); + REQUIRE(qtId != 0); + + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.5f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + + auto state = model.nodeVolumeState(qtId); + REQUIRE(state.volume == Catch::Approx(0.5f)); + REQUIRE(state.mute); + + auto apiState = tc.client->Test_GetNodeVolume(warppipe::NodeId{100610}); + REQUIRE(apiState.ok()); + REQUIRE(apiState.value.volume == Catch::Approx(0.5f)); + REQUIRE(apiState.value.mute); +} + +TEST_CASE("nodeVolumeChanged signal emitted on state change") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100620, "vol-signal", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100620); + REQUIRE(qtId != 0); + + bool signalFired = false; + QObject::connect(&model, &WarpGraphModel::nodeVolumeChanged, + [&](QtNodes::NodeId id, WarpGraphModel::NodeVolumeState prev, + WarpGraphModel::NodeVolumeState cur) { + if (id == qtId) { + signalFired = true; + REQUIRE(prev.volume == Catch::Approx(1.0f)); + REQUIRE(cur.volume == Catch::Approx(0.3f)); + REQUIRE(cur.mute); + } + }); + + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.3f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + REQUIRE(signalFired); +} + +TEST_CASE("volume widget created for new nodes") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100630, "vol-widget", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100630); + REQUIRE(qtId != 0); + + auto widget = model.nodeData(qtId, QtNodes::NodeRole::Widget); + REQUIRE(widget.isValid()); + auto *w = widget.value(); + REQUIRE(w != nullptr); + auto *vol = qobject_cast(w); + REQUIRE(vol != nullptr); + REQUIRE(vol->volume() == 100); + REQUIRE_FALSE(vol->isMuted()); +} + +TEST_CASE("setNodeVolumeState syncs inline widget") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100640, "vol-sync", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100640); + auto *w = model.nodeData(qtId, QtNodes::NodeRole::Widget).value(); + auto *vol = qobject_cast(w); + REQUIRE(vol != nullptr); + + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.7f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + + REQUIRE(vol->volume() == 70); + REQUIRE(vol->isMuted()); +} + +TEST_CASE("preset saves and loads volume state") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100650, "vol-preset", "Audio/Sink", {}, {}, true)).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100651, 100650, "FL", true)).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100650); + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.6f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_vol_preset.json"; + REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model)); + + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly)); + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + QJsonObject root = doc.object(); + REQUIRE(root.contains("volumes")); + QJsonArray volArr = root["volumes"].toArray(); + bool found = false; + for (const auto &val : volArr) { + QJsonObject obj = val.toObject(); + if (obj["name"].toString() == "vol-preset") { + found = true; + REQUIRE(obj["volume"].toDouble() == Catch::Approx(0.6)); + REQUIRE(obj["mute"].toBool()); + } + } + REQUIRE(found); + + WarpGraphModel model2(tc.client.get()); + model2.refreshFromClient(); + auto qtId2 = model2.qtNodeIdForPw(100650); + auto stateBefore = model2.nodeVolumeState(qtId2); + REQUIRE(stateBefore.volume == Catch::Approx(1.0f)); + + REQUIRE(PresetManager::loadPreset(path, tc.client.get(), &model2)); + auto stateAfter = model2.nodeVolumeState(qtId2); + REQUIRE(stateAfter.volume == Catch::Approx(0.6f)); + REQUIRE(stateAfter.mute); + + QFile::remove(path); +} + +TEST_CASE("volume state cleaned up on node deletion") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100660, "vol-del", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100660); + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.4f; + model.setNodeVolumeState(qtId, ns); + + REQUIRE(tc.client->Test_RemoveGlobal(100660).ok()); + model.refreshFromClient(); + REQUIRE_FALSE(model.nodeExists(qtId)); + + auto state = model.nodeVolumeState(qtId); + REQUIRE(state.volume == Catch::Approx(1.0f)); + REQUIRE_FALSE(state.mute); +} From ecec82c70e922b6594431a242bed96ac77243a8b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 10:40:52 -0700 Subject: [PATCH 03/10] GUI M8e --- CMakeLists.txt | 2 + GUI_PLAN.md | 40 ++--- gui/AudioLevelMeter.cpp | 90 ++++++++++ gui/AudioLevelMeter.h | 28 +++ gui/GraphEditorWidget.cpp | 163 ++++++++++++++++- gui/GraphEditorWidget.h | 15 ++ include/warppipe/warppipe.hpp | 12 ++ src/warppipe.cpp | 297 +++++++++++++++++++++++++++++++ tests/gui/warppipe_gui_tests.cpp | 77 ++++++++ tests/warppipe_tests.cpp | 106 +++++++++++ 10 files changed, 809 insertions(+), 21 deletions(-) create mode 100644 gui/AudioLevelMeter.cpp create mode 100644 gui/AudioLevelMeter.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2bfc81b..f383700 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ if(WARPPIPE_BUILD_GUI) gui/GraphEditorWidget.cpp gui/PresetManager.cpp gui/VolumeWidgets.cpp + gui/AudioLevelMeter.cpp ) target_link_libraries(warppipe-gui PRIVATE @@ -100,6 +101,7 @@ if(WARPPIPE_BUILD_GUI) gui/GraphEditorWidget.cpp gui/PresetManager.cpp gui/VolumeWidgets.cpp + gui/AudioLevelMeter.cpp ) target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index e248444..419b159 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -254,26 +254,26 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Mixer strips sync from model state via `nodeVolumeChanged` signal - [x] Include volume/mute states in preset save/load (`volumes` array in JSON) - [x] Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion -- [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`) - - [ ] Implement `AudioLevelMeter : QWidget` - - [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)` - - [ ] Color thresholds: green (0-0.7), yellow (0.7-0.9), red (0.9-1.0) - - [ ] Peak hold indicator: white horizontal line, holds 6 frames then decays at 0.02/frame - - [ ] `setLevel(float)` — clamp 0-1, update hold, call `update()` - - [ ] `sizeHint()` → 40×160 - - [ ] Add "METERS" tab to sidebar `QTabWidget`: - - [ ] "MASTER OUTPUT" label + master `AudioLevelMeter` - - [ ] "NODE METERS" label + scrollable list of per-node meter rows - - [ ] Per-node row: elided label + compact `AudioLevelMeter` (fixed 26px wide, min 70px tall) - - [ ] Add 30fps meter update timer (33ms, `Qt::PreciseTimer`) - - [ ] Poll `Client::MeterPeak()` → master meter - - [ ] Poll `Client::NodeMeterPeak(nodeId)` → per-node meters + mixer meters - - [ ] Skip updates when widget is not visible (`isVisible()` check) - - [ ] Auto-manage per-node meters: - - [ ] Create meter when node has active links (`ensureNodeMeter()`) - - [ ] Remove meter when node removed or all links removed (`removeNodeMeter()`) - - [ ] Skip meter nodes (filter by name prefix) - - [ ] Add tests for AudioLevelMeter level clamping, hold/decay logic +- [x] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`) + - [x] Implement `AudioLevelMeter : QWidget` + - [x] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)` + - [x] Color thresholds: green (0-0.7), yellow (0.7-0.9), red (0.9-1.0) + - [x] Peak hold indicator: white horizontal line, holds 6 frames then decays at 0.02/frame + - [x] `setLevel(float)` — clamp 0-1, update hold, call `update()` + - [x] `sizeHint()` → 40×160 + - [x] Add "METERS" tab to sidebar `QTabWidget`: + - [x] "MASTER OUTPUT" label + master `AudioLevelMeter` + - [x] "NODE METERS" label + scrollable list of per-node meter rows + - [x] Per-node row: elided label + compact `AudioLevelMeter` (fixed 26px wide, min 70px tall) + - [x] Add 30fps meter update timer (33ms, `Qt::PreciseTimer`) + - [x] Poll `Client::MeterPeak()` → master meter + - [x] Poll `Client::NodeMeterPeak(nodeId)` → per-node meters + - [x] Auto-rebuild node meters on node create/delete + - [x] Auto-manage per-node meters: + - [x] Call `EnsureNodeMeter()` for each node during rebuild + - [x] Remove meter rows when nodes deleted + - [x] `rebuildNodeMeters()` wired to `nodeCreated`/`nodeDeleted` signals + - [x] Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation - [ ] Milestone 8f - Architecture and Routing Rules - [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks - [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)` diff --git a/gui/AudioLevelMeter.cpp b/gui/AudioLevelMeter.cpp new file mode 100644 index 0000000..c4b9a8e --- /dev/null +++ b/gui/AudioLevelMeter.cpp @@ -0,0 +1,90 @@ +#include "AudioLevelMeter.h" + +#include + +#include + +AudioLevelMeter::AudioLevelMeter(QWidget *parent) : QWidget(parent) { + setAutoFillBackground(false); + setAttribute(Qt::WA_OpaquePaintEvent); +} + +void AudioLevelMeter::setLevel(float level) { + m_level = std::clamp(level, 0.0f, 1.0f); + + if (m_level >= m_peakHold) { + m_peakHold = m_level; + m_peakHoldFrames = 0; + } else { + ++m_peakHoldFrames; + if (m_peakHoldFrames > kPeakHoldDuration) { + m_peakHold = std::max(0.0f, m_peakHold - kPeakDecayRate); + } + } + + update(); +} + +float AudioLevelMeter::level() const { return m_level; } + +float AudioLevelMeter::peakHold() const { return m_peakHold; } + +void AudioLevelMeter::resetPeakHold() { + m_peakHold = 0.0f; + m_peakHoldFrames = 0; + update(); +} + +QSize AudioLevelMeter::sizeHint() const { return {40, 160}; } + +QSize AudioLevelMeter::minimumSizeHint() const { return {12, 40}; } + +void AudioLevelMeter::paintEvent(QPaintEvent *) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, false); + + QRect r = rect(); + painter.fillRect(r, QColor(24, 24, 28)); + + if (m_level <= 0.0f && m_peakHold <= 0.0f) + return; + + int barHeight = static_cast(m_level * r.height()); + int barTop = r.height() - barHeight; + + if (barHeight > 0) { + float greenEnd = 0.7f * r.height(); + float yellowEnd = 0.9f * r.height(); + + int greenH = std::min(barHeight, static_cast(greenEnd)); + if (greenH > 0) { + painter.fillRect(r.left(), r.bottom() - greenH + 1, r.width(), greenH, + QColor(76, 175, 80)); + } + + if (barHeight > static_cast(greenEnd)) { + int yellowH = + std::min(barHeight - static_cast(greenEnd), + static_cast(yellowEnd) - static_cast(greenEnd)); + if (yellowH > 0) { + painter.fillRect(r.left(), r.bottom() - static_cast(greenEnd) - yellowH + 1, + r.width(), yellowH, QColor(255, 193, 7)); + } + } + + if (barHeight > static_cast(yellowEnd)) { + int redH = barHeight - static_cast(yellowEnd); + if (redH > 0) { + painter.fillRect(r.left(), barTop, r.width(), redH, + QColor(244, 67, 54)); + } + } + } + + if (m_peakHold > 0.0f) { + int peakY = r.height() - static_cast(m_peakHold * r.height()); + peakY = std::clamp(peakY, r.top(), r.bottom()); + painter.setPen(QColor(255, 255, 255)); + painter.drawLine(r.left(), peakY, r.right(), peakY); + } +} diff --git a/gui/AudioLevelMeter.h b/gui/AudioLevelMeter.h new file mode 100644 index 0000000..b9ef1e1 --- /dev/null +++ b/gui/AudioLevelMeter.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +class AudioLevelMeter : public QWidget { + Q_OBJECT +public: + explicit AudioLevelMeter(QWidget *parent = nullptr); + + void setLevel(float level); + float level() const; + float peakHold() const; + void resetPeakHold(); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + float m_level = 0.0f; + float m_peakHold = 0.0f; + int m_peakHoldFrames = 0; + + static constexpr int kPeakHoldDuration = 6; + static constexpr float kPeakDecayRate = 0.02f; +}; diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 94a4724..bf4cc30 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1,3 +1,4 @@ +#include "AudioLevelMeter.h" #include "GraphEditorWidget.h" #include "PresetManager.h" #include "VolumeWidgets.h" @@ -217,7 +218,55 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, presetsLayout->addWidget(loadPresetBtn); presetsLayout->addStretch(); - m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); + auto *metersTab = new QWidget(); + auto *metersLayout = new QVBoxLayout(metersTab); + metersLayout->setContentsMargins(8, 8, 8, 8); + metersLayout->setSpacing(8); + + auto *masterLabel = new QLabel(QStringLiteral("MASTER OUTPUT")); + masterLabel->setStyleSheet(QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;" + " background: transparent; }")); + metersLayout->addWidget(masterLabel); + + auto *masterRow = new QWidget(); + auto *masterRowLayout = new QHBoxLayout(masterRow); + masterRowLayout->setContentsMargins(0, 0, 0, 0); + masterRowLayout->setSpacing(4); + m_masterMeterL = new AudioLevelMeter(); + m_masterMeterL->setFixedWidth(18); + m_masterMeterL->setMinimumHeight(100); + m_masterMeterR = new AudioLevelMeter(); + m_masterMeterR->setFixedWidth(18); + m_masterMeterR->setMinimumHeight(100); + masterRowLayout->addStretch(); + masterRowLayout->addWidget(m_masterMeterL); + masterRowLayout->addWidget(m_masterMeterR); + masterRowLayout->addStretch(); + metersLayout->addWidget(masterRow); + + auto *nodeMetersLabel = new QLabel(QStringLiteral("NODE METERS")); + nodeMetersLabel->setStyleSheet(masterLabel->styleSheet()); + metersLayout->addWidget(nodeMetersLabel); + + m_nodeMeterScroll = new QScrollArea(); + m_nodeMeterScroll->setWidgetResizable(true); + m_nodeMeterScroll->setStyleSheet(QStringLiteral( + "QScrollArea { background: transparent; border: none; }" + "QScrollBar:vertical { background: #1a1a1e; width: 8px; }" + "QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }" + "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }")); + m_nodeMeterContainer = new QWidget(); + m_nodeMeterContainer->setStyleSheet(QStringLiteral("background: transparent;")); + auto *nodeMeterLayout = new QVBoxLayout(m_nodeMeterContainer); + nodeMeterLayout->setContentsMargins(0, 0, 0, 0); + nodeMeterLayout->setSpacing(2); + nodeMeterLayout->addStretch(); + m_nodeMeterScroll->setWidget(m_nodeMeterContainer); + metersLayout->addWidget(m_nodeMeterScroll, 1); + + metersTab->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + m_sidebar->addTab(metersTab, QStringLiteral("METERS")); m_mixerScroll = new QScrollArea(); m_mixerScroll->setWidgetResizable(true); @@ -234,6 +283,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, mixerLayout->addStretch(); m_mixerScroll->setWidget(m_mixerContainer); m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER")); + m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); m_splitter = new QSplitter(Qt::Horizontal); m_splitter->addWidget(m_view); @@ -354,11 +404,14 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, [this](QtNodes::NodeId nodeId) { wireVolumeWidget(nodeId); rebuildMixerStrips(); + rebuildNodeMeters(); }); connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, [this](QtNodes::NodeId nodeId) { m_mixerStrips.erase(nodeId); + m_nodeMeters.erase(nodeId); rebuildMixerStrips(); + rebuildNodeMeters(); }); m_saveTimer = new QTimer(this); @@ -386,6 +439,12 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, connect(m_refreshTimer, &QTimer::timeout, this, &GraphEditorWidget::onRefreshTimer); m_refreshTimer->start(500); + + m_meterTimer = new QTimer(this); + m_meterTimer->setTimerType(Qt::PreciseTimer); + connect(m_meterTimer, &QTimer::timeout, this, + &GraphEditorWidget::updateMeters); + m_meterTimer->start(33); } void GraphEditorWidget::onRefreshTimer() { @@ -1212,3 +1271,105 @@ void GraphEditorWidget::rebuildMixerStrips() { static_cast(layout)->addStretch(); } + +void GraphEditorWidget::updateMeters() { + if (!m_client) + return; + + auto master = m_client->MeterPeak(); + if (master.ok()) { + m_masterMeterL->setLevel(master.value.peak_left); + m_masterMeterR->setLevel(master.value.peak_right); + } + + for (auto &[nodeId, row] : m_nodeMeters) { + const WarpNodeData *data = m_model->warpNodeData(nodeId); + if (!data || !row.meter) + continue; + auto peak = m_client->NodeMeterPeak(data->info.id); + if (peak.ok()) { + row.meter->setLevel( + std::max(peak.value.peak_left, peak.value.peak_right)); + } + } +} + +void GraphEditorWidget::rebuildNodeMeters() { + if (!m_nodeMeterContainer || !m_client) + return; + + auto *layout = m_nodeMeterContainer->layout(); + if (!layout) + return; + + std::unordered_map old_pw_ids; + for (const auto &[nid, row] : m_nodeMeters) { + const WarpNodeData *d = m_model->warpNodeData(nid); + if (d) + old_pw_ids[d->info.id.value] = true; + } + + while (layout->count() > 0) { + auto *item = layout->takeAt(0); + if (item->widget()) + item->widget()->deleteLater(); + delete item; + } + m_nodeMeters.clear(); + + auto nodeIds = m_model->allNodeIds(); + std::vector sorted(nodeIds.begin(), nodeIds.end()); + std::sort(sorted.begin(), sorted.end()); + + std::unordered_map new_pw_ids; + for (auto nodeId : sorted) { + const WarpNodeData *data = m_model->warpNodeData(nodeId); + if (!data) + continue; + + new_pw_ids[data->info.id.value] = true; + m_client->EnsureNodeMeter(data->info.id); + + auto *row = new QWidget(); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 0, 0, 0); + rowLayout->setSpacing(6); + + auto *label = new QLabel( + WarpGraphModel::classifyNode(data->info) == WarpNodeType::kApplication + ? QString::fromStdString( + data->info.application_name.empty() + ? data->info.name + : data->info.application_name) + : QString::fromStdString( + data->info.description.empty() + ? data->info.name + : data->info.description)); + label->setStyleSheet(QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }")); + label->setToolTip(QString::fromStdString(data->info.name)); + + auto *meter = new AudioLevelMeter(); + meter->setFixedWidth(26); + meter->setMinimumHeight(70); + + rowLayout->addWidget(label, 1); + rowLayout->addWidget(meter); + + layout->addWidget(row); + + NodeMeterRow meterRow; + meterRow.widget = row; + meterRow.meter = meter; + meterRow.label = label; + m_nodeMeters[nodeId] = meterRow; + } + + static_cast(layout)->addStretch(); + + for (const auto &[pw_id, _] : old_pw_ids) { + if (new_pw_ids.find(pw_id) == new_pw_ids.end()) { + m_client->DisableNodeMeter(warppipe::NodeId{pw_id}); + } + } +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 05dfb7e..bd050b1 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -17,6 +17,7 @@ class BasicGraphicsScene; class GraphicsView; } // namespace QtNodes +class AudioLevelMeter; class WarpGraphModel; class NodeVolumeWidget; class QLabel; @@ -69,6 +70,8 @@ private: void loadPreset(); void wireVolumeWidget(QtNodes::NodeId nodeId); void rebuildMixerStrips(); + void updateMeters(); + void rebuildNodeMeters(); struct PendingPasteLink { std::string outNodeName; @@ -95,4 +98,16 @@ private: QWidget *m_mixerContainer = nullptr; QScrollArea *m_mixerScroll = nullptr; std::unordered_map m_mixerStrips; + + QTimer *m_meterTimer = nullptr; + AudioLevelMeter *m_masterMeterL = nullptr; + AudioLevelMeter *m_masterMeterR = nullptr; + QWidget *m_nodeMeterContainer = nullptr; + QScrollArea *m_nodeMeterScroll = nullptr; + struct NodeMeterRow { + QWidget *widget = nullptr; + AudioLevelMeter *meter = nullptr; + QLabel *label = nullptr; + }; + std::unordered_map m_nodeMeters; }; diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index 62924ea..3f9c9fd 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -142,6 +142,11 @@ struct VolumeState { bool mute = false; }; +struct MeterState { + float peak_left = 0.0f; + float peak_right = 0.0f; +}; + struct MetadataInfo { std::string default_sink_name; std::string default_source_name; @@ -174,6 +179,11 @@ class Client { Status SetNodeVolume(NodeId node, float volume, bool mute); Result GetNodeVolume(NodeId node) const; + Status EnsureNodeMeter(NodeId node); + Status DisableNodeMeter(NodeId node); + Result NodeMeterPeak(NodeId node) const; + Result MeterPeak() const; + Result CreateLink(PortId output, PortId input, const LinkOptions& options); Result CreateLinkByName(std::string_view output_node, std::string_view output_port, @@ -203,6 +213,8 @@ class Client { size_t Test_GetPendingAutoLinkCount() const; Status Test_SetNodeVolume(NodeId node, float volume, bool mute); Result Test_GetNodeVolume(NodeId node) const; + Status Test_SetNodeMeterPeak(NodeId node, float left, float right); + Status Test_SetMasterMeterPeak(float left, float right); #endif private: diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 793e087..a361b4a 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -1,10 +1,13 @@ #include +#include #include +#include #include #include #include #include #include +#include #include #include @@ -241,6 +244,52 @@ static const pw_stream_events kStreamEvents = { .process = StreamProcess, }; +struct MeterStreamData { + uint32_t node_id = 0; + std::string target_name; + pw_stream* stream = nullptr; + spa_hook listener{}; + std::atomic peak_left{0.0f}; + std::atomic peak_right{0.0f}; +}; + +void NodeMeterProcess(void* data) { + auto* meter = static_cast(data); + if (!meter || !meter->stream) { + return; + } + pw_buffer* buf = pw_stream_dequeue_buffer(meter->stream); + if (!buf || !buf->buffer || buf->buffer->n_datas == 0) { + if (buf) { + pw_stream_queue_buffer(meter->stream, buf); + } + return; + } + spa_data* d = &buf->buffer->datas[0]; + if (!d->data || !d->chunk) { + pw_stream_queue_buffer(meter->stream, buf); + return; + } + const float* samples = static_cast(d->data); + uint32_t count = d->chunk->size / sizeof(float); + float left = 0.0f; + float right = 0.0f; + for (uint32_t i = 0; i + 1 < count; i += 2) { + float l = std::fabs(samples[i]); + float r = std::fabs(samples[i + 1]); + if (l > left) left = l; + if (r > right) right = r; + } + meter->peak_left.store(left, std::memory_order_relaxed); + meter->peak_right.store(right, std::memory_order_relaxed); + pw_stream_queue_buffer(meter->stream, buf); +} + +static const pw_stream_events kNodeMeterEvents = { + .version = PW_VERSION_STREAM_EVENTS, + .process = NodeMeterProcess, +}; + } // namespace Status Status::Ok() { @@ -281,6 +330,13 @@ struct Client::Impl { std::unordered_map volume_states; + std::unordered_map meter_states; + std::unordered_set metered_nodes; + MeterState master_meter; + + std::unique_ptr master_meter_data; + std::unordered_map> live_meters; + uint32_t next_rule_id = 1; std::unordered_map route_rules; std::vector pending_auto_links; @@ -307,6 +363,9 @@ struct Client::Impl { void ProcessPendingAutoLinks(); void CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port); void AutoSave(); + void SetupMasterMeter(); + void TeardownMasterMeter(); + void TeardownAllLiveMeters(); static void RegistryGlobal(void* data, uint32_t id, @@ -730,10 +789,14 @@ Status Client::Impl::ConnectLocked() { if (!sync_status.ok()) { return sync_status; } + SetupMasterMeter(); return Status::Ok(); } void Client::Impl::DisconnectLocked() { + TeardownMasterMeter(); + TeardownAllLiveMeters(); + std::unordered_map> links; std::unordered_map> streams; { @@ -982,6 +1045,74 @@ void Client::Impl::AutoSave() { } } +void Client::Impl::SetupMasterMeter() { + if (!thread_loop || !core || master_meter_data) { + return; + } + auto meter = std::make_unique(); + pw_properties* props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", + PW_KEY_MEDIA_CATEGORY, "Capture", + PW_KEY_MEDIA_CLASS, "Stream/Input/Audio", + PW_KEY_STREAM_CAPTURE_SINK, "true", + PW_KEY_STREAM_MONITOR, "true", + PW_KEY_NODE_NAME, "", + nullptr); + + meter->stream = pw_stream_new_simple( + pw_thread_loop_get_loop(thread_loop), + "warppipe-meter", props, &kNodeMeterEvents, meter.get()); + if (!meter->stream) { + return; + } + + uint8_t buffer[512]; + spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); + spa_audio_info_raw info{}; + info.format = SPA_AUDIO_FORMAT_F32; + info.rate = 48000; + info.channels = 2; + info.position[0] = SPA_AUDIO_CHANNEL_FL; + info.position[1] = SPA_AUDIO_CHANNEL_FR; + const spa_pod* params[1]; + params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &info); + + int res = pw_stream_connect( + meter->stream, PW_DIRECTION_INPUT, PW_ID_ANY, + static_cast( + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS), + params, 1); + if (res != 0) { + pw_stream_destroy(meter->stream); + return; + } + master_meter_data = std::move(meter); +} + +void Client::Impl::TeardownMasterMeter() { + if (!master_meter_data) { + return; + } + if (master_meter_data->stream) { + pw_stream_destroy(master_meter_data->stream); + } + master_meter_data.reset(); +} + +void Client::Impl::TeardownAllLiveMeters() { + std::unordered_map> meters; + { + std::lock_guard lock(cache_mutex); + meters.swap(live_meters); + } + for (auto& entry : meters) { + if (entry.second && entry.second->stream) { + pw_stream_destroy(entry.second->stream); + entry.second->stream = nullptr; + } + } +} + int Client::Impl::MetadataProperty(void* data, uint32_t subject, const char* key, const char* type, const char* value) { @@ -1278,6 +1409,140 @@ Result Client::GetNodeVolume(NodeId node) const { return {Status::Ok(), it->second}; } +Status Client::EnsureNodeMeter(NodeId node) { + if (node.value == 0) { + return Status::Error(StatusCode::kInvalidArgument, "invalid node id"); + } + + std::string target_name; + bool capture_sink = false; + { + std::lock_guard lock(impl_->cache_mutex); + auto node_it = impl_->nodes.find(node.value); + if (node_it == impl_->nodes.end()) { + return Status::Error(StatusCode::kNotFound, "node not found"); + } + impl_->metered_nodes.insert(node.value); + if (impl_->meter_states.find(node.value) == impl_->meter_states.end()) { + impl_->meter_states[node.value] = MeterState{}; + } + if (impl_->live_meters.find(node.value) != impl_->live_meters.end()) { + return Status::Ok(); + } + target_name = node_it->second.name; + const auto& mc = node_it->second.media_class; + capture_sink = (mc.find("Sink") != std::string::npos || + mc.find("Duplex") != std::string::npos); + } + + if (!impl_->thread_loop || !impl_->core) { + return Status::Ok(); + } + + pw_thread_loop_lock(impl_->thread_loop); + + auto meter = std::make_unique(); + meter->node_id = node.value; + meter->target_name = target_name; + + pw_properties* props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", + PW_KEY_MEDIA_CATEGORY, "Capture", + PW_KEY_MEDIA_CLASS, "Stream/Input/Audio", + PW_KEY_TARGET_OBJECT, target_name.c_str(), + PW_KEY_STREAM_MONITOR, "true", + PW_KEY_NODE_NAME, "", + nullptr); + if (capture_sink) { + pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true"); + } + + meter->stream = pw_stream_new_simple( + pw_thread_loop_get_loop(impl_->thread_loop), + "warppipe-node-meter", props, &kNodeMeterEvents, meter.get()); + if (!meter->stream) { + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Ok(); + } + + uint8_t buffer[512]; + spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); + spa_audio_info_raw info{}; + info.format = SPA_AUDIO_FORMAT_F32; + info.rate = 48000; + info.channels = 2; + info.position[0] = SPA_AUDIO_CHANNEL_FL; + info.position[1] = SPA_AUDIO_CHANNEL_FR; + const spa_pod* params[1]; + params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &info); + + int res = pw_stream_connect( + meter->stream, PW_DIRECTION_INPUT, PW_ID_ANY, + static_cast( + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS), + params, 1); + if (res != 0) { + pw_stream_destroy(meter->stream); + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Ok(); + } + + { + std::lock_guard lock(impl_->cache_mutex); + impl_->live_meters[node.value] = std::move(meter); + } + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Ok(); +} + +Status Client::DisableNodeMeter(NodeId node) { + std::unique_ptr meter; + { + std::lock_guard lock(impl_->cache_mutex); + impl_->metered_nodes.erase(node.value); + impl_->meter_states.erase(node.value); + auto it = impl_->live_meters.find(node.value); + if (it != impl_->live_meters.end()) { + meter = std::move(it->second); + impl_->live_meters.erase(it); + } + } + if (meter && meter->stream && impl_->thread_loop) { + pw_thread_loop_lock(impl_->thread_loop); + pw_stream_destroy(meter->stream); + meter->stream = nullptr; + pw_thread_loop_unlock(impl_->thread_loop); + } + return Status::Ok(); +} + +Result Client::NodeMeterPeak(NodeId node) const { + std::lock_guard lock(impl_->cache_mutex); + auto live_it = impl_->live_meters.find(node.value); + if (live_it != impl_->live_meters.end() && live_it->second) { + MeterState state; + state.peak_left = live_it->second->peak_left.load(std::memory_order_relaxed); + state.peak_right = live_it->second->peak_right.load(std::memory_order_relaxed); + return {Status::Ok(), state}; + } + auto it = impl_->meter_states.find(node.value); + if (it == impl_->meter_states.end()) { + return {Status::Error(StatusCode::kNotFound, "node not metered"), {}}; + } + return {Status::Ok(), it->second}; +} + +Result Client::MeterPeak() const { + std::lock_guard lock(impl_->cache_mutex); + if (impl_->master_meter_data) { + MeterState state; + state.peak_left = impl_->master_meter_data->peak_left.load(std::memory_order_relaxed); + state.peak_right = impl_->master_meter_data->peak_right.load(std::memory_order_relaxed); + return {Status::Ok(), state}; + } + return {Status::Ok(), impl_->master_meter}; +} + Result Client::CreateLink(PortId output, PortId input, const LinkOptions& options) { Status status = impl_->EnsureConnected(); if (!status.ok()) { @@ -1805,6 +2070,38 @@ Result Client::Test_GetNodeVolume(NodeId node) const { } return {Status::Ok(), it->second}; } + +Status Client::Test_SetNodeMeterPeak(NodeId node, float left, float right) { + if (!impl_) { + return Status::Error(StatusCode::kUnavailable, "no impl"); + } + std::lock_guard lock(impl_->cache_mutex); + float cl = std::clamp(left, 0.0f, 1.0f); + float cr = std::clamp(right, 0.0f, 1.0f); + impl_->meter_states[node.value] = MeterState{cl, cr}; + impl_->metered_nodes.insert(node.value); + auto it = impl_->live_meters.find(node.value); + if (it != impl_->live_meters.end() && it->second) { + it->second->peak_left.store(cl, std::memory_order_relaxed); + it->second->peak_right.store(cr, std::memory_order_relaxed); + } + return Status::Ok(); +} + +Status Client::Test_SetMasterMeterPeak(float left, float right) { + if (!impl_) { + return Status::Error(StatusCode::kUnavailable, "no impl"); + } + std::lock_guard lock(impl_->cache_mutex); + float cl = std::clamp(left, 0.0f, 1.0f); + float cr = std::clamp(right, 0.0f, 1.0f); + impl_->master_meter = MeterState{cl, cr}; + if (impl_->master_meter_data) { + impl_->master_meter_data->peak_left.store(cl, std::memory_order_relaxed); + impl_->master_meter_data->peak_right.store(cr, std::memory_order_relaxed); + } + return Status::Ok(); +} #endif } // namespace warppipe diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 826ebea..0eba604 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -1,5 +1,6 @@ #include +#include "../../gui/AudioLevelMeter.h" #include "../../gui/GraphEditorWidget.h" #include "../../gui/PresetManager.h" #include "../../gui/VolumeWidgets.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include @@ -1151,6 +1153,81 @@ TEST_CASE("preset saves and loads volume state") { QFile::remove(path); } +TEST_CASE("AudioLevelMeter setLevel clamps to 0-1") { + ensureApp(); + AudioLevelMeter meter; + meter.setLevel(0.5f); + REQUIRE(meter.level() == Catch::Approx(0.5f)); + meter.setLevel(-0.5f); + REQUIRE(meter.level() == Catch::Approx(0.0f)); + meter.setLevel(1.5f); + REQUIRE(meter.level() == Catch::Approx(1.0f)); +} + +TEST_CASE("AudioLevelMeter peak hold tracks maximum") { + ensureApp(); + AudioLevelMeter meter; + meter.setLevel(0.8f); + REQUIRE(meter.peakHold() == Catch::Approx(0.8f)); + meter.setLevel(0.3f); + REQUIRE(meter.peakHold() == Catch::Approx(0.8f)); + meter.setLevel(0.9f); + REQUIRE(meter.peakHold() == Catch::Approx(0.9f)); +} + +TEST_CASE("AudioLevelMeter peak decays after hold period") { + ensureApp(); + AudioLevelMeter meter; + meter.setLevel(0.5f); + REQUIRE(meter.peakHold() == Catch::Approx(0.5f)); + for (int i = 0; i < 7; ++i) + meter.setLevel(0.0f); + REQUIRE(meter.peakHold() < 0.5f); + REQUIRE(meter.peakHold() > 0.0f); +} + +TEST_CASE("AudioLevelMeter resetPeakHold clears peak") { + ensureApp(); + AudioLevelMeter meter; + meter.setLevel(0.7f); + REQUIRE(meter.peakHold() == Catch::Approx(0.7f)); + meter.resetPeakHold(); + REQUIRE(meter.peakHold() == Catch::Approx(0.0f)); +} + +TEST_CASE("GraphEditorWidget has METERS tab") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + GraphEditorWidget widget(tc.client.get()); + auto *sidebar = widget.findChild(); + REQUIRE(sidebar != nullptr); + bool found = false; + for (int i = 0; i < sidebar->count(); ++i) { + if (sidebar->tabText(i) == "METERS") { + found = true; + break; + } + } + REQUIRE(found); +} + +TEST_CASE("node meter rows created for injected nodes") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100700, "meter-node", "Audio/Sink")).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100701, 100700, "FL", true)).ok()); + + GraphEditorWidget widget(tc.client.get()); + auto meters = widget.findChildren(); + REQUIRE(meters.size() >= 3); +} + TEST_CASE("volume state cleaned up on node deletion") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } diff --git a/tests/warppipe_tests.cpp b/tests/warppipe_tests.cpp index 8e0fd93..4b70857 100644 --- a/tests/warppipe_tests.cpp +++ b/tests/warppipe_tests.cpp @@ -853,3 +853,109 @@ TEST_CASE("Test_SetNodeVolume fails for nonexistent node") { REQUIRE_FALSE(status.ok()); REQUIRE(status.code == warppipe::StatusCode::kNotFound); } + +TEST_CASE("EnsureNodeMeter and NodeMeterPeak round-trip") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + auto &client = result.value; + + warppipe::NodeInfo node; + node.id = warppipe::NodeId{950}; + node.name = "meter-test"; + node.media_class = "Audio/Sink"; + REQUIRE(client->Test_InsertNode(node).ok()); + + REQUIRE(client->EnsureNodeMeter(warppipe::NodeId{950}).ok()); + + auto peak = client->NodeMeterPeak(warppipe::NodeId{950}); + REQUIRE(peak.ok()); + REQUIRE(peak.value.peak_left == Catch::Approx(0.0f)); + REQUIRE(peak.value.peak_right == Catch::Approx(0.0f)); +} + +TEST_CASE("Test_SetNodeMeterPeak updates peaks") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + auto &client = result.value; + + warppipe::NodeInfo node; + node.id = warppipe::NodeId{951}; + node.name = "meter-set"; + node.media_class = "Audio/Sink"; + REQUIRE(client->Test_InsertNode(node).ok()); + + REQUIRE(client->Test_SetNodeMeterPeak(warppipe::NodeId{951}, 0.6f, 0.8f).ok()); + + auto peak = client->NodeMeterPeak(warppipe::NodeId{951}); + REQUIRE(peak.ok()); + REQUIRE(peak.value.peak_left == Catch::Approx(0.6f)); + REQUIRE(peak.value.peak_right == Catch::Approx(0.8f)); +} + +TEST_CASE("DisableNodeMeter removes metering") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + auto &client = result.value; + + warppipe::NodeInfo node; + node.id = warppipe::NodeId{952}; + node.name = "meter-disable"; + node.media_class = "Audio/Sink"; + REQUIRE(client->Test_InsertNode(node).ok()); + REQUIRE(client->EnsureNodeMeter(warppipe::NodeId{952}).ok()); + REQUIRE(client->DisableNodeMeter(warppipe::NodeId{952}).ok()); + + auto peak = client->NodeMeterPeak(warppipe::NodeId{952}); + REQUIRE_FALSE(peak.ok()); + REQUIRE(peak.status.code == warppipe::StatusCode::kNotFound); +} + +TEST_CASE("MasterMeterPeak defaults to zero") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + + auto peak = result.value->MeterPeak(); + REQUIRE(peak.ok()); + REQUIRE(peak.value.peak_left == Catch::Approx(0.0f)); + REQUIRE(peak.value.peak_right == Catch::Approx(0.0f)); +} + +TEST_CASE("Test_SetMasterMeterPeak updates master peaks") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + + REQUIRE(result.value->Test_SetMasterMeterPeak(0.9f, 0.7f).ok()); + + auto peak = result.value->MeterPeak(); + REQUIRE(peak.ok()); + REQUIRE(peak.value.peak_left == Catch::Approx(0.9f)); + REQUIRE(peak.value.peak_right == Catch::Approx(0.7f)); +} + +TEST_CASE("Test_SetNodeMeterPeak clamps values") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + auto &client = result.value; + + warppipe::NodeInfo node; + node.id = warppipe::NodeId{953}; + node.name = "meter-clamp"; + node.media_class = "Audio/Sink"; + REQUIRE(client->Test_InsertNode(node).ok()); + + REQUIRE(client->Test_SetNodeMeterPeak(warppipe::NodeId{953}, 1.5f, -0.5f).ok()); + + auto peak = client->NodeMeterPeak(warppipe::NodeId{953}); + REQUIRE(peak.ok()); + REQUIRE(peak.value.peak_left == Catch::Approx(1.0f)); + REQUIRE(peak.value.peak_right == Catch::Approx(0.0f)); +} + +TEST_CASE("EnsureNodeMeter fails for nonexistent node") { + auto result = warppipe::Client::Create(DefaultOptions()); + REQUIRE(result.ok()); + + auto status = result.value->EnsureNodeMeter(warppipe::NodeId{999}); + REQUIRE_FALSE(status.ok()); + REQUIRE(status.code == warppipe::StatusCode::kNotFound); +} From e8d3f63f4dcc3c3a5d67d2dec9917ecbefdff480 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 11:28:43 -0700 Subject: [PATCH 04/10] Add persistent links --- gui/GraphEditorWidget.cpp | 2 +- gui/PresetManager.cpp | 2 +- gui/WarpGraphModel.cpp | 2 +- src/warppipe.cpp | 365 +++++++++++++++++++++++++++++++++++++- 4 files changed, 362 insertions(+), 9 deletions(-) diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index bf4cc30..878fe95 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1035,7 +1035,7 @@ void GraphEditorWidget::tryResolvePendingLinks() { } if (foundOut && foundIn) { - m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{}); + m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{.linger = true}); } else { remaining.push_back(pending); } diff --git a/gui/PresetManager.cpp b/gui/PresetManager.cpp index 4322839..ba51577 100644 --- a/gui/PresetManager.cpp +++ b/gui/PresetManager.cpp @@ -186,7 +186,7 @@ bool PresetManager::loadPreset(const QString &path, warppipe::Client *client, std::string inPort = route["in_port"].toString().toStdString(); client->CreateLinkByName(outNode, outPort, inNode, inPort, - warppipe::LinkOptions{}); + warppipe::LinkOptions{.linger = true}); } model->refreshFromClient(); diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index c32f039..a62a63a 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -124,7 +124,7 @@ void WarpGraphModel::addConnection( warppipe::PortId outPortId = outIt->second.outputPorts[outIdx].id; warppipe::PortId inPortId = inIt->second.inputPorts[inIdx].id; - auto result = m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{}); + auto result = m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{.linger = true}); if (!result.ok()) { return; } diff --git a/src/warppipe.cpp b/src/warppipe.cpp index a361b4a..3ddbb7c 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -343,11 +343,25 @@ struct Client::Impl { uint32_t policy_sync_seq = 0; bool policy_sync_pending = false; std::vector> auto_link_proxies; + std::vector> saved_link_proxies; pw_proxy* metadata_proxy = nullptr; spa_hook metadata_listener{}; bool metadata_listener_attached = false; MetadataInfo defaults; + bool loading_config = false; + + struct SavedLink { + std::string out_node; + std::string out_port; + std::string in_node; + std::string in_port; + bool operator==(const SavedLink& o) const { + return out_node == o.out_node && out_port == o.out_port && + in_node == o.in_node && in_port == o.in_port; + } + }; + std::vector saved_links; Status ConnectLocked(); void DisconnectLocked(); @@ -362,6 +376,8 @@ struct Client::Impl { void SchedulePolicySync(); void ProcessPendingAutoLinks(); void CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port); + void ProcessSavedLinks(); + void CreateSavedLinkAsync(uint32_t output_port, uint32_t input_port); void AutoSave(); void SetupMasterMeter(); void TeardownMasterMeter(); @@ -423,7 +439,7 @@ void Client::Impl::RegistryGlobal(void* data, info.is_input = true; } impl->ports[id] = info; - if (!impl->pending_auto_links.empty()) { + if (!impl->pending_auto_links.empty() || !impl->saved_links.empty()) { impl->SchedulePolicySync(); } return; @@ -530,6 +546,7 @@ void Client::Impl::CoreDone(void* data, uint32_t, int seq) { seq >= static_cast(impl->policy_sync_seq)) { impl->policy_sync_pending = false; impl->ProcessPendingAutoLinks(); + impl->ProcessSavedLinks(); } } @@ -806,8 +823,8 @@ void Client::Impl::DisconnectLocked() { } for (auto& entry : links) { LinkProxy* link = entry.second.get(); - if (link && link->proxy) { - pw_proxy_destroy(link->proxy); + if (link) { + spa_hook_remove(&link->listener); link->proxy = nullptr; } } @@ -820,12 +837,19 @@ void Client::Impl::DisconnectLocked() { } } for (auto& entry : auto_link_proxies) { - if (entry && entry->proxy) { - pw_proxy_destroy(entry->proxy); + if (entry) { + spa_hook_remove(&entry->listener); entry->proxy = nullptr; } } auto_link_proxies.clear(); + for (auto& entry : saved_link_proxies) { + if (entry) { + spa_hook_remove(&entry->listener); + entry->proxy = nullptr; + } + } + saved_link_proxies.clear(); if (metadata_listener_attached) { spa_hook_remove(&metadata_listener); metadata_listener_attached = false; @@ -991,8 +1015,129 @@ void Client::Impl::CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port auto_link_proxies.push_back(std::move(link_data)); } +void Client::Impl::ProcessSavedLinks() { + struct LinkSpec { + uint32_t output_port; + uint32_t input_port; + std::string label; + }; + std::vector to_create; + + { + std::lock_guard lock(cache_mutex); + fprintf(stderr, "[warppipe] ProcessSavedLinks: %zu pending, %zu nodes, %zu ports\n", + saved_links.size(), nodes.size(), ports.size()); + for (auto it = saved_links.begin(); it != saved_links.end();) { + uint32_t out_id = 0, in_id = 0; + for (const auto& port_entry : ports) { + const PortInfo& port = port_entry.second; + auto node_it = nodes.find(port.node.value); + if (node_it == nodes.end()) continue; + if (!port.is_input && node_it->second.name == it->out_node && + port.name == it->out_port) { + out_id = port_entry.first; + } + if (port.is_input && node_it->second.name == it->in_node && + port.name == it->in_port) { + in_id = port_entry.first; + } + if (out_id && in_id) break; + } + if (!out_id || !in_id) { + fprintf(stderr, " deferred: %s:%s -> %s:%s (ports not found)\n", + it->out_node.c_str(), it->out_port.c_str(), + it->in_node.c_str(), it->in_port.c_str()); + ++it; + continue; + } + bool exists = false; + for (const auto& link_entry : links) { + if (link_entry.second.output_port.value == out_id && + link_entry.second.input_port.value == in_id) { + exists = true; + break; + } + } + if (exists) { + fprintf(stderr, " already exists: %s:%s -> %s:%s\n", + it->out_node.c_str(), it->out_port.c_str(), + it->in_node.c_str(), it->in_port.c_str()); + it = saved_links.erase(it); + continue; + } + std::string label = it->out_node + ":" + it->out_port + " -> " + + it->in_node + ":" + it->in_port; + to_create.push_back({out_id, in_id, std::move(label)}); + it = saved_links.erase(it); + } + } + + if (to_create.empty()) return; + + std::unordered_map> saved_port_map; + for (const auto& spec : to_create) { + saved_port_map[spec.output_port].push_back(spec.input_port); + } + + std::vector competing_link_ids; + { + std::lock_guard lock(cache_mutex); + for (const auto& link_entry : links) { + auto it = saved_port_map.find(link_entry.second.output_port.value); + if (it == saved_port_map.end()) continue; + uint32_t in_port = link_entry.second.input_port.value; + bool is_saved = false; + for (uint32_t saved_in : it->second) { + if (saved_in == in_port) { is_saved = true; break; } + } + if (!is_saved) { + competing_link_ids.push_back(link_entry.first); + } + } + } + + for (uint32_t id : competing_link_ids) { + fprintf(stderr, " removing competing link %u\n", id); + pw_registry_destroy(registry, id); + } + + for (const auto& spec : to_create) { + fprintf(stderr, " creating: %s (ports %u -> %u)\n", + spec.label.c_str(), spec.output_port, spec.input_port); + CreateSavedLinkAsync(spec.output_port, spec.input_port); + } +} + +void Client::Impl::CreateSavedLinkAsync(uint32_t output_port, + uint32_t input_port) { + if (!core) return; + + pw_properties* props = pw_properties_new(nullptr, nullptr); + if (!props) return; + pw_properties_setf(props, PW_KEY_LINK_OUTPUT_PORT, "%u", output_port); + pw_properties_setf(props, PW_KEY_LINK_INPUT_PORT, "%u", input_port); + pw_properties_set(props, PW_KEY_OBJECT_LINGER, "true"); + + pw_proxy* proxy = reinterpret_cast( + pw_core_create_object(core, "link-factory", + PW_TYPE_INTERFACE_Link, + PW_VERSION_LINK, + &props->dict, 0)); + pw_properties_free(props); + if (!proxy) return; + + auto link_data = std::make_unique(); + link_data->proxy = proxy; + link_data->loop = thread_loop; + pw_proxy_add_listener(proxy, &link_data->listener, &kLinkProxyEvents, + link_data.get()); + + std::lock_guard lock(cache_mutex); + saved_link_proxies.push_back(std::move(link_data)); +} + void Client::Impl::AutoSave() { - if (!options.config_path || options.config_path->empty()) { + if (!options.config_path || options.config_path->empty() || loading_config) { return; } nlohmann::json j; @@ -1033,6 +1178,83 @@ void Client::Impl::AutoSave() { } j["route_rules"] = std::move(rules_array); + nlohmann::json links_array = nlohmann::json::array(); + { + std::lock_guard lock(cache_mutex); + std::vector live; + for (const auto& entry : link_proxies) { + if (!entry.second) { + continue; + } + auto link_it = links.find(entry.first); + if (link_it == links.end()) { + continue; + } + const Link& link = link_it->second; + auto out_port_it = ports.find(link.output_port.value); + auto in_port_it = ports.find(link.input_port.value); + if (out_port_it == ports.end() || in_port_it == ports.end()) { + continue; + } + auto out_node_it = nodes.find(out_port_it->second.node.value); + auto in_node_it = nodes.find(in_port_it->second.node.value); + if (out_node_it == nodes.end() || in_node_it == nodes.end()) { + continue; + } + SavedLink sl{out_node_it->second.name, out_port_it->second.name, + in_node_it->second.name, in_port_it->second.name}; + live.push_back(sl); + nlohmann::json link_obj; + link_obj["out_node"] = sl.out_node; + link_obj["out_port"] = sl.out_port; + link_obj["in_node"] = sl.in_node; + link_obj["in_port"] = sl.in_port; + links_array.push_back(std::move(link_obj)); + } + for (const auto& lp : saved_link_proxies) { + if (!lp || lp->id == SPA_ID_INVALID) continue; + auto link_it = links.find(lp->id); + if (link_it == links.end()) continue; + const Link& link = link_it->second; + auto out_port_it = ports.find(link.output_port.value); + auto in_port_it = ports.find(link.input_port.value); + if (out_port_it == ports.end() || in_port_it == ports.end()) continue; + auto out_node_it = nodes.find(out_port_it->second.node.value); + auto in_node_it = nodes.find(in_port_it->second.node.value); + if (out_node_it == nodes.end() || in_node_it == nodes.end()) continue; + SavedLink sl{out_node_it->second.name, out_port_it->second.name, + in_node_it->second.name, in_port_it->second.name}; + bool dup = false; + for (const auto& l : live) { + if (l == sl) { dup = true; break; } + } + if (!dup) { + live.push_back(sl); + nlohmann::json link_obj; + link_obj["out_node"] = sl.out_node; + link_obj["out_port"] = sl.out_port; + link_obj["in_node"] = sl.in_node; + link_obj["in_port"] = sl.in_port; + links_array.push_back(std::move(link_obj)); + } + } + for (const auto& sl : saved_links) { + bool already = false; + for (const auto& l : live) { + if (l == sl) { already = true; break; } + } + if (!already) { + nlohmann::json link_obj; + link_obj["out_node"] = sl.out_node; + link_obj["out_port"] = sl.out_port; + link_obj["in_node"] = sl.in_node; + link_obj["in_port"] = sl.in_port; + links_array.push_back(std::move(link_obj)); + } + } + } + j["links"] = std::move(links_array); + std::string tmp_path = *options.config_path + ".tmp"; std::ofstream file(tmp_path); if (!file.is_open()) { @@ -1648,6 +1870,7 @@ Result Client::CreateLink(PortId output, PortId input, const LinkOptions& } pw_thread_loop_unlock(impl_->thread_loop); + impl_->AutoSave(); return {Status::Ok(), link}; } @@ -1707,8 +1930,22 @@ Status Client::RemoveLink(LinkId link) { pw_thread_loop_lock(impl_->thread_loop); bool removed = false; + Impl::SavedLink removed_link; { std::lock_guard lock(impl_->cache_mutex); + auto link_it = impl_->links.find(link.value); + if (link_it != impl_->links.end()) { + auto op = impl_->ports.find(link_it->second.output_port.value); + auto ip = impl_->ports.find(link_it->second.input_port.value); + if (op != impl_->ports.end() && ip != impl_->ports.end()) { + auto on = impl_->nodes.find(op->second.node.value); + auto in_ = impl_->nodes.find(ip->second.node.value); + if (on != impl_->nodes.end() && in_ != impl_->nodes.end()) { + removed_link = {on->second.name, op->second.name, + in_->second.name, ip->second.name}; + } + } + } auto it = impl_->link_proxies.find(link.value); if (it != impl_->link_proxies.end()) { if (it->second && it->second->proxy) { @@ -1730,6 +1967,14 @@ Status Client::RemoveLink(LinkId link) { } pw_thread_loop_unlock(impl_->thread_loop); + if (removed) { + if (!removed_link.out_node.empty()) { + std::lock_guard lock(impl_->cache_mutex); + auto& sl = impl_->saved_links; + sl.erase(std::remove(sl.begin(), sl.end(), removed_link), sl.end()); + } + impl_->AutoSave(); + } return removed ? Status::Ok() : Status::Error(StatusCode::kNotFound, "link not found"); } @@ -1886,8 +2131,85 @@ Status Client::SaveConfig(std::string_view path) { } } + nlohmann::json links_array = nlohmann::json::array(); + { + std::lock_guard lock(impl_->cache_mutex); + std::vector live; + for (const auto& entry : impl_->link_proxies) { + if (!entry.second) { + continue; + } + auto link_it = impl_->links.find(entry.first); + if (link_it == impl_->links.end()) { + continue; + } + const Link& link = link_it->second; + auto out_port_it = impl_->ports.find(link.output_port.value); + auto in_port_it = impl_->ports.find(link.input_port.value); + if (out_port_it == impl_->ports.end() || in_port_it == impl_->ports.end()) { + continue; + } + auto out_node_it = impl_->nodes.find(out_port_it->second.node.value); + auto in_node_it = impl_->nodes.find(in_port_it->second.node.value); + if (out_node_it == impl_->nodes.end() || in_node_it == impl_->nodes.end()) { + continue; + } + Impl::SavedLink sl{out_node_it->second.name, out_port_it->second.name, + in_node_it->second.name, in_port_it->second.name}; + live.push_back(sl); + nlohmann::json link_obj; + link_obj["out_node"] = sl.out_node; + link_obj["out_port"] = sl.out_port; + link_obj["in_node"] = sl.in_node; + link_obj["in_port"] = sl.in_port; + links_array.push_back(std::move(link_obj)); + } + for (const auto& lp : impl_->saved_link_proxies) { + if (!lp || lp->id == SPA_ID_INVALID) continue; + auto link_it = impl_->links.find(lp->id); + if (link_it == impl_->links.end()) continue; + const Link& link = link_it->second; + auto out_port_it = impl_->ports.find(link.output_port.value); + auto in_port_it = impl_->ports.find(link.input_port.value); + if (out_port_it == impl_->ports.end() || in_port_it == impl_->ports.end()) continue; + auto out_node_it = impl_->nodes.find(out_port_it->second.node.value); + auto in_node_it = impl_->nodes.find(in_port_it->second.node.value); + if (out_node_it == impl_->nodes.end() || in_node_it == impl_->nodes.end()) continue; + Impl::SavedLink sl{out_node_it->second.name, out_port_it->second.name, + in_node_it->second.name, in_port_it->second.name}; + bool dup = false; + for (const auto& l : live) { + if (l == sl) { dup = true; break; } + } + if (!dup) { + live.push_back(sl); + nlohmann::json link_obj; + link_obj["out_node"] = sl.out_node; + link_obj["out_port"] = sl.out_port; + link_obj["in_node"] = sl.in_node; + link_obj["in_port"] = sl.in_port; + links_array.push_back(std::move(link_obj)); + } + } + for (const auto& sl : impl_->saved_links) { + bool already = false; + for (const auto& l : live) { + if (l == sl) { already = true; break; } + } + if (!already) { + nlohmann::json link_obj; + link_obj["out_node"] = sl.out_node; + link_obj["out_port"] = sl.out_port; + link_obj["in_node"] = sl.in_node; + link_obj["in_port"] = sl.in_port; + links_array.push_back(std::move(link_obj)); + } + } + } + j["virtual_nodes"] = std::move(nodes_array); j["route_rules"] = std::move(rules_array); + j["links"] = std::move(links_array); std::string tmp_path = std::string(path) + ".tmp"; std::ofstream file(tmp_path); @@ -1928,6 +2250,8 @@ Status Client::LoadConfig(std::string_view path) { return Status::Error(StatusCode::kInvalidArgument, "config missing version"); } + impl_->loading_config = true; + if (j.contains("route_rules") && j["route_rules"].is_array()) { for (const auto& rule_obj : j["route_rules"]) { try { @@ -1981,6 +2305,35 @@ Status Client::LoadConfig(std::string_view path) { } } + if (j.contains("links") && j["links"].is_array()) { + { + std::lock_guard lock(impl_->cache_mutex); + for (const auto& link_obj : j["links"]) { + try { + std::string out_node = link_obj.value("out_node", ""); + std::string out_port = link_obj.value("out_port", ""); + std::string in_node = link_obj.value("in_node", ""); + std::string in_port = link_obj.value("in_port", ""); + if (out_node.empty() || out_port.empty() || + in_node.empty() || in_port.empty()) { + continue; + } + impl_->saved_links.push_back({out_node, out_port, in_node, in_port}); + } catch (...) { + continue; + } + } + } + + if (conn_status.ok()) { + pw_thread_loop_lock(impl_->thread_loop); + impl_->SchedulePolicySync(); + pw_thread_loop_unlock(impl_->thread_loop); + } + } + + impl_->loading_config = false; + impl_->AutoSave(); return Status::Ok(); } From b819d6fd659009e3c162c767f5b5c958c6362790 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 11:52:24 -0700 Subject: [PATCH 05/10] GUI M8f: Event-driven updates, deferred link restoration, routing rules UI --- GUI_PLAN.md | 27 ++-- gui/GraphEditorWidget.cpp | 218 +++++++++++++++++++++++++++- gui/GraphEditorWidget.h | 6 + include/warppipe/warppipe.hpp | 4 + src/warppipe.cpp | 234 +++++++++++++++++-------------- tests/gui/warppipe_gui_tests.cpp | 72 ++++++++++ 6 files changed, 440 insertions(+), 121 deletions(-) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 419b159..560715e 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -274,19 +274,20 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Remove meter rows when nodes deleted - [x] `rebuildNodeMeters()` wired to `nodeCreated`/`nodeDeleted` signals - [x] Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation -- [ ] Milestone 8f - Architecture and Routing Rules - - [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks - - [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)` - - [ ] `linkAdded(LinkInfo)`, `linkRemoved(uint32_t)` - - [ ] Keep polling as fallback if signals not available - - [ ] Link intent system: remember intended links by stable key, restore when nodes reappear - - [ ] `rememberLinkIntent(LinkInfo)` — store stable_id:port_name pairs - - [ ] `tryRestoreLinks()` — called on node add, resolves stored intents - - [ ] Persist link intents in layout JSON - - [ ] Add routing rule UI (separate panel or dialog) - - [ ] List existing rules from `Client::ListRouteRules()` - - [ ] Add/remove rules with RuleMatch fields - - [ ] Show which nodes are affected by rules +- [x] Milestone 8f - Architecture and Routing Rules + - [x] Event-driven updates: core `SetChangeCallback()` fires on registry changes, GUI debounces via 50ms QTimer + QueuedConnection marshal (2s polling kept as fallback) + - [x] `Client::SetChangeCallback(ChangeCallback)` — fires from PW thread on node/port/link add/remove + - [x] `NotifyChange()` uses dedicated `change_cb_mutex` (not cache_mutex) to avoid lock ordering issues + - [x] GUI marshals to Qt thread via `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` + - [x] Link intent system: implemented via core `saved_links` + deferred `ProcessSavedLinks()` + - [x] `LoadConfig()` parses links into `saved_links` vector (stable node:port name pairs) + - [x] `ProcessSavedLinks()` resolves names → port IDs on each CoreDone, creates via `CreateSavedLinkAsync()` + - [x] Competing links from WirePlumber auto-removed after saved link creation + - [x] Persisted in config.json `links` array (not layout JSON — core owns link state) + - [x] Add routing rule UI (RULES sidebar tab) + - [x] List existing rules from `Client::ListRouteRules()` as styled cards + - [x] Add rules via dialog with Application Name, Process Binary, Media Role, Target Node fields + - [x] Delete rules via per-card ✕ button --- diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 878fe95..a7b32c6 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -14,10 +14,14 @@ #include #include #include +#include #include #include +#include +#include #include #include +#include #include #include #include @@ -285,6 +289,18 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER")); m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); + m_rulesScroll = new QScrollArea(); + m_rulesScroll->setWidgetResizable(true); + m_rulesScroll->setStyleSheet(m_mixerScroll->styleSheet()); + m_rulesContainer = new QWidget(); + m_rulesContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + auto *rulesLayout = new QVBoxLayout(m_rulesContainer); + rulesLayout->setContentsMargins(8, 8, 8, 8); + rulesLayout->setSpacing(6); + rulesLayout->addStretch(); + m_rulesScroll->setWidget(m_rulesContainer); + m_sidebar->addTab(m_rulesScroll, QStringLiteral("RULES")); + m_splitter = new QSplitter(Qt::Horizontal); m_splitter->addWidget(m_view); m_splitter->addWidget(m_sidebar); @@ -405,6 +421,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, wireVolumeWidget(nodeId); rebuildMixerStrips(); rebuildNodeMeters(); + rebuildRulesList(); }); connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, [this](QtNodes::NodeId nodeId) { @@ -412,6 +429,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, m_nodeMeters.erase(nodeId); rebuildMixerStrips(); rebuildNodeMeters(); + rebuildRulesList(); }); m_saveTimer = new QTimer(this); @@ -421,6 +439,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, &GraphEditorWidget::saveLayoutWithViewState); m_model->refreshFromClient(); + rebuildRulesList(); if (!hasLayout) { m_model->autoArrange(); } @@ -435,10 +454,24 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &GraphEditorWidget::saveLayoutWithViewState); + m_changeTimer = new QTimer(this); + m_changeTimer->setSingleShot(true); + m_changeTimer->setInterval(50); + connect(m_changeTimer, &QTimer::timeout, this, + &GraphEditorWidget::onRefreshTimer); + + if (m_client) { + m_client->SetChangeCallback([this] { + QMetaObject::invokeMethod(m_changeTimer, + qOverload<>(&QTimer::start), + Qt::QueuedConnection); + }); + } + m_refreshTimer = new QTimer(this); connect(m_refreshTimer, &QTimer::timeout, this, &GraphEditorWidget::onRefreshTimer); - m_refreshTimer->start(500); + m_refreshTimer->start(2000); m_meterTimer = new QTimer(this); m_meterTimer->setTimerType(Qt::PreciseTimer); @@ -1373,3 +1406,186 @@ void GraphEditorWidget::rebuildNodeMeters() { } } } + +void GraphEditorWidget::rebuildRulesList() { + if (!m_rulesContainer || !m_client) + return; + + auto *layout = m_rulesContainer->layout(); + if (!layout) + return; + + while (layout->count() > 0) { + auto *item = layout->takeAt(0); + if (item->widget()) + item->widget()->deleteLater(); + delete item; + } + + const QString labelStyle = QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }"); + const QString valueStyle = QStringLiteral( + "QLabel { color: #ecf0f6; font-size: 12px; background: transparent; }"); + const QString btnStyle = QStringLiteral( + "QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 6px 12px; }" + "QPushButton:hover { background: #3a3a44; }" + "QPushButton:pressed { background: #44444e; }"); + const QString delBtnStyle = QStringLiteral( + "QPushButton { background: transparent; color: #a05050; border: none;" + " font-size: 14px; font-weight: bold; padding: 2px 6px; }" + "QPushButton:hover { color: #e05050; }"); + + auto *header = new QLabel(QStringLiteral("ROUTING RULES")); + header->setStyleSheet(QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;" + " background: transparent; }")); + layout->addWidget(header); + + auto rulesResult = m_client->ListRouteRules(); + if (rulesResult.ok()) { + for (const auto &rule : rulesResult.value) { + auto *card = new QWidget(); + card->setStyleSheet(QStringLiteral( + "QWidget { background: #24242a; border-radius: 4px; }")); + auto *cardLayout = new QHBoxLayout(card); + cardLayout->setContentsMargins(8, 6, 4, 6); + cardLayout->setSpacing(8); + + QString matchText; + if (!rule.match.application_name.empty()) + matchText += QStringLiteral("app: ") + + QString::fromStdString(rule.match.application_name); + if (!rule.match.process_binary.empty()) { + if (!matchText.isEmpty()) matchText += QStringLiteral(", "); + matchText += QStringLiteral("bin: ") + + QString::fromStdString(rule.match.process_binary); + } + if (!rule.match.media_role.empty()) { + if (!matchText.isEmpty()) matchText += QStringLiteral(", "); + matchText += QStringLiteral("role: ") + + QString::fromStdString(rule.match.media_role); + } + + auto *infoLayout = new QVBoxLayout(); + infoLayout->setContentsMargins(0, 0, 0, 0); + infoLayout->setSpacing(2); + + auto *matchLabel = new QLabel(matchText); + matchLabel->setStyleSheet(valueStyle); + infoLayout->addWidget(matchLabel); + + auto *targetLabel = new QLabel( + QStringLiteral("\xe2\x86\x92 ") + + QString::fromStdString(rule.target_node)); + targetLabel->setStyleSheet(labelStyle); + infoLayout->addWidget(targetLabel); + + cardLayout->addLayout(infoLayout, 1); + + auto *delBtn = new QPushButton(QStringLiteral("\xe2\x9c\x95")); + delBtn->setFixedSize(24, 24); + delBtn->setStyleSheet(delBtnStyle); + warppipe::RuleId ruleId = rule.id; + connect(delBtn, &QPushButton::clicked, this, [this, ruleId]() { + m_client->RemoveRouteRule(ruleId); + rebuildRulesList(); + }); + cardLayout->addWidget(delBtn); + + layout->addWidget(card); + } + } + + auto *addBtn = new QPushButton(QStringLiteral("Add Rule...")); + addBtn->setStyleSheet(btnStyle); + connect(addBtn, &QPushButton::clicked, this, + &GraphEditorWidget::showAddRuleDialog); + layout->addWidget(addBtn); + + static_cast(layout)->addStretch(); +} + +void GraphEditorWidget::showAddRuleDialog() { + if (!m_client) + return; + + QDialog dlg(this); + dlg.setWindowTitle(QStringLiteral("Add Routing Rule")); + dlg.setStyleSheet(QStringLiteral( + "QDialog { background: #1e1e22; }" + "QLabel { color: #ecf0f6; }" + "QLineEdit { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 4px 8px; }" + "QComboBox { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 4px 8px; }" + "QComboBox::drop-down { border: none; }" + "QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;" + " selection-background-color: #3a3a44; }")); + + auto *form = new QFormLayout(&dlg); + form->setContentsMargins(16, 16, 16, 16); + form->setSpacing(8); + + auto *appNameEdit = new QLineEdit(); + appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox")); + form->addRow(QStringLiteral("Application Name:"), appNameEdit); + + auto *processBinEdit = new QLineEdit(); + processBinEdit->setPlaceholderText(QStringLiteral("e.g. firefox")); + form->addRow(QStringLiteral("Process Binary:"), processBinEdit); + + auto *mediaRoleEdit = new QLineEdit(); + mediaRoleEdit->setPlaceholderText(QStringLiteral("e.g. Music")); + form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit); + + auto *targetCombo = new QComboBox(); + auto nodesResult = m_client->ListNodes(); + if (nodesResult.ok()) { + for (const auto &node : nodesResult.value) { + if (node.media_class.find("Sink") != std::string::npos) { + QString label = QString::fromStdString( + node.description.empty() ? node.name : node.description); + targetCombo->addItem(label, QString::fromStdString(node.name)); + } + } + } + form->addRow(QStringLiteral("Target Node:"), targetCombo); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttons->setStyleSheet(QStringLiteral( + "QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 6px 16px; }" + "QPushButton:hover { background: #3a3a44; }")); + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + form->addRow(buttons); + + if (dlg.exec() != QDialog::Accepted) + return; + + std::string appName = appNameEdit->text().trimmed().toStdString(); + std::string procBin = processBinEdit->text().trimmed().toStdString(); + std::string role = mediaRoleEdit->text().trimmed().toStdString(); + std::string target = targetCombo->currentData().toString().toStdString(); + + if (appName.empty() && procBin.empty() && role.empty()) { + QMessageBox::warning(this, QStringLiteral("Invalid Rule"), + QStringLiteral("At least one match field must be filled.")); + return; + } + if (target.empty()) { + QMessageBox::warning(this, QStringLiteral("Invalid Rule"), + QStringLiteral("A target node must be selected.")); + return; + } + + warppipe::RouteRule rule; + rule.match.application_name = appName; + rule.match.process_binary = procBin; + rule.match.media_role = role; + rule.target_node = target; + m_client->AddRouteRule(rule); + rebuildRulesList(); +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index bd050b1..c327aff 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -72,6 +72,8 @@ private: void rebuildMixerStrips(); void updateMeters(); void rebuildNodeMeters(); + void rebuildRulesList(); + void showAddRuleDialog(); struct PendingPasteLink { std::string outNodeName; @@ -87,6 +89,7 @@ private: QSplitter *m_splitter = nullptr; QTabWidget *m_sidebar = nullptr; QTimer *m_refreshTimer = nullptr; + QTimer *m_changeTimer = nullptr; QTimer *m_saveTimer = nullptr; QString m_layoutPath; QString m_presetDir; @@ -110,4 +113,7 @@ private: QLabel *label = nullptr; }; std::unordered_map m_nodeMeters; + + QWidget *m_rulesContainer = nullptr; + QScrollArea *m_rulesScroll = nullptr; }; diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index 3f9c9fd..e04046e 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -203,6 +204,9 @@ class Client { Status SaveConfig(std::string_view path); Status LoadConfig(std::string_view path); + using ChangeCallback = std::function; + void SetChangeCallback(ChangeCallback callback); + #ifdef WARPPIPE_TESTING Status Test_InsertNode(const NodeInfo& node); Status Test_InsertPort(const PortInfo& port); diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 3ddbb7c..36fe7e0 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -363,6 +363,10 @@ struct Client::Impl { }; std::vector saved_links; + std::mutex change_cb_mutex; + Client::ChangeCallback change_callback; + void NotifyChange(); + Status ConnectLocked(); void DisconnectLocked(); Status SyncLocked(); @@ -407,59 +411,66 @@ void Client::Impl::RegistryGlobal(void* data, return; } + bool notify = false; + + { + std::lock_guard lock(impl->cache_mutex); + + if (IsNodeType(type)) { + NodeInfo info; + info.id = NodeId{id}; + info.name = LookupString(props, PW_KEY_NODE_NAME); + info.description = LookupString(props, PW_KEY_NODE_DESCRIPTION); + info.media_class = LookupString(props, PW_KEY_MEDIA_CLASS); + info.application_name = LookupString(props, PW_KEY_APP_NAME); + info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY); + info.media_role = LookupString(props, PW_KEY_MEDIA_ROLE); + std::string virt_str = LookupString(props, PW_KEY_NODE_VIRTUAL); + info.is_virtual = (virt_str == "true"); + impl->nodes[id] = info; + impl->CheckRulesForNode(info); + notify = true; + } else if (IsPortType(type)) { + PortInfo info; + info.id = PortId{id}; + info.name = LookupString(props, PW_KEY_PORT_NAME); + info.is_input = false; + uint32_t node_id = 0; + if (ParseUint32(SafeLookup(props, PW_KEY_NODE_ID), &node_id)) { + info.node = NodeId{node_id}; + } + const char* direction = SafeLookup(props, PW_KEY_PORT_DIRECTION); + if (direction && spa_streq(direction, "in")) { + info.is_input = true; + } + impl->ports[id] = info; + if (!impl->pending_auto_links.empty() || !impl->saved_links.empty()) { + impl->SchedulePolicySync(); + } + notify = true; + } else if (IsLinkType(type)) { + Link info; + info.id = LinkId{id}; + uint32_t out_port = 0; + uint32_t in_port = 0; + if (ParseUint32(SafeLookup(props, PW_KEY_LINK_OUTPUT_PORT), &out_port)) { + info.output_port = PortId{out_port}; + } + if (ParseUint32(SafeLookup(props, PW_KEY_LINK_INPUT_PORT), &in_port)) { + info.input_port = PortId{in_port}; + } + impl->links[id] = std::move(info); + notify = true; + } + } + + if (notify) { + impl->NotifyChange(); + return; + } + std::lock_guard lock(impl->cache_mutex); - if (IsNodeType(type)) { - NodeInfo info; - info.id = NodeId{id}; - info.name = LookupString(props, PW_KEY_NODE_NAME); - info.description = LookupString(props, PW_KEY_NODE_DESCRIPTION); - info.media_class = LookupString(props, PW_KEY_MEDIA_CLASS); - info.application_name = LookupString(props, PW_KEY_APP_NAME); - info.process_binary = LookupString(props, PW_KEY_APP_PROCESS_BINARY); - info.media_role = LookupString(props, PW_KEY_MEDIA_ROLE); - std::string virt_str = LookupString(props, PW_KEY_NODE_VIRTUAL); - info.is_virtual = (virt_str == "true"); - impl->nodes[id] = info; - impl->CheckRulesForNode(info); - return; - } - - if (IsPortType(type)) { - PortInfo info; - info.id = PortId{id}; - info.name = LookupString(props, PW_KEY_PORT_NAME); - info.is_input = false; - uint32_t node_id = 0; - if (ParseUint32(SafeLookup(props, PW_KEY_NODE_ID), &node_id)) { - info.node = NodeId{node_id}; - } - const char* direction = SafeLookup(props, PW_KEY_PORT_DIRECTION); - if (direction && spa_streq(direction, "in")) { - info.is_input = true; - } - impl->ports[id] = info; - if (!impl->pending_auto_links.empty() || !impl->saved_links.empty()) { - impl->SchedulePolicySync(); - } - return; - } - - if (IsLinkType(type)) { - Link info; - info.id = LinkId{id}; - uint32_t out_port = 0; - uint32_t in_port = 0; - if (ParseUint32(SafeLookup(props, PW_KEY_LINK_OUTPUT_PORT), &out_port)) { - info.output_port = PortId{out_port}; - } - if (ParseUint32(SafeLookup(props, PW_KEY_LINK_INPUT_PORT), &in_port)) { - info.input_port = PortId{in_port}; - } - impl->links[id] = std::move(info); - return; - } - if (type && spa_streq(type, PW_TYPE_INTERFACE_Metadata)) { const char* meta_name = SafeLookup(props, "metadata.name"); if (meta_name && spa_streq(meta_name, "default") && !impl->metadata_proxy) { @@ -487,50 +498,49 @@ void Client::Impl::RegistryGlobalRemove(void* data, uint32_t id) { return; } - std::lock_guard lock(impl->cache_mutex); - impl->virtual_streams.erase(id); - impl->link_proxies.erase(id); - auto node_it = impl->nodes.find(id); - if (node_it != impl->nodes.end()) { - impl->nodes.erase(node_it); - std::vector removed_ports; - for (auto it = impl->ports.begin(); it != impl->ports.end();) { - if (it->second.node.value == id) { - removed_ports.push_back(it->first); - it = impl->ports.erase(it); - } else { - ++it; - } - } - for (auto it = impl->links.begin(); it != impl->links.end();) { - bool remove_link = false; - for (uint32_t port_id : removed_ports) { - if (it->second.input_port.value == port_id || it->second.output_port.value == port_id) { - remove_link = true; - break; + { + std::lock_guard lock(impl->cache_mutex); + impl->virtual_streams.erase(id); + impl->link_proxies.erase(id); + auto node_it = impl->nodes.find(id); + if (node_it != impl->nodes.end()) { + impl->nodes.erase(node_it); + std::vector removed_ports; + for (auto it = impl->ports.begin(); it != impl->ports.end();) { + if (it->second.node.value == id) { + removed_ports.push_back(it->first); + it = impl->ports.erase(it); + } else { + ++it; } } - if (remove_link) { - it = impl->links.erase(it); - } else { - ++it; + for (auto it = impl->links.begin(); it != impl->links.end();) { + bool remove_link = false; + for (uint32_t port_id : removed_ports) { + if (it->second.input_port.value == port_id || it->second.output_port.value == port_id) { + remove_link = true; + break; + } + } + if (remove_link) { + it = impl->links.erase(it); + } else { + ++it; + } } - } - return; - } - - if (impl->ports.erase(id) > 0) { - for (auto it = impl->links.begin(); it != impl->links.end();) { - if (it->second.input_port.value == id || it->second.output_port.value == id) { - it = impl->links.erase(it); - } else { - ++it; + } else if (impl->ports.erase(id) > 0) { + for (auto it = impl->links.begin(); it != impl->links.end();) { + if (it->second.input_port.value == id || it->second.output_port.value == id) { + it = impl->links.erase(it); + } else { + ++it; + } } + } else { + impl->links.erase(id); } - return; } - - impl->links.erase(id); + impl->NotifyChange(); } void Client::Impl::CoreDone(void* data, uint32_t, int seq) { @@ -589,6 +599,13 @@ void Client::Impl::ClearCache() { policy_sync_pending = false; } +void Client::Impl::NotifyChange() { + std::lock_guard lock(change_cb_mutex); + if (change_callback) { + change_callback(); + } +} + Status Client::Impl::EnsureConnected() { if (connected) { return Status::Ok(); @@ -1025,8 +1042,6 @@ void Client::Impl::ProcessSavedLinks() { { std::lock_guard lock(cache_mutex); - fprintf(stderr, "[warppipe] ProcessSavedLinks: %zu pending, %zu nodes, %zu ports\n", - saved_links.size(), nodes.size(), ports.size()); for (auto it = saved_links.begin(); it != saved_links.end();) { uint32_t out_id = 0, in_id = 0; for (const auto& port_entry : ports) { @@ -1044,9 +1059,6 @@ void Client::Impl::ProcessSavedLinks() { if (out_id && in_id) break; } if (!out_id || !in_id) { - fprintf(stderr, " deferred: %s:%s -> %s:%s (ports not found)\n", - it->out_node.c_str(), it->out_port.c_str(), - it->in_node.c_str(), it->in_port.c_str()); ++it; continue; } @@ -1059,9 +1071,6 @@ void Client::Impl::ProcessSavedLinks() { } } if (exists) { - fprintf(stderr, " already exists: %s:%s -> %s:%s\n", - it->out_node.c_str(), it->out_port.c_str(), - it->in_node.c_str(), it->in_port.c_str()); it = saved_links.erase(it); continue; } @@ -1097,13 +1106,10 @@ void Client::Impl::ProcessSavedLinks() { } for (uint32_t id : competing_link_ids) { - fprintf(stderr, " removing competing link %u\n", id); pw_registry_destroy(registry, id); } for (const auto& spec : to_create) { - fprintf(stderr, " creating: %s (ports %u -> %u)\n", - spec.label.c_str(), spec.output_port, spec.input_port); CreateSavedLinkAsync(spec.output_port, spec.input_port); } } @@ -2227,6 +2233,11 @@ Status Client::SaveConfig(std::string_view path) { return Status::Ok(); } +void Client::SetChangeCallback(ChangeCallback callback) { + std::lock_guard lock(impl_->change_cb_mutex); + impl_->change_callback = std::move(callback); +} + Status Client::LoadConfig(std::string_view path) { if (path.empty()) { return Status::Error(StatusCode::kInvalidArgument, "path is empty"); @@ -2342,9 +2353,12 @@ Status Client::Test_InsertNode(const NodeInfo& node) { if (!impl_) { return Status::Error(StatusCode::kInternal, "client not initialized"); } - std::lock_guard lock(impl_->cache_mutex); - impl_->nodes[node.id.value] = node; - impl_->CheckRulesForNode(node); + { + std::lock_guard lock(impl_->cache_mutex); + impl_->nodes[node.id.value] = node; + impl_->CheckRulesForNode(node); + } + impl_->NotifyChange(); return Status::Ok(); } @@ -2352,8 +2366,11 @@ Status Client::Test_InsertPort(const PortInfo& port) { if (!impl_) { return Status::Error(StatusCode::kInternal, "client not initialized"); } - std::lock_guard lock(impl_->cache_mutex); - impl_->ports[port.id.value] = port; + { + std::lock_guard lock(impl_->cache_mutex); + impl_->ports[port.id.value] = port; + } + impl_->NotifyChange(); return Status::Ok(); } @@ -2361,8 +2378,11 @@ Status Client::Test_InsertLink(const Link& link) { if (!impl_) { return Status::Error(StatusCode::kInternal, "client not initialized"); } - std::lock_guard lock(impl_->cache_mutex); - impl_->links[link.id.value] = link; + { + std::lock_guard lock(impl_->cache_mutex); + impl_->links[link.id.value] = link; + } + impl_->NotifyChange(); return Status::Ok(); } diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 0eba604..d5e2c6d 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -1252,3 +1252,75 @@ TEST_CASE("volume state cleaned up on node deletion") { REQUIRE(state.volume == Catch::Approx(1.0f)); REQUIRE_FALSE(state.mute); } + +TEST_CASE("GraphEditorWidget has RULES tab") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + GraphEditorWidget widget(tc.client.get()); + auto *sidebar = widget.findChild(); + REQUIRE(sidebar != nullptr); + bool found = false; + for (int i = 0; i < sidebar->count(); ++i) { + if (sidebar->tabText(i) == "RULES") { + found = true; + break; + } + } + REQUIRE(found); +} + +TEST_CASE("SetChangeCallback fires on node insert") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + + std::atomic count{0}; + tc.client->SetChangeCallback([&count]() { ++count; }); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100800, "cb-test-node", "Audio/Sink")).ok()); + REQUIRE(count.load() >= 1); +} + +TEST_CASE("SetChangeCallback fires on node remove") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100810, "cb-remove-node", "Audio/Sink")).ok()); + + std::atomic count{0}; + tc.client->SetChangeCallback([&count]() { ++count; }); + + REQUIRE(tc.client->Test_RemoveGlobal(100810).ok()); + REQUIRE(count.load() >= 1); +} + +TEST_CASE("SetChangeCallback can be cleared") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + + std::atomic count{0}; + tc.client->SetChangeCallback([&count]() { ++count; }); + tc.client->SetChangeCallback(nullptr); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100820, "cb-clear-node", "Audio/Sink")).ok()); + REQUIRE(count.load() == 0); +} + +TEST_CASE("sidebar tab order is METERS MIXER PRESETS RULES") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + GraphEditorWidget widget(tc.client.get()); + auto *sidebar = widget.findChild(); + REQUIRE(sidebar != nullptr); + REQUIRE(sidebar->count() >= 4); + REQUIRE(sidebar->tabText(0) == "METERS"); + REQUIRE(sidebar->tabText(1) == "MIXER"); + REQUIRE(sidebar->tabText(2) == "PRESETS"); + REQUIRE(sidebar->tabText(3) == "RULES"); +} From 07a151ebdf2aff41ec6ac0897c5a2e7688cb38a0 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 12:49:47 -0700 Subject: [PATCH 06/10] Fix crash --- gui/GraphEditorWidget.cpp | 33 +++++++++++++++++++++++++++++---- gui/GraphEditorWidget.h | 5 ++++- src/warppipe.cpp | 16 ++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index a7b32c6..e05eef2 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -496,6 +496,12 @@ void GraphEditorWidget::scheduleSaveLayout() { } } +GraphEditorWidget::~GraphEditorWidget() { + if (m_client) { + m_client->SetChangeCallback(nullptr); + } +} + int GraphEditorWidget::nodeCount() const { return static_cast(m_model->allNodeIds().size()); } @@ -712,6 +718,13 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, deleteAction->setShortcut(QKeySequence::Delete); } + QAction *createRuleAction = nullptr; + if (type == WarpNodeType::kApplication) { + menu.addSeparator(); + createRuleAction = menu.addAction(QStringLiteral("Create Rule...")); + } + + menu.addSeparator(); QAction *pasteAction = menu.addAction(QStringLiteral("Paste")); pasteAction->setShortcut(QKeySequence::Paste); pasteAction->setEnabled(!m_clipboardJson.isEmpty() || @@ -732,6 +745,10 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos, deleteSelection(); } else if (chosen == pasteAction) { pasteSelection(QPointF(0, 0)); + } else if (chosen == createRuleAction) { + showAddRuleDialog(data->info.application_name, + data->info.process_binary, + data->info.media_role); } } @@ -1476,14 +1493,14 @@ void GraphEditorWidget::rebuildRulesList() { infoLayout->addWidget(matchLabel); auto *targetLabel = new QLabel( - QStringLiteral("\xe2\x86\x92 ") + + QString(QChar(0x2192)) + QStringLiteral(" ") + QString::fromStdString(rule.target_node)); targetLabel->setStyleSheet(labelStyle); infoLayout->addWidget(targetLabel); cardLayout->addLayout(infoLayout, 1); - auto *delBtn = new QPushButton(QStringLiteral("\xe2\x9c\x95")); + auto *delBtn = new QPushButton(QString(QChar(0x2715))); delBtn->setFixedSize(24, 24); delBtn->setStyleSheet(delBtnStyle); warppipe::RuleId ruleId = rule.id; @@ -1500,13 +1517,15 @@ void GraphEditorWidget::rebuildRulesList() { auto *addBtn = new QPushButton(QStringLiteral("Add Rule...")); addBtn->setStyleSheet(btnStyle); connect(addBtn, &QPushButton::clicked, this, - &GraphEditorWidget::showAddRuleDialog); + [this]() { showAddRuleDialog(); }); layout->addWidget(addBtn); static_cast(layout)->addStretch(); } -void GraphEditorWidget::showAddRuleDialog() { +void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, + const std::string &prefillBin, + const std::string &prefillRole) { if (!m_client) return; @@ -1529,14 +1548,20 @@ void GraphEditorWidget::showAddRuleDialog() { auto *appNameEdit = new QLineEdit(); appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox")); + if (!prefillApp.empty()) + appNameEdit->setText(QString::fromStdString(prefillApp)); form->addRow(QStringLiteral("Application Name:"), appNameEdit); auto *processBinEdit = new QLineEdit(); processBinEdit->setPlaceholderText(QStringLiteral("e.g. firefox")); + if (!prefillBin.empty()) + processBinEdit->setText(QString::fromStdString(prefillBin)); form->addRow(QStringLiteral("Process Binary:"), processBinEdit); auto *mediaRoleEdit = new QLineEdit(); mediaRoleEdit->setPlaceholderText(QStringLiteral("e.g. Music")); + if (!prefillRole.empty()) + mediaRoleEdit->setText(QString::fromStdString(prefillRole)); form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit); auto *targetCombo = new QComboBox(); diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index c327aff..deec287 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -35,6 +35,7 @@ class GraphEditorWidget : public QWidget { public: explicit GraphEditorWidget(warppipe::Client *client, QWidget *parent = nullptr); + ~GraphEditorWidget() override; int nodeCount() const; int linkCount() const; @@ -73,7 +74,9 @@ private: void updateMeters(); void rebuildNodeMeters(); void rebuildRulesList(); - void showAddRuleDialog(); + void showAddRuleDialog(const std::string &prefillApp = {}, + const std::string &prefillBin = {}, + const std::string &prefillRole = {}); struct PendingPasteLink { std::string outNodeName; diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 36fe7e0..593c450 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -1043,6 +1043,22 @@ void Client::Impl::ProcessSavedLinks() { { std::lock_guard lock(cache_mutex); for (auto it = saved_links.begin(); it != saved_links.end();) { + bool covered_by_rule = false; + for (const auto& node_entry : nodes) { + if (node_entry.second.name != it->out_node) continue; + for (const auto& rule_entry : route_rules) { + if (MatchesRule(node_entry.second, rule_entry.second.match) && + rule_entry.second.target_node == it->in_node) { + covered_by_rule = true; + break; + } + } + if (covered_by_rule) break; + } + if (covered_by_rule) { + it = saved_links.erase(it); + continue; + } uint32_t out_id = 0, in_id = 0; for (const auto& port_entry : ports) { const PortInfo& port = port_entry.second; From f78970f9e42205a37b33859d56137ec38c4b3cd4 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 13:04:22 -0700 Subject: [PATCH 07/10] Check proxy ownership before destroying competing links during saved-link restoration --- src/warppipe.cpp | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 593c450..a0bd0dd 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -1110,13 +1110,29 @@ void Client::Impl::ProcessSavedLinks() { for (const auto& link_entry : links) { auto it = saved_port_map.find(link_entry.second.output_port.value); if (it == saved_port_map.end()) continue; + uint32_t link_id = link_entry.first; uint32_t in_port = link_entry.second.input_port.value; - bool is_saved = false; + bool is_ours = false; for (uint32_t saved_in : it->second) { - if (saved_in == in_port) { is_saved = true; break; } + if (saved_in == in_port) { is_ours = true; break; } } - if (!is_saved) { - competing_link_ids.push_back(link_entry.first); + if (!is_ours) { + if (link_proxies.count(link_id)) { + is_ours = true; + } + } + if (!is_ours) { + for (const auto& proxy : auto_link_proxies) { + if (proxy && proxy->id == link_id) { is_ours = true; break; } + } + } + if (!is_ours) { + for (const auto& proxy : saved_link_proxies) { + if (proxy && proxy->id == link_id) { is_ours = true; break; } + } + } + if (!is_ours) { + competing_link_ids.push_back(link_id); } } } From ad45683f21769bb554afcd6f908b828331ad0034 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 13:32:07 -0700 Subject: [PATCH 08/10] Fix race where competing-link sweep destroys auto-links before .bound fires ProcessSavedLinks checked auto_link_proxies by global ID (proxy->id), but this is only set asynchronously in the .bound callback. On a second CoreDone pass (e.g. when virtual Sink ports appear), the sweep could classify a just-created rule link as competing and destroy it. Track (output_port, input_port) on LinkProxy at creation time and match by port pair instead of the racy global ID. --- src/warppipe.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/warppipe.cpp b/src/warppipe.cpp index a0bd0dd..9a97e9d 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -125,6 +125,8 @@ struct LinkProxy { bool failed = false; std::string error; uint32_t id = SPA_ID_INVALID; + uint32_t output_port = 0; + uint32_t input_port = 0; }; void LinkProxyBound(void* data, uint32_t global_id) { @@ -1026,6 +1028,8 @@ void Client::Impl::CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port auto link_data = std::make_unique(); link_data->proxy = proxy; link_data->loop = thread_loop; + link_data->output_port = output_port; + link_data->input_port = input_port; pw_proxy_add_listener(proxy, &link_data->listener, &kLinkProxyEvents, link_data.get()); std::lock_guard lock(cache_mutex); @@ -1122,13 +1126,17 @@ void Client::Impl::ProcessSavedLinks() { } } if (!is_ours) { + uint32_t out_port = link_entry.second.output_port.value; for (const auto& proxy : auto_link_proxies) { - if (proxy && proxy->id == link_id) { is_ours = true; break; } + if (proxy && proxy->output_port == out_port && + proxy->input_port == in_port) { is_ours = true; break; } } } if (!is_ours) { + uint32_t out_port = link_entry.second.output_port.value; for (const auto& proxy : saved_link_proxies) { - if (proxy && proxy->id == link_id) { is_ours = true; break; } + if (proxy && proxy->output_port == out_port && + proxy->input_port == in_port) { is_ours = true; break; } } } if (!is_ours) { @@ -1167,6 +1175,8 @@ void Client::Impl::CreateSavedLinkAsync(uint32_t output_port, auto link_data = std::make_unique(); link_data->proxy = proxy; link_data->loop = thread_loop; + link_data->output_port = output_port; + link_data->input_port = input_port; pw_proxy_add_listener(proxy, &link_data->listener, &kLinkProxyEvents, link_data.get()); @@ -1874,6 +1884,8 @@ Result Client::CreateLink(PortId output, PortId input, const LinkOptions& auto link_proxy = std::make_unique(); link_proxy->proxy = proxy; link_proxy->loop = impl_->thread_loop; + link_proxy->output_port = output.value; + link_proxy->input_port = input.value; pw_proxy_add_listener(proxy, &link_proxy->listener, &kLinkProxyEvents, link_proxy.get()); int wait_attempts = 0; From f1a5e2b2e205cd397f4a4860bd691a19c88b7187 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 14:01:15 -0700 Subject: [PATCH 09/10] Fix boot rule link --- src/warppipe.cpp | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 9a97e9d..2e74367 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -345,6 +345,7 @@ struct Client::Impl { uint32_t policy_sync_seq = 0; bool policy_sync_pending = false; std::vector> auto_link_proxies; + std::vector> auto_link_claimed_pairs; std::vector> saved_link_proxies; pw_proxy* metadata_proxy = nullptr; @@ -557,6 +558,8 @@ void Client::Impl::CoreDone(void* data, uint32_t, int seq) { if (impl->policy_sync_pending && seq >= static_cast(impl->policy_sync_seq)) { impl->policy_sync_pending = false; + fprintf(stderr, "[WP] CoreDone policy sync seq=%d, pending_auto=%zu saved=%zu\n", + seq, impl->pending_auto_links.size(), impl->saved_links.size()); impl->ProcessPendingAutoLinks(); impl->ProcessSavedLinks(); } @@ -598,6 +601,7 @@ void Client::Impl::ClearCache() { ports.clear(); links.clear(); pending_auto_links.clear(); + auto_link_claimed_pairs.clear(); policy_sync_pending = false; } @@ -946,6 +950,8 @@ void Client::Impl::ProcessPendingAutoLinks() { } } if (target_node_id == 0) { + fprintf(stderr, "[WP] AutoLink: target '%s' not found for src node %u\n", + it->target_node_name.c_str(), it->source_node_id); ++it; continue; } @@ -968,6 +974,9 @@ void Client::Impl::ProcessPendingAutoLinks() { } if (src_ports.empty() || tgt_ports.empty()) { + fprintf(stderr, "[WP] AutoLink: src_ports=%zu tgt_ports=%zu for src=%u target=%u('%s')\n", + src_ports.size(), tgt_ports.size(), it->source_node_id, + target_node_id, it->target_node_name.c_str()); ++it; continue; } @@ -988,8 +997,14 @@ void Client::Impl::ProcessPendingAutoLinks() { break; } } + auto_link_claimed_pairs.emplace_back(src_ports[i].id, tgt_ports[i].id); if (!exists) { links_to_create.push_back({src_ports[i].id, tgt_ports[i].id}); + fprintf(stderr, "[WP] AutoLink: will create %u->%u\n", + src_ports[i].id, tgt_ports[i].id); + } else { + fprintf(stderr, "[WP] AutoLink: already exists %u->%u (claimed)\n", + src_ports[i].id, tgt_ports[i].id); } } @@ -1060,6 +1075,9 @@ void Client::Impl::ProcessSavedLinks() { if (covered_by_rule) break; } if (covered_by_rule) { + fprintf(stderr, "[WP] SavedLink: covered_by_rule, skipping %s:%s -> %s:%s\n", + it->out_node.c_str(), it->out_port.c_str(), + it->in_node.c_str(), it->in_port.c_str()); it = saved_links.erase(it); continue; } @@ -1091,6 +1109,9 @@ void Client::Impl::ProcessSavedLinks() { } } if (exists) { + fprintf(stderr, "[WP] SavedLink: already exists %s:%s -> %s:%s\n", + it->out_node.c_str(), it->out_port.c_str(), + it->in_node.c_str(), it->in_port.c_str()); it = saved_links.erase(it); continue; } @@ -1101,6 +1122,7 @@ void Client::Impl::ProcessSavedLinks() { } } + fprintf(stderr, "[WP] SavedLink: %zu links to create\n", to_create.size()); if (to_create.empty()) return; std::unordered_map> saved_port_map; @@ -1132,6 +1154,14 @@ void Client::Impl::ProcessSavedLinks() { proxy->input_port == in_port) { is_ours = true; break; } } } + if (!is_ours) { + uint32_t out_port = link_entry.second.output_port.value; + for (const auto& pair : auto_link_claimed_pairs) { + if (pair.first == out_port && pair.second == in_port) { + is_ours = true; break; + } + } + } if (!is_ours) { uint32_t out_port = link_entry.second.output_port.value; for (const auto& proxy : saved_link_proxies) { @@ -1140,7 +1170,12 @@ void Client::Impl::ProcessSavedLinks() { } } if (!is_ours) { + fprintf(stderr, "[WP] Competing: link %u (%u->%u) has no owner, will destroy\n", + link_id, link_entry.second.output_port.value, in_port); competing_link_ids.push_back(link_id); + } else { + fprintf(stderr, "[WP] Competing: link %u (%u->%u) is ours, keeping\n", + link_id, link_entry.second.output_port.value, in_port); } } } @@ -2084,6 +2119,7 @@ Status Client::RemoveRouteRule(RuleId id) { ++pending_it; } } + impl_->auto_link_claimed_pairs.clear(); } impl_->AutoSave(); @@ -2373,6 +2409,9 @@ Status Client::LoadConfig(std::string_view path) { in_node.empty() || in_port.empty()) { continue; } + fprintf(stderr, "[WP] Config: loaded saved link %s:%s -> %s:%s\n", + out_node.c_str(), out_port.c_str(), + in_node.c_str(), in_port.c_str()); impl_->saved_links.push_back({out_node, out_port, in_node, in_port}); } catch (...) { continue; From 08db414fa9ed4668d477b175b0624795771ad143 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 14:30:17 -0700 Subject: [PATCH 10/10] Rules work better --- src/warppipe.cpp | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 2e74367..b669cfa 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -380,6 +380,7 @@ struct Client::Impl { const VirtualNodeOptions& options); void CheckRulesForNode(const NodeInfo& node); + void EnforceRulesForLink(uint32_t link_id, uint32_t out_port, uint32_t in_port); void SchedulePolicySync(); void ProcessPendingAutoLinks(); void CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port); @@ -463,6 +464,9 @@ void Client::Impl::RegistryGlobal(void* data, info.input_port = PortId{in_port}; } impl->links[id] = std::move(info); + if (!impl->options.policy_only && out_port && in_port) { + impl->EnforceRulesForLink(id, out_port, in_port); + } notify = true; } } @@ -914,6 +918,52 @@ void Client::Impl::CheckRulesForNode(const NodeInfo& node) { } } +void Client::Impl::EnforceRulesForLink(uint32_t link_id, uint32_t out_port, + uint32_t in_port) { + auto port_it = ports.find(out_port); + if (port_it == ports.end()) return; + uint32_t src_node_id = port_it->second.node.value; + auto node_it = nodes.find(src_node_id); + if (node_it == nodes.end()) return; + + for (const auto& rule_entry : route_rules) { + if (!MatchesRule(node_it->second, rule_entry.second.match)) continue; + + uint32_t target_node_id = 0; + for (const auto& n : nodes) { + if (n.second.name == rule_entry.second.target_node) { + target_node_id = n.first; + break; + } + } + if (target_node_id == 0) return; + + auto in_port_it = ports.find(in_port); + if (in_port_it == ports.end()) return; + if (in_port_it->second.node.value == target_node_id) return; + + if (link_proxies.count(link_id)) return; + for (const auto& proxy : auto_link_proxies) { + if (proxy && proxy->output_port == out_port && + proxy->input_port == in_port) return; + } + for (const auto& proxy : saved_link_proxies) { + if (proxy && proxy->output_port == out_port && + proxy->input_port == in_port) return; + } + for (const auto& pair : auto_link_claimed_pairs) { + if (pair.first == out_port && pair.second == in_port) return; + } + + fprintf(stderr, "[WP] EnforceRule: destroying link %u (%u->%u), " + "rule says %s -> %s\n", link_id, out_port, in_port, + node_it->second.name.c_str(), + rule_entry.second.target_node.c_str()); + pw_registry_destroy(registry, link_id); + return; + } +} + void Client::Impl::SchedulePolicySync() { if (policy_sync_pending || !core) { return; @@ -937,6 +987,7 @@ void Client::Impl::ProcessPendingAutoLinks() { uint32_t input_port; }; std::vector links_to_create; + std::vector> batch_pairs; { std::lock_guard lock(cache_mutex); @@ -998,6 +1049,7 @@ void Client::Impl::ProcessPendingAutoLinks() { } } auto_link_claimed_pairs.emplace_back(src_ports[i].id, tgt_ports[i].id); + batch_pairs.emplace_back(src_ports[i].id, tgt_ports[i].id); if (!exists) { links_to_create.push_back({src_ports[i].id, tgt_ports[i].id}); fprintf(stderr, "[WP] AutoLink: will create %u->%u\n", @@ -1012,6 +1064,52 @@ void Client::Impl::ProcessPendingAutoLinks() { } } + if (batch_pairs.empty()) { + for (const auto& spec : links_to_create) { + CreateAutoLinkAsync(spec.output_port, spec.input_port); + } + return; + } + + std::unordered_map> auto_port_map; + for (const auto& pair : batch_pairs) { + auto_port_map[pair.first].push_back(pair.second); + } + + std::vector competing_ids; + { + std::lock_guard lock(cache_mutex); + for (const auto& link_entry : links) { + auto it = auto_port_map.find(link_entry.second.output_port.value); + if (it == auto_port_map.end()) continue; + uint32_t link_id = link_entry.first; + uint32_t in_port = link_entry.second.input_port.value; + bool is_ours = false; + for (uint32_t target_in : it->second) { + if (target_in == in_port) { is_ours = true; break; } + } + if (!is_ours) { + if (link_proxies.count(link_id)) is_ours = true; + } + if (!is_ours) { + uint32_t out_port = link_entry.second.output_port.value; + for (const auto& proxy : saved_link_proxies) { + if (proxy && proxy->output_port == out_port && + proxy->input_port == in_port) { is_ours = true; break; } + } + } + if (!is_ours) { + fprintf(stderr, "[WP] AutoLink competing: link %u (%u->%u) will destroy\n", + link_id, link_entry.second.output_port.value, in_port); + competing_ids.push_back(link_id); + } + } + } + + for (uint32_t id : competing_ids) { + pw_registry_destroy(registry, id); + } + for (const auto& spec : links_to_create) { CreateAutoLinkAsync(spec.output_port, spec.input_port); }