@effetune/dsp 0.0.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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/THIRD_PARTY_NOTICES.txt +11 -0
- package/dist/artifacts.js +185 -0
- package/dist/assets/NOTICE.txt +30 -0
- package/dist/assets/effetune-dsp.meta.json +502 -0
- package/dist/assets/effetune-dsp.simd.wasm +0 -0
- package/dist/assets/effetune-dsp.wasm +0 -0
- package/dist/assets.js +767 -0
- package/dist/catalog/effects-v1.json +5369 -0
- package/dist/catalog-entry.d.ts +9 -0
- package/dist/catalog-entry.js +5 -0
- package/dist/catalog.js +50 -0
- package/dist/effect.d.ts +23 -0
- package/dist/effect.js +167 -0
- package/dist/engine.js +291 -0
- package/dist/errors.js +31 -0
- package/dist/generated-effects.d.ts +1460 -0
- package/dist/generated-effects.js +1007 -0
- package/dist/index.d.ts +512 -0
- package/dist/index.js +175 -0
- package/dist/internal/dsp-engine-binding.js +859 -0
- package/dist/internal/dsp-params.generated.js +1379 -0
- package/dist/internal/dsp-wasm-loader.js +346 -0
- package/dist/internal/ir-asset-payload.js +98 -0
- package/dist/internal/ir-plugin-contract.js +265 -0
- package/dist/preset.js +518 -0
- package/dist/runtime.js +488 -0
- package/dist/schemas/bundle-v1.schema.json +223 -0
- package/dist/schemas/chain-v1.schema.json +6530 -0
- package/dist/semantics.js +309 -0
- package/dist/telemetry.js +322 -0
- package/dist/worklet-processor.js +175 -0
- package/dist/worklet.d.ts +32 -0
- package/dist/worklet.js +386 -0
- package/package.json +53 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { loadDspArtifact } from './artifacts.js';
|
|
2
|
+
import { isBundleDocument, resolveChainAssets, splitBundle } from './assets.js';
|
|
3
|
+
import { createEngineSession } from './engine.js';
|
|
4
|
+
import { StateError, ValidationError } from './errors.js';
|
|
5
|
+
import {
|
|
6
|
+
normalizeChainDocument,
|
|
7
|
+
channelRange,
|
|
8
|
+
packEffect,
|
|
9
|
+
setEffectParameter,
|
|
10
|
+
validateStreamParameterUpdate,
|
|
11
|
+
validateEffectSampleRate,
|
|
12
|
+
validateSampleRate,
|
|
13
|
+
validateSeed
|
|
14
|
+
} from './semantics.js';
|
|
15
|
+
|
|
16
|
+
const PARAMETER_EVENT_KEYS = new Set(['frame', 'effectId', 'parameters']);
|
|
17
|
+
|
|
18
|
+
function validateAudio(audio) {
|
|
19
|
+
if (!Array.isArray(audio) || audio.length < 1 || audio.length > 8) {
|
|
20
|
+
throw new ValidationError('Audio must be an array containing between 1 and 8 Float32Array channels.');
|
|
21
|
+
}
|
|
22
|
+
const frames = audio[0] instanceof Float32Array ? audio[0].length : -1;
|
|
23
|
+
for (const channel of audio) {
|
|
24
|
+
if (!(channel instanceof Float32Array) || channel.length !== frames) {
|
|
25
|
+
throw new ValidationError('Audio channels must be equally sized Float32Array values.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const channel of audio) {
|
|
29
|
+
for (let frame = 0; frame < frames; frame++) {
|
|
30
|
+
if (!Number.isFinite(channel[frame])) {
|
|
31
|
+
throw new ValidationError('Audio samples must all be finite.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { channels: audio.length, frames };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateBlockSize(value = 128) {
|
|
39
|
+
if (!Number.isInteger(value) || value < 1 || value > 16384) {
|
|
40
|
+
throw new ValidationError('blockSize must be an integer from 1 to 16384.');
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function activeEffects(document) {
|
|
46
|
+
return document.chain.filter(effect => effect.enabled);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cloneDocument(document) {
|
|
50
|
+
return {
|
|
51
|
+
version: 1,
|
|
52
|
+
chain: document.chain.map(effect => ({
|
|
53
|
+
...effect,
|
|
54
|
+
parameters: Object.fromEntries(
|
|
55
|
+
Object.entries(effect.parameters).map(([name, value]) => [
|
|
56
|
+
name,
|
|
57
|
+
Array.isArray(value) ? [...value] : value
|
|
58
|
+
])
|
|
59
|
+
),
|
|
60
|
+
...(effect.assets ? { assets: { ...effect.assets } } : {})
|
|
61
|
+
}))
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isRecord(value) {
|
|
66
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function effectIndex(document, effectId) {
|
|
70
|
+
if (typeof effectId !== 'string' || effectId.length === 0) {
|
|
71
|
+
throw new ValidationError('effectId must be a non-empty string.');
|
|
72
|
+
}
|
|
73
|
+
const index = document.chain.findIndex(effect => effect.id === effectId);
|
|
74
|
+
if (index < 0) throw new ValidationError(`Unknown effect id: ${effectId}`);
|
|
75
|
+
if (!document.chain[index].enabled) {
|
|
76
|
+
throw new ValidationError(`Effect ${effectId} is disabled.`);
|
|
77
|
+
}
|
|
78
|
+
return index;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function updateEffectParameters(effect, parameters) {
|
|
82
|
+
if (!isRecord(parameters)) {
|
|
83
|
+
throw new ValidationError('Event parameters must be an object.');
|
|
84
|
+
}
|
|
85
|
+
let updated = effect;
|
|
86
|
+
for (const [name, value] of Object.entries(parameters)) {
|
|
87
|
+
validateStreamParameterUpdate(updated, name);
|
|
88
|
+
updated = setEffectParameter(updated, name, value);
|
|
89
|
+
}
|
|
90
|
+
return updated;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function replaceEffect(document, index, effect) {
|
|
94
|
+
const chain = [...document.chain];
|
|
95
|
+
chain[index] = effect;
|
|
96
|
+
return { version: 1, chain };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateParameterEvents(events, document, frameCount) {
|
|
100
|
+
if (events === undefined) return [];
|
|
101
|
+
if (!Array.isArray(events)) {
|
|
102
|
+
throw new ValidationError('events must be an array.');
|
|
103
|
+
}
|
|
104
|
+
let workingDocument = cloneDocument(document);
|
|
105
|
+
let previousFrame = -1;
|
|
106
|
+
return events.map((event, eventIndex) => {
|
|
107
|
+
if (!isRecord(event)) {
|
|
108
|
+
throw new ValidationError(`Event ${eventIndex} must be an object.`);
|
|
109
|
+
}
|
|
110
|
+
for (const key of Object.keys(event)) {
|
|
111
|
+
if (!PARAMETER_EVENT_KEYS.has(key)) {
|
|
112
|
+
throw new ValidationError(`Event ${eventIndex} has an unsupported field: ${key}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isInteger(event.frame) || event.frame < 0 || event.frame >= frameCount) {
|
|
116
|
+
throw new ValidationError(
|
|
117
|
+
`Event ${eventIndex} frame must be an integer from 0 to ${Math.max(0, frameCount - 1)}.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (event.frame < previousFrame) {
|
|
121
|
+
throw new ValidationError('events must be ordered by non-decreasing frame.');
|
|
122
|
+
}
|
|
123
|
+
previousFrame = event.frame;
|
|
124
|
+
const index = effectIndex(workingDocument, event.effectId);
|
|
125
|
+
const effect = updateEffectParameters(workingDocument.chain[index], event.parameters);
|
|
126
|
+
workingDocument = replaceEffect(workingDocument, index, effect);
|
|
127
|
+
return {
|
|
128
|
+
frame: event.frame,
|
|
129
|
+
effectId: event.effectId,
|
|
130
|
+
effect,
|
|
131
|
+
packed: packEffect(effect)
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class ChainStream {
|
|
137
|
+
constructor(document, session, {
|
|
138
|
+
sampleRate,
|
|
139
|
+
channels,
|
|
140
|
+
blockSize,
|
|
141
|
+
onTelemetry
|
|
142
|
+
}) {
|
|
143
|
+
this._initialDocument = cloneDocument(document);
|
|
144
|
+
this._document = cloneDocument(document);
|
|
145
|
+
this._session = session;
|
|
146
|
+
this._sampleRate = sampleRate;
|
|
147
|
+
this._channels = channels;
|
|
148
|
+
this._blockSize = blockSize;
|
|
149
|
+
this._processedFrames = 0;
|
|
150
|
+
this._closed = false;
|
|
151
|
+
this._emptyTelemetryCallbacks = new Set();
|
|
152
|
+
if (onTelemetry !== undefined) this.subscribe(onTelemetry);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get preset() {
|
|
156
|
+
this._assertOpen();
|
|
157
|
+
return cloneDocument(this._document);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get effects() {
|
|
161
|
+
return this.preset.chain;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get droppedTelemetryFrames() {
|
|
165
|
+
this._assertOpen();
|
|
166
|
+
return this._session?.droppedTelemetryFrames ?? 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get latencySamples() {
|
|
170
|
+
this._assertOpen();
|
|
171
|
+
return this._session?.latencySamples ?? 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_assertOpen() {
|
|
175
|
+
if (this._closed) throw new StateError('The stream is closed.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setParam(effectId, parameterName, value) {
|
|
179
|
+
this._assertOpen();
|
|
180
|
+
const index = effectIndex(this._document, effectId);
|
|
181
|
+
validateStreamParameterUpdate(this._document.chain[index], parameterName);
|
|
182
|
+
const effect = setEffectParameter(this._document.chain[index], parameterName, value);
|
|
183
|
+
const packed = packEffect(effect);
|
|
184
|
+
this._session?.setPacked(effectId, packed.values, packed.hash, packed.bytes);
|
|
185
|
+
this._document = replaceEffect(this._document, index, effect);
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
subscribe(callback) {
|
|
190
|
+
this._assertOpen();
|
|
191
|
+
if (typeof callback !== 'function') {
|
|
192
|
+
throw new TypeError('Telemetry callback must be a function.');
|
|
193
|
+
}
|
|
194
|
+
if (this._session) return this._session.subscribe(callback);
|
|
195
|
+
this._emptyTelemetryCallbacks.add(callback);
|
|
196
|
+
return () => this.unsubscribe(callback);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
unsubscribe(callback) {
|
|
200
|
+
this._assertOpen();
|
|
201
|
+
return this._session
|
|
202
|
+
? this._session.unsubscribe(callback)
|
|
203
|
+
: this._emptyTelemetryCallbacks.delete(callback);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async process(audio, { events } = {}) {
|
|
207
|
+
this._assertOpen();
|
|
208
|
+
const layout = validateAudio(audio);
|
|
209
|
+
if (layout.channels !== this._channels) {
|
|
210
|
+
throw new ValidationError(
|
|
211
|
+
`Audio has ${layout.channels} channel(s); this stream requires ${this._channels}.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const parameterEvents = validateParameterEvents(events, this._document, layout.frames);
|
|
215
|
+
const output = audio.map(channel => new Float32Array(channel));
|
|
216
|
+
if (layout.frames === 0) return output;
|
|
217
|
+
|
|
218
|
+
let offset = 0;
|
|
219
|
+
let eventIndex = 0;
|
|
220
|
+
while (offset < layout.frames) {
|
|
221
|
+
while (parameterEvents[eventIndex]?.frame === offset) {
|
|
222
|
+
const event = parameterEvents[eventIndex];
|
|
223
|
+
this._session?.setPacked(
|
|
224
|
+
event.effectId,
|
|
225
|
+
event.packed.values,
|
|
226
|
+
event.packed.hash,
|
|
227
|
+
event.packed.bytes
|
|
228
|
+
);
|
|
229
|
+
const index = effectIndex(this._document, event.effectId);
|
|
230
|
+
this._document = replaceEffect(this._document, index, event.effect);
|
|
231
|
+
eventIndex += 1;
|
|
232
|
+
}
|
|
233
|
+
const nextEventFrame = parameterEvents[eventIndex]?.frame ?? layout.frames;
|
|
234
|
+
const frameCount = Math.min(
|
|
235
|
+
this._blockSize,
|
|
236
|
+
nextEventFrame - offset,
|
|
237
|
+
layout.frames - offset
|
|
238
|
+
);
|
|
239
|
+
this._session?.process(
|
|
240
|
+
audio,
|
|
241
|
+
output,
|
|
242
|
+
offset,
|
|
243
|
+
frameCount,
|
|
244
|
+
this._sampleRate,
|
|
245
|
+
this._processedFrames + offset
|
|
246
|
+
);
|
|
247
|
+
offset += frameCount;
|
|
248
|
+
}
|
|
249
|
+
this._processedFrames += layout.frames;
|
|
250
|
+
return output;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
reset() {
|
|
254
|
+
this._assertOpen();
|
|
255
|
+
this._session?.reset();
|
|
256
|
+
this._document = cloneDocument(this._initialDocument);
|
|
257
|
+
this._processedFrames = 0;
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
close() {
|
|
262
|
+
if (this._closed) return;
|
|
263
|
+
this._closed = true;
|
|
264
|
+
this._emptyTelemetryCallbacks.clear();
|
|
265
|
+
this._session?.close();
|
|
266
|
+
this._session = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export class Chain {
|
|
271
|
+
constructor(document, resolvedAssets, artifact, artifactOptions) {
|
|
272
|
+
this._document = document;
|
|
273
|
+
this._resolvedAssets = resolvedAssets;
|
|
274
|
+
this._artifact = artifact;
|
|
275
|
+
this._artifactOptions = artifactOptions;
|
|
276
|
+
this._closed = false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get preset() {
|
|
280
|
+
this._assertOpen();
|
|
281
|
+
return cloneDocument(this._document);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
get effects() {
|
|
285
|
+
return this.preset.chain;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_assertOpen() {
|
|
289
|
+
if (this._closed) throw new StateError('The chain is closed.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
setParam(effectId, parameterName, value) {
|
|
293
|
+
this._assertOpen();
|
|
294
|
+
if (typeof effectId !== 'string' || effectId.length === 0) {
|
|
295
|
+
throw new ValidationError('effectId must be a non-empty string.');
|
|
296
|
+
}
|
|
297
|
+
const index = this._document.chain.findIndex(effect => effect.id === effectId);
|
|
298
|
+
if (index < 0) throw new ValidationError(`Unknown effect id: ${effectId}`);
|
|
299
|
+
const effects = [...this._document.chain];
|
|
300
|
+
effects[index] = setEffectParameter(effects[index], parameterName, value);
|
|
301
|
+
this._document = { version: 1, chain: effects };
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
reset() {
|
|
306
|
+
this._assertOpen();
|
|
307
|
+
// Offline calls always create fresh state, so there is no retained state to reset.
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async _artifactForCurrentDocument() {
|
|
312
|
+
if (activeEffects(this._document).length === 0) return null;
|
|
313
|
+
if (!this._artifact) this._artifact = await loadDspArtifact(this._artifactOptions);
|
|
314
|
+
return this._artifact;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async prewarm({
|
|
318
|
+
sampleRate,
|
|
319
|
+
channels = 2,
|
|
320
|
+
blockSize = 128,
|
|
321
|
+
seed = 0
|
|
322
|
+
} = {}) {
|
|
323
|
+
this._assertOpen();
|
|
324
|
+
const rate = validateSampleRate(sampleRate);
|
|
325
|
+
if (!Number.isInteger(channels) || channels < 1 || channels > 8) {
|
|
326
|
+
throw new ValidationError('channels must be an integer from 1 to 8.');
|
|
327
|
+
}
|
|
328
|
+
const frames = validateBlockSize(blockSize);
|
|
329
|
+
const normalizedSeed = validateSeed(seed);
|
|
330
|
+
for (const effect of activeEffects(this._document)) {
|
|
331
|
+
validateEffectSampleRate(effect, rate);
|
|
332
|
+
channelRange(effect.channel, channels);
|
|
333
|
+
}
|
|
334
|
+
const artifact = await this._artifactForCurrentDocument();
|
|
335
|
+
if (!artifact) return this;
|
|
336
|
+
const session = await createEngineSession(
|
|
337
|
+
artifact,
|
|
338
|
+
this._document.chain,
|
|
339
|
+
this._resolvedAssets,
|
|
340
|
+
{ sampleRate: rate, channels, maxFrames: Math.max(128, frames), seed: normalizedSeed }
|
|
341
|
+
);
|
|
342
|
+
session.close();
|
|
343
|
+
return this;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async latencySamples({
|
|
347
|
+
sampleRate,
|
|
348
|
+
channels = 2,
|
|
349
|
+
blockSize = 128
|
|
350
|
+
} = {}) {
|
|
351
|
+
this._assertOpen();
|
|
352
|
+
const stream = await this.stream({ sampleRate, channels, blockSize });
|
|
353
|
+
try {
|
|
354
|
+
return stream.latencySamples;
|
|
355
|
+
} finally {
|
|
356
|
+
stream.close();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async stream({
|
|
361
|
+
sampleRate,
|
|
362
|
+
channels = 2,
|
|
363
|
+
blockSize = 128,
|
|
364
|
+
seed = 0,
|
|
365
|
+
onTelemetry
|
|
366
|
+
} = {}) {
|
|
367
|
+
this._assertOpen();
|
|
368
|
+
const rate = validateSampleRate(sampleRate);
|
|
369
|
+
if (!Number.isInteger(channels) || channels < 1 || channels > 8) {
|
|
370
|
+
throw new ValidationError('channels must be an integer from 1 to 8.');
|
|
371
|
+
}
|
|
372
|
+
const framesPerBlock = validateBlockSize(blockSize);
|
|
373
|
+
const normalizedSeed = validateSeed(seed);
|
|
374
|
+
if (onTelemetry !== undefined && typeof onTelemetry !== 'function') {
|
|
375
|
+
throw new TypeError('onTelemetry must be a function.');
|
|
376
|
+
}
|
|
377
|
+
for (const effect of activeEffects(this._document)) {
|
|
378
|
+
validateEffectSampleRate(effect, rate);
|
|
379
|
+
channelRange(effect.channel, channels);
|
|
380
|
+
}
|
|
381
|
+
const artifact = await this._artifactForCurrentDocument();
|
|
382
|
+
const document = cloneDocument(this._document);
|
|
383
|
+
const session = artifact
|
|
384
|
+
? await createEngineSession(
|
|
385
|
+
artifact,
|
|
386
|
+
document.chain,
|
|
387
|
+
this._resolvedAssets,
|
|
388
|
+
{
|
|
389
|
+
sampleRate: rate,
|
|
390
|
+
channels,
|
|
391
|
+
maxFrames: Math.max(128, framesPerBlock),
|
|
392
|
+
seed: normalizedSeed
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
: null;
|
|
396
|
+
return new ChainStream(document, session, {
|
|
397
|
+
sampleRate: rate,
|
|
398
|
+
channels,
|
|
399
|
+
blockSize: framesPerBlock,
|
|
400
|
+
onTelemetry
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async process(audio, {
|
|
405
|
+
sampleRate,
|
|
406
|
+
seed = 0,
|
|
407
|
+
blockSize = 128,
|
|
408
|
+
onTelemetry
|
|
409
|
+
} = {}) {
|
|
410
|
+
this._assertOpen();
|
|
411
|
+
const layout = validateAudio(audio);
|
|
412
|
+
const rate = validateSampleRate(sampleRate);
|
|
413
|
+
const normalizedSeed = validateSeed(seed);
|
|
414
|
+
const framesPerBlock = validateBlockSize(blockSize);
|
|
415
|
+
if (onTelemetry !== undefined && typeof onTelemetry !== 'function') {
|
|
416
|
+
throw new TypeError('onTelemetry must be a function.');
|
|
417
|
+
}
|
|
418
|
+
const output = audio.map(channel => new Float32Array(channel));
|
|
419
|
+
const effects = activeEffects(this._document);
|
|
420
|
+
if (layout.frames === 0 || effects.length === 0) return output;
|
|
421
|
+
for (const effect of effects) {
|
|
422
|
+
validateEffectSampleRate(effect, rate);
|
|
423
|
+
channelRange(effect.channel, layout.channels);
|
|
424
|
+
}
|
|
425
|
+
const artifact = await this._artifactForCurrentDocument();
|
|
426
|
+
const session = await createEngineSession(
|
|
427
|
+
artifact,
|
|
428
|
+
this._document.chain,
|
|
429
|
+
this._resolvedAssets,
|
|
430
|
+
{
|
|
431
|
+
sampleRate: rate,
|
|
432
|
+
channels: layout.channels,
|
|
433
|
+
maxFrames: Math.max(128, Math.min(framesPerBlock, layout.frames)),
|
|
434
|
+
seed: normalizedSeed
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
try {
|
|
438
|
+
if (onTelemetry) session.subscribe(onTelemetry);
|
|
439
|
+
for (let offset = 0; offset < layout.frames; offset += framesPerBlock) {
|
|
440
|
+
const frameCount = Math.min(framesPerBlock, layout.frames - offset);
|
|
441
|
+
session.process(audio, output, offset, frameCount, rate);
|
|
442
|
+
}
|
|
443
|
+
} finally {
|
|
444
|
+
session.close();
|
|
445
|
+
}
|
|
446
|
+
return output;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
close() {
|
|
450
|
+
if (this._closed) return;
|
|
451
|
+
this._closed = true;
|
|
452
|
+
this._artifact = null;
|
|
453
|
+
this._resolvedAssets.clear();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function createChain(input, options = {}) {
|
|
458
|
+
const source = typeof input === 'string'
|
|
459
|
+
? (() => {
|
|
460
|
+
try {
|
|
461
|
+
return JSON.parse(input);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
throw new ValidationError('Chain input is not valid JSON.', { cause: error });
|
|
464
|
+
}
|
|
465
|
+
})()
|
|
466
|
+
: input;
|
|
467
|
+
const { chain, manifest } = splitBundle(source);
|
|
468
|
+
const document = normalizeChainDocument(chain);
|
|
469
|
+
const resolvedAssets = await resolveChainAssets(document, {
|
|
470
|
+
assetResolver: options.assetResolver,
|
|
471
|
+
manifest
|
|
472
|
+
});
|
|
473
|
+
const artifactOptions = {
|
|
474
|
+
variant: options.variant,
|
|
475
|
+
wasmUrl: options.wasmUrl,
|
|
476
|
+
simdWasmUrl: options.simdWasmUrl,
|
|
477
|
+
metaUrl: options.metaUrl,
|
|
478
|
+
fetch: options.fetch,
|
|
479
|
+
webAssembly: options.webAssembly,
|
|
480
|
+
cache: options.cache
|
|
481
|
+
};
|
|
482
|
+
const artifact = activeEffects(document).length > 0
|
|
483
|
+
? await loadDspArtifact(artifactOptions)
|
|
484
|
+
: null;
|
|
485
|
+
return new Chain(document, resolvedAssets, artifact, artifactOptions);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export { isBundleDocument };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://effetune.frieve.com/dsp/schemas/bundle-v1.schema.json",
|
|
4
|
+
"title": "EffeTune DSP Bundle v1",
|
|
5
|
+
"description": "Manifest form for a canonical chain and bounded external impulse-response assets. Container and transport are outside this schema.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"version",
|
|
10
|
+
"chain",
|
|
11
|
+
"assets"
|
|
12
|
+
],
|
|
13
|
+
"properties": {
|
|
14
|
+
"version": {
|
|
15
|
+
"type": "integer",
|
|
16
|
+
"const": 1
|
|
17
|
+
},
|
|
18
|
+
"chain": {
|
|
19
|
+
"$ref": "https://effetune.frieve.com/dsp/schemas/chain-v1.schema.json"
|
|
20
|
+
},
|
|
21
|
+
"assets": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"maxItems": 64,
|
|
24
|
+
"items": {
|
|
25
|
+
"$ref": "#/$defs/asset"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"$comment": "Duplicate asset IDs, missing chain references, digest verification, and byteLength === 32 + pathCount*12 + channels*frames*4 are runtime validation responsibilities.",
|
|
30
|
+
"$defs": {
|
|
31
|
+
"asset": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": false,
|
|
34
|
+
"required": [
|
|
35
|
+
"id",
|
|
36
|
+
"kind",
|
|
37
|
+
"reference",
|
|
38
|
+
"sha256",
|
|
39
|
+
"byteLength",
|
|
40
|
+
"format"
|
|
41
|
+
],
|
|
42
|
+
"properties": {
|
|
43
|
+
"id": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"minLength": 1,
|
|
46
|
+
"maxLength": 128
|
|
47
|
+
},
|
|
48
|
+
"kind": {
|
|
49
|
+
"const": "impulseResponse"
|
|
50
|
+
},
|
|
51
|
+
"reference": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"minLength": 1,
|
|
54
|
+
"maxLength": 2048,
|
|
55
|
+
"description": "Opaque resolver reference. The bundle schema does not require ZIP or filesystem paths."
|
|
56
|
+
},
|
|
57
|
+
"sha256": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"pattern": "^[0-9a-f]{64}$"
|
|
60
|
+
},
|
|
61
|
+
"byteLength": {
|
|
62
|
+
"type": "integer",
|
|
63
|
+
"minimum": 36,
|
|
64
|
+
"maximum": 33554432
|
|
65
|
+
},
|
|
66
|
+
"format": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"additionalProperties": false,
|
|
69
|
+
"required": [
|
|
70
|
+
"formatTag",
|
|
71
|
+
"magic",
|
|
72
|
+
"headerBytes",
|
|
73
|
+
"pathRecordBytes",
|
|
74
|
+
"sampleType",
|
|
75
|
+
"byteOrder",
|
|
76
|
+
"layout",
|
|
77
|
+
"channels",
|
|
78
|
+
"frames",
|
|
79
|
+
"sampleRate",
|
|
80
|
+
"topology",
|
|
81
|
+
"pathCount",
|
|
82
|
+
"reservedBytes"
|
|
83
|
+
],
|
|
84
|
+
"properties": {
|
|
85
|
+
"formatTag": {
|
|
86
|
+
"type": "integer",
|
|
87
|
+
"const": 1,
|
|
88
|
+
"description": "ET_ASSET_F32_MULTICH format tag."
|
|
89
|
+
},
|
|
90
|
+
"magic": {
|
|
91
|
+
"const": "ETA1",
|
|
92
|
+
"description": "ASCII bytes stored at payload offsets 0..3."
|
|
93
|
+
},
|
|
94
|
+
"headerBytes": {
|
|
95
|
+
"type": "integer",
|
|
96
|
+
"const": 32
|
|
97
|
+
},
|
|
98
|
+
"pathRecordBytes": {
|
|
99
|
+
"type": "integer",
|
|
100
|
+
"const": 12
|
|
101
|
+
},
|
|
102
|
+
"reservedBytes": {
|
|
103
|
+
"type": "integer",
|
|
104
|
+
"const": 8,
|
|
105
|
+
"description": "Zero-filled payload header bytes at offsets 24..31."
|
|
106
|
+
},
|
|
107
|
+
"sampleType": {
|
|
108
|
+
"const": "float32"
|
|
109
|
+
},
|
|
110
|
+
"byteOrder": {
|
|
111
|
+
"const": "little-endian"
|
|
112
|
+
},
|
|
113
|
+
"layout": {
|
|
114
|
+
"const": "planar"
|
|
115
|
+
},
|
|
116
|
+
"channels": {
|
|
117
|
+
"type": "integer",
|
|
118
|
+
"minimum": 1,
|
|
119
|
+
"maximum": 8
|
|
120
|
+
},
|
|
121
|
+
"frames": {
|
|
122
|
+
"type": "integer",
|
|
123
|
+
"minimum": 1,
|
|
124
|
+
"maximum": 8388600
|
|
125
|
+
},
|
|
126
|
+
"sampleRate": {
|
|
127
|
+
"type": "integer",
|
|
128
|
+
"minimum": 1,
|
|
129
|
+
"maximum": 4294967295
|
|
130
|
+
},
|
|
131
|
+
"topology": {
|
|
132
|
+
"type": "string",
|
|
133
|
+
"enum": [
|
|
134
|
+
"unspecified",
|
|
135
|
+
"mono",
|
|
136
|
+
"independent",
|
|
137
|
+
"trueStereo",
|
|
138
|
+
"matrix"
|
|
139
|
+
],
|
|
140
|
+
"description": "Decoded uint32 header tag: unspecified=0, mono=1, independent=2, trueStereo=3, matrix=4."
|
|
141
|
+
},
|
|
142
|
+
"pathCount": {
|
|
143
|
+
"type": "integer",
|
|
144
|
+
"minimum": 0,
|
|
145
|
+
"maximum": 8
|
|
146
|
+
},
|
|
147
|
+
"paths": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"minItems": 1,
|
|
150
|
+
"maxItems": 8,
|
|
151
|
+
"description": "Matrix routes. Distinct inputSlot values must be exactly 0..N-1, and N must not exceed the selected effect processing-channel count.",
|
|
152
|
+
"items": {
|
|
153
|
+
"type": "object",
|
|
154
|
+
"additionalProperties": false,
|
|
155
|
+
"required": [
|
|
156
|
+
"inputSlot",
|
|
157
|
+
"outputSlot",
|
|
158
|
+
"irChannel"
|
|
159
|
+
],
|
|
160
|
+
"properties": {
|
|
161
|
+
"inputSlot": {
|
|
162
|
+
"type": "integer",
|
|
163
|
+
"minimum": 0,
|
|
164
|
+
"maximum": 7,
|
|
165
|
+
"description": "Zero-based matrix input; the set of input slots must be contiguous from 0."
|
|
166
|
+
},
|
|
167
|
+
"outputSlot": {
|
|
168
|
+
"type": "integer",
|
|
169
|
+
"minimum": 0,
|
|
170
|
+
"maximum": 7
|
|
171
|
+
},
|
|
172
|
+
"irChannel": {
|
|
173
|
+
"type": "integer",
|
|
174
|
+
"minimum": 0,
|
|
175
|
+
"maximum": 7
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
"allOf": [
|
|
182
|
+
{
|
|
183
|
+
"if": {
|
|
184
|
+
"properties": {
|
|
185
|
+
"topology": {
|
|
186
|
+
"const": "matrix"
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"required": [
|
|
190
|
+
"topology"
|
|
191
|
+
]
|
|
192
|
+
},
|
|
193
|
+
"then": {
|
|
194
|
+
"required": [
|
|
195
|
+
"paths"
|
|
196
|
+
],
|
|
197
|
+
"properties": {
|
|
198
|
+
"pathCount": {
|
|
199
|
+
"type": "integer",
|
|
200
|
+
"minimum": 1,
|
|
201
|
+
"maximum": 8
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"else": {
|
|
206
|
+
"properties": {
|
|
207
|
+
"pathCount": {
|
|
208
|
+
"const": 0
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"not": {
|
|
212
|
+
"required": [
|
|
213
|
+
"paths"
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|