diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /testing/web-platform/tests/webaudio | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/webaudio')
318 files changed, 35651 insertions, 0 deletions
diff --git a/testing/web-platform/tests/webaudio/META.yml b/testing/web-platform/tests/webaudio/META.yml new file mode 100644 index 0000000000..3bcd1cb8d3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/META.yml @@ -0,0 +1,4 @@ +spec: https://webaudio.github.io/web-audio-api/ +suggested_reviewers: + - hoch + - padenot diff --git a/testing/web-platform/tests/webaudio/README.md b/testing/web-platform/tests/webaudio/README.md new file mode 100644 index 0000000000..bcfe291ff3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/README.md @@ -0,0 +1,5 @@ +Our test suite is currently tracking the [editor's draft](https://webaudio.github.io/web-audio-api/) of the Web Audio API. + +The tests are arranged in subdirectories, corresponding to different +sections of the spec. So, for example, tests for the `DelayNode` are +in `the-audio-api/the-delaynode-interface`. diff --git a/testing/web-platform/tests/webaudio/historical.html b/testing/web-platform/tests/webaudio/historical.html new file mode 100644 index 0000000000..1f3146c39d --- /dev/null +++ b/testing/web-platform/tests/webaudio/historical.html @@ -0,0 +1,29 @@ +<!doctype html> +<title>Historical Web Audio API features</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +[ + "webkitAudioContext", + "webkitAudioPannerNode", + "webkitOfflineAudioContext", +].forEach(name => { + test(function() { + assert_false(name in window); + }, name + " interface should not exist"); +}); + +[ + "dopplerFactor", + "speedOfSound", + "setVelocity" +].forEach(name => { + test(function() { + assert_false(name in AudioListener.prototype); + }, name + " member should not exist on the AudioListener."); +}); + +test(function() { + assert_false("setVelocity" in PannerNode.prototype); +}, "setVelocity should not exist on PannerNodes."); +</script> diff --git a/testing/web-platform/tests/webaudio/idlharness.https.window.js b/testing/web-platform/tests/webaudio/idlharness.https.window.js new file mode 100644 index 0000000000..e941a75c26 --- /dev/null +++ b/testing/web-platform/tests/webaudio/idlharness.https.window.js @@ -0,0 +1,72 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: timeout=long + +// https://webaudio.github.io/web-audio-api/ + +'use strict'; + +idl_test( + ['webaudio'], + ['cssom', 'uievents', 'mediacapture-streams', 'html', 'dom'], + async idl_array => { + idl_array.add_untested_idls('interface SVGElement {};'); + + idl_array.add_objects({ + BaseAudioContext: [], + AudioContext: ['context'], + OfflineAudioContext: ['new OfflineAudioContext(1, 1, sample_rate)'], + OfflineAudioCompletionEvent: [ + 'new OfflineAudioCompletionEvent("", {renderedBuffer: buffer})' + ], + AudioBuffer: ['buffer'], + AudioNode: [], + AudioParam: ['new AudioBufferSourceNode(context).playbackRate'], + AudioScheduledSourceNode: [], + AnalyserNode: ['new AnalyserNode(context)'], + AudioBufferSourceNode: ['new AudioBufferSourceNode(context)'], + AudioDestinationNode: ['context.destination'], + AudioListener: ['context.listener'], + AudioProcessingEvent: [`new AudioProcessingEvent('', { + playbackTime: 0, inputBuffer: buffer, outputBuffer: buffer + })`], + BiquadFilterNode: ['new BiquadFilterNode(context)'], + ChannelMergerNode: ['new ChannelMergerNode(context)'], + ChannelSplitterNode: ['new ChannelSplitterNode(context)'], + ConstantSourceNode: ['new ConstantSourceNode(context)'], + ConvolverNode: ['new ConvolverNode(context)'], + DelayNode: ['new DelayNode(context)'], + DynamicsCompressorNode: ['new DynamicsCompressorNode(context)'], + GainNode: ['new GainNode(context)'], + IIRFilterNode: [ + 'new IIRFilterNode(context, {feedforward: [1], feedback: [1]})' + ], + MediaElementAudioSourceNode: [ + 'new MediaElementAudioSourceNode(context, {mediaElement: new Audio})' + ], + MediaStreamAudioDestinationNode: [ + 'new MediaStreamAudioDestinationNode(context)' + ], + MediaStreamAudioSourceNode: [], + MediaStreamTrackAudioSourceNode: [], + OscillatorNode: ['new OscillatorNode(context)'], + PannerNode: ['new PannerNode(context)'], + PeriodicWave: ['new PeriodicWave(context)'], + ScriptProcessorNode: ['context.createScriptProcessor()'], + StereoPannerNode: ['new StereoPannerNode(context)'], + WaveShaperNode: ['new WaveShaperNode(context)'], + AudioWorklet: ['context.audioWorklet'], + AudioWorkletGlobalScope: [], + AudioParamMap: ['worklet_node.parameters'], + AudioWorkletNode: ['worklet_node'], + AudioWorkletProcessor: [], + }); + + self.sample_rate = 44100; + self.context = new AudioContext; + self.buffer = new AudioBuffer({length: 1, sampleRate: sample_rate}); + await context.audioWorklet.addModule( + 'the-audio-api/the-audioworklet-interface/processors/dummy-processor.js'); + self.worklet_node = new AudioWorkletNode(context, 'dummy'); + } +); diff --git a/testing/web-platform/tests/webaudio/js/buffer-loader.js b/testing/web-platform/tests/webaudio/js/buffer-loader.js new file mode 100644 index 0000000000..453dc4a521 --- /dev/null +++ b/testing/web-platform/tests/webaudio/js/buffer-loader.js @@ -0,0 +1,44 @@ +/* Taken from + https://raw.github.com/WebKit/webkit/master/LayoutTests/webaudio/resources/buffer-loader.js */ + +function BufferLoader(context, urlList, callback) { + this.context = context; + this.urlList = urlList; + this.onload = callback; + this.bufferList = new Array(); + this.loadCount = 0; +} + +BufferLoader.prototype.loadBuffer = function(url, index) { + // Load buffer asynchronously + var request = new XMLHttpRequest(); + request.open("GET", url, true); + request.responseType = "arraybuffer"; + + var loader = this; + + request.onload = function() { + loader.context.decodeAudioData(request.response, decodeSuccessCallback, decodeErrorCallback); + }; + + request.onerror = function() { + alert('BufferLoader: XHR error'); + }; + + var decodeSuccessCallback = function(buffer) { + loader.bufferList[index] = buffer; + if (++loader.loadCount == loader.urlList.length) + loader.onload(loader.bufferList); + }; + + var decodeErrorCallback = function() { + alert('decodeErrorCallback: decode error'); + }; + + request.send(); +} + +BufferLoader.prototype.load = function() { + for (var i = 0; i < this.urlList.length; ++i) + this.loadBuffer(this.urlList[i], i); +} diff --git a/testing/web-platform/tests/webaudio/js/helpers.js b/testing/web-platform/tests/webaudio/js/helpers.js new file mode 100644 index 0000000000..413c72051b --- /dev/null +++ b/testing/web-platform/tests/webaudio/js/helpers.js @@ -0,0 +1,250 @@ +/* + Returns an array (typed or not), of the passed array with removed trailing and ending + zero-valued elements + */ +function trimEmptyElements(array) { + var start = 0; + var end = array.length; + + while (start < array.length) { + if (array[start] !== 0) { + break; + } + start++; + } + + while (end > 0) { + end--; + if (array[end] !== 0) { + break; + } + } + return array.subarray(start, end); +} + + +function fuzzyCompare(a, b) { + return Math.abs(a - b) < 9e-3; +} + +function compareChannels(buf1, buf2, + /*optional*/ length, + /*optional*/ sourceOffset, + /*optional*/ destOffset, + /*optional*/ skipLengthCheck) { + if (!skipLengthCheck) { + assert_equals(buf1.length, buf2.length, "Channels must have the same length"); + } + sourceOffset = sourceOffset || 0; + destOffset = destOffset || 0; + if (length == undefined) { + length = buf1.length - sourceOffset; + } + var difference = 0; + var maxDifference = 0; + var firstBadIndex = -1; + for (var i = 0; i < length; ++i) { + if (!fuzzyCompare(buf1[i + sourceOffset], buf2[i + destOffset])) { + difference++; + maxDifference = Math.max(maxDifference, Math.abs(buf1[i + sourceOffset] - buf2[i + destOffset])); + if (firstBadIndex == -1) { + firstBadIndex = i; + } + } + }; + + assert_equals(difference, 0, "maxDifference: " + maxDifference + + ", first bad index: " + firstBadIndex + " with test-data offset " + + sourceOffset + " and expected-data offset " + destOffset + + "; corresponding values " + buf1[firstBadIndex + sourceOffset] + " and " + + buf2[firstBadIndex + destOffset] + " --- differences"); +} + +function compareBuffers(got, expected) { + if (got.numberOfChannels != expected.numberOfChannels) { + assert_equals(got.numberOfChannels, expected.numberOfChannels, + "Correct number of buffer channels"); + return; + } + if (got.length != expected.length) { + assert_equals(got.length, expected.length, + "Correct buffer length"); + return; + } + if (got.sampleRate != expected.sampleRate) { + assert_equals(got.sampleRate, expected.sampleRate, + "Correct sample rate"); + return; + } + + for (var i = 0; i < got.numberOfChannels; ++i) { + compareChannels(got.getChannelData(i), expected.getChannelData(i), + got.length, 0, 0, true); + } +} + +/** + * This function assumes that the test is a "single page test" [0], and defines a + * single gTest variable with the following properties and methods: + * + * + numberOfChannels: optional property which specifies the number of channels + * in the output. The default value is 2. + * + createGraph: mandatory method which takes a context object and does + * everything needed in order to set up the Web Audio graph. + * This function returns the node to be inspected. + * + createGraphAsync: async version of createGraph. This function takes + * a callback which should be called with an argument + * set to the node to be inspected when the callee is + * ready to proceed with the test. Either this function + * or createGraph must be provided. + * + createExpectedBuffers: optional method which takes a context object and + * returns either one expected buffer or an array of + * them, designating what is expected to be observed + * in the output. If omitted, the output is expected + * to be silence. All buffers must have the same + * length, which must be a bufferSize supported by + * ScriptProcessorNode. This function is guaranteed + * to be called before createGraph. + * + length: property equal to the total number of frames which we are waiting + * to see in the output, mandatory if createExpectedBuffers is not + * provided, in which case it must be a bufferSize supported by + * ScriptProcessorNode (256, 512, 1024, 2048, 4096, 8192, or 16384). + * If createExpectedBuffers is provided then this must be equal to + * the number of expected buffers * the expected buffer length. + * + * + skipOfflineContextTests: optional. when true, skips running tests on an offline + * context by circumventing testOnOfflineContext. + * + * [0]: https://web-platform-tests.org/writing-tests/testharness-api.html#single-page-tests + */ +function runTest(name) +{ + function runTestFunction () { + if (!gTest.numberOfChannels) { + gTest.numberOfChannels = 2; // default + } + + var testLength; + + function runTestOnContext(context, callback, testOutput) { + if (!gTest.createExpectedBuffers) { + // Assume that the output is silence + var expectedBuffers = getEmptyBuffer(context, gTest.length); + } else { + var expectedBuffers = gTest.createExpectedBuffers(context); + } + if (!(expectedBuffers instanceof Array)) { + expectedBuffers = [expectedBuffers]; + } + var expectedFrames = 0; + for (var i = 0; i < expectedBuffers.length; ++i) { + assert_equals(expectedBuffers[i].numberOfChannels, gTest.numberOfChannels, + "Correct number of channels for expected buffer " + i); + expectedFrames += expectedBuffers[i].length; + } + if (gTest.length && gTest.createExpectedBuffers) { + assert_equals(expectedFrames, + gTest.length, "Correct number of expected frames"); + } + + if (gTest.createGraphAsync) { + gTest.createGraphAsync(context, function(nodeToInspect) { + testOutput(nodeToInspect, expectedBuffers, callback); + }); + } else { + testOutput(gTest.createGraph(context), expectedBuffers, callback); + } + } + + function testOnNormalContext(callback) { + function testOutput(nodeToInspect, expectedBuffers, callback) { + testLength = 0; + var sp = context.createScriptProcessor(expectedBuffers[0].length, gTest.numberOfChannels, 1); + nodeToInspect.connect(sp).connect(context.destination); + sp.onaudioprocess = function(e) { + var expectedBuffer = expectedBuffers.shift(); + testLength += expectedBuffer.length; + compareBuffers(e.inputBuffer, expectedBuffer); + if (expectedBuffers.length == 0) { + sp.onaudioprocess = null; + callback(); + } + }; + } + var context = new AudioContext(); + runTestOnContext(context, callback, testOutput); + } + + function testOnOfflineContext(callback, sampleRate) { + function testOutput(nodeToInspect, expectedBuffers, callback) { + nodeToInspect.connect(context.destination); + context.oncomplete = function(e) { + var samplesSeen = 0; + while (expectedBuffers.length) { + var expectedBuffer = expectedBuffers.shift(); + assert_equals(e.renderedBuffer.numberOfChannels, expectedBuffer.numberOfChannels, + "Correct number of input buffer channels"); + for (var i = 0; i < e.renderedBuffer.numberOfChannels; ++i) { + compareChannels(e.renderedBuffer.getChannelData(i), + expectedBuffer.getChannelData(i), + expectedBuffer.length, + samplesSeen, + undefined, + true); + } + samplesSeen += expectedBuffer.length; + } + callback(); + }; + context.startRendering(); + } + + var context = new OfflineAudioContext(gTest.numberOfChannels, testLength, sampleRate); + runTestOnContext(context, callback, testOutput); + } + + testOnNormalContext(function() { + if (!gTest.skipOfflineContextTests) { + testOnOfflineContext(function() { + testOnOfflineContext(done, 44100); + }, 48000); + } else { + done(); + } + }); + }; + + runTestFunction(); +} + +// Simpler than audit.js, but still logs the message. Requires +// `setup("explicit_done": true)` if testing code that runs after the "load" +// event. +function equals(a, b, msg) { + test(function() { + assert_equals(a, b); + }, msg); +} +function is_true(a, msg) { + test(function() { + assert_true(a); + }, msg); +} + +// This allows writing AudioWorkletProcessor code in the same file as the rest +// of the test, for quick one off AudioWorkletProcessor testing. +function URLFromScriptsElements(ids) +{ + var scriptTexts = []; + for (let id of ids) { + + const e = document.querySelector("script#"+id) + if (!e) { + throw id+" is not the id of a <script> tag"; + } + scriptTexts.push(e.innerText); + } + const blob = new Blob(scriptTexts, {type: "application/javascript"}); + + return URL.createObjectURL(blob); +} diff --git a/testing/web-platform/tests/webaudio/js/worklet-recorder.js b/testing/web-platform/tests/webaudio/js/worklet-recorder.js new file mode 100644 index 0000000000..913ab742aa --- /dev/null +++ b/testing/web-platform/tests/webaudio/js/worklet-recorder.js @@ -0,0 +1,55 @@ +/** + * @class RecorderProcessor + * @extends AudioWorkletProcessor + * + * A simple recorder AudioWorkletProcessor. Returns the recorded buffer to the + * node when recording is finished. + */ +class RecorderProcessor extends AudioWorkletProcessor { + /** + * @param {*} options + * @param {number} options.duration A duration to record in seconds. + * @param {number} options.channelCount A channel count to record. + */ + constructor(options) { + super(); + this._createdAt = currentTime; + this._elapsed = 0; + this._recordDuration = options.duration || 1; + this._recordChannelCount = options.channelCount || 1; + this._recordBufferLength = sampleRate * this._recordDuration; + this._recordBuffer = []; + for (let i = 0; i < this._recordChannelCount; ++i) { + this._recordBuffer[i] = new Float32Array(this._recordBufferLength); + } + } + + process(inputs, outputs) { + if (this._recordBufferLength <= currentFrame) { + this.port.postMessage({ + type: 'recordfinished', + recordBuffer: this._recordBuffer + }); + this.port.close(); + return false; + } + + // Records the incoming data from |inputs| and also bypasses the data to + // |outputs|. + const input = inputs[0]; + const output = outputs[0]; + for (let channel = 0; channel < input.length; ++channel) { + const inputChannel = input[channel]; + const outputChannel = output[channel]; + outputChannel.set(inputChannel); + + const buffer = this._recordBuffer[channel]; + const capacity = buffer.length - currentFrame; + buffer.set(inputChannel.slice(0, capacity), currentFrame); + } + + return true; + } +} + +registerProcessor('recorder-processor', RecorderProcessor); diff --git a/testing/web-platform/tests/webaudio/resources/4ch-440.wav b/testing/web-platform/tests/webaudio/resources/4ch-440.wav Binary files differnew file mode 100644 index 0000000000..85dc1ea904 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/4ch-440.wav diff --git a/testing/web-platform/tests/webaudio/resources/audio-param.js b/testing/web-platform/tests/webaudio/resources/audio-param.js new file mode 100644 index 0000000000..bc33fe8a21 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/audio-param.js @@ -0,0 +1,44 @@ +// Define functions that implement the formulas for AudioParam automations. + +// AudioParam linearRamp value at time t for a linear ramp between (t0, v0) and +// (t1, v1). It is assumed that t0 <= t. Results are undefined otherwise. +function audioParamLinearRamp(t, v0, t0, v1, t1) { + if (t >= t1) + return v1; + return (v0 + (v1 - v0) * (t - t0) / (t1 - t0)) +} + +// AudioParam exponentialRamp value at time t for an exponential ramp between +// (t0, v0) and (t1, v1). It is assumed that t0 <= t. Results are undefined +// otherwise. +function audioParamExponentialRamp(t, v0, t0, v1, t1) { + if (t >= t1) + return v1; + return v0 * Math.pow(v1 / v0, (t - t0) / (t1 - t0)); +} + +// AudioParam setTarget value at time t for a setTarget curve starting at (t0, +// v0) with a final value of vFainal and a time constant of timeConstant. It is +// assumed that t0 <= t. Results are undefined otherwise. +function audioParamSetTarget(t, v0, t0, vFinal, timeConstant) { + return vFinal + (v0 - vFinal) * Math.exp(-(t - t0) / timeConstant); +} + +// AudioParam setValueCurve value at time t for a setValueCurve starting at time +// t0 with curve, curve, and duration duration. The sample rate is sampleRate. +// It is assumed that t0 <= t. +function audioParamSetValueCurve(t, curve, t0, duration) { + if (t > t0 + duration) + return curve[curve.length - 1]; + + let curvePointsPerSecond = (curve.length - 1) / duration; + + let virtualIndex = (t - t0) * curvePointsPerSecond; + let index = Math.floor(virtualIndex); + + let delta = virtualIndex - index; + + let c0 = curve[index]; + let c1 = curve[Math.min(index + 1, curve.length - 1)]; + return c0 + (c1 - c0) * delta; +} diff --git a/testing/web-platform/tests/webaudio/resources/audiobuffersource-testing.js b/testing/web-platform/tests/webaudio/resources/audiobuffersource-testing.js new file mode 100644 index 0000000000..2233641914 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/audiobuffersource-testing.js @@ -0,0 +1,102 @@ +function createTestBuffer(context, sampleFrameLength) { + let audioBuffer = + context.createBuffer(1, sampleFrameLength, context.sampleRate); + let channelData = audioBuffer.getChannelData(0); + + // Create a simple linear ramp starting at zero, with each value in the buffer + // equal to its index position. + for (let i = 0; i < sampleFrameLength; ++i) + channelData[i] = i; + + return audioBuffer; +} + +function checkSingleTest(renderedBuffer, i, should) { + let renderedData = renderedBuffer.getChannelData(0); + let offsetFrame = i * testSpacingFrames; + + let test = tests[i]; + let expected = test.expected; + let description; + + if (test.description) { + description = test.description; + } else { + // No description given, so create a basic one from the given test + // parameters. + description = + 'loop from ' + test.loopStartFrame + ' -> ' + test.loopEndFrame; + if (test.offsetFrame) + description += ' with offset ' + test.offsetFrame; + if (test.playbackRate && test.playbackRate != 1) + description += ' with playbackRate of ' + test.playbackRate; + } + + let framesToTest; + + if (test.renderFrames) + framesToTest = test.renderFrames; + else if (test.durationFrames) + framesToTest = test.durationFrames; + + // Verify that the output matches + let prefix = 'Case ' + i + ': '; + should( + renderedData.slice(offsetFrame, offsetFrame + framesToTest), + prefix + description) + .beEqualToArray(expected); + + // Verify that we get all zeroes after the buffer (or duration) has passed. + should( + renderedData.slice( + offsetFrame + framesToTest, offsetFrame + testSpacingFrames), + prefix + description + ': tail') + .beConstantValueOf(0); +} + +function checkAllTests(renderedBuffer, should) { + for (let i = 0; i < tests.length; ++i) + checkSingleTest(renderedBuffer, i, should); +} + + +// Create the actual result by modulating playbackRate or detune AudioParam of +// ABSN. |modTarget| is a string of AudioParam name, |modOffset| is the offset +// (anchor) point of modulation, and |modRange| is the range of modulation. +// +// createSawtoothWithModulation(context, 'detune', 440, 1200); +// +// The above will perform a modulation on detune within the range of +// [1200, -1200] around the sawtooth waveform on 440Hz. +function createSawtoothWithModulation(context, modTarget, modOffset, modRange) { + let lfo = context.createOscillator(); + let amp = context.createGain(); + + // Create a sawtooth generator with the signal range of [0, 1]. + let phasor = context.createBufferSource(); + let phasorBuffer = context.createBuffer(1, sampleRate, sampleRate); + let phasorArray = phasorBuffer.getChannelData(0); + let phase = 0, phaseStep = 1 / sampleRate; + for (let i = 0; i < phasorArray.length; i++) { + phasorArray[i] = phase % 1.0; + phase += phaseStep; + } + phasor.buffer = phasorBuffer; + phasor.loop = true; + + // 1Hz for audible (human-perceivable) parameter modulation by LFO. + lfo.frequency.value = 1.0; + + amp.gain.value = modRange; + phasor.playbackRate.value = modOffset; + + // The oscillator output should be amplified accordingly to drive the + // modulation within the desired range. + lfo.connect(amp); + amp.connect(phasor[modTarget]); + + phasor.connect(context.destination); + + lfo.start(); + phasor.start(); +} diff --git a/testing/web-platform/tests/webaudio/resources/audionodeoptions.js b/testing/web-platform/tests/webaudio/resources/audionodeoptions.js new file mode 100644 index 0000000000..3b7867cabf --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/audionodeoptions.js @@ -0,0 +1,292 @@ +// Test that constructor for the node with name |nodeName| handles the +// various possible values for channelCount, channelCountMode, and +// channelInterpretation. + +// The |should| parameter is the test function from new |Audit|. +function testAudioNodeOptions(should, context, nodeName, expectedNodeOptions) { + if (expectedNodeOptions === undefined) + expectedNodeOptions = {}; + let node; + + // Test that we can set channelCount and that errors are thrown for + // invalid values + let testChannelCount = 17; + if (expectedNodeOptions.channelCount) { + testChannelCount = expectedNodeOptions.channelCount.value; + } + should( + () => { + node = new window[nodeName]( + context, Object.assign({}, expectedNodeOptions.additionalOptions, { + channelCount: testChannelCount + })); + }, + 'new ' + nodeName + '(c, {channelCount: ' + testChannelCount + '})') + .notThrow(); + should(node.channelCount, 'node.channelCount').beEqualTo(testChannelCount); + + if (expectedNodeOptions.channelCount && + expectedNodeOptions.channelCount.isFixed) { + // The channel count is fixed. Verify that we throw an error if + // we try to change it. Arbitrarily set the count to be one more + // than the expected value. + testChannelCount = expectedNodeOptions.channelCount.value + 1; + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelCount: testChannelCount})); + }, + 'new ' + nodeName + '(c, {channelCount: ' + testChannelCount + '})') + .throw(DOMException, + expectedNodeOptions.channelCount.exceptionType); + // And test that setting it to the fixed value does not throw. + testChannelCount = expectedNodeOptions.channelCount.value; + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelCount: testChannelCount})); + node.channelCount = testChannelCount; + }, + '(new ' + nodeName + '(c, {channelCount: ' + testChannelCount + '})).channelCount = ' + testChannelCount) + .notThrow(); + } else { + // The channel count is not fixed. Try to set the count to invalid + // values and make sure an error is thrown. + [0, 99].forEach(testValue => { + should(() => { + node = new window[nodeName]( + context, Object.assign({}, expectedNodeOptions.additionalOptions, { + channelCount: testValue + })); + }, `new ${nodeName}(c, {channelCount: ${testValue}})`) + .throw(DOMException, 'NotSupportedError'); + }); + } + + // Test channelCountMode + let testChannelCountMode = 'max'; + if (expectedNodeOptions.channelCountMode) { + testChannelCountMode = expectedNodeOptions.channelCountMode.value; + } + should( + () => { + node = new window[nodeName]( + context, Object.assign({}, expectedNodeOptions.additionalOptions, { + channelCountMode: testChannelCountMode + })); + }, + 'new ' + nodeName + '(c, {channelCountMode: "' + testChannelCountMode + + '"}') + .notThrow(); + should(node.channelCountMode, 'node.channelCountMode') + .beEqualTo(testChannelCountMode); + + if (expectedNodeOptions.channelCountMode && + expectedNodeOptions.channelCountMode.isFixed) { + // Channel count mode is fixed. Test setting to something else throws. + ['max', 'clamped-max', 'explicit'].forEach(testValue => { + if (testValue !== expectedNodeOptions.channelCountMode.value) { + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelCountMode: testValue})); + }, + `new ${nodeName}(c, {channelCountMode: "${testValue}"})`) + .throw(DOMException, + expectedNodeOptions.channelCountMode.exceptionType); + } else { + // Test that explicitly setting the the fixed value is allowed. + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelCountMode: testValue})); + node.channelCountMode = testValue; + }, + `(new ${nodeName}(c, {channelCountMode: "${testValue}"})).channelCountMode = "${testValue}"`) + .notThrow(); + } + }); + } else { + // Mode is not fixed. Verify that we can set the mode to all valid + // values, and that we throw for invalid values. + + let testValues = ['max', 'clamped-max', 'explicit']; + + testValues.forEach(testValue => { + should(() => { + node = new window[nodeName]( + context, Object.assign({}, expectedNodeOptions.additionalOptions, { + channelCountMode: testValue + })); + }, `new ${nodeName}(c, {channelCountMode: "${testValue}"})`).notThrow(); + should( + node.channelCountMode, 'node.channelCountMode after valid setter') + .beEqualTo(testValue); + + }); + + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelCountMode: 'foobar'})); + }, + 'new ' + nodeName + '(c, {channelCountMode: "foobar"}') + .throw(TypeError); + should(node.channelCountMode, 'node.channelCountMode after invalid setter') + .beEqualTo(testValues[testValues.length - 1]); + } + + // Test channelInterpretation + if (expectedNodeOptions.channelInterpretation && + expectedNodeOptions.channelInterpretation.isFixed) { + // The channel interpretation is fixed. Verify that we throw an + // error if we try to change it. + ['speakers', 'discrete'].forEach(testValue => { + if (testValue !== expectedNodeOptions.channelInterpretation.value) { + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionOptions, + {channelInterpretation: testValue})); + }, + `new ${nodeName}(c, {channelInterpretation: "${testValue}"})`) + .throw(DOMException, + expectedNodeOptions.channelCountMode.exceptionType); + } else { + // Check that assigning the fixed value is OK. + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionOptions, + {channelInterpretation: testValue})); + node.channelInterpretation = testValue; + }, + `(new ${nodeName}(c, {channelInterpretation: "${testValue}"})).channelInterpretation = "${testValue}"`) + .notThrow(); + } + }); + } else { + // Channel interpretation is not fixed. Verify that we can set it + // to all possible values. + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelInterpretation: 'speakers'})); + }, + 'new ' + nodeName + '(c, {channelInterpretation: "speakers"})') + .notThrow(); + should(node.channelInterpretation, 'node.channelInterpretation') + .beEqualTo('speakers'); + + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelInterpretation: 'discrete'})); + }, + 'new ' + nodeName + '(c, {channelInterpretation: "discrete"})') + .notThrow(); + should(node.channelInterpretation, 'node.channelInterpretation') + .beEqualTo('discrete'); + + should( + () => { + node = new window[nodeName]( + context, + Object.assign( + {}, expectedNodeOptions.additionalOptions, + {channelInterpretation: 'foobar'})); + }, + 'new ' + nodeName + '(c, {channelInterpretation: "foobar"})') + .throw(TypeError); + should( + node.channelInterpretation, + 'node.channelInterpretation after invalid setter') + .beEqualTo('discrete'); + } +} + +function initializeContext(should) { + let c; + should(() => { + c = new OfflineAudioContext(1, 1, 48000); + }, 'context = new OfflineAudioContext(...)').notThrow(); + + return c; +} + +function testInvalidConstructor(should, name, context) { + should(() => { + new window[name](); + }, 'new ' + name + '()').throw(TypeError); + should(() => { + new window[name](1); + }, 'new ' + name + '(1)').throw(TypeError); + should(() => { + new window[name](context, 42); + }, 'new ' + name + '(context, 42)').throw(TypeError); +} + +function testDefaultConstructor(should, name, context, options) { + let node; + + let message = options.prefix + ' = new ' + name + '(context'; + if (options.constructorOptions) + message += ', ' + JSON.stringify(options.constructorOptions); + message += ')' + + should(() => { + node = new window[name](context, options.constructorOptions); + }, message).notThrow(); + + should(node instanceof window[name], options.prefix + ' instanceof ' + name) + .beEqualTo(true); + should(node.numberOfInputs, options.prefix + '.numberOfInputs') + .beEqualTo(options.numberOfInputs); + should(node.numberOfOutputs, options.prefix + '.numberOfOutputs') + .beEqualTo(options.numberOfOutputs); + should(node.channelCount, options.prefix + '.channelCount') + .beEqualTo(options.channelCount); + should(node.channelCountMode, options.prefix + '.channelCountMode') + .beEqualTo(options.channelCountMode); + should(node.channelInterpretation, options.prefix + '.channelInterpretation') + .beEqualTo(options.channelInterpretation); + + return node; +} + +function testDefaultAttributes(should, node, prefix, items) { + items.forEach((item) => { + let attr = node[item.name]; + if (attr instanceof AudioParam) { + should(attr.value, prefix + '.' + item.name + '.value') + .beEqualTo(item.value); + } else { + should(attr, prefix + '.' + item.name).beEqualTo(item.value); + } + }); +} diff --git a/testing/web-platform/tests/webaudio/resources/audioparam-testing.js b/testing/web-platform/tests/webaudio/resources/audioparam-testing.js new file mode 100644 index 0000000000..bc90ddbef8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/audioparam-testing.js @@ -0,0 +1,554 @@ +(function(global) { + + // Information about the starting/ending times and starting/ending values for + // each time interval. + let timeValueInfo; + + // The difference between starting values between each time interval. + let startingValueDelta; + + // For any automation function that has an end or target value, the end value + // is based the starting value of the time interval. The starting value will + // be increased or decreased by |startEndValueChange|. We choose half of + // |startingValueDelta| so that the ending value will be distinct from the + // starting value for next time interval. This allows us to detect where the + // ramp begins and ends. + let startEndValueChange; + + // Default threshold to use for detecting discontinuities that should appear + // at each time interval. + let discontinuityThreshold; + + // Time interval between value changes. It is best if 1 / numberOfTests is + // not close to timeInterval. + let timeIntervalInternal = .03; + + let context; + + // Make sure we render long enough to capture all of our test data. + function renderLength(numberOfTests) { + return timeToSampleFrame((numberOfTests + 1) * timeInterval, sampleRate); + } + + // Create a constant reference signal with the given |value|. Basically the + // same as |createConstantBuffer|, but with the parameters to match the other + // create functions. The |endValue| is ignored. + function createConstantArray( + startTime, endTime, value, endValue, sampleRate) { + let startFrame = timeToSampleFrame(startTime, sampleRate); + let endFrame = timeToSampleFrame(endTime, sampleRate); + let length = endFrame - startFrame; + + let buffer = createConstantBuffer(context, length, value); + + return buffer.getChannelData(0); + } + + function getStartEndFrames(startTime, endTime, sampleRate) { + // Start frame is the ceiling of the start time because the ramp starts at + // or after the sample frame. End frame is the ceiling because it's the + // exclusive ending frame of the automation. + let startFrame = Math.ceil(startTime * sampleRate); + let endFrame = Math.ceil(endTime * sampleRate); + + return {startFrame: startFrame, endFrame: endFrame}; + } + + // Create a linear ramp starting at |startValue| and ending at |endValue|. The + // ramp starts at time |startTime| and ends at |endTime|. (The start and end + // times are only used to compute how many samples to return.) + function createLinearRampArray( + startTime, endTime, startValue, endValue, sampleRate) { + let frameInfo = getStartEndFrames(startTime, endTime, sampleRate); + let startFrame = frameInfo.startFrame; + let endFrame = frameInfo.endFrame; + let length = endFrame - startFrame; + let array = new Array(length); + + let step = Math.fround( + (endValue - startValue) / (endTime - startTime) / sampleRate); + let start = Math.fround( + startValue + + (endValue - startValue) * (startFrame / sampleRate - startTime) / + (endTime - startTime)); + + let slope = (endValue - startValue) / (endTime - startTime); + + // v(t) = v0 + (v1 - v0)*(t-t0)/(t1-t0) + for (k = 0; k < length; ++k) { + // array[k] = Math.fround(start + k * step); + let t = (startFrame + k) / sampleRate; + array[k] = startValue + slope * (t - startTime); + } + + return array; + } + + // Create an exponential ramp starting at |startValue| and ending at + // |endValue|. The ramp starts at time |startTime| and ends at |endTime|. + // (The start and end times are only used to compute how many samples to + // return.) + function createExponentialRampArray( + startTime, endTime, startValue, endValue, sampleRate) { + let deltaTime = endTime - startTime; + + let frameInfo = getStartEndFrames(startTime, endTime, sampleRate); + let startFrame = frameInfo.startFrame; + let endFrame = frameInfo.endFrame; + let length = endFrame - startFrame; + let array = new Array(length); + + let ratio = endValue / startValue; + + // v(t) = v0*(v1/v0)^((t-t0)/(t1-t0)) + for (let k = 0; k < length; ++k) { + let t = Math.fround((startFrame + k) / sampleRate); + array[k] = Math.fround( + startValue * Math.pow(ratio, (t - startTime) / deltaTime)); + } + + return array; + } + + function discreteTimeConstantForSampleRate(timeConstant, sampleRate) { + return 1 - Math.exp(-1 / (sampleRate * timeConstant)); + } + + // Create a signal that starts at |startValue| and exponentially approaches + // the target value of |targetValue|, using a time constant of |timeConstant|. + // The ramp starts at time |startTime| and ends at |endTime|. (The start and + // end times are only used to compute how many samples to return.) + function createExponentialApproachArray( + startTime, endTime, startValue, targetValue, sampleRate, timeConstant) { + let startFrameFloat = startTime * sampleRate; + let frameInfo = getStartEndFrames(startTime, endTime, sampleRate); + let startFrame = frameInfo.startFrame; + let endFrame = frameInfo.endFrame; + let length = Math.floor(endFrame - startFrame); + let array = new Array(length); + let c = discreteTimeConstantForSampleRate(timeConstant, sampleRate); + + let delta = startValue - targetValue; + + // v(t) = v1 + (v0 - v1) * exp(-(t-t0)/tau) + for (let k = 0; k < length; ++k) { + let t = (startFrame + k) / sampleRate; + let value = + targetValue + delta * Math.exp(-(t - startTime) / timeConstant); + array[k] = value; + } + + return array; + } + + // Create a sine wave of the specified duration. + function createReferenceSineArray( + startTime, endTime, startValue, endValue, sampleRate) { + // Ignore |startValue| and |endValue| for the sine wave. + let curve = createSineWaveArray( + endTime - startTime, freqHz, sineAmplitude, sampleRate); + // Sample the curve appropriately. + let frameInfo = getStartEndFrames(startTime, endTime, sampleRate); + let startFrame = frameInfo.startFrame; + let endFrame = frameInfo.endFrame; + let length = Math.floor(endFrame - startFrame); + let array = new Array(length); + + // v(t) = linearly interpolate between V[k] and V[k + 1] where k = + // floor((N-1)/duration*(t - t0)) + let f = (length - 1) / (endTime - startTime); + + for (let k = 0; k < length; ++k) { + let t = (startFrame + k) / sampleRate; + let indexFloat = f * (t - startTime); + let index = Math.floor(indexFloat); + if (index + 1 < length) { + let v0 = curve[index]; + let v1 = curve[index + 1]; + array[k] = v0 + (v1 - v0) * (indexFloat - index); + } else { + array[k] = curve[length - 1]; + } + } + + return array; + } + + // Create a sine wave of the given frequency and amplitude. The sine wave is + // offset by half the amplitude so that result is always positive. + function createSineWaveArray(durationSeconds, freqHz, amplitude, sampleRate) { + let length = timeToSampleFrame(durationSeconds, sampleRate); + let signal = new Float32Array(length); + let omega = 2 * Math.PI * freqHz / sampleRate; + let halfAmplitude = amplitude / 2; + + for (let k = 0; k < length; ++k) { + signal[k] = halfAmplitude + halfAmplitude * Math.sin(omega * k); + } + + return signal; + } + + // Return the difference between the starting value and the ending value for + // time interval |timeIntervalIndex|. We alternate between an end value that + // is above or below the starting value. + function endValueDelta(timeIntervalIndex) { + if (timeIntervalIndex & 1) { + return -startEndValueChange; + } else { + return startEndValueChange; + } + } + + // Relative error metric + function relativeErrorMetric(actual, expected) { + return (actual - expected) / Math.abs(expected); + } + + // Difference metric + function differenceErrorMetric(actual, expected) { + return actual - expected; + } + + // Return the difference between the starting value at |timeIntervalIndex| and + // the starting value at the next time interval. Since we started at a large + // initial value, we decrease the value at each time interval. + function valueUpdate(timeIntervalIndex) { + return -startingValueDelta; + } + + // Compare a section of the rendered data against our expected signal. + function comparePartialSignals( + should, rendered, expectedFunction, startTime, endTime, valueInfo, + sampleRate, errorMetric) { + let startSample = timeToSampleFrame(startTime, sampleRate); + let expected = expectedFunction( + startTime, endTime, valueInfo.startValue, valueInfo.endValue, + sampleRate, timeConstant); + + let n = expected.length; + let maxError = -1; + let maxErrorIndex = -1; + + for (let k = 0; k < n; ++k) { + // Make sure we don't pass these tests because a NaN has been generated in + // either the + // rendered data or the reference data. + if (!isValidNumber(rendered[startSample + k])) { + maxError = Infinity; + maxErrorIndex = startSample + k; + should( + isValidNumber(rendered[startSample + k]), + 'NaN or infinity for rendered data at ' + maxErrorIndex) + .beTrue(); + break; + } + if (!isValidNumber(expected[k])) { + maxError = Infinity; + maxErrorIndex = startSample + k; + should( + isValidNumber(expected[k]), + 'NaN or infinity for rendered data at ' + maxErrorIndex) + .beTrue(); + break; + } + let error = Math.abs(errorMetric(rendered[startSample + k], expected[k])); + if (error > maxError) { + maxError = error; + maxErrorIndex = k; + } + } + + return {maxError: maxError, index: maxErrorIndex, expected: expected}; + } + + // Find the discontinuities in the data and compare the locations of the + // discontinuities with the times that define the time intervals. There is a + // discontinuity if the difference between successive samples exceeds the + // threshold. + function verifyDiscontinuities(should, values, times, threshold) { + let n = values.length; + let success = true; + let badLocations = 0; + let breaks = []; + + // Find discontinuities. + for (let k = 1; k < n; ++k) { + if (Math.abs(values[k] - values[k - 1]) > threshold) { + breaks.push(k); + } + } + + let testCount; + + // If there are numberOfTests intervals, there are only numberOfTests - 1 + // internal interval boundaries. Hence the maximum number of discontinuties + // we expect to find is numberOfTests - 1. If we find more than that, we + // have no reference to compare against. We also assume that the actual + // discontinuities are close to the expected ones. + // + // This is just a sanity check when something goes really wrong. For + // example, if the threshold is too low, every sample frame looks like a + // discontinuity. + if (breaks.length >= numberOfTests) { + testCount = numberOfTests - 1; + should(breaks.length, 'Number of discontinuities') + .beLessThan(numberOfTests); + success = false; + } else { + testCount = breaks.length; + } + + // Compare the location of each discontinuity with the end time of each + // interval. (There is no discontinuity at the start of the signal.) + for (let k = 0; k < testCount; ++k) { + let expectedSampleFrame = timeToSampleFrame(times[k + 1], sampleRate); + if (breaks[k] != expectedSampleFrame) { + success = false; + ++badLocations; + should(breaks[k], 'Discontinuity at index') + .beEqualTo(expectedSampleFrame); + } + } + + if (badLocations) { + should(badLocations, 'Number of discontinuites at incorrect locations') + .beEqualTo(0); + success = false; + } else { + should( + breaks.length + 1, + 'Number of tests started and ended at the correct time') + .beEqualTo(numberOfTests); + } + + return success; + } + + // Compare the rendered data with the expected data. + // + // testName - string describing the test + // + // maxError - maximum allowed difference between the rendered data and the + // expected data + // + // rendererdData - array containing the rendered (actual) data + // + // expectedFunction - function to compute the expected data + // + // timeValueInfo - array containing information about the start and end times + // and the start and end values of each interval. + // + // breakThreshold - threshold to use for determining discontinuities. + function compareSignals( + should, testName, maxError, renderedData, expectedFunction, timeValueInfo, + breakThreshold, errorMetric) { + let success = true; + let failedTestCount = 0; + let times = timeValueInfo.times; + let values = timeValueInfo.values; + let n = values.length; + let expectedSignal = []; + + success = + verifyDiscontinuities(should, renderedData, times, breakThreshold); + + for (let k = 0; k < n; ++k) { + let result = comparePartialSignals( + should, renderedData, expectedFunction, times[k], times[k + 1], + values[k], sampleRate, errorMetric); + + expectedSignal = + expectedSignal.concat(Array.prototype.slice.call(result.expected)); + + should( + result.maxError, + 'Max error for test ' + k + ' at offset ' + + (result.index + timeToSampleFrame(times[k], sampleRate))) + .beLessThanOrEqualTo(maxError); + } + + should( + failedTestCount, + 'Number of failed tests with an acceptable relative tolerance of ' + + maxError) + .beEqualTo(0); + } + + // Create a function to test the rendered data with the reference data. + // + // testName - string describing the test + // + // error - max allowed error between rendered data and the reference data. + // + // referenceFunction - function that generates the reference data to be + // compared with the rendered data. + // + // jumpThreshold - optional parameter that specifies the threshold to use for + // detecting discontinuities. If not specified, defaults to + // discontinuityThreshold. + // + function checkResultFunction( + task, should, testName, error, referenceFunction, jumpThreshold, + errorMetric) { + return function(event) { + let buffer = event.renderedBuffer; + renderedData = buffer.getChannelData(0); + + let threshold; + + if (!jumpThreshold) { + threshold = discontinuityThreshold; + } else { + threshold = jumpThreshold; + } + + compareSignals( + should, testName, error, renderedData, referenceFunction, + timeValueInfo, threshold, errorMetric); + task.done(); + } + } + + // Run all the automation tests. + // + // numberOfTests - number of tests (time intervals) to run. + // + // initialValue - The initial value of the first time interval. + // + // setValueFunction - function that sets the specified value at the start of a + // time interval. + // + // automationFunction - function that sets the end value for the time + // interval. It specifies how the value approaches the end value. + // + // An object is returned containing an array of start times for each time + // interval, and an array giving the start and end values for the interval. + function doAutomation( + numberOfTests, initialValue, setValueFunction, automationFunction) { + let timeInfo = [0]; + let valueInfo = []; + let value = initialValue; + + for (let k = 0; k < numberOfTests; ++k) { + let startTime = k * timeInterval; + let endTime = (k + 1) * timeInterval; + let endValue = value + endValueDelta(k); + + // Set the value at the start of the time interval. + setValueFunction(value, startTime); + + // Specify the end or target value, and how we should approach it. + automationFunction(endValue, startTime, endTime); + + // Keep track of the start times, and the start and end values for each + // time interval. + timeInfo.push(endTime); + valueInfo.push({startValue: value, endValue: endValue}); + + value += valueUpdate(k); + } + + return {times: timeInfo, values: valueInfo}; + } + + // Create the audio graph for the test and then run the test. + // + // numberOfTests - number of time intervals (tests) to run. + // + // initialValue - the initial value of the gain at time 0. + // + // setValueFunction - function to set the value at the beginning of each time + // interval. + // + // automationFunction - the AudioParamTimeline automation function + // + // testName - string indicating the test that is being run. + // + // maxError - maximum allowed error between the rendered data and the + // reference data + // + // referenceFunction - function that generates the reference data to be + // compared against the rendered data. + // + // jumpThreshold - optional parameter that specifies the threshold to use for + // detecting discontinuities. If not specified, defaults to + // discontinuityThreshold. + // + function createAudioGraphAndTest( + task, should, numberOfTests, initialValue, setValueFunction, + automationFunction, testName, maxError, referenceFunction, jumpThreshold, + errorMetric) { + // Create offline audio context. + context = + new OfflineAudioContext(2, renderLength(numberOfTests), sampleRate); + let constantBuffer = + createConstantBuffer(context, renderLength(numberOfTests), 1); + + // We use an AudioGainNode here simply as a convenient way to test the + // AudioParam automation, since it's easy to pass a constant value through + // the node, automate the .gain attribute and observe the resulting values. + + gainNode = context.createGain(); + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = constantBuffer; + bufferSource.connect(gainNode); + gainNode.connect(context.destination); + + // Set up default values for the parameters that control how the automation + // test values progress for each time interval. + startingValueDelta = initialValue / numberOfTests; + startEndValueChange = startingValueDelta / 2; + discontinuityThreshold = startEndValueChange / 2; + + // Run the automation tests. + timeValueInfo = doAutomation( + numberOfTests, initialValue, setValueFunction, automationFunction); + bufferSource.start(0); + + context.oncomplete = checkResultFunction( + task, should, testName, maxError, referenceFunction, jumpThreshold, + errorMetric || relativeErrorMetric); + context.startRendering(); + } + + // Export local references to global scope. All the new objects in this file + // must be exported through this if it is to be used in the actual test HTML + // page. + let exports = { + 'sampleRate': 44100, + 'gainNode': null, + 'timeInterval': timeIntervalInternal, + + // Some suitable time constant so that we can see a significant change over + // a timeInterval. This is only needed by setTargetAtTime() which needs a + // time constant. + 'timeConstant': timeIntervalInternal / 3, + + 'renderLength': renderLength, + 'createConstantArray': createConstantArray, + 'getStartEndFrames': getStartEndFrames, + 'createLinearRampArray': createLinearRampArray, + 'createExponentialRampArray': createExponentialRampArray, + 'discreteTimeConstantForSampleRate': discreteTimeConstantForSampleRate, + 'createExponentialApproachArray': createExponentialApproachArray, + 'createReferenceSineArray': createReferenceSineArray, + 'createSineWaveArray': createSineWaveArray, + 'endValueDelta': endValueDelta, + 'relativeErrorMetric': relativeErrorMetric, + 'differenceErrorMetric': differenceErrorMetric, + 'valueUpdate': valueUpdate, + 'comparePartialSignals': comparePartialSignals, + 'verifyDiscontinuities': verifyDiscontinuities, + 'compareSignals': compareSignals, + 'checkResultFunction': checkResultFunction, + 'doAutomation': doAutomation, + 'createAudioGraphAndTest': createAudioGraphAndTest + }; + + for (let reference in exports) { + global[reference] = exports[reference]; + } + +})(window); diff --git a/testing/web-platform/tests/webaudio/resources/audit-util.js b/testing/web-platform/tests/webaudio/resources/audit-util.js new file mode 100644 index 0000000000..a4dea79658 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/audit-util.js @@ -0,0 +1,195 @@ +// Copyright 2016 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + + +/** + * @fileOverview This file includes legacy utility functions for the layout + * test. + */ + +// How many frames in a WebAudio render quantum. +let RENDER_QUANTUM_FRAMES = 128; + +// Compare two arrays (commonly extracted from buffer.getChannelData()) with +// constraints: +// options.thresholdSNR: Minimum allowed SNR between the actual and expected +// signal. The default value is 10000. +// options.thresholdDiffULP: Maximum allowed difference between the actual +// and expected signal in ULP(Unit in the last place). The default is 0. +// options.thresholdDiffCount: Maximum allowed number of sample differences +// which exceeds the threshold. The default is 0. +// options.bitDepth: The expected result is assumed to come from an audio +// file with this number of bits of precision. The default is 16. +function compareBuffersWithConstraints(should, actual, expected, options) { + if (!options) + options = {}; + + // Only print out the message if the lengths are different; the + // expectation is that they are the same, so don't clutter up the + // output. + if (actual.length !== expected.length) { + should( + actual.length === expected.length, + 'Length of actual and expected buffers should match') + .beTrue(); + } + + let maxError = -1; + let diffCount = 0; + let errorPosition = -1; + let thresholdSNR = (options.thresholdSNR || 10000); + + let thresholdDiffULP = (options.thresholdDiffULP || 0); + let thresholdDiffCount = (options.thresholdDiffCount || 0); + + // By default, the bit depth is 16. + let bitDepth = (options.bitDepth || 16); + let scaleFactor = Math.pow(2, bitDepth - 1); + + let noisePower = 0, signalPower = 0; + + for (let i = 0; i < actual.length; i++) { + let diff = actual[i] - expected[i]; + noisePower += diff * diff; + signalPower += expected[i] * expected[i]; + + if (Math.abs(diff) > maxError) { + maxError = Math.abs(diff); + errorPosition = i; + } + + // The reference file is a 16-bit WAV file, so we will almost never get + // an exact match between it and the actual floating-point result. + if (Math.abs(diff) > scaleFactor) + diffCount++; + } + + let snr = 10 * Math.log10(signalPower / noisePower); + let maxErrorULP = maxError * scaleFactor; + + should(snr, 'SNR').beGreaterThanOrEqualTo(thresholdSNR); + + should( + maxErrorULP, + options.prefix + ': Maximum difference (in ulp units (' + bitDepth + + '-bits))') + .beLessThanOrEqualTo(thresholdDiffULP); + + should(diffCount, options.prefix + ': Number of differences between results') + .beLessThanOrEqualTo(thresholdDiffCount); +} + +// Create an impulse in a buffer of length sampleFrameLength +function createImpulseBuffer(context, sampleFrameLength) { + let audioBuffer = + context.createBuffer(1, sampleFrameLength, context.sampleRate); + let n = audioBuffer.length; + let dataL = audioBuffer.getChannelData(0); + + for (let k = 0; k < n; ++k) { + dataL[k] = 0; + } + dataL[0] = 1; + + return audioBuffer; +} + +// Create a buffer of the given length with a linear ramp having values 0 <= x < +// 1. +function createLinearRampBuffer(context, sampleFrameLength) { + let audioBuffer = + context.createBuffer(1, sampleFrameLength, context.sampleRate); + let n = audioBuffer.length; + let dataL = audioBuffer.getChannelData(0); + + for (let i = 0; i < n; ++i) + dataL[i] = i / n; + + return audioBuffer; +} + +// Create an AudioBuffer of length |sampleFrameLength| having a constant value +// |constantValue|. If |constantValue| is a number, the buffer has one channel +// filled with that value. If |constantValue| is an array, the buffer is created +// wit a number of channels equal to the length of the array, and channel k is +// filled with the k'th element of the |constantValue| array. +function createConstantBuffer(context, sampleFrameLength, constantValue) { + let channels; + let values; + + if (typeof constantValue === 'number') { + channels = 1; + values = [constantValue]; + } else { + channels = constantValue.length; + values = constantValue; + } + + let audioBuffer = + context.createBuffer(channels, sampleFrameLength, context.sampleRate); + let n = audioBuffer.length; + + for (let c = 0; c < channels; ++c) { + let data = audioBuffer.getChannelData(c); + for (let i = 0; i < n; ++i) + data[i] = values[c]; + } + + return audioBuffer; +} + +// Create a stereo impulse in a buffer of length sampleFrameLength +function createStereoImpulseBuffer(context, sampleFrameLength) { + let audioBuffer = + context.createBuffer(2, sampleFrameLength, context.sampleRate); + let n = audioBuffer.length; + let dataL = audioBuffer.getChannelData(0); + let dataR = audioBuffer.getChannelData(1); + + for (let k = 0; k < n; ++k) { + dataL[k] = 0; + dataR[k] = 0; + } + dataL[0] = 1; + dataR[0] = 1; + + return audioBuffer; +} + +// Convert time (in seconds) to sample frames. +function timeToSampleFrame(time, sampleRate) { + return Math.floor(0.5 + time * sampleRate); +} + +// Compute the number of sample frames consumed by noteGrainOn with +// the specified |grainOffset|, |duration|, and |sampleRate|. +function grainLengthInSampleFrames(grainOffset, duration, sampleRate) { + let startFrame = timeToSampleFrame(grainOffset, sampleRate); + let endFrame = timeToSampleFrame(grainOffset + duration, sampleRate); + + return endFrame - startFrame; +} + +// True if the number is not an infinity or NaN +function isValidNumber(x) { + return !isNaN(x) && (x != Infinity) && (x != -Infinity); +} + +// Compute the (linear) signal-to-noise ratio between |actual| and +// |expected|. The result is NOT in dB! If the |actual| and +// |expected| have different lengths, the shorter length is used. +function computeSNR(actual, expected) { + let signalPower = 0; + let noisePower = 0; + + let length = Math.min(actual.length, expected.length); + + for (let k = 0; k < length; ++k) { + let diff = actual[k] - expected[k]; + signalPower += expected[k] * expected[k]; + noisePower += diff * diff; + } + + return signalPower / noisePower; +} diff --git a/testing/web-platform/tests/webaudio/resources/audit.js b/testing/web-platform/tests/webaudio/resources/audit.js new file mode 100644 index 0000000000..2bb078b111 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/audit.js @@ -0,0 +1,1445 @@ +// Copyright 2016 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// See https://github.com/web-platform-tests/wpt/issues/12781 for information on +// the purpose of audit.js, and why testharness.js does not suffice. + +/** + * @fileOverview WebAudio layout test utility library. Built around W3C's + * testharness.js. Includes asynchronous test task manager, + * assertion utilities. + * @dependency testharness.js + */ + + +(function() { + + 'use strict'; + + // Selected methods from testharness.js. + let testharnessProperties = [ + 'test', 'async_test', 'promise_test', 'promise_rejects_js', 'generate_tests', + 'setup', 'done', 'assert_true', 'assert_false' + ]; + + // Check if testharness.js is properly loaded. Throw otherwise. + for (let name in testharnessProperties) { + if (!self.hasOwnProperty(testharnessProperties[name])) + throw new Error('Cannot proceed. testharness.js is not loaded.'); + } +})(); + + +window.Audit = (function() { + + 'use strict'; + + // NOTE: Moving this method (or any other code above) will change the location + // of 'CONSOLE ERROR...' message in the expected text files. + function _logError(message) { + console.error('[audit.js] ' + message); + } + + function _logPassed(message) { + test(function(arg) { + assert_true(true); + }, message); + } + + function _logFailed(message, detail) { + test(function() { + assert_true(false, detail); + }, message); + } + + function _throwException(message) { + throw new Error(message); + } + + // TODO(hongchan): remove this hack after confirming all the tests are + // finished correctly. (crbug.com/708817) + const _testharnessDone = window.done; + window.done = () => { + _throwException('Do NOT call done() method from the test code.'); + }; + + // Generate a descriptive string from a target value in various types. + function _generateDescription(target, options) { + let targetString; + + switch (typeof target) { + case 'object': + // Handle Arrays. + if (target instanceof Array || target instanceof Float32Array || + target instanceof Float64Array || target instanceof Uint8Array) { + let arrayElements = target.length < options.numberOfArrayElements ? + String(target) : + String(target.slice(0, options.numberOfArrayElements)) + '...'; + targetString = '[' + arrayElements + ']'; + } else if (target === null) { + targetString = String(target); + } else { + targetString = '' + String(target).split(/[\s\]]/)[1]; + } + break; + case 'function': + if (Error.isPrototypeOf(target)) { + targetString = "EcmaScript error " + target.name; + } else { + targetString = String(target); + } + break; + default: + targetString = String(target); + break; + } + + return targetString; + } + + // Return a string suitable for printing one failed element in + // |beCloseToArray|. + function _formatFailureEntry(index, actual, expected, abserr, threshold) { + return '\t[' + index + ']\t' + actual.toExponential(16) + '\t' + + expected.toExponential(16) + '\t' + abserr.toExponential(16) + '\t' + + (abserr / Math.abs(expected)).toExponential(16) + '\t' + + threshold.toExponential(16); + } + + // Compute the error threshold criterion for |beCloseToArray| + function _closeToThreshold(abserr, relerr, expected) { + return Math.max(abserr, relerr * Math.abs(expected)); + } + + /** + * @class Should + * @description Assertion subtask for the Audit task. + * @param {Task} parentTask Associated Task object. + * @param {Any} actual Target value to be tested. + * @param {String} actualDescription String description of the test target. + */ + class Should { + constructor(parentTask, actual, actualDescription) { + this._task = parentTask; + + this._actual = actual; + this._actualDescription = (actualDescription || null); + this._expected = null; + this._expectedDescription = null; + + this._detail = ''; + // If true and the test failed, print the actual value at the + // end of the message. + this._printActualForFailure = true; + + this._result = null; + + /** + * @param {Number} numberOfErrors Number of errors to be printed. + * @param {Number} numberOfArrayElements Number of array elements to be + * printed in the test log. + * @param {Boolean} verbose Verbose output from the assertion. + */ + this._options = { + numberOfErrors: 4, + numberOfArrayElements: 16, + verbose: false + }; + } + + _processArguments(args) { + if (args.length === 0) + return; + + if (args.length > 0) + this._expected = args[0]; + + if (typeof args[1] === 'string') { + // case 1: (expected, description, options) + this._expectedDescription = args[1]; + Object.assign(this._options, args[2]); + } else if (typeof args[1] === 'object') { + // case 2: (expected, options) + Object.assign(this._options, args[1]); + } + } + + _buildResultText() { + if (this._result === null) + _throwException('Illegal invocation: the assertion is not finished.'); + + let actualString = _generateDescription(this._actual, this._options); + + // Use generated text when the description is not provided. + if (!this._actualDescription) + this._actualDescription = actualString; + + if (!this._expectedDescription) { + this._expectedDescription = + _generateDescription(this._expected, this._options); + } + + // For the assertion with a single operand. + this._detail = + this._detail.replace(/\$\{actual\}/g, this._actualDescription); + + // If there is a second operand (i.e. expected value), we have to build + // the string for it as well. + this._detail = + this._detail.replace(/\$\{expected\}/g, this._expectedDescription); + + // If there is any property in |_options|, replace the property name + // with the value. + for (let name in this._options) { + if (name === 'numberOfErrors' || name === 'numberOfArrayElements' || + name === 'verbose') { + continue; + } + + // The RegExp key string contains special character. Take care of it. + let re = '\$\{' + name + '\}'; + re = re.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); + this._detail = this._detail.replace( + new RegExp(re, 'g'), _generateDescription(this._options[name])); + } + + // If the test failed, add the actual value at the end. + if (this._result === false && this._printActualForFailure === true) { + this._detail += ' Got ' + actualString + '.'; + } + } + + _finalize() { + if (this._result) { + _logPassed(' ' + this._detail); + } else { + _logFailed('X ' + this._detail); + } + + // This assertion is finished, so update the parent task accordingly. + this._task.update(this); + + // TODO(hongchan): configurable 'detail' message. + } + + _assert(condition, passDetail, failDetail) { + this._result = Boolean(condition); + this._detail = this._result ? passDetail : failDetail; + this._buildResultText(); + this._finalize(); + + return this._result; + } + + get result() { + return this._result; + } + + get detail() { + return this._detail; + } + + /** + * should() assertions. + * + * @example All the assertions can have 1, 2 or 3 arguments: + * should().doAssert(expected); + * should().doAssert(expected, options); + * should().doAssert(expected, expectedDescription, options); + * + * @param {Any} expected Expected value of the assertion. + * @param {String} expectedDescription Description of expected value. + * @param {Object} options Options for assertion. + * @param {Number} options.numberOfErrors Number of errors to be printed. + * (if applicable) + * @param {Number} options.numberOfArrayElements Number of array elements + * to be printed. (if + * applicable) + * @notes Some assertions can have additional options for their specific + * testing. + */ + + /** + * Check if |actual| exists. + * + * @example + * should({}, 'An empty object').exist(); + * @result + * "PASS An empty object does exist." + */ + exist() { + return this._assert( + this._actual !== null && this._actual !== undefined, + '${actual} does exist.', '${actual} does not exist.'); + } + + /** + * Check if |actual| operation wrapped in a function throws an exception + * with a expected error type correctly. |expected| is optional. If it is an + * instance of DOMException, then the description (second argument) can be + * provided to be more strict about the expected exception type. |expected| + * also can be other generic error types such as TypeError, RangeError or + * etc. + * + * @example + * should(() => { let a = b; }, 'A bad code').throw(); + * should(() => { new SomeConstructor(); }, 'A bad construction') + * .throw(DOMException, 'NotSupportedError'); + * should(() => { let c = d; }, 'Assigning d to c') + * .throw(ReferenceError); + * should(() => { let e = f; }, 'Assigning e to f') + * .throw(ReferenceError, { omitErrorMessage: true }); + * + * @result + * "PASS A bad code threw an exception of ReferenceError: b is not + * defined." + * "PASS A bad construction threw DOMException:NotSupportedError." + * "PASS Assigning d to c threw ReferenceError: d is not defined." + * "PASS Assigning e to f threw ReferenceError: [error message + * omitted]." + */ + throw() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let didThrowCorrectly = false; + let passDetail, failDetail; + + try { + // This should throw. + this._actual(); + // Catch did not happen, so the test is failed. + failDetail = '${actual} did not throw an exception.'; + } catch (error) { + let errorMessage = this._options.omitErrorMessage ? + ': [error message omitted]' : + ': "' + error.message + '"'; + if (this._expected === null || this._expected === undefined) { + // The expected error type was not given. + didThrowCorrectly = true; + passDetail = '${actual} threw ' + error.name + errorMessage + '.'; + } else if (this._expected === DOMException && + this._expectedDescription !== undefined) { + // Handles DOMException with an expected exception name. + if (this._expectedDescription === error.name) { + didThrowCorrectly = true; + passDetail = '${actual} threw ${expected}' + errorMessage + '.'; + } else { + didThrowCorrectly = false; + failDetail = + '${actual} threw "' + error.name + '" instead of ${expected}.'; + } + } else if (this._expected == error.constructor) { + // Handler other error types. + didThrowCorrectly = true; + passDetail = '${actual} threw ' + error.name + errorMessage + '.'; + } else { + didThrowCorrectly = false; + failDetail = + '${actual} threw "' + error.name + '" instead of ${expected}.'; + } + } + + return this._assert(didThrowCorrectly, passDetail, failDetail); + } + + /** + * Check if |actual| operation wrapped in a function does not throws an + * exception correctly. + * + * @example + * should(() => { let foo = 'bar'; }, 'let foo = "bar"').notThrow(); + * + * @result + * "PASS let foo = "bar" did not throw an exception." + */ + notThrow() { + this._printActualForFailure = false; + + let didThrowCorrectly = false; + let passDetail, failDetail; + + try { + this._actual(); + passDetail = '${actual} did not throw an exception.'; + } catch (error) { + didThrowCorrectly = true; + failDetail = '${actual} incorrectly threw ' + error.name + ': "' + + error.message + '".'; + } + + return this._assert(!didThrowCorrectly, passDetail, failDetail); + } + + /** + * Check if |actual| promise is resolved correctly. Note that the returned + * result from promise object will be passed to the following then() + * function. + * + * @example + * should('My promise', promise).beResolve().then((result) => { + * log(result); + * }); + * + * @result + * "PASS My promise resolved correctly." + * "FAIL X My promise rejected *INCORRECTLY* with _ERROR_." + */ + beResolved() { + return this._actual.then( + function(result) { + this._assert(true, '${actual} resolved correctly.', null); + return result; + }.bind(this), + function(error) { + this._assert( + false, null, + '${actual} rejected incorrectly with ' + error + '.'); + }.bind(this)); + } + + /** + * Check if |actual| promise is rejected correctly. + * + * @example + * should('My promise', promise).beRejected().then(nextStuff); + * + * @result + * "PASS My promise rejected correctly (with _ERROR_)." + * "FAIL X My promise resolved *INCORRECTLY*." + */ + beRejected() { + return this._actual.then( + function() { + this._assert(false, null, '${actual} resolved incorrectly.'); + }.bind(this), + function(error) { + this._assert( + true, '${actual} rejected correctly with ' + error + '.', null); + }.bind(this)); + } + + /** + * Check if |actual| promise is rejected correctly. + * + * @example + * should(promise, 'My promise').beRejectedWith('_ERROR_').then(); + * + * @result + * "PASS My promise rejected correctly with _ERROR_." + * "FAIL X My promise rejected correctly but got _ACTUAL_ERROR instead of + * _EXPECTED_ERROR_." + * "FAIL X My promise resolved incorrectly." + */ + beRejectedWith() { + this._processArguments(arguments); + + return this._actual.then( + function() { + this._assert(false, null, '${actual} resolved incorrectly.'); + }.bind(this), + function(error) { + if (this._expected !== error.name) { + this._assert( + false, null, + '${actual} rejected correctly but got ' + error.name + + ' instead of ' + this._expected + '.'); + } else { + this._assert( + true, + '${actual} rejected correctly with ' + this._expected + '.', + null); + } + }.bind(this)); + } + + /** + * Check if |actual| is a boolean true. + * + * @example + * should(3 < 5, '3 < 5').beTrue(); + * + * @result + * "PASS 3 < 5 is true." + */ + beTrue() { + return this._assert( + this._actual === true, '${actual} is true.', + '${actual} is not true.'); + } + + /** + * Check if |actual| is a boolean false. + * + * @example + * should(3 > 5, '3 > 5').beFalse(); + * + * @result + * "PASS 3 > 5 is false." + */ + beFalse() { + return this._assert( + this._actual === false, '${actual} is false.', + '${actual} is not false.'); + } + + /** + * Check if |actual| is strictly equal to |expected|. (no type coercion) + * + * @example + * should(1).beEqualTo(1); + * + * @result + * "PASS 1 is equal to 1." + */ + beEqualTo() { + this._processArguments(arguments); + return this._assert( + this._actual === this._expected, '${actual} is equal to ${expected}.', + '${actual} is not equal to ${expected}.'); + } + + /** + * Check if |actual| is not equal to |expected|. + * + * @example + * should(1).notBeEqualTo(2); + * + * @result + * "PASS 1 is not equal to 2." + */ + notBeEqualTo() { + this._processArguments(arguments); + return this._assert( + this._actual !== this._expected, + '${actual} is not equal to ${expected}.', + '${actual} should not be equal to ${expected}.'); + } + + /** + * check if |actual| is NaN + * + * @example + * should(NaN).beNaN(); + * + * @result + * "PASS NaN is NaN" + * + */ + beNaN() { + this._processArguments(arguments); + return this._assert( + isNaN(this._actual), + '${actual} is NaN.', + '${actual} is not NaN but should be.'); + } + + /** + * check if |actual| is NOT NaN + * + * @example + * should(42).notBeNaN(); + * + * @result + * "PASS 42 is not NaN" + * + */ + notBeNaN() { + this._processArguments(arguments); + return this._assert( + !isNaN(this._actual), + '${actual} is not NaN.', + '${actual} is NaN but should not be.'); + } + + /** + * Check if |actual| is greater than |expected|. + * + * @example + * should(2).beGreaterThanOrEqualTo(2); + * + * @result + * "PASS 2 is greater than or equal to 2." + */ + beGreaterThan() { + this._processArguments(arguments); + return this._assert( + this._actual > this._expected, + '${actual} is greater than ${expected}.', + '${actual} is not greater than ${expected}.'); + } + + /** + * Check if |actual| is greater than or equal to |expected|. + * + * @example + * should(2).beGreaterThan(1); + * + * @result + * "PASS 2 is greater than 1." + */ + beGreaterThanOrEqualTo() { + this._processArguments(arguments); + return this._assert( + this._actual >= this._expected, + '${actual} is greater than or equal to ${expected}.', + '${actual} is not greater than or equal to ${expected}.'); + } + + /** + * Check if |actual| is less than |expected|. + * + * @example + * should(1).beLessThan(2); + * + * @result + * "PASS 1 is less than 2." + */ + beLessThan() { + this._processArguments(arguments); + return this._assert( + this._actual < this._expected, '${actual} is less than ${expected}.', + '${actual} is not less than ${expected}.'); + } + + /** + * Check if |actual| is less than or equal to |expected|. + * + * @example + * should(1).beLessThanOrEqualTo(1); + * + * @result + * "PASS 1 is less than or equal to 1." + */ + beLessThanOrEqualTo() { + this._processArguments(arguments); + return this._assert( + this._actual <= this._expected, + '${actual} is less than or equal to ${expected}.', + '${actual} is not less than or equal to ${expected}.'); + } + + /** + * Check if |actual| array is filled with a constant |expected| value. + * + * @example + * should([1, 1, 1]).beConstantValueOf(1); + * + * @result + * "PASS [1,1,1] contains only the constant 1." + */ + beConstantValueOf() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let passed = true; + let passDetail, failDetail; + let errors = {}; + + let actual = this._actual; + let expected = this._expected; + for (let index = 0; index < actual.length; ++index) { + if (actual[index] !== expected) + errors[index] = actual[index]; + } + + let numberOfErrors = Object.keys(errors).length; + passed = numberOfErrors === 0; + + if (passed) { + passDetail = '${actual} contains only the constant ${expected}.'; + } else { + let counter = 0; + failDetail = + '${actual}: Expected ${expected} for all values but found ' + + numberOfErrors + ' unexpected values: '; + failDetail += '\n\tIndex\tActual'; + for (let errorIndex in errors) { + failDetail += '\n\t[' + errorIndex + ']' + + '\t' + errors[errorIndex]; + if (++counter >= this._options.numberOfErrors) { + failDetail += + '\n\t...and ' + (numberOfErrors - counter) + ' more errors.'; + break; + } + } + } + + return this._assert(passed, passDetail, failDetail); + } + + /** + * Check if |actual| array is not filled with a constant |expected| value. + * + * @example + * should([1, 0, 1]).notBeConstantValueOf(1); + * should([0, 0, 0]).notBeConstantValueOf(0); + * + * @result + * "PASS [1,0,1] is not constantly 1 (contains 1 different value)." + * "FAIL X [0,0,0] should have contain at least one value different + * from 0." + */ + notBeConstantValueOf() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let passed = true; + let passDetail; + let failDetail; + let differences = {}; + + let actual = this._actual; + let expected = this._expected; + for (let index = 0; index < actual.length; ++index) { + if (actual[index] !== expected) + differences[index] = actual[index]; + } + + let numberOfDifferences = Object.keys(differences).length; + passed = numberOfDifferences > 0; + + if (passed) { + let valueString = numberOfDifferences > 1 ? 'values' : 'value'; + passDetail = '${actual} is not constantly ${expected} (contains ' + + numberOfDifferences + ' different ' + valueString + ').'; + } else { + failDetail = '${actual} should have contain at least one value ' + + 'different from ${expected}.'; + } + + return this._assert(passed, passDetail, failDetail); + } + + /** + * Check if |actual| array is identical to |expected| array element-wise. + * + * @example + * should([1, 2, 3]).beEqualToArray([1, 2, 3]); + * + * @result + * "[1,2,3] is identical to the array [1,2,3]." + */ + beEqualToArray() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let passed = true; + let passDetail, failDetail; + let errorIndices = []; + + if (this._actual.length !== this._expected.length) { + passed = false; + failDetail = 'The array length does not match.'; + return this._assert(passed, passDetail, failDetail); + } + + let actual = this._actual; + let expected = this._expected; + for (let index = 0; index < actual.length; ++index) { + if (actual[index] !== expected[index]) + errorIndices.push(index); + } + + passed = errorIndices.length === 0; + + if (passed) { + passDetail = '${actual} is identical to the array ${expected}.'; + } else { + let counter = 0; + failDetail = + '${actual} expected to be equal to the array ${expected} ' + + 'but differs in ' + errorIndices.length + ' places:' + + '\n\tIndex\tActual\t\t\tExpected'; + for (let index of errorIndices) { + failDetail += '\n\t[' + index + ']' + + '\t' + this._actual[index].toExponential(16) + '\t' + + this._expected[index].toExponential(16); + if (++counter >= this._options.numberOfErrors) { + failDetail += '\n\t...and ' + (errorIndices.length - counter) + + ' more errors.'; + break; + } + } + } + + return this._assert(passed, passDetail, failDetail); + } + + /** + * Check if |actual| array contains only the values in |expected| in the + * order of values in |expected|. + * + * @example + * Should([1, 1, 3, 3, 2], 'My random array').containValues([1, 3, 2]); + * + * @result + * "PASS [1,1,3,3,2] contains all the expected values in the correct + * order: [1,3,2]. + */ + containValues() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let passed = true; + let indexedActual = []; + let firstErrorIndex = null; + + // Collect the unique value sequence from the actual. + for (let i = 0, prev = null; i < this._actual.length; i++) { + if (this._actual[i] !== prev) { + indexedActual.push({index: i, value: this._actual[i]}); + prev = this._actual[i]; + } + } + + // Compare against the expected sequence. + let failMessage = + '${actual} expected to have the value sequence of ${expected} but ' + + 'got '; + if (this._expected.length === indexedActual.length) { + for (let j = 0; j < this._expected.length; j++) { + if (this._expected[j] !== indexedActual[j].value) { + firstErrorIndex = indexedActual[j].index; + passed = false; + failMessage += this._actual[firstErrorIndex] + ' at index ' + + firstErrorIndex + '.'; + break; + } + } + } else { + passed = false; + let indexedValues = indexedActual.map(x => x.value); + failMessage += `${indexedActual.length} values, [${ + indexedValues}], instead of ${this._expected.length}.`; + } + + return this._assert( + passed, + '${actual} contains all the expected values in the correct order: ' + + '${expected}.', + failMessage); + } + + /** + * Check if |actual| array does not have any glitches. Note that |threshold| + * is not optional and is to define the desired threshold value. + * + * @example + * should([0.5, 0.5, 0.55, 0.5, 0.45, 0.5]).notGlitch(0.06); + * + * @result + * "PASS [0.5,0.5,0.55,0.5,0.45,0.5] has no glitch above the threshold + * of 0.06." + * + */ + notGlitch() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let passed = true; + let passDetail, failDetail; + + let actual = this._actual; + let expected = this._expected; + for (let index = 0; index < actual.length; ++index) { + let diff = Math.abs(actual[index - 1] - actual[index]); + if (diff >= expected) { + passed = false; + failDetail = '${actual} has a glitch at index ' + index + + ' of size ' + diff + '.'; + } + } + + passDetail = + '${actual} has no glitch above the threshold of ${expected}.'; + + return this._assert(passed, passDetail, failDetail); + } + + /** + * Check if |actual| is close to |expected| using the given relative error + * |threshold|. + * + * @example + * should(2.3).beCloseTo(2, { threshold: 0.3 }); + * + * @result + * "PASS 2.3 is 2 within an error of 0.3." + * @param {Object} options Options for assertion. + * @param {Number} options.threshold Threshold value for the comparison. + */ + beCloseTo() { + this._processArguments(arguments); + + // The threshold is relative except when |expected| is zero, in which case + // it is absolute. + let absExpected = this._expected ? Math.abs(this._expected) : 1; + let error = Math.abs(this._actual - this._expected) / absExpected; + + return this._assert( + error <= this._options.threshold, + '${actual} is ${expected} within an error of ${threshold}.', + '${actual} is not close to ${expected} within a relative error of ' + + '${threshold} (RelErr=' + error + ').'); + } + + /** + * Check if |target| array is close to |expected| array element-wise within + * a certain error bound given by the |options|. + * + * The error criterion is: + * abs(actual[k] - expected[k]) < max(absErr, relErr * abs(expected)) + * + * If nothing is given for |options|, then absErr = relErr = 0. If + * absErr = 0, then the error criterion is a relative error. A non-zero + * absErr value produces a mix intended to handle the case where the + * expected value is 0, allowing the target value to differ by absErr from + * the expected. + * + * @param {Number} options.absoluteThreshold Absolute threshold. + * @param {Number} options.relativeThreshold Relative threshold. + */ + beCloseToArray() { + this._processArguments(arguments); + this._printActualForFailure = false; + + let passed = true; + let passDetail, failDetail; + + // Parsing options. + let absErrorThreshold = (this._options.absoluteThreshold || 0); + let relErrorThreshold = (this._options.relativeThreshold || 0); + + // A collection of all of the values that satisfy the error criterion. + // This holds the absolute difference between the target element and the + // expected element. + let errors = {}; + + // Keep track of the max absolute error found. + let maxAbsError = -Infinity, maxAbsErrorIndex = -1; + + // Keep track of the max relative error found, ignoring cases where the + // relative error is Infinity because the expected value is 0. + let maxRelError = -Infinity, maxRelErrorIndex = -1; + + let actual = this._actual; + let expected = this._expected; + + for (let index = 0; index < expected.length; ++index) { + let diff = Math.abs(actual[index] - expected[index]); + let absExpected = Math.abs(expected[index]); + let relError = diff / absExpected; + + if (diff > + Math.max(absErrorThreshold, relErrorThreshold * absExpected)) { + if (diff > maxAbsError) { + maxAbsErrorIndex = index; + maxAbsError = diff; + } + + if (!isNaN(relError) && relError > maxRelError) { + maxRelErrorIndex = index; + maxRelError = relError; + } + + errors[index] = diff; + } + } + + let numberOfErrors = Object.keys(errors).length; + let maxAllowedErrorDetail = JSON.stringify({ + absoluteThreshold: absErrorThreshold, + relativeThreshold: relErrorThreshold + }); + + if (numberOfErrors === 0) { + // The assertion was successful. + passDetail = '${actual} equals ${expected} with an element-wise ' + + 'tolerance of ' + maxAllowedErrorDetail + '.'; + } else { + // Failed. Prepare the detailed failure log. + passed = false; + failDetail = '${actual} does not equal ${expected} with an ' + + 'element-wise tolerance of ' + maxAllowedErrorDetail + '.\n'; + + // Print out actual, expected, absolute error, and relative error. + let counter = 0; + failDetail += '\tIndex\tActual\t\t\tExpected\t\tAbsError' + + '\t\tRelError\t\tTest threshold'; + let printedIndices = []; + for (let index in errors) { + failDetail += + '\n' + + _formatFailureEntry( + index, actual[index], expected[index], errors[index], + _closeToThreshold( + absErrorThreshold, relErrorThreshold, expected[index])); + + printedIndices.push(index); + if (++counter > this._options.numberOfErrors) { + failDetail += + '\n\t...and ' + (numberOfErrors - counter) + ' more errors.'; + break; + } + } + + // Finalize the error log: print out the location of both the maxAbs + // error and the maxRel error so we can adjust thresholds appropriately + // in the test. + failDetail += '\n' + + '\tMax AbsError of ' + maxAbsError.toExponential(16) + + ' at index of ' + maxAbsErrorIndex + '.\n'; + if (printedIndices.find(element => { + return element == maxAbsErrorIndex; + }) === undefined) { + // Print an entry for this index if we haven't already. + failDetail += + _formatFailureEntry( + maxAbsErrorIndex, actual[maxAbsErrorIndex], + expected[maxAbsErrorIndex], errors[maxAbsErrorIndex], + _closeToThreshold( + absErrorThreshold, relErrorThreshold, + expected[maxAbsErrorIndex])) + + '\n'; + } + failDetail += '\tMax RelError of ' + maxRelError.toExponential(16) + + ' at index of ' + maxRelErrorIndex + '.\n'; + if (printedIndices.find(element => { + return element == maxRelErrorIndex; + }) === undefined) { + // Print an entry for this index if we haven't already. + failDetail += + _formatFailureEntry( + maxRelErrorIndex, actual[maxRelErrorIndex], + expected[maxRelErrorIndex], errors[maxRelErrorIndex], + _closeToThreshold( + absErrorThreshold, relErrorThreshold, + expected[maxRelErrorIndex])) + + '\n'; + } + } + + return this._assert(passed, passDetail, failDetail); + } + + /** + * A temporary escape hat for printing an in-task message. The description + * for the |actual| is required to get the message printed properly. + * + * TODO(hongchan): remove this method when the transition from the old Audit + * to the new Audit is completed. + * @example + * should(true, 'The message is').message('truthful!', 'false!'); + * + * @result + * "PASS The message is truthful!" + */ + message(passDetail, failDetail) { + return this._assert( + this._actual, '${actual} ' + passDetail, '${actual} ' + failDetail); + } + + /** + * Check if |expected| property is truly owned by |actual| object. + * + * @example + * should(BaseAudioContext.prototype, + * 'BaseAudioContext.prototype').haveOwnProperty('createGain'); + * + * @result + * "PASS BaseAudioContext.prototype has an own property of + * 'createGain'." + */ + haveOwnProperty() { + this._processArguments(arguments); + + return this._assert( + this._actual.hasOwnProperty(this._expected), + '${actual} has an own property of "${expected}".', + '${actual} does not own the property of "${expected}".'); + } + + + /** + * Check if |expected| property is not owned by |actual| object. + * + * @example + * should(BaseAudioContext.prototype, + * 'BaseAudioContext.prototype') + * .notHaveOwnProperty('startRendering'); + * + * @result + * "PASS BaseAudioContext.prototype does not have an own property of + * 'startRendering'." + */ + notHaveOwnProperty() { + this._processArguments(arguments); + + return this._assert( + !this._actual.hasOwnProperty(this._expected), + '${actual} does not have an own property of "${expected}".', + '${actual} has an own the property of "${expected}".') + } + + + /** + * Check if an object is inherited from a class. This looks up the entire + * prototype chain of a given object and tries to find a match. + * + * @example + * should(sourceNode, 'A buffer source node') + * .inheritFrom('AudioScheduledSourceNode'); + * + * @result + * "PASS A buffer source node inherits from 'AudioScheduledSourceNode'." + */ + inheritFrom() { + this._processArguments(arguments); + + let prototypes = []; + let currentPrototype = Object.getPrototypeOf(this._actual); + while (currentPrototype) { + prototypes.push(currentPrototype.constructor.name); + currentPrototype = Object.getPrototypeOf(currentPrototype); + } + + return this._assert( + prototypes.includes(this._expected), + '${actual} inherits from "${expected}".', + '${actual} does not inherit from "${expected}".'); + } + } + + + // Task Class state enum. + const TaskState = {PENDING: 0, STARTED: 1, FINISHED: 2}; + + + /** + * @class Task + * @description WebAudio testing task. Managed by TaskRunner. + */ + class Task { + /** + * Task constructor. + * @param {Object} taskRunner Reference of associated task runner. + * @param {String||Object} taskLabel Task label if a string is given. This + * parameter can be a dictionary with the + * following fields. + * @param {String} taskLabel.label Task label. + * @param {String} taskLabel.description Description of task. + * @param {Function} taskFunction Task function to be performed. + * @return {Object} Task object. + */ + constructor(taskRunner, taskLabel, taskFunction) { + this._taskRunner = taskRunner; + this._taskFunction = taskFunction; + + if (typeof taskLabel === 'string') { + this._label = taskLabel; + this._description = null; + } else if (typeof taskLabel === 'object') { + if (typeof taskLabel.label !== 'string') { + _throwException('Task.constructor:: task label must be string.'); + } + this._label = taskLabel.label; + this._description = (typeof taskLabel.description === 'string') ? + taskLabel.description : + null; + } else { + _throwException( + 'Task.constructor:: task label must be a string or ' + + 'a dictionary.'); + } + + this._state = TaskState.PENDING; + this._result = true; + + this._totalAssertions = 0; + this._failedAssertions = 0; + } + + get label() { + return this._label; + } + + get state() { + return this._state; + } + + get result() { + return this._result; + } + + // Start the assertion chain. + should(actual, actualDescription) { + // If no argument is given, we cannot proceed. Halt. + if (arguments.length === 0) + _throwException('Task.should:: requires at least 1 argument.'); + + return new Should(this, actual, actualDescription); + } + + // Run this task. |this| task will be passed into the user-supplied test + // task function. + run(harnessTest) { + this._state = TaskState.STARTED; + this._harnessTest = harnessTest; + // Print out the task entry with label and description. + _logPassed( + '> [' + this._label + '] ' + + (this._description ? this._description : '')); + + return new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + let result = this._taskFunction(this, this.should.bind(this)); + if (result && typeof result.then === "function") { + result.then(() => this.done()).catch(reject); + } + }); + } + + // Update the task success based on the individual assertion/test inside. + update(subTask) { + // After one of tests fails within a task, the result is irreversible. + if (subTask.result === false) { + this._result = false; + this._failedAssertions++; + } + + this._totalAssertions++; + } + + // Finish the current task and start the next one if available. + done() { + assert_equals(this._state, TaskState.STARTED) + this._state = TaskState.FINISHED; + + let message = '< [' + this._label + '] '; + + if (this._result) { + message += 'All assertions passed. (total ' + this._totalAssertions + + ' assertions)'; + _logPassed(message); + } else { + message += this._failedAssertions + ' out of ' + this._totalAssertions + + ' assertions were failed.' + _logFailed(message); + } + + this._resolve(); + } + + // Runs |subTask| |time| milliseconds later. |setTimeout| is not allowed in + // WPT linter, so a thin wrapper around the harness's |step_timeout| is + // used here. Returns a Promise which is resolved after |subTask| runs. + timeout(subTask, time) { + return new Promise(resolve => { + this._harnessTest.step_timeout(() => { + let result = subTask(); + if (result && typeof result.then === "function") { + // Chain rejection directly to the harness test Promise, to report + // the rejection against the subtest even when the caller of + // timeout does not handle the rejection. + result.then(resolve, this._reject()); + } else { + resolve(); + } + }, time); + }); + } + + isPassed() { + return this._state === TaskState.FINISHED && this._result; + } + + toString() { + return '"' + this._label + '": ' + this._description; + } + } + + + /** + * @class TaskRunner + * @description WebAudio testing task runner. Manages tasks. + */ + class TaskRunner { + constructor() { + this._tasks = {}; + this._taskSequence = []; + + // Configure testharness.js for the async operation. + setup(new Function(), {explicit_done: true}); + } + + _finish() { + let numberOfFailures = 0; + for (let taskIndex in this._taskSequence) { + let task = this._tasks[this._taskSequence[taskIndex]]; + numberOfFailures += task.result ? 0 : 1; + } + + let prefix = '# AUDIT TASK RUNNER FINISHED: '; + if (numberOfFailures > 0) { + _logFailed( + prefix + numberOfFailures + ' out of ' + this._taskSequence.length + + ' tasks were failed.'); + } else { + _logPassed( + prefix + this._taskSequence.length + ' tasks ran successfully.'); + } + + return Promise.resolve(); + } + + // |taskLabel| can be either a string or a dictionary. See Task constructor + // for the detail. If |taskFunction| returns a thenable, then the task + // is considered complete when the thenable is fulfilled; otherwise the + // task must be completed with an explicit call to |task.done()|. + define(taskLabel, taskFunction) { + let task = new Task(this, taskLabel, taskFunction); + if (this._tasks.hasOwnProperty(task.label)) { + _throwException('Audit.define:: Duplicate task definition.'); + return; + } + this._tasks[task.label] = task; + this._taskSequence.push(task.label); + } + + // Start running all the tasks scheduled. Multiple task names can be passed + // to execute them sequentially. Zero argument will perform all defined + // tasks in the order of definition. + run() { + // Display the beginning of the test suite. + _logPassed('# AUDIT TASK RUNNER STARTED.'); + + // If the argument is specified, override the default task sequence with + // the specified one. + if (arguments.length > 0) { + this._taskSequence = []; + for (let i = 0; i < arguments.length; i++) { + let taskLabel = arguments[i]; + if (!this._tasks.hasOwnProperty(taskLabel)) { + _throwException('Audit.run:: undefined task.'); + } else if (this._taskSequence.includes(taskLabel)) { + _throwException('Audit.run:: duplicate task request.'); + } else { + this._taskSequence.push(taskLabel); + } + } + } + + if (this._taskSequence.length === 0) { + _throwException('Audit.run:: no task to run.'); + return; + } + + for (let taskIndex in this._taskSequence) { + let task = this._tasks[this._taskSequence[taskIndex]]; + // Some tests assume that tasks run in sequence, which is provided by + // promise_test(). + promise_test((t) => task.run(t), `Executing "${task.label}"`); + } + + // Schedule a summary report on completion. + promise_test(() => this._finish(), "Audit report"); + + // From testharness.js. The harness now need not wait for more subtests + // to be added. + _testharnessDone(); + } + } + + /** + * Load file from a given URL and pass ArrayBuffer to the following promise. + * @param {String} fileUrl file URL. + * @return {Promise} + * + * @example + * Audit.loadFileFromUrl('resources/my-sound.ogg').then((response) => { + * audioContext.decodeAudioData(response).then((audioBuffer) => { + * // Do something with AudioBuffer. + * }); + * }); + */ + function loadFileFromUrl(fileUrl) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open('GET', fileUrl, true); + xhr.responseType = 'arraybuffer'; + + xhr.onload = () => { + // |status = 0| is a workaround for the run_web_test.py server. We are + // speculating the server quits the transaction prematurely without + // completing the request. + if (xhr.status === 200 || xhr.status === 0) { + resolve(xhr.response); + } else { + let errorMessage = 'loadFile: Request failed when loading ' + + fileUrl + '. ' + xhr.statusText + '. (status = ' + xhr.status + + ')'; + if (reject) { + reject(errorMessage); + } else { + new Error(errorMessage); + } + } + }; + + xhr.onerror = (event) => { + let errorMessage = + 'loadFile: Network failure when loading ' + fileUrl + '.'; + if (reject) { + reject(errorMessage); + } else { + new Error(errorMessage); + } + }; + + xhr.send(); + }); + } + + /** + * @class Audit + * @description A WebAudio layout test task manager. + * @example + * let audit = Audit.createTaskRunner(); + * audit.define('first-task', function (task, should) { + * should(someValue).beEqualTo(someValue); + * task.done(); + * }); + * audit.run(); + */ + return { + + /** + * Creates an instance of Audit task runner. + * @param {Object} options Options for task runner. + * @param {Boolean} options.requireResultFile True if the test suite + * requires explicit text + * comparison with the expected + * result file. + */ + createTaskRunner: function(options) { + if (options && options.requireResultFile == true) { + _logError( + 'this test requires the explicit comparison with the ' + + 'expected result when it runs with run_web_tests.py.'); + } + + return new TaskRunner(); + }, + + /** + * Load file from a given URL and pass ArrayBuffer to the following promise. + * See |loadFileFromUrl| method for the detail. + */ + loadFileFromUrl: loadFileFromUrl + + }; + +})(); diff --git a/testing/web-platform/tests/webaudio/resources/biquad-filters.js b/testing/web-platform/tests/webaudio/resources/biquad-filters.js new file mode 100644 index 0000000000..467436326a --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/biquad-filters.js @@ -0,0 +1,376 @@ +// A biquad filter has a z-transform of +// H(z) = (b0 + b1 / z + b2 / z^2) / (1 + a1 / z + a2 / z^2) +// +// The formulas for the various filters were taken from +// http://www.musicdsp.org/files/Audio-EQ-Cookbook.txt. + + +// Lowpass filter. +function createLowpassFilter(freq, q, gain) { + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + + if (freq == 1) { + // The formula below works, except for roundoff. When freq = 1, + // the filter is just a wire, so hardwire the coefficients. + b0 = 1; + b1 = 0; + b2 = 0; + a0 = 1; + a1 = 0; + a2 = 0; + } else { + let theta = Math.PI * freq; + let alpha = Math.sin(theta) / (2 * Math.pow(10, q / 20)); + let cosw = Math.cos(theta); + let beta = (1 - cosw) / 2; + + b0 = beta; + b1 = 2 * beta; + b2 = beta; + a0 = 1 + alpha; + a1 = -2 * cosw; + a2 = 1 - alpha; + } + + return normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); +} + +function createHighpassFilter(freq, q, gain) { + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + + if (freq == 1) { + // The filter is 0 + b0 = 0; + b1 = 0; + b2 = 0; + a0 = 1; + a1 = 0; + a2 = 0; + } else if (freq == 0) { + // The filter is 1. Computation of coefficients below is ok, but + // there's a pole at 1 and a zero at 1, so round-off could make + // the filter unstable. + b0 = 1; + b1 = 0; + b2 = 0; + a0 = 1; + a1 = 0; + a2 = 0; + } else { + let theta = Math.PI * freq; + let alpha = Math.sin(theta) / (2 * Math.pow(10, q / 20)); + let cosw = Math.cos(theta); + let beta = (1 + cosw) / 2; + + b0 = beta; + b1 = -2 * beta; + b2 = beta; + a0 = 1 + alpha; + a1 = -2 * cosw; + a2 = 1 - alpha; + } + + return normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); +} + +function normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2) { + let scale = 1 / a0; + + return { + b0: b0 * scale, + b1: b1 * scale, + b2: b2 * scale, + a1: a1 * scale, + a2: a2 * scale + }; +} + +function createBandpassFilter(freq, q, gain) { + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + let coef; + + if (freq > 0 && freq < 1) { + let w0 = Math.PI * freq; + if (q > 0) { + let alpha = Math.sin(w0) / (2 * q); + let k = Math.cos(w0); + + b0 = alpha; + b1 = 0; + b2 = -alpha; + a0 = 1 + alpha; + a1 = -2 * k; + a2 = 1 - alpha; + + coef = normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); + } else { + // q = 0, and frequency is not 0 or 1. The above formula has a + // divide by zero problem. The limit of the z-transform as q + // approaches 0 is 1, so set the filter that way. + coef = {b0: 1, b1: 0, b2: 0, a1: 0, a2: 0}; + } + } else { + // When freq = 0 or 1, the z-transform is identically 0, + // independent of q. + coef = { b0: 0, b1: 0, b2: 0, a1: 0, a2: 0 } + } + + return coef; +} + +function createLowShelfFilter(freq, q, gain) { + // q not used + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + let coef; + + let S = 1; + let A = Math.pow(10, gain / 40); + + if (freq == 1) { + // The filter is just a constant gain + coef = {b0: A * A, b1: 0, b2: 0, a1: 0, a2: 0}; + } else if (freq == 0) { + // The filter is 1 + coef = {b0: 1, b1: 0, b2: 0, a1: 0, a2: 0}; + } else { + let w0 = Math.PI * freq; + let alpha = 1 / 2 * Math.sin(w0) * Math.sqrt((A + 1 / A) * (1 / S - 1) + 2); + let k = Math.cos(w0); + let k2 = 2 * Math.sqrt(A) * alpha; + let Ap1 = A + 1; + let Am1 = A - 1; + + b0 = A * (Ap1 - Am1 * k + k2); + b1 = 2 * A * (Am1 - Ap1 * k); + b2 = A * (Ap1 - Am1 * k - k2); + a0 = Ap1 + Am1 * k + k2; + a1 = -2 * (Am1 + Ap1 * k); + a2 = Ap1 + Am1 * k - k2; + coef = normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); + } + + return coef; +} + +function createHighShelfFilter(freq, q, gain) { + // q not used + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + let coef; + + let A = Math.pow(10, gain / 40); + + if (freq == 1) { + // When freq = 1, the z-transform is 1 + coef = {b0: 1, b1: 0, b2: 0, a1: 0, a2: 0}; + } else if (freq > 0) { + let w0 = Math.PI * freq; + let S = 1; + let alpha = 0.5 * Math.sin(w0) * Math.sqrt((A + 1 / A) * (1 / S - 1) + 2); + let k = Math.cos(w0); + let k2 = 2 * Math.sqrt(A) * alpha; + let Ap1 = A + 1; + let Am1 = A - 1; + + b0 = A * (Ap1 + Am1 * k + k2); + b1 = -2 * A * (Am1 + Ap1 * k); + b2 = A * (Ap1 + Am1 * k - k2); + a0 = Ap1 - Am1 * k + k2; + a1 = 2 * (Am1 - Ap1 * k); + a2 = Ap1 - Am1 * k - k2; + + coef = normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); + } else { + // When freq = 0, the filter is just a gain + coef = {b0: A * A, b1: 0, b2: 0, a1: 0, a2: 0}; + } + + return coef; +} + +function createPeakingFilter(freq, q, gain) { + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + let coef; + + let A = Math.pow(10, gain / 40); + + if (freq > 0 && freq < 1) { + if (q > 0) { + let w0 = Math.PI * freq; + let alpha = Math.sin(w0) / (2 * q); + let k = Math.cos(w0); + + b0 = 1 + alpha * A; + b1 = -2 * k; + b2 = 1 - alpha * A; + a0 = 1 + alpha / A; + a1 = -2 * k; + a2 = 1 - alpha / A; + + coef = normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); + } else { + // q = 0, we have a divide by zero problem in the formulas + // above. But if we look at the z-transform, we see that the + // limit as q approaches 0 is A^2. + coef = {b0: A * A, b1: 0, b2: 0, a1: 0, a2: 0}; + } + } else { + // freq = 0 or 1, the z-transform is 1 + coef = {b0: 1, b1: 0, b2: 0, a1: 0, a2: 0}; + } + + return coef; +} + +function createNotchFilter(freq, q, gain) { + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + let coef; + + if (freq > 0 && freq < 1) { + if (q > 0) { + let w0 = Math.PI * freq; + let alpha = Math.sin(w0) / (2 * q); + let k = Math.cos(w0); + + b0 = 1; + b1 = -2 * k; + b2 = 1; + a0 = 1 + alpha; + a1 = -2 * k; + a2 = 1 - alpha; + coef = normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); + } else { + // When q = 0, we get a divide by zero above. The limit of the + // z-transform as q approaches 0 is 0, so set the coefficients + // appropriately. + coef = {b0: 0, b1: 0, b2: 0, a1: 0, a2: 0}; + } + } else { + // When freq = 0 or 1, the z-transform is 1 + coef = {b0: 1, b1: 0, b2: 0, a1: 0, a2: 0}; + } + + return coef; +} + +function createAllpassFilter(freq, q, gain) { + let b0; + let b1; + let b2; + let a0; + let a1; + let a2; + let coef; + + if (freq > 0 && freq < 1) { + if (q > 0) { + let w0 = Math.PI * freq; + let alpha = Math.sin(w0) / (2 * q); + let k = Math.cos(w0); + + b0 = 1 - alpha; + b1 = -2 * k; + b2 = 1 + alpha; + a0 = 1 + alpha; + a1 = -2 * k; + a2 = 1 - alpha; + coef = normalizeFilterCoefficients(b0, b1, b2, a0, a1, a2); + } else { + // q = 0 + coef = {b0: -1, b1: 0, b2: 0, a1: 0, a2: 0}; + } + } else { + coef = {b0: 1, b1: 0, b2: 0, a1: 0, a2: 0}; + } + + return coef; +} + +function filterData(filterCoef, signal, len) { + let y = new Array(len); + let b0 = filterCoef.b0; + let b1 = filterCoef.b1; + let b2 = filterCoef.b2; + let a1 = filterCoef.a1; + let a2 = filterCoef.a2; + + // Prime the pump. (Assumes the signal has length >= 2!) + y[0] = b0 * signal[0]; + y[1] = b0 * signal[1] + b1 * signal[0] - a1 * y[0]; + + // Filter all of the signal that we have. + for (let k = 2; k < Math.min(signal.length, len); ++k) { + y[k] = b0 * signal[k] + b1 * signal[k - 1] + b2 * signal[k - 2] - + a1 * y[k - 1] - a2 * y[k - 2]; + } + + // If we need to filter more, but don't have any signal left, + // assume the signal is zero. + for (let k = signal.length; k < len; ++k) { + y[k] = -a1 * y[k - 1] - a2 * y[k - 2]; + } + + return y; +} + +// Map the filter type name to a function that computes the filter coefficents +// for the given filter type. +let filterCreatorFunction = { + 'lowpass': createLowpassFilter, + 'highpass': createHighpassFilter, + 'bandpass': createBandpassFilter, + 'lowshelf': createLowShelfFilter, + 'highshelf': createHighShelfFilter, + 'peaking': createPeakingFilter, + 'notch': createNotchFilter, + 'allpass': createAllpassFilter +}; + +let filterTypeName = { + 'lowpass': 'Lowpass filter', + 'highpass': 'Highpass filter', + 'bandpass': 'Bandpass filter', + 'lowshelf': 'Lowshelf filter', + 'highshelf': 'Highshelf filter', + 'peaking': 'Peaking filter', + 'notch': 'Notch filter', + 'allpass': 'Allpass filter' +}; + +function createFilter(filterType, freq, q, gain) { + return filterCreatorFunction[filterType](freq, q, gain); +} diff --git a/testing/web-platform/tests/webaudio/resources/biquad-testing.js b/testing/web-platform/tests/webaudio/resources/biquad-testing.js new file mode 100644 index 0000000000..7f90a1f72b --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/biquad-testing.js @@ -0,0 +1,172 @@ +// Globals, to make testing and debugging easier. +let context; +let filter; +let signal; +let renderedBuffer; +let renderedData; + +// Use a power of two to eliminate round-off in converting frame to time +let sampleRate = 32768; +let pulseLengthFrames = .1 * sampleRate; + +// Maximum allowed error for the test to succeed. Experimentally determined. +let maxAllowedError = 5.9e-8; + +// This must be large enough so that the filtered result is essentially zero. +// See comments for createTestAndRun. This must be a whole number of frames. +let timeStep = Math.ceil(.1 * sampleRate) / sampleRate; + +// Maximum number of filters we can process (mostly for setting the +// render length correctly.) +let maxFilters = 5; + +// How long to render. Must be long enough for all of the filters we +// want to test. +let renderLengthSeconds = timeStep * (maxFilters + 1); + +let renderLengthSamples = Math.round(renderLengthSeconds * sampleRate); + +// Number of filters that will be processed. +let nFilters; + +function createImpulseBuffer(context, length) { + let impulse = context.createBuffer(1, length, context.sampleRate); + let data = impulse.getChannelData(0); + for (let k = 1; k < data.length; ++k) { + data[k] = 0; + } + data[0] = 1; + + return impulse; +} + + +function createTestAndRun(context, filterType, testParameters) { + // To test the filters, we apply a signal (an impulse) to each of + // the specified filters, with each signal starting at a different + // time. The output of the filters is summed together at the + // output. Thus for filter k, the signal input to the filter + // starts at time k * timeStep. For this to work well, timeStep + // must be large enough for the output of each filter to have + // decayed to zero with timeStep seconds. That way the filter + // outputs don't interfere with each other. + + let filterParameters = testParameters.filterParameters; + nFilters = Math.min(filterParameters.length, maxFilters); + + signal = new Array(nFilters); + filter = new Array(nFilters); + + impulse = createImpulseBuffer(context, pulseLengthFrames); + + // Create all of the signal sources and filters that we need. + for (let k = 0; k < nFilters; ++k) { + signal[k] = context.createBufferSource(); + signal[k].buffer = impulse; + + filter[k] = context.createBiquadFilter(); + filter[k].type = filterType; + filter[k].frequency.value = + context.sampleRate / 2 * filterParameters[k].cutoff; + filter[k].detune.value = (filterParameters[k].detune === undefined) ? + 0 : + filterParameters[k].detune; + filter[k].Q.value = filterParameters[k].q; + filter[k].gain.value = filterParameters[k].gain; + + signal[k].connect(filter[k]); + filter[k].connect(context.destination); + + signal[k].start(timeStep * k); + } + + return context.startRendering().then(buffer => { + checkFilterResponse(buffer, filterType, testParameters); + }); +} + +function addSignal(dest, src, destOffset) { + // Add src to dest at the given dest offset. + for (let k = destOffset, j = 0; k < dest.length, j < src.length; ++k, ++j) { + dest[k] += src[j]; + } +} + +function generateReference(filterType, filterParameters) { + let result = new Array(renderLengthSamples); + let data = new Array(renderLengthSamples); + // Initialize the result array and data. + for (let k = 0; k < result.length; ++k) { + result[k] = 0; + data[k] = 0; + } + // Make data an impulse. + data[0] = 1; + + for (let k = 0; k < nFilters; ++k) { + // Filter an impulse + let detune = (filterParameters[k].detune === undefined) ? + 0 : + filterParameters[k].detune; + let frequency = filterParameters[k].cutoff * + Math.pow(2, detune / 1200); // Apply detune, converting from Cents. + + let filterCoef = createFilter( + filterType, frequency, filterParameters[k].q, filterParameters[k].gain); + let y = filterData(filterCoef, data, renderLengthSamples); + + // Accumulate this filtered data into the final output at the desired + // offset. + addSignal(result, y, timeToSampleFrame(timeStep * k, sampleRate)); + } + + return result; +} + +function checkFilterResponse(renderedBuffer, filterType, testParameters) { + let filterParameters = testParameters.filterParameters; + let maxAllowedError = testParameters.threshold; + let should = testParameters.should; + + renderedData = renderedBuffer.getChannelData(0); + + reference = generateReference(filterType, filterParameters); + + let len = Math.min(renderedData.length, reference.length); + + let success = true; + + // Maximum error between rendered data and expected data + let maxError = 0; + + // Sample offset where the maximum error occurred. + let maxPosition = 0; + + // Number of infinities or NaNs that occurred in the rendered data. + let invalidNumberCount = 0; + + should(nFilters, 'Number of filters tested') + .beEqualTo(filterParameters.length); + + // Compare the rendered signal with our reference, keeping + // track of the maximum difference (and the offset of the max + // difference.) Check for bad numbers in the rendered output + // too. There shouldn't be any. + for (let k = 0; k < len; ++k) { + let err = Math.abs(renderedData[k] - reference[k]); + if (err > maxError) { + maxError = err; + maxPosition = k; + } + if (!isValidNumber(renderedData[k])) { + ++invalidNumberCount; + } + } + + should( + invalidNumberCount, 'Number of non-finite values in the rendered output') + .beEqualTo(0); + + should(maxError, 'Max error in ' + filterTypeName[filterType] + ' response') + .beLessThanOrEqualTo(maxAllowedError); +} diff --git a/testing/web-platform/tests/webaudio/resources/convolution-testing.js b/testing/web-platform/tests/webaudio/resources/convolution-testing.js new file mode 100644 index 0000000000..c976f86c78 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/convolution-testing.js @@ -0,0 +1,168 @@ +let sampleRate = 44100.0; + +let renderLengthSeconds = 8; +let pulseLengthSeconds = 1; +let pulseLengthFrames = pulseLengthSeconds * sampleRate; + +function createSquarePulseBuffer(context, sampleFrameLength) { + let audioBuffer = + context.createBuffer(1, sampleFrameLength, context.sampleRate); + + let n = audioBuffer.length; + let data = audioBuffer.getChannelData(0); + + for (let i = 0; i < n; ++i) + data[i] = 1; + + return audioBuffer; +} + +// The triangle buffer holds the expected result of the convolution. +// It linearly ramps up from 0 to its maximum value (at the center) +// then linearly ramps down to 0. The center value corresponds to the +// point where the two square pulses overlap the most. +function createTrianglePulseBuffer(context, sampleFrameLength) { + let audioBuffer = + context.createBuffer(1, sampleFrameLength, context.sampleRate); + + let n = audioBuffer.length; + let halfLength = n / 2; + let data = audioBuffer.getChannelData(0); + + for (let i = 0; i < halfLength; ++i) + data[i] = i + 1; + + for (let i = halfLength; i < n; ++i) + data[i] = n - i - 1; + + return audioBuffer; +} + +function log10(x) { + return Math.log(x) / Math.LN10; +} + +function linearToDecibel(x) { + return 20 * log10(x); +} + +// Verify that the rendered result is very close to the reference +// triangular pulse. +function checkTriangularPulse(rendered, reference, should) { + let match = true; + let maxDelta = 0; + let valueAtMaxDelta = 0; + let maxDeltaIndex = 0; + + for (let i = 0; i < reference.length; ++i) { + let diff = rendered[i] - reference[i]; + let x = Math.abs(diff); + if (x > maxDelta) { + maxDelta = x; + valueAtMaxDelta = reference[i]; + maxDeltaIndex = i; + } + } + + // allowedDeviationFraction was determined experimentally. It + // is the threshold of the relative error at the maximum + // difference between the true triangular pulse and the + // rendered pulse. + let allowedDeviationDecibels = -124.41; + let maxDeviationDecibels = linearToDecibel(maxDelta / valueAtMaxDelta); + + should( + maxDeviationDecibels, + 'Deviation (in dB) of triangular portion of convolution') + .beLessThanOrEqualTo(allowedDeviationDecibels); + + return match; +} + +// Verify that the rendered data is close to zero for the first part +// of the tail. +function checkTail1(data, reference, breakpoint, should) { + let isZero = true; + let tail1Max = 0; + + for (let i = reference.length; i < reference.length + breakpoint; ++i) { + let mag = Math.abs(data[i]); + if (mag > tail1Max) { + tail1Max = mag; + } + } + + // Let's find the peak of the reference (even though we know a + // priori what it is). + let refMax = 0; + for (let i = 0; i < reference.length; ++i) { + refMax = Math.max(refMax, Math.abs(reference[i])); + } + + // This threshold is experimentally determined by examining the + // value of tail1MaxDecibels. + let threshold1 = -129.7; + + let tail1MaxDecibels = linearToDecibel(tail1Max / refMax); + should(tail1MaxDecibels, 'Deviation in first part of tail of convolutions') + .beLessThanOrEqualTo(threshold1); + + return isZero; +} + +// Verify that the second part of the tail of the convolution is +// exactly zero. +function checkTail2(data, reference, breakpoint, should) { + let isZero = true; + let tail2Max = 0; + // For the second part of the tail, the maximum value should be + // exactly zero. + let threshold2 = 0; + for (let i = reference.length + breakpoint; i < data.length; ++i) { + if (Math.abs(data[i]) > 0) { + isZero = false; + break; + } + } + + should(isZero, 'Rendered signal after tail of convolution is silent') + .beTrue(); + + return isZero; +} + +function checkConvolvedResult(renderedBuffer, trianglePulse, should) { + let referenceData = trianglePulse.getChannelData(0); + let renderedData = renderedBuffer.getChannelData(0); + + let success = true; + + // Verify the triangular pulse is actually triangular. + + success = + success && checkTriangularPulse(renderedData, referenceData, should); + + // Make sure that portion after convolved portion is totally + // silent. But round-off prevents this from being completely + // true. At the end of the triangle, it should be close to + // zero. If we go farther out, it should be even closer and + // eventually zero. + + // For the tail of the convolution (where the result would be + // theoretically zero), we partition the tail into two + // parts. The first is the at the beginning of the tail, + // where we tolerate a small but non-zero value. The second part is + // farther along the tail where the result should be zero. + + // breakpoint is the point dividing the first two tail parts + // we're looking at. Experimentally determined. + let breakpoint = 12800; + + success = + success && checkTail1(renderedData, referenceData, breakpoint, should); + + success = + success && checkTail2(renderedData, referenceData, breakpoint, should); + + should(success, 'Test signal convolved').message('correctly', 'incorrectly'); +} diff --git a/testing/web-platform/tests/webaudio/resources/delay-testing.js b/testing/web-platform/tests/webaudio/resources/delay-testing.js new file mode 100644 index 0000000000..9033da6730 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/delay-testing.js @@ -0,0 +1,66 @@ +let sampleRate = 44100.0; + +let renderLengthSeconds = 4; +let delayTimeSeconds = 0.5; +let toneLengthSeconds = 2; + +function createToneBuffer(context, frequency, numberOfCycles, sampleRate) { + let duration = numberOfCycles / frequency; + let sampleFrameLength = duration * sampleRate; + + let audioBuffer = context.createBuffer(1, sampleFrameLength, sampleRate); + + let n = audioBuffer.length; + let data = audioBuffer.getChannelData(0); + + for (let i = 0; i < n; ++i) + data[i] = Math.sin(frequency * 2.0 * Math.PI * i / sampleRate); + + return audioBuffer; +} + +function checkDelayedResult(renderedBuffer, toneBuffer, should) { + let sourceData = toneBuffer.getChannelData(0); + let renderedData = renderedBuffer.getChannelData(0); + + let delayTimeFrames = delayTimeSeconds * sampleRate; + let toneLengthFrames = toneLengthSeconds * sampleRate; + + let success = true; + + let n = renderedBuffer.length; + + for (let i = 0; i < n; ++i) { + if (i < delayTimeFrames) { + // Check that initial portion is 0 (since signal is delayed). + if (renderedData[i] != 0) { + should( + renderedData[i], 'Initial portion expected to be 0 at frame ' + i) + .beEqualTo(0); + success = false; + break; + } + } else if (i >= delayTimeFrames && i < delayTimeFrames + toneLengthFrames) { + // Make sure that the tone data is delayed by exactly the expected number + // of frames. + let j = i - delayTimeFrames; + if (renderedData[i] != sourceData[j]) { + should(renderedData[i], 'Actual data at frame ' + i) + .beEqualTo(sourceData[j]); + success = false; + break; + } + } else { + // Make sure we have silence after the delayed tone. + if (renderedData[i] != 0) { + should(renderedData[j], 'Final portion at frame ' + i).beEqualTo(0); + success = false; + break; + } + } + } + + should( + success, 'Delaying test signal by ' + delayTimeSeconds + ' sec was done') + .message('correctly', 'incorrectly') +} diff --git a/testing/web-platform/tests/webaudio/resources/distance-model-testing.js b/testing/web-platform/tests/webaudio/resources/distance-model-testing.js new file mode 100644 index 0000000000..f8a6cf940a --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/distance-model-testing.js @@ -0,0 +1,196 @@ +// Use a power of two to eliminate round-off when converting frames to time and +// vice versa. +let sampleRate = 32768; + +// How many panner nodes to create for the test. +let nodesToCreate = 100; + +// Time step when each panner node starts. Make sure it starts on a frame +// boundary. +let timeStep = Math.floor(0.001 * sampleRate) / sampleRate; + +// Make sure we render long enough to get all of our nodes. +let renderLengthSeconds = timeStep * (nodesToCreate + 1); + +// Length of an impulse signal. +let pulseLengthFrames = Math.round(timeStep * sampleRate); + +// Globals to make debugging a little easier. +let context; +let impulse; +let bufferSource; +let panner; +let position; +let time; + +// For the record, these distance formulas were taken from the OpenAL +// spec +// (http://connect.creativelabs.com/openal/Documentation/OpenAL%201.1%20Specification.pdf), +// not the code. The Web Audio spec follows the OpenAL formulas. + +function linearDistance(panner, x, y, z) { + let distance = Math.sqrt(x * x + y * y + z * z); + distance = Math.min(distance, panner.maxDistance); + let rolloff = panner.rolloffFactor; + let gain = + (1 - + rolloff * (distance - panner.refDistance) / + (panner.maxDistance - panner.refDistance)); + + return gain; +} + +function inverseDistance(panner, x, y, z) { + let distance = Math.sqrt(x * x + y * y + z * z); + distance = Math.min(distance, panner.maxDistance); + let rolloff = panner.rolloffFactor; + let gain = panner.refDistance / + (panner.refDistance + rolloff * (distance - panner.refDistance)); + + return gain; +} + +function exponentialDistance(panner, x, y, z) { + let distance = Math.sqrt(x * x + y * y + z * z); + distance = Math.min(distance, panner.maxDistance); + let rolloff = panner.rolloffFactor; + let gain = Math.pow(distance / panner.refDistance, -rolloff); + + return gain; +} + +// Map the distance model to the function that implements the model +let distanceModelFunction = { + 'linear': linearDistance, + 'inverse': inverseDistance, + 'exponential': exponentialDistance +}; + +function createGraph(context, distanceModel, nodeCount) { + bufferSource = new Array(nodeCount); + panner = new Array(nodeCount); + position = new Array(nodeCount); + time = new Array(nodesToCreate); + + impulse = createImpulseBuffer(context, pulseLengthFrames); + + // Create all the sources and panners. + // + // We MUST use the EQUALPOWER panning model so that we can easily + // figure out the gain introduced by the panner. + // + // We want to stay in the middle of the panning range, which means + // we want to stay on the z-axis. If we don't, then the effect of + // panning model will be much more complicated. We're not testing + // the panner, but the distance model, so we want the panner effect + // to be simple. + // + // The panners are placed at a uniform intervals between the panner + // reference distance and the panner max distance. The source is + // also started at regular intervals. + for (let k = 0; k < nodeCount; ++k) { + bufferSource[k] = context.createBufferSource(); + bufferSource[k].buffer = impulse; + + panner[k] = context.createPanner(); + panner[k].panningModel = 'equalpower'; + panner[k].distanceModel = distanceModel; + + let distanceStep = + (panner[k].maxDistance - panner[k].refDistance) / nodeCount; + position[k] = distanceStep * k + panner[k].refDistance; + panner[k].setPosition(0, 0, position[k]); + + bufferSource[k].connect(panner[k]); + panner[k].connect(context.destination); + + time[k] = k * timeStep; + bufferSource[k].start(time[k]); + } +} + +// distanceModel should be the distance model string like +// "linear", "inverse", or "exponential". +function createTestAndRun(context, distanceModel, should) { + // To test the distance models, we create a number of panners at + // uniformly spaced intervals on the z-axis. Each of these are + // started at equally spaced time intervals. After rendering the + // signals, we examine where each impulse is located and the + // attenuation of the impulse. The attenuation is compared + // against our expected attenuation. + + createGraph(context, distanceModel, nodesToCreate); + + return context.startRendering().then( + buffer => checkDistanceResult(buffer, distanceModel, should)); +} + +// The gain caused by the EQUALPOWER panning model, if we stay on the +// z axis, with the default orientations. +function equalPowerGain() { + return Math.SQRT1_2; +} + +function checkDistanceResult(renderedBuffer, model, should) { + renderedData = renderedBuffer.getChannelData(0); + + // The max allowed error between the actual gain and the expected + // value. This is determined experimentally. Set to 0 to see + // what the actual errors are. + let maxAllowedError = 2.2720e-6; + + let success = true; + + // Number of impulses we found in the rendered result. + let impulseCount = 0; + + // Maximum relative error in the gain of the impulses. + let maxError = 0; + + // Array of locations of the impulses that were not at the + // expected location. (Contains the actual and expected frame + // of the impulse.) + let impulsePositionErrors = new Array(); + + // Step through the rendered data to find all the non-zero points + // so we can find where our distance-attenuated impulses are. + // These are tested against the expected attenuations at that + // distance. + for (let k = 0; k < renderedData.length; ++k) { + if (renderedData[k] != 0) { + // Convert from string to index. + let distanceFunction = distanceModelFunction[model]; + let expected = + distanceFunction(panner[impulseCount], 0, 0, position[impulseCount]); + + // Adjust for the center-panning of the EQUALPOWER panning + // model that we're using. + expected *= equalPowerGain(); + + let error = Math.abs(renderedData[k] - expected) / Math.abs(expected); + + maxError = Math.max(maxError, Math.abs(error)); + + should(renderedData[k]).beCloseTo(expected, {threshold: maxAllowedError}); + + // Keep track of any impulses that aren't where we expect them + // to be. + let expectedOffset = timeToSampleFrame(time[impulseCount], sampleRate); + if (k != expectedOffset) { + impulsePositionErrors.push({actual: k, expected: expectedOffset}); + } + ++impulseCount; + } + } + should(impulseCount, 'Number of impulses').beEqualTo(nodesToCreate); + + should(maxError, 'Max error in distance gains') + .beLessThanOrEqualTo(maxAllowedError); + + // Display any timing errors that we found. + if (impulsePositionErrors.length > 0) { + let actual = impulsePositionErrors.map(x => x.actual); + let expected = impulsePositionErrors.map(x => x.expected); + should(actual, 'Actual impulse positions found').beEqualToArray(expected); + } +} diff --git a/testing/web-platform/tests/webaudio/resources/merger-testing.js b/testing/web-platform/tests/webaudio/resources/merger-testing.js new file mode 100644 index 0000000000..4477ec0a1f --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/merger-testing.js @@ -0,0 +1,24 @@ +// This file is for the audiochannelmerger-* layout tests. +// Requires |audio-testing.js| to work properly. + +function testMergerInput(should, config) { + let context = new OfflineAudioContext(config.numberOfChannels, 128, 44100); + let merger = context.createChannelMerger(config.numberOfChannels); + let source = context.createBufferSource(); + source.buffer = createConstantBuffer(context, 128, config.testBufferContent); + + // Connect the output of source into the specified input of merger. + if (config.mergerInputIndex) + source.connect(merger, 0, config.mergerInputIndex); + else + source.connect(merger); + merger.connect(context.destination); + source.start(); + + return context.startRendering().then(function(buffer) { + let prefix = config.testBufferContent.length + '-channel source: '; + for (let i = 0; i < config.numberOfChannels; i++) + should(buffer.getChannelData(i), prefix + 'Channel #' + i) + .beConstantValueOf(config.expected[i]); + }); +} diff --git a/testing/web-platform/tests/webaudio/resources/mix-testing.js b/testing/web-platform/tests/webaudio/resources/mix-testing.js new file mode 100644 index 0000000000..63c8e1aca6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/mix-testing.js @@ -0,0 +1,23 @@ +let toneLengthSeconds = 1; + +// Create a buffer with multiple channels. +// The signal frequency in each channel is the multiple of that in the first +// channel. +function createToneBuffer(context, frequency, duration, numberOfChannels) { + let sampleRate = context.sampleRate; + let sampleFrameLength = duration * sampleRate; + + let audioBuffer = + context.createBuffer(numberOfChannels, sampleFrameLength, sampleRate); + + let n = audioBuffer.length; + + for (let k = 0; k < numberOfChannels; ++k) { + let data = audioBuffer.getChannelData(k); + + for (let i = 0; i < n; ++i) + data[i] = Math.sin(frequency * (k + 1) * 2.0 * Math.PI * i / sampleRate); + } + + return audioBuffer; +} diff --git a/testing/web-platform/tests/webaudio/resources/mixing-rules.js b/testing/web-platform/tests/webaudio/resources/mixing-rules.js new file mode 100644 index 0000000000..e06a1468a3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/mixing-rules.js @@ -0,0 +1,350 @@ +// Utilities for mixing rule testing. +// http://webaudio.github.io/web-audio-api/#channel-up-mixing-and-down-mixing + + +/** + * Create an n-channel buffer, with all sample data zero except for a shifted + * impulse. The impulse position depends on the channel index. For example, for + * a 4-channel buffer: + * channel 0: 1 0 0 0 0 0 0 0 + * channel 1: 0 1 0 0 0 0 0 0 + * channel 2: 0 0 1 0 0 0 0 0 + * channel 3: 0 0 0 1 0 0 0 0 + * @param {AudioContext} context Associated AudioContext. + * @param {Number} numberOfChannels Number of channels of test buffer. + * @param {Number} frameLength Buffer length in frames. + * @return {AudioBuffer} + */ +function createShiftedImpulseBuffer(context, numberOfChannels, frameLength) { + let shiftedImpulseBuffer = + context.createBuffer(numberOfChannels, frameLength, context.sampleRate); + for (let channel = 0; channel < numberOfChannels; ++channel) { + let data = shiftedImpulseBuffer.getChannelData(channel); + data[channel] = 1; + } + + return shiftedImpulseBuffer; +} + +/** + * Create a string that displays the content of AudioBuffer. + * @param {AudioBuffer} audioBuffer AudioBuffer object to stringify. + * @param {Number} frameLength Number of frames to be printed. + * @param {Number} frameOffset Starting frame position for printing. + * @return {String} + */ +function stringifyBuffer(audioBuffer, frameLength, frameOffset) { + frameOffset = (frameOffset || 0); + + let stringifiedBuffer = ''; + for (let channel = 0; channel < audioBuffer.numberOfChannels; ++channel) { + let channelData = audioBuffer.getChannelData(channel); + for (let i = 0; i < frameLength; ++i) + stringifiedBuffer += channelData[i + frameOffset] + ' '; + stringifiedBuffer += '\n'; + } + + return stringifiedBuffer; +} + +/** + * Compute number of channels from the connection. + * http://webaudio.github.io/web-audio-api/#dfn-computednumberofchannels + * @param {String} connections A string specifies the connection. For + * example, the string "128" means 3 + * connections, having 1, 2, and 8 channels + * respectively. + * @param {Number} channelCount Channel count. + * @param {String} channelCountMode Channel count mode. + * @return {Number} Computed number of channels. + */ +function computeNumberOfChannels(connections, channelCount, channelCountMode) { + if (channelCountMode == 'explicit') + return channelCount; + + // Must have at least one channel. + let computedNumberOfChannels = 1; + + // Compute "computedNumberOfChannels" based on all the connections. + for (let i = 0; i < connections.length; ++i) { + let connectionNumberOfChannels = parseInt(connections[i]); + computedNumberOfChannels = + Math.max(computedNumberOfChannels, connectionNumberOfChannels); + } + + if (channelCountMode == 'clamped-max') + computedNumberOfChannels = Math.min(computedNumberOfChannels, channelCount); + + return computedNumberOfChannels; +} + +/** + * Apply up/down-mixing (in-place summing) based on 'speaker' interpretation. + * @param {AudioBuffer} input Input audio buffer. + * @param {AudioBuffer} output Output audio buffer. + */ +function speakersSum(input, output) { + if (input.length != output.length) { + throw '[mixing-rules.js] speakerSum(): buffer lengths mismatch (input: ' + + input.length + ', output: ' + output.length + ')'; + } + + if (input.numberOfChannels === output.numberOfChannels) { + for (let channel = 0; channel < output.numberOfChannels; ++channel) { + let inputChannel = input.getChannelData(channel); + let outputChannel = output.getChannelData(channel); + for (let i = 0; i < outputChannel.length; i++) + outputChannel[i] += inputChannel[i]; + } + } else if (input.numberOfChannels < output.numberOfChannels) { + processUpMix(input, output); + } else { + processDownMix(input, output); + } +} + +/** + * In-place summing to |output| based on 'discrete' channel interpretation. + * @param {AudioBuffer} input Input audio buffer. + * @param {AudioBuffer} output Output audio buffer. + */ +function discreteSum(input, output) { + if (input.length != output.length) { + throw '[mixing-rules.js] speakerSum(): buffer lengths mismatch (input: ' + + input.length + ', output: ' + output.length + ')'; + } + + let numberOfChannels = + Math.min(input.numberOfChannels, output.numberOfChannels) + + for (let channel = 0; channel < numberOfChannels; ++channel) { + let inputChannel = input.getChannelData(channel); + let outputChannel = output.getChannelData(channel); + for (let i = 0; i < outputChannel.length; i++) + outputChannel[i] += inputChannel[i]; + } +} + +/** + * Perform up-mix by in-place summing to |output| buffer. + * @param {AudioBuffer} input Input audio buffer. + * @param {AudioBuffer} output Output audio buffer. + */ +function processUpMix(input, output) { + let numberOfInputChannels = input.numberOfChannels; + let numberOfOutputChannels = output.numberOfChannels; + let i, length = output.length; + + // Up-mixing: 1 -> 2, 1 -> 4 + // output.L += input + // output.R += input + // output.SL += 0 (in the case of 1 -> 4) + // output.SR += 0 (in the case of 1 -> 4) + if ((numberOfInputChannels === 1 && numberOfOutputChannels === 2) || + (numberOfInputChannels === 1 && numberOfOutputChannels === 4)) { + let inputChannel = input.getChannelData(0); + let outputChannel0 = output.getChannelData(0); + let outputChannel1 = output.getChannelData(1); + for (i = 0; i < length; i++) { + outputChannel0[i] += inputChannel[i]; + outputChannel1[i] += inputChannel[i]; + } + + return; + } + + // Up-mixing: 1 -> 5.1 + // output.L += 0 + // output.R += 0 + // output.C += input + // output.LFE += 0 + // output.SL += 0 + // output.SR += 0 + if (numberOfInputChannels == 1 && numberOfOutputChannels == 6) { + let inputChannel = input.getChannelData(0); + let outputChannel2 = output.getChannelData(2); + for (i = 0; i < length; i++) + outputChannel2[i] += inputChannel[i]; + + return; + } + + // Up-mixing: 2 -> 4, 2 -> 5.1 + // output.L += input.L + // output.R += input.R + // output.C += 0 (in the case of 2 -> 5.1) + // output.LFE += 0 (in the case of 2 -> 5.1) + // output.SL += 0 + // output.SR += 0 + if ((numberOfInputChannels === 2 && numberOfOutputChannels === 4) || + (numberOfInputChannels === 2 && numberOfOutputChannels === 6)) { + let inputChannel0 = input.getChannelData(0); + let inputChannel1 = input.getChannelData(1); + let outputChannel0 = output.getChannelData(0); + let outputChannel1 = output.getChannelData(1); + for (i = 0; i < length; i++) { + outputChannel0[i] += inputChannel0[i]; + outputChannel1[i] += inputChannel1[i]; + } + + return; + } + + // Up-mixing: 4 -> 5.1 + // output.L += input.L + // output.R += input.R + // output.C += 0 + // output.LFE += 0 + // output.SL += input.SL + // output.SR += input.SR + if (numberOfInputChannels === 4 && numberOfOutputChannels === 6) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let inputChannel2 = input.getChannelData(2); // input.SL + let inputChannel3 = input.getChannelData(3); // input.SR + let outputChannel0 = output.getChannelData(0); // output.L + let outputChannel1 = output.getChannelData(1); // output.R + let outputChannel4 = output.getChannelData(4); // output.SL + let outputChannel5 = output.getChannelData(5); // output.SR + for (i = 0; i < length; i++) { + outputChannel0[i] += inputChannel0[i]; + outputChannel1[i] += inputChannel1[i]; + outputChannel4[i] += inputChannel2[i]; + outputChannel5[i] += inputChannel3[i]; + } + + return; + } + + // All other cases, fall back to the discrete sum. + discreteSum(input, output); +} + +/** + * Perform down-mix by in-place summing to |output| buffer. + * @param {AudioBuffer} input Input audio buffer. + * @param {AudioBuffer} output Output audio buffer. + */ +function processDownMix(input, output) { + let numberOfInputChannels = input.numberOfChannels; + let numberOfOutputChannels = output.numberOfChannels; + let i, length = output.length; + + // Down-mixing: 2 -> 1 + // output += 0.5 * (input.L + input.R) + if (numberOfInputChannels === 2 && numberOfOutputChannels === 1) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let outputChannel0 = output.getChannelData(0); + for (i = 0; i < length; i++) + outputChannel0[i] += 0.5 * (inputChannel0[i] + inputChannel1[i]); + + return; + } + + // Down-mixing: 4 -> 1 + // output += 0.25 * (input.L + input.R + input.SL + input.SR) + if (numberOfInputChannels === 4 && numberOfOutputChannels === 1) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let inputChannel2 = input.getChannelData(2); // input.SL + let inputChannel3 = input.getChannelData(3); // input.SR + let outputChannel0 = output.getChannelData(0); + for (i = 0; i < length; i++) { + outputChannel0[i] += 0.25 * + (inputChannel0[i] + inputChannel1[i] + inputChannel2[i] + + inputChannel3[i]); + } + + return; + } + + // Down-mixing: 5.1 -> 1 + // output += sqrt(1/2) * (input.L + input.R) + input.C + // + 0.5 * (input.SL + input.SR) + if (numberOfInputChannels === 6 && numberOfOutputChannels === 1) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let inputChannel2 = input.getChannelData(2); // input.C + let inputChannel4 = input.getChannelData(4); // input.SL + let inputChannel5 = input.getChannelData(5); // input.SR + let outputChannel0 = output.getChannelData(0); + let scaleSqrtHalf = Math.sqrt(0.5); + for (i = 0; i < length; i++) { + outputChannel0[i] += + scaleSqrtHalf * (inputChannel0[i] + inputChannel1[i]) + + inputChannel2[i] + 0.5 * (inputChannel4[i] + inputChannel5[i]); + } + + return; + } + + // Down-mixing: 4 -> 2 + // output.L += 0.5 * (input.L + input.SL) + // output.R += 0.5 * (input.R + input.SR) + if (numberOfInputChannels == 4 && numberOfOutputChannels == 2) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let inputChannel2 = input.getChannelData(2); // input.SL + let inputChannel3 = input.getChannelData(3); // input.SR + let outputChannel0 = output.getChannelData(0); // output.L + let outputChannel1 = output.getChannelData(1); // output.R + for (i = 0; i < length; i++) { + outputChannel0[i] += 0.5 * (inputChannel0[i] + inputChannel2[i]); + outputChannel1[i] += 0.5 * (inputChannel1[i] + inputChannel3[i]); + } + + return; + } + + // Down-mixing: 5.1 -> 2 + // output.L += input.L + sqrt(1/2) * (input.C + input.SL) + // output.R += input.R + sqrt(1/2) * (input.C + input.SR) + if (numberOfInputChannels == 6 && numberOfOutputChannels == 2) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let inputChannel2 = input.getChannelData(2); // input.C + let inputChannel4 = input.getChannelData(4); // input.SL + let inputChannel5 = input.getChannelData(5); // input.SR + let outputChannel0 = output.getChannelData(0); // output.L + let outputChannel1 = output.getChannelData(1); // output.R + let scaleSqrtHalf = Math.sqrt(0.5); + for (i = 0; i < length; i++) { + outputChannel0[i] += inputChannel0[i] + + scaleSqrtHalf * (inputChannel2[i] + inputChannel4[i]); + outputChannel1[i] += inputChannel1[i] + + scaleSqrtHalf * (inputChannel2[i] + inputChannel5[i]); + } + + return; + } + + // Down-mixing: 5.1 -> 4 + // output.L += input.L + sqrt(1/2) * input.C + // output.R += input.R + sqrt(1/2) * input.C + // output.SL += input.SL + // output.SR += input.SR + if (numberOfInputChannels === 6 && numberOfOutputChannels === 4) { + let inputChannel0 = input.getChannelData(0); // input.L + let inputChannel1 = input.getChannelData(1); // input.R + let inputChannel2 = input.getChannelData(2); // input.C + let inputChannel4 = input.getChannelData(4); // input.SL + let inputChannel5 = input.getChannelData(5); // input.SR + let outputChannel0 = output.getChannelData(0); // output.L + let outputChannel1 = output.getChannelData(1); // output.R + let outputChannel2 = output.getChannelData(2); // output.SL + let outputChannel3 = output.getChannelData(3); // output.SR + let scaleSqrtHalf = Math.sqrt(0.5); + for (i = 0; i < length; i++) { + outputChannel0[i] += inputChannel0[i] + scaleSqrtHalf * inputChannel2[i]; + outputChannel1[i] += inputChannel1[i] + scaleSqrtHalf * inputChannel2[i]; + outputChannel2[i] += inputChannel4[i]; + outputChannel3[i] += inputChannel5[i]; + } + + return; + } + + // All other cases, fall back to the discrete sum. + discreteSum(input, output); +} diff --git a/testing/web-platform/tests/webaudio/resources/note-grain-on-testing.js b/testing/web-platform/tests/webaudio/resources/note-grain-on-testing.js new file mode 100644 index 0000000000..ad0631670d --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/note-grain-on-testing.js @@ -0,0 +1,165 @@ +// Use a power of two to eliminate round-off converting from frames to time. +let sampleRate = 32768; + +// How many grains to play. +let numberOfTests = 100; + +// Duration of each grain to be played. Make a whole number of frames +let duration = Math.floor(0.01 * sampleRate) / sampleRate; + +// A little extra bit of silence between grain boundaries. Must be a whole +// number of frames. +let grainGap = Math.floor(0.005 * sampleRate) / sampleRate; + +// Time step between the start of each grain. We need to add a little +// bit of silence so we can detect grain boundaries +let timeStep = duration + grainGap; + +// Time step between the start for each grain. Must be a whole number of +// frames. +let grainOffsetStep = Math.floor(0.001 * sampleRate) / sampleRate; + +// How long to render to cover all of the grains. +let renderTime = (numberOfTests + 1) * timeStep; + +let context; +let renderedData; + +// Create a buffer containing the data that we want. The function f +// returns the desired value at sample frame k. +function createSignalBuffer(context, f) { + // Make sure the buffer has enough data for all of the possible + // grain offsets and durations. The additional 1 is for any + // round-off errors. + let signalLength = + Math.floor(1 + sampleRate * (numberOfTests * grainOffsetStep + duration)); + + let buffer = context.createBuffer(2, signalLength, sampleRate); + let data = buffer.getChannelData(0); + + for (let k = 0; k < signalLength; ++k) { + data[k] = f(k); + } + + return buffer; +} + +// From the data array, find the start and end sample frame for each +// grain. This depends on the data having 0's between grain, and +// that the grain is always strictly non-zero. +function findStartAndEndSamples(data) { + let nSamples = data.length; + + let startTime = []; + let endTime = []; + let lookForStart = true; + + // Look through the rendered data to find the start and stop + // times of each grain. + for (let k = 0; k < nSamples; ++k) { + if (lookForStart) { + // Find a non-zero point and record the start. We're not + // concerned with the value in this test, only that the + // grain started here. + if (renderedData[k]) { + startTime.push(k); + lookForStart = false; + } + } else { + // Find a zero and record the end of the grain. + if (!renderedData[k]) { + endTime.push(k); + lookForStart = true; + } + } + } + + return {start: startTime, end: endTime}; +} + +function playGrain(context, source, time, offset, duration) { + let bufferSource = context.createBufferSource(); + + bufferSource.buffer = source; + bufferSource.connect(context.destination); + bufferSource.start(time, offset, duration); +} + +// Play out all grains. Returns a object containing two arrays, one +// for the start time and one for the grain offset time. +function playAllGrains(context, source, numberOfNotes) { + let startTimes = new Array(numberOfNotes); + let offsets = new Array(numberOfNotes); + + for (let k = 0; k < numberOfNotes; ++k) { + let timeOffset = k * timeStep; + let grainOffset = k * grainOffsetStep; + + playGrain(context, source, timeOffset, grainOffset, duration); + startTimes[k] = timeOffset; + offsets[k] = grainOffset; + } + + return {startTimes: startTimes, grainOffsetTimes: offsets}; +} + +// Verify that the start and end frames for each grain match our +// expected start and end frames. +function verifyStartAndEndFrames(startEndFrames, should) { + let startFrames = startEndFrames.start; + let endFrames = startEndFrames.end; + + // Count of how many grains started at the incorrect time. + let errorCountStart = 0; + + // Count of how many grains ended at the incorrect time. + let errorCountEnd = 0; + + should( + startFrames.length == endFrames.length, 'Found all grain starts and ends') + .beTrue(); + + should(startFrames.length, 'Number of start frames').beEqualTo(numberOfTests); + should(endFrames.length, 'Number of end frames').beEqualTo(numberOfTests); + + // Examine the start and stop times to see if they match our + // expectations. + for (let k = 0; k < startFrames.length; ++k) { + let expectedStart = timeToSampleFrame(k * timeStep, sampleRate); + // The end point is the duration. + let expectedEnd = expectedStart + + grainLengthInSampleFrames(k * grainOffsetStep, duration, sampleRate); + + if (startFrames[k] != expectedStart) + ++errorCountStart; + if (endFrames[k] != expectedEnd) + ++errorCountEnd; + + should([startFrames[k], endFrames[k]], 'Pulse ' + k + ' boundary') + .beEqualToArray([expectedStart, expectedEnd]); + } + + // Check that all the grains started or ended at the correct time. + if (!errorCountStart) { + should( + startFrames.length, 'Number of grains that started at the correct time') + .beEqualTo(numberOfTests); + } else { + should( + errorCountStart, + 'Number of grains out of ' + numberOfTests + + 'that started at the wrong time') + .beEqualTo(0); + } + + if (!errorCountEnd) { + should(endFrames.length, 'Number of grains that ended at the correct time') + .beEqualTo(numberOfTests); + } else { + should( + errorCountEnd, + 'Number of grains out of ' + numberOfTests + + ' that ended at the wrong time') + .beEqualTo(0); + } +} diff --git a/testing/web-platform/tests/webaudio/resources/panner-formulas.js b/testing/web-platform/tests/webaudio/resources/panner-formulas.js new file mode 100644 index 0000000000..ae6f516668 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/panner-formulas.js @@ -0,0 +1,190 @@ +// For the record, these distance formulas were taken from the OpenAL +// spec +// (http://connect.creativelabs.com/openal/Documentation/OpenAL%201.1%20Specification.pdf), +// not the code. The Web Audio spec follows the OpenAL formulas. + +function linearDistance(panner, x, y, z) { + let distance = Math.sqrt(x * x + y * y + z * z); + let dref = Math.min(panner.refDistance, panner.maxDistance); + let dmax = Math.max(panner.refDistance, panner.maxDistance); + distance = Math.max(Math.min(distance, dmax), dref); + let rolloff = Math.max(Math.min(panner.rolloffFactor, 1), 0); + if (dref === dmax) + return 1 - rolloff; + + let gain = (1 - rolloff * (distance - dref) / (dmax - dref)); + + return gain; +} + +function inverseDistance(panner, x, y, z) { + let distance = Math.sqrt(x * x + y * y + z * z); + distance = Math.max(distance, panner.refDistance); + let rolloff = panner.rolloffFactor; + let gain = panner.refDistance / + (panner.refDistance + + rolloff * (Math.max(distance, panner.refDistance) - panner.refDistance)); + + return gain; +} + +function exponentialDistance(panner, x, y, z) { + let distance = Math.sqrt(x * x + y * y + z * z); + distance = Math.max(distance, panner.refDistance); + let rolloff = panner.rolloffFactor; + let gain = Math.pow(distance / panner.refDistance, -rolloff); + + return gain; +} + +// Simple implementations of 3D vectors implemented as a 3-element array. + +// x - y +function vec3Sub(x, y) { + let z = new Float32Array(3); + z[0] = x[0] - y[0]; + z[1] = x[1] - y[1]; + z[2] = x[2] - y[2]; + + return z; +} + +// x/|x| +function vec3Normalize(x) { + let mag = Math.hypot(...x); + return x.map(function(c) { + return c / mag; + }); +} + +// x == 0? +function vec3IsZero(x) { + return x[0] === 0 && x[1] === 0 && x[2] === 0; +} + +// Vector cross product +function vec3Cross(u, v) { + let cross = new Float32Array(3); + cross[0] = u[1] * v[2] - u[2] * v[1]; + cross[1] = u[2] * v[0] - u[0] * v[2]; + cross[2] = u[0] * v[1] - u[1] * v[0]; + return cross; +} + +// Dot product +function vec3Dot(x, y) { + return x[0] * y[0] + x[1] * y[1] + x[2] * y[2]; +} + +// a*x, for scalar a +function vec3Scale(a, x) { + return x.map(function(c) { + return a * c; + }); +} + +function calculateAzimuth(source, listener, listenerForward, listenerUp) { + let sourceListener = vec3Sub(source, listener); + + if (vec3IsZero(sourceListener)) + return 0; + + sourceListener = vec3Normalize(sourceListener); + + let listenerRight = vec3Normalize(vec3Cross(listenerForward, listenerUp)); + let listenerForwardNorm = vec3Normalize(listenerForward); + + let up = vec3Cross(listenerRight, listenerForwardNorm); + let upProjection = vec3Dot(sourceListener, up); + + let projectedSource = + vec3Normalize(vec3Sub(sourceListener, vec3Scale(upProjection, up))); + + let azimuth = + 180 / Math.PI * Math.acos(vec3Dot(projectedSource, listenerRight)); + + // Source in front or behind the listener + let frontBack = vec3Dot(projectedSource, listenerForwardNorm); + if (frontBack < 0) + azimuth = 360 - azimuth; + + // Make azimuth relative to "front" and not "right" listener vector. + if (azimuth >= 0 && azimuth <= 270) + azimuth = 90 - azimuth; + else + azimuth = 450 - azimuth; + + // We don't need elevation, so we're skipping that computation. + return azimuth; +} + +// Map our position angle to the azimuth angle (in degrees). +// +// An angle of 0 corresponds to an azimuth of 90 deg; pi, to -90 deg. +function angleToAzimuth(angle) { + return 90 - angle * 180 / Math.PI; +} + +// The gain caused by the EQUALPOWER panning model +function equalPowerGain(azimuth, numberOfChannels) { + let halfPi = Math.PI / 2; + + if (azimuth < -90) + azimuth = -180 - azimuth; + else + azimuth = 180 - azimuth; + + if (numberOfChannels == 1) { + let panPosition = (azimuth + 90) / 180; + + let gainL = Math.cos(halfPi * panPosition); + let gainR = Math.sin(halfPi * panPosition); + + return {left: gainL, right: gainR}; + } else { + if (azimuth <= 0) { + let panPosition = (azimuth + 90) / 90; + + let gainL = Math.cos(halfPi * panPosition); + let gainR = Math.sin(halfPi * panPosition); + + return {left: gainL, right: gainR}; + } else { + let panPosition = azimuth / 90; + + let gainL = Math.cos(halfPi * panPosition); + let gainR = Math.sin(halfPi * panPosition); + + return {left: gainL, right: gainR}; + } + } +} + +function applyPanner(azimuth, srcL, srcR, numberOfChannels) { + let length = srcL.length; + let outL = new Float32Array(length); + let outR = new Float32Array(length); + + if (numberOfChannels == 1) { + for (let k = 0; k < length; ++k) { + let gains = equalPowerGain(azimuth[k], numberOfChannels); + + outL[k] = srcL[k] * gains.left; + outR[k] = srcR[k] * gains.right; + } + } else { + for (let k = 0; k < length; ++k) { + let gains = equalPowerGain(azimuth[k], numberOfChannels); + + if (azimuth[k] <= 0) { + outL[k] = srcL[k] + srcR[k] * gains.left; + outR[k] = srcR[k] * gains.right; + } else { + outL[k] = srcL[k] * gains.left; + outR[k] = srcR[k] + srcL[k] * gains.right; + } + } + } + + return {left: outL, right: outR}; +} diff --git a/testing/web-platform/tests/webaudio/resources/panner-model-testing.js b/testing/web-platform/tests/webaudio/resources/panner-model-testing.js new file mode 100644 index 0000000000..4df3e17813 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/panner-model-testing.js @@ -0,0 +1,184 @@ +// Use a power of two to eliminate round-off when converting frames to time and +// vice versa. +let sampleRate = 32768; + +let numberOfChannels = 1; + +// Time step when each panner node starts. Make sure it starts on a frame +// boundary. +let timeStep = Math.floor(0.001 * sampleRate) / sampleRate; + +// Length of the impulse signal. +let pulseLengthFrames = Math.round(timeStep * sampleRate); + +// How many panner nodes to create for the test +let nodesToCreate = 100; + +// Be sure we render long enough for all of our nodes. +let renderLengthSeconds = timeStep * (nodesToCreate + 1); + +// These are global mostly for debugging. +let context; +let impulse; +let bufferSource; +let panner; +let position; +let time; + +let renderedBuffer; +let renderedLeft; +let renderedRight; + +function createGraph(context, nodeCount, positionSetter) { + bufferSource = new Array(nodeCount); + panner = new Array(nodeCount); + position = new Array(nodeCount); + time = new Array(nodeCount); + // Angle between panner locations. (nodeCount - 1 because we want + // to include both 0 and 180 deg. + let angleStep = Math.PI / (nodeCount - 1); + + if (numberOfChannels == 2) { + impulse = createStereoImpulseBuffer(context, pulseLengthFrames); + } else + impulse = createImpulseBuffer(context, pulseLengthFrames); + + for (let k = 0; k < nodeCount; ++k) { + bufferSource[k] = context.createBufferSource(); + bufferSource[k].buffer = impulse; + + panner[k] = context.createPanner(); + panner[k].panningModel = 'equalpower'; + panner[k].distanceModel = 'linear'; + + let angle = angleStep * k; + position[k] = {angle: angle, x: Math.cos(angle), z: Math.sin(angle)}; + positionSetter(panner[k], position[k].x, 0, position[k].z); + + bufferSource[k].connect(panner[k]); + panner[k].connect(context.destination); + + // Start the source + time[k] = k * timeStep; + bufferSource[k].start(time[k]); + } +} + +function createTestAndRun( + context, should, nodeCount, numberOfSourceChannels, positionSetter) { + numberOfChannels = numberOfSourceChannels; + + createGraph(context, nodeCount, positionSetter); + + return context.startRendering().then(buffer => checkResult(buffer, should)); +} + +// Map our position angle to the azimuth angle (in degrees). +// +// An angle of 0 corresponds to an azimuth of 90 deg; pi, to -90 deg. +function angleToAzimuth(angle) { + return 90 - angle * 180 / Math.PI; +} + +// The gain caused by the EQUALPOWER panning model +function equalPowerGain(angle) { + let azimuth = angleToAzimuth(angle); + + if (numberOfChannels == 1) { + let panPosition = (azimuth + 90) / 180; + + let gainL = Math.cos(0.5 * Math.PI * panPosition); + let gainR = Math.sin(0.5 * Math.PI * panPosition); + + return {left: gainL, right: gainR}; + } else { + if (azimuth <= 0) { + let panPosition = (azimuth + 90) / 90; + + let gainL = 1 + Math.cos(0.5 * Math.PI * panPosition); + let gainR = Math.sin(0.5 * Math.PI * panPosition); + + return {left: gainL, right: gainR}; + } else { + let panPosition = azimuth / 90; + + let gainL = Math.cos(0.5 * Math.PI * panPosition); + let gainR = 1 + Math.sin(0.5 * Math.PI * panPosition); + + return {left: gainL, right: gainR}; + } + } +} + +function checkResult(renderedBuffer, should) { + renderedLeft = renderedBuffer.getChannelData(0); + renderedRight = renderedBuffer.getChannelData(1); + + // The max error we allow between the rendered impulse and the + // expected value. This value is experimentally determined. Set + // to 0 to make the test fail to see what the actual error is. + let maxAllowedError = 1.1597e-6; + + let success = true; + + // Number of impulses found in the rendered result. + let impulseCount = 0; + + // Max (relative) error and the index of the maxima for the left + // and right channels. + let maxErrorL = 0; + let maxErrorIndexL = 0; + let maxErrorR = 0; + let maxErrorIndexR = 0; + + // Number of impulses that don't match our expected locations. + let timeCount = 0; + + // Locations of where the impulses aren't at the expected locations. + let timeErrors = new Array(); + + for (let k = 0; k < renderedLeft.length; ++k) { + // We assume that the left and right channels start at the same instant. + if (renderedLeft[k] != 0 || renderedRight[k] != 0) { + // The expected gain for the left and right channels. + let pannerGain = equalPowerGain(position[impulseCount].angle); + let expectedL = pannerGain.left; + let expectedR = pannerGain.right; + + // Absolute error in the gain. + let errorL = Math.abs(renderedLeft[k] - expectedL); + let errorR = Math.abs(renderedRight[k] - expectedR); + + if (Math.abs(errorL) > maxErrorL) { + maxErrorL = Math.abs(errorL); + maxErrorIndexL = impulseCount; + } + if (Math.abs(errorR) > maxErrorR) { + maxErrorR = Math.abs(errorR); + maxErrorIndexR = impulseCount; + } + + // Keep track of the impulses that didn't show up where we + // expected them to be. + let expectedOffset = timeToSampleFrame(time[impulseCount], sampleRate); + if (k != expectedOffset) { + timeErrors[timeCount] = {actual: k, expected: expectedOffset}; + ++timeCount; + } + ++impulseCount; + } + } + + should(impulseCount, 'Number of impulses found').beEqualTo(nodesToCreate); + + should( + timeErrors.map(x => x.actual), + 'Offsets of impulses at the wrong position') + .beEqualToArray(timeErrors.map(x => x.expected)); + + should(maxErrorL, 'Error in left channel gain values') + .beLessThanOrEqualTo(maxAllowedError); + + should(maxErrorR, 'Error in right channel gain values') + .beLessThanOrEqualTo(maxAllowedError); +} diff --git a/testing/web-platform/tests/webaudio/resources/sin_440Hz_-6dBFS_1s.wav b/testing/web-platform/tests/webaudio/resources/sin_440Hz_-6dBFS_1s.wav Binary files differnew file mode 100644 index 0000000000..f660c3c4b8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/sin_440Hz_-6dBFS_1s.wav diff --git a/testing/web-platform/tests/webaudio/resources/start-stop-exceptions.js b/testing/web-platform/tests/webaudio/resources/start-stop-exceptions.js new file mode 100644 index 0000000000..0d2ea12f6d --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/start-stop-exceptions.js @@ -0,0 +1,45 @@ +// Test that exceptions are throw for invalid values for start and +// stop. +function testStartStop(should, node, options) { + // Test non-finite values for start. These should all throw a TypeError + const nonFiniteValues = [NaN, Infinity, -Infinity]; + + nonFiniteValues.forEach(time => { + should(() => { + node.start(time); + }, `start(${time})`) + .throw(TypeError); + }); + + should(() => { + node.stop(); + }, 'Calling stop() before start()').throw(DOMException, 'InvalidStateError'); + + should(() => { + node.start(-1); + }, 'start(-1)').throw(RangeError); + + if (options) { + options.forEach(test => { + should(() => {node.start(...test.args)}, + 'start(' + test.args + ')').throw(test.errorType); + }); + } + + node.start(); + should(() => { + node.start(); + }, 'Calling start() twice').throw(DOMException, 'InvalidStateError'); + should(() => { + node.stop(-1); + }, 'stop(-1)').throw(RangeError); + + // Test non-finite stop times + nonFiniteValues.forEach(time => { + should(() => { + node.stop(time); + }, `stop(${time})`) + .throw(TypeError); + }); +} + diff --git a/testing/web-platform/tests/webaudio/resources/stereopanner-testing.js b/testing/web-platform/tests/webaudio/resources/stereopanner-testing.js new file mode 100644 index 0000000000..6ea5eb6269 --- /dev/null +++ b/testing/web-platform/tests/webaudio/resources/stereopanner-testing.js @@ -0,0 +1,205 @@ +let StereoPannerTest = (function() { + + // Constants + let PI_OVER_TWO = Math.PI * 0.5; + + // Use a power of two to eliminate any round-off when converting frames to + // time. + let gSampleRate = 32768; + + // Time step when each panner node starts. Make sure this is on a frame boundary. + let gTimeStep = Math.floor(0.001 * gSampleRate) / gSampleRate; + + // How many panner nodes to create for the test + let gNodesToCreate = 100; + + // Total render length for all of our nodes. + let gRenderLength = gTimeStep * (gNodesToCreate + 1) + gSampleRate; + + // Calculates channel gains based on equal power panning model. + // See: http://webaudio.github.io/web-audio-api/#panning-algorithm + function getChannelGain(pan, numberOfChannels) { + // The internal panning clips the pan value between -1, 1. + pan = Math.min(Math.max(pan, -1), 1); + let gainL, gainR; + // Consider number of channels and pan value's polarity. + if (numberOfChannels == 1) { + let panRadian = (pan * 0.5 + 0.5) * PI_OVER_TWO; + gainL = Math.cos(panRadian); + gainR = Math.sin(panRadian); + } else { + let panRadian = (pan <= 0 ? pan + 1 : pan) * PI_OVER_TWO; + if (pan <= 0) { + gainL = 1 + Math.cos(panRadian); + gainR = Math.sin(panRadian); + } else { + gainL = Math.cos(panRadian); + gainR = 1 + Math.sin(panRadian); + } + } + return {gainL: gainL, gainR: gainR}; + } + + + /** + * Test implementation class. + * @param {Object} options Test options + * @param {Object} options.description Test description + * @param {Object} options.numberOfInputChannels Number of input channels + */ + function Test(should, options) { + // Primary test flag. + this.success = true; + + this.should = should; + this.context = null; + this.prefix = options.prefix; + this.numberOfInputChannels = (options.numberOfInputChannels || 1); + switch (this.numberOfInputChannels) { + case 1: + this.description = 'Test for mono input'; + break; + case 2: + this.description = 'Test for stereo input'; + break; + } + + // Onset time position of each impulse. + this.onsets = []; + + // Pan position value of each impulse. + this.panPositions = []; + + // Locations of where the impulses aren't at the expected locations. + this.errors = []; + + // The index of the current impulse being verified. + this.impulseIndex = 0; + + // The max error we allow between the rendered impulse and the + // expected value. This value is experimentally determined. Set + // to 0 to make the test fail to see what the actual error is. + this.maxAllowedError = 1.284318e-7; + + // Max (absolute) error and the index of the maxima for the left + // and right channels. + this.maxErrorL = 0; + this.maxErrorR = 0; + this.maxErrorIndexL = 0; + this.maxErrorIndexR = 0; + + // The maximum value to use for panner pan value. The value will range from + // -panLimit to +panLimit. + this.panLimit = 1.0625; + } + + + Test.prototype.init = function() { + this.context = new OfflineAudioContext(2, gRenderLength, gSampleRate); + }; + + // Prepare an audio graph for testing. Create multiple impulse generators and + // panner nodes, then play them sequentially while varying the pan position. + Test.prototype.prepare = function() { + let impulse; + let impulseLength = Math.round(gTimeStep * gSampleRate); + let sources = []; + let panners = []; + + // Moves the pan value for each panner by pan step unit from -2 to 2. + // This is to check if the internal panning value is clipped properly. + let panStep = (2 * this.panLimit) / (gNodesToCreate - 1); + + if (this.numberOfInputChannels === 1) { + impulse = createImpulseBuffer(this.context, impulseLength); + } else { + impulse = createStereoImpulseBuffer(this.context, impulseLength); + } + + for (let i = 0; i < gNodesToCreate; i++) { + sources[i] = this.context.createBufferSource(); + panners[i] = this.context.createStereoPanner(); + sources[i].connect(panners[i]); + panners[i].connect(this.context.destination); + sources[i].buffer = impulse; + panners[i].pan.value = this.panPositions[i] = panStep * i - this.panLimit; + + // Store the onset time position of impulse. + this.onsets[i] = gTimeStep * i; + + sources[i].start(this.onsets[i]); + } + }; + + + Test.prototype.verify = function() { + let chanL = this.renderedBufferL; + let chanR = this.renderedBufferR; + for (let i = 0; i < chanL.length; i++) { + // Left and right channels must start at the same instant. + if (chanL[i] !== 0 || chanR[i] !== 0) { + // Get amount of error between actual and expected gain. + let expected = getChannelGain( + this.panPositions[this.impulseIndex], this.numberOfInputChannels); + let errorL = Math.abs(chanL[i] - expected.gainL); + let errorR = Math.abs(chanR[i] - expected.gainR); + + if (errorL > this.maxErrorL) { + this.maxErrorL = errorL; + this.maxErrorIndexL = this.impulseIndex; + } + if (errorR > this.maxErrorR) { + this.maxErrorR = errorR; + this.maxErrorIndexR = this.impulseIndex; + } + + // Keep track of the impulses that didn't show up where we expected + // them to be. + let expectedOffset = + timeToSampleFrame(this.onsets[this.impulseIndex], gSampleRate); + if (i != expectedOffset) { + this.errors.push({actual: i, expected: expectedOffset}); + } + + this.impulseIndex++; + } + } + }; + + + Test.prototype.showResult = function() { + this.should(this.impulseIndex, this.prefix + 'Number of impulses found') + .beEqualTo(gNodesToCreate); + + this.should( + this.errors.length, + this.prefix + 'Number of impulse at the wrong offset') + .beEqualTo(0); + + this.should(this.maxErrorL, this.prefix + 'Left channel error magnitude') + .beLessThanOrEqualTo(this.maxAllowedError); + + this.should(this.maxErrorR, this.prefix + 'Right channel error magnitude') + .beLessThanOrEqualTo(this.maxAllowedError); + }; + + Test.prototype.run = function() { + + this.init(); + this.prepare(); + + return this.context.startRendering().then(renderedBuffer => { + this.renderedBufferL = renderedBuffer.getChannelData(0); + this.renderedBufferR = renderedBuffer.getChannelData(1); + this.verify(); + this.showResult(); + }); + }; + + return { + create: function(should, options) { + return new Test(should, options); + } + }; + +})(); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/processing-model/cycle-without-delay.html b/testing/web-platform/tests/webaudio/the-audio-api/processing-model/cycle-without-delay.html new file mode 100644 index 0000000000..cab0f6ca8e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/processing-model/cycle-without-delay.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html class="a"> + <head> + <title>Cycles without DelayNode in audio node graph</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + function doTest() { + var off = new OfflineAudioContext(1, 512, 48000); + var osc = new OscillatorNode(off); + var fb = new GainNode(off); + // zero delay feedback loop + osc.connect(fb).connect(fb).connect(off.destination); + osc.start(0); + return off.startRendering().then((b) => { + return Promise.resolve(b.getChannelData(0)); + }); + } + + promise_test(() => { + return doTest().then(samples => { + var silent = true; + for (var i = 0; i < samples.length; i++) { + if (samples[i] != 0.0) { + silent = false; + break; + } + } + assert_true(silent); + }); + }, 'Test that cycles that don\'t contain a DelayNode are muted'); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/processing-model/delay-time-clamping.html b/testing/web-platform/tests/webaudio/the-audio-api/processing-model/delay-time-clamping.html new file mode 100644 index 0000000000..fa010df3cd --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/processing-model/delay-time-clamping.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html class="a"> + <head> + <title>Delay time clamping in cycles</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + function doTest() { + let off = new OfflineAudioContext(1, 512, 48000); + let b = new AudioBuffer({sampleRate: off.sampleRate, length: 1}); + b.getChannelData(0)[0] = 1; + let impulse = new AudioBufferSourceNode(off, {buffer: b}); + impulse.start(0); + // This delayTime of 64 samples MUST be clamped to 128 samples when + // in a cycle. + let delay = new DelayNode(off, {delayTime: 64 / 48000}); + let fb = new GainNode(off); + impulse.connect(fb).connect(delay).connect(fb).connect(off.destination); + return off.startRendering().then((b) => { + return Promise.resolve(b.getChannelData(0)); + }) + } + + promise_test(() => { + return doTest().then(samples => { + for (var i = 0; i < samples.length; i++) { + if ((i % 128) != 0) { + assert_equals( + samples[i], 0.0, + 'Non-silent audio found in between delayed impulses'); + } else { + assert_equals( + samples[i], 1.0, + 'Silent audio found instead of a delayed impulse'); + } + } + }); + }, 'Test that a DelayNode allows a feedback loop of a single rendering quantum'); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/processing-model/feedback-delay-time.html b/testing/web-platform/tests/webaudio/the-audio-api/processing-model/feedback-delay-time.html new file mode 100644 index 0000000000..96c2eb0658 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/processing-model/feedback-delay-time.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html class="a"> + <head> + <title>Feedback cycle with delay in audio node graph</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + function doTest() { + var off = new OfflineAudioContext(1, 512, 48000); + var b = off.createBuffer(1, 1, 48000); + b.getChannelData(0)[0] = 1; + var impulse = new AudioBufferSourceNode(off, {buffer: b}); + impulse.start(0); + var delay = new DelayNode(off, {delayTime: 128 / 48000}); + var fb = new GainNode(off); + impulse.connect(fb).connect(delay).connect(fb).connect(off.destination); + var samples; + return off.startRendering().then((b) => { + return Promise.resolve(b.getChannelData(0)); + }); + } + + promise_test(() => { + return doTest().then(samples => { + for (var i = 0; i < samples.length; i++) { + if ((i % 128) != 0) { + assert_equals( + samples[i], 0.0, + 'Non-silent audio found in between delayed impulses'); + } else { + assert_equals( + samples[i], 1.0, + 'Silent audio found instead of a delayed impulse'); + } + } + }); + }, 'Test that a DelayNode allows a feedback loop of a single rendering quantum'); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/ctor-analyser.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/ctor-analyser.html new file mode 100644 index 0000000000..a9aa483151 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/ctor-analyser.html @@ -0,0 +1,183 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: AnalyserNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'AnalyserNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'AnalyserNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [ + {name: 'fftSize', value: 2048}, + {name: 'frequencyBinCount', value: 1024}, + {name: 'minDecibels', value: -100}, {name: 'maxDecibels', value: -30}, + {name: 'smoothingTimeConstant', value: 0.8} + ]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'AnalyserNode'); + task.done(); + }); + + audit.define('constructor with options', (task, should) => { + let options = { + fftSize: 32, + maxDecibels: 1, + minDecibels: -13, + // Choose a value that can be represented the same as a float and as a + // double. + smoothingTimeConstant: 0.125 + }; + + let node; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node1 = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + should(node instanceof AnalyserNode, 'node1 instanceof AnalyserNode') + .beEqualTo(true); + should(node.fftSize, 'node1.fftSize').beEqualTo(options.fftSize); + should(node.maxDecibels, 'node1.maxDecibels') + .beEqualTo(options.maxDecibels); + should(node.minDecibels, 'node1.minDecibels') + .beEqualTo(options.minDecibels); + should(node.smoothingTimeConstant, 'node1.smoothingTimeConstant') + .beEqualTo(options.smoothingTimeConstant); + + task.done(); + }); + + audit.define('construct invalid options', (task, should) => { + let node; + + should( + () => { + node = new AnalyserNode(context, {fftSize: 33}); + }, + 'node = new AnalyserNode(c, { fftSize: 33 })') + .throw(DOMException, 'IndexSizeError'); + should( + () => { + node = new AnalyserNode(context, {maxDecibels: -500}); + }, + 'node = new AnalyserNode(c, { maxDecibels: -500 })') + .throw(DOMException, 'IndexSizeError'); + should( + () => { + node = new AnalyserNode(context, {minDecibels: -10}); + }, + 'node = new AnalyserNode(c, { minDecibels: -10 })') + .throw(DOMException, 'IndexSizeError'); + should( + () => { + node = new AnalyserNode(context, {smoothingTimeConstant: 2}); + }, + 'node = new AnalyserNode(c, { smoothingTimeConstant: 2 })') + .throw(DOMException, 'IndexSizeError'); + should(function() { + node = new AnalyserNode(context, {frequencyBinCount: 33}); + }, 'node = new AnalyserNode(c, { frequencyBinCount: 33 })').notThrow(); + should(node.frequencyBinCount, 'node.frequencyBinCount') + .beEqualTo(1024); + + task.done(); + }); + + audit.define('setting min/max', (task, should) => { + let node; + + // Recall the default values of minDecibels and maxDecibels are -100, + // and -30, respectively. Setting both values in the constructor should + // not signal an error in any of the following cases. + let options = {minDecibels: -10, maxDecibels: 20}; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + options = {maxDecibels: 20, minDecibels: -10}; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + options = {minDecibels: -200, maxDecibels: -150}; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + options = {maxDecibels: -150, minDecibels: -200}; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + // But these should signal because minDecibel > maxDecibel + options = {maxDecibels: -150, minDecibels: -10}; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'IndexSizeError'); + + options = {minDecibels: -10, maxDecibels: -150}; + should( + () => { + node = new AnalyserNode(context, options); + }, + 'node = new AnalyserNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'IndexSizeError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-basic.html new file mode 100644 index 0000000000..e176d6111e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-basic.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html> + <head> + <title> + realtimeanalyser-basic.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context = 0; + + let audit = Audit.createTaskRunner(); + + audit.define('Basic AnalyserNode test', function(task, should) { + context = new AudioContext(); + let analyser = context.createAnalyser(); + + should(analyser.numberOfInputs, 'Number of inputs for AnalyserNode') + .beEqualTo(1); + + should(analyser.numberOfOutputs, 'Number of outputs for AnalyserNode') + .beEqualTo(1); + + should(analyser.minDecibels, 'Default minDecibels value') + .beEqualTo(-100); + + should(analyser.maxDecibels, 'Default maxDecibels value') + .beEqualTo(-30); + + should( + analyser.smoothingTimeConstant, + 'Default smoothingTimeConstant value') + .beEqualTo(0.8); + + let expectedValue = -50 - (1 / 3); + analyser.minDecibels = expectedValue; + + should(analyser.minDecibels, 'node.minDecibels = ' + expectedValue) + .beEqualTo(expectedValue); + + expectedValue = -40 - (1 / 3); + analyser.maxDecibels = expectedValue; + + should(analyser.maxDecibels, 'node.maxDecibels = ' + expectedValue) + .beEqualTo(expectedValue); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-fft-scaling.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-fft-scaling.html new file mode 100644 index 0000000000..043bd5890a --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-fft-scaling.html @@ -0,0 +1,111 @@ +<!DOCTYPE html> +<html> + <head> + <title> + realtimeanalyser-fft-scaling.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <div id="description"></div> + <div id="console"></div> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // The number of analysers. We have analysers from size for each of the + // possible sizes of 2^5 to 2^15 for a total of 11. + let numberOfAnalysers = 11; + let sampleRate = 44100; + let nyquistFrequency = sampleRate / 2; + + // Frequency of the sine wave test signal. Should be high enough so that + // we get at least one full cycle for the 32-point FFT. This should also + // be such that the frequency should be exactly in one of the FFT bins for + // each of the possible FFT sizes. + let oscFrequency = nyquistFrequency / 16; + + // The actual peak values from each analyser. Useful for examining the + // actual peak values. + let peakValue = new Array(numberOfAnalysers); + + // For a 0dBFS sine wave, we would expect the FFT magnitude to be 0dB as + // well, but the analyzer node applies a Blackman window (to smooth the + // estimate). This reduces the energy of the signal so the FFT peak is + // less than 0dB. The threshold value given here was determined + // experimentally. + // + // See https://code.google.com/p/chromium/issues/detail?id=341596. + let peakThreshold = [ + -14.43, -13.56, -13.56, -13.56, -13.56, -13.56, -13.56, -13.56, -13.56, + -13.56, -13.56 + ]; + + function checkResult(order, analyser, should) { + return function() { + let index = order - 5; + let fftSize = 1 << order; + let fftData = new Float32Array(fftSize); + analyser.getFloatFrequencyData(fftData); + + // Compute the frequency bin that should contain the peak. + let expectedBin = + analyser.frequencyBinCount * (oscFrequency / nyquistFrequency); + + // Find the actual bin by finding the bin containing the peak. + let actualBin = 0; + peakValue[index] = -1000; + for (k = 0; k < analyser.frequencyBinCount; ++k) { + if (fftData[k] > peakValue[index]) { + actualBin = k; + peakValue[index] = fftData[k]; + } + } + + should(actualBin, (1 << order) + '-point FFT peak position') + .beEqualTo(expectedBin); + + should( + peakValue[index], (1 << order) + '-point FFT peak value in dBFS') + .beGreaterThanOrEqualTo(peakThreshold[index]); + } + } + + audit.define( + { + label: 'FFT scaling tests', + description: 'Test Scaling of FFT in AnalyserNode' + }, + async function(task, should) { + let tests = []; + for (let k = 5; k <= 15; ++k) + await runTest(k, should); + task.done(); + }); + + function runTest(order, should) { + let context = new OfflineAudioContext(1, 1 << order, sampleRate); + // Use a sine wave oscillator as the reference source signal. + let osc = context.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = oscFrequency; + osc.connect(context.destination); + + let analyser = context.createAnalyser(); + // No smoothing to simplify the analysis of the result. + analyser.smoothingTimeConstant = 0; + analyser.fftSize = 1 << order; + osc.connect(analyser); + + osc.start(); + return context.startRendering().then(() => { + checkResult(order, analyser, should)(); + }); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-fft-sizing.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-fft-sizing.html new file mode 100644 index 0000000000..7ee6a2237e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/realtimeanalyser-fft-sizing.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html> + <head> + <title> + realtimeanalyser-fft-sizing.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + function doTest(fftSize, illegal, should) { + let c = new OfflineAudioContext(1, 1000, 44100); + let a = c.createAnalyser(); + let message = 'Setting fftSize to ' + fftSize; + let tester = function() { + a.fftSize = fftSize; + }; + + if (illegal) { + should(tester, message).throw(DOMException, 'IndexSizeError'); + } else { + should(tester, message).notThrow(); + } + } + + audit.define( + { + label: 'FFT size test', + description: 'Test that re-sizing the FFT arrays does not fail.' + }, + function(task, should) { + doTest(-1, true, should); + doTest(0, true, should); + doTest(1, true, should); + for (let i = 2; i <= 0x20000; i *= 2) { + if (i >= 32 && i <= 32768) + doTest(i, false, should); + else + doTest(i, true, should); + doTest(i + 1, true, should); + } + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-gain.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-gain.html new file mode 100644 index 0000000000..dff51a74c5 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-gain.html @@ -0,0 +1,50 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script> +promise_test(function() { + // fftSize <= bufferSize so that the time domain data is full of input after + // processing the buffer. + const fftSize = 32; + const bufferSize = 128; + + var context = new OfflineAudioContext(1, bufferSize, 48000); + + var analyser1 = context.createAnalyser(); + analyser1.fftSize = fftSize; + analyser1.connect(context.destination); + var analyser2 = context.createAnalyser(); + analyser2.fftSize = fftSize; + + var gain = context.createGain(); + gain.gain.value = 2.0; + gain.connect(analyser1); + gain.connect(analyser2); + + // Create a DC input to make getFloatTimeDomainData() output consistent at + // any time. + var buffer = context.createBuffer(1, 1, context.sampleRate); + buffer.getChannelData(0)[0] = 1.0 / gain.gain.value; + var source = context.createBufferSource(); + source.buffer = buffer; + source.loop = true; + source.connect(gain); + source.start(); + + return context.startRendering().then(function(buffer) { + assert_equals(buffer.getChannelData(0)[0], 1.0, "analyser1 output"); + + var data = new Float32Array(1); + analyser1.getFloatTimeDomainData(data); + assert_equals(data[0], 1.0, "analyser1 time domain data"); + analyser2.getFloatTimeDomainData(data); + assert_equals(data[0], 1.0, "analyser2 time domain data"); + }); +}, "Test effect of AnalyserNode on GainNode output"); + </script> +</head> +</body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-minimum.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-minimum.html new file mode 100644 index 0000000000..ab0fe6b2d6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-minimum.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test AnalyserNode when the input is silent</title> + <meta name="timeout" content="long"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script> + setup({ single_test: true }); + var ac = new AudioContext(); + var analyser = ac.createAnalyser(); + var constant = ac.createConstantSource(); + var sp = ac.createScriptProcessor(2048, 1, 1); + + constant.offset.value = 0.0; + + constant.connect(analyser).connect(ac.destination); + + constant.connect(sp).connect(ac.destination); + + var buf = new Float32Array(analyser.frequencyBinCount); + var iteration_count = 10; + sp.onaudioprocess = function() { + analyser.getFloatFrequencyData(buf); + var correct = true; + for (var i = 0; i < buf.length; i++) { + correct &= buf[i] == -Infinity; + } + assert_true(!!correct, "silent input process -Infinity in decibel bins"); + if (!iteration_count--) { + sp.onaudioprocess = null; + constant.stop(); + ac.close(); + done(); + } + }; + + constant.start(); + </script> +</head> +</body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-output.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-output.html new file mode 100644 index 0000000000..43d56b8990 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-output.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>AnalyserNode output</title> + <meta name="timeout" content="long"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/js/helpers.js"></script> + <script> +setup({ single_test: true }); + +var gTest = { + length: 2048, + numberOfChannels: 1, + createGraph: function(context) { + var source = context.createBufferSource(); + + var analyser = context.createAnalyser(); + + source.buffer = this.buffer; + + source.connect(analyser); + + source.start(0); + return analyser; + }, + createExpectedBuffers: function(context) { + this.buffer = context.createBuffer(1, 2048, context.sampleRate); + for (var i = 0; i < 2048; ++i) { + this.buffer.getChannelData(0)[i] = Math.sin( + 440 * 2 * Math.PI * i / context.sampleRate + ); + } + + return [this.buffer]; + } +}; + +runTest("AnalyserNode output"); + </script> +</head> +</body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-scale.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-scale.html new file mode 100644 index 0000000000..904b14bede --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analyser-scale.html @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test AnalyserNode when the input is scaled</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script> + setup({ single_test: true }); + + var context = new AudioContext(); + + var gain = context.createGain(); + var analyser = context.createAnalyser(); + var osc = context.createOscillator(); + + osc.connect(gain); + gain.connect(analyser); + + osc.start(); + + var array = new Uint8Array(analyser.frequencyBinCount); + + function getAnalyserData() { + gain.gain.setValueAtTime(currentGain, context.currentTime); + analyser.getByteTimeDomainData(array); + var inrange = true; + var max = -1; + for (var i = 0; i < array.length; i++) { + if (array[i] > max) { + max = Math.abs(array[i] - 128); + } + } + if (max <= currentGain * 128) { + assert_true(true, "Analyser got scaled data for " + currentGain); + currentGain = tests.shift(); + if (currentGain == undefined) { + done(); + return; + } + } + requestAnimationFrame(getAnalyserData); + } + + var tests = [1.0, 0.5, 0.0]; + var currentGain = tests.shift(); + requestAnimationFrame(getAnalyserData); + </script> +</head> +</body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analysernode.html b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analysernode.html new file mode 100644 index 0000000000..e8325388d1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-analysernode-interface/test-analysernode.html @@ -0,0 +1,237 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script> + function testNode() { + var context = new AudioContext(); + var buffer = context.createBuffer(1, 2048, context.sampleRate); + for (var i = 0; i < 2048; ++i) { + buffer.getChannelData(0)[i] = Math.sin( + 440 * 2 * Math.PI * i / context.sampleRate + ); + } + + var destination = context.destination; + + var source = context.createBufferSource(); + + var analyser = context.createAnalyser(); + + source.buffer = buffer; + + source.connect(analyser); + analyser.connect(destination); + + assert_equals( + analyser.channelCount, + 2, + "analyser node has 2 input channels by default" + ); + assert_equals( + analyser.channelCountMode, + "max", + "Correct channelCountMode for the analyser node" + ); + assert_equals( + analyser.channelInterpretation, + "speakers", + "Correct channelCountInterpretation for the analyser node" + ); + + assert_equals( + analyser.fftSize, + 2048, + "Correct default value for fftSize" + ); + assert_equals( + analyser.frequencyBinCount, + 1024, + "Correct default value for frequencyBinCount" + ); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 0; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 1; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 8; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 100; + }); // non-power of two + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 2049; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 4097; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 8193; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 16385; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 32769; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.fftSize = 65536; + }); + analyser.fftSize = 1024; + assert_equals( + analyser.frequencyBinCount, + 512, + "Correct new value for frequencyBinCount" + ); + + assert_equals( + analyser.minDecibels, + -100, + "Correct default value for minDecibels" + ); + assert_equals( + analyser.maxDecibels, + -30, + "Correct default value for maxDecibels" + ); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.minDecibels = -30; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.minDecibels = -29; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.maxDecibels = -100; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.maxDecibels = -101; + }); + + assert_true( + Math.abs(analyser.smoothingTimeConstant - 0.8) < 0.001, + "Correct default value for smoothingTimeConstant" + ); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.smoothingTimeConstant = -0.1; + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser.smoothingTimeConstant = 1.1; + }); + analyser.smoothingTimeConstant = 0; + analyser.smoothingTimeConstant = 1; + } + + function testConstructor() { + var context = new AudioContext(); + + var analyser = new AnalyserNode(context); + assert_equals( + analyser.channelCount, + 2, + "analyser node has 2 input channels by default" + ); + assert_equals( + analyser.channelCountMode, + "max", + "Correct channelCountMode for the analyser node" + ); + assert_equals( + analyser.channelInterpretation, + "speakers", + "Correct channelCountInterpretation for the analyser node" + ); + + assert_equals( + analyser.fftSize, + 2048, + "Correct default value for fftSize" + ); + assert_equals( + analyser.frequencyBinCount, + 1024, + "Correct default value for frequencyBinCount" + ); + assert_equals( + analyser.minDecibels, + -100, + "Correct default value for minDecibels" + ); + assert_equals( + analyser.maxDecibels, + -30, + "Correct default value for maxDecibels" + ); + assert_true( + Math.abs(analyser.smoothingTimeConstant - 0.8) < 0.001, + "Correct default value for smoothingTimeConstant" + ); + + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 0 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 1 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 8 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 100 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 2049 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 4097 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 8193 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 16385 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 32769 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { fftSize: 65536 }); + }); + analyser = new AnalyserNode(context, { fftSize: 1024 }); + assert_equals( + analyser.frequencyBinCount, + 512, + "Correct new value for frequencyBinCount" + ); + + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { minDecibels: -30 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { minDecibels: -29 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { maxDecibels: -100 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { maxDecibels: -101 }); + }); + + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { smoothingTimeConstant: -0.1 }); + }); + assert_throws_dom("INDEX_SIZE_ERR", function() { + analyser = new AnalyserNode(context, { smoothingTimeConstant: -1.1 }); + }); + analyser = new AnalyserNode(context, { smoothingTimeConstant: 0 }); + analyser = new AnalyserNode(context, { smoothingTimeConstant: 1 }); + } + test(testNode, "Test AnalyserNode API"); + test(testConstructor, "Test AnalyserNode's ctor API"); + </script> +</head> +</body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/acquire-the-content.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/acquire-the-content.html new file mode 100644 index 0000000000..70f5d8e32c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/acquire-the-content.html @@ -0,0 +1,85 @@ +<!doctype html> +<meta charset="utf-8"> +<title>Test for AudioBuffer's "acquire the content" operation</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +const SAMPLERATE = 8000; +const LENGTH = 128; + +var tests = { + "AudioBufferSourceNode setter set with non-null buffer": function(oac) { + var buf = oac.createBuffer(1, LENGTH, SAMPLERATE) + var bs = new AudioBufferSourceNode(oac); + var channelData = buf.getChannelData(0); + for (var i = 0; i < channelData.length; i++) { + channelData[i] = 1.0; + } + bs.buffer = buf; + bs.start(); // This acquires the content since buf is not null + for (var i = 0; i < channelData.length; i++) { + channelData[i] = 0.5; + } + allSamplesAtOne(buf, "reading back"); + bs.connect(oac.destination); + return oac.startRendering(); + }, + "AudioBufferSourceNode buffer setter set with null" : (oac) => { + var buf = oac.createBuffer(1, LENGTH, SAMPLERATE) + var bs = new AudioBufferSourceNode(oac); + var channelData = buf.getChannelData(0); + for (var i = 0; i < channelData.length; i++) { + channelData[i] = 1.0; + } + bs.buffer = null; + bs.start(); // This does not acquire the content + bs.buffer = buf; // This does + for (var i = 0; i < channelData.length; i++) { + channelData[i] = 0.5; + } + allSamplesAtOne(buf, "reading back"); + bs.connect(oac.destination); + return oac.startRendering(); + }, + "ConvolverNode": (oac) => { + var buf = oac.createBuffer(1, LENGTH, SAMPLERATE) + var impulse = oac.createBuffer(1, 1, SAMPLERATE) + var bs = new AudioBufferSourceNode(oac); + var convolver = new ConvolverNode(oac, {disableNormalization: true}); + + impulse.getChannelData(0)[0] = 1.0; // unit impulse function + convolver.buffer = impulse; // This does acquire the content + impulse.getChannelData(0)[0] = 0.5; + + var channelData = buf.getChannelData(0); + for (var i = 0; i < channelData.length; i++) { + channelData[i] = 1.0; + } + bs.buffer = buf; + bs.start(); + + bs.connect(convolver).connect(oac.destination); + return oac.startRendering(); + } +}; + +function allSamplesAtOne(audiobuffer, location) { + var buf = audiobuffer.getChannelData(0); + for (var i = 0; i < buf.length; i++) { + // The convolver can introduce a slight numerical error. + if (Math.abs(buf[i] - 1.0) > 0.0001) { + assert_true(false, `Invalid value at index ${i}, expected close to 1.0, found ${buf[i]} when ${location}`) + return Promise.reject(); + } + } + assert_true(true, `Buffer unmodified when ${location}.`); + return Promise.resolve(); +} + +for (const test of Object.keys(tests)) { + promise_test(async function(t) { + var buf = await tests[test](new OfflineAudioContext(1, LENGTH, SAMPLERATE)); + return allSamplesAtOne(buf, "rendering"); + }, test); +}; +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-copy-channel.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-copy-channel.html new file mode 100644 index 0000000000..c0cd49d325 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-copy-channel.html @@ -0,0 +1,330 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Basic Functionality of AudioBuffer.copyFromChannel and + AudioBuffer.copyToChannel + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Define utility routines. + + // Initialize the AudioBuffer |buffer| with a ramp signal on each channel. + // The ramp starts at channel number + 1. + function initializeAudioBufferRamp(buffer) { + for (let c = 0; c < buffer.numberOfChannels; ++c) { + let d = buffer.getChannelData(c); + for (let k = 0; k < d.length; ++k) { + d[k] = k + c + 1; + } + } + } + + // Create a Float32Array of length |length| and initialize the array to + // -1. + function createInitializedF32Array(length) { + let x = new Float32Array(length); + for (let k = 0; k < length; ++k) { + x[k] = -1; + } + return x; + } + + // Create a Float32Array of length |length| that is initialized to be a + // ramp starting at 1. + function createFloat32RampArray(length) { + let x = new Float32Array(length); + for (let k = 0; k < x.length; ++k) { + x[k] = k + 1; + } + + return x; + } + + // Test that the array |x| is a ramp starting at value |start| of length + // |length|, starting at |startIndex| in the array. |startIndex| is + // optional and defaults to 0. Any other values must be -1. + function shouldBeRamp( + should, testName, x, startValue, length, startIndex) { + let k; + let startingIndex = startIndex || 0; + let expected = Array(x.length); + + // Fill the expected array with the correct results. + + // The initial part (if any) must be -1. + for (k = 0; k < startingIndex; ++k) { + expected[k] = -1; + } + + // The second part should be a ramp starting with |startValue| + for (; k < startingIndex + length; ++k) { + expected[k] = startValue + k - startingIndex; + } + + // The last part (if any) should be -1. + for (; k < x.length; ++k) { + expected[k] = -1; + } + + should(x, testName, {numberOfArrayLog: 32}).beEqualToArray(expected); + } + + let audit = Audit.createTaskRunner(); + + let context = new AudioContext(); + // Temp array for testing exceptions for copyToChannel/copyFromChannel. + // The length is arbitrary. + let x = new Float32Array(8); + + // Number of frames in the AudioBuffer for testing. This is pretty + // arbitrary so choose a fairly small value. + let bufferLength = 16; + + // Number of channels in the AudioBuffer. Also arbitrary, but it should + // be greater than 1 for test coverage. + let numberOfChannels = 3; + + // AudioBuffer that will be used for testing copyFrom and copyTo. + let buffer = context.createBuffer( + numberOfChannels, bufferLength, context.sampleRate); + + let initialValues = Array(numberOfChannels); + + // Initialize things + audit.define('initialize', (task, should) => { + // Initialize to -1. + initialValues.fill(-1); + should(initialValues, 'Initialized values').beConstantValueOf(-1) + task.done(); + }); + + // Test that expected exceptions are signaled for copyFrom. + audit.define('copyFrom-exceptions', (task, should) => { + should( + AudioBuffer.prototype.copyFromChannel, + 'AudioBuffer.prototype.copyFromChannel') + .exist(); + + should( + () => { + buffer = context.createBuffer( + numberOfChannels, bufferLength, context.sampleRate); + }, + '0: buffer = context.createBuffer(' + numberOfChannels + ', ' + + bufferLength + ', context.sampleRate)') + .notThrow(); + should(() => { + buffer.copyFromChannel(null, 0); + }, '1: buffer.copyFromChannel(null, 0)').throw(TypeError); + should(() => { + buffer.copyFromChannel(context, 0); + }, '2: buffer.copyFromChannel(context, 0)').throw(TypeError); + should(() => { + buffer.copyFromChannel(x, -1); + }, '3: buffer.copyFromChannel(x, -1)').throw(DOMException, 'IndexSizeError'); + should( + () => { + buffer.copyFromChannel(x, numberOfChannels); + }, + '4: buffer.copyFromChannel(x, ' + numberOfChannels + ')') + .throw(DOMException, 'IndexSizeError'); + ; + should(() => { + buffer.copyFromChannel(x, 0, -1); + }, '5: buffer.copyFromChannel(x, 0, -1)').notThrow(); + should( + () => { + buffer.copyFromChannel(x, 0, bufferLength); + }, + '6: buffer.copyFromChannel(x, 0, ' + bufferLength + ')') + .notThrow(); + + should(() => { + buffer.copyFromChannel(x, 3); + }, '7: buffer.copyFromChannel(x, 3)').throw(DOMException, 'IndexSizeError'); + + // See https://github.com/whatwg/html/issues/5380 for why not `new SharedArrayBuffer()` + // WebAssembly.Memory's size is in multiples of 64 KiB + const shared_buffer = new Float32Array(new WebAssembly.Memory({ shared:true, initial:1, maximum:1 }).buffer); + should( + () => { + buffer.copyFromChannel(shared_buffer, 0); + }, + '8: buffer.copyFromChannel(SharedArrayBuffer view, 0)') + .throw(TypeError); + + should( + () => { + buffer.copyFromChannel(shared_buffer, 0, 0); + }, + '9: buffer.copyFromChannel(SharedArrayBuffer view, 0, 0)') + .throw(TypeError); + + task.done(); + }); + + // Test that expected exceptions are signaled for copyTo. + audit.define('copyTo-exceptions', (task, should) => { + should( + AudioBuffer.prototype.copyToChannel, + 'AudioBuffer.prototype.copyToChannel') + .exist(); + should(() => { + buffer.copyToChannel(null, 0); + }, '0: buffer.copyToChannel(null, 0)').throw(TypeError); + should(() => { + buffer.copyToChannel(context, 0); + }, '1: buffer.copyToChannel(context, 0)').throw(TypeError); + should(() => { + buffer.copyToChannel(x, -1); + }, '2: buffer.copyToChannel(x, -1)').throw(DOMException, 'IndexSizeError'); + should( + () => { + buffer.copyToChannel(x, numberOfChannels); + }, + '3: buffer.copyToChannel(x, ' + numberOfChannels + ')') + .throw(DOMException, 'IndexSizeError'); + should(() => { + buffer.copyToChannel(x, 0, -1); + }, '4: buffer.copyToChannel(x, 0, -1)').notThrow(); + should( + () => { + buffer.copyToChannel(x, 0, bufferLength); + }, + '5: buffer.copyToChannel(x, 0, ' + bufferLength + ')') + .notThrow(); + + should(() => { + buffer.copyToChannel(x, 3); + }, '6: buffer.copyToChannel(x, 3)').throw(DOMException, 'IndexSizeError'); + + // See https://github.com/whatwg/html/issues/5380 for why not `new SharedArrayBuffer()` + // WebAssembly.Memory's size is in multiples of 64 KiB + const shared_buffer = new Float32Array(new WebAssembly.Memory({ shared:true, initial:1, maximum:1 }).buffer); + should( + () => { + buffer.copyToChannel(shared_buffer, 0); + }, + '7: buffer.copyToChannel(SharedArrayBuffer view, 0)') + .throw(TypeError); + + should( + () => { + buffer.copyToChannel(shared_buffer, 0, 0); + }, + '8: buffer.copyToChannel(SharedArrayBuffer view, 0, 0)') + .throw(TypeError); + + task.done(); + }); + + // Test copyFromChannel + audit.define('copyFrom-validate', (task, should) => { + // Initialize the AudioBuffer to a ramp for testing copyFrom. + initializeAudioBufferRamp(buffer); + + // Test copyFrom operation with a short destination array, filling the + // destination completely. + for (let c = 0; c < numberOfChannels; ++c) { + let dst8 = createInitializedF32Array(8); + buffer.copyFromChannel(dst8, c); + shouldBeRamp( + should, 'buffer.copyFromChannel(dst8, ' + c + ')', dst8, c + 1, 8) + } + + // Test copyFrom operation with a short destination array using a + // non-zero start index that still fills the destination completely. + for (let c = 0; c < numberOfChannels; ++c) { + let dst8 = createInitializedF32Array(8); + buffer.copyFromChannel(dst8, c, 1); + shouldBeRamp( + should, 'buffer.copyFromChannel(dst8, ' + c + ', 1)', dst8, c + 2, + 8) + } + + // Test copyFrom operation with a short destination array using a + // non-zero start index that does not fill the destinatiom completely. + // The extra elements should be unchanged. + for (let c = 0; c < numberOfChannels; ++c) { + let dst8 = createInitializedF32Array(8); + let startInChannel = bufferLength - 5; + buffer.copyFromChannel(dst8, c, startInChannel); + shouldBeRamp( + should, + 'buffer.copyFromChannel(dst8, ' + c + ', ' + startInChannel + ')', + dst8, c + 1 + startInChannel, bufferLength - startInChannel); + } + + // Copy operation with the destination longer than the buffer, leaving + // the trailing elements of the destination untouched. + for (let c = 0; c < numberOfChannels; ++c) { + let dst26 = createInitializedF32Array(bufferLength + 10); + buffer.copyFromChannel(dst26, c); + shouldBeRamp( + should, 'buffer.copyFromChannel(dst26, ' + c + ')', dst26, c + 1, + bufferLength); + } + + task.done(); + }); + + // Test copyTo + audit.define('copyTo-validate', (task, should) => { + // Create a source consisting of a ramp starting at 1, longer than the + // AudioBuffer + let src = createFloat32RampArray(bufferLength + 10); + + // Test copyTo with AudioBuffer shorter than Float32Array. The + // AudioBuffer should be completely filled with the Float32Array. + should( + () => { + buffer = + createConstantBuffer(context, bufferLength, initialValues); + }, + 'buffer = createConstantBuffer(context, ' + bufferLength + ', [' + + initialValues + '])') + .notThrow(); + + for (let c = 0; c < numberOfChannels; ++c) { + buffer.copyToChannel(src, c); + shouldBeRamp( + should, 'buffer.copyToChannel(src, ' + c + ')', + buffer.getChannelData(c), 1, bufferLength); + } + + // Test copyTo with AudioBuffer longer than the Float32Array. The tail + // of the AudioBuffer should be unchanged. + buffer = createConstantBuffer(context, bufferLength, initialValues); + let src10 = createFloat32RampArray(10); + for (let c = 0; c < numberOfChannels; ++c) { + buffer.copyToChannel(src10, c); + shouldBeRamp( + should, 'buffer.copyToChannel(src10, ' + c + ')', + buffer.getChannelData(c), 1, 10); + } + + // Test copyTo with non-default startInChannel. Part of the AudioBuffer + // should filled with the beginning and end sections untouched. + buffer = createConstantBuffer(context, bufferLength, initialValues); + for (let c = 0; c < numberOfChannels; ++c) { + let startInChannel = 5; + buffer.copyToChannel(src10, c, startInChannel); + + shouldBeRamp( + should, + 'buffer.copyToChannel(src10, ' + c + ', ' + startInChannel + ')', + buffer.getChannelData(c), 1, src10.length, startInChannel); + } + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-getChannelData.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-getChannelData.html new file mode 100644 index 0000000000..612a91cf4e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-getChannelData.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioBuffer.getChannelData() Returns the Same Object + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + let renderDuration = 0.5; + + let audit = Audit.createTaskRunner(); + + audit.define('buffer-eq', (task, should) => { + // Verify that successive calls to getChannelData return the same + // buffer. + let context = new AudioContext(); + let channelCount = 2; + let frameLength = 1000; + let buffer = + context.createBuffer(channelCount, frameLength, context.sampleRate); + + for (let c = 0; c < channelCount; ++c) { + let a = buffer.getChannelData(c); + let b = buffer.getChannelData(c); + + let message = 'buffer.getChannelData(' + c + ')'; + should(a === b, message + ' === ' + message).beEqualTo(true); + } + + task.done(); + }); + + audit.define('buffer-not-eq', (task, should) => { + let context = new AudioContext(); + let channelCount = 2; + let frameLength = 1000; + let buffer1 = + context.createBuffer(channelCount, frameLength, context.sampleRate); + let buffer2 = + context.createBuffer(channelCount, frameLength, context.sampleRate); + let success = true; + + for (let c = 0; c < channelCount; ++c) { + let a = buffer1.getChannelData(c); + let b = buffer2.getChannelData(c); + + let message = 'getChannelData(' + c + ')'; + should(a === b, 'buffer1.' + message + ' === buffer2.' + message) + .beEqualTo(false) && + success; + } + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-reuse.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-reuse.html new file mode 100644 index 0000000000..dabe323cbe --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer-reuse.html @@ -0,0 +1,36 @@ +<!doctype html> +<meta charset="utf-8"> +<title>AudioBuffer can be reused between AudioBufferSourceNodes</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +function render_audio_context() { + let sampleRate = 44100; + let context = new OfflineAudioContext( + 2, sampleRate * 0.1, sampleRate); + let buf = context.createBuffer(1, 0.1 * sampleRate, context.sampleRate); + let data = buf.getChannelData(0); + data[0] = 0.5; + data[1] = 0.25; + let b1 = context.createBufferSource(); + b1.buffer = buf; + b1.start(); + let b2 = context.createBufferSource(); + b2.buffer = buf; + b2.start(); + let merger = context.createChannelMerger(2); + b1.connect(merger, 0, 0); + b2.connect(merger, 0, 1); + merger.connect(context.destination); + return context.startRendering(); +} +promise_test(function() { + return render_audio_context() + .then(function(buffer) { + assert_equals(buffer.getChannelData(0)[0], 0.5); + assert_equals(buffer.getChannelData(1)[0], 0.5); + assert_equals(buffer.getChannelData(0)[1], 0.25); + assert_equals(buffer.getChannelData(1)[1], 0.25); + }); +}, "AudioBuffer can be reused between AudioBufferSourceNodes"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer.html new file mode 100644 index 0000000000..a2c4581c4e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/audiobuffer.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiobuffer.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 44100.0 + let lengthInSeconds = 2; + let numberOfChannels = 4; + + let audit = Audit.createTaskRunner(); + + audit.define('Basic tests for AudioBuffer', function(task, should) { + let context = new AudioContext(); + let buffer = context.createBuffer( + numberOfChannels, sampleRate * lengthInSeconds, sampleRate); + + // Just for printing out a message describing what "buffer" is in the + // following tests. + should( + true, + 'buffer = context.createBuffer(' + numberOfChannels + ', ' + + (sampleRate * lengthInSeconds) + ', ' + sampleRate + ')') + .beTrue(); + + should(buffer.sampleRate, 'buffer.sampleRate').beEqualTo(sampleRate); + + should(buffer.length, 'buffer.length') + .beEqualTo(sampleRate * lengthInSeconds); + + should(buffer.duration, 'buffer.duration').beEqualTo(lengthInSeconds); + + should(buffer.numberOfChannels, 'buffer.numberOfChannels') + .beEqualTo(numberOfChannels); + + for (let index = 0; index < buffer.numberOfChannels; ++index) { + should( + buffer.getChannelData(index) instanceof window.Float32Array, + 'buffer.getChannelData(' + index + + ') instanceof window.Float32Array') + .beTrue(); + } + + should( + function() { + buffer.getChannelData(buffer.numberOfChannels); + }, + 'buffer.getChannelData(' + buffer.numberOfChannels + ')') + .throw(DOMException, 'IndexSizeError'); + + let buffer2 = context.createBuffer(1, 1000, 24576); + let expectedDuration = 1000 / 24576; + + should( + buffer2.duration, 'context.createBuffer(1, 1000, 24576).duration') + .beEqualTo(expectedDuration); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/crashtests/copyFromChannel-bufferOffset-1.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/crashtests/copyFromChannel-bufferOffset-1.html new file mode 100644 index 0000000000..564317f7de --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/crashtests/copyFromChannel-bufferOffset-1.html @@ -0,0 +1,11 @@ +<html> +<head> + <title>Test large bufferOffset in copyFromChannel()</title> +</head> +<script> + const a = new AudioBuffer({length: 0x51986, sampleRate: 44100}); + const b = new Float32Array(0x10); + a.getChannelData(0); // to avoid zero data optimization + a.copyFromChannel(b, 0, 0x1523c7cc) +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/crashtests/copyToChannel-bufferOffset-1.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/crashtests/copyToChannel-bufferOffset-1.html new file mode 100644 index 0000000000..999925a983 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/crashtests/copyToChannel-bufferOffset-1.html @@ -0,0 +1,10 @@ +<html> +<head> + <title>Test large bufferOffset in copyToChannel()</title> +</head> +<script> + const a = new AudioBuffer({length: 0x10, sampleRate: 44100}); + const b = new Float32Array(0x51986); + a.copyToChannel(b, 0, 0x40004000) +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/ctor-audiobuffer.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/ctor-audiobuffer.html new file mode 100644 index 0000000000..fbe6e42e31 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffer-interface/ctor-audiobuffer.html @@ -0,0 +1,236 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: AudioBuffer + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + should(() => { + new AudioBuffer(); + }, 'new AudioBuffer()').throw(TypeError); + should(() => { + new AudioBuffer(1); + }, 'new AudioBuffer(1)').throw(TypeError); + should(() => { + new AudioBuffer(Date, 42); + }, 'new AudioBuffer(Date, 42)').throw(TypeError); + + task.done(); + }); + + audit.define('required options', (task, should) => { + let buffer; + + // The length and sampleRate attributes are required; all others are + // optional. + should(() => { + new AudioBuffer({}); + }, 'buffer = new AudioBuffer({})').throw(TypeError); + + should(() => { + new AudioBuffer({length: 1}); + }, 'buffer = new AudioBuffer({length: 1})').throw(TypeError); + + should(() => { + new AudioBuffer({sampleRate: 48000}); + }, 'buffer = new AudioBuffer({sampleRate: 48000})').throw(TypeError); + + should(() => { + buffer = new AudioBuffer({numberOfChannels: 1}); + }, 'buffer = new AudioBuffer({numberOfChannels: 1}').throw(TypeError); + + // Length and sampleRate are required, but others are optional. + should( + () => { + buffer = + new AudioBuffer({length: 21, sampleRate: context.sampleRate}); + }, + 'buffer0 = new AudioBuffer({length: 21, sampleRate: ' + + context.sampleRate + '}') + .notThrow(); + // Verify the buffer has the correct values. + should(buffer.numberOfChannels, 'buffer0.numberOfChannels') + .beEqualTo(1); + should(buffer.length, 'buffer0.length').beEqualTo(21); + should(buffer.sampleRate, 'buffer0.sampleRate') + .beEqualTo(context.sampleRate); + + should( + () => { + buffer = new AudioBuffer( + {numberOfChannels: 3, length: 1, sampleRate: 48000}); + }, + 'buffer1 = new AudioBuffer(' + + '{numberOfChannels: 3, length: 1, sampleRate: 48000})') + .notThrow(); + // Verify the buffer has the correct values. + should(buffer.numberOfChannels, 'buffer1.numberOfChannels') + .beEqualTo(3); + should(buffer.length, 'buffer1.length').beEqualTo(1); + should(buffer.sampleRate, 'buffer1.sampleRate').beEqualTo(48000); + + task.done(); + }); + + audit.define('invalid option values', (task, should) => { + let options = {numberOfChannels: 0, length: 1, sampleRate: 16000}; + should( + () => { + let buffer = new AudioBuffer(options); + }, + 'new AudioBuffer(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + options = {numberOfChannels: 99, length: 0, sampleRate: 16000}; + should( + () => { + let buffer = new AudioBuffer(options); + }, + 'new AudioBuffer(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + options = {numberOfChannels: 1, length: 0, sampleRate: 16000}; + should( + () => { + let buffer = new AudioBuffer(options); + }, + 'new AudioBuffer(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + options = {numberOfChannels: 1, length: 1, sampleRate: 100}; + should( + () => { + let buffer = new AudioBuffer(options); + }, + 'new AudioBuffer(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let buffer; + + let options = {numberOfChannels: 5, length: 17, sampleRate: 16000}; + should( + () => { + buffer = new AudioBuffer(options); + }, + 'buffer = new AudioBuffer(' + JSON.stringify(options) + ')') + .notThrow(); + + should(buffer.numberOfChannels, 'buffer.numberOfChannels') + .beEqualTo(options.numberOfChannels); + should(buffer.length, 'buffer.length').beEqualTo(options.length); + should(buffer.sampleRate, 'buffer.sampleRate').beEqualTo(16000); + + task.done(); + }); + + audit.define('valid constructor', (task, should) => { + let buffer; + + let options = {numberOfChannels: 3, length: 42, sampleRate: 54321}; + + let message = 'new AudioBuffer(' + JSON.stringify(options) + ')'; + should(() => { + buffer = new AudioBuffer(options); + }, message).notThrow(); + + should(buffer.numberOfChannels, 'buffer.numberOfChannels') + .beEqualTo(options.numberOfChannels); + + should(buffer.length, 'buffer.length').beEqualTo(options.length); + + should(buffer.sampleRate, 'buffer.sampleRate') + .beEqualTo(options.sampleRate); + + // Verify that we actually got the right number of channels + for (let k = 0; k < options.numberOfChannels; ++k) { + let data; + let message = 'buffer.getChannelData(' + k + ')'; + should(() => { + data = buffer.getChannelData(k); + }, message).notThrow(); + + should(data.length, message + ' length').beEqualTo(options.length); + } + + should( + () => { + buffer.getChannelData(options.numberOfChannels); + }, + 'buffer.getChannelData(' + options.numberOfChannels + ')') + .throw(DOMException, 'IndexSizeError'); + + task.done(); + }); + + audit.define('multiple contexts', (task, should) => { + // Test that an AudioBuffer can be used for different contexts. + let buffer = + new AudioBuffer({length: 128, sampleRate: context.sampleRate}); + + // Don't use getChannelData here because we want to be able to use + // |data| to compare the final results of playing out this buffer. (If + // we did, |data| gets detached when the sources play.) + let data = new Float32Array(buffer.length); + for (let k = 0; k < data.length; ++k) + data[k] = 1 + k; + buffer.copyToChannel(data, 0); + + let c1 = new OfflineAudioContext(1, 128, context.sampleRate); + let c2 = new OfflineAudioContext(1, 128, context.sampleRate); + + let s1 = new AudioBufferSourceNode(c1, {buffer: buffer}); + let s2 = new AudioBufferSourceNode(c2, {buffer: buffer}); + + s1.connect(c1.destination); + s2.connect(c2.destination); + + s1.start(); + s2.start(); + + Promise + .all([ + c1.startRendering().then(function(resultBuffer) { + return resultBuffer; + }), + c2.startRendering().then(function(resultBuffer) { + return resultBuffer; + }), + ]) + .then(resultBuffers => { + let c1ResultValue = should(resultBuffers[0].getChannelData(0), 'c1 result') + .beEqualToArray(data); + let c2ResultValue = should(resultBuffers[1].getChannelData(0), 'c2 result') + .beEqualToArray(data); + should( + c1ResultValue && c2ResultValue, + 'AudioBuffer shared between two different contexts') + .message('correctly', 'incorrectly'); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/active-processing.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/active-processing.https.html new file mode 100644 index 0000000000..25565b7686 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/active-processing.https.html @@ -0,0 +1,100 @@ +<!doctype html> +<html> + <head> + <title> + Test Active Processing for AudioBufferSourceNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Arbitrary sample rate. And we only new a few blocks for rendering to + // see if things are working. + let sampleRate = 8000; + let renderLength = 10 * RENDER_QUANTUM_FRAMES; + + // Offline context used for the tests. + let context; + + // Number of channels for the AudioBufferSource. Fairly arbitrary, but + // should be more than 2. + let numberOfChannels = 7; + + // Number of frames in the AudioBuffer. Fairly arbitrary, but should + // probably be more than one render quantum and significantly less than + // |renderLength|. + let bufferFrames = 131; + + let filePath = + '../the-audioworklet-interface/processors/input-count-processor.js'; + + audit.define('Setup graph', (task, should) => { + context = + new OfflineAudioContext(numberOfChannels, renderLength, sampleRate); + + should( + context.audioWorklet.addModule(filePath).then(() => { + let buffer = new AudioBuffer({ + numberOfChannels: numberOfChannels, + length: bufferFrames, + sampleRate: context.sampleRate + }); + + src = new AudioBufferSourceNode(context, {buffer: buffer}); + let counter = new AudioWorkletNode(context, 'counter'); + + src.connect(counter).connect(context.destination); + src.start(); + }), + 'AudioWorklet and graph construction') + .beResolved() + .then(() => task.done()); + }); + + audit.define('verify count change', (task, should) => { + context.startRendering() + .then(renderedBuffer => { + let output = renderedBuffer.getChannelData(0); + + // Find the first time the number of channels changes to 0. + let countChangeIndex = output.findIndex(x => x == 0); + + // Verify that the count did change. If it didn't there's a bug + // in the implementation, or it takes longer than the render + // length to change. For the latter case, increase the render + // length, but it can't be arbitrarily large. The change needs to + // happen at some reasonable time after the source stops. + should(countChangeIndex >= 0, 'Number of channels changed') + .beTrue(); + should( + countChangeIndex, 'Index where input channel count changed') + .beLessThanOrEqualTo(renderLength); + + // Verify the number of channels at the beginning matches the + // number of channels in the AudioBuffer. + should( + output.slice(0, countChangeIndex), + `Number of channels in input[0:${countChangeIndex - 1}]`) + .beConstantValueOf(numberOfChannels); + + // Verify that after the source has stopped, the number of + // channels is 0. + should( + output.slice(countChangeIndex), + `Number of channels in input[${countChangeIndex}:]`) + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-basic.html new file mode 100644 index 0000000000..6ce7eb0c10 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-basic.html @@ -0,0 +1,37 @@ +<!doctype html> +<html> + <head> + <title> + Basic Test of AudioBufferSourceNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/start-stop-exceptions.js"></script> + </head> + <script id="layout-test-code"> + let sampleRate = 44100; + let renderLengthSeconds = 0.25; + + let oscTypes = ['sine', 'square', 'sawtooth', 'triangle', 'custom']; + + let audit = Audit.createTaskRunner(); + + audit.define('start/stop exceptions', (task, should) => { + // We're not going to render anything, so make it simple + let context = new OfflineAudioContext(1, 1, sampleRate); + let node = new AudioBufferSourceNode(context); + + testStartStop(should, node, [ + {args: [0, -1], errorType: RangeError}, + {args: [0, 0, -1], errorType: RangeError} + ]); + task.done(); + }); + + audit.run(); + </script> + <body> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-channels.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-channels.html new file mode 100644 index 0000000000..f3f16c4c64 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-channels.html @@ -0,0 +1,97 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiobuffersource-channels.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let context; + let source; + + audit.define( + { + label: 'validate .buffer', + description: + 'Validatation of AudioBuffer in .buffer attribute setter' + }, + function(task, should) { + context = new AudioContext(); + source = context.createBufferSource(); + + // Make sure we can't set to something which isn't an AudioBuffer. + should(function() { + source.buffer = 57; + }, 'source.buffer = 57').throw(TypeError); + + // It's ok to set the buffer to null. + should(function() { + source.buffer = null; + }, 'source.buffer = null').notThrow(); + + // Set the buffer to a valid AudioBuffer + let buffer = + new AudioBuffer({length: 128, sampleRate: context.sampleRate}); + + should(function() { + source.buffer = buffer; + }, 'source.buffer = buffer').notThrow(); + + // The buffer has been set; we can't set it again. + should(function() { + source.buffer = + new AudioBuffer({length: 128, sampleRate: context.sampleRate}) + }, 'source.buffer = new buffer').throw(DOMException, 'InvalidStateError'); + + // The buffer has been set; it's ok to set it to null. + should(function() { + source.buffer = null; + }, 'source.buffer = null again').notThrow(); + + // The buffer was already set (and set to null). Can't set it + // again. + should(function() { + source.buffer = buffer; + }, 'source.buffer = buffer again').throw(DOMException, 'InvalidStateError'); + + // But setting to null is ok. + should(function() { + }, 'source.buffer = null after setting to null').notThrow(); + + // Check that mono buffer can be set. + should(function() { + let monoBuffer = + context.createBuffer(1, 1024, context.sampleRate); + let testSource = context.createBufferSource(); + testSource.buffer = monoBuffer; + }, 'Setting source with mono buffer').notThrow(); + + // Check that stereo buffer can be set. + should(function() { + let stereoBuffer = + context.createBuffer(2, 1024, context.sampleRate); + let testSource = context.createBufferSource(); + testSource.buffer = stereoBuffer; + }, 'Setting source with stereo buffer').notThrow(); + + // Check buffers with more than two channels. + for (let i = 3; i < 10; ++i) { + should(function() { + let buffer = context.createBuffer(i, 1024, context.sampleRate); + let testSource = context.createBufferSource(); + testSource.buffer = buffer; + }, 'Setting source with ' + i + ' channels buffer').notThrow(); + } + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-duration-loop.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-duration-loop.html new file mode 100644 index 0000000000..abb8983cc0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-duration-loop.html @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioBufferSourceNode With Looping And Duration + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + audit.define('loop with duration', (task, should) => { + // Create the context + let context = new OfflineAudioContext(1, 4096, 48000); + + // Create the sample buffer and fill the second half with 1 + let buffer = context.createBuffer(1, 2048, context.sampleRate); + for (let i = 1024; i < 2048; i++) { + buffer.getChannelData(0)[i] = 1; + } + + // Create the source and set its value + let source = context.createBufferSource(); + source.loop = true; + source.loopStart = 1024 / context.sampleRate; + source.loopEnd = 2048 / context.sampleRate; + source.buffer = buffer; + source.connect(context.destination); + source.start(0, 1024 / context.sampleRate, 2048 / context.sampleRate); + // Expectations + let expected = new Float32Array(4096); + for (let i = 0; i < 2048; i++) { + expected[i] = 1; + } + // Render it! + context.startRendering() + .then(function(audioBuffer) { + should( + audioBuffer.getChannelData(0), 'audioBuffer.getChannelData') + .beEqualToArray(expected); + }) + .then(task.done()); + }); + + audit.run(); + + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-ended.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-ended.html new file mode 100644 index 0000000000..b9922f61ef --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-ended.html @@ -0,0 +1,40 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiobuffersource-ended.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audiobuffersource-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let context; + let source; + + audit.define( + 'AudioBufferSourceNode calls its onended EventListener', + function(task, should) { + let sampleRate = 44100.0; + let numberOfFrames = 32; + context = new OfflineAudioContext(1, numberOfFrames, sampleRate); + source = context.createBufferSource(); + source.buffer = createTestBuffer(context, numberOfFrames); + source.connect(context.destination); + source.onended = function() { + should(true, 'source.onended called').beTrue(); + task.done(); + }; + source.start(0); + context.startRendering(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-grain.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-grain.html new file mode 100644 index 0000000000..f554304a21 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-grain.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Start Grain with Delayed Buffer Setting + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let context; + let source; + let buffer; + let renderedData; + + let sampleRate = 44100; + + let testDurationSec = 1; + let testDurationSamples = testDurationSec * sampleRate; + let startTime = 0.9 * testDurationSec; + + audit.define( + 'Test setting the source buffer after starting the grain', + function(task, should) { + context = + new OfflineAudioContext(1, testDurationSamples, sampleRate); + + buffer = createConstantBuffer(context, testDurationSamples, 1); + source = context.createBufferSource(); + source.connect(context.destination); + + // Start the source BEFORE we set the buffer. The grain offset and + // duration aren't important, as long as we specify some offset. + source.start(startTime, .1); + source.buffer = buffer; + + // Render it! + context.startRendering() + .then(function(buffer) { + checkResult(buffer, should); + }) + .then(task.done.bind(task)); + ; + }); + + function checkResult(buffer, should) { + let success = false; + + renderedData = buffer.getChannelData(0); + + // Check that the rendered data is not all zeroes. Any non-zero data + // means the test passed. + let startFrame = Math.round(startTime * sampleRate); + for (k = 0; k < renderedData.length; ++k) { + if (renderedData[k]) { + success = true; + break; + } + } + + should(success, 'Buffer was played').beTrue(); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-multi-channels.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-multi-channels.html new file mode 100644 index 0000000000..4e0de21e96 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-multi-channels.html @@ -0,0 +1,78 @@ +<!DOCTYPE html> +<!-- +Test AudioBufferSourceNode supports 5.1 channel. +--> +<html> + <head> + <title> + audiobuffersource-multi-channels.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/mix-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let context; + let expectedAudio; + + audit.define('initialize', (task, should) => { + // Create offline audio context + let sampleRate = 44100.0; + should(() => { + context = new OfflineAudioContext( + 6, sampleRate * toneLengthSeconds, sampleRate); + }, 'Creating context for testing').notThrow(); + should( + Audit + .loadFileFromUrl('resources/audiobuffersource-multi-channels-expected.wav') + .then(arrayBuffer => { + context.decodeAudioData(arrayBuffer).then(audioBuffer => { + expectedAudio = audioBuffer; + task.done(); + }).catch(error => { + assert_unreached("Could not decode audio data due to " + error.message); + }) + }) + , 'Fetching expected audio').beResolved(); + }); + + audit.define( + {label: 'test', description: 'AudioBufferSource with 5.1 buffer'}, + (task, should) => { + let toneBuffer = + createToneBuffer(context, 440, toneLengthSeconds, 6); + + let source = context.createBufferSource(); + source.buffer = toneBuffer; + + source.connect(context.destination); + source.start(0); + + context.startRendering() + .then(renderedAudio => { + // Compute a threshold based on the maximum error, |maxUlp|, + // in ULP. This is experimentally determined. Assuming that + // the reference file is a 16-bit wav file, the max values in + // the wave file are +/- 32768. + let maxUlp = 1; + let threshold = maxUlp / 32768; + for (let k = 0; k < renderedAudio.numberOfChannels; ++k) { + should( + renderedAudio.getChannelData(k), + 'Rendered audio for channel ' + k) + .beCloseToArray( + expectedAudio.getChannelData(k), + {absoluteThreshold: threshold}); + } + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-null.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-null.html new file mode 100644 index 0000000000..b5b1ec0c3d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-null.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test ABSN Outputs Silence if buffer is null + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + + audit.define('ABSN with null buffer', (task, should) => { + // Create test context. Length and sampleRate are pretty arbitrary, but + // we don't need either to be very large. + const context = new OfflineAudioContext( + {numberOfChannels: 1, length: 1024, sampleRate: 8192}); + + // Just create a constant buffer for testing. Anything will do as long + // as the buffer contents are not identically zero. + const audioBuffer = + new AudioBuffer({length: 10, sampleRate: context.sampleRate}); + const audioBufferSourceNode = new AudioBufferSourceNode(context); + + audioBuffer.getChannelData(0).fill(1); + + // These two tests are mostly for the informational messages to show + // what's happening. They should never fail! + should(() => { + audioBufferSourceNode.buffer = audioBuffer; + }, 'Setting ABSN.buffer to AudioBuffer').notThrow(); + + // This is the important part. Setting the buffer to null after setting + // it to something else should cause the source to produce silence. + should(() => { + audioBufferSourceNode.buffer = null; + }, 'Setting ABSN.buffer = null').notThrow(); + + audioBufferSourceNode.start(0); + audioBufferSourceNode.connect(context.destination); + + context.startRendering() + .then(buffer => { + // Since the buffer is null, the output of the source should be + // silence. + should(buffer.getChannelData(0), 'ABSN output') + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-one-sample-loop.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-one-sample-loop.html new file mode 100644 index 0000000000..af1454a5a9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-one-sample-loop.html @@ -0,0 +1,47 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioBufferSourceNode With Looping a Single-Sample Buffer + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 44100; + let testDurationSamples = 1000; + + audit.define('one-sample-loop', function(task, should) { + // Create the offline context for the test. + let context = + new OfflineAudioContext(1, testDurationSamples, sampleRate); + + // Create the single sample buffer + let buffer = createConstantBuffer(context, 1, 1); + + // Create the source and connect it to the destination + let source = context.createBufferSource(); + source.buffer = buffer; + source.loop = true; + source.connect(context.destination); + source.start(); + + // Render it! + context.startRendering() + .then(function(audioBuffer) { + should(audioBuffer.getChannelData(0), 'Rendered data') + .beConstantValueOf(1); + }) + .then(task.done.bind(task)); + ; + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-playbackrate-zero.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-playbackrate-zero.html new file mode 100644 index 0000000000..5624054e32 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-playbackrate-zero.html @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiobuffersource-playbackrate-zero.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Sample rate should be power of 128 to observe the change of AudioParam + // at the beginning of rendering quantum. (playbackRate is k-rate) This is + // the minimum sample rate in the valid sample rate range. + let sampleRate = 8192; + + // The render duration in seconds, and the length in samples. + let renderDuration = 1.0; + let renderLength = renderDuration * sampleRate; + + let context = new OfflineAudioContext(1, renderLength, sampleRate); + let audit = Audit.createTaskRunner(); + + + // Task: Render the actual buffer and compare with the reference. + audit.define('synthesize-verify', (task, should) => { + let ramp = context.createBufferSource(); + let rampBuffer = createLinearRampBuffer(context, renderLength); + ramp.buffer = rampBuffer; + + ramp.connect(context.destination); + ramp.start(); + + // Leave the playbackRate as 1 for the first half, then change it + // to zero at the exact half. The zero playback rate should hold the + // sample value of the buffer index at the moment. (sample-and-hold) + ramp.playbackRate.setValueAtTime(1.0, 0.0); + ramp.playbackRate.setValueAtTime(0.0, renderDuration / 2); + + context.startRendering() + .then(function(renderedBuffer) { + let data = renderedBuffer.getChannelData(0); + let rampData = rampBuffer.getChannelData(0); + let half = rampData.length / 2; + let passed = true; + let i; + + for (i = 1; i < rampData.length; i++) { + if (i < half) { + // Before the half position, the actual should match with the + // original ramp data. + if (data[i] !== rampData[i]) { + passed = false; + break; + } + } else { + // From the half position, the actual value should not change. + if (data[i] !== rampData[half]) { + passed = false; + break; + } + } + } + + should(passed, 'The zero playbackRate') + .message( + 'held the sample value correctly', + 'should hold the sample value. ' + + 'Expected ' + rampData[half] + ' but got ' + data[i] + + ' at the index ' + i); + }) + .then(() => task.done()); + }); + + audit.define('subsample start with playback rate 0', (task, should) => { + let context = new OfflineAudioContext(1, renderLength, sampleRate); + let rampBuffer = new AudioBuffer( + {length: renderLength, sampleRate: context.sampleRate}); + let data = new Float32Array(renderLength); + let startValue = 5; + for (let k = 0; k < data.length; ++k) { + data[k] = k + startValue; + } + rampBuffer.copyToChannel(data, 0); + + let src = new AudioBufferSourceNode( + context, {buffer: rampBuffer, playbackRate: 0}); + + src.connect(context.destination); + + // Purposely start the source between frame boundaries + let startFrame = 27.3; + src.start(startFrame / context.sampleRate); + + context.startRendering() + .then(audioBuffer => { + let actualStartFrame = Math.ceil(startFrame); + let audio = audioBuffer.getChannelData(0); + + should( + audio.slice(0, actualStartFrame), + `output[0:${actualStartFrame - 1}]`) + .beConstantValueOf(0); + should( + audio.slice(actualStartFrame), `output[${actualStartFrame}:]`) + .beConstantValueOf(startValue); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-start.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-start.html new file mode 100644 index 0000000000..19331954b0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiobuffersource-start.html @@ -0,0 +1,174 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiobuffersource-start.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audiobuffersource-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // The following test cases assume an AudioBuffer of length 8 whose PCM + // data is a linear ramp, 0, 1, 2, 3,... + + let tests = [ + + { + description: + 'start(when): implicitly play whole buffer from beginning to end', + offsetFrame: 'none', + durationFrames: 'none', + renderFrames: 16, + playbackRate: 1, + expected: [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 0): play whole buffer from beginning to end explicitly giving offset of 0', + offsetFrame: 0, + durationFrames: 'none', + renderFrames: 16, + playbackRate: 1, + expected: [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 0, 8_frames): play whole buffer from beginning to end explicitly giving offset of 0 and duration of 8 frames', + offsetFrame: 0, + durationFrames: 8, + renderFrames: 16, + playbackRate: 1, + expected: [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 4_frames): play with explicit non-zero offset', + offsetFrame: 4, + durationFrames: 'none', + renderFrames: 16, + playbackRate: 1, + expected: [4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 4_frames, 4_frames): play with explicit non-zero offset and duration', + offsetFrame: 4, + durationFrames: 4, + renderFrames: 16, + playbackRate: 1, + expected: [4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 7_frames): play with explicit non-zero offset near end of buffer', + offsetFrame: 7, + durationFrames: 1, + renderFrames: 16, + playbackRate: 1, + expected: [7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 8_frames): play with explicit offset at end of buffer', + offsetFrame: 8, + durationFrames: 0, + renderFrames: 16, + playbackRate: 1, + expected: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + { + description: + 'start(when, 9_frames): play with explicit offset past end of buffer', + offsetFrame: 8, + durationFrames: 0, + renderFrames: 16, + playbackRate: 1, + expected: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + // When the duration exceeds the buffer, just play to the end of the + // buffer. (This is different from the case when we're looping, which is + // tested in loop-comprehensive.) + { + description: + 'start(when, 0, 15_frames): play with whole buffer, with long duration (clipped)', + offsetFrame: 0, + durationFrames: 15, + renderFrames: 16, + playbackRate: 1, + expected: [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0, 0, 0, 0, 0] + }, + + // Enable test when AudioBufferSourceNode hack is fixed: + // https://bugs.webkit.org/show_bug.cgi?id=77224 { description: + // "start(when, 3_frames, 3_frames): play a middle section with explicit + // offset and duration", + // offsetFrame: 3, durationFrames: 3, renderFrames: 16, playbackRate: + // 1, expected: [4,5,6,7,0,0,0,0,0,0,0,0,0,0,0,0] }, + + ]; + + let sampleRate = 44100; + let buffer; + let bufferFrameLength = 8; + let testSpacingFrames = 32; + let testSpacingSeconds = testSpacingFrames / sampleRate; + let totalRenderLengthFrames = tests.length * testSpacingFrames; + + function runLoopTest(context, testNumber, test) { + let source = context.createBufferSource(); + + source.buffer = buffer; + source.playbackRate.value = test.playbackRate; + + source.connect(context.destination); + + // Render each test one after the other, spaced apart by + // testSpacingSeconds. + let startTime = testNumber * testSpacingSeconds; + + if (test.offsetFrame == 'none' && test.durationFrames == 'none') { + source.start(startTime); + } else if (test.durationFrames == 'none') { + let offset = test.offsetFrame / context.sampleRate; + source.start(startTime, offset); + } else { + let offset = test.offsetFrame / context.sampleRate; + let duration = test.durationFrames / context.sampleRate; + source.start(startTime, offset, duration); + } + } + + audit.define( + 'Tests AudioBufferSourceNode start()', function(task, should) { + // Create offline audio context. + let context = + new OfflineAudioContext(1, totalRenderLengthFrames, sampleRate); + buffer = createTestBuffer(context, bufferFrameLength); + + for (let i = 0; i < tests.length; ++i) + runLoopTest(context, i, tests[i]); + + context.startRendering().then(function(audioBuffer) { + checkAllTests(audioBuffer, should); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiosource-onended.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiosource-onended.html new file mode 100644 index 0000000000..20ef4a1c63 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiosource-onended.html @@ -0,0 +1,101 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Onended Event Listener + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 44100; + let renderLengthSeconds = 1; + let renderLengthFrames = renderLengthSeconds * sampleRate; + + // Length of the source buffer. Anything less than the render length is + // fine. + let sourceBufferLengthFrames = renderLengthFrames / 8; + // When to stop the oscillator. Anything less than the render time is + // fine. + let stopTime = renderLengthSeconds / 8; + + let audit = Audit.createTaskRunner(); + + audit.define('absn-set-onended', (task, should) => { + // Test that the onended event for an AudioBufferSourceNode is fired + // when it is set directly. + let context = + new OfflineAudioContext(1, renderLengthFrames, sampleRate); + let buffer = context.createBuffer( + 1, sourceBufferLengthFrames, context.sampleRate); + let source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + source.onended = function(e) { + should( + true, 'AudioBufferSource.onended called when ended set directly') + .beEqualTo(true); + }; + source.start(); + context.startRendering().then(() => task.done()); + }); + + audit.define('absn-add-listener', (task, should) => { + // Test that the onended event for an AudioBufferSourceNode is fired + // when addEventListener is used to set the handler. + let context = + new OfflineAudioContext(1, renderLengthFrames, sampleRate); + let buffer = context.createBuffer( + 1, sourceBufferLengthFrames, context.sampleRate); + let source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + source.addEventListener('ended', function(e) { + should( + true, + 'AudioBufferSource.onended called when using addEventListener') + .beEqualTo(true); + }); + source.start(); + context.startRendering().then(() => task.done()); + }); + + audit.define('osc-set-onended', (task, should) => { + // Test that the onended event for an OscillatorNode is fired when it is + // set directly. + let context = + new OfflineAudioContext(1, renderLengthFrames, sampleRate); + let source = context.createOscillator(); + source.connect(context.destination); + source.onended = function(e) { + should(true, 'Oscillator.onended called when ended set directly') + .beEqualTo(true); + }; + source.start(); + source.stop(stopTime); + context.startRendering().then(() => task.done()); + }); + + audit.define('osc-add-listener', (task, should) => { + // Test that the onended event for an OscillatorNode is fired when + // addEventListener is used to set the handler. + let context = + new OfflineAudioContext(1, renderLengthFrames, sampleRate); + let source = context.createOscillator(); + source.connect(context.destination); + source.addEventListener('ended', function(e) { + should(true, 'Oscillator.onended called when using addEventListener') + .beEqualTo(true); + }); + source.start(); + source.stop(stopTime); + context.startRendering().then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiosource-time-limits.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiosource-time-limits.html new file mode 100644 index 0000000000..3ac9c05938 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/audiosource-time-limits.html @@ -0,0 +1,74 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Scheduled Sources with Huge Time Limits + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + let renderFrames = 1000; + + let audit = Audit.createTaskRunner(); + + audit.define('buffersource: huge stop time', (task, should) => { + // We only need to generate a small number of frames for this test. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let src = context.createBufferSource(); + + // Constant source of amplitude 1, looping. + src.buffer = createConstantBuffer(context, 1, 1); + src.loop = true; + + // Create the graph and go! + let endTime = 1e300; + src.connect(context.destination); + src.start(); + src.stop(endTime); + + context.startRendering() + .then(function(resultBuffer) { + let result = resultBuffer.getChannelData(0); + should( + result, 'Output from AudioBufferSource.stop(' + endTime + ')') + .beConstantValueOf(1); + }) + .then(() => task.done()); + }); + + + audit.define('oscillator: huge stop time', (task, should) => { + // We only need to generate a small number of frames for this test. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let src = context.createOscillator(); + + // Create the graph and go! + let endTime = 1e300; + src.connect(context.destination); + src.start(); + src.stop(endTime); + + context.startRendering() + .then(function(resultBuffer) { + let result = resultBuffer.getChannelData(0); + // The buffer should not be empty. Just find the max and verify + // that it's not zero. + let max = Math.max.apply(null, result); + should( + max, 'Peak amplitude from oscillator.stop(' + endTime + ')') + .beGreaterThan(0); + }) + .then(() => task.done()); + }); + + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/buffer-resampling.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/buffer-resampling.html new file mode 100644 index 0000000000..c181ceb8e0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/buffer-resampling.html @@ -0,0 +1,101 @@ +<!doctype html> +<html> + <head> + <title>Test Extrapolation at end of AudibBuffer in an AudioBufferSourceNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + const sampleRate = 48000; + + // For testing we only need a few render quanta. + const renderSamples = 512 + + // Sample rate for our buffers. This is the lowest sample rate that is + // required to be supported. + const bufferRate = 8000; + + // Number of samples in each AudioBuffer; this is fairly arbitrary but + // should be less than a render quantum. + const bufferLength = 30; + + // Frequency of the sine wave for testing. + const frequency = 440; + + audit.define( + { + label: 'interpolate', + description: 'Interpolation of AudioBuffers to context sample rate' + }, + (task, should) => { + // The first channel is for the interpolated signal, and the second + // channel is for the reference signal from an oscillator. + let context = new OfflineAudioContext({ + numberOfChannels: 2, + length: renderSamples, + sampleRate: sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfChannels: context.destination.channelCount}); + merger.connect(context.destination); + + // Create a set of AudioBuffers which are samples from a pure sine + // wave with frequency |frequency|. + const nBuffers = Math.floor(context.length / bufferLength); + const omega = 2 * Math.PI * frequency / bufferRate; + + let frameNumber = 0; + let startTime = 0; + + for (let k = 0; k < nBuffers; ++k) { + // let buffer = context.createBuffer(1, bufferLength, + // bufferRate); + let buffer = new AudioBuffer( + {length: bufferLength, sampleRate: bufferRate}); + let data = buffer.getChannelData(0); + for (let n = 0; n < bufferLength; ++n) { + data[n] = Math.sin(omega * frameNumber); + ++frameNumber; + } + // Create a source using this buffer and start it at the end of + // the previous buffer. + let src = new AudioBufferSourceNode(context, {buffer: buffer}); + + src.connect(merger, 0, 0); + src.start(startTime); + startTime += buffer.duration; + } + + // Create the reference sine signal using an oscillator. + let osc = new OscillatorNode( + context, {type: 'sine', frequency: frequency}); + osc.connect(merger, 0, 1); + osc.start(0); + + context.startRendering() + .then(audioBuffer => { + let actual = audioBuffer.getChannelData(0); + let expected = audioBuffer.getChannelData(1); + + should(actual, 'Interpolated sine wave') + .beCloseToArray(expected, {absoluteThreshold: 9.0348e-2}); + + // Compute SNR between them. + let snr = 10 * Math.log10(computeSNR(actual, expected)); + + should(snr, `SNR (${snr.toPrecision(4)} dB)`) + .beGreaterThanOrEqualTo(37.17); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/ctor-audiobuffersource.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/ctor-audiobuffersource.html new file mode 100644 index 0000000000..c1c3203451 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/ctor-audiobuffersource.html @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: AudioBufferSource + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'AudioBufferSourceNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = + testDefaultConstructor(should, 'AudioBufferSourceNode', context, { + prefix: prefix, + numberOfInputs: 0, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [ + {name: 'buffer', value: null}, + {name: 'detune', value: 0}, + {name: 'loop', value: false}, + {name: 'loopEnd', value: 0.0}, + {name: 'loopStart', value: 0.0}, + {name: 'playbackRate', value: 1.0}, + ]); + + task.done(); + }); + + audit.define('nullable buffer', (task, should) => { + let node; + let options = {buffer: null}; + + should( + () => { + node = new AudioBufferSourceNode(context, options); + }, + 'node1 = new AudioBufferSourceNode(c, ' + JSON.stringify(options)) + .notThrow(); + + should(node.buffer, 'node1.buffer').beEqualTo(null); + + task.done(); + }); + + audit.define('constructor options', (task, should) => { + let node; + let buffer = context.createBuffer(2, 1000, context.sampleRate); + + let options = { + buffer: buffer, + detune: .5, + loop: true, + loopEnd: (buffer.length / 2) / context.sampleRate, + loopStart: 5 / context.sampleRate, + playbackRate: .75 + }; + + let message = 'node = new AudioBufferSourceNode(c, ' + + JSON.stringify(options) + ')'; + + should(() => { + node = new AudioBufferSourceNode(context, options); + }, message).notThrow(); + + // Use the factory method to create an equivalent node and compare the + // results from the constructor against this node. + let factoryNode = context.createBufferSource(); + factoryNode.buffer = options.buffer; + factoryNode.detune.value = options.detune; + factoryNode.loop = options.loop; + factoryNode.loopEnd = options.loopEnd; + factoryNode.loopStart = options.loopStart; + factoryNode.playbackRate.value = options.playbackRate; + + should(node.buffer === buffer, 'node2.buffer === buffer') + .beEqualTo(true); + should(node.detune.value, 'node2.detune.value') + .beEqualTo(factoryNode.detune.value); + should(node.loop, 'node2.loop').beEqualTo(factoryNode.loop); + should(node.loopEnd, 'node2.loopEnd').beEqualTo(factoryNode.loopEnd); + should(node.loopStart, 'node2.loopStart') + .beEqualTo(factoryNode.loopStart); + should(node.playbackRate.value, 'node2.playbackRate.value') + .beEqualTo(factoryNode.playbackRate.value); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/note-grain-on-play.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/note-grain-on-play.html new file mode 100644 index 0000000000..37c4462add --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/note-grain-on-play.html @@ -0,0 +1,121 @@ +<!DOCTYPE html> +<html> + <head> + <title> + note-grain-on-play.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/note-grain-on-testing.js"></script> + </head> + <body> + <div id="description"></div> + <div id="console"></div> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // To test noteGrainOn, a single ramp signal is created. + // Various sections of the ramp are rendered by noteGrainOn() at + // different times, and we verify that the actual output + // consists of the correct section of the ramp at the correct + // time. + + let linearRampBuffer; + + // Array of the grain offset used for each ramp played. + let grainOffsetTime = []; + + // Verify the received signal is a ramp from the correct section + // of our ramp signal. + function verifyGrain(renderedData, startFrame, endFrame, grainIndex) { + let grainOffsetFrame = + timeToSampleFrame(grainOffsetTime[grainIndex], sampleRate); + let grainFrameLength = endFrame - startFrame; + let ramp = linearRampBuffer.getChannelData(0); + let isCorrect = true; + + let expected; + let actual; + let frame; + + for (let k = 0; k < grainFrameLength; ++k) { + if (renderedData[startFrame + k] != ramp[grainOffsetFrame + k]) { + expected = ramp[grainOffsetFrame + k]; + actual = renderedData[startFrame + k]; + frame = startFrame + k; + isCorrect = false; + break; + } + } + return { + verified: isCorrect, + expected: expected, + actual: actual, + frame: frame + }; + } + + function checkResult(buffer, should) { + renderedData = buffer.getChannelData(0); + let nSamples = renderedData.length; + + // Number of grains that we found that have incorrect data. + let invalidGrainDataCount = 0; + + let startEndFrames = findStartAndEndSamples(renderedData); + + // Verify the start and stop times. Not strictly needed for + // this test, but it's useful to know that if the ramp data + // appears to be incorrect. + verifyStartAndEndFrames(startEndFrames, should); + + // Loop through each of the rendered grains and check that + // each grain contains our expected ramp. + for (let k = 0; k < startEndFrames.start.length; ++k) { + // Verify that the rendered data matches the expected + // section of our ramp signal. + let result = verifyGrain( + renderedData, startEndFrames.start[k], startEndFrames.end[k], k); + should(result.verified, 'Pulse ' + k + ' contained the expected data') + .beTrue(); + } + should( + invalidGrainDataCount, + 'Number of grains that did not contain the expected data') + .beEqualTo(0); + } + + audit.define( + { + label: 'note-grain-on-play', + description: 'Test noteGrainOn offset rendering' + }, + function(task, should) { + // Create offline audio context. + context = + new OfflineAudioContext(2, sampleRate * renderTime, sampleRate); + + // Create a linear ramp for testing noteGrainOn. + linearRampBuffer = createSignalBuffer(context, function(k) { + // Want the ramp to start + // with 1, not 0. + return k + 1; + }); + + let grainInfo = + playAllGrains(context, linearRampBuffer, numberOfTests); + + grainOffsetTime = grainInfo.grainOffsetTimes; + + context.startRendering().then(function(audioBuffer) { + checkResult(audioBuffer, should); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/note-grain-on-timing.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/note-grain-on-timing.html new file mode 100644 index 0000000000..0db297b42c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/note-grain-on-timing.html @@ -0,0 +1,47 @@ +<!DOCTYPE html> +<html> + <head> + <title> + note-grain-on-timing.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/note-grain-on-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let squarePulseBuffer; + + function checkResult(buffer, should) { + renderedData = buffer.getChannelData(0); + let nSamples = renderedData.length; + let startEndFrames = findStartAndEndSamples(renderedData); + + verifyStartAndEndFrames(startEndFrames, should); + } + + audit.define('Test timing of noteGrainOn', function(task, should) { + // Create offline audio context. + context = + new OfflineAudioContext(2, sampleRate * renderTime, sampleRate); + + squarePulseBuffer = createSignalBuffer(context, function(k) { + return 1 + }); + + playAllGrains(context, squarePulseBuffer, numberOfTests); + + context.startRendering().then(function(audioBuffer) { + checkResult(audioBuffer, should); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/resources/audiobuffersource-multi-channels-expected.wav b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/resources/audiobuffersource-multi-channels-expected.wav Binary files differnew file mode 100644 index 0000000000..ab9d5fe5a9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/resources/audiobuffersource-multi-channels-expected.wav diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sample-accurate-scheduling.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sample-accurate-scheduling.html new file mode 100644 index 0000000000..fd244e8a5f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sample-accurate-scheduling.html @@ -0,0 +1,109 @@ +<!DOCTYPE html> +<!-- +Tests that we are able to schedule a series of notes to playback with sample-accuracy. +We use an impulse so we can tell exactly where the rendering is happening. +--> +<html> + <head> + <title> + sample-accurate-scheduling.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 44100.0; + let lengthInSeconds = 4; + + let context = 0; + let bufferLoader = 0; + let impulse; + + // See if we can render at exactly these sample offsets. + let sampleOffsets = [0, 3, 512, 517, 1000, 1005, 20000, 21234, 37590]; + + function createImpulse() { + // An impulse has a value of 1 at time 0, and is otherwise 0. + impulse = context.createBuffer(2, 512, sampleRate); + let sampleDataL = impulse.getChannelData(0); + let sampleDataR = impulse.getChannelData(1); + sampleDataL[0] = 1.0; + sampleDataR[0] = 1.0; + } + + function playNote(time) { + let bufferSource = context.createBufferSource(); + bufferSource.buffer = impulse; + bufferSource.connect(context.destination); + bufferSource.start(time); + } + + function checkSampleAccuracy(buffer, should) { + let bufferDataL = buffer.getChannelData(0); + let bufferDataR = buffer.getChannelData(1); + + let impulseCount = 0; + let badOffsetCount = 0; + + // Left and right channels must be the same. + should(bufferDataL, 'Content of left and right channels match and') + .beEqualToArray(bufferDataR); + + // Go through every sample and make sure it's 0, except at positions in + // sampleOffsets. + for (let i = 0; i < buffer.length; ++i) { + if (bufferDataL[i] != 0) { + // Make sure this index is in sampleOffsets + let found = false; + for (let j = 0; j < sampleOffsets.length; ++j) { + if (sampleOffsets[j] == i) { + found = true; + break; + } + } + ++impulseCount; + should(found, 'Non-zero sample found at sample offset ' + i) + .beTrue(); + if (!found) { + ++badOffsetCount; + } + } + } + + should(impulseCount, 'Number of impulses found') + .beEqualTo(sampleOffsets.length); + + if (impulseCount == sampleOffsets.length) { + should(badOffsetCount, 'bad offset').beEqualTo(0); + } + } + + audit.define( + {label: 'test', description: 'Test sample-accurate scheduling'}, + function(task, should) { + + // Create offline audio context. + context = new OfflineAudioContext( + 2, sampleRate * lengthInSeconds, sampleRate); + createImpulse(); + + for (let i = 0; i < sampleOffsets.length; ++i) { + let timeInSeconds = sampleOffsets[i] / sampleRate; + playNote(timeInSeconds); + } + + context.startRendering().then(function(buffer) { + checkSampleAccuracy(buffer, should); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-buffer-stitching.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-buffer-stitching.html new file mode 100644 index 0000000000..3700bfa8ce --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-buffer-stitching.html @@ -0,0 +1,133 @@ +<!doctype html> +<html> + <head> + <title> + Test Sub-Sample Accurate Stitching of ABSNs + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'buffer-stitching-1', + description: 'Subsample buffer stitching, same rates' + }, + (task, should) => { + const sampleRate = 44100; + const bufferRate = 44100; + const bufferLength = 30; + + // Experimentally determined thresholds. DO NOT relax these values + // to far from these values to make the tests pass. + const errorThreshold = 9.0957e-5; + const snrThreshold = 85.580; + + // Informative message + should(sampleRate, 'Test 1: context.sampleRate') + .beEqualTo(sampleRate); + testBufferStitching(sampleRate, bufferRate, bufferLength) + .then(resultBuffer => { + const actual = resultBuffer.getChannelData(0); + const expected = resultBuffer.getChannelData(1); + should( + actual, + `Stitched sine-wave buffers at sample rate ${bufferRate}`) + .beCloseToArray( + expected, {absoluteThreshold: errorThreshold}); + const SNR = 10 * Math.log10(computeSNR(actual, expected)); + should(SNR, `SNR (${SNR} dB)`) + .beGreaterThanOrEqualTo(snrThreshold); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'buffer-stitching-2', + description: 'Subsample buffer stitching, different rates' + }, + (task, should) => { + const sampleRate = 44100; + const bufferRate = 43800; + const bufferLength = 30; + + // Experimentally determined thresholds. DO NOT relax these values + // to far from these values to make the tests pass. + const errorThreshold = 3.8986e-3; + const snrThreshold = 65.737; + + // Informative message + should(sampleRate, 'Test 2: context.sampleRate') + .beEqualTo(sampleRate); + testBufferStitching(sampleRate, bufferRate, bufferLength) + .then(resultBuffer => { + const actual = resultBuffer.getChannelData(0); + const expected = resultBuffer.getChannelData(1); + should( + actual, + `Stitched sine-wave buffers at sample rate ${bufferRate}`) + .beCloseToArray( + expected, {absoluteThreshold: errorThreshold}); + const SNR = 10 * Math.log10(computeSNR(actual, expected)); + should(SNR, `SNR (${SNR} dB)`) + .beGreaterThanOrEqualTo(snrThreshold); + }) + .then(() => task.done()); + }); + + audit.run(); + + // Create graph to test stitching of consecutive ABSNs. The context rate + // is |sampleRate|, and the buffers have a fixed length of |bufferLength| + // and rate of |bufferRate|. The |bufferRate| should not be too different + // from |sampleRate| because of interpolation of the buffer to the context + // rate. + function testBufferStitching(sampleRate, bufferRate, bufferLength) { + // The context for testing. Channel 0 contains the output from + // stitching all the buffers together, and channel 1 contains the + // expected output. + const context = new OfflineAudioContext( + {numberOfChannels: 2, length: sampleRate, sampleRate: sampleRate}); + + const merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + merger.connect(context.destination); + + // The reference is a sine wave at 440 Hz. + const ref = new OscillatorNode(context, {frequency: 440, type: 'sine'}); + ref.connect(merger, 0, 1); + ref.start(); + + // The test signal is a bunch of short AudioBufferSources containing + // bits of a sine wave. + let waveSignal = new Float32Array(context.length); + const omega = 2 * Math.PI / bufferRate * ref.frequency.value; + for (let k = 0; k < context.length; ++k) { + waveSignal[k] = Math.sin(omega * k); + } + + // Slice the sine wave into many little buffers to be assigned to ABSNs + // that are started at the appropriate times to produce a final sine + // wave. + for (let k = 0; k < context.length; k += bufferLength) { + const buffer = + new AudioBuffer({length: bufferLength, sampleRate: bufferRate}); + buffer.copyToChannel(waveSignal.slice(k, k + bufferLength), 0); + + const src = new AudioBufferSourceNode(context, {buffer: buffer}); + src.connect(merger, 0, 0); + src.start(k / bufferRate); + } + + return context.startRendering(); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-scheduling.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-scheduling.html new file mode 100644 index 0000000000..8c627f90f2 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiobuffersourcenode-interface/sub-sample-scheduling.html @@ -0,0 +1,423 @@ +<!doctype html> +<html> + <head> + <title> + Test Sub-Sample Accurate Scheduling for ABSN + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script> + // Power of two so there's no roundoff converting from integer frames to + // time. + let sampleRate = 32768; + + let audit = Audit.createTaskRunner(); + + audit.define('sub-sample accurate start', (task, should) => { + // There are two channels, one for each source. Only need to render + // quanta for this test. + let context = new OfflineAudioContext( + {numberOfChannels: 2, length: 8192, sampleRate: sampleRate}); + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + merger.connect(context.destination); + + // Use a simple linear ramp for the sources with integer steps starting + // at 1 to make it easy to verify and test that have sub-sample accurate + // start. Ramp starts at 1 so we can easily tell when the source + // starts. + let rampBuffer = new AudioBuffer( + {length: context.length, sampleRate: context.sampleRate}); + let r = rampBuffer.getChannelData(0); + for (let k = 0; k < r.length; ++k) { + r[k] = k + 1; + } + + const src0 = new AudioBufferSourceNode(context, {buffer: rampBuffer}); + const src1 = new AudioBufferSourceNode(context, {buffer: rampBuffer}); + + // Frame where sources should start. This is pretty arbitrary, but one + // should be close to an integer and the other should be close to the + // next integer. We do this to catch the case where rounding of the + // start frame is being done. Rounding is incorrect. + const startFrame = 33; + const startFrame0 = startFrame + 0.1; + const startFrame1 = startFrame + 0.9; + + src0.connect(merger, 0, 0); + src1.connect(merger, 0, 1); + + src0.start(startFrame0 / context.sampleRate); + src1.start(startFrame1 / context.sampleRate); + + context.startRendering() + .then(audioBuffer => { + const output0 = audioBuffer.getChannelData(0); + const output1 = audioBuffer.getChannelData(1); + + // Compute the expected output by interpolating the ramp buffer of + // the sources if they started at the given frame. + const ramp = rampBuffer.getChannelData(0); + const expected0 = interpolateRamp(ramp, startFrame0); + const expected1 = interpolateRamp(ramp, startFrame1); + + // Verify output0 has the correct values + + // For information only + should(startFrame0, 'src0 start frame').beEqualTo(startFrame0); + + // Output must be zero before the source start frame, and it must + // be interpolated correctly after the start frame. The + // absoluteThreshold below is currently set for Chrome which does + // linear interpolation. This needs to be updated eventually if + // other browsers do not user interpolation. + should( + output0.slice(0, startFrame + 1), `output0[0:${startFrame}]`) + .beConstantValueOf(0); + should( + output0.slice(startFrame + 1, expected0.length), + `output0[${startFrame + 1}:${expected0.length - 1}]`) + .beCloseToArray( + expected0.slice(startFrame + 1), {absoluteThreshold: 0}); + + // Verify output1 has the correct values. Same approach as for + // output0. + should(startFrame1, 'src1 start frame').beEqualTo(startFrame1); + + should( + output1.slice(0, startFrame + 1), `output1[0:${startFrame}]`) + .beConstantValueOf(0); + should( + output1.slice(startFrame + 1, expected1.length), + `output1[${startFrame + 1}:${expected1.length - 1}]`) + .beCloseToArray( + expected1.slice(startFrame + 1), {absoluteThreshold: 0}); + }) + .then(() => task.done()); + }); + + audit.define('sub-sample accurate stop', (task, should) => { + // There are threes channesl, one for each source. Only need to render + // quanta for this test. + let context = new OfflineAudioContext( + {numberOfChannels: 3, length: 128, sampleRate: sampleRate}); + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + merger.connect(context.destination); + + // The source can be as simple constant for this test. + let buffer = new AudioBuffer( + {length: context.length, sampleRate: context.sampleRate}); + buffer.getChannelData(0).fill(1); + + const src0 = new AudioBufferSourceNode(context, {buffer: buffer}); + const src1 = new AudioBufferSourceNode(context, {buffer: buffer}); + const src2 = new AudioBufferSourceNode(context, {buffer: buffer}); + + // Frame where sources should start. This is pretty arbitrary, but one + // should be an integer, one should be close to an integer and the other + // should be close to the next integer. This is to catch the case where + // rounding is used for the end frame. Rounding is incorrect. + const endFrame = 33; + const endFrame1 = endFrame + 0.1; + const endFrame2 = endFrame + 0.9; + + src0.connect(merger, 0, 0); + src1.connect(merger, 0, 1); + src2.connect(merger, 0, 2); + + src0.start(0); + src1.start(0); + src2.start(0); + src0.stop(endFrame / context.sampleRate); + src1.stop(endFrame1 / context.sampleRate); + src2.stop(endFrame2 / context.sampleRate); + + context.startRendering() + .then(audioBuffer => { + let actual0 = audioBuffer.getChannelData(0); + let actual1 = audioBuffer.getChannelData(1); + let actual2 = audioBuffer.getChannelData(2); + + // Just verify that we stopped at the right time. + + // This is case where the end frame is an integer. Since the first + // output ends on an exact frame, the output must be zero at that + // frame number. We print the end frame for information only; it + // makes interpretation of the rest easier. + should(endFrame - 1, 'src0 end frame') + .beEqualTo(endFrame - 1); + should(actual0[endFrame - 1], `output0[${endFrame - 1}]`) + .notBeEqualTo(0); + should(actual0.slice(endFrame), + `output0[${endFrame}:]`) + .beConstantValueOf(0); + + // The case where the end frame is just a little above an integer. + // The output must not be zero just before the end and must be zero + // after. + should(endFrame1, 'src1 end frame') + .beEqualTo(endFrame1); + should(actual1[endFrame], `output1[${endFrame}]`) + .notBeEqualTo(0); + should(actual1.slice(endFrame + 1), + `output1[${endFrame + 1}:]`) + .beConstantValueOf(0); + + // The case where the end frame is just a little below an integer. + // The output must not be zero just before the end and must be zero + // after. + should(endFrame2, 'src2 end frame') + .beEqualTo(endFrame2); + should(actual2[endFrame], `output2[${endFrame}]`) + .notBeEqualTo(0); + should(actual2.slice(endFrame + 1), + `output2[${endFrame + 1}:]`) + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.define('sub-sample-grain', (task, should) => { + let context = new OfflineAudioContext( + {numberOfChannels: 2, length: 128, sampleRate: sampleRate}); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + merger.connect(context.destination); + + // The source can be as simple constant for this test. + let buffer = new AudioBuffer( + {length: context.length, sampleRate: context.sampleRate}); + buffer.getChannelData(0).fill(1); + + let src0 = new AudioBufferSourceNode(context, {buffer: buffer}); + let src1 = new AudioBufferSourceNode(context, {buffer: buffer}); + + src0.connect(merger, 0, 0); + src1.connect(merger, 0, 1); + + // Start a short grain. + const src0StartGrain = 3.1; + const src0EndGrain = 37.2; + src0.start( + src0StartGrain / context.sampleRate, 0, + (src0EndGrain - src0StartGrain) / context.sampleRate); + + const src1StartGrain = 5.8; + const src1EndGrain = 43.9; + src1.start( + src1StartGrain / context.sampleRate, 0, + (src1EndGrain - src1StartGrain) / context.sampleRate); + + context.startRendering() + .then(audioBuffer => { + let output0 = audioBuffer.getChannelData(0); + let output1 = audioBuffer.getChannelData(1); + + let expected = new Float32Array(context.length); + + // Compute the expected output for output0 and verify the actual + // output matches. + expected.fill(1); + for (let k = 0; k <= Math.floor(src0StartGrain); ++k) { + expected[k] = 0; + } + for (let k = Math.ceil(src0EndGrain); k < expected.length; ++k) { + expected[k] = 0; + } + + verifyGrain(should, output0, { + startGrain: src0StartGrain, + endGrain: src0EndGrain, + sourceName: 'src0', + outputName: 'output0' + }); + + verifyGrain(should, output1, { + startGrain: src1StartGrain, + endGrain: src1EndGrain, + sourceName: 'src1', + outputName: 'output1' + }); + }) + .then(() => task.done()); + }); + + audit.define( + 'sub-sample accurate start with playbackRate', (task, should) => { + // There are two channels, one for each source. Only need to render + // quanta for this test. + let context = new OfflineAudioContext( + {numberOfChannels: 2, length: 8192, sampleRate: sampleRate}); + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + merger.connect(context.destination); + + // Use a simple linear ramp for the sources with integer steps + // starting at 1 to make it easy to verify and test that have + // sub-sample accurate start. Ramp starts at 1 so we can easily + // tell when the source starts. + let buffer = new AudioBuffer( + {length: context.length, sampleRate: context.sampleRate}); + let r = buffer.getChannelData(0); + for (let k = 0; k < r.length; ++k) { + r[k] = k + 1; + } + + // Two sources with different playback rates + const src0 = new AudioBufferSourceNode( + context, {buffer: buffer, playbackRate: .25}); + const src1 = new AudioBufferSourceNode( + context, {buffer: buffer, playbackRate: 4}); + + // Frame where sources start. Pretty arbitrary but should not be an + // integer. + const startFrame = 17.8; + + src0.connect(merger, 0, 0); + src1.connect(merger, 0, 1); + + src0.start(startFrame / context.sampleRate); + src1.start(startFrame / context.sampleRate); + + context.startRendering() + .then(audioBuffer => { + const output0 = audioBuffer.getChannelData(0); + const output1 = audioBuffer.getChannelData(1); + + const frameBefore = Math.floor(startFrame); + const frameAfter = frameBefore + 1; + + // Informative message so we know what the following output + // indices really mean. + should(startFrame, 'Source start frame') + .beEqualTo(startFrame); + + // Verify the output + + // With a startFrame of 17.8, the first output is at frame 18, + // but the actual start is at 17.8. So we would interpolate + // the output 0.2 fraction of the way between 17.8 and 18, for + // an output of 1.2 for our ramp. But the playback rate is + // 0.25, so we're really only 1/4 as far along as we think so + // the output is .2*0.25 of the way between 1 and 2 or 1.05. + + const ramp0 = buffer.getChannelData(0)[0]; + const ramp1 = buffer.getChannelData(0)[1]; + + const src0Output = ramp0 + + (ramp1 - ramp0) * (frameAfter - startFrame) * + src0.playbackRate.value; + + let playbackMessage = + `With playbackRate ${src0.playbackRate.value}:`; + + should( + output0[frameBefore], + `${playbackMessage} output0[${frameBefore}]`) + .beEqualTo(0); + should( + output0[frameAfter], + `${playbackMessage} output0[${frameAfter}]`) + .beCloseTo(src0Output, {threshold: 4.542e-8}); + + const src1Output = ramp0 + + (ramp1 - ramp0) * (frameAfter - startFrame) * + src1.playbackRate.value; + + playbackMessage = + `With playbackRate ${src1.playbackRate.value}:`; + + should( + output1[frameBefore], + `${playbackMessage} output1[${frameBefore}]`) + .beEqualTo(0); + should( + output1[frameAfter], + `${playbackMessage} output1[${frameAfter}]`) + .beCloseTo(src1Output, {threshold: 4.542e-8}); + }) + .then(() => task.done()); + }); + + audit.run(); + + // Given an input ramp in |rampBuffer|, interpolate the signal assuming + // this ramp is used for an ABSN that starts at frame |startFrame|, which + // is not necessarily an integer. For simplicity we just use linear + // interpolation here. The interpolation is not part of the spec but + // this should be pretty close to whatever interpolation is being done. + function interpolateRamp(rampBuffer, startFrame) { + // |start| is the last zero sample before the ABSN actually starts. + const start = Math.floor(startFrame); + // One less than the rampBuffer because we can't linearly interpolate + // the last frame. + let result = new Float32Array(rampBuffer.length - 1); + + for (let k = 0; k <= start; ++k) { + result[k] = 0; + } + + // Now start linear interpolation. + let frame = startFrame; + let index = 1; + for (let k = start + 1; k < result.length; ++k) { + let s0 = rampBuffer[index]; + let s1 = rampBuffer[index - 1]; + let delta = frame - k; + let s = s1 - delta * (s0 - s1); + result[k] = s; + ++frame; + ++index; + } + + return result; + } + + function verifyGrain(should, output, options) { + let {startGrain, endGrain, sourceName, outputName} = options; + let expected = new Float32Array(output.length); + // Compute the expected output for output and verify the actual + // output matches. + expected.fill(1); + for (let k = 0; k <= Math.floor(startGrain); ++k) { + expected[k] = 0; + } + for (let k = Math.ceil(endGrain); k < expected.length; ++k) { + expected[k] = 0; + } + + should(startGrain, `${sourceName} grain start`).beEqualTo(startGrain); + should(endGrain - startGrain, `${sourceName} grain duration`) + .beEqualTo(endGrain - startGrain); + should(endGrain, `${sourceName} grain end`).beEqualTo(endGrain); + should(output, outputName).beEqualToArray(expected); + should( + output[Math.floor(startGrain)], + `${outputName}[${Math.floor(startGrain)}]`) + .beEqualTo(0); + should( + output[1 + Math.floor(startGrain)], + `${outputName}[${1 + Math.floor(startGrain)}]`) + .notBeEqualTo(0); + should( + output[Math.floor(endGrain)], + `${outputName}[${Math.floor(endGrain)}]`) + .notBeEqualTo(0); + should( + output[1 + Math.floor(endGrain)], + `${outputName}[${1 + Math.floor(endGrain)}]`) + .beEqualTo(0); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-detached-execution-context.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-detached-execution-context.html new file mode 100644 index 0000000000..a83fa1dbe6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-detached-execution-context.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Testing behavior of AudioContext after execution context is detached + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + + audit.define('decoding-on-detached-iframe', (task, should) => { + const iframe = + document.createElementNS("http://www.w3.org/1999/xhtml", "iframe"); + document.body.appendChild(iframe); + let context = new iframe.contentWindow.AudioContext(); + document.body.removeChild(iframe); + + should(context.decodeAudioData(new ArrayBuffer(1)), + 'decodeAudioData() upon a detached iframe') + .beRejectedWith('InvalidStateError') + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-getoutputtimestamp-cross-realm.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-getoutputtimestamp-cross-realm.html new file mode 100644 index 0000000000..5889faf7cc --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-getoutputtimestamp-cross-realm.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Testing AudioContext.getOutputTimestamp() method (cross-realm) + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + + audit.define("getoutputtimestamp-cross-realm", function(task, should) { + const mainContext = new AudioContext(); + return task.timeout(() => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const iframeContext = new iframe.contentWindow.AudioContext(); + + should(mainContext.getOutputTimestamp().performanceTime, "mainContext's performanceTime") + .beGreaterThan(iframeContext.getOutputTimestamp().performanceTime, "iframeContext's performanceTime"); + should(iframeContext.getOutputTimestamp.call(mainContext).performanceTime, "mainContext's performanceTime (via iframeContext's method)") + .beCloseTo(mainContext.getOutputTimestamp().performanceTime, "mainContext's performanceTime", { threshold: 0.01 }); + }, 1000); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-getoutputtimestamp.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-getoutputtimestamp.html new file mode 100644 index 0000000000..952f38b1ed --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-getoutputtimestamp.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Testing AudioContext.getOutputTimestamp() method + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('getoutputtimestamp-initial-values', function(task, should) { + let context = new AudioContext; + let timestamp = context.getOutputTimestamp(); + + should(timestamp.contextTime, 'timestamp.contextTime').exist(); + should(timestamp.performanceTime, 'timestamp.performanceTime').exist(); + + should(timestamp.contextTime, 'timestamp.contextTime') + .beGreaterThanOrEqualTo(0); + should(timestamp.performanceTime, 'timestamp.performanceTime') + .beGreaterThanOrEqualTo(0); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-not-fully-active.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-not-fully-active.html new file mode 100644 index 0000000000..e4f6001eda --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-not-fully-active.html @@ -0,0 +1,94 @@ +<!doctype html> +<title>Test AudioContext construction when document is not fully active</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src="/common/get-host-info.sub.js"></script> +<body></body> +<script> +const dir = location.pathname.replace(/\/[^\/]*$/, '/'); +const helper = dir + 'resources/not-fully-active-helper.sub.html?childsrc='; +const remote_helper = get_host_info().HTTP_NOTSAMESITE_ORIGIN + helper; +const blank_url = get_host_info().ORIGIN + '/common/blank.html'; + +const load_content = (frame, src) => { + if (src == undefined) { + frame.srcdoc = '<html></html>'; + } else { + frame.src = src; + } + return new Promise(resolve => frame.onload = () => resolve(frame)); +}; +const append_iframe = (src) => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + return load_content(frame, src); +}; +const remote_op = (win, op) => { + win.postMessage(op, '*'); + return new Promise(resolve => window.onmessage = e => { + if (e.data == 'DONE ' + op) resolve(); + }); +}; +const test_constructor_throws = async (win, deactivate) => { + const {AudioContext, DOMException} = win; + await deactivate(); + assert_throws_dom("InvalidStateError", DOMException, + () => new AudioContext()); +}; + +promise_test(async () => { + const frame = await append_iframe(); + return test_constructor_throws(frame.contentWindow, () => frame.remove()); +}, "removed frame"); +promise_test(async () => { + const frame = await append_iframe(); + return test_constructor_throws(frame.contentWindow, + () => load_content(frame)); +}, "navigated frame"); +promise_test(async () => { + const frame = await append_iframe(helper + blank_url); + const inner = frame.contentWindow.frames[0]; + return test_constructor_throws(inner, () => frame.remove()); +}, "frame in removed frame"); +promise_test(async () => { + const frame = await append_iframe(helper + blank_url); + const inner = frame.contentWindow.frames[0]; + return test_constructor_throws(inner, () => load_content(frame)); +}, "frame in navigated frame"); +promise_test(async () => { + const frame = await append_iframe(remote_helper + blank_url); + const inner = frame.contentWindow.frames[0]; + return test_constructor_throws(inner, () => frame.remove()); +}, "frame in removed remote-site frame"); +promise_test(async () => { + const frame = await append_iframe(remote_helper + blank_url); + const inner = frame.contentWindow.frames[0]; + return test_constructor_throws(inner, () => load_content(frame)); +}, "frame in navigated remote-site frame"); +promise_test(async () => { + const outer = (await append_iframe(remote_helper + blank_url)).contentWindow; + const inner = outer.frames[0]; + return test_constructor_throws(inner, + () => remote_op(outer, 'REMOVE FRAME')); +}, "removed frame in remote-site frame"); +promise_test(async () => { + const outer = (await append_iframe(remote_helper + blank_url)).contentWindow; + const inner = outer.frames[0]; + return test_constructor_throws(inner, + () => remote_op(outer, 'NAVIGATE FRAME')); +}, "navigated frame in remote-site frame"); +promise_test(async () => { + const url = remote_helper + helper + blank_url; + const outer = (await append_iframe(url)).contentWindow; + const inner = outer.frames[0].frames[0]; + return test_constructor_throws(inner, + () => remote_op(outer, 'REMOVE FRAME')); +}, "frame in removed remote-site frame in remote-site frame"); +promise_test(async () => { + const url = remote_helper + helper + blank_url; + const outer = (await append_iframe(url)).contentWindow; + const inner = outer.frames[0].frames[0]; + return test_constructor_throws(inner, + () => remote_op(outer, 'NAVIGATE FRAME')); +}, "frame in navigated remote-site frame in remote-site frame"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-constructor.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-constructor.https.html new file mode 100644 index 0000000000..d2dc54aee6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-constructor.https.html @@ -0,0 +1,126 @@ +<!DOCTYPE html> +<head> +<title>Test AudioContext constructor with sinkId options</title> +</head> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +"use strict"; + +let outputDeviceList = null; +let firstDeviceId = null; + +navigator.mediaDevices.getUserMedia({audio: true}).then(() => { + navigator.mediaDevices.enumerateDevices().then((deviceList) => { + outputDeviceList = + deviceList.filter(({kind}) => kind === 'audiooutput'); + assert_greater_than(outputDeviceList.length, 1, + 'the system must have more than 1 device.'); + firstDeviceId = outputDeviceList[1].deviceId; + + // Run async tests concurrently. + async_test(t => testDefaultSinkId(t), + 'Setting sinkId to the empty string at construction should ' + + 'succeed.'); + async_test(t => testValidSinkId(t), + 'Setting sinkId with a valid device identifier at ' + + 'construction should succeed.'); + async_test(t => testAudioSinkOptions(t), + 'Setting sinkId with an AudioSinkOptions at construction ' + + 'should succeed.'); + async_test(t => testExceptions(t), + 'Invalid sinkId arguments should throw an appropriate ' + + 'exception.') + }); +}); + +// 1.2.1. AudioContext constructor +// https://webaudio.github.io/web-audio-api/#AudioContext-constructors + +// Step 10.1.1. If sinkId is equal to [[sink ID]], abort these substeps. +const testDefaultSinkId = (t) => { + // The initial `sinkId` is the empty string. This will cause the same value + // check. + const audioContext = new AudioContext({sinkId: ''}); + audioContext.addEventListener('statechange', () => { + t.step(() => { + assert_equals(audioContext.sinkId, ''); + assert_equals(audioContext.state, 'running'); + }); + audioContext.close(); + t.done(); + }, {once: true}); +}; + +// Step 10.1.2~3: See "Validating sinkId" tests below. + +// Step 10.1.4. If sinkId is a type of DOMString, set [[sink ID]] to sinkId and +// abort these substeps. +const testValidSinkId = (t) => { + const audioContext = new AudioContext({sinkId: firstDeviceId}); + audioContext.addEventListener('statechange', () => { + t.step(() => { + assert_true(audioContext.sinkId === firstDeviceId, + 'the context sinkId should match the given sinkId.'); + }); + audioContext.close(); + t.done(); + }, {once: true}); + t.step_timeout(t.unreached_func('onstatechange not fired or assert failed'), + 100); +}; + +// Step 10.1.5. If sinkId is a type of AudioSinkOptions, set [[sink ID]] to a +// new instance of AudioSinkInfo created with the value of type of sinkId. +const testAudioSinkOptions = (t) => { + const audioContext = new AudioContext({sinkId: {type: 'none'}}); + // The only signal we can use for the sinkId change after construction is + // `statechange` event. + audioContext.addEventListener('statechange', () => { + t.step(() => { + assert_equals(typeof audioContext.sinkId, 'object'); + assert_equals(audioContext.sinkId.type, 'none'); + }); + audioContext.close(); + t.done(); + }, {once: true}); + t.step_timeout(t.unreached_func('onstatechange not fired or assert failed'), + 100); +}; + +// 1.2.4. Validating sinkId +// https://webaudio.github.io/web-audio-api/#validating-sink-identifier + +// Step 3. If document is not allowed to use the feature identified by +// "speaker-selection", return a new DOMException whose name is +// "NotAllowedError". +// TODO(https://crbug.com/1380872): Due to the lack of "speaker-selection" +// implementation, a test for such step does not exist yet. + +const testExceptions = (t) => { + t.step(() => { + // The wrong AudioSinkOption.type should cause a TypeError. + assert_throws_js(TypeError, () => { + const audioContext = new AudioContext({sinkId: {type: 'something_else'}}); + audioContext.close(); + }, 'An invalid AudioSinkOptions.type value should throw a TypeError ' + + 'exception.'); + }); + + t.step(() => { + // Step 4. If sinkIdArg is a type of DOMString but it is not equal to the + // empty string or it does not match any audio output device identified by + // the result that would be provided by enumerateDevices(), return a new + // DOMException whose name is "NotFoundError". + // TODO(https://crbug.com/1439947): This does not throw in Chromium because + // the logic automatically fallbacks to the default device when a given ID + // is invalid. + assert_throws_dom('NotFoundError', () => { + const audioContext = new AudioContext({sinkId: 'some_random_device_id'}); + audioContext.close(); + }, 'An invalid device identifier should throw a NotFoundError exception.'); + }); + t.done(); +}; +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-setsinkid.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-setsinkid.https.html new file mode 100644 index 0000000000..61d2586bfb --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-setsinkid.https.html @@ -0,0 +1,122 @@ +<!DOCTYPE html> +<head> +<title>Test AudioContext.setSinkId() method</title> +</head> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +"use strict"; + +const audioContext = new AudioContext(); +let outputDeviceList = null; +let firstDeviceId = null; + +// Setup: Get permission via getUserMedia() and a list of audio output devices. +promise_setup(async t => { + await navigator.mediaDevices.getUserMedia({ audio: true }); + const deviceList = await navigator.mediaDevices.enumerateDevices(); + outputDeviceList = + deviceList.filter(({kind}) => kind === 'audiooutput'); + assert_greater_than(outputDeviceList.length, 1, + 'the system must have more than 1 device.'); + firstDeviceId = outputDeviceList[1].deviceId; +}, 'Get permission via getUserMedia() and a list of audio output devices.'); + + +// 1.2.3. AudioContext.setSinkId() method +// https://webaudio.github.io/web-audio-api/#dom-audiocontext-setsinkid-domstring-or-audiosinkoptions-sinkid + +promise_test(async t => { + t.step(() => { + // The default value of `sinkId` is the empty string. + assert_equals(audioContext.sinkId, ''); + }); + t.done(); +}, 'setSinkId() with a valid device identifier should succeeded.'); + +promise_test(async t => { + // Change to the first non-default device in the list. + await audioContext.setSinkId(firstDeviceId); + t.step(() => { + // If both `sinkId` and [[sink ID]] are a type of DOMString, and they are + // equal to each other, resolve the promise immediately. + assert_equals(typeof audioContext.sinkId, 'string'); + assert_equals(audioContext.sinkId, firstDeviceId); + }); + return audioContext.setSinkId(firstDeviceId); +}, 'setSinkId() with the same sink ID should resolve immediately.'); + +promise_test(async t => { + // If sinkId is a type of AudioSinkOptions and [[sink ID]] is a type of + // AudioSinkInfo, and type in sinkId and type in [[sink ID]] are equal, + // resolve the promise immediately. + await audioContext.setSinkId({type: 'none'}); + t.step(() => { + assert_equals(typeof audioContext.sinkId, 'object'); + assert_equals(audioContext.sinkId.type, 'none'); + }); + return audioContext.setSinkId({type: 'none'}); +}, 'setSinkId() with the same AudioSinkOptions.type value should resolve ' + + 'immediately.'); + +// 1.2.4. Validating sinkId +// https://webaudio.github.io/web-audio-api/#validating-sink-identifier + +// Step 3. If document is not allowed to use the feature identified by +// "speaker-selection", return a new DOMException whose name is +// "NotAllowedError". +// TODO: Due to the lack of implementation, this step is not tested. + +// The wrong AudioSinkOption.type should cause a TypeError. +promise_test(t => + promise_rejects_js(t, TypeError, + audioContext.setSinkId({type: 'something_else'})), + 'setSinkId() should fail with TypeError on an invalid ' + + 'AudioSinkOptions.type value.'); + +// Step 4. If sinkId is a type of DOMString but it is not equal to the empty +// string or it does not match any audio output device identified by the result +// that would be provided by enumerateDevices(), return a new DOMException whose +// name is "NotFoundError". +promise_test(t => + promise_rejects_dom(t, 'NotFoundError', + audioContext.setSinkId('some_random_device_id')), + 'setSinkId() should fail with NotFoundError on an invalid device ' + + 'identifier.'); + +// setSinkId invoked from closed AudioContext should throw InvalidStateError +// DOMException. +promise_test(async t => { + await audioContext.close(); + t.step(() => { + assert_equals(audioContext.state, 'closed'); + }); + promise_rejects_dom(t, 'InvalidStateError', + audioContext.setSinkId('some_random_device_id')) +},'setSinkId() should fail with InvalidStateError when calling from a' + + 'stopped AudioContext'); + +// setSinkId invoked from detached document should throw InvalidStateError +// DOMException. +promise_test(async t => { + const iframe = document.createElementNS("http://www.w3.org/1999/xhtml", "iframe"); + document.body.appendChild(iframe); + let iframeAudioContext = new iframe.contentWindow.AudioContext(); + document.body.removeChild(iframe); + promise_rejects_dom(t, 'InvalidStateError', + iframeAudioContext.setSinkId('some_random_device_id')); +},'setSinkId() should fail with InvalidStateError when calling from a' + + 'detached document'); + +// Pending setSinkId() promises should be rejected with a +// DOMException of InvalidStateError when the context is closed. +// See: crbug.com/1408376 +promise_test(async t => { + const audioContext = new AudioContext(); + promise_rejects_dom(t, 'InvalidStateError', + audioContext.setSinkId('some_random_device_id')); + await audioContext.close(); +},'pending setSinkId() should be rejected with InvalidStateError when' + + 'AudioContext is closed'); +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-state-change.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-state-change.https.html new file mode 100644 index 0000000000..8462b52619 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-sinkid-state-change.https.html @@ -0,0 +1,98 @@ +<!DOCTYPE html> +<head> +<title>Test AudioContext.setSinkId() state change</title> +</head> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +"use strict"; + +let outputDeviceList = null; +let firstDeviceId = null; + +// Setup: Get permission via getUserMedia() and a list of audio output devices. +promise_setup(async t => { + await navigator.mediaDevices.getUserMedia({ audio: true }); + const deviceList = await navigator.mediaDevices.enumerateDevices(); + outputDeviceList = + deviceList.filter(({kind}) => kind === 'audiooutput'); + assert_greater_than(outputDeviceList.length, 1, + 'the system must have more than 1 device.'); + firstDeviceId = outputDeviceList[1].deviceId; +}, 'Get permission via getUserMedia() and a list of audio output devices.'); + +// Test the sink change when from a suspended context. +promise_test(async t => { + let events = []; + const audioContext = new AudioContext(); + await audioContext.suspend(); + + // Step 6. Set wasRunning to false if the [[rendering thread state]] on the + // AudioContext is "suspended". + assert_equals(audioContext.state, 'suspended'); + + // Step 11.5. Fire an event named sinkchange at the associated AudioContext. + audioContext.onsinkchange = t.step_func(() => { + events.push('sinkchange'); + assert_equals(audioContext.sinkId, firstDeviceId); + }); + + await audioContext.setSinkId(firstDeviceId); + assert_equals(events[0], 'sinkchange'); + t.done(); +}, 'Calling setSinkId() on a suspended AudioContext should fire only sink ' + + 'change events.'); + +// Test the sink change on a running AudioContext. +promise_test(async t => { + let events = []; + let silentSinkOption = {type: 'none'}; + const audioContext = new AudioContext(); + + // Make sure the context is "running". This also will fire a state change + // event upon resolution. + await audioContext.resume(); + + return new Promise(async resolve => { + + function eventCheckpoint() { + // We're expecting 4 events from AudioContext. + if (events.length === 4) { + // The initial context state was "running". + assert_equals(events[0], 'statechange:running'); + assert_equals(events[1], 'statechange:suspended'); + assert_equals(events[2], 'sinkchange'); + assert_equals(events[3], 'statechange:running'); + resolve(); + } + } + + // This is to catch a sink change event: + // - Step 11.5. Fire an event named sinkchange at the associated + // AudioContext. + audioContext.onsinkchange = t.step_func(() => { + assert_equals(audioContext.sinkId.type, silentSinkOption.type); + events.push('sinkchange'); + eventCheckpoint(); + }); + + // The following event handler will catch 3 state change events: + // - The initial 'running' state change. + // - Step 9.2.1. Set the state attribute of the AudioContext to "suspended". + // Fire an event named statechange at the associated AudioContext. + // - Step 12.2. Set the state attribute of the AudioContext to "running". + // Fire an event named statechange at the associated AudioContext. + audioContext.onstatechange = async () => { + events.push(`statechange:${audioContext.state}`); + eventCheckpoint(); + }; + + // To trigger a series of state changes, we need a device change. The + // context started with the default device, and this call changes it to a + // silent sink. + audioContext.setSinkId(silentSinkOption); + }); +}, 'Calling setSinkId() on a running AudioContext should fire both state ' + + 'and sink change events.'); +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-state-change-after-close.http.window.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-state-change-after-close.http.window.js new file mode 100644 index 0000000000..eccb0d172d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-state-change-after-close.http.window.js @@ -0,0 +1,28 @@ +'use strict'; + +promise_test(async t => { + let audioContext = new AudioContext(); + await new Promise((resolve) => (audioContext.onstatechange = resolve)); + await audioContext.close(); + return promise_rejects_dom( + t, 'InvalidStateError', audioContext.close(), + 'A closed AudioContext should reject calls to close'); +}, 'Call close on a closed AudioContext'); + +promise_test(async t => { + let audioContext = new AudioContext(); + await new Promise((resolve) => (audioContext.onstatechange = resolve)); + await audioContext.close(); + return promise_rejects_dom( + t, 'InvalidStateError', audioContext.resume(), + 'A closed AudioContext should reject calls to resume'); +}, 'Call resume on a closed AudioContext'); + +promise_test(async t => { + let audioContext = new AudioContext(); + await new Promise((resolve) => (audioContext.onstatechange = resolve)); + await audioContext.close(); + return promise_rejects_dom( + t, 'InvalidStateError', audioContext.suspend(), + 'A closed AudioContext should reject calls to suspend'); +}, 'Call suspend on a closed AudioContext'); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-suspend-resume-close.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-suspend-resume-close.html new file mode 100644 index 0000000000..c011f336df --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-suspend-resume-close.html @@ -0,0 +1,406 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script type="module"> +"use strict"; + +function tryToCreateNodeOnClosedContext(ctx) { + assert_equals(ctx.state, "closed", "The context is in closed state"); + + [ + { name: "createBufferSource" }, + { + name: "createMediaStreamDestination", + onOfflineAudioContext: false, + }, + { name: "createScriptProcessor" }, + { name: "createStereoPanner" }, + { name: "createAnalyser" }, + { name: "createGain" }, + { name: "createDelay" }, + { name: "createBiquadFilter" }, + { name: "createWaveShaper" }, + { name: "createPanner" }, + { name: "createConvolver" }, + { name: "createChannelSplitter" }, + { name: "createChannelMerger" }, + { name: "createDynamicsCompressor" }, + { name: "createOscillator" }, + { + name: "createMediaElementSource", + args: [new Audio()], + onOfflineAudioContext: false, + }, + { + name: "createMediaStreamSource", + args: [new AudioContext().createMediaStreamDestination().stream], + onOfflineAudioContext: false, + }, + ].forEach(function (e) { + if ( + e.onOfflineAudioContext == false && + ctx instanceof OfflineAudioContext + ) { + return; + } + + try { + ctx[e.name].apply(ctx, e.args); + } catch (err) { + assert_true(false, "unexpected exception thrown for " + e.name); + } + }); +} + +function loadFile(url, callback) { + return new Promise((resolve) => { + var xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.responseType = "arraybuffer"; + xhr.onload = function () { + resolve(xhr.response); + }; + xhr.send(); + }); +} + +// createBuffer, createPeriodicWave and decodeAudioData should work on a context +// that has `state` == "closed" +async function tryLegalOperationsOnClosedContext(ctx) { + assert_equals(ctx.state, "closed", "The context is in closed state"); + + [ + { name: "createBuffer", args: [1, 44100, 44100] }, + { + name: "createPeriodicWave", + args: [new Float32Array(10), new Float32Array(10)], + }, + ].forEach(function (e) { + try { + ctx[e.name].apply(ctx, e.args); + } catch (err) { + assert_true(false, "unexpected exception thrown"); + } + }); + var buf = await loadFile("/webaudio/resources/sin_440Hz_-6dBFS_1s.wav"); + return ctx + .decodeAudioData(buf) + .then(function (decodedBuf) { + assert_true( + true, + "decodeAudioData on a closed context should work, it did." + ); + }) + .catch(function (e) { + assert_true( + false, + "decodeAudioData on a closed context should work, it did not" + ); + }); +} + +// Test that MediaStreams that are the output of a suspended AudioContext are +// producing silence +// ac1 produce a sine fed to a MediaStreamAudioDestinationNode +// ac2 is connected to ac1 with a MediaStreamAudioSourceNode, and check that +// there is silence when ac1 is suspended +async function testMultiContextOutput() { + var ac1 = new AudioContext(), + ac2 = new AudioContext(); + + await new Promise((resolve) => (ac1.onstatechange = resolve)); + + ac1.onstatechange = null; + await ac1.suspend(); + assert_equals(ac1.state, "suspended", "ac1 is suspended"); + var osc1 = ac1.createOscillator(), + mediaStreamDestination1 = ac1.createMediaStreamDestination(); + + var mediaStreamAudioSourceNode2 = ac2.createMediaStreamSource( + mediaStreamDestination1.stream + ), + sp2 = ac2.createScriptProcessor(), + silentBuffersInARow = 0; + + osc1.connect(mediaStreamDestination1); + mediaStreamAudioSourceNode2.connect(sp2); + osc1.start(); + + let e = await new Promise((resolve) => (sp2.onaudioprocess = resolve)); + + while (true) { + let e = await new Promise( + (resolve) => (sp2.onaudioprocess = resolve) + ); + var input = e.inputBuffer.getChannelData(0); + var silent = true; + for (var i = 0; i < input.length; i++) { + if (input[i] != 0.0) { + silent = false; + } + } + + if (silent) { + silentBuffersInARow++; + if (silentBuffersInARow == 10) { + assert_true( + true, + "MediaStreams produce silence when their input is blocked." + ); + break; + } + } else { + assert_equals( + silentBuffersInARow, + 0, + "No non silent buffer inbetween silent buffers." + ); + } + } + + sp2.onaudioprocess = null; + ac1.close(); + ac2.close(); +} + +// Test that there is no buffering between contexts when connecting a running +// AudioContext to a suspended AudioContext. Gecko's ScriptProcessorNode does some +// buffering internally, so we ensure this by using a very very low frequency +// on a sine, and oberve that the phase has changed by a big enough margin. +async function testMultiContextInput() { + var ac1 = new AudioContext(), + ac2 = new AudioContext(); + + await new Promise((resolve) => (ac1.onstatechange = resolve)); + ac1.onstatechange = null; + + var osc1 = ac1.createOscillator(), + mediaStreamDestination1 = ac1.createMediaStreamDestination(), + sp1 = ac1.createScriptProcessor(); + + var mediaStreamAudioSourceNode2 = ac2.createMediaStreamSource( + mediaStreamDestination1.stream + ), + sp2 = ac2.createScriptProcessor(), + eventReceived = 0; + + osc1.frequency.value = 0.0001; + osc1.connect(mediaStreamDestination1); + osc1.connect(sp1); + mediaStreamAudioSourceNode2.connect(sp2); + osc1.start(); + + var e = await new Promise((resolve) => (sp2.onaudioprocess = resolve)); + var inputBuffer1 = e.inputBuffer.getChannelData(0); + sp2.value = inputBuffer1[inputBuffer1.length - 1]; + await ac2.suspend(); + await ac2.resume(); + + while (true) { + var e = await new Promise( + (resolve) => (sp2.onaudioprocess = resolve) + ); + var inputBuffer = e.inputBuffer.getChannelData(0); + if (eventReceived++ == 3) { + var delta = Math.abs(inputBuffer[1] - sp2.value), + theoreticalIncrement = + (2048 * 3 * Math.PI * 2 * osc1.frequency.value) / + ac1.sampleRate; + assert_true( + delta >= theoreticalIncrement, + "Buffering did not occur when the context was suspended (delta:" + + delta + + " increment: " + + theoreticalIncrement + + ")" + ); + break; + } + } + ac1.close(); + ac2.close(); + sp1.onaudioprocess = null; + sp2.onaudioprocess = null; +} + +// Take an AudioContext, make sure it switches to running when the audio starts +// flowing, and then, call suspend, resume and close on it, tracking its state. +async function testAudioContext() { + var ac = new AudioContext(); + assert_equals( + ac.state, + "suspended", + "AudioContext should start in suspended state." + ); + var stateTracker = { + previous: ac.state, + // no promise for the initial suspended -> running + initial: { handler: false }, + suspend: { promise: false, handler: false }, + resume: { promise: false, handler: false }, + close: { promise: false, handler: false }, + }; + + await new Promise((resolve) => (ac.onstatechange = resolve)); + + assert_true( + stateTracker.previous == "suspended" && ac.state == "running", + 'AudioContext should switch to "running" when the audio hardware is' + + " ready." + ); + + stateTracker.previous = ac.state; + stateTracker.initial.handler = true; + + let promise_statechange_suspend = new Promise((resolve) => { + ac.onstatechange = resolve; + }).then(() => { + stateTracker.suspend.handler = true; + }); + await ac.suspend(); + assert_true( + !stateTracker.suspend.handler, + "Promise should be resolved before the callback." + ); + assert_equals( + ac.state, + "suspended", + 'AudioContext should switch to "suspended" when the audio stream is ' + + "suspended." + ); + await promise_statechange_suspend; + stateTracker.previous = ac.state; + + let promise_statechange_resume = new Promise((resolve) => { + ac.onstatechange = resolve; + }).then(() => { + stateTracker.resume.handler = true; + }); + await ac.resume(); + assert_true( + !stateTracker.resume.handler, + "Promise should be resolved before the callback." + ); + assert_equals( + ac.state, + "running", + 'AudioContext should switch to "running" when the audio stream is ' + + "resumed." + ); + await promise_statechange_resume; + stateTracker.previous = ac.state; + + let promise_statechange_close = new Promise((resolve) => { + ac.onstatechange = resolve; + }).then(() => { + stateTracker.close.handler = true; + }); + await ac.close(); + assert_true( + !stateTracker.close.handler, + "Promise should be resolved before the callback." + ); + assert_equals( + ac.state, + "closed", + 'AudioContext should switch to "closed" when the audio stream is ' + + "closed." + ); + await promise_statechange_close; + stateTracker.previous = ac.state; + + tryToCreateNodeOnClosedContext(ac); + await tryLegalOperationsOnClosedContext(ac); +} + +async function testOfflineAudioContext() { + var o = new OfflineAudioContext(1, 44100, 44100); + assert_equals( + o.state, + "suspended", + "OfflineAudioContext should start in suspended state." + ); + + var previousState = o.state, + finishedRendering = false; + + o.startRendering().then(function (buffer) { + finishedRendering = true; + }); + + await new Promise((resolve) => (o.onstatechange = resolve)); + + assert_true( + previousState == "suspended" && o.state == "running", + "onstatechanged" + + "handler is called on state changed, and the new state is running" + ); + previousState = o.state; + await new Promise((resolve) => (o.onstatechange = resolve)); + assert_true( + previousState == "running" && o.state == "closed", + "onstatechanged handler is called when rendering finishes, " + + "and the new state is closed" + ); + assert_true( + finishedRendering, + "The Promise that is resolved when the rendering is " + + "done should be resolved earlier than the state change." + ); + previousState = o.state; + function afterRenderingFinished() { + assert_true( + false, + "There should be no transition out of the closed state." + ); + } + o.onstatechange = afterRenderingFinished; + + tryToCreateNodeOnClosedContext(o); + await tryLegalOperationsOnClosedContext(o); +} + +async function testSuspendResumeEventLoop() { + var ac = new AudioContext(); + var source = ac.createBufferSource(); + source.buffer = ac.createBuffer(1, 44100, 44100); + await new Promise((resolve) => (ac.onstatechange = resolve)); + ac.onstatechange = null; + assert_true(ac.state == "running", "initial state is running"); + await ac.suspend(); + source.start(); + ac.resume(); + await new Promise((resolve) => (source.onended = resolve)); + assert_true(true, "The AudioContext did resume"); +} + +function testResumeInStateChangeForResumeCallback() { + return new Promise((resolve) => { + var ac = new AudioContext(); + ac.onstatechange = function () { + ac.resume().then(() => { + assert_true(true, "resume promise resolved as expected."); + resolve(); + }); + }; + }); +} + +var tests = [ + testOfflineAudioContext, + testSuspendResumeEventLoop, + testResumeInStateChangeForResumeCallback, + testAudioContext, + testMultiContextOutput, + testMultiContextInput, +]; + +tests.forEach(function (f) { + promise_test(f, f.name); +}); + </script> + </head> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-suspend-resume.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-suspend-resume.html new file mode 100644 index 0000000000..ff3daebf39 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontext-suspend-resume.html @@ -0,0 +1,145 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioContext.suspend() and AudioContext.resume() + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let offlineContext; + let osc; + let p1; + let p2; + let p3; + + let sampleRate = 44100; + let durationInSeconds = 1; + + let audit = Audit.createTaskRunner(); + + // Task: test suspend(). + audit.define( + { + label: 'test-suspend', + description: 'Test suspend() for offline context' + }, + function(task, should) { + // Test suspend/resume. Ideally this test is best with a online + // AudioContext, but content shell doesn't really have a working + // online AudioContext. Hence, use an OfflineAudioContext. Not all + // possible scenarios can be easily checked with an offline context + // instead of an online context. + + // Create an audio context with an oscillator. + should( + () => { + offlineContext = new OfflineAudioContext( + 1, durationInSeconds * sampleRate, sampleRate); + }, + 'offlineContext = new OfflineAudioContext(1, ' + + (durationInSeconds * sampleRate) + ', ' + sampleRate + ')') + .notThrow(); + osc = offlineContext.createOscillator(); + osc.connect(offlineContext.destination); + + // Verify the state. + should(offlineContext.state, 'offlineContext.state') + .beEqualTo('suspended'); + + // Multiple calls to suspend() should not be a problem. But we can't + // test that on an offline context. Thus, check that suspend() on + // an OfflineAudioContext rejects the promise. + should( + () => p1 = offlineContext.suspend(), + 'p1 = offlineContext.suspend()') + .notThrow(); + should(p1 instanceof Promise, 'p1 instanceof Promise').beTrue(); + + should(p1, 'p1').beRejected().then(task.done.bind(task)); + }); + + + // Task: test resume(). + audit.define( + { + label: 'test-resume', + description: 'Test resume() for offline context' + }, + function(task, should) { + // Multiple calls to resume should not be a problem. But we can't + // test that on an offline context. Thus, check that resume() on an + // OfflineAudioContext rejects the promise. + should( + () => p2 = offlineContext.resume(), + 'p2 = offlineContext.resume()') + .notThrow(); + should(p2 instanceof Promise, 'p2 instanceof Promise').beTrue(); + + // Resume doesn't actually resume an offline context + should(offlineContext.state, 'After resume, offlineContext.state') + .beEqualTo('suspended'); + should(p2, 'p2').beRejected().then(task.done.bind(task)); + }); + + // Task: test the state after context closed. + audit.define( + { + label: 'test-after-close', + description: 'Test state after context closed' + }, + function(task, should) { + // Render the offline context. + osc.start(); + + // Test suspend/resume in tested promise pattern. We don't care + // about the actual result of the offline rendering. + should( + () => p3 = offlineContext.startRendering(), + 'p3 = offlineContext.startRendering()') + .notThrow(); + + p3.then(() => { + should(offlineContext.state, 'After close, offlineContext.state') + .beEqualTo('closed'); + + // suspend() should be rejected on a closed context. + should(offlineContext.suspend(), 'offlineContext.suspend()') + .beRejected() + .then(() => { + // resume() should be rejected on closed context. + should(offlineContext.resume(), 'offlineContext.resume()') + .beRejected() + .then(task.done.bind(task)); + }) + }); + }); + + audit.define( + { + label: 'resume-running-context', + description: 'Test resuming a running context' + }, + (task, should) => { + let context; + should(() => context = new AudioContext(), 'Create online context') + .notThrow(); + + should(context.state, 'context.state').beEqualTo('suspended'); + should(context.resume(), 'context.resume') + .beResolved() + .then(() => { + should(context.state, 'context.state after resume') + .beEqualTo('running'); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontextoptions.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontextoptions.html new file mode 100644 index 0000000000..136abedaa8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/audiocontextoptions.html @@ -0,0 +1,215 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioContextOptions + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + let defaultLatency; + let interactiveLatency; + let balancedLatency; + let playbackLatency; + + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test-audiocontextoptions-latencyHint-basic', + description: 'Test creating contexts with basic latencyHint types.' + }, + function(task, should) { + let closingPromises = []; + + // Verify that an AudioContext can be created with default options. + should(function() { + context = new AudioContext() + }, 'context = new AudioContext()').notThrow(); + + should(context.sampleRate, + `context.sampleRate (${context.sampleRate} Hz)`).beGreaterThan(0); + + defaultLatency = context.baseLatency; + should(defaultLatency, 'default baseLatency').beGreaterThanOrEqualTo(0); + + // Verify that an AudioContext can be created with the expected + // latency types. + should( + function() { + context = new AudioContext({'latencyHint': 'interactive'}) + }, + 'context = new AudioContext({\'latencyHint\': \'interactive\'})') + .notThrow(); + + interactiveLatency = context.baseLatency; + should(interactiveLatency, 'interactive baseLatency') + .beEqualTo(defaultLatency); + closingPromises.push(context.close()); + + should( + function() { + context = new AudioContext({'latencyHint': 'balanced'}) + }, + 'context = new AudioContext({\'latencyHint\': \'balanced\'})') + .notThrow(); + + balancedLatency = context.baseLatency; + should(balancedLatency, 'balanced baseLatency') + .beGreaterThanOrEqualTo(interactiveLatency); + closingPromises.push(context.close()); + + should( + function() { + context = new AudioContext({'latencyHint': 'playback'}) + }, + 'context = new AudioContext({\'latencyHint\': \'playback\'})') + .notThrow(); + + playbackLatency = context.baseLatency; + should(playbackLatency, 'playback baseLatency') + .beGreaterThanOrEqualTo(balancedLatency); + closingPromises.push(context.close()); + + Promise.all(closingPromises).then(function() { + task.done(); + }); + }); + + audit.define( + { + label: 'test-audiocontextoptions-latencyHint-double', + description: + 'Test creating contexts with explicit latencyHint values.' + }, + function(task, should) { + let closingPromises = []; + + // Verify too small exact latency clamped to 'interactive' + should( + function() { + context = + new AudioContext({'latencyHint': interactiveLatency / 2}) + }, + 'context = new AudioContext({\'latencyHint\': ' + + 'interactiveLatency/2})') + .notThrow(); + should(context.baseLatency, 'double-constructor baseLatency small') + .beLessThanOrEqualTo(interactiveLatency); + closingPromises.push(context.close()); + + // Verify that exact latency in range works as expected + let validLatency = (interactiveLatency + playbackLatency) / 2; + should( + function() { + context = new AudioContext({'latencyHint': validLatency}) + }, + 'context = new AudioContext({\'latencyHint\': validLatency})') + .notThrow(); + should( + context.baseLatency, 'double-constructor baseLatency inrange 1') + .beGreaterThanOrEqualTo(interactiveLatency); + should( + context.baseLatency, 'double-constructor baseLatency inrange 2') + .beLessThanOrEqualTo(playbackLatency); + closingPromises.push(context.close()); + + // Verify too big exact latency clamped to some value + let context1; + let context2; + should(function() { + context1 = + new AudioContext({'latencyHint': playbackLatency * 10}); + context2 = + new AudioContext({'latencyHint': playbackLatency * 20}); + }, 'creating two high latency contexts').notThrow(); + should(context1.baseLatency, 'high latency context baseLatency') + .beEqualTo(context2.baseLatency); + should(context1.baseLatency, 'high latency context baseLatency') + .beGreaterThanOrEqualTo(interactiveLatency); + closingPromises.push(context1.close()); + closingPromises.push(context2.close()); + + // Verify that invalid latencyHint values are rejected. + should( + function() { + context = new AudioContext({'latencyHint': 'foo'}) + }, + 'context = new AudioContext({\'latencyHint\': \'foo\'})') + .throw(TypeError); + + // Verify that no extra options can be passed into the + // AudioContextOptions. + should( + function() { + context = new AudioContext('latencyHint') + }, + 'context = new AudioContext(\'latencyHint\')') + .throw(TypeError); + + Promise.all(closingPromises).then(function() { + task.done(); + }); + }); + + audit.define( + { + label: 'test-audiocontextoptions-sampleRate', + description: + 'Test creating contexts with non-default sampleRate values.' + }, + function(task, should) { + // A sampleRate of 1 is unlikely to be supported on any browser, + // test that this rate is rejected. + should( + () => { + context = new AudioContext({sampleRate: 1}) + }, + 'context = new AudioContext({sampleRate: 1})') + .throw(DOMException, 'NotSupportedError'); + + // A sampleRate of 1,000,000 is unlikely to be supported on any + // browser, test that this rate is also rejected. + should( + () => { + context = new AudioContext({sampleRate: 1000000}) + }, + 'context = new AudioContext({sampleRate: 1000000})') + .throw(DOMException, 'NotSupportedError'); + // A negative sample rate should not be accepted + should( + () => { + context = new AudioContext({sampleRate: -1}) + }, + 'context = new AudioContext({sampleRate: -1})') + .throw(DOMException, 'NotSupportedError'); + // A null sample rate should not be accepted + should( + () => { + context = new AudioContext({sampleRate: 0}) + }, + 'context = new AudioContext({sampleRate: 0})') + .throw(DOMException, 'NotSupportedError'); + + should( + () => { + context = new AudioContext({sampleRate: 24000}) + }, + 'context = new AudioContext({sampleRate: 24000})') + .notThrow(); + should( + context.sampleRate, 'sampleRate inrange') + .beEqualTo(24000); + + context.close(); + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/constructor-allowed-to-start.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/constructor-allowed-to-start.html new file mode 100644 index 0000000000..f866b5f7a1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/constructor-allowed-to-start.html @@ -0,0 +1,25 @@ +<!doctype html> +<title>AudioContext state around "allowed to start" in constructor</title> +<link rel=help href=https://webaudio.github.io/web-audio-api/#dom-audiocontext-audiocontext> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script> +setup({ single_test: true }); +test_driver.bless("audio playback", () => { + const ctx = new AudioContext(); + // Immediately after the constructor the state is "suspended" because the + // control message to start processing has just been sent, but the state + // should change soon. + assert_equals(ctx.state, "suspended", "initial state"); + ctx.onstatechange = () => { + assert_equals(ctx.state, "running", "state after statechange event"); + // Now create another context and ensure it starts out in the "suspended" + // state too, ensuring it's not synchronously "running". + const ctx2 = new AudioContext(); + assert_equals(ctx2.state, "suspended", "initial state of 2nd context"); + done(); + }; +}); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/crashtests/currentTime-after-discard.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/crashtests/currentTime-after-discard.html new file mode 100644 index 0000000000..8c74bd0aa1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/crashtests/currentTime-after-discard.html @@ -0,0 +1,14 @@ +<html> +<head> + <title> + Test currentTime after browsing context discard + </title> +</head> +<script> + const frame = document.createElement('frame'); + document.documentElement.appendChild(frame); + const ctx = new frame.contentWindow.AudioContext(); + frame.remove(); + ctx.currentTime; +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/processing-after-resume.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/processing-after-resume.https.html new file mode 100644 index 0000000000..e000ab124f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/processing-after-resume.https.html @@ -0,0 +1,55 @@ +<!doctype html> +<title>Test consistency of processing after resume()</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +const get_node_and_reply = (context) => { + const node = new AudioWorkletNode(context, 'port-processor'); + return new Promise((resolve) => { + node.port.onmessage = (event) => resolve({node: node, reply: event.data}); + }); +}; +const ping_for_reply = (node) => { + return new Promise((resolve) => { + node.port.onmessage = (event) => resolve(event.data); + node.port.postMessage('ping'); + }); +}; +const assert_consistent = (constructReply, pong, expectedPongTime, name) => { + const blockSize = 128; + assert_equals(pong.timeStamp, expectedPongTime, `${name} pong time`); + assert_equals(pong.processCallCount * blockSize, + pong.currentFrame - constructReply.currentFrame, + `${name} processed frame count`); +}; +const modulePath = '/webaudio/the-audio-api/' + + 'the-audioworklet-interface/processors/port-processor.js'; + +promise_test(async () => { + const realtime = new AudioContext(); + await realtime.audioWorklet.addModule(modulePath); + await realtime.suspend(); + const timeBeforeResume = realtime.currentTime; + // Two AudioWorkletNodes are constructed. + // node1 is constructed before and node2 after the resume() call. + const construct1 = get_node_and_reply(realtime); + const resume = realtime.resume(); + const construct2 = get_node_and_reply(realtime); + const {node: node1, reply: constructReply1} = await construct1; + assert_equals(constructReply1.timeStamp, timeBeforeResume, + 'construct time before resume'); + const {node: node2, reply: constructReply2} = await construct2; + assert_greater_than_equal(constructReply2.timeStamp, timeBeforeResume, + 'construct time after resume'); + await resume; + // Suspend the context to freeze time and check that the processing for each + // node matches the elapsed time. + await realtime.suspend(); + const timeAfterSuspend = realtime.currentTime; + const pong1 = await ping_for_reply(node1); + const pong2 = await ping_for_reply(node2); + assert_consistent(constructReply1, pong1, timeAfterSuspend, 'node1'); + assert_consistent(constructReply2, pong2, timeAfterSuspend, 'node2'); + assert_equals(pong1.currentFrame, pong2.currentFrame, 'currentFrame matches'); +}); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/promise-methods-after-discard.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/promise-methods-after-discard.html new file mode 100644 index 0000000000..2fb3c5a50b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/promise-methods-after-discard.html @@ -0,0 +1,28 @@ +<!doctype html> +<title>Test for rejected promises from methods on an AudioContext in a + discarded browsing context</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<body></body> +<script> +let context; +let childDOMException; +setup(() => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + context = new frame.contentWindow.AudioContext(); + childDOMException = frame.contentWindow.DOMException; + frame.remove(); +}); + +promise_test((t) => promise_rejects_dom(t, 'InvalidStateError', + childDOMException, context.suspend()), + 'suspend()'); +promise_test((t) => promise_rejects_dom(t, 'InvalidStateError', + childDOMException, context.resume()), + 'resume()'); +promise_test((t) => promise_rejects_dom(t, 'InvalidStateError', + childDOMException, context.close()), + 'close()'); +// decodeAudioData() is tested in audiocontext-detached-execution-context.html +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/resources/not-fully-active-helper.sub.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/resources/not-fully-active-helper.sub.html new file mode 100644 index 0000000000..2654a2a504 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/resources/not-fully-active-helper.sub.html @@ -0,0 +1,22 @@ +<!doctype html> +<html> +<iframe src="{{GET[childsrc]}}"> +</iframe> +<script> +const frame = document.getElementsByTagName('iframe')[0]; +const reply = op => window.parent.postMessage('DONE ' + op, '*'); + +window.onmessage = e => { + switch (e.data) { + case 'REMOVE FRAME': + frame.remove(); + reply(e.data); + break; + case 'NAVIGATE FRAME': + frame.srcdoc = '<html></html>'; + frame.onload = () => reply(e.data); + break; + } +}; +</script> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/suspend-after-construct.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/suspend-after-construct.html new file mode 100644 index 0000000000..596a825c3d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/suspend-after-construct.html @@ -0,0 +1,72 @@ +<!doctype html> +<title>Test AudioContext state updates with suspend() shortly after + construction</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +// A separate async_test is used for tracking state change counts so that it +// can report excess changes after the promise_test for the iteration has +// completed. +const changeCountingTest = async_test('State change counting'); + +const doTest = async (testCount) => { + const ctx = new AudioContext(); + // Explicitly resume to get a promise to indicate whether the context + // successfully started running. + const resume = ctx.resume(); + const suspend = ctx.suspend(); + let stateChangesDone = new Promise((resolve) => { + ctx.onstatechange = () => { + ++ctx.stateChangeCount; + changeCountingTest.step(() => { + assert_less_than_equal(ctx.stateChangeCount, + ctx.expectedStateChangeCount, + `ctx ${testCount} state change count.`); + assert_equals(ctx.state, ctx.expectedState, `ctx ${testCount} state`); + }); + if (ctx.stateChangeCount == ctx.totalStateChangeCount) { + resolve(); + } + }; + }); + ctx.stateChangeCount = 0; + ctx.expectedStateChangeCount = 1; + ctx.expectedState = 'running'; + ctx.totalStateChangeCount = 2; + let resumeState = 'pending'; + resume.then(() => { + resumeState = 'fulfilled'; + assert_equals(ctx.state, 'running', 'state on resume fulfilled.'); + }).catch(() => { + // The resume() promise may be rejected if "Attempt to acquire system + // resources" fails. The spec does not discuss the possibility of a + // subsequent suspend causing such a failure, but accept this as a + // reasonable behavior. + resumeState = 'rejected'; + assert_equals(ctx.state, 'suspended', 'state on resume rejected.'); + assert_equals(ctx.stateChangeCount, 0); + ctx.expectedStateChangeCount = 0; + stateChangesDone = Promise.resolve(); + }); + suspend.then(() => { + assert_not_equals(resumeState, 'pending', + 'resume promise should settle before suspend promise.') + if (resumeState == 'fulfilled') { + ++ctx.expectedStateChangeCount; + } + ctx.expectedState = 'suspended'; + assert_equals(ctx.state, 'suspended', 'state on suspend fulfilled.'); + }); + await resume; + await suspend; + await stateChangesDone; +}; + +// Repeat the test because Gecko uses different code when there is more than +// one AudioContext. The third run provides time to check that no further +// state changes from the second run are pending. +for (const testCount of [1, 2, 3]) { + promise_test(() => { return doTest(testCount); }, `Iteration ${testCount}`); +} +promise_test(async () => changeCountingTest.done(), 'Stop waiting'); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/suspend-with-navigation.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/suspend-with-navigation.html new file mode 100644 index 0000000000..b9328ae95d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audiocontext-interface/suspend-with-navigation.html @@ -0,0 +1,65 @@ +<!doctype html> +<meta name="timeout" content="long"> +<title>AudioContext.suspend() with navigation</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/common/utils.js"></script> +<script src="/common/dispatcher/dispatcher.js"></script> +<script src="/html/browsers/browsing-the-web/back-forward-cache/resources/helper.sub.js"></script> +<script> +'use strict'; +runBfcacheTest({ + funcBeforeNavigation: async () => { + window.promise_event = (target, name) => { + return new Promise(resolve => target[`on${name}`] = resolve); + }; + window.promise_source_ended = (audioCtx) => { + const source = new ConstantSourceNode(audioCtx); + source.start(0); + source.stop(audioCtx.currentTime + 1/audioCtx.sampleRate); + return promise_event(source, "ended"); + }; + + window.suspended_ctx = new AudioContext(); + // Perform the equivalent of test_driver.bless() to request a user gesture + // for when the test is run from a browser. test_driver would need to be + // able to postMessage() to the test context, which is not available due + // to window.open() being called with noopener (for back/forward cache). + // Audio autoplay is expected to be allowed when run through webdriver + // from `wpt run`. + let button = document.createElement('button'); + button.innerHTML = 'This test requires user interaction.<br />' + + 'Please click here to allow AudioContext.'; + document.body.appendChild(button); + button.addEventListener('click', () => { + document.body.removeChild(button); + suspended_ctx.resume(); + }, {once: true}); + // Wait for user gesture, if required. + await suspended_ctx.resume(); + await suspended_ctx.suspend(); + window.ended_promise = promise_source_ended(suspended_ctx); + }, + funcAfterAssertion: async (pageA) => { + const state = await pageA.execute_script(() => suspended_ctx.state); + assert_equals(state, 'suspended', 'state after back()'); + const first_ended = await pageA.execute_script(async () => { + // Wait for an ended event from a running AudioContext to provide enough + // time to check that the ended event has not yet been dispatched from + // the suspended ctx. + const running_ctx = new AudioContext(); + await running_ctx.resume(); + return Promise.race([ + ended_promise.then(() => 'suspended_ctx'), + promise_source_ended(running_ctx).then(() => 'running_ctx'), + ]); + }); + assert_equals(first_ended, 'running_ctx', + 'AudioContext of first ended event'); + await pageA.execute_script(() => { + window.suspended_ctx.resume(); + return ended_promise; + }); + }, +}, 'suspend() with navigation'); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-channel-rules.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-channel-rules.html new file mode 100644 index 0000000000..9067e6869b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-channel-rules.html @@ -0,0 +1,278 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audionode-channel-rules.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/mixing-rules.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let context = 0; + // Use a power of two to eliminate round-off converting frames to time. + let sampleRate = 32768; + let renderNumberOfChannels = 8; + let singleTestFrameLength = 8; + let testBuffers; + + // A list of connections to an AudioNode input, each of which is to be + // used in one or more specific test cases. Each element in the list is a + // string, with the number of connections corresponding to the length of + // the string, and each character in the string is from '1' to '8' + // representing a 1 to 8 channel connection (from an AudioNode output). + + // For example, the string "128" means 3 connections, having 1, 2, and 8 + // channels respectively. + + let connectionsList = [ + '1', '2', '3', '4', '5', '6', '7', '8', '11', '12', '14', '18', '111', + '122', '123', '124', '128' + ]; + + // A list of mixing rules, each of which will be tested against all of the + // connections in connectionsList. + let mixingRulesList = [ + { + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }, + { + channelCount: 4, + channelCountMode: 'clamped-max', + channelInterpretation: 'speakers' + }, + + // Test up-down-mix to some explicit speaker layouts. + { + channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'speakers' + }, + { + channelCount: 2, + channelCountMode: 'explicit', + channelInterpretation: 'speakers' + }, + { + channelCount: 4, + channelCountMode: 'explicit', + channelInterpretation: 'speakers' + }, + { + channelCount: 6, + channelCountMode: 'explicit', + channelInterpretation: 'speakers' + }, + + { + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'discrete' + }, + { + channelCount: 4, + channelCountMode: 'clamped-max', + channelInterpretation: 'discrete' + }, + { + channelCount: 4, + channelCountMode: 'explicit', + channelInterpretation: 'discrete' + }, + { + channelCount: 8, + channelCountMode: 'explicit', + channelInterpretation: 'discrete' + }, + ]; + + let numberOfTests = mixingRulesList.length * connectionsList.length; + + // Print out the information for an individual test case. + function printTestInformation( + testNumber, actualBuffer, expectedBuffer, frameLength, frameOffset) { + let actual = stringifyBuffer(actualBuffer, frameLength); + let expected = + stringifyBuffer(expectedBuffer, frameLength, frameOffset); + debug('TEST CASE #' + testNumber + '\n'); + debug('actual channels:\n' + actual); + debug('expected channels:\n' + expected); + } + + function scheduleTest( + testNumber, connections, channelCount, channelCountMode, + channelInterpretation) { + let mixNode = context.createGain(); + mixNode.channelCount = channelCount; + mixNode.channelCountMode = channelCountMode; + mixNode.channelInterpretation = channelInterpretation; + mixNode.connect(context.destination); + + for (let i = 0; i < connections.length; ++i) { + let connectionNumberOfChannels = + connections.charCodeAt(i) - '0'.charCodeAt(0); + + let source = context.createBufferSource(); + // Get a buffer with the right number of channels, converting from + // 1-based to 0-based index. + let buffer = testBuffers[connectionNumberOfChannels - 1]; + source.buffer = buffer; + source.connect(mixNode); + + // Start at the right offset. + let sampleFrameOffset = testNumber * singleTestFrameLength; + let time = sampleFrameOffset / sampleRate; + source.start(time); + } + } + + function checkTestResult( + renderedBuffer, testNumber, connections, channelCount, + channelCountMode, channelInterpretation, should) { + let s = 'connections: ' + connections + ', ' + channelCountMode; + + // channelCount is ignored in "max" mode. + if (channelCountMode == 'clamped-max' || + channelCountMode == 'explicit') { + s += '(' + channelCount + ')'; + } + + s += ', ' + channelInterpretation; + + let computedNumberOfChannels = computeNumberOfChannels( + connections, channelCount, channelCountMode); + + // Create a zero-initialized silent AudioBuffer with + // computedNumberOfChannels. + let destBuffer = context.createBuffer( + computedNumberOfChannels, singleTestFrameLength, + context.sampleRate); + + // Mix all of the connections into the destination buffer. + for (let i = 0; i < connections.length; ++i) { + let connectionNumberOfChannels = + connections.charCodeAt(i) - '0'.charCodeAt(0); + let sourceBuffer = + testBuffers[connectionNumberOfChannels - 1]; // convert from + // 1-based to + // 0-based index + + if (channelInterpretation == 'speakers') { + speakersSum(sourceBuffer, destBuffer); + } else if (channelInterpretation == 'discrete') { + discreteSum(sourceBuffer, destBuffer); + } else { + alert('Invalid channel interpretation!'); + } + } + + // Use this when debugging mixing rules. + // printTestInformation(testNumber, renderedBuffer, destBuffer, + // singleTestFrameLength, sampleFrameOffset); + + // Validate that destBuffer matches the rendered output. We need to + // check the rendered output at a specific sample-frame-offset + // corresponding to the specific test case we're checking for based on + // testNumber. + + let sampleFrameOffset = testNumber * singleTestFrameLength; + for (let c = 0; c < renderNumberOfChannels; ++c) { + let renderedData = renderedBuffer.getChannelData(c); + for (let frame = 0; frame < singleTestFrameLength; ++frame) { + let renderedValue = renderedData[frame + sampleFrameOffset]; + + let expectedValue = 0; + if (c < destBuffer.numberOfChannels) { + let expectedData = destBuffer.getChannelData(c); + expectedValue = expectedData[frame]; + } + + // We may need to add an epsilon in the comparison if we add more + // test vectors. + if (renderedValue != expectedValue) { + let message = s + 'rendered: ' + renderedValue + + ' expected: ' + expectedValue + ' channel: ' + c + + ' frame: ' + frame; + // testFailed(s); + should(renderedValue, s).beEqualTo(expectedValue); + return; + } + } + } + + should(true, s).beTrue(); + } + + function checkResult(buffer, should) { + // Sanity check result. + should(buffer.length, 'Rendered number of frames') + .beEqualTo(numberOfTests * singleTestFrameLength); + should(buffer.numberOfChannels, 'Rendered number of channels') + .beEqualTo(renderNumberOfChannels); + + // Check all the tests. + let testNumber = 0; + for (let m = 0; m < mixingRulesList.length; ++m) { + let mixingRules = mixingRulesList[m]; + for (let i = 0; i < connectionsList.length; ++i, ++testNumber) { + checkTestResult( + buffer, testNumber, connectionsList[i], + mixingRules.channelCount, mixingRules.channelCountMode, + mixingRules.channelInterpretation, should); + } + } + } + + audit.define( + {label: 'test', description: 'Channel mixing rules for AudioNodes'}, + function(task, should) { + + // Create 8-channel offline audio context. Each test will render 8 + // sample-frames starting at sample-frame position testNumber * 8. + let totalFrameLength = numberOfTests * singleTestFrameLength; + context = new OfflineAudioContext( + renderNumberOfChannels, totalFrameLength, sampleRate); + + // Set destination to discrete mixing. + context.destination.channelCount = renderNumberOfChannels; + context.destination.channelCountMode = 'explicit'; + context.destination.channelInterpretation = 'discrete'; + + // Create test buffers from 1 to 8 channels. + testBuffers = new Array(); + for (let i = 0; i < renderNumberOfChannels; ++i) { + testBuffers[i] = createShiftedImpulseBuffer( + context, i + 1, singleTestFrameLength); + } + + // Schedule all the tests. + let testNumber = 0; + for (let m = 0; m < mixingRulesList.length; ++m) { + let mixingRules = mixingRulesList[m]; + for (let i = 0; i < connectionsList.length; ++i, ++testNumber) { + scheduleTest( + testNumber, connectionsList[i], mixingRules.channelCount, + mixingRules.channelCountMode, + mixingRules.channelInterpretation); + } + } + + // Render then check results. + // context.oncomplete = checkResult; + context.startRendering().then(buffer => { + checkResult(buffer, should); + task.done(); + }); + ; + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-method-chaining.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-method-chaining.html new file mode 100644 index 0000000000..02caea667b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-method-chaining.html @@ -0,0 +1,165 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audionode-connect-method-chaining.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // AudioNode dictionary with associated arguments. + let nodeDictionary = [ + {name: 'Analyser'}, {name: 'BiquadFilter'}, {name: 'BufferSource'}, + {name: 'ChannelMerger', args: [6]}, + {name: 'ChannelSplitter', args: [6]}, {name: 'Convolver'}, + {name: 'Delay', args: []}, {name: 'DynamicsCompressor'}, {name: 'Gain'}, + {name: 'Oscillator'}, {name: 'Panner'}, + {name: 'ScriptProcessor', args: [512, 1, 1]}, {name: 'StereoPanner'}, + {name: 'WaveShaper'} + ]; + + + function verifyReturnedNode(should, config) { + should( + config.destination === config.returned, + 'The return value of ' + config.desc + ' matches the destination ' + + config.returned.constructor.name) + .beEqualTo(true); + } + + // Test utility for batch method checking: in order to test 3 method + // signatures, so we create 3 dummy destinations. + // 1) .connect(GainNode) + // 2) .connect(BiquadFilterNode, output) + // 3) .connect(ChannelMergerNode, output, input) + function testConnectMethod(context, should, options) { + let source = + context['create' + options.name].apply(context, options.args); + let sourceName = source.constructor.name; + + let destination1 = context.createGain(); + verifyReturnedNode(should, { + source: source, + destination: destination1, + returned: source.connect(destination1), + desc: sourceName + '.connect(' + destination1.constructor.name + ')' + }); + + let destination2 = context.createBiquadFilter(); + verifyReturnedNode(should, { + source: source, + destination: destination2, + returned: source.connect(destination2, 0), + desc: + sourceName + '.connect(' + destination2.constructor.name + ', 0)' + }); + + let destination3 = context.createChannelMerger(); + verifyReturnedNode(should, { + source: source, + destination: destination3, + returned: source.connect(destination3, 0, 1), + desc: sourceName + '.connect(' + destination3.constructor.name + + ', 0, 1)' + }); + } + + + let audit = Audit.createTaskRunner(); + + // Task: testing entries from the dictionary. + audit.define('from-dictionary', (task, should) => { + let context = new AudioContext(); + + for (let i = 0; i < nodeDictionary.length; i++) + testConnectMethod(context, should, nodeDictionary[i]); + + task.done(); + }); + + // Task: testing Media* nodes. + audit.define('media-group', (task, should) => { + let context = new AudioContext(); + + // Test MediaElementSourceNode needs an <audio> element. + let mediaElement = document.createElement('audio'); + testConnectMethod( + context, should, + {name: 'MediaElementSource', args: [mediaElement]}); + + // MediaStreamAudioDestinationNode has no output so it connect method + // chaining isn't possible. + + // MediaStreamSourceNode requires 'stream' object to be constructed, + // which is a part of MediaStreamDestinationNode. + let streamDestination = context.createMediaStreamDestination(); + let stream = streamDestination.stream; + testConnectMethod( + context, should, {name: 'MediaStreamSource', args: [stream]}); + + task.done(); + }); + + // Task: test the exception thrown by invalid operation. + audit.define('invalid-operation', (task, should) => { + let contextA = new AudioContext(); + let contextB = new AudioContext(); + let gain1 = contextA.createGain(); + let gain2 = contextA.createGain(); + + // Test if the first connection throws correctly. The first gain node + // does not have the second output, so it should throw. + should(function() { + gain1.connect(gain2, 1).connect(contextA.destination); + }, 'Connecting with an invalid output').throw(DOMException, 'IndexSizeError'); + + // Test if the second connection throws correctly. The contextB's + // destination is not compatible with the nodes from contextA, thus the + // first connection succeeds but the second one should throw. + should( + function() { + gain1.connect(gain2).connect(contextB.destination); + }, + 'Connecting to a node from the different context') + .throw(DOMException, 'InvalidAccessError'); + + task.done(); + }); + + // Task: verify if the method chaining actually works. + audit.define('verification', (task, should) => { + // We pick the lowest sample rate allowed to run the test efficiently. + let context = new OfflineAudioContext(1, 128, 8000); + + let constantBuffer = createConstantBuffer(context, 1, 1.0); + + let source = context.createBufferSource(); + source.buffer = constantBuffer; + source.loop = true; + + let gain1 = context.createGain(); + gain1.gain.value = 0.5; + let gain2 = context.createGain(); + gain2.gain.value = 0.25; + + source.connect(gain1).connect(gain2).connect(context.destination); + source.start(); + + context.startRendering() + .then(function(buffer) { + should( + buffer.getChannelData(0), + 'The output of chained connection of gain nodes') + .beConstantValueOf(0.125); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-order.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-order.html new file mode 100644 index 0000000000..eca15dedfa --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-order.html @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audionode-connect-order.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let sampleRate = 44100.0; + let renderLengthSeconds = 0.125; + let delayTimeSeconds = 0.1; + + function createSinWaveBuffer(context, lengthInSeconds, frequency) { + let audioBuffer = + context.createBuffer(1, lengthInSeconds * sampleRate, sampleRate); + + let n = audioBuffer.length; + let data = audioBuffer.getChannelData(0); + + for (let i = 0; i < n; ++i) { + data[i] = Math.sin(frequency * 2 * Math.PI * i / sampleRate); + } + + return audioBuffer; + } + + audit.define( + { + label: 'Test connections', + description: + 'AudioNode connection order doesn\'t trigger assertion errors' + }, + function(task, should) { + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = + createSinWaveBuffer(context, renderLengthSeconds, 880); + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + bufferSource.connect(context.destination); + + let delay = context.createDelay(); + delay.delayTime.value = delayTimeSeconds; + + // We connect delay node to gain node before anything is connected + // to delay node itself. We do this because we try to trigger the + // ASSERT which might be fired due to AudioNode connection order, + // especially when gain node and delay node is involved e.g. + // https://bugs.webkit.org/show_bug.cgi?id=76685. + + should(() => { + let gain = context.createGain(); + gain.connect(context.destination); + delay.connect(gain); + }, 'Connecting nodes').notThrow(); + + bufferSource.start(0); + + let promise = context.startRendering(); + + should(promise, 'OfflineContext startRendering()') + .beResolved() + .then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-return-value.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-return-value.html new file mode 100644 index 0000000000..3af44fb7af --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-connect-return-value.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<title>Test the return value of connect when connecting two AudioNodes</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +test(function(t) { + var context = new OfflineAudioContext(1, 1, 44100); + var g1 = context.createGain(); + var g2 = context.createGain(); + var rv = g1.connect(g2); + assert_equals(rv, g2); + var rv = g1.connect(g2); + assert_equals(rv, g2); +}, "connect should return the node connected to."); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-disconnect-audioparam.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-disconnect-audioparam.html new file mode 100644 index 0000000000..0b09edd4a7 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-disconnect-audioparam.html @@ -0,0 +1,221 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audionode-disconnect-audioparam.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let renderQuantum = 128; + + let sampleRate = 44100; + let renderDuration = 0.5; + let disconnectTime = 0.5 * renderDuration; + + let audit = Audit.createTaskRunner(); + + // Calculate the index for disconnection. + function getDisconnectIndex(disconnectTime) { + let disconnectIndex = disconnectTime * sampleRate; + disconnectIndex = renderQuantum * + Math.floor((disconnectIndex + renderQuantum - 1) / renderQuantum); + return disconnectIndex; + } + + // Get the index of value change. + function getValueChangeIndex(array, targetValue) { + return array.findIndex(function(element, index) { + if (element === targetValue) + return true; + }); + } + + // Task 1: test disconnect(AudioParam) method. + audit.define('disconnect(AudioParam)', (task, should) => { + // Creates a buffer source with value [1] and then connect it to two + // gain nodes in series. The output of the buffer source is lowered by + // half + // (* 0.5) and then connected to two |.gain| AudioParams in each gain + // node. + // + // (1) bufferSource => gain1 => gain2 + // (2) bufferSource => half => gain1.gain + // (3) half => gain2.gain + // + // This graph should produce the output of 2.25 (= 1 * 1.5 * 1.5). After + // disconnecting (3), it should produce 1.5. + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + let source = context.createBufferSource(); + let buffer1ch = createConstantBuffer(context, 1, 1); + let half = context.createGain(); + let gain1 = context.createGain(); + let gain2 = context.createGain(); + + source.buffer = buffer1ch; + source.loop = true; + half.gain.value = 0.5; + + source.connect(gain1); + gain1.connect(gain2); + gain2.connect(context.destination); + source.connect(half); + + // Connecting |half| to both |gain1.gain| and |gain2.gain| amplifies the + // signal by 2.25 (= 1.5 * 1.5) because each gain node amplifies the + // signal by 1.5 (= 1.0 + 0.5). + half.connect(gain1.gain); + half.connect(gain2.gain); + + source.start(); + + // Schedule the disconnection at the half of render duration. + context.suspend(disconnectTime).then(function() { + half.disconnect(gain2.gain); + context.resume(); + }); + + context.startRendering() + .then(function(buffer) { + let channelData = buffer.getChannelData(0); + let disconnectIndex = getDisconnectIndex(disconnectTime); + let valueChangeIndex = getValueChangeIndex(channelData, 1.5); + + // Expected values are: 1 * 1.5 * 1.5 -> 1 * 1.5 = [2.25, 1.5] + should(channelData, 'Channel #0').containValues([2.25, 1.5]); + should(valueChangeIndex, 'The index of value change') + .beEqualTo(disconnectIndex); + }) + .then(() => task.done()); + }); + + // Task 2: test disconnect(AudioParam, output) method. + audit.define('disconnect(AudioParam, output)', (task, should) => { + // Create a 2-channel buffer source with [1, 2] in each channel and + // make a serial connection through gain1 and gain 2. The make the + // buffer source half with a gain node and connect it to a 2-output + // splitter. Connect each output to 2 gain AudioParams respectively. + // + // (1) bufferSource => gain1 => gain2 + // (2) bufferSource => half => splitter(2) + // (3) splitter#0 => gain1.gain + // (4) splitter#1 => gain2.gain + // + // This graph should produce 3 (= 1 * 1.5 * 2) and 6 (= 2 * 1.5 * 2) for + // each channel. After disconnecting (4), it should output 1.5 and 3. + let context = + new OfflineAudioContext(2, renderDuration * sampleRate, sampleRate); + let source = context.createBufferSource(); + let buffer2ch = createConstantBuffer(context, 1, [1, 2]); + let splitter = context.createChannelSplitter(2); + let half = context.createGain(); + let gain1 = context.createGain(); + let gain2 = context.createGain(); + + source.buffer = buffer2ch; + source.loop = true; + half.gain.value = 0.5; + + source.connect(gain1); + gain1.connect(gain2); + gain2.connect(context.destination); + + // |source| originally is [1, 2] but it becomes [0.5, 1] after 0.5 gain. + // Each splitter's output will be applied to |gain1.gain| and + // |gain2.gain| respectively in an additive fashion. + source.connect(half); + half.connect(splitter); + + // This amplifies the signal by 1.5. (= 1.0 + 0.5) + splitter.connect(gain1.gain, 0); + + // This amplifies the signal by 2. (= 1.0 + 1.0) + splitter.connect(gain2.gain, 1); + + source.start(); + + // Schedule the disconnection at the half of render duration. + context.suspend(disconnectTime).then(function() { + splitter.disconnect(gain2.gain, 1); + context.resume(); + }); + + context.startRendering() + .then(function(buffer) { + let channelData0 = buffer.getChannelData(0); + let channelData1 = buffer.getChannelData(1); + + let disconnectIndex = getDisconnectIndex(disconnectTime); + let valueChangeIndexCh0 = getValueChangeIndex(channelData0, 1.5); + let valueChangeIndexCh1 = getValueChangeIndex(channelData1, 3); + + // Expected values are: 1 * 1.5 * 2 -> 1 * 1.5 = [3, 1.5] + should(channelData0, 'Channel #0').containValues([3, 1.5]); + should( + valueChangeIndexCh0, + 'The index of value change in channel #0') + .beEqualTo(disconnectIndex); + + // Expected values are: 2 * 1.5 * 2 -> 2 * 1.5 = [6, 3] + should(channelData1, 'Channel #1').containValues([6, 3]); + should( + valueChangeIndexCh1, + 'The index of value change in channel #1') + .beEqualTo(disconnectIndex); + }) + .then(() => task.done()); + }); + + // Task 3: exception checks. + audit.define('exceptions', (task, should) => { + let context = new AudioContext(); + let gain1 = context.createGain(); + let splitter = context.createChannelSplitter(2); + let gain2 = context.createGain(); + let gain3 = context.createGain(); + + // Connect a splitter to gain nodes and merger so we can test the + // possible ways of disconnecting the nodes to verify that appropriate + // exceptions are thrown. + gain1.connect(splitter); + splitter.connect(gain2.gain, 0); + splitter.connect(gain3.gain, 1); + gain2.connect(gain3); + gain3.connect(context.destination); + + // gain1 is not connected to gain3.gain. Exception should be thrown. + should( + function() { + gain1.disconnect(gain3.gain); + }, + 'gain1.disconnect(gain3.gain)') + .throw(DOMException, 'InvalidAccessError'); + + // When the output index is good but the destination is invalid. + should( + function() { + splitter.disconnect(gain1.gain, 1); + }, + 'splitter.disconnect(gain1.gain, 1)') + .throw(DOMException, 'InvalidAccessError'); + + // When both arguments are wrong, throw IndexSizeError first. + should( + function() { + splitter.disconnect(gain1.gain, 2); + }, + 'splitter.disconnect(gain1.gain, 2)') + .throw(DOMException, 'IndexSizeError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-disconnect.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-disconnect.html new file mode 100644 index 0000000000..65b93222d1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-disconnect.html @@ -0,0 +1,298 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audionode-disconnect.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Task 1: test disconnect() method. + audit.define('disconnect()', (task, should) => { + + // Connect a source to multiple gain nodes, each connected to the + // destination. Then disconnect the source. The expected output should + // be all zeros since the source was disconnected. + let context = new OfflineAudioContext(1, 128, 44100); + let source = context.createBufferSource(); + let buffer1ch = createConstantBuffer(context, 128, [1]); + let gain1 = context.createGain(); + let gain2 = context.createGain(); + let gain3 = context.createGain(); + + source.buffer = buffer1ch; + + source.connect(gain1); + source.connect(gain2); + source.connect(gain3); + gain1.connect(context.destination); + gain2.connect(context.destination); + gain3.connect(context.destination); + source.start(); + + // This disconnects everything. + source.disconnect(); + + context.startRendering() + .then(function(buffer) { + + // With everything disconnected, the result should be zero. + should(buffer.getChannelData(0), 'Channel #0') + .beConstantValueOf(0); + + }) + .then(() => task.done()); + }); + + // Task 2: test disconnect(output) method. + audit.define('disconnect(output)', (task, should) => { + + // Create multiple connections from each output of a ChannelSplitter + // to a gain node. Then test if disconnecting a single output of + // splitter is actually disconnected. + let context = new OfflineAudioContext(1, 128, 44100); + let source = context.createBufferSource(); + let buffer3ch = createConstantBuffer(context, 128, [1, 2, 3]); + let splitter = context.createChannelSplitter(3); + let sum = context.createGain(); + + source.buffer = buffer3ch; + + source.connect(splitter); + splitter.connect(sum, 0); + splitter.connect(sum, 1); + splitter.connect(sum, 2); + sum.connect(context.destination); + source.start(); + + // This disconnects the second output. + splitter.disconnect(1); + + context.startRendering() + .then(function(buffer) { + + // The rendered channel should contain 4. (= 1 + 0 + 3) + should(buffer.getChannelData(0), 'Channel #0') + .beConstantValueOf(4); + + }) + .then(() => task.done()); + }); + + // Task 3: test disconnect(AudioNode) method. + audit.define('disconnect(AudioNode)', (task, should) => { + + // Connect a source to multiple gain nodes. Then test if disconnecting a + // single destination selectively works correctly. + let context = new OfflineAudioContext(1, 128, 44100); + let source = context.createBufferSource(); + let buffer1ch = createConstantBuffer(context, 128, [1]); + let gain1 = context.createGain(); + let gain2 = context.createGain(); + let gain3 = context.createGain(); + let orphan = context.createGain(); + + source.buffer = buffer1ch; + + source.connect(gain1); + source.connect(gain2); + source.connect(gain3); + gain1.connect(context.destination); + gain2.connect(context.destination); + gain3.connect(context.destination); + source.start(); + + source.disconnect(gain2); + + context.startRendering() + .then(function(buffer) { + + // The |sum| gain node should produce value 2. (1 + 0 + 1 = 2) + should(buffer.getChannelData(0), 'Channel #0') + .beConstantValueOf(2); + + }) + .then(() => task.done()); + }); + + // Task 4: test disconnect(AudioNode, output) method. + audit.define('disconnect(AudioNode, output)', (task, should) => { + + // Connect a buffer with 2 channels with each containing 1 and 2 + // respectively to a ChannelSplitter, then connect the splitter to 2 + // gain nodes as shown below: + // (1) splitter#0 => gain1 + // (2) splitter#0 => gain2 + // (3) splitter#1 => gain2 + // Then disconnect (2) and verify if the selective disconnection on a + // specified output of the destination node works correctly. + let context = new OfflineAudioContext(1, 128, 44100); + let source = context.createBufferSource(); + let buffer2ch = createConstantBuffer(context, 128, [1, 2]); + let splitter = context.createChannelSplitter(2); + let gain1 = context.createGain(); + let gain2 = context.createGain(); + + source.buffer = buffer2ch; + + source.connect(splitter); + splitter.connect(gain1, 0); // gain1 gets channel 0. + splitter.connect(gain2, 0); // gain2 sums channel 0 and 1. + splitter.connect(gain2, 1); + gain1.connect(context.destination); + gain2.connect(context.destination); + source.start(); + + splitter.disconnect(gain2, 0); // Now gain2 gets [2] + + context.startRendering() + .then(function(buffer) { + + // The sum of gain1 and gain2 should produce value 3. (= 1 + 2) + should(buffer.getChannelData(0), 'Channel #0') + .beConstantValueOf(3); + + }) + .then(() => task.done()); + }); + + // Task 5: test disconnect(AudioNode, output, input) method. + audit.define('disconnect(AudioNode, output, input)', (task, should) => { + + // Create a 3-channel buffer with [1, 2, 3] in each channel and then + // pass it through a splitter and a merger. Each input/output of the + // splitter and the merger is connected in a sequential order as shown + // below. + // (1) splitter#0 => merger#0 + // (2) splitter#1 => merger#1 + // (3) splitter#2 => merger#2 + // Then disconnect (3) and verify if each channel contains [1] and [2] + // respectively. + let context = new OfflineAudioContext(3, 128, 44100); + let source = context.createBufferSource(); + let buffer3ch = createConstantBuffer(context, 128, [1, 2, 3]); + let splitter = context.createChannelSplitter(3); + let merger = context.createChannelMerger(3); + + source.buffer = buffer3ch; + + source.connect(splitter); + splitter.connect(merger, 0, 0); + splitter.connect(merger, 1, 1); + splitter.connect(merger, 2, 2); + merger.connect(context.destination); + source.start(); + + splitter.disconnect(merger, 2, 2); + + context.startRendering() + .then(function(buffer) { + + // Each channel should have 1, 2, and 0 respectively. + should(buffer.getChannelData(0), 'Channel #0') + .beConstantValueOf(1); + should(buffer.getChannelData(1), 'Channel #1') + .beConstantValueOf(2); + should(buffer.getChannelData(2), 'Channel #2') + .beConstantValueOf(0); + + }) + .then(() => task.done()); + }); + + // Task 6: exception checks. + audit.define('exceptions', (task, should) => { + let context = new OfflineAudioContext(2, 128, 44100); + let gain1 = context.createGain(); + let splitter = context.createChannelSplitter(2); + let merger = context.createChannelMerger(2); + let gain2 = context.createGain(); + let gain3 = context.createGain(); + + // Connect a splitter to gain nodes and merger so we can test the + // possible ways of disconnecting the nodes to verify that appropriate + // exceptions are thrown. + gain1.connect(splitter); + splitter.connect(gain2, 0); + splitter.connect(gain3, 1); + splitter.connect(merger, 0, 0); + splitter.connect(merger, 1, 1); + gain2.connect(gain3); + gain3.connect(context.destination); + merger.connect(context.destination); + + // There is no output #2. An exception should be thrown. + should(function() { + splitter.disconnect(2); + }, 'splitter.disconnect(2)').throw(DOMException, 'IndexSizeError'); + + // Disconnecting the output already disconnected should not throw. + should(function() { + splitter.disconnect(1); + splitter.disconnect(1); + }, 'Disconnecting a connection twice').notThrow(); + + // gain1 is not connected gain2. An exception should be thrown. + should(function() { + gain1.disconnect(gain2); + }, 'gain1.disconnect(gain2)').throw(DOMException, 'InvalidAccessError'); + + // gain1 and gain3 are not connected. An exception should be thrown. + should(function() { + gain1.disconnect(gain3); + }, 'gain1.disconnect(gain3)').throw(DOMException, 'InvalidAccessError'); + + // There is no output #2 in the splitter. An exception should be thrown. + should(function() { + splitter.disconnect(gain2, 2); + }, 'splitter.disconnect(gain2, 2)').throw(DOMException, 'IndexSizeError'); + + // The splitter and gain1 are not connected. An exception should be + // thrown. + should(function() { + splitter.disconnect(gain1, 0); + }, 'splitter.disconnect(gain1, 0)').throw(DOMException, 'InvalidAccessError'); + + // The splitter output #0 and the gain3 output #0 are not connected. An + // exception should be thrown. + should(function() { + splitter.disconnect(gain3, 0, 0); + }, 'splitter.disconnect(gain3, 0, 0)').throw(DOMException, 'InvalidAccessError'); + + // The output index is out of bound. An exception should be thrown. + should(function() { + splitter.disconnect(merger, 3, 0); + }, 'splitter.disconnect(merger, 3, 0)').throw(DOMException, 'IndexSizeError'); + + task.done(); + }); + + audit.define('disabled-outputs', (task, should) => { + // See crbug.com/656652 + let context = new OfflineAudioContext(2, 1024, 44100); + let g1 = context.createGain(); + let g2 = context.createGain(); + g1.connect(g2); + g1.disconnect(g2); + let g3 = context.createGain(); + g2.connect(g3); + g1.connect(g2); + context.startRendering() + .then(function() { + // If we make it here, we passed. + should(true, 'Disabled outputs handled') + .message('correctly', 'inccorrectly'); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-iframe.window.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-iframe.window.js new file mode 100644 index 0000000000..89bdf2aa98 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode-iframe.window.js @@ -0,0 +1,14 @@ +test(function() { + const iframe = + document.createElementNS('http://www.w3.org/1999/xhtml', 'iframe'); + document.body.appendChild(iframe); + + // Create AudioContext and AudioNode from iframe + const context = new iframe.contentWindow.AudioContext(); + const source = context.createOscillator(); + source.connect(context.destination); + + // AudioContext should be put closed state after iframe destroyed + document.body.removeChild(iframe); + assert_equals(context.state, 'closed'); +}, 'Call a constructor from iframe page and then destroy the iframe'); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode.html new file mode 100644 index 0000000000..0b57d27e8e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/audionode.html @@ -0,0 +1,93 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audionode.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <div id="description"></div> + <div id="console"></div> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let context = 0; + let context2 = 0; + let context3 = 0; + + audit.define( + {label: 'test', description: 'Basic tests for AudioNode API.'}, + function(task, should) { + + context = new AudioContext(); + window.audioNode = context.createBufferSource(); + + // Check input and output numbers of AudioSourceNode. + should(audioNode.numberOfInputs, 'AudioBufferSource.numberOfInputs') + .beEqualTo(0); + should( + audioNode.numberOfOutputs, 'AudioBufferSource.numberOfOutputs') + .beEqualTo(1); + + // Check input and output numbers of AudioDestinationNode + should( + context.destination.numberOfInputs, + 'AudioContext.destination.numberOfInputs') + .beEqualTo(1); + should( + context.destination.numberOfOutputs, + 'AudioContext.destination.numberOfOutputs') + .beEqualTo(0); + + // Try calling connect() method with illegal values. + should( + () => audioNode.connect(0, 0, 0), 'audioNode.connect(0, 0, 0)') + .throw(TypeError); + should( + () => audioNode.connect(null, 0, 0), + 'audioNode.connect(null, 0, 0)') + .throw(TypeError); + should( + () => audioNode.connect(context.destination, 5, 0), + 'audioNode.connect(context.destination, 5, 0)') + .throw(DOMException, 'IndexSizeError'); + should( + () => audioNode.connect(context.destination, 0, 5), + 'audioNode.connect(context.destination, 0, 5)') + .throw(DOMException, 'IndexSizeError'); + + should( + () => audioNode.connect(context.destination, 0, 0), + 'audioNode.connect(context.destination, 0, 0)') + .notThrow(); + + // Create a new context and try to connect the other context's node + // to this one. + context2 = new AudioContext(); + should( + () => window.audioNode.connect(context2.destination), + 'Connecting a node to a different context') + .throw(DOMException, 'InvalidAccessError'); + + // 3-arg AudioContext doesn't create an offline context anymore. + should( + () => context3 = new AudioContext(1, 44100, 44100), + 'context3 = new AudioContext(1, 44100, 44100)') + .throw(TypeError); + + // Ensure it is an EventTarget + should( + audioNode instanceof EventTarget, 'AudioNode is an EventTarget') + .beTrue(); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/channel-mode-interp-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/channel-mode-interp-basic.html new file mode 100644 index 0000000000..35cfca8e4e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/channel-mode-interp-basic.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Setting of channelCountMode and channelInterpretation + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Fairly arbitrary sample rate and number of frames, except the number of + // frames should be more than a few render quantums. + let sampleRate = 16000; + let renderFrames = 10 * 128; + + let audit = Audit.createTaskRunner(); + + audit.define('interp', (task, should) => { + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let node = context.createGain(); + + // Set a new interpretation and verify that it changed. + node.channelInterpretation = 'discrete'; + let value = node.channelInterpretation; + should(value, 'node.channelInterpretation').beEqualTo('discrete'); + node.connect(context.destination); + + context.startRendering() + .then(function(buffer) { + // After rendering, the value should have been changed. + should( + node.channelInterpretation, + 'After rendering node.channelInterpretation') + .beEqualTo('discrete'); + }) + .then(() => task.done()); + }); + + audit.define('mode', (task, should) => { + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let node = context.createGain(); + + // Set a new mode and verify that it changed. + node.channelCountMode = 'explicit'; + let value = node.channelCountMode; + should(value, 'node.channelCountMode').beEqualTo('explicit'); + node.connect(context.destination); + + context.startRendering() + .then(function(buffer) { + // After rendering, the value should have been changed. + should( + node.channelCountMode, + 'After rendering node.channelCountMode') + .beEqualTo('explicit'); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/different-contexts.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/different-contexts.html new file mode 100644 index 0000000000..f763d34787 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audionode-interface/different-contexts.html @@ -0,0 +1,101 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Connections and disconnections with different contexts + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Different contexts to be used for testing. + let c1; + let c2; + + audit.define( + {label: 'setup', description: 'Contexts for testing'}, + (task, should) => { + should(() => {c1 = new AudioContext()}, 'c1 = new AudioContext()') + .notThrow(); + should(() => {c2 = new AudioContext()}, 'c2 = new AudioContext()') + .notThrow(); + task.done(); + }); + + audit.define( + {label: 'Test 1', description: 'Connect nodes between contexts'}, + (task, should) => { + let g1; + let g2; + should( + () => {g1 = new GainNode(c1)}, 'Test 1: g1 = new GainNode(c1)') + .notThrow(); + should( + () => {g2 = new GainNode(c2)}, 'Test 1: g2 = new GainNode(c2)') + .notThrow(); + should(() => {g2.connect(g1)}, 'Test 1: g2.connect(g1)') + .throw(DOMException, 'InvalidAccessError'); + task.done(); + }); + + audit.define( + {label: 'Test 2', description: 'Connect AudioParam between contexts'}, + (task, should) => { + let g1; + let g2; + should( + () => {g1 = new GainNode(c1)}, 'Test 2: g1 = new GainNode(c1)') + .notThrow(); + should( + () => {g2 = new GainNode(c2)}, 'Test 2: g2 = new GainNode(c2)') + .notThrow(); + should(() => {g2.connect(g1.gain)}, 'Test 2: g2.connect(g1.gain)') + .throw(DOMException, 'InvalidAccessError'); + task.done(); + }); + + audit.define( + {label: 'Test 3', description: 'Disconnect nodes between contexts'}, + (task, should) => { + let g1; + let g2; + should( + () => {g1 = new GainNode(c1)}, 'Test 3: g1 = new GainNode(c1)') + .notThrow(); + should( + () => {g2 = new GainNode(c2)}, 'Test 3: g2 = new GainNode(c2)') + .notThrow(); + should(() => {g2.disconnect(g1)}, 'Test 3: g2.disconnect(g1)') + .throw(DOMException, 'InvalidAccessError'); + task.done(); + }); + + audit.define( + { + label: 'Test 4', + description: 'Disconnect AudioParam between contexts' + }, + (task, should) => { + let g1; + let g2; + should( + () => {g1 = new GainNode(c1)}, 'Test 4: g1 = new GainNode(c1)') + .notThrow(); + should( + () => {g2 = new GainNode(c2)}, 'Test 4: g2 = new GainNode(c2)') + .notThrow(); + should( + () => {g2.disconnect(g1.gain)}, 'Test 4: g2.connect(g1.gain)') + .throw(DOMException, 'InvalidAccessError'); + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/adding-events.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/adding-events.html new file mode 100644 index 0000000000..ab527b6695 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/adding-events.html @@ -0,0 +1,144 @@ +<!doctype html> +<html> + <head> + <title>Adding Events</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audio-param.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Arbitrary power of two to eliminate round-off in computing time from + // frame. + const sampleRate = 8192; + + audit.define( + { + label: 'linearRamp', + description: 'Insert linearRamp after running for some time' + }, + (task, should) => { + testInsertion(should, { + method: 'linearRampToValueAtTime', + prefix: 'linearRamp' + }).then(() => task.done()); + }); + + audit.define( + { + label: 'expoRamp', + description: 'Insert expoRamp after running for some time' + }, + (task, should) => { + testInsertion(should, { + method: 'exponentialRampToValueAtTime', + prefix: 'expoRamp' + }).then(() => task.done()); + }); + + // Test insertion of an event in the middle of rendering. + // + // options dictionary: + // method - automation method to test + // prefix - string to use for prefixing messages + function testInsertion(should, options) { + let {method, prefix} = options; + + // Channel 0 is the output for the test, and channel 1 is the + // reference output. + let context = new OfflineAudioContext( + {numberOfChannels: 2, length: sampleRate, sampleRate: sampleRate}); + let merger = new ChannelMergerNode( + context, {numberOfChannels: context.destination.channelCount}); + + merger.connect(context.destination); + + // Initial value and final values of the source node + let initialValue = 1; + let finalValue = 2; + + // Set up the node for the automations under test + let src = new ConstantSourceNode(context, {offset: initialValue}); + src.connect(merger, 0, 0); + + // Set initial event to occur at this time. Keep it in the first + // render quantum. + const initialEventTime = 64 / context.sampleRate; + should( + () => src.offset.setValueAtTime(initialValue, initialEventTime), + `${prefix}: setValueAtTime(${initialValue}, ${initialEventTime})`) + .notThrow(); + + // Let time pass and then add a new event with time in the future. + let insertAtFrame = 512; + let insertTime = insertAtFrame / context.sampleRate; + let automationEndFrame = 1024 + 64; + let automationEndTime = automationEndFrame / context.sampleRate; + context.suspend(insertTime) + .then(() => { + should( + () => src.offset[method](finalValue, automationEndTime), + `${prefix}: At time ${insertTime} scheduling ${method}(${ + finalValue}, ${automationEndTime})`) + .notThrow(); + }) + .then(() => context.resume()); + + // Set up graph for the reference result. Automate the source with + // the events scheduled from the beginning. Let the gain node + // simulate the insertion of the event above. This is done by + // setting the gain to 1 at the insertion time. + let srcRef = new ConstantSourceNode(context, {offset: 1}); + let g = new GainNode(context, {gain: 0}); + srcRef.connect(g).connect(merger, 0, 1); + srcRef.offset.setValueAtTime(initialValue, initialEventTime); + srcRef.offset[method](finalValue, automationEndTime); + + // Allow everything through after |insertFrame| frames. + g.gain.setValueAtTime(1, insertTime); + + // Go! + src.start(); + srcRef.start(); + + return context.startRendering().then(audioBuffer => { + let actual = audioBuffer.getChannelData(0); + let expected = audioBuffer.getChannelData(1); + + // Verify that the output is 1 until we reach + // insertAtFrame. Ignore the expected data because that always + // produces 1. + should( + actual.slice(0, insertAtFrame), + `${prefix}: output[0:${insertAtFrame - 1}]`) + .beConstantValueOf(initialValue); + + // Verify ramp is correct by comparing it to the expected + // data. + should( + actual.slice( + insertAtFrame, automationEndFrame - insertAtFrame + 1), + `${prefix}: output[${insertAtFrame}:${ + automationEndFrame - insertAtFrame}]`) + .beCloseToArray( + expected.slice( + insertAtFrame, automationEndFrame - insertAtFrame + 1), + {absoluteThreshold: 0, numberOfArrayElements: 0}); + + // Verify final output has the expected value + should( + actual.slice(automationEndFrame), + `${prefix}: output[${automationEndFrame}:]`) + .beConstantValueOf(finalValue); + }) + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-cancel-and-hold.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-cancel-and-hold.html new file mode 100644 index 0000000000..0a8e7a7f2f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-cancel-and-hold.html @@ -0,0 +1,855 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test CancelValuesAndHoldAtTime + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audio-param.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + let renderDuration = 0.5; + + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'cancelTime', description: 'Test Invalid Values'}, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 1, + length: 1, + sampleRate: 8000 + }); + + let src = new ConstantSourceNode(context); + src.connect(context.destination); + + should( + () => src.offset.cancelAndHoldAtTime(-1), + 'cancelAndHoldAtTime(-1)') + .throw(RangeError); + + // These are TypeErrors because |cancelTime| is a + // double, not unrestricted double. + should( + () => src.offset.cancelAndHoldAtTime(NaN), + 'cancelAndHoldAtTime(NaN)') + .throw(TypeError); + + should( + () => src.offset.cancelAndHoldAtTime(Infinity), + 'cancelAndHoldAtTime(Infinity)') + .throw(TypeError); + + task.done(); + }); + + // The first few tasks test the cancellation of each relevant automation + // function. For the test, a simple linear ramp from 0 to 1 is used to + // start things off. Then the automation to be tested is scheduled and + // cancelled. + + audit.define( + {label: 'linear', description: 'Cancel linearRampToValueAtTime'}, + function(task, should) { + cancelTest(should, linearRampTest('linearRampToValueAtTime'), { + valueThreshold: 8.3998e-5, + curveThreshold: 5.9605e-5 + }).then(task.done.bind(task)); + }); + + audit.define( + {label: 'exponential', description: 'Cancel exponentialRampAtTime'}, + function(task, should) { + // Cancel an exponential ramp. The thresholds are experimentally + // determined. + cancelTest(should, function(g, v0, t0, cancelTime) { + // Initialize values to 0. + g[0].gain.setValueAtTime(0, 0); + g[1].gain.setValueAtTime(0, 0); + // Schedule a short linear ramp to start things off. + g[0].gain.linearRampToValueAtTime(v0, t0); + g[1].gain.linearRampToValueAtTime(v0, t0); + + // After the linear ramp, schedule an exponential ramp to the end. + // (This is the event that will be be cancelled.) + let v1 = 0.001; + let t1 = renderDuration; + + g[0].gain.exponentialRampToValueAtTime(v1, t1); + g[1].gain.exponentialRampToValueAtTime(v1, t1); + + expectedConstant = Math.fround( + v0 * Math.pow(v1 / v0, (cancelTime - t0) / (t1 - t0))); + return { + expectedConstant: expectedConstant, + autoMessage: 'exponentialRampToValue(' + v1 + ', ' + t1 + ')', + summary: 'exponentialRampToValueAtTime', + }; + }, { + valueThreshold: 1.8664e-6, + curveThreshold: 5.9605e-8 + }).then(task.done.bind(task)); + }); + + audit.define( + {label: 'setTarget', description: 'Cancel setTargetAtTime'}, + function(task, should) { + // Cancel a setTarget event. + cancelTest(should, function(g, v0, t0, cancelTime) { + // Initialize values to 0. + g[0].gain.setValueAtTime(0, 0); + g[1].gain.setValueAtTime(0, 0); + // Schedule a short linear ramp to start things off. + g[0].gain.linearRampToValueAtTime(v0, t0); + g[1].gain.linearRampToValueAtTime(v0, t0); + + // At the end of the linear ramp, schedule a setTarget. (This is + // the event that will be cancelled.) + let v1 = 0; + let t1 = t0; + let timeConstant = 0.05; + + g[0].gain.setTargetAtTime(v1, t1, timeConstant); + g[1].gain.setTargetAtTime(v1, t1, timeConstant); + + expectedConstant = Math.fround( + v1 + (v0 - v1) * Math.exp(-(cancelTime - t0) / timeConstant)); + return { + expectedConstant: expectedConstant, + autoMessage: 'setTargetAtTime(' + v1 + ', ' + t1 + ', ' + + timeConstant + ')', + summary: 'setTargetAtTime', + }; + }, { + valueThreshold: 4.5267e-7, // 1.1317e-7, + curveThreshold: 0 + }).then(task.done.bind(task)); + }); + + audit.define( + {label: 'setValueCurve', description: 'Cancel setValueCurveAtTime'}, + function(task, should) { + // Cancel a setValueCurve event. + cancelTest(should, function(g, v0, t0, cancelTime) { + // Initialize values to 0. + g[0].gain.setValueAtTime(0, 0); + g[1].gain.setValueAtTime(0, 0); + // Schedule a short linear ramp to start things off. + g[0].gain.linearRampToValueAtTime(v0, t0); + g[1].gain.linearRampToValueAtTime(v0, t0); + + // After the linear ramp, schedule a setValuesCurve. (This is the + // event that will be cancelled.) + let v1 = 0; + let duration = renderDuration - t0; + + // For simplicity, a 2-point curve so we get a linear interpolated + // result. + let curve = Float32Array.from([v0, 0]); + + g[0].gain.setValueCurveAtTime(curve, t0, duration); + g[1].gain.setValueCurveAtTime(curve, t0, duration); + + let index = + Math.floor((curve.length - 1) / duration * (cancelTime - t0)); + + let curvePointsPerFrame = + (curve.length - 1) / duration / sampleRate; + let virtualIndex = + (cancelTime - t0) * sampleRate * curvePointsPerFrame; + + let delta = virtualIndex - index; + expectedConstant = curve[0] + (curve[1] - curve[0]) * delta; + return { + expectedConstant: expectedConstant, + autoMessage: 'setValueCurveAtTime([' + curve + '], ' + t0 + + ', ' + duration + ')', + summary: 'setValueCurveAtTime', + }; + }, { + valueThreshold: 9.5368e-9, + curveThreshold: 0 + }).then(task.done.bind(task)); + }); + + audit.define( + { + label: 'setValueCurve after end', + description: 'Cancel setValueCurveAtTime after the end' + }, + function(task, should) { + cancelTest(should, function(g, v0, t0, cancelTime) { + // Initialize values to 0. + g[0].gain.setValueAtTime(0, 0); + g[1].gain.setValueAtTime(0, 0); + // Schedule a short linear ramp to start things off. + g[0].gain.linearRampToValueAtTime(v0, t0); + g[1].gain.linearRampToValueAtTime(v0, t0); + + // After the linear ramp, schedule a setValuesCurve. (This is the + // event that will be cancelled.) Make sure the curve ends before + // the cancellation time. + let v1 = 0; + let duration = cancelTime - t0 - 0.125; + + // For simplicity, a 2-point curve so we get a linear interpolated + // result. + let curve = Float32Array.from([v0, 0]); + + g[0].gain.setValueCurveAtTime(curve, t0, duration); + g[1].gain.setValueCurveAtTime(curve, t0, duration); + + expectedConstant = curve[1]; + return { + expectedConstant: expectedConstant, + autoMessage: 'setValueCurveAtTime([' + curve + '], ' + t0 + + ', ' + duration + ')', + summary: 'setValueCurveAtTime', + }; + }, { + valueThreshold: 0, + curveThreshold: 0 + }).then(task.done.bind(task)); + }); + + // Special case where we schedule a setTarget and there is no earlier + // automation event. This tests that we pick up the starting point + // correctly from the last setting of the AudioParam value attribute. + + + audit.define( + { + label: 'initial setTarget', + description: 'Cancel with initial setTargetAtTime' + }, + function(task, should) { + cancelTest(should, function(g, v0, t0, cancelTime) { + let v1 = 0; + let timeConstant = 0.1; + g[0].gain.value = 1; + g[0].gain.setTargetAtTime(v1, t0, timeConstant); + g[1].gain.value = 1; + g[1].gain.setTargetAtTime(v1, t0, timeConstant); + + let expectedConstant = Math.fround( + v1 + (v0 - v1) * Math.exp(-(cancelTime - t0) / timeConstant)); + + return { + expectedConstant: expectedConstant, + autoMessage: 'setTargetAtTime(' + v1 + ', ' + t0 + ', ' + + timeConstant + ')', + summary: 'Initial setTargetAtTime', + }; + }, { + valueThreshold: 3.1210e-6, + curveThreshold: 0 + }).then(task.done.bind(task)); + }); + + // Test automations scheduled after the call to cancelAndHoldAtTime. + // Very similar to the above tests, but we also schedule an event after + // cancelAndHoldAtTime and verify that curve after cancellation has + // the correct values. + + audit.define( + { + label: 'post cancel: Linear', + description: 'LinearRamp after cancelling' + }, + function(task, should) { + // Run the cancel test using a linearRamp as the event to be + // cancelled. Then schedule another linear ramp after the + // cancellation. + cancelTest( + should, + linearRampTest('Post cancellation linearRampToValueAtTime'), + {valueThreshold: 8.3998e-5, curveThreshold: 5.9605e-8}, + function(g, cancelTime, expectedConstant) { + // Schedule the linear ramp on g[0], and do the same for g[2], + // using the starting point given by expectedConstant. + let v2 = 2; + let t2 = cancelTime + 0.125; + g[0].gain.linearRampToValueAtTime(v2, t2); + g[2].gain.setValueAtTime(expectedConstant, cancelTime); + g[2].gain.linearRampToValueAtTime(v2, t2); + return { + constantEndTime: cancelTime, + message: 'Post linearRamp(' + v2 + ', ' + t2 + ')' + }; + }) + .then(task.done.bind(task)); + }); + + audit.define( + { + label: 'post cancel: Exponential', + description: 'ExponentialRamp after cancelling' + }, + function(task, should) { + // Run the cancel test using a linearRamp as the event to be + // cancelled. Then schedule an exponential ramp after the + // cancellation. + cancelTest( + should, + linearRampTest('Post cancel exponentialRampToValueAtTime'), + {valueThreshold: 8.3998e-5, curveThreshold: 5.9605e-8}, + function(g, cancelTime, expectedConstant) { + // Schedule the exponential ramp on g[0], and do the same for + // g[2], using the starting point given by expectedConstant. + let v2 = 2; + let t2 = cancelTime + 0.125; + g[0].gain.exponentialRampToValueAtTime(v2, t2); + g[2].gain.setValueAtTime(expectedConstant, cancelTime); + g[2].gain.exponentialRampToValueAtTime(v2, t2); + return { + constantEndTime: cancelTime, + message: 'Post exponentialRamp(' + v2 + ', ' + t2 + ')' + }; + }) + .then(task.done.bind(task)); + }); + + audit.define('post cancel: ValueCurve', function(task, should) { + // Run the cancel test using a linearRamp as the event to be cancelled. + // Then schedule a setValueCurve after the cancellation. + cancelTest( + should, linearRampTest('Post cancel setValueCurveAtTime'), + {valueThreshold: 8.3998e-5, curveThreshold: 5.9605e-8}, + function(g, cancelTime, expectedConstant) { + // Schedule the exponential ramp on g[0], and do the same for + // g[2], using the starting point given by expectedConstant. + let t2 = cancelTime + 0.125; + let duration = 0.125; + let curve = Float32Array.from([.125, 2]); + g[0].gain.setValueCurveAtTime(curve, t2, duration); + g[2].gain.setValueAtTime(expectedConstant, cancelTime); + g[2].gain.setValueCurveAtTime(curve, t2, duration); + return { + constantEndTime: cancelTime, + message: 'Post setValueCurve([' + curve + '], ' + t2 + ', ' + + duration + ')', + errorThreshold: 8.3998e-5 + }; + }) + .then(task.done.bind(task)); + }); + + audit.define('post cancel: setTarget', function(task, should) { + // Run the cancel test using a linearRamp as the event to be cancelled. + // Then schedule a setTarget after the cancellation. + cancelTest( + should, linearRampTest('Post cancel setTargetAtTime'), + {valueThreshold: 8.3998e-5, curveThreshold: 5.9605e-8}, + function(g, cancelTime, expectedConstant) { + // Schedule the exponential ramp on g[0], and do the same for + // g[2], using the starting point given by expectedConstant. + let v2 = 0.125; + let t2 = cancelTime + 0.125; + let timeConstant = 0.1; + g[0].gain.setTargetAtTime(v2, t2, timeConstant); + g[2].gain.setValueAtTime(expectedConstant, cancelTime); + g[2].gain.setTargetAtTime(v2, t2, timeConstant); + return { + constantEndTime: cancelTime + 0.125, + message: 'Post setTargetAtTime(' + v2 + ', ' + t2 + ', ' + + timeConstant + ')', + errorThreshold: 8.4037e-5 + }; + }) + .then(task.done.bind(task)); + }); + + audit.define('post cancel: setValue', function(task, should) { + // Run the cancel test using a linearRamp as the event to be cancelled. + // Then schedule a setTarget after the cancellation. + cancelTest( + should, linearRampTest('Post cancel setValueAtTime'), + {valueThreshold: 8.3998e-5, curveThreshold: 5.9605e-8}, + function(g, cancelTime, expectedConstant) { + // Schedule the exponential ramp on g[0], and do the same for + // g[2], using the starting point given by expectedConstant. + let v2 = 0.125; + let t2 = cancelTime + 0.125; + g[0].gain.setValueAtTime(v2, t2); + g[2].gain.setValueAtTime(expectedConstant, cancelTime); + g[2].gain.setValueAtTime(v2, t2); + return { + constantEndTime: cancelTime + 0.125, + message: 'Post setValueAtTime(' + v2 + ', ' + t2 + ')' + }; + }) + .then(task.done.bind(task)); + }); + + audit.define('cancel future setTarget', (task, should) => { + const context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + const src = new ConstantSourceNode(context); + src.connect(context.destination); + + src.offset.setValueAtTime(0.5, 0); + src.offset.setTargetAtTime(0, 0.75 * renderDuration, 0.1); + // Now cancel the effect of the setTarget. + src.offset.cancelAndHoldAtTime(0.5 * renderDuration); + + src.start(); + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + // Because the setTarget was cancelled, the output should be a + // constant. + should(actual, 'After cancelling future setTarget event, output') + .beConstantValueOf(0.5); + }) + .then(task.done.bind(task)); + }); + + audit.define('cancel setTarget now', (task, should) => { + const context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + const src = new ConstantSourceNode(context); + src.connect(context.destination); + + src.offset.setValueAtTime(0.5, 0); + src.offset.setTargetAtTime(0, 0.5 * renderDuration, 0.1); + // Now cancel the effect of the setTarget. + src.offset.cancelAndHoldAtTime(0.5 * renderDuration); + + src.start(); + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + // Because the setTarget was cancelled, the output should be a + // constant. + should( + actual, + 'After cancelling setTarget event starting now, output') + .beConstantValueOf(0.5); + }) + .then(task.done.bind(task)); + }); + + audit.define('cancel future setValueCurve', (task, should) => { + const context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + const src = new ConstantSourceNode(context); + src.connect(context.destination); + + src.offset.setValueAtTime(0.5, 0); + src.offset.setValueCurveAtTime([-1, 1], 0.75 * renderDuration, 0.1); + // Now cancel the effect of the setTarget. + src.offset.cancelAndHoldAtTime(0.5 * renderDuration); + + src.start(); + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + // Because the setTarget was cancelled, the output should be a + // constant. + should( + actual, 'After cancelling future setValueCurve event, output') + .beConstantValueOf(0.5); + }) + .then(task.done.bind(task)); + }); + + audit.define('cancel setValueCurve now', (task, should) => { + const context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + const src = new ConstantSourceNode(context); + src.connect(context.destination); + + src.offset.setValueAtTime(0.5, 0); + src.offset.setValueCurveAtTime([-1, 1], 0.5 * renderDuration, 0.1); + // Now cancel the effect of the setTarget. + src.offset.cancelAndHoldAtTime(0.5 * renderDuration); + + src.start(); + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + // Because the setTarget was cancelled, the output should be a + // constant. + should( + actual, + 'After cancelling current setValueCurve event starting now, output') + .beConstantValueOf(0.5); + }) + .then(task.done.bind(task)); + }); + + audit.define( + { + label: 'linear, cancel, linear, cancel, linear', + description: 'Schedules 3 linear ramps, cancelling 2 of them, ' + + 'so that we end up with 2 cancel events next to each other' + }, + (task, should) => { + cancelTest2( + should, + linearRampTest('1st linearRamp'), + {valueThreshold: 0, curveThreshold: 5.9605e-8}, + (g, cancelTime, expectedConstant, cancelTime2) => { + // Ramp from first cancel time to the end will be cancelled at + // second cancel time. + const v1 = expectedConstant; + const t1 = cancelTime; + const v2 = 2; + const t2 = renderDuration; + g[0].gain.linearRampToValueAtTime(v2, t2); + g[2].gain.setValueAtTime(v1, t1); + g[2].gain.linearRampToValueAtTime(v2, t2); + + const expectedConstant2 = + audioParamLinearRamp(cancelTime2, v1, t1, v2, t2); + + return { + constantEndTime: cancelTime, + message: `2nd linearRamp(${v2}, ${t2})`, + expectedConstant2 + }; + }, + (g, cancelTime2, expectedConstant2) => { + // Ramp from second cancel time to the end. + const v3 = 0; + const t3 = renderDuration; + g[0].gain.linearRampToValueAtTime(v3, t3); + g[3].gain.setValueAtTime(expectedConstant2, cancelTime2); + g[3].gain.linearRampToValueAtTime(v3, t3); + return { + constantEndTime2: cancelTime2, + message2: `3rd linearRamp(${v3}, ${t3})`, + }; + }) + .then(() => task.done()); + }); + + audit.run(); + + // Common function for doing a linearRamp test. This just does a linear + // ramp from 0 to v0 at from time 0 to t0. Then another linear ramp is + // scheduled from v0 to 0 from time t0 to t1. This is the ramp that is to + // be cancelled. + function linearRampTest(message) { + return function(g, v0, t0, cancelTime) { + g[0].gain.setValueAtTime(0, 0); + g[1].gain.setValueAtTime(0, 0); + g[0].gain.linearRampToValueAtTime(v0, t0); + g[1].gain.linearRampToValueAtTime(v0, t0); + + let v1 = 0; + let t1 = renderDuration; + g[0].gain.linearRampToValueAtTime(v1, t1); + g[1].gain.linearRampToValueAtTime(v1, t1); + + expectedConstant = + Math.fround(v0 + (v1 - v0) * (cancelTime - t0) / (t1 - t0)); + + return { + expectedConstant: expectedConstant, + autoMessage: + message + ': linearRampToValue(' + v1 + ', ' + t1 + ')', + summary: message, + }; + } + } + + // Run the cancellation test. A set of automations is created and + // canceled. + // + // |testerFunction| is a function that generates the automation to be + // tested. It is given an array of 3 gain nodes, the value and time of an + // initial linear ramp, and the time where the cancellation should occur. + // The function must do the automations for the first two gain nodes. It + // must return a dictionary with |expectedConstant| being the value at the + // cancellation time, |autoMessage| for message to describe the test, and + // |summary| for general summary message to be printed at the end of the + // test. + // + // |thresholdOptions| is a property bag that specifies the error threshold + // to use. |thresholdOptions.valueThreshold| is the error threshold for + // comparing the actual constant output after cancelling to the expected + // value. |thresholdOptions.curveThreshold| is the error threshold for + // comparing the actual and expected automation curves before the + // cancelation point. + // + // For cancellation tests, |postCancelTest| is a function that schedules + // some automation after the cancellation. It takes 3 arguments: an array + // of the gain nodes, the cancellation time, and the expected value at the + // cancellation time. This function must return a dictionary consisting + // of |constantEndtime| indicating when the held constant from + // cancellation stops being constant, |message| giving a summary of what + // automation is being used, and |errorThreshold| that is the error + // threshold between the expected curve and the actual curve. + // + function cancelTest( + should, testerFunction, thresholdOptions, postCancelTest) { + // Create a context with three channels. Channel 0 is the test channel + // containing the actual output that includes the cancellation of + // events. Channel 1 is the expected data upto the cancellation so we + // can verify the cancellation produced the correct result. Channel 2 + // is for verifying events inserted after the cancellation so we can + // verify that automations are correctly generated after the + // cancellation point. + let context = + new OfflineAudioContext(3, renderDuration * sampleRate, sampleRate); + + // Test source is a constant signal + let src = context.createBufferSource(); + src.buffer = createConstantBuffer(context, 1, 1); + src.loop = true; + + // We'll do the automation tests with three gain nodes. One (g0) will + // have cancelAndHoldAtTime and the other (g1) will not. g1 is + // used as the expected result for that automation up to the + // cancellation point. They should be the same. The third node (g2) is + // used for testing automations inserted after the cancellation point, + // if any. g2 is the expected result from the cancellation point to the + // end of the test. + + let g0 = context.createGain(); + let g1 = context.createGain(); + let g2 = context.createGain(); + let v0 = 1; + let t0 = 0.01; + + let cancelTime = renderDuration / 2; + + // Test automation here. The tester function is responsible for setting + // up the gain nodes with the desired automation for testing. + autoResult = testerFunction([g0, g1, g2], v0, t0, cancelTime); + let expectedConstant = autoResult.expectedConstant; + let autoMessage = autoResult.autoMessage; + let summaryMessage = autoResult.summary; + + // Cancel scheduled events somewhere in the middle of the test + // automation. + g0.gain.cancelAndHoldAtTime(cancelTime); + + let constantEndTime; + if (postCancelTest) { + postResult = + postCancelTest([g0, g1, g2], cancelTime, expectedConstant); + constantEndTime = postResult.constantEndTime; + } + + // Connect everything together (with a merger to make a two-channel + // result). Channel 0 is the test (with cancelAndHoldAtTime) and + // channel 1 is the reference (without cancelAndHoldAtTime). + // Channel 1 is used to verify that everything up to the cancellation + // has the correct values. + src.connect(g0); + src.connect(g1); + src.connect(g2); + let merger = context.createChannelMerger(3); + g0.connect(merger, 0, 0); + g1.connect(merger, 0, 1); + g2.connect(merger, 0, 2); + merger.connect(context.destination); + + // Go! + src.start(); + + return context.startRendering().then(function(buffer) { + let actual = buffer.getChannelData(0); + let expected = buffer.getChannelData(1); + + // The actual output should be a constant from the cancel time to the + // end. We use the last value of the actual output as the constant, + // but we also want to compare that with what we thought it should + // really be. + + let cancelFrame = Math.ceil(cancelTime * sampleRate); + + // Verify that the curves up to the cancel time are "identical". The + // should be but round-off may make them differ slightly due to the + // way cancelling is done. + let endFrame = Math.floor(cancelTime * sampleRate); + should( + actual.slice(0, endFrame), + autoMessage + ' up to time ' + cancelTime) + .beCloseToArray( + expected.slice(0, endFrame), + {absoluteThreshold: thresholdOptions.curveThreshold}); + + // Verify the output after the cancellation is a constant. + let actualTail; + let constantEndFrame; + + if (postCancelTest) { + constantEndFrame = Math.ceil(constantEndTime * sampleRate); + actualTail = actual.slice(cancelFrame, constantEndFrame); + } else { + actualTail = actual.slice(cancelFrame); + } + + let actualConstant = actual[cancelFrame]; + + should( + actualTail, + 'Cancelling ' + autoMessage + ' at time ' + cancelTime) + .beConstantValueOf(actualConstant); + + // Verify that the constant is the value we expect. + should( + actualConstant, + 'Expected value for cancelling ' + autoMessage + ' at time ' + + cancelTime) + .beCloseTo( + expectedConstant, + {threshold: thresholdOptions.valueThreshold}); + + // Verify the curve after the constantEndTime matches our + // expectations. + if (postCancelTest) { + let c2 = buffer.getChannelData(2); + should(actual.slice(constantEndFrame), postResult.message) + .beCloseToArray( + c2.slice(constantEndFrame), + {absoluteThreshold: postResult.errorThreshold || 0}); + } + }); + } + + // Similar to cancelTest, but does 2 cancels. + function cancelTest2( + should, testerFunction, thresholdOptions, + postCancelTest, postCancelTest2) { + // Channel 0: Actual output that includes the cancellation of events. + // Channel 1: Expected data up to the first cancellation. + // Channel 2: Expected data from 1st cancellation to 2nd cancellation. + // Channel 3: Expected data from 2nd cancellation to the end. + const context = + new OfflineAudioContext(4, renderDuration * sampleRate, sampleRate); + + const src = context.createConstantSource(); + + // g0: Actual gain which will have cancelAndHoldAtTime called on it + // twice. + // g1: Expected gain from start to the 1st cancel. + // g2: Expected gain from 1st cancel to the 2nd cancel. + // g3: Expected gain from the 2nd cancel to the end. + const g0 = context.createGain(); + const g1 = context.createGain(); + const g2 = context.createGain(); + const g3 = context.createGain(); + const v0 = 1; + const t0 = 0.01; + + const cancelTime1 = renderDuration * 0.5; + const cancelTime2 = renderDuration * 0.75; + + // Run testerFunction to generate the 1st ramp. + const { + expectedConstant, autoMessage, summaryMessage} = + testerFunction([g0, g1, g2], v0, t0, cancelTime1); + + // 1st cancel, cancelling the 1st ramp. + g0.gain.cancelAndHoldAtTime(cancelTime1); + + // Run postCancelTest to generate the 2nd ramp. + const { + constantEndTime, message, errorThreshold = 0, expectedConstant2} = + postCancelTest( + [g0, g1, g2], cancelTime1, expectedConstant, cancelTime2); + + // 2nd cancel, cancelling the 2nd ramp. + g0.gain.cancelAndHoldAtTime(cancelTime2); + + // Run postCancelTest2 to generate the 3rd ramp. + const {constantEndTime2, message2} = + postCancelTest2([g0, g1, g2, g3], cancelTime2, expectedConstant2); + + // Connect everything together + src.connect(g0); + src.connect(g1); + src.connect(g2); + src.connect(g3); + const merger = context.createChannelMerger(4); + g0.connect(merger, 0, 0); + g1.connect(merger, 0, 1); + g2.connect(merger, 0, 2); + g3.connect(merger, 0, 3); + merger.connect(context.destination); + + // Go! + src.start(); + + return context.startRendering().then(function (buffer) { + const actual = buffer.getChannelData(0); + const expected1 = buffer.getChannelData(1); + const expected2 = buffer.getChannelData(2); + const expected3 = buffer.getChannelData(3); + + const cancelFrame1 = Math.ceil(cancelTime1 * sampleRate); + const cancelFrame2 = Math.ceil(cancelTime2 * sampleRate); + + const constantEndFrame1 = Math.ceil(constantEndTime * sampleRate); + const constantEndFrame2 = Math.ceil(constantEndTime2 * sampleRate); + + const actualTail1 = actual.slice(cancelFrame1, constantEndFrame1); + const actualTail2 = actual.slice(cancelFrame2, constantEndFrame2); + + const actualConstant1 = actual[cancelFrame1]; + const actualConstant2 = actual[cancelFrame2]; + + // Verify first section curve + should( + actual.slice(0, cancelFrame1), + autoMessage + ' up to time ' + cancelTime1) + .beCloseToArray( + expected1.slice(0, cancelFrame1), + {absoluteThreshold: thresholdOptions.curveThreshold}); + + // Verify that a value was held after 1st cancel + should( + actualTail1, + 'Cancelling ' + autoMessage + ' at time ' + cancelTime1) + .beConstantValueOf(actualConstant1); + + // Verify that held value after 1st cancel was correct + should( + actualConstant1, + 'Expected value for cancelling ' + autoMessage + ' at time ' + + cancelTime1) + .beCloseTo( + expectedConstant, + {threshold: thresholdOptions.valueThreshold}); + + // Verify middle section curve + should(actual.slice(constantEndFrame1, cancelFrame2), message) + .beCloseToArray( + expected2.slice(constantEndFrame1, cancelFrame2), + {absoluteThreshold: errorThreshold}); + + // Verify that a value was held after 2nd cancel + should( + actualTail2, + 'Cancelling ' + message + ' at time ' + cancelTime2) + .beConstantValueOf(actualConstant2); + + // Verify that held value after 2nd cancel was correct + should( + actualConstant2, + 'Expected value for cancelling ' + message + ' at time ' + + cancelTime2) + .beCloseTo( + expectedConstant2, + {threshold: thresholdOptions.valueThreshold}); + + // Verify end section curve + should(actual.slice(constantEndFrame2), message2) + .beCloseToArray( + expected3.slice(constantEndFrame2), + {absoluteThreshold: errorThreshold || 0}); + }); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-close.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-close.html new file mode 100644 index 0000000000..b5555b0137 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-close.html @@ -0,0 +1,161 @@ +<!doctype html> +<html> + <head> + <title>Test AudioParam events very close in time</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + + // Largest sample rate that is required to be supported and is a power of + // two, to eliminate round-off as much as possible. + const sampleRate = 65536; + + // Only need one render quantum for testing. + const testFrames = 128; + + // Largest representable single-float number + const floatMax = Math.fround(3.4028234663852886e38); + + // epspos is the smallest x such that 1 + x != 1 + const epspos = 1.1102230246251568e-16; + // epsneg is the smallest x such that 1 - x != 1 + const epsneg = 5.551115123125784e-17; + + audit.define( + {label: 'no-nan', description: 'NaN does not occur'}, + (task, should) => { + const context = new OfflineAudioContext({ + numberOfChannels: 1, + sampleRate: sampleRate, + length: testFrames + }); + + const src0 = new ConstantSourceNode(context, {offset: 0}); + + // This should always succeed. We just want to print out a message + // that |src0| is a constant source node for the following + // processing. + should(src0, 'src0 = new ConstantSourceNode(context, {offset: 0})') + .beEqualTo(src0); + + src0.connect(context.destination); + + // Values for the first event (setValue). |time1| MUST be 0. + const time1 = 0; + const value1 = 10; + + // Values for the second event (linearRamp). |value2| must be huge, + // and |time2| must be small enough that 1/|time2| overflows a + // single float. This value is the least positive single float. + const value2 = floatMax; + const time2 = 1.401298464324817e-45; + + // These should always succeed; the messages are just informational + // to show the events that we scheduled. + should( + src0.offset.setValueAtTime(value1, time1), + `src0.offset.setValueAtTime(${value1}, ${time1})`) + .beEqualTo(src0.offset); + should( + src0.offset.linearRampToValueAtTime(value2, time2), + `src0.offset.linearRampToValueAtTime(${value2}, ${time2})`) + .beEqualTo(src0.offset); + + src0.start(); + + context.startRendering() + .then(buffer => { + const output = buffer.getChannelData(0); + + // Since time1 = 0, the output at frame 0 MUST be value1. + should(output[0], 'output[0]').beEqualTo(value1); + + // Since time2 < 1, output from frame 1 and later must be a + // constant. + should(output.slice(1), 'output[1]') + .beConstantValueOf(value2); + }) + .then(() => task.done()); + }); + + audit.define( + {label: 'interpolation', description: 'Interpolation of linear ramp'}, + (task, should) => { + const context = new OfflineAudioContext({ + numberOfChannels: 1, + sampleRate: sampleRate, + length: testFrames + }); + + const src1 = new ConstantSourceNode(context, {offset: 0}); + + // This should always succeed. We just want to print out a message + // that |src1| is a constant source node for the following + // processing. + should(src1, 'src1 = new ConstantSourceNode(context, {offset: 0})') + .beEqualTo(src1); + + src1.connect(context.destination); + + const frame = 1; + + // These time values are arranged so that time1 < frame/sampleRate < + // time2. This means we need to interpolate to get a value at given + // frame. + // + // The values are not so important, but |value2| should be huge. + const time1 = frame * (1 - epsneg) / context.sampleRate; + const value1 = 1e15; + + const time2 = frame * (1 + epspos) / context.sampleRate; + const value2 = floatMax; + + should( + src1.offset.setValueAtTime(value1, time1), + `src1.offset.setValueAtTime(${value1}, ${time1})`) + .beEqualTo(src1.offset); + should( + src1.offset.linearRampToValueAtTime(value2, time2), + `src1.offset.linearRampToValueAtTime(${value2}, ${time2})`) + .beEqualTo(src1.offset); + + src1.start(); + + context.startRendering() + .then(buffer => { + const output = buffer.getChannelData(0); + + // Sanity check + should(time2 - time1, 'Event time difference') + .notBeEqualTo(0); + + // Because 0 < time1 < 1, output must be 0 at time 0. + should(output[0], 'output[0]').beEqualTo(0); + + // Because time1 < 1/sampleRate < time2, we need to + // interpolate the value between these times to determine the + // output at frame 1. + const t = frame / context.sampleRate; + const v = value1 + + (value2 - value1) * (t - time1) / (time2 - time1); + + should(output[1], 'output[1]').beCloseTo(v, {threshold: 0}); + + // Because 1 < time2 < 2, the output at frame 2 and higher is + // constant. + should(output.slice(2), 'output[2:]') + .beConstantValueOf(value2); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-connect-audioratesignal.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-connect-audioratesignal.html new file mode 100644 index 0000000000..b0455f86bc --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-connect-audioratesignal.html @@ -0,0 +1,103 @@ +<!DOCTYPE html> +<!-- +Tests that an audio-rate signal (AudioNode output) can be connected to an +AudioParam. Specifically, this tests that an audio-rate signal coming from an +AudioBufferSourceNode playing an AudioBuffer containing a specific curve can be +connected to an AudioGainNode's .gain attribute (an AudioParam). Another +AudioBufferSourceNode will be the audio source having its gain changed. We load +this one with an AudioBuffer containing a constant value of 1. Thus it's easy +to check that the resultant signal should be equal to the gain-scaling curve. +--> +<html> + <head> + <title> + audioparam-connect-audioratesignal.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 44100.0; + let lengthInSeconds = 1; + + let context = 0; + let constantOneBuffer = 0; + let linearRampBuffer = 0; + + function checkResult(renderedBuffer, should) { + let renderedData = renderedBuffer.getChannelData(0); + let expectedData = linearRampBuffer.getChannelData(0); + let n = renderedBuffer.length; + + should(n, 'Rendered signal length').beEqualTo(linearRampBuffer.length); + + // Check that the rendered result exactly matches the buffer used to + // control gain. This is because we're changing the gain of a signal + // having constant value 1. + let success = true; + for (let i = 0; i < n; ++i) { + if (renderedData[i] != expectedData[i]) { + success = false; + break; + } + } + + should( + success, + 'Rendered signal exactly matches the audio-rate gain changing signal') + .beTrue(); + } + + audit.define('test', function(task, should) { + let sampleFrameLength = sampleRate * lengthInSeconds; + + // Create offline audio context. + context = new OfflineAudioContext(1, sampleFrameLength, sampleRate); + + // Create buffer used by the source which will have its gain controlled. + constantOneBuffer = createConstantBuffer(context, sampleFrameLength, 1); + + // Create buffer used to control gain. + linearRampBuffer = createLinearRampBuffer(context, sampleFrameLength); + + // Create the two sources. + + let constantSource = context.createBufferSource(); + constantSource.buffer = constantOneBuffer; + + let gainChangingSource = context.createBufferSource(); + gainChangingSource.buffer = linearRampBuffer; + + // Create a gain node controlling the gain of constantSource and make + // the connections. + let gainNode = context.createGain(); + + // Intrinsic baseline gain of zero. + gainNode.gain.value = 0; + + constantSource.connect(gainNode); + gainNode.connect(context.destination); + + // Connect an audio-rate signal to control the .gain AudioParam. + // This is the heart of what is being tested. + gainChangingSource.connect(gainNode.gain); + + // Start both sources at time 0. + constantSource.start(0); + gainChangingSource.start(0); + + context.startRendering().then(buffer => { + checkResult(buffer, should); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-default-value.window.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-default-value.window.js new file mode 100644 index 0000000000..ae55f217f4 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-default-value.window.js @@ -0,0 +1,9 @@ +'use strict'; + +test(() => { + const context = new OfflineAudioContext(1, 1, 44100); + const defaultValue = -1; + const gainNode = new GainNode(context, { gain: defaultValue }); + + assert_equals(gainNode.gain.defaultValue, defaultValue, "AudioParam's defaultValue is not correct."); +}, "AudioParam's defaultValue"); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-exceptional-values.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-exceptional-values.html new file mode 100644 index 0000000000..982731d338 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-exceptional-values.html @@ -0,0 +1,240 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audioparam-exceptional-values.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Context to use for all of the tests. The context isn't used for any + // processing; just need one for creating a gain node, which is used for + // all the tests. + let context; + + // For these values, AudioParam methods should throw a Typeerror because + // they are not finite values. + let nonFiniteValues = [Infinity, -Infinity, NaN]; + + audit.define('initialize', (task, should) => { + should(() => { + // Context for testing. Rendering isn't done, so any valid values can + // be used here so might as well make them small. + context = new OfflineAudioContext(1, 1, 8000); + }, 'Creating context for testing').notThrow(); + + task.done(); + }); + + audit.define( + { + label: 'test value', + description: 'Test non-finite arguments for AudioParam value' + }, + (task, should) => { + let gain = context.createGain(); + + // Default method for generating the arguments for an automation + // method for testing the value of the automation. + let defaultFuncArg = (value) => [value, 1]; + + // Test the value parameter + doTests(should, gain, TypeError, nonFiniteValues, [ + {automationName: 'setValueAtTime', funcArg: defaultFuncArg}, { + automationName: 'linearRampToValueAtTime', + funcArg: defaultFuncArg + }, + { + automationName: 'exponentialRampToValueAtTime', + funcArg: defaultFuncArg + }, + { + automationName: 'setTargetAtTime', + funcArg: (value) => [value, 1, 1] + } + ]); + task.done(); + }); + + audit.define( + { + label: 'test time', + description: 'Test non-finite arguments for AudioParam time' + }, + (task, should) => { + let gain = context.createGain(); + + // Default method for generating the arguments for an automation + // method for testing the time parameter of the automation. + let defaultFuncArg = (startTime) => [1, startTime]; + + // Test the time parameter + doTests(should, gain, TypeError, nonFiniteValues, [ + {automationName: 'setValueAtTime', funcArg: defaultFuncArg}, + { + automationName: 'linearRampToValueAtTime', + funcArg: defaultFuncArg + }, + { + automationName: 'exponentialRampToValueAtTime', + funcArg: defaultFuncArg + }, + // Test start time for setTarget + { + automationName: 'setTargetAtTime', + funcArg: (startTime) => [1, startTime, 1] + }, + // Test time constant for setTarget + { + automationName: 'setTargetAtTime', + funcArg: (timeConstant) => [1, 1, timeConstant] + }, + ]); + + task.done(); + }); + + audit.define( + { + label: 'test setValueCurve', + description: 'Test non-finite arguments for setValueCurveAtTime' + }, + (task, should) => { + let gain = context.createGain(); + + // Just an array for use by setValueCurveAtTime. The length and + // contents of the array are not important. + let curve = new Float32Array(3); + + doTests(should, gain, TypeError, nonFiniteValues, [ + { + automationName: 'setValueCurveAtTime', + funcArg: (startTime) => [curve, startTime, 1] + }, + ]); + + // Non-finite values for the curve should signal an error + doTests( + should, gain, TypeError, + [[1, 2, Infinity, 3], [1, NaN, 2, 3]], [{ + automationName: 'setValueCurveAtTime', + funcArg: (c) => [c, 1, 1] + }]); + + task.done(); + }); + + audit.define( + { + label: 'special cases 1', + description: 'Test exceptions for finite values' + }, + (task, should) => { + let gain = context.createGain(); + + // Default method for generating the arguments for an automation + // method for testing the time parameter of the automation. + let defaultFuncArg = (startTime) => [1, startTime]; + + // Test the time parameter + let curve = new Float32Array(3); + doTests(should, gain, RangeError, [-1], [ + {automationName: 'setValueAtTime', funcArg: defaultFuncArg}, + { + automationName: 'linearRampToValueAtTime', + funcArg: defaultFuncArg + }, + { + automationName: 'exponentialRampToValueAtTime', + funcArg: defaultFuncArg + }, + { + automationName: 'setTargetAtTime', + funcArg: (startTime) => [1, startTime, 1] + }, + // Test time constant + { + automationName: 'setTargetAtTime', + funcArg: (timeConstant) => [1, 1, timeConstant] + }, + // startTime and duration for setValueCurve + { + automationName: 'setValueCurveAtTime', + funcArg: (startTime) => [curve, startTime, 1] + }, + { + automationName: 'setValueCurveAtTime', + funcArg: (duration) => [curve, 1, duration] + }, + ]); + + // Two final tests for setValueCurve: duration must be strictly + // positive. + should( + () => gain.gain.setValueCurveAtTime(curve, 1, 0), + 'gain.gain.setValueCurveAtTime(curve, 1, 0)') + .throw(RangeError); + should( + () => gain.gain.setValueCurveAtTime(curve, 1, -1), + 'gain.gain.setValueCurveAtTime(curve, 1, -1)') + .throw(RangeError); + + task.done(); + }); + + audit.define( + { + label: 'special cases 2', + description: 'Test special cases for expeonentialRamp' + }, + (task, should) => { + let gain = context.createGain(); + + doTests(should, gain, RangeError, [0, -1e-100, 1e-100], [{ + automationName: 'exponentialRampToValueAtTime', + funcArg: (value) => [value, 1] + }]); + + task.done(); + }); + + audit.run(); + + // Run test over the set of values in |testValues| for all of the + // automation methods in |testMethods|. The expected error type is + // |errorName|. |testMethods| is an array of dictionaries with attributes + // |automationName| giving the name of the automation method to be tested + // and |funcArg| being a function of one parameter that produces an array + // that will be used as the argument to the automation method. + function doTests(should, node, errorName, testValues, testMethods) { + testValues.forEach(value => { + testMethods.forEach(method => { + let args = method.funcArg(value); + let message = 'gain.gain.' + method.automationName + '(' + + argString(args) + ')'; + should(() => node.gain[method.automationName](...args), message) + .throw(errorName); + }); + }); + } + + // Specialized printer for automation arguments so that messages make + // sense. We assume the first element is either a number or an array. If + // it's an array, there are always three elements, and we want to print + // out the brackets for the array argument. + function argString(arg) { + if (typeof(arg[0]) === 'number') { + return arg.toString(); + } + + return '[' + arg[0] + '],' + arg[1] + ',' + arg[2]; + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-exponentialRampToValueAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-exponentialRampToValueAtTime.html new file mode 100644 index 0000000000..bec4c1286b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-exponentialRampToValueAtTime.html @@ -0,0 +1,63 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioParam.exponentialRampToValueAtTime + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Play a long DC signal out through an AudioGainNode, and call + // setValueAtTime() and exponentialRampToValueAtTime() at regular + // intervals to set the starting and ending values for an exponential + // ramp. Each time interval has a ramp with a different starting and + // ending value so that there is a discontinuity at each time interval + // boundary. The discontinuity is for testing timing. Also, we alternate + // between an increasing and decreasing ramp for each interval. + + // Number of tests to run. + let numberOfTests = 100; + + // Max allowed difference between the rendered data and the expected + // result. + let maxAllowedError = 1.222e-5; + + // The AudioGainNode starts with this value instead of the default value. + let initialValue = 100; + + // Set the gain node value to the specified value at the specified time. + function setValue(value, time) { + gainNode.gain.setValueAtTime(value, time); + } + + // Generate an exponential ramp ending at time |endTime| with an ending + // value of |value|. + function generateRamp(value, startTime, endTime){ + // |startTime| is ignored because the exponential ramp + // uses the value from the setValueAtTime() call above. + gainNode.gain.exponentialRampToValueAtTime(value, endTime)} + + audit.define( + { + label: 'test', + description: + 'AudioParam exponentialRampToValueAtTime() functionality' + }, + function(task, should) { + createAudioGraphAndTest( + task, should, numberOfTests, initialValue, setValue, + generateRamp, 'exponentialRampToValueAtTime()', maxAllowedError, + createExponentialRampArray); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-large-endtime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-large-endtime.html new file mode 100644 index 0000000000..d8f38eeba0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-large-endtime.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> +<html> + <head> + <title> + AudioParam with Huge End Time + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + // Render for some small (but fairly arbitrary) time. + let renderDuration = 0.125; + // Any huge time value that won't fit in a size_t (2^64 on a 64-bit + // machine). + let largeTime = 1e300; + + let audit = Audit.createTaskRunner(); + + // See crbug.com/582701. Create an audioparam with a huge end time and + // verify that to automation is run. We don't care about the actual + // results, just that it runs. + + // Test linear ramp with huge end time + audit.define('linearRamp', (task, should) => { + let graph = createGraph(); + graph.gain.gain.linearRampToValueAtTime(0.1, largeTime); + + graph.source.start(); + graph.context.startRendering() + .then(function(buffer) { + should(true, 'linearRampToValue(0.1, ' + largeTime + ')') + .message('successfully rendered', 'unsuccessfully rendered'); + }) + .then(() => task.done()); + }); + + // Test exponential ramp with huge end time + audit.define('exponentialRamp', (task, should) => { + let graph = createGraph(); + graph.gain.gain.exponentialRampToValueAtTime(.1, largeTime); + + graph.source.start(); + graph.context.startRendering() + .then(function(buffer) { + should(true, 'exponentialRampToValue(0.1, ' + largeTime + ')') + .message('successfully rendered', 'unsuccessfully rendered'); + }) + .then(() => task.done()); + }); + + audit.run(); + + // Create the graph and return the context, the source, and the gain node. + function createGraph() { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + let src = context.createBufferSource(); + src.buffer = createConstantBuffer(context, 1, 1); + src.loop = true; + let gain = context.createGain(); + src.connect(gain); + gain.connect(context.destination); + gain.gain.setValueAtTime(1, 0.1 / sampleRate); + + return {context: context, gain: gain, source: src}; + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-linearRampToValueAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-linearRampToValueAtTime.html new file mode 100644 index 0000000000..509c254d92 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-linearRampToValueAtTime.html @@ -0,0 +1,60 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioParam.linearRampToValueAtTime + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Play a long DC signal out through an AudioGainNode, and call + // setValueAtTime() and linearRampToValueAtTime() at regular intervals to + // set the starting and ending values for a linear ramp. Each time + // interval has a ramp with a different starting and ending value so that + // there is a discontinuity at each time interval boundary. The + // discontinuity is for testing timing. Also, we alternate between an + // increasing and decreasing ramp for each interval. + + // Number of tests to run. + let numberOfTests = 100; + + // Max allowed difference between the rendered data and the expected + // result. + let maxAllowedError = 1.865e-6; + + // Set the gain node value to the specified value at the specified time. + function setValue(value, time) { + gainNode.gain.setValueAtTime(value, time); + } + + // Generate a linear ramp ending at time |endTime| with an ending value of + // |value|. + function generateRamp(value, startTime, endTime){ + // |startTime| is ignored because the linear ramp uses the value from + // the + // setValueAtTime() call above. + gainNode.gain.linearRampToValueAtTime(value, endTime)} + + audit.define( + { + label: 'test', + description: 'AudioParam linearRampToValueAtTime() functionality' + }, + function(task, should) { + createAudioGraphAndTest( + task, should, numberOfTests, 1, setValue, generateRamp, + 'linearRampToValueAtTime()', maxAllowedError, + createLinearRampArray); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-method-chaining.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-method-chaining.html new file mode 100644 index 0000000000..ffe46035fd --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-method-chaining.html @@ -0,0 +1,143 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audioparam-method-chaining.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 8000; + + // Create a dummy array for setValueCurveAtTime method. + let curveArray = new Float32Array([5.0, 6.0]); + + // AudioNode dictionary with associated dummy arguments. + let methodDictionary = [ + {name: 'setValueAtTime', args: [1.0, 0.0]}, + {name: 'linearRampToValueAtTime', args: [2.0, 1.0]}, + {name: 'exponentialRampToValueAtTime', args: [3.0, 2.0]}, + {name: 'setTargetAtTime', args: [4.0, 2.0, 0.5]}, + {name: 'setValueCurveAtTime', args: [curveArray, 5.0, 1.0]}, + {name: 'cancelScheduledValues', args: [6.0]} + ]; + + let audit = Audit.createTaskRunner(); + + // Task: testing entries from the dictionary. + audit.define('from-dictionary', (task, should) => { + let context = new AudioContext(); + + methodDictionary.forEach(function(method) { + let sourceParam = context.createGain().gain; + should( + sourceParam === sourceParam[method.name](...method.args), + 'The return value of ' + sourceParam.constructor.name + '.' + + method.name + '()' + + ' matches the source AudioParam') + .beEqualTo(true); + + }); + + task.done(); + }); + + // Task: test method chaining with invalid operation. + audit.define('invalid-operation', (task, should) => { + let context = new OfflineAudioContext(1, sampleRate, sampleRate); + let osc = context.createOscillator(); + let amp1 = context.createGain(); + let amp2 = context.createGain(); + + osc.connect(amp1); + osc.connect(amp2); + amp1.connect(context.destination); + amp2.connect(context.destination); + + // The first operation fails with an exception, thus the second one + // should not have effect on the parameter value. Instead, it should + // maintain the default value of 1.0. + should( + function() { + amp1.gain.setValueAtTime(0.25, -1.0) + .linearRampToValueAtTime(2.0, 1.0); + }, + 'Calling setValueAtTime() with a negative end time') + .throw(RangeError); + + // The first operation succeeds but the second fails due to zero target + // value for the exponential ramp. Thus only the first should have + // effect on the parameter value, setting the value to 0.5. + should( + function() { + amp2.gain.setValueAtTime(0.5, 0.0).exponentialRampToValueAtTime( + 0.0, 1.0); + }, + 'Calling exponentialRampToValueAtTime() with a zero target value') + .throw(RangeError); + + osc.start(); + osc.stop(1.0); + + context.startRendering() + .then(function(buffer) { + should(amp1.gain.value, 'The gain value of the first gain node') + .beEqualTo(1.0); + should(amp2.gain.value, 'The gain value of the second gain node') + .beEqualTo(0.5); + }) + .then(() => task.done()); + }); + + // Task: verify if the method chaining actually works. Create an arbitrary + // envelope and compare the result with the expected one created by JS + // code. + audit.define('verification', (task, should) => { + let context = new OfflineAudioContext(1, sampleRate * 4, sampleRate); + let constantBuffer = createConstantBuffer(context, 1, 1.0); + + let source = context.createBufferSource(); + source.buffer = constantBuffer; + source.loop = true; + + let envelope = context.createGain(); + + source.connect(envelope); + envelope.connect(context.destination); + + envelope.gain.setValueAtTime(0.0, 0.0) + .linearRampToValueAtTime(1.0, 1.0) + .exponentialRampToValueAtTime(0.5, 2.0) + .setTargetAtTime(0.001, 2.0, 0.5); + + source.start(); + + context.startRendering() + .then(function(buffer) { + let expectedEnvelope = + createLinearRampArray(0.0, 1.0, 0.0, 1.0, sampleRate); + expectedEnvelope.push(...createExponentialRampArray( + 1.0, 2.0, 1.0, 0.5, sampleRate)); + expectedEnvelope.push(...createExponentialApproachArray( + 2.0, 4.0, 0.5, 0.001, sampleRate, 0.5)); + + // There are slight differences between JS implementation of + // AudioParam envelope and the internal implementation. (i.e. + // double/float and rounding up) The error threshold is adjusted + // empirically through the local testing. + should(buffer.getChannelData(0), 'The rendered envelope') + .beCloseToArray( + expectedEnvelope, {absoluteThreshold: 4.0532e-6}); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-nominal-range.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-nominal-range.html new file mode 100644 index 0000000000..517fc6e956 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-nominal-range.html @@ -0,0 +1,497 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioParam Nominal Range Values + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Some arbitrary sample rate for the offline context. + let sampleRate = 48000; + + // The actual contexts to use. Generally use the offline context for + // testing except for the media nodes which require an AudioContext. + let offlineContext; + let audioContext; + + // The set of all methods that we've tested for verifying that we tested + // all of the necessary objects. + let testedMethods = new Set(); + + // The most positive single float value (the value just before infinity). + // Be careful when changing this value! Javascript only uses double + // floats, so the value here should be the max single-float value, + // converted directly to a double-float value. This also depends on + // Javascript reading this value and producing the desired double-float + // value correctly. + let mostPositiveFloat = 3.4028234663852886e38; + + let audit = Audit.createTaskRunner(); + + // Array describing the tests that should be run. |testOfflineConfigs| is + // for tests that can use an offline context. |testOnlineConfigs| is for + // tests that need to use an online context. Offline contexts are + // preferred when possible. + let testOfflineConfigs = [ + { + // The name of the method to create the particular node to be tested. + creator: 'createGain', + + // Any args to pass to the creator function. + args: [], + + // The min/max limits for each AudioParam of the node. This is a + // dictionary whose keys are + // the names of each AudioParam in the node. Don't define this if the + // node doesn't have any + // AudioParam attributes. + limits: { + gain: { + // The expected min and max values for this AudioParam. + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat + } + } + }, + { + creator: 'createDelay', + // Just specify a non-default value for the maximum delay so we can + // make sure the limits are + // set correctly. + args: [1.5], + limits: {delayTime: {minValue: 0, maxValue: 1.5}} + }, + { + creator: 'createBufferSource', + args: [], + limits: { + playbackRate: + {minValue: -mostPositiveFloat, maxValue: mostPositiveFloat}, + detune: {minValue: -mostPositiveFloat, maxValue: mostPositiveFloat} + } + }, + { + creator: 'createStereoPanner', + args: [], + limits: {pan: {minValue: -1, maxValue: 1}} + }, + { + creator: 'createDynamicsCompressor', + args: [], + // Do not set limits for reduction; it's currently an AudioParam but + // should be a float. + // So let the test fail for reduction. When reduction is changed, + // this test will then + // correctly pass. + limits: { + threshold: {minValue: -100, maxValue: 0}, + knee: {minValue: 0, maxValue: 40}, + ratio: {minValue: 1, maxValue: 20}, + attack: {minValue: 0, maxValue: 1}, + release: {minValue: 0, maxValue: 1} + } + }, + { + creator: 'createBiquadFilter', + args: [], + limits: { + gain: { + minValue: -mostPositiveFloat, + // This complicated expression is used to get all the arithmetic + // to round to the correct single-precision float value for the + // desired max. This also assumes that the implication computes + // the limit as 40 * log10f(std::numeric_limits<float>::max()). + maxValue: + Math.fround(40 * Math.fround(Math.log10(mostPositiveFloat))) + }, + Q: {minValue: -mostPositiveFloat, maxValue: mostPositiveFloat}, + frequency: {minValue: 0, maxValue: sampleRate / 2}, + detune: { + minValue: -Math.fround(1200 * Math.log2(mostPositiveFloat)), + maxValue: Math.fround(1200 * Math.log2(mostPositiveFloat)) + } + } + }, + { + creator: 'createOscillator', + args: [], + limits: { + frequency: {minValue: -sampleRate / 2, maxValue: sampleRate / 2}, + detune: { + minValue: -Math.fround(1200 * Math.log2(mostPositiveFloat)), + maxValue: Math.fround(1200 * Math.log2(mostPositiveFloat)) + } + } + }, + { + creator: 'createPanner', + args: [], + limits: { + positionX: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + positionY: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + positionZ: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + orientationX: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + orientationY: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + orientationZ: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + } + }, + }, + { + creator: 'createConstantSource', + args: [], + limits: { + offset: {minValue: -mostPositiveFloat, maxValue: mostPositiveFloat} + } + }, + // These nodes don't have AudioParams, but we want to test them anyway. + // Any arguments for the + // constructor are pretty much arbitrary; they just need to be valid. + { + creator: 'createBuffer', + args: [1, 1, sampleRate], + }, + {creator: 'createIIRFilter', args: [[1, 2], [1, .9]]}, + { + creator: 'createWaveShaper', + args: [], + }, + { + creator: 'createConvolver', + args: [], + }, + { + creator: 'createAnalyser', + args: [], + }, + { + creator: 'createScriptProcessor', + args: [0], + }, + { + creator: 'createPeriodicWave', + args: [Float32Array.from([0, 0]), Float32Array.from([1, 0])], + }, + { + creator: 'createChannelSplitter', + args: [], + }, + { + creator: 'createChannelMerger', + args: [], + }, + ]; + + let testOnlineConfigs = [ + {creator: 'createMediaElementSource', args: [new Audio()]}, + {creator: 'createMediaStreamDestination', args: []} + // Can't currently test MediaStreamSource because we're using an offline + // context. + ]; + + // Create the contexts so we can use it in the following test. + audit.define('initialize', (task, should) => { + // Just any context so that we can create the nodes. + should(() => { + offlineContext = new OfflineAudioContext(1, 1, sampleRate); + }, 'Create offline context for tests').notThrow(); + should(() => { + onlineContext = new AudioContext(); + }, 'Create online context for tests').notThrow(); + task.done(); + }); + + // Create a task for each entry in testOfflineConfigs + for (let test in testOfflineConfigs) { + let config = testOfflineConfigs[test] + audit.define('Offline ' + config.creator, (function(c) { + return (task, should) => { + let node = offlineContext[c.creator](...c.args); + testLimits(should, c.creator, node, c.limits); + task.done(); + }; + })(config)); + } + + for (let test in testOnlineConfigs) { + let config = testOnlineConfigs[test] + audit.define('Online ' + config.creator, (function(c) { + return (task, should) => { + let node = onlineContext[c.creator](...c.args); + testLimits(should, c.creator, node, c.limits); + task.done(); + }; + })(config)); + } + + // Test the AudioListener params that were added for the automated Panner + audit.define('AudioListener', (task, should) => { + testLimits(should, '', offlineContext.listener, { + positionX: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + positionY: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + positionZ: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + forwardX: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + forwardY: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + forwardZ: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + upX: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + upY: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + }, + upZ: { + minValue: -mostPositiveFloat, + maxValue: mostPositiveFloat, + } + }); + task.done(); + }); + + // Verify that we have tested all the create methods available on the + // context. + audit.define('verifyTests', (task, should) => { + let allNodes = new Set(); + // Create the set of all "create" methods from the context. + for (let method in offlineContext) { + if (typeof offlineContext[method] === 'function' && + method.substring(0, 6) === 'create') { + allNodes.add(method); + } + } + + // Compute the difference between the set of all create methods on the + // context and the set of tests that we've run. + let diff = new Set([...allNodes].filter(x => !testedMethods.has(x))); + + // Can't currently test a MediaStreamSourceNode, so remove it from the + // diff set. + diff.delete('createMediaStreamSource'); + + // It's a test failure if we didn't test all of the create methods in + // the context (except createMediaStreamSource, of course). + let output = []; + if (diff.size) { + for (let item of diff) + output.push(' ' + item.substring(6)); + } + + should(output.length === 0, 'Number of nodes not tested') + .message(': 0', ': ' + output); + + task.done(); + }); + + // Simple test of a few automation methods to verify we get warnings. + audit.define('automation', (task, should) => { + // Just use a DelayNode for testing because the audio param has finite + // limits. + should(() => { + let d = offlineContext.createDelay(); + + // The console output should have the warnings that we're interested + // in. + d.delayTime.setValueAtTime(-1, 0); + d.delayTime.linearRampToValueAtTime(2, 1); + d.delayTime.exponentialRampToValueAtTime(3, 2); + d.delayTime.setTargetAtTime(-1, 3, .1); + d.delayTime.setValueCurveAtTime( + Float32Array.from([.1, .2, 1.5, -1]), 4, .1); + }, 'Test automations (check console logs)').notThrow(); + task.done(); + }); + + audit.run(); + + // Is |object| an AudioParam? We determine this by checking the + // constructor name. + function isAudioParam(object) { + return object && object.constructor.name === 'AudioParam'; + } + + // Does |limitOptions| exist and does it have valid values for the + // expected min and max values? + function hasValidLimits(limitOptions) { + return limitOptions && (typeof limitOptions.minValue === 'number') && + (typeof limitOptions.maxValue === 'number'); + } + + // Check the min and max values for the AudioParam attribute named + // |paramName| for the |node|. The expected limits is given by the + // dictionary |limits|. If some test fails, add the name of the failed + function validateAudioParamLimits(should, node, paramName, limits) { + let nodeName = node.constructor.name; + let parameter = node[paramName]; + let prefix = nodeName + '.' + paramName; + + let success = true; + if (hasValidLimits(limits[paramName])) { + // Verify that the min and max values for the parameter are correct. + let isCorrect = should(parameter.minValue, prefix + '.minValue') + .beEqualTo(limits[paramName].minValue); + isCorrect = should(parameter.maxValue, prefix + '.maxValue') + .beEqualTo(limits[paramName].maxValue) && + isCorrect; + + // Verify that the min and max attributes are read-only. |testValue| + // MUST be a number that can be represented exactly the same way as + // both a double and single float. A small integer works nicely. + const testValue = 42; + parameter.minValue = testValue; + let isReadOnly; + isReadOnly = + should(parameter.minValue, `${prefix}.minValue = ${testValue}`) + .notBeEqualTo(testValue); + + should(isReadOnly, prefix + '.minValue is read-only').beEqualTo(true); + + isCorrect = isReadOnly && isCorrect; + + parameter.maxValue = testValue; + isReadOnly = + should(parameter.maxValue, `${prefix}.maxValue = ${testValue}`) + .notBeEqualTo(testValue); + should(isReadOnly, prefix + '.maxValue is read-only').beEqualTo(true); + + isCorrect = isReadOnly && isCorrect; + + // Now try to set the parameter outside the nominal range. + let newValue = 2 * limits[paramName].minValue - 1; + + let isClipped = true; + let clippingTested = false; + // If the new value is beyond float the largest single-precision + // float, skip the test because Chrome throws an error. + if (newValue >= -mostPositiveFloat) { + parameter.value = newValue; + clippingTested = true; + isClipped = + should( + parameter.value, 'Set ' + prefix + '.value = ' + newValue) + .beEqualTo(parameter.minValue) && + isClipped; + } + + newValue = 2 * limits[paramName].maxValue + 1; + + if (newValue <= mostPositiveFloat) { + parameter.value = newValue; + clippingTested = true; + isClipped = + should( + parameter.value, 'Set ' + prefix + '.value = ' + newValue) + .beEqualTo(parameter.maxValue) && + isClipped; + } + + if (clippingTested) { + should( + isClipped, + prefix + ' was clipped to lie within the nominal range') + .beEqualTo(true); + } + + isCorrect = isCorrect && isClipped; + + success = isCorrect && success; + } else { + // Test config didn't specify valid limits. Fail this test! + should( + clippingTested, + 'Limits for ' + nodeName + '.' + paramName + + ' were correctly defined') + .beEqualTo(false); + + success = false; + } + + return success; + } + + // Test all of the AudioParams for |node| using the expected values in + // |limits|. |creatorName| is the name of the method to create the node, + // and is used to keep trakc of which tests we've run. + function testLimits(should, creatorName, node, limits) { + let nodeName = node.constructor.name; + testedMethods.add(creatorName); + + let success = true; + + // List of all of the AudioParams that were tested. + let audioParams = []; + + // List of AudioParams that failed the test. + let incorrectParams = []; + + // Look through all of the keys for the node and extract just the + // AudioParams + Object.keys(node.__proto__).forEach(function(paramName) { + if (isAudioParam(node[paramName])) { + audioParams.push(paramName); + let isValid = validateAudioParamLimits( + should, node, paramName, limits, incorrectParams); + if (!isValid) + incorrectParams.push(paramName); + + success = isValid && success; + } + }); + + // Print an appropriate message depending on whether there were + // AudioParams defined or not. + if (audioParams.length) { + let message = + 'Nominal ranges for AudioParam(s) of ' + node.constructor.name; + should(success, message) + .message('are correct', 'are incorrect for: ' + +incorrectParams); + return success; + } else { + should(!limits, nodeName) + .message( + 'has no AudioParams as expected', + 'has no AudioParams but test expected ' + limits); + } + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setTargetAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setTargetAtTime.html new file mode 100644 index 0000000000..faf00c007b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setTargetAtTime.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioParam.setTargetAtTime + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Play a long DC signal out through an AudioGainNode, and call + // setValueAtTime() and setTargetAtTime at regular intervals to set the + // starting value and the target value. Each time interval has a ramp with + // a different starting and target value so that there is a discontinuity + // at each time interval boundary. The discontinuity is for testing + // timing. Also, we alternate between an increasing and decreasing ramp + // for each interval. + + // Number of tests to run. + let numberOfTests = 100; + + // Max allowed difference between the rendered data and the expected + // result. + let maxAllowedError = 6.5683e-4 + + // The AudioGainNode starts with this value instead of the default value. + let initialValue = 100; + + // Set the gain node value to the specified value at the specified time. + function setValue(value, time) { + gainNode.gain.setValueAtTime(value, time); + } + + // Generate an exponential approach starting at |startTime| with a target + // value of |value|. + function automation(value, startTime, endTime){ + // endTime is not used for setTargetAtTime. + gainNode.gain.setTargetAtTime(value, startTime, timeConstant)} + + audit.define( + { + label: 'test', + description: 'AudioParam setTargetAtTime() functionality.' + }, + function(task, should) { + createAudioGraphAndTest( + task, should, numberOfTests, initialValue, setValue, automation, + 'setTargetAtTime()', maxAllowedError, + createExponentialApproachArray); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueAtTime.html new file mode 100644 index 0000000000..ab2edfd009 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueAtTime.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audioparam-setValueAtTime.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Play a long DC signal out through an AudioGainNode, and call + // setValueAtTime() at regular intervals to set the value for the duration + // of the interval. Each time interval has different value so that there + // is a discontinuity at each time interval boundary. The discontinuity + // is for testing timing. + + // Number of tests to run. + let numberOfTests = 100; + + // Max allowed difference between the rendered data and the expected + // result. + let maxAllowedError = 6e-8; + + // Set the gain node value to the specified value at the specified time. + function setValue(value, time) { + gainNode.gain.setValueAtTime(value, time); + } + + // For testing setValueAtTime(), we don't need to do anything for + // automation. because the value at the beginning of the interval is set + // by setValue and it remains constant for the duration, which is what we + // want. + function automation(value, startTime, endTime) { + // Do nothing. + } + + audit.define( + { + label: 'test', + description: 'AudioParam setValueAtTime() functionality.' + }, + function(task, should) { + createAudioGraphAndTest( + task, should, numberOfTests, 1, setValue, automation, + 'setValueAtTime()', maxAllowedError, createConstantArray); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueCurve-exceptions.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueCurve-exceptions.html new file mode 100644 index 0000000000..ed0c15fb9b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueCurve-exceptions.html @@ -0,0 +1,426 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Exceptions from setValueCurveAtTime + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + // Some short duration because we don't need to run the test for very + // long. + let testDurationSec = 0.125; + let testDurationFrames = testDurationSec * sampleRate; + + let audit = Audit.createTaskRunner(); + + audit.define('setValueCurve', (task, should) => { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let g = context.createGain(); + let curve = new Float32Array(2); + + // Start time and duration for setValueCurveAtTime + let curveStartTime = 0.1 * testDurationSec; + let duration = 0.1 * testDurationSec; + + // Some time that is known to be during the setValueCurveTime interval. + let automationTime = curveStartTime + duration / 2; + + should( + () => { + g.gain.setValueCurveAtTime(curve, curveStartTime, duration); + }, + 'setValueCurveAtTime(curve, ' + curveStartTime + ', ' + duration + + ')') + .notThrow(); + + should( + function() { + g.gain.setValueAtTime(1, automationTime); + }, + 'setValueAtTime(1, ' + automationTime + ')') + .throw(DOMException, 'NotSupportedError'); + + should( + function() { + g.gain.linearRampToValueAtTime(1, automationTime); + }, + 'linearRampToValueAtTime(1, ' + automationTime + ')') + .throw(DOMException, 'NotSupportedError'); + + should( + function() { + g.gain.exponentialRampToValueAtTime(1, automationTime); + }, + 'exponentialRampToValueAtTime(1, ' + automationTime + ')') + .throw(DOMException, 'NotSupportedError'); + + should( + function() { + g.gain.setTargetAtTime(1, automationTime, 1); + }, + 'setTargetAtTime(1, ' + automationTime + ', 1)') + .throw(DOMException, 'NotSupportedError'); + + should( + function() { + g.gain.setValueAtTime(1, curveStartTime + 1.1 * duration); + }, + 'setValueAtTime(1, ' + (curveStartTime + 1.1 * duration) + ')') + .notThrow(); + + task.done(); + }); + + audit.define('value setter', (task, should) => { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let g = context.createGain(); + let curve = new Float32Array(2); + + // Start time and duration for setValueCurveAtTime + let curveStartTime = 0.; + let duration = 0.2 * testDurationSec; + + // Some time that is known to be during the setValueCurveTime interval. + let automationTime = 0.; + + should( + () => { + g.gain.setValueCurveAtTime(curve, curveStartTime, duration); + }, + 'setValueCurveAtTime(curve, ' + curveStartTime + ', ' + duration + + ')') + .notThrow(); + + should( + function() { + g.gain.value = 0.; + }, + 'value setter') + .throw(DOMException, 'NotSupportedError'); + + task.done(); + }); + + audit.define('automations', (task, should) => { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let g = context.createGain(); + + let curve = new Float32Array(2); + // Start time and duration for setValueCurveAtTime + let startTime = 0; + let timeInterval = testDurationSec / 10; + let time; + + startTime += timeInterval; + should(() => { + g.gain.linearRampToValueAtTime(1, startTime); + }, 'linearRampToValueAtTime(1, ' + startTime + ')').notThrow(); + + startTime += timeInterval; + should(() => { + g.gain.exponentialRampToValueAtTime(1, startTime); + }, 'exponentialRampToValueAtTime(1, ' + startTime + ')').notThrow(); + + startTime += timeInterval; + should(() => { + g.gain.setTargetAtTime(1, startTime, 0.1); + }, 'setTargetAtTime(1, ' + startTime + ', 0.1)').notThrow(); + + startTime += timeInterval; + should(() => { + g.gain.setValueCurveAtTime(curve, startTime, 0.1); + }, 'setValueCurveAtTime(curve, ' + startTime + ', 0.1)').notThrow(); + + // Now try to setValueCurve that overlaps each of the above automations + startTime = timeInterval / 2; + + for (let k = 0; k < 4; ++k) { + time = startTime + timeInterval * k; + should( + () => { + g.gain.setValueCurveAtTime(curve, time, 0.01); + }, + 'setValueCurveAtTime(curve, ' + time + ', 0.01)') + .throw(DOMException, 'NotSupportedError'); + } + + // Elements of setValueCurve should be finite. + should( + () => { + g.gain.setValueCurveAtTime( + Float32Array.from([NaN, NaN]), time, 0.01); + }, + 'setValueCurveAtTime([NaN, NaN], ' + time + ', 0.01)') + .throw(TypeError); + + should( + () => { + g.gain.setValueCurveAtTime( + Float32Array.from([1, Infinity]), time, 0.01); + }, + 'setValueCurveAtTime([1, Infinity], ' + time + ', 0.01)') + .throw(TypeError); + + let d = context.createDelay(); + // Check that we get warnings for out-of-range values and also throw for + // non-finite values. + should( + () => { + d.delayTime.setValueCurveAtTime( + Float32Array.from([1, 5]), time, 0.01); + }, + 'delayTime.setValueCurveAtTime([1, 5], ' + time + ', 0.01)') + .notThrow(); + + should( + () => { + d.delayTime.setValueCurveAtTime( + Float32Array.from([1, 5, Infinity]), time, 0.01); + }, + 'delayTime.setValueCurveAtTime([1, 5, Infinity], ' + time + + ', 0.01)') + .throw(TypeError); + + // One last test that prints out lots of digits for the time. + time = Math.PI / 100; + should( + () => { + g.gain.setValueCurveAtTime(curve, time, 0.01); + }, + 'setValueCurveAtTime(curve, ' + time + ', 0.01)') + .throw(DOMException, 'NotSupportedError'); + + task.done(); + }); + + audit.define('catch-exception', (task, should) => { + // Verify that the curve isn't inserted into the time line even if we + // catch the exception. + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let gain = context.createGain(); + let source = context.createBufferSource(); + let buffer = context.createBuffer(1, 1, context.sampleRate); + buffer.getChannelData(0)[0] = 1; + source.buffer = buffer; + source.loop = true; + + source.connect(gain); + gain.connect(context.destination); + + gain.gain.setValueAtTime(1, 0); + try { + // The value curve has an invalid element. This automation shouldn't + // be inserted into the timeline at all. + gain.gain.setValueCurveAtTime( + Float32Array.from([0, NaN]), 128 / context.sampleRate, .5); + } catch (e) { + }; + source.start(); + + context.startRendering() + .then(function(resultBuffer) { + // Since the setValueCurve wasn't inserted, the output should be + // exactly 1 for the entire duration. + should( + resultBuffer.getChannelData(0), + 'Handled setValueCurve exception so output') + .beConstantValueOf(1); + + }) + .then(() => task.done()); + }); + + audit.define('start-end', (task, should) => { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let g = context.createGain(); + let curve = new Float32Array(2); + + // Verify that a setValueCurve can start at the end of an automation. + let time = 0; + let timeInterval = testDurationSec / 50; + should(() => { + g.gain.setValueAtTime(1, time); + }, 'setValueAtTime(1, ' + time + ')').notThrow(); + + time += timeInterval; + should(() => { + g.gain.linearRampToValueAtTime(0, time); + }, 'linearRampToValueAtTime(0, ' + time + ')').notThrow(); + + // setValueCurve starts at the end of the linear ramp. This should be + // fine. + should( + () => { + g.gain.setValueCurveAtTime(curve, time, timeInterval); + }, + 'setValueCurveAtTime(..., ' + time + ', ' + timeInterval + ')') + .notThrow(); + + // exponentialRamp ending one interval past the setValueCurve should be + // fine. + time += 2 * timeInterval; + should(() => { + g.gain.exponentialRampToValueAtTime(1, time); + }, 'exponentialRampToValueAtTime(1, ' + time + ')').notThrow(); + + // setValueCurve starts at the end of the exponential ramp. This should + // be fine. + should( + () => { + g.gain.setValueCurveAtTime(curve, time, timeInterval); + }, + 'setValueCurveAtTime(..., ' + time + ', ' + timeInterval + ')') + .notThrow(); + + // setValueCurve at the end of the setValueCurve should be fine. + time += timeInterval; + should( + () => { + g.gain.setValueCurveAtTime(curve, time, timeInterval); + }, + 'setValueCurveAtTime(..., ' + time + ', ' + timeInterval + ')') + .notThrow(); + + // setValueAtTime at the end of setValueCurve should be fine. + time += timeInterval; + should(() => { + g.gain.setValueAtTime(0, time); + }, 'setValueAtTime(0, ' + time + ')').notThrow(); + + // setValueCurve at the end of setValueAtTime should be fine. + should( + () => { + g.gain.setValueCurveAtTime(curve, time, timeInterval); + }, + 'setValueCurveAtTime(..., ' + time + ', ' + timeInterval + ')') + .notThrow(); + + // setTarget starting at the end of setValueCurve should be fine. + time += timeInterval; + should(() => { + g.gain.setTargetAtTime(1, time, 1); + }, 'setTargetAtTime(1, ' + time + ', 1)').notThrow(); + + task.done(); + }); + + audit.define('curve overlap', (task, should) => { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let g = context.createGain(); + let startTime = 5; + let startTimeLater = 10; + let startTimeEarlier = 2.5; + let curveDuration = 10; + let curveDurationShorter = 5; + let curve = [1, 2, 3]; + + // An initial curve event + should( + () => { + g.gain.setValueCurveAtTime(curve, startTime, curveDuration); + }, + `g.gain.setValueCurveAtTime([${curve}], ${startTime}, ${curveDuration})`) + .notThrow(); + + // Check that an exception is thrown when trying to overlap two curves, + // in various ways + + // Same start time and end time (curve exactly overlapping) + should( + () => { + g.gain.setValueCurveAtTime(curve, startTime, curveDuration); + }, + `second g.gain.setValueCurveAtTime([${curve}], ${startTime}, ${curveDuration})`) + .throw(DOMException, 'NotSupportedError'); + // Same start time, shorter end time + should( + () => { + g.gain.setValueCurveAtTime(curve, startTime, curveDurationShorter); + }, + `g.gain.setValueCurveAtTime([${curve}], ${startTime}, ${curveDurationShorter})`) + .throw(DOMException, 'NotSupportedError'); + // Earlier start time, end time after the start time an another curve + should( + () => { + g.gain.setValueCurveAtTime(curve, startTimeEarlier, curveDuration); + }, + `g.gain.setValueCurveAtTime([${curve}], ${startTimeEarlier}, ${curveDuration})`) + .throw(DOMException, 'NotSupportedError'); + // Start time after the start time of the other curve, but earlier than + // its end. + should( + () => { + g.gain.setValueCurveAtTime(curve, startTimeLater, curveDuration); + }, + `g.gain.setValueCurveAtTime([${curve}], ${startTimeLater}, ${curveDuration})`) + .throw(DOMException, 'NotSupportedError'); + + // New event wholly contained inside existing event + should( + () => { + g.gain.setValueCurveAtTime(curve, startTime + 1, curveDuration - 1); + }, + `g.gain.setValueCurveAtTime([${curve}], ${startTime+1}, ${curveDuration-1})`) + .throw(DOMException, 'NotSupportedError'); + // Old event completely contained inside new event + should( + () => { + g.gain.setValueCurveAtTime(curve, startTime - 1, curveDuration + 1); + }, + `g.gain.setValueCurveAtTime([${curve}], ${startTime-1}, ${curveDuration+1})`) + .throw(DOMException, 'NotSupportedError'); + // Setting an event exactly at the end of the curve should work. + should( + () => { + g.gain.setValueAtTime(1.0, startTime + curveDuration); + }, + `g.gain.setValueAtTime(1.0, ${startTime + curveDuration})`) + .notThrow(); + + task.done(); + }); + + audit.define('curve lengths', (task, should) => { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + let g = context.createGain(); + let time = 0; + + // Check for invalid curve lengths + should( + () => { + g.gain.setValueCurveAtTime(Float32Array.from([]), time, 0.01); + }, + 'setValueCurveAtTime([], ' + time + ', 0.01)') + .throw(DOMException, 'InvalidStateError'); + + should( + () => { + g.gain.setValueCurveAtTime(Float32Array.from([1]), time, 0.01); + }, + 'setValueCurveAtTime([1], ' + time + ', 0.01)') + .throw(DOMException, 'InvalidStateError'); + + should(() => { + g.gain.setValueCurveAtTime(Float32Array.from([1, 2]), time, 0.01); + }, 'setValueCurveAtTime([1,2], ' + time + ', 0.01)').notThrow(); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueCurveAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueCurveAtTime.html new file mode 100644 index 0000000000..de8406244b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-setValueCurveAtTime.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioParam.setValueCurveAtTime + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Play a long DC signal out through an AudioGainNode and for each time + // interval call setValueCurveAtTime() to set the values for the duration + // of the interval. Each curve is a sine wave, and we assume that the + // time interval is not an exact multiple of the period. This causes a + // discontinuity between time intervals which is used to test timing. + + // Number of tests to run. + let numberOfTests = 20; + + // Max allowed difference between the rendered data and the expected + // result. Because of the linear interpolation, the rendered curve isn't + // exactly the same as the reference. This value is experimentally + // determined. + let maxAllowedError = 3.7194e-6; + + // The amplitude of the sine wave. + let sineAmplitude = 1; + + // Frequency of the sine wave. + let freqHz = 440; + + // Curve to use for setValueCurveAtTime(). + let curve; + + // Sets the curve data for the entire time interval. + function automation(value, startTime, endTime) { + gainNode.gain.setValueCurveAtTime( + curve, startTime, endTime - startTime); + } + + audit.define( + { + label: 'test', + description: 'AudioParam setValueCurveAtTime() functionality.' + }, + function(task, should) { + // The curve of values to use. + curve = createSineWaveArray( + timeInterval, freqHz, sineAmplitude, sampleRate); + + createAudioGraphAndTest( + task, should, numberOfTests, sineAmplitude, + function(k) { + // Don't need to set the value. + }, + automation, 'setValueCurveAtTime()', maxAllowedError, + createReferenceSineArray, + 2 * Math.PI * sineAmplitude * freqHz / sampleRate, + differenceErrorMetric); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-summingjunction.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-summingjunction.html new file mode 100644 index 0000000000..9084942f70 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/audioparam-summingjunction.html @@ -0,0 +1,120 @@ +<!DOCTYPE html> +<!-- +Tests that multiple audio-rate signals (AudioNode outputs) can be connected to an AudioParam +and that these signals are summed, along with the AudioParams intrinsic value. +--> +<html> + <head> + <title> + audioparam-summingjunction.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/mix-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 44100.0; + let lengthInSeconds = 1; + + let context = 0; + + // Buffers used by the two gain controlling sources. + let linearRampBuffer; + let toneBuffer; + let toneFrequency = 440; + + // Arbitrary non-zero value. + let baselineGain = 5; + + // Allow for a small round-off error. + let maxAllowedError = 1e-6; + + function checkResult(renderedBuffer, should) { + let renderedData = renderedBuffer.getChannelData(0); + + // Get buffer data from the two sources used to control gain. + let linearRampData = linearRampBuffer.getChannelData(0); + let toneData = toneBuffer.getChannelData(0); + + let n = renderedBuffer.length; + + should(n, 'Rendered signal length').beEqualTo(linearRampBuffer.length); + + // Check that the rendered result exactly matches the sum of the + // intrinsic gain plus the two sources used to control gain. This is + // because we're changing the gain of a signal having constant value 1. + let success = true; + for (let i = 0; i < n; ++i) { + let expectedValue = baselineGain + linearRampData[i] + toneData[i]; + let error = Math.abs(expectedValue - renderedData[i]); + + if (error > maxAllowedError) { + success = false; + break; + } + } + + should( + success, + 'Rendered signal matches sum of two audio-rate gain changing signals plus baseline gain') + .beTrue(); + } + + audit.define('test', function(task, should) { + let sampleFrameLength = sampleRate * lengthInSeconds; + + // Create offline audio context. + context = new OfflineAudioContext(1, sampleFrameLength, sampleRate); + + // Create buffer used by the source which will have its gain controlled. + let constantOneBuffer = + createConstantBuffer(context, sampleFrameLength, 1); + let constantSource = context.createBufferSource(); + constantSource.buffer = constantOneBuffer; + + // Create 1st buffer used to control gain (a linear ramp). + linearRampBuffer = createLinearRampBuffer(context, sampleFrameLength); + let gainSource1 = context.createBufferSource(); + gainSource1.buffer = linearRampBuffer; + + // Create 2st buffer used to control gain (a simple sine wave tone). + toneBuffer = + createToneBuffer(context, toneFrequency, lengthInSeconds, 1); + let gainSource2 = context.createBufferSource(); + gainSource2.buffer = toneBuffer; + + // Create a gain node controlling the gain of constantSource and make + // the connections. + let gainNode = context.createGain(); + + // Intrinsic baseline gain. + // This gain value should be summed with gainSource1 and gainSource2. + gainNode.gain.value = baselineGain; + + constantSource.connect(gainNode); + gainNode.connect(context.destination); + + // Connect two audio-rate signals to control the .gain AudioParam. + gainSource1.connect(gainNode.gain); + gainSource2.connect(gainNode.gain); + + // Start all sources at time 0. + constantSource.start(0); + gainSource1.start(0); + gainSource2.start(0); + + context.startRendering().then(buffer => { + checkResult(buffer, should); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/automation-rate-testing.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/automation-rate-testing.js new file mode 100644 index 0000000000..43279f91d6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/automation-rate-testing.js @@ -0,0 +1,155 @@ +// Test k-rate vs a-rate AudioParams. +// +// |options| describes how the testing of the AudioParam should be done: +// +// sourceNodeName: name of source node to use for testing; defaults to +// 'OscillatorNode'. If set to 'none', then no source node +// is created for testing and it is assumed that the AudioNode +// under test are sources and need to be started. +// verifyPieceWiseConstant: if true, verify that the k-rate output is +// piecewise constant for each render quantum. +// nodeName: name of the AudioNode to be tested +// nodeOptions: options to be used in the AudioNode constructor +// +// prefix: Prefix for all output messages (to make them unique for +// testharness) +// +// rateSettings: A vector of dictionaries specifying how to set the automation +// rate(s): +// name: Name of the AudioParam +// value: The automation rate for the AudioParam given by |name|. +// +// automations: A vector of dictionaries specifying how to automate each +// AudioParam: +// name: Name of the AudioParam +// +// methods: A vector of dictionaries specifying the automation methods to +// be used for testing: +// name: Automation method to call +// options: Arguments for the automation method +// +// Testing is somewhat rudimentary. We create two nodes of the same type. One +// node uses the default automation rates for each AudioParam (expecting them to +// be a-rate). The second node sets the automation rate of AudioParams to +// "k-rate". The set is speciified by |options.rateSettings|. +// +// For both of these nodes, the same set of automation methods (given by +// |options.automations|) is applied. A simple oscillator is connected to each +// node which in turn are connected to different channels of an offline context. +// Channel 0 is the k-rate node output; channel 1, the a-rate output; and +// channel 3, the difference between the outputs. +// +// Success is declared if the difference signal is not exactly zero. This means +// the the automations did different things, as expected. +// +// The promise from |startRendering| is returned. +function doTest(context, should, options) { + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = null; + + // Skip creating a source to drive the graph if |sourceNodeName| is 'none'. + // If |sourceNodeName| is given, use that, else default to OscillatorNode. + if (options.sourceNodeName !== 'none') { + src = new window[options.sourceNodeName || 'OscillatorNode'](context); + } + + let kRateNode = new window[options.nodeName](context, options.nodeOptions); + let aRateNode = new window[options.nodeName](context, options.nodeOptions); + let inverter = new GainNode(context, {gain: -1}); + + // Set kRateNode filter to use k-rate params. + options.rateSettings.forEach(setting => { + kRateNode[setting.name].automationRate = setting.value; + // Mostly for documentation in the output. These should always + // pass. + should( + kRateNode[setting.name].automationRate, + `${options.prefix}: Setting ${ + setting.name + }.automationRate to "${setting.value}"`) + .beEqualTo(setting.value); + }); + + // Run through all automations for each node separately. (Mostly to keep + // output of automations together.) + options.automations.forEach(param => { + param.methods.forEach(method => { + // Most for documentation in the output. These should never throw. + let message = `${param.name}.${method.name}(${method.options})` + should(() => { + kRateNode[param.name][method.name](...method.options); + }, options.prefix + ': k-rate node: ' + message).notThrow(); + }); + }); + options.automations.forEach(param => { + param.methods.forEach(method => { + // Most for documentation in the output. These should never throw. + let message = `${param.name}.${method.name}(${method.options})` + should(() => { + aRateNode[param.name][method.name](...method.options); + }, options.prefix + ': a-rate node:' + message).notThrow(); + }); + }); + + // Connect the source, if specified. + if (src) { + src.connect(kRateNode); + src.connect(aRateNode); + } + + // The k-rate result is channel 0, and the a-rate result is channel 1. + kRateNode.connect(merger, 0, 0); + aRateNode.connect(merger, 0, 1); + + // Compute the difference between the a-rate and k-rate results and send + // that to channel 2. + kRateNode.connect(merger, 0, 2); + aRateNode.connect(inverter).connect(merger, 0, 2); + + if (src) { + src.start(); + } else { + // If there's no source, then assume the test nodes are sources and start + // them. + kRateNode.start(); + aRateNode.start(); + } + + return context.startRendering().then(renderedBuffer => { + let kRateOutput = renderedBuffer.getChannelData(0); + let aRateOutput = renderedBuffer.getChannelData(1); + let diff = renderedBuffer.getChannelData(2); + + // Some informative messages to print out values of the k-rate and + // a-rate outputs. These should always pass. + should( + kRateOutput, `${options.prefix}: Output of k-rate ${options.nodeName}`) + .beEqualToArray(kRateOutput); + should( + aRateOutput, `${options.prefix}: Output of a-rate ${options.nodeName}`) + .beEqualToArray(aRateOutput); + + // The real test. If k-rate AudioParam is working correctly, the + // k-rate result MUST differ from the a-rate result. + should( + diff, + `${ + options.prefix + }: Difference between a-rate and k-rate ${options.nodeName}`) + .notBeConstantValueOf(0); + + if (options.verifyPieceWiseConstant) { + // Verify that the output from the k-rate parameter is step-wise + // constant. + for (let k = 0; k < kRateOutput.length; k += 128) { + should( + kRateOutput.slice(k, k + 128), + `${options.prefix} k-rate output [${k}: ${k + 127}]`) + .beConstantValueOf(kRateOutput[k]); + } + } + }); +} diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/automation-rate.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/automation-rate.html new file mode 100644 index 0000000000..a3c11994bb --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/automation-rate.html @@ -0,0 +1,167 @@ +<!doctype html> +<html> + <head> + <title>AudioParam.automationRate tests</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + // For each node that has an AudioParam, verify that the default + // |automationRate| has the expected value and that we can change it or + // throw an error if it can't be changed. + + // Any valid sample rate is fine; we don't actually render anything in the + // tests. + let sampleRate = 8000; + + let audit = Audit.createTaskRunner(); + + // Array of tests. Each test is a dictonary consisting of the name of the + // node and an array specifying the AudioParam's of the node. This array + // in turn gives the name of the AudioParam, the default value for the + // |automationRate|, and whether it is fixed (isFixed). + const tests = [ + { + nodeName: 'AudioBufferSourceNode', + audioParams: [ + {name: 'detune', defaultRate: 'k-rate', isFixed: true}, + {name: 'playbackRate', defaultRate: 'k-rate', isFixed: true} + ] + }, + { + nodeName: 'BiquadFilterNode', + audioParams: [ + {name: 'frequency', defaultRate: 'a-rate', isFixed: false}, + {name: 'detune', defaultRate: 'a-rate', isFixed: false}, + {name: 'Q', defaultRate: 'a-rate', isFixed: false}, + {name: 'gain', defaultRate: 'a-rate', isFixed: false}, + ] + }, + { + nodeName: 'ConstantSourceNode', + audioParams: [{name: 'offset', defaultRate: 'a-rate', isFixed: false}] + }, + { + nodeName: 'DelayNode', + audioParams: + [{name: 'delayTime', defaultRate: 'a-rate', isFixed: false}] + }, + { + nodeName: 'DynamicsCompressorNode', + audioParams: [ + {name: 'threshold', defaultRate: 'k-rate', isFixed: true}, + {name: 'knee', defaultRate: 'k-rate', isFixed: true}, + {name: 'ratio', defaultRate: 'k-rate', isFixed: true}, + {name: 'attack', defaultRate: 'k-rate', isFixed: true}, + {name: 'release', defaultRate: 'k-rate', isFixed: true} + ] + }, + { + nodeName: 'GainNode', + audioParams: [{name: 'gain', defaultRate: 'a-rate', isFixed: false}] + }, + { + nodeName: 'OscillatorNode', + audioParams: [ + {name: 'frequency', defaultRate: 'a-rate', isFixed: false}, + {name: 'detune', defaultRate: 'a-rate', isFixed: false} + ] + }, + { + nodeName: 'PannerNode', + audioParams: [ + {name: 'positionX', defaultRate: 'a-rate', isFixed: false}, + {name: 'positionY', defaultRate: 'a-rate', isFixed: false}, + {name: 'positionZ', defaultRate: 'a-rate', isFixed: false}, + {name: 'orientationX', defaultRate: 'a-rate', isFixed: false}, + {name: 'orientationY', defaultRate: 'a-rate', isFixed: false}, + {name: 'orientationZ', defaultRate: 'a-rate', isFixed: false}, + ] + }, + { + nodeName: 'StereoPannerNode', + audioParams: [{name: 'pan', defaultRate: 'a-rate', isFixed: false}] + }, + ]; + + tests.forEach(test => { + // Define a separate test for each test entry. + audit.define(test.nodeName, (task, should) => { + let context = new OfflineAudioContext( + {length: sampleRate, sampleRate: sampleRate}); + // Construct the node and test each AudioParam of the node. + let node = new window[test.nodeName](context); + test.audioParams.forEach(param => { + testAudioParam( + should, {nodeName: test.nodeName, node: node, param: param}); + }); + + task.done(); + }); + }); + + // AudioListener needs it's own special test since it's not a node. + audit.define('AudioListener', (task, should) => { + let context = new OfflineAudioContext( + {length: sampleRate, sampleRate: sampleRate}); + + [{name: 'positionX', defaultRate: 'a-rate', isFixed: false}, + {name: 'positionY', defaultRate: 'a-rate', isFixed: false}, + {name: 'positionZ', defaultRate: 'a-rate', isFixed: false}, + {name: 'forwardX', defaultRate: 'a-rate', isFixed: false}, + {name: 'forwardY', defaultRate: 'a-rate', isFixed: false}, + {name: 'forwardZ', defaultRate: 'a-rate', isFixed: false}, + {name: 'upX', defaultRate: 'a-rate', isFixed: false}, + {name: 'upY', defaultRate: 'a-rate', isFixed: false}, + {name: 'upZ', defaultRate: 'a-rate', isFixed: false}, + ].forEach(param => { + testAudioParam(should, { + nodeName: 'AudioListener', + node: context.listener, + param: param + }); + }); + task.done(); + }); + + audit.run(); + + function testAudioParam(should, options) { + let param = options.param; + let audioParam = options.node[param.name]; + let defaultRate = param.defaultRate; + + // Verify that the default value is correct. + should( + audioParam.automationRate, + `Default ${options.nodeName}.${param.name}.automationRate`) + .beEqualTo(defaultRate); + + // Try setting the rate to a different rate. If the |automationRate| + // is fixed, expect an error. Otherwise, expect no error and expect + // the value is changed to the new value. + let newRate = defaultRate === 'a-rate' ? 'k-rate' : 'a-rate'; + let setMessage = `Set ${ + options.nodeName + }.${param.name}.automationRate to "${newRate}"` + + if (param.isFixed) { + should(() => audioParam.automationRate = newRate, setMessage) + .throw(DOMException, 'InvalidStateError'); + } + else { + should(() => audioParam.automationRate = newRate, setMessage) + .notThrow(); + should( + audioParam.automationRate, + `${options.nodeName}.${param.name}.automationRate`) + .beEqualTo(newRate); + } + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/cancel-scheduled-values.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/cancel-scheduled-values.html new file mode 100644 index 0000000000..ac1da8cd51 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/cancel-scheduled-values.html @@ -0,0 +1,155 @@ +<!doctype html> +<html> + <head> + <title> + cancelScheduledValues + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script> + let sampleRate = 8000; + let renderFrames = 8000; + + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'cancel-time', description: 'handle cancelTime values'}, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 1, + length: renderFrames, + sampleRate: sampleRate + }); + + let src = new ConstantSourceNode(context); + src.connect(context.destination); + + should( + () => src.offset.cancelScheduledValues(-1), + 'cancelScheduledValues(-1)') + .throw(RangeError); + + // These are TypeErrors because |cancelTime| is a + // double, not unrestricted double. + should( + () => src.offset.cancelScheduledValues(NaN), + 'cancelScheduledValues(NaN)') + .throw(TypeError); + + should( + () => src.offset.cancelScheduledValues(Infinity), + 'cancelScheduledValues(Infinity)') + .throw(TypeError); + + task.done(); + }); + + audit.define( + {label: 'cancel1', description: 'cancel setValueCurve'}, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 1, + length: renderFrames, + sampleRate: sampleRate + }); + + let src = new ConstantSourceNode(context); + let gain = new GainNode(context); + src.connect(gain).connect(context.destination); + + // Initial time and value for first automation (setValue) + let time0 = 0; + let value0 = 0.5; + + // Time and duration of the setValueCurve. We'll also schedule a + // setValue at the same time. + let value1 = 1.5; + let curveStartTime = 0.25; + let curveDuration = 0.25; + + // Time at which to cancel events + let cancelTime = 0.3; + + // Time and value for event added after cancelScheduledValues has + // been called. + let time2 = curveStartTime + curveDuration / 2; + let value2 = 3; + + // Self-consistency checks for the test. + should(cancelTime, 'cancelTime is after curve start') + .beGreaterThan(curveStartTime); + should(cancelTime, 'cancelTime is before curve ends') + .beLessThan(curveStartTime + curveDuration); + + // These assertions are just to show what's happening + should( + () => gain.gain.setValueAtTime(value0, time0), + `gain.gain.setValueAtTime(${value0}, ${time0})`) + .notThrow(); + // setValue at the sime time as the curve, to test that this event + // wasn't rmeoved. + should( + () => gain.gain.setValueAtTime(value1, curveStartTime), + `gain.gain.setValueAtTime(${value1}, ${curveStartTime})`) + .notThrow(); + + should( + () => gain.gain.setValueCurveAtTime( + [1, -1], curveStartTime, curveDuration), + `gain.gain.setValueCurveAtTime(..., ${curveStartTime}, ${ + curveDuration})`) + .notThrow(); + + // An event after the curve to verify this is removed. + should( + () => gain.gain.setValueAtTime( + 99, curveStartTime + curveDuration), + `gain.gain.setValueAtTime(99, ${ + curveStartTime + curveDuration})`) + .notThrow(); + + // Cancel events now. + should( + () => gain.gain.cancelScheduledValues(cancelTime), + `gain.gain.cancelScheduledValues(${cancelTime})`) + .notThrow(); + + // Simple check that the setValueCurve is gone, by scheduling + // something in the middle of the (now deleted) event + should( + () => gain.gain.setValueAtTime(value2, time2), + `gain.gain.setValueAtTime(${value2}, ${time2})`) + .notThrow(); + + src.start(); + context.startRendering() + .then(buffer => { + let audio = buffer.getChannelData(0); + + // After canceling events, verify that the outputs have the + // desired values. + let curveFrame = curveStartTime * context.sampleRate; + should( + audio.slice(0, curveFrame), `output[0:${curveFrame - 1}]`) + .beConstantValueOf(value0); + + let time2Frame = time2 * context.sampleRate; + should( + audio.slice(curveFrame, time2Frame), + `output[${curveFrame}:${time2Frame - 1}]`) + .beConstantValueOf(value1); + + should(audio.slice(time2Frame), `output[${time2Frame}:]`) + .beConstantValueOf(value2); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/event-insertion.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/event-insertion.html new file mode 100644 index 0000000000..b846f982ab --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/event-insertion.html @@ -0,0 +1,411 @@ +<!doctype html> +<html> + <head> + <title> + Test Handling of Event Insertion + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audio-param.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Use a power of two for the sample rate so there's no round-off in + // computing time from frame. + let sampleRate = 16384; + + audit.define( + {label: 'Insert same event at same time'}, (task, should) => { + // Context for testing. + let context = new OfflineAudioContext( + {length: 16384, sampleRate: sampleRate}); + + // The source node to use. Automations will be scheduled here. + let src = new ConstantSourceNode(context, {offset: 0}); + src.connect(context.destination); + + // An array of tests to be done. Each entry specifies the event + // type and the event time. The events are inserted in the order + // given (in |values|), and the second event should be inserted + // after the first one, as required by the spec. + let testCases = [ + { + event: 'setValueAtTime', + frame: RENDER_QUANTUM_FRAMES, + values: [99, 1], + outputTestFrame: RENDER_QUANTUM_FRAMES, + expectedOutputValue: 1 + }, + { + event: 'linearRampToValueAtTime', + frame: 2 * RENDER_QUANTUM_FRAMES, + values: [99, 2], + outputTestFrame: 2 * RENDER_QUANTUM_FRAMES, + expectedOutputValue: 2 + }, + { + event: 'exponentialRampToValueAtTime', + frame: 3 * RENDER_QUANTUM_FRAMES, + values: [99, 3], + outputTestFrame: 3 * RENDER_QUANTUM_FRAMES, + expectedOutputValue: 3 + }, + { + event: 'setValueCurveAtTime', + frame: 3 * RENDER_QUANTUM_FRAMES, + values: [[3, 4]], + extraArgs: RENDER_QUANTUM_FRAMES / context.sampleRate, + outputTestFrame: 4 * RENDER_QUANTUM_FRAMES, + expectedOutputValue: 4 + }, + { + event: 'setValueAtTime', + frame: 5 * RENDER_QUANTUM_FRAMES - 1, + values: [99, 1, 5], + outputTestFrame: 5 * RENDER_QUANTUM_FRAMES, + expectedOutputValue: 5 + } + ]; + + testCases.forEach(entry => { + entry.values.forEach(value => { + let eventTime = entry.frame / context.sampleRate; + let message = eventToString( + entry.event, value, eventTime, entry.extraArgs); + // This is mostly to print out the event that is getting + // inserted. It should never ever throw. + should(() => { + src.offset[entry.event](value, eventTime, entry.extraArgs); + }, message).notThrow(); + }); + }); + + src.start(); + + context.startRendering() + .then(audioBuffer => { + let audio = audioBuffer.getChannelData(0); + + // Look through the test cases to figure out what the correct + // output values should be. + testCases.forEach(entry => { + let expected = entry.expectedOutputValue; + let frame = entry.outputTestFrame; + let time = frame / context.sampleRate; + should( + audio[frame], `Output at frame ${frame} (time ${time})`) + .beEqualTo(expected); + }); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'Linear + Expo', + description: 'Different events at same time' + }, + (task, should) => { + // Should be a linear ramp up to the event time, and after a + // constant value because the exponential ramp has ended. + let testCase = [ + {event: 'linearRampToValueAtTime', value: 2, relError: 0}, + {event: 'setValueAtTime', value: 99}, + {event: 'exponentialRampToValueAtTime', value: 3}, + ]; + let eventFrame = 2 * RENDER_QUANTUM_FRAMES; + let prefix = 'Linear+Expo: '; + + testEventInsertion(prefix, should, eventFrame, testCase) + .then(expectConstant(prefix, should, eventFrame, testCase)) + .then(() => task.done()); + }); + + audit.define( + { + label: 'Expo + Linear', + description: 'Different events at same time', + }, + (task, should) => { + // Should be an exponential ramp up to the event time, and after a + // constant value because the linear ramp has ended. + let testCase = [ + { + event: 'exponentialRampToValueAtTime', + value: 3, + relError: 4.2533e-6 + }, + {event: 'setValueAtTime', value: 99}, + {event: 'linearRampToValueAtTime', value: 2}, + ]; + let eventFrame = 2 * RENDER_QUANTUM_FRAMES; + let prefix = 'Expo+Linear: '; + + testEventInsertion(prefix, should, eventFrame, testCase) + .then(expectConstant(prefix, should, eventFrame, testCase)) + .then(() => task.done()); + }); + + audit.define( + { + label: 'Linear + SetTarget', + description: 'Different events at same time', + }, + (task, should) => { + // Should be a linear ramp up to the event time, and then a + // decaying value. + let testCase = [ + {event: 'linearRampToValueAtTime', value: 3, relError: 0}, + {event: 'setValueAtTime', value: 100}, + {event: 'setTargetAtTime', value: 0, extraArgs: 0.1}, + ]; + let eventFrame = 2 * RENDER_QUANTUM_FRAMES; + let prefix = 'Linear+SetTarget: '; + + testEventInsertion(prefix, should, eventFrame, testCase) + .then(audioBuffer => { + let audio = audioBuffer.getChannelData(0); + let prefix = 'Linear+SetTarget: '; + let eventTime = eventFrame / sampleRate; + let expectedValue = methodMap[testCase[0].event]( + (eventFrame - 1) / sampleRate, 1, 0, testCase[0].value, + eventTime); + should( + audio[eventFrame - 1], + prefix + + `At time ${ + (eventFrame - 1) / sampleRate + } (frame ${eventFrame - 1}) output`) + .beCloseTo( + expectedValue, + {threshold: testCase[0].relError || 0}); + + // The setValue should have taken effect + should( + audio[eventFrame], + prefix + + `At time ${eventTime} (frame ${eventFrame}) output`) + .beEqualTo(testCase[1].value); + + // The final event is setTarget. Compute the expected output. + let actual = audio.slice(eventFrame); + let expected = new Float32Array(actual.length); + for (let k = 0; k < expected.length; ++k) { + let t = (eventFrame + k) / sampleRate; + expected[k] = audioParamSetTarget( + t, testCase[1].value, eventTime, testCase[2].value, + testCase[2].extraArgs); + } + should( + actual, + prefix + + `At time ${eventTime} (frame ${ + eventFrame + }) and later`) + .beCloseToArray(expected, {relativeThreshold: 2.6694e-7}); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'Multiple linear ramps at the same time', + description: 'Verify output' + }, + (task, should) => { + testMultipleSameEvents(should, { + method: 'linearRampToValueAtTime', + prefix: 'Multiple linear ramps: ', + threshold: 0 + }).then(() => task.done()); + }); + + audit.define( + { + label: 'Multiple exponential ramps at the same time', + description: 'Verify output' + }, + (task, should) => { + testMultipleSameEvents(should, { + method: 'exponentialRampToValueAtTime', + prefix: 'Multiple exponential ramps: ', + threshold: 5.3924e-7 + }).then(() => task.done()); + }); + + audit.run(); + + // Takes a list of |testCases| consisting of automation methods and + // schedules them to occur at |eventFrame|. |prefix| is a prefix for + // messages produced by |should|. + // + // Each item in |testCases| is a dictionary with members: + // event - the name of automation method to be inserted, + // value - the value for the event, + // extraArgs - extra arguments if the event needs more than the value + // and time (such as setTargetAtTime). + function testEventInsertion(prefix, should, eventFrame, testCases) { + let context = new OfflineAudioContext( + {length: 4 * RENDER_QUANTUM_FRAMES, sampleRate: sampleRate}); + + // The source node to use. Automations will be scheduled here. + let src = new ConstantSourceNode(context, {offset: 0}); + src.connect(context.destination); + + // Initialize value to 1 at the beginning. + src.offset.setValueAtTime(1, 0); + + // Test automations have this event time. + let eventTime = eventFrame / context.sampleRate; + + // Sanity check that context is long enough for the test + should( + eventFrame < context.length, + prefix + 'Context length is long enough for the test') + .beTrue(); + + // Automations to be tested. The first event should be the actual + // output up to the event time. The last event should be the final + // output from the event time and onwards. + testCases.forEach(entry => { + should( + () => { + src.offset[entry.event]( + entry.value, eventTime, entry.extraArgs); + }, + prefix + + eventToString( + entry.event, entry.value, eventTime, entry.extraArgs)) + .notThrow(); + }); + + src.start(); + + return context.startRendering(); + } + + // Verify output of test where the final value of the automation is + // expected to be constant. + function expectConstant(prefix, should, eventFrame, testCases) { + return audioBuffer => { + let audio = audioBuffer.getChannelData(0); + + let eventTime = eventFrame / sampleRate; + + // Compute the expected value of the first automation one frame before + // the event time. This is a quick check that the correct automation + // was done. + let expectedValue = methodMap[testCases[0].event]( + (eventFrame - 1) / sampleRate, 1, 0, testCases[0].value, + eventTime); + should( + audio[eventFrame - 1], + prefix + + `At time ${ + (eventFrame - 1) / sampleRate + } (frame ${eventFrame - 1}) output`) + .beCloseTo(expectedValue, {threshold: testCases[0].relError}); + + // The last event scheduled is expected to set the value for all + // future times. Verify that the output has the expected value. + should( + audio.slice(eventFrame), + prefix + + `At time ${eventTime} (frame ${ + eventFrame + }) and later, output`) + .beConstantValueOf(testCases[testCases.length - 1].value); + }; + } + + // Test output when two events of the same time are scheduled at the same + // time. + function testMultipleSameEvents(should, options) { + let {method, prefix, threshold} = options; + + // Context for testing. + let context = + new OfflineAudioContext({length: 16384, sampleRate: sampleRate}); + + let src = new ConstantSourceNode(context); + src.connect(context.destination); + + let initialValue = 1; + + // Informative print + should(() => { + src.offset.setValueAtTime(initialValue, 0); + }, prefix + `setValueAtTime(${initialValue}, 0)`).notThrow(); + + let frame = 64; + let time = frame / context.sampleRate; + let values = [2, 7, 10]; + + // Schedule two events of the same type at the same time, but with + // different values. + + values.forEach(value => { + // Informative prints to show what we're doing in this test. + should( + () => { + src.offset[method](value, time); + }, + prefix + + eventToString( + method, + value, + time, + )) + .notThrow(); + }) + + src.start(); + + return context.startRendering().then(audioBuffer => { + let actual = audioBuffer.getChannelData(0); + + // The output should be a ramp from time 0 to the event time. But we + // only verify the value just before the event time, which should be + // fairly close to values[0]. (But compute the actual expected value + // to be sure.) + let expected = methodMap[method]( + (frame - 1) / context.sampleRate, initialValue, 0, values[0], + time); + should(actual[frame - 1], prefix + `Output at frame ${frame - 1}`) + .beCloseTo(expected, {threshold: threshold, precision: 3}); + + // Any other values shouldn't show up in the output. Only the value + // from last event should appear. We only check the value at the + // event time. + should( + actual[frame], prefix + `Output at frame ${frame} (${time} sec)`) + .beEqualTo(values[values.length - 1]); + }); + } + + // Convert an automation method to a string for printing. + function eventToString(method, value, time, extras) { + let string = method + '('; + string += (value instanceof Array) ? `[${value}]` : value; + string += ', ' + time; + if (extras) { + string += ', ' + extras; + } + string += ')'; + return string; + } + + // Map between the automation method name and a function that computes the + // output value of the automation method. + const methodMap = { + linearRampToValueAtTime: audioParamLinearRamp, + exponentialRampToValueAtTime: audioParamExponentialRamp, + setValueAtTime: (t, v) => v + }; + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/exponentialRamp-special-cases.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/exponentialRamp-special-cases.html new file mode 100644 index 0000000000..d197809821 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/exponentialRamp-special-cases.html @@ -0,0 +1,58 @@ +<!DOCTYPE html> +<title>Test exponentialRampToValueAtTime() special cases</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +promise_test(async function() { + const bufferSize = 5; + const sampleRate = 16384; + const startSample = 3; + const offset0 = 2.; + const offset1 = -3.; + const context = new OfflineAudioContext(1, bufferSize, sampleRate); + + const source = new ConstantSourceNode(context); + source.start(); + // Explicit event to work around + // https://bugzilla.mozilla.org/show_bug.cgi?id=1265393 + source.offset.setValueAtTime(offset0, 0.); + source.offset.exponentialRampToValueAtTime(offset1, startSample/sampleRate); + source.connect(context.destination); + + const buffer = await context.startRendering(); + assert_equals(buffer.length, bufferSize, "output buffer length"); + const output = buffer.getChannelData(0); + for (let i = 0; i < startSample; ++i) { + assert_equals(output[i], offset0, "initial offset at sample " + i); + } + for (let i = startSample; i < bufferSize; ++i) { + assert_equals(output[i], offset1, "scheduled value at sample " + i); + } +}, "v0 and v1 have opposite signs"); + +promise_test(async function() { + const bufferSize = 4; + const sampleRate = 16384; + const startSample = 2; + const offset = -2.; + const context = new OfflineAudioContext(1, bufferSize, sampleRate); + + const source = new ConstantSourceNode(context); + source.start(); + // Explicit event to work around + // https://bugzilla.mozilla.org/show_bug.cgi?id=1265393 + source.offset.setValueAtTime(0, 0.); + source.offset.exponentialRampToValueAtTime(offset, startSample/sampleRate); + source.connect(context.destination); + + const buffer = await context.startRendering(); + assert_equals(buffer.length, bufferSize, "output buffer length"); + const output = buffer.getChannelData(0); + for (let i = 0; i < startSample; ++i) { + assert_equals(output[i], 0., "initial offset at sample " + i); + } + for (let i = startSample; i < bufferSize; ++i) { + assert_equals(output[i], offset, "scheduled value at sample " + i); + } +}, "v0 is zero"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audiobuffersource-connections.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audiobuffersource-connections.html new file mode 100644 index 0000000000..0b94bd70f9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audiobuffersource-connections.html @@ -0,0 +1,164 @@ +<!doctype html> +<html> + <head> + <title>k-rate AudioParams with inputs for AudioBufferSourceNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Fairly abitrary sampleRate and somewhat duration + const sampleRate = 8000; + const testDuration = 0.25; + + [['playbackRate', [1, 0], [2, testDuration]], + ['detune', [-1200, 0], [1200, testDuration]]] + .forEach(param => { + audit.define( + {label: param[0], description: `AudioBufferSource ${param[0]}`}, + async (task, should) => { + await doTest(should, { + prefix: task.label, + paramName: param[0], + startValue: param[1], + endValue: param[2] + }); + task.done(); + }); + }); + + audit.run(); + + async function doTest(should, options) { + // Test k-rate automation of AudioBufferSourceNode with connected + // input. + // + // A reference source node is created with an automation on the + // selected AudioParam. For simplicity, we just use a linear ramp from + // the minValue to the maxValue of the AudioParam. + // + // The test node has an input signal connected to the AudioParam. This + // input signal is created to match the automation on the reference + // node. + // + // Finally, the output from the two nodes must be identical if k-rate + // inputs are working correctly. + // + // Options parameter is a dictionary with the following required + // members: + // prefix - prefix to use for the messages. + // paramName - Name of the AudioParam to be tested + + let {prefix, paramName, startValue, endValue} = options; + + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // Linear ramp to use for the buffer sources + let ramp = createLinearRampBuffer(context, context.length); + + // Create the reference and test nodes. + let refNode; + let tstNode; + + const nodeOptions = {buffer: ramp}; + + should( + () => refNode = new AudioBufferSourceNode(context, nodeOptions), + `${prefix}: refNode = new AudioBufferSourceNode(context, ${ + JSON.stringify(nodeOptions)})`) + .notThrow(); + + should( + () => tstNode = new AudioBufferSourceNode(context, nodeOptions), + `${prefix}: tstNode = new AudioBufferSourceNode(context, ${ + JSON.stringify(nodeOptions)})`) + .notThrow(); + + + // Automate the AudioParam of the reference node with a linear ramp + should( + () => refNode[paramName].setValueAtTime(...startValue), + `${prefix}: refNode[${paramName}].setValueAtTime(${ + startValue[0]}, ${startValue[1]})`) + .notThrow(); + + should( + () => refNode[paramName].linearRampToValueAtTime(...endValue), + `${prefix}: refNode[${paramName}].linearRampToValueAtTime(${ + endValue[0]}, ${endValue[1]})`) + .notThrow(); + + + // Create the input node and automate it so that it's output when added + // to the intrinsic value of the AudioParam we get the same values as + // the automations on the reference node. + + // Compute the start and end values based on the defaultValue of the + // param and the desired startValue and endValue. The input is added to + // the intrinsic value of the AudioParam, so we need to account for + // that. + + let mod; + should( + () => mod = new ConstantSourceNode(context, {offset: 0}), + `${prefix}: mod = new ConstantSourceNode(context, {offset: 0})`) + .notThrow(); + + let modStart = startValue[0] - refNode[paramName].defaultValue; + let modEnd = endValue[0] - refNode[paramName].defaultValue; + should( + () => mod.offset.setValueAtTime(modStart, startValue[1]), + `${prefix}: mod.offset.setValueAtTime(${modStart}, ${ + startValue[1]})`) + .notThrow(); + should( + () => mod.offset.linearRampToValueAtTime(modEnd, endValue[1]), + `${prefix}: mod.offset.linearRampToValueAtTime(${modEnd}, ${ + endValue[1]})`) + .notThrow(); + + // Connect up everything. + should( + () => mod.connect(tstNode[paramName]), + `${prefix}: mod.connect(tstNode[${paramName}])`) + .notThrow(); + + refNode.connect(merger, 0, 0); + tstNode.connect(merger, 0, 1); + + // Go! + refNode.start(); + tstNode.start(); + mod.start(); + + const buffer = await context.startRendering(); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // Quick sanity check that output isn't zero. This means we messed up + // the connections or automations or the buffer source. + should(expected, `Expected k-rate ${paramName} AudioParam with input`) + .notBeConstantValueOf(0); + should(actual, `Actual k-rate ${paramName} AudioParam with input`) + .notBeConstantValueOf(0); + + // The expected and actual results must be EXACTLY the same. + should(actual, `k-rate ${paramName} AudioParam with input`) + .beCloseToArray(expected, {absoluteThreshold: 0}); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audioworklet-connections.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audioworklet-connections.https.html new file mode 100644 index 0000000000..4d2eb40d55 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audioworklet-connections.https.html @@ -0,0 +1,77 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParams with inputs for AudioWorkletNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + + // Use the worklet gain node to test k-rate parameters. + const filePath = + '../the-audioworklet-interface/processors/gain-processor.js'; + + // Context for testing + let context; + + audit.define('Create Test Worklet', (task, should) => { + // Arbitrary sample rate and duration. + const sampleRate = 8000; + + // Only new a few render quanta to verify things are working. + const testDuration = 4 * 128 / sampleRate; + + context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + should( + context.audioWorklet.addModule(filePath), + 'Construction of AudioWorklet') + .beResolved() + .then(() => task.done()); + }); + + audit.define('AudioWorklet k-rate AudioParam', async (task, should) => { + let src = new ConstantSourceNode(context); + let kRateNode = new AudioWorkletNode(context, 'gain'); + src.connect(kRateNode).connect(context.destination); + + let kRateParam = kRateNode.parameters.get('gain'); + kRateParam.automationRate = 'k-rate'; + kRateParam.value = 0; + + let mod = new ConstantSourceNode(context); + mod.offset.setValueAtTime(0, 0); + mod.offset.linearRampToValueAtTime( + 10, context.length / context.sampleRate); + mod.connect(kRateParam); + + mod.start(); + src.start(); + + const audioBuffer = await context.startRendering(); + let output = audioBuffer.getChannelData(0); + + // Verify that the output isn't constantly zero. + should(output, 'output').notBeConstantValueOf(0); + // Verify that the output from the worklet is step-wise + // constant. + for (let k = 0; k < output.length; k += 128) { + should(output.slice(k, k + 128), ` k-rate output [${k}: ${k + 127}]`) + .beConstantValueOf(output[k]); + } + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audioworklet.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audioworklet.https.html new file mode 100644 index 0000000000..e891da6da2 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-audioworklet.https.html @@ -0,0 +1,79 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParam of AudioWorkletNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + + // Use the worklet gain node to test k-rate parameters. + const filePath = + '../the-audioworklet-interface/processors/gain-processor.js'; + + // Context for testing + let context; + + audit.define('Create Test Worklet', (task, should) => { + + // Arbitrary sample rate and duration. + const sampleRate = 8000; + + // Only new a few render quanta to verify things are working. + const testDuration = 4 * 128 / sampleRate; + + context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + should( + context.audioWorklet.addModule(filePath), + 'Construction of AudioWorklet') + .beResolved() + .then(() => task.done()); + }); + + audit.define('AudioWorklet k-rate AudioParam', (task, should) => { + let src = new ConstantSourceNode(context); + + let kRateNode = new AudioWorkletNode(context, 'gain'); + + src.connect(kRateNode).connect(context.destination); + + let kRateParam = kRateNode.parameters.get('gain'); + kRateParam.automationRate = 'k-rate'; + + // Automate the gain + kRateParam.setValueAtTime(0, 0); + kRateParam.linearRampToValueAtTime( + 10, context.length / context.sampleRate); + + src.start(); + + context.startRendering() + .then(audioBuffer => { + let output = audioBuffer.getChannelData(0); + + // Verify that the output from the worklet is step-wise + // constant. + for (let k = 0; k < output.length; k += 128) { + should( + output.slice(k, k + 128), + ` k-rate output [${k}: ${k + 127}]`) + .beConstantValueOf(output[k]); + } + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-biquad-connection.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-biquad-connection.html new file mode 100644 index 0000000000..ab9df8740f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-biquad-connection.html @@ -0,0 +1,456 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParam Inputs for BiquadFilterNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + // sampleRate and duration are fairly arbitrary. We use low values to + // limit the complexity of the test. + let sampleRate = 8192; + let testDuration = 0.5; + + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'Frequency AudioParam', description: 'k-rate input works'}, + async (task, should) => { + // Test frequency AudioParam using a lowpass filter whose bandwidth + // is initially larger than the oscillator frequency. Then automate + // the frequency to 0 so that the output of the filter is 0 (because + // the cutoff is 0). + let oscFrequency = 440; + + let options = { + sampleRate: sampleRate, + paramName: 'frequency', + oscFrequency: oscFrequency, + testDuration: testDuration, + filterOptions: {type: 'lowpass', frequency: 0}, + autoStart: + {method: 'setValueAtTime', args: [2 * oscFrequency, 0]}, + autoEnd: { + method: 'linearRampToValueAtTime', + args: [0, testDuration / 4] + } + }; + + let buffer = await doTest(should, options); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + let halfLength = expected.length / 2; + + // Sanity check. The expected output should not be zero for + // the first half, but should be zero for the second half + // (because the filter bandwidth is exactly 0). + const prefix = 'Expected k-rate frequency with automation'; + + should( + expected.slice(0, halfLength), + `${prefix} output[0:${halfLength - 1}]`) + .notBeConstantValueOf(0); + should( + expected.slice(expected.length), + `${prefix} output[${halfLength}:]`) + .beConstantValueOf(0); + + // Outputs should be the same. Break the message into two + // parts so we can see the expected outputs. + checkForSameOutput(should, options.paramName, actual, expected); + + task.done(); + }); + + audit.define( + {label: 'Q AudioParam', description: 'k-rate input works'}, + async (task, should) => { + // Test Q AudioParam. Use a bandpass filter whose center frequency + // is fairly far from the oscillator frequency. Then start with a Q + // value of 0 (so everything goes through) and then increase Q to + // some large value such that the out-of-band signals are basically + // cutoff. + let frequency = 440; + let oscFrequency = 4 * frequency; + + let options = { + sampleRate: sampleRate, + oscFrequency: oscFrequency, + testDuration: testDuration, + paramName: 'Q', + filterOptions: {type: 'bandpass', frequency: frequency, Q: 0}, + autoStart: {method: 'setValueAtTime', args: [0, 0]}, + autoEnd: { + method: 'linearRampToValueAtTime', + args: [100, testDuration / 4] + } + }; + + const buffer = await doTest(should, options); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // Outputs should be the same + checkForSameOutput(should, options.paramName, actual, expected); + + task.done(); + }); + + audit.define( + {label: 'Gain AudioParam', description: 'k-rate input works'}, + async (task, should) => { + // Test gain AudioParam. Use a peaking filter with a large Q so the + // peak is narrow with a center frequency the same as the oscillator + // frequency. Start with a gain of 0 so everything goes through and + // then ramp the gain down to -100 so that the oscillator is + // filtered out. + let oscFrequency = 4 * 440; + + let options = { + sampleRate: sampleRate, + oscFrequency: oscFrequency, + testDuration: testDuration, + paramName: 'gain', + filterOptions: + {type: 'peaking', frequency: oscFrequency, Q: 100, gain: 0}, + autoStart: {method: 'setValueAtTime', args: [0, 0]}, + autoEnd: { + method: 'linearRampToValueAtTime', + args: [-100, testDuration / 4] + } + }; + + const buffer = await doTest(should, options); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // Outputs should be the same + checkForSameOutput(should, options.paramName, actual, expected); + + task.done(); + }); + + audit.define( + {label: 'Detune AudioParam', description: 'k-rate input works'}, + async (task, should) => { + // Test detune AudioParam. The basic idea is the same as the + // frequency test above, but insteda of automating the frequency, we + // automate the detune value so that initially the filter cutuff is + // unchanged and then changing the detune until the cutoff goes to 1 + // Hz, which would cause the oscillator to be filtered out. + let oscFrequency = 440; + let filterFrequency = 5 * oscFrequency; + + // For a detune value d, the computed frequency, fc, of the filter + // is fc = f*2^(d/1200), where f is the frequency of the filter. Or + // d = 1200*log2(fc/f). Compute the detune value to produce a final + // cutoff frequency of 1 Hz. + let detuneEnd = 1200 * Math.log2(1 / filterFrequency); + + let options = { + sampleRate: sampleRate, + oscFrequency: oscFrequency, + testDuration: testDuration, + paramName: 'detune', + filterOptions: { + type: 'lowpass', + frequency: filterFrequency, + detune: 0, + gain: 0 + }, + autoStart: {method: 'setValueAtTime', args: [0, 0]}, + autoEnd: { + method: 'linearRampToValueAtTime', + args: [detuneEnd, testDuration / 4] + } + }; + + const buffer = await doTest(should, options); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // Outputs should be the same + checkForSameOutput(should, options.paramName, actual, expected); + + task.done(); + }); + + audit.define('All k-rate inputs', async (task, should) => { + // Test the case where all AudioParams are set to k-rate with an input + // to each AudioParam. Similar to the above tests except all the params + // are k-rate. + let testFrames = testDuration * sampleRate; + let context = new OfflineAudioContext( + {numberOfChannels: 2, sampleRate: sampleRate, length: testFrames}); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + + // The peaking filter uses all four AudioParams, so this is the node to + // test. + let filterOptions = + {type: 'peaking', frequency: 0, detune: 0, gain: 0, Q: 0}; + let refNode; + should( + () => refNode = new BiquadFilterNode(context, filterOptions), + `Create: refNode = new BiquadFilterNode(context, ${ + JSON.stringify(filterOptions)})`) + .notThrow(); + + let tstNode; + should( + () => tstNode = new BiquadFilterNode(context, filterOptions), + `Create: tstNode = new BiquadFilterNode(context, ${ + JSON.stringify(filterOptions)})`) + .notThrow(); + ; + + // Make all the AudioParams k-rate. + ['frequency', 'Q', 'gain', 'detune'].forEach(param => { + should( + () => refNode[param].automationRate = 'k-rate', + `Set rate: refNode[${param}].automationRate = 'k-rate'`) + .notThrow(); + should( + () => tstNode[param].automationRate = 'k-rate', + `Set rate: tstNode[${param}].automationRate = 'k-rate'`) + .notThrow(); + }); + + // One input for each AudioParam. + let mod = {}; + ['frequency', 'Q', 'gain', 'detune'].forEach(param => { + should( + () => mod[param] = new ConstantSourceNode(context, {offset: 0}), + `Create: mod[${ + param}] = new ConstantSourceNode(context, {offset: 0})`) + .notThrow(); + ; + should( + () => mod[param].offset.automationRate = 'a-rate', + `Set rate: mod[${param}].offset.automationRate = 'a-rate'`) + .notThrow(); + }); + + // Set up automations for refNode. We want to start the filter with + // parameters that let the oscillator signal through more or less + // untouched. Then change the filter parameters to filter out the + // oscillator. What happens in between doesn't reall matter for this + // test. Hence, set the initial parameters with a center frequency well + // above the oscillator and a Q and gain of 0 to pass everthing. + [['frequency', [4 * src.frequency.value, 0]], ['Q', [0, 0]], + ['gain', [0, 0]], ['detune', [4 * 1200, 0]]] + .forEach(param => { + should( + () => refNode[param[0]].setValueAtTime(...param[1]), + `Automate 0: refNode.${param[0]}.setValueAtTime(${ + param[1][0]}, ${param[1][1]})`) + .notThrow(); + should( + () => mod[param[0]].offset.setValueAtTime(...param[1]), + `Automate 0: mod[${param[0]}].offset.setValueAtTime(${ + param[1][0]}, ${param[1][1]})`) + .notThrow(); + }); + + // Now move the filter frequency to the oscillator frequency with a high + // Q and very low gain to remove the oscillator signal. + [['frequency', [src.frequency.value, testDuration / 4]], + ['Q', [40, testDuration / 4]], ['gain', [-100, testDuration / 4]], [ + 'detune', [0, testDuration / 4] + ]].forEach(param => { + should( + () => refNode[param[0]].linearRampToValueAtTime(...param[1]), + `Automate 1: refNode[${param[0]}].linearRampToValueAtTime(${ + param[1][0]}, ${param[1][1]})`) + .notThrow(); + should( + () => mod[param[0]].offset.linearRampToValueAtTime(...param[1]), + `Automate 1: mod[${param[0]}].offset.linearRampToValueAtTime(${ + param[1][0]}, ${param[1][1]})`) + .notThrow(); + }); + + // Connect everything + src.connect(refNode).connect(merger, 0, 0); + src.connect(tstNode).connect(merger, 0, 1); + + src.start(); + for (let param in mod) { + should( + () => mod[param].connect(tstNode[param]), + `Connect: mod[${param}].connect(tstNode.${param})`) + .notThrow(); + } + + for (let param in mod) { + should(() => mod[param].start(), `Start: mod[${param}].start()`) + .notThrow(); + } + + const buffer = await context.startRendering(); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // Sanity check that the output isn't all zeroes. + should(actual, 'All k-rate AudioParams').notBeConstantValueOf(0); + should(actual, 'All k-rate AudioParams').beCloseToArray(expected, { + absoluteThreshold: 0 + }); + + task.done(); + }); + + audit.run(); + + async function doTest(should, options) { + // Test that a k-rate AudioParam with an input reads the input value and + // is actually k-rate. + // + // A refNode is created with an automation timeline. This is the + // expected output. + // + // The testNode is the same, but it has a node connected to the k-rate + // AudioParam. The input to the node is an a-rate ConstantSourceNode + // whose output is automated in exactly the same was as the refNode. If + // the test passes, the outputs of the two nodes MUST match exactly. + + // The options argument MUST contain the following members: + // sampleRate - the sample rate for the offline context + // testDuration - duration of the offline context, in sec. + // paramName - the name of the AudioParam to be tested + // oscFrequency - frequency of oscillator source + // filterOptions - options used to construct the BiquadFilterNode + // autoStart - information about how to start the automation + // autoEnd - information about how to end the automation + // + // The autoStart and autoEnd options are themselves dictionaries with + // the following required members: + // method - name of the automation method to be applied + // args - array of arguments to be supplied to the method. + let { + sampleRate, + paramName, + oscFrequency, + autoStart, + autoEnd, + testDuration, + filterOptions + } = options; + + let testFrames = testDuration * sampleRate; + let context = new OfflineAudioContext( + {numberOfChannels: 2, sampleRate: sampleRate, length: testFrames}); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // Any calls to |should| are meant to be informational so we can see + // what nodes are created and the automations used. + let src; + + // Create the source. + should( + () => { + src = new OscillatorNode(context, {frequency: oscFrequency}); + }, + `${paramName}: new OscillatorNode(context, {frequency: ${ + oscFrequency}})`) + .notThrow(); + + // The refNode automates the AudioParam with k-rate automations, no + // inputs. + let refNode; + should( + () => { + refNode = new BiquadFilterNode(context, filterOptions); + }, + `Reference BiquadFilterNode(c, ${JSON.stringify(filterOptions)})`) + .notThrow(); + + refNode[paramName].automationRate = 'k-rate'; + + // Set up automations for the reference node. + should( + () => { + refNode[paramName][autoStart.method](...autoStart.args); + }, + `refNode.${paramName}.${autoStart.method}(${autoStart.args})`) + .notThrow(); + should( + () => { + refNode[paramName][autoEnd.method](...autoEnd.args); + }, + `refNode.${paramName}.${autoEnd.method}.(${autoEnd.args})`) + .notThrow(); + + // The tstNode does the same automation, but it comes from the input + // connected to the AudioParam. + let tstNode; + should( + () => { + tstNode = new BiquadFilterNode(context, filterOptions); + }, + `Test BiquadFilterNode(context, ${JSON.stringify(filterOptions)})`) + .notThrow(); + tstNode[paramName].automationRate = 'k-rate'; + + // Create the input to the AudioParam of the test node. The output of + // this node MUST have the same set of automations as the reference + // node, and MUST be a-rate to make sure we're handling k-rate inputs + // correctly. + let mod = new ConstantSourceNode(context); + mod.offset.automationRate = 'a-rate'; + should( + () => { + mod.offset[autoStart.method](...autoStart.args); + }, + `${paramName}: mod.offset.${autoStart.method}(${autoStart.args})`) + .notThrow(); + should( + () => { + mod.offset[autoEnd.method](...autoEnd.args); + }, + `${paramName}: mod.offset.${autoEnd.method}(${autoEnd.args})`) + .notThrow(); + + // Create graph + mod.connect(tstNode[paramName]); + src.connect(refNode).connect(merger, 0, 0); + src.connect(tstNode).connect(merger, 0, 1); + + // Run! + src.start(); + mod.start(); + return context.startRendering(); + } + + function checkForSameOutput(should, paramName, actual, expected) { + let halfLength = expected.length / 2; + + // Outputs should be the same. We break the check into halves so we can + // see the expected outputs. Mostly for a simple visual check that the + // output from the second half is small because the tests generally try + // to filter out the signal so that the last half of the output is + // small. + should( + actual.slice(0, halfLength), + `k-rate ${paramName} with input: output[0,${halfLength}]`) + .beCloseToArray( + expected.slice(0, halfLength), {absoluteThreshold: 0}); + should( + actual.slice(halfLength), + `k-rate ${paramName} with input: output[${halfLength}:]`) + .beCloseToArray(expected.slice(halfLength), {absoluteThreshold: 0}); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-biquad.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-biquad.html new file mode 100644 index 0000000000..85ae4f175f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-biquad.html @@ -0,0 +1,111 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParams of BiquadFilterNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="automation-rate-testing.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + {task: 'BiquadFilter-0', label: 'Biquad k-rate AudioParams (all)'}, + (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 1; + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + doTest(context, should, { + nodeName: 'BiquadFilterNode', + nodeOptions: {type: 'lowpass'}, + prefix: 'All k-rate params', + // Set all AudioParams to k-rate + rateSettings: [ + {name: 'Q', value: 'k-rate'}, + {name: 'detune', value: 'k-rate'}, + {name: 'frequency', value: 'k-rate'}, + {name: 'gain', value: 'k-rate'}, + ], + // Automate just the frequency + automations: [{ + name: 'frequency', + methods: [ + {name: 'setValueAtTime', options: [350, 0]}, { + name: 'linearRampToValueAtTime', + options: [0, testDuration] + } + ] + }] + }).then(() => task.done()); + }); + + // Define a test where we verify that a k-rate audio param produces + // different results from an a-rate audio param for each of the audio + // params of a biquad. + // + // Each entry gives the name of the AudioParam, an initial value to be + // used with setValueAtTime, and a final value to be used with + // linearRampToValueAtTime. (See |doTest| for details as well.) + + [{name: 'Q', + initial: 1, + final: 10 + }, + {name: 'detune', + initial: 0, + final: 1200 + }, + {name: 'frequency', + initial: 350, + final: 0 + }, + {name: 'gain', + initial: 10, + final: 0 + }].forEach(paramProperty => { + audit.define('Biquad k-rate ' + paramProperty.name, (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 1; + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + doTest(context, should, { + nodeName: 'BiquadFilterNode', + nodeOptions: {type: 'peaking', Q: 1, gain: 10}, + prefix: `k-rate ${paramProperty.name}`, + // Just set the frequency to k-rate + rateSettings: [ + {name: paramProperty.name, value: 'k-rate'}, + ], + // Automate just the given AudioParam + automations: [{ + name: paramProperty.name, + methods: [ + {name: 'setValueAtTime', options: [paramProperty.initial, 0]}, { + name: 'linearRampToValueAtTime', + options: [paramProperty.final, testDuration] + } + ] + }] + }).then(() => task.done()); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-connections.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-connections.html new file mode 100644 index 0000000000..730f03e561 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-connections.html @@ -0,0 +1,139 @@ +<!doctype html> +<html> + <head> + <title>k-rate AudioParams with Inputs</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Must be power of two to eliminate round-off + const sampleRate = 8192; + + // Arbitrary duration that doesn't need to be too long to verify k-rate + // automations. Probably should be at least a few render quanta. + const testDuration = 8 * RENDER_QUANTUM_FRAMES / sampleRate; + + // Test k-rate GainNode.gain is k-rate + audit.define( + {label: 'Gain', description: 'k-rate GainNode.gain'}, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new ConstantSourceNode(context); + + createTestSubGraph(context, src, merger, 'GainNode', 'gain'); + + src.start(); + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + let expected = buffer.getChannelData(1); + + for (let k = 0; k < actual.length; + k += RENDER_QUANTUM_FRAMES) { + should( + actual.slice(k, k + RENDER_QUANTUM_FRAMES), + `gain[${k}:${k + RENDER_QUANTUM_FRAMES}]`) + .beConstantValueOf(expected[k]); + } + }) + .then(() => task.done()); + }); + + // Test k-rate StereoPannerNode.pan is k-rate + audit.define( + {label: 'StereoPanner', description: 'k-rate StereoPannerNode.pan'}, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new ConstantSourceNode(context); + + createTestSubGraph( + context, src, merger, 'StereoPannerNode', 'pan', { + testModSetup: node => { + node.offset.setValueAtTime(-1, 0); + node.offset.linearRampToValueAtTime(1, testDuration); + } + }); + + src.start(); + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + let expected = buffer.getChannelData(1); + + for (let k = 0; k < actual.length; k += 128) { + should(actual.slice(k, k + 128), `pan[${k}:${k + 128}]`) + .beConstantValueOf(expected[k]); + } + }) + .then(() => task.done()); + }); + + audit.run(); + + function createTestSubGraph( + context, src, merger, nodeName, paramName, options) { + // The test node which has its AudioParam set up for k-rate autmoations. + let tstNode = new window[nodeName](context); + + if (options && options.setups) { + options.setups(tstNode); + } + tstNode[paramName].automationRate = 'k-rate'; + + // Modulating signal for the test node. Just a linear ramp. This is + // connected to the AudioParam of the tstNode. + let tstMod = new ConstantSourceNode(context); + if (options && options.testModSetup) { + options.testModSetup(tstMod); + } else { + tstMod.offset.linearRampToValueAtTime(context.length, testDuration); + } + + tstMod.connect(tstNode[paramName]); + src.connect(tstNode).connect(merger, 0, 0); + + // The ref node is the same type of node as the test node, but uses + // a-rate automation. However, the modulating signal is k-rate. This + // causes the input to the audio param to be constant over a render, + // which is basically the same as making the audio param be k-rate. + let refNode = new window[nodeName](context); + let refMod = new ConstantSourceNode(context); + refMod.offset.automationRate = 'k-rate'; + if (options && options.testModSetup) { + options.testModSetup(refMod); + } else { + refMod.offset.linearRampToValueAtTime(context.length, testDuration); + } + + refMod.connect(refNode[paramName]); + src.connect(refNode).connect(merger, 0, 1); + + tstMod.start(); + refMod.start(); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-constant-source.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-constant-source.html new file mode 100644 index 0000000000..0bea5c91f8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-constant-source.html @@ -0,0 +1,176 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParam of ConstantSourceNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="automation-rate-testing.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define('ConstantSource k-rate offset', (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + + // Only new a few render quanta to verify things are working. + let testDuration = 4 * 128 / sampleRate; + + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + doTest(context, should, { + sourceNodeName: 'none', + verifyPieceWiseConstant: true, + nodeName: 'ConstantSourceNode', + prefix: 'k-rate offset', + rateSettings: [{name: 'offset', value: 'k-rate'}], + automations: [{ + name: 'offset', + methods: [ + {name: 'setValueAtTime', options: [0, 0]}, { + name: 'linearRampToValueAtTime', + options: [10, testDuration] + } + ] + }] + }).then(() => task.done()); + }); + + // Parameters for the For the following tests. + + // Must be power of two to eliminate round-off + const sampleRate8k = 8192; + + // Arbitrary duration that doesn't need to be too long to verify k-rate + // automations. Probably should be at least a few render quanta. + const testDuration = 8 * RENDER_QUANTUM_FRAMES / sampleRate8k; + + // Basic test that k-rate ConstantSourceNode.offset is k-rate. This is + // the basis for all of the following tests, so make sure it's right. + audit.define( + { + label: 'ConstantSourceNode.offset k-rate automation', + description: + 'Explicitly test ConstantSourceNode.offset k-rate automation is k-rate' + }, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate8k, + length: testDuration * sampleRate8k + }); + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // k-rate ConstantSource.offset using a linear ramp starting at 0 + // and incrementing by 1 for each frame. + let src = new ConstantSourceNode(context, {offset: 0}); + src.offset.automationRate = 'k-rate'; + + src.offset.setValueAtTime(0, 0); + src.offset.linearRampToValueAtTime(context.length, testDuration); + + src.connect(merger, 0, 0); + + src.start(); + + // a-rate ConstantSource using the same ramp as above. + let refSrc = new ConstantSourceNode(context, {offset: 0}); + + refSrc.offset.setValueAtTime(0, 0); + refSrc.offset.linearRampToValueAtTime(context.length, testDuration); + + refSrc.connect(merger, 0, 1); + + refSrc.start(); + + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + let expected = buffer.getChannelData(1); + + for (let k = 0; k < actual.length; + k += RENDER_QUANTUM_FRAMES) { + // Verify that the k-rate output is constant over the render + // and that it matches the value of the a-rate value at the + // beginning of the render. + should( + actual.slice(k, k + RENDER_QUANTUM_FRAMES), + `k-rate ConstantSource.offset: output[${k}:${ + k + RENDER_QUANTUM_FRAMES}]`) + .beConstantValueOf(expected[k]); + } + }) + .then(() => task.done()); + }); + + // This test verifies that a k-rate input to the ConstantSourceNode.offset + // works just as if we set the AudioParam to be k-rate. This is the basis + // of the following tests, so make sure it works. + audit.define( + { + label: 'ConstantSource.offset', + description: 'Verify k-rate automation matches k-rate input' + }, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate8k, + length: testDuration * sampleRate8k + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let tstSrc = new ConstantSourceNode(context); + let tstMod = new ConstantSourceNode(context); + tstSrc.offset.automationRate = 'k-rate'; + tstMod.offset.linearRampToValueAtTime(context.length, testDuration); + + tstMod.connect(tstSrc.offset) + tstSrc.connect(merger, 0, 0); + + let refSrc = new ConstantSourceNode(context); + let refMod = new ConstantSourceNode(context); + refMod.offset.linearRampToValueAtTime(context.length, testDuration); + refMod.offset.automationRate = 'k-rate'; + + refMod.connect(refSrc.offset); + refSrc.connect(merger, 0, 1); + + tstSrc.start(); + tstMod.start(); + refSrc.start(); + refMod.start(); + + context.startRendering() + .then(buffer => { + let actual = buffer.getChannelData(0); + let expected = buffer.getChannelData(1); + + for (let k = 0; k < context.length; + k += RENDER_QUANTUM_FRAMES) { + should( + actual.slice(k, k + RENDER_QUANTUM_FRAMES), + `ConstantSource.offset k-rate input: output[${k}:${ + k + RENDER_QUANTUM_FRAMES}]`) + .beConstantValueOf(expected[k]); + } + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-delay-connections.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-delay-connections.html new file mode 100644 index 0000000000..fcf66f2e3e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-delay-connections.html @@ -0,0 +1,156 @@ +<!doctype html> +<html> + <head> + <title>k-rate AudioParams with inputs for DelayNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Power-of-two to eliminate round-off in computing time and frames, but + // is otherwise arbitrary. + const sampleRate = 8192; + + // Arbitrary duration except it must be greater than or equal to 1. + const testDuration = 1.5; + + audit.define( + {label: 'delayTime', description: `DelayNode delayTime k-rate input`}, + async (task, should) => { + // Two channels: 0 = test result, 1 = expected result. + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // Test the DelayNode by having a reference node (refNode) that uses + // k-rate automations of delayTime. The test node (testNode) sets + // delayTime to k-rate with a connected input that has the same + // automation vlaues as the reference node. The test passes if the + // output from each node is identical to each other. + + // Just some non-constant source. + let src = new OscillatorNode(context); + + // The end value and time for the linear ramp. These values are + // chosen so that the delay advances faster than real time. + let endValue = 1.125; + let endTime = 1; + + let refNode; + + should( + () => refNode = new DelayNode(context), + `refNode = new DelayNode(context)`) + .notThrow(); + + should( + () => refNode.delayTime.automationRate = 'k-rate', + `refNode.delayTime.automationRate = 'k-rate'`) + .notThrow(); + + should( + () => refNode.delayTime.setValueAtTime(0, 0), + `refNode.delayTime.setValueAtTime(0, 0)`) + .notThrow(); + + should( + () => refNode.delayTime.linearRampToValueAtTime( + endValue, endTime), + `refNode.delayTime.linearRampToValueAtTime(${endValue}, ${ + endTime})`) + .notThrow(); + + let testNode; + + should( + () => testNode = new DelayNode(context), + `testNode = new DelayNode(context)`) + .notThrow(); + + should( + () => testNode.delayTime.automationRate = 'k-rate', + `testNode.delayTime.automationRate = 'k-rate'`) + .notThrow(); + + let testMod; + + should( + () => testMod = new ConstantSourceNode(context), + `testMod = new ConstantSourceNode(context)`) + .notThrow(); + + should( + () => testMod.offset.setValueAtTime(0, 0), + `testMod.offset.setValueAtTime(0, 0)`) + .notThrow(); + + should( + () => testMod.offset.linearRampToValueAtTime(endValue, endTime), + `testMod.offset.linearRampToValueAtTime(${endValue}, ${ + endTime})`) + .notThrow(); + + should( + () => testMod.connect(testNode.delayTime), + `testMod.connect(testNode.delayTime)`) + .notThrow(); + + // Connect up everything and go! + src.connect(testNode).connect(merger, 0, 0); + src.connect(refNode).connect(merger, 0, 1); + + src.start(); + testMod.start(); + + const buffer = await context.startRendering(); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // Quick sanity check that output isn't zero. This means we messed + // up the connections or automations or the buffer source. + should(expected, `Expected k-rate delayTime AudioParam with input`) + .notBeConstantValueOf(0); + should(actual, `Actual k-rate delayTime AudioParam with input`) + .notBeConstantValueOf(0); + + // Quick sanity check. The amount of delay after one render is + // endValue * 128 / sampleRate. But after 1 render, time has + // advanced 128/sampleRate. Hence, the delay exceeds the time by + // (endValue - 1)*128/sampleRate sec or (endValue - 1)*128 frames. + // This means the output must be EXACTLY zero for this many frames + // in the second render. + let zeroFrames = (endValue - 1) * RENDER_QUANTUM_FRAMES; + should( + actual.slice( + RENDER_QUANTUM_FRAMES, RENDER_QUANTUM_FRAMES + zeroFrames), + `output[${RENDER_QUANTUM_FRAMES}, ${ + RENDER_QUANTUM_FRAMES + zeroFrames - 1}]`) + .beConstantValueOf(0); + should( + actual.slice( + RENDER_QUANTUM_FRAMES + zeroFrames, + 2 * RENDER_QUANTUM_FRAMES), + `output[${RENDER_QUANTUM_FRAMES + zeroFrames}, ${ + 2 * RENDER_QUANTUM_FRAMES - 1}]`) + .notBeConstantValueOf(0); + + // The expected and actual results must be EXACTLY the same. + should(actual, `k-rate delayTime AudioParam with input`) + .beCloseToArray(expected, {absoluteThreshold: 0}); + }); + + audit.run(); + </script> + </body> + </html>
\ No newline at end of file diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-delay.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-delay.html new file mode 100644 index 0000000000..5465c39430 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-delay.html @@ -0,0 +1,49 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParam of DelayNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="automation-rate-testing.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define('Test k-rate DelayNode', (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 1; + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + + doTest(context, should, { + nodeName: 'DelayNode', + nodeOptions: null, + prefix: 'DelayNode', + // Set all AudioParams to k-rate + rateSettings: [{name: 'delayTime', value: 'k-rate'}], + // Automate just the frequency + automations: [{ + name: 'delayTime', + methods: [ + {name: 'setValueAtTime', options: [0, 0]}, { + name: 'linearRampToValueAtTime', + options: [.5, testDuration] + } + ] + }] + }).then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-dynamics-compressor-connections.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-dynamics-compressor-connections.html new file mode 100644 index 0000000000..c1755cd155 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-dynamics-compressor-connections.html @@ -0,0 +1,145 @@ +<!doctype html> +<html> + <head> + <title>k-rate AudioParams with inputs for DynamicsCompressorNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Fairly abitrary sampleRate and somewhat duration + const sampleRate = 48000; + const testDuration = 0.25; + + ['attack', 'knee', 'ratio', 'release', 'threshold'].forEach(param => { + audit.define( + {label: param, description: `Dynamics compressor ${param}`}, + async (task, should) => { + await doTest(should, {prefix: task.label, paramName: param}); + task.done(); + }); + }); + + audit.run(); + + async function doTest(should, options) { + // Test k-rate automation of DynamicsCompressorNode with connected + // input. + // + // A reference compressor node is created with an automation on the + // selected AudioParam. For simplicity, we just use a linear ramp from + // the minValue to the maxValue of the AudioParam. + // + // The test node has an input signal connected to the AudioParam. This + // input signal is created to match the automation on the reference + // node. + // + // Finally, the output from the two nodes must be identical if k-rate + // inputs are working correctly. + // + // Options parameter is a dictionary with the following required + // members: + // prefix - prefix to use for the messages. + // paramName - Name of the AudioParam to be tested + + let {prefix, paramName} = options; + + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // Use an oscillator for the source. Pretty arbitrary parameters. + let src = + new OscillatorNode(context, {type: 'sawtooth', frequency: 440}); + + // Create the reference and test nodes. + let refNode; + let tstNode; + + should( + () => refNode = new DynamicsCompressorNode(context), + `${prefix}: refNode = new DynamicsCompressorNode(context)`) + .notThrow(); + + let tstOptions = {}; + tstOptions[paramName] = refNode[paramName].minValue; + should( + () => tstNode = new DynamicsCompressorNode(context, tstOptions), + `${prefix}: tstNode = new DynamicsCompressorNode(context, ${ + JSON.stringify(tstOptions)})`) + .notThrow(); + + + // Automate the AudioParam of the reference node with a linear ramp + should( + () => refNode[paramName].setValueAtTime( + refNode[paramName].minValue, 0), + `${prefix}: refNode[${paramName}].setValueAtTime(refNode[${ + paramName}].minValue, 0)`) + .notThrow(); + + should( + () => refNode[paramName].linearRampToValueAtTime( + refNode[paramName].maxValue, testDuration), + `${prefix}: refNode[${paramName}].linearRampToValueAtTime(refNode[${ + paramName}].minValue, ${testDuration})`) + .notThrow(); + + + // Create the input node and automate it so that it's output when added + // to the intrinsic value of the AudioParam we get the same values as + // the automations on the ference node. We need to do it this way + // because the ratio AudioParam has a nominal range of [1, 20] so we + // can't just set the value to 0, which is what we'd normally do. + let mod; + should( + () => mod = new ConstantSourceNode(context, {offset: 0}), + `${prefix}: mod = new ConstantSourceNode(context, {offset: 0})`) + .notThrow(); + let endValue = + refNode[paramName].maxValue - refNode[paramName].minValue; + should( + () => mod.offset.setValueAtTime(0, 0), + `${prefix}: mod.offset.setValueAtTime(0, 0)`) + .notThrow(); + should( + () => mod.offset.linearRampToValueAtTime(endValue, testDuration), + `${prefix}: mod.offset.linearRampToValueAtTime(${endValue}, ${ + testDuration})`) + .notThrow(); + + // Connect up everything. + should( + () => mod.connect(tstNode[paramName]), + `${prefix}: mod.connect(tstNode[${paramName}])`) + .notThrow(); + + src.connect(refNode).connect(merger, 0, 0); + src.connect(tstNode).connect(merger, 0, 1); + + // Go! + src.start(); + mod.start(); + + const buffer = await context.startRendering(); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // The expected and actual results must be EXACTLY the same. + should(actual, `k-rate ${paramName} AudioParam with input`) + .beCloseToArray(expected, {absoluteThreshold: 0}); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-gain.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-gain.html new file mode 100644 index 0000000000..887d9f78db --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-gain.html @@ -0,0 +1,47 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParam of GainNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="automation-rate-testing.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define('Test k-rate GainNode', (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 1; + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + + doTest(context, should, { + nodeName: 'GainNode', + nodeOptions: null, + prefix: 'GainNode', + // Set AudioParam to k-rate + rateSettings: [{name: 'gain', value: 'k-rate'}], + // Automate + automations: [{ + name: 'gain', + methods: [ + {name: 'setValueAtTime', options: [1, 0]}, + {name: 'linearRampToValueAtTime', options: [0, testDuration]} + ] + }] + }).then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-oscillator-connections.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-oscillator-connections.html new file mode 100644 index 0000000000..475b364367 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-oscillator-connections.html @@ -0,0 +1,578 @@ +<!doctype html> +<html> + <head> + <title> + k-rate AudioParams with inputs for OscillatorNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Sample rate must be a power of two to eliminate round-off when + // computing time from frames and vice versa. Using a non-power of two + // will work, but the thresholds below will not be zero. They're probably + // closer to 1e-5 or so, but if everything is working correctly, the + // outputs really should be exactly equal. + const sampleRate = 8192; + + // Fairly arbitrary but short duration to limit runtime. + const testFrames = 5 * RENDER_QUANTUM_FRAMES; + const testDuration = testFrames / sampleRate; + + audit.define( + {label: 'Test 1', description: 'k-rate frequency input'}, + async (task, should) => { + // Test that an input to the frequency AudioParam set to k-rate + // works. + + // Fairly arbitrary start and end frequencies for the automation. + const freqStart = 100; + const freqEnd = 2000; + + let refSetup = (context) => { + let srcRef = new OscillatorNode(context, {frequency: 0}); + + should( + () => srcRef.frequency.automationRate = 'k-rate', + `${task.label}: srcRef.frequency.automationRate = 'k-rate'`) + .notThrow(); + should( + () => srcRef.frequency.setValueAtTime(freqStart, 0), + `${task.label}: srcRef.frequency.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + should( + () => srcRef.frequency.linearRampToValueAtTime( + freqEnd, testDuration), + `${task.label}: srcRef.frequency.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + + return srcRef; + }; + + let testSetup = (context) => { + let srcTest = new OscillatorNode(context, {frequency: 0}); + should( + () => srcTest.frequency.automationRate = 'k-rate', + `${task.label}: srcTest.frequency.automationRate = 'k-rate'`) + .notThrow(); + + return srcTest; + }; + + let modSetup = (context) => { + let mod = new ConstantSourceNode(context, {offset: 0}); + + should( + () => mod.offset.setValueAtTime(freqStart, 0), + `${task.label}: modFreq.offset.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + should( + () => + mod.offset.linearRampToValueAtTime(freqEnd, testDuration), + `${task.label}: modFreq.offset.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + + // This node is going to be connected to the frequency AudioParam. + return {frequency: mod}; + }; + + await testParams(should, { + prefix: task.label, + summary: 'k-rate frequency with input', + setupRefOsc: refSetup, + setupTestOsc: testSetup, + setupMod: modSetup + }); + + task.done(); + }); + + audit.define( + {label: 'Test 2', description: 'k-rate detune input'}, + async (task, should) => { + // Test that an input to the detune AudioParam set to k-rate works. + // Threshold experimentally determined. It should be probably not + // be much larger than 5e-5. or something is not right. + + // Fairly arbitrary start and end detune values for automation. + const detuneStart = 0; + const detuneEnd = 2000; + + let refSetup = (context) => { + let srcRef = new OscillatorNode(context, {detune: 0}); + + should( + () => srcRef.detune.automationRate = 'k-rate', + `${task.label}: srcRef.detune.automationRate = 'k-rate'`) + .notThrow(); + + should( + () => srcRef.detune.setValueAtTime(detuneStart, 0), + `${task.label}: srcRef.detune.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => srcRef.detune.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: srcRef.detune.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return srcRef; + }; + + let testSetup = (context) => { + let srcTest = new OscillatorNode(context, {detune: 0}); + + should( + () => srcTest.detune.automationRate = 'k-rate', + `${task.label}: srcTest.detune.automationRate = 'k-rate'`) + .notThrow(); + + return srcTest; + }; + + let modSetup = (context) => { + let mod = new ConstantSourceNode(context, {offset: 0}); + + should( + () => mod.offset.setValueAtTime(detuneStart, 0), + `${task.label}: modDetune.offset.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => mod.offset.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: modDetune.offset.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return {detune: mod}; + }; + + await testParams(should, { + prefix: task.label, + summary: 'k-rate detune with input', + setupRefOsc: refSetup, + setupTestOsc: testSetup, + setupMod: modSetup + }); + + task.done(); + }); + + audit.define( + { + label: 'Test 3', + description: 'k-rate frequency input with a-rate detune' + }, + async (task, should) => { + // Test OscillatorNode with a k-rate frequency with input and an + // a-rate detune iwth automations. + + // Fairly arbitrary start and end values for the frequency and + // detune automations. + const freqStart = 100; + const freqEnd = 2000; + const detuneStart = 0; + const detuneEnd = -2000; + + let refSetup = (context) => { + let node = new OscillatorNode(context, {frequency: 0}); + + // Set up k-rate frequency and a-rate detune + should( + () => node.frequency.automationRate = 'k-rate', + `${task.label}: srcRef.frequency.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.frequency.setValueAtTime(freqStart, 0), + `${task.label}: srcRef.frequency.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + should( + () => node.frequency.linearRampToValueAtTime( + 2000, testDuration), + `${task.label}: srcRef.frequency.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + should( + () => node.detune.setValueAtTime(detuneStart, 0), + `${task.label}: srcRef.detune.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => node.detune.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: srcRef.detune.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return node; + }; + + let testSetup = (context) => { + let node = new OscillatorNode(context, {frequency: 0}); + + should( + () => node.frequency.automationRate = 'k-rate', + `${task.label}: srcTest.frequency.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.detune.setValueAtTime(detuneStart, 0), + `${task.label}: srcTest.detune.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => node.detune.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: srcTest.detune.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return node; + }; + + let modSetup = (context) => { + let mod = {}; + mod['frequency'] = new ConstantSourceNode(context, {offset: 0}); + + should( + () => mod['frequency'].offset.setValueAtTime(freqStart, 0), + `${task.label}: modFreq.offset.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + + should( + () => mod['frequency'].offset.linearRampToValueAtTime( + 2000, testDuration), + `${task.label}: modFreq.offset.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + + return mod; + }; + + await testParams(should, { + prefix: task.label, + summary: 'k-rate frequency input with a-rate detune', + setupRefOsc: refSetup, + setupTestOsc: testSetup, + setupMod: modSetup + }); + + task.done(); + }); + + audit.define( + { + label: 'Test 4', + description: 'a-rate frequency with k-rate detune input' + }, + async (task, should) => { + // Test OscillatorNode with an a-rate frequency with automations and + // a k-rate detune with input. + + // Fairly arbitrary start and end values for the frequency and + // detune automations. + const freqStart = 100; + const freqEnd = 2000; + const detuneStart = 0; + const detuneEnd = -2000; + + let refSetup = (context) => { + let node = new OscillatorNode(context, {detune: 0}); + + // Set up a-rate frequency and k-rate detune + should( + () => node.frequency.setValueAtTime(freqStart, 0), + `${task.label}: srcRef.frequency.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + should( + () => node.frequency.linearRampToValueAtTime( + 2000, testDuration), + `${task.label}: srcRef.frequency.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + should( + () => node.detune.automationRate = 'k-rate', + `${task.label}: srcRef.detune.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.detune.setValueAtTime(detuneStart, 0), + `${task.label}: srcRef.detune.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => node.detune.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: srcRef.detune.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return node; + }; + + let testSetup = (context) => { + let node = new OscillatorNode(context, {detune: 0}); + + should( + () => node.detune.automationRate = 'k-rate', + `${task.label}: srcTest.detune.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.frequency.setValueAtTime(freqStart, 0), + `${task.label}: srcTest.frequency.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + should( + () => node.frequency.linearRampToValueAtTime( + freqEnd, testDuration), + `${task.label}: srcTest.frequency.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + + return node; + }; + + let modSetup = (context) => { + let mod = {}; + const name = 'detune'; + + mod['detune'] = new ConstantSourceNode(context, {offset: 0}); + should( + () => mod[name].offset.setValueAtTime(detuneStart, 0), + `${task.label}: modDetune.offset.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + + should( + () => mod[name].offset.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: modDetune.offset.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return mod; + }; + + await testParams(should, { + prefix: task.label, + summary: 'k-rate detune input with a-rate frequency', + setupRefOsc: refSetup, + setupTestOsc: testSetup, + setupMod: modSetup + }); + + task.done(); + }); + + audit.define( + { + label: 'Test 5', + description: 'k-rate inputs for frequency and detune' + }, + async (task, should) => { + // Test OscillatorNode with k-rate frequency and detune with inputs + // on both. + + // Fairly arbitrary start and end values for the frequency and + // detune automations. + const freqStart = 100; + const freqEnd = 2000; + const detuneStart = 0; + const detuneEnd = -2000; + + let refSetup = (context) => { + let node = new OscillatorNode(context, {frequency: 0, detune: 0}); + + should( + () => node.frequency.automationRate = 'k-rate', + `${task.label}: srcRef.frequency.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.frequency.setValueAtTime(freqStart, 0), + `${task.label}: srcRef.setValueAtTime(${freqStart}, 0)`) + .notThrow(); + should( + () => node.frequency.linearRampToValueAtTime( + freqEnd, testDuration), + `${task.label}: srcRef;.frequency.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + should( + () => node.detune.automationRate = 'k-rate', + `${task.label}: srcRef.detune.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.detune.setValueAtTime(detuneStart, 0), + `${task.label}: srcRef.detune.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => node.detune.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: srcRef.detune.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return node; + }; + + let testSetup = (context) => { + let node = new OscillatorNode(context, {frequency: 0, detune: 0}); + + should( + () => node.frequency.automationRate = 'k-rate', + `${task.label}: srcTest.frequency.automationRate = 'k-rate'`) + .notThrow(); + should( + () => node.detune.automationRate = 'k-rate', + `${task.label}: srcTest.detune.automationRate = 'k-rate'`) + .notThrow(); + + return node; + }; + + let modSetup = (context) => { + let modF = new ConstantSourceNode(context, {offset: 0}); + + should( + () => modF.offset.setValueAtTime(freqStart, 0), + `${task.label}: modFreq.offset.setValueAtTime(${ + freqStart}, 0)`) + .notThrow(); + should( + () => modF.offset.linearRampToValueAtTime( + freqEnd, testDuration), + `${task.label}: modFreq.offset.linearRampToValueAtTime(${ + freqEnd}, ${testDuration})`) + .notThrow(); + + let modD = new ConstantSourceNode(context, {offset: 0}); + + should( + () => modD.offset.setValueAtTime(detuneStart, 0), + `${task.label}: modDetune.offset.setValueAtTime(${ + detuneStart}, 0)`) + .notThrow(); + should( + () => modD.offset.linearRampToValueAtTime( + detuneEnd, testDuration), + `${task.label}: modDetune.offset.linearRampToValueAtTime(${ + detuneEnd}, ${testDuration})`) + .notThrow(); + + return {frequency: modF, detune: modD}; + }; + + await testParams(should, { + prefix: task.label, + summary: 'k-rate inputs for both frequency and detune', + setupRefOsc: refSetup, + setupTestOsc: testSetup, + setupMod: modSetup + }); + + task.done(); + }); + + audit.run(); + + async function testParams(should, options) { + // Test a-rate and k-rate AudioParams of an OscillatorNode. + // + // |options| should be a dictionary with these members: + // prefix - prefix to use for messages + // summary - message to be printed with the final results + // setupRefOsc - function returning the reference oscillator + // setupTestOsc - function returning the test oscillator + // setupMod - function returning nodes to be connected to the + // AudioParams. + // + // |setupRefOsc| and |setupTestOsc| are given the context and each + // method is expected to create an OscillatorNode with the appropriate + // automations for testing. The constructed OscillatorNode is returned. + // + // The reference oscillator + // should automate the desired AudioParams at the appropriate automation + // rate, and the output is the expected result. + // + // The test oscillator should set up the AudioParams but expect the + // AudioParam(s) have an input that matches the automation for the + // reference oscillator. + // + // |setupMod| must create one or two ConstantSourceNodes with exactly + // the same automations as used for the reference oscillator. This node + // is used as the input to an AudioParam of the test oscillator. This + // function returns a dictionary whose members are named 'frequency' and + // 'detune'. The name indicates which AudioParam the constant source + // node should be connected to. + + // Two channels: 0 = reference signal, 1 = test signal + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // The reference oscillator. + let srcRef = options.setupRefOsc(context); + + // The test oscillator. + let srcTest = options.setupTestOsc(context); + + // Inputs to AudioParam. + let mod = options.setupMod(context); + + if (mod['frequency']) { + should( + () => mod['frequency'].connect(srcTest.frequency), + `${options.prefix}: modFreq.connect(srcTest.frequency)`) + .notThrow(); + mod['frequency'].start() + } + + if (mod['detune']) { + should( + () => mod['detune'].connect(srcTest.detune), + `${options.prefix}: modDetune.connect(srcTest.detune)`) + .notThrow(); + mod['detune'].start() + } + + srcRef.connect(merger, 0, 0); + srcTest.connect(merger, 0, 1); + + srcRef.start(); + srcTest.start(); + + let buffer = await context.startRendering(); + let expected = buffer.getChannelData(0); + let actual = buffer.getChannelData(1); + + // The output of the reference and test oscillator should be + // exactly equal because the AudioParam values should be exactly + // equal. + should(actual, options.summary).beCloseToArray(expected, { + absoluteThreshold: 0 + }); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-oscillator.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-oscillator.html new file mode 100644 index 0000000000..6803f55eab --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-oscillator.html @@ -0,0 +1,88 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParams of OscillatorNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Arbitrary sample rate and duration. + let sampleRate = 8000; + + // Only new a few render quanta to verify things are working. + let testDuration = 4 * 128 / sampleRate; + + [{name: 'detune', initial: 0, final: 1200}, { + name: 'frequency', + initial: 440, + final: sampleRate / 2 + }].forEach(paramProperty => { + audit.define( + 'Oscillator k-rate ' + paramProperty.name, (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + let inverter = new GainNode(context, {gain: -1}); + inverter.connect(merger, 0, 2); + + let kRateNode = new OscillatorNode(context); + let aRateNode = new OscillatorNode(context); + + kRateNode.connect(merger, 0, 0); + aRateNode.connect(merger, 0, 1); + + kRateNode.connect(merger, 0, 2); + aRateNode.connect(inverter); + + // Set the rate + kRateNode[paramProperty.name].automationRate = 'k-rate'; + + // Automate the offset + kRateNode[paramProperty.name].setValueAtTime( + paramProperty.initial, 0); + kRateNode[paramProperty.name].linearRampToValueAtTime( + paramProperty.final, testDuration); + + aRateNode[paramProperty.name].setValueAtTime( + paramProperty.initial, 0); + aRateNode[paramProperty.name].linearRampToValueAtTime( + paramProperty.final, testDuration); + + kRateNode.start(); + aRateNode.start(); + + context.startRendering() + .then(audioBuffer => { + let kRateOut = audioBuffer.getChannelData(0); + let aRateOut = audioBuffer.getChannelData(1); + let diff = audioBuffer.getChannelData(2); + + // Verify that the outputs are different. + should( + diff, + 'k-rate ' + paramProperty.name + + ': Difference between a-rate and k-rate outputs') + .notBeConstantValueOf(0); + + }) + .then(() => task.done()); + }); + }); + + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-panner-connections.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-panner-connections.html new file mode 100644 index 0000000000..001cf63bd3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-panner-connections.html @@ -0,0 +1,238 @@ +<!doctype html> +<html> + <head> + <title> + k-rate AudioParams with inputs for PannerNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </title> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'Panner x', description: 'k-rate input'}, + async (task, should) => { + await testPannerParams(should, {param: 'positionX'}); + task.done(); + }); + + audit.define( + {label: 'Panner y', description: 'k-rate input'}, + async (task, should) => { + await testPannerParams(should, {param: 'positionY'}); + task.done(); + }); + + audit.define( + {label: 'Panner z', description: 'k-rate input'}, + async (task, should) => { + await testPannerParams(should, {param: 'positionZ'}); + task.done(); + }); + + audit.define( + {label: 'Listener x', description: 'k-rate input'}, + async (task, should) => { + await testListenerParams(should, {param: 'positionX'}); + task.done(); + }); + + audit.define( + {label: 'Listener y', description: 'k-rate input'}, + async (task, should) => { + await testListenerParams(should, {param: 'positionY'}); + task.done(); + }); + + audit.define( + {label: 'Listener z', description: 'k-rate input'}, + async (task, should) => { + await testListenerParams(should, {param: 'positionZ'}); + task.done(); + }); + + audit.run(); + + async function testPannerParams(should, options) { + // Arbitrary sample rate and duration. + const sampleRate = 8000; + const testFrames = 5 * RENDER_QUANTUM_FRAMES; + let testDuration = testFrames / sampleRate; + // Four channels needed because the first two are for the output of + // the reference panner, and the next two are for the test panner. + let context = new OfflineAudioContext({ + numberOfChannels: 4, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // Create a stereo source out of two mono sources + let src0 = new ConstantSourceNode(context, {offset: 1}); + let src1 = new ConstantSourceNode(context, {offset: 2}); + let src = new ChannelMergerNode(context, {numberOfInputs: 2}); + src0.connect(src, 0, 0); + src1.connect(src, 0, 1); + + let finalPosition = 100; + + // Reference panner node with k-rate AudioParam automations. The + // output of this panner is the reference output. + let refNode = new PannerNode(context); + // Initialize the panner location to somewhat arbitrary values. + refNode.positionX.value = 1; + refNode.positionY.value = 50; + refNode.positionZ.value = -25; + + // Set the AudioParam under test with the appropriate automations. + refNode[options.param].automationRate = 'k-rate'; + refNode[options.param].setValueAtTime(1, 0); + refNode[options.param].linearRampToValueAtTime( + finalPosition, testDuration); + let refSplit = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + + // Test panner node with k-rate AudioParam with inputs. + let tstNode = new PannerNode(context); + tstNode.positionX.value = 1; + tstNode.positionY.value = 50; + tstNode.positionZ.value = -25; + tstNode[options.param].value = 0; + tstNode[options.param].automationRate = 'k-rate'; + let tstSplit = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + + // The input to the AudioParam. It must have the same automation + // sequence as used by refNode. And must be a-rate to demonstrate + // the k-rate effect of the AudioParam. + let mod = new ConstantSourceNode(context, {offset: 0}); + mod.offset.setValueAtTime(1, 0); + mod.offset.linearRampToValueAtTime(finalPosition, testDuration); + + mod.connect(tstNode[options.param]); + + src.connect(refNode).connect(refSplit); + src.connect(tstNode).connect(tstSplit); + + refSplit.connect(merger, 0, 0); + refSplit.connect(merger, 1, 1); + tstSplit.connect(merger, 0, 2); + tstSplit.connect(merger, 1, 3); + + mod.start(); + src0.start(); + src1.start(); + + const buffer = await context.startRendering(); + let expected0 = buffer.getChannelData(0); + let expected1 = buffer.getChannelData(1); + let actual0 = buffer.getChannelData(2); + let actual1 = buffer.getChannelData(3); + + should(expected0, `Panner: ${options.param}: Expected output channel 0`) + .notBeConstantValueOf(expected0[0]); + should(expected1, `${options.param}: Expected output channel 1`) + .notBeConstantValueOf(expected1[0]); + + // Verify output is a stair step because positionX is k-rate, + // and no other AudioParam is changing. + + for (let k = 0; k < testFrames; k += RENDER_QUANTUM_FRAMES) { + should( + actual0.slice(k, k + RENDER_QUANTUM_FRAMES), + `Panner: ${options.param}: Channel 0 output[${k}, ${ + k + RENDER_QUANTUM_FRAMES - 1}]`) + .beConstantValueOf(actual0[k]); + } + + for (let k = 0; k < testFrames; k += RENDER_QUANTUM_FRAMES) { + should( + actual1.slice(k, k + RENDER_QUANTUM_FRAMES), + `Panner: ${options.param}: Channel 1 output[${k}, ${ + k + RENDER_QUANTUM_FRAMES - 1}]`) + .beConstantValueOf(actual1[k]); + } + + should(actual0, `Panner: ${options.param}: Actual output channel 0`) + .beCloseToArray(expected0, {absoluteThreshold: 0}); + should(actual1, `Panner: ${options.param}: Actual output channel 1`) + .beCloseToArray(expected1, {absoluteThreshold: 0}); + } + + async function testListenerParams(should, options) { + // Arbitrary sample rate and duration. + const sampleRate = 8000; + const testFrames = 5 * RENDER_QUANTUM_FRAMES; + let testDuration = testFrames / sampleRate; + // Four channels needed because the first two are for the output of + // the reference panner, and the next two are for the test panner. + let context = new OfflineAudioContext({ + numberOfChannels: 2, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + // Create a stereo source out of two mono sources + let src0 = new ConstantSourceNode(context, {offset: 1}); + let src1 = new ConstantSourceNode(context, {offset: 2}); + let src = new ChannelMergerNode(context, {numberOfInputs: 2}); + src0.connect(src, 0, 0); + src1.connect(src, 0, 1); + + let finalPosition = 100; + + // Reference panner node with k-rate AudioParam automations. The + // output of this panner is the reference output. + let panner = new PannerNode(context); + panner.positionX.value = 10; + panner.positionY.value = 50; + panner.positionZ.value = -25; + + src.connect(panner); + + let mod = new ConstantSourceNode(context, {offset: 0}); + mod.offset.setValueAtTime(1, 0); + mod.offset.linearRampToValueAtTime(finalPosition, testDuration); + + context.listener[options.param].automationRate = 'k-rate'; + mod.connect(context.listener[options.param]); + + panner.connect(context.destination); + + src0.start(); + src1.start(); + mod.start(); + + const buffer = await context.startRendering(); + let c0 = buffer.getChannelData(0); + let c1 = buffer.getChannelData(1); + + // Verify output is a stair step because positionX is k-rate, + // and no other AudioParam is changing. + + for (let k = 0; k < testFrames; k += RENDER_QUANTUM_FRAMES) { + should( + c0.slice(k, k + RENDER_QUANTUM_FRAMES), + `Listener: ${options.param}: Channel 0 output[${k}, ${ + k + RENDER_QUANTUM_FRAMES - 1}]`) + .beConstantValueOf(c0[k]); + } + + for (let k = 0; k < testFrames; k += RENDER_QUANTUM_FRAMES) { + should( + c1.slice(k, k + RENDER_QUANTUM_FRAMES), + `Listener: ${options.param}: Channel 1 output[${k}, ${ + k + RENDER_QUANTUM_FRAMES - 1}]`) + .beConstantValueOf(c1[k]); + } + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-panner.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-panner.html new file mode 100644 index 0000000000..60200b2471 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-panner.html @@ -0,0 +1,178 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParams of PannerNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="automation-rate-testing.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Define a test where we verify that a k-rate audio param produces + // different results from an a-rate audio param for each of the audio + // params of a biquad. + // + // Each entry gives the name of the AudioParam, an initial value to be + // used with setValueAtTime, and a final value to be used with + // linearRampToValueAtTime. (See |doTest| for details as well.) + + [{name: 'positionX', initial: 0, final: 1000}, + {name: 'positionY', initial: 0, final: 1000}, + {name: 'orientationX', initial: 1, final: 10}, + {name: 'orientationY', initial: 1, final: 10}, + {name: 'orientationZ', initial: 1, final: 10}, + ].forEach(paramProperty => { + audit.define('Panner k-rate ' + paramProperty.name, (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 5 * 128 / sampleRate; + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + doTest(context, should, { + sourceNodeName: 'ConstantSourceNode', + verifyPieceWiseConstant: true, + nodeName: 'PannerNode', + // Make the source directional so orientation matters, and set some + // defaults for the position and orientation so that we're not on an + // axis where the azimuth and elevation might be constant when + // moving one of the AudioParams. + nodeOptions: { + distanceModel: 'inverse', + coneOuterAngle: 360, + coneInnerAngle: 0, + positionX: 1, + positionY: 1, + positionZ: 1, + orientationX: 0, + orientationY: 1, + orientationZ: 1 + }, + prefix: `k-rate ${paramProperty.name}`, + // Just set the frequency to k-rate + rateSettings: [ + {name: paramProperty.name, value: 'k-rate'}, + ], + // Automate just the given AudioParam + automations: [{ + name: paramProperty.name, + methods: [ + {name: 'setValueAtTime', options: [paramProperty.initial, 0]}, { + name: 'linearRampToValueAtTime', + options: [paramProperty.final, testDuration] + } + ] + }] + }).then(() => task.done()); + }); + }); + + // Test k-rate automation of the listener. The intial and final + // automation values are pretty arbitrary, except that they should be such + // that the panner and listener produces non-constant output. + [{name: 'positionX', initial: [1, 0], final: [1000, 1]}, + {name: 'positionY', initial: [1, 0], final: [1000, 1]}, + {name: 'positionZ', initial: [1, 0], final: [1000, 1]}, + {name: 'forwardX', initial: [-1, 0], final: [1, 1]}, + {name: 'forwardY', initial: [-1, 0], final: [1, 1]}, + {name: 'forwardZ', initial: [-1, 0], final: [1, 1]}, + {name: 'upX', initial: [-1, 0], final: [1000, 1]}, + {name: 'upY', initial: [-1, 0], final: [1000, 1]}, + {name: 'upZ', initial: [-1, 0], final: [1000, 1]}, + ].forEach(paramProperty => { + audit.define( + 'Listener k-rate ' + paramProperty.name, (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 5 * 128 / sampleRate; + let context = new OfflineAudioContext({ + numberOfChannels: 1, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + doListenerTest(context, should, { + param: paramProperty.name, + initial: paramProperty.initial, + final: paramProperty.final + }).then(() => task.done()); + }); + }); + + audit.run(); + + function doListenerTest(context, should, options) { + let src = new ConstantSourceNode(context); + let panner = new PannerNode(context, { + distanceModel: 'inverse', + coneOuterAngle: 360, + coneInnerAngle: 10, + positionX: 10, + positionY: 10, + positionZ: 10, + orientationX: 1, + orientationY: 1, + orientationZ: 1 + }); + + src.connect(panner).connect(context.destination); + + src.start(); + + let listener = context.listener; + + // Set listener properties to "random" values so that motion on one of + // the attributes actually changes things relative to the panner + // location. And the up and forward directions should have a simple + // relationship between them. + listener.positionX.value = -1; + listener.positionY.value = 1; + listener.positionZ.value = -1; + listener.forwardX.value = -1; + listener.forwardY.value = 1; + listener.forwardZ.value = -1; + // Make the up vector not parallel or perpendicular to the forward and + // position vectors so that automations of the up vector produce + // noticeable differences. + listener.upX.value = 1; + listener.upY.value = 1; + listener.upZ.value = 2; + + let audioParam = listener[options.param]; + audioParam.automationRate = 'k-rate'; + + let prefix = `Listener ${options.param}`; + should(audioParam.automationRate, prefix + '.automationRate') + .beEqualTo('k-rate'); + should(() => { + audioParam.setValueAtTime(...options.initial); + }, prefix + `.setValueAtTime(${options.initial})`).notThrow(); + should(() => { + audioParam.linearRampToValueAtTime(...options.final); + }, prefix + `.linearRampToValueAtTime(${options.final})`).notThrow(); + + return context.startRendering().then(renderedBuffer => { + let prefix = `Listener k-rate ${options.param}: `; + let output = renderedBuffer.getChannelData(0); + // Sanity check that the output isn't constant. + should(output, prefix + `Output`).notBeConstantValueOf(output[0]); + + // Verify that the output is constant over each render quantum + for (let k = 0; k < output.length; k += 128) { + should( + output.slice(k, k + 128), prefix + `Output [${k}, ${k + 127}]`) + .beConstantValueOf(output[k]); + } + }); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-stereo-panner.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-stereo-panner.html new file mode 100644 index 0000000000..06905b89c3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/k-rate-stereo-panner.html @@ -0,0 +1,48 @@ +<!doctype html> +<html> + <head> + <title>Test k-rate AudioParam of StereoPannerNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="automation-rate-testing.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define('Test k-rate StereoPannerNode', (task, should) => { + // Arbitrary sample rate and duration. + let sampleRate = 8000; + let testDuration = 1; + let context = new OfflineAudioContext({ + numberOfChannels: 3, + sampleRate: sampleRate, + length: testDuration * sampleRate + }); + + doTest(context, should, { + nodeName: 'StereoPannerNode', + nodeOptions: null, + prefix: 'StereoPannerNode', + // Set all AudioParams to k-rate. + rateSettings: [{name: 'pan', value: 'k-rate'}], + // Automate just the frequency. + automations: [{ + name: 'pan', + methods: [ + {name: 'setValueAtTime', options: [0, 0]}, { + name: 'linearRampToValueAtTime', + options: [.5, testDuration] + } + ] + }] + }).then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/moderate-exponentialRamp.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/moderate-exponentialRamp.html new file mode 100644 index 0000000000..cf32d253ae --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/moderate-exponentialRamp.html @@ -0,0 +1,50 @@ +<!DOCTYPE html> +<title>Test exponentialRampToValueAtTime() with a moderate ratio of change</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +'use strict'; + +promise_test(async function() { + const bufferSize = 128; + const sampleRate = 16384; + // 3 * 2^6, not a power of two, so that there is some rounding error in the + // exponent + const rampEndSample = 192; + // These values are chosen so that there is rounding error in + // (offset1/offset0)^(1/rampEndSample). + const offset0 = 4.; + const offset1 = 7.; + // 8 units in the last place (ulp) is a generous tolerance if single + // precision powf() is used. Math.pow(2, -23) ~ 1 ulp. + // A simple recurrence relation formulation with only single precision + // arithmetic applied across the whole rendering quantum would accumulate + // error up to 50 ulp, losing 5.6 of the 24 bits of precision. + const relativeTolerance = 8 * Math.pow(2, -23); + + const context = new OfflineAudioContext(1, bufferSize, sampleRate); + + const source = new ConstantSourceNode(context); + source.start(); + // Explicit event to work around + // https://bugzilla.mozilla.org/show_bug.cgi?id=1265393 + source.offset.setValueAtTime(offset0, 0.); + source.offset.exponentialRampToValueAtTime(offset1, rampEndSample/sampleRate); + source.connect(context.destination); + + const buffer = await context.startRendering(); + assert_equals(buffer.length, bufferSize, "output buffer length"); + const output = buffer.getChannelData(0); + const ratio = offset1 / offset0; + for (let i = 0; i < bufferSize; ++i) { + // Math.pow() uses double precision, while `output` has single precision, + // but `tolerance` is more than enough to accommodate differences. + const expected = offset0 * Math.pow(offset1/offset0, i/rampEndSample); + assert_approx_equals( + output[i], + expected, + relativeTolerance * expected, + "scheduled value at " + i); + } +}); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/nan-param.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/nan-param.html new file mode 100644 index 0000000000..e9b8f0accb --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/nan-param.html @@ -0,0 +1,92 @@ +<!doctype html> +<html> + <head> + <title>Test Flushing of NaN to Zero in AudioParams</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // See + // https://webaudio.github.io/web-audio-api/#computation-of-value. + // + // The computed value must replace NaN values in the output with + // the default value of the param. + audit.define('AudioParam NaN', async (task, should) => { + // For testing, we only need a small number of frames; and + // a low sample rate is perfectly fine. Use two channels. + // The first channel is for the AudioParam output. The + // second channel is for the AudioParam input. + let context = new OfflineAudioContext( + {numberOfChannels: 2, length: 256, sampleRate: 8192}); + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // A constant source with a huge value. + let mod = new ConstantSourceNode(context, {offset: 1e30}); + + // Gain nodes with a huge positive gain and huge negative + // gain. Combined with the huge offset in |mod|, the + // output of the gain nodes are +Infinity and -Infinity. + let gainPos = new GainNode(context, {gain: 1e30}); + let gainNeg = new GainNode(context, {gain: -1e30}); + + mod.connect(gainPos); + mod.connect(gainNeg); + + // Connect these to the second merger channel. This is a + // sanity check that the AudioParam input really is NaN. + gainPos.connect(merger, 0, 1); + gainNeg.connect(merger, 0, 1); + + // Source whose AudioParam is connected to the graph + // that produces NaN values. Use a non-default value offset + // just in case something is wrong we get default for some + // other reason. + let src = new ConstantSourceNode(context, {offset: 100}); + + gainPos.connect(src.offset); + gainNeg.connect(src.offset); + + // AudioParam output goes to channel 1 of the destination. + src.connect(merger, 0, 0); + + // Let's go! + mod.start(); + src.start(); + + let buffer = await context.startRendering(); + + let input = buffer.getChannelData(1); + let output = buffer.getChannelData(0); + + // Have to test manually for NaN values in the input because + // NaN fails all comparisons. + let isNaN = true; + for (let k = 0; k < input.length; ++k) { + if (!Number.isNaN(input[k])) { + isNaN = false; + break; + } + } + + should(isNaN, 'AudioParam input contains only NaN').beTrue(); + + // Output of the AudioParam should have all NaN values + // replaced by the default. + should(output, 'AudioParam output') + .beConstantValueOf(src.offset.defaultValue); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-exponentialRampToValueAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-exponentialRampToValueAtTime.html new file mode 100644 index 0000000000..c81c3ad23e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-exponentialRampToValueAtTime.html @@ -0,0 +1,70 @@ +<!doctype html> +<meta charset=utf-8> +<html> + <head> + <title>Test exponentialRampToValue with end time in the past</title> + <script src=/resources/testharness.js></script> + <script src=/resources/testharnessreport.js></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="retrospective-test.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Test exponentialRampToValue with end time in the past' + }, + (task, should) => { + let {context, source, test, reference} = setupRetrospectiveGraph(); + + // Suspend the context at this frame so we can synchronously set up + // automations. + const suspendFrame = 128; + + context.suspend(suspendFrame / context.sampleRate) + .then(() => { + // Call setTargetAtTime with a time in the past + test.gain.exponentialRampToValueAtTime( + 0.1, 0.5 * context.currentTime); + test.gain.exponentialRampToValueAtTime(0.9, 1.0); + + reference.gain.exponentialRampToValueAtTime( + 0.1, context.currentTime); + reference.gain.exponentialRampToValueAtTime(0.9, 1.0); + }) + .then(() => context.resume()); + + source.start(); + + context.startRendering() + .then(resultBuffer => { + let testValue = resultBuffer.getChannelData(0); + let referenceValue = resultBuffer.getChannelData(1); + + // Until the suspendFrame, both should be exactly equal to 1. + should( + testValue.slice(0, suspendFrame), + `Test[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + should( + referenceValue.slice(0, suspendFrame), + `Reference[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + + // After the suspendFrame, both should be equal (and not + // constant) + should( + testValue.slice(suspendFrame), `Test[${suspendFrame}:]`) + .beEqualToArray(referenceValue.slice(suspendFrame)); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-linearRampToValueAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-linearRampToValueAtTime.html new file mode 100644 index 0000000000..9f5e55fe55 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-linearRampToValueAtTime.html @@ -0,0 +1,70 @@ +<!doctype html> +<meta charset=utf-8> +<html> + <head> + <title>Test linearRampToValue with end time in the past</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="retrospective-test.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Test linearRampToValue with end time in the past' + }, + (task, should) => { + let {context, source, test, reference} = setupRetrospectiveGraph(); + + // Suspend the context at this frame so we can synchronously set up + // automations. + const suspendFrame = 128; + + context.suspend(suspendFrame / context.sampleRate) + .then(() => { + // Call setTargetAtTime with a time in the past + test.gain.linearRampToValueAtTime( + 0.1, 0.5 * context.currentTime); + test.gain.linearRampToValueAtTime(0.9, 1.0); + + reference.gain.linearRampToValueAtTime( + 0.1, context.currentTime); + reference.gain.linearRampToValueAtTime(0.9, 1.0); + }) + .then(() => context.resume()); + + source.start(); + + context.startRendering() + .then(resultBuffer => { + let testValue = resultBuffer.getChannelData(0); + let referenceValue = resultBuffer.getChannelData(1); + + // Until the suspendFrame, both should be exactly equal to 1. + should( + testValue.slice(0, suspendFrame), + `Test[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + should( + referenceValue.slice(0, suspendFrame), + `Reference[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + + // After the suspendFrame, both should be equal (and not + // constant) + should( + testValue.slice(suspendFrame), `Test[${suspendFrame}:]`) + .beEqualToArray(referenceValue.slice(suspendFrame)); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setTargetAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setTargetAtTime.html new file mode 100644 index 0000000000..41a37bdb91 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setTargetAtTime.html @@ -0,0 +1,80 @@ +<!doctype html> +<meta charset=utf-8> +<html> + <head> + <title>Test setTargetAtTime with start time in the past</title> + <script src=/resources/testharness.js></script> + <script src=/resources/testharnessreport.js></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Test setTargetAtTime with start time in the past' + }, + (task, should) => { + // Use a sample rate that is a power of two to eliminate round-off + // in computing the currentTime. + let context = new OfflineAudioContext(2, 16384, 16384); + let source = new ConstantSourceNode(context); + + // Suspend the context at this frame so we can synchronously set up + // automations. + const suspendFrame = 128; + + let test = new GainNode(context); + let reference = new GainNode(context); + + source.connect(test); + source.connect(reference); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + test.connect(merger, 0, 0); + reference.connect(merger, 0, 1); + + merger.connect(context.destination); + + context.suspend(suspendFrame / context.sampleRate) + .then(() => { + // Call setTargetAtTime with a time in the past + test.gain.setTargetAtTime(0.1, 0.5*context.currentTime, 0.1); + reference.gain.setTargetAtTime(0.1, context.currentTime, 0.1); + }) + .then(() => context.resume()); + + source.start(); + + context.startRendering() + .then(resultBuffer => { + let testValue = resultBuffer.getChannelData(0); + let referenceValue = resultBuffer.getChannelData(1); + + // Until the suspendFrame, both should be exactly equal to 1. + should( + testValue.slice(0, suspendFrame), + `Test[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + should( + referenceValue.slice(0, suspendFrame), + `Reference[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + + // After the suspendFrame, both should be equal (and not + // constant) + should( + testValue.slice(suspendFrame), `Test[${suspendFrame}:]`) + .beEqualToArray(referenceValue.slice(suspendFrame)); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setValueAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setValueAtTime.html new file mode 100644 index 0000000000..32cdc6307f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setValueAtTime.html @@ -0,0 +1,74 @@ +<!DOCTYPE html> +<html> + <head> + <title>Test setValueAtTime with startTime in the past</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="retrospective-test.js"></script> + </head> + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Test setValueAtTime with startTime in the past' + }, + (task, should) => { + let {context, source, test, reference} = setupRetrospectiveGraph(); + + // Suspend the context at this frame so we can synchronously set up + // automations. + const suspendFrame = 128; + + // Use a ramp of slope 1 per frame to measure time. + // The end value is the extent of exact precision in single + // precision float. + const rampEnd = context.length - suspendFrame; + const rampEndSeconds = context.length / context.sampleRate; + + context.suspend(suspendFrame / context.sampleRate) + .then(() => { + // Call setValueAtTime with a time in the past + test.gain.setValueAtTime(0.0, 0.5 * context.currentTime); + test.gain.linearRampToValueAtTime(rampEnd, rampEndSeconds); + + reference.gain.setValueAtTime(0.0, context.currentTime); + reference.gain.linearRampToValueAtTime( + rampEnd, rampEndSeconds); + }) + .then(() => context.resume()); + + source.start(); + + context.startRendering() + .then(resultBuffer => { + let testValue = resultBuffer.getChannelData(0); + let referenceValue = resultBuffer.getChannelData(1); + + // Until the suspendFrame, both should be exactly equal to 1. + should( + testValue.slice(0, suspendFrame), + `Test[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + should( + referenceValue.slice(0, suspendFrame), + `Reference[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + + // After the suspendFrame, both should be equal (and not + // constant) + should( + testValue.slice(suspendFrame), `Test[${suspendFrame}:]`) + .beEqualToArray(referenceValue.slice(suspendFrame)); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setValueCurveAtTime.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setValueCurveAtTime.html new file mode 100644 index 0000000000..451b6ea829 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-setValueCurveAtTime.html @@ -0,0 +1,67 @@ +<!doctype html> +<html> + <head> + <title>Test SetValueCurve with start time in the past</title> + <script src=/resources/testharness.js></script> + <script src=/resources/testharnessreport.js></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="retrospective-test.js"></script> + </head> + </body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Test SetValueCurve with start time in the past' + }, + (task, should) => { + let {context, source, test, reference} = setupRetrospectiveGraph(); + + // Suspend the context at this frame so we can synchronously set up + // automations. + const suspendFrame = 128; + + context.suspend(suspendFrame / context.sampleRate) + .then(() => { + // Call setValueAtTime with a time in the past + test.gain.setValueCurveAtTime( + new Float32Array([1.0, 0.1]), 0.5 * context.currentTime, + 1.0); + reference.gain.setValueCurveAtTime( + new Float32Array([1.0, 0.1]), context.currentTime, 1.0); + }) + .then(() => context.resume()); + + source.start(); + + context.startRendering() + .then(resultBuffer => { + let testValue = resultBuffer.getChannelData(0); + let referenceValue = resultBuffer.getChannelData(1); + + // Until the suspendFrame, both should be exactly equal to 1. + should( + testValue.slice(0, suspendFrame), + `Test[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + should( + referenceValue.slice(0, suspendFrame), + `Reference[0:${suspendFrame - 1}]`) + .beConstantValueOf(1); + + // After the suspendFrame, both should be equal (and not + // constant) + should( + testValue.slice(suspendFrame), `Test[${suspendFrame}:]`) + .beEqualToArray(referenceValue.slice(suspendFrame)); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-test.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-test.js new file mode 100644 index 0000000000..bbda190f09 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/retrospective-test.js @@ -0,0 +1,29 @@ +// Create an audio graph on an offline context that consists of a +// constant source and two gain nodes. One of the nodes is the node te +// be tested and the other is the reference node. The output from the +// test node is in channel 0 of the offline context; the reference +// node is in channel 1. +// +// Returns a dictionary with the context, source node, the test node, +// and the reference node. +function setupRetrospectiveGraph() { + // Use a sample rate that is a power of two to eliminate round-off + // in computing the currentTime. + let context = new OfflineAudioContext(2, 16384, 16384); + let source = new ConstantSourceNode(context); + + let test = new GainNode(context); + let reference = new GainNode(context); + + source.connect(test); + source.connect(reference); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + test.connect(merger, 0, 0); + reference.connect(merger, 0, 1); + + merger.connect(context.destination); + + return {context: context, source: source, test: test, reference: reference}; +} diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/set-target-conv.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/set-target-conv.html new file mode 100644 index 0000000000..2ed076cccf --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/set-target-conv.html @@ -0,0 +1,93 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> +<html> + <head> + <title>Test convergence of setTargetAtTime</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src='/webaudio/resources/audio-param.js'></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + {task: 'setTargetAtTime', label: 'convergence handled correctly'}, + (task, should) => { + // Two channels: + // 0 - actual result + // 1 - expected result + const context = new OfflineAudioContext( + {numberOfChannels: 2, sampleRate: 8000, length: 8000}); + + const merger = new ChannelMergerNode( + context, {numberOfChannels: context.destination.channelCount}); + merger.connect(context.destination); + + // Construct test source that will have tha AudioParams being tested + // to verify that the AudioParams are working correctly. + let src; + + should( + () => src = new ConstantSourceNode(context), + 'src = new ConstantSourceNode(context)') + .notThrow(); + + src.connect(merger, 0, 0); + src.offset.setValueAtTime(1, 0); + + const timeConstant = 0.01; + + // testTime must be at least 10*timeConstant. Also, this must not + // lie on a render boundary. + const testTime = 0.15; + const rampEnd = testTime + 0.001; + + should( + () => src.offset.setTargetAtTime(0.5, 0.01, timeConstant), + `src.offset.setTargetAtTime(0.5, 0.01, ${timeConstant})`) + .notThrow(); + should( + () => src.offset.setValueAtTime(0.5, testTime), + `src.offset.setValueAtTime(0.5, ${testTime})`) + .notThrow(); + should( + () => src.offset.linearRampToValueAtTime(1, rampEnd), + `src.offset.linearRampToValueAtTime(1, ${rampEnd})`) + .notThrow(); + + // The reference node that will generate the expected output. We do + // the same automations, except we don't apply the setTarget + // automation. + const refSrc = new ConstantSourceNode(context); + refSrc.connect(merger, 0, 1); + + refSrc.offset.setValueAtTime(0.5, 0); + refSrc.offset.setValueAtTime(0.5, testTime); + refSrc.offset.linearRampToValueAtTime(1, rampEnd); + + src.start(); + refSrc.start(); + + context.startRendering() + .then(audio => { + const actual = audio.getChannelData(0); + const expected = audio.getChannelData(1); + + // Just verify that the actual output matches the expected + // starting a little bit before testTime. + let testFrame = + Math.floor(testTime * context.sampleRate) - 128; + should(actual.slice(testFrame), `output[${testFrame}:]`) + .beCloseToArray( + expected.slice(testFrame), + {relativeThreshold: 4.1724e-6}); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/setTargetAtTime-after-event-within-block.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/setTargetAtTime-after-event-within-block.html new file mode 100644 index 0000000000..ca02b0db97 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/setTargetAtTime-after-event-within-block.html @@ -0,0 +1,99 @@ +<!DOCTYPE html> +<title>Test setTargetAtTime after an event in the same processing block</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +promise_test(function() { + const bufferSize = 179; + const valueStartOffset = 42; + const targetStartOffset = 53; + const sampleRate = 48000; + const scheduledValue = -0.5; + + var context = new OfflineAudioContext(1, bufferSize, sampleRate); + + var gain = context.createGain(); + gain.gain.setValueAtTime(scheduledValue, valueStartOffset/sampleRate); + gain.gain.setTargetAtTime(scheduledValue, targetStartOffset/sampleRate, + 128/sampleRate); + gain.connect(context.destination); + + // Apply unit DC signal to gain node. + var source = context.createBufferSource(); + source.buffer = + function() { + var buffer = context.createBuffer(1, 1, context.sampleRate); + buffer.getChannelData(0)[0] = 1.0; + return buffer; + }(); + source.loop = true; + source.start(); + source.connect(gain); + + return context.startRendering(). + then(function(buffer) { + assert_equals(buffer.length, bufferSize, "output buffer length"); + var output = buffer.getChannelData(0); + var i = 0; + for (; i < valueStartOffset; ++i) { + // "Its default value is 1." + assert_equals(output[i], 1.0, "default gain at sample " + i); + } + for (; i < buffer.length; ++i) { + // "If the next event (having time T1) after this SetValue event is + // not of type LinearRampToValue or ExponentialRampToValue, then, for + // T0≤t<T1: v(t)=V". + // "Start exponentially approaching the target value at the given time + // with a rate having the given time constant." + // The target is the same value, and so the SetValue value continues. + assert_equals(output[i], scheduledValue, + "scheduled value at sample " + i); + } + }); +}, "setTargetAtTime() after setValueAtTime()"); + +promise_test(async function() { + const bufferSize = 129; + const sampleRate = 16384; + const startSample1 = 125; + const target1 = Math.fround(-1./Math.expm1(-1.)); + // Intentionally testing the second curve before and after the + // rendering quantum boundary. + const startSample2 = startSample1 + 1; + const target2 = 0.; + const timeConstant = 1./sampleRate; + const tolerance = Math.pow(2, -24); // Allow single precision math. + const context = new OfflineAudioContext(1, bufferSize, sampleRate); + + const source = new ConstantSourceNode(context, {offset: 0.}); + source.start(); + source.offset.setTargetAtTime(target1, startSample1/sampleRate, + timeConstant); + source.offset.setTargetAtTime(target2, startSample2/sampleRate, + timeConstant); + source.connect(context.destination); + + const buffer = await context.startRendering(); + + assert_equals(buffer.length, bufferSize, "output buffer length"); + const output = buffer.getChannelData(0); + for (let i = 0; i <= startSample1; ++i) { + assert_equals(output[i], 0., "initial offset at sample " + i); + } + assert_approx_equals( + output[startSample2], + Math.fround(target1 * -Math.expm1(-(startSample2 - startSample1))), + tolerance, + "scheduled value at startSample2"); + assert_approx_equals( + output[startSample2 + 1], + Math.fround(output[startSample2] * Math.exp(-1.)), + tolerance, + "scheduled value at startSample2 + 1"); + assert_approx_equals( + output[startSample2 + 2], + Math.fround(output[startSample2] * Math.exp(-2.)), + tolerance, + "scheduled value at startSample2 + 2"); +}, "setTargetAtTime() after setTargetAtTime()"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/setValueAtTime-within-block.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/setValueAtTime-within-block.html new file mode 100644 index 0000000000..36fde2b996 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioparam-interface/setValueAtTime-within-block.html @@ -0,0 +1,48 @@ +<!DOCTYPE html> +<title>Test setValueAtTime with start time not on a block boundary</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +promise_test(function() { + const bufferSize = 200; + const offset = 65; + const sampleRate = 48000; + const scheduledValue = -2.0; + + var context = new OfflineAudioContext(1, bufferSize, sampleRate); + + var gain = context.createGain(); + gain.gain.setValueAtTime(scheduledValue, offset/sampleRate); + gain.connect(context.destination); + + // Apply unit DC signal to gain node. + var source = context.createBufferSource(); + source.buffer = + function() { + var buffer = context.createBuffer(1, 1, context.sampleRate); + buffer.getChannelData(0)[0] = 1.0; + return buffer; + }(); + source.loop = true; + source.start(); + source.connect(gain); + + return context.startRendering(). + then(function(buffer) { + assert_equals(buffer.length, bufferSize, "output buffer length"); + var output = buffer.getChannelData(0); + var i = 0; + for (; i < offset; ++i) { + // "Its default value is 1." + assert_equals(output[i], 1.0, "default gain at sample " + i); + } + for (; i < buffer.length; ++i) { + // "If there are no more events after this SetValue event, then for + // t≥T0, v(t)=V, where T0 is the startTime parameter and V is the + // value parameter." + assert_equals(output[i], scheduledValue, + "scheduled value at sample " + i); + } + }); +}); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-addmodule-resolution.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-addmodule-resolution.https.html new file mode 100644 index 0000000000..dc324b22d6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-addmodule-resolution.https.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test the invocation order of AudioWorklet.addModule() and BaseAudioContext + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + setup(() => { + let sampleRate = 48000; + let realtimeContext = new AudioContext(); + let offlineContext = new OfflineAudioContext(1, sampleRate, sampleRate); + + let filePath = 'processors/dummy-processor.js'; + + // Test if the browser does not crash upon addModule() call after the + // realtime context construction. + audit.define( + {label: 'module-loading-after-realtime-context-creation'}, + (task, should) => { + let dummyWorkletNode = + new AudioWorkletNode(realtimeContext, 'dummy'); + dummyWorkletNode.connect(realtimeContext.destination); + should(dummyWorkletNode instanceof AudioWorkletNode, + '"dummyWorkletNode" is an instance of AudioWorkletNode ' + + 'from realtime context') + .beTrue(); + task.done(); + }); + + // Test if the browser does not crash upon addModule() call after the + // offline context construction. + audit.define( + {label: 'module-loading-after-offline-context-creation'}, + (task, should) => { + let dummyWorkletNode = + new AudioWorkletNode(offlineContext, 'dummy'); + dummyWorkletNode.connect(offlineContext.destination); + should(dummyWorkletNode instanceof AudioWorkletNode, + '"dummyWorkletNode" is an instance of AudioWorkletNode ' + + 'from offline context') + .beTrue(); + task.done(); + }); + + Promise.all([ + realtimeContext.audioWorklet.addModule(filePath), + offlineContext.audioWorklet.addModule(filePath) + ]).then(() => { + audit.run(); + }); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam-iterable.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam-iterable.https.html new file mode 100644 index 0000000000..9e93f48ab8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam-iterable.https.html @@ -0,0 +1,205 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title> + Test get parameterDescriptor as various iterables + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/js/helpers.js"></script> + </head> + + <body> + <script id="params"> + // A series of AudioParamDescriptors, copied one by one into various iterable + // data structures. This is used by both the processor side and the main + // thread side, so is in a different script tag. + const PARAMS = [ + { + name: "a control-rate parameter", + defaultValue: 0.5, + minValue: 0, + maxValue: 1, + automationRate: "a-rate", + }, + { + name: "ä½ å¥½", + defaultValue: 2.5, + minValue: 0, + maxValue: 7, + automationRate: "a-rate", + }, + { + name: "🎶", + defaultValue: 8.5, + minValue: 0, + maxValue: 11115, + automationRate: "k-rate", + }, + ]; + </script> + <script id="processors" type="worklet"> + registerProcessor("set", + class SetParamProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + var s = new Set(); + s.add(PARAMS[0]); + s.add(PARAMS[1]); + s.add(PARAMS[2]); + return s; + } + constructor() { super(); } + process() { + } + }); + + registerProcessor("array", + class ArrayParamProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return PARAMS; + } + constructor() { super(); } + process() { } + }); + + function* gen() { + yield PARAMS[0]; + yield PARAMS[1]; + yield PARAMS[2]; + } + registerProcessor("generator", + class GeneratorParamProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return gen(); + } + constructor() { super(); } + process() { } + }); + // Test a processor that has a get parameterDescriptors, but it returns + // something that is not iterable. + try { + registerProcessor("invalid", + class InvalidParamProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return 4; + } + constructor() { super(); } + process() { } + }); + throw "This should not have been reached."; + } catch (e) { + // unclear how to signal success here, but we can signal failure in the + // developer console + if (e.name != "TypeError") { + throw "This should be TypeError"; + } + } + // Test a processor that has a get parameterDescriptors, with a duplicate + // param name something that is not iterable. + try { + registerProcessor("duplicate-param-name", + class DuplicateParamProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + var p = { + name: "a", + defaultValue: 1, + minValue: 0, + maxValue: 1, + automationRate: "k-rate", + }; + return [p,p]; + } + constructor() { super(); } + process() { } + }); + throw "This should not have been reached."; + } catch (e) { + // unclear how to signal success here, but we can signal failure in the + // developer console + if (e.name != "NotSupportedError") { + throw "This should be NotSupportedError"; + } + } + // Test a processor that has a no get parameterDescriptors. + try { + registerProcessor("no-params", + class NoParamProcessor extends AudioWorkletProcessor { + constructor() { super(); } + process() { } + }); + } catch (e) { + throw "Construction should have worked."; + } + </script> + <script> + setup({ explicit_done: true }); + // Mangle the PARAMS object into a map that has the same shape as what an + // AudioWorkletNode.parameter property would + var PARAMS_MAP = new Map(); + for (var param of PARAMS) { + var o = param; + var name = o.name; + delete o.name; + PARAMS_MAP.set(name, o); + } + + // This compares `lhs` and `rhs`, that are two maplike with the same shape + // as PARAMS_MAP. + function compare(testname, lhs, rhs) { + equals(lhs.size, rhs.size, "Map match in size for " + testname); + var i = 0; + for (var [k, v] of lhs) { + is_true(rhs.has(k), testname + ": " + k + " exists in both maps"); + var vrhs = rhs.get(k); + ["defaultValue", "minValue", "maxValue", "automationRate"].forEach( + paramKey => { + equals( + v[paramKey], + vrhs[paramKey], + `Values for ${k}.${paramKey} match for ${testname}` + ); + } + ); + } + } + var ac = new AudioContext(); + var url = URLFromScriptsElements(["params", "processors"]); + ac.audioWorklet + .addModule(url) + .then(() => { + ["set", "array", "generator"].forEach(iterable => { + test(() => { + var node = new AudioWorkletNode(ac, iterable); + compare(iterable, node.parameters, PARAMS_MAP); + }, `Creating an AudioWorkletNode with a ${iterable} for + parameter descriptor worked`); + }); + }) + .then(function() { + test(function() { + assert_throws_dom("InvalidStateError", function() { + new AudioWorkletNode(ac, "invalid"); + }); + }, `Attempting to create an AudioWorkletNode with an non + iterable for parameter descriptor should not work`); + }) + .then(function() { + test(() => { + new AudioWorkletNode(ac, "no-params"); + }, `Attempting to create an AudioWorkletNode from a processor + that does not have a parameterDescriptors getter should work`); + }) + .then(function() { + test(function() { + assert_throws_dom("InvalidStateError", function() { + new AudioWorkletNode(ac, "duplicate-param-name"); + }); + }, `Attempting to create an AudioWorkletNode with two parameter + descriptor with the same name should not work`); + }).then(function() { + done(); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam-size.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam-size.https.html new file mode 100644 index 0000000000..9578b26881 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam-size.https.html @@ -0,0 +1,96 @@ +<!doctype html> +<html> + <head> + <title> + Test AudioParam Array Size + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + let filePath = 'processors/param-size-processor.js'; + let context; + + // Use a power of two so there's no roundoff computing times from frames. + let sampleRate = 16384; + + // Sets up AudioWorklet and OfflineAudioContext. + audit.define('Initializing AudioWorklet and Context', (task, should) => { + should(() => { + context = new OfflineAudioContext( + 1, 10 * RENDER_QUANTUM_FRAMES, sampleRate); + }, 'Creating offline context for testing').notThrow(); + + should( + context.audioWorklet.addModule(filePath), 'Creating test worklet') + .beResolved() + .then(() => { + task.done(); + }); + }); + + audit.define('Verify Size of AudioParam Arrays', (task, should) => { + let node = new AudioWorkletNode(context, 'param-size'); + let nodeParam = node.parameters.get('param'); + + node.connect(context.destination); + + let renderQuantumDuration = RENDER_QUANTUM_FRAMES / context.sampleRate; + + // Set up some automations, after one render quantum. We want the first + // render not to have any automations, just to be sure we handle that + // case correctly. + context.suspend(renderQuantumDuration) + .then(() => { + let now = context.currentTime; + + // Establish the first automation event. + nodeParam.setValueAtTime(1, now); + // The second render should be constant + nodeParam.setValueAtTime(0, now + renderQuantumDuration); + // The third render and part of the fourth is a linear ramp + nodeParam.linearRampToValueAtTime( + 1, now + 2.5 * renderQuantumDuration); + // Everything afterwards should be constant. + }) + .then(() => context.resume()); + + context.startRendering() + .then(renderedBuffer => { + let data = renderedBuffer.getChannelData(0); + + // The very first render quantum should be constant, so the array + // has length 1. + should( + data.slice(0, RENDER_QUANTUM_FRAMES), + 'Render quantum 0: array size') + .beConstantValueOf(1); + + should( + data.slice(RENDER_QUANTUM_FRAMES, 2 * RENDER_QUANTUM_FRAMES), + 'Render quantum 1: array size') + .beConstantValueOf(1); + + should( + data.slice( + 2 * RENDER_QUANTUM_FRAMES, 4 * RENDER_QUANTUM_FRAMES), + 'Render quantum 2-3: array size') + .beConstantValueOf(RENDER_QUANTUM_FRAMES); + + should( + data.slice(4 * RENDER_QUANTUM_FRAMES), + 'Remaining renders: array size') + .beConstantValueOf(1); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam.https.html new file mode 100644 index 0000000000..8e51470f64 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-audioparam.https.html @@ -0,0 +1,85 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioWorkletNode's basic AudioParam features + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 48000; + let renderLength = 48000 * 0.6; + let context; + + let filePath = 'processors/gain-processor.js'; + + // Sets up AudioWorklet and OfflineAudioContext. + audit.define('Initializing AudioWorklet and Context', (task, should) => { + context = new OfflineAudioContext(1, renderLength, sampleRate); + context.audioWorklet.addModule(filePath).then(() => { + task.done(); + }); + }); + + // Verifies the functionality of AudioParam in AudioWorkletNode by + // comparing (canceling out) values from GainNode and AudioWorkletNode + // with simple gain computation code by AudioParam. + audit.define( + 'Verifying AudioParam in AudioWorkletNode', + (task, should) => { + let constantSourceNode = new ConstantSourceNode(context); + let gainNode = new GainNode(context); + let inverterNode = new GainNode(context, {gain: -1}); + let gainWorkletNode = new AudioWorkletNode(context, 'gain'); + let gainWorkletParam = gainWorkletNode.parameters.get('gain'); + + // Test default value and setter/getter functionality. + should(gainWorkletParam.value, + 'Default gain value of gainWorkletNode') + .beEqualTo(Math.fround(0.707)); + gainWorkletParam.value = 0.1; + should(gainWorkletParam.value, + 'Value of gainWorkletParam after setter = 0.1') + .beEqualTo(Math.fround(0.1)); + + constantSourceNode.connect(gainNode) + .connect(inverterNode) + .connect(context.destination); + constantSourceNode.connect(gainWorkletNode) + .connect(context.destination); + + // With arbitrary times and values, test all possible AudioParam + // automations. + [gainNode.gain, gainWorkletParam].forEach((param) => { + param.setValueAtTime(0, 0); + param.linearRampToValueAtTime(1, 0.1); + param.exponentialRampToValueAtTime(0.5, 0.2); + param.setValueCurveAtTime([0, 2, 0.3], 0.2, 0.1); + param.setTargetAtTime(0.01, 0.4, 0.5); + }); + + // Test if the setter works correctly in the middle of rendering. + context.suspend(0.5).then(() => { + gainNode.gain.value = 1.5; + gainWorkletParam.value = 1.5; + context.resume(); + }); + + constantSourceNode.start(); + context.startRendering().then((renderedBuffer) => { + should(renderedBuffer.getChannelData(0), + 'The rendered buffer') + .beConstantValueOf(0); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-denormals.https.window.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-denormals.https.window.js new file mode 100644 index 0000000000..39b9be56e6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-denormals.https.window.js @@ -0,0 +1,26 @@ +'use strict'; + +// Test if the JS code execution in AudioWorkletGlobalScope can handle the +// denormals properly. For more details, see: +// https://esdiscuss.org/topic/float-denormal-issue-in-javascript-processor-node-in-web-audio-api +promise_test(async () => { + // In the main thread, the denormals should be non-zeros. + assert_not_equals(Number.MIN_VALUE, 0.0, + 'The denormals should be non-zeros.'); + + const context = new AudioContext(); + await context.audioWorklet.addModule( + './processors/denormal-test-processor.js'); + + const denormalTestProcessor = new AudioWorkletNode(context, 'denormal-test'); + + return new Promise(resolve => { + denormalTestProcessor.port.onmessage = resolve; + denormalTestProcessor.connect(context.destination); + }).then(event => { + // In the AudioWorkletGlobalScope, the denormals should be non-zeros too. + assert_true( + event.data.result, + 'The denormals should be non-zeros in AudioWorkletGlobalScope.'); + }); +}, 'Test denormal behavior in AudioWorkletGlobalScope'); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-messageport.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-messageport.https.html new file mode 100644 index 0000000000..546bd1d0d0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-messageport.https.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test MessagePort in AudioWorkletNode and AudioWorkletProcessor + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let context = new AudioContext(); + + let filePath = 'processors/port-processor.js'; + + // Creates an AudioWorkletNode and sets an EventHandler on MessagePort + // object. The associated PortProcessor will post a message upon its + // construction. Test if the message is received correctly. + audit.define( + 'Test postMessage from AudioWorkletProcessor to AudioWorkletNode', + (task, should) => { + let porterWorkletNode = + new AudioWorkletNode(context, 'port-processor'); + + // Upon the creation of PortProcessor, it will post a message to the + // node with 'created' status. + porterWorkletNode.port.onmessage = (event) => { + should(event.data.state, + 'The initial message from PortProcessor') + .beEqualTo('created'); + task.done(); + }; + }); + + // PortProcessor is supposed to echo the message back to the + // AudioWorkletNode. + audit.define( + 'Test postMessage from AudioWorkletNode to AudioWorkletProcessor', + (task, should) => { + let porterWorkletNode = + new AudioWorkletNode(context, 'port-processor'); + + porterWorkletNode.port.onmessage = (event) => { + // Ignore if the delivered message has |state|. This is already + // tested in the previous task. + if (event.data.state) + return; + + should(event.data.message, + 'The response from PortProcessor') + .beEqualTo('hello'); + task.done(); + }; + + porterWorkletNode.port.postMessage('hello'); + }); + + context.audioWorklet.addModule(filePath).then(() => { + audit.run(); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-postmessage-sharedarraybuffer.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-postmessage-sharedarraybuffer.https.html new file mode 100644 index 0000000000..a5dd004981 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-postmessage-sharedarraybuffer.https.html @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test passing SharedArrayBuffer to an AudioWorklet + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let context = new AudioContext(); + + let filePath = 'processors/sharedarraybuffer-processor.js'; + + audit.define( + 'Test postMessage from AudioWorkletProcessor to AudioWorkletNode', + (task, should) => { + let workletNode = + new AudioWorkletNode(context, 'sharedarraybuffer-processor'); + + // After it is created, the worklet will send a new + // SharedArrayBuffer to the main thread. + // + // The worklet will then wait to receive a message from the main + // thread. + // + // When it receives the message, it will check whether it is a + // SharedArrayBuffer, and send this information back to the main + // thread. + + workletNode.port.onmessage = (event) => { + let data = event.data; + switch (data.state) { + case 'created': + should( + data.sab instanceof SharedArrayBuffer, + 'event.data.sab from worklet is an instance of SharedArrayBuffer') + .beTrue(); + + // Send a SharedArrayBuffer back to the worklet. + let sab = new SharedArrayBuffer(8); + workletNode.port.postMessage(sab); + break; + + case 'received message': + should(data.isSab, 'event.data from main thread is an instance of SharedArrayBuffer') + .beTrue(); + task.done(); + break; + + default: + should(false, + `Got unexpected message from worklet: ${data.state}`) + .beTrue(); + task.done(); + break; + } + }; + + workletNode.port.onmessageerror = (event) => { + should(false, 'Got messageerror from worklet').beTrue(); + task.done(); + }; + }); + + context.audioWorklet.addModule(filePath).then(() => { + audit.run(); + }); + </script> + </body> +</html> + diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-postmessage-sharedarraybuffer.https.html.headers b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-postmessage-sharedarraybuffer.https.html.headers new file mode 100644 index 0000000000..63b60e490f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-postmessage-sharedarraybuffer.https.html.headers @@ -0,0 +1,2 @@ +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-called-on-globalthis.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-called-on-globalthis.https.html new file mode 100644 index 0000000000..718cadffc7 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-called-on-globalthis.https.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioWorkletGlobalScope's registerProcessor() called on globalThis + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + const realtimeContext = new AudioContext(); + const filePath = 'processors/dummy-processor-globalthis.js'; + + audit.define('registerprocessor-called-on-globalthis', (task, should) => { + realtimeContext.audioWorklet.addModule(filePath).then(() => { + const dummyWorkletNode = new AudioWorkletNode(realtimeContext, 'dummy-globalthis'); + should(dummyWorkletNode instanceof AudioWorkletNode, + '"dummyWorkletNode" is an instance of AudioWorkletNode').beTrue(); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-constructor.https.window.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-constructor.https.window.js new file mode 100644 index 0000000000..679480b480 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-constructor.https.window.js @@ -0,0 +1,33 @@ +'use strict'; + +// https://crbug.com/1078902: this test verifies two TypeError cases from +// registerProcessor() method: +// - When a given parameter is not a Function. +// - When a given parameter is not a constructor. +const TestDescriptions = [ + 'The parameter should be of type "Function".', + 'The class definition of AudioWorkletProcessor should be a constructor.' +]; + +// See `register-processor-exception.js` file for the test details. +promise_test(async () => { + const context = new AudioContext(); + await context.audioWorklet.addModule( + './processors/register-processor-typeerrors.js'); + const messenger = new AudioWorkletNode(context, 'messenger-processor'); + + return new Promise(resolve => { + let testIndex = 0; + messenger.port.onmessage = (event) => { + const exception = event.data; + assert_equals(exception.name, 'TypeError', + TestDescriptions[testIndex]); + if (++testIndex === TestDescriptions.length) { + resolve(); + } + }; + + // Start the test on AudioWorkletGlobalScope. + messenger.port.postMessage({}); + }); +}, 'Verifies two TypeError cases from registerProcessor() method.'); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-dynamic.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-dynamic.https.html new file mode 100644 index 0000000000..de31f71427 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-registerprocessor-dynamic.https.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test dynamic registerProcessor() calls in AudioWorkletGlobalScope + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + const t = async_test('Dynamic registration in AudioWorkletGlobalScope'); + + const realtimeContext = new AudioContext(); + const filePath = 'processors/dynamic-register-processor.js'; + + // Test if registering an AudioWorkletProcessor dynamically (after the + // initial module script loading) works correctly. In the construction of + // nodeB (along with ProcessorB), it registers ProcessorA's definition. + realtimeContext.audioWorklet.addModule(filePath).then(() => { + const nodeB = new AudioWorkletNode(realtimeContext, 'ProcessorB'); + assert_true(nodeB instanceof AudioWorkletNode, + 'nodeB should be instance of AudioWorkletNode'); + nodeB.port.postMessage({}); + nodeB.port.onmessage = () => { + const nodeA = new AudioWorkletNode(realtimeContext, 'ProcessorA'); + t.step(() => { + assert_true(nodeA instanceof AudioWorkletNode, + 'nodeA should be instance of AudioWorkletNode'); + }); + t.done(); + }; + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-suspend.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-suspend.https.html new file mode 100644 index 0000000000..685546aeb5 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-suspend.https.html @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test if activation of worklet thread does not resume context rendering. + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + const context = new AudioContext(); + const filePath = 'processors/dummy-processor.js'; + + context.suspend(); + + // Suspends the context right away and then activate worklet. The current + // time must not advance since the context is suspended. + audit.define( + {label: 'load-worklet-and-suspend'}, + async (task, should) => { + await context.audioWorklet.addModule(filePath); + const suspendTime = context.currentTime; + const dummy = new AudioWorkletNode(context, 'dummy'); + dummy.connect(context.destination); + return task.timeout(() => { + should(context.currentTime === suspendTime, + 'context.currentTime did not change after worklet started') + .beTrue(); + should(context.state, 'context.state').beEqualTo('suspended'); + }, 500); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-throw-onmessage.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-throw-onmessage.https.html new file mode 100644 index 0000000000..3a480464e9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworklet-throw-onmessage.https.html @@ -0,0 +1,62 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title> + Test the behaviour of AudioWorkletProcessor when an `onmessage` handler + throws. + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/js/helpers.js"></script> + </head> + + <body> + <script id="processor" type="worklet"> + registerProcessor("test-throw", class param extends AudioWorkletProcessor { + constructor() { + super() + this.i = 0; + this.port.onmessage = function(arg) { + throw "asdasd"; + } + } + process(input, output, parameters) { + this.i++; + this.port.postMessage(this.i); + return true; + } + }); + </script> + <script> + var latestIndexReceived = 0; + var node = null; + var ac = null; + promise_setup(function() { + ac = new AudioContext(); + var url = URLFromScriptsElements(["processor"]); + return ac.audioWorklet.addModule(url).then(function() { + node = new AudioWorkletNode(ac, "test-throw"); + node.port.onmessage = function(e) { + latestIndexReceived = parseInt(e.data); + }; + }); + }); + promise_test(async t => { + var currentIndex = latestIndexReceived; + await t.step_wait(() => { + return latestIndexReceived > currentIndex; + }, "Process is still being called"); + + node.port.postMessage("asdasd"); // This throws on the processor side. + node.onprocessorerror = function() { + assert_true(false, "onprocessorerror must not be called."); + }; + currentIndex = latestIndexReceived; + await t.step_wait(() => { + return latestIndexReceived > currentIndex + 2; + }, "Process is still being called"); + }, `Throwing in an onmessage handler in the AudioWorkletGlobalScope shouldn't stop AudioWorkletProcessor`); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletglobalscope-sample-rate.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletglobalscope-sample-rate.https.html new file mode 100644 index 0000000000..84458d0aaa --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletglobalscope-sample-rate.https.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test sampleRate in AudioWorkletGlobalScope + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + setup(() => { + let sampleRate = 48000; + let renderLength = 512; + let context = new OfflineAudioContext(1, renderLength, sampleRate); + + let filePath = 'processors/one-pole-processor.js'; + + // Without rendering the context, attempt to access |sampleRate| in the + // global scope as soon as it is created. + audit.define( + 'Query |sampleRate| upon AudioWorkletGlobalScope construction', + (task, should) => { + let onePoleFilterNode = + new AudioWorkletNode(context, 'one-pole-filter'); + let frequencyParam = onePoleFilterNode.parameters.get('frequency'); + + should(frequencyParam.maxValue, + 'frequencyParam.maxValue') + .beEqualTo(0.5 * context.sampleRate); + + task.done(); + }); + + context.audioWorklet.addModule(filePath).then(() => { + audit.run(); + }); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletglobalscope-timing-info.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletglobalscope-timing-info.https.html new file mode 100644 index 0000000000..5f4bee7c53 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletglobalscope-timing-info.https.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test currentTime and currentFrame in AudioWorkletGlobalScope + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + setup(() => { + let sampleRate = 48000; + let renderLength = 512; + let context = new OfflineAudioContext(1, renderLength, sampleRate); + + let filePath = 'processors/timing-info-processor.js'; + + audit.define( + 'Check the timing information from AudioWorkletProcessor', + (task, should) => { + let portWorkletNode = + new AudioWorkletNode(context, 'timing-info-processor'); + portWorkletNode.connect(context.destination); + + // Suspend at render quantum boundary and check the timing + // information between the main thread and the rendering thread. + [0, 128, 256, 384].map((suspendFrame) => { + context.suspend(suspendFrame/sampleRate).then(() => { + portWorkletNode.port.onmessage = (event) => { + should(event.data.currentFrame, + 'currentFrame from the processor at ' + suspendFrame) + .beEqualTo(suspendFrame); + should(event.data.currentTime, + 'currentTime from the processor at ' + + context.currentTime) + .beEqualTo(context.currentTime); + context.resume(); + }; + + portWorkletNode.port.postMessage('query-timing-info'); + }); + }); + + context.startRendering().then(() => { + task.done(); + }); + }); + + context.audioWorklet.addModule(filePath).then(() => { + audit.run(); + }); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-automatic-pull.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-automatic-pull.https.html new file mode 100644 index 0000000000..330b359f7d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-automatic-pull.https.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioWorkletNode's automatic pull feature + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + + // Arbitrary sample rate. Anything should work. + const sampleRate = 48000; + const renderLength = RENDER_QUANTUM_FRAMES * 2; + const channelCount = 1; + const filePath = 'processors/zero-output-processor.js'; + + const sourceOffset = 0.5; + + // Connect a constant source node to the zero-output AudioWorkletNode. + // Then verify if it captures the data correctly. + audit.define('setup-worklet', (task, should) => { + const context = + new OfflineAudioContext(channelCount, renderLength, sampleRate); + + context.audioWorklet.addModule(filePath).then(() => { + let testSource = + new ConstantSourceNode(context, { offset: sourceOffset }); + let zeroOutputWorkletNode = + new AudioWorkletNode(context, 'zero-output-processor', { + numberOfInputs: 1, + numberOfOutputs: 0, + processorOptions: { + bufferLength: renderLength, + channeCount: channelCount + } + }); + + // Start the source and stop at the first render quantum. + testSource.connect(zeroOutputWorkletNode); + testSource.start(); + testSource.stop(RENDER_QUANTUM_FRAMES/sampleRate); + + zeroOutputWorkletNode.port.onmessage = (event) => { + // The |capturedBuffer| can be multichannel. Iterate through it. + for (let i = 0; i < event.data.capturedBuffer.length; ++i) { + let buffer = event.data.capturedBuffer[i]; + // Split the captured buffer in half for the easier test. + should(buffer.subarray(0, RENDER_QUANTUM_FRAMES), + 'The first half of the captured buffer') + .beConstantValueOf(sourceOffset); + should(buffer.subarray(RENDER_QUANTUM_FRAMES, renderLength), + 'The second half of the captured buffer') + .beConstantValueOf(0); + } + task.done(); + }; + + // Starts the rendering, but we don't need the rendered buffer from + // the context. + context.startRendering(); + }); + }); + + audit.run(); + </script> + </body> +</html> + diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-channel-count.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-channel-count.https.html new file mode 100644 index 0000000000..11c237f19d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-channel-count.https.html @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioWorkletNode's dynamic channel count feature + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Arbitrary numbers used to align the test with render quantum boundary. + let sampleRate = RENDER_QUANTUM_FRAMES * 100; + let renderLength = RENDER_QUANTUM_FRAMES * 2; + let context; + + let filePath = 'processors/gain-processor.js'; + + let testChannelValues = [1, 2, 3]; + + // Creates a 3-channel buffer and play with BufferSourceNode. The source + // goes through a bypass AudioWorkletNode (gain value of 1). + audit.define('setup-buffer-and-worklet', (task, should) => { + context = new OfflineAudioContext(testChannelValues.length, + renderLength, + sampleRate); + + // Explicitly sets the destination channelCountMode and + // channelInterpretation to make sure the result does no mixing. + context.channeCountMode = 'explicit'; + context.channelInterpretation = 'discrete'; + + context.audioWorklet.addModule(filePath).then(() => { + let testBuffer = createConstantBuffer(context, 1, testChannelValues); + let sourceNode = new AudioBufferSourceNode(context); + let gainWorkletNode = new AudioWorkletNode(context, 'gain'); + + gainWorkletNode.parameters.get('gain').value = 1.0; + sourceNode.connect(gainWorkletNode).connect(context.destination); + + // Suspend the context at 128 sample frames and play the source with + // the assigned buffer. + context.suspend(RENDER_QUANTUM_FRAMES/sampleRate).then(() => { + sourceNode.buffer = testBuffer; + sourceNode.loop = true; + sourceNode.start(); + context.resume(); + }); + task.done(); + }); + }); + + // Verifies if the rendered buffer has all zero for the first half (before + // 128 samples) and the expected values for the second half. + audit.define('verify-rendered-buffer', (task, should) => { + context.startRendering().then(renderedBuffer => { + testChannelValues.forEach((value, index) => { + let channelData = renderedBuffer.getChannelData(index); + should(channelData.subarray(0, RENDER_QUANTUM_FRAMES), + 'First half of Channel #' + index) + .beConstantValueOf(0); + should(channelData.subarray(RENDER_QUANTUM_FRAMES, renderLength), + 'Second half of Channel #' + index) + .beConstantValueOf(value); + }); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-construction.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-construction.https.html new file mode 100644 index 0000000000..8b7704a781 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-construction.https.html @@ -0,0 +1,53 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test the construction of AudioWorkletNode with real-time context + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let realtimeContext = new AudioContext(); + + let filePath = 'processors/dummy-processor.js'; + + // Test if an exception is thrown correctly when AWN constructor is + // invoked before resolving |.addModule()| promise. + audit.define( + {label: 'construction-before-module-loading'}, + (task, should) => { + should(() => new AudioWorkletNode(realtimeContext, 'dummy'), + 'Creating a node before loading a module should throw.') + .throw(DOMException, 'InvalidStateError'); + + task.done(); + }); + + // Test the construction of AudioWorkletNode after the resolution of + // |.addModule()|. Also the constructor must throw an exception when + // a unregistered node name was given. + audit.define( + {label: 'construction-after-module-loading'}, + (task, should) => { + realtimeContext.audioWorklet.addModule(filePath).then(() => { + let dummyWorkletNode = + new AudioWorkletNode(realtimeContext, 'dummy'); + should(dummyWorkletNode instanceof AudioWorkletNode, + '"dummyWorkletNode" is an instance of AudioWorkletNode') + .beTrue(); + should(() => new AudioWorkletNode(realtimeContext, 'foobar'), + 'Unregistered name "foobar" must throw an exception.') + .throw(); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-constructor-options.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-constructor-options.https.html new file mode 100644 index 0000000000..d3347d265e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-constructor-options.https.html @@ -0,0 +1,149 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test of AudioWorkletNodeOptions + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const sampleRate = 48000; + + const audit = Audit.createTaskRunner(); + let context; + + let filePath = 'processors/dummy-processor.js'; + + // Load script file and create a OfflineAudiocontext. + audit.define('setup', (task, should) => { + context = new OfflineAudioContext(1, 1, sampleRate); + context.audioWorklet.addModule(filePath).then(() => { + task.done(); + }); + }); + + // Test AudioWorkletNode construction without AudioWorkletNodeOptions. + audit.define('without-audio-node-options', (task, should) => { + let testNode; + should( + () => testNode = new AudioWorkletNode(context, 'dummy'), + 'Creating AudioWOrkletNode without options') + .notThrow(); + should(testNode instanceof AudioWorkletNode, + 'testNode is instance of AudioWorkletNode').beEqualTo(true); + should(testNode.numberOfInputs, + 'testNode.numberOfInputs (default)').beEqualTo(1); + should(testNode.numberOfOutputs, + 'testNode.numberOfOutputs (default)').beEqualTo(1); + should(testNode.channelCount, + 'testNode.channelCount (default)').beEqualTo(2); + should(testNode.channelCountMode, + 'testNode.channelCountMode (default)').beEqualTo('max'); + should(testNode.channelInterpretation, + 'testNode.channelInterpretation (default)') + .beEqualTo('speakers'); + task.done(); + }); + + // Test AudioWorkletNode constructor with AudioNodeOptions. + audit.define('audio-node-options', (task, should) => { + const options = { + numberOfInputs: 7, + numberOfOutputs: 18, + channelCount: 4, + channelCountMode: 'clamped-max', + channelInterpretation: 'discrete' + }; + const optionsString = JSON.stringify(options); + + let testNode; + should( + () => testNode = new AudioWorkletNode(context, 'dummy', options), + 'Creating AudioWOrkletNode with options: ' + optionsString) + .notThrow(); + should(testNode.numberOfInputs, + 'testNode.numberOfInputs').beEqualTo(options.numberOfInputs); + should(testNode.numberOfOutputs, + 'testNode.numberOfOutputs').beEqualTo(options.numberOfOutputs); + should(testNode.channelCount, + 'testNode.channelCount').beEqualTo(options.channelCount); + should(testNode.channelCountMode, + 'testNode.channelCountMode').beEqualTo(options.channelCountMode); + should(testNode.channelInterpretation, + 'testNode.channelInterpretation') + .beEqualTo(options.channelInterpretation); + + task.done(); + }); + + // Test AudioWorkletNode.channelCount. + audit.define('channel-count', (task, should) => { + const options1 = {channelCount: 17}; + let testNode = new AudioWorkletNode(context, 'dummy', options1); + should(testNode.channelCount, 'testNode.channelCount') + .beEqualTo(options1.channelCount); + + const options2 = {channelCount: 0}; + should( + () => new AudioWorkletNode(context, 'dummy', options2), + 'Creating AudioWorkletNode with channelCount 0') + .throw(DOMException, 'NotSupportedError'); + + const options3 = {channelCount: 33}; + should( + () => new AudioWorkletNode(context, 'dummy', options3), + 'Creating AudioWorkletNode with channelCount 33') + .throw(DOMException, 'NotSupportedError'); + + task.done(); + }); + + // Test AudioWorkletNode.channelCountMode. + audit.define('channel-count-mode', (task, should) => { + const channelCountModes = ['max', 'clamped-max', 'explicit']; + channelCountModes.forEach((mode) => { + const options = {channelCountMode: mode}; + let testNode = new AudioWorkletNode(context, 'dummy', options); + should(testNode.channelCountMode, + 'testNode.channelCountMode (set via options.' + mode + ')') + .beEqualTo(options.channelCountMode); + }); + + const options1 = {channelCountMode: 'foobar'}; + should( + () => new AudioWorkletNode(context, 'dummy', options1), + 'Creating AudioWorkletNode with channelCountMode "foobar"') + .throw(TypeError); + + task.done(); + }); + + // Test AudioWorkletNode.channelInterpretation. + audit.define('channel-interpretation', (task, should) => { + const channelInterpretations = ['speakers', 'discrete']; + channelInterpretations.forEach((interpretation) => { + const options = {channelInterpretation: interpretation}; + let testNode = new AudioWorkletNode(context, 'dummy', options); + should( + testNode.channelInterpretation, + 'testNode.channelInterpretation (set via options.' + + interpretation + ')') + .beEqualTo(options.channelInterpretation); + }); + + const options1 = {channelInterpretation: 'foobar'}; + should( + () => new AudioWorkletNode(context, 'dummy', options1), + 'Creating AudioWorkletNode with channelInterpretation "foobar"') + .throw(TypeError); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-disconnected-input.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-disconnected-input.https.html new file mode 100644 index 0000000000..c58502af01 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-disconnected-input.https.html @@ -0,0 +1,100 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test AudioWorkletNode's Disconnected Input Array Length + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Arbitrary numbers used to align the test with render quantum boundary. + // The sample rate is a power of two to eliminate roundoff in computing + // the suspend time needed for the test. + let sampleRate = 16384; + let renderLength = 8 * RENDER_QUANTUM_FRAMES; + let context; + + let filePath = 'processors/input-length-processor.js'; + + let testChannelValues = [1, 2, 3]; + + // Creates a 3-channel buffer and play with BufferSourceNode. The source + // goes through a bypass AudioWorkletNode (gain value of 1). + audit.define( + { + label: 'test', + description: + 'Input array length should be zero for disconnected input' + }, + (task, should) => { + context = new OfflineAudioContext({ + numberOfChannels: 1, + length: renderLength, + sampleRate: sampleRate + }); + + context.audioWorklet.addModule(filePath).then(() => { + let sourceNode = new ConstantSourceNode(context); + let workletNode = + new AudioWorkletNode(context, 'input-length-processor'); + + workletNode.connect(context.destination); + + // Connect the source now. + let connectFrame = RENDER_QUANTUM_FRAMES; + + context.suspend(connectFrame / sampleRate) + .then(() => { + sourceNode.connect(workletNode); + }) + .then(() => context.resume()); + ; + + // Then disconnect the source after a few renders + let disconnectFrame = 3 * RENDER_QUANTUM_FRAMES; + context.suspend(disconnectFrame / sampleRate) + .then(() => { + sourceNode.disconnect(workletNode); + }) + .then(() => context.resume()); + + sourceNode.start(); + context.startRendering() + .then(resultBuffer => { + let data = resultBuffer.getChannelData(0); + + should( + data.slice(0, connectFrame), + 'Before connecting the source: Input array length') + .beConstantValueOf(0); + + // Find where the output is no longer 0. + let nonZeroIndex = data.findIndex(x => x > 0); + should(nonZeroIndex, 'First non-zero output') + .beEqualTo(connectFrame); + + should( + data.slice( + nonZeroIndex, + nonZeroIndex + (disconnectFrame - connectFrame)), + 'While source is connected: Input array length') + .beConstantValueOf(RENDER_QUANTUM_FRAMES); + should( + data.slice(disconnectFrame), + 'After disconnecting the source: Input array length') + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-onerror.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-onerror.https.html new file mode 100644 index 0000000000..95126a8c86 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-onerror.https.html @@ -0,0 +1,58 @@ +<!DOCTYPE html> +<title>Test onprocessorerror handler in AudioWorkletNode</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +let context = null; + +promise_setup(async () => { + const sampleRate = 48000; + const renderLength = sampleRate * 0.1; + context = new OfflineAudioContext(1, renderLength, sampleRate); + + // Loads all processor definitions that are necessary for tests in this file. + await context.audioWorklet.addModule('./processors/error-processor.js'); +}); + +promise_test(async () => { + const constructorErrorWorkletNode = + new AudioWorkletNode(context, 'constructor-error'); + let error = await new Promise(resolve => { + constructorErrorWorkletNode.onprocessorerror = (e) => resolve(e); + }); + assert_true(error instanceof ErrorEvent, + 'onprocessorerror argument should be an ErrorEvent when ' + + 'the constructor of AudioWorkletProcessor has an error.'); +}, 'Test if |onprocessorerror| is called for an exception thrown from the ' + + 'processor constructor.'); + +promise_test(async () => { + // An arbitrary Blob for testing. This is not deserializable on + // AudioWorkletGlobalScope. + const blob = new Blob([JSON.stringify({ hello: "world"}, null, 2)], { + type: "application/json", + }); + const emptyErrorWorkletNode = + new AudioWorkletNode(context, 'empty-error', {processorOptions: {blob}}); + let error = await new Promise(resolve => { + emptyErrorWorkletNode.onprocessorerror = (e) => resolve(e); + }); + assert_true(error instanceof ErrorEvent, + 'onprocessorerror argument should be an ErrorEvent when ' + + 'the constructor of AudioWorkletProcessor has an error.'); +}, 'Test if |onprocessorerror| is called for a transfered object that cannot ' + + 'be deserialized on the AudioWorkletGlobalScope.'); + +promise_test(async () => { + const processErrorWorkletNode = + new AudioWorkletNode(context, 'process-error'); + let error = await new Promise(resolve => { + processErrorWorkletNode.onprocessorerror = (e) => resolve(e); + // Need to start render to cause an exception in process(). + context.startRendering(); + }); + assert_true(error instanceof ErrorEvent, + 'onprocessorerror argument should be an ErrorEvent when the ' + + 'process method of the AudioWorkletProcessor has an error.'); +}, 'Test if |onprocessorerror| is called upon failure of process() method.'); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-output-channel-count.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-output-channel-count.https.html new file mode 100644 index 0000000000..8dafa2f811 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletnode-output-channel-count.https.html @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test the construction of AudioWorkletNode with real-time context + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + const context = new AudioContext(); + + setup(function () { + context.audioWorklet.addModule( + 'processors/channel-count-processor.js').then(() => audit.run()); + + // Test if the output channe count dynamically changes if the input + // and output is 1. + audit.define( + {label: 'Dynamically change the channel count to if unspecified.'}, + (task, should) => { + // Use arbitrary parameters for the test. + const buffer = new AudioBuffer({ + numberOfChannels: 17, + length: 1, + sampleRate: context.sampleRate, + }); + const source = new AudioBufferSourceNode(context); + source.buffer = buffer; + + const node = new AudioWorkletNode(context, 'channel-count', { + numberOfInputs: 1, + numberOfOutputs: 1, + }); + + node.port.onmessage = (message) => { + const expected = message.data; + should(expected.outputChannel, + 'The expected output channel count').beEqualTo(17); + task.done(); + }; + + // We need to make an actual connection becasue the channel count + // change happen when the rendering starts. It is to test if the + // channel count adapts to the upstream node correctly. + source.connect(node).connect(context.destination); + source.start(); + }); + + // Test if outputChannelCount is honored as expected even if the input + // and output is 1. + audit.define( + {label: 'Givien outputChannelCount must be honored.'}, + (task, should) => { + const node = new AudioWorkletNode( + context, 'channel-count', { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [2], + }); + + node.port.onmessage = (message) => { + const expected = message.data; + should(expected.outputChannel, + 'The expected output channel count').beEqualTo(2); + task.done(); + }; + + // We need to make an actual connection becasue the channel count + // change might happen when the rendering starts. It is to test + // if the specified channel count is kept correctly. + node.connect(context.destination); + }); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-options.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-options.https.html new file mode 100644 index 0000000000..ea840ed11a --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-options.https.html @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test cross-thread passing of AudioWorkletNodeOptions + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + const context = new AudioContext(); + + let filePath = 'processors/option-test-processor.js'; + + // Create a OptionTestProcessor and feed |processorData| to it. The + // processor should echo the received data to the node's |onmessage| + // handler. + audit.define('valid-processor-data', (task, should) => { + context.audioWorklet.addModule(filePath).then(() => { + let processorOptions = { + description: 'foo', + payload: [0, 1, 2, 3] + }; + + let optionTestNode = + new AudioWorkletNode(context, 'option-test-processor', { + processorOptions: processorOptions + }); + + optionTestNode.port.onmessage = (event) => { + should(event.data.processorOptions.description, + '|description| field in processorOptions from processor("' + + event.data.processorOptions.description + '")') + .beEqualTo(processorOptions.description, + 'the field in node constructor options ("' + + processorOptions.description + '")'); + should(event.data.processorOptions.payload, + '|payload| array in processorOptions from processor([' + + event.data.processorOptions.payload + '])') + .beEqualToArray([0, 1, 2, 3], + 'the array in node constructor options ([' + + event.data.processorOptions.payload + '])'); + task.done(); + }; + }); + }); + + + // Passing empty option dictionary should work without a problem. + audit.define('empty-option', (task, should) => { + context.audioWorklet.addModule(filePath).then(() => { + let optionTestNode = + new AudioWorkletNode(context, 'option-test-processor'); + + optionTestNode.port.onmessage = (event) => { + should(Object.keys(event.data).length, + 'Number of properties in data from processor') + .beEqualTo(2); + should(event.data.numberOfInputs, + '|numberOfInputs| field in data from processor') + .beEqualTo(1); + should(event.data.numberOfOutputs, + '|numberOfOutputs| field in data from processor') + .beEqualToArray(1); + task.done(); + }; + }); + }); + + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-param-getter-overridden.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-param-getter-overridden.https.html new file mode 100644 index 0000000000..e3fb6e533d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-param-getter-overridden.https.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test if AudioWorkletProcessor with invalid parameters array getter + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Arbitrarily determined. Any numbers should work. + let sampleRate = 16000; + let renderLength = 1280; + let context; + let filePath = 'processors/invalid-param-array-processor.js'; + + audit.define('Initializing AudioWorklet and Context', async (task) => { + context = new OfflineAudioContext(1, renderLength, sampleRate); + await context.audioWorklet.addModule(filePath); + task.done(); + }); + + audit.define('Verifying AudioParam in AudioWorkletNode', + async (task, should) => { + let buffer = context.createBuffer(1, 2, context.sampleRate); + buffer.getChannelData(0)[0] = 1; + + let source = new AudioBufferSourceNode(context); + source.buffer = buffer; + source.loop = true; + source.start(); + + let workletNode1 = + new AudioWorkletNode(context, 'invalid-param-array-1'); + let workletNode2 = + new AudioWorkletNode(context, 'invalid-param-array-2'); + workletNode1.connect(workletNode2).connect(context.destination); + + // Manually invoke the param getter. + source.connect(workletNode2.parameters.get('invalidParam')); + + const renderedBuffer = await context.startRendering(); + + // |workletNode2| should be no-op after the parameter getter is + // invoked. Therefore, the rendered result should be silent. + should(renderedBuffer.getChannelData(0), 'The rendered buffer') + .beConstantValueOf(0); + task.done(); + } + ); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-frozen-array.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-frozen-array.https.html new file mode 100644 index 0000000000..ce0cfa40b6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-frozen-array.https.html @@ -0,0 +1,56 @@ +<!doctype html> +<html> + <head> + <title> + Test given arrays within AudioWorkletProcessor.process() method + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + const filePath = 'processors/array-check-processor.js'; + const context = new AudioContext(); + + // Test if the incoming arrays are frozen as expected. + audit.define('check-frozen-array', (task, should) => { + context.audioWorklet.addModule(filePath).then(() => { + const workletNode = + new AudioWorkletNode(context, 'array-frozen-processor'); + workletNode.port.onmessage = (message) => { + const actual = message.data; + should(actual.isInputFrozen, '|inputs| is frozen').beTrue(); + should(actual.isOutputFrozen, '|outputs| is frozen').beTrue(); + task.done(); + }; + }); + }); + + // The incoming arrays should not be transferred, but the associated + // ArrayBuffers can be transferred. See the `array-transfer-processor` + // definition for the details. + audit.define('transfer-frozen-array', (task, should) => { + const sourceNode = new ConstantSourceNode(context); + const workletNode = + new AudioWorkletNode(context, 'array-transfer-processor'); + workletNode.port.onmessage = (message) => { + const actual = message.data; + if (actual.type === 'assertion') + should(actual.success, actual.message).beTrue(); + if (actual.done) + task.done(); + }; + // To have valid ArrayBuffers for both input and output, we need + // both connections. + // See: https://github.com/WebAudio/web-audio-api/issues/2566 + sourceNode.connect(workletNode).connect(context.destination); + sourceNode.start(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-zero-outputs.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-zero-outputs.https.html new file mode 100644 index 0000000000..e1c19f0d75 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-process-zero-outputs.https.html @@ -0,0 +1,36 @@ +<!doctype html> +<html> + <head> + <title> + Test if |outputs| argument is all zero in AudioWorkletProcessor.process() + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + const filePath = 'processors/zero-outputs-check-processor.js'; + const context = new AudioContext(); + + // Test if the incoming arrays are frozen as expected. + audit.define('check-zero-outputs', (task, should) => { + context.audioWorklet.addModule(filePath).then(() => { + const workletNode = + new AudioWorkletNode(context, 'zero-outputs-check-processor'); + workletNode.port.onmessage = (message) => { + const actual = message.data; + if (actual.type === 'assertion') { + should(actual.success, actual.message).beTrue(); + task.done(); + } + }; + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-promises.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-promises.https.html new file mode 100644 index 0000000000..079b57b959 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/audioworkletprocessor-promises.https.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test micro task checkpoints in AudioWorkletGlobalScope + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <meta charset=utf-8> + </head> + <body> + <script id="layout-test-code"> + promise_test(async () => { + const context = new AudioContext(); + + let filePath = 'processors/promise-processor.js'; + + await context.audioWorklet.addModule(filePath); + await context.suspend(); + let node1 = new AudioWorkletNode(context, 'promise-processor'); + let node2 = new AudioWorkletNode(context, 'promise-processor'); + + // Connecting to the destination is not strictly necessary in theory, + // but see + // https://bugs.chromium.org/p/chromium/issues/detail?id=1045926 + // for why it is in practice. + node1.connect(node2).connect(context.destination); + + await context.resume(); + + // The second node is the one that is going to receive the message, + // per spec: it is the second that will be processed, each time. + const e = await new Promise((resolve) => { + node2.port.onmessage = resolve; + }); + context.close(); + assert_equals(e.data, "ok", + `Microtask checkpoints are performed + in between render quantum`); + }, "test"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/baseaudiocontext-audioworklet.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/baseaudiocontext-audioworklet.https.html new file mode 100644 index 0000000000..4281f56379 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/baseaudiocontext-audioworklet.https.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Checking BaseAudioContext.audioWorklet + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let realtimeContext = new AudioContext(); + let offlineContext = new OfflineAudioContext(1, 1, 44100); + + // Test if AudioWorklet exists. + audit.define('Test if AudioWorklet exists', (task, should) => { + should(realtimeContext.audioWorklet instanceof AudioWorklet && + offlineContext.audioWorklet instanceof AudioWorklet, + 'BaseAudioContext.audioWorklet is an instance of AudioWorklet') + .beTrue(); + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/extended-audioworkletnode-with-parameters.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/extended-audioworkletnode-with-parameters.https.html new file mode 100644 index 0000000000..75f4aa4020 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/extended-audioworkletnode-with-parameters.https.html @@ -0,0 +1,16 @@ +<!doctype html> +<title>Test AudioWorkletNode subclass with parameters</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +class Extended extends AudioWorkletNode {} + +const modulePath = 'processors/gain-processor.js'; + +promise_test(async () => { + const context = new AudioContext(); + await context.audioWorklet.addModule(modulePath); + const node = new Extended(context, 'gain'); + assert_equals(Object.getPrototypeOf(node), Extended.prototype); +}); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html new file mode 100644 index 0000000000..a4c59123a1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/process-getter.https.html @@ -0,0 +1,23 @@ +<!doctype html> +<title>Test use of 'process' getter for AudioWorkletProcessor callback</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +const do_test = async (node_name) => { + const context = new AudioContext(); + const filePath = `processors/${node_name}-processor.js`; + await context.audioWorklet.addModule(filePath); + const node = new AudioWorkletNode(context, node_name); + const event = await new Promise((resolve) => { + node.port.onmessage = resolve; + }); + assert_equals(event.data.message, "done"); +}; + +// Includes testing for https://github.com/WebAudio/web-audio-api/pull/2104 +promise_test(async () => do_test('process-getter-test-prototype'), + "'process' getter on prototype"); + +promise_test(async () => do_test('process-getter-test-instance'), + "'process' getter on instance"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/process-parameters.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/process-parameters.https.html new file mode 100644 index 0000000000..4c6a10dfab --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/process-parameters.https.html @@ -0,0 +1,87 @@ +<!doctype html> +<title>Test parameters of process() AudioWorkletProcessor callback</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +var context; +promise_setup(async (t) => { + context = new AudioContext(); + const filePath = 'processors/process-parameter-test-processor.js'; + await context.audioWorklet.addModule(filePath); +}); + +const get_parameters = async (node, options) => { + const event = await new Promise((resolve) => { + node.port.onmessage = resolve; + }); + const inputs = event.data.inputs; + assert_equals(inputs.length, options.numberOfInputs, 'inputs length'); + const outputs = event.data.outputs; + assert_equals(outputs.length, options.numberOfOutputs, 'outputs length'); + for (let port = 0; port < inputs.length; ++port) { + for (let channel = 0; channel < inputs[port].length; ++channel) { + assert_equals(inputs[port][channel].length, 128, + `inputs[${port}][${channel}].length`); + } + } + for (let port = 0; port < outputs.length; ++port) { + for (let channel = 0; channel < outputs[port].length; ++channel) { + assert_equals(outputs[port][channel].length, 128, + `outputs[${port}][${channel}].length`); + } + } + return event.data; +}; + +promise_test(async (t) => { + const options = { + numberOfInputs: 3, + numberOfOutputs: 0 + }; + // Connect a source so that one channel of one input is active. + context.suspend(); + const source = new ConstantSourceNode(context); + source.start(); + const merger = new ChannelMergerNode(context, {numberOfInputs: 2}); + const active_channel_index = merger.numberOfInputs - 1; + source.connect(merger, 0, active_channel_index); + const node = new AudioWorkletNode(context, 'process-parameter-test', options); + const active_port_index = options.numberOfInputs - 1; + merger.connect(node, 0, active_port_index); + context.resume(); + const {inputs} = await get_parameters(node, options); + for (let port = 0; port < inputs.length - 1; ++port) { + if (port != active_port_index) { + assert_equals(inputs[port].length, 0, `inputs[${port}].length`); + } + } + const active_input = inputs[active_port_index]; + assert_equals(active_input.length, merger.numberOfInputs, + 'active_input.length'); + for (let channel = 0; channel < active_input.length; ++channel) { + let expected = channel == active_channel_index ? 1.0 : 0.0; + for (let sample = 0; sample < inputs.length; ++sample) { + assert_equals(active_input[channel][sample], expected, + `active_input[${channel}][${sample}]`); + } + } +}, '3 inputs; 0 outputs'); + +promise_test(async (t) => { + const options = { + numberOfInputs: 0, + numberOfOutputs: 3 + }; + const node = new AudioWorkletNode(context, 'process-parameter-test', options); + const {outputs} = await get_parameters(node, options); + for (let port = 0; port < outputs.length; ++port) { + assert_equals(outputs[port].length, 1, `outputs[${port}].length`); + for (let channel = 0; channel < outputs[port].length; ++channel) { + for (let sample = 0; sample < outputs.length; ++sample) { + assert_equals(outputs[port][channel][sample], 0.0, + `outputs[${port}][${channel}][${sample}]`); + } + } + } +}, '0 inputs; 3 outputs'); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processor-construction-port.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processor-construction-port.https.html new file mode 100644 index 0000000000..6f1aa59225 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processor-construction-port.https.html @@ -0,0 +1,61 @@ +<!doctype html> +<title>Test processor port assignment on processor callback function construction</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +// https://webaudio.github.io/web-audio-api/#AudioWorkletProcessor-instantiation + +const get_context_for_node_name = async (node_name) => { + const context = new AudioContext(); + const filePath = `processors/construction-port-${node_name}.js`; + await context.audioWorklet.addModule(filePath); + return context; +} + +const test_throws = async ({node_name, thrower} = {}) => { + const context = await get_context_for_node_name(node_name); + const node = new AudioWorkletNode(context, node_name); + const event = await new Promise((resolve) => { + node.port.onmessage = resolve; + }); + assert_true(event.data.threw, `${thrower} should throw`); + assert_equals(event.data.errorName, "TypeError"); + assert_true(event.data.isTypeError, "exception should be TypeError"); +}; + +const throw_tests = [ + { + test_name: 'super() after new AudioWorkletProcessor()', + node_name: 'super-after-new', + thrower: 'super()' + }, + { + test_name: 'new AudioWorkletProcessor() after super()', + node_name: 'new-after-super', + thrower: 'new AudioWorkletProcessor()' + }, + { + test_name: 'new AudioWorkletProcessor() after new AudioWorkletProcessor()', + node_name: 'new-after-new', + thrower: 'new AudioWorkletProcessor()' + } +]; +for (const test_info of throw_tests) { + promise_test(async () => test_throws(test_info), test_info.test_name); +} + +promise_test(async (t) => { + const node_name = 'singleton'; + const context = await get_context_for_node_name(node_name); + const node1 = new AudioWorkletNode(context, node_name); + const node2 = new AudioWorkletNode(context, node_name); + node2.onmessage = t.unreached_func("node2 should not receive a message"); + let count = 0; + await new Promise((resolve) => { + node1.port.onmessage = t.step_func((event) => { + assert_less_than(count, 2, "message count"); + if (++count == 2) { resolve(); }; + }); + }); +}, 'Singleton AudioWorkletProcessor'); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/active-processing.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/active-processing.js new file mode 100644 index 0000000000..ef497733ca --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/active-processing.js @@ -0,0 +1,54 @@ +/** + * @class ActiveProcessingTester + * @extends AudioWorkletProcessor + * + * This processor class sends a message to its AudioWorkletNodew whenever the + * number of channels on the input changes. The message includes the actual + * number of channels, the context time at which this occurred, and whether + * we're done processing or not. + */ +class ActiveProcessingTester extends AudioWorkletProcessor { + constructor(options) { + super(options); + this._lastChannelCount = 0; + + // See if user specified a value for test duration. + if (options.hasOwnProperty('processorOptions') && + options.processorOptions.hasOwnProperty('testDuration')) { + this._testDuration = options.processorOptions.testDuration; + } else { + this._testDuration = 5; + } + + // Time at which we'll signal we're done, based on the requested + // |testDuration| + this._endTime = currentTime + this._testDuration; + } + + process(inputs, outputs) { + const input = inputs[0]; + const output = outputs[0]; + const inputChannelCount = input.length; + const isFinished = currentTime > this._endTime; + + // Send a message if we're done or the count changed. + if (isFinished || (inputChannelCount != this._lastChannelCount)) { + this.port.postMessage({ + channelCount: inputChannelCount, + finished: isFinished, + time: currentTime + }); + this._lastChannelCount = inputChannelCount; + } + + // Just copy the input to the output for no particular reason. + for (let channel = 0; channel < input.length; ++channel) { + output[channel].set(input[channel]); + } + + // When we're finished, this method no longer needs to be called. + return !isFinished; + } +} + +registerProcessor('active-processing-tester', ActiveProcessingTester); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/add-offset.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/add-offset.js new file mode 100644 index 0000000000..d05056bd84 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/add-offset.js @@ -0,0 +1,34 @@ +/* + * @class AddOffsetProcessor + * @extends AudioWorkletProcessor + * + * Just adds a fixed value to the input + */ +class AddOffsetProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + + this._offset = options.processorOptions.offset; + } + + process(inputs, outputs) { + // This processor assumes the node has at least 1 input and 1 output. + let input = inputs[0]; + let output = outputs[0]; + let outputChannel = output[0]; + + if (input.length > 0) { + let inputChannel = input[0]; + for (let k = 0; k < outputChannel.length; ++k) + outputChannel[k] = inputChannel[k] + this._offset; + } else { + // No input connected, so pretend it's silence and just fill the + // output with the offset value. + outputChannel.fill(this._offset); + } + + return true; + } +} + +registerProcessor('add-offset-processor', AddOffsetProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/array-check-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/array-check-processor.js new file mode 100644 index 0000000000..d6eeff3d15 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/array-check-processor.js @@ -0,0 +1,94 @@ +/** + * @class ArrayFrozenProcessor + * @extends AudioWorkletProcessor + */ +class ArrayFrozenProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._messageSent = false; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + if (!this._messageSent) { + this.port.postMessage({ + inputLength: input.length, + isInputFrozen: Object.isFrozen(inputs) && Object.isFrozen(input), + outputLength: output.length, + isOutputFrozen: Object.isFrozen(outputs) && Object.isFrozen(output) + }); + this._messageSent = true; + } + + return false; + } +} + +/** + * @class ArrayTransferProcessor + * @extends AudioWorkletProcessor + */ +class ArrayTransferProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._messageSent = false; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + + if (!this._messageSent) { + try { + // Transferring Array objects should NOT work. + this.port.postMessage({ + inputs, input, inputChannel: input[0], + outputs, output, outputChannel: output[0] + }, [inputs, input, inputs[0], outputs, output, output[0]]); + // Hence, the following must NOT be reached. + this.port.postMessage({ + type: 'assertion', + success: false, + message: 'Transferring inputs/outputs, an individual input/output ' + + 'array, or a channel Float32Array MUST fail, but succeeded.' + }); + } catch (error) { + this.port.postMessage({ + type: 'assertion', + success: true, + message: 'Transferring inputs/outputs, an individual input/output ' + + 'array, or a channel Float32Array is not allowed as expected.' + }); + } + + try { + // Transferring ArrayBuffers should work. + this.port.postMessage( + {inputChannel: input[0], outputChannel: output[0]}, + [input[0].buffer, output[0].buffer]); + this.port.postMessage({ + type: 'assertion', + success: true, + message: 'Transferring ArrayBuffers was successful as expected.' + }); + } catch (error) { + // This must NOT be reached. + this.port.postMessage({ + type: 'assertion', + success: false, + message: 'Transferring ArrayBuffers unexpectedly failed.' + }); + } + + this.port.postMessage({done: true}); + this._messageSent = true; + } + + return false; + } +} + +registerProcessor('array-frozen-processor', ArrayFrozenProcessor); +registerProcessor('array-transfer-processor', ArrayTransferProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/channel-count-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/channel-count-processor.js new file mode 100644 index 0000000000..556459f46b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/channel-count-processor.js @@ -0,0 +1,19 @@ +/** + * @class ChannelCountProcessor + * @extends AudioWorkletProcessor + */ +class ChannelCountProcessor extends AudioWorkletProcessor { + constructor(options) { + super(options); + } + + process(inputs, outputs) { + this.port.postMessage({ + inputChannel: inputs[0].length, + outputChannel: outputs[0].length + }); + return false; + } +} + +registerProcessor('channel-count', ChannelCountProcessor);
\ No newline at end of file diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-new-after-new.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-new-after-new.js new file mode 100644 index 0000000000..d4c63f7775 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-new-after-new.js @@ -0,0 +1,16 @@ +class NewAfterNew extends AudioWorkletProcessor { + constructor() { + const processor = new AudioWorkletProcessor() + let message = {threw: false}; + try { + new AudioWorkletProcessor(); + } catch (e) { + message.threw = true; + message.errorName = e.name; + message.isTypeError = e instanceof TypeError; + } + processor.port.postMessage(message); + return processor; + } +} +registerProcessor("new-after-new", NewAfterNew); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-new-after-super.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-new-after-super.js new file mode 100644 index 0000000000..a6d4f0e2e8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-new-after-super.js @@ -0,0 +1,15 @@ +class NewAfterSuper extends AudioWorkletProcessor { + constructor() { + super() + let message = {threw: false}; + try { + new AudioWorkletProcessor() + } catch (e) { + message.threw = true; + message.errorName = e.name; + message.isTypeError = e instanceof TypeError; + } + this.port.postMessage(message); + } +} +registerProcessor("new-after-super", NewAfterSuper); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-singleton.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-singleton.js new file mode 100644 index 0000000000..c40b5a7179 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-singleton.js @@ -0,0 +1,16 @@ +let singleton; +class Singleton extends AudioWorkletProcessor { + constructor() { + if (!singleton) { + singleton = new AudioWorkletProcessor(); + singleton.process = function() { + this.port.postMessage({message: "process called"}); + // This function will be called at most once for each AudioWorkletNode + // if the node has no input connections. + return false; + } + } + return singleton; + } +} +registerProcessor("singleton", Singleton); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-super-after-new.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-super-after-new.js new file mode 100644 index 0000000000..e447830c5f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/construction-port-super-after-new.js @@ -0,0 +1,16 @@ +class SuperAfterNew extends AudioWorkletProcessor { + constructor() { + const processor = new AudioWorkletProcessor() + let message = {threw: false}; + try { + super(); + } catch (e) { + message.threw = true; + message.errorName = e.name; + message.isTypeError = e instanceof TypeError; + } + processor.port.postMessage(message); + return processor; + } +} +registerProcessor("super-after-new", SuperAfterNew); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/denormal-test-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/denormal-test-processor.js new file mode 100644 index 0000000000..2b7929437d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/denormal-test-processor.js @@ -0,0 +1,12 @@ +class DenormalTestProcessor extends AudioWorkletProcessor { + process() { + // The denormals should be non-zeros. Otherwise, it's a violation of + // ECMA specification: https://tc39.es/ecma262/#sec-number.min_value + this.port.postMessage({ + result: Number.MIN_VALUE !== 0.0 + }); + return false; + } +} + +registerProcessor('denormal-test', DenormalTestProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dummy-processor-globalthis.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dummy-processor-globalthis.js new file mode 100644 index 0000000000..d1b16cc9aa --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dummy-processor-globalthis.js @@ -0,0 +1,12 @@ +class DummyProcessor extends AudioWorkletProcessor { + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + // Doesn't do anything here. + return true; + } +} + +globalThis.registerProcessor('dummy-globalthis', DummyProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dummy-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dummy-processor.js new file mode 100644 index 0000000000..11155d508c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dummy-processor.js @@ -0,0 +1,18 @@ +/** + * @class DummyProcessor + * @extends AudioWorkletProcessor + * + * This processor class demonstrates the bare-bone structure of the processor. + */ +class DummyProcessor extends AudioWorkletProcessor { + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + // Doesn't do anything here. + return true; + } +} + +registerProcessor('dummy', DummyProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dynamic-register-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dynamic-register-processor.js new file mode 100644 index 0000000000..5e825aebb4 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/dynamic-register-processor.js @@ -0,0 +1,22 @@ +class ProcessorA extends AudioWorkletProcessor { + process() { + return true; + } +} + +// ProcessorB registers ProcessorA upon the construction. +class ProcessorB extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = () => { + registerProcessor('ProcessorA', ProcessorA); + this.port.postMessage({}); + }; + } + + process() { + return true; + } +} + +registerProcessor('ProcessorB', ProcessorB); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/error-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/error-processor.js new file mode 100644 index 0000000000..66ff5e2e25 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/error-processor.js @@ -0,0 +1,40 @@ +/** + * @class ConstructorErrorProcessor + * @extends AudioWorkletProcessor + */ +class ConstructorErrorProcessor extends AudioWorkletProcessor { + constructor() { + throw 'ConstructorErrorProcessor: an error thrown from constructor.'; + } + + process() { + return true; + } +} + + +/** + * @class ProcessErrorProcessor + * @extends AudioWorkletProcessor + */ +class ProcessErrorProcessor extends AudioWorkletProcessor { + constructor() { + super(); + } + + process() { + throw 'ProcessErrorProcessor: an error throw from process method.'; + return true; + } +} + + +/** + * @class EmptyErrorProcessor + * @extends AudioWorkletProcessor + */ +class EmptyErrorProcessor extends AudioWorkletProcessor { process() {} } + +registerProcessor('constructor-error', ConstructorErrorProcessor); +registerProcessor('process-error', ProcessErrorProcessor); +registerProcessor('empty-error', EmptyErrorProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/gain-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/gain-processor.js new file mode 100644 index 0000000000..e9e130e374 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/gain-processor.js @@ -0,0 +1,38 @@ +/** + * @class GainProcessor + * @extends AudioWorkletProcessor + * + * This processor class demonstrates the bare-bone structure of the processor. + */ +class GainProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [ + {name: 'gain', defaultValue: 0.707} + ]; + } + + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + let input = inputs[0]; + let output = outputs[0]; + let gain = parameters.gain; + for (let channel = 0; channel < input.length; ++channel) { + let inputChannel = input[channel]; + let outputChannel = output[channel]; + if (gain.length === 1) { + for (let i = 0; i < inputChannel.length; ++i) + outputChannel[i] = inputChannel[i] * gain[0]; + } else { + for (let i = 0; i < inputChannel.length; ++i) + outputChannel[i] = inputChannel[i] * gain[i]; + } + } + + return true; + } +} + +registerProcessor('gain', GainProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/input-count-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/input-count-processor.js new file mode 100644 index 0000000000..6d53ba84c7 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/input-count-processor.js @@ -0,0 +1,22 @@ +/** + * @class CountProcessor + * @extends AudioWorkletProcessor + * + * This processor class just looks at the number of input channels on the first + * input and fills the first output channel with that value. + */ +class CountProcessor extends AudioWorkletProcessor { + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + let input = inputs[0]; + let output = outputs[0]; + output[0].fill(input.length); + + return true; + } +} + +registerProcessor('counter', CountProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/input-length-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/input-length-processor.js new file mode 100644 index 0000000000..be485f03e8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/input-length-processor.js @@ -0,0 +1,27 @@ +/** + * @class InputLengthProcessor + * @extends AudioWorkletProcessor + * + * This processor class just sets the output to the length of the + * input array for verifying that the input length changes when the + * input is disconnected. + */ +class InputLengthProcessor extends AudioWorkletProcessor { + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + let input = inputs[0]; + let output = outputs[0]; + + // Set output channel to the length of the input channel array. + // If the input is unconnected, set the value to zero. + const fillValue = input.length > 0 ? input[0].length : 0; + output[0].fill(fillValue); + + return true; + } +} + +registerProcessor('input-length-processor', InputLengthProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/invalid-param-array-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/invalid-param-array-processor.js new file mode 100644 index 0000000000..e4a5dc39ba --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/invalid-param-array-processor.js @@ -0,0 +1,47 @@ +/** + * @class InvalidParamArrayProcessor + * @extends AudioWorkletProcessor + * + * This processor intentionally returns an array with an invalid size when the + * processor's getter is queried. + */ +let singleton = undefined; +let secondFetch = false; +let useDescriptor = false; +let processCounter = 0; + +class InvalidParamArrayProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + if (useDescriptor) + return [{name: 'invalidParam'}]; + useDescriptor = true; + return []; + } + + constructor() { + super(); + if (singleton === undefined) + singleton = this; + return singleton; + } + + process(inputs, outputs, parameters) { + const output = outputs[0]; + for (let channel = 0; channel < output.length; ++channel) + output[channel].fill(1); + return false; + } +} + +// This overridden getter is invoked under the hood before process() gets +// called. After this gets called, process() method above will be invalidated, +// and mark the worklet node non-functional. (i.e. in an error state) +Object.defineProperty(Object.prototype, 'invalidParam', {'get': () => { + if (secondFetch) + return new Float32Array(256); + secondFetch = true; + return new Float32Array(128); +}}); + +registerProcessor('invalid-param-array-1', InvalidParamArrayProcessor); +registerProcessor('invalid-param-array-2', InvalidParamArrayProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/one-pole-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/one-pole-processor.js new file mode 100644 index 0000000000..0bcc43f6f0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/one-pole-processor.js @@ -0,0 +1,49 @@ +/** + * @class OnePoleFilter + * @extends AudioWorkletProcessor + * + * A simple One-pole filter. + */ + +class OnePoleFilter extends AudioWorkletProcessor { + + // This gets evaluated as soon as the global scope is created. + static get parameterDescriptors() { + return [{ + name: 'frequency', + defaultValue: 250, + minValue: 0, + maxValue: 0.5 * sampleRate + }]; + } + + constructor() { + super(); + this.updateCoefficientsWithFrequency_(250); + } + + updateCoefficientsWithFrequency_(frequency) { + this.b1_ = Math.exp(-2 * Math.PI * frequency / sampleRate); + this.a0_ = 1.0 - this.b1_; + this.z1_ = 0; + } + + process(inputs, outputs, parameters) { + let input = inputs[0]; + let output = outputs[0]; + let frequency = parameters.frequency; + for (let channel = 0; channel < output.length; ++channel) { + let inputChannel = input[channel]; + let outputChannel = output[channel]; + for (let i = 0; i < outputChannel.length; ++i) { + this.updateCoefficientsWithFrequency_(frequency[i]); + this.z1_ = inputChannel[i] * this.a0_ + this.z1_ * this.b1_; + outputChannel[i] = this.z1_; + } + } + + return true; + } +} + +registerProcessor('one-pole-filter', OnePoleFilter); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/option-test-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/option-test-processor.js new file mode 100644 index 0000000000..27e1da6325 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/option-test-processor.js @@ -0,0 +1,19 @@ +/** + * @class OptionTestProcessor + * @extends AudioWorkletProcessor + * + * This processor class demonstrates the option passing feature by echoing the + * received |nodeOptions| back to the node. + */ +class OptionTestProcessor extends AudioWorkletProcessor { + constructor(nodeOptions) { + super(); + this.port.postMessage(nodeOptions); + } + + process() { + return true; + } +} + +registerProcessor('option-test-processor', OptionTestProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/param-size-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/param-size-processor.js new file mode 100644 index 0000000000..d7ce836500 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/param-size-processor.js @@ -0,0 +1,30 @@ +/** + * @class ParamSizeProcessor + * @extends AudioWorkletProcessor + * + * This processor is a source node which basically outputs the size of the + * AudioParam array for each render quantum. + */ + +class ParamSizeProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{name: 'param'}]; + } + + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + let output = outputs[0]; + let param = parameters.param; + + for (let channel = 0; channel < output.length; ++channel) { + output[channel].fill(param.length); + } + + return true; + } +} + +registerProcessor('param-size', ParamSizeProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/port-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/port-processor.js new file mode 100644 index 0000000000..8def5a61d7 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/port-processor.js @@ -0,0 +1,34 @@ +/** + * @class PortProcessor + * @extends AudioWorkletProcessor + * + * This processor class demonstrates the message port functionality. + */ +class PortProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = this.handleMessage.bind(this); + this.port.postMessage({ + state: 'created', + timeStamp: currentTime, + currentFrame: currentFrame + }); + this.processCallCount = 0; + } + + handleMessage(event) { + this.port.postMessage({ + message: event.data, + timeStamp: currentTime, + currentFrame: currentFrame, + processCallCount: this.processCallCount + }); + } + + process() { + ++this.processCallCount; + return true; + } +} + +registerProcessor('port-processor', PortProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-getter-test-instance-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-getter-test-instance-processor.js new file mode 100644 index 0000000000..b1434f54ba --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-getter-test-instance-processor.js @@ -0,0 +1,44 @@ +/** + * @class ProcessGetterTestInstanceProcessor + * @extends AudioWorkletProcessor + * + * This processor class tests that a 'process' getter on an + * AudioWorkletProcessorConstructor instance is called at the right times. + */ + +class ProcessGetterTestInstanceProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.getterCallCount = 0; + this.totalProcessCallCount = 0; + Object.defineProperty(this, 'process', { get: function() { + if (!(this instanceof ProcessGetterTestInstanceProcessor)) { + throw new Error('`process` getter called with bad `this`.'); + } + ++this.getterCallCount; + let functionCallCount = 0; + return () => { + if (++functionCallCount > 1) { + const message = 'Closure of function returned from `process` getter' + + ' should be used for only one call.' + this.port.postMessage({message: message}); + throw new Error(message); + } + if (++this.totalProcessCallCount < 2) { + return true; // Expect another getter call. + } + if (this.totalProcessCallCount != this.getterCallCount) { + const message = + 'Getter should be called only once for each process() call.' + this.port.postMessage({message: message}); + throw new Error(message); + } + this.port.postMessage({message: 'done'}); + return false; // No more calls required. + }; + }}); + } +} + +registerProcessor('process-getter-test-instance', + ProcessGetterTestInstanceProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-getter-test-prototype-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-getter-test-prototype-processor.js new file mode 100644 index 0000000000..cef5fa8b52 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-getter-test-prototype-processor.js @@ -0,0 +1,55 @@ +/** + * @class ProcessGetterTestPrototypeProcessor + * @extends AudioWorkletProcessor + * + * This processor class tests that a 'process' getter on + * AudioWorkletProcessorConstructor is called at the right times. + */ + +// Reporting errors during registerProcess() is awkward. +// The occurrance of an error is flagged, so that a trial registration can be +// performed and registration against the expected AudioWorkletNode name is +// performed only if no errors are flagged during the trial registration. +let error_flag = false; + +class ProcessGetterTestPrototypeProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.getterCallCount = 0; + this.totalProcessCallCount = 0; + } + get process() { + if (!(this instanceof ProcessGetterTestPrototypeProcessor)) { + error_flag = true; + throw new Error('`process` getter called with bad `this`.'); + } + ++this.getterCallCount; + let functionCallCount = 0; + return () => { + if (++functionCallCount > 1) { + const message = 'Closure of function returned from `process` getter' + + ' should be used for only one call.' + this.port.postMessage({message: message}); + throw new Error(message); + } + if (++this.totalProcessCallCount < 2) { + return true; // Expect another getter call. + } + if (this.totalProcessCallCount != this.getterCallCount) { + const message = + 'Getter should be called only once for each process() call.' + this.port.postMessage({message: message}); + throw new Error(message); + } + this.port.postMessage({message: 'done'}); + return false; // No more calls required. + }; + } +} + +registerProcessor('trial-process-getter-test-prototype', + ProcessGetterTestPrototypeProcessor); +if (!error_flag) { + registerProcessor('process-getter-test-prototype', + ProcessGetterTestPrototypeProcessor); +} diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-parameter-test-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-parameter-test-processor.js new file mode 100644 index 0000000000..a300d3cdec --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/process-parameter-test-processor.js @@ -0,0 +1,18 @@ +/** + * @class ProcessParameterTestProcessor + * @extends AudioWorkletProcessor + * + * This processor class forwards input and output parameters to its + * AudioWorkletNode. + */ +class ProcessParameterTestProcessor extends AudioWorkletProcessor { + process(inputs, outputs) { + this.port.postMessage({ + inputs: inputs, + outputs: outputs + }); + return false; + } +} + +registerProcessor('process-parameter-test', ProcessParameterTestProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/promise-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/promise-processor.js new file mode 100644 index 0000000000..6a8144b3cc --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/promise-processor.js @@ -0,0 +1,40 @@ +/** + * @class PromiseProcessor + * @extends AudioWorkletProcessor + * + * This processor creates and resolves a promise in its `process` method. When + * the handler passed to `then()` is called, a counter that is global in the + * global scope is incremented. There are two copies of this + * AudioWorkletNode/Processor, so the counter should always be even in the + * process method of the AudioWorklet processing, since the Promise completion + * handler are resolved in between render quanta. + * + * After a few iterations of the test, one of the worklet posts back the string + * "ok" to the main thread, and the test is considered a success. + */ +var idx = 0; + +class PromiseProcessor extends AudioWorkletProcessor { + constructor(options) { + super(options); + } + + process(inputs, outputs) { + if (idx % 2 != 0) { + this.port.postMessage("ko"); + // Don't bother continuing calling process in this case, the test has + // already failed. + return false; + } + Promise.resolve().then(() => { + idx++; + if (idx == 100) { + this.port.postMessage("ok"); + } + }); + // Ensure process is called again. + return true; + } +} + +registerProcessor('promise-processor', PromiseProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/register-processor-typeerrors.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/register-processor-typeerrors.js new file mode 100644 index 0000000000..93894842fc --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/register-processor-typeerrors.js @@ -0,0 +1,39 @@ +// For cross-thread messaging. +class MessengerProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = this.startTest.bind(this); + } + + process() {} + + startTest(message) { + runRegisterProcessorTest(this.port); + } +} + +function runRegisterProcessorTest(messagePort) { + try { + // TypeError when a given parameter is not a Function. + const DummyObject = {}; + registerProcessor('type-error-on-object', DummyObject); + } catch (exception) { + messagePort.postMessage({ + name: exception.name, + message: exception.message + }); + } + + try { + // TypeError When a given parameter is a Function, but not a constructor. + const DummyFunction = () => {}; + registerProcessor('type-error-on-function', DummyFunction); + } catch (exception) { + messagePort.postMessage({ + name: exception.name, + message: exception.message + }); + } +} + +registerProcessor('messenger-processor', MessengerProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/sharedarraybuffer-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/sharedarraybuffer-processor.js new file mode 100644 index 0000000000..2ccacccd4b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/sharedarraybuffer-processor.js @@ -0,0 +1,35 @@ +/** + * @class SharedArrayBufferProcessor + * @extends AudioWorkletProcessor + * + * This processor class demonstrates passing SharedArrayBuffers to and from + * workers. + */ +class SharedArrayBufferProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = this.handleMessage.bind(this); + this.port.onmessageerror = this.handleMessageError.bind(this); + let sab = new SharedArrayBuffer(8); + this.port.postMessage({state: 'created', sab}); + } + + handleMessage(event) { + this.port.postMessage({ + state: 'received message', + isSab: event.data instanceof SharedArrayBuffer + }); + } + + handleMessageError(event) { + this.port.postMessage({ + state: 'received messageerror' + }); + } + + process() { + return true; + } +} + +registerProcessor('sharedarraybuffer-processor', SharedArrayBufferProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/timing-info-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/timing-info-processor.js new file mode 100644 index 0000000000..714e32dbb5 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/timing-info-processor.js @@ -0,0 +1,25 @@ +/** + * @class TimingInfoProcessor + * @extends AudioWorkletProcessor + * + * This processor class is to test the timing information in AWGS. + */ +class TimingInfoProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.port.onmessage = this.echoMessage.bind(this); + } + + echoMessage(event) { + this.port.postMessage({ + currentTime: currentTime, + currentFrame: currentFrame + }); + } + + process() { + return true; + } +} + +registerProcessor('timing-info-processor', TimingInfoProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/zero-output-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/zero-output-processor.js new file mode 100644 index 0000000000..2d7399ca3b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/zero-output-processor.js @@ -0,0 +1,42 @@ +/** + * @class ZeroOutputProcessor + * @extends AudioWorkletProcessor + * + * This processor accumulates the incoming buffer and send the buffered data + * to the main thread when it reaches the specified frame length. The processor + * only supports the single input. + */ + +const kRenderQuantumFrames = 128; + +class ZeroOutputProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + + this._framesRequested = options.processorOptions.bufferLength; + this._framesCaptured = 0; + this._buffer = []; + for (let i = 0; i < options.processorOptions.channeCount; ++i) { + this._buffer[i] = new Float32Array(this._framesRequested); + } + } + + process(inputs) { + let input = inputs[0]; + let startIndex = this._framesCaptured; + let endIndex = startIndex + kRenderQuantumFrames; + for (let i = 0; i < this._buffer.length; ++i) { + this._buffer[i].subarray(startIndex, endIndex).set(input[i]); + } + this._framesCaptured = endIndex; + + if (this._framesCaptured >= this._framesRequested) { + this.port.postMessage({ capturedBuffer: this._buffer }); + return false; + } else { + return true; + } + } +} + +registerProcessor('zero-output-processor', ZeroOutputProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/zero-outputs-check-processor.js b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/zero-outputs-check-processor.js new file mode 100644 index 0000000000..f816e918a2 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/processors/zero-outputs-check-processor.js @@ -0,0 +1,78 @@ +/** + * Returns true if a given AudioPort is completely filled with zero samples. + * "AudioPort" is a short-hand for FrozenArray<FrozenArray<Float32Array>>. + * + * @param {FrozenArray<FrozenArray<Float32Array>>} audioPort + * @returns bool + */ +function IsAllZero(audioPort) { + for (let busIndex = 0; busIndex < audioPort.length; ++busIndex) { + const audioBus = audioPort[busIndex]; + for (let channelIndex = 0; channelIndex < audioBus.length; ++channelIndex) { + const audioChannel = audioBus[channelIndex]; + for (let sample = 0; sample < audioChannel.length; ++sample) { + if (audioChannel[sample] != 0) + return false; + } + } + } + return true; +} + +const kRenderQuantumFrames = 128; +const kTestLengthInSec = 1.0; +const kPulseDuration = 100; + +/** + * Checks the |outputs| argument of AudioWorkletProcessor.process() and + * send a message to an associated AudioWorkletNode. It needs to be all zero + * at all times. + * + * @class ZeroOutputsCheckProcessor + * @extends {AudioWorkletProcessor} + */ +class ZeroOutputsCheckProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.startTime = currentTime; + this.counter = 0; + } + + process(inputs, outputs) { + if (!IsAllZero(outputs)) { + this.port.postMessage({ + type: 'assertion', + success: false, + message: 'Unexpected Non-zero sample found in |outputs|.' + }); + return false; + } + + if (currentTime - this.startTime >= kTestLengthInSec) { + this.port.postMessage({ + type: 'assertion', + success: true, + message: `|outputs| has been all zeros for ${kTestLengthInSec} ` + + 'seconds as expected.' + }); + return false; + } + + // Every ~0.25 second (100 render quanta), switch between outputting white + // noise and just exiting without doing anything. (from crbug.com/1099756) + this.counter++; + if (Math.floor(this.counter / kPulseDuration) % 2 == 0) + return true; + + let output = outputs[0]; + for (let channel = 0; channel < output.length; ++channel) { + for (let sample = 0; sample < 128; sample++) { + output[channel][sample] = 0.1 * (Math.random() - 0.5); + } + } + + return true; + } +} + +registerProcessor('zero-outputs-check-processor', ZeroOutputsCheckProcessor); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/simple-input-output.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/simple-input-output.https.html new file mode 100644 index 0000000000..7b9e7f0ac3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/simple-input-output.https.html @@ -0,0 +1,90 @@ +<!DOCTYPE html> +<html> + <head> + <title>Test Simple AudioWorklet I/O</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + // Arbitrary sample rate + const sampleRate = 48000; + + // The offset to be applied by the worklet to its inputs. + const offset = 1; + + // Location of the worklet's code + const filePath = 'processors/add-offset.js'; + + let audit = Audit.createTaskRunner(); + + // Context to be used for the tests. + let context; + + audit.define('Initialize worklet', (task, should) => { + // Two channels for testing. Channel 0 is the output of the + // AudioWorklet. Channel 1 is the oscillator so we can compare + // the outputs. + context = new OfflineAudioContext( + {numberOfChannels: 2, length: sampleRate, sampleRate: sampleRate}); + + // Load up the code for the worklet. + should( + context.audioWorklet.addModule(filePath), + 'Creation of AudioWorklet') + .beResolved() + .then(() => task.done()); + }); + + audit.define( + {label: 'test', description: 'Simple AudioWorklet I/O'}, + (task, should) => { + let merger = new ChannelMergerNode( + context, {numberOfChannels: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + + let worklet = new AudioWorkletNode( + context, 'add-offset-processor', + {processorOptions: {offset: offset}}); + + src.connect(worklet).connect(merger, 0, 0); + src.connect(merger, 0, 1); + + // Start and stop the source. The stop time is fairly arbitrary, + // but use a render quantum boundary for simplicity. + const stopFrame = RENDER_QUANTUM_FRAMES; + src.start(0); + src.stop(stopFrame / context.sampleRate); + + context.startRendering() + .then(buffer => { + let ch0 = buffer.getChannelData(0); + let ch1 = buffer.getChannelData(1); + + let shifted = ch1.slice(0, stopFrame).map(x => x + offset); + + // The initial part of the output should be the oscillator + // shifted by |offset|. + should( + ch0.slice(0, stopFrame), + `AudioWorklet output[0:${stopFrame - 1}]`) + .beCloseToArray(shifted, {absoluteThreshold: 0}); + + // Output should be constant after the source has stopped. + should( + ch0.slice(stopFrame), + `AudioWorklet output[${stopFrame}:]`) + .beConstantValueOf(offset); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/suspended-context-messageport.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/suspended-context-messageport.https.html new file mode 100644 index 0000000000..f6fa6ddd98 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-audioworklet-interface/suspended-context-messageport.https.html @@ -0,0 +1,51 @@ +<!doctype html> +<title>Test MessagePort while AudioContext is not running</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +const get_node_and_message = (context) => { + const node = new AudioWorkletNode(context, 'port-processor'); + return new Promise((resolve) => { + node.port.onmessage = (event) => resolve({node: node, event: event}); + }); +}; +const ping_for_message = (node) => { + return new Promise((resolve) => { + node.port.onmessage = resolve; + node.port.postMessage('ping'); + }); +}; +const modulePath = 'processors/port-processor.js'; + +promise_test(async () => { + const realtime = new AudioContext(); + await realtime.audioWorklet.addModule(modulePath); + await realtime.suspend(); + const currentTime = realtime.currentTime; + let {node, event} = await get_node_and_message(realtime); + assert_equals(event.data.timeStamp, currentTime, 'created message time'); + event = await ping_for_message(node); + assert_equals(event.data.timeStamp, currentTime, 'pong time'); +}, 'realtime suspended'); + +let offline; +promise_test(async () => { + offline = new OfflineAudioContext({length: 128 + 1, sampleRate: 16384}); + await offline.audioWorklet.addModule(modulePath); + assert_equals(offline.currentTime, 0, 'time before start'); + let {node, event} = await get_node_and_message(offline); + assert_equals(event.data.timeStamp, 0, 'created time before start'); + event = await ping_for_message(node); + assert_equals(event.data.timeStamp, 0, 'pong time before start'); +}, 'offline before start'); + +promise_test(async () => { + await offline.startRendering(); + const expected = 2 * 128 / offline.sampleRate; + assert_equals(offline.currentTime, expected, 'time on complete'); + let {node, event} = await get_node_and_message(offline); + assert_equals(event.data.timeStamp, expected, "created time on complete"); + event = await ping_for_message(node); + assert_equals(event.data.timeStamp, expected, "pong time on complete"); +}, 'offline on complete'); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-allpass.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-allpass.html new file mode 100644 index 0000000000..86618f9e46 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-allpass.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-allpass.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad allpass filter'}, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + let filterParameters = [ + {cutoff: 0, q: 10, gain: 1}, + {cutoff: 1, q: 10, gain: 1}, + {cutoff: .5, q: 0, gain: 1}, + {cutoff: 0.25, q: 10, gain: 1}, + ]; + createTestAndRun(context, 'allpass', { + should: should, + threshold: 3.9337e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-automation.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-automation.html new file mode 100644 index 0000000000..d459d16fb1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-automation.html @@ -0,0 +1,406 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Biquad Automation Test + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Don't need to run these tests at high sampling rate, so just use a low + // one to reduce memory usage and complexity. + let sampleRate = 16000; + + // How long to render for each test. + let renderDuration = 0.25; + // Where to end the automations. Fairly arbitrary, but must end before + // the renderDuration. + let automationEndTime = renderDuration / 2; + + let audit = Audit.createTaskRunner(); + + // The definition of the linear ramp automation function. + function linearRamp(t, v0, v1, t0, t1) { + return v0 + (v1 - v0) * (t - t0) / (t1 - t0); + } + + // Generate the filter coefficients for the specified filter using the + // given parameters for the given duration. |filterTypeFunction| is a + // function that returns the filter coefficients for one set of + // parameters. |parameters| is a property bag that contains the start and + // end values (as an array) for each of the biquad attributes. The + // properties are |freq|, |Q|, |gain|, and |detune|. |duration| is the + // number of seconds for which the coefficients are generated. + // + // A property bag with properties |b0|, |b1|, |b2|, |a1|, |a2|. Each + // propery is an array consisting of the coefficients for the time-varying + // biquad filter. + function generateFilterCoefficients( + filterTypeFunction, parameters, duration) { + let renderEndFrame = Math.ceil(renderDuration * sampleRate); + let endFrame = Math.ceil(duration * sampleRate); + let nCoef = renderEndFrame; + let b0 = new Float64Array(nCoef); + let b1 = new Float64Array(nCoef); + let b2 = new Float64Array(nCoef); + let a1 = new Float64Array(nCoef); + let a2 = new Float64Array(nCoef); + + let k = 0; + // If the property is not given, use the defaults. + let freqs = parameters.freq || [350, 350]; + let qs = parameters.Q || [1, 1]; + let gains = parameters.gain || [0, 0]; + let detunes = parameters.detune || [0, 0]; + + for (let frame = 0; frame <= endFrame; ++frame) { + // Apply linear ramp at frame |frame|. + let f = + linearRamp(frame / sampleRate, freqs[0], freqs[1], 0, duration); + let q = linearRamp(frame / sampleRate, qs[0], qs[1], 0, duration); + let g = + linearRamp(frame / sampleRate, gains[0], gains[1], 0, duration); + let d = linearRamp( + frame / sampleRate, detunes[0], detunes[1], 0, duration); + + // Compute actual frequency parameter + f = f * Math.pow(2, d / 1200); + + // Compute filter coefficients + let coef = filterTypeFunction(f / (sampleRate / 2), q, g); + b0[k] = coef.b0; + b1[k] = coef.b1; + b2[k] = coef.b2; + a1[k] = coef.a1; + a2[k] = coef.a2; + ++k; + } + + // Fill the rest of the arrays with the constant value to the end of + // the rendering duration. + b0.fill(b0[endFrame], endFrame + 1); + b1.fill(b1[endFrame], endFrame + 1); + b2.fill(b2[endFrame], endFrame + 1); + a1.fill(a1[endFrame], endFrame + 1); + a2.fill(a2[endFrame], endFrame + 1); + + return {b0: b0, b1: b1, b2: b2, a1: a1, a2: a2}; + } + + // Apply the given time-varying biquad filter to the given signal, + // |signal|. |coef| should be the time-varying coefficients of the + // filter, as returned by |generateFilterCoefficients|. + function timeVaryingFilter(signal, coef) { + let length = signal.length; + // Use double precision for the internal computations. + let y = new Float64Array(length); + + // Prime the pump. (Assumes the signal has length >= 2!) + y[0] = coef.b0[0] * signal[0]; + y[1] = + coef.b0[1] * signal[1] + coef.b1[1] * signal[0] - coef.a1[1] * y[0]; + + for (let n = 2; n < length; ++n) { + y[n] = coef.b0[n] * signal[n] + coef.b1[n] * signal[n - 1] + + coef.b2[n] * signal[n - 2]; + y[n] -= coef.a1[n] * y[n - 1] + coef.a2[n] * y[n - 2]; + } + + // But convert the result to single precision for comparison. + return y.map(Math.fround); + } + + // Configure the audio graph using |context|. Returns the biquad filter + // node and the AudioBuffer used for the source. + function configureGraph(context, toneFrequency) { + // The source is just a simple sine wave. + let src = context.createBufferSource(); + let b = + context.createBuffer(1, renderDuration * sampleRate, sampleRate); + let data = b.getChannelData(0); + let omega = 2 * Math.PI * toneFrequency / sampleRate; + for (let k = 0; k < data.length; ++k) { + data[k] = Math.sin(omega * k); + } + src.buffer = b; + let f = context.createBiquadFilter(); + src.connect(f); + f.connect(context.destination); + + src.start(); + + return {filter: f, source: b}; + } + + function createFilterVerifier( + should, filterCreator, threshold, parameters, input, message) { + return function(resultBuffer) { + let actual = resultBuffer.getChannelData(0); + let coefs = generateFilterCoefficients( + filterCreator, parameters, automationEndTime); + + reference = timeVaryingFilter(input, coefs); + + should(actual, message).beCloseToArray(reference, { + absoluteThreshold: threshold + }); + }; + } + + // Automate just the frequency parameter. A bandpass filter is used where + // the center frequency is swept across the source (which is a simple + // tone). + audit.define('automate-freq', (task, should) => { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + + // Center frequency of bandpass filter and also the frequency of the + // test tone. + let centerFreq = 10 * 440; + + // Sweep the frequency +/- 5*440 Hz from the center. This should cause + // the output to be low at the beginning and end of the test where the + // tone is outside the pass band of the filter, but high in the middle + // of the automation time where the tone is near the center of the pass + // band. Make sure the frequency sweep stays inside the Nyquist + // frequency. + let parameters = {freq: [centerFreq - 5 * 440, centerFreq + 5 * 440]}; + let graph = configureGraph(context, centerFreq); + let f = graph.filter; + let b = graph.source; + + f.type = 'bandpass'; + f.frequency.setValueAtTime(parameters.freq[0], 0); + f.frequency.linearRampToValueAtTime( + parameters.freq[1], automationEndTime); + + context.startRendering() + .then(createFilterVerifier( + should, createBandpassFilter, 4.6455e-6, parameters, + b.getChannelData(0), + 'Output of bandpass filter with frequency automation')) + .then(() => task.done()); + }); + + // Automate just the Q parameter. A bandpass filter is used where the Q + // of the filter is swept. + audit.define('automate-q', (task, should) => { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + + // The frequency of the test tone. + let centerFreq = 440; + + // Sweep the Q paramter between 1 and 200. This will cause the output + // of the filter to pass most of the tone at the beginning to passing + // less of the tone at the end. This is because we set center frequency + // of the bandpass filter to be slightly off from the actual tone. + let parameters = { + Q: [1, 200], + // Center frequency of the bandpass filter is just 25 Hz above the + // tone frequency. + freq: [centerFreq + 25, centerFreq + 25] + }; + let graph = configureGraph(context, centerFreq); + let f = graph.filter; + let b = graph.source; + + f.type = 'bandpass'; + f.frequency.value = parameters.freq[0]; + f.Q.setValueAtTime(parameters.Q[0], 0); + f.Q.linearRampToValueAtTime(parameters.Q[1], automationEndTime); + + context.startRendering() + .then(createFilterVerifier( + should, createBandpassFilter, 1.0133e-6, parameters, + b.getChannelData(0), + 'Output of bandpass filter with Q automation')) + .then(() => task.done()); + }); + + // Automate just the gain of the lowshelf filter. A test tone will be in + // the lowshelf part of the filter. The output will vary as the gain of + // the lowshelf is changed. + audit.define('automate-gain', (task, should) => { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + + // Frequency of the test tone. + let centerFreq = 440; + + // Set the cutoff frequency of the lowshelf to be significantly higher + // than the test tone. Sweep the gain from 20 dB to -20 dB. (We go from + // 20 to -20 to easily verify that the filter didn't go unstable.) + let parameters = {freq: [3500, 3500], gain: [20, -20]}; + let graph = configureGraph(context, centerFreq); + let f = graph.filter; + let b = graph.source; + + f.type = 'lowshelf'; + f.frequency.value = parameters.freq[0]; + f.gain.setValueAtTime(parameters.gain[0], 0); + f.gain.linearRampToValueAtTime(parameters.gain[1], automationEndTime); + + context.startRendering() + .then(createFilterVerifier( + should, createLowShelfFilter, 2.7657e-5, parameters, + b.getChannelData(0), + 'Output of lowshelf filter with gain automation')) + .then(() => task.done()); + }); + + // Automate just the detune parameter. Basically the same test as for the + // frequncy parameter but we just use the detune parameter to modulate the + // frequency parameter. + audit.define('automate-detune', (task, should) => { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + let centerFreq = 10 * 440; + let parameters = { + freq: [centerFreq, centerFreq], + detune: [-10 * 1200, 10 * 1200] + }; + let graph = configureGraph(context, centerFreq); + let f = graph.filter; + let b = graph.source; + + f.type = 'bandpass'; + f.frequency.value = parameters.freq[0]; + f.detune.setValueAtTime(parameters.detune[0], 0); + f.detune.linearRampToValueAtTime( + parameters.detune[1], automationEndTime); + + context.startRendering() + .then(createFilterVerifier( + should, createBandpassFilter, 3.1471e-5, parameters, + b.getChannelData(0), + 'Output of bandpass filter with detune automation')) + .then(() => task.done()); + }); + + // Automate all of the filter parameters at once. This is a basic check + // that everything is working. A peaking filter is used because it uses + // all of the parameters. + audit.define('automate-all', (task, should) => { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + let graph = configureGraph(context, 10 * 440); + let f = graph.filter; + let b = graph.source; + + // Sweep all of the filter parameters. These are pretty much arbitrary. + let parameters = { + freq: [8000, 100], + Q: [f.Q.value, .0001], + gain: [f.gain.value, 20], + detune: [2400, -2400] + }; + + f.type = 'peaking'; + // Set starting points for all parameters of the filter. Start at 10 + // kHz for the center frequency, and the defaults for Q and gain. + f.frequency.setValueAtTime(parameters.freq[0], 0); + f.Q.setValueAtTime(parameters.Q[0], 0); + f.gain.setValueAtTime(parameters.gain[0], 0); + f.detune.setValueAtTime(parameters.detune[0], 0); + + // Linear ramp each parameter + f.frequency.linearRampToValueAtTime( + parameters.freq[1], automationEndTime); + f.Q.linearRampToValueAtTime(parameters.Q[1], automationEndTime); + f.gain.linearRampToValueAtTime(parameters.gain[1], automationEndTime); + f.detune.linearRampToValueAtTime( + parameters.detune[1], automationEndTime); + + context.startRendering() + .then(createFilterVerifier( + should, createPeakingFilter, 6.2907e-4, parameters, + b.getChannelData(0), + 'Output of peaking filter with automation of all parameters')) + .then(() => task.done()); + }); + + // Test that modulation of the frequency parameter of the filter works. A + // sinusoid of 440 Hz is the test signal that is applied to a bandpass + // biquad filter. The frequency parameter of the filter is modulated by a + // sinusoid at 103 Hz, and the frequency modulation varies from 116 to 412 + // Hz. (This test was taken from the description in + // https://github.com/WebAudio/web-audio-api/issues/509#issuecomment-94731355) + audit.define('modulation', (task, should) => { + let context = + new OfflineAudioContext(1, renderDuration * sampleRate, sampleRate); + + // Create a graph with the sinusoidal source at 440 Hz as the input to a + // biquad filter. + let graph = configureGraph(context, 440); + let f = graph.filter; + let b = graph.source; + + f.type = 'bandpass'; + f.Q.value = 5; + f.frequency.value = 264; + + // Create the modulation source, a sinusoid with frequency 103 Hz and + // amplitude 148. (The amplitude of 148 is added to the filter's + // frequency value of 264 to produce a sinusoidal modulation of the + // frequency parameter from 116 to 412 Hz.) + let mod = context.createBufferSource(); + let mbuffer = + context.createBuffer(1, renderDuration * sampleRate, sampleRate); + let d = mbuffer.getChannelData(0); + let omega = 2 * Math.PI * 103 / sampleRate; + for (let k = 0; k < d.length; ++k) { + d[k] = 148 * Math.sin(omega * k); + } + mod.buffer = mbuffer; + + mod.connect(f.frequency); + + mod.start(); + context.startRendering() + .then(function(resultBuffer) { + let actual = resultBuffer.getChannelData(0); + // Compute the filter coefficients using the mod sine wave + + let endFrame = Math.ceil(renderDuration * sampleRate); + let nCoef = endFrame; + let b0 = new Float64Array(nCoef); + let b1 = new Float64Array(nCoef); + let b2 = new Float64Array(nCoef); + let a1 = new Float64Array(nCoef); + let a2 = new Float64Array(nCoef); + + // Generate the filter coefficients when the frequency varies from + // 116 to 248 Hz using the 103 Hz sinusoid. + for (let k = 0; k < nCoef; ++k) { + let freq = f.frequency.value + d[k]; + let c = createBandpassFilter( + freq / (sampleRate / 2), f.Q.value, f.gain.value); + b0[k] = c.b0; + b1[k] = c.b1; + b2[k] = c.b2; + a1[k] = c.a1; + a2[k] = c.a2; + } + reference = timeVaryingFilter( + b.getChannelData(0), + {b0: b0, b1: b1, b2: b2, a1: a1, a2: a2}); + + should( + actual, + 'Output of bandpass filter with sinusoidal modulation of bandpass center frequency') + .beCloseToArray(reference, {absoluteThreshold: 3.9787e-5}); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-bandpass.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-bandpass.html new file mode 100644 index 0000000000..166aa9b3cb --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-bandpass.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-bandpass.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad bandpass filter.'}, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + // The filters we want to test. + let filterParameters = [ + {cutoff: 0, q: 0, gain: 1}, + {cutoff: 1, q: 0, gain: 1}, + {cutoff: 0.5, q: 0, gain: 1}, + {cutoff: 0.25, q: 1, gain: 1}, + ]; + + createTestAndRun(context, 'bandpass', { + should: should, + threshold: 2.2501e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-basic.html new file mode 100644 index 0000000000..441e98a251 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-basic.html @@ -0,0 +1,134 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Basic BiquadFilterNode Properties + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + let testFrames = 100; + + // Global context that can be used by the individual tasks. It must be + // defined by the initialize task. + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + should(() => { + context = new OfflineAudioContext(1, testFrames, sampleRate); + }, 'Initialize context for testing').notThrow(); + task.done(); + }); + + audit.define('existence', (task, should) => { + should(context.createBiquadFilter, 'context.createBiquadFilter') + .exist(); + task.done(); + }); + + audit.define('parameters', (task, should) => { + // Create a really simple IIR filter. Doesn't much matter what. + let coef = Float32Array.from([1]); + + let f = context.createBiquadFilter(coef, coef); + + should(f.numberOfInputs, 'numberOfInputs').beEqualTo(1); + should(f.numberOfOutputs, 'numberOfOutputs').beEqualTo(1); + should(f.channelCountMode, 'channelCountMode').beEqualTo('max'); + should(f.channelInterpretation, 'channelInterpretation') + .beEqualTo('speakers'); + + task.done(); + }); + + audit.define('exceptions-createBiquadFilter', (task, should) => { + should(function() { + // Two args are required. + context.createBiquadFilter(); + }, 'createBiquadFilter()').notThrow(); + + task.done(); + }); + + audit.define('exceptions-getFrequencyData', (task, should) => { + // Create a really simple IIR filter. Doesn't much matter what. + let coef = Float32Array.from([1]); + + let f = context.createBiquadFilter(coef, coef); + + should( + function() { + // frequencyHz can't be null. + f.getFrequencyResponse( + null, new Float32Array(1), new Float32Array(1)); + }, + 'getFrequencyResponse(' + + 'null, ' + + 'new Float32Array(1), ' + + 'new Float32Array(1))') + .throw(TypeError); + + should( + function() { + // magResponse can't be null. + f.getFrequencyResponse( + new Float32Array(1), null, new Float32Array(1)); + }, + 'getFrequencyResponse(' + + 'new Float32Array(1), ' + + 'null, ' + + 'new Float32Array(1))') + .throw(TypeError); + + should( + function() { + // phaseResponse can't be null. + f.getFrequencyResponse( + new Float32Array(1), new Float32Array(1), null); + }, + 'getFrequencyResponse(' + + 'new Float32Array(1), ' + + 'new Float32Array(1), ' + + 'null)') + .throw(TypeError); + + should( + function() { + // magResponse array must the same length as frequencyHz + f.getFrequencyResponse( + new Float32Array(10), new Float32Array(1), + new Float32Array(20)); + }, + 'getFrequencyResponse(' + + 'new Float32Array(10), ' + + 'new Float32Array(1), ' + + 'new Float32Array(20))') + .throw(DOMException, 'InvalidAccessError'); + + should( + function() { + // phaseResponse array must be the same length as frequencyHz + f.getFrequencyResponse( + new Float32Array(10), new Float32Array(20), + new Float32Array(1)); + }, + 'getFrequencyResponse(' + + 'new Float32Array(10), ' + + 'new Float32Array(20), ' + + 'new Float32Array(1))') + .throw(DOMException, 'InvalidAccessError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-getFrequencyResponse.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-getFrequencyResponse.html new file mode 100644 index 0000000000..23222e4df9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-getFrequencyResponse.html @@ -0,0 +1,394 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test BiquadFilter getFrequencyResponse() functionality + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Test the frequency response of a biquad filter. We compute the + // frequency response for a simple peaking biquad filter and compare it + // with the expected frequency response. The actual filter used doesn't + // matter since we're testing getFrequencyResponse and not the actual + // filter output. The filters are extensively tested in other biquad + // tests. + + // The magnitude response of the biquad filter. + let magResponse; + + // The phase response of the biquad filter. + let phaseResponse; + + // Number of frequency samples to take. + let numberOfFrequencies = 1000; + + // The filter parameters. + let filterCutoff = 1000; // Hz. + let filterQ = 1; + let filterGain = 5; // Decibels. + + // The magnitudes and phases of the reference frequency response. + let expectedMagnitudes; + let expectedPhases; + + // Convert frequency in Hz to a normalized frequency between 0 to 1 with 1 + // corresponding to the Nyquist frequency. + function normalizedFrequency(freqHz, sampleRate) { + let nyquist = sampleRate / 2; + return freqHz / nyquist; + } + + // Get the filter response at a (normalized) frequency |f| for the filter + // with coefficients |coef|. + function getResponseAt(coef, f) { + let b0 = coef.b0; + let b1 = coef.b1; + let b2 = coef.b2; + let a1 = coef.a1; + let a2 = coef.a2; + + // H(z) = (b0 + b1 / z + b2 / z^2) / (1 + a1 / z + a2 / z^2) + // + // Compute H(exp(i * pi * f)). No native complex numbers in javascript, + // so break H(exp(i * pi * // f)) in to the real and imaginary parts of + // the numerator and denominator. Let omega = pi * f. Then the + // numerator is + // + // b0 + b1 * cos(omega) + b2 * cos(2 * omega) - i * (b1 * sin(omega) + + // b2 * sin(2 * omega)) + // + // and the denominator is + // + // 1 + a1 * cos(omega) + a2 * cos(2 * omega) - i * (a1 * sin(omega) + a2 + // * sin(2 * omega)) + // + // Compute the magnitude and phase from the real and imaginary parts. + + let omega = Math.PI * f; + let numeratorReal = + b0 + b1 * Math.cos(omega) + b2 * Math.cos(2 * omega); + let numeratorImag = -(b1 * Math.sin(omega) + b2 * Math.sin(2 * omega)); + let denominatorReal = + 1 + a1 * Math.cos(omega) + a2 * Math.cos(2 * omega); + let denominatorImag = + -(a1 * Math.sin(omega) + a2 * Math.sin(2 * omega)); + + let magnitude = Math.sqrt( + (numeratorReal * numeratorReal + numeratorImag * numeratorImag) / + (denominatorReal * denominatorReal + + denominatorImag * denominatorImag)); + let phase = Math.atan2(numeratorImag, numeratorReal) - + Math.atan2(denominatorImag, denominatorReal); + + if (phase >= Math.PI) { + phase -= 2 * Math.PI; + } else if (phase <= -Math.PI) { + phase += 2 * Math.PI; + } + + return {magnitude: magnitude, phase: phase}; + } + + // Compute the reference frequency response for the biquad filter |filter| + // at the frequency samples given by |frequencies|. + function frequencyResponseReference(filter, frequencies) { + let sampleRate = filter.context.sampleRate; + let normalizedFreq = + normalizedFrequency(filter.frequency.value, sampleRate); + let filterCoefficients = createFilter( + filter.type, normalizedFreq, filter.Q.value, filter.gain.value); + + let magnitudes = []; + let phases = []; + + for (let k = 0; k < frequencies.length; ++k) { + let response = getResponseAt( + filterCoefficients, + normalizedFrequency(frequencies[k], sampleRate)); + magnitudes.push(response.magnitude); + phases.push(response.phase); + } + + return {magnitudes: magnitudes, phases: phases}; + } + + // Compute a set of linearly spaced frequencies. + function createFrequencies(nFrequencies, sampleRate) { + let frequencies = new Float32Array(nFrequencies); + let nyquist = sampleRate / 2; + let freqDelta = nyquist / nFrequencies; + + for (let k = 0; k < nFrequencies; ++k) { + frequencies[k] = k * freqDelta; + } + + return frequencies; + } + + function linearToDecibels(x) { + if (x) { + return 20 * Math.log(x) / Math.LN10; + } else { + return -1000; + } + } + + function decibelsToLinear(x) { + return Math.pow(10, x/20); + } + + // Look through the array and find any NaN or infinity. Returns the index + // of the first occurence or -1 if none. + function findBadNumber(signal) { + for (let k = 0; k < signal.length; ++k) { + if (!isValidNumber(signal[k])) { + return k; + } + } + return -1; + } + + // Compute absolute value of the difference between phase angles, taking + // into account the wrapping of phases. + function absolutePhaseDifference(x, y) { + let diff = Math.abs(x - y); + + if (diff > Math.PI) { + diff = 2 * Math.PI - diff; + } + return diff; + } + + // Compare the frequency response with our expected response. + // + // should - The |should| method provided by audit.define + // filter - The filter node used in the test + // frequencies - array of frequencies provided to |getFrequencyResponse| + // magResponse - mag response from |getFrequencyResponse| + // phaseResponse - phase response from |getFrequencyResponse| + // maxAllowedMagError - error threshold for mag response, in dB + // maxAllowedPhaseError - error threshold for phase response, in rad. + function compareResponses( + should, filter, frequencies, magResponse, phaseResponse, + maxAllowedMagError, maxAllowedPhaseError) { + let expectedResponse = frequencyResponseReference(filter, frequencies); + + expectedMagnitudes = expectedResponse.magnitudes; + expectedPhases = expectedResponse.phases; + + let n = magResponse.length; + let badResponse = false; + + let maxMagError = -1; + let maxMagErrorIndex = -1; + + let k; + let hasBadNumber; + + hasBadNumber = findBadNumber(magResponse); + badResponse = + !should( + hasBadNumber >= 0 ? 1 : 0, + filter.type + + ': Number of non-finite values in magnitude response') + .beEqualTo(0); + + hasBadNumber = findBadNumber(phaseResponse); + badResponse = + !should( + hasBadNumber >= 0 ? 1 : 0, + filter.type + ': Number of non-finte values in phase response') + .beEqualTo(0); + + // These aren't testing the implementation itself. Instead, these are + // sanity checks on the reference. Failure here does not imply an error + // in the implementation. + hasBadNumber = findBadNumber(expectedMagnitudes); + badResponse = + !should( + hasBadNumber >= 0 ? 1 : 0, + filter.type + + ': Number of non-finite values in the expected magnitude response') + .beEqualTo(0); + + hasBadNumber = findBadNumber(expectedPhases); + badResponse = + !should( + hasBadNumber >= 0 ? 1 : 0, + filter.type + + ': Number of non-finite values in expected phase response') + .beEqualTo(0); + + // If we found a NaN or infinity, the following tests aren't very + // helpful, especially for NaN. We run them anyway, after printing a + // warning message. + should( + !badResponse, + filter.type + + ': Actual and expected results contained only finite values') + .beTrue(); + + for (k = 0; k < n; ++k) { + let error = Math.abs( + linearToDecibels(magResponse[k]) - + linearToDecibels(expectedMagnitudes[k])); + if (error > maxMagError) { + maxMagError = error; + maxMagErrorIndex = k; + } + } + + should( + linearToDecibels(maxMagError), + filter.type + ': Max error (' + linearToDecibels(maxMagError) + + ' dB) of magnitude response at frequency ' + + frequencies[maxMagErrorIndex] + ' Hz') + .beLessThanOrEqualTo(linearToDecibels(maxAllowedMagError)); + let maxPhaseError = -1; + let maxPhaseErrorIndex = -1; + + for (k = 0; k < n; ++k) { + let error = + absolutePhaseDifference(phaseResponse[k], expectedPhases[k]); + if (error > maxPhaseError) { + maxPhaseError = error; + maxPhaseErrorIndex = k; + } + } + + should( + radToDegree(maxPhaseError), + filter.type + ': Max error (' + radToDegree(maxPhaseError) + + ' deg) in phase response at frequency ' + + frequencies[maxPhaseErrorIndex] + ' Hz') + .beLessThanOrEqualTo(radToDegree(maxAllowedPhaseError)); + } + + function radToDegree(rad) { + // Radians to degrees + return rad * 180 / Math.PI; + } + + // Test the getFrequencyResponse for each of filter types. Each entry in + // this array is a dictionary with these elements: + // + // type: filter type to be tested + // maxErrorInMagnitude: Allowed error in computed magnitude response + // maxErrorInPhase: Allowed error in computed magnitude phase + [{ + type: 'lowpass', + maxErrorInMagnitude: decibelsToLinear(-73.0178), + maxErrorInPhase: 8.04360e-6 + }, + { + type: 'highpass', + maxErrorInMagnitude: decibelsToLinear(-117.5461), + maxErrorInPhase: 6.9691e-6 + }, + { + type: 'bandpass', + maxErrorInMagnitude: decibelsToLinear(-79.0139), + maxErrorInPhase: 4.9371e-6 + }, + { + type: 'lowshelf', + maxErrorInMagnitude: decibelsToLinear(-120.4038), + maxErrorInPhase: 4.0724e-6 + }, + { + type: 'highshelf', + maxErrorInMagnitude: decibelsToLinear(-120, 1303), + maxErrorInPhase: 4.0724e-6 + }, + { + type: 'peaking', + maxErrorInMagnitude: decibelsToLinear(-119.1176), + maxErrorInPhase: 6.4724e-8 + }, + { + type: 'notch', + maxErrorInMagnitude: decibelsToLinear(-87.0808), + maxErrorInPhase: 6.6300e-6 + }, + { + type: 'allpass', + maxErrorInMagnitude: decibelsToLinear(-265.3517), + maxErrorInPhase: 1.3260e-5 + }].forEach(test => { + audit.define( + {label: test.type, description: 'Frequency response'}, + (task, should) => { + let context = new AudioContext(); + + let filter = new BiquadFilterNode(context, { + type: test.type, + frequency: filterCutoff, + Q: filterQ, + gain: filterGain + }); + + let frequencies = + createFrequencies(numberOfFrequencies, context.sampleRate); + magResponse = new Float32Array(numberOfFrequencies); + phaseResponse = new Float32Array(numberOfFrequencies); + + filter.getFrequencyResponse( + frequencies, magResponse, phaseResponse); + compareResponses( + should, filter, frequencies, magResponse, phaseResponse, + test.maxErrorInMagnitude, test.maxErrorInPhase); + + task.done(); + }); + }); + + audit.define( + { + label: 'getFrequencyResponse', + description: 'Test out-of-bounds frequency values' + }, + (task, should) => { + let context = new OfflineAudioContext(1, 1, sampleRate); + let filter = new BiquadFilterNode(context); + + // Frequencies to test. These are all outside the valid range of + // frequencies of 0 to Nyquist. + let freq = new Float32Array(2); + freq[0] = -1; + freq[1] = context.sampleRate / 2 + 1; + + let mag = new Float32Array(freq.length); + let phase = new Float32Array(freq.length); + + filter.getFrequencyResponse(freq, mag, phase); + + // Verify that the returned magnitude and phase entries are alL NaN + // since the frequencies are outside the valid range + for (let k = 0; k < mag.length; ++k) { + should(mag[k], + 'Magnitude response at frequency ' + freq[k]) + .beNaN(); + } + + for (let k = 0; k < phase.length; ++k) { + should(phase[k], + 'Phase response at frequency ' + freq[k]) + .beNaN(); + } + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-highpass.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-highpass.html new file mode 100644 index 0000000000..45c335bc4b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-highpass.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-highpass.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad highpass filter'}, + function(task, should) { + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + // The filters we want to test. + let filterParameters = [ + {cutoff: 0, q: 1, gain: 1}, + {cutoff: 1, q: 1, gain: 1}, + {cutoff: 0.25, q: 1, gain: 1}, + ]; + + createTestAndRun(context, 'highpass', { + should: should, + threshold: 1.5487e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-highshelf.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-highshelf.html new file mode 100644 index 0000000000..345195f104 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-highshelf.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-highshelf.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad highshelf filter'}, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + // The filters we want to test. + let filterParameters = [ + {cutoff: 0, q: 10, gain: 10}, + {cutoff: 1, q: 10, gain: 10}, + {cutoff: 0.25, q: 10, gain: 10}, + ]; + + createTestAndRun(context, 'highshelf', { + should: should, + threshold: 6.2577e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-lowpass.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-lowpass.html new file mode 100644 index 0000000000..d20786e36b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-lowpass.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-lowpass.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad lowpass filter'}, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + // The filters we want to test. + let filterParameters = [ + {cutoff: 0, q: 1, gain: 1}, + {cutoff: 1, q: 1, gain: 1}, + {cutoff: 0.25, q: 1, gain: 1}, + {cutoff: 0.25, q: 1, gain: 1, detune: 100}, + {cutoff: 0.01, q: 1, gain: 1, detune: -200}, + ]; + + createTestAndRun(context, 'lowpass', { + should: should, + threshold: 9.7869e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-lowshelf.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-lowshelf.html new file mode 100644 index 0000000000..ab76cefd4b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-lowshelf.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-lowshelf.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad lowshelf filter'}, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + // The filters we want to test. + let filterParameters = [ + {cutoff: 0, q: 10, gain: 10}, + {cutoff: 1, q: 10, gain: 10}, + {cutoff: 0.25, q: 10, gain: 10}, + ]; + + createTestAndRun(context, 'lowshelf', { + should: should, + threshold: 3.8349e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-notch.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-notch.html new file mode 100644 index 0000000000..98e6e6e02c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-notch.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-notch.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad notch filter'}, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + let filterParameters = [ + {cutoff: 0, q: 10, gain: 1}, + {cutoff: 1, q: 10, gain: 1}, + {cutoff: .5, q: 0, gain: 1}, + {cutoff: 0.25, q: 10, gain: 1}, + ]; + + createTestAndRun(context, 'notch', { + should: should, + threshold: 1.9669e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-peaking.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-peaking.html new file mode 100644 index 0000000000..90b7c1546d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-peaking.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-peaking.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + <script src="/webaudio/resources/biquad-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Biquad peaking filter'}, + function(task, should) { + + window.jsTestIsAsync = true; + + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + // The filters we want to test. + let filterParameters = [ + {cutoff: 0, q: 10, gain: 10}, + {cutoff: 1, q: 10, gain: 10}, + {cutoff: .5, q: 0, gain: 10}, + {cutoff: 0.25, q: 10, gain: 10}, + ]; + + createTestAndRun(context, 'peaking', { + should: should, + threshold: 5.8234e-8, + filterParameters: filterParameters + }).then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-tail.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-tail.html new file mode 100644 index 0000000000..3141bf7ff3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquad-tail.html @@ -0,0 +1,71 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Biquad Tail Output + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // A high sample rate shows the issue more clearly. + let sampleRate = 192000; + // Some short duration because we don't need to run the test for very + // long. + let testDurationSec = 0.5; + let testDurationFrames = testDurationSec * sampleRate; + + // Amplitude experimentally determined to give a biquad output close to 1. + // (No attempt was made to produce exactly 1; it's not needed.) + let sourceAmplitude = 100; + + // The output of the biquad filter should not change by more than this + // much between output samples. Threshold was determined experimentally. + let glitchThreshold = 0.012968; + + // Test that a Biquad filter doesn't have it's output terminated because + // the input has gone away. Generally, when a source node is finished, it + // disconnects itself from any downstream nodes. This is the correct + // behavior. Nodes that have no inputs (disconnected) are generally + // assumed to output zeroes. This is also desired behavior. However, + // biquad filters have memory so they should not suddenly output zeroes + // when the input is disconnected. This test checks to see if the output + // doesn't suddenly change to zero. + audit.define( + {label: 'test', description: 'Biquad Tail Output'}, + function(task, should) { + let context = + new OfflineAudioContext(1, testDurationFrames, sampleRate); + + // Create an impulse source. + let buffer = context.createBuffer(1, 1, context.sampleRate); + buffer.getChannelData(0)[0] = sourceAmplitude; + let source = context.createBufferSource(); + source.buffer = buffer; + + // Create the biquad filter. It doesn't really matter what kind, so + // the default filter type and parameters is fine. Connect the + // source to it. + let biquad = context.createBiquadFilter(); + source.connect(biquad); + biquad.connect(context.destination); + + source.start(); + + context.startRendering().then(function(result) { + // There should be no large discontinuities in the output + should(result.getChannelData(0), 'Biquad output') + .notGlitch(glitchThreshold); + task.done(); + }) + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquadfilternode-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquadfilternode-basic.html new file mode 100644 index 0000000000..7e71d07302 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/biquadfilternode-basic.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquadfilternode-basic.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Basic tests for BiquadFilterNode'}, + function(task, should) { + + let context = new AudioContext(); + let filter = context.createBiquadFilter(); + + should(filter.numberOfInputs, 'Number of inputs').beEqualTo(1); + + should(filter.numberOfOutputs, 'Number of outputs').beEqualTo(1); + + should(filter.type, 'Default filter type').beEqualTo('lowpass'); + + should(filter.frequency.value, 'Default frequency value') + .beEqualTo(350); + + should(filter.Q.value, 'Default Q value').beEqualTo(1); + + should(filter.gain.value, 'Default gain value').beEqualTo(0); + + // Check that all legal filter types can be set. + let filterTypeArray = [ + {type: 'lowpass'}, {type: 'highpass'}, {type: 'bandpass'}, + {type: 'lowshelf'}, {type: 'highshelf'}, {type: 'peaking'}, + {type: 'notch'}, {type: 'allpass'} + ]; + + for (let i = 0; i < filterTypeArray.length; ++i) { + should( + () => filter.type = filterTypeArray[i].type, + 'Setting filter.type to ' + filterTypeArray[i].type) + .notThrow(); + should(filter.type, 'Filter type is') + .beEqualTo(filterTypeArray[i].type); + } + + + // Check that numerical values are no longer supported + filter.type = 99; + should(filter.type, 'Setting filter.type to (invalid) 99') + .notBeEqualTo(99); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/ctor-biquadfilter.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/ctor-biquadfilter.html new file mode 100644 index 0000000000..e63479f985 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/ctor-biquadfilter.html @@ -0,0 +1,86 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: BiquadFilter + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'BiquadFilterNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'BiquadFilterNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [ + {name: 'type', value: 'lowpass'}, {name: 'Q', value: 1}, + {name: 'detune', value: 0}, {name: 'frequency', value: 350}, + {name: 'gain', value: 0.0} + ]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'BiquadFilterNode'); + task.done(); + }); + + audit.define('construct with options', (task, should) => { + let node; + let options = { + type: 'highpass', + frequency: 512, + detune: 1, + Q: 5, + gain: 3, + }; + + should( + () => { + node = new BiquadFilterNode(context, options); + }, + 'node = new BiquadFilterNode(..., ' + JSON.stringify(options) + ')') + .notThrow(); + + // Test that attributes are set according to the option values. + should(node.type, 'node.type').beEqualTo(options.type); + should(node.frequency.value, 'node.frequency.value') + .beEqualTo(options.frequency); + should(node.detune.value, 'node.detuen.value') + .beEqualTo(options.detune); + should(node.Q.value, 'node.Q.value').beEqualTo(options.Q); + should(node.gain.value, 'node.gain.value').beEqualTo(options.gain); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/no-dezippering.html b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/no-dezippering.html new file mode 100644 index 0000000000..79dc27035c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-biquadfilternode-interface/no-dezippering.html @@ -0,0 +1,288 @@ +<!DOCTYPE html> +<html> + <head> + <title> + biquad-bandpass.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/biquad-filters.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // In the tests below, the initial values are not important, except that + // we wanted them to be all different so that the output contains + // different values for the first few samples. Otherwise, the actual + // values don't really matter. A peaking filter is used because the + // frequency, Q, gain, and detune parameters are used by this filter. + // + // Also, for the changeList option, the times and new values aren't really + // important. They just need to change so that we can verify that the + // outputs from the .value setter still matches the output from the + // corresponding setValueAtTime. + audit.define( + {label: 'Test 0', description: 'No dezippering for frequency'}, + (task, should) => { + doTest(should, { + paramName: 'frequency', + initializer: {type: 'peaking', Q: 1, gain: 5}, + changeList: + [{quantum: 2, newValue: 800}, {quantum: 7, newValue: 200}], + threshold: 3.0399e-6 + }).then(() => task.done()); + }); + + audit.define( + {label: 'Test 1', description: 'No dezippering for detune'}, + (task, should) => { + doTest(should, { + paramName: 'detune', + initializer: + {type: 'peaking', frequency: 400, Q: 3, detune: 33, gain: 10}, + changeList: + [{quantum: 2, newValue: 1000}, {quantum: 5, newValue: -400}], + threshold: 4.0532e-6 + }).then(() => task.done()); + }); + + audit.define( + {label: 'Test 2', description: 'No dezippering for Q'}, + (task, should) => { + doTest(should, { + paramName: 'Q', + initializer: {type: 'peaking', Q: 5}, + changeList: + [{quantum: 2, newValue: 10}, {quantum: 8, newValue: -10}] + }).then(() => task.done()); + }); + + audit.define( + {label: 'Test 3', description: 'No dezippering for gain'}, + (task, should) => { + doTest(should, { + paramName: 'gain', + initializer: {type: 'peaking', gain: 1}, + changeList: + [{quantum: 2, newValue: 5}, {quantum: 6, newValue: -.3}], + threshold: 1.9074e-6 + }).then(() => task.done()); + }); + + // This test compares the filter output against a JS implementation of the + // filter. We're only testing a change in the frequency for a lowpass + // filter. This assumes we don't need to test other AudioParam changes + // with JS code because any mistakes would be exposed in the tests above. + audit.define( + { + label: 'Test 4', + description: 'No dezippering of frequency vs JS filter' + }, + (task, should) => { + // Channel 0 is the source, channel 1 is the filtered output. + let context = new OfflineAudioContext(2, 2048, 16384); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + let f = new BiquadFilterNode(context, {type: 'lowpass'}); + + // Remember the initial filter parameters. + let initialFilter = { + type: f.type, + frequency: f.frequency.value, + gain: f.gain.value, + detune: f.detune.value, + Q: f.Q.value + }; + + src.connect(merger, 0, 0); + src.connect(f).connect(merger, 0, 1); + + // Apply the filter change at frame |changeFrame| with a new + // frequency value of |newValue|. + let changeFrame = 2 * RENDER_QUANTUM_FRAMES; + let newValue = 750; + + context.suspend(changeFrame / context.sampleRate) + .then(() => f.frequency.value = newValue) + .then(() => context.resume()); + + src.start(); + + context.startRendering() + .then(audio => { + let signal = audio.getChannelData(0); + let actual = audio.getChannelData(1); + + // Get initial filter coefficients and updated coefficients + let nyquistFreq = context.sampleRate / 2; + let initialCoef = createFilter( + initialFilter.type, initialFilter.frequency / nyquistFreq, + initialFilter.Q, initialFilter.gain); + + let finalCoef = createFilter( + f.type, f.frequency.value / nyquistFreq, f.Q.value, + f.gain.value); + + let expected = new Float32Array(signal.length); + + // Filter the initial part of the signal. + expected[0] = + filterSample(signal[0], initialCoef, 0, 0, 0, 0); + expected[1] = filterSample( + signal[1], initialCoef, expected[0], 0, signal[0], 0); + + for (let k = 2; k < changeFrame; ++k) { + expected[k] = filterSample( + signal[k], initialCoef, expected[k - 1], + expected[k - 2], signal[k - 1], signal[k - 2]); + } + + // Filter the rest of the input with the new coefficients + for (let k = changeFrame; k < signal.length; ++k) { + expected[k] = filterSample( + signal[k], finalCoef, expected[k - 1], expected[k - 2], + signal[k - 1], signal[k - 2]); + } + + // The JS filter should match the actual output. + let match = + should(actual, 'Output from ' + f.type + ' filter') + .beCloseToArray( + expected, {absoluteThreshold: 6.8546e-7}); + should(match, 'Output matches JS filter results').beTrue(); + }) + .then(() => task.done()); + }); + + audit.define( + {label: 'Test 5', description: 'Test with modulation'}, + (task, should) => { + doTest(should, { + prefix: 'Modulation: ', + paramName: 'frequency', + initializer: {type: 'peaking', Q: 5, gain: 5}, + modulation: true, + changeList: + [{quantum: 2, newValue: 10}, {quantum: 8, newValue: -10}] + }).then(() => task.done()); + + }); + + audit.run(); + + // Run test, returning the promise from startRendering. |options| + // specifies the parameters for the test. |options.paramName| is the name + // of the AudioParam of the filter that is being tested. + // |options.initializer| is the initial value to be used in constructing + // the filter. |options.changeList| is an array consisting of dictionary + // with two members: |quantum| is the rendering quantum at which time we + // want to change the AudioParam value, and |newValue| is the value to be + // used. + function doTest(should, options) { + let paramName = options.paramName; + let newValue = options.newValue; + let prefix = options.prefix || ''; + + // Create offline audio context. The sample rate should be a power of + // two to eliminate any round-off errors in computing the time at which + // to suspend the context for the parameter change. The length is + // fairly arbitrary as long as it's big enough to the changeList + // values. There are two channels: channel 0 is output for the filter + // under test, and channel 1 is the output of referencef filter. + let context = new OfflineAudioContext(2, 2048, 16384); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + + // |f0| is the filter under test that will have its AudioParam value + // changed. |f1| is the reference filter that uses setValueAtTime to + // update the AudioParam value. + let f0 = new BiquadFilterNode(context, options.initializer); + let f1 = new BiquadFilterNode(context, options.initializer); + + src.connect(f0).connect(merger, 0, 0); + src.connect(f1).connect(merger, 0, 1); + + // Modulate the AudioParam with an input signal, if requested. + if (options.modulation) { + // The modulation signal is a sine wave with amplitude 1/3 the cutoff + // frequency of the test filter. The amplitude is fairly arbitrary, + // but we want it to be a significant fraction of the cutoff so that + // the cutoff varies quite a bit in the test. + let mod = + new OscillatorNode(context, {type: 'sawtooth', frequency: 1000}); + let modGain = new GainNode(context, {gain: f0.frequency.value / 3}); + mod.connect(modGain); + modGain.connect(f0[paramName]); + modGain.connect(f1[paramName]); + mod.start(); + } + // Output a message showing where we're starting from. + should(f0[paramName].value, prefix + `At time 0, ${paramName}`) + .beEqualTo(f0[paramName].value); + + // Schedule all of the desired changes from |changeList|. + options.changeList.forEach(change => { + let changeTime = + change.quantum * RENDER_QUANTUM_FRAMES / context.sampleRate; + let value = change.newValue; + + // Just output a message to show what we're doing. + should(value, prefix + `At time ${changeTime}, ${paramName}`) + .beEqualTo(value); + + // Update the AudioParam value of each filter using setValueAtTime or + // the value setter. + f1[paramName].setValueAtTime(value, changeTime); + context.suspend(changeTime) + .then(() => f0[paramName].value = value) + .then(() => context.resume()); + }); + + src.start(); + + return context.startRendering().then(audio => { + let actual = audio.getChannelData(0); + let expected = audio.getChannelData(1); + + // The output from both filters MUST match exactly if dezippering has + // been properly removed. + let match = should(actual, `${prefix}Output from ${paramName} setter`) + .beCloseToArray( + expected, {absoluteThreshold: options.threshold}); + + // Just an extra message saying that what we're comparing, to make the + // output clearer. (Not really neceesary, but nice.) + should( + match, + `${prefix}Output from ${ + paramName + } setter matches setValueAtTime output`) + .beTrue(); + }); + } + + // Filter one sample: + // + // y[n] = b0 * x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2] + // + // where |x| is x[n], |xn1| is x[n-1], |xn2| is x[n-2], |yn1| is y[n-1], + // and |yn2| is y[n-2]. |coef| is a dictonary of the filter coefficients + // |b0|, |b1|, |b2|, |a1|, and |a2|. + function filterSample(x, coef, yn1, yn2, xn1, xn2) { + return coef.b0 * x + coef.b1 * xn1 + coef.b2 * xn2 - coef.a1 * yn1 - + coef.a2 * yn2; + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/active-processing.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/active-processing.https.html new file mode 100644 index 0000000000..9012526bdc --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/active-processing.https.html @@ -0,0 +1,93 @@ +<!doctype html> +<html> + <head> + <title> + Test Active Processing for ChannelMergerNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script id="layout-test-code"> + // AudioProcessor that sends a message to its AudioWorkletNode whenver the + // number of channels on its input changes. + let filePath = + '../the-audioworklet-interface/processors/active-processing.js'; + + const audit = Audit.createTaskRunner(); + + let context; + + audit.define('initialize', (task, should) => { + // Create context and load the module + context = new AudioContext(); + should( + context.audioWorklet.addModule(filePath), + 'AudioWorklet module loading') + .beResolved() + .then(() => task.done()); + }); + + audit.define('test', (task, should) => { + const src = new OscillatorNode(context); + + // Number of inputs for the ChannelMergerNode. Pretty arbitrary, but + // should not be 1. + const numberOfInputs = 7; + const merger = + new ChannelMergerNode(context, {numberOfInputs: numberOfInputs}); + + const testerNode = + new AudioWorkletNode(context, 'active-processing-tester', { + // Use as short a duration as possible to keep the test from + // taking too much time. + processorOptions: {testDuration: .5}, + }); + + // Expected number of output channels from the merger node. We should + // start with the number of inputs, because the source (oscillator) is + // actively processing. When the source stops, the number of channels + // should change to 0. + const expectedValues = [numberOfInputs, 0]; + let index = 0; + + testerNode.port.onmessage = event => { + let count = event.data.channelCount; + let finished = event.data.finished; + + // If we're finished, end testing. + if (finished) { + // Verify that we got the expected number of changes. + should(index, 'Number of distinct values') + .beEqualTo(expectedValues.length); + + task.done(); + return; + } + + if (index < expectedValues.length) { + // Verify that the number of channels matches the expected number of + // channels. + should(count, `Test ${index}: Number of convolver output channels`) + .beEqualTo(expectedValues[index]); + } + + ++index; + }; + + // Create the graph and go + src.connect(merger).connect(testerNode).connect(context.destination); + src.start(); + + // Stop the source after a short time so we can test that the channel + // merger changes to not actively processing and thus produces a single + // channel of silence. + src.stop(context.currentTime + .1); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-basic.html new file mode 100644 index 0000000000..71a62f176f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-basic.html @@ -0,0 +1,67 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiochannelmerger-basic.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Task: Checking constraints in ChannelMergerNode. + audit.define('exceptions-channels', (task, should) => { + let context = new OfflineAudioContext(2, 128, 44100); + let merger; + + should(function() { + merger = context.createChannelMerger(); + }, 'context.createChannelMerger()').notThrow(); + + should(function() { + merger = context.createChannelMerger(0); + }, 'context.createChannelMerger(0)').throw(DOMException, 'IndexSizeError'); + + should(function() { + merger = context.createChannelMerger(32); + }, 'context.createChannelMerger(32)').notThrow(); + + // Can't create a channel merger with 33 channels because the audio + // context has a 32-channel-limit in Chrome. + should(function() { + merger = context.createChannelMerger(33); + }, 'context.createChannelMerger(33)').throw(DOMException, 'IndexSizeError'); + + task.done(); + }); + + // Task: checking the channel-related properties have the correct value + // and can't be changed. + audit.define('exceptions-properties', (task, should) => { + let context = new OfflineAudioContext(2, 128, 44100); + let merger = context.createChannelMerger(); + + should(merger.channelCount, 'merger.channelCount').beEqualTo(1); + + should(function() { + merger.channelCount = 3; + }, 'merger.channelCount = 3').throw(DOMException, 'InvalidStateError'); + + should(merger.channelCountMode, 'merger.channelCountMode') + .beEqualTo('explicit'); + + should(function() { + merger.channelCountMode = 'max'; + }, 'merger.channelCountMode = "max"').throw(DOMException, 'InvalidStateError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-disconnect.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-disconnect.html new file mode 100644 index 0000000000..ad74d5e004 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-disconnect.html @@ -0,0 +1,82 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiochannelmerger-disconnect.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let renderQuantum = 128; + + let numberOfChannels = 2; + let sampleRate = 44100; + let renderDuration = 0.5; + let disconnectTime = 0.5 * renderDuration; + + let audit = Audit.createTaskRunner(); + + // Task: Check if the merger outputs a silent channel when an input is + // disconnected. + audit.define('silent-disconnect', (task, should) => { + let context = new OfflineAudioContext( + numberOfChannels, renderDuration * sampleRate, sampleRate); + let merger = context.createChannelMerger(); + let source1 = context.createBufferSource(); + let source2 = context.createBufferSource(); + + // Create and assign a constant buffer. + let bufferDCOffset = createConstantBuffer(context, 1, 1); + source1.buffer = source2.buffer = bufferDCOffset; + source1.loop = source2.loop = true; + + // Connect the output of source into the 4th input of merger. The merger + // should produce 6 channel output. + source1.connect(merger, 0, 0); + source2.connect(merger, 0, 1); + merger.connect(context.destination); + source1.start(); + source2.start(); + + // Schedule the disconnection of |source2| at the half of render + // duration. + context.suspend(disconnectTime).then(function() { + source2.disconnect(); + context.resume(); + }); + + context.startRendering() + .then(function(buffer) { + // The entire first channel of the output should be 1. + should(buffer.getChannelData(0), 'Channel #0') + .beConstantValueOf(1); + + // Calculate the first zero index in the second channel. + let channel1 = buffer.getChannelData(1); + let disconnectIndex = disconnectTime * sampleRate; + disconnectIndex = renderQuantum * + Math.floor( + (disconnectIndex + renderQuantum - 1) / renderQuantum); + let firstZeroIndex = channel1.findIndex(function(element, index) { + if (element === 0) + return index; + }); + + // The second channel should contain 1, and 0 after the + // disconnection. + should(channel1, 'Channel #1').containValues([1, 0]); + should( + firstZeroIndex, 'The index of first zero in the channel #1') + .beEqualTo(disconnectIndex); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-input-non-default.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-input-non-default.html new file mode 100644 index 0000000000..6fe77ab763 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-input-non-default.html @@ -0,0 +1,79 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiochannelmerger-input-non-default.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/merger-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + + // Task: Check if an inactive input renders a silent mono channel in the + // output. + audit.define('silent-channel', (task, should) => { + testMergerInput(should, { + numberOfChannels: 7, + + // Create a mono source buffer filled with '1'. + testBufferContent: [1], + + // Connect the output of source into the 7th input of merger. + mergerInputIndex: 6, + + // 7th channel should be '1'. + expected: [0, 0, 0, 0, 0, 0, 1], + }).then(() => task.done()); + }); + + + // Task: Check if a stereo input is being down-mixed to mono channel + // correctly based on the mixing rule. + audit.define('stereo-down-mixing', (task, should) => { + testMergerInput(should, { + numberOfChannels: 7, + + // Create a stereo buffer filled with '1' and '2' for left and right + // channels respectively. + testBufferContent: [1, 2], + + // Connect the output of source into the 7th input of merger. + mergerInputIndex: 6, + + // The result of summed and down-mixed stereo audio should be 1.5. + // (= 1 * 0.5 + 2 * 0.5) + expected: [0, 0, 0, 0, 0, 0, 1.5], + }).then(() => task.done()); + }); + + + // Task: Check if 3-channel input gets processed by the 'discrete' mixing + // rule. + audit.define('undefined-channel-layout', (task, should) => { + testMergerInput(should, { + numberOfChannels: 7, + + // Create a 3-channel buffer filled with '1', '2', and '3' + // respectively. + testBufferContent: [1, 2, 3], + + // Connect the output of source into the 7th input of merger. + mergerInputIndex: 6, + + // The result of summed stereo audio should be 1 because 3-channel is + // not a canonical layout, so the input channel 2 and 3 should be + // dropped by 'discrete' mixing rule. + expected: [0, 0, 0, 0, 0, 0, 1], + }).then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-input.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-input.html new file mode 100644 index 0000000000..66a70dcb3b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/audiochannelmerger-input.html @@ -0,0 +1,113 @@ +<!DOCTYPE html> +<html> + <head> + <title> + audiochannelmerger-input.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/merger-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Task: Check if an inactive input renders a silent mono channel in the + // output. + audit.define('silent-channel', (task, should) => { + testMergerInput(should, { + numberOfChannels: 6, + + // Create a mono source buffer filled with '1'. + testBufferContent: [1], + + // Connect the output of source into the 4th input of merger. + mergerInputIndex: 3, + + // All channels should contain 0, except channel 4 which should be 1. + expected: [0, 0, 0, 1, 0, 0], + }).then(() => task.done()); + }); + + + // Task: Check if a stereo input is being down-mixed to mono channel + // correctly based on the mixing rule. + audit.define('stereo-down-mixing', (task, should) => { + testMergerInput(should, { + numberOfChannels: 6, + + // Create a stereo buffer filled with '1' and '2' for left and right + // channels respectively. + testBufferContent: [1, 2], + + // Connect the output of source into the 1st input of merger. + mergerInputIndex: undefined, + + // The result of summed and down-mixed stereo audio should be 1.5. + // (= 1 * 0.5 + 2 * 0.5) + expected: [1.5, 0, 0, 0, 0, 0], + }).then(() => task.done()); + }); + + + // Task: Check if 3-channel input gets processed by the 'discrete' mixing + // rule. + audit.define('undefined-channel-layout', (task, should) => { + testMergerInput(should, { + numberOfChannels: 6, + + // Create a 3-channel buffer filled with '1', '2', and '3' + // respectively. + testBufferContent: [1, 2, 3], + + // Connect the output of source into the 1st input of merger. + mergerInputIndex: undefined, + + // The result of summed stereo audio should be 1 because 3-channel is + // not a canonical layout, so the input channel 2 and 3 should be + // dropped by 'discrete' mixing rule. + expected: [1, 0, 0, 0, 0, 0], + }).then(() => task.done()); + }); + + + // Task: Merging two inputs into a single stereo stream. + audit.define('merging-to-stereo', (task, should) => { + + // For this test, the number of channel should be 2. + let context = new OfflineAudioContext(2, 128, 44100); + let merger = context.createChannelMerger(); + let source1 = context.createBufferSource(); + let source2 = context.createBufferSource(); + + // Create a DC offset buffer (mono) filled with 1 and assign it to BS + // nodes. + let positiveDCOffset = createConstantBuffer(context, 128, 1); + let negativeDCOffset = createConstantBuffer(context, 128, -1); + source1.buffer = positiveDCOffset; + source2.buffer = negativeDCOffset; + + // Connect: BS#1 => merger_input#0, BS#2 => Inverter => merger_input#1 + source1.connect(merger, 0, 0); + source2.connect(merger, 0, 1); + merger.connect(context.destination); + source1.start(); + source2.start(); + + context.startRendering().then(function(buffer) { + + // Channel#0 = 1, Channel#1 = -1 + should(buffer.getChannelData(0), 'Channel #0').beConstantValueOf(1); + should(buffer.getChannelData(1), 'Channel #1').beConstantValueOf(-1); + + task.done(); + }); + }); + + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/ctor-channelmerger.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/ctor-channelmerger.html new file mode 100644 index 0000000000..0d6b45c56d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelmergernode-interface/ctor-channelmerger.html @@ -0,0 +1,112 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: ChannelMerger + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'ChannelMergerNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = + testDefaultConstructor(should, 'ChannelMergerNode', context, { + prefix: prefix, + numberOfInputs: 6, + numberOfOutputs: 1, + channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'speakers' + }); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'ChannelMergerNode', { + channelCount: { + value: 1, + isFixed: true, + exceptionType: 'InvalidStateError' + }, + channelCountMode: { + value: 'explicit', + isFixed: true, + exceptionType: 'InvalidStateError' + } + }); + task.done(); + }); + + audit.define('constructor options', (task, should) => { + let node; + let options = { + numberOfInputs: 3, + numberOfOutputs: 9, + channelInterpretation: 'discrete' + }; + + should( + () => { + node = new ChannelMergerNode(context, options); + }, + 'node1 = new ChannelMergerNode(context, ' + + JSON.stringify(options) + ')') + .notThrow(); + + should(node.numberOfInputs, 'node1.numberOfInputs') + .beEqualTo(options.numberOfInputs); + should(node.numberOfOutputs, 'node1.numberOfOutputs').beEqualTo(1); + should(node.channelInterpretation, 'node1.channelInterpretation') + .beEqualTo(options.channelInterpretation); + + options = {numberOfInputs: 99}; + should( + () => { + node = new ChannelMergerNode(context, options); + }, + 'new ChannelMergerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'IndexSizeError'); + + options = {channelCount: 3}; + should( + () => { + node = new ChannelMergerNode(context, options); + }, + 'new ChannelMergerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + + options = {channelCountMode: 'max'}; + should( + () => { + node = new ChannelMergerNode(context, options); + }, + 'new ChannelMergerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelsplitternode-interface/audiochannelsplitter.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelsplitternode-interface/audiochannelsplitter.html new file mode 100644 index 0000000000..954c71a96b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelsplitternode-interface/audiochannelsplitter.html @@ -0,0 +1,141 @@ +<!DOCTYPE html> +<!-- +Tests that AudioChannelSplitter works correctly. +--> +<html> + <head> + <title> + audiochannelsplitter.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 44100.0; + let lengthInSampleFrames = 512; + + let context = 0; + let sourceBuffer; + let sourceNode; + let channelSplitter; + let channelMerger; + + function createStereoBufferWithDCOffset(length, sampleRate, offset) { + let buffer = context.createBuffer(2, length, sampleRate); + let n = buffer.length; + let channelL = buffer.getChannelData(0); + let channelR = buffer.getChannelData(1); + + for (let i = 0; i < n; ++i) { + channelL[i] = offset; + channelR[i] = -1.0 * offset; + } + + return buffer; + } + + // checkResult() checks that the rendered buffer is stereo and that the + // left channel is all -1 and right channel all +1. In other words, we've + // reversed the order of the two channels. + function checkResult(buffer, should) { + let success = true; + + if (buffer.numberOfChannels == 2) { + let bufferDataL = buffer.getChannelData(0); + let bufferDataR = buffer.getChannelData(1); + + success = should(bufferDataL, 'Left channel').beConstantValueOf(-1) && + success; + success = should(bufferDataR, 'Right channel').beConstantValueOf(1) && + success; + } else { + success = false; + } + + should(success, 'Left and right channels were exchanged') + .message('correctly', 'incorrectly'); + } + + audit.define( + { + label: 'construction', + description: 'Construction of ChannelSplitterNode' + }, + function(task, should) { + + // Create stereo offline audio context. + context = + new OfflineAudioContext(2, lengthInSampleFrames, sampleRate); + + let splitternode; + should(() => { + let splitternode = context.createChannelSplitter(0); + }, 'createChannelSplitter(0)').throw(DOMException, 'IndexSizeError'); + + should(() => { + splitternode = context.createChannelSplitter(33); + }, 'createChannelSplitter(33)').throw(DOMException, 'IndexSizeError'); + + should(() => { + splitternode = context.createChannelSplitter(32); + }, 'splitternode = context.createChannelSplitter(32)').notThrow(); + + should(splitternode.numberOfOutputs, 'splitternode.numberOfOutputs') + .beEqualTo(32); + should(splitternode.numberOfInputs, 'splitternode.numberOfInputs') + .beEqualTo(1) + + should(() => { + splitternode = context.createChannelSplitter(); + }, 'splitternode = context.createChannelSplitter()').notThrow(); + + should(splitternode.numberOfOutputs, 'splitternode.numberOfOutputs') + .beEqualTo(6); + + task.done(); + }); + + audit.define( + { + label: 'functionality', + description: 'Functionality of ChannelSplitterNode' + }, + function(task, should) { + + // Create a stereo buffer, with all +1 values in left channel, all + // -1 in right channel. + sourceBuffer = createStereoBufferWithDCOffset( + lengthInSampleFrames, sampleRate, 1); + + sourceNode = context.createBufferSource(); + sourceNode.buffer = sourceBuffer; + + // Create a channel splitter and connect it so that it split the + // stereo stream into two mono streams. + channelSplitter = context.createChannelSplitter(2); + sourceNode.connect(channelSplitter); + + // Create a channel merger to merge the output of channel splitter. + channelMerger = context.createChannelMerger(); + channelMerger.connect(context.destination); + + // When merging, exchange channel layout: left->right, right->left + channelSplitter.connect(channelMerger, 0, 1); + channelSplitter.connect(channelMerger, 1, 0); + + sourceNode.start(0); + + context.startRendering() + .then(buffer => checkResult(buffer, should)) + .then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-channelsplitternode-interface/ctor-channelsplitter.html b/testing/web-platform/tests/webaudio/the-audio-api/the-channelsplitternode-interface/ctor-channelsplitter.html new file mode 100644 index 0000000000..b7165bac33 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-channelsplitternode-interface/ctor-channelsplitter.html @@ -0,0 +1,115 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: ChannelSplitter + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'ChannelSplitterNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + testDefaultConstructor(should, 'ChannelSplitterNode', context, { + prefix: 'node0', + numberOfInputs: 1, + numberOfOutputs: 6, + channelCount: 6, + channelCountMode: 'explicit', + channelInterpretation: 'discrete' + }); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'ChannelSplitterNode', { + channelCount: { + value: 6, + isFixed: true, + exceptionType: 'InvalidStateError' + }, + channelCountMode: { + value: 'explicit', + isFixed: true, + exceptionType: 'InvalidStateError' + }, + channelInterpretation: { + value: 'discrete', + isFixed: true, + exceptionType: 'InvalidStateError' + }, + }); + task.done(); + }); + + audit.define('constructor options', (task, should) => { + let node; + let options = { + numberOfInputs: 3, + numberOfOutputs: 9, + channelInterpretation: 'discrete' + }; + + should( + () => { + node = new ChannelSplitterNode(context, options); + }, + 'node1 = new ChannelSplitterNode(context, ' + + JSON.stringify(options) + ')') + .notThrow(); + + should(node.numberOfInputs, 'node1.numberOfInputs').beEqualTo(1); + should(node.numberOfOutputs, 'node1.numberOfOutputs') + .beEqualTo(options.numberOfOutputs); + should(node.channelInterpretation, 'node1.channelInterpretation') + .beEqualTo(options.channelInterpretation); + + options = {numberOfOutputs: 99}; + should( + () => { + node = new ChannelSplitterNode(context, options); + }, + 'new ChannelSplitterNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'IndexSizeError'); + + options = {channelCount: 3}; + should( + () => { + node = new ChannelSplitterNode(context, options); + }, + 'new ChannelSplitterNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + + options = {channelCountMode: 'max'}; + should( + () => { + node = new ChannelSplitterNode(context, options); + }, + 'new ChannelSplitterNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-basic.html new file mode 100644 index 0000000000..4f925df5cd --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-basic.html @@ -0,0 +1,85 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Basic ConstantSourceNode Tests + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/start-stop-exceptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context = new AudioContext(); + + let audit = Audit.createTaskRunner(); + + audit.define('createConstantSource()', (task, should) => { + let node; + let prefix = 'Factory method: '; + + should(() => { + node = context.createConstantSource(); + }, prefix + 'node = context.createConstantSource()').notThrow(); + should( + node instanceof ConstantSourceNode, + prefix + 'node instance of ConstantSourceNode') + .beEqualTo(true); + + verifyNodeDefaults(should, node, prefix); + + task.done(); + }); + + audit.define('new ConstantSourceNode()', (task, should) => { + let node; + let prefix = 'Constructor: '; + + should(() => { + node = new ConstantSourceNode(context); + }, prefix + 'node = new ConstantSourceNode()').notThrow(); + should( + node instanceof ConstantSourceNode, + prefix + 'node instance of ConstantSourceNode') + .beEqualTo(true); + + + verifyNodeDefaults(should, node, prefix); + + task.done(); + }); + + audit.define('start/stop exceptions', (task, should) => { + let node = new ConstantSourceNode(context); + + testStartStop(should, node); + task.done(); + }); + + function verifyNodeDefaults(should, node, prefix) { + should(node.numberOfInputs, prefix + 'node.numberOfInputs') + .beEqualTo(0); + should(node.numberOfOutputs, prefix + 'node.numberOfOutputs') + .beEqualTo(1); + should(node.channelCount, prefix + 'node.channelCount').beEqualTo(2); + should(node.channelCountMode, prefix + 'node.channelCountMode') + .beEqualTo('max'); + should( + node.channelInterpretation, prefix + 'node.channelInterpretation') + .beEqualTo('speakers'); + + should(node.offset.value, prefix + 'node.offset.value').beEqualTo(1); + should(node.offset.defaultValue, prefix + 'node.offset.defaultValue') + .beEqualTo(1); + should(node.offset.minValue, prefix + 'node.offset.minValue') + .beEqualTo(Math.fround(-3.4028235e38)); + should(node.offset.maxValue, prefix + 'node.offset.maxValue') + .beEqualTo(Math.fround(3.4028235e38)); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-onended.html b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-onended.html new file mode 100644 index 0000000000..64bc54f21b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-onended.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test ConstantSourceNode onended + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 44100.0; + // Number of frames that the source will run; fairly arbitrary + let numberOfFrames = 32; + // Number of frames to render; arbitrary, but should be larger than + // numberOfFrames; + let renderFrames = 16 * numberOfFrames; + + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let src = new ConstantSourceNode(context); + src.connect(context.destination); + + let tester = async_test('ConstantSourceNode onended event fired'); + + src.onended = function() { + tester.step(function() { + assert_true(true, 'ConstantSourceNode.onended fired'); + }); + tester.done(); + }; + + src.start(); + src.stop(numberOfFrames / context.sampleRate); + + context.startRendering(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-output.html b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-output.html new file mode 100644 index 0000000000..5990376cff --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/constant-source-output.html @@ -0,0 +1,207 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test ConstantSourceNode Output + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/audioparam-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + let renderDuration = 0.125; + let renderFrames = sampleRate * renderDuration; + + let audit = Audit.createTaskRunner(); + + audit.define('constant source', (task, should) => { + // Verify a constant source outputs the correct (fixed) constant. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let node = new ConstantSourceNode(context, {offset: 0.5}); + node.connect(context.destination); + node.start(); + + context.startRendering() + .then(function(buffer) { + let actual = buffer.getChannelData(0); + let expected = new Float32Array(actual.length); + expected.fill(node.offset.value); + + should(actual, 'Basic: ConstantSourceNode({offset: 0.5})') + .beEqualToArray(expected); + }) + .then(() => task.done()); + }); + + audit.define('stop before start', (task, should) => { + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let node = new ConstantSourceNode(context, {offset: 1}); + node.connect(context.destination); + node.start(61 / context.sampleRate); + node.stop(31 / context.sampleRate); + + context.startRendering() + .then(function(buffer) { + let actual = buffer.getChannelData(0); + should(actual, + "ConstantSourceNode with stop before " + + "start must output silence") + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.define('stop equal to start', (task, should) => { + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let node = new ConstantSourceNode(context, {offset: 1}); + node.connect(context.destination); + node.start(31 / context.sampleRate); + node.stop(31 / context.sampleRate); + + context.startRendering() + .then(function(buffer) { + let actual = buffer.getChannelData(0); + should(actual, + "ConstantSourceNode with stop equal to start " + + " must output silence") + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.define('start/stop', (task, should) => { + // Verify a constant source starts and stops at the correct time and has + // the correct (fixed) value. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let node = new ConstantSourceNode(context, {offset: 1}); + node.connect(context.destination); + + let startFrame = 10; + let stopFrame = 300; + + node.start(startFrame / context.sampleRate); + node.stop(stopFrame / context.sampleRate); + + context.startRendering() + .then(function(buffer) { + let actual = buffer.getChannelData(0); + let expected = new Float32Array(actual.length); + // The expected output is all 1s from start to stop time. + expected.fill(0); + + for (let k = startFrame; k < stopFrame; ++k) { + expected[k] = node.offset.value; + } + + let prefix = 'start/stop: '; + should(actual.slice(0, startFrame), + prefix + 'ConstantSourceNode frames [0, ' + + startFrame + ')') + .beConstantValueOf(0); + + should(actual.slice(startFrame, stopFrame), + prefix + 'ConstantSourceNode frames [' + + startFrame + ', ' + stopFrame + ')') + .beConstantValueOf(1); + + should( + actual.slice(stopFrame), + prefix + 'ConstantSourceNode frames [' + stopFrame + + ', ' + renderFrames + ')') + .beConstantValueOf(0); + }) + .then(() => task.done()); + + }); + + audit.define('basic automation', (task, should) => { + // Verify that automation works as expected. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let source = context.createConstantSource(); + source.connect(context.destination); + + let rampEndTime = renderDuration / 2; + source.offset.setValueAtTime(0.5, 0); + source.offset.linearRampToValueAtTime(1, rampEndTime); + + source.start(); + + context.startRendering() + .then(function(buffer) { + let actual = buffer.getChannelData(0); + let expected = createLinearRampArray( + 0, rampEndTime, 0.5, 1, context.sampleRate); + + let rampEndFrame = Math.ceil(rampEndTime * context.sampleRate); + let prefix = 'Automation: '; + + should(actual.slice(0, rampEndFrame), + prefix + 'ConstantSourceNode.linearRamp(1, 0.5)') + .beCloseToArray(expected, { + // Experimentally determined threshold. + relativeThreshold: 7.1610e-7 + }); + + should(actual.slice(rampEndFrame), + prefix + 'ConstantSourceNode after ramp') + .beConstantValueOf(1); + }) + .then(() => task.done()); + }); + + audit.define('connected audioparam', (task, should) => { + // Verify the constant source output with connected AudioParam produces + // the correct output. + let context = new OfflineAudioContext(2, renderFrames, sampleRate) + context.destination.channelInterpretation = 'discrete'; + let source = new ConstantSourceNode(context, {offset: 1}); + let osc = context.createOscillator(); + let merger = context.createChannelMerger(2); + merger.connect(context.destination); + + source.connect(merger, 0, 0); + osc.connect(merger, 0, 1); + osc.connect(source.offset); + + osc.start(); + let sourceStartFrame = 10; + source.start(sourceStartFrame / context.sampleRate); + + context.startRendering() + .then(function(buffer) { + // Channel 0 and 1 should be identical, except channel 0 (the + // source) is silent at the beginning. + let actual = buffer.getChannelData(0); + let expected = buffer.getChannelData(1); + // The expected output should be oscillator + 1 because offset + // is 1. + expected = expected.map(x => 1 + x); + let prefix = 'Connected param: '; + + // The initial part of the output should be silent because the + // source node hasn't started yet. + should( + actual.slice(0, sourceStartFrame), + prefix + 'ConstantSourceNode frames [0, ' + sourceStartFrame + + ')') + .beConstantValueOf(0); + // The rest of the output should be the same as the oscillator (in + // channel 1) + should( + actual.slice(sourceStartFrame), + prefix + 'ConstantSourceNode frames [' + sourceStartFrame + + ', ' + renderFrames + ')') + .beCloseToArray(expected.slice(sourceStartFrame), 0); + + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/ctor-constantsource.html b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/ctor-constantsource.html new file mode 100644 index 0000000000..ea4a65e146 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/ctor-constantsource.html @@ -0,0 +1,50 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: ConstantSource + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'ConstantSourceNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = + testDefaultConstructor(should, 'ConstantSourceNode', context, { + prefix: prefix, + numberOfInputs: 0, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes( + should, node, prefix, [{name: 'offset', value: 1}]); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/test-constantsourcenode.html b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/test-constantsourcenode.html new file mode 100644 index 0000000000..9dd03ea116 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-constantsourcenode-interface/test-constantsourcenode.html @@ -0,0 +1,135 @@ +<!doctype html> +<meta charset=utf-8> +<title>Test the ConstantSourceNode Interface</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +test(function(t) { + var ac = new AudioContext(); + + var csn = ac.createConstantSource(); + assert_equals(csn.offset.value, 1.0, "Default offset is 1.0"); + + csn = new ConstantSourceNode(ac); + assert_equals(csn.offset.value, 1.0, "Default offset is 1.0"); + + csn = new ConstantSourceNode(ac, {offset: -0.25}); + assert_equals(csn.offset.value, -0.25, "Offset can be set during construction"); +}, "ConstantSourceNode can be constructed"); + +test(function(t) { + var ac = new AudioContext(); + + var csn = ac.createConstantSource(); + + assert_throws_dom("InvalidStateError", function() { + csn.stop(1); + }, "Start must be called before stop"); + + assert_throws_js(RangeError, function() { + csn.start(-1); + }, "When can not be negative"); + + csn.start(0); + assert_throws_js(RangeError, function() { + csn.stop(-1); + }, "When can not be negative"); +}, "ConstantSourceNode stop and start"); + +async_test(function(t) { + var ac = new OfflineAudioContext(1, 2048, 44100); + var csn = ac.createConstantSource(); + csn.connect(ac.destination); + csn.start() + csn.stop(1024/44100) + csn.onended = function(e) { + t.step(function() { + assert_equals(e.type, "ended", "Event type should be 'ended', received: " + e.type); + }); + t.done(); + } + ac.startRendering(); +}, "ConstantSourceNode onended event"); + +async_test(function(t) { + var ac = new OfflineAudioContext(1, 2048, 44100); + var csn = ac.createConstantSource(); + csn.connect(ac.destination); + csn.start(512/44100) + csn.stop(1024/44100) + + ac.oncomplete = function(e) { + t.step(function() { + var result = e.renderedBuffer.getChannelData(0); + for (var i = 0; i < 2048; ++i) { + if (i >= 512 && i < 1024) { + assert_equals(result[i], 1.0, "sample " + i + " should equal 1.0"); + } else { + assert_equals(result[i], 0.0, "sample " + i + " should equal 0.0"); + } + } + }); + t.done(); + } + + ac.startRendering(); +}, "ConstantSourceNode start and stop when work"); + +async_test(function(t) { + var ac = new OfflineAudioContext(1, 2048, 44100); + var csn = ac.createConstantSource(); + csn.offset.value = 0.25; + csn.connect(ac.destination); + csn.start() + + ac.oncomplete = function(e) { + t.step(function() { + var result = e.renderedBuffer.getChannelData(0); + for (var i = 0; i < 2048; ++i) { + assert_equals(result[i], 0.25, "sample " + i + " should equal 0.25"); + } + }); + t.done(); + } + + ac.startRendering(); +}, "ConstantSourceNode with no automation"); + +async_test(function(t) { + var ac = new OfflineAudioContext(1, 2048, 44100); + + var timeConstant = 2.0; + var offsetStart = 0.25; + var offsetEnd = 0.1; + + var csn = ac.createConstantSource(); + csn.offset.value = offsetStart; + csn.offset.setTargetAtTime(offsetEnd, 1024/ac.sampleRate, timeConstant); + csn.connect(ac.destination); + csn.start() + + ac.oncomplete = function(e) { + t.step(function() { + // create buffer with expected values + var buffer = ac.createBuffer(1, 2048, ac.sampleRate); + for (var i = 0; i < 2048; ++i) { + if (i < 1024) { + buffer.getChannelData(0)[i] = offsetStart; + } else { + time = (i-1024)/ac.sampleRate; + buffer.getChannelData(0)[i] = offsetEnd + (offsetStart - offsetEnd)*Math.exp(-time/timeConstant); + } + } + + var result = e.renderedBuffer.getChannelData(0); + var expected = buffer.getChannelData(0); + for (var i = 0; i < 2048; ++i) { + assert_approx_equals(result[i], expected[i], 1.342e-6, "sample " + i); + } + }); + t.done(); + } + + ac.startRendering(); +}, "ConstantSourceNode with automation"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/active-processing.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/active-processing.https.html new file mode 100644 index 0000000000..0712d6bcce --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/active-processing.https.html @@ -0,0 +1,93 @@ +<!doctype html> +<html> + <head> + <title> + Test Active Processing for ConvolverNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script id="layout-test-code"> + // AudioProcessor that sends a message to its AudioWorkletNode whenver the + // number of channels on its input changes. + let filePath = + '../the-audioworklet-interface/processors/active-processing.js'; + + const audit = Audit.createTaskRunner(); + + let context; + + audit.define('initialize', (task, should) => { + // Create context and load the module + context = new AudioContext(); + should( + context.audioWorklet.addModule(filePath), + 'AudioWorklet module loading') + .beResolved() + .then(() => task.done()); + }); + + audit.define('test', (task, should) => { + const src = new OscillatorNode(context); + + const response = new AudioBuffer({numberOfChannels: 2, length: 150, + sampleRate: context.sampleRate}); + + const conv = new ConvolverNode(context, {buffer: response}); + + const testerNode = + new AudioWorkletNode(context, 'active-processing-tester', { + // Use as short a duration as possible to keep the test from + // taking too much time. + processorOptions: {testDuration: .5}, + }); + + // Expected number of output channels from the convolver node. We should + // start with the number of inputs, because the source (oscillator) is + // actively processing. When the source stops, the number of channels + // should change to 0. + const expectedValues = [2, 0]; + let index = 0; + + testerNode.port.onmessage = event => { + let count = event.data.channelCount; + let finished = event.data.finished; + + // If we're finished, end testing. + if (finished) { + // Verify that we got the expected number of changes. + should(index, 'Number of distinct values') + .beEqualTo(expectedValues.length); + + task.done(); + return; + } + + if (index < expectedValues.length) { + // Verify that the number of channels matches the expected number of + // channels. + should(count, `Test ${index}: Number of convolver output channels`) + .beEqualTo(expectedValues[index]); + } + + ++index; + }; + + // Create the graph and go + src.connect(conv).connect(testerNode).connect(context.destination); + src.start(); + + // Stop the source after a short time so we can test that the convolver + // changes to not actively processing and thus produces a single channel + // of silence. + src.stop(context.currentTime + .1); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolution-mono-mono.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolution-mono-mono.html new file mode 100644 index 0000000000..570efebe22 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolution-mono-mono.html @@ -0,0 +1,62 @@ +<!DOCTYPE html> +<html> + <head> + <title> + convolution-mono-mono.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/convolution-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // description("Tests ConvolverNode processing a mono channel with mono + // impulse response."); + + // To test the convolver, we convolve two square pulses together to + // produce a triangular pulse. To verify the result is correct we + // check several parts of the result. First, we make sure the initial + // part of the result is zero (due to the latency in the convolver). + // Next, the triangular pulse should match the theoretical result to + // within some roundoff. After the triangular pulse, the result + // should be exactly zero, but round-off prevents that. We make sure + // the part after the pulse is sufficiently close to zero. Finally, + // the result should be exactly zero because the inputs are exactly + // zero. + audit.define('test', function(task, should) { + // Create offline audio context. + let context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + let squarePulse = createSquarePulseBuffer(context, pulseLengthFrames); + let trianglePulse = + createTrianglePulseBuffer(context, 2 * pulseLengthFrames); + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = squarePulse; + + let convolver = context.createConvolver(); + convolver.normalize = false; + convolver.buffer = squarePulse; + + bufferSource.connect(convolver); + convolver.connect(context.destination); + + bufferSource.start(0); + + context.startRendering() + .then(buffer => { + checkConvolvedResult(buffer, trianglePulse, should); + }) + .then(task.done.bind(task)); + ; + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-cascade.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-cascade.html new file mode 100644 index 0000000000..20bdfbdf4e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-cascade.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Cascade of Mono Convolvers + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // Arbitrary sample rate and reasonably short duration + let sampleRate = 8000; + let duration = 0.25; + let renderFrames = duration * sampleRate; + + audit.define( + {label: 'cascade-mono', description: 'Cascaded mono convolvers'}, + (task, should) => { + // Cascade two convolvers with mono responses and verify that the + // output is not silent. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + + let b0 = + new AudioBuffer({length: 5, sampleRate: context.sampleRate}); + b0.getChannelData(0)[1] = 1; + let c0 = new ConvolverNode( + context, {disableNormalization: true, buffer: b0}); + + let b1 = + new AudioBuffer({length: 5, sampleRate: context.sampleRate}); + b1.getChannelData(0)[2] = 1; + + let c1 = new ConvolverNode( + context, {disableNormalization: true, buffer: b1}); + + let src = new OscillatorNode(context); + + src.connect(c0).connect(c1).connect(context.destination); + + src.start(); + + context.startRendering() + .then(audioBuffer => { + // Just verify the output is not silent + let audio = audioBuffer.getChannelData(0); + + should(audio, 'Output of cascaded mono convolvers') + .notBeConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-channels.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-channels.html new file mode 100644 index 0000000000..ac4f198d7c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-channels.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Supported Number of Channels for ConvolverNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('channel-count-test', (task, should) => { + // Just need a context to create nodes on, so any allowed length and + // rate is ok. + let context = new OfflineAudioContext(1, 1, 48000); + + let success = true; + + for (let count = 1; count <= 32; ++count) { + let convolver = context.createConvolver(); + let buffer = context.createBuffer(count, 1, context.sampleRate); + let message = 'ConvolverNode with buffer of ' + count + ' channels'; + + if (count == 1 || count == 2 || count == 4) { + // These are the only valid channel counts for the buffer. + should(() => convolver.buffer = buffer, message).notThrow(); + } else { + should(() => convolver.buffer = buffer, message) + .throw(DOMException, 'NotSupportedError'); + } + } + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-1-chan.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-1-chan.html new file mode 100644 index 0000000000..e239a5e86f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-1-chan.html @@ -0,0 +1,406 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Convolver Channel Outputs for Response with 1 channel + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Test various convolver configurations when the convolver response has + // one channel (mono). + + // This is somewhat arbitrary. It is the minimum value for which tests + // pass with both FFmpeg and KISS FFT implementations for 256 points. + // The value was similar for each implementation. + const absoluteThreshold = Math.pow(2, -21); + + // Fairly arbitrary sample rate, except that we want the rate to be a + // power of two so that 1/sampleRate is exactly representable as a + // single-precision float. + let sampleRate = 8192; + + // A fairly arbitrary number of frames, except the number of frames should + // be more than a few render quanta. + let renderFrames = 10 * 128; + + let audit = Audit.createTaskRunner(); + + // Convolver response + let response; + + audit.define( + { + label: 'initialize', + description: 'Convolver response with one channel' + }, + (task, should) => { + // Convolver response + should( + () => { + response = new AudioBuffer( + {numberOfChannels: 1, length: 2, sampleRate: sampleRate}); + response.getChannelData(0)[1] = 1; + }, + 'new AudioBuffer({numberOfChannels: 1, length: 2, sampleRate: ' + + sampleRate + '})') + .notThrow(); + + task.done(); + }); + + audit.define( + {label: '1-channel input', description: 'produces 1-channel output'}, + (task, should) => { + // Create a 3-channel context: channel 0 = convolver under test, + // channel 1: test that convolver output is not stereo, channel 2: + // expected output. The context MUST be discrete so that the + // channels don't get mixed in some unexpected way. + let context = new OfflineAudioContext(3, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + let src = new OscillatorNode(context); + let conv = new ConvolverNode( + context, {disableNormalization: true, buffer: response}); + + // Splitter node to verify that the output of the convolver is mono. + // channelInterpretation must be 'discrete' so we don't do any + // mixing of the input to the node. + let splitter = new ChannelSplitterNode( + context, + {numberOfOutputs: 2, channelInterpretation: 'discrete'}); + + // Final merger to feed all of the individual channels into the + // destination. + let merger = new ChannelMergerNode(context, {numberOfInputs: 3}); + + src.connect(conv).connect(splitter); + splitter.connect(merger, 0, 0); + splitter.connect(merger, 1, 1); + + // The convolver response is a 1-sample delay. Use a delay node to + // implement this. + let delay = + new DelayNode(context, {delayTime: 1 / context.sampleRate}); + src.connect(delay); + delay.connect(merger, 0, 2); + + merger.connect(context.destination); + + src.start(); + + context.startRendering() + .then(audioBuffer => { + // Extract out the three channels + let actual = audioBuffer.getChannelData(0); + let c1 = audioBuffer.getChannelData(1); + let expected = audioBuffer.getChannelData(2); + + // c1 is expected to be zero. + should(c1, '1: Channel 1').beConstantValueOf(0); + + // The expected and actual results should be identical + should(actual, 'Convolver output') + .beCloseToArray(expected, + {absoluteThreshold: absoluteThreshold}); + }) + .then(() => task.done()); + }); + + audit.define( + {label: '2-channel input', description: 'produces 2-channel output'}, + (task, should) => { + downMixTest({numberOfInputs: 2, prefix: '2'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '3-channel input', + description: '3->2 downmix producing 2-channel output' + }, + (task, should) => { + downMixTest({numberOfInputs: 3, prefix: '3'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '4-channel input', + description: '4->2 downmix producing 2-channel output' + }, + (task, should) => { + downMixTest({numberOfInputs: 4, prefix: '4'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '5.1-channel input', + description: '5.1->2 downmix producing 2-channel output' + }, + (task, should) => { + // Scale tolerance by maximum amplitude expected in down-mix + // output. + let threshold = (1.0 + Math.sqrt(0.5) * 2) * absoluteThreshold; + + downMixTest({numberOfInputs: 6, prefix: '5.1', + absoluteThreshold: threshold}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '3-channel input, explicit', + description: '3->2 explicit downmix producing 2-channel output' + }, + (task, should) => { + downMixTest( + { + channelCountMode: 'explicit', + numberOfInputs: 3, + prefix: '3 chan downmix explicit' + }, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: '4-channel input, explicit', + description: '4->2 explicit downmix producing 2-channel output' + }, + (task, should) => { + downMixTest( + { + channelCountMode: 'explicit', + numberOfInputs: 4, + prefix: '4 chan downmix explicit' + }, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: '5.1-channel input, explicit', + description: '5.1->2 explicit downmix producing 2-channel output' + }, + (task, should) => { + // Scale tolerance by maximum amplitude expected in down-mix + // output. + let threshold = (1.0 + Math.sqrt(0.5) * 2) * absoluteThreshold; + + downMixTest( + { + channelCountMode: 'explicit', + numberOfInputs: 6, + prefix: '5.1 chan downmix explicit', + absoluteThreshold: threshold + }, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: 'mono-upmix-explicit', + description: '1->2 upmix, count mode explicit' + }, + (task, should) => { + upMixTest(should, {channelCountMode: 'explicit'}) + .then(buffer => { + let length = buffer.length; + let input = buffer.getChannelData(0); + let out0 = buffer.getChannelData(1); + let out1 = buffer.getChannelData(2); + + // The convolver is basically a one-sample delay. Verify that + // that each channel is delayed by one sample. + should(out0.slice(1), '1->2 explicit upmix: channel 0') + .beCloseToArray( + input.slice(0, length - 1), + {absoluteThreshold: absoluteThreshold}); + should(out1.slice(1), '1->2 explicit upmix: channel 1') + .beCloseToArray( + input.slice(0, length - 1), + {absoluteThreshold: absoluteThreshold}); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'mono-upmix-clamped-max', + description: '1->2 upmix, count mode clamped-max' + }, + (task, should) => { + upMixTest(should, {channelCountMode: 'clamped-max'}) + .then(buffer => { + let length = buffer.length; + let input = buffer.getChannelData(0); + let out0 = buffer.getChannelData(1); + let out1 = buffer.getChannelData(2); + + // The convolver is basically a one-sample delay. With a mono + // input, the count set to 2, and a mode of 'clamped-max', the + // output should be mono + should(out0.slice(1), '1->2 clamped-max upmix: channel 0') + .beCloseToArray( + input.slice(0, length - 1), + {absoluteThreshold: absoluteThreshold}); + should(out1, '1->2 clamped-max upmix: channel 1') + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + function downMixTest(options, should) { + // Create an 4-channel offline context. The first two channels are for + // the stereo output of the convolver and the next two channels are for + // the reference stereo signal. + let context = new OfflineAudioContext(4, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + // Create oscillators for use as the input. The type and frequency is + // arbitrary except that oscillators must be different. + let src = new Array(options.numberOfInputs); + for (let k = 0; k < src.length; ++k) { + src[k] = new OscillatorNode( + context, {type: 'square', frequency: 440 + 220 * k}); + } + + // Merger to combine the oscillators into one output stream. + let srcMerger = + new ChannelMergerNode(context, {numberOfInputs: src.length}); + + for (let k = 0; k < src.length; ++k) { + src[k].connect(srcMerger, 0, k); + } + + // Convolver under test. + let conv = new ConvolverNode(context, { + disableNormalization: true, + buffer: response, + channelCountMode: options.channelCountMode + }); + srcMerger.connect(conv); + + // Splitter to get individual channels of the convolver output so we can + // feed them (eventually) to the context in the right set of channels. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + conv.connect(splitter); + + // Reference graph consists of a delay node to simulate the response of + // the convolver. (The convolver response is designed this way.) + let delay = new DelayNode(context, {delayTime: 1 / context.sampleRate}); + + // Gain node to mix the sources to stereo in the desired way. (Could be + // done in the delay node, but let's keep the mixing separated from the + // functionality.) + let gainMixer = new GainNode( + context, {channelCount: 2, channelCountMode: 'explicit'}); + srcMerger.connect(gainMixer); + + // Splitter to extract the channels of the reference signal. + let refSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + gainMixer.connect(delay).connect(refSplitter); + + // Final merger to bring back the individual channels from the convolver + // and the reference in the right order for the destination. + let finalMerger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + // First two channels are for the convolver output, and the next two are + // for the reference. + splitter.connect(finalMerger, 0, 0); + splitter.connect(finalMerger, 1, 1); + refSplitter.connect(finalMerger, 0, 2); + refSplitter.connect(finalMerger, 1, 3); + + finalMerger.connect(context.destination); + + // Start the sources at last. + for (let k = 0; k < src.length; ++k) { + src[k].start(); + } + + return context.startRendering().then(audioBuffer => { + // Extract the various channels out + let actual0 = audioBuffer.getChannelData(0); + let actual1 = audioBuffer.getChannelData(1); + let expected0 = audioBuffer.getChannelData(2); + let expected1 = audioBuffer.getChannelData(3); + + let threshold = options.absoluteThreshold ? + options.absoluteThreshold : absoluteThreshold; + + // Verify that each output channel of the convolver matches + // the delayed signal from the reference + should(actual0, options.prefix + ': Channel 0') + .beCloseToArray(expected0, {absoluteThreshold: threshold}); + should(actual1, options.prefix + ': Channel 1') + .beCloseToArray(expected1, {absoluteThreshold: threshold}); + }); + } + + function upMixTest(should, options) { + // Offline context with 3 channels: 0 = source + // 1 = convolver output, left, 2 = convolver output, right. Context + // destination must be discrete so that channels don't get mixed in + // unexpected ways. + let context = new OfflineAudioContext(3, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.maxChannelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + + // Mono response for convolver. Just a simple 1-frame delay. + let response = + new AudioBuffer({length: 2, sampleRate: context.sampleRate}); + response.getChannelData(0)[1] = 1; + + // Set mode to explicit and count to 2 so we manually force the + // convolver to produce stereo output. Without this, it would be + // mono input with mono response, which produces a mono output. + let conv; + + should( + () => {conv = new ConvolverNode(context, { + buffer: response, + disableNormalization: true, + channelCount: 2, + channelCountMode: options.channelCountMode + })}, + `new ConvolverNode({channelCountMode: '${ + options.channelCountMode}'})`) + .notThrow(); + + // Split output of convolver into individual channels. + let convSplit = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + + src.connect(conv); + conv.connect(convSplit); + + // Connect signals to destination in the desired way. + src.connect(merger, 0, 0); + convSplit.connect(merger, 0, 1); + convSplit.connect(merger, 1, 2); + + src.start(); + + return context.startRendering(); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-2-chan.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-2-chan.html new file mode 100644 index 0000000000..a73eb3f8ab --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-2-chan.html @@ -0,0 +1,373 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Convolver Channel Outputs for Response with 2 channels + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Test various convolver configurations when the convolver response has + // a stereo response. + + // This is somewhat arbitrary. It is the minimum value for which tests + // pass with both FFmpeg and KISS FFT implementations for 256 points. + // The value was similar for each implementation. + const absoluteThreshold = Math.pow(2, -21); + + // Fairly arbitrary sample rate, except that we want the rate to be a + // power of two so that 1/sampleRate is exactly representable as a + // single-precision float. + let sampleRate = 8192; + + // A fairly arbitrary number of frames, except the number of frames should + // be more than a few render quanta. + let renderFrames = 10 * 128; + + let audit = Audit.createTaskRunner(); + + // Convolver response + let response; + + audit.define( + { + label: 'initialize', + description: 'Convolver response with one channel' + }, + (task, should) => { + // Convolver response + should( + () => { + response = new AudioBuffer( + {numberOfChannels: 2, length: 4, sampleRate: sampleRate}); + // Each channel of the response is a simple impulse (with + // different delay) so that we can use a DelayNode to simulate + // the convolver output. Channel k is delayed by k+1 frames. + for (let k = 0; k < response.numberOfChannels; ++k) { + response.getChannelData(k)[k + 1] = 1; + } + }, + 'new AudioBuffer({numberOfChannels: 2, length: 4, sampleRate: ' + + sampleRate + '})') + .notThrow(); + + task.done(); + }); + + audit.define( + {label: '1-channel input', description: 'produces 2-channel output'}, + (task, should) => { + stereoResponseTest({numberOfInputs: 1, prefix: '1'}, should) + .then(() => task.done()); + }); + + audit.define( + {label: '2-channel input', description: 'produces 2-channel output'}, + (task, should) => { + stereoResponseTest({numberOfInputes: 2, prefix: '2'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '3-channel input', + description: '3->2 downmix producing 2-channel output' + }, + (task, should) => { + stereoResponseTest({numberOfInputs: 3, prefix: '3'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '4-channel input', + description: '4->2 downmix producing 2-channel output' + }, + (task, should) => { + stereoResponseTest({numberOfInputs: 4, prefix: '4'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '5.1-channel input', + description: '5.1->2 downmix producing 2-channel output' + }, + (task, should) => { + // Scale tolerance by maximum amplitude expected in down-mix + // output. + let threshold = (1.0 + Math.sqrt(0.5) * 2) * absoluteThreshold; + + stereoResponseTest({numberOfInputs: 6, prefix: '5.1', + absoluteThreshold: threshold}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '2-channel input, explicit mode', + description: 'produces 2-channel output' + }, + (task, should) => { + stereoResponseExplicitTest( + { + numberOfInputes: 2, + prefix: '2-in explicit mode' + }, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: '3-channel input explicit mode', + description: '3->1 downmix producing 2-channel output' + }, + (task, should) => { + stereoResponseExplicitTest( + { + numberOfInputs: 3, + prefix: '3-in explicit' + }, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: '4-channel input explicit mode', + description: '4->1 downmix producing 2-channel output' + }, + (task, should) => { + stereoResponseExplicitTest( + { + numberOfInputs: 4, + prefix: '4-in explicit' + }, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: '5.1-channel input explicit mode', + description: '5.1->1 downmix producing 2-channel output' + }, + (task, should) => { + // Scale tolerance by maximum amplitude expected in down-mix + // output. + let threshold = (Math.sqrt(0.5) * 2 + 2.0) * absoluteThreshold; + + stereoResponseExplicitTest( + { + numberOfInputs: 6, + prefix: '5.1-in explicit', + absoluteThreshold: threshold + }, + should) + .then(() => task.done()); + }); + + function stereoResponseTest(options, should) { + // Create an 4-channel offline context. The first two channels are for + // the stereo output of the convolver and the next two channels are for + // the reference stereo signal. + let context = new OfflineAudioContext(4, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + // Create oscillators for use as the input. The type and frequency is + // arbitrary except that oscillators must be different. + let src = new Array(options.numberOfInputs); + for (let k = 0; k < src.length; ++k) { + src[k] = new OscillatorNode( + context, {type: 'square', frequency: 440 + 220 * k}); + } + + // Merger to combine the oscillators into one output stream. + let srcMerger = + new ChannelMergerNode(context, {numberOfInputs: src.length}); + + for (let k = 0; k < src.length; ++k) { + src[k].connect(srcMerger, 0, k); + } + + // Convolver under test. + let conv = new ConvolverNode( + context, {disableNormalization: true, buffer: response}); + srcMerger.connect(conv); + + // Splitter to get individual channels of the convolver output so we can + // feed them (eventually) to the context in the right set of channels. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + conv.connect(splitter); + + // Reference graph consists of a delays node to simulate the response of + // the convolver. (The convolver response is designed this way.) + let delay = new Array(2); + for (let k = 0; k < delay.length; ++k) { + delay[k] = new DelayNode(context, { + delayTime: (k + 1) / context.sampleRate, + channelCount: 1, + channelCountMode: 'explicit' + }); + } + + // Gain node to mix the sources to stereo in the desired way. (Could be + // done in the delay node, but let's keep the mixing separated from the + // functionality.) + let gainMixer = new GainNode( + context, {channelCount: 2, channelCountMode: 'explicit'}); + srcMerger.connect(gainMixer); + + // Splitter to extract the channels of the reference signal. + let refSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + gainMixer.connect(refSplitter); + + // Connect each channel to the delay nodes + for (let k = 0; k < delay.length; ++k) { + refSplitter.connect(delay[k], k); + } + + // Final merger to bring back the individual channels from the convolver + // and the reference in the right order for the destination. + let finalMerger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + // First two channels are for the convolver output, and the next two are + // for the reference. + splitter.connect(finalMerger, 0, 0); + splitter.connect(finalMerger, 1, 1); + delay[0].connect(finalMerger, 0, 2); + delay[1].connect(finalMerger, 0, 3); + + finalMerger.connect(context.destination); + + // Start the sources at last. + for (let k = 0; k < src.length; ++k) { + src[k].start(); + } + + return context.startRendering().then(audioBuffer => { + // Extract the various channels out + let actual0 = audioBuffer.getChannelData(0); + let actual1 = audioBuffer.getChannelData(1); + let expected0 = audioBuffer.getChannelData(2); + let expected1 = audioBuffer.getChannelData(3); + + let threshold = options.absoluteThreshold ? + options.absoluteThreshold : absoluteThreshold; + + // Verify that each output channel of the convolver matches + // the delayed signal from the reference + should(actual0, options.prefix + ': Channel 0') + .beCloseToArray(expected0, {absoluteThreshold: threshold}); + should(actual1, options.prefix + ': Channel 1') + .beCloseToArray(expected1, {absoluteThreshold: threshold}); + }); + } + + function stereoResponseExplicitTest(options, should) { + // Create an 4-channel offline context. The first two channels are for + // the stereo output of the convolver and the next two channels are for + // the reference stereo signal. + let context = new OfflineAudioContext(4, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + // Create oscillators for use as the input. The type and frequency is + // arbitrary except that oscillators must be different. + let src = new Array(options.numberOfInputs); + for (let k = 0; k < src.length; ++k) { + src[k] = new OscillatorNode( + context, {type: 'square', frequency: 440 + 220 * k}); + } + + // Merger to combine the oscillators into one output stream. + let srcMerger = + new ChannelMergerNode(context, {numberOfInputs: src.length}); + + for (let k = 0; k < src.length; ++k) { + src[k].connect(srcMerger, 0, k); + } + + // Convolver under test. + let conv = new ConvolverNode(context, { + channelCount: 1, + channelCountMode: 'explicit', + disableNormalization: true, + buffer: response + }); + srcMerger.connect(conv); + + // Splitter to get individual channels of the convolver output so we can + // feed them (eventually) to the context in the right set of channels. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + conv.connect(splitter); + + // Reference graph consists of a delays node to simulate the response of + // the convolver. (The convolver response is designed this way.) + let delay = new Array(2); + for (let k = 0; k < delay.length; ++k) { + delay[k] = new DelayNode(context, { + delayTime: (k + 1) / context.sampleRate, + channelCount: 1, + channelCountMode: 'explicit' + }); + } + + // Gain node to mix the sources in the same way as the convolver. + let gainMixer = new GainNode( + context, {channelCount: 1, channelCountMode: 'explicit'}); + srcMerger.connect(gainMixer); + + // Connect each channel to the delay nodes + for (let k = 0; k < delay.length; ++k) { + gainMixer.connect(delay[k]); + } + + // Final merger to bring back the individual channels from the convolver + // and the reference in the right order for the destination. + let finalMerger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + // First two channels are for the convolver output, and the next two are + // for the reference. + splitter.connect(finalMerger, 0, 0); + splitter.connect(finalMerger, 1, 1); + delay[0].connect(finalMerger, 0, 2); + delay[1].connect(finalMerger, 0, 3); + + finalMerger.connect(context.destination); + + // Start the sources at last. + for (let k = 0; k < src.length; ++k) { + src[k].start(); + } + + return context.startRendering().then(audioBuffer => { + // Extract the various channels out + let actual0 = audioBuffer.getChannelData(0); + let actual1 = audioBuffer.getChannelData(1); + let expected0 = audioBuffer.getChannelData(2); + let expected1 = audioBuffer.getChannelData(3); + + let threshold = options.absoluteThreshold ? + options.absoluteThreshold : absoluteThreshold; + + // Verify that each output channel of the convolver matches + // the delayed signal from the reference + should(actual0, options.prefix + ': Channel 0') + .beCloseToArray(expected0, {absoluteThreshold: threshold}); + should(actual1, options.prefix + ': Channel 1') + .beCloseToArray(expected1, {absoluteThreshold: threshold}); + }); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-4-chan.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-4-chan.html new file mode 100644 index 0000000000..f188d87b71 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-response-4-chan.html @@ -0,0 +1,508 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Convolver Channel Outputs for Response with 4 channels + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Test various convolver configurations when the convolver response has + // a four channels. + + // This is somewhat arbitrary. It is the minimum value for which tests + // pass with both FFmpeg and KISS FFT implementations for 256 points. + // The value was similar for each implementation. + const absoluteThreshold = 3 * Math.pow(2, -22); + + // Fairly arbitrary sample rate, except that we want the rate to be a + // power of two so that 1/sampleRate is exactly representable as a + // single-precision float. + let sampleRate = 8192; + + // A fairly arbitrary number of frames, except the number of frames should + // be more than a few render quanta. + let renderFrames = 10 * 128; + + let audit = Audit.createTaskRunner(); + + // Convolver response + let response; + + audit.define( + { + label: 'initialize', + description: 'Convolver response with one channel' + }, + (task, should) => { + // Convolver response + should( + () => { + response = new AudioBuffer( + {numberOfChannels: 4, length: 8, sampleRate: sampleRate}); + // Each channel of the response is a simple impulse (with + // different delay) so that we can use a DelayNode to simulate + // the convolver output. Channel k is delayed by k+1 frames. + for (let k = 0; k < response.numberOfChannels; ++k) { + response.getChannelData(k)[k + 1] = 1; + } + }, + 'new AudioBuffer({numberOfChannels: 2, length: 4, sampleRate: ' + + sampleRate + '})') + .notThrow(); + + task.done(); + }); + + audit.define( + {label: '1-channel input', description: 'produces 2-channel output'}, + (task, should) => { + fourChannelResponseTest({numberOfInputs: 1, prefix: '1'}, should) + .then(() => task.done()); + }); + + audit.define( + {label: '2-channel input', description: 'produces 2-channel output'}, + (task, should) => { + fourChannelResponseTest({numberOfInputs: 2, prefix: '2'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '3-channel input', + description: '3->2 downmix producing 2-channel output' + }, + (task, should) => { + fourChannelResponseTest({numberOfInputs: 3, prefix: '3'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '4-channel input', + description: '4->2 downmix producing 2-channel output' + }, + (task, should) => { + fourChannelResponseTest({numberOfInputs: 4, prefix: '4'}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: '5.1-channel input', + description: '5.1->2 downmix producing 2-channel output' + }, + (task, should) => { + // Scale tolerance by maximum amplitude expected in down-mix + // output. + let threshold = (1.0 + Math.sqrt(0.5) * 2) * absoluteThreshold; + + fourChannelResponseTest({numberOfInputs: 6, prefix: '5.1', + absoluteThreshold: threshold}, should) + .then(() => task.done()); + }); + + audit.define( + { + label: 'delayed buffer set', + description: 'Delayed set of 4-channel response' + }, + (task, should) => { + // Don't really care about the output for this test. It's to verify + // we don't crash in a debug build when setting the convolver buffer + // after creating the graph. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + let src = new OscillatorNode(context); + let convolver = + new ConvolverNode(context, {disableNormalization: true}); + let buffer = new AudioBuffer({ + numberOfChannels: 4, + length: 4, + sampleRate: context.sampleRate + }); + + // Impulse responses for the convolver aren't important, as long as + // it's not all zeroes. + for (let k = 0; k < buffer.numberOfChannels; ++k) { + buffer.getChannelData(k).fill(1); + } + + src.connect(convolver).connect(context.destination); + + // Set the buffer after a few render quanta have passed. The actual + // value must be least one, but is otherwise arbitrary. + context.suspend(512 / context.sampleRate) + .then(() => convolver.buffer = buffer) + .then(() => context.resume()); + + src.start(); + context.startRendering() + .then(audioBuffer => { + // Just make sure output is not silent. + should( + audioBuffer.getChannelData(0), + 'Output with delayed setting of convolver buffer') + .notBeConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'count 1, 2-channel in', + description: '2->1 downmix because channel count is 1' + }, + (task, should) => { + channelCount1ExplicitTest( + {numberOfInputs: 1, prefix: 'Convolver count 1, stereo in'}, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: 'count 1, 4-channel in', + description: '4->1 downmix because channel count is 1' + }, + (task, should) => { + channelCount1ExplicitTest( + {numberOfInputs: 4, prefix: 'Convolver count 1, 4-channel in'}, + should) + .then(() => task.done()); + }); + + audit.define( + { + label: 'count 1, 5.1-channel in', + description: '5.1->1 downmix because channel count is 1' + }, + (task, should) => { + channelCount1ExplicitTest( + { + numberOfInputs: 6, + prefix: 'Convolver count 1, 5.1 channel in' + }, + should) + .then(() => task.done()); + }); + + audit.run(); + + function fourChannelResponseTest(options, should) { + // Create an 4-channel offline context. The first two channels are for + // the stereo output of the convolver and the next two channels are for + // the reference stereo signal. + let context = new OfflineAudioContext(4, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + // Create oscillators for use as the input. The type and frequency is + // arbitrary except that oscillators must be different. + let src = new Array(options.numberOfInputs); + for (let k = 0; k < src.length; ++k) { + src[k] = new OscillatorNode( + context, {type: 'square', frequency: 440 + 220 * k}); + } + + // Merger to combine the oscillators into one output stream. + let srcMerger = + new ChannelMergerNode(context, {numberOfInputs: src.length}); + + for (let k = 0; k < src.length; ++k) { + src[k].connect(srcMerger, 0, k); + } + + // Convolver under test. + let conv = new ConvolverNode( + context, {disableNormalization: true, buffer: response}); + srcMerger.connect(conv); + + // Splitter to get individual channels of the convolver output so we can + // feed them (eventually) to the context in the right set of channels. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + conv.connect(splitter); + + // Reference graph consists of a delays node to simulate the response of + // the convolver. (The convolver response is designed this way.) + let delay = new Array(4); + for (let k = 0; k < delay.length; ++k) { + delay[k] = new DelayNode(context, { + delayTime: (k + 1) / context.sampleRate, + channelCount: 1, + channelCountMode: 'explicit' + }); + } + + // Gain node to mix the sources to stereo in the desired way. (Could be + // done in the delay node, but let's keep the mixing separated from the + // functionality.) + let gainMixer = new GainNode( + context, {channelCount: 2, channelCountMode: 'explicit'}); + srcMerger.connect(gainMixer); + + // Splitter to extract the channels of the reference signal. + let refSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + gainMixer.connect(refSplitter); + + // Connect the left channel to the first two nodes and the right channel + // to the second two as required for "true" stereo matrix response. + for (let k = 0; k < 2; ++k) { + refSplitter.connect(delay[k], 0, 0); + refSplitter.connect(delay[k + 2], 1, 0); + } + + // Gain nodes to sum the responses to stereo + let gain = new Array(2); + for (let k = 0; k < gain.length; ++k) { + gain[k] = new GainNode(context, { + channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'discrete' + }); + } + + delay[0].connect(gain[0]); + delay[2].connect(gain[0]); + delay[1].connect(gain[1]); + delay[3].connect(gain[1]); + + // Final merger to bring back the individual channels from the convolver + // and the reference in the right order for the destination. + let finalMerger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + // First two channels are for the convolver output, and the next two are + // for the reference. + splitter.connect(finalMerger, 0, 0); + splitter.connect(finalMerger, 1, 1); + gain[0].connect(finalMerger, 0, 2); + gain[1].connect(finalMerger, 0, 3); + + finalMerger.connect(context.destination); + + // Start the sources at last. + for (let k = 0; k < src.length; ++k) { + src[k].start(); + } + + return context.startRendering().then(audioBuffer => { + // Extract the various channels out + let actual0 = audioBuffer.getChannelData(0); + let actual1 = audioBuffer.getChannelData(1); + let expected0 = audioBuffer.getChannelData(2); + let expected1 = audioBuffer.getChannelData(3); + + let threshold = options.absoluteThreshold ? + options.absoluteThreshold : absoluteThreshold; + + // Verify that each output channel of the convolver matches + // the delayed signal from the reference + should(actual0, options.prefix + ': Channel 0') + .beCloseToArray(expected0, {absoluteThreshold: threshold}); + should(actual1, options.prefix + ': Channel 1') + .beCloseToArray(expected1, {absoluteThreshold: threshold}); + }); + } + + function fourChannelResponseExplicitTest(options, should) { + // Create an 4-channel offline context. The first two channels are for + // the stereo output of the convolver and the next two channels are for + // the reference stereo signal. + let context = new OfflineAudioContext(4, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + + // Create oscillators for use as the input. The type and frequency is + // arbitrary except that oscillators must be different. + let src = new Array(options.numberOfInputs); + for (let k = 0; k < src.length; ++k) { + src[k] = new OscillatorNode( + context, {type: 'square', frequency: 440 + 220 * k}); + } + + // Merger to combine the oscillators into one output stream. + let srcMerger = + new ChannelMergerNode(context, {numberOfInputs: src.length}); + + for (let k = 0; k < src.length; ++k) { + src[k].connect(srcMerger, 0, k); + } + + // Convolver under test. + let conv = new ConvolverNode( + context, {disableNormalization: true, buffer: response}); + srcMerger.connect(conv); + + // Splitter to get individual channels of the convolver output so we can + // feed them (eventually) to the context in the right set of channels. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + conv.connect(splitter); + + // Reference graph consists of a delays node to simulate the response of + // the convolver. (The convolver response is designed this way.) + let delay = new Array(4); + for (let k = 0; k < delay.length; ++k) { + delay[k] = new DelayNode(context, { + delayTime: (k + 1) / context.sampleRate, + channelCount: 1, + channelCountMode: 'explicit' + }); + } + + // Gain node to mix the sources to stereo in the desired way. (Could be + // done in the delay node, but let's keep the mixing separated from the + // functionality.) + let gainMixer = new GainNode( + context, {channelCount: 2, channelCountMode: 'explicit'}); + srcMerger.connect(gainMixer); + + // Splitter to extract the channels of the reference signal. + let refSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + gainMixer.connect(refSplitter); + + // Connect the left channel to the first two nodes and the right channel + // to the second two as required for "true" stereo matrix response. + for (let k = 0; k < 2; ++k) { + refSplitter.connect(delay[k], 0, 0); + refSplitter.connect(delay[k + 2], 1, 0); + } + + // Gain nodes to sum the responses to stereo + let gain = new Array(2); + for (let k = 0; k < gain.length; ++k) { + gain[k] = new GainNode(context, { + channelCount: 1, + channelCountMode: 'explicit', + channelInterpretation: 'discrete' + }); + } + + delay[0].connect(gain[0]); + delay[2].connect(gain[0]); + delay[1].connect(gain[1]); + delay[3].connect(gain[1]); + + // Final merger to bring back the individual channels from the convolver + // and the reference in the right order for the destination. + let finalMerger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + + // First two channels are for the convolver output, and the next two are + // for the reference. + splitter.connect(finalMerger, 0, 0); + splitter.connect(finalMerger, 1, 1); + gain[0].connect(finalMerger, 0, 2); + gain[1].connect(finalMerger, 0, 3); + + finalMerger.connect(context.destination); + + // Start the sources at last. + for (let k = 0; k < src.length; ++k) { + src[k].start(); + } + + return context.startRendering().then(audioBuffer => { + // Extract the various channels out + let actual0 = audioBuffer.getChannelData(0); + let actual1 = audioBuffer.getChannelData(1); + let expected0 = audioBuffer.getChannelData(2); + let expected1 = audioBuffer.getChannelData(3); + + // Verify that each output channel of the convolver matches + // the delayed signal from the reference + should(actual0, options.prefix + ': Channel 0') + .beEqualToArray(expected0); + should(actual1, options.prefix + ': Channel 1') + .beEqualToArray(expected1); + }); + } + + function channelCount1ExplicitTest(options, should) { + // Create an 4-channel offline context. The first two channels are + // for the stereo output of the convolver and the next two channels + // are for the reference stereo signal. + let context = new OfflineAudioContext(4, renderFrames, sampleRate); + context.destination.channelInterpretation = 'discrete'; + // Final merger to bring back the individual channels from the + // convolver and the reference in the right order for the + // destination. + let finalMerger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + finalMerger.connect(context.destination); + + // Create source using oscillators + let src = new Array(options.numberOfInputs); + for (let k = 0; k < src.length; ++k) { + src[k] = new OscillatorNode( + context, {type: 'square', frequency: 440 + 220 * k}); + } + + // Merger to combine the oscillators into one output stream. + let srcMerger = + new ChannelMergerNode(context, {numberOfInputs: src.length}); + for (let k = 0; k < src.length; ++k) { + src[k].connect(srcMerger, 0, k); + } + + // Convolver under test + let conv = new ConvolverNode(context, { + channelCount: 1, + channelCountMode: 'explicit', + disableNormalization: true, + buffer: response + }); + srcMerger.connect(conv); + + // Splitter to extract the channels of the test signal. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + conv.connect(splitter); + + // Reference convolver, with a gain node to do the desired mixing. The + // gain node should do the same thing that the convolver under test + // should do. + let gain = new GainNode( + context, {channelCount: 1, channelCountMode: 'explicit'}); + let convRef = new ConvolverNode( + context, {disableNormalization: true, buffer: response}); + + srcMerger.connect(gain).connect(convRef); + + // Splitter to extract the channels of the reference signal. + let refSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + + convRef.connect(refSplitter); + + // Merge all the channels into one + splitter.connect(finalMerger, 0, 0); + splitter.connect(finalMerger, 1, 1); + refSplitter.connect(finalMerger, 0, 2); + refSplitter.connect(finalMerger, 1, 3); + + // Start sources and render! + for (let k = 0; k < src.length; ++k) { + src[k].start(); + } + + return context.startRendering().then(buffer => { + // The output from the test convolver should be identical to + // the reference result. + let testOut0 = buffer.getChannelData(0); + let testOut1 = buffer.getChannelData(1); + let refOut0 = buffer.getChannelData(2); + let refOut1 = buffer.getChannelData(3); + + should(testOut0, `${options.prefix}: output 0`) + .beEqualToArray(refOut0); + should(testOut1, `${options.prefix}: output 1`) + .beEqualToArray(refOut1); + }) + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-setBuffer-already-has-value.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-setBuffer-already-has-value.html new file mode 100644 index 0000000000..ce2d5fcfe9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-setBuffer-already-has-value.html @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> + <head> + <title> + convolver-setBuffer-already-has-value.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('test', (task, should) => { + let context = new AudioContext(); + let audioBuffer = new AudioBuffer( + {numberOfChannels: 1, length: 1, sampleRate: context.sampleRate}); + let convolver = context.createConvolver(); + should(() => { + convolver.buffer = null; + }, 'Set buffer to null before set non-null').notThrow(); + + should(() => { + convolver.buffer = audioBuffer; + }, 'Set buffer first normally').notThrow(); + + should(() => { + convolver.buffer = audioBuffer; + }, 'Set buffer a second time').notThrow(); + + should(() => { + convolver.buffer = null; + }, 'Set buffer to null').notThrow(); + + should(() => { + convolver.buffer = null; + }, 'Set buffer to null again, to make sure').notThrow(); + + should(() => { + convolver.buffer = audioBuffer; + }, 'Set buffer to non-null to verify it is set') + .notThrow(); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-setBuffer-null.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-setBuffer-null.html new file mode 100644 index 0000000000..d35b8ec54b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-setBuffer-null.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> + <head> + <title> + convolver-setBuffer-null.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('test', function(task, should) { + let context = new AudioContext(); + let conv = context.createConvolver(); + + should(() => { + conv.buffer = null; + }, 'Setting ConvolverNode impulse response buffer to null').notThrow(); + should(conv.buffer === null, 'conv.buffer === null').beTrue(); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-upmixing-1-channel-response.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-upmixing-1-channel-response.html new file mode 100644 index 0000000000..b0b3a5965e --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/convolver-upmixing-1-channel-response.html @@ -0,0 +1,143 @@ +<!DOCTYPE html> +<title>Test that up-mixing signals in ConvolverNode processing is linear</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +const EPSILON = 3.0 * Math.pow(2, -22); +// sampleRate is a power of two so that delay times are exact in base-2 +// floating point arithmetic. +const SAMPLE_RATE = 32768; +// Length of stereo convolver input in frames (arbitrary): +const STEREO_FRAMES = 256; +// Length of mono signal in frames. This is more than two blocks to ensure +// that at least one block will be mono, even if interpolation in the +// DelayNode means that stereo is output one block earlier and later than +// if frames are delayed without interpolation. +const MONO_FRAMES = 384; +// Length of response buffer: +const RESPONSE_FRAMES = 256; + +function test_linear_upmixing(channelInterpretation, initial_mono_frames) +{ + let stereo_input_end = initial_mono_frames + STEREO_FRAMES; + // Total length: + let length = stereo_input_end + RESPONSE_FRAMES + MONO_FRAMES + STEREO_FRAMES; + // The first two channels contain signal where some up-mixing occurs + // internally to a ConvolverNode when a stereo signal is added and removed. + // The last two channels are expected to contain the same signal, but mono + // and stereo signals are convolved independently before up-mixing the mono + // output to mix with the stereo output. + let context = new OfflineAudioContext({numberOfChannels: 4, + length: length, + sampleRate: SAMPLE_RATE}); + + let response = new AudioBuffer({numberOfChannels: 1, + length: RESPONSE_FRAMES, + sampleRate: context.sampleRate}); + + // Two stereo channel splitters will collect test and reference outputs. + let destinationMerger = new ChannelMergerNode(context, {numberOfInputs: 4}); + destinationMerger.connect(context.destination); + let testSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + let referenceSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + testSplitter.connect(destinationMerger, 0, 0); + testSplitter.connect(destinationMerger, 1, 1); + referenceSplitter.connect(destinationMerger, 0, 2); + referenceSplitter.connect(destinationMerger, 1, 3); + + // A GainNode mixes reference stereo and mono signals because up-mixing + // cannot be performed at a channel splitter. + let referenceGain = new GainNode(context); + referenceGain.connect(referenceSplitter); + referenceGain.channelInterpretation = channelInterpretation; + + // The impulse response for convolution contains two impulses so as to test + // effects in at least two processing blocks. + response.getChannelData(0)[0] = 0.5; + response.getChannelData(0)[response.length - 1] = 0.5; + + let testConvolver = new ConvolverNode(context, {disableNormalization: true, + buffer: response}); + testConvolver.channelInterpretation = channelInterpretation; + let referenceMonoConvolver = new ConvolverNode(context, + {disableNormalization: true, + buffer: response}); + let referenceStereoConvolver = new ConvolverNode(context, + {disableNormalization: true, + buffer: response}); + // No need to set referenceStereoConvolver.channelInterpretation because + // input is either silent or stereo. + testConvolver.connect(testSplitter); + // Mix reference convolver output. + referenceMonoConvolver.connect(referenceGain); + referenceStereoConvolver.connect(referenceGain); + + // The DelayNode initially has a single channel of silence, which is used to + // switch the stereo signal in and out. The output of the delay node is + // first mono silence (if there is a non-zero initial_mono_frames), then + // stereo, then mono silence, and finally stereo again. maxDelayTime is + // used to generate the middle mono silence period from the initial silence + // in the DelayNode and then generate the final period of stereo from its + // initial input. + let maxDelayTime = (length - STEREO_FRAMES) / context.sampleRate; + let delay = + new DelayNode(context, + {maxDelayTime: maxDelayTime, + delayTime: initial_mono_frames / context.sampleRate}); + // Schedule an increase in the delay to return to mono silence. + delay.delayTime.setValueAtTime(maxDelayTime, + stereo_input_end / context.sampleRate); + delay.connect(testConvolver); + delay.connect(referenceStereoConvolver); + + let stereoMerger = new ChannelMergerNode(context, {numberOfInputs: 2}); + stereoMerger.connect(delay); + + // Three independent signals + let monoSignal = new OscillatorNode(context, {frequency: 440}); + let leftSignal = new OscillatorNode(context, {frequency: 450}); + let rightSignal = new OscillatorNode(context, {frequency: 460}); + monoSignal.connect(testConvolver); + monoSignal.connect(referenceMonoConvolver); + leftSignal.connect(stereoMerger, 0, 0); + rightSignal.connect(stereoMerger, 0, 1); + monoSignal.start(); + leftSignal.start(); + rightSignal.start(); + + return context.startRendering(). + then((buffer) => { + let maxDiff = -1.0; + let frameIndex = 0; + let channelIndex = 0; + for (let c = 0; c < 2; ++c) { + let testOutput = buffer.getChannelData(0 + c); + let referenceOutput = buffer.getChannelData(2 + c); + for (var i = 0; i < buffer.length; ++i) { + var diff = Math.abs(testOutput[i] - referenceOutput[i]); + if (diff > maxDiff) { + maxDiff = diff; + frameIndex = i; + channelIndex = c; + } + } + } + assert_approx_equals(buffer.getChannelData(0 + channelIndex)[frameIndex], + buffer.getChannelData(2 + channelIndex)[frameIndex], + EPSILON, + `output at ${frameIndex} ` + + `in channel ${channelIndex}` ); + }); +} + +promise_test(() => test_linear_upmixing("speakers", MONO_FRAMES), + "speakers, initially mono"); +promise_test(() => test_linear_upmixing("discrete", MONO_FRAMES), + "discrete"); +// Gecko uses a separate path for "speakers" up-mixing when the convolver's +// first input is stereo, so test that separately. +promise_test(() => test_linear_upmixing("speakers", 0), + "speakers, initially stereo"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/ctor-convolver.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/ctor-convolver.html new file mode 100644 index 0000000000..28a0fc1c3c --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/ctor-convolver.html @@ -0,0 +1,186 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: Convolver + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'ConvolverNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'ConvolverNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'clamped-max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes( + should, node, prefix, + [{name: 'normalize', value: true}, {name: 'buffer', value: null}]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + // Can't use testAudioNodeOptions because the constraints for this node + // are not supported there. + let node; + + // An array of tests. + [{ + // Test that we can set the channel count to 1 or 2 and that other + // channel counts throw an error. + attribute: 'channelCount', + tests: [ + {value: 1}, {value: 2}, {value: 0, error: 'NotSupportedError'}, + {value: 3, error: 'NotSupportedError'}, + {value: 99, error: 'NotSupportedError'} + ] + }, + { + // Test channelCountMode. A mode of "max" is illegal, but others are + // ok. But also throw an error of unknown values. + attribute: 'channelCountMode', + tests: [ + {value: 'clamped-max'}, {value: 'explicit'}, + {value: 'max', error: 'NotSupportedError'}, + {value: 'foobar', error: TypeError} + ] + }, + { + // Test channelInterpretation can be set for valid values and an + // error is thrown for others. + attribute: 'channelInterpretation', + tests: [ + {value: 'speakers'}, {value: 'discrete'}, + {value: 'foobar', error: TypeError} + ] + }].forEach(entry => { + entry.tests.forEach(testItem => { + let options = {}; + options[entry.attribute] = testItem.value; + + const testFunction = () => { + node = new ConvolverNode(context, options); + }; + const testDescription = + `new ConvolverNode(c, ${JSON.stringify(options)})`; + + if (testItem.error) { + testItem.error === TypeError + ? should(testFunction, testDescription).throw(TypeError) + : should(testFunction, testDescription) + .throw(DOMException, 'NotSupportedError'); + } else { + should(testFunction, testDescription).notThrow(); + should(node[entry.attribute], `node.${entry.attribute}`) + .beEqualTo(options[entry.attribute]); + } + }); + }); + + task.done(); + }); + + audit.define('nullable buffer', (task, should) => { + let node; + let options = {buffer: null}; + + should( + () => { + node = new ConvolverNode(context, options); + }, + 'node1 = new ConvolverNode(c, ' + JSON.stringify(options)) + .notThrow(); + + should(node.buffer, 'node1.buffer').beEqualTo(null); + + task.done(); + }); + audit.define('illegal sample-rate', (task, should) => { + let node; + let options = {buffer: context.createBuffer(1, 1, context.sampleRate / 2)}; + + should( + () => { + node = new ConvolverNode(context, options); + }, + 'node1 = new ConvolverNode(c, ' + JSON.stringify(options)) + .throw(DOMException, 'NotSupportedError'); + + task.done(); + }); + + audit.define('construct with options', (task, should) => { + let buf = context.createBuffer(1, 1, context.sampleRate); + let options = {buffer: buf, disableNormalization: false}; + + let message = + 'node = new ConvolverNode(c, ' + JSON.stringify(options) + ')'; + + let node; + should(() => { + node = new ConvolverNode(context, options); + }, message).notThrow(); + + should(node instanceof ConvolverNode, 'node1 instanceOf ConvolverNode') + .beEqualTo(true); + should(node.buffer === options.buffer, 'node1.buffer === <buf>') + .beEqualTo(true); + should(node.normalize, 'node1.normalize') + .beEqualTo(!options.disableNormalization); + + options.buffer = null; + options.disableNormalization = true; + + message = + 'node2 = new ConvolverNode(, ' + JSON.stringify(options) + ')'; + + should(() => { + node = new ConvolverNode(context, options); + }, message).notThrow(); + should(node.buffer, 'node2.buffer').beEqualTo(null); + should(node.normalize, 'node2.normalize') + .beEqualTo(!options.disableNormalization); + + options.disableNormalization = false; + message = 'node3 = new ConvolverNode(context, ' + + JSON.stringify(options) + ')'; + + should(() => { + node = new ConvolverNode(context, options); + }, message).notThrow(); + should(node.buffer, 'node3.buffer').beEqualTo(null); + should(node.normalize, 'node3.normalize') + .beEqualTo(!options.disableNormalization); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/realtime-conv.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/realtime-conv.html new file mode 100644 index 0000000000..505f0f03f5 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/realtime-conv.html @@ -0,0 +1,152 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Convolver on Real-time Context + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/convolution-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + // Choose a length that is larger enough to cause multiple threads to be + // used in the convolver. For browsers that don't support this, this + // value doesn't matter. + const pulseLength = 16384; + + // The computed SNR should be at least this large. This value depends on + // the platform and browser. Don't set this value to be to much lower + // than this. It probably indicates a fairly inaccurate convolver or + // constant source node automations that should be fixed instead. + // + // Any major change of operating system or CPU architecture might affect + // this value significantly. See: https://crbug.com/1339291 + const minRequiredSNR = 68.40; + + // To test the real-time convolver, we convolve two square pulses together + // to produce a triangular pulse. To verify the result is correct we + // compare it against a constant source node configured to generate the + // expected ramp. + audit.define( + {label: 'test', description: 'Test convolver with real-time context'}, + (task, should) => { + // Use a power of two for the sample rate to eliminate round-off in + // computing times from frames. + const context = new AudioContext({sampleRate: 16384}); + + // Square pulse for the convolver impulse response. + const squarePulse = new AudioBuffer( + {length: pulseLength, sampleRate: context.sampleRate}); + squarePulse.getChannelData(0).fill(1); + + const convolver = new ConvolverNode( + context, {buffer: squarePulse, disableNormalization: true}); + + // Square pulse for testing + const srcSquare = new ConstantSourceNode(context, {offset: 0}); + srcSquare.connect(convolver); + + // Reference ramp. Automations on this constant source node will + // generate the desired ramp. + const srcRamp = new ConstantSourceNode(context, {offset: 0}); + + // Use these gain nodes to compute the difference between the + // convolver output and the expected ramp to create the error + // signal. + const inverter = new GainNode(context, {gain: -1}); + const sum = new GainNode(context, {gain: 1}); + convolver.connect(sum); + srcRamp.connect(inverter).connect(sum); + + // Square the error signal using this gain node. + const squarer = new GainNode(context, {gain: 0}); + sum.connect(squarer); + sum.connect(squarer.gain); + + // Merge the error signal and the square source so we can integrate + // the error signal to find an SNR. + const merger = new ChannelMergerNode(context, {numberOfInputs: 2}); + + squarer.connect(merger, 0, 0); + srcSquare.connect(merger, 0, 1); + + // For simplicity, use a ScriptProcessor to integrate the error + // signal. The square pulse signal is used as a gate over which the + // integration is done. When the pulse ends, the SNR is computed + // and the test ends. + + // |doSum| is used to determine when to integrate and when it + // becomes false, it signals the end of integration. + let doSum = false; + + // |signalSum| is the energy in the square pulse. |errorSum| is the + // energy in the error signal. + let signalSum = 0; + let errorSum = 0; + + let spn = context.createScriptProcessor(0, 2, 1); + spn.onaudioprocess = (event) => { + // Sum the values on the first channel when the second channel is + // not zero. When the second channel goes from non-zero to 0, + // dump the value out and terminate the test. + let c0 = event.inputBuffer.getChannelData(0); + let c1 = event.inputBuffer.getChannelData(1); + + for (let k = 0; k < c1.length; ++k) { + if (c1[k] == 0) { + if (doSum) { + doSum = false; + // Square wave is now silent and we were integration, so we + // can stop now and verify the SNR. + should(10 * Math.log10(signalSum / errorSum), 'SNR') + .beGreaterThanOrEqualTo(minRequiredSNR); + spn.onaudioprocess = null; + task.done(); + } + } else { + // Signal is non-zero so sum up the values. + doSum = true; + errorSum += c0[k]; + signalSum += c1[k] * c1[k]; + } + } + }; + + merger.connect(spn).connect(context.destination); + + // Schedule everything to start a bit in the futurefrom now, and end + // pulseLength frames later. + let now = context.currentTime; + + // |startFrame| is the number of frames to schedule ahead for + // testing. + const startFrame = 512; + const startTime = startFrame / context.sampleRate; + const pulseDuration = pulseLength / context.sampleRate; + + // Create a square pulse in the constant source node. + srcSquare.offset.setValueAtTime(1, now + startTime); + srcSquare.offset.setValueAtTime(0, now + startTime + pulseDuration); + + // Create the reference ramp. + srcRamp.offset.setValueAtTime(1, now + startTime); + srcRamp.offset.linearRampToValueAtTime( + pulseLength, + now + startTime + pulseDuration - 1 / context.sampleRate); + srcRamp.offset.linearRampToValueAtTime( + 0, + now + startTime + 2 * pulseDuration - 1 / context.sampleRate); + + // Start the ramps! + srcRamp.start(); + srcSquare.start(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/transferred-buffer-output.html b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/transferred-buffer-output.html new file mode 100644 index 0000000000..e37a98c386 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-convolvernode-interface/transferred-buffer-output.html @@ -0,0 +1,107 @@ +<!doctype html> +<html> + <head> + <title> + Test Convolver Output with Transferred Buffer + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + // Arbitrary sample rate. + const sampleRate = 16000; + + // Number of frames to render. Just need to have at least 2 render + // quanta. + const lengthInFrames = 10 * RENDER_QUANTUM_FRAMES; + + let audit = Audit.createTaskRunner(); + + // Buffer to use for the impulse response of a ConvolverNode. + let impulseBuffer; + + // This sets up a worker to receive one channel of an AudioBuffer. + function setUpWorkerForTest() { + impulseBuffer = new AudioBuffer({ + numberOfChannels: 2, + length: 2 * RENDER_QUANTUM_FRAMES, + sampleRate: sampleRate + }); + + // Just fill the buffer with a constant value; the contents shouldn't + // matter for this test since we're transferring one of the channels. + impulseBuffer.getChannelData(0).fill(1); + impulseBuffer.getChannelData(1).fill(2); + + // We're going to transfer channel 0 to the worker, making it + // unavailable for the convolver + let data = impulseBuffer.getChannelData(0).buffer; + + let string = [ + 'onmessage = function(e) {', ' postMessage(\'done\');', '};' + ].join('\n'); + + let blobURL = URL.createObjectURL(new Blob([string])); + let worker = new Worker(blobURL); + worker.onmessage = workerReply; + worker.postMessage(data, [data]); + } + + function workerReply() { + // Worker has received the message. Run the test. + audit.run(); + } + + audit.define( + { + label: 'Test Convolver with transferred buffer', + description: 'Output should be all zeroes' + }, + async (task, should) => { + // Two channels so we can capture the output of the convolver with a + // stereo convolver. + let context = new OfflineAudioContext({ + numberOfChannels: 2, + length: lengthInFrames, + sampleRate: sampleRate + }); + + // Use a simple constant source so we easily check that the + // convolver output is correct. + let source = new ConstantSourceNode(context); + + // Create the convolver with the desired impulse response and + // disable normalization so we can easily check the output. + let conv = new ConvolverNode( + context, {disableNormalization: true, buffer: impulseBuffer}); + + source.connect(conv).connect(context.destination); + + source.start(); + + let renderedBuffer = await context.startRendering(); + + // Get the actual data + let c0 = renderedBuffer.getChannelData(0); + let c1 = renderedBuffer.getChannelData(1); + + // Since one channel was transferred, we must behave as if all were + // transferred. Hence, the output should be all zeroes for both + // channels. + should(c0, `Convolver channel 0 output[0:${c0.length - 1}]`) + .beConstantValueOf(0); + + should(c1, `Convolver channel 1 output[0:${c1.length - 1}]`) + .beConstantValueOf(0); + + task.done(); + }); + + setUpWorkerForTest(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/ctor-delay.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/ctor-delay.html new file mode 100644 index 0000000000..e7ccefc655 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/ctor-delay.html @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: Delay + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'DelayNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'DelayNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes( + should, node, prefix, [{name: 'delayTime', value: 0}]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'DelayNode'); + task.done(); + }); + + audit.define('constructor options', (task, should) => { + let node; + let options = { + delayTime: 0.5, + maxDelayTime: 1.5, + }; + + should( + () => { + node = new DelayNode(context, options); + }, + 'node1 = new DelayNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + should(node.delayTime.value, 'node1.delayTime.value') + .beEqualTo(options.delayTime); + should(node.delayTime.maxValue, 'node1.delayTime.maxValue') + .beEqualTo(options.maxDelayTime); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delay-test.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delay-test.html new file mode 100644 index 0000000000..6277c253ec --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delay-test.html @@ -0,0 +1,61 @@ +<!doctype html> +<html> + <head> + <title>Test DelayNode Delay</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test0', description: 'Test delay of 3 frames'}, + async (task, should) => { + // Only need a few outputs samples. The sample rate is arbitrary. + const context = + new OfflineAudioContext(1, RENDER_QUANTUM_FRAMES, 8192); + let src; + let delay; + + should( + () => { + src = new ConstantSourceNode(context); + delay = new DelayNode(context); + }, + 'Creating ConstantSourceNode(context) and DelayNode(context)') + .notThrow(); + + // The number of frames to delay for the DelayNode. Should be a + // whole number, but is otherwise arbitrary. + const delayFrames = 3; + + should(() => { + delay.delayTime.value = delayFrames / context.sampleRate; + }, `Setting delayTime to ${delayFrames} frames`).notThrow(); + + src.connect(delay).connect(context.destination); + + src.start(); + + let buffer = await context.startRendering(); + let output = buffer.getChannelData(0); + + // Verify output was delayed the correct number of frames. + should(output.slice(0, delayFrames), `output[0:${delayFrames - 1}]`) + .beConstantValueOf(0); + should( + output.slice(delayFrames), + `output[${delayFrames}:${output.length - 1}]`) + .beConstantValueOf(1); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-channel-count-1.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-channel-count-1.html new file mode 100644 index 0000000000..dd964ef9e3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-channel-count-1.html @@ -0,0 +1,104 @@ +<!DOCTYPE html> +<title>Test that DelayNode output channelCount matches that of the delayed input</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +// See https://github.com/WebAudio/web-audio-api/issues/25 + +// sampleRate is a power of two so that delay times are exact in base-2 +// floating point arithmetic. +const SAMPLE_RATE = 32768; +// Arbitrary delay time in frames (but this is assumed a multiple of block +// size below): +const DELAY_FRAMES = 3 * 128; +// Implementations may apply interpolation to input samples, which can spread +// the effect of input with larger channel counts over neighbouring blocks. +// This test ignores enough neighbouring blocks to ignore the effects of +// filter radius of up to this number of frames: +const INTERPOLATION_GRACE = 128; +// Number of frames of DelayNode output that are known to be stereo: +const STEREO_FRAMES = 128; +// The delay will be increased at this frame to switch DelayNode output back +// to mono. +const MONO_OUTPUT_START_FRAME = + DELAY_FRAMES + INTERPOLATION_GRACE + STEREO_FRAMES; +// Number of frames of output that are known to be mono after the known stereo +// and interpolation grace. +const MONO_FRAMES = 128; +// Total length allows for interpolation after effects of stereo input are +// finished and one block to test return to mono output: +const TOTAL_LENGTH = + MONO_OUTPUT_START_FRAME + INTERPOLATION_GRACE + MONO_FRAMES; +// maxDelayTime, is a multiple of block size, because the Gecko implementation +// once had a bug with delayTime = maxDelayTime in this situation: +const MAX_DELAY_FRAMES = TOTAL_LENGTH + INTERPOLATION_GRACE; + +promise_test(() => { + let context = new OfflineAudioContext({numberOfChannels: 1, + length: TOTAL_LENGTH, + sampleRate: SAMPLE_RATE}); + + // Only channel 1 of the splitter is connected to the destination. + let splitter = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + splitter.connect(context.destination, 1); + + // A gain node has channelCountMode "max" and channelInterpretation + // "speakers", and so will up-mix a mono input when there is stereo input. + let gain = new GainNode(context); + gain.connect(splitter); + + // The delay node initially outputs a single channel of silence, when it + // does not have enough signal in its history to output what it has + // previously received. After the delay period, it will then output the + // stereo signal it received. + let delay = + new DelayNode(context, + {maxDelayTime: MAX_DELAY_FRAMES / context.sampleRate, + delayTime: DELAY_FRAMES / context.sampleRate}); + // Schedule an increase in the delay to return to mono silent output from + // the unfilled portion of the DelayNode's buffer. + delay.delayTime.setValueAtTime(MAX_DELAY_FRAMES / context.sampleRate, + MONO_OUTPUT_START_FRAME / context.sampleRate); + delay.connect(gain); + + let stereoMerger = new ChannelMergerNode(context, {numberOfInputs: 2}); + stereoMerger.connect(delay); + + let leftOffset = 0.125; + let rightOffset = 0.5; + let leftSource = new ConstantSourceNode(context, {offset: leftOffset}); + let rightSource = new ConstantSourceNode(context, {offset: rightOffset}); + leftSource.start(); + rightSource.start(); + leftSource.connect(stereoMerger, 0, 0); + rightSource.connect(stereoMerger, 0, 1); + // Connect a mono source directly to the gain, so that even stereo silence + // will be detected in channel 1 of the gain output because it will cause + // the mono source to be up-mixed. + let monoOffset = 0.25 + let monoSource = new ConstantSourceNode(context, {offset: monoOffset}); + monoSource.start(); + monoSource.connect(gain); + + return context.startRendering(). + then((buffer) => { + let output = buffer.getChannelData(0); + + function assert_samples_equal(startIndex, length, expected, description) + { + for (let i = startIndex; i < startIndex + length; ++i) { + assert_equals(output[i], expected, description + ` at ${i}`); + } + } + + assert_samples_equal(0, DELAY_FRAMES - INTERPOLATION_GRACE, + 0, "Initial mono"); + assert_samples_equal(DELAY_FRAMES + INTERPOLATION_GRACE, STEREO_FRAMES, + monoOffset + rightOffset, "Stereo"); + assert_samples_equal(MONO_OUTPUT_START_FRAME + INTERPOLATION_GRACE, + MONO_FRAMES, + 0, "Final mono"); + }); +}); + +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-max-default-delay.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-max-default-delay.html new file mode 100644 index 0000000000..ef526c96ff --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-max-default-delay.html @@ -0,0 +1,49 @@ +<!DOCTYPE html> +<html> + <head> + <title> + delaynode-max-default-delay.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/delay-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'DelayNode with delay set to default maximum delay' + }, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = createToneBuffer( + context, 20, 20 * toneLengthSeconds, sampleRate); // 20Hz tone + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + + let delay = context.createDelay(); + delayTimeSeconds = 1; + delay.delayTime.value = delayTimeSeconds; + + bufferSource.connect(delay); + delay.connect(context.destination); + bufferSource.start(0); + + context.startRendering() + .then(buffer => checkDelayedResult(buffer, toneBuffer, should)) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-max-nondefault-delay.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-max-nondefault-delay.html new file mode 100644 index 0000000000..3be07255e1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-max-nondefault-delay.html @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> + <head> + <title> + delaynode-max-nondefault-delay.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/delay-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'DelayNode with delay set to non-default maximum delay' + }, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = createToneBuffer( + context, 20, 20 * toneLengthSeconds, sampleRate); // 20Hz tone + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + + let maxDelay = 1.5; + let delay = context.createDelay(maxDelay); + delayTimeSeconds = maxDelay; + delay.delayTime.value = delayTimeSeconds; + + bufferSource.connect(delay); + delay.connect(context.destination); + bufferSource.start(0); + + context.startRendering() + .then(buffer => checkDelayedResult(buffer, toneBuffer, should)) + .then(() => task.done()); + ; + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-maxdelay.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-maxdelay.html new file mode 100644 index 0000000000..a43ceeb7be --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-maxdelay.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html> + <head> + <title> + delaynode-maxdelay.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/delay-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: + 'Basic functionality of DelayNode with a non-default max delay time' + }, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = createToneBuffer( + context, 20, 20 * toneLengthSeconds, sampleRate); // 20Hz tone + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + + // Create a delay node with an explicit max delay time (greater than + // the default of 1 second). + let delay = context.createDelay(2); + // Set the delay time to a value greater than the default max delay + // so we can verify the delay is working for this case. + delayTimeSeconds = 1.5; + delay.delayTime.value = delayTimeSeconds; + + bufferSource.connect(delay); + delay.connect(context.destination); + bufferSource.start(0); + + context.startRendering() + .then(buffer => checkDelayedResult(buffer, toneBuffer, should)) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-maxdelaylimit.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-maxdelaylimit.html new file mode 100644 index 0000000000..caf2f85dfd --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-maxdelaylimit.html @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<html> + <head> + <title> + delaynode-maxdelaylimit.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/delay-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: + 'Tests attribute and maximum allowed delay of DelayNode' + }, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = createToneBuffer( + context, 20, 20 * toneLengthSeconds, sampleRate); // 20Hz tone + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + + window.context = context; + should(() => context.createDelay(180), + 'Setting Delay length to 180 seconds or more') + .throw(DOMException, 'NotSupportedError'); + should(() => context.createDelay(0), + 'Setting Delay length to 0 seconds') + .throw(DOMException, 'NotSupportedError'); + should(() => context.createDelay(-1), + 'Setting Delay length to negative') + .throw(DOMException, 'NotSupportedError'); + should(() => context.createDelay(NaN), + 'Setting Delay length to NaN') + .throw(TypeError); + + let delay = context.createDelay(179); + delay.delayTime.value = delayTimeSeconds; + window.delay = delay; + should( + delay.delayTime.value, + 'delay.delayTime.value = ' + delayTimeSeconds) + .beEqualTo(delayTimeSeconds); + + bufferSource.connect(delay); + delay.connect(context.destination); + bufferSource.start(0); + + context.startRendering() + .then(buffer => checkDelayedResult(buffer, toneBuffer, should)) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-scheduling.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-scheduling.html new file mode 100644 index 0000000000..af6c54950a --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode-scheduling.html @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> + <head> + <title> + delaynode-scheduling.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/delay-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: + 'DelayNode delayTime parameter can be scheduled at a given time' + }, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = createToneBuffer( + context, 20, 20 * toneLengthSeconds, sampleRate); // 20Hz tone + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + + let delay = context.createDelay(); + + // Schedule delay time at time zero. + delay.delayTime.setValueAtTime(delayTimeSeconds, 0); + + bufferSource.connect(delay); + delay.connect(context.destination); + bufferSource.start(0); + + context.startRendering() + .then(buffer => checkDelayedResult(buffer, toneBuffer, should)) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode.html new file mode 100644 index 0000000000..da508e439f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/delaynode.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <title> + delaynode.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/delay-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Tests attribute and basic functionality of DelayNode' + }, + function(task, should) { + + // Create offline audio context. + let context = new OfflineAudioContext( + 1, sampleRate * renderLengthSeconds, sampleRate); + let toneBuffer = createToneBuffer( + context, 20, 20 * toneLengthSeconds, sampleRate); // 20Hz tone + + let bufferSource = context.createBufferSource(); + bufferSource.buffer = toneBuffer; + + let delay = context.createDelay(); + + window.delay = delay; + should(delay.numberOfInputs, 'delay.numberOfInputs').beEqualTo(1); + should(delay.numberOfOutputs, 'delay.numberOfOutputs').beEqualTo(1); + should(delay.delayTime.defaultValue, 'delay.delayTime.defaultValue') + .beEqualTo(0.0); + should(delay.delayTime.value, 'delay.delayTime.value') + .beEqualTo(0.0); + + delay.delayTime.value = delayTimeSeconds; + should( + delay.delayTime.value, + 'delay.delayTime.value = ' + delayTimeSeconds) + .beEqualTo(delayTimeSeconds); + + bufferSource.connect(delay); + delay.connect(context.destination); + bufferSource.start(0); + + context.startRendering() + .then(buffer => checkDelayedResult(buffer, toneBuffer, should)) + .then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/maxdelay-rounding.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/maxdelay-rounding.html new file mode 100644 index 0000000000..84d9f18138 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/maxdelay-rounding.html @@ -0,0 +1,78 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test DelayNode when maxDelayTime requires rounding + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 44100; + let inputLengthSeconds = 1; + let renderLengthSeconds = 2; + + // Delay for one second plus 0.4 of a sample frame, to test that + // DelayNode is properly rounding up when calculating its buffer + // size (crbug.com/1065110). + let delayTimeSeconds = 1 + 0.4 / sampleRate; + + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'maxdelay-rounding', + description: 'Test DelayNode when maxDelayTime requires rounding', + }, + (task, should) => { + let context = new OfflineAudioContext({ + numberOfChannels: 1, + length: sampleRate * renderLengthSeconds, + sampleRate: sampleRate, + }); + + // Create a constant source to use as input. + let src = new ConstantSourceNode(context); + + // Create a DelayNode to delay for delayTimeSeconds. + let delay = new DelayNode(context, { + maxDelayTime: delayTimeSeconds, + delayTime: delayTimeSeconds, + }); + + src.connect(delay).connect(context.destination); + + src.start(); + context.startRendering() + .then(renderedBuffer => { + let renderedData = renderedBuffer.getChannelData(0); + + // The first delayTimeSeconds of output should be silent. + let expectedSilentFrames = Math.floor( + delayTimeSeconds * sampleRate); + + should( + renderedData.slice(0, expectedSilentFrames), + `output[0:${expectedSilentFrames - 1}]`) + .beConstantValueOf(0); + + // The rest should be non-silent: that is, there should + // be at least one non-zero sample. (Any reasonable + // interpolation algorithm will make all these samples + // non-zero, but I don't think that's guaranteed by the + // spec, so we use a conservative test for now.) + should( + renderedData.slice(expectedSilentFrames), + `output[${expectedSilentFrames}:]`) + .notBeConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/no-dezippering.html b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/no-dezippering.html new file mode 100644 index 0000000000..ccca103a3b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-delaynode-interface/no-dezippering.html @@ -0,0 +1,184 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test DelayNode Has No Dezippering + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // The sample rate must be a power of two to avoid any round-off errors in + // computing when to suspend a context on a rendering quantum boundary. + // Otherwise this is pretty arbitrary. + let sampleRate = 16384; + + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test0', description: 'Test DelayNode has no dezippering'}, + (task, should) => { + let context = new OfflineAudioContext(1, sampleRate, sampleRate); + + // Simple integer ramp for testing delay node + let buffer = new AudioBuffer( + {length: context.length, sampleRate: context.sampleRate}); + let rampData = buffer.getChannelData(0); + for (let k = 0; k < rampData.length; ++k) { + rampData[k] = k + 1; + } + + // |delay0Frame| is the initial delay in frames. |delay1Frame| is + // the new delay in frames. These must be integers. + let delay0Frame = 64; + let delay1Frame = 16; + + let src = new AudioBufferSourceNode(context, {buffer: buffer}); + let delay = new DelayNode( + context, {delayTime: delay0Frame / context.sampleRate}); + + src.connect(delay).connect(context.destination); + + // After a render quantum, change the delay to |delay1Frame|. + context.suspend(RENDER_QUANTUM_FRAMES / context.sampleRate) + .then(() => { + delay.delayTime.value = delay1Frame / context.sampleRate; + }) + .then(() => context.resume()); + + src.start(); + context.startRendering() + .then(renderedBuffer => { + let renderedData = renderedBuffer.getChannelData(0); + + // The first |delay0Frame| frames should be zero. + should( + renderedData.slice(0, delay0Frame), + 'output[0:' + (delay0Frame - 1) + ']') + .beConstantValueOf(0); + + // Now we have the ramp should show up from the delay. + let ramp0 = + new Float32Array(RENDER_QUANTUM_FRAMES - delay0Frame); + for (let k = 0; k < ramp0.length; ++k) { + ramp0[k] = rampData[k]; + } + + should( + renderedData.slice(delay0Frame, RENDER_QUANTUM_FRAMES), + 'output[' + delay0Frame + ':' + + (RENDER_QUANTUM_FRAMES - 1) + ']') + .beEqualToArray(ramp0); + + // After one rendering quantum, the delay is changed to + // |delay1Frame|. + let ramp1 = + new Float32Array(context.length - RENDER_QUANTUM_FRAMES); + for (let k = 0; k < ramp1.length; ++k) { + // ramp1[k] = 1 + k + RENDER_QUANTUM_FRAMES - delay1Frame; + ramp1[k] = + rampData[k + RENDER_QUANTUM_FRAMES - delay1Frame]; + } + should( + renderedData.slice(RENDER_QUANTUM_FRAMES), + 'output[' + RENDER_QUANTUM_FRAMES + ':]') + .beEqualToArray(ramp1); + }) + .then(() => task.done()); + }); + + audit.define( + {label: 'test1', description: 'Test value setter and setValueAtTime'}, + (task, should) => { + testWithAutomation(should, {prefix: '', threshold: 6.5819e-5}) + .then(() => task.done()); + }); + + audit.define( + {label: 'test2', description: 'Test value setter and modulation'}, + (task, should) => { + testWithAutomation(should, { + prefix: 'With modulation: ', + modulator: true + }).then(() => task.done()); + }); + + // Compare .value setter with setValueAtTime, Optionally allow modulation + // of |delayTime|. + function testWithAutomation(should, options) { + let prefix = options.prefix; + // Channel 0 is the output of delay node using the setter and channel 1 + // is the output using setValueAtTime. + let context = new OfflineAudioContext(2, sampleRate, sampleRate); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + + // |delay0Frame| is the initial delay value in frames. |delay1Frame| is + // the new delay in frames. The values here are constrained only by the + // constraints for a DelayNode. These are pretty arbitrary except we + // wanted them to be fractional so as not be on a frame boundary to + // test interpolation compared with |setValueAtTime()|.. + let delay0Frame = 3.1; + let delay1Frame = 47.2; + + let delayTest = new DelayNode( + context, {delayTime: delay0Frame / context.sampleRate}); + let delayRef = new DelayNode( + context, {delayTime: delay0Frame / context.sampleRate}); + + src.connect(delayTest).connect(merger, 0, 0); + src.connect(delayRef).connect(merger, 0, 1); + + if (options.modulator) { + // Fairly arbitrary modulation of the delay time, with a peak + // variation of 10 ms. + let mod = new OscillatorNode(context, {frequency: 1000}); + let modGain = new GainNode(context, {gain: .01}); + mod.connect(modGain); + modGain.connect(delayTest.delayTime); + modGain.connect(delayRef.delayTime); + mod.start(); + } + + // The time at which the delay time of |delayTest| node will be + // changed. This MUST be on a render quantum boundary, but is + // otherwise arbitrary. + let changeTime = 3 * RENDER_QUANTUM_FRAMES / context.sampleRate; + + // Schedule the delay change on |delayRef| and also apply the value + // setter for |delayTest| at |changeTime|. + delayRef.delayTime.setValueAtTime( + delay1Frame / context.sampleRate, changeTime); + context.suspend(changeTime) + .then(() => { + delayTest.delayTime.value = delay1Frame / context.sampleRate; + }) + .then(() => context.resume()); + + src.start(); + + return context.startRendering().then(renderedBuffer => { + let actual = renderedBuffer.getChannelData(0); + let expected = renderedBuffer.getChannelData(1); + + let match = should(actual, prefix + '.value setter output') + .beCloseToArray( + expected, {absoluteThreshold: options.threshold}); + should( + match, + prefix + '.value setter output matches setValueAtTime output') + .beTrue(); + }); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-destinationnode-interface/destination.html b/testing/web-platform/tests/webaudio/the-audio-api/the-destinationnode-interface/destination.html new file mode 100644 index 0000000000..1af0e0f010 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-destinationnode-interface/destination.html @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> + <head> + <title> + AudioDestinationNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body> + <script> + function assert_doesnt_throw(f, desc) { + try { + f(); + } catch (e) { + assert_true(false, desc); + return; + } + assert_true(true, desc); + } + + test(function() { + var ac = new AudioContext(); + + assert_equals(ac.destination.channelCount, 2, + "A DestinationNode should have two channels by default"); + + assert_greater_than_equal(ac.destination.maxChannelCount, 2, + "maxChannelCount should be >= 2"); + + assert_throws_dom("IndexSizeError", function() { + ac.destination.channelCount = ac.destination.maxChannelCount + 1 + }, `Setting the channelCount to something greater than + the maxChannelCount should throw IndexSizeError`); + + assert_throws_dom("NotSupportedError", function() { + ac.destination.channelCount = 0; + }, "Setting the channelCount to 0 should throw NotSupportedError"); + + assert_doesnt_throw(function() { + ac.destination.channelCount = ac.destination.maxChannelCount; + }, "Setting the channelCount to maxChannelCount should not throw"); + + assert_doesnt_throw(function() { + ac.destination.channelCount = 1; + }, "Setting the channelCount to 1 should not throw"); + }); + + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-dynamicscompressornode-interface/ctor-dynamicscompressor.html b/testing/web-platform/tests/webaudio/the-audio-api/the-dynamicscompressornode-interface/ctor-dynamicscompressor.html new file mode 100644 index 0000000000..c2460dfa1d --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-dynamicscompressornode-interface/ctor-dynamicscompressor.html @@ -0,0 +1,199 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: DynamicsCompressor + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'DynamicsCompressorNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = + testDefaultConstructor(should, 'DynamicsCompressorNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'clamped-max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [ + {name: 'threshold', value: -24}, {name: 'knee', value: 30}, + {name: 'ratio', value: 12}, {name: 'reduction', value: 0}, + {name: 'attack', value: Math.fround(0.003)}, + {name: 'release', value: 0.25} + ]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + // Can't use testAudioNodeOptions because the constraints for this node + // are not supported there. + + // Array of test options to be run. Each entry is a dictionary where + // |testAttribute| is the name of the attribute to be tested, + // |testValue| is the value to be used, and |expectedErrorType| is the + // error type if the test is expected to throw an error. + // |expectedErrorType| should be set only if the test does throw. + let testOptions = [ + // Test channel count + { + testAttribute: 'channelCount', + testValue: 1, + }, + { + testAttribute: 'channelCount', + testValue: 2, + }, + { + testAttribute: 'channelCount', + testValue: 0, + expectedErrorType: 'NotSupportedError' + }, + { + testAttribute: 'channelCount', + testValue: 3, + expectedErrorType: 'NotSupportedError' + }, + { + testAttribute: 'channelCount', + testValue: 99, + expectedErrorType: 'NotSupportedError' + }, + // Test channel count mode + { + testAttribute: 'channelCountMode', + testValue: 'clamped-max', + }, + { + testAttribute: 'channelCountMode', + testValue: 'explicit', + }, + { + testAttribute: 'channelCountMode', + testValue: 'max', + expectedErrorType: 'NotSupportedError' + }, + { + testAttribute: 'channelCountMode', + testValue: 'foobar', + expectedErrorType: TypeError + }, + // Test channel interpretation + { + testAttribute: 'channelInterpretation', + testValue: 'speakers', + }, + { + testAttribute: 'channelInterpretation', + testValue: 'discrete', + }, + { + testAttribute: 'channelInterpretation', + testValue: 'foobar', + expectedErrorType: TypeError + } + ]; + + testOptions.forEach((option) => { + let nodeOptions = {}; + nodeOptions[option.testAttribute] = option.testValue; + + testNode(should, context, { + nodeOptions: nodeOptions, + testAttribute: option.testAttribute, + expectedValue: option.testValue, + expectedErrorType: option.expectedErrorType + }); + }); + + task.done(); + }); + + audit.define('constructor with options', (task, should) => { + let node; + let options = + {threshold: -33, knee: 15, ratio: 7, attack: 0.625, release: 0.125}; + + should( + () => { + node = new DynamicsCompressorNode(context, options); + }, + 'node1 = new DynamicsCompressorNode(c, ' + JSON.stringify(options) + + ')') + .notThrow(); + should( + node instanceof DynamicsCompressorNode, + 'node1 instanceof DynamicsCompressorNode') + .beEqualTo(true); + + should(node.threshold.value, 'node1.threshold.value') + .beEqualTo(options.threshold); + should(node.knee.value, 'node1.knee.value').beEqualTo(options.knee); + should(node.ratio.value, 'node1.ratio.value').beEqualTo(options.ratio); + should(node.attack.value, 'node1.attack.value') + .beEqualTo(options.attack); + should(node.release.value, 'node1.release.value') + .beEqualTo(options.release); + + should(node.channelCount, 'node1.channelCount').beEqualTo(2); + should(node.channelCountMode, 'node1.channelCountMode') + .beEqualTo('clamped-max'); + should(node.channelInterpretation, 'node1.channelInterpretation') + .beEqualTo('speakers'); + + task.done(); + }); + + audit.run(); + + // Test possible options for DynamicsCompressor constructor. + function testNode(should, context, options) { + // Node to be tested + let node; + + let createNodeFunction = () => { + return () => node = + new DynamicsCompressorNode(context, options.nodeOptions); + }; + + let message = 'new DynamicsCompressorNode(c, ' + + JSON.stringify(options.nodeOptions) + ')'; + + if (options.expectedErrorType === TypeError) { + should(createNodeFunction(), message) + .throw(options.expectedErrorType); + } else if (options.expectedErrorType === 'NotSupportedError') { + should(createNodeFunction(), message) + .throw(DOMException, 'NotSupportedError'); + } else { + should(createNodeFunction(), message).notThrow(); + should(node[options.testAttribute], 'node.' + options.testAttribute) + .beEqualTo(options.expectedValue); + } + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-dynamicscompressornode-interface/dynamicscompressor-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-dynamicscompressornode-interface/dynamicscompressor-basic.html new file mode 100644 index 0000000000..6c602010d0 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-dynamicscompressornode-interface/dynamicscompressor-basic.html @@ -0,0 +1,48 @@ +<!DOCTYPE html> +<html> + <head> + <title> + dynamicscompressor-basic.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let context; + let compressor; + + audit.define( + { + label: 'test', + description: 'Basic tests for DynamicsCompressorNode API' + }, + function(task, should) { + + context = new AudioContext(); + compressor = context.createDynamicsCompressor(); + + should(compressor.threshold.value, 'compressor.threshold.value') + .beEqualTo(-24); + should(compressor.knee.value, 'compressor.knee.value') + .beEqualTo(30); + should(compressor.ratio.value, 'compressor.ratio.value') + .beEqualTo(12); + should(compressor.attack.value, 'compressor.attack.value') + .beEqualTo(Math.fround(0.003)); + should(compressor.release.value, 'compressor.release.value') + .beEqualTo(0.25); + should(typeof compressor.reduction, 'typeof compressor.reduction') + .beEqualTo('number'); + should(compressor.reduction, 'compressor.reduction').beEqualTo(0); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/ctor-gain.html b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/ctor-gain.html new file mode 100644 index 0000000000..dec273e969 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/ctor-gain.html @@ -0,0 +1,79 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: Gain + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'GainNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'GainNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [{name: 'gain', value: 1}]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'GainNode'); + task.done(); + }); + + audit.define('constructor with options', (task, should) => { + let node; + let options = { + gain: -2, + }; + + should( + () => { + node = new GainNode(context, options); + }, + 'node1 = new GainNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node instanceof GainNode, 'node1 instanceof GainNode') + .beEqualTo(true); + + should(node.gain.value, 'node1.gain.value').beEqualTo(options.gain); + + should(node.channelCount, 'node1.channelCount').beEqualTo(2); + should(node.channelCountMode, 'node1.channelCountMode') + .beEqualTo('max'); + should(node.channelInterpretation, 'node1.channelInterpretation') + .beEqualTo('speakers'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/gain-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/gain-basic.html new file mode 100644 index 0000000000..de2ba11a7f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/gain-basic.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<!-- +Verifies GainNode attributes and their type. +--> +<html> + <head> + <title> + gain-basic.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('test', function(task, should) { + // Create audio context. + let context = new AudioContext(); + + // Create gain node. + let gainNode = context.createGain(); + + should( + gainNode.gain instanceof AudioParam, + 'gainNode.gain instanceof AudioParam') + .beTrue(); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/gain.html b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/gain.html new file mode 100644 index 0000000000..c41f4c9080 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/gain.html @@ -0,0 +1,162 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Basic GainNode Functionality + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Tests that GainNode is properly scaling the gain. We'll render 11 + // notes, starting at a gain of 1.0, decreasing in gain by 0.1. The 11th + // note will be of gain 0.0, so it should be silent (at the end in the + // rendered output). + + let audit = Audit.createTaskRunner(); + + // Use a power of two to eliminate any round-off when converting frame to + // time. + let sampleRate = 32768; + // Make sure the buffer duration and spacing are all exact frame lengths + // so that the note spacing is also on frame boundaries to eliminate + // sub-sample accurate start of a ABSN. + let bufferDurationSeconds = Math.floor(0.125 * sampleRate) / sampleRate; + let numberOfNotes = 11; + // Leave about 20ms of silence, being sure this is an exact frame + // duration. + let noteSilence = Math.floor(0.020 * sampleRate) / sampleRate; + let noteSpacing = bufferDurationSeconds + noteSilence; + + let lengthInSeconds = numberOfNotes * noteSpacing; + + let context = 0; + let sinWaveBuffer = 0; + + // Create a stereo AudioBuffer of duration |lengthInSeconds| consisting of + // a pure sine wave with the given |frequency|. Both channels contain the + // same data. + function createSinWaveBuffer(lengthInSeconds, frequency) { + let audioBuffer = + context.createBuffer(2, lengthInSeconds * sampleRate, sampleRate); + + let n = audioBuffer.length; + let channelL = audioBuffer.getChannelData(0); + let channelR = audioBuffer.getChannelData(1); + + for (let i = 0; i < n; ++i) { + channelL[i] = Math.sin(frequency * 2.0 * Math.PI * i / sampleRate); + channelR[i] = channelL[i]; + } + + return audioBuffer; + } + + function playNote(time, gain, merger) { + let source = context.createBufferSource(); + source.buffer = sinWaveBuffer; + + let gainNode = context.createGain(); + gainNode.gain.value = gain; + + let sourceSplitter = context.createChannelSplitter(2); + let gainSplitter = context.createChannelSplitter(2); + + // Split the stereo channels from the source output and the gain output + // and merge them into the desired channels of the merger. + source.connect(gainNode).connect(gainSplitter); + source.connect(sourceSplitter); + + gainSplitter.connect(merger, 0, 0); + gainSplitter.connect(merger, 1, 1); + sourceSplitter.connect(merger, 0, 2); + sourceSplitter.connect(merger, 1, 3); + + source.start(time); + } + + audit.define( + {label: 'create context', description: 'Create context for test'}, + function(task, should) { + // Create offline audio context. + context = new OfflineAudioContext( + 4, sampleRate * lengthInSeconds, sampleRate); + task.done(); + }); + + audit.define( + {label: 'test', description: 'GainNode functionality'}, + function(task, should) { + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // Create a buffer for a short "note". + sinWaveBuffer = createSinWaveBuffer(bufferDurationSeconds, 880.0); + + let startTimes = []; + let gainValues = []; + + // Render 11 notes, starting at a gain of 1.0, decreasing in gain by + // 0.1. The last note will be of gain 0.0, so shouldn't be + // perceptible in the rendered output. + for (let i = 0; i < numberOfNotes; ++i) { + let time = i * noteSpacing; + let gain = 1.0 - i / (numberOfNotes - 1); + startTimes.push(time); + gainValues.push(gain); + playNote(time, gain, merger); + } + + context.startRendering() + .then(buffer => { + let actual0 = buffer.getChannelData(0); + let actual1 = buffer.getChannelData(1); + let reference0 = buffer.getChannelData(2); + let reference1 = buffer.getChannelData(3); + + // It's ok to a frame too long since the sine pulses are + // followed by silence. + let bufferDurationFrames = + Math.ceil(bufferDurationSeconds * context.sampleRate); + + // Apply the gains to the reference signal. + for (let k = 0; k < startTimes.length; ++k) { + // It's ok to be a frame early because the sine pulses are + // preceded by silence. + let startFrame = + Math.floor(startTimes[k] * context.sampleRate); + let gain = gainValues[k]; + for (let n = 0; n < bufferDurationFrames; ++n) { + reference0[startFrame + n] *= gain; + reference1[startFrame + n] *= gain; + } + } + + // Verify the channels are clsoe to the reference. + should(actual0, 'Left output from gain node') + .beCloseToArray( + reference0, {relativeThreshold: 1.1877e-7}); + should(actual1, 'Right output from gain node') + .beCloseToArray( + reference1, {relativeThreshold: 1.1877e-7}); + + // Test the SNR too for both channels. + let snr0 = 10 * Math.log10(computeSNR(actual0, reference0)); + let snr1 = 10 * Math.log10(computeSNR(actual1, reference1)); + should(snr0, 'Left SNR (in dB)') + .beGreaterThanOrEqualTo(148.71); + should(snr1, 'Right SNR (in dB)') + .beGreaterThanOrEqualTo(148.71); + }) + .then(() => task.done()); + ; + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/no-dezippering.html b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/no-dezippering.html new file mode 100644 index 0000000000..6326d00dfb --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-gainnode-interface/no-dezippering.html @@ -0,0 +1,121 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Gain Dezippering Test: Dezippering Removed + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test0', description: 'Dezippering of GainNode removed'}, + (task, should) => { + // Only need a few frames to verify that dezippering has been + // removed from the GainNode. Sample rate is pretty arbitrary. + let context = new OfflineAudioContext(1, 1024, 16000); + + // Send a unit source to the gain node so we can measure the effect + // of the gain node. + let src = new ConstantSourceNode(context, {offset: 1}); + let g = new GainNode(context, {gain: 1}); + src.connect(g).connect(context.destination); + + context.suspend(RENDER_QUANTUM_FRAMES / context.sampleRate) + .then(() => { + g.gain.value = .5; + }) + .then(() => context.resume()); + + src.start(); + + context.startRendering() + .then(audio => { + let c = audio.getChannelData(0); + + // If dezippering has been removed, the gain output should + // instantly jump at frame 128 to 0.5. + should(c.slice(0, 128), 'output[0:127]').beConstantValueOf(1); + should(c.slice(128), 'output[128:]').beConstantValueOf(0.5); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'test2', + description: 'Compare value setter and setValueAtTime' + }, + (task, should) => { + testWithAutomation(should, {prefix: ''}).then(() => task.done()); + }); + + audit.define( + {label: 'test3', description: 'Automation effects'}, + (task, should) => { + testWithAutomation(should, { + prefix: 'With modulation: ', + modulator: true + }).then(() => task.done()); + }); + + audit.run(); + + function testWithAutomation(should, options) { + // Sample rate must be a power of two to eliminate round-off in + // computing the time at render quantum boundaries. + let context = new OfflineAudioContext(2, 1024, 16384); + let merger = new ChannelMergerNode(context, {numberOfChannels: 2}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + let gainTest = new GainNode(context); + let gainRef = new GainNode(context); + + src.connect(gainTest).connect(merger, 0, 0); + src.connect(gainRef).connect(merger, 0, 1); + + if (options.modulator) { + let mod = new OscillatorNode(context, {frequency: 1000}); + let modGain = new GainNode(context); + mod.connect(modGain); + modGain.connect(gainTest.gain); + modGain.connect(gainRef.gain); + mod.start(); + } + + // Change the gains. Must do the change on a render boundary! + let changeTime = 3 * RENDER_QUANTUM_FRAMES / context.sampleRate; + let newGain = .3; + + gainRef.gain.setValueAtTime(newGain, changeTime); + context.suspend(changeTime) + .then(() => gainTest.gain.value = newGain) + .then(() => context.resume()); + + src.start(); + + return context.startRendering().then(audio => { + let actual = audio.getChannelData(0); + let expected = audio.getChannelData(1); + + // The values using the .value setter must be identical to the + // values using setValueAtTime. + let match = should(actual, options.prefix + '.value setter output') + .beEqualToArray(expected); + + should( + match, + options.prefix + + '.value setter output matches setValueAtTime output') + .beTrue(); + }); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/ctor-iirfilter.html b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/ctor-iirfilter.html new file mode 100644 index 0000000000..e884d487af --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/ctor-iirfilter.html @@ -0,0 +1,126 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: IIRFilter + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'IIRFilterNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'IIRFilterNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers', + constructorOptions: {feedforward: [1], feedback: [1, -.9]} + }); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions( + should, context, 'IIRFilterNode', + {additionalOptions: {feedforward: [1, 1], feedback: [1, .5]}}); + task.done(); + }); + + audit.define('constructor options', (task, should) => { + let node; + + let options = {feedback: [1, .5]}; + should( + () => { + node = new IIRFilterNode(context, options); + }, + 'node = new IIRFilterNode(, ' + JSON.stringify(options) + ')') + .throw(TypeError); + + options = {feedforward: [1, 0.5]}; + should( + () => { + node = new IIRFilterNode(context, options); + }, + 'node = new IIRFilterNode(c, ' + JSON.stringify(options) + ')') + .throw(TypeError); + + task.done(); + }); + + // Test functionality of constructor. This is needed because we have no + // way of determining if the filter coefficients were were actually set + // appropriately. + + // TODO(rtoy): This functionality test should be moved out to a separate + // file. + audit.define('functionality', (task, should) => { + let options = {feedback: [1, .5], feedforward: [1, 1]}; + + // Create two-channel offline context; sample rate and length are fairly + // arbitrary. Channel 0 contains the test output and channel 1 contains + // the expected output. + let sampleRate = 48000; + let renderLength = 0.125; + let testContext = + new OfflineAudioContext(2, renderLength * sampleRate, sampleRate); + + // The test node uses the constructor. The reference node creates the + // same filter but uses the old factory method. + let testNode = new IIRFilterNode(testContext, options); + let refNode = testContext.createIIRFilter( + Float32Array.from(options.feedforward), + Float32Array.from(options.feedback)); + + let source = testContext.createOscillator(); + source.connect(testNode); + source.connect(refNode); + + let merger = testContext.createChannelMerger( + testContext.destination.channelCount); + + testNode.connect(merger, 0, 0); + refNode.connect(merger, 0, 1); + + merger.connect(testContext.destination); + + source.start(); + testContext.startRendering() + .then(function(resultBuffer) { + let actual = resultBuffer.getChannelData(0); + let expected = resultBuffer.getChannelData(1); + + // The output from the two channels should be exactly equal + // because exactly the same IIR filter should have been created. + should(actual, 'Output of filter using new IIRFilter(...)') + .beEqualToArray(expected); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter-basic.html new file mode 100644 index 0000000000..7828f05226 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter-basic.html @@ -0,0 +1,204 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Basic IIRFilterNode Properties + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + let testFrames = 100; + + // Global context that can be used by the individual tasks. It must be + // defined by the initialize task. + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + should(() => { + context = new OfflineAudioContext(1, testFrames, sampleRate); + }, 'Initialize context for testing').notThrow(); + task.done(); + }); + + audit.define('existence', (task, should) => { + should(context.createIIRFilter, 'context.createIIRFilter').exist(); + task.done(); + }); + + audit.define('parameters', (task, should) => { + // Create a really simple IIR filter. Doesn't much matter what. + let coef = Float32Array.from([1]); + + let f = context.createIIRFilter(coef, coef); + + should(f.numberOfInputs, 'numberOfInputs').beEqualTo(1); + should(f.numberOfOutputs, 'numberOfOutputs').beEqualTo(1); + should(f.channelCountMode, 'channelCountMode').beEqualTo('max'); + should(f.channelInterpretation, 'channelInterpretation') + .beEqualTo('speakers'); + + task.done(); + }); + + audit.define('exceptions-createIIRFilter', (task, should) => { + should(function() { + // Two args are required. + context.createIIRFilter(); + }, 'createIIRFilter()').throw(TypeError); + + should(function() { + // Two args are required. + context.createIIRFilter(new Float32Array(1)); + }, 'createIIRFilter(new Float32Array(1))').throw(TypeError); + + should(function() { + // null is not valid + context.createIIRFilter(null, null); + }, 'createIIRFilter(null, null)').throw(TypeError); + + should(function() { + // There has to be at least one coefficient. + context.createIIRFilter([], []); + }, 'createIIRFilter([], [])').throw(DOMException, 'NotSupportedError'); + + should(function() { + // There has to be at least one coefficient. + context.createIIRFilter([1], []); + }, 'createIIRFilter([1], [])').throw(DOMException, 'NotSupportedError'); + + should(function() { + // There has to be at least one coefficient. + context.createIIRFilter([], [1]); + }, 'createIIRFilter([], [1])').throw(DOMException, 'NotSupportedError'); + + should( + function() { + // Max allowed size for the coefficient arrays. + let fb = new Float32Array(20); + fb[0] = 1; + context.createIIRFilter(fb, fb); + }, + 'createIIRFilter(new Float32Array(20), new Float32Array(20))') + .notThrow(); + + should( + function() { + // Max allowed size for the feedforward coefficient array. + let coef = new Float32Array(21); + coef[0] = 1; + context.createIIRFilter(coef, [1]); + }, + 'createIIRFilter(new Float32Array(21), [1])') + .throw(DOMException, 'NotSupportedError'); + + should( + function() { + // Max allowed size for the feedback coefficient array. + let coef = new Float32Array(21); + coef[0] = 1; + context.createIIRFilter([1], coef); + }, + 'createIIRFilter([1], new Float32Array(21))') + .throw(DOMException, 'NotSupportedError'); + + should( + function() { + // First feedback coefficient can't be 0. + context.createIIRFilter([1], new Float32Array(2)); + }, + 'createIIRFilter([1], new Float32Array(2))') + .throw(DOMException, 'InvalidStateError'); + + should( + function() { + // feedforward coefficients can't all be zero. + context.createIIRFilter(new Float32Array(10), [1]); + }, + 'createIIRFilter(new Float32Array(10), [1])') + .throw(DOMException, 'InvalidStateError'); + + should(function() { + // Feedback coefficients must be finite. + context.createIIRFilter([1], [1, Infinity, NaN]); + }, 'createIIRFilter([1], [1, NaN, Infinity])').throw(TypeError); + + should(function() { + // Feedforward coefficients must be finite. + context.createIIRFilter([1, Infinity, NaN], [1]); + }, 'createIIRFilter([1, NaN, Infinity], [1])').throw(TypeError); + + should(function() { + // Test that random junk in the array is converted to NaN. + context.createIIRFilter([1, 'abc', []], [1]); + }, 'createIIRFilter([1, \'abc\', []], [1])').throw(TypeError); + + task.done(); + }); + + audit.define('exceptions-getFrequencyData', (task, should) => { + // Create a really simple IIR filter. Doesn't much matter what. + let coef = Float32Array.from([1]); + + let f = context.createIIRFilter(coef, coef); + + should( + function() { + // frequencyHz can't be null. + f.getFrequencyResponse( + null, new Float32Array(1), new Float32Array(1)); + }, + 'getFrequencyResponse(null, new Float32Array(1), new Float32Array(1))') + .throw(TypeError); + + should( + function() { + // magResponse can't be null. + f.getFrequencyResponse( + new Float32Array(1), null, new Float32Array(1)); + }, + 'getFrequencyResponse(new Float32Array(1), null, new Float32Array(1))') + .throw(TypeError); + + should( + function() { + // phaseResponse can't be null. + f.getFrequencyResponse( + new Float32Array(1), new Float32Array(1), null); + }, + 'getFrequencyResponse(new Float32Array(1), new Float32Array(1), null)') + .throw(TypeError); + + should( + function() { + // magResponse array must the same length as frequencyHz + f.getFrequencyResponse( + new Float32Array(10), new Float32Array(1), + new Float32Array(20)); + }, + 'getFrequencyResponse(new Float32Array(10), new Float32Array(1), new Float32Array(20))') + .throw(DOMException, 'InvalidAccessError'); + + should( + function() { + // phaseResponse array must be the same length as frequencyHz + f.getFrequencyResponse( + new Float32Array(10), new Float32Array(20), + new Float32Array(1)); + }, + 'getFrequencyResponse(new Float32Array(10), new Float32Array(20), new Float32Array(1))') + .throw(DOMException, 'InvalidAccessError'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter-getFrequencyResponse.html b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter-getFrequencyResponse.html new file mode 100644 index 0000000000..c98555f161 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter-getFrequencyResponse.html @@ -0,0 +1,159 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test IIRFilter getFrequencyResponse() functionality + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/biquad-filters.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + // Some short duration; we're not actually looking at the rendered output. + let testDurationSec = 0.01; + + // Number of frequency samples to take. + let numberOfFrequencies = 1000; + + let audit = Audit.createTaskRunner(); + + + // Compute a set of linearly spaced frequencies. + function createFrequencies(nFrequencies, sampleRate) { + let frequencies = new Float32Array(nFrequencies); + let nyquist = sampleRate / 2; + let freqDelta = nyquist / nFrequencies; + + for (let k = 0; k < nFrequencies; ++k) { + frequencies[k] = k * freqDelta; + } + + return frequencies; + } + + audit.define('1-pole IIR', (task, should) => { + let context = new OfflineAudioContext( + 1, testDurationSec * sampleRate, sampleRate); + + let iir = context.createIIRFilter([1], [1, -0.9]); + let frequencies = + createFrequencies(numberOfFrequencies, context.sampleRate); + + let iirMag = new Float32Array(numberOfFrequencies); + let iirPhase = new Float32Array(numberOfFrequencies); + let trueMag = new Float32Array(numberOfFrequencies); + let truePhase = new Float32Array(numberOfFrequencies); + + // The IIR filter is + // H(z) = 1/(1 - 0.9*z^(-1)). + // + // The frequency response is + // H(exp(j*w)) = 1/(1 - 0.9*exp(-j*w)). + // + // Thus, the magnitude is + // |H(exp(j*w))| = 1/sqrt(1.81-1.8*cos(w)). + // + // The phase is + // arg(H(exp(j*w)) = atan(0.9*sin(w)/(.9*cos(w)-1)) + + let frequencyScale = Math.PI / (sampleRate / 2); + + for (let k = 0; k < frequencies.length; ++k) { + let omega = frequencyScale * frequencies[k]; + trueMag[k] = 1 / Math.sqrt(1.81 - 1.8 * Math.cos(omega)); + truePhase[k] = + Math.atan(0.9 * Math.sin(omega) / (0.9 * Math.cos(omega) - 1)); + } + + iir.getFrequencyResponse(frequencies, iirMag, iirPhase); + + // Thresholds were experimentally determined. + should(iirMag, '1-pole IIR Magnitude Response') + .beCloseToArray(trueMag, {absoluteThreshold: 2.8611e-6}); + should(iirPhase, '1-pole IIR Phase Response') + .beCloseToArray(truePhase, {absoluteThreshold: 1.7882e-7}); + + task.done(); + }); + + audit.define('compare IIR and biquad', (task, should) => { + // Create an IIR filter equivalent to the biquad filter. Compute the + // frequency response for both and verify that they are the same. + let context = new OfflineAudioContext( + 1, testDurationSec * sampleRate, sampleRate); + + let biquad = context.createBiquadFilter(); + let coef = createFilter( + biquad.type, biquad.frequency.value / (context.sampleRate / 2), + biquad.Q.value, biquad.gain.value); + + let iir = context.createIIRFilter( + [coef.b0, coef.b1, coef.b2], [1, coef.a1, coef.a2]); + + let frequencies = + createFrequencies(numberOfFrequencies, context.sampleRate); + let biquadMag = new Float32Array(numberOfFrequencies); + let biquadPhase = new Float32Array(numberOfFrequencies); + let iirMag = new Float32Array(numberOfFrequencies); + let iirPhase = new Float32Array(numberOfFrequencies); + + biquad.getFrequencyResponse(frequencies, biquadMag, biquadPhase); + iir.getFrequencyResponse(frequencies, iirMag, iirPhase); + + // Thresholds were experimentally determined. + should(iirMag, 'IIR Magnitude Response').beCloseToArray(biquadMag, { + absoluteThreshold: 2.7419e-5 + }); + should(iirPhase, 'IIR Phase Response').beCloseToArray(biquadPhase, { + absoluteThreshold: 2.7657e-5 + }); + + task.done(); + }); + + audit.define( + { + label: 'getFrequencyResponse', + description: 'Test out-of-bounds frequency values' + }, + (task, should) => { + let context = new OfflineAudioContext(1, 1, sampleRate); + let filter = new IIRFilterNode( + context, {feedforward: [1], feedback: [1, -.9]}); + + // Frequencies to test. These are all outside the valid range of + // frequencies of 0 to Nyquist. + let freq = new Float32Array(2); + freq[0] = -1; + freq[1] = context.sampleRate / 2 + 1; + + let mag = new Float32Array(freq.length); + let phase = new Float32Array(freq.length); + + filter.getFrequencyResponse(freq, mag, phase); + + // Verify that the returned magnitude and phase entries are alL NaN + // since the frequencies are outside the valid range + for (let k = 0; k < mag.length; ++k) { + should(mag[k], + 'Magnitude response at frequency ' + freq[k]) + .beNaN(); + } + + for (let k = 0; k < phase.length; ++k) { + should(phase[k], + 'Phase response at frequency ' + freq[k]) + .beNaN(); + } + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter.html b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter.html new file mode 100644 index 0000000000..aa38a6bfca --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/iirfilter.html @@ -0,0 +1,572 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Basic IIRFilterNode Operation + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/biquad-filters.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 24000; + let testDurationSec = 0.25; + let testFrames = testDurationSec * sampleRate; + + let audit = Audit.createTaskRunner(); + + audit.define('coefficient-normalization', (task, should) => { + // Test that the feedback coefficients are normalized. Do this be + // creating two IIRFilterNodes. One has normalized coefficients, and + // one doesn't. Compute the difference and make sure they're the same. + let context = new OfflineAudioContext(2, testFrames, sampleRate); + + // Use a simple impulse as the source. + let buffer = context.createBuffer(1, 1, sampleRate); + buffer.getChannelData(0)[0] = 1; + let source = context.createBufferSource(); + source.buffer = buffer; + + // Gain node for computing the difference between the filters. + let gain = context.createGain(); + gain.gain.value = -1; + + // The IIR filters. Use a common feedforward array. + let ff = [1]; + + let fb1 = [1, .9]; + + let fb2 = new Float64Array(2); + // Scale the feedback coefficients by an arbitrary factor. + let coefScaleFactor = 2; + for (let k = 0; k < fb2.length; ++k) { + fb2[k] = coefScaleFactor * fb1[k]; + } + + let iir1; + let iir2; + + should(function() { + iir1 = context.createIIRFilter(ff, fb1); + }, 'createIIRFilter with normalized coefficients').notThrow(); + + should(function() { + iir2 = context.createIIRFilter(ff, fb2); + }, 'createIIRFilter with unnormalized coefficients').notThrow(); + + // Create the graph. The output of iir1 (normalized coefficients) is + // channel 0, and the output of iir2 (unnormalized coefficients), with + // appropriate scaling, is channel 1. + let merger = context.createChannelMerger(2); + source.connect(iir1); + source.connect(iir2); + iir1.connect(merger, 0, 0); + iir2.connect(gain); + + // The gain for the gain node should be set to compensate for the + // scaling of the coefficients. Since iir2 has scaled the coefficients + // by coefScaleFactor, the output is reduced by the same factor, so + // adjust the gain to scale the output of iir2 back up. + gain.gain.value = coefScaleFactor; + gain.connect(merger, 0, 1); + + merger.connect(context.destination); + + source.start(); + + // Rock and roll! + + context.startRendering() + .then(function(result) { + // Find the max amplitude of the result, which should be near + // zero. + let iir1Data = result.getChannelData(0); + let iir2Data = result.getChannelData(1); + + // Threshold isn't exactly zero because the arithmetic is done + // differently between the IIRFilterNode and the BiquadFilterNode. + should( + iir2Data, + 'Output of IIR filter with unnormalized coefficients') + .beCloseToArray(iir1Data, {absoluteThreshold: 2.1958e-38}); + }) + .then(() => task.done()); + }); + + audit.define('one-zero', (task, should) => { + // Create a simple 1-zero filter and compare with the expected output. + let context = new OfflineAudioContext(1, testFrames, sampleRate); + + // Use a simple impulse as the source + let buffer = context.createBuffer(1, 1, sampleRate); + buffer.getChannelData(0)[0] = 1; + let source = context.createBufferSource(); + source.buffer = buffer; + + // The filter is y(n) = 0.5*(x(n) + x(n-1)), a simple 2-point moving + // average. This is rather arbitrary; keep it simple. + + let iir = context.createIIRFilter([0.5, 0.5], [1]); + + // Create the graph + source.connect(iir); + iir.connect(context.destination); + + // Rock and roll! + source.start(); + + context.startRendering() + .then(function(result) { + let actual = result.getChannelData(0); + let expected = new Float64Array(testFrames); + // The filter is a simple 2-point moving average of an impulse, so + // the first two values are non-zero and the rest are zero. + expected[0] = 0.5; + expected[1] = 0.5; + should(actual, 'IIR 1-zero output').beCloseToArray(expected, { + absoluteThreshold: 0 + }); + }) + .then(() => task.done()); + }); + + audit.define('one-pole', (task, should) => { + // Create a simple 1-pole filter and compare with the expected output. + + // The filter is y(n) + c*y(n-1)= x(n). The analytical response is + // (-c)^n, so choose a suitable number of frames to run the test for + // where the output isn't flushed to zero. + let c = 0.9; + let eps = 1e-20; + let duration = Math.floor(Math.log(eps) / Math.log(Math.abs(c))); + let context = new OfflineAudioContext(1, duration, sampleRate); + + // Use a simple impulse as the source + let buffer = context.createBuffer(1, 1, sampleRate); + buffer.getChannelData(0)[0] = 1; + let source = context.createBufferSource(); + source.buffer = buffer; + + let iir = context.createIIRFilter([1], [1, c]); + + // Create the graph + source.connect(iir); + iir.connect(context.destination); + + // Rock and roll! + source.start(); + + context.startRendering() + .then(function(result) { + let actual = result.getChannelData(0); + let expected = new Float64Array(actual.length); + + // The filter is a simple 1-pole filter: y(n) = -c*y(n-k)+x(n), + // with an impulse as the input. + expected[0] = 1; + for (k = 1; k < testFrames; ++k) { + expected[k] = -c * expected[k - 1]; + } + + // Threshold isn't exactly zero due to round-off in the + // single-precision IIRFilterNode computations versus the + // double-precision Javascript computations. + should(actual, 'IIR 1-pole output').beCloseToArray(expected, { + absoluteThreshold: 2.7657e-8 + }); + }) + .then(() => task.done()); + }); + + // Return a function suitable for use as a defineTask function. This + // function creates an IIRFilterNode equivalent to the specified + // BiquadFilterNode and compares the outputs. The outputs from the two + // filters should be virtually identical. + function testWithBiquadFilter(filterType, errorThreshold, snrThreshold) { + return (task, should) => { + let context = new OfflineAudioContext(2, testFrames, sampleRate); + + // Use a constant (step function) as the source + let buffer = createConstantBuffer(context, testFrames, 1); + let source = context.createBufferSource(); + source.buffer = buffer; + + + // Create the biquad. Choose some rather arbitrary values for Q and + // gain for the biquad so that the shelf filters aren't identical. + let biquad = context.createBiquadFilter(); + biquad.type = filterType; + biquad.Q.value = 10; + biquad.gain.value = 10; + + // Create the equivalent IIR Filter node by computing the coefficients + // of the given biquad filter type. + let nyquist = sampleRate / 2; + let coef = createFilter( + filterType, biquad.frequency.value / nyquist, biquad.Q.value, + biquad.gain.value); + + let iir = context.createIIRFilter( + [coef.b0, coef.b1, coef.b2], [1, coef.a1, coef.a2]); + + let merger = context.createChannelMerger(2); + // Create the graph + source.connect(biquad); + source.connect(iir); + + biquad.connect(merger, 0, 0); + iir.connect(merger, 0, 1); + + merger.connect(context.destination); + + // Rock and roll! + source.start(); + + context.startRendering() + .then(function(result) { + // Find the max amplitude of the result, which should be near + // zero. + let expected = result.getChannelData(0); + let actual = result.getChannelData(1); + + // On MacOSX, WebAudio uses an optimized Biquad implementation + // that is different from the implementation used for Linux and + // Windows. This will cause the output to differ, even if the + // threshold passes. Thus, only print out a very small number + // of elements of the array where we have tested that they are + // consistent. + should(actual, 'IIRFilter for Biquad ' + filterType) + .beCloseToArray(expected, errorThreshold); + + let snr = 10 * Math.log10(computeSNR(actual, expected)); + should(snr, 'SNR for IIRFIlter for Biquad ' + filterType) + .beGreaterThanOrEqualTo(snrThreshold); + }) + .then(() => task.done()); + }; + } + + // Thresholds here are experimentally determined. + let biquadTestConfigs = [ + { + filterType: 'lowpass', + snrThreshold: 91.221, + errorThreshold: {relativeThreshold: 4.9834e-5} + }, + { + filterType: 'highpass', + snrThreshold: 105.4590, + errorThreshold: {absoluteThreshold: 2.9e-6, relativeThreshold: 3e-5} + }, + { + filterType: 'bandpass', + snrThreshold: 104.060, + errorThreshold: {absoluteThreshold: 2e-7, relativeThreshold: 8.7e-4} + }, + { + filterType: 'notch', + snrThreshold: 91.312, + errorThreshold: {absoluteThreshold: 0, relativeThreshold: 4.22e-5} + }, + { + filterType: 'allpass', + snrThreshold: 91.319, + errorThreshold: {absoluteThreshold: 0, relativeThreshold: 4.31e-5} + }, + { + filterType: 'lowshelf', + snrThreshold: 90.609, + errorThreshold: {absoluteThreshold: 0, relativeThreshold: 2.98e-5} + }, + { + filterType: 'highshelf', + snrThreshold: 103.159, + errorThreshold: {absoluteThreshold: 0, relativeThreshold: 1.24e-5} + }, + { + filterType: 'peaking', + snrThreshold: 91.504, + errorThreshold: {absoluteThreshold: 0, relativeThreshold: 5.05e-5} + } + ]; + + // Create a set of tasks based on biquadTestConfigs. + for (k = 0; k < biquadTestConfigs.length; ++k) { + let config = biquadTestConfigs[k]; + let name = k + ': ' + config.filterType; + audit.define( + name, + testWithBiquadFilter( + config.filterType, config.errorThreshold, config.snrThreshold)); + } + + audit.define('multi-channel', (task, should) => { + // Multi-channel test. Create a biquad filter and the equivalent IIR + // filter. Filter the same multichannel signal and compare the results. + let nChannels = 3; + let context = + new OfflineAudioContext(nChannels, testFrames, sampleRate); + + // Create a set of oscillators as the multi-channel source. + let source = []; + + for (k = 0; k < nChannels; ++k) { + source[k] = context.createOscillator(); + source[k].type = 'sawtooth'; + // The frequency of the oscillator is pretty arbitrary, but each + // oscillator should have a different frequency. + source[k].frequency.value = 100 + k * 100; + } + + let merger = context.createChannelMerger(3); + + let biquad = context.createBiquadFilter(); + + // Create the equivalent IIR Filter node. + let nyquist = sampleRate / 2; + let coef = createFilter( + biquad.type, biquad.frequency.value / nyquist, biquad.Q.value, + biquad.gain.value); + let fb = [1, coef.a1, coef.a2]; + let ff = [coef.b0, coef.b1, coef.b2]; + + let iir = context.createIIRFilter(ff, fb); + // Gain node to compute the difference between the IIR and biquad + // filter. + let gain = context.createGain(); + gain.gain.value = -1; + + // Create the graph. + for (k = 0; k < nChannels; ++k) + source[k].connect(merger, 0, k); + + merger.connect(biquad); + merger.connect(iir); + iir.connect(gain); + biquad.connect(context.destination); + gain.connect(context.destination); + + for (k = 0; k < nChannels; ++k) + source[k].start(); + + context.startRendering() + .then(function(result) { + let errorThresholds = [3.7671e-5, 3.0071e-5, 2.6241e-5]; + + // Check the difference signal on each channel + for (channel = 0; channel < result.numberOfChannels; ++channel) { + // Find the max amplitude of the result, which should be near + // zero. + let data = result.getChannelData(channel); + let maxError = + data.reduce(function(reducedValue, currentValue) { + return Math.max(reducedValue, Math.abs(currentValue)); + }); + + should( + maxError, + 'Max difference between IIR and Biquad on channel ' + + channel) + .beLessThanOrEqualTo(errorThresholds[channel]); + } + + }) + .then(() => task.done()); + }); + + // Apply an IIRFilter to the given input signal. + // + // IIR filter in the time domain is + // + // y[n] = sum(ff[k]*x[n-k], k, 0, M) - sum(fb[k]*y[n-k], k, 1, N) + // + function iirFilter(input, feedforward, feedback) { + // For simplicity, create an x buffer that contains the input, and a y + // buffer that contains the output. Both of these buffers have an + // initial work space to implement the initial memory of the filter. + let workSize = Math.max(feedforward.length, feedback.length); + let x = new Float32Array(input.length + workSize); + + // Float64 because we want to match the implementation that uses doubles + // to minimize roundoff. + let y = new Float64Array(input.length + workSize); + + // Copy the input over. + for (let k = 0; k < input.length; ++k) + x[k + feedforward.length] = input[k]; + + // Run the filter + for (let n = 0; n < input.length; ++n) { + let index = n + workSize; + let yn = 0; + for (let k = 0; k < feedforward.length; ++k) + yn += feedforward[k] * x[index - k]; + for (let k = 0; k < feedback.length; ++k) + yn -= feedback[k] * y[index - k]; + + y[index] = yn; + } + + return y.slice(workSize).map(Math.fround); + } + + // Cascade the two given biquad filters to create one IIR filter. + function cascadeBiquads(f1Coef, f2Coef) { + // The biquad filters are: + // + // f1 = (b10 + b11/z + b12/z^2)/(1 + a11/z + a12/z^2); + // f2 = (b20 + b21/z + b22/z^2)/(1 + a21/z + a22/z^2); + // + // To cascade them, multiply the two transforms together to get a fourth + // order IIR filter. + + let numProduct = [ + f1Coef.b0 * f2Coef.b0, f1Coef.b0 * f2Coef.b1 + f1Coef.b1 * f2Coef.b0, + f1Coef.b0 * f2Coef.b2 + f1Coef.b1 * f2Coef.b1 + f1Coef.b2 * f2Coef.b0, + f1Coef.b1 * f2Coef.b2 + f1Coef.b2 * f2Coef.b1, f1Coef.b2 * f2Coef.b2 + ]; + + let denProduct = [ + 1, f2Coef.a1 + f1Coef.a1, + f2Coef.a2 + f1Coef.a1 * f2Coef.a1 + f1Coef.a2, + f1Coef.a1 * f2Coef.a2 + f1Coef.a2 * f2Coef.a1, f1Coef.a2 * f2Coef.a2 + ]; + + return { + ff: numProduct, fb: denProduct + } + } + + // Find the magnitude of the root of the quadratic that has the maximum + // magnitude. + // + // The quadratic is z^2 + a1 * z + a2 and we want the root z that has the + // largest magnitude. + function largestRootMagnitude(a1, a2) { + let discriminant = a1 * a1 - 4 * a2; + if (discriminant < 0) { + // Complex roots: -a1/2 +/- i*sqrt(-d)/2. Thus the magnitude of each + // root is the same and is sqrt(a1^2/4 + |d|/4) + let d = Math.sqrt(-discriminant); + return Math.hypot(a1 / 2, d / 2); + } else { + // Real roots + let d = Math.sqrt(discriminant); + return Math.max(Math.abs((-a1 + d) / 2), Math.abs((-a1 - d) / 2)); + } + } + + audit.define('4th-order-iir', (task, should) => { + // Cascade 2 lowpass biquad filters and compare that with the equivalent + // 4th order IIR filter. + + let nyquist = sampleRate / 2; + // Compute the coefficients of a lowpass filter. + + // First some preliminary stuff. Compute the coefficients of the + // biquad. This is used to figure out how frames to use in the test. + let biquadType = 'lowpass'; + let biquadCutoff = 350; + let biquadQ = 5; + let biquadGain = 1; + + let coef = createFilter( + biquadType, biquadCutoff / nyquist, biquadQ, biquadGain); + + // Cascade the biquads together to create an equivalent IIR filter. + let cascade = cascadeBiquads(coef, coef); + + // Since we're cascading two identical biquads, the root of denominator + // of the IIR filter is repeated, so the root of the denominator with + // the largest magnitude occurs twice. The impulse response of the IIR + // filter will be roughly c*(r*r)^n at time n, where r is the root of + // largest magnitude. This approximation gets better as n increases. + // We can use this to get a rough idea of when the response has died + // down to a small value. + + // This is the value we will use to determine how many frames to render. + // Rendering too many is a waste of time and also makes it hard to + // compare the actual result to the expected because the magnitudes are + // so small that they could be mostly round-off noise. + // + // Find magnitude of the root with largest magnitude + let rootMagnitude = largestRootMagnitude(coef.a1, coef.a2); + + // Find n such that |r|^(2*n) <= eps. That is, n = log(eps)/(2*log(r)). + // Somewhat arbitrarily choose eps = 1e-20; + let eps = 1e-20; + let framesForTest = + Math.floor(Math.log(eps) / (2 * Math.log(rootMagnitude))); + + // We're ready to create the graph for the test. The offline context + // has two channels: channel 0 is the expected (cascaded biquad) result + // and channel 1 is the actual IIR filter result. + let context = new OfflineAudioContext(2, framesForTest, sampleRate); + + // Use a simple impulse with a large (arbitrary) amplitude as the source + let amplitude = 1; + let buffer = context.createBuffer(1, testFrames, sampleRate); + buffer.getChannelData(0)[0] = amplitude; + let source = context.createBufferSource(); + source.buffer = buffer; + + // Create the two biquad filters. Doesn't really matter what, but for + // simplicity we choose identical lowpass filters with the same + // parameters. + let biquad1 = context.createBiquadFilter(); + biquad1.type = biquadType; + biquad1.frequency.value = biquadCutoff; + biquad1.Q.value = biquadQ; + + let biquad2 = context.createBiquadFilter(); + biquad2.type = biquadType; + biquad2.frequency.value = biquadCutoff; + biquad2.Q.value = biquadQ; + + let iir = context.createIIRFilter(cascade.ff, cascade.fb); + + // Create the merger to get the signals into multiple channels + let merger = context.createChannelMerger(2); + + // Create the graph, filtering the source through two biquads. + source.connect(biquad1); + biquad1.connect(biquad2); + biquad2.connect(merger, 0, 0); + + source.connect(iir); + iir.connect(merger, 0, 1); + + merger.connect(context.destination); + + // Now filter the source through the IIR filter. + let y = iirFilter(buffer.getChannelData(0), cascade.ff, cascade.fb); + + // Rock and roll! + source.start(); + + context.startRendering() + .then(function(result) { + let expected = result.getChannelData(0); + let actual = result.getChannelData(1); + + should(actual, '4-th order IIRFilter (biquad ref)') + .beCloseToArray(expected, { + // Thresholds experimentally determined. + absoluteThreshold: 1.59e-7, + relativeThreshold: 2.11e-5, + }); + + let snr = 10 * Math.log10(computeSNR(actual, expected)); + should(snr, 'SNR of 4-th order IIRFilter (biquad ref)') + .beGreaterThanOrEqualTo(108.947); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/test-iirfilternode.html b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/test-iirfilternode.html new file mode 100644 index 0000000000..001a2a6172 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-iirfilternode-interface/test-iirfilternode.html @@ -0,0 +1,59 @@ +<!doctype html> +<meta charset=utf-8> +<title>Test the IIRFilterNode Interface</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +test(function(t) { + var ac = new AudioContext(); + + function check_args(arg1, arg2, err, desc) { + test(function() { + assert_throws_dom(err, function() { + ac.createIIRFilter(arg1, arg2) + }) + }, desc) + } + + check_args([], [1.0], 'NotSupportedError', + 'feedforward coefficients can not be empty'); + + check_args([1.0], [], 'NotSupportedError', + 'feedback coefficients can not be empty'); + + var coeff = new Float32Array(21) + coeff[0] = 1.0; + + check_args(coeff, [1.0], 'NotSupportedError', + 'more than 20 feedforward coefficients can not be used'); + + check_args([1.0], coeff, 'NotSupportedError', + 'more than 20 feedback coefficients can not be used'); + + check_args([0.0, 0.0], [1.0], 'InvalidStateError', + 'at least one feedforward coefficient must be non-zero'); + + check_args([0.5, 0.5], [0.0], 'InvalidStateError', + 'the first feedback coefficient must be non-zero'); + +}, "IIRFilterNode coefficients are checked properly"); + +test(function(t) { + var ac = new AudioContext(); + + var frequencies = new Float32Array([-1.0, ac.sampleRate*0.5 - 1.0, ac.sampleRate]); + var magResults = new Float32Array(3); + var phaseResults = new Float32Array(3); + + var filter = ac.createIIRFilter([0.5, 0.5], [1.0]); + filter.getFrequencyResponse(frequencies, magResults, phaseResults); + + assert_true(isNaN(magResults[0]), "Invalid input frequency should give NaN magnitude response"); + assert_true(!isNaN(magResults[1]), "Valid input frequency should not give NaN magnitude response"); + assert_true(isNaN(magResults[2]), "Invalid input frequency should give NaN magnitude response"); + assert_true(isNaN(phaseResults[0]), "Invalid input frequency should give NaN phase response"); + assert_true(!isNaN(phaseResults[1]), "Valid input frequency should not give NaN phase response"); + assert_true(isNaN(phaseResults[2]), "Invalid input frequency should give NaN phase response"); + +}, "IIRFilterNode getFrequencyResponse handles invalid frequencies properly"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/cors-check.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/cors-check.https.html new file mode 100644 index 0000000000..38bd94a037 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/cors-check.https.html @@ -0,0 +1,76 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test if MediaElementAudioSourceNode works for cross-origin redirects with + "cors" request mode. + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/common/get-host-info.sub.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + + setup(() => { + const context = new AudioContext(); + context.suspend(); + + const host_info = get_host_info(); + const audioElement = document.createElement('audio'); + audioElement.loop = true; + audioElement.crossOrigin = 'anonymous'; + const wav = + host_info.HTTPS_ORIGIN + '/webaudio/resources/4ch-440.wav?' + + 'pipe=header(access-control-allow-origin,*)'; + audioElement.src = + host_info.HTTPS_REMOTE_ORIGIN + + '/fetch/api/resources/redirect.py?location=' + + encodeURIComponent(wav); + let source; + let workletRecorder; + + audit.define( + {label: 'setting-up-graph'}, + (task, should) => { + source = new MediaElementAudioSourceNode(context, { + mediaElement: audioElement + }); + workletRecorder = new AudioWorkletNode( + context, 'recorder-processor', {channelCount: 4}); + source.connect(workletRecorder).connect(context.destination); + task.done(); + }); + + // The recorded data from MESN must be non-zero. The source file contains + // 4 channels of sine wave. + audit.define( + {label: 'start-playback-and-capture'}, + (task, should) => { + workletRecorder.port.onmessage = (event) => { + if (event.data.type === 'recordfinished') { + for (let i = 0; i < event.data.recordBuffer.length; ++i) { + const channelData = event.data.recordBuffer[i]; + should(channelData, `Recorded channel #${i}`) + .notBeConstantValueOf(0); + } + } + + task.done(); + }; + + context.resume(); + audioElement.play(); + }); + + Promise.all([ + context.audioWorklet.addModule('/webaudio/js/worklet-recorder.js') + ]).then(() => { + audit.run(); + }); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/mediaElementAudioSourceToScriptProcessorTest.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/mediaElementAudioSourceToScriptProcessorTest.html new file mode 100644 index 0000000000..56d0787b76 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/mediaElementAudioSourceToScriptProcessorTest.html @@ -0,0 +1,130 @@ +<!doctype html> + +<!-- +Tests that a create MediaElementSourceNode that is passed through +a script processor passes the stream data. +The the script processor saves the input buffers it gets to a temporary +array, and after the playback has stopped, the contents are compared +to those of a loaded AudioBuffer with the same source. + +Somewhat similiar to a test from Mozilla: +https://searchfox.org/mozilla-central/source/dom/media/webaudio/test/test_mediaElementAudioSourceNode.html +--> + +<html class="a"> + <head> + <title>MediaElementAudioSource interface test (to scriptProcessor)</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/js/helpers.js"></script> + <script src="/webaudio/js/buffer-loader.js"></script> + </head> + <body class="a"> + <div id="log"></div> + <script> + var elementSourceTest = async_test(function(elementSourceTest) { + + var src = '/webaudio/resources/sin_440Hz_-6dBFS_1s.wav'; + var BUFFER_SIZE = 2048; + var context = null; + var actualBufferArrayC0 = new Float32Array(0); + var actualBufferArrayC1 = new Float32Array(0); + var audio = null, source = null, processor = null + + function loadExpectedBuffer(event) { + bufferLoader = new BufferLoader( + context, + [src], + elementSourceTest.step_func(bufferLoadCompleted) + ); + bufferLoader.load(); + }; + + function bufferLoadCompleted(buffer) { + runTests(buffer); + }; + + function concatTypedArray(arr1, arr2) { + var result = new Float32Array(arr1.length + arr2.length); + result.set(arr1); + result.set(arr2, arr1.length); + return result; + } + + // Create Audio context. The reference wav file is sampled at 44.1 kHz so + // use the same rate for the context to remove extra resampling that might + // be required. + context = new AudioContext({sampleRate: 44100}); + + // Create an audio element, and a media element source + audio = document.createElement('audio'); + audio.src = src; + source = context.createMediaElementSource(audio); + + function processListener (e) { + actualBufferArrayC0 = concatTypedArray(actualBufferArrayC0, e.inputBuffer.getChannelData(0)); + actualBufferArrayC1 = concatTypedArray(actualBufferArrayC1, e.inputBuffer.getChannelData(1)); + } + + // Create a processor node to copy the input to the actual buffer + processor = context.createScriptProcessor(BUFFER_SIZE); + source.connect(processor); + processor.connect(context.destination); + let audioprocessListener = elementSourceTest.step_func(processListener); + processor.addEventListener('audioprocess', audioprocessListener); + + context.addEventListener('statechange', elementSourceTest.step_func(() => { + assert_equals(context.state, "running", "context.state"); + audio.play(); + }), {once: true}); + + // When media playback ended, save the begin to compare with expected buffer + audio.addEventListener("ended", elementSourceTest.step_func(function(e) { + // Setting a timeout since we need audioProcess event to run for all samples + window.setTimeout(elementSourceTest.step_func(loadExpectedBuffer), 50); + })); + + function runTests(expected) { + source.disconnect(); + processor.disconnect(); + + // firefox seems to process events after disconnect + processor.removeEventListener('audioprocess', audioprocessListener) + + // Note: the expected result is from a mono source file. + var expectedBuffer = expected[0]; + + // Trim the actual elements because we don't have a fine-grained + // control over the start and end time of recording the data. + var actualTrimmedC0 = trimEmptyElements(actualBufferArrayC0); + var actualTrimmedC1 = trimEmptyElements(actualBufferArrayC1); + var expectedLength = trimEmptyElements(expectedBuffer.getChannelData(0)).length; + + // Test that there is some data. + test(function() { + assert_greater_than(actualTrimmedC0.length, 0, + "processed data array (C0) length greater than 0"); + assert_greater_than(actualTrimmedC1.length, 0, + "processed data array (C1) length greater than 0"); + }, "Channel 0 processed some data"); + + // Test the actual contents of the 1st and second channel. + test(function() { + assert_array_approx_equals( + actualTrimmedC0, + trimEmptyElements(expectedBuffer.getChannelData(0)), + 1e-4, + "comparing expected and rendered buffers (channel 0)"); + assert_array_approx_equals( + actualTrimmedC1, + trimEmptyElements(expectedBuffer.getChannelData(0)), + 1e-4, + "comparing expected and rendered buffers (channel 1)"); + }, "All data processed correctly"); + + elementSourceTest.done(); + }; + }, "Element Source tests completed"); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/no-cors.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/no-cors.https.html new file mode 100644 index 0000000000..de2f0b7dd3 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/no-cors.https.html @@ -0,0 +1,75 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test if MediaElementAudioSourceNode works for cross-origin redirects with + "no-cors" request mode. + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/common/get-host-info.sub.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + + setup(() => { + const context = new AudioContext(); + context.suspend(); + + const host_info = get_host_info(); + const audioElement = document.createElement('audio'); + audioElement.loop = true; + const wav = + host_info.HTTPS_ORIGIN + '/webaudio/resources/4ch-440.wav?' + + 'pipe=header(access-control-allow-origin,*)'; + audioElement.src = + host_info.HTTPS_REMOTE_ORIGIN + + '/fetch/api/resources/redirect.py?location=' + + encodeURIComponent(wav); + let source; + let workletRecorder; + + audit.define( + {label: 'setting-up-graph'}, + (task, should) => { + source = new MediaElementAudioSourceNode(context, { + mediaElement: audioElement + }); + workletRecorder = new AudioWorkletNode( + context, 'recorder-processor', {channelCount: 4}); + source.connect(workletRecorder).connect(context.destination); + task.done(); + }); + + // The recorded data from MESN must be non-zero. The source file contains + // 4 channels of sine wave. + audit.define( + {label: 'start-playback-and-capture'}, + (task, should) => { + workletRecorder.port.onmessage = (event) => { + if (event.data.type === 'recordfinished') { + for (let i = 0; i < event.data.recordBuffer.length; ++i) { + const channelData = event.data.recordBuffer[i]; + should(channelData, `Recorded channel #${i}`) + .beConstantValueOf(0); + } + } + + task.done(); + }; + + context.resume(); + audioElement.play(); + }); + + Promise.all([ + context.audioWorklet.addModule('/webaudio/js/worklet-recorder.js') + ]).then(() => { + audit.run(); + }); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/setSinkId-with-MediaElementAudioSourceNode.https.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/setSinkId-with-MediaElementAudioSourceNode.https.html new file mode 100644 index 0000000000..af71782717 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediaelementaudiosourcenode-interface/setSinkId-with-MediaElementAudioSourceNode.https.html @@ -0,0 +1,49 @@ +<!doctype html> +<head> +<title>Test HTMLMediaElement.setSinkId() with MediaElementAudioSourceNode</title> +<link rel="help" href="https://webaudio.github.io/web-audio-api/#MediaElementAudioSourceNode"> +</head> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script src=/resources/testdriver.js></script> +<script src=/resources/testdriver-vendor.js></script> +<script> +"use strict"; +/* +MediaElementAudioSourceNode silences HTMLMediaElement output to underlying +devices but setSinkId() should still function as if there were no +MediaElementAudioSourceNode according to +"The HTMLMediaElement MUST behave in an identical fashion after the +MediaElementAudioSourceNode has been created, except that the rendered audio +will no longer be heard directly, but instead will be heard as a consequence +of the MediaElementAudioSourceNode being connected through the routing graph." + */ + +let audio; +promise_setup(async () => { + audio = new Audio(); + audio.src = "/media/sound_5.oga"; + audio.autoplay = true; + audio.loop = true; + new AudioContext().createMediaElementSource(audio); + await new Promise(r => audio.onplay = r); +}); + +promise_test(t => audio.setSinkId(""), "setSinkId on default audio output should always work"); + +promise_test(t => promise_rejects_dom(t, "NotFoundError", audio.setSinkId("nonexistent_device_id")), + "setSinkId fails with NotFoundError on made up deviceid"); + +promise_test(async t => { + await test_driver.bless('transient activation for selectAudioOutput()'); + const {deviceId} = await navigator.mediaDevices.selectAudioOutput(); + assert_greater_than(deviceId.length, 0, "deviceId.length"); + const p1 = audio.setSinkId(deviceId); + assert_equals(audio.sinkId, "", "before it resolves, setSinkId is unchanged"); + await p1; + assert_equals(audio.sinkId, deviceId, "setSinkId updates sinkId to the requested deviceId"); + await audio.setSinkId(""); + assert_equals(audio.sinkId, "", "resetting sink ID to default audio output should always work"); +}, "setSinkId() with output device ID exposed by selectAudioOutput() should resolve"); + +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiodestinationnode-interface/ctor-mediastreamaudiodestination.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiodestinationnode-interface/ctor-mediastreamaudiodestination.html new file mode 100644 index 0000000000..5d3fd0c26f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiodestinationnode-interface/ctor-mediastreamaudiodestination.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: MediaStreamAudioDestinationNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context = new AudioContext(); + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + // Need AudioContext, not OfflineAudioContext, for these tests. + should(() => { + context = new AudioContext(); + }, 'context = new AudioContext()').notThrow(); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor( + should, 'MediaStreamAudioDestinationNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor( + should, 'MediaStreamAudioDestinationNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 0, + channelCount: 2, + channelCountMode: 'explicit', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, []); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions( + should, context, 'MediaStreamAudioDestinationNode', { + channelCount: { + // An arbitrary but valid, non-default count for this node. + value: 7 + } + }); + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiosourcenode-interface/mediastreamaudiosourcenode-ctor.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiosourcenode-interface/mediastreamaudiosourcenode-ctor.html new file mode 100644 index 0000000000..a711419656 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiosourcenode-interface/mediastreamaudiosourcenode-ctor.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> + +<html class="a"> + <head> + <title>MediaStreamAudioSourceNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body class="a"> + <div id="log"></div> + <script> + setup({explicit_done: true}); + // Wait until the DOM is ready to be able to get a reference to the canvas + // element. + window.addEventListener("load", function() { + const ac = new AudioContext(); + const emptyStream = new MediaStream(); + + test(function() { + assert_throws_dom( + "InvalidStateError", + function() { + ac.createMediaStreamSource(emptyStream); + }, + `A MediaStreamAudioSourceNode can only be constructed via the factory + method with a MediaStream that has at least one track of kind "audio"` + ); + }, "MediaStreamAudioSourceNode created with factory method and MediaStream with no tracks"); + + test(function() { + assert_throws_dom( + "InvalidStateError", + function() { + new MediaStreamAudioSourceNode(ac, { mediaStream: emptyStream }); + }, + `A MediaStreamAudioSourceNode can only be constructed via the constructor + with a MediaStream that has at least one track of kind "audio"` + ); + }, "MediaStreamAudioSourceNode created with constructor and MediaStream with no tracks"); + + const canvas = document.querySelector("canvas"); + const ctx = canvas.getContext("2d"); + const videoOnlyStream = canvas.captureStream(); + + test(function() { + assert_throws_dom( + "InvalidStateError", + function() { + ac.createMediaStreamSource(videoOnlyStream); + }, + `A MediaStreamAudioSourceNode can only be constructed via the factory with a + MediaStream that has at least one track of kind "audio"` + ); + }, `MediaStreamAudioSourceNode created with the factory method and MediaStream with only a video track`); + + test(function() { + assert_throws_dom( + "InvalidStateError", + function() { + new MediaStreamAudioSourceNode(ac, { + mediaStream: videoOnlyStream, + }); + }, + `A MediaStreamAudioSourceNode can only be constructed via the factory with a + MediaStream that has at least one track of kind "audio"` + ); + }, `MediaStreamAudioSourceNode created with constructor and MediaStream with only a video track`); + done(); + }); + </script> + </body> + <canvas></canvas> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiosourcenode-interface/mediastreamaudiosourcenode-routing.html b/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiosourcenode-interface/mediastreamaudiosourcenode-routing.html new file mode 100644 index 0000000000..816eba0b29 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-mediastreamaudiosourcenode-interface/mediastreamaudiosourcenode-routing.html @@ -0,0 +1,127 @@ +<!DOCTYPE html> + +<html class="a"> + <head> + <title>MediaStreamAudioSourceNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + </head> + <body class="a"> + <div id="log"></div> + <script> + function binIndexForFrequency(frequency, analyser) { + return ( + 1 + + Math.round( + (frequency * analyser.fftSize) / analyser.context.sampleRate + ) + ); + } + + const t = async_test( + "MediaStreamAudioSourceNode captures the right track." + ); + t.step(function() { + const ac = new AudioContext(); + // Test that the right track is captured. Set up a MediaStream that has two + // tracks, one with a tone at 100Hz and one with a tone at 1000Hz. + const dest0 = ac.createMediaStreamDestination(); + const dest1 = ac.createMediaStreamDestination(); + const osc0 = ac.createOscillator(); + const osc1 = ac.createOscillator(); + osc0.frequency.value = 100; + osc1.frequency.value = 1000; + osc0.connect(dest0); + osc1.connect(dest1); + osc0.start(0); + osc1.start(0); + const track0 = dest0.stream.getAudioTracks()[0]; + const track0id = track0.id; + const track1 = dest1.stream.getAudioTracks()[0]; + const track1id = track1.id; + + let ids = [track0id, track1id]; + ids.sort(); + let targetFrequency; + let otherFrequency; + if (ids[0] == track0id) { + targetFrequency = 100; + otherFrequency = 1000; + } else { + targetFrequency = 1000; + otherFrequency = 100; + } + + let twoTrackMediaStream = new MediaStream(); + twoTrackMediaStream.addTrack(track0); + twoTrackMediaStream.addTrack(track1); + + const twoTrackSource = ac.createMediaStreamSource(twoTrackMediaStream); + const analyser = ac.createAnalyser(); + // Don't do smoothing so that the frequency data changes quickly + analyser.smoothingTimeConstant = 0; + + twoTrackSource.connect(analyser); + + const indexToCheckForHighEnergy = binIndexForFrequency( + targetFrequency, + analyser + ); + const indexToCheckForLowEnergy = binIndexForFrequency( + otherFrequency, + analyser + ); + let frequencyData = new Float32Array(1024); + let checkCount = 0; + let numberOfRemovals = 0; + let stopped = false; + function analyse() { + analyser.getFloatFrequencyData(frequencyData); + // there should be high energy in the right bin, higher than 40dbfs because + // it's supposed to be a sine wave at 0dbfs + if (frequencyData[indexToCheckForHighEnergy] > -40 && !stopped) { + assert_true(true, "Correct track routed to the AudioContext."); + checkCount++; + } + if (stopped && frequencyData[indexToCheckForHighEnergy] < -40) { + assert_true( + true, + `After stopping the track, low energy is found in the + same bin` + ); + checkCount++; + } + if (checkCount > 5 && checkCount < 20) { + twoTrackMediaStream.getAudioTracks().forEach(track => { + if (track.id == ids[0]) { + numberOfRemovals++; + window.removedTrack = track; + twoTrackMediaStream.removeTrack(track); + } + }); + assert_true( + numberOfRemovals == 1, + `The mediastreamtrack can only be + removed once from the mediastream` + ); + } else if (checkCount >= 20 && checkCount < 30) { + window.removedTrack.stop(); + stopped = true; + } else if (checkCount >= 30) { + assert_true( + numberOfRemovals == 1, + `After removing the track from the + mediastream, it's still routed to the graph.` + ); + // After some time, consider that it worked. + t.done(); + return; + } + + t.step_timeout(analyse, 100); + } + t.step_timeout(analyse, 100); + }); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/ctor-offlineaudiocontext.html b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/ctor-offlineaudiocontext.html new file mode 100644 index 0000000000..4b68631036 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/ctor-offlineaudiocontext.html @@ -0,0 +1,203 @@ +<!doctype html> +<html> + <head> + <title>Test Constructor: OfflineAudioContext</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + + <body> + <script> + let audit = Audit.createTaskRunner(); + + // Just a simple test of the 3-arg constructor; This should be + // well-covered by other layout tests that use the 3-arg constructor. + audit.define( + {label: 'basic', description: 'Old-style constructor'}, + (task, should) => { + let context; + + // First and only arg should be a dictionary. + should(() => { + new OfflineAudioContext(3); + }, 'new OfflineAudioContext(3)').throw(TypeError); + + // Constructor needs 1 or 3 args, so 2 should throw. + should(() => { + new OfflineAudioContext(3, 42); + }, 'new OfflineAudioContext(3, 42)').throw(TypeError); + + // Valid constructor + should(() => { + context = new OfflineAudioContext(3, 42, 12345); + }, 'context = new OfflineAudioContext(3, 42, 12345)').notThrow(); + + // Verify that the context was constructed correctly. + should(context.length, 'context.length').beEqualTo(42); + should(context.sampleRate, 'context.sampleRate').beEqualTo(12345); + should( + context.destination.channelCount, + 'context.destination.channelCount') + .beEqualTo(3); + should( + context.destination.channelCountMode, + 'context.destination.channelCountMode') + .beEqualTo('explicit'); + should( + context.destination.channelInterpretation, + 'context.destination.channelInterpretation') + .beEqualTo('speakers'); + task.done(); + }); + + // Test constructor throws an error if the required members of the + // dictionary are not given. + audit.define( + {label: 'options-1', description: 'Required options'}, + (task, should) => { + let context2; + + // No args should throw + should(() => { + new OfflineAudioContext(); + }, 'new OfflineAudioContext()').throw(TypeError); + + // Empty OfflineAudioContextOptions should throw + should(() => { + new OfflineAudioContext({}); + }, 'new OfflineAudioContext({})').throw(TypeError); + + let options = {length: 42}; + // sampleRate is required. + should( + () => { + new OfflineAudioContext(options); + }, + 'new OfflineAudioContext(' + JSON.stringify(options) + ')') + .throw(TypeError); + + options = {sampleRate: 12345}; + // length is required. + should( + () => { + new OfflineAudioContext(options); + }, + 'new OfflineAudioContext(' + JSON.stringify(options) + ')') + .throw(TypeError); + + // Valid constructor. Verify that the resulting context has the + // correct values. + options = {length: 42, sampleRate: 12345}; + should( + () => { + context2 = new OfflineAudioContext(options); + }, + 'c2 = new OfflineAudioContext(' + JSON.stringify(options) + ')') + .notThrow(); + should( + context2.destination.channelCount, + 'c2.destination.channelCount') + .beEqualTo(1); + should(context2.length, 'c2.length').beEqualTo(options.length); + should(context2.sampleRate, 'c2.sampleRate') + .beEqualTo(options.sampleRate); + should( + context2.destination.channelCountMode, + 'c2.destination.channelCountMode') + .beEqualTo('explicit'); + should( + context2.destination.channelInterpretation, + 'c2.destination.channelInterpretation') + .beEqualTo('speakers'); + + task.done(); + }); + + // Constructor should throw errors for invalid values specified by + // OfflineAudioContextOptions. + audit.define( + {label: 'options-2', description: 'Invalid options'}, + (task, should) => { + let options = {length: 42, sampleRate: 8000, numberOfChannels: 33}; + + // channelCount too large. + should( + () => { + new OfflineAudioContext(options); + }, + 'new OfflineAudioContext(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + // length cannot be 0 + options = {length: 0, sampleRate: 8000}; + should( + () => { + new OfflineAudioContext(options); + }, + 'new OfflineAudioContext(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + // sampleRate outside valid range + options = {length: 1, sampleRate: 1}; + should( + () => { + new OfflineAudioContext(options); + }, + 'new OfflineAudioContext(' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + + task.done(); + }); + + audit.define( + {label: 'options-3', description: 'Valid options'}, + (task, should) => { + let context; + let options = { + length: 1, + sampleRate: 8000, + }; + + // Verify context with valid constructor has the correct values. + should( + () => { + context = new OfflineAudioContext(options); + }, + 'c = new OfflineAudioContext' + JSON.stringify(options) + ')') + .notThrow(); + should(context.length, 'c.length').beEqualTo(options.length); + should(context.sampleRate, 'c.sampleRate') + .beEqualTo(options.sampleRate); + should( + context.destination.channelCount, 'c.destination.channelCount') + .beEqualTo(1); + should( + context.destination.channelCountMode, + 'c.destination.channelCountMode') + .beEqualTo('explicit'); + should( + context.destination.channelInterpretation, + 'c.destination.channelCountMode') + .beEqualTo('speakers'); + + options.numberOfChannels = 7; + should( + () => { + context = new OfflineAudioContext(options); + }, + 'c = new OfflineAudioContext' + JSON.stringify(options) + ')') + .notThrow(); + should( + context.destination.channelCount, 'c.destination.channelCount') + .beEqualTo(options.numberOfChannels); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/current-time-block-size.html b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/current-time-block-size.html new file mode 100644 index 0000000000..ee976f7f72 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/current-time-block-size.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<title>Test currentTime at completion of OfflineAudioContext rendering</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +promise_test(function() { + // sampleRate is a power of two so that time can be represented exactly + // in double currentTime. + var context = new OfflineAudioContext(1, 1, 65536); + return context.startRendering(). + then(function(buffer) { + assert_equals(buffer.length, 1, "buffer length"); + assert_equals(context.currentTime, 128 / context.sampleRate, + "currentTime at completion"); + }); +}); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/offlineaudiocontext-detached-execution-context.html b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/offlineaudiocontext-detached-execution-context.html new file mode 100644 index 0000000000..6eafd15fd2 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/offlineaudiocontext-detached-execution-context.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Testing behavior OfflineAudioContext after execution context is detached + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + const audit = Audit.createTaskRunner(); + + audit.define('decoding-on-detached-iframe', (task, should) => { + const iframe = + document.createElementNS("http://www.w3.org/1999/xhtml", "iframe"); + document.body.appendChild(iframe); + + // Use the lowest value possible for the faster test. + let context = + new iframe.contentWindow.OfflineAudioContext(1, 1, 8000); + + document.body.removeChild(iframe); + + return should(context.decodeAudioData(new ArrayBuffer(1)), + 'decodeAudioData() upon a detached iframe') + .beRejectedWith('InvalidStateError'); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/startrendering-after-discard.html b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/startrendering-after-discard.html new file mode 100644 index 0000000000..dd610ec335 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-offlineaudiocontext-interface/startrendering-after-discard.html @@ -0,0 +1,24 @@ +<!doctype html> +<title>Test for rejected promise from startRendering() on an + OfflineAudioContext in a discarded browsing context</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<body></body> +<script> +let context; +let childDOMException; +setup(() => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + context = new frame.contentWindow.OfflineAudioContext( + {length: 1, sampleRate: 48000}); + childDOMException = frame.contentWindow.DOMException; + frame.remove(); +}); + +promise_test((t) => promise_rejects_dom( + t, 'InvalidStateError', childDOMException, context.startRendering()), + 'startRendering()'); +// decodeAudioData() is tested in +// offlineaudiocontext-detached-execution-context.html +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/ctor-oscillator.html b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/ctor-oscillator.html new file mode 100644 index 0000000000..bf50195a5b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/ctor-oscillator.html @@ -0,0 +1,112 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: Oscillator + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'OscillatorNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'OscillatorNode', context, { + prefix: prefix, + numberOfInputs: 0, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes( + should, node, prefix, + [{name: 'type', value: 'sine'}, {name: 'frequency', value: 440}]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'OscillatorNode'); + task.done(); + }); + + audit.define('constructor options', (task, should) => { + let node; + let options = {type: 'sawtooth', detune: 7, frequency: 918}; + + should( + () => { + node = new OscillatorNode(context, options); + }, + 'node1 = new OscillatorNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + + should(node.type, 'node1.type').beEqualTo(options.type); + should(node.detune.value, 'node1.detune.value') + .beEqualTo(options.detune); + should(node.frequency.value, 'node1.frequency.value') + .beEqualTo(options.frequency); + + should(node.channelCount, 'node1.channelCount').beEqualTo(2); + should(node.channelCountMode, 'node1.channelCountMode') + .beEqualTo('max'); + should(node.channelInterpretation, 'node1.channelInterpretation') + .beEqualTo('speakers'); + + // Test that type and periodicWave options work as described. + options = { + type: 'sine', + periodicWave: new PeriodicWave(context, {real: [1, 1]}) + }; + should(() => { + node = new OscillatorNode(context, options); + }, 'new OscillatorNode(c, ' + JSON.stringify(options) + ')').notThrow(); + + options = {type: 'custom'}; + should( + () => { + node = new OscillatorNode(context, options); + }, + 'new OscillatorNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + + options = { + type: 'custom', + periodicWave: new PeriodicWave(context, {real: [1, 1]}) + }; + should(() => { + node = new OscillatorNode(context, options); + }, 'new OscillatorNode(c, ' + JSON.stringify(options) + ')').notThrow(); + + should( + () => { + node = new OscillatorNode(context, {periodicWave: null}); + }, + 'new OscillatorNode(c, {periodicWave: null}') + .throw(DOMException, 'TypeError'); + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/detune-limiting.html b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/detune-limiting.html new file mode 100644 index 0000000000..81a1293d03 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/detune-limiting.html @@ -0,0 +1,154 @@ +<!doctype html> +<html> + <head> + <title> + Oscillator Detune Limits + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + const sampleRate = 44100; + const renderLengthSeconds = 0.125; + + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'detune limits', + description: + 'Oscillator with detune and frequency at Nyquist or above' + }, + (task, should) => { + let context = new OfflineAudioContext( + 2, renderLengthSeconds * sampleRate, sampleRate); + + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + // For test oscillator, set the oscillator frequency to -Nyquist and + // set detune to be a large number that would cause the detuned + // frequency to be way above Nyquist. + const oscFrequency = 1; + const detunedFrequency = sampleRate; + const detuneValue = Math.fround(1200 * Math.log2(detunedFrequency)); + + let testOsc = new OscillatorNode( + context, {frequency: oscFrequency, detune: detuneValue}); + testOsc.connect(merger, 0, 1); + + // For the reference oscillator, determine the computed oscillator + // frequency using the values above and set that as the oscillator + // frequency. + let computedFreq = oscFrequency * Math.pow(2, detuneValue / 1200); + + let refOsc = new OscillatorNode(context, {frequency: computedFreq}); + refOsc.connect(merger, 0, 0); + + // Start 'em up and render + testOsc.start(); + refOsc.start(); + + context.startRendering() + .then(renderedBuffer => { + let expected = renderedBuffer.getChannelData(0); + let actual = renderedBuffer.getChannelData(1); + + // Let user know about the smaple rate so following messages + // make more sense. + should(context.sampleRate, 'Context sample rate') + .beEqualTo(context.sampleRate); + + // Since the frequency is at Nyquist, the reference oscillator + // output should be zero. + should( + refOsc.frequency.value, 'Reference oscillator frequency') + .beGreaterThanOrEqualTo(context.sampleRate / 2); + should( + expected, `Osc(freq: ${refOsc.frequency.value}) output`) + .beConstantValueOf(0); + // The output from each oscillator should be the same. + should( + actual, + 'Osc(freq: ' + oscFrequency + ', detune: ' + detuneValue + + ') output') + .beCloseToArray(expected, {absoluteThreshold: 0}); + + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'detune automation', + description: + 'Oscillator output with detune automation should be zero ' + + 'above Nyquist' + }, + (task, should) => { + let context = new OfflineAudioContext( + 1, renderLengthSeconds * sampleRate, sampleRate); + + const baseFrequency = 1; + const rampEnd = renderLengthSeconds / 2; + const detuneEnd = 1e7; + + let src = new OscillatorNode(context, {frequency: baseFrequency}); + src.detune.linearRampToValueAtTime(detuneEnd, rampEnd); + + src.connect(context.destination); + + src.start(); + + context.startRendering() + .then(renderedBuffer => { + let audio = renderedBuffer.getChannelData(0); + + // At some point, the computed oscillator frequency will go + // above Nyquist. Determine at what time this occurrs. The + // computed frequency is f * 2^(d/1200) where |f| is the + // oscillator frequency and |d| is the detune value. Thus, + // find |d| such that Nyquist = f*2^(d/1200). That is, d = + // 1200*log2(Nyquist/f) + let criticalDetune = + 1200 * Math.log2(context.sampleRate / 2 / baseFrequency); + + // Now figure out at what point on the linear ramp does the + // detune value reach this critical value. For a linear ramp: + // + // v(t) = V0+(V1-V0)*(t-T0)/(T1-T0) + // + // Thus, + // + // t = ((T1-T0)*v(t) + T0*V1 - T1*V0)/(V1-V0) + // + // In this test, T0 = 0, V0 = 0, T1 = rampEnd, V1 = + // detuneEnd, and v(t) = criticalDetune + let criticalTime = (rampEnd * criticalDetune) / detuneEnd; + let criticalFrame = + Math.ceil(criticalTime * context.sampleRate); + + should( + criticalFrame, + `Frame where detuned oscillator reaches Nyquist`) + .beEqualTo(criticalFrame); + + should( + audio.slice(0, criticalFrame), + `osc[0:${criticalFrame - 1}]`) + .notBeConstantValueOf(0); + + should(audio.slice(criticalFrame), `osc[${criticalFrame}:]`) + .beConstantValueOf(0); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/detune-overflow.html b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/detune-overflow.html new file mode 100644 index 0000000000..28c28bc1db --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/detune-overflow.html @@ -0,0 +1,41 @@ +<!doctype html> +<html> + <head> + <title>Test Osc.detune Overflow</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + </head> + + <body> + <script> + const sampleRate = 44100; + const renderLengthFrames = RENDER_QUANTUM_FRAMES; + + let audit = Audit.createTaskRunner(); + + audit.define('detune overflow', async (task, should) => { + let context = + new OfflineAudioContext(1, renderLengthFrames, sampleRate); + + // This value of frequency and detune results in a computed frequency of + // 440*2^(153600/1200) = 1.497e41. The frequency needs to be clamped to + // Nyquist. But a sine wave at Nyquist frequency is all zeroes. Verify + // the output is 0. + let osc = new OscillatorNode(context, {frequency: 440, detune: 153600}); + + osc.connect(context.destination); + + let buffer = await context.startRendering(); + let output = buffer.getChannelData(0); + should(output, 'Osc freq and detune outside nominal range') + .beConstantValueOf(0); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/osc-basic-waveform.html b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/osc-basic-waveform.html new file mode 100644 index 0000000000..ce6e262fa9 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-oscillatornode-interface/osc-basic-waveform.html @@ -0,0 +1,229 @@ +<!doctype html> +<html> + <head> + <title> + Test Basic Oscillator Sine Wave Test + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + // Don't change the sample rate. The tests below depend on this sample + // rate to cover all the cases in Chrome's implementation. But the tests + // are general and apply to any browser. + const sampleRate = 44100; + + // Only need a few samples for testing, so just use two renders. + const durationFrames = 2 * RENDER_QUANTUM_FRAMES; + + let audit = Audit.createTaskRunner(); + + // The following tests verify that the oscillator produces the same + // results as the mathematical oscillators. We choose sine wave and a + // custom wave because we know they're bandlimited and won't change with + // the frequency. + // + // The tests for 1 and 2 Hz are intended to test Chrome's interpolation + // algorithm, but are still generally applicable to any browser. + + audit.define( + {label: 'Test 0', description: 'Sine wave: 100 Hz'}, + async (task, should) => { + let context = new OfflineAudioContext( + {length: durationFrames, sampleRate: sampleRate}); + + const freqHz = 100; + + let src = + new OscillatorNode(context, {type: 'sine', frequency: freqHz}); + src.connect(context.destination); + + src.start(); + + let renderedBuffer = await context.startRendering(); + checkResult(should, renderedBuffer, context, { + freqHz: freqHz, + a1: 0, + b1: 1, + prefix: 'Sine', + threshold: 1.8045e-5, + snrThreshold: 112.5 + }); + task.done(); + }); + + audit.define( + {label: 'Test 1', description: 'Sine wave: -100 Hz'}, + async (task, should) => { + let context = new OfflineAudioContext( + {length: durationFrames, sampleRate: sampleRate}); + + const freqHz = -100; + + let src = + new OscillatorNode(context, {type: 'sine', frequency: freqHz}); + src.connect(context.destination); + + src.start(); + + let renderedBuffer = await context.startRendering(); + checkResult(should, renderedBuffer, context, { + freqHz: freqHz, + a1: 0, + b1: 1, + prefix: 'Sine', + threshold: 1.8045e-5, + snrThreshold: 112.67 + }); + task.done(); + }); + + audit.define( + {label: 'Test 2', description: 'Sine wave: 2 Hz'}, + async (task, should) => { + let context = new OfflineAudioContext( + {length: durationFrames, sampleRate: sampleRate}); + + const freqHz = 2; + + let src = + new OscillatorNode(context, {type: 'sine', frequency: freqHz}); + src.connect(context.destination); + + src.start(); + + let renderedBuffer = await context.startRendering(); + checkResult(should, renderedBuffer, context, { + freqHz: freqHz, + a1: 0, + b1: 1, + prefix: 'Sine', + threshold: 1.4516e-7, + snrThreshold: 119.93 + }); + task.done(); + }); + + audit.define( + {label: 'Test 3', description: 'Sine wave: 1 Hz'}, + async (task, should) => { + let context = new OfflineAudioContext( + {length: durationFrames, sampleRate: sampleRate}); + + const freqHz = 1; + + let src = + new OscillatorNode(context, {type: 'sine', frequency: freqHz}); + src.connect(context.destination); + + src.start(); + + let renderedBuffer = await context.startRendering(); + checkResult(should, renderedBuffer, context, { + freqHz: freqHz, + a1: 0, + b1: 1, + prefix: 'Sine', + threshold: 1.4157e-7, + snrThreshold: 112.22 + }); + task.done(); + }); + + audit.define( + {label: 'Test 4', description: 'Custom wave: 100 Hz'}, + async (task, should) => { + let context = new OfflineAudioContext( + {length: durationFrames, sampleRate: sampleRate}); + + const freqHz = 100; + + let wave = new PeriodicWave( + context, + {real: [0, 1], imag: [0, 1], disableNormalization: true}); + let src = new OscillatorNode( + context, + {type: 'custom', frequency: freqHz, periodicWave: wave}); + src.connect(context.destination); + + src.start(); + + let renderedBuffer = await context.startRendering(); + checkResult(should, renderedBuffer, context, { + freqHz: freqHz, + a1: 1, + b1: 1, + prefix: 'Custom', + threshold: 5.1e-5, + snrThreshold: 112.6 + }); + task.done(); + }); + + audit.define( + {label: 'Test 5', description: 'Custom wave: 1 Hz'}, + async (task, should) => { + let context = new OfflineAudioContext( + {length: durationFrames, sampleRate: sampleRate}); + + const freqHz = 1; + + let wave = new PeriodicWave( + context, + {real: [0, 1], imag: [0, 1], disableNormalization: true}); + let src = new OscillatorNode( + context, + {type: 'custom', frequency: freqHz, periodicWave: wave}); + src.connect(context.destination); + + src.start(); + + let renderedBuffer = await context.startRendering(); + checkResult(should, renderedBuffer, context, { + freqHz: freqHz, + a1: 1, + b1: 1, + prefix: 'Custom', + threshold: 4.7684e-7, + snrThreshold: 133.0 + }); + task.done(); + }); + + audit.run(); + + function waveForm(context, freqHz, a1, b1, nsamples) { + let buffer = + new AudioBuffer({length: nsamples, sampleRate: context.sampleRate}); + let signal = buffer.getChannelData(0); + const omega = 2 * Math.PI * freqHz / context.sampleRate; + for (let k = 0; k < nsamples; ++k) { + signal[k] = a1 * Math.cos(omega * k) + b1 * Math.sin(omega * k); + } + + return buffer; + } + + function checkResult(should, renderedBuffer, context, options) { + let {freqHz, a1, b1, prefix, threshold, snrThreshold} = options; + + let actual = renderedBuffer.getChannelData(0); + + let expected = + waveForm(context, freqHz, a1, b1, actual.length).getChannelData(0); + + should(actual, `${prefix}: ${freqHz} Hz`).beCloseToArray(expected, { + absoluteThreshold: threshold + }); + + let snr = 10 * Math.log10(computeSNR(actual, expected)); + + should(snr, `${prefix}: SNR (db)`).beGreaterThanOrEqualTo(snrThreshold); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/automation-changes.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/automation-changes.html new file mode 100644 index 0000000000..8aa73552aa --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/automation-changes.html @@ -0,0 +1,140 @@ +<!doctype html> +<html> + <head> + <title>Panner Node Automation</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + + <body> + <script> + // Use a power-of-two to eliminate some round-off; otherwise, this isn't + // really important. + const sampleRate = 16384; + + // Render enough for the test; we don't need a lot. + const renderFrames = 2048; + + // Initial panner positionX and final positionX for listener. + const positionX = 2000; + + const audit = Audit.createTaskRunner(); + + // Test that listener.positionX.value setter does the right thing. + audit.define('Set Listener.positionX.value', (task, should) => { + const context = new OfflineAudioContext(2, renderFrames, sampleRate); + + createGraph(context); + + // Frame at which the listener instantaneously moves to a new location. + const moveFrame = 512; + + context.suspend(moveFrame / context.sampleRate) + .then(() => { + context.listener.positionX.value = positionX; + }) + .then(() => context.resume()); + + verifyOutput(context, moveFrame, should, 'listenr.positionX.value') + .then(() => task.done()); + }); + + // Test that listener.positionX.setValueAtTime() does the right thing. + audit.define('Listener.positionX.setValue', (task, should) => { + const context = new OfflineAudioContext(2, renderFrames, sampleRate); + + createGraph(context); + + // Frame at which the listener instantaneously moves to a new location. + const moveFrame = 512; + + context.listener.positionX.setValueAtTime( + positionX, moveFrame / context.sampleRate); + + verifyOutput( + context, moveFrame, should, 'listener.positionX.setValueATTime') + .then(() => task.done()); + }); + + // Test that listener.setPosition() does the right thing. + audit.define('Listener.setPosition', (task, should) => { + const context = new OfflineAudioContext(2, renderFrames, sampleRate); + + createGraph(context); + + // Frame at which the listener instantaneously moves to a new location. + const moveFrame = 512; + + context.suspend(moveFrame / context.sampleRate) + .then(() => { + context.listener.setPosition(positionX, 0, 0); + }) + .then(() => context.resume()); + + verifyOutput(context, moveFrame, should, 'listener.setPostion') + .then(() => task.done()); + }); + + audit.run(); + + + // Create the basic graph for testing which consists of an oscillator node + // connected to a panner node. + function createGraph(context) { + const listener = context.listener; + + listener.positionX.value = 0; + listener.positionY.value = 0; + listener.positionZ.value = 0; + + const src = new OscillatorNode(context); + + const panner = new PannerNode(context, { + distanceModel: 'linear', + refDistance: 1, + maxDistance: 3000, + positionX: positionX, + positionY: 0, + positionZ: 0 + }); + src.connect(panner).connect(context.destination); + + src.start(); + } + + + // Verify the output from the panner is correct. + function verifyOutput(context, moveFrame, should, prefix) { + return context.startRendering().then(resultBuffer => { + // Get the outputs (left and right) + const c0 = resultBuffer.getChannelData(0); + const c1 = resultBuffer.getChannelData(1); + + // The src/listener set up is such that audio should only come + // from the right for until |moveFrame|. Hence the left channel + // should be 0 (or very nearly 0). + const zero = new Float32Array(moveFrame); + + should( + c0.slice(0, moveFrame), `${prefix}: output0[0:${moveFrame - 1}]`) + .beCloseToArray(zero, {absoluteThreshold: 1e-16}); + should( + c1.slice(0, moveFrame), `${prefix}: output1[0:${moveFrame - 1}]`) + .notBeConstantValueOf(0); + + // At |moveFrame| and beyond, the listener and source are at the + // same position, so the outputs from the left and right should be + // identical, and the left channel should not be 0 anymore. + + should(c0.slice(moveFrame), `${prefix}: output0[${moveFrame}:]`) + .notBeConstantValueOf(0); + should(c1.slice(moveFrame), `${prefix}: output1[${moveFrame}:]`) + .beCloseToArray(c0.slice(moveFrame)); + }); + } + </script> + </body> +</html> + diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/ctor-panner.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/ctor-panner.html new file mode 100644 index 0000000000..c434aa8c6a --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/ctor-panner.html @@ -0,0 +1,468 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: Panner + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'PannerNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'PannerNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'clamped-max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [ + {name: 'panningModel', value: 'equalpower'}, + {name: 'positionX', value: 0}, {name: 'positionY', value: 0}, + {name: 'positionZ', value: 0}, {name: 'orientationX', value: 1}, + {name: 'orientationY', value: 0}, {name: 'orientationZ', value: 0}, + {name: 'distanceModel', value: 'inverse'}, + {name: 'refDistance', value: 1}, {name: 'maxDistance', value: 10000}, + {name: 'rolloffFactor', value: 1}, + {name: 'coneInnerAngle', value: 360}, + {name: 'coneOuterAngle', value: 360}, + {name: 'coneOuterGain', value: 0} + ]); + + // Test the listener too, while we're at it. + let listenerAttributes = [ + {name: 'positionX', value: 0}, + {name: 'positionY', value: 0}, + {name: 'positionZ', value: 0}, + {name: 'forwardX', value: 0}, + {name: 'forwardY', value: 0}, + {name: 'forwardZ', value: -1}, + {name: 'upX', value: 0}, + {name: 'upY', value: 1}, + {name: 'upZ', value: 0}, + ]; + + listenerAttributes.forEach((item) => { + should( + context.listener[item.name].value, + 'context.listener.' + item.name + '.value') + .beEqualTo(item.value); + }); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + // Can't use testAudioNodeOptions because the constraints for this node + // are not supported there. + let node; + let success = true; + + // Test that we can set the channel count to 1 or 2. + let options = {channelCount: 1}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node1 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.channelCount, 'node1.channelCount') + .beEqualTo(options.channelCount); + + options = {channelCount: 2}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node2 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.channelCount, 'node2.channelCount') + .beEqualTo(options.channelCount); + + // Test that other channel counts throw an error + options = {channelCount: 0}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + should( + () => { + node = new PannerNode(context); + node.channelCount = options.channelCount; + }, + `node.channelCount = ${options.channelCount}`) + .throw(DOMException, "NotSupportedError"); + should(node.channelCount, + `node.channelCount after setting to ${options.channelCount}`) + .beEqualTo(2); + + options = {channelCount: 3}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + should( + () => { + node = new PannerNode(context); + node.channelCount = options.channelCount; + }, + `node.channelCount = ${options.channelCount}`) + .throw(DOMException, "NotSupportedError"); + should(node.channelCount, + `node.channelCount after setting to ${options.channelCount}`) + .beEqualTo(2); + + options = {channelCount: 99}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + should( + () => { + node = new PannerNode(context); + node.channelCount = options.channelCount; + }, + `node.channelCount = ${options.channelCount}`) + .throw(DOMException, "NotSupportedError"); + should(node.channelCount, + `node.channelCount after setting to ${options.channelCount}`) + .beEqualTo(2); + + // Test channelCountMode. A mode of "max" is illegal, but others are + // ok. + options = {channelCountMode: 'clamped-max'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node3 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.channelCountMode, 'node3.channelCountMode') + .beEqualTo(options.channelCountMode); + + options = {channelCountMode: 'explicit'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node4 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.channelCountMode, 'node4.channelCountMode') + .beEqualTo(options.channelCountMode); + + options = {channelCountMode: 'max'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'NotSupportedError'); + should( + () => { + node = new PannerNode(context); + node.channelCountMode = options.channelCountMode; + }, + `node.channelCountMode = ${options.channelCountMode}`) + .throw(DOMException, "NotSupportedError"); + should(node.channelCountMode, + `node.channelCountMode after setting to ${options.channelCountMode}`) + .beEqualTo("clamped-max"); + + options = {channelCountMode: 'foobar'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, " + JSON.stringify(options) + ")') + .throw(TypeError); + should( + () => { + node = new PannerNode(context); + node.channelCountMode = options.channelCountMode; + }, + `node.channelCountMode = ${options.channelCountMode}`) + .notThrow(); // Invalid assignment to enum-valued attrs does not throw. + should(node.channelCountMode, + `node.channelCountMode after setting to ${options.channelCountMode}`) + .beEqualTo("clamped-max"); + + // Test channelInterpretation. + options = {channelInterpretation: 'speakers'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node5 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.channelInterpretation, 'node5.channelInterpretation') + .beEqualTo(options.channelInterpretation); + + options = {channelInterpretation: 'discrete'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node6 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.channelInterpretation, 'node6.channelInterpretation') + .beEqualTo(options.channelInterpretation); + + options = {channelInterpretation: 'foobar'}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(TypeError); + + // Test maxDistance + options = {maxDistance: -1}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(RangeError); + should( + () => { + node = new PannerNode(context); + node.maxDistance = options.maxDistance; + }, + `node.maxDistance = ${options.maxDistance}`) + .throw(RangeError); + should(node.maxDistance, + `node.maxDistance after setting to ${options.maxDistance}`) + .beEqualTo(10000); + + options = {maxDistance: 100}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node7 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.maxDistance, 'node7.maxDistance') + .beEqualTo(options.maxDistance); + + // Test rolloffFactor + options = {rolloffFactor: -1}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(RangeError); + should( + () => { + node = new PannerNode(context); + node.rolloffFactor = options.rolloffFactor; + }, + `node.rolloffFactor = ${options.rolloffFactor}`) + .throw(RangeError); + should(node.rolloffFactor, + `node.rolloffFactor after setting to ${options.rolloffFactor}`) + .beEqualTo(1); + + options = {rolloffFactor: 0}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node8 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.rolloffFactor, 'node8.rolloffFactor') + .beEqualTo(options.rolloffFactor); + + options = {rolloffFactor: 0.5}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node8 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.rolloffFactor, 'node8.rolloffFactor') + .beEqualTo(options.rolloffFactor); + + options = {rolloffFactor: 100}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node8 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.rolloffFactor, 'node8.rolloffFactor') + .beEqualTo(options.rolloffFactor); + + // Test coneOuterGain + options = {coneOuterGain: -1}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + should( + () => { + node = new PannerNode(context); + node.coneOuterGain = options.coneOuterGain; + }, + `node.coneOuterGain = ${options.coneOuterGain}`) + .throw(DOMException, 'InvalidStateError'); + should(node.coneOuterGain, + `node.coneOuterGain after setting to ${options.coneOuterGain}`) + .beEqualTo(0); + + options = {coneOuterGain: 1.1}; + should( + () => { + node = new PannerNode(context, options); + }, + 'new PannerNode(c, ' + JSON.stringify(options) + ')') + .throw(DOMException, 'InvalidStateError'); + should( + () => { + node = new PannerNode(context); + node.coneOuterGain = options.coneOuterGain; + }, + `node.coneOuterGain = ${options.coneOuterGain}`) + .throw(DOMException, 'InvalidStateError'); + should(node.coneOuterGain, + `node.coneOuterGain after setting to ${options.coneOuterGain}`) + .beEqualTo(0); + + options = {coneOuterGain: 0.0}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node9 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.coneOuterGain, 'node9.coneOuterGain') + .beEqualTo(options.coneOuterGain); + options = {coneOuterGain: 0.5}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node9 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.coneOuterGain, 'node9.coneOuterGain') + .beEqualTo(options.coneOuterGain); + + options = {coneOuterGain: 1.0}; + should( + () => { + node = new PannerNode(context, options); + }, + 'node9 = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node.coneOuterGain, 'node9.coneOuterGain') + .beEqualTo(options.coneOuterGain); + + task.done(); + }); + + audit.define('constructor with options', (task, should) => { + let node; + let success = true; + let options = { + panningModel: 'HRTF', + // We use full double float values here to verify also that the actual + // AudioParam value is properly rounded to a float. The actual value + // is immaterial as long as x != Math.fround(x). + positionX: Math.SQRT2, + positionY: 2 * Math.SQRT2, + positionZ: 3 * Math.SQRT2, + orientationX: -Math.SQRT2, + orientationY: -2 * Math.SQRT2, + orientationZ: -3 * Math.SQRT2, + distanceModel: 'linear', + // We use full double float values here to verify also that the actual + // attribute is a double float. The actual value is immaterial as + // long as x != Math.fround(x). + refDistance: Math.PI, + maxDistance: 2 * Math.PI, + rolloffFactor: 3 * Math.PI, + coneInnerAngle: 4 * Math.PI, + coneOuterAngle: 5 * Math.PI, + coneOuterGain: 0.1 * Math.PI + }; + + should( + () => { + node = new PannerNode(context, options); + }, + 'node = new PannerNode(c, ' + JSON.stringify(options) + ')') + .notThrow(); + should(node instanceof PannerNode, 'node instanceof PannerNode') + .beEqualTo(true); + + should(node.panningModel, 'node.panningModel') + .beEqualTo(options.panningModel); + should(node.positionX.value, 'node.positionX.value') + .beEqualTo(Math.fround(options.positionX)); + should(node.positionY.value, 'node.positionY.value') + .beEqualTo(Math.fround(options.positionY)); + should(node.positionZ.value, 'node.positionZ.value') + .beEqualTo(Math.fround(options.positionZ)); + should(node.orientationX.value, 'node.orientationX.value') + .beEqualTo(Math.fround(options.orientationX)); + should(node.orientationY.value, 'node.orientationY.value') + .beEqualTo(Math.fround(options.orientationY)); + should(node.orientationZ.value, 'node.orientationZ.value') + .beEqualTo(Math.fround(options.orientationZ)); + should(node.distanceModel, 'node.distanceModel') + .beEqualTo(options.distanceModel); + should(node.refDistance, 'node.refDistance') + .beEqualTo(options.refDistance); + should(node.maxDistance, 'node.maxDistance') + .beEqualTo(options.maxDistance); + should(node.rolloffFactor, 'node.rolloffFactor') + .beEqualTo(options.rolloffFactor); + should(node.coneInnerAngle, 'node.coneInnerAngle') + .beEqualTo(options.coneInnerAngle); + should(node.coneOuterAngle, 'node.coneOuterAngle') + .beEqualTo(options.coneOuterAngle); + should(node.coneOuterGain, 'node.coneOuterGain') + .beEqualTo(options.coneOuterGain); + + should(node.channelCount, 'node.channelCount').beEqualTo(2); + should(node.channelCountMode, 'node.channelCountMode') + .beEqualTo('clamped-max'); + should(node.channelInterpretation, 'node.channelInterpretation') + .beEqualTo('speakers'); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-exponential.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-exponential.html new file mode 100644 index 0000000000..383e2c67b6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-exponential.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> + <head> + <title> + distance-exponential.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/distance-model-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'Exponential distance model for PannerNode' + }, + (task, should) => { + // Create offline audio context. + context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + createTestAndRun(context, 'exponential', should) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-inverse.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-inverse.html new file mode 100644 index 0000000000..a4ff984e09 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-inverse.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html> + <head> + <title> + distance-inverse.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/distance-model-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('test', (task, should) => { + // Create offline audio context. + context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + createTestAndRun(context, 'inverse', should).then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-linear.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-linear.html new file mode 100644 index 0000000000..812fea3eba --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/distance-linear.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> + <head> + <title> + distance-linear.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/distance-model-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + {label: 'test', description: 'Linear distance model PannerNode'}, + (task, should) => { + // Create offline audio context. + context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + createTestAndRun(context, 'linear', should).then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-basic.html new file mode 100644 index 0000000000..5c3df0e6fd --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-basic.html @@ -0,0 +1,298 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Basic PannerNode with Automation Position Properties + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/panner-formulas.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + + // These tests are quite slow, so don't run for many frames. 256 frames + // should be enough to demonstrate that automations are working. + let renderFrames = 256; + let renderDuration = renderFrames / sampleRate; + + let audit = Audit.createTaskRunner(); + + // Array of tests for setting the panner positions. These tests basically + // verify that the position setters for the panner and listener are + // working correctly. + let testConfig = [ + { + setter: 'positionX', + }, + { + setter: 'positionY', + }, + { + setter: 'positionZ', + } + ]; + + // Create tests for the panner position setters. Both mono and steroe + // sources are tested. + for (let k = 0; k < testConfig.length; ++k) { + let config = testConfig[k]; + // Function to create the test to define the test. + let tester = function(config, channelCount) { + return (task, should) => { + let nodes = createGraph(channelCount); + let {context, source, panner} = nodes; + + let message = channelCount == 1 ? 'Mono' : 'Stereo'; + message += ' panner.' + config.setter; + + testPositionSetter(should, { + nodes: nodes, + pannerSetter: panner[config.setter], + message: message + }).then(() => task.done()); + } + }; + + audit.define('Stereo panner.' + config.setter, tester(config, 2)); + audit.define('Mono panner.' + config.setter, tester(config, 1)); + } + + // Create tests for the listener position setters. Both mono and steroe + // sources are tested. + for (let k = 0; k < testConfig.length; ++k) { + let config = testConfig[k]; + // Function to create the test to define the test. + let tester = function(config, channelCount) { + return (task, should) => { + let nodes = createGraph(channelCount); + let {context, source, panner} = nodes; + + let message = channelCount == 1 ? 'Mono' : 'Stereo'; + message += ' listener.' + config.setter; + + // Some relatively arbitrary (non-default) position for the source + // location. + panner.setPosition(1, 0, 1); + + testPositionSetter(should, { + nodes: nodes, + pannerSetter: context.listener[config.setter], + message: message + }).then(() => task.done()); + } + }; + + audit.define('Stereo listener.' + config.setter, tester(config, 2)); + audit.define('Mono listener.' + config.setter, tester(config, 1)); + } + + // Test setPosition method. + audit.define('setPosition', (task, should) => { + let {context, panner, source} = createGraph(2); + + // Initialize source position (values don't really matter). + panner.setPosition(1, 1, 1); + + // After some (unimportant) time, move the panner to a (any) new + // location. + let suspendFrame = 128; + context.suspend(suspendFrame / sampleRate) + .then(function() { + panner.setPosition(-100, 2000, 8000); + }) + .then(context.resume.bind(context)); + + context.startRendering() + .then(function(resultBuffer) { + verifyPannerOutputChanged( + should, resultBuffer, + {message: 'setPosition', suspendFrame: suspendFrame}); + }) + .then(() => task.done()); + }); + + audit.define('orientation setter', (task, should) => { + let {context, panner, source} = createGraph(2); + + // For orientation to matter, we need to make the source directional, + // and also move away from the listener (because the default location is + // 0,0,0). + panner.setPosition(0, 0, 1); + panner.coneInnerAngle = 0; + panner.coneOuterAngle = 360; + panner.coneOuterGain = .001; + + // After some (unimportant) time, change the panner orientation to a new + // orientation. The only constraint is that the orientation changes + // from before. + let suspendFrame = 128; + context.suspend(suspendFrame / sampleRate) + .then(function() { + panner.orientationX.value = -100; + panner.orientationY.value = 2000; + panner.orientationZ.value = 8000; + }) + .then(context.resume.bind(context)); + + context.startRendering() + .then(function(resultBuffer) { + verifyPannerOutputChanged(should, resultBuffer, { + message: 'panner.orientation{XYZ}', + suspendFrame: suspendFrame + }); + }) + .then(() => task.done()); + }); + + audit.define('forward setter', (task, should) => { + let {context, panner, source} = createGraph(2); + + // For orientation to matter, we need to make the source directional, + // and also move away from the listener (because the default location is + // 0,0,0). + panner.setPosition(0, 0, 1); + panner.coneInnerAngle = 0; + panner.coneOuterAngle = 360; + panner.coneOuterGain = .001; + + // After some (unimportant) time, change the panner orientation to a new + // orientation. The only constraint is that the orientation changes + // from before. + let suspendFrame = 128; + context.suspend(suspendFrame / sampleRate) + .then(function() { + context.listener.forwardX.value = -100; + context.listener.forwardY.value = 2000; + context.listener.forwardZ.value = 8000; + }) + .then(context.resume.bind(context)); + + context.startRendering() + .then(function(resultBuffer) { + verifyPannerOutputChanged(should, resultBuffer, { + message: 'listener.forward{XYZ}', + suspendFrame: suspendFrame + }); + }) + .then(() => task.done()); + }); + + audit.define('up setter', (task, should) => { + let {context, panner, source} = createGraph(2); + + // For orientation to matter, we need to make the source directional, + // and also move away from the listener (because the default location is + // 0,0,0). + panner.setPosition(0, 0, 1); + panner.coneInnerAngle = 0; + panner.coneOuterAngle = 360; + panner.coneOuterGain = .001; + panner.setPosition(1, 0, 1); + + // After some (unimportant) time, change the panner orientation to a new + // orientation. The only constraint is that the orientation changes + // from before. + let suspendFrame = 128; + context.suspend(suspendFrame / sampleRate) + .then(function() { + context.listener.upX.value = 100; + context.listener.upY.value = 100; + context.listener.upZ.value = 100; + ; + }) + .then(context.resume.bind(context)); + + context.startRendering() + .then(function(resultBuffer) { + verifyPannerOutputChanged( + should, resultBuffer, + {message: 'listener.up{XYZ}', suspendFrame: suspendFrame}); + }) + .then(() => task.done()); + }); + + audit.run(); + + function createGraph(channelCount) { + let context = new OfflineAudioContext(2, renderFrames, sampleRate); + let panner = context.createPanner(); + let source = context.createBufferSource(); + source.buffer = + createConstantBuffer(context, 1, channelCount == 1 ? 1 : [1, 2]); + source.loop = true; + + source.connect(panner); + panner.connect(context.destination); + + source.start(); + return {context: context, source: source, panner: panner}; + } + + function testPositionSetter(should, options) { + let {nodes, pannerSetter, message} = options; + + let {context, source, panner} = nodes; + + // Set panner x position. (Value doesn't matter); + pannerSetter.value = 1; + + // Wait a bit and set a new position. (Actual time and position doesn't + // matter). + let suspendFrame = 128; + context.suspend(suspendFrame / sampleRate) + .then(function() { + pannerSetter.value = 10000; + }) + .then(context.resume.bind(context)); + + return context.startRendering().then(function(resultBuffer) { + verifyPannerOutputChanged( + should, resultBuffer, + {message: message, suspendFrame: suspendFrame}); + }); + } + + function verifyPannerOutputChanged(should, resultBuffer, options) { + let {message, suspendFrame} = options; + // Verify that the first part of output is constant. (Doesn't matter + // what.) + let data0 = resultBuffer.getChannelData(0); + let data1 = resultBuffer.getChannelData(1); + + let middle = '[0, ' + suspendFrame + ') '; + should( + data0.slice(0, suspendFrame), + message + '.value frame ' + middle + 'channel 0') + .beConstantValueOf(data0[0]); + should( + data1.slice(0, suspendFrame), + message + '.value frame ' + middle + 'channel 1') + .beConstantValueOf(data1[0]); + + // The rest after suspendTime should be constant and different from the + // first part. + middle = '[' + suspendFrame + ', ' + renderFrames + ') '; + should( + data0.slice(suspendFrame), + message + '.value frame ' + middle + 'channel 0') + .beConstantValueOf(data0[suspendFrame]); + should( + data1.slice(suspendFrame), + message + '.value frame ' + middle + 'channel 1') + .beConstantValueOf(data1[suspendFrame]); + should( + data0[suspendFrame], + message + ': Output at frame ' + suspendFrame + ' channel 0') + .notBeEqualTo(data0[0]); + should( + data1[suspendFrame], + message + ': Output at frame ' + suspendFrame + ' channel 1') + .notBeEqualTo(data1[0]); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-equalpower-stereo.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-equalpower-stereo.html new file mode 100644 index 0000000000..7afc9c2a39 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-equalpower-stereo.html @@ -0,0 +1,47 @@ +<!DOCTYPE html> +<html> + <head> + <title> + panner-automation-equalpower-stereo.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/panner-model-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // To test the panner, we create a number of panner nodes + // equally spaced on a semicircle at unit distance. The + // semicircle covers the azimuth range from -90 to 90 deg, + // covering full left to full right. Each source is an impulse + // turning at a different time and we check that the rendered + // impulse has the expected gain. + audit.define( + { + label: 'test', + description: + 'Equal-power panner model of AudioPannerNode with stereo source', + }, + (task, should) => { + // Create offline audio context. + context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + createTestAndRun( + context, should, nodesToCreate, 2, + function(panner, x, y, z) { + panner.positionX.value = x; + panner.positionY.value = y; + panner.positionZ.value = z; + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-position.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-position.html new file mode 100644 index 0000000000..8e09e869ac --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-automation-position.html @@ -0,0 +1,265 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Automation of PannerNode Positions + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/panner-formulas.js"></script> + </head> + <body> + <script id="layout-test-code"> + let sampleRate = 48000; + // These tests are quite slow, so don't run for many frames. 256 frames + // should be enough to demonstrate that automations are working. + let renderFrames = 256; + let renderDuration = renderFrames / sampleRate; + + let context; + let panner; + + let audit = Audit.createTaskRunner(); + + // Set of tests for the panner node with automations applied to the + // position of the source. + let testConfigs = [ + { + // Distance model parameters for the panner + distanceModel: {model: 'inverse', rolloff: 1}, + // Initial location of the source + startPosition: [0, 0, 1], + // Final position of the source. For this test, we only want to move + // on the z axis which + // doesn't change the azimuth angle. + endPosition: [0, 0, 10000], + }, + { + distanceModel: {model: 'inverse', rolloff: 1}, + startPosition: [0, 0, 1], + // An essentially random end position, but it should be such that + // azimuth angle changes as + // we move from the start to the end. + endPosition: [20000, 30000, 10000], + errorThreshold: [ + { + // Error threshold for 1-channel case + relativeThreshold: 4.8124e-7 + }, + { + // Error threshold for 2-channel case + relativeThreshold: 4.3267e-7 + } + ], + }, + { + distanceModel: {model: 'exponential', rolloff: 1.5}, + startPosition: [0, 0, 1], + endPosition: [20000, 30000, 10000], + errorThreshold: + [{relativeThreshold: 5.0783e-7}, {relativeThreshold: 5.2180e-7}] + }, + { + distanceModel: {model: 'linear', rolloff: 1}, + startPosition: [0, 0, 1], + endPosition: [20000, 30000, 10000], + errorThreshold: [ + {relativeThreshold: 6.5324e-6}, {relativeThreshold: 6.5756e-6} + ] + } + ]; + + for (let k = 0; k < testConfigs.length; ++k) { + let config = testConfigs[k]; + let tester = function(c, channelCount) { + return (task, should) => { + runTest(should, c, channelCount).then(() => task.done()); + } + }; + + let baseTestName = config.distanceModel.model + + ' rolloff: ' + config.distanceModel.rolloff; + + // Define tasks for both 1-channel and 2-channel + audit.define(k + ': 1-channel ' + baseTestName, tester(config, 1)); + audit.define(k + ': 2-channel ' + baseTestName, tester(config, 2)); + } + + audit.run(); + + function runTest(should, options, channelCount) { + // Output has 5 channels: channels 0 and 1 are for the stereo output of + // the panner node. Channels 2-5 are the for automation of the x,y,z + // coordinate so that we have actual coordinates used for the panner + // automation. + context = new OfflineAudioContext(5, renderFrames, sampleRate); + + // Stereo source for the panner. + let source = context.createBufferSource(); + source.buffer = createConstantBuffer( + context, renderFrames, channelCount == 1 ? 1 : [1, 2]); + + panner = context.createPanner(); + panner.distanceModel = options.distanceModel.model; + panner.rolloffFactor = options.distanceModel.rolloff; + panner.panningModel = 'equalpower'; + + // Source and gain node for the z-coordinate calculation. + let dist = context.createBufferSource(); + dist.buffer = createConstantBuffer(context, 1, 1); + dist.loop = true; + let gainX = context.createGain(); + let gainY = context.createGain(); + let gainZ = context.createGain(); + dist.connect(gainX); + dist.connect(gainY); + dist.connect(gainZ); + + // Set the gain automation to match the z-coordinate automation of the + // panner. + + // End the automation some time before the end of the rendering so we + // can verify that automation has the correct end time and value. + let endAutomationTime = 0.75 * renderDuration; + + gainX.gain.setValueAtTime(options.startPosition[0], 0); + gainX.gain.linearRampToValueAtTime( + options.endPosition[0], endAutomationTime); + gainY.gain.setValueAtTime(options.startPosition[1], 0); + gainY.gain.linearRampToValueAtTime( + options.endPosition[1], endAutomationTime); + gainZ.gain.setValueAtTime(options.startPosition[2], 0); + gainZ.gain.linearRampToValueAtTime( + options.endPosition[2], endAutomationTime); + + dist.start(); + + // Splitter and merger to map the panner output and the z-coordinate + // automation to the correct channels in the destination. + let splitter = context.createChannelSplitter(2); + let merger = context.createChannelMerger(5); + + source.connect(panner); + // Split the output of the panner to separate channels + panner.connect(splitter); + + // Merge the panner outputs and the z-coordinate output to the correct + // destination channels. + splitter.connect(merger, 0, 0); + splitter.connect(merger, 1, 1); + gainX.connect(merger, 0, 2); + gainY.connect(merger, 0, 3); + gainZ.connect(merger, 0, 4); + + merger.connect(context.destination); + + // Initialize starting point of the panner. + panner.positionX.setValueAtTime(options.startPosition[0], 0); + panner.positionY.setValueAtTime(options.startPosition[1], 0); + panner.positionZ.setValueAtTime(options.startPosition[2], 0); + + // Automate z coordinate to move away from the listener + panner.positionX.linearRampToValueAtTime( + options.endPosition[0], 0.75 * renderDuration); + panner.positionY.linearRampToValueAtTime( + options.endPosition[1], 0.75 * renderDuration); + panner.positionZ.linearRampToValueAtTime( + options.endPosition[2], 0.75 * renderDuration); + + source.start(); + + // Go! + return context.startRendering().then(function(renderedBuffer) { + // Get the panner outputs + let data0 = renderedBuffer.getChannelData(0); + let data1 = renderedBuffer.getChannelData(1); + let xcoord = renderedBuffer.getChannelData(2); + let ycoord = renderedBuffer.getChannelData(3); + let zcoord = renderedBuffer.getChannelData(4); + + // We're doing a linear ramp on the Z axis with the equalpower panner, + // so the equalpower panning gain remains constant. We only need to + // model the distance effect. + + // Compute the distance gain + let distanceGain = new Float32Array(xcoord.length); + ; + + if (panner.distanceModel === 'inverse') { + for (let k = 0; k < distanceGain.length; ++k) { + distanceGain[k] = + inverseDistance(panner, xcoord[k], ycoord[k], zcoord[k]) + } + } else if (panner.distanceModel === 'linear') { + for (let k = 0; k < distanceGain.length; ++k) { + distanceGain[k] = + linearDistance(panner, xcoord[k], ycoord[k], zcoord[k]) + } + } else if (panner.distanceModel === 'exponential') { + for (let k = 0; k < distanceGain.length; ++k) { + distanceGain[k] = + exponentialDistance(panner, xcoord[k], ycoord[k], zcoord[k]) + } + } + + // Compute the expected result. Since we're on the z-axis, the left + // and right channels pass through the equalpower panner unchanged. + // Only need to apply the distance gain. + let buffer0 = source.buffer.getChannelData(0); + let buffer1 = + channelCount == 2 ? source.buffer.getChannelData(1) : buffer0; + + let azimuth = new Float32Array(buffer0.length); + + for (let k = 0; k < data0.length; ++k) { + azimuth[k] = calculateAzimuth( + [xcoord[k], ycoord[k], zcoord[k]], + [ + context.listener.positionX.value, + context.listener.positionY.value, + context.listener.positionZ.value + ], + [ + context.listener.forwardX.value, + context.listener.forwardY.value, + context.listener.forwardZ.value + ], + [ + context.listener.upX.value, context.listener.upY.value, + context.listener.upZ.value + ]); + } + + let expected = applyPanner(azimuth, buffer0, buffer1, channelCount); + let expected0 = expected.left; + let expected1 = expected.right; + + for (let k = 0; k < expected0.length; ++k) { + expected0[k] *= distanceGain[k]; + expected1[k] *= distanceGain[k]; + } + + let info = options.distanceModel.model + + ', rolloff: ' + options.distanceModel.rolloff; + let prefix = channelCount + '-channel ' + + '[' + options.startPosition[0] + ', ' + options.startPosition[1] + + ', ' + options.startPosition[2] + '] -> [' + + options.endPosition[0] + ', ' + options.endPosition[1] + ', ' + + options.endPosition[2] + ']: '; + + let errorThreshold = 0; + + if (options.errorThreshold) + errorThreshold = options.errorThreshold[channelCount - 1] + + should(data0, prefix + 'distanceModel: ' + info + ', left channel') + .beCloseToArray(expected0, {absoluteThreshold: errorThreshold}); + should(data1, prefix + 'distanceModel: ' + info + ', right channel') + .beCloseToArray(expected1, {absoluteThreshold: errorThreshold}); + }); + } + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-azimuth.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-azimuth.html new file mode 100644 index 0000000000..d09f2ec352 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-azimuth.html @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> + <head> + <title>Test Panner Azimuth Calculation</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit.js"></script> + </head> + + <body> + <script> + const audit = Audit.createTaskRunner(); + + // Fairly arbitrary sample rate + const sampleRate = 16000; + + audit.define('Azimuth calculation', (task, should) => { + // Two channels for the context so we can see each channel of the + // panner node. + let context = new OfflineAudioContext(2, sampleRate, sampleRate); + + let src = new ConstantSourceNode(context); + let panner = new PannerNode(context); + + src.connect(panner).connect(context.destination); + + // The source is still pointed directly at the listener, but is now + // directly above. The audio should be the same in both the left and + // right channels. + panner.positionY.value = 1; + + src.start(); + + context.startRendering() + .then(audioBuffer => { + // The left and right channels should contain the same signal. + let c0 = audioBuffer.getChannelData(0); + let c1 = audioBuffer.getChannelData(1); + + let expected = Math.fround(Math.SQRT1_2); + + should(c0, 'Left channel').beConstantValueOf(expected); + should(c1, 'Righteft channel').beConstantValueOf(expected); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-distance-clamping.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-distance-clamping.html new file mode 100644 index 0000000000..78c1ec6dc2 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-distance-clamping.html @@ -0,0 +1,227 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Clamping of Distance for PannerNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Arbitrary sample rate and render length. + let sampleRate = 48000; + let renderFrames = 128; + + let audit = Audit.createTaskRunner(); + + audit.define('ref-distance-error', (task, should) => { + testDistanceLimits(should, {name: 'refDistance', isZeroAllowed: true}); + task.done(); + }); + + audit.define('max-distance-error', (task, should) => { + testDistanceLimits(should, {name: 'maxDistance', isZeroAllowed: false}); + task.done(); + }); + + function testDistanceLimits(should, options) { + // Verify that exceptions are thrown for invalid values of refDistance. + let context = new OfflineAudioContext(1, renderFrames, sampleRate); + + let attrName = options.name; + let prefix = 'new PannerNode(c, {' + attrName + ': '; + + should(function() { + let nodeOptions = {}; + nodeOptions[attrName] = -1; + new PannerNode(context, nodeOptions); + }, prefix + '-1})').throw(RangeError); + + if (options.isZeroAllowed) { + should(function() { + let nodeOptions = {}; + nodeOptions[attrName] = 0; + new PannerNode(context, nodeOptions); + }, prefix + '0})').notThrow(); + } else { + should(function() { + let nodeOptions = {}; + nodeOptions[attrName] = 0; + new PannerNode(context, nodeOptions); + }, prefix + '0})').throw(RangeError); + } + + // The smallest representable positive single float. + let leastPositiveDoubleFloat = 4.9406564584124654e-324; + + should(function() { + let nodeOptions = {}; + nodeOptions[attrName] = leastPositiveDoubleFloat; + new PannerNode(context, nodeOptions); + }, prefix + leastPositiveDoubleFloat + '})').notThrow(); + + prefix = 'panner.' + attrName + ' = '; + panner = new PannerNode(context); + should(function() { + panner[attrName] = -1; + }, prefix + '-1').throw(RangeError); + + if (options.isZeroAllowed) { + should(function() { + panner[attrName] = 0; + }, prefix + '0').notThrow(); + } else { + should(function() { + panner[attrName] = 0; + }, prefix + '0').throw(RangeError); + } + + should(function() { + panner[attrName] = leastPositiveDoubleFloat; + }, prefix + leastPositiveDoubleFloat).notThrow(); + } + + audit.define('min-distance', async (task, should) => { + // Test clamping of panner distance to refDistance for all of the + // distance models. The actual distance is arbitrary as long as it's + // less than refDistance. We test default and non-default values for + // the panner's refDistance and maxDistance. + // correctly. + await runTest(should, { + distance: 0.01, + distanceModel: 'linear', + }); + await runTest(should, { + distance: 0.01, + distanceModel: 'exponential', + }); + await runTest(should, { + distance: 0.01, + distanceModel: 'inverse', + }); + await runTest(should, { + distance: 2, + distanceModel: 'linear', + maxDistance: 1000, + refDistance: 10, + }); + await runTest(should, { + distance: 2, + distanceModel: 'exponential', + maxDistance: 1000, + refDistance: 10, + }); + await runTest(should, { + distance: 2, + distanceModel: 'inverse', + maxDistance: 1000, + refDistance: 10, + }); + task.done(); + }); + + audit.define('max-distance', async (task, should) => { + // Like the "min-distance" task, but for clamping to the max + // distance. The actual distance is again arbitrary as long as it is + // greater than maxDistance. + await runTest(should, { + distance: 20000, + distanceModel: 'linear', + }); + await runTest(should, { + distance: 21000, + distanceModel: 'exponential', + }); + await runTest(should, { + distance: 23000, + distanceModel: 'inverse', + }); + await runTest(should, { + distance: 5000, + distanceModel: 'linear', + maxDistance: 1000, + refDistance: 10, + }); + await runTest(should, { + distance: 5000, + distanceModel: 'exponential', + maxDistance: 1000, + refDistance: 10, + }); + await runTest(should, { + distance: 5000, + distanceModel: 'inverse', + maxDistance: 1000, + refDistance: 10, + }); + task.done(); + }); + + function runTest(should, options) { + let context = new OfflineAudioContext(2, renderFrames, sampleRate); + let src = new OscillatorNode(context, { + type: 'sawtooth', + frequency: 20 * 440, + }); + + // Set panner options. Use a non-default rolloffFactor so that the + // various distance models look distinctly different. + let pannerOptions = {}; + Object.assign(pannerOptions, options, {rolloffFactor: 0.5}); + + let pannerRef = new PannerNode(context, pannerOptions); + let pannerTest = new PannerNode(context, pannerOptions); + + // Split the panner output so we can grab just one of the output + // channels. + let splitRef = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + let splitTest = new ChannelSplitterNode(context, {numberOfOutputs: 2}); + + // Merge the panner outputs back into one stereo stream for the + // destination. + let merger = new ChannelMergerNode(context, {numberOfInputs: 2}); + + src.connect(pannerTest).connect(splitTest).connect(merger, 0, 0); + src.connect(pannerRef).connect(splitRef).connect(merger, 0, 1); + + merger.connect(context.destination); + + // Move the panner some distance away. Arbitrarily select the x + // direction. For the reference panner, manually clamp the distance. + // All models clamp the distance to a minimum of refDistance. Only the + // linear model also clamps to a maximum of maxDistance. + let xRef = Math.max(options.distance, pannerRef.refDistance); + + if (pannerRef.distanceModel === 'linear') { + xRef = Math.min(xRef, pannerRef.maxDistance); + } + + let xTest = options.distance; + + pannerRef.positionZ.setValueAtTime(xRef, 0); + pannerTest.positionZ.setValueAtTime(xTest, 0); + + src.start(); + + return context.startRendering().then(function(resultBuffer) { + let actual = resultBuffer.getChannelData(0); + let expected = resultBuffer.getChannelData(1); + + should( + xTest < pannerRef.refDistance || xTest > pannerRef.maxDistance, + 'Model: ' + options.distanceModel + ': Distance (' + xTest + + ') is outside the range [' + pannerRef.refDistance + ', ' + + pannerRef.maxDistance + ']') + .beEqualTo(true); + should(actual, 'Test panner output ' + JSON.stringify(options)) + .beEqualToArray(expected); + }); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-equalpower-stereo.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-equalpower-stereo.html new file mode 100644 index 0000000000..2a0225b3f6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-equalpower-stereo.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <title> + panner-equalpower-stereo.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/panner-model-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // To test the panner, we create a number of panner nodes + // equally spaced on a semicircle at unit distance. The + // semicircle covers the azimuth range from -90 to 90 deg, + // covering full left to full right. Each source is an impulse + // turning at a different time and we check that the rendered + // impulse has the expected gain. + audit.define( + { + label: 'test', + description: + 'Equal-power panner model of AudioPannerNode with stereo source' + }, + (task, should) => { + context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + createTestAndRun( + context, should, nodesToCreate, 2, + function(panner, x, y, z) { + panner.setPosition(x, y, z); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-equalpower.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-equalpower.html new file mode 100644 index 0000000000..3ff21b651f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-equalpower.html @@ -0,0 +1,139 @@ +<!DOCTYPE html> +<html> + <head> + <title> + panner-equalpower.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/panner-model-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + // To test the panner, we create a number of panner nodes + // equally spaced on a semicircle at unit distance. The + // semicircle covers the azimuth range from -90 to 90 deg, + // covering full left to full right. Each source is an impulse + // turning at a different time and we check that the rendered + // impulse has the expected gain. + audit.define( + { + label: 'test', + description: 'Equal-power panner model of AudioPannerNode', + }, + (task, should) => { + // Create offline audio context. + context = new OfflineAudioContext( + 2, sampleRate * renderLengthSeconds, sampleRate); + + createTestAndRun( + context, should, nodesToCreate, 1, + function(panner, x, y, z) { + panner.setPosition(x, y, z); + }) + .then(() => task.done()); + ; + }); + + // Test that a mono source plays out on both the left and right channels + // when the source and listener positions are the same. + audit.define( + { + label: 'mono source=listener', + description: 'Source and listener at the same position' + }, + (task, should) => { + // Must be stereo to verify output and only need a short duration + let context = + new OfflineAudioContext(2, 0.25 * sampleRate, sampleRate); + + // Arbitrary position for source and listener. Just so we don't use + // defaults positions. + let x = 1; + let y = 2; + let z = 3; + + context.listener.setPosition(x, y, z); + + let src = new OscillatorNode(context); + let panner = new PannerNode(context, { + panningModel: 'equalpower', + positionX: x, + positionY: y, + positionZ: z + }); + + src.connect(panner).connect(context.destination); + + src.start(); + + context.startRendering() + .then(renderedBuffer => { + // Verify that both channels have the same data because they + // should when the source and listener are at the same + // position + let c0 = renderedBuffer.getChannelData(0); + let c1 = renderedBuffer.getChannelData(1); + should(c0, 'Mono: Left and right channels').beEqualToArray(c1); + }) + .then(() => task.done()); + }); + + // Test that a stereo source plays out on both the left and right channels + // when the source and listener positions are the same. + audit.define( + { + label: 'stereo source=listener', + description: 'Source and listener at the same position' + }, + (task, should) => { + // Must be stereo to verify output and only need a short duration. + let context = + new OfflineAudioContext(2, 0.25 * sampleRate, sampleRate); + + // Arbitrary position for source and listener. Just so we don't use + // defaults positions. + let x = 1; + let y = 2; + let z = 3; + + context.listener.setPosition(x, y, z); + + let src = new OscillatorNode(context); + let merger = new ChannelMergerNode(context, {numberOfInputs: 2}); + let panner = new PannerNode(context, { + panningModel: 'equalpower', + positionX: x, + positionY: y, + positionZ: z + }); + + // Make the oscillator a stereo signal (with identical signals on + // each channel). + src.connect(merger, 0, 0); + src.connect(merger, 0, 1); + + merger.connect(panner).connect(context.destination); + + src.start(); + + context.startRendering() + .then(renderedBuffer => { + // Verify that both channels have the same data because they + // should when the source and listener are at the same + // position. + let c0 = renderedBuffer.getChannelData(0); + let c1 = renderedBuffer.getChannelData(1); + should(c0, 'Stereo: Left and right channels').beEqualToArray(c1); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-rolloff-clamping.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-rolloff-clamping.html new file mode 100644 index 0000000000..387f873010 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/panner-rolloff-clamping.html @@ -0,0 +1,98 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Clamping of PannerNode rolloffFactor + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Fairly arbitrary sample rate and render frames. + let sampleRate = 16000; + let renderFrames = 2048; + + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'linear-clamp-high', + description: 'rolloffFactor clamping for linear distance model' + }, + (task, should) => { + runTest(should, { + distanceModel: 'linear', + // Fairly arbitrary value outside the nominal range + rolloffFactor: 2, + clampedRolloff: 1 + }).then(() => task.done()); + }); + + // Test clamping of the rolloffFactor. The test is done by comparing the + // output of a panner with the rolloffFactor set outside the nominal range + // against the output of a panner with the rolloffFactor clamped to the + // nominal range. The outputs should be the same. + // + // The |options| dictionary should contain the members + // distanceModel - The distance model to use for the panners + // rolloffFactor - The desired rolloffFactor. Should be outside the + // nominal range of the distance model. + // clampedRolloff - The rolloffFactor (above) clamped to the nominal + // range for the given distance model. + function runTest(should, options) { + // Offline context with two channels. The first channel is the panner + // node under test. The second channel is the reference panner node. + let context = new OfflineAudioContext(2, renderFrames, sampleRate); + + // The source for the panner nodes. This is fairly arbitrary. + let src = new OscillatorNode(context, {type: 'sawtooth'}); + + // Create the test panner with the specified rolloff factor. The + // position is fairly arbitrary, but something that is not the default + // is good to show the distance model had some effect. + let pannerTest = new PannerNode(context, { + rolloffFactor: options.rolloffFactor, + distanceModel: options.distanceModel, + positionX: 5000 + }); + + // Create the reference panner with the rolloff factor clamped to the + // appropriate limit. + let pannerRef = new PannerNode(context, { + rolloffFactor: options.clampedRolloff, + distanceModel: options.distanceModel, + positionX: 5000 + }); + + + // Connect the source to the panners to the destination appropriately. + let merger = new ChannelMergerNode(context, {numberOfInputs: 2}); + + + src.connect(pannerTest).connect(merger, 0, 0); + src.connect(pannerRef).connect(merger, 0, 1); + + merger.connect(context.destination); + + src.start(); + + return context.startRendering().then(function(resultBuffer) { + // The two channels should be the same due to the clamping. Verify + // that they are the same. + let actual = resultBuffer.getChannelData(0); + let expected = resultBuffer.getChannelData(1); + + let message = 'Panner distanceModel: "' + options.distanceModel + + '", rolloffFactor: ' + options.rolloffFactor; + + should(actual, message).beEqualToArray(expected); + }); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/pannernode-basic.window.js b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/pannernode-basic.window.js new file mode 100644 index 0000000000..298fce0f20 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/pannernode-basic.window.js @@ -0,0 +1,71 @@ +test((t) => { + const context = new AudioContext(); + const source = new ConstantSourceNode(context); + const panner = new PannerNode(context); + source.connect(panner).connect(context.destination); + + // Basic parameters + assert_equals(panner.numberOfInputs,1); + assert_equals(panner.numberOfOutputs,1); + assert_equals(panner.refDistance, 1); + panner.refDistance = 270.5; + assert_equals(panner.refDistance, 270.5); + assert_equals(panner.maxDistance, 10000); + panner.maxDistance = 100.5; + assert_equals(panner.maxDistance, 100.5); + assert_equals(panner.rolloffFactor, 1); + panner.rolloffFactor = 0.75; + assert_equals(panner.rolloffFactor, 0.75); + assert_equals(panner.coneInnerAngle, 360); + panner.coneInnerAngle = 240.5; + assert_equals(panner.coneInnerAngle, 240.5); + assert_equals(panner.coneOuterAngle, 360); + panner.coneOuterAngle = 166.5; + assert_equals(panner.coneOuterAngle, 166.5); + assert_equals(panner.coneOuterGain, 0); + panner.coneOuterGain = 0.25; + assert_equals(panner.coneOuterGain, 0.25); + assert_equals(panner.panningModel, 'equalpower'); + assert_equals(panner.distanceModel, 'inverse'); + + // Position/orientation AudioParams + assert_equals(panner.positionX.value, 0); + assert_equals(panner.positionY.value, 0); + assert_equals(panner.positionZ.value, 0); + assert_equals(panner.orientationX.value, 1); + assert_equals(panner.orientationY.value, 0); + assert_equals(panner.orientationZ.value, 0); + + // AudioListener + assert_equals(context.listener.positionX.value, 0); + assert_equals(context.listener.positionY.value, 0); + assert_equals(context.listener.positionZ.value, 0); + assert_equals(context.listener.forwardX.value, 0); + assert_equals(context.listener.forwardY.value, 0); + assert_equals(context.listener.forwardZ.value, -1); + assert_equals(context.listener.upX.value, 0); + assert_equals(context.listener.upY.value, 1); + assert_equals(context.listener.upZ.value, 0); + + panner.panningModel = 'equalpower'; + assert_equals(panner.panningModel, 'equalpower'); + panner.panningModel = 'HRTF'; + assert_equals(panner.panningModel, 'HRTF'); + panner.panningModel = 'invalid'; + assert_equals(panner.panningModel, 'HRTF'); + + // Check that numerical values are no longer supported. We shouldn't + // throw and the value shouldn't be changed. + panner.panningModel = 1; + assert_equals(panner.panningModel, 'HRTF'); + + panner.distanceModel = 'linear'; + assert_equals(panner.distanceModel, 'linear'); + panner.distanceModel = 'inverse'; + assert_equals(panner.distanceModel, 'inverse'); + panner.distanceModel = 'exponential'; + assert_equals(panner.distanceModel, 'exponential'); + + panner.distanceModel = 'invalid'; + assert_equals(panner.distanceModel, 'exponential'); +}, 'Test the PannerNode interface'); diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/pannernode-setposition-throws.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/pannernode-setposition-throws.html new file mode 100644 index 0000000000..2053411943 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/pannernode-setposition-throws.html @@ -0,0 +1,37 @@ +<!doctype html> +<meta charset=utf-8> +<title>Test PannerNode.setPosition() throws with parameter out of range of float</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +// https://webaudio.github.io/web-audio-api/#dom-pannernode-setposition +// setPosition(x, y, z) "is equivalent to setting positionX.value, +// positionY.value, and positionZ.value directly with the given x, y, and z +// values, respectively." setPosition() parameters are double, but the +// AudioParam value setter has a float parameter, so out of range values +// throw. +const FLT_MAX = 3.40282e+38; +let panner; +setup(() => { + const ctx = new OfflineAudioContext({length: 1, sampleRate: 24000}); + panner = ctx.createPanner(); +}); +test(() => { + assert_throws_js(TypeError, () => panner.setPosition(2 * FLT_MAX, 0, 0)); +}, "setPosition x"); +test(() => { + assert_throws_js(TypeError, () => panner.setPosition(0, -2 * FLT_MAX, 0)); +}, "setPosition y"); +test(() => { + assert_throws_js(TypeError, () => panner.setPosition(0, 0, 2 * FLT_MAX)); +}, "setPosition z"); +test(() => { + assert_throws_js(TypeError, () => panner.setOrientation(-2 * FLT_MAX, 0, 0)); +}, "setOrientation x"); +test(() => { + assert_throws_js(TypeError, () => panner.setOrientation(0, 2 * FLT_MAX, 0)); +}, "setOrientation y"); +test(() => { + assert_throws_js(TypeError, () => panner.setOrientation(0, 0, -2 * FLT_MAX)); +}, "setOrientation z"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/test-pannernode-automation.html b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/test-pannernode-automation.html new file mode 100644 index 0000000000..ce474b10b5 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-pannernode-interface/test-pannernode-automation.html @@ -0,0 +1,36 @@ +<!doctype html> +<meta charset=utf-8> +<title></title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> + +// This value is purposefuly not aligned on a 128-block boundary so that we test +// that the PannerNode position audioparam is a-rate. +const POSITION_CHANGE_FRAME = 1111; + +promise_test(function(t) { + var ac = new OfflineAudioContext(2, 2048, 44100); + var panner = ac.createPanner(); + panner.positionX.value = -1; + panner.positionY.value = -1; + panner.positionZ.value = 1; + panner.positionX.setValueAtTime(1, POSITION_CHANGE_FRAME/ac.sampleRate); + var osc = ac.createOscillator(); + osc.connect(panner); + panner.connect(ac.destination); + osc.start() + return ac.startRendering().then(function(buffer) { + var left = buffer.getChannelData(0); + var right = buffer.getChannelData(1); + for (var i = 0; i < 2048; ++i) { + if (i < POSITION_CHANGE_FRAME) { + assert_true(Math.abs(left[i]) >= Math.abs(right[i]), "index " + i + " should be on the left"); + } else { + assert_true(Math.abs(left[i]) < Math.abs(right[i]), "index " + i + " should be on the right"); + } + } + }); +}, "PannerNode AudioParam automation works properly"); + +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-periodicwave-interface/createPeriodicWaveInfiniteValuesThrows.html b/testing/web-platform/tests/webaudio/the-audio-api/the-periodicwave-interface/createPeriodicWaveInfiniteValuesThrows.html new file mode 100644 index 0000000000..928f45bd8f --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-periodicwave-interface/createPeriodicWaveInfiniteValuesThrows.html @@ -0,0 +1,22 @@ +<!doctype html> +<meta charset=utf-8> +<title>Test AudioContext.createPeriodicWave when inputs contain Infinite values</title> +<script src=/resources/testharness.js></script> +<script src=/resources/testharnessreport.js></script> +<script> +let ctx; +setup(() => { + ctx = new OfflineAudioContext({length: 1, sampleRate: 24000}); +}); +test(() => { + const real = new Float32Array([0, Infinity]); + const imag = new Float32Array([0, 1]); + assert_throws_js(TypeError, () => ctx.createPeriodicWave(real, imag)); +}, "createPeriodicWave with Infinity real values should throw"); + +test(() => { + const real = new Float32Array([0, 1]); + const imag = new Float32Array([1, Infinity]); + assert_throws_js(TypeError, () => ctx.createPeriodicWave(real, imag)); +}, "createPeriodicWave with Infinity imag values should throw"); +</script> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-periodicwave-interface/periodicWave.html b/testing/web-platform/tests/webaudio/the-audio-api/the-periodicwave-interface/periodicWave.html new file mode 100644 index 0000000000..fe42f8ad50 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-periodicwave-interface/periodicWave.html @@ -0,0 +1,130 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: PeriodicWave + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // real and imag are used in separate PeriodicWaves to make their peak values + // easy to determine. + const realMax = 99; + var real = new Float32Array(realMax + 1); + real[1] = 2.0; // fundamental + real[realMax] = 3.0; + const realPeak = real[1] + real[realMax]; + const realFundamental = 19.0; + var imag = new Float32Array(4); + imag[0] = 6.0; // should be ignored. + imag[3] = 0.5; + const imagPeak = imag[3]; + const imagFundamental = 551.0; + + const testLength = 8192; + let context = new AudioContext(); + + let audit = Audit.createTaskRunner(); + + // Create with the factory method + + audit.define('create with factory method', (task, should) => { + should(() => { + context.createPeriodicWave(new Float32Array(testLength), new Float32Array(testLength)); + }, 'context.createPeriodicWave(new Float32Array(' + testLength + '), ' + + 'new Float32Array(' + testLength + '))').notThrow(); + task.done(); + }); + + audit.define('different length with factory method', (task, should) => { + should(() => { + context.createPeriodicWave(new Float32Array(512), new Float32Array(4)); + }, 'context.createPeriodicWave(new Float32Array(512), ' + + 'new Float32Array(4))').throw(DOMException, "IndexSizeError"); + task.done(); + }); + + audit.define('too small with factory method', (task, should) => { + should(() => { + context.createPeriodicWave(new Float32Array(1), new Float32Array(1)); + }, 'context.createPeriodicWave(new Float32Array(1), ' + + 'new Float32Array(1))').throw(DOMException, "IndexSizeError"); + task.done(); + }); + + // Create with the constructor + + audit.define('create with constructor', (task, should) => { + should(() => { + new PeriodicWave(context, { real: new Float32Array(testLength), imag: new Float32Array(testLength) }); + }, 'new PeriodicWave(context, { real : new Float32Array(' + testLength + '), ' + + 'imag : new Float32Array(' + testLength + ') })').notThrow(); + task.done(); + }); + + audit.define('different length with constructor', (task, should) => { + should(() => { + new PeriodicWave(context, { real: new Float32Array(testLength), imag: new Float32Array(4) }); + }, 'new PeriodicWave(context, { real : new Float32Array(' + testLength + '), ' + + 'imag : new Float32Array(4) })').throw(DOMException, "IndexSizeError"); + task.done(); + }); + + audit.define('too small with constructor', (task, should) => { + should(() => { + new PeriodicWave(context, { real: new Float32Array(1), imag: new Float32Array(1) }); + }, 'new PeriodicWave(context, { real : new Float32Array(1), ' + + 'imag : new Float32Array(1) })').throw(DOMException, "IndexSizeError"); + task.done(); + }); + + audit.define('output test', (task, should) => { + let context = new OfflineAudioContext(2, testLength, 44100); + // Create the expected output buffer + let expectations = context.createBuffer(2, testLength, context.sampleRate); + for (var i = 0; i < expectations.length; ++i) { + + expectations.getChannelData(0)[i] = 1.0 / realPeak * + (real[1] * Math.cos(2 * Math.PI * realFundamental * i / + context.sampleRate) + + real[realMax] * Math.cos(2 * Math.PI * realMax * realFundamental * i / + context.sampleRate)); + + expectations.getChannelData(1)[i] = 1.0 / imagPeak * + imag[3] * Math.sin(2 * Math.PI * 3 * imagFundamental * i / + context.sampleRate); + } + + // Create the real output buffer + let merger = context.createChannelMerger(); + + let osc1 = context.createOscillator(); + let osc2 = context.createOscillator(); + + osc1.setPeriodicWave(context.createPeriodicWave( + real, new Float32Array(real.length))); + osc2.setPeriodicWave(context.createPeriodicWave( + new Float32Array(imag.length), imag)); + osc1.frequency.value = realFundamental; + osc2.frequency.value = imagFundamental; + + osc1.start(); + osc2.start(); + + osc1.connect(merger, 0, 0); + osc2.connect(merger, 0, 1); + + context.startRendering().then(reality => { + should(reality, 'rendering PeriodicWave').beEqualToArray(expectations); + task.done(); + }); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-scriptprocessornode-interface/simple-input-output.html b/testing/web-platform/tests/webaudio/the-audio-api/the-scriptprocessornode-interface/simple-input-output.html new file mode 100644 index 0000000000..7fd20e67a7 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-scriptprocessornode-interface/simple-input-output.html @@ -0,0 +1,98 @@ +<!doctype html> +<html> + <head> + <title>Test ScriptProcessorNode</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + + <body> + <script> + // Arbitrary sample rate + const sampleRate = 48000; + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: 'ScriptProcessor with stopped input source' + }, + (task, should) => { + // Two channels for testing. Channel 0 is the output of the + // scriptProcessor. Channel 1 is the oscillator so we can compare + // the outputs. + let context = new OfflineAudioContext({ + numberOfChannels: 2, + length: sampleRate, + sampleRate: sampleRate + }); + + let merger = new ChannelMergerNode( + context, {numberOfChannels: context.destination.channelCount}); + merger.connect(context.destination); + + let src = new OscillatorNode(context); + + // Arbitrary buffer size for the ScriptProcessorNode. Don't use 0; + // we need to know the actual size to know the latency of the node + // (easily). + const spnSize = 512; + let spn = context.createScriptProcessor(spnSize, 1, 1); + + // Arrange for the ScriptProcessor to add |offset| to the input. + const offset = 1; + spn.onaudioprocess = (event) => { + let input = event.inputBuffer.getChannelData(0); + let output = event.outputBuffer.getChannelData(0); + for (let k = 0; k < output.length; ++k) { + output[k] = input[k] + offset; + } + }; + + src.connect(spn).connect(merger, 0, 0); + src.connect(merger, 0, 1); + + // Start and stop the source. The stop time is fairly arbitrary, + // but use a render quantum boundary for simplicity. + const stopFrame = RENDER_QUANTUM_FRAMES; + src.start(0); + src.stop(stopFrame / context.sampleRate); + + context.startRendering() + .then(buffer => { + let ch0 = buffer.getChannelData(0); + let ch1 = buffer.getChannelData(1); + + let shifted = ch1.slice(0, stopFrame).map(x => x + offset); + + // SPN has a basic latency of 2*|spnSize| fraems, so the + // beginning is silent. + should( + ch0.slice(0, 2 * spnSize - 1), + `ScriptProcessor output[0:${2 * spnSize - 1}]`) + .beConstantValueOf(0); + + // For the middle section (after adding latency), the output + // should be the source shifted by |offset|. + should( + ch0.slice(2 * spnSize, 2 * spnSize + stopFrame), + `ScriptProcessor output[${2 * spnSize}:${ + 2 * spnSize + stopFrame - 1}]`) + .beCloseToArray(shifted, {absoluteThreshold: 0}); + + // Output should be constant after the source has stopped. + // Include the latency introduced by the node. + should( + ch0.slice(2 * spnSize + stopFrame), + `ScriptProcessor output[${2 * spnSize + stopFrame}:]`) + .beConstantValueOf(offset); + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/ctor-stereopanner.html b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/ctor-stereopanner.html new file mode 100644 index 0000000000..9409f1ffce --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/ctor-stereopanner.html @@ -0,0 +1,131 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: StereoPanner + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('invalid constructor', (task, should) => { + testInvalidConstructor(should, 'StereoPannerNode', context); + task.done(); + }); + + audit.define('default constructor', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'StereoPannerNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'clamped-max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [{name: 'pan', value: 0}]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + // Can't use testAudioNodeOptions because the constraints for this node + // are not supported there. + let node; + + // An array of tests. + [{ + // Test that we can set the channel count to 1 or 2 and that other + // channel counts throw an error. + attribute: 'channelCount', + tests: [ + {value: 1}, {value: 2}, {value: 0, error: 'NotSupportedError'}, + {value: 3, error: 'NotSupportedError'}, + {value: 99, error: 'NotSupportedError'} + ] + }, + { + // Test channelCountMode. A mode of "max" is illegal, but others are + // ok. But also throw an error of unknown values. + attribute: 'channelCountMode', + tests: [ + {value: 'clamped-max'}, {value: 'explicit'}, + {value: 'max', error: 'NotSupportedError'}, + {value: 'foobar', error: TypeError} + ] + }, + { + // Test channelInterpretation can be set for valid values and an + // error is thrown for others. + attribute: 'channelInterpretation', + tests: [ + {value: 'speakers'}, {value: 'discrete'}, + {value: 'foobar', error: TypeError} + ] + }].forEach(entry => { + entry.tests.forEach(testItem => { + let options = {}; + options[entry.attribute] = testItem.value; + + const testFunction = () => { + node = new StereoPannerNode(context, options); + }; + const testDescription = + `new StereoPannerNode(c, ${JSON.stringify(options)})`; + + if (testItem.error) { + testItem.error === TypeError + ? should(testFunction, testDescription).throw(TypeError) + : should(testFunction, testDescription) + .throw(DOMException, 'NotSupportedError'); + } else { + should(testFunction, testDescription).notThrow(); + should(node[entry.attribute], `node.${entry.attribute}`) + .beEqualTo(options[entry.attribute]); + } + }); + }); + + task.done(); + }); + + audit.define('constructor with options', (task, should) => { + let node; + let options = { + pan: 0.75, + }; + + should( + () => { + node = new StereoPannerNode(context, options); + }, + 'node1 = new StereoPannerNode(, ' + JSON.stringify(options) + ')') + .notThrow(); + should( + node instanceof StereoPannerNode, + 'node1 instanceof StereoPannerNode') + .beEqualTo(true); + + should(node.pan.value, 'node1.pan.value').beEqualTo(options.pan); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/no-dezippering.html b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/no-dezippering.html new file mode 100644 index 0000000000..355db8b9dc --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/no-dezippering.html @@ -0,0 +1,261 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test StereoPannerNode Has No Dezippering + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Arbitrary sample rate except that it should be a power of two to + // eliminate any round-off in computing frame boundaries. + let sampleRate = 16384; + + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test mono input', + description: 'Test StereoPanner with mono input has no dezippering' + }, + (task, should) => { + let context = new OfflineAudioContext(2, sampleRate, sampleRate); + let src = new ConstantSourceNode(context, {offset: 1}); + let p = new StereoPannerNode(context, {pan: -1}); + + src.connect(p).connect(context.destination); + src.start(); + + // Frame at which to change pan value. + let panFrame = 256; + context.suspend(panFrame / context.sampleRate) + .then(() => p.pan.value = 1) + .then(() => context.resume()); + + context.startRendering() + .then(renderedBuffer => { + let c0 = renderedBuffer.getChannelData(0); + let c1 = renderedBuffer.getChannelData(1); + + // The first part should be full left. + should( + c0.slice(0, panFrame), 'Mono: Left channel, pan = -1: ') + .beConstantValueOf(1); + should( + c1.slice(0, panFrame), 'Mono: Right channel, pan = -1:') + .beConstantValueOf(0); + + // The second part should be full right, but due to roundoff, + // the left channel won't be exactly zero. Compare the left + // channel against zero with a threshold instead. + let tail = c0.slice(panFrame); + let zero = new Float32Array(tail.length); + + should(c0.slice(panFrame), 'Mono: Left channel, pan = 1: ') + .beCloseToArray(zero, {absoluteThreshold: 6.1233e-17}); + should(c1.slice(panFrame), 'Mono: Right channel, pan = 1:') + .beConstantValueOf(1); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'test stereo input', + description: + 'Test StereoPanner with stereo input has no dezippering' + }, + (task, should) => { + let context = new OfflineAudioContext(2, sampleRate, sampleRate); + + // Create stereo source from two constant source nodes. + let s0 = new ConstantSourceNode(context, {offset: 1}); + let s1 = new ConstantSourceNode(context, {offset: 2}); + let merger = new ChannelMergerNode(context, {numberOfInputs: 2}); + + s0.connect(merger, 0, 0); + s1.connect(merger, 0, 1); + + let p = new StereoPannerNode(context, {pan: -1}); + + merger.connect(p).connect(context.destination); + s0.start(); + s1.start(); + + // Frame at which to change pan value. + let panFrame = 256; + context.suspend(panFrame / context.sampleRate) + .then(() => p.pan.value = 1) + .then(() => context.resume()); + + context.startRendering() + .then(renderedBuffer => { + let c0 = renderedBuffer.getChannelData(0); + let c1 = renderedBuffer.getChannelData(1); + + // The first part should be full left. + should( + c0.slice(0, panFrame), 'Stereo: Left channel, pan = -1: ') + .beConstantValueOf(3); + should( + c1.slice(0, panFrame), 'Stereo: Right channel, pan = -1:') + .beConstantValueOf(0); + + // The second part should be full right, but due to roundoff, + // the left channel won't be exactly zero. Compare the left + // channel against zero with a threshold instead. + let tail = c0.slice(panFrame); + let zero = new Float32Array(tail.length); + + should(c0.slice(panFrame), 'Stereo: Left channel, pan = 1: ') + .beCloseToArray(zero, {absoluteThreshold: 6.1233e-17}); + should(c1.slice(panFrame), 'Stereo: Right channel, pan = 1:') + .beConstantValueOf(3); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'test mono input setValue', + description: 'Test StereoPanner with mono input value setter ' + + 'vs setValueAtTime' + }, + (task, should) => { + let context = new OfflineAudioContext(4, sampleRate, sampleRate); + + let src = new OscillatorNode(context); + + src.start(); + testWithSetValue(context, src, should, { + prefix: 'Mono' + }).then(() => task.done()); + }); + + audit.define( + { + label: 'test stereo input setValue', + description: 'Test StereoPanner with mono input value setter ' + + ' vs setValueAtTime' + }, + (task, should) => { + let context = new OfflineAudioContext(4, sampleRate, sampleRate); + + let src0 = new OscillatorNode(context, {frequency: 800}); + let src1 = new OscillatorNode(context, {frequency: 250}); + let merger = new ChannelMergerNode(context, {numberOfChannels: 2}); + + src0.connect(merger, 0, 0); + src1.connect(merger, 0, 1); + + src0.start(); + src1.start(); + + testWithSetValue(context, merger, should, { + prefix: 'Stereo' + }).then(() => task.done()); + }); + + audit.define( + { + label: 'test mono input automation', + description: 'Test StereoPanner with mono input and automation' + }, + (task, should) => { + let context = new OfflineAudioContext(4, sampleRate, sampleRate); + + let src0 = new OscillatorNode(context, {frequency: 800}); + let src1 = new OscillatorNode(context, {frequency: 250}); + let merger = new ChannelMergerNode(context, {numberOfChannels: 2}); + + src0.connect(merger, 0, 0); + src1.connect(merger, 0, 1); + + src0.start(); + src1.start(); + + let mod = new OscillatorNode(context, {frequency: 100}); + mod.start(); + + testWithSetValue(context, merger, should, { + prefix: 'Modulated Stereo', + modulator: (testNode, refNode) => { + mod.connect(testNode.pan); + mod.connect(refNode.pan); + } + }).then(() => task.done()); + }); + + + function testWithSetValue(context, src, should, options) { + let merger = new ChannelMergerNode( + context, {numberOfInputs: context.destination.channelCount}); + merger.connect(context.destination); + + let pannerRef = new StereoPannerNode(context, {pan: -0.3}); + let pannerTest = + new StereoPannerNode(context, {pan: pannerRef.pan.value}); + + let refSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + let testSplitter = + new ChannelSplitterNode(context, {numberOfOutputs: 2}); + + pannerRef.connect(refSplitter); + pannerTest.connect(testSplitter); + + testSplitter.connect(merger, 0, 0); + testSplitter.connect(merger, 1, 1); + refSplitter.connect(merger, 0, 2); + refSplitter.connect(merger, 1, 3); + + src.connect(pannerRef); + src.connect(pannerTest); + + let changeTime = 3 * RENDER_QUANTUM_FRAMES / context.sampleRate; + // An arbitrary position, different from the default pan value. + let newPanPosition = .71; + + pannerRef.pan.setValueAtTime(newPanPosition, changeTime); + context.suspend(changeTime) + .then(() => pannerTest.pan.value = newPanPosition) + .then(() => context.resume()); + + if (options.modulator) { + options.modulator(pannerTest, pannerRef); + } + return context.startRendering().then(renderedBuffer => { + let actual = new Array(2); + let expected = new Array(2); + + actual[0] = renderedBuffer.getChannelData(0); + actual[1] = renderedBuffer.getChannelData(1); + expected[0] = renderedBuffer.getChannelData(2); + expected[1] = renderedBuffer.getChannelData(3); + + let label = ['Left', 'Right']; + + for (let k = 0; k < 2; ++k) { + let match = + should( + actual[k], + options.prefix + ' ' + label[k] + ' .value setter output') + .beCloseToArray(expected[k], {absoluteThreshold: 1.192094e-7}); + should( + match, + options.prefix + ' ' + label[k] + + ' .value setter output matches setValueAtTime output') + .beTrue(); + } + + }); + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/stereopannernode-basic.html b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/stereopannernode-basic.html new file mode 100644 index 0000000000..48bacb08c6 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/stereopannernode-basic.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html> + <head> + <title> + stereopannernode-basic.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test', + description: + 'Attributes and basic functionality of StereoPannerNode' + }, + (task, should) => { + + let context = new AudioContext(); + let panner = context.createStereoPanner(); + + should(panner.numberOfInputs, 'panner.numberOfInputs').beEqualTo(1); + should(panner.numberOfOutputs, 'panner.numberOfOutputs') + .beEqualTo(1); + should(panner.pan.defaultValue, 'panner.pan.defaultValue') + .beEqualTo(0.0); + should(() => panner.pan.value = 1.0, 'panner.pan.value = 1.0') + .notThrow(); + should(panner.pan.value, 'panner.pan.value').beEqualTo(1.0); + + should(() => panner.channelCount = 1, 'panner.channelCount = 1') + .notThrow(); + should(() => panner.channelCount = 3, 'panner.channelCount = 3') + .throw(); + should( + () => panner.channelCountMode = 'explicit', + 'panner.channelCountMode = "explicit"') + .notThrow(); + should( + () => panner.channelCountMode = 'max', + 'panner.channelCountMode = "max"') + .throw(); + + task.done(); + }); + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/stereopannernode-panning.html b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/stereopannernode-panning.html new file mode 100644 index 0000000000..f683fd78bf --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-stereopanner-interface/stereopannernode-panning.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> + <head> + <title> + stereopannernode-panning.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/stereopanner-testing.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('mono-test', (task, should) => { + StereoPannerTest + .create(should, {numberOfInputChannels: 1, prefix: 'Mono: '}) + .run() + .then(() => task.done()); + }); + + audit.define('stereo-test', (task, should) => { + StereoPannerTest + .create(should, {numberOfInputChannels: 2, prefix: 'Stereo: '}) + .run() + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/ctor-waveshaper.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/ctor-waveshaper.html new file mode 100644 index 0000000000..7aa33ca5aa --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/ctor-waveshaper.html @@ -0,0 +1,72 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test Constructor: WaveShaper + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + <script src="/webaudio/resources/audionodeoptions.js"></script> + </head> + <body> + <script id="layout-test-code"> + let context; + + let audit = Audit.createTaskRunner(); + + audit.define('initialize', (task, should) => { + context = initializeContext(should); + task.done(); + }); + + audit.define('incorrect construction', (task, should) => { + testInvalidConstructor(should, 'WaveShaperNode', context); + task.done(); + }); + + audit.define('valid default construction', (task, should) => { + let prefix = 'node0'; + let node = testDefaultConstructor(should, 'WaveShaperNode', context, { + prefix: prefix, + numberOfInputs: 1, + numberOfOutputs: 1, + channelCount: 2, + channelCountMode: 'max', + channelInterpretation: 'speakers' + }); + + testDefaultAttributes(should, node, prefix, [ + {name: 'curve', value: null}, {name: 'oversample', value: 'none'} + ]); + + task.done(); + }); + + audit.define('test AudioNodeOptions', (task, should) => { + testAudioNodeOptions(should, context, 'WaveShaperNode'); + task.done(); + }); + + audit.define('valid non-default', (task, should) => { + // Construct an WaveShaperNode with options + let options = {curve: Float32Array.from([1, 2, 3]), oversample: '4x'}; + let node; + + let message = + 'node1 = new WaveShaperNode(, ' + JSON.stringify(options) + ')'; + should(() => { + node = new WaveShaperNode(context, options); + }, message).notThrow(); + should(node.curve, 'node1.curve').beEqualToArray(options.curve); + should(node.oversample, 'node1.oversample') + .beEqualTo(options.oversample); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/curve-tests.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/curve-tests.html new file mode 100644 index 0000000000..d09cf78fd8 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/curve-tests.html @@ -0,0 +1,184 @@ +<!doctype html> +<html> +<head> + <title>WaveShaperNode interface - Curve tests | WebAudio</title> + + <script type="text/javascript" src="/resources/testharness.js"></script> + <script type="text/javascript" src="/resources/testharnessreport.js"></script> +</head> +<body> + <div id="log"> + </div> + + <script type="text/javascript"> + var sampleRate=44100.0; + var tolerance=0.01; + + /* + Testing that -1, 0 and +1 map correctly to curve (with 1:1 correlation) + ======================================================================= + From the specification: + The input signal is nominally within the range -1 -> +1. + Each input sample within this range will index into the shaping curve with a signal level of zero corresponding + to the center value of the curve array. + */ + (function() { + var threeElementCurve=[2.0, -3.0, 4.0]; + var inputData=[-1.0, 0, 1.0]; + var expectedData=[2.0, -3.0, 4.0]; + executeTest(threeElementCurve, inputData, expectedData, "Testing that -1, 0 and +1 map correctly to curve (with 1:1 correlation)"); + })(); + + /* + Testing interpolation (where inputs don't correlate directly to curve elements) + =============================================================================== + From the specification: + The implementation must perform linear interpolation between adjacent points in the curve. + */ + (function() { + var threeElementCurve=[2.0, -3.0, 4.0]; + var inputData=[-0.5, +0.5, +0.75]; + var expectedData=[-0.5, +0.5, +2.25]; + executeTest(threeElementCurve, inputData, expectedData, "Testing interpolation (where inputs don't correlate directly to curve elements)"); + })(); + + /* + Testing out-of-range inputs (should be mapped to the first/last elements of the curve) + ====================================================================================== + From the specification: + Any sample value less than -1 will correspond to the first value in the curve array. + Any sample value greater than +1 will correspond to the last value in the curve array. + */ + (function() { + var threeElementCurve=[2.0, -3.0, 4.0]; + var inputData=[-1.5, +1.5]; + var expectedData=[2.0, 4.0]; + executeTest(threeElementCurve, inputData, expectedData, "Testing out-of-range inputs (should be mapped to the first/last elements of the curve)"); + })(); + + /* + Testing a 2-element curve (does not have a middle element) + ========================================================== + From the specification: + Each input sample within this range will index into the shaping curve with a signal level of zero corresponding + to the center value of the curve array. + The implementation must perform linear interpolation between adjacent points in the curve. + */ + (function() { + var twoElementCurve=[2.0, -2.0]; + var inputData=[-1.0, 0, 1.0]; + var expectedData=[2.0, 0.0, -2.0]; + executeTest(twoElementCurve, inputData, expectedData, "Testing a 2-element curve (does not have a middle element)"); + })(); + + /* + Testing a 4-element curve (does not have a middle element) + ========================================================== + From the specification: + Each input sample within this range will index into the shaping curve with a signal level of zero corresponding + to the center value of the curve array. + The implementation must perform linear interpolation between adjacent points in the curve. + */ + (function() { + var fourElementCurve=[1.0, 2.0, 4.0, 7.0]; + var inputData=[-1.0, 0, 1.0]; + var expectedData=[1.0, 3.0, 7.0]; + executeTest(fourElementCurve, inputData, expectedData, "Testing a 4-element curve (does not have a middle element)"); + })(); + + /* + Testing a huge curve + ==================== + From the specification: + Each input sample within this range will index into the shaping curve with a signal level of zero corresponding + to the center value of the curve array. + */ + (function() { + var bigCurve=[]; + for(var i=0;i<=60000;i++) { bigCurve.push(i/3.5435); } + var inputData=[-1.0, 0, 1.0]; + var expectedData=[bigCurve[0], bigCurve[30000], bigCurve[60000]]; + executeTest(bigCurve, inputData, expectedData, "Testing a huge curve"); + })(); + + /* + Testing single-element curve (boundary condition) + ================================================= + From the specification: + Each input sample within this range will index into the shaping curve with a signal level of zero corresponding + to the center value of the curve array. + Any sample value less than -1 will correspond to the first value in the curve array. + Any sample value greater than +1 will correspond to the last value in the curve array. + The implementation must perform linear interpolation between adjacent points in the curve. + */ + + /* + Testing null curve (should return input values) + =============================================== + From the specification: + Initially the curve attribute is null, which means that the WaveShaperNode will pass its input to its output + without modification. + */ + (function() { + var inputData=[-1.0, 0, 1.0, 2.0]; + var expectedData=[-1.0, 0.0, 1.0, 2.0]; + executeTest(null, inputData, expectedData, "Testing null curve (should return input values)"); + })(); + + /** + * Function that does the actual testing (using an asynchronous test). + * @param {?Array.<number>} curveData - Array containing values for the WaveShaper curve. + * @param {!Array.<number>} inputData - Array containing values for the input stream. + * @param {!Array.<number>} expectedData - Array containing expected results for each of the corresponding inputs. + * @param {!string} testName - Name of the test case. + */ + function executeTest(curveData, inputData, expectedData, testName) { + var stTest=async_test("WaveShaperNode - "+testName); + stTest.step(function() { + + // Create offline audio context. + var ac=new OfflineAudioContext(1, inputData.length, sampleRate); + + // Create the WaveShaper and its curve. + var waveShaper=ac.createWaveShaper(); + if(curveData!=null) { + var curve=new Float32Array(curveData.length); + for(var i=0;i<curveData.length;i++) { curve[i]=curveData[i]; } + waveShaper.curve=curve; + } + waveShaper.connect(ac.destination); + + // Create buffer containing the input values. + var inputBuffer=ac.createBuffer(1, Math.max(inputData.length, 2), sampleRate); + var d=inputBuffer.getChannelData(0); + for(var i=0;i<inputData.length;i++) { d[i]=inputData[i]; } + + // Play the input buffer through the WaveShaper. + var src=ac.createBufferSource(); + src.buffer=inputBuffer; + src.connect(waveShaper); + src.start(); + + // Test the outputs match the expected values. + ac.oncomplete=stTest.step_func_done(function(ev) { + var d=ev.renderedBuffer.getChannelData(0); + + for(var i=0;i<expectedData.length;i++) { + var curveText="null"; + if(curve!=null) { + if(curveData.length<20) { + curveText=curveData.join(","); + } else { + curveText="TooBigToDisplay ("+(curveData.length-1)+" elements)"; + } + } + var comment="Input="+inputData[i]+", Curve=["+curveText+"] >>> "; + assert_approx_equals(d[i], expectedData[i], tolerance, comment); + } + }); + ac.startRendering(); + }); + } + </script> +</body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/silent-inputs.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/silent-inputs.html new file mode 100644 index 0000000000..45d2c9ad4b --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/silent-inputs.html @@ -0,0 +1,103 @@ +<!doctype html> +<html> + <head> + <title> + Test Silent Inputs to WaveShaperNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/webaudio/resources/audit-util.js"></script> + <script src="/webaudio/resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + let sampleRate = 16000; + + // Identity curve for the wave shaper: the input value is mapped directly + // to the output value. + let identityCurve = [-1, 0, 1]; + let nonZeroCurve = [0.5, 0.5, 0.5]; + + audit.define( + { + label: 'test-0', + description: 'curve output is non-zero for silent inputs' + }, + (task, should) => { + let {context, source, shaper} = + setupGraph(nonZeroCurve, sampleRate, sampleRate); + + source.offset.setValueAtTime(0, 0); + + context.startRendering() + .then(audioBuffer => { + should( + audioBuffer.getChannelData(0), + 'WaveShaper with silent inputs and curve ' + + JSON.stringify(shaper.curve)) + .beConstantValueOf(0.5); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'test-1', + description: '2x curve output is non-zero for silent inputs' + }, + (task, should) => { + let {context, source, shaper} = + setupGraph(nonZeroCurve, sampleRate, sampleRate); + + source.offset.setValueAtTime(0, 0); + shaper.overSample = '2x'; + + context.startRendering() + .then(audioBuffer => { + should( + audioBuffer.getChannelData(0), + 'WaveShaper with ' + shaper.overSample + + ' oversample, silent inputs, and curve ' + + JSON.stringify(shaper.curve)) + .beConstantValueOf(0.5); + }) + .then(() => task.done()); + }); + + audit.define( + { + label: 'test-2', + description: 'curve output is non-zero for no inputs' + }, + (task, should) => { + let {context, source, shaper} = + setupGraph(nonZeroCurve, sampleRate, sampleRate); + + source.disconnect(); + + context.startRendering() + .then(audioBuffer => { + should( + audioBuffer.getChannelData(0), + 'WaveShaper with no inputs and curve ' + + JSON.stringify(shaper.curve)) + .beConstantValueOf(0.5); + }) + .then(() => task.done()); + }); + + function setupGraph(curve, testFrames, sampleRate) { + let context = new OfflineAudioContext(1, testFrames, sampleRate); + let source = new ConstantSourceNode(context); + let shaper = new WaveShaperNode(context, {curve: curve}); + + source.connect(shaper).connect(context.destination); + + return {context: context, source: source, shaper: shaper}; + } + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-copy-curve.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-copy-curve.html new file mode 100644 index 0000000000..e897ac08a1 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-copy-curve.html @@ -0,0 +1,100 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Test WaveShaper Copies Curve Data + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + // Sample rate and number of frames are fairly arbitrary. We need to + // render, however, at least 384 frames. 1024 is a nice small value. + let sampleRate = 16000; + let renderFrames = 1024; + + let audit = Audit.createTaskRunner(); + + audit.define( + { + label: 'test copying', + description: 'Modifying curve should not modify WaveShaper' + }, + (task, should) => { + // Two-channel context; channel 0 contains the test data and channel + // 1 contains the expected result. Channel 1 has the normal + // WaveShaper output and channel 0 has the WaveShaper output with a + // modified curve. + let context = new OfflineAudioContext(2, renderFrames, sampleRate); + + // Just use a default oscillator as the source. Doesn't really + // matter what we use. + let src = context.createOscillator(); + src.type = 'sawtooth'; + + // Create the wave shapers: ws0 is the test shaper, and ws1 is the + // reference wave shaper. + let ws0 = context.createWaveShaper(); + let ws1 = context.createWaveShaper(); + + // Wave shaper curves. Doesn't really matter what we use as long as + // it modifies the input in some way. Thus, keep it simple and just + // invert the input. + let desiredCurve = [1, 0, -1]; + let curve0 = Float32Array.from(desiredCurve); + let curve1 = Float32Array.from(desiredCurve); + + ws0.curve = curve0; + ws1.curve = curve1; + + let merger = context.createChannelMerger(2); + + // Connect the graph + src.connect(ws0); + src.connect(ws1); + + ws0.connect(merger, 0, 0); + ws1.connect(merger, 0, 1); + + merger.connect(context.destination); + + // Let the context run for a bit and then modify the curve for ws0. + // Doesn't really matter what we modify the curve to as long as it's + // different. + context.suspend(256 / context.sampleRate) + .then(() => { + should( + () => { + curve0[0] = -0.5; + curve0[1] = 0.125; + curve0[2] = 0.75; + }, + `Modifying curve array at time ${context.currentTime}`) + .notThrow(); + }) + .then(context.resume.bind(context)); + + src.start(); + + context.startRendering() + .then(function(renderedBuffer) { + let actual = renderedBuffer.getChannelData(0); + let expected = renderedBuffer.getChannelData(1); + + // Modifying the wave shaper curve should not modify the + // output so the outputs from the two wave shaper nodes should + // be exactly identical. + should(actual, 'Output of WaveShaper with modified curve') + .beEqualToArray(expected); + + }) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-limits.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-limits.html new file mode 100644 index 0000000000..13e88be567 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-limits.html @@ -0,0 +1,110 @@ +<!DOCTYPE html> +<html> + <head> + <title> + waveshaper-limits.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let context; + let bufferData; + let outputData; + let reference; + + let sampleRate = 48000; + // Must be odd so we have an exact middle point. + let testFrames = 23; + let scale = 1 / ((testFrames - 1) / 2 - 1); + // Number of decimal digits to print + let decimals = 6; + // Required accuracy + let diffThreshold = Math.pow(10, -decimals); + + // Generate reference data + function generateReference() { + // The curve data is 0, 1, 0, and the input data is a ramp from -1+eps + // to 1+eps. Then the output is a ramp from 0 to 1 back to 0. + let ref = new Float32Array(testFrames); + let midPoint = (testFrames - 1) / 2; + // First sample is below -1 at -1-scale. + ref[0] = 0; + // Generate ramp up to the mid-point + for (let k = 0; k < midPoint; ++k) { + ref[k + 1] = k * scale; + } + // The value at the mid-point must be 1, from the curve + ref[midPoint] = 1; + // Generate a ramp from 1 down to 0 + for (let k = midPoint; k < testFrames - 1; ++k) { + ref[k + 1] = 2 - k * scale; + } + // The last sample is out of range at 1+scale + ref[testFrames - 1] = 0; + return ref; + } + + function checkResult(renderedBuffer, should) { + outputData = renderedBuffer.getChannelData(0); + reference = generateReference(); + let success = true; + // Verify that every output value matches our expected reference value. + for (let k = 0; k < outputData.length; ++k) { + let diff = outputData[k] - reference[k]; + should( + Math.abs(diff), + 'Max error mapping ' + bufferData[k].toFixed(decimals) + ' to ' + + outputData[k].toFixed(decimals)) + .beLessThanOrEqualTo(diffThreshold); + } + } + + audit.define( + { + label: 'test', + description: + 'WaveShaperNode including values outside the range of [-1,1]' + }, + function(task, should) { + context = new OfflineAudioContext(1, testFrames, sampleRate); + // Create input values between -1.1 and 1.1 + let buffer = + context.createBuffer(1, testFrames, context.sampleRate); + bufferData = new Float32Array(testFrames); + let start = -1 - scale; + for (let k = 0; k < testFrames; ++k) { + bufferData[k] = k * scale + start; + } + buffer.copyToChannel(bufferData, 0); + + let source = context.createBufferSource(); + source.buffer = buffer; + + // Create simple waveshaper. It should map -1 to 0, 0 to 1, and +1 + // to 0 and interpolate all points in between using a simple linear + // interpolator. + let shaper = context.createWaveShaper(); + let curve = new Float32Array(3); + curve[0] = 0; + curve[1] = 1; + curve[2] = 0; + shaper.curve = curve; + source.connect(shaper); + shaper.connect(context.destination); + + source.start(); + context.startRendering() + .then(buffer => checkResult(buffer, should)) + .then(() => task.done()); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-simple.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-simple.html new file mode 100644 index 0000000000..affd0c58af --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper-simple.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <title> + Simple Tests of WaveShaperNode + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + audit.define('simple', (task, should) => { + let context = new OfflineAudioContext(1, 1, 48000); + let shaper = context.createWaveShaper(); + + // Verify default values are correct. + should(shaper.curve, 'Initial WaveShaper.curve').beEqualTo(null); + should(shaper.oversample, 'Initial WaveShaper.oversample') + .beEqualTo('none'); + + // Set oversample and verify that it is set correctly. + should(() => shaper.oversample = '2x', 'Setting oversample to "2x"') + .notThrow(); + should(shaper.oversample, 'Waveshaper.oversample = "2x"') + .beEqualTo('2x'); + + should(() => shaper.oversample = '4x', 'Setting oversample to "4x"') + .notThrow(); + should(shaper.oversample, 'Waveshaper.oversample = "4x"') + .beEqualTo('4x'); + + should( + () => shaper.oversample = 'invalid', + 'Setting oversample to "invalid"') + .notThrow(); + should(shaper.oversample, 'Waveshaper.oversample = "invalid"') + .beEqualTo('4x'); + + // Set the curve and verify that the returned curve is the same as what + // it was set to. + let curve = Float32Array.from([-1, 0.25, .75]); + should(() => shaper.curve = curve, 'Setting curve to [' + curve + ']') + .notThrow(); + should(shaper.curve, 'WaveShaper.curve').beEqualToArray(curve); + + // Verify setting the curve to null works. + should(() => shaper.curve = null, 'Setting curve back to null') + .notThrow(); + should(shaper.curve, 'Waveshaper.curve = null').beEqualTo(null); + + task.done(); + }); + + audit.run(); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper.html b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper.html new file mode 100644 index 0000000000..8bfa009b18 --- /dev/null +++ b/testing/web-platform/tests/webaudio/the-audio-api/the-waveshapernode-interface/waveshaper.html @@ -0,0 +1,127 @@ +<!DOCTYPE html> +<html> + <head> + <title> + waveshaper.html + </title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../../resources/audit-util.js"></script> + <script src="../../resources/audit.js"></script> + <script src="../../resources/buffer-loader.js"></script> + </head> + <body> + <script id="layout-test-code"> + let audit = Audit.createTaskRunner(); + + let sampleRate = 44100; + let lengthInSeconds = 4; + let numberOfRenderFrames = sampleRate * lengthInSeconds; + let numberOfCurveFrames = 65536; + let inputBuffer; + let waveShapingCurve; + + let context; + + function generateInputBuffer() { + // Create mono input buffer. + let buffer = + context.createBuffer(1, numberOfRenderFrames, context.sampleRate); + let data = buffer.getChannelData(0); + + // Generate an input vector with values from -1 -> +1 over a duration of + // lengthInSeconds. This exercises the full nominal input range and will + // touch every point of the shaping curve. + for (let i = 0; i < numberOfRenderFrames; ++i) { + let x = i / numberOfRenderFrames; // 0 -> 1 + x = 2 * x - 1; // -1 -> +1 + data[i] = x; + } + + return buffer; + } + + // Generates a symmetric curve: Math.atan(5 * x) / (0.5 * Math.PI) + // (with x == 0 corresponding to the center of the array) + // This curve is arbitrary, but would be useful in the real-world. + // To some extent, the actual curve we choose is not important in this + // test, since the input vector walks through all possible curve values. + function generateWaveShapingCurve() { + let curve = new Float32Array(numberOfCurveFrames); + + let n = numberOfCurveFrames; + let n2 = n / 2; + + for (let i = 0; i < n; ++i) { + let x = (i - n2) / n2; + let y = Math.atan(5 * x) / (0.5 * Math.PI); + } + + return curve; + } + + function checkShapedCurve(buffer, should) { + let inputData = inputBuffer.getChannelData(0); + let outputData = buffer.getChannelData(0); + + let success = true; + + // Go through every sample and make sure it has been shaped exactly + // according to the shaping curve we gave it. + for (let i = 0; i < buffer.length; ++i) { + let input = inputData[i]; + + // Calculate an index based on input -1 -> +1 with 0 being at the + // center of the curve data. + let index = Math.floor(numberOfCurveFrames * 0.5 * (input + 1)); + + // Clip index to the input range of the curve. + // This takes care of input outside of nominal range -1 -> +1 + index = index < 0 ? 0 : index; + index = + index > numberOfCurveFrames - 1 ? numberOfCurveFrames - 1 : index; + + let expectedOutput = waveShapingCurve[index]; + + let output = outputData[i]; + + if (output != expectedOutput) { + success = false; + break; + } + } + + should( + success, 'WaveShaperNode applied non-linear distortion correctly') + .beTrue(); + } + + audit.define('test', function(task, should) { + // Create offline audio context. + context = new OfflineAudioContext(1, numberOfRenderFrames, sampleRate); + + // source -> waveshaper -> destination + let source = context.createBufferSource(); + let waveshaper = context.createWaveShaper(); + source.connect(waveshaper); + waveshaper.connect(context.destination); + + // Create an input test vector. + inputBuffer = generateInputBuffer(); + source.buffer = inputBuffer; + + // We'll apply non-linear distortion according to this shaping curve. + waveShapingCurve = generateWaveShapingCurve(); + waveshaper.curve = waveShapingCurve; + + source.start(0); + + context.startRendering() + .then(buffer => checkShapedCurve(buffer, should)) + .then(task.done.bind(task)); + }); + + audit.run(); + </script> + </body> +</html> |