-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathformat.ts
More file actions
162 lines (150 loc) · 4.93 KB
/
Copy pathformat.ts
File metadata and controls
162 lines (150 loc) · 4.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import type { InputSpec, OutputSpec } from "../audio/types";
/** File identifiers for the patch format. Bump VERSION on breaking changes. */
export const PATCH_FORMAT = "faustmod-patch";
export const BLOCK_FORMAT = "faustmod-block";
export const PATCH_VERSION = 1;
export const PATCH_EXTENSION = ".faustmod";
/** A single node in a saved patch. `value` is only present for Constant nodes. */
export interface PatchNode {
id: string;
componentId: string;
position: { x: number; y: number };
/** User-renamed node title (absent = the component's default title). */
label?: string;
value?: number;
/**
* Adjusted resting values for this node's control inputs, keyed by socket ("in-1").
* Only inputs the user changed appear; the rest use the component's declared default.
*/
params?: Record<string, number>;
/** Widget node size (resizable widgets). */
size?: { w: number; h: number };
/** Widget node state (e.g. sequencer notes). */
state?: Record<string, unknown>;
/** Edited Faust source for a module node (module editor override). */
code?: string;
}
export interface PatchConnection {
id: string;
source: string;
sourceOutput: string;
target: string;
targetInput: string;
}
/**
* A user-authored DSP block: Faust source plus the port metadata our control-input
* model needs (labels + defaults). Embedded in patches so they stay self-contained,
* and stored in the custom-block registry so they appear in the palette.
*/
export interface CustomBlockDef {
id: string;
title: string;
category: string;
inputs: InputSpec[];
outputs: OutputSpec[];
code: string;
/** Saved but not yet successfully compiled (draft). */
dirty?: boolean;
}
/** The `.faustmod` patch file. */
export interface PatchFile {
format: typeof PATCH_FORMAT;
version: number;
name: string;
createdAt?: string;
masterVolume?: number;
/** Custom blocks referenced by this patch (built-in blocks are not embedded). */
customBlocks: CustomBlockDef[];
nodes: PatchNode[];
connections: PatchConnection[];
}
/** The graph portion the editor round-trips (subset of PatchFile). */
export interface GraphSnapshot {
nodes: PatchNode[];
connections: PatchConnection[];
}
/**
* Every new patch starts with an Audio Input and a Stereo Output already placed, so
* the user never has to add the endpoints by hand. They're left unconnected.
*/
export function starterNodes(): PatchNode[] {
return [
{ id: "audio-input", componentId: "input", position: { x: 80, y: 160 } },
{ id: "stereo-output", componentId: "output", position: { x: 520, y: 160 } },
];
}
export function emptyPatch(name = "Untitled"): PatchFile {
return {
format: PATCH_FORMAT,
version: PATCH_VERSION,
name,
createdAt: new Date().toISOString(),
customBlocks: [],
nodes: starterNodes(),
connections: [],
};
}
export function serializePatch(patch: PatchFile): string {
return JSON.stringify(patch, null, 2);
}
/** Parse + validate a `.faustmod` patch file. Throws on malformed input. */
export function parsePatch(text: string): PatchFile {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error("Not valid JSON.");
}
const p = data as Partial<PatchFile>;
if (p?.format !== PATCH_FORMAT) {
throw new Error(`Not a FaustMod patch (missing "format": "${PATCH_FORMAT}").`);
}
if (!Array.isArray(p.nodes) || !Array.isArray(p.connections)) {
throw new Error("Patch is missing nodes/connections.");
}
return {
format: PATCH_FORMAT,
version: typeof p.version === "number" ? p.version : PATCH_VERSION,
name: typeof p.name === "string" ? p.name : "Untitled",
createdAt: p.createdAt,
masterVolume: p.masterVolume,
customBlocks: Array.isArray(p.customBlocks) ? p.customBlocks : [],
nodes: p.nodes as PatchNode[],
connections: p.connections as PatchConnection[],
};
}
/** Parse a standalone custom-block definition (the "faustmod-block" paste format). */
export function parseBlock(text: string): CustomBlockDef {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error("Not valid JSON.");
}
const b = data as Partial<CustomBlockDef> & { format?: string };
if (b?.format && b.format !== BLOCK_FORMAT) {
throw new Error(`Expected "format": "${BLOCK_FORMAT}".`);
}
if (typeof b.code !== "string" || !b.code.trim()) {
throw new Error("Block is missing Faust `code`.");
}
if (typeof b.title !== "string" || !b.title.trim()) {
throw new Error("Block is missing a `title`.");
}
return {
id: b.id && b.id.trim() ? b.id : slugify(b.title),
title: b.title,
category: b.category?.trim() || "Custom",
inputs: Array.isArray(b.inputs) ? b.inputs : [],
outputs: Array.isArray(b.outputs) && b.outputs.length ? b.outputs : [{ label: "out" }],
code: b.code,
};
}
export function slugify(s: string): string {
return (
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "block"
);
}