feat: save and restore virtual nodes (null-sinks, loopbacks) in profiles

- Add VirtualNodeDef type; track it in PatchbayState.virtual_nodes
- createNullSink/createLoopback now register the node in the global
  virtual_nodes registry on success
- Destroying a node via context menu removes it from the registry
- saveProfile snapshots virtual_nodes into the profile
- loadProfile recreates any missing virtual nodes before applying
  connections (waits 1.5s for graph to settle after creation)
- Backward compat: virtual_nodes defaults to [] for old save files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-04-02 19:27:59 +02:00
parent 58d1972d19
commit 68307556e9
3 changed files with 67 additions and 12 deletions

View File

@@ -12,7 +12,7 @@
setAutoPin, setAutoDisconnect, setAutoPin, setAutoDisconnect,
saveProfile, loadProfile, deleteProfile, saveProfile, loadProfile, deleteProfile,
setNodeVolume, setNodeMute, setNodeVolume, setNodeMute,
setAlias, setAlias, removeVirtualNode,
createNullSink, createLoopback, loadModule, createNullSink, createLoopback, loadModule,
getQuantum, setQuantum, getQuantum, setQuantum,
} from '../lib/stores'; } from '../lib/stores';
@@ -704,6 +704,7 @@
nodeContextMenu = null; nodeContextMenu = null;
}}>Hide</button> }}>Hide</button>
<button onclick={() => { <button onclick={() => {
removeVirtualNode(nodeContextMenu!.nodeName);
fetch('/api/destroy-node', { fetch('/api/destroy-node', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,5 +1,5 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule } from './types'; import type { Node, Port, Link, GraphMessage, PatchbayState, PatchbayProfile, ConnectionRule, VirtualNodeDef } from './types';
import { subscribe, connectPorts, disconnectPorts } from './ws'; import { subscribe, connectPorts, disconnectPorts } from './ws';
// Raw graph stores // Raw graph stores
@@ -20,6 +20,7 @@ export const patchbay = writable<PatchbayState>({
hide_rules: [], hide_rules: [],
merge_rules: [], merge_rules: [],
aliases: {}, aliases: {},
virtual_nodes: [],
}); });
// Port/node lookups // Port/node lookups
@@ -83,7 +84,7 @@ export async function initGraph() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data && data.profiles) { if (data && data.profiles) {
patchbay.set({ ...data, aliases: data.aliases ?? {} } as PatchbayState); patchbay.set({ ...data, aliases: data.aliases ?? {}, virtual_nodes: data.virtual_nodes ?? [] } as PatchbayState);
} }
} }
} catch {} } catch {}
@@ -336,6 +337,7 @@ export async function saveProfile(name: string) {
merge_rules: [...pb.merge_rules], merge_rules: [...pb.merge_rules],
volumes, volumes,
mutes, mutes,
virtual_nodes: [...pb.virtual_nodes],
}; };
patchbay.update(pb => ({ patchbay.update(pb => ({
@@ -346,6 +348,22 @@ export async function saveProfile(name: string) {
savePatchbayState(); savePatchbayState();
} }
async function restoreVirtualNodes(defs: VirtualNodeDef[]) {
if (!defs.length) return;
const currentNodes = get_store_value(nodes);
const existingNames = new Set(currentNodes.map(n => n.name));
let created = false;
for (const vn of defs) {
if (!existingNames.has(vn.name)) {
if (vn.type === 'null-sink') await createNullSink(vn.name);
else if (vn.type === 'loopback') await createLoopback(vn.name);
created = true;
}
}
// Give the graph time to settle after creation before applying connections
if (created) await new Promise(r => setTimeout(r, 1500));
}
async function applyProfileVolumes(profile: PatchbayProfile) { async function applyProfileVolumes(profile: PatchbayProfile) {
if (!profile.volumes && !profile.mutes) return; if (!profile.volumes && !profile.mutes) return;
const currentNodes = get_store_value(nodes); const currentNodes = get_store_value(nodes);
@@ -370,13 +388,17 @@ async function applyProfileVolumes(profile: PatchbayProfile) {
} }
} }
export function loadProfile(name: string) { export async function loadProfile(name: string) {
patchbay.update(pb => ({ ...pb, active_profile: name })); patchbay.update(pb => ({ ...pb, active_profile: name }));
const pb = get_store_value(patchbay); const pb = get_store_value(patchbay);
// Always apply connections when explicitly loading a profile
applyPatchbay(pb);
const profile = pb.profiles[name]; const profile = pb.profiles[name];
if (profile) applyProfileVolumes(profile); if (!profile) { savePatchbayState(); return; }
// Recreate missing virtual nodes before restoring connections
await restoreVirtualNodes(profile.virtual_nodes ?? []);
applyPatchbay(pb);
applyProfileVolumes(profile);
savePatchbayState(); savePatchbayState();
} }
@@ -441,7 +463,15 @@ export async function createNullSink(name: string): Promise<number | null> {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
const data = await res.json(); const data = await res.json();
return data.module_id > 0 ? data.module_id : null; const moduleId = data.module_id > 0 ? data.module_id : null;
if (moduleId) {
patchbay.update(pb => ({
...pb,
virtual_nodes: [...pb.virtual_nodes.filter(v => v.name !== name), { type: 'null-sink', name }],
}));
savePatchbayState();
}
return moduleId;
} catch (e) { } catch (e) {
console.error('[api] create-null-sink failed:', e); console.error('[api] create-null-sink failed:', e);
return null; return null;
@@ -456,13 +486,29 @@ export async function createLoopback(name: string): Promise<number | null> {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
const data = await res.json(); const data = await res.json();
return data.module_id > 0 ? data.module_id : null; const moduleId = data.module_id > 0 ? data.module_id : null;
if (moduleId) {
patchbay.update(pb => ({
...pb,
virtual_nodes: [...pb.virtual_nodes.filter(v => v.name !== name), { type: 'loopback', name }],
}));
savePatchbayState();
}
return moduleId;
} catch (e) { } catch (e) {
console.error('[api] create-loopback failed:', e); console.error('[api] create-loopback failed:', e);
return null; return null;
} }
} }
export function removeVirtualNode(name: string) {
patchbay.update(pb => ({
...pb,
virtual_nodes: pb.virtual_nodes.filter(v => v.name !== name),
}));
savePatchbayState();
}
export async function loadModule(module: string, args: string): Promise<number | null> { export async function loadModule(module: string, args: string): Promise<number | null> {
try { try {
const res = await fetch('/api/load-module', { const res = await fetch('/api/load-module', {

View File

@@ -57,6 +57,12 @@ export interface DisconnectMessage {
export type WsMessage = ConnectMessage | DisconnectMessage; export type WsMessage = ConnectMessage | DisconnectMessage;
// Virtual device definition (user-created null-sinks, loopbacks, etc.)
export interface VirtualNodeDef {
type: 'null-sink' | 'loopback';
name: string;
}
// Patchbay types // Patchbay types
export interface ConnectionRule { export interface ConnectionRule {
output_port_name: string; output_port_name: string;
@@ -75,6 +81,7 @@ export interface PatchbayProfile {
merge_rules?: string[]; merge_rules?: string[];
volumes?: Record<string, number>; // PW node name → volume (0..1) volumes?: Record<string, number>; // PW node name → volume (0..1)
mutes?: Record<string, boolean>; // PW node name → mute state mutes?: Record<string, boolean>; // PW node name → mute state
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
} }
export interface PatchbayState { export interface PatchbayState {
@@ -88,4 +95,5 @@ export interface PatchbayState {
hide_rules: string[]; hide_rules: string[];
merge_rules: string[]; merge_rules: string[];
aliases: Record<string, string>; // PW node name → custom display name aliases: Record<string, string>; // PW node name → custom display name
virtual_nodes: VirtualNodeDef[]; // user-created virtual devices (global registry)
} }