From 2879469d13dc87bb52ae5bd75f98e342209da1ed Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:50:01 +0200 Subject: [PATCH 1/7] 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. --- frontend/src/components/GraphCanvas.svelte | 31 +++++++- frontend/src/lib/stores.ts | 43 ++++++++++++ src/graph_engine.cpp | 53 ++++++++++++-- src/graph_engine.h | 4 ++ src/web_server.cpp | 82 ++++++++++++++++++++++ 5 files changed, 207 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index c09fbc2..7d33f9d 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -12,6 +12,7 @@ setAutoPin, setAutoDisconnect, saveProfile, loadProfile, deleteProfile, setNodeVolume, setNodeMute, + createNullSink, createLoopback, } from '../lib/stores'; import type { Node, Port, Link } from '../lib/types'; @@ -37,6 +38,7 @@ let showMergeDialog = $state(false); let showProfileDialog = $state(false); let showRuleDialog = $state(false); + let showVirtualMenu = $state(false); let newHideRule = $state(''); let newMergeRule = $state(''); let newProfileName = $state(''); @@ -377,7 +379,7 @@ onDestroy(() => { destroyGraph(); }); - + { contextMenu = null; showVirtualMenu = false; }} />
@@ -412,10 +414,20 @@ + + {$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}
+ + {#if showVirtualMenu} +
+ + +
+ {/if} + diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 2d7f54e..0b5e87d 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -383,4 +383,47 @@ export async function setNodeMute(nodeId: number, mute: boolean) { } } +// Virtual devices +export async function createNullSink(name: string): Promise { + try { + const res = await fetch('/api/create-null-sink', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + return data.module_id || null; + } catch (e) { + console.error('[api] create-null-sink failed:', e); + return null; + } +} + +export async function createLoopback(name: string): Promise { + try { + const res = await fetch('/api/create-loopback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + return data.module_id || null; + } catch (e) { + console.error('[api] create-loopback failed:', e); + return null; + } +} + +export async function unloadModule(moduleId: number) { + try { + await fetch('/api/unload-module', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ module_id: moduleId }), + }); + } catch (e) { + console.error('[api] unload-module failed:', e); + } +} + export { connectPorts, disconnectPorts }; diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index 8cf1ff4..409c7be 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -1,6 +1,8 @@ #include "graph_engine.h" #include +#include +#include #include #include #include @@ -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 diff --git a/src/graph_engine.h b/src/graph_engine.h index 30d009b..6d5d842 100644 --- a/src/graph_engine.h +++ b/src/graph_engine.h @@ -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; diff --git a/src/web_server.cpp b/src/web_server.cpp index 93dc924..f7d8acc 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -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 From 9dc685acdaa56106ea24d0815864b310d87a1cd7 Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:55:19 +0200 Subject: [PATCH 2/7] feat: pactl-based module loading, more device types Backend: - Switched null-sink and loopback to use pactl (works on all systems) - Generic POST /api/load-module {module, args} for any pactl module - Fixed SVG toolbar click issue (z-index layering) Frontend: - + Add Device dropdown now includes: - Null Sink (virtual audio output) - Loopback Device (paired input+output) - TCP Network Server (module-native-protocol-tcp) - TCP Tunnel Sink - TCP Tunnel Source - Dropdown header and section separators - Fixed canvas z-index so toolbar is clickable --- frontend/src/components/GraphCanvas.svelte | 14 ++- frontend/src/lib/stores.ts | 19 ++- src/web_server.cpp | 127 +++++++++++++-------- 3 files changed, 110 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index 7d33f9d..d5ce99c 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -12,7 +12,7 @@ setAutoPin, setAutoDisconnect, saveProfile, loadProfile, deleteProfile, setNodeVolume, setNodeMute, - createNullSink, createLoopback, + createNullSink, createLoopback, loadModule, } from '../lib/stores'; import type { Node, Port, Link } from '../lib/types'; @@ -423,8 +423,13 @@ {#if showVirtualMenu}
+
Add Virtual Device
+
+ + +
{/if} @@ -693,7 +698,7 @@