/* ============================================================================== PluginProcessor.cpp Created: 5 Sep 2023 Author: binya ============================================================================== */ #include "PluginProcessor.h" #include "PluginEditor.h" #include "PluginParameters.h" #include "Sampler/CustomSynthesizer.h" #if JUCE_DEBUG #include "Utilities/BufferUtils.h" #endif JustaSampleAudioProcessor::JustaSampleAudioProcessor() #ifndef JucePlugin_PreferredChannelConfigurations : AudioProcessor(BusesProperties() #if ! JucePlugin_IsMidiEffect #if ! JucePlugin_IsSynth .withInput("Input", juce::AudioChannelSet::stereo(), true) #endif .withOutput("Output", juce::AudioChannelSet::stereo(), true) #endif ), apvts(*this, &undoManager, "Parameters", PluginParameters::createParameterLayout()), samplerSound(apvts, pluginState, sampleBuffer, int(bufferSampleRate)), fileFilter("", {}, {}), deviceRecorder(deviceManager) #endif { deviceRecorder.addListener(this); pitchDetector.addListener(this); formatManager.registerBasicFormats(); fileFilter = juce::WildcardFileFilter(formatManager.getWildcardForAllFormats(), {}, {}); mtsClient = MTS_RegisterClient(); } JustaSampleAudioProcessor::~JustaSampleAudioProcessor() { for (int i = synth.getNumVoices() - 1; i >= 0; i--) synth.removeVoiceWithoutDeleting(i); MTS_DeregisterClient(mtsClient); } #ifndef JucePlugin_PreferredChannelConfigurations bool JustaSampleAudioProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const { #if JucePlugin_IsMidiEffect juce::ignoreUnused(layouts); return true; #else // This is the place where you check if the layout is supported. // In this template code we only support mono or stereo. // Some plugin hosts, such as certain GarageBand versions, will only // load plugins that support stereo bus layouts. if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::mono() && layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) return false; // This checks if the input layout matches the output layout #if ! JucePlugin_IsSynth if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet()) return false; #endif return true; #endif } #endif bool JustaSampleAudioProcessor::acceptsMidi() const { #if JucePlugin_WantsMidiInput return true; #else return false; #endif } bool JustaSampleAudioProcessor::producesMidi() const { #if JucePlugin_ProducesMidiOutput return true; #else return false; #endif } bool JustaSampleAudioProcessor::isMidiEffect() const { #if JucePlugin_IsMidiEffect return true; #else return false; #endif } juce::AudioProcessorEditor* JustaSampleAudioProcessor::createEditor() { // A bit of a hack to set a default look and feel for the editor as it's created without sharing the object globally juce::LookAndFeel::setDefaultLookAndFeel(&lookAndFeel); auto* editor = new JustaSampleAudioProcessorEditor(*this); editor->setLookAndFeel(&lookAndFeel); juce::LookAndFeel::setDefaultLookAndFeel(nullptr); return editor; } juce::VST3ClientExtensions* JustaSampleAudioProcessor::getVST3ClientExtensions() { if (PluginParameters::REAPER_INTEGRATION_ENABLED && hostType.isReaper() && wrapperType == wrapperType_VST3) return &reaperExtensions; return nullptr; } void JustaSampleAudioProcessor::prepareToPlay(double sampleRate, int /*maximumExpectedSamplesPerBlock*/) { juce::ScopedLock lock(voiceLock); synth.clearSounds(); synth.addSound(new BlankSynthesizerSound()); for (int i = synth.getNumVoices() - 1; i >= 0; i--) synth.removeVoiceWithoutDeleting(i); samplerVoices.clear(); synth.setCurrentPlaybackSampleRate(sampleRate); for (int i = 0; i < PluginParameters::MAX_VOICES; i++) { const bool initializeSample = samplerSound.sampleRate > 0 && samplerSound.sample.getNumSamples() > 0; auto* voice = new CustomSamplerVoice(samplerSound, mtsClient, sampleRate, getBlockSize(), initializeSample); samplerVoices.add(voice); } } void JustaSampleAudioProcessor::releaseResources() { // When playback stops, this is a place to clean up resources } void JustaSampleAudioProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) { juce::ScopedNoDenormals noDenormals; auto totalNumInputChannels = getTotalNumInputChannels(); auto totalNumOutputChannels = getTotalNumOutputChannels(); for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i) buffer.clear(i, 0, buffer.getNumSamples()); // Handle VST3 Reaper extensions functionality if (PluginParameters::REAPER_INTEGRATION_ENABLED && hostType.isReaper() && wrapperType == wrapperType_VST3) { auto file = reaperExtensions.getNamedConfigParam(REAPER_FILE_PATH); juce::File filePath{ file }; auto currentFile = lastLoadAttempt.isNotEmpty() ? lastLoadAttempt : juce::String(pluginState.filePath); juce::File currentFilePath{ currentFile }; if (file.isNotEmpty() && filePath.getFileIdentifier() != currentFilePath.getFileIdentifier()) { loadedFromReaper = true; loadSampleFromPath(file, true, "", false, [&](bool loaded) -> void { if (!loaded) loadedFromReaper = false; }); } } if (sampleBuffer.getNumSamples() == 0 || sampleBuffer.getNumChannels() == 0) return; juce::ScopedTryLock lock(voiceLock); if (lock.isLocked()) { adjustVoiceCount(); synth.renderNextBlock(buffer, midiMessages, 0, buffer.getNumSamples()); #if JUCE_DEBUG for (int ch = 0; ch < buffer.getNumChannels(); ch++) { protectYourEars(buffer.getWritePointer(ch), buffer.getNumSamples()); } #endif } } void JustaSampleAudioProcessor::adjustVoiceCount(int count) { int numVoices = juce::jmax(1, p(PluginParameters::NUM_VOICES)); if (count >= 0) numVoices = count; int currentVoices = synth.getNumVoices(); // We never need to instantiate new voices if (currentVoices > numVoices) { for (int i = currentVoices - 1; i >= numVoices; --i) { synth.removeVoiceWithoutDeleting(i); samplerVoices[i]->immediateHalt(); } } if (currentVoices < numVoices) { for (int i = currentVoices; i < numVoices; ++i) { synth.addVoice(samplerVoices[i]); } } } //============================================================================== void JustaSampleAudioProcessor::getStateInformation(juce::MemoryBlock& destData) { // All state properties should be saved at this point spv(PluginParameters::State::WIDTH) = pluginState.width.load(); spv(PluginParameters::State::HEIGHT) = pluginState.height.load(); spv(PluginParameters::State::FILE_PATH) = pluginState.filePath.load(); spv(PluginParameters::State::SAMPLE_HASH) = pluginState.sampleHash.load(); spv(PluginParameters::State::USING_FILE_REFERENCE) = pluginState.usingFileReference.load(); spv(PluginParameters::State::RECENT_FILES) = pluginState.recentFiles.load(); if (const auto stateXml = deviceManager.createStateXml()) spv(PluginParameters::State::SAVED_DEVICE_SETTINGS) = stateXml->toString(); spv(PluginParameters::State::UI_VIEW_START) = pluginState.viewStart.load(); spv(PluginParameters::State::UI_VIEW_END) = pluginState.viewEnd.load(); spv(PluginParameters::State::PIN_VIEW) = pluginState.pinView.load(); spv(PluginParameters::State::PRIMARY_CHANNEL) = pluginState.primaryChannel.load(); spv(PluginParameters::State::SAMPLE_START) = pluginState.sampleStart.load(); spv(PluginParameters::State::SAMPLE_END) = pluginState.sampleEnd.load(); spv(PluginParameters::State::LOOP_START) = pluginState.loopStart.load(); spv(PluginParameters::State::LOOP_END) = pluginState.loopEnd.load(); spv(PluginParameters::State::SHOW_FX) = pluginState.showFX.load(); spv(PluginParameters::State::DARK_MODE) = pluginState.darkMode.load(); // Then, write empty "header" information to the stream size_t initialSize{ 0 }; size_t apvtsSize{ 0 }; { auto apvtsMos = juce::MemoryOutputStream{ destData, true }; apvtsMos.writeInt(0); // apvts size apvtsMos.writeInt(0); // sample size initialSize = apvtsMos.getDataSize(); apvts.state.writeToStream(apvtsMos); apvtsSize = apvtsMos.getDataSize() - initialSize; } size_t sampleSize = 0; // If we're not using a file reference, write the sample buffer to the stream if (!pluginState.usingFileReference && sampleBuffer.getNumSamples()) { juce::WavAudioFormat wavFormat; auto options = juce::AudioFormatWriterOptions{} .withSampleRate(bufferSampleRate) .withNumChannels(sampleBuffer.getNumChannels()) .withBitsPerSample(PluginParameters::STORED_BITRATE); std::unique_ptr stream = std::make_unique(destData, true); auto formatWriter = wavFormat.createWriterFor(stream, options); formatWriter->writeFromAudioSampleBuffer(sampleBuffer, 0, sampleBuffer.getNumSamples()); formatWriter.reset(); sampleSize = destData.getSize() - apvtsSize - initialSize; } // Write the header auto headerMos = juce::MemoryOutputStream{ destData, true }; headerMos.setPosition(0); headerMos.writeInt(int(apvtsSize)); headerMos.writeInt(int(sampleSize)); } void JustaSampleAudioProcessor::setStateInformation(const void* data, int sizeInBytes) { // First, read the header information which includes the sizes of the APVTS and sample buffer juce::MemoryInputStream mis(data, sizeInBytes, false); size_t apvtsSize = mis.readInt(); size_t sampleSize = mis.readInt(); if (size_t(sizeInBytes) != apvtsSize + sampleSize + 8) return; // format issue // Read the APVTS juce::SubregionStream apvtsStream{ &mis, mis.getPosition(), juce::int64(apvtsSize), false }; auto tree = juce::ValueTree::readFromStream(apvtsStream); if (tree.isValid()) { apvts.replaceState(tree); // Load the plugin state struct pluginState.width = sp(PluginParameters::State::WIDTH); pluginState.height = sp(PluginParameters::State::HEIGHT); pluginState.filePath = sp(PluginParameters::State::FILE_PATH); pluginState.usingFileReference = sp(PluginParameters::State::USING_FILE_REFERENCE); pluginState.pinView = sp(PluginParameters::State::PIN_VIEW); pluginState.primaryChannel = sp(PluginParameters::State::PRIMARY_CHANNEL); pluginState.showFX = sp(PluginParameters::State::SHOW_FX); pluginState.darkMode = sp(PluginParameters::State::DARK_MODE); // We'd rather wait to update certain fields until the sample is actually loaded. This is usually irrelevant, but if the DAW // allows undo and redo then a previous sample will likely be loaded while the new one is loading, so the new info will not make sense. auto sampleHash = sp(PluginParameters::State::SAMPLE_HASH); auto updateFileInfo = [this, sampleHash, viewStart = sp(PluginParameters::State::UI_VIEW_START), viewEnd = sp(PluginParameters::State::UI_VIEW_END), sampleStart = sp(PluginParameters::State::SAMPLE_START), sampleEnd = sp(PluginParameters::State::SAMPLE_END), loopStart = sp(PluginParameters::State::LOOP_START), loopEnd = sp(PluginParameters::State::LOOP_END)] { pluginState.sampleHash = sampleHash; pluginState.viewStart = viewStart; pluginState.viewEnd = viewEnd; pluginState.sampleStart = sampleStart; pluginState.sampleEnd = sampleEnd; pluginState.loopStart = loopStart; pluginState.loopEnd = loopEnd; }; bool newFile = pluginState.sampleHash != sp(PluginParameters::State::SAMPLE_HASH).toString(); if (!newFile) updateFileInfo(); auto recentFiles{ sp(PluginParameters::State::RECENT_FILES) }; juce::StringArray fileArray{}; for (int i = recentFiles.size() - 1; i >= 0; i--) fileArray.add(recentFiles[i].toString()); pluginState.recentFiles = fileArray; juce::XmlDocument deviceSettingsDocument{ sp(PluginParameters::State::SAVED_DEVICE_SETTINGS) }; deviceManagerLoadedState = deviceSettingsDocument.getDocumentElement(); auto currentState = deviceManager.createStateXml(); deviceManagerLoaded = currentState && currentState->isEquivalentTo(&*deviceManagerLoadedState, true); // Either load the sample from the file reference or from the stream directly juce::String filePath = pluginState.filePath; if (pluginState.usingFileReference && filePath.isNotEmpty() && newFile) { loadSampleFromPath(filePath, false, sampleHash, false, [this, filePath, updateFileInfo, sampleHash](bool fileLoaded) -> void { if (!fileLoaded) // Either the file was not found, loaded incorrectly, or the hash was incorrect { openFileChooser("File was not found. Please locate " + juce::File(filePath).getFileName(), juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, [this, updateFileInfo, sampleHash](const juce::FileChooser& chooser) { auto file = chooser.getResult(); loadSampleFromPath(file.getFullPathName(), false, sampleHash, true, [updateFileInfo](bool loaded) { if (loaded) updateFileInfo(); }); }); } else updateFileInfo(); }); } else if (sampleSize && newFile) { // A copy must be made to allow reading in another thread, outside the lifetime of this function auto sampleData = std::make_shared(sampleSize); mis.read(sampleData->getData(), int(sampleSize)); auto wavStream = new juce::MemoryInputStream(*sampleData, false); juce::WavAudioFormat wavFormat; std::unique_ptr wavFormatReader{ wavFormat.createReaderFor(wavStream, true) }; if (wavFormatReader) { lastLoadAttempt = ""; reaperExtensions.setNamedConfigParam(REAPER_FILE_PATH, ""); sampleLoader.loadSample(std::move(wavFormatReader), [this, sampleData /* necessary capture */, updateFileInfo, sampleHash] (const std::unique_ptr>& loadedSample, const juce::String&, const std::unique_ptr& reader) -> void { if (!reader) return; loadSample(*loadedSample, int(reader->sampleRate), false, sampleHash); updateFileInfo(); }); } } } } //============================================================================== void JustaSampleAudioProcessor::loadSample(juce::AudioBuffer& sample, int sampleRate, bool resetParameters, const juce::String& precomputedHash) { juce::ScopedLock lock(voiceLock); haltVoices(); sampleBuffer = std::move(sample); bufferSampleRate = float(sampleRate); if (precomputedHash.isNotEmpty()) pluginState.sampleHash = precomputedHash; else pluginState.sampleHash = getSampleHash(sampleBuffer); if (resetParameters) { pluginState.usingFileReference = sampleBufferNeedsReference(); pluginState.sampleStart = 0; pluginState.sampleEnd = sampleBuffer.getNumSamples() - 1; // The order here is actually important, because the start and end buttons can otherwise enable the main loop button pv(PluginParameters::LOOPING_HAS_START) = false; pv(PluginParameters::LOOPING_HAS_END) = false; pv(PluginParameters::IS_LOOPING) = false; pluginState.loopStart = 0; pluginState.loopEnd = sampleBuffer.getNumSamples() - 1; } samplerSound.sampleChanged(int(bufferSampleRate)); for (const auto& voice : samplerVoices) voice->initializeSample(); } void JustaSampleAudioProcessor::loadSampleFromPath(const juce::String& path, bool resetParameters, const juce::String& expectedHash, bool continueWithWrongHash, const std::function& callback) { const juce::File file{ path }; std::unique_ptr formatReader{ formatManager.createReaderFor(file) }; if (!formatReader || !formatReader->lengthInSamples) return callback(false); lastLoadAttempt = path; reaperExtensions.setNamedConfigParam(REAPER_FILE_PATH, ""); // Load the file and check the hash sampleLoader.loadSample(std::move(formatReader), [this, callback, path, expectedHash, continueWithWrongHash, resetParameters] (const std::unique_ptr>& loadedSample, const juce::String& sampleHash, const std::unique_ptr& reader) -> void { if (expectedHash.isNotEmpty() && sampleHash != expectedHash && !continueWithWrongHash) return callback(false); pluginState.filePath = path; loadSample(*loadedSample, int(reader->sampleRate), resetParameters || (sampleHash != expectedHash && continueWithWrongHash), sampleHash); return callback(true); }); } //============================================================================== bool JustaSampleAudioProcessor::canLoadFileExtension(const juce::String& filePath) const { return fileFilter.isFileSuitable(filePath); } bool JustaSampleAudioProcessor::sampleBufferNeedsReference(const juce::AudioBuffer& buffer) const { double totalFileBits = double(buffer.getNumSamples()) * buffer.getNumChannels() * PluginParameters::STORED_BITRATE; return totalFileBits > PluginParameters::MAX_FILE_SIZE; } //============================================================================== void JustaSampleAudioProcessor::openFileChooser(const juce::String& message, int flags, const std::function& callback, bool wavOnly) { // DAW specific exceptions bool useNative = !hostType.isArdour(); fileChooser = std::make_unique(message, juce::File::getSpecialLocation(juce::File::userDesktopDirectory), wavOnly ? "*.wav" : formatManager.getWildcardForAllFormats(), useNative); juce::MessageManager::callAsync([this, flags, callback] { fileChooser->launchAsync(flags, callback); }); } void JustaSampleAudioProcessor::haltVoices() { for (auto voice : samplerVoices) { voice->immediateHalt(); } } void JustaSampleAudioProcessor::playVoice() { synth.noteOn(0, 70, 1.0f); } void JustaSampleAudioProcessor::initializeDeviceManager() { if (!deviceManagerLoaded) { deviceManager.initialise(2, 0, &*deviceManagerLoadedState, true); deviceManagerLoaded = true; } } void JustaSampleAudioProcessor::recordingFinished(juce::AudioBuffer recordingBuffer, int recordingSampleRate) { // If the recording is too large, we prompt to save it to a file, otherwise load it into the plugin simply if (sampleBufferNeedsReference(recordingBuffer)) { openFileChooser("Recording too large for plugin state, save to a file", juce::FileBrowserComponent::saveMode | juce::FileBrowserComponent::canSelectFiles, [this, recordingBuffer, recordingSampleRate](const juce::FileChooser& chooser) -> void { juce::File file = chooser.getResult(); auto fileStream = std::make_unique(file); if (file.hasWriteAccess() && fileStream->openedOk()) { fileStream->setPosition(0); fileStream->truncate(); juce::WavAudioFormat wavFormat; auto options = juce::AudioFormatWriterOptions{} .withSampleRate(recordingSampleRate) .withNumChannels(recordingBuffer.getNumChannels()) .withBitsPerSample(PluginParameters::STORED_BITRATE); std::unique_ptr outputStream = std::move(fileStream); auto formatWriter = wavFormat.createWriterFor(outputStream, options); formatWriter->writeFromAudioSampleBuffer(recordingBuffer, 0, recordingBuffer.getNumSamples()); outputStream.release(); loadSampleFromPath(file.getFullPathName(), true); } }, true); } else { pluginState.filePath = ""; lastLoadAttempt = ""; reaperExtensions.setNamedConfigParam(REAPER_FILE_PATH, ""); loadSample(recordingBuffer, recordingSampleRate, true); } } bool JustaSampleAudioProcessor::startPitchDetectionRoutine(int startSample, int endSample) { if (!sampleBuffer.getNumSamples()) return false; // Truth be told, this does not need to be in a separate thread, but the algorithm I was using before was much slower pitchDetector.setData(sampleBuffer, startSample, endSample, bufferSampleRate); pitchDetector.startThread(); return true; } void JustaSampleAudioProcessor::exitSignalSent() { double pitch = pitchDetector.getPitch(); if (pitch > 0) { float a4_hz = p(PluginParameters::A4_HZ); if (a4_hz <= 0) a4_hz = 440.0f; // Large pitch adjustments result in worse quality, so we limit to +/- 6 semitones for this feature float tuningAmount = 12.f * std::log2(a4_hz / pitch); float rangeMin = -6.f; float rangeMax = 6.f; float range = rangeMax - rangeMin; tuningAmount = std::fmod(tuningAmount - rangeMin, range); if (tuningAmount < 0) tuningAmount += range; tuningAmount += rangeMin; int semitones = int(tuningAmount); int cents = int(100 * (tuningAmount - semitones)); apvts.getParameterAsValue(PluginParameters::SEMITONE_TUNING) = semitones; apvts.getParameterAsValue(PluginParameters::CENT_TUNING) = cents; apvts.getParameterAsValue(PluginParameters::WIDE_TUNING) = 0.0f; } } //============================================================================== int JustaSampleAudioProcessor::visibleSamples() const { return pluginState.viewEnd.load() - pluginState.viewStart.load() + 1; } //============================================================================== // This creates new instances of the plugin... juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { return new JustaSampleAudioProcessor(); }