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

4: Colors and Dragging

First, some housekeeping: I've realized in ESPatternClip:patternToPlay, as mentioned in the Pseed helpfile, we need to limit the length of our randSeed stream to one to avoid infinite repitition of the input pattern:

/* ESPatternClip.sc */

  // helper method to return the actual pattern that will be played
  patternToPlay {
    // if this clip is seeded, return the seeded pattern.
    if (isSeeded) {
      ^Pseed(Pn(randSeed, 1), this.pattern);
    } {
      ^this.pattern;
    }
  }
    

Clip colors and names

I think it would be nice to be able to change the color of a clip, and for different types of clips to have different default colors. I also think each clip should display a title that gives a hint at its contents. We will ask the prDraw method to return the name to display:

/* ESClip.sc */

  prDraw { ^"[empty]" }
  
/* ESRoutineClip.sc */

  prDraw { ^"Routine" }
  
/* ESSynthClip.sc */

  prDraw { ^defName.asString }
  
/* ESPatternClip.sc */

  prDraw { |left, top, width, height|
    var t = 0.0;
    var instrument = Set(8);
    this.drawData.do { |event|
      if (event.isRest.not) {
        var x = left + (t * width / duration);
        var eventHeight = 2;
        // I've also changed this to accept arrays of note sustains and amplitudes:
        event.freq.asArray.do { |freq, i|
          var eventWidth = event.sustain.asArray.wrapAt(i) * width / duration;
          var y = freq.explin(20, 20000, height, top);
          Pen.color = Color.gray(1, event.amp.asArray.wrapAt(i).ampdb.linexp(-60.0, 0.0, 0.05, 1.0));
          Pen.addRect(Rect(x, y, eventWidth, eventHeight));
          Pen.fill;
        };
        instrument.add(event.instrument.asString);
      };
      t = t + event.dur;
    };
    // return the title of the clip: the instrument(s) used in the Patterns
    ^instrument.asArray.join(" / ");
  }
    

And also add a "color" parameter, with a customizable default color (different for each type of clip):

/* ESClip.sc */

ESClip {
  var <startTime, <duration, color;
  var <isPlaying = false;
  var playRout;

  *new { |startTime, duration, color|
    ^super.newCopyArgs(startTime, duration, color);
  }

  .....

  defaultColor { ^Color.hsv(0.5, 0.5, 0.5); }

  color {
    ^color ?? { this.defaultColor }
  }
    
/* ESRoutineClip.sc */

  defaultColor { ^Color.hsv(0.5, 0.5, 0.5); }
    
/* ESSynthClip.sc */

  defaultColor { ^Color.hsv(0.85, 0.5, 0.5) }
    
/* ESPatternClip.sc */

  defaultColor { ^Color.hsv(0.4, 0.5, 0.5) }
    

So now we can modify the clip draw method:

/* ESClip.sc */

  // draw this clip on a UserView using Pen
  draw { |left, top, width, height|
    Pen.color = this.color;
    Pen.addRect(Rect(left, top, width, height));
    Pen.fill;
    // if it's more than 5 pixels wide, call the prDraw function
    if (width > 5) {
      var title = this.prDraw(left, top, width, height);
      var font = Font("Helvetica", 14, true);
      // make sure the title is on the screen
      if (left < 0) {
        width = width + left;
        left = 0;
      };
      // make sure the title fits inside the clip, shorten if necessary
      while { max(0, width - 3.5) < (QtGUI.stringBounds(title, font).width) } {
        if (title.size == 1) {
          title = "";
        } {
          title = title[0..title.size-2];
        };
      };
      // draw the title
      Pen.stringAtPoint(title, (left + 3.5)@(top + 2), font, Color.gray(1, 0.5));
    };
  }
    

e.g.:

Dragging clip edges

Now the thing I would really love is to be able to adjust the start point of a pattern clip without changing the timing of the notes it's playing, by clicking and dragging with the mouse, like trimming MIDI clips in a DAW. This is a pretty big thing to think about all at once, so I will start by figuring out how to adjust a pattern's offset (how far into the pattern the clip starts).

Pattern offsets

I will accomplish this by adjusting the stream that is played; similar to our prStart method, I will use .fastForward:

~pattern = Pbind(\midinote, Pseries(20, 1), \dur, 1/3);
~offset = 20;

(
~stream = ~pattern.asStream;

// wait time is the result of .fastForward
~wait = ~stream.fastForward(~offset);

// if offset is negative, flip it and wait
if (~offset.isNegative) { ~wait = -1 * ~offset };

// if there is a wait time, add a dummy rest event to the beginning of the stream
if (~wait != 0) {
  ~stream = Routine({ (dur: ~wait, restdummy: Rest()).yield; }) ++ ~stream;
};

// play
EventStreamPlayer(~stream).play
)
    

I will wrap this up in a new helper method, patternStream:

/* ESPatternClip.sc */

  // helper method to generate stream
  patternStream {
    var stream = this.patternToPlay.asStream;
    var wait = if (offset.isPositive) {
      stream.fastForward(offset);
    } {
      -1 * offset;
    };
    if (wait != 0) {
      stream = Routine({ (dur: wait, restdummy: Rest()).yield; }) ++ stream;
    };
    ^stream;
  }
    

And use a find and replace to change all ocurrences of this.patternToPlay.asStream to this.patternStream.

And also add a new field, offset, to our clip template:

/* ESClip.sc */

ESClip {
  var <startTime, <duration, color, <offset;
  var <isPlaying = false;
  var playRout;

  *new { |startTime, duration, color, offset = 0|
    ^super.newCopyArgs(startTime, duration, color, offset);
  }
  
  // be able to adjust startTime independently and together with offset
  startTime_ { |val, adjustOffset = false|
    val = max(val, 0);
    if (adjustOffset) {
      var delta;
      val = min(val, this.endTime);
      delta = val - startTime;
      offset = offset + delta;
      duration = duration - delta;
    };
    startTime = val;
  }

  endTime_ { |val|
    duration = max(val - startTime, 0);
  }
    

Our other clip types don't yet care about the offset value, but it won't hurt them and it leaves open the possibility that other types of clips can reuse this startTime_ logic. We need to add an additional step to our Pattern clip to reset its cached drawData:

/* ESPatternClip.sc */

  startTime_ { |val, adjustOffset = false|
    super.startTime_(val, adjustOffset);
    if (adjustOffset) {
      drawData = nil;
    };
  }

  endTime_ { |val|
    super.endTime_(val);
    drawData = nil;
  }
    

Now, recompile, and run:

(
thisThread.randSeed = 25;

~timeline = ESTimeline([
  ESTrack([
    ESPatternClip(0, 50, Pbind(
      \midinote, Pseries(40, Pwrand([1, 2, 3], [2, 5, 0.4].normalizeSum, inf)).wrap(30, 120),
      \dur, Pbrown(0.1, 2) + Pwhite(-0.1, 0.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, duration:60);
)
    

And now, you can move the entire clip like this:

~timeline.tracks[0].clips[0].startTime = 4; ~view.refresh;
    

You will notice that the notes all move with the clip, and the end point also moves (because the duration hasn't changed). But we can also with our new startTime_ method specify that the end time and note timings should not change:

~timeline.tracks[0].clips[0].startTime_(8), true); ~view.refresh;
    

You will see here that the timing of the notes and the end point do not change. This seems quite a durable solution, i.e.:

(
{
  100.do {
    ~timeline.tracks[0].clips[0].startTime_(rrand(0.0, 20.0), true); ~view.refresh;
    0.01.wait;
  }
}.fork(AppClock)
)
    

changing the value 100 times in a second does not alter the note timings at all.

Clicking and dragging

Now we adjust our timeline view class to add a "dragView" (just a red bar that shows when the mouse is over the edge of a clip), a new method clipAtPoint that detects which clip lies under a point and which edge of the clip the point is over, and adding to the mouseOverAction and mouseMoveAction logic for handling the mouse dragging a clip (move the clip), and dragging the edge of the clip (adjust the start or end time of the clip without affecting note timings).

/* ESTimelineView.sc */

ESTimelineView : UserView {
  var <timeline;
  var <trackViews, <playheadView, playheadRout;
  var <dragView;
  var <startTime, <duration;
  var <trackHeight;
  var clickPoint, clickTime, scrolling = false, originalDuration;
  var hoverClip, hoverCode, hoverClipStartTime;


  *new { |parent, bounds, timeline, startTime = -2.0, duration = 50.0|
    ^super.new(parent, bounds).init(timeline, startTime, duration);
  }

  init { |argtimeline, argstartTime, argduration|
    var width = this.bounds.width;
    var height = this.bounds.height;

    startTime = argstartTime;
    duration = argduration;

    timeline = argtimeline;
    trackHeight = (height - 20) / timeline.tracks.size;

    trackViews = timeline.tracks.collect { |track, i|
      var top = i * trackHeight + 20;
      ESTrackView(this, Rect(0, top, width, trackHeight), track)
    };

    playheadView = UserView(this, this.bounds.copy.origin_(0@0))
    .acceptsMouse_(false)
    .drawFunc_({
      var left;
      Pen.use {
        // sounding playhead in black
        left = this.absoluteTimeToPixels(timeline.soundingNow);
        Pen.addRect(Rect(left, 0, 2, height));
        Pen.color = Color.black;
        Pen.fill;

        if (timeline.isPlaying) {
          // "scheduling playhead" in gray
          Pen.color = Color.gray(0.5, 0.5);
          left = this.absoluteTimeToPixels(timeline.now);
          Pen.addRect(Rect(left, 0, 2, height));
          Pen.fill;
        };
      };
    });

    dragView = View(this, Rect(0, 0, 2, trackHeight)).visible_(false).background_(Color.red).acceptsMouse_(false);

    this.drawFunc_({
      var division = (60 / (this.bounds.width / this.duration)).ceil;
      Pen.use {
        Pen.color = Color.black;
        (this.startTime + this.duration + 1).asInteger.do { |i|
          if (i % division == 0) {
            var left = this.absoluteTimeToPixels(i);
            Pen.addRect(Rect(left, 0, 1, 20));
            Pen.fill;
            Pen.stringAtPoint(i.asString, (left + 3)@0, Font("Courier New", 16));
          }
        };
      };
    }).mouseWheelAction_({ |view, x, y, modifiers, xDelta, yDelta|
      var xTime = view.pixelsToAbsoluteTime(x);
      view.duration = view.duration * yDelta.linexp(-100, 100, 0.5, 2, nil);
      view.startTime = xTime - view.pixelsToRelativeTime(x);
      view.startTime = view.startTime + (xDelta * view.duration * -0.002);
      dragView.visible_(false);
    }).mouseDownAction_({ |view, x, y, mods|
      clickPoint = x@y;
      clickTime = this.pixelsToAbsoluteTime(x);
      originalDuration = duration;
      if (hoverClip.notNil) {
        hoverClipStartTime = hoverClip.startTime;
      };

      if (y < 20) { scrolling = true } { scrolling = false };
    }).mouseUpAction_({ |view, x, y, mods|
      // if the mouse didn't move during the click, move the playhead to the click point:
      if (clickPoint == (x@y)) {
        if (timeline.isPlaying.not) {
          timeline.now = clickTime;
        };
      };

      clickPoint = nil;
      clickTime = nil;
      scrolling = false;
      originalDuration = nil;
      hoverClipStartTime = nil;
      this.refresh;
    }).mouseMoveAction_({ |view, x, y, mods|
      var yDelta = y - clickPoint.y;
      var xDelta = x - clickPoint.x;
      // drag timeline
      if (scrolling) {
        if (mods.isAlt) { // hold option to zoom in opposite direction
          yDelta = yDelta.neg;
        };
        duration = (originalDuration * yDelta.linexp(-100, 100, 0.5, 2, nil));
        startTime = (clickTime - this.pixelsToRelativeTime(clickPoint.x));
        startTime = (xDelta.linlin(0, this.bounds.width, startTime, startTime - duration, nil));
        this.refresh;
      };

      switch (hoverCode)
      {1} { // drag left edge
        hoverClip.startTime_(this.pixelsToAbsoluteTime(x), true);
        dragView.bounds_(dragView.bounds.left_(this.absoluteTimeToPixels(hoverClip.startTime)));
        this.refresh;
      }
      {2} { // drag right edge
        hoverClip.endTime = this.pixelsToAbsoluteTime(x);
        dragView.bounds_(dragView.bounds.left_(this.absoluteTimeToPixels(hoverClip.endTime) - 2));
        this.refresh;
      }
      {0} { // drag clip
        hoverClip.startTime = hoverClipStartTime + this.pixelsToRelativeTime(xDelta);
        this.refresh;
      };
    }).mouseOverAction_({ |view, x, y|
      var i, j;
      # hoverClip, i, j, hoverCode = this.clipAtPoint(x@y);

      switch (hoverCode)
      {1} { // left edge
        dragView.bounds_(dragView.bounds.origin_(this.absoluteTimeToPixels(hoverClip.startTime)@(i * trackHeight + 20)));
        dragView.visible_(true);
      }
      {2} { // right edge
        dragView.bounds_(dragView.bounds.origin_((this.absoluteTimeToPixels(hoverClip.endTime) - 2)@(i * trackHeight + 20)));
        dragView.visible_(true);
      }
      { // default
        dragView.visible_(false);
      };
    }).keyDownAction_({ |view, char, mods, unicode, keycode, key|
      if (char == $ ) { timeline.togglePlay };
    });

    // call update method on changed
    timeline.addDependant(this);
    this.onClose = { timeline.removeDependant(this) };
  }

  // called when the timeline is changed
  update { |argtimeline, what, value|
    if (what == \isPlaying) {
      if (value) {
        var waitTime = 30.reciprocal; // 30 fps
        playheadRout.stop; // just to make sure
        playheadRout = {
          inf.do {
            playheadView.refresh;
            waitTime.wait;
          };
        }.fork(AppClock) // lower priority clock for GUI updates
      } {
        playheadRout.stop;
        playheadView.refresh;
      };
    };
  }

  clipAtPoint { |point|
    timeline.tracks.do { |track, i|
      var top = i * trackHeight + 20;
      track.clips.do { |clip, j|
        // if clip within bounds...
        if ((clip.startTime < this.endTime) and: (clip.endTime > this.startTime)) {
          var left = this.absoluteTimeToPixels(clip.startTime);
          var width = this.relativeTimeToPixels(clip.duration);
          // if our point is within the clip's bounds...
          if (point.x.inRange(left, left + width) and: point.y.inRange(top, top + trackHeight)) {
            if ((point.x - left) < 2) { ^[clip, i, j, 1] }; // code for mouse over left edge
            if (((left + width) - point.x) < 2) { ^[clip, i, j, 2] }; // code for mouse over right edge
            ^[clip, i, j, 0];
          };
        };
      };
    };
    ^[nil, nil, nil, nil];
  }

  // helper methods:
  relativeTimeToPixels { |time| ^(time / duration) * this.bounds.width }
  absoluteTimeToPixels { |clipStartTime| ^this.relativeTimeToPixels(clipStartTime - startTime) }
  pixelsToRelativeTime { |pixels| ^(pixels / this.bounds.width) * duration }
  pixelsToAbsoluteTime { |pixels| ^this.pixelsToRelativeTime(pixels) + startTime }

  startTime_ { |val|
    startTime = val;
    this.refresh;
  }

  duration_ { |val|
    duration = val;
    this.refresh;
  }

  endTime {
    ^startTime + duration;
  }
}
    

Recompile, and now we need to tell the view's enclosing Window that it should accept mouse over:

(
thisThread.randSeed = 25;

~timeline = ESTimeline([
  ESTrack([
    ESPatternClip(0, 50, Pbind(
      \midinote, Pseries(40, Pwrand([1, 2, 3], [2, 5, 0.4].normalizeSum, inf)).wrap(30, 120),
      \dur, Pbrown(0.1, 2) + Pwhite(-0.1, 0.1)
    ))
  ])
]);

Window.closeAll;
~window = Window("Timeline", Rect(0, Window.availableBounds.height - 500, Window.availableBounds.width, 500))
.acceptsMouseOver_(true).front;

~view = ESTimelineView(~window, ~window.bounds.copy.origin_(0@0), ~timeline, duration:60);
)
    

And you will see when you hover over the left or right edge of the clip a red bar appear, and when you drag it the clip will resize appropriately. And when you drag on the clip itself, it will move as you would expect.

Conclusion

We're really getting going now! You might notice that I should probably add some .changed notifications when I adjust clip start and end times instead of calling this.refresh manually from inside the mouseMoveAction, but I will save that for another day.

Download my final classes from this part here.

What I really need to implement now is an interface for saving and editing the contents of these timeline clips, and for adding new clips. Also I think an undo/redo system is a must. Stay tuned for that.