From 604d0e30de05c2688f32123dcc254cc73e4d55d0 Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 10:51:27 +0200 Subject: [PATCH 01/18] cleaning --- js/AudioWorkletGlobalScope.js | 10 ++-------- js/AudioWorkletProcessor.js | 5 +---- js/BaseAudioContext.js | 2 +- js/lib/audio-worklet/BufferPool.js | 8 +++++--- src/audio_worklet_node.rs | 7 +++++++ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 2b7a42b6..18b8c421 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -10,24 +10,18 @@ import { AudioWorkletProcessor } from './AudioWorkletProcessor.js'; import { isIterable } from './lib/is-iterable.js'; import { isConstructor } from './lib/is-constructor.js'; import { - kWorkletCallableProcess, kWorkletMarkNonCallableProcess, - kWorkletInputs, - kWorkletOutputs, - kWorkletParams, - kWorkletParamsCache, kWorkletGetBuffer, kWorkletGetBuffer1, kWorkletRecycleBuffer, kWorkletRecycleBuffer1, kWorkletMarkAsUntransferable, - kWorkletUnpackProcess, } from './lib/audio-worklet/symbols.js'; import { pendingProcessorConstructionData, -} from './lib/audio-worklet/pending-processor-construction-data.js' +} from './lib/audio-worklet/pending-processor-construction-data.js'; import { - BufferPool + BufferPool, } from './lib/audio-worklet/BufferPool.js'; import nativeBinding from '../load-native.js'; diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index d3150a25..e099a90b 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -7,14 +7,11 @@ import { kWorkletParamsCache, kWorkletGetBuffer, kWorkletGetBuffer1, - kWorkletRecycleBuffer, - kWorkletRecycleBuffer1, - kWorkletMarkAsUntransferable, kWorkletUnpackProcess, } from './lib/audio-worklet/symbols.js'; import { pendingProcessorConstructionData, -} from './lib/audio-worklet/pending-processor-construction-data.js' +} from './lib/audio-worklet/pending-processor-construction-data.js'; export class AudioWorkletProcessor { static get parameterDescriptors() { diff --git a/js/BaseAudioContext.js b/js/BaseAudioContext.js index 00a29bfd..7e6db617 100644 --- a/js/BaseAudioContext.js +++ b/js/BaseAudioContext.js @@ -514,8 +514,8 @@ Object.defineProperties(BaseAudioContext.prototype, { currentTime: kEnumerableProperty, renderQuantumSize: kEnumerableProperty, state: kEnumerableProperty, - onstatechange: kEnumerableProperty, decodeAudioData: kEnumerableProperty, createBuffer: kEnumerableProperty, createPeriodicWave: kEnumerableProperty, + onstatechange: kEnumerableProperty, }); diff --git a/js/lib/audio-worklet/BufferPool.js b/js/lib/audio-worklet/BufferPool.js index f5ec12c2..df1fe87d 100644 --- a/js/lib/audio-worklet/BufferPool.js +++ b/js/lib/audio-worklet/BufferPool.js @@ -39,9 +39,11 @@ export class BufferPool { } recycle(buffer) { - // make sure we can't pollute the pool - if (buffer.length === this.#bufferSize) { - this.#pool.push(buffer); + // make sure we don't pollute the pool + if (buffer.length !== this.#bufferSize) { + throw new Error(`Attempt to recycle a buffer of length ${buffer.length} in a pool of buffers of length ${this.#bufferSize}`); } + + this.#pool.push(buffer); } } diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 6313c75f..4f8a77f5 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -354,15 +354,22 @@ fn process_audio_worklet( let js_params_cache = processor.get_property::(k_worklet_params_cache)?; + use std::time::Instant; // Check input and output channel layout, and rebuild JS object if something changed if !check_same_io_layout(&js_inputs, inputs) { + let start = Instant::now(); let new_js_inputs = rebuild_io_layout(env, js_inputs, inputs)?; + let duration = start.elapsed(); + println!("rebuild input layout duration {:?}", duration); processor.set_property(k_worklet_inputs, new_js_inputs)?; js_inputs = processor.get_property::(k_worklet_inputs)?; } if !check_same_io_layout(&js_outputs, outputs) { + let start = Instant::now(); let new_js_outputs = rebuild_io_layout(env, js_outputs, outputs)?; + let duration = start.elapsed(); + println!("rebuild output layout duration {:?}", duration); processor.set_property(k_worklet_outputs, new_js_outputs)?; js_outputs = processor.get_property::(k_worklet_outputs)?; } From 2e6f23343209d08fe54129ab0c131c1f8ec7792a Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 12:52:06 +0200 Subject: [PATCH 02/18] cleaning --- js/AudioWorkletProcessor.js | 10 ++++------ src/audio_worklet_node.rs | 16 ++++------------ 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index e099a90b..bc6bf75d 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -43,8 +43,8 @@ export class AudioWorkletProcessor { // not for the reason explained try { // Populate with dummy values which will be replaced in first render call - this[kWorkletInputs] = new Array(numberOfInputs).fill([]); - this[kWorkletOutputs] = new Array(numberOfOutputs).fill([]); + this[kWorkletInputs] = Object.freeze(new Array(numberOfInputs).fill(Object.freeze([]))); + this[kWorkletOutputs] = Object.freeze( new Array(numberOfOutputs).fill(Object.freeze([]))); // 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 @@ -77,11 +77,9 @@ export class AudioWorkletProcessor { // // This method is called only if a "real" process method has been found [kWorkletUnpackProcess]([inputs, outputs, parameters]) { - // output must be filled with zero + // output must be cleaned up and filled with zeros // cf. the-audioworklet-interface/audioworkletprocessor-unconnected-outputs.https.window.html - outputs.forEach(output => { - output.forEach(channel => channel.fill(0)); - }); + outputs.forEach(output => output.forEach(channel => channel.fill(0))); try { return !!this.process(inputs, outputs, parameters); diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 4f8a77f5..962ff263 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -141,7 +141,7 @@ struct WorkletAbruptCompletionResult { /// /// Note that we don't check the number of inputs / outputs as they are defined /// at construction and cannot be changed -fn check_same_io_layout(js_io: &Array, rs_io: &'static [&'static [&'static [f32]]]) -> bool { +fn is_same_io_layout(js_io: &Array, rs_io: &'static [&'static [&'static [f32]]]) -> bool { for (i, rs_channels) in rs_io.iter().enumerate() { let js_channels = js_io.get::(i as u32); @@ -165,8 +165,7 @@ fn check_same_io_layout(js_io: &Array, rs_io: &'static [&'static [&'static [f32] /// Recreate the JS inputs or output data structures (input and output are handled separately). /// We must rebuild the whole structure from scratch because the resulting Arrays are frozen. -/// -/// @todo - move this logic to JS to minimize language boundary crossing (needs benchmarking) +// @note: mini benchmarks have been made w/ an alternative JS implementation, was way slower fn rebuild_io_layout<'a>( env: &'a Env, js_io: Array, @@ -354,22 +353,15 @@ fn process_audio_worklet( let js_params_cache = processor.get_property::(k_worklet_params_cache)?; - use std::time::Instant; // Check input and output channel layout, and rebuild JS object if something changed - if !check_same_io_layout(&js_inputs, inputs) { - let start = Instant::now(); + if !is_same_io_layout(&js_inputs, inputs) { let new_js_inputs = rebuild_io_layout(env, js_inputs, inputs)?; - let duration = start.elapsed(); - println!("rebuild input layout duration {:?}", duration); processor.set_property(k_worklet_inputs, new_js_inputs)?; js_inputs = processor.get_property::(k_worklet_inputs)?; } - if !check_same_io_layout(&js_outputs, outputs) { - let start = Instant::now(); + if !is_same_io_layout(&js_outputs, outputs) { let new_js_outputs = rebuild_io_layout(env, js_outputs, outputs)?; - let duration = start.elapsed(); - println!("rebuild output layout duration {:?}", duration); processor.set_property(k_worklet_outputs, new_js_outputs)?; js_outputs = processor.get_property::(k_worklet_outputs)?; } From 7c3aff95663c48efc08f54454d2decf77b90c0e9 Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 16:55:42 +0200 Subject: [PATCH 03/18] fix: more robust AudioWorkletProcessor construction process --- js/AudioWorkletGlobalScope.js | 29 +++++++++++++------ js/AudioWorkletProcessor.js | 18 ++++++++++-- .../pending-processor-construction-data.js | 1 - js/lib/audio-worklet/pending-processor.js | 4 +++ 4 files changed, 39 insertions(+), 13 deletions(-) delete mode 100644 js/lib/audio-worklet/pending-processor-construction-data.js create mode 100644 js/lib/audio-worklet/pending-processor.js diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 18b8c421..44b95d91 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -18,8 +18,8 @@ import { kWorkletMarkAsUntransferable, } from './lib/audio-worklet/symbols.js'; import { - pendingProcessorConstructionData, -} from './lib/audio-worklet/pending-processor-construction-data.js'; + pendingProcessor, +} from './lib/audio-worklet/pending-processor.js'; import { BufferPool, } from './lib/audio-worklet/BufferPool.js'; @@ -236,19 +236,20 @@ parentPort.on('message', async event => { const ctor = nameProcessorCtorMap.get(name); // entities of interest for the AudioWorkletProcess base class - pendingProcessorConstructionData.inner = { + pendingProcessor.constructionData = { messagePort, errorPort, numberOfInputs: options.numberOfInputs, numberOfOutputs: options.numberOfOutputs, parameterDescriptors: ctor.parameterDescriptors, + errored: null, }; - let instance; + // let instance; let errored = false; try { - instance = new ctor(options); + pendingProcessor.instance = new ctor(options); } catch (err) { // if the given processor constructor failed, we create a dummy processor // that we mark immediately as non-callable. This prevents situations where @@ -257,14 +258,24 @@ parentPort.on('message', async event => { // @todo - This design could be improved in the future by flagging somehow // the Rust processor to avoid the cross thread communication errored = true; - instance = new AudioWorkletProcessor(options); - instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:ctor-error', err]); + + if (!pendingProcessor.instance) { + pendingProcessor.instance = new AudioWorkletProcessor(options); + pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:ctor-error', err]); + } + } + + if (!(typeof pendingProcessor.instance.process === 'function')) { + const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": no process method found`); + pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); } - pendingProcessorConstructionData.inner = null; // store in global so that Rust can match the JS processor // with its corresponding NapiAudioWorkletProcessor - processors[`${id}`] = instance; + processors[`${id}`] = pendingProcessor.instance; + + pendingProcessor.constructionData = null; + pendingProcessor.instance = null; // notify main thread that instantiation has finished somehow if (errored) { parentPort.postMessage({ cmd: 'node-web-audio-api:worklet:ctor-error', id }); diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index bc6bf75d..0a4a712c 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -10,8 +10,8 @@ import { kWorkletUnpackProcess, } from './lib/audio-worklet/symbols.js'; import { - pendingProcessorConstructionData, -} from './lib/audio-worklet/pending-processor-construction-data.js'; + pendingProcessor, +} from './lib/audio-worklet/pending-processor.js'; export class AudioWorkletProcessor { static get parameterDescriptors() { @@ -22,17 +22,24 @@ export class AudioWorkletProcessor { #errorPort = null; constructor() { + if (pendingProcessor.instance !== null) { + this[kWorkletCallableProcess] = false; + throw new TypeError('Cannot construct "AudioWorkletProcessor": Invalid pending construction data');; + } + const { messagePort, errorPort, numberOfInputs, numberOfOutputs, parameterDescriptors, - } = pendingProcessorConstructionData.inner; + } = pendingProcessor.constructionData; this.#messagePort = messagePort; this.#errorPort = errorPort; + pendingProcessor.instance = this; + // Mark [[callable process]] as true, set to false in render quantum // either if "process" does not exists or if it throws an error this[kWorkletCallableProcess] = true; @@ -84,6 +91,11 @@ export class AudioWorkletProcessor { try { return !!this.process(inputs, outputs, parameters); } catch (err) { + // make sure Rust receives a "real" error instance, i.e. support `throw "my message";` + if (!Error.isError(err)) { + err = new Error(err); + } + return err; } } diff --git a/js/lib/audio-worklet/pending-processor-construction-data.js b/js/lib/audio-worklet/pending-processor-construction-data.js deleted file mode 100644 index 5ff4500b..00000000 --- a/js/lib/audio-worklet/pending-processor-construction-data.js +++ /dev/null @@ -1 +0,0 @@ -export const pendingProcessorConstructionData = { inner: null }; diff --git a/js/lib/audio-worklet/pending-processor.js b/js/lib/audio-worklet/pending-processor.js new file mode 100644 index 00000000..aec7a412 --- /dev/null +++ b/js/lib/audio-worklet/pending-processor.js @@ -0,0 +1,4 @@ +export const pendingProcessor = { + constructionData: null, + instance: null, +}; From 7d4ed744b6e408789efae58daed82788e57988d3 Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 16:57:36 +0200 Subject: [PATCH 04/18] lint --- js/AudioWorkletProcessor.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index 0a4a712c..2933389f 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -91,12 +91,15 @@ export class AudioWorkletProcessor { try { return !!this.process(inputs, outputs, parameters); } catch (err) { + let returnedError; // make sure Rust receives a "real" error instance, i.e. support `throw "my message";` if (!Error.isError(err)) { - err = new Error(err); + returnedError = new Error(err); + } else { + returnedError = err; } - return err; + return returnedError; } } From 92f014036e38648f8b010436dc22773d24ae83e2 Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 16:58:25 +0200 Subject: [PATCH 05/18] cleaning --- js/AudioWorkletGlobalScope.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 44b95d91..ebff434b 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -245,7 +245,6 @@ parentPort.on('message', async event => { errored: null, }; - // let instance; let errored = false; try { From 3f375f7b1be3cb086ce31f1634b7b364bb5bf167 Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 17:09:44 +0200 Subject: [PATCH 06/18] fix: do not recheck that `process` method exists in each render quantum, this is done at instanciation --- js/AudioWorkletGlobalScope.js | 7 +- src/audio_worklet_node.rs | 185 +++++++++++++++------------------- 2 files changed, 88 insertions(+), 104 deletions(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index ebff434b..6f1a1f4e 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -246,6 +246,7 @@ parentPort.on('message', async event => { }; let errored = false; + let isMock = false; try { pendingProcessor.instance = new ctor(options); @@ -259,12 +260,16 @@ parentPort.on('message', async event => { errored = true; if (!pendingProcessor.instance) { + isMock = true; + pendingProcessor.instance = new AudioWorkletProcessor(options); pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:ctor-error', err]); } } - if (!(typeof pendingProcessor.instance.process === 'function')) { + // if instance is a mock, we know it as no `process` method and it has + // already been marked as non callable, no need to report it again + if (!isMock && typeof pendingProcessor.instance.process !== 'function') { const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": no process method found`); pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); } diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 962ff263..62f086b6 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -314,125 +314,104 @@ fn process_audio_worklet( return Ok(()); } - // The `process` method is not executed directly because napi-rs `apply` + let render_quantum_size = global.get_named_property::("renderQuantumSize")? as usize; + // The `process` method is executed indirectly because napi-rs `apply` // implementation retrieve the arguments as an array in the first argument, Then // we need to unpack them first (cf. `AudioWorkletProcessor[kWorkletUnpackProcess]`) - // - // Note that we use `get_named_property` rather than `has_named_property` so that - // we check that `process` is a function as well. - let process_method_result = - processor.get_named_property::>("process"); - - let abrupt_completion = match process_method_result { - Ok(_process_method) => { - let render_quantum_size = - global.get_named_property::("renderQuantumSize")? as usize; - - let k_worklet_unpack_process = - env.symbol_for("node-web-audio-api:worklet-unpack-process")?; - - // The `kWorkletUnpackProcess` wrapper function coerce value returned from `process` - // to bool, therefore if we get anything else, i.e. `Unknown`, an error occurred. - let unpack_process_function = processor - .get_property::>>( - k_worklet_unpack_process, - )?; - - let k_worklet_inputs = env.symbol_for("node-web-audio-api:worklet-inputs")?; - let mut js_inputs = processor.get_property::(k_worklet_inputs)?; - - let k_worklet_outputs = env.symbol_for("node-web-audio-api:worklet-outputs")?; - let mut js_outputs = processor.get_property::(k_worklet_outputs)?; - - // - let k_worklet_params = env.symbol_for("node-web-audio-api:worklet-params")?; - let mut js_params = processor.get_property::(k_worklet_params)?; - // - let k_worklet_params_cache = - env.symbol_for("node-web-audio-api:worklet-params-cache")?; - let js_params_cache = - processor.get_property::(k_worklet_params_cache)?; - - // Check input and output channel layout, and rebuild JS object if something changed - if !is_same_io_layout(&js_inputs, inputs) { - let new_js_inputs = rebuild_io_layout(env, js_inputs, inputs)?; - processor.set_property(k_worklet_inputs, new_js_inputs)?; - js_inputs = processor.get_property::(k_worklet_inputs)?; - } + let k_worklet_unpack_process = env.symbol_for("node-web-audio-api:worklet-unpack-process")?; - if !is_same_io_layout(&js_outputs, outputs) { - let new_js_outputs = rebuild_io_layout(env, js_outputs, outputs)?; - processor.set_property(k_worklet_outputs, new_js_outputs)?; - js_outputs = processor.get_property::(k_worklet_outputs)?; - } + // The `kWorkletUnpackProcess` wrapper function coerce value returned from `process` + // to bool, therefore if we get anything else, i.e. `Unknown`, an error occurred. + let unpack_process_function = processor + .get_property::>>( + k_worklet_unpack_process, + )?; - // Copy inputs into JS inputs buffers - for (input_number, input) in inputs.iter().enumerate() { - let js_input = js_inputs.get::(input_number as u32)?.unwrap(); + let k_worklet_inputs = env.symbol_for("node-web-audio-api:worklet-inputs")?; + let mut js_inputs = processor.get_property::(k_worklet_inputs)?; - for (channel_number, channel) in input.iter().enumerate() { - let mut js_channel = js_input - .get::(channel_number as u32)? - .unwrap(); - let js_channel: &mut [f32] = unsafe { js_channel.as_mut() }; - js_channel.copy_from_slice(channel); - } - } + let k_worklet_outputs = env.symbol_for("node-web-audio-api:worklet-outputs")?; + let mut js_outputs = processor.get_property::(k_worklet_outputs)?; - // Copy params values into JS params buffers - // - // @todo(perf) - We could rely on the fact that ParameterDescriptors - // are ordered maps to avoid sending param names in `param_values` - for (name, data) in param_values.iter() { - let float32_arr_cache = js_params_cache.get_named_property::(name)?; - // retrieve right Float32Array according to actual param size, i.e. 128 or 1 - let cache_index = if data.len() == 1 { 1 } else { 0 }; - let mut param_values = float32_arr_cache.get::(cache_index)?.unwrap(); - // copy data into underlying ArrayBuffer - let buffer: &mut [f32] = unsafe { param_values.as_mut() }; - buffer.copy_from_slice(data); - // replace current values with new Float32Array - js_params.set_named_property(name, param_values)?; - } + // + let k_worklet_params = env.symbol_for("node-web-audio-api:worklet-params")?; + let mut js_params = processor.get_property::(k_worklet_params)?; + // + let k_worklet_params_cache = env.symbol_for("node-web-audio-api:worklet-params-cache")?; + let js_params_cache = processor.get_property::(k_worklet_params_cache)?; - let js_result = - unpack_process_function.apply(processor, (js_inputs, js_outputs, js_params))?; + // Check input and output channel layout, and rebuild JS object if something changed + if !is_same_io_layout(&js_inputs, inputs) { + let new_js_inputs = rebuild_io_layout(env, js_inputs, inputs)?; + processor.set_property(k_worklet_inputs, new_js_inputs)?; + js_inputs = processor.get_property::(k_worklet_inputs)?; + } - match js_result { - Either::A(tail_time) => { - // copy JS output buffers back into outputs - for (output_number, output) in outputs.iter().enumerate() { - let js_output = js_outputs.get_element::(output_number as u32)?; + if !is_same_io_layout(&js_outputs, outputs) { + let new_js_outputs = rebuild_io_layout(env, js_outputs, outputs)?; + processor.set_property(k_worklet_outputs, new_js_outputs)?; + js_outputs = processor.get_property::(k_worklet_outputs)?; + } - for (channel_number, channel) in output.iter().enumerate() { - let js_channel = js_output - .get::(channel_number as u32)? - .unwrap(); + // Copy inputs into JS inputs buffers + for (input_number, input) in inputs.iter().enumerate() { + let js_input = js_inputs.get::(input_number as u32)?.unwrap(); - let src = js_channel.as_ptr(); - let dst = channel.as_ptr() as *mut f32; + for (channel_number, channel) in input.iter().enumerate() { + let mut js_channel = js_input + .get::(channel_number as u32)? + .unwrap(); + let js_channel: &mut [f32] = unsafe { js_channel.as_mut() }; + js_channel.copy_from_slice(channel); + } + } - unsafe { - std::ptr::copy_nonoverlapping(src, dst, render_quantum_size); - } - } - } + // Copy params values into JS params buffers + // + // @todo(perf) - We could rely on the fact that ParameterDescriptors + // are ordered maps to avoid sending param names in `param_values` + for (name, data) in param_values.iter() { + let float32_arr_cache = js_params_cache.get_named_property::(name)?; + // retrieve right Float32Array according to actual param size, i.e. 128 or 1 + let cache_index = if data.len() == 1 { 1 } else { 0 }; + let mut param_values = float32_arr_cache.get::(cache_index)?.unwrap(); + // copy data into underlying ArrayBuffer + let buffer: &mut [f32] = unsafe { param_values.as_mut() }; + buffer.copy_from_slice(data); + // replace current values with new Float32Array + js_params.set_named_property(name, param_values)?; + } + + let js_result = unpack_process_function.apply(processor, (js_inputs, js_outputs, js_params))?; - let _ = tail_time_sender.send(tail_time); // allowed to fail + let abrupt_completion = match js_result { + Either::A(tail_time) => { + // copy JS output buffers back into outputs + for (output_number, output) in outputs.iter().enumerate() { + let js_output = js_outputs.get_element::(output_number as u32)?; - None + for (channel_number, channel) in output.iter().enumerate() { + let js_channel = js_output + .get::(channel_number as u32)? + .unwrap(); + + let src = js_channel.as_ptr(); + let dst = channel.as_ptr() as *mut f32; + + unsafe { + std::ptr::copy_nonoverlapping(src, dst, render_quantum_size); + } } - // error thrown in process - Either::B(err) => Some(WorkletAbruptCompletionResult { - cmd: "node-web-audio-api:worklet:process-error".to_string(), - err: err.into(), - }), } + + let _ = tail_time_sender.send(tail_time); // allowed to fail + + None } - // no process method found - Err(err) => Some(WorkletAbruptCompletionResult { - cmd: "node-web-audio-api:worklet:process-invalid".to_string(), - err, + // error thrown in process + Either::B(err) => Some(WorkletAbruptCompletionResult { + cmd: "node-web-audio-api:worklet:process-error".to_string(), + err: err.into(), }), }; From 3ed43ebac96ee1c55d3ba663f2c5b1fa24cc537e Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 17:42:28 +0200 Subject: [PATCH 07/18] fix: check existence of process attribute in prototype and instance at construction --- js/AudioWorkletGlobalScope.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 6f1a1f4e..59ee8198 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -267,11 +267,15 @@ parentPort.on('message', async event => { } } - // if instance is a mock, we know it as no `process` method and it has - // already been marked as non callable, no need to report it again - if (!isMock && typeof pendingProcessor.instance.process !== 'function') { - const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": no process method found`); - pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); + // check that process method exists either on instance or on prototype + // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html + // If execution of process fail for any reason, it will be catched in + // AudioWorkletProcessor::[kWorkletUnpackProcess] + if (!isMock) { + if (!ctor.prototype.hasOwnProperty('process') && !pendingProcessor.instance.hasOwnProperty('process')) { + const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": Invalid "process" method`); + pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); + } } // store in global so that Rust can match the JS processor From 0983bd010206d1369ca872f6a8aa0710832dfaa5 Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 19:45:29 +0200 Subject: [PATCH 08/18] fix: improve constructor error handling --- js/AudioWorkletGlobalScope.js | 10 ++++++---- js/AudioWorkletProcessor.js | 6 ++++-- js/lib/audio-worklet/pending-processor.js | 3 ++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 59ee8198..1b97a9cc 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -251,17 +251,18 @@ parentPort.on('message', async event => { try { pendingProcessor.instance = new ctor(options); } catch (err) { - // if the given processor constructor failed, we create a dummy processor + errored = true; + + // If the given processor constructor failed, we create a dummy processor // that we mark immediately as non-callable. This prevents situations where // the NapiAudioWorkletProcessor, which already exists at this point, hangs // forever waiting for its JS counterpart // @todo - This design could be improved in the future by flagging somehow // the Rust processor to avoid the cross thread communication - errored = true; - if (!pendingProcessor.instance) { isMock = true; - + // super may have been called but instance did throw, we can reset super + pendingProcessor.super = null; pendingProcessor.instance = new AudioWorkletProcessor(options); pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:ctor-error', err]); } @@ -284,6 +285,7 @@ parentPort.on('message', async event => { pendingProcessor.constructionData = null; pendingProcessor.instance = null; + pendingProcessor.super = null; // notify main thread that instantiation has finished somehow if (errored) { parentPort.postMessage({ cmd: 'node-web-audio-api:worklet:ctor-error', id }); diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index 2933389f..a86c744e 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -22,7 +22,9 @@ export class AudioWorkletProcessor { #errorPort = null; constructor() { - if (pendingProcessor.instance !== null) { + // check that this constructor has never been called for this processor instantiation + // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/processor-construction-port.https.html + if (pendingProcessor.super !== null) { this[kWorkletCallableProcess] = false; throw new TypeError('Cannot construct "AudioWorkletProcessor": Invalid pending construction data');; } @@ -38,7 +40,7 @@ export class AudioWorkletProcessor { this.#messagePort = messagePort; this.#errorPort = errorPort; - pendingProcessor.instance = this; + pendingProcessor.super = this; // Mark [[callable process]] as true, set to false in render quantum // either if "process" does not exists or if it throws an error diff --git a/js/lib/audio-worklet/pending-processor.js b/js/lib/audio-worklet/pending-processor.js index aec7a412..d21a59fd 100644 --- a/js/lib/audio-worklet/pending-processor.js +++ b/js/lib/audio-worklet/pending-processor.js @@ -1,4 +1,5 @@ export const pendingProcessor = { constructionData: null, - instance: null, + instance: null, // mark derived class constructor as been successfully called + super: null, // mark AudioWorkletProcessor constructor as been called }; From 68f49dd8307bb58702d80e4d5323f6e5fb62143f Mon Sep 17 00:00:00 2001 From: b-ma Date: Wed, 27 May 2026 20:13:25 +0200 Subject: [PATCH 09/18] lint --- .scripts/wpt-harness.js | 1 + js/AudioWorkletGlobalScope.js | 10 +++++----- tests/AudioWorklet.spec.js | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.scripts/wpt-harness.js b/.scripts/wpt-harness.js index 434846cc..efe0b314 100644 --- a/.scripts/wpt-harness.js +++ b/.scripts/wpt-harness.js @@ -39,6 +39,7 @@ const ignoreCases = [ 'the-audio-api/the-iirfilternode-interface/iir-filter-silent-block-crash.html', 'the-audio-api/the-mediaelementaudiosourcenode-interface/mediaElementAudioSource-mediaStreamAudioDestination-stream-crash.html', 'the-audio-api/the-mediaelementaudiosourcenode-interface/mediaElementAudioSource_closed_context-crash.html', + 'the-audio-api/the-audioworklet-interface/process-parameters.https.html', ] // ------------------------------------------------------- diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 1b97a9cc..aae03db8 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -268,19 +268,19 @@ parentPort.on('message', async event => { } } - // check that process method exists either on instance or on prototype - // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html + // Check that process method exists either on instance or on prototype. // If execution of process fail for any reason, it will be catched in // AudioWorkletProcessor::[kWorkletUnpackProcess] + // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html if (!isMock) { - if (!ctor.prototype.hasOwnProperty('process') && !pendingProcessor.instance.hasOwnProperty('process')) { + if (!Object.prototype.hasOwnProperty.call(ctor.prototype, 'process') + && !Object.prototype.hasOwnProperty.call(pendingProcessor.instance, 'process')) { const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": Invalid "process" method`); pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); } } - // store in global so that Rust can match the JS processor - // with its corresponding NapiAudioWorkletProcessor + // Store in global to match the JS processor with its corresponding NapiAudioWorkletProcessor processors[`${id}`] = pendingProcessor.instance; pendingProcessor.constructionData = null; diff --git a/tests/AudioWorklet.spec.js b/tests/AudioWorklet.spec.js index 79e71184..3d7e8807 100644 --- a/tests/AudioWorklet.spec.js +++ b/tests/AudioWorklet.spec.js @@ -194,7 +194,7 @@ describe('AudioWorklet', () => { await audioContext.close(); }); - it(`should throw clean error if worklet is invalid`, async () => { + it(`should throw clean error if worklet is invalid (1)`, async () => { // blob worklets do not support import const blob = new Blob(['import stuff from "./abc"'], { type: 'application/javascript' }); const objectUrl = URL.createObjectURL(blob); @@ -213,7 +213,7 @@ describe('AudioWorklet', () => { assert.isTrue(errored); }); - it(`should throw clean error if worklet is invalid`, async () => { + it(`should throw clean error if worklet is invalid (2)`, async () => { const audioContext = new AudioContext(); let errored = false; From 6e622a21bda26021df3eade516e494c49f66cbdc Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 09:32:06 +0200 Subject: [PATCH 10/18] fix: look for process method in whole prototype chain --- js/AudioWorkletGlobalScope.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index aae03db8..4fbdc54a 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -272,9 +272,10 @@ parentPort.on('message', async event => { // If execution of process fail for any reason, it will be catched in // AudioWorkletProcessor::[kWorkletUnpackProcess] // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html + // Do not use `hasOwnProperty` because we cannot assume that we know + // the prototype chain. if (!isMock) { - if (!Object.prototype.hasOwnProperty.call(ctor.prototype, 'process') - && !Object.prototype.hasOwnProperty.call(pendingProcessor.instance, 'process')) { + if (!('process' in pendingProcessor.instance)) { const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": Invalid "process" method`); pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); } From 018e71b84ae43c1c801d9d408ab790c3350a44e8 Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 09:32:37 +0200 Subject: [PATCH 11/18] refactor: fill output with zeros on rust side --- js/AudioWorkletProcessor.js | 10 ++++------ src/audio_worklet_node.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index a86c744e..980bb4d3 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -83,16 +83,14 @@ export class AudioWorkletProcessor { // - unpack arguments from napi-rs `apply` // - cast return value to boolean // - catch and cleanly return error so that rust can properly handle it - // - // This method is called only if a "real" process method has been found + // This method is called only if a "real" process attribute has been found at construction + // However if this is the first call we don't know yet if process is callable [kWorkletUnpackProcess]([inputs, outputs, parameters]) { - // output must be cleaned up and filled with zeros - // cf. the-audioworklet-interface/audioworkletprocessor-unconnected-outputs.https.window.html - outputs.forEach(output => output.forEach(channel => channel.fill(0))); - try { return !!this.process(inputs, outputs, parameters); } catch (err) { + // no need to return the error to rust and have another roundtrip + // we can just mark the process as non callable here and return false let returnedError; // make sure Rust receives a "real" error instance, i.e. support `throw "my message";` if (!Error.isError(err)) { diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 62f086b6..16b1eb1c 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -366,6 +366,20 @@ fn process_audio_worklet( } } + // Clear output buffers + // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-zero-outputs.https.html + for (output_number, output) in outputs.iter().enumerate() { + let js_output = js_outputs.get::(output_number as u32)?.unwrap(); + + for (channel_number, channel) in output.iter().enumerate() { + let mut js_channel = js_output + .get::(channel_number as u32)? + .unwrap(); + let js_channel: &mut [f32] = unsafe { js_channel.as_mut() }; + js_channel.fill(0.); + } + } + // Copy params values into JS params buffers // // @todo(perf) - We could rely on the fact that ParameterDescriptors From ab4055ad33f3726b69d3a7fe3b92542a67c97c88 Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 09:45:53 +0200 Subject: [PATCH 12/18] tests: add some tests for invalid process --- tests/AudioWorklet.spec.js | 34 +++++++++++++++++++++++ tests/worklets/invalid-process.worklet.js | 15 ++++++++++ 2 files changed, 49 insertions(+) create mode 100644 tests/worklets/invalid-process.worklet.js diff --git a/tests/AudioWorklet.spec.js b/tests/AudioWorklet.spec.js index 3d7e8807..871d3934 100644 --- a/tests/AudioWorklet.spec.js +++ b/tests/AudioWorklet.spec.js @@ -264,6 +264,40 @@ describe('AudioWorkletProcessor', () => { assert.isTrue(errored); }); + it('should throw a clean error when process is not callable', async () => { + let errored = false; + + const audioContext = new AudioContext(); + await audioContext.audioWorklet.addModule('./worklets/invalid-process.worklet.js'); + + const invalid = new AudioWorkletNode(audioContext, 'invalid-process'); + invalid.addEventListener('processorerror', (e) => { + prettyPrintErr(e.error); + errored = true; + }); + + await delay(100); + await audioContext.close(); + assert.isTrue(errored); + }); + + it('should throw a clean error when process throws', async () => { + let errored = false; + + const audioContext = new AudioContext(); + await audioContext.audioWorklet.addModule('./worklets/invalid-process.worklet.js'); + + const invalid = new AudioWorkletNode(audioContext, 'process-throws'); + invalid.addEventListener('processorerror', (e) => { + prettyPrintErr(e.error); + errored = true; + }); + + await delay(100); + await audioContext.close(); + assert.isTrue(errored); + }); + it('OfflineAudioContext.startRendering should return when processor constructor is invalid', async () => { const audioContext = new OfflineAudioContext(1, 128, 48000); await audioContext.audioWorklet.addModule('./worklets/invalid-ctor.worklet.js'); diff --git a/tests/worklets/invalid-process.worklet.js b/tests/worklets/invalid-process.worklet.js new file mode 100644 index 00000000..9df99768 --- /dev/null +++ b/tests/worklets/invalid-process.worklet.js @@ -0,0 +1,15 @@ +class InvalidProcess extends AudioWorkletProcessor { + // attribute exists but is not callbable + process = null +} + +registerProcessor('invalid-process', InvalidProcess); + +class ProcessThrows extends AudioWorkletProcessor { + // attribute exists but is not callbable + process = (inputs, outputs, params) => { + outputs[3][1] = 'throws'; + } +} + +registerProcessor('process-throws', ProcessThrows); From 736d67153ef5ff9a82ce869e7b2acbe3df1939ab Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 09:46:22 +0200 Subject: [PATCH 13/18] clippy --- src/audio_worklet_node.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 16b1eb1c..45ad3105 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -371,7 +371,7 @@ fn process_audio_worklet( for (output_number, output) in outputs.iter().enumerate() { let js_output = js_outputs.get::(output_number as u32)?.unwrap(); - for (channel_number, channel) in output.iter().enumerate() { + for (channel_number, _) in output.iter().enumerate() { let mut js_channel = js_output .get::(channel_number as u32)? .unwrap(); From 474287b26c0cdd3cdcbc273c6f7ffdcc5d8c583e Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 10:15:32 +0200 Subject: [PATCH 14/18] fix: improve invalid constructor data error message --- js/AudioWorkletProcessor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index 980bb4d3..946df649 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -26,7 +26,7 @@ export class AudioWorkletProcessor { // cf. wpt/webaudio/the-audio-api/the-audioworklet-interface/processor-construction-port.https.html if (pendingProcessor.super !== null) { this[kWorkletCallableProcess] = false; - throw new TypeError('Cannot construct "AudioWorkletProcessor": Invalid pending construction data');; + throw new TypeError(`Cannot construct "${this.constructor.name}": Invalid pending construction data`); } const { From ec67e5415ee18495a460413365b5156e6bac70c6 Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 10:40:30 +0200 Subject: [PATCH 15/18] refactor: handle process on js side, simply return proper tail time to rust --- js/AudioWorkletGlobalScope.js | 4 +- js/AudioWorkletProcessor.js | 16 ++++--- src/audio_worklet_node.rs | 87 +++++++++++------------------------ 3 files changed, 37 insertions(+), 70 deletions(-) diff --git a/js/AudioWorkletGlobalScope.js b/js/AudioWorkletGlobalScope.js index 4fbdc54a..06ccc6f5 100644 --- a/js/AudioWorkletGlobalScope.js +++ b/js/AudioWorkletGlobalScope.js @@ -264,7 +264,7 @@ parentPort.on('message', async event => { // super may have been called but instance did throw, we can reset super pendingProcessor.super = null; pendingProcessor.instance = new AudioWorkletProcessor(options); - pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:ctor-error', err]); + pendingProcessor.instance[kWorkletMarkNonCallableProcess]('node-web-audio-api:worklet:ctor-error', err); } } @@ -277,7 +277,7 @@ parentPort.on('message', async event => { if (!isMock) { if (!('process' in pendingProcessor.instance)) { const err = new TypeError(`Invalid AudioWorkletNode "${pendingProcessor.instance.constructor.name}": Invalid "process" method`); - pendingProcessor.instance[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:process-invalid', err]); + pendingProcessor.instance[kWorkletMarkNonCallableProcess]('node-web-audio-api:worklet:process-invalid', err); } } diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index 946df649..c87c8757 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -67,7 +67,7 @@ export class AudioWorkletProcessor { ]; } } catch (err) { - this[kWorkletMarkNonCallableProcess](['node-web-audio-api:worklet:ctor-error', err]); + this[kWorkletMarkNonCallableProcess]('node-web-audio-api:worklet:ctor-error', err); } } @@ -82,7 +82,7 @@ export class AudioWorkletProcessor { // Wrapper around the "real" process method that allows to // - unpack arguments from napi-rs `apply` // - cast return value to boolean - // - catch and cleanly return error so that rust can properly handle it + // - catch and propagate error while keeping the rust side clean // This method is called only if a "real" process attribute has been found at construction // However if this is the first call we don't know yet if process is callable [kWorkletUnpackProcess]([inputs, outputs, parameters]) { @@ -91,19 +91,21 @@ export class AudioWorkletProcessor { } catch (err) { // no need to return the error to rust and have another roundtrip // we can just mark the process as non callable here and return false - let returnedError; + let error; // make sure Rust receives a "real" error instance, i.e. support `throw "my message";` if (!Error.isError(err)) { - returnedError = new Error(err); + error = new Error(err); } else { - returnedError = err; + error = err; } - return returnedError; + this[kWorkletMarkNonCallableProcess]('node-web-audio-api:worklet:process-error', error); + + return false; } } - [kWorkletMarkNonCallableProcess]([cmd, err]) { + [kWorkletMarkNonCallableProcess](cmd, err) { this[kWorkletCallableProcess] = false; this.#errorPort.postMessage({ cmd, err }); } diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 45ad3105..87128860 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -131,10 +131,6 @@ thread_local! { static HAS_THREAD_PRIO: Cell = const { Cell::new(false) }; } -struct WorkletAbruptCompletionResult { - cmd: String, - err: Error, -} /// Check that given JS and Rust input / output layout are the same, /// i.e. that each input / output have the same number of channels @@ -315,17 +311,6 @@ fn process_audio_worklet( } let render_quantum_size = global.get_named_property::("renderQuantumSize")? as usize; - // The `process` method is executed indirectly because napi-rs `apply` - // implementation retrieve the arguments as an array in the first argument, Then - // we need to unpack them first (cf. `AudioWorkletProcessor[kWorkletUnpackProcess]`) - let k_worklet_unpack_process = env.symbol_for("node-web-audio-api:worklet-unpack-process")?; - - // The `kWorkletUnpackProcess` wrapper function coerce value returned from `process` - // to bool, therefore if we get anything else, i.e. `Unknown`, an error occurred. - let unpack_process_function = processor - .get_property::>>( - k_worklet_unpack_process, - )?; let k_worklet_inputs = env.symbol_for("node-web-audio-api:worklet-inputs")?; let mut js_inputs = processor.get_property::(k_worklet_inputs)?; @@ -381,7 +366,6 @@ fn process_audio_worklet( } // Copy params values into JS params buffers - // // @todo(perf) - We could rely on the fact that ParameterDescriptors // are ordered maps to avoid sending param names in `param_values` for (name, data) in param_values.iter() { @@ -396,60 +380,41 @@ fn process_audio_worklet( js_params.set_named_property(name, param_values)?; } - let js_result = unpack_process_function.apply(processor, (js_inputs, js_outputs, js_params))?; + // The `process` method is executed indirectly because napi-rs `apply` implementation + // retrieve the arguments as an array in the first argument, Then we need to unpack + // them first (cf. `AudioWorkletProcessor[kWorkletUnpackProcess]`) + let k_worklet_unpack_process = env.symbol_for("node-web-audio-api:worklet-unpack-process")?; - let abrupt_completion = match js_result { - Either::A(tail_time) => { - // copy JS output buffers back into outputs - for (output_number, output) in outputs.iter().enumerate() { - let js_output = js_outputs.get_element::(output_number as u32)?; + // The `kWorkletUnpackProcess` wrapper function coerce value returned from `process` + // to bool, if any error occurred in process, it has been catched in `kWorkletUnpackProcess`` + // which marked the processor has non-callable and returned false + let unpack_process_function = processor + .get_property::>( + k_worklet_unpack_process, + )?; - for (channel_number, channel) in output.iter().enumerate() { - let js_channel = js_output - .get::(channel_number as u32)? - .unwrap(); + let tail_time = unpack_process_function.apply(processor, (js_inputs, js_outputs, js_params))?; - let src = js_channel.as_ptr(); - let dst = channel.as_ptr() as *mut f32; + // copy JS output buffers back into outputs + for (output_number, output) in outputs.iter().enumerate() { + let js_output = js_outputs.get_element::(output_number as u32)?; - unsafe { - std::ptr::copy_nonoverlapping(src, dst, render_quantum_size); - } - } - } + for (channel_number, channel) in output.iter().enumerate() { + let js_channel = js_output + .get::(channel_number as u32)? + .unwrap(); - let _ = tail_time_sender.send(tail_time); // allowed to fail + let src = js_channel.as_ptr(); + let dst = channel.as_ptr() as *mut f32; - None + unsafe { + std::ptr::copy_nonoverlapping(src, dst, render_quantum_size); + } } - // error thrown in process - Either::B(err) => Some(WorkletAbruptCompletionResult { - cmd: "node-web-audio-api:worklet:process-error".to_string(), - err: err.into(), - }), - }; - - // Handle errors - // @todo - propagate back to rust side to remove processor from graph - if let Some(abrupt_completion) = abrupt_completion { - let WorkletAbruptCompletionResult { cmd, err } = abrupt_completion; - - // Mark as non callable and dispatch `processorerror` event on main thread - let k_worklet_mark_non_callable = - env.symbol_for("node-web-audio-api:worklet-mark-non-callable-process")?; - - let mark_non_callable_function = processor - .get_property::>( - k_worklet_mark_non_callable, - )?; - let js_error = env.create_error(err)?; - let _ = mark_non_callable_function.apply(processor, (cmd, js_error)); - - // Mark tail time to false in audio graph - // https://webaudio.github.io/web-audio-api/#active-source - let _ = tail_time_sender.send(false); } + let _ = tail_time_sender.send(tail_time); // allowed to fail + Ok(()) } From 7109da87d0caef7dd5902f636756f3ce0fb828a0 Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 10:43:37 +0200 Subject: [PATCH 16/18] fmt --- src/audio_worklet_node.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/audio_worklet_node.rs b/src/audio_worklet_node.rs index 87128860..bb51f230 100644 --- a/src/audio_worklet_node.rs +++ b/src/audio_worklet_node.rs @@ -131,7 +131,6 @@ thread_local! { static HAS_THREAD_PRIO: Cell = const { Cell::new(false) }; } - /// Check that given JS and Rust input / output layout are the same, /// i.e. that each input / output have the same number of channels /// From 8af3e1fdaffe38be1c0fc7a7bbaaabc4729b5821 Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 10:44:14 +0200 Subject: [PATCH 17/18] tests: add warning prefix --- tests/OfflineAudioContext.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OfflineAudioContext.spec.js b/tests/OfflineAudioContext.spec.js index 79284ab6..04aac687 100644 --- a/tests/OfflineAudioContext.spec.js +++ b/tests/OfflineAudioContext.spec.js @@ -36,7 +36,7 @@ describe('# OfflineAudioContext', () => { assert.deepEqual(aResult, bResult); }); - it(`should throw clean error when called twice`, async () => { + it(`[TO BE IMPROVED] should throw clean error when called twice`, async () => { const offline = new OfflineAudioContext(1, 48000, 48000); await offline.startRendering(); From 2e5d715950f4b8e30d74d8001e85369d65642a11 Mon Sep 17 00:00:00 2001 From: b-ma Date: Thu, 28 May 2026 11:30:01 +0200 Subject: [PATCH 18/18] fix: remove Error.isError, not available in node 22 --- js/AudioWorkletProcessor.js | 7 ++++--- tests/AudioWorklet.spec.js | 17 +++++++++++++++++ tests/worklets/invalid-process.worklet.js | 10 ++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/js/AudioWorkletProcessor.js b/js/AudioWorkletProcessor.js index c87c8757..8c47dfac 100644 --- a/js/AudioWorkletProcessor.js +++ b/js/AudioWorkletProcessor.js @@ -89,11 +89,12 @@ export class AudioWorkletProcessor { try { return !!this.process(inputs, outputs, parameters); } catch (err) { - // no need to return the error to rust and have another roundtrip // we can just mark the process as non callable here and return false + // (no need to return the error to rust and have another rust / js roundtrip) let error; - // make sure Rust receives a "real" error instance, i.e. support `throw "my message";` - if (!Error.isError(err)) { + // make sure we propagate an error instance, i.e. support `throw "my message";` + // @note that `Error.isError` is not available in Node v22 + if (!(err instanceof Error)) { error = new Error(err); } else { error = err; diff --git a/tests/AudioWorklet.spec.js b/tests/AudioWorklet.spec.js index 871d3934..328ff0c2 100644 --- a/tests/AudioWorklet.spec.js +++ b/tests/AudioWorklet.spec.js @@ -298,6 +298,23 @@ describe('AudioWorkletProcessor', () => { assert.isTrue(errored); }); + it('should throw a clean error when process throws raw message', async () => { + let errored = false; + + const audioContext = new AudioContext(); + await audioContext.audioWorklet.addModule('./worklets/invalid-process.worklet.js'); + + const invalid = new AudioWorkletNode(audioContext, 'process-throws-raw'); + invalid.addEventListener('processorerror', (e) => { + prettyPrintErr(e.error); + errored = true; + }); + + await delay(100); + await audioContext.close(); + assert.isTrue(errored); + }); + it('OfflineAudioContext.startRendering should return when processor constructor is invalid', async () => { const audioContext = new OfflineAudioContext(1, 128, 48000); await audioContext.audioWorklet.addModule('./worklets/invalid-ctor.worklet.js'); diff --git a/tests/worklets/invalid-process.worklet.js b/tests/worklets/invalid-process.worklet.js index 9df99768..873e593c 100644 --- a/tests/worklets/invalid-process.worklet.js +++ b/tests/worklets/invalid-process.worklet.js @@ -13,3 +13,13 @@ class ProcessThrows extends AudioWorkletProcessor { } registerProcessor('process-throws', ProcessThrows); + + +class ProcessThrowsRaw extends AudioWorkletProcessor { + // attribute exists but is not callbable + process = (inputs, outputs, params) => { + throw 'row string thrown'; + } +} + +registerProcessor('process-throws-raw', ProcessThrowsRaw);