@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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { instantiateDspBinding } from './dsp-engine-binding.js';
|
|
2
|
+
|
|
3
|
+
export const EXPECTED_ABI_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
// A minimal module whose only function returns a v128.const value.
|
|
6
|
+
export const SIMD_PROBE_BYTES = new Uint8Array([
|
|
7
|
+
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
|
8
|
+
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b,
|
|
9
|
+
0x03, 0x02, 0x01, 0x00,
|
|
10
|
+
0x0a, 0x16, 0x01, 0x14, 0x00, 0xfd, 0x0c,
|
|
11
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
12
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
13
|
+
0x0b
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const moduleCache = new Map();
|
|
17
|
+
|
|
18
|
+
function defaultWarning(message) {
|
|
19
|
+
if (globalThis.console?.warn) {
|
|
20
|
+
globalThis.console.warn(message);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function warn(warning, message) {
|
|
25
|
+
warning(`[dsp-wasm] ${message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function joinAssetPath(basePath, relativePath) {
|
|
29
|
+
const base = String(basePath || '');
|
|
30
|
+
if (!base) return relativePath;
|
|
31
|
+
if (base === '/') return `/${relativePath}`;
|
|
32
|
+
return `${base.replace(/\/$/, '')}/${relativePath}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertResponse(response, url) {
|
|
36
|
+
if (!response || response.ok === false) {
|
|
37
|
+
const status = response?.status ? ` (${response.status})` : '';
|
|
38
|
+
throw new Error(`Failed to fetch ${url}${status}`);
|
|
39
|
+
}
|
|
40
|
+
return response;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readJsonResponse(response, url) {
|
|
44
|
+
assertResponse(response, url);
|
|
45
|
+
if (typeof response.json === 'function') return response.json();
|
|
46
|
+
if (typeof response.text === 'function') return JSON.parse(await response.text());
|
|
47
|
+
throw new Error(`Response for ${url} cannot be read as JSON`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readBinaryResponse(response, url) {
|
|
51
|
+
assertResponse(response, url);
|
|
52
|
+
if (typeof response.arrayBuffer !== 'function') {
|
|
53
|
+
throw new Error(`Response for ${url} cannot be read as an ArrayBuffer`);
|
|
54
|
+
}
|
|
55
|
+
const bytes = await response.arrayBuffer();
|
|
56
|
+
if (!(bytes instanceof ArrayBuffer)) {
|
|
57
|
+
throw new Error(`Response for ${url} did not return an ArrayBuffer`);
|
|
58
|
+
}
|
|
59
|
+
return bytes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeKernel(kernel, index) {
|
|
63
|
+
if (!kernel || typeof kernel.name !== 'string' || kernel.name.length === 0) {
|
|
64
|
+
throw new Error(`Invalid DSP metadata kernel at index ${index}`);
|
|
65
|
+
}
|
|
66
|
+
if (!Number.isInteger(kernel.hash) || kernel.hash < 0 || kernel.hash > 0xffffffff) {
|
|
67
|
+
throw new Error(`Invalid DSP parameter hash for ${kernel.name}`);
|
|
68
|
+
}
|
|
69
|
+
const byteCapacity = kernel.byteCapacity ?? 0;
|
|
70
|
+
if (!Number.isInteger(byteCapacity) || byteCapacity < 0 || byteCapacity > 4096) {
|
|
71
|
+
throw new Error(`Invalid DSP structured parameter capacity for ${kernel.name}`);
|
|
72
|
+
}
|
|
73
|
+
return { name: kernel.name, hash: kernel.hash >>> 0, byteCapacity };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateDspMeta(meta) {
|
|
77
|
+
if (!meta || typeof meta !== 'object') throw new Error('DSP metadata is not an object');
|
|
78
|
+
if (!Number.isInteger(meta.abiVersion) || meta.abiVersion < 0) {
|
|
79
|
+
throw new Error('DSP metadata has an invalid ABI version');
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(meta.kernels)) throw new Error('DSP metadata kernels must be an array');
|
|
82
|
+
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const kernels = meta.kernels.map((kernel, index) => {
|
|
85
|
+
const normalized = normalizeKernel(kernel, index);
|
|
86
|
+
if (seen.has(normalized.name)) throw new Error(`Duplicate DSP kernel ${normalized.name}`);
|
|
87
|
+
seen.add(normalized.name);
|
|
88
|
+
return normalized;
|
|
89
|
+
});
|
|
90
|
+
return { ...meta, abiVersion: meta.abiVersion >>> 0, kernels };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function detectSimdSupport(webAssembly = globalThis.WebAssembly) {
|
|
94
|
+
if (!webAssembly || typeof webAssembly.validate !== 'function') return false;
|
|
95
|
+
try {
|
|
96
|
+
return webAssembly.validate(SIMD_PROBE_BYTES);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function detectWasmExceptionHandlingSupport(webAssembly = globalThis.WebAssembly) {
|
|
103
|
+
return Boolean(
|
|
104
|
+
webAssembly
|
|
105
|
+
&& typeof webAssembly.Tag === 'function'
|
|
106
|
+
&& typeof webAssembly.Exception === 'function'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function canCloneWasmModule(module, structuredCloneImpl = globalThis.structuredClone) {
|
|
111
|
+
if (typeof structuredCloneImpl !== 'function') return false;
|
|
112
|
+
try {
|
|
113
|
+
structuredCloneImpl(module);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function reconcileKernels(meta, capabilities, warning) {
|
|
121
|
+
const available = new Map(capabilities.kernels.map(kernel => [kernel.name, kernel]));
|
|
122
|
+
const compatible = [];
|
|
123
|
+
for (const kernel of meta.kernels) {
|
|
124
|
+
const actual = available.get(kernel.name);
|
|
125
|
+
if (actual && (actual.hash >>> 0) === kernel.hash &&
|
|
126
|
+
(actual.byteCapacity ?? 0) === kernel.byteCapacity) {
|
|
127
|
+
compatible.push(kernel);
|
|
128
|
+
} else {
|
|
129
|
+
const reason = actual === undefined
|
|
130
|
+
? 'is absent from the module'
|
|
131
|
+
: 'has a parameter layout mismatch';
|
|
132
|
+
warn(warning, `${kernel.name} ${reason}; disabling its WASM path`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { ...meta, kernels: compatible };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findHash(generatedModule, typeName) {
|
|
139
|
+
const candidates = [
|
|
140
|
+
`${typeName}_PARAMS_HASH`,
|
|
141
|
+
`${typeName}ParamsHash`,
|
|
142
|
+
`${typeName.toUpperCase()}_PARAMS_HASH`
|
|
143
|
+
];
|
|
144
|
+
for (const name of candidates) {
|
|
145
|
+
if (Number.isInteger(generatedModule?.[name])) return generatedModule[name] >>> 0;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizePackerCollection(collection) {
|
|
151
|
+
if (collection instanceof Map) return collection;
|
|
152
|
+
if (collection && typeof collection === 'object') return new Map(Object.entries(collection));
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createDspParamPackers(generatedModule, kernels = null, warning = defaultWarning) {
|
|
157
|
+
const kernelHashes = kernels
|
|
158
|
+
? new Map(kernels.map(kernel => [kernel.name, kernel.hash >>> 0]))
|
|
159
|
+
: null;
|
|
160
|
+
const kernelByteCapacities = kernels
|
|
161
|
+
? new Map(kernels.map(kernel => [kernel.name, kernel.byteCapacity ?? 0]))
|
|
162
|
+
: null;
|
|
163
|
+
const packers = new Map();
|
|
164
|
+
const declared = normalizePackerCollection(
|
|
165
|
+
generatedModule?.DSP_PARAM_PACKERS ||
|
|
166
|
+
generatedModule?.dspParamPackers ||
|
|
167
|
+
generatedModule?.PARAM_PACKERS
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (declared) {
|
|
171
|
+
for (const [typeName, value] of declared) {
|
|
172
|
+
const pack = typeof value === 'function' ? value : value?.pack;
|
|
173
|
+
const packBytes = typeof value?.packBytes === 'function' ? value.packBytes : null;
|
|
174
|
+
const byteCapacity = Number.isInteger(value?.byteCapacity) ? value.byteCapacity : 0;
|
|
175
|
+
const hash = Number.isInteger(value?.hash) ? value.hash >>> 0 : findHash(generatedModule, typeName);
|
|
176
|
+
if (typeof pack !== 'function' || hash === null) continue;
|
|
177
|
+
if (kernelHashes && (kernelHashes.get(typeName) !== hash ||
|
|
178
|
+
kernelByteCapacities.get(typeName) !== byteCapacity)) {
|
|
179
|
+
warn(warning, `${typeName} generated parameter layout does not match the loaded kernel`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
packers.set(typeName, {
|
|
183
|
+
pack,
|
|
184
|
+
hash,
|
|
185
|
+
...(packBytes ? { packBytes, byteCapacity } : {})
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return packers;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const [exportName, pack] of Object.entries(generatedModule || {})) {
|
|
192
|
+
const match = /^pack(.+)Params$/.exec(exportName);
|
|
193
|
+
if (!match || typeof pack !== 'function') continue;
|
|
194
|
+
const typeName = match[1];
|
|
195
|
+
const hash = findHash(generatedModule, typeName);
|
|
196
|
+
if (hash === null) continue;
|
|
197
|
+
if (kernelHashes && kernelHashes.get(typeName) !== hash) {
|
|
198
|
+
warn(warning, `${typeName} generated parameter layout does not match the loaded kernel`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
packers.set(typeName, { pack, hash });
|
|
202
|
+
}
|
|
203
|
+
return packers;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function publishDspParamPackers(generatedModule, {
|
|
207
|
+
target = globalThis.window,
|
|
208
|
+
kernels = null,
|
|
209
|
+
warning = defaultWarning
|
|
210
|
+
} = {}) {
|
|
211
|
+
const packers = createDspParamPackers(generatedModule, kernels, warning);
|
|
212
|
+
if (target && (typeof target === 'object' || typeof target === 'function')) {
|
|
213
|
+
target.dspParamPackers = packers;
|
|
214
|
+
}
|
|
215
|
+
return packers;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function importGeneratedParamPackers() {
|
|
219
|
+
try {
|
|
220
|
+
return await import('./dsp-params.generated.js');
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function loadAndValidate({
|
|
227
|
+
basePath,
|
|
228
|
+
fetchImpl,
|
|
229
|
+
webAssembly,
|
|
230
|
+
structuredCloneImpl,
|
|
231
|
+
expectedAbiVersion,
|
|
232
|
+
instantiateImpl,
|
|
233
|
+
warning,
|
|
234
|
+
paramPackersModule,
|
|
235
|
+
publishTarget
|
|
236
|
+
}) {
|
|
237
|
+
const simd = detectSimdSupport(webAssembly);
|
|
238
|
+
const artifactName = simd ? 'effetune-dsp.simd.wasm' : 'effetune-dsp.wasm';
|
|
239
|
+
const artifactUrl = joinAssetPath(basePath, `plugins/dsp/${artifactName}`);
|
|
240
|
+
const metaUrl = joinAssetPath(basePath, 'plugins/dsp/effetune-dsp.meta.json');
|
|
241
|
+
|
|
242
|
+
const [binaryResponse, metaResponse] = await Promise.all([
|
|
243
|
+
fetchImpl(artifactUrl),
|
|
244
|
+
fetchImpl(metaUrl)
|
|
245
|
+
]);
|
|
246
|
+
const [bytes, rawMeta] = await Promise.all([
|
|
247
|
+
readBinaryResponse(binaryResponse, artifactUrl),
|
|
248
|
+
readJsonResponse(metaResponse, metaUrl)
|
|
249
|
+
]);
|
|
250
|
+
const meta = validateDspMeta(rawMeta);
|
|
251
|
+
if (meta.abiVersion !== expectedAbiVersion) {
|
|
252
|
+
throw new Error(`Metadata ABI ${meta.abiVersion} does not match host ABI ${expectedAbiVersion}`);
|
|
253
|
+
}
|
|
254
|
+
if (!webAssembly || typeof webAssembly.compile !== 'function') {
|
|
255
|
+
throw new Error('WebAssembly.compile is unavailable');
|
|
256
|
+
}
|
|
257
|
+
const module = await webAssembly.compile(bytes);
|
|
258
|
+
|
|
259
|
+
let binding = null;
|
|
260
|
+
let capabilities;
|
|
261
|
+
try {
|
|
262
|
+
binding = await instantiateImpl(module, { webAssembly, warning });
|
|
263
|
+
capabilities = binding.getCapabilities();
|
|
264
|
+
} finally {
|
|
265
|
+
binding?.close();
|
|
266
|
+
}
|
|
267
|
+
if (capabilities.abiVersion !== expectedAbiVersion) {
|
|
268
|
+
throw new Error(`Module ABI ${capabilities.abiVersion} does not match host ABI ${expectedAbiVersion}`);
|
|
269
|
+
}
|
|
270
|
+
if (capabilities.simd !== simd) {
|
|
271
|
+
throw new Error(`Module SIMD flag does not match selected artifact ${artifactName}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const compatibleMeta = reconcileKernels(meta, capabilities, warning);
|
|
275
|
+
const generated = paramPackersModule === undefined
|
|
276
|
+
? await importGeneratedParamPackers()
|
|
277
|
+
: paramPackersModule;
|
|
278
|
+
const paramPackers = publishDspParamPackers(generated, {
|
|
279
|
+
target: publishTarget,
|
|
280
|
+
kernels: compatibleMeta.kernels,
|
|
281
|
+
warning
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
module,
|
|
286
|
+
bytes,
|
|
287
|
+
moduleCloneable: canCloneWasmModule(module, structuredCloneImpl),
|
|
288
|
+
simd,
|
|
289
|
+
meta: compatibleMeta,
|
|
290
|
+
paramPackers
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function loadDspModule({
|
|
295
|
+
basePath = '',
|
|
296
|
+
fetchImpl = globalThis.fetch,
|
|
297
|
+
webAssembly = globalThis.WebAssembly,
|
|
298
|
+
structuredCloneImpl = globalThis.structuredClone,
|
|
299
|
+
expectedAbiVersion = EXPECTED_ABI_VERSION,
|
|
300
|
+
instantiateImpl = instantiateDspBinding,
|
|
301
|
+
warning = defaultWarning,
|
|
302
|
+
paramPackersModule,
|
|
303
|
+
publishTarget = globalThis.window,
|
|
304
|
+
cache = true
|
|
305
|
+
} = {}) {
|
|
306
|
+
if (typeof fetchImpl !== 'function') {
|
|
307
|
+
warn(warning, 'fetch is unavailable; using the JavaScript DSP path');
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!detectWasmExceptionHandlingSupport(webAssembly)) {
|
|
312
|
+
warn(warning, 'WebAssembly exception handling is unavailable; using the JavaScript DSP path');
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const simd = detectSimdSupport(webAssembly);
|
|
317
|
+
const artifactName = simd ? 'effetune-dsp.simd.wasm' : 'effetune-dsp.wasm';
|
|
318
|
+
const cacheKey = `${basePath}|${artifactName}|${expectedAbiVersion}`;
|
|
319
|
+
if (cache && moduleCache.has(cacheKey)) return moduleCache.get(cacheKey);
|
|
320
|
+
|
|
321
|
+
const loadPromise = loadAndValidate({
|
|
322
|
+
basePath,
|
|
323
|
+
fetchImpl,
|
|
324
|
+
webAssembly,
|
|
325
|
+
structuredCloneImpl,
|
|
326
|
+
expectedAbiVersion,
|
|
327
|
+
instantiateImpl,
|
|
328
|
+
warning,
|
|
329
|
+
paramPackersModule,
|
|
330
|
+
publishTarget
|
|
331
|
+
}).catch(error => {
|
|
332
|
+
if (cache) moduleCache.delete(cacheKey);
|
|
333
|
+
warn(warning, `load failed: ${error?.message || String(error)}; using the JavaScript DSP path`);
|
|
334
|
+
return null;
|
|
335
|
+
});
|
|
336
|
+
if (cache) moduleCache.set(cacheKey, loadPromise);
|
|
337
|
+
return loadPromise;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function clearDspModuleCache() {
|
|
341
|
+
moduleCache.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function instantiateDsp(moduleOrBytes, options) {
|
|
345
|
+
return instantiateDspBinding(moduleOrBytes, options);
|
|
346
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const IR_ASSET_FORMAT_TAG = 1;
|
|
2
|
+
export const IR_ASSET_HEADER_BYTES = 32;
|
|
3
|
+
export const IR_ASSET_MAGIC = 0x31415445;
|
|
4
|
+
|
|
5
|
+
export const IR_ASSET_TOPOLOGY = Object.freeze({
|
|
6
|
+
unspecified: 0,
|
|
7
|
+
mono: 1,
|
|
8
|
+
independent: 2,
|
|
9
|
+
trueStereo: 3,
|
|
10
|
+
matrix: 4
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const MAX_CHANNELS = 8;
|
|
14
|
+
const MAX_MATRIX_PATHS = 8;
|
|
15
|
+
const MATRIX_PATH_BYTES = 12;
|
|
16
|
+
|
|
17
|
+
function validateChannels(channels) {
|
|
18
|
+
if (!Array.isArray(channels) || channels.length < 1 || channels.length > MAX_CHANNELS) {
|
|
19
|
+
throw new TypeError('IR channels must contain between 1 and 8 Float32Array values');
|
|
20
|
+
}
|
|
21
|
+
const frames = channels[0] instanceof Float32Array ? channels[0].length : 0;
|
|
22
|
+
if (frames === 0) throw new TypeError('IR channels must not be empty');
|
|
23
|
+
for (const channel of channels) {
|
|
24
|
+
if (!(channel instanceof Float32Array) || channel.length !== frames) {
|
|
25
|
+
throw new TypeError('IR channels must be equally sized Float32Array values');
|
|
26
|
+
}
|
|
27
|
+
for (const sample of channel) {
|
|
28
|
+
if (!Number.isFinite(sample)) throw new TypeError('IR samples must be finite');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return frames;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateTopology(topology, paths, channelCount) {
|
|
35
|
+
if (!Number.isInteger(topology) || topology < 0 || topology > IR_ASSET_TOPOLOGY.matrix) {
|
|
36
|
+
throw new TypeError('IR topology must be a supported integer enum value');
|
|
37
|
+
}
|
|
38
|
+
if (topology !== IR_ASSET_TOPOLOGY.matrix) {
|
|
39
|
+
if (paths !== undefined && paths.length !== 0) {
|
|
40
|
+
throw new TypeError('IR paths are only valid for matrix topology');
|
|
41
|
+
}
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(paths) || paths.length < 1 || paths.length > MAX_MATRIX_PATHS) {
|
|
45
|
+
throw new TypeError('Matrix topology requires between 1 and 8 paths');
|
|
46
|
+
}
|
|
47
|
+
return paths.map(path => {
|
|
48
|
+
const inputSlot = path?.inputSlot;
|
|
49
|
+
const outputSlot = path?.outputSlot;
|
|
50
|
+
const irChannel = path?.irChannel;
|
|
51
|
+
if (![inputSlot, outputSlot, irChannel].every(Number.isSafeInteger) ||
|
|
52
|
+
inputSlot < 0 || inputSlot > 0xffffffff || outputSlot < 0 || outputSlot > 0xffffffff ||
|
|
53
|
+
irChannel < 0 || irChannel >= channelCount) {
|
|
54
|
+
throw new TypeError('Matrix paths require non-negative slots and a valid IR channel');
|
|
55
|
+
}
|
|
56
|
+
return { inputSlot, outputSlot, irChannel };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildIrAssetPayload({
|
|
61
|
+
channels,
|
|
62
|
+
sampleRate,
|
|
63
|
+
topology = IR_ASSET_TOPOLOGY.unspecified,
|
|
64
|
+
paths
|
|
65
|
+
}) {
|
|
66
|
+
const frames = validateChannels(channels);
|
|
67
|
+
if (!Number.isSafeInteger(sampleRate) || sampleRate <= 0 || sampleRate > 0xffffffff) {
|
|
68
|
+
throw new TypeError('IR sample rate must be a positive 32-bit integer');
|
|
69
|
+
}
|
|
70
|
+
const matrixPaths = validateTopology(topology, paths, channels.length);
|
|
71
|
+
const pathBytes = matrixPaths.length * MATRIX_PATH_BYTES;
|
|
72
|
+
const sampleBytes = channels.length * frames * Float32Array.BYTES_PER_ELEMENT;
|
|
73
|
+
const payload = new ArrayBuffer(IR_ASSET_HEADER_BYTES + pathBytes + sampleBytes);
|
|
74
|
+
const view = new DataView(payload);
|
|
75
|
+
|
|
76
|
+
view.setUint32(0, IR_ASSET_MAGIC, true);
|
|
77
|
+
view.setUint32(4, channels.length, true);
|
|
78
|
+
view.setUint32(8, frames, true);
|
|
79
|
+
view.setUint32(12, sampleRate, true);
|
|
80
|
+
view.setUint32(16, topology, true);
|
|
81
|
+
view.setUint32(20, matrixPaths.length, true);
|
|
82
|
+
|
|
83
|
+
let offset = IR_ASSET_HEADER_BYTES;
|
|
84
|
+
for (const path of matrixPaths) {
|
|
85
|
+
view.setUint32(offset, path.inputSlot, true);
|
|
86
|
+
view.setUint32(offset + 4, path.outputSlot, true);
|
|
87
|
+
view.setUint32(offset + 8, path.irChannel, true);
|
|
88
|
+
offset += MATRIX_PATH_BYTES;
|
|
89
|
+
}
|
|
90
|
+
for (const channel of channels) {
|
|
91
|
+
for (const sample of channel) {
|
|
92
|
+
view.setFloat32(offset, sample, true);
|
|
93
|
+
offset += Float32Array.BYTES_PER_ELEMENT;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return payload;
|
|
98
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { IR_ASSET_HEADER_BYTES, IR_ASSET_TOPOLOGY } from './ir-asset-payload.js';
|
|
2
|
+
|
|
3
|
+
export const IR_ASSET_SLOT = 0;
|
|
4
|
+
export const IR_KERNEL_ASSET_CAPACITY_BYTES = 32 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
const KERNEL_FIXED_FOOTPRINT_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const KERNEL_PATH_FRAME_BYTES = 16;
|
|
8
|
+
const CONVOLVER_IMPL_BYTES_UPPER_BOUND = 512;
|
|
9
|
+
const CONVOLVER_STAGE_BYTES_UPPER_BOUND = 512;
|
|
10
|
+
const PFFFT_SETUP_FIXED_BYTES_UPPER_BOUND = 136;
|
|
11
|
+
const MATRIX_PATH_BYTES = 12;
|
|
12
|
+
|
|
13
|
+
function topologyPathCount(topology, assetChannels, processingChannels, pathCount = 0) {
|
|
14
|
+
if (topology === IR_ASSET_TOPOLOGY.mono) return processingChannels;
|
|
15
|
+
if (topology === IR_ASSET_TOPOLOGY.trueStereo) return 4;
|
|
16
|
+
if (topology === IR_ASSET_TOPOLOGY.matrix) return pathCount;
|
|
17
|
+
return assetChannels;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function topologyInputCount(topology, processingChannels, inputCount = 0) {
|
|
21
|
+
if (topology === IR_ASSET_TOPOLOGY.trueStereo) return 2;
|
|
22
|
+
if (topology === IR_ASSET_TOPOLOGY.matrix) return inputCount;
|
|
23
|
+
return processingChannels;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function selectedIrChannelCount(channel, engineChannels) {
|
|
27
|
+
if (!Number.isInteger(engineChannels) || engineChannels < 1 || engineChannels > 8) return 0;
|
|
28
|
+
if (channel === 'A') return engineChannels;
|
|
29
|
+
if (channel === null || channel === undefined) return engineChannels >= 2 ? 2 : 1;
|
|
30
|
+
if (channel === 'L' || channel === 'R' || /^[1-8]$/.test(String(channel))) return 1;
|
|
31
|
+
if (channel === '34') return engineChannels >= 4 ? 2 : 0;
|
|
32
|
+
if (channel === '56') return engineChannels >= 6 ? 2 : 0;
|
|
33
|
+
if (channel === '78') return engineChannels >= 8 ? 2 : 0;
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function diagonalPaths(channelCount, selectedChannels) {
|
|
38
|
+
const count = Math.min(channelCount, selectedChannels, 8);
|
|
39
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
40
|
+
inputSlot: index,
|
|
41
|
+
outputSlot: index,
|
|
42
|
+
irChannel: index
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nextPowerOfTwo(value) {
|
|
47
|
+
let result = 1;
|
|
48
|
+
while (result < value) result *= 2;
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function convolutionStages(frames, headBlock) {
|
|
53
|
+
const latency = headBlock ?? 128;
|
|
54
|
+
if (![0, 128, 256, 512, 1024].includes(latency)) {
|
|
55
|
+
throw new TypeError('IR head block must be a supported latency value');
|
|
56
|
+
}
|
|
57
|
+
const head = latency === 0 ? 128 : latency;
|
|
58
|
+
const stages = [];
|
|
59
|
+
const add = (block, offset, end) => {
|
|
60
|
+
if (offset >= frames || end <= offset) return;
|
|
61
|
+
stages.push({ block, offset, segmentFrames: Math.min(end, frames) - offset });
|
|
62
|
+
};
|
|
63
|
+
add(head, latency === 0 ? 128 : 0, 4 * head);
|
|
64
|
+
for (let block = 2 * head; block < 4096; block *= 2) {
|
|
65
|
+
add(block, 2 * block, 4 * block);
|
|
66
|
+
}
|
|
67
|
+
add(4096, 8192, frames);
|
|
68
|
+
return { latency, stages };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function estimateIrConvolverMemoryUpperBound({
|
|
72
|
+
frames,
|
|
73
|
+
assetChannels,
|
|
74
|
+
topology,
|
|
75
|
+
processingChannels,
|
|
76
|
+
headBlock = 128,
|
|
77
|
+
pathCount = 0,
|
|
78
|
+
inputCount = 0
|
|
79
|
+
}) {
|
|
80
|
+
const paths = topologyPathCount(topology, assetChannels, processingChannels, pathCount);
|
|
81
|
+
const inputs = topologyInputCount(topology, processingChannels, inputCount);
|
|
82
|
+
if (paths < 1 || inputs < 1) throw new TypeError('IR topology requires at least one path and input');
|
|
83
|
+
const { latency, stages } = convolutionStages(frames, headBlock);
|
|
84
|
+
let requiredRing = latency + 4096;
|
|
85
|
+
let bytes = CONVOLVER_IMPL_BYTES_UPPER_BOUND;
|
|
86
|
+
for (const stage of stages) {
|
|
87
|
+
const required = latency + stage.offset + stage.block + 4096;
|
|
88
|
+
if (required > requiredRing) requiredRing = required;
|
|
89
|
+
const fft = 2 * stage.block;
|
|
90
|
+
const partitions = Math.ceil(stage.segmentFrames / stage.block);
|
|
91
|
+
const floatCount = 3 * inputs * stage.block + 2 * fft +
|
|
92
|
+
(inputs + assetChannels) * partitions * fft + 2 * processingChannels * fft;
|
|
93
|
+
bytes += CONVOLVER_STAGE_BYTES_UPPER_BOUND + floatCount * 4 +
|
|
94
|
+
nextPowerOfTwo(paths) * 12 + PFFFT_SETUP_FIXED_BYTES_UPPER_BOUND + fft * 4;
|
|
95
|
+
}
|
|
96
|
+
bytes += processingChannels * nextPowerOfTwo(requiredRing) * 4;
|
|
97
|
+
if (latency === 0) bytes += (assetChannels + inputs) * 128 * 4;
|
|
98
|
+
bytes += inputs * 4;
|
|
99
|
+
return bytes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveIrProcessingConfig({
|
|
103
|
+
sampleRate,
|
|
104
|
+
channelCount,
|
|
105
|
+
engineChannels = 8,
|
|
106
|
+
selectedChannels,
|
|
107
|
+
channel,
|
|
108
|
+
channelMode = 'auto',
|
|
109
|
+
latency = '128',
|
|
110
|
+
convolutionRate = 'auto'
|
|
111
|
+
}) {
|
|
112
|
+
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
|
113
|
+
return { valid: false, message: 'The current audio sample rate is unavailable.' };
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isInteger(channelCount) || channelCount < 1 || channelCount > 8) {
|
|
116
|
+
return { valid: false, message: 'This impulse response has an unsupported channel count.' };
|
|
117
|
+
}
|
|
118
|
+
const routedChannels = selectedChannels ?? selectedIrChannelCount(channel, engineChannels);
|
|
119
|
+
if (!Number.isInteger(routedChannels) || routedChannels < 1 || routedChannels > engineChannels) {
|
|
120
|
+
return { valid: false, message: 'The selected audio channels are not available.' };
|
|
121
|
+
}
|
|
122
|
+
if (!['auto', 'mono', 'indep', 'true', 'multi'].includes(channelMode)) {
|
|
123
|
+
return { valid: false, message: 'Choose a supported channel mode.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const headBlock = Number(latency);
|
|
127
|
+
if (![0, 128, 256, 512, 1024].includes(headBlock)) {
|
|
128
|
+
return { valid: false, message: 'Choose a supported latency setting.' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let rateMode = convolutionRate;
|
|
132
|
+
if (headBlock === 0) rateMode = 'full';
|
|
133
|
+
if (rateMode === 'auto') rateMode = sampleRate >= 88200 ? 'half' : 'full';
|
|
134
|
+
if (!['full', 'half', 'quarter'].includes(rateMode)) {
|
|
135
|
+
return { valid: false, message: 'Choose a supported convolution rate.' };
|
|
136
|
+
}
|
|
137
|
+
if (rateMode === 'quarter' && sampleRate < 176400) {
|
|
138
|
+
return {
|
|
139
|
+
valid: false,
|
|
140
|
+
message: 'Quarter rate is available at sample rates of 176.4 kHz or higher.'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rateDivider = rateMode === 'quarter' ? 4 : rateMode === 'half' ? 2 : 1;
|
|
145
|
+
let resolvedMode = channelMode;
|
|
146
|
+
if (resolvedMode === 'auto') {
|
|
147
|
+
if (channelCount === 1) resolvedMode = 'mono';
|
|
148
|
+
else if (channelCount === 4 && routedChannels === 2) {
|
|
149
|
+
resolvedMode = 'true';
|
|
150
|
+
} else if (channelCount === routedChannels) {
|
|
151
|
+
resolvedMode = 'indep';
|
|
152
|
+
} else {
|
|
153
|
+
resolvedMode = 'multi';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let topology;
|
|
158
|
+
let assetChannels;
|
|
159
|
+
let paths = [];
|
|
160
|
+
if (resolvedMode === 'mono') {
|
|
161
|
+
topology = IR_ASSET_TOPOLOGY.mono;
|
|
162
|
+
assetChannels = 1;
|
|
163
|
+
} else if (resolvedMode === 'true') {
|
|
164
|
+
if (channelCount !== 4 || routedChannels !== 2) {
|
|
165
|
+
return { valid: false, message: 'True Stereo requires a four-channel IR and a stereo channel selection.' };
|
|
166
|
+
}
|
|
167
|
+
topology = IR_ASSET_TOPOLOGY.trueStereo;
|
|
168
|
+
assetChannels = 4;
|
|
169
|
+
} else if (resolvedMode === 'indep') {
|
|
170
|
+
if (channelCount < routedChannels) {
|
|
171
|
+
return { valid: false, message: 'Independent mode requires one IR channel for each selected audio channel.' };
|
|
172
|
+
}
|
|
173
|
+
topology = IR_ASSET_TOPOLOGY.independent;
|
|
174
|
+
assetChannels = routedChannels;
|
|
175
|
+
} else {
|
|
176
|
+
paths = diagonalPaths(channelCount, routedChannels);
|
|
177
|
+
if (paths.length === 0) {
|
|
178
|
+
return { valid: false, message: 'Matrix mode could not create a valid channel route.' };
|
|
179
|
+
}
|
|
180
|
+
topology = IR_ASSET_TOPOLOGY.matrix;
|
|
181
|
+
assetChannels = channelCount;
|
|
182
|
+
}
|
|
183
|
+
const pathCount = topology === IR_ASSET_TOPOLOGY.matrix ? paths.length : 0;
|
|
184
|
+
const inputCount = topology === IR_ASSET_TOPOLOGY.matrix
|
|
185
|
+
? new Set(paths.map(path => path.inputSlot)).size
|
|
186
|
+
: 0;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
valid: true,
|
|
190
|
+
channelMode: resolvedMode,
|
|
191
|
+
topology,
|
|
192
|
+
assetChannels,
|
|
193
|
+
selectedChannels: routedChannels,
|
|
194
|
+
processingChannels: routedChannels,
|
|
195
|
+
paths,
|
|
196
|
+
pathCount,
|
|
197
|
+
inputCount,
|
|
198
|
+
headBlock,
|
|
199
|
+
rateMode,
|
|
200
|
+
rateDivider,
|
|
201
|
+
sampleRate: Math.round(sampleRate / rateDivider)
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function estimateIrKernelCommitFootprint({
|
|
206
|
+
frames,
|
|
207
|
+
assetChannels,
|
|
208
|
+
topology,
|
|
209
|
+
processingChannels,
|
|
210
|
+
headBlock,
|
|
211
|
+
pathCount = 0,
|
|
212
|
+
inputCount = 0
|
|
213
|
+
}) {
|
|
214
|
+
if (!Number.isInteger(frames) || frames < 1 ||
|
|
215
|
+
!Number.isInteger(assetChannels) || assetChannels < 1 ||
|
|
216
|
+
!Number.isInteger(processingChannels) || processingChannels < 1) {
|
|
217
|
+
throw new TypeError('IR footprint inputs must be positive integers');
|
|
218
|
+
}
|
|
219
|
+
const paths = topologyPathCount(topology, assetChannels, processingChannels, pathCount);
|
|
220
|
+
const payloadBytes = IR_ASSET_HEADER_BYTES +
|
|
221
|
+
(topology === IR_ASSET_TOPOLOGY.matrix ? paths * MATRIX_PATH_BYTES : 0) +
|
|
222
|
+
frames * assetChannels * Float32Array.BYTES_PER_ELEMENT;
|
|
223
|
+
const kernelBeginBound = payloadBytes + frames * assetChannels * KERNEL_PATH_FRAME_BYTES +
|
|
224
|
+
KERNEL_FIXED_FOOTPRINT_BYTES;
|
|
225
|
+
const convolverBound = payloadBytes + estimateIrConvolverMemoryUpperBound({
|
|
226
|
+
frames,
|
|
227
|
+
assetChannels,
|
|
228
|
+
topology,
|
|
229
|
+
processingChannels,
|
|
230
|
+
headBlock,
|
|
231
|
+
pathCount,
|
|
232
|
+
inputCount
|
|
233
|
+
});
|
|
234
|
+
return Math.max(kernelBeginBound, convolverBound);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function maximumIrFramesForKernel({
|
|
238
|
+
sourceFrames,
|
|
239
|
+
assetChannels,
|
|
240
|
+
topology,
|
|
241
|
+
processingChannels,
|
|
242
|
+
headBlock,
|
|
243
|
+
pathCount = 0,
|
|
244
|
+
inputCount = 0,
|
|
245
|
+
capacityBytes = IR_KERNEL_ASSET_CAPACITY_BYTES
|
|
246
|
+
}) {
|
|
247
|
+
if (!Number.isInteger(sourceFrames) || sourceFrames < 1) return 1;
|
|
248
|
+
let low = 1;
|
|
249
|
+
let high = sourceFrames;
|
|
250
|
+
while (low < high) {
|
|
251
|
+
const middle = Math.ceil((low + high) / 2);
|
|
252
|
+
const footprint = estimateIrKernelCommitFootprint({
|
|
253
|
+
frames: middle,
|
|
254
|
+
assetChannels,
|
|
255
|
+
topology,
|
|
256
|
+
processingChannels,
|
|
257
|
+
headBlock,
|
|
258
|
+
pathCount,
|
|
259
|
+
inputCount
|
|
260
|
+
});
|
|
261
|
+
if (footprint <= capacityBytes) low = middle;
|
|
262
|
+
else high = middle - 1;
|
|
263
|
+
}
|
|
264
|
+
return low;
|
|
265
|
+
}
|