Compare commits

..

9 commits

17 changed files with 779 additions and 75 deletions

View file

@ -1,4 +1,4 @@
# Warppipe GUI Plan (Qt6 Node-Based Audio Router) # Warp Pipe GUI Plan (Qt6 Node-Based Audio Router)
## Overview ## 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. 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) option(BUILD_GUI "Build Qt6 GUI application" ON)
if(BUILD_GUI) if(WARPPIPE_BUILD_GUI)
find_package(Qt6 6.2 REQUIRED COMPONENTS Core Widgets) find_package(Qt6 6.2 REQUIRED COMPONENTS Core Widgets)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
@ -441,6 +441,7 @@ if(BUILD_GUI)
target_link_libraries(warppipe-gui-tests PRIVATE target_link_libraries(warppipe-gui-tests PRIVATE
warppipe warppipe
Qt6::Core
Qt6::Widgets Qt6::Widgets
QtNodes QtNodes
Catch2::Catch2WithMain Catch2::Catch2WithMain
@ -464,9 +465,8 @@ endif()
### Dependencies ### Dependencies
- Qt6 >= 6.2 (Core, Widgets) - Qt6 >= 6.2 (Core, Widgets)
- Qt6::Test (for GUI test target)
- QtNodes (nodeeditor) — fetched via CMake FetchContent - 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) - warppipe library (existing)
--- ---

View file

@ -1,4 +1,4 @@
# Warppipe Plan (C++ libpipewire library) # Warp Pipe Plan (C++ libpipewire library)
- [x] Milestone 0 - Groundwork and constraints - [x] Milestone 0 - Groundwork and constraints
- [x] Choose build system: CMake (confirmed). Define minimal library target and example app target. - [x] Choose build system: CMake (confirmed). Define minimal library target and example app target.

View file

@ -1,6 +1,6 @@
# warppipe # 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 ## 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 - **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 - **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 - **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 - **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) - **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 - **Thread-safe** — dedicated PipeWire thread loop; all public methods callable from any thread
@ -18,20 +20,24 @@ Requirements:
- CMake 3.20+ - CMake 3.20+
- pkg-config - pkg-config
- libpipewire-0.3 development files - 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 ```sh
cmake -S . -B build cmake -S . -B build
cmake --build 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 ### Dependencies
- [libpipewire-0.3](https://pipewire.org/) — system package - [libpipewire-0.3](https://pipewire.org/) — system package
- [nlohmann/json](https://github.com/nlohmann/json) — fetched automatically via CMake FetchContent - [nlohmann/json](https://github.com/nlohmann/json) — fetched automatically if not installed
- [Catch2 v3](https://github.com/catchorg/Catch2) — fetched automatically via CMake FetchContent - [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 ## Quick Start
@ -77,11 +83,13 @@ warppipe_cli load-config <path>
warppipe_cli defaults 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. `create-sink` and `create-source` block until interrupted with Ctrl-C.
## Configuration ## 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 ```json
{ {
@ -93,7 +101,9 @@ Config is JSON. Set `ConnectionOptions::config_path` to enable auto-save/load, o
"rate": 48000, "rate": 48000,
"channels": 2, "channels": 2,
"loopback": false, "loopback": false,
"target_node": "" "target_node": "",
"volume": 1.0,
"mute": false
} }
], ],
"route_rules": [ "route_rules": [
@ -105,6 +115,14 @@ Config is JSON. Set `ConnectionOptions::config_path` to enable auto-save/load, o
}, },
"target_node": "warppipe-gaming-sink" "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 - [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 - [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 ## Tests

View file

@ -1,8 +1,8 @@
# Warppipe API # Warp Pipe API
## Overview ## 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 `<warppipe/warppipe.hpp>`. 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 `<warppipe/warppipe.hpp>`.
## Quick Start ## 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 ### 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. 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<VirtualSource> CreateVirtualSource(std::string_view name, const VirtualNo
Status RemoveNode(NodeId node); Status RemoveNode(NodeId node);
``` ```
```cpp
struct VirtualNodeOptions {
AudioFormat format; // rate/channels
VirtualBehavior behavior; // kNull (default) or kLoopback
std::optional<std::string> target_node; // Required for kLoopback
std::optional<std::string> 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. 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<VolumeState> 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<MeterState> NodeMeterPeak(NodeId node) const;
Result<MeterState> 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()>;
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 ## Link Management
```cpp ```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. 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) ## Route Rules (Policy Engine)
```cpp ```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. 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. Adding a rule also scans existing nodes for matches.
## Metadata ## Metadata
@ -173,7 +255,13 @@ JSON format. See `docs/config-schema.md` for the schema.
When `config_path` is set in `ConnectionOptions`: When `config_path` is set in `ConnectionOptions`:
- Config is loaded automatically after connection. - 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 ## Performance
@ -206,3 +294,5 @@ warppipe_cli save-config <path>
warppipe_cli load-config <path> warppipe_cli load-config <path>
warppipe_cli defaults warppipe_cli defaults
``` ```
`link` uses node/port names (not IDs). `defaults` prints current and configured default sink/source names.

View file

@ -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 ## Schema Version 1
@ -14,7 +14,9 @@ Warppipe uses JSON for configuration persistence. The config file stores virtual
"rate": 48000, "rate": 48000,
"channels": 2, "channels": 2,
"loopback": false, "loopback": false,
"target_node": "" "target_node": "",
"volume": 1.0,
"mute": false
}, },
{ {
"name": "warppipe-mic-source", "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" "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 - `channels` (integer, default 2): Channel count
- `loopback` (boolean, default false): Whether node forwards to a target - `loopback` (boolean, default false): Whether node forwards to a target
- `target_node` (string, optional): Required when loopback is true - `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 ### 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.application_name` (string): Match PW_KEY_APP_NAME
- `match.process_binary` (string): Match PW_KEY_APP_PROCESS_BINARY - `match.process_binary` (string): Match PW_KEY_APP_PROCESS_BINARY
- `match.media_role` (string): Match PW_KEY_MEDIA_ROLE - `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 - `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. 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 ## Persistence Behavior
- **Auto-save**: When `ConnectionOptions::config_path` is set, config is saved after: - **Auto-save**: When `ConnectionOptions::config_path` is set, config is saved after:
- Virtual node created/removed - Virtual node created/removed
- Routing rule added/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. - **Load on startup**: When `config_path` is set and the file exists, it is loaded during `Client::Create()` after connection is established.

115
docs/examples.md Normal file
View file

@ -0,0 +1,115 @@
# Warp Pipe Examples
This page collects common usage patterns for the library and CLI. Snippets assume `#include <warppipe/warppipe.hpp>` 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 <node-id>
warppipe_cli link <out-node-name> <out-port-name> <in-node-name> <in-port-name> --linger
warppipe_cli unlink <link-id>
```
## 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.

33
docs/gui-usage.md Normal file
View file

@ -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 <path>`: capture a PNG screenshot and exit
- `--screenshot-delay <ms>`: delay before capture (default 800)
- `--debug-screenshot-dir <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

View file

@ -3,7 +3,7 @@
## Build system ## Build system
- CMake 3.20+ - CMake 3.20+
- C++17 - C++20
- pkg-config - pkg-config
- libpipewire-0.3 development files - libpipewire-0.3 development files

View file

@ -48,9 +48,20 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
namespace {
inline float sliderToVolume(int slider) {
float x = static_cast<float>(slider) / 100.0f;
return x * x * x;
}
inline int volumeToSlider(float volume) {
return static_cast<int>(std::round(std::cbrt(volume) * 100.0f));
}
}
class DeleteVirtualNodeCommand : public QUndoCommand { class DeleteVirtualNodeCommand : public QUndoCommand {
public: public:
struct Snapshot { struct Snapshot {
@ -1192,7 +1203,7 @@ void GraphEditorWidget::wireVolumeWidget(QtNodes::NodeId nodeId) {
connect(vol, &NodeVolumeWidget::volumeChanged, this, connect(vol, &NodeVolumeWidget::volumeChanged, this,
[this, capturedId](int value) { [this, capturedId](int value) {
auto state = m_model->nodeVolumeState(capturedId); auto state = m_model->nodeVolumeState(capturedId);
state.volume = static_cast<float>(value) / 100.0f; state.volume = sliderToVolume(value);
m_model->setNodeVolumeState(capturedId, state); m_model->setNodeVolumeState(capturedId, state);
}); });
@ -1241,6 +1252,8 @@ void GraphEditorWidget::rebuildMixerStrips() {
const WarpNodeData *data = m_model->warpNodeData(nodeId); const WarpNodeData *data = m_model->warpNodeData(nodeId);
if (!data) if (!data)
continue; continue;
if (!nodeHasVolume(WarpGraphModel::classifyNode(data->info)))
continue;
auto *strip = new QWidget(); auto *strip = new QWidget();
strip->setStyleSheet(QStringLiteral( strip->setStyleSheet(QStringLiteral(
@ -1268,7 +1281,7 @@ void GraphEditorWidget::rebuildMixerStrips() {
auto *slider = new ClickSlider(Qt::Horizontal); auto *slider = new ClickSlider(Qt::Horizontal);
slider->setRange(0, 100); slider->setRange(0, 100);
auto state = m_model->nodeVolumeState(nodeId); auto state = m_model->nodeVolumeState(nodeId);
slider->setValue(static_cast<int>(state.volume * 100.0f)); slider->setValue(volumeToSlider(state.volume));
slider->setStyleSheet(QStringLiteral( slider->setStyleSheet(QStringLiteral(
"QSlider::groove:horizontal {" "QSlider::groove:horizontal {"
" background: #1a1a1e; border-radius: 3px; height: 6px; }" " background: #1a1a1e; border-radius: 3px; height: 6px; }"
@ -1301,7 +1314,7 @@ void GraphEditorWidget::rebuildMixerStrips() {
connect(slider, &QSlider::valueChanged, this, connect(slider, &QSlider::valueChanged, this,
[this, capturedId](int value) { [this, capturedId](int value) {
auto s = m_model->nodeVolumeState(capturedId); auto s = m_model->nodeVolumeState(capturedId);
s.volume = static_cast<float>(value) / 100.0f; s.volume = sliderToVolume(value);
m_model->setNodeVolumeState(capturedId, s); m_model->setNodeVolumeState(capturedId, s);
}); });
@ -1330,7 +1343,7 @@ void GraphEditorWidget::rebuildMixerStrips() {
return; return;
QSignalBlocker sb(slider); QSignalBlocker sb(slider);
QSignalBlocker mb(muteBtn); QSignalBlocker mb(muteBtn);
slider->setValue(static_cast<int>(cur.volume * 100.0f)); slider->setValue(volumeToSlider(cur.volume));
muteBtn->setChecked(cur.mute); muteBtn->setChecked(cur.mute);
}); });

View file

@ -94,6 +94,8 @@ int NodeVolumeWidget::volume() const { return m_slider->value(); }
bool NodeVolumeWidget::isMuted() const { return m_muteBtn->isChecked(); } bool NodeVolumeWidget::isMuted() const { return m_muteBtn->isChecked(); }
bool NodeVolumeWidget::isSliderDown() const { return m_slider->isSliderDown(); }
void NodeVolumeWidget::setVolume(int value) { void NodeVolumeWidget::setVolume(int value) {
QSignalBlocker blocker(m_slider); QSignalBlocker blocker(m_slider);
m_slider->setValue(value); m_slider->setValue(value);

View file

@ -20,6 +20,7 @@ public:
int volume() const; int volume() const;
bool isMuted() const; bool isMuted() const;
bool isSliderDown() const;
void setVolume(int value); void setVolume(int value);
void setMuted(bool muted); void setMuted(bool muted);

View file

@ -13,6 +13,14 @@
#include <QtNodes/NodeStyle> #include <QtNodes/NodeStyle>
#include <QtNodes/StyleCollection> #include <QtNodes/StyleCollection>
#include <cmath>
namespace {
inline int volumeToSlider(float volume) {
return static_cast<int>(std::round(std::cbrt(volume) * 100.0f));
}
}
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@ -82,6 +90,10 @@ bool WarpGraphModel::connectionPossible(
if (connectionExists(connectionId)) { if (connectionExists(connectionId)) {
return false; return false;
} }
if (m_ghostNodes.count(connectionId.outNodeId) ||
m_ghostNodes.count(connectionId.inNodeId)) {
return false;
}
auto outIt = m_nodes.find(connectionId.outNodeId); auto outIt = m_nodes.find(connectionId.outNodeId);
auto inIt = m_nodes.find(connectionId.inNodeId); auto inIt = m_nodes.find(connectionId.inNodeId);
@ -98,6 +110,14 @@ bool WarpGraphModel::connectionPossible(
return false; 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; return true;
} }
@ -222,6 +242,9 @@ QVariant WarpGraphModel::portData(QtNodes::NodeId nodeId,
const auto &data = it->second; const auto &data = it->second;
if (role == QtNodes::PortRole::DataType) { if (role == QtNodes::PortRole::DataType) {
WarpNodeType ntype = classifyNode(data.info);
if (ntype == WarpNodeType::kVideoSource || ntype == WarpNodeType::kVideoSink)
return QString("video");
return QString("audio"); return QString("audio");
} }
@ -471,9 +494,11 @@ void WarpGraphModel::refreshFromClient() {
} }
} }
auto *volumeWidget = new NodeVolumeWidget(); if (nodeHasVolume(nodeType)) {
m_volumeWidgets[qtId] = volumeWidget; auto *volumeWidget = new NodeVolumeWidget();
m_volumeStates[qtId] = {}; m_volumeWidgets[qtId] = volumeWidget;
m_volumeStates[qtId] = {};
}
Q_EMIT nodeCreated(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<NodeVolumeWidget *>(wIt->second);
if (!vw->isSliderDown()) {
vw->setVolume(sliderVal);
vw->setMuted(mute);
}
}
Q_EMIT nodeVolumeChanged(qtId, previous, cached);
}
m_refreshing = false; m_refreshing = false;
} }
@ -761,6 +818,12 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) {
if (mc == "Stream/Output/Audio" || mc == "Stream/Input/Audio") { if (mc == "Stream/Output/Audio" || mc == "Stream/Input/Audio") {
return WarpNodeType::kApplication; return WarpNodeType::kApplication;
} }
if (mc == "Video/Source") {
return WarpNodeType::kVideoSource;
}
if (mc == "Video/Sink") {
return WarpNodeType::kVideoSink;
}
return WarpNodeType::kUnknown; return WarpNodeType::kUnknown;
} }
@ -788,7 +851,7 @@ void WarpGraphModel::setNodeVolumeState(QtNodes::NodeId nodeId,
if (wIt != m_volumeWidgets.end()) { if (wIt != m_volumeWidgets.end()) {
auto *w = qobject_cast<NodeVolumeWidget *>(wIt->second); auto *w = qobject_cast<NodeVolumeWidget *>(wIt->second);
if (w) { if (w) {
w->setVolume(static_cast<int>(state.volume * 100.0f)); w->setVolume(volumeToSlider(state.volume));
w->setMuted(state.mute); w->setMuted(state.mute);
} }
} }
@ -1031,9 +1094,11 @@ bool WarpGraphModel::loadLayout(const QString &path) {
? m_positions.at(qtId) ? m_positions.at(qtId)
: QPointF(0, 0); : QPointF(0, 0);
auto *volumeWidget = new NodeVolumeWidget(); if (nodeHasVolume(classifyNode(info))) {
m_volumeWidgets[qtId] = volumeWidget; auto *volumeWidget = new NodeVolumeWidget();
m_volumeStates[qtId] = {}; m_volumeWidgets[qtId] = volumeWidget;
m_volumeStates[qtId] = {};
}
Q_EMIT nodeCreated(qtId); Q_EMIT nodeCreated(qtId);
} }
@ -1065,6 +1130,7 @@ void WarpGraphModel::autoArrange() {
Column sources; Column sources;
Column apps; Column apps;
Column sinks; Column sinks;
Column video;
for (const auto &[qtId, data] : m_nodes) { for (const auto &[qtId, data] : m_nodes) {
WarpNodeType type = classifyNode(data.info); WarpNodeType type = classifyNode(data.info);
@ -1081,6 +1147,11 @@ void WarpGraphModel::autoArrange() {
apps.ids.push_back(qtId); apps.ids.push_back(qtId);
apps.maxWidth = std::max(apps.maxWidth, w); apps.maxWidth = std::max(apps.maxWidth, w);
break; break;
case WarpNodeType::kVideoSource:
case WarpNodeType::kVideoSink:
video.ids.push_back(qtId);
video.maxWidth = std::max(video.maxWidth, w);
break;
default: default:
sinks.ids.push_back(qtId); sinks.ids.push_back(qtId);
sinks.maxWidth = std::max(sinks.maxWidth, w); sinks.maxWidth = std::max(sinks.maxWidth, w);
@ -1106,6 +1177,10 @@ void WarpGraphModel::autoArrange() {
layoutColumn(apps, x); layoutColumn(apps, x);
x += apps.maxWidth + kHorizontalGap * 3; x += apps.maxWidth + kHorizontalGap * 3;
layoutColumn(sinks, x); layoutColumn(sinks, x);
if (!video.ids.empty()) {
x += sinks.maxWidth + kHorizontalGap * 3;
layoutColumn(video, x);
}
} }
QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) { QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) {
@ -1128,6 +1203,12 @@ QVariant WarpGraphModel::styleForNode(WarpNodeType type, bool ghost) {
case WarpNodeType::kApplication: case WarpNodeType::kApplication:
base = QColor(138, 104, 72); base = QColor(138, 104, 72);
break; break;
case WarpNodeType::kVideoSource:
base = QColor(120, 80, 130);
break;
case WarpNodeType::kVideoSink:
base = QColor(100, 70, 140);
break;
default: default:
base = QColor(86, 94, 108); base = QColor(86, 94, 108);
break; break;

View file

@ -20,8 +20,21 @@ enum class WarpNodeType : uint8_t {
kVirtualSink, kVirtualSink,
kVirtualSource, kVirtualSource,
kApplication, 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 { struct WarpNodeData {
warppipe::NodeInfo info; warppipe::NodeInfo info;
std::vector<warppipe::PortInfo> inputPorts; std::vector<warppipe::PortInfo> inputPorts;

View file

@ -11,6 +11,21 @@ cmake --build build
## Run ## 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` <node-name> (loopback target)
Create/destroy microbenchmark (milestone 0/2): Create/destroy microbenchmark (milestone 0/2):
``` ```
./build/warppipe_perf --mode create-destroy --count 200 --type sink ./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 ./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: Optional format and loopback:
``` ```
./build/warppipe_perf --mode create-destroy --count 200 --type sink --rate 48000 --channels 2 ./build/warppipe_perf --mode create-destroy --count 200 --type sink --rate 48000 --channels 2

View file

@ -19,6 +19,7 @@
#include <spa/param/audio/format-utils.h> #include <spa/param/audio/format-utils.h>
#include <spa/param/props.h> #include <spa/param/props.h>
#include <spa/pod/builder.h> #include <spa/pod/builder.h>
#include <spa/pod/parser.h>
#include <spa/utils/defs.h> #include <spa/utils/defs.h>
#include <spa/utils/result.h> #include <spa/utils/result.h>
@ -260,31 +261,37 @@ void NodeMeterProcess(void* data) {
if (!meter || !meter->stream) { if (!meter || !meter->stream) {
return; 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<const float*>(d->data);
uint32_t count = d->chunk->size / sizeof(float);
float left = 0.0f; float left = 0.0f;
float right = 0.0f; float right = 0.0f;
for (uint32_t i = 0; i + 1 < count; i += 2) { bool had_data = false;
float l = std::fabs(samples[i]); pw_buffer* buf = nullptr;
float r = std::fabs(samples[i + 1]); while ((buf = pw_stream_dequeue_buffer(meter->stream)) != nullptr) {
if (l > left) left = l; if (!buf->buffer || buf->buffer->n_datas == 0) {
if (r > right) right = r; 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<const float*>(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 = { static const pw_stream_events kNodeMeterEvents = {
@ -292,6 +299,14 @@ static const pw_stream_events kNodeMeterEvents = {
.process = NodeMeterProcess, .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 } // namespace
Status Status::Ok() { Status Status::Ok() {
@ -331,6 +346,8 @@ struct Client::Impl {
std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> link_proxies; std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> link_proxies;
std::unordered_map<uint32_t, VolumeState> volume_states; std::unordered_map<uint32_t, VolumeState> volume_states;
std::unordered_map<uint32_t, std::unique_ptr<NodeProxyData>> node_proxies;
std::unordered_map<uint32_t, uint32_t> node_channel_counts;
std::unordered_map<uint32_t, MeterState> meter_states; std::unordered_map<uint32_t, MeterState> meter_states;
std::unordered_set<uint32_t> metered_nodes; std::unordered_set<uint32_t> metered_nodes;
@ -391,6 +408,8 @@ struct Client::Impl {
void SetupMasterMeter(); void SetupMasterMeter();
void TeardownMasterMeter(); void TeardownMasterMeter();
void TeardownAllLiveMeters(); void TeardownAllLiveMeters();
void BindNodeForParams(uint32_t id);
void UnbindNodeForParams(uint32_t id);
static void RegistryGlobal(void* data, static void RegistryGlobal(void* data,
uint32_t id, 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 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, static int MetadataProperty(void* data, uint32_t subject, const char* key,
const char* type, const char* value); 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<NodeProxyData*>(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<pw_node*>(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<NodeProxyData*>(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<Client::Impl*>(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<const spa_pod_object*>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<NodeProxyData>();
np->proxy = static_cast<pw_proxy*>(
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<std::mutex> lock(cache_mutex);
node_proxies[id] = std::move(np);
}
void Client::Impl::UnbindNodeForParams(uint32_t id) {
std::unique_ptr<NodeProxyData> np;
{
std::lock_guard<std::mutex> 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, void Client::Impl::RegistryGlobal(void* data,
uint32_t id, uint32_t id,
uint32_t, uint32_t,
@ -417,6 +594,7 @@ void Client::Impl::RegistryGlobal(void* data,
} }
bool notify = false; bool notify = false;
bool bind_node = false;
{ {
std::lock_guard<std::mutex> lock(impl->cache_mutex); std::lock_guard<std::mutex> lock(impl->cache_mutex);
@ -435,6 +613,7 @@ void Client::Impl::RegistryGlobal(void* data,
impl->nodes[id] = info; impl->nodes[id] = info;
impl->CheckRulesForNode(info); impl->CheckRulesForNode(info);
notify = true; notify = true;
bind_node = true;
} else if (IsPortType(type)) { } else if (IsPortType(type)) {
PortInfo info; PortInfo info;
info.id = PortId{id}; info.id = PortId{id};
@ -472,6 +651,10 @@ void Client::Impl::RegistryGlobal(void* data,
} }
} }
if (bind_node) {
impl->BindNodeForParams(id);
}
if (notify) { if (notify) {
impl->NotifyChange(); impl->NotifyChange();
return; return;
@ -548,6 +731,7 @@ void Client::Impl::RegistryGlobalRemove(void* data, uint32_t id) {
impl->links.erase(id); impl->links.erase(id);
} }
} }
impl->UnbindNodeForParams(id);
impl->NotifyChange(); 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<Client::Impl*>(data); auto* impl = static_cast<Client::Impl*>(data);
if (!impl) { if (!impl) {
return; return;
} }
impl->connected = false; if (id == PW_ID_CORE) {
impl->last_error = Status::Error(StatusCode::kUnavailable, impl->connected = false;
message ? message : spa_strerror(res)); impl->last_error = Status::Error(StatusCode::kUnavailable,
if (impl->thread_loop) { message ? message : spa_strerror(res));
pw_thread_loop_signal(impl->thread_loop, false); if (impl->thread_loop) {
pw_thread_loop_signal(impl->thread_loop, false);
}
} }
} }
@ -876,6 +1062,14 @@ void Client::Impl::DisconnectLocked() {
} }
} }
saved_link_proxies.clear(); 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) { if (metadata_listener_attached) {
spa_hook_remove(&metadata_listener); spa_hook_remove(&metadata_listener);
metadata_listener_attached = false; metadata_listener_attached = false;
@ -1329,6 +1523,11 @@ void Client::Impl::AutoSave() {
node_obj["channels"] = sd.channels; node_obj["channels"] = sd.channels;
node_obj["loopback"] = sd.loopback; node_obj["loopback"] = sd.loopback;
node_obj["target_node"] = sd.target_node; 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)); 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_node*>( uint32_t n_channels;
pw_registry_bind(impl_->registry, node.value, pw_stream* own_stream = nullptr;
PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0)); pw_proxy* proxy = nullptr;
if (!proxy) { {
pw_thread_loop_unlock(impl_->thread_loop); std::lock_guard<std::mutex> lock(impl_->cache_mutex);
return Status::Error(StatusCode::kInternal, "failed to bind node proxy"); 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)); spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
auto* param = reinterpret_cast<const spa_pod*>(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); uint32_t vol_prop = own_stream ? SPA_PROP_monitorVolumes : SPA_PROP_channelVolumes;
pw_proxy_destroy(reinterpret_cast<pw_proxy*>(proxy)); 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<const spa_pod*>(
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<pw_node*>(proxy),
SPA_PARAM_Props, 0, param);
}
{ {
std::lock_guard<std::mutex> lock(impl_->cache_mutex); std::lock_guard<std::mutex> lock(impl_->cache_mutex);
@ -1790,6 +2023,7 @@ Status Client::SetNodeVolume(NodeId node, float volume, bool mute) {
} }
pw_thread_loop_unlock(impl_->thread_loop); pw_thread_loop_unlock(impl_->thread_loop);
impl_->AutoSave();
return Status::Ok(); return Status::Ok();
} }
@ -2139,8 +2373,12 @@ Status Client::RemoveLink(LinkId link) {
auto it = impl_->link_proxies.find(link.value); auto it = impl_->link_proxies.find(link.value);
if (it != impl_->link_proxies.end()) { if (it != impl_->link_proxies.end()) {
if (it->second && it->second->proxy) { if (it->second && it->second->proxy) {
spa_hook_remove(&it->second->listener);
pw_proxy_destroy(it->second->proxy); pw_proxy_destroy(it->second->proxy);
} }
if (impl_->registry) {
pw_registry_destroy(impl_->registry, link.value);
}
impl_->link_proxies.erase(it); impl_->link_proxies.erase(it);
auto link_it2 = impl_->links.find(link.value); auto link_it2 = impl_->links.find(link.value);
if (link_it2 != impl_->links.end()) { if (link_it2 != impl_->links.end()) {
@ -2174,6 +2412,8 @@ Status Client::RemoveLink(LinkId link) {
for (auto& p : impl_->saved_link_proxies) { for (auto& p : impl_->saved_link_proxies) {
if (p && p->output_port == out_port && p->input_port == in_port) { if (p && p->output_port == out_port && p->input_port == in_port) {
spa_hook_remove(&p->listener); 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) { 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) { for (auto& p : impl_->auto_link_proxies) {
if (p && p->output_port == out_port && p->input_port == in_port) { if (p && p->output_port == out_port && p->input_port == in_port) {
spa_hook_remove(&p->listener); 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) { 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["channels"] = sd.channels;
node_obj["loopback"] = sd.loopback; node_obj["loopback"] = sd.loopback;
node_obj["target_node"] = sd.target_node; 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)); nodes_array.push_back(std::move(node_obj));
} }
@ -2587,10 +2834,20 @@ Status Client::LoadConfig(std::string_view path) {
opts.behavior = VirtualBehavior::kLoopback; opts.behavior = VirtualBehavior::kLoopback;
opts.target_node = target; opts.target_node = target;
} }
Result<uint32_t> result;
if (is_source) { if (is_source) {
CreateVirtualSource(name, opts); auto r = CreateVirtualSource(name, opts);
result = {r.status, r.ok() ? r.value.node.value : 0};
} else { } 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 (...) { } catch (...) {
continue; continue;

View file

@ -2,6 +2,31 @@
Milestone 0 test instructions are tracked in docs/milestone-0.md. 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: Planned coverage:
- Missing PipeWire daemon - Missing PipeWire daemon
- Missing link-factory module - Missing link-factory module

View file

@ -18,6 +18,8 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp> #include <catch2/catch_approx.hpp>
#include <cmath>
namespace { namespace {
warppipe::ConnectionOptions TestOptions() { warppipe::ConnectionOptions TestOptions() {
@ -1093,7 +1095,8 @@ TEST_CASE("setNodeVolumeState syncs inline widget") {
ns.mute = true; ns.mute = true;
model.setNodeVolumeState(qtId, ns); model.setNodeVolumeState(qtId, ns);
REQUIRE(vol->volume() == 70); // Cubic scaling: slider = cbrt(0.7) * 100 ≈ 89
REQUIRE(vol->volume() == static_cast<int>(std::round(std::cbrt(0.7f) * 100.0f)));
REQUIRE(vol->isMuted()); REQUIRE(vol->isMuted());
} }
@ -1139,6 +1142,8 @@ TEST_CASE("preset saves and loads volume state") {
} }
REQUIRE(found); REQUIRE(found);
tc.client->Test_SetNodeVolume(warppipe::NodeId{100650}, 1.0f, false);
WarpGraphModel model2(tc.client.get()); WarpGraphModel model2(tc.client.get());
model2.refreshFromClient(); model2.refreshFromClient();
auto qtId2 = model2.qtNodeIdForPw(100650); auto qtId2 = model2.qtNodeIdForPw(100650);