Compare commits
9 commits
978c8c10e3
...
8557383794
| Author | SHA1 | Date | |
|---|---|---|---|
| 8557383794 | |||
| 9d57ba5d25 | |||
| 69debb202c | |||
| d29c1a5db5 | |||
| 8a8b039dcc | |||
| d314ad7dd9 | |||
| 609a195452 | |||
| cb9770a757 | |||
| 10fe7103da |
17 changed files with 779 additions and 75 deletions
|
|
@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
2
PLAN.md
2
PLAN.md
|
|
@ -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.
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
96
docs/api.md
96
docs/api.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
115
docs/examples.md
Normal 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
33
docs/gui-usage.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
345
src/warppipe.cpp
345
src/warppipe.cpp
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue