Skip to content

Notes on writing UGens

Michael McCrea edited this page Aug 10, 2020 · 27 revisions

A collection of topics on writing UGens. This isn't meant as a linear tutorial, just observations and pointers gathered along the way...

Initializing the UGen

Initialization sample vs. the first output sample

(from https://github.com/supercollider/supercollider/issues/4127)

This issue is more generally about setting the proper state of the UGen ahead of the initialization sample calculation, then restoring that state after the initialization sample calculation. Furthermore, some ideas on guidelines that should at some point be agreed on and committed to docs.

One illustrative example using Osc with a sine table:

(
var p1, p2, buf1, buf2, numSampsToPlot = 10;
fork ({
	s = Server.local;
	buf1 = Buffer.alloc(s, 8192, 1);
	buf2 = Buffer.alloc(s, 8192, 1); // just for plotting

	0.3.wait;
	buf1.sine1(1.0, true, true, true);

	// plot the source wavetable, not in wavetable format
	buf2.sine1(1.0, true, false, true);
	buf2.loadToFloatArray(action: {|arr|
		var win = arr[0..numSampsToPlot-1].round(1e-7);
		postf("Wavetable source\n>> %\n", win);
		p1 = win.plot("Wavetable source, starts at t=0").plotMode_(\points)
	});

	p2 = {
		Osc.ar(buf1.bufnum, (8192/s.sampleRate).reciprocal, 0)
	}.plot(
		numSampsToPlot+1/s.sampleRate,
		bounds: p1.parent.bounds.left_(p1.parent.bounds.right)
	).plotMode_(\points);
	
	// p.name_("Doesn't work"); // Plotter:name_ bug
	p2.parent.name_("Osc UGen, starts at t=1");
	
	0.2.wait;
	postf("Osc UGen\n>> %\n", p2.data[0][0..numSampsToPlot-1].round(1e-7));
	x = p2
}, AppClock)
)

/*
Wavetable source
>> [ 0.0, 0.000767, 0.001534, 0.002301, 0.003068, 0.0038349, 0.0046019, 0.0053689, 0.0061359, 0.0069029 ]
Osc UGen
>> [ 0.000767, 0.001534, 0.002301, 0.003068, 0.0038349, 0.0046019, 0.0053689, 0.0061359, 0.0069029, 0.0076698 ]
*/

Or simply, with SinOsc:

p = {SinOsc.kr(10, 0)}.plot(duration:0.02).plotMode_(\points);
p.data;

// >> [ [ 0.091058231890202, 0.18135985732079, 0.27015459537506, ...

Expected Behavior: The first sample in both of the above examples should be 0.

Current Behavior What's happening here is that many UGens are initialized using UGen_next(unit, 1), in order to provide downstream UGens with their proper initial input sample value. This generates the first sample and advances the state of the UGen to sample 1, y(1). This value is subsequently recalculated at the start of the first sample loop, i.e. y(0), though the state of the UGen has been advanced ahead of it.

In the case of SinOsc, the problem looks like this in the Ctor function: https://github.com/supercollider/supercollider/blob/f65a1ef7bfb100f4ccb7dcab3a6dff6826be4344/server/plugins/OscUGens.cpp#L1569-L1571

and the fix looks like this:

	int32 initPhase = unit->m_phase; // store init state
	SinOsc_next_ikk(unit, 1);
	unit->m_phase = initPhase; // restore initial state

This is not the only approach to doing this correctly, and shouldn't be considered a one-size-fits all solution, but this works in lots of cases. The general approach is to store the state of any member variables before using UGen_next(unit, 1) to generate a first sample value, then restore that state afterwards.

The problem is that this aspect of initializing UGens has been poorly documented, so authors have either reasoned through the initialization stage with their own solutions, followed other erroneous UGens, or lost sight of solutions that were previously modeled. Shaper, for example handles this with a dedicated function for calculating the first sample: https://github.com/supercollider/supercollider/blob/f65a1ef7bfb100f4ccb7dcab3a6dff6826be4344/server/plugins/OscUGens.cpp#L1143-L1153

Steps toward specifying proper UGen initialization

First, regarding documentation:

  • Clarify the role of initializing the UGen in documentation: Ditch the language of "priming the pump" (it's unspecific) and "calculating the first sample" (misleading). Part of the role of the Ctor is to temporarily set the output of ZOUT0(0) for downstream UGen initialization. This sample will be calculated again as the first sample out.
    • perhaps calling it an "initialization sample" or something similar would help distinguish it from the "first sample".

I'll summarize what I've gathered from [#2333, #2343] and propose these guidelines for initializing UGens:

  1. The initialization sample and the first sample should be equal and both represent the unit's output at time 0, y(0). Make no assumptions about how your signal will be used downstream (e.g. as a trigger).
  2. If the initialization sample represents anything other than y(0), it needs to be clearly documented as and exception to the rule, what it is and why.
  3. For units requiring values for y(-1), y(-2), etc, it's the author's responsibility to
    • a) provide an interface to them. I'd consider this best practice: optional arguments to the UGen to directly provide these values.
    • or b) set these manually with values that are sensible for the algorithm. In both cases, the decision should be clearly documented, both in the source and the help docs for the UGen.
  4. Triggerd inputs should default to their "untriggered" state (usually the value 0) so as to be ready for a trigger on the first sample (at time 0);
  5. If using your calculation function to calculate the ZOUT0(0), you must restore your unit's state properly so that this state will occur again when calculating the first sample.
    • IOW, as shown above in the SinOsc fix, if your calculation function UGen_next(unit, 1) advances the state of member variables by 1 frame, generating y(0), in your constructor you need to follow up your call to next(1) by resetting member variables to the state before calling next(1), so that the first sample output will again be y(0).

Catching unexpected input values

You could send all kinds of values into a UGen's inputs, and your UGen should check for these if it doesn't know how to, e.g., consume a NaN.

In the MovingXCtorHelper example in the next section, you'll see a few checks, which will error out if any answer true:

test description
ZIN0(2) < 1 maximum summing window size can't be less than 1 sample
ZIN0(2) > std::numeric_limits<int>::max() maximum summing window size can't be larger than integer resolution
!std::isfinite(ZIN0(2)) maximum window size must be finite, i.e. not NaN or inf

Allocating memory

A few things to look out for when allocating memory. The basic allocation in the constructor looks like this:

unit->myArray  = (float*)RTAlloc(unit->mWorld, sizeOfArray * sizeof(float));

In this case the data stored are floats, so the returned type is float *, though the data type could be another type.

Importantly, this isn't guaranteed to succeed, if, for example, there isn't enough memory available. So after trying to allocate this memory, you need to check that it succeeded, and if not, exit gracefully. The pattern for that is in the following example.

Also note that if there are other points of failure, say catching bad input values, the UGen will still be instantiated and run, but will (likely) just be outputting 0s, assuming the response to the bad values is to

SETCALC(*ClearUnitOutputs);
ClearUnitOutputs(unit, 1);
unit->mDone = true;
return;

If this error is caught and the constructor returns before a member variable is assigned, any future access to that member variable will be undefined behavior. So if there is a risk that any of the variables will still be called after the constructor, these should be initialized to nullptr before any error checking is done. This is the case in the following example with unit->msquares. An early version of the Ctor didn't initialize this member variable, and when an input error was caught, the synth started, and when subsequently cmd + . was called, the server crashed because the Dtor tried freeing unit->msquares, which was uninitialized. (An example is mentioned here.)

inline void MovingXCtorHelper( MovingX* unit, UnitCalcFunc calcFunc )
{
    unit->msquares = nullptr; // in case we error out before msquares is assigned

    // check that input values are sane
    if (ZIN0(2) < 1 || ZIN0(2) > std::numeric_limits<int>::max() || !std::isfinite(ZIN0(2))) {
        Print("MovingSum/Average Error:\n\t'maxsamp' argument must be >= 1, and within integer resolution.\n\tReceived: %f\n", ZIN0(2));
        SETCALC(*ClearUnitOutputs);
        ClearUnitOutputs(unit, 1);
        unit->mDone = true;
        return;
    }

    unit->mCalcFunc = calcFunc;

    unit->maxSamps  = (int) ZIN0(2);
    unit->nsamps    = sc_clip((int) ZIN0(1), 1, unit->maxSamps);
    unit->msum      = 0.0f;
    unit->msum2     = 0.0f;
    unit->resetCounter = 0;
    unit->head      = 0;    // first write position
    unit->tail      = unit->maxSamps - unit->nsamps;
    unit->reset     = false;
    unit->msquares  = (float*)RTAlloc(unit->mWorld, unit->maxSamps * sizeof(float));

    if (unit->msquares == nullptr) {
        SETCALC(*ClearUnitOutputs);
        ClearUnitOutputs(unit, 1);
        if (unit->mWorld->mVerbosity > -2) {
            Print("Failed to allocate memory for MovingSum/Average\n");
        }
        return;
    }

    // zero the summing buffer
    for (int i=0; i < unit->maxSamps; ++i)
        unit->msquares[i] = 0.f;

    ZOUT0(0) = ZIN0(0); // note, not actual init sample, just here for brevity (this wouldn't be correct for Moving Average).
}

Order of operations

As seen in the previous section, the order of operations in the constructor is important, so you need to strike a balance between what is intuitive and methodical, and necessary for proper handling of errors. The order (work in progress):

  1. Initialize member variables
    • Identify if you'll need to be allocating memory, initialize those variables to nullptr.
    • Allocate memory, assigning to the member variable, exit if necessary.
    • Identify if you'll need to check the range of initial input values. Check and set those, exit if necessary.
    • Initialize all other member variables, reading IN0(x), etc.
  2. If in the course of generating the init sample, you'll be calling a calculation function, storing the state of your member variables which may be changed/advanced in that function call (so you can restore this value afterward)
  3. Set the calculation function based on input rates, etc.
  4. Calculate and set the first output sample/frame
    • This might mean calling calculation function, e.g. next_aa(1)
    • This might mean simply making the calculation to ensure that the init sample will match the first-generated sample
  5. If the state of the UGen was advanced in 4., reset the state of member variables for the calculation of the first block.

Naming conventions

SCUnit inherits a number of member variables from Unit, and your UGen will inherit these from SCUnit as well. The convention is that these variables are prepended with m, e.g. mCalcFunc, mNumInputs, **mInBuf, etc. In some pre-existing UGens, authors have taken to distinguishing these from variables that are local to the UGen with the prefix m_, e.g. m_phase. This could be useful...

Calculation functions

Order of operations

The general pattern The most basic pattern for the sample-generating loop looks like:

~ begin the sample loop iteration

  1. read in the state of the UGen
  2. calculate and write out the current output value
  3. advance the UGen state based on the new inputs to prepare for next sample iteration

~ end sample loop iteration

But there are conceptual considerations regarding when in the sample calculation loop the inputs to the UGen are used.

How inputs affect the UGen's state

This output frame or the next?

The most obvious input to a UGen which would affect the current frame's output is audio signal input. The audio signal sample is read in, the state of the UGen is imparted on this input, and it is written out in the current frame. A trigger is another example: A trigger input likely impacts the current frame's output. For example, Phasor's first argument is trig, which is a trigger that resets the phasor to it's start position. So despite the phase increment calculated for this frame's output sample on the previous frame, the trigger input overrides this state, resets the phasor and causes the output sample to be the value of the startPos input. After this output is determined, but still in this sample iteration, the phase is incremented to ready the state of the UGen for the next output frame.

However, when an input is something like freq, a new state for this input won't "take effect" until the next frame. This is because freq affects the rate of change of the phase pointer value. So the current output sample will be a result of the UGens state on the previous sample period (i.e. phase was advanced based on the previous frame's freq). The new frequency input will now be used to calculate how much the phase advances to determine the next sample. (Frequency, being a rate of change, needs time to accumulate to mean anything.)

In-place operation

Based on the description above, it might seem like there are many cases where the order of operations in a UGen would be:

  1. Read the state of the UGen, as set on the previous frame, storing the state in local variables.
  2. Write the sample output based on the current state.
  3. Read the inputs.
  4. Use the inputs to calculate the new state.
  5. Save the state for the next frame.

But the input buffer and output buffer actually share space in memory. So in fact if you write the outputs first, you would be overwriting the inputs, and when you then read the input, you'd be reading in the value you just wrote to the output. This allows for efficient in-place operation in the UGen graph inside SynthDef as each UGen passes its output on the input of the next, and so on. For this reason, you need read the inputs before writing the outputs. This likely requires storing inputs into variables local to the sample loop. So the order actually looks something like this:

  1. Read the "current" state of the UGen, as calculated in the previous frame, storing the state in local variables.
  2. Read the inputs, storing the state in local variables.
  3. Process any inputs that impact the output of the current frame (e.g. did a trigger occur on input 3? yes? set the output to the reset position and reset the phase pointer).

(after this point the order is less important) 4. Write out the output sample(s) of the current frame based on the state that was read in and any inputs that affect the current output. 5. Advance the UGen's state for the next frame, based on new inputs (e.g. phase pointer advances based on the frequency).

4. and 5. above could be safely swapped, it just depends on what makes most intuitive sense for your UGen. The thing to understand about this is that the current output sample is a combination of the state of the previous frame, and some (but maybe not all or any) of the inputs.

A note on buffer aliasing

While buffer aliasing is useful for optimization through in-place operations, note that the input and output buffer pointers may not be equal, and shouldn't be assumed to be even is buffer aliasing is enabled. Whether or not these pointers are equal are out of your control as a UGen author, which shouldn't necessarily deter you from writing your UGen with aliased buffers. See this issue for context: https://github.com/supercollider/supercollider/issues/4382#issuecomment-482908321

k-rate interpolation

SC doesn't seem to have a spec on when to interpolate k-rate inputs if the output is audio-rate.

(review SC book Ch. 25)

Intuitively it seems interpolating most all k-rate inputs is desirable—that means the audio output will be more "accurate". If inputs are "stepped" every control period, that means to some degree the audio output will be stepped, introducing sidebands, noise, etc. The degree to which this is acceptable seems to be up to the author, though it would be good to keep an eye out for specs in other languages/plugins, etc...

An example:

Index outputs the value of an array based on in index argument. There's no interpolation of the k-rate in argument. Here's part of the calculation function in Line used for a k-rate in argument (Line_next_k):

        // ...
	int32 index = (int32)ZIN0(1);

	index = sc_clip(index, 0, maxindex);
	float val = table[index];            // output value calculated only once, before the sample loop
	LOOP1(inNumSamples,
		ZXP(out) = val;              // same value written out to every frame in the block
	);

Now an example of the output:

(
s.waitForBoot({ // ar out, kr arg
	{
		var vals = [ 10.0, 20.0, 30.0, 40.0 ];
		Index.ar(
			LocalBuf.newFrom(vals),
			Line.kr(
				0,            // start 
				vals.size-1,  // end
				(2  * s.options.blockSize / s.sampleRate) // to the end by block 3
			),
		)
	}.loadToFloatArray(
		(64*4 / s.sampleRate),     // store 4 blocks of output
		action: { |arr| a  = arr }
	);
})
)

a[64..68] // beginning of block 2
// -> FloatArray[ 20.0, 20.0, 20.0, 20.0, 20.0 ]

a[126..130] // end of block 2 into beginning of block 3
// -> FloatArray[ 20.0, 20.0, 40.0, 40.0, 40.0 ]

Because the in argument (the index into the array) isn't interpolated over the block, the output jumps from 20.0 on block 2, to 40.0 on block 3. The more accurate result would have been at some point midway through block two, the output would have crossed to 30.0, then to 40.0. The advantage of not interpolating is that you save operations by not incrementing the index by a slope value and looking up that value in table on every frame. Interpolating the index argument increases accuracy, but the cost of that accuracy gain may make the savings of k-rate input negligible compared to simply an audio-rate input. It seems the author judged that if you want this level of accuracy, you should provide an audio-rate input signal to the in argument.

On the other hand, one might argue that if the output is audio-rate, it's best to make the UGen as accurate as possible, even when provided k-rate inputs. I.e. always interpolate relevant k-rate inputs. (I tend toward this line of thiking.) After all, if the musician want's the savings of control rates, the UGen could simply be run at k-rate and leave it up to downstream UGens to take advantage of that efficiency gain (or not). This method serves the approach of "upsampling" control signals only at the last possible stage when actual audio output is generated. (Currently Index.ar run with a k-rate in is equivalent to K2A.ar(Index.kr).)

So without guidance from a spec for UGens, it's up to the author to decide how to handle input/output rate mismatches.

Input rates, output rates, rate checking

How many rates to support? A calc function for each combination?

All input rates should be considered when designing the calculation functions. Ideally there would be a calculation function for every combination of input/output rate. Though this can quickly turn into many calculation functions.

Some calculation functions can serve multiple purposes, though, because they are called with the inNumSamples argument, which tells it how many times to iterate to generate output samples. For example, an audio-rate UGen will have a calculation function that accommodates all of its input arguments being audio-rate. This function may also be set as the calculation function for the control-rate output version of the UGen, because although the calc function will be reading inputs from an input pointer

const float * in = IN(0)

as opposed to dereferencing the first value in the input buffer with IN0(0), which is typical in k-rate calc functions, the effect is the same because the sample loop in the audio-rate calc function will only iterate 1 time.

There's no one-size-fits-all pattern, but some forethought can lead to some clever optimizations.

Rate checking:

For some UGens it doesn't make much sense to support all possible input rates. An obvious case is that control-rate UGens don't need to support audio-rate inputs. But unless you explicitly catch this case and handle it, you may get unexpected results, or an error.

An example of an existing problem:

"Index" UGens: Index, IndexL, FoldIndex, etc., and likely others, error out (in a non-descriptive way) when a kr rate UGen receives an ar input: File '/Users/admin/Library/Application Support/SuperCollider/tmp/-9878902' could not be opened: Format not recognised.

There should be rate checking in either the _Ctor to handle this gracefully, or in the SC class definition.

Question: is there guidance on what rates "should" be supported in existing resources?

TODO: add a list of methods used for rate checking.

Multi-channel inputs and outputs

I've set up a template for reading inputs that are arrays of variable size, and multichannel output, here.

gotcha with .dup and !

An edge case gotcha that came up involves reading inputs and setting outputs in a loop in which each output successively fed into the neighboring input. This was because the inputs were a multichannel signal created with .dup(size). Here's an example using MultiInOut, the source for which is linked above.

(
d = SynthDef(\testMulti, {
	
	/* BREAKS: One UGen is created, with 5 pointers to it's location */
	var in = DC.ar(1).dup(5);
	
	/* OK: 5 distinct UGens are created via multichannel expansion */
	// var in = DC.ar(1.dup(5)); 	

	Out.ar(2, 
		MultiInOut.ar(in, modFreq: 5, modDepth: 0.5);
	);
})
)

/* Observe the graph! */
d.dumpUGens
// -> ...
// [ 0_DC, audio, [ 1 ] ]
// [ 1_MultiInOut, audio, [ 5, 0.5, 0_DC[0], 0_DC[0], 0_DC[0], 0_DC[0], 0_DC[0] ] ]
// [ 2_Out, audio, [ 2, 1_MultiInOut[0], 1_MultiInOut[1], 1_MultiInOut[2], 1_MultiInOut[3], 1_MultiInOut[4] ] ]

Note all of the 0_DC[0] in the MultiInOut inputs of MultiInOut. You can see that each channel is actually pointing to the same place, so on account of the in-place operation of UGen, if you read input and write output in a single loop, the output of channel 1 will be read into the input of channel 2, etc. Note this is an edge case specific to duplicated channels created with .dup(size) or mySignal ! size (which are equivalent).

Documentation

Document all aspects of your UGen!

Rates

What are the supported output rates for the UGen? And each of its arguments?

For inputs rates of the arguments, specify which rates are accepted, and how those rates are processed. For example, if your UGen is running at ar, specify whether or not interpolation is performed on kr input signals (i.e. slewing to the new input value over the sample block loop).

Notes on efficiency

Are there any circumstances in which your UGen may cause CPU spikes? If so, document it.

Wiki Home

Wiki directories

Contributing
Contributing to the project, filing bugs, creating pull requests, style guidelines.

Development resources
Project documentation, structure, reviewing, releasing, and other processes.

User resources
FAQ, resources for learning, tutorial lists.

Community
Forums and communication, community projects, artworks and initiatives, info about the SuperCollider project.

Clone this wiki locally