@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/dist/assets.js ADDED
@@ -0,0 +1,767 @@
1
+ import {
2
+ buildIrAssetPayload,
3
+ IR_ASSET_FORMAT_TAG,
4
+ IR_ASSET_HEADER_BYTES,
5
+ IR_ASSET_TOPOLOGY
6
+ } from './internal/ir-asset-payload.js';
7
+ import {
8
+ estimateIrKernelCommitFootprint,
9
+ IR_KERNEL_ASSET_CAPACITY_BYTES,
10
+ resolveIrProcessingConfig
11
+ } from './internal/ir-plugin-contract.js';
12
+ import { AssetError, ValidationError } from './errors.js';
13
+ import { channelRange, channelToEngine, requireResolvedAsset } from './semantics.js';
14
+
15
+ const MAX_ASSET_BYTES = 32 * 1024 * 1024;
16
+ const TOPOLOGY_NAMES = new Map([
17
+ ['unspecified', IR_ASSET_TOPOLOGY.unspecified],
18
+ ['mono', IR_ASSET_TOPOLOGY.mono],
19
+ ['independent', IR_ASSET_TOPOLOGY.independent],
20
+ ['trueStereo', IR_ASSET_TOPOLOGY.trueStereo],
21
+ ['matrix', IR_ASSET_TOPOLOGY.matrix]
22
+ ]);
23
+
24
+ function isRecord(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+
28
+ function byteView(value) {
29
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
30
+ if (ArrayBuffer.isView(value)) {
31
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
32
+ }
33
+ return null;
34
+ }
35
+
36
+ async function cancelResponseBody(body, reader = null) {
37
+ try {
38
+ if (reader) await reader.cancel();
39
+ else if (typeof body?.cancel === 'function') await body.cancel();
40
+ } catch {
41
+ // Cancellation is best-effort after the public asset error is determined.
42
+ }
43
+ }
44
+
45
+ function responseContentLength(response, reference) {
46
+ let value;
47
+ try {
48
+ value = response.headers?.get?.('content-length');
49
+ } catch (error) {
50
+ throw new AssetError(`Asset ${reference} has unreadable response headers.`, { cause: error });
51
+ }
52
+ if (value === null || value === undefined) return null;
53
+ if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) {
54
+ throw new AssetError(`Asset ${reference} has an invalid Content-Length header.`);
55
+ }
56
+ const length = Number(value);
57
+ if (!Number.isSafeInteger(length)) {
58
+ throw new AssetError(`Asset ${reference} has an invalid Content-Length header.`);
59
+ }
60
+ return length;
61
+ }
62
+
63
+ async function readBoundedResponse(response, reference, expectedLength = null) {
64
+ const limit = expectedLength ?? MAX_ASSET_BYTES;
65
+ const contentLength = responseContentLength(response, reference);
66
+ if (contentLength !== null && contentLength > limit) {
67
+ await cancelResponseBody(response.body);
68
+ throw new AssetError(`Asset ${reference} exceeds its permitted byte length.`);
69
+ }
70
+ if (contentLength !== null &&
71
+ expectedLength !== null &&
72
+ contentLength !== expectedLength) {
73
+ await cancelResponseBody(response.body);
74
+ throw new AssetError(`Asset ${reference} does not match its declared byte length.`);
75
+ }
76
+ const reader = response.body?.getReader?.();
77
+ if (!reader) {
78
+ throw new AssetError(`Asset ${reference} requires a readable response body.`);
79
+ }
80
+
81
+ const fixedLength = expectedLength ?? contentLength;
82
+ let bytes = new Uint8Array(fixedLength ?? 0);
83
+ let length = 0;
84
+ try {
85
+ for (;;) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+ const chunk = byteView(value);
89
+ if (!chunk) {
90
+ await cancelResponseBody(response.body, reader);
91
+ throw new AssetError(`Asset ${reference} returned a non-binary response chunk.`);
92
+ }
93
+ if (chunk.byteLength > limit - length) {
94
+ await cancelResponseBody(response.body, reader);
95
+ throw new AssetError(`Asset ${reference} exceeds its permitted byte length.`);
96
+ }
97
+ const requiredLength = length + chunk.byteLength;
98
+ if (fixedLength !== null && requiredLength > fixedLength) {
99
+ await cancelResponseBody(response.body, reader);
100
+ throw new AssetError(`Asset ${reference} does not match its Content-Length header.`);
101
+ }
102
+ if (fixedLength === null && requiredLength > bytes.byteLength) {
103
+ let capacity = bytes.byteLength === 0 ? 4096 : bytes.byteLength;
104
+ while (capacity < requiredLength) {
105
+ const doubled = capacity * 2;
106
+ capacity = doubled > limit ? limit : doubled;
107
+ }
108
+ const grown = new Uint8Array(capacity);
109
+ grown.set(bytes.subarray(0, length));
110
+ bytes = grown;
111
+ }
112
+ bytes.set(chunk, length);
113
+ length += chunk.byteLength;
114
+ }
115
+ } catch (error) {
116
+ if (error instanceof AssetError) throw error;
117
+ throw new AssetError(`Asset ${reference} could not be read.`, { cause: error });
118
+ }
119
+ if (contentLength !== null && length !== contentLength) {
120
+ throw new AssetError(`Asset ${reference} does not match its Content-Length header.`);
121
+ }
122
+ if (expectedLength !== null && length !== expectedLength) {
123
+ throw new AssetError(`Asset ${reference} does not match its declared byte length.`);
124
+ }
125
+ return fixedLength === null && bytes.byteLength !== length
126
+ ? bytes.slice(0, length)
127
+ : bytes;
128
+ }
129
+
130
+ function inferEta1Format(bytes, reference) {
131
+ if (bytes.byteLength < IR_ASSET_HEADER_BYTES || bytes.byteLength > MAX_ASSET_BYTES) {
132
+ throw new AssetError(`Asset ${reference} does not contain a bounded ETA1 payload.`);
133
+ }
134
+ const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
135
+ const channels = header.getUint32(4, true);
136
+ const frames = header.getUint32(8, true);
137
+ const sampleRate = header.getUint32(12, true);
138
+ const topology = header.getUint32(16, true);
139
+ const pathCount = header.getUint32(20, true);
140
+ if (header.getUint32(0, true) !== 0x31415445 ||
141
+ channels < 1 || channels > 8 ||
142
+ frames < 1 || frames > 8388600 ||
143
+ sampleRate < 1 ||
144
+ topology > IR_ASSET_TOPOLOGY.matrix ||
145
+ pathCount > 8 ||
146
+ header.getUint32(24, true) !== 0 ||
147
+ header.getUint32(28, true) !== 0) {
148
+ throw new AssetError(`Asset ${reference} has an invalid ETA1 header.`);
149
+ }
150
+ if ((topology === IR_ASSET_TOPOLOGY.matrix) !== (pathCount > 0)) {
151
+ throw new AssetError(`Asset ${reference} has an invalid ETA1 path count.`);
152
+ }
153
+ const expected = IR_ASSET_HEADER_BYTES + pathCount * 12 +
154
+ channels * frames * Float32Array.BYTES_PER_ELEMENT;
155
+ if (!Number.isSafeInteger(expected) || expected !== bytes.byteLength) {
156
+ throw new AssetError(`Asset ${reference} byte length does not match its ETA1 header.`);
157
+ }
158
+ const paths = [];
159
+ for (let index = 0; index < pathCount; index++) {
160
+ const offset = IR_ASSET_HEADER_BYTES + index * 12;
161
+ paths.push({
162
+ inputSlot: header.getUint32(offset, true),
163
+ outputSlot: header.getUint32(offset + 4, true),
164
+ irChannel: header.getUint32(offset + 8, true)
165
+ });
166
+ }
167
+ return {
168
+ formatTag: 1,
169
+ magic: 'ETA1',
170
+ headerBytes: IR_ASSET_HEADER_BYTES,
171
+ pathRecordBytes: 12,
172
+ reservedBytes: 8,
173
+ sampleType: 'float32',
174
+ byteOrder: 'little-endian',
175
+ layout: 'planar',
176
+ channels,
177
+ frames,
178
+ sampleRate,
179
+ topology,
180
+ pathCount,
181
+ ...(paths.length > 0 ? { paths } : {})
182
+ };
183
+ }
184
+
185
+ async function resolverResult(resolver, reference, descriptor, effectId) {
186
+ const resolve = typeof resolver === 'function' ? resolver : resolver?.resolve?.bind(resolver);
187
+ if (typeof resolve !== 'function') {
188
+ throw new AssetError(`${effectId} requires an assetResolver.`);
189
+ }
190
+ let result;
191
+ let ownsBytes = false;
192
+ try {
193
+ result = await resolve(reference, descriptor);
194
+ } catch (error) {
195
+ throw new AssetError(`${effectId} could not resolve asset ${reference}.`, { cause: error });
196
+ }
197
+ if (result?.arrayBuffer && typeof result.arrayBuffer === 'function') {
198
+ if (result.ok === false) throw new AssetError(`${effectId} could not read asset ${reference}.`);
199
+ result = {
200
+ bytes: await readBoundedResponse(
201
+ result,
202
+ reference,
203
+ descriptor?.byteLength ?? null
204
+ )
205
+ };
206
+ ownsBytes = true;
207
+ }
208
+ if (result === null || result === undefined) {
209
+ throw new AssetError(`${effectId} could not resolve asset ${reference}.`);
210
+ }
211
+ const wrapped = isRecord(result) && !ArrayBuffer.isView(result) && !(result instanceof ArrayBuffer)
212
+ ? result
213
+ : { bytes: result };
214
+ const bytes = byteView(wrapped.bytes ?? wrapped.data);
215
+ if (!bytes) throw new AssetError(`${effectId} asset ${reference} did not return binary data.`);
216
+ if (descriptor && bytes.byteLength !== descriptor.byteLength) {
217
+ throw new AssetError(`Asset ${descriptor.id} does not match its declared byte length.`);
218
+ }
219
+ if (!descriptor && bytes.byteLength > MAX_ASSET_BYTES) {
220
+ if (wrapped.format !== undefined) {
221
+ normalizeFormatMetadata(wrapped.format, bytes.byteLength, reference);
222
+ }
223
+ throw new AssetError(`Asset ${reference} does not contain a bounded ETA1 payload.`);
224
+ }
225
+ const copy = ownsBytes ? bytes : new Uint8Array(bytes);
226
+ const inferredFormat = wrapped.format === undefined && descriptor === undefined;
227
+ return {
228
+ bytes: copy,
229
+ format: wrapped.format ?? (descriptor ? undefined : inferEta1Format(copy, reference)),
230
+ inferredFormat
231
+ };
232
+ }
233
+
234
+ function validatePaths(paths, channels) {
235
+ if (!Array.isArray(paths) || paths.length < 1 || paths.length > 8) {
236
+ throw new AssetError('Matrix impulse responses require between 1 and 8 paths.');
237
+ }
238
+ const normalized = paths.map(path => {
239
+ if (!isRecord(path) ||
240
+ Object.keys(path).length !== 3 ||
241
+ !Object.hasOwn(path, 'inputSlot') ||
242
+ !Object.hasOwn(path, 'outputSlot') ||
243
+ !Object.hasOwn(path, 'irChannel')) {
244
+ throw new AssetError('Matrix impulse-response paths must contain exact path fields.');
245
+ }
246
+ const inputSlot = path?.inputSlot;
247
+ const outputSlot = path?.outputSlot;
248
+ const irChannel = path?.irChannel;
249
+ if (![inputSlot, outputSlot, irChannel].every(Number.isSafeInteger) ||
250
+ inputSlot < 0 || inputSlot > 7 ||
251
+ outputSlot < 0 || outputSlot > 7 ||
252
+ irChannel < 0 || irChannel >= channels) {
253
+ throw new AssetError('Matrix impulse-response paths contain an invalid channel or slot.');
254
+ }
255
+ return { inputSlot, outputSlot, irChannel };
256
+ });
257
+ const inputSlots = [...new Set(normalized.map(path => path.inputSlot))]
258
+ .sort((left, right) => left - right);
259
+ if (inputSlots.some((value, index) => value !== index)) {
260
+ throw new AssetError(
261
+ 'Matrix impulse-response inputSlot values must form a contiguous range starting at 0.'
262
+ );
263
+ }
264
+ return normalized;
265
+ }
266
+
267
+ function normalizeTopology(format, { allowNumeric = false } = {}) {
268
+ const supplied = format.topology;
269
+ const topology = typeof supplied === 'string'
270
+ ? TOPOLOGY_NAMES.get(supplied)
271
+ : allowNumeric
272
+ ? supplied
273
+ : undefined;
274
+ if (!Number.isInteger(topology) || topology < 0 || topology > IR_ASSET_TOPOLOGY.matrix) {
275
+ throw new AssetError('Impulse-response topology is unsupported.');
276
+ }
277
+ const paths = topology === IR_ASSET_TOPOLOGY.matrix
278
+ ? validatePaths(format.paths, format.channels)
279
+ : [];
280
+ if (topology !== IR_ASSET_TOPOLOGY.matrix && format.paths !== undefined) {
281
+ throw new AssetError('Impulse-response paths are only valid for matrix topology.');
282
+ }
283
+ return { topology, paths };
284
+ }
285
+
286
+ export function encodeEta1(options) {
287
+ if (!isRecord(options)) {
288
+ throw new AssetError('ETA1 encoding options must be an object.');
289
+ }
290
+ const { channels, sampleRate, topology = 'unspecified', paths } = options;
291
+ if (!Array.isArray(channels) || channels.length < 1 || channels.length > 8) {
292
+ throw new AssetError('ETA1 channels must contain between 1 and 8 Float32Array values.');
293
+ }
294
+ const frames = channels[0] instanceof Float32Array ? channels[0].length : 0;
295
+ if (frames < 1) {
296
+ throw new AssetError('ETA1 channels must be non-empty Float32Array values.');
297
+ }
298
+ for (const channel of channels) {
299
+ if (!(channel instanceof Float32Array) || channel.length !== frames) {
300
+ throw new AssetError('ETA1 channels must be equally sized Float32Array values.');
301
+ }
302
+ }
303
+ if (!Number.isSafeInteger(sampleRate) || sampleRate < 1 || sampleRate > 0xffffffff) {
304
+ throw new AssetError('ETA1 sampleRate must be a positive 32-bit integer.');
305
+ }
306
+ const normalized = normalizeTopology({
307
+ topology,
308
+ paths,
309
+ channels: channels.length
310
+ });
311
+ const byteLength = IR_ASSET_HEADER_BYTES + normalized.paths.length * 12 +
312
+ channels.length * frames * Float32Array.BYTES_PER_ELEMENT;
313
+ if (!Number.isSafeInteger(byteLength) || byteLength > MAX_ASSET_BYTES) {
314
+ throw new AssetError('ETA1 payload exceeds the 32 MiB limit.');
315
+ }
316
+ for (const channel of channels) {
317
+ for (const sample of channel) {
318
+ if (!Number.isFinite(sample)) {
319
+ throw new AssetError('ETA1 samples must be finite.');
320
+ }
321
+ }
322
+ }
323
+ try {
324
+ return buildIrAssetPayload({
325
+ channels,
326
+ sampleRate,
327
+ topology: normalized.topology,
328
+ paths: normalized.paths
329
+ });
330
+ } catch (error) {
331
+ if (error instanceof AssetError) throw error;
332
+ throw new AssetError('Unable to encode the ETA1 payload.', { cause: error });
333
+ }
334
+ }
335
+
336
+ function normalizeFormatMetadata(format, byteLength, reference, options = {}) {
337
+ if (!isRecord(format)) {
338
+ throw new AssetError(`Asset ${reference} requires float32 planar format metadata.`);
339
+ }
340
+ if (format.formatTag !== 1 || format.magic !== 'ETA1' ||
341
+ format.headerBytes !== IR_ASSET_HEADER_BYTES || format.pathRecordBytes !== 12 ||
342
+ format.reservedBytes !== 8 ||
343
+ format.sampleType !== 'float32' || format.byteOrder !== 'little-endian' ||
344
+ format.layout !== 'planar') {
345
+ throw new AssetError(`Asset ${reference} must use the ETA1 planar little-endian float32 format.`);
346
+ }
347
+ const { channels, frames, sampleRate } = format;
348
+ if (!Number.isInteger(channels) || channels < 1 || channels > 8 ||
349
+ !Number.isInteger(frames) || frames < 1 || frames > 8388600 ||
350
+ !Number.isInteger(sampleRate) || sampleRate < 1 || sampleRate > 0xffffffff) {
351
+ throw new AssetError(`Asset ${reference} has invalid channel, frame, or sample-rate metadata.`);
352
+ }
353
+ const topology = normalizeTopology(format, options);
354
+ const allowedFormatKeys = new Set([
355
+ 'formatTag', 'magic', 'headerBytes', 'pathRecordBytes',
356
+ 'reservedBytes',
357
+ 'sampleType', 'byteOrder', 'layout', 'channels', 'frames',
358
+ 'sampleRate', 'topology', 'pathCount', 'paths'
359
+ ]);
360
+ for (const key of Object.keys(format)) {
361
+ if (!allowedFormatKeys.has(key)) {
362
+ throw new AssetError(`Asset ${reference} has an unsupported format field: ${key}`);
363
+ }
364
+ }
365
+ const pathCount = topology.topology === IR_ASSET_TOPOLOGY.matrix
366
+ ? topology.paths.length
367
+ : 0;
368
+ if (format.pathCount !== pathCount) {
369
+ throw new AssetError(`Asset ${reference} path count does not match its topology.`);
370
+ }
371
+ const expected = IR_ASSET_HEADER_BYTES + pathCount * 12 +
372
+ channels * frames * Float32Array.BYTES_PER_ELEMENT;
373
+ if (!Number.isSafeInteger(expected) || expected !== byteLength) {
374
+ throw new AssetError(`Asset ${reference} byte length does not match its ETA1 format.`);
375
+ }
376
+ if (byteLength > MAX_ASSET_BYTES) {
377
+ throw new AssetError(`Asset ${reference} exceeds the 32 MiB limit.`);
378
+ }
379
+ return {
380
+ ...format,
381
+ channels,
382
+ frames,
383
+ sampleRate,
384
+ ...topology,
385
+ sampleOffset: IR_ASSET_HEADER_BYTES + pathCount * 12
386
+ };
387
+ }
388
+
389
+ function normalizeFormat(format, bytes, reference, options = {}) {
390
+ const normalized = normalizeFormatMetadata(format, bytes.byteLength, reference, options);
391
+ const {
392
+ channels,
393
+ frames,
394
+ sampleRate,
395
+ topology,
396
+ paths,
397
+ sampleOffset
398
+ } = normalized;
399
+ const pathCount = paths.length;
400
+ const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
401
+ if (header.getUint32(0, true) !== 0x31415445 ||
402
+ header.getUint32(4, true) !== channels ||
403
+ header.getUint32(8, true) !== frames ||
404
+ header.getUint32(12, true) !== sampleRate ||
405
+ header.getUint32(16, true) !== topology ||
406
+ header.getUint32(20, true) !== pathCount ||
407
+ header.getUint32(24, true) !== 0 ||
408
+ header.getUint32(28, true) !== 0) {
409
+ throw new AssetError(`Asset ${reference} ETA1 header does not match its manifest.`);
410
+ }
411
+ for (let index = 0; index < pathCount; index++) {
412
+ const offset = IR_ASSET_HEADER_BYTES + index * 12;
413
+ const path = paths[index];
414
+ if (header.getUint32(offset, true) !== path.inputSlot ||
415
+ header.getUint32(offset + 4, true) !== path.outputSlot ||
416
+ header.getUint32(offset + 8, true) !== path.irChannel) {
417
+ throw new AssetError(`Asset ${reference} ETA1 path records do not match its manifest.`);
418
+ }
419
+ }
420
+ for (let offset = sampleOffset; offset < bytes.byteLength; offset += 4) {
421
+ if (!Number.isFinite(header.getFloat32(offset, true))) {
422
+ throw new AssetError(`Asset ${reference} contains a non-finite sample.`);
423
+ }
424
+ }
425
+ return {
426
+ ...normalized
427
+ };
428
+ }
429
+
430
+ async function sha256(bytes) {
431
+ if (globalThis.crypto?.subtle) {
432
+ const digest = await globalThis.crypto.subtle.digest(
433
+ 'SHA-256',
434
+ bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
435
+ );
436
+ return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join('');
437
+ }
438
+ try {
439
+ const { createHash } = await import('node:crypto');
440
+ return createHash('sha256').update(bytes).digest('hex');
441
+ } catch (error) {
442
+ throw new AssetError('SHA-256 is unavailable for bundle verification.', { cause: error });
443
+ }
444
+ }
445
+
446
+ function validateBundleAsset(entry) {
447
+ if (!isRecord(entry) || typeof entry.id !== 'string' || entry.id.length < 1 ||
448
+ entry.id.length > 128 || entry.kind !== 'impulseResponse' ||
449
+ typeof entry.reference !== 'string' || entry.reference.length < 1 ||
450
+ entry.reference.length > 2048 ||
451
+ typeof entry.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(entry.sha256) ||
452
+ !Number.isInteger(entry.byteLength) || entry.byteLength < 36 ||
453
+ entry.byteLength > MAX_ASSET_BYTES) {
454
+ throw new AssetError('Bundle contains an invalid asset manifest entry.');
455
+ }
456
+ const allowed = new Set(['id', 'kind', 'reference', 'sha256', 'byteLength', 'format']);
457
+ for (const key of Object.keys(entry)) {
458
+ if (!allowed.has(key)) throw new AssetError(`Bundle asset ${entry.id} has an unsupported field: ${key}`);
459
+ }
460
+ normalizeFormatMetadata(entry.format, entry.byteLength, entry.id);
461
+ }
462
+
463
+ export function isBundleDocument(input) {
464
+ return isRecord(input) && Array.isArray(input.assets) && isRecord(input.chain);
465
+ }
466
+
467
+ export function splitBundle(input) {
468
+ if (!isBundleDocument(input)) return { chain: input, manifest: null };
469
+ const keys = new Set(['version', 'chain', 'assets']);
470
+ for (const key of Object.keys(input)) {
471
+ if (!keys.has(key)) throw new ValidationError(`Unsupported bundle field: ${key}`);
472
+ }
473
+ if (input.version !== 1) throw new ValidationError('Only bundle version 1 is supported.');
474
+ const manifest = new Map();
475
+ if (input.assets.length > 64) throw new AssetError('A bundle can contain at most 64 assets.');
476
+ for (const entry of input.assets) {
477
+ validateBundleAsset(entry);
478
+ if (manifest.has(entry.id)) throw new AssetError(`Duplicate bundle asset id: ${entry.id}`);
479
+ const paths = entry.format.paths?.map(path => Object.freeze({ ...path }));
480
+ const format = Object.freeze({
481
+ ...entry.format,
482
+ ...(paths === undefined ? {} : { paths: Object.freeze(paths) })
483
+ });
484
+ manifest.set(entry.id, Object.freeze({ ...entry, format }));
485
+ }
486
+ return { chain: input.chain, manifest };
487
+ }
488
+
489
+ export async function resolveChainAssets(chainDocument, {
490
+ assetResolver,
491
+ manifest = null
492
+ } = {}) {
493
+ const resolved = new Map();
494
+ if (manifest) {
495
+ for (const effect of chainDocument.chain) {
496
+ for (const reference of Object.values(effect.assets ?? {})) {
497
+ if (!manifest.has(reference)) {
498
+ throw new AssetError(`${effect.id} references missing bundle asset ${reference}.`);
499
+ }
500
+ }
501
+ }
502
+ }
503
+ for (const effect of chainDocument.chain) {
504
+ if (!effect.enabled || !effect.assets) continue;
505
+ const effectAssets = {};
506
+ for (const [name, reference] of Object.entries(effect.assets)) {
507
+ const descriptor = manifest?.get(reference);
508
+ const result = await resolverResult(
509
+ assetResolver,
510
+ descriptor?.reference ?? reference,
511
+ descriptor,
512
+ effect.id
513
+ );
514
+ if (descriptor) {
515
+ if (result.bytes.byteLength !== descriptor.byteLength) {
516
+ throw new AssetError(`Asset ${reference} does not match its declared byte length.`);
517
+ }
518
+ const digest = await sha256(result.bytes);
519
+ if (digest !== descriptor.sha256) {
520
+ throw new AssetError(`Asset ${reference} failed SHA-256 verification.`);
521
+ }
522
+ }
523
+ const format = normalizeFormat(
524
+ descriptor?.format ?? result.format,
525
+ result.bytes,
526
+ reference,
527
+ { allowNumeric: result.inferredFormat === true }
528
+ );
529
+ effectAssets[name] = Object.freeze({ bytes: result.bytes, format, reference });
530
+ }
531
+ resolved.set(effect.id, Object.freeze(effectAssets));
532
+ }
533
+ return resolved;
534
+ }
535
+
536
+ function float32Channels(asset, count) {
537
+ const channels = [];
538
+ for (let channel = 0; channel < count; channel++) {
539
+ const offset = asset.format.sampleOffset + channel * asset.format.frames * 4;
540
+ const values = new Float32Array(asset.format.frames);
541
+ const view = new DataView(
542
+ asset.bytes.buffer,
543
+ asset.bytes.byteOffset + offset,
544
+ asset.format.frames * 4
545
+ );
546
+ for (let frame = 0; frame < values.length; frame++) {
547
+ const value = view.getFloat32(frame * 4, true);
548
+ if (!Number.isFinite(value)) throw new AssetError(`Asset ${asset.reference} contains a non-finite sample.`);
549
+ values[frame] = value;
550
+ }
551
+ channels.push(values);
552
+ }
553
+ return channels;
554
+ }
555
+
556
+ export function prepareIrAsset(effect, resolvedAssets, {
557
+ sampleRate,
558
+ engineChannels
559
+ }) {
560
+ const asset = requireResolvedAsset(effect, resolvedAssets);
561
+ const config = resolveIrProcessingConfig({
562
+ sampleRate,
563
+ channelCount: asset.format.channels,
564
+ engineChannels,
565
+ channel: channelToEngine(effect.channel),
566
+ channelMode: {
567
+ automatic: 'auto',
568
+ mono: 'mono',
569
+ independent: 'indep',
570
+ trueStereo: 'true',
571
+ matrix: 'multi'
572
+ }[effect.parameters.channelMode],
573
+ latency: String(effect.parameters.latency),
574
+ convolutionRate: effect.parameters.convolutionRate
575
+ });
576
+ if (!config.valid) throw new AssetError(`${effect.id}: ${config.message}`);
577
+ if (asset.format.sampleRate !== config.sampleRate) {
578
+ throw new AssetError(
579
+ `${effect.id} requires an impulse response at ${config.sampleRate} Hz for this processing configuration.`
580
+ );
581
+ }
582
+
583
+ const explicitTopology = asset.format.topology;
584
+ const topology = explicitTopology === IR_ASSET_TOPOLOGY.unspecified
585
+ ? config.topology
586
+ : explicitTopology;
587
+ if (explicitTopology !== IR_ASSET_TOPOLOGY.unspecified &&
588
+ explicitTopology !== config.topology) {
589
+ throw new AssetError(`${effect.id} impulse-response topology does not match its channel mode.`);
590
+ }
591
+ const paths = topology === IR_ASSET_TOPOLOGY.matrix
592
+ ? (asset.format.paths.length > 0 ? asset.format.paths : config.paths)
593
+ : [];
594
+ const inputCount = topology === IR_ASSET_TOPOLOGY.matrix
595
+ ? new Set(paths.map(path => path.inputSlot)).size
596
+ : 0;
597
+ if (inputCount > config.processingChannels) {
598
+ throw new AssetError(
599
+ `${effect.id} matrix impulse response uses more input slots than its processing channels.`
600
+ );
601
+ }
602
+ if (paths.some(path => path.outputSlot >= config.processingChannels)) {
603
+ throw new AssetError(
604
+ `${effect.id} matrix impulse response routes output beyond its processing channels.`
605
+ );
606
+ }
607
+ const assetChannels = topology === IR_ASSET_TOPOLOGY.mono ? 1 : config.assetChannels;
608
+ if (asset.format.channels < assetChannels) {
609
+ throw new AssetError(`${effect.id} does not have enough impulse-response channels.`);
610
+ }
611
+ const channels = float32Channels(asset, assetChannels);
612
+ let payload;
613
+ try {
614
+ payload = buildIrAssetPayload({
615
+ channels,
616
+ sampleRate: asset.format.sampleRate,
617
+ topology,
618
+ paths
619
+ });
620
+ } catch (error) {
621
+ throw new AssetError(`${effect.id} has an invalid impulse-response payload.`, { cause: error });
622
+ }
623
+ const pathCount = topology === IR_ASSET_TOPOLOGY.matrix ? paths.length : 0;
624
+ const footprintBytes = estimateIrKernelCommitFootprint({
625
+ frames: asset.format.frames,
626
+ assetChannels,
627
+ topology,
628
+ processingChannels: config.processingChannels,
629
+ headBlock: config.headBlock,
630
+ pathCount,
631
+ inputCount
632
+ });
633
+ if (payload.byteLength > MAX_ASSET_BYTES ||
634
+ payload.byteLength < IR_ASSET_HEADER_BYTES ||
635
+ footprintBytes > IR_KERNEL_ASSET_CAPACITY_BYTES) {
636
+ throw new AssetError(`${effect.id} exceeds the 32 MiB impulse-response kernel limit.`);
637
+ }
638
+ return {
639
+ payload,
640
+ formatTag: IR_ASSET_FORMAT_TAG,
641
+ beginInfo: {
642
+ channels: assetChannels,
643
+ frames: asset.format.frames,
644
+ topology,
645
+ headBlock: config.headBlock,
646
+ rateDivider: config.rateDivider,
647
+ pathCount,
648
+ inputCount,
649
+ processingChannels: config.processingChannels,
650
+ footprintBytes
651
+ }
652
+ };
653
+ }
654
+
655
+ function prepareFirFilterAsset(effect, resolvedAssets, {
656
+ sampleRate,
657
+ engineChannels
658
+ }) {
659
+ const asset = requireResolvedAsset(effect, resolvedAssets);
660
+ if (asset.format.sampleRate !== sampleRate) {
661
+ throw new AssetError(
662
+ `${effect.id} requires filter coefficients at the processing sample rate of ${sampleRate} Hz.`
663
+ );
664
+ }
665
+ if (asset.format.frames > 131072) {
666
+ throw new AssetError(`${effect.id} filter coefficients exceed 131072 frames.`);
667
+ }
668
+
669
+ const processingChannels = channelRange(effect.channel, engineChannels).count;
670
+ const headBlock = Number(effect.parameters.latencyMode);
671
+ const supportedHeadBlocks = new Set([0, 128, 256, 512, 1024]);
672
+ if (!supportedHeadBlocks.has(headBlock)) {
673
+ throw new AssetError(`${effect.id} has an unsupported latencyMode.`);
674
+ }
675
+
676
+ let assetChannels;
677
+ let topology;
678
+ let paths;
679
+ let inputCount;
680
+ if (effect.type === 'FIRCrossover') {
681
+ const bandCount = effect.parameters.bandCount;
682
+ if (![4, 6, 8].includes(processingChannels) ||
683
+ bandCount * 2 > processingChannels ||
684
+ asset.format.channels !== bandCount) {
685
+ throw new AssetError(
686
+ `${effect.id} requires 4, 6, or 8 processing channels and one filter channel per band.`
687
+ );
688
+ }
689
+ topology = IR_ASSET_TOPOLOGY.matrix;
690
+ assetChannels = bandCount;
691
+ paths = Array.from({ length: bandCount }, (_, band) => [
692
+ { inputSlot: 0, outputSlot: band * 2, irChannel: band },
693
+ { inputSlot: 1, outputSlot: band * 2 + 1, irChannel: band }
694
+ ]).flat();
695
+ inputCount = 2;
696
+ if (asset.format.topology !== IR_ASSET_TOPOLOGY.unspecified &&
697
+ asset.format.topology !== topology) {
698
+ throw new AssetError(`${effect.id} requires matrix filter topology.`);
699
+ }
700
+ if (asset.format.topology === topology &&
701
+ (asset.format.paths.length !== paths.length ||
702
+ asset.format.paths.some((path, index) =>
703
+ path.inputSlot !== paths[index].inputSlot ||
704
+ path.outputSlot !== paths[index].outputSlot ||
705
+ path.irChannel !== paths[index].irChannel))) {
706
+ throw new AssetError(`${effect.id} filter paths do not match its band layout.`);
707
+ }
708
+ } else {
709
+ topology = IR_ASSET_TOPOLOGY.mono;
710
+ assetChannels = 1;
711
+ paths = [];
712
+ inputCount = 0;
713
+ if (asset.format.channels !== 1 ||
714
+ (asset.format.topology !== IR_ASSET_TOPOLOGY.unspecified &&
715
+ asset.format.topology !== topology)) {
716
+ throw new AssetError(`${effect.id} requires a mono filter-coefficient asset.`);
717
+ }
718
+ }
719
+
720
+ let payload;
721
+ try {
722
+ payload = buildIrAssetPayload({
723
+ channels: float32Channels(asset, assetChannels),
724
+ sampleRate,
725
+ topology,
726
+ paths
727
+ });
728
+ } catch (error) {
729
+ throw new AssetError(`${effect.id} has an invalid filter-coefficient payload.`, { cause: error });
730
+ }
731
+ const pathCount = paths.length;
732
+ const footprintBytes = estimateIrKernelCommitFootprint({
733
+ frames: asset.format.frames,
734
+ assetChannels,
735
+ topology,
736
+ processingChannels,
737
+ headBlock,
738
+ pathCount,
739
+ inputCount
740
+ });
741
+ if (payload.byteLength > MAX_ASSET_BYTES ||
742
+ payload.byteLength < IR_ASSET_HEADER_BYTES ||
743
+ footprintBytes > IR_KERNEL_ASSET_CAPACITY_BYTES) {
744
+ throw new AssetError(`${effect.id} exceeds the 32 MiB filter-kernel limit.`);
745
+ }
746
+ return {
747
+ payload,
748
+ formatTag: IR_ASSET_FORMAT_TAG,
749
+ beginInfo: {
750
+ channels: assetChannels,
751
+ frames: asset.format.frames,
752
+ topology,
753
+ headBlock,
754
+ rateDivider: 1,
755
+ pathCount,
756
+ inputCount,
757
+ processingChannels,
758
+ footprintBytes
759
+ }
760
+ };
761
+ }
762
+
763
+ export function prepareConvolutionAsset(effect, resolvedAssets, options) {
764
+ return effect.type === 'IRReverb'
765
+ ? prepareIrAsset(effect, resolvedAssets, options)
766
+ : prepareFirFilterAsset(effect, resolvedAssets, options);
767
+ }