wam-community
Version:
A collection of prebuilt Web Audio Modules ready for use
492 lines (436 loc) • 13.7 kB
JavaScript
import MgrAudioParam from './MgrAudioParam.js';
/** @typedef {import('@webaudiomodules/api').WebAudioModule} WebAudioModule */
/** @typedef {import('@webaudiomodules/api').WamNode} WamNode */
/** @typedef {import('@webaudiomodules/api').WamParameterDataMap} WamParameterValueMap */
/** @typedef {import('@webaudiomodules/api').WamEvent} WamEvent */
/** @typedef {import('@webaudiomodules/api').WamAutomationEvent} WamAutomationEvent */
/** @typedef {import('./types').ParamMgrOptions} ParamMgrOptions */
/** @typedef {import('./types').ParamMgrCallFromProcessor} ParamMgrCallFromProcessor */
/** @typedef {import('./types').ParamMgrCallToProcessor} ParamMgrCallToProcessor */
/** @typedef {import('./types').ParamMgrNodeMsgIn} ParamMgrNodeMsgIn */
/** @typedef {import('./types').ParamMgrNodeMsgOut} ParamMgrNodeMsgOut */
/** @typedef {import('./types').ParamMgrNode} IParamMgrNode */
/** @type {typeof import('./TypedAudioWorklet').TypedAudioWorkletNode} */
// @ts-ignore
const AudioWorkletNode = globalThis.AudioWorkletNode;
/**
* @extends {AudioWorkletNode<ParamMgrNodeMsgIn, ParamMgrNodeMsgOut>}
* @implements {IParamMgrNode}
*/
export default class ParamMgrNode extends AudioWorkletNode {
/**
* @param {WebAudioModule} module
* @param {ParamMgrOptions} options
*/
constructor(module, options) {
super(module.audioContext, module.moduleId, {
numberOfInputs: 0,
numberOfOutputs: 1 + options.processorOptions.internalParams.length,
parameterData: options.parameterData,
processorOptions: options.processorOptions,
});
const { processorOptions, internalParamsConfig } = options;
this.initialized = false;
this.module = module;
this.instanceId = options.processorOptions.instanceId;
this.groupId = options.processorOptions.groupId;
this.paramsConfig = processorOptions.paramsConfig;
this.internalParams = processorOptions.internalParams;
this.internalParamsConfig = internalParamsConfig;
this.$prevParamsBuffer = new Float32Array(this.internalParams.length);
this.paramsUpdateCheckFn = [];
this.paramsUpdateCheckFnRef = [];
this.messageRequestId = 0;
/** Fixes Chromium bug: https://issues.chromium.org/issues/324293899, allowing automating AudioParams */
this.dummyGainNode = module.audioContext.createGain();
Object.entries(this.getParams()).forEach(([name, param]) => {
Object.setPrototypeOf(param, MgrAudioParam.prototype);
param._info = this.paramsConfig[name];
});
/** @type {Record<number, ((...args: any[]) => any)>} */
const resolves = {};
/** @type {Record<number, ((...args: any[]) => any)>} */
const rejects = {};
/**
* @param {keyof ParamMgrCallToProcessor} call
* @param {any} args
*/
this.call = (call, ...args) => {
const id = this.messageRequestId;
this.messageRequestId += 1;
return new Promise((resolve, reject) => {
resolves[id] = resolve;
rejects[id] = reject;
this.port.postMessage({ id, call, args });
});
};
this.handleMessage = ({ data }) => {
const { id, call, args, value, error } = data;
if (call) {
/** @type {any} */
const r = { id };
try {
r.value = this[call](...args);
} catch (e) {
r.error = e;
}
this.port.postMessage(r);
} else {
if (error) rejects[id]?.(error);
else resolves[id]?.(value);
delete resolves[id];
delete rejects[id];
}
};
this.port.start();
this.port.addEventListener('message', this.handleMessage);
}
/**
* @returns {ReadonlyMap<string, MgrAudioParam>}
*/
get parameters() {
// @ts-ignore
return super.parameters;
}
get moduleId() {
return this.module.moduleId;
}
async initialize() {
/** @type {ReturnType<ParamMgrCallToProcessor['getBuffer']>} */
const response = await this.call('getBuffer');
const { lock, paramsBuffer } = response;
this.$lock = lock;
this.$paramsBuffer = paramsBuffer;
const offset = 1;
Object.entries(this.internalParamsConfig).forEach(([name, config], i) => {
if (this.context.state === 'suspended') this.$paramsBuffer[i] = config.defaultValue;
if (config instanceof AudioParam) {
try {
config.automationRate = 'a-rate';
// eslint-disable-next-line no-empty
} catch {
} finally {
config.value = Math.max(0, config.minValue);
this.connect(config, offset + i);
// Fix Chromium bug: https://issues.chromium.org/issues/324293899
this.connect(this.dummyGainNode, offset + i, 0);
}
} else if (config instanceof AudioNode) {
this.connect(config, offset + i);
} else {
this.requestDispatchIParamChange(name);
}
});
this.connect(this.module.audioContext.destination, 0, 0);
this.initialized = true;
return this;
}
/**
* @param {ReturnType<ParamMgrCallToProcessor['getBuffer']>} buffer
*/
setBuffer({ lock, paramsBuffer }) {
this.$lock = lock;
this.$paramsBuffer = paramsBuffer;
}
setParamsMapping(paramsMapping) {
return this.call('setParamsMapping', paramsMapping);
}
getCompensationDelay() {
return this.call('getCompensationDelay');
}
getParameterInfo(...parameterIdQuery) {
return this.call('getParameterInfo', ...parameterIdQuery);
}
getParameterValues(normalized, ...parameterIdQuery) {
return this.call('getParameterValues', normalized, ...parameterIdQuery);
}
/**
* @param {WamAutomationEvent} event
*/
scheduleAutomation(event) {
const time = event.time || this.context.currentTime;
const { id, normalized, value } = event.data;
const audioParam = this.getParam(id);
if (!audioParam) return;
if (audioParam.info.type === 'float') {
if (normalized) audioParam.linearRampToNormalizedValueAtTime(value, time);
else audioParam.linearRampToValueAtTime(value, time);
} else {
// eslint-disable-next-line no-lonely-if
if (normalized) audioParam.setNormalizedValueAtTime(value, time);
else audioParam.setValueAtTime(value, time);
}
}
/**
* @param {WamEvent[]} events
*/
scheduleEvents(...events) {
events.forEach((event) => {
if (event.type === 'wam-automation') {
this.scheduleAutomation(event);
}
});
this.call('scheduleEvents', ...events);
}
/**
* @param {WamEvent[]} events
*/
emitEvents(...events) {
this.call('emitEvents', ...events);
}
clearEvents() {
this.call('clearEvents');
}
/**
* @param {WamEvent} event
*/
dispatchWamEvent(event) {
if (event.type === 'wam-automation') {
this.scheduleAutomation(event);
} else {
this.dispatchEvent(new CustomEvent(event.type, { detail: event }));
}
}
/**
* @param {WamParameterValueMap} parameterValues
*/
async setParameterValues(parameterValues) {
Object.keys(parameterValues).forEach((parameterId) => {
const parameterUpdate = parameterValues[parameterId];
const parameter = this.parameters.get(parameterId);
if (!parameter) return;
if (!parameterUpdate.normalized) parameter.value = parameterUpdate.value;
else parameter.normalizedValue = parameterUpdate.value;
});
}
async getState() {
return this.getParamsValues();
}
async setState(state) {
this.setParamsValues(state);
}
convertTimeToFrame(time) {
return Math.round(time * this.context.sampleRate);
}
convertFrameToTime(frame) {
return frame / this.context.sampleRate;
}
/**
* @param {string} name
*/
requestDispatchIParamChange = (name) => {
const config = this.internalParamsConfig[name];
if (!('onChange' in config)) return;
const { automationRate, onChange } = config;
if (typeof automationRate !== 'number' || !automationRate) return;
const interval = 1000 / automationRate;
const i = this.internalParams.indexOf(name);
if (i === -1) return;
if (i >= this.internalParams.length) return;
if (typeof this.paramsUpdateCheckFnRef[i] === 'number') {
window.clearTimeout(this.paramsUpdateCheckFnRef[i]);
}
this.paramsUpdateCheckFn[i] = () => {
const prev = this.$prevParamsBuffer[i];
const cur = this.$paramsBuffer[i];
if (cur !== prev) {
onChange(cur, prev);
this.$prevParamsBuffer[i] = cur;
}
this.paramsUpdateCheckFnRef[i] = window.setTimeout(this.paramsUpdateCheckFn[i], interval);
};
this.paramsUpdateCheckFn[i]();
}
/**
* @param {string} name
*/
getIParamIndex(name) {
const i = this.internalParams.indexOf(name);
return i === -1 ? null : i;
}
/**
* @param {string} name
* @param {AudioParam | AudioNode} dest
* @param {number} index
*/
connectIParam(name, dest, index) {
const offset = 1;
const i = this.getIParamIndex(name);
if (i !== null) {
if (dest instanceof AudioNode) {
if (typeof index === 'number') this.connect(dest, offset + i, index);
else this.connect(dest, offset + i);
} else {
this.connect(dest, offset + i);
}
}
}
/**
* @param {string} name
* @param {AudioParam | AudioNode} dest
* @param {number} index
*/
disconnectIParam(name, dest, index) {
const offset = 1;
const i = this.getIParamIndex(name);
if (i !== null) {
if (dest instanceof AudioNode) {
if (typeof index === 'number') this.disconnect(dest, offset + i, index);
else this.disconnect(dest, offset + i);
} else {
this.disconnect(dest, offset + i);
}
}
}
getIParamValue(name) {
const i = this.getIParamIndex(name);
return i !== null ? this.$paramsBuffer[i] : null;
}
getIParamsValues() {
/** @type {Record<string, number>} */
const values = {};
this.internalParams.forEach((name, i) => {
values[name] = this.$paramsBuffer[i];
});
return values;
}
getParam(name) {
return this.parameters.get(name) || null;
}
getParams() {
// @ts-ignore
return Object.fromEntries(this.parameters);
}
getParamValue(name) {
const param = this.parameters.get(name);
if (!param) return null;
return param.value;
}
setParamValue(name, value) {
const param = this.parameters.get(name);
if (!param) return;
param.value = value;
}
getParamsValues() {
/** @type {Record<string, number>} */
const values = {};
this.parameters.forEach((v, k) => {
values[k] = v.value;
});
return values;
}
/**
* @param {Record<string, number>} values
*/
setParamsValues(values) {
if (!values) return;
Object.entries(values).forEach(([k, v]) => {
this.setParamValue(k, v);
});
}
getNormalizedParamValue(name) {
const param = this.parameters.get(name);
if (!param) return null;
return param.normalizedValue;
}
setNormalizedParamValue(name, value) {
const param = this.parameters.get(name);
if (!param) return;
param.normalizedValue = value;
}
getNormalizedParamsValues() {
const values = {};
this.parameters.forEach((v, k) => {
values[k] = this.getNormalizedParamValue(k);
});
return values;
}
setNormalizedParamsValues(values) {
if (!values) return;
Object.entries(values).forEach(([k, v]) => {
this.setNormalizedParamValue(k, v);
});
}
setParamValueAtTime(name, value, startTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.setValueAtTime(value, startTime);
}
setNormalizedParamValueAtTime(name, value, startTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.setNormalizedValueAtTime(value, startTime);
}
linearRampToParamValueAtTime(name, value, endTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.linearRampToValueAtTime(value, endTime);
}
linearRampToNormalizedParamValueAtTime(name, value, endTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.linearRampToNormalizedValueAtTime(value, endTime);
}
exponentialRampToParamValueAtTime(name, value, endTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.exponentialRampToValueAtTime(value, endTime);
}
exponentialRampToNormalizedParamValueAtTime(name, value, endTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.exponentialRampToNormalizedValueAtTime(value, endTime);
}
setParamTargetAtTime(name, target, startTime, timeConstant) {
const param = this.parameters.get(name);
if (!param) return null;
return param.setTargetAtTime(target, startTime, timeConstant);
}
setNormalizedParamTargetAtTime(name, target, startTime, timeConstant) {
const param = this.parameters.get(name);
if (!param) return null;
return param.setNormalizedTargetAtTime(target, startTime, timeConstant);
}
setParamValueCurveAtTime(name, values, startTime, duration) {
const param = this.parameters.get(name);
if (!param) return null;
return param.setValueCurveAtTime(values, startTime, duration);
}
setNormalizedParamValueCurveAtTime(name, values, startTime, duration) {
const param = this.parameters.get(name);
if (!param) return null;
return param.setNormalizedValueCurveAtTime(values, startTime, duration);
}
cancelScheduledParamValues(name, cancelTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.cancelScheduledValues(cancelTime);
}
cancelAndHoldParamAtTime(name, cancelTime) {
const param = this.parameters.get(name);
if (!param) return null;
return param.cancelAndHoldAtTime(cancelTime);
}
/**
* @param {string} toId
* @param {number} [output]
*/
connectEvents(toId, output) {
this.call('connectEvents', toId, output);
}
/**
* @param {string} [toId]
* @param {number} [output]
*/
disconnectEvents(toId, output) {
this.call('disconnectEvents', toId, output);
}
async destroy() {
this.disconnect();
this.paramsUpdateCheckFnRef.forEach((ref) => {
if (typeof ref === 'number') window.clearTimeout(ref);
});
await this.call('destroy');
this.port.close();
}
}