Zack Scholl

zack.scholl@gmail.com

SuperCollider sample playback

 / #supercollider #music #tutorial 

Making a crossfading sampler engine.

SuperCollider is a free platform for audio synthesis and algorithm composition. It really excels at real-time analysis, synthesis and processing and provides a framework for seamless combination of audio techniques like additive and subtractive synthesis (as explored in my previous tutorial on drones) or be used for sophisticated sampling+splicing, the focus of this tutorial.

A sampler+splicer is useful when you have an audiofile and you want to cut it up into pieces and play it back in specific order. You can do this and change the tempo of a song by playing back snippets faster, or shuffle drum breaks to make new beats, or map specific sounds from a single audio file.

Before you begin

Before starting, make sure you have SuperCollider and plugins (if you want).

If you have these basic things installed, you are good to go. Feel free to do a SuperCollider tutorial if you want, but I’ll assume you don’t know how to use it.

With SuperCollider installed, you will need to run the program and then start the server using Ctl+B.

Here is a link to download all the files for this tutorial: https://github.com/schollz/sc-tutorial/archive/refs/heads/main.zip

1. Load a buffer with audio

We are going to start playing with a simple piano loop that I got from the radio:

Let’s first load this into SuperCollider, by running the following lines:

(
  b=Buffer.read(s,thisProcess.nowExecutingPath.dirname++"sounds/pianochords.wav");
)

2. Use PlayBuf to play sample

And now lets play it back using the simple PlayBuf “UGen”:

(
SynthDef("PlayBufPlayer", {
    arg out = 0, bufnum = 0;
    var snd;
    snd=PlayBuf.ar(2, bufnum, BufRateScale.kr(bufnum), doneAction: Done.freeSelf);
    Out.ar(out,snd)
}).play(s, [\out, 0, \bufnum, b]);
)

When running that code you should immediately hear it play back!

While this already works as a sampler (it plays back a sample), it actually won’t let us set the endpoints so we can splice the sample. Let’s do that next.

3. Use BufRd + Phasor to precisely play sample

In order to precisely control the position of playback we must use something called a Phasor. A Phasor is basically a sawtooth-like signal that increments at a specific rate. It is really useful as a position index, to set the position of playback, because you can control its start and end poitns and the rate.

Here is what a Phasor will look like:

{ Phasor.kr(0,0.5,0,100) }.plot(1)

And instead of going from 0-100 we can change it to whatever we want while also changing the rate:

{ Phasor.kr(0,0.5,0,100) }.plot(1)

We will introduce a lot of code to do this, but I’ll go through it piece by piece:

(
x=SynthDef("PlayBufPlayer", {
    arg out=0, bufnum=0, rate=1, start=0, end=1;
    var snd,pos,frames;

    rate = rate*BufRateScale.kr(bufnum);
    frames = BufFrames.kr(bufnum);

    pos=Phasor.ar(
        rate:rate,
        start:start*frames,
        end:end*frames,
        resetPos:start*frames,
    );

    snd=BufRd.ar(
        numChannels:2,
        bufnum:bufnum,
        phase:pos,
        loop:0,
        interpolation:4,
    );
    Out.ar(out,snd)
}).play(s, [\out, 0, \bufnum, b]);
)

The rate is now an argument and default is 1. However, it gets modulated by BufRateScale.kr(bufnum) where bufnum is a reference to the audio file. This modulation is a scale based on the sample rate of the audio file and the sample rate of the server. In the case the two sample rates don’t match, then you do need to change the speed you playback a sample to make sure it is exactly the same pitch when played at the other sample rate at a rate of 1.

The Phasor has start and end points and a rate and a resetPos which is the position it resets. The Phasor takes inputs in numbers of frames so we multiple the start and end points (which I am denoting to always be between 0 and 1) by the number of frames.

The BufRd is where the playback happens. We set the position of playback using the phase parameter. Notice we set loop to 0, but in actuality, because we are using a Phasor this will be ignored.

We can now change the start and end positions by running this command after running the SynthDef above:

x.set(\start,0.5,\end,0.6);

One thing you might notice is that you cannot get this sample to reset if you run that command more than once. Resetting is a really useful thing, and we can fix this SynthDef to allow that.

4. Use trigger to allow resetting sample

This fix is only one line, and adding a new argument, t_trig. This is a special argument and when it goes from 0 to 1 it can cause a change in a UGen. In this case we will use it to cause a change in Phasor, which will make it reset.

(
x=SynthDef("PlayBufPlayer", {
    arg out=0, bufnum=0, rate=1, start=0, end=1, t_trig=0; // NEW t_trig!
    var snd,pos,frames;

    rate = rate*BufRateScale.kr(bufnum);
    frames = BufFrames.kr(bufnum);

    pos=Phasor.ar(
        trig:t_trig, // NEW!
        rate:rate,
        start:start*frames,
        end:end*frames,
        resetPos:start*frames,
    );

    snd=BufRd.ar(
        numChannels:2,
        bufnum:bufnum,
        phase:pos,
        loop:0,
        interpolation:4,
    );
    Out.ar(out,snd)
}).play(s, [\out, 0, \bufnum, b]);
)

Now you can send various commands to this SynthDef which can cause it to reset to the new start position:

x.set(\t_trig,1,\start,0,\end,1)
x.set(\t_trig,1,\start,0.5,\end,1)

You can hear in that audio that I reset the position multiple times.

One problem that persists though is that, as mentioned earlier, we are using a Phasor which loops forever and overrides the loop parameter of the BufRd UGen. It would be better to have an option to set the loops to any number you want.

5. Use an envelope, Env, to control looping

It turns out we can do a little trick to get a precise loop - we can use an envelope. An envelope is essentially a curve in time that we can multiple by the output amplitude. The function of the curve is to shape the amplitude, and in this case we will shape it so it turns on and then turns off after a certain amount of time.

A basic envelope is generated like this:

({ 
EnvGen.ar(
    Env.new(
        levels: [0,1,1,0],
        times: [0,1,0],
    ),
)
}.plot(2);)

which generates an envelope that lasts 1 second and then goes to 0. It looks like this:

Simple square envelope

There are all sorts of envelopes you can make. We can make this one fancier by having it fade in/out a little smoother by adding some time to the “attack” and “release” part of the envelope:

(
{ EnvGen.ar(
    Env.new(
        levels: [0,1,1,0],
        times: [0.2,1-0.4,0.2],
        curve:\sine,
    ),
)
}.plot(2);
)

Simple square envelope

Great! Well all we need to do to preserve a loop is to calculate how long the duration of the envelope should be. This involves a little math, but it should be related to the number of frames, the rate (1/2 the rate = twice the time), the sample rate, and the number of loops:

duration = frames*(end-start)/rate.abs/s.sampleRate*loops

We then just need to trigger the envelope when we trigger our sample and voila!

(
x=SynthDef("PlayBufPlayer", {
    arg out=0, bufnum=0, rate=1, start=0, end=1, t_trig=1,
    loops=1; // NEW
    var snd,pos,frames,duration,env;

    rate = rate*BufRateScale.kr(bufnum);
    frames = BufFrames.kr(bufnum);
    duration = frames*(end-start)/rate/s.sampleRate*loops; // NEW

    // envelope to clamp looping
    env=EnvGen.ar(
        Env.new(
            levels: [0,1,1,0],
            times: [0,duration-0.01,0.01],
            curve:\sine,
        ),
        gate:t_trig,
    );

    pos=Phasor.ar(
        trig:t_trig,
        rate:rate,
        start:start*frames,
        end:end*frames,
        resetPos:start*frames,
    );

    snd=BufRd.ar(
        numChannels:2,
        bufnum:bufnum,
        phase:pos,
        interpolation:4,
    );

    snd = snd * env;

    Out.ar(out,snd)
}).play(s, [\out, 0, \bufnum, b]);
)

Now we can set the loop number, and reset the sample, and control the start and stop positions:

x.set(\t_trig,1,\start,0.0,\end,0.15,\loops,1)
x.set(\t_trig,1,\start,0.5,\end,0.52,\loops,6)

This sounds like the following:

You can see that loops stop and stay stopped! And you can make it loop as many times as you want.

But what happens if we reverse? If you change the rate to -1 it actually won’t play:

x.set(\t_trig,1,\start,0.5,\end,0.6,\loops,3,\rate,-1)

Its because the start and end points need to swap. If we do that, then we can succesfully play in reverse:

x.set(\t_trig,1,\start,0.6,\end,0.5,\loops,3,\rate,-1)

But, lets alter the code above so that it works in reverse without having the start and end points switching around!

6. Automatically swap start/end points when reversing

To swap the start/end points we need to check the rate and add some logic about whether the rate is positive or negative. You can’t really use if-statements in SuperCollider (well you can but its weird), but we can do this easily with math. For example, the start parameter of the Phasor can calculate a binary whether the rate is positive or negative and then make a sum based on it:

start:(((rate>0)*start)+((rate<0)*end))*frames

It looks complicated but its quite simple. If the rate>0 then the second part in the parentheses is 0 and its just start. If the rate<0 then the first part is 0 and then it just switches to end.

The final SynthDef looks like this:

(
x=SynthDef("PlayBufPlayer", {
    arg out=0, bufnum=0, rate=1, start=0, end=1, t_trig=1,
    loops=1;
    var snd,pos,frames,duration,env;

    rate = rate*BufRateScale.kr(bufnum);
    frames = BufFrames.kr(bufnum);
    duration = frames*(end-start)/rate.abs/s.sampleRate*loops; // use rate.abs instead now

    // envelope to clamp looping
    env=EnvGen.ar(
        Env.new(
            levels: [0,1,1,0],
            times: [0,duration,0],
        ),
        gate:t_trig,
    );

    pos=Phasor.ar(
        trig:t_trig,
        rate:rate,
        start:(((rate>0)*start)+((rate<0)*end))*frames,
        end:(((rate>0)*end)+((rate<0)*start))*frames,
        resetPos:(((rate>0)*start)+((rate<0)*end))*frames,
    );

    snd=BufRd.ar(
        numChannels:2,
        bufnum:bufnum,
        phase:pos,
        interpolation:4,
    );

    snd = snd * env;

    Out.ar(out,snd)
}).play(s, [\out, 0, \bufnum, b]);
)

And when we use the problematic statement above, we can see that it just works:

x.set(\t_trig,1,\start,0.5,\end,0.6,\loops,3,\rate,-1)

Okay, now one more thing. You may have noticed in playing around that there is an annoying “click” or “pop” when you reset the sample. For example, try to send the following a bunch of times really fast you’ll hear an audible noise artifact from the switching:

x.set(\t_trig,1,\start,0.2,\end,0.6,\loops,1,\rate,1)

It’s unpleasant right? Well we can make one more improvement to remove this type of thing.

7. Preventing switching pops

The main problem of pops caused by switching is that there isn’t a smooth transition to the next position. The smooth transition we expect actually needs to come from the audio itself and not the position. To make a smooth transition then, we need to crossfade between the old loop and the new loop.

Doing this is not nearly as hard as it seems at first. We already have one loop playing. We need to make another loop. Then we just need to make a new trigger, based on t_trig which we already use, that flips between triggering the two loops. We can use a special UGen for this called ToggleFF and then another UGen called Latch which will allow us to change only one of the loops at a time, alternating between the two.

Below is the new code. We still have the same arguments, but we have new variables for the two loops (startA+B, endA+B, crossfade, and aORB which is the new trigger). The crossfade is doing the major work here, we can create the audio crossfade by using something called Lag which will transition between the two values slowly.

For example, this is a 50 ms crossfade:

crossfade=Lag.ar(K2A.ar(aOrB),0.05);

This is what the new code ends up being:

(
x=SynthDef("PlayBufPlayer", {
    arg out=0, bufnum=0, rate=1, start=0, end=1, t_trig=0,
    loops=1;
    var snd,snd2,pos,pos2,frames,duration,env;
    var startA,endA,startB,endB,crossfade,aOrB;

    // latch to change trigger between the two
    aOrB=ToggleFF.kr(t_trig);
    startA=Latch.kr(start,aOrB);
    endA=Latch.kr(end,aOrB);
    startB=Latch.kr(start,1-aOrB);
    endB=Latch.kr(end,1-aOrB);
    crossfade=Lag.ar(K2A.ar(aOrB),0.05);


    rate = rate*BufRateScale.kr(bufnum);
    frames = BufFrames.kr(bufnum);
    duration = frames*(end-start)/rate.abs/s.sampleRate*loops;

    // envelope to clamp looping
    env=EnvGen.ar(
        Env.new(
            levels: [0,1,1,0],
            times: [0,duration-0.05,0.05],
        ),
        gate:t_trig,
    );

    pos=Phasor.ar(
        trig:aOrB,
        rate:rate,
        start:(((rate>0)*startA)+((rate<0)*endA))*frames,
        end:(((rate>0)*endA)+((rate<0)*startA))*frames,
        resetPos:(((rate>0)*startA)+((rate<0)*endA))*frames,
    );
    snd=BufRd.ar(
        numChannels:2,
        bufnum:bufnum,
        phase:pos,
        interpolation:4,
    );

    // add a second reader
    pos2=Phasor.ar(
        trig:(1-aOrB),
        rate:rate,
        start:(((rate>0)*startB)+((rate<0)*endB))*frames,
        end:(((rate>0)*endB)+((rate<0)*startB))*frames,
        resetPos:(((rate>0)*startB)+((rate<0)*endB))*frames,
    );
    snd2=BufRd.ar(
        numChannels:2,
        bufnum:bufnum,
        phase:pos2,
        interpolation:4,
    );

    Out.ar(out,(crossfade*snd)+((1-crossfade)*snd2) * env)
}).play(s, [\out, 0, \bufnum, b]);
)

And we can try this out on the problematic loop from before:

x.set(\t_trig,1,\start,0.2,\end,0.6,\loops,1,\rate,1)

Even though it is triggered a bunch of times, it sounds smooth! This is because, underneath it all, its switching back and forth between loops while crossfading them simultaneously.

8. COOL, but now what?

Now you can branch off with this SynthDef and do all sorts of things. You can add effects to it (maybe I can add more about this). One thing I like to do is that you can immediately do “onset detection”, gather splices, and pattern them!

Here’s a synthdef I cobbled together to determine splicing from arbitrary samples:

(
SynthDef.removeAt("OnsetDetection");

o = OSCFunc({ arg msg, time;
	[time, msg].postln;
	if (msg[3]-~pos1.last>(0.15*s.sampleRate/b.numFrames),{
		"added".postln;
		~pos1.add(msg[3]);
	},{});
},'/tr', s.addr);

SynthDef("OnsetDetection", {
    arg bufnum, out, threshold;
    var sig, chain, onsets, pips, pos, env, speedup=1;

    env=EnvGen.ar(
        Env.new(
            levels: [0,1,1,0],
            times: [0,BufDur.kr(bufnum)/speedup,0],
            curve:\sine,
        ),
        doneAction: Done.freeSelf
    );

    pos=Phasor.ar(0, BufRateScale.kr(bufnum)*speedup, 0, BufFrames.kr(bufnum),0);
    sig = BufRd.ar(2, bufnum, pos,0);

    chain = FFT(LocalBuf(512), sig[0]+sig[1]);

    onsets = Onsets.kr(chain, threshold, \rcomplex,mingap:160);

    // You'll hear percussive "ticks" whenever an onset is detected
    pips = WhiteNoise.ar(EnvGen.kr(Env.perc(0.001, 0.1, 0.2), onsets));
    SendTrig.kr(onsets,0,Clip.kr((pos-300)/BufFrames.kr(bufnum),0,1));
    Out.ar(out,Pan2.ar(sig, -0.75, 0.2) + Pan2.ar(pips, 0.75, 1));
}).add;
)

Once defined, you can run it with the following:

(
~pos1=List.new();
~pos1.add(0);
b=Buffer.read(s,thisProcess.nowExecutingPath.dirname++"/sounds/pianochords.wav",
    action:{
        Synth("OnsetDetection",[\out,0,\bufnum,b.bufnum,\threshold,2.0]); // CHANGE THRESHOLD TO SOMETHING THAT WORKS
});
)

Make sure you change the threshold (above its 2.0) to something where you hear a “click” on the appropriate onset. For example, using the piano from above:

You can hear it automatically detect onsets. And now you can playback individual splices:

f = {arg i;x.set(\t_trig,1,\start,~pos1[i],\end,~pos1[i+1]);}
f.value(0); // plays splice "0"
f.value(1); // plays splice "1"

Or visualize them:

g = {arg i; b.loadToFloatArray(~pos1[i]*b.numFrames,(~pos1[i+1]-~pos1[i])*b.numFrames,{arg array; a=array; {~temp=a.plot;~temp.setProperties(\gridOnX,false,\gridOnY,false)}.defer; })};
g.value(0);
g.value(1); // visualize it

Splice 2

Or sequence them:

(
~player=[1,0,2,0,2,0,0,0,3,3,3,3,4,0,3,0];
t = Task({
    inf.do({ arg i;
        var toPlay;
        0.125.wait;
        toPlay = ~player[i%~player.size].postln;
        if (toPlay>0,{
            toPlay.postln;
            x.set(\t_trig,1,\start,~pos1[toPlay-1],\end,~pos1[toPlay],\loops,1);
        },{});
    });
}).play;
)

Plus much more…but you’ve had enough right?