node-web-audio-api
Version:
Web Audio API implementation for Node.js
267 lines (236 loc) • 7.73 kB
JavaScript
const {
resolveObjectURL,
} = require('node:buffer');
const {
existsSync,
} = require('node:fs');
const path = require('node:path');
const {
Worker,
MessageChannel,
} = require('node:worker_threads');
const {
kProcessorRegistered,
kGetParameterDescriptors,
kCreateProcessor,
kPrivateConstructor,
kWorkletRelease,
kCheckProcessorsCreated,
} = require('./lib/symbols.js');
const {
kEnumerableProperty,
} = require('./lib/utils.js');
const caller = require('caller');
// cf. https://www.npmjs.com/package/node-fetch#commonjs
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
/**
* Retrieve code with different module resolution strategies
* - file - absolute or relative to cwd path
*
* - URL - do not support import within module
* - Blob - do not support import within module
* - fallback: relative to caller site
* + in fs - support import within module
* + caller site is url - required for wpt, probably no other use case
*/
const resolveModule = async (moduleUrl) => {
let code = null;
let absPathname = null;
if (existsSync(moduleUrl)) {
if (path.isAbsolute(moduleUrl)) {
absPathname = moduleUrl;
} else { // moduleUrl is relative to process.cwd();
absPathname = path.join(process.cwd(), moduleUrl);
}
} else if (moduleUrl.startsWith('http')) {
try {
const res = await fetch(moduleUrl);
code = await res.text();
} catch (err) {
throw new DOMException(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`, 'AbortError');
}
} else if (moduleUrl.startsWith('blob:')) {
try {
const blob = resolveObjectURL(moduleUrl);
code = await blob.text();
} catch (err) {
throw new DOMException(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`, 'AbortError');
}
} else {
const callerSite = caller(2);
if (callerSite.startsWith('http')) { // this branch exists for wpt where caller site is an url
const baseUrl = callerSite.substring(0, callerSite.lastIndexOf('/'));
const url = baseUrl + '/' + moduleUrl;
try {
const res = await fetch(url);
code = await res.text();
} catch (err) {
throw new DOMException(`Failed to execute 'addModule' on 'AudioWorklet': ${err.message}`, 'AbortError');
}
} else {
// filesystem, relative to caller site or in node_modules
const dirname = callerSite.substring(0, callerSite.lastIndexOf(path.sep));
const absDirname = dirname.replace('file://', '');
const pathname = path.join(absDirname, moduleUrl);
if (existsSync(pathname)) { // relative to caller site
absPathname = pathname;
} else {
try {
// try resolve according to process.cwd()
absPathname = require.resolve(moduleUrl, { paths: [process.cwd()] });
} catch {
throw new DOMException(`Failed to execute 'addModule' on 'AudioWorklet': Cannot resolve module ${moduleUrl}`, 'AbortError');
}
}
}
}
return { absPathname, code };
};
class AudioWorklet {
constructor(options) {
if (
(typeof options !== 'object') ||
options[kPrivateConstructor] !== true
) {
throw new TypeError('Illegal constructor');
}
this.
this.
}
// @todo
// - better error handling, stack trace, etc.
// - handle 'node-web-audio-api:worklet:ctor-error' message
this.
switch (event.cmd) {
case 'node-web-audio-api:worklet:module-added': {
const { promiseId } = event;
const { resolve } = this.
this.
resolve();
break;
}
case 'node-web-audio-api:worklet:add-module-failed': {
const { promiseId, err } = event;
const { reject } = this.
this.
reject(err);
break;
}
case 'node-web-audio-api:worlet:processor-registered': {
const { name, parameterDescriptors } = event;
this.
break;
}
case 'node-web-audio-api:worklet:processor-created': {
const { id } = event;
this.
break;
}
}
});
}
get port() {
return this.
}
async addModule(moduleUrl) {
// @important - `resolveModule` must be called first because it uses `caller`
// which will return `null` if this is not in the first line...
const resolved = await resolveModule(moduleUrl);
// launch Worker if not exists
if (!this.
await new Promise(resolve => {
const workletPathname = path.join(__dirname, 'AudioWorkletGlobalScope.js');
this.
workerData: {
workletId: this.
sampleRate: this.
},
});
this.
this.
});
}
const promiseId = this.
// This promise is resolved when the Worker returns the name and
// parameterDescriptors from the added module
await new Promise((resolve, reject) => {
this.
this.
cmd: 'node-web-audio-api:worklet:add-module',
moduleUrl: resolved.absPathname,
code: resolved.code,
promiseId,
});
});
}
// For OfflineAudioContext only, check that all processors have been properly
// created before actual `startRendering`
async [kCheckProcessorsCreated]() {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async resolve => {
while (this.
// we need a microtask to ensure message can be received
await new Promise(resolve => setTimeout(resolve, 0));
}
resolve();
});
}
[](name) {
return Array.from(this.
}
[](name) {
return this.
}
[](name, options, id) {
this.
const { port1, port2 } = new MessageChannel();
// @todo - check if some processorOptions must be transfered as well
this.
cmd: 'node-web-audio-api:worklet:create-processor',
name,
id,
options,
port: port2,
}, [port2]);
return port1;
}
async [kWorkletRelease]() {
if (this.
await new Promise(resolve => {
this.
this.
cmd: 'node-web-audio-api:worklet:exit',
});
});
}
}
}
Object.defineProperties(AudioWorklet, {
length: {
__proto__: null,
writable: false,
enumerable: false,
configurable: true,
value: 0,
},
});
Object.defineProperties(AudioWorklet.prototype, {
[]: {
__proto__: null,
writable: false,
enumerable: false,
configurable: true,
value: 'AudioWorklet',
},
addModule: kEnumerableProperty,
port: kEnumerableProperty,
});
module.exports = AudioWorklet;