web-audio-api
Version:
Portable Web Audio API
399 lines (343 loc) • 15.5 kB
JavaScript
import AudioNode from './AudioNode.js'
import AudioParam from './AudioParam.js'
import AudioBuffer from 'audio-buffer'
import { BLOCK_SIZE } from './constants.js'
// Pending port for processor construction — set before instantiation, consumed by super()
// _CONSUMED sentinel means a construction is active but the port was already claimed.
let _pendingPort = null
const _CONSUMED = Symbol('consumed')
// AudioWorkletProcessor — base class users extend
class AudioWorkletProcessor {
constructor() {
// Per spec: during AudioWorkletNode construction, only one super()/new call
// may consume the pending port. A second call throws TypeError.
if (_pendingPort === _CONSUMED)
throw new TypeError('AudioWorkletProcessor constructor may only be called once per node construction')
// When called outside node construction (e.g. direct instantiation), port is null
this.port = _pendingPort
if (_pendingPort !== null) _pendingPort = _CONSUMED
}
// Per spec: no default process() — subclasses must define it.
// Calling process on a processor without one triggers processorerror.
static get parameterDescriptors() { return [] }
}
// AudioWorkletGlobalScope — processor registry + global scope for worklet code
class AudioWorkletGlobalScope {
#processors = new Map()
#context
port = null // MessagePort — wired by AudioWorklet.addModule
constructor(context) {
this.#context = context
}
// Per spec, AudioWorkletGlobalScope exposes sampleRate, currentTime, currentFrame
// and AudioWorkletProcessor as bare identifiers. The closure-style addModule
// path passes this scope object to user code, so these must be live + present
// for symmetry with the URL/data-URI path (which injects them via `with`).
get sampleRate() { return this.#context.sampleRate }
get currentTime() { return this.#context.currentTime }
get currentFrame() { return this.#context._frame }
get AudioWorkletProcessor() { return AudioWorkletProcessor }
registerProcessor(name, processorClass) {
if (this.#processors.has(name))
throw new DOMException(`Processor "${name}" already registered`, 'NotSupportedError')
if (!(processorClass.prototype instanceof AudioWorkletProcessor) && processorClass !== AudioWorkletProcessor)
throw new TypeError('processorClass must extend AudioWorkletProcessor')
// Validate parameterDescriptors per spec
let descriptors = processorClass.parameterDescriptors
if (descriptors !== undefined) {
if (descriptors == null || typeof descriptors[Symbol.iterator] !== 'function')
throw new TypeError('parameterDescriptors must be iterable')
let names = new Set()
for (let d of descriptors) {
if (names.has(d.name))
throw new DOMException(`Duplicate parameter name "${d.name}"`, 'NotSupportedError')
names.add(d.name)
let min = d.minValue ?? -3.4028235e38
let max = d.maxValue ?? 3.4028235e38
let def = d.defaultValue ?? 0
if (def < min || def > max)
throw new DOMException(`defaultValue ${def} out of range [${min}, ${max}]`, 'InvalidStateError')
}
}
this.#processors.set(name, processorClass)
}
_getProcessor(name) {
let cls = this.#processors.get(name)
if (!cls) throw new DOMException(`Processor "${name}" not registered`, 'InvalidStateError')
return cls
}
}
// Check if all values in a Float32Array are the same (constant)
function _isConstant(arr) {
let v = arr[0]
for (let i = 1; i < arr.length; i++)
if (arr[i] !== v) return false
return true
}
// AudioWorkletNode — audio node backed by a processor instance
class AudioWorkletNode extends AudioNode {
#processor
#paramMap = new Map()
#alive = true
#nodePort // node-side port exposed to user
#onprocessorerror
#procPort // processor-side port
#dynamicOutput // true when outputChannelCount was not explicitly set
get port() { return this.#nodePort }
get parameters() { return this.#paramMap }
get onprocessorerror() { return this.#onprocessorerror }
set onprocessorerror(fn) {
if (this.#onprocessorerror) this.removeEventListener('processorerror', this.#onprocessorerror)
this.#onprocessorerror = fn
if (fn) this.addEventListener('processorerror', fn)
}
constructor(context, processorName, options) {
options = AudioNode._checkOpts(options)
let numberOfInputs = options.numberOfInputs ?? 1
let numberOfOutputs = options.numberOfOutputs ?? 1
let dynamicOutput = !options.outputChannelCount
let outputChannelCount = options.outputChannelCount ?? [1]
let channelCount = options.channelCount ?? (dynamicOutput ? 2 : outputChannelCount[0] ?? 2)
// normalize outputChannelCount to match numberOfOutputs
while (outputChannelCount.length < numberOfOutputs)
outputChannelCount.push(dynamicOutput ? 1 : channelCount)
if (outputChannelCount.length > numberOfOutputs)
outputChannelCount = outputChannelCount.slice(0, numberOfOutputs)
super(context, numberOfInputs, numberOfOutputs, channelCount, 'max', 'speakers')
this._applyOpts(options)
this.#dynamicOutput = dynamicOutput
// onprocessorerror event handler property
this.#onprocessorerror = null
// resolve processor class from context's worklet scope
let scope = context._workletScope
if (!scope) throw new Error('No AudioWorklet scope — call context.audioWorklet.addModule() first')
let ProcessorClass = scope._getProcessor(processorName)
// wire entangled message ports: node ↔ processor
// Port must be available during processor constructor (via _pendingPort)
let channel = new MessageChannel()
this.#nodePort = channel.port1
this.#procPort = channel.port2
// Build resolved options dict for processor constructor (per spec)
let procOptions = { numberOfInputs, numberOfOutputs }
if (options.outputChannelCount) procOptions.outputChannelCount = outputChannelCount.slice()
if (options.parameterData) procOptions.parameterData = options.parameterData
if (options.processorOptions !== undefined) procOptions.processorOptions = options.processorOptions
_pendingPort = this.#procPort
try {
this.#processor = new ProcessorClass(procOptions)
} catch (e) {
// Spec: constructor errors fire onprocessorerror
queueMicrotask(() => {
let ev = new (this.context._ErrorEvent || globalThis.ErrorEvent || Event)('processorerror', { error: e, message: e?.message })
this.dispatchEvent(ev)
})
this.#processor = null
}
_pendingPort = null
// create AudioParams from parameterDescriptors
// Per spec, parameterData entries override the descriptor's defaultValue
// for the AudioParam's current value, while defaultValue itself remains
// the descriptor default (exposed as AudioParam.defaultValue).
let descriptors = ProcessorClass.parameterDescriptors || []
let parameterData = options.parameterData || {}
for (let desc of descriptors) {
let param = new AudioParam(context, desc.defaultValue ?? 0, desc.automationRate === 'k-rate' ? 'k' : 'a', desc.minValue, desc.maxValue)
if (parameterData[desc.name] !== undefined) param.value = parameterData[desc.name]
this.#paramMap.set(desc.name, param)
}
// pre-allocate output buffers
this._outBufs = outputChannelCount.map(ch =>
new AudioBuffer(ch, BLOCK_SIZE, context.sampleRate))
// Per spec: AudioWorkletNodes are always processed (active processing)
// even when not connected to destination, as long as keepAlive is true.
if (context._tailNodes) context._tailNodes.add(this)
}
_tick() {
super._tick()
let outBuf = this._outBufs[0] || null
if (!this.#alive) {
// dead node → output silence
if (outBuf) for (let ch = 0; ch < outBuf.numberOfChannels; ch++) outBuf.getChannelData(ch).fill(0)
return outBuf
}
// gather inputs — per spec, disconnected inputs have zero channels
let inputs = []
for (let i = 0; i < this.numberOfInputs; i++) {
if (this._inputs[i].sources.length === 0) {
inputs.push(Object.freeze([]))
} else {
// Check ended state BEFORE ticking, since ticking may set _ended
// during the source's last active quantum (should still report channels)
let sources = this._inputs[i].sources
let allEndedBefore = sources.every(s => s.node && s.node._ended)
let buf = this._inputs[i]._tick()
if (allEndedBefore) {
// All sources already ended — report zero channels per spec
inputs.push(Object.freeze([]))
} else {
let chArrays = []
for (let ch = 0; ch < buf.numberOfChannels; ch++)
chArrays.push(buf.getChannelData(ch))
inputs.push(Object.freeze(chArrays))
}
}
}
Object.freeze(inputs)
// Dynamic output: resize output buffers to match computedNumberOfChannels
if (this.#dynamicOutput && this.numberOfOutputs > 0 && this.numberOfInputs > 0) {
let inCh = inputs[0].length || 1
if (this._outBufs[0].numberOfChannels !== inCh)
this._outBufs[0] = new AudioBuffer(inCh, BLOCK_SIZE, this.context.sampleRate)
}
outBuf = this._outBufs[0] || null
// prepare outputs (zeroed)
let outputs = []
for (let i = 0; i < this.numberOfOutputs; i++) {
let buf = this._outBufs[i]
let chArrays = []
for (let ch = 0; ch < buf.numberOfChannels; ch++) {
let d = buf.getChannelData(ch)
d.fill(0)
chArrays.push(d)
}
outputs.push(Object.freeze(chArrays))
}
Object.freeze(outputs)
// gather parameters
let parameters = {}
let paramError = false
for (let [name, param] of this.#paramMap) {
let vals = param._tick()
// Per spec: k-rate params produce Float32Array of length 1;
// a-rate params with constant value MAY have length 1
let arr
if (param.automationRate === 'k-rate') {
arr = new Float32Array([vals[0]])
} else if (param._input.sources.length === 0 && _isConstant(vals)) {
arr = new Float32Array([vals[0]])
} else {
arr = vals
}
try {
parameters[name] = arr
} catch {
paramError = true
}
}
if (paramError) {
this.#alive = false
return outBuf
}
// call processor — spec requires reading 'process' property each call (getter support)
if (!this.#processor) return outBuf
let keepAlive
try {
let processFn = this.#processor.process
if (typeof processFn !== 'function') {
let e = new TypeError('process is not a function')
let ev = new (this.context._ErrorEvent || globalThis.ErrorEvent || Event)('processorerror', { error: e, message: e.message })
this.dispatchEvent(ev)
this.#alive = false
if (this.context._tailNodes) this.context._tailNodes.delete(this)
return outBuf
}
keepAlive = processFn.call(this.#processor, inputs, outputs, parameters)
} catch (e) {
let ev = new (this.context._ErrorEvent || globalThis.ErrorEvent || Event)('processorerror', { error: e, message: e?.message })
this.dispatchEvent(ev)
this.#alive = false
if (this.context._tailNodes) this.context._tailNodes.delete(this)
return outBuf
}
if (!keepAlive) {
this.#alive = false
if (this.context._tailNodes) this.context._tailNodes.delete(this)
}
return outBuf
}
}
// AudioWorklet — attached to context, provides addModule()
class AudioWorklet {
#scope
#context
#loadedModules = new Set()
#port // main-thread side port for global scope messaging
constructor(context) {
this.#context = context
this.#scope = new AudioWorkletGlobalScope(context)
context._workletScope = this.#scope
// Wire up global scope port
let channel = new MessageChannel()
this.#port = channel.port1
this.#scope.port = channel.port2
}
get port() { return this.#port }
async addModule(moduleOrSetup) {
if (typeof moduleOrSetup === 'function') {
return moduleOrSetup(this.#scope)
}
if (typeof moduleOrSetup !== 'string')
throw new TypeError('addModule requires a URL string or setup function')
// Per spec: same module URL loaded only once
if (this.#loadedModules.has(moduleOrSetup)) return
this.#loadedModules.add(moduleOrSetup)
let code = await this.#readModule(moduleOrSetup)
let scope = this.#scope
let regFn = (name, cls) => scope.registerProcessor(name, cls)
// Run processor code with AudioWorkletGlobalScope globals.
// Per spec, currentTime/currentFrame must be live values. We pass a context
// ref and define them as local getters using Object.defineProperties on a
// scope object. The code is wrapped to read from this scope.
let ctx = this.#context
let args = {
registerProcessor: regFn,
AudioWorkletProcessor,
sampleRate: ctx.sampleRate,
currentTime: 0,
currentFrame: 0,
port: scope.port,
_ctx: ctx,
}
// `with` is required here: the spec mandates that currentTime/currentFrame are live
// values in the AudioWorkletGlobalScope, accessible as bare identifiers in processor
// code. Only `with` + getter-backed scope object achieves this without polluting
// globalThis. We strip 'use strict' because `with` is forbidden in strict mode.
// This is safe: processor code runs in an isolated Function scope, not the module scope.
let cleanCode = code.replace(/^(['"])use strict\1;?\s*/gm, '')
let names = Object.keys(args)
// Wrap with a scope proxy so currentTime/currentFrame are live
let scopeObj = Object.create(null)
Object.defineProperty(scopeObj, 'currentTime', { get() { return ctx.currentTime }, enumerable: true, configurable: true })
Object.defineProperty(scopeObj, 'currentFrame', { get() { return ctx._frame }, enumerable: true, configurable: true })
for (let k of names) {
if (k === 'currentTime' || k === 'currentFrame' || k === '_ctx') continue
scopeObj[k] = args[k]
}
new Function('_s', 'with(_s){' + cleanCode + '}')(scopeObj)
}
async #readModule(url) {
// Allow custom reader (e.g. for test runners with vm sandboxes or blob URLs)
if (this.#context._readModule) return this.#context._readModule(url)
// data: URI — inline module code
if (url.startsWith('data:')) {
let comma = url.indexOf(',')
if (comma < 0) throw new Error('Invalid data URI')
let meta = url.slice(5, comma).toLowerCase()
let body = url.slice(comma + 1)
return meta.includes('base64') ? atob(body) : decodeURIComponent(body)
}
if (url.startsWith('blob:')) {
return await fetch(url).then(res => res.text())
}
// Dynamic import fs/path — works in Node.js, throws in browser
let fs, path
try { fs = await import('fs'); path = await import('path') } catch {
throw new Error('addModule(url) with string requires Node.js; use addModule(fn) in browser')
}
let rel = url.startsWith('/') ? url.slice(1) : url
let base = this.#context._basePath || process.cwd()
return fs.readFileSync(path.resolve(base, rel), 'utf8')
}
get _scope() { return this.#scope }
}
export { AudioWorkletNode, AudioWorkletProcessor, AudioWorkletGlobalScope, AudioWorklet }