Merge feature/profile-virtual-nodes into master

This commit is contained in:
joren
2026-04-02 19:27:59 +02:00
3 changed files with 67 additions and 12 deletions

View File

@@ -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' },

View File

@@ -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', {

View File

@@ -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;
@@ -75,6 +81,7 @@ export interface PatchbayProfile {
merge_rules?: string[];
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 {
@@ -88,4 +95,5 @@ export interface PatchbayState {
hide_rules: string[];
merge_rules: string[];
aliases: Record<string, string>; // PW node name → custom display name
virtual_nodes: VirtualNodeDef[]; // user-created virtual devices (global registry)
}