From e9dc3a4d66b7c72288a8498ab9365a3aa7963f95 Mon Sep 17 00:00:00 2001 From: Armin Date: Fri, 24 Jul 2026 13:09:52 +0200 Subject: [PATCH] add ping-pong loop mode and crossfade length rotary --- CMakeLists.txt | 9 ++++---- Source/CustomLookAndFeel.h | 2 +- Source/PluginEditor.cpp | 33 +++++++++++++++++++++++---- Source/PluginEditor.h | 6 +++-- Source/PluginParameters.h | 2 ++ Source/PluginProcessor.cpp | 1 + Source/Sampler/CustomSamplerVoice.cpp | 25 +++++++++++++++++--- Source/Sampler/CustomSamplerVoice.h | 3 ++- Source/Sampler/SamplerParameters.cpp | 1 + Source/Sampler/SamplerParameters.h | 2 +- 10 files changed, 67 insertions(+), 17 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c8af27..42c161e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,10 +178,10 @@ juce_add_plugin(JustASample DESCRIPTION "Just a Sample is a modern, open-source audio sampler" ICON_BIG "Assets/Icons/IconLogo.svg" ICON_SMALL "Assets/Icons/IconLogo.svg" - COMPANY_NAME "Binyamin Friedman" - COMPANY_COPYRIGHT "Copyright (c) 2026 Binyamin Friedman" - COMPANY_WEBSITE "https://github.com/BOBONA/Just-a-Sample/" - BUNDLE_ID com.BinyaminFriedman.JustASample + COMPANY_NAME "Just a Sample" + COMPANY_COPYRIGHT "(c) 2026 B. Friedman & A. Jenewein" + COMPANY_WEBSITE "https://codeberg.org/armin/justasample" + BUNDLE_ID org.justasample.JustASample PLUGIN_MANUFACTURER_CODE Manu PLUGIN_CODE Bpf2 FORMATS AU VST3 @@ -268,6 +268,7 @@ juce_add_binary_data(AudioPluginData Assets/Icons/IconLogo.svg Assets/Icons/IconLoop.svg Assets/Icons/IconLoopStart.svg + Assets/Icons/IconPingPong.svg Assets/Icons/IconMono.svg Assets/Icons/IconPin.svg Assets/Icons/IconPlay.svg diff --git a/Source/CustomLookAndFeel.h b/Source/CustomLookAndFeel.h index 1a7b869..6656957 100644 --- a/Source/CustomLookAndFeel.h +++ b/Source/CustomLookAndFeel.h @@ -90,7 +90,7 @@ struct Layout static constexpr int attackWidth{ 204 }; static constexpr int releaseWidth{ 204 }; static constexpr int playbackWidth{ 534 }; - static constexpr int loopWidth{ 217 }; + static constexpr int loopWidth{ 310 }; static constexpr int masterWidth{ 295 }; static constexpr int standardRotarySize{ 87 }; // Not including padding diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 5c75366..7ffdb21 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -50,9 +50,12 @@ JustaSampleAudioProcessorEditor::JustaSampleAudioProcessorEditor(JustaSampleAudi loopButton(true), loopStartButton(true), loopEndButton(true), + pingPongButton(true), loopAttachment(p.APVTS(), PluginParameters::IS_LOOPING, loopButton), loopStartAttachment(p.APVTS(), PluginParameters::LOOPING_HAS_START, loopStartButton), loopEndAttachment(p.APVTS(), PluginParameters::LOOPING_HAS_END, loopEndButton), + pingPongAttachment(p.APVTS(), PluginParameters::IS_PING_PONG, pingPongButton), + crossfadeAttachment(p.APVTS(), PluginParameters::CROSSFADE_SAMPLES, crossfadeRotary), // Master module monoOutputAttachment(p.APVTS(), PluginParameters::MONO_OUTPUT, monoOutputButton), @@ -179,6 +182,15 @@ JustaSampleAudioProcessorEditor::JustaSampleAudioProcessorEditor(JustaSampleAudi loopEndButton.setHelpText("Loop with release portion"); addAndMakeVisible(&loopEndButton); + pingPongButton.useShape(getOutlineFromSVG(BinaryData::IconPingPong_svg)); + pingPongButton.setOwnerButton(&loopButton); + pingPongButton.setHelpText("Ping-pong loop"); + addAndMakeVisible(&pingPongButton); + + crossfadeRotary.getProperties().set(ComponentProps::ROTARY_UNIT, "samp"); + crossfadeRotary.setHelpText("Loop crossfade length"); + addAndMakeVisible(&crossfadeRotary); + monoOutputButton.useShape(getOutlineFromSVG(BinaryData::IconMono_svg)); monoOutputButton.setHelpText("Mix output to mono"); addAndMakeVisible(&monoOutputButton); @@ -190,7 +202,7 @@ JustaSampleAudioProcessorEditor::JustaSampleAudioProcessorEditor(JustaSampleAudi gainSlider.setHelpText("Sample gain"); addAndMakeVisible(&gainSlider); - rotaries = { &semitoneRotary, &waveformSemitoneRotary, ¢Rotary, &waveformCentRotary, &attackTimeRotary, &releaseTimeRotary, &playbackSpeedRotary }; + rotaries = { &semitoneRotary, &waveformSemitoneRotary, ¢Rotary, &waveformCentRotary, &attackTimeRotary, &releaseTimeRotary, &playbackSpeedRotary, &crossfadeRotary }; for (auto rotary : rotaries) { rotary->setSliderStyle(juce::Slider::RotaryVerticalDrag); @@ -651,22 +663,32 @@ void JustaSampleAudioProcessorEditor::resized() loopingLabel.setBounds(loopingLabelBounds.toNearestInt()); loopModule.removeFromTop(scalei(Layout::moduleLabelGap)); - loopModule.reduce(scalei(16.f), scalei(6.f)); + loopModule.reduce(scalei(16.f) - rotaryPadding, scalei(6.f) - rotaryPadding); loopStartButton.setBounds(loopModule.removeFromLeft(scalei(34.f)).toNearestInt()); loopStartButton.setBorder(scalef(2.5f), scalef(6.f), true, false, true, false); loopStartButton.setPadding(scalef(8.f), scalef(4.f), scalef(7.5f), scalef(7.5f)); loopModule.removeFromLeft(scalei(6.f)); - loopButton.setBounds(loopModule.removeFromLeft(scalei(105.f)).toNearestInt()); + loopButton.setBounds(loopModule.removeFromLeft(scalei(65.f)).toNearestInt()); loopButton.setBorder(scalef(2.5f)); loopButton.setPadding(scalef(13.f)); + loopModule.removeFromLeft(scalei(3.f)); + pingPongButton.setBounds(loopModule.removeFromLeft(scalei(34.f)).toNearestInt()); + pingPongButton.setBorder(scalef(2.5f), scalef(6.f)); + pingPongButton.setPadding(scalef(8.f)); + loopModule.removeFromLeft(scalei(6.f)); loopEndButton.setBounds(loopModule.removeFromLeft(scalei(34.f)).toNearestInt()); loopEndButton.setBorder(scalef(2.5f), scalef(6.f), false, true, false, true); loopEndButton.setPadding(scalef(4.f), scalef(8.f), scalef(7.5f), scalef(7.5f)); + loopModule.removeFromLeft(scalei(6.f)); + auto crossfadeRotaryBounds = loopModule.removeFromLeft(rotarySize + 2 * rotaryPadding); + crossfadeRotary.setBounds(crossfadeRotaryBounds.toNearestInt()); + crossfadeRotary.sendLookAndFeelChange(); + controls.removeFromLeft(scalei(Layout::moduleGap)); // Master module @@ -787,6 +809,7 @@ void JustaSampleAudioProcessorEditor::lookAndFeelChanged() loopButton.setColors(theme.dark, theme.loop, shadow); loopStartButton.setColors(theme.dark, theme.loop, shadow); loopEndButton.setColors(theme.dark, theme.loop, shadow); + pingPongButton.setColors(theme.dark, theme.loop, shadow); monoOutputButton.setColors(theme.dark, theme.light, shadow); linkSampleToggle.setColors(theme.dark, theme.slate.withAlpha(0.5f), shadow); waveformModeLabel.setColors(theme.foreground, theme.slate.withAlpha(0.f), shadow); @@ -810,7 +833,7 @@ void JustaSampleAudioProcessorEditor::lookAndFeelChanged() juce::Array foregroundComponents = { &tuningLabel, &attackLabel, &releaseLabel, &playbackLabel, &loopingLabel, &masterLabel, &semitoneRotary, &waveformSemitoneRotary, ¢Rotary, &waveformCentRotary, &tuningDetectLabel, &tuningDetectButton, - &attackTimeRotary, &attackCurve, &releaseTimeRotary, &releaseCurve, &lofiModeButton, &playbackModeButton, &playbackSpeedRotary, &loopStartButton, &loopButton, &loopEndButton, + &attackTimeRotary, &attackCurve, &releaseTimeRotary, &releaseCurve, &lofiModeButton, &playbackModeButton, &playbackSpeedRotary, &loopStartButton, &loopButton, &loopEndButton, &pingPongButton, &crossfadeRotary, &monoOutputButton, &gainSlider, &filenameComponent, &linkSampleToggle, &playStopButton, &recordButton, &deviceSettingsButton, &fitButton, &pinButton, &sampleNavigator, &preFXButton, &showFXButton }; @@ -1047,7 +1070,7 @@ void JustaSampleAudioProcessorEditor::enablementChanged() &playStopButton, &fitButton, &pinButton, &sampleEditor, &sampleNavigator, &fxChain, &showFXButton }; - juce::Array notWaveformModeComponents = { &playbackModeButton, &loopStartButton, &loopButton, &loopEndButton, &tuningDetectLabel, &tuningDetectButton }; + juce::Array notWaveformModeComponents = { &playbackModeButton, &loopStartButton, &loopButton, &loopEndButton, &pingPongButton, &crossfadeRotary, &tuningDetectLabel, &tuningDetectButton }; juce::Array notLoadingDependentComponents = { &tuningDetectButton, &tuningDetectLabel, &recordButton, &deviceSettingsButton }; diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index acbcdac..16a78f8 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -182,8 +182,10 @@ private: CustomRotaryAttachment playbackSpeedAttachment; // Loop module - CustomToggleableButton loopButton, loopStartButton, loopEndButton; - APVTS::ButtonAttachment loopAttachment, loopStartAttachment, loopEndAttachment; + CustomToggleableButton loopButton, loopStartButton, loopEndButton, pingPongButton; + APVTS::ButtonAttachment loopAttachment, loopStartAttachment, loopEndAttachment, pingPongAttachment; + CustomRotary crossfadeRotary; + CustomRotaryAttachment crossfadeAttachment; // Mixing module CustomToggleableButton monoOutputButton; diff --git a/Source/PluginParameters.h b/Source/PluginParameters.h index 96f294f..8b1fae7 100644 --- a/Source/PluginParameters.h +++ b/Source/PluginParameters.h @@ -106,6 +106,7 @@ inline static const String PLAY_UNTIL_END{ "Play Until End" }; inline static const String IS_LOOPING{ "Loop" }; inline static const String LOOPING_HAS_START{ "Loop With Start" }; inline static const String LOOPING_HAS_END{ "Loop With End" }; +inline static const String IS_PING_PONG{ "Ping Pong" }; inline static const String PLAYBACK_MODE{ "Playback Mode" }; inline static const StringArray PLAYBACK_MODE_LABELS{ "Basic", "Bungee" }; // for IDs and display @@ -381,6 +382,7 @@ inline juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout addBool(layout, LOOPING_HAS_START, false, Version::V1); addBool(layout, IS_LOOPING, false, Version::V1); addBool(layout, LOOPING_HAS_END, false, Version::V1); + addBool(layout, IS_PING_PONG, false, Version::V1_3_2); addFloat(layout, SAMPLE_GAIN, 0.f, addSkew({ -32.f, 16.f, 0.1f }, 0.f), Version::V1, suffixF(" " + VOLUME_UNIT, 0.1f)); addBool(layout, MONO_OUTPUT, false, Version::V1); diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 41de540..77d7369 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -413,6 +413,7 @@ void JustaSampleAudioProcessor::loadSample(juce::AudioBuffer& sample, int // 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_PING_PONG) = false; pv(PluginParameters::IS_LOOPING) = false; pluginState.loopStart = 0; diff --git a/Source/Sampler/CustomSamplerVoice.cpp b/Source/Sampler/CustomSamplerVoice.cpp index 313175f..905cf05 100644 --- a/Source/Sampler/CustomSamplerVoice.cpp +++ b/Source/Sampler/CustomSamplerVoice.cpp @@ -91,11 +91,17 @@ void CustomSamplerVoice::startNote(int midiNoteNumber, float velocity, juce::Syn playUntilEnd = sampleSound.playUntilEnd->get(); isLooping = sampleSound.isLooping->get() || wavetableMode; - loopingHasStart = isLooping && sampleSound.loopingHasStart->get() && sampleSound.loopStart < sampleSound.sampleStart && !wavetableMode; + isPingPong = isLooping && sampleSound.isPingPong->get() && !wavetableMode; + loopingHasStart = isLooping && sampleSound.loopingHasStart->get() && sampleSound.loopStart < sampleSound.sampleStart && !wavetableMode && !isPingPong; loopStart = sampleSound.loopStart; - loopingHasEnd = isLooping && sampleSound.loopingHasEnd->get() && sampleSound.loopEnd > sampleSound.sampleEnd && !wavetableMode; + loopingHasEnd = isLooping && sampleSound.loopingHasEnd->get() && sampleSound.loopEnd > sampleSound.sampleEnd && !wavetableMode && !isPingPong; loopEnd = sampleSound.loopEnd; + if (isPingPong) + playbackMode = PluginParameters::BASIC; // BungeeStretcher is forward-only + + pingPongDirection = 1.0f; + effectiveStart = loopingHasStart ? loopStart : sampleStart; effectiveEnd = loopingHasEnd ? loopEnd : sampleEnd; @@ -264,6 +270,9 @@ void CustomSamplerVoice::renderNextBlock(juce::AudioBuffer& outputBuffer, updateSpeedAndPitch(getCurrentlyPlayingNote(), pitchWheel); + if (isPingPong && isLooping) + speed *= pingPongDirection; + bool someFXEnabled{ false }; for (const auto& effect : effects) { @@ -350,7 +359,17 @@ void CustomSamplerVoice::renderNextBlock(juce::AudioBuffer& outputBuffer, con.speedMovedSinceRelease += 1; // Handle transitions - if (con.state == PLAYING && isLooping && con.currentPosition >= sampleEnd - crossfade) // Loop crossfade + if (con.state == PLAYING && isLooping && isPingPong && pingPongDirection > 0 && con.currentPosition >= sampleEnd) // Ping-pong: reverse at end + { + pingPongDirection = -1.0f; + speed = std::abs(speed) * -1.0f; + } + else if (con.state == PLAYING && isLooping && isPingPong && pingPongDirection < 0 && con.currentPosition <= sampleStart) // Ping-pong: reverse at start + { + pingPongDirection = 1.0f; + speed = std::abs(speed); + } + else if (con.state == PLAYING && isLooping && !isPingPong && con.currentPosition >= sampleEnd - crossfade) // Loop crossfade { con.currentPosition -= (sampleEnd - sampleStart + 1) - crossfade; con.isCrossfadingLoop = true; diff --git a/Source/Sampler/CustomSamplerVoice.h b/Source/Sampler/CustomSamplerVoice.h index a698dbe..1a865d4 100644 --- a/Source/Sampler/CustomSamplerVoice.h +++ b/Source/Sampler/CustomSamplerVoice.h @@ -219,8 +219,9 @@ private: float noteVelocity{ 0.f }; bool playUntilEnd{ false }; - bool isLooping{ false }, loopingHasStart{ false }, loopingHasEnd{ false }; + bool isLooping{ false }, loopingHasStart{ false }, loopingHasEnd{ false }, isPingPong{ false }; int sampleStart{ 0 }, sampleEnd{ 0 }, loopStart{ 0 }, loopEnd{ 0 }; + float pingPongDirection{ 1.0f }; /** We call this "smoothing" but it's a pretty normal attack/release envelope. */ float attackSmoothing{ 0.f }, releaseSmoothing{ 0.f }; diff --git a/Source/Sampler/SamplerParameters.cpp b/Source/Sampler/SamplerParameters.cpp index 5588ece..efc5231 100644 --- a/Source/Sampler/SamplerParameters.cpp +++ b/Source/Sampler/SamplerParameters.cpp @@ -37,6 +37,7 @@ SamplerParameters::SamplerParameters(const juce::AudioProcessorValueTreeState& a isLooping(dynamic_cast(apvts.getParameter(PluginParameters::IS_LOOPING))), loopingHasStart(dynamic_cast(apvts.getParameter(PluginParameters::LOOPING_HAS_START))), loopingHasEnd(dynamic_cast(apvts.getParameter(PluginParameters::LOOPING_HAS_END))), + isPingPong(dynamic_cast(apvts.getParameter(PluginParameters::IS_PING_PONG))), sampleStart(pluginState.sampleStart), sampleEnd(pluginState.sampleEnd), loopStart(pluginState.loopStart), loopEnd(pluginState.loopEnd), diff --git a/Source/Sampler/SamplerParameters.h b/Source/Sampler/SamplerParameters.h index 7f92a3c..d994efd 100644 --- a/Source/Sampler/SamplerParameters.h +++ b/Source/Sampler/SamplerParameters.h @@ -34,7 +34,7 @@ public: /** Playback details */ juce::AudioParameterFloat* gain, * speedFactor, * octaveSpeedFactor, * attack, * release, * attackShape, * releaseShape, * a4_freq, * pitchWheelRange, * wideTuningControl; juce::AudioParameterInt* semitoneTuning, * centTuning, * waveformSemitoneTuning, * waveformCentTuning, * crossfadeSamples; - juce::AudioParameterBool* monoOutput, * disableVelocity, * skipAntialiasing, * applyFXPre, * playUntilEnd, * disableWavetableMode, * isLooping, * loopingHasStart, * loopingHasEnd; + juce::AudioParameterBool* monoOutput, * disableVelocity, * skipAntialiasing, * applyFXPre, * playUntilEnd, * disableWavetableMode, * isLooping, * loopingHasStart, * loopingHasEnd, * isPingPong; ListenableAtomic& sampleStart, & sampleEnd, & loopStart, & loopEnd; juce::AudioParameterInt* midiStart, * midiEnd, * midiRoot;