@effetune/dsp 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ // Generated by scripts/gen-dsp-library-bindings.mjs. Do not edit.
2
+ export const GRAPH_V1_CAPACITY = Object.freeze({
3
+ "maxStructuralNodes": 128,
4
+ "maxEffectiveInstances": 96,
5
+ "maxEdges": 512,
6
+ "maxLiveBuffers": 129,
7
+ "maxWorkspaceBytes": 67108864
8
+ });
@@ -0,0 +1,644 @@
1
+ import { Effect } from './effect.js';
2
+ import { AssetError, EffectError, ValidationError, validationDetail } from './errors.js';
3
+ import { normalizeChainDocument } from './semantics.js';
4
+
5
+ const ROOT_KEYS = new Set(['version', 'input', 'output', 'nodes', 'edges']);
6
+ const ENDPOINT_KEYS = new Set(['id']);
7
+ const EDGE_KEYS = new Set([
8
+ 'id', 'source', 'destination', 'gain', 'mute', 'pan', 'mixGroup', 'solo'
9
+ ]);
10
+ const textEncoder = new TextEncoder();
11
+
12
+ function isRecord(value) {
13
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ }
15
+
16
+ function graphError(message, { code, path = '', nodeId, edgeId, cause } = {}) {
17
+ return new ValidationError(message, { code, path, nodeId, edgeId, cause });
18
+ }
19
+
20
+ function utf8Compare(left, right) {
21
+ const a = textEncoder.encode(left);
22
+ const b = textEncoder.encode(right);
23
+ const length = Math.min(a.length, b.length);
24
+ for (let index = 0; index < length; index++) {
25
+ if (a[index] !== b[index]) return a[index] - b[index];
26
+ }
27
+ return a.length - b.length;
28
+ }
29
+
30
+ function cloneValue(value) {
31
+ if (Array.isArray(value)) return value.map(cloneValue);
32
+ if (isRecord(value)) {
33
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneValue(item)]));
34
+ }
35
+ return value;
36
+ }
37
+
38
+ export function cloneGraphDocument(document) {
39
+ return cloneValue(document);
40
+ }
41
+
42
+ function deepFreeze(value) {
43
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
44
+ for (const child of Object.values(value)) deepFreeze(child);
45
+ return Object.freeze(value);
46
+ }
47
+
48
+ function unicodeScalarLength(value) {
49
+ let length = 0;
50
+ for (const scalar of value) {
51
+ const codePoint = scalar.codePointAt(0);
52
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) return -1;
53
+ length++;
54
+ }
55
+ return length;
56
+ }
57
+
58
+ function validateId(value, path) {
59
+ const length = typeof value === 'string' ? unicodeScalarLength(value) : -1;
60
+ if (length < 1 || length > 128) {
61
+ throw graphError('Graph IDs must contain between 1 and 128 Unicode scalar values.', {
62
+ code: 'GRAPH_DOCUMENT_ID',
63
+ path
64
+ });
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function normalizeEndpoint(value, path) {
70
+ if (!isRecord(value)) {
71
+ throw graphError('Graph endpoints must be objects.', {
72
+ code: 'GRAPH_DOCUMENT_REFERENCE', path
73
+ });
74
+ }
75
+ for (const key of Object.keys(value)) {
76
+ if (!ENDPOINT_KEYS.has(key)) {
77
+ throw graphError(`Graph endpoint has an unsupported field: ${key}`, {
78
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: `${path}/${key}`
79
+ });
80
+ }
81
+ }
82
+ return { id: validateId(value.id, `${path}/id`) };
83
+ }
84
+
85
+ // Chain validation tags its ValidationError/EffectError/AssetError with the structured cause so a
86
+ // Graph node failure lands on the field the caller actually got wrong instead of a
87
+ // message-regexp guess. The thrown class is preserved so the Chain and Graph entry points
88
+ // report the same category for the same mistake.
89
+ function graphNodeError(error, path, nodeId) {
90
+ const detail = validationDetail(error);
91
+ const kind = detail?.kind ?? (error instanceof AssetError ? 'assets' : undefined);
92
+ let code = 'GRAPH_DOCUMENT_REFERENCE';
93
+ let suffix = '';
94
+ if (kind === 'channel') {
95
+ code = 'GRAPH_DOCUMENT_CHANNEL';
96
+ suffix = '/channel';
97
+ } else if (kind === 'parameter') {
98
+ code = 'GRAPH_DOCUMENT_PARAMETER';
99
+ suffix = detail?.parameter === undefined ? '' : `/parameters/${detail.parameter}`;
100
+ } else if (kind === 'type') {
101
+ suffix = '/type';
102
+ } else if (kind === 'assets') {
103
+ suffix = '/assets';
104
+ }
105
+ const ErrorType = error instanceof AssetError ? AssetError
106
+ : error instanceof EffectError ? EffectError
107
+ : ValidationError;
108
+ return new ErrorType(error.message, {
109
+ code, path: `${path}${suffix}`, nodeId, cause: error
110
+ });
111
+ }
112
+
113
+ function normalizeNode(value, index) {
114
+ const path = `/nodes/${index}`;
115
+ if (!isRecord(value)) {
116
+ throw graphError(`Graph node ${index} must be an object.`, {
117
+ code: 'GRAPH_DOCUMENT_REFERENCE', path
118
+ });
119
+ }
120
+ const id = validateId(value.id, `${path}/id`);
121
+ try {
122
+ const normalized = normalizeChainDocument({
123
+ version: 1,
124
+ chain: [{ ...value, id: `graph-node-${index + 1}` }]
125
+ }, { entryLabel: `Graph node ${id}` }).chain[0];
126
+ return { ...normalized, id };
127
+ } catch (error) {
128
+ if (error instanceof ValidationError || error instanceof EffectError ||
129
+ error instanceof AssetError) {
130
+ throw graphNodeError(error, path, value.id);
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ function finiteNumber(value, minimum, maximum, label, path, edgeId) {
137
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) {
138
+ throw graphError(`${label} must be a finite number from ${minimum} to ${maximum}.`, {
139
+ code: 'GRAPH_DOCUMENT_EDGE_CONTROL', path, edgeId
140
+ });
141
+ }
142
+ return value;
143
+ }
144
+
145
+ function normalizeEdge(value, index) {
146
+ const path = `/edges/${index}`;
147
+ if (!isRecord(value)) {
148
+ throw graphError(`Graph edge ${index} must be an object.`, {
149
+ code: 'GRAPH_DOCUMENT_REFERENCE', path
150
+ });
151
+ }
152
+ for (const key of Object.keys(value)) {
153
+ if (!EDGE_KEYS.has(key)) {
154
+ throw graphError(`Graph edge ${index} has an unsupported field: ${key}`, {
155
+ code: 'GRAPH_DOCUMENT_EDGE_CONTROL', path: `${path}/${key}`
156
+ });
157
+ }
158
+ }
159
+ const id = validateId(value.id, `${path}/id`);
160
+ const source = validateId(value.source, `${path}/source`);
161
+ const destination = validateId(value.destination, `${path}/destination`);
162
+ const gain = finiteNumber(value.gain ?? 1, 0, 4, 'Edge gain', `${path}/gain`, id);
163
+ const mute = value.mute ?? false;
164
+ const solo = value.solo ?? false;
165
+ if (typeof mute !== 'boolean') {
166
+ throw graphError('Edge mute must be boolean.', {
167
+ code: 'GRAPH_DOCUMENT_EDGE_CONTROL', path: `${path}/mute`, edgeId: id
168
+ });
169
+ }
170
+ if (typeof solo !== 'boolean') {
171
+ throw graphError('Edge solo must be boolean.', {
172
+ code: 'GRAPH_DOCUMENT_EDGE_CONTROL', path: `${path}/solo`, edgeId: id
173
+ });
174
+ }
175
+ const mixGroup = value.mixGroup ?? 'default';
176
+ const mixGroupLength = typeof mixGroup === 'string' ? unicodeScalarLength(mixGroup) : -1;
177
+ if (mixGroupLength < 1 || mixGroupLength > 128) {
178
+ throw graphError('Edge mixGroup must contain between 1 and 128 Unicode scalar values.', {
179
+ code: 'GRAPH_DOCUMENT_EDGE_CONTROL', path: `${path}/mixGroup`, edgeId: id
180
+ });
181
+ }
182
+ let pan;
183
+ if (Object.hasOwn(value, 'pan')) {
184
+ pan = finiteNumber(value.pan, -1, 1, 'Edge pan', `${path}/pan`, id);
185
+ }
186
+ return {
187
+ id,
188
+ source,
189
+ destination,
190
+ gain,
191
+ mute,
192
+ ...(pan === undefined ? {} : { pan }),
193
+ mixGroup,
194
+ solo
195
+ };
196
+ }
197
+
198
+ function validateUniqueIds(input, output, nodes, edges, nodeIndexes, edgeIndexes) {
199
+ const seen = new Map();
200
+ const add = (id, path) => {
201
+ const previous = seen.get(id);
202
+ if (previous !== undefined) {
203
+ throw graphError(`Duplicate Graph id: ${id}`, {
204
+ code: 'GRAPH_DOCUMENT_ID', path
205
+ });
206
+ }
207
+ seen.set(id, path);
208
+ };
209
+ add(input.id, '/input/id');
210
+ add(output.id, '/output/id');
211
+ for (const node of nodes) add(node.id, `/nodes/${nodeIndexes.get(node.id)}/id`);
212
+ for (const edge of edges) add(edge.id, `/edges/${edgeIndexes.get(edge.id)}/id`);
213
+ }
214
+
215
+ function structuralOrder(input, output, nodes, edges, nodeIndexes, edgeIndexes) {
216
+ const nodeIds = new Set(nodes.map(node => node.id));
217
+ const targets = new Set([input.id, output.id, ...nodeIds]);
218
+ const incomingCount = new Map(nodes.map(node => [node.id, 0]));
219
+ const outgoingNodes = new Map(nodes.map(node => [node.id, []]));
220
+ const outgoing = new Map([...targets].map(id => [id, []]));
221
+ const incoming = new Map([...targets].map(id => [id, []]));
222
+
223
+ for (const edge of edges) {
224
+ const path = `/edges/${edgeIndexes.get(edge.id)}`;
225
+ if (!targets.has(edge.source) || !targets.has(edge.destination)) {
226
+ const field = !targets.has(edge.source) ? 'source' : 'destination';
227
+ throw graphError(`Graph edge ${edge.id} references an unknown endpoint.`, {
228
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: `${path}/${field}`, edgeId: edge.id
229
+ });
230
+ }
231
+ if (edge.source === output.id || edge.destination === input.id) {
232
+ const field = edge.source === output.id ? 'source' : 'destination';
233
+ throw graphError('The main input is source-only and the main output is destination-only.', {
234
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: `${path}/${field}`, edgeId: edge.id
235
+ });
236
+ }
237
+ outgoing.get(edge.source).push(edge);
238
+ incoming.get(edge.destination).push(edge);
239
+ if (nodeIds.has(edge.source) && nodeIds.has(edge.destination)) {
240
+ outgoingNodes.get(edge.source).push(edge.destination);
241
+ incomingCount.set(edge.destination, incomingCount.get(edge.destination) + 1);
242
+ }
243
+ }
244
+
245
+ const visitState = new Map(nodes.map(node => [node.id, 0]));
246
+ const visit = id => {
247
+ visitState.set(id, 1);
248
+ for (const edge of outgoing.get(id)) {
249
+ if (!nodeIds.has(edge.destination)) continue;
250
+ if (visitState.get(edge.destination) === 1) {
251
+ throw graphError('Graph nodes and edges must form an acyclic graph.', {
252
+ code: 'GRAPH_DOCUMENT_CYCLE',
253
+ path: `/edges/${edgeIndexes.get(edge.id)}`,
254
+ edgeId: edge.id
255
+ });
256
+ }
257
+ if (visitState.get(edge.destination) === 0) visit(edge.destination);
258
+ }
259
+ visitState.set(id, 2);
260
+ };
261
+ for (const node of nodes) {
262
+ if (visitState.get(node.id) === 0) visit(node.id);
263
+ }
264
+
265
+ const ready = nodes.filter(node => incomingCount.get(node.id) === 0).map(node => node.id);
266
+ ready.sort(utf8Compare);
267
+ const order = [];
268
+ while (ready.length > 0) {
269
+ const id = ready.shift();
270
+ order.push(id);
271
+ for (const destination of outgoingNodes.get(id).sort(utf8Compare)) {
272
+ const count = incomingCount.get(destination) - 1;
273
+ incomingCount.set(destination, count);
274
+ if (count === 0) {
275
+ ready.push(destination);
276
+ ready.sort(utf8Compare);
277
+ }
278
+ }
279
+ }
280
+ if (order.length !== nodes.length) throw new Error('Internal Graph topological ordering failure.');
281
+
282
+ if (nodes.length !== 0 || edges.length !== 0) {
283
+ const forward = new Set([input.id]);
284
+ const queue = [input.id];
285
+ while (queue.length > 0) {
286
+ for (const edge of outgoing.get(queue.shift())) {
287
+ if (!forward.has(edge.destination)) {
288
+ forward.add(edge.destination);
289
+ queue.push(edge.destination);
290
+ }
291
+ }
292
+ }
293
+ const backward = new Set([output.id]);
294
+ queue.push(output.id);
295
+ while (queue.length > 0) {
296
+ for (const edge of incoming.get(queue.shift())) {
297
+ if (!backward.has(edge.source)) {
298
+ backward.add(edge.source);
299
+ queue.push(edge.source);
300
+ }
301
+ }
302
+ }
303
+ const disconnectedNode = nodes.find(node => !forward.has(node.id) || !backward.has(node.id));
304
+ if (disconnectedNode) {
305
+ throw graphError(`Graph node ${disconnectedNode.id} is not on a main input-output path.`, {
306
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY',
307
+ path: `/nodes/${nodeIndexes.get(disconnectedNode.id)}`,
308
+ nodeId: disconnectedNode.id
309
+ });
310
+ }
311
+ const disconnectedEdge = edges.find(
312
+ edge => !forward.has(edge.source) || !backward.has(edge.destination)
313
+ );
314
+ if (disconnectedEdge) {
315
+ throw graphError(`Graph edge ${disconnectedEdge.id} is not on a main input-output path.`, {
316
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY',
317
+ path: `/edges/${edgeIndexes.get(disconnectedEdge.id)}`,
318
+ edgeId: disconnectedEdge.id
319
+ });
320
+ }
321
+ if (!forward.has(output.id)) {
322
+ throw graphError('Graph does not connect the main input to the main output.', {
323
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY', path: ''
324
+ });
325
+ }
326
+ }
327
+ return { order, incoming, outgoing };
328
+ }
329
+
330
+ export function _normalizeGraphInput(input) {
331
+ let source = input;
332
+ if (typeof source === 'string') {
333
+ try {
334
+ source = JSON.parse(source);
335
+ } catch (error) {
336
+ throw graphError('Graph input is not valid JSON.', {
337
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: '', cause: error
338
+ });
339
+ }
340
+ }
341
+ if (!isRecord(source)) {
342
+ throw graphError('A Graph must be a version 1 Graph document.', {
343
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: ''
344
+ });
345
+ }
346
+ for (const key of Object.keys(source)) {
347
+ if (!ROOT_KEYS.has(key)) {
348
+ throw graphError(`Unsupported Graph document field: ${key}`, {
349
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: `/${key}`
350
+ });
351
+ }
352
+ }
353
+ if (source.version !== 1) {
354
+ throw graphError('Only Graph document version 1 is supported.', {
355
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: '/version'
356
+ });
357
+ }
358
+ if (!Array.isArray(source.nodes) || !Array.isArray(source.edges)) {
359
+ throw graphError('Graph document nodes and edges must be arrays.', {
360
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: ''
361
+ });
362
+ }
363
+ const inputEndpoint = normalizeEndpoint(source.input, '/input');
364
+ const outputEndpoint = normalizeEndpoint(source.output, '/output');
365
+ const originalNodes = source.nodes.map(normalizeNode);
366
+ const originalEdges = source.edges.map(normalizeEdge);
367
+ const nodeIndexes = new Map(originalNodes.map((node, index) => [node.id, index]));
368
+ const edgeIndexes = new Map(originalEdges.map((edge, index) => [edge.id, index]));
369
+ validateUniqueIds(
370
+ inputEndpoint, outputEndpoint, originalNodes, originalEdges, nodeIndexes, edgeIndexes
371
+ );
372
+ const structural = structuralOrder(
373
+ inputEndpoint, outputEndpoint, originalNodes, originalEdges, nodeIndexes, edgeIndexes
374
+ );
375
+ const nodes = [...originalNodes].sort((a, b) => utf8Compare(a.id, b.id));
376
+ const edges = [...originalEdges].sort((a, b) => utf8Compare(a.id, b.id));
377
+ return {
378
+ document: { version: 1, input: inputEndpoint, output: outputEndpoint, nodes, edges },
379
+ originalNodeIndexes: nodeIndexes,
380
+ originalEdgeIndexes: edgeIndexes,
381
+ structuralOrder: structural.order,
382
+ incoming: structural.incoming,
383
+ outgoing: structural.outgoing
384
+ };
385
+ }
386
+
387
+ export function normalizeGraphDocument(input) {
388
+ return cloneGraphDocument(_normalizeGraphInput(input).document);
389
+ }
390
+
391
+ export function graphStructuralSnapshot(normalized) {
392
+ const state = normalized.document ? normalized : _normalizeGraphInput(normalized);
393
+ const ids = [state.document.input.id, ...state.document.nodes.map(node => node.id), state.document.output.id];
394
+ const incoming = Object.fromEntries(ids.map(id => [
395
+ id,
396
+ (state.incoming.get(id) ?? []).map(edge => edge.id).sort(utf8Compare)
397
+ ]));
398
+ const outgoing = Object.fromEntries(ids.map(id => [
399
+ id,
400
+ (state.outgoing.get(id) ?? []).map(edge => edge.id).sort(utf8Compare)
401
+ ]));
402
+ return deepFreeze({
403
+ document: cloneGraphDocument(state.document),
404
+ topologicalOrder: [...state.structuralOrder],
405
+ incoming,
406
+ outgoing
407
+ });
408
+ }
409
+
410
+ export function graphVisualizationSnapshot(normalized, compileSnapshot = null) {
411
+ const state = normalized.document ? normalized : _normalizeGraphInput(normalized);
412
+ const compiledNodes = new Map(
413
+ (compileSnapshot?.nodes ?? []).map(node => [node.id, node])
414
+ );
415
+ const compiledEdges = new Map(
416
+ (compileSnapshot?.edges ?? []).map(edge => [edge.id, edge])
417
+ );
418
+ return deepFreeze({
419
+ version: 1,
420
+ nodes: [
421
+ { id: state.document.input.id, kind: 'input' },
422
+ ...state.document.nodes.map(node => {
423
+ const compiled = compiledNodes.get(node.id);
424
+ return {
425
+ id: node.id,
426
+ kind: 'effect',
427
+ effectType: node.type,
428
+ enabled: node.enabled,
429
+ ...(compiled ? {
430
+ effective: compiled.effective,
431
+ dormant: compiled.dormant,
432
+ disabledBypass: compiled.disabledBypass
433
+ } : {}),
434
+ state: compiled?.disabledBypass ? 'disabled-bypass'
435
+ : compiled?.effective ? 'effective'
436
+ : compiled?.dormant ? 'dormant'
437
+ : node.enabled ? 'structural' : 'disabled-bypass'
438
+ };
439
+ }),
440
+ { id: state.document.output.id, kind: 'output' }
441
+ ],
442
+ edges: state.document.edges.map(edge => {
443
+ const compiled = compiledEdges.get(edge.id);
444
+ return {
445
+ id: edge.id,
446
+ source: edge.source,
447
+ destination: edge.destination,
448
+ ...(compiled ? {
449
+ active: compiled.active,
450
+ suppressed: compiled.suppressed,
451
+ dormant: compiled.dormant
452
+ } : {}),
453
+ state: compiled?.suppressed ? 'suppressed'
454
+ : compiled?.dormant ? 'dormant'
455
+ : compiled?.active ? 'effective' : 'structural'
456
+ };
457
+ })
458
+ });
459
+ }
460
+
461
+ function availableId(preferred, used) {
462
+ let id = preferred;
463
+ for (let suffix = 2; used.has(id); suffix++) id = `${preferred}-${suffix}`;
464
+ used.add(id);
465
+ return id;
466
+ }
467
+
468
+ export function graphDocumentFromChain(chainInput) {
469
+ const chain = normalizeChainDocument(chainInput);
470
+ for (const [index, node] of chain.chain.entries()) {
471
+ if (node.enabled && node.type === 'IRReverb' &&
472
+ node.parameters.latency > 0 && node.parameters.dryEnabled !== false &&
473
+ node.parameters.dryLevel > -96) {
474
+ throw graphError(
475
+ 'IRReverb must be wet-only in a Graph; turn dryEnabled off or set dryLevel to -96 dB (the parameter minimum), then use the external dry edge.',
476
+ {
477
+ code: 'GRAPH_UNSUPPORTED_CAPABILITY',
478
+ path: `/nodes/${index}/parameters/dryLevel`,
479
+ nodeId: node.id
480
+ }
481
+ );
482
+ }
483
+ }
484
+ const used = new Set(chain.chain.map(effect => effect.id));
485
+ const inputId = availableId('main-input', used);
486
+ const outputId = availableId('main-output', used);
487
+ const edges = [];
488
+ let source = inputId;
489
+ for (const [index, node] of chain.chain.entries()) {
490
+ const id = availableId(`route-${index + 1}`, used);
491
+ edges.push({ id, source, destination: node.id });
492
+ source = node.id;
493
+ }
494
+ if (chain.chain.length > 0) {
495
+ edges.push({
496
+ id: availableId(`route-${chain.chain.length + 1}`, used),
497
+ source,
498
+ destination: outputId
499
+ });
500
+ }
501
+ return normalizeGraphDocument({
502
+ version: 1,
503
+ input: { id: inputId },
504
+ output: { id: outputId },
505
+ nodes: chain.chain,
506
+ edges
507
+ });
508
+ }
509
+
510
+ export function chainDocumentFromGraph(graphInput) {
511
+ const state = graphInput?.document ? graphInput : _normalizeGraphInput(graphInput);
512
+ const { document } = state;
513
+ const identityEdge = edge => edge.gain === 1 && !edge.mute && !edge.solo &&
514
+ edge.mixGroup === 'default' && (edge.pan === undefined || edge.pan === 0);
515
+ if (!document.edges.every(identityEdge)) {
516
+ throw graphError('Only a serial Graph with identity edge controls can be converted to a Chain.', {
517
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY', path: '/edges'
518
+ });
519
+ }
520
+ if (document.nodes.length === 0 && document.edges.length === 0) {
521
+ return { version: 1, chain: [] };
522
+ }
523
+ const bySource = new Map();
524
+ for (const edge of document.edges) {
525
+ if (bySource.has(edge.source)) {
526
+ throw graphError('Only a serial Graph can be converted to a Chain.', {
527
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY',
528
+ path: `/edges/${state.originalEdgeIndexes.get(edge.id)}`,
529
+ edgeId: edge.id
530
+ });
531
+ }
532
+ bySource.set(edge.source, edge);
533
+ }
534
+ const nodesById = new Map(document.nodes.map(node => [node.id, node]));
535
+ const chain = [];
536
+ const visited = new Set();
537
+ let source = document.input.id;
538
+ while (source !== document.output.id) {
539
+ const edge = bySource.get(source);
540
+ if (!edge || visited.has(edge.id)) {
541
+ throw graphError('Only a serial Graph can be converted to a Chain.', {
542
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY', path: ''
543
+ });
544
+ }
545
+ visited.add(edge.id);
546
+ if (edge.destination === document.output.id) {
547
+ source = document.output.id;
548
+ break;
549
+ }
550
+ const node = nodesById.get(edge.destination);
551
+ if (!node) {
552
+ throw graphError('Only a serial Graph can be converted to a Chain.', {
553
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY', path: ''
554
+ });
555
+ }
556
+ chain.push(cloneValue(node));
557
+ source = node.id;
558
+ }
559
+ if (visited.size !== document.edges.length || chain.length !== document.nodes.length) {
560
+ throw graphError('Only a serial Graph can be converted to a Chain.', {
561
+ code: 'GRAPH_DOCUMENT_CONNECTIVITY', path: ''
562
+ });
563
+ }
564
+ return { version: 1, chain };
565
+ }
566
+
567
+ function recipeEffect(effect, nodeId, defaultId) {
568
+ const value = effect instanceof Effect ? effect.toJSON() : cloneValue(effect);
569
+ if (!isRecord(value)) {
570
+ throw graphError('A Graph recipe effect must be an Effect or effect document.', {
571
+ code: 'GRAPH_DOCUMENT_REFERENCE', path: '/nodes/0'
572
+ });
573
+ }
574
+ return { ...value, id: nodeId ?? value.id ?? defaultId };
575
+ }
576
+
577
+ // Every Graph recipe rejects a positive-latency IRReverb that still mixes its own dry signal,
578
+ // because the Graph plans delay compensation around a wet-only node.
579
+ function requireWetOnlyIRReverb(node) {
580
+ if (node.enabled && node.type === 'IRReverb' &&
581
+ node.parameters?.latency > 0 && node.parameters?.dryEnabled !== false &&
582
+ node.parameters?.dryLevel > -96) {
583
+ throw graphError(
584
+ 'IRReverb must be wet-only in a Graph; turn dryEnabled off or set dryLevel to -96 dB (the parameter minimum), then use the external dry edge.',
585
+ {
586
+ code: 'GRAPH_UNSUPPORTED_CAPABILITY',
587
+ path: '/nodes/0/parameters/dryLevel',
588
+ nodeId: node.id
589
+ }
590
+ );
591
+ }
592
+ }
593
+
594
+ export function createWetDryGraphDocument(effect, {
595
+ wet = 1,
596
+ dry = 1,
597
+ nodeId,
598
+ inputId = 'input',
599
+ outputId = 'output'
600
+ } = {}) {
601
+ const node = normalizeNode(recipeEffect(effect, nodeId, 'wet'), 0);
602
+ requireWetOnlyIRReverb(node);
603
+ const used = new Set([inputId, outputId, node.id]);
604
+ const dryId = availableId('dry', used);
605
+ const wetInputId = availableId('wet-input', used);
606
+ const wetOutputId = availableId('wet-output', used);
607
+ return normalizeGraphDocument({
608
+ version: 1,
609
+ input: { id: inputId },
610
+ output: { id: outputId },
611
+ nodes: [node],
612
+ edges: [
613
+ { id: dryId, source: inputId, destination: outputId, gain: dry, mixGroup: 'main' },
614
+ { id: wetInputId, source: inputId, destination: node.id },
615
+ { id: wetOutputId, source: node.id, destination: outputId, gain: wet, mixGroup: 'main' }
616
+ ]
617
+ });
618
+ }
619
+
620
+ export function createSendReturnGraphDocument(effect, {
621
+ send = 1,
622
+ returnGain = 1,
623
+ nodeId,
624
+ inputId = 'input',
625
+ outputId = 'output'
626
+ } = {}) {
627
+ const node = normalizeNode(recipeEffect(effect, nodeId, 'return'), 0);
628
+ requireWetOnlyIRReverb(node);
629
+ const used = new Set([inputId, outputId, node.id]);
630
+ const mainId = availableId('main', used);
631
+ const sendId = availableId('send', used);
632
+ const returnId = availableId('return', used);
633
+ return normalizeGraphDocument({
634
+ version: 1,
635
+ input: { id: inputId },
636
+ output: { id: outputId },
637
+ nodes: [node],
638
+ edges: [
639
+ { id: mainId, source: inputId, destination: outputId, mixGroup: 'main' },
640
+ { id: sendId, source: inputId, destination: node.id, gain: send },
641
+ { id: returnId, source: node.id, destination: outputId, gain: returnGain, mixGroup: 'main' }
642
+ ]
643
+ });
644
+ }