From 65db5daa7cc97d85dcd53c9b8ff805ecedb9e5fd Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:21:39 +0200 Subject: [PATCH 1/4] 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 From 044c5e551ce2b796e0196943e1743d70f4726bc1 Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:29:40 +0200 Subject: [PATCH 2/4] fix: volume slider and mute button race conditions - Mute button click no longer triggers node drag (priority check in onMouseDown) - Volume slider clamped to 0-100% in both display and API - Added draggable circle handle on slider end - Volume drag state tracked globally, not per-element - Backend: fixed spa_pod_builder API usage (push_object/pop with frame) - Volume calculated from SVG coordinates properly via svgPoint conversion --- frontend/src/components/GraphCanvas.svelte | 104 +++++++++++++++------ src/graph_engine.cpp | 16 ++-- 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index 86387a3..c57728c 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -214,6 +214,22 @@ return null; } + // Volume drag state + let volumeDragging = $state<{ nodeId: number } | null>(null); + + function applyVolumeAtMouse(e: MouseEvent, nodeId: number) { + const nd = graphNodes.find(n => n.id === nodeId); + if (!nd || !svgEl) return; + const volX = nd.x + 8; + const volW = nd.width - 36; + const svgRect = svgEl.getBoundingClientRect(); + // Convert mouse X to SVG coordinate + const mouseSvgX = viewBox.x + (e.clientX - svgRect.left) * viewBox.w / svgRect.width; + const ratio = (mouseSvgX - volX) / volW; + const clamped = Math.max(0, Math.min(1, ratio)); + setNodeVolume(nodeId, clamped); + } + // Mouse handlers function onMouseDown(e: MouseEvent) { contextMenu = null; @@ -221,6 +237,29 @@ const pt = svgPoint(e); const target = e.target as HTMLElement; + // Volume slider handle - highest priority + if (target.classList.contains('vol-handle') || target.classList.contains('vol-bar') || target.classList.contains('vol-bg')) { + const nodeEl = target.closest('.node-group'); + if (nodeEl) { + const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId); + volumeDragging = { nodeId }; + // Immediately apply volume at click position + applyVolumeAtMouse(e, nodeId); + } + return; + } + + // Mute button + if (target.classList.contains('mute-btn') || target.classList.contains('mute-text')) { + const nodeEl = target.closest('.node-group'); + if (nodeEl) { + const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId); + const nd = graphNodes.find(n => n.id === nodeId); + if (nd) setNodeMute(nodeId, !nd.mute); + } + return; + } + if (target.classList.contains('port-circle')) { const portId = Number(target.dataset.portId); const port = $portById.get(portId); @@ -253,6 +292,11 @@ } function onMouseMove(e: MouseEvent) { + // Volume dragging takes priority + if (volumeDragging) { + applyVolumeAtMouse(e, volumeDragging.nodeId); + return; + } if (!dragging && !connecting) return; const pt = svgPoint(e); if (connecting) { @@ -272,6 +316,10 @@ } function onMouseUp(e: MouseEvent) { + if (volumeDragging) { + volumeDragging = null; + return; + } if (connecting) { const target = e.target as HTMLElement; if (target.classList.contains('port-circle')) { @@ -479,44 +527,30 @@ {/each} - setNodeMute(nd.id, !nd.mute)} + class="mute-btn" + x={nd.x + nd.width - 24} y={nd.y + nd.height - 17} + width="16" height="12" rx="2" + fill={nd.mute ? '#a44' : '#2a2a3e'} + stroke="#555" stroke-width="0.5" /> {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)}% + + {Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}% @@ -758,4 +792,12 @@ .port-circle:hover { filter: brightness(1.5); } .edge-path { cursor: pointer; pointer-events: stroke; } .edge-path:hover { stroke-width: 4; } + + .mute-btn { cursor: pointer; } + .mute-btn:hover { filter: brightness(1.3); } + .mute-text { pointer-events: none; } + .vol-bg { pointer-events: none; } + .vol-bar { pointer-events: none; } + .vol-handle { cursor: ew-resize; } + .vol-handle:hover { filter: brightness(1.3); r: 6; } diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index 74f6cac..8cf1ff4 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -806,10 +806,12 @@ bool GraphEngine::setNodeVolume(uint32_t node_id, float volume) { // Build Props param with volume uint8_t buf[1024]; struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + struct spa_pod_frame f; - 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)); + spa_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props); + spa_pod_builder_prop(&b, SPA_PROP_volume, 0); + spa_pod_builder_float(&b, volume); + struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f); pw_node_set_param((pw_node*)nobj->proxy, SPA_PARAM_Props, 0, param); @@ -834,10 +836,12 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) { uint8_t buf[1024]; struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buf, sizeof(buf)); + struct spa_pod_frame f; - 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)); + spa_pod_builder_push_object(&b, &f, SPA_TYPE_OBJECT_Props, SPA_PARAM_Props); + spa_pod_builder_prop(&b, SPA_PROP_mute, 0); + spa_pod_builder_bool(&b, mute); + struct spa_pod *param = (struct spa_pod*)spa_pod_builder_pop(&b, &f); pw_node_set_param((pw_node*)nobj->proxy, SPA_PARAM_Props, 0, param); From 0b50400f69aa0811c8b4d1001318e375c345066c Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:33:15 +0200 Subject: [PATCH 3/4] fix: smaller volume handle, click-to-jump already works --- frontend/src/components/GraphCanvas.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index c57728c..f8f408f 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -547,7 +547,7 @@ - + {Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}% @@ -799,5 +799,5 @@ .vol-bg { pointer-events: none; } .vol-bar { pointer-events: none; } .vol-handle { cursor: ew-resize; } - .vol-handle:hover { filter: brightness(1.3); r: 6; } + .vol-handle:hover { filter: brightness(1.3); } From 37e78342699399e758dc4b1245e55a63d64d165a Mon Sep 17 00:00:00 2001 From: joren Date: Sun, 29 Mar 2026 23:34:52 +0200 Subject: [PATCH 4/4] fix: click-to-jump on volume slider Added transparent hitarea rect over the full slider area (14px tall). Handle is pointer-events:none so clicks pass through to hitarea. Clicking anywhere on the slider bar now jumps volume there. --- frontend/src/components/GraphCanvas.svelte | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index f8f408f..c09fbc2 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -237,8 +237,8 @@ const pt = svgPoint(e); const target = e.target as HTMLElement; - // Volume slider handle - highest priority - if (target.classList.contains('vol-handle') || target.classList.contains('vol-bar') || target.classList.contains('vol-bg')) { + // Volume slider - high priority + if (target.classList.contains('vol-handle') || target.classList.contains('vol-hitarea')) { const nodeEl = target.closest('.node-group'); if (nodeEl) { const nodeId = Number((nodeEl as HTMLElement).dataset.nodeId); @@ -542,10 +542,12 @@ text-anchor="middle" >{nd.mute ? 'M' : 'm'} - - - - + + + + + + @@ -798,6 +800,7 @@ .mute-text { pointer-events: none; } .vol-bg { pointer-events: none; } .vol-bar { pointer-events: none; } - .vol-handle { cursor: ew-resize; } + .vol-hitarea { cursor: pointer; } + .vol-handle { cursor: ew-resize; pointer-events: none; } .vol-handle:hover { filter: brightness(1.3); }