diff --git a/data/developers.txt b/data/developers.txt index 784b98373..c7d8ee4b9 100644 --- a/data/developers.txt +++ b/data/developers.txt @@ -11,3 +11,4 @@ mmoole. (mmoole) schnitzeltony splisp Heath Dutton (heathdutton) +Alexandr Zyurkalov (Alexander-Zyurkalov) diff --git a/src/engine/midioutput.cpp b/src/engine/midioutput.cpp new file mode 100644 index 000000000..560212766 --- /dev/null +++ b/src/engine/midioutput.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "midioutput.h" +namespace element { +#if JUCE_MAC + +MIDIEndpointRef findDestination (const juce::MidiDeviceInfo& deviceInfo) +{ + SInt32 targetUID = deviceInfo.identifier.containsChar (' ') + ? deviceInfo.identifier.fromLastOccurrenceOf (" ", false, false).getIntValue() + : deviceInfo.identifier.getIntValue(); + ItemCount count = MIDIGetNumberOfDestinations(); + + for (ItemCount i = 0; i < count; ++i) + { + MIDIEndpointRef endpoint = MIDIGetDestination (i); + SInt32 uid = 0; + MIDIObjectGetIntegerProperty (endpoint, kMIDIPropertyUniqueID, &uid); + + if (uid == targetUID) + return endpoint; + } + + return 0; +} + +ElementMidiOutput::ElementMidiOutput (const juce::MidiDeviceInfo& deviceInfo) +{ + destination = findDestination (deviceInfo); + if (destination) + { + MIDIClientCreate (CFSTR ("ElementMIDIOutput"), nullptr, nullptr, &client); + MIDIOutputPortCreate (client, deviceInfo.name.toCFString(), &port); + mach_timebase_info (&timebase); + } +} + +juce::Result ElementMidiOutput::openDevice (const juce::MidiDeviceInfo& deviceInfo, std::unique_ptr& out) +{ + out = std::make_unique (deviceInfo); + if (! out->destination) + { + out.reset(); + return juce::Result::fail ("MIDI destination not found: " + deviceInfo.name); + } + return juce::Result::ok(); +} + +void ElementMidiOutput::closeDevice() +{ + if (! destination) + return; + MIDIFlushOutput (destination); + MIDIClientDispose (client); + destination = 0; +} + +MIDITimeStamp ElementMidiOutput::futureTimestamp (uint64_t nanosFromNow) const +{ + MIDITimeStamp ticks = nanosFromNow * timebase.denom / timebase.numer; + return mach_absolute_time() + ticks; +} + +juce::Result ElementMidiOutput::sendBlockOfMessages (const juce::MidiBuffer& midi, double delayMs, double sampleRate) const +{ + constexpr size_t BUFFER_SIZE_FOR_PACKET_LIST = 1024; + uint8_t buffer[BUFFER_SIZE_FOR_PACKET_LIST]; + auto* packetList = reinterpret_cast (buffer); + MIDIPacket* packet = MIDIPacketListInit (packetList); + + for (juce::MidiMessageMetadata message : midi) + { + const double nanoSeconds = message.samplePosition / sampleRate * 1'000'000'000.0 + delayMs * 1'000'000.0; + const auto ticks = futureTimestamp (static_cast (std::round (nanoSeconds))); + packet = MIDIPacketListAdd (packetList, BUFFER_SIZE_FOR_PACKET_LIST, packet, ticks, message.numBytes, message.data); + } + + OSStatus status = MIDISend (port, destination, packetList); + if (status != noErr) + return juce::Result::fail ("MIDISend failed with OSStatus " + juce::String (status)); + return juce::Result::ok(); +} + +#else + +void ElementMidiOutput::closeDevice() +{ + if (output) + { + output->stopBackgroundThread(); + output->clearAllPendingMessages(); + output.reset(); + } +} + +ElementMidiOutput::ElementMidiOutput (const juce::MidiDeviceInfo& deviceInfo) +{ + output = juce::MidiOutput::openDevice (deviceInfo.identifier); + + if (output) + { + output->clearAllPendingMessages(); + output->startBackgroundThread(); + } + else + { + DBG ("[element] could not open MIDI output: " << deviceInfo.name); + } +} + +juce::Result ElementMidiOutput::openDevice (const juce::MidiDeviceInfo& deviceInfo, std::unique_ptr& out) +{ + out = std::make_unique (deviceInfo); + if (! out->output) + { + out.reset(); + return juce::Result::fail ("Could not open MIDI output: " + deviceInfo.name); + } + return juce::Result::ok(); +} + +juce::Result ElementMidiOutput::sendBlockOfMessages (const juce::MidiBuffer& midi, double delayMs, double sampleRate) const +{ +#if JUCE_WINDOWS + output->sendBlockOfMessagesNow (midi); +#else + output->sendBlockOfMessages ( + midi, delayMs + juce::Time::getMillisecondCounterHiRes(), sampleRate); +#endif + return juce::Result::ok(); +} +#endif + +} // namespace element \ No newline at end of file diff --git a/src/engine/midioutput.h b/src/engine/midioutput.h new file mode 100644 index 000000000..e5a817570 --- /dev/null +++ b/src/engine/midioutput.h @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "element/juce.hpp" + +#if JUCE_MAC +#include +//#include +#include +#endif + +#include "midioutput.h" +#include + +namespace element { + +class ElementMidiOutput +{ +public: + ElementMidiOutput() = delete; + ElementMidiOutput (const juce::MidiDeviceInfo& deviceInfo); + + void closeDevice(); + static juce::Result openDevice (const juce::MidiDeviceInfo& deviceInfo, std::unique_ptr& out); + juce::Result sendBlockOfMessages (const juce::MidiBuffer& midi, double delayMs, double sampleRate) const; + +private: +#if JUCE_MAC + MIDIClientRef client {}; + MIDIEndpointRef destination {}; + MIDIPortRef port {}; + mach_timebase_info_data_t timebase{}; + MIDITimeStamp futureTimestamp (uint64_t nanosFromNow) const; +#else + std::unique_ptr output; +#endif +}; + +} // namespace element \ No newline at end of file diff --git a/src/nodes/mididevice.cpp b/src/nodes/mididevice.cpp index 406aa004f..3deb590d4 100644 --- a/src/nodes/mididevice.cpp +++ b/src/nodes/mididevice.cpp @@ -205,22 +205,19 @@ void MidiDeviceProcessor::setDevice (const MidiDeviceInfo& newDevice) { if (output) { - output->stopBackgroundThread(); - output->clearAllPendingMessages(); + output->closeDevice(); output.reset(); } - output = MidiOutput::openDevice (deviceWanted.identifier); + auto openResult = ElementMidiOutput::openDevice (deviceWanted, output); - if (output) + if (openResult.wasOk()) { - output->clearAllPendingMessages(); - output->startBackgroundThread(); device = deviceWanted; } else { - DBG ("[element] could not open MIDI output: " << deviceWanted.name); + DBG ("[element] " << openResult.getErrorMessage()); } } @@ -252,10 +249,10 @@ Result MidiDeviceProcessor::closeDevice() { if (output) { - output->clearAllPendingMessages(); - std::unique_ptr closer; + std::unique_ptr closer; { ScopedLock sl (getCallbackLock()); + output->closeDevice(); std::swap (output, closer); } closer.reset(); @@ -319,12 +316,7 @@ void MidiDeviceProcessor::processBlock (AudioBuffer& audio, MidiBuffer& m if (output && ! midi.isEmpty()) { const auto delayMs = midiOutLatency.get(); -#if JUCE_WINDOWS - output->sendBlockOfMessagesNow (midi); -#else - output->sendBlockOfMessages ( - midi, delayMs + Time::getMillisecondCounterHiRes(), getSampleRate()); -#endif + output->sendBlockOfMessages (midi, delayMs, getSampleRate()); } midi.clear (0, nframes); @@ -403,7 +395,7 @@ bool MidiDeviceProcessor::deviceIsAvailable (const String& name) if (info.name == name) return true; } - return true; + return false; } bool MidiDeviceProcessor::deviceIsAvailable (const MidiDeviceInfo& dev) @@ -411,7 +403,7 @@ bool MidiDeviceProcessor::deviceIsAvailable (const MidiDeviceInfo& dev) for (const auto& info : getAvailableDevices()) if (info.identifier == dev.identifier) return true; - return true; + return false; } void MidiDeviceProcessor::timerCallback() diff --git a/src/nodes/mididevice.hpp b/src/nodes/mididevice.hpp index 29b796ce9..0f02f3df8 100644 --- a/src/nodes/mididevice.hpp +++ b/src/nodes/mididevice.hpp @@ -5,6 +5,7 @@ #include #include "nodes/baseprocessor.hpp" +#include namespace element { @@ -154,7 +155,7 @@ class MidiDeviceProcessor : public BaseProcessor, MidiDeviceInfo deviceWanted; // The device as saved in Stage and chosen by users. MidiMessageCollector inputMessages; std::unique_ptr input; - std::unique_ptr output; + std::unique_ptr output; Atomic midiOutLatency { 0.0 }; void waitForDevice() {} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 87b346645..246d391f8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -6,8 +6,8 @@ file(GLOB_RECURSE TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") juce_add_console_app(test_element) target_sources(test_element PRIVATE ${TEST_SOURCES}) target_link_libraries(test_element PRIVATE kv::element) -target_include_directories(test_element - PRIVATE +target_include_directories(test_element + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" ${GENERATED_INCLUDE_DIR} ${Boost_INCLUDE_DIRS}) @@ -32,6 +32,9 @@ add_test(NAME "MidiChannelMapTest" COMMAND test_element --run_test=MidiChannelMa add_test(NAME "MidiClockTest" COMMAND test_element --run_test=MidiClockTest) add_test(NAME "MidiProgramMapTests" COMMAND test_element --run_test=MidiProgramMapTests) add_test(NAME "MidiScriptTests" COMMAND test_element --run_test=MidiScriptTests) +if (APPLE) + add_test(NAME "MidiOutputTests" COMMAND test_element --run_test=MidiOutputTests) +endif() add_test(NAME "NodeFactoryTests" COMMAND test_element --run_test=NodeFactoryTests) add_test(NAME "NodeObjectTests" COMMAND test_element --run_test=NodeObjectTests) add_test(NAME "NodeTests" COMMAND test_element --run_test=NodeTests) diff --git a/test/engine/MidiOutputTest.cpp b/test/engine/MidiOutputTest.cpp new file mode 100644 index 000000000..a2b923173 --- /dev/null +++ b/test/engine/MidiOutputTest.cpp @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +// SPDX-License-Identifier: GPL-3.0-or-later +#include "juce_audio_devices/juce_audio_devices.h" +#if JUCE_MAC + #include + #include + #include "engine/midioutput.h" + +using namespace element; + +namespace { + +struct LoopbackFixture { + MIDIClientRef client {}; + MIDIEndpointRef dest {}; + MIDIPortRef outPort {}; + juce::CriticalSection lock; + juce::Array received; + + LoopbackFixture() + { + MIDIClientCreate (CFSTR ("LoopbackTestClient"), nullptr, nullptr, &client); + + MIDIDestinationCreate (client, CFSTR ("LoopbackDest"), [] (const MIDIPacketList* list, void* userData, void*) { + auto& self = *static_cast (userData); + const MIDIPacket* packet = &list->packet[0]; + const juce::ScopedLock sl (self.lock); + for (UInt32 i = 0; i < list->numPackets; ++i) { + self.received.add (*packet); + packet = MIDIPacketNext (packet); + } }, this, &dest); + + MIDIOutputPortCreate (client, CFSTR ("LoopbackOut"), &outPort); + } + + ~LoopbackFixture() + { + MIDIEndpointDispose (dest); + MIDIPortDispose (outPort); + MIDIClientDispose (client); + } + + int waitForMessages (int count, int timeoutMs) const + { + auto start = juce::Time::getMillisecondCounter(); + for (;;) { + { + const juce::ScopedLock sl (lock); + const int size = received.size(); + if (size > count) { + return size; + } + } + if (juce::Time::getMillisecondCounter() - start > (juce::uint32) timeoutMs) + return 0; + juce::Thread::sleep (5); + } + } + + std::optional getReceivedMessage() + { + const juce::ScopedLock sl (lock); + if (received.size() > 0) + return received.removeAndReturn (0); + return std::nullopt; + } +}; + +juce::MidiDeviceInfo findMidiOutputByName (const juce::String& name) +{ + for (auto& device : juce::MidiOutput::getAvailableDevices()) { + if (device.name == name) + return device; + } + + return {}; +} + +} // namespace + +BOOST_AUTO_TEST_SUITE (MidiOutputTests) +BOOST_AUTO_TEST_CASE (Instantiate) +{ + LoopbackFixture loopback; + + juce::MidiDeviceInfo loopbackDeviceInfo = findMidiOutputByName ("LoopbackDest"); + BOOST_CHECK_NE (loopbackDeviceInfo.identifier, ""); + + std::unique_ptr midiOutput; + auto openResult = ElementMidiOutput::openDevice (loopbackDeviceInfo, midiOutput); + BOOST_REQUIRE (openResult.wasOk()); + BOOST_REQUIRE (midiOutput != nullptr); + + const juce::MidiMessage& message1 = juce::MidiMessage::noteOn (1, 50, (juce::uint8) 100); + const juce::MidiMessage& message2 = juce::MidiMessage::noteOff (1, 50, (juce::uint8) 100); + juce::MidiBuffer buffer {}; + buffer.addEvent (message1, 0); + + constexpr double timeDelayMs = 5.0; + constexpr double sampleRate = 44100.0; + constexpr int sampleNumber = static_cast (timeDelayMs * sampleRate / 1000.0); + buffer.addEvent (message2, sampleNumber); + auto result = midiOutput->sendBlockOfMessages (buffer, 0, sampleRate); + + BOOST_REQUIRE (result.wasOk()); + + loopback.waitForMessages (2, 100); + auto msg1 = loopback.getReceivedMessage(); + auto msg2 = loopback.getReceivedMessage(); + BOOST_REQUIRE (msg1.has_value()); + BOOST_REQUIRE (msg2.has_value()); + + MIDITimeStamp delta = msg2->timeStamp - msg1->timeStamp; + + mach_timebase_info_data_t timebase; + mach_timebase_info (&timebase); + uint64_t deltaNanos = delta * timebase.numer / timebase.denom; + + double deltaMs = deltaNanos / 1'000'000.0; + + BOOST_CHECK_CLOSE (deltaMs, timeDelayMs, 20.0); +} +BOOST_AUTO_TEST_SUITE_END() +#endif \ No newline at end of file