setNodeVolume/setNodeMute only send API requests; the nodes store wasn't updated until the backend broadcast a graph change, so sliders showed stale values. Now the store is updated immediately before firing the API calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
515 lines
15 KiB
TypeScript
515 lines
15 KiB
TypeScript
import { writable, derived } from 'svelte/store';
|
|
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types';
|
|
import { subscribe, connectPorts, disconnectPorts } from './ws';
|
|
|
|
// Raw graph stores
|
|
export const nodes = writable<Node[]>([]);
|
|
export const ports = writable<Port[]>([]);
|
|
export const links = writable<Link[]>([]);
|
|
export const connected = writable(false);
|
|
|
|
// Patchbay state
|
|
export const patchbay = writable<PatchbayState>({
|
|
profiles: {},
|
|
active_profile: '',
|
|
activated: false,
|
|
exclusive: false,
|
|
auto_pin: false,
|
|
auto_disconnect: false,
|
|
pinned_connections: [],
|
|
hide_rules: [],
|
|
merge_rules: [],
|
|
aliases: {},
|
|
});
|
|
|
|
// Port/node lookups
|
|
export const portById = derived(ports, ($ports) => {
|
|
const map = new Map<number, Port>();
|
|
for (const p of $ports) map.set(p.id, p);
|
|
return map;
|
|
});
|
|
|
|
export const nodeById = derived(nodes, ($nodes) => {
|
|
const map = new Map<number, Node>();
|
|
for (const n of $nodes) map.set(n.id, n);
|
|
return map;
|
|
});
|
|
|
|
// SSE init
|
|
let unsubscribe: (() => void) | null = null;
|
|
|
|
function applyGraph(graph: GraphMessage) {
|
|
nodes.set(graph.nodes);
|
|
ports.set(graph.ports);
|
|
links.set(graph.links);
|
|
connected.set(true);
|
|
|
|
// Auto-restore if activated
|
|
const pb = get_store_value(patchbay);
|
|
if (pb.activated && pb.active_profile) {
|
|
applyPatchbay(pb);
|
|
}
|
|
// Auto-pin new connections
|
|
if (pb.auto_pin) {
|
|
const current = get_store_value(links);
|
|
const pinned = [...pb.pinned_connections];
|
|
for (const link of current) {
|
|
if (!pinned.includes(link.id)) {
|
|
pinned.push(link.id);
|
|
}
|
|
}
|
|
patchbay.update(pb => ({ ...pb, pinned_connections: pinned }));
|
|
}
|
|
// Exclusive mode: disconnect unpinned links not in profile
|
|
if (pb.activated && pb.exclusive && pb.active_profile) {
|
|
enforceExclusive(pb);
|
|
}
|
|
}
|
|
|
|
// Simple store value getter (for reading outside components)
|
|
function get_store_value<T>(store: { subscribe: (fn: (val: T) => void) => () => void }): T {
|
|
let val: T;
|
|
const unsub = store.subscribe(v => { val = v; });
|
|
unsub();
|
|
return val!;
|
|
}
|
|
|
|
export async function initGraph() {
|
|
if (unsubscribe) return;
|
|
|
|
// Load patchbay state from server
|
|
try {
|
|
const res = await fetch('/api/patchbay');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data && data.profiles) {
|
|
patchbay.set({ ...data, aliases: data.aliases ?? {} } as PatchbayState);
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Fetch initial graph
|
|
try {
|
|
const res = await fetch('/api/graph');
|
|
if (res.ok) {
|
|
applyGraph(await res.json());
|
|
}
|
|
} catch (e) {
|
|
console.warn('[graph] REST fetch failed:', e);
|
|
}
|
|
|
|
unsubscribe = subscribe((graph: GraphMessage) => {
|
|
applyGraph(graph);
|
|
});
|
|
}
|
|
|
|
export function destroyGraph() {
|
|
if (unsubscribe) {
|
|
unsubscribe();
|
|
unsubscribe = null;
|
|
connected.set(false);
|
|
}
|
|
}
|
|
|
|
// Patchbay operations
|
|
function linkKey(link: { output_port_id: number; input_port_id: number }): string {
|
|
return `${link.output_port_id}:${link.input_port_id}`;
|
|
}
|
|
|
|
function ruleKey(rule: ConnectionRule): string {
|
|
return `${rule.output_node_name}:${rule.output_port_name}=>${rule.input_node_name}:${rule.input_port_name}`;
|
|
}
|
|
|
|
export async function savePatchbayState() {
|
|
const pb = get_store_value(patchbay);
|
|
await fetch('/api/patchbay', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(pb),
|
|
});
|
|
}
|
|
|
|
async function applyPatchbay(pb: PatchbayState) {
|
|
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
|
|
const profile = pb.profiles[pb.active_profile];
|
|
const currentNodes = get_store_value(nodes);
|
|
const currentPorts = get_store_value(ports);
|
|
const currentLinks = get_store_value(links);
|
|
const existingKeys = new Set(currentLinks.map(linkKey));
|
|
|
|
// Build name lookup for current graph
|
|
const portByName = new Map<string, Port>();
|
|
const nodeByIdMap = new Map<number, Node>();
|
|
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
|
for (const p of currentPorts) {
|
|
const node = nodeByIdMap.get(p.node_id);
|
|
if (node) {
|
|
const key = `${node.name}:${p.name}:${p.mode}:${p.port_type}`;
|
|
portByName.set(key, p);
|
|
}
|
|
}
|
|
|
|
for (const rule of profile.connections) {
|
|
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
|
|
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
|
|
const outPort = portByName.get(outKey);
|
|
const inPort = portByName.get(inKey);
|
|
if (outPort && inPort) {
|
|
const lk = `${outPort.id}:${inPort.id}`;
|
|
if (!existingKeys.has(lk)) {
|
|
connectPorts(outPort.id, inPort.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function enforceExclusive(pb: PatchbayState) {
|
|
if (!pb.active_profile || !pb.profiles[pb.active_profile]) return;
|
|
const profile = pb.profiles[pb.active_profile];
|
|
const currentNodes = get_store_value(nodes);
|
|
const currentPorts = get_store_value(ports);
|
|
const currentLinks = get_store_value(links);
|
|
const nodeByIdMap = new Map<number, Node>();
|
|
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
|
|
|
// Build set of allowed connection keys from profile
|
|
const allowed = new Set<string>();
|
|
const portByName = new Map<string, Port>();
|
|
for (const p of currentPorts) {
|
|
const node = nodeByIdMap.get(p.node_id);
|
|
if (node) {
|
|
portByName.set(`${node.name}:${p.name}:${p.mode}:${p.port_type}`, p);
|
|
}
|
|
}
|
|
for (const rule of profile.connections) {
|
|
const outKey = `${rule.output_node_name}:${rule.output_port_name}:output:${rule.output_port_type}`;
|
|
const inKey = `${rule.input_node_name}:${rule.input_port_name}:input:${rule.input_port_type}`;
|
|
const outPort = portByName.get(outKey);
|
|
const inPort = portByName.get(inKey);
|
|
if (outPort && inPort) {
|
|
allowed.add(`${outPort.id}:${inPort.id}`);
|
|
}
|
|
}
|
|
|
|
// Also keep pinned connections
|
|
for (const linkId of pb.pinned_connections) {
|
|
const link = currentLinks.find(l => l.id === linkId);
|
|
if (link) allowed.add(linkKey(link));
|
|
}
|
|
|
|
// Disconnect everything else
|
|
for (const link of currentLinks) {
|
|
if (!allowed.has(linkKey(link))) {
|
|
disconnectPorts(link.output_port_id, link.input_port_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function togglePin(linkId: number) {
|
|
patchbay.update(pb => {
|
|
const idx = pb.pinned_connections.indexOf(linkId);
|
|
if (idx >= 0) {
|
|
pb.pinned_connections.splice(idx, 1);
|
|
} else {
|
|
pb.pinned_connections.push(linkId);
|
|
}
|
|
return { ...pb };
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function addHideRule(pattern: string) {
|
|
patchbay.update(pb => {
|
|
if (!pb.hide_rules.includes(pattern)) {
|
|
pb.hide_rules.push(pattern);
|
|
}
|
|
return { ...pb };
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function removeHideRule(pattern: string) {
|
|
patchbay.update(pb => {
|
|
return { ...pb, hide_rules: pb.hide_rules.filter(r => r !== pattern) };
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function addMergeRule(prefix: string) {
|
|
patchbay.update(pb => {
|
|
if (!pb.merge_rules.includes(prefix)) {
|
|
pb.merge_rules.push(prefix);
|
|
}
|
|
return { ...pb };
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function removeMergeRule(prefix: string) {
|
|
patchbay.update(pb => {
|
|
return { ...pb, merge_rules: pb.merge_rules.filter(r => r !== prefix) };
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function setActivated(active: boolean) {
|
|
patchbay.update(pb => {
|
|
const newPb = { ...pb, activated: active };
|
|
if (active && pb.active_profile) {
|
|
setTimeout(() => applyPatchbay(newPb), 100);
|
|
}
|
|
if (!active && pb.exclusive) {
|
|
// Auto-disconnect: remove all pinned connections when deactivated
|
|
if (pb.auto_disconnect) {
|
|
const currentLinks = get_store_value(links);
|
|
for (const linkId of pb.pinned_connections) {
|
|
const link = currentLinks.find(l => l.id === linkId);
|
|
if (link) disconnectPorts(link.output_port_id, link.input_port_id);
|
|
}
|
|
}
|
|
}
|
|
return newPb;
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function setExclusive(exclusive: boolean) {
|
|
patchbay.update(pb => ({ ...pb, exclusive }));
|
|
if (exclusive) {
|
|
const pb = get_store_value(patchbay);
|
|
if (pb.activated) enforceExclusive(pb);
|
|
}
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function setAutoPin(val: boolean) {
|
|
patchbay.update(pb => ({ ...pb, auto_pin: val }));
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function setAutoDisconnect(val: boolean) {
|
|
patchbay.update(pb => ({ ...pb, auto_disconnect: val }));
|
|
savePatchbayState();
|
|
}
|
|
|
|
export async function saveProfile(name: string) {
|
|
const currentLinks = get_store_value(links);
|
|
const currentPorts = get_store_value(ports);
|
|
const currentNodes = get_store_value(nodes);
|
|
const pb = get_store_value(patchbay);
|
|
|
|
const portByIdMap = new Map<number, Port>();
|
|
for (const p of currentPorts) portByIdMap.set(p.id, p);
|
|
const nodeByIdMap = new Map<number, Node>();
|
|
for (const n of currentNodes) nodeByIdMap.set(n.id, n);
|
|
|
|
const connections: ConnectionRule[] = [];
|
|
for (const link of currentLinks) {
|
|
const outPort = portByIdMap.get(link.output_port_id);
|
|
const inPort = portByIdMap.get(link.input_port_id);
|
|
if (!outPort || !inPort) continue;
|
|
const outNode = nodeByIdMap.get(outPort.node_id);
|
|
const inNode = nodeByIdMap.get(inPort.node_id);
|
|
if (!outNode || !inNode) continue;
|
|
connections.push({
|
|
output_node_name: outNode.name,
|
|
output_port_name: outPort.name,
|
|
output_port_type: outPort.port_type,
|
|
input_node_name: inNode.name,
|
|
input_port_name: inPort.name,
|
|
input_port_type: inPort.port_type,
|
|
pinned: pb.pinned_connections.includes(link.id),
|
|
});
|
|
}
|
|
|
|
const volumes: Record<string, number> = {};
|
|
const mutes: Record<string, boolean> = {};
|
|
for (const n of currentNodes) {
|
|
volumes[n.name] = n.volume;
|
|
mutes[n.name] = n.mute;
|
|
}
|
|
|
|
const profile: PatchbayProfile = {
|
|
name,
|
|
connections,
|
|
hide_rules: [...pb.hide_rules],
|
|
merge_rules: [...pb.merge_rules],
|
|
volumes,
|
|
mutes,
|
|
};
|
|
|
|
patchbay.update(pb => ({
|
|
...pb,
|
|
profiles: { ...pb.profiles, [name]: profile },
|
|
active_profile: name,
|
|
}));
|
|
savePatchbayState();
|
|
}
|
|
|
|
async function applyProfileVolumes(profile: PatchbayProfile) {
|
|
if (!profile.volumes && !profile.mutes) return;
|
|
const currentNodes = get_store_value(nodes);
|
|
|
|
// Optimistically update store so sliders reflect new values immediately
|
|
nodes.update(ns => ns.map(n => {
|
|
const vol = profile.volumes?.[n.name];
|
|
const mute = profile.mutes?.[n.name];
|
|
if (vol !== undefined || mute !== undefined) {
|
|
return { ...n, ...(vol !== undefined ? { volume: vol } : {}), ...(mute !== undefined ? { mute } : {}) };
|
|
}
|
|
return n;
|
|
}));
|
|
|
|
for (const n of currentNodes) {
|
|
if (profile.volumes?.[n.name] !== undefined) {
|
|
await setNodeVolume(n.id, profile.volumes[n.name]);
|
|
}
|
|
if (profile.mutes?.[n.name] !== undefined) {
|
|
await setNodeMute(n.id, profile.mutes[n.name]);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function loadProfile(name: string) {
|
|
patchbay.update(pb => ({ ...pb, active_profile: name }));
|
|
const pb = get_store_value(patchbay);
|
|
// Always apply connections when explicitly loading a profile
|
|
applyPatchbay(pb);
|
|
const profile = pb.profiles[name];
|
|
if (profile) applyProfileVolumes(profile);
|
|
savePatchbayState();
|
|
}
|
|
|
|
export function deleteProfile(name: string) {
|
|
patchbay.update(pb => {
|
|
const profiles = { ...pb.profiles };
|
|
delete profiles[name];
|
|
return {
|
|
...pb,
|
|
profiles,
|
|
active_profile: pb.active_profile === name ? '' : pb.active_profile,
|
|
};
|
|
});
|
|
savePatchbayState();
|
|
}
|
|
|
|
// Node aliases (custom display names, keyed by PW node name)
|
|
export function setAlias(pwName: string, alias: string) {
|
|
patchbay.update(pb => {
|
|
const aliases = { ...pb.aliases };
|
|
if (alias.trim()) {
|
|
aliases[pwName] = alias.trim();
|
|
} else {
|
|
delete aliases[pwName]; // empty string = remove alias
|
|
}
|
|
return { ...pb, aliases };
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Virtual devices
|
|
export async function createNullSink(name: string): Promise<number | null> {
|
|
try {
|
|
const res = await fetch('/api/create-null-sink', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
const data = await res.json();
|
|
return data.module_id > 0 ? data.module_id : null;
|
|
} catch (e) {
|
|
console.error('[api] create-null-sink failed:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function createLoopback(name: string): Promise<number | null> {
|
|
try {
|
|
const res = await fetch('/api/create-loopback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
const data = await res.json();
|
|
return data.module_id > 0 ? data.module_id : null;
|
|
} catch (e) {
|
|
console.error('[api] create-loopback failed:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function loadModule(module: string, args: string): Promise<number | null> {
|
|
try {
|
|
const res = await fetch('/api/load-module', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ module, args }),
|
|
});
|
|
const data = await res.json();
|
|
return data.module_id > 0 ? data.module_id : null;
|
|
} catch (e) {
|
|
console.error('[api] load-module failed:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function unloadModule(moduleId: number) {
|
|
try {
|
|
await fetch('/api/unload-module', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ module_id: moduleId }),
|
|
});
|
|
} catch (e) {
|
|
console.error('[api] unload-module failed:', e);
|
|
}
|
|
}
|
|
|
|
// 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 };
|