Function for both M/S and L/R panning

Hello everybody,

I wrote a very simple function for doing both mid/side and left/right panning on a signal (see below). As I’m fairly new to SuperCollider, I wonder whether this is a good way to do it? Or would there be a more efficient way not requiring the use of two Balance2 uGens?

// Define the function
(
~panWidth = {|sig, msBalance=0, lrBalance=0|
	sig = Balance2.ar(sig[0] + sig[1], sig[0] - sig[1], msBalance);
	sig = Balance2.ar(sig[0] + sig[1], sig[0] - sig[1], lrBalance);
};
)


// Two simple examples:

// Mid only, panned center (lrBalance argument left at default)
play { a = SinOsc.ar([88,89]) * 0.1; ~panWidth.(a, -1) }

// Side only, panned 50% left
play { a = SinOsc.ar([88,89]) * 0.1; ~panWidth.(a, 1, -0.5) }

Hi,

Don’t quite know what you are trying to achieve here. Could you elaborate?
If you wanted to boost or cut either the side or mid I’d do something like this:

~msBal = {
    |lr, dbInput|
    var db = (\midDb: 0, \sideDb: 0) ++ dbInput;
    var amps = [db.midDb, db.sideDb].dbamps;
    var msIn = [lr[0] + lr[1], lr[0] - lr[1]];
    var msOut = msIn * amps * -6.dbamp; // -6 is needed to compensate for the ms conversion.
    [msOut[0] + msOut[1], msOut[0] - msOut[1]]; 
};

// examples

x = {
    var lr = Pan2.ar(PinkNoise.ar(-15.dbamp), LFNoise2.kr(1));
    var msBalanced = ~msBal.(lr, (\sideDb: 5)); // boost side by 5db
    //var msBalanced = ~msBal.(lr, (\midDb: -5)); // cut mid by -5db
    //var msBalanced = ~msBal.(lr, (\midDb: -15, sideDb: 2.5)); // cut mid by -15db and boost side by 2.5db
    msBalanced
}.play;

Just a thing with the function you have written, it doesn’t return anything and you should refrain from modifying the inputs. It will make things easier in the long run if you avoid these two things.

Don’t worry about efficiency, not only will this sort of thing will have near zero impact, but you shouldn’t even worry about efficiency until the code is too slow.

J

1 Like

Hey Jordan,

thank you very much for your reply!

What I wanted to achieve is a simple M/S and L/R control: balancing mids and sides and the left and right channel with one simple bipolar argument each. (Something akin to pan and width knobs in a DAW mixer.) I thought of it as a building block I could put e.g. on top of a SynthDef to easily call later.

Concerning the function, I thought it did return the panned signal, since that is the last line of the function? I could have also written it as:

(
~panWidth = {|sig, msBalance=0, lrBalance=0|
	sig = Balance2.ar(sig[0] + sig[1], sig[0] - sig[1], msBalance);
	Balance2.ar(sig[0] + sig[1], sig[0] - sig[1], lrBalance);
};
)

Of course I could also replace

sig = Balance2.ar(sig[0] + sig[1], sig[0] - sig[1], msBalance);
...

with something like

var panSig = Balance2.ar(sig[0] + sig[1], sig[0] - sig[1], msBalance);
Balance2.ar(panSig[0] + panSig[1], panSig[0] - panSig[1], lrBalance);

to create a local variable. Is this better practice? But then, does my first example really have a side effect on the input besides the return value? (Sorry for being quite a progamming noob.)

Thank you also for your example, which gives of course more detailed m/s control and seems to be quite useful.

Best,
Max

To follow up: maybe this is cleaner code for a simple M/S balance function (leaving L/R pan out since that is already covered in some basic uGens):

(
~midside = {|in, msBalance=0|
	var sig = Balance2.ar(in[0] + in[1], in[0] - in[1], msBalance);
	[sig[0] + sig[1], sig[0] - sig[1]]
};
)

// Simple examples:

// Mid 75%
play { a = SinOsc.ar([88,88.5]) * 0.1; ~midside.(a, -0.75) }

// Side only (silent on mono speaker)
play { a = SinOsc.ar([88,88.5]) * 0.1; ~midside.(a, 1) }

As an FYI, you might also like to enjoy something I put together some time ago: Classic Stereo Imaging Transforms—A Review

1 Like

Thank you, I’ll look into this!

It does return. Every expression in SC has a result, including assignment statements (which return the value being assigned into the variable).

It’s indeed a bit pointless to assign to a local variable, then immediately return from the function (destroying the variable just assigned to). But the caller will still get the value.

Conversion from mid-side to left-right this way will double the amplitude:

mid = inLeft + inRight
side = inLeft - inRight

outLeft = mid + side = inLeft + inRight + inLeft - inRight = 2 * inLeft
outRight = mid - side = inLeft + inRight - inLeft + inRight = 2 * inRight

So the last stage should be 0.5 * Balance2.ar(...). (Or the first stage.)

I’m curious to double check the Balance2 formula – I suspect that your “75% mid” example may not actually work out to be 75% mid, 25% side. It may still be a useful mapping but I wouldn’t assume casually that a pan value that is 7/8 of the total range (pan range is -1 to +1, spanning two units, not one) toward the -1 side would match up to a 75-25 split.

EDIT: The Balance2 formula, by the way, is: if p is the pan value given to Balance2, then scale -1…+1 onto 0 to pi/2 = x = (p + 1) * 0.25pi. Then the left amplitude is cos(x) and the right is sin(x).

The Balance2 help file is incorrect about this point. It claims that it’s using the standard equal-power panning curve, which is the square root of a linear segment between 0 and 1. sin() can approximate this (in the middle, sqrt(0.5) == sin(pi/4)), so SC saves CPU cycles by reading the amplitude from the SinOsc wavetable instead of computing square roots.

Unfortunately it means that when I worked out how to calculate the panning value for a given ratio between channels, for the sqrt formula, it didn’t actually work with Balance2.

I don’t remember enough trigonometry to figure it out. I got as far as sin(x) = (r / (1-r)) * cos(x) (I’m not confident of this, though, better derive it again from scratch) where r is the ratio of the right channel vs the total, but I have other things to do, so I need to stop there. I guess you could square it and then exploit sin2 + cos2 = 1 somehow. (Actually I’m pretty sure that would work but I’m out of time.)

hjh

OK… partly for fun, partly as a nice math exercise… I was curious what it would take to get Balance2 to weight the signal 75% to the left.

Because pan values increase to the right, it’s a bit easier to think of weighting the signal some percentage to the right. 75% left = 25% right, so we’ll use 0.25.

What is “25% to the right”? It means the volume allotted to the right channel is 1/4 of the total volume of both channels. If a = left channel volume and b = right channel volume, this ratio r is

r = b / (a+b)

Then:

ra + rb = b
b - rb = ra
b(1-r) = ra
b = (r/(1-r)) * a

That ‘r’ thing will be inconvenient to carry around, so let q = r / (1-r). (Also notice that r == 1 will be a problem – easy to handle as a special case, but worth noting.)

From the earlier post, Balance2 uses cos(x) as a falling function for the left channel fadeout as x rises, and sin(x) as a rising function for the right channel. So a = cos x and b = sin x. ‘x’, for now, is in radians, ranging 0 to 0.5pi – this is 0, up to the crest of a sine wave.

b = q * a
sin x = q * cos x

It would be helpful to get cos x in terms of sin x. There’s a trigonometric identity sin2 x + cos2 x = 1, so cos2 x = 1 - sin2 x. First square:

(sin x)^2 = q^2 * (cos x)^2

Then:

(sin x)^2 = q^2 * (1 - (sin x)^2)
(sin x)^2 = q^2 - q^2 (sin x)^2
(sin x)^2 + q^2 (sin x)^2 = q^2
(sin x)^2 * (1 + q^2) = q^2
(sin x)^2 = q^2 / (1 + q^2)

Unsquare:

sin x = sqrt(q^2 / (1 + q^2)) = q / sqrt(1 + q^2)

So the angle x should be the arcsin of q / sqrt(1 + q.squared). Again, this is 0 to 0.5pi. We need to map this onto a bipolar panning range for Balance2. Target range 2 / source range 0.5pi = 4/pi so first do x / 0.25pi, then add subtract 1.

(
f = { |r|
	var q;
	r = r.clip(0, 1);
	if(r == 1) {
		1
	} {
		q = r / (1 - r);
		asin(q / sqrt(1 + q.squared)) / 0.25pi - 1
	}
};
)

So the pan value for Balance2, which should result in a 75-25% split, is about -0.590334 (which is rather far from -0.75).

Let’s test:

(
a = {
	var unity = DC.kr(1);
	var trig = Impulse.kr(0);
	var stereo = Balance2.kr(unity, unity, f.(0.25)).poll(trig);
	(stereo[1] / stereo.sum).poll(trig);
	FreeSelf.kr(trig <= 0);
	Silent.ar(1)
}.play;
)

UGen Array [0]: 0.948804
UGen Array [1]: 0.315866
UGen(BinaryOpUGen): 0.249761

So the quotient of the right channel volume over the total is, allowing for rounding error, certainly close enough to the desired ratio = 0.25.

That was a bit deeper dive than I expected – but a useful bit of math to have around.

hjh

1 Like

That’s how I understood it. Also something like (to give a trivial example)

x=5; {|y| y=y+1; y.squared}.(x);

does not change the value of x, except when written as

x = 5; x = {|y| y = y.squared}.(x);

That’s why I thought assigning different values directly to an argument within the function (instead of creating a local variable) is valid, as with “sig” in my original example. In my understanding, a side effect would only be involved once I use an environmental variable like ~sig inside the function instead. (But please correct me if I’m wrong.)

Thank you, this is valuable information! And I appreciate the deep dive into the mathematics of it all. Obviously my understanding of what Balance2 does was far too simplistic – I shall experiment some more with the whole approach.

It shouldn’t be expected to change x.

Variables are never passed by reference in SuperCollider. There’s no case in SC where you are ever passing a variable. Instead, it passes the value of the variable at that moment.

The only thing y knows is that it’s gotten the value 5 from somewhere. After that, y is strictly local to the function; you can do anything you like to it, and there are no side effects outside of the function.

Consider x=5; {|y| y=y+1; y.squared}.(x+1); – then imagine an arbitrarily complex expression in the argument list.

Objects are passed by reference. But variables aren’t objects – they’re only references to objects. So all variables must resolve to objects before anything is done with them.

hjh

1 Like

Thank you for the clarification! That’s what I thought originally. (But I got a bit confused by the remark that my proposed function would not return anything but work via side effects instead.)