Syncing GrainBuf with an LFO that modulates its playback rate

I want to modulate the playback rate of a buffer without changing the pitch (i.e. time-stretching). So I am playing it through GrainBuf, and using a SinOsc as an LFO to modulate the phasor rate. This is simple enough, but the tricky part is that I want the period of the LFO to exactly match the period of the modulated buffer so that when it plays in a loop, it always slows down and speeds up at the exact same parts of the buffer. If you run this code, you will hear the effect that I’m going for:

b = Buffer.read(s, Platform.resourceDir +/+ "sounds/a11wlk01.wav");

(
SynthDef(\bufstretch, {|buf|
    var bufdur, maxrate, minrate, lfo_period,
    phasor_rate, phasor, graintrig, graindur, sig;
    bufdur = BufDur.ir(buf); // 4.28329
    maxrate = 4.0;
    minrate = 0.1;
    lfo_period = 2.09;
    // I found the correct lfo_period through trial and error.
    // Can it be calculated as a function of maxrate, minrate, and bufdur?
    phasor_rate = SinOsc.ar(lfo_period.reciprocal, pi/2).range(minrate, maxrate);
    phasor = Sweep.ar(1, phasor_rate / bufdur);
    graintrig = Impulse.ar(\trigrate.kr(20));
    graindur = \trigrate.kr.reciprocal * \overlap.kr(2);
    sig = GrainBuf.ar(2, graintrig, graindur, buf, BufRateScale.ir(buf), phasor);
    Out.ar(0, sig);
}).add;
)

Synth(\bufstretch, [buf: b]);

You can see in my code that I have hard-coded lfo_period = 2.09, which I arrived at through trial and error. This is the approximate duration of the buffer after it has undergone one period of modulation. But I would like to be able to calculate lfo_period as a function of maxrate, minrate, and bufdur so that I can change these values and still maintain perfect sync between the buffer and LFO. I think it is possible to do so with some calculus, but unfortunately my calculus skills are extremely rusty. Does anyone know how to solve this?

1 Like

I think I figured out a solution. I use Integrator in combination with Sweep to find the average of all phasor_rate values and assign this to the variable integrated_mean. I figured the correct lfo_rate should be integrated_mean / bufdur, so I use LocalOut/LocalIn to feedback this value to lfo_rate. I didn’t think it would work, but it does!

b = Buffer.read(s, Platform.resourceDir +/+ "sounds/a11wlk01.wav");

(
SynthDef(\bufstretch, {|buf|
    var bufdur, maxrate, minrate, lfo_rate, integrated_mean, phasor_avg,
    phasor_rate, phasor_rate_inv, phasor, graintrig, graindur, sig;
    bufdur = BufDur.ir(buf); // 4.28329
    maxrate = \maxrate.ir(4.0);
    minrate = \minrate.ir(0.1);
    lfo_rate = LocalIn.ar(1).poll(label: "calculated lfo rate");
    phasor_rate = SinOsc.ar(lfo_rate, \phase.kr(pi/2)).range(minrate, maxrate);
    phasor_rate_inv = phasor_rate.linlin(minrate, maxrate, maxrate, minrate);
    phasor_avg = (phasor_rate + phasor_rate_inv) / 2;
    integrated_mean = Integrator.ar(phasor_avg) / Sweep.ar(0, SampleRate.ir);
    LocalOut.ar(integrated_mean / bufdur);
    phasor = Sweep.ar(1, phasor_rate / bufdur);
    graintrig = Impulse.ar(\trigrate.kr(20));
    graindur = \trigrate.kr.reciprocal * \overlap.kr(2);
    sig = GrainBuf.ar(2, graintrig, graindur, buf, BufRateScale.ir(buf), phasor);
    Out.ar(0, sig);
}).add;
)

// some tests. These work well:
Synth(\bufstretch, [buf: b]);
Synth(\bufstretch, [buf: b, maxrate: 5.0, minrate: 0.5, phase: pi]);
Synth(\bufstretch, [buf: b, maxrate: 3.0, minrate: 0.2, trigrate: 100]);

// doesn't sound in sync with fast rates...
Synth(\bufstretch, [buf: b, maxrate: 15.0, minrate: 4.0, phase: 3pi/2]);

// ...unless you also use a very fast trigrate in the GrainBuf:
Synth(\bufstretch, [buf: b, maxrate: 15.0, minrate: 4.0, phase: 3pi/2, trigrate: 200]);

Now the only issue I have is that maxrate and minrate can’t be changed on the fly because Integrator would still use previous values to calculate the mean. Unless it is possible to reset Integrator when a new value is received for maxrate or minrate? Is this possible? I’m still interested in an analytical/calculus solution as well, but this is looking like some kind of differential equation or something beyond my pay grade.

You have to reset both the numerator and denominator of the integrated mean (the Integrator and the Sweep). Resetting the integrator is accomplished by setting the integration coefficient to zero using a trigger, and Sweep already has a reset argument.

(
SynthDef(\bufstretch, {|buf|
    var bufdur, maxrate, minrate, changed, lfo_rate, integrated_mean, phasor_avg,
    phasor_rate, phasor_rate_inv, phasor, graintrig, graindur, sig;
    bufdur = BufDur.ir(buf); // 4.28329
    maxrate = \maxrate.kr(4.0);
    minrate = \minrate.kr(0.1);
	changed = Changed.kr(maxrate) + Changed.kr(minrate) / 2;
    lfo_rate = LocalIn.ar(1).poll(label: "calculated lfo rate");
    phasor_rate = SinOsc.ar(lfo_rate, \phase.kr(pi/2)).range(minrate, maxrate);
    phasor_rate_inv = phasor_rate.linlin(minrate, maxrate, maxrate, minrate);
    phasor_avg = (phasor_rate + phasor_rate_inv) / 2;
	integrated_mean = Integrator.ar(phasor_avg, 1 - changed) / Sweep.ar(changed, SampleRate.ir);
    LocalOut.ar(integrated_mean / bufdur);
    phasor = Sweep.ar(1, phasor_rate / bufdur);
    graintrig = Impulse.ar(\trigrate.kr(20));
    graindur = \trigrate.kr.reciprocal * \overlap.kr(2);
    sig = GrainBuf.ar(2, graintrig, graindur, buf, BufRateScale.ir(buf), phasor);
    Out.ar(0, sig);
}).add;
)

x = Synth(\bufstretch, [buf: b]);
x.set(\maxrate, 2);
x.set(\minrate, 0.6);
4 Likes

Ah, I totally forgot about Changed. That works perfectly! Thanks so much for your help

One catch here is that Integrator’s reset trigger is control rate, not audio rate. An audio rate trigger occurring in the middle of a control block will not reset it.

If you need an audio rate reset:

var reset_trig = /* some audio-rate trigger */;
var inverse_trig = reset_trig <= 0;

// audio-rate resettable integrator
FOS.ar(phasor_avg, inverse_trig, DC.ar(0), inverse_trig)

For FOS or to update its coefficients at audio rate, all inputs must be audio rate. If reset_trig is ar, then inverse_trig will also be ar.

hjh

2 Likes

Thank you, James! It definitely finds the “correct” lfo_rate much more quickly when using FOS.ar and audio rate controls. Here is my updated version:

b = Buffer.read(s, Platform.resourceDir +/+ "sounds/a11wlk01.wav");

(
SynthDef(\bufstretch, {|buf|
    var bufdur, maxrate, minrate, lfo_rate, phasor_avg,
    reset_trig, inverse_trig, integral, integrated_mean,
    phasor_rate, phasor_rate_inv, phasor, graintrig, graindur, sig;
    bufdur = BufDur.ir(buf);
    maxrate = \maxrate.ar(4.0);
    minrate = \minrate.ar(0.1);
    reset_trig = Changed.ar(maxrate) + Changed.ar(minrate) / 2;
    inverse_trig = reset_trig <= 0;
    lfo_rate = LocalIn.ar(1).poll(label: "calculated lfo rate");
    phasor_rate = SinOsc.ar(lfo_rate, \phase.kr(pi/2)).range(minrate, maxrate);
    phasor_rate_inv = phasor_rate.linlin(minrate, maxrate, maxrate, minrate);
    phasor_avg = (phasor_rate + phasor_rate_inv) / 2;
    integral = FOS.ar(phasor_avg, inverse_trig, DC.ar(0), inverse_trig);
    integrated_mean = integral / Sweep.ar(reset_trig, SampleRate.ir);
    LocalOut.ar(integrated_mean / bufdur);
    phasor = Sweep.ar(1, phasor_rate / bufdur);
    graintrig = Impulse.ar(\trigrate.kr(20));
    graindur = \trigrate.kr.reciprocal * \overlap.kr(2);
    sig = GrainBuf.ar(2, graintrig, graindur, buf, BufRateScale.ir(buf), phasor);
    Out.ar(0, sig);
}).add;
)

x = Synth(\bufstretch, [buf: b]);
x.set(\maxrate, 6);
x.set(\minrate, 0.6);
2 Likes

this is so great. tried it out with some piano phrases :slight_smile:

1 Like