feat: create virtual devices (+ Add Device dropdown)

Backend:
- POST /api/create-null-sink {name} - loads null-sink module
- POST /api/create-loopback {name} - loads loopback module
- POST /api/unload-module {module_id} - unloads a module
- Fixed double-proxy-destroy crash in GraphEngine
- Graceful failure when module not available (no crash)

Frontend:
- + Add Device button in toolbar with dropdown menu
- Null Sink option (creates virtual audio output)
- Loopback Device option (creates paired input+output)
- Dropdown closes on outside click

Note: null-sink requires libpipewire-module-null-sink to be installed.
Loopback works on all PipeWire installations.
This commit is contained in:
joren
2026-03-29 23:50:01 +02:00
parent bda57d9680
commit 2879469d13
5 changed files with 207 additions and 6 deletions

View File

@@ -1,6 +1,8 @@
#include "graph_engine.h"
#include <pipewire/pipewire.h>
#include <pipewire/impl-module.h>
#include <pipewire/global.h>
#include <spa/utils/result.h>
#include <spa/utils/list.h>
#include <spa/param/props.h>
@@ -45,12 +47,19 @@ static void remove_pending(GraphEngine::Object *obj) {
// ============================================================================
static void destroy_proxy(GraphEngine::Object *obj) {
if (obj->proxy) {
pw_proxy_destroy((pw_proxy*)obj->proxy);
pw_proxy *proxy = (pw_proxy*)obj->proxy;
if (proxy) {
obj->proxy = nullptr;
pw_proxy_destroy(proxy);
}
if (obj->object_listener.link.next) {
spa_hook_remove(&obj->object_listener);
spa_zero(obj->object_listener);
}
if (obj->proxy_listener.link.next) {
spa_hook_remove(&obj->proxy_listener);
spa_zero(obj->proxy_listener);
}
spa_hook_remove(&obj->object_listener);
spa_hook_remove(&obj->proxy_listener);
remove_pending(obj);
if (obj->info && obj->destroy_info) {
obj->destroy_info(obj->info);
@@ -853,4 +862,40 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
return true;
}
// ============================================================================
// Module loading (virtual devices)
// ============================================================================
uint32_t GraphEngine::loadModule(const char *name, const char *args) {
if (!m_pw.loop || !m_pw.context) return 0;
pw_thread_loop_lock(m_pw.loop);
struct pw_impl_module *mod = pw_context_load_module(m_pw.context, name, args, nullptr);
uint32_t id = 0;
if (!mod) {
fprintf(stderr, "pwweb: failed to load module %s\n", name);
} else {
struct pw_global *global = pw_impl_module_get_global(mod);
if (global) {
id = pw_global_get_id(global);
}
fprintf(stderr, "pwweb: loaded module %s (id=%u)\n", name, id);
}
pw_thread_loop_unlock(m_pw.loop);
return id;
}
bool GraphEngine::unloadModule(uint32_t module_id) {
if (!m_pw.loop || !m_pw.registry) return false;
pw_thread_loop_lock(m_pw.loop);
pw_registry_destroy(m_pw.registry, module_id);
pw_thread_loop_unlock(m_pw.loop);
fprintf(stderr, "pwweb: unloaded module id=%u\n", module_id);
return true;
}
// end of graph_engine.cpp

View File

@@ -48,6 +48,10 @@ public:
bool setNodeVolume(uint32_t node_id, float volume);
bool setNodeMute(uint32_t node_id, bool mute);
// Module loading (virtual devices)
uint32_t loadModule(const char *name, const char *args);
bool unloadModule(uint32_t module_id);
// PipeWire internal data (exposed for C callbacks)
struct PwData {
pw_thread_loop *loop;

View File

@@ -446,6 +446,88 @@ void WebServer::setupRoutes() {
m_http.Options("/api/volume", cors_handler);
m_http.Options("/api/mute", cors_handler);
m_http.Options("/api/create-null-sink", cors_handler);
m_http.Options("/api/create-loopback", cors_handler);
m_http.Options("/api/unload-module", cors_handler);
// Create null sink: POST /api/create-null-sink {"name":"My Sink"}
m_http.Post("/api/create-null-sink", [this](const httplib::Request &req, httplib::Response &res) {
// Parse name from JSON - simple extraction
std::string name = "pwweb-null";
size_t pos = req.body.find("\"name\"");
if (pos != std::string::npos) {
size_t start = req.body.find('"', pos + 6);
if (start != std::string::npos) {
start++;
size_t end = req.body.find('"', start);
if (end != std::string::npos) {
name = req.body.substr(start, end - start);
}
}
}
std::string args = "node.name=" + name +
" media.class=Audio/Sink " +
"audio.position=[FL FR]";
uint32_t id = m_engine.loadModule(
"libpipewire-module-null-sink",
args.c_str());
if (id > 0) broadcastGraph();
char buf[128];
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%u}",
id > 0 ? "true" : "false", id);
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Create loopback: POST /api/create-loopback {"name":"My Loopback"}
m_http.Post("/api/create-loopback", [this](const httplib::Request &req, httplib::Response &res) {
std::string name = "pwweb-loopback";
size_t pos = req.body.find("\"name\"");
if (pos != std::string::npos) {
size_t start = req.body.find('"', pos + 6);
if (start != std::string::npos) {
start++;
size_t end = req.body.find('"', start);
if (end != std::string::npos) {
name = req.body.substr(start, end - start);
}
}
}
std::string args = "node.name=" + name +
" capture.props=node.name=" + name + "-capture" +
" playback.props=node.name=" + name + "-playback";
uint32_t id = m_engine.loadModule(
"libpipewire-module-loopback",
args.c_str());
if (id > 0) broadcastGraph();
char buf[128];
snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%u}",
id > 0 ? "true" : "false", id);
res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Unload module: POST /api/unload-module {"module_id":N}
m_http.Post("/api/unload-module", [this](const httplib::Request &req, httplib::Response &res) {
uint32_t module_id = 0;
if (sscanf(req.body.c_str(), "{\"module_id\":%u}", &module_id) == 1) {
bool ok = m_engine.unloadModule(module_id);
if (ok) broadcastGraph();
res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json");
} else {
res.status = 400;
res.set_content("{\"error\":\"invalid json\"}", "application/json");
}
res.set_header("Access-Control-Allow-Origin", "*");
});
}
// end of web_server.cpp