revert: remove level meters and VU meter feature

Reverts:
- fix: move VU meter above volume controls to stop blocking mute button
- Merge feature/graph-ux-meters (region fix, toggle, segmented VU meters)
- Merge feature/level-meters (pw_stream peak metering, /api/peaks)

VU meters / level meters don't belong in a patchbay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-04-02 23:23:06 +02:00
parent 6fe6d05aad
commit 8b7ad6e9a8
5 changed files with 7 additions and 250 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
nodes, ports, links, connected, patchbay, peaks,
nodes, ports, links, connected, patchbay,
portById,
initGraph, destroyGraph,
connectPorts, disconnectPorts,
@@ -139,22 +139,6 @@
return $patchbay.aliases?.[nd.name] || nd.nick || nd.name;
}
// Graph visibility toggle
let showGraph = $state(true);
// Level meter: returns how many of `total` segments should be lit
function meterSegs(nodeId: number, total: number): number {
const p = $peaks[nodeId] ?? 0;
if (p <= 0) return 0;
const db = Math.max(-60, 20 * Math.log10(p));
return Math.round(Math.max(0, (db + 60) / 60) * total);
}
// Segment color by position (0 = leftmost/quietest)
function segColor(i: number, total: number): string {
const pct = i / total;
return pct >= 0.90 ? '#c44' : pct >= 0.75 ? '#ca4' : '#4a9';
}
// Build computed layout
let graphNodes = $derived.by(() => {
const n = $nodes;
@@ -210,7 +194,7 @@
const headerH = 22;
const portH = 16;
const maxPorts = Math.max(allInPorts.length, allOutPorts.length, 1);
const height = headerH + maxPorts * portH + 36; // 20 for volume slider + 16 for VU meter
const height = headerH + maxPorts * portH + 20; // extra 20 for volume slider
const width = 220;
const portPositions = new Map<number, { x: number; y: number }>();
@@ -510,8 +494,6 @@
</label>
<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="sep"></span>
<button class="toggle" class:active={showGraph} onclick={() => showGraph = !showGraph} title="Show/hide graph canvas">Graph</button>
</div>
<!-- Virtual device dropdown -->
@@ -556,7 +538,6 @@
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if showGraph}
<svg
bind:this={svgEl}
viewBox="{viewBox.x} {viewBox.y} {viewBox.w} {viewBox.h}"
@@ -696,22 +677,9 @@
{Math.round(Math.max(0, Math.min(1, nd.volume)) * 100)}%
</text>
<!-- VU meter: 20 segmented LEDs (above the volume/mute controls) -->
{#each Array(20) as _, i}
<rect
x={nd.x + 8 + i * ((nd.width - 16) / 20)}
y={nd.y + nd.height - 35}
width={(nd.width - 16) / 20 - 1.5}
height="10"
rx="1"
fill={i < meterSegs(nd.id, 20) ? segColor(i, 20) : '#1e1e28'}
/>
{/each}
</g>
{/each}
</svg>
{/if}
<!-- Context menu -->
{#if contextMenu}
@@ -923,11 +891,11 @@
</div>
<style>
.wrap { width: 100%; height: 100vh; background: #14141e; display: flex; flex-direction: column; position: relative; overflow: hidden; user-select: none; -webkit-user-select: none; }
.canvas { flex: 1; min-height: 0; width: 100%; display: block; cursor: default; z-index: 1; user-select: none; -webkit-user-select: none; }
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; user-select: none; -webkit-user-select: none; }
.canvas { width: 100%; height: 100%; display: block; cursor: default; position: absolute; top: 0; left: 0; z-index: 1; user-select: none; -webkit-user-select: none; }
.toolbar {
position: relative; flex-shrink: 0; z-index: 10;
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; gap: 6px;
padding: 4px 10px;
background: rgba(16, 16, 24, 0.97);

View File

@@ -8,21 +8,6 @@ export const ports = writable<Port[]>([]);
export const links = writable<Link[]>([]);
export const connected = writable(false);
// Level meter peaks: node_id → linear peak (01)
export const peaks = writable<Record<number, number>>({});
let peakInterval: ReturnType<typeof setInterval> | null = null;
async function pollPeaks() {
try {
const res = await fetch('/api/peaks');
if (!res.ok) return;
const raw: Record<string, number> = await res.json();
const out: Record<number, number> = {};
for (const [k, v] of Object.entries(raw)) out[Number(k)] = v;
peaks.set(out);
} catch {}
}
// Patchbay state
export const patchbay = writable<PatchbayState>({
profiles: {},
@@ -117,8 +102,6 @@ export async function initGraph() {
unsubscribe = subscribe((graph: GraphMessage) => {
applyGraph(graph);
});
peakInterval = setInterval(pollPeaks, 100);
}
export function destroyGraph() {
@@ -127,11 +110,6 @@ export function destroyGraph() {
unsubscribe = null;
connected.set(false);
}
if (peakInterval) {
clearInterval(peakInterval);
peakInterval = null;
}
peaks.set({});
}
// Patchbay operations

View File

@@ -27,138 +27,6 @@
using namespace pwgraph;
// ============================================================================
// Level meter: pw_stream per audio node, RT process callback stores peak
// ============================================================================
GraphEngine::MeterStream::~MeterStream() {
if (stream) {
pw_stream_destroy(stream);
stream = nullptr;
}
}
static void on_meter_process(void *data) {
auto *ms = static_cast<GraphEngine::MeterStream*>(data);
struct pw_buffer *buf = pw_stream_dequeue_buffer(ms->stream);
if (!buf) return;
float peak = 0.0f;
struct spa_buffer *sbuf = buf->buffer;
for (uint32_t d = 0; d < sbuf->n_datas; d++) {
if (!sbuf->datas[d].data) continue;
const float *s = static_cast<const float*>(sbuf->datas[d].data);
uint32_t n = sbuf->datas[d].chunk->size / sizeof(float);
for (uint32_t i = 0; i < n; i++) {
float v = s[i] < 0 ? -s[i] : s[i];
if (v > peak) peak = v;
}
}
ms->peak.store(peak, std::memory_order_relaxed);
pw_stream_queue_buffer(ms->stream, buf);
}
static const struct pw_stream_events meter_stream_events = {
.version = PW_VERSION_STREAM_EVENTS,
.process = on_meter_process,
};
void GraphEngine::createMeterIfNeeded(uint32_t node_id, PortMode mode,
NodeType ntype, const std::string& name)
{
// Only meter audio nodes
if ((ntype & NodeType::Audio) == NodeType::None) return;
// Skip nodes with no meaningful direction
if (mode == PortMode::None) return;
{
std::lock_guard<std::mutex> lk(m_meter_mutex);
if (m_meters.count(node_id)) return; // already created
}
// Sinks (speakers/headphones) are metered via their monitor port
bool capture_sink = (mode == PortMode::Input || mode == PortMode::Duplex);
std::string meter_node_name = "pwweb.meter." + std::to_string(node_id);
struct pw_properties *props = pw_properties_new(
PW_KEY_MEDIA_TYPE, "Audio",
PW_KEY_MEDIA_CATEGORY, "Capture",
PW_KEY_MEDIA_ROLE, "DSP",
PW_KEY_APP_NAME, "pwweb-meter",
PW_KEY_NODE_NAME, meter_node_name.c_str(),
PW_KEY_TARGET_OBJECT, std::to_string(node_id).c_str(),
"stream.capture.sink", capture_sink ? "true" : "false",
nullptr);
struct pw_stream *stream = pw_stream_new(m_pw.core, "pwweb-meter", props);
if (!stream) {
fprintf(stderr, "pwweb: meter stream alloc failed for node %u\n", node_id);
return;
}
auto *ms = new MeterStream();
ms->node_id = node_id;
ms->stream = stream;
ms->engine_ref = this;
pw_stream_add_listener(stream, &ms->listener, &meter_stream_events, ms);
uint8_t pod_buf[1024];
struct spa_pod_builder b = SPA_POD_BUILDER_INIT(pod_buf, sizeof(pod_buf));
const struct spa_pod *params[1];
struct spa_audio_info_raw info = {};
info.format = SPA_AUDIO_FORMAT_F32;
info.rate = 48000;
info.channels = 2;
params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &info);
int ret = pw_stream_connect(stream,
PW_DIRECTION_INPUT,
SPA_ID_INVALID,
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT |
PW_STREAM_FLAG_MAP_BUFFERS),
params, 1);
if (ret < 0) {
fprintf(stderr, "pwweb: meter connect failed node %u (%s): %s\n",
node_id, name.c_str(), spa_strerror(ret));
delete ms;
return;
}
{
std::lock_guard<std::mutex> lk(m_meter_mutex);
m_meters[node_id] = ms;
}
fprintf(stderr, "pwweb: meter created for node %u (%s%s)\n",
node_id, name.c_str(), capture_sink ? ", monitor" : "");
}
void GraphEngine::destroyMeter(uint32_t node_id) {
std::lock_guard<std::mutex> lk(m_meter_mutex);
auto it = m_meters.find(node_id);
if (it == m_meters.end()) return;
delete it->second;
m_meters.erase(it);
}
void GraphEngine::clearMeters() {
std::lock_guard<std::mutex> lk(m_meter_mutex);
for (auto &[id, ms] : m_meters)
delete ms;
m_meters.clear();
}
std::unordered_map<uint32_t, float> GraphEngine::getPeaks() const {
std::lock_guard<std::mutex> lk(m_meter_mutex);
std::unordered_map<uint32_t, float> out;
out.reserve(m_meters.size());
for (auto &[id, ms] : m_meters)
out[id] = ms->peak.load(std::memory_order_relaxed);
return out;
}
// ============================================================================
// Pending/sync helpers (ported from qpwgraph_pipewire.cpp)
// ============================================================================
@@ -295,11 +163,6 @@ static void on_node_info(void *data, const struct pw_node_info *info) {
}
}
// On first ready: spin up a meter stream for this node
if (!nobj->node.ready && obj->engine_ref)
obj->engine_ref->createMeterIfNeeded(
nobj->node.id, nobj->node.mode, nobj->node.node_type, nobj->node.name);
nobj->node.changed = true;
nobj->node.ready = true;
@@ -572,10 +435,6 @@ static void on_registry_global(void *data,
auto *engine = static_cast<GraphEngine*>(data);
if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) {
// Skip our own meter streams (avoid graph clutter and infinite recursion)
const char *app = spa_dict_lookup(props, PW_KEY_APP_NAME);
if (app && strcmp(app, "pwweb-meter") == 0) return;
// Parse node properties (ported from qpwgraph lines 444-489)
const char *str = spa_dict_lookup(props, PW_KEY_NODE_DESCRIPTION);
const char *nick = spa_dict_lookup(props, PW_KEY_NODE_NICK);
@@ -584,6 +443,7 @@ static void on_registry_global(void *data,
if (!str || strlen(str) < 1) str = "node";
std::string node_name;
const char *app = spa_dict_lookup(props, PW_KEY_APP_NAME);
if (app && strlen(app) > 0 && strcmp(app, str) != 0) {
node_name += app;
node_name += '/';
@@ -848,9 +708,6 @@ void GraphEngine::close() {
if (m_pw.loop)
pw_thread_loop_stop(m_pw.loop);
// PW thread is stopped; safe to destroy meter streams now
clearMeters();
if (m_pw.registry) {
spa_hook_remove(&m_pw.registry_listener);
pw_proxy_destroy((pw_proxy*)m_pw.registry);
@@ -939,14 +796,13 @@ void GraphEngine::removeObject(uint32_t id) {
}
}
// If it's a node, remove all its ports and tear down its meter
// If it's a node, remove all its ports
if (obj->type == Object::ObjNode) {
auto *nobj = static_cast<NodeObj*>(obj);
auto port_ids_copy = nobj->node.port_ids;
for (uint32_t pid : port_ids_copy) {
removeObject(pid);
}
destroyMeter(id);
}
// If it's a link, remove from output port's link list

View File

@@ -16,7 +16,6 @@ struct pw_thread_loop;
struct pw_context;
struct pw_core;
struct pw_registry;
struct pw_stream;
namespace pwgraph {
@@ -136,35 +135,6 @@ public:
uint32_t midiPortType() const { return m_midi_type; }
uint32_t videoPortType() const { return m_video_type; }
uint32_t otherPortType() const { return m_other_type; }
// -----------------------------------------------------------------------
// Level metering
// -----------------------------------------------------------------------
struct MeterStream {
uint32_t node_id = 0;
struct pw_stream *stream = nullptr;
struct spa_hook listener = {};
std::atomic<float> peak {0.0f}; // instantaneous linear peak
GraphEngine *engine_ref = nullptr;
MeterStream() = default;
~MeterStream();
MeterStream(const MeterStream&) = delete;
MeterStream& operator=(const MeterStream&) = delete;
};
// Called from on_node_info when a node first becomes ready
void createMeterIfNeeded(uint32_t node_id, PortMode mode, NodeType ntype,
const std::string& name);
void destroyMeter(uint32_t node_id);
void clearMeters();
// Returns linear peak (01) per node_id; called from HTTP thread
std::unordered_map<uint32_t, float> getPeaks() const;
private:
std::unordered_map<uint32_t, MeterStream*> m_meters;
mutable std::mutex m_meter_mutex;
};
} // namespace pwgraph

View File

@@ -302,21 +302,6 @@ void WebServer::setupRoutes() {
res.set_header("Access-Control-Allow-Origin", "*");
});
// Level meters: GET /api/peaks → {"<node_id>": <linear_peak_0_1>, ...}
m_http.Get("/api/peaks", [this](const httplib::Request &, httplib::Response &res) {
auto peaks = m_engine.getPeaks();
std::string json = "{";
bool first = true;
for (auto &[id, peak] : peaks) {
if (!first) json += ",";
json += "\"" + std::to_string(id) + "\":" + std::to_string(peak);
first = false;
}
json += "}";
res.set_content(json, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// REST API: POST /api/connect
m_http.Post("/api/connect", [this](const httplib::Request &req, httplib::Response &res) {
uint32_t out_id = 0, in_id = 0;