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:
@@ -12,7 +12,7 @@
|
||||
setAutoPin, setAutoDisconnect,
|
||||
saveProfile, loadProfile, deleteProfile,
|
||||
setNodeVolume, setNodeMute,
|
||||
setAlias,
|
||||
setAlias, removeVirtualNode,
|
||||
createNullSink, createLoopback, loadModule,
|
||||
getQuantum, setQuantum,
|
||||
} from '../lib/stores';
|
||||
@@ -704,6 +704,7 @@
|
||||
nodeContextMenu = null;
|
||||
}}>Hide</button>
|
||||
<button onclick={() => {
|
||||
removeVirtualNode(nodeContextMenu!.nodeName);
|
||||
fetch('/api/destroy-node', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
// Raw graph stores
|
||||
@@ -20,6 +20,7 @@ export const patchbay = writable<PatchbayState>({
|
||||
hide_rules: [],
|
||||
merge_rules: [],
|
||||
aliases: {},
|
||||
virtual_nodes: [],
|
||||
});
|
||||
|
||||
// Port/node lookups
|
||||
@@ -83,7 +84,7 @@ export async function initGraph() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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 {}
|
||||
@@ -336,6 +337,7 @@ export async function saveProfile(name: string) {
|
||||
merge_rules: [...pb.merge_rules],
|
||||
volumes,
|
||||
mutes,
|
||||
virtual_nodes: [...pb.virtual_nodes],
|
||||
};
|
||||
|
||||
patchbay.update(pb => ({
|
||||
@@ -346,6 +348,22 @@ export async function saveProfile(name: string) {
|
||||
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) {
|
||||
if (!profile.volumes && !profile.mutes) return;
|
||||
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 }));
|
||||
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);
|
||||
if (!profile) { savePatchbayState(); return; }
|
||||
|
||||
// Recreate missing virtual nodes before restoring connections
|
||||
await restoreVirtualNodes(profile.virtual_nodes ?? []);
|
||||
|
||||
applyPatchbay(pb);
|
||||
applyProfileVolumes(profile);
|
||||
savePatchbayState();
|
||||
}
|
||||
|
||||
@@ -441,7 +463,15 @@ export async function createNullSink(name: string): Promise<number | null> {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
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) {
|
||||
console.error('[api] create-null-sink failed:', e);
|
||||
return null;
|
||||
@@ -456,13 +486,29 @@ export async function createLoopback(name: string): Promise<number | null> {
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
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) {
|
||||
console.error('[api] create-loopback failed:', e);
|
||||
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> {
|
||||
try {
|
||||
const res = await fetch('/api/load-module', {
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface 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
|
||||
export interface ConnectionRule {
|
||||
output_port_name: string;
|
||||
@@ -73,8 +79,9 @@ export interface PatchbayProfile {
|
||||
connections: ConnectionRule[];
|
||||
hide_rules?: string[];
|
||||
merge_rules?: string[];
|
||||
volumes?: Record<string, number>; // PW node name → volume (0..1)
|
||||
mutes?: Record<string, boolean>; // PW node name → mute state
|
||||
volumes?: Record<string, number>; // PW node name → volume (0..1)
|
||||
mutes?: Record<string, boolean>; // PW node name → mute state
|
||||
virtual_nodes?: VirtualNodeDef[]; // user-created virtual devices
|
||||
}
|
||||
|
||||
export interface PatchbayState {
|
||||
@@ -87,5 +94,6 @@ export interface PatchbayState {
|
||||
pinned_connections: number[];
|
||||
hide_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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user