-
Notifications
You must be signed in to change notification settings - Fork 70
Expand file tree
/
Copy pathAudioWorklet.js
More file actions
398 lines (343 loc) · 15.1 KB
/
AudioWorklet.js
File metadata and controls
398 lines (343 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
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 }