node-web-audio-api
Version:
Web Audio API implementation for Node.js
374 lines (308 loc) • 13.8 kB
JavaScript
const {
parentPort,
workerData,
markAsUntransferable,
} = require('node:worker_threads');
const conversions = require('webidl-conversions');
// these are defined in rust side
const {
exit_audio_worklet_global_scope,
run_audio_worklet_global_scope,
} = require('../load-native.cjs');
const {
workletId,
sampleRate,
} = workerData;
const kWorkletQueueTask = Symbol.for('node-web-audio-api:worklet-queue-task');
const kWorkletCallableProcess = Symbol.for('node-web-audio-api:worklet-callable-process');
const kWorkletInputs = Symbol.for('node-web-audio-api:worklet-inputs');
const kWorkletOutputs = Symbol.for('node-web-audio-api:worklet-outputs');
const kWorkletParams = Symbol.for('node-web-audio-api:worklet-params');
const kWorkletParamsCache = Symbol.for('node-web-audio-api:worklet-params-cache');
const kWorkletGetBuffer = Symbol.for('node-web-audio-api:worklet-get-buffer');
const kWorkletRecycleBuffer = Symbol.for('node-web-audio-api:worklet-recycle-buffer');
const kWorkletRecycleBuffer1 = Symbol.for('node-web-audio-api:worklet-recycle-buffer-1');
const kWorkletMarkAsUntransferable = Symbol.for('node-web-audio-api:worklet-mark-as-untransferable');
// const kWorkletOrderedParamNames = Symbol.for('node-web-audio-api:worklet-ordered-param-names');
const nameProcessorCtorMap = new Map();
const processors = {};
let pendingProcessorConstructionData = null;
let loopStarted = false;
let runLoopImmediateId = null;
class BufferPool {
#bufferSize;
#pool;
constructor(bufferSize, initialPoolSize) {
this.#bufferSize = bufferSize;
this.#pool = new Array(initialPoolSize);
for (let i = 0; i < this.#pool.length; i++) {
this.#pool[i] = this.#allocate();
}
}
#allocate() {
const float32 = new Float32Array(this.#bufferSize);
markAsUntransferable(float32);
// Mark underlying buffer as untransfrable too, this will fail one of
// the task in `audioworkletprocessor-process-frozen-array.https.html`
// but prevent segmentation fault
markAsUntransferable(float32.buffer);
return float32;
}
get() {
if (this.#pool.length === 0) {
return this.#allocate();
}
return this.#pool.pop();
}
recycle(buffer) {
// make sure we cannot polute our pool
if (buffer.length === this.#bufferSize) {
this.#pool.push(buffer);
}
}
}
const renderQuantumSize = 128;
const pool128 = new BufferPool(renderQuantumSize, 256);
const pool1 = new BufferPool(1, 64);
// allow rust to access some methods required when io layout change
globalThis[kWorkletGetBuffer] = () => pool128.get();
globalThis[kWorkletRecycleBuffer] = buffer => pool128.recycle(buffer);
globalThis[kWorkletRecycleBuffer1] = buffer => pool1.recycle(buffer);
globalThis[kWorkletMarkAsUntransferable] = obj => {
markAsUntransferable(obj);
return obj;
};
function isIterable(obj) {
// checks for null and undefined
if (obj === null || obj === undefined) {
return false;
}
return typeof obj[Symbol.iterator] === 'function';
}
// cf. https://stackoverflow.com/a/46759625
function isConstructor(f) {
try {
Reflect.construct(String, [], f);
} catch {
return false;
}
return true;
}
function runLoop() {
// block until we need to render a quantum
run_audio_worklet_global_scope(workletId, processors);
// yield to the event loop, and then repeat
runLoopImmediateId = setImmediate(runLoop);
}
globalThis.currentTime = 0;
globalThis.currentFrame = 0;
globalThis.sampleRate = sampleRate;
// @todo - implement in upstream crate
globalThis.renderQuantumSize = renderQuantumSize;
globalThis.AudioWorkletProcessor = class AudioWorkletProcessor {
static get parameterDescriptors() {
return [];
}
#port = null;
constructor() {
const {
port,
numberOfInputs,
numberOfOutputs,
parameterDescriptors,
} = pendingProcessorConstructionData;
// Mark [[callable process]] as true, set to false in render quantum
// either "process" doese not exists, either it throws an error
this[kWorkletCallableProcess] = true;
// Populate with dummy values which will be replaced in first render call
this[kWorkletInputs] = new Array(numberOfInputs).fill([]);
this[kWorkletOutputs] = new Array(numberOfOutputs).fill([]);
// Object to be reused as `process` parameters argument
this[kWorkletParams] = {};
// Cache of 2 Float32Array (of length 128 and 1) for each param, to be reused on
// each process call according to the size the param for the current render quantum
this[kWorkletParamsCache] = {};
parameterDescriptors.forEach(desc => {
this[kWorkletParamsCache][desc.name] = [
pool128.get(), // should be globalThis.renderQuantumSize
pool1.get(),
];
});
this.#port = port;
}
get port() {
if (!(this instanceof AudioWorkletProcessor)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioWorkletProcessor\'');
}
return this.#port;
}
[kWorkletQueueTask](cmd, err) {
this.#port.postMessage({ cmd, err });
}
};
// follow algorithm from:
// https://webaudio.github.io/web-audio-api/#dom-audioworkletglobalscope-registerprocessor
globalThis.registerProcessor = function registerProcessor(name, processorCtor) {
const parsedName = conversions['DOMString'](name, {
context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': name (${name})`,
});
if (parsedName === '') {
throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': name is empty`, 'NotSupportedError');
}
if (nameProcessorCtorMap.has(name)) {
throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': A processor with name '${name}' has already been registered in this scope`, 'NotSupportedError');
}
if (!isConstructor(processorCtor)) {
throw new TypeError(`Cannot execute 'registerProcessor")' in 'AudoWorkletGlobalScope': argument 2 for name '${name}' is not a constructor`);
}
if (typeof processorCtor.prototype !== 'object') {
throw new TypeError(`Cannot execute 'registerProcessor")' in 'AudoWorkletGlobalScope': argument 2 for name '${name}' is not is not a valid AudioWorkletProcessor`);
}
// must support Array, Set or iterators
let parameterDescriptorsValue = processorCtor.parameterDescriptors;
if (!isIterable(parameterDescriptorsValue)) {
throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: 'parameterDescriptors' is not iterable'`);
}
const paramDescriptors = Array.from(parameterDescriptorsValue);
const parsedParamDescriptors = [];
// Parse AudioParamDescriptor sequence
// cf. https://webaudio.github.io/web-audio-api/#AudioParamDescriptor
for (let i = 0; i < paramDescriptors.length; i++) {
const descriptor = paramDescriptors[i];
const parsedDescriptor = {};
if (typeof descriptor !== 'object' || descriptor === null) {
throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Element at index ${i} is not an instance of 'AudioParamDescriptor'`);
}
if (descriptor.name === undefined) {
throw new TypeError(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Element at index ${i} is not an instance of 'AudioParamDescriptor'`);
}
parsedDescriptor.name = conversions['DOMString'](descriptor.name, {
context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'name' for 'AudioParamDescriptor' at index ${i}`,
});
if (descriptor.defaultValue !== undefined) {
parsedDescriptor.defaultValue = conversions['float'](descriptor.defaultValue, {
context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'defaultValue' for 'AudioParamDescriptor' at index ${i}`,
});
} else {
parsedDescriptor.defaultValue = 0;
}
if (descriptor.maxValue !== undefined) {
parsedDescriptor.maxValue = conversions['float'](descriptor.maxValue, {
context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'maxValue' for 'AudioParamDescriptor' at index ${i}`,
});
} else {
parsedDescriptor.maxValue = 3.4028235e38;
}
if (descriptor.minValue !== undefined) {
parsedDescriptor.minValue = conversions['float'](descriptor.minValue, {
context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: Invalid 'minValue' for 'AudioParamDescriptor' at index ${i}`,
});
} else {
parsedDescriptor.minValue = -3.4028235e38;
}
if (descriptor.automationRate !== undefined) {
if (!['a-rate', 'k-rate'].includes(descriptor.automationRate)) {
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}`);
}
parsedDescriptor.automationRate = conversions['DOMString'](descriptor.automationRate, {
context: `Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}: The provided value '${descriptor.automationRate}'`,
});
} else {
parsedDescriptor.automationRate = 'a-rate';
}
parsedParamDescriptors.push(parsedDescriptor);
}
// check for duplicate parame names and consistency of min, max and default values
const paramNames = [];
for (let i = 0; i < parsedParamDescriptors.length; i++) {
const { name, defaultValue, minValue, maxValue } = parsedParamDescriptors[i];
if (paramNames.includes(name)) {
throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}': 'AudioParamDescriptor' with name '${name}' already declared`, 'NotSupportedError');
}
paramNames.push(name);
if (!(minValue <= defaultValue && defaultValue <= maxValue)) {
throw new DOMException(`Cannot execute 'registerProcessor' in 'AudoWorkletGlobalScope': Invalid 'parameterDescriptors' for processor '${name}': The constraint minValue <= defaultValue <= maxValue is not met`, 'InvalidStateError');
}
}
// store constructor
nameProcessorCtorMap.set(parsedName, processorCtor);
// send param descriptors back to main thread
parentPort.postMessage({
cmd: 'node-web-audio-api:worlet:processor-registered',
name: parsedName,
parameterDescriptors: parsedParamDescriptors,
});
};
// @todo - recheck this, not sure this is relevant in our case
// NOTE: Authors that register an event listener on the "message" event of this
// port should call close on either end of the MessageChannel (either in the
// AudioWorklet or the AudioWorkletGlobalScope side) to allow for resources to be collected.
// parentPort.on('exit', () => {
// process.stdout.write('closing worklet');
// });
parentPort.on('message', async event => {
switch (event.cmd) {
case 'node-web-audio-api:worklet:exit': {
clearImmediate(runLoopImmediateId);
// properly exit audio worklet on rust side
exit_audio_worklet_global_scope(workletId, processors);
// exit process
process.exit(0);
break;
}
case 'node-web-audio-api:worklet:add-module': {
const { moduleUrl, code, promiseId } = event;
try {
// 1. If given module is a "real" file, we can import it as is,
// 2. If module is a blob or loaded from an URL, we use the raw text as
// input. In this case, if the module uses `import` it will crash
if (moduleUrl !== null) {
await import(moduleUrl);
} else {
await import(`data:text/javascript;base64,${btoa(unescape(encodeURIComponent(code)))}`);
}
// send registered param descriptors on main thread and resolve Promise
parentPort.postMessage({
cmd: 'node-web-audio-api:worklet:module-added',
promiseId,
});
} catch (err) {
parentPort.postMessage({
cmd: 'node-web-audio-api:worklet:add-module-failed',
promiseId,
err,
});
}
break;
}
case 'node-web-audio-api:worklet:create-processor': {
const { name, id, options, port } = event;
const ctor = nameProcessorCtorMap.get(name);
// re-wrap options of interest for the AudioWorkletNodeBaseClass
pendingProcessorConstructionData = {
port,
numberOfInputs: options.numberOfInputs,
numberOfOutputs: options.numberOfOutputs,
parameterDescriptors: ctor.parameterDescriptors,
};
let instance;
try {
instance = new ctor(options);
} catch (err) {
port.postMessage({ cmd: 'node-web-audio-api:worklet:ctor-error', err });
return;
}
pendingProcessorConstructionData = null;
// store in global so that Rust can match the JS processor
// with its corresponding NapiAudioWorkletProcessor
processors[`${id}`] = instance;
// notify audio worklet back that processor has finished instanciation
parentPort.postMessage({ cmd: 'node-web-audio-api:worklet:processor-created', id });
if (!loopStarted) {
loopStarted = true;
setImmediate(runLoop);
}
break;
}
}
});