SC Timeline Tutorial

  1. Scrolling and Zooming
  2. Sequencing and Patterns
  3. Visualizing Playback and Patterns
  4. Synth and Routine clips
  5. Colors and Dragging

3: Synth and Routine clips

The next types of clips will contain Synths and Routines.

Synth clips

For a clip that will simply start a Synth, all we need are the arguments to Synth.newdefName, args, target, and addAction.

• I will for simplicity here play the Synth on the default Server — we might finesse this in the future.
/* ESSynthClip.sc */

ESSynthClip : ESClip {
  var <defName, <args, <target, <addAction;
  var <synth;

  *new { |startTime, duration, defName, args, target, addAction = 'addToHead'|
    ^super.new(startTime, duration).init(defName, args, target, addAction);
  }

  init { |argDefName, argArgs, argTarget, argAddAction|
    defName = argDefName;
    args = argArgs;
    target = argTarget;
    addAction = argAddAction;
  }

  prStop {
    Server.default.bind { synth.release };
    synth = nil;
  }

  prStart { |startOffset = 0.0, clock|
    Server.default.bind {
      synth = Synth(defName, args, target, addAction)
    };
  }
}
    
• For more information about server latency and Server.default.bind { ... }, see this thread on the user forum started by Nathan Ho, or his SuperCollider Tips blog post.

Since there is really no way to start a Synth a certain part of the way through, I simply accept that if you start playing in the middle of a synth clip, it will start playing as though the note was just pressed, so to speak. In the future we might make it an option not to play at all if in the middle of the clip.

So now, with our code from before:

(
s.waitForBoot {
  SynthDef(\sin, { |out, freq = 100, gate = 1, amp = 0.1, preamp = 1.5, attack = 0.001, release = 0.01, pan, verbbus, verbamt, vibrato = 0.2|
    var env, sig;
    var lfo = XLine.ar(0.01, vibrato, ExpRand(0.5, 2.0)) * SinOsc.ar(5.4 + (LFDNoise3.kr(0.1) * 0.5));
    gate = gate + Impulse.kr(0);
    env = Env.adsr(attack, 0.1, 0.4, release).ar(2, gate);
    sig = SinOsc.ar(freq * lfo.midiratio) * env;
    sig = (sig * preamp).tanh;
    sig = Pan2.ar(sig, pan, amp);
    Out.ar(out, sig);
    Out.ar(verbbus, sig * verbamt);
  }).add;

  SynthDef(\verb, { |out, verbbus, gate = 1|
    var in = In.ar(verbbus, 2);
    var env = Env.adsr(0.01, 0, 1, 1.0).ar(2, gate);
    var verb = NHHall.ar(in) * env;
    Out.ar(out, verb);
  }).add;

  ~verbbus = Bus.audio(s, 2);
};
)
    

We can do this:

(
~timeline = ESTimeline([
  ESTrack(
    50.collect { |i|
      ESSynthClip(
        startTime: i * 0.3333, 
        duration: 0.2, 
        defName: \sin, 
        args: [
          freq: 50 * (i + 1), 
          verbbus: ~verbbus, 
          verbamt: 1, 
          pan: rrand(-1.0, 1.0)
        ]
      )
    }
  ),
  ESTrack([
    ESSynthClip(0, 17, \verb, [verbbus: ~verbbus], addAction: \addToTail)
  ])
]);

Window.closeAll;
~window = Window("Timeline", Rect(0, Window.availableBounds.height - 500, Window.availableBounds.width, 500)).front;
~view = ESTimelineView(~window, ~window.bounds.copy.origin_(0@0), ~timeline);
)
    

Or even this:

(
~timeline = ESTimeline(
  10.collect { |i|
    ESTrack(
      50.collect { |j|
        ESSynthClip(
          startTime: i * 0.3333 + (j * 3), 
          duration: 1 / (i + 1), 
          defName: \sin, 
          args: [
            freq: (100 + (j * 20)) * (i + 1), 
            verbbus: ~verbbus, 
            verbamt: 1, 
            pan: rrand(-1.0, 1.0)
          ]
        )
      }
    )
  } ++ [
    ESTrack([
      ESSynthClip(0, 150, \verb, [verbbus: ~verbbus], addAction: \addToTail)
    ])
  ]
);

Window.closeAll;
~window = Window("Timeline", Rect(0, Window.availableBounds.height - 500, Window.availableBounds.width, 500)).front;
~view = ESTimelineView(~window, ~window.bounds.copy.origin_(0@0), ~timeline);
)
    

or even this:

(
~timeline = ESTimeline(
  20.collect { |i|
    ESTrack(
      500.collect { |j|
        ESSynthClip(i * 0.3333 + (j * 3), 1 / (i + 1), \sin, [freq: ((100 + (j * 20)) * (i + 1)) / 16, verbbus: ~verbbus, verbamt: 1, pan: rrand(-1.0, 1.0)])
      }
    )
  } ++ [
    ESTrack([
      ESSynthClip(0, 1500, \verb, [verbbus: ~verbbus], addAction: \addToTail)
    ])
  ]
);

Window.closeAll;
~window = Window("Timeline", Rect(0, Window.availableBounds.height - 500, Window.availableBounds.width, 500)).front;
~view = ESTimelineView(~window, ~window.bounds.copy.origin_(0@0), ~timeline);
~view.duration = 1500;
)
    

all of which get more exciting with e.g.

TempoClock.default.tempo = 10
TempoClock.default.tempo = 117.3908528797
// reset to 1
TempoClock.default.tempo = 1
    

or, a dedicated tempo knob:

(
if (~tempoWin.notNil) { ~tempoWin.close };
~tempoWin = Window("Tempo", Rect(Window.availableBounds.width - 200, Window.availableBounds.height - 760, 200, 200)).front;
~tempoKnob = EZKnob(~tempoWin, ~tempoWin.bounds.copy.origin_(0@0), 
  label: "Tempo (bpm)",
  controlSpec: ControlSpec(0.005 * 60, 500 * 60, \exp, 0.0, 60.0),
  action: { |knob|
    TempoClock.default.tempo = knob.value / 60
  }
)
)
    

Routines

Here is a Routine:

(
r = Routine({
  10.do { |i|
    i.postln;
    0.5.wait;
  }
})
)
    

To play this Routine:

r.play
// stop in the middle
r.stop
// play it again from the beginning
r.reset; r.play
    

As you can see, this routine counts briskly from 0 to 9 in the post window.

So you can play it, and reset it and play it again. But can you start in the middle? Well, it turns out a Routine is a kind of Stream, so we might try the fastForward method of Stream...

r.reset;
r.fastForward(2.2);
    

but we just get a cryptic error message. And it turns out that fastForward is implemented as part of JITLib library and only really meant to apply to streams of Events. (We can find the implementation by placing the cursor anywhere inside the fastForward and hitting Cmd-I on mac or Ctrl-I)

Fast-forwarding routines

So, I will make a wrapper class around a Routine to make it fast-forwardable, copying the logic from the Stream fastForward method:

/* ESRoutine.sc */

ESRoutine : Routine {
  fastForward { |by, tolerance=0|
    // fast forwards a routine by a certain amount
    var t = 0.0, delta;
    if (by <= 0) { ^0.0 };
    while { t.roundUp(tolerance) < by } {
      // delta is the amount of time to wait
      delta = this.next;
      if(delta.isNil) { ("end of stream. Time left:" + (by - t)).postln; ^t - by };
      t = t + delta;
    };
    ^t - by; // time left to next event
  }
}
    

Now, after a recompile, I will modify our routine to use this wrapper class and then make sure it works the same as before:

(
r = ESRoutine({
  10.do { |i|
    i.postln;
    0.5.wait;
  }
})
)
r.play
r.stop
r.reset; r.play;
    

So far so good. So what does our fast forward method do? To see this clearly, hit Shift-Cmd-P / Shift-Ctrl-P to clear the post window, then run:

r.reset; r.fastForward(2.2); 
    

You will see that this evaluates the first five iterations immediately, and returns a wait time just like Stream's fastForward method.

• Unfortunately there is no way to silently fast forward a Routine, so we will probably want to make this behavior optional as there are times when immediately evaluating the entire first part of the routine will not be a good thing.

So to actually play the routine from the desired point:

(
var startOffset = 2.2;
fork {
  r.reset;
  r.fastForward(startOffset).wait;
  r.play;
};
)
    

So now let's try an example with sound, using our \sin synth from before:

(
s.waitForBoot {
  SynthDef(\sin, { |out, freq = 100, gate = 1, amp = 0.1, preamp = 1.5, attack = 0.001, release = 0.01, pan, verbbus, verbamt, vibrato = 0.2|
    var env, sig;
    var lfo = XLine.ar(0.01, vibrato, ExpRand(0.5, 2.0)) * SinOsc.ar(5.4 + (LFDNoise3.kr(0.1) * 0.5));
    gate = gate + Impulse.kr(0);
    env = Env.adsr(attack, 0.1, 0.4, release).ar(2, gate);
    sig = SinOsc.ar(freq * lfo.midiratio) * env;
    sig = (sig * preamp).tanh;
    sig = Pan2.ar(sig, pan, amp);
    Out.ar(out, sig);
    Out.ar(verbbus, sig * verbamt);
  }).add;

  r = ESRoutine({
    var synth;
    100.do { |i|
      s.bind { synth = Synth(\sin, [freq: (i % 12 + 60 + (i / 12)).midicps]) };
      0.1.wait;
      s.bind { synth.release };
    }
  });
}
)

r.play
    

Fine enough, but what happens when we fast forward? (warning: loud click)

(
var startOffset = 8.15;
fork {
  r.reset;
  r.fastForward(startOffset).wait;
  r.play;
};
)
    

Okay, so this isn't the best. It's happening because as before the fastForward method is evaluating every iteration simultaneously until our desired start time, so we hear a lot of Synths very briefly instantiated and then immediately released.

• Note that this could even result in a lot of zombie synth nodes if we didn't have the line in our SynthDef above: gate = gate + Impulse.kr(0); : for more information, see this thread on the user forum.

One option of course is just not to use the fast forward method. Another option is to briefly mute the server while this loud click is happening.

(
var startOffset = 8.15;
fork {
  r.reset;
  if (startOffset > 0) {
    fork {
      s.mute;
      s.bind { s.unmute };
    };
  };
  r.fastForward(startOffset).wait;
  r.play;
};
)
    

Of course this isn't a great solution either: it works well for this specific case, where each synth has a very fast release time, but if we just lengthen the release time a bit, we still hear some (kind of interesting) garbage at the beginning:

(
r = ESRoutine({
  var synth;
  100.do { |i|
    s.bind { synth = Synth(\sin, [freq: (i % 12 + 60 + (i / 12)).midicps, release: 1.0]) };
    0.1.wait;
    s.bind { synth.release };
  }
});
)

(
var startOffset = 8.15;
fork {
  r.reset;
  if (startOffset > 0) {
    fork {
      s.mute;
      s.bind { s.unmute };
    };
  };
  r.fastForward(startOffset).wait;
  r.play;
};
)
    

And in fact, we only hear this errant noise because we force the envelope to fire with the line

  gate = gate + Impulse.kr(0);
    

in our synthdef.

If we use the default synthdef, which does not modify the gate, we don't hear any noise when we fast forward,

(
r = ESRoutine({
  var synth;
  100.do { |i|
    s.bind { synth = Synth(\default, [freq: (i % 12 + 60 + (i / 12)).midicps]) };
    0.1.wait;
    s.bind { synth.release };
  }
});

fork {
  r.reset;
  r.fastForward(8.15).wait;
  r.play;
};
)
    

but if you check the node tree with Cmd-opt-T (or, I think, ctrl-alt-T), you will see a ton of zombie synth nodes made by creating a synth and then immediately setting its gate to 0 which means the envelope never fires and the synth never sounds nor frees itself.

We could do something more invasive by e.g. overwriting Server:bind with a switch to mute all outgoing OSC while we fast forward, but this will also not handle all cases gracefully (e.g. creating one Synth at the beginning of the routine that we then repeatedly call .set(...) on)

So the best solution is probably to manage whether or not the synth plays from within the routine. The function in a Routine takes a single argument, inval, which when you call .next defaults to nil, but when you call .play it is given the current beats on the clock that it is playing on:

(
r = ESRoutine({ |inval|
  100.do { |i|
    i.postln;
    inval.postln;
    inval = 0.1.wait; // here you MUST assign the result of the wait back to inval!
  }
})
)

// call next a few times
r.next

// you can specify a value for inval if you want
r.next(true)
r.next(0.34)

// when you call .play, it is passed the current clock beats:
r.play
    

So we can use this behavior to "mute" when .next is called (i.e. we're fast forwarding):

(
r = ESRoutine({ |inval|
  100.do { |i|
    if (inval.notNil) {
      i.postln;
    };
    inval = 0.1.wait;
  }
})
)

r.next
r.play
r.stop

(
r.reset;
fork {
  r.fastForward(8.15).wait;
  r.play;
};
)
    

This is unfortunately something you'll have to do every time you make a fast-forwardable routine, and it becomes more annoying when you want to play Synths:

(
r = ESRoutine({ |inval|
  var synth;
  100.do { |i|
    if (inval.notNil) {
      {
        s.bind { synth = Synth(\default, [freq: (i % 12 + 60 + (i / 12)).midicps]) };
        0.1.wait;
        s.bind { synth.release };
      }.fork(thisThread.clock)
    };
    inval = 0.1.wait;
  }
});

fork {
  r.reset;
  r.fastForward(8.15).wait;
  r.play;
};
)
    

Let's think about how to improve this user experience, as it were... and meanwhile:

Synchronizing routines

One more thing we need to keep in mind:

(
~r1 = ESRoutine({
  var synth;
  10.do { |i|
    s.bind { synth = Synth(\sin, [freq: (50 + i).midicps]) };
    0.5.wait;
    s.bind { synth.release };
    0.5.wait;
  };
});
~r2 = ESRoutine({
  10.do { |i|
    i.postln;
    1.wait;
  };
})
)

~r1.play; ~r2.play
    

Again, we encounter server latency here. Our first routine plays Synths, which we wrap in s.bind { ... } according to best practice to avoid timing jitter, as previously discussed. But that means that our second routine, which counts from 0 to 9 in the post window with the same timing as the first routine, plays 0.2 seconds earlier than the notes we are hearing. So to naïvely fix this, I will wait the appropriate amount of time before starting the second routine:

(
[~r1, ~r2].do(_.reset); 
fork { 
  ~r1.play; 
  (s.latency * TempoClock.default.tempo).wait; 
  ~r2.play 
};
)
    

This does seem to do the trick, even if we change tempos in the middle of playback:

TempoClock.default.tempo = 0.1
TempoClock.default.tempo = 1
TempoClock.default.tempo = 10
    

I am a bit surprised this works so well. We should continue to keep an eye on this.

Random seed

And one last thing, similar to how we handled Pattern seeding earlier, if we have a Routine that generates random values, we can set its seed so that it generates the same values each time:

(
r = ESRoutine({
  10.do { |i|
    rrand(0, 100).postln;
    0.1.wait;
  };
});
)

r.reset; r.randSeed = 5; r.play
    

Routine clips

Okay so now let's actually start to wrap this all up into a clip. I think the data we need is:

/* ESRoutineClip.sc */

ESRoutineClip : ESClip {
  var <routine, <randSeed, <>isSeeded, <>addLatency,
  <>fastForward, // 0 - don't play from middle, 1 - fast forward from middle, 2 - start from beginning always
  <cleanupFunc;
  var player;

  *new { |startTime, duration, func, randSeed, isSeeded = true, addLatency = false, fastForward = 0, cleanupFunc|
    ^super.new(startTime, duration).init(func, randSeed, isSeeded, addLatency, fastForward, cleanupFunc);
  }

  init { |argFunc, argRandSeed, argIsSeeded, argAddLatency, argFastForward, argCleanupFunc|
    routine = ESRoutine(argFunc);
    randSeed = argRandSeed ?? rand(2000000000);
    isSeeded = argIsSeeded;
    addLatency = argAddLatency;
    fastForward = argFastForward;
    cleanupFunc = argCleanupFunc;
  }

  prStop {
    {
      if (addLatency) {
        // adjust for server latency
        Server.default.latency.wait;
      };
      player.stop;
      routine.stop;
      cleanupFunc.value;
    }.fork(SystemClock);
  }

  prStart { |startOffset = 0.0, clock|
    routine.reset;
    if (isSeeded) {
      // set random seed
      routine.randSeed = randSeed;
    };
    if ((startOffset > 0) and: (fastForward == 0)) {
      // don't play from middle
    } {
      player = {
        if (fastForward == 1) {
          // fast forward
          routine.fastForward(startOffset).wait;
        };
        if (addLatency) {
          // adjust for server latency
          (Server.default.latency * clock.tempo).wait;
        };
        routine.play(clock);
      }.fork(clock);
    };
  }
}
    

And here is an example timeline with routine clips on three tracks, the last track we've basically recreated our synth clip from earlier but using our new routine clip to demonstrate that the cleanup function works.

(
s.waitForBoot {
  SynthDef(\sin, { |out, freq = 100, gate = 1, amp = 0.1, preamp = 1.5, attack = 0.001, release = 0.01, pan, verbbus, verbamt, vibrato = 0.2|
    var env, sig;
    var lfo = XLine.ar(0.01, vibrato, ExpRand(0.5, 2.0)) * SinOsc.ar(5.4 + (LFDNoise3.kr(0.1) * 0.5));
    gate = gate + Impulse.kr(0);
    env = Env.adsr(attack, 0.1, 0.4, release).ar(2, gate);
    sig = SinOsc.ar(freq * lfo.midiratio) * env;
    sig = (sig * preamp).tanh;
    sig = Pan2.ar(sig, pan, amp);
    Out.ar(out, sig);
    Out.ar(verbbus, sig * verbamt);
  }).add;
  
  SynthDef(\verb, { |out, verbbus, gate = 1|
    var in = In.ar(verbbus, 2);
    var env = Env.adsr(0.01, 0, 1, 1.0).ar(2, gate);
    var verb = NHHall.ar(in) * env;
    Out.ar(out, verb);
  }).add;
  
  ~verbbus = Bus.audio(s, 2);
  
  ~timeline = ESTimeline([
    ESTrack([
      ESRoutineClip(0, 5.1, { |inval|
        var synth;
        inf.do { |i|
          if (inval.notNil) {
            {
              s.bind { 
                synth = Synth(\sin, [
                  freq: (i % 12 + 50 + (i / 6)).midicps, 
                  verbbus: ~verbbus, 
                  verbamt: 2.0
                ]) 
              };
              0.1.wait;
              s.bind { synth.release };
            }.fork(thisThread.clock);
          };
          inval = 0.2.wait;
        };
      }, fastForward: 1)
    ]),
    ESTrack([
      ESRoutineClip(0, 5.1, { |inval|
        inf.do { |i|
          if (inval.notNil) { i.postln };
          inval = 0.2.wait;
        };
      }, addLatency: true, fastForward: 1)
    ]),
    ESTrack([
      ESRoutineClip(0, 2.4, 
        { |inval|
          s.bind { ~verbsynth = Synth(\verb, [verbbus: ~verbbus], addAction: \addToTail) };
        },
        fastForward: 1,
        cleanupFunc: {
          s.bind { ~verbsynth.release };
        }
      ),
      ESRoutineClip(4.8, 0.4, 
        { |inval|
          s.bind { ~verbsynth = Synth(\verb, [verbbus: ~verbbus], addAction: \addToTail) };
        }, 
        fastForward: 1,
        cleanupFunc: {
          s.bind { ~verbsynth.release };
        }
      ),
    ])
  ]);
  
  Window.closeAll;
  ~window = Window("Timeline", Rect(0, Window.availableBounds.height - 500, Window.availableBounds.width, 500)).front;
  ~view = ESTimelineView(~window, ~window.bounds.copy.origin_(0@0), ~timeline);
  ~view.duration = 10;
}
)
    

And here we have our synth example from before with a Routine modifying its own tempo:

(
~timeline = ESTimeline(
  20.collect { |i|
    ESTrack(
      500.collect { |j|
        ESSynthClip(i * 0.3333 + (j * 3), 1 / (i + 1), \sin, [freq: ((100 + (j * 20)) * (i + 1)) / 16, verbbus: ~verbbus, verbamt: 1, pan: rrand(-1.0, 1.0)])
      }
    )
  } ++ [
    ESTrack([
      ESSynthClip(0, 1500, \verb, [verbbus: ~verbbus], addAction: \addToTail)
    ]),
    ESTrack([
      ESRoutineClip(0, 1500, {
        var clock = thisThread.clock;
        var tempo;
        clock.tempo = tempo = 117.3908528797;
        200.wait;
        "200".postln;
        45.do { |i|
          clock.tempo = tempo = tempo * 0.90909090909091;
          5.wait;
        };
        5.wait;
        "430".postln;
        45.do { |i|
          clock.tempo = tempo = tempo * 1.1;
          2.wait;
        };
        "520".postln;
        790.wait;
        "1310".postln;
        50.do { |i|
          clock.tempo = tempo = tempo * 0.90909090909091;
          5.wait;
        };
        "done".postln;
      }, fastForward: 1)
    ])
  ]
);

Window.closeAll;
~window = Window("Timeline", Rect(0, Window.availableBounds.height - 500, Window.availableBounds.width, 500)).front;
~view = ESTimelineView(~window, ~window.bounds.copy.origin_(0@0), ~timeline);
~view.duration = 1500;
)
    

I've tested this a little bit and sometimes the fast forward seems to behave, and sometimes it doesn't... so, I guess that's the warning, if you use a routine to play with its own tempo this way.

Conclusion

Download my final classes from this part here.

In the next part, I think we will work more on the visualization.