diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 560715e..c733dfe 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -1,4 +1,4 @@ -# Warppipe GUI Plan (Qt6 Node-Based Audio Router) +# Warp Pipe GUI Plan (Qt6 Node-Based Audio Router) ## Overview A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. Visualizes PipeWire audio nodes, ports, and links as draggable nodes with connection lines. Supports creating virtual sinks/sources via context menu and displays ephemeral sources with visual fade when inactive. @@ -395,7 +395,7 @@ Active vs Ghost: option(BUILD_GUI "Build Qt6 GUI application" ON) -if(BUILD_GUI) +if(WARPPIPE_BUILD_GUI) find_package(Qt6 6.2 REQUIRED COMPONENTS Core Widgets) set(CMAKE_AUTOMOC ON) @@ -441,6 +441,7 @@ if(BUILD_GUI) target_link_libraries(warppipe-gui-tests PRIVATE warppipe + Qt6::Core Qt6::Widgets QtNodes Catch2::Catch2WithMain @@ -464,9 +465,8 @@ endif() ### Dependencies - Qt6 >= 6.2 (Core, Widgets) -- Qt6::Test (for GUI test target) - QtNodes (nodeeditor) — fetched via CMake FetchContent -- Catch2 v3 — fetched via CMake FetchContent (shared with existing tests) +- Catch2 v3 — required for GUI tests (via `find_package`) - warppipe library (existing) --- diff --git a/PLAN.md b/PLAN.md index 7c5b693..e09ec4f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,4 +1,4 @@ -# Warppipe Plan (C++ libpipewire library) +# Warp Pipe Plan (C++ libpipewire library) - [x] Milestone 0 - Groundwork and constraints - [x] Choose build system: CMake (confirmed). Define minimal library target and example app target. diff --git a/README.md b/README.md index 7adda47..bb94fe0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # warppipe -A C++17 static library wrapping libpipewire for virtual audio node management, link routing, and per-app routing policy. +Warp Pipe is a C++20 static library wrapping libpipewire for virtual audio node management, link routing, and per-app routing policy. ## Features @@ -8,6 +8,8 @@ A C++17 static library wrapping libpipewire for virtual audio node management, l - **Link management** — connect and disconnect ports by node+port name or ID, with passive and linger options - **Per-app routing rules** — match applications by name, process binary, or media role and auto-route to target sinks - **Persistence** — JSON config with atomic writes, auto-save on change, auto-load on startup +- **Volume control** — set per-node volume and mute state +- **Audio metering** — per-node and master peak meters - **Metadata integration** — read and set default audio sink/source via PipeWire metadata - **Policy-only mode** — observe and set metadata without creating links (avoids fighting WirePlumber) - **Thread-safe** — dedicated PipeWire thread loop; all public methods callable from any thread @@ -18,20 +20,24 @@ Requirements: - CMake 3.20+ - pkg-config - libpipewire-0.3 development files -- C++17 compiler +- C++20 compiler +- Qt6 6.2+ (for the GUI target; disable with `-DWARPPIPE_BUILD_GUI=OFF`) +- Catch2 v3 (for tests; required when `-DWARPPIPE_BUILD_TESTS=ON`) ```sh cmake -S . -B build cmake --build build ``` -Targets: `warppipe` (library), `warppipe_example`, `warppipe_cli`, `warppipe_tests`, `warppipe_perf` +Targets: `warppipe` (library), `warppipe_example`, `warppipe_cli`, `warppipe_tests`, `warppipe_perf`, `warppipe-gui`, `warppipe-gui-tests` ### Dependencies - [libpipewire-0.3](https://pipewire.org/) — system package -- [nlohmann/json](https://github.com/nlohmann/json) — fetched automatically via CMake FetchContent -- [Catch2 v3](https://github.com/catchorg/Catch2) — fetched automatically via CMake FetchContent +- [nlohmann/json](https://github.com/nlohmann/json) — fetched automatically if not installed +- [Catch2 v3](https://github.com/catchorg/Catch2) — required for tests (via `find_package`) +- [Qt6](https://www.qt.io/) — required for `warppipe-gui` (default on) +- [QtNodes](https://github.com/paceholder/nodeeditor) — fetched automatically when GUI is enabled ## Quick Start @@ -77,11 +83,13 @@ warppipe_cli load-config warppipe_cli defaults ``` +`link` uses node and port names (not IDs). `--passive` sets `PW_KEY_LINK_PASSIVE`, and `--linger` sets `PW_KEY_OBJECT_LINGER` so links persist after the client exits. `defaults` prints the current and configured default sink/source names. + `create-sink` and `create-source` block until interrupted with Ctrl-C. ## Configuration -Config is JSON. Set `ConnectionOptions::config_path` to enable auto-save/load, or use `SaveConfig`/`LoadConfig` manually. +Config is JSON. Set `ConnectionOptions::config_path` to enable auto-save/load, or use `SaveConfig`/`LoadConfig` manually. The config persists virtual nodes, routing rules, saved links, and per-node volume/mute state. ```json { @@ -93,7 +101,9 @@ Config is JSON. Set `ConnectionOptions::config_path` to enable auto-save/load, o "rate": 48000, "channels": 2, "loopback": false, - "target_node": "" + "target_node": "", + "volume": 1.0, + "mute": false } ], "route_rules": [ @@ -105,6 +115,14 @@ Config is JSON. Set `ConnectionOptions::config_path` to enable auto-save/load, o }, "target_node": "warppipe-gaming-sink" } + ], + "links": [ + { + "out_node": "Firefox", + "out_port": "output_FL", + "in_node": "warppipe-gaming-sink", + "in_port": "input_FL" + } ] } ``` @@ -126,6 +144,8 @@ In this mode the policy engine still evaluates rules but does not create links. - [API Reference](docs/api.md) — full API, threading model, error model, performance notes - [Config Schema](docs/config-schema.md) — JSON configuration format and persistence behavior +- [GUI Usage](docs/gui-usage.md) — how to run the Qt GUI and capture screenshots +- [Examples](docs/examples.md) — common patterns and usage recipes ## Tests diff --git a/docs/api.md b/docs/api.md index 5760500..54c32c4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,8 +1,8 @@ -# Warppipe API +# Warp Pipe API ## Overview -Warppipe is a C++17 library wrapping libpipewire for virtual audio node management, link routing, and per-app routing policy. The entire public API is in ``. +Warp Pipe is a C++20 library wrapping libpipewire for virtual audio node management, link routing, and per-app routing policy. The entire public API is in ``. ## Quick Start @@ -84,6 +84,19 @@ struct ConnectionOptions { }; ``` +Notes: +- `ThreadingMode::kCallerThread` returns `StatusCode::kNotImplemented`. +- When `autoconnect` is false, operations return `kUnavailable` after a disconnect instead of reconnecting. +- `remote_name` connects to a non-default PipeWire instance. + +## Lifecycle + +```cpp +Status Shutdown(); +``` + +`Shutdown()` stops the PipeWire thread loop and releases resources. It is called automatically by the destructor, but you can invoke it explicitly to tear down early. + ### Policy-Only Mode When `policy_only = true`, the policy engine observes the registry and matches rules, but does not create links. This avoids conflicts with WirePlumber or other session managers. @@ -117,8 +130,73 @@ Result CreateVirtualSource(std::string_view name, const VirtualNo Status RemoveNode(NodeId node); ``` +```cpp +struct VirtualNodeOptions { + AudioFormat format; // rate/channels + VirtualBehavior behavior; // kNull (default) or kLoopback + std::optional target_node; // Required for kLoopback + std::optional media_class_override; // Override PW_KEY_MEDIA_CLASS + std::string display_name; // PW_KEY_MEDIA_NAME / PW_KEY_NODE_DESCRIPTION + std::string group; // PW_KEY_NODE_GROUP +}; +``` + Virtual nodes are PipeWire streams with `PW_KEY_NODE_VIRTUAL = true`. They live as long as the `Client` (or until explicitly removed). Null behavior discards audio; loopback behavior forwards to a target node. +Behavior and validation: +- `format.rate` and `format.channels` must be non-zero (`kInvalidArgument` otherwise). +- `behavior = kLoopback` requires `target_node` and the target must exist (`kInvalidArgument`/`kNotFound`). +- `media_class_override` overrides the PipeWire media class (cannot be an empty string). +- `display_name` and `group` map to `PW_KEY_MEDIA_NAME` / `PW_KEY_NODE_DESCRIPTION` and `PW_KEY_NODE_GROUP`. + +Example loopback node: + +```cpp +warppipe::VirtualNodeOptions opts; +opts.behavior = warppipe::VirtualBehavior::kLoopback; +opts.target_node = "alsa_output.pci-0000_00_1f.3.analog-stereo"; +auto sink = client->CreateVirtualSink("warppipe-loopback", opts); +``` + +## Volume Control + +```cpp +Status SetNodeVolume(NodeId node, float volume, bool mute); +Result GetNodeVolume(NodeId node) const; + +struct VolumeState { + float volume; // 0.0 - 1.5 + bool mute; +}; +``` + +`SetNodeVolume` clamps `volume` to `[0.0, 1.5]` and stores the last known volume/mute state. `GetNodeVolume` returns the cached state (defaults to 1.0/false when unknown). Volume and mute are persisted in config files when saved. + +## Audio Metering + +```cpp +Status EnsureNodeMeter(NodeId node); +Status DisableNodeMeter(NodeId node); +Result NodeMeterPeak(NodeId node) const; +Result MeterPeak() const; + +struct MeterState { + float peak_left; + float peak_right; +}; +``` + +Call `EnsureNodeMeter` before querying `NodeMeterPeak`; otherwise you may receive `kNotFound` for unmetered nodes. `MeterPeak` returns master peak levels gathered by a monitor stream. + +## Change Notifications + +```cpp +using ChangeCallback = std::function; +void SetChangeCallback(ChangeCallback callback); +``` + +The callback fires when the registry cache changes (nodes/ports/links) and when volume/mute updates are observed. It is invoked on the PipeWire thread loop; do not call `Client` methods inside the callback to avoid deadlocks. + ## Link Management ```cpp @@ -131,6 +209,8 @@ Status RemoveLink(LinkId link); Links are created via `link-factory` (`pw_core_create_object`). Use `linger = true` so links persist after the creating client disconnects. Use `passive = true` for monitoring/side-chain links that don't affect the session manager's routing decisions. +`CreateLinkByName` matches by node and port name and returns `kNotFound` if no matching ports are found. + ## Route Rules (Policy Engine) ```cpp @@ -148,6 +228,8 @@ Match criteria (all non-empty fields must match): When a matching node appears, the engine waits for ports to register (via PipeWire sync), then creates links pairwise by port name order. +`AddRouteRule` returns `kInvalidArgument` if no match criteria are provided or if the target node name is empty. + Adding a rule also scans existing nodes for matches. ## Metadata @@ -173,7 +255,13 @@ JSON format. See `docs/config-schema.md` for the schema. When `config_path` is set in `ConnectionOptions`: - Config is loaded automatically after connection. -- Config is saved automatically after mutations (add/remove rules, create/remove virtual nodes). +- Config is saved automatically after mutations (add/remove rules, create/remove virtual nodes, `CreateLink`/`RemoveLink`, `SetNodeVolume`). + +Saved configs include virtual nodes, routing rules, saved links, and per-node volume/mute state. + +## Testing Hooks + +When compiled with `-DWARPPIPE_TESTING`, additional helpers are available for tests (node/port/link injection, forcing disconnects, manual policy triggers, and meter/volume overrides). These APIs are not intended for production use. ## Performance @@ -206,3 +294,5 @@ warppipe_cli save-config warppipe_cli load-config warppipe_cli defaults ``` + +`link` uses node/port names (not IDs). `defaults` prints current and configured default sink/source names. diff --git a/docs/config-schema.md b/docs/config-schema.md index 483a32b..c84b71f 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -1,6 +1,6 @@ -# Warppipe Configuration Schema +# Warp Pipe Configuration Schema -Warppipe uses JSON for configuration persistence. The config file stores virtual nodes and routing rules using stable identifiers (names, not serial IDs). +Warp Pipe uses JSON for configuration persistence. The config file stores virtual nodes, routing rules, saved links, and per-node volume/mute using stable identifiers (names, not serial IDs). ## Schema Version 1 @@ -14,7 +14,9 @@ Warppipe uses JSON for configuration persistence. The config file stores virtual "rate": 48000, "channels": 2, "loopback": false, - "target_node": "" + "target_node": "", + "volume": 1.0, + "mute": false }, { "name": "warppipe-mic-source", @@ -50,6 +52,14 @@ Warppipe uses JSON for configuration persistence. The config file stores virtual }, "target_node": "alsa_output.usb-headset" } + ], + "links": [ + { + "out_node": "Firefox", + "out_port": "output_FL", + "in_node": "warppipe-gaming-sink", + "in_port": "input_FL" + } ] } ``` @@ -64,6 +74,8 @@ Warppipe uses JSON for configuration persistence. The config file stores virtual - `channels` (integer, default 2): Channel count - `loopback` (boolean, default false): Whether node forwards to a target - `target_node` (string, optional): Required when loopback is true +- `volume` (number, default 1.0): Stored node volume (0.0 - 1.5) +- `mute` (boolean, default false): Stored node mute state ### route_rules @@ -72,15 +84,27 @@ Rules match ephemeral audio sources to target sinks by stable application metada - `match.application_name` (string): Match PW_KEY_APP_NAME - `match.process_binary` (string): Match PW_KEY_APP_PROCESS_BINARY - `match.media_role` (string): Match PW_KEY_MEDIA_ROLE +- `id` (integer, optional): Auto-saved rule id (ignored on load) - `target_node` (string, required): Destination node name All non-empty match fields must match (AND logic). At least one match field must be non-empty. +### links + +Saved links are recreated by matching node and port names when both endpoints appear. + +- `out_node` (string, required): Output node name +- `out_port` (string, required): Output port name +- `in_node` (string, required): Input node name +- `in_port` (string, required): Input port name + ## Persistence Behavior - **Auto-save**: When `ConnectionOptions::config_path` is set, config is saved after: - Virtual node created/removed - Routing rule added/removed + - `SetNodeVolume` updates + - `CreateLink`/`RemoveLink` updates - **Load on startup**: When `config_path` is set and the file exists, it is loaded during `Client::Create()` after connection is established. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..34802bb --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,115 @@ +# Warp Pipe Examples + +This page collects common usage patterns for the library and CLI. Snippets assume `#include ` and a successful `Client::Create` call. + +## Connect with auto-save + +```cpp +warppipe::ConnectionOptions opts; +opts.application_name = "my-app"; +opts.config_path = "/home/user/.config/warppipe/config.json"; + +auto result = warppipe::Client::Create(opts); +if (!result.ok()) { + // handle result.status +} +auto& client = result.value; +``` + +## Connect to a non-default PipeWire instance + +```cpp +warppipe::ConnectionOptions opts; +opts.application_name = "my-app"; +opts.remote_name = "pipewire-0"; // or a custom PipeWire instance name + +auto result = warppipe::Client::Create(opts); +``` + +## Per-app routing rule + +```cpp +warppipe::RouteRule rule; +rule.match.application_name = "Firefox"; +rule.target_node = "warppipe-gaming-sink"; + +auto add = client->AddRouteRule(rule); +if (!add.ok()) { + // kInvalidArgument if no match criteria or target is empty +} +``` + +## Loopback sink to a target node + +```cpp +warppipe::VirtualNodeOptions opts; +opts.behavior = warppipe::VirtualBehavior::kLoopback; +opts.target_node = "alsa_output.pci-0000_00_1f.3.analog-stereo"; + +auto sink = client->CreateVirtualSink("warppipe-loopback", opts); +``` + +## Policy-only mode (no auto-links) + +```cpp +warppipe::ConnectionOptions opts; +opts.policy_only = true; + +auto result = warppipe::Client::Create(opts); +auto& client = result.value; + +client->SetDefaultSink("warppipe-gaming-sink"); +client->SetDefaultSource("warppipe-mic-source"); +``` + +## Volume and mute + +```cpp +auto nodes = client->ListNodes(); +if (nodes.ok() && !nodes.value.empty()) { + warppipe::NodeId node = nodes.value.front().id; + client->SetNodeVolume(node, 0.85f, false); + auto state = client->GetNodeVolume(node); +} +``` + +## Audio metering + +```cpp +auto nodes = client->ListNodes(); +if (nodes.ok() && !nodes.value.empty()) { + warppipe::NodeId node = nodes.value.front().id; + client->EnsureNodeMeter(node); + auto peak = client->NodeMeterPeak(node); // kNotFound if not metered +} + +auto master = client->MeterPeak(); +``` + +## Change callback + +```cpp +client->SetChangeCallback([] { + // Fires on registry changes and volume/mute updates. + // Runs on the PipeWire thread loop; do not call Client methods here. +}); +``` + +## CLI workflow: link by name + +``` +warppipe_cli list-nodes +warppipe_cli list-ports + +warppipe_cli link --linger +warppipe_cli unlink +``` + +## Persisted links and volume state + +``` +client->SaveConfig("/tmp/warppipe.json"); +client->LoadConfig("/tmp/warppipe.json"); +``` + +Saved configs include virtual nodes, routing rules, links (by node/port name), and per-node volume/mute state. Links are recreated when both endpoints appear in the registry. diff --git a/docs/gui-usage.md b/docs/gui-usage.md new file mode 100644 index 0000000..f832db0 --- /dev/null +++ b/docs/gui-usage.md @@ -0,0 +1,33 @@ +# Warp Pipe GUI Usage + +## Build + +``` +cmake -S . -B build +cmake --build build +``` + +Disable the GUI target with `-DWARPPIPE_BUILD_GUI=OFF`. + +## Run + +``` +./build/warppipe-gui +``` + +## Configuration + +The GUI uses `ConnectionOptions::config_path` pointed at Qt's `AppConfigLocation` with `/config.json` appended. On Linux this is typically under `~/.config/Warppipe/config.json`. + +## Command-line options + +- `--screenshot `: capture a PNG screenshot and exit +- `--screenshot-delay `: delay before capture (default 800) +- `--debug-screenshot-dir `: save a timestamped screenshot on every graph update +- `--offscreen`: run with `QT_QPA_PLATFORM=offscreen` +- `--help`, `--version` + +## Shortcuts + +- `F12`: capture a screenshot to `~/Pictures/warppipe` with a timestamp +- `Ctrl+Q`: quit diff --git a/docs/milestone-0.md b/docs/milestone-0.md index 38d227c..bfe1cf3 100644 --- a/docs/milestone-0.md +++ b/docs/milestone-0.md @@ -3,7 +3,7 @@ ## Build system - CMake 3.20+ -- C++17 +- C++20 - pkg-config - libpipewire-0.3 development files diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 8dc75df..bfde545 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -48,9 +48,20 @@ #include #include +#include #include #include +namespace { +inline float sliderToVolume(int slider) { + float x = static_cast(slider) / 100.0f; + return x * x * x; +} +inline int volumeToSlider(float volume) { + return static_cast(std::round(std::cbrt(volume) * 100.0f)); +} +} + class DeleteVirtualNodeCommand : public QUndoCommand { public: struct Snapshot { @@ -1192,7 +1203,7 @@ void GraphEditorWidget::wireVolumeWidget(QtNodes::NodeId nodeId) { connect(vol, &NodeVolumeWidget::volumeChanged, this, [this, capturedId](int value) { auto state = m_model->nodeVolumeState(capturedId); - state.volume = static_cast(value) / 100.0f; + state.volume = sliderToVolume(value); m_model->setNodeVolumeState(capturedId, state); }); @@ -1241,6 +1252,8 @@ void GraphEditorWidget::rebuildMixerStrips() { const WarpNodeData *data = m_model->warpNodeData(nodeId); if (!data) continue; + if (!nodeHasVolume(WarpGraphModel::classifyNode(data->info))) + continue; auto *strip = new QWidget(); strip->setStyleSheet(QStringLiteral( @@ -1268,7 +1281,7 @@ void GraphEditorWidget::rebuildMixerStrips() { 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->setValue(volumeToSlider(state.volume)); slider->setStyleSheet(QStringLiteral( "QSlider::groove:horizontal {" " background: #1a1a1e; border-radius: 3px; height: 6px; }" @@ -1301,7 +1314,7 @@ void GraphEditorWidget::rebuildMixerStrips() { connect(slider, &QSlider::valueChanged, this, [this, capturedId](int value) { auto s = m_model->nodeVolumeState(capturedId); - s.volume = static_cast(value) / 100.0f; + s.volume = sliderToVolume(value); m_model->setNodeVolumeState(capturedId, s); }); @@ -1330,7 +1343,7 @@ void GraphEditorWidget::rebuildMixerStrips() { return; QSignalBlocker sb(slider); QSignalBlocker mb(muteBtn); - slider->setValue(static_cast(cur.volume * 100.0f)); + slider->setValue(volumeToSlider(cur.volume)); muteBtn->setChecked(cur.mute); }); diff --git a/gui/VolumeWidgets.cpp b/gui/VolumeWidgets.cpp index ec80874..1e65609 100644 --- a/gui/VolumeWidgets.cpp +++ b/gui/VolumeWidgets.cpp @@ -94,6 +94,8 @@ int NodeVolumeWidget::volume() const { return m_slider->value(); } bool NodeVolumeWidget::isMuted() const { return m_muteBtn->isChecked(); } +bool NodeVolumeWidget::isSliderDown() const { return m_slider->isSliderDown(); } + void NodeVolumeWidget::setVolume(int value) { QSignalBlocker blocker(m_slider); m_slider->setValue(value); diff --git a/gui/VolumeWidgets.h b/gui/VolumeWidgets.h index fa61730..333e693 100644 --- a/gui/VolumeWidgets.h +++ b/gui/VolumeWidgets.h @@ -20,6 +20,7 @@ public: int volume() const; bool isMuted() const; + bool isSliderDown() const; void setVolume(int value); void setMuted(bool muted); diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 80d4dcd..6fcf210 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -13,6 +13,14 @@ #include #include +#include + +namespace { +inline int volumeToSlider(float volume) { + return static_cast(std::round(std::cbrt(volume) * 100.0f)); +} +} + #include #include @@ -82,6 +90,10 @@ bool WarpGraphModel::connectionPossible( if (connectionExists(connectionId)) { return false; } + if (m_ghostNodes.count(connectionId.outNodeId) || + m_ghostNodes.count(connectionId.inNodeId)) { + return false; + } auto outIt = m_nodes.find(connectionId.outNodeId); auto inIt = m_nodes.find(connectionId.inNodeId); @@ -98,6 +110,14 @@ bool WarpGraphModel::connectionPossible( return false; } + WarpNodeType outType = classifyNode(outIt->second.info); + WarpNodeType inType = classifyNode(inIt->second.info); + bool outIsVideo = (outType == WarpNodeType::kVideoSource || outType == WarpNodeType::kVideoSink); + bool inIsVideo = (inType == WarpNodeType::kVideoSource || inType == WarpNodeType::kVideoSink); + if (outIsVideo != inIsVideo) { + return false; + } + return true; } @@ -222,6 +242,9 @@ QVariant WarpGraphModel::portData(QtNodes::NodeId nodeId, const auto &data = it->second; if (role == QtNodes::PortRole::DataType) { + WarpNodeType ntype = classifyNode(data.info); + if (ntype == WarpNodeType::kVideoSource || ntype == WarpNodeType::kVideoSink) + return QString("video"); return QString("audio"); } @@ -471,9 +494,11 @@ void WarpGraphModel::refreshFromClient() { } } - auto *volumeWidget = new NodeVolumeWidget(); - m_volumeWidgets[qtId] = volumeWidget; - m_volumeStates[qtId] = {}; + if (nodeHasVolume(nodeType)) { + auto *volumeWidget = new NodeVolumeWidget(); + m_volumeWidgets[qtId] = volumeWidget; + m_volumeStates[qtId] = {}; + } Q_EMIT nodeCreated(qtId); } @@ -627,6 +652,38 @@ void WarpGraphModel::refreshFromClient() { } } + for (const auto &[pwId, qtId] : m_pwToQt) { + auto volResult = m_client->GetNodeVolume(warppipe::NodeId{pwId}); + if (!volResult.ok()) continue; + + float vol = volResult.value.volume; + bool mute = volResult.value.mute; + int sliderVal = volumeToSlider(vol); + sliderVal = std::clamp(sliderVal, 0, 150); + + auto stateIt = m_volumeStates.find(qtId); + if (stateIt == m_volumeStates.end()) continue; + + NodeVolumeState &cached = stateIt->second; + bool changed = (std::abs(cached.volume - vol) > 1e-4f) || (cached.mute != mute); + if (!changed) continue; + + NodeVolumeState previous = cached; + cached.volume = vol; + cached.mute = mute; + + auto wIt = m_volumeWidgets.find(qtId); + if (wIt != m_volumeWidgets.end()) { + auto *vw = static_cast(wIt->second); + if (!vw->isSliderDown()) { + vw->setVolume(sliderVal); + vw->setMuted(mute); + } + } + + Q_EMIT nodeVolumeChanged(qtId, previous, cached); + } + m_refreshing = false; } @@ -761,6 +818,12 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { if (mc == "Stream/Output/Audio" || mc == "Stream/Input/Audio") { return WarpNodeType::kApplication; } + if (mc == "Video/Source") { + return WarpNodeType::kVideoSource; + } + if (mc == "Video/Sink") { + return WarpNodeType::kVideoSink; + } return WarpNodeType::kUnknown; } @@ -788,7 +851,7 @@ void WarpGraphModel::setNodeVolumeState(QtNodes::NodeId nodeId, if (wIt != m_volumeWidgets.end()) { auto *w = qobject_cast(wIt->second); if (w) { - w->setVolume(static_cast(state.volume * 100.0f)); + w->setVolume(volumeToSlider(state.volume)); w->setMuted(state.mute); } } @@ -1031,9 +1094,11 @@ bool WarpGraphModel::loadLayout(const QString &path) { ? m_positions.at(qtId) : QPointF(0, 0); - auto *volumeWidget = new NodeVolumeWidget(); - m_volumeWidgets[qtId] = volumeWidget; - m_volumeStates[qtId] = {}; + if (nodeHasVolume(classifyNode(info))) { + auto *volumeWidget = new NodeVolumeWidget(); + m_volumeWidgets[qtId] = volumeWidget; + m_volumeStates[qtId] = {}; + } Q_EMIT nodeCreated(qtId); } @@ -1065,6 +1130,7 @@ void WarpGraphModel::autoArrange() { Column sources; Column apps; Column sinks; + Column video; for (const auto &[qtId, data] : m_nodes) { WarpNodeType type = classifyNode(data.info); @@ -1081,6 +1147,11 @@ void WarpGraphModel::autoArrange() { apps.ids.push_back(qtId); apps.maxWidth = std::max(apps.maxWidth, w); break; + case WarpNodeType::kVideoSource: + case WarpNodeType::kVideoSink: + video.ids.push_back(qtId); + video.maxWidth = std::max(video.maxWidth, w); + break; default: sinks.ids.push_back(qtId); sinks.maxWidth = std::max(sinks.maxWidth, w); @@ -1106,6 +1177,10 @@ void WarpGraphModel::autoArrange() { layoutColumn(apps, x); x += apps.maxWidth + kHorizontalGap * 3; layoutColumn(sinks, x); + if (!video.ids.empty()) { + x += sinks.maxWidth + kHorizontalGap * 3; + layoutColumn(video, x); + } } QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) { @@ -1128,6 +1203,12 @@ QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) { case WarpNodeType::kApplication: base = QColor(138, 104, 72); break; + case WarpNodeType::kVideoSource: + base = QColor(120, 80, 130); + break; + case WarpNodeType::kVideoSink: + base = QColor(100, 70, 140); + break; default: base = QColor(86, 94, 108); break; diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index b339057..807383d 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -20,8 +20,21 @@ enum class WarpNodeType : uint8_t { kVirtualSink, kVirtualSource, kApplication, + kVideoSource, + kVideoSink, }; +inline bool nodeHasVolume(WarpNodeType type) { + switch (type) { + case WarpNodeType::kVideoSource: + case WarpNodeType::kVideoSink: + case WarpNodeType::kUnknown: + return false; + default: + return true; + } +} + struct WarpNodeData { warppipe::NodeInfo info; std::vector inputPorts; diff --git a/perf/README.md b/perf/README.md index 7d515ae..3683b9b 100644 --- a/perf/README.md +++ b/perf/README.md @@ -11,6 +11,21 @@ cmake --build build ## Run +Usage: +``` +./build/warppipe_perf --mode create-destroy|registry|links|policy|e2e [options] +``` + +Options: +- `--type` sink|source|both (default `sink`) +- `--count` N (default 200; per-type when `--type both`) +- `--events` N (registry mode, default 100) +- `--links` N (links mode, default 200) +- `--batch` N (links mode batch size) +- `--rate` N (default 48000) +- `--channels` N (default 2) +- `--target` (loopback target) + Create/destroy microbenchmark (milestone 0/2): ``` ./build/warppipe_perf --mode create-destroy --count 200 --type sink @@ -28,6 +43,16 @@ Link creation + removal (milestone 3): ./build/warppipe_perf --mode links --links 200 --batch 50 ``` +Policy auto-routing (milestone 4): +``` +./build/warppipe_perf --mode policy --count 200 +``` + +End-to-end (milestone 5): +``` +./build/warppipe_perf --mode e2e --count 200 +``` + Optional format and loopback: ``` ./build/warppipe_perf --mode create-destroy --count 200 --type sink --rate 48000 --channels 2 diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 98472e1..ec57de8 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -260,31 +261,37 @@ void NodeMeterProcess(void* 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; + bool had_data = false; + pw_buffer* buf = nullptr; + while ((buf = pw_stream_dequeue_buffer(meter->stream)) != nullptr) { + if (!buf->buffer || buf->buffer->n_datas == 0) { + pw_stream_queue_buffer(meter->stream, buf); + continue; + } + spa_data* d = &buf->buffer->datas[0]; + if (!d->data || !d->chunk) { + pw_stream_queue_buffer(meter->stream, buf); + continue; + } + const float* samples = static_cast(d->data); + uint32_t count = d->chunk->size / sizeof(float); + left = 0.0f; + 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; + } + had_data = true; + pw_stream_queue_buffer(meter->stream, buf); + } + if (had_data) { + meter->peak_left.store(left, std::memory_order_relaxed); + meter->peak_right.store(right, std::memory_order_relaxed); } - 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 = { @@ -292,6 +299,14 @@ static const pw_stream_events kNodeMeterEvents = { .process = NodeMeterProcess, }; +struct NodeProxyData { + pw_proxy* proxy = nullptr; + spa_hook object_listener{}; + uint32_t node_id = 0; + void* impl_ptr = nullptr; + bool params_subscribed = false; +}; + } // namespace Status Status::Ok() { @@ -331,6 +346,8 @@ struct Client::Impl { std::unordered_map> link_proxies; std::unordered_map volume_states; + std::unordered_map> node_proxies; + std::unordered_map node_channel_counts; std::unordered_map meter_states; std::unordered_set metered_nodes; @@ -391,6 +408,8 @@ struct Client::Impl { void SetupMasterMeter(); void TeardownMasterMeter(); void TeardownAllLiveMeters(); + void BindNodeForParams(uint32_t id); + void UnbindNodeForParams(uint32_t id); static void RegistryGlobal(void* data, uint32_t id, @@ -403,8 +422,166 @@ struct Client::Impl { static void CoreError(void* data, uint32_t id, int seq, int res, const char* message); static int MetadataProperty(void* data, uint32_t subject, const char* key, const char* type, const char* value); + static void NodeInfoChanged(void* data, const struct pw_node_info* info); + static void NodeParamChanged(void* data, int seq, uint32_t id, + uint32_t index, uint32_t next, + const struct spa_pod* param); }; +void Client::Impl::NodeInfoChanged(void* data, const struct pw_node_info* info) { + auto* np = static_cast(data); + if (!np || !info || np->params_subscribed) return; + + for (uint32_t i = 0; i < info->n_params; ++i) { + if (info->params[i].id == SPA_PARAM_Props && + (info->params[i].flags & SPA_PARAM_INFO_READ)) { + np->params_subscribed = true; + uint32_t params[] = {SPA_PARAM_Props}; + pw_node_subscribe_params( + reinterpret_cast(np->proxy), params, 1); + break; + } + } +} + +void Client::Impl::NodeParamChanged(void* data, int, uint32_t id, + uint32_t, uint32_t, + const struct spa_pod* param) { + auto* np = static_cast(data); + if (!np || !np->impl_ptr || !param) return; + if (id != SPA_PARAM_Props) return; + if (!spa_pod_is_object(param)) return; + + auto* impl = static_cast(np->impl_ptr); + + float volume = -1.0f; + float mon_volume = -1.0f; + bool mute = false; + bool mon_mute = false; + bool found_mute = false; + bool found_mon_mute = false; + uint32_t n_channels = 0; + + const auto* obj = reinterpret_cast(param); + const spa_pod_prop* prop; + SPA_POD_OBJECT_FOREACH(obj, prop) { + switch (prop->key) { + case SPA_PROP_channelVolumes: { + float vols[64]; + uint32_t n = spa_pod_copy_array(&prop->value, SPA_TYPE_Float, vols, 64); + if (n > 0) { + volume = vols[0]; + n_channels = n; + } + break; + } + case SPA_PROP_monitorVolumes: { + float vols[64]; + uint32_t n = spa_pod_copy_array(&prop->value, SPA_TYPE_Float, vols, 64); + if (n > 0) { + mon_volume = vols[0]; + if (n_channels == 0) n_channels = n; + } + break; + } + case SPA_PROP_mute: { + bool m = false; + if (spa_pod_get_bool(&prop->value, &m) >= 0) { + mute = m; + found_mute = true; + } + break; + } + case SPA_PROP_monitorMute: { + bool m = false; + if (spa_pod_get_bool(&prop->value, &m) >= 0) { + mon_mute = m; + found_mon_mute = true; + } + break; + } + } + } + + bool is_virtual = false; + { + std::lock_guard lock(impl->cache_mutex); + is_virtual = impl->virtual_streams.count(np->node_id) > 0; + } + + float effective_vol = is_virtual && mon_volume >= 0.0f ? mon_volume : volume; + bool effective_mute = is_virtual && found_mon_mute ? mon_mute : mute; + bool effective_found_mute = is_virtual ? found_mon_mute : found_mute; + + bool changed = false; + { + std::lock_guard lock(impl->cache_mutex); + auto& vs = impl->volume_states[np->node_id]; + if (effective_vol >= 0.0f && std::abs(vs.volume - effective_vol) > 0.0001f) { + vs.volume = effective_vol; + changed = true; + } + if (effective_found_mute && vs.mute != effective_mute) { + vs.mute = effective_mute; + changed = true; + } + if (n_channels > 0) { + impl->node_channel_counts[np->node_id] = n_channels; + } + } + + if (changed) { + impl->NotifyChange(); + } +} + +void Client::Impl::BindNodeForParams(uint32_t id) { + if (!registry) return; + + { + std::lock_guard lock(cache_mutex); + if (node_proxies.count(id)) return; + } + + static const pw_node_events node_param_events = { + .version = PW_VERSION_NODE_EVENTS, + .info = NodeInfoChanged, + .param = NodeParamChanged, + }; + + auto np = std::make_unique(); + np->proxy = static_cast( + pw_registry_bind(registry, id, + PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0)); + if (!np->proxy) return; + + np->node_id = id; + np->impl_ptr = this; + + pw_proxy_add_object_listener(np->proxy, + &np->object_listener, &node_param_events, np.get()); + + std::lock_guard lock(cache_mutex); + node_proxies[id] = std::move(np); +} + +void Client::Impl::UnbindNodeForParams(uint32_t id) { + std::unique_ptr np; + { + std::lock_guard lock(cache_mutex); + auto it = node_proxies.find(id); + if (it != node_proxies.end()) { + np = std::move(it->second); + node_proxies.erase(it); + } + node_channel_counts.erase(id); + } + if (np) { + spa_hook_remove(&np->object_listener); + pw_proxy_destroy(np->proxy); + } +} + void Client::Impl::RegistryGlobal(void* data, uint32_t id, uint32_t, @@ -417,6 +594,7 @@ void Client::Impl::RegistryGlobal(void* data, } bool notify = false; + bool bind_node = false; { std::lock_guard lock(impl->cache_mutex); @@ -435,6 +613,7 @@ void Client::Impl::RegistryGlobal(void* data, impl->nodes[id] = info; impl->CheckRulesForNode(info); notify = true; + bind_node = true; } else if (IsPortType(type)) { PortInfo info; info.id = PortId{id}; @@ -472,6 +651,10 @@ void Client::Impl::RegistryGlobal(void* data, } } + if (bind_node) { + impl->BindNodeForParams(id); + } + if (notify) { impl->NotifyChange(); return; @@ -548,6 +731,7 @@ void Client::Impl::RegistryGlobalRemove(void* data, uint32_t id) { impl->links.erase(id); } } + impl->UnbindNodeForParams(id); impl->NotifyChange(); } @@ -568,16 +752,18 @@ void Client::Impl::CoreDone(void* data, uint32_t, int seq) { } } -void Client::Impl::CoreError(void* data, uint32_t, int, int res, const char* message) { +void Client::Impl::CoreError(void* data, uint32_t id, int seq, int res, const char* message) { auto* impl = static_cast(data); if (!impl) { return; } - impl->connected = false; - impl->last_error = Status::Error(StatusCode::kUnavailable, - message ? message : spa_strerror(res)); - if (impl->thread_loop) { - pw_thread_loop_signal(impl->thread_loop, false); + if (id == PW_ID_CORE) { + impl->connected = false; + impl->last_error = Status::Error(StatusCode::kUnavailable, + message ? message : spa_strerror(res)); + if (impl->thread_loop) { + pw_thread_loop_signal(impl->thread_loop, false); + } } } @@ -876,6 +1062,14 @@ void Client::Impl::DisconnectLocked() { } } saved_link_proxies.clear(); + for (auto& entry : node_proxies) { + if (entry.second) { + spa_hook_remove(&entry.second->object_listener); + entry.second->proxy = nullptr; + } + } + node_proxies.clear(); + node_channel_counts.clear(); if (metadata_listener_attached) { spa_hook_remove(&metadata_listener); metadata_listener_attached = false; @@ -1329,6 +1523,11 @@ void Client::Impl::AutoSave() { node_obj["channels"] = sd.channels; node_obj["loopback"] = sd.loopback; node_obj["target_node"] = sd.target_node; + auto volIt = volume_states.find(entry.first); + if (volIt != volume_states.end()) { + node_obj["volume"] = volIt->second.volume; + node_obj["mute"] = volIt->second.mute; + } nodes_array.push_back(std::move(node_obj)); } } @@ -1765,24 +1964,58 @@ Status Client::SetNodeVolume(NodeId node, float volume, bool mute) { } } - 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"); + uint32_t n_channels; + pw_stream* own_stream = nullptr; + pw_proxy* proxy = nullptr; + { + std::lock_guard lock(impl_->cache_mutex); + auto streamIt = impl_->virtual_streams.find(node.value); + if (streamIt != impl_->virtual_streams.end() && streamIt->second->stream) { + own_stream = streamIt->second->stream; + n_channels = streamIt->second->channels; + } else { + auto proxyIt = impl_->node_proxies.find(node.value); + if (proxyIt == impl_->node_proxies.end() || !proxyIt->second->proxy) { + pw_thread_loop_unlock(impl_->thread_loop); + return Status::Error(StatusCode::kNotFound, "no proxy bound for node"); + } + proxy = proxyIt->second->proxy; + auto chIt = impl_->node_channel_counts.find(node.value); + n_channels = (chIt != impl_->node_channel_counts.end()) ? chIt->second : 2; + } } + if (n_channels == 0) n_channels = 2; - uint8_t buffer[128]; + uint8_t buffer[512]; 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)); + uint32_t vol_prop = own_stream ? SPA_PROP_monitorVolumes : SPA_PROP_channelVolumes; + uint32_t mute_prop = own_stream ? SPA_PROP_monitorMute : SPA_PROP_mute; + + spa_pod_frame obj_frame; + spa_pod_builder_push_object(&builder, &obj_frame, + SPA_TYPE_OBJECT_Props, SPA_PARAM_Props); + + spa_pod_builder_prop(&builder, vol_prop, 0); + spa_pod_frame arr_frame; + spa_pod_builder_push_array(&builder, &arr_frame); + for (uint32_t ch = 0; ch < n_channels; ++ch) { + spa_pod_builder_float(&builder, volume); + } + spa_pod_builder_pop(&builder, &arr_frame); + + spa_pod_builder_prop(&builder, mute_prop, 0); + spa_pod_builder_bool(&builder, mute); + + auto* param = static_cast( + spa_pod_builder_pop(&builder, &obj_frame)); + + if (own_stream) { + pw_stream_set_param(own_stream, SPA_PARAM_Props, param); + } else { + pw_node_set_param(reinterpret_cast(proxy), + SPA_PARAM_Props, 0, param); + } { std::lock_guard lock(impl_->cache_mutex); @@ -1790,6 +2023,7 @@ Status Client::SetNodeVolume(NodeId node, float volume, bool mute) { } pw_thread_loop_unlock(impl_->thread_loop); + impl_->AutoSave(); return Status::Ok(); } @@ -2139,8 +2373,12 @@ Status Client::RemoveLink(LinkId link) { auto it = impl_->link_proxies.find(link.value); if (it != impl_->link_proxies.end()) { if (it->second && it->second->proxy) { + spa_hook_remove(&it->second->listener); pw_proxy_destroy(it->second->proxy); } + if (impl_->registry) { + pw_registry_destroy(impl_->registry, link.value); + } impl_->link_proxies.erase(it); auto link_it2 = impl_->links.find(link.value); if (link_it2 != impl_->links.end()) { @@ -2174,6 +2412,8 @@ Status Client::RemoveLink(LinkId link) { for (auto& p : impl_->saved_link_proxies) { if (p && p->output_port == out_port && p->input_port == in_port) { spa_hook_remove(&p->listener); + if (p->proxy) pw_proxy_destroy(p->proxy); + p->proxy = nullptr; } } std::erase_if(impl_->saved_link_proxies, [&](const auto& p) { @@ -2182,6 +2422,8 @@ Status Client::RemoveLink(LinkId link) { for (auto& p : impl_->auto_link_proxies) { if (p && p->output_port == out_port && p->input_port == in_port) { spa_hook_remove(&p->listener); + if (p->proxy) pw_proxy_destroy(p->proxy); + p->proxy = nullptr; } } std::erase_if(impl_->auto_link_proxies, [&](const auto& p) { @@ -2406,6 +2648,11 @@ Status Client::SaveConfig(std::string_view path) { node_obj["channels"] = sd.channels; node_obj["loopback"] = sd.loopback; node_obj["target_node"] = sd.target_node; + auto volIt = impl_->volume_states.find(entry.first); + if (volIt != impl_->volume_states.end()) { + node_obj["volume"] = volIt->second.volume; + node_obj["mute"] = volIt->second.mute; + } nodes_array.push_back(std::move(node_obj)); } @@ -2587,10 +2834,20 @@ Status Client::LoadConfig(std::string_view path) { opts.behavior = VirtualBehavior::kLoopback; opts.target_node = target; } + Result result; if (is_source) { - CreateVirtualSource(name, opts); + auto r = CreateVirtualSource(name, opts); + result = {r.status, r.ok() ? r.value.node.value : 0}; } else { - CreateVirtualSink(name, opts); + auto r = CreateVirtualSink(name, opts); + result = {r.status, r.ok() ? r.value.node.value : 0}; + } + if (result.ok() && result.value != 0) { + float vol = node_obj.value("volume", 1.0f); + bool muted = node_obj.value("mute", false); + if (vol != 1.0f || muted) { + SetNodeVolume(NodeId{result.value}, vol, muted); + } } } catch (...) { continue; diff --git a/tests/README.md b/tests/README.md index 9e23053..f012551 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,6 +2,31 @@ Milestone 0 test instructions are tracked in docs/milestone-0.md. +## Build + +``` +cmake -S . -B build -DWARPPIPE_BUILD_TESTS=ON +cmake --build build +``` + +Catch2 v3 is required when tests are enabled. + +## Run + +``` +./build/warppipe_tests +``` + +GUI tests (requires Qt6, `-DWARPPIPE_BUILD_GUI=ON`): + +``` +./build/warppipe-gui-tests +``` + +## Notes + +When tests are enabled, the library is compiled with `WARPPIPE_TESTING`, which exposes test-only helpers in the public header. + Planned coverage: - Missing PipeWire daemon - Missing link-factory module diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index d5e2c6d..8fd9971 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -18,6 +18,8 @@ #include #include +#include + namespace { warppipe::ConnectionOptions TestOptions() { @@ -1093,7 +1095,8 @@ TEST_CASE("setNodeVolumeState syncs inline widget") { ns.mute = true; model.setNodeVolumeState(qtId, ns); - REQUIRE(vol->volume() == 70); + // Cubic scaling: slider = cbrt(0.7) * 100 ≈ 89 + REQUIRE(vol->volume() == static_cast(std::round(std::cbrt(0.7f) * 100.0f))); REQUIRE(vol->isMuted()); } @@ -1139,6 +1142,8 @@ TEST_CASE("preset saves and loads volume state") { } REQUIRE(found); + tc.client->Test_SetNodeVolume(warppipe::NodeId{100650}, 1.0f, false); + WarpGraphModel model2(tc.client.get()); model2.refreshFromClient(); auto qtId2 = model2.qtNodeIdForPw(100650);