ITP Notebook

Rhythm Interface

March 05, 2021

Music Interaction Design

This week I started an interface for designing rhythms. I used the core idea from a project of mine called Shape Your Music where loops are generated by drawing polygons.

The device is a Max MIDI instrument that will generate notes from the points placed on the canvas. In this demo you can see a simulation of the final result — as the white progress indicator moves around the shape, a note will be triggered at each vertex. Eventually I want there to be multiple polygons of different colors, each color representing a different note/drum hit. I also want the ability to sync the total perimeter length so that the different shapes will loop in sync with the global transport.

I used the jsui component to build the input canvas, but had trouble translating the list of edge lengths (appearing to the right of the canvas) into a looped sequence of MIDI notes that would be sent to the output.

The Javascript code is still very much in progress but this is what it looks like so far:

sketch.default2d();
var val = 0;
var vbrgb = [1, 1, 1, 1];
var vfrgb = [0.5, 0.5, 0.5, 1];
var vrgb2 = [0.7, 0.7, 0.7, 1];
var last_x = 0;
var last_y = 0;

var hoveredVertex = -1;
var hoveredMidpoint = -1;
var draggingVertex = -1;
var mousePos = [-2, -2];

var points = [];
var edges = [];

var currEdgeEndIndex = 1;

var progress = 0;

outlets = 4;

setoutletassist(0, 'Edge Durations');
setoutletassist(1, 'Perimeter Length');
setoutletassist(2, 'Number of edges');

// process arguments
// if (jsarguments.length > 1) vfrgb[0] = jsarguments[1] / 255;
// if (jsarguments.length > 2) vfrgb[1] = jsarguments[2] / 255;
// if (jsarguments.length > 3) vfrgb[2] = jsarguments[3] / 255;

function getEdges() {
  var edges = [];
  if (points.length < 2) return edges;
  var n = points.length;
  for (var i = 0; i < n - 1; i++) {
    var p = points[i];
    var pNext = points[i + 1];
    edges.push([p, pNext]);
  }
  edges.push([points[n - 1], points[0]]);

  return edges;
}

function clampToScreen(val) {
  return Math.min(Math.max(val, -1), 1);
}

function dist(p1, p2) {
  var a = p1[0] - p2[0];
  var b = p1[1] - p2[1];
  return Math.sqrt(a * a + b * b);
}

function getPerimeterLength(points) {
  if (!points) return 0;
  var len = 0;
  var n = points.length;
  for (var i = 1; i < points.length; i += 1) {
    var p = points[i];
    var prevP = points[i - 1];
    len += dist(p, prevP);
  }
  // last edge
  len += dist(points[0], points[n - 2]);
  return len;
}

function getMidPoint(p1, p2) {
  return [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2];
}

draw();

function drawVertex(p, r, withOutline) {
  sketch.glcolor(0.2, 0.2, 0.2, 1);
  sketch.moveto(p[0], p[1]);
  sketch.circle(r);

  if (withOutline) {
    sketch.glcolor(0.2, 0.2, 0.2, 0.9);
    sketch.gllinewidth(2);
    sketch.framecircle(r * 1.2);
  }
}

function drawMidpoint(p, r, a) {
  sketch.moveto(p[0], p[1]);
  sketch.gllinewidth(2);
  sketch.glcolor(0.2, 0.2, 0.2, a || 0.3);
  sketch.circle(r);
  // sketch.glcolor(0.2, 0.2, 0.2, 0.3);
  // sketch.framecircle(r);
}

function draw() {
  with (sketch) {
    glclearcolor(0.8, 0.8, 0.8, 0.4);
    glclear();
    // draw edges
    var perimeterLength = edges.length > 0 && getPerimeterLength(points);
    var currentPerim = 0;
    var currProgress = 0;
    edges.forEach(function (edge, i) {
      var p1 = edge[0];
      var p2 = edge[1];
      gllinewidth(2);
      const edgeLength = dist(p1, p2);
      var ratio = edgeLength / perimeterLength;

      glcolor(0.2, 0.2, 0.2, 1);
      linesegment(p1[0], p1[1], 0, p2[0], p2[1], 0);
      var r = 0.03;
      var a = 0.2;
      if (hoveredMidpoint > -1 && hoveredMidpoint === i) {
        r = r * 1.4;
        a = 0.6;
      }
      var midpoint = getMidPoint(p1, p2);
      drawMidpoint(midpoint, r, a);

      if (currentPerim / perimeterLength < progress) {
        gllinewidth(6);
        glcolor(1, 1, 1, 1);
        // linesegment(p1[0], p1[1], 0, p2[0], p2[1], 0);
        const percentAlongEdge = (progress - currProgress) / ratio;
        var progressP = pointAlongEdge(edge, percentAlongEdge);
        if ((currentPerim + edgeLength) / perimeterLength > progress) {
          moveto(progressP[0], progressP[1]);
          lineto(p1[0], p1[1]);
          circle(0.05);
        }
      }

      currentPerim += edgeLength;
      currProgress += ratio;
    });
    // draw vertexes
    points.forEach(function (p, i) {
      var r = 0.03;
      var isFirst = i === 0;
      if (isFirst) {
        r = 0.04;
      }
      if (hoveredVertex > -1 && hoveredVertex + 1 === i) {
        r = r * 1.4;
      }
      drawVertex(p, r, isFirst);
    });
    // point/line under mouse
    if (hoveredVertex === -1 && hoveredMidpoint === -1) {
      glcolor(0.2, 0.2, 0.2, 1);
      var n = points.length;
      gllinewidth(1);
      glcolor(0.2, 0.2, 0.2, 2);
      // moveto(mousePos[0], mousePos[1]);
      // lineto(points[0][0], points[0][1]);
      // moveto(mousePos[0], mousePos[1]);
      // lineto(points[n - 1][0], points[n - 1][1]);
      moveto(mousePos[0], mousePos[1]);
      circle(0.03);
    }
  }
}

function bang() {
  edges = getEdges();
  draw();
  refresh();

  var durations = edges.map(function (edge) {
    return dist(edge[0], edge[1]);
  });
  outlet(0, durations.length > 0 ? durations : '');
  var perimLength = points.length > 1 ? getPerimeterLength(points) : 0;
  outlet(1, perimLength);
  outlet(2, edges.length);
}

function reset(v) {
  post('reset');
  points.length = 0;
  hoveredVertex = -1;
  hoveredMidpoint = -1;
  edges = [];
  bang();
  // msg_float();
}

// function msg_float(v) {
//   val = Math.min(Math.max(0, v), 1);
//   notifyclients();
//   bang();
// }

// function set(v) {
//   val = Math.min(Math.max(0, v), 1);
//   notifyclients();
//   draw();
//   refresh();
// }

function pointAlongEdge(edge, percent) {
  var p1 = edge[0];
  var p2 = edge[1];
  return [p1[0] + (p2[0] - p1[0]) * percent, p1[1] + (p2[1] - p1[1]) * percent];
}

function setProgress(v) {
  progress = v;
}

// all mouse events are of the form:
// onevent <x>, <y>, <button down>, <cmd(PC ctrl)>, <shift>, <capslock>, <option>, <ctrl(PC rbutton)>
// if you don't care about the additonal modifiers args, you can simply leave them out.
// one potentially confusing thing is that mouse events are in absolute screen coordinates,
// with (0,0) as left top, and (width,height) as right, bottom, while drawing
// coordinates are in relative world coordinates, with (0,0) as the center, +1 top, -1 bottom,
// and x coordinates using a uniform scale based on the y coordinates. to convert between screen
// and world coordinates, use sketch.screentoworld(x,y) and sketch.worldtoscreen(x,y,z).

function onclick(x, y, but, cmd, shift, capslock, option, ctrl) {
  var p = sketch.screentoworld(x, y);
  var newP = [p[0], p[1]];
  draggingVertex = -1;

  if (hoveredMidpoint > -1) {
    points.splice(hoveredMidpoint + 1, 0, newP);
    draggingVertex = hoveredMidpoint;
    hoveredVertex = hoveredMidpoint;
    hoveredMidpoint = -1;
  } else if (hoveredVertex > -1) {
    // hold shift to delete
    if (shift) {
      post('deleting point');
      points.splice(hoveredVertex + 1, 1);
    } else {
      draggingVertex = hoveredVertex;
    }
  } else {
    points.push(newP);
  }

  edges = getEdges();
}
onclick.local = 1; //private. could be left public to permit "synthetic" events

function ondrag(x, y, but, cmd, shift, capslock, option, ctrl) {
  post('drag');

  var currP = sketch.screentoworld(x, y);
  // post(draggingVertex);
  // post(dx);
  // post("\n");
  if (draggingVertex > -1) {
    points[draggingVertex + 1] = currP.map(clampToScreen);
  }
  edges = getEdges();
}
ondrag.local = 1; //private. could be left public to permit "synthetic" events

// function ondblclick(x, y, but, cmd, shift, capslock, option, ctrl) {
//   last_x = x;
//   last_y = y;
//   msg_float(0); // reset dial?
// }
// ondblclick.local = 1; //private. could be left public to permit "synthetic" events

function onidle(x, y, but, cmd, shift, capslock, option, ctrl) {
  var p = sketch.screentoworld(x, y);
  mousePos = [p[0], p[1]];

  hoveredMidpoint = -1;
  hoveredVertex = -1;

  for (var i = 0; i < points.length - 1; i++) {
    var p = points[i];
    var pNext = points[i + 1];
    var midpoint = getMidPoint(p, pNext);
    var hoverRadius = 0.05;
    if (dist(mousePos, pNext) < hoverRadius) {
      hoveredVertex = i;
    } else if (dist(mousePos, midpoint) < hoverRadius) {
      hoveredMidpoint = i;
    }
  }
}

function forcesize(w, h) {
  if (w != h) {
    h = w;
    box.size(w, h);
  }
}
forcesize.local = 1; //private

function onresize(w, h) {
  forcesize(w, h);
  draw();
  refresh();
}
onresize.local = 1; //private