GrainUtils - sub-sample accurate EventScheduler and dynamic VoiceAllocator

Hey,

The maximum polyphony meaning the number of grains which can be played simultaneously is set with the numChannels variable and is fixed with SynthDef evaluation.
The number of channels which are needed to represent the current polyphony can differ from the maximum polyphony. At every moment in time only the amount of channels is beeing used which is necessary to play the current number of grains simultaneously.
The whole point of the VoiceAllocator is that its not distributing each event to a new channel (round-robin method) but checks which channel is currently free to schedule a new grain, which makes it possbile to overlap grains of unequal durations. We get grains of unequal durations if we modulate the frequency of our scheduling phasor.

The overlap parameter in the example you are describing scales the trigger frequency by 2 and additionally we modulate the trigger frequency of our scheduling phasor. It turns out that using this specific modulation amount and scaling each grain duration by 2, we only need 4 channels of polyphony and not the maximum polyphony defined by numChannels.
What you can try is to disable the modulation, meaning setting tFreqMD = 0, then we are not modulating our trigger frequency and our grains have all the same durations. If you then increase overlap you see that the channels we are using is directly correlated to the overlap parameter, where the maximum overlap is still defined by numChannels.

Im actually a bit worried that this point is not coming across. Ive put alot of effort on writing the two guides, where the guide on voice allocation describes what is beeing solved when using the VoiceAllocator Ugen. Maybe i have to make it more clear where to find the guides.

Thank you.very much.
I already read the 2 guides.
If it can reassure you, I am surely stupid.

hey, it took me alot of time to figure out the problem which this library is trying to solve and even more time to make an attempt to solve it. Maybe my explanations have to be improved in the guides and helpfiles.

Hey,

I think you did a great job.
The 2 guides and the tools are wonderfull.
Like I said, I’m a noob on this domain (granular synthesis) so I think it will take me some times to understand all the details.
I will continue experimenting with these tools and regularly come back to the guides to have a reference.
Thank you, you are like a kind of Santa Claus

3 Likes

feel free to ask any questions here and im sure we can improve the user experience for everyone :slight_smile:

While answering these questions ive figured out that currently there is a subtle bug in VoiceAllocator. When you set numChannels to 5 and overlap to 5 without any trigger rate modulation of the scheduling phasor, we expect no gaps between the ramps on each channel. But we get a maximum polyphony of numChannels - 1 before every next event is dropped.
I guess thats because of 1-sample delay mismatch with incrementing and outputting phases and setting each channels isActive param. I will try to figure that out.

1 Like

The current work in progress state of the FilterBank does already have the ability to warp the spacing of the bandpass filters, here with a simple saw wave chord. I will implement the formant capability as well which is just imposing a multichannel raised cosine window on the spectrum via amplitude modulation to modulate the number and depth of the notches.

1 Like

I would suggest to take a simplified pulsar example and first add the DualOscOS without any other additional stuff. Then we can look at what you have :slight_smile:

You could start from here:

(
{
    var numChannels = 8;

    var reset, tFreq, grainFreq, overlap;
    var events, voices, windowPhases, triggers;
    var grainPhases, grainWindows;
    var grainOscs, grains, sig;

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

    tFreq = \tFreq.kr(100);
    grainFreq = \freq.kr(400);
    overlap = \overlap.ar(5);

    events = SchedulerCycle.ar(tFreq, reset);

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

    grainWindows = HanningWindow.ar(
        phase: voices[\phases],
        skew: \skew.kr(0.01)
    );
	
    grainPhases = JCurve.ar(voices[\phases], \shape.kr(0));
    grainPhases = (grainPhases * Latch.ar(overlap, voices[\triggers])).wrap(0, 1);

    grainOscs = SinOsc.ar(DC.ar(0), grainPhases * 2pi);

    grains = grainOscs * grainWindows;

    grains = PanAz.ar(2, grains, \pan.kr(0));
    sig = grains.sum;

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

okay i have fixed the bug in VoiceAllocator, i guess from now on i will make additional tags if someone finds anything and mark this as the first official release.

(
{
    var numChannels = 5;

    var reset, tFreq;
    var events, voices;

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

    tFreq = \tFreq.kr(400);

    events = SchedulerCycle.ar(tFreq, reset);

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

    voices[\phases];

}.plot(0.041);
)

now we get exactly what we want:
overlap = 1, only one channel is used


overlap = numChannels all channels are used and no gaps between phases

1 Like

My personal opinion is that the parameter binding often used in pulsar synthesis is not very versatile and only interesting for creating ratchets with some physical modelling flavour. Especially because without any additional trickery you barely get overlapping grains, or your trigger frequency has always to be close to the grain frequency, which makes the usable frequency range more narrow.

When looking at how people use the nuPG where this parameter binding also exists, i often see either people heavily using the envelope dilation parameter which is identical to our overlap parameter to actually compensate for the parameter binding or to use a one cycle window for the carrier wavetable and a multicycle window for the grain window, which bypasses the parameter binding.

In my personal pulsar implementation i therefore dont use that and have based everything on the following structure which is more versatile in my opinion. This also shows that these different microsound attempts pulsar, glisson, trainlet but also buffer granulation etc. are all implemented in the same way and implementing parameter bindings is optional.

(
{
    var numChannels = 8;
    var numSpeakers = 2;

    var reset, tFreq;
    var events, voices, windowPhases;
    var grainFreqMod, grainFreqs, grainPhases, grainWindows;
    var grainOscs, grains, sig, pan;

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

    events = SchedulerCycle.ar(tFreq, reset);

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

    grainWindows = HanningWindow.ar(
        phase: voices[\phases],
        skew: \skew.kr(0.5)
    );
	
    grainPhases = RampIntegrator.ar(
        trig: voices[\triggers],
        rate: \freq.kr(400),
        subSampleOffset: events[\subSampleOffset]
    );
    grainOscs = SinOsc.ar(DC.ar(0), grainPhases * 2pi);

    grains = grainOscs * grainWindows;

    grains = PanAz.ar(2, grains, \pan.kr(0));
    sig = grains.sum;

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

Thank you for your time.
This is my attempt starting from your example:

(
t = Signal.sineFill(2048, [1], [0]); // sine
u = Signal.sineFill(2048, 1.0/((1..512)**2)*([1,0,-1,0]!128).flatten); // tri
w = Signal.sineFill(2048, 1.0/(1..512)*([1,0]!256).flatten); // square
x = Signal.sineFill(2048, 1.0/(1..512)); // saw
v = t.addAll(u).addAll(w).addAll(x);

b = Buffer.loadCollection(s, t);
)

c.plot;

(
t = Signal.sineFill(2048, [1], [0]); // sine
u = Signal.sineFill(2048, 1.0/((1..512)**2)*([1,0,-1,0]!128).flatten); // tri
w = Signal.sineFill(2048, 1.0/(1..512)*([1,0]!256).flatten); // square
x = Signal.sineFill(2048, 1.0/(1..512)); // saw
v = t.addAll(u).addAll(w).addAll(x);

c = Buffer.loadCollection(s, w);
)

(
{
    var numChannels = 8;

    var reset, tFreq, grainFreq, overlap;
    var events, voices, windowPhases, triggers;
    var grainPhases, grainWindows;
    var grainOscs, grains, sig;
	var modScaleBipolarUp, sndBuf1, sndBuf2;

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

    tFreq = \tFreq.kr(100);
    grainFreq = \freq.kr(40);
    overlap = \overlap.ar(8);

    events = SchedulerCycle.ar(tFreq, reset);

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

    grainWindows = HanningWindow.ar(
        phase: voices[\phases],
        skew: \skew.kr(0.01)
    );

    grainPhases = JCurve.ar(voices[\phases], \shape.kr(0));
    grainPhases = (grainPhases * Latch.ar(overlap, voices[\triggers])).wrap(0, 1);

	// grainOscs = SinOsc.ar(DC.ar(0), grainPhases * 2pi);
	/*modScaleBipolarUp = { |modulator, value, amount|
        value + (modulator * (1 - value) * amount);
    };*/

	sndBuf1 = \one_sndBuf.kr(b.bufnum);
	sndBuf2 = \two_sndBuf.kr(c.bufnum);

	grainOscs = DualOscOS.ar(

		bufnumA: sndBuf1,
        phaseA: grainPhases,
        numCyclesA: BufFrames.kr(sndBuf1) / 2048,
        cyclePosA: 0/*modScaleBipolarUp.(
			modulator: SinOsc.ar(\one_tableIndexMF.kr(0.1, spec: ControlSpec(0.1, 100, step: 0.1)), 0.5 * pi),
			value: \one_tableIndex.kr(0, spec: ControlSpec(0, 1)),
			amount: \one_tableIndexMD.kr(1, spec: ControlSpec(0, 1))
		)*/,

        bufnumB: sndBuf2,
        phaseB: grainPhases,
        numCyclesB: BufFrames.kr(sndBuf2) / 2048,
		cyclePosB: 0/*modScaleBipolarUp.(
			modulator: SinOsc.ar(\two_tableIndexMF.kr(0.1, spec: ControlSpec(0.1, 100, step: 0.1)), 1.5 * pi),
			value: \two_tableIndex.kr(0, spec: ControlSpec(0, 1)),
			amount: \two_tableIndexMD.kr(1, spec: ControlSpec(0, 1))
		)*/,

		pmIndexA: \one_pmIndex.kr(0, spec: ControlSpec(0, 5)),
        pmIndexB: \two_pmIndex.kr(0, spec: ControlSpec(0, 5)),
		pmFilterRatioA: \one_pmFltRatio.kr(1, spec: ControlSpec(1, 5)),
        pmFilterRatioB: \two_pmFltRatio.kr(1, spec: ControlSpec(1, 5)),

        oversample: 1
    );

	grainOscs = grainOscs.lace;

	grainOscs = XFade2.ar(
		grainOscs[..(numChannels - 1)],
		grainOscs[numChannels..],
		\all_chainMix.kr(0.5, spec: ControlSpec(0, 1)) * 2 - 1
	);

    grains = grainOscs * grainWindows;

    grains = PanAz.ar(2, grains, \pan.kr(0));
    sig = grains.sum;

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

Is it how it is suppose to be ?

I removed my previous code cause after updating GrainUtils and DualOscOS, it was not working anymore (heavy CPU use on my machine).

that looks fine, what you can do is to use .flop here:

grainOscs = DualOscOS.ar(
	
	bufnumA: sndBuf1,
	phaseA: grainPhases,
	numCyclesA: BufFrames.kr(sndBuf1) / 2048,
	cyclePosA: 0,
	
	bufnumB: sndBuf2,
	phaseB: grainPhases,
	numCyclesB: BufFrames.kr(sndBuf2) / 2048,
	cyclePosB: 0,
	
	pmIndexA: \one_pmIndex.kr(0, spec: ControlSpec(0, 5)),
	pmIndexB: \two_pmIndex.kr(0, spec: ControlSpec(0, 5)),
	pmFilterRatioA: \one_pmFltRatio.kr(1, spec: ControlSpec(1, 5)),
	pmFilterRatioB: \two_pmFltRatio.kr(1, spec: ControlSpec(1, 5)),
	
	oversample: 1
).flop;

grainOscs = XFade2.ar(
	grainOscs[0],
	grainOscs[1],
	\all_chainMix.kr(0.5, spec: ControlSpec(0, 1)) * 2 - 1
);

what you then additionaly can do, is to setup VoiceAllocator twice to get grainPhasesA and grainPhasesB.

In your former example you have mixed up the grainPhases logic. You either use events[\rate] for VoiceAllocator and then RampIntegrator / RampAccumulator for your grainPhases or when you bind these together in combination with the phase shaping then you plug grainFreq into VoiceAllocator but then dont use RampIntegrator for your grainPhases. The parameter binding in combination with the phase shaping via JCurve and rescaling by overlap is a bit tricky to understand.

Oversampling in combination with Multichannel Expansion is pretty Heavy on CPU.

what you can also do which is similar to using JCurve for a frequency trajectory per grain is to use my other example and setup a frequency window for example:


(
{
    var numChannels = 8;

    var reset, tFreq, grainFreq, overlap;
    var events, voices, windowPhases;
    var grainPhases, grainWindows, freqWindows, grainFreqs;
    var grainOscs, grains, sig;
	var modScaleBipolarUp, sndBuf1, sndBuf2;

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

    tFreq = \tFreq.kr(100);
    overlap = \overlap.ar(1);

    events = SchedulerCycle.ar(tFreq, reset);

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

    grainWindows = HanningWindow.ar(
        phase: voices[\phases],
        skew: \windowSkew.kr(0.01)
    );
	
	freqWindows = ExponentialWindow.ar(
		phase: voices[\phases],
		skew: \freqSkew.kr(0),
		shape: \freqShpe.kr(0)
	);
	
	grainFreqs = \freq.kr(400) * (1 + (freqWindows * \freqMD.kr(2)));
	
	grainPhases = RampIntegrator.ar(
		trig: voices[\triggers],
		rate: grainFreqs,
		subSampleOffset: events[\subSampleOffset]
	);
	
	sndBuf1 = \one_sndBuf.kr(b.bufnum);
	sndBuf2 = \two_sndBuf.kr(c.bufnum);

	grainOscs = DualOscOS.ar(
		
		bufnumA: sndBuf1,
		phaseA: grainPhases,
		numCyclesA: BufFrames.kr(sndBuf1) / 2048,
		cyclePosA: 0,
		
		bufnumB: sndBuf2,
		phaseB: grainPhases,
		numCyclesB: BufFrames.kr(sndBuf2) / 2048,
		cyclePosB: 0,
		
		pmIndexA: \one_pmIndex.kr(0, spec: ControlSpec(0, 5)),
		pmIndexB: \two_pmIndex.kr(0, spec: ControlSpec(0, 5)),
		pmFilterRatioA: \one_pmFltRatio.kr(1, spec: ControlSpec(1, 5)),
		pmFilterRatioB: \two_pmFltRatio.kr(1, spec: ControlSpec(1, 5)),
		
		oversample: 1
	).flop;
	
	grainOscs = XFade2.ar(
		grainOscs[0],
		grainOscs[1],
		\all_chainMix.kr(0.5, spec: ControlSpec(0, 1)) * 2 - 1
	);

    grains = grainOscs * grainWindows;

    grains = PanAz.ar(2, grains, \pan.kr(0));
    sig = grains.sum;

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

Hello @dietcv,

is this one break the logic ?

(
t = Signal.sineFill(2048, [1], [0]); // sine
u = Signal.sineFill(2048, 1.0/((1..512)**2)*([1,0,-1,0]!128).flatten); // tri
w = Signal.sineFill(2048, 1.0/(1..512)*([1,0]!256).flatten); // square
x = Signal.sineFill(2048, 1.0/(1..512)); // saw
v = t.addAll(u).addAll(w).addAll(x);

b = Buffer.loadCollection(s, v);
)

(
SynthDef(\granul_fmPmPulsar, { arg out = 0, amp = 0.1, gate = 1, doneAction = 2;
    var numChannels = 8; // a multiple of 2
	var framesPerCycle = 2048;

	var env, reset;
    var tFreqMD, tFreqMF, tFreq;
    var overlapMD1, overlapMF1, overlap1, overlapMD2, overlapMF2, overlap2;
    var events, voicesOne, voicesTwo, halfNumChan, triggers;

    var grainFreq, grainFreqMod, grainFreqs, grainPhasesOne, grainPhasesTwo, grainWindowsOne, grainWindowsTwo;
    var grains, panPos, fmods, modPhases, pmods, flux;
    var pmIndex, modScaleBipolarUp, sndBuf1, sndBuf2, modGrains;
	var multiChannelDwhite, lfo, lfNoiseDeter, rotateUgen;

    multiChannelDwhite = { |triggers|
        var demand = Dwhite(-1.0, 1.0);
        triggers.collect{ |localTrig|
            Demand.ar(localTrig, DC.ar(0), demand)
        };
    };

    lfo = {
        var measurePhase = Phasor.ar(DC.ar(0), \rate.kr(0.5, spec: ControlSpec(0, 1000, step: 0.1)) * SampleDur.ir);
        var stepPhase = (measurePhase * \stepsPerMeasure.kr(2, spec: ControlSpec(0.0, 10, step: 0.01))).wrap(0, 1);
        var measureLFO = HanningWindow.ar(measurePhase, \skewA.kr(0.75, spec: ControlSpec(0.0, 1, step: 0.01)));
        var stepLFO = GaussianWindow.ar(stepPhase, \skewB.kr(0.5, spec: ControlSpec(0.0, 1, step: 0.01)), \index.kr(1, spec: ControlSpec(0.0, 1)));
        stepLFO * measureLFO;
    };

	modScaleBipolarUp = { |modulator, value, amount|
		value + (modulator * (1 - value) * amount);
	};

	lfNoiseDeter = { arg freq = 1, lagTime = 0.1;
		Ramp.ar(
			Hasher.ar(Sweep.ar(SinOsc.ar(freq))),
			lagTime
		);
	};

	rotateUgen = { arg rotate = 1, array;
		var size = array.size;
		var indices = ((0..(size - 1)) + rotate.neg).mod(size);
		LinSelectX.ar(indices, array);
	};

	env = Env.asr(
		\atk.kr(0.0, spec: ControlSpec(0.0, 1, step: 0.01)),
		1,
		\rls.kr(0.4, spec: ControlSpec(0.0, 1, step: 0.01)),
		\crv.kr(-4, spec: ControlSpec(-40, 40, step: 0.1))
	).ar(gate, doneAction);

    reset = Trig1.ar(\reset.tr(0, ControlSpec(-1, 1, step: 1)), SampleDur.ir);

    flux = lfNoiseDeter.(\fluxMF.kr(1, spec: ControlSpec(0, 1000, step: 1)));
    flux = 2 ** (flux * \fluxMD.kr(0.5, spec: ControlSpec(0, 10, step: 0.01)));

    tFreqMD = \tFreqMD.kr(2, spec: ControlSpec(0.0, 4, step: 0.01));
	tFreqMF = \tFreqMF.kr(0.3, spec: ControlSpec(0.01, 1000, step: 1));
    tFreq = \tFreq.kr(10, spec: ControlSpec(0.0, 1000, step: 1)) * (2 ** (SinOsc.ar(tFreqMF) * tFreqMD));

	grainFreq = (\freq.kr(120, spec: ControlSpec(1, 5000, step: 1)) * flux).clip(0.1, 20000);

    overlapMD1 = \one_overlapMD.kr(0, spec: ControlSpec(0, 10, step: 0.01));
	overlapMF1 = \one_overlapMF.kr(0.1, spec: ControlSpec(0.0, 1000, step: 1));
	overlap1 = \one_overlap.kr(1, spec: ControlSpec(0, 50, step: 0.01)) * (2 ** (lfNoiseDeter.(overlapMF1, LFCub.ar(overlapMF1).linlin(-1, 1, 0.01, 0.2)) * overlapMD1));

	overlapMD2 = \two_overlapMD.kr(0, spec: ControlSpec(0, 10, step: 0.01));
	overlapMF2 = \two_overlapMF.kr(0.1, spec: ControlSpec(0.0, 1000, step: 1));
	overlap2 = \two_overlap.kr(1, spec: ControlSpec(0, 50, step: 0.01)) * (2 ** (lfNoiseDeter.(overlapMF2, LFCub.ar(overlapMF2).linlin(-1, 1, 0.01, 0.2)) * overlapMD2));

    events = SchedulerCycle.ar(tFreq, reset);

	halfNumChan = (numChannels * 0.5).asInteger;
    voicesOne = VoiceAllocator.ar(
		numChannels: halfNumChan,
        trig: events[\trigger],
        rate: grainFreq / overlap1,
        subSampleOffset: events[\subSampleOffset],
    );

	voicesTwo = VoiceAllocator.ar(
        numChannels: halfNumChan,
        trig: events[\trigger],
		rate: grainFreq / overlap2,
        subSampleOffset: events[\subSampleOffset],
    );

	modGrains = LocalIn.ar(halfNumChan, 1.0);
	modGrains = rotateUgen.(
		Latch.ar(\modGrainsRot.kr(1, spec: ControlSpec(0, halfNumChan - 1, step: 1)), voicesOne[\triggers]),
		modGrains
	);

    grainWindowsOne = HanningWindow.ar(
        phase: voicesOne[\phases],
		skew: \one_skew.kr(0.05, spec: ControlSpec(0, 1, step: 0.01))
    );

	grainWindowsTwo = HanningWindow.ar(
        phase: voicesTwo[\phases],
		skew: \two_skew.kr(0.05, spec: ControlSpec(0, 1, step: 0.01))
    );

    grainFreqMod = multiChannelDwhite.(voicesOne[\triggers]);
	grainFreqs = (grainFreq * (2 ** (grainFreqMod * \freqMD.kr(1, spec: ControlSpec(0, 10, step: 0.01))))).clip(20, 20000);

    fmods = ExponentialWindow.ar(
        phase: voicesOne[\phases],
        skew: (\pitchSkew.kr(0.03, spec: ControlSpec(0, 1, step: 0.01)) * lfo.()).wrap(0.0, 1).lag2,
        shape: (\pitchShape.kr(0, spec: ControlSpec(0, 1, step: 0.01)) * (1 - lfo.())).wrap(0.0, 1).lag2
    ) * \pitchMD.kr(0, spec: ControlSpec(0, 10, step: 0.01));

    grainPhasesOne = RampIntegrator.ar(
        trig: voicesOne[\triggers],
		rate: (grainFreqs * (1 + ((2 ** fmods) * modGrains.linexp(0.0, 1, 0.01, 100)))).clip(0.1, 20000),
        subSampleOffset: events[\subSampleOffset]
    );

   modPhases = RampIntegrator.ar(
        trig: voicesOne[\triggers],
        rate: grainFreqs * \pmRatio.kr(1.5, spec: ControlSpec(0, 10, step: 0.01)),
        subSampleOffset: events[\subSampleOffset]
    );
    pmods = SinOsc.ar(DC.ar(0), modPhases * 2pi);

    grainPhasesOne = (grainPhasesOne + (pmods * \pmIndex.kr(1, spec: ControlSpec(0, 10, step: 0.01)))).wrap(0, 1);

	grainPhasesTwo = SCurve.ar(
		voicesTwo[\phases],
		\sCurveShape.kr(0.5, spec: ControlSpec(0, 1, step: 0.01)),
		\sCurveInflection.kr(0.5, spec: ControlSpec(0, 1, step: 0.01))
	);
    grainPhasesTwo = (grainPhasesTwo * Latch.ar(overlap2, voicesTwo[\triggers])).wrap(0, 1);

	sndBuf1 = \one_sndBuf.kr(b.bufnum);
	sndBuf2 = \two_sndBuf.kr(b.bufnum);

	grains = DualOscOS.ar(

		bufnumA: sndBuf1,
        phaseA: grainPhasesOne,
        numCyclesA: BufFrames.kr(sndBuf1) / framesPerCycle,
        cyclePosA: modScaleBipolarUp.(
			modulator: SinOsc.ar(\one_tableIndexMF.kr(0.1, spec: ControlSpec(0.1, 1000, step: 1)), 0.5 * pi),
			value: \one_tableIndex.kr(0, spec: ControlSpec(0, 1)),
			amount: \one_tableIndexMD.kr(1, spec: ControlSpec(0, 1))
		),

        bufnumB: sndBuf2,
        phaseB: grainPhasesTwo,
        numCyclesB: BufFrames.kr(sndBuf2) / framesPerCycle,
		cyclePosB: modScaleBipolarUp.(
			modulator: SinOsc.ar(\two_tableIndexMF.kr(0.1, spec: ControlSpec(0.1, 1000, step: 1)), 1.5 * pi),
			value: \two_tableIndex.kr(0, spec: ControlSpec(0, 1)),
			amount: \two_tableIndexMD.kr(1, spec: ControlSpec(0, 1))
		),

		pmIndexA: \one_pmIndex.kr(0, spec: ControlSpec(0, 10)),
        pmIndexB: \two_pmIndex.kr(0, spec: ControlSpec(0, 10)),
		pmFilterRatioA: \one_pmFltRatio.kr(1, spec: ControlSpec(1, 10)),
        pmFilterRatioB: \two_pmFltRatio.kr(1, spec: ControlSpec(1, 10)),

        oversample: 1
    );

	grains = grains.flop;

	grains = XFade2.ar(
		grains[0] * grainWindowsOne,
		grains[1] * grainWindowsTwo,
		\all_chainMix.kr(0.5, spec: ControlSpec(0, 1)) * 2 - 1
	);

	LocalOut.ar(
		Amplitude.ar(
			grains,
			\modGrainAtk.kr(0.0, spec: ControlSpec(0, 1, step: 0.01)),
			\modGrainRls.kr(0.7, spec: ControlSpec(0, 1, step: 0.01)),
		) * \modGrainMD.kr(0.98, spec: ControlSpec(0, 1, step: 0.01))
	);

    panPos = multiChannelDwhite.(voicesOne[\triggers] ++ voicesTwo[\triggers]);
    grains = Pan2.ar(
        in: grains,
        pos: panPos
    );

	grains = Splay.ar(Mix.ar(grains), \spreadSt.kr(1, spec: ControlSpec(0.0, 1, step:0.01)));

	grains = Balance2.ar(grains[0], grains[1], \pan.kr(0.5, spec: ControlSpec(0.0, 1, step: 0.01)).linlin(0.0, 1, -1, 1.0));

    grains = LeakDC.ar(grains);
    OffsetOut.ar(out, grains * amp * env);
}).add;

~nodeProxyGui2 = { |synthDefName, initArgs=#[], excludeParams=#[], ignoreParams=#[]|
    var synthDef;

    synthDef = try{ SynthDescLib.global[synthDefName].def } ?? {
        Error("SynthDef '%' not found".format(synthDefName)).throw
    };

    a = NodeProxy.audio(s, 2);
    a.prime(synthDef).set(*initArgs);

    NodeProxyGui2(a, show: true)
    .excludeParams_(excludeParams)
    .ignoreParams_(ignoreParams)
};
)

(
g = ~nodeProxyGui2.(\granul_fmPmPulsar,
    [\one_sndBuf, b.bufnum, \two_sndBuf, b.bufnum, 'two_overlapMD', 2.66, 'pmRatio', 9.6, 'rate', 851.2, 'fluxMF', 12.0, 'sCurveInflection', 0.23, 'one_pmIndex', 4.64, 'two_pmFltRatio', 1.1088, 'pmIndex', 0.66, 'two_overlapMF', 84.32, 'tFreq', 16.0, 'two_tableIndexMF', 12.2, 'modGrainAtk', 0.0, 'one_tableIndexMD', 0.9696, 'fluxMD', 0.0, 'modGrainRls', 0.28, 'pitchSkew', 0.14, 'freq', 20.0, 'spreadSt', 0.83, 'index', 0.11036169528961, 'one_tableIndex', 0.0016, 'one_overlap', 2.88, 'modGrainsRot', 2.0, 'rls', 0.62, 'one_tableIndexMF', 47.4, 'skewA', 0.73, 'two_pmIndex', 3.696, 'two_skew', 0.09, 'pitchMD', 6.7, 'sCurveShape', 0.79, 'skewB', 0.23, 'one_skew', 1.0, 'two_tableIndexMD', 0.6816, 'pitchShape', 0.09, 'amp', 0.11717455621302, 'one_overlapMD', 0.0, 'modGrainMD', 0.45, 'two_overlap', 1.36, 'all_chainMix', 0.7984, 'one_overlapMF', 72.64, 'tFreqMF', 36.65, 'freqMD', 0.0, 'stepsPerMeasure', 0.96, 'tFreqMD', 2.85, 'one_pmFltRatio', 1.512, 'two_tableIndex', 0.096],
    [\one_sndBuf, \two_sndBuf, \gate, \doneAction],
	[\one_sndBuf, \two_sndBuf, \reset, \amp, \pan, \atk, \rls, \crv, \pan, \tFreqMD, \gate, \doneAction],
);
a.play;
)

a.set('crv', 5.5, 'two_overlapMD', 6.89, 'pmRatio', 0.8, 'rate', 204.0, 'fluxMF', 774.0, 'sCurveInflection', 0.51, 'one_pmIndex', 4.7212326526642, 'two_pmFltRatio', 4.460146188736, 'pmIndex', 3.26, 'two_overlapMF', 424.0, 'tFreq', 8.0, 'two_tableIndexMF', 939.0, 'modGrainAtk', 0.56, 'one_tableIndexMD', 0.15365064144135, 'fluxMD', 3.89, 'modGrainRls', 0.63, 'pitchSkew', 0.44, 'rls', 0.62, 'freq', 2213.0, 'spreadSt', 0.7, 'index', 0.28012585639954, 'one_tableIndex', 0.75902736186981, 'one_overlap', 40.24, 'modGrainsRot', 3.0, 'one_tableIndexMF', 886.0, 'skewA', 0.55, 'two_pmIndex', 5.3263545036316, 'two_skew', 0.85, 'pitchMD', 9.19, 'sCurveShape', 0.51, 'skewB', 0.67, 'one_skew', 0.22, 'two_tableIndexMD', 0.040286064147949, 'pitchShape', 0.03, 'atk', 0.0, 'amp', 0.11717455621302, 'one_overlapMD', 3.74, 'modGrainMD', 0.68, 'two_overlap', 27.09, 'all_chainMix', 0.38863885402679, 'one_overlapMF', 439.0, 'tFreqMF', 610.0, 'freqMD', 5.03, 'stepsPerMeasure', 7.19, 'tFreqMD', 1.38, 'one_pmFltRatio', 2.2449086904526, 'two_tableIndex', 0.56642937660217);

a.set('crv', 5.5, 'two_overlapMD', 2.21, 'pmRatio', 1.33, 'rate', 896.1, 'fluxMF', 626.0, 'sCurveInflection', 0.29, 'one_pmIndex', 0.028153657913208, 'two_pmFltRatio', 9.2342529296875, 'pmIndex', 0.38, 'two_overlapMF', 13.0, 'tFreq', 5.0, 'two_tableIndexMF', 237.0, 'modGrainAtk', 0.09, 'one_tableIndexMD', 0.76888370513916, 'fluxMD', 2.67, 'modGrainRls', 0.55, 'pitchSkew', 0.22, 'rls', 0.62, 'freq', 3662.0, 'spreadSt', 0.13, 'index', 0.99739801883698, 'one_tableIndex', 0.42799246311188, 'one_overlap', 2.75, 'modGrainsRot', 0.0, 'one_tableIndexMF', 143.0, 'skewA', 0.87, 'two_pmIndex', 5.4234480857849, 'two_skew', 0.65, 'pitchMD', 4.94, 'sCurveShape', 0.41, 'skewB', 0.26, 'one_skew', 0.34, 'two_tableIndexMD', 0.089032173156738, 'pitchShape', 0.19, 'atk', 0.0, 'amp', 0.11717455621302, 'one_overlapMD', 7.44, 'modGrainMD', 0.13, 'two_overlap', 35.63, 'all_chainMix', 0.95477521419525, 'one_overlapMF', 80.0, 'tFreqMF', 44.0, 'freqMD', 8.3, 'stepsPerMeasure', 5.29, 'tFreqMD', 1.38, 'one_pmFltRatio', 3.2887836694717, 'two_tableIndex', 0.068849325180054);

a.set('crv', 5.5, 'two_overlapMD', 5.91, 'pmRatio', 9.96, 'rate', 576.8, 'fluxMF', 263.0, 'sCurveInflection', 0.51, 'one_pmIndex', 3.4016919136047, 'two_pmFltRatio', 7.2178508043289, 'pmIndex', 1.37, 'two_overlapMF', 655.0, 'tFreq', 12.0, 'two_tableIndexMF', 930.0, 'modGrainAtk', 0.03, 'one_tableIndexMD', 0.55664503574371, 'fluxMD', 0.8, 'modGrainRls', 0.39, 'pitchSkew', 0.76, 'rls', 0.62, 'freq', 4930.0, 'spreadSt', 0.63, 'index', 0.21828091144562, 'one_tableIndex', 0.51904475688934, 'one_overlap', 10.84, 'modGrainsRot', 0.0, 'one_tableIndexMF', 332.0, 'skewA', 0.0, 'two_pmIndex', 2.2157835960388, 'two_skew', 0.91, 'pitchMD', 6.68, 'sCurveShape', 0.72, 'skewB', 0.3, 'one_skew', 0.49, 'two_tableIndexMD', 0.40403890609741, 'pitchShape', 0.51, 'atk', 0.0, 'amp', 0.11717455621302, 'one_overlapMD', 0.79, 'modGrainMD', 0.77, 'two_overlap', 8.88, 'all_chainMix', 1.0, 'one_overlapMF', 360.0, 'tFreqMF', 268.0, 'freqMD', 5.58, 'stepsPerMeasure', 6.87, 'tFreqMD', 1.38, 'one_pmFltRatio', 6.8094372749329, 'two_tableIndex', 0.40684270858765);

a.set('crv', 5.5, 'two_overlapMD', 5.1, 'pmRatio', 7.45, 'rate', 142.9, 'fluxMF', 672.0, 'sCurveInflection', 0.79, 'one_pmIndex', 3.4045553207397, 'two_pmFltRatio', 9.6182851791382, 'pmIndex', 0.39, 'two_overlapMF', 724.0, 'tFreq', 6.0, 'two_tableIndexMF', 564.0, 'modGrainAtk', 0.2, 'one_tableIndexMD', 0.42526161670685, 'fluxMD', 4.8, 'modGrainRls', 0.59, 'pitchSkew', 0.93, 'rls', 0.62, 'freq', 1877.0, 'spreadSt', 0.35, 'index', 0.6485869884491, 'one_tableIndex', 0.73375153541565, 'one_overlap', 9.66, 'modGrainsRot', 2.0, 'one_tableIndexMF', 55.0, 'skewA', 0.32, 'two_pmIndex', 6.8074655532837, 'two_skew', 0.45, 'pitchMD', 5.49, 'sCurveShape', 0.3, 'skewB', 0.9, 'one_skew', 0.38, 'two_tableIndexMD', 0.82318103313446, 'pitchShape', 0.63, 'atk', 0.0, 'amp', 0.11717455621302, 'one_overlapMD', 0.34, 'modGrainMD', 0.0, 'two_overlap', 14.72, 'all_chainMix', 0.53241193294525, 'one_overlapMF', 610.0, 'tFreqMF', 391.0, 'freqMD', 1.09, 'stepsPerMeasure', 9.39, 'tFreqMD', 1.38, 'one_pmFltRatio', 3.7132893800735, 'two_tableIndex', 0.020217895507812);

a.set('crv', 5.5, 'two_overlapMD', 6.35, 'pmRatio', 4.12, 'rate', 875.5, 'fluxMF', 803.0, 'sCurveInflection', 0.52, 'one_pmIndex', 4.0700900554657, 'two_pmFltRatio', 2.1450650691986, 'pmIndex', 1.32, 'two_overlapMF', 858.0, 'tFreq', 6.0, 'two_tableIndexMF', 960.0, 'modGrainAtk', 0.09, 'one_tableIndexMD', 0.92516887187958, 'fluxMD', 0.67, 'modGrainRls', 0.39, 'pitchSkew', 0.66, 'rls', 0.62, 'freq', 1308.0, 'spreadSt', 0.72, 'index', 0.84444868564606, 'one_tableIndex', 0.97361671924591, 'one_overlap', 35.34, 'modGrainsRot', 2.0, 'one_tableIndexMF', 565.0, 'skewA', 0.86, 'two_pmIndex', 1.3722670078278, 'two_skew', 0.05, 'pitchMD', 6.24, 'sCurveShape', 0.56, 'skewB', 0.18, 'one_skew', 0.21, 'two_tableIndexMD', 0.34513318538666, 'pitchShape', 0.81, 'atk', 0.0, 'amp', 0.11717455621302, 'one_overlapMD', 0.77, 'modGrainMD', 0.01, 'two_overlap', 20.72, 'all_chainMix', 1.0, 'one_overlapMF', 847.0, 'tFreqMF', 346.0, 'freqMD', 9.62, 'stepsPerMeasure', 1.32, 'tFreqMD', 1.38, 'one_pmFltRatio', 1.8739827871323, 'two_tableIndex', 0.52670407295227);

a.set('crv', 5.5, 'two_overlapMD', 2.51, 'pmRatio', 0.93, 'rate', 534.5, 'fluxMF', 435.0, 'sCurveInflection', 0.05, 'one_pmIndex', 6.679162979126, 'two_pmFltRatio', 3.2207596302032, 'pmIndex', 6.51, 'two_overlapMF', 335.0, 'tFreq', 93.0, 'two_tableIndexMF', 121.0, 'modGrainAtk', 0.14, 'one_tableIndexMD', 0.41621971130371, 'fluxMD', 7.28, 'modGrainRls', 0.75, 'pitchSkew', 0.24, 'rls', 0.62, 'freq', 4724.0, 'spreadSt', 0.4, 'index', 0.90952014923096, 'one_tableIndex', 0.90721964836121, 'one_overlap', 24.26, 'modGrainsRot', 1.0, 'one_tableIndexMF', 816.0, 'skewA', 0.36, 'two_pmIndex', 6.9025266170502, 'two_skew', 0.51, 'pitchMD', 5.66, 'sCurveShape', 0.05, 'skewB', 0.95, 'one_skew', 0.76, 'two_tableIndexMD', 0.4649178981781, 'pitchShape', 0.29, 'atk', 0.0, 'amp', 0.11717455621302, 'one_overlapMD', 2.24, 'modGrainMD', 0.17, 'two_overlap', 41.03, 'all_chainMix', 0.46884906291962, 'one_overlapMF', 302.0, 'tFreqMF', 561.0, 'freqMD', 9.08, 'stepsPerMeasure', 4.41, 'tFreqMD', 1.38, 'one_pmFltRatio', 1.9905687570572, 'two_tableIndex', 0.049690246582031);

g.randomize;
g.vary

It would really help to implement all the different things one by one, now its packed again with alot of other stuff. The first thing you might want to have are two different grain phases with different frequencies which are driving each oscillator of DualOscOS. You could either create these by using grainFreqA and grainFreqB into voicesA and voicesB and then scale these linear phases between 0 and 1 by a ratioA and a ratioB and wrap these phases between 0 and 1, or you use tFreq for creating the voices and then accumulate or integrate your grainPhasesA and grainPhasesB with RampIntegrator / RampAccumulator each with a different frequency grainFreqA and grainFreqB. It seems to me that the second version might be easier to understand. With the first version you dont use frequencies anymore after you have created voicesA and voicesB you just scale phases.

for example like this, this would be option a.

(
var multiChannelDwhite = { |triggers|
	var demand = Dwhite(-1.0, 1.0);
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, DC.ar(0), demand)
	};
};

SynthDef(\granul_fmPmPulsar, { |sndBufOne, sndBufTwo|
	
    var numChannels = 5;
	var framesPerCycle = 2048;

	var reset, tFreq, events;
	var grainFreqOne, overlapOne, voicesOne, grainWindowsOne, grainPhasesOne;
	var grainFreqTwo, overlapTwo, voicesTwo, grainWindowsTwo, grainPhasesTwo;
	
	var panPosOne, grainsOne;
	var panPosTwo, grainsTwo;
	var grains, sig;
	
    reset = Trig1.ar(\reset.tr(0), SampleDur.ir);

    tFreq = \tFreq.kr(10);

    events = SchedulerCycle.ar(tFreq, reset);
	
	/////////////////////////////////////////////////////////////////////////

	// chain one - allocate voices and calculate grain phases
	
	grainFreqOne = \one_freq.kr(440);
	overlapOne = \one_overlap.ar(1);
	
    voicesOne = VoiceAllocator.ar(
		numChannels: numChannels,
        trig: events[\trigger],
        rate: grainFreqOne / overlapOne,
        subSampleOffset: events[\subSampleOffset],
    );
	
	grainWindowsOne = HanningWindow.ar(
        phase: voicesOne[\phases],
		skew: \one_skew.kr(0.5)
    );
	
	grainPhasesOne = (voicesOne[\phases] * Latch.ar(overlapOne, voicesOne[\triggers])).wrap(0, 1);
	
	/////////////////////////////////////////////////////////////////////////
	
	// chain two - allocate voices and calculate grain phases
	
	grainFreqTwo = \two_freq.kr(440);
	overlapTwo = \two_overlap.ar(1);

	voicesTwo = VoiceAllocator.ar(
        numChannels: numChannels,
        trig: events[\trigger],
		rate: grainFreqTwo / overlapTwo,
        subSampleOffset: events[\subSampleOffset],
    );

	grainWindowsTwo = HanningWindow.ar(
        phase: voicesTwo[\phases],
		skew: \two_skew.kr(0.05, spec: ControlSpec(0, 1, step: 0.01))
    );

    grainPhasesTwo = (voicesTwo[\phases] * Latch.ar(overlapTwo, voicesTwo[\triggers])).wrap(0, 1);

	/////////////////////////////////////////////////////////////////////////

	grains = DualOscOS.ar(

		bufnumA: sndBufOne,
        phaseA: grainPhasesOne,
        numCyclesA: BufFrames.kr(sndBufOne) / framesPerCycle,
        cyclePosA: \one_tableIndex.kr(0, spec: ControlSpec(0, 1)),

        bufnumB: sndBufTwo,
        phaseB: grainPhasesTwo,
        numCyclesB: BufFrames.kr(sndBufTwo) / framesPerCycle,
		cyclePosB: \two_tableIndex.kr(0, spec: ControlSpec(0, 1)),

		pmIndexA: \one_pmIndex.kr(0, spec: ControlSpec(0, 10)),
        pmIndexB: \two_pmIndex.kr(0, spec: ControlSpec(0, 10)),
		pmFilterRatioA: \one_pmFltRatio.kr(1, spec: ControlSpec(1, 10)),
        pmFilterRatioB: \two_pmFltRatio.kr(1, spec: ControlSpec(1, 10)),

        oversample: 1
    );

	grains = grains.flop;
	
	/////////////////////////////////////////////////////////////////////////
	
	// chain one - multiply oscs by grainwindows and apply panning 
	
	grainsOne = grains[0] * grainWindowsOne;
	
	panPosOne = multiChannelDwhite.(voicesOne[\triggers]);
	grainsOne = Pan2.ar(
		in: grainsOne,
		pos: panPosOne
	);
	grainsOne = grainsOne.sum;
	
	/////////////////////////////////////////////////////////////////////////
	
	// chain two - multiply oscs by grainwindows and apply panning
	
	grainsTwo = grains[1] * grainWindowsTwo;
	
	panPosTwo = multiChannelDwhite.(voicesTwo[\triggers]);
	grainsTwo = Pan2.ar(
		in: grainsTwo,
		pos: panPosTwo
	);
	grainsTwo = grainsTwo.sum;
	
	/////////////////////////////////////////////////////////////////////////
	
	// mix both chains

	sig = XFade2.ar(
		grainsOne,
		grainsTwo,
		\all_chainMix.kr(0.5, spec: ControlSpec(0, 1)) * 2 - 1
	);
	
    sig = LeakDC.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)

Thank you so much and some more characters

1 Like

When adding modulation to each of the parameters for the chains the SynthDef gets really packed and you could easily do a typo. You can consider to create a function for grainData, oscData etc. and pass the chainID to these functions, where these functions then encapsulate the modulation and output a dictionary of different things. Like im doing in the DualOscOS helpfile example. Then you also have a more easy time deciding what to add to which chain because they are symmetrical, you wouldnt think about adding phase shaping to one and phase modulation to the other, just because you can. Thats just some interface design logic.

In my version im doing something like that:


	// Function to calculate grains
	calcGrains = { |chainID, phaseOffset, windowPhases, grainOscs|

		var panMF, panMod, pan;
		var grainWindows, grains;

		// Create position modulation
		panMF = param.(chainID, \panMF, 0.3, ControlSpec(0.01, 1));
		panMod = { |phase|
			SinOsc.ar(panMF, phase + phaseOffset * pi)
		};

		pan = ~utils.helperFunctions[\modScaleBipolar].(
			modulator: panMod.(0.5),
			value: param.(chainID, \pan, 0.5, ControlSpec(0, 1)),
			amount: param.(chainID, \panMD, 0, ControlSpec(0, 1)),
			direction: \full
		);

		// Create grain windows
		grainWindows = GaussianWindow.ar(
			windowPhases,
			param.(chainID, \windowSkew, 0.5, ControlSpec(0.001, 0.999)),
			param.(chainID, \windowIndex, 0, ControlSpec(0, 5))
		);

		grains = grainOscs * grainWindows;

		grains = PanAz.ar(
			numChans: numSpeakers,
			in: grains,
			pos: pan.linlin(0, 1, -1 / numSpeakers, (2 * numSpeakers - 3) / numSpeakers)
		);
		grains.sum;
	};

and then you evaluate calcGrains two times for each chain, you additionally need to create the functions to calculate the phaseData and the oscData but i guess you can figure that out on your own :wink:

1 Like

Hello @dietcv,

I followed your advices and I made these sounds with a new graph from your example and by implementing things one after the other.
I don’t know if it’s perfect but I have the feeling of getting closer of what I’m after, so thank you for the tools, the guides and your patience.



2 Likes