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:
- Proper organization of compositional code. Each process, or musical stream, resides in an enhanced environment called Proto so that its resources are kept private and don't collide with resources created by other processes. It's designed for large-scale performances where you will need to create and destroy resources periodically.
- On-the-fly alteration of playing streams. This makes the process of composing with SuperCollider even more interactive. A properly-designed process allows many parameters to be changed from the outside while it's playing, and for the results to be heard immediately. This is very difficult to do with the stream/pattern classes in the common library.
- Object-oriented composition of very complex behaviors. Since Protos can contain other Protos, it becomes simpler to write large structures that can be reused. While composing, you spend less time thinking through programming logic and more time thinking about the desired result. I use this feature for so-called "adaptive sequencing," in which the process modifies its own input material according to rules specified by the composer.
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:
- Instead of starting with a brand-new, empty Proto, we use a prototype called abstractProcess as the starting point. The most important reason is that abstractProcess defines methods that make it easy to chuck new patterns into the process.
- abstractProcess also automatically specifies the default event, so we can omit the ~event = Event.default line here.
- ~prep is a special method that gets automatically called whenever the prototype (PR) gets turned into a bound process (BP). It lets you do whatever initialization you need. Here, we bind some objects that are to be treated as patterns to specific keys in the environment (degree, delta, sus, db). We'll get to the internals in a minute.
- Having stored the patterns, now we have to use them in the Pbind. BPStream takes care of this by making separate streams that reference the environment's keys (again, internals to follow).
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:
- Save the pattern into ~yourKeyPattern (e.g., ~degreePattern);
- Create a stream from the pattern and save it into ~yourKeyStream (e.g., ~degreeStream).
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:
- By collecting the base pattern and all the external objects it depends on into a single package (the prototype or bound process), it becomes possible to address the package as a single entity.
- Complex designs can be invoked by name in performance, without having to search through long code files for the exact portions to execute. (Also, you can debug complex designs beforehand and avoid some of the worst dangers of all-out live coding.)
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!