Multichannel expansion and Demand UGens

Hi all!
I’m using Dseries and Demand in a synthdef i’m doing, but i don’t know how to deal with multichannel expansion on those cases. I’m also still a bit dumb with arrays in SC.

Can someone point me into the right direction for this synthdef to be able to have different pitches, one per channel, and Dseries and Demand act accordingly?

this is my “full” not working code:

(
SynthDef(\stutter13,{
	arg in=0, out=0;
    var insig, sig, delayTime, increment, trigger;
	var reset=\reset.kr(0);
	var delayPitch=\pitch.kr(12!13); //this is basically what doesn't work!!!
	var offsetms=\offsetms.kr(0)/1000;
	var gatepw=\gatepw.kr(1);
	var on=\onoff.kr(0);
	var envGen, sustainTime;
	var atkTime=0.002, relTime=0.002;

	reset=reset*on;
    // Convert pitch to delay time in seconds
    delayTime = delayPitch.midicps.reciprocal;

    // Trigger for incrementing delay time
    trigger = Impulse.ar(delayTime.reciprocal);

	// Calculate sustain time to ensure envelope stays high for the chunk duration minus attack and release
    sustainTime = delayTime - (atkTime + relTime);

    // Ensure sustainTime is not negative
    sustainTime = max(sustainTime, 0)*gatepw;

    // Increment delay time
	//This is the part where the multichannel expanded "delayTime" variable doesnt unfold properly.
	//How to deal with this?
	increment = Dseries(0, delayTime, inf)*on;
	increment = Demand.ar(trigger, reset, increment);

	// Generate an envelope to soften clicks
    envGen = EnvGen.ar(Env([0, 1, 1, 0], [atkTime, sustainTime, relTime], 'lin'), trigger);


    // Get input signal
    insig = In.ar(in, 13); // Assuming stereo input

    // Apply delay
    sig = DelayC.ar(insig, 24, increment+offsetms);

	// Apply envGen
	sig=sig*envGen;
	sig=(sig*on)+(insig*(1-on));

    // Output
    Out.ar(out, sig);
}).writeDefFile(d);
)

And this is the exact part which seems is not unfolding the multichannel expanded “delayTime” variable properly, giving as a result a single channel result for the “increment” variable:

increment = Dseries(0, delayTime, inf)*on;
increment = Demand.ar(trigger, reset, increment);

How should i deal with this?

On a basic level, demand UGens respond to multichannel expansion in a predictable manner:

s.boot;

(
{
	var sig, n;
	n = \n.kr([2, 4]);
	n = Dseq([Dseries(50, n, 8)], inf);
	n = Demand.kr(Impulse.kr(12), 0, n).midicps;
	sig = LFTri.ar(n) * 0.05
}.play;
)

So the issues you’re facing might be related to something else. I’m having a hard time precisely understanding the type of result you’re after. I see your SynthDef is named “stutter” and you mention wanting “a different pitch per channel,” but it would be helpful if you could simplify the code as much as possible, so that the crux of the issue is more isolated, and/or describe the type of sound you want as specifically as possible. It could even be that there is a simpler approach that just uses conventional mc expansion and doesn’t involve demand UGens at all.

On closer inspection, I’m noticing that if two arguments of Demand (e.g. trig and demandUGens) are n-channel arrays, the result is an array of arrays:

(
SynthDef(\a, {
	var sig = Demand.ar(Impulse.ar([1, 1.5]), 0, Dseries(1, [1, 2], 4));
	sig.postln;
}).add;
)

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

I’m a little surprised by this — I would have expected the above example to be equivalent to the following, but it’s not:

(
SynthDef(\a, {
	var sig = [
		Demand.ar(Impulse.ar(1), 0, Dseries(1, 1, 4)),
		Demand.ar(Impulse.ar(1.5), 0, Dseries(1, 2, 4)),		
	];
	sig.postln;
}).add;

// [ an OutputProxy, an OutputProxy ]
)

It could be that Demand UGens are designed differently so that they perform combinatorial “table” operations with arrays instead of the more familiar operation. Regardless, maybe a solution would involve changing trigger to trigger[0] in your increment array, or applying sum to increment before proceeding, or flatten-ing the array…hard to say without fully understanding what you’re going for.

Hi Eli!
i’ve learned with your tutorials and just ordered your new book, and now interacting with you here is another proof of how cool this community is. Thanks for your help!

What i want to do is basically a beatrepeat where the size of the repeated chunks of audio is defined by pitch. Probably the best way to do what i want would be writing the incoming audio in a buffer, a la “granular delay”. But the software architecture where i want to integrate this synthdef (GitHub - PlaymodesStudio/ofxOceanode: LFO Modulation Framework) still doesnt manage buffer writing.

I recently learned on this forum that i can use localbuff inside a synthdef, but i’m still not sure if i can use that feature to implement a granular delay of incoming audio.
That would definetely be a better solution (and an open door for many other synthdefs), but i still need to delve into the mysteries of localbuff and granular audio in supercollider.

My “vanilla” approach (look ma, no buffers), is doing this chunk repetition by delaying the signal recursively using the delaytime period with a sequential increment of delaytime. This way, when a chunk has been repeated, it goes back again to the begining of that chunk. This, obviously, has the inconvenience (and unefficiency!) that the number of repetitions is constrained by the maximum size of the delay (which i have set to 24 seconds).

This approach is more or less working, and the resulting sound is correct if using a scalar pitch value, the same for every channel:

(
SynthDef(\stutter13,{
	arg in=0, out=0;
    var insig, sig, delayTime, increment, trigger;
	var reset=\reset.kr(0);
	var delayPitch=\pitch.kr(12);
	var offsetms=\offsetms.kr(0)/1000;
	var gatepw=\gatepw.kr(1);
	var on=\onoff.kr(0);
	var envGen, sustainTime;
	var atkTime=0.002, relTime=0.002;

	reset=reset*on;
    // Convert pitch to delay time in seconds
    delayTime = delayPitch.midicps.reciprocal;

    // Trigger for incrementing delay time
    trigger = Impulse.ar(delayTime.reciprocal);

	// Calculate sustain time to ensure envelope stays high for the chunk duration minus attack and release
    sustainTime = delayTime - (atkTime + relTime);

    // Ensure sustainTime is not negative
    sustainTime = max(sustainTime, 0)*gatepw;

    // Increment delay time
	increment = Dseries(0, delayTime, inf)*on;
	increment = (Demand.ar(trigger, reset, increment))!13; //this is not exactly my intention, as i'd preffer to have 13 different increments, one per channel

	// Generate an envelope to soften clicks
    envGen = EnvGen.ar(Env([0, 1, 1, 0], [atkTime, sustainTime, relTime], 'lin'), trigger);


    // Get input signal
    insig = In.ar(in, 13); // Assuming stereo input

    // Apply delay
    sig = DelayC.ar(insig, 24, increment+offsetms);

	// Apply envGen
	sig=sig*envGen;
	sig=(sig*on)+(insig*(1-on));

    // Output
    Out.ar(out, sig);
}).writeDefFile(d);
)

But i’d like to be able to feed the \pitch parameter with a an array of 13 values, and have each voice stuttering at different pitches. Initializing as \pitch.kr(36!13) doesn’t work as expected, and when an array of 13 different pitches is sent to this named control it doesnt work properly. And here it is where i’m stuck.

Probably it might just takes a couple of tutorials to see and read to find the solution, but it’s always nice to interact with the community to learn and share!

Thanks for the kind words!

Using Demand and DelayC seems like a hacky way to go about this. I agree that writing incoming audio into a buffer and loop/granulate as needed is probably one of the better approaches. I don’t think it’s as complicated as you might be imagining.

Here is a stab in the dark at what you’re trying to do, using a simple RecordBuf/PlayBuf combo. You could probably also do this with LocalBuf, and it wouldn’t be much different. This code is based on your SynthDef but I’ve made a number of simplifications, like removing the envelope stuff. In the following, you can provide an array of 12 pitch values and it’ll produce those pitches by looping buffer chunks of the appropriate size. I might not be capturing your intent accurately.

Separately, I do not understand your In.ar(in, 13) — the comment suggests you’re expecting stereo input, so I don’t get why you’re setting the number of channels to be 13.

s.boot;

// using built-in sample as source sound
b = Buffer.read(s, Platform.resourceDir ++ "/sounds/a11wlk01.wav");

// empty 5 sec buffer for loop record/playback
~b = Buffer.alloc(s, s.sampleRate * 5, 1);

(
SynthDef(\stutter, {
	arg in=0, out=0;
	var sig, insig, trigger, delayTime;
	var buf = \buf.kr(~b);
	var delayPitch = \pitch.kr(12!12);
	
	// source signal — can be replaced with In/SoundIn or whatever
	insig = PlayBuf.ar(1, b, BufRateScale.ir(b), loop:1) * 0.2;

	// calculate delay times and trigger signal
	delayTime = delayPitch.midicps.reciprocal;
	trigger = Impulse.ar(delayTime.reciprocal);

	// record source to empty buffer
	RecordBuf.ar(insig, buf, loop: 0);
	
	// loop playback specific chunk of recorded audio
	sig = PlayBuf.ar(1, buf, trigger: trigger);

	// mix 12 channels down to 2 so all channels are audible
	sig = Splay.ar(sig);
	
	// output
	Out.ar(out, sig);
}).add;
)

x = Synth(\stutter, [pitch: [ 40, 43, 45, 47, 50, 52, 55, 57, 59, 62, 64, 67 ]]);

x.free;

So cool!

That’s close to what i’m searching for, and certainly the buffer solution is a way better approach for the task.

Anyhow, the way i work with supercollider is not very idiosincratic. I am working with a custom software framework (GitHub - PlaymodesStudio/ofxOceanode: LFO Modulation Framework) which interfaces directly with scsynth, and which won’t let me allocate and write on buffers. In few words, i’m restricted to work with just whatever i can define inside a synthdef, nothing else. And hence the reason why my first approach to create a stutter (micro-beat-repeat, or whatever the name) has been to work with a DelayC ugen, as it provides a realtime writable pseudo-buffer which would allow me to re-create this beatrepeat effect without the need to use buffers, and using demand ugens to move the delay header at audiorate to re-create the stuttering effect.

I just screen-recorded this software framework (we call it Oceanode) in action here, where in fact i’m also loading the stutter synthdef (but still with the missing pitch arrays):

We’re still in development of this software, and while it is very promising (a modular interface for synthdefs and myriads of lfos!) it is still on its very early stage and there’s many features lacking. We’ve been using it for years at Playmodes (https://www.playmodes.com/) to create audiovisual or light installations where we use banks of lfos to control dmx, motors, LEDs or external audio software (reaktor or max), but one year ago we decided to interface with scsynth while being able to communicate with custom synthdefs which are seen as nodes in our visual patching environment.
Another video of how we generally use this software here:

Ok, so after all this context (i think it was necessary to give some clues to why i’m asking for weird things…):
I tried modifying your code to have the buffer created inside the synthdef, but it drops many errors. I’m afraid i can’t do it this way, right?

(
SynthDef(\stutter, {
	arg in=0, out=0;
	var b;
	var sig, insig, trigger, delayTime;
	var delayPitch = \pitch.kr(12!3);
	var buf = LocalBuf.new(44100,1);

	// source signal — can be replaced with In/SoundIn or whatever
	insig = SoundIn.ar(0);

	// calculate delay times and trigger signal
	delayTime = delayPitch.midicps.reciprocal;
	trigger = Impulse.ar(delayTime.reciprocal);

	// record source to empty buffer
	RecordBuf.ar(insig, buf, loop: 0);

	// loop playback specific chunk of recorded audio
	sig = PlayBuf.ar(1, buf, 1, trigger, MouseX.kr(0,BufFrames.kr(buf)));

	// mix 12 channels down to 2 so all channels are audible
	sig = Splay.ar(sig);

	// output
	Out.ar(out, sig);
}).add;
)

x = Synth(\stutter, [pitch: [-10,2,4]]);

x.free;

Also, regarding the question about the 13 channels: my comment on the code is wrong!. The effect has to work for 13 channels. Later on the fx processing chain i’ll be mixing it down to stereo (or another number of channels), but as a starting point i want to have 13 voices.

Thanks for your help… and for your latest tutorial on demand Ugens!
Really appreciated!!!

PD: sorry for my late reply. The work at the studio has been terrifying since monday, with myriads of paperwork and boring stuff!

2 Likes

Thanks for providing some context. Given the custom software you’re working with, there might be variables I can’t account for. But, for what it’s worth, your most recent code example works for me. I don’t get any errors, and it sounds correct based on what you’ve shared.

I made a few small changes: using pink noise instead of a live mic (but this can be changed to any one-channel source), used SampleRate.ir instead of hard-coding 44100, and shortened the LocalBuf. You will probably want to constrain user-specified pitch values so that it’s impossible to specify a delay longer than the length of the LocalBuf.

So, if this doesn’t work for you, I would guess there’s something else going on that I won’t be able to diagnose.

(
SynthDef(\stutter, {
	arg in=0, out=0;
	var sig, insig, trigger, delayTime;
	var delayPitch = \pitch.kr(12!3);
	var buf = LocalBuf.new(SampleRate.ir / 20, 1);

	insig = PinkNoise.ar(0.1);
	delayTime = delayPitch.midicps.reciprocal;
	trigger = Impulse.ar(delayTime.reciprocal);
	RecordBuf.ar(insig, buf, loop: 0);
	sig = PlayBuf.ar(1, buf, 1, trigger, MouseX.kr(0, BufFrames.kr(buf)));
	sig = Splay.ar(sig);
	Out.ar(out, sig);
}).add;
)

x = Synth(\stutter, [pitch: [55, 65, 75]]);

x.set(\pitch, [53, 62, 70]);

x.free;
1 Like

It took a while, but I see now what is going on.

“Classical” multichannel expansion occurs when a UGen accepts only single-value inputs. For instance, a typical filter accepts one signal and one frequency. If you supply an array for each, then it matches signal channel 0 with frequency channel 0, signal channel 1 with frequency channel 1 etc. and you get one filter for each matched-up combo.

SynthDef(\test, {
    Out.ar(0, LPF.ar([Pulse.ar, Saw.ar], [2000, 3000]))
}).dumpUGens;

[0_Pulse, audio, [440.0, 0.5]]
[1_LPF, audio, [0_Pulse, 2000]]  -- both arrays' 0 values
[2_Saw, audio, [440.0]]
[3_LPF, audio, [2_Saw, 3000]]  -- both arrays' 1 values
[4_Out, audio, [0, 1_LPF, 3_LPF]]

Demand is different in that it accepts multiple demand inputs (kind of like EnvGen accepts variable-length envelopes, or SendReply takes a variable-length array of values to append to the outgoing message).

SynthDef(\test, {
    Out.ar(0, Demand.ar(
        Impulse.ar(1),
        0,
        [Dseries(0, 1, inf), Dseries(100, 1, inf)]
    ))
}).dumpUGens;

[0_Impulse, audio, [1, 0.0]]
[1_Dseries, demand, [inf, 0, 1]]
[2_Dseries, demand, [inf, 100, 1]]
[3_Demand, audio, [0_Impulse, 0, 1_Dseries, 2_Dseries]]
[4_Out, audio, [0, 3_Demand[0], 3_Demand[1]]]

Classical multichannel expansion would produce one Demand for 1_Dseries, and a second for 2_Dseries. But here we get one Demand, pulling from both Dseries-es.

So then if you supply multiple triggers, you will get a separate Demand for each trigger, and each one will pull values from all of the demand inputs.

You can defeat this by, counterintuitively, double-array wrapping the demand inputs:

SynthDef(\test, {
    Out.ar(0, Demand.ar(
        Impulse.ar([1, 2]),
        0,
        [[Dseries(0, 1, inf), Dseries(100, 1, inf)]]
    ))
}).dumpUGens;

[0_Impulse, audio, [1, 0.0]]
[1_Impulse, audio, [2, 0.0]]
[2_Dseries, demand, [inf, 0, 1]]
[3_Demand, audio, [0_Impulse, 0, 2_Dseries]]
[4_Dseries, demand, [inf, 100, 1]]
[5_Demand, audio, [1_Impulse, 0, 4_Dseries]]
[6_Out, audio, [0, 3_Demand[0], 5_Demand[0]]]

That’s a gotcha – because array expansion in events works the opposite way: a single array multichannel-expands to multiple synths, while a two-level array unwraps one layer and passes the result to a single synth. Here, the single array gets passed to a single UGen, while the two-layer array expands to multiple UGens.

That’s an inconsistency in the programming interfaces, which is… well… not quite ideal. TBH I’m not sure whether or not to log it as a bug. I’m generally cautious about breaking backward compatibility – at the same time, though, it would be good if [args] vs [[args]] were more consistent.

hjh

2 Likes

Interesting, thanks for the clarity. I don’t think I would have been able to get to the bottom of this one on my own!

thanks @jamshark70!

I need to take my time to understand and test this behavior of the demand ugens, as i will be probably using it extensively in the future with a multichannel expansion requirement.

Thanks @elifieldsteel for the localbuff solution, and the new example using pink noise!
I’ll be dissecting all this info soon, as my next step is probably implementing a grain cloud delay i can use on our visual patching framework.

I’ll update asap with results!