From 65db5daa7cc97d85dcd53c9b8ff805ecedb9e5fd Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:21:39 +0200 Subject: [PATCH] feat: built-in volume management - Volume slider on every node (green bar, draggable) - Mute toggle button (M/m) on every node - Backend: read volume/mute from PipeWire node props - Backend: POST /api/volume {node_id, volume} to set volume - Backend: POST /api/mute {node_id, mute} to toggle mute - Graph JSON includes volume and mute fields per node - Slider supports drag-to-adjust with mouse --- frontend/src/components/GraphCanvas.svelte | 45 +++++++++++- frontend/src/lib/stores.ts | 25 +++++++ frontend/src/lib/types.ts | 2 + src/graph_engine.cpp | 85 +++++++++++++++++++++- src/graph_engine.h | 4 + src/graph_types.h | 7 +- src/web_server.cpp | 43 +++++++++++ 7 files changed, 206 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index a6bfdf7..86387a3 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -11,6 +11,7 @@ setActivated, setExclusive, setAutoPin, setAutoDisconnect, saveProfile, loadProfile, deleteProfile, + setNodeVolume, setNodeMute, } from '../lib/stores'; import type { Node, Port, Link } from '../lib/types'; @@ -166,7 +167,7 @@ const headerH = 22; const portH = 16; const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1); - const height = headerH + maxPorts * portH + 4; + const height = headerH + maxPorts * portH + 20; // extra 20 for volume slider const width = 220; const portPositions = new Map(); @@ -476,6 +477,48 @@ {/if} {/each} + + + + setNodeMute(nd.id, !nd.mute)} + /> + {nd.mute ? 'M' : 'm'} + + + + + + { + e.stopPropagation(); + const svgRect = svgEl?.getBoundingClientRect(); + if (!svgRect) return; + const vbx = nd.x + 8; + const vbw = nd.width - 36; + const onMove = (ev: MouseEvent) => { + const ratio = (ev.clientX - svgRect.left - (vbx - viewBox.x) * svgRect.width / viewBox.w) / (vbw * svgRect.width / viewBox.w); + setNodeVolume(nd.id, Math.max(0, Math.min(1.5, ratio))); + }; + const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }} + /> + + + {Math.round(nd.volume * 100)}% + + {/each} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 73e2fbf..2d7f54e 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -358,4 +358,29 @@ export function deleteProfile(name: string) { savePatchbayState(); } +// Volume control +export async function setNodeVolume(nodeId: number, volume: number) { + try { + await fetch('/api/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ node_id: nodeId, volume }), + }); + } catch (e) { + console.error('[api] volume failed:', e); + } +} + +export async function setNodeMute(nodeId: number, mute: boolean) { + try { + await fetch('/api/mute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ node_id: nodeId, mute }), + }); + } catch (e) { + console.error('[api] mute failed:', e); + } +} + export { connectPorts, disconnectPorts }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 454a3c9..c9d337a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -16,6 +16,8 @@ export interface Node { media_name: string; mode: 'input' | 'output' | 'duplex' | 'none'; node_type: string; + volume: number; + mute: boolean; port_ids: number[]; } diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index 20b64f6..74f6cac 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -3,6 +3,10 @@ #include #include #include +#include +#include +#include +#include #include #include @@ -65,16 +69,30 @@ static void on_node_info(void *data, const struct pw_node_info *info) { info = pw_node_info_update((struct pw_node_info*)obj->info, info); obj->info = (void*)info; + auto *nobj = static_cast(obj); + if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) { - auto *nobj = static_cast(obj); if (info->props) { const char *media_name = spa_dict_lookup(info->props, PW_KEY_MEDIA_NAME); if (media_name && strlen(media_name) > 0) nobj->node.media_name = media_name; + + // Read volume from props + const char *vol_str = spa_dict_lookup(info->props, "volume"); + if (vol_str) { + nobj->node.volume = pw_properties_parse_float(vol_str); + } + + // Read mute from props + const char *mute_str = spa_dict_lookup(info->props, "mute"); + if (mute_str) { + nobj->node.mute = pw_properties_parse_bool(mute_str); + } } - nobj->node.changed = true; - nobj->node.ready = true; } + + nobj->node.changed = true; + nobj->node.ready = true; } static const struct pw_node_events node_events = { @@ -770,4 +788,65 @@ bool GraphEngine::disconnectPorts(uint32_t output_port_id, uint32_t input_port_i return found; } +// ============================================================================ +// Volume control +// ============================================================================ + +bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) { + if (!m_pw.loop) return false; + + pw_thread_loop_lock(m_pw.loop); + + NodeObj *nobj = findNode(node_id); + if (!nobj || !nobj->proxy) { + pw_thread_loop_unlock(m_pw.loop); + return false; + } + + // Build Props param with volume + uint8_t buf[1024]; + struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + + struct spa_pod *param = (struct spa_pod*)spa_pod_builder_add_object(&b, + SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_volume, SPA_POD_Float(volume)); + + pw_node_set_param((pw_node*)nobj->proxy, + SPA_PARAM_Props, 0, param); + + nobj->node.volume = volume; + nobj->node.changed = true; + + pw_thread_loop_unlock(m_pw.loop); + return true; +} + +bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) { + if (!m_pw.loop) return false; + + pw_thread_loop_lock(m_pw.loop); + + NodeObj *nobj = findNode(node_id); + if (!nobj || !nobj->proxy) { + pw_thread_loop_unlock(m_pw.loop); + return false; + } + + uint8_t buf[1024]; + struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + + struct spa_pod *param = (struct spa_pod*)spa_pod_builder_add_object(&b, + SPA_TYPE_OBJECT_Props, SPA_PARAM_Props, + SPA_PROP_mute, SPA_POD_Bool(mute)); + + pw_node_set_param((pw_node*)nobj->proxy, + SPA_PARAM_Props, 0, param); + + nobj->node.mute = mute; + nobj->node.changed = true; + + pw_thread_loop_unlock(m_pw.loop); + return true; +} + // end of graph_engine.cpp diff --git a/src/graph_engine.h b/src/graph_engine.h index c5ae46e..30d009b 100644 --- a/src/graph_engine.h +++ b/src/graph_engine.h @@ -44,6 +44,10 @@ public: bool connectPorts(uint32_t output_port_id, uint32_t input_port_id); bool disconnectPorts(uint32_t output_port_id, uint32_t input_port_id); + // Volume control + bool setNodeVolume(uint32_t node_id, float volume); + bool setNodeMute(uint32_t node_id, bool mute); + // PipeWire internal data (exposed for C callbacks) struct PwData { pw_thread_loop *loop; diff --git a/src/graph_types.h b/src/graph_types.h index 42fe24f..cc27d25 100644 --- a/src/graph_types.h +++ b/src/graph_types.h @@ -63,10 +63,15 @@ struct Node { bool ready; bool changed; + // Volume + float volume; // 0.0 - 1.0 (linear) + bool mute; + std::vector port_ids; // child port IDs Node() : id(0), mode(PortMode::None), node_type(NodeType::None), - mode2(PortMode::None), ready(false), changed(false) {} + mode2(PortMode::None), ready(false), changed(false), + volume(1.0f), mute(false) {} }; struct Link; diff --git a/src/web_server.cpp b/src/web_server.cpp index 63e2cd2..93dc924 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -109,6 +109,8 @@ std::string WebServer::buildGraphJson() const { << ",\"media_name\":\"" << escapeJson(n.media_name) << "\"" << ",\"mode\":\"" << portModeStr(n.mode) << "\"" << ",\"node_type\":\"" << nodeTypeStr(n.node_type) << "\"" + << ",\"volume\":" << n.volume + << ",\"mute\":" << (n.mute ? "true" : "false") << ",\"port_ids\":["; bool first_p = true; for (uint32_t pid : n.port_ids) { @@ -403,6 +405,47 @@ void WebServer::setupRoutes() { res.set_content(buf, "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); + + // Volume: POST /api/volume {"node_id": N, "volume": 0.0-1.0} + m_http.Post("/api/volume", [this](const httplib::Request &req, httplib::Response &res) { + uint32_t node_id = 0; + float volume = 0; + if (sscanf(req.body.c_str(), + "{\"node_id\":%u,\"volume\":%f}", &node_id, &volume) == 2 || + sscanf(req.body.c_str(), + "{\"node_id\":%u, \"volume\":%f}", &node_id, &volume) == 2) + { + if (volume < 0) volume = 0; + if (volume > 1.5) volume = 1.5; + bool ok = m_engine.setNodeVolume(node_id, volume); + 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", "*"); + }); + + // Mute: POST /api/mute {"node_id": N, "mute": true/false} + m_http.Post("/api/mute", [this](const httplib::Request &req, httplib::Response &res) { + uint32_t node_id = 0; + if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 || + sscanf(req.body.c_str(), "{\"node_id\": %u", &node_id) == 1) + { + bool mute = req.body.find("true") != std::string::npos; + bool ok = m_engine.setNodeMute(node_id, mute); + 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", "*"); + }); + + m_http.Options("/api/volume", cors_handler); + m_http.Options("/api/mute", cors_handler); } // end of web_server.cpp