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([]); export const ports = writable([]); export const links = writable([]); export const connected = writable(false); // Patchbay state export const patchbay = writable({ 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(); for (const p of $ports) map.set(p.id, p); return map; }); export const nodeById = derived(nodes, ($nodes) => { const map = new Map(); 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(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(); const nodeByIdMap = new Map(); 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(); for (const n of currentNodes) nodeByIdMap.set(n.id, n); // Build set of allowed connection keys from profile const allowed = new Set(); const portByName = new Map(); 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(); for (const p of currentPorts) portByIdMap.set(p.id, p); const nodeByIdMap = new Map(); 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 = {}; const mutes: Record = {}; 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 { 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 { 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 { 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 { 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 };