node-web-audio-api 0.20.0 → 0.21.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,261 @@
1
+ const {
2
+ resolveObjectURL
3
+ } = require('node:buffer');
4
+ const fs = require('node:fs').promises;
5
+ const { existsSync } = require('node:fs');
6
+ const path = require('node:path');
7
+ const {
8
+ Worker,
9
+ MessageChannel,
10
+ } = require('node:worker_threads');
11
+
12
+ const {
13
+ kProcessorRegistered,
14
+ kGetParameterDescriptors,
15
+ kCreateProcessor,
16
+ kPrivateConstructor,
17
+ kWorkletRelease,
18
+ kCheckProcessorsCreated,
19
+ } = require('./lib/symbols.js');
20
+ const {
21
+ kEnumerableProperty,
22
+ } = require('./lib/utils.js');
23
+
24
+ const caller = require('caller');
25
+ // cf. https://www.npmjs.com/package/node-fetch#commonjs
26
+ const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
27
+
28
+ /**
29
+ * Retrieve code with different module resolution strategies
30
+ * - file - absolute or relative to cwd path
31
+ * - URL
32
+ * - Blob
33
+ * - fallback: relative to caller site
34
+ * + in fs
35
+ * + caller site is url - required for wpt, probably no other use case
36
+ */
37
+ const resolveModule = async (moduleUrl) => {
38
+ let code;
39
+
40
+ if (existsSync(moduleUrl)) {
41
+ const pathname = moduleUrl;
42
+
43
+ try {
44
+ const buffer = await fs.readFile(pathname);
45
+ code = buffer.toString();
46
+ } catch (err) {
47
+ throw new Error(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`);
48
+ }
49
+ } else if (moduleUrl.startsWith('http')) {
50
+ try {
51
+ const res = await fetch(moduleUrl);
52
+ code = await res.text();
53
+ } catch (err) {
54
+ throw new Error(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`);
55
+ }
56
+ } else if (moduleUrl.startsWith('blob:')) {
57
+ try {
58
+ const blob = resolveObjectURL(moduleUrl);
59
+ code = await blob.text();
60
+ } catch (err) {
61
+ throw new Error(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`);
62
+ }
63
+ } else {
64
+ // get caller site from error stack trace
65
+ const callerSite = caller(2);
66
+
67
+ if (callerSite.startsWith('http')) {
68
+ let url;
69
+ // handle origin relative and caller path relative URLs
70
+ if (moduleUrl.startsWith('/')) {
71
+ const origin = new URL(baseUrl).origin;
72
+ url = origin + moduleUrl;
73
+ } else {
74
+ // we know separators are '/'
75
+ const baseUrl = callerSite.substr(0, callerSite.lastIndexOf('/'));
76
+ url = baseUrl + '/' + moduleUrl;
77
+ }
78
+
79
+ try {
80
+ const res = await fetch(url);
81
+ code = await res.text();
82
+ } catch (err) {
83
+ throw new Error(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`);
84
+ }
85
+ } else {
86
+ const dirname = callerSite.substr(0, callerSite.lastIndexOf(path.sep));
87
+ const absDirname = dirname.replace('file://', '');
88
+ const pathname = path.join(absDirname, moduleUrl);
89
+
90
+ if (existsSync(pathname)) {
91
+ try {
92
+ const buffer = await fs.readFile(pathname);
93
+ code = buffer.toString();
94
+ } catch (err) {
95
+ throw new Error(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`);
96
+ }
97
+ } else {
98
+ throw new Error(`Failed to execute 'addModule' on 'AudioWorklet': Cannot resolve module ${moduleUrl}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ return code;
104
+ }
105
+
106
+ class AudioWorklet {
107
+ #workletId = null;
108
+ #sampleRate = null;
109
+ #port = null;
110
+ #idPromiseMap = new Map();
111
+ #promiseId = 0;
112
+ #workletParamDescriptorsMap = new Map();
113
+ #pendingCreateProcessors = new Set();
114
+
115
+ constructor(options) {
116
+ if (
117
+ (typeof options !== 'object') ||
118
+ options[kPrivateConstructor] !== true
119
+ ) {
120
+ throw new TypeError('Illegal constructor');
121
+ }
122
+
123
+ this.#workletId = options.workletId;
124
+ this.#sampleRate = options.sampleRate;
125
+ }
126
+
127
+ #bindEvents() {
128
+ this.#port.on('message', event => {
129
+ switch (event.cmd) {
130
+ case 'node-web-audio-api:worklet:module-added': {
131
+ const { promiseId } = event;
132
+ const { resolve } = this.#idPromiseMap.get(promiseId);
133
+ this.#idPromiseMap.delete(promiseId);
134
+ resolve();
135
+ break;
136
+ }
137
+ case 'node-web-audio-api:worlet:processor-registered': {
138
+ const { name, parameterDescriptors } = event;
139
+ this.#workletParamDescriptorsMap.set(name, parameterDescriptors);
140
+ break;
141
+ }
142
+ case 'node-web-audio-api:worklet:processor-created': {
143
+ const { id } = event;
144
+ this.#pendingCreateProcessors.delete(id);
145
+ break;
146
+ }
147
+ }
148
+ });
149
+ }
150
+
151
+ get port() {
152
+ return this.#port;
153
+ }
154
+
155
+ async addModule(moduleUrl) {
156
+ const code = await resolveModule(moduleUrl);
157
+
158
+ // launch Worker if not exists
159
+ if (!this.#port) {
160
+ await new Promise(resolve => {
161
+ const workletPathname = path.join(__dirname, 'AudioWorkletGlobalScope.js');
162
+ this.#port = new Worker(workletPathname, {
163
+ workerData: {
164
+ workletId: this.#workletId,
165
+ sampleRate: this.#sampleRate,
166
+ },
167
+ });
168
+ this.#port.on('online', resolve);
169
+
170
+ this.#bindEvents();
171
+ });
172
+ }
173
+
174
+ const promiseId = this.#promiseId++;
175
+ // This promise is resolved when the Worker returns the name and
176
+ // parameterDescriptors from the added module
177
+ await new Promise((resolve, reject) => {
178
+ this.#idPromiseMap.set(promiseId, { resolve, reject });
179
+
180
+ this.#port.postMessage({
181
+ cmd: 'node-web-audio-api:worklet:add-module',
182
+ code,
183
+ promiseId,
184
+ });
185
+ });
186
+ }
187
+
188
+ // For OfflineAudioContext only, check that all processors have been properly
189
+ // created before actual `startRendering`
190
+ async [kCheckProcessorsCreated]() {
191
+ // console.log(this.#pendingCreateProcessors);
192
+ return new Promise(async resolve => {
193
+ while (this.#pendingCreateProcessors.size !== 0) {
194
+ // we need a microtask to ensure message can be received
195
+ await new Promise(resolve => setTimeout(resolve, 0));
196
+ }
197
+
198
+ resolve();
199
+ });
200
+ }
201
+
202
+ [kProcessorRegistered](name) {
203
+ return Array.from(this.#workletParamDescriptorsMap.keys()).includes(name);
204
+ }
205
+
206
+ [kGetParameterDescriptors](name) {
207
+ return this.#workletParamDescriptorsMap.get(name);
208
+ }
209
+
210
+ [kCreateProcessor](name, options, id) {
211
+ this.#pendingCreateProcessors.add(id);
212
+
213
+ const { port1, port2 } = new MessageChannel();
214
+ // @todo - check if some processorOptions must be transfered as well
215
+ this.#port.postMessage({
216
+ cmd: 'node-web-audio-api:worklet:create-processor',
217
+ name,
218
+ id,
219
+ options,
220
+ port: port2,
221
+ }, [port2]);
222
+
223
+ return port1;
224
+ }
225
+
226
+ async [kWorkletRelease]() {
227
+ if (this.#port) {
228
+ await new Promise(resolve => {
229
+ this.#port.on('exit', resolve);
230
+ this.#port.postMessage({
231
+ cmd: 'node-web-audio-api:worklet:exit',
232
+ });
233
+ });
234
+ }
235
+ }
236
+ }
237
+
238
+ Object.defineProperties(AudioWorklet, {
239
+ length: {
240
+ __proto__: null,
241
+ writable: false,
242
+ enumerable: false,
243
+ configurable: true,
244
+ value: 0,
245
+ },
246
+ });
247
+
248
+ Object.defineProperties(AudioWorklet.prototype, {
249
+ [Symbol.toStringTag]: {
250
+ __proto__: null,
251
+ writable: false,
252
+ enumerable: false,
253
+ configurable: true,
254
+ value: 'AudioWorklet',
255
+ },
256
+ addModule: kEnumerableProperty,
257
+ port: kEnumerableProperty,
258
+ });
259
+
260
+ module.exports = AudioWorklet;
261
+
@@ -0,0 +1,303 @@
1
+ const {
2
+ parentPort,
3
+ workerData,
4
+ } = require('node:worker_threads');
5
+
6
+ const conversions = require('webidl-conversions');
7
+
8
+ const {
9
+ exit_audio_worklet_global_scope,
10
+ run_audio_worklet_global_scope,
11
+ } = require('../load-native.cjs');
12
+
13
+ const {
14
+ workletId,
15
+ sampleRate,
16
+ } = workerData;
17
+
18
+ const kWorkletQueueTask = Symbol.for('node-web-audio-api:worklet-queue-task');
19
+ const kWorkletCallableProcess = Symbol.for('node-web-audio-api:worklet-callable-process');
20
+ const kWorkletInputs = Symbol.for('node-web-audio-api:worklet-inputs');
21
+ const kWorkletOutputs = Symbol.for('node-web-audio-api:worklet-outputs');
22
+ const kWorkletParams = Symbol.for('node-web-audio-api:worklet-params');
23
+ const kWorkletParamsCache = Symbol.for('node-web-audio-api:worklet-params-cache');
24
+ // const kWorkletOrderedParamNames = Symbol.for('node-web-audio-api:worklet-ordered-param-names');
25
+
26
+ const nameProcessorCtorMap = new Map();
27
+ const processors = {};
28
+ let pendingProcessorConstructionData = null;
29
+ let loopStarted = false;
30
+ let runLoopImmediateId = null;
31
+
32
+ function isIterable(obj) {
33
+ // checks for null and undefined
34
+ if (obj === null || obj === undefined) {
35
+ return false;
36
+ }
37
+ return typeof obj[Symbol.iterator] === 'function';
38
+ }
39
+
40
+ // cf. https://stackoverflow.com/a/46759625
41
+ function isConstructor(f) {
42
+ try {
43
+ Reflect.construct(String, [], f);
44
+ } catch (e) {
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+
50
+ function runLoop() {
51
+ // block until we need to render a quantum
52
+ run_audio_worklet_global_scope(workletId, processors);
53
+ // yield to the event loop, and then repeat
54
+ runLoopImmediateId = setImmediate(runLoop);
55
+ }
56
+
57
+ // s
58
+ globalThis.currentTime = 0
59
+ globalThis.currentFrame = 0;
60
+ globalThis.sampleRate = sampleRate;
61
+ // @todo - implement in upstream crate
62
+ // globalThis.renderQuantumSize = 128;
63
+
64
+ globalThis.AudioWorkletProcessor = class AudioWorkletProcessor {
65
+ static get parameterDescriptors() {
66
+ return [];
67
+ }
68
+
69
+ #port = null;
70
+
71
+ constructor() {
72
+ const {
73
+ port,
74
+ numberOfInputs,
75
+ numberOfOutputs,
76
+ parameterDescriptors,
77
+ } = pendingProcessorConstructionData;
78
+
79
+ // @todo - Mark [[callable process]] as true, set to false in render quantum
80
+ // either "process" doese not exists, either it throws an error
81
+ this[kWorkletCallableProcess] = true;
82
+ // @todo - reuse Float32Arrays between calls + freeze arrays
83
+ this[kWorkletInputs] = new Array(numberOfInputs).fill([]);
84
+ // @todo - use `outputChannelCount`
85
+ this[kWorkletOutputs] = new Array(numberOfOutputs).fill([]);
86
+ // Object to be reused as `process` parameters argument
87
+ this[kWorkletParams] = {};
88
+ // Cache of 2 Float32Array (of length 128 and 1) for each param, to be reused on
89
+ // each process call according to the size the param for the current render quantum
90
+ this[kWorkletParamsCache] = {};
91
+
92
+ parameterDescriptors.forEach(desc => {
93
+ this[kWorkletParamsCache][desc.name] = [
94
+ new Float32Array(128), // should be globalThis.renderQuantumSize
95
+ new Float32Array(1),
96
+ ]
97
+ });
98
+
99
+ this.#port = port;
100
+ }
101
+
102
+ get port() {
103
+ if (!(this instanceof AudioWorkletProcessor)) {
104
+ throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioWorkletProcessor\'');
105
+ }
106
+
107
+ return this.#port;
108
+ }
109
+
110
+ [kWorkletQueueTask](cmd, err) {
111
+ this.#port.postMessage({ cmd, err });
112
+ }
113
+ }
114
+
115
+ // follow algorithm from:
116
+ // https://webaudio.github.io/web-audio-api/#dom-audioworkletglobalscope-registerprocessor
117
+ globalThis.registerProcessor = function registerProcessor(name, processorCtor) {
118
+ const parsedName = conversions['DOMString'](name, {
119
+ context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': name (${name})`,
120
+ });
121
+
122
+ if (parsedName === '') {
123
+ throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': name is empty`, 'NotSupportedError');
124
+ }
125
+
126
+ if (nameProcessorCtorMap.has(name)) {
127
+ throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': A processor with name '${name}' has already been registered in this scope`, 'NotSupportedError');
128
+ }
129
+
130
+ if (!isConstructor(processorCtor)) {
131
+ throw new TypeError(`Cannot execute 'registerProcessor")' in 'AudoWorkletGlobalScope': argument 2 for name '${name}' is not a constructor`);
132
+ }
133
+
134
+ if (typeof processorCtor.prototype !== 'object') {
135
+ throw new TypeError(`Cannot execute 'registerProcessor")' in 'AudoWorkletGlobalScope': argument 2 for name '${name}' is not is not a valid AudioWorkletProcessor`);
136
+ }
137
+
138
+ // must support Array, Set or iterators
139
+ let parameterDescriptorsValue = processorCtor.parameterDescriptors;
140
+
141
+ if (!isIterable(parameterDescriptorsValue)) {
142
+ throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: 'parameterDescriptors' is not iterable'`);
143
+ }
144
+
145
+ const paramDescriptors = Array.from(parameterDescriptorsValue);
146
+ const parsedParamDescriptors = [];
147
+
148
+ // Parse AudioParamDescriptor sequence
149
+ // cf. https://webaudio.github.io/web-audio-api/#AudioParamDescriptor
150
+ for (let i = 0; i < paramDescriptors.length; i++) {
151
+ const descriptor = paramDescriptors[i];
152
+ const parsedDescriptor = {};
153
+
154
+ if (typeof descriptor !== 'object' || descriptor === null) {
155
+ throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Element at index ${i} is not an instance of 'AudioParamDescriptor'`);
156
+ }
157
+
158
+ if (descriptor.name === undefined) {
159
+ throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Element at index ${i} is not an instance of 'AudioParamDescriptor'`);
160
+ }
161
+
162
+ parsedDescriptor.name = conversions['DOMString'](descriptor.name, {
163
+ context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'name' for 'AudioParamDescriptor' at index ${i}`,
164
+ });
165
+
166
+ if (descriptor.defaultValue !== undefined) {
167
+ parsedDescriptor.defaultValue = conversions['float'](descriptor.defaultValue, {
168
+ context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'defaultValue' for 'AudioParamDescriptor' at index ${i}`,
169
+ });
170
+ } else {
171
+ parsedDescriptor.defaultValue = 0;
172
+ }
173
+
174
+ if (descriptor.maxValue !== undefined) {
175
+ parsedDescriptor.maxValue = conversions['float'](descriptor.maxValue, {
176
+ context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'maxValue' for 'AudioParamDescriptor' at index ${i}`,
177
+ });
178
+ } else {
179
+ parsedDescriptor.maxValue = 3.4028235e38;
180
+ }
181
+
182
+ if (descriptor.minValue !== undefined) {
183
+ parsedDescriptor.minValue = conversions['float'](descriptor.minValue, {
184
+ context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'minValue' for 'AudioParamDescriptor' at index ${i}`,
185
+ });
186
+ } else {
187
+ parsedDescriptor.minValue = -3.4028235e38;
188
+ }
189
+
190
+ if (descriptor.automationRate !== undefined) {
191
+ if (!['a-rate', 'k-rate'].includes(descriptor.automationRate)) {
192
+ throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: The provided value '${descriptor.automationRate}' is not a valid enum value of type AutomationRate for 'AudioParamDescriptor' at index ${i}`);
193
+ }
194
+
195
+ parsedDescriptor.automationRate = conversions['DOMString'](descriptor.automationRate, {
196
+ context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: The provided value '${descriptor.automationRate}'`,
197
+ });
198
+ } else {
199
+ parsedDescriptor.automationRate = 'a-rate';
200
+ }
201
+
202
+ parsedParamDescriptors.push(parsedDescriptor);
203
+ }
204
+
205
+ // check for duplicate parame names and consistency of min, max and default values
206
+ const paramNames = [];
207
+
208
+ for (let i = 0; i < parsedParamDescriptors.length; i++) {
209
+ const { name, defaultValue, minValue, maxValue } = parsedParamDescriptors[i];
210
+
211
+ if (paramNames.includes(name)) {
212
+ throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}': 'AudioParamDescriptor' with name '${name}' already declared`, 'NotSupportedError');
213
+ }
214
+
215
+ paramNames.push(name);
216
+
217
+ if (!(minValue <= defaultValue && defaultValue <= maxValue)) {
218
+ throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}': The constraint minValue <= defaultValue <= maxValue is not met`, 'InvalidStateError');
219
+ }
220
+ }
221
+
222
+ // store constructor
223
+ nameProcessorCtorMap.set(parsedName, processorCtor);
224
+ // send param descriptors back to main thread
225
+ parentPort.postMessage({
226
+ cmd: 'node-web-audio-api:worlet:processor-registered',
227
+ name: parsedName,
228
+ parameterDescriptors: parsedParamDescriptors,
229
+ });
230
+ };
231
+
232
+
233
+ // @todo - recheck this, not sure this is relevant in our case
234
+ // NOTE: Authors that register an event listener on the "message" event of this
235
+ // port should call close on either end of the MessageChannel (either in the
236
+ // AudioWorklet or the AudioWorkletGlobalScope side) to allow for resources to be collected.
237
+ parentPort.on('exit', () => {
238
+ process.stdout.write('closing worklet');
239
+ });
240
+
241
+ parentPort.on('message', event => {
242
+ console.log(event.cmd + '\n');
243
+
244
+ switch (event.cmd) {
245
+ case 'node-web-audio-api:worklet:init': {
246
+ const { workletId, processors, promiseId } = event;
247
+ break;
248
+ }
249
+ case 'node-web-audio-api:worklet:exit': {
250
+ clearImmediate(runLoopImmediateId);
251
+ // properly exit audio worklet on rust side
252
+ exit_audio_worklet_global_scope(workletId, processors);
253
+ // exit process
254
+ process.exit(0);
255
+ break;
256
+ }
257
+ case 'node-web-audio-api:worklet:add-module': {
258
+ const { code, promiseId } = event;
259
+ const func = new Function('AudioWorkletProcessor', 'registerProcessor', code);
260
+ func(AudioWorkletProcessor, registerProcessor);
261
+
262
+ // send registered param descriptors on main thread and resolve Promise
263
+ parentPort.postMessage({
264
+ cmd: 'node-web-audio-api:worklet:module-added',
265
+ promiseId,
266
+ });
267
+ break;
268
+ }
269
+ case 'node-web-audio-api:worklet:create-processor': {
270
+ const { name, id, options, port } = event;
271
+ const ctor = nameProcessorCtorMap.get(name);
272
+
273
+ // rewrap options of interest for the AudioWorkletNodeBaseClass
274
+ pendingProcessorConstructionData = {
275
+ port,
276
+ numberOfInputs: options.numberOfInputs,
277
+ numberOfOutputs: options.numberOfOutputs,
278
+ parameterDescriptors: ctor.parameterDescriptors,
279
+ };
280
+
281
+ let instance;
282
+
283
+ try {
284
+ instance = new ctor(options);
285
+ } catch (err) {
286
+ port.postMessage({ cmd: 'node-web-audio-api:worklet:ctor-error', err });
287
+ }
288
+
289
+ pendingProcessorConstructionData = null;
290
+ // store in global so that Rust can match the JS processor
291
+ // with its corresponding NapiAudioWorkletProcessor
292
+ processors[`${id}`] = instance;
293
+ // notify audio worklet back that processor has finished instanciation
294
+ parentPort.postMessage({ cmd: 'node-web-audio-api:worklet:processor-created', id });
295
+
296
+ if (!loopStarted) {
297
+ loopStarted = true;
298
+ setImmediate(runLoop);
299
+ }
300
+ break;
301
+ }
302
+ }
303
+ });