Previous page: GLApp
Next page: Daily Crap 2009-06-10


Cyclone

A time-scaling metronome for ChucK

Consider this skeleton of a ChucK sketch for playing sequenced music.

minute / 120. => dur beat;

fun void voice1() {
  while(beat => now) {
    // play a note
  }
}

fun void voice2() {
  while(beat / 2. => now) {
    // play a hi-hat
  }
}

spork ~ voice1();
spork ~ voice2();

while(1::day => now);

What happens if you want to change the tempo in the middle of a song? There's really no way to do that without the voices getting out of sync. The relationship between real time and musical time is fluid, but dur beat is effectively fixed. We need a way to replace "wait for time X" with "wait for musical moment X."

To do this I created a class named Cyclone based on ideas from Dan Trueman's Cyclotron. It is a metronome that allows shreds to operate in musical time instead of ChucK time: you say cyclone.wait(X) => now where X is a number of beats, not an amount of time. If the tempo changes underfoot, Cyclone still wakes shreds up at the proper musical time.

The code is below, followed by a warning and an example.

cyclone.ck

class TimeoutEvent extends Event {
  0 => int sent;
  0 => int timed_out;
}

class Timeout {
  fun TimeoutEvent either(Event ev, time t) {
    TimeoutEvent tev;
    spork ~ time_alert(tev, t);
    spork ~ event_alert(tev, ev);
    return tev;
  }

  fun void time_alert(TimeoutEvent tev, time t) {
    t => now;
    1 => tev.timed_out;
    alert(tev);
  }

  fun void event_alert(TimeoutEvent tev, Event ev) {
    ev => now;
    alert(tev);
  }

  fun void alert(TimeoutEvent tev) {
    if(!tev.sent) {
      1 => tev.sent;
      tev.broadcast();
    }
  }
}

public class Cyclone {
  // singleton cyclone, in case people want to share
  static Cyclone @ s;

  1::second => dur _period;

  0 => float _cnow; // current time (in beats)
  now => time _last_calib; // last time _cnow was updated

  // raised whenever shreds need to readjust their timing
  Event _adjustment;

  Timeout _timeout;

  fun dur period() { return _period; }

  fun dur period(dur period) {
    _update_cnow();
    period => _period;
    _adjustment.broadcast();
    return period;
  }

  // returns an event that will be triggered this many beats from now
  // (but use at() instead in most cases)
  fun Event wait(float beats) {
    Event ev;
    spork ~ _waiter(ev, cyclone_time(now) + beats);
    return ev;
  }

  // returns an event that will be triggered at this many beats from
  // when the cyclone was created
  fun Event at(float beats) {
    Event ev;
    spork ~ _waiter(ev, beats);
    return ev;
  }

  // converts Cyclone time to ChucK time
  fun time chuck_time(float cyc_time) {
    return _last_calib + _period * (cyc_time - _cnow);
  }

  // converts ChucK time to Cyclone time
  fun float cyclone_time(time ck_time) {
    return _cnow + (ck_time - _last_calib) / _period;
  }

  fun void _update_cnow() {
    cyclone_time(now) => _cnow;
    now => _last_calib;
  }

  fun void _waiter(Event ev, float target) {
    TimeoutEvent tev;
    do {
      _timeout.either(_adjustment, chuck_time(target)) @=> tev;
      tev => now;
    } while(!tev.timed_out);

    ev.broadcast();
  }
}

null => Cyclone.s;

Bugs

There's a major problem with Cyclone. The short of it is using wait() racks up rounding errors so shreds quickly get out of sync. Therefore, I recommend only using at() unless only one shred is using the Cyclone.

The symptoms appear in the most common use case, a loop like while(cyc.wait(1.0) => now) { /* music */; }. After one beat, Cyclone broadcasts the event returned by wait(), but ChucK will not wake the shred until the beginning of the next sample. That allows time to pass between the loop executing and the next cyc.wait() call. Those lost samples quickly add up.

A solution is to remember the Cyclone's "now" when a shred is woken and use that as an offset for the next wait() call, but that has its own problems and there is no obvious way to implement it. Programming with at() is the best work-around I have found. I really want to fix this problem, but I haven't yet found a way…

An example

The example plays two click tracks, one on the beat and one every fourth of a beat, creating an accent pattern like "ONE, two, three, four, TWO, two, three, four." Press up and down to adjust the length of a beat as it goes.

Notice how this example uses at() for accuracy (see the Bugs section above).

Cyclone cyc;

fun void click(float beats) {
  Impulse i => dac;
  beats => float n;
  while(true) {
    1 => i.next;
    cyc.at(n) => now;
    n + beats => n;
  }
}

fun void key(int device) {
  Hid hi;
  HidMsg msg;

  if( !hi.openKeyboard( device ) )
    me.exit();

  while( true ) {
    hi => now;

    while( hi.recv( msg ) ) {
      if( msg.isButtonDown() ) {
        if(msg.which == 81) // down
          cyc.period() / 1.1 => cyc.period;
        if(msg.which == 82) // up
          cyc.period() * 1.1 => cyc.period;
        <<< cyc.period() / second >>>;
      }
    }
  }
}

spork ~ click(1);
spork ~ click(.25);
spork ~ key(1);

while(1::day => now);

Comments

Click here to view the comments on this post.