Stability of MIDI system?

How stable should the MIDI data stream be if you send it from AudioAppComponent::getNextAudioBlock()
and do it with
MidiOutput::sendBlockOfMessages (const MidiBuffer& buffer, double millisecondCounterToStartAt, double samplesPerSecondForBuffer) ?

I’m observing fluctuations in MIDI signal timing with that combination. I notice it now and then when the application (Mac application) is in focus a longer time on screen. I also notice these much more easily if I move to another application, for example web browser, and use it. Then the MIDI timing can have a lot of glitches.

So my question is:
Does JUCE ensure this should not happen or is it more likely that I have some flaw in my own application?
What could cause this kind of behaviour if the issue is with my code?
How to test if I somehow accidentally block the realtime audio/MIDI thread(s) with GUI code or something along those lines?

As a comparison test I made Ableton Live output the same kind of MIDI data as my application does. When Ableton Live is outputting the MIDI data, I can browse the internet without glitches. So can I safely assume that it is my application code then which is causing these issues?

Attached to this message is a tiny test application JUCE project to demonstrate this issue. Compile and run the application. Select the MIDI input and output. Hit the note you want to play repeatedly. Now you should hear the note playing fast repeatedly. Now either listen a long time or start using other applications. For example browsing the internet seems to bring the issue on surface quite fast.

The audible MIDI jitter is not there if I use for example Ableton Live to create similar test.

Could someone take a look at the test code, the getNextAudioBlock() method, to see if they can spot the issue?

MIDI_Jitter_Test.zip (18.8 KB)

My best guesses for the causes of this issue are:

  1. MIDI output thread gets stalled somehow by other threads in JUCE.
  2. getNextAudioBlock() gets called in irregular intervals in some situations, thus the time calculation doesn’t work as it should.
  3. Somekind of mutex lock prevents the MIDI thread from reading and sending the data in time.

Here’s the getNextAudiOBlock() which does all the important things that are related to this issue:

void getNextAudioBlock(const AudioSourceChannelInfo& bufferToFill) override
{
    const double currentTime = Time::getMillisecondCounter();

    // Clear audio data
    bufferToFill.clearActiveBufferRegion();

    //
    // Get MIDI input data to find out which note we want to play repeatedly
    //
    midiInputBuffer.clear();
    midiInputCollector.removeNextBlockOfMessages(midiInputBuffer, expectedSamplesPerAudioBlock);

    // Create MIDI output data
    midiOutBuffer.clear();

    for (auto x : midiInputBuffer)
    {
        auto message = x.getMessage();

        // Forward only note on/off messages
        if (!message.isNoteOnOrOff())
            continue;

        midiNoteNumber = message.getNoteNumber();
    }

    //
    // Play the note repeatedly with fixed intervals
    //
    audioSamplesPerNote = 60*sampleRate / (8*bpm);     // 32th notes
    
    if (audioSamplesToNextNote < expectedSamplesPerAudioBlock)
    {
        auto noteOffMessage = juce::MidiMessage::noteOff(channel, currentMIDINoteNumber);
        auto noteOnMessage  = juce::MidiMessage::noteOn(channel, currentMIDINoteNumber, 1.0f);
        
        // If there is an old note playing: turn it off
        if (currentMIDINoteNumber != 0)
            midiOutBuffer.addEvent(noteOffMessage, audioSamplesToNextNote);

        // If we have actually selected a note to be triggered, then play it
        if (midiNoteNumber != 0)
        {
            midiOutBuffer.addEvent(noteOnMessage, audioSamplesToNextNote);
            currentMIDINoteNumber = midiNoteNumber;
        }
    }
    
    audioSamplesToNextNote -= expectedSamplesPerAudioBlock;
    if (audioSamplesToNextNote < 0)
        audioSamplesToNextNote += audioSamplesPerNote;
    
    if (midiOutputDevice)
        midiOutputDevice->sendBlockOfMessages(midiOutBuffer, currentTime, sampleRate);
}

I made a test to see if the getNextAudioBlock() itself has jitter in the frequency it is called with. My test gives plus/minus 2 ms leeway for the jitter. The jitter is non existent during internet browsing according to the below test:

        // ADDED THESE INTO THE CLASS ITSELF
    double  oldTime         = 0.0;
    double  oldDeltaTime    = 0.0;
    int     jitterCounter   = 0;    // THIS VALUE GETS LARGER EVERY TIME JITTER IS DETECTED

        // ADDED THESE AS THE FIRST THING IN getNextAudioBlock()

        const double currentTime = Time::getMillisecondCounter();
        const double deltaTime   = currentTime - oldTime;

        if (abs(deltaTime - oldDeltaTime) > 2)
            jitterCounter++;

        oldTime      = currentTime;
        oldDeltaTime = deltaTime;

So the issue is definitely not how and when the getNextAudioBlock() is called since the “jitterCounter” doesn’t get larger even when jitter is audible. The issue is somewhere else.

All this points me to suspect that the real issue is how JUCE handles MIDI data sending in one of its threads. Is this something I can fix by doing things differently in my code, or does the JUCE team need to fix something from JUCE itself?

Could the issue be that MIDI output (or timer) thread priority in JUCE is simply too low or is blocked by some mutex?

Also if the issue isn’t easily fixable as such, should I just create my own timer/thread which is called for example every 1 ms and uses HighResolutionTimer class for the timer duties and sendBlockOfMessagesNow() for sending the MIDI data?
If that works, can there be a potential mutex lock stall problem with the MidiBuffer it takes as an input?

My first test with using HighResolutionTimer to call sendBlockOfMessagesNow() seems to give stable timing results. I’ll use that and write some non-blocking FIFO system between AudioAppComponent::getNextAudioBlock() and the HighResolutionTimer::hiResTimerCallback().

I would like to see JUCE implement sendBlockOfMessages() so that there are no timing issues. Then that method could also be used for “DAW grade” applications.