diff --git a/.github/workflows/cmake_ctest.yml b/.github/workflows/cmake_ctest.yml index 25a7d9e9..a28a97f9 100644 --- a/.github/workflows/cmake_ctest.yml +++ b/.github/workflows/cmake_ctest.yml @@ -149,6 +149,9 @@ jobs: run: cmake --build ${{ env.BUILD_DIR }} --config ${{ env.BUILD_TYPE }} --parallel 4 # dummy build # run: touch dummy + - name: Install + shell: bash + run: cmake --install ${{ env.BUILD_DIR }} --config ${{ env.BUILD_TYPE }} --prefix install # why the python3 -m pip install packaging? see this issue: https://github.com/nodejs/node-gyp/issues/2869 - name: Install Python dependencies for MACOS dmg @@ -167,14 +170,14 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ env.PRODUCT_NAME }} - path: ${{ env.ARTIFACTS_PATH }} + path: ${{ env.BUILD_DIR }}/install - name: Upload Zip (Linux) if: ${{ matrix.name == 'Linux' }} uses: actions/upload-artifact@v4 with: name: ${{ env.PRODUCT_NAME }} - path: ${{ env.ARTIFACTS_PATH }} + path: ${{ env.BUILD_DIR }}/install - name: Upload DMG (macOS) if: ${{ matrix.name == 'macOS' }} diff --git a/CMakeLists.txt b/CMakeLists.txt index c2efdf26..1b05d238 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,14 @@ target_sources(${PROJECT_NAME} src/external/fontaudio/data/FontAudioIcons.h ) +if(APPLE) + target_sources(${PROJECT_NAME} PRIVATE src/utils/copy/CopyMacOS.mm) +elseif(WIN32) + target_sources(${PROJECT_NAME} PRIVATE src/utils/copy/CopyWindows.cpp) +elseif(LINUX) + target_sources(${PROJECT_NAME} PRIVATE src/utils/copy/CopyLinux.cpp) +endif() + # `target_compile_definitions` adds some preprocessor definitions to our target. In a Projucer # project, these might be passed in the 'Preprocessor Definitions' field. JUCE modules also make use # of compile definitions to switch certain features on/off, so if there's a particular feature you @@ -196,59 +204,21 @@ target_link_libraries(${PROJECT_NAME} juce::juce_recommended_warning_flags ) -# C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.36.32532\x64\Microsoft.VC143.CRT\msvcp140.dll -# C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.36.32532\x64\Microsoft.VC143.CRT\vcruntime140_1.dll -# C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.36.32532\x64\Microsoft.VC143.CRT\vcruntime140.dll +if(LINUX) + find_package(X11 REQUIRED) + target_link_libraries(${PROJECT_NAME} PRIVATE X11::X11) +endif() + if (WIN32) - # Function to find the specific runtime DLLs - function(find_vc_runtime_dlls out_var) - message(STATUS "Searching for specific runtime DLLs...") - - # Define the target DLLs - set(target_dlls - "msvcp140.dll" - "vcruntime140_1.dll" - "vcruntime140.dll" - ) - - # Set the base path for Visual Studio 2022 redistributable DLLs - set(VS_CRT_BASE_PATH "C:/Program Files/Microsoft Visual Studio/2022/*/VC/Redist/MSVC/*/x64/Microsoft.VC143.CRT") - - # Search for the DLLs in the specified base path - file(GLOB_RECURSE all_runtime_dlls - LIST_DIRECTORIES false - "${VS_CRT_BASE_PATH}/*.dll" - ) - - set(runtime_dlls) - - foreach(dll ${all_runtime_dlls}) - get_filename_component(dll_name ${dll} NAME) - if(dll_name IN_LIST target_dlls) - list(APPEND runtime_dlls ${dll}) - endif() - endforeach() - - if(runtime_dlls) - message(STATUS "Found specific runtime DLLs:") - foreach(dll ${runtime_dlls}) - message(STATUS "${dll}") - endforeach() - set(${out_var} ${runtime_dlls} PARENT_SCOPE) - else() - message(FATAL_ERROR "Required specific runtime DLLs not found") - endif() - endfunction() - - # Call the function to find the specific DLLs - find_vc_runtime_dlls(RUNTIME_DLLS) - - # Add a post-build step to copy the specific DLLs to the output directory - add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${RUNTIME_DLLS} - $) + install(TARGETS ${PROJECT_NAME} + RUNTIME_DEPENDENCIES + PRE_EXCLUDE_REGEXES "api-ms-" "ext-ms-" + POST_EXCLUDE_REGEXES "system32" + RUNTIME DESTINATION . + RESOURCE DESTINATION . + ) endif() + # copy the pyinstaller tools to the bundle # if (APPLE) # add_custom_command(TARGET ${PROJECT_NAME} diff --git a/JUCE b/JUCE index 9f643254..501c0767 160000 --- a/JUCE +++ b/JUCE @@ -1 +1 @@ -Subproject commit 9f64325446ec0baf11f0f5f99c8484a15bbd1ab0 +Subproject commit 501c07674e1ad693085a7e7c398f205c2677f5da diff --git a/src/MainComponent.cpp b/src/MainComponent.cpp index a31a32df..46bf43b9 100644 --- a/src/MainComponent.cpp +++ b/src/MainComponent.cpp @@ -15,9 +15,12 @@ MainComponent::MainComponent() addAndMakeVisible(mainModelTab); addAndMakeVisible(statusAreaWidget); addAndMakeVisible(mediaClipboardWidget); + addAndMakeVisible(dragOverlay); showStatusArea = Settings::getBoolValue("view.showStatusArea", true); showMediaClipboard = Settings::getBoolValue("view.showMediaClipboard", false); + dragOverlay.setVisible(false); + dragOverlay.toFront(false); requiredWindowWidth = minimumWindowWidth; requiredWindowHeight = minimumWindowHeight; @@ -126,6 +129,8 @@ void MainComponent::resized() { welcomeWindow->refreshHighlightForCurrentStep(); } + + dragOverlay.setBounds(getLocalBounds()); } void MainComponent::updateWindowConstraints() diff --git a/src/MainComponent.h b/src/MainComponent.h index b229d7a4..353ec27f 100644 --- a/src/MainComponent.h +++ b/src/MainComponent.h @@ -152,7 +152,8 @@ class MainComponent : public Component, ModelTab mainModelTab; StatusAreaWidget statusAreaWidget; - MediaClipboardWidget mediaClipboardWidget; + DragOverlayComponent dragOverlay; + MediaClipboardWidget mediaClipboardWidget { &dragOverlay }; bool isTutorialActive = false; Rectangle tutorialHighlightRect; diff --git a/src/media/MediaDisplayComponent.cpp b/src/media/MediaDisplayComponent.cpp index 91178c12..2a1765b0 100644 --- a/src/media/MediaDisplayComponent.cpp +++ b/src/media/MediaDisplayComponent.cpp @@ -2,6 +2,155 @@ #include "AudioDisplayComponent.h" #include "MidiDisplayComponent.h" +#include "../utils/Interface.h" + +#include + +void OptionalBannerComponent::paint(Graphics& g) +{ + const float cx = static_cast(getWidth()) / 2.0f; + const float cy = static_cast(getHeight()) / 2.0f; + + g.setColour(Colour::fromRGB(90, 105, 105)); + g.fillAll(); + + g.setColour(Colours::white); + g.setFont(12.0f); + + Graphics::ScopedSaveState state(g); + g.addTransform(AffineTransform::rotation(-MathConstants::halfPi, cx, cy)); + + Rectangle textBounds( + 0, 0, static_cast(getHeight()), static_cast(getWidth())); + textBounds.setCentre(cx, cy); + + g.drawText("OPTIONAL", textBounds, Justification::centred, false); +} + +namespace +{ +struct TickScheme +{ + double majorStep; + double minorStep; + int minorCount; +}; + +TickScheme chooseTickScheme(double visibleLength) +{ + static const TickScheme schemes[] = { + { 0.1, 0.02, 5 }, { 0.5, 0.1, 5 }, { 1.0, 0.25, 4 }, { 2.0, 0.5, 4 }, + { 5.0, 1.0, 5 }, { 15.0, 5.0, 3 }, { 30.0, 10.0, 3 }, { 60.0, 15.0, 4 }, + { 120.0, 30.0, 4 }, { 300.0, 60.0, 5 }, { 600.0, 120.0, 5 }, + }; + + for (const auto& s : schemes) + { + double numMajor = visibleLength / s.majorStep; + if (numMajor >= 2.0 && numMajor <= 15.0) + return s; + } + + double majorStep = std::pow(10.0, std::floor(std::log10(visibleLength / 5.0))); + majorStep = std::max(0.01, majorStep); + return { majorStep, majorStep / 5.0, 5 }; +} + +String formatTime(double t, double step) +{ + if (t >= 3600.0) + { + int hrs = static_cast(t / 3600.0); + int mins = static_cast(std::fmod(t, 3600.0) / 60.0); + int secs = static_cast(std::fmod(t, 60.0)); + return String(hrs) + "h " + String(mins) + "m " + String(secs) + "s"; + } + if (t >= 60.0) + { + int mins = static_cast(t / 60.0); + int secs = static_cast(std::fmod(t, 60.0)); + return String(mins) + "m " + String(secs) + "s"; + } + if (step >= 1.0) + return String(static_cast(t)) + "s"; + + return String(t, 2) + "s"; +} +} // namespace + +void TimeAxisStrip::paint(Graphics& g) +{ + if (owner == nullptr || ! owner->isFileLoaded()) + return; + + const auto& visibleRange = owner->getVisibleRange(); + const float pps = owner->getPixelsPerSecond(); + const double totalLength = owner->getTotalLengthInSecs(); + + if (pps <= 0.0f || visibleRange.getLength() <= 0.0) + return; + + const double visibleStart = visibleRange.getStart(); + const double visibleEnd = visibleRange.getEnd(); + const int w = getWidth(); + const int h = getHeight(); + + g.setColour(Colours::darkgrey); + g.fillRect(getLocalBounds()); + + const double visibleLength = visibleRange.getLength(); + const auto scheme = chooseTickScheme(visibleLength); + const double majorStep = scheme.majorStep; + const double minorStep = scheme.minorStep; + + const float majorTickTop = 0.0f; + const float majorTickBot = static_cast(h); + const float minorTickTop = static_cast(h) * 0.55f; + const float minorTickBot = static_cast(h); + + // Minor ticks + g.setColour(Colours::grey.withAlpha(0.5f)); + + const double firstMinor = std::ceil(visibleStart / minorStep) * minorStep; + for (double t = firstMinor; t <= visibleEnd && t <= totalLength; t += minorStep) + { + double remainder = std::fmod(t, majorStep); + if (remainder < minorStep * 0.1 || (majorStep - remainder) < minorStep * 0.1) + continue; + + const float x = static_cast((t - visibleStart) * pps); + if (x < 0.0f || x > static_cast(w)) + continue; + + g.drawVerticalLine(static_cast(x), minorTickTop, minorTickBot); + } + + // Major ticks and labels + g.setColour(Colours::lightgrey.withAlpha(0.9f)); + + const int labelH = jmin(13, h - 2); + g.setFont(static_cast(labelH)); + + const double firstMajor = std::ceil(visibleStart / majorStep) * majorStep; + for (double t = firstMajor; t <= visibleEnd && t <= totalLength; t += majorStep) + { + const float x = static_cast((t - visibleStart) * pps); + if (x < -60.0f || x > static_cast(w) + 60.0f) + continue; + + g.drawVerticalLine(static_cast(x), majorTickTop, majorTickBot); + + String label = formatTime(t, majorStep); + g.drawText(label, + static_cast(x) + 3, + 0, + jmin(90, w - static_cast(x)), + h, + Justification::centredLeft, + true); + } +} + MediaDisplayComponent::MediaDisplayComponent() : MediaDisplayComponent("Media Track") {} MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW, DisplayMode mode) @@ -36,8 +185,13 @@ MediaDisplayComponent::MediaDisplayComponent(String name, bool req, bool fromDAW horizontalScrollBar.setAutoHide(false); horizontalScrollBar.addListener(this); + addAndMakeVisible(optionalBanner); + + timeAxisStrip = std::make_unique(this); + mediaAreaContainer.addAndMakeVisible(overheadPanel); mediaAreaContainer.addAndMakeVisible(contentComponent); + mediaAreaContainer.addAndMakeVisible(*timeAxisStrip); mediaAreaContainer.addAndMakeVisible(horizontalScrollBar); addAndMakeVisible(mediaAreaContainer); @@ -106,6 +260,22 @@ void MediaDisplayComponent::initializeButtons() saveFileButton.addMode(saveFileButtonInactiveInfo); headerComponent.addAndMakeVisible(saveFileButton); + // Mode when an copyable file is loaded + copyFileButtonActiveInfo = MultiButton::Mode { "Copy-Active", + "Click to copy the media file.", + [this] { copyFileCallback(); }, + MultiButton::DrawingMode::IconOnly, + Colours::lightblue, + fontawesome::Copy }; + // Mode when there is nothing to copy + copyFileButtonInactiveInfo = + MultiButton::Mode { "Copy-Inactive", "Nothing to copy.", + [this] {}, MultiButton::DrawingMode::IconOnly, + Colours::lightgrey, fontawesome::Copy }; + copyFileButton.addMode(copyFileButtonActiveInfo); + copyFileButton.addMode(copyFileButtonInactiveInfo); + headerComponent.addAndMakeVisible(copyFileButton); + resetButtonState(); } @@ -188,6 +358,16 @@ void MediaDisplayComponent::resized() { // Place header beside media mainFlexBox.flexDirection = FlexBox::Direction::row; + + if (! isRequired() && isInputTrack() && ! isThumbnailTrack()) + { + mainFlexBox.items.add(FlexItem(optionalBanner).withWidth(24)); + } + else + { + optionalBanner.setBounds(0, 0, 0, 0); + } + // Fixed area for track label and buttons mainFlexBox.items.add(FlexItem(headerComponent).withFlex(1).withMaxWidth(40).withMargin(4)); } @@ -242,16 +422,18 @@ void MediaDisplayComponent::resized() // Add buttons to flex with equal height buttonsFlexBox.items.add( - FlexItem(playStopButton).withHeight(22).withWidth(22).withMargin({ 2, 0, 2, 0 })); + FlexItem(playStopButton).withHeight(25).withWidth(25).withMargin({ 2, 0, 2, 0 })); if (isInputTrack()) { buttonsFlexBox.items.add( - FlexItem(chooseFileButton).withHeight(22).withWidth(22).withMargin({ 2, 0, 2, 0 })); + FlexItem(chooseFileButton).withHeight(25).withWidth(25).withMargin({ 2, 0, 2, 0 })); } if (isOutputTrack()) { buttonsFlexBox.items.add( - FlexItem(saveFileButton).withHeight(22).withWidth(22).withMargin({ 2, 0, 2, 0 })); + FlexItem(saveFileButton).withHeight(25).withWidth(25).withMargin({ 2, 0, 2, 0 })); + buttonsFlexBox.items.add( + FlexItem(copyFileButton).withHeight(25).withWidth(25).withMargin({ 2, 0, 2, 0 })); } buttonsFlexBox.performLayout(buttonsComponent.getBounds()); @@ -277,6 +459,20 @@ void MediaDisplayComponent::resized() // Media component takes remaining space mediaAreaFlexBox.items.add(FlexItem(contentComponent).withFlex(1)); + if (timeAxisStrip != nullptr) + { + timeAxisStrip->setVisible(horizontalScrollBar.isVisible()); + if (timeAxisStrip->isVisible()) + { + mediaAreaFlexBox.items.add(FlexItem(*timeAxisStrip) + .withHeight(timeAxisHeight) + .withMargin({ 0, + getVerticalControlsWidth(), + static_cast(controlSpacing), + getMediaXPos() })); + } + } + if (horizontalScrollBar.isVisible()) { // Add horizontal scrollbar with fixed height @@ -441,6 +637,7 @@ void MediaDisplayComponent::resetButtonState() playStopButton.setMode(playButtonInactiveInfo.displayLabel); chooseFileButton.setMode(chooseFileButtonActiveInfo.displayLabel); saveFileButton.setMode(saveFileButtonInactiveInfo.displayLabel); + copyFileButton.setMode(copyFileButtonInactiveInfo.displayLabel); } void MediaDisplayComponent::initializeDisplay(const URL& filePath) @@ -473,6 +670,7 @@ void MediaDisplayComponent::updateDisplay(const URL& filePath) playStopButton.setMode(playButtonActiveInfo.displayLabel); saveFileButton.setMode(saveFileButtonActiveInfo.displayLabel); + copyFileButton.setMode(copyFileButtonActiveInfo.displayLabel); } void MediaDisplayComponent::setOriginalFilePath(URL filePath) @@ -753,6 +951,31 @@ void MediaDisplayComponent::saveFileCallback() } } +void MediaDisplayComponent::copyFileCallback() +{ + if (isFileLoaded()) + { + File file = getOriginalFilePath().getLocalFile(); + + if (file.exists()) + { + copyFileToClipboard(file); + + if (statusMessage != nullptr) + { + statusMessage->setMessage("File copied to clipboard."); + } + } + else + { + if (statusMessage != nullptr) + { + statusMessage->setMessage("Failed to copy file to clipboard."); + } + } + } +} + float MediaDisplayComponent::getPixelsPerSecond() { if (visibleRange.getLength()) @@ -823,6 +1046,9 @@ void MediaDisplayComponent::updateVisibleRange(Range r) updateCursorPosition(); repositionLabels(); + if (timeAxisStrip != nullptr) + timeAxisStrip->repaint(); + visibleRangeCallback(); } @@ -831,24 +1057,53 @@ void MediaDisplayComponent::horizontalMove(double deltaT) double visibleStart = visibleRange.getStart(); double visibleLength = visibleRange.getLength(); + const double totalLength = getTotalLengthInSecs(); + const double maxStart = jmax(0.0, totalLength - visibleLength); double newStart = visibleStart - deltaT * visibleLength / 10.0; - newStart = jlimit(0.0, jmax(0.0, getTotalLengthInSecs() - visibleLength), newStart); + newStart = jlimit(0.0, maxStart, newStart); updateVisibleRange({ newStart, newStart + visibleLength }); } void MediaDisplayComponent::horizontalZoom(double deltaZoom, double scrollPosT) { - horizontalZoomFactor = jlimit(1.0, 2.0, horizontalZoomFactor + deltaZoom); + const float mediaWidth = getMediaWidth(); + const double totalLength = getTotalLengthInSecs(); + + if (mediaWidth <= 0.0f || totalLength <= 0.0) + return; + + const float pps = getPixelsPerSecond(); + if (pps <= 0.0f) + return; - double newScale = jmax(0.05, getTotalLengthInSecs() * (2.0 - horizontalZoomFactor)); + const double minVisibleSeconds = 5.0; + const float minPps = static_cast(mediaWidth / totalLength); + float maxPps = static_cast(mediaWidth / minVisibleSeconds); + maxPps = jmax(maxPps, minPps); + float newPps = pps * (1.0f + 0.5f * static_cast(deltaZoom)); + newPps = jlimit(minPps, maxPps, newPps); + + if (std::abs(newPps - pps) < 0.01f) + return; + + double newVisibleLength = static_cast(mediaWidth) / static_cast(newPps); + newVisibleLength = jmin(newVisibleLength, totalLength); + + double anchorRatio = 0.5; double visibleStart = visibleRange.getStart(); - double visibleEnd = visibleRange.getEnd(); double visibleLength = visibleRange.getLength(); - double newStart = scrollPosT - newScale * (scrollPosT - visibleStart) / visibleLength; - double newEnd = scrollPosT + newScale * (visibleEnd - scrollPosT) / visibleLength; + if (visibleLength > 0.0) + anchorRatio = (scrollPosT - visibleStart) / visibleLength; + + anchorRatio = jlimit(0.0, 1.0, anchorRatio); + + const double maxStart = jmax(0.0, totalLength - newVisibleLength); + double newStart = scrollPosT - anchorRatio * newVisibleLength; + newStart = jlimit(0.0, maxStart, newStart); + double newEnd = newStart + newVisibleLength; updateVisibleRange({ newStart, newEnd }); } @@ -879,7 +1134,7 @@ void MediaDisplayComponent::mouseWheelMove(const MouseEvent& evt, const MouseWhe double scrollTime = mediaXToTime(evt.position.getX()); - if (! commandMod) + if (! commandMod && evt.eventComponent == getMediaComponent()) { if (std::abs(wheel.deltaX) > 2 * std::abs(wheel.deltaY)) { diff --git a/src/media/MediaDisplayComponent.h b/src/media/MediaDisplayComponent.h index a4020658..7769861b 100644 --- a/src/media/MediaDisplayComponent.h +++ b/src/media/MediaDisplayComponent.h @@ -17,6 +17,8 @@ using namespace juce; +class MediaDisplayComponent; + enum class DisplayMode { Input, @@ -25,6 +27,23 @@ enum class DisplayMode Thumbnail // Reduced functionality }; +class OptionalBannerComponent : public Component +{ +public: + void paint(Graphics& g) override; +}; + +class TimeAxisStrip : public Component +{ +public: + explicit TimeAxisStrip(MediaDisplayComponent* ownerIn) : owner(ownerIn) {} + + void paint(Graphics& g) override; + +private: + MediaDisplayComponent* owner = nullptr; +}; + class ColorablePanel : public Component { public: @@ -111,10 +130,12 @@ class MediaDisplayComponent : public Component, bool isDuplicateFile(const URL& fileParth); void saveFileCallback(); + void copyFileCallback(); virtual double getTotalLengthInSecs() = 0; virtual double getTimeAtOrigin() { return visibleRange.getStart(); } virtual float getPixelsPerSecond(); + const Range& getVisibleRange() const { return visibleRange; } virtual void setPlaybackPosition(double t) { transportSource.setPosition(t); } virtual double getPlaybackPosition() { return transportSource.getCurrentPosition(); } @@ -151,6 +172,7 @@ class MediaDisplayComponent : public Component, const int controlSpacing = 1; const int scrollBarSize = 8; + const int timeAxisHeight = 20; // Media (audio or MIDI) content area Component contentComponent; @@ -159,6 +181,8 @@ class MediaDisplayComponent : public Component, Range visibleRange; + std::unique_ptr timeAxisStrip; + AudioFormatManager formatManager; AudioDeviceManager deviceManager; @@ -250,6 +274,12 @@ class MediaDisplayComponent : public Component, MultiButton saveFileButton; MultiButton::Mode saveFileButtonActiveInfo; MultiButton::Mode saveFileButtonInactiveInfo; + MultiButton copyFileButton; + MultiButton::Mode copyFileButtonActiveInfo; + MultiButton::Mode copyFileButtonInactiveInfo; + + // Banner shown on left edge of optional input tracks + OptionalBannerComponent optionalBanner; // Panel displaying overhead labels ColorablePanel overheadPanel { overheadPanelColor }; @@ -291,4 +321,4 @@ class MediaDisplayComponent : public Component, SharedResourcePointer instructionsMessage; SharedResourcePointer statusMessage; -}; +}; \ No newline at end of file diff --git a/src/media/MidiDisplayComponent.cpp b/src/media/MidiDisplayComponent.cpp index 2c13d614..8c8a7cb4 100644 --- a/src/media/MidiDisplayComponent.cpp +++ b/src/media/MidiDisplayComponent.cpp @@ -96,10 +96,9 @@ void MidiDisplayComponent::loadMediaFile(const URL& filePath) int channel = midiMessage.getChannel(); - if (channel == 10) - { - // TODO - handle drums channel - } + // Handle drums channel + bool isDrum = (channel == 10); + if (midiMessage.isProgramChange()) { @@ -137,7 +136,8 @@ void MidiDisplayComponent::loadMediaFile(const URL& filePath) startTime, duration, static_cast(velocity), - static_cast(instrument)); + static_cast(instrument), + isDrum); pianoRoll.insertNote(n); } } diff --git a/src/media/pianoroll/NoteGridComponent.cpp b/src/media/pianoroll/NoteGridComponent.cpp index 3fc574c7..c7e15637 100644 --- a/src/media/pianoroll/NoteGridComponent.cpp +++ b/src/media/pianoroll/NoteGridComponent.cpp @@ -27,26 +27,46 @@ void NoteGridComponent::paint(Graphics& g) Rectangle bounds(noteXPos, noteYPos, jmax(3.0f, noteWidth), noteHeight - 1.0f); + float maxDrumWidth = 0.01f * getParentComponent()->getWidth(); + float hue = static_cast(n->instrument) / 127.0f; Colour color = Colour::fromHSV(hue, 1.0f, 1.0f, 1.0f).brighter(); // Note fill - g.setColour(color.withAlpha(0.75f)); - g.fillRect(bounds.toNearestInt()); + + if (n->isDrum) + { + g.setColour(color); + + const float thickness = jmax(2.0f, noteHeight * 0.18f); + const float pad = jmax(1.0f, thickness * 0.5f); - if ((noteWidth >= 5) & (noteHeight >= 8)) + const float x1 = noteXPos - maxDrumWidth/2; + const float y1 = bounds.getY() + pad; + const float x2 = noteXPos + maxDrumWidth/2; + const float y2 = bounds.getBottom() - pad; + + g.drawLine(x1, y1, x2, y2, thickness); + g.drawLine(x2, y1, x1, y2, thickness); + } + else { - const float maxVelocityWidth = static_cast(noteWidth - 4); - const float verticalOffset = static_cast(noteHeight) * 0.5f - 2.0f; - const float velocityHeight = 4.0f; - - // Velocity fill - g.setColour(color.brighter()); - g.fillRect(bounds.translated(2, verticalOffset) - .withWidth(maxVelocityWidth * n->velocity / 127.0f) - .withHeight(velocityHeight) - .toNearestInt()); + g.setColour(color.withAlpha(0.75f)); + g.fillRect(bounds.toNearestInt()); + + if ((noteWidth >= 5) && (noteHeight >= 8)) + { + const float maxVelocityWidth = static_cast(noteWidth - 4); + const float verticalOffset = static_cast(noteHeight) * 0.5f - 2.0f; + const float velocityHeight = 4.0f; + + g.setColour(color.brighter()); + g.fillRect(bounds.translated(2, verticalOffset) + .withWidth(maxVelocityWidth * n->velocity / 127.0f) + .withHeight(velocityHeight) + .toNearestInt()); + } } } } diff --git a/src/media/pianoroll/NoteGridComponent.hpp b/src/media/pianoroll/NoteGridComponent.hpp index 7a3eb19a..d4f8e13e 100644 --- a/src/media/pianoroll/NoteGridComponent.hpp +++ b/src/media/pianoroll/NoteGridComponent.hpp @@ -15,8 +15,8 @@ using namespace juce; struct MidiNote { public: - MidiNote(unsigned char n, double s, double d, unsigned char v, unsigned char i = 0) - : noteNumber(n), startTime(s), duration(d), velocity(v), instrument(i) + MidiNote(unsigned char n, double s, double d, unsigned char v, unsigned char i = 0, bool drum = false) + : noteNumber(n), startTime(s), duration(d), velocity(v), instrument(i), isDrum(drum) { } @@ -27,6 +27,7 @@ struct MidiNote duration = other.duration; velocity = other.velocity; instrument = other.instrument; + isDrum = other.isDrum; } unsigned char noteNumber; @@ -34,6 +35,7 @@ struct MidiNote double duration; unsigned char velocity; unsigned char instrument; + bool isDrum; }; class NoteGridComponent : public KeyboardComponent diff --git a/src/utils/Interface.h b/src/utils/Interface.h index a44effa1..620c9f25 100644 --- a/src/utils/Interface.h +++ b/src/utils/Interface.h @@ -1,7 +1,7 @@ /** * @file Interface.h * @brief Simple helper functions for interface. - * @author hugofloresgarcia + * @author hugofloresgarcia, JEYuhas */ #pragma once @@ -20,3 +20,5 @@ inline Colour getUIColourIfAvailable(LookAndFeel_V4::ColourScheme::UIColour uiCo return fallback; } + +void copyFileToClipboard(const File& file); diff --git a/src/utils/copy/CopyLinux.cpp b/src/utils/copy/CopyLinux.cpp new file mode 100644 index 00000000..ba0d1542 --- /dev/null +++ b/src/utils/copy/CopyLinux.cpp @@ -0,0 +1,175 @@ +/** + * @file CopyLinux.cpp + * @brief Copy file path to clipboard on Linux. + * @author JEYuhas, cwitkowitz + */ + +extern "C" +{ +#include +#include +} + +#include +#include + +#include "../Interface.h" + +namespace +{ +struct Clipboard +{ + Display* display = nullptr; + Window window = 0; + + Atom clipboard; + Atom targets; + Atom uriList; + Atom textPlain; + Atom utf8; + Atom gnome; + + std::string dataUri; + std::string dataPlain; + std::string dataGnome; + + std::atomic running { true }; +}; + +Clipboard* clipboardState = nullptr; + +void handleRequest(XEvent& e) +{ + auto* req = &e.xselectionrequest; + + XEvent res {}; + res.xselection.type = SelectionNotify; + res.xselection.display = req->display; + res.xselection.requestor = req->requestor; + res.xselection.selection = req->selection; + res.xselection.target = req->target; + res.xselection.time = req->time; + res.xselection.property = None; + + if (req->target == clipboardState->targets) + { + Atom list[] = { clipboardState->targets, + clipboardState->uriList, + clipboardState->textPlain, + clipboardState->utf8, + clipboardState->gnome }; + + XChangeProperty(req->display, + req->requestor, + req->property, + XA_ATOM, + 32, + PropModeReplace, + (unsigned char*) list, + 5); + + res.xselection.property = req->property; + } + else if (req->target == clipboardState->uriList) + { + XChangeProperty(req->display, + req->requestor, + req->property, + clipboardState->uriList, + 8, + PropModeReplace, + (unsigned char*) clipboardState->dataUri.c_str(), + (int) clipboardState->dataUri.size()); + + res.xselection.property = req->property; + } + else if (req->target == clipboardState->textPlain || req->target == clipboardState->utf8) + { + XChangeProperty(req->display, + req->requestor, + req->property, + req->target, + 8, + PropModeReplace, + (unsigned char*) clipboardState->dataPlain.c_str(), + (int) clipboardState->dataPlain.size()); + + res.xselection.property = req->property; + } + else if (req->target == clipboardState->gnome) + { + XChangeProperty(req->display, + req->requestor, + req->property, + clipboardState->gnome, + 8, + PropModeReplace, + (unsigned char*) clipboardState->dataGnome.c_str(), + (int) clipboardState->dataGnome.size()); + + res.xselection.property = req->property; + } + + XSendEvent(req->display, req->requestor, False, 0, &res); + XFlush(req->display); +} + +void runLoop() +{ + while (clipboardState->running) + { + while (XPending(clipboardState->display)) + { + XEvent e; + XNextEvent(clipboardState->display, &e); + + if (e.type == SelectionRequest) + handleRequest(e); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } +} +} // namespace + +void copyFileToClipboard(const File& file) +{ + if (! file.existsAsFile()) + return; + + if (clipboardState) + { + clipboardState->running = false; + delete clipboardState; + clipboardState = nullptr; + } + + clipboardState = new Clipboard(); + + clipboardState->display = XOpenDisplay(nullptr); + if (! clipboardState->display) + return; + + clipboardState->window = XCreateSimpleWindow( + clipboardState->display, DefaultRootWindow(clipboardState->display), 0, 0, 1, 1, 0, 0, 0); + + clipboardState->clipboard = XInternAtom(clipboardState->display, "CLIPBOARD", False); + clipboardState->targets = XInternAtom(clipboardState->display, "TARGETS", False); + clipboardState->uriList = XInternAtom(clipboardState->display, "text/uri-list", False); + clipboardState->textPlain = XInternAtom(clipboardState->display, "text/plain", False); + clipboardState->utf8 = XInternAtom(clipboardState->display, "UTF8_STRING", False); + clipboardState->gnome = + XInternAtom(clipboardState->display, "x-special/gnome-copied-files", False); + + std::string path = file.getFullPathName().toStdString(); + std::string uri = "file://" + path; + + clipboardState->dataUri = uri + "\r\n"; + clipboardState->dataPlain = path; + clipboardState->dataGnome = "copy\n" + uri + "\n"; + + XSetSelectionOwner( + clipboardState->display, clipboardState->clipboard, clipboardState->window, CurrentTime); + + std::thread(runLoop).detach(); +} diff --git a/src/utils/copy/CopyMacOS.mm b/src/utils/copy/CopyMacOS.mm new file mode 100644 index 00000000..d501d6c6 --- /dev/null +++ b/src/utils/copy/CopyMacOS.mm @@ -0,0 +1,26 @@ +/** + * @file CopyMacOS.mm + * @brief Copy file path to clipboard on MacOS. + * @author JEYuhas + */ + +#import + +#include "../Interface.h" + +void copyFileToClipboard (const juce::File& file) +{ + if (! file.existsAsFile()) + return; + + NSPasteboard* pb = [NSPasteboard generalPasteboard]; + + [pb declareTypes: [NSArray arrayWithObject: NSPasteboardTypeFileURL] + owner: nil]; + + NSString* path = [NSString stringWithUTF8String: file.getFullPathName().toUTF8()]; + NSURL* fileURL = [NSURL fileURLWithPath: path]; + + [pb setString: [fileURL absoluteString] + forType: NSPasteboardTypeFileURL]; +} diff --git a/src/utils/copy/CopyWindows.cpp b/src/utils/copy/CopyWindows.cpp new file mode 100644 index 00000000..973def2b --- /dev/null +++ b/src/utils/copy/CopyWindows.cpp @@ -0,0 +1,39 @@ +/** + * @file CopyWindows.cpp + * @brief Copy file path to clipboard on Windows. + * @author JEYuhas + */ + +#include +#include + +#include "../Interface.h" + +void copyFileToClipboard(const juce::File& file) +{ + if (! file.existsAsFile()) + return; + + if (! OpenClipboard(nullptr)) + return; + + EmptyClipboard(); + + std::wstring path = file.getFullPathName().toWideCharPointer(); + + size_t size = sizeof(DROPFILES) + (path.size() + 2) * sizeof(wchar_t); + + HGLOBAL hMem = GlobalAlloc(GHND, size); + DROPFILES* df = (DROPFILES*) GlobalLock(hMem); + + df->pFiles = sizeof(DROPFILES); + df->fWide = TRUE; + + wchar_t* data = (wchar_t*) ((BYTE*) df + sizeof(DROPFILES)); + wcscpy(data, path.c_str()); + + GlobalUnlock(hMem); + + SetClipboardData(CF_HDROP, hMem); + CloseClipboard(); +} diff --git a/src/widgets/MediaClipboardWidget.h b/src/widgets/MediaClipboardWidget.h index 3753cbfd..19f3fcf8 100644 --- a/src/widgets/MediaClipboardWidget.h +++ b/src/widgets/MediaClipboardWidget.h @@ -19,7 +19,9 @@ using namespace juce; class MediaClipboardWidget : public Component, public ChangeListener { public: - MediaClipboardWidget() + MediaClipboardWidget(DragOverlayComponent* overlay) + : dragOverlay(overlay), + trackAreaWidget(DisplayMode::Thumbnail, 75, overlay) { selectionTextBox.onReturnKey = [this] { renameSelectionCallback(); }; controlsComponent.addAndMakeVisible(selectionTextBox); @@ -133,7 +135,10 @@ class MediaClipboardWidget : public Component, public ChangeListener Rectangle getClipboardTrackAreaBounds() const { return trackArea.getBounds().expanded(2); } - Rectangle getClipboardControlsBounds() const { return controlsComponent.getBounds().expanded(2); } + Rectangle getClipboardControlsBounds() const + { + return controlsComponent.getBounds().expanded(2); + } Rectangle getClipboardNameBoxBounds() const { @@ -261,7 +266,7 @@ class MediaClipboardWidget : public Component, public ChangeListener File selectedFile = selectedTrack->getOriginalFilePath().getLocalFile(); - /*StringArray validExtensions = + StringArray validExtensions = originalTrack->getInstanceExtensions(); if (! validExtensions.contains(selectedFile.getFileExtension())) @@ -269,52 +274,89 @@ class MediaClipboardWidget : public Component, public ChangeListener AlertWindow::showMessageBoxAsync( AlertWindow::WarningIcon, "Invalid File", - "This track can only be overwritten with data of the following file types: " - + validExtensions.joinIntoString(", ") + ".", + "This track cannot be overwritten with the selected file.", "OK"); - }*/ - if (originalFile.getFileExtension() - != selectedFile.getFileExtension()) + return; + } + + bool successfulOverwrite = false; + + String ext = originalFile.getFileExtension().toLowerCase(); + + if (AudioDisplayComponent::getSupportedExtensions().contains( + ext) + && ext != selectedFile.getFileExtension().toLowerCase()) { - AlertWindow::showMessageBoxAsync( - AlertWindow::WarningIcon, - "File Type Mismatch", - "Cannot overwrite file of type \"" - + originalFile.getFileExtension() - + "\" with file of type \"" - + selectedFile.getFileExtension() + "\".", - "OK"); + File tempFile = selectedFile.getSiblingFile( + selectedFile.getFileNameWithoutExtension() + + "_converted" + originalFile.getFileExtension()); - // TODO - perform conversion if possible + if (convertAudioFile(selectedFile, tempFile)) + { + if (tempFile.copyFileTo(originalFile)) + { + DBG_AND_LOG( + "MediaClipboardWidget::sendToDAWCallback: Converted and overwrote \"" + << originalFile.getFullPathName() + << "\" with \"" + << selectedFile.getFullPathName() << "\"."); + + successfulOverwrite = true; + } + else + { + DBG_AND_LOG( + "MediaClipboardWidget::sendToDAWCallback: Conversion succeeded " + "but failed to copy to \"" + << originalFile.getFullPathName() << "\"."); + } + + tempFile.deleteFile(); + } + else + { + AlertWindow::showMessageBoxAsync( + AlertWindow::WarningIcon, + "File Type Mismatch", + "Cannot convert file of type \"" + + selectedFile.getFileExtension() + "\" to \"" + + originalFile.getFileExtension() + "\".", + "OK"); + } } else { if (selectedFile.copyFileTo(originalFile)) { DBG_AND_LOG( - "MediaClipboardWidget::sendToDAWCallback: Overwriting file " - << originalFile.getFullPathName() << " with " - << selectedFile.getFullPathName() << "."); + "MediaClipboardWidget::sendToDAWCallback: Overwriting file \"" + << originalFile.getFullPathName() << "\" with \"" + << selectedFile.getFullPathName() << "\"."); - // Update display with overwritten media - linkedDisplays[selectedIndex]->initializeDisplay( - URL(originalFile)); - - // Remove selected track - removeSelectionCallback(); - - // Select overwritten track - linkedDisplays[selectedIndex]->selectTrack(); + successfulOverwrite = true; } else { DBG_AND_LOG( - "MediaClipboardWidget::sendToDAWCallback: Failed to overwrite file " - << originalFile.getFullPathName() << " with " - << selectedFile.getFullPathName() << "."); + "MediaClipboardWidget::sendToDAWCallback: Failed to overwrite file \"" + << originalFile.getFullPathName() << "\" with \"" + << selectedFile.getFullPathName() << "\"."); } } + + if (successfulOverwrite) + { + // Update display with overwritten media + linkedDisplays[selectedIndex]->initializeDisplay( + URL(originalFile)); + + // Remove selected track + removeSelectionCallback(); + + // Select overwritten track + linkedDisplays[selectedIndex]->selectTrack(); + } } } }), @@ -544,6 +586,81 @@ class MediaClipboardWidget : public Component, public ChangeListener } } + // TODO - move to utils/? + bool convertAudioFile(const File& source, const File& target) + { + AudioFormatManager formatManager; + formatManager.registerBasicFormats(); + + std::unique_ptr reader(formatManager.createReaderFor(source)); + + if (! reader) + { + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Could not read source file \"" + << source.getFullPathName() << "\"."); + + return false; + } + + String ext = target.getFileExtension().toLowerCase(); + AudioFormat* format = nullptr; + + if (ext == ".wav") + format = formatManager.findFormatForFileExtension("wav"); + else if (ext == ".aiff" || ext == ".aif") + format = formatManager.findFormatForFileExtension("aiff"); + else if (ext == ".flac") + format = formatManager.findFormatForFileExtension("flac"); + else + { + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Unsupported target format \"" + << ext << "\"."); + + return false; + } + + target.deleteFile(); + + auto outputStream = target.createOutputStream(); + + if (! outputStream) + { + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Could not create output file \"" + << target.getFullPathName() << "\"."); + + return false; + } + + std::unique_ptr writer(format->createWriterFor( + outputStream.release(), reader->sampleRate, reader->numChannels, 16, {}, 0)); + + if (! writer) + { + DBG_AND_LOG("convertAudioFile: Could not create writer for \"" + << target.getFullPathName() << "\"."); + + return false; + } + + const int blockSize = 4096; + AudioBuffer buffer((int) reader->numChannels, blockSize); + int64 totalFrames = (int64) reader->lengthInSamples; + int64 position = 0; + + while (position < totalFrames) + { + int64 framesToRead = jmin((int64) blockSize, totalFrames - position); + reader->read(&buffer, 0, (int) framesToRead, position, true, true); + writer->writeFromAudioSampleBuffer(buffer, 0, (int) framesToRead); + position += framesToRead; + } + + DBG_AND_LOG("MediaClipboardWidget::convertAudioFile: Converted and saved \"" + << source.getFullPathName() << "\" to \"" << target.getFullPathName() << "\"."); + + return true; + } + void resetState() { selectionTextBox.clear(); @@ -625,6 +742,8 @@ class MediaClipboardWidget : public Component, public ChangeListener MultiButton::Mode sendToDAWButtonInactiveInfo1; MultiButton::Mode sendToDAWButtonInactiveInfo2; + DragOverlayComponent* dragOverlay = nullptr; + Viewport trackArea; TrackAreaWidget trackAreaWidget { DisplayMode::Thumbnail, 75 }; diff --git a/src/widgets/TrackAreaWidget.h b/src/widgets/TrackAreaWidget.h index f81f7765..e15f3c05 100644 --- a/src/widgets/TrackAreaWidget.h +++ b/src/widgets/TrackAreaWidget.h @@ -18,15 +18,111 @@ using namespace juce; +class DragOverlayComponent : public Component +{ +public: + DragOverlayComponent() + { + setInterceptsMouseClicks(false, false); + } + + void startDrag(Image snapshot, Point offset) + { + if (isActive) return; + dragImage = snapshot; + dragOffset = offset; + isActive = true; + setVisible(true); + repaint(); + } + + // Called every mouseDrag event with the current mouse position + void updatePosition(Point newPos) + { + if (newPos == currentPos) return; + + // Clear old position + repaint(currentPos.x - dragOffset.x, + currentPos.y - dragOffset.y, + dragImage.getWidth(), + dragImage.getHeight()); + + currentPos = newPos; + + // Repaint only new position + repaint(currentPos.x - dragOffset.x, + currentPos.y - dragOffset.y, + dragImage.getWidth(), + dragImage.getHeight()); + } + + void stopDrag() + { + isActive = false; + dragImage = Image(); // Releases the image data + setVisible(false); + repaint(); + } + + void paint(Graphics& g) override + { + if (!isActive || dragImage.isNull()) + return; + + g.drawImage(dragImage, + currentPos.x - dragOffset.x, + currentPos.y - dragOffset.y, + dragImage.getWidth(), + dragImage.getHeight(), + 0, + 0, + dragImage.getWidth(), + dragImage.getHeight()); + } + +private: + Image dragImage; + Point currentPos; + Point dragOffset; + bool isActive = false; +}; + +class GhostTrackComponent : public Component +{ +public: + void setImage(const Image& img) + { + ghostImage = img; + repaint(); + } + + void paint(Graphics& g) override + { + if (ghostImage.isNull()) return; + + g.setOpacity(0.4f); + g.drawImage(ghostImage, + getLocalBounds().toFloat(), + RectanglePlacement::stretchToFit); + } + +private: + Image ghostImage; +}; + class TrackAreaWidget : public Component, public ChangeListener, public ChangeBroadcaster, public FileDragAndDropTarget { public: - TrackAreaWidget(DisplayMode mode = DisplayMode::Input, int trackHeight = 0) - : displayMode(mode), fixedTrackHeight(trackHeight) + TrackAreaWidget(DisplayMode mode = DisplayMode::Input, + int trackHeight = 0, + DragOverlayComponent* overlay = nullptr) + : displayMode(mode), fixedTrackHeight(trackHeight), dragOverlay(overlay) { + addMouseListener(this, true); + addChildComponent(ghostTrack); } ~TrackAreaWidget() { resetState(); } @@ -47,8 +143,42 @@ class TrackAreaWidget : public Component, if (getNumTracks() > 0) { + int draggedIndex = isDraggingTrack ? getDraggedTrackIndex() : -1; + + int visualGapIndex = dragInsertIndex; + if (isDraggingTrack && draggedIndex >= 0 && dragInsertIndex > draggedIndex) + visualGapIndex = dragInsertIndex - 1; + + int layoutIndex = 0; + for (auto& m : mediaDisplays) { + // Don't draw dragged track + if (m.get() == draggedTrack && isDraggingTrack) + continue; + + if (isDraggingTrack && dragInsertIndex >= 0 && layoutIndex == visualGapIndex) + { + FlexItem gap; + if (isDraggingOutside) + { + ghostTrack.setVisible(true); + if (fixedTrackHeight) + gap = FlexItem(ghostTrack).withHeight(fixedTrackHeight).withMargin(marginSize); + else + gap = FlexItem(ghostTrack).withFlex(1).withMinHeight(50).withMargin(marginSize); + } + else + { + ghostTrack.setVisible(false); + if (fixedTrackHeight) + gap = FlexItem().withHeight(fixedTrackHeight).withMargin(marginSize); + else + gap = FlexItem().withFlex(1).withMinHeight(50).withMargin(marginSize); + } + mainBox.items.add(gap); + } + FlexItem i = FlexItem(*m); if (fixedTrackHeight) @@ -61,6 +191,31 @@ class TrackAreaWidget : public Component, } mainBox.items.add(i.withMargin(marginSize)); + + layoutIndex++; + } + + // If the drag gap is at the end of the list + if (isDraggingTrack && dragInsertIndex >= 0 && layoutIndex == visualGapIndex) + { + FlexItem gap; + if (isDraggingOutside) + { + ghostTrack.setVisible(true); + if (fixedTrackHeight) + gap = FlexItem(ghostTrack).withHeight(fixedTrackHeight).withMargin(marginSize); + else + gap = FlexItem(ghostTrack).withFlex(1).withMinHeight(50).withMargin(marginSize); + } + else + { + ghostTrack.setVisible(false); + if (fixedTrackHeight) + gap = FlexItem().withHeight(fixedTrackHeight).withMargin(marginSize); + else + gap = FlexItem().withFlex(1).withMinHeight(50).withMargin(marginSize); + } + mainBox.items.add(gap); } if (fixedTrackHeight) @@ -336,6 +491,32 @@ class TrackAreaWidget : public Component, resized(); } + void reorderTrack(MediaDisplayComponent* draggedDisplay, int newIndex) + { + auto it = + std::find_if(mediaDisplays.begin(), + mediaDisplays.end(), + [draggedDisplay](const auto& ptr) { return ptr.get() == draggedDisplay; }); + + if (it == mediaDisplays.end()) return; + + // Track the old index for downward reordering + int oldIndex = std::distance(mediaDisplays.begin(), it); + + auto draggedPtr = std::move(*it); + mediaDisplays.erase(it); + + // Decrement index if moving downward to account for shifting indicies + if (newIndex > oldIndex) + newIndex--; + + newIndex = jlimit(0, (int)mediaDisplays.size(), newIndex); + + mediaDisplays.insert(mediaDisplays.begin() + newIndex, std::move(draggedPtr)); + + resized(); + } + void filesDropped(const StringArray& files, int /*x*/, int /*y*/) override { for (String f : files) @@ -365,6 +546,156 @@ class TrackAreaWidget : public Component, } } + int getInsertIndexAtY(int y) + { + int trackSlotHeight = fixedTrackHeight + static_cast(2 * marginSize); + + if (isDraggingTrack && draggedTrack != nullptr) + { + int draggedIndex = getDraggedTrackIndex(); + + int draggedSlotTop = draggedIndex * trackSlotHeight; + + if (draggedIndex >= 0 && y > draggedSlotTop) + y += trackSlotHeight; + } + + int index = y / trackSlotHeight; + return jlimit(0, getNumTracks(), index); + } + + int getDraggedTrackIndex() const + { + if (draggedTrack == nullptr) return -1; + + for (int i = 0; i < (int)mediaDisplays.size(); i++) + { + if (mediaDisplays[i].get() == draggedTrack) + return i; + } + + return -1; + } + + void mouseDown(const MouseEvent& e) override + { + if (!isThumbnailWidget()) return; + + // Reset drag state at the start of every click + draggedTrack = nullptr; + isDraggingTrack = false; + + Component* clicked = e.eventComponent; + + MediaDisplayComponent* clickedDisplay = nullptr; + Component* c = clicked; + while (c != nullptr && c != this) + { + if (auto* md = dynamic_cast(c)) + { + clickedDisplay = md; + break; + } + c = c->getParentComponent(); + } + + if (clickedDisplay) + { + draggedTrack = clickedDisplay; + dragOriginIndex = getDraggedTrackIndex(); + + Point mouseInThis = e.getEventRelativeTo(this).getPosition(); + Point trackTopLeft = draggedTrack->getBounds().getTopLeft(); + dragClickOffset = mouseInThis - trackTopLeft; + } + } + + void mouseDrag(const MouseEvent& e) override + { + if (!isThumbnailWidget() || draggedTrack == nullptr) return; + + Point posInThis = e.getEventRelativeTo(this).getPosition(); + + if (!isDraggingTrack && e.getDistanceFromDragStart() > 5) + { + isDraggingTrack = true; + + // Takes a snapshot of the track at the moment dragging starts + if (dragOverlay != nullptr) + { + Image snapshot = draggedTrack->createComponentSnapshot(draggedTrack->getLocalBounds()); + ghostTrack.setImage(snapshot); + dragOverlay->startDrag(snapshot, dragClickOffset); + draggedTrack->setVisible(false); + } + } + + if (isDraggingTrack) + { + int newInsertIndex = -1; + bool wasOutside = isDraggingOutside; + + if (getLocalBounds().contains(posInThis)) + { + newInsertIndex = getInsertIndexAtY(posInThis.y); + isDraggingOutside = false; + } + else + { + newInsertIndex = dragOriginIndex; + isDraggingOutside = true; + } + + if (newInsertIndex != dragInsertIndex || isDraggingOutside != wasOutside) + { + dragInsertIndex = newInsertIndex; + resized(); + } + + // Converts the position to the overlay's coordinate space + if (dragOverlay != nullptr) + { + Point posInOverlay = dragOverlay->getLocalPoint(this, posInThis); + dragOverlay->updatePosition(posInOverlay); + } + } + } + + void mouseUp(const MouseEvent& e) override + { + if (!isThumbnailWidget()) return; + + // Tracks the release position of the mouse + Point releasePos = e.getEventRelativeTo(this).getPosition(); + + // Only reorders if the mouse was released inside the widget + if (isDraggingTrack && draggedTrack != nullptr && getLocalBounds().contains(releasePos)) + { + int currentIndex = getDraggedTrackIndex(); + + if (dragInsertIndex != currentIndex) + { + reorderTrack(draggedTrack, dragInsertIndex); + } + } + + // Restore the track's appearance and stop the overlay + if (draggedTrack != nullptr) + draggedTrack->setVisible(true); + if (dragOverlay != nullptr) + dragOverlay->stopDrag(); + + // Reset the drag state + draggedTrack = nullptr; + dragInsertIndex = -1; + dragOriginIndex = -1; + isDraggingTrack = false; + isDraggingOutside = false; + ghostTrack.setVisible(false); + + resized(); + } + const DisplayMode displayMode; const int fixedTrackHeight = 0; @@ -372,5 +703,15 @@ class TrackAreaWidget : public Component, int fixedTotalWidth = 0; int minTotalHeight = 0; + // For reordering tracks via dragging + MediaDisplayComponent* draggedTrack = nullptr; + DragOverlayComponent* dragOverlay = nullptr; + GhostTrackComponent ghostTrack; + int dragInsertIndex = -1; + int dragOriginIndex = -1; + bool isDraggingTrack = false; + bool isDraggingOutside = false; + Point dragClickOffset { 0, 0 }; + std::vector> mediaDisplays; }; diff --git a/website/content/pyharp_docs/example.md b/website/content/pyharp_docs/example.md index 97b14144..a0246d63 100644 --- a/website/content/pyharp_docs/example.md +++ b/website/content/pyharp_docs/example.md @@ -10,7 +10,13 @@ To get started, we'll install [the code](https://github.com/JusperLee/TIGER) nee ``` git clone https://github.com/JusperLee/TIGER.git -cd TIGER && pip install -r requirements.txt +cd TIGER +``` + +In the `requirements.txt`, remove `triton==3.1.0` to avoid dependency issues. Then pip install: + +``` +pip install -r requirements.txt ``` Next, we'll look at how TIGER processes audio files to separate speech. From [`TIGER/inference_speech.py`](https://github.com/JusperLee/TIGER/blob/main/inference_speech.py), we can see that TIGER requires audio files to be resampled to 16kHz and formatted as a PyTorch tensor of shape `[1, C, T]` where `C` is the number of channels and `T` is the number of audio samples; then, the model can be applied in one line of code: @@ -21,11 +27,12 @@ Next, we'll look at how TIGER processes audio files to separate speech. From [`T ests_speech = model(audio_input) # Expected output shape: [1, num_spk, T] ``` -Here, `num_spk` is the estimated number of speakers in the recording. For our example application, we'll only return audio of the first speaker. +Here, `num_spk` is the estimated number of speakers in the recording. The TIGER model sets `num_spk` to two. We are going to take both outputs from the model: ``` -# Select audio of first speaker -output = ests_speech[:, 0, :] # Expected output shape: [1, T] +# Select audio of each speaker independently +output_1 = ests_speech[:, 0, :] # Expected output shape: [1, T] +output_2 = ests_speech[:, 1, :] # Expected output shape: [1, T] ``` And that's it -- to deploy TIGER in HARP, we need to write a Gradio application for processing audio files like this, sprinkle in some PyHARP functions, and then run our application in the background to handle any audio sent by HARP. @@ -42,17 +49,28 @@ Now that we have a grip on how to run TIGER, let's revisit the [elements](/conte name="TIGER", description="The TIGER speech separation model of Xu et al. (https://arxiv.org/abs/2410.01469)", author="Your name", - tags=["example", "speech separation"], - midi_in=False, - midi_out=False + tags=["example", "speech separation"] ) ``` -* We need to define a list of __Gradio interactive components__ specifying the interface. In our case, because we only require audio input/output, we'll leave this list empty: +* We need to define a list of __Gradio interactive components__ specifying the interface. In our case, we need one audio input and two audio outputs: ``` # Define Gradio Components - components = [] - ``` -* We need to define a __processing function__ for handling file input and output. This function will load an audio file from a given path, format it for TIGER and run separation as discussed above, save the output to a new file, and return the path of this output file. + # Input + input_audio = gr.Audio( + label="Input Audio", + type="filepath", + sources=["upload", "microphone"] + ) + # Outputs + output_audio_1 = gr.Audio( + type="filepath", + label="Output Audio 1" + ) + output_audio_2 = gr.Audio( + type="filepath", + label="Output Audio 2" + ) +* Then we define a __processing function__ for handling file input and output. This function will load an audio file from a given path, format it for TIGER and run separation as discussed above, save the outputs to new files, and return the paths of the output files. Note that the parameters of the function correspond to the input components and the return values correspond to the output components. ``` # Define the processing function @torch.inference_mode() @@ -61,31 +79,49 @@ Now that we have a grip on how to run TIGER, let's revisit the [elements](/conte # By default, load audio as a Descript-AudioTools `AudioSignal` object sig = load_audio(input_audio_path) # Wraps a tensor of shape [1, C, T] - audio_input = sig.resample(16_000).audio_data # Tensor of shape [1, C, T] + audio_input = sig.resample(16_000).audio_data.to(device) # Tensor of shape [1, C, T] # Apply TIGER ests_speech = model(audio_input) # Expected output shape: [1, num_spk, T] - output = ests_speech[:, 0, :] # Expected output shape: [1, T] + output_1 = ests_speech[:, 0, :] # Expected output shape: [1, T] + output_2 = ests_speech[:, 1, :] - sig.audio_data = output - output_audio_path = save_audio(sig) + # Create two new audio + sig_1 = AudioSignal(output_1.cpu().numpy().astype("float32"), sample_rate=16000) + sig_2 = AudioSignal(output_2.cpu().numpy().astype("float32"), sample_rate=16000) - # Because this application does not apply labels to audio, return an empty - # label list - output_labels = LabelList() + # save to files + output_dir = Path("_outputs").resolve() + output_dir.mkdir(exist_ok=True, parents=True) - return output_audio_path, output_labels + output_audio_path_1 = output_dir / "sig1.wav" + output_audio_path_2 = output_dir / "sig2.wav" + + save_audio(sig_1, output_audio_path_1) + save_audio(sig_2, output_audio_path_2) + + return output_audio_path_1, output_audio_path_2 + ``` +* After we define the __model card__, __I/O components__ and the __processing function__, we aggregate them into an endpoint: + ``` + # Build Endpoint + app = build_endpoint( + model_card=model_card, + input_components = [input_audio], + output_components = [output_audio_1, output_audio_2], + process_fn=process_fn + ) ``` -Adding imports and some model-loading code, our final `app.py` should look like this: +Adding imports and model-loading code, our final `app.py` should look like this: ``` from pyharp import * +from audiotools import AudioSignal -import yaml import os +from pathlib import Path import gradio as gr -import torchaudio import torch import look2hear.models @@ -107,9 +143,7 @@ model_card = ModelCard( name="TIGER", description="The TIGER speech separation model of Xu et al. (https://arxiv.org/abs/2410.01469)", author="Your name", - tags=["example", "speech separation"], - midi_in=False, - midi_out=False + tags=["example", "speech separation"] ) # Define the processing function @@ -123,25 +157,47 @@ def process_fn(input_audio_path): # Apply TIGER ests_speech = model(audio_input) # Expected output shape: [1, num_spk, T] - output = ests_speech[:, :1, :] # Expected output shape: [1, 1, T] - - sig.audio_data = output - output_audio_path = save_audio(sig.cpu()) # Ensure our output signal is on CPU + output_1 = ests_speech[:, 0, :] # Expected output shape: [1, 1, T] + output_2 = ests_speech[:, 1, :] + + sig_1 = AudioSignal(output_1.cpu().numpy().astype("float32"), sample_rate=16000) + sig_2 = AudioSignal(output_2.cpu().numpy().astype("float32"), sample_rate=16000) + + output_dir = Path("_outputs").resolve() + output_dir.mkdir(exist_ok=True, parents=True) + + output_audio_path_1 = output_dir / "sig1.wav" + output_audio_path_2 = output_dir / "sig2.wav" - # Because this application does not apply labels to audio, return an empty - # label list - output_labels = LabelList() + save_audio(sig_1, output_audio_path_1) + save_audio(sig_2, output_audio_path_2) - return output_audio_path, output_labels + return output_audio_path_1, output_audio_path_2 # Build Gradio endpoint with gr.Blocks() as demo: # Define Gradio Components - components = [] - - app = build_endpoint(model_card=model_card, - components=components, - process_fn=process_fn) + input_audio = gr.Audio( + label="Input Audio", + type="filepath", + sources=["upload", "microphone"] + ) + output_audio_1 = gr.Audio( + type="filepath", + label="Output Audio 1" + ) + output_audio_2 = gr.Audio( + type="filepath", + label="Output Audio 2" + ) + # output_labels = gr.JSON(label="Separation") + + app = build_endpoint( + model_card=model_card, + input_components = [input_audio], + output_components = [output_audio_1, output_audio_2], # , output_labels], + process_fn=process_fn + ) demo.queue() demo.launch(share=True, show_error=True) @@ -149,7 +205,7 @@ demo.launch(share=True, show_error=True) ### Deploying Our App -For now, we'll put `app.py` inside the `TIGER/` directory to avoid further installation steps. Note that while PyHARP's utilities use [Descript-AudioTools](https://github.com/descriptinc/audiotools) under the hood to handle audio loading and saving, you're free to use whichever libraries you want as long as they can read and produce valid audio files. +For now, we'll put `app.py` inside the cloned `TIGER/` directory to avoid further installation steps. Note that while PyHARP's utilities use [Descript-AudioTools](https://github.com/descriptinc/audiotools) under the hood to handle audio loading and saving, you're free to use whichever libraries you want as long as they can read and produce valid audio files. With our application up and running, it's time to link it to HARP. Run: diff --git a/website/content/pyharp_docs/host.md b/website/content/pyharp_docs/host.md index 54ccb031..24e18bee 100644 --- a/website/content/pyharp_docs/host.md +++ b/website/content/pyharp_docs/host.md @@ -1,3 +1,121 @@ -# Hosting PyHARP Apps in the Cloud +# Hosting PyHARP Apps in the Cloud (HuggingFace Spaces) +Automatically generated Gradio endpoints are only available for a maximum of 72 hours. If you'd like to keep an endpoint active and share it with other users, you can use [HuggingFace Spaces](https://huggingface.co/docs/hub/spaces-overview) (similar hosting services are also available) to host your PyHARP app indefinitely. -(Coming soon) \ No newline at end of file +### Gradio Endpoints +This is the most convenient solution for hosting a PyHARP app. If you are a Hugging Face PRO subscriber, you can use [ZeroGPU](https://huggingface.co/docs/hub/spaces-zerogpu) to dynamically allocate GPU resources according to user requests without any additional charges. Non-PRO users can select from CPU environments or paid GPU options. + +1. Create a new [HuggingFace Space](https://huggingface.co/new-space). +2. Choose Gradio as the SDK along with the blank template. +3. Select the desired hardware option. +4. Create the space and clone the initialized repository locally: +```bash +git clone https://huggingface.co/spaces// +``` +5. Add your files to the repository, commit, then push to the `main` branch: +```bash +git add . +git commit -m "initial commit" +git push -u origin main +``` +6. Configure the following repository files: + - `README.md` + + Set __sdk_version__ to __5.28.0__, the recommended version of `gradio`. HARP may not work with the very latest or earlier versions. + + - `requirements.txt` + + Place all of the required **pip** packages in this file. It should also include the latest version of `pyharp`: + ``` + git+https://github.com/TEAMuP-dev/pyharp.git@v0.3.0 + ``` + Note that you do not have to include the `gradio` package in this file. + + - `packages.txt` + + Place any necessary **apt-get install** debian packages in this file. Some models may require these. + +### Docker Endpoints +Some models may have been developed with older versions of Python, and attempting to deploy them would lead to dependency issues. For example, the `numpy.float` and `numpy.int` deprecation in `numpy==1.24` breaks older packages such as `madmom`. Therefore, we may need to patch any corresponding source files during the deployment process. However, this is not supported by the highly-modularized Gradio SDK. + +Using Docker endpoints can help circumvent these issues. Docker will allow you to customize the deployment, which makes room for any necessary patches. Note however that ZeroGPU is not available for Docker spaces, meaning you must pay to use GPU resources with this option. + +1. Create a new [HuggingFace Space](https://huggingface.co/new-space). +2. Choose Docker as the SDK along with the blank template. +3. Select the desired hardware option. +4. Create the space and clone the initialized repository locally: +```bash +git clone https://huggingface.co/spaces// +``` +5. Add your files to the repository, commit, then push to the `main` branch: +```bash +git add . +git commit -m "initial commit" +git push -u origin main +``` +6. Configure the following repository files: + - `README.md` + + Set **app_port** to any valid ``. + + - `requirements.txt` + + Place all of the required **pip** packages in this file. It should also include the recommended version of `gradio` and the latest version of `pyharp`: + ``` + gradio==5.28.0 + git+https://github.com/TEAMuP-dev/pyharp.git@v0.3.0 + ``` + + - `packages.txt` + + Place any necessary **apt-get install** debian packages in this file. Some models may require these. + + - `Dockerfile` + + Installs the required **pip** and **apt-get** packages, and supports manual patching (_e.g._ of `madmom` in the following example): + ```Docker + FROM python:3.10-slim # Set python version + + WORKDIR /app + COPY packages.txt /app/packages.txt + + # System dependencies for building packages from source + RUN apt-get update + RUN xargs apt-get install -y --no-install-recommends < /app/packages.txt + RUN rm -rf /var/lib/apt/lists/* + + COPY requirements.txt /app/requirements.txt + # Disable build isolation so Cython installed in the environment is visible at build time + ENV PIP_NO_BUILD_ISOLATION=1 + RUN pip install --no-cache-dir -U pip wheel Cython + RUN pip install --no-cache-dir setuptools==80.9.0 + RUN pip install --no-cache-dir -r /app/requirements.txt + RUN pip install --no-cache-dir --no-build-isolation madmom + + # Patch madmom package + COPY patch_madmom.py /app/scripts/patch_madmom.py # Script to patch madmom source files + RUN python /app/scripts/patch_madmom.py + RUN python -c "import madmom; print('madmom import OK')" + + # Copy remainder of the repo + COPY . /app + + # HF Spaces route traffic to + # Gradio should listen accordingly + ENV PORT= # in README.md + EXPOSE + + # Run the app + CMD ["python", "app.py"] + ``` + +--- +Here are a few tips and best practices when dealing with HuggingFace Spaces: +- Spaces operate based off of the files in the `main` branch +- An [access token](https://huggingface.co/docs/hub/security-tokens) may be required to push commits to HuggingFace Spaces +- A `.gitignore` file should be added to maintain repository orderliness (_e.g._, to ignore `src/_outputs`) +- Pin versions for `numpy` (_e.g._, `<2`), `torch` (_e.g._, `==2.2.2`), and `torchaudio` (_e.g._, `==2.2.2`) to avoid unexpected build issues caused by the latest versions of these packages + +For more information, please refer to the offical document from Hugging Face about [Spaces](https://huggingface.co/docs/hub/spaces). + +## Accessing Within HARP +PyHARP apps deployed to HuggingFace will begin running at `https://huggingface.co/spaces//`. The shorthand `/` can also be used within HARP to reference the endpoint. The two deployment methods above produce identical UIs and functionality. diff --git a/website/content/pyharp_docs/install.md b/website/content/pyharp_docs/install.md index b888406f..4ff988ed 100644 --- a/website/content/pyharp_docs/install.md +++ b/website/content/pyharp_docs/install.md @@ -4,3 +4,5 @@ PyHARP can be installed using `pip`: - **Clone the PyHarp repository:** `git clone https://github.com/TEAMuP-dev/pyharp` - **Install:** `pip install -e pyharp && cd pyharp` + +Note that PyHARP depends on [Gradio](https://www.gradio.app/). We recommend installing `gradio==5.28.0`, which requires `python>=3.10`. diff --git a/website/content/pyharp_docs/overview.md b/website/content/pyharp_docs/overview.md index 76ee1b03..df900688 100644 --- a/website/content/pyharp_docs/overview.md +++ b/website/content/pyharp_docs/overview.md @@ -6,7 +6,7 @@ We can use a variety of [state-of-the-art models within HARP](/content/usage/mod Under the hood, HARP simply routes data (audio, MIDI, etc.) to Python applications for processing and renders the results in the editor. To make this work, we require (1) that these applications are built using [Gradio](https://www.gradio.app/), and (2) that these applications follow a simple set of specifications defined in the [PyHARP](https://github.com/TEAMuP-dev/pyharp) library. Crucially, PyHARP wraps a number of interactive components from Gradio (e.g. knobs, sliders, text boxes) to allow for rendering application-defined interfaces within the HARP editor. -Within these constraints, developers can build a wide variety of audio and MIDI-processing applications. For instance, HARP is compatible with any Python deep learning framework because it only requires that applications consume and produce audio files, MIDI files, or structrued labels -- it places no limits on how these objects are created or modified. +Within these constraints, developers can build a wide variety of audio and MIDI-processing applications. For instance, HARP is compatible with any Python deep learning framework because it only requires that applications consume and produce audio files, MIDI files, or structured labels -- it places no limits on how these objects are created or modified. ### Host Your Endpoint Locally or in the Cloud