Bezier approach
The initial question is broad - perhaps even broad for SO, as there are many different scenarios that need to be considered in order to create a "one solution that fits all." This is a whole project in itself. Therefore, I will present the basis for a solution that you can rely on - this is not a complete solution (but close to one ...). I added some suggestions to add at the end.
The main steps for these solutions:
Group notes into two groups: left and right.
Then the control points are based on the largest angle from the first (end) point and the distance to any other note in this group, and the last end point is at any point in the second group.
The resulting angles from the two groups are then doubled (max. 90 Β°) and used as the basis for calculating the control points (mainly, turning the point). The distance can be further trimmed using the tension value.
Angular displacement, doubling, distance, tension and displacement complete the fine-tuning to get the best result. There may be special cases that need additional conditional checks, but this is not within the scope of this scope to cover (this will not be a complete key ready, but will provide a good basis for further work).
A few snapshots from the process:


The main code in the example is divided into two sections, two loops that analyze each half to find the maximum angle, as well as the distance. This can be combined into one loop and have a second iterator to go from right to middle in addition to that that goes from left to right, but for simplicity and a better understanding of what is happening, I split them into two loops (and presented the error in the second half - just know. I will leave this as an exercise):
var dist1 = 0, // final distance and angles for the control points dist2 = 0, a1 = 0, a2 = 0; // get min angle from the half first points for(i = 2; i < len * 0.5 - 2; i += 2) { var dx = notes[i ] - notes[0], // diff between end point and dy = notes[i+1] - notes[1], // current point. dist = Math.sqrt(dx*dx + dy*dy), // get distance a = Math.atan2(dy, dx); // get angle if (a < a1) { // if less (neg) then update finals a1 = a; dist1 = dist; } } if (a1 < -0.5 * Math.PI) a1 = -0.5 * Math.PI; // limit to 90 deg.
And the same with the second half, but here we rotate around the corners, so itβs easier to handle them by comparing the current point with the end point instead of the end point compared to the current point. After the cycle is completed, we turn it over 180 Β°;
// get min angle from the half last points for(i = len * 0.5; i < len - 2; i += 2) { var dx = notes[len-2] - notes[i], dy = notes[len-1] - notes[i+1], dist = Math.sqrt(dx*dx + dy*dy), a = Math.atan2(dy, dx); if (a > a2) { a2 = a; if (dist2 < dist) dist2 = dist; //bug here* } } a2 -= Math.PI; // flip 180 deg. if (a2 > -0.5 * Math.PI) a2 = -0.5 * Math.PI; // limit to 90 deg.
(The error is that the longest distance is used, even if the shorter distance point has a larger angle - I will give this for now, as this is meant as an example. It can be fixed by changing the iteration.).
The relationships I found work well, this is the difference in angles between the floor and the point two times:
var da1 = Math.abs(a1);
Now we can simply calculate the control points and use the voltage value to fine-tune the result:
var t = 0.8, // tension cp1x = notes[0] + dist1 * t * Math.cos(a1), cp1y = notes[1] + dist1 * t * Math.sin(a1), cp2x = notes[len-2] + dist2 * t * Math.cos(a2), cp2y = notes[len-1] + dist2 * t * Math.sin(a2);
And voila:
ctx.moveTo(notes[0], notes[1]); ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, notes[len-2], notes[len-1]); ctx.stroke();
Add narrowing effect
To create a curve that is more visually pleasing to narrow, you can add simply as follows:
Instead of stroking the path after adding the first Bezier curve, adjust the control points with a slight angle offset. Then continue the path by adding another Bezier curve going from right to left, and finally fill it ( fill() closes the path implicitly):
// first path from left to right ctx.beginPath(); ctx.moveTo(notes[0], notes[1]); // start point ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, notes[len-2], notes[len-1]); // taper going from right to left var taper = 0.15; // angle offset cp1x = notes[0] + dist1*t*Math.cos(a1-taper); cp1y = notes[1] + dist1*t*Math.sin(a1-taper); cp2x = notes[len-2] + dist2*t*Math.cos(a2+taper); cp2y = notes[len-1] + dist2*t*Math.sin(a2+taper); // note the order of the control points ctx.bezierCurveTo(cp2x, cp2y, cp1x, cp1y, notes[0], notes[1]); ctx.fill(); // close and fill
Final result (with pseudonyms - voltage = 0.7, filling = 10)

Fiddle
Recommended improvements:
- If the distances between the two groups are large or the angles are steep, they can probably be used as a sum to reduce the tension (distance) or increase it (angle).
- The dominance / area factor can affect distances. Dominance, indicating where the highest parts are shifted (it lies more on the left or right side and accordingly affects the voltage for each side). It is possible / potentially can be enough in itself, but needs to be tested.
- The cone angle offset should also be related to the sum of the distance. In some cases, the lines intersect and do not look so good. The cone can be replaced by manually selecting the parsing of Bezier points (manual implementation) and add the distance between the source points and the points for the return path depending on the position of the array.
Hope this helps!