Commit 23f7ac0a authored by Raymond Toy's avatar Raymond Toy Committed by Commit Bot

Remove Dezippering from StereoPannerNode

Setting the pan attribute will set the value immediately instead of
dezippering from the current value to the new value.

Chromium Feature: https://www.chromestatus.com/features/5287995770929152
Intent to ship: https://groups.google.com/a/chromium.org/d/msg/blink-dev/YKYRrh0nWMo/aGzd3049AgAJ

Bug: 752985
Test: StereoPanner/dezipper.html
Change-Id: I13815d7cb3a6d4ea93dd8f0f1bab739e6768e561
Reviewed-on: https://chromium-review.googlesource.com/607028
Commit-Queue: Raymond Toy <rtoy@chromium.org>
Reviewed-by: default avatarHongchan Choi <hongchan@chromium.org>
Cr-Commit-Position: refs/heads/master@{#532527}
parent e7efd152
<!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')
.beEqualToArray(expected[k]);
should(
match,
options.prefix + ' ' + label[k] +
' .value setter output matches setValueAtTime output')
.beTrue();
}
});
}
audit.run();
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>
stereopannernode-no-glitch.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 sampleRate = 44100;
let renderDuration = 0.5;
// The threshold for glitch detection. This was experimentally determined.
let GLITCH_THRESHOLD = 0.0005;
// The maximum threshold for the error between the actual and the expected
// sample values. Experimentally determined.
let MAX_ERROR_ALLOWED = 0.0000001;
// Option for |Should| test util. The number of array elements to be
// printed out is arbitrary.
let SHOULD_OPTS = {numberOfArrayLog: 2};
let audit = Audit.createTaskRunner();
// Extract a transitional region from the AudioBuffer. If no transition
// found, fail this test.
function extractPanningTransition(should, input, prefix) {
let chanL = input.getChannelData(0);
let chanR = input.getChannelData(1);
let start, end;
let index = 1;
// Find transition by comparing two consecutive samples. If two
// consecutive samples are identical, the transition has not started.
while (chanL[index - 1] === chanL[index] ||
chanR[index - 1] === chanR[index]) {
if (++index >= input.length) {
should(false, prefix + ': Transition in the channel data')
.summarize('found', 'not found');
return null;
}
}
start = index - 1;
// Find the end of transition. If two consecutive samples are not equal,
// the transition is still ongoing.
while (chanL[index - 1] !== chanL[index] ||
chanR[index - 1] !== chanR[index]) {
if (++index >= input.length) {
should(false, 'Transition found')
.summarize('', 'but the buffer ended prematurely');
return null;
}
}
end = index;
return {
left: chanL.subarray(start, end),
right: chanR.subarray(start, end),
length: end - start
};
}
// JS implementation of stereo equal power panning.
function panStereoEqualPower(pan, inputL, inputR) {
pan = Math.min(1.0, Math.max(-1.0, pan));
let output = [];
let panRadian;
if (!inputR) { // mono case.
panRadian = (pan * 0.5 + 0.5) * Math.PI / 2;
output[0] = inputL * Math.cos(panRadian);
output[1] = inputR * Math.sin(panRadian);
} else { // stereo case.
panRadian = (pan <= 0 ? pan + 1 : pan) * Math.PI / 2;
let gainL = Math.cos(panRadian);
let gainR = Math.sin(panRadian);
if (pan <= 0) {
output[0] = inputL + inputR * gainL;
output[1] = inputR * gainR;
} else {
output[0] = inputL * gainL;
output[1] = inputR + inputL * gainR;
}
}
return output;
}
// Generate the expected result of stereo equal panning. |input| is an
// AudioBuffer to be panned.
function generateStereoEqualPanningResult(
input, startPan, endPan, length) {
// Smoothing constant time is 0.05 second.
let smoothingConstant = 1 - Math.exp(-1 / (sampleRate * 0.05));
let inputL = input.getChannelData(0);
let inputR = input.getChannelData(1);
let pan = startPan;
let outputL = [], outputR = [];
for (let i = 0; i < length; i++) {
let samples = panStereoEqualPower(pan, inputL[i], inputR[i]);
outputL[i] = samples[0];
outputR[i] = samples[1];
pan += (endPan - pan) * smoothingConstant;
}
return {left: outputL, right: outputR};
}
// Build audio graph and render. Change the pan parameter in the middle of
// rendering.
function panAndVerify(should, options) {
let context =
new OfflineAudioContext(2, renderDuration * sampleRate, sampleRate);
let source = context.createBufferSource();
let panner = context.createStereoPanner();
let stereoBuffer = createConstantBuffer(
context, renderDuration * sampleRate, [1.0, 1.0]);
source.buffer = stereoBuffer;
panner.pan.value = options.startPanValue;
source.connect(panner);
panner.connect(context.destination);
source.start();
// Schedule the parameter transition by the setter at 1/10 of the render
// duration.
context.suspend(0.1 * renderDuration).then(function() {
panner.pan.value = options.endPanValue;
context.resume();
});
return context.startRendering().then(function(buffer) {
let actual =
extractPanningTransition(should, buffer, options.message);
let expected = generateStereoEqualPanningResult(
stereoBuffer, options.startPanValue, options.endPanValue,
actual.length);
// |notGlitch| tests are redundant if the actual and expected results
// match and if the expected results themselves don't glitch.
should(actual.left, options.message + ': Channel #0')
.notGlitch(GLITCH_THRESHOLD);
should(actual.right, options.message + ': Channel #1')
.notGlitch(GLITCH_THRESHOLD);
should(actual.left, options.message + ': Channel #0', SHOULD_OPTS)
.beCloseToArray(
expected.left, {absoluteThreshold: MAX_ERROR_ALLOWED});
should(actual.right, options.message + ': Channel #1', SHOULD_OPTS)
.beCloseToArray(
expected.right, {absoluteThreshold: MAX_ERROR_ALLOWED});
});
}
// Task: move pan from negative (-0.1) to positive (0.1) value to check if
// there is a glitch during the transition. See crbug.com/470559.
audit.define('negative-to-positive', (task, should) => {
panAndVerify(
should, {startPanValue: -0.1, endPanValue: 0.1, message: 'L->R'})
.then(() => task.done());
});
// Task: move pan from positive (0.1) to negative (-0.1) value to check if
// there is a glitch during the transition.
audit.define('positive-to-negative', (task, should) => {
panAndVerify(
should, {startPanValue: 0.1, endPanValue: -0.1, message: 'R->L'})
.then(() => task.done());
});
audit.run();
</script>
</body>
</html>
......@@ -9,9 +9,6 @@
#include "platform/audio/AudioUtilities.h"
#include "platform/wtf/MathExtras.h"
// Use a 50ms smoothing / de-zippering time-constant.
const float SmoothingTimeConstant = 0.050f;
namespace blink {
// Implement equal-power panning algorithm for mono or stereo input.
......@@ -21,12 +18,7 @@ std::unique_ptr<StereoPanner> StereoPanner::Create(float sample_rate) {
return WTF::WrapUnique(new StereoPanner(sample_rate));
}
StereoPanner::StereoPanner(float sample_rate)
: is_first_render_(true), pan_(0.0) {
// Convert smoothing time (50ms) to a per-sample time value.
smoothing_constant_ = AudioUtilities::DiscreteTimeConstantForSampleRate(
SmoothingTimeConstant, sample_rate);
}
StereoPanner::StereoPanner(float sample_rate) {}
void StereoPanner::PanWithSampleAccurateValues(const AudioBus* input_bus,
AudioBus* output_bus,
......@@ -66,9 +58,9 @@ void StereoPanner::PanWithSampleAccurateValues(const AudioBus* input_bus,
if (number_of_input_channels == 1) { // For mono source case.
while (n--) {
float input_l = *source_l++;
pan_ = clampTo(*pan_values++, -1.0, 1.0);
double pan = clampTo(*pan_values++, -1.0, 1.0);
// Pan from left to right [-1; 1] will be normalized as [0; 1].
pan_radian = (pan_ * 0.5 + 0.5) * piOverTwoDouble;
pan_radian = (pan * 0.5 + 0.5) * piOverTwoDouble;
gain_l = std::cos(pan_radian);
gain_r = std::sin(pan_radian);
*destination_l++ = static_cast<float>(input_l * gain_l);
......@@ -78,12 +70,12 @@ void StereoPanner::PanWithSampleAccurateValues(const AudioBus* input_bus,
while (n--) {
float input_l = *source_l++;
float input_r = *source_r++;
pan_ = clampTo(*pan_values++, -1.0, 1.0);
double pan = clampTo(*pan_values++, -1.0, 1.0);
// Normalize [-1; 0] to [0; 1]. Do nothing when [0; 1].
pan_radian = (pan_ <= 0 ? pan_ + 1 : pan_) * piOverTwoDouble;
pan_radian = (pan <= 0 ? pan + 1 : pan) * piOverTwoDouble;
gain_l = std::cos(pan_radian);
gain_r = std::sin(pan_radian);
if (pan_ <= 0) {
if (pan <= 0) {
*destination_l++ = static_cast<float>(input_l + input_r * gain_l);
*destination_r++ = static_cast<float>(input_r * gain_r);
} else {
......@@ -127,46 +119,36 @@ void StereoPanner::PanToTargetValue(const AudioBus* input_bus,
float target_pan = clampTo(pan_value, -1.0, 1.0);
// Don't de-zipper on first render call.
if (is_first_render_) {
is_first_render_ = false;
pan_ = target_pan;
}
double gain_l, gain_r, pan_radian;
const double smoothing_constant = smoothing_constant_;
int n = frames_to_process;
if (number_of_input_channels == 1) { // For mono source case.
while (n--) {
float input_l = *source_l++;
pan_ += (target_pan - pan_) * smoothing_constant;
// Pan from left to right [-1; 1] will be normalized as [0; 1].
double pan_radian = (target_pan * 0.5 + 0.5) * piOverTwoDouble;
// Pan from left to right [-1; 1] will be normalized as [0; 1].
pan_radian = (pan_ * 0.5 + 0.5) * piOverTwoDouble;
double gain_l = std::cos(pan_radian);
double gain_r = std::sin(pan_radian);
gain_l = std::cos(pan_radian);
gain_r = std::sin(pan_radian);
// TODO(rtoy): This can be vectorized using VectorMath::Vsmul
while (n--) {
float input_l = *source_l++;
*destination_l++ = static_cast<float>(input_l * gain_l);
*destination_r++ = static_cast<float>(input_l * gain_r);
}
} else { // For stereo source case.
// Normalize [-1; 0] to [0; 1] for the left pan position (<= 0), and
// do nothing when [0; 1].
double pan_radian =
(target_pan <= 0 ? target_pan + 1 : target_pan) * piOverTwoDouble;
double gain_l = std::cos(pan_radian);
double gain_r = std::sin(pan_radian);
// TODO(rtoy): Consider moving the if statement outside the loop
// since |target_pan| is constant inside the loop.
while (n--) {
float input_l = *source_l++;
float input_r = *source_r++;
pan_ += (target_pan - pan_) * smoothing_constant;
// Normalize [-1; 0] to [0; 1] for the left pan position (<= 0), and
// do nothing when [0; 1].
pan_radian = (pan_ <= 0 ? pan_ + 1 : pan_) * piOverTwoDouble;
gain_l = std::cos(pan_radian);
gain_r = std::sin(pan_radian);
// The pan value should be checked every sample when de-zippering.
// See crbug.com/470559.
if (pan_ <= 0) {
if (target_pan <= 0) {
// When [-1; 0], keep left channel intact and equal-power pan the
// right channel only.
*destination_l++ = static_cast<float>(input_l + input_r * gain_l);
......
......@@ -36,11 +36,6 @@ class PLATFORM_EXPORT StereoPanner {
private:
explicit StereoPanner(float sample_rate);
bool is_first_render_;
double smoothing_constant_;
double pan_;
};
} // namespace blink
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment