node-web-audio-api
Version:
Web Audio API implementation for Node.js
331 lines (269 loc) • 11.4 kB
JavaScript
const conversions = require('webidl-conversions');
const {
throwSanitizedError,
} = require('./lib/errors.js');
const {
isFunction,
kEnumerableProperty,
} = require('./lib/utils.js');
const {
kNapiObj,
kOnStateChange,
kOnSinkChange,
kWorkletRelease,
} = require('./lib/symbols.js');
const {
propagateEvent,
} = require('./lib/events.js');
let contextId = 0;
module.exports = function(jsExport, nativeBinding) {
class AudioContext extends jsExport.BaseAudioContext {
#sinkId = '';
#renderCapacity = null;
#onsinkchange = null;
constructor(options = {}) {
if (typeof options !== 'object') {
throw new TypeError(`Failed to construct 'AudioContext': The provided value is not of type 'AudioContextOptions'`);
}
let targetOptions = {};
if (options.latencyHint !== undefined) {
if (['balanced', 'interactive', 'playback'].includes(options.latencyHint)) {
targetOptions.latencyHint = conversions['DOMString'](options.latencyHint);
} else {
targetOptions.latencyHint = conversions['double'](options.latencyHint, {
context: `Failed to construct 'AudioContext': Failed to read the 'sinkId' property from AudioNodeOptions: The provided value (${options.latencyHint})`,
});
}
} else {
targetOptions.latencyHint = 'interactive';
}
if (options.sampleRate !== undefined) {
targetOptions.sampleRate = conversions['float'](options.sampleRate, {
context: `Failed to construct 'AudioContext': Failed to read the 'sinkId' property from AudioNodeOptions: The provided value (${options.sampleRate})`,
});
} else {
targetOptions.sampleRate = null;
}
if (options.sinkId !== undefined) {
if (typeof options.sinkId === 'object') {
// https://webaudio.github.io/web-audio-api/#enumdef-audiosinktype
if (!('type' in options.sinkId) || options.sinkId.type !== 'none') {
throw TypeError(`Failed to construct 'AudioContext': Failed to read the 'sinkId' property from AudioNodeOptions: Failed to read the 'type' property from 'AudioSinkOptions': The provided value (${options.sinkId.type}) is not a valid enum value of type AudioSinkType.`);
}
targetOptions.sinkId = 'none';
} else {
targetOptions.sinkId = conversions['DOMString'](options.sinkId, {
context: `Failed to construct 'AudioContext': Failed to read the 'sinkId' property from AudioNodeOptions: Failed to read the 'type' property from 'AudioSinkOptions': The provided value (${options.sinkId})`,
});
}
} else {
targetOptions.sinkId = '';
}
let napiObj;
try {
napiObj = new nativeBinding.AudioContext(targetOptions);
} catch (err) {
throwSanitizedError(err);
}
super({ [kNapiObj]: napiObj });
if (options.sinkId !== undefined) {
this.#sinkId = options.sinkId;
}
this.#renderCapacity = new jsExport.AudioRenderCapacity({
[kNapiObj]: this[kNapiObj].renderCapacity,
});
// Add function to Napi object to bridge from Rust events to JS EventTarget
this[kNapiObj][kOnStateChange] = (function(err, rawEvent) {
const event = new Event(rawEvent.type);
propagateEvent(this, event);
}).bind(this);
this[kNapiObj][kOnSinkChange] = (function(err, rawEvent) {
const event = new Event(rawEvent.type);
propagateEvent(this, event);
}).bind(this);
// Workaround to bind the `sinkchange` and `statechange` events to EventTarget.
// This must be called from JS facade ctor as the JS handler are added to the Napi
// object after its instantiation, and that we don't have any initial `resume` call.
this[kNapiObj].listen_to_events();
// @todo - This is probably not requested anymore as the event listeners
// prevent garbage collection and process exit
const id = contextId++;
// store in process to prevent garbage collection
const kAudioContextId = Symbol(`node-web-audio-api:audio-context-${id}`);
Object.defineProperty(process, kAudioContextId, {
__proto__: null,
enumerable: false,
configurable: true,
value: this,
});
// keep process awake until context is closed
const keepAwakeId = setInterval(() => {}, 10 * 1000);
// clear on close
this.addEventListener('statechange', () => {
if (this.state === 'closed') {
// allow to garbage collect the context and to the close the process
delete process[kAudioContextId];
clearTimeout(keepAwakeId);
}
});
// for wpt tests, see ./.scripts/wpt_harness.mjs for informations
if (process.WPT_TEST_RUNNER) {
process.WPT_TEST_RUNNER.once('cleanup', () => this.close());
}
}
get baseLatency() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
return this[kNapiObj].baseLatency;
}
get outputLatency() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
return this[kNapiObj].outputLatency;
}
get sinkId() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
return this.#sinkId;
}
get renderCapacity() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
return this.#renderCapacity;
}
get onsinkchange() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
return this.#onsinkchange;
}
set onsinkchange(value) {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
if (isFunction(value) || value === null) {
this.#onsinkchange = value;
}
}
getOutputTimestamp() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
throw new Error(`AudioContext::getOutputTimestamp is not yet implemented`);
}
async resume() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
await this[kNapiObj].resume();
}
async suspend() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
await this[kNapiObj].suspend();
}
async close() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
// Close audioWorklet first so that `run_audio_worklet_global_scope` exit first
// The other way around works too because of `recv_timeout` but cleaner this way
await this.audioWorklet[kWorkletRelease]();
await this[kNapiObj].close();
}
async setSinkId(sinkId) {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
if (arguments.length < 1) {
throw new TypeError(`Failed to execute 'setSinkId' on 'AudioContext': 1 argument required, but only ${arguments.length} present`);
}
let targetSinkId = '';
if (typeof sinkId === 'object') {
if (!('type' in sinkId) || sinkId.type !== 'none') {
throw new TypeError(`Failed to execute 'setSinkId' on 'AudioContext': Failed to read the 'type' property from 'AudioSinkOptions': The provided value '${sinkId.type}' is not a valid enum value of type AudioSinkType.`);
}
targetSinkId = 'none';
} else {
targetSinkId = conversions['DOMString'](sinkId, {
context: `Failed to execute 'setSinkId' on 'AudioContext': Failed to read the 'type' property from 'AudioSinkOptions': The provided value '${sinkId.type}'`,
});
}
this.#sinkId = sinkId;
try {
this[kNapiObj].setSinkId(targetSinkId);
} catch (err) {
throwSanitizedError(err);
}
}
// online context only AudioNodes
createMediaStreamSource(mediaStream) {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
if (arguments.length < 1) {
throw new TypeError(`Failed to execute 'createMediaStreamSource' on 'AudioContext': 1 argument required, but only ${arguments.length} present`);
}
const options = {
mediaStream,
};
return new jsExport.MediaStreamAudioSourceNode(this, options);
}
createMediaElementSource() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
throw new Error(`AudioContext::createMediaElementSource() is not yet implemented, cf. https://github.com/ircam-ismm/node-web-audio-api/issues/91 for more information`);
}
createMediaStreamTrackSource() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
throw new Error(`AudioContext::createMediaStreamTrackSource() is not yet implemented, cf. https://github.com/ircam-ismm/node-web-audio-api/issues/91 for more information`);
}
createMediaStreamDestination() {
if (!(this instanceof AudioContext)) {
throw new TypeError('Invalid Invocation: Value of \'this\' must be of type \'AudioContext\'');
}
throw new Error(`AudioContext::createMediaStreamDestination() is not yet implemented, cf. https://github.com/ircam-ismm/node-web-audio-api/issues/91 for more information`);
}
}
Object.defineProperties(AudioContext, {
length: {
__proto__: null,
writable: false,
enumerable: false,
configurable: true,
value: 0,
},
});
Object.defineProperties(AudioContext.prototype, {
[Symbol.toStringTag]: {
__proto__: null,
writable: false,
enumerable: false,
configurable: true,
value: 'AudioContext',
},
baseLatency: kEnumerableProperty,
outputLatency: kEnumerableProperty,
sinkId: kEnumerableProperty,
renderCapacity: kEnumerableProperty,
onsinkchange: kEnumerableProperty,
getOutputTimestamp: kEnumerableProperty,
resume: kEnumerableProperty,
suspend: kEnumerableProperty,
close: kEnumerableProperty,
setSinkId: kEnumerableProperty,
createMediaStreamSource: kEnumerableProperty,
createMediaElementSource: kEnumerableProperty,
createMediaStreamTrackSource: kEnumerableProperty,
createMediaStreamDestination: kEnumerableProperty,
});
return AudioContext;
};