Adding / removing MidiNotes from a playing MidiSequence in a MidiClip without restarting playback

I removed my external controller handling code from the picture and now it’s almost working well, however, it is still sending ‘all notes off’ when a note is added which adds a very slight hiccup (note cut off sometimes). My triggering code is in a juce::Timer callback: (x is quarter not position, y is pitch offset from some base pitch)

    void MainComponent::timerCallback()
    {
        static int x = 0, y = 0;
        sequencer->track->toggleNote(x, y);
        ++x;
        ++y;
    }

Now it doesn’t wait a whole bar to restart, but there is this slight delay every time the sequence is modified (5 seconds, that’s how often the callback gets called in this example). I’m wondering why does it do this? I’m also wondering if the juce::Timer that my MIDI controller handling code runs might interfere and be creating the original problem where a whole bar is skipped before the loop is played. The timer callbacks are my suspicion as to what is causing the big delay. I’m basically putting the notes into a MidiBuffer and then transforming them into an std:vector using two 1ms timers. Maybe this is not the best way to do things.

Add message to a MidiBuffer

    void MidiController::handleIncomingMidiMessage(juce::MidiInput* source, const juce::MidiMessage& message)
    {
        auto sampleNumber = (int)(message.getTimeStamp() * deviceManager.getSampleRate());
        inputBuffer->addEvent(message, sampleNumber);
    }

Process them using a timer:

    void MidiController::timerCallback()
    {
        for (const auto message: *inputBuffer)
        {
            processMessage(message.getMessage());
        }

        inputBuffer->clear();
    }

processMessage() then pushes the new messages (no longer MIDI) into an std::vector called controllerMessages, and they are processed further in another juce::Timer (maybe there’s a better way to make such callbacks that are located in various classes?):

    void MultiAkaiFire::timerCallback()
    {
        int fireIndex = 0;
        for (auto& fire: fires)
        {
            while (!fire->controllerMessages.empty())
            {
                auto message = fire->controllerMessages.front();
                fire->controllerMessages.pop();
                processControllerMessage(message, fireIndex);
            }

            fireIndex++;
        }
    }

Even if the controller code is fixed to not cause the delay in the sequencer though, there’s still the problem of the momentary ‘all notes off’ (I think this is causing the issue) when the MIDI sequence is modified that happens when just adding notes using a simple timer callback. Can this be turned off somehow? It happens even when the note is added to the end of the clip, beyond the loop point.

  • Do you know where the all-note-off message is being added?
  • Edit::restartPlayback doesn’t change the transport/playhead at all, it just rebuilds the audio graph and sets it to the “currently playing graph” as soon as possible so I doubt this is the problem.
  • What is inputBuffer? What thread does handleIncomingMidiMessage get called on? Is it thread-safe to access inputBuffer between the two methods?
  • I don’t really understand the logic between your two timers and containers of messages. If they don’t run quickly enough it could be that you’re just not dispatching incoming messages to modify the MidiList quickly enough. I’d put some logging in to see the timestamps of your various handleIncomingMidiMessage/timerCallback/timerCallback/processControllerMessage methods to see how they’re separated in time
  • Double check your timers are running at the frequency you expect. Remember they are started either in a period in ms or a frequency in Hz (two different startTimer/startTimerHz calls).
  • The all note off message might be coming from calling the addNote() method on the sequence of the MidiClip that’s playing because when I remove that call there is no pause whatsoever with everything else still running, timers, midi input, etc. I changed the code to add/remove notes from a different sequence and there is no all notes off message and no pausing. It’s a pause followed by resuming at the top of the next measure. Therefore I don’t think this is due to threading or timing issues because it always starts precisely at the top of the next measure, something that seems unlikely to be due to threading/timer issues. Also, the metronome has no issues keeping time while the midi is stopped. That’s why I keep wondering if there is another way to modify the midi clip while it’s playing without it restarting from the top at the next measure.
  • As far as I can tell, all my processing is on the message thread. I’m using juce::MessageManager::callAsync() in MidiController::handleIncomingMidiMessage() and timerCallback() to call back my processing functions on the message thread
  • I got rid of inputBuffer altogether but it didn’t change anything.
  • I got rid of the two timers also but that didn’t change anything (added one back in);
  • I set the timers on 1ms originally. Tried other values like 10ms. It doesn’t make any difference.

The only thing that makes a difference is not calling addNote()/removeNote() on the playing clip. I can call all the rest of my code and it doesn’t make the sequence pause. I can call those functions on a seperate MidiList without issues also. But I simply cannot modify the playing sequence while it’s playing without it stopping and restarting at the top of the next measure. I have also tried making the changes in a seperate sequence and replacing the current sequence with the new sequence. This fails in the same way. I’ve even tried removing the clip altogether and rendering the sequence onto a new clip and there’s still the same issue.

I also tried adding regular UI buttons to trigger adding of notes / start / stop and the same issue is happening. All my code is running on the message thread, btw. Perhaps there’s a different way to do what I’m trying to do or something else I’m missing?

Do you have some small example code? Like a modification to the Step Sequencer example that uses a MIDI clip rather than a step clip? I think I’m going to have to debug this to see what’s going on.

@dave96 I’ve stripped out everything but the 3 UI buttons (add note, play, stop) and uploaded the project here: minimal-example.zip - Google Drive

It’s even more minimal than the included examples. The add note button will add a note starting at timestamp 0, every 16th note (if I calculated correctly). It should be like a drum roll that loops endlessly except you’ll see if you add a note while others are playing they stop playing till the next measure/loop point (though the metronome doesn’t stop).

I hate to say it but there’s a fair bit wrong with that example, it’s a bit difficult for me to figure out if there is any actual issue with the engine or not…
For a start it won’t close correctly as you’re taking std::shared_ptrs to Tracks that are managed by the Edit.

But the main problem is there’s no audible output. I presume it’s trying to send to a MIDI output device but it’s doing it by name (“Deluge OUT”). I don’t have that device and without some UI to allow me to set a MIDI output device I can’t really debug it.

Perhaps you should start with a soft-synth like the internal Sampler or 4OSC so you can rule out external MIDI devices as a potential problem?

You can use any midi output, just change the name to something available on your system. On a mac you can use the IAC midi driver though I’m not sure what would be the equivalent virtual MIDI on win/linux:

                track->getOutput().setOutputByName("IAC Driver Bus 1");

Anyway, I’ve tried it with multiple synths, both software and hardware as well as the midi monitor so it’s not the specific midi instrument that’s having the issue.

I took your suggestion and rewrote the createMidiOut() function to use a 4xosc instead of MIDI and there is no issue with the audio! This is the new function:

        void createMidiOut()
        {
            track = getOrInsertAudioTrackAt(*edit, 0);
            if (track)
            {
                if (auto synth = dynamic_cast<te::FourOscPlugin*> (edit->getPluginCache().createNewPlugin (te::FourOscPlugin::xmlTypeName, {}).get()))
                {
                    static juce::String organPatch = R"(<PLUGIN type="4osc" windowLocked="1" id="1069" enabled="1" filterType="1" presetDirty="0" presetName="4OSC: Organ" filterFreq="127.00000000000000000000" ampAttack="0.60000002384185791016" ampDecay="10.00000000000000000000" ampSustain="100.00000000000000000000" ampRelease="0.40000000596046447754" waveShape1="4" tune2="-24.00000000000000000000" waveShape2="4"> <MACROPARAMETERS id="1069"/> <MODIFIERASSIGNMENTS/> <MODMATRIX/> </PLUGIN>)";

                    if (auto e = parseXML (organPatch))
                    {
                        auto vt = juce::ValueTree::fromXml (*e);

                        if (vt.isValid())
                            synth->restorePluginStateFromValueTree (vt);
                    }

                    track->pluginList.insertPlugin (*synth, 0, nullptr);
                }

                const te::EditTimeRange editTimeRange(0, edit->tempoSequence.barsBeatsToTime({ 1, 0.0 }));
                if (auto clip = getClip())
                {
                    track->getClips()[0]->removeFromParentTrack();
                }

                track->insertNewClip(te::TrackItem::Type::midi, "MIDI Clip", editTimeRange, nullptr);

                midiClip = getClip();
            }
        }

So it happens only in MIDI, not in audio. I tried to add the midi output at the same time as the audio, but I don’t know if it’s supposed to work like that and could not get both going at the same time to compare. Any ideas on why the MIDI output might work this way but the audio not?

Also, I definitely appreciate your insight on any other issues (I’m sure there are many) you may see like the shared pointer issue as I’m rather new to audio programming and modern C++ so everything helps.

So I tried adding one 4xOSC track and one MIDI only track and can confirm that when modifying the sequence by adding a note, only the MIDI track pauses. The 4xOSC continues playing along w/ the metronome. Here’s the updated class that demos this:

#pragma once

#include <utility>

#include "Views/Clip/MainView.h"
#include "Views/BaseView.h"

namespace phenotype
{
    namespace te = tracktion_engine;

    struct Note
    {
        int pitch;
        double beats;
        float length;
        int velocity;
    };

    static bool operator<(const Note& t1, const Note& t2)
    {
        return (t1.beats < t2.beats);
    }

    class Track
    {
    public:
        explicit Track(std::shared_ptr<te::Edit> edit)
                :edit(std::move(edit))
        {
            createAudioOut();
            createMidiOut();
        }

        ~Track() = default;

        static te::AudioTrack* getOrInsertAudioTrackAt(te::Edit& edit, int index)
        {
            edit.ensureNumberOfAudioTracks(index + 1);
            return te::getAudioTracks(edit)[index];
        }

        te::MidiClip::Ptr getClip()
        {
            if (auto clip = dynamic_cast<te::MidiClip*> (track->getClips()[0]))
            {
                return *clip;
            }

            return {};
        }

        te::MidiClip::Ptr getClip2()
        {
            if (auto clip = dynamic_cast<te::MidiClip*> (track2->getClips()[0]))
            {
                return *clip;
            }

            return {};
        }

        void createMidiOut()
        {
            track2 = getOrInsertAudioTrackAt(*edit, 1);
            if (track2)
            {
                // Set MIDI out device here:
                track2->getOutput().setOutputByName("Deluge OUT");
//                track->getOutput().setOutputByName("IAC Driver Bus 1");

                const te::EditTimeRange editTimeRange(0, edit->tempoSequence.barsBeatsToTime({ 1, 0.0 }));
                if (auto clip = getClip2())
                {
                    track2->getClips()[0]->removeFromParentTrack();
                }

                track2->insertNewClip(te::TrackItem::Type::midi, "MIDI Clip 2", editTimeRange, nullptr);

                midiClip2 = getClip2();
            }
        }

        void createAudioOut()
        {
            track = getOrInsertAudioTrackAt(*edit, 0);
            if (track)
            {
                if (auto synth = dynamic_cast<te::FourOscPlugin*> (edit->getPluginCache()
                        .createNewPlugin(te::FourOscPlugin::xmlTypeName, {}).get()))
                {
                    static juce::String organPatch = R"(<PLUGIN type="4osc" windowLocked="1" id="1069" enabled="1" filterType="1" presetDirty="0" presetName="4OSC: Organ" filterFreq="127.00000000000000000000" ampAttack="0.60000002384185791016" ampDecay="10.00000000000000000000" ampSustain="100.00000000000000000000" ampRelease="0.40000000596046447754" waveShape1="4" tune2="-24.00000000000000000000" waveShape2="4"> <MACROPARAMETERS id="1069"/> <MODIFIERASSIGNMENTS/> <MODMATRIX/> </PLUGIN>)";

                    if (auto e = parseXML(organPatch))
                    {
                        auto vt = juce::ValueTree::fromXml(*e);

                        if (vt.isValid())
                            synth->restorePluginStateFromValueTree(vt);
                    }

                    track->pluginList.insertPlugin(*synth, 0, nullptr);
                }

                const te::EditTimeRange editTimeRange(0, edit->tempoSequence.barsBeatsToTime({ 1, 0.0 }));
                if (auto clip = getClip())
                {
                    track->getClips()[0]->removeFromParentTrack();
                }

                track->insertNewClip(te::TrackItem::Type::midi, "MIDI Clip", editTimeRange, nullptr);

                midiClip = getClip();
            }
        }

        void toggleNote(int col, int row)
        {
            double beats = edit->tempoSequence.timeToBeats(col * .125);
            Note note = {
                    75 + row,
                    beats,
                    .125,
                    127
            };

            auto& seq = midiClip->getSequence();
            auto& seq2 = midiClip2->getSequence();

            if (notes.find(note) == notes.end())
            {
                // could just modify the sequence here and get rid of renderMidi()
                seq.addNote(note.pitch, note.beats, note.length, note.velocity, 0, nullptr);
                seq2.addNote(note.pitch, note.beats, note.length, note.velocity, 0, nullptr);
            }
        }

    protected:
        std::shared_ptr<te::Edit> edit;
        std::map<Note, Note> notes;
        //std::map<Note, te::MidiNote*> notes; // alt structure for direct manipulation
        te::MidiClip::Ptr midiClip;
        te::MidiClip::Ptr midiClip2;
        te::AudioTrack* track{};
        te::AudioTrack* track2{};
    };
}

What is playing your MIDI out though? It sounds like whatever synth you have plugged in there is just pausing for some reason?

Have you monitored the actual MIDI in something like “MIDI Monitor”?

I’ve tried both hardware and software synths and a midi monitor. It’s clear from the midi monitor that the all notes off / reset all controllers fires and then there are no more messages. If I keep adding notes to the sequence, the midi does not resume until after I stop pressing the button and the sequence is no longer being modified.

Ok, well that narrows it down a bit, it sounds like it’s somewhere in the MIDI output then.

Can you log the messages that get sent in MidiOutputDevice::sendMessageNow, does that send all the note on/offs or does that pause between bars too?

If that’s not sending the correct messages, are they getting passed to MidiOutputDeviceInstanceInjectingNode::process correctly (as the sourceBuffers.midi)?

MidiOutputDeviceInstanceInjectingNode::process has all the correct messages even during the pause.
MidiOutputDevice::sendMessageNow doesn’t have any messages during the pause.

When adding new notes to the sequence with the button, somewhere in between there, they seem to be getting lost.

They also don’t seem to make it to MidiNoteDispatcher::hiResTimerCallback

Ok, does adding this line
midiDispatcher.masterTimeUpdate (editTime); in EditPlaybackContext::fillNextNodeBlock:
here:

    const double editTime = tracktion_graph::sampleToTime (nodePlaybackContext->playHead.getPosition(), nodePlaybackContext->getSampleRate());
    edit.updateModifierTimers (editTime, numSamples);
    midiDispatcher.masterTimeUpdate (editTime);    //<<< Add this line

Fix your problem?
If so, I’ll push that to develop

Yes, this fixes the pause and almost the entire problem! Can you explain what the bug is a little bit so I can try to understand? This is really amazing and exciting!

The only minor issue that remains after adding this line is that the “all notes off”/“reset all controllers” still happens when modifying the sequence, so a part of a playing note might be cut off while it’s playing, especially if adding many notes quickly in succession. I wonder if that has a cause that can be fixed also.

It was an issue that the current time wasn’t getting updated so messages were being queued up forever.

I’ll have to have another look at why the all-notes-off message is being sent. I have a feeling its because the playhead is registering as having jumped when it doesn’t.

I see. That makes sense. Any particular functions, classes, or areas of the code I could look into for the playhead issue to help debug it? Would love to have a fix for that so I can work more on other parts of the app.

I presume they’re getting added by MidiOutputDevice::sendNoteOffMessages()?

If so, what is the callstack if you put a breakpoint in there? If it’s in MidiNoteDispatcher::hiResTimerCallback, in the if (buffer.isAllNotesOff), we’ll have to see where it’s getting set to true. I thought the only place that happened was in PluginNode though.
Does it still happen if you remove all the plugins? (including vol/pan and level meter?)

It seems to be coming from the Edit::timerCallback() function. The MIDI device is stopped and restarted, I think from looking through the calls. If I comment out the stop() call on line 647 of MidiOutputDeviceInstance::prepareToPlay, then it no longer happens, but I’m not sure of the repercussions of doing that overall as it seems to sometimes lead to hung notes and such though the playing notes aren’t getting cut off anymore. Not sure if that is a proper fix or what is. But I have tried replacing that stop() call with an identical function that calls a similar function to sendNoteOffMessages() that does everything sendNoteOffMessages does except send the all notes off/all controllers off but that didn’t help much. Finally, I replaced it with a function that simply does the below and it seems ok but with some sticky notes:

    if (playing)
    {
        playing = false;
    }

So probably there is a better solution for this. Here is the sendNoteOffMessages stacktrace:

tracktion_engine::MidiOutputDevice::sendNoteOffMessages() tracktion_MidiOutputDevice.cpp:502
tracktion_engine::MidiOutputDeviceInstance::stop() tracktion_MidiOutputDevice.cpp:676
tracktion_engine::MidiOutputDeviceInstance::prepareToPlay(double, bool) tracktion_MidiOutputDevice.cpp:647
tracktion_engine::EditPlaybackContext::prepareOutputDevices(double) tracktion_EditPlaybackContext.cpp:584
tracktion_engine::EditPlaybackContext::startPlaying(double) tracktion_EditPlaybackContext.cpp:518
tracktion_engine::EditPlaybackContext::createPlayAudioNodes(double) tracktion_EditPlaybackContext.cpp:502
tracktion_engine::TransportControl::ensureContextAllocated(bool) tracktion_TransportControl.cpp:731
tracktion_engine::TransportControl::editHasChanged() tracktion_TransportControl.cpp:684
tracktion_engine::Edit::timerCallback() tracktion_Edit.cpp:1604
juce::Timer::TimerThread::callTimers() juce_Timer.cpp:114
juce::Timer::TimerThread::CallTimersMessage::messageCallback() juce_Timer.cpp:180
juce::MessageQueue::deliverNextMessage() juce_osx_MessageQueue.h:81
juce::MessageQueue::runLoopCallback() juce_osx_MessageQueue.h:92
juce::MessageQueue::runLoopSourceCallback(void*) juce_osx_MessageQueue.h:100
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 0x00007fff2064b37c
__CFRunLoopDoSource0 0x00007fff2064b2e4
__CFRunLoopDoSources0 0x00007fff2064b064
__CFRunLoopRun 0x00007fff20649a8c
CFRunLoopRunSpecific 0x00007fff2064904c
RunCurrentEventLoopInMode 0x00007fff28891a83
ReceiveNextEventCommon 0x00007fff288917e5
_BlockUntilNextEventMatchingListInModeWithFilter 0x00007fff28891583
_DPSNextEvent 0x00007fff22e51d72
-[NSApplication(NSEvent) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] 0x00007fff22e50545
-[NSApplication run] 0x00007fff22e42869
juce::MessageManager::runDispatchLoop() juce_mac_MessageManager.mm:359
juce::JUCEApplicationBase::main() juce_ApplicationBase.cpp:262
juce::JUCEApplicationBase::main(int, char const**) juce_ApplicationBase.cpp:240
main Main.cpp:109
start 0x00007fff2056ef3d

It seems that modifying the MIDI clip is what triggers the stop/restart and the all notes off/reset all controllers. It seems that Edit::restartPlayback() is being called which sets shouldRestartPlayback = true. The callstack for Edit::restartPlayback() seems to lead back to MidiList::copyFrom:

tracktion_engine::Edit::restartPlayback() tracktion_Edit.cpp:1055
tracktion_engine::Edit::TreeWatcher::restart() tracktion_Edit.cpp:353
tracktion_engine::Edit::TreeWatcher::childAddedOrRemoved(juce::ValueTree&, juce::ValueTree&) tracktion_Edit.cpp:305
tracktion_engine::Edit::TreeWatcher::valueTreeChildRemoved(juce::ValueTree&, juce::ValueTree&, int) tracktion_Edit.cpp:278
juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)::operator()(juce::ValueTree::Listener&) const juce_ValueTree.cpp:112
void juce::ListenerList<juce::ValueTree::Listener, juce::Array<juce::ValueTree::Listener*, juce::DummyCriticalSection, 0> >::callExcluding<juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)&>(juce::ValueTree::Listener*, juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)&) juce_ListenerList.h:140
void juce::ValueTree::SharedObject::callListeners<juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)>(juce::ValueTree::Listener*, juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)) const juce_ValueTree.cpp:85
void juce::ValueTree::SharedObject::callListenersForAllParents<juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)>(juce::ValueTree::Listener*, juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int)::'lambda'(juce::ValueTree::Listener&)) const juce_ValueTree.cpp:94
juce::ValueTree::SharedObject::sendChildRemovedMessage(juce::ValueTree, int) juce_ValueTree.cpp:112
juce::ValueTree::SharedObject::removeChild(int, juce::UndoManager*) juce_ValueTree.cpp:296
juce::ValueTree::SharedObject::removeAllChildren(juce::UndoManager*) juce_ValueTree.cpp:309
juce::ValueTree::removeAllChildren(juce::UndoManager*) juce_ValueTree.cpp:938
tracktion_engine::MidiList::clear(juce::UndoManager*) tracktion_MidiList.cpp:1090
tracktion_engine::MidiList::copyFrom(tracktion_engine::MidiList const&, juce::UndoManager*) tracktion_MidiList.cpp:1098
phenotype::Clip::renderMidi() Clip.h:110
phenotype::MainView::onPadReleased(int) MainView.h:64
phenotype::MultiAkaiFire::onPadReleased(int)::$_6::operator()(phenotype::MidiControllerListener&) const MultiAkaiFire.cpp:111
void juce::ListenerList<phenotype::MidiControllerListener, juce::Array<phenotype::MidiControllerListener*, juce::DummyCriticalSection, 0> >::call<phenotype::MultiAkaiFire::onPadReleased(int)::$_6>(phenotype::MultiAkaiFire::onPadReleased(int)::$_6&&) juce_ListenerList.h:124
phenotype::MultiAkaiFire::onPadReleased(int) MultiAkaiFire.cpp:109
phenotype::MultiAkaiFire::processControllerMessage(std::__1::shared_ptr<phenotype::ControllerMessage>&, int) MultiAkaiFire.cpp:155
phenotype::MultiAkaiFire::processMessages() MultiAkaiFire.cpp:190
phenotype::MultiAkaiFire::timerCallback()::$_7::operator()() const MultiAkaiFire.cpp:201
decltype(std::__1::forward<phenotype::MultiAkaiFire::timerCallback()::$_7&>(fp)()) std::__1::__invoke<phenotype::MultiAkaiFire::timerCallback()::$_7&>(phenotype::MultiAkaiFire::timerCallback()::$_7&) type_traits:3747
void std::__1::__invoke_void_return_wrapper<void>::__call<phenotype::MultiAkaiFire::timerCallback()::$_7&>(phenotype::MultiAkaiFire::timerCallback()::$_7&) __functional_base:348
std::__1::__function::__alloc_func<phenotype::MultiAkaiFire::timerCallback()::$_7, std::__1::allocator<phenotype::MultiAkaiFire::timerCallback()::$_7>, void ()>::operator()() functional:1553
std::__1::__function::__func<phenotype::MultiAkaiFire::timerCallback()::$_7, std::__1::allocator<phenotype::MultiAkaiFire::timerCallback()::$_7>, void ()>::operator()() functional:1727
std::__1::__function::__value_func<void ()>::operator()() const functional:1880
std::__1::function<void ()>::operator()() const functional:2555
juce::MessageManager::callAsync(std::__1::function<void ()>)::AsyncCallInvoker::messageCallback() juce_MessageManager.cpp:195
juce::MessageQueue::deliverNextMessage() juce_osx_MessageQueue.h:81
juce::MessageQueue::runLoopCallback() juce_osx_MessageQueue.h:92
juce::MessageQueue::runLoopSourceCallback(void*) juce_osx_MessageQueue.h:100
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 0x00007fff2064b37c
__CFRunLoopDoSource0 0x00007fff2064b2e4
__CFRunLoopDoSources0 0x00007fff2064b064
__CFRunLoopRun 0x00007fff20649a8c
CFRunLoopRunSpecific 0x00007fff2064904c
RunCurrentEventLoopInMode 0x00007fff28891a83
ReceiveNextEventCommon 0x00007fff288917e5
_BlockUntilNextEventMatchingListInModeWithFilter 0x00007fff28891583
_DPSNextEvent 0x00007fff22e51d72
-[NSApplication(NSEvent) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] 0x00007fff22e50545
-[NSApplication run] 0x00007fff22e42869
juce::MessageManager::runDispatchLoop() juce_mac_MessageManager.mm:359
juce::JUCEApplicationBase::main() juce_ApplicationBase.cpp:262
juce::JUCEApplicationBase::main(int, char const**) juce_ApplicationBase.cpp:240
main Main.cpp:109
start 0x00007fff2056ef3d

If I use MidiList::addNode() instead of copyFrom(), the issue is the same:

tracktion_engine::Edit::restartPlayback() tracktion_Edit.cpp:1055
tracktion_engine::Edit::TreeWatcher::restart() tracktion_Edit.cpp:353
tracktion_engine::Edit::TreeWatcher::childAddedOrRemoved(juce::ValueTree&, juce::ValueTree&) tracktion_Edit.cpp:305
tracktion_engine::Edit::TreeWatcher::valueTreeChildAdded(juce::ValueTree&, juce::ValueTree&) tracktion_Edit.cpp:273
juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)::operator()(juce::ValueTree::Listener&) const juce_ValueTree.cpp:106
void juce::ListenerList<juce::ValueTree::Listener, juce::Array<juce::ValueTree::Listener*, juce::DummyCriticalSection, 0> >::callExcluding<juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)&>(juce::ValueTree::Listener*, juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)&) juce_ListenerList.h:140
void juce::ValueTree::SharedObject::callListeners<juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)>(juce::ValueTree::Listener*, juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)) const juce_ValueTree.cpp:85
void juce::ValueTree::SharedObject::callListenersForAllParents<juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)>(juce::ValueTree::Listener*, juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree)::'lambda'(juce::ValueTree::Listener&)) const juce_ValueTree.cpp:94
juce::ValueTree::SharedObject::sendChildAddedMessage(juce::ValueTree) juce_ValueTree.cpp:106
juce::ValueTree::SharedObject::addChild(juce::ValueTree::SharedObject*, int, juce::UndoManager*) juce_ValueTree.cpp:268
juce::ValueTree::addChild(juce::ValueTree const&, int, juce::UndoManager*) juce_ValueTree.cpp:915
tracktion_engine::MidiList::addNote(int, double, double, int, int, juce::UndoManager*) tracktion_MidiList.cpp:1275
phenotype::Clip::renderMidi() Clip.h:106
phenotype::MainView::onPadReleased(int) MainView.h:64
phenotype::MultiAkaiFire::onPadReleased(int)::$_6::operator()(phenotype::MidiControllerListener&) const MultiAkaiFire.cpp:111
void juce::ListenerList<phenotype::MidiControllerListener, juce::Array<phenotype::MidiControllerListener*, juce::DummyCriticalSection, 0> >::call<phenotype::MultiAkaiFire::onPadReleased(int)::$_6>(phenotype::MultiAkaiFire::onPadReleased(int)::$_6&&) juce_ListenerList.h:124
phenotype::MultiAkaiFire::onPadReleased(int) MultiAkaiFire.cpp:109
phenotype::MultiAkaiFire::processControllerMessage(std::__1::shared_ptr<phenotype::ControllerMessage>&, int) MultiAkaiFire.cpp:155
phenotype::MultiAkaiFire::processMessages() MultiAkaiFire.cpp:190
phenotype::MultiAkaiFire::timerCallback()::$_7::operator()() const MultiAkaiFire.cpp:201
decltype(std::__1::forward<phenotype::MultiAkaiFire::timerCallback()::$_7&>(fp)()) std::__1::__invoke<phenotype::MultiAkaiFire::timerCallback()::$_7&>(phenotype::MultiAkaiFire::timerCallback()::$_7&) type_traits:3747
void std::__1::__invoke_void_return_wrapper<void>::__call<phenotype::MultiAkaiFire::timerCallback()::$_7&>(phenotype::MultiAkaiFire::timerCallback()::$_7&) __functional_base:348
std::__1::__function::__alloc_func<phenotype::MultiAkaiFire::timerCallback()::$_7, std::__1::allocator<phenotype::MultiAkaiFire::timerCallback()::$_7>, void ()>::operator()() functional:1553
std::__1::__function::__func<phenotype::MultiAkaiFire::timerCallback()::$_7, std::__1::allocator<phenotype::MultiAkaiFire::timerCallback()::$_7>, void ()>::operator()() functional:1727
std::__1::__function::__value_func<void ()>::operator()() const functional:1880
std::__1::function<void ()>::operator()() const functional:2555
juce::MessageManager::callAsync(std::__1::function<void ()>)::AsyncCallInvoker::messageCallback() juce_MessageManager.cpp:195
juce::MessageQueue::deliverNextMessage() juce_osx_MessageQueue.h:81
juce::MessageQueue::runLoopCallback() juce_osx_MessageQueue.h:92
juce::MessageQueue::runLoopSourceCallback(void*) juce_osx_MessageQueue.h:100
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 0x00007fff2064b37c
__CFRunLoopDoSource0 0x00007fff2064b2e4
__CFRunLoopDoSources0 0x00007fff2064b064
__CFRunLoopRun 0x00007fff20649a8c
CFRunLoopRunSpecific 0x00007fff2064904c
RunCurrentEventLoopInMode 0x00007fff28891a83
ReceiveNextEventCommon 0x00007fff288917e5
_BlockUntilNextEventMatchingListInModeWithFilter 0x00007fff28891583
_DPSNextEvent 0x00007fff22e51d72
-[NSApplication(NSEvent) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] 0x00007fff22e50545
-[NSApplication run] 0x00007fff22e42869
juce::MessageManager::runDispatchLoop() juce_mac_MessageManager.mm:359
juce::JUCEApplicationBase::main() juce_ApplicationBase.cpp:262
juce::JUCEApplicationBase::main(int, char const**) juce_ApplicationBase.cpp:240
main Main.cpp:109
start 0x00007fff2056ef3d

I also noticed that there are a lot of MIDI messages with channel 0 that fail the asserts in MidiMessage::isForChannel(), MidiMessage::noteOff / MidiMessage::noteOn, and the MidiNode::MidiNode constructor but only when I use copyFrom instead of just addNote.

I think that MidiOutputDeviceInstance::stop/start is there in case there are playing notes which have been removed. What should probably happen is that an ActiveNoteList is used to send note-offs if the get removed.

I’m fully slammed with some other things right now though so won’t have a chance to do this myself for a while. You could have a go by looking at how it’s used in ExternalPlugin if you want to try it yourself?
I think when the topology changes and restartPlayback is called, MidiNode::createMessagesForTime will be called sending note-on messages for notes that should be on in the audio callback.
If you compare those with the an ActiveNoteList that’s updated in MidiOutputDeviceInstance::mergeInMidiMessages, you should be able to tell what note-offs should be sent for notes that are no longer active.

I think that should work…