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; } }
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.:
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).
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.
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.
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.