From f4e33faea19cf90fa2624a3d58591dd10295c95e Mon Sep 17 00:00:00 2001 From: attila Date: Wed, 4 May 2022 17:47:05 +0200 Subject: [PATCH] Add ARAPluginDemo PIP --- examples/CMakeLists.txt | 6 + examples/Plugins/ARAPluginDemo.h | 1420 ++++++++++++++++++++++++++++++ 2 files changed, 1426 insertions(+) create mode 100644 examples/Plugins/ARAPluginDemo.h diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index b0fc919c7e..2ed42ec712 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -34,6 +34,12 @@ function(_juce_add_pips) "${CMAKE_CURRENT_SOURCE_DIR}/PushNotificationsDemo.h") endif() + if(NOT (TARGET juce_ara_sdk + AND (CMAKE_SYSTEM_NAME STREQUAL "Windows" OR CMAKE_SYSTEM_NAME STREQUAL "Darwin"))) + list(REMOVE_ITEM headers + "${CMAKE_CURRENT_SOURCE_DIR}/ARAPluginDemo.h") + endif() + foreach(header IN ITEMS ${headers}) juce_add_pip(${header} added_target) target_link_libraries(${added_target} PUBLIC diff --git a/examples/Plugins/ARAPluginDemo.h b/examples/Plugins/ARAPluginDemo.h new file mode 100644 index 0000000000..4ca66694fb --- /dev/null +++ b/examples/Plugins/ARAPluginDemo.h @@ -0,0 +1,1420 @@ +/* + ============================================================================== + + This file is part of the JUCE examples. + Copyright (c) 2022 - Raw Material Software Limited + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + To use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, + WHETHER EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR + PURPOSE, ARE DISCLAIMED. + + ============================================================================== +*/ + +/******************************************************************************* + The block below describes the properties of this PIP. A PIP is a short snippet + of code that can be read by the Projucer and used to generate a JUCE project. + + BEGIN_JUCE_PIP_METADATA + + name: ARAPluginDemo + version: 1.0.0 + vendor: JUCE + website: http://juce.com + description: Audio plugin using the ARA API. + + dependencies: juce_audio_basics, juce_audio_devices, juce_audio_formats, + juce_audio_plugin_client, juce_audio_processors, + juce_audio_utils, juce_core, juce_data_structures, + juce_events, juce_graphics, juce_gui_basics, juce_gui_extra + exporters: xcode_mac, vs2019 + + moduleFlags: JUCE_STRICT_REFCOUNTEDPOINTER=1 + + type: AudioProcessor + mainClass: ARADemoPluginAudioProcessor + documentControllerClass: ARADemoPluginDocumentControllerSpecialisation + + useLocalCopy: 1 + + END_JUCE_PIP_METADATA + +*******************************************************************************/ + +#pragma once + + +//============================================================================== +struct PreviewState +{ + std::atomic previewTime { 0.0 }; + std::atomic previewedRegion { nullptr }; +}; + +class SharedTimeSliceThread : public TimeSliceThread +{ +public: + SharedTimeSliceThread() + : TimeSliceThread (String (JucePlugin_Name) + " ARA Sample Reading Thread") + { + startThread (7); // Above default priority so playback is fluent, but below realtime + } +}; + +class AsyncConfigurationCallback : private AsyncUpdater +{ +public: + explicit AsyncConfigurationCallback (std::function callbackIn) + : callback (std::move (callbackIn)) {} + + ~AsyncConfigurationCallback() override { cancelPendingUpdate(); } + + template + auto withLock (RequiresLock&& fn) + { + const SpinLock::ScopedTryLockType scope (processingFlag); + return fn (scope.isLocked()); + } + + void startConfigure() { triggerAsyncUpdate(); } + +private: + void handleAsyncUpdate() override + { + const SpinLock::ScopedLockType scope (processingFlag); + callback(); + } + + std::function callback; + SpinLock processingFlag; +}; + +class Looper +{ +public: + Looper() : inputBuffer (nullptr), pos (loopRange.getStart()) + { + } + + Looper (const AudioBuffer* buffer, Range range) + : inputBuffer (buffer), loopRange (range), pos (range.getStart()) + { + } + + void writeInto (AudioBuffer& buffer) + { + if (loopRange.getLength() == 0) + buffer.clear(); + + const auto numChannelsToCopy = std::min (inputBuffer->getNumChannels(), buffer.getNumChannels()); + + for (auto samplesCopied = 0; samplesCopied < buffer.getNumSamples();) + { + const auto numSamplesToCopy = + std::min (buffer.getNumSamples() - samplesCopied, (int) (loopRange.getEnd() - pos)); + + for (int i = 0; i < numChannelsToCopy; ++i) + { + buffer.copyFrom (i, samplesCopied, *inputBuffer, i, (int) pos, numSamplesToCopy); + } + + samplesCopied += numSamplesToCopy; + pos += numSamplesToCopy; + + jassert (pos <= loopRange.getEnd()); + + if (pos == loopRange.getEnd()) + pos = loopRange.getStart(); + } + } + +private: + const AudioBuffer* inputBuffer; + Range loopRange; + int64 pos; +}; + +class OptionalRange +{ +public: + using Type = Range; + + OptionalRange() : valid (false) {} + explicit OptionalRange (Type valueIn) : valid (true), value (std::move (valueIn)) {} + + explicit operator bool() const noexcept { return valid; } + + const auto& operator*() const + { + jassert (valid); + return value; + } + +private: + bool valid; + Type value; +}; + +//============================================================================== +// Returns the modified sample range in the output buffer. +inline OptionalRange readPlaybackRangeIntoBuffer (Range playbackRange, + const ARAPlaybackRegion* playbackRegion, + AudioBuffer& buffer, + const std::function& getReader) +{ + const auto rangeInAudioModificationTime = playbackRange.movedToStartAt (playbackRange.getStart() + - playbackRegion->getStartInAudioModificationTime()); + + const auto audioSource = playbackRegion->getAudioModification()->getAudioSource(); + const auto audioModificationSampleRate = audioSource->getSampleRate(); + + const Range sampleRangeInAudioModification { + ARA::roundSamplePosition (rangeInAudioModificationTime.getStart() * audioModificationSampleRate), + ARA::roundSamplePosition (rangeInAudioModificationTime.getEnd() * audioModificationSampleRate) - 1 + }; + + const auto inputOffset = jlimit ((int64_t) 0, audioSource->getSampleCount(), sampleRangeInAudioModification.getStart()); + + const auto outputOffset = -std::min (sampleRangeInAudioModification.getStart(), (int64_t) 0); + + /* TODO: Handle different AudioSource and playback sample rates. + + The conversion should be done inside a specialized AudioFormatReader so that we could use + playbackSampleRate everywhere in this function and we could still read `readLength` number of samples + from the source. + + The current implementation will be incorrect when sampling rates differ. + */ + const auto readLength = [&] + { + const auto sourceReadLength = + std::min (sampleRangeInAudioModification.getEnd(), audioSource->getSampleCount()) - inputOffset; + + const auto outputReadLength = + std::min (outputOffset + sourceReadLength, (int64_t) buffer.getNumSamples()) - outputOffset; + + return std::min (sourceReadLength, outputReadLength); + }(); + + if (readLength == 0) + return OptionalRange { {} }; + + auto* reader = getReader (audioSource); + + if (reader != nullptr && reader->read (&buffer, (int) outputOffset, (int) readLength, inputOffset, true, true)) + return OptionalRange { { outputOffset, readLength } }; + + return {}; +} + +class PossiblyBufferedReader +{ +public: + PossiblyBufferedReader() = default; + + explicit PossiblyBufferedReader (std::unique_ptr readerIn) + : setTimeoutFn ([ptr = readerIn.get()] (int ms) { ptr->setReadTimeout (ms); }), + reader (std::move (readerIn)) + {} + + explicit PossiblyBufferedReader (std::unique_ptr readerIn) + : setTimeoutFn(), + reader (std::move (readerIn)) + {} + + void setReadTimeout (int ms) + { + NullCheckedInvocation::invoke (setTimeoutFn, ms); + } + + AudioFormatReader* get() const { return reader.get(); } + +private: + std::function setTimeoutFn; + std::unique_ptr reader; +}; + +//============================================================================== +class PlaybackRenderer : public ARAPlaybackRenderer +{ +public: + using ARAPlaybackRenderer::ARAPlaybackRenderer; + + void prepareToPlay (double sampleRateIn, + int maximumSamplesPerBlockIn, + int numChannelsIn, + AudioProcessor::ProcessingPrecision, + AlwaysNonRealtime alwaysNonRealtime) override + { + numChannels = numChannelsIn; + sampleRate = sampleRateIn; + maximumSamplesPerBlock = maximumSamplesPerBlockIn; + tempBuffer.reset (new AudioBuffer (numChannels, maximumSamplesPerBlock)); + + useBufferedAudioSourceReader = alwaysNonRealtime == AlwaysNonRealtime::no; + + for (const auto playbackRegion : getPlaybackRegions()) + { + auto audioSource = playbackRegion->getAudioModification()->getAudioSource(); + + if (audioSourceReaders.find (audioSource) == audioSourceReaders.end()) + { + auto reader = std::make_unique (audioSource); + + if (! useBufferedAudioSourceReader) + { + audioSourceReaders.emplace (audioSource, + PossiblyBufferedReader { std::move (reader) }); + } + else + { + const auto readAheadSize = jmax (4 * maximumSamplesPerBlock, + roundToInt (2.0 * sampleRate)); + audioSourceReaders.emplace (audioSource, + PossiblyBufferedReader { std::make_unique (reader.release(), + *sharedTimesliceThread, + readAheadSize) }); + } + } + } + } + + void releaseResources() override + { + audioSourceReaders.clear(); + tempBuffer.reset(); + } + + bool processBlock (AudioBuffer& buffer, + AudioProcessor::Realtime realtime, + const AudioPlayHead::CurrentPositionInfo& positionInfo) noexcept override + { + const auto numSamples = buffer.getNumSamples(); + jassert (numSamples <= maximumSamplesPerBlock); + jassert (numChannels == buffer.getNumChannels()); + jassert (realtime == AudioProcessor::Realtime::no || useBufferedAudioSourceReader); + const auto timeInSamples = positionInfo.timeInSamples; + const auto isPlaying = positionInfo.isPlaying; + + bool success = true; + bool didRenderAnyRegion = false; + + if (isPlaying) + { + const auto blockRange = Range::withStartAndLength (timeInSamples, numSamples); + + for (const auto& playbackRegion : getPlaybackRegions()) + { + // Evaluate region borders in song time, calculate sample range to render in song time. + // Note that this example does not use head- or tailtime, so the includeHeadAndTail + // parameter is set to false here - this might need to be adjusted in actual plug-ins. + const auto playbackSampleRange = playbackRegion->getSampleRange (sampleRate, ARAPlaybackRegion::IncludeHeadAndTail::no); + auto renderRange = blockRange.getIntersectionWith (playbackSampleRange); + + if (renderRange.isEmpty()) + continue; + + // Evaluate region borders in modification/source time and calculate offset between + // song and source samples, then clip song samples accordingly + // (if an actual plug-in supports time stretching, this must be taken into account here). + Range modificationSampleRange { playbackRegion->getStartInAudioModificationSamples(), + playbackRegion->getEndInAudioModificationSamples() }; + const auto modificationSampleOffset = modificationSampleRange.getStart() - playbackSampleRange.getStart(); + + renderRange = renderRange.getIntersectionWith (modificationSampleRange.movedToStartAt (playbackSampleRange.getStart())); + + if (renderRange.isEmpty()) + continue; + + // Get the audio source for the region and find the reader for that source. + // This simplified example code only produces audio if sample rate and channel count match - + // a robust plug-in would need to do conversion, see ARA SDK documentation. + const auto audioSource = playbackRegion->getAudioModification()->getAudioSource(); + const auto readerIt = audioSourceReaders.find (audioSource); + + if (std::make_tuple (audioSource->getChannelCount(), audioSource->getSampleRate()) != std::make_tuple (numChannels, sampleRate) + || (readerIt == audioSourceReaders.end())) + { + success = false; + continue; + } + + auto& reader = readerIt->second; + reader.setReadTimeout (realtime == AudioProcessor::Realtime::no ? 100 : 0); + + // Calculate buffer offsets. + const int numSamplesToRead = (int) renderRange.getLength(); + const int startInBuffer = (int) (renderRange.getStart() - blockRange.getStart()); + auto startInSource = renderRange.getStart() + modificationSampleOffset; + + // Read samples: + // first region can write directly into output, later regions need to use local buffer. + auto& readBuffer = (didRenderAnyRegion) ? *tempBuffer : buffer; + + if (! reader.get()->read (&readBuffer, startInBuffer, numSamplesToRead, startInSource, true, true)) + { + success = false; + continue; + } + + if (didRenderAnyRegion) + { + // Mix local buffer into the output buffer. + for (int c = 0; c < numChannels; ++c) + buffer.addFrom (c, startInBuffer, *tempBuffer, c, startInBuffer, numSamplesToRead); + } + else + { + // Clear any excess at start or end of the region. + if (startInBuffer != 0) + buffer.clear (0, startInBuffer); + + const int endInBuffer = startInBuffer + numSamplesToRead; + const int remainingSamples = numSamples - endInBuffer; + + if (remainingSamples != 0) + buffer.clear (endInBuffer, remainingSamples); + + didRenderAnyRegion = true; + } + } + } + + // If no playback or no region did intersect, clear buffer now. + if (! didRenderAnyRegion) + buffer.clear(); + + return success; + } + +private: + //============================================================================== + // We're subclassing here only to provide a proper default c'tor for our shared resource + + SharedResourcePointer sharedTimesliceThread; + std::map audioSourceReaders; + bool useBufferedAudioSourceReader = true; + int numChannels = 2; + double sampleRate = 48000.0; + int maximumSamplesPerBlock = 128; + std::unique_ptr> tempBuffer; +}; + +class EditorRenderer : public ARAEditorRenderer, + private ARARegionSequence::Listener +{ +public: + EditorRenderer (ARA::PlugIn::DocumentController* documentController, const PreviewState* previewStateIn) + : ARAEditorRenderer (documentController), previewState (previewStateIn), previewBuffer() + { + jassert (previewState != nullptr); + } + + ~EditorRenderer() override + { + for (const auto& rs : regionSequences) + rs->removeListener (this); + } + + void didAddPlaybackRegionToRegionSequence (ARARegionSequence*, ARAPlaybackRegion*) override + { + asyncConfigCallback.startConfigure(); + } + + void didAddRegionSequence (ARA::PlugIn::RegionSequence* rs) noexcept override + { + auto* sequence = dynamic_cast (rs); + sequence->addListener (this); + regionSequences.insert (sequence); + asyncConfigCallback.startConfigure(); + } + + void didAddPlaybackRegion (ARA::PlugIn::PlaybackRegion*) noexcept override + { + asyncConfigCallback.startConfigure(); + } + + /* An ARA host could be using either the `addPlaybackRegion()` or `addRegionSequence()` interface + so we need to check the other side of both. + + The callback must have a signature of `bool (ARAPlaybackRegion*)` + */ + template + void forEachPlaybackRegion (Callback&& cb) + { + for (const auto& playbackRegion : getPlaybackRegions()) + if (! cb (playbackRegion)) + return; + + for (const auto& regionSequence : getRegionSequences()) + for (const auto& playbackRegion : regionSequence->getPlaybackRegions()) + if (! cb (playbackRegion)) + return; + } + + void prepareToPlay (double sampleRateIn, + int maximumExpectedSamplesPerBlock, + int numChannels, + AudioProcessor::ProcessingPrecision, + AlwaysNonRealtime alwaysNonRealtime) override + { + sampleRate = sampleRateIn; + previewBuffer = std::make_unique> (numChannels, (int) (2 * sampleRateIn)); + + ignoreUnused (maximumExpectedSamplesPerBlock, alwaysNonRealtime); + } + + void releaseResources() override + { + audioSourceReaders.clear(); + } + + void reset() override + { + previewBuffer->clear(); + } + + bool processBlock (AudioBuffer& buffer, + AudioProcessor::Realtime realtime, + const AudioPlayHead::CurrentPositionInfo& positionInfo) noexcept override + { + ignoreUnused (realtime); + + return asyncConfigCallback.withLock ([&] (bool locked) + { + if (! locked) + return true; + + if (positionInfo.isPlaying) + return true; + + if (const auto previewedRegion = previewState->previewedRegion.load()) + { + const auto regionIsAssignedToEditor = [&]() + { + bool regionIsAssigned = false; + + forEachPlaybackRegion ([&previewedRegion, ®ionIsAssigned] (const auto& region) + { + if (region == previewedRegion) + { + regionIsAssigned = true; + return false; + } + + return true; + }); + + return regionIsAssigned; + }(); + + if (regionIsAssignedToEditor) + { + const auto previewTime = previewState->previewTime.load(); + + if (lastPreviewTime != previewTime || lastPlaybackRegion != previewedRegion) + { + Range previewRangeInPlaybackTime { previewTime - 0.25, previewTime + 0.25 }; + previewBuffer->clear(); + const auto rangeInOutput = readPlaybackRangeIntoBuffer (previewRangeInPlaybackTime, + previewedRegion, + *previewBuffer, + [this] (auto* source) -> auto* + { + const auto iter = audioSourceReaders.find (source); + return iter != audioSourceReaders.end() ? iter->second.get() : nullptr; + }); + + if (rangeInOutput) + { + lastPreviewTime = previewTime; + lastPlaybackRegion = previewedRegion; + previewLooper = Looper (previewBuffer.get(), *rangeInOutput); + } + } + else + { + previewLooper.writeInto (buffer); + } + } + } + + return true; + }); + } + +private: + void configure() + { + forEachPlaybackRegion ([this, maximumExpectedSamplesPerBlock = 1000] (const auto& playbackRegion) + { + const auto audioSource = playbackRegion->getAudioModification()->getAudioSource(); + + if (audioSourceReaders.find (audioSource) == audioSourceReaders.end()) + { + audioSourceReaders[audioSource] = std::make_unique ( + new ARAAudioSourceReader (playbackRegion->getAudioModification()->getAudioSource()), + *timeSliceThread, + std::max (4 * maximumExpectedSamplesPerBlock, (int) sampleRate)); + } + + return true; + }); + } + + const PreviewState* previewState = nullptr; + AsyncConfigurationCallback asyncConfigCallback { [this] { configure(); } }; + double lastPreviewTime = 0.0; + ARAPlaybackRegion* lastPlaybackRegion = nullptr; + std::unique_ptr> previewBuffer; + Looper previewLooper; + + double sampleRate = 48000.0; + SharedResourcePointer timeSliceThread; + std::map> audioSourceReaders; + + std::set regionSequences; +}; + +//============================================================================== +class ARADemoPluginDocumentControllerSpecialisation : public ARADocumentControllerSpecialisation +{ +public: + using ARADocumentControllerSpecialisation::ARADocumentControllerSpecialisation; + + PreviewState previewState; + +protected: + ARAPlaybackRenderer* doCreatePlaybackRenderer() noexcept override + { + return new PlaybackRenderer (getDocumentController()); + } + + EditorRenderer* doCreateEditorRenderer() noexcept override + { + return new EditorRenderer (getDocumentController(), &previewState); + } + + bool doRestoreObjectsFromStream (ARAInputStream& input, + const ARARestoreObjectsFilter* filter) noexcept override + { + ignoreUnused (input, filter); + return false; + } + + bool doStoreObjectsToStream (ARAOutputStream& output, const ARAStoreObjectsFilter* filter) noexcept override + { + ignoreUnused (output, filter); + return false; + } +}; + +//============================================================================== +class ARADemoPluginAudioProcessorImpl : public AudioProcessor, + public AudioProcessorARAExtension +{ +public: + //============================================================================== + ARADemoPluginAudioProcessorImpl() + : AudioProcessor (getBusesProperties()) + {} + + ~ARADemoPluginAudioProcessorImpl() override = default; + + //============================================================================== + void prepareToPlay (double sampleRate, int samplesPerBlock) override + { + prepareToPlayForARA (sampleRate, samplesPerBlock, getMainBusNumOutputChannels(), getProcessingPrecision()); + } + + void releaseResources() override + { + releaseResourcesForARA(); + } + + bool isBusesLayoutSupported (const BusesLayout& layouts) const override + { + if (layouts.getMainOutputChannelSet() != AudioChannelSet::mono() + && layouts.getMainOutputChannelSet() != AudioChannelSet::stereo()) + return false; + + return true; + } + + void processBlock (AudioBuffer& buffer, MidiBuffer& midiMessages) override + { + ignoreUnused (midiMessages); + + ScopedNoDenormals noDenormals; + + if (! processBlockForARA (buffer, isRealtime(), getPlayHead())) + processBlockBypassed (buffer, midiMessages); + } + + //============================================================================== + const String getName() const override { return "ARAPluginDemo"; } + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return true; } + double getTailLengthSeconds() const override { return 0.0; } + + //============================================================================== + int getNumPrograms() override { return 0; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram (int) override {} + const String getProgramName (int) override { return "None"; } + void changeProgramName (int, const String&) override {} + + //============================================================================== + void getStateInformation (MemoryBlock&) override {} + void setStateInformation (const void*, int) override {} + +private: + //============================================================================== + static BusesProperties getBusesProperties() + { + return BusesProperties().withInput ("Input", AudioChannelSet::stereo(), true) + .withOutput ("Output", AudioChannelSet::stereo(), true); + } + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ARADemoPluginAudioProcessorImpl) +}; + +//============================================================================== +struct WaveformCache : private ARAAudioSource::Listener +{ + WaveformCache() : thumbnailCache (20) + { + } + + ~WaveformCache() override + { + for (const auto& entry : thumbnails) + { + entry.first->removeListener (this); + } + } + + //============================================================================== + void willDestroyAudioSource (ARAAudioSource* audioSource) override + { + removeAudioSource (audioSource); + } + + AudioThumbnail& getOrCreateThumbnail (ARAAudioSource* audioSource) + { + const auto iter = thumbnails.find (audioSource); + + if (iter != std::end (thumbnails)) + return *iter->second; + + auto thumb = std::make_unique (128, dummyManager, thumbnailCache); + auto& result = *thumb; + + ++hash; + thumb->setReader (new ARAAudioSourceReader (audioSource), hash); + + audioSource->addListener (this); + thumbnails.emplace (audioSource, std::move (thumb)); + return result; + } + +private: + void removeAudioSource (ARAAudioSource* audioSource) + { + audioSource->removeListener (this); + thumbnails.erase (audioSource); + } + + int64 hash = 0; + AudioFormatManager dummyManager; + AudioThumbnailCache thumbnailCache; + std::map> thumbnails; +}; + +class PlaybackRegionView : public Component, + public ChangeListener +{ +public: + PlaybackRegionView (ARAPlaybackRegion& region, WaveformCache& cache) + : playbackRegion (region), waveformCache (cache) + { + auto* audioSource = playbackRegion.getAudioModification()->getAudioSource(); + + waveformCache.getOrCreateThumbnail (audioSource).addChangeListener (this); + } + + ~PlaybackRegionView() override + { + waveformCache.getOrCreateThumbnail (playbackRegion.getAudioModification()->getAudioSource()) + .removeChangeListener (this); + } + + void mouseDown (const MouseEvent& m) override + { + const auto relativeTime = (double) m.getMouseDownX() / getLocalBounds().getWidth(); + const auto previewTime = playbackRegion.getStartInPlaybackTime() + + relativeTime * playbackRegion.getDurationInPlaybackTime(); + auto& previewState = ARADocumentControllerSpecialisation::getSpecialisedDocumentController (playbackRegion.getDocumentController())->previewState; + previewState.previewTime.store (previewTime); + previewState.previewedRegion.store (&playbackRegion); + } + + void mouseUp (const MouseEvent&) override + { + auto& previewState = ARADocumentControllerSpecialisation::getSpecialisedDocumentController (playbackRegion.getDocumentController())->previewState; + previewState.previewTime.store (0.0); + previewState.previewedRegion.store (nullptr); + } + + void changeListenerCallback (ChangeBroadcaster*) override + { + repaint(); + } + + void paint (Graphics& g) override + { + g.fillAll (Colours::white.darker()); + g.setColour (Colours::darkgrey.darker()); + auto& thumbnail = waveformCache.getOrCreateThumbnail (playbackRegion.getAudioModification()->getAudioSource()); + thumbnail.drawChannels (g, + getLocalBounds(), + playbackRegion.getStartInAudioModificationTime(), + playbackRegion.getEndInAudioModificationTime(), + 1.0f); + g.setColour (Colours::black); + g.drawRect (getLocalBounds()); + } + + void resized() override + { + repaint(); + } + +private: + ARAPlaybackRegion& playbackRegion; + WaveformCache& waveformCache; +}; + +class RegionSequenceView : public Component, + public ARARegionSequence::Listener, + public ChangeBroadcaster, + private ARAPlaybackRegion::Listener +{ +public: + RegionSequenceView (ARARegionSequence& rs, WaveformCache& cache, double pixelPerSec) + : regionSequence (rs), waveformCache (cache), zoomLevelPixelPerSecond (pixelPerSec) + { + regionSequence.addListener (this); + + for (auto* playbackRegion : regionSequence.getPlaybackRegions()) + createAndAddPlaybackRegionView (playbackRegion); + + updatePlaybackDuration(); + } + + ~RegionSequenceView() override + { + regionSequence.removeListener (this); + + for (const auto& it : playbackRegionViews) + it.first->removeListener (this); + } + + //============================================================================== + // ARA Document change callback overrides + void willRemovePlaybackRegionFromRegionSequence (ARARegionSequence*, + ARAPlaybackRegion* playbackRegion) override + { + playbackRegion->removeListener (this); + removeChildComponent (playbackRegionViews[playbackRegion].get()); + playbackRegionViews.erase (playbackRegion); + updatePlaybackDuration(); + } + + void didAddPlaybackRegionToRegionSequence (ARARegionSequence*, ARAPlaybackRegion* playbackRegion) override + { + createAndAddPlaybackRegionView (playbackRegion); + updatePlaybackDuration(); + } + + void willDestroyPlaybackRegion (ARAPlaybackRegion* playbackRegion) override + { + playbackRegion->removeListener (this); + removeChildComponent (playbackRegionViews[playbackRegion].get()); + playbackRegionViews.erase (playbackRegion); + updatePlaybackDuration(); + } + + void willUpdatePlaybackRegionProperties (ARAPlaybackRegion*, ARAPlaybackRegion::PropertiesPtr) override + { + } + + void didUpdatePlaybackRegionProperties (ARAPlaybackRegion*) override + { + updatePlaybackDuration(); + } + + void resized() override + { + for (auto& pbr : playbackRegionViews) + { + const auto playbackRegion = pbr.first; + pbr.second->setBounds ( + getLocalBounds() + .withTrimmedLeft (roundToInt (playbackRegion->getStartInPlaybackTime() * zoomLevelPixelPerSecond)) + .withWidth (roundToInt (playbackRegion->getDurationInPlaybackTime() * zoomLevelPixelPerSecond))); + } + } + + auto getPlaybackDuration() const noexcept + { + return playbackDuration; + } + + void setZoomLevel (double pixelPerSecond) + { + zoomLevelPixelPerSecond = pixelPerSecond; + resized(); + } + +private: + void createAndAddPlaybackRegionView (ARAPlaybackRegion* playbackRegion) + { + playbackRegionViews[playbackRegion] = std::make_unique (*playbackRegion, waveformCache); + playbackRegion->addListener (this); + addAndMakeVisible (*playbackRegionViews[playbackRegion]); + } + + void updatePlaybackDuration() + { + const auto iter = std::max_element ( + playbackRegionViews.begin(), + playbackRegionViews.end(), + [] (const auto& a, const auto& b) { return a.first->getEndInPlaybackTime() < b.first->getEndInPlaybackTime(); }); + + playbackDuration = iter != playbackRegionViews.end() ? iter->first->getEndInPlaybackTime() + : 0.0; + + sendChangeMessage(); + } + + ARARegionSequence& regionSequence; + WaveformCache& waveformCache; + std::unordered_map> playbackRegionViews; + double playbackDuration = 0.0; + double zoomLevelPixelPerSecond; +}; + +class ZoomControls : public Component +{ +public: + ZoomControls() + { + addAndMakeVisible (zoomInButton); + addAndMakeVisible (zoomOutButton); + } + + void setZoomInCallback (std::function cb) { zoomInButton.onClick = std::move (cb); } + void setZoomOutCallback (std::function cb) { zoomOutButton.onClick = std::move (cb); } + + void resized() override + { + FlexBox fb; + fb.justifyContent = FlexBox::JustifyContent::flexEnd; + + for (auto* button : { &zoomInButton, &zoomOutButton }) + fb.items.add (FlexItem (*button).withMinHeight (30.0f).withMinWidth (30.0f).withMargin ({ 5, 5, 5, 0 })); + + fb.performLayout (getLocalBounds()); + } + +private: + TextButton zoomInButton { "+" }, zoomOutButton { "-" }; +}; + +class TrackHeader : public Component +{ +public: + explicit TrackHeader (const ARARegionSequence& regionSequenceIn) : regionSequence (regionSequenceIn) + { + update(); + + addAndMakeVisible (trackNameLabel); + } + + void resized() override + { + trackNameLabel.setBounds (getLocalBounds().reduced (2)); + } + + void paint (Graphics& g) override + { + g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId)); + g.fillRoundedRectangle (getLocalBounds().reduced (2).toType(), 6.0f); + g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).contrasting()); + g.drawRoundedRectangle (getLocalBounds().reduced (2).toType(), 6.0f, 1.0f); + } + +private: + void update() + { + const auto getWithDefaultValue = + [] (const ARA::PlugIn::OptionalProperty& optional, String defaultValue) + { + if (const ARA::ARAUtf8String value = optional) + return String (value); + + return defaultValue; + }; + + trackNameLabel.setText (getWithDefaultValue (regionSequence.getName(), "No track name"), + NotificationType::dontSendNotification); + } + + const ARARegionSequence& regionSequence; + Label trackNameLabel; +}; + +constexpr auto trackHeight = 60; + +class VerticalLayoutViewportContent : public Component +{ +public: + void resized() override + { + auto bounds = getLocalBounds(); + + for (auto* component : getChildren()) + { + component->setBounds (bounds.removeFromTop (trackHeight)); + component->resized(); + } + } + + void setOverlayComponent (Component* component) + { + if (overlayComponent != nullptr && overlayComponent != component) + removeChildComponent (overlayComponent); + + addChildComponent (component); + overlayComponent = component; + } + +private: + Component* overlayComponent = nullptr; +}; + +class VerticalLayoutViewport : public Viewport +{ +public: + VerticalLayoutViewport() + { + setViewedComponent (&content, false); + } + + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).brighter()); + } + + std::function)> onVisibleAreaChanged; + + VerticalLayoutViewportContent content; + +private: + void visibleAreaChanged (const Rectangle& newVisibleArea) override + { + NullCheckedInvocation::invoke (onVisibleAreaChanged, newVisibleArea); + } +}; + +class OverlayComponent : public Component, + private Timer +{ +public: + class PlayheadMarkerComponent : public Component + { + void paint (Graphics& g) override { g.fillAll (juce::Colours::yellow.darker (0.2f)); } + }; + + OverlayComponent(std::function getAudioPlayheadIn) + : getAudioPlayhead (std::move (getAudioPlayheadIn)) + { + addChildComponent (playheadMarker); + setInterceptsMouseClicks (false, false); + startTimerHz (30); + } + + ~OverlayComponent() override + { + stopTimer(); + } + + void resized() override + { + doResize(); + } + + void setZoomLevel (double pixelPerSecondIn) + { + pixelPerSecond = pixelPerSecondIn; + } + + void setHorizontalOffset (int offset) + { + horizontalOffset = offset; + } + +private: + void doResize() + { + auto* aph = getAudioPlayhead(); + AudioPlayHead::CurrentPositionInfo positionInfo; + aph->getCurrentPosition (positionInfo); + + if (positionInfo.isPlaying) + { + const auto markerX = positionInfo.timeInSeconds * pixelPerSecond; + const auto playheadLine = getLocalBounds().withTrimmedLeft ((int) (markerX - markerWidth / 2.0) - horizontalOffset) + .removeFromLeft ((int) markerWidth); + playheadMarker.setVisible (true); + playheadMarker.setBounds (playheadLine); + } + else + { + playheadMarker.setVisible (false); + } + } + + void timerCallback() override + { + doResize(); + } + + static constexpr double markerWidth = 2.0; + + std::function getAudioPlayhead; + double pixelPerSecond = 1.0; + int horizontalOffset = 0; + PlayheadMarkerComponent playheadMarker; +}; + +class DocumentView : public Component, + public ChangeListener, + private ARADocument::Listener, + private ARAEditorView::Listener +{ +public: + explicit DocumentView (ARADocument& document, std::function getAudioPlayhead) + : araDocument (document), + overlay (std::move (getAudioPlayhead)) + { + addAndMakeVisible (tracksBackground); + + viewport.onVisibleAreaChanged = [this] (const auto& r) + { + viewportHeightOffset = r.getY(); + overlay.setHorizontalOffset (r.getX()); + resized(); + }; + + addAndMakeVisible (viewport); + + overlay.setZoomLevel (zoomLevelPixelPerSecond); + addAndMakeVisible (overlay); + + zoomControls.setZoomInCallback ([this] { zoom (2.0); }); + zoomControls.setZoomOutCallback ([this] { zoom (0.5); }); + addAndMakeVisible (zoomControls); + + invalidateRegionSequenceViews(); + araDocument.addListener (this); + } + + ~DocumentView() override + { + araDocument.removeListener (this); + } + + //============================================================================== + // ARADocument::Listener overrides + void didReorderRegionSequencesInDocument (ARADocument*) override + { + invalidateRegionSequenceViews(); + } + + void didAddRegionSequenceToDocument (ARADocument*, ARARegionSequence*) override + { + invalidateRegionSequenceViews(); + } + + void willRemoveRegionSequenceFromDocument (ARADocument*, ARARegionSequence* regionSequence) override + { + removeRegionSequenceView (regionSequence); + } + + void didEndEditing (ARADocument*) override + { + rebuildRegionSequenceViews(); + update(); + } + + //============================================================================== + void changeListenerCallback (ChangeBroadcaster*) override + { + update(); + } + + //============================================================================== + // ARAEditorView::Listener overrides + void onNewSelection (const ARA::PlugIn::ViewSelection&) override + { + } + + void onHideRegionSequences (const std::vector&) override + { + } + + //============================================================================== + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).darker()); + } + + void resized() override + { + auto bounds = getLocalBounds(); + const auto bottomControlsBounds = bounds.removeFromBottom (40); + const auto headerBounds = bounds.removeFromLeft (headerWidth).reduced (2); + + zoomControls.setBounds (bottomControlsBounds); + layOutVertically (headerBounds, trackHeaders, viewportHeightOffset); + tracksBackground.setBounds (bounds); + viewport.setBounds (bounds); + overlay.setBounds (bounds); + } + + //============================================================================== + void setZoomLevel (double pixelPerSecond) + { + zoomLevelPixelPerSecond = pixelPerSecond; + + for (const auto& view : regionSequenceViews) + view.second->setZoomLevel (zoomLevelPixelPerSecond); + + overlay.setZoomLevel (zoomLevelPixelPerSecond); + + update(); + } + + static constexpr int headerWidth = 120; + +private: + struct RegionSequenceViewKey + { + explicit RegionSequenceViewKey (ARARegionSequence* regionSequence) + : orderIndex (regionSequence->getOrderIndex()), sequence (regionSequence) + { + } + + bool operator< (const RegionSequenceViewKey& other) const + { + return std::tie (orderIndex, sequence) < std::tie (other.orderIndex, other.sequence); + } + + ARA::ARAInt32 orderIndex; + ARARegionSequence* sequence; + }; + + void zoom (double factor) + { + zoomLevelPixelPerSecond = jlimit (minimumZoom, minimumZoom * 32, zoomLevelPixelPerSecond * factor); + setZoomLevel (zoomLevelPixelPerSecond); + } + + template + void layOutVertically (Rectangle bounds, T& components, int verticalOffset = 0) + { + bounds = bounds.withY (bounds.getY() - verticalOffset).withHeight (bounds.getHeight() + verticalOffset); + + for (auto& component : components) + { + component.second->setBounds (bounds.removeFromTop (trackHeight)); + component.second->resized(); + } + } + + void update() + { + timelineLength = 0.0; + + for (const auto& view : regionSequenceViews) + timelineLength = std::max (timelineLength, view.second->getPlaybackDuration()); + + const Rectangle timelineSize (roundToInt (timelineLength * zoomLevelPixelPerSecond), + (int) regionSequenceViews.size() * trackHeight); + viewport.content.setSize (timelineSize.getWidth(), timelineSize.getHeight()); + viewport.content.resized(); + + resized(); + } + + void addTrackViews (ARARegionSequence* regionSequence) + { + const auto insertIntoMap = [](auto& map, auto key, auto value) -> auto& + { + auto it = map.insert ({ std::move (key), std::move (value) }); + return *(it.first->second); + }; + + auto& regionSequenceView = insertIntoMap ( + regionSequenceViews, + RegionSequenceViewKey { regionSequence }, + std::make_unique (*regionSequence, waveformCache, zoomLevelPixelPerSecond)); + + regionSequenceView.addChangeListener (this); + viewport.content.addAndMakeVisible (regionSequenceView); + + auto& trackHeader = insertIntoMap (trackHeaders, + RegionSequenceViewKey { regionSequence }, + std::make_unique (*regionSequence)); + + addAndMakeVisible (trackHeader); + } + + void removeRegionSequenceView (ARARegionSequence* regionSequence) + { + const auto& view = regionSequenceViews.find (RegionSequenceViewKey { regionSequence }); + + if (view != regionSequenceViews.cend()) + { + removeChildComponent (view->second.get()); + regionSequenceViews.erase (view); + } + + invalidateRegionSequenceViews(); + } + + void invalidateRegionSequenceViews() + { + regionSequenceViewsAreValid = false; + rebuildRegionSequenceViews(); + } + + void rebuildRegionSequenceViews() + { + if (! regionSequenceViewsAreValid && ! araDocument.getDocumentController()->isHostEditingDocument()) + { + for (auto& view : regionSequenceViews) + removeChildComponent (view.second.get()); + + regionSequenceViews.clear(); + + for (auto& view : trackHeaders) + removeChildComponent (view.second.get()); + + trackHeaders.clear(); + + for (auto* regionSequence : araDocument.getRegionSequences()) + { + addTrackViews (regionSequence); + } + + update(); + + regionSequenceViewsAreValid = true; + } + } + + class TracksBackgroundComponent : public Component + { + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).brighter()); + } + }; + + static constexpr auto minimumZoom = 10.0; + static constexpr auto trackHeight = 60; + + ARADocument& araDocument; + + bool regionSequenceViewsAreValid = false; + double timelineLength = 0; + double zoomLevelPixelPerSecond = minimumZoom * 4; + + WaveformCache waveformCache; + TracksBackgroundComponent tracksBackground; + std::map> trackHeaders; + std::map> regionSequenceViews; + VerticalLayoutViewport viewport; + OverlayComponent overlay; + ZoomControls zoomControls; + + int viewportHeightOffset = 0; +}; + + +class ARADemoPluginProcessorEditor : public AudioProcessorEditor, + public AudioProcessorEditorARAExtension +{ +public: + explicit ARADemoPluginProcessorEditor (ARADemoPluginAudioProcessorImpl& p) + : AudioProcessorEditor (&p), + AudioProcessorEditorARAExtension (&p) + { + if (auto* editorView = getARAEditorView()) + { + auto* document = ARADocumentControllerSpecialisation::getSpecialisedDocumentController(editorView->getDocumentController())->getDocument(); + documentView = std::make_unique (*document, + [this]() { return getAudioProcessor()->getPlayHead(); }); + } + + addAndMakeVisible (documentView.get()); + + // ARA requires that plugin editors are resizable to support tight integration + // into the host UI + setResizable (true, false); + setSize (400, 300); + } + + //============================================================================== + void paint (Graphics& g) override + { + g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId)); + + if (! isARAEditorView()) + { + g.setColour (Colours::white); + g.setFont (15.0f); + g.drawFittedText ("ARA host isn't detected. This plugin only supports ARA mode", + getLocalBounds(), + Justification::centred, + 1); + } + } + + void resized() override + { + if (documentView != nullptr) + documentView->setBounds (getLocalBounds()); + } + +private: + std::unique_ptr documentView; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ARADemoPluginProcessorEditor) +}; + +class ARADemoPluginAudioProcessor : public ARADemoPluginAudioProcessorImpl +{ +public: + bool hasEditor() const override { return true; } + AudioProcessorEditor* createEditor() override { return new ARADemoPluginProcessorEditor (*this); } +};