Milestone 2
This commit is contained in:
parent
14e3afdd7b
commit
866f0419ad
19 changed files with 2006 additions and 23 deletions
9
.clangd
Normal file
9
.clangd
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
CompileFlags:
|
||||||
|
CompilationDatabase: build
|
||||||
|
Add:
|
||||||
|
- "-isystem"
|
||||||
|
- "/var/home/linuxbrew/.linuxbrew/Cellar/gcc/15.2.0/include/c++/15"
|
||||||
|
- "-isystem"
|
||||||
|
- "/var/home/linuxbrew/.linuxbrew/Cellar/gcc/15.2.0/include/c++/15/x86_64-pc-linux-gnu"
|
||||||
|
- "-isystem"
|
||||||
|
- "/var/home/linuxbrew/.linuxbrew/Cellar/gcc/15.2.0/include/c++/15/backward"
|
||||||
14
.envrc
Normal file
14
.envrc
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Environment setup for warppipe LSP
|
||||||
|
# Source this file or use direnv (https://direnv.net/)
|
||||||
|
|
||||||
|
# Tell clangd to query GCC for system includes
|
||||||
|
export CLANGD_FLAGS="--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"
|
||||||
|
|
||||||
|
# Optional: Set C++ include paths (some tools respect this)
|
||||||
|
export CPLUS_INCLUDE_PATH="/var/home/linuxbrew/.linuxbrew/Cellar/gcc/15.2.0/include/c++/15:/var/home/linuxbrew/.linuxbrew/Cellar/gcc/15.2.0/include/c++/15/x86_64-pc-linux-gnu"
|
||||||
|
|
||||||
|
# Ensure Homebrew GCC is in PATH
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
|
||||||
|
# Ensure pkg-config can find pipewire
|
||||||
|
export PKG_CONFIG_PATH="/home/linuxbrew/.linuxbrew/lib/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
10
.envrc.example
Normal file
10
.envrc.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Example .envrc for use with direnv
|
||||||
|
# Install direnv: https://direnv.net/
|
||||||
|
# Then run: direnv allow
|
||||||
|
|
||||||
|
# Tell clangd to query GCC for system includes
|
||||||
|
export CLANGD_FLAGS="--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"
|
||||||
|
|
||||||
|
# Ensure Homebrew tools are in PATH
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
export PKG_CONFIG_PATH="/home/linuxbrew/.linuxbrew/lib/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/build/
|
||||||
|
/build-*/
|
||||||
|
/cmake-build-*/
|
||||||
|
/.cache/
|
||||||
|
/CMakeFiles/
|
||||||
|
/CMakeCache.txt
|
||||||
|
/cmake_install.cmake
|
||||||
|
/compile_commands.json
|
||||||
|
/Testing/
|
||||||
|
/CTestTestfile.cmake
|
||||||
8
.vim/coc-settings.json
Normal file
8
.vim/coc-settings.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"clangd.arguments": [
|
||||||
|
"--background-index",
|
||||||
|
"--clang-tidy",
|
||||||
|
"--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"
|
||||||
|
],
|
||||||
|
"clangd.path": "/home/linuxbrew/.linuxbrew/bin/clangd"
|
||||||
|
}
|
||||||
53
CMakeLists.txt
Normal file
53
CMakeLists.txt
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
project(warppipe VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(WARPPIPE_HOMEBREW_PREFIX "/home/linuxbrew/.linuxbrew")
|
||||||
|
if(EXISTS "${WARPPIPE_HOMEBREW_PREFIX}/bin/brew")
|
||||||
|
list(PREPEND CMAKE_PREFIX_PATH "${WARPPIPE_HOMEBREW_PREFIX}")
|
||||||
|
set(ENV{PKG_CONFIG_PATH}
|
||||||
|
"${WARPPIPE_HOMEBREW_PREFIX}/lib/pkgconfig:${WARPPIPE_HOMEBREW_PREFIX}/share/pkgconfig:$ENV{PKG_CONFIG_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(WARPPIPE_BUILD_EXAMPLES "Build warppipe examples" ON)
|
||||||
|
option(WARPPIPE_BUILD_TESTS "Build warppipe tests" ON)
|
||||||
|
option(WARPPIPE_BUILD_PERF "Build warppipe perf tools" ON)
|
||||||
|
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3)
|
||||||
|
|
||||||
|
add_library(warppipe STATIC
|
||||||
|
src/warppipe.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(warppipe
|
||||||
|
PUBLIC
|
||||||
|
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||||
|
$<INSTALL_INTERFACE:include>
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_features(warppipe PUBLIC cxx_std_17)
|
||||||
|
target_link_libraries(warppipe PUBLIC PkgConfig::PIPEWIRE)
|
||||||
|
|
||||||
|
if(WARPPIPE_BUILD_EXAMPLES)
|
||||||
|
add_executable(warppipe_example examples/minimal.cpp)
|
||||||
|
target_link_libraries(warppipe_example PRIVATE warppipe)
|
||||||
|
|
||||||
|
add_executable(warppipe_cli examples/warppipe_cli.cpp)
|
||||||
|
target_link_libraries(warppipe_cli PRIVATE warppipe)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WARPPIPE_BUILD_PERF)
|
||||||
|
add_executable(warppipe_perf perf/warppipe_perf.cpp)
|
||||||
|
target_link_libraries(warppipe_perf PRIVATE warppipe)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WARPPIPE_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
find_package(Catch2 3 REQUIRED)
|
||||||
|
add_executable(warppipe_tests tests/warppipe_tests.cpp)
|
||||||
|
target_link_libraries(warppipe_tests PRIVATE warppipe Catch2::Catch2WithMain)
|
||||||
|
target_compile_definitions(warppipe PRIVATE WARPPIPE_TESTING)
|
||||||
|
target_compile_definitions(warppipe_tests PRIVATE WARPPIPE_TESTING)
|
||||||
|
add_test(NAME warppipe_tests COMMAND warppipe_tests)
|
||||||
|
endif()
|
||||||
145
LSP_SETUP.md
Normal file
145
LSP_SETUP.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# LSP Setup for warppipe
|
||||||
|
|
||||||
|
This project uses GCC 15 from Homebrew, which requires special configuration for clangd to find standard library headers.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
1. **compile_commands.json** (symlink to build/compile_commands.json)
|
||||||
|
- Tells clangd how files are compiled
|
||||||
|
|
||||||
|
2. **.clangd** - Configuration file
|
||||||
|
- Points clangd to the compilation database
|
||||||
|
|
||||||
|
3. **clangd-wrapper.sh** - Wrapper script with --query-driver flag
|
||||||
|
- Helps clangd extract system includes from GCC
|
||||||
|
|
||||||
|
4. **.envrc** - Environment variables for LSP
|
||||||
|
- Sets CLANGD_FLAGS to configure clangd globally
|
||||||
|
|
||||||
|
## Using the LSP
|
||||||
|
|
||||||
|
### Option 1: Use CLANGD_FLAGS Environment Variable (Easiest)
|
||||||
|
|
||||||
|
Set the environment variable in your shell before starting your editor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLANGD_FLAGS="--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this to your `~/.bashrc` or `~/.zshrc` to make it permanent.
|
||||||
|
|
||||||
|
**Using direnv (Recommended for per-project setup):**
|
||||||
|
1. Install direnv: `brew install direnv`
|
||||||
|
2. Copy `.envrc.example` to `.envrc` (or use the existing `.envrc`)
|
||||||
|
3. Run `direnv allow` in the project directory
|
||||||
|
4. The environment will be set automatically when you cd into the project
|
||||||
|
|
||||||
|
**Neovim users**: If you start Neovim from the shell with the env var set, clangd will automatically pick it up.
|
||||||
|
|
||||||
|
**VS Code users**: Add to your workspace settings (.vscode/settings.json):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"terminal.integrated.env.linux": {
|
||||||
|
"CLANGD_FLAGS": "--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Use clangd-wrapper.sh
|
||||||
|
|
||||||
|
Configure your editor to use `/var/home/joey/Projects/warppipe/clangd-wrapper.sh` instead of `clangd`.
|
||||||
|
|
||||||
|
**Neovim (nvim-lspconfig)**:
|
||||||
|
```lua
|
||||||
|
require('lspconfig').clangd.setup{
|
||||||
|
cmd = { vim.fn.getcwd() .. '/clangd-wrapper.sh' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VS Code (settings.json)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clangd.path": "/var/home/joey/Projects/warppipe/clangd-wrapper.sh"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Pass --query-driver via Editor Config
|
||||||
|
|
||||||
|
Add to your editor's clangd configuration:
|
||||||
|
|
||||||
|
**Neovim**:
|
||||||
|
```lua
|
||||||
|
require('lspconfig').clangd.setup{
|
||||||
|
cmd = { 'clangd', '--query-driver=/home/linuxbrew/.linuxbrew/bin/g++' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**VS Code (settings.json)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clangd.arguments": ["--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 4: Install llvm/clang++ from Homebrew
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install llvm
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild with clang++ (requires libstdc++ setup).
|
||||||
|
|
||||||
|
## Neovim with coc.nvim Setup
|
||||||
|
|
||||||
|
### 1. Install coc-clangd
|
||||||
|
|
||||||
|
In Neovim, run:
|
||||||
|
```vim
|
||||||
|
:CocInstall coc-clangd
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure coc-clangd
|
||||||
|
|
||||||
|
**Option A: Project-specific (Recommended)**
|
||||||
|
|
||||||
|
A `.vim/coc-settings.json` file has been created in this project with the correct configuration. Just open Neovim in this directory and it will work automatically.
|
||||||
|
|
||||||
|
**Option B: Global configuration**
|
||||||
|
|
||||||
|
Edit `~/.config/nvim/coc-settings.json` (or create it):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clangd.arguments": [
|
||||||
|
"--background-index",
|
||||||
|
"--clang-tidy",
|
||||||
|
"--query-driver=/home/linuxbrew/.linuxbrew/bin/g++"
|
||||||
|
],
|
||||||
|
"clangd.path": "/home/linuxbrew/.linuxbrew/bin/clangd"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The global config will apply the `--query-driver` to all projects, which might cause issues for non-Homebrew projects. The project-specific config (Option A) is safer.
|
||||||
|
|
||||||
|
### 3. Restart coc
|
||||||
|
|
||||||
|
After configuration:
|
||||||
|
```vim
|
||||||
|
:CocRestart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifying LSP Works
|
||||||
|
|
||||||
|
After configuration, open any C++ file and check:
|
||||||
|
- Autocomplete works
|
||||||
|
- Go-to-definition works (F12/gd)
|
||||||
|
- No errors on standard library includes (<vector>, <string>, etc.)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you still see "file not found" errors for standard headers:
|
||||||
|
1. Ensure you're using the wrapper script
|
||||||
|
2. Restart your LSP server
|
||||||
|
3. Check that GCC 15 is still at the same path:
|
||||||
|
```bash
|
||||||
|
ls -la /var/home/linuxbrew/.linuxbrew/Cellar/gcc/15.2.0/include/c++/15
|
||||||
|
```
|
||||||
48
PLAN.md
48
PLAN.md
|
|
@ -1,45 +1,47 @@
|
||||||
# Warppipe Plan (C++ libpipewire library)
|
# Warppipe Plan (C++ libpipewire library)
|
||||||
|
|
||||||
- [ ] Milestone 0 — Groundwork and constraints
|
- [x] Milestone 0 - Groundwork and constraints
|
||||||
- [ ] 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.
|
||||||
- [ ] Define public API surface (namespaces, class/struct layout, error model, threading model).
|
- [x] Define public API surface (namespaces, class/struct layout, error model, threading model).
|
||||||
- [ ] Define performance budget and metrics (e.g., 200 create/modify/delete ops in < 1s on typical desktop).
|
- [x] Define performance budget and metrics (e.g., 200 create/modify/delete ops in < 1s on typical desktop).
|
||||||
- [ ] Choose identity strategy for ephemeral sources (match rules on application.name, application.process.binary, media.role, node.name, fallback to client properties; avoid serial IDs).
|
- [x] Choose identity strategy for ephemeral sources (match rules on application.name, application.process.binary, media.role, node.name, fallback to client properties; avoid serial IDs).
|
||||||
- [ ] Tests to add (non-happy path/edge cases): instructions: create unit tests that fail on missing PipeWire daemon, missing libpipewire-module-link-factory, missing libpipewire-module-metadata, and misconfigured runtime properties (e.g., invalid PW_KEY_MEDIA_CLASS).
|
- [x] Tests to add (non-happy path/edge cases): instructions: create unit tests that fail on missing PipeWire daemon, missing libpipewire-module-link-factory, missing libpipewire-module-metadata, and misconfigured runtime properties (e.g., invalid PW_KEY_MEDIA_CLASS).
|
||||||
- [ ] Performance tests: instructions: add a microbenchmark harness that measures connect→create→destroy of N no-op objects (no links) and asserts subsecond N=200 on a warm PipeWire connection.
|
- [x] Performance tests: instructions: add a microbenchmark harness that measures connect->create->destroy of N no-op objects (no links) and asserts subsecond N=200 on a warm PipeWire connection.
|
||||||
|
|
||||||
- [ ] Milestone 1 — Core runtime and registry model
|
- [ ] Milestone 1 - Core runtime and registry model
|
||||||
- [ ] Implement a WarpContext (pw_main_loop/pw_thread_loop + pw_context + pw_core) with lifecycle and reconnect handling.
|
- [x] Implement a WarpContext (pw_main_loop/pw_thread_loop + pw_context + pw_core) with lifecycle and reconnect handling.
|
||||||
- [ ] Implement registry cache for nodes/ports/links with event listeners, and a stable “object identity” resolver (node name, application properties).
|
- [x] Implement registry cache for nodes/ports/links with event listeners, and a stable "object identity" resolver (node name, application properties).
|
||||||
- [ ] Expose a query API to list nodes, ports, and identify sinks/sources.
|
- [x] Expose a query API to list nodes, ports, and identify sinks/sources.
|
||||||
- [ ] Tests to add (non-happy path/edge cases): instructions: simulate registry events where nodes/ports disappear mid-iteration; ensure safe iteration and cleanup; verify reconnection logic after daemon restart.
|
- [x] Add Catch2 test harness with smoke coverage for connection modes.
|
||||||
- [ ] Performance tests: instructions: measure time to ingest registry snapshot of 1000 objects and process 100 add/remove events; assert latency per event and total under subsecond.
|
- [x] Add warppipe_cli for list-nodes, list-ports, list-links.
|
||||||
|
- [x] Tests to add (non-happy path/edge cases): instructions: simulate registry events where nodes/ports disappear mid-iteration; ensure safe iteration and cleanup; verify reconnection logic after daemon restart.
|
||||||
|
- [x] Performance tests: instructions: measure time to ingest registry snapshot of 1000 objects and process 100 add/remove events; assert latency per event and total under subsecond.
|
||||||
|
|
||||||
- [ ] Milestone 2 — Virtual sinks and sources
|
- [ ] Milestone 2 - Virtual sinks and sources
|
||||||
- [ ] Implement virtual sink/source creation via pw_stream_new with PW_KEY_MEDIA_CLASS set to Audio/Sink or Audio/Source and autoconnect flags as needed (see src/pipewire/stream.h).
|
- [x] Implement virtual sink/source creation via pw_stream_new with PW_KEY_MEDIA_CLASS set to Audio/Sink or Audio/Source and autoconnect flags as needed (see src/pipewire/stream.h).
|
||||||
- [ ] Support “null” behavior (discard) and “loopback” behavior (sink that forwards to target) using stream properties and explicit links.
|
- [x] Support "null" behavior (discard) and "loopback" behavior (sink that forwards to target) using stream properties and explicit links.
|
||||||
- [ ] Provide a naming scheme and metadata tags for virtual nodes to ensure stable identification.
|
- [x] Provide a naming scheme and metadata tags for virtual nodes to ensure stable identification.
|
||||||
- [ ] Tests to add (non-happy path/edge cases): instructions: create sink/source with missing media class and expect validation error; create duplicate node name; attempt to connect when target node is absent.
|
- [x] Tests to add (non-happy path/edge cases): instructions: create sink/source with missing media class and expect validation error; create duplicate node name; attempt to connect when target node is absent.
|
||||||
- [ ] Performance tests: instructions: create/destroy 100 virtual sinks and 100 virtual sources in a tight loop; measure wall time and ensure it stays within the target budget.
|
- [x] Performance tests: instructions: create/destroy 100 virtual sinks and 100 virtual sources in a tight loop; measure wall time and ensure it stays within the target budget.
|
||||||
|
|
||||||
- [ ] Milestone 3 — Link management API
|
- [ ] Milestone 3 - Link management API
|
||||||
- [ ] Implement link creation via link-factory (load libpipewire-module-link-factory and call pw_core_create_object with link.input.* and link.output.* props; see src/modules/module-link-factory.c, src/examples/internal.c, src/tools/pw-link.c).
|
- [ ] Implement link creation via link-factory (load libpipewire-module-link-factory and call pw_core_create_object with link.input.* and link.output.* props; see src/modules/module-link-factory.c, src/examples/internal.c, src/tools/pw-link.c).
|
||||||
- [ ] Support linking by node+port names and by object IDs; add object.linger and link.passive options.
|
- [ ] Support linking by node+port names and by object IDs; add object.linger and link.passive options.
|
||||||
- [ ] Add link deletion and link reconciliation (auto-remove stale links when endpoints vanish).
|
- [ ] Add link deletion and link reconciliation (auto-remove stale links when endpoints vanish).
|
||||||
- [ ] Tests to add (non-happy path/edge cases): instructions: link to non-existent port; link output-to-output or input-to-input; remove node while link is initializing; create two links to same port and validate policy behavior.
|
- [ ] Tests to add (non-happy path/edge cases): instructions: link to non-existent port; link output-to-output or input-to-input; remove node while link is initializing; create two links to same port and validate policy behavior.
|
||||||
- [ ] Performance tests: instructions: create 200 links between existing ports; measure create+destroy time and verify subsecond target where possible.
|
- [ ] Performance tests: instructions: create 200 links between existing ports; measure create+destroy time and verify subsecond target where possible.
|
||||||
|
|
||||||
- [ ] Milestone 4 — Persistence and “ephemeral source” policy
|
- [ ] Milestone 4 - Persistence and "ephemeral source" policy
|
||||||
- [ ] Implement persistence (JSON or TOML) for: virtual nodes, links, and per-app routing rules. Persist on change; load on startup.
|
- [ ] Implement persistence (JSON or TOML) for: virtual nodes, links, and per-app routing rules. Persist on change; load on startup.
|
||||||
- [ ] Implement policy engine:
|
- [ ] Implement policy engine:
|
||||||
- [ ] Watch for node/port appearance; apply stored rules to auto-link ephemeral sources to preferred sinks.
|
- [ ] Watch for node/port appearance; apply stored rules to auto-link ephemeral sources to preferred sinks.
|
||||||
- [ ] Store mapping by rule (app identity → target sink/source). Avoid serial IDs; use stable metadata (app/process/role).
|
- [ ] Store mapping by rule (app identity -> target sink/source). Avoid serial IDs; use stable metadata (app/process/role).
|
||||||
- [ ] Allow user override to update rule and persist.
|
- [ ] Allow user override to update rule and persist.
|
||||||
- [ ] Integrate metadata store for defaults and routing hints using libpipewire-module-metadata (see src/modules/module-metadata.c). Track default.audio.sink/source and default.configured.audio.sink/source for stable defaults; use a dedicated warppipe.* metadata namespace to avoid conflicts.
|
- [ ] Integrate metadata store for defaults and routing hints using libpipewire-module-metadata (see src/modules/module-metadata.c). Track default.audio.sink/source and default.configured.audio.sink/source for stable defaults; use a dedicated warppipe.* metadata namespace to avoid conflicts.
|
||||||
- [ ] Tests to add (non-happy path/edge cases): instructions: rule for app that disappears and reappears under a different PID; verify re-routing; conflicting rules (two matches) resolved deterministically; persistence file corrupted; metadata module not available.
|
- [ ] Tests to add (non-happy path/edge cases): instructions: rule for app that disappears and reappears under a different PID; verify re-routing; conflicting rules (two matches) resolved deterministically; persistence file corrupted; metadata module not available.
|
||||||
- [ ] Performance tests: instructions: simulate 200 ephemeral sources (connect/disconnect) and measure time to apply routing rules and create links; ensure rule lookup is O(1) or O(log n).
|
- [ ] Performance tests: instructions: simulate 200 ephemeral sources (connect/disconnect) and measure time to apply routing rules and create links; ensure rule lookup is O(1) or O(log n).
|
||||||
|
|
||||||
- [ ] Milestone 5 — Stability, compatibility, and tooling
|
- [ ] Milestone 5 - Stability, compatibility, and tooling
|
||||||
- [ ] Provide a simple CLI (optional) to inspect nodes, create virtual nodes, link/unlink, and export/import config (useful for manual testing).
|
- [ ] Provide a simple CLI (optional) to inspect nodes, create virtual nodes, link/unlink, and export/import config (useful for manual testing).
|
||||||
- [ ] Add documentation: API usage patterns, threading model, and performance notes.
|
- [ ] Add documentation: API usage patterns, threading model, and performance notes.
|
||||||
- [ ] Validate behavior with PipeWire session manager present (WirePlumber) to avoid fighting policy; allow “policy-only” mode (observes and sets metadata without forcing links).
|
- [ ] Validate behavior with PipeWire session manager present (WirePlumber) to avoid fighting policy; allow “policy-only” mode (observes and sets metadata without forcing links).
|
||||||
|
|
|
||||||
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# warppipe
|
||||||
|
|
||||||
|
A C++ libpipewire library for creating virtual sinks/sources and managing links.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- CMake 3.20+
|
||||||
|
- pkg-config
|
||||||
|
- libpipewire-0.3 development files
|
||||||
|
- C++17 compiler
|
||||||
|
|
||||||
|
Aurora / Universal Blue (Homebrew):
|
||||||
|
```
|
||||||
|
/home/linuxbrew/.linuxbrew/bin/brew install cmake pkg-config pipewire
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
```
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
./build/warppipe_example
|
||||||
|
```
|
||||||
4
clangd-wrapper.sh
Executable file
4
clangd-wrapper.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
exec /home/linuxbrew/.linuxbrew/bin/clangd \
|
||||||
|
--query-driver="/home/linuxbrew/.linuxbrew/bin/g++" \
|
||||||
|
"$@"
|
||||||
53
docs/milestone-0.md
Normal file
53
docs/milestone-0.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Milestone 0 - Foundation
|
||||||
|
|
||||||
|
## Build system
|
||||||
|
|
||||||
|
- CMake 3.20+
|
||||||
|
- C++17
|
||||||
|
- pkg-config
|
||||||
|
- libpipewire-0.3 development files
|
||||||
|
|
||||||
|
## Public API surface
|
||||||
|
|
||||||
|
- Namespace: warppipe
|
||||||
|
- Core types: Status, StatusCode, Result<T>
|
||||||
|
- Threading: ThreadingMode (caller thread or managed thread loop)
|
||||||
|
- Primary entry: Client
|
||||||
|
- Handles: NodeId, PortId, LinkId, RuleId
|
||||||
|
- Data shapes: NodeInfo, PortInfo, VirtualSink, VirtualSource, Link, RouteRule
|
||||||
|
|
||||||
|
## Error model
|
||||||
|
|
||||||
|
- StatusCode enum with explicit categories (ok, invalid argument, not found, unavailable, permission denied, timeout, internal, not implemented)
|
||||||
|
- Status carries code + message; Result<T> pairs Status with value
|
||||||
|
|
||||||
|
## Threading model
|
||||||
|
|
||||||
|
- Default: managed thread loop owned by Client
|
||||||
|
- Optional: caller thread loop for embedding in existing event loop
|
||||||
|
|
||||||
|
## Performance budget and metrics
|
||||||
|
|
||||||
|
- Target: 200 create/modify/delete operations in under 1 second on a warm PipeWire connection
|
||||||
|
- Target per operation: median < 2 ms, p95 < 10 ms for create or delete
|
||||||
|
- Track metrics for: create virtual sink/source, create link, delete link
|
||||||
|
|
||||||
|
## Ephemeral source identity strategy
|
||||||
|
|
||||||
|
- Match priority: application.name, application.process.binary, media.role, node.name
|
||||||
|
- Fallback: client properties and media class
|
||||||
|
- Avoid serial IDs or object IDs for persistence
|
||||||
|
- Rule matching is stable across restarts and reconnects
|
||||||
|
|
||||||
|
## Tests to add (Milestone 0)
|
||||||
|
|
||||||
|
- Missing PipeWire daemon: Client::Create returns StatusCode::kUnavailable
|
||||||
|
- Missing link-factory module: CreateLink returns StatusCode::kUnavailable
|
||||||
|
- Missing metadata module: SaveConfig or policy init returns StatusCode::kUnavailable
|
||||||
|
- Invalid media class in virtual node creation: returns StatusCode::kInvalidArgument
|
||||||
|
|
||||||
|
## Performance tests (Milestone 0)
|
||||||
|
|
||||||
|
- Microbenchmark harness that measures connect -> create N -> destroy N
|
||||||
|
- Baseline: N=200 with subsecond wall time on a warm PipeWire connection
|
||||||
|
- Record median and p95 latency per operation
|
||||||
22
examples/minimal.cpp
Normal file
22
examples/minimal.cpp
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
warppipe::ConnectionOptions options;
|
||||||
|
auto client_result = warppipe::Client::Create(options);
|
||||||
|
if (!client_result.ok()) {
|
||||||
|
std::cerr << "warppipe: failed to create client: "
|
||||||
|
<< client_result.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto sink_result = client_result.value->CreateVirtualSink("example_sink");
|
||||||
|
if (!sink_result.ok()) {
|
||||||
|
std::cerr << "warppipe: failed to create sink: "
|
||||||
|
<< sink_result.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
95
examples/warppipe_cli.cpp
Normal file
95
examples/warppipe_cli.cpp
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
uint32_t ParseId(const char* value) {
|
||||||
|
if (!value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
unsigned long parsed = std::strtoul(value, &end, 10);
|
||||||
|
if (!end || end == value) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return static_cast<uint32_t>(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
int Usage() {
|
||||||
|
std::cerr << "Usage:\n"
|
||||||
|
<< " warppipe_cli list-nodes\n"
|
||||||
|
<< " warppipe_cli list-ports <node-id>\n"
|
||||||
|
<< " warppipe_cli list-links\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
if (argc < 2) {
|
||||||
|
return Usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string command = argv[1];
|
||||||
|
warppipe::ConnectionOptions options;
|
||||||
|
options.application_name = "warppipe-cli";
|
||||||
|
|
||||||
|
auto client_result = warppipe::Client::Create(options);
|
||||||
|
if (!client_result.ok()) {
|
||||||
|
std::cerr << "warppipe: failed to connect: "
|
||||||
|
<< client_result.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "list-nodes") {
|
||||||
|
auto nodes = client_result.value->ListNodes();
|
||||||
|
if (!nodes.ok()) {
|
||||||
|
std::cerr << "warppipe: list-nodes failed: " << nodes.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const auto& node : nodes.value) {
|
||||||
|
std::cout << node.id.value << "\t" << node.name << "\t" << node.media_class << "\n";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "list-ports") {
|
||||||
|
if (argc < 3) {
|
||||||
|
return Usage();
|
||||||
|
}
|
||||||
|
uint32_t node_id = ParseId(argv[2]);
|
||||||
|
if (node_id == 0) {
|
||||||
|
std::cerr << "warppipe: invalid node id\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto ports = client_result.value->ListPorts(warppipe::NodeId{node_id});
|
||||||
|
if (!ports.ok()) {
|
||||||
|
std::cerr << "warppipe: list-ports failed: " << ports.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const auto& port : ports.value) {
|
||||||
|
std::cout << port.id.value << "\t" << port.name << "\t"
|
||||||
|
<< (port.is_input ? "in" : "out") << "\n";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command == "list-links") {
|
||||||
|
auto links = client_result.value->ListLinks();
|
||||||
|
if (!links.ok()) {
|
||||||
|
std::cerr << "warppipe: list-links failed: " << links.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const auto& link : links.value) {
|
||||||
|
std::cout << link.id.value << "\t" << link.output_port.value << "\t"
|
||||||
|
<< link.input_port.value << "\n";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Usage();
|
||||||
|
}
|
||||||
178
include/warppipe/warppipe.hpp
Normal file
178
include/warppipe/warppipe.hpp
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace warppipe {
|
||||||
|
|
||||||
|
enum class StatusCode : uint8_t {
|
||||||
|
kOk = 0,
|
||||||
|
kInvalidArgument,
|
||||||
|
kNotFound,
|
||||||
|
kUnavailable,
|
||||||
|
kPermissionDenied,
|
||||||
|
kTimeout,
|
||||||
|
kInternal,
|
||||||
|
kNotImplemented,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Status {
|
||||||
|
StatusCode code = StatusCode::kOk;
|
||||||
|
std::string message;
|
||||||
|
|
||||||
|
static Status Ok();
|
||||||
|
static Status Error(StatusCode code, std::string message);
|
||||||
|
bool ok() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
struct Result {
|
||||||
|
Status status;
|
||||||
|
T value{};
|
||||||
|
|
||||||
|
bool ok() const { return status.ok(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ThreadingMode : uint8_t {
|
||||||
|
kCallerThread = 0,
|
||||||
|
kThreadLoop,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ConnectionOptions {
|
||||||
|
ThreadingMode threading = ThreadingMode::kThreadLoop;
|
||||||
|
bool autoconnect = true;
|
||||||
|
std::string application_name = "warppipe";
|
||||||
|
std::optional<std::string> remote_name;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AudioFormat {
|
||||||
|
uint32_t rate = 48000;
|
||||||
|
uint32_t channels = 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class VirtualBehavior : uint8_t {
|
||||||
|
kNull = 0,
|
||||||
|
kLoopback,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VirtualNodeOptions {
|
||||||
|
AudioFormat format{};
|
||||||
|
VirtualBehavior behavior = VirtualBehavior::kNull;
|
||||||
|
std::optional<std::string> target_node;
|
||||||
|
std::optional<std::string> media_class_override;
|
||||||
|
std::string display_name;
|
||||||
|
std::string group;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NodeId {
|
||||||
|
uint32_t value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PortId {
|
||||||
|
uint32_t value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LinkId {
|
||||||
|
uint32_t value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RuleId {
|
||||||
|
uint32_t value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NodeInfo {
|
||||||
|
NodeId id;
|
||||||
|
std::string name;
|
||||||
|
std::string media_class;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PortInfo {
|
||||||
|
PortId id;
|
||||||
|
NodeId node;
|
||||||
|
std::string name;
|
||||||
|
bool is_input = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VirtualSink {
|
||||||
|
NodeId node;
|
||||||
|
std::string name;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VirtualSource {
|
||||||
|
NodeId node;
|
||||||
|
std::string name;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Link {
|
||||||
|
LinkId id;
|
||||||
|
PortId output_port;
|
||||||
|
PortId input_port;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LinkOptions {
|
||||||
|
bool passive = false;
|
||||||
|
bool linger = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RuleMatch {
|
||||||
|
std::string application_name;
|
||||||
|
std::string process_binary;
|
||||||
|
std::string media_role;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RouteRule {
|
||||||
|
RuleMatch match;
|
||||||
|
std::string target_node;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Client {
|
||||||
|
public:
|
||||||
|
Client(const Client&) = delete;
|
||||||
|
Client& operator=(const Client&) = delete;
|
||||||
|
Client(Client&&) noexcept;
|
||||||
|
Client& operator=(Client&&) noexcept;
|
||||||
|
~Client();
|
||||||
|
|
||||||
|
static Result<std::unique_ptr<Client>> Create(const ConnectionOptions& options);
|
||||||
|
|
||||||
|
Status Shutdown();
|
||||||
|
|
||||||
|
Result<std::vector<NodeInfo>> ListNodes();
|
||||||
|
Result<std::vector<PortInfo>> ListPorts(NodeId node);
|
||||||
|
Result<std::vector<Link>> ListLinks();
|
||||||
|
|
||||||
|
Result<VirtualSink> CreateVirtualSink(std::string_view name,
|
||||||
|
const VirtualNodeOptions& options = VirtualNodeOptions{});
|
||||||
|
Result<VirtualSource> CreateVirtualSource(std::string_view name,
|
||||||
|
const VirtualNodeOptions& options = VirtualNodeOptions{});
|
||||||
|
Status RemoveNode(NodeId node);
|
||||||
|
|
||||||
|
Result<Link> CreateLink(PortId output, PortId input, const LinkOptions& options);
|
||||||
|
Status RemoveLink(LinkId link);
|
||||||
|
|
||||||
|
Result<RuleId> AddRouteRule(const RouteRule& rule);
|
||||||
|
Status RemoveRouteRule(RuleId id);
|
||||||
|
|
||||||
|
Status SaveConfig(std::string_view path);
|
||||||
|
Status LoadConfig(std::string_view path);
|
||||||
|
|
||||||
|
#ifdef WARPPIPE_TESTING
|
||||||
|
Status Test_InsertNode(const NodeInfo& node);
|
||||||
|
Status Test_InsertPort(const PortInfo& port);
|
||||||
|
Status Test_InsertLink(const Link& link);
|
||||||
|
Status Test_RemoveGlobal(uint32_t id);
|
||||||
|
Status Test_ForceDisconnect();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
explicit Client(std::unique_ptr<Impl> impl);
|
||||||
|
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace warppipe
|
||||||
33
perf/README.md
Normal file
33
perf/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Performance tests
|
||||||
|
|
||||||
|
Milestone 0 perf test instructions are tracked in docs/milestone-0.md.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Create/destroy microbenchmark (milestone 0/2):
|
||||||
|
```
|
||||||
|
./build/warppipe_perf --mode create-destroy --count 200 --type sink
|
||||||
|
./build/warppipe_perf --mode create-destroy --count 100 --type both
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry snapshot + add/remove events (milestone 1):
|
||||||
|
```
|
||||||
|
./build/warppipe_perf --mode registry --count 1000 --events 100
|
||||||
|
```
|
||||||
|
|
||||||
|
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 --target "some-node-name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Planned coverage:
|
||||||
|
- Microbenchmark: connect -> create N -> destroy N
|
||||||
|
- Target: subsecond for N=200 on warm PipeWire connection
|
||||||
231
perf/warppipe_perf.cpp
Normal file
231
perf/warppipe_perf.cpp
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct Options {
|
||||||
|
std::string mode = "create-destroy";
|
||||||
|
std::string type = "sink";
|
||||||
|
std::string target;
|
||||||
|
uint32_t count = 200;
|
||||||
|
uint32_t events = 100;
|
||||||
|
uint32_t rate = 48000;
|
||||||
|
uint32_t channels = 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool ParseUInt(const char* value, uint32_t* out) {
|
||||||
|
if (!value || !out) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
unsigned long parsed = std::strtoul(value, &end, 10);
|
||||||
|
if (!end || end == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*out = static_cast<uint32_t>(parsed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PrintUsage() {
|
||||||
|
std::cout << "warppipe_perf usage:\n"
|
||||||
|
<< " --mode create-destroy|registry\n"
|
||||||
|
<< " --type sink|source|both\n"
|
||||||
|
<< " --count N (default 200, per-type when --type both)\n"
|
||||||
|
<< " --events N (registry mode, default 100)\n"
|
||||||
|
<< " --rate N (default 48000)\n"
|
||||||
|
<< " --channels N (default 2)\n"
|
||||||
|
<< " --target <node-name> (loopback target, optional)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ParseArgs(int argc, char* argv[], Options* options) {
|
||||||
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
std::string arg = argv[i];
|
||||||
|
if (arg == "--mode" && i + 1 < argc) {
|
||||||
|
options->mode = argv[++i];
|
||||||
|
} else if (arg == "--type" && i + 1 < argc) {
|
||||||
|
options->type = argv[++i];
|
||||||
|
} else if (arg == "--count" && i + 1 < argc) {
|
||||||
|
if (!ParseUInt(argv[++i], &options->count)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (arg == "--events" && i + 1 < argc) {
|
||||||
|
if (!ParseUInt(argv[++i], &options->events)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (arg == "--rate" && i + 1 < argc) {
|
||||||
|
if (!ParseUInt(argv[++i], &options->rate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (arg == "--channels" && i + 1 < argc) {
|
||||||
|
if (!ParseUInt(argv[++i], &options->channels)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (arg == "--target" && i + 1 < argc) {
|
||||||
|
options->target = argv[++i];
|
||||||
|
} else if (arg == "--help" || arg == "-h") {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options->type != "sink" && options->type != "source" && options->type != "both") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (options->mode != "create-destroy" && options->mode != "registry") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Prefix() {
|
||||||
|
return "warppipe-perf-" + std::to_string(static_cast<long>(getpid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
double ToMillis(std::chrono::steady_clock::duration duration) {
|
||||||
|
return std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(duration).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
Options options;
|
||||||
|
if (!ParseArgs(argc, argv, &options)) {
|
||||||
|
PrintUsage();
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
warppipe::ConnectionOptions connection;
|
||||||
|
connection.application_name = "warppipe-perf";
|
||||||
|
auto client = warppipe::Client::Create(connection);
|
||||||
|
if (!client.ok()) {
|
||||||
|
std::cerr << "warppipe_perf: failed to connect: " << client.status.message << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
warppipe::VirtualNodeOptions node_options;
|
||||||
|
node_options.format.rate = options.rate;
|
||||||
|
node_options.format.channels = options.channels;
|
||||||
|
node_options.display_name = "warppipe-perf";
|
||||||
|
node_options.group = "warppipe-perf";
|
||||||
|
if (!options.target.empty()) {
|
||||||
|
node_options.behavior = warppipe::VirtualBehavior::kLoopback;
|
||||||
|
node_options.target_node = options.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool is_source = options.type == "source";
|
||||||
|
const bool is_both = options.type == "both";
|
||||||
|
const std::string prefix = Prefix();
|
||||||
|
|
||||||
|
if (options.mode == "create-destroy") {
|
||||||
|
std::vector<warppipe::NodeId> sink_nodes;
|
||||||
|
std::vector<warppipe::NodeId> source_nodes;
|
||||||
|
sink_nodes.reserve(options.count);
|
||||||
|
if (is_both) {
|
||||||
|
source_nodes.reserve(options.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto start = std::chrono::steady_clock::now();
|
||||||
|
if (!is_source || is_both) {
|
||||||
|
for (uint32_t i = 0; i < options.count; ++i) {
|
||||||
|
std::string name = prefix + "-sink-" + std::to_string(i);
|
||||||
|
warppipe::Result<warppipe::VirtualSink> sink_result =
|
||||||
|
client.value->CreateVirtualSink(name, node_options);
|
||||||
|
if (!sink_result.ok()) {
|
||||||
|
std::cerr << "create failed at " << i << ": " << sink_result.status.message << "\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sink_nodes.push_back(sink_result.value.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_source || is_both) {
|
||||||
|
for (uint32_t i = 0; i < options.count; ++i) {
|
||||||
|
std::string name = prefix + "-source-" + std::to_string(i);
|
||||||
|
warppipe::Result<warppipe::VirtualSource> source_result =
|
||||||
|
client.value->CreateVirtualSource(name, node_options);
|
||||||
|
if (!source_result.ok()) {
|
||||||
|
std::cerr << "create failed at " << i << ": " << source_result.status.message << "\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
source_nodes.push_back(source_result.value.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto created = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
for (const auto& node : sink_nodes) {
|
||||||
|
client.value->RemoveNode(node);
|
||||||
|
}
|
||||||
|
for (const auto& node : source_nodes) {
|
||||||
|
client.value->RemoveNode(node);
|
||||||
|
}
|
||||||
|
auto destroyed = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
const double create_ms = ToMillis(created - start);
|
||||||
|
const double destroy_ms = ToMillis(destroyed - created);
|
||||||
|
const double total_ms = ToMillis(destroyed - start);
|
||||||
|
const double ops = static_cast<double>(sink_nodes.size() + source_nodes.size());
|
||||||
|
std::cout << "create_count=" << static_cast<size_t>(ops) << "\n"
|
||||||
|
<< "create_ms=" << std::fixed << std::setprecision(2) << create_ms << "\n"
|
||||||
|
<< "destroy_ms=" << destroy_ms << "\n"
|
||||||
|
<< "total_ms=" << total_ms << "\n";
|
||||||
|
if (total_ms > 0.0) {
|
||||||
|
std::cout << "ops_per_sec=" << (ops / (total_ms / 1000.0)) << "\n";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mode == "registry") {
|
||||||
|
std::vector<warppipe::NodeId> nodes;
|
||||||
|
nodes.reserve(options.count);
|
||||||
|
for (uint32_t i = 0; i < options.count; ++i) {
|
||||||
|
std::string name = prefix + "-node-" + std::to_string(i);
|
||||||
|
auto result = client.value->CreateVirtualSink(name, node_options);
|
||||||
|
if (!result.ok()) {
|
||||||
|
std::cerr << "create failed at " << i << ": " << result.status.message << "\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nodes.push_back(result.value.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto list_start = std::chrono::steady_clock::now();
|
||||||
|
auto listed = client.value->ListNodes();
|
||||||
|
auto list_end = std::chrono::steady_clock::now();
|
||||||
|
if (!listed.ok()) {
|
||||||
|
std::cerr << "ListNodes failed: " << listed.status.message << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto events_start = std::chrono::steady_clock::now();
|
||||||
|
for (uint32_t i = 0; i < options.events; ++i) {
|
||||||
|
std::string name = prefix + "-event-" + std::to_string(i);
|
||||||
|
auto result = client.value->CreateVirtualSink(name, node_options);
|
||||||
|
if (!result.ok()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
client.value->RemoveNode(result.value.node);
|
||||||
|
}
|
||||||
|
auto events_end = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
for (const auto& node : nodes) {
|
||||||
|
client.value->RemoveNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const double list_ms = ToMillis(list_end - list_start);
|
||||||
|
const double events_ms = ToMillis(events_end - events_start);
|
||||||
|
std::cout << "registry_nodes=" << nodes.size() << "\n"
|
||||||
|
<< "list_ms=" << std::fixed << std::setprecision(2) << list_ms << "\n"
|
||||||
|
<< "event_ops=" << options.events << "\n"
|
||||||
|
<< "event_ms=" << events_ms << "\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
PrintUsage();
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
862
src/warppipe.cpp
Normal file
862
src/warppipe.cpp
Normal file
|
|
@ -0,0 +1,862 @@
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <pipewire/keys.h>
|
||||||
|
#include <pipewire/pipewire.h>
|
||||||
|
|
||||||
|
#include <spa/param/audio/format-utils.h>
|
||||||
|
#include <spa/utils/defs.h>
|
||||||
|
|
||||||
|
#include <spa/utils/result.h>
|
||||||
|
#include <spa/utils/string.h>
|
||||||
|
|
||||||
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
namespace warppipe {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kSyncWaitSeconds = 2;
|
||||||
|
constexpr uint32_t kDefaultRate = 48000;
|
||||||
|
constexpr uint32_t kDefaultChannels = 2;
|
||||||
|
|
||||||
|
const char* SafeLookup(const spa_dict* dict, const char* key) {
|
||||||
|
if (!dict || !key) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return spa_dict_lookup(dict, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string LookupString(const spa_dict* dict, const char* key) {
|
||||||
|
const char* value = SafeLookup(dict, key);
|
||||||
|
return value ? std::string(value) : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ParseUint32(const char* value, uint32_t* out) {
|
||||||
|
if (!value || !out) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
unsigned long parsed = std::strtoul(value, &end, 10);
|
||||||
|
if (!end || end == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*out = static_cast<uint32_t>(parsed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsNodeType(const char* type) {
|
||||||
|
return type && spa_streq(type, PW_TYPE_INTERFACE_Node);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsPortType(const char* type) {
|
||||||
|
return type && spa_streq(type, PW_TYPE_INTERFACE_Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsLinkType(const char* type) {
|
||||||
|
return type && spa_streq(type, PW_TYPE_INTERFACE_Link);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamData {
|
||||||
|
pw_stream* stream = nullptr;
|
||||||
|
spa_hook listener{};
|
||||||
|
pw_thread_loop* loop = nullptr;
|
||||||
|
bool is_source = false;
|
||||||
|
bool loopback = false;
|
||||||
|
std::string target_node;
|
||||||
|
std::string name;
|
||||||
|
bool ready = false;
|
||||||
|
bool failed = false;
|
||||||
|
std::string error;
|
||||||
|
uint32_t node_id = SPA_ID_INVALID;
|
||||||
|
uint32_t channels = kDefaultChannels;
|
||||||
|
uint32_t rate = kDefaultRate;
|
||||||
|
};
|
||||||
|
|
||||||
|
void StreamProcess(void* data) {
|
||||||
|
auto* stream_data = static_cast<StreamData*>(data);
|
||||||
|
if (!stream_data || !stream_data->stream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stream_data->is_source) {
|
||||||
|
struct pw_buffer* buffer = nullptr;
|
||||||
|
while ((buffer = pw_stream_dequeue_buffer(stream_data->stream)) != nullptr) {
|
||||||
|
pw_stream_queue_buffer(stream_data->stream, buffer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct pw_buffer* buffer = nullptr;
|
||||||
|
while ((buffer = pw_stream_dequeue_buffer(stream_data->stream)) != nullptr) {
|
||||||
|
struct spa_buffer* spa_buffer = buffer->buffer;
|
||||||
|
if (!spa_buffer) {
|
||||||
|
pw_stream_queue_buffer(stream_data->stream, buffer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint32_t stride = sizeof(float) * stream_data->channels;
|
||||||
|
for (uint32_t i = 0; i < spa_buffer->n_datas; ++i) {
|
||||||
|
struct spa_data* data_entry = &spa_buffer->datas[i];
|
||||||
|
if (!data_entry->data || !data_entry->chunk) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::memset(data_entry->data, 0, data_entry->maxsize);
|
||||||
|
uint32_t frames = stride > 0 ? data_entry->maxsize / stride : 0;
|
||||||
|
if (buffer->requested > 0 && buffer->requested < frames) {
|
||||||
|
frames = buffer->requested;
|
||||||
|
}
|
||||||
|
data_entry->chunk->offset = 0;
|
||||||
|
data_entry->chunk->stride = stride;
|
||||||
|
data_entry->chunk->size = frames * stride;
|
||||||
|
}
|
||||||
|
pw_stream_queue_buffer(stream_data->stream, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StreamStateChanged(void* data,
|
||||||
|
enum pw_stream_state,
|
||||||
|
enum pw_stream_state state,
|
||||||
|
const char* error) {
|
||||||
|
auto* stream_data = static_cast<StreamData*>(data);
|
||||||
|
if (!stream_data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == PW_STREAM_STATE_ERROR) {
|
||||||
|
stream_data->failed = true;
|
||||||
|
if (error) {
|
||||||
|
stream_data->error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream_data->stream) {
|
||||||
|
uint32_t node_id = pw_stream_get_node_id(stream_data->stream);
|
||||||
|
if (node_id != SPA_ID_INVALID) {
|
||||||
|
stream_data->node_id = node_id;
|
||||||
|
stream_data->ready = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream_data->loop) {
|
||||||
|
pw_thread_loop_signal(stream_data->loop, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const pw_stream_events kStreamEvents = {
|
||||||
|
PW_VERSION_STREAM_EVENTS,
|
||||||
|
.state_changed = StreamStateChanged,
|
||||||
|
.process = StreamProcess,
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Status Status::Ok() {
|
||||||
|
return Status{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Status::Error(StatusCode code, std::string message) {
|
||||||
|
Status status;
|
||||||
|
status.code = code;
|
||||||
|
status.message = std::move(message);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Status::ok() const {
|
||||||
|
return code == StatusCode::kOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Client::Impl {
|
||||||
|
ConnectionOptions options;
|
||||||
|
pw_thread_loop* thread_loop = nullptr;
|
||||||
|
pw_context* context = nullptr;
|
||||||
|
pw_core* core = nullptr;
|
||||||
|
pw_registry* registry = nullptr;
|
||||||
|
spa_hook core_listener{};
|
||||||
|
spa_hook registry_listener{};
|
||||||
|
bool core_listener_attached = false;
|
||||||
|
bool registry_listener_attached = false;
|
||||||
|
bool connected = false;
|
||||||
|
uint32_t pending_sync = 0;
|
||||||
|
uint32_t last_sync = 0;
|
||||||
|
Status last_error = Status::Ok();
|
||||||
|
std::mutex cache_mutex;
|
||||||
|
std::unordered_map<uint32_t, NodeInfo> nodes;
|
||||||
|
std::unordered_map<uint32_t, PortInfo> ports;
|
||||||
|
std::unordered_map<uint32_t, Link> links;
|
||||||
|
std::unordered_map<uint32_t, std::unique_ptr<StreamData>> virtual_streams;
|
||||||
|
|
||||||
|
Status ConnectLocked();
|
||||||
|
void DisconnectLocked();
|
||||||
|
Status SyncLocked();
|
||||||
|
void ClearCache();
|
||||||
|
Status EnsureConnected();
|
||||||
|
Result<uint32_t> CreateVirtualStreamLocked(std::string_view name,
|
||||||
|
bool is_source,
|
||||||
|
const VirtualNodeOptions& options);
|
||||||
|
|
||||||
|
static void RegistryGlobal(void* data,
|
||||||
|
uint32_t id,
|
||||||
|
uint32_t permissions,
|
||||||
|
const char* type,
|
||||||
|
uint32_t version,
|
||||||
|
const spa_dict* props);
|
||||||
|
static void RegistryGlobalRemove(void* data, uint32_t id);
|
||||||
|
static void CoreDone(void* data, uint32_t id, int seq);
|
||||||
|
static void CoreError(void* data, uint32_t id, int seq, int res, const char* message);
|
||||||
|
};
|
||||||
|
|
||||||
|
void Client::Impl::RegistryGlobal(void* data,
|
||||||
|
uint32_t id,
|
||||||
|
uint32_t,
|
||||||
|
const char* type,
|
||||||
|
uint32_t,
|
||||||
|
const spa_dict* props) {
|
||||||
|
auto* impl = static_cast<Client::Impl*>(data);
|
||||||
|
if (!impl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(impl->cache_mutex);
|
||||||
|
|
||||||
|
if (IsNodeType(type)) {
|
||||||
|
NodeInfo info;
|
||||||
|
info.id = NodeId{id};
|
||||||
|
info.name = LookupString(props, PW_KEY_NODE_NAME);
|
||||||
|
info.media_class = LookupString(props, PW_KEY_MEDIA_CLASS);
|
||||||
|
impl->nodes[id] = std::move(info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPortType(type)) {
|
||||||
|
PortInfo info;
|
||||||
|
info.id = PortId{id};
|
||||||
|
info.name = LookupString(props, PW_KEY_PORT_NAME);
|
||||||
|
info.is_input = false;
|
||||||
|
uint32_t node_id = 0;
|
||||||
|
if (ParseUint32(SafeLookup(props, PW_KEY_NODE_ID), &node_id)) {
|
||||||
|
info.node = NodeId{node_id};
|
||||||
|
}
|
||||||
|
const char* direction = SafeLookup(props, PW_KEY_PORT_DIRECTION);
|
||||||
|
if (direction && spa_streq(direction, "in")) {
|
||||||
|
info.is_input = true;
|
||||||
|
}
|
||||||
|
impl->ports[id] = std::move(info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLinkType(type)) {
|
||||||
|
Link info;
|
||||||
|
info.id = LinkId{id};
|
||||||
|
uint32_t out_port = 0;
|
||||||
|
uint32_t in_port = 0;
|
||||||
|
if (ParseUint32(SafeLookup(props, PW_KEY_LINK_OUTPUT_PORT), &out_port)) {
|
||||||
|
info.output_port = PortId{out_port};
|
||||||
|
}
|
||||||
|
if (ParseUint32(SafeLookup(props, PW_KEY_LINK_INPUT_PORT), &in_port)) {
|
||||||
|
info.input_port = PortId{in_port};
|
||||||
|
}
|
||||||
|
impl->links[id] = std::move(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::RegistryGlobalRemove(void* data, uint32_t id) {
|
||||||
|
auto* impl = static_cast<Client::Impl*>(data);
|
||||||
|
if (!impl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(impl->cache_mutex);
|
||||||
|
impl->virtual_streams.erase(id);
|
||||||
|
auto node_it = impl->nodes.find(id);
|
||||||
|
if (node_it != impl->nodes.end()) {
|
||||||
|
impl->nodes.erase(node_it);
|
||||||
|
std::vector<uint32_t> removed_ports;
|
||||||
|
for (auto it = impl->ports.begin(); it != impl->ports.end();) {
|
||||||
|
if (it->second.node.value == id) {
|
||||||
|
removed_ports.push_back(it->first);
|
||||||
|
it = impl->ports.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto it = impl->links.begin(); it != impl->links.end();) {
|
||||||
|
bool remove_link = false;
|
||||||
|
for (uint32_t port_id : removed_ports) {
|
||||||
|
if (it->second.input_port.value == port_id || it->second.output_port.value == port_id) {
|
||||||
|
remove_link = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remove_link) {
|
||||||
|
it = impl->links.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impl->ports.erase(id) > 0) {
|
||||||
|
for (auto it = impl->links.begin(); it != impl->links.end();) {
|
||||||
|
if (it->second.input_port.value == id || it->second.output_port.value == id) {
|
||||||
|
it = impl->links.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl->links.erase(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::CoreDone(void* data, uint32_t, int seq) {
|
||||||
|
auto* impl = static_cast<Client::Impl*>(data);
|
||||||
|
if (!impl || !impl->thread_loop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seq >= static_cast<int>(impl->pending_sync)) {
|
||||||
|
impl->last_sync = static_cast<uint32_t>(seq);
|
||||||
|
pw_thread_loop_signal(impl->thread_loop, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::CoreError(void* data, uint32_t, int, int res, const char* message) {
|
||||||
|
auto* impl = static_cast<Client::Impl*>(data);
|
||||||
|
if (!impl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
impl->connected = false;
|
||||||
|
impl->last_error = Status::Error(StatusCode::kUnavailable,
|
||||||
|
message ? message : spa_strerror(res));
|
||||||
|
if (impl->thread_loop) {
|
||||||
|
pw_thread_loop_signal(impl->thread_loop, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Impl::SyncLocked() {
|
||||||
|
if (!core || !thread_loop) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "pipewire core not connected");
|
||||||
|
}
|
||||||
|
pending_sync = pw_core_sync(core, PW_ID_CORE, 0);
|
||||||
|
if (pending_sync == SPA_ID_INVALID) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "failed to sync with pipewire core");
|
||||||
|
}
|
||||||
|
while (last_sync < pending_sync) {
|
||||||
|
int wait_res = pw_thread_loop_timed_wait(thread_loop, kSyncWaitSeconds);
|
||||||
|
if (wait_res == -ETIMEDOUT) {
|
||||||
|
return Status::Error(StatusCode::kTimeout, "timeout waiting for pipewire sync");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::ClearCache() {
|
||||||
|
std::lock_guard<std::mutex> lock(cache_mutex);
|
||||||
|
nodes.clear();
|
||||||
|
ports.clear();
|
||||||
|
links.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Impl::EnsureConnected() {
|
||||||
|
if (connected) {
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
if (!options.autoconnect) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "pipewire core disconnected");
|
||||||
|
}
|
||||||
|
if (!thread_loop) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "pipewire thread loop not running");
|
||||||
|
}
|
||||||
|
pw_thread_loop_lock(thread_loop);
|
||||||
|
Status status = ConnectLocked();
|
||||||
|
pw_thread_loop_unlock(thread_loop);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<uint32_t> Client::Impl::CreateVirtualStreamLocked(std::string_view name,
|
||||||
|
bool is_source,
|
||||||
|
const VirtualNodeOptions& options) {
|
||||||
|
if (!core || !thread_loop) {
|
||||||
|
return {Status::Error(StatusCode::kUnavailable, "pipewire core not connected"), 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.format.rate == 0 || options.format.channels == 0) {
|
||||||
|
return {Status::Error(StatusCode::kInvalidArgument, "invalid audio format"), 0};
|
||||||
|
}
|
||||||
|
if (options.behavior == VirtualBehavior::kLoopback && !options.target_node) {
|
||||||
|
return {Status::Error(StatusCode::kInvalidArgument, "loopback requires target node"), 0};
|
||||||
|
}
|
||||||
|
if (options.media_class_override && options.media_class_override->empty()) {
|
||||||
|
return {Status::Error(StatusCode::kInvalidArgument, "missing media class"), 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string stream_name = name.empty() ? (is_source ? "warppipe-source" : "warppipe-sink")
|
||||||
|
: std::string(name);
|
||||||
|
const char* media_class = is_source ? "Audio/Source" : "Audio/Sink";
|
||||||
|
std::string media_class_value = options.media_class_override ? *options.media_class_override
|
||||||
|
: std::string(media_class);
|
||||||
|
if (media_class_value.empty()) {
|
||||||
|
return {Status::Error(StatusCode::kInvalidArgument, "missing media class"), 0};
|
||||||
|
}
|
||||||
|
const char* media_category = is_source ? "Capture" : "Playback";
|
||||||
|
|
||||||
|
std::string display_name = options.display_name.empty() ? stream_name : options.display_name;
|
||||||
|
const char* node_group = options.group.empty() ? nullptr : options.group.c_str();
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(cache_mutex);
|
||||||
|
for (const auto& entry : nodes) {
|
||||||
|
if (entry.second.name == stream_name) {
|
||||||
|
return {Status::Error(StatusCode::kInvalidArgument, "duplicate node name"), 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto& entry : virtual_streams) {
|
||||||
|
if (entry.second && entry.second->name == stream_name) {
|
||||||
|
return {Status::Error(StatusCode::kInvalidArgument, "duplicate node name"), 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.behavior == VirtualBehavior::kLoopback && options.target_node) {
|
||||||
|
bool found_target = false;
|
||||||
|
for (const auto& entry : nodes) {
|
||||||
|
if (entry.second.name == *options.target_node) {
|
||||||
|
found_target = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found_target) {
|
||||||
|
return {Status::Error(StatusCode::kNotFound, "target node not found"), 0};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_properties* props = pw_properties_new(PW_KEY_MEDIA_TYPE, "Audio",
|
||||||
|
PW_KEY_MEDIA_CATEGORY, media_category,
|
||||||
|
PW_KEY_MEDIA_ROLE, "Music",
|
||||||
|
PW_KEY_MEDIA_CLASS, media_class_value.c_str(),
|
||||||
|
PW_KEY_NODE_NAME, stream_name.c_str(),
|
||||||
|
PW_KEY_MEDIA_NAME, display_name.c_str(),
|
||||||
|
PW_KEY_NODE_DESCRIPTION, display_name.c_str(),
|
||||||
|
PW_KEY_NODE_VIRTUAL, "true",
|
||||||
|
nullptr);
|
||||||
|
if (!props) {
|
||||||
|
return {Status::Error(StatusCode::kInternal, "failed to allocate stream properties"), 0};
|
||||||
|
}
|
||||||
|
if (node_group) {
|
||||||
|
pw_properties_set(props, PW_KEY_NODE_GROUP, node_group);
|
||||||
|
}
|
||||||
|
if (options.behavior == VirtualBehavior::kLoopback && options.target_node) {
|
||||||
|
pw_properties_set(props, PW_KEY_TARGET_OBJECT, options.target_node->c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_stream* stream = pw_stream_new(core, stream_name.c_str(), props);
|
||||||
|
if (!stream) {
|
||||||
|
return {Status::Error(StatusCode::kUnavailable, "failed to create pipewire stream"), 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto stream_data = std::make_unique<StreamData>();
|
||||||
|
stream_data->stream = stream;
|
||||||
|
stream_data->loop = thread_loop;
|
||||||
|
stream_data->is_source = is_source;
|
||||||
|
stream_data->loopback = options.behavior == VirtualBehavior::kLoopback;
|
||||||
|
if (options.target_node) {
|
||||||
|
stream_data->target_node = *options.target_node;
|
||||||
|
}
|
||||||
|
stream_data->name = stream_name;
|
||||||
|
if (options.format.rate != 0) {
|
||||||
|
stream_data->rate = options.format.rate;
|
||||||
|
}
|
||||||
|
if (options.format.channels != 0) {
|
||||||
|
stream_data->channels = options.format.channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_stream_add_listener(stream, &stream_data->listener, &kStreamEvents, stream_data.get());
|
||||||
|
|
||||||
|
const struct spa_pod* params[1];
|
||||||
|
uint8_t buffer[1024];
|
||||||
|
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
spa_audio_info_raw audio_info{};
|
||||||
|
audio_info.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
audio_info.rate = stream_data->rate;
|
||||||
|
audio_info.channels = stream_data->channels;
|
||||||
|
params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &audio_info);
|
||||||
|
|
||||||
|
enum pw_direction direction = is_source ? PW_DIRECTION_OUTPUT : PW_DIRECTION_INPUT;
|
||||||
|
enum pw_stream_flags flags = PW_STREAM_FLAG_MAP_BUFFERS;
|
||||||
|
if (options.behavior == VirtualBehavior::kLoopback && options.target_node) {
|
||||||
|
flags = static_cast<pw_stream_flags>(flags | PW_STREAM_FLAG_AUTOCONNECT);
|
||||||
|
}
|
||||||
|
int res = pw_stream_connect(stream, direction, PW_ID_ANY, flags, params, 1);
|
||||||
|
if (res < 0) {
|
||||||
|
pw_stream_destroy(stream);
|
||||||
|
return {Status::Error(StatusCode::kUnavailable, "failed to connect pipewire stream"), 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t node_id = pw_stream_get_node_id(stream);
|
||||||
|
int wait_attempts = 0;
|
||||||
|
while (node_id == SPA_ID_INVALID && !stream_data->failed && wait_attempts < 3) {
|
||||||
|
int wait_res = pw_thread_loop_timed_wait(thread_loop, kSyncWaitSeconds);
|
||||||
|
if (wait_res == -ETIMEDOUT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node_id = stream_data->node_id;
|
||||||
|
++wait_attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream_data->failed) {
|
||||||
|
std::string error = stream_data->error.empty() ? "stream entered error state" : stream_data->error;
|
||||||
|
pw_stream_destroy(stream);
|
||||||
|
return {Status::Error(StatusCode::kUnavailable, std::move(error)), 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node_id == SPA_ID_INVALID) {
|
||||||
|
pw_stream_destroy(stream);
|
||||||
|
return {Status::Error(StatusCode::kTimeout, "timed out waiting for stream node id"), 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_data->node_id = node_id;
|
||||||
|
stream_data->ready = true;
|
||||||
|
virtual_streams.emplace(node_id, std::move(stream_data));
|
||||||
|
return {Status::Ok(), node_id};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Impl::ConnectLocked() {
|
||||||
|
if (connected) {
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
if (!thread_loop) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "thread loop not initialized");
|
||||||
|
}
|
||||||
|
pw_loop* loop = pw_thread_loop_get_loop(thread_loop);
|
||||||
|
if (!context) {
|
||||||
|
context = pw_context_new(loop, nullptr, 0);
|
||||||
|
if (!context) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "failed to create pipewire context");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_properties* props = pw_properties_new(PW_KEY_APP_NAME, options.application_name.c_str(), nullptr);
|
||||||
|
if (options.remote_name && !options.remote_name->empty()) {
|
||||||
|
pw_properties_set(props, PW_KEY_REMOTE_NAME, options.remote_name->c_str());
|
||||||
|
}
|
||||||
|
core = pw_context_connect(context, props, 0);
|
||||||
|
|
||||||
|
if (!core) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "failed to connect to pipewire core");
|
||||||
|
}
|
||||||
|
|
||||||
|
static const pw_core_events core_events = {
|
||||||
|
PW_VERSION_CORE_EVENTS,
|
||||||
|
.done = CoreDone,
|
||||||
|
.error = CoreError,
|
||||||
|
};
|
||||||
|
pw_core_add_listener(core, &core_listener, &core_events, this);
|
||||||
|
core_listener_attached = true;
|
||||||
|
|
||||||
|
registry = pw_core_get_registry(core, PW_VERSION_REGISTRY, 0);
|
||||||
|
if (!registry) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "failed to get pipewire registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
static const pw_registry_events registry_events = {
|
||||||
|
PW_VERSION_REGISTRY_EVENTS,
|
||||||
|
.global = RegistryGlobal,
|
||||||
|
.global_remove = RegistryGlobalRemove,
|
||||||
|
};
|
||||||
|
pw_registry_add_listener(registry, ®istry_listener, ®istry_events, this);
|
||||||
|
registry_listener_attached = true;
|
||||||
|
connected = true;
|
||||||
|
last_error = Status::Ok();
|
||||||
|
ClearCache();
|
||||||
|
return SyncLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::DisconnectLocked() {
|
||||||
|
for (auto& entry : virtual_streams) {
|
||||||
|
StreamData* stream_data = entry.second.get();
|
||||||
|
if (stream_data && stream_data->stream) {
|
||||||
|
pw_stream_disconnect(stream_data->stream);
|
||||||
|
pw_stream_destroy(stream_data->stream);
|
||||||
|
stream_data->stream = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
virtual_streams.clear();
|
||||||
|
if (registry_listener_attached) {
|
||||||
|
spa_hook_remove(®istry_listener);
|
||||||
|
registry_listener_attached = false;
|
||||||
|
}
|
||||||
|
if (core_listener_attached) {
|
||||||
|
spa_hook_remove(&core_listener);
|
||||||
|
core_listener_attached = false;
|
||||||
|
}
|
||||||
|
if (registry) {
|
||||||
|
pw_proxy_destroy(reinterpret_cast<pw_proxy*>(registry));
|
||||||
|
registry = nullptr;
|
||||||
|
}
|
||||||
|
if (core) {
|
||||||
|
pw_core_disconnect(core);
|
||||||
|
core = nullptr;
|
||||||
|
}
|
||||||
|
connected = false;
|
||||||
|
ClearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
Client::Client(std::unique_ptr<Impl> impl) : impl_(std::move(impl)) {}
|
||||||
|
|
||||||
|
Client::Client(Client&&) noexcept = default;
|
||||||
|
Client& Client::operator=(Client&&) noexcept = default;
|
||||||
|
|
||||||
|
Client::~Client() {
|
||||||
|
if (impl_) {
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<std::unique_ptr<Client>> Client::Create(const ConnectionOptions& options) {
|
||||||
|
pw_init(nullptr, nullptr);
|
||||||
|
|
||||||
|
if (options.threading == ThreadingMode::kCallerThread) {
|
||||||
|
return {Status::Error(StatusCode::kNotImplemented, "caller thread mode not implemented"), {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto impl = std::make_unique<Impl>();
|
||||||
|
impl->options = options;
|
||||||
|
impl->thread_loop = pw_thread_loop_new("warppipe", nullptr);
|
||||||
|
if (!impl->thread_loop) {
|
||||||
|
return {Status::Error(StatusCode::kUnavailable, "failed to create pipewire thread loop"), {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pw_thread_loop_start(impl->thread_loop) != 0) {
|
||||||
|
pw_thread_loop_destroy(impl->thread_loop);
|
||||||
|
impl->thread_loop = nullptr;
|
||||||
|
return {Status::Error(StatusCode::kUnavailable, "failed to start pipewire thread loop"), {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_lock(impl->thread_loop);
|
||||||
|
Status status = impl->ConnectLocked();
|
||||||
|
pw_thread_loop_unlock(impl->thread_loop);
|
||||||
|
if (!status.ok()) {
|
||||||
|
pw_thread_loop_lock(impl->thread_loop);
|
||||||
|
impl->DisconnectLocked();
|
||||||
|
if (impl->context) {
|
||||||
|
pw_context_destroy(impl->context);
|
||||||
|
impl->context = nullptr;
|
||||||
|
}
|
||||||
|
pw_thread_loop_unlock(impl->thread_loop);
|
||||||
|
pw_thread_loop_stop(impl->thread_loop);
|
||||||
|
pw_thread_loop_destroy(impl->thread_loop);
|
||||||
|
impl->thread_loop = nullptr;
|
||||||
|
return {status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {Status::Ok(), std::unique_ptr<Client>(new Client(std::move(impl)))};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Shutdown() {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
if (impl_->thread_loop) {
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
impl_->DisconnectLocked();
|
||||||
|
if (impl_->context) {
|
||||||
|
pw_context_destroy(impl_->context);
|
||||||
|
impl_->context = nullptr;
|
||||||
|
}
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
pw_thread_loop_stop(impl_->thread_loop);
|
||||||
|
pw_thread_loop_destroy(impl_->thread_loop);
|
||||||
|
impl_->thread_loop = nullptr;
|
||||||
|
}
|
||||||
|
pw_deinit();
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<std::vector<NodeInfo>> Client::ListNodes() {
|
||||||
|
Status status = impl_->EnsureConnected();
|
||||||
|
if (!status.ok()) {
|
||||||
|
return {status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
std::vector<NodeInfo> items;
|
||||||
|
items.reserve(impl_->nodes.size());
|
||||||
|
for (const auto& entry : impl_->nodes) {
|
||||||
|
items.push_back(entry.second);
|
||||||
|
}
|
||||||
|
return {Status::Ok(), std::move(items)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<std::vector<PortInfo>> Client::ListPorts(NodeId node) {
|
||||||
|
Status status = impl_->EnsureConnected();
|
||||||
|
if (!status.ok()) {
|
||||||
|
return {status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
std::vector<PortInfo> items;
|
||||||
|
for (const auto& entry : impl_->ports) {
|
||||||
|
if (entry.second.node.value == node.value) {
|
||||||
|
items.push_back(entry.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {Status::Ok(), std::move(items)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<std::vector<Link>> Client::ListLinks() {
|
||||||
|
Status status = impl_->EnsureConnected();
|
||||||
|
if (!status.ok()) {
|
||||||
|
return {status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
std::vector<Link> items;
|
||||||
|
items.reserve(impl_->links.size());
|
||||||
|
for (const auto& entry : impl_->links) {
|
||||||
|
items.push_back(entry.second);
|
||||||
|
}
|
||||||
|
return {Status::Ok(), std::move(items)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<VirtualSink> Client::CreateVirtualSink(std::string_view name,
|
||||||
|
const VirtualNodeOptions& options) {
|
||||||
|
Status status = impl_->EnsureConnected();
|
||||||
|
if (!status.ok()) {
|
||||||
|
return {status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string name_value = name.empty() ? std::string() : std::string(name);
|
||||||
|
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
auto result = impl_->CreateVirtualStreamLocked(name_value, false, options);
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
if (!result.ok()) {
|
||||||
|
return {result.status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
VirtualSink sink;
|
||||||
|
sink.node = NodeId{result.value};
|
||||||
|
sink.name = name_value.empty() ? "warppipe-sink" : name_value;
|
||||||
|
return {Status::Ok(), std::move(sink)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<VirtualSource> Client::CreateVirtualSource(std::string_view name,
|
||||||
|
const VirtualNodeOptions& options) {
|
||||||
|
Status status = impl_->EnsureConnected();
|
||||||
|
if (!status.ok()) {
|
||||||
|
return {status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string name_value = name.empty() ? std::string() : std::string(name);
|
||||||
|
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
auto result = impl_->CreateVirtualStreamLocked(name_value, true, options);
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
if (!result.ok()) {
|
||||||
|
return {result.status, {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
VirtualSource source;
|
||||||
|
source.node = NodeId{result.value};
|
||||||
|
source.name = name_value.empty() ? "warppipe-source" : name_value;
|
||||||
|
return {Status::Ok(), std::move(source)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::RemoveNode(NodeId node) {
|
||||||
|
Status status = impl_->EnsureConnected();
|
||||||
|
if (!status.ok()) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
auto it = impl_->virtual_streams.find(node.value);
|
||||||
|
if (it == impl_->virtual_streams.end()) {
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
return Status::Error(StatusCode::kNotFound, "node not managed by warppipe");
|
||||||
|
}
|
||||||
|
StreamData* stream_data = it->second.get();
|
||||||
|
if (stream_data && stream_data->stream) {
|
||||||
|
pw_stream_disconnect(stream_data->stream);
|
||||||
|
pw_stream_destroy(stream_data->stream);
|
||||||
|
stream_data->stream = nullptr;
|
||||||
|
}
|
||||||
|
impl_->virtual_streams.erase(it);
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<Link> Client::CreateLink(PortId, PortId, const LinkOptions&) {
|
||||||
|
return {Status::Error(StatusCode::kNotImplemented, "create link not implemented"), {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::RemoveLink(LinkId) {
|
||||||
|
return Status::Error(StatusCode::kNotImplemented, "remove link not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<RuleId> Client::AddRouteRule(const RouteRule&) {
|
||||||
|
return {Status::Error(StatusCode::kNotImplemented, "add route rule not implemented"), {}};
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::RemoveRouteRule(RuleId) {
|
||||||
|
return Status::Error(StatusCode::kNotImplemented, "remove route rule not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::SaveConfig(std::string_view) {
|
||||||
|
return Status::Error(StatusCode::kNotImplemented, "save config not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::LoadConfig(std::string_view) {
|
||||||
|
return Status::Error(StatusCode::kNotImplemented, "load config not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef WARPPIPE_TESTING
|
||||||
|
Status Client::Test_InsertNode(const NodeInfo& node) {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "client not initialized");
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
impl_->nodes[node.id.value] = node;
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Test_InsertPort(const PortInfo& port) {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "client not initialized");
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
impl_->ports[port.id.value] = port;
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Test_InsertLink(const Link& link) {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "client not initialized");
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
impl_->links[link.id.value] = link;
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Test_RemoveGlobal(uint32_t id) {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "client not initialized");
|
||||||
|
}
|
||||||
|
Client::Impl::RegistryGlobalRemove(impl_.get(), id);
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Test_ForceDisconnect() {
|
||||||
|
if (!impl_ || !impl_->thread_loop) {
|
||||||
|
return Status::Error(StatusCode::kInternal, "thread loop not initialized");
|
||||||
|
}
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
impl_->DisconnectLocked();
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
} // namespace warppipe
|
||||||
9
tests/README.md
Normal file
9
tests/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Tests
|
||||||
|
|
||||||
|
Milestone 0 test instructions are tracked in docs/milestone-0.md.
|
||||||
|
|
||||||
|
Planned coverage:
|
||||||
|
- Missing PipeWire daemon
|
||||||
|
- Missing link-factory module
|
||||||
|
- Missing metadata module
|
||||||
|
- Invalid media class in virtual node creation
|
||||||
218
tests/warppipe_tests.cpp
Normal file
218
tests/warppipe_tests.cpp
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
warppipe::ConnectionOptions DefaultOptions() {
|
||||||
|
warppipe::ConnectionOptions options;
|
||||||
|
options.threading = warppipe::ThreadingMode::kThreadLoop;
|
||||||
|
options.autoconnect = true;
|
||||||
|
options.application_name = "warppipe-tests";
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ContainsNode(const std::vector<warppipe::NodeInfo>& nodes, uint32_t id) {
|
||||||
|
for (const auto& node : nodes) {
|
||||||
|
if (node.id.value == id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ContainsLink(const std::vector<warppipe::Link>& links, uint32_t id) {
|
||||||
|
for (const auto& link : links) {
|
||||||
|
if (link.id.value == id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("caller thread mode is not implemented") {
|
||||||
|
warppipe::ConnectionOptions options = DefaultOptions();
|
||||||
|
options.threading = warppipe::ThreadingMode::kCallerThread;
|
||||||
|
auto result = warppipe::Client::Create(options);
|
||||||
|
REQUIRE_FALSE(result.ok());
|
||||||
|
REQUIRE(result.status.code == warppipe::StatusCode::kNotImplemented);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("connects or reports unavailable") {
|
||||||
|
warppipe::ConnectionOptions connection_options = DefaultOptions();
|
||||||
|
auto result = warppipe::Client::Create(connection_options);
|
||||||
|
if (!result.ok()) {
|
||||||
|
REQUIRE(result.status.code == warppipe::StatusCode::kUnavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto nodes = result.value->ListNodes();
|
||||||
|
REQUIRE(nodes.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("invalid remote name fails") {
|
||||||
|
warppipe::ConnectionOptions options = DefaultOptions();
|
||||||
|
options.remote_name = "warppipe-test-missing-remote";
|
||||||
|
auto result = warppipe::Client::Create(options);
|
||||||
|
REQUIRE_FALSE(result.ok());
|
||||||
|
REQUIRE(result.status.code == warppipe::StatusCode::kUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("create and remove virtual sink/source when available") {
|
||||||
|
warppipe::ConnectionOptions options = DefaultOptions();
|
||||||
|
auto result = warppipe::Client::Create(options);
|
||||||
|
if (!result.ok()) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warppipe::VirtualNodeOptions node_options;
|
||||||
|
node_options.display_name = "warppipe-test-virtual";
|
||||||
|
node_options.group = "warppipe-test";
|
||||||
|
node_options.format.rate = 44100;
|
||||||
|
node_options.format.channels = 2;
|
||||||
|
auto sink = result.value->CreateVirtualSink("warppipe-test-sink", node_options);
|
||||||
|
if (!sink.ok()) {
|
||||||
|
if (sink.status.code == warppipe::StatusCode::kUnavailable) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
REQUIRE(sink.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto source = result.value->CreateVirtualSource("warppipe-test-source", node_options);
|
||||||
|
if (!source.ok()) {
|
||||||
|
if (source.status.code == warppipe::StatusCode::kUnavailable) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
REQUIRE(source.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUIRE(result.value->RemoveNode(sink.value.node).ok());
|
||||||
|
REQUIRE(result.value->RemoveNode(source.value.node).ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("missing media class returns invalid argument") {
|
||||||
|
auto result = warppipe::Client::Create(DefaultOptions());
|
||||||
|
if (!result.ok()) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warppipe::VirtualNodeOptions options;
|
||||||
|
options.media_class_override = "";
|
||||||
|
auto sink = result.value->CreateVirtualSink("warppipe-test-missing-class", options);
|
||||||
|
REQUIRE_FALSE(sink.ok());
|
||||||
|
REQUIRE(sink.status.code == warppipe::StatusCode::kInvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("duplicate node name returns invalid argument") {
|
||||||
|
auto result = warppipe::Client::Create(DefaultOptions());
|
||||||
|
if (!result.ok()) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto first = result.value->CreateVirtualSink("warppipe-dup", warppipe::VirtualNodeOptions{});
|
||||||
|
if (!first.ok()) {
|
||||||
|
if (first.status.code == warppipe::StatusCode::kUnavailable) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
REQUIRE(first.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto second = result.value->CreateVirtualSink("warppipe-dup", warppipe::VirtualNodeOptions{});
|
||||||
|
REQUIRE_FALSE(second.ok());
|
||||||
|
REQUIRE(second.status.code == warppipe::StatusCode::kInvalidArgument);
|
||||||
|
|
||||||
|
REQUIRE(result.value->RemoveNode(first.value.node).ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("loopback target missing returns not found") {
|
||||||
|
auto result = warppipe::Client::Create(DefaultOptions());
|
||||||
|
if (!result.ok()) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warppipe::VirtualNodeOptions options;
|
||||||
|
options.behavior = warppipe::VirtualBehavior::kLoopback;
|
||||||
|
options.target_node = "warppipe-missing-target";
|
||||||
|
|
||||||
|
auto sink = result.value->CreateVirtualSink("warppipe-loopback", options);
|
||||||
|
REQUIRE_FALSE(sink.ok());
|
||||||
|
REQUIRE(sink.status.code == warppipe::StatusCode::kNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("registry removal cleans up ports and links") {
|
||||||
|
auto result = warppipe::Client::Create(DefaultOptions());
|
||||||
|
if (!result.ok()) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t node_id = 500001;
|
||||||
|
const uint32_t out_port_id = 500002;
|
||||||
|
const uint32_t in_port_id = 500003;
|
||||||
|
const uint32_t link_id = 500004;
|
||||||
|
|
||||||
|
warppipe::NodeInfo node;
|
||||||
|
node.id = warppipe::NodeId{node_id};
|
||||||
|
node.name = "warppipe-test-node";
|
||||||
|
node.media_class = "Audio/Sink";
|
||||||
|
REQUIRE(result.value->Test_InsertNode(node).ok());
|
||||||
|
|
||||||
|
warppipe::PortInfo out_port;
|
||||||
|
out_port.id = warppipe::PortId{out_port_id};
|
||||||
|
out_port.node = warppipe::NodeId{node_id};
|
||||||
|
out_port.name = "output";
|
||||||
|
out_port.is_input = false;
|
||||||
|
REQUIRE(result.value->Test_InsertPort(out_port).ok());
|
||||||
|
|
||||||
|
warppipe::PortInfo in_port;
|
||||||
|
in_port.id = warppipe::PortId{in_port_id};
|
||||||
|
in_port.node = warppipe::NodeId{node_id};
|
||||||
|
in_port.name = "input";
|
||||||
|
in_port.is_input = true;
|
||||||
|
REQUIRE(result.value->Test_InsertPort(in_port).ok());
|
||||||
|
|
||||||
|
warppipe::Link link;
|
||||||
|
link.id = warppipe::LinkId{link_id};
|
||||||
|
link.output_port = warppipe::PortId{out_port_id};
|
||||||
|
link.input_port = warppipe::PortId{in_port_id};
|
||||||
|
REQUIRE(result.value->Test_InsertLink(link).ok());
|
||||||
|
|
||||||
|
auto snapshot = result.value->ListNodes();
|
||||||
|
REQUIRE(snapshot.ok());
|
||||||
|
REQUIRE(ContainsNode(snapshot.value, node_id));
|
||||||
|
|
||||||
|
REQUIRE(result.value->Test_RemoveGlobal(node_id).ok());
|
||||||
|
|
||||||
|
auto ports = result.value->ListPorts(warppipe::NodeId{node_id});
|
||||||
|
REQUIRE(ports.ok());
|
||||||
|
REQUIRE(ports.value.empty());
|
||||||
|
|
||||||
|
auto links = result.value->ListLinks();
|
||||||
|
REQUIRE(links.ok());
|
||||||
|
REQUIRE_FALSE(ContainsLink(links.value, link_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("autoconnect reconnects after forced disconnect") {
|
||||||
|
auto result = warppipe::Client::Create(DefaultOptions());
|
||||||
|
if (!result.ok()) {
|
||||||
|
SUCCEED("PipeWire unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto nodes = result.value->ListNodes();
|
||||||
|
REQUIRE(nodes.ok());
|
||||||
|
|
||||||
|
REQUIRE(result.value->Test_ForceDisconnect().ok());
|
||||||
|
|
||||||
|
auto nodes_after = result.value->ListNodes();
|
||||||
|
REQUIRE(nodes_after.ok());
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue