| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title> | |
| Test Handling of Tail Processing | |
| </title> | |
| <script src="../../imported/w3c/web-platform-tests/resources/testharness.js"></script> | |
| <script src="../../resources/testharnessreport.js"></script> | |
| <script src="../resources/audit.js"></script> | |
| <script src="../resources/audit-util.js"></script> | |
| </head> | |
| <body> | |
| <script id="layout-test-code"> | |
| let audit = Audit.createTaskRunner(); | |
| // Fairly arbitrary but must be a power of two to eliminate roundoff when | |
| // we compute times from sample frames | |
| const sampleRate = 32768; | |
| // Fairly arbitrary duration | |
| const renderDuration = 0.25; | |
| const renderFrames = renderDuration * sampleRate; | |
| audit.define( | |
| {label: 'grain', description: 'ABSN grain has the correct duration'}, | |
| (task, should) => { | |
| // Test that the grain duration is the requested duration. | |
| let context = new OfflineAudioContext(2, sampleRate, sampleRate); | |
| let merger = new ChannelMergerNode( | |
| context, {numberOfInputs: context.destination.channelCount}); | |
| merger.connect(context.destination); | |
| // Number of frames for the grain we want to play. (Pretty much | |
| // arbitrary.) | |
| let grainFrames = 128; | |
| // Test signal. Just fill the buffer with all ones. | |
| let bTest = new AudioBuffer( | |
| {length: context.length, sampleRate: context.sampleRate}); | |
| bTest.getChannelData(0).fill(1); | |
| let sTest = new AudioBufferSourceNode(context, {buffer: bTest}); | |
| // Reference signal. | |
| let bRef = new AudioBuffer( | |
| {length: context.length, sampleRate: context.sampleRate}); | |
| bRef.getChannelData(0).fill(1, 0, grainFrames); | |
| let sRef = new AudioBufferSourceNode(context, {buffer: bRef}); | |
| sTest.connect(merger, 0, 0); | |
| sRef.connect(merger, 0, 1); | |
| sTest.start(0, 0, grainFrames / context.sampleRate); | |
| sRef.start(); | |
| context.startRendering() | |
| .then(renderedBuffer => { | |
| let actual = renderedBuffer.getChannelData(0); | |
| let expected = renderedBuffer.getChannelData(1); | |
| // The actual and expected should be the same because we got | |
| // rid of the grain hack that caused a grain to play for an | |
| // extra 512 frames. | |
| should(actual, 'Rendered ABSN grain') | |
| .beEqualToArray(expected); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('hrtf-panner-tail', (task, should) => { | |
| runTest('PannerNode', {panningModel: 'HRTF', distanceMode: 'linear'}) | |
| .then(renderedBuffer => { | |
| let prefix = 'HRTF PannerNode'; | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| let latencyFrame = findLatencyFrame(response); | |
| let tailFrame = findTailFrame(response); | |
| // The HRTF panner has both a latency component and a tail | |
| // component. Make sure both are non-zero. | |
| should(latencyFrame, `${prefix} latency frame (${latencyFrame})`) | |
| .beGreaterThan(0); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| // Because of the latency, the output is zero at the beginning. | |
| // Make sure this is true. | |
| should( | |
| output.slice(0, latencyFrame), | |
| `${prefix} Latency output[0:` + (latencyFrame - 1) + ']') | |
| .beConstantValueOf(0); | |
| // Verify the rest of the output matches the expected values. The | |
| // output should be non-zero from latencyFrame to tailFrame and | |
| // zero after tailFrame. | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: latencyFrame, | |
| nonZeroEndFrame: Math.min(tailFrame, output.length), | |
| zeroStartFrame: roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('biquad-tail', (task, should) => { | |
| runTest('BiquadFilterNode', {Q: 20, frequency: 100}) | |
| .then(renderedBuffer => { | |
| let prefix = 'BiquadFilter' | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| let tailFrame = findTailFrame(response); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| // Verify biquad output which should be non-zero up to tailFrame | |
| // and zero afterwards. However, the actual output isn't after | |
| // tailFrame because the internal biquad tail time uses an | |
| // approximation. That's why zeroStartFrame is one render quantum | |
| // after tailFrame. | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: 0, | |
| nonZeroEndFrame: | |
| Math.min(tailFrame + RENDER_QUANTUM_FRAMES, output.length), | |
| zeroStartFrame: RENDER_QUANTUM_FRAMES + roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('iir-tail', (task, should) => { | |
| runTest('IIRFilterNode', {feedforward: [1], feedback: [1, -.99]}) | |
| .then(renderedBuffer => { | |
| let prefix = 'IIRFilter'; | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| let tailFrame = findTailFrame(response); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: 0, | |
| nonZeroEndFrame: | |
| Math.min(tailFrame + RENDER_QUANTUM_FRAMES, output.length), | |
| zeroStartFrame: 2 * RENDER_QUANTUM_FRAMES + roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('delay-tail', (task, should) => { | |
| // For the test, make sure the delay is greater than one render | |
| // quantum. If it's less we won't be able to tell if tail processing | |
| // worked because the input signal is an impulse. | |
| let delayFrames = RENDER_QUANTUM_FRAMES + 64; | |
| runTest('DelayNode', {delayTime: delayFrames / sampleRate}) | |
| .then(renderedBuffer => { | |
| let prefix = 'Delay'; | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| let tailFrame = findTailFrame(response); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| // As a delay node with delay time greater than one render | |
| // quantum, the first render quantum must be 0. | |
| should( | |
| output.slice(0, RENDER_QUANTUM_FRAMES), | |
| `${prefix} output[0:` + (RENDER_QUANTUM_FRAMES - 1) + ']') | |
| .beConstantValueOf(0); | |
| // The output of the delay node should be nonzero in the second | |
| // render quantum and zero forever after. | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: RENDER_QUANTUM_FRAMES, | |
| nonZeroEndFrame: Math.min(tailFrame, output.length), | |
| zeroStartFrame: roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('convolver-tail', (task, should) => { | |
| // The convolver response. It needs to be longer than one render | |
| // quantum to show the tail processing. | |
| let response = new AudioBuffer( | |
| {length: RENDER_QUANTUM_FRAMES + 64, sampleRate: sampleRate}); | |
| // For simplicity, just make the response all ones. | |
| response.getChannelData(0).fill(1); | |
| runTest('ConvolverNode', {disableNormalization: true, buffer: response}) | |
| .then(renderedBuffer => { | |
| let prefix = 'Convolver'; | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| let tailFrame = findTailFrame(response); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: 0, | |
| nonZeroEndFrame: | |
| Math.min(tailFrame + RENDER_QUANTUM_FRAMES, output.length), | |
| zeroStartFrame: RENDER_QUANTUM_FRAMES + roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('dynamics-compressor-tail', (task, should) => { | |
| runTest('DynamicsCompressorNode', {}) | |
| .then(renderedBuffer => { | |
| let prefix = 'DyamicsCompressor'; | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| let tailFrame = findTailFrame(response); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| let latencyFrame = roundDown(tailFrame - 1); | |
| should( | |
| output.slice(0, latencyFrame), | |
| `${prefix} output[0:` + (latencyFrame - 1) + ']') | |
| .beConstantValueOf(0); | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: latencyFrame, | |
| nonZeroEndFrame: Math.min(tailFrame, output.length), | |
| zeroStartFrame: roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.define('waveshaper-tail', (task, should) => { | |
| // Fairly arbitrary curve for the WaveShaper | |
| let curve = Float32Array.from([-1, -.5, 0, 0.5, 1]); | |
| runTest('WaveShaperNode', {curve: curve, oversample: '2x'}) | |
| .then(renderedBuffer => { | |
| let prefix = 'WaveShaper'; | |
| let output = renderedBuffer.getChannelData(0); | |
| let response = renderedBuffer.getChannelData(1); | |
| // FFT resampler introduces some very small round-off. Use a | |
| // threshold of zero to find the tail frame. | |
| let tailFrame = findTailFrame(response, 0); | |
| should(tailFrame, `${prefix} tail frame (${tailFrame})`) | |
| .beGreaterThan(0); | |
| verifyOutput(should, output, { | |
| prefix: prefix, | |
| startFrame: 0, | |
| nonZeroEndFrame: Math.min(tailFrame, output.length), | |
| zeroStartFrame: roundUp(tailFrame), | |
| tailFrame: tailFrame, | |
| reference: response | |
| }); | |
| }) | |
| .then(() => task.done()); | |
| }); | |
| audit.run('waveshaper-tail'); | |
| function runTest(nodeName, nodeOptions) { | |
| // Two-channel output. Channel 0 is the test result; channel 1 is the | |
| // impulse response that is used to figure out when the tail should | |
| // start. | |
| let context = new OfflineAudioContext(2, sampleRate, sampleRate); | |
| // Merge channels for the destination. | |
| let merger = new ChannelMergerNode(context, {numberOfInputs: 2}); | |
| merger.connect(context.destination); | |
| let src = new ConstantSourceNode(context, {offset: 1}); | |
| // Impulse for testing. We want a full buffer so as not to worry about | |
| // the source disconnecting prematurely from the filter. | |
| let b = new AudioBuffer( | |
| {length: context.length, sampleRate: context.sampleRate}); | |
| b.getChannelData(0)[0] = 1; | |
| let impulse = new AudioBufferSourceNode(context, {buffer: b}); | |
| let testNode = new window[nodeName](context, nodeOptions); | |
| let refNode = new window[nodeName](context, nodeOptions); | |
| src.connect(testNode).connect(merger, 0, 0); | |
| impulse.connect(refNode).connect(merger, 0, 1); | |
| src.start(); | |
| src.stop(1 / context.sampleRate); | |
| impulse.start(); | |
| return context.startRendering(); | |
| } | |
| // Starting from the end find the first frame that exceeds our threshold. | |
| // This assumes that everything less than the threshold is equivalent to 0 | |
| // for our purposes. | |
| function findTailFrame(response, zeroThreshold) { | |
| // Any value below this is considered to be zero for our purpose of | |
| // finding the first non-zero value. Somewhat arbitrary, but the value | |
| // here is the size of the LSB of a 16-bit PCM sample. | |
| let threshold = zeroThreshold === undefined ? 1 / 32768 : zeroThreshold; | |
| let tailFrame = response.length; | |
| for (let k = response.length - 1; k >= 0; --k) { | |
| if (Math.abs(response[k]) > threshold) { | |
| tailFrame = k + 1; | |
| break; | |
| } | |
| } | |
| return tailFrame; | |
| } | |
| function findLatencyFrame(response) { | |
| for (let k = 0; k < response.length; ++k) { | |
| if (response[k] != 0) | |
| return k; | |
| } | |
| return response.length; | |
| } | |
| function verifyOutput(should, output, options) { | |
| let prefix = options.prefix || ''; | |
| if (options.tailFrame && options.reference) { | |
| should( | |
| output.slice(0, options.tailFrame), | |
| `${prefix} Tail output[0:` + (options.tailFrame - 1) + ']') | |
| .beEqualToArray(options.reference.slice(0, options.tailFrame)); | |
| } | |
| // Verify that |output| is non-zero between |startFrame| and | |
| // |nonZeroEndFrame|. | |
| for (let k = options.startFrame; k < options.nonZeroEndFrame; | |
| k += RENDER_QUANTUM_FRAMES) { | |
| should( | |
| output.slice(k, k + RENDER_QUANTUM_FRAMES), | |
| `${prefix} output[` + k + ':' + (k + 127) + ']') | |
| .notBeConstantValueOf(0); | |
| } | |
| // Verify |output| is zero starting at frame |zeroStartFrame|, inclusive | |
| if (options.zeroStartFrame < output.length) { | |
| should( | |
| output.slice(options.zeroStartFrame), | |
| `${prefix} output[` + options.zeroStartFrame + ':]') | |
| .beConstantValueOf(0); | |
| } | |
| } | |
| function roundDown(frame) { | |
| return RENDER_QUANTUM_FRAMES * | |
| Math.floor(frame / RENDER_QUANTUM_FRAMES); | |
| } | |
| function roundUp(frame) { | |
| return RENDER_QUANTUM_FRAMES * Math.ceil(frame / RENDER_QUANTUM_FRAMES); | |
| } | |
| </script> | |
| </body> | |
| </html> |