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
This commit is contained in:
joren
2026-03-29 23:21:39 +02:00
parent 8c6a1f44c1
commit 65db5daa7c
7 changed files with 206 additions and 5 deletions

View File

@@ -11,6 +11,7 @@
setActivated, setExclusive, setActivated, setExclusive,
setAutoPin, setAutoDisconnect, setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile, saveProfile, loadProfile, deleteProfile,
setNodeVolume, setNodeMute,
} from '../lib/stores'; } from '../lib/stores';
import type { Node, Port, Link } from '../lib/types'; import type { Node, Port, Link } from '../lib/types';
@@ -166,7 +167,7 @@
const headerH = 22; const headerH = 22;
const portH = 16; const portH = 16;
const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1); 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 width = 220;
const portPositions = new Map<number, { x: number; y: number }>(); const portPositions = new Map<number, { x: number; y: number }>();
@@ -476,6 +477,48 @@
<circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" /> <circle cx={nd.x + nd.width} cy={py} r="3" fill="#333" />
{/if} {/if}
{/each} {/each}
<!-- Volume slider + mute button at bottom of node -->
<!-- Mute button -->
<rect
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"
style="cursor:pointer"
onclick={() => setNodeMute(nd.id, !nd.mute)}
/>
<text
x={nd.x + nd.width - 16} y={nd.y + nd.height - 8}
font-size="8" font-family="monospace" fill={nd.mute ? '#fff' : '#888'}
text-anchor="middle" style="pointer-events:none"
>{nd.mute ? 'M' : 'm'}</text>
<!-- Volume bar background -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={nd.width - 36} height="3" rx="1.5" fill="#333" />
<!-- Volume bar fill -->
<rect x={nd.x + 8} y={nd.y + nd.height - 12} width={(nd.width - 36) * Math.min(nd.volume, 1)} height="3" rx="1.5" fill={nd.mute ? '#666' : '#4a9'} />
<!-- Volume slider hit area -->
<rect
x={nd.x + 8} y={nd.y + nd.height - 16} width={nd.width - 36} height="11"
fill="transparent" style="cursor:pointer"
onmousedown={(e) => {
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);
}}
/>
<!-- Volume % label -->
<text x={nd.x + 8} y={nd.y + nd.height - 14} font-size="7" font-family="monospace" fill="#666">
{Math.round(nd.volume * 100)}%
</text>
</g> </g>
{/each} {/each}
</svg> </svg>

View File

@@ -358,4 +358,29 @@ export function deleteProfile(name: string) {
savePatchbayState(); 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 }; export { connectPorts, disconnectPorts };

View File

@@ -16,6 +16,8 @@ export interface Node {
media_name: string; media_name: string;
mode: 'input' | 'output' | 'duplex' | 'none'; mode: 'input' | 'output' | 'duplex' | 'none';
node_type: string; node_type: string;
volume: number;
mute: boolean;
port_ids: number[]; port_ids: number[];
} }

View File

@@ -3,6 +3,10 @@
#include <pipewire/pipewire.h> #include <pipewire/pipewire.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/pod/pod.h>
#include <spa/pod/builder.h>
#include <spa/pod/iter.h>
#include <cstring> #include <cstring>
#include <algorithm> #include <algorithm>
@@ -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); info = pw_node_info_update((struct pw_node_info*)obj->info, info);
obj->info = (void*)info; obj->info = (void*)info;
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj); auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
if (info->props) { if (info->props) {
const char *media_name = spa_dict_lookup(info->props, PW_KEY_MEDIA_NAME); const char *media_name = spa_dict_lookup(info->props, PW_KEY_MEDIA_NAME);
if (media_name && strlen(media_name) > 0) if (media_name && strlen(media_name) > 0)
nobj->node.media_name = media_name; 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.changed = true;
nobj->node.ready = true; nobj->node.ready = true;
}
} }
static const struct pw_node_events node_events = { 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; 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 // end of graph_engine.cpp

View File

@@ -44,6 +44,10 @@ public:
bool connectPorts(uint32_t output_port_id, uint32_t input_port_id); bool connectPorts(uint32_t output_port_id, uint32_t input_port_id);
bool disconnectPorts(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) // PipeWire internal data (exposed for C callbacks)
struct PwData { struct PwData {
pw_thread_loop *loop; pw_thread_loop *loop;

View File

@@ -63,10 +63,15 @@ struct Node {
bool ready; bool ready;
bool changed; bool changed;
// Volume
float volume; // 0.0 - 1.0 (linear)
bool mute;
std::vector<uint32_t> port_ids; // child port IDs std::vector<uint32_t> port_ids; // child port IDs
Node() : id(0), mode(PortMode::None), node_type(NodeType::None), 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; struct Link;

View File

@@ -109,6 +109,8 @@ std::string WebServer::buildGraphJson() const {
<< ",\"media_name\":\"" << escapeJson(n.media_name) << "\"" << ",\"media_name\":\"" << escapeJson(n.media_name) << "\""
<< ",\"mode\":\"" << portModeStr(n.mode) << "\"" << ",\"mode\":\"" << portModeStr(n.mode) << "\""
<< ",\"node_type\":\"" << nodeTypeStr(n.node_type) << "\"" << ",\"node_type\":\"" << nodeTypeStr(n.node_type) << "\""
<< ",\"volume\":" << n.volume
<< ",\"mute\":" << (n.mute ? "true" : "false")
<< ",\"port_ids\":["; << ",\"port_ids\":[";
bool first_p = true; bool first_p = true;
for (uint32_t pid : n.port_ids) { for (uint32_t pid : n.port_ids) {
@@ -403,6 +405,47 @@ void WebServer::setupRoutes() {
res.set_content(buf, "application/json"); res.set_content(buf, "application/json");
res.set_header("Access-Control-Allow-Origin", "*"); 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 // end of web_server.cpp