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:
@@ -12,6 +12,7 @@
|
|||||||
setAutoPin, setAutoDisconnect,
|
setAutoPin, setAutoDisconnect,
|
||||||
saveProfile, loadProfile, deleteProfile,
|
saveProfile, loadProfile, deleteProfile,
|
||||||
setNodeVolume, setNodeMute,
|
setNodeVolume, setNodeMute,
|
||||||
|
createNullSink, createLoopback,
|
||||||
} from '../lib/stores';
|
} from '../lib/stores';
|
||||||
import type { Node, Port, Link } from '../lib/types';
|
import type { Node, Port, Link } from '../lib/types';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
let showMergeDialog = $state(false);
|
let showMergeDialog = $state(false);
|
||||||
let showProfileDialog = $state(false);
|
let showProfileDialog = $state(false);
|
||||||
let showRuleDialog = $state(false);
|
let showRuleDialog = $state(false);
|
||||||
|
let showVirtualMenu = $state(false);
|
||||||
let newHideRule = $state('');
|
let newHideRule = $state('');
|
||||||
let newMergeRule = $state('');
|
let newMergeRule = $state('');
|
||||||
let newProfileName = $state('');
|
let newProfileName = $state('');
|
||||||
@@ -377,7 +379,7 @@
|
|||||||
onDestroy(() => { destroyGraph(); });
|
onDestroy(() => { destroyGraph(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; showVirtualMenu = false; }} />
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -412,10 +414,20 @@
|
|||||||
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
|
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
|
||||||
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
|
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
|
||||||
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
|
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
|
||||||
|
<span class="sep"></span>
|
||||||
|
<button class="add-btn" onclick={() => { showVirtualMenu = !showVirtualMenu; }} title="Add virtual device">+ Add Device</button>
|
||||||
|
|
||||||
<span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
|
<span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Virtual device dropdown -->
|
||||||
|
{#if showVirtualMenu}
|
||||||
|
<div class="virt-menu">
|
||||||
|
<button onclick={async () => { showVirtualMenu = false; await createNullSink('pwweb-null-' + Date.now().toString(36)); }}>Null Sink (Virtual Output)</button>
|
||||||
|
<button onclick={async () => { showVirtualMenu = false; await createLoopback('pwweb-loop-' + Date.now().toString(36)); }}>Loopback Device</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<svg
|
<svg
|
||||||
bind:this={svgEl}
|
bind:this={svgEl}
|
||||||
@@ -802,5 +814,20 @@
|
|||||||
.vol-bar { pointer-events: none; }
|
.vol-bar { pointer-events: none; }
|
||||||
.vol-hitarea { cursor: pointer; }
|
.vol-hitarea { cursor: pointer; }
|
||||||
.vol-handle { cursor: ew-resize; pointer-events: none; }
|
.vol-handle { cursor: ew-resize; pointer-events: none; }
|
||||||
.vol-handle:hover { filter: brightness(1.3); }
|
.vol-handle:hover { filter: brightness(1.3); } .add-btn { background: #1a3a2a !important; border-color: #4a9 !important; color: #6c9 !important; }
|
||||||
|
|
||||||
|
.virt-menu {
|
||||||
|
position: absolute; top: 32px; z-index: 20;
|
||||||
|
left: 50%; transform: translateX(-50%);
|
||||||
|
background: #2a2a3e; border: 1px solid #555;
|
||||||
|
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.virt-menu button {
|
||||||
|
display: block; width: 100%; padding: 6px 16px;
|
||||||
|
background: none; border: none; color: #ccc;
|
||||||
|
font-size: 11px; cursor: pointer; text-align: left; font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.virt-menu button:hover { background: #444; }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -383,4 +383,47 @@ export async function setNodeMute(nodeId: number, mute: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virtual devices
|
||||||
|
export async function createNullSink(name: string): Promise<number | null> {
|
||||||
|
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<number | null> {
|
||||||
|
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 };
|
export { connectPorts, disconnectPorts };
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "graph_engine.h"
|
#include "graph_engine.h"
|
||||||
|
|
||||||
#include <pipewire/pipewire.h>
|
#include <pipewire/pipewire.h>
|
||||||
|
#include <pipewire/impl-module.h>
|
||||||
|
#include <pipewire/global.h>
|
||||||
#include <spa/utils/result.h>
|
#include <spa/utils/result.h>
|
||||||
#include <spa/utils/list.h>
|
#include <spa/utils/list.h>
|
||||||
#include <spa/param/props.h>
|
#include <spa/param/props.h>
|
||||||
@@ -45,12 +47,19 @@ static void remove_pending(GraphEngine::Object *obj) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
static void destroy_proxy(GraphEngine::Object *obj) {
|
static void destroy_proxy(GraphEngine::Object *obj) {
|
||||||
if (obj->proxy) {
|
pw_proxy *proxy = (pw_proxy*)obj->proxy;
|
||||||
pw_proxy_destroy((pw_proxy*)obj->proxy);
|
if (proxy) {
|
||||||
obj->proxy = nullptr;
|
obj->proxy = nullptr;
|
||||||
|
pw_proxy_destroy(proxy);
|
||||||
}
|
}
|
||||||
|
if (obj->object_listener.link.next) {
|
||||||
spa_hook_remove(&obj->object_listener);
|
spa_hook_remove(&obj->object_listener);
|
||||||
|
spa_zero(obj->object_listener);
|
||||||
|
}
|
||||||
|
if (obj->proxy_listener.link.next) {
|
||||||
spa_hook_remove(&obj->proxy_listener);
|
spa_hook_remove(&obj->proxy_listener);
|
||||||
|
spa_zero(obj->proxy_listener);
|
||||||
|
}
|
||||||
remove_pending(obj);
|
remove_pending(obj);
|
||||||
if (obj->info && obj->destroy_info) {
|
if (obj->info && obj->destroy_info) {
|
||||||
obj->destroy_info(obj->info);
|
obj->destroy_info(obj->info);
|
||||||
@@ -853,4 +862,40 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) {
|
|||||||
return true;
|
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
|
// end of graph_engine.cpp
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ public:
|
|||||||
bool setNodeVolume(uint32_t node_id, float volume);
|
bool setNodeVolume(uint32_t node_id, float volume);
|
||||||
bool setNodeMute(uint32_t node_id, bool mute);
|
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)
|
// PipeWire internal data (exposed for C callbacks)
|
||||||
struct PwData {
|
struct PwData {
|
||||||
pw_thread_loop *loop;
|
pw_thread_loop *loop;
|
||||||
|
|||||||
@@ -446,6 +446,88 @@ void WebServer::setupRoutes() {
|
|||||||
|
|
||||||
m_http.Options("/api/volume", cors_handler);
|
m_http.Options("/api/volume", cors_handler);
|
||||||
m_http.Options("/api/mute", 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
|
// end of web_server.cpp
|
||||||
|
|||||||
Reference in New Issue
Block a user