Merge feature/buffer-latency-and-fixes into master

This commit is contained in:
joren
2026-03-30 01:18:39 +02:00
3 changed files with 101 additions and 18 deletions

View File

@@ -13,6 +13,7 @@
saveProfile, loadProfile, deleteProfile, saveProfile, loadProfile, deleteProfile,
setNodeVolume, setNodeMute, setNodeVolume, setNodeMute,
createNullSink, createLoopback, loadModule, createNullSink, createLoopback, loadModule,
getQuantum, setQuantum,
} from '../lib/stores'; } from '../lib/stores';
import type { Node, Port, Link } from '../lib/types'; import type { Node, Port, Link } from '../lib/types';
@@ -41,6 +42,7 @@
let showProfileDialog = $state(false); let showProfileDialog = $state(false);
let showRuleDialog = $state(false); let showRuleDialog = $state(false);
let showVirtualMenu = $state(false); let showVirtualMenu = $state(false);
let splitNodes = $state(false);
let showNetworkDialog = $state<{ type: string } | null>(null); let showNetworkDialog = $state<{ type: string } | null>(null);
let netHost = $state('127.0.0.1'); let netHost = $state('127.0.0.1');
let netPort = $state('4713'); let netPort = $state('4713');
@@ -50,6 +52,7 @@
// Positions // Positions
let nodePositions = $state<Record<string, { x: number; y: number }>>({}); let nodePositions = $state<Record<string, { x: number; y: number }>>({});
let currentQuantum = $state(0);
const POS_KEY = 'pwweb_positions'; const POS_KEY = 'pwweb_positions';
function loadPositions() { function loadPositions() {
@@ -136,20 +139,26 @@
// Filter hidden nodes // Filter hidden nodes
let visible = n.filter(nd => !isNodeHidden(nd.name)); let visible = n.filter(nd => !isNodeHidden(nd.name));
// Merge nodes by prefix // Merge nodes by prefix (unless split mode)
if (!splitNodes) {
const merged = new Map<string, typeof visible[0]>(); const merged = new Map<string, typeof visible[0]>();
for (const nd of visible) { for (const nd of visible) {
const mergedName = getMergedName(nd.name); const mergedName = getMergedName(nd.name);
const existing = merged.get(mergedName); const existing = merged.get(mergedName);
if (existing) { if (existing) {
// Merge port IDs
existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])]; existing.port_ids = [...new Set([...existing.port_ids, ...nd.port_ids])];
existing.name = mergedName; existing.name = mergedName;
const mergedInPorts = existing.port_ids.map(pid => portMap.get(pid)).filter((p): p is Port => !!p && p.mode === 'input');
const mergedOutPorts = existing.port_ids.map(pid => portMap.get(pid)).filter((p): p is Port => !!p && p.mode === 'output');
if (mergedInPorts.length > 0 && mergedOutPorts.length > 0) {
existing.mode = 'duplex';
}
} else { } else {
merged.set(mergedName, { ...nd, name: mergedName }); merged.set(mergedName, { ...nd, name: mergedName });
} }
} }
visible = Array.from(merged.values()); visible = Array.from(merged.values());
}
const out = visible.filter(nd => nd.mode === 'output'); const out = visible.filter(nd => nd.mode === 'output');
const inp = visible.filter(nd => nd.mode === 'input'); const inp = visible.filter(nd => nd.mode === 'input');
@@ -420,7 +429,7 @@
} }
} }
onMount(() => { initGraph(); loadPositions(); }); onMount(() => { initGraph(); loadPositions(); getQuantum().then(q => { currentQuantum = q; }); });
onDestroy(() => { destroyGraph(); }); onDestroy(() => { destroyGraph(); });
</script> </script>
@@ -457,9 +466,19 @@
<!-- Dialogs --> <!-- Dialogs -->
<button onclick={() => { showHideDialog = !showHideDialog; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node hiding rules">Hide Nodes</button> <button onclick={() => { showHideDialog = !showHideDialog; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node hiding rules">Hide Nodes</button>
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button> <button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
<button class="toggle" class:active={splitNodes} onclick={() => { splitNodes = !splitNodes; }} title="Show input/output as separate nodes">Split</button>
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button> <button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button> <button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button> <button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>
<span class="sep"></span>
<label class="quantum-label">Buffer:
<select class="quantum-select" onchange={(e) => { const q = Number((e.target as HTMLSelectElement).value); if (q > 0) { currentQuantum = q; setQuantum(q); } }}>
<option value="0" selected={currentQuantum === 0}>default</option>
{#each [32, 64, 128, 256, 512, 1024, 2048, 4096] as q}
<option value={q} selected={currentQuantum === q}>{q}</option>
{/each}
</select>
</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="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
</div> </div>
@@ -562,9 +581,10 @@
{#each graphNodes as nd (nd.id)} {#each graphNodes as nd (nd.id)}
{@const isSource = nd.mode === 'output'} {@const isSource = nd.mode === 'output'}
{@const isSink = nd.mode === 'input'} {@const isSink = nd.mode === 'input'}
{@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : '#1e1e2e'} {@const isDuplex = nd.mode === 'duplex'}
{@const border = isSource ? '#4a9' : isSink ? '#a44' : '#555'} {@const bg = isSource ? '#1a2a1a' : isSink ? '#2a1a1a' : isDuplex ? '#1a1a2a' : '#1e1e2e'}
{@const headerBg = isSource ? '#263826' : isSink ? '#382626' : '#262638'} {@const border = isSource ? '#4a9' : isSink ? '#a44' : isDuplex ? '#49a' : '#555'}
{@const headerBg = isSource ? '#263826' : isSink ? '#382626' : isDuplex ? '#262638' : '#262638'}
<g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)"> <g class="node-group" data-node-id={String(nd.id)} filter="url(#shadow)">
<rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" /> <rect x={nd.x} y={nd.y} width={nd.width} height={nd.height} rx="4" fill={bg} stroke={border} stroke-width="1" />
@@ -821,8 +841,8 @@
</div> </div>
<style> <style>
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; } .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; } .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 { .toolbar {
position: absolute; top: 0; left: 0; right: 0; z-index: 10; position: absolute; top: 0; left: 0; right: 0; z-index: 10;
@@ -859,6 +879,12 @@
.toolbar button:hover, .toggle:hover { background: #3a3a4e; } .toolbar button:hover, .toggle:hover { background: #3a3a4e; }
.toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; } .toggle.active { background: #1a3a2a; border-color: #4a9; color: #6c9; }
.quantum-label { font-size: 10px; color: #888; display: flex; align-items: center; gap: 4px; }
.quantum-select {
padding: 1px 4px; background: #1a1a2e; border: 1px solid #444;
color: #aaa; font-size: 10px; border-radius: 3px; font-family: monospace;
cursor: pointer;
}
.stats { margin-left: auto; color: #555; } .stats { margin-left: auto; color: #555; }
/* Context menu */ /* Context menu */

View File

@@ -441,4 +441,25 @@ export async function unloadModule(moduleId: number) {
} }
} }
// Quantum (buffer size) control
export async function getQuantum(): Promise<number> {
try {
const res = await fetch('/api/quantum');
const data = await res.json();
return data.quantum || 0;
} catch { return 0; }
}
export async function setQuantum(quantum: number) {
try {
await fetch('/api/quantum', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantum }),
});
} catch (e) {
console.error('[api] set-quantum failed:', e);
}
}
export { connectPorts, disconnectPorts }; export { connectPorts, disconnectPorts };

View File

@@ -603,6 +603,42 @@ void WebServer::setupRoutes() {
} }
res.set_header("Access-Control-Allow-Origin", "*"); res.set_header("Access-Control-Allow-Origin", "*");
}); });
m_http.Options("/api/quantum", cors_handler);
// Get current quantum: GET /api/quantum
m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) {
int quantum = 0;
FILE *fp = popen("pw-metadata 0 default.clock.quantum 2>/dev/null | grep -oP \"value:'\\K[0-9]+\"", "r");
if (fp) {
char buf[32] = {};
if (fgets(buf, sizeof(buf), fp))
quantum = atoi(buf);
pclose(fp);
}
char out[64];
snprintf(out, sizeof(out), "{\"quantum\":%d}", quantum);
res.set_content(out, "application/json");
res.set_header("Access-Control-Allow-Origin", "*");
});
// Set quantum: POST /api/quantum {"quantum":256}
m_http.Post("/api/quantum", [](const httplib::Request &req, httplib::Response &res) {
int quantum = 0;
if (sscanf(req.body.c_str(), "{\"quantum\":%d}", &quantum) == 1 && quantum > 0) {
char cmd[128];
snprintf(cmd, sizeof(cmd), "pw-metadata 0 default.clock.quantum %d 2>/dev/null", quantum);
int ret = system(cmd);
(void)ret;
char out[64];
snprintf(out, sizeof(out), "{\"ok\":true,\"quantum\":%d}", quantum);
res.set_content(out, "application/json");
} else {
res.status = 400;
res.set_content("{\"error\":\"invalid json\"}", "application/json");
}
res.set_header("Access-Control-Allow-Origin", "*");
});
} }
// end of web_server.cpp // end of web_server.cpp