Rhythm Interface
March 05, 2021
Music Interaction DesignThis 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