GrainUtils - sub-sample accurate EventScheduler and dynamic VoiceAllocator

Hello @dietcv,
I have this error when I try to build grainutils on Linux

[ 45%] Building CXX object CMakeFiles/Oscs_scsynth.dir/plugins/Oscs/Oscs.cpp.o
In file included from /home/fabien/SuperCollider_source/grainutils/plugins/Oscs/Oscs.hpp:3,
                 from /home/fabien/SuperCollider_source/grainutils/plugins/Oscs/Oscs.cpp:1:
/home/fabien/SuperCollider_source/grainutils/plugins/Utils/OscUtils.hpp:4:10: fatal error: utils.hpp: Aucun fichier ou dossier de ce nom
    4 | #include "utils.hpp"
      |          ^~~~~~~~~~~
compilation terminated.
gmake[2]: *** [CMakeFiles/Oscs_scsynth.dir/build.make:76 : CMakeFiles/Oscs_scsynth.dir/plugins/Oscs/Oscs.cpp.o] Erreur 1
gmake[1]: *** [CMakeFiles/Makefile2:205 : CMakeFiles/Oscs_scsynth.dir/all] Erreur 2
gmake: *** [Makefile:136 : all] Erreur 2

I just replaced

#include “utils.hpp”

by

#include “Utils.hpp”

in grainutils/plugins/Utils/OscUtils.hpp and it seems to work fine.

hey, thanks. I have corrected the typo, should all be fine now :slight_smile:

1 Like

Just felt like it, i have renamed UnitRand to UnitStep i guess that makes a lovely pair with UnitWalk and added an interp argument to UnitStep and UnitWalk for 0 = no interpolation or 1 = cosine interpolation. I miss the days not so long ago, when i was just resetting the whole repository instead of adding and making typos and testing after having created a new release and spamming the history haha.

Now for UnitStep you get:


// Random values with no interpolation (stepped)

(
{
    var phase, stepped;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    stepped = UnitStep.ar(phase, \interp.kr(0));
    
    [phase, stepped];
}.plot(0.041);
)

// Random values with cosine interpolation (smooth)

(
{
    var phase, smooth;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    smooth = UnitStep.ar(phase, \interp.kr(1));
    
    [phase, smooth];
}.plot(0.041);
)

and for UnitWalk:


// Random walk with no interpolation (stepped)

(
{
    var phase, stepped;

    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));

    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    stepped = UnitWalk.ar(phase, \step.kr(0.2), \interp.kr(0));

    [phase, stepped];
}.plot(0.041);
)

// Random walk with cosine interpolation (smooth)

(
{
    var phase, smooth;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    smooth = UnitWalk.ar(phase, \step.kr(0.2), \interp.kr(1));
    
    [phase, smooth];
}.plot(0.041);
)

Will probably add the interp argument for the ShiftRegister as well :slight_smile:

7 Likes

I have created a new release with some internal improvements :slight_smile:

GrainDelay is now using NEXTPOWEROFTWO() for m_bufSize with fast bitwise wrapping for the cubic interpolated buffer lookup
replaced sc_wrap between 0 and 1 by sc_frac in all the plugins
replaced std:floor and std::ceil with sc_floor and sc_ceil in all the plugins (these seem to have some internal optimisations)

3 Likes

right after i have made the update i have found the sc_CalcFeedback function which calculates the time for the feedback signal to decay by 60dB based on delayTime and decayTime. I have now replaced our feedback param in the GrainDelay with decayTime between 0.01 - 10 secs and reduced the maxDelayTime from 5.0 to 2.0 secs (i guess thats a more sensible value), will probably make another update soon. Im currently also looking at the MI Clouds which does have some nice param bindings like density which combines trigger frequency and overlap. Lets see what makes sense here to potentially reduce the current parameters to some macro controls :slight_smile:

2 Likes

Hello @dietcv ,

I’m using your gainutils tools for a few weeks now, I’m a big fan but I have a problem that prevents me from using it live and that’s very sad.
I built it on Linux (Ubuntu studio 22.04) with SuperCollider 3.14-dev and I experienced interpreter crash from time to time.
I have this message when it’s happening:

Interpreter has crashed or stopped forcefully. [Exit code: 11]

The interpreter crash is the worst scenario because it forces me to restart everything.
Next to that, when the interpreter doesn’t crash, I often have many xruns that appear in Jack’s logs:

21:21:32.291 Récupération désynchronisation (XRUN) (1).
Fri Oct 31 21:21:32 2025: ERROR: JackEngine::XRun: client = PulseAudio JACK Sink was not finished, state = Triggered
Fri Oct 31 21:21:32 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
Fri Oct 31 21:21:32 2025: ERROR: JackEngine::XRun: client = PulseAudio JACK Sink was not finished, state = Triggered
Fri Oct 31 21:21:32 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
Fri Oct 31 21:21:32 2025: ERROR: JackEngine::XRun: client = PulseAudio JACK Sink was not finished, state = Triggered
Fri Oct 31 21:21:32 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
21:21:33.770 Récupération de désynchronisation (XRUN) (6 sauté).
Fri Oct 31 21:21:33 2025: ERROR: JackEngine::XRun: client = PulseAudio JACK Sink was not finished, state = Triggered
Fri Oct 31 21:21:33 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
Fri Oct 31 21:21:33 2025: ERROR: JackEngine::XRun: client = PulseAudio JACK Sink was not finished, state = Triggered
Fri Oct 31 21:21:33 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
21:21:34.970 Récupération désynchronisation (XRUN) (8).
21:25:13.054 Récupération désynchronisation (XRUN) (9).
Fri Oct 31 21:25:13 2025: ERROR: JackEngine::XRun: client = SuperCollider was not finished, state = Triggered
Fri Oct 31 21:25:13 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
Fri Oct 31 21:25:13 2025: ERROR: JackEngine::XRun: client = SuperCollider was not finished, state = Triggered
Fri Oct 31 21:25:13 2025: ERROR: JackAudioDriver::ProcessGraphAsyncMaster: Process error
21:25:14.228 Récupération de désynchronisation (XRUN) (1 sauté).
...

Sometimes, I can see this warning in the post window (I assumed that’s from C++):
exception in GraphDef_Load: ios_base:failbit set: iostream stream error
I also got this message with another machine running on windows 10 (SuperCollider 3.13).

Unfortunately all these happens from time to time (5 times last week) and I can’t manage to isolate a chunk of code to reproduce these behaviors systematically.
So I can’t affirmate that it’s comming from grainutils but all I can tell is that I never encounter these problems before, it started after installing and using grainutils and it doesn’t happen when I don’t use it.

hey, thanks for reporting. unfortunately i have no idea what this is about. I think it would help to have a reproducible example.

sorry for pinging, but do you have an idea @Spacechild1 ?

I have reconsidered this, i think delayTime based on T60 makes sense for a conventional feedback delay effect, but in our case the overlap param already controls the sustain and i therefore think having the direct feedback control between 0 and 1 is the better choice here.

I have done a lengthy refactor of the library and added UnitUSR and Disperser.
You can grab the latest release here:

The UnitUSR (Universal Shift Register) replaces the former ShiftRegister Ugen.
Now the shift register is clocked by a ramp signal as the input instead of a trigger and you have additional interpolation available. The Universal Shift Register could also be transformed into a Rungler by feeding back into itself, will experiment more with that and maybe release UnitRungler then.

(
{
	var phase, register;

	RandSeed.kr(\seedtrg.kr(1), \seed.kr(500));

	phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	register = UnitUSR.ar(
		phase: phase,
		chance: 0.5,
		length: 8,
		rotate: 1,
		interp: 0
	);

	[
		register[\bit3],
		register[\bit8]
	];

}.plot(0.02);
)

(
{
	var phase, register;

	RandSeed.kr(\seedtrg.kr(1), \seed.kr(500));

	phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	register = UnitUSR.ar(
		phase: phase,
		chance: 0.5,
		length: 8,
		rotate: 1,
		interp: 1
	);

	[
		register[\bit3],
		register[\bit8]
	];

}.plot(0.02);
)

The Disperser Ugen is based on an Allpass Cascade of 2nd order biquad allpass filters with control over filter q to change the steepness of the phase response and carefully calculated coefficients (often times phaser plugins are based on 1st order allpass filters where this option is not available, if you setup your cascade with Allpass2 from SC Plugins modulating the resonance will blow up the filter because of the coefficient calculation, not the case here!).
For some configurations it is similiar to a phaser with feedback for others its more similiar to the kilohertz disperser plugin which decorates your input with a chirp by a frequency dependent delay (spectral delay filter). Currently only possible to use control rate to modulate the params, will change that soon.

(
{
	var sig = Saw.ar(32.midicps * (2 ** TIRand.ar(-1, 1, Dust.ar(2))));
	Disperser.ar(
		input: sig,
		freq: 500,
		resonance: SinOsc.kr(0.3).linlin(-1, 1, 0, 1),
		mix: 1.0,
		feedback: 0.85,
	)!2 * 0.1
}.play;
)
3 Likes

added internal control rate interpolation and audio rate param control for Disperser and GrainDelay.
This is part I of these implementations, the other ugens will follow.
Currently most of the Ugens are forcing params to be audio rate in the .sc file, but now you can use both with internal interpolation.
Let me know how it goes :slight_smile:

I have also implemented an example where we use the UnitUSR to index into a multichannel Dswitch1 in the helpfile, play around with the chance, length and rotate params of the universal shift register and see the pattern evolve or start with another seed (still not sure if we shouldnt rename that to UnitRegister im always very picky with names haha):

(
var multiChannelDswitch = { |triggers, reset, index, arrayOfItems, numOfItems, repeatItem|
    var indexQuantized = (index * numOfItems).floor;
    var demand = Ddup(repeatItem, Dswitch1(arrayOfItems, indexQuantized));
    triggers.collect{ |localTrig|
        Demand.ar(localTrig + reset, reset, demand)
    };
};

var tuning = Tuning.new((0..12) * (3.ratiomidi / 13), 3.0, "Bohlen-Pierce").ratios;
var degrees = [0, 5, 6, 7, 4, 0];
var arrayOfRatios = degrees.collect{ |degree| tuning[degree] };

{
    var numChannels = 8;
    var numSpeakers = 2;

    var reset, tFreq;
    var events, register, voices, calcGrainData;
    var grains, sig, pan;

    RandSeed.kr(\seedtrg.kr(1), \seed.kr(300));

    reset = Trig1.ar(\reset.tr(0), SampleDur.ir);
    tFreq = \tFreq.kr(10);

    events = SchedulerCycle.ar(tFreq, reset);

    register = UnitUSR.ar(
        phase: events[\phase],
        chance: 1,
        length: 8,
        rotate: 1,
        interp: 0,
        reset: reset
    );

    voices = VoiceAllocator.ar(
        numChannels: numChannels,
        trig: events[\trigger],
        rate: events[\rate] / \overlap.kr(4),
        subSampleOffset: events[\subSampleOffset],
    );

    calcGrainData = { |chainID|

        var grainFreqs, grainPhases;
        var grainOscs, grainWindows;

        grainWindows = ExponentialWindow.ar(
            phase: voices[\phases],
            skew: \skew.kr(0.03),
            shape: \shape.kr(0)
        );

        grainFreqs = case
        { chainID == \A } {
            var mod = multiChannelDswitch.(
                voices[\triggers],
                reset,
                register[\bit3],
                arrayOfRatios,
                \numOfItemsA.kr(6),
                \repeatItemA.kr(1)
            );
            \freqA.kr(220) * mod;
        }
        { chainID == \B } {
            var mod = multiChannelDswitch.(
                voices[\triggers],
                reset,
                register[\bit8],
                arrayOfRatios,
                \numOfItemsB.kr(6),
                \repeatItemB.kr(1)
            );
            \freqB.kr(440) * mod;
        };

        grainPhases = RampIntegrator.ar(
            trig: voices[\triggers],
            rate: grainFreqs,
            subSampleOffset: events[\subSampleOffset]
        );
        grainOscs = SinOsc.ar(DC.ar(0), grainPhases * 2pi);

        grainOscs * grainWindows;
    };

    grains = XFade2.ar(
        inA: calcGrainData.(\A),
        inB: calcGrainData.(\B),
        pan: \mix.kr(0.5) * 2 - 1
    );

    pan = UnitWalk.ar(voices[\phases]);
    grains = PanAz.ar(
        numChans: numSpeakers,
        in: grains,
        pos: pan.linlin(0, 1, -1 / numSpeakers, (2 * numSpeakers - 3) / numSpeakers);
    );
    sig = grains.sum;

    sig = LeakDC.ar(sig);
    sig * 0.1;
}.play;
)
2 Likes

I have added internal control rate interpolation and audio rate param control for all the other ugens as well. Was a tricky decision for some of the params which are integers or sampled and held per trigger.

2 Likes

added additional bounds so the last value from which to interpolate from is also in those boundaries. Before you would have gotten values which are out of boundaries for control rate params, now fixed.

1 Like

There is a new DAFX paper on “wave pulse phase modulation” where you basically take your segmented non-linear transfer function with adjustable breakpoint between 0 and 1 (UnitKink) used for phase distortion synthesis and add a phase modulation term to each of the two segments. That doesnt sound too interesting in my opinion but just wanted to share that because you could use the same approach to nest different unit shapers and its related to pulsar synthesis.

(
var wppm = { |freq, skew = 0.5, ratioA = 1, ratioB = 1, indexA = 0.5, indexB = 0.5|

	var safeSkew = skew.max(1e-10);
    var safeInvSkew = (1 - skew).max(1e-10);

	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);

    var phase1 = phase / safeSkew;
    var phase2 = (phase - skew) / safeInvSkew;

    var mod = { |phase, index, ratio|
        sin(phase * ratio * 2pi) * index * sin(phase * pi);
    };

	var wppmPhase = Select.ar(phase > skew, [
		(0.5 * phase1) + mod.(phase1, indexA, ratioA),
		(0.5 + (0.5 * phase2)) + mod.(phase2, indexB, ratioB)
	]);

    cos(2pi * wppmPhase).neg;
};

{
    wppm.(freq: 100, skew: 0.30, ratioA: 1, ratioB: 1, indexA: 2, indexB: 0);
}.plot(0.021);
)

(
var wppm = { |freq, skew = 0.5, ratioA = 1, ratioB = 1, indexA = 0.5, indexB = 0.5|

	var safeSkew = skew.max(1e-10);
    var safeInvSkew = (1 - skew).max(1e-10);

	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);

	var phase1 = phase / safeSkew;
    var phase2 = (phase - skew) / safeInvSkew;

    var mod = { |phase, index, ratio|
		sin(phase * ratio * 2pi) * index * sin(phase * pi);
    };

	var wppmPhase = Select.ar(phase > skew, [
		(0.5 * phase1) + mod.(phase1, indexA, ratioA),
		(0.5 + (0.5 * phase2)) + mod.(phase2, indexB, ratioB)
	]);

    cos(2pi * wppmPhase).neg;
};

{
    wppm.(
        freq: 55,
        skew: MouseX.kr(0.01, 0.99),
        ratioA: 2,
        ratioB: 1,
        indexA: 2,
        indexB: 3
	) !2 * 0.1;
}.play;
)

I have added UnitUrn which creates non-repeating normalized random integers using Fisher-Yates shuffle. Additionally, it ensures no repeats across cycle boundaries - the first value of a new cycle will never match the last value of the previous cycle. The chance parameter sets the probability for the deck being reshuffled at the start of each new cycle.

The output could be scaled by length - 1 to index into a collection of data (i have decided to normalize the output between 0 and 1, so its consistent with the other ugens).

repeating cycles of non-repeating normalized random integers (no reshuffling)

(
{
    var phase, urn;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    urn = UnitUrn.ar(phase, \chance.kr(0), \length.kr(8));
    
    [phase, urn];
}.plot(0.041);
)

non-repeating cycles of non-repeating normalized random integers (reshuffling per cycle)

(
{
    var phase, urn;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    urn = UnitUrn.ar(phase, \chance.kr(1), \length.kr(8));
    
    [phase, urn];
}.plot(0.041);
)

Indexing into MultiChannel Dswitch1

(
var multiChannelDswitch = { |triggers, reset, index, arrayOfItems, numOfItems, repeatItem|
    var indexScaled = index * (numOfItems - 1);
    var demand = Ddup(repeatItem, Dswitch1(arrayOfItems, indexScaled));
    triggers.collect{ |localTrig|
        Demand.ar(localTrig + reset, reset, demand)
    };
};

var arrayOfRatios = Tuning.new((0..12) * (3.ratiomidi / 13), 3.0, "Bohlen-Pierce").ratios;

{
    var numChannels = 8;
    var numSpeakers = 2;

    var reset, tFreq;
    var events, urn, voices;
    var grainWindows, ratios, grainFreqs, grainPhases, grainOscs;
    var grains, sig, pan;

    reset = Trig1.ar(\reset.tr(0), SampleDur.ir);
    tFreq = \tFreq.kr(10);

    events = SchedulerCycle.ar(tFreq, reset);

    urn = UnitUrn.ar(
        phase: events[\phase],
        chance: 0.5,
        length: 8,
        reset: reset
    );

    voices = VoiceAllocator.ar(
        numChannels: numChannels,
        trig: events[\trigger],
        rate: events[\rate] / \overlap.kr(4),
        subSampleOffset: events[\subSampleOffset],
    );

    grainWindows = ExponentialWindow.ar(
        phase: voices[\phases],
        skew: \skew.kr(0.05),
        shape: \shape.kr(0)
    );

    ratios = multiChannelDswitch.(
        voices[\triggers],
        reset,
        urn,
        arrayOfRatios,
        \numOfItems.kr(8),
        \repeatItem.kr(1)
    );

    grainFreqs = \freqA.kr(440) * ratios;

    grainPhases = RampIntegrator.ar(
        trig: voices[\triggers],
        rate: grainFreqs,
        subSampleOffset: events[\subSampleOffset]
    );
    grainOscs = SinOsc.ar(DC.ar(0), grainPhases * 2pi);

    grains = grainOscs * grainWindows;

    pan = UnitWalk.ar(voices[\phases]);
    grains = PanAz.ar(
        numChans: numSpeakers,
        in: grains,
        pos: pan.linlin(0, 1, -1 / numSpeakers, (2 * numSpeakers - 3) / numSpeakers);
    );
    sig = grains.sum;

    sig = LeakDC.ar(sig);
    sig * 0.1;
}.play;
)
5 Likes

I have updated the release because i was not satisfied with reshuffling the whole deck in advance for every cycle. Instead we are shuffling for each draw now. This ensures no value repeats within a cycle while allowing continuous randomization via the chance parameter. The chance parameter now sets the probability of shuffling on each individual draw instead of reshuffling for each cycle. It was a bit tricky to prevent repeats across cycle boundaries for this attempt, but ive figured that out - the first value of a new cycle will never match the last value of the previous cycle.

You can spot the difference now very clearly for chance = 0.5 vs chance = 1.

(
{
    var phase, urn;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    urn = UnitUrn.ar(phase, \chance.kr(0.5), \length.kr(8));
    
    [phase, urn];
}.plot(0.041);
)

(
{
    var phase, urn;
    
    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1000));
    
    phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
    urn = UnitUrn.ar(phase, \chance.kr(1), \length.kr(8));
    
    [phase, urn];
}.plot(0.041);
)

3 Likes

The UnitUrn and UnitRegister could possibly also be Demand Ugens to index into Dswitch. Currently not sure how to make that happen. When i can figure that out i will extend the library with some Demand Ugens, where it makes sense. step generators vs. phase shapers.

2 Likes

Got DUrn to work:

(
{
    var trig, urn;

    RandSeed.kr(\seedtrg.kr(1), \seed.kr(1005));

	trig = Impulse.ar(1000);
	urn = Demand.ar(trig, DC.ar(0), DUrn(\chance.kr(0), \size.kr(8), inf));

	[Sweep.ar(trig, 1000), urn / (8 - 1)];
}.plot(0.041);
)

3 Likes

Also figured out DRegister. But the problem here is that you cant make use of the reverse encoding for contrapuntal motion between the former two outputs (3bit and 8bit) to drive complementary voices anymore. You can have a mode param to chose from 3bit or 8bit but you have to create two ugens with different seeds and therefore the voices are unrelated.

(
{
    var trig, demand, demand2, register, register2;

	trig = Impulse.ar(1000);

	RandSeed.kr(\seedtrg.kr(1), \seed.kr(200));

	demand = DRegister(\chance.kr(0), \size.kr(8), \rotate.kr(1), \modeA.kr(0), inf);
	register = Demand.ar(trig, DC.ar(0), demand);

	demand2 = DRegister(\chance.kr(0), \size.kr(8), \rotate.kr(1), \modeB.kr(1), inf);
	register2 = Demand.ar(trig, DC.ar(0), demand2);

	[Sweep.ar(trig, 1000), register, register2];
}.plot(0.041);
)

Read some stuff and did some experiments with anti derivative anti-aliasing for the buchla 259 wavefolder. Already for 1st order ADAA without additional oversampling pretty nice. The 2nd order version had some frequency and gain dependent instabilities, which im currently not able to fix.

(
{
	var sig = SinOsc.ar(880);
	sig = BuchlaFoldADAA.ar(
		input: sig, 
		drive: MouseX.kr(0, 10), 
		oversample: 0
	)!2 * 0.1;
}.play;
)

without anti-aliasing and no oversampling

with 1st order ADAA and no oversampling

2 Likes