| <!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> |