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,
|
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' },
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -73,8 +79,9 @@ export interface PatchbayProfile {
|
|||||||
connections: ConnectionRule[];
|
connections: ConnectionRule[];
|
||||||
hide_rules?: string[];
|
hide_rules?: string[];
|
||||||
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 {
|
||||||
@@ -87,5 +94,6 @@ export interface PatchbayState {
|
|||||||
pinned_connections: number[];
|
pinned_connections: number[];
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user