How to quantize parameter updates sent to an Ndef?

Hi everyone,

I’m working with an Ndef in SuperCollider that generates a quantized sequence of notes without relying on Patterns. Here’s a simplified example of my Ndef for context:

(
Ndef(\ns, {
    var dur, trig, degree, freq, octave, sig, scale;
    
    // Sequencer
    dur = Dser(1, 1);
    trig = Dseq([dur], inf) / TempoClock.tempo;
    trig = TDuty.ar(trig, 0, 1);

    // Degrees
    degree = Dser([0, 2, 4, 6], 4);
    degree = Demand.ar(trig, 0, Dseq([degree], inf));
    scale = \scale.kr(Buffer.loadCollection(s, Scale.at(\scriabin)));
    octave = \octave.kr(0).clip(0, 2);
    
    // Frequency
    freq = (DegreeToKey.ar(scale, degree) + 48 + octave).midicps;
	freq = freq * { Rand(-0.1, 0.1).midiratio }.dup(4);

    // Waveform & Envelope
    sig = Pulse.ar(freq);
    sig = sig * Env.perc(0.005, 0.5).ar(gate: trig);

    Splay.ar(sig * \amp.kr(1));
}).quant_(4);
)

When I reevaluate the Ndef, it respects the quantization, however when I use Ndef(\ns).set to update parameters, the updates take effect immediately and are not quantized to the timing grid of the sequence. Example:

Ndef(\ns).set(\octave, 2);

Is there a built-in way in SuperCollider to handle this kind of quantization for set parameter updates? What Are there best practices or alternative strategies that might help in this scenario?

Thanks in advance for your insights! Any advice or suggestions would be greatly appreciated. :blush:

Hey, i don’t have time to explain that in Detail Right now but the best Method imo is to trigger a one-Shot Measure Ramp from the Language and subdivide that for getting Server side Audio triggers. See the example with pmono here Sub-sample accurate granulation with random periods - #24 by dietcv

Then everytime you Use set you also should Send a trigger to the Ndef with set(\trig, 1)

Thanks for your answer, @dietcv. I checked your example, but I wasn’t able to make it work.

I’ve developed the following solution, but it feels somewhat over-engineered. I haven’t yet found a more straightforward alternative:

~quantizedSet = { |key, value|
    var clock = TempoClock.default;
    var nextBeat = clock.nextTimeOnGrid(4);
    
    clock.schedAbs(nextBeat, {
        Ndef(\ns).set(key, value);
    });
};

~quantizedSet.(\octave, 1); 

hey, if you could tell me what you have done and what hasnt worked regarding the example i have shared i can help you out with that. Maybe you have a wrong assumption about how its ment to be used.

I meant that I couldn’t figure out how to implement it or adapt it to my specific use case. I may have misunderstood some aspects of how it’s supposed to be used. Thank you!

every param which you update with synth.set here will be updated on the beginning of each measure:

(
var getSubDivs = { |trig, arrayOfSubDivs, numOfSubDivs, duration|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var subDiv = Ddup(2, (Dseq(arrayOfSubDivs, numOfSubDivs).dpoll * duration));
	Duty.ar(subDiv, trig, subDiv) * hasTriggered;
};

var rampOneShot = { |trig, duration, cycles|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var phase = Sweep.ar(trig, 1 / duration).clip(0, cycles);
	phase * hasTriggered;
};

var oneShotBurstsToTrig = { |phaseScaled|
	var phaseStepped = phaseScaled.ceil;
	var delta = HPZ1.ar(phaseStepped);
	delta > 0;
};

var rampToSlope = { |phase|
	var history = Delay1.ar(phase);
	var delta = (phase - history);
	delta.wrap(-0.5, 0.5);
};

var multiChannelTrigger = { |numChannels, trig|
	var count = Demand.ar(trig, DC.ar(0), Dseries(0, 1, inf));
	numChannels.collect{ |chan|
		trig * BinaryOpUGen('==', (count + (numChannels - 1 - chan) + 1) % numChannels, 0);
	};
};

var getSubSampleOffset = { |phase, trig|
	var slope = rampToSlope.(phase);
	var sampleCount = phase - (slope < 0) / slope;
	Latch.ar(sampleCount, trig);
};

var accumulatorSubSample = { |trig, subSampleOffset|
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
	accum + subSampleOffset;
};

var multiChannelAccumulator = { |triggers, subSampleOffsets|
	triggers.collect{ |localTrig, i|
		var hasTriggered = PulseCount.ar(localTrig) > 0;
		var localAccum = accumulatorSubSample.(localTrig, subSampleOffsets[i]);
		localAccum * hasTriggered;
	};
};

var channelMask = { |triggers, numChannels, channelMask, centerMask|
	var panChannels = Array.series(numChannels, -1 / numChannels, 2 / numChannels).wrap(-1.0, 1.0);
	var panPositions = panChannels.collect { |pos| Dser([pos], channelMask) };
	Demand.ar(triggers, 0, Dseq(panPositions ++ Dser([0], centerMask), inf));
};

SynthDef(\burst, {

	var numChannels = 5;

	var initTrigger, arrayOfSubDivs, numOfSubDivs;
	var seqOfSubDivs, stepPhaseScaled, stepPhase, stepTrigger, stepSlope;
	var triggers, subSampleOffsets, accumulator, overlap, maxOverlap, chanMask;
	var windowSlopes, windowPhases, grainWindows;
	var grainSlope, grainPhases, sigs, sig;

	initTrigger = Trig1.ar(\trig.tr(0), SampleDur.ir);
	arrayOfSubDivs = \arrayOfSubDivs.kr(Array.fill(16, 1));
	numOfSubDivs = \numOfSubDivs.kr(12);

	initTrigger.poll(initTrigger, \initTrig);

	seqOfSubDivs = getSubDivs.(initTrigger, arrayOfSubDivs, numOfSubDivs, \sustain.kr(1));
	stepPhaseScaled = rampOneShot.(initTrigger, seqOfSubDivs, numOfSubDivs);
	stepTrigger = oneShotBurstsToTrig.(stepPhaseScaled);
	stepPhase = stepPhaseScaled.wrap(0, 1);
	stepSlope = rampToSlope.(stepPhase);

	// distribute triggers round-robin across the channels
	triggers = multiChannelTrigger.(numChannels, stepTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(stepPhase, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

	overlap = \overlap.kr(1);
	maxOverlap = min(overlap, numChannels);
	chanMask = channelMask.(triggers, numChannels - 1, \channelMask.kr(1), \centerMask.kr(1));

	windowSlopes = Latch.ar(stepSlope, triggers) / max(0.001, maxOverlap);
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	grainWindows = IEnvGen.ar(Env([0, 1, 0], [0.01, 0.99], [4.0, -4.0]), windowPhases);

	grainSlope = \freq.kr(440) * SampleDur.ir;
	grainPhases = (grainSlope * accumulator).wrap(0, 1);
	sigs = sin(grainPhases * 2pi);

	sigs = sigs * grainWindows;

	sigs = PanAz.ar(2, sigs, chanMask * \panMax.kr(0.8));
	sig = sigs.sum;

	sig = sig * \amp.kr(-15).dbamp;

	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

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

(
//var arrayOfSubDivs = Array.fill(12, { 2 ** rrand(-1.0, 1.0) } ).normalizeSum;
//arrayOfSubDivs.debug(\arrayOfSubDivs);

var arrayOfSubDivs = [0.090043825988599, 0.10094508851196, 0.059173625247572, 0.078758510686157, 0.057549414766475, 0.085870514810413, 0.10684511179581, 0.082087147023147, 0.1207103511832, 0.047718306079275, 0.12704484171827, 0.043253262189117];

Routine({

	s.bind {

		~synth = Synth(\burst, [

			\trig, 0,
			
			\freq, 440,
			\overlap, 1,

			\amp, -15,
			\out, 0,

		]);

	};

	s.sync;

	loop {

		s.bind {

			~synth.set(
				\trig, 1,
				\sustain, 3.2,
				\arrayOfSubDivs, arrayOfSubDivs,
				\numOfSubDivs, 12,
			);

		};

		4.wait;

	};

}).play;
)

if you think this solution might work for you, i can help you to rewrite your initial example to be used in this way and explain a few things here and there.

I’ve reviewed your example more carefully and I think that I understand how to implement it. The stepTrigger derived from Sweep generates the triggers, aligning the updates with the quantification. I believe I could integrate this into my work, but it seems a bit more verbose than my solution, as it requires implementing additional logic for each parameter I want to control.

Since my application relies on Ndef and updates arguments dynamically, I’m keen to learn about best practices. Could you elaborate on the specific advantages of your approach compared to mine, especially considering the extra code per param it requires?

Thank you a lot for your time.

Telling from your example, you think about the output from TDuty as a continuous stream of server side triggers and when you would like to make a change, language scheduling and server side triggers should be in sync. I think thats impossible.
You can schedule your value changes via .set on a specific clock tick with your ~quantizedSet function, but these changes wont be in sync with the audio rate triggers coming from TDuty.

But you can schedule a measure ramp from the language, which will always be in sync with your clock and subdivide that to derive your audio rate triggers.

In your example the envelope will truncate your waveform if its duration is longer then your trigger period. The example i have shared is solving that by scaling the window function exactly to the duration of one event. Additionally your oscillator should ideally have a phase reset.

In the example i have shared above is some additionally chi chi. Here is your initial example with my attempt from above. Have used the measureTrigger to trigger a new random value between -0.1 and 0.1 for the midiratio on the beginning of each measure.

(
var getSubDivs = { |trig, arrayOfSubDivs, numOfSubDivs, duration|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var subDiv = Ddup(2, (Dseq(arrayOfSubDivs, numOfSubDivs).dpoll * duration));
	Duty.ar(subDiv, trig, subDiv) * hasTriggered;
};

var rampOneShot = { |trig, duration, cycles = 1|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var phase = Sweep.ar(trig, 1 / duration).clip(0, cycles);
	phase * hasTriggered;
};

var oneShotRampToTrig = { |phase|
	var compare = phase > 0;
	var delta = HPZ1.ar(compare);
	delta > 0;
};

var oneShotBurstsToTrig = { |phaseScaled|
	var phaseStepped = phaseScaled.ceil;
	var delta = HPZ1.ar(phaseStepped);
	delta > 0;
};

SynthDef(\ns, {

	var initTrigger, duration, measurePhase, measureTrigger, arrayOfSubDivs, numOfSubDivs;
	var seqOfSubDivs, stepPhaseScaled, stepTrigger;
	var degree, scale, freqs, freq, octave, sigs, sig;

	initTrigger = Trig1.ar(\trig.tr(0), SampleDur.ir);
	duration = \sustain.kr(1);
	arrayOfSubDivs = \arrayOfSubDivs.kr(Array.fill(16, 1));
	numOfSubDivs = \numOfSubDivs.kr(12);

	initTrigger.poll(initTrigger, \initTrig);
	
	measurePhase = rampOneShot.(initTrigger, duration);
	measureTrigger = oneShotRampToTrig.(measurePhase);

	seqOfSubDivs = getSubDivs.(initTrigger, arrayOfSubDivs, numOfSubDivs, duration);
	stepPhaseScaled = rampOneShot.(initTrigger, seqOfSubDivs, numOfSubDivs);
	stepTrigger = oneShotBurstsToTrig.(stepPhaseScaled);

	// Degrees
    degree = Dser([0, 2, 4, 6], 4);
    degree = Demand.ar(stepTrigger, 0, Dseq([degree], inf));
    scale = \scale.kr(Buffer.loadCollection(s, Scale.at(\scriabin)));
    octave = \octave.kr(0).clip(0, 2);
    
    // Frequency
    freq = (DegreeToKey.ar(scale, degree) + 48 + octave).midicps;
	freqs = freq * { TRand.ar(-0.1, 0.1, measureTrigger).midiratio }.dup(4);
	
	// Waveform & Envelope
    sigs = Pulse.ar(freqs);
    sigs = sigs * Env.perc(0.005, 0.25).ar(gate: stepTrigger);

	sig = Splay.ar(sigs);

	sig = sig * \amp.kr(-15).dbamp;

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

(
var arrayOfSubDivs = [3, 1, 2, 1].normalizeSum;
var numOfSubDivs = 4;

Routine({

	s.bind {

		~synth = Synth(\ns, [

			\trig, 0,

			\amp, -25,
			\out, 0,

		]);

	};

	s.sync;

	loop {

		s.bind {

			~synth.set(
				\trig, 1,
				\sustain, 1.6,
				\arrayOfSubDivs, arrayOfSubDivs,
				\numOfSubDivs, numOfSubDivs,
			);

		};

		2.wait;

	};

}).play;
)

Wow, this approach is very different from what I imagined. In your example, the server generates sound only when triggered by the tempo of the language-side routine, ensuring perfect alignment. Integrating this into my workflow will require a significant refactor of my existing code, which relies on continuously playing looped Ndefs.

I’ll spend some time experimenting with this approach to see how it can integrate with other parts of my application. I had initially hoped to find a built-in way to quantize NodeProxy updates, similar to how they are quantized when re-evaluated. However, it seems that this isn’t possible, and using a phase ramp is the only viable solution.

I’ve learned a lot from your responses—thank you very much for sharing your insights!

1 Like

This might come as a downside at first, but i think its pretty useful to think in terms of phrasing when composing a piece of music instead of having endless streams of data.