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:
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
|
||||||
|
|
||||||
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
|
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
|
||||||
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
|
|
||||||
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.ready = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nobj->node.changed = 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user