On potentiometers and parameter mapping, part 2

Posted by Stefano at 13/02/2023, 17:05:21 UTC.

As promised, in this second episode we'll see how the potentiometer interaction metaphor influences music DSP and how we can leaverage our knowledge to improve user experience.

Parameter mapping

Let's consider again the voltage divider example — similar and related considerations apply in many other circumstances — where \(V_{\rm out}=f(p)V_{\rm in}\) and \(f(p)\) is a function that maps \(p \in [0,1]\) to \([0,1]\). By now we should know that the perceived loudness is approximately proportional to the logarithm of the signal intensity, hence we want \(f(p)\) to look like an exponential.

If we were designing an analog circuit we could maybe just get away with a logarithmic potentiometer with a midpoint value of our liking (or, rather, that satisfies some specification), but in the digital domain we have much more freedom and users may have higher expectations. For example, it would be most likely desirable to control the attenuation directly in terms of decibels (dB).

But here the first dilemma arises: \(f(p)=0\) corresponds to \(-\infty\) dB, so we can't actually define \(f(p)\) to "work on" a finite input range if we want to be able to achieve silence. Some would say this is not an issue since the user may be supplied with controls that allow for endless/unrestricted interaction — I tend to disagree, hence this post will only consider finite input ranges. In this context, we can call \(p\) the unmapped and \(f(p)\) the mapped parameter value.

At least when developing plugins, it is useful to be able to convert back and forth between mapped and unmapped values, as plugin standards work differently in this regard (e.g., the VST API mostly deals with unmapped values, while LV2 relies heavily on mapped values). We'll take that into consideration too.

If we still want a finite-range control that can reach silence and behaves decently w.r.t. loudness perception, we have quite some potential options at our disposal. It is beneficial, before delving into the details, to actually define a range of interest. Let's assume we are interested to control our attenuation in the range \([l, 0]\) dB (with \(l<0\)).

dB mapping with exception

A quick cheat is to use dB scale and special-case \(f(0)=0\): \[ f(p)= \begin{cases} 10^\frac{l(1-p)}{20} & {\rm if}~ p>0\\ 0 & {\rm if}~ p=0 \end{cases} \]

In these plots, as well as in most of the following ones, \(l=-20,-40,-60\) dB.

Of course, this is really good if you can afford the computation and don't mind having an abrupt discontinuity. In practice, this is usable as long as \(l\) is quite negative. IMO a sensible choice would be \(l=-96\) dB, corresponding to the minimum representable level difference using 16 bits quantization without dithering, but such a wide range would make the applicability of this mapping limited to few use cases.

Anyway, here's the inverse mapping: \[ f^{-1}(x)= \begin{cases} 1-\frac{20}{l}{\rm log_{10}}(x) & {\rm if}~ x>0\\ 0 & {\rm if}~ x=0 \end{cases} \] and a trivial and unoptimized C99 implementation:

#include <math.h>

float map_dB_w_exception(float p, float l) {
	return p == 0.f ? 0.f : powf(10.f, l * (1.f - p) / 20.f);
}

float unmap_dB_w_exception(float x, float l) {
	return x == 0.f ? 0.f : 1.f - 20.f / l * log10f(x);
}

Exponential mapping

We could otherwise do it like in the analog domain. If we take the range-limited dB scale as a reference, the midpoint value is \(m=10^\frac{l}{40}\), that is \(\frac{l}{2}\) dB, hence: \[ \begin{align} f(p)&=\frac{10^\frac{l}{20}\left(1-\left(10^{-\frac{l}{40}}-1\right)^{2p}\right)}{2\cdot10^\frac{l}{40}-1} \\ f^{-1}(x)&=\frac{{\rm log}\left(1-\frac{2\cdot 10^\frac{l}{40}-1}{10^\frac{l}{20}}x\right)}{2{\rm log}\left(10^{-\frac{l}{40}}-1\right)} \end{align} \]

Please notice how the relative error increases as \(p\) approaches \(0\). Also with this mapping more negative values of \(l\) give less error, but this time the mapping and its inverse are \(C^\infty\) (a.k.a., infinitely differentiable, that is, really smooth).

Here's a trivial and unoptimized C99 implementation:

#include <math.h>

float map_exp(float p, float l) {
	float v = powf(10.f, l / 40.f);
	float v2 = v * v; // = 10^(l/20)
	return v2 * (1.f - powf(1.f / v - 1.f, p + p)) / (v + v - 1.f);
}

float unmap_exp(float x, float l) {
	float v = powf(10.f, l / 40.f);
	float v2 = v * v; // = 10^(l/20)
	return logf(1.f - (v + v - 1.f) / v2 * x) / (2.f * logf(1.f / v - 1.f));
}

Polynomial mapping

If we want to make computations as cheap as possible we can try approximating with polynomials. The possibilities here are endless, so we'll just examine a couple of cases.

\(p^n\)

As \(0^n=0,~ 1^n=1~ \forall n \neq 0\), we can just define a mapping:

\[ \begin{align} f(p)&=p^n \\ f^{-1}(x)&=\sqrt[n]{x} \end{align} \]

The midpoint value is \(f\left(\frac{1}{2}\right)=\frac{1}{2^n}\), that is \(-20{\rm log_{10}}(2)n\) dB. Hence, to obtain a midpoint gain of \(g\) dB we need to choose \(n=-\frac{g}{20{\rm log}(2)}\).

Quite interestingly, this happens to be the same mapping used for gamma correction.

\(f(p)\) is extremely convenient to compute as long as \(n\) is integer (just multiply \(n\)-times \(p\) with itself). The deviation from the dB scale can be quite severe though.

Here's a trivial C99 implementation that works in the general case:

#include <math.h>

float map_pn(float p, float n) {
	return powf(p, n);
}

float unmap_pn(float x, float n) {
	return powf(x, 1.f / n);
}
Parabolic functions

Finding roots of polynomials of degree higher than 2 tends to be quite cumbersome, so let's focus on second-degree polynomials here, that is, parabolas. Useful mappings in our case take the form

\[ \begin{align} f(p)&=ap^2+(1-a)p \\ f^{-1}(x)&=\frac{\sqrt{(1-a)^2+4ax}+a-1}{2a} \end{align} \]

where we have a free parameter \(a\). We need \(a \in (0,1]\) for the first and second derivative to be positive, that is, to get useful shapes for the problem at hand. Here are the obligatory plots:

The midpoint value is \(f\left(\frac{1}{2}\right)=\frac{1}{2}-\frac{a}{4}\), that is \(20{\rm log_{10}}(\frac{1}{2}-\frac{a}{4})\) dB. Hence, to obtain a midpoint gain of \(g\) dB we need to choose \(a=2-4\cdot 10^\frac{g}{20}\).

In practice, this mapping is only useful to get intermediate behavior between a linear mapping and \(p^2\). Again, the forward mapping is very cheap to compute but not the inverse one, and the deviation from the dB scale is intense.

Here's a trivial C99 implementation:

#include <math.h>

float map_parabolic(float p, float a) {
	return p * (a * p + 1.f - a);
}

float unmap_parabolic(float x, float a) {
	float v = 1.f - a;
	return (sqrtf(v * v + 4.f * a * x) - v) / (a + a);
}

Rational mapping

Taking inspiration from emulated tapers, we can also try to use rational functions. Anything that goes beyond the first degree at numerator or denominator is probably hard to manage mathematically (I confess I haven't tried though), so let's limit ourselves accordingly. Useful mappings in our case have the form:

\[ \begin{align} f(p)&=\frac{ap}{p+a-1} \\ f^{-1}(x)&=\frac{(1-a)x}{x-a} \end{align} \]

where once again \(a\) is a free parameter. This time we need \(a < 0\), which keeps us from having singularities in the range of interest and guarantees positive second derivative (good shape). Visually we have:

The midpoint value is \(f\left(\frac{1}{2}\right)=\frac{a}{2a-1}\), that is \(20{\rm log_{10}}(\frac{a}{2a-1})\) dB. Hence, to obtain a midpoint gain of \(g\) dB we need to choose \(a=\frac{10^\frac{g}{20}}{2\cdot 10^\frac{g}{20}-1}\).

This mapping is closer to dB scale than the polynomial ones we discusses, yet it is still relatively cheap to compute. The inverse mapping, in particular, is really cheap compared to the other mappings. The deviation from dB scale tends to grow as \(g\) gets more negative.

Here's a trivial C99 implementation:

float map_rational(float p, float a) {
	return a * p / (p + a - 1.f);
}

float unmap_rational(float x, float a) {
	return (1.f - a) * x / (x - a);
}

Linear + dB mapping

Yet another possibility is to use different mappings in different subranges. For example, we can use a linear mapping in the first part of the range and then switch to dB scale, yiedling:

\[ \begin{align} f(p)&= \begin{cases} ap & {\rm if}~ p<p_0 \\ 10^\frac{l(1-p)}{20} & {\rm otherwise} \end{cases} \\ f^{-1}(x)&= \begin{cases} 0 & {\rm if}~ x=0 \\ \frac{x}{a} & {\rm if}~ 0<x<ap_0 \\ 1-\frac{20}{l}{\rm log_{10}}(x) & {\rm otherwise} \end{cases} \end{align} \]

where \( p_0=\frac{20}{{\rm log}(10)l} W_0\left( \frac{{\rm log}(10)l}{20a}10^\frac{l}{20} \right) \) and \(W_0()\) is the principal branch of the Lambert W function.

We now have to choose the value for the free parameter \(a\). Calling \(a_0 = -e\frac{{\rm log}(10)l}{20}10^{\frac{l}{20}} \), it can be shown that we need \(a\geq a_0\) for the linear part to intersect the exponential part, so that a continuous (\(C^0\)) mapping can be defined. In particular if \(a=a_0\) there is a single point of intersection, otherwise there will be 2 such points.

Let's examine a couple of possibilities.

Choosing \(p_0\)

We can choose the position \(p_0\) at which the two curves intersect and accordingly set \(a = \frac{10^\frac{l(1-p_0)}{20}}{p_0} \). Visually, we obtain:

We indeed are able to define mappings that are continuous (\(C^0\)) and that fully match the dB scale up to an arbitrarily-chosen minimum position.

Here's a trivial C99 implementation:

#include <math.h>

float map_linear_dB_p0(float p, float l, float p0) {
	return p < p0
		? powf(10.f, l * (1.f - p0) / 20.f) / p0 * p
		: powf(10.f, l * (1.f - p) / 20.f);
}

float unmap_linear_dB_p0(float x, float l, float p0) {
	if (x == 0.f)
		return 0.f;
	float v = powf(10.f, l * (1.f - p0) / 20.f);
	return x < v
		? p0 * x / v
		: 1.f - 20.f / l * log10f(x);
}
Choosing \(a=a_0\) instead

In this case we actually trade the choise of \(p_0\) with a smoother (\(C^1\) continuous) overall curve. Visually:

The intersection position will be \(p_0 = -\frac{20}{{\rm log}(10)l}\) and we are limited by \(l>-\frac{20}{{\rm log}(10)}\).

Here's a trivial C99 implementation:

#include <math.h>

float map_linear_dB_c1(float p, float l) {
	return p < -8.685889638065035f / l
		? -0.3129537608383198f * l * powf(10.f, l / 20.f) * p
		: powf(10.f, l * (1.f - p) / 20.f);
}

float unmap_linear_dB_c1(float x, float l) {
	if (x == 0.f)
		return 0.f;
	float a = -0.3129537608383198f * l * powf(10.f, l / 20.f);
	return x < a * -8.685889638065035 / l
		? x / a
		: 1.f - 20.f / l * log10f(x);
}

Power + dB mapping

If we want a high-quality piecewise-defined mapping that is both smooth and where we can control the intersection point, we can replace the linear part in the last mapping with a power function like this:

\[ \begin{align} f(p)&= \begin{cases} 0 & {\rm if}~ p=0 \\ 10^\frac{l\left(\frac{{p_0}^2}{p}-2p_0+1\right)}{20} & {\rm if}~ 0<p<p_0 \\ 10^\frac{l(1-p)}{20} & {\rm otherwise} \end{cases} \\ f^{-1}(x)&= \begin{cases} 0 & {\rm if}~ x=0 \\ \frac{{p_0}^2}{\frac{20{\rm log_{10}}(x)}{l}+2p_0-1} & {\rm if}~ 0<x<10^\frac{l(1-p_0)}{20} \\ 1-\frac{20}{l}{\rm log_{10}}(x) & {\rm otherwise} \end{cases} \end{align} \]

Here's a trivial C99 implementation:

#include <math.h>

float map_pow_dB(float p, float l, float p0) {
	return p == 0.f ? 0.f :
		powf(10.f, l * (p < p0 ? p0 * p0 / p - p0 - p0 + 1.f : 1.f - p) / 20.f);

}

float unmap_pow_dB(float x, float l, float p0) {
	return x == 0.f ? 0.f :
		(x < powf(10.f, l * (1.f - p0) / 20.f)
			? p0 * p0 / (20.f * log10f(x) / l + p0 + p0 - 1.f)
			: 1.f - 20.f / l * log10f(x));
}

Which one to use

The case we have examined is admittedly more problematic than usual. E.g., other mappings that are approximately logarithmic in nature, such as those related to frequencies, do not usually need to go to 0. However, I hope you can take away that these details have a concrete impact on user experience, even if the user is not aware of that. Dealing with them properly improves the quality perception of a product.

The impelementations shown here are generic but far from being optimal. You'll hopefully never see something like this in actual DSP engines. They can be improved by pre-computing constant expression parts, and then they can be sped up using either fast math routines, such as those included in Brickworks, or by storing and interpolating lookup tables. BTW, do not blindly trust performance tests when dealing with lookup tables, as cache locality is very different in test programs when compared to actual production code.

That said, choosing which of these alternatives is better depends on the use case, as usual. In short, I'd choose as follows:

  1. if sticking to dB scale is not important and inverse mapping is not needed/relevant, I'd go for a \(p^n\) mapping, and possibly with an integer \(n\);
  2. otheriwse, in similar scenarios where inverse mapping is needed and/or you want to stick to basic arithemtic operations, I'd use a rational mapping;
  3. otherwise, if performance is less of a concern but you want to stay as branchless (and hence deterministic) as possible, I'd go for an exponential mapping;
  4. finally, if sticking to the dB scale is the major concern, the power + dB mapping would be the way to go.

On digital hardware

When doing DSP on hardware, you most likely have to deal with linear taper potentiometers whose position is read by means of ADCs. In the real world you have a region of linearity you can rely on and, if possible, you should perform some form of calibration to exploit it fully. Visually:

Furthermore, ADC readings will be affected by noise, so they will constantly change. I bet you don't want to update all coefficients linked to parameters all the time, hence you should store previous values and discard changes below the noise threshold (this is conceptually similar to button debouncing, but on the "quasi-continuous" scale).

In order to still be able to reach extremes, I'd suggest to restrict further the useful range as shown (\(\epsilon\) is the noise threshold):

You can then remap by:
\[ \widehat{f}(p)=\frac{{\rm clamp}(f(p),f\left(p_1\right),f\left(p_2\right))-f\left(p_{1}\right)-\epsilon}{f\left(p_2\right)-f\left(p_1\right)-2\epsilon} \]

Comments