Array size as argument within SynthDef

Hi!

Something I’ve been wondering about for a while. Why can’t an array’s size be evaluated dynamically as an argument in a SynthDef, and what would be a workaround?

this fails :

SynthDef.new(\superSaw1, {|out=0, numVoices=10, gate=1|
	
	var voices;
	voices = Array.fill(numVoices, { LFSaw.ar(36.midicps*Rand(1-0.1, 1+0.1), 0, 0.1) });
	
	Out.ar(out
		, Splay.ar(voices, 0.5)
		* EnvGen.kr(Env.asr(0.1, 1, 0.1, #[-1.5, 2]), gate:gate, doneAction: 2);
	) 
}).send(s);

Many thanks!
-j

Im sure there is someone who can explain that way better then me, but the SynthDef graph is fixed with SynthDef evaluation. This means that the number of Ugens cant be changed dynamically.

But why not have some arguments for mix and detune and collect some frequencies to play a chord:


(
var superSaw = { |freq, mix, detune|
	var detuneCurve = { |x|
		(10028.7312891634 * x.pow(11)) -
		(50818.8652045924 * x.pow(10)) +
		(111363.4808729368 * x.pow(9)) -
		(138150.6761080548 * x.pow(8)) +
		(106649.6679158292 * x.pow(7)) -
		(53046.9642751875 * x.pow(6)) +
		(17019.9518580080 * x.pow(5)) -
		(3425.0836591318 * x.pow(4)) +
		(404.2703938388 * x.pow(3)) -
		(24.1878824391 * x.pow(2)) +
		(0.6717417634 * x) +
		0.0030115596
	};
	var centerGain = { |x| (-0.55366 * x) + 0.99785 };
	var sideGain = { |x| (-0.73764 * x.pow(2)) + (1.2841 * x) + 0.044372 };
	var center = SawDPW.ar(freq, Rand(-1, 1));
	var freqs = [
		(freq - (freq * (detuneCurve.(detune)) * 0.11002313)),
		(freq - (freq * (detuneCurve.(detune)) * 0.06288439)),
		(freq - (freq * (detuneCurve.(detune)) * 0.01952356)),
		(freq + (freq * (detuneCurve.(detune)) * 0.01991221)),
		(freq + (freq * (detuneCurve.(detune)) * 0.06216538)),
		(freq + (freq * (detuneCurve.(detune)) * 0.10745242))
	];
	var side = freqs.collect{ |freq| SawDPW.ar(freq, Rand(-1, 1)) }.sum;
	var sig = (center * centerGain.(mix)) + (side * sideGain.(mix));
	HPF.ar(sig, freq);
};

SynthDef(\superSaw, {
	var sig;
	sig = superSaw.(\freq.kr(440), \mix.kr(0), \detune.kr(0));
	sig = Pan2.ar(sig, \pan.kr(0));
	sig = sig * \amp.kr(-15.dbamp);
	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));
	Out.ar(\out.kr(0), sig);
}).add;
)

(
Routine {

	var freqs = [57, 60, 64, 65, 70].midicps;

	s.bind {
		freqs.collect{ |freq|

			Synth(\superSaw, [

				\freq, freq,
				\mix, 0.75,
				\detune, 0.75,

				\amp, -20.dbamp,
				\out, 0,

			]);

		};
	};

}.play;
)
1 Like

A synthdef is the instructions (set of operations) for how to make a synth.

If you change the number of channels, you need more operations (ugens), which results in a different synthdef.

The synthdef is send to the server as a static structure, you can’t change it, you can replace it though.

1 Like

A common approach is to run all the channels you’re going to need, and set the amplitudes of the ones you don’t want to 0. They’re running, but you don’t hear them, so the effect is that of a dynamically changing number of channels.

If you have 20 predefined channels, and n is the number you want to hear, multiply the channel array by (0..19) < n.

hjh

2 Likes

When i was starting to use SC, i also thought that i need that. But often times its more elegant to use a different approach, for example mixing in detuned Oscillators for a SuperSaw (see example above) or implementing filters for additive synthesis.

I think it would be nice to have a voice allocator Ugen for a fixed number of channels, but with the information about each channels current state, busy or free.

Maybe a different way to think about this:

In general, audio synthesis graphs with a fixed structure that doesn’t change (e.g. a fixed array number of oscillators or channels) can be optimized more - and, structural changes like changing the number of oscillators is very expensive compared to e.g. most DSP calculations. For both reasons, fixed graph structures are much more efficient in terms of CPU usage and latency.

The server node tree is non-fixed: you can add and remove nodes while its running, re-order things etc. This is much less efficient, and more likely to cause unexpected performance problems.

So: SynthDefs versus the tree of Nodes on the server is just the separation of the “fixed structure” parts of the graph from “non-fixed-structure” parts, since these two things have different implications and have to be handled in different ways. The question isn’t so much “why can’t you have dynamic structures inside a Synth”, because Synths are by definition the parts of the graph that are fixed.

This is a bit confusing because of some naming and implementation things:
The name “Synth” implies something like, you know, VST plugin - which gives a picture of how it should be used that isn’t REALLY it’s technical reason for its existence. And, doing routing and signal flow inside of a SynthDef is very easy to code, whereas routing between different Synths is more complex and clunky. One can imagine how this might feel different if “SynthDef” was named “FixedGraph” and there was another thing called “DynamicGraph” where you could route different FixedGraph objects together but WERE allowed to e.g. resize arrays. I would say this is the more “correct” semantic breakdown of these objects - the only problem is: “DynamicGraph” is actually implemented with: Synths, and Groups, and node ordering, and Buses, and bus mapping, and In.ar, and, and, and… The coding style and general behavior feels completely different than writing a SynthDef. This is in the end a design problem more than anything.

I think when people ask the perennial question “why can’t I have a dynamic array in a SynthDef”, the real underlying question in the background is: “Why can’t I write the dynamic parts of my signal chain the same way I write the fixed-structure parts in a SynthDef” - or maybe “Why is it such a pain to do routing for the dynamic parts of the server graph”. And the answer is just: this is a shortcoming of the SuperCollider class library - it’s fixable, it takes some work, some folks have made some special case improvements but there’s not really a general solution yet.

6 Likes

One important aspect is that SC does not support real multi-channel processing. Multi-channel expansion in sclang gives the illusion of multi-channel DSP, but under the hood it just creates multiple instances of the same UGen. Changing the number of channels effectively changes the underlying graph, that’s why we need different SynthDefs for different channel counts.

Pd and Max, on the other hand, have true multi-channel processing in the form of multi-channel signals. Although the DSP graph is fixed, the number of channels of each signal can be set dynamically, without altering the graph structure!

(Side note: in JMC’s new language that he presented at the SC symposium signals are really matrices, so they can contain multiple channels as well.)

If SC had actual multi-channel signals, we would be able to dynamically set the number of channels for individual Synth instances (of the same SynthDef). AFAICT there is no technical reason why this couldn’t be done, but it would require massive refactoring and probably wouldn’t make sense without big breaking changes…

(Another benefit of “true” multi-channel signal processing is that it enables new kinds of optimizations. For example, you can calculate multiple oscillators or filters in parallel with SIMD operations, something which simply isn’t possible with single-channel signals. Also, if the channel data is layed out in a single flat array, as is the case for Pd, you can vectorize across all channels.)

5 Likes

It would be possible for the class library to automate the bus routing. For some reason that I could never figure out, we have a strong cognitive bias toward using the low level server abstractions directly and have largely never paid any attention at all to the development of higher level abstractions. I never could quite figure out why. (My ddwPlug quark is a step in that direction, though without expandable multichannel use… I’m using it my live coding system but I don’t know if anyone else is using it.)

hjh

4 Likes

Thanks for the edifying and helpful replies! Will be extremely valuable to understand. I’ve been using SC for an embarrassingly long time to just be learning this…

This is great!
Would you mind explaining where the detuneCurve coefficients came from?

Very cool. Thanks again!
-j

The oversampling oscillators are a great addition to this:

var superSaw = { |freq, mix, detune|
	var detuneCurve = { |x|
		(10028.7312891634 * x.pow(11)) -
		(50818.8652045924 * x.pow(10)) +
		(111363.4808729368 * x.pow(9)) -
		(138150.6761080548 * x.pow(8)) +
		(106649.6679158292 * x.pow(7)) -
		(53046.9642751875 * x.pow(6)) +
		(17019.9518580080 * x.pow(5)) -
		(3425.0836591318 * x.pow(4)) +
		(404.2703938388 * x.pow(3)) -
		(24.1878824391 * x.pow(2)) +
		(0.6717417634 * x) +
		0.0030115596
	};
	var centerGain = { |x| (-0.55366 * x) + 0.99785 };
	var sideGain = { |x| (-0.73764 * x.pow(2)) + (1.2841 * x) + 0.044372 };
	var center = SawOS.ar(freq, oversample: 2);
	var freqs = [
		(freq - (freq * (detuneCurve.(detune)) * 0.11002313)),
		(freq - (freq * (detuneCurve.(detune)) * 0.06288439)),
		(freq - (freq * (detuneCurve.(detune)) * 0.01952356)),
		(freq + (freq * (detuneCurve.(detune)) * 0.01991221)),
		(freq + (freq * (detuneCurve.(detune)) * 0.06216538)),
		(freq + (freq * (detuneCurve.(detune)) * 0.10745242))
	];
	var side = freqs.collect{ |freq| SawOS.ar(freq, Rand(-1, 1), oversample: 2) }.sum;
	var sig = (center * centerGain.(mix)) + (side * sideGain.(mix));
	HPF.ar(sig, freq);
};
1 Like

I wasn’t aware of those! Are they available as a Quark package?

you can download the latest release here and save them as an extension: Releases · spluta/OversamplingOscillators · GitHub

Another possible workaround could be the following.

(
//make a Ndef and wrap it into a function
f={arg numVoices=10;
	Ndef(\superSaw1, {|gate=1|
		var voices = Array.fill(numVoices, { LFSaw.ar(36.midicps*Rand(1-0.1, 1+0.1), 0, 0.1) });
		var out = Splay.ar(voices, 0.5)* EnvGen.kr(Env.asr(0.1, 1, 0.1, #[-1.5, 2]), gate:gate, doneAction: 2);
		out 
	}).play;
}

)
//change the numVoices here
f.value(10)
f.value(1)
f.value(2)
f.value(5)
1 Like