ChucK Tricks

ChucK jumps through a ring of fire

ChucK is the audio software powering most of PLOrk (the PRinceton Laptop Orchestra), and pretty much anything I've made recently requiring real-time audio. It's a flaky scripting language that's strongly-timed, meaning that it makes guarantees about where samples you create will appear in the output stream (even if the stream doesn't make it to the speaker on time because your script is slow).

But if you don't already know ChucK, I'll have to point you toward his wonderful documentation before you read much further.

Generic Polyphonic

In creating the various ChucK instruments I have over the semester, I've often needed a way to combine many sounds with interleaved start and end times. There are two major stumbling blocks to doing this well:

  1. ChucK gets slow and has trouble keeping up with your requested timing when there are too many generators sending audio to the output. Voices should only be connected to the dac when they are playing audio for efficiency.
  2. You should be able to kill a voice while it is still "warming up." That is, if a voice is not quite through its initial "attack," you should still be able to cancel it so that the closing envelope can be applied.

After many, many trials, I believe I'm finally getting close to something usable, even though I have reservations. Take a look:

Gain mix => dac;

9 => int numVoices;
Envelope outs[numVoices];
Shred managers[numVoices];
for(0 => int i; i < numVoices; i++) {
    Envelope e @=> outs[i];
    null => managers[i];
}

fun void open(int voice) { outs[voice] => mix; }
fun void close(int voice) { outs[voice] =< mix; }

fun void rampTo(int voice, float gain, dur len) {
    gain => outs[voice].target;
    len => outs[voice].duration;
    outs[voice].keyOn();
    len => now;
}

// spork this
fun void on(int voice, float gain) {
    if(managers[voice] != null) Machine.remove(managers[voice].id());
    me @=> managers[voice];

    open(voice);
    rampTo(voice, gain * 2.0, 50::ms); // attack
    rampTo(voice, gain, 50::ms); // sustain

    null => managers[voice];
}

// spork this
fun void off(int voice) {
    if(managers[voice] != null) Machine.remove(managers[voice].id());
    me @=> managers[voice];

    rampTo(voice, 0.0, 500::ms);
    close(voice);

    null => managers[voice];
}

Each voice is an Envelope you chuck audio to. Think of it as an organ; you chuck sounds to the different pipes, which you on() and off() to open or close.

Each voice also has a reference to the "shred" which is currently managing its envelope. If you should on() a voice while it is still fading out, the shred doing the fading is killed and the new, fade-in shred takes over.

That the shreds go around killing each other really bothers me. What happens when a voice is told to on and off at the same ChucK time? Who knows? However, I cannot argue with the results: this is my first implementation that works well enough that I don't feel like my system for handling voices is getting in the way when I banging out notes. It's hard to argue with results, even when race conditions lurk behind every corner.

The code I'm using this for requires a special MIDI device that I've been working with recently, but here is some pseudo-code that should work just as well as a demonstration.

for(0 => int i; i < numVoices; i++) {
    SinOsc s => outs[i];
    0.4 => s.gain; Std.mtof(60 + i) => s.freq;
}

spork ~ on(0, 1.0);
500::ms => now;
spork ~ on(1, 1.0);
300::ms => now;
spork ~ off(0);
spork ~ on(2, 1.0);
500::ms => now;
spork ~ off(1);
spork ~ off(2);
1::second => now;

'Course if there are better solutions out there, I'd love to hear them.