.poll changes UGen behavior

Trying to make a phasor that only updates its frequency when it resets (so each ramp is still linear), I thought I’d hack EnvGen:

(
{ 
  var freq = LFSaw.ar(2.5).exprange(12, 60);
  var period = freq.reciprocal.poll;
  EnvGen.ar(Env([0, 0, 1, 1], [0, period, 0], 'lin', 2, 0));
}.plot(2)
)

So that looks good, but take away the .poll and:

(
{ 
  var freq = LFSaw.ar(2.5).exprange(12, 60);
  var period = freq.reciprocal;
  EnvGen.ar(Env([0, 0, 1, 1], [0, period, 0], 'lin', 2, 0));
}.plot(2)
)

Schroedinger’s phasor? Why would that be?

(SC 3.13.0 official release, mac os 12.4)

hey, i dont know whats up with Poll, but i have investigated the same topic in my thread for sub-sample accurate granulation

A segmented envelope like EnvGen is divided into discrete time segments. The output depends on which segment is currently active. When beeing modulated at audio rate it can introduce discontinuities at segment boundaries and become unstable due to internal state transitions. For modulation i would recommend stateless functions which are driven by a ramp signal between 0 and 1. Their output only depend on the instantaneous input of the ramp signal.

The ways i know to achieve the desired behaviour for a continuous, linear ramp signal between 0 and 1 are:

1.) to derive a trigger from its own wrap to latch the modulation of its own rate
2.) to derive a trigger from its own wrap to increment a counter which looks up the next value in a list to be used as the next slope

When the specifications are linear ramps between 0 and 1 from a continuous phasor (no phase reset), its only possible with a single-sample feedback loop.

I have tried to model a solution i found in gen~ with different attempts:

Here is a little detour on the context for the approaches i have tried out (find some of them below, the last one is an attempt with LFO modulation. If the following requirements dont matter for your use case, one of these approaches might be working for you).

In the context of deriving sub-sample accurate events from a continuous ramp signal between 0 and 1, the following specifications have to be met:

The scheduling phasor (your clock) should be a continuous ramp between 0 and 1.
In this picture, you see that the scheduling phasor does have a value of non zero at the moment its wraps from 1 to 0. This value (the subsample offset) is calculated for each sample frame the scheduling phasor wraps to reset your accumulator to the sub sample offset, which then drives your grain window for sub-sample accurate granulation.

1.) Your scheduling phasor only has a non-zero value and the moment it wraps around if its not beeing reset to 0 by a trigger to calculate the sub-sample offset. Therefore you cannot reset it by a trigger.

2.) To have precise timing and distribute your linear phases between 0 and 1 correctly round robin accross the channels, the phase wrap of your scheduling phasor and the new slope derived from random periods have to be 100% in sync. The only way you can ensure this in SC, is to reset your scheduling phasor, which doesnt fullfill the first requirement.

3.) In SC calculations are done with 32bit floats, every attempt whichs needs to calculate the new slope of your scheduling phasor on a sample level (e.g. Duty), will therefore cause floating point number errors which accumulate over time and give you nasty sounding aliasing (have tested that with no randomness for rates which divide your sample rate to perfect integers (works of course) and tried out the alternative to round your samples per period using .round or .floor which changes the pitch and isnt as sub-sample accurate as using an ordinary Phasor for the case no randomness).

The attempts i have tried are:

  • use Duty for random periods, derive a trigger from it with Changed, latch the random periods with that trigger and reset Sweep where you plugin your random periods as an argument directly (doesnt fullfill the first requirement and Sweep currently has a bug not starting from sample count 0 and is therefore unrealiable for testing)
  • use Duty for random periods, derive a trigger from it with Changed, latch the random periods with that trigger to be used as an argument forPhasor without any reset of Phasor (doesnt fullfill the second requirement)
  • use Duty for random periods, derive a trigger from it with Changed, latch the random periods with that trigger to be used as an argument forPhasor and reset Phasor with that trigger (for randomness > 0 this attempt is timing inaccurate and therefore doesnt distribute the phases correctly accross the channels and doesnt fullfill the first requirement).
  • use Duty for random periods, derive a trigger from it with Changed, latch the random periods with that trigger and reset your accumulator multiplied by that random period (doesnt fullfill the first and the third requirement)
  • use Duty for random periods and to accumulate samples multiplied by your random periods to output linear ramps between 0 and 1 inside Duty directly, either reset by the random periods themselves or TDuty (doesnt fullfill the first and the third requirement)
  • use Duty for random periods and to accumulate samples multiplied by your random periods to output linear ramps between 0 and 1 inside Duty directly, but not reset by a trigger but with modulus 1 (doesnt fullfill the second and the third requirement)
// random periods with duty, derived trigger latching the slope, into phasor (no phase reset)

(
var randomPeriods = { |rate, randomness|
	var randomPeriod = Ddup(2, (2 ** (Dwhite(-1.0, 1.0) * randomness))) / rate;
	Duty.ar(randomPeriod, DC.ar(0), 1 / randomPeriod);
};

var rampFromRandom = { |rate, randomness|
	var initTrigger = Impulse.ar(0);
	var randomPeriod = randomPeriods.(rate, randomness);
	var trig = Changed.ar(randomPeriod) > 0 + initTrigger;
	var slope = Latch.ar(randomPeriod, trig) * SampleDur.ir;
	Phasor.ar(DC.ar(0), slope);
};

{
	rampFromRandom.(\tFreq.kr(200), \randomness.kr(1));
}.plot(0.02);
)

// random periods with duty, derived trigger latching the slope, accumulating phasor with accum * slope (with phase reset)

(
var randomPeriods = { |rate, randomness|
	var randomPeriod = Ddup(2, (2 ** (Dwhite(-1.0, 1.0) * randomness))) / rate;
	Duty.ar(randomPeriod, DC.ar(0), 1 / randomPeriod);
};

var rampFromRandom = { |rate, randomness|
	var initTrigger = Impulse.ar(0);
	var randomPeriod = randomPeriods.(rate, randomness);
	var trig = Changed.ar(randomPeriod) > 0 + initTrigger;
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
	var slope = randomPeriod * SampleDur.ir;
	slope * accum;
};

{
	rampFromRandom.(\tFreq.kr(200), \randomness.kr(1));
}.plot(0.02);
)


// random periods, durations and level with duty (with phase reset)

(
var rampFromRandom = { |rate, randomness|
	var randomPeriod = Ddup(2, 2 ** (Dwhite(-1.0, 1.0) *  randomness)) / rate;
	Duty.ar(
		// update every sample
		dur: SampleDur.ir,
		// reset every randomPeriod
		reset: randomPeriod,
		// "sample and hold" increment of (SampleDur.ir / randomPeriod) for every randomPeriod
		// to increment for each sample by the sample count multiplied by slope modulus 1
		level: Ddup(SampleRate.ir, SampleDur.ir / randomPeriod) * Dseries(0, 1) % 1
	);
	
};

{
	rampFromRandom.(\rate.kr(200), \randomness.kr(1));
}.plot(0.021);
)

// modulation of slope with an LFO, with Duty as a sample and hold, derived trigger latching a slope, into phasor (no phase reset)

(
var rampAndHold = { |rate|
	var initTrigger = Impulse.ar(0);
	var period = Duty.ar(1 / rate, DC.ar(0), rate);
	var trig = Changed.ar(period) > 0 + initTrigger;
	var slope = Latch.ar(period, trig) * SampleDur.ir;
	Phasor.ar(DC.ar(0), slope);
};

{
	var rateMod = 2 ** (SinOsc.ar(50) * \modDepth.kr(1));
	var rate = \rate.kr(200) * rateMod;
	rampAndHold.(rate);
}.plot(0.02);
)

With all the attemps from above which are not resetting the phase, you dont sync the wrapping of the ramp signal with the scheduling of the new slope value. These are parallel processes but should not be parallel but in succession (e.g. single-sample feedback loop). The trigger derived from the phasors wrap should determine when its time to pick a new slope value for its next cycle.

With these parallel attempts and without a phase reset you are often times wrapping the phasor, where the next slope value after the wrap is still the old slope value before the wrap and then the slope value is updated somewhere during the current cycle, which leads to a kink in slope for the Phasor (look at the plot, where you have the phasors wrap from 1 to 0 in red followed by the spike in blue of the single sample trigger dervied via Changed from Duty which updates the slope). This doesnt look like a big deal on the plot (its just one sample late), but in the case of deriving slopes and accumulating ramps from the scheduling phasor, which are reset by the derived trigger (like im doing for sub-sample accurate events), it entirely messes up the distribution of derived events.

1 Like

The Poll thing is very weird. If you look at the order of UGens, Poll happens after EnvGen – and Poll is read-only, so it shouldn’t be changing anything in the audio-rate wire buffer.

The segment duration is read in the source code by the expression dur = *envPtr[1] * ZIN0(kEnvGen_timeScale); – I’m not sure how the pointer dereferencing and array indexing interact with an audio-rate wire buffer. I suspect some weirdness with this, but I don’t have enough C background to go further.

However – EnvGen samples the envelope segment properties at control rate only. If you provide audio rate inputs, there is no guarantee of correct behavior… you might get lucky, or you might not. (VarSaw.ar is known not to work for this reason.)

Your LFO is running at 2.5 Hz – well within reach of control rate. So you could use LFSaw.kr instead, or A2K.kr(period).

hjh

Ahhh… one shower later, I get it.

The two graphs are different:

// broken version
LFSaw
|
LinExp
|
reciprocal
|
EnvGen
|
RecordBuf (for plot)

// working version
LFSaw
|
LinExp
|
reciprocal
|
|__ EnvGen --> RecordBuf (for plot)
|
|__ Poll

Since all UGens here are audio rate, they all need to use wire buffers. (kr signals have only one float per control block, so there’s no buffer for them, just a pointer.)

In A → B → C, where A and B both have only a single descendent, it’s known in advance that A’s signal will not be needed after B, and B’s signal will not be needed after C, so all three units can use the same wire buffer. This is how you can have large SynthDefs even though the default numWireBufs is 64: wirebufs get reused whenever possible.

With Poll, reciprocal’s result is needed in two inputs. Therefore EnvGen must output to a different wire buffer from reciprocal. If it used the same wire buffer, then Poll would not have access to the reciprocal value and would post wrong data. The server doesn’t do that.

EnvGen.ar may begin a segment in the middle of a control block, but it reads the duration (and level etc.) value only from the beginning of the wire buffer. If it is reusing a wire buffer that is supplying one of its inputs, then it has already written envelope data into the beginning of the buffer – but later in the block, it reads already-corrupted data when the next segment starts.

I can think of two ways to test:

One, put the Poll in a different place. That is, split the graph so that there’s a 1:1 chain reciprocal → EnvGen → RecordBuf.

(
{ 
	var freq = LFSaw.ar(2.5).exprange(12, 60).poll;
	var period = freq.reciprocal;
	EnvGen.ar(Env([0, 0, 1, 1], [0, period, 0], 'lin', 2, 0));
}.plot(2)
)

The 1:1 chain after reciprocal likewise gets a bad result, consistent with the theory.

Two, try a different second descendent after reciprocal.

(
{ 
	var freq = LFSaw.ar(2.5).exprange(12, 60);
	var period = freq.reciprocal;
	var out = EnvGen.ar(Env([0, 0, 1, 1], [0, period, 0], 'lin', 2, 0));
	var otherBranch = BufWr.ar(period, LocalBuf(100), DC.ar(0));
	out
}.plot(2)
)

Splitting the signal chain at the same point, but with a different downstream operation, also gets a good result.

IOW the behavior here qualifies as “not getting lucky” with an audio-rate input into EnvGen :man_shrugging: – probably EnvGen help should be revised to warn users that ar inputs are not safe.

hjh

Wow, thanks to both for some very mind bending responses.

@dietcv your rampAndHold function makes way fewer artifacts than the hacky EnvGen method I was trying – thanks and cool way to use Duty, even if not perfect as you illustrate it’s plenty good enough for my use right now.

and @jamshark70 holy moley
my main takeaway is, take more showers :slight_smile: