Endlessly repeating...

A synced audio looper in SuperCollider

7 December 2010

Solving this problem has been bugging me on and off for ages now so I thought I'd share the solution I eventually came up with. First, the problem: how to do on-the-fly looping of audio in SuperCollider in sync with a sequencer? In this case the sequencer is going to be a very simple example in Processing but the method will work with a hardware drum machine as well.

The trick I settled on is to think like an old hardware loop pedal. In other words, set up the looper with more capacity that you think you're going to need and then do the recording into some part of that space. The record 'head' (to borrow some old tape terminology) is positioned using a phasor that, left alone, runs from the start of the recording area to the end. An OSC responder (or MIDI responder or whatever your sequencer speaks) is set up that causes the phasor to jump back to zero when it's triggered. If you send the trigger at the end of your sequenced loop you get synchronised looping. Overdubbing on to the loop is a simple matter of making sure that the contents of the buffer is read before the new material is added on each pass.

This is, in a way, a bit of a dumb approach and I'm kind of embarrassed about how long it took me. The obvious problem is that you need to make a buffer longer than you're ever going to need and "ever" is always a bit hard to define. Also, having huge under-utilised chunks of memory isn't exactly efficient but in, in practice, a 60 second loop only takes up ~2.5MB so it's not going to bring a modern laptop to it's knees even with a few instances going. Anyway, I hope this helps someone.

First the SuperCollider code:

// definition of the looper
SynthDef(\looper, {
    // need a buffer to listen to and an input for the loop trigger
    arg bufnum, t_reset;

    // variables for the existing signal in the loop, the new input,
    // the output signal and the recording head position
    var inputSig, outputSig, existingSig, recHead;

    // get the input signal
    inputSig = In.ar(0);

    // generate the recording (also playback) position
    recHead = Phasor.ar(t_reset, BufRateScale.kr(bufnum), 0, BufFrames.kr(0));

    // read the existing signal from the loop
    existingSig = BufRd.ar(1, bufnum, recHead);

    // put the existing signal plus the new signal into the loop
    BufWr.ar(inputSig + existingSig, bufnum, recHead);

    // play back signal we got from the loop before the writing operation
    Out.ar(0, existingSig);
}).add;

// a crappy instrument to test with
SynthDef(\ping, {
    arg freq;
    var sig;
    sig = SinOsc.ar(freq) * 0.5;
    sig = EnvGen.kr(Env.perc(0.05, 2), doneAction:2) * sig;
    Out.ar(0, sig);
}).add;

// create a big empty buffer (20 secs is enough for me)
b = Buffer.alloc(s, 20 * s.sampleRate, 1)

// listen for a an OSC message indicating that we've reached the loop point
p = OSCresponderNode(nil, '/newbar', {|t, r, msg| ~looper.set(\t_reset, 1)}).add;

// listen for OSC triggering the test instrument
o = OSCresponderNode(nil, '/newnote', {
    |t, r, msg| msg[1].postln; Synth.new(\ping, [\freq, msg[1].midicps])
}).add;

// create an instance of the looper
~looper = Synth.new(\looper, [\bufnum, b.bufnum])

Now the Processing:

// import the OSC libraries (see http://www.sojamo.de/libraries/oscP5/)
import oscP5.*;
import netP5.*;

// the OSC handler and target address
OscP5 oscP5;
NetAddress remote;

// where in the bar are we
int beatCount;

// lock to a scale so the test instrument sounds a bit nicer
int[] cMinor = {48, 50, 52, 53, 55, 57, 59, 60, 62, 64, 65, 67, 69};

void setup() {
  size(260, 200);
  frameRate(30);

  // draw a keyboard to test with
  for(int i = 1; i <= 12; i++) {
    line(20 * i, 0, 20 * i, 100);
  }

  // instantiate the OSC handler
  oscP5 = new OscP5(this, 10000);

  // Supercollider is assumed to be listening on 
  // localhost at the default sclang port
  remote = new NetAddress("127.0.0.1", 57120);
}

void draw() {
  // pulse in time with beat
  fill(255 / ((frameCount % 15) + 1));
  rect(10, 110, 40, 40);

  // one beat every 15 frames
  if(frameCount % 15 == 0) {
    beatCount++;
  }

  // at the end of the 16 beat bar, trigger the loop back to the beginning
  if(beatCount == 16) {
    OscMessage msg = new OscMessage("/newbar");

    oscP5.send(msg, remote);
    beatCount = 0;
  }

  // pulse in time with the bar
  fill(255 / (beatCount + 1));
  rect(60, 110, 40, 40);
}

// use the mouse to play the test instrument
void mousePressed() {
  int noteNum = int(mouseX / 20);
  OscMessage msg = new OscMessage("/newnote");
  msg.add(cMinor[noteNum]);
  oscP5.send(msg, remote);
}