(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);