@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.
@@ -0,0 +1,175 @@
1
+ import { createEngineSession } from './engine.js';
2
+ import { errorMessage } from './errors.js';
3
+ import { countTelemetryFrames, TELEMETRY_RING_BYTES } from './telemetry.js';
4
+
5
+ class EffeTuneDspProcessor extends AudioWorkletProcessor {
6
+ constructor() {
7
+ super();
8
+ this.session = null;
9
+ this.ready = false;
10
+ this.closed = false;
11
+ this.channels = 0;
12
+ this.pendingCommands = [];
13
+ this.sourceChannels = [];
14
+ this.targetChannels = [];
15
+ this.telemetryEnabled = false;
16
+ this.telemetryPackets = Array.from(
17
+ { length: 4 },
18
+ () => new ArrayBuffer(TELEMETRY_RING_BYTES)
19
+ );
20
+ this.telemetryDiscardPacket = new Uint8Array(TELEMETRY_RING_BYTES);
21
+ this.telemetryDropped = 0;
22
+ this.port.onmessage = event => this.handleMessage(event.data);
23
+ }
24
+
25
+ handleMessage(message) {
26
+ if (message?.type === 'initialize') {
27
+ this.initialize(message);
28
+ return;
29
+ }
30
+ if (message?.type === 'close') {
31
+ this.closed = true;
32
+ this.session?.close();
33
+ this.session = null;
34
+ return;
35
+ }
36
+ if (message?.type === 'telemetryReturn') {
37
+ if (message.packet instanceof ArrayBuffer &&
38
+ message.packet.byteLength === TELEMETRY_RING_BYTES) {
39
+ this.telemetryPackets.push(message.packet);
40
+ }
41
+ return;
42
+ }
43
+ if (message?.type === 'setParam' || message?.type === 'reset' ||
44
+ message?.type === 'setTelemetryEnabled') {
45
+ this.pendingCommands.push(message);
46
+ }
47
+ }
48
+
49
+ async initialize(message) {
50
+ try {
51
+ this.channels = message.channels;
52
+ this.sourceChannels = new Array(this.channels);
53
+ this.targetChannels = new Array(this.channels);
54
+ const active = message.document.chain.some(effect => effect.enabled);
55
+ if (active) {
56
+ this.session = await createEngineSession(
57
+ { bytes: message.wasmBytes },
58
+ message.document.chain,
59
+ message.resolvedAssets,
60
+ {
61
+ sampleRate,
62
+ channels: message.channels,
63
+ maxFrames: 128,
64
+ seed: message.seed
65
+ }
66
+ );
67
+ }
68
+ this.ready = true;
69
+ this.port.postMessage({ type: 'ready' });
70
+ } catch (error) {
71
+ this.port.postMessage({
72
+ type: 'initializationError',
73
+ ...errorMessage(
74
+ error,
75
+ 'The AudioWorklet DSP processor could not be initialized.'
76
+ )
77
+ });
78
+ }
79
+ }
80
+
81
+ applyCommands() {
82
+ for (const command of this.pendingCommands.splice(0)) {
83
+ try {
84
+ if (command.type === 'setParam') {
85
+ this.session?.setPacked(
86
+ command.effectId,
87
+ command.values,
88
+ command.hash,
89
+ command.bytes
90
+ );
91
+ } else if (command.type === 'reset') {
92
+ this.session?.reset();
93
+ } else if (command.type === 'setTelemetryEnabled') {
94
+ this.telemetryEnabled = command.enabled === true;
95
+ this.session?.setTelemetryEnabled(this.telemetryEnabled);
96
+ continue;
97
+ }
98
+ this.port.postMessage({ type: 'commandResult', commandId: command.commandId, ok: true });
99
+ } catch (error) {
100
+ this.port.postMessage({
101
+ type: 'commandResult',
102
+ commandId: command.commandId,
103
+ ok: false,
104
+ ...errorMessage(error, 'The AudioWorklet command failed.')
105
+ });
106
+ }
107
+ }
108
+ }
109
+
110
+ drainTelemetry() {
111
+ if (!this.session || !this.telemetryEnabled) return;
112
+ const buffer = this.telemetryPackets.pop();
113
+ const packet = buffer ? new Uint8Array(buffer) : this.telemetryDiscardPacket;
114
+ const { bytes, dropped } = this.session.readTelemetryPacket(packet);
115
+ if (!buffer) {
116
+ this.telemetryDropped += dropped + countTelemetryFrames(packet, bytes);
117
+ return;
118
+ }
119
+ if (bytes === 0) {
120
+ this.telemetryDropped += dropped;
121
+ this.telemetryPackets.push(buffer);
122
+ return;
123
+ }
124
+ this.port.postMessage({
125
+ type: 'telemetry',
126
+ packet: buffer,
127
+ bytes,
128
+ dropped: this.telemetryDropped + dropped
129
+ }, [buffer]);
130
+ this.telemetryDropped = 0;
131
+ }
132
+
133
+ passthrough(input, output) {
134
+ for (let channel = 0; channel < output.length; channel++) {
135
+ const source = input[channel];
136
+ if (source) output[channel].set(source);
137
+ else output[channel].fill(0);
138
+ }
139
+ }
140
+
141
+ process(inputs, outputs) {
142
+ if (this.closed) return false;
143
+ const input = inputs[0] ?? [];
144
+ const output = outputs[0] ?? [];
145
+ if (!this.ready) {
146
+ this.passthrough(input, output);
147
+ return true;
148
+ }
149
+ this.applyCommands();
150
+ if (!this.session) {
151
+ this.passthrough(input, output);
152
+ return true;
153
+ }
154
+ for (let channel = 0; channel < this.channels; channel++) {
155
+ this.sourceChannels[channel] = input[channel] ?? output[channel];
156
+ this.targetChannels[channel] = output[channel];
157
+ }
158
+ try {
159
+ this.session.process(this.sourceChannels, this.targetChannels, 0, 128, sampleRate);
160
+ this.drainTelemetry();
161
+ } catch (error) {
162
+ this.session.close();
163
+ this.session = null;
164
+ for (const channel of output) channel.fill(0);
165
+ this.port.postMessage({
166
+ type: 'processingError',
167
+ ...errorMessage(error, 'Audio processing failed.')
168
+ });
169
+ return false;
170
+ }
171
+ return true;
172
+ }
173
+ }
174
+
175
+ registerProcessor('effetune-dsp-processor', EffeTuneDspProcessor);
@@ -0,0 +1,32 @@
1
+ import type {
2
+ ArtifactOptions,
3
+ AssetResolver,
4
+ BundleDocument,
5
+ ChainDocumentInput,
6
+ ChainEffectInput,
7
+ TelemetryCallback
8
+ } from './index.js';
9
+ import type { Effect } from './effect.js';
10
+
11
+ export interface EffeTuneNodeOptions extends ArtifactOptions {
12
+ readonly channels?: number;
13
+ readonly seed?: number;
14
+ readonly assetResolver?: AssetResolver | { resolve: AssetResolver };
15
+ readonly processorUrl?: string | URL;
16
+ }
17
+
18
+ export declare class EffeTuneNode extends AudioWorkletNode {
19
+ private constructor();
20
+ static create(
21
+ context: BaseAudioContext,
22
+ input: string | ChainDocumentInput | BundleDocument |
23
+ readonly (Effect | ChainEffectInput)[],
24
+ options?: EffeTuneNodeOptions
25
+ ): Promise<EffeTuneNode>;
26
+ readonly droppedTelemetryFrames: number;
27
+ subscribe(callback: TelemetryCallback): () => void;
28
+ unsubscribe(callback: TelemetryCallback): boolean;
29
+ setParam(effectId: string, parameterName: string, value: unknown): Promise<void>;
30
+ reset(): Promise<void>;
31
+ close(): void;
32
+ }
@@ -0,0 +1,386 @@
1
+ import { loadDspArtifact } from './artifacts.js';
2
+ import { resolveChainAssets, splitBundle } from './assets.js';
3
+ import {
4
+ EffeTuneRuntimeError,
5
+ errorFromMessage,
6
+ StateError,
7
+ ValidationError
8
+ } from './errors.js';
9
+ import {
10
+ normalizeChainDocument,
11
+ channelRange,
12
+ packEffect,
13
+ setEffectParameter,
14
+ validateStreamParameterUpdate,
15
+ validateEffectSampleRate,
16
+ validateSeed
17
+ } from './semantics.js';
18
+ import { decodeTelemetryPacket } from './telemetry.js';
19
+
20
+ const PROCESSOR_NAME = 'effetune-dsp-processor';
21
+ const AudioWorkletNodeBase = globalThis.AudioWorkletNode ?? class {};
22
+ const moduleLoads = new WeakMap();
23
+
24
+ function activeEffects(document) {
25
+ return document.chain.filter(effect => effect.enabled);
26
+ }
27
+
28
+ function cloneDocument(document) {
29
+ return {
30
+ version: 1,
31
+ chain: document.chain.map(effect => ({
32
+ ...effect,
33
+ parameters: Object.fromEntries(
34
+ Object.entries(effect.parameters).map(([name, value]) => [
35
+ name,
36
+ Array.isArray(value) ? [...value] : value
37
+ ])
38
+ ),
39
+ ...(effect.assets ? { assets: { ...effect.assets } } : {})
40
+ }))
41
+ };
42
+ }
43
+
44
+ function validateChannels(channels) {
45
+ if (!Number.isInteger(channels) || channels < 1 || channels > 8) {
46
+ throw new ValidationError('channels must be an integer from 1 to 8.');
47
+ }
48
+ return channels;
49
+ }
50
+
51
+ function loadProcessorModule(context, processorUrl) {
52
+ if (!context?.audioWorklet || typeof context.audioWorklet.addModule !== 'function') {
53
+ throw new ValidationError('An AudioContext with AudioWorklet support is required.');
54
+ }
55
+ let byUrl = moduleLoads.get(context.audioWorklet);
56
+ if (!byUrl) {
57
+ byUrl = new Map();
58
+ moduleLoads.set(context.audioWorklet, byUrl);
59
+ }
60
+ const key = String(processorUrl);
61
+ if (!byUrl.has(key)) {
62
+ let load;
63
+ try {
64
+ load = Promise.resolve(context.audioWorklet.addModule(processorUrl));
65
+ } catch (error) {
66
+ throw new EffeTuneRuntimeError(
67
+ 'Unable to load the AudioWorklet DSP processor module.',
68
+ { cause: error }
69
+ );
70
+ }
71
+ const cached = load.catch(error => {
72
+ if (byUrl.get(key) === cached) byUrl.delete(key);
73
+ throw new EffeTuneRuntimeError(
74
+ 'Unable to load the AudioWorklet DSP processor module.',
75
+ { cause: error }
76
+ );
77
+ });
78
+ byUrl.set(key, cached);
79
+ }
80
+ return byUrl.get(key);
81
+ }
82
+
83
+ function validateProcessorUrl(value) {
84
+ const url = value instanceof URL ? value : new URL(String(value), import.meta.url);
85
+ if (!['file:', 'https:', 'http:'].includes(url.protocol)) {
86
+ throw new ValidationError(`Unsupported AudioWorklet processor URL scheme: ${url.protocol}`);
87
+ }
88
+ if (url.protocol === 'http:') {
89
+ const moduleUrl = new URL(import.meta.url);
90
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
91
+ if (!local && url.origin !== moduleUrl.origin) {
92
+ throw new ValidationError('Cross-origin AudioWorklet processors must use HTTPS.');
93
+ }
94
+ }
95
+ return url;
96
+ }
97
+
98
+ function cloneAssetsForWorklet(resolvedAssets) {
99
+ const cloned = new Map();
100
+ const transfer = [];
101
+ for (const [effectId, assets] of resolvedAssets) {
102
+ const values = {};
103
+ for (const [name, asset] of Object.entries(assets)) {
104
+ const bytes = new Uint8Array(asset.bytes);
105
+ values[name] = { ...asset, bytes };
106
+ transfer.push(bytes.buffer);
107
+ }
108
+ cloned.set(effectId, values);
109
+ }
110
+ return { cloned, transfer };
111
+ }
112
+
113
+ export class EffeTuneNode extends AudioWorkletNodeBase {
114
+ static async create(context, input, options = {}) {
115
+ const channels = validateChannels(options.channels ?? 2);
116
+ const seed = validateSeed(options.seed ?? 0);
117
+ const source = typeof input === 'string'
118
+ ? (() => {
119
+ try {
120
+ return JSON.parse(input);
121
+ } catch (error) {
122
+ throw new ValidationError('Chain input is not valid JSON.', { cause: error });
123
+ }
124
+ })()
125
+ : input;
126
+ const { chain, manifest } = splitBundle(source);
127
+ const document = normalizeChainDocument(chain);
128
+ const resolvedAssets = await resolveChainAssets(document, {
129
+ assetResolver: options.assetResolver,
130
+ manifest
131
+ });
132
+ for (const effect of activeEffects(document)) {
133
+ validateEffectSampleRate(effect, context.sampleRate);
134
+ channelRange(effect.channel, channels);
135
+ }
136
+
137
+ const processorUrl = validateProcessorUrl(
138
+ options.processorUrl ?? new URL('./worklet-processor.js', import.meta.url)
139
+ );
140
+ await loadProcessorModule(context, processorUrl);
141
+ const artifact = activeEffects(document).length > 0
142
+ ? await loadDspArtifact({
143
+ variant: options.variant,
144
+ wasmUrl: options.wasmUrl,
145
+ simdWasmUrl: options.simdWasmUrl,
146
+ metaUrl: options.metaUrl,
147
+ fetch: options.fetch,
148
+ webAssembly: options.webAssembly,
149
+ cache: options.cache
150
+ })
151
+ : null;
152
+ const node = new EffeTuneNode(context, channels, document, seed);
153
+ const wasmBytes = artifact ? artifact.bytes.slice(0) : null;
154
+ const assets = cloneAssetsForWorklet(resolvedAssets);
155
+ const transfer = [...assets.transfer, ...(wasmBytes ? [wasmBytes] : [])];
156
+ node.port.postMessage({
157
+ type: 'initialize',
158
+ document,
159
+ resolvedAssets: assets.cloned,
160
+ wasmBytes,
161
+ channels,
162
+ seed
163
+ }, transfer);
164
+ try {
165
+ await node._waitUntilReady();
166
+ return node;
167
+ } catch (error) {
168
+ node.close();
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ constructor(context, channels, document, seed) {
174
+ super(context, PROCESSOR_NAME, {
175
+ numberOfInputs: 1,
176
+ numberOfOutputs: 1,
177
+ outputChannelCount: [channels],
178
+ channelCount: channels,
179
+ channelCountMode: 'explicit',
180
+ channelInterpretation: 'discrete'
181
+ });
182
+ this._initialDocument = cloneDocument(document);
183
+ this._document = cloneDocument(document);
184
+ this._seed = seed;
185
+ this._closed = false;
186
+ this._runtimeError = null;
187
+ this._nextCommandId = 1;
188
+ this._pending = new Map();
189
+ this._telemetryCallbacks = new Set();
190
+ this._telemetryPendingDropped = 0;
191
+ this._droppedTelemetryFrames = 0;
192
+ let tapId = 1;
193
+ this._telemetryNodesByTap = new Map();
194
+ for (const [effectIndex, effect] of document.chain.entries()) {
195
+ if (!effect.enabled) continue;
196
+ this._telemetryNodesByTap.set(tapId++, {
197
+ effectType: effect.type,
198
+ effectId: effect.id,
199
+ effectIndex
200
+ });
201
+ }
202
+ this._mutationQueue = Promise.resolve();
203
+ this._ready = new Promise((resolve, reject) => {
204
+ this._resolveReady = resolve;
205
+ this._rejectReady = reject;
206
+ });
207
+ this.port.onmessage = event => this._handleMessage(event.data);
208
+ this.port.start?.();
209
+ }
210
+
211
+ _handleMessage(message) {
212
+ if (message?.type === 'telemetry') {
213
+ const packet = message.packet;
214
+ const dropped = Number.isInteger(message.dropped) && message.dropped >= 0
215
+ ? message.dropped
216
+ : 0;
217
+ this._droppedTelemetryFrames += dropped;
218
+ if (packet instanceof ArrayBuffer) {
219
+ const decoded = decodeTelemetryPacket(
220
+ new Uint8Array(packet),
221
+ message.bytes,
222
+ this._telemetryNodesByTap,
223
+ this._telemetryPendingDropped + dropped
224
+ );
225
+ this._telemetryPendingDropped = decoded.pendingDropped;
226
+ for (const frame of decoded.frames) {
227
+ for (const callback of this._telemetryCallbacks) {
228
+ try {
229
+ callback(frame);
230
+ } catch (error) {
231
+ console.warn('EffeTune telemetry callback failed.', error);
232
+ }
233
+ }
234
+ }
235
+ this.port.postMessage({ type: 'telemetryReturn', packet }, [packet]);
236
+ }
237
+ return;
238
+ }
239
+ if (message?.type === 'ready') {
240
+ this._resolveReady();
241
+ return;
242
+ }
243
+ if (message?.type === 'initializationError') {
244
+ this._rejectReady(errorFromMessage(
245
+ message.errorType,
246
+ message.message,
247
+ 'The AudioWorklet DSP processor could not be initialized.'
248
+ ));
249
+ return;
250
+ }
251
+ if (message?.type === 'processingError') {
252
+ const error = errorFromMessage(
253
+ message.errorType,
254
+ message.message,
255
+ 'The AudioWorklet DSP processor stopped after a processing error.'
256
+ );
257
+ this._runtimeError = error;
258
+ for (const pending of this._pending.values()) pending.reject(error);
259
+ this._pending.clear();
260
+ this.dispatchEvent?.(new Event('processorerror'));
261
+ return;
262
+ }
263
+ if (message?.type !== 'commandResult') return;
264
+ const pending = this._pending.get(message.commandId);
265
+ if (!pending) return;
266
+ this._pending.delete(message.commandId);
267
+ if (message.ok) pending.resolve();
268
+ else pending.reject(errorFromMessage(
269
+ message.errorType,
270
+ message.message,
271
+ 'The AudioWorklet command failed.'
272
+ ));
273
+ }
274
+
275
+ async _waitUntilReady() {
276
+ const timeout = new Promise((_, reject) => {
277
+ const timer = setTimeout(() => {
278
+ reject(errorFromMessage(
279
+ 'EffeTuneRuntimeError',
280
+ undefined,
281
+ 'Timed out while initializing the AudioWorklet DSP processor.'
282
+ ));
283
+ }, 10000);
284
+ this._ready.then(
285
+ () => clearTimeout(timer),
286
+ () => clearTimeout(timer)
287
+ );
288
+ });
289
+ return Promise.race([this._ready, timeout]);
290
+ }
291
+
292
+ _assertOpen() {
293
+ if (this._closed) throw new StateError('The EffeTuneNode is closed.');
294
+ if (this._runtimeError) throw this._runtimeError;
295
+ }
296
+
297
+ _command(command) {
298
+ this._assertOpen();
299
+ const commandId = this._nextCommandId++;
300
+ const promise = new Promise((resolve, reject) => {
301
+ this._pending.set(commandId, { resolve, reject });
302
+ });
303
+ this.port.postMessage({ ...command, commandId });
304
+ return promise;
305
+ }
306
+
307
+ _mutate(operation) {
308
+ const result = this._mutationQueue.then(operation);
309
+ this._mutationQueue = result.catch(() => {});
310
+ return result;
311
+ }
312
+
313
+ get droppedTelemetryFrames() {
314
+ this._assertOpen();
315
+ return this._droppedTelemetryFrames;
316
+ }
317
+
318
+ subscribe(callback) {
319
+ this._assertOpen();
320
+ if (typeof callback !== 'function') {
321
+ throw new TypeError('Telemetry callback must be a function.');
322
+ }
323
+ const wasEmpty = this._telemetryCallbacks.size === 0;
324
+ this._telemetryCallbacks.add(callback);
325
+ if (wasEmpty) {
326
+ this.port.postMessage({ type: 'setTelemetryEnabled', enabled: true });
327
+ }
328
+ return () => this.unsubscribe(callback);
329
+ }
330
+
331
+ unsubscribe(callback) {
332
+ this._assertOpen();
333
+ const removed = this._telemetryCallbacks.delete(callback);
334
+ if (removed && this._telemetryCallbacks.size === 0) {
335
+ this.port.postMessage({ type: 'setTelemetryEnabled', enabled: false });
336
+ }
337
+ return removed;
338
+ }
339
+
340
+ setParam(effectId, parameterName, value) {
341
+ return this._mutate(async () => {
342
+ this._assertOpen();
343
+ const index = this._document.chain.findIndex(effect => effect.id === effectId);
344
+ if (index < 0) throw new ValidationError(`Unknown effect id: ${effectId}`);
345
+ const effects = [...this._document.chain];
346
+ validateStreamParameterUpdate(effects[index], parameterName);
347
+ effects[index] = setEffectParameter(effects[index], parameterName, value);
348
+ if (effects[index].enabled) {
349
+ const packed = packEffect(effects[index]);
350
+ await this._command({
351
+ type: 'setParam',
352
+ effectId,
353
+ values: packed.values,
354
+ hash: packed.hash,
355
+ bytes: packed.bytes
356
+ });
357
+ }
358
+ this._document = { version: 1, chain: effects };
359
+ });
360
+ }
361
+
362
+ reset() {
363
+ return this._mutate(async () => {
364
+ this._assertOpen();
365
+ await this._command({ type: 'reset' });
366
+ this._document = cloneDocument(this._initialDocument);
367
+ });
368
+ }
369
+
370
+ close() {
371
+ if (this._closed) return;
372
+ this._closed = true;
373
+ this._telemetryCallbacks.clear();
374
+ this.port.postMessage({ type: 'close' });
375
+ for (const pending of this._pending.values()) {
376
+ pending.reject(new StateError('The EffeTuneNode was closed before its command completed.'));
377
+ }
378
+ this._pending.clear();
379
+ this.port.onmessage = null;
380
+ try {
381
+ this.disconnect();
382
+ } catch {
383
+ // Already disconnected.
384
+ }
385
+ }
386
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@effetune/dsp",
3
+ "version": "0.0.0",
4
+ "description": "EffeTune's deterministic WebAssembly audio effects for JavaScript and AudioWorklet",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Yoshiyuki Kobayashi",
8
+ "homepage": "https://effetune.frieve.com/dsp/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/Frieve-A/effetune",
12
+ "directory": "dsp/bindings/js"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Frieve-A/effetune/issues"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "sideEffects": [
21
+ "./dist/worklet-processor.js"
22
+ ],
23
+ "files": [
24
+ "dist/",
25
+ "LICENSE",
26
+ "README.md",
27
+ "THIRD_PARTY_NOTICES.txt"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ },
34
+ "./worklet": {
35
+ "types": "./dist/worklet.d.ts",
36
+ "import": "./dist/worklet.js"
37
+ },
38
+ "./processor": "./dist/worklet-processor.js",
39
+ "./schemas/chain-v1.json": "./dist/schemas/chain-v1.schema.json",
40
+ "./schemas/bundle-v1.json": "./dist/schemas/bundle-v1.schema.json",
41
+ "./catalog": {
42
+ "types": "./dist/catalog-entry.d.ts",
43
+ "import": "./dist/catalog-entry.js"
44
+ },
45
+ "./catalog.json": "./dist/catalog/effects-v1.json"
46
+ },
47
+ "scripts": {
48
+ "build": "node scripts/build.mjs",
49
+ "test": "npm run build && node --test test/*.test.mjs",
50
+ "pack:check": "node scripts/verify-pack.mjs",
51
+ "install:check": "node scripts/verify-install.mjs"
52
+ }
53
+ }