Tone.js
Tone.js copied to clipboard
Limiter not working as expected, i.e. -100 still lets audio through
Describe the bug
Tone.Limiter should reduce the incoming signal below the threshold but it doesn't do that.
To Reproduce
Check this codepen: https://codepen.io/provos/pen/MYwXMdP
Expected behavior At -100db no audio should be audible.
What I've tried The above is the simplest example I could come up with.
Additional context This is in the context of mixing/mastering audio using Tone.Js and the limiter is not working as expected.
This appears to be an implementation bug(?) of the DynamicsCompressor in the Web Audio API. Same problem without using tone.js:
https://codepen.io/provos/pen/zxGLZYQ
The cause is likely due to a quirk in Web Audio's DynamicsCompressorNode, which is that the Web Audio spec defines the inclusion of an unexposed makeup gain (foolishly, IMO). I complained about its being non-user-configurable in this still-open Chromium issue. You can negate the makeup gain, as long as you have set a hard knee at 0, by connecting the compressor to a gain and doing
const dBToGain = dB => Math.pow(10, dB / 20);
postGain.gain.value = dBToGain(.5 * compressorNode.threshold.value);
But once there is an active knee its more difficult to calculate how the makeup gain is handled across browsers, at least I haven't figured out a simple universal way to defeat the makeup gain when knee > 0, I believe differing smoothing algorithms start to come into play at that point. But typically for a limiter the knee would be hard anyway, so the makeup gain is defeatable.
FYI Chromium's compressor source is here if you want to take a look.
Thank you, Marcel. That's very helpful. When I was looking into whether there was a hidden make up gain applied, I couldn't find this section. I agree with you that this does not make much sense and essentially makes the DynamicsProcessor on its own useless for audio processing. Thanks for the suggested fix. I will try that out and can live with a hard knee at least for the application that I was working on right now. The spec should be changed though.
Tone.js should apply a similar gain adjustment as you propose for any node that uses the Web Audio API DynamicsProcessor.
Tone.js should apply a similar gain adjustment as you propose for any node that uses the Web Audio API DynamicsProcessor.
The problem here is that handling knee in these calculations is not as straightforward, since AFAICT it differs across browsers. The DynamicsCompressorNode that Tone's Limiter uses keeps the knee at the default value of 30 rather than 0. If @tambien is for it, I'd be happy to PR a change to Tone's Limiter that both sets the compressor's knee to 0 and includes the above makeup gain negation. As I understand it, a traditional limiter usually has a hard knee anyway. There may be reasons @tambien left it at 30, and there would also be an argument that this would be undesirable for backward compatibility as a breaking change, though the counterargument would be that this is essentially a bug fix.
For what's it worth, here is a typescript implementation for reversing the makeup gain applied by Chrome:
import * as Tone from "tone";
/**
* Creates a Tone.Gain node that reverses the makeup gain applied by a compressor.
* A compressor without makeup gain is required for any kind of mixing or mastering chain.
*
* @param {DynamicsCompressorNode | Tone.Compressor | Tone.Limiter} compressorNode - The compressor node whose makeup gain should be reversed
* @returns {Tone.Gain} A Tone.Gain node with the inverse of the compressor's makeup gain
*/
export function getReverseCompressorMakeupGain(
compressorNode: DynamicsCompressorNode | Tone.Compressor | Tone.Limiter
): Tone.Gain {
const reverseGain = reverseCompressorMakeupGain(compressorNode);
return new Tone.Gain(reverseGain);
}
export function reverseCompressorMakeupGain(
compressorNode: DynamicsCompressorNode | Tone.Compressor | Tone.Limiter
): number {
// Get compressor parameters
const threshold = compressorNode.threshold.value;
const knee = "knee" in compressorNode ? compressorNode.knee.value : 0;
const ratio = "ratio" in compressorNode ? compressorNode.ratio.value : 20;
// Calculate slope (inverse of ratio)
const desiredSlope = 1 / ratio;
// Calculate k parameter using the same algorithm as Chrome
const k = calculateKAtSlope(threshold, knee, desiredSlope);
// Calculate the makeup gain that Chrome applies
const saturatedK = saturate(1, k, threshold, knee, ratio);
const appliedLinearGain = Math.pow(1 / saturatedK, 0.6);
// To reverse it, we need to divide by this gain (multiply by inverse)
const reverseGain = 1 / appliedLinearGain;
return reverseGain;
}
// Helper functions for dB/linear conversion
const dBToLinear = (dB: number): number => Math.pow(10, dB / 20);
const linearToDb = (linear: number): number => 20 * Math.log10(linear);
// The knee curve function used by Chrome
function kneeCurve(x: number, k: number, linearThreshold: number): number {
// It is 1st derivative matched at linearThreshold and asymptotically
// approaches the value linearThreshold + 1 / k.
if (x < linearThreshold) {
return x;
}
return linearThreshold + (1 - Math.exp(-k * (x - linearThreshold))) / k;
}
// Full compression curve with constant ratio after knee.
function saturate(
x: number,
k: number,
dbThreshold: number,
dbKnee: number,
ratio: number
): number {
const linearThreshold = dBToLinear(dbThreshold);
const dbKneeThreshold = dbThreshold + dbKnee;
const kneeThreshold = dBToLinear(dbKneeThreshold);
if (x < kneeThreshold) {
return kneeCurve(x, k, linearThreshold);
}
// After the knee, the curve becomes a straight line in dB space.
const yKnee = kneeCurve(kneeThreshold, k, linearThreshold);
// Avoid taking log of 0
if (yKnee <= 0) {
return 0;
}
const dbYKneeThreshold = linearToDb(yKnee);
const slope = 1 / ratio;
const dbX = linearToDb(x);
const dbY = dbYKneeThreshold + slope * (dbX - dbKneeThreshold);
return dBToLinear(dbY);
}
// Calculate the k parameter using binary search (mimics KAtSlope from C++)
function calculateKAtSlope(
dbThreshold: number,
dbKnee: number,
desiredSlope: number
): number {
const linearThreshold = dBToLinear(dbThreshold);
const dbX = dbThreshold + dbKnee;
const x = dBToLinear(dbX);
let x2 = x * 1.001;
let dbX2 = linearToDb(x2);
if (x < linearThreshold) {
x2 = 1;
dbX2 = 0;
}
// Binary search for k
let minK = 0.1;
let maxK = 10000;
let k = 5; // Initial guess
for (let i = 0; i < 15; i++) {
// A high value for k will more quickly asymptotically approach a slope of 0.
// Approximate 1st derivative with input and output expressed in dB.
// This slope is equal to the inverse of the compression "ratio".
const y = kneeCurve(x, k, linearThreshold);
const y2 = kneeCurve(x2, k, linearThreshold);
const dbY = linearToDb(y);
const dbY2 = linearToDb(y2);
const slope = (dbY2 - dbY) / (dbX2 - dbX);
if (slope < desiredSlope) {
// k is too high (slope too shallow)
maxK = k;
} else {
// k is too low (slope too steep)
minK = k;
}
// Re-calculate based on geometric mean.
k = Math.sqrt(minK * maxK);
}
return k;
}
Sounds similar to me for Safari as well. In terms of fixing this for tone.js - you probably want to add a new option to the Compressor and Limiter nodes. Or perhaps even create new nodes for it, e.g. UsefulCompressor and UsefulLimiter - just kidding with the names.
Ah cool, you managed to transpile those C++ functions from the Chromium source. I had spent some time looking further into this last year, IIRC I had concluded that at least Firefox handled things differently, and I was concerned about adding potentially expensive calculations when the native DynamicsCompressorNode is already fairly cpu intensive. I think, also, that the way the compressor's reduction property is reported also varies x-browser. I had created a little DynamicsCompressorNode tester here with visualizations, feel free to play around with: https://codepen.io/keymapper/pen/QWeMyLL
For Tone I think the ideal way to do it would be to add a toggleable flag to its compressor to optionally cancel the makeup gain, but the x-browser issues are a concern. Another approach would be to add this to standardized-audio-context in a more x-browser-complete way, but this probably goes beyond the scope of what standardized-audio-context tries to achieve.
BTW Tone has inbuilt gainToDb() and dbToGain() functions.
In terms of expensive computations, they are fortunately not in the signal path. It's paid once when setting up the node. So, that should be less of a concern. I agree with your recommendation for tone.js to have an optional flag that adds a Gain node with the computed reverse gain. It would be nice for Chromium to address your bug report. Let me chime in on that as well.
Not paid only once if you're modifying the compressor in realtime, for example with a user-adjustable slider as in my codepen, and things get a little more complicated if you're scheduling or ramping param changes and the reverseGain needs to schedule along with it etc. Note: I haven't tested your code to see if it is truly that expensive. Just thinking about a knob or slider updating compressor properties in realtime.
Hi @marcelblum, I don't want to hijack the conversation but your comment above made me curious. Do you know of an example which uses the DynamicsCompressorNode and produces different results in Firefox compared to Chrome and Safari?
@chrisguttandin The way reduction is calculated and reported, and smoothed over time, can vary quite a bit between browsers. A quick example would be to go to my testing codepen, click to start playing "1khz tone", and lower the threshold to -50 while keeping all other settings at the defaults, and keep an eye on the reported reduction value. A quick test on Win 11 for me shows that reduction settles at -9.3 on FF and -8.2 on Chrome. Also, on FF reduction can erroneously stick on the last "active" value even when no node is connected.
Also if you listen carefully comparing between the 2 with the same settings you'll hear very different smoothing curves and anti-clipping measures at play especially with the "Play music" and "Play white noise" options. For example try these settings to exaggerate the differences: threshold -70, ratio 4, attack 1, release 1, knee 24.
Some of this is obviously due to the ambiguity of the spec and differing implementations, but the reduction differences seem more like a bug.
Thanks @marcelblum, I didn't know that reduction is reported differently from browser to browser. Maybe I should disable it entirely in standardized-audio-context.
Also if you listen carefully comparing between the 2 with the same settings you'll hear very different smoothing curves and anti-clipping measures at play especially with the "Play music" and "Play white noise" options. For example try these settings to exaggerate the differences: threshold -70, ratio 4, attack 1, release 1, knee 24.
Is this maybe just the difference from browser to browser when handling a signal outside the range from -1 to +1. I think the difference goes away when lowering the "Pregain" with the slider to make sure there is no clipping. But I listened to it with the default laptop speakers while something else was making sound at the same time. I might be wrong.
@chrisguttandin you are certainly right about the differing handling of clipping - and I guess browsers can handle clipping differently without technically going off spec. I did intentionally choose a kind of blown out music clip for testing purposes. But the white noise leads to anomalies where clipping isn't coming into play, I think the frequency distribution there can really throw off the internal makeup gain/smoothing and highlight the differing compressor implementations. Try threshold -64, ratio 5, attack 300, release 300, knee 20, and play white noise. The result in Chrome is clearly louder than Firefox, and the reduction reflects this with FF showing an additional ~5dB.
Hi @marcelblum, sorry for being so skeptical. I ran a couple more tests and I'm convinced now, too. But I somehow had the info in the back of my head that the behaviour should be the same at least in Chrome and Firefox.
I began browsing the Firefox source code and it is indeed using a copy of Chromium's implementation. I believe this is the file that contains the code which is doing the hard work in Firefox: https://searchfox.org/mozilla-central/source/dom/media/webaudio/blink/DynamicsCompressorKernel.cpp.
The problem though is that this file doesn't exist anymore in Chromium's source code. It was refactored away. This is one of the commits which applied the changes: https://source.chromium.org/chromium/chromium/src/+/4997480861f583e9ab392697299f9d04a746afb4.
Maybe something was changed along the way which is now causing the different behaviour. The only thing that looked suspicous to me is the pre-computation of some values. I tried to replicate the changes and these are the values for Chrome and Firefox respectively if the sample rate is 48kHz.
25.920001983642578, 1.7247633934020996, 14.517939567565918, 3.6447598934173584, 0.27254000306129456
25.920001983642578, 1.724759578704834, 14.517940521240234, 3.6447603702545166, 0.27254000306129456
Some of these values are slightly different but I'm not sure if that has such a large impact in the end.
I didn't know Firefox source had any Blink in it! I guess there was code sharing when web audio was first being developed?
In any case, I do not have enough C++ DSP knowledge to make too much sense of the implementation differences from the source code. But I noticed that Chromium's implementation uses double in a few calculations where FireFox only uses float. I wonder if the difference in precision plays a role here.
After spending far more time on this than I'm willing to admit, I found at least one major difference between Firefox and Chrome/Safari. Chrome keeps processing the internal state of a DynamicsCompressorNode and also starts doing so right away. Firefox doesn't do that. For now I only added a set of expectation tests (https://github.com/chrisguttandin/standardized-audio-context/commit/3370309af13e51bcd15e171db5069112ed163993) for Chrome and Safari.
This appears to be an implementation bug(?) of the DynamicsCompressor in the Web Audio API. Same problem without using tone.js:
https://codepen.io/provos/pen/zxGLZYQ
Thanks for the really useful discussion and for looking deeply into this issue @marcelblum and @chrisguttandin! for housekeeping i'm going to close this since it seems like there's nothing to be done on the Tone.js-side of things.
You may want to create a feature tracking bug for tone.js to implement the changes to the web audio specification that will allow turning off the make up gain once that's ready? See https://github.com/WebAudio/web-audio-api/issues/2639