diff --git a/.gitignore b/.gitignore index 567609b..67b3628 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build/ +build-tests/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index cb9f366..bf7bdab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,42 @@ target_link_libraries(Monoslicer juce::juce_recommended_lto_flags ) +# Drag and drop regression test. Off by default: a console app needs its own copy +# of the JUCE modules, so building it by default would roughly double build time. +# Enable with -DMONOSLICER_BUILD_TESTS=ON, or just use `make test`. +option(MONOSLICER_BUILD_TESTS "Build the test executables" OFF) + +if(MONOSLICER_BUILD_TESTS) + juce_add_console_app(MonoslicerDnDTest + PRODUCT_NAME "MonoslicerDnDTest" + ) + target_sources(MonoslicerDnDTest PRIVATE + Tests/DragAndDropTest.cpp + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/SliceManager.cpp + Source/WaveformDisplay.cpp + Source/PianoRollDisplay.cpp + ) + target_compile_definitions(MonoslicerDnDTest PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 + JucePlugin_Name="Monoslicer" + GIT_VERSION="${GIT_VERSION}" + # handleDragDrop() defers filesDropped() via MessageManager::callAsync, so the + # test needs to be able to pump the message loop to observe it. + JUCE_MODAL_LOOPS_PERMITTED=1 + ) + target_link_libraries(MonoslicerDnDTest + PRIVATE + juce::juce_audio_utils + juce::juce_audio_processors + juce::juce_audio_formats + juce::juce_dsp + ) +endif() + # Suppress JUCE / VST3 SDK warnings globally for all targets. if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fcolor-diagnostics") diff --git a/Makefile b/Makefile index 8aed04b..7b8a814 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,32 @@ JUCE_REPO := https://github.com/juce-framework/JUCE.git JUCE_DIR := lib/JUCE JUCE_TAG := 8.0.9 BUILD_DIR := build -CMAKE_ARGS := -DCMAKE_BUILD_TYPE=Release +TEST_BUILD_DIR := build-tests +BUILD_TYPE := Release +CMAKE_ARGS := -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) +UNAME_S := $(shell uname -s) + +# Platform-aware CPU count +ifeq ($(UNAME_S),Darwin) + JOBS := $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) +else + JOBS := $(shell nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4) +endif + +ARTEFACTS := $(BUILD_DIR)/Monoslicer_artefacts/$(BUILD_TYPE) +VST3 := $(ARTEFACTS)/VST3/Monoslicer.vst3 + +# AU only exists on macOS; Standalone is a .app on macOS, a plain binary elsewhere +ifeq ($(UNAME_S),Darwin) + AU := $(ARTEFACTS)/AU/Monoslicer.component + AU_DIR := ~/Library/Audio/Plug-Ins/Components + VST3_DIR := ~/Library/Audio/Plug-Ins/VST3 + FORMATS := AU and VST3 +else + AU := + VST3_DIR := ~/.vst3 + FORMATS := VST3 +endif C_RESET := \033[0m C_BOLD := \033[1m @@ -25,9 +50,9 @@ COLORIZE = sed -E "$$(printf ' \ s/(.*error:.*)$$/\033[1m\\1\033[0m/i \ ')" -.PHONY: all clean fetch configure build install +.PHONY: all clean fetch configure build install test -all: install +all: build $(JUCE_DIR)/CMakeLists.txt: $(call STEP,Fetching JUCE $(JUCE_TAG)) @@ -38,28 +63,42 @@ fetch: $(JUCE_DIR)/CMakeLists.txt configure: fetch $(call STEP,Configuring CMake) - $(call INFO,$(C_DIM)cmake -B build -S . -DCMAKE_BUILD_TYPE=Release$(C_RESET)) + $(call INFO,$(C_DIM)cmake -B $(BUILD_DIR) -S . $(CMAKE_ARGS)$(C_RESET)) cmake -B $(BUILD_DIR) -S . $(CMAKE_ARGS) | $(COLORIZE) build: configure $(call STEP,Building Monoslicer) - $(call INFO,$(C_DIM)$(C_YELLOW)$(shell getconf _NPROCESSORS_ONLN)$(C_RESET)$(C_DIM) parallel jobs$(C_RESET)) - cmake --build $(BUILD_DIR) -j$(shell getconf _NPROCESSORS_ONLN) | $(COLORIZE) + $(call INFO,$(C_DIM)$(C_YELLOW)$(JOBS)$(C_RESET)$(C_DIM) parallel jobs$(C_RESET)) + cmake --build $(BUILD_DIR) -j$(JOBS) | $(COLORIZE) install: build $(call STEP,Installing plugins) - $(call INFO,$(C_DIM)AU → ~/Library/Audio/Plug-Ins/Components$(C_RESET)) - mkdir -p ~/Library/Audio/Plug-Ins/Components - cp -R $(BUILD_DIR)/Monoslicer_artefacts/Release/AU/Monoslicer.component ~/Library/Audio/Plug-Ins/Components/ - $(call INFO,$(C_DIM)VST3 → ~/Library/Audio/Plug-Ins/VST3$(C_RESET)) - mkdir -p ~/Library/Audio/Plug-Ins/VST3 - cp -R $(BUILD_DIR)/Monoslicer_artefacts/Release/VST3/Monoslicer.vst3 ~/Library/Audio/Plug-Ins/VST3/ +ifeq ($(UNAME_S),Darwin) + $(call INFO,AU → $(AU_DIR)) + mkdir -p $(AU_DIR) + cp -R $(AU) $(AU_DIR)/ + $(call INFO,VST3 → $(VST3_DIR)) + mkdir -p $(VST3_DIR) + cp -R $(VST3) $(VST3_DIR)/ $(call STEP,Rescanning Audio Units) killall -9 AudioComponentRegistrar 2>/dev/null; true - @printf '\n $(C_GREEN)$(C_BOLD)✓ Done.$(C_RESET) $(C_GREEN)Monoslicer$(C_RESET)$(C_DIM) installed as AU and VST3$(C_RESET)\n\n' +else + $(call INFO,VST3 → $(VST3_DIR)) + mkdir -p $(VST3_DIR) + cp -R $(VST3) $(VST3_DIR)/ +endif + @printf '\n $(C_GREEN)$(C_BOLD)✓ Done.$(C_RESET) $(C_GREEN)Monoslicer$(C_RESET)$(C_DIM) installed as $(FORMATS)$(C_RESET)\n\n' clean: $(call STEP,Cleaning) - rm -rf $(BUILD_DIR) - $(call INFO,$(C_DIM)removed $(BUILD_DIR)/$(C_RESET)) + rm -rf $(BUILD_DIR) $(TEST_BUILD_DIR) + $(call INFO,$(C_DIM)removed $(BUILD_DIR)/ and $(TEST_BUILD_DIR)/$(C_RESET)) @printf ' $(C_GREEN)$(C_BOLD)✓ Clean.$(C_RESET)\n' + +# Tests live in their own build dir so `make` stays fast. +test: + $(call STEP,Building and running tests) + $(call INFO,$(C_DIM)separate build dir → $(TEST_BUILD_DIR)$(C_RESET)) + cmake -B $(TEST_BUILD_DIR) -S . $(CMAKE_ARGS) -DMONOSLICER_BUILD_TESTS=ON > /dev/null + cmake --build $(TEST_BUILD_DIR) -j$(JOBS) --target MonoslicerDnDTest > /dev/null + $(TEST_BUILD_DIR)/MonoslicerDnDTest_artefacts/$(BUILD_TYPE)/MonoslicerDnDTest diff --git a/README.md b/README.md index 55cfdcb..ee0a86a 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,15 @@ Load an audio file, slice it into regions (automatically via onset detection or Requires CMake 3.22+ and a C++17 compiler. ```sh -make # Fetch JUCE, configure, build, and install (AU + VST3) +make # Fetch JUCE, configure, build +make install # Build, then copy the plugins to your system directories ``` -Running `make` with no arguments will fetch JUCE, build Monoslicer in Release mode, copy the AU and VST3 plugins to your system directories, and trigger an Audio Unit rescan. +`make` only builds — installing to system plugin folders is opt-in via `make install`. + +On macOS `make install` copies the AU to `~/Library/Audio/Plug-Ins/Components` and the VST3 +to `~/Library/Audio/Plug-Ins/VST3`, then triggers an Audio Unit rescan. On Linux it copies the +VST3 bundle to `~/.vst3`. Individual targets are also available: @@ -39,13 +44,24 @@ Individual targets are also available: make fetch # Clone JUCE 8.0.9 make configure # CMake configure make build # Build (Release) -make install # Install AU + VST3 to system directories +make install # Build, then install (AU + VST3 on macOS, VST3 on Linux) ``` +### Tests + +```sh +make test +``` + +Currently one suite: `Tests/DragAndDropTest.cpp` verifies that dropping an audio file +onto the editor is discovered and loaded. It uses a real native peer, so it exercises +the same code path a host does. Tests build in a separate `build-tests/` directory, so +`make` is unaffected. + ### Clean ```sh -make clean +make clean # removes build/ and build-tests/ ``` ## Plugin Formats diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 3f42c70..4018426 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -552,33 +552,18 @@ void MonoslicerEditor::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) } } -// [EDITOR DIAG] temporary -static void ediag(const char* what, const juce::StringArray& files, int accept) -{ - FILE* f = fopen("/tmp/monoslicer_drag.log", "a"); - if (f == nullptr) return; - fprintf(f, "[editor] %s accept=%d files=%d", what, accept, (int)files.size()); - for (const auto& x : files) fprintf(f, " \"%s\"", x.toUTF8().getAddress()); - fprintf(f, "\n"); - fclose(f); -} - bool MonoslicerEditor::isInterestedInFileDrag(const juce::StringArray& files) { for (const auto& f : files) if (f.endsWithIgnoreCase(".wav") || f.endsWithIgnoreCase(".aiff") || f.endsWithIgnoreCase(".flac") || f.endsWithIgnoreCase(".ogg")) - { - ediag("isInterested", files, 1); return true; - } - ediag("isInterested", files, 0); + return false; } void MonoslicerEditor::fileDragEnter(const juce::StringArray& files, int, int) { - ediag("enter", files, isInterestedInFileDrag(files) ? 1 : 0); if (isInterestedInFileDrag(files)) { dropOverlay.setVisible(true); @@ -595,13 +580,11 @@ void MonoslicerEditor::fileDragMove(const juce::StringArray& files, int, int) void MonoslicerEditor::fileDragExit(const juce::StringArray&) { - ediag("exit", juce::StringArray(), 0); dropOverlay.setVisible(false); } void MonoslicerEditor::filesDropped(const juce::StringArray& files, int, int) { - ediag("drop", files, 1); dropOverlay.setVisible(false); for (const auto& f : files) @@ -611,7 +594,7 @@ void MonoslicerEditor::filesDropped(const juce::StringArray& files, int, int) { processorRef.loadAudioFile(file); waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); - + break; } } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index c53068a..42fc3b3 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -101,7 +101,7 @@ private: }; class MonoslicerEditor : public juce::AudioProcessorEditor, - private juce::FileDragAndDropTarget, + public juce::FileDragAndDropTarget, private juce::Button::Listener, private juce::ComboBox::Listener, private juce::Timer diff --git a/Tests/DragAndDropTest.cpp b/Tests/DragAndDropTest.cpp new file mode 100644 index 0000000..e80bac8 --- /dev/null +++ b/Tests/DragAndDropTest.cpp @@ -0,0 +1,215 @@ +// Regression test: dropping an audio file onto the plugin editor. +// +// Drag and drop discovery is driven entirely by RTTI. JUCE's +// ComponentPeer::DragHelpers::findDragAndDropTarget walks up the component +// chain and does dynamic_cast on each candidate, so the +// interface only has to be visible as a *public* base. Inheriting it privately +// makes the cast return null and the drop handlers are never called - silently, +// with no warning at build time. +// +// Build and run: +// cmake --build build --target MonoslicerDnDTest +// ./build/MonoslicerDnDTest_artefacts/Release/MonoslicerDnDTest + +#include "../Source/PluginEditor.h" +#include "../Source/PluginProcessor.h" + +#include + +namespace +{ +int failures = 0; + +void check (bool condition, const juce::String& what) +{ + std::cout << (condition ? " \033[32mPASS\033[0m " : " \033[1;31mFAIL\033[0m ") + << what << std::endl; + + if (!condition) + ++failures; +} + +// Mirrors JUCE's DragHelpers::findDragAndDropTarget (juce_ComponentPeer.cpp). +// Returns the winning *component* rather than a FileDragAndDropTarget* so that +// this compiles - and fails at runtime rather than at build time - even when the +// interface is inherited privately. That is precisely the regression being +// guarded against: JUCE casts from Component*, which is always legal. +juce::Component* findDragTargetComponent (juce::Component* c, + const juce::StringArray& files) +{ + for (; c != nullptr; c = c->getParentComponent()) + { + if (auto* target = dynamic_cast (c)) + if (target->isInterestedInFileDrag (files)) + return c; + } + + return nullptr; +} + +// Mirrors the host structure: JUCE's VST3 wrapper puts the plugin editor inside a +// ContentWrapperComponent and adds *that* to the desktop +// (juce_audio_plugin_client_VST3.cpp:1996). The peer therefore knows nothing about +// the editor directly - it can only reach it by walking the component chain. +struct HostWrapper final : juce::Component +{ + explicit HostWrapper (juce::Component& child) + { + addAndMakeVisible (child); + setSize (child.getWidth(), child.getHeight()); + } + + void paint (juce::Graphics& g) override { g.fillAll (juce::Colours::black); } +}; + +void dumpChain (juce::Component* c) +{ + std::cout << " chain:"; + + for (; c != nullptr; c = c->getParentComponent()) + std::cout << " \"" << juce::String (typeid (*c).name()).upToLastOccurrenceOf ("::", true, false) + << "\"" + << (c->isVisible() ? "" : "(hidden)") + << (dynamic_cast (c) != nullptr ? "[fdnd]" : "") + << " " << c->getWidth() << "x" << c->getHeight(); + + std::cout << std::endl; +} + +// The editor scales itself via setTransform() in resized(); at the base size +// that transform is the identity, which keeps pointer maths straightforward. +constexpr int baseWidth = 920; +constexpr int baseHeight = 606; + +juce::File writeTestWav() +{ + auto file = juce::File::getSpecialLocation (juce::File::tempDirectory) + .getChildFile ("monoslicer_dnd_test.wav"); + + constexpr double sampleRate = 44100.0; + constexpr int numSamples = 4096; + + juce::AudioBuffer buffer (1, numSamples); + + for (int i = 0; i < numSamples; ++i) + buffer.setSample (0, i, std::sin (i / 20.0f)); + + juce::WavAudioFormat format; + std::unique_ptr out (file.createOutputStream()); + + if (out == nullptr) + { + std::cout << " \033[1;31mFAIL\033[0m could not create " << file.getFullPathName() << std::endl; + ++failures; + return {}; + } + + { + // The writer has to be destroyed before the stream it writes to. + std::unique_ptr writer ( + format.createWriterFor (out.get(), sampleRate, 1, 16, {}, 0)); + + if (writer == nullptr) + { + std::cout << " \033[1;31mFAIL\033[0m could not create a WAV writer" << std::endl; + ++failures; + return {}; + } + + writer->writeFromAudioSampleBuffer (buffer, 0, numSamples); + } + + out.release(); // commits and closes the file + + if (file.getSize() == 0) + { + std::cout << " \033[1;31mFAIL\033[0m test WAV was written empty" << std::endl; + ++failures; + return {}; + } + + return file; +} +} + +int main() +{ + juce::ScopedJuceInitialiser_GUI juceInitialiser; + + std::cout << "\n\033[1mMonoslicer - file drag and drop\033[0m\n\n"; + + const auto wavFile = writeTestWav(); + + if (!wavFile.existsAsFile()) + return 1; + + const auto path = wavFile.getFullPathName(); + const juce::Point pointerPos (400, 300); + +MonoslicerProcessor processor; + MonoslicerEditor editor (processor); + editor.setSize (baseWidth, baseHeight); + + HostWrapper wrapper (editor); + wrapper.addToDesktop (0); + wrapper.setVisible (true); + + auto* peer = wrapper.getPeer(); + + std::cout << "\n\033[1mRTTI visibility\033[0m\n"; + check (dynamic_cast (static_cast (&editor)) != nullptr, + "editor exposes FileDragAndDropTarget as a public base"); + + std::cout << "\n\033[1mAcceptance\033[0m\n"; + check (editor.isInterestedInFileDrag ({ path }), "accepts a .wav file"); + check (editor.isInterestedInFileDrag ({ "/tmp/notes.txt" }) == false, "rejects a .txt file"); + + std::cout << "\n\033[1mTarget discovery (as used by JUCE)\033[0m\n"; + auto* underPointer = peer != nullptr ? peer->getComponent().getComponentAt (pointerPos) + : wrapper.getComponentAt (pointerPos); + + if (underPointer == nullptr) + std::cout << " (no component under the pointer)" << std::endl; + + dumpChain (underPointer); + + check (findDragTargetComponent (underPointer, { path }) == static_cast (&editor), + "lookup from the component under the pointer finds the editor"); + check (findDragTargetComponent (underPointer, { "/tmp/notes.txt" }) == nullptr, + "lookup for an unsupported file finds no target"); + + std::cout << "\n\033[1mEnd-to-end drop via a real native peer\033[0m\n"; + + if (peer != nullptr) + { + juce::ComponentPeer::DragInfo info; + info.files.add (path); + info.position = pointerPos; + + check (peer->handleDragMove (info), "peer accepts the drag (handleDragMove)"); + check (peer->handleDragDrop (info), "peer accepts the drop (handleDragDrop)"); + + // handleDragDrop() defers filesDropped() through MessageManager::callAsync + juce::MessageManager::getInstance()->runDispatchLoopUntil (250); + + check (processor.getSampleBuffer().getNumSamples() == 4096, + "the dropped sample was decoded into the processor"); + check (processor.getCurrentFile() == wavFile, + "the dropped file is recorded as the current file"); +// Note: dropping does not auto-slice - that is a separate explicit action, + // and matches the behaviour of the "Load" button. + check (processor.getSampleRateLoaded() == 44100.0, + "the reader set the sample rate of the dropped file"); + } + else + { + std::cout << " \033[33mSKIP\033[0m no native peer available (headless session)" + << std::endl; + } + + std::cout << "\n" << (failures == 0 ? "\033[32;1mAll checks passed.\033[0m" + : juce::String (failures) + " check(s) failed.") + << "\n\n"; + + return failures == 0 ? 0 : 1; +} \ No newline at end of file