diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index e82e3f33f3..b11427aa3f 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -52,8 +52,8 @@ use super::super::{ url_form::build_url_constructor_template, url_search_params_runtime::build_url_search_params_constructor_template, web_audio_runtime::{ - build_audio_context_constructor_template, build_audio_worklet_node_constructor_template, - offline_audio_context_constructor_callback, + build_audio_buffer_constructor_template, build_audio_context_constructor_template, + build_audio_worklet_node_constructor_template, offline_audio_context_constructor_callback, }, webrtc::{rtc_ice_candidate_constructor_callback, rtc_peer_connection_constructor_callback}, websocket::{ @@ -617,12 +617,12 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>( | ConstructorKind::DynamicsCompressorNode | ConstructorKind::AnalyserNode | ConstructorKind::BiquadFilterNode - | ConstructorKind::AudioParam - | ConstructorKind::AudioBuffer => { + | ConstructorKind::AudioParam => { v8::FunctionTemplate::builder(illegal_constructor_callback) .length(0) .build(scope) } + ConstructorKind::AudioBuffer => build_audio_buffer_constructor_template(scope), ConstructorKind::Text => v8::FunctionTemplate::builder(text_constructor_callback) .length(0) .build(scope), diff --git a/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs b/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs index 1d812561ab..43f6463bc7 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/prototype_bindings.rs @@ -52,9 +52,7 @@ use super::super::{ svg_runtime::install_svg_template_bindings, touch_runtime::install_touch_template_bindings, view_transition_runtime::install_view_transition_template_bindings, - web_audio_runtime::{ - audio_buffer_get_channel_data_callback, install_web_audio_template_bindings, - }, + web_audio_runtime::install_web_audio_template_bindings, webrtc::install_webrtc_template_bindings, websocket::{install_websocket_bindings, install_websocket_stream_bindings}, window_runtime::storage_bucket_caches_getter_callback, @@ -181,17 +179,6 @@ struct HtmlCanvasElementTemplateMethodsDeclaration { transfer_control_to_offscreen: (), } -#[derive(WebApiFunctionTemplate)] -#[webapi(name = "AudioBuffer", enumerable)] -struct AudioBufferTemplateMethodsDeclaration { - #[webapi( - method = "getChannelData", - length = 1, - callback = audio_buffer_get_channel_data_callback - )] - get_channel_data: (), -} - #[derive(WebApiFunctionTemplate)] #[webapi(name = "Worker", enumerable)] struct WorkerTemplateMethodsDeclaration { @@ -563,10 +550,6 @@ pub(super) fn install_constructor_template_bindings<'s>( "Navigation" => { install_navigation_bindings(scope, template); } - "AudioBuffer" => { - let proto = template.prototype_template(scope); - AudioBufferTemplateMethodsDeclaration::initialize_prototype_template(scope, proto); - } "Worker" => { let proto = template.prototype_template(scope); WorkerTemplateMethodsDeclaration::initialize_prototype_template(scope, proto); diff --git a/moli-renderer-v8/src/context_bootstrap/shared/slots.rs b/moli-renderer-v8/src/context_bootstrap/shared/slots.rs index 92186b1b37..6befbaa6cc 100644 --- a/moli-renderer-v8/src/context_bootstrap/shared/slots.rs +++ b/moli-renderer-v8/src/context_bootstrap/shared/slots.rs @@ -40,7 +40,6 @@ pub(crate) const SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT: &str = "__moliSimpleEventTargetOrderedHandlers"; pub(in crate::context_bootstrap) const OFFLINE_AUDIO_LISTENERS_SLOT: &str = "__moliOfflineAudioListeners"; -pub(in crate::context_bootstrap) const OFFLINE_AUDIO_BUFFER_SLOT: &str = "__moliOfflineAudioBuffer"; pub(in crate::context_bootstrap) const SCREEN_EVENT_LISTENERS_SLOT: &str = "__moliScreenEventListeners"; pub(in crate::context_bootstrap) const SCREEN_ORIENTATION_EVENT_LISTENERS_SLOT: &str = diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs index e70a5cbc0f..991f6a4d7f 100644 --- a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs @@ -12,10 +12,12 @@ use crate::util::{ use crate::webidl; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; +mod audio_buffer; mod audio_param; mod biquad; mod graph; +pub(in crate::context_bootstrap) use audio_buffer::build_constructor_template as build_audio_buffer_constructor_template; use audio_param::{audio_param, detune_param}; const AUDIO_CONTEXT_LISTENERS_SLOT: &str = "__moliAudioContextListeners"; @@ -265,19 +267,6 @@ struct OfflineAudioCompletionEventDeclaration<'scope> { #[webapi(interface = "AudioDestinationNode", allow_empty)] struct AudioDestinationNodeObjectDeclaration {} -#[derive(WebApiObject)] -#[webapi(interface = "AudioBuffer")] -struct AudioBufferObjectDeclaration<'scope> { - #[webapi(data_property)] - length: f64, - #[webapi(data_property = "sampleRate")] - sample_rate: f64, - #[webapi(data_property)] - duration: f64, - #[webapi(slot = OFFLINE_AUDIO_BUFFER_SLOT)] - channel_data: v8::Local<'scope, v8::Object>, -} - // Captured from the Chromium-on-Linux baseline we use for Zhihu probe parity. // If that browser profile changes, update this together with the audio probe // assertions in `script_vm/tests.rs`. @@ -361,13 +350,6 @@ struct OfflineAudioContextConstructorArgs { sample_rate: f64, } -#[derive(webidl::WebIdlArgs)] -#[webidl(prefix = "AudioBuffer.getChannelData")] -struct AudioBufferGetChannelDataArgs { - #[webidl(required)] - channel: f64, -} - #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "AudioParam.setValueAtTime")] struct AudioParamSetValueAtTimeArgs { @@ -413,6 +395,9 @@ struct AudioWorkletNodeTemplateDeclaration { #[derive(WebApiFunctionTemplate)] #[webapi(name = "BaseAudioContext", enumerable)] struct BaseAudioContextPrototypeDeclaration { + #[webapi(method = "createBuffer", length = 3, callback = audio_buffer::create_buffer)] + create_buffer: (), + #[webapi(method = "createBiquadFilter", length = 0, callback = biquad::create_biquad_filter)] create_biquad_filter: (), @@ -1282,30 +1267,6 @@ pub(in crate::context_bootstrap) fn offline_audio_context_constructor_callback<' rv.set(context.into()); } -pub(in crate::context_bootstrap) fn audio_buffer_get_channel_data_callback<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'s, v8::Value>, -) { - let buffer = args.this(); - let Some(parsed) = webidl::parse_args::(scope, &args) else { - return; - }; - let requested_channel = parsed.channel.trunc(); - if !requested_channel.is_finite() || requested_channel != 0.0 { - throw_range_error( - scope, - "Failed to execute 'getChannelData' on 'AudioBuffer': channel index is out of range.", - ); - return; - } - let Some(data) = web_audio_object_slot(scope, buffer, OFFLINE_AUDIO_BUFFER_SLOT) else { - rv.set_undefined(); - return; - }; - rv.set(data.into()); -} - fn require_base_audio_context<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, @@ -1387,7 +1348,13 @@ fn offline_audio_context_start_rendering_callback<'s>( let sample_rate = web_audio_number_slot(scope, context, OFFLINE_AUDIO_SAMPLE_RATE_SLOT).unwrap_or(44_100.0); let has_input = graph::prepare_offline_render(scope, context, length as f64 / sample_rate); - let rendered_buffer = build_audio_buffer(scope, length, sample_rate, has_input); + let channel_count = web_audio_number_slot(scope, context, OFFLINE_AUDIO_CHANNEL_COUNT_SLOT) + .unwrap_or(1.0) as u32; + let Some(rendered_buffer) = + build_audio_buffer(scope, channel_count, length, sample_rate, has_input) + else { + return; + }; define_non_enumerable_string_property(scope, context, "state", "closed"); let payload = OfflineAudioCompletePayloadDeclaration::new(context, rendered_buffer) @@ -1750,40 +1717,30 @@ fn audio_destination_node<'s>( fn build_audio_buffer<'s>( scope: &mut v8::PinScope<'s, '_>, + channel_count: u32, length: usize, sample_rate: f64, has_input: bool, -) -> v8::Local<'s, v8::Object> { - let channel_data = build_channel_data_view(scope, length, has_input); - AudioBufferObjectDeclaration::new( - length as f64, - sample_rate, - (length as f64) / sample_rate.max(1.0), - channel_data, - ) - .bind(scope) - .expect("AudioBuffer declaration should bind") -} - -fn build_channel_data_view<'s>( - scope: &mut v8::PinScope<'s, '_>, - length: usize, - has_input: bool, -) -> v8::Local<'s, v8::Object> { - let samples = if has_input { - synthetic_audio_samples(length) - } else { - vec![0.0; length] +) -> Option> { + let Ok(length) = u32::try_from(length) else { + throw_dom_exception( + scope, + "NotSupportedError", + 9, + "AudioBuffer length is too large.", + ); + return None; }; - let mut bytes = Vec::with_capacity(samples.len() * std::mem::size_of::()); - for sample in samples { - bytes.extend_from_slice(&sample.to_le_bytes()); + let buffer = audio_buffer::new_buffer(scope, channel_count, length, sample_rate)?; + if has_input { + // The existing input renderer remains synthetic. Buffer ownership and + // channel copies are independent of that backend and use actual PCM. + let samples = synthetic_audio_samples(length as usize); + for channel in 0..channel_count { + audio_buffer::write_channel(scope, buffer, channel, &samples)?; + } } - let backing_store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); - let buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_store); - let view = v8::Float32Array::new(scope, buffer, 0, length) - .expect("Float32Array construction should succeed"); - view.into() + Some(buffer) } fn synthetic_audio_samples(length: usize) -> Vec { diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/audio_buffer.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/audio_buffer.rs new file mode 100644 index 0000000000..ff7271a9f2 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/audio_buffer.rs @@ -0,0 +1,405 @@ +//! Owned PCM channels shared by AudioBuffer's constructor and context factories. +//! +//! The channel views are private, GC-traced values. Metadata remains valid when +//! a caller transfers a channel's ArrayBuffer; all copies use intrinsic view +//! bounds after argument conversion, never author-overridable JS properties. + +use super::*; +use crate::util::callback_data_index_value; + +const CHANNELS: &str = "__moliAudioBufferChannels"; +const LENGTH: &str = "__moliAudioBufferLength"; +const SAMPLE_RATE: &str = "__moliAudioBufferSampleRate"; + +#[derive(WebApiObject)] +#[webapi(interface = "AudioBuffer")] +struct AudioBufferData<'s> { + #[webapi(slot = CHANNELS)] + channels: v8::Local<'s, v8::Array>, + #[webapi(slot = LENGTH)] + length: u32, + #[webapi(slot = SAMPLE_RATE)] + sample_rate: f64, +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "AudioBufferOptions")] +struct AudioBufferOptions { + // WebIDL dictionary members are converted in lexicographic order. + #[webidl(required)] + length: u32, + #[webidl(default = 1)] + number_of_channels: u32, + #[webidl(required, converter = "double")] + sample_rate: f64, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "AudioBuffer")] +struct ConstructorArgs<'s> { + #[webidl(required)] + options: v8::Local<'s, v8::Object>, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "BaseAudioContext.createBuffer")] +struct CreateBufferArgs { + #[webidl(required)] + number_of_channels: u32, + #[webidl(required)] + length: u32, + #[webidl(required, converter = "double")] + sample_rate: f64, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "AudioBuffer.getChannelData")] +struct GetChannelDataArgs { + #[webidl(required)] + channel: u32, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "AudioBuffer channel copy")] +struct CopyChannelArgs { + #[webidl(index = 1, required)] + channel: u32, + #[webidl(index = 2, default = 0)] + offset: u32, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(name = "AudioBuffer", constructor_callback = constructor, constructor_length = 1, enumerable)] +struct AudioBufferTemplate { + #[webapi(accessor_property, getter = metadata, data = callback_data_index_value(scope, 0))] + length: (), + #[webapi(accessor_property, getter = metadata, data = callback_data_index_value(scope, 1))] + duration: (), + #[webapi(accessor_property, getter = metadata, data = callback_data_index_value(scope, 2))] + sample_rate: (), + #[webapi(accessor_property, getter = metadata, data = callback_data_index_value(scope, 3))] + number_of_channels: (), + #[webapi(method, length = 1, callback = get_channel_data)] + get_channel_data: (), + #[webapi(method, length = 2, callback = copy_from_channel)] + copy_from_channel: (), + #[webapi(method, length = 2, callback = copy_to_channel)] + copy_to_channel: (), +} + +pub(in crate::context_bootstrap) fn build_constructor_template<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, +) -> v8::Local<'s, v8::FunctionTemplate> { + AudioBufferTemplate::build(scope) +} + +fn constructor<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if !args.is_construct_call() { + throw_type_error( + scope, + "Failed to construct 'AudioBuffer': Please use the 'new' operator.", + ); + return; + } + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let options = match webidl::parse_dictionary_object::(scope, parsed.options) + { + Ok(options) => options, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + let Some(data) = allocate( + scope, + options.number_of_channels, + options.length, + options.sample_rate, + ) else { + return; + }; + // Initialize the object allocated for newTarget, preserving subclasses. + data.initialize(scope, args.this()) + .expect("AudioBuffer should initialize"); + rv.set(args.this().into()); +} + +pub(super) fn create_buffer<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if !require_base_audio_context(scope, args.this()) { + return; + } + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + if let Some(buffer) = new_buffer( + scope, + parsed.number_of_channels, + parsed.length, + parsed.sample_rate, + ) { + rv.set(buffer.into()); + } +} + +pub(super) fn new_buffer<'s>( + scope: &mut v8::PinScope<'s, '_>, + number_of_channels: u32, + length: u32, + sample_rate: f64, +) -> Option> { + let data = allocate(scope, number_of_channels, length, sample_rate)?; + Some(data.bind(scope).expect("AudioBuffer should bind")) +} + +fn allocate<'s>( + scope: &mut v8::PinScope<'s, '_>, + number_of_channels: u32, + length: u32, + sample_rate: f64, +) -> Option> { + // AudioBufferOptions uses restricted float, not double. Round before the + // algorithm's range checks, including values near the supported endpoints. + let sample_rate = sample_rate as f32 as f64; + if !sample_rate.is_finite() { + throw_type_error(scope, "AudioBuffer sampleRate must be a finite float."); + return None; + } + if !(1..=32).contains(&number_of_channels) + || !(3000.0..=768_000.0).contains(&sample_rate) + || length == 0 + { + throw_dom_exception( + scope, + "NotSupportedError", + 9, + "AudioBuffer requires 1 to 32 channels, a positive length, and a sample rate between 3000 and 768000.", + ); + return None; + } + let Some(byte_length) = (length as usize) + .checked_mul(size_of::()) + .filter(|_| length as usize <= v8::Float32Array::MAX_LENGTH) + else { + allocation_failed(scope); + return None; + }; + let mut channels = Vec::with_capacity(number_of_channels as usize); + for _ in 0..number_of_channels { + let mut bytes = Vec::new(); + if bytes.try_reserve_exact(byte_length).is_err() { + allocation_failed(scope); + return None; + } + bytes.resize(byte_length, 0); + let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared(); + let buffer = v8::ArrayBuffer::with_backing_store(scope, &store); + let Some(view) = v8::Float32Array::new(scope, buffer, 0, length as usize) else { + allocation_failed(scope); + return None; + }; + channels.push(view.into()); + } + // Define own dense elements directly. Array.prototype setters must not see + // this internal collection, nor can callers obtain or mutate it. + let channels = v8::Array::new_with_elements(scope, &channels); + Some(AudioBufferData::new(channels, length, sample_rate)) +} + +fn allocation_failed(scope: &mut v8::PinScope<'_, '_>) { + throw_dom_exception( + scope, + "NotSupportedError", + 9, + "Unable to allocate AudioBuffer channel data.", + ); +} + +fn require_channels<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> Option> { + let channels = web_audio_array_slot(scope, receiver, CHANNELS); + if channels.is_none() { + throw_type_error(scope, "Illegal invocation: expected an AudioBuffer."); + } + channels +} + +fn channel_view<'s>( + scope: &mut v8::PinScope<'s, '_>, + channels: v8::Local<'s, v8::Array>, + channel: u32, +) -> Option> { + if channel >= channels.length() { + throw_dom_exception( + scope, + "IndexSizeError", + 1, + "AudioBuffer channel index is out of range.", + ); + return None; + } + channels + .get_index(scope, channel) + .and_then(|value| v8::Local::::try_from(value).ok()) +} + +fn metadata<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(channels) = require_channels(scope, args.this()) else { + return; + }; + let length = web_audio_number_slot(scope, args.this(), LENGTH).expect("AudioBuffer length"); + let sample_rate = + web_audio_number_slot(scope, args.this(), SAMPLE_RATE).expect("AudioBuffer sample rate"); + let value = match args.data().int32_value(scope) { + Some(0) => length, + Some(1) => length / sample_rate, + Some(2) => sample_rate, + Some(3) => channels.length() as f64, + _ => unreachable!("AudioBuffer metadata field"), + }; + rv.set(v8::Number::new(scope, value).into()); +} + +fn get_channel_data<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(channels) = require_channels(scope, args.this()) else { + return; + }; + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + if let Some(view) = channel_view(scope, channels, parsed.channel) { + rv.set(view.into()); + } +} + +fn copy_from_channel<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + copy_channel(scope, &args, true); +} + +fn copy_to_channel<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + copy_channel(scope, &args, false); +} + +fn copy_channel<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + from_channel: bool, +) { + let Some(channels) = require_channels(scope, args.this()) else { + return; + }; + if args.length() < 2 { + throw_type_error(scope, "AudioBuffer channel copy requires two arguments."); + return; + } + let Ok(array) = v8::Local::::try_from(args.get(0)) else { + throw_type_error(scope, "AudioBuffer channel copy requires a Float32Array."); + return; + }; + if let Some(store) = array.get_backing_store() + && (store.is_shared() || store.is_resizable_by_user_javascript()) + { + throw_type_error( + scope, + "AudioBuffer channel copy requires a non-shared, fixed-length buffer.", + ); + return; + } + // Numeric coercion can transfer either view's backing buffer. Acquire the + // intrinsic lengths and stores only after every argument has been converted. + let Some(parsed) = webidl::parse_args::(scope, args) else { + return; + }; + if array.length() == 0 { + return; + } + let Some(channel) = channel_view(scope, channels, parsed.channel) else { + return; + }; + let offset = parsed.offset as usize; + let length = array.length().min(channel.length().saturating_sub(offset)); + if length == 0 { + return; + } + let Some(channel_store) = channel.get_backing_store() else { + return; + }; + let Some(array_store) = array.get_backing_store() else { + return; + }; + let Some(channel_data) = channel_store.data() else { + return; + }; + let Some(array_data) = array_store.data() else { + return; + }; + // SAFETY: both stores are live and non-shared, and intrinsic view lengths + // bound these ranges. No JS runs between the bounds check and copy. `copy` + // (memmove) also handles views overlapping the same channel's backing store. + unsafe { + let channel_ptr = channel_data + .as_ptr() + .cast::() + .add(channel.byte_offset() + offset * size_of::()); + let array_ptr = array_data.as_ptr().cast::().add(array.byte_offset()); + let (src, dst) = if from_channel { + (channel_ptr, array_ptr) + } else { + (array_ptr, channel_ptr) + }; + std::ptr::copy(src, dst, length * size_of::()); + } +} + +pub(super) fn write_channel<'s>( + scope: &mut v8::PinScope<'s, '_>, + buffer: v8::Local<'s, v8::Object>, + channel: u32, + samples: &[f32], +) -> Option<()> { + let channels = require_channels(scope, buffer)?; + let view = channel_view(scope, channels, channel)?; + let length = samples.len().min(view.length()); + if length == 0 { + return Some(()); + } + let store = view.get_backing_store()?; + let data = store.data()?; + // SAFETY: newly allocated channels are fixed, non-shared buffers. The source + // is independent Rust-owned PCM data and length is bounded by both ranges. + unsafe { + std::ptr::copy_nonoverlapping( + samples.as_ptr().cast::(), + data.as_ptr().cast::().add(view.byte_offset()), + length * size_of::(), + ); + } + Some(()) +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/audio_buffer.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/audio_buffer.rs new file mode 100644 index 0000000000..623c4b3c45 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/audio_buffer.rs @@ -0,0 +1,79 @@ +use super::*; + +#[test] +fn audio_buffer_public_api_matches_chromium_fixture() { + let mut vm = new_storage_test_vm("https://audio-buffer.test/"); + let source = include_str!("../../../../tests/fixtures/audio-buffer.js"); + let result = vm + .eval(&format!("JSON.stringify({source})")) + .expect("AudioBuffer fixture"); + let result: serde_json::Value = serde_json::from_str(&result).expect("fixture JSON"); + assert_eq!( + result, + serde_json::json!({ + "construction": "ok", + "metadata": "ok", + "channels": "ok", + "copy": "ok", + "numeric_conversion": "ok", + "bitwise_storage": "ok", + "receivers_and_argument_order": "ok", + "detached_views": "ok", + "context_factory": "ok", + }) + ); +} + +#[test] +fn audio_buffer_offline_result_shares_channel_views_and_copy_storage() { + let mut vm = new_storage_test_vm("https://audio-buffer-render.test/"); + vm.exec(r#" +globalThis.bufferResults = []; +for (const connected of [false, true]) { + const context = new OfflineAudioContext(2, 32, 8000); + if (connected) { + const oscillator = context.createOscillator(); + oscillator.connect(context.destination); + oscillator.start(); + } + context.startRendering().then(buffer => { + const channels = [buffer.getChannelData(0), buffer.getChannelData(1)]; + const initialCopiesMatch = channels.every((channel, index) => { + const copy = new Float32Array(32); + buffer.copyFromChannel(copy, index); + return copy.every((value, i) => value === channel[i]); + }); + const initiallySilent = channels.every(channel => channel.every(x => x === 0)); + const oldSecondValue = channels[1][1]; + buffer.copyToChannel(new Float32Array([10, 20]), 0, 1); + const copy = new Float32Array(3).fill(99); + buffer.copyFromChannel(copy, 0, 30); + bufferResults.push({ + connected, + metadata: [buffer.numberOfChannels, buffer.length, buffer.sampleRate, buffer.duration], + prototype: Object.getPrototypeOf(buffer) === AudioBuffer.prototype, + initialCopiesMatch, + initiallySilent, + sharedView: buffer.getChannelData(0) === channels[0] && channels[0][1] === 10 && channels[0][2] === 20, + independentChannels: channels[1][1] === oldSecondValue, + untouchedSuffix: copy[2] === 99 + }); + }); +} +"#, None).expect("render AudioBuffer outputs"); + let result = vm + .eval("JSON.stringify(bufferResults)") + .expect("completed render results"); + let result: serde_json::Value = serde_json::from_str(&result).expect("render JSON"); + assert_eq!( + result, + serde_json::json!([ + {"connected": false, "metadata": [2,32,8000,0.004], "prototype": true, + "initialCopiesMatch": true, "initiallySilent": true, "sharedView": true, + "independentChannels": true, "untouchedSuffix": true}, + {"connected": true, "metadata": [2,32,8000,0.004], "prototype": true, + "initialCopiesMatch": true, "initiallySilent": false, "sharedView": true, + "independentChannels": true, "untouchedSuffix": true} + ]) + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs index a638e16c4b..1378297529 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs @@ -2875,7 +2875,7 @@ fn web_audio_numeric_entrypoints_parse_webidl_arguments() { assert_eq!( result, - r#"{"ctorMissing":"TypeError","ctorSymbol":"TypeError","length":16,"sampleRate":44100,"setMissingTime":"TypeError","setSymbol":"TypeError","frequency":123.5,"channelMissing":"TypeError","channelSymbol":"TypeError","channelRange":"RangeError","channel0":"[object Float32Array]"}"# + r#"{"ctorMissing":"TypeError","ctorSymbol":"TypeError","length":16,"sampleRate":44100,"setMissingTime":"TypeError","setSymbol":"TypeError","frequency":123.5,"channelMissing":"TypeError","channelSymbol":"TypeError","channelRange":"IndexSizeError","channel0":"[object Float32Array]"}"# ); } #[test] diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 4b7999855d..6152383e73 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -1,5 +1,6 @@ use super::*; +mod audio_buffer; mod broadcast_channel; mod chrome; mod console; diff --git a/moli-renderer-v8/tests/fixtures/audio-buffer.js b/moli-renderer-v8/tests/fixtures/audio-buffer.js new file mode 100644 index 0000000000..b3ff86dfb8 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/audio-buffer.js @@ -0,0 +1,209 @@ +(() => { + const results = {}; + const check = (condition, message) => { if (!condition) throw new Error(message); }; + const equal = (actual, expected, message) => check(JSON.stringify(actual) === JSON.stringify(expected), message); + const throws = (name, fn) => { + try { fn(); } catch (error) { check(error.name === name, `expected ${name}, got ${error.name}`); return; } + throw new Error(`expected ${name}`); + }; + const run = (name, fn) => { + try { fn(); results[name] = 'ok'; } + catch (error) { results[name] = {name: error.name, message: error.message}; } + }; + + run('construction', () => { + const buffer = new AudioBuffer({length: 8, sampleRate: 8000}); + check(buffer instanceof AudioBuffer, 'brand'); + check(Object.prototype.toString.call(buffer) === '[object AudioBuffer]', 'tag'); + equal([buffer.length, buffer.sampleRate, buffer.duration, buffer.numberOfChannels], [8, 8000, .001, 1], 'metadata'); + check(Object.getOwnPropertyNames(buffer).length === 0, 'no public backing fields'); + check(AudioBuffer.length === 1, 'constructor arity'); + class Derived extends AudioBuffer {} + check(Object.getPrototypeOf(new Derived({length: 1, sampleRate: 44100})) === Derived.prototype, 'newTarget prototype'); + throws('TypeError', () => AudioBuffer({length: 8, sampleRate: 8000})); + for (const options of [undefined, null, 1, {}, {length: 8}, {sampleRate: 8000}]) { + throws('TypeError', () => new AudioBuffer(options)); + } + }); + + run('metadata', () => { + const buffer = new AudioBuffer({length: 8, sampleRate: 8000, numberOfChannels: 2}); + for (const name of ['length', 'sampleRate', 'duration', 'numberOfChannels']) { + const descriptor = Object.getOwnPropertyDescriptor(AudioBuffer.prototype, name); + check(descriptor.enumerable && descriptor.configurable, `${name} flags`); + check(typeof descriptor.get === 'function' && descriptor.set === undefined, `${name} readonly getter`); + check(descriptor.get.name === `get ${name}` && descriptor.get.length === 0, `${name} function metadata`); + check(Reflect.set(buffer, name, 100) === false, `${name} write`); + for (const receiver of [{}, AudioBuffer.prototype, Object.create(buffer), new Proxy(buffer, {})]) { + throws('TypeError', () => descriptor.get.call(receiver)); + } + } + for (const [name, length] of [['getChannelData', 1], ['copyFromChannel', 2], ['copyToChannel', 2]]) { + const descriptor = Object.getOwnPropertyDescriptor(AudioBuffer.prototype, name); + check(descriptor.enumerable && descriptor.configurable && descriptor.writable, `${name} flags`); + check(descriptor.value.name === name && descriptor.value.length === length, `${name} function metadata`); + check(!Object.hasOwn(buffer, name), `${name} not per-instance`); + } + }); + + run('channels', () => { + const buffer = new AudioBuffer({length: 8, sampleRate: 8000, numberOfChannels: 3}); + const channels = [0, 1, 2].map(i => buffer.getChannelData(i)); + for (let i = 0; i < channels.length; i++) { + check(channels[i] instanceof Float32Array && channels[i].length === 8, 'float32 channel'); + check(channels[i] === buffer.getChannelData(i), 'stable view identity'); + check(channels[i].every(x => x === 0), 'zero initialized'); + } + channels[0][0] = 1; + channels[1][1] = 2; + equal(channels.map(x => [x[0], x[1]]), [[1, 0], [0, 2], [0, 0]], 'independent channels'); + buffer.__moliOfflineAudioBuffer = new Float32Array([99]); + buffer.__moliAudioBufferChannels = [new Float32Array([99])]; + Object.defineProperty(buffer, 'length', {value: 99}); + check(buffer.getChannelData(0) === channels[0] && channels[0].length === 8, 'private storage'); + }); + + run('copy', () => { + const buffer = new AudioBuffer({length: 6, sampleRate: 8000, numberOfChannels: 2}); + const source = new Float32Array([90, 1, 2, 3, 4, 91]); + check(buffer.copyToChannel(source.subarray(1, 5), 1, 2) === undefined, 'copyTo return'); + equal([...buffer.getChannelData(1)], [0, 0, 1, 2, 3, 4], 'source view and offset'); + check(buffer.getChannelData(0).every(x => x === 0), 'other channel unchanged'); + const target = new Float32Array(8).fill(99); + check(buffer.copyFromChannel(target.subarray(1, 7), 1, 2) === undefined, 'copyFrom return'); + equal([...target], [99, 1, 2, 3, 4, 99, 99, 99], 'destination view and untouched suffix'); + buffer.copyToChannel(new Float32Array([5, 6]), 1, 5); + equal([...buffer.getChannelData(1)], [0, 0, 1, 2, 3, 5], 'clamped write'); + buffer.copyFromChannel(target, 1, 6); + buffer.copyToChannel(source, 1, 6); + buffer.copyToChannel(source, 1, -1); + equal([...target], [99, 1, 2, 3, 4, 99, 99, 99], 'offset at/past end is no-op'); + const channel = buffer.getChannelData(0); + channel.set([1, 2, 3, 4, 5, 6]); + buffer.copyToChannel(channel.subarray(0, 4), 0, 1); + equal([...channel], [1, 1, 2, 3, 4, 6], 'overlapping copyTo'); + channel.set([1, 2, 3, 4, 5, 6]); + buffer.copyFromChannel(channel.subarray(1), 0); + equal([...channel], [1, 1, 2, 3, 4, 5], 'overlapping copyFrom'); + }); + + run('numeric_conversion', () => { + const buffer = new AudioBuffer({length: '4.9', numberOfChannels: 2 ** 32 + 2, sampleRate: 8000.123456}); + equal([buffer.length, buffer.numberOfChannels, buffer.sampleRate], [4, 2, Math.fround(8000.123456)], 'WebIDL conversion'); + const zero = buffer.getChannelData(0); + for (const channel of [null, undefined, NaN, Infinity, 2 ** 32, '0.9']) { + check(buffer.getChannelData(channel) === zero, 'unsigned long channel'); + } + for (const channel of [-1, 2]) throws('IndexSizeError', () => buffer.getChannelData(channel)); + for (const channel of [Symbol(), 1n]) throws('TypeError', () => buffer.getChannelData(channel)); + throws('TypeError', () => buffer.getChannelData()); + for (const length of [0, 2 ** 32]) throws('NotSupportedError', () => new AudioBuffer({length, sampleRate: 8000})); + for (const numberOfChannels of [0, 33, -1]) throws('NotSupportedError', () => new AudioBuffer({length: 1, sampleRate: 8000, numberOfChannels})); + for (const sampleRate of [0, 2999, 768001]) throws('NotSupportedError', () => new AudioBuffer({length: 1, sampleRate})); + for (const sampleRate of [NaN, Infinity, -Infinity, 1e40, Symbol(), 1n]) throws('TypeError', () => new AudioBuffer({length: 1, sampleRate})); + check(new AudioBuffer({length: 1, sampleRate: 2999.99999}).sampleRate === 3000, 'float conversion precedes range check'); + check(new AudioBuffer({length: 1, sampleRate: 768000}).sampleRate === 768000, 'sample rate upper bound'); + }); + + run('bitwise_storage', () => { + const buffer = new AudioBuffer({length: 4, sampleRate: 8000}); + const source = new Float32Array(4); + const bits = [0x80000000, 0x7fc00001, 0xff800000, 0x00000001]; + new Uint32Array(source.buffer).set(bits); + buffer.copyToChannel(source, 0); + const channel = buffer.getChannelData(0); + equal([...new Uint32Array(channel.buffer)], bits, 'preserve sample bits'); + const copy = new Float32Array(4); + Object.defineProperty(copy, 'length', {value: 0}); + Object.defineProperty(copy, 'byteOffset', {value: 100000}); + buffer.copyFromChannel(copy, 0); + equal([...new Uint32Array(copy.buffer)], bits, 'intrinsic destination bounds'); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, '0'); + try { + Object.defineProperty(Array.prototype, '0', { + configurable: true, + get() { throw new Error('internal channel read invoked page getter'); }, + set() { throw new Error('internal channel write invoked page setter'); } + }); + const clean = new AudioBuffer({length: 4, sampleRate: 8000}); + check(clean.getChannelData(0)[0] === 0, 'own channel storage'); + } finally { + if (previous) Object.defineProperty(Array.prototype, '0', previous); + else delete Array.prototype[0]; + } + }); + + run('receivers_and_argument_order', () => { + const buffer = new AudioBuffer({length: 4, sampleRate: 8000}); + const array = new Float32Array(4); + let conversions = 0; + const channel = {valueOf() { conversions++; return 0; }}; + for (const name of ['getChannelData', 'copyFromChannel', 'copyToChannel']) { + const args = name === 'getChannelData' ? [channel] : [array, channel]; + for (const receiver of [{}, AudioBuffer.prototype, Object.create(buffer), new Proxy(buffer, {})]) { + throws('TypeError', () => buffer[name].apply(receiver, args)); + } + } + check(conversions === 0, 'brand check before conversion'); + for (const name of ['copyFromChannel', 'copyToChannel']) { + for (const bad of [undefined, null, [], new Float64Array(4), new Proxy(array, {})]) { + throws('TypeError', () => buffer[name](bad, channel)); + } + check(conversions === 0, 'typed array check before channel conversion'); + throws('TypeError', () => buffer[name](array)); + throws('IndexSizeError', () => buffer[name](array, 1)); + check(buffer[name](new Float32Array(0), 99) === undefined, 'empty view returns before range check'); + const rab = new ArrayBuffer(16, {maxByteLength: 32}); + throws('TypeError', () => buffer[name](new Float32Array(rab), 0)); + if (typeof SharedArrayBuffer === 'function') { + throws('TypeError', () => buffer[name](new Float32Array(new SharedArrayBuffer(16)), 0)); + } + } + const order = []; + new AudioBuffer({ + get sampleRate() { order.push('sampleRate'); return 8000; }, + get numberOfChannels() { order.push('numberOfChannels'); return 1; }, + get length() { order.push('length'); return 4; } + }); + equal(order, ['length', 'numberOfChannels', 'sampleRate'], 'dictionary member order'); + const sentinel = new Error('sentinel'); + try { new AudioBuffer({get length() { throw sentinel; }}); throw new Error('missing exception'); } + catch (error) { check(error === sentinel, 'preserve getter exception'); } + try { buffer.copyFromChannel(array, {valueOf() { throw sentinel; }}); throw new Error('missing exception'); } + catch (error) { check(error === sentinel, 'preserve argument exception'); } + }); + + run('detached_views', () => { + const buffer = new AudioBuffer({length: 4, sampleRate: 8000}); + const channel = buffer.getChannelData(0); + channel.set([1, 2, 3, 4]); + structuredClone(channel.buffer, {transfer: [channel.buffer]}); + check(buffer.getChannelData(0) === channel && channel.length === 0, 'retain detached channel view'); + equal([buffer.length, buffer.duration, buffer.numberOfChannels], [4, .0005, 1], 'detaching does not change metadata'); + const target = new Float32Array([9, 9, 9, 9]); + buffer.copyFromChannel(target, 0); + equal([...target], [9, 9, 9, 9], 'detached source is empty'); + buffer.copyToChannel(target, 0); + const second = new AudioBuffer({length: 4, sampleRate: 8000}); + second.copyFromChannel(target, {valueOf() { structuredClone(target.buffer, {transfer: [target.buffer]}); return 0; }}); + check(target.length === 0, 'argument conversion may detach destination'); + }); + + run('context_factory', () => { + const contexts = [new AudioContext(), new OfflineAudioContext(1, 4, 8000)]; + const method = BaseAudioContext.prototype.createBuffer; + check(method.length === 3, 'factory arity'); + for (const context of contexts) { + check(context.createBuffer === method, 'shared factory'); + const buffer = context.createBuffer(2, 16, 16000); + equal([buffer.numberOfChannels, buffer.length, buffer.sampleRate, buffer.duration], [2, 16, 16000, .001], 'factory metadata'); + check(buffer instanceof AudioBuffer && buffer.getChannelData(1).every(x => x === 0), 'factory channel storage'); + throws('TypeError', () => method.call({}, 1, 4, 8000)); + throws('TypeError', () => method.call(context, 1, 4)); + throws('NotSupportedError', () => method.call(context, 0, 4, 8000)); + } + contexts[0].close(); + }); + + return results; +})()