diff --git a/Source/CommonPluginEditor.cpp b/Source/CommonPluginEditor.cpp index 30c3ab2e..dde900a5 100644 --- a/Source/CommonPluginEditor.cpp +++ b/Source/CommonPluginEditor.cpp @@ -1,6 +1,6 @@ #include "CommonPluginProcessor.h" #include "CommonPluginEditor.h" -#include +#include "CustomStandaloneFilterWindow.h" CommonPluginEditor::CommonPluginEditor(CommonAudioProcessor& p, juce::String appName, juce::String projectFileType, int defaultWidth, int defaultHeight) : AudioProcessorEditor(&p), audioProcessor(p), appName(appName), projectFileType(projectFileType) diff --git a/Source/CommonPluginEditor.h b/Source/CommonPluginEditor.h index 5ab1a0e3..5d0b8be5 100644 --- a/Source/CommonPluginEditor.h +++ b/Source/CommonPluginEditor.h @@ -52,9 +52,9 @@ public: #endif #if SOSCI_FEATURES - int VISUALISER_SETTINGS_HEIGHT = 1100; + int VISUALISER_SETTINGS_HEIGHT = 1250; #else - int VISUALISER_SETTINGS_HEIGHT = 700; + int VISUALISER_SETTINGS_HEIGHT = 800; #endif VisualiserSettings visualiserSettings = VisualiserSettings(audioProcessor.visualiserParameters, 3); diff --git a/Source/CustomStandalone.cpp b/Source/CustomStandalone.cpp new file mode 100644 index 00000000..be5045e7 --- /dev/null +++ b/Source/CustomStandalone.cpp @@ -0,0 +1,247 @@ +/* + ============================================================================== + + This file is part of the JUCE framework. + Copyright (c) Raw Material Software Limited + + JUCE is an open source framework subject to commercial or open source + licensing. + + By downloading, installing, or using the JUCE framework, or combining the + JUCE framework with any other source code, object code, content or any other + copyrightable work, you agree to the terms of the JUCE End User Licence + Agreement, and all incorporated terms including the JUCE Privacy Policy and + the JUCE Website Terms of Service, as applicable, which will bind you. If you + do not agree to the terms of these agreements, we will not license the JUCE + framework to you, and you must discontinue the installation or download + process and cease use of the JUCE framework. + + JUCE End User Licence Agreement: https://juce.com/legal/juce-8-licence/ + JUCE Privacy Policy: https://juce.com/juce-privacy-policy + JUCE Website Terms of Service: https://juce.com/juce-website-terms-of-service/ + + Or: + + You may also use this code under the terms of the AGPLv3: + https://www.gnu.org/licenses/agpl-3.0.en.html + + THE JUCE FRAMEWORK IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL + WARRANTIES, WHETHER EXPRESSED OR IMPLIED, INCLUDING WARRANTY OF + MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE, ARE DISCLAIMED. + + ============================================================================== +*/ + +#include + +#if JucePlugin_Build_Standalone + +#if ! JUCE_MODULE_AVAILABLE_juce_audio_utils + #error To compile AudioUnitv3 and/or Standalone plug-ins, you need to add the juce_audio_utils and juce_audio_devices modules! +#endif + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +// You can set this flag in your build if you need to specify a different +// standalone JUCEApplication class for your app to use. If you don't +// set it then by default we'll just create a simple one as below. +#if JUCE_USE_CUSTOM_PLUGIN_STANDALONE_APP + +// #include +#include "CustomStandaloneFilterWindow.h" + +namespace juce +{ + +//============================================================================== +class CustomStandaloneFilterApp final : public JUCEApplication +{ +public: + CustomStandaloneFilterApp() + { + PropertiesFile::Options options; + + options.applicationName = CharPointer_UTF8 (JucePlugin_Name); + options.filenameSuffix = ".settings"; + options.osxLibrarySubFolder = "Application Support"; + #if JUCE_LINUX || JUCE_BSD + options.folderName = "~/.config"; + #else + options.folderName = ""; + #endif + + appProperties.setStorageParameters (options); + } + + const String getApplicationName() override { return CharPointer_UTF8 (JucePlugin_Name); } + const String getApplicationVersion() override { return JucePlugin_VersionString; } + bool moreThanOneInstanceAllowed() override { return true; } + void anotherInstanceStarted (const String& commandLine) override + { + if (mainWindow != nullptr) + { + mainWindow->toFront(true); + mainWindow->handleCommandLine(commandLine); + } + } + + virtual StandaloneFilterWindow* createWindow() + { + if (Desktop::getInstance().getDisplays().displays.isEmpty()) + { + // No displays are available, so no window will be created! + jassertfalse; + return nullptr; + } + + return new StandaloneFilterWindow (getApplicationName(), + LookAndFeel::getDefaultLookAndFeel().findColour (ResizableWindow::backgroundColourId), + createPluginHolder()); + } + + virtual std::unique_ptr createPluginHolder() + { + constexpr auto autoOpenMidiDevices = + #if (JUCE_ANDROID || JUCE_IOS) && ! JUCE_DONT_AUTO_OPEN_MIDI_DEVICES_ON_MOBILE + true; + #else + false; + #endif + + + #ifdef JucePlugin_PreferredChannelConfigurations + constexpr StandalonePluginHolder::PluginInOuts channels[] { JucePlugin_PreferredChannelConfigurations }; + const Array channelConfig (channels, juce::numElementsInArray (channels)); + #else + const Array channelConfig; + #endif + + return std::make_unique (appProperties.getUserSettings(), + false, + String{}, + nullptr, + channelConfig, + autoOpenMidiDevices); + } + + //============================================================================== + void initialise (const String& commandLine) override + { + mainWindow = rawToUniquePtr (createWindow()); + + if (mainWindow != nullptr) + { + #if JUCE_STANDALONE_FILTER_WINDOW_USE_KIOSK_MODE + Desktop::getInstance().setKioskModeComponent (mainWindow.get(), false); + #endif + + mainWindow->setVisible (true); + } + else + { + pluginHolder = createPluginHolder(); + mainWindow->handleCommandLine(commandLine); + } + } + + void shutdown() override + { + pluginHolder = nullptr; + mainWindow = nullptr; + appProperties.saveIfNeeded(); + } + + //============================================================================== + void systemRequestedQuit() override + { + if (pluginHolder != nullptr) + pluginHolder->savePluginState(); + + if (mainWindow != nullptr) + mainWindow->pluginHolder->savePluginState(); + + if (ModalComponentManager::getInstance()->cancelAllModalComponents()) + { + Timer::callAfterDelay (100, []() + { + if (auto app = JUCEApplicationBase::getInstance()) + app->systemRequestedQuit(); + }); + } + else + { + quit(); + } + } + +protected: + ApplicationProperties appProperties; + std::unique_ptr mainWindow; + +private: + std::unique_ptr pluginHolder; +}; + +} // namespace juce + +#if JucePlugin_Build_Standalone && JUCE_IOS + +JUCE_BEGIN_IGNORE_WARNINGS_GCC_LIKE ("-Wmissing-prototypes") + +using namespace juce; + +bool JUCE_CALLTYPE juce_isInterAppAudioConnected() +{ + if (auto holder = StandalonePluginHolder::getInstance()) + return holder->isInterAppAudioConnected(); + + return false; +} + +void JUCE_CALLTYPE juce_switchToHostApplication() +{ + if (auto holder = StandalonePluginHolder::getInstance()) + holder->switchToHostApplication(); +} + +Image JUCE_CALLTYPE juce_getIAAHostIcon (int size) +{ + if (auto holder = StandalonePluginHolder::getInstance()) + return holder->getIAAHostIcon (size); + + return Image(); +} + +JUCE_END_IGNORE_WARNINGS_GCC_LIKE + +#endif + +#endif + +#if JUCE_USE_CUSTOM_PLUGIN_STANDALONE_APP + // extern juce::JUCEApplicationBase* juce_CreateApplication(); + JUCE_CREATE_APPLICATION_DEFINE (juce::CustomStandaloneFilterApp) + + #if JUCE_IOS + extern void* juce_GetIOSCustomDelegateClass(); + #endif + +#else + JUCE_CREATE_APPLICATION_DEFINE (juce::CustomStandaloneFilterApp) +#endif + +#if ! JUCE_USE_CUSTOM_PLUGIN_STANDALONE_ENTRYPOINT + // JUCE_MAIN_FUNCTION_DEFINITION +#endif + +#endif diff --git a/Source/CustomStandaloneFilterWindow.h b/Source/CustomStandaloneFilterWindow.h new file mode 100644 index 00000000..02cd0993 --- /dev/null +++ b/Source/CustomStandaloneFilterWindow.h @@ -0,0 +1,1194 @@ +/* + ============================================================================== + + This file is part of the JUCE framework. + Copyright (c) Raw Material Software Limited + + JUCE is an open source framework subject to commercial or open source + licensing. + + By downloading, installing, or using the JUCE framework, or combining the + JUCE framework with any other source code, object code, content or any other + copyrightable work, you agree to the terms of the JUCE End User Licence + Agreement, and all incorporated terms including the JUCE Privacy Policy and + the JUCE Website Terms of Service, as applicable, which will bind you. If you + do not agree to the terms of these agreements, we will not license the JUCE + framework to you, and you must discontinue the installation or download + process and cease use of the JUCE framework. + + JUCE End User Licence Agreement: https://juce.com/legal/juce-8-licence/ + JUCE Privacy Policy: https://juce.com/juce-privacy-policy + JUCE Website Terms of Service: https://juce.com/juce-website-terms-of-service/ + + Or: + + You may also use this code under the terms of the AGPLv3: + https://www.gnu.org/licenses/agpl-3.0.en.html + + THE JUCE FRAMEWORK IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL + WARRANTIES, WHETHER EXPRESSED OR IMPLIED, INCLUDING WARRANTY OF + MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE, ARE DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#ifndef DOXYGEN + #include +#endif + +namespace juce +{ + +//============================================================================== +/** + An object that creates and plays a standalone instance of an AudioProcessor. + + The object will create your processor using the same createPluginFilter() + function that the other plugin wrappers use, and will run it through the + computer's audio/MIDI devices using AudioDeviceManager and AudioProcessorPlayer. + + @tags{Audio} +*/ +class StandalonePluginHolder : private AudioIODeviceCallback, + private Timer, + private Value::Listener +{ +public: + //============================================================================== + /** Structure used for the number of inputs and outputs. */ + struct PluginInOuts { short numIns, numOuts; }; + + //============================================================================== + /** Creates an instance of the default plugin. + + The settings object can be a PropertySet that the class should use to store its + settings - the takeOwnershipOfSettings indicates whether this object will delete + the settings automatically when no longer needed. The settings can also be nullptr. + + A default device name can be passed in. + + Preferably a complete setup options object can be used, which takes precedence over + the preferredDefaultDeviceName and allows you to select the input & output device names, + sample rate, buffer size etc. + + In all instances, the settingsToUse will take precedence over the "preferred" options if not null. + */ + StandalonePluginHolder (PropertySet* settingsToUse, + bool takeOwnershipOfSettings = true, + const String& preferredDefaultDeviceName = String(), + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions = nullptr, + const Array& channels = Array(), + #if JUCE_ANDROID || JUCE_IOS + bool shouldAutoOpenMidiDevices = true + #else + bool shouldAutoOpenMidiDevices = false + #endif + ) + + : settings (settingsToUse, takeOwnershipOfSettings), + channelConfiguration (channels), + autoOpenMidiDevices (shouldAutoOpenMidiDevices) + { + // Only one StandalonePluginHolder may be created at a time + jassert (currentInstance == nullptr); + currentInstance = this; + + shouldMuteInput.addListener (this); + shouldMuteInput = ! isInterAppAudioConnected(); + + handleCreatePlugin(); + + auto inChannels = (channelConfiguration.size() > 0 ? channelConfiguration[0].numIns + : processor->getMainBusNumInputChannels()); + + if (preferredSetupOptions != nullptr) + options.reset (new AudioDeviceManager::AudioDeviceSetup (*preferredSetupOptions)); + + auto audioInputRequired = (inChannels > 0); + + if (audioInputRequired && RuntimePermissions::isRequired (RuntimePermissions::recordAudio) + && ! RuntimePermissions::isGranted (RuntimePermissions::recordAudio)) + RuntimePermissions::request (RuntimePermissions::recordAudio, + [this, preferredDefaultDeviceName] (bool granted) { init (granted, preferredDefaultDeviceName); }); + else + init (audioInputRequired, preferredDefaultDeviceName); + } + + void init (bool enableAudioInput, const String& preferredDefaultDeviceName) + { + setupAudioDevices (enableAudioInput, preferredDefaultDeviceName, options.get()); + reloadPluginState(); + startPlaying(); + + if (autoOpenMidiDevices) + startTimer (500); + } + + ~StandalonePluginHolder() override + { + stopTimer(); + + handleDeletePlugin(); + shutDownAudioDevices(); + + currentInstance = nullptr; + } + + //============================================================================== + virtual void createPlugin() + { + handleCreatePlugin(); + } + + virtual void deletePlugin() + { + handleDeletePlugin(); + } + + int getNumInputChannels() const + { + if (processor == nullptr) + return 0; + + return (channelConfiguration.size() > 0 ? channelConfiguration[0].numIns + : processor->getMainBusNumInputChannels()); + } + + int getNumOutputChannels() const + { + if (processor == nullptr) + return 0; + + return (channelConfiguration.size() > 0 ? channelConfiguration[0].numOuts + : processor->getMainBusNumOutputChannels()); + } + + static String getFilePatterns (const String& fileSuffix) + { + if (fileSuffix.isEmpty()) + return {}; + + return (fileSuffix.startsWithChar ('.') ? "*" : "*.") + fileSuffix; + } + + //============================================================================== + Value& getMuteInputValue() { return shouldMuteInput; } + bool getProcessorHasPotentialFeedbackLoop() const { return processorHasPotentialFeedbackLoop; } + void valueChanged (Value& value) override { muteInput = (bool) value.getValue(); } + + //============================================================================== + File getLastFile() const + { + File f; + + if (settings != nullptr) + f = File (settings->getValue ("lastStateFile")); + + if (f == File()) + f = File::getSpecialLocation (File::userDocumentsDirectory); + + return f; + } + + void setLastFile (const FileChooser& fc) + { + if (settings != nullptr) + settings->setValue ("lastStateFile", fc.getResult().getFullPathName()); + } + + /** Pops up a dialog letting the user save the processor's state to a file. */ + void askUserToSaveState (const String& fileSuffix = String()) + { + stateFileChooser = std::make_unique (TRANS ("Save current state"), + getLastFile(), + getFilePatterns (fileSuffix)); + auto flags = FileBrowserComponent::saveMode + | FileBrowserComponent::canSelectFiles + | FileBrowserComponent::warnAboutOverwriting; + + stateFileChooser->launchAsync (flags, [this] (const FileChooser& fc) + { + if (fc.getResult() == File{}) + return; + + setLastFile (fc); + + MemoryBlock data; + processor->getStateInformation (data); + + if (! fc.getResult().replaceWithData (data.getData(), data.getSize())) + { + auto opts = MessageBoxOptions::makeOptionsOk (AlertWindow::WarningIcon, + TRANS ("Error whilst saving"), + TRANS ("Couldn't write to the specified file!")); + messageBox = AlertWindow::showScopedAsync (opts, nullptr); + } + }); + } + + /** Pops up a dialog letting the user re-load the processor's state from a file. */ + void askUserToLoadState (const String& fileSuffix = String()) + { + stateFileChooser = std::make_unique (TRANS ("Load a saved state"), + getLastFile(), + getFilePatterns (fileSuffix)); + auto flags = FileBrowserComponent::openMode + | FileBrowserComponent::canSelectFiles; + + stateFileChooser->launchAsync (flags, [this] (const FileChooser& fc) + { + if (fc.getResult() == File{}) + return; + + setLastFile (fc); + + MemoryBlock data; + + if (fc.getResult().loadFileAsData (data)) + { + processor->setStateInformation (data.getData(), (int) data.getSize()); + } + else + { + auto opts = MessageBoxOptions::makeOptionsOk (AlertWindow::WarningIcon, + TRANS ("Error whilst loading"), + TRANS ("Couldn't read from the specified file!")); + messageBox = AlertWindow::showScopedAsync (opts, nullptr); + } + }); + } + + //============================================================================== + void startPlaying() + { + player.setProcessor (processor.get()); + + #if JucePlugin_Enable_IAA && JUCE_IOS + if (auto device = dynamic_cast (deviceManager.getCurrentAudioDevice())) + { + processor->setPlayHead (device->getAudioPlayHead()); + device->setMidiMessageCollector (&player.getMidiMessageCollector()); + } + #endif + } + + void stopPlaying() + { + player.setProcessor (nullptr); + } + + //============================================================================== + /** Shows an audio properties dialog box modally. */ + void showAudioSettingsDialog() + { + DialogWindow::LaunchOptions o; + + int maxNumInputs = 0, maxNumOutputs = 0; + + if (channelConfiguration.size() > 0) + { + auto& defaultConfig = channelConfiguration.getReference (0); + + maxNumInputs = jmax (0, (int) defaultConfig.numIns); + maxNumOutputs = jmax (0, (int) defaultConfig.numOuts); + } + + if (auto* bus = processor->getBus (true, 0)) + maxNumInputs = jmax (0, bus->getDefaultLayout().size()); + + if (auto* bus = processor->getBus (false, 0)) + maxNumOutputs = jmax (0, bus->getDefaultLayout().size()); + + auto content = std::make_unique (*this, deviceManager, maxNumInputs, maxNumOutputs); + content->setSize (500, 550); + content->setToRecommendedSize(); + + o.content.setOwned (content.release()); + + o.dialogTitle = TRANS ("Audio/MIDI Settings"); + o.dialogBackgroundColour = o.content->getLookAndFeel().findColour (ResizableWindow::backgroundColourId); + o.escapeKeyTriggersCloseButton = true; + o.useNativeTitleBar = true; + o.resizable = false; + + o.launchAsync(); + } + + void saveAudioDeviceState() + { + if (settings != nullptr) + { + auto xml = deviceManager.createStateXml(); + + settings->setValue ("audioSetup", xml.get()); + + #if ! (JUCE_IOS || JUCE_ANDROID) + settings->setValue ("shouldMuteInput", (bool) shouldMuteInput.getValue()); + #endif + } + } + + void reloadAudioDeviceState (bool enableAudioInput, + const String& preferredDefaultDeviceName, + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions) + { + std::unique_ptr savedState; + + if (settings != nullptr) + { + savedState = settings->getXmlValue ("audioSetup"); + + #if ! (JUCE_IOS || JUCE_ANDROID) + shouldMuteInput.setValue (settings->getBoolValue ("shouldMuteInput", true)); + #endif + } + + auto inputChannels = getNumInputChannels(); + auto outputChannels = getNumOutputChannels(); + + if (inputChannels == 0 && outputChannels == 0 && processor->isMidiEffect()) + { + // add a dummy output channel for MIDI effect plug-ins so they can receive audio callbacks + outputChannels = 1; + } + + deviceManager.initialise (enableAudioInput ? inputChannels : 0, + outputChannels, + savedState.get(), + true, + preferredDefaultDeviceName, + preferredSetupOptions); + } + + //============================================================================== + void savePluginState() + { + if (settings != nullptr && processor != nullptr) + { + MemoryBlock data; + processor->getStateInformation (data); + + settings->setValue ("filterState", data.toBase64Encoding()); + } + } + + void reloadPluginState() + { + if (settings != nullptr) + { + MemoryBlock data; + + if (data.fromBase64Encoding (settings->getValue ("filterState")) && data.getSize() > 0) + processor->setStateInformation (data.getData(), (int) data.getSize()); + } + } + + //============================================================================== + void switchToHostApplication() + { + #if JUCE_IOS + if (auto device = dynamic_cast (deviceManager.getCurrentAudioDevice())) + device->switchApplication(); + #endif + } + + bool isInterAppAudioConnected() + { + #if JUCE_IOS + if (auto device = dynamic_cast (deviceManager.getCurrentAudioDevice())) + return device->isInterAppAudioConnected(); + #endif + + return false; + } + + Image getIAAHostIcon ([[maybe_unused]] int size) + { + #if JUCE_IOS && JucePlugin_Enable_IAA + if (auto device = dynamic_cast (deviceManager.getCurrentAudioDevice())) + return device->getIcon (size); + #else + #endif + + return {}; + } + + static StandalonePluginHolder* getInstance() + { + return currentInstance; + } + + //============================================================================== + OptionalScopedPointer settings; + std::unique_ptr processor; + AudioDeviceManager deviceManager; + AudioProcessorPlayer player; + Array channelConfiguration; + + // avoid feedback loop by default + bool processorHasPotentialFeedbackLoop = true; + std::atomic muteInput { true }; + Value shouldMuteInput; + AudioBuffer emptyBuffer; + bool autoOpenMidiDevices; + + std::unique_ptr options; + Array lastMidiDevices; + + std::unique_ptr stateFileChooser; + ScopedMessageBox messageBox; + +private: + inline static StandalonePluginHolder* currentInstance = nullptr; + + //============================================================================== + void handleCreatePlugin() + { + processor = createPluginFilterOfType (AudioProcessor::wrapperType_Standalone); + processor->disableNonMainBuses(); + processor->setRateAndBufferSizeDetails (44100, 512); + + processorHasPotentialFeedbackLoop = (getNumInputChannels() > 0 && getNumOutputChannels() > 0); + } + + void handleDeletePlugin() + { + stopPlaying(); + processor = nullptr; + } + + //============================================================================== + /* This class can be used to ensure that audio callbacks use buffers with a + predictable maximum size. + + On some platforms (such as iOS 10), the expected buffer size reported in + audioDeviceAboutToStart may be smaller than the blocks passed to + audioDeviceIOCallbackWithContext. This can lead to out-of-bounds reads if the render + callback depends on additional buffers which were initialised using the + smaller size. + + As a workaround, this class will ensure that the render callback will + only ever be called with a block with a length less than or equal to the + expected block size. + */ + class CallbackMaxSizeEnforcer : public AudioIODeviceCallback + { + public: + explicit CallbackMaxSizeEnforcer (AudioIODeviceCallback& callbackIn) + : inner (callbackIn) {} + + void audioDeviceAboutToStart (AudioIODevice* device) override + { + maximumSize = device->getCurrentBufferSizeSamples(); + storedInputChannels .resize ((size_t) device->getActiveInputChannels() .countNumberOfSetBits()); + storedOutputChannels.resize ((size_t) device->getActiveOutputChannels().countNumberOfSetBits()); + + inner.audioDeviceAboutToStart (device); + } + + void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + [[maybe_unused]] int numInputChannels, + float* const* outputChannelData, + [[maybe_unused]] int numOutputChannels, + int numSamples, + const AudioIODeviceCallbackContext& context) override + { + jassert ((int) storedInputChannels.size() == numInputChannels); + jassert ((int) storedOutputChannels.size() == numOutputChannels); + + int position = 0; + + while (position < numSamples) + { + const auto blockLength = jmin (maximumSize, numSamples - position); + + initChannelPointers (inputChannelData, storedInputChannels, position); + initChannelPointers (outputChannelData, storedOutputChannels, position); + + inner.audioDeviceIOCallbackWithContext (storedInputChannels.data(), + (int) storedInputChannels.size(), + storedOutputChannels.data(), + (int) storedOutputChannels.size(), + blockLength, + context); + + position += blockLength; + } + } + + void audioDeviceStopped() override + { + inner.audioDeviceStopped(); + } + + private: + struct GetChannelWithOffset + { + int offset; + + template + auto operator() (Ptr ptr) const noexcept -> Ptr { return ptr + offset; } + }; + + template + void initChannelPointers (Ptr&& source, Vector&& target, int offset) + { + std::transform (source, source + target.size(), target.begin(), GetChannelWithOffset { offset }); + } + + AudioIODeviceCallback& inner; + int maximumSize = 0; + std::vector storedInputChannels; + std::vector storedOutputChannels; + }; + + CallbackMaxSizeEnforcer maxSizeEnforcer { *this }; + + //============================================================================== + class SettingsComponent : public Component + { + public: + SettingsComponent (StandalonePluginHolder& pluginHolder, + AudioDeviceManager& deviceManagerToUse, + int maxAudioInputChannels, + int maxAudioOutputChannels) + : owner (pluginHolder), + deviceSelector (deviceManagerToUse, + 0, maxAudioInputChannels, + 0, maxAudioOutputChannels, + true, + (pluginHolder.processor.get() != nullptr && pluginHolder.processor->producesMidi()), + true, false), + shouldMuteLabel ("Feedback Loop:", "Feedback Loop:"), + shouldMuteButton ("Mute audio input") + { + setOpaque (true); + + shouldMuteButton.setClickingTogglesState (true); + shouldMuteButton.getToggleStateValue().referTo (owner.shouldMuteInput); + + addAndMakeVisible (deviceSelector); + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + addAndMakeVisible (shouldMuteButton); + addAndMakeVisible (shouldMuteLabel); + + shouldMuteLabel.attachToComponent (&shouldMuteButton, true); + } + } + + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId)); + } + + void resized() override + { + const ScopedValueSetter scope (isResizing, true); + + auto r = getLocalBounds(); + + if (owner.getProcessorHasPotentialFeedbackLoop()) + { + auto itemHeight = deviceSelector.getItemHeight(); + auto extra = r.removeFromTop (itemHeight); + + auto seperatorHeight = (itemHeight >> 1); + shouldMuteButton.setBounds (Rectangle (extra.proportionOfWidth (0.35f), seperatorHeight, + extra.proportionOfWidth (0.60f), deviceSelector.getItemHeight())); + + r.removeFromTop (seperatorHeight); + } + + deviceSelector.setBounds (r); + } + + void childBoundsChanged (Component* childComp) override + { + if (! isResizing && childComp == &deviceSelector) + setToRecommendedSize(); + } + + void setToRecommendedSize() + { + const auto extraHeight = [&] + { + if (! owner.getProcessorHasPotentialFeedbackLoop()) + return 0; + + const auto itemHeight = deviceSelector.getItemHeight(); + const auto separatorHeight = (itemHeight >> 1); + return itemHeight + separatorHeight; + }(); + + setSize (getWidth(), deviceSelector.getHeight() + extraHeight); + } + + private: + //============================================================================== + StandalonePluginHolder& owner; + AudioDeviceSelectorComponent deviceSelector; + Label shouldMuteLabel; + ToggleButton shouldMuteButton; + bool isResizing = false; + + //============================================================================== + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SettingsComponent) + }; + + //============================================================================== + void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const AudioIODeviceCallbackContext& context) override + { + if (muteInput) + { + emptyBuffer.clear(); + inputChannelData = emptyBuffer.getArrayOfReadPointers(); + } + + player.audioDeviceIOCallbackWithContext (inputChannelData, + numInputChannels, + outputChannelData, + numOutputChannels, + numSamples, + context); + } + + void audioDeviceAboutToStart (AudioIODevice* device) override + { + emptyBuffer.setSize (device->getActiveInputChannels().countNumberOfSetBits(), device->getCurrentBufferSizeSamples()); + emptyBuffer.clear(); + + player.audioDeviceAboutToStart (device); + player.setMidiOutput (deviceManager.getDefaultMidiOutput()); + } + + void audioDeviceStopped() override + { + player.setMidiOutput (nullptr); + player.audioDeviceStopped(); + emptyBuffer.setSize (0, 0); + } + + //============================================================================== + void setupAudioDevices (bool enableAudioInput, + const String& preferredDefaultDeviceName, + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions) + { + deviceManager.addAudioCallback (&maxSizeEnforcer); + deviceManager.addMidiInputDeviceCallback ({}, &player); + + reloadAudioDeviceState (enableAudioInput, preferredDefaultDeviceName, preferredSetupOptions); + } + + void shutDownAudioDevices() + { + saveAudioDeviceState(); + + deviceManager.removeMidiInputDeviceCallback ({}, &player); + deviceManager.removeAudioCallback (&maxSizeEnforcer); + } + + void timerCallback() override + { + auto newMidiDevices = MidiInput::getAvailableDevices(); + + if (newMidiDevices != lastMidiDevices) + { + for (auto& oldDevice : lastMidiDevices) + if (! newMidiDevices.contains (oldDevice)) + deviceManager.setMidiInputDeviceEnabled (oldDevice.identifier, false); + + for (auto& newDevice : newMidiDevices) + if (! lastMidiDevices.contains (newDevice)) + deviceManager.setMidiInputDeviceEnabled (newDevice.identifier, true); + + lastMidiDevices = newMidiDevices; + } + } + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (StandalonePluginHolder) +}; + +//============================================================================== +/** + A class that can be used to run a simple standalone application containing your filter. + + Just create one of these objects in your JUCEApplicationBase::initialise() method, and + let it do its work. It will create your filter object using the same createPluginFilter() function + that the other plugin wrappers use. + + @tags{Audio} +*/ +class StandaloneFilterWindow : public DocumentWindow, + private Button::Listener +{ +public: + //============================================================================== + typedef StandalonePluginHolder::PluginInOuts PluginInOuts; + + StandaloneFilterWindow (const String& title, + Colour backgroundColour, + std::unique_ptr pluginHolderIn) + : DocumentWindow (title, backgroundColour, DocumentWindow::minimiseButton | DocumentWindow::closeButton), + pluginHolder (std::move (pluginHolderIn)), + optionsButton ("Options") + { + setConstrainer (&decoratorConstrainer); + + #if JUCE_IOS || JUCE_ANDROID + setTitleBarHeight (0); + #else + setUsingNativeTitleBar(true); + setTitleBarButtonsRequired (DocumentWindow::minimiseButton | DocumentWindow::closeButton, false); + + Component::addAndMakeVisible (optionsButton); + optionsButton.addListener (this); + optionsButton.setTriggeredOnMouseDown (true); + #endif + + #if JUCE_IOS || JUCE_ANDROID + setFullScreen (true); + updateContent(); + #else + updateContent(); + + const auto windowScreenBounds = [this]() -> Rectangle + { + const auto width = getWidth(); + const auto height = getHeight(); + + const auto& displays = Desktop::getInstance().getDisplays(); + + if (displays.displays.isEmpty()) + return { width, height }; + + if (auto* props = pluginHolder->settings.get()) + { + constexpr int defaultValue = -100; + + const auto x = props->getIntValue ("windowX", defaultValue); + const auto y = props->getIntValue ("windowY", defaultValue); + + if (x != defaultValue && y != defaultValue) + { + const auto screenLimits = displays.getDisplayForRect ({ x, y, width, height })->userArea; + + return { jlimit (screenLimits.getX(), jmax (screenLimits.getX(), screenLimits.getRight() - width), x), + jlimit (screenLimits.getY(), jmax (screenLimits.getY(), screenLimits.getBottom() - height), y), + width, height }; + } + } + + const auto displayArea = displays.getPrimaryDisplay()->userArea; + + return { displayArea.getCentreX() - width / 2, + displayArea.getCentreY() - height / 2, + width, height }; + }(); + + setBoundsConstrained (windowScreenBounds); + + if (auto* processor = getAudioProcessor()) + if (auto* editor = processor->getActiveEditor()) + setResizable (editor->isResizable(), false); + #endif + } + + //============================================================================== + /** Creates a window with a given title and colour. + The settings object can be a PropertySet that the class should use to + store its settings (it can also be null). If takeOwnershipOfSettings is + true, then the settings object will be owned and deleted by this object. + */ + StandaloneFilterWindow (const String& title, + Colour backgroundColour, + PropertySet* settingsToUse, + bool takeOwnershipOfSettings, + const String& preferredDefaultDeviceName = String(), + const AudioDeviceManager::AudioDeviceSetup* preferredSetupOptions = nullptr, + const Array& constrainToConfiguration = {}, + #if JUCE_ANDROID || JUCE_IOS + bool autoOpenMidiDevices = true + #else + bool autoOpenMidiDevices = false + #endif + ) + : StandaloneFilterWindow (title, + backgroundColour, + std::make_unique (settingsToUse, + takeOwnershipOfSettings, + preferredDefaultDeviceName, + preferredSetupOptions, + constrainToConfiguration, + autoOpenMidiDevices)) + { + } + + ~StandaloneFilterWindow() override + { + #if (! JUCE_IOS) && (! JUCE_ANDROID) + if (auto* props = pluginHolder->settings.get()) + { + props->setValue ("windowX", getX()); + props->setValue ("windowY", getY()); + } + #endif + + pluginHolder->stopPlaying(); + clearContentComponent(); + pluginHolder = nullptr; + } + + //============================================================================== + AudioProcessor* getAudioProcessor() const noexcept { return pluginHolder->processor.get(); } + AudioDeviceManager& getDeviceManager() const noexcept { return pluginHolder->deviceManager; } + + /** Deletes and re-creates the plugin, resetting it to its default state. */ + void resetToDefaultState() + { + pluginHolder->stopPlaying(); + clearContentComponent(); + pluginHolder->deletePlugin(); + + if (auto* props = pluginHolder->settings.get()) + props->removeValue ("filterState"); + + pluginHolder->createPlugin(); + updateContent(); + pluginHolder->startPlaying(); + } + + //============================================================================== + void closeButtonPressed() override + { + pluginHolder->savePluginState(); + + JUCEApplicationBase::quit(); + } + + void handleMenuResult (int result) + { + switch (result) + { + case 1: pluginHolder->showAudioSettingsDialog(); break; + case 2: pluginHolder->askUserToSaveState(); break; + case 3: pluginHolder->askUserToLoadState(); break; + case 4: resetToDefaultState(); break; + default: break; + } + } + + static void menuCallback (int result, StandaloneFilterWindow* button) + { + if (button != nullptr && result != 0) + button->handleMenuResult (result); + } + + void resized() override + { + DocumentWindow::resized(); + optionsButton.setBounds (8, 6, 60, getTitleBarHeight() - 8); + } + + virtual StandalonePluginHolder* getPluginHolder() { return pluginHolder.get(); } + + std::unique_ptr pluginHolder; + + void handleCommandLine(const String& commandLine) + { + if (commandLine.isNotEmpty()) + { + handleOpenFile(commandLine); + } + } + + void handleOpenFile(const String& fileName) + { + if (auto* processor = getAudioProcessor()) + { +// if (processor->openProjectCallback) +// { +// processor->openProjectCallback(File(fileName)); +// } + } + } + +private: + void updateContent() + { + auto* content = new MainContentComponent (*this); + decoratorConstrainer.setMainContentComponent (content); + + #if JUCE_IOS || JUCE_ANDROID + constexpr auto resizeAutomatically = false; + #else + constexpr auto resizeAutomatically = true; + #endif + + setContentOwned (content, resizeAutomatically); + } + + void buttonClicked (Button*) override + { + PopupMenu m; + m.addItem (1, TRANS ("Audio/MIDI Settings...")); + m.addSeparator(); + m.addItem (2, TRANS ("Save current state...")); + m.addItem (3, TRANS ("Load a saved state...")); + m.addSeparator(); + m.addItem (4, TRANS ("Reset to default state")); + + m.showMenuAsync (PopupMenu::Options(), + ModalCallbackFunction::forComponent (menuCallback, this)); + } + + //============================================================================== + class MainContentComponent : public Component, + private Value::Listener, + private Button::Listener, + private ComponentListener + { + public: + MainContentComponent (StandaloneFilterWindow& filterWindow) + : owner (filterWindow), notification (this), + editor (owner.getAudioProcessor()->hasEditor() ? owner.getAudioProcessor()->createEditorIfNeeded() + : new GenericAudioProcessorEditor (*owner.getAudioProcessor())) + { + inputMutedValue.referTo (owner.pluginHolder->getMuteInputValue()); + + if (editor != nullptr) + { + editor->addComponentListener (this); + handleMovedOrResized(); + + addAndMakeVisible (editor.get()); + } + + addChildComponent (notification); + + if (owner.pluginHolder->getProcessorHasPotentialFeedbackLoop()) + { + inputMutedValue.addListener (this); + shouldShowNotification = inputMutedValue.getValue(); + } + + inputMutedChanged (shouldShowNotification); + } + + ~MainContentComponent() override + { + if (editor != nullptr) + { + editor->removeComponentListener (this); + owner.pluginHolder->processor->editorBeingDeleted (editor.get()); + editor = nullptr; + } + } + + void resized() override + { + handleResized(); + } + + ComponentBoundsConstrainer* getEditorConstrainer() const + { + if (auto* e = editor.get()) + return e->getConstrainer(); + + return nullptr; + } + + BorderSize computeBorder() const + { + const auto nativeFrame = [&]() -> BorderSize + { + if (auto* peer = owner.getPeer()) + if (const auto frameSize = peer->getFrameSizeIfPresent()) + return *frameSize; + + return {}; + }(); + + return nativeFrame.addedTo (owner.getContentComponentBorder()) + .addedTo (BorderSize { shouldShowNotification ? NotificationArea::height : 0, 0, 0, 0 }); + } + + private: + //============================================================================== + class NotificationArea : public Component + { + public: + enum { height = 30 }; + + NotificationArea (Button::Listener* settingsButtonListener) + : notification ("notification", "Audio input is muted to avoid feedback loop"), + #if JUCE_IOS || JUCE_ANDROID + settingsButton ("Unmute Input") + #else + settingsButton ("Settings...") + #endif + { + setOpaque (true); + + notification.setColour (Label::textColourId, Colours::black); + + settingsButton.addListener (settingsButtonListener); + + addAndMakeVisible (notification); + addAndMakeVisible (settingsButton); + } + + void paint (Graphics& g) override + { + auto r = getLocalBounds(); + + g.setColour (Colours::darkgoldenrod); + g.fillRect (r.removeFromBottom (1)); + + g.setColour (Colours::lightgoldenrodyellow); + g.fillRect (r); + } + + void resized() override + { + auto r = getLocalBounds().reduced (5); + + settingsButton.setBounds (r.removeFromRight (70)); + notification.setBounds (r); + } + private: + Label notification; + TextButton settingsButton; + }; + + //============================================================================== + void inputMutedChanged (bool newInputMutedValue) + { + shouldShowNotification = newInputMutedValue; + notification.setVisible (shouldShowNotification); + + #if JUCE_IOS || JUCE_ANDROID + handleResized(); + #else + if (editor != nullptr) + { + const int extraHeight = shouldShowNotification ? NotificationArea::height : 0; + const auto rect = getSizeToContainEditor(); + setSize (rect.getWidth(), rect.getHeight() + extraHeight); + } + #endif + } + + void valueChanged (Value& value) override { inputMutedChanged (value.getValue()); } + void buttonClicked (Button*) override + { + #if JUCE_IOS || JUCE_ANDROID + owner.pluginHolder->getMuteInputValue().setValue (false); + #else + owner.pluginHolder->showAudioSettingsDialog(); + #endif + } + + //============================================================================== + void handleResized() + { + auto r = getLocalBounds(); + + if (shouldShowNotification) + notification.setBounds (r.removeFromTop (NotificationArea::height)); + + if (editor != nullptr) + { + const auto newPos = r.getTopLeft().toFloat().transformedBy (editor->getTransform().inverted()); + + if (preventResizingEditor) + editor->setTopLeftPosition (newPos.roundToInt()); + else + editor->setBoundsConstrained (editor->getLocalArea (this, r.toFloat()).withPosition (newPos).toNearestInt()); + } + } + + void handleMovedOrResized() + { + const ScopedValueSetter scope (preventResizingEditor, true); + + if (editor != nullptr) + { + auto rect = getSizeToContainEditor(); + + setSize (rect.getWidth(), + rect.getHeight() + (shouldShowNotification ? NotificationArea::height : 0)); + } + } + + void componentMovedOrResized (Component&, bool, bool) override + { + handleMovedOrResized(); + } + + Rectangle getSizeToContainEditor() const + { + if (editor != nullptr) + return getLocalArea (editor.get(), editor->getLocalBounds()); + + return {}; + } + + //============================================================================== + StandaloneFilterWindow& owner; + NotificationArea notification; + std::unique_ptr editor; + Value inputMutedValue; + bool shouldShowNotification = false; + bool preventResizingEditor = false; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainContentComponent) + }; + + /* This custom constrainer checks with the AudioProcessorEditor (which might itself be + constrained) to ensure that any size we choose for the standalone window will be suitable + for the editor too. + + Without this constrainer, attempting to resize the standalone window may set bounds on the + peer that are unsupported by the inner editor. In this scenario, the peer will be set to a + 'bad' size, then the inner editor will be resized. The editor will check the new bounds with + its own constrainer, and may set itself to a more suitable size. After that, the resizable + window will see that its content component has changed size, and set the bounds of the peer + accordingly. The end result is that the peer is resized twice in a row to different sizes, + which can appear glitchy/flickery to the user. + */ + class DecoratorConstrainer : public BorderedComponentBoundsConstrainer + { + public: + ComponentBoundsConstrainer* getWrappedConstrainer() const override + { + return contentComponent != nullptr ? contentComponent->getEditorConstrainer() : nullptr; + } + + BorderSize getAdditionalBorder() const override + { + return contentComponent != nullptr ? contentComponent->computeBorder() : BorderSize{}; + } + + void setMainContentComponent (MainContentComponent* in) { contentComponent = in; } + + private: + MainContentComponent* contentComponent = nullptr; + }; + + //============================================================================== + TextButton optionsButton; + DecoratorConstrainer decoratorConstrainer; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (StandaloneFilterWindow) +}; + +} // namespace juce diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index 79173f92..2e52e6e6 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -10,7 +10,7 @@ MainComponent::MainComponent(OscirenderAudioProcessor& p, OscirenderAudioProcess fileButton.setButtonText("Choose File(s)"); fileButton.onClick = [this] { - chooser = std::make_unique("Open", audioProcessor.lastOpenedDirectory, "*.obj;*.svg;*.lua;*.txt;*.gpla;*.gif;*.png;*.jpg;*.jpeg;*.wav;*.aiff"); + chooser = std::make_unique("Open", audioProcessor.lastOpenedDirectory, "*.obj;*.svg;*.lua;*.txt;*.gpla;*.gif;*.png;*.jpg;*.jpeg;*.wav;*.aiff;*.ogg;*.flac;*.mp3"); auto flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectMultipleItems | juce::FileBrowserComponent::canSelectFiles; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 9c8ff033..618b57e1 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -1,6 +1,6 @@ #include "PluginProcessor.h" #include "PluginEditor.h" -#include +#include "CustomStandaloneFilterWindow.h" OscirenderAudioProcessorEditor::OscirenderAudioProcessorEditor(OscirenderAudioProcessor& p) : CommonPluginEditor(p, "osci-render", "osci", 1100, 750), audioProcessor(p), collapseButton("Collapse", juce::Colours::white, juce::Colours::white, juce::Colours::white) { #if !SOSCI_FEATURES @@ -109,6 +109,9 @@ bool OscirenderAudioProcessorEditor::isInterestedInFileDrag(const juce::StringAr return file.hasFileExtension("wav") || file.hasFileExtension("aiff") || + file.hasFileExtension("ogg") || + file.hasFileExtension("flac") || + file.hasFileExtension("mp3") || file.hasFileExtension("osci") || file.hasFileExtension("txt") || file.hasFileExtension("lua") || @@ -139,7 +142,8 @@ void OscirenderAudioProcessorEditor::filesDropped(const juce::StringArray& files } bool OscirenderAudioProcessorEditor::isBinaryFile(juce::String name) { - return name.endsWith(".gpla") || name.endsWith(".gif") || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".wav") || name.endsWith(".aiff"); + name = name.toLowerCase(); + return name.endsWith(".gpla") || name.endsWith(".gif") || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".wav") || name.endsWith(".aiff") || name.endsWith(".ogg") || name.endsWith(".mp3") || name.endsWith(".flac"); } // parsersLock must be held diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 6301bfdf..bf18aee6 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -321,7 +321,7 @@ void OscirenderAudioProcessor::openFile(int index) { if (index < 0 || index >= fileBlocks.size()) { return; } - parsers[index]->parse(juce::String(fileIds[index]), fileNames[index].fromLastOccurrenceOf(".", true, false), std::make_unique(*fileBlocks[index], false), font); + parsers[index]->parse(juce::String(fileIds[index]), fileNames[index].fromLastOccurrenceOf(".", true, false).toLowerCase(), std::make_unique(*fileBlocks[index], false), font); changeCurrentFile(index); } diff --git a/Source/SosciPluginEditor.cpp b/Source/SosciPluginEditor.cpp index 15f16c70..58203e9a 100644 --- a/Source/SosciPluginEditor.cpp +++ b/Source/SosciPluginEditor.cpp @@ -1,6 +1,6 @@ #include "SosciPluginProcessor.h" #include "SosciPluginEditor.h" -#include +#include "CustomStandaloneFilterWindow.h" SosciPluginEditor::SosciPluginEditor(SosciAudioProcessor& p) : CommonPluginEditor(p, "sosci", "sosci", 1180, 750), audioProcessor(p) { initialiseMenuBar(model); diff --git a/Source/components/EffectComponent.cpp b/Source/components/EffectComponent.cpp index 864a42f1..2a6dbc70 100644 --- a/Source/components/EffectComponent.cpp +++ b/Source/components/EffectComponent.cpp @@ -192,7 +192,9 @@ void EffectComponent::parameterGestureChanged(int parameterIndex, bool gestureIs void EffectComponent::handleAsyncUpdate() { setupComponent(); - getParentComponent()->repaint(); + if (auto* parent = getParentComponent()) { + parent->repaint(); + } } void EffectComponent::setRangeEnabled(bool enabled) { diff --git a/Source/components/OsciMainMenuBarModel.cpp b/Source/components/OsciMainMenuBarModel.cpp index 10ff9d82..aca48167 100644 --- a/Source/components/OsciMainMenuBarModel.cpp +++ b/Source/components/OsciMainMenuBarModel.cpp @@ -10,9 +10,9 @@ OsciMainMenuBarModel::OsciMainMenuBarModel(OscirenderAudioProcessor& p, Oscirend addTopLevelMenu("Audio"); } - addMenuItem(0, "Open", [this] { editor.openProject(); }); - addMenuItem(0, "Save", [this] { editor.saveProject(); }); - addMenuItem(0, "Save As", [this] { editor.saveProjectAs(); }); + addMenuItem(0, "Open Project", [this] { editor.openProject(); }); + addMenuItem(0, "Save Project", [this] { editor.saveProject(); }); + addMenuItem(0, "Save Project As", [this] { editor.saveProjectAs(); }); if (editor.processor.wrapperType == juce::AudioProcessor::WrapperType::wrapperType_Standalone) { addMenuItem(0, "Create New Project", [this] { editor.resetToDefault(); }); } diff --git a/Source/img/ImageParser.cpp b/Source/img/ImageParser.cpp index cf992a76..06218303 100644 --- a/Source/img/ImageParser.cpp +++ b/Source/img/ImageParser.cpp @@ -12,6 +12,9 @@ ImageParser::ImageParser(OscirenderAudioProcessor& p, juce::String extension, ju if (output.openedOk()) { output.write(image.getData(), image.getSize()); output.flush(); + } else { + handleError("The image could not be loaded."); + return; } } @@ -43,38 +46,45 @@ ImageParser::ImageParser(OscirenderAudioProcessor& p, juce::String extension, ju } gd_close_gif(gif); + } else { + handleError("The image could not be loaded. Please try optimising the GIF with https://ezgif.com/optimize."); + return; } } else { juce::Image image = juce::ImageFileFormat::loadFrom(file); - image.desaturate(); - - width = image.getWidth(); - height = image.getHeight(); - int frameSize = width * height; - - visited = std::vector(frameSize, false); - frames.emplace_back(std::vector(frameSize)); - - for (int x = 0; x < width; x++) { - for (int y = 0; y < height; y++) { - juce::Colour pixel = image.getPixelAt(x, y); - int index = y * width + x; - // RGB should be equal since we have desaturated - int value = pixel.getRed(); - // value of 0 is reserved for transparent pixels - frames[0][index] = pixel.isTransparent() ? 0 : juce::jmax(1, value); + if (image.isValid()) { + image.desaturate(); + + width = image.getWidth(); + height = image.getHeight(); + int frameSize = width * height; + + visited = std::vector(frameSize, false); + frames.emplace_back(std::vector(frameSize)); + + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + juce::Colour pixel = image.getPixelAt(x, y); + int index = y * width + x; + // RGB should be equal since we have desaturated + int value = pixel.getRed(); + // value of 0 is reserved for transparent pixels + frames[0][index] = pixel.isTransparent() ? 0 : juce::jmax(1, value); + } } + } else { + handleError("The image could not be loaded."); + return; } } if (frames.size() == 0) { - juce::MessageManager::callAsync([this] { - juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::AlertIconType::WarningIcon, "Invalid GIF", "The image could not be loaded. Please try optimising the GIF with https://ezgif.com/optimize."); - }); - - width = 1; - height = 1; - frames.emplace_back(std::vector(1)); + if (extension.equalsIgnoreCase(".gif")) { + handleError("The image could not be loaded. Please try optimising the GIF with https://ezgif.com/optimize."); + } else { + handleError("The image could not be loaded."); + } + return; } setFrame(0); @@ -82,6 +92,17 @@ ImageParser::ImageParser(OscirenderAudioProcessor& p, juce::String extension, ju ImageParser::~ImageParser() {} +void ImageParser::handleError(juce::String message) { + juce::MessageManager::callAsync([this, message] { + juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::AlertIconType::WarningIcon, "Error", message); + }); + + width = 1; + height = 1; + frames.emplace_back(std::vector(1)); + setFrame(0); +} + void ImageParser::setFrame(int index) { // Ensure that the frame number is within the bounds of the number of frames // This weird modulo trick is to handle negative numbers @@ -160,20 +181,52 @@ void ImageParser::findNearestNeighbour(int searchRadius, float thresholdPow, int } OsciPoint ImageParser::getSample() { - if (count % jumpFrequency() == 0) { - resetPosition(); + if (ALGORITHM == "HILLIGOSS") { + if (count % jumpFrequency() == 0) { + resetPosition(); + } + + if (count % 10 * jumpFrequency() == 0) { + std::fill(visited.begin(), visited.end(), false); + } + + float thresholdPow = audioProcessor.imageThreshold->getActualValue() * 10 + 1; + + findNearestNeighbour(10, thresholdPow, audioProcessor.imageStride->getActualValue(), audioProcessor.invertImage->getValue()); + float maxDim = juce::jmax(width, height); + count++; + float widthDiff = (maxDim - width) / 2; + float heightDiff = (maxDim - height) / 2; + return OsciPoint(2 * (currentX + widthDiff) / maxDim - 1, 2 * (currentY + heightDiff) / maxDim - 1); + } else { + double scanIncrement = audioProcessor.imageStride->getActualValue() / 100; + + double pixel = 0; + int maxIterations = 10000; + while (pixel <= audioProcessor.imageThreshold->getActualValue() && maxIterations > 0) { + int x = (int) ((scanX + 1) * width / 2); + int y = (int) ((scanY + 1) * height / 2); + pixel = getPixelValue(x, y, audioProcessor.invertImage->getValue()); + + double increment = 0.01; + if (pixel > audioProcessor.imageThreshold->getActualValue()) { + increment = (1 - tanh(4 * pixel)) * 0.3; + } + + scanX += increment; + if (scanX >= 1) { + scanX = -1; + scanY -= scanIncrement; + } + if (scanY < -1) { + double offset = ((scanCount % 15) / 15.0) * scanIncrement; + scanY = 1 - offset; + scanCount++; + } + + maxIterations--; + } + + return OsciPoint(scanX, scanY); } - - if (count % 10 * jumpFrequency() == 0) { - std::fill(visited.begin(), visited.end(), false); - } - - float thresholdPow = audioProcessor.imageThreshold->getActualValue() * 10 + 1; - - findNearestNeighbour(10, thresholdPow, audioProcessor.imageStride->getActualValue(), audioProcessor.invertImage->getValue()); - float maxDim = juce::jmax(width, height); - count++; - float widthDiff = (maxDim - width) / 2; - float heightDiff = (maxDim - height) / 2; - return OsciPoint(2 * (currentX + widthDiff) / maxDim - 1, 2 * (currentY + heightDiff) / maxDim - 1); } diff --git a/Source/img/ImageParser.h b/Source/img/ImageParser.h index 8c089402..522923cd 100644 --- a/Source/img/ImageParser.h +++ b/Source/img/ImageParser.h @@ -21,6 +21,9 @@ private: void findWhite(double thresholdPow, bool invert); bool isOverThreshold(double pixel, double thresholdValue); int jumpFrequency(); + void handleError(juce::String message); + + const juce::String ALGORITHM = "HILLIGOSS"; OscirenderAudioProcessor& audioProcessor; juce::Random rng; @@ -30,4 +33,9 @@ private: int currentX, currentY; int width, height; int count = 0; + + // experiments + double scanX = -1; + double scanY = 1; + int scanCount = 0; }; diff --git a/Source/parser/FileParser.cpp b/Source/parser/FileParser.cpp index 0a9fe76b..85888e78 100644 --- a/Source/parser/FileParser.cpp +++ b/Source/parser/FileParser.cpp @@ -41,8 +41,7 @@ void FileParser::parse(juce::String fileId, juce::String extension, std::unique_ } if (isBinary) { gpla = std::make_shared(gplaData, bytesRead); - } - else { + } else { stream->setPosition(0); gpla = std::make_shared(stream->readEntireStreamAsString()); } @@ -50,9 +49,13 @@ void FileParser::parse(juce::String fileId, juce::String extension, std::unique_ juce::MemoryBlock buffer{}; int bytesRead = stream->readIntoMemoryBlock(buffer); img = std::make_shared(audioProcessor, extension, buffer); - } else if (extension == ".wav" || extension == ".aiff") { + } else if (extension == ".wav" || extension == ".aiff" || extension == ".flac" || extension == ".ogg" || extension == ".mp3") { wav = std::make_shared(audioProcessor); - wav->parse(std::move(stream)); + if (!wav->parse(std::move(stream))) { + juce::MessageManager::callAsync([this] { + juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::AlertIconType::WarningIcon, "Error", "The audio file could not be loaded."); + }); + } } isAnimatable = gpla != nullptr || (img != nullptr && extension == ".gif"); diff --git a/Source/svg/SvgParser.cpp b/Source/svg/SvgParser.cpp index a49ef18c..618be4c7 100644 --- a/Source/svg/SvgParser.cpp +++ b/Source/svg/SvgParser.cpp @@ -22,6 +22,10 @@ SvgParser::SvgParser(juce::String svgFile) { } } + juce::MessageManager::callAsync([this] { + juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::AlertIconType::WarningIcon, "Error", "The SVG could not be loaded."); + }); + // draw an X to indicate an error. shapes.push_back(std::make_unique(-0.5, -0.5, 0.5, 0.5)); shapes.push_back(std::make_unique(-0.5, 0.5, 0.5, -0.5)); diff --git a/Source/visualiser/LineFragmentShader.glsl b/Source/visualiser/LineFragmentShader.glsl index 361524b6..0c68ab0d 100644 --- a/Source/visualiser/LineFragmentShader.glsl +++ b/Source/visualiser/LineFragmentShader.glsl @@ -9,7 +9,6 @@ uniform float uSize; uniform float uIntensity; uniform vec2 uOffset; uniform vec2 uScale; -uniform float uScreenOverlay; uniform float uFishEye; uniform sampler2D uScreen; varying float vSize; diff --git a/Source/visualiser/LineVertexShader.glsl b/Source/visualiser/LineVertexShader.glsl index a4a9b14e..fac5edda 100644 --- a/Source/visualiser/LineVertexShader.glsl +++ b/Source/visualiser/LineVertexShader.glsl @@ -7,6 +7,7 @@ uniform float uSize; uniform float uNEdges; uniform float uFadeAmount; uniform float uIntensity; +uniform bool uShutterSync; uniform float uGain; attribute vec3 aStart, aEnd; attribute float aIdx; @@ -25,8 +26,8 @@ void main () { vec2 aStartPos = aStart.xy; vec2 aEndPos = aEnd.xy; - float aStartBrightness = aStart.z; - float aEndBrightness = aEnd.z; + float aStartBrightness = clamp(aStart.z, 0.0, 1.0); + float aEndBrightness = clamp(aEnd.z, 0.0, 1.0); // `dir` vector is storing the normalized difference // between end and start @@ -56,17 +57,22 @@ void main () { uvl.w = aStartBrightness; } // `side` corresponds to shift to the "right" or "left" - float side = (mod(idx, 2.0)-0.5)*2.0; + float side = (mod(idx, 2.0) - 0.5) * 2.0; uvl.y = side * vSize; - uvl.w *= intensity * mix(1.0-uFadeAmount, 1.0, floor(aIdx / 4.0 + 0.5)/uNEdges); + float intensityScale = floor(aIdx / 4.0 + 0.5)/uNEdges; + + if (uShutterSync) { + float avgIntensityScale = floor(uNEdges / 4.0 + 0.5)/uNEdges; + intensityScale = avgIntensityScale; + } + float intensityFade = mix(1.0 - uFadeAmount, 1.0, intensityScale); + + uvl.w *= intensity * intensityFade; vec4 pos = vec4((current+(tang*dir+norm*side)*vSize)*uInvert,0.0,1.0); gl_Position = pos; - vTexCoord = 0.5*pos.xy+0.5; - //float seed = floor(aIdx/4.0); - //seed = mod(sin(seed*seed), 7.0); - //if (mod(seed/2.0, 1.0)<0.5) gl_Position = vec4(10.0); + vTexCoord = 0.5 * pos.xy + 0.5; } )"; diff --git a/Source/visualiser/OutputFragmentShader.glsl b/Source/visualiser/OutputFragmentShader.glsl index 3837aff6..133e8595 100644 --- a/Source/visualiser/OutputFragmentShader.glsl +++ b/Source/visualiser/OutputFragmentShader.glsl @@ -65,7 +65,7 @@ void main() { // r components have grid; g components do not. vec4 screen = texture2D(uTexture3, vTexCoord); vec4 tightGlow = texture2D(uTexture1, linePos); - vec4 scatter = texture2D(uTexture2, linePos) + (1.0 - uRealScreen) * max(uAmbient - 0.35, 0.0); + vec4 scatter = texture2D(uTexture2, linePos); if (uRealScreen > 0.5) { vec4 reflection = texture2D(uTexture4, vTexCoord); @@ -73,8 +73,14 @@ void main() { scatter += max4(screenGlow * reflection * max(1.0 - 0.5 * uAmbient, 0.0), vec4(0.0)); } - float light = line.r + uGlow * 1.5 * screen.g * screen.g * tightGlow.r; - light += uGlow * 0.3 * scatter.g * (2.0 + 1.0 * screen.g + 0.5 * screen.r); + // making the range of the glow slider more useful + float glow = 1.05 * pow(uGlow, 1.5); + float light = line.r + glow * 1.5 * screen.g * screen.g * tightGlow.r; + float scatterScalar = 0.3 * (2.0 + 1.0 * screen.g + 0.5 * screen.r); + light += glow * scatter.g * scatterScalar; + // add ambient light to graticule + light += (1.0 - uRealScreen) * max(uAmbient - 0.35, 0.0) * scatterScalar; + float tlight = 1.0-pow(2.0, -uExposure*light); float tlight2 = tlight * tlight * tlight; gl_FragColor.rgb = mix(uColour, vec3(1.0), 0.3+tlight2*tlight2*uOverexposure) * tlight; diff --git a/Source/visualiser/VisualiserComponent.cpp b/Source/visualiser/VisualiserComponent.cpp index 6c3487fa..49aa04a0 100644 --- a/Source/visualiser/VisualiserComponent.cpp +++ b/Source/visualiser/VisualiserComponent.cpp @@ -239,6 +239,14 @@ void VisualiserComponent::runTask(const std::vector& points) { for (const OsciPoint& rawPoint : points) { OsciPoint point = applyEffects(rawPoint); +#if SOSCI_FEATURES + if (settings.isGoniometer()) { + // x and y go to a diagonal currently, so we need to scale them down, and rotate them + point.scale(1.0 / std::sqrt(2.0), 1.0 / std::sqrt(2.0), 1.0); + point.rotate(0, 0, juce::MathConstants::pi / 4); + } +#endif + xSamples.push_back(point.x); ySamples.push_back(point.y); zSamples.push_back(point.z); @@ -1083,8 +1091,11 @@ void VisualiserComponent::drawLine(const std::vector& xPoints, const std: setOffsetAndScale(lineShader.get()); #if SOSCI_FEATURES - lineShader->setUniform("uScreenOverlay", (GLfloat) screenOverlay); lineShader->setUniform("uFishEye", screenOverlay == ScreenOverlay::VectorDisplay ? VECTOR_DISPLAY_FISH_EYE : 0.0f); + lineShader->setUniform("uShutterSync", settings.getShutterSync()); +#else + lineShader->setUniform("uFishEye", 0.0f); + lineShader->setUniform("uShutterSync", false); #endif glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vertexIndexBuffer); diff --git a/Source/visualiser/VisualiserComponent.h b/Source/visualiser/VisualiserComponent.h index f7c234d8..ef76054f 100644 --- a/Source/visualiser/VisualiserComponent.h +++ b/Source/visualiser/VisualiserComponent.h @@ -240,7 +240,7 @@ private: juce::OpenGLShaderProgram* currentShader; float fadeAmount; - ScreenOverlay screenOverlay = ScreenOverlay::MAX; + ScreenOverlay screenOverlay = ScreenOverlay::INVALID; const double RESAMPLE_RATIO = 6.0; double sampleRate = -1; diff --git a/Source/visualiser/VisualiserSettings.cpp b/Source/visualiser/VisualiserSettings.cpp index 87aa3082..9d57b14e 100644 --- a/Source/visualiser/VisualiserSettings.cpp +++ b/Source/visualiser/VisualiserSettings.cpp @@ -19,6 +19,8 @@ VisualiserSettings::VisualiserSettings(VisualiserParameters& p, int numChannels) addAndMakeVisible(screenColour); addAndMakeVisible(flipVerticalToggle); addAndMakeVisible(flipHorizontalToggle); + addAndMakeVisible(goniometerToggle); + addAndMakeVisible(shutterSyncToggle); #endif for (int i = 1; i <= parameters.screenOverlay->max; i++) { @@ -81,6 +83,8 @@ void VisualiserSettings::resized() { area.removeFromTop(10); flipVerticalToggle.setBounds(area.removeFromTop(rowHeight)); flipHorizontalToggle.setBounds(area.removeFromTop(rowHeight)); + goniometerToggle.setBounds(area.removeFromTop(rowHeight)); + shutterSyncToggle.setBounds(area.removeFromTop(rowHeight)); #endif #if !SOSCI_FEATURES diff --git a/Source/visualiser/VisualiserSettings.h b/Source/visualiser/VisualiserSettings.h index 64f2e6f5..0e1574b7 100644 --- a/Source/visualiser/VisualiserSettings.h +++ b/Source/visualiser/VisualiserSettings.h @@ -11,6 +11,7 @@ #include "../audio/StereoEffect.h" enum class ScreenOverlay : int { + INVALID = -1, Empty = 1, Graticule = 2, Smudged = 3, @@ -97,6 +98,8 @@ public: #if SOSCI_FEATURES BooleanParameter* flipVertical = new BooleanParameter("Flip Vertical", "flipVertical", VERSION_HINT, false, "Flips the visualiser vertically."); BooleanParameter* flipHorizontal = new BooleanParameter("Flip Horizontal", "flipHorizontal", VERSION_HINT, false, "Flips the visualiser horizontally."); + BooleanParameter* goniometer = new BooleanParameter("Goniometer", "goniometer", VERSION_HINT, false, "Rotates the visualiser to replicate a goniometer display to show the phase relationship between two channels."); + BooleanParameter* shutterSync = new BooleanParameter("Shutter Sync", "shutterSync", VERSION_HINT, false, "Controls whether the camera's shutter speed is in sync with framerate. This makes the brightness of a single frame constant. This can be beneficial when the drawing frequency and frame rate are in sync."); std::shared_ptr screenSaturationEffect = std::make_shared( new EffectParameter( @@ -301,6 +304,8 @@ public: #if SOSCI_FEATURES flipVertical, flipHorizontal, + goniometer, + shutterSync, #endif }; std::vector integers = { @@ -389,6 +394,14 @@ public: bool isFlippedHorizontal() { return parameters.flipHorizontal->getBoolValue(); } + + bool isGoniometer() { + return parameters.goniometer->getBoolValue(); + } + + bool getShutterSync() { + return parameters.shutterSync->getBoolValue(); + } #endif double getFocus() { @@ -505,6 +518,8 @@ private: jux::SwitchButton flipVerticalToggle{parameters.flipVertical}; jux::SwitchButton flipHorizontalToggle{parameters.flipHorizontal}; + jux::SwitchButton goniometerToggle{parameters.goniometer}; + jux::SwitchButton shutterSyncToggle{parameters.shutterSync}; #endif JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(VisualiserSettings) diff --git a/ci/build.sh b/ci/build.sh index 4208ef20..ff2c8743 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -23,17 +23,6 @@ eval "$RESAVE_COMMAND" if [ "$OS" = "mac" ]; then cd "$ROOT/Builds/$PLUGIN/MacOSX" xcodebuild -configuration Release || exit 1 - - cp -R "$ROOT/Builds/$PLUGIN/MacOSX/build/Release/$PLUGIN.app" "$ROOT/ci/bin/$OUTPUT_NAME.app" - cp -R ~/Library/Audio/Plug-Ins/VST3/$PLUGIN.vst3 "$ROOT/ci/bin/$OUTPUT_NAME.vst3" - cp -R ~/Library/Audio/Plug-Ins/Components/$PLUGIN.component "$ROOT/ci/bin/$OUTPUT_NAME.component" - - cd "$ROOT/ci/bin" - - zip -r ${OUTPUT_NAME}-mac.vst3.zip $OUTPUT_NAME.vst3 - zip -r ${OUTPUT_NAME}-mac.component.zip $OUTPUT_NAME.component - zip -r ${OUTPUT_NAME}-mac.app.zip $OUTPUT_NAME.app - cp ${OUTPUT_NAME}*.zip "$ROOT/bin" fi # Build linux version @@ -41,13 +30,13 @@ if [ "$OS" = "linux" ]; then cd "$ROOT/Builds/$PLUGIN/LinuxMakefile" make CONFIG=Release - cp -r ./build/$PLUGIN.vst3 "$ROOT/ci/bin/$OUTPUT_NAME.vst3" - cp -r ./build/$PLUGIN "$ROOT/ci/bin/$OUTPUT_NAME" + cp -r ./build/$PLUGIN.vst3 "$ROOT/ci/bin/$PLUGIN.vst3" + cp -r ./build/$PLUGIN "$ROOT/ci/bin/$PLUGIN" cd "$ROOT/ci/bin" - zip -r ${OUTPUT_NAME}-linux-vst3.zip $OUTPUT_NAME.vst3 - zip -r ${OUTPUT_NAME}-linux.zip $OUTPUT_NAME + zip -r ${OUTPUT_NAME}-linux-vst3.zip $PLUGIN.vst3 + zip -r ${OUTPUT_NAME}-linux.zip $PLUGIN cp ${OUTPUT_NAME}*.zip "$ROOT/bin" fi @@ -60,10 +49,7 @@ if [ "$OS" = "win" ]; then cd "$ROOT/Builds/$PLUGIN/VisualStudio2022" "$MSBUILD_EXE" "$PLUGIN.sln" "//p:VisualStudioVersion=16.0" "//m" "//t:Build" "//p:Configuration=Release" "//p:Platform=x64" "//p:PreferredToolArchitecture=x64" "//restore" "//p:RestorePackagesConfig=true" - cd "$ROOT/ci/bin" - cp "$ROOT/Builds/$PLUGIN/VisualStudio2022/x64/Release/Standalone Plugin/$PLUGIN.exe" "$ROOT/bin/$OUTPUT_NAME-win.exe" - cp "$ROOT/Builds/$PLUGIN/VisualStudio2022/x64/Release/Standalone Plugin/$PLUGIN.pdb" "$ROOT/bin/$OUTPUT_NAME-win.pdb" - cp -r "$ROOT/Builds/$PLUGIN/VisualStudio2022/x64/Release/VST3/$PLUGIN.vst3/Contents/x86_64-win/$PLUGIN.vst3" "$ROOT/bin/$OUTPUT_NAME-win.vst3" + cp "$ROOT/Builds/$PLUGIN/VisualStudio2022/x64/Release/Standalone Plugin/$PLUGIN.pdb" "$ROOT/bin/$OUTPUT_NAME.pdb" fi cd "$ROOT" diff --git a/osci-render.jucer b/osci-render.jucer index 0b48569f..acdfb2cc 100644 --- a/osci-render.jucer +++ b/osci-render.jucer @@ -4,8 +4,8 @@ addUsingNamespaceToJuceHeader="0" jucerFormatVersion="1" pluginCharacteristicsValue="pluginWantsMidiIn" pluginManufacturer="jameshball" aaxIdentifier="sh.ball.oscirender" cppLanguageStandard="20" projectLineFeed=" " headerPath="./include" - version="2.4.8.0" companyName="James H Ball" companyWebsite="https://osci-render.com" - companyEmail="james@ball.sh" defines="NOMINMAX=1 INTERNET_FLAG_NO_AUTO_REDIRECT=0 SOSCI_FEATURES=1" + version="2.4.10.3" companyName="James H Ball" companyWebsite="https://osci-render.com" + companyEmail="james@ball.sh" defines="NOMINMAX=1 INTERNET_FLAG_NO_AUTO_REDIRECT=0 SOSCI_FEATURES=1 JUCE_USE_CUSTOM_PLUGIN_STANDALONE_APP=1" pluginAUMainType="'aumf'"> @@ -673,6 +673,10 @@ file="Source/CommonPluginProcessor.cpp"/> + + @@ -76,6 +76,10 @@ + +