Quick stereo pan question

Very quickly, I would love some expertise regarding a simple question:

How to play a chord with 3 voices with voice 1 panning hard left in the stereo field, voice 3 panning hard right, and voice 2 mixed evenly across both channels & in the center (i.e. Pan2 with a zero position).

To demonstrate, using a Saw wave with three separate voices & cycle rates: [432, 480, 528]

The question being our best method or practice for having 432 in position -1, 528 in position 1, and 480 in position 0.

?

Mix

([
	Pan2.ar(Saw.ar(432), -1)[0], 
	
	Mix(Pan2.ar(Saw.ar(480), 0)),
	
	Pan2.ar(Saw.ar(528), 1)[1]
])

This results in a Sum3 or a single channel of audio… which isn’t my intent.

I’d want to be able to have 3, 4, or 5 voices in a chord, with full control over each voice’s position in the stereo field.

The use of Pan2 being perhaps ideal, with it’s perfect compensation for balancing stereo levels.

If I can trust my ears, the following all have the same result:

{Splay.ar(SinOsc.ar(([59.5, 63.5, 63.86, 68.5]).midicps))*0.2}.play;

{SinOsc.ar([59.5, 63.5, 63.86, 68.5].midicps, 0, 0.1).collect{|sine, i| Pan2.ar(sine, i.linlin(0,3,-1,1))}.sum}.play;

{Pan2.ar(SinOsc.ar(([59.5, 63.5, 63.86, 68.5]).midicps), [-1,-0.33, 0.33, 1]).sum*0.1}.play;

Thanks a million.

Probably, Pan2 documentation should include the way of dealing with multichannel input signals. For the non-programmers or musicians, detailed information similar to the following examples might be helpful.

// ex. 1:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], 0.5).scope * 0.02 }.play
// wrong.

// ex. 2:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], SinOsc.kr(0.1, [0, 1/3, 2/3]*2pi)).scope * 0.02 }.play
// wrong. 

// the reason for wrongness: Pan2 distributes mono input channel to two output channels. 

// Remember that output channel three is available only when the server is booted with an appropriate server option like the following example: 
// s.options.numOutputchannel = 3;

// The following five examples show how the individual channels react with Pan2.ar.

// ex. 3-1. the first channel of a three channel input:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], [ SinOsc.kr(0.1), 0.5, 0.5]).scope * 0.02}.play

// ex. 3-2. the second channel of a three channel input:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], [0.5, SinOsc.kr(0.1), 0.5]).scope * 0.02 }.play

// ex. 3-3. the third channel of a three channel input:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], [0.5, 0.5, SinOsc.kr(0.1)]).scope * 0.02 }.play

// ex. 3-4. the first channel of a two channel input:
{ Pan2.ar(SinOsc.ar([220, 880]), [-1, 1], [ SinOsc.kr(0.1), 0.5]).scope * 0.02}.play

// ex. 3-5. the second channel of a two channel input:
{ Pan2.ar(SinOsc.ar([220, 880]), [-1, 1], [0.5, SinOsc.kr(0.1)]).scope * 0.02 }.play


// However, adding ".sum" to the output of Pan2.ar can fix this. 

// ex. 4:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], 0.5 ).sum.scope * 0.02 }.play
// works!

// ex. 5:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], SinOsc.kr(0.1, [0, 1/3, 2/3]*2pi)).sum.scope * 0.02 }.play
// works!

// ex. 6-1. automated example to inspect the output of input channel 1:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], [1, 0, 0] * SinOsc.kr(0.1, [0, 1/3, 2/3]*2pi)).sum.scope * 0.02}.play

// ex. 6-2. automated example to inspect the output of input channel 2:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], [0, 1, 0] * SinOsc.kr(0.1, [0, 1/3, 2/3]*2pi)).sum.scope * 0.02 }.play

// ex. 6-3. automated example to inspect the output of input channel 3:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), [-1, 0, 1], [0, 0, 1] * SinOsc.kr(0.1, [0, 1/3, 2/3]*2pi)).sum.scope * 0.02 }.play

// ex. 7. mouse control example for a 3-channel input signal:
{ Pan2.ar(SinOsc.ar([220, 880, 3520]), MouseX.kr([-1, -0.3, 1], [1, 0.3, -1])).sum.scope * 0.02 }.play

// ex. 8. mouse control example for a 2-channel input signal:
{ Pan2.ar(SinOsc.ar([880, 5500])* 0.01, MouseX.kr([-1, 1], [1, -1])).sum }.play

// Please consider to use Balance2.ar for 2-channel inputs and Splay.ar for multichannel inputs.
1 Like

Excellent, very impressive.

So far, this is the best solution:

scope

{
	sum
	
	(
		Pan2.ar

		(
			in: Saw ar: [54, 432, 1728], 
			
			pos: [-1, 0, 1], 
			
			level: 0.5
		)
	)
}

Without the use of sum, it is a distinct 3-channel output, returning:

[ [ an OutputProxy, an OutputProxy ], [ an OutputProxy, an OutputProxy ], [ an OutputProxy, an OutputProxy ] ]

With the use of sum, it returns:

[ a BinaryOpUGen, a BinaryOpUGen ]

Is there anything else anyone cares to add?

[(f: 54, p: -1), (f: 432, p: 0), (f: 1728, p: 1)]
.collect {  |a| Pan2.ar( Saw.ar( a.f ), a.p ) }
.sum

Bit overkill… But if you have a lot of argument breaking each voice down as an event can be really helpful. Also it follows the way you phrased the question…

2 Likes

[(f: 54, p: -1), (f: 432, p: 0), (f: 1728, p: 1)]
.collect { |a| Pan.ar( Saw.ar( a.f ), a.p ) }
.sum

I think we can reduce typing if we here use arrays instead of events as follows:

[[54, -1], [432, 0], [1728, 1]]
.collect {  |a| Pan.ar( Saw.ar( a[0] ), a[1] ) }
.sum

However, events would be better if the code got more extensive than this example.

By the way, I have found an inconsistency in further testing the multichannel input behaviour of Pan2.ar. In the following example, .sum should not be added:

(
// ex. 9:
{ Pan2.ar(
	[WhiteNoise.ar, SinOsc.ar],
	MouseX.kr(-1, 1)*[1, -1],
	(MouseX.kr(0, 1) - [1, 0]).abs * 0.1
)
}.play
)

By adding .sum, we hear SinOsc in the left channel when the mouse is in the right portion of the screen:

(
// ex. 10:
{ Pan2.ar(
	[WhiteNoise.ar, SinOsc.ar],
	MouseX.kr(-1, 1)*[1, -1],
	(MouseX.kr(0, 1) - [1, 0]).abs * 0.1
).sum
}.play
)

This result could prove that my examples with explanations in the post (Quick stereo pan question - #5 by prko) are not logical and should be considered bad examples. I am perplexed. I can understand the phenomenon if it occurs when the mouse pointer is at the utmost right side of the screen. Could someone explain it to me?

.sum is an instance method for Collection

[
	[-1, 1],
	[0, 0],
	[1, 1]
]
.sum 

//  [0, 2]

MouseX.kr(-1, 1)*[1, -1] // -> [ a MouseX, an UnaryOpUGen ]

This makes more sense:

{ 
	Pan2.ar
	(
		in: [WhiteNoise.ar, SinOsc.ar],
		pos: [MouseX.kr(-1, 1), MouseX.kr(1, -1)],
		level: 1
	)
	.sum
}.scope

There is no problem with this formulation (and it makes perfect sense).

FWIW, this is the behavior I’d expect! Because, if the mouse is full right, then your example pans the noise full right, and the sinewave full left.

I think what is happening in the non-sum example is this:

  • Pan2 gets two-item arrays, so it multichannel-expands to two Pan2’s.
  • Each Pan2 produces two channels: [ [ an OutputProxy, an OutputProxy ], [ an OutputProxy, an OutputProxy ] ].
  • Out handles arrays by assigning each item to subsequent buses (not the same bus). This is to support usages like Out.ar(bus, stereoPair).
    • So the first array pair ends up in bus 0.
    • And the second array pair ends up in bus 1.
  • To handle the multiple inputs, Out needs to multichannel expand. So the first Pan2 collapses down to one channel (left) and the second collapses down to the next channel (right).

I’m not at the computer but I think this is it.

Then it’s mono WhiteNoise → stereo-panned → collapsed back down to mono, and mono SinOsc → stereo-panned → collapsed back down to mono. Two problems: 1/ wasting operations and 2/ when the mouse is full right, SinOsc remains in the right channel – contradicting the pan value of -1! There’s no point in panning it left if you wanted it on the right.

I believe that in the vast majority of cases of multiple channels being independently panned, the correct usage is to mix down the panned results before outputting to any buses.

hjh

1 Like

The “sine wave” you may be hearing could be a subtle ringing in the ears, or the hum of surrounding electricity.

Hearing it within white noise could be an auditory illusion, while also being visually imperceptible.

I recommend using a saw wave, instead of white noise.

Yep sine waves are prone to false localization even when coming from a single source due to interaction with the room / head. When coming from multiple sources (ie panned) you will get peaks and troughs distributed through space due to interference.

Thank you very much!
I understand now why it happens.

It was my misconception!

The following statement of mine is also wrong:

By the way, I have found an inconsistency in further testing the multichannel input behaviour of Pan2.ar.

It is consistent. I lost my logic in the last example:

(
// ex. 10:
{ Pan2.ar(
[WhiteNoise.ar, SinOsc.ar],
MouseX.kr(-1, 1)*[1, -1],
(MouseX.kr(0, 1) - [1, 0]).abs * 0.1
).sum
}.play
)

It should be as follows:

(
// ex. 11:
{ Pan2.ar(
	[WhiteNoise.ar, SinOsc.ar],
	MouseX.kr(-1, 1),
	(MouseX.kr(0, 1) - [1, 0]).abs * 0.1
).sum
}.play
)

I think examples 7 and 8 are somewhat redundant, and example 11 could be used with the other examples from 1 to 6.

Thanks again!

One more below for your collection.

Array expansion is a kind of matrix transposition:

[[1, 3], [5, 9]].multiChannelExpand == [[1, 5], [3, 9]]

Since Out is summing, when it expands it’s a transposition and then a sum:

[[1, 3], [5, 9]].multiChannelExpand.sum == [4, 14]

which is different from just a sum

[[1, 3], [5, 9]].sum == [6, 12]

To rewrite a graph with multiple Out’s to have one Out you need to do the transpose yourself:

Pan2.ar([PinkNoise.ar, SinOsc.ar]) * 0.1 // noise left, sine right

Pan2.ar([PinkNoise.ar, SinOsc.ar]).flop.sum * 0.1 // noise left, sine right

Pan2.ar([PinkNoise.ar, SinOsc.ar]).sum * 0.1 // stereo noise, stereo sine

(flop is a synonym for multiChannelExpand)

1 Like

Someone probably already pointed out that it should be Pan2 and not Pan, but I really like this way of using events as structures with named properties instead of purely positional tuples. This seems a sweet spot between readability and ease of typing (for e.g. live coding). Thanks for this great idea!

1 Like