Arrays and For loops for DSP in genExpr

Eren Utku's icon

In the following forum post, I found a Schroeder Reverb written completely in genExpr:

and I saw that the allpass filters and comb filters are described in the following way:

	ap1 = allpass(sig, 0.7, 347 * size);
	ap2 = allpass(ap1, 0.7, 113 * size);
	ap3 = allpass(ap2, 0.7, 37  * size);

	fc1 = fbcomb(ap3, 0.773, 1687);
	fc2 = fbcomb(ap3, 0.802, 1601);
	fc3 = fbcomb(ap3, 0.753, 2053);
	fc4 = fbcomb(ap3, 0.733, 2251);

I see this kind of approach in all genExpr codes. But can't we write this something similar to this? (ignore vec since it doesn't work in gen dsp apparently, take it as a pseudo-code):

dtimes_AP = vec(347, 113, 37);
gains_Comb = vec(0.773, 0.802, 0.753, 0.733);
dtimes_Comb = vec(1687, 1601, 2053, 2251);
fc = vec();

for(i=0; i<=2; i+=1) {
	sig = allpass(sig, 0.7, dtimes_AP(i) * size);
}
	
for(i=0; i<=3; i+=1) {
	fc(i) = fbcomb(sig, gains_Comb(i), dtimes_Comb(i));
}

In addition, array structures are not available on genExpr. Doesn't this create an extreme handicap, how do you all overcome this?

Sidenote: I also would appreciate if anyone has an online resource for genExpr Syntax. The documentation doesn't seem to cover all the capabilities.

larme's icon

Because gen has something called "lexical instancing", function that has internal states will not work as intended in for loop. In fact I think these functions are more like objects that can also act as a function in other programming language.

I use [data] as array replacement in some cases.

Iain Duncan's icon

This is similar to the situation in other music DSLs, such as Csound. Those functions are really callable objects - called "opcodes" in Music X family languages. If you run a loop over one of them, you just clobber the last call because you update the same object a bunch of times before outputting the result on that sample pass.

The way to handle this is to create arrays of opcodes/objects and update the whole array. I have only done this in csound mind you, so don't know the correct syntax in Gen.

Graham Wakefield's icon

Yes, any op that has state is like a functor/callable object in other languages. This includes things like phasor and cycle and delta and += and so on, as well as any genexpr function that incorporates any of those operators, or has its own History or Delay etc (like all filters).

With such a callable object, there's two possible interpretations of what placing them in a for loop means. Either it is the same object called many times, or each call is a new object.

Both options have plusses and minuses for audio programming -- the 'each call is a new object' option produces parallel behaviour, which is intuitive for building a filter bank; whereas the 'one object called many times' leads to recursive behaviour, which is intuitive for oversampling a process, filling a buffer with an audio process, running a nonlinear solver, etc.

Neither are more right or wrong than the other, but when we designed gen~ we had to pick one of them. We chose the recursive-like "same object called many times" option, and for a fairly technical but very solid reason. For the curious, I can try to explain and justify why:

The alternative would imply memory allocation in the audio thread, which is a very bad idea. For example, let's say a user created a combfilter function, which requires an internal delay memory to work, and they placed this inside a for() loop, where the number of iterations depends on some unknown input value (e.g. a param that doesn't have @max). So now gen~ would not be able to know in advance how many comb filters will be required at runtime, and therefore doesn't know how much delay memory they will need, and so the only option would be to allocate that memory right at the moment when suddenly the user requested more of them via changing the param. Allocating memory within the audio process is generally a terrible thing to do because it can take unbounded time, and lead to an audio dropouts. That's why the design of gen~ strictly forbids dynamic memory allocation, and that means that the full memory footprint of a patch is known at compile time (which is also incredibly important when using gen~ to export code for embedded devices!)

Incidentally, the same semantics would occur if you wrote a functor (a callable object) in C++ for example. Making calls within a for loop would simply call the same object N times, not create N objects. To call N objects in a for loop in C++ you'd need to declare an array of them and index that array in the for loop, or use template metaprogramming, which are a whole other layer of complexity, or macros, which are obscure and error-prone.

The Genexpr language was intended to be much simpler to learn than C++, and closer to the representation of a patch, where the declaration of an object is the calling of the object. (That's the "lexical instancing") That doesn't really allow a structure for creating an array of objects (and for the most part, neither does a Max patch -- the nearest we have is poly~, which is just short-hand for creating N subpatchers, and has different complexities in addressing them; or mc.*, which we also have as mc.gen~ and mcs.gen~).

To create N parallel calls you can just copy-paste N abstractions in gen~, or writecombfilter() N times in a codebox. What a genexpr for loop offers is something different, which can't be achieved in a Max patch at all. And as mentioned above it can be very useful -- for writing an oversampled process, or writing a solver (e.g. for a nonlinear filter), or for filling a buffer/data from a recursive process (e.g. a grain generator), etc. etc.

Graham