Sequencing

Up to this point, we've been testing sounds by playing them manually using their functions defined by defsynth, or by using proxy. However, it would be very difficult to play interesting music just by typing every sound we want to hear on the keyboard. Most synthesizers have methods of triggering notes automatically, either by connecting external hardware or software, or by using functionality built in to the synthesizer. Typically, we'd refer to the hardware or software controlling the synthesizer as a "sequencer".

callback

cl-collider comes with a built in method of sequencing sounds. It's fairly simple and easy to learn, as it is basically just a few functions and macros that allow sounds (or other functions) to be triggered automatically at some point in the future. Let's take a look. First, we'll define a basic synth that we can play around with:

(defsynth simple ((gate 1) (freq 440) (pan 0) (amp 0.5))
  (let* ((env (env-gen.kr (asr 0.01 1 0.1) :gate gate :act :free))
         (sig (sin-osc.ar freq 0 0.2)))
    (out.ar 0 (pan2.ar sig pan (* env amp)))))

Then, we can define a function to start playing this synth:

(defun testing ()
  (let ((syn (synth 'simple :freq (midicps (+ 30 (random 40))))))
    (callback (+ (now) 0.7) #'release syn))
  (callback (+ (now) 1) #'testing))

The first thing the testing function does is start a new simple synth. Calling (synth 'simple) returns a node object representing the synth playing on the server. Here we provide the frequency to the synth by generating a random MIDI note number and converting it to a frequency with the midicps function.

Below that, we use the callback function to schedule the release of the synth 0.7 seconds in the future. The now function returns the current time, so adding 0.7 to it means "0.7 seconds from now". callback effectively causes a function to run at a specified time using cl-collider's scheduler. Respectively, its arguments are: when to run the function, the function to run (in this case release), and (optionally) the arguments to that function. Here we provide the node returned by synth so it will be released.

After that, we use callback again to re-schedule the testing function. That way, we continue to hear notes playing. To give some space between notes, we schedule it to trigger again after one second.

Once you define that function, you can call it using (testing) and you should hear random notes being played. Since this is a standard Common Lisp function, you can re-define it on the fly and the next time it's called it will automatically use the new definition. So if we wanted the function to stop playing, we could simply evaluate the following:

(defun testing ()
  nil)

Another example; if we want multiple notes simultaneously:

(defun testing-2 ()
  (let* ((note (+ 30 (random 40)))
         (syns (loop :for i :in '(0 12 24)
                  :collect (synth 'simple :freq (midicps (+ i note))))))
    (loop :for i :in syns
       :do (callback (+ (now) 0.7) #'release i)))
  (callback (+ (now) 1) #'testing-2))

This function generates a random MIDI note, then creates two more synths that play at the same time as the first, but one and two octaves higher. This gives a chord or organ-like sound. Of course, you could just put more UGens in the synthdef itself and get the same effect if you so choose. It would certainly be easier, since you can just rely on cl-collider's multi-channel expansion in synthdefs.

Closures

Using closures, we can write functions that perform different actions each time they're called:

(let ((index 0)
      (list (list 0 2 4 6)))
  (defun testing-3 ()
    (let* ((note (+ 30 (nth index list)))
           (syns (loop :for i :in '(0 12 24)
                    :collect (synth 'simple :freq (midicps (+ i note))))))
      (loop :for i :in syns
         :do (callback (+ (now) 0.7) #'release i)))
    (setf index (mod (1+ index) (length list)))
    (callback (+ (now) 1) #'testing-3)))

Since closures let us keep track of state, we can make arpeggios instead of just playing random midi notes.

Patterns

Though you can of course implement any behaviors you want using functions and cl-collider's scheduler as shown above, it does require a lot of bookkeeping. Calculating the frequency for each note, saving each synth in a variable, releasing it later, etcetera–and that's just for simple behavior like the above. You could, of course, define some functions and macros to make it easier, but there is another way.

If you're using SuperCollider's built-in programming language, it comes with a library of sequencing functionality called "patterns". These can be used to create musical sequences much more easily than manually writing functions like we've done above. SuperCollider's patterns language is designed to make it simple to express musical behaviors and specify synth parameters for each musical event. There are many kinds of patterns that can be used to generate everything from static sequences that sound the same every time, to totally algorithmic or random patterns that result in constantly-changing outputs. Patterns can even be combined to easily create more complex behaviors.

cl-collider doesn't have a patterns system built in, but there is a library called cl-patterns which re-creates much of its functionality in Lisp. It's beyond the scope of this tutorial to describe cl-patterns in-depth, but here are a few examples to show what can be done with it:

(pbind :pdef :testing
       :instrument :simple
       :midinote (pwhite 30 70)
       :dur 1
       :legato 0.7)

This pattern produces the same output as our original testing function above. The most obvious advantage is that it's much more obvious what the code is supposed to do; each key specifies its value directly rather than being entangled in the definition of the behavior. Instead of having to write (+ 30 (random 40)) to get values from 30 to 70, we can just use the pwhite pattern as (pwhite 30 70) to get values within that range.

Another advantage of using cl-patterns is that all patterns are automatically synchronized to a tempo. If the tempo is changed, all patterns will automatically use the new tempo value and will stay in sync, without us needing to update their code or remember to multiply the duration and release time by the tempo.

Here's an example of how testing-2 would be rewritten as a pattern:

(pbind :pdef :testing-2
       :instrument :simple
       :midinote (pwhite 30 70)
       :midinote (p+ (pk :midinote) (list 0 12 24))
       :dur 1
       :legato 0.7)

As you can see, it's nearly the same as our first pattern; we only had to add one additional line to produce two additional notes per event. The additional notes are automatically handled and released when necessary just as expected.

And finally, here's how our last function, testing-3, would look as a pattern:

(pbind :pdef :testing-3
       :instrument :simple
       :midinote (p+ 40 (pseq (list 0 2 4 6)))
       :midinote (p+ (pk :midinote) (list 0 12 24))
       :dur 1
       :legato 0.7)

(Note again that this example will not work in the current verison of cl-patterns, for the same reason as the previous.)

As you can see, we don't need to create a closure to keep track of state; the pseq pattern does that for us, allowing us to focus more on the behavior we want and less on how to implement it.

As mentioned before, a full explanation of how cl-patterns works is beyond the scope of this document, but the project's repo includes plenty of documentation that should help to get you started.

Last updated: 2023-10-04 Wed 20:58