Introduction to chucklib processes and prototypes

This is the first in a planned series of documents explaining how to use the chucklib add-on to the dewdrop library. I've been working on chucklib for about a year and a half as of this writing (January 2006) and it will continue to grow as I use it in compositions and discover new needs for it. It takes its name from the chuck operator, which I brazenly stole from Ge Wang and Perry Cook's ChucK audio programming language (though the usage in my case is nothing so radical as theirs).

Note: Before reading this tutorial, make sure you understand the streams-patterns-events helpfiles in the SuperCollider main help. This is not going to make sense to you until you do!

chucklib exists for several purposes:

This article will only scratch the surface, showing how to open up a simple pattern to outside experimentation, then demonstrating the use of some of the predefined drum machine process prototypes.

1. Patterns are easy but can be inflexible

Let's make a pattern to play an ascending C-major scale over and over.

// Initialize the server and prepare for using patterns
s.boot;
SynthDescLib.global.read;


// 1. Simple pattern
(
p = Pbind(
   \degree, Pseq((0..7), inf),
   \delta, 0.25,
   \sustain, 0.2,
   \db, -30
).play;
)

// when you're bored with it, stop it
p.stop;

Pretty straightforward—in the standard event framework, the \degree key maps onto scale degrees, which by default are interpreted in C major. We also specify 16th notes (0.25 of a beat), and lower the volume a bit using the \db key. With only a moderate degree of familiarity with the event scheme, you can get this result in less than a minute.

2. chucklib processes must make a pattern

At the simplest level, a process need be no more than a wrapper for a simple pattern.

(
Proto({
   ~event = Event.default;
   ~asPattern = {
      Pbind(
         \degree, Pseq((0..7), inf),
         \delta, 0.25,
         \sustain, 0.2,
         \db, -30
      )
   };
}) => PR(\cmaj);
)

PR stands for "Process prototype." It exists for storage only—you can't play prototypes. We'll get to playing in a minute.

Every prototype has a name, so you can wrap up functionality in a neat little package and recall it using something easy to remember. In that statement, the \cmaj prototype doesn't exist. chucklib will create the storage slot for you automatically.

PR(\cmaj) retrieves the storage object. Most of what you put into the prototype goes into the value of the storage slot, accessed in shortcut with .v.

Inside the Proto constructor function, you need to define environment variables (indicated by ~ preceding the name). They may be straightforward values (like Event.default) or functions. If they're functions, you can invoke the function just like calling a method on any other object.

PR(\cmaj).v.asPattern

— that statement will run the asPattern function and return the Pbind. This is done for you automatically when you go to play the process.

Every process prototype must have an ~asPattern method. In this case, I also had to put the default event into the ~event environment variable. The process player looks to this variable to find the instructions on how to interpret the events generated by the pattern. Since we're making the Proto from scratch, we have to state which event framework to use. There's another way to specify it, which will be covered in a later tutorial.

To play it, you have to chuck the prototype into a BP object. BP stands for bound process, because you might (and usually will) have an abstract prototype that has to be bound to specific values before it can be played.

// make the bound process
PR(\cmaj) => BP(\cmaj);

BP(\cmaj).play;   // start -- assumes you want to start on a 4-beat boundary
BP(\cmaj).stop;   // stop -- same assumption

BP(\cmaj).free;   // release all resources that may have been created

So far, it might seem a little inconvenient. You have to write a little more code up front and go through an intermediate object to play the pattern. (Actually, in the simple pattern example, you also go through an intermediate object called EventStreamPlayer. The conversion is done for you when you call .play on the pattern, which generates confusion sometimes.)

Let's look at one of the real benefits of working this way.

3. Process with variable streams

Let's recast the above process definition so that the components of the Pbind can be swapped in and out on the fly.

(
PR(\abstractProcess).v.clone({
      // create component streams
   ~prep = {
      ~degree = Pseq((0..7), inf);
      ~delta = 0.25;
      ~sus = 0.2;
      ~db = -30;
   };

      // use them in a pattern
   ~asPattern = {
      Pbind(
         \degree, BPStream(\degree),
         \delta, BPStream(\delta),
         \sustain, BPStream(\sus),
         \db, BPStream(\db)
      )
   };
}) => PR(\cmaj2);
)

Several new elements here:

Let's toy around with it a bit.

PR(\cmaj2) => BP(\cmaj);

BP(\cmaj).play;

So far it sounds the same as the other. That's good—you can replicate the behavior of a simple pattern using this technique. Then, you can go further:

Pbrown(0, 7, 2, inf) =>.degree BP(\cmaj);

Now we've replaced the ascending scale with Brownian motion over the C major scale. Note the syntax: newPatternObject =>.key BP(\processName)—you could just as easily write BP(\processName).v.bindPattern(newPatternObject, key), but the => syntax is tighter and easier in performance.

We can twiddle with the rhythm too:

Prand([0.25, Pseq([0.5, 0.25], 1), Pseq([0.25, 0.125], 1)], inf) =>.delta BP(\cmaj);

Feel free to experiment and put whatever patterns you like into any of the four keys defined initially.

BP(\cmaj).stop;
BP(\cmaj).free;

This approach isn't entirely free of restrictions. Once you define the Pbind, you can't add new keys arbitrarily while the process is playing. That's a limitation of the pattern architecture. You have to modify the prototype, then re-chuck the prototype into a BP.

So how does it work? Remember, in a pattern, you can't change the makeup of the stream after making the stream from the pattern. There are good reasons for that, so I didn't try to change the pattern architecture. Instead, the Pbind consists of references to objects outside the pattern proper. If you change the outside object, the reference picks up different values and the Pbind's behavior changes.

~bindPattern does two things to the environment:

Then, ~makeProut makes a routine that retrieves the stream from the appropriate key and returns the next value.

chucklib's goal is to make this transparent in performance. The design does this in a couple of ways:

4. Drum machines—prototypes with parameters

Next up, I want to illustrate how to use a couple of the predefined drum machine prototypes to build a simple drum track. What's noteworthy is how simple the code is—the complexity is hidden inside PR(\break) and PR(\bufPerc).

We'll use the infamous Apollo 11 walk sound, since I know everybody using SuperCollider has it. I've preselected some fragments that work pretty well (though the results are still ridiculous!).

Let's start with a simple 4-on-the-floor kick drum.

(

TempoClock.default.tempo = 2.1366279069767;

PR(\bufPerc).chuck(BP(\kik), parms: (
   bufPaths: ["sounds/a11wlk01-44_1.aiff"],
   bufCoords: [[18000, 1632]] * 4,
   amps: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
   rates: [0.5]
));
)

What's this "parms: ( )"? These prototypes depend on certain objects being put into the environment before the initialization method (~prep) gets run.

Note: The => operator doesn't allow a parameter dictionary, so you have to revert to the messaging syntax source.chuck(target, parms: ( )). It's an easy mistake to do PR(\source).chuck(BP(\target), parms: ( )) => BP(\target), but that will give you an error because you're chucking in the prototype twice.

bufPaths lists the soundfiles that are to be loaded. Even if you only need one file, wrap the string in an array—the array is needed because you can load any number of files you want. We'll see that in a moment. bufCoords is optional and specifies the portion(s) of the file(s) to load. Use the same parameters as in Buffer.read: starting frame, and number of frames to load. If you have multiple files, it's [ [ file 0 start frame, number of frames ], [ file 1 start, frames ], [ file 2 start, frames ] ... ]. The default value is [[0, -1]] which will read the whole file for every file specified.

Note values are written into the amps array. Like a drum machine, zero values are rests, and non-zeros will produce notes. There are other arrays that run in parallel, but they should have values corresponding only to the non-zeros. A later tutorial will describe all the parameters, but you might be able to infer some of them from these examples.

By default, there should be one amps array element per 16th-note. Here, we have a note every fourth tick of the clock, so we should hear quarter notes.

BP(\kik).play;

... and indeed, we do. To add a snare:

(
PR(\bufPerc).chuck(BP(\snare), parms: (
   bufPaths: ["sounds/a11wlk01-44_1.aiff"],
   bufCoords: [[3816, 816]] * 4,
   amps: [0, 0, 0, 0, 1, 0, 0, 0.7, 0, 0, 0, 0, 1, 0, 0, 0],
   rates: [1.6]
));
)

BP(\snare).play;

// snare isn't loud enough, so increase its MixerChannel level
BP(\snare).v.chan.level = 1.7;

Not too much different. You might notice that both these examples specify a "rates" parameter. You can indicate a different playback rate for each note. For any note-based parameters, if the array is too short, it wraps around, so providing a one-element array applies the same value to every note.

(
PR(\bufPerc).chuck(BP(\hh), parms: (
   bufPaths: "sounds/a11wlk01-44_1.aiff" ! 2,
   bufCoords: [[19968, 3288], [1104, 2640]] * 4,
   amps: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0],
   bufs: { 2.rand } ! 4
));
)

BP(\hh).play;

For the hihat, we load two segments of the same soundfile (the path is duplicated using ! 2), then use the bufs parameter to indicate which segment to use for each note. Since there are four backbeats, we choose the segments randomly and repeat them for each bar.

Now for some breakbeat fun.

(
PR(\break).chuck(BP(\break), parms: (
   bufPaths: ["sounds/a11wlk01-44_1.aiff"],
   segStart: [[2712, 7872, 13032, 18192]] * 4,
   start: [0, 1, 2, 3],
   amps: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0],
   inChannels: 2,
   def: \bufGrainPan
));
)

BP(\break).play;

For breaks, it's more flexible to load the entire loop into a single buffer and give starting coordinates in a separate array (the segStart array). As with bufCoords, each coordinate array has to be wrapped in an outer array because you can load more than one soundfile here also.

Since I chose the coordinates to mesh with the tempo, the result sounds continuous. Let's mix it up. The drum machine prototypes offer the user a hook to run a function at the beginning of every bar, which we can use to calculate a new set of arrays for every bar. That's a way to do algorithmic composition of drum tracks. To start simply, let's choose the same segments, but in a different order every time:

(
BP(\break).v.pbindPreAction = {
   ~start = ~start.scramble;
};
)

Or, we can have a 4/10 probability of placing in note on a given 16th. It gets funkier with the added syncopation.

(
BP(\break).v.pbindPreAction = {
   ~amps = { 0.4.coin.binaryValue } ! 16;
   ~start = Array.new(16);
   ~amps.do({ |amp|
      (amp > 0).if({ ~start.add(4.rand) });
   });
};
)

Or, to take it a step further, we can choose normal speed, half speed or double speed for each note.

(
BP(\break).v.pbindPreAction = {
   ~amps = { 0.4.coin.binaryValue } ! 16;
   ~start = Array.new(16);
   ~rates = Array.new(16);
   ~amps.do({ |amp|
      (amp > 0).if({
         ~start.add(4.rand);
         ~rates.add(#[0.5, 1.0, 2.0].wchoose(#[0.2, 0.7, 0.1]));
      });
   });
};
)

For the heck of it, let's pan each note randomly. This depends on setting up the prototype as a stereo drum track in the first place (by default it's mono).

BP(\break).v.argPairs = [\pan, Pwhite(-1, 1, inf)];

We can stop and release them with one fell swoop:

BP(#[break, hh, snare, kik]).stop;

// release all resources
BP(#[break, hh, snare, kik]).free;

Note: Currently only play, stop and free are implemented for arrays. For other messages, you should do BP(#[name1, name2, name3]).do(_.whatIWantToDo)

The point to emphasize is that to prototype basic drum tracks, in none of these examples did we have to write complex control flow logic or duplicate efforts in managing resources. We could say simply, "Play this rhythm using these soundfiles." In another tutorial, I'll explain deeper levels and how to set up a good default mix, but in the meantime, I hope this illustrates what I wrote earlier about "spend[ing] less time thinking through programming logic and more time thinking about the desired result." Even the functions to generate the breakbeat algorithmically are pretty simple (just produce a couple of arrays).

Enjoy the new toys!