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