Time in Extempore
Most programming environments operate using an iterative ‘generate and test’ style of programming. First you write the program, then you run the program. If there are problems with the program you re-write the program and then re-run the program, etc…
Extempore is different. Extempore is a dynamic, interactive programming environment where programs are modified and extended while they are running. As a simple example: type the following code into your editor and evaluate it:
(now)
Your editor’s echo area should display the result of evaluating the expression,
and it should be a big (integer) number—when I did it on my computer just now
it came back with 770432
.
Now try pressing the eval button again. It’s a different result! (now)
is a
function that returns the time (in audio samples) since Extempore was started.
The important thing to keep in mind for the present is that Extempore is a
program that is already running. Extempore isn’t just a programming language, or
just a compiler, it’s a run-time as well. And one of the primary differences
between Extempore and most other general purpose programming environments is an
emphasis on precisely scheduled code execution.
Each extempore
process has a scheduling engine built-in, allowing tasks to be
scheduled for execution at a precise time in the future. Unlike threading
constructs such as sleep()
or wait()
, which don’t provide strong guarantees
about temporal accuracy (i.e. the time they’ll sleep/wait for), Extempore’s
scheduling engine is guaranteed to execute its tasks at the requested time
(the number of audio samples since Extempore was started). This temporal
guarantee is significant for time critical domains such as audio and graphic,
and real-time systems programming.
Extempore’s scheduler (which what we’re accessing when we call (now)
is driven
by the audio device which the Extempore process connected to upon startup. This
isn’t the same as the time value on the system clock, although you can access
the system clock as well through (clock:clock)
. Instead, the audio device will
(usually) have a much more accurate clock, running at the audio sample rate
(which will usually be 44.1kHz). That’s why Extempore always connects to an
audio device on startup—even if you’re not producing any audio output.
Scheduling events for future execution
Being able to schedule events (e.g. for music playback or even arbitrary code execution) for execution in the future is super handy. As an example, let’s load up the default synth instrument and play three notes in sequence (an arpeggiated triad).
;; load the instruments file
(sys:load "libs/core/instruments.xtm")
;; define a synth using the predefined instrument fmsynth
(make-instrument synth fmsynth)
;; add the instrument to the DSP output callback
(bind-func dsp:DSP
(lambda (in time chan dat)
(synth in time chan dat)))
(dsp:set! dsp)
;; schedule three nodes to play in succession
(define play-seq
(lambda ()
(play-note (now) synth 60 80 10000)
(play-note (+ (now) 22050) synth 64 80 10000)
(play-note (+ (now) 44100) synth 67 80 10000)))
;; play
(play-seq)
Extempore uses an asynchronous ‘schedule and forget’ style of programming, often
in conjunction with a design pattern called temporal recursion—a concept I’ll
come back to shortly. This is different from languages such as ChucK which use a
synchronous approach. What is the difference? Synchronous timing works by
holding up code until some specified time in the future. This is basically the
same concept as using sleep()
, although strongly timed languages like ChucK
guarantee the length of the sleep. Extempore, on the other hand, works by
scheduling tasks to be executed at some time in the future. Once a task has been
scheduled, thread execution moves immediately onto the next expression. A
pseudocode example may help to illustrate this difference.
//synchronous timing
play-note(now)
time = (now) + 44100
play-note(now)
//asynchronous timing
play-note(now)
play-note(now + 44100)
Strongly timed code holds up thread execution until the global time is equal to
the value time
, and multitasking is achieved by running multiple concurrent
threads of execution. The asynchronous code example schedules tasks into the
future and immediately continues execution. Multitasking in Extempore is
achieved with very little effort by evaluating multiple simultaneous temporal
recursions.
Asynchronous event scheduling is a fairly common programming technique, and there wouldn’t be much else to say if Extempore wasn’t a dynamic language. However, Extempore allows us to create and schedule code for future execution. This turns out to be very useful in time-based programming.
Temporal recursion
There is a common design pattern in Extempore programming called temporal
recursion. By writing a function which schedules itself as its final action,
a temporally recursive callback loop is established. Here is an example
demonstrating a foo
function that will play a note and then schedule itself to
be called back in one second (this loop will continue indefinitely).
(define foo
(lambda ()
(play-note (now) synth 60 80 *second*)
(callback (+ (now) *second*) 'foo)))
(foo)
You can create as many of these temporal recursion loops as you like—try
evaluating foo
multiple times. Notice that you get multitasking for free, you
don’t need to do anything special to run two event streams. You can even create
temporal recursions inside temporal recursions.
A temporal recursion need not ‘recur’ at a constant rate. By adjusting the time
increment on each cycle the callback
rate (control rate) can be constantly
adjusted. Here is an extension to the previous example that will randomize the
note length. Note that each callback
is now scheduled at (now)
+ the
duration of the note.
In making this change, we’re also taking advantage of the fact that you can re-evaluate a function while it is temporally recursing, changing its functionality on the fly (provided that the signature of the method does not change, i.e. same arguments and same name). Try evaluating the code below while the old version of foo is running.
;; re-define foo
(define foo
(lambda ()
(let ((note-length (random '(0.25 0.5 1.0 2.0))))
(play-note (now) synth (random 60 80) 80 (* *second* note-length))
(callback (+ (now) (* note-length *second*)) 'foo))))
One-off anonymous functions can also be scheduled for future evaluation. The
code example below shows a one off anonymous function scheduled for evaluation
one minute from (now)
.
(callback (+ (now) *minute*)
(lambda () (play-note (now) synth 60 80 *second*)))
There are a couple of gotchas to keep in mind when doing ‘schedule and forget’
programming. The first is that (now)
can be a slippery thing. In the example
below, the two notes may be scheduled to play on the same sample, but then
again, they may not! (now)
may have moved forward in time between the two
calls, even if they were evaluated at the same time.
(play-note (now) synth 60 80 *second*)
(play-note (now) synth 72 80 *second*)
Often this lack of precision is fine (i.e. too small a change to be noticeable) but where absolute accuracy is required a time variable should be used.
(let ((time (now)))
(play-note time synth 60 80 *second*)
(play-note time synth 72 80 *second*))
This inaccuracy becomes more of an issue when amplified over time, such as using
(now)
inside a recursive callback loop. We can avoid the problem by precisely
incrementing a time
value between each recursive callback (note that any
arguments required by the function being called back must also be passed to
callback
).
;; This is bad
(define loop
(lambda ()
(play-note (now) synth 60 80 *second*)
(callback (+ (now) *second*) 'loop)))
(loop)
;; This is good (precise time arg is now incremented each recursion)
(define loop
(lambda (time)
(play-note time synth 60 80 *second* )
(callback (+ time *second*) 'loop (+ time *second*))))
(loop (now))
The second major gotcha in recursive callback loops is that (now)
is now.
Code requires some time to execute. If you are executing a call to evaluate a
note (now)
, by the time the code is evaluated it will already be late: (now)
will have moved on. You should always try to schedule your code execution
ahead of the scheduled time of your tasks.
;; This is best (callback happens 4100 samples earlier than new time)
(define loop
(lambda (time)
(play-note time synth 60 80 1.0)
(callback (+ time 40000) 'loop (+ time 44100))))
(loop (now))
In the ‘good’ version of loop
, the time
passed as an argument to loop
is
exactly the same time as the scheduled callback time. The problem with this is
that the next note needs to be scheduled at exactly the same time that the
function is called. The note will always be late. The ‘best’ version schedules
the callback just ahead of the time that we want the note to play. This gives us
4100
samples to execute the code to schedule the note before the note is
required to sound.
- Previous
- Next