Ambisonics Toolkit for panning multichannel signal to stereo

hey, i have this multichannel signal distributed round robin to 4 or 5 channels (center + 4 channels) with pan positions for PanAz.
At the moment im using these 4 or 5 input channels (center + 4 channels) into PanAz with 2 output channels.

(
var timingInformation = { |maxOverlap, trig, grainRate|
	var rate = if(trig.rate == \audio, \ar, \kr);
	var arrayOfTrigsAndPhases = maxOverlap.collect{ |i|
		var localTrig = PulseDivider.perform(rate, trig, maxOverlap, i);
		var hasTriggered = PulseCount.perform(rate, localTrig) > 0;
		var localPhase = Sweep.perform(rate, localTrig, grainRate * hasTriggered);
		[localTrig, localPhase];
	};
	var trigsAndPhasesArray = arrayOfTrigsAndPhases.flop;
	(\trigger: trigsAndPhasesArray[0], \phase: trigsAndPhasesArray[1]).postln
};

var channelMask = { |trig, channelMask, centerMask|
	var channelsArray = [
		Dser([-0.25], channelMask),
		Dser([0.25], channelMask),
		Dser([0.75], channelMask),
		Dser([-0.75], channelMask),
		Dser([0], centerMask)
	];
	Demand.ar(trig, 0, Dseq(channelsArray, inf)).lag(0.001);
};

var decoder = FoaDecoderMatrix.newStereo((131/2).degrad, 0.5);
var encoder = FoaEncoderMatrix.newOmni;

{
	var maxOverlap = 4;
	var tFreq = \tFreq.kr(100);
	var trig = Impulse.ar(tFreq);
	var grainFreq = \grainFreq.kr(400);
	var grainRate = grainFreq / \overlap.kr(4);

	var timings = timingInformation.(maxOverlap, trig, grainRate);
	var pan = channelMask.(timings.trigger, \channelMask.kr(1), \centerMask.kr(0));

	var grainWindow = (1 - (timings.phase * 2pi).cos) / 2 * (timings.phase < 1);

	var sig = SinOsc.ar(grainFreq);

	sig = sig * grainWindow;

	sig = PanAz.ar(2, sig, pan);
	
/*
	sig = FoaEncode.ar(sig, encoder);
	sig = FoaTransform.ar(sig, 'pushX', LFNoise2.kr(0.1).linlin(-1, 1, pi/2, 0));
    sig = FoaTransform.ar(sig, 'rotate', LFSaw.kr(0.1).linlin(-1, 1, pi, -pi));
    sig = FoaDecode.ar(sig, decoder);
*/
	
}.plot(0.1);
)

I would like to know how to setup a First Order Ambisonic System with Encoder, Transformer and Decoder so i could use this with my stereo setup at home but could also scale the output channels later for a multichannel speaker setup instead.

what Encoder should i choose and what are the correct panning positions for the 5 panning positions?

thanks :slight_smile:

decoder

For performance

//.... figure this out, its the angle of the speakers on the right hand side of the room.
var right_hand_angles_of_speakers_radians = [0.2, 1.2]; 
var dec = FoaDecoderMatrix.newDiametric(right_hand_angles_of_speakers_radians, 'single');

For Stereo

var dec = FoaDecoderMatrix.newStereo();

Also you don’t have to define these out of line unless you use the Kernal variants.
This is fine: FoaDecode.ar(sig, FoaDecoderMatrix.newDiametric(angles_of_speakers_radians))

encoder

I don’t really understand what you want to do,
At this line: sig = sig * grainWindow * 0.1; you have a 4 channel signal.
At this line: sig = PanAz.ar(2, sig, pan) you still have a 4 channel signal.
How are they positioned in space? Here are some options…

each channel is a speaker

This doesn’t really work in ambisonics as you’d expect, but here it is…

var angles_of_speakers_radians = [0.2, 1.2, -0.2, -1.2]; 
var enc = FoaEncoderMatrix.newDirection(angles_of_speakers_radians);

each channel comes from a unique equally spaced direction in a 2D circle

var enc = FoaEncoderMatrix.newPanto(4);

each channel has a unique position

{
	var maxOverlap = 4;
	var tFreq = \tFreq.kr(100);
	var trig = Impulse.ar(tFreq);
	var grainFreq = \grainFreq.kr(400);
	var grainRate = grainFreq / \overlap.kr(4);

	var timings = timingInformation.(maxOverlap, trig, grainRate);
	var pan = channelMask.(timings.trigger, \channelMask.kr(1), \centerMask.kr(0));

	var grainWindow = (1 - (timings.phase * 2pi).cos) / 2 * (timings.phase < 1);

	var sig = SinOsc.ar(grainFreq) * grainWindow * 0.1;
	
	var pans = \pans.kr([-1, 0, 1, pi]);
	var ambisArray = [sig, pans].flop.collect{ |a|
		var chan = a[0];
		var pan = a[1];
		FoaPanB.ar(chan, pan)
	};
	var ambi = Mix.ar(ambisArray);

    FoaDecode.ar(ambi, dec);
	
}.play;

hey, thanks for your detailed reply. Im going through your examples right now.

with my example i sum the output after the PanAz, i left it out so you could see the distribution around the 4 channels.

(
var timingInformation = { |maxOverlap, trig, grainRate|
	var rate = if(trig.rate == \audio, \ar, \kr);
	var arrayOfTrigsAndPhases = maxOverlap.collect{ |i|
		var localTrig = PulseDivider.perform(rate, trig, maxOverlap, i);
		var hasTriggered = PulseCount.perform(rate, localTrig) > 0;
		var localPhase = Sweep.perform(rate, localTrig, grainRate * hasTriggered);
		[localTrig, localPhase];
	};
	var trigsAndPhasesArray = arrayOfTrigsAndPhases.flop;
	(\trigger: trigsAndPhasesArray[0], \phase: trigsAndPhasesArray[1]).postln
};

var channelMask = { |trig, channelMask, centerMask, panMax|
	var channelsArray = [
		Dser([-0.25], channelMask),
		Dser([0.25], channelMask),
		Dser([0.75], channelMask),
		Dser([-0.75], channelMask),
		Dser([0], centerMask)
	];
	Demand.ar(trig, 0, Dseq(channelsArray, inf) * panMax).lag(0.001);
};

{
	var maxOverlap = 4;
	var tFreq = \tFreq.kr(5);
	var trig = Impulse.ar(tFreq);
	var grainFreq = \grainFreq.kr(400);
	var grainRate = grainFreq / \overlap.kr(1);

	var timings = timingInformation.(maxOverlap, trig, grainRate);
	var pan = channelMask.(timings.trigger, \channelMask.kr(1), \centerMask.kr(1), \panMax.kr(0.8));

	var grainWindow = (1 - (timings.phase * 2pi).cos) / 2 * (timings.phase < 1);

	var sig = SinOsc.ar(grainFreq);

	sig = sig * grainWindow * 0.1;

	sig = PanAz.ar(2, sig, pan);
	
	sig.sum;
	
}.play;
)

currently i have 5 pan positions (center + 4 channels) and you can enable and disable them via \channelMask.kr and \centerMask.kr and also \panMax.kr for stereo wideness.
I would when possible keep this functionalty to implement the panning in the synthesis rather then afterwards on the stereo signal.
So i think there should be 5 point sources / speakers, where one of them is center front and each channel of the multichannel signal should be send to one of them in a sequence i specify with channelMask.

@jordan has already given a fairly verbose reply… I’ll summarize by noting that you have a lot of choice.

My advice would be to encode to (first order) B-format via FoaEncoderMatrix:*newDirections. You can then specify each of the incident directions of your four or five inputs. Given your use case, you can pass the directions argument a single rank array of angles (radians). 0 is front center, 0.5pi is hard left, 0.5pi.neg is hard right, and so on. (The ATK uses the classic Ambisonic convention of counter-clockwise angles. For the geeks among us, this ends up matching the signs/polarity of the various channel coefficients.)

Also, as @jordan mentions, you can use FoaDecoderMatrix:*newStereo to then monitor.


For further reading, you may like to review the Ambisonic Enlightenment tutorial. The section on Panorama Laws compares pairwise panning via PanAz with the optimized (anti-aliased!) panning laws returned by Ambisonics.

i think maxOverlap should be 5 and the center channel be handled as a specific speaker, so the encoder should have 5 channels of audio, so i mean 5.1.
This specific case for the center channel playing audio on both speakers in a stereo setup is just a side effect of me never using ambisonics before.

thanks for your help im going through the resources right now, especially the Ambisonic Enlightenment tutorial

Im not sure if this is the right way to do it or conceptually wrong, but i think i get the result i was looking for. maybe there is room for adjustment. let me know what you think.
i have used FoaEncoderMatrix.newDirections with an Array of [0, 0, 0, 0, 0]; and then i use FoaTransform rotate where the angle comes from the channelMask. This way you can adjust the \channelMask.kr, \centerMask.kr and \panMax.kr like i wanted them to behave. But i had to use trig instead of timings.trigger inside of the channelMask which is a bit of a headscratcher to me.

(
var timingInformation = { |maxOverlap, trig, grainRate|
	var rate = if(trig.rate == \audio, \ar, \kr);
	var arrayOfTrigsAndPhases = maxOverlap.collect{ |i|
		var localTrig = PulseDivider.perform(rate, trig, maxOverlap, i);
		var hasTriggered = PulseCount.perform(rate, localTrig) > 0;
		var localPhase = Sweep.perform(rate, localTrig, grainRate * hasTriggered);
		[localTrig, localPhase];
	};
	var trigsAndPhasesArray = arrayOfTrigsAndPhases.flop;
	(\trigger: trigsAndPhasesArray[0], \phase: trigsAndPhasesArray[1]);
};

var channelMask = { |trig, numChannels, channelMask, centerMask, panMax|
	var rate = if(trig.rate == \audio, \ar, \kr);
	var panChannels = Array.series(numChannels, -1 / numChannels, 2 / numChannels).wrap(-1.0, 1.0);
	var panPositions = panChannels.collect { |pos| Dser([pos], channelMask) };
	panPositions = panPositions ++ Dser([0], centerMask);
	panChannels.debug(\panChannels);
	Demand.perform(rate, trig, 0, Dseq(panPositions, inf) * panMax).lag(0.001);
};

var hanningWindow = { |phase, overlap|
	var windowRate = phase / overlap;
	(1 - (windowRate * 2pi).cos) / 2 * (windowRate < 1);
};

SynthDef(\ambisonics_test, {
	var numChannels = 5;
	var tFreq = \tFreq.kr(1);
	var trig = Impulse.ar(tFreq);
	var grainFreq = \freq.kr(440);

	var timings = timingInformation.(numChannels, trig, grainFreq);
	var pan = channelMask.(trig, numChannels - 1, \channelMask.kr(1), \centerMask.kr(1), \panMax.kr(1));
	var grainWindow = hanningWindow.(timings.phase, \overlap.kr(1));
	var sig = sin(timings.phase * 2pi) * grainWindow * \amp.kr(0.25);

	sig = FoaEncode.ar(sig, FoaEncoderMatrix.newDirections(Array.fill(numChannels, 0)));
	sig = FoaTransform.ar(sig, 'rotate', pan * pi);
    sig = FoaDecode.ar(sig, FoaDecoderMatrix.newStereo);

	Out.ar(\out.kr(0), sig);
}).add;
)

s.meter;

x = Synth(\ambisonics_test);

x.set(\panMax, 0.8);
x.set(\channelMask, 1);

x.free;

EDIT: okay, this seems to be similiar to @jordan example for each channel has a unique position maybe better to collect a number of FoaEncoderMatrix.newOmni here.

ive been investigating the ambisonics toolkit via its tutorials once more, could this be rewritten in the most generalized way with HoaEncodeDirection / HoaDecodeDirection while using channelMask to pan the different input channels per grain?

Hello @dietcv,

HoaEncodeDirection.ar is a traveling wave encoder, which encodes a mono source into HOA at a specified incidence, theta, phi, and radius. You can think of this as a mono panner.

From the help:

Discussion:

HoaEncodeDirection offers HOA encoding equivalent to FoaPanB followed by FoaProximity, in the first order case.

HoaDecodeDirection.ar is a single channel beam former. (Some people call this operation beaming.) Multichannel decoders are made up of a collection of single channel beam formers. E.g., if we want to decode to a quad array, we generate 4 beams designed for the purpose. Optimal / correct beam forming for decoding to arrays is not super trivial, except in the case where the array is evenly sampled via a spherical design. (Here’s SphericalDesign.)

However, if we just want a single beam, you can think of it as a spatial bandpass filter into the HOA soundfield, HoaDecodeDirection.ar is the tool for you!

From the help:

Discussion:

HoaDecodeDirection offers HOA decoding equivalent to FoaNFC followed by FoaDecode where FoaDecoderMatrix: *newMono is the supplied decoder, in the first order case.

Hi,

getting a little off topic here…

do you know if this …

A spherical design , part of combinatorial design theory in mathematics, is a finite set of N points on the d -dimensional unit d-sphere Sd such that the average value of any polynomial f of degree t or less on the set equals the average value of f on the whole sphere (that is, the integral of f over Sd divided by the area or measure of Sd ). Such a set is often called a spherical t -design to indicate the value of t , which is a fundamental parameter.

…basically means evenly spaced around the sphere? If not, can you describe it in far simpler terms?

Opportunities to travel down the spherical rabbit hole:

Yes, the short answer is uniform sampling of the surface of the sphere.

1 Like

This seems to be a bit over my head, probably i will stick with PanAz for now.