/* ============================================================================== CustomSamplerVoice.cpp Created: 5 Sep 2023 3:35:03pm Author: binya ============================================================================== */ #include "CustomSamplerVoice.h" #include "../Utilities/BufferUtils.h" #include "Effects/BandEQ.h" #include "Effects/Chorus.h" #include "Effects/Distortion.h" #include "Effects/Reverb.h" CustomSamplerVoice::CustomSamplerVoice(const SamplerParameters& samplerSound, MTSClient* client, double applicationSampleRate, int expectedBlockSize, bool initSample) : expectedBlockSize(expectedBlockSize), sampleSound(samplerSound), mainStretcher(samplerSound.sample, samplerSound.sampleRate), loopStretcher(samplerSound.sample, samplerSound.sampleRate), endStretcher(samplerSound.sample, samplerSound.sampleRate), mtsClient(client) { SynthesiserVoice::setCurrentPlaybackSampleRate(applicationSampleRate); if (expectedBlockSize <= 0) this->expectedBlockSize = 512; // In case a DAW reports this incorrectly at the time of prepareToPlay if (initSample) initializeSample(); } void CustomSamplerVoice::initializeSample() { if (sampleSound.sample.getNumChannels() <= 0) return; mainStretcher = BungeeStretcher(sampleSound.sample, sampleSound.sampleRate); loopStretcher = BungeeStretcher(sampleSound.sample, sampleSound.sampleRate); endStretcher = BungeeStretcher(sampleSound.sample, sampleSound.sampleRate); const int sampleRate = int(getSampleRate()); if (sampleRate > 0) { mainStretcher.preallocateStretcher(sampleRate); loopStretcher.preallocateStretcher(sampleRate); endStretcher.preallocateStretcher(sampleRate); } tempOutputBuffer.setSize(sampleSound.sample.getNumChannels(), expectedBlockSize * 2); envelopeBuffer.setSize(sampleSound.sample.getNumChannels(), expectedBlockSize * 2); tailOffBuffer.setSize(2, TAIL_OFF, false, true); mainStretcherBuffer.setSize(sampleSound.sample.getNumChannels(), expectedBlockSize * 2); loopStretcherBuffer.setSize(sampleSound.sample.getNumChannels() - 1, expectedBlockSize * 2); endStretcherBuffer.setSize(sampleSound.sample.getNumChannels() - 1, expectedBlockSize * 2); mainLowpass.clear(); loopLowpass.clear(); endLowpass.clear(); for (int i = 0; i < sampleSound.sample.getNumChannels(); i++) { mainLowpass.emplace_back(std::make_unique(2 * LANCZOS_WINDOW_SIZE)); loopLowpass.emplace_back(std::make_unique(2 * LANCZOS_WINDOW_SIZE)); endLowpass.emplace_back(std::make_unique(2 * LANCZOS_WINDOW_SIZE)); } } void CustomSamplerVoice::startNote(int midiNoteNumber, float velocity, juce::SynthesiserSound* sound, int currentPitchWheelPosition) { if (midiNoteNumber < sampleSound.midiStart->get() || midiNoteNumber > sampleSound.midiEnd->get() || MTS_ShouldFilterNote(mtsClient, char(midiNoteNumber), -1)) return; if (!sampleSound.disableVelocity->get()) noteVelocity = velocity; else noteVelocity = 1.f; if (sound) { sampleRateConversion = float(sampleSound.sampleRate / getSampleRate()); sampleStart = sampleSound.sampleStart; // Note this implicitly loads the atomic value sampleEnd = sampleSound.sampleEnd; wavetableMode = isWavetableModeAvailable(float(sampleSound.sampleRate), sampleStart, sampleEnd) && !sampleSound.disableWavetableMode->get(); playbackMode = wavetableMode ? PluginParameters::BASIC : sampleSound.getPlaybackMode(); playUntilEnd = sampleSound.playUntilEnd->get(); isLooping = sampleSound.isLooping->get() || wavetableMode; loopingHasStart = isLooping && sampleSound.loopingHasStart->get() && sampleSound.loopStart < sampleSound.sampleStart && !wavetableMode; loopStart = sampleSound.loopStart; loopingHasEnd = isLooping && sampleSound.loopingHasEnd->get() && sampleSound.loopEnd > sampleSound.sampleEnd && !wavetableMode; loopEnd = sampleSound.loopEnd; effectiveStart = loopingHasStart ? loopStart : sampleStart; effectiveEnd = loopingHasEnd ? loopEnd : sampleEnd; // While release can be applied before or after the FX, a minimum number of attack smoothing always needs to be applied to the sample before FX attackSmoothing = sampleSound.attack->get() * float(getSampleRate()) / 1000.f; releaseSmoothing = sampleSound.release->get() * float(getSampleRate()) / 1000.f; attackShape = sampleSound.attackShape->get(); releaseShape = sampleSound.releaseShape->get(); if (loopingHasEnd) // Keep release smoothing within end portion releaseSmoothing = juce::jmin(releaseSmoothing, float(loopEnd - sampleEnd)); crossfade = juce::jmin(float(sampleSound.crossfadeSamples->get()), (sampleEnd - sampleStart + 1) / 2.f + 1); vc = VoiceContext(); midiReleased = false; doLowpass = false; vc.currentPosition = effectiveStart; updateSpeedAndPitch(midiNoteNumber, currentPitchWheelPosition); if (playbackMode == PluginParameters::BUNGEE) mainStretcher.initialize(effectiveStart, tuning, speedFactor); effects.clear(); initializeFx(); for (auto& effect : effects) { effect.fx->initialize(sampleSound.sample.getNumChannels(), int(getSampleRate())); effect.fx->updateParams(sampleSound); } updateFXParamsTimer = 0; // Set the initial state (vc.currentPosition is set before updateSpeedAndPitch) vc.state = PLAYING; vc.isSmoothingAttack = attackSmoothing > 0; } } void CustomSamplerVoice::stopNote(float /*velocity*/, bool allowTailOff) { if (allowTailOff) { if (!playUntilEnd || isLooping) midiReleased = true; } else { // We render a quick tail-off to avoid clicks juce::AudioBuffer temp{ tailOffBuffer.getNumChannels(), tailOffBuffer.getNumSamples() }; temp.clear(); renderNextBlock(temp, 0, TAIL_OFF); tailOffBuffer = temp; tailOff = 0; vc.state = STOPPED; clearCurrentNote(); } } void CustomSamplerVoice::immediateHalt() { vc.state = STOPPED; clearCurrentNote(); } void CustomSamplerVoice::pitchWheelMoved(int newPitchWheelValue) { updateSpeedAndPitch(getCurrentlyPlayingNote(), newPitchWheelValue); } void CustomSamplerVoice::updateSpeedAndPitch(int currentNote, int pitchWheelPosition) { pitchWheel = pitchWheelPosition; // Account for tuning adjustments float tuningRatio = 1.f; if (playbackMode == PluginParameters::BASIC && wavetableMode) tuningRatio *= std::pow(2.f, (sampleSound.waveformSemitoneTuning->get() + sampleSound.waveformCentTuning->get() / 100.f) / 12.f); else tuningRatio *= std::pow(2.f, (sampleSound.semitoneTuning->get() + sampleSound.centTuning->get() / 100.f) / 12.f); float wheelRange = sampleSound.pitchWheelRange->get(); tuningRatio *= std::pow(2.f, juce::jmap(float(pitchWheelPosition), 0.f, 16383.f, -wheelRange, wheelRange) / 12.f); tuningRatio *= std::pow(2.f, sampleSound.wideTuningControl->get() / 12.f); // Account for MIDI note if (sampleSound.followMidiPitch->get()) { int rootNote = sampleSound.midiRoot->get(); tuningRatio *= std::pow(2.f, (currentNote - rootNote) / 12.f); tuningRatio *= float(MTS_RetuningAsRatio(mtsClient, char(currentNote), -1)); } tuning = tuningRatio; speedFactor = sampleSound.speedFactor->get(); speedFactor *= 1.f + (tuning - 1.f) * sampleSound.octaveSpeedFactor->get(); if (playbackMode == PluginParameters::BASIC) { // In wavetable mode, the sample is treated as a single cycle // Otherwise, the sample is simply sped up according to the tuning float a4_hz = sampleSound.a4_freq->get(); speed = wavetableMode ? (tuning * a4_hz) * (sampleEnd - sampleStart + 1 - crossfade) / float(getSampleRate()) / 2.f : tuning * sampleRateConversion; // Configure the filters auto frequency = sampleSound.sampleRate / 2.f / speed; auto filterLimit = sampleSound.sampleRate / 2.f - 10.f; // We've run into some issues when the filter is too close to the Nyquist frequency bool wasLowpass = doLowpass; doLowpass = speed > 1.f && frequency < filterLimit; if (!wasLowpass && doLowpass) { for (int ch = 0; ch < sampleSound.sample.getNumChannels(); ch++) mainLowpass[ch]->resetProcessing(int(vc.currentPosition)); } if (doLowpass) { for (int ch = 0; ch < sampleSound.sample.getNumChannels(); ch++) { mainLowpass[ch]->setCoefficients(sampleSound.sampleRate, frequency); loopLowpass[ch]->setCoefficients(sampleSound.sampleRate, frequency); endLowpass[ch]->setCoefficients(sampleSound.sampleRate, frequency); } } } else { // Update the stretchers mainStretcher.setPitchAndSpeed(tuning, speedFactor); loopStretcher.setPitchAndSpeed(tuning, speedFactor); endStretcher.setPitchAndSpeed(tuning, speedFactor); speed = speedFactor * sampleRateConversion; } } //============================================================================== void CustomSamplerVoice::renderNextBlock(juce::AudioBuffer& outputBuffer, int startSample, int numSamples) { if (vc.state == STOPPED && (!doFxTailOff || !getCurrentlyPlayingSound())) { clearCurrentNote(); return; } // These resizes will happen rarely, if at all if (tempOutputBuffer.getNumSamples() < numSamples) { tempOutputBuffer.setSize(tempOutputBuffer.getNumChannels(), numSamples); envelopeBuffer.setSize(envelopeBuffer.getNumChannels(), numSamples); } tempOutputBuffer.clear(); envelopeBuffer.clear(); if (playbackMode == PluginParameters::BUNGEE && loopStretcherBuffer.getNumSamples() < numSamples) { mainStretcherBuffer.setSize(mainStretcherBuffer.getNumChannels(), numSamples); loopStretcherBuffer.setSize(loopStretcherBuffer.getNumChannels(), numSamples); endStretcherBuffer.setSize(endStretcherBuffer.getNumChannels(), numSamples); } updateSpeedAndPitch(getCurrentlyPlayingNote(), pitchWheel); bool someFXEnabled{ false }; for (const auto& effect : effects) { someFXEnabled = someFXEnabled || effect.enablementSource->get(); } // Main processing loop VoiceContext con; for (auto ch = 0; ch < sampleSound.sample.getNumChannels(); ch++) { // This struct is used to easily process channel by channel con = vc; for (auto i = 0; i < numSamples; i++) { if (con.state == STOPPED) { con.samplesSinceStopped++; continue; } // Fetch the sample according to the playback mode float sample = playbackMode == PluginParameters::BASIC ? fetchSample(ch, con.currentPosition, mainLowpass) : nextSample(ch, &mainStretcher, mainStretcherBuffer, i); // Crossfading if (con.isCrossfadingLoop) { double crossfadePosition = con.currentPosition - sampleStart; if (crossfadePosition >= crossfade) { con.isCrossfadingLoop = false; } else { // Power preserving crossfade (https://www.youtube.com/watch?v=-5cB3rec2T0) float crossfadeIncrease = float(std::sqrt(0.5f + 0.5f * std::cos(juce::MathConstants::pi * crossfadePosition / crossfade + juce::MathConstants::pi))); float crossfadeDecrease = float(std::sqrt(0.5f + 0.5f * std::cos(juce::MathConstants::pi * crossfadePosition / crossfade))); float next = playbackMode == PluginParameters::BASIC ? fetchSample(ch, con.currentPosition + sampleEnd - sampleStart - crossfade, loopLowpass) : nextSample(ch, &loopStretcher, loopStretcherBuffer, i); sample = sample * crossfadeIncrease + next * crossfadeDecrease; } } if (con.isCrossfadingEnd) { double crossfadePosition = con.currentPosition - sampleEnd; if (crossfadePosition >= crossfade) { con.isCrossfadingEnd = false; } else { float crossfadeIncrease = float(std::sqrt(0.5f + 0.5f * std::cos(juce::MathConstants::pi * crossfadePosition / crossfade + juce::MathConstants::pi))); float crossfadeDecrease = float(std::sqrt(0.5f + 0.5f * std::cos(juce::MathConstants::pi * crossfadePosition / crossfade))); float next = playbackMode == PluginParameters::BASIC ? fetchSample(ch, con.crossfadeEndPosition, endLowpass) : nextSample(ch, &endStretcher, endStretcherBuffer, i); sample = sample * crossfadeIncrease + next * crossfadeDecrease; con.crossfadeEndPosition += speed; } } // Attack and release envelopes envelopeBuffer.setSample(ch, i, 1.f); if (con.isSmoothingAttack) { if (con.speedMovedSinceStart >= attackSmoothing) con.isSmoothingAttack = false; else envelopeBuffer.setSample(ch, i, exponentialCurve(attackShape, con.speedMovedSinceStart / attackSmoothing)); } if (con.isReleasing) envelopeBuffer.setSample(ch, i, envelopeBuffer.getSample(ch, i) * exponentialCurve(releaseShape, 1 - con.speedMovedSinceRelease / releaseSmoothing)); // Update the position con.currentPosition += speed; con.speedMovedSinceStart += 1; if (con.isReleasing) con.speedMovedSinceRelease += 1; // Handle transitions if (con.state == PLAYING && isLooping && con.currentPosition >= sampleEnd - crossfade) // Loop crossfade { con.currentPosition -= (sampleEnd - sampleStart + 1) - crossfade; con.isCrossfadingLoop = true; if (playbackMode == PluginParameters::BUNGEE && ch == 0) { std::swap(mainStretcher, loopStretcher); mainStretcher.initialize(con.currentPosition, tuning, speedFactor); // This could also be done at note start } else { std::swap(mainLowpass[ch], loopLowpass[ch]); mainLowpass[ch]->resetProcessing(int(con.currentPosition)); } } if (midiReleased && !con.isReleasing && con.state == PLAYING) // Midi release, end crossfade { if (loopingHasEnd) { con.crossfadeEndPosition = con.currentPosition; con.currentPosition = sampleEnd + 1; con.state = PLAYING_END; con.isCrossfadingEnd = true; if (playbackMode == PluginParameters::BUNGEE && ch == 0) { std::swap(mainStretcher, endStretcher); mainStretcher.initialize(con.currentPosition, tuning, speedFactor); // This could also be done at note start } else { std::swap(mainLowpass[ch], endLowpass[ch]); mainLowpass[ch]->resetProcessing(int(con.currentPosition)); } } else { con.isReleasing = true; } } if (((con.state == PLAYING && !isLooping) || con.state == PLAYING_END) && !con.isReleasing && con.currentPosition > effectiveEnd - releaseSmoothing * speed) // Release smoothing { con.isReleasing = true; } if (con.currentPosition > effectiveEnd || (con.isReleasing && con.speedMovedSinceRelease >= releaseSmoothing)) // End of playback reached con.state = STOPPED; // Scale with standard velocity curve sample *= juce::Decibels::decibelsToGain(40 * log10(noteVelocity)); sample *= juce::Decibels::decibelsToGain(float(sampleSound.gain->get())); tempOutputBuffer.setSample(ch, i, sample); } } vc = con; // Check for updated FX order if (updateFXParamsTimer == UPDATE_PARAMS_LENGTH) initializeFx(); // Apply envelope here or after FX if PRE_FX is enabled if (!sampleSound.applyFXPre->get()) for (int ch = 0; ch < tempOutputBuffer.getNumChannels(); ch++) juce::FloatVectorOperations::multiply(tempOutputBuffer.getWritePointer(ch), tempOutputBuffer.getReadPointer(ch), envelopeBuffer.getReadPointer(ch), numSamples); // Apply FX int reverbSampleDelay = int(1000.f + sampleSound.reverbPredelay->get() * float(getSampleRate()) / 1000.f); // the 1000.f is approximate someFXEnabled = false; for (auto& effect : effects) { // Check for updated enablement bool enablement = effect.enablementSource->get(); if (!effect.enabled && enablement) { effect.fx->initialize(sampleSound.sample.getNumChannels(), int(getSampleRate())); effect.fx->updateParams(sampleSound, false); } effect.enabled = enablement; someFXEnabled = someFXEnabled || effect.enabled; if (effect.enabled && !effect.locallyDisabled) { // Update params every UPDATE_PARAMS_LENGTH calls to process if (updateFXParamsTimer == UPDATE_PARAMS_LENGTH) effect.fx->updateParams(sampleSound, true); effect.fx->process(tempOutputBuffer, numSamples); // Check if an effect should be locally disabled. Note that reverb can only be disabled after a certain delay if (con.state == STOPPED && numSamples > 10 && !(effect.fxType == PluginParameters::REVERB && con.samplesSinceStopped <= reverbSampleDelay)) { bool disable{ true }; for (int ch = 0; ch < tempOutputBuffer.getNumChannels(); ch++) { float level = tempOutputBuffer.getRMSLevel(ch, 0, numSamples); if (level > 0) { disable = false; break; } } if (disable) effect.locallyDisabled = true; } } } if (sampleSound.applyFXPre->get()) for (int ch = 0; ch < tempOutputBuffer.getNumChannels(); ch++) juce::FloatVectorOperations::multiply(tempOutputBuffer.getWritePointer(ch), tempOutputBuffer.getReadPointer(ch), envelopeBuffer.getReadPointer(ch), numSamples); updateFXParamsTimer--; if (updateFXParamsTimer <= 0) updateFXParamsTimer = UPDATE_PARAMS_LENGTH; doFxTailOff = !sampleSound.applyFXPre->get() && someFXEnabled && !effects.empty(); // Check RMS level to see if a voice should be ended despite tailing off effects if (con.state == STOPPED && someFXEnabled && numSamples > 10 && con.samplesSinceStopped > reverbSampleDelay) { bool end{ true }; // Whether all channels are below the threshold for (int ch = 0; ch < tempOutputBuffer.getNumChannels(); ch++) { float level = tempOutputBuffer.getRMSLevel(ch, 0, numSamples); if (level >= PluginParameters::FX_TAIL_OFF_MAX) { end = false; break; } } if (end) { clearCurrentNote(); return; } } mixToBuffer(tempOutputBuffer, outputBuffer, startSample, numSamples, sampleSound.monoOutput->get()); // Add the previous tail-off samples to the output buffer tailOffBuffer.setSize(tempOutputBuffer.getNumChannels(), tailOffBuffer.getNumSamples(), true, true); int i = 0; for (; tailOff < TAIL_OFF; tailOff++) { if (i >= numSamples) break; for (int ch = 0; ch < tailOffBuffer.getNumChannels(); ch++) { float sample = tailOffBuffer.getSample(ch, tailOff) * (TAIL_OFF - tailOff) / TAIL_OFF; outputBuffer.addSample(ch, startSample + i, sample); } i++; } } float CustomSamplerVoice::fetchSample(int channel, double position, std::vector>& lowpassStreams) const { if (0 > position || position >= float(sampleSound.sample.getNumSamples())) return 0.f; if (sampleSound.skipAntialiasing->get()) { return sampleSound.sample.getSample(channel, int(position)); } else { return lanczosInterpolate(channel, position, lowpassStreams); } } float CustomSamplerVoice::nextSample(int channel, BungeeStretcher* stretcher, juce::AudioBuffer& channelBuffer, int i) const { if (channel == 0) { for (int ch = 1; ch < sampleSound.sample.getNumChannels(); ch++) channelBuffer.setSample(ch - 1, i, stretcher->nextSample(ch, false)); return stretcher->nextSample(0); } else { return channelBuffer.getSample(channel - 1, i); } } float CustomSamplerVoice::getEnvelopeGain() const { float gain = 1.f; if (vc.isSmoothingAttack) gain *= exponentialCurve(attackShape, vc.speedMovedSinceStart / attackSmoothing); if (vc.isReleasing) gain *= exponentialCurve(releaseShape, 1 - vc.speedMovedSinceRelease / releaseSmoothing); return gain; } //============================================================================== void CustomSamplerVoice::initializeFx() { auto fxOrder = sampleSound.getFxOrder(); bool changed = false; for (size_t i = 0; i < fxOrder.size(); i++) { if (effects.size() <= i || effects[i].fxType != fxOrder[i]) { changed = true; break; } } if (changed) { effects.clear(); for (auto& fxType : fxOrder) { switch (fxType) { case PluginParameters::DISTORTION: effects.emplace_back(PluginParameters::DISTORTION, std::make_unique(), sampleSound.distortionEnabled); break; case PluginParameters::REVERB: effects.emplace_back(PluginParameters::REVERB, std::make_unique(), sampleSound.reverbEnabled); break; case PluginParameters::CHORUS: effects.emplace_back(PluginParameters::CHORUS, std::make_unique(expectedBlockSize), sampleSound.chorusEnabled); break; case PluginParameters::EQ: effects.emplace_back(PluginParameters::EQ, std::make_unique(), sampleSound.eqEnabled); break; } } } } //============================================================================== /** Thank god for Wikipedia, I don't really know why this works. https://en.wikipedia.org/wiki/Lanczos_resampling The technical details of resampling elude me, but JUCE's filters seem to work well enough for this... */ float CustomSamplerVoice::lanczosInterpolate(int channel, double position, std::vector>& lowpassStreams) const { // First, process the lowpass filter auto& lowpassStream = *lowpassStreams[channel]; if (doLowpass && lowpassStream.getNextSample() < sampleSound.sample.getNumSamples()) { int lastWindowSample = juce::jmin(int(std::floor(position)) + LANCZOS_WINDOW_SIZE, sampleSound.sample.getNumSamples() - 1); lowpassStream.processSamples(sampleSound.sample.getReadPointer(channel, lowpassStream.getNextSample()), lastWindowSample - lowpassStream.getNextSample() + 1); } // Then, interpolate int floorIndex = int(std::floor(position)); float result = 0.f; for (int i = -LANCZOS_WINDOW_SIZE + 1; i <= LANCZOS_WINDOW_SIZE; i++) { int iPlus = i + floorIndex; float sample = 0.f; if (0 <= iPlus && iPlus < sampleSound.sample.getNumSamples()) // Bounds checking is a bit awkward here but handles some edge cases { if (doLowpass) { if (iPlus >= lowpassStream.getStartSample()) sample = lowpassStream.getProcessedSample(iPlus); } else { sample = sampleSound.sample.getSample(channel, iPlus); } } float window = lanczosWindow(position - floorIndex - i); result += sample * window; } return result; } float CustomSamplerVoice::lanczosWindow(double x) { return x == 0.f ? 1.f : float(LANCZOS_WINDOW_SIZE * std::sin(juce::MathConstants::pi * x) * std::sin(juce::MathConstants::pi * x / LANCZOS_WINDOW_SIZE) * INVERSE_SIN_SQUARED / (x * x)); }