MIDI Remote API "Rounding errors"

Just an update based on an earlier conversation I had with another friend, with a real-world example:

Say we have set our fader to pitchBend (we know this one goes from -8192 to 8191). This means that the range that the API has to deal with is in reality [0,16383].

Now, say, we have moved our fader to a pitchBend value of +60.

Let’s do the math: The API will convert this one to 8192+60=8252.

Now comes the interesting part:

The API needs to convert this one to the [0,1] range and as we previously saw, this means that it will try to set the hostValue to 8252/16383 = 0,503692852346945.

In fact, it does so and spits out this value to our mOnChangeValue of the hostValue. Cool!

BUT NOW, it has to store it! This far, the API was working at a “high” level, but now it really has to go on with a “final” storage to a binary.

Converting the above value to binary, we will get (note that certain rounding processes occur here):
00111111000000001111001000000100

BUT, this bin is no longer equal to our starting value, and we all know that it’s due to rounding. This is indeed a concern with the API, its binary rounding places!

If we convert this back to our float, we now get:

0.5036928653717041015625.

Now, the API will go on and RESEND this value, because it looks to it like we really have a change again. And it will resend this. This way we indeed have TWO messages for a unique action, and INDEED this can eventually cause jitters, while we’re changing the values using our controller.

The way to go is that Steinberg rounds consistently both while mOnProcessValueChange is triggered and BEFORE the mOnValueChange of the hostValue in order to have the very same value. I don’t know the infrastructure, but possibly it has to do with different rounding algorithms of javascript and c. But again, I simply don’t know. At which accuracy should this be done? Not that important, but since the most I know is 14-digits controllers, this rounding should go at least this far.

@Jochen_Trappe, I think you should have a look at this one, because it indeed can break controller’s behaviour. Note that what I mention above is already tested with a script and I can surely provide the snippet exposing the issue.

4 Likes

Hi @m.c, fantastically analysed. That really helps! Would be great if you could provide a code snippet to reproduce the rounding errors. Thanks!

3 Likes

Sure, @Jochen_Trappe. Here’s a binding to a fader on channel 0:

var midiremote_api = require('midiremote_api_v1')

var deviceDriver = midiremote_api.makeDeviceDriver('Issues', 'Rounding', 'Issues')

var midiInput = deviceDriver.mPorts.makeMidiInput("loopMIDI Port 1")
var midiOutput = deviceDriver.mPorts.makeMidiOutput("loopMIDI Port 2")

var detect = deviceDriver.makeDetectionUnit()

detect
    .detectPortPair(midiInput, midiOutput)
    .expectInputNameEquals("loopMIDI Port 1")
    .expectOutputNameEquals("loopMIDI Port 2")
    

var surface=deviceDriver.mSurface

var fader=surface.makeFader(0,0,1,5)
fader.mSurfaceValue.mMidiBinding
    .setInputPort(midiInput)    
    .bindToPitchBend(0)

var page=deviceDriver.mMapping.makePage("page")

page.makeValueBinding(fader.mSurfaceValue,page.mHostAccess.mTrackSelection.mMixerChannel.mValue.mVolume).mOnValueChange=function(activeDevice,activeMapping,value,diff){
    
    console.log("got value="+value+" diff="+diff)
    var value16383=Math.round(16383*value)
    console.log("got value16383="+value16383) //Not important, just for viewing purposes

    //let's convert this to binary with different precisions 
    console.log(decimalToBinary(value,32))
    console.log(decimalToBinary(value,24))
    //Seems to me that a rounding to 24 finally happens and retriggers the function

}

function decimalToBinary(fraction, precision) {
    
    var result = "."
    
    while (precision--) {
    
        fraction *= 2
    
        if (fraction >= 1) {
    
            result += "1"
            fraction -= 1
    
        } else {
    
            result += "0"
    
        }
    
    }
    
    return result

}

The implementation is simple: We bind a fader and we move it. I personally tried with step 1 every time.
Anyway, below I attach some logs, let’s have a look:

We’re logging the decimal value we get and then its binary representation at 32 and 24 precision.
As we can see, initially the mOnValueChange responds with a diff somewhere near -0.00115. This is OK, it’s the response to our fader’s move.
But immediately after this one, we get a second response, not initiated by our fader (I know this because I’m using a pseudo fader for testing, sending just one value).
Now, we get a much much smaller diff, somewhere near 10^(-9). This is happening due to rounding.
As we can see from the binary representations, it seems like this second time, the mOnValue is triggered by our previous value rounded somewhere near 24-precision. In this screenshot it’s exactly 24, however I did notice cases where it was a bit more, 25-26. Not sure why.

Anyway, let’s move our fader another one point, and we now have:

Same story again: The initial value has a bigger precision, near 28-digits, while then a rounding occurs and retriggers the mOnValue with a 24-digits bin.

Normally, this shouldn’t be a real issue. However, there are cases where this second mOnValue is not triggered immediately, but there is a small delay. It’s there that problems may occur, when we’re moving our control fast.

In the snippet I post, there was no delay at all!

To the question WHAT can cause the delay, I have to GUESS that it can be complicated or just time-consuming calculations upon a customProcessVariable which triggers the event, or even an overload with all the other handlings occurring at the same time. Note that the snippet is just handling one fader and no hostValues apart from the mVolume of the selected track. BUT that’s just a speculation, I really don’t know. However, it might be of help to get rid of the second call (by rounding handling) IF of course there is no real internal reason for it to behave like this.

TOTALLY out of topic but is there any chance that in a update we can see MediaBay’s createTrack by the URL of the preset, exposed? Something like page.mHostAccess.mMediaBay.createTrack(URL) and additionaly page.mHostAccess.mMediaBay.replaceTrack(URL) ? I’ve implemented mediaBay browsing in my Novation SL MK3 script (not published this version for now), and it would be great if we could have such calls (for now I’m using an archaic keystrokes/mouse approach for correctly calling the selected plugin):

3 Likes

Oh, I should have mentioned that a workaround is really straight-forward. Here’s a snippet which corrects the issue by using a pseudo fader. The idea is simple, we get the “real” fader’s value on its mOnProcessValueChange, round it properly and set the pseudo fader’s mSurfaceValue to this rounded one. In the testing I’ve done, I see no double triggers any more:

var midiremote_api = require('midiremote_api_v1')

var deviceDriver = midiremote_api.makeDeviceDriver('Issues', 'Rounding', 'Issues')

var midiInput = deviceDriver.mPorts.makeMidiInput("loopMIDI Port 1")
var midiOutput = deviceDriver.mPorts.makeMidiOutput("loopMIDI Port 2")

var detect = deviceDriver.makeDetectionUnit()

detect
    .detectPortPair(midiInput, midiOutput)
    .expectInputNameEquals("loopMIDI Port 1")
    .expectOutputNameEquals("loopMIDI Port 2")
    

var surface=deviceDriver.mSurface

var fader=surface.makeFader(0,0,1,5)
fader.mSurfaceValue.mMidiBinding
    .setInputPort(midiInput)    
    .bindToPitchBend(0)


var faderRounded=surface.makeFader(1,0,1,5)
faderRounded.mSurfaceValue.mMidiBinding
    .setInputPort(midiInput)
    .bindToPitchBend(1)

var page=deviceDriver.mMapping.makePage("page")

page.makeValueBinding(faderRounded.mSurfaceValue,page.mHostAccess.mTrackSelection.mMixerChannel.mValue.mVolume).mOnValueChange=function(activeDevice,activeMapping,value,diff){
    
    console.log("got value="+value+" diff="+diff)
    console.log("time="+new Date().getTime())
    var value16383=Math.round(16383*value)
    console.log("got value16383="+value16383) //Not important, just for viewing purposes

    //let's convert this to binary with different precisions 
    console.log(decimalToBinary(value,32))
    console.log(decimalToBinary(value,24))
    //Seems to me that a rounding to 24 finally happens and retriggers the function

}

fader.mSurfaceValue.mOnProcessValueChange=function(activeDevice,value,diff){

    var round24Bin=decimalToBinary(value,24)

    var round24Dec=binaryToDecimal(round24Bin)

    faderRounded.mSurfaceValue.setProcessValue(activeDevice,round24Dec)

}

function decimalToBinary(fraction, precision) {
    
    var result = "."
    
    while (precision--) {
    
        fraction *= 2
    
        if (fraction >= 1) {
    
            result += "1"
            fraction -= 1
    
        } else {
    
            result += "0"
    
        }
    
    }
    
    return result

}

function binaryToDecimal(binaryFraction) {

    var fraction = binaryFraction.slice(1)
    
    var result = 0
    
    for (var i = 0; i < fraction.length; i++) {
    
        if (fraction[i] === '1') {
    
            result += Math.pow(2, -(i + 1))
    
        }
    
    }
    
    return result

}

Still, I don’t think this is the way to go, I mean to round before send. I believe this should be handled internally by the API.

3 Likes

Hi @m.c,

  1. I can reproduce the double-trigger issue using a nodejs-script and virtual ports to send single pitch bend messages.
  2. The incoming pitch bend message is translated to a ‘normalized’ 64bit float (value 0.0 to 1.0). But the Cubase parameters are all 32 bit precision float.
  3. When I down-cast the normalized pitch bend value from 64bit to 32bit float, the double-trigger is gone. Yeah!

This is what I get without the down-casting:

This is what I get with the down-casting:

Thanks a lot for finding and analysing the rounding issue, you’re amazing!

2 Likes

Yes, and pretty enough I think :slight_smile:

Excellent mate! This also explains why occasionally I was seeing digits upon the 24 limit.

Thank you for your king words!

Mind asking when this update will be available? :slight_smile:

1 Like

That I don’t know @m.c . And I have to investigate on the 7bit values (note velo, cc value, …) as well. There the fix didn’t solve the double-trigger. But I created a ticket to work on.
Have yourself a great weekend, I’m out 'til Monday :sunglasses:

1 Like

True, because in 7-bit we have much bigger fractions that bring up the issue again.
A way I could suggest, is to have a look at hard-coding triggering based on diffs.
Approximating this (I neglect smaller parts here), we have:
In 7-bit, our limit for abs(diff) can be set to 7pow(10,-3), i.e., any abs(diff) smaller than this can be safely considered a rounding correction.
In 14-bit, the limit is 6pow(10,-5).
Finally, in 32-bit, we can set it to 2pow(10,-10). Now since even MIDI 2.0 goes up to 32-bit, it can stop here for now.

There can be smarter ways to do such things obviously, however, even the above could solve the issue under the hood with not an overhead created by roundings and checking apart from a sequence of 3 ifs :slight_smile:

I understand your approach but I am afraid that if we use an “approx equal/less/…” operator we’d run in the situation of value-drift between hardware-internal- and daw-internal-values. I will rather investigate on streamlining the rounding function.

3 Likes

Sure, this is unquestionably the best approach!

Note sure if this can actually happen, since the limit described is probably way beyond the values treated by the DAW and the Controller, however, as said, sure, it’s much much better to always stick with finding the catch :slight_smile:

2 Likes

Has there been any progress on this issue?