fix(audio): implement native AudioBuffer channel storage and copying

This commit is contained in:
ldm0
2026-09-09 20:38:30 +08:00
parent c4d85e0065
commit b9d04ea93f
9 changed files with 731 additions and 98 deletions
@@ -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),
@@ -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);
@@ -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 =
@@ -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::<AudioBufferGetChannelDataArgs>(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<v8::Local<'s, v8::Object>> {
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::<f32>());
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<f32> {
@@ -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::<ConstructorArgs>(scope, &args) else {
return;
};
let options = match webidl::parse_dictionary_object::<AudioBufferOptions>(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::<CreateBufferArgs>(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<v8::Local<'s, v8::Object>> {
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<AudioBufferData<'s>> {
// 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::<f32>())
.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<v8::Local<'s, v8::Array>> {
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<v8::Local<'s, v8::Float32Array>> {
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::<v8::Float32Array>::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::<GetChannelDataArgs>(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::<v8::Float32Array>::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::<CopyChannelArgs>(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::<u8>()
.add(channel.byte_offset() + offset * size_of::<f32>());
let array_ptr = array_data.as_ptr().cast::<u8>().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::<f32>());
}
}
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::<u8>(),
data.as_ptr().cast::<u8>().add(view.byte_offset()),
length * size_of::<f32>(),
);
}
Some(())
}
@@ -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}
])
);
}
@@ -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]
@@ -1,5 +1,6 @@
use super::*;
mod audio_buffer;
mod broadcast_channel;
mod chrome;
mod console;
+209
View File
@@ -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;
})()