Use Path for MIDI velocity scaling graph


#1

Hi everyone,
I’m starting my journey on JUCE development and sometimes I wonder whether what I am doing makes sense or not and, as a beginner, I could use some advice here.

My target is to create a component that displays an editable MIDI velocity scale curve. I want user to be able to add points and change the shape of each segment by mouse interaction, so I am using Path::cubicTo and some mouse events handling.

The display and the user interactivity parts are working fine.
Now I need to mathematically invert the curve, I’d ideally need y = getYatX(myPath, x) so I can actually scale out incoming MIDI notes

I see PathFlatteningIterator might be one solution here, so I can approximate the path in a piecewise linear fashion and calculate the Y with some little error around the corners I guess.

I wonder, when doing stuff like this (I am thinking to Envelopes, ADSR, user defined waveforms etc) is this the “usual / recommended” approach?

Any better way of doing this?


#2

yeah, the Path class really does need a getYatX(Path& path, float x) method…

I think the problem with the PathFlatteningIterator is the slowdown when your path gets or complex and is broken up into lots of tiny little flat segments. you could end up with a subPath that needs interating for every single pixel, and if your window is 300px, well, that’s 300 paths you gotta iterate thru to find your y value.

Is there another way? maybe using some kind of math-based approach? You said you’re using cubic curves, so maybe you could just pass the parameters to each cubic segment to some kind of cubic bezier curve algorithm. maybe one of the formulas shown here: https://stackoverflow.com/questions/15397596/find-all-the-points-of-a-cubic-bezier-curve-in-javascript

Honestly, I would just create a lookup table that is updated whenever the mouse is released from one of the control nodes, that contains all of the y values at every x coordinate in your window. The PathFlatteningIterator class fills in the x1/y1 x2/y2 values for each line segment. I think if you tossed those values into a Line<> instance and used Point<ValueType> Line< ValueType >::getIntersection( Line< ValueType >line) const with a vertical Line(x,0,x,getHeight()) you’d have your y value for x.

Path p;
//...
PathFlatteningIterator iter(p);

while( !iter.isLastInSubPath() )
{
  iter.next();
  for( auto x = iter.x1; x < iter.x2; x++ )
  {
    auto y = Line<float>(iter.x1, iter.y1, iter.x2, iter.y2).getIntersection(Line<float>(x,0,x,getHeight())).getY();
    YforXTable.push_back(y);
  }
}

completely untested and probably overthinking it, but i’m sure you can follow the concept… hope that helps!


#3

Thanks for your detailed reply!

Yes, in my case since I am only using Bezier, I can do the math, fill-up the lookup table for all the 128 values and it’ll work. In fact this is now my implementation.
But still I am thinking the path object (or maybe whichever class will eventually do the on screen drawing) “knows” this already, so that getYatX method should be feasible I guess.

That might be handy for complicated paths where doing the math would be not convenient to say the least.

But I am sure I am missing something here, otherwise this thing would have been alredy available.
@jules is this the case?


#4

Path is totally the wrong object to use for automation curves… How could we add a .getYatX call when this is a re-entrant 2D shape!? You just need a basic 1D bezier spline class, not a 2D one!


#5

Hi Jules, thanks for replying,
I am using Path as I need to draw cubic bezier curves and I do not see any Graphics::drawCubic()
So basically I have an open-ended path which I’m just stroking to achieve this.

But I do get what you say though, path are 2D objects that getXatY I was talking about is nonsense.
So what about
Array<Point> Path::getIntersection(Line line) or something similar?

I know I can write my own 1D bezier spline class, draw it as needed and reverse the cubic polynomial with some numerical method like Newton-Rhapson or whatever,

I just want to learn what is already available in the library to support this kind of needs (which BTW I believe should not be so uncommon)


#6

Well, yes, the Path class has line intersection methods and you could bodge something using them, but it’s really not what they’re designed for.

I guess a simple spline class is something we should probably add to the DSP module at some point.


#7

If you’ve not already done so, I recommend reading https://pomax.github.io/bezierinfo/. Contains a lot of useful info about using Bézier curves including some very good animated illustrations.

While Béziers are parametric functions you can instead of searching for y-values for some x-values, “walk the curve”. By letting a parameter t vary from start to end of the curve (often normalized to 0…1) you get both x and y for each t input. This is advantageous e.g if you have a very steep curve because the change in x-values will be small for each increment, i.e if used for a velocity or gain curve you get better resolution the steeper the curve is, resulting in less zipper effect.


#8

I once did this with lookup table for envelope curves, and would not do it again. I wouldn’t even recommend bezier curves in the first place. Too many control points for a user to get confused, the big redeeming factor is that you can get very sharp curves.

What’s much easier to do is to use an exponential function. I forget what equation works best here, but if you start with the form

y = (xe^kx)/e^k

Where the user control is k, you can get some decent range of curves. That equation is normalized for the interval 0-1, but you have to be careful because a sufficiently high k call will make a curve that peaks in the middle. Which is cool if you want it, but not cool for making an intuitive interface.

Scaling vertically and horizontally are just your basic algebraic transforms. For example:

 hor = (x1-x0); 
 ver = (y1-y0);

 y = ver * (x/hor) * exp (k * x/hor) / exp (k); 

This gives you a nice curve between points (x0,y0) and (x1, y1) whose curvature depends on the value of k, and can range from concave to linear to convex. It’s not perfect and you can manipulate it more to get an easier to control curve. The control point then can be fixed to exactly halfway between the end points of a given segment.


#9

Yes true, cubic bezier might be too complicated in some cases, but if you constrain the two control points and bind them to some simple mouse gesture you can get a lot of flexibility.
Of course if one needs only the exp/log shape then Bezier is overkill, I agree.
For example when the two ctrl points are forced to have same value and move only along the perpendicular line you baiscally get that exp/log shape. this nice JS tool can show this http://cubic-bezier.com/#.17,.67,.83,.67

Yes, thanks for the link, lots of useful info there! I am in fact using the Cardano’s algorithm to get the real root, so everytime the user interacts with the curve, I fillup the lookup table with the 128 values I need and that’s it.
Since is a scaling function I guess this implementation is fine, but if it was something “automatable” or tempo-synced (like envelope, lfo-shapes etc) then I’m not sure this approach would have been “light” enough on the CPU…
But I see many synths out there that have such features so there must be a way, maybe better than this.
There are some where the user can like free-hand draw the envelope and the plugin will interpolate with something that looks like Beziers to me.

Ah, and before someone says it, I am fully aware that cubic bezier for a simple MIDI velocity scale graph is totally overkill, maybe simple lines will do.
I’m just taking this as a chance to learn so when I’ll have to implement those Absynth-like envelopes, I know at least some background!


#10

Just an FYI that if you do use something complex like a bezier curve but need it to be fast, you can just wrap it in one of these:
https://www.juce.com/doc/group__juce__dsp-maths#classLookupTableTransform


#11

Cool, I was not aware of this helper class.BTW when the user modifies the envelope (either by mouse or DAW automation) the function to be wrapped will also change (or at least its parameters) so a new lookup table should be generated either by custom code or through this helper class. So I guess this class will make the code look better but won’t really bring any performance gain at least in this particular case (unless I’m missing something here)


#12

Pardon my newbish question, but what was re-entrancy (https://en.wikipedia.org/wiki/Reentrancy_(computing)) have to do with the Path class?


#13

I think reentrant = concave in that context: https://en.m.wikipedia.org/wiki/Concave_polygon
Basically for a closed path for a given X coordinate you might get multiple Y coordinates (technically “infinite” if you catched exactly one vertical edge)