This commit is contained in:
Armin 2026-07-23 23:40:48 +02:00
commit d21bc831e1
178 changed files with 24136 additions and 0 deletions

377
Source/Components/Buttons.h Normal file
View file

@ -0,0 +1,377 @@
/*
==============================================================================
Buttons.h
Created: 30 Jul 2024 11:30:24pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../Utilities/ComponentUtils.h"
/** Simple wrapper around JUCE's ShapeButton for transparent disabled styling */
class CustomShapeButton final : public juce::ShapeButton, public CustomHelpTextProvider, public EnabledMouseCursor
{
public:
explicit CustomShapeButton(juce::Colour buttonColor, const juce::Path& shape = {}, CustomHelpTextDisplay* helpTextDisplay = nullptr) :
ShapeButton("", buttonColor, buttonColor, buttonColor),
CustomHelpTextProvider(this, helpTextDisplay),
color(buttonColor)
{
ShapeButton::setShape(shape, false, true, false);
}
explicit CustomShapeButton(const juce::Path& shape = {}, CustomHelpTextDisplay* helpTextDisplay = nullptr) :
CustomShapeButton(juce::Colours::transparentBlack, shape, helpTextDisplay)
{
}
void setColor(juce::Colour buttonColor)
{
color = buttonColor;
setColours(buttonColor, buttonColor, buttonColor);
enablementChanged();
}
void setShape(const juce::Path& newShape)
{
ShapeButton::setShape(newShape, false, true, false);
repaint();
}
private:
void enablementChanged() override
{
auto adjustedColor = color.withMultipliedAlpha(isEnabled() ? 1.f : 0.5f);
setColours(adjustedColor, adjustedColor, adjustedColor);
EnabledMouseCursor::enablementChanged(*this);
repaint();
}
juce::Colour color;
};
/** A nicely styled button that can be toggled on and off. An owner button can be set to "own" this button,
meaning this button is only on when the owner button is on.
*/
class CustomToggleableButton final : public juce::Button, public juce::Button::Listener, public CustomHelpTextProvider, public EnabledMouseCursor
{
public:
/** Create a new button with the given colors.
altStyle keeps the text the same while changing the background, onBackground removes the shadow and sets a background when on
*/
CustomToggleableButton(juce::Colour offColor, juce::Colour onColor, bool altStyle = false, bool useOnBackground = false, CustomHelpTextDisplay* helpTextDisplay = nullptr) :
Button(""), CustomHelpTextProvider(this, helpTextDisplay),
offColor(offColor), onColor(onColor), altStyle(altStyle), onBackground(useOnBackground)
{
setClickingTogglesState(true);
}
CustomToggleableButton(bool altStyle = false, bool useOnBackground = false, CustomHelpTextDisplay* helpTextDisplay = nullptr) :
CustomToggleableButton(juce::Colours::transparentBlack, juce::Colours::transparentBlack, altStyle, useOnBackground, helpTextDisplay)
{
}
~CustomToggleableButton() override
{
if (ownerButton)
ownerButton->removeListener(this);
}
void setColors(juce::Colour off, juce::Colour on, juce::Colour shadow = defaultTheme.dark.withAlpha(0.25f))
{
offColor = off;
onColor = on;
onShadow.setColor(shadow);
repaint();
}
/** An owner button is a button that will be toggled on when this button is toggled but can also be toggled independently. */
void setOwnerButton(Button* owner)
{
ownerButton = owner;
owner->addListener(this);
if (!owner->getToggleState())
setClickingTogglesState(false);
}
/** Use x, y, width, height to set the left, top, right, and bottom bounds of the button */
void setBorder(float width, float rounding = 0.f, bool roundTopLeft = true, bool roundTopRight = true, bool roundBottomLeft = true, bool roundBottomRight = true)
{
borderWidth = width;
borderRounding = rounding;
topLeft = roundTopLeft;
topRight = roundTopRight;
bottomLeft = roundBottomLeft;
bottomRight = roundBottomRight;
}
void setPadding(float amount)
{
setPadding(amount, amount, amount, amount);
}
void setPadding(float paddingLeft, float paddingRight, float paddingTop, float paddingBottom)
{
padLeft = paddingLeft;
padRight = paddingRight;
padTop = paddingTop;
padBottom = paddingBottom;
}
void useShape(const juce::Path& shapePath, const juce::Path& offShapePath = {}, juce::Justification justification = juce::Justification::centred)
{
shape = shapePath;
offShape = offShapePath;
shapeJustification = justification;
}
private:
void buttonStateChanged(Button* button) override
{
if (button == ownerButton)
{
setClickingTogglesState(ownerButton->getToggleState());
if (getToggleState())
setState(ownerButton->getState());
repaint();
}
}
void buttonClicked(Button*) override {}
void clicked() override
{
if (ownerButton && !ownerButton->getToggleState())
{
ownerButton->setToggleState(true, juce::sendNotificationSync);
ownerButton->setState(buttonNormal);
setToggleState(true, juce::dontSendNotification);
repaint();
}
}
void buttonStateChanged() override
{
if (ownerButton && !ownerButton->getToggleState())
{
ownerButton->setState(getState());
ownerButton->repaint();
}
}
void paintButton(juce::Graphics& g, bool /*shouldDrawButtonAsHighlighted*/, bool shouldDrawButtonAsDown) override
{
bool toggledOn = getToggleState() && (!ownerButton || ownerButton->getToggleState());
bool drawAsOn = toggledOn != shouldDrawButtonAsDown;
auto bounds = getLocalBounds().toFloat();
if (drawAsOn)
{
juce::Path background;
auto pathBounds = bounds.reduced(borderWidth * 0.33f);
background.addRoundedRectangle(pathBounds.getX(), pathBounds.getY(), pathBounds.getWidth(), pathBounds.getHeight(),
borderRounding, borderRounding, topLeft, topRight, bottomLeft, bottomRight);
g.setColour(altStyle ? onColor : offColor);
g.fillPath(background);
if (!onBackground)
onShadow.render(g, background);
}
else if (!onBackground)
{
juce::Path border;
auto pathBounds = bounds.reduced(borderWidth * 0.66f);
border.addRoundedRectangle(pathBounds.getX(), pathBounds.getY(), pathBounds.getWidth(), pathBounds.getHeight(),
borderRounding, borderRounding, topLeft, topRight, bottomLeft, bottomRight);
g.setColour(offColor);
g.strokePath(border, juce::PathStrokeType(borderWidth));
}
bounds.removeFromLeft(padLeft);
bounds.removeFromRight(padRight);
bounds.removeFromTop(padTop);
bounds.removeFromBottom(padBottom);
auto useShape = !drawAsOn && !offShape.isEmpty() ? offShape : shape;
auto trans = useShape.getTransformToScaleToFit(bounds, true, shapeJustification);
g.setColour(drawAsOn && !altStyle ? onColor : offColor);
if (!useShape.isEmpty())
{
g.fillPath(useShape, trans);
}
else if (getButtonText().isNotEmpty())
{
g.setFont(getInterBold().withHeight(getHeight() * 0.381f));
g.drawText(getButtonText(), getLocalBounds(), juce::Justification::centred);
}
if (!isEnabled())
{
g.setColour(findColour(Colors::backgroundColorId, true).withAlpha(0.5f));
g.fillRect(getLocalBounds());
}
}
void enablementChanged() override
{
EnabledMouseCursor::enablementChanged(*this);
repaint();
}
//==============================================================================
juce::Colour offColor, onColor;
bool altStyle{ false }, onBackground{ false };
float borderWidth{ 1.f };
float borderRounding{ 0.f };
bool topLeft{ true }, topRight{ true }, bottomLeft{ true }, bottomRight{ true };
float padLeft{ 0.f }, padRight{ 0.f }, padTop{ 0.f }, padBottom{ 0.f };
juce::Path shape, offShape;
juce::Justification shapeJustification{ juce::Justification::centred };
melatonin::InnerShadow onShadow{ defaultTheme.dark.withAlpha(0.25f), 3, {0, 2} };
Button* ownerButton{ nullptr };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (CustomToggleableButton)
};
/** Two buttons that act as a nicely styled toggle. */
class CustomChoiceButton final : public CustomComponent
{
public:
CustomChoiceButton(const APVTS& apvts, const juce::Identifier& parameter, juce::Colour offColor, juce::Colour onColor,
const juce::String& firstButtonText, const juce::String& secondButtonText, CustomHelpTextDisplay* helpTextDisplay = nullptr) :
CustomComponent(helpTextDisplay),
firstButton(offColor, onColor), secondButton(offColor, onColor),
choiceAttachment(*apvts.getParameter(parameter), [this](float newValue) { valueChanged(newValue); }, apvts.undoManager),
offColor(offColor), onColor(onColor)
{
setPaintingIsUnclipped(true); // Stop border from clipping
firstButton.setButtonText(firstButtonText);
secondButton.setButtonText(secondButtonText);
firstButton.onClick = [this] { choiceAttachment.setValueAsCompleteGesture(0.f); };
secondButton.onClick = [this] { choiceAttachment.setValueAsCompleteGesture(1.f); };
firstButton.onStateChange = [this] { secondButton.setState(firstButton.getState()); };
secondButton.onStateChange = [this] { firstButton.setState(secondButton.getState()); };
addAndMakeVisible(firstButton);
addAndMakeVisible(secondButton);
choiceAttachment.sendInitialUpdate();
}
CustomChoiceButton(const APVTS& apvts, const juce::Identifier& parameter, const juce::String& firstButtonText, const juce::String& secondButtonText,
CustomHelpTextDisplay* helpTextDisplay = nullptr) :
CustomChoiceButton(apvts, parameter, juce::Colours::transparentBlack, juce::Colours::transparentBlack, firstButtonText, secondButtonText, helpTextDisplay)
{
}
void setColors(juce::Colour off, juce::Colour on, juce::Colour shadow = defaultTheme.dark.withAlpha(0.25f))
{
offColor = off;
onColor = on;
firstButton.setColors(off, on, shadow);
secondButton.setColors(off, on, shadow);
repaint();
}
void setBorder(float width, float rounding = 0.f)
{
borderWidth = width;
borderRounding = rounding;
repaint();
}
bool getChoice() const
{
return secondButton.getToggleState();
}
void setHelpText(const juce::String& firstHelpText, const juce::String& secondHelpText)
{
firstButton.setHelpText(firstHelpText);
secondButton.setHelpText(secondHelpText);
}
juce::String getCustomHelpText() override
{
return getChoice() ? secondButton.getHelpText() : firstButton.getHelpText();
}
private:
void paint(juce::Graphics& g) override
{
auto bounds = getLocalBounds().toFloat();
firstButton.setBorder(0.f, borderRounding, true, false, true, false);
secondButton.setBorder(0.f, borderRounding, false, true, false, true);
g.setColour(offColor);
g.drawRoundedRectangle(bounds.reduced(borderWidth / 2.f), borderRounding, borderWidth);
}
void resized() override
{
auto bounds = getLocalBounds();
firstButton.setBounds(bounds.removeFromLeft(int(bounds.getWidth() / 2.f)));
secondButton.setBounds(bounds);
}
void valueChanged(float newValue)
{
firstButton.setToggleState(newValue < 0.5f, juce::dontSendNotification);
secondButton.setToggleState(newValue > 0.5f, juce::dontSendNotification);
firstButton.setInterceptsMouseClicks(!firstButton.getToggleState(), false);
secondButton.setInterceptsMouseClicks(!secondButton.getToggleState(), false);
sendHelpTextUpdate(false);
}
void enablementChanged() override
{
if (!isEnabled())
{
firstButton.setInterceptsMouseClicks(false, false);
secondButton.setInterceptsMouseClicks(false, false);
}
else
{
choiceAttachment.sendInitialUpdate();
}
repaint();
}
//==============================================================================
CustomToggleableButton firstButton;
CustomToggleableButton secondButton;
juce::ParameterAttachment choiceAttachment;
juce::Colour offColor, onColor;
float borderWidth{ 1.f }, borderRounding{ 0.f };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(CustomChoiceButton)
};

View file

@ -0,0 +1,62 @@
/*
==============================================================================
ChorusVisualizer.cpp
Created: 30 Jan 2024 6:12:49pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "ChorusVisualizer.h"
ChorusVisualizer::ChorusVisualizer(APVTS& apvts, int sampleRate) :
apvts(apvts), sampleRate(sampleRate),
rateAttachment(*apvts.getParameter(PluginParameters::CHORUS_RATE), [this](float newValue) { rate = newValue; repaint(); }, apvts.undoManager),
depthAttachment(*apvts.getParameter(PluginParameters::CHORUS_DEPTH), [this](float newValue) { depth = newValue; repaint(); }, apvts.undoManager),
centerDelayAttachment(*apvts.getParameter(PluginParameters::CHORUS_CENTER_DELAY), [this](float newValue) { centerDelay = newValue; repaint(); }, apvts.undoManager)
{
rateAttachment.sendInitialUpdate();
depthAttachment.sendInitialUpdate();
centerDelayAttachment.sendInitialUpdate();
}
void ChorusVisualizer::paint(juce::Graphics& g)
{
auto theme = getTheme();
juce::Path path;
constexpr int pointsPerPixel = 10;
for (int i = 0; i < getWidth() * pointsPerPixel; i++)
{
float pos = float(i) / pointsPerPixel;
float lfo = std::sin(WINDOW_LENGTH * rate * pos / getWidth() * 2 * juce::MathConstants<float>::pi);
float skewedDepth = logf(depth) + b;
float delay = centerDelay * 0.98f + skewedDepth * lfo * multiplier;
float loc = juce::jmap<float>(delay, -oscRange, oscRange, float(getHeight()), 0.f);
if (i == 0)
path.startNewSubPath(pos, loc);
else
path.lineTo(pos, loc);
}
auto strokeWidth = getWidth() * Layout::fxDisplayStrokeWidth;
g.setColour(theme.dark.withAlpha(0.2f));
g.drawRect(0.f, (getHeight() - strokeWidth) / 2.f, float(getWidth()), strokeWidth);
g.setColour(theme.dark);
g.strokePath(path, juce::PathStrokeType(strokeWidth));
if (!isEnabled())
g.fillAll(theme.background.withAlpha(0.5f));
}
void ChorusVisualizer::enablementChanged()
{
repaint();
}

View file

@ -0,0 +1,43 @@
/*
==============================================================================
ChorusVisualizer.h
Created: 30 Jan 2024 6:12:49pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../../PluginParameters.h"
#include "../../Utilities/ComponentUtils.h"
/** A simple chorus visualizer, which displays a sine wave representing the delay line. */
class ChorusVisualizer final : public CustomComponent
{
public:
ChorusVisualizer(APVTS& apvts, int sampleRate);
private:
void paint(juce::Graphics&) override;
void enablementChanged() override;
//==============================================================================
static constexpr float WINDOW_LENGTH{ 1.5f };
static constexpr float b{ 5.f };
static constexpr float multiplier{ 25.f };
static constexpr float oscRange{ b * multiplier + PluginParameters::CHORUS_CENTER_DELAY_RANGE.getEnd() };
//==============================================================================
APVTS& apvts;
int sampleRate;
bool shouldRepaint{ false };
float rate{ 0.f }, depth{ 0.f }, centerDelay{ 0.f }; /*feedback{ 0.f }, mix{ 0.f }*/
juce::ParameterAttachment rateAttachment, depthAttachment, centerDelayAttachment;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ChorusVisualizer)
};

View file

@ -0,0 +1,61 @@
/*
==============================================================================
DistortionVisualizer.cpp
Created: 23 Jan 2024 9:12:52pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "DistortionVisualizer.h"
DistortionVisualizer::DistortionVisualizer(APVTS& apvts, int sampleRate) : apvts(apvts), inputBuffer(1, WINDOW_LENGTH),
densityAttachment(*apvts.getParameter(PluginParameters::DISTORTION_DENSITY), [this](float newValue) { distortionDensity = newValue; repaint(); }, apvts.undoManager),
mixAttachment(*apvts.getParameter(PluginParameters::DISTORTION_MIX), [this](float newValue) { distortionMix = newValue; repaint(); }, apvts.undoManager)
{
densityAttachment.sendInitialUpdate();
mixAttachment.sendInitialUpdate();
distortion.initialize(1, sampleRate);
}
void DistortionVisualizer::paint(juce::Graphics& g)
{
auto theme = getTheme();
// Fill the input buffer with a pre-chosen wave
for (int i = 0; i < WINDOW_LENGTH; i++)
inputBuffer.setSample(0, i, sinf(juce::MathConstants<float>::twoPi * i * SINE_HZ / WINDOW_LENGTH) + cosf(2.5f * juce::MathConstants<float>::pi * i * SINE_HZ / WINDOW_LENGTH));
distortion.updateParams(distortionDensity, 0.f, distortionMix);
distortion.process(inputBuffer, inputBuffer.getNumSamples());
auto range = inputBuffer.findMinMax(0, 0, inputBuffer.getNumSamples()).getLength() / 2.f;
// Draw the resulting waveform
juce::Path path;
int numPointsPerPixel = 10;
for (int i = 0; i < getWidth() * numPointsPerPixel; i++)
{
float pos = float(i) / numPointsPerPixel;
float sample = inputBuffer.getSample(0, int(pos * WINDOW_LENGTH / getWidth()));
float y = juce::jmap<float>(sample, -range, range, float(getHeight()) * 0.98f, getHeight() * 0.02f);
if (i == 0)
path.startNewSubPath(0, y);
else
path.lineTo(float(pos), y);
}
auto strokeWidth = getWidth() * Layout::fxDisplayStrokeWidth;
g.setColour(theme.dark);
g.strokePath(path, juce::PathStrokeType(strokeWidth));
if (!isEnabled())
g.fillAll(theme.background.withAlpha(0.5f));
}
void DistortionVisualizer::enablementChanged()
{
repaint();
}

View file

@ -0,0 +1,42 @@
/*
==============================================================================
DistortionVisualizer.h
Created: 23 Jan 2024 9:12:52pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../../Sampler/Effects/Distortion.h"
#include "../../Utilities/ComponentUtils.h"
/** Visualizes the distortion effect by displaying its result on a sine wave. */
class DistortionVisualizer final : public CustomComponent
{
public:
DistortionVisualizer(APVTS& apvts, int sampleRate);
private:
void paint(juce::Graphics&) override;
void enablementChanged() override;
//==============================================================================
static constexpr int WINDOW_LENGTH{ 30000 };
static constexpr float SINE_HZ{ 13.f };
//==============================================================================
APVTS& apvts;
juce::AudioBuffer<float> inputBuffer;
Distortion distortion;
float distortionDensity{ 0.f }, distortionMix{ 0.f }; /*distortionHighpass{ 0.f }*/
juce::ParameterAttachment densityAttachment, mixAttachment;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (DistortionVisualizer)
};

View file

@ -0,0 +1,207 @@
/*
==============================================================================
FilterResponse.cpp
Created: 2 Jan 2024 10:40:21pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "FilterResponse.h"
FilterResponse::FilterResponse(APVTS& apvts, int sampleRate) : apvts(apvts),
lowFreqAttachment(*apvts.getParameter(PluginParameters::EQ_LOW_FREQ), [&](float newValue) { lowFreq = newValue; repaint(); }, apvts.undoManager),
highFreqAttachment(*apvts.getParameter(PluginParameters::EQ_HIGH_FREQ), [&](float newValue) { highFreq = newValue; repaint(); }, apvts.undoManager),
lowGainAttachment(*apvts.getParameter(PluginParameters::EQ_LOW_GAIN), [&](float newValue) { lowGain = newValue; repaint(); }, apvts.undoManager),
midGainAttachment(*apvts.getParameter(PluginParameters::EQ_MID_GAIN), [&](float newValue) { midGain = newValue; repaint(); }, apvts.undoManager),
highGainAttachment(*apvts.getParameter(PluginParameters::EQ_HIGH_GAIN), [&](float newValue) { highGain = newValue; repaint(); }, apvts.undoManager)
{
eq.initialize(1, sampleRate);
lowFreqAttachment.sendInitialUpdate();
highFreqAttachment.sendInitialUpdate();
lowGainAttachment.sendInitialUpdate();
midGainAttachment.sendInitialUpdate();
highGainAttachment.sendInitialUpdate();
}
void FilterResponse::paint(juce::Graphics& g)
{
using namespace juce;
auto theme = getTheme();
auto bounds = getLocalBounds().toFloat();
Array<double> frequencies;
for (int i = 0; i < bounds.getWidth(); i++)
frequencies.add(posToFreq(bounds, float(i)));
eq.updateParams(lowFreq, highFreq, lowGain, midGain, highGain);
Array<double> magnitudes = eq.getMagnitudeForFrequencyArray(frequencies);
auto gainRange = PluginParameters::EQ_GAIN_RANGE;
Path path;
path.startNewSubPath(bounds.getBottomLeft());
for (int i = 0; i < bounds.getWidth(); i++)
{
float normalizedDecibel = jmap<float>(float(Decibels::gainToDecibels(magnitudes[i])), gainRange.start - 1.f, gainRange.end + 1.f, bounds.getHeight(), 0.f);
if (i == 0)
path.startNewSubPath(0, normalizedDecibel);
else
path.lineTo(bounds.getX() + i, normalizedDecibel);
}
auto borderWidth = getWidth() * Layout::fxDisplayStrokeWidth * 1.5f;
g.setColour(theme.dark);
g.strokePath(path, PathStrokeType{ borderWidth, PathStrokeType::curved });
auto boundsPad = getWidth() * Layout::fxDisplayStrokeWidth * 6.f;
int lowLoc = int(freqToPos(bounds, lowFreq));
float curveLowPos = jmap<float>(float(Decibels::gainToDecibels(magnitudes[lowLoc])), gainRange.start, gainRange.end, bounds.getHeight() * 0.98f, 0.02f);
g.setColour(dragging && draggingTarget == LOW_FREQ ? theme.slate.withAlpha(0.5f) : theme.slate);
if (float height = curveLowPos - boundsPad; height > 0.f)
g.fillRect(lowLoc - borderWidth / 2.f, 0.f, borderWidth, height);
if (float height = getHeight() - curveLowPos - boundsPad; height > 0.f)
g.fillRect(lowLoc - borderWidth / 2.f, curveLowPos + boundsPad, borderWidth, height);
int highLoc = int(freqToPos(bounds, highFreq));
float curveHighPos = jmap<float>(float(Decibels::gainToDecibels(magnitudes[highLoc])), gainRange.start, gainRange.end, bounds.getHeight() * 0.98f, 0.02f);
g.setColour(dragging && draggingTarget == HIGH_FREQ ? theme.slate.withAlpha(0.5f) : theme.slate);
if (float height = curveHighPos - boundsPad; height > 0.f)
g.fillRect(highLoc - borderWidth / 2.f, 0.f, borderWidth, height);
if (float height = getHeight() - curveHighPos - boundsPad; height > 0.f)
g.fillRect(highLoc - borderWidth / 2.f, curveHighPos + boundsPad, borderWidth, height);
if (!isEnabled())
g.fillAll(theme.background.withAlpha(0.5f));
}
void FilterResponse::enablementChanged()
{
repaint();
}
void FilterResponse::mouseMove(const juce::MouseEvent& event)
{
if (!isEnabled())
{
setMouseCursor(juce::MouseCursor::NormalCursor);
return;
}
FilterResponseParts part = getClosestPartInRange(event.x, event.y);
switch (part)
{
case LOW_FREQ:
case HIGH_FREQ:
setMouseCursor(juce::MouseCursor::LeftRightResizeCursor);
break;
case NONE:
setMouseCursor(juce::MouseCursor::NormalCursor);
break;
}
}
void FilterResponse::mouseDown(const juce::MouseEvent& event)
{
if (!isEnabled() || dragging)
return;
FilterResponseParts closest = getClosestPartInRange(event.getMouseDownX(), event.getMouseDownY());
if (closest == NONE)
return;
if (closest == LOW_FREQ)
lowFreqAttachment.beginGesture();
else if (closest == HIGH_FREQ)
highFreqAttachment.beginGesture();
dragging = true;
draggingTarget = closest;
repaint();
}
void FilterResponse::mouseUp(const juce::MouseEvent&)
{
if (!dragging)
return;
if (draggingTarget == LOW_FREQ)
lowFreqAttachment.endGesture();
else if (draggingTarget == HIGH_FREQ)
highFreqAttachment.endGesture();
dragging = false;
repaint();
}
void FilterResponse::mouseDrag(const juce::MouseEvent& event)
{
if (!dragging || !isEnabled())
return;
auto bounds = getLocalBounds().toFloat();
auto newFreq = posToFreq(bounds, float(event.getMouseDownX() + event.getOffsetFromDragStart().getX()));
auto freqStartBound = draggingTarget == LOW_FREQ ? PluginParameters::EQ_LOW_FREQ_RANGE.getStart() : PluginParameters::EQ_HIGH_FREQ_RANGE.getStart();
auto freqEndBound = draggingTarget == LOW_FREQ ? PluginParameters::EQ_LOW_FREQ_RANGE.getEnd() : PluginParameters::EQ_HIGH_FREQ_RANGE.getEnd();
newFreq = juce::jlimit<float>(freqStartBound, freqEndBound, newFreq);
switch (draggingTarget)
{
case LOW_FREQ:
lowFreqAttachment.setValueAsPartOfGesture(newFreq);
break;
case HIGH_FREQ:
highFreqAttachment.setValueAsPartOfGesture(newFreq);
break;
}
}
void FilterResponse::mouseDoubleClick(const juce::MouseEvent& event)
{
auto part = getClosestPartInRange(event.getMouseDownX(), event.getMouseDownY());
switch (part)
{
case LOW_FREQ:
lowFreqAttachment.setValueAsCompleteGesture(PluginParameters::EQ_LOW_FREQ_DEFAULT);
break;
case HIGH_FREQ:
highFreqAttachment.setValueAsCompleteGesture(PluginParameters::EQ_HIGH_FREQ_DEFAULT);
break;
}
}
FilterResponseParts FilterResponse::getClosestPartInRange(int x, int y) const
{
auto bounds = getLocalBounds().toFloat();
juce::Array targets = {
CompPart {LOW_FREQ, juce::Rectangle<float>(freqToPos(bounds, lowFreq), bounds.getY(), 0, bounds.getHeight()), 1},
CompPart {HIGH_FREQ, juce::Rectangle<float>(freqToPos(bounds, highFreq), bounds.getY(), 0, bounds.getHeight()), 1},
};
return CompPart<FilterResponseParts>::getClosestInRange(targets, x, y, Feel::DRAGGABLE_SNAP);
}
float FilterResponse::freqToPos(juce::Rectangle<float> bounds, float freq) const
{
return (bounds.getWidth() - 1) * logf(freq / startFreq) / logf(float(endFreq) / startFreq);
}
float FilterResponse::posToFreq(juce::Rectangle<float> bounds, float pos) const
{
return startFreq * powf(float(endFreq) / startFreq, float(pos) / (bounds.getWidth() - 1.f));
}
juce::String FilterResponse::getCustomHelpText()
{
auto part = dragging ? draggingTarget : getClosestPartInRange(getMouseXYRelative().getX(), getMouseXYRelative().getY());
switch (part)
{
case LOW_FREQ: return PluginParameters::EQ_LOW_FREQ + ": " + juce::String(int(lowFreq)) + " " + PluginParameters::FREQUENCY_UNIT;
case HIGH_FREQ: return PluginParameters::EQ_HIGH_FREQ + ": " + juce::String(int(highFreq)) + " " + PluginParameters::FREQUENCY_UNIT;
default: return "Drag to adjust filter cutoffs";
}
}

View file

@ -0,0 +1,61 @@
/*
==============================================================================
FilterResponse.h
Created: 2 Jan 2024 10:40:21pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../../Sampler/Effects/BandEQ.h"
#include "../../Utilities/ComponentUtils.h"
enum FilterResponseParts
{
NONE,
LOW_FREQ,
HIGH_FREQ
};
class FilterResponse final : public CustomComponent
{
public:
FilterResponse(APVTS& apvts, int sampleRate);
private:
void paint(juce::Graphics&) override;
void enablementChanged() override;
void mouseMove(const juce::MouseEvent& event) override;
void mouseDown(const juce::MouseEvent& event) override;
void mouseUp(const juce::MouseEvent& event) override;
void mouseDrag(const juce::MouseEvent& event) override;
void mouseDoubleClick(const juce::MouseEvent& event) override;
FilterResponseParts getClosestPartInRange(int x, int y) const;
float freqToPos(juce::Rectangle<float> bounds, float freq) const;
float posToFreq(juce::Rectangle<float> bounds, float pos) const;
juce::String getCustomHelpText() override;
//==============================================================================
constexpr static int startFreq{ 20 };
constexpr static int endFreq{ 17500 };
//==============================================================================
APVTS& apvts;
BandEQ eq;
float lowFreq{ 0 }, highFreq{ 0 }, lowGain{ 0 }, midGain{ 0 }, highGain{ 0 };
juce::ParameterAttachment lowFreqAttachment, highFreqAttachment, lowGainAttachment, midGainAttachment, highGainAttachment;
bool dragging{ false };
FilterResponseParts draggingTarget{ NONE };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (FilterResponse)
};

View file

@ -0,0 +1,150 @@
/*
==============================================================================
ReverbResponse.cpp
Created: 17 Jan 2024 11:16:38pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "ReverbResponse.h"
ReverbResponse::ReverbResponse(APVTS& apvts) : apvts(apvts),
lowsAttachment(*apvts.getParameter(PluginParameters::REVERB_LOWS), [this](float newValue) { lows = newValue; repaint(); }, apvts.undoManager),
highsAttachment(*apvts.getParameter(PluginParameters::REVERB_HIGHS), [this](float newValue) { highs = newValue; repaint(); }, apvts.undoManager),
mixAttachment(*apvts.getParameter(PluginParameters::REVERB_MIX), [this](float newValue) { mix = newValue; repaint(); }, apvts.undoManager),
responseThread(apvts, sampleRate),
response(1, int(ReverbResponseThread::DISPLAY_TIME * sampleRate / ReverbResponseThread::SAMPLE_RATE_RATIO))
{
lowsAttachment.sendInitialUpdate();
highsAttachment.sendInitialUpdate();
mixAttachment.sendInitialUpdate();
response.clear();
responseThread.startThread();
setBufferedToImage(true);
startTimerHz(30);
}
void ReverbResponse::paint(juce::Graphics& g)
{
auto bounds = getLocalBounds().toFloat();
auto theme = getTheme();
// Calculate the values
float numPointsPerPixel = 0.5f;
int numPoints = int(numPointsPerPixel * getWidth());
int samplesPerPoint = int(response.getNumSamples() / numPoints);
juce::Array<float> responseValues;
for (int i = 0; i < numPoints; i++)
{
int sampleIndex = i * samplesPerPoint;
float rms = response.getRMSLevel(0, sampleIndex, juce::jmin(samplesPerPoint, response.getNumSamples() - sampleIndex));
float multiplier = 0.2f + 0.4f * lows + 0.4f * highs;
float yPos = juce::jmap<float>(rms * multiplier, 0.f, 0.7f, bounds.getHeight() / 2.f, getHeight() * 0.02f);
responseValues.add(yPos);
}
// Draw the response
juce::Path rmsPath;
for (int i = 0; i < numPoints; i++)
{
float x = float(i) / numPointsPerPixel;
float y = responseValues[i];
if (i == 0)
rmsPath.startNewSubPath(x, y);
else
rmsPath.lineTo(x, y);
}
for (int i = numPoints - 1; i >= 0; i--)
{
float x = float(i) / numPointsPerPixel;
float y = responseValues[i];
rmsPath.lineTo(x, getHeight() - y);
}
rmsPath.closeSubPath();
g.setColour(theme.dark);
g.fillRect(0.f, mix * getHeight() / 2.f, getWidth() * 0.01f, float(getHeight()) * (1 - mix));
g.fillPath(rmsPath);
if (!isEnabled())
g.fillAll(theme.background.withAlpha(0.5f));
}
void ReverbResponse::enablementChanged()
{
repaint();
}
void ReverbResponse::timerCallback()
{
// Process the response thread changes
bool changesMade{ false };
while (responseThread.getResponseChangeQueue().peek() != nullptr)
{
response = *responseThread.getResponseChangeQueue().peek();
responseThread.getResponseChangeQueue().pop();
changesMade = true;
}
if (changesMade)
repaint();
}
ReverbResponseThread::ReverbResponseThread(const APVTS& apvts, int sampleRate) : Thread("Reverb_Response_Thread"), sampleRate(sampleRate),
sizeAttachment(*apvts.getParameter(PluginParameters::REVERB_SIZE), [this](float value) { size = value; reverbChanged = true; notify(); }, apvts.undoManager),
dampingAttachment(*apvts.getParameter(PluginParameters::REVERB_DAMPING), [this](float value) { damping = value; reverbChanged = true; notify(); }, apvts.undoManager),
delayAttachment(*apvts.getParameter(PluginParameters::REVERB_PREDELAY), [this](float value) { delay = value; reverbChanged = true; notify(); }, apvts.undoManager),
mixAttachment(*apvts.getParameter(PluginParameters::REVERB_MIX), [this](float value) { mix = value; reverbChanged = true; notify(); }, apvts.undoManager),
impulse(1, int(DISPLAY_TIME * sampleRate / SAMPLE_RATE_RATIO))
{
sizeAttachment.sendInitialUpdate();
dampingAttachment.sendInitialUpdate();
delayAttachment.sendInitialUpdate();
mixAttachment.sendInitialUpdate();
initializeImpulse();
}
ReverbResponseThread::~ReverbResponseThread()
{
stopThread(5000);
}
void ReverbResponseThread::initializeImpulse()
{
// Exponential chirp (seems to provide most accurate response)
impulse.clear();
auto impulseSize = int(IMPULSE_TIME * sampleRate / SAMPLE_RATE_RATIO);
for (int i = 1; i <= impulseSize; i++)
{
impulse.setSample(0, i - 1,
sinf(juce::MathConstants<float>::twoPi * CHIRP_START * (powf(CHIRP_END / CHIRP_START, float(i) / impulseSize) - 1.f) /
(float(impulseSize) / i * logf(CHIRP_END / CHIRP_START)))
);
}
}
void ReverbResponseThread::run()
{
while (!threadShouldExit())
{
if (!reverbChanged)
wait(-1); // Wait until the RMS value needs to be updated, and notify() is called
reverbChanged = false;
initializeImpulse();
reverb.initialize(1, juce::jmax(1000, int(sampleRate / SAMPLE_RATE_RATIO)));
reverb.updateParams(size, damping, delay, 1.f, 1.f, mix);
reverb.process(impulse, impulse.getNumSamples());
responseChangeQueue.enqueue(impulse);
}
}

View file

@ -0,0 +1,74 @@
/*
==============================================================================
ReverbResponse.h
Created: 17 Jan 2024 11:16:38pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include <readerwriterqueue.h>
#include "../../Utilities/ComponentUtils.h"
#include "../../Sampler/Effects/Reverb.h"
class ReverbResponseThread final : public juce::Thread
{
public:
explicit ReverbResponseThread(const APVTS& apvts, int sampleRate);
~ReverbResponseThread() override;
void run() override;
moodycamel::ReaderWriterQueue<juce::AudioBuffer<float>, 16384>& getResponseChangeQueue() { return responseChangeQueue; }
//==============================================================================
static constexpr int DISPLAY_TIME{ 10 };
static constexpr float SAMPLE_RATE_RATIO{ 10.f };
private:
static constexpr float IMPULSE_TIME{ 0.05f };
static constexpr float CHIRP_START{ 50.f };
static constexpr float CHIRP_END{ 18000.f };
void initializeImpulse();
//==============================================================================
int sampleRate;
Reverb reverb;
std::atomic<float> size{ 0.f }, damping{ 0.f }, delay{ 0.f }, mix{ 0.f };
std::atomic<bool> reverbChanged{ false };
juce::ParameterAttachment sizeAttachment, dampingAttachment, delayAttachment, mixAttachment;
juce::AudioBuffer<float> impulse;
#pragma warning(disable: 4324) // structure was padded due to __declspec(align())
moodycamel::ReaderWriterQueue<juce::AudioBuffer<float>, 16384> responseChangeQueue;
};
class ReverbResponse final : public CustomComponent, public juce::Timer
{
public:
ReverbResponse(APVTS& apvts);
void paint (juce::Graphics&) override;
void enablementChanged() override;
void timerCallback() override;
private:
APVTS& apvts;
constexpr static int sampleRate = 48000;
float lows{ 0.f }, highs{ 0.f }, mix{ 0.f };
juce::ParameterAttachment lowsAttachment, highsAttachment, mixAttachment;
ReverbResponseThread responseThread;
juce::AudioBuffer<float> response;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ReverbResponse)
};

View file

@ -0,0 +1,453 @@
/*
==============================================================================
SamplePainter.cpp
Created: 19 Sep 2023 3:02:14pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include <algorithm>
#include "SamplePainter.h"
SamplePainter::SamplePainter(ListenableAtomic<int>& primaryVisibleChannel, float resolutionScale, UIDummyParam* uiDummyParam) :
resolutionScale(resolutionScale), sampleData(2, 0),
primaryChannel(primaryVisibleChannel), dummyParam(uiDummyParam)
{
primaryChannel.addListener(this);
setBufferedToImage(true);
}
SamplePainter::~SamplePainter()
{
primaryChannel.removeListener(this);
}
void SamplePainter::paint(juce::Graphics& g)
{
if (!sample || !sample->getNumChannels() || sample->getNumSamples() <= 1 || viewEnd <= viewStart || viewStart >= sample->getNumSamples() ||
viewEnd >= sample->getNumSamples() || sample->getNumSamples() != sampleSize || numPoints == 0)
return;
using namespace juce;
g.setColour(findColour(Colors::painterColorId, true));
// Draw a horizontal line at 0
float dividerHeight = getHeight() * 0.0035f;
g.fillRect(0.f, (getHeight() - dividerHeight) / 2.f, float(getWidth()), dividerHeight);
int start = viewStart;
int end = viewEnd;
int viewSize = end - start + 1;
// While we could do a more general solution with a variable amount of caches, let's just use two fixed caches
int cacheRatio = intervalWidth >= cache2Amount ? cache2Amount : intervalWidth >= cache1Amount ? cache1Amount : 1;
auto& cacheData = (mono && sample->getNumChannels() > 1)
? (cacheRatio == cache1Amount ? cache1DataMono : cache2DataMono)
: (cacheRatio == cache1Amount ? cache1Data : cache2Data);
auto strokeWidth = getWidth() / resolution * 1.25f;
// Regular display
if (!isSampleBySample())
{
// Sample the data
sampleData.setSize(sampleData.getNumChannels(), numPoints, false, false, true);
// To keep sampling more consistent, we round down to intervalWidth
float startX = std::floor(start / intervalWidth) * intervalWidth / cacheRatio;
for (auto i = 0; i < numPoints; i++)
{
int index = int(startX + intervalWidth / cacheRatio * i);
int numValues = int(startX + intervalWidth / cacheRatio * (i + 1)) - index;
float min;
float max;
if (cacheRatio > 1.f)
{
min = FloatVectorOperations::findMinimum(cacheData.getReadPointer(0, index), numValues);
max = FloatVectorOperations::findMaximum(cacheData.getReadPointer(1, index), numValues);
}
else if (mono)
{
downsample(*sample, true, index, numValues, min, max);
}
else
{
downsample(*sample, false, index, numValues, min, max);
}
sampleData.setSample(0, i, min * gain);
sampleData.setSample(1, i, max * gain);
}
// Create the path
float offset = float(startX * cacheRatio - viewStart) / viewSize * getWidth();
Path path;
path.preallocateSpace(numPoints * 2 * 3);
for (auto i = 0; i < numPoints; i++)
{
float x = offset + jmap<float>(float(i), 0.f, numPoints - 2.f, 0.f, float(getWidth()));
float yMax = jmap<float>(sampleData.getSample(1, i), -1.f, 1.f, float(getHeight()), 0.f);
float yMin = jmap<float>(sampleData.getSample(0, i), -1.f, 1.f, float(getHeight()), 0.f);
if (!i)
path.startNewSubPath(x, yMax);
else
path.lineTo(x, yMax);
path.lineTo(x, yMin);
}
g.strokePath(path, PathStrokeType(strokeWidth, PathStrokeType::beveled));
}
// Sample by sample display
else
{
// If mono is enabled, we average the channels and exit this loop on the first iteration
// Otherwise, we draw each channel separately with a different opacity
// Find the primary channel to display
int primary = primaryChannel;
if (selectingChannel >= 0)
primary = selectingChannel;
Path path;
path.preallocateSpace(3 * (viewSize + 1));
Path circles;
auto circRadius = 0.003125f * getWidth();
bool drawCircles = viewSize <= SAMPLE_BY_SAMPLE_THRESHOLD;
if (drawCircles)
path.preallocateSpace(4 * viewSize);
for (auto ch = 0; ch < sample->getNumChannels(); ch++)
{
for (auto i = 0; i < viewSize; i++)
{
float level = sample->getSample(ch, start + i);
// If mono is enabled, we average the channels
if (mono)
{
level = 0;
for (auto ch2 = 0; ch2 < sample->getNumChannels(); ch2++)
level += sample->getSample(ch2, start + i);
level /= sample->getNumChannels();
}
level *= gain;
float xPos = float(getWidth() * i) / (end - start);
float yPos = jmap<float>(level, -1, 1, float(getHeight()), 0);
if (!i)
path.startNewSubPath(0, yPos);
path.lineTo(xPos, yPos);
// Only draw the circles when we are further zoomed in
if (drawCircles)
circles.addEllipse(xPos - circRadius, yPos - circRadius, circRadius * 2.f, circRadius * 2.f);
}
int channelOrderNum = (primary - ch + sample->getNumChannels()) % sample->getNumChannels();
float opacity = std::pow(0.6f, float(channelOrderNum));
if (mono)
opacity = 1.f;
g.setColour(findColour(Colors::painterColorId, true).withMultipliedAlpha(opacity));
g.strokePath(path, PathStrokeType(strokeWidth, PathStrokeType::curved));
g.fillPath(circles);
if (mono)
break;
path.clear();
circles.clear();
}
path.clear();
circles.clear();
}
}
void SamplePainter::resized()
{
recalculateIntervals();
}
void SamplePainter::enablementChanged()
{
repaint();
}
void SamplePainter::colourChanged()
{
repaint();
}
juce::String SamplePainter::getCustomHelpText()
{
auto mousePos = getMouseXYRelative();
int hoveringChannel = getChannel(mousePos.x, mousePos.y);
if (hoveringChannel == -1 || sample->getNumChannels() == 1)
return "";
if (sample->getNumChannels() == 2)
return "Focus " + juce::String(hoveringChannel == 0 ? "left" : "right") + " channel";
return "Focus channel " + juce::String(hoveringChannel + 1);
}
bool SamplePainter::isSampleBySample() const
{
return intervalWidth <= 5.f;
}
int SamplePainter::getChannel(int x, int y) const
{
if (!sample || !sample->getNumChannels() || !isSampleBySample() || mono)
return -1;
int closestCh = -1;
float bestYDist = getHeight() / 6.f;
int start = viewStart;
int end = viewEnd;
int viewSize = end - start + 1;
// Estimate closest sample index from x
float normX = float(x) / getWidth();
int i = juce::jlimit(0, viewSize - 1, int(std::round(normX * (end - start))));
for (int ch = 0; ch < sample->getNumChannels(); ++ch)
{
float level = sample->getSample(ch, start + i) * gain;
float yPos = juce::jmap<float>(level, -1, 1, float(getHeight()), 0);
float yDist = std::abs(y - yPos);
if (yDist < bestYDist)
{
bestYDist = yDist;
closestCh = ch;
}
}
return closestCh;
}
void SamplePainter::mouseDown(const juce::MouseEvent& event)
{
if (!isSampleBySample() || mono)
return;
selectingChannel = getChannel(event.x, event.y);
repaint();
}
void SamplePainter::mouseUp(const juce::MouseEvent& event)
{
if (!isSampleBySample() || mono)
return;
int hoveringChannel = getChannel(event.x, event.y);
if (selectingChannel >= 0 && hoveringChannel == selectingChannel)
{
primaryChannel = selectingChannel;
if (dummyParam)
dummyParam->sendUIUpdate();
}
selectingChannel = -1;
repaint();
}
void SamplePainter::valueChanged(ListenableValue<int>&, int)
{
repaint();
}
void SamplePainter::downsample(const juce::AudioBuffer<float>& buffer, bool average, int start, int numSamples, float& outMin, float& outMax)
{
// Average channels for mono downsample
if (average)
{
downsampleBuffer.setSize(1, numSamples, false, false, true);
downsampleBuffer.clear();
for (int ch = 0; ch < buffer.getNumChannels(); ch++)
downsampleBuffer.addFrom(0, 0, buffer, ch, start, numSamples);
auto range = juce::FloatVectorOperations::findMinAndMax(downsampleBuffer.getReadPointer(0), numSamples);
outMin = range.getStart() / buffer.getNumChannels();
outMax = range.getEnd() / buffer.getNumChannels();
}
// Downsample across all channels
else
{
float min = std::numeric_limits<float>::max();
float max = std::numeric_limits<float>::lowest();
for (int ch = 0; ch < buffer.getNumChannels(); ch++)
{
auto range = juce::FloatVectorOperations::findMinAndMax(buffer.getReadPointer(ch, start), numSamples);
min = juce::jmin(range.getStart(), min);
max = juce::jmax(range.getEnd(), max);
}
outMin = min;
outMax = max;
}
}
void SamplePainter::updateCaches(int start, int end)
{
if (!sample || start < 0)
return;
float min;
float max;
int cacheRatio = int(cache2Amount / cache1Amount);
const int cache1Size = int(1 + std::ceil(float(sample->getNumSamples()) / cache1Amount));
int effectiveStart = (start / cache1Amount) * cache1Amount; // Round down to the nearest cache1Amount
cache1Data.setSize(2, cache1Size, true, false, false);
for (int i = effectiveStart; i < end; i += cache1Amount)
{
int numSamples = juce::jmin(cache1Amount, sample->getNumSamples() - i);
downsample(*sample, false, i, numSamples, min, max);
cache1Data.setSample(0, i / cache1Amount, min);
cache1Data.setSample(1, i / cache1Amount, max);
}
const int cache2Size = int(std::ceil(float(cache1Data.getNumSamples() + 1) / cacheRatio));
int effectiveCache2Start = (effectiveStart / cache1Amount / cacheRatio) * cacheRatio; // Round down to the nearest cacheRatio
cache2Data.setSize(2, cache2Size, true, false, false);
for (int i = effectiveCache2Start; i < end / cache1Amount; i += cacheRatio)
{
int numSamples = juce::jmin(cacheRatio, cache1Data.getNumSamples() - i);
min = juce::FloatVectorOperations::findMinimum(cache1Data.getReadPointer(0, i), numSamples);
max = juce::FloatVectorOperations::findMaximum(cache1Data.getReadPointer(1, i), numSamples);
cache2Data.setSample(0, i / cacheRatio, min);
cache2Data.setSample(1, i / cacheRatio, max);
}
if (sample->getNumChannels() > 1)
{
cache1DataMono.setSize(2, cache1Size, true, false, false);
for (int i = effectiveStart; i < end; i += cache1Amount)
{
int numSamples = juce::jmin(cache1Amount, sample->getNumSamples() - i);
downsample(*sample, true, i, numSamples, min, max);
cache1DataMono.setSample(0, i / cache1Amount, min);
cache1DataMono.setSample(1, i / cache1Amount, max);
}
cache2DataMono.setSize(2, cache2Size, true, false, false);
for (int i = effectiveCache2Start; i < end / cache1Amount; i += cacheRatio)
{
int numSamples = juce::jmin(cacheRatio, cache1DataMono.getNumSamples() - i);
min = juce::FloatVectorOperations::findMinimum(cache1DataMono.getReadPointer(0, i), numSamples);
max = juce::FloatVectorOperations::findMaximum(cache1DataMono.getReadPointer(1, i), numSamples);
cache2DataMono.setSample(0, i / cacheRatio, min);
cache2DataMono.setSample(1, i / cacheRatio, max);
}
}
}
void SamplePainter::appendToPath(int startSample, int endSample)
{
if (!sample)
return;
updateCaches(startSample, endSample);
recalculateIntervals();
repaint();
}
void SamplePainter::setSample(const juce::AudioBuffer<float>& sampleBuffer)
{
sample = &sampleBuffer;
sampleSize = sampleBuffer.getNumSamples();
viewStart = 0;
viewEnd = sampleBuffer.getNumSamples() - 1;
if (!sample)
return;
updateCaches(0, sample->getNumSamples());
recalculateIntervals();
repaint();
}
void SamplePainter::setSample(const juce::AudioBuffer<float>& sampleBuffer, int viewStartSample, int viewEndSample)
{
setSample(sampleBuffer);
viewStart = viewStartSample;
viewEnd = viewEndSample;
recalculateIntervals();
repaint();
}
void SamplePainter::setSampleView(int viewStartSample, int viewEndSample)
{
viewStart = viewStartSample;
viewEnd = viewEndSample;
recalculateIntervals();
repaint();
}
void SamplePainter::recalculateIntervals()
{
int viewSize = viewEnd - viewStart + 1;
// Set resolution as a constant factor of the screen width
int width = getWidth();
if (const juce::Displays::Display* screen = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay())
width = screen->userArea.getWidth();
resolution = width * resolutionScale;
// We adjust numPoints in factors of 2 to keep the view smooth looking
float sampleDiv = viewSize / resolution;
float base = 2.f;
float nextPower = std::pow(base, std::ceil(std::log(sampleDiv) / std::log(base)));
intervalWidth = nextPower / base;
numPoints = int(viewSize / intervalWidth);
}
void SamplePainter::setGain(float newGain)
{
gain = newGain;
repaint();
}
void SamplePainter::setMono(bool isMono)
{
mono = isMono;
if (!isMono)
selectingChannel = -1;
repaint();
}

View file

@ -0,0 +1,96 @@
/*
==============================================================================
SamplePainter.h
Created: 19 Sep 2023 3:02:14pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../../Sampler/CustomSamplerVoice.h"
#include "../../Utilities/ComponentUtils.h"
/** A custom component that paints a waveform of a sample. A cache is used to help render large
waveforms with minimal overhead. Additionally, a method is provided to append to the path for real-time recording.
*/
class SamplePainter final : public CustomComponent, public ValueListener<int>
{
public:
explicit SamplePainter(ListenableAtomic<int>& primaryVisibleChannel, float resolutionScale = 0.25f, UIDummyParam* dummyParam = nullptr);
~SamplePainter() override;
/** This adds (does not remove) to the path along the given start and end samples */
void appendToPath(int startSample, int endSample);
void setSample(const juce::AudioBuffer<float>& sampleBuffer);
void setSample(const juce::AudioBuffer<float>& sampleBuffer, int viewStartSample, int viewEndSample);
void setSampleView(int viewStartSample, int viewEndSample);
/** Change gain and repaint */
void setGain(float newGain);
/** Change mono display and repaint */
void setMono(bool isMono);
private:
void paint(juce::Graphics& g) override;
void resized() override;
void enablementChanged() override;
void colourChanged() override;
juce::String getCustomHelpText() override;
/** Whether sample by sample display is active */
bool isSampleBySample() const;
/** Get the channel number of the sample at the given x and y coordinates (within a snap amount) */
int getChannel(int x, int y) const;
void mouseDown(const juce::MouseEvent& event) override;
void mouseUp(const juce::MouseEvent& event) override;
void valueChanged(ListenableValue<int>& source, int newValue) override;
/** Downsample a range of a buffer. If average is true, the values are averaged, otherwise the max value is taken (across a
a single channel if channel is set, or across all channels if channel is -1).
*/
void downsample(const juce::AudioBuffer<float>& buffer, bool average, int start, int numSamples, float& outMin, float& outMax);
void updateCaches(int start, int end);
void recalculateIntervals();
//==============================================================================
const juce::AudioBuffer<float>* sample{ nullptr };
int sampleSize{ 0 };
int viewStart{ 0 }, viewEnd{ 0 };
float gain{ 1.f };
bool mono{ false };
float resolutionScale{ 1.f };
float resolution{ 0.f };
int numPoints{ 0 };
float intervalWidth{ 0.f };
/** An intermediate buffer */
juce::AudioBuffer<float> sampleData;
juce::AudioBuffer<float> downsampleBuffer;
/** The cache is a down-sampled version of the sample that is used to speed up rendering */
juce::AudioBuffer<float> cache1Data, cache2Data;
juce::AudioBuffer<float> cache1DataMono, cache2DataMono;
static constexpr int cache1Amount{ 100 }, cache2Amount{ 5000 };
const int SAMPLE_BY_SAMPLE_THRESHOLD{ 150 };
/** Which channel has full opacity */
ListenableAtomic<int>& primaryChannel;
UIDummyParam* dummyParam{ nullptr };
/** Set when a mouse down has occurred on a channel */
int selectingChannel{ -1 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SamplePainter)
};

View file

@ -0,0 +1,211 @@
/*
==============================================================================
FxChain.cpp
Created: 5 Jan 2024 3:26:37pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "FxChain.h"
void FxChainShadows::resized()
{
int innerShadowOffset = int(0.001f * getWidth());
innerShadow.setOffset({ 0, innerShadowOffset }, 0);
innerShadow.setOffset({ 0, -innerShadowOffset }, 1);
dragShadow.setOffset({ innerShadowOffset, innerShadowOffset });
}
void FxChainShadows::paint(juce::Graphics& g)
{
juce::Path innerShadowPath;
innerShadowPath.addRectangle(getLocalBounds());
innerShadow.render(g, innerShadowPath);
auto parent = dynamic_cast<FxChain*>(getParentComponent());
if (parent->isChainDragging())
{
juce::Path dragShadowPath;
dragShadowPath.addRectangle(parent->getDragComp()->getBounds());
dragShadow.render(g, dragShadowPath);
}
}
void FxChainShadows::lookAndFeelChanged()
{
auto colors = getTheme();
innerShadow.setColor(colors.slate.withAlpha(0.25f), 0);
innerShadow.setColor(colors.slate.withAlpha(0.25f), 1);
dragShadow.setColor(colors.slate.withAlpha(0.125f), 0);
}
FxChain::FxChain(JustaSampleAudioProcessor& processor) :
reverbDisplay(processor.APVTS()),
distortionDisplay(processor.APVTS(), int(processor.getSampleRate())),
eqDisplay(processor.APVTS(), int(processor.getSampleRate())),
chorusDisplay(processor.APVTS(), int(processor.getSampleRate())),
reverbModule(this, processor.APVTS(), "Reverb", PluginParameters::REVERB, PluginParameters::REVERB_ENABLED, PluginParameters::REVERB_MIX, reverbDisplay),
distortionModule(this, processor.APVTS(), "Distortion", PluginParameters::DISTORTION, PluginParameters::DISTORTION_ENABLED, PluginParameters::DISTORTION_MIX, distortionDisplay),
eqModule(this, processor.APVTS(), "Equalizer", PluginParameters::EQ, PluginParameters::EQ_ENABLED, eqDisplay),
chorusModule(this, processor.APVTS(), "Chorus", PluginParameters::CHORUS, PluginParameters::CHORUS_ENABLED, PluginParameters::CHORUS_MIX, chorusDisplay),
fxPermAttachment(*processor.APVTS().getParameter(PluginParameters::FX_PERM), [&](float newValue) { moduleOrder = PluginParameters::paramToPerm(int(newValue)); oldVal = int(newValue); resized(); }, &processor.getUndoManager())
{
reverbModule.addRotary(PluginParameters::REVERB_SIZE, "Size", { 11.4f, 82.f }, 87.f);
reverbModule.addRotary(PluginParameters::REVERB_DAMPING, "Damping", { 108.4f, 52.f }, 87.f);
reverbModule.addRotary(PluginParameters::REVERB_PREDELAY, "Delay", { 208.f, 82.f }, 87.f, PluginParameters::TIME_UNIT);
reverbModule.addRotary(PluginParameters::REVERB_LOWS, "Lows", { 302.f, 52.f }, 87.f);
reverbModule.addRotary(PluginParameters::REVERB_HIGHS, "Highs", { 399.4f, 82.f }, 87.f);
addAndMakeVisible(reverbModule);
distortionModule.addRotary(PluginParameters::DISTORTION_DENSITY, "Density", { 99.f, 46.f }, 110.f);
distortionModule.addRotary(PluginParameters::DISTORTION_HIGHPASS, "Highpass", { 289.f, 46.f }, 110.f);
addAndMakeVisible(distortionModule);
eqModule.addRotary(PluginParameters::EQ_LOW_GAIN, "Lows", { 44.5f, 46.f }, 110.f, PluginParameters::VOLUME_UNIT);
eqModule.addRotary(PluginParameters::EQ_MID_GAIN, "Mids", { 194.f, 46.f }, 110.f, PluginParameters::VOLUME_UNIT);
eqModule.addRotary(PluginParameters::EQ_HIGH_GAIN, "Highs", { 344.f, 46.f }, 110.f, PluginParameters::VOLUME_UNIT);
addAndMakeVisible(eqModule);
chorusModule.addRotary(PluginParameters::CHORUS_RATE, "Rate", { 40.f, 82.f }, 87.f, PluginParameters::FREQUENCY_UNIT);
chorusModule.addRotary(PluginParameters::CHORUS_DEPTH, "Depth", { 150.f, 52.f }, 87.f);
chorusModule.addRotary(PluginParameters::CHORUS_CENTER_DELAY, "Delay", { 260.6f, 82.f }, 87.f, PluginParameters::TIME_UNIT);
chorusModule.addRotary(PluginParameters::CHORUS_FEEDBACK, "Feedback", { 371.f, 52.f }, 87.f);
addAndMakeVisible(chorusModule);
shadows.setInterceptsMouseClicks(false, false);
addAndMakeVisible(shadows);
fxPermAttachment.sendInitialUpdate();
addMouseListener(this, true);
}
void FxChain::paint(juce::Graphics& g)
{
auto colors = getTheme();
g.setColour(colors.slate);
auto bounds = getLocalBounds();
auto dividerWidth = int(std::ceil(getWidth() * Layout::fxChainDivider));
auto moduleWidth = int(std::round((bounds.getWidth() - 3 * dividerWidth) / 4.f));
for (size_t i = 0; i < moduleOrder.size() - 1; i++)
{
bounds.removeFromLeft(moduleWidth);
auto divider = bounds.removeFromLeft(dividerWidth);
g.fillRect(divider);
}
}
void FxChain::resized()
{
auto bounds = getLocalBounds();
auto dividerWidth = int(std::ceil(getWidth() * Layout::fxChainDivider));
auto moduleWidth = int(std::round((bounds.getWidth() - 3 * dividerWidth) / 4.f));
for (const auto& fxType : moduleOrder)
{
auto& module = getModule(fxType);
auto moduleBounds = bounds.removeFromLeft(moduleWidth);
bounds.removeFromLeft(dividerWidth);
if (!(dragging && &module == dragComp))
module.setBounds(moduleBounds.toNearestInt());
}
shadows.setBounds(getLocalBounds());
if (dragging)
{
auto dragCompBounds = dragComp->getBounds();
dragCompBounds.setPosition(juce::jlimit<int>(0, getWidth() - dragComp->getWidth(), mouseX + dragOffset), 0);
dragComp->setBounds(dragCompBounds);
repaint();
}
}
void FxChain::mouseDrag(const juce::MouseEvent& event)
{
if (!dragging)
return;
auto localEvent = event.getEventRelativeTo(this);
mouseX = localEvent.x;
auto dragCompBounds = dragComp->getBounds();
dragCompBounds.setPosition(juce::jlimit<int>(0, getWidth() - dragComp->getWidth(), mouseX + dragOffset), 0);
// See if the chain's module order needs to be updated
auto bounds = getLocalBounds();
auto moduleWidth = bounds.getWidth() / 4;
for (int i = 0; i < 4; i++)
{
auto moduleBounds = bounds.removeFromLeft(moduleWidth);
if (i != dragCompIndex && moduleBounds.getX() <= dragCompBounds.getCentreX() && dragCompBounds.getCentreX() <= moduleBounds.getRight())
{
auto fxType = moduleOrder[i];
moduleOrder[i] = moduleOrder[dragCompIndex];
moduleOrder[dragCompIndex] = fxType;
dragCompIndex = i;
}
}
resized();
}
void FxChain::dragStarted(Component* comp, const juce::MouseEvent& event)
{
dragging = true;
dragComp = comp;
dragTarget = dynamic_cast<FxModule*>(comp)->getEffectType();
for (int i = 0; i < 4; i++)
if (&getModule(moduleOrder[i]) == dragComp)
dragCompIndex = i;
shadows.toFront(true);
dragComp->toFront(true);
mouseX = dragComp->getX();
dragOffset = dragComp->getX() - event.getEventRelativeTo(this).x;
}
void FxChain::dragEnded()
{
dragging = false;
int newVal = permToParam(moduleOrder);
if (newVal != oldVal)
fxPermAttachment.setValueAsCompleteGesture(float(newVal));
dragComp->toBehind(&shadows);
resized();
repaint();
}
void FxChain::enablementChanged()
{
reverbModule.setEnabled(isEnabled());
distortionModule.setEnabled(isEnabled());
eqModule.setEnabled(isEnabled());
chorusModule.setEnabled(isEnabled());
}
FxModule& FxChain::getModule(PluginParameters::FxTypes type)
{
switch (type)
{
case PluginParameters::REVERB:
return reverbModule;
case PluginParameters::DISTORTION:
return distortionModule;
case PluginParameters::EQ:
return eqModule;
case PluginParameters::CHORUS:
default:
return chorusModule;
}
}

View file

@ -0,0 +1,82 @@
/*
==============================================================================
FxChain.h
Created: 5 Jan 2024 3:26:37pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../PluginProcessor.h"
#include "../PluginParameters.h"
#include "../Utilities/ComponentUtils.h"
#include "FxModule.h"
#include "FxDragTarget.h"
#include "Displays/FilterResponse.h"
#include "Displays/ReverbResponse.h"
#include "Displays/DistortionVisualizer.h"
#include "Displays/ChorusVisualizer.h"
class FxChainShadows final : public CustomComponent
{
void resized() override;
void paint(juce::Graphics& g) override;
void lookAndFeelChanged() override;
melatonin::InnerShadow innerShadow{
{defaultTheme.slate.withAlpha(0.25f), 3, {0, 2}},
{defaultTheme.slate.withAlpha(0.25f), 3, {0, -2}}
};
melatonin::DropShadow dragShadow{ defaultTheme.slate.withAlpha(0.125f), 3, {2, 2} };
};
/** The FX chain allows for drag and drop reordering of the effects modules. */
class FxChain final : public CustomComponent, public FxDragTarget
{
public:
explicit FxChain(JustaSampleAudioProcessor& processor);
~FxChain() override = default;
bool isChainDragging() const { return dragging; }
Component* getDragComp() const { return dragComp; }
private:
void paint(juce::Graphics& g) override;
void resized() override;
void mouseDrag(const juce::MouseEvent& event) override;
void dragStarted(juce::Component* comp, const juce::MouseEvent& event) override;
void dragEnded() override;
void enablementChanged() override;
FxModule& getModule(PluginParameters::FxTypes type);
//==============================================================================
ReverbResponse reverbDisplay;
DistortionVisualizer distortionDisplay;
FilterResponse eqDisplay;
ChorusVisualizer chorusDisplay;
FxModule reverbModule, distortionModule, eqModule, chorusModule;
FxChainShadows shadows;
// Dragging functionality
juce::ParameterAttachment fxPermAttachment;
int oldVal{ 0 };
std::array<PluginParameters::FxTypes, 4> moduleOrder{};
bool dragging{ false };
PluginParameters::FxTypes dragTarget{ PluginParameters::REVERB };
Component* dragComp{ nullptr };
int dragCompIndex{ 0 };
int mouseX{ 0 };
int dragOffset{ 0 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (FxChain)
};

View file

@ -0,0 +1,20 @@
/*
==============================================================================
FxDragTarget.h
Created: 7 Jan 2024 12:21:57pm
Author: binya
==============================================================================
*/
#pragma once
/** This interface mostly exists separately to avoid some circular reference problems */
class FxDragTarget
{
public:
virtual ~FxDragTarget() = default;
virtual void dragStarted(juce::Component* component, const juce::MouseEvent& event) = 0;
virtual void dragEnded() = 0;
};

View file

@ -0,0 +1,219 @@
/*
==============================================================================
FXModule.cpp
Created: 28 Dec 2023 8:09:18pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "FxModule.h"
FxModule::FxModule(FxDragTarget* fxChain, APVTS& apvts, const juce::String& fxName, PluginParameters::FxTypes effectType, const juce::String& fxEnabledParameter, Component& displayComponent) :
fxChain(fxChain), apvts(apvts), fxType(effectType), nameLabel("", fxName), mixControl(juce::Slider::RotaryVerticalDrag, juce::Slider::NoTextBox),
enablementAttachment(apvts, fxEnabledParameter, fxEnabled), display(displayComponent),
dragIcon(getOutlineFromSVG(BinaryData::IconDrag_svg))
{
nameLabel.setInterceptsMouseClicks(false, false);
addAndMakeVisible(&nameLabel);
fxEnabled.onStateChange = [&] {
display.setEnabled(fxEnabled.getToggleState());
for (auto rotary : rotaryReferences)
rotary->setEnabled(fxEnabled.getToggleState());
for (auto& label : labels)
label->setEnabled(fxEnabled.getToggleState());
};
fxEnabled.onStateChange(); // Set the initial state
fxEnabled.setHelpText("Toggle " + fxName.toLowerCase());
addAndMakeVisible(&fxEnabled);
addAndMakeVisible(&displayComponent);
setRepaintsOnMouseActivity(true);
addMouseListener(this, true);
setHelpText(fxName + " module");
setBufferedToImage(true);
}
FxModule::FxModule(FxDragTarget* fxChain, APVTS& apvts, const juce::String& fxName, PluginParameters::FxTypes effectType, const juce::String& fxEnabledParameter, const juce::String& mixControlParameter, Component& displayComponent) :
FxModule(fxChain, apvts, fxName, effectType, fxEnabledParameter, displayComponent)
{
setupRotary(mixControl, false);
mixControl.getProperties().set(ComponentProps::ROTARY_UNIT, "%");
mixControlAttachment = std::make_unique<CustomRotaryAttachment>(apvts, mixControlParameter, mixControl);
}
juce::Slider* FxModule::addRotary(const juce::String& parameter, const juce::String& label, juce::Point<float> position, float width, const juce::String& unit)
{
auto rotary = std::make_unique<CustomRotary>();
CustomRotary* rawRotaryPtr = rotary.get();
auto attachment = std::make_unique<CustomRotaryAttachment>(apvts, parameter, *rawRotaryPtr);
attachments.push_back(std::move(attachment));
setupRotary(*rotary);
rotary->getProperties().set(ComponentProps::ROTARY_UNIT, unit);
addAndMakeVisible(rawRotaryPtr);
rotaries.push_back(std::move(rotary));
auto rotaryLabel = std::make_unique<juce::Label>("", label);
rotaryLabel->setInterceptsMouseClicks(false, false);
rotaryLabel->setJustificationType(juce::Justification::centredBottom);
rotaryLabel->setEnabled(fxEnabled.getToggleState());
addAndMakeVisible(rotaryLabel.get());
labels.push_back(std::move(rotaryLabel));
rotaryPositions.push_back(position);
rotaryWidths.push_back(width);
return rawRotaryPtr;
}
void FxModule::setupRotary(CustomRotary& rotary, bool useTextbox)
{
rotary.setSliderStyle(juce::Slider::RotaryVerticalDrag);
rotary.setMouseDragSensitivity(150);
if (useTextbox)
rotary.setTextBoxStyle(juce::Slider::TextBoxBelow, false, 0, 0);
rotary.addMouseListener(this, true); // We need this to pass drag events through the labels
rotary.setEnabled(fxEnabled.getToggleState());
addAndMakeVisible(rotary);
rotaryReferences.push_back(&rotary);
}
void FxModule::paint(juce::Graphics& g)
{
auto colors = getTheme();
auto bounds = getLocalBounds();
g.setColour(colors.background);
g.fillAll();
// Draw the drag icon
auto header = bounds.toFloat().removeFromTop(scalef(Layout::fxModuleHeader));
if (header.contains(getMouseXYRelative().toFloat()))
{
g.setColour(colors.darkerSlate);
header.removeFromTop(scalef(25.f));
auto iconBounds = header.removeFromTop(scalef(28.f));
g.fillPath(dragIcon, dragIcon.getTransformToScaleToFit(iconBounds.toFloat(), true));
}
}
void FxModule::resized()
{
auto bounds = getLocalBounds().toFloat();
// Header
auto mixPad = scale(43.f) * Layout::rotaryPadding;
auto header = bounds.removeFromTop(scale(Layout::fxModuleHeader)).reduced(scale(21.f), scale(15.f) - mixPad);
fxEnabled.setBounds(header.removeFromRight(scale(43.f)).reduced(0.f, mixPad).toNearestInt());
header.removeFromRight(scale(20.f) - mixPad);
if (mixControlAttachment)
mixControl.setBounds(header.removeFromRight(scale(43.f) + 2 * mixPad).toNearestInt());
header.removeFromRight(scale(20.f) - mixPad);
nameLabel.setBounds(header.reduced(0.f, mixPad).toNearestInt());
nameLabel.setFont(getInriaSans().withHeight(scalef(41.4f)));
// Controls
display.setBounds(bounds.removeFromTop(scale(103.f)).toNearestInt());
bounds.reduce(0.f, scale(27.5f));
for (size_t i = 0; i < rotaries.size(); i++)
{
auto* rotary = rotaries[i].get();
auto [xPos, yPos] = rotaryPositions[i];
auto width = rotaryWidths[i];
auto padding = Layout::rotaryPadding * scale(width);
rotary->setBounds(juce::Rectangle<float>(bounds.getX() + scale(xPos), bounds.getY() + scale(yPos), scale(width), scale(width) * Layout::rotaryHeightRatio).expanded(padding).toNearestInt());
auto* label = labels[i].get();
auto labelBounds = juce::Rectangle<float>(bounds.getX() + scale(xPos), bounds.getY() + scale(yPos) - scale(width) * 0.45f, scale(width), scale(width * 0.356f)).expanded(width, 0.f);
label->setFont(getInriaSans().withHeight(scale(width) * 0.356f));
label->setBounds(labelBounds.toNearestInt());
}
if (mixControlAttachment)
mixControl.sendLookAndFeelChange();
for (auto& rotary : rotaries)
rotary->sendLookAndFeelChange();
}
void FxModule::enablementChanged()
{
setInterceptsMouseClicks(isEnabled(), isEnabled());
repaint();
}
void FxModule::mouseDown(const juce::MouseEvent& event)
{
// We pass the events on the rotary number labels through to the rotary
auto parent = event.originalComponent->getParentComponent();
for (auto rotary : rotaryReferences)
if (parent == rotary)
rotary->mouseDown(event.getEventRelativeTo(rotary));
}
//==============================================================================
void FxModule::mouseUp(const juce::MouseEvent& event)
{
if (dragging)
{
dragging = false;
fxChain->dragEnded();
repaint();
}
auto parent = event.originalComponent->getParentComponent();
for (auto rotary : rotaryReferences)
if (parent == rotary)
rotary->mouseUp(event.getEventRelativeTo(rotary));
}
void FxModule::mouseMove(const juce::MouseEvent& /*event*/)
{
auto header = getLocalBounds().toFloat().removeFromTop(scale(Layout::fxModuleHeader));
auto mouseInHeader = header.contains(getMouseXYRelative().toFloat());
if (mouseInHeader)
setMouseCursor(juce::MouseCursor::DraggingHandCursor);
else
setMouseCursor(juce::MouseCursor::NormalCursor);
}
void FxModule::mouseDrag(const juce::MouseEvent& event)
{
auto header = getLocalBounds().toFloat().removeFromTop(scale(Layout::fxModuleHeader));
auto mouseInHeader = header.contains(getMouseXYRelative().toFloat());
if (!dragging && event.eventComponent == this && mouseInHeader)
{
dragging = true;
fxChain->dragStarted(this, event);
repaint();
}
auto parent = event.originalComponent->getParentComponent();
for (auto rotary : rotaryReferences)
if (parent == rotary)
rotary->mouseDrag(event.getEventRelativeTo(rotary));
}
void FxModule::mouseEnter(const juce::MouseEvent&)
{
repaint();
}
void FxModule::mouseExit(const juce::MouseEvent&)
{
repaint();
}

View file

@ -0,0 +1,78 @@
/*
==============================================================================
FXModule.h
Created: 28 Dec 2023 8:09:18pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "FxDragTarget.h"
#include "../PluginParameters.h"
#include "../Utilities/ComponentUtils.h"
/** An FX module within the FX chain. */
class FxModule final : public CustomComponent
{
public:
FxModule(FxDragTarget* fxChain, APVTS& apvts, const juce::String& fxName, PluginParameters::FxTypes effectType, const juce::String& fxEnabledParameter, Component& displayComponent);
/** Constructor for a module with a mix control. */
FxModule(FxDragTarget* fxChain, APVTS& apvts, const juce::String& fxName, PluginParameters::FxTypes effectType, const juce::String& fxEnabledParameter, const juce::String& mixControlParameter, Component& displayComponent);
juce::Slider* addRotary(const juce::String& parameter, const juce::String& label, juce::Point<float> position, float width, const juce::String& unit = "");
PluginParameters::FxTypes getEffectType() const { return fxType; }
private:
void paint(juce::Graphics&) override;
void resized() override;
void enablementChanged() override;
void mouseDown(const juce::MouseEvent& event) override;
void mouseUp(const juce::MouseEvent& event) override;
void mouseMove(const juce::MouseEvent& event) override;
void mouseDrag(const juce::MouseEvent& event) override;
void mouseEnter(const juce::MouseEvent& event) override;
void mouseExit(const juce::MouseEvent& event) override;
void setupRotary(CustomRotary& rotary, bool useTextbox = true);
float scale(float value) const { return std::round(value * getWidth() / (Layout::figmaWidth / 4.f)); }
float scalef(float value) const { return value * getWidth() / (Layout::figmaWidth / 4.f); }
//==============================================================================
FxDragTarget* fxChain;
APVTS& apvts;
PluginParameters::FxTypes fxType;
// Header controls
juce::Label nameLabel;
CustomRotary mixControl;
std::unique_ptr<APVTS::SliderAttachment> mixControlAttachment;
juce::ToggleButton fxEnabled;
APVTS::ButtonAttachment enablementAttachment;
// Controls
Component& display;
std::vector<std::unique_ptr<juce::Label>> labels;
std::vector<std::unique_ptr<CustomRotary>> rotaries;
std::vector<std::unique_ptr<CustomRotaryAttachment>> attachments;
std::vector<juce::Point<float>> rotaryPositions; // Relative to the area
std::vector<float> rotaryWidths;
std::vector<CustomRotary*> rotaryReferences;
bool dragging{ false };
juce::Path dragIcon;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (FxModule)
};

View file

@ -0,0 +1,352 @@
/*
==============================================================================
InputDeviceSelector.cpp
Created: 18 Aug 2024 8:20:19pm
Author: binya
==============================================================================
*/
#include "InputDeviceSelector.h"
ChannelSelectorListBox::ChannelSelectorListBox(juce::AudioDeviceManager& audioDeviceManager, int minNumInputChannels, int maxNumInputChannels, bool useStereoPairs)
: manager(audioDeviceManager), minInputChannels(minNumInputChannels), maxInputChannels(maxNumInputChannels), useStereoPairs(useStereoPairs)
{
setModel(this);
audioDeviceManager.addChangeListener(this);
changeListenerCallback(nullptr);
}
ChannelSelectorListBox::~ChannelSelectorListBox()
{
manager.removeChangeListener(this);
}
void ChannelSelectorListBox::changeListenerCallback(juce::ChangeBroadcaster*)
{
repaint();
auto* currentDevice = manager.getCurrentAudioDevice();
if (device == currentDevice)
return;
channels.clear();
if (!currentDevice)
return;
channels = currentDevice->getInputChannelNames();
device = currentDevice;
if (useStereoPairs)
{
juce::StringArray pairs;
int numPairs = channels.size() / 2;
for (int i = 0; i < numPairs; i++)
pairs.add(getNameForChannelPair(channels[i * 2], channels[i * 2 + 1]));
// Interestingly, StringArray->end() caused an issue on Mac
if (channels.size() % 2)
pairs.add(channels[channels.size() - 1].trim());
channels = pairs;
}
updateContent();
}
int ChannelSelectorListBox::getNumRows()
{
return channels.size();
}
void ChannelSelectorListBox::listBoxItemClicked(int row, const juce::MouseEvent& /*e*/)
{
if (row < channels.size())
flipEnablement(row);
}
void ChannelSelectorListBox::paintListBoxItem(int row, juce::Graphics& g, int width, int height, bool)
{
using namespace juce;
if (row >= channels.size())
return;
auto item = channels[row];
auto inputChannels = manager.getAudioDeviceSetup().inputChannels;
// We need to actually check the device for enablement
bool enabled = useStereoPairs ? inputChannels[row * 2] || inputChannels[row * 2 + 1] : inputChannels[row];
Rectangle<float> bounds{ float(width), float(height) };
bounds = bounds.reduced(bounds.getHeight() * 0.04f);
// To draw the tick box, we create a temporary ToggleButton (due to how I implemented the lnf function)
ToggleButton tempComp;
tempComp.setBounds(bounds.removeFromLeft(bounds.getHeight()).toNearestInt());
getLookAndFeel().drawTickBox(g, tempComp, 0, 0, 0, 0, enabled, true, true, false);
bounds.removeFromLeft(bounds.getHeight() * 0.3f);
g.setColour(findColour(textColourId));
g.setFont(getInter().withHeight(bounds.getHeight() * 0.85f));
g.drawText(channels[row], bounds, Justification::centredLeft);
}
void ChannelSelectorListBox::flipEnablement(int row) const
{
auto config = manager.getAudioDeviceSetup();
config.useDefaultInputChannels = false;
if (useStereoPairs)
{
bool enabled = config.inputChannels[row * 2] || config.inputChannels[row * 2 + 1];
setBit(config.inputChannels, row * 2 + 1, !enabled);
setBit(config.inputChannels, row * 2, !enabled);
}
else
{
setBit(config.inputChannels, row, !config.inputChannels[row]);
}
manager.setAudioDeviceSetup(config, true);
}
void ChannelSelectorListBox::setBit(juce::BigInteger& ch, int index, bool set) const
{
auto numActive = ch.countNumberOfSetBits();
if (ch[index] == set || (!set && numActive <= minInputChannels))
return;
// Clear a bit if necessary
if (set && numActive >= maxInputChannels)
{
auto firstActiveChan = ch.findNextSetBit(0);
ch.clearBit(index > firstActiveChan ? firstActiveChan : ch.getHighestBit());
}
ch.setBit(index, set);
}
juce::String ChannelSelectorListBox::getNameForChannelPair(const juce::String& name1, const juce::String& name2) const
{
juce::String commonBit;
for (int j = 0; j < name1.length(); ++j)
if (name1.substring(0, j).equalsIgnoreCase(name2.substring(0, j)))
commonBit = name1.substring(0, j);
// Make sure we only split the name at a space, because otherwise, things like "input 11" + "input 12" would become "input 11 + 2"
while (commonBit.isNotEmpty() && !juce::CharacterFunctions::isWhitespace(commonBit.getLastCharacter()))
commonBit = commonBit.dropLastCharacters(1);
return name1.trim() + " + " + name2.substring(commonBit.length()).trim();
}
//==============================================================================
InputDeviceSelector::InputDeviceSelector(juce::AudioDeviceManager& deviceManager, int minInputChannelsToUse, int maxInputChannelsToUse, bool showChannelsAsStereoPairs) :
manager(deviceManager),
titleLabel("", "Input Device Settings"),
warningLabel("", "No device opened, check DAW audio settings if issues persist"),
deviceTypeLabel("", "Type: "),
deviceLabel("", "Device: "),
channelsLabel("", "Channels: "),
sampleRateLabel("", "Sample rate: "),
bufferSizeLabel("", "Buffer size: "),
levelMeter(deviceManager),
channelSelector(deviceManager, minInputChannelsToUse, maxInputChannelsToUse, showChannelsAsStereoPairs)
{
jassert(minInputChannelsToUse >= 0 && minInputChannelsToUse <= maxInputChannelsToUse);
titleLabel.setJustificationType(juce::Justification::centred);
juce::Array labels = { &titleLabel, &deviceTypeLabel, &deviceLabel, &channelsLabel, &sampleRateLabel, &bufferSizeLabel };
for (auto* label : labels)
addAndMakeVisible(label);
warningLabel.setJustificationType(juce::Justification::centred);
addChildComponent(warningLabel);
const auto& types= manager.getAvailableDeviceTypes();
for (int i = 0; i < types.size(); ++i)
deviceTypeChooser.addItem(types.getUnchecked(i)->getTypeName(), i + 1);
deviceTypeChooser.setTextWhenNoChoicesAvailable("No devices available");
deviceTypeChooser.setSelectedId(1);
juce::Array comboBoxes = { &deviceTypeChooser, &deviceChooser, &sampleRateChooser, &bufferSizeChooser };
for (auto* box : comboBoxes)
{
box->onChange = [this] { settingsChanged(); };
addAndMakeVisible(box);
}
addAndMakeVisible(levelMeter);
addAndMakeVisible(channelSelector);
manager.addChangeListener(this);
settingsChanged();
}
InputDeviceSelector::~InputDeviceSelector()
{
manager.removeChangeListener(this);
}
void InputDeviceSelector::paint(juce::Graphics& g)
{
auto colors = getTheme();
g.fillAll(colors.prompt);
g.setColour(colors.foreground);
g.fillRoundedRectangle(getLocalBounds().toFloat(), 5.f);
}
void InputDeviceSelector::resized()
{
auto bounds = getLocalBounds().toFloat();
auto w = bounds.getWidth();
const auto row = bounds.getHeight() * 0.07f;
const auto padding = bounds.getHeight() * 0.035f;
constexpr auto labelProp = 0.25f;
bounds.reduce(w * 0.12f, w * 0.05f);
auto titleBounds = bounds.removeFromTop(bounds.proportionOfHeight(0.1f));
titleLabel.setFont(getInter().withHeight(titleBounds.getHeight()));
titleLabel.setBounds(titleBounds.toNearestInt());
bounds.removeFromTop(padding / 4);
auto warningLabelHeight = std::round(row * 0.665f);
warningLabel.setFont(getInter().withHeight(warningLabelHeight));
warningLabel.setBounds(bounds.removeFromTop(warningLabelHeight).toNearestInt());
bounds.removeFromTop(padding);
auto deviceTypeBounds = bounds.removeFromTop(row);
deviceTypeLabel.setFont(getInter().withHeight(std::round(row * 0.95f)));
deviceTypeLabel.setBounds(deviceTypeBounds.removeFromLeft(deviceTypeBounds.proportionOfWidth(labelProp)).toNearestInt());
deviceTypeChooser.setBounds(deviceTypeBounds.withHeight(row * 1.2f).toNearestInt());
bounds.removeFromTop(padding);
auto deviceBounds = bounds.removeFromTop(row);
deviceLabel.setFont(getInter().withHeight(std::round(row * 0.95f)));
deviceLabel.setBounds(deviceBounds.removeFromLeft(deviceBounds.proportionOfWidth(labelProp)).toNearestInt());
deviceChooser.setBounds(deviceBounds.removeFromLeft(bounds.proportionOfWidth(0.56f)).withHeight(row * 1.2f).toNearestInt());
levelMeter.setBounds(deviceBounds.removeFromRight(bounds.proportionOfWidth(0.15f)).toNearestInt());
bounds.removeFromTop(padding);
auto channelsBounds = bounds.removeFromTop(5 * row);
channelsLabel.setFont(getInter().withHeight(std::round(row * 0.95f)));
channelsLabel.setBounds(channelsBounds.removeFromLeft(channelsBounds.proportionOfWidth(labelProp)).removeFromTop(row).toNearestInt());
channelSelector.setBounds(channelsBounds.toNearestInt());
channelSelector.setRowHeight(int(std::round(row)));
bounds.removeFromTop(padding);
auto lastRow = row * 0.7f;
auto sampleRateBounds = bounds.removeFromTop(lastRow);
auto bufferSizeBounds = sampleRateBounds.removeFromRight(sampleRateBounds.proportionOfWidth(0.55f));
auto sampleRateLabelBounds = sampleRateBounds.removeFromLeft(sampleRateBounds.proportionOfWidth(0.5f));
sampleRateLabel.setFont(getInter().withHeight(std::round(lastRow * 0.95f)));
sampleRateLabel.setBounds(sampleRateLabelBounds.toNearestInt());
sampleRateChooser.setBounds(sampleRateBounds.withHeight(lastRow * 1.2f).toNearestInt());
bufferSizeBounds.removeFromLeft(bounds.getWidth() * 0.05f);
bufferSizeLabel.setFont(getInter().withHeight(std::round(lastRow * 0.95f)));
bufferSizeLabel.setBounds(bufferSizeBounds.removeFromLeft(bufferSizeBounds.proportionOfWidth(0.5f)).toNearestInt());
bufferSizeChooser.setBounds(bufferSizeBounds.withHeight(lastRow * 1.2f).toNearestInt());
}
void InputDeviceSelector::lookAndFeelChanged()
{
auto colors = getTheme();
warningLabel.setColour(juce::Label::textColourId, colors.highlight);
juce::Array comboBoxes = { &deviceTypeChooser, &deviceChooser, &sampleRateChooser, &bufferSizeChooser };
for (auto* box : comboBoxes)
box->setColour(Colors::backgroundColorId, colors.foreground);
}
void InputDeviceSelector::changeListenerCallback(juce::ChangeBroadcaster*)
{
auto* device = manager.getCurrentAudioDevice();
warningLabel.setVisible(!device);
if (!device)
return;
inputDevice = device;
deviceChooser.setText(device->getName());
sampleRateChooser.clear();
for (auto rate : device->getAvailableSampleRates())
sampleRateChooser.addItem(juce::String(rate) + " hz", int(rate));
sampleRateChooser.setText(juce::String(int(device->getCurrentSampleRate())) + " hz");
bufferSizeChooser.clear();
for (auto size : device->getAvailableBufferSizes())
bufferSizeChooser.addItem(juce::String(size), int(size));
bufferSizeChooser.setText(juce::String(device->getCurrentBufferSizeSamples()));
updateEnablement();
}
void InputDeviceSelector::settingsChanged()
{
juce::AudioDeviceManager::AudioDeviceSetup settings = manager.getAudioDeviceSetup();
auto deviceType = manager.getCurrentAudioDeviceType();
auto* selectedDeviceType = manager.getAvailableDeviceTypes()[deviceTypeChooser.getSelectedId() - 1];
bool newDeviceSelected = false;
if (deviceType != selectedDeviceType->getTypeName() || !bool(deviceChooser.getNumItems()))
{
manager.setCurrentAudioDeviceType(selectedDeviceType->getTypeName(), true);
deviceChooser.clear();
auto devices = selectedDeviceType->getDeviceNames(true);
for (int i = 0; i < devices.size(); i++)
deviceChooser.addItem(devices[i], i + 1);
deviceChooser.setSelectedId(1);
newDeviceSelected = true;
}
auto device = settings.inputDeviceName;
auto selectedDevice = deviceChooser.getItemText(deviceChooser.getSelectedItemIndex());
if (device != selectedDevice || newDeviceSelected)
{
settings.inputDeviceName = selectedDevice;
settings.useDefaultInputChannels = true;
}
auto sampleRate = int(settings.sampleRate);
auto selectedSampleRate = sampleRateChooser.getSelectedId();
if (sampleRate != selectedSampleRate && !newDeviceSelected && selectedSampleRate)
settings.sampleRate = selectedSampleRate;
auto bufferSize = settings.bufferSize;
auto selectedBufferSize = bufferSizeChooser.getSelectedId();
if (bufferSize != selectedBufferSize && !newDeviceSelected && selectedBufferSize)
settings.bufferSize = selectedBufferSize;
manager.setAudioDeviceSetup(settings, true);
updateEnablement();
}
void InputDeviceSelector::updateEnablement()
{
deviceChooser.setEnabled(bool(deviceChooser.getNumItems()));
sampleRateChooser.setEnabled(bool(sampleRateChooser.getNumItems()) && inputDevice && manager.getAudioDeviceSetup().inputChannels.countNumberOfSetBits());
bufferSizeChooser.setEnabled(bool(bufferSizeChooser.getNumItems()) && inputDevice && manager.getAudioDeviceSetup().inputChannels.countNumberOfSetBits());
levelMeter.setEnabled(bool(inputDevice));
channelSelector.setVisible(bool(inputDevice));
}

View file

@ -0,0 +1,114 @@
/*
==============================================================================
InputDeviceSelector.h
Created: 18 Aug 2024 8:20:19pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../Utilities/ComponentUtils.h"
/** A simple meter that displays the input level of the active audio device. */
struct DeviceLevelMeter final : CustomComponent, juce::Timer
{
explicit DeviceLevelMeter(juce::AudioDeviceManager& m) : manager(m)
{
startTimerHz(20);
inputLevelGetter = manager.getInputLevelGetter();
}
void timerCallback() override
{
if (!isVisible())
return;
auto newLevel = float(inputLevelGetter->getCurrentLevel());
if (std::abs(level - newLevel) > 0.005f)
level = newLevel;
auto* device = manager.getCurrentAudioDevice();
if (!device || !manager.getAudioDeviceSetup().inputChannels.countNumberOfSetBits())
level = 0;
repaint();
}
void paint(juce::Graphics& g) override
{
// Add a bit of skew
getLookAndFeel().drawLevelMeter(g, getWidth(), getHeight(), float(std::exp(std::log(level) / 3.0)));
}
juce::AudioDeviceManager& manager;
juce::AudioDeviceManager::LevelMeter::Ptr inputLevelGetter;
float level = 0;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(DeviceLevelMeter)
};
/** A simple component that allows the user to select the input channels of the active audio device. */
class ChannelSelectorListBox final : public juce::ListBox, juce::ListBoxModel, public juce::ChangeListener
{
public:
ChannelSelectorListBox(juce::AudioDeviceManager& audioDeviceManager, int minNumInputChannels, int maxNumInputChannels, bool useStereoPairs);
~ChannelSelectorListBox() override;
void changeListenerCallback(juce::ChangeBroadcaster*) override;
int getNumRows() override;
void listBoxItemClicked(int row, const juce::MouseEvent& e) override;
void paintListBoxItem(int row, juce::Graphics& g, int width, int height, bool) override;
private:
void flipEnablement(int row) const;
void setBit(juce::BigInteger& ch, int index, bool set) const;
juce::String getNameForChannelPair(const juce::String& name1, const juce::String& name2) const;
//==============================================================================
juce::AudioDeviceManager& manager;
int minInputChannels, maxInputChannels;
bool useStereoPairs;
juce::AudioIODevice* device{ nullptr };
juce::StringArray channels;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ChannelSelectorListBox)
};
/** A cleaner version of JUCE's AudioDeviceSelectorComponent */
class InputDeviceSelector final : public CustomComponent, public juce::ChangeListener
{
public:
InputDeviceSelector(juce::AudioDeviceManager& deviceManager, int minInputChannelsToUse, int maxInputChannelsToUse, bool showChannelsAsStereoPairs);
~InputDeviceSelector() override;
private:
void paint(juce::Graphics& g) override;
void resized() override;
void lookAndFeelChanged() override;
/** Update the choosers once the device updates */
void changeListenerCallback(juce::ChangeBroadcaster* source) override;
/** Update the device manager when a user selects something */
void settingsChanged();
void updateEnablement();
//==============================================================================
juce::AudioDeviceManager& manager;
juce::AudioIODevice* inputDevice{ nullptr };
juce::Label titleLabel, warningLabel, settingsLabel, deviceTypeLabel, deviceLabel, channelsLabel, sampleRateLabel, bufferSizeLabel;
juce::ComboBox deviceTypeChooser, deviceChooser, sampleRateChooser, bufferSizeChooser;
DeviceLevelMeter levelMeter;
ChannelSelectorListBox channelSelector;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(InputDeviceSelector)
};

122
Source/Components/Prompt.h Normal file
View file

@ -0,0 +1,122 @@
/*
==============================================================================
Prompt.h
Created: 15 May 2024 5:09:33pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../Utilities/ComponentUtils.h"
/** A prompt that can be opened and closed with a set of components to be kept "active" */
class Prompt final : public CustomComponent
{
public:
Prompt()
{
closePrompt();
}
~Prompt() override = default;
/** Opens a prompt with showComponents made visible with the prompt and highlightComponents placed in front
of the prompt background (although their visibility is never changed). onClose is called when the prompt is closed.
*/
void openPrompt(const juce::Array<Component*>& visibleComponents, const std::function<void()>& onClose = []() -> void {})
{
if (visible)
closePrompt();
onCloseCallback = onClose;
shownComponents = visibleComponents;
setVisible(true);
setInterceptsMouseClicks(true, true);
setWantsKeyboardFocus(true);
grabKeyboardFocus();
repaint();
visible = true;
}
/** Closes the prompt */
void closePrompt()
{
setVisible(false);
setInterceptsMouseClicks(false, false);
setWantsKeyboardFocus(false);
visible = false;
if (onCloseCallback)
onCloseCallback();
onCloseCallback = nullptr;
}
/** Returns whether the prompt is visible */
bool isPromptVisible() const { return visible; }
private:
/** Paint in all but the shown components */
void paint(juce::Graphics& g) override
{
if (!visible)
return;
auto colors = getTheme();
for (auto* comp : shownComponents)
g.excludeClipRegion(comp->getBounds().translated(-getX(), -getY()));
g.fillAll(colors.prompt);
auto bounds = getLocalBounds().toFloat();
auto xBounds = bounds.removeFromTop(getWidth() * 0.035f).removeFromRight(getWidth() * 0.035f).reduced(getWidth() * 0.01f);
auto thickness = getWidth() * 0.003f;
g.setColour(colors.dark);
g.drawLine({ xBounds.getTopLeft(), xBounds.getBottomRight() }, thickness);
g.drawLine({ xBounds.getTopRight(), xBounds.getBottomLeft() }, thickness);
}
/** Close the prompt when escape or space is pressed */
bool keyPressed(const juce::KeyPress& key) override
{
if (visible)
{
if (key == juce::KeyPress::escapeKey || key == juce::KeyPress::spaceKey)
{
closePrompt();
return true;
}
}
return false;
}
bool hitTest(int x, int y) override
{
return visible && !std::ranges::any_of(shownComponents, [this, x, y](const Component* comp) {
return comp->getBounds().translated(-getX(), -getY()).contains(x, y);
});
}
void mouseDown(const juce::MouseEvent&) override
{
if (visible)
closePrompt();
}
//==============================================================================
bool visible{ false };
juce::Array<juce::Component*> shownComponents;
std::function<void()> onCloseCallback;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Prompt)
};

View file

@ -0,0 +1,125 @@
/*
==============================================================================
RangeSelection.h
Created: 16 May 2024 8:28:47pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../Utilities/ComponentUtils.h"
/** A component that allows the user to select a range of a display */
class RangeSelector final : public CustomComponent
{
public:
RangeSelector()
{
cancelRangeSelect();
}
/** Begin the range selection prompt */
void promptRangeSelect(const std::function<void(int startPos, int endPos)>& onCloseCallback = {})
{
isSelecting = true;
draggingStarted = false;
onSelectedCallback = onCloseCallback;
setVisible(true);
setInterceptsMouseClicks(true, false);
resized();
}
/** Cancel the prompt */
void cancelRangeSelect()
{
isSelecting = false;
setVisible(false);
setInterceptsMouseClicks(false, false);
resized();
}
bool isSelectingRange() const
{
return isSelecting;
}
private:
void paint(juce::Graphics& g) override
{
if (!isSelecting)
return;
auto colors = getTheme();
if (draggingStarted)
{
int x = juce::jmin(startLoc, endLoc);
int width = juce::jmax(startLoc, endLoc) - x;
g.setColour(colors.slate.withAlpha(0.15f));
g.fillRect(x, 0, width, getHeight());
g.setColour(colors.slate);
g.fillRect(float(startLoc), 0.f, getWidth() * Layout::boundsWidth / 2.f, float(getHeight()));
g.fillRect(float(endLoc), 0.f, getWidth() * Layout::boundsWidth / 2.f, float(getHeight()));
}
else
{
int mousePos = getMouseXYRelative().getX();
g.setColour(colors.slate);
g.fillRect(float(mousePos), 0.f, getWidth() * Layout::boundsWidth / 2.f, float(getHeight()));
}
}
void mouseDown(const juce::MouseEvent& event) override
{
if (isSelecting && !draggingStarted)
{
draggingStarted = true;
startLoc = endLoc = juce::jlimit<int>(getX(), getRight(), event.x);
}
}
void mouseDrag(const juce::MouseEvent& event) override
{
if (isSelecting && draggingStarted)
{
endLoc = juce::jlimit<int>(getX(), getRight(), event.x);
repaint();
}
}
void mouseUp(const juce::MouseEvent& event) override
{
if (isSelecting && draggingStarted)
{
endLoc = juce::jlimit<int>(getX(), getRight(), event.x);
int leftLoc = juce::jmin(startLoc, endLoc);
int rightLoc = juce::jmax(startLoc, endLoc);
cancelRangeSelect(); // To hide the component
onSelectedCallback(leftLoc, rightLoc);
}
}
void mouseMove(const juce::MouseEvent&) override
{
repaint();
}
//==============================================================================
bool isSelecting{ false };
bool draggingStarted{ false };
int startLoc{ 0 }, endLoc{ 0 };
std::function<void(int, int)> onSelectedCallback;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (RangeSelector)
};

View file

@ -0,0 +1,556 @@
/*
==============================================================================
SampleEditorOverlay.cpp
Created: 19 Sep 2023 2:03:29pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "SampleEditor.h"
SampleEditorOverlay::SampleEditorOverlay(const APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray<CustomSamplerVoice>& synthVoices, UIDummyParam& dummy, CustomComponent* forwardEventsTo) :
synthVoices(synthVoices), dummyParam(dummy),
viewStart(pluginState.viewStart),
viewEnd(pluginState.viewEnd),
sampleStart(pluginState.sampleStart),
sampleEnd(pluginState.sampleEnd),
loopStart(pluginState.loopStart),
loopEnd(pluginState.loopEnd),
pinnedBounds(pluginState.pinView),
isWavetableModeDisabled(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::DISABLE_WAVETABLE_MODE))),
isLooping(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::IS_LOOPING))),
loopingHasStart(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::LOOPING_HAS_START))),
loopingHasEnd(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::LOOPING_HAS_END))),
isWavetableModeDisabledAttachment(*isWavetableModeDisabled, [this](bool) { repaint(); }, apvts.undoManager),
isLoopingAttachment(*isLooping, [this](bool) { repaint(); }, apvts.undoManager),
loopingHasStartAttachment(*loopingHasStart, [this](bool) { repaint(); }, apvts.undoManager),
loopingHasEndAttachment(*loopingHasEnd, [this](bool) { repaint(); }, apvts.undoManager),
forwardMouseEvents(forwardEventsTo)
{
viewStart.addListener(this);
viewEnd.addListener(this);
sampleStart.addListener(this);
sampleEnd.addListener(this);
loopStart.addListener(this);
loopEnd.addListener(this);
}
SampleEditorOverlay::~SampleEditorOverlay()
{
viewStart.removeListener(this);
viewEnd.removeListener(this);
sampleStart.removeListener(this);
sampleEnd.removeListener(this);
loopStart.removeListener(this);
loopEnd.removeListener(this);
}
//==============================================================================
void SampleEditorOverlay::valueChanged(ListenableValue<int>&, int)
{
safeRepaint();
}
void SampleEditorOverlay::paint(juce::Graphics& g)
{
if (viewStart == 0 && viewEnd == 0)
return;
using namespace juce;
auto colors = getTheme();
// Draw voice positions
auto waveformMode = isWaveformMode();
auto gain = 0.f;
for (auto& voice : synthVoices)
{
if (voice->isPlaying())
{
int location = int(std::ceil(voice->getPosition()));
auto pos = sampleToPosition(location) + getWidth() * Layout::boundsWidth;
Path voicePosition;
voicePosition.addLineSegment(Line<float>(pos, 0.f, pos, float(getHeight())), 1);
if (!waveformMode)
{
g.setColour(colors.light.withAlpha(voice->getEnvelopeGain()));
g.strokePath(voicePosition, PathStrokeType(Layout::playheadWidth * getWidth()));
}
gain += voice->getEnvelopeGain() / synthVoices.size();
}
}
float boundsWidth = getBoundsWidth();
float boundsSeparation = jmax(5 * boundsWidth, sampleToPosition(viewStart + Feel::MINIMUM_BOUNDS_DISTANCE) + 2 * boundsWidth);
float handleStrokeWidth = Layout::handleWidth * getWidth();
float startPos = sampleToPosition(sampleStart);
float endPos = sampleToPosition(sampleEnd) + boundsWidth;
float loopStartPos = sampleToPosition(loopStart);
float loopEndPos = sampleToPosition(loopEnd) + boundsWidth;
auto looping = isLooping->get() && !waveformMode;
auto loopingWithStart = isLooping->get() && loopingHasStart->get() && !waveformMode;
auto loopingWithEnd = isLooping->get() && loopingHasEnd->get() && !waveformMode;
// Paint the backgrounds
if (looping || waveformMode)
{
auto color = looping ? colors.loop.withAlpha(0.07f) : colors.highlight.withAlpha(jmin(gain * 0.5f, 0.2f));
g.setColour(disabled(color));
g.fillRect(Rectangle(startPos, 0.f, endPos - startPos, float(getHeight())));
}
g.setColour(disabled(colors.slate.withAlpha(0.15f)));
g.fillRect(Rectangle(0.f, 0.f, loopingWithStart ? loopStartPos : startPos, float(getHeight())));
g.fillRect(Rectangle(loopingWithEnd ? loopEndPos : endPos, 0.f, float(getWidth()) - endPos, float(getHeight())));
// Paint the start bound
Path startPosPath;
startPosPath.addRectangle(startPos, 0.f, boundsWidth, float(getHeight()));
(looping ? loopBoundsShadow : boundsShadow).render(g, startPosPath);
auto color = looping ? colors.loop : waveformMode ? colors.highlight : colors.slate;
g.setColour(dragging && draggingTarget == EditorParts::SAMPLE_START ? color.withAlpha(0.5f) : disabled(color));
g.fillPath(startPosPath);
if (isMouseOverOrDragging() && (!loopingWithStart || startPos - loopStartPos > boundsSeparation))
g.strokePath(handleLeft, PathStrokeType(handleStrokeWidth), AffineTransform::translation(startPos, 0.f));
if (isMouseOverOrDragging() && endPos - startPos > boundsSeparation)
g.strokePath(handleRight, PathStrokeType(handleStrokeWidth), AffineTransform::translation(startPos, 0.f));
// Paint the end bound
Path endPosPath;
endPosPath.addRectangle(endPos, 0.f, boundsWidth, float(getHeight()));
(looping ? loopBoundsShadow : boundsShadow).render(g, endPosPath);
g.setColour(dragging && draggingTarget == EditorParts::SAMPLE_END ? color.withAlpha(0.5f) : disabled(color));
g.fillPath(endPosPath);
if (isMouseOverOrDragging() && endPos - startPos > boundsSeparation)
g.strokePath(handleLeft, PathStrokeType(handleStrokeWidth), AffineTransform::translation(endPos, 0.f));
if (isMouseOverOrDragging() && (!loopingWithEnd || loopEndPos - endPos > boundsSeparation))
g.strokePath(handleRight, PathStrokeType(handleStrokeWidth), AffineTransform::translation(endPos, 0.f));
// Paint the loop bounds
if (looping)
{
if (loopingWithStart)
{
Path loopStartPath;
loopStartPath.addRectangle(loopStartPos, 0.f, boundsWidth, float(getHeight()));
boundsShadow.render(g, loopStartPath);
g.setColour(dragging && draggingTarget == EditorParts::LOOP_START ? colors.slate.withAlpha(0.5f) : disabled(colors.slate));
g.fillPath(loopStartPath);
if (isMouseOverOrDragging())
g.strokePath(handleLeft, PathStrokeType(handleStrokeWidth), AffineTransform::translation(loopStartPos, 0.f));
if (isMouseOverOrDragging() && startPos - loopStartPos > boundsSeparation)
g.strokePath(handleRight, PathStrokeType(handleStrokeWidth), AffineTransform::translation(loopStartPos, 0.f));
}
if (loopingWithEnd)
{
Path loopEndPath;
loopEndPath.addRectangle(loopEndPos, 0.f, boundsWidth, float(getHeight()));
boundsShadow.render(g, loopEndPath);
g.setColour(dragging && draggingTarget == EditorParts::LOOP_END ? colors.slate.withAlpha(0.5f) : disabled(colors.slate));
g.fillPath(loopEndPath);
if (isMouseOverOrDragging() && loopEndPos - endPos > boundsSeparation)
g.strokePath(handleLeft, PathStrokeType(handleStrokeWidth, PathStrokeType::curved, PathStrokeType::rounded), AffineTransform::translation(loopEndPos, 0.f));
if (isMouseOverOrDragging())
g.strokePath(handleRight, PathStrokeType(handleStrokeWidth, PathStrokeType::curved, PathStrokeType::rounded), AffineTransform::translation(loopEndPos, 0.f));
}
}
Path innerShadowPath;
innerShadowPath.addRectangle(getLocalBounds());
innerShadow.render(g, innerShadowPath);
}
void SampleEditorOverlay::resized()
{
int offset = int(0.0005f * getWidth());
boundsShadow.setOffset({ offset, 0 }, 0);
boundsShadow.setOffset({ -offset, 0 }, 1);
loopBoundsShadow.setOffset({ offset, 0 }, 0);
loopBoundsShadow.setOffset({ -offset, 0 }, 1);
int innerShadowOffset = int(0.001f * getWidth());
innerShadow.setOffset({ 0, innerShadowOffset }, 0);
innerShadow.setOffset({ 0, -innerShadowOffset }, 1);
float bounds = getBoundsWidth();
float handleHeight = 0.04f * getWidth();
handleLeft.clear();
handleLeft.startNewSubPath(-bounds / 2.f, (getHeight() + handleHeight) / 2.f);
handleLeft.lineTo(-bounds * 4.f / 3.f, getHeight() / 2.f);
handleLeft.lineTo(-bounds / 2.f, (getHeight() - handleHeight) / 2.f);
handleRight.clear();
handleRight.startNewSubPath(bounds + bounds / 2.f, (getHeight() + handleHeight) / 2.f);
handleRight.lineTo(bounds + bounds * 4.f / 3.f, getHeight() / 2.f);
handleRight.lineTo(bounds + bounds / 2.f, (getHeight() - handleHeight) / 2.f);
}
void SampleEditorOverlay::enablementChanged()
{
setInterceptsMouseClicks(isEnabled(), isEnabled());
repaint();
}
void SampleEditorOverlay::lookAndFeelChanged()
{
auto colors = getTheme();
boundsShadow.setColor(colors.slate.withAlpha(0.25f), 0);
boundsShadow.setColor(colors.slate.withAlpha(0.25f), 1);
loopBoundsShadow.setColor(colors.loop.withAlpha(0.25f), 0);
loopBoundsShadow.setColor(colors.loop.withAlpha(0.25f), 1);
innerShadow.setColor(colors.slate.withAlpha(0.25f), 0);
innerShadow.setColor(colors.slate.withAlpha(0.25f), 1);
}
//==============================================================================
void SampleEditorOverlay::mouseMove(const juce::MouseEvent& event)
{
EditorParts editorPart = getClosestPartInRange(event.x, event.y);
switch (editorPart)
{
case EditorParts::SAMPLE_START:
case EditorParts::SAMPLE_END:
case EditorParts::LOOP_START:
case EditorParts::LOOP_END:
setMouseCursor(juce::MouseCursor::LeftRightResizeCursor);
break;
case EditorParts::NONE:
setMouseCursor(juce::MouseCursor::NormalCursor);
}
}
void SampleEditorOverlay::mouseDown(const juce::MouseEvent& event)
{
EditorParts closest = getClosestPartInRange(event.getMouseDownX(), event.getMouseDownY());
switch (closest)
{
case EditorParts::NONE:
if (forwardMouseEvents)
forwardMouseEvents->mouseDown(event.getEventRelativeTo(forwardMouseEvents));
break;
default:
dragging = true;
draggingTarget = closest;
repaint();
break;
}
}
void SampleEditorOverlay::mouseUp(const juce::MouseEvent& event)
{
if (dragging)
dummyParam.sendUIUpdate();
dragging = false;
EditorParts closest = getClosestPartInRange(event.getMouseDownX(), event.getMouseDownY());
if (closest == EditorParts::NONE && forwardMouseEvents)
forwardMouseEvents->mouseUp(event.getEventRelativeTo(forwardMouseEvents));
repaint();
}
void SampleEditorOverlay::mouseDrag(const juce::MouseEvent& event)
{
using namespace juce;
if (!dragging)
return;
auto newSample = positionToSample(event.getMouseDownX() + event.getOffsetFromDragStart().getX() - getBoundsWidth());
auto loopHasStart = isLooping->get() && loopingHasStart->get() && !isWaveformMode();
auto loopHasEnd = isLooping->get() && loopingHasEnd->get() && !isWaveformMode();
auto pin = pinnedBounds.load();
auto minBufferSize = (1 + int(loopHasStart) + int(loopHasEnd)) * Feel::MINIMUM_BOUNDS_DISTANCE;
if (sampleBuffer->getNumSamples() < minBufferSize)
return;
// An intricate way to handle the dragging of the bounds, hopefully for a user-friendly experience
// Note that jmin(jmax(value, lower), upper) is equivalent to jlimit but uses the upper bound if min > max, while jmax(jmin(value, upper), lower) does the opposite
switch (draggingTarget)
{
case EditorParts::LOOP_START:
viewStart = jmax<int>(jmin<int>(viewStart, newSample), 0);
viewEnd = jmin<int>(jmax<int>(viewEnd, newSample + (2 + int(loopHasEnd)) * Feel::MINIMUM_BOUNDS_DISTANCE), sampleBuffer->getNumSamples() - 1);
if (newSample < loopStart)
{
loopStart = jmax(0, newSample);
}
else
{
loopEnd = jmin<int>(jmax<int>(loopEnd, newSample + 3 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasEnd && pin && loopEnd <= viewEnd ? viewEnd.load() : sampleBuffer->getNumSamples() - 1);
sampleEnd = jmin<int>(jmax<int>(sampleEnd, newSample + 2 * Feel::MINIMUM_BOUNDS_DISTANCE), jmin<int>(loopHasEnd ? loopEnd - Feel::MINIMUM_BOUNDS_DISTANCE : loopEnd.load(), pin ? viewEnd.load() : sampleBuffer->getNumSamples() - 1));
sampleStart = jmin<int>(jmax<int>(sampleStart, newSample + Feel::MINIMUM_BOUNDS_DISTANCE), sampleEnd - Feel::MINIMUM_BOUNDS_DISTANCE);
loopStart = jmin<int>(newSample, sampleStart - Feel::MINIMUM_BOUNDS_DISTANCE);
}
break;
case EditorParts::SAMPLE_START:
viewStart = jmax<int>(jmin<int>(viewStart, newSample - int(loopHasStart) * Feel::MINIMUM_BOUNDS_DISTANCE), 0);
viewEnd = jmin<int>(jmax<int>(viewEnd, newSample + (1 + int(loopHasEnd)) * Feel::MINIMUM_BOUNDS_DISTANCE), sampleBuffer->getNumSamples() - 1);
if (newSample < sampleStart)
{
loopStart = jmax<int>(jmin<int>(loopStart, newSample - Feel::MINIMUM_BOUNDS_DISTANCE), loopHasStart && pin && loopStart >= viewStart ? viewStart.load() : 0);
sampleStart = jmax<int>(newSample, loopStart + int(loopHasStart) * Feel::MINIMUM_BOUNDS_DISTANCE);
}
else
{
loopEnd = jmin<int>(jmax<int>(loopEnd, newSample + 2 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasEnd && pin && loopEnd <= viewEnd ? viewEnd.load() : sampleBuffer->getNumSamples() - 1);
sampleEnd = jmin<int>(jmax<int>(sampleEnd, newSample + Feel::MINIMUM_BOUNDS_DISTANCE), jmin<int>(loopHasEnd ? loopEnd - Feel::MINIMUM_BOUNDS_DISTANCE : loopEnd.load(), pin ? viewEnd.load() : sampleBuffer->getNumSamples() - 1));
sampleStart = jmin<int>(newSample, sampleEnd - Feel::MINIMUM_BOUNDS_DISTANCE);
}
break;
case EditorParts::SAMPLE_END:
viewStart = jmax<int>(jmin<int>(viewStart, newSample - (1 + int(loopHasStart)) * Feel::MINIMUM_BOUNDS_DISTANCE), 0);
viewEnd = jmin<int>(jmax<int>(viewEnd, newSample + int(loopHasEnd) * Feel::MINIMUM_BOUNDS_DISTANCE), sampleBuffer->getNumSamples() - 1);
if (newSample > sampleEnd)
{
loopEnd = jmin<int>(jmax<int>(loopEnd, newSample + Feel::MINIMUM_BOUNDS_DISTANCE), loopHasEnd && pin && loopEnd <= viewEnd ? viewEnd.load() : sampleBuffer->getNumSamples() - 1);
sampleEnd = jmin<int>(newSample, loopEnd - int(loopHasEnd) * Feel::MINIMUM_BOUNDS_DISTANCE);
}
else
{
loopStart = jmax<int>(jmin<int>(loopStart, newSample - 2 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasStart && pin && loopStart >= viewStart ? viewStart.load() : 0);
sampleStart = jmax<int>(jmin<int>(sampleStart, newSample - Feel::MINIMUM_BOUNDS_DISTANCE), jmax<int>(loopHasStart ? loopStart + Feel::MINIMUM_BOUNDS_DISTANCE : loopStart.load(), pin ? viewStart.load() : 0));
sampleEnd = jmax<int>(newSample, sampleStart + Feel::MINIMUM_BOUNDS_DISTANCE);
}
break;
case EditorParts::LOOP_END:
viewStart = jmax<int>(jmin<int>(viewStart, newSample - (2 + int(loopHasStart)) * Feel::MINIMUM_BOUNDS_DISTANCE), 0);
viewEnd = jmin<int>(jmax<int>(viewEnd, newSample), sampleBuffer->getNumSamples() - 1);
if (newSample > loopEnd)
{
loopEnd = jmin(sampleBuffer->getNumSamples() - 1, newSample);
}
else
{
loopStart = jmax<int>(jmin<int>(loopStart, newSample - 3 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasStart && pin && loopStart >= viewStart ? viewStart.load() : 0);
sampleStart = jmax<int>(jmin<int>(sampleStart, newSample - 2 * Feel::MINIMUM_BOUNDS_DISTANCE), jmax<int>(loopHasStart ? loopStart + Feel::MINIMUM_BOUNDS_DISTANCE : loopStart.load(), pin ? viewStart.load() : 0));
sampleEnd = jmax<int>(jmin<int>(sampleEnd, newSample - Feel::MINIMUM_BOUNDS_DISTANCE), sampleStart + Feel::MINIMUM_BOUNDS_DISTANCE);
loopEnd = jmax<int>(newSample, sampleEnd + Feel::MINIMUM_BOUNDS_DISTANCE);
}
break;
}
}
//==============================================================================
void SampleEditorOverlay::setSample(const juce::AudioBuffer<float>& sample, float bufferSampleRate)
{
sampleBuffer = &sample;
sampleRate = bufferSampleRate;
}
juce::String SampleEditorOverlay::getCustomHelpText()
{
auto closest = dragging ? draggingTarget : getClosestPartInRange(getMouseXYRelative().getX(), getMouseXYRelative().getY());
switch (closest)
{
case EditorParts::SAMPLE_START: return "Adjust sample start";
case EditorParts::SAMPLE_END: return "Adjust sample end";
case EditorParts::LOOP_START: return "Adjust loop start portion";
case EditorParts::LOOP_END: return "Adjust loop release portion";
default:
if (forwardMouseEvents)
{
const auto& helpText = forwardMouseEvents->getCustomHelpText();
if (helpText.isNotEmpty())
return helpText;
}
return "Scroll to navigate";
}
}
//==============================================================================
EditorParts SampleEditorOverlay::getClosestPartInRange(int x, int y) const
{
auto startPos = sampleToPosition(sampleStart);
auto endPos = sampleToPosition(sampleEnd);
juce::Array targets = {
CompPart {EditorParts::SAMPLE_START, juce::Rectangle(startPos + getBoundsWidth() / 2.f, 0.f, 1.f, float(getHeight())), 1},
CompPart {EditorParts::SAMPLE_END, juce::Rectangle(endPos + 3 * getBoundsWidth() / 2.f, 0.f, 1.f, float(getHeight())), 1},
};
if (isLooping->get() && !isWaveformMode())
{
if (loopingHasStart->get())
targets.add(CompPart{ EditorParts::LOOP_START, juce::Rectangle(sampleToPosition(loopStart) + getBoundsWidth() / 2.f, 0.f, 1.f, float(getHeight())), 1 });
if (loopingHasEnd->get())
targets.add(CompPart{ EditorParts::LOOP_END, juce::Rectangle(sampleToPosition(loopEnd) + 3 * getBoundsWidth() / 2.f, 0.f, 1.f, float(getHeight())), 1 });
}
return CompPart<EditorParts>::getClosestInRange(targets, x, y, Feel::DRAGGABLE_SNAP);
}
float SampleEditorOverlay::getBoundsWidth() const
{
return Layout::boundsWidth * getWidth();
}
float SampleEditorOverlay::sampleToPosition(int sampleIndex) const
{
return juce::jmap<float>(float(sampleIndex - viewStart), 0.f, float(viewEnd - viewStart), 0.f, float(getWidth() - 2 * getBoundsWidth()));
}
int SampleEditorOverlay::positionToSample(float position) const
{
return viewStart + int(std::round(juce::jmap<float>(position, 0.f, float(getWidth() - 2 * getBoundsWidth()), 0.f, float(viewEnd - viewStart))));
}
bool SampleEditorOverlay::isWaveformMode() const
{
return CustomSamplerVoice::isWavetableModeAvailable(sampleRate, sampleStart, sampleEnd) && !isWavetableModeDisabled->get();
}
/*
==============================================================================
SampleEditor.cpp
Created: 19 Sep 2023 2:03:29pm
Author: binya
==============================================================================
*/
SampleEditor::SampleEditor(APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray<CustomSamplerVoice>& synthVoices, const std::function<void(const juce::MouseWheelDetails& details, int centerSample)>& navScrollFunc) :
apvts(apvts), pluginState(pluginState), dummyParam(apvts, PluginParameters::State::UI_DUMMY_PARAM),
painter(pluginState.primaryChannel, 0.25f, &dummyParam),
gainAttachment(*apvts.getParameter(PluginParameters::SAMPLE_GAIN), [this](float newValue) { painter.setGain(juce::Decibels::decibelsToGain(newValue)); }, apvts.undoManager),
monoAttachment(*apvts.getParameter(PluginParameters::MONO_OUTPUT), [this](bool newValue) { painter.setMono(newValue); }, apvts.undoManager),
overlay(apvts, pluginState, synthVoices, dummyParam, &painter), scrollFunc(navScrollFunc)
{
pluginState.viewStart.addListener(this);
pluginState.viewEnd.addListener(this);
painter.setGain(juce::Decibels::decibelsToGain(float(apvts.getParameterAsValue(PluginParameters::SAMPLE_GAIN).getValue())));
painter.setMono(bool(apvts.getParameterAsValue(PluginParameters::MONO_OUTPUT).getValue()));
addAndMakeVisible(&painter);
overlay.toFront(true);
addAndMakeVisible(&overlay);
boundsSelector.toFront(true);
addAndMakeVisible(&boundsSelector);
}
SampleEditor::~SampleEditor()
{
pluginState.viewStart.removeListener(this);
pluginState.viewEnd.removeListener(this);
}
//==============================================================================
void SampleEditor::valueChanged(ListenableValue<int>& source, int /*newValue*/)
{
if ((&source == &pluginState.viewStart || &source == &pluginState.viewEnd) && pluginState.viewStart < pluginState.viewEnd)
{
painter.setSampleView(pluginState.viewStart, pluginState.viewEnd);
}
}
//==============================================================================
void SampleEditor::resized()
{
auto bounds = getLocalBounds();
overlay.setBounds(bounds);
bounds.reduce(int(Layout::boundsWidth * bounds.getWidth()), 0);
painter.setBounds(bounds.reduced(0, int(getWidth() * 0.01f)));
boundsSelector.setBounds(bounds);
}
void SampleEditor::enablementChanged()
{
overlay.setEnabled(isEnabled());
painter.setEnabled(isEnabled());
}
void SampleEditor::mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel)
{
if (!sampleBuffer || recordingMode)
return;
int sample = positionToSample(event.position.getX());
scrollFunc(wheel, sample);
}
int SampleEditor::positionToSample(float position) const
{
if (!sampleBuffer)
return 0;
return juce::jlimit<int>(0, sampleBuffer->getNumSamples() - 1, overlay.positionToSample(position - Layout::boundsWidth * getWidth()));
}
//==============================================================================
void SampleEditor::setSample(const juce::AudioBuffer<float>& sample, float bufferSampleRate, bool resetView)
{
sampleBuffer = &sample;
sampleRate = bufferSampleRate;
if (resetView || recordingMode)
painter.setSample(sample);
else
painter.setSample(sample, pluginState.viewStart, pluginState.viewEnd);
overlay.setSample(sample, bufferSampleRate);
}
void SampleEditor::setRecordingMode(bool recording)
{
recordingMode = recording;
overlay.setVisible(!recording);
}
bool SampleEditor::isRecordingMode() const
{
return recordingMode;
}
void SampleEditor::sampleUpdated(int oldSize, int newSize)
{
painter.appendToPath(oldSize, newSize - 1);
}
//==============================================================================
void SampleEditor::promptBoundsSelection(const std::function<void(int, int)>& callback)
{
overlay.setEnabled(false);
boundsSelector.promptRangeSelect([this, callback](int startPos, int endPos) -> void {
return callback(positionToSample(float(startPos)), positionToSample(float(endPos)));
});
}
void SampleEditor::cancelBoundsSelection()
{
overlay.setEnabled(true);
boundsSelector.cancelRangeSelect();
}
bool SampleEditor::isInBoundsSelection() const
{
return boundsSelector.isSelectingRange();
}

View file

@ -0,0 +1,168 @@
/*
==============================================================================
SampleEditorOverlay.h
Created: 19 Sep 2023 2:03:29pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../Sampler/CustomSamplerVoice.h"
#include "../Utilities/ComponentUtils.h"
#include "RangeSelector.h"
#include "Displays/SamplePainter.h"
/** An enum of selectable parts of the editor overlay. */
enum class EditorParts
{
NONE,
SAMPLE_START,
SAMPLE_END,
LOOP_START,
LOOP_END,
};
/** The overlay draws the bounds and active voices, to be placed over the main editor object */
class SampleEditorOverlay final : public CustomComponent, public ValueListener<int>
{
public:
SampleEditorOverlay(const APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray<CustomSamplerVoice>& synthVoices, UIDummyParam& dummy, CustomComponent* forwardEventsTo = nullptr);
~SampleEditorOverlay() override;
void setSample(const juce::AudioBuffer<float>& sample, float bufferSampleRate);
/** Utility functions */
float sampleToPosition(int sampleIndex) const;
int positionToSample(float position) const;
bool isWaveformMode() const;
private:
void valueChanged(ListenableValue<int>& source, int newValue) override;
void paint(juce::Graphics&) override;
void resized() override;
void enablementChanged() override;
void lookAndFeelChanged() override;
void mouseMove(const juce::MouseEvent& event) override;
void mouseDown(const juce::MouseEvent& event) override;
void mouseUp(const juce::MouseEvent& event) override;
void mouseDrag(const juce::MouseEvent& event) override;
void mouseEnter(const juce::MouseEvent&) override { repaint(); };
void mouseExit(const juce::MouseEvent&) override { repaint(); };
juce::String getCustomHelpText() override;
EditorParts getClosestPartInRange(int x, int y) const;
float getBoundsWidth() const;
//==============================================================================
const juce::AudioBuffer<float>* sampleBuffer{ nullptr };
float sampleRate{ 0.f };
const juce::OwnedArray<CustomSamplerVoice>& synthVoices;
UIDummyParam& dummyParam;
ListenableAtomic<int>& viewStart, & viewEnd, & sampleStart, & sampleEnd, & loopStart, & loopEnd;
ListenableAtomic<bool>& pinnedBounds;
juce::AudioParameterBool* isWavetableModeDisabled, * isLooping, * loopingHasStart, * loopingHasEnd;
juce::ParameterAttachment isWavetableModeDisabledAttachment, isLoopingAttachment, loopingHasStartAttachment, loopingHasEndAttachment;
bool dragging{ false };
EditorParts draggingTarget{ EditorParts::NONE };
melatonin::DropShadow boundsShadow{
{defaultTheme.slate.withAlpha(0.25f), 2, {1, 0}},
{defaultTheme.slate.withAlpha(0.25f), 2, {-1, 0}}
};
melatonin::DropShadow loopBoundsShadow{
{defaultTheme.loop.withAlpha(0.25f), 2, {1, 0}},
{defaultTheme.loop.withAlpha(0.25f), 2, {-1, 0}}
};
melatonin::InnerShadow innerShadow{
{defaultTheme.slate.withAlpha(0.25f), 3, {0, 2}},
{defaultTheme.slate.withAlpha(0.25f), 3, {0, -2}}
};
juce::Path handleLeft, handleRight;
/** The overlay can be provided with a component to forward unhandled mouse events to.
This currently only forwards particular mouse events, but can be expanded if we need.
*/
CustomComponent* forwardMouseEvents{ nullptr };
};
/*
==============================================================================
SampleEditor.h
Created: 19 Sep 2023 2:03:29pm
Author: binya
==============================================================================
*/
/** The SampleEditor is the main component responsible for editing a sample. It allows for setting
the bounds for playback and looping. It also allows for bounds selection for operations that need it.
It reacts to viewport changes from the sample navigator. Like the navigator, it also displays active voices.
*/
class SampleEditor final : public CustomComponent, public ValueListener<int>
{
public:
/** The SampleEditor requires reference to the apvts, pluginState, synthVoices, and a
function reference to scroll the navigator (navigator.scrollView).
*/
SampleEditor(APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray<CustomSamplerVoice>& synthVoices,
const std::function<void(const juce::MouseWheelDetails& details, int centerSample)>& navScrollFunc);
~SampleEditor() override;
//==============================================================================
void setSample(const juce::AudioBuffer<float>& sample, float bufferSampleRate, bool resetView);
/** Recording mode hides the bounds selection and turns the editor into a view only
display while a recording is in progress.
*/
void setRecordingMode(bool recording);
bool isRecordingMode() const;
void sampleUpdated(int oldSize, int newSize); // Currently used for recording
//==============================================================================
/** Prompts the user to select a range of samples within the current viewport. */
void promptBoundsSelection(const std::function<void(int startSample, int endSample)>& callback);
void cancelBoundsSelection();
bool isInBoundsSelection() const;
private:
void valueChanged(ListenableValue<int>& source, int newValue) override;
void resized() override;
void enablementChanged() override;
void mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) override;
int positionToSample(float position) const;
//==============================================================================
APVTS& apvts;
PluginParameters::State& pluginState;
UIDummyParam dummyParam;
const juce::AudioBuffer<float>* sampleBuffer{ nullptr };
float sampleRate{ 0.f };
SamplePainter painter;
juce::ParameterAttachment gainAttachment;
juce::ParameterAttachment monoAttachment;
SampleEditorOverlay overlay;
RangeSelector boundsSelector;
bool recordingMode{ false };
std::function<void(const juce::MouseWheelDetails& details, int centerSample)> scrollFunc;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SampleEditor)
};

View file

@ -0,0 +1,525 @@
/*
==============================================================================
SampleNavigator.cpp
Created: 19 Sep 2023 4:41:12pm
Author: binya
==============================================================================
*/
#include <JuceHeader.h>
#include "SampleNavigator.h"
SampleNavigator::SampleNavigator(APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray<CustomSamplerVoice>& synthVoices) :
apvts(apvts), state(pluginState), dummyParam(apvts, PluginParameters::State::UI_DUMMY_PARAM),
painter(pluginState.primaryChannel, 0.2f),
gainAttachment(*apvts.getParameter(PluginParameters::SAMPLE_GAIN), [this](float newValue) { painter.setGain(juce::Decibels::decibelsToGain(newValue)); }, apvts.undoManager),
monoAttachment(*apvts.getParameter(PluginParameters::MONO_OUTPUT), [this](bool newValue) { painter.setMono(newValue); }, apvts.undoManager),
synthVoices(synthVoices),
isWavetableModeDisabled(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::DISABLE_WAVETABLE_MODE))),
isLooping(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::IS_LOOPING))),
loopHasStart(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::LOOPING_HAS_START))),
loopHasEnd(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::LOOPING_HAS_END))),
isWavetableModeDisabledAttachment(*this->isWavetableModeDisabled, [this](bool) { repaint(); }, apvts.undoManager),
loopAttachment(*isLooping, [this](bool newValue) { loopHasStartUpdate(newValue && loopHasStart->get()); loopHasEndUpdate(newValue && loopHasEnd->get()); }, apvts.undoManager),
loopStartAttachment(*loopHasStart, [this](bool newValue) { loopHasStartUpdate(newValue); }, apvts.undoManager),
loopEndAttachment(*loopHasEnd, [this](bool newValue) { loopHasEndUpdate(newValue); }, apvts.undoManager)
{
state.viewStart.addListener(this);
state.viewEnd.addListener(this);
state.sampleStart.addListener(this);
state.sampleEnd.addListener(this);
state.loopStart.addListener(this);
state.loopEnd.addListener(this);
state.pinView.addListener(this);
updatePinnedPositions();
painter.setGain(juce::Decibels::decibelsToGain(float(apvts.getParameterAsValue(PluginParameters::SAMPLE_GAIN).getValue())));
addAndMakeVisible(&painter);
painter.setInterceptsMouseClicks(false, false);
}
SampleNavigator::~SampleNavigator()
{
state.viewStart.removeListener(this);
state.viewEnd.removeListener(this);
state.sampleStart.removeListener(this);
state.sampleEnd.removeListener(this);
state.loopStart.removeListener(this);
state.loopEnd.removeListener(this);
state.pinView.removeListener(this);
}
void SampleNavigator::valueChanged(ListenableValue<int>& source, int /*newValue*/)
{
auto& sourceA = dynamic_cast<ListenableAtomic<int>&>(source);
if (!navigatorUpdate && (sourceA == state.loopStart || sourceA == state.loopEnd || sourceA == state.sampleStart || sourceA == state.sampleEnd))
updatePinnedPositions();
safeRepaint();
}
void SampleNavigator::valueChanged(ListenableValue<bool>& source, bool newValue)
{
// When the pin is enabled, we move the bounds to within the viewport
updatePinnedPositions();
bool loopingHasStart = isLooping->get() && loopHasStart->get() && !isWaveformMode();
bool loopingHasEnd = isLooping->get() && loopHasEnd->get() && !isWaveformMode();
if (state.pinView && dynamic_cast<ListenableAtomic<bool>&>(source) == state.pinView && newValue &&
((loopingHasStart && state.loopStart < state.viewStart) || (loopingHasEnd && state.loopEnd > state.viewEnd) ||
state.sampleStart < state.viewStart || state.sampleEnd > state.viewEnd))
{
int currentViewStart = state.viewStart;
int currentViewEnd = state.viewEnd;
int newViewStart = loopingHasStart ? state.loopStart.load() : state.sampleStart.load();
int newViewEnd = loopingHasEnd ? state.loopEnd.load() : state.sampleEnd.load();
state.viewStart = newViewStart;
state.viewEnd = newViewEnd;
updatePinnedPositions();
state.viewStart = currentViewStart;
state.viewEnd = currentViewEnd;
moveBoundsToPinnedPositions(newViewStart > currentViewStart);
}
}
//==============================================================================
void SampleNavigator::setSample(const juce::AudioBuffer<float>& sampleBuffer, float bufferSampleRate, bool resetView)
{
painter.setSample(sampleBuffer);
sample = &sampleBuffer;
sampleRate = bufferSampleRate;
if (resetView || recordingMode)
{
state.viewStart = 0;
state.viewEnd = sampleBuffer.getNumSamples() - 1;
updatePinnedPositions();
}
repaint();
}
void SampleNavigator::sampleUpdated(int oldSize, int newSize)
{
painter.appendToPath(oldSize, newSize - 1);
}
void SampleNavigator::setRecordingMode(bool recording)
{
recordingMode = recording;
}
//==============================================================================
void SampleNavigator::paintOverChildren(juce::Graphics& g)
{
if (!sample || !sample->getNumSamples())
return;
using namespace juce;
auto colors = getTheme();
// Paints the voice positions
if (!recordingMode && !isWaveformMode())
{
for (auto& voice : synthVoices)
{
if (voice->isPlaying())
{
int location = int(std::ceil(voice->getPosition()));
auto pos = sampleToPosition(location);
Path voicePath{};
voicePath.addLineSegment(Line<float>(pos, 0.f, pos, float(getHeight())), 1.f);
g.setColour(colors.light.withAlpha(voice->getEnvelopeGain()));
g.strokePath(voicePath, PathStrokeType(Layout::playheadWidth * getWidth()));
}
}
}
float boundsThickness = getWidth() * Layout::navigatorBoundsWidth * 0.66f;
float sampleStartPos = sampleToPosition(state.sampleStart);
float sampleEndPos = sampleToPosition(state.sampleEnd);
float loopStartPos = sampleToPosition(state.loopStart);
float loopEndPos = sampleToPosition(state.loopEnd);
auto boundColor = isWaveformMode() ? colors.highlight : isLooping->get() ? colors.loop : colors.slate;
g.setColour(disabled(boundColor));
g.fillRect(sampleStartPos - boundsThickness, getHeight() * 0.1f, boundsThickness, getHeight() * 0.8f);
g.setColour(disabled(boundColor));
g.fillRect(sampleEndPos, getHeight() * 0.1f, boundsThickness, getHeight() * 0.8f);
g.setColour(disabled(colors.slate));
if (isLooping->get() && loopHasStart->get() && !isWaveformMode())
g.fillRect(loopStartPos - boundsThickness, getHeight() * 0.1f, boundsThickness, getHeight() * 0.8f);
if (isLooping->get() && loopHasEnd->get() && !isWaveformMode())
g.fillRect(loopEndPos, getHeight() * 0.1f, boundsThickness, getHeight() * 0.8f);
// Paints the start and stop
float startPos = sampleToPosition(recordingMode ? 0 : int(state.viewStart));
float stopPos = sampleToPosition(recordingMode ? sample->getNumSamples() - 1 : int(state.viewEnd));
g.setColour(disabled(colors.slate.withAlpha(0.15f)));
g.fillRect(startPos, 0.f, stopPos - startPos + 1.f, float(getHeight()));
float lineThickness = getWidth() * Layout::navigatorBoundsWidth;
g.setColour(disabled(colors.slate));
g.fillRect(startPos - lineThickness, 0.f, lineThickness, float(getHeight()));
g.fillRect(stopPos, 0.f, lineThickness, float(getHeight()));
}
void SampleNavigator::resized()
{
auto bounds = getLocalBounds().toFloat();
float lineThickness = bounds.getWidth() * Layout::navigatorBoundsWidth;
bounds.reduce(lineThickness, 0.f);
painter.setBounds(bounds.toNearestInt());
}
void SampleNavigator::lookAndFeelChanged()
{
auto colors = getTheme();
painter.setColour(Colors::painterColorId, colors.darkerSlate);
}
void SampleNavigator::enablementChanged()
{
painter.setEnabled(isEnabled());
painter.repaint();
}
//==============================================================================
void SampleNavigator::mouseDown(const juce::MouseEvent& event)
{
if (!sample || !isEnabled() || recordingMode)
return;
draggingTarget = getDraggingTarget(event.getMouseDownX(), event.getMouseDownY());
if (draggingTarget == Drag::SAMPLE_FULL)
dragSelectOffset = event.getMouseDownX() - sampleToPosition(state.viewStart);
dragging = draggingTarget != Drag::NONE;
lastDragOffset = 0;
if (draggingTarget == Drag::SAMPLE_START || draggingTarget == Drag::SAMPLE_END || draggingTarget == Drag::SAMPLE_FULL)
juce::Desktop::getInstance().getMainMouseSource().enableUnboundedMouseMovement(true, false);
repaint();
}
void SampleNavigator::mouseUp(const juce::MouseEvent&)
{
if (!sample || recordingMode || !dragging)
return;
if (dragging)
dummyParam.sendUIUpdate();
dragging = false;
juce::Desktop::getInstance().getMainMouseSource().enableUnboundedMouseMovement(false);
auto screenPos = getScreenBounds().toFloat();
juce::Point<float> newMousePos;
if (draggingTarget == Drag::SAMPLE_START)
newMousePos = juce::Point(screenPos.getX() + sampleToPosition(state.viewStart) * screenPos.getWidth() / getWidth(), screenPos.getCentreY());
else if (draggingTarget == Drag::SAMPLE_END)
newMousePos = juce::Point(screenPos.getX() + sampleToPosition(state.viewEnd) * screenPos.getWidth() / getWidth(), screenPos.getCentreY());
else if (draggingTarget == Drag::SAMPLE_FULL)
newMousePos = juce::Point(screenPos.getX() + (sampleToPosition(state.viewStart) + dragSelectOffset) * screenPos.getWidth() / getWidth(), screenPos.getCentreY());
juce::Desktop::getInstance().getMainMouseSource().setScreenPosition(newMousePos);
repaint();
}
void SampleNavigator::mouseMove(const juce::MouseEvent& event)
{
if (!sample || !isEnabled() || recordingMode)
{
setMouseCursor(juce::MouseCursor::NormalCursor);
return;
}
if (dragging) // Don't change while dragging
return;
Drag currentTarget = getDraggingTarget(event.getMouseDownX(), event.getMouseDownY());
switch (currentTarget)
{
case Drag::SAMPLE_START:
case Drag::SAMPLE_END:
setMouseCursor(juce::MouseCursor::LeftRightResizeCursor);
break;
case Drag::SAMPLE_FULL:
setMouseCursor(juce::MouseCursor::DraggingHandCursor);
break;
case Drag::NONE:
setMouseCursor(juce::MouseCursor::NormalCursor);
break;
}
}
void SampleNavigator::mouseDrag(const juce::MouseEvent& event)
{
if (!sample || !dragging || !isEnabled() || recordingMode)
return;
// The goal is to keep the positions within their normal constraints
bool secondary = juce::ModifierKeys::currentModifiers.isAnyModifierKeyDown();
float edgeSensitivity = getDragSensitivity(secondary);
float fullSensitivity = getDragSensitivity(!secondary);
switch (draggingTarget)
{
case Drag::SAMPLE_START:
{
int difference = event.getOffsetFromDragStart().getX() - lastDragOffset;
moveStart(difference / 2.f, edgeSensitivity);
moveEnd(-difference / 2.f, edgeSensitivity);
break;
}
case Drag::SAMPLE_END:
{
int difference = event.getOffsetFromDragStart().getX() - lastDragOffset;
moveEnd(difference / 2.f, edgeSensitivity);
moveStart(-difference / 2.f, edgeSensitivity);
break;
}
case Drag::SAMPLE_FULL:
{
int change = event.getOffsetFromDragStart().getX() - lastDragOffset;
moveBoth(float(change), fullSensitivity);
break;
}
case Drag::NONE:
break;
}
lastDragOffset = event.getOffsetFromDragStart().getX();
}
void SampleNavigator::mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel)
{
if (!sample || dragging || !isEnabled() || recordingMode)
return;
int sampleCenter = positionToSample(event.position.getX());
scrollView(wheel, sampleCenter);
}
void SampleNavigator::mouseDoubleClick(const juce::MouseEvent&)
{
if (!sample || recordingMode)
return;
state.viewStart = 0;
state.viewEnd = sample->getNumSamples() - 1;
}
void SampleNavigator::scrollView(const juce::MouseWheelDetails& wheel, int sampleCenter, bool centerZoomOut)
{
if (!sample)
return;
float changeY = wheel.deltaY * Feel::MOUSE_SENSITIVITY;
float changeX = wheel.deltaX * Feel::MOUSE_SENSITIVITY;
// MacOS sensitivity is a little different
if (wheel.isSmooth && ((juce::SystemStats::getOperatingSystemType() & juce::SystemStats::MacOSX) == 0))
changeY *= 0.75f;
bool trackHorizontal = std::abs(wheel.deltaX) > std::abs(wheel.deltaY);
bool modifier = juce::ModifierKeys::currentModifiers.isAnyModifierKeyDown();
if (modifier || trackHorizontal)
{
float sensitivity = getDragSensitivity(false);
moveBoth(trackHorizontal ? changeX : changeY, sensitivity);
}
else if (!modifier)
{
float sensitivity = getDragSensitivity(false);
float startRatio = 1.f;
float endRatio = 1.f;
if (sampleCenter >= state.viewStart && sampleCenter <= state.viewEnd &&
((centerZoomOut && state.viewStart > 0 && state.viewEnd < sample->getNumSamples() - 1) || changeY > 0))
{
endRatio = float(state.viewEnd - sampleCenter) / (state.viewEnd - state.viewStart);
startRatio = 1.f - endRatio;
}
// The order must change depending on the direction of the wheel, since both maintain constraints and there's a minimum view size
if (changeY > 0)
{
moveEnd(-changeY * endRatio, sensitivity);
moveStart(changeY * startRatio, sensitivity);
}
else
{
moveStart(changeY * startRatio, sensitivity);
moveEnd(-changeY * endRatio, sensitivity);
}
}
}
void SampleNavigator::fitView()
{
if (!sample)
return;
int viewStart = isLooping->get() && loopHasStart->get() && !isWaveformMode() ? state.loopStart.load() : state.sampleStart.load();
int viewEnd = isLooping->get() && loopHasEnd->get() && !isWaveformMode() ? state.loopEnd.load() : state.sampleEnd.load();
// Keep the view size larger than the minimum
if (viewEnd - viewStart + 1 < Feel::MINIMUM_VIEW)
{
viewEnd = juce::jmin<int>(sample->getNumSamples() - 1, viewStart + Feel::MINIMUM_VIEW - 1);
viewStart = juce::jmax<int>(0, viewEnd - Feel::MINIMUM_VIEW + 1);
}
state.viewStart = viewStart;
state.viewEnd = viewEnd;
updatePinnedPositions();
repaint();
}
void SampleNavigator::moveStart(float change, float sensitivity)
{
int oldViewStart = state.viewStart;
auto viewStart = juce::jmax<int>(juce::jmin<int>(int(std::round(state.viewStart + change * sensitivity)), state.viewEnd - Feel::MINIMUM_VIEW), 0);
state.viewStart = viewStart;
if (state.pinView)
moveBoundsToPinnedPositions(viewStart < oldViewStart);
}
void SampleNavigator::moveEnd(float change, float sensitivity)
{
int oldViewEnd = state.viewEnd;
auto viewEnd = juce::jmin<int>(juce::jmax<int>(int(std::round(state.viewEnd + change * sensitivity)), state.viewStart + Feel::MINIMUM_VIEW), sample->getNumSamples() - 1);
state.viewEnd = viewEnd;
if (state.pinView)
moveBoundsToPinnedPositions(viewEnd < oldViewEnd);
}
void SampleNavigator::moveBoth(float change, float sensitivity)
{
int difference = juce::jlimit<int>(-state.viewStart, sample->getNumSamples() - 1 - state.viewEnd, int(change * sensitivity));
state.viewStart = state.viewStart + difference;
state.viewEnd = state.viewEnd + difference;
if (state.pinView)
moveBoundsToPinnedPositions(difference < 0);
}
//==============================================================================
NavigatorParts SampleNavigator::getDraggingTarget(int x, int y) const
{
auto bounds = getLocalBounds().toFloat();
auto startPos = sampleToPosition(state.viewStart);
auto stopPos = sampleToPosition(state.viewEnd);
// A little trick to make the full sample always possible to drag
auto offset = juce::jmax<float>(30 - (stopPos - startPos), 0) / 2;
juce::Array targets = {
CompPart {Drag::SAMPLE_START, juce::Rectangle<float>{startPos - offset, bounds.getY(), 0, bounds.getHeight()}, 2},
CompPart {Drag::SAMPLE_END, juce::Rectangle<float>{stopPos + offset, bounds.getY(), 0, bounds.getHeight()}, 2},
CompPart {Drag::SAMPLE_FULL, juce::Rectangle<float>{startPos, bounds.getY(), stopPos - startPos, bounds.getHeight()}, 1}
};
return CompPart<Drag>::getClosestInRange(targets, x, y, Feel::DRAGGABLE_SNAP);
}
float SampleNavigator::sampleToPosition(int sampleIndex) const
{
float boundsWidth = getWidth() * Layout::navigatorBoundsWidth;
return juce::jmap<float>(float(sampleIndex), 0.f, float(sample->getNumSamples() - 1), 0.f, float(painter.getWidth())) + boundsWidth;
}
int SampleNavigator::positionToSample(float position) const
{
return int(juce::jmap<float>(position - getWidth() * Layout::navigatorBoundsWidth, 0.f, float(painter.getWidth()), 0.f, float(sample->getNumSamples())));
}
float SampleNavigator::getDragSensitivity(bool constant) const
{
int viewSize = state.viewEnd - state.viewStart + 1;
if (constant)
return float(sample->getNumSamples()) / getWidth();
else
return -std::log(float(viewSize) / sample->getNumSamples() / juce::MathConstants<float>::euler) * viewSize / getWidth();
}
void SampleNavigator::loopHasStartUpdate(bool newValue)
{
repaint();
if (!bool(newValue))
return;
if (state.viewStart > state.loopStart && (state.loopStart == 0 || state.pinView))
state.loopStart = state.viewStart.load();
}
void SampleNavigator::loopHasEndUpdate(bool newValue)
{
repaint();
if (!bool(newValue))
return;
if (state.viewEnd < state.loopEnd && (state.loopEnd == sample->getNumSamples() - 1 || state.pinView))
state.loopEnd = state.viewEnd.load();
}
void SampleNavigator::updatePinnedPositions()
{
int viewStart = state.viewStart;
int viewEnd = state.viewEnd;
pinnedSampleStart = juce::jlimit<float>(0.f, 1.f, float(state.sampleStart - viewStart) / (viewEnd - viewStart));
pinnedSampleEnd = juce::jlimit<float>(0.f, 1.f, float(state.sampleEnd - viewStart) / (viewEnd - viewStart));
pinnedLoopStart = juce::jlimit<float>(0.f, 1.f, float(state.loopStart - viewStart) / (viewEnd - viewStart));
pinnedLoopEnd = juce::jlimit<float>(0.f, 1.f, float(state.loopEnd - viewStart) / (viewEnd - viewStart));
}
void SampleNavigator::moveBoundsToPinnedPositions(bool startToEnd)
{
// To maintain constraints, we change the bounds in order depending on the direction of the change
navigatorUpdate = true;
if (startToEnd)
{
state.loopStart = juce::jmax(int(std::round(pinnedLoopStart * (state.viewEnd - state.viewStart))) + state.viewStart, 0);
state.sampleStart = int(std::round(pinnedSampleStart * (state.viewEnd - state.viewStart))) + state.viewStart;
state.sampleEnd = int(std::round(pinnedSampleEnd * (state.viewEnd - state.viewStart))) + state.viewStart;
state.loopEnd = juce::jmin(int(std::round(pinnedLoopEnd * (state.viewEnd - state.viewStart))) + state.viewStart, sample->getNumSamples() - 1);
}
else
{
state.loopEnd = juce::jmin(int(std::round(pinnedLoopEnd * (state.viewEnd - state.viewStart))) + state.viewStart, sample->getNumSamples() - 1);
state.sampleEnd = int(std::round(pinnedSampleEnd * (state.viewEnd - state.viewStart))) + state.viewStart;
state.sampleStart = int(std::round(pinnedSampleStart * (state.viewEnd - state.viewStart))) + state.viewStart;
state.loopStart = juce::jmax(int(std::round(pinnedLoopStart * (state.viewEnd - state.viewStart))) + state.viewStart, 0);
}
navigatorUpdate = false;
}
bool SampleNavigator::isWaveformMode() const
{
return CustomSamplerVoice::isWavetableModeAvailable(sampleRate, state.sampleStart, state.sampleEnd) && !isWavetableModeDisabled->get();
}

View file

@ -0,0 +1,118 @@
/*
==============================================================================
SampleNavigator.h
Created: 19 Sep 2023 4:41:12pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../Sampler/CustomSamplerVoice.h"
#include "../Utilities/ComponentUtils.h"
#include "Displays/SamplePainter.h"
enum class NavigatorParts
{
NONE,
SAMPLE_START,
SAMPLE_END,
SAMPLE_FULL
};
/** A navigator control for the viewing window of the sample editor */
class SampleNavigator final : public CustomComponent, public ValueListener<int>, public ValueListener<bool>
{
using Drag = NavigatorParts;
public:
SampleNavigator(APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray<CustomSamplerVoice>& synthVoices);
~SampleNavigator() override;
//==============================================================================
void setSample(const juce::AudioBuffer<float>& sampleBuffer, float bufferSampleRate, bool resetView);
/** Call this while recording when more samples have been filled in the sampleBuffer */
void sampleUpdated(int oldSize, int newSize);
void setRecordingMode(bool recording);
/** Scrolling can be centered on a sample */
void scrollView(const juce::MouseWheelDetails& wheel, int sampleCenter, bool centerZoomOut = false);
/** Set the view to fit the sample play bounds */
void fitView();
private:
/** React to view changes */
void valueChanged(ListenableValue<int>& source, int newValue) override;
void valueChanged(ListenableValue<bool>& source, bool newValue) override;
void paintOverChildren(juce::Graphics& g) override;
void resized() override;
void lookAndFeelChanged() override;
void enablementChanged() override;
//==============================================================================
void mouseDown(const juce::MouseEvent& event) override;
void mouseUp(const juce::MouseEvent& event) override;
void mouseMove(const juce::MouseEvent& event) override;
void mouseDrag(const juce::MouseEvent& event) override;
void mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) override;
void mouseDoubleClick(const juce::MouseEvent& event) override;
/** Move the view start, maintaining constraints */
void moveStart(float change, float sensitivity);
void moveEnd(float change, float sensitivity);
void moveBoth(float change, float sensitivity);
NavigatorParts getDraggingTarget(int x, int y) const;
/** Relative to the bounds of the painter */
float sampleToPosition(int sampleIndex) const;
int positionToSample(float position) const;
/** constant sensitivity means that the drag distance is the same regardless of zoom level */
float getDragSensitivity(bool constant) const;
/** In some cases we'd like to adjust the loop start/end positions when they are enabled */
void loopHasStartUpdate(bool newValue);
void loopHasEndUpdate(bool newValue);
/** Update the stored position ratios, for use when pinned */
void updatePinnedPositions();
void moveBoundsToPinnedPositions(bool startToEnd);
bool isWaveformMode() const;
//==============================================================================
APVTS& apvts;
PluginParameters::State& state;
UIDummyParam dummyParam;
SamplePainter painter;
juce::ParameterAttachment gainAttachment;
juce::ParameterAttachment monoAttachment;
const juce::AudioBuffer<float>* sample{ nullptr };
float sampleRate;
const juce::OwnedArray<CustomSamplerVoice>& synthVoices;
juce::AudioParameterBool* isWavetableModeDisabled, * isLooping, * loopHasStart, * loopHasEnd;
juce::ParameterAttachment isWavetableModeDisabledAttachment, loopAttachment, loopStartAttachment, loopEndAttachment;
bool dragging{ false };
NavigatorParts draggingTarget{ NavigatorParts::NONE };
float dragSelectOffset{ 0 }; // When dragging full, this is the offset from the view start where the drag started
int lastDragOffset{ 0 };
bool recordingMode{ false };
// To keep the bounds in a consistent location when pinned, we need to store the ratios (necessary because zooming in more
// causes rounding issues and the position moves drastically).
long double pinnedSampleStart{ 0.f }, pinnedSampleEnd{ 0.f }, pinnedLoopStart{ 0.f }, pinnedLoopEnd{ 0.f };
bool navigatorUpdate{ false }; // This flag allows the navigator to avoid updating the pinned positions when they are being used to update the bounds
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SampleNavigator)
};

View file

@ -0,0 +1,563 @@
/*
==============================================================================
LookAndFeel.cpp
Created: 19 Sep 2023 4:48:33pm
Author: binya
==============================================================================
*/
#include "CustomLookAndFeel.h"
#include "Components/Buttons.h"
#include "Sampler/CustomSamplerVoice.h"
CustomLookAndFeel::CustomLookAndFeel()
{
setTheme(defaultTheme);
setUsingNativeAlertWindows(true);
}
void CustomLookAndFeel::setTheme(const Colors& theme)
{
colors = theme;
setColour(juce::Label::textColourId, colors.dark);
setColour(juce::Label::textWhenEditingColourId, colors.dark);
setColour(juce::Label::outlineWhenEditingColourId, juce::Colours::transparentWhite);
setColour(juce::TextEditor::highlightColourId, colors.slate.withAlpha(0.15f));
setColour(juce::TextEditor::highlightedTextColourId, colors.dark);
setColour(juce::CaretComponent::caretColourId, colors.dark);
setColour(juce::ComboBox::textColourId, colors.dark);
setColour(juce::Slider::thumbColourId, colors.highlight);
setColour(juce::ResizableWindow::backgroundColourId, colors.foreground);
setColour(juce::ListBox::backgroundColourId, colors.foreground.withAlpha(0.f));
setColour(juce::ListBox::outlineColourId, colors.dark.withAlpha(0.f));
setColour(juce::ListBox::textColourId, colors.dark);
setColour(juce::ScrollBar::thumbColourId, colors.dark);
setColour(Colors::backgroundColorId, colors.background);
setColour(Colors::painterColorId, colors.dark);
}
juce::Slider::SliderLayout CustomLookAndFeel::getSliderLayout(juce::Slider& slider)
{
auto bounds = slider.getLocalBounds().toFloat();
bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding);
float textSize = bounds.getWidth() * Layout::rotaryTextSize;
juce::Slider::SliderLayout layout;
layout.sliderBounds = slider.getLocalBounds();
if (slider.getTextBoxPosition() != juce::Slider::NoTextBox)
layout.textBoxBounds = juce::Rectangle(bounds.getX(), (bounds.getHeight() - textSize / 3.5f) / 2.f, bounds.getWidth(), textSize).reduced(bounds.getWidth() * 0.1f, 0.f).getLargestIntegerWithin();
return layout;
}
juce::Label* CustomLookAndFeel::createSliderTextBox(juce::Slider& slider)
{
using namespace juce;
auto bounds = slider.getLocalBounds().toFloat();
bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding);
auto label = new CustomLabel();
label->setColour(TextEditor::highlightColourId, Colours::transparentWhite);
label->setJustificationType(Justification::centredBottom);
label->setFont(getInriaSansBold().withHeight(bounds.getWidth() * Layout::rotaryTextSize));
label->setHasFocusOutline(false);
label->setKeyboardType(TextInputTarget::decimalKeyboard);
label->setEditable(false, true, false);
label->setEnabledMouseCursor(MouseCursor::IBeamCursor);
label->onEditorShow = [label]
{
auto editor = label->getCurrentTextEditor();
editor->setJustification(Justification::centredBottom);
editor->setIndents(0, 0);
editor->moveCaretToEnd();
editor->selectAll();
// Some compensation to keep the text in a constant position
auto editorBounds = editor->getBounds().toFloat();
float offsetAmount = editorBounds.getHeight() * 0.125f;
editorBounds.setY(editorBounds.getY() - offsetAmount);
editorBounds.translate(offsetAmount, 0);
editorBounds.expand(0, offsetAmount);
editor->setBounds(editorBounds.toNearestInt());
};
return label;
}
void CustomLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPosProportional, float, float, juce::Slider& slider)
{
using namespace juce;
auto bounds = Rectangle(x, y, width, height).toFloat();
bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding / (1 + Layout::rotaryPadding));
bounds.setHeight(bounds.getWidth() * Layout::rotaryHeightRatio);
auto radius = bounds.getWidth() / 2.f;
auto lineWidth = radius * 0.2f;
constexpr float rotaryStartAngle = MathConstants<float>::pi * (1 + 5.f / 26.f);
constexpr float rotaryEndAngle = rotaryStartAngle + MathConstants<float>::pi * (2.f - 5.f / 13.f);
auto toAngle = rotaryStartAngle + sliderPosProportional * (rotaryEndAngle - rotaryStartAngle);
// Draw the background arc
const float lineOffset = lineWidth * 0.5f;
Path backgroundArc;
backgroundArc.addCentredArc(bounds.getCentreX(), bounds.getY() + radius + lineOffset / 2.f, radius - lineOffset, radius - lineOffset / 2.f,
0.0f, rotaryStartAngle, rotaryEndAngle, true);
g.setColour(colors.dark);
g.strokePath(backgroundArc, PathStrokeType(lineWidth));
// Draw the value arc
float baseAngle = rotaryStartAngle;
if (approximatelyEqual(slider.getRange().getStart(), -slider.getRange().getEnd()))
baseAngle = MathConstants<float>::twoPi;
Path valueArc;
valueArc.addCentredArc(bounds.getCentreX(), bounds.getY() + radius + lineOffset / 2.f, radius - lineOffset, radius - lineOffset / 2.f,
0.0f, baseAngle, toAngle, true);
g.setColour(colors.highlight);
g.strokePath(valueArc, PathStrokeType(lineWidth));
// Draw the unit label
g.setColour(colors.dark);
if (slider.getProperties().contains(ComponentProps::ROTARY_ICON))
{
auto iconPath = dynamic_cast<ReferenceCountedPath*>(slider.getProperties()[ComponentProps::ROTARY_ICON].getObject())->path;
g.fillPath(iconPath, iconPath.getTransformToScaleToFit(Rectangle{ bounds.getX() + 0.025f * bounds.getWidth(), bounds.getY() + 0.78f * bounds.getHeight(), bounds.getWidth(), 12.f * bounds.getWidth() / Layout::standardRotarySize}, true));
}
else if (slider.getTextBoxPosition() != Slider::NoTextBox)
{
var unit = slider.getProperties().getWithDefault(ComponentProps::ROTARY_UNIT, "");
if (slider.getValue() >= 1000)
unit = slider.getProperties().getWithDefault(ComponentProps::ROTARY_GREATER_UNIT, unit);
g.setFont(getInriaSansBold().withHeight(20.f * bounds.getWidth() / Layout::standardRotarySize));
g.drawText(unit, Rectangle{ bounds.getX(), bounds.getY() + 0.78f * bounds.getHeight(), bounds.getWidth(), g.getCurrentFont().getAscent() }, Justification::centredBottom);
}
// Draw % if there is no textbox
if (slider.getTextBoxPosition() == Slider::NoTextBox)
{
g.setFont(getInriaSansBold().withHeight(0.68f * bounds.getWidth()));
bounds.removeFromTop(bounds.getHeight() * 0.08f);
g.drawText("%", bounds, Justification::centred);
}
if (!slider.isEnabled())
{
g.setColour(slider.findColour(colors.backgroundColorId, true).withAlpha(0.5f));
g.fillRect(slider.getLocalBounds());
}
}
void CustomLookAndFeel::drawLabel(juce::Graphics& g, juce::Label& label)
{
using namespace juce;
if (!label.isBeingEdited())
{
auto textArea = label.getLocalBounds();
// Draw the background (if the colors are set)
float cornerSize = textArea.getHeight() * 0.2f;
float borderSize = textArea.getHeight() * 0.05f;
g.setColour(label.findColour(Label::backgroundColourId));
g.fillRoundedRectangle(textArea.toFloat().reduced(borderSize * 0.33f), cornerSize);
g.setColour(label.findColour(Label::outlineColourId));
g.drawRoundedRectangle(textArea.toFloat().reduced(borderSize * 0.48f), cornerSize, borderSize);
// Draw the text
const Font font(getLabelFont(label));
g.setColour(label.findColour(Label::textColourId).withMultipliedAlpha(label.isEnabled() ? 1.f : 0.5f));
g.setFont(font);
g.drawText(label.getText(), textArea, label.getJustificationType(), label.getProperties().contains(ComponentProps::LABEL_ELLIPSES));
}
}
juce::Button* CustomLookAndFeel::createFilenameComponentBrowseButton(const juce::String& /*text*/)
{
return new CustomShapeButton(colors.dark, getOutlineFromSVG(BinaryData::IconAdd_svg));
}
void CustomLookAndFeel::layoutFilenameComponent(juce::FilenameComponent& filenameComp, juce::ComboBox* filenameBox, juce::Button* browseButton)
{
if (browseButton == nullptr || filenameBox == nullptr)
return;
auto compBounds = filenameComp.getLocalBounds().toFloat();
auto addButtonBounds = compBounds.removeFromRight(filenameComp.getHeight() * 0.945f).reduced(0.158f * compBounds.getHeight(), 0.18f * compBounds.getHeight());
browseButton->setBounds(addButtonBounds.toNearestInt());
filenameBox->setBounds(compBounds.toNearestInt());
}
juce::Font CustomLookAndFeel::getComboBoxFont(juce::ComboBox& comboBox)
{
return getInter().withHeight(comboBox.getHeight() * 0.73f);
}
juce::Label* CustomLookAndFeel::createComboBoxTextBox(juce::ComboBox& comboBox)
{
using namespace juce;
auto bounds = comboBox.getLocalBounds().toFloat();
auto label = new CustomLabel();
label->setFont(getInter().withHeight(bounds.getWidth() * Layout::rotaryTextSize));
label->setJustificationType(Justification::centredLeft);
label->setHasFocusOutline(false);
label->setKeyboardType(TextInputTarget::decimalKeyboard);
label->setEditable(false, true, false);
label->getProperties().set(ComponentProps::LABEL_ELLIPSES, true);
label->setEnabledMouseCursor(MouseCursor::IBeamCursor);
label->onEditorShow = [label]
{
auto editor = label->getCurrentTextEditor();
editor->setJustification(Justification::centredLeft);
editor->setIndents(0, 0);
editor->moveCaretToEnd();
// Some compensation to keep the text in a constant position
auto editorBounds = editor->getBounds().toFloat();
float offset = editorBounds.getHeight() * 0.125f;
editorBounds.translate(-1, 0);
editorBounds.expand(0, offset);
editor->setBounds(editorBounds.toNearestInt());
};
return label;
}
void CustomLookAndFeel::positionComboBoxText(juce::ComboBox& box, juce::Label& label)
{
auto bounds = box.getLocalBounds().toFloat();
bounds.removeFromRight(Layout::expandWidth * box.getHeight());
label.setBounds(bounds.toNearestInt());
label.setFont(getComboBoxFont(box));
}
void CustomLookAndFeel::drawComboBox(juce::Graphics& g, int /*width*/, int /*height*/, bool isButtonDown, int /*buttonX*/,
int /*buttonY*/, int /*buttonW*/, int /*buttonH*/, juce::ComboBox& box)
{
using namespace juce;
auto bounds = box.getLocalBounds().toFloat();
auto expandBounds = bounds.removeFromRight(Layout::expandWidth * bounds.getHeight()).reduced(0.272f * bounds.getHeight(), 0.321f * bounds.getHeight());
if (isButtonDown)
expandBounds.reduce(expandBounds.getHeight() * 0.05f, expandBounds.getHeight() * 0.05f);
Path path;
path.startNewSubPath(expandBounds.getTopLeft());
path.lineTo(expandBounds.getCentreX(), expandBounds.getBottom());
path.lineTo(expandBounds.getTopRight());
g.setColour(colors.dark);
g.strokePath(path, PathStrokeType(0.1f * box.getHeight(), PathStrokeType::curved, PathStrokeType::rounded));
if (!box.isEnabled())
{
g.setColour(box.findColour(colors.backgroundColorId, true).withAlpha(0.5f));
g.fillRect(box.getLocalBounds());
}
}
void CustomLookAndFeel::drawComboBoxTextWhenNothingSelected(juce::Graphics& g, juce::ComboBox& box,
juce::Label& label)
{
auto textArea = getLabelBorderSize(label).subtractedFrom(label.getLocalBounds());
auto font = label.getLookAndFeel().getLabelFont(label);
g.setColour(findColour(juce::ComboBox::textColourId));
g.setFont(font);
g.drawText(box.getTextWhenNothingSelected(), textArea, label.getJustificationType());
if (!box.isEnabled())
{
g.setColour(box.findColour(colors.backgroundColorId, true).withAlpha(0.5f));
g.fillRect(box.getLocalBounds());
}
}
juce::PopupMenu::Options CustomLookAndFeel::getOptionsForComboBoxPopupMenu(juce::ComboBox& box, juce::Label& label)
{
return juce::PopupMenu::Options().withTargetComponent(&box)
.withPreferredPopupDirection(juce::PopupMenu::Options::PopupDirection::downwards)
.withItemThatMustBeVisible(box.getSelectedId())
.withInitiallySelectedItem(box.getSelectedId())
.withMinimumWidth(box.getWidth())
.withMaximumNumColumns(1)
.withStandardItemHeight(label.getHeight());
}
juce::Font CustomLookAndFeel::getPopupMenuFont()
{
return getInter().withHeight(17.f);
}
void CustomLookAndFeel::drawPopupMenuBackground(juce::Graphics& graphics, int, int)
{
graphics.fillAll(colors.slate);
}
void CustomLookAndFeel::drawPopupMenuItem(juce::Graphics& g, const juce::Rectangle<int>& area, bool /*isSeparator*/,
bool isActive, bool isHighlighted, bool isTicked, bool /*hasSubMenu*/, const juce::String& text,
const juce::String& /*shortcutKeyText*/, const juce::Drawable* /*icon*/, const Colour* /*textColour*/)
{
using namespace juce;
auto bounds = area.toFloat();
if (isHighlighted && isActive)
{
g.setColour(colors.darkerSlate);
g.fillRect(bounds);
}
float padding = area.getWidth() * 0.02f;
bounds.reduce(padding, 0);
auto font = getPopupMenuFont();
auto maxFontHeight = bounds.getHeight() * 0.77f;
if (font.getHeight() > maxFontHeight)
font.setHeight(maxFontHeight);
g.setColour(colors.light);
g.setFont(font);
auto iconArea = bounds.removeFromLeft(bounds.getHeight()).toFloat().reduced(bounds.getHeight() * 0.29f);
if (isTicked)
{
auto tick = getTickShape(1.0f);
g.strokePath(tick, PathStrokeType(iconArea.getWidth() * 0.18f, PathStrokeType::curved, PathStrokeType::rounded), tick.getTransformToScaleToFit(iconArea, true));
}
bounds.removeFromRight(3);
g.drawText(text, bounds, Justification::centredLeft, true);
}
void CustomLookAndFeel::drawPopupMenuUpDownArrow(juce::Graphics& g, int width, int height, bool isScrollUpArrow)
{
using namespace juce;
auto hw = float(width * 0.5f);
auto arrowW = float(height * 0.3f);
auto y1 = float(height) * (isScrollUpArrow ? 0.6f : 0.3f);
auto y2 = float(height) * (isScrollUpArrow ? 0.3f : 0.6f);
Path p;
p.addTriangle(hw - arrowW, y1,
hw + arrowW, y1,
hw, y2);
g.setColour(colors.dark);
g.fillPath(p);
}
juce::Path CustomLookAndFeel::getTickShape(float height)
{
juce::Path tick;
tick.startNewSubPath(height, 0.f);
tick.lineTo(height * 0.4f, height);
tick.lineTo(0.f, height * 0.5f);
return tick;
}
void CustomLookAndFeel::drawTickBox(juce::Graphics& g, juce::Component& component, float /*x*/, float /*y*/, float /*w*/,
float /*h*/, bool ticked, bool /*isEnabled*/, bool /*shouldDrawButtonAsHighlighted*/, bool /*shouldDrawButtonAsDown*/)
{
using namespace juce;
auto bounds = component.getLocalBounds().toFloat();
auto size = jmin(bounds.getWidth(), bounds.getHeight());
bounds.reduce((bounds.getWidth() - size) / 2.f, (bounds.getHeight() - size) / 2.f);
float lineThickness = 0.093f * size;
if (ticked)
{
g.setColour(colors.highlight);
g.fillRect(bounds.reduced(lineThickness * 0.75f));
}
g.setColour(colors.dark);
g.drawRoundedRectangle(bounds.reduced(lineThickness * 0.66f), 0.14f * size, lineThickness);
if (!component.isEnabled())
{
g.setColour(component.findColour(colors.backgroundColorId, true).withAlpha(0.5f));
g.fillRect(component.getLocalBounds());
}
}
//==============================================================================
template <EnvelopeSlider Direction>
void EnvelopeSliderLookAndFeel<Direction>::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float, float, float, juce::Slider& slider)
{
using namespace juce;
auto bounds = Rectangle(x, y, width, height).toFloat();
bounds = bounds.reduced(bounds.getWidth() * Layout::rotaryPadding);
float exp = float(slider.getValue());
Path curve;
for (int i = 0; i < width; i++)
{
float xPos = float(i) / width;
float yPos = CustomSamplerVoice::exponentialCurve(exp, xPos);
if constexpr (Direction == EnvelopeSlider::release)
yPos = 1 - yPos;
if (i == 0)
curve.startNewSubPath(float(i), bounds.getHeight() * (1 - yPos));
else
curve.lineTo(float(i), bounds.getHeight() * (1 - yPos));
}
g.setColour(colors.dark.withAlpha(slider.isEnabled() ? 1.f : 0.5f));
g.strokePath(curve, PathStrokeType(0.08f * width, PathStrokeType::curved, PathStrokeType::rounded), curve.getTransformToScaleToFit(bounds, false));
slider.setMouseCursor(slider.isEnabled() ? MouseCursor::UpDownResizeCursor : MouseCursor::NormalCursor);
}
void CustomLookAndFeel::drawCornerResizer(juce::Graphics& g, int w, int h, bool isMouseOver,
bool isMouseDragging)
{
auto lineThickness = juce::jmin(float(w), float(h)) * 0.075f;
auto d = 0.6f;
for (auto i = 0; i < 1 + int(isMouseOver || isMouseDragging); i++)
{
g.setColour(colors.slate);
g.drawLine(float(w) * d, float(h) + 1.0f, float(w) + 1.0f, float(h) * d, lineThickness);
g.setColour(colors.darkerSlate);
g.drawLine(float(w) * d + lineThickness, float(h) + 1.0f, float(w) + 1.0f, float(h) * d + lineThickness, lineThickness);
d -= 0.3f;
}
}
//==============================================================================
juce::Slider::SliderLayout VolumeSliderLookAndFeel::getSliderLayout(juce::Slider& slider)
{
auto borderSize = slider.getWidth() * 0.020f;
juce::Slider::SliderLayout layout;
layout.sliderBounds = slider.getLocalBounds();
layout.sliderBounds.removeFromLeft(int(std::round(slider.getWidth() * 0.12f)));
layout.sliderBounds.removeFromRight(int(std::round(borderSize + slider.getWidth() * 0.09f)));
layout.textBoxBounds = slider.getLocalBounds().removeFromTop(int(std::round(0.21f * slider.getWidth())));
layout.textBoxBounds.removeFromLeft(int(std::round(slider.getWidth() * 0.1f)));
layout.textBoxBounds.removeFromRight(int(std::round(slider.getWidth() * 0.25f)));
return layout;
}
juce::Label* VolumeSliderLookAndFeel::createSliderTextBox(juce::Slider& slider)
{
auto label = CustomLookAndFeel::createSliderTextBox(slider);
label->setFont(getInriaSansBold().withHeight(slider.getWidth() * 0.225f));
label->setJustificationType(juce::Justification::bottomRight);
auto superOnEditorShow = label->onEditorShow;
label->onEditorShow = [label, superOnEditorShow]
{
superOnEditorShow();
auto editor = label->getCurrentTextEditor();
auto editorBounds = editor->getBounds();
editorBounds.translate(3, 0);
editor->setBounds(editorBounds);
editor->setJustification(juce::Justification::bottomRight);
};
return label;
}
void VolumeSliderLookAndFeel::drawLinearSlider(juce::Graphics& g, int /*x*/, int /*y*/, int /*width*/, int /*height*/, float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider)
{
using namespace juce;
auto bounds = slider.getBounds().withZeroOrigin().toFloat();
auto borderSize = bounds.getWidth() * 0.020f;
bounds = bounds.reduced(borderSize);
// Get thumb dimensions
auto thumbWidth = bounds.getWidth() * 0.203f;
auto thumbHeight = thumbWidth * 0.5f;
auto thumbRound = bounds.getWidth() * 0.032f;
auto thumb = Rectangle(sliderPos - thumbWidth / 2.f, bounds.getHeight() - thumbHeight, thumbWidth, thumbHeight);
// Draw fader shape
Path sliderShape;
float bottom = bounds.getHeight() - thumbHeight / 2.f;
float right = bounds.getRight() - bounds.getWidth() * 0.08f;
sliderShape.addTriangle(bounds.getX(), bottom, right, bottom, right, bottom - bounds.getWidth() * 0.4f);
sliderShape = sliderShape.createPathWithRoundedCorners(2 * borderSize);
g.setColour(colors.highlight);
float scale = sliderPos / (right - bounds.getX()) * 0.985f;
g.fillPath(sliderShape, AffineTransform::scale(scale, scale * 0.98f, bounds.getX(), bottom));
g.setColour(colors.dark);
g.strokePath(sliderShape, PathStrokeType(borderSize, PathStrokeType::JointStyle::curved));
if (!slider.isEnabled())
{
g.setColour(slider.findColour(colors.backgroundColorId, true).withAlpha(0.5f));
g.fillRect(slider.getLocalBounds());
}
}
//==============================================================================
const juce::Font& getInriaSans()
{
static juce::Font inriaSans{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InriaSansRegular_ttf, BinaryData::InriaSansRegular_ttfSize)) };
return inriaSans;
}
const juce::Font& getInriaSansBold()
{
static juce::Font inriaSansBold{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InriaSansBold_ttf, BinaryData::InriaSansBold_ttfSize)) };
return inriaSansBold;
}
const juce::Font& getInter()
{
static juce::Font inter{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InterRegular_ttf, BinaryData::InterRegular_ttfSize)) };
return inter;
}
const juce::Font& getInterBold()
{
static juce::Font interBold{ juce::FontOptions(juce::Typeface::createSystemTypefaceFor(BinaryData::InterBold_ttf, BinaryData::InterBold_ttfSize)) };
return interBold;
}
juce::Path getOutlineFromSVG(const char* data)
{
auto xml = juce::parseXML(data);
jassert(xml != nullptr);
return juce::Drawable::createFromSVG(*xml)->getOutlineAsPath();
}

227
Source/CustomLookAndFeel.h Normal file
View file

@ -0,0 +1,227 @@
/*
==============================================================================
LookAndFeel.h
Created: 19 Sep 2023 4:48:33pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
/** This struct contains the plugin's colors. */
struct Colors
{
/** Background for the editor */
juce::Colour background;
/** Foreground for the controls */
juce::Colour foreground;
/** Dark color */
juce::Colour dark;
/** Highlight color for controls */
juce::Colour highlight;
/** Loop color */
juce::Colour loop;
/** Lighter slate for some buttons */
juce::Colour slate;
/** Darker slate for waveform and some buttons */
juce::Colour darkerSlate;
/** Background neutral color (outside editor) */
juce::Colour light;
/** Prompt background color */
juce::Colour prompt;
static constexpr int backgroundColorId{ -1 };
static constexpr int painterColorId{ -2 };
};
static const Colors defaultTheme
{
.background = juce::Colour{0xFFFDF6C3},
.foreground = juce::Colour{0xFFEAFFF7},
.dark = juce::Colour{0xFF171614},
.highlight = juce::Colour{0xFFFF595E},
.loop = juce::Colour{0xFFFFDA22},
.slate = juce::Colour{0xFF6E7894},
.darkerSlate = juce::Colour{0xFF403D37},
.light = juce::Colour{0xFFFFFFFF},
.prompt = juce::Colour{0xFF171614}.withAlpha(0.3f)
};
static const Colors darkTheme
{
.background = juce::Colour{0xFF0F0F1A},
.foreground = juce::Colour{0xFF1A1A30},
.dark = juce::Colour{0xFF8888AA},
.highlight = juce::Colour{0xFF3A73E5},
.loop = juce::Colour{0xFFEE8833},
.slate = juce::Colour{0xFF2A2A4A},
.darkerSlate = juce::Colour{0xFF1A192D},
.light = juce::Colour{0xFFFFFFFF},
.prompt = juce::Colour{0xFF0F0F1A}.withAlpha(0.8f)
};
/** This struct contains layout constants. */
struct Layout
{
// Toolbar values should all be scaled according to the window width
static constexpr int figmaWidth{ 1999 };
static constexpr int controlsHeight{ 159 };
static constexpr int controlsPaddingX{ 16 };
static constexpr int moduleGap{ 36 };
static constexpr int moduleLabelHeight{ 42 };
static constexpr int moduleLabelPadding{ 3 };
static constexpr int moduleLabelGap{ 3 };
static constexpr int moduleControlsGap{ 22 };
static constexpr int tuningWidth{ 320 }; // As ratios of the window width
static constexpr int attackWidth{ 204 };
static constexpr int releaseWidth{ 204 };
static constexpr int playbackWidth{ 534 };
static constexpr int loopWidth{ 217 };
static constexpr int masterWidth{ 295 };
static constexpr int standardRotarySize{ 87 }; // Not including padding
static constexpr float rotaryHeightRatio{ 0.965f }; // The height of the rotary slider as a ratio of its width
static constexpr float rotaryPadding{ 0.09f };
static constexpr float rotaryTextSize{ 0.40f };
static constexpr float boundsWidth{ 0.0045f };
static constexpr float handleWidth{ boundsWidth / 2.f };
static constexpr float playheadWidth{ handleWidth * 0.66f };
static constexpr juce::Point<int> sampleControlsMargin{ 46, 25 };
static constexpr int sampleControlsHeight{ 45 };
static constexpr int fileControlsWidth{ 1179 };
static constexpr int waveformModeWidth{ 238 };
static constexpr int playbackControlsWidth{ 137 };
static constexpr float expandWidth{ 1.185f }; // Ratio of combobox height
static constexpr int sampleNavigatorHeight{ 93 };
static constexpr float navigatorBoundsWidth{ 0.002f };
static constexpr juce::Point<int> navigatorControlsSize{ 124, 52 };
static constexpr int fxChainHeight{ 384 };
static constexpr float fxChainDivider{ 0.001f };
static constexpr float fxModuleHeader{ 73.f };
static constexpr float fxDisplayStrokeWidth{ 0.004f };
static constexpr int footerHeight{ 64 };
};
/** This struct contains certain UX constants */
struct Feel
{
static constexpr int MOUSE_SENSITIVITY{ 60 };
static constexpr int DRAGGABLE_SNAP{ 10 };
static constexpr int MINIMUM_BOUNDS_DISTANCE{ 10 }; // This needs to be at some minimum value
static constexpr int MINIMUM_VIEW{ 6 * MINIMUM_BOUNDS_DISTANCE }; // Minimum view size in samples
};
//==============================================================================
/** Some utilities */
struct ComponentProps
{
inline static const juce::String& ROTARY_UNIT{ "props_unit" }; // The rotary unit
inline static const juce::String& ROTARY_GREATER_UNIT{ "greater_unit" }; // A larger unit (e.g. s instead of ms)
inline static const juce::String& ROTARY_ICON{ "label_icon" }; // An icon to display in place of the unit
inline static const juce::String& ROTARY_PARAMETER_NAME{ "parameter_name" }; // For use in a custom rotaries help text
inline static const juce::String& LABEL_ELLIPSES{ "label_ellipses" }; // Use ellipses for the label
};
class ReferenceCountedPath final : public juce::ReferenceCountedObject
{
public:
explicit ReferenceCountedPath(juce::Path path) : path(std::move(path)) {}
juce::Path path;
};
const juce::Font& getInriaSans();
const juce::Font& getInriaSansBold();
const juce::Font& getInter();
const juce::Font& getInterBold();
juce::Path getOutlineFromSVG(const char* data);
//==============================================================================
class CustomLookAndFeel : public juce::LookAndFeel_V4
{
using Colour = juce::Colour;
public:
CustomLookAndFeel();
void setTheme(const Colors& theme);
juce::Slider::SliderLayout getSliderLayout(juce::Slider& slider) override;
juce::Label* createSliderTextBox(juce::Slider& slider) override;
void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPosProportional, float rotaryStartAngle, float rotaryEndAngle, juce::Slider& slider) override;
void drawLabel(juce::Graphics&, juce::Label&) override;
juce::Button* createFilenameComponentBrowseButton(const juce::String& text) override;
void layoutFilenameComponent(juce::FilenameComponent&, juce::ComboBox* filenameBox, juce::Button* browseButton) override;
juce::Font getComboBoxFont(juce::ComboBox&) override;
juce::Label* createComboBoxTextBox(juce::ComboBox&) override;
void positionComboBoxText(juce::ComboBox&, juce::Label& label) override;
void drawComboBox(juce::Graphics&, int width, int height, bool isButtonDown, int buttonX, int buttonY, int buttonW, int buttonH, juce::ComboBox&) override;
void drawComboBoxTextWhenNothingSelected(juce::Graphics&, juce::ComboBox&, juce::Label&) override;
juce::PopupMenu::Options getOptionsForComboBoxPopupMenu(juce::ComboBox&, juce::Label&) override;
juce::Font getPopupMenuFont() override;
void drawPopupMenuBackground(juce::Graphics&, int width, int height) override;
void drawPopupMenuItem(juce::Graphics&, const juce::Rectangle<int>& area, bool isSeparator, bool isActive, bool isHighlighted, bool isTicked, bool hasSubMenu, const juce::String& text, const juce::String& shortcutKeyText, const juce::Drawable* icon, const Colour* textColour) override;
void drawPopupMenuUpDownArrow(juce::Graphics&, int width, int height, bool isScrollUpArrow) override;
juce::Path getTickShape(float height) override;
void drawTickBox(juce::Graphics&, juce::Component&, float x, float y, float w, float h, bool ticked, bool isEnabled, bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) override;
juce::Font getAlertWindowTitleFont() override { return getInterBold().withHeight(18.f); }
juce::Font getAlertWindowMessageFont() override { return getInter().withHeight(16.f); }
juce::Font getAlertWindowFont() override { return getInter().withHeight(14.f); }
void drawCornerResizer(juce::Graphics&, int w, int h, bool isMouseOver, bool isMouseDragging) override;
Colors colors;
};
//==============================================================================
/** Here's a custom look and feel for the envelope sliders. A template was not the only way to do this, but I thought I'd try it. */
enum class EnvelopeSlider : std::uint8_t
{
attack,
release
};
template <EnvelopeSlider Direction>
class EnvelopeSliderLookAndFeel final : public CustomLookAndFeel
{
void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, float rotaryStartAngle, float rotaryEndAngle, juce::Slider& slider) override;
};
template class EnvelopeSliderLookAndFeel<EnvelopeSlider::attack>;
template class EnvelopeSliderLookAndFeel<EnvelopeSlider::release>;
//==============================================================================
/** Here's a custom look and feel for the volume slider. */
class VolumeSliderLookAndFeel final : public CustomLookAndFeel
{
juce::Slider::SliderLayout getSliderLayout(juce::Slider& slider) override;
juce::Label* createSliderTextBox(juce::Slider& slider) override;
void drawLinearSlider(juce::Graphics& g, int x, int y, int width, int height, float sliderPos, float minSliderPos, float maxSliderPos, juce::Slider::SliderStyle, juce::Slider&) override;
};

1204
Source/PluginEditor.cpp Normal file

File diff suppressed because it is too large Load diff

250
Source/PluginEditor.h Normal file
View file

@ -0,0 +1,250 @@
/*
==============================================================================
PluginProcessor.cpp
Created: 5 Sep 2023
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "PluginProcessor.h"
#include "CustomLookAndFeel.h"
#include "Components/Buttons.h"
#include "Components/InputDeviceSelector.h"
#include "Components/SampleEditor.h"
#include "Components/FxChain.h"
#include "Components/Prompt.h"
#include "Components/SampleNavigator.h"
/** Painting ordering requires a separate component... */
class EditorOverlay final : public CustomComponent
{
public:
void setWaveformMode(bool isWaveformMode)
{
if (waveformModeAvailable != isWaveformMode)
{
waveformModeAvailable = isWaveformMode;
repaint();
}
}
void setWaveformModeDisabled(bool disabled)
{
if (waveformModeDisabled != disabled)
{
waveformModeDisabled = disabled;
repaint();
}
}
private:
void paint(juce::Graphics& g) override;
void resized() override;
void lookAndFeelChanged() override;
float scale(float value) const { return value * getWidth() / Layout::figmaWidth; }
float scale(int value) const { return scale(float(value)); }
//==============================================================================
melatonin::DropShadow sampleControlShadow{ defaultTheme.slate.withAlpha(0.125f), 3, {2, 2} };
melatonin::DropShadow navControlShadow{ defaultTheme.slate.withAlpha(0.125f), 3, {-2, -2} };
bool waveformModeAvailable{ false };
bool waveformModeDisabled{ false };
};
//==============================================================================
class JustaSampleAudioProcessorEditor final : public juce::AudioProcessorEditor, public juce::Timer, public juce::FileDragAndDropTarget,
public juce::FilenameComponentListener, public CustomHelpTextDisplay, public ThemeProvider
{
public:
explicit JustaSampleAudioProcessorEditor(JustaSampleAudioProcessor& audioProcessor);
~JustaSampleAudioProcessorEditor() override;
private:
void timerCallback() override;
void paint(juce::Graphics&) override;
void resized() override;
void lookAndFeelChanged() override;
void mouseDown(const juce::MouseEvent& event) override;
void mouseUp(const juce::MouseEvent& event) override;
void mouseDrag(const juce::MouseEvent& event) override;
void mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) override;
void helpTextChanged(const juce::String&) override {}
void setTheme(Colors newTheme) override;
Colors getTheme() const override;
juce::Rectangle<int> getConstrainedBounds() const;
//==============================================================================
/** Update the Editor to fit with the processor's sample. On the initial load, the
SampleNavigator will not update the viewing bounds.
*/
void loadSample();
/** Handles the incoming recording messages from the recorder queue */
void handleActiveRecording();
/** If permitted, toggles whether the plugin state will use a file reference or store the
samples directly. If necessary, this will prompt the user to save the current sample.
*/
void toggleLinkSample();
/** Signals to the processor to start recording, or first opens the device settings prompt if
no valid input device is selected. Note that no editor changes occur here, instead it polls
for changes in the timer callback (simpler than registering as a listener to the recorder).
*/
void startRecording(bool promptSettings = true);
/** Starts a pitch detection prompt */
void promptPitchDetection();
/** Opens the device settings prompt */
void promptDeviceSettings(bool recordOnClose = false);
/** We use this as a place to update the enablement and display of our plugin controls. */
void enablementChanged() override;
void fxEnablementChanged();
//==============================================================================
/** Whether the editor is interested in a file */
bool isInterestedInFile(const juce::String& file) const;
bool isInterestedInFileDrag(const juce::StringArray& files) override;
void filesDropped(const juce::StringArray& files, int x, int y) override;
void filenameComponentChanged(juce::FilenameComponent* fileComponentThatHasChanged) override;
void fileDragEnter(const juce::StringArray& files, int x, int y) override;
void fileDragExit(const juce::StringArray& files) override;
void updateLabel(const juce::String& text = "");
//==============================================================================
/** Scaling the sizes in our Figma demo to percentages of width.
This rounding operation is important to ensure consistent spacing with JUCE's integer component bounds.
Otherwise, we'd just do a simple division.
*/
float scalei(int value) const { return scalei(float(value)); }
float scalei(float value) const { return std::roundf(value * prompt.getWidth() / Layout::figmaWidth); }
float scalef(float value) const { return value * prompt.getWidth() / Layout::figmaWidth; }
//==============================================================================
JustaSampleAudioProcessor& p;
PluginParameters::State& pluginState;
UIDummyParam dummyParam;
const juce::OwnedArray<CustomSamplerVoice>& synthVoices;
bool currentlyPlaying{ false };
/** Some thought is needed to keep the editor synchronized when changes to the sample occur
or files are loaded. I decided the easiest way is to have the processor and editor both
keep track of the buffer's hash.
*/
juce::String expectedHash{ 0 };
bool userDraggedSample{ false }; // We use this to reset the UI only when a sample loads as result of a user selection
//==============================================================================
juce::AudioBuffer<float> pendingRecordingBuffer;
int recordingBufferSize{ 0 };
//==============================================================================
// Modules
juce::Label tuningLabel, attackLabel, releaseLabel, playbackLabel, loopingLabel, masterLabel;
juce::Array<juce::Slider*> rotaries;
// Tuning module
CustomRotary semitoneRotary, centRotary, waveformSemitoneRotary, waveformCentRotary;
CustomRotaryAttachment semitoneRotaryAttachment, centRotaryAttachment, waveformSemitoneRotaryAttachment, waveformCentRotaryAttachment;
juce::Label tuningDetectLabel;
CustomShapeButton tuningDetectButton;
// Attack and release modules
CustomRotary attackTimeRotary, attackCurve, releaseTimeRotary, releaseCurve;
CustomRotaryAttachment attackTimeAttachment, attackCurveAttachment, releaseTimeAttachment, releaseCurveAttachment;
EnvelopeSliderLookAndFeel<EnvelopeSlider::attack> attackCurveLNF;
EnvelopeSliderLookAndFeel<EnvelopeSlider::release> releaseCurveLNF;
// Playback module
CustomToggleableButton lofiModeButton;
APVTS::ButtonAttachment lofiModeAttachment;
CustomChoiceButton playbackModeButton;
juce::ParameterAttachment playbackModeAttachment;
CustomRotary playbackSpeedRotary;
CustomRotaryAttachment playbackSpeedAttachment;
// Loop module
CustomToggleableButton loopButton, loopStartButton, loopEndButton;
APVTS::ButtonAttachment loopAttachment, loopStartAttachment, loopEndAttachment;
// Mixing module
CustomToggleableButton monoOutputButton;
APVTS::ButtonAttachment monoOutputAttachment;
CustomRotary gainSlider;
CustomRotaryAttachment gainSliderAttachment;
VolumeSliderLookAndFeel gainSliderLNF;
// Sample controls
EditorOverlay editorOverlay;
juce::FilenameComponent filenameComponent;
CustomToggleableButton linkSampleToggle; // Whether the sample should be stored in the plugin state
CustomToggleableButton waveformModeLabel;
juce::ButtonParameterAttachment waveformModeLabelAttachment;
CustomShapeButton playStopButton;
juce::Path playPath, stopPath;
const juce::String playHelpText{ "Play sample" }, stopHelpText{ "Halt voices" };
CustomShapeButton recordButton;
CustomShapeButton deviceSettingsButton;
InputDeviceSelector audioDeviceSettings;
// Nav controls
CustomShapeButton fitButton;
CustomToggleableButton pinButton;
ToggleButtonAttachment pinButtonAttachment;
// Main components
SampleEditor sampleEditor;
SampleNavigator sampleNavigator; // Note that SampleNavigator manages ViewStart and ViewEnd
FxChain fxChain;
juce::Label statusLabel;
bool fileDragging{ false };
// Some variables to help detect scroll gestures
const int maxCallbacksSinceInZoomGesture{ 20 };
int zoomGestureEndCallbacks{ 0 };
bool mouseWheelDetected{ false };
int lastViewStart{ 0 }, lastViewEnd{ 0 };
// Footer
juce::Label versionInfo;
CustomToggleableButton preFXButton;
APVTS::ButtonAttachment preFXAttachment;
CustomToggleableButton showFXButton;
ToggleButtonAttachment showFXAttachment;
juce::ParameterAttachment eqEnablementAttachment, reverbEnablementAttachment, distortionEnablementAttachment, chorusEnablementAttachment;
bool eqEnabled{ false }, reverbEnabled{ false }, distortionEnabled{ false }, chorusEnabled{ false };
Prompt prompt;
CustomLookAndFeel& lnf;
Colors theme{ defaultTheme };
juce::OpenGLContext openGLContext;
juce::PluginHostType hostType;
#if JUCE_DEBUG
melatonin::Inspector inspector{ *this, false };
#endif
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (JustaSampleAudioProcessorEditor)
};

438
Source/PluginParameters.h Normal file
View file

@ -0,0 +1,438 @@
/*
==============================================================================
PluginParameters.h
Created: 4 Oct 2023 10:40:47am
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "Utilities/ListenableValue.h"
#ifndef JAS_DARKMODE_DEFAULT
#define JAS_DARKMODE_DEFAULT true
#endif
#ifndef JAS_VST3_REAPER_INTEGRATION
#define JAS_VST3_REAPER_INTEGRATION false
#endif
/** This namespace contains all APVTS parameter IDs, the other plugin state, various plugin configuration settings, and the parameter layout */
namespace PluginParameters
{
using String = juce::String;
using StringArray = juce::StringArray;
using NormalisableRange = juce::NormalisableRange<float>;
using Range = juce::Range<float>;
/** Stores the non-parameter state of the plugin */
struct State
{
ListenableAtomic<int> width{ 0 };
inline static const String WIDTH{ "Width" };
ListenableAtomic<int> height{ 0 };
inline static const String HEIGHT{ "Height" };
ListenableMutex<String> filePath{ "" };
inline static const String FILE_PATH{ "File Path" };
ListenableMutex<String> sampleHash{ "" };
inline static const String SAMPLE_HASH{ "Sample Hash" };
ListenableAtomic<bool> usingFileReference{ false };
inline static const String USING_FILE_REFERENCE{ "Using File Reference" };
ListenableMutex<StringArray> recentFiles;
inline static const String RECENT_FILES{ "Recent Files" };
inline static const String SAVED_DEVICE_SETTINGS{ "Saved Device Settings" };
ListenableAtomic<int> viewStart{ 0 };
inline static const String UI_VIEW_START{ "UI View Start" };
ListenableAtomic<int> viewEnd{ 0 };
inline static const String UI_VIEW_END{ "UI View End" };
ListenableAtomic<bool> pinView{ false };
inline static const String PIN_VIEW{ "Pin View" };
ListenableAtomic<int> primaryChannel{ 0 };
inline static const String PRIMARY_CHANNEL{ "Primary Channel" };
ListenableAtomic<int> sampleStart{ 0 };
inline static const String SAMPLE_START{ "Sample Start" };
ListenableAtomic<int> sampleEnd{ 0 };
inline static const String SAMPLE_END{ "Sample End" };
ListenableAtomic<int> loopStart{ 0 };
inline static const String LOOP_START{ "Loop Start" };
ListenableAtomic<int> loopEnd{ 0 };
inline static const String LOOP_END{ "Loop End" };
ListenableAtomic<bool> showFX{ false };
inline static const String SHOW_FX{ "Show FX" };
ListenableAtomic<bool> darkMode{ JAS_DARKMODE_DEFAULT };
inline static const String DARK_MODE{ "Dark Mode" };
inline static const String UI_DUMMY_PARAM{ "UI Update" };
};
// Misc
inline static constexpr int FRAME_RATE{ 60 };
inline static constexpr bool REAPER_INTEGRATION_ENABLED{ JAS_VST3_REAPER_INTEGRATION };
// Sample storage
inline static constexpr bool USE_FILE_REFERENCE{ true };
inline static constexpr int STORED_BITRATE{ 16 };
inline static constexpr double MAX_FILE_SIZE{ 320000000.0 }; // in bits, 40MB
// Tuning
inline static const String SEMITONE_TUNING{ "Semitone Tuning" };
inline static const String CENT_TUNING{ "Cent Tuning" };
inline static const String PITCH_WHEEL_RANGE{ "Pitch Wheel Range" };
inline static const String WIDE_TUNING{ "Wide Tuning" };
inline static const String A4_HZ{ "A4 Frequency" };
inline static const String WAVEFORM_SEMITONE_TUNING{ "Waveform Semitone Tuning" };
inline static const String WAVEFORM_CENT_TUNING{ "Waveform Cent Tuning" };
// Sample envelope
inline static const String ATTACK{ "Attack Time" };
inline static const String RELEASE{ "Release Time" };
inline static const NormalisableRange ENVELOPE_TIME_RANGE{ 0.f, 5000.f, 1.f };
inline static const String ATTACK_SHAPE{ "Attack Curve Shape" };
inline static const String RELEASE_SHAPE{ "Release Curve Shape" };
inline static const String CROSSFADE_SAMPLES{"Crossfade Samples"};
// Sample playback
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 PLAYBACK_MODE{ "Playback Mode" };
inline static const StringArray PLAYBACK_MODE_LABELS{ "Basic", "Bungee" }; // for IDs and display
enum PLAYBACK_MODES : std::uint8_t
{
BASIC,
BUNGEE,
};
/** Returns an enum representation of a playback mode given a float */
inline PLAYBACK_MODES getPlaybackMode(int value) { return static_cast<PLAYBACK_MODES>(value); }
/** Skipping antialiasing can be an interesting effect */
inline static const String SKIP_ANTIALIASING{ "Lo-fi Resampling" };
// Some controls for advanced playback
inline static const String SPEED_FACTOR{ "Playback Speed" };
inline static const String OCTAVE_SPEED_FACTOR{ "Octave Speed Factor" };
inline static constexpr float WAVETABLE_CUTOFF_HZ{ 20 }; // The cutoff frequency for "wavetable mode"
inline static const String DISABLE_WAVETABLE_MODE{ "Disable Waveform Mode" };
inline static const String SAMPLE_GAIN{ "Sample Gain" };
inline static const String MONO_OUTPUT{ "Mono Output" };
inline static const String DISABLE_VELOCITY{ "Disable Velocity" };
inline static constexpr int MAX_VOICES{ 256 };
inline static const String NUM_VOICES{ "Voice Count" };
inline static constexpr juce::Range MIDI_NOTE_RANGE{ 0, 127 };
inline static const String MIDI_START{ "MIDI Range Start" };
inline static const String MIDI_END{ "MIDI Range End" };
inline static const String MIDI_ROOT{ "MIDI Root Note" };
inline static const String FOLLOW_MIDI_PITCH{ "Follow MIDI Pitch" };
// FX parameters
inline static const String REVERB_ENABLED{ "Reverb Enabled" };
inline static const String REVERB_MIX{ "Reverb Mix" };
inline static const String REVERB_SIZE{ "Reverb Size" };
inline static constexpr Range REVERB_SIZE_RANGE{ 5.f, 100.f};
inline static const String REVERB_DAMPING{ "Reverb Damping" };
inline static constexpr Range REVERB_DAMPING_RANGE{ 0.f, 95.f };
inline static const String REVERB_LOWS{ "Reverb Lows" }; // These controls map to filters built into Gin's SimpleVerb
inline static constexpr Range REVERB_LOWS_RANGE{ 0.f, 1.f };
inline static const String REVERB_HIGHS{ "Reverb Highs" };
inline static constexpr Range REVERB_HIGHS_RANGE{ 0.f, 1.f };
inline static const String REVERB_PREDELAY{ "Reverb Predelay" };
inline static const String DISTORTION_ENABLED{ "Distortion Enabled" };
inline static const String DISTORTION_DENSITY{ "Distortion Density" };
inline static constexpr Range DISTORTION_DENSITY_RANGE{ -0.5f, 1.f };
inline static const String DISTORTION_HIGHPASS{ "Distortion Highpass" };
inline static constexpr Range DISTORTION_HIGHPASS_RANGE{ 0.f, 0.999f };
inline static const String DISTORTION_MIX{ "Distortion Mix" };
inline static const String EQ_ENABLED{ "EQ Enabled" };
inline static const String EQ_LOW_GAIN{ "EQ Low Gain" };
inline static const NormalisableRange EQ_GAIN_RANGE{ -18.f, 12.f, 0.1f }; // Decibels
inline static const String EQ_MID_GAIN{ "EQ Mid Gain" };
inline static const String EQ_HIGH_GAIN{ "EQ High Gain" };
inline static const String EQ_LOW_FREQ{ "EQ Low Cutoff" };
inline static constexpr Range EQ_LOW_FREQ_RANGE{ 25.f, 600.f };
inline static constexpr float EQ_LOW_FREQ_DEFAULT{ 200.f };
inline static const String EQ_HIGH_FREQ{ "EQ High Cutoff" };
inline static constexpr Range EQ_HIGH_FREQ_RANGE{ 700.f, 15500.f };
inline static constexpr float EQ_HIGH_FREQ_DEFAULT{ 2000.f };
inline static const String CHORUS_ENABLED{ "Chorus Enabled" };
inline static const String CHORUS_RATE{ "Chorus Rate" };
inline static const NormalisableRange CHORUS_RATE_RANGE{ 0.1f, 20.f, 0.1f, 0.7f }; // in hz, upper range could be extended to 100hz
inline static const String CHORUS_DEPTH{ "Chorus Depth" };
inline static const NormalisableRange CHORUS_DEPTH_RANGE{ 0.01f, 1.f, 0.01f, 0.5f };
inline static const String CHORUS_FEEDBACK{ "Chorus Feedback" };
inline static constexpr Range CHORUS_FEEDBACK_RANGE{ -0.95f, 0.95f };
inline static const String CHORUS_CENTER_DELAY{ "Chorus Center Delay" };
inline static constexpr Range CHORUS_CENTER_DELAY_RANGE{ 0.f, 100.f }; // in ms
inline static const String CHORUS_MIX{ "Chorus Mix" };
inline static const String FX_PERM{ "FX Ordering" };
enum FxTypes : std::uint8_t
{
DISTORTION,
CHORUS,
REVERB,
EQ
};
inline static const String PRE_FX{ "FX Before Envelope" };
inline static constexpr float FX_TAIL_OFF_MAX{ 0.0001f }; // The cutoff RMS value for tailing off effects
//==============================================================================
/** Returns a permutation of FxTypes, given a representative integer */
inline std::array<FxTypes, 4> paramToPerm(int fxParam)
{
std::array types{ DISTORTION, CHORUS, REVERB, EQ };
std::array<FxTypes, 4> perm{};
int factorial = 6; // 3!
for (int i = 0; i < 4; i++)
{
int index = fxParam / factorial;
perm[i] = types[index];
for (int j = index; j < 3; j++)
types[j] = types[j + 1];
fxParam %= factorial;
if (i < 3)
factorial /= 3 - i;
}
return perm;
}
/** Returns a representative integer, given a permutation of FxTypes */
static int permToParam(std::array<FxTypes, 4> fxPerm)
{
std::array types{ DISTORTION, CHORUS, REVERB, EQ };
int result = 0;
int factorial = 6; // 3!
for (int i = 0; i < 3; i++)
{
int type = fxPerm[i];
int index;
for (index = 0; index < types.size(); index++)
if (type == types[index])
break;
result += factorial * index;
for (int j = index; j < 3 - i; j++)
types[j] = types[j + 1];
if (i < 3)
factorial /= 3 - i;
}
return result;
}
// Units
inline static const String SEMITONE_UNIT{ "sm" };
inline static const String CENT_UNIT{ "%" };
inline static const String TIME_UNIT{ "ms" };
inline static const String TIME_UNIT_LONG{ "sec" };
inline static const String SPEED_UNIT{ "x" };
inline static const String VOLUME_UNIT{ "dB" };
inline static const String FREQUENCY_UNIT{ "Hz" };
//==============================================================================
namespace Version
{
// For DAW compatibility, these must not change once released
constexpr int V1 = 100;
constexpr int V1_1 = 101;
constexpr int V1_2 = 102;
constexpr int V1_3 = 1030;
constexpr int V1_3_2 = 1032;
}
/** Utility to add an integer parameter to the layout */
inline void addInt(juce::AudioProcessorValueTreeState::ParameterLayout& layout, const juce::String& identifier,
int defaultValue, const juce::NormalisableRange<int>& range, int versionNum, const std::function<String(int value, int maximumStringLength)>& formatFunc = nullptr)
{
layout.add(std::make_unique<juce::AudioParameterInt>(
juce::ParameterID{ identifier, versionNum }, identifier, range.start, range.end, defaultValue, juce::AudioParameterIntAttributes{}.withStringFromValueFunction(formatFunc)
));
}
/** Utility to add a float parameter to the layout */
inline void addFloat(juce::AudioProcessorValueTreeState::ParameterLayout& layout, const juce::String& identifier,
float defaultValue, const juce::NormalisableRange<float>& range, int versionNum, const std::function<String(float value, int maximumStringLength)>& formatFunc = nullptr)
{
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{ identifier, versionNum }, identifier, range, defaultValue, juce::AudioParameterFloatAttributes{}.withStringFromValueFunction(formatFunc)
));
}
/** Utility to add a boolean parameter to the layout */
inline void addBool(juce::AudioProcessorValueTreeState::ParameterLayout& layout, const juce::String& identifier, bool defaultValue, int versionNum, const std::function<String(bool value, int maximumStringLength)>& formatFunc = nullptr)
{
layout.add(std::make_unique<juce::AudioParameterBool>(
juce::ParameterID{ identifier, versionNum }, identifier, defaultValue, juce::AudioParameterBoolAttributes{}.withStringFromValueFunction(formatFunc)
));
};
/** Utility to add a choice parameter to the layout */
inline void addChoice(juce::AudioProcessorValueTreeState::ParameterLayout& layout, const juce::String& identifier, int defaultIndex,
const juce::StringArray& choicesToUse, int versionNum, const std::function<String(int value, int maximumStringLength)>& formatFunc = nullptr)
{
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{ identifier, versionNum }, identifier, choicesToUse, defaultIndex, juce::AudioParameterChoiceAttributes{}.withStringFromValueFunction(formatFunc)
));
}
template <typename T> juce::NormalisableRange<T> addSkew(juce::NormalisableRange<T> range, T skewCenter)
{
auto r = range.getRange();
range.skew = std::log(0.5f) / std::log((float(skewCenter) - r.getStart()) / r.getLength());
return range;
}
/** This inverts the proportions, such that "increasing" a slider value will decrease the parameter value */
template <typename T> juce::NormalisableRange<T> invertProportions(juce::NormalisableRange<T> range)
{
auto convertFrom0To1Function = [](T rangeStart, T rangeEnd, T normalised) { return juce::jmap<float>(normalised, rangeEnd, rangeStart); };
auto convertTo0From1Function = [](T rangeStart, T rangeEnd, T value) { return juce::jmap<float>(value, rangeEnd, rangeStart, 0.0f, 1.0f); };
auto newRange = juce::NormalisableRange<T>{ range.start, range.end, convertFrom0To1Function, convertTo0From1Function };
newRange.interval = range.interval;
return newRange;
}
/** Returns a function that appends a suffix to an integer */
inline std::function<String(int, int)> suffixI(const juce::String& suffix)
{
return [suffix](int value, int) { return juce::String(value) + suffix; };
}
/** Returns a function that appends a suffix to a float. */
inline std::function<String(float, int)> suffixF(const juce::String& suffix, float interval)
{
return [suffix, interval](float value, int maxLength)
{
int numDecimalPlaces = String{ int(1.f / interval) }.length() - 1;
auto asText{ String{value, numDecimalPlaces} + suffix };
return maxLength > 0 ? asText.substring(0, maxLength) : asText;
};
}
const auto FORMAT_MIDI_NOTE = [](int value, int) -> String { return juce::MidiMessage::getMidiNoteName(value, true, true, 4); };
const auto FORMAT_PERM_VALUE = [](int value, int) -> String
{
std::array<FxTypes, 4> perm = paramToPerm(value);
String result;
for (auto fx : perm)
{
switch (fx)
{
case DISTORTION:
result += "D";
break;
case CHORUS:
result += "C";
break;
case REVERB:
result += "R";
break;
case EQ:
result += "E";
break;
}
}
return result;
};
inline juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout()
{
juce::AudioProcessorValueTreeState::ParameterLayout layout;
addInt(layout, SEMITONE_TUNING, 0, { -18, 18 }, Version::V1, suffixI(" " + SEMITONE_UNIT));
addInt(layout, CENT_TUNING, 0, { -100, 100 }, Version::V1, suffixI(CENT_UNIT));
addFloat(layout, PITCH_WHEEL_RANGE, 1.f, { 0.f, 12.f, 0.1f }, Version::V1_3, suffixF(" " + SEMITONE_UNIT, 0.1f));
addFloat(layout, WIDE_TUNING, 0.f, { -48.f, 48.f, 0.01f }, Version::V1_3, suffixF(" " + SEMITONE_UNIT, 0.01f));
addFloat(layout, A4_HZ, 440.f, { 400.f, 480.f, 0.1f }, Version::V1_3);
addInt(layout, WAVEFORM_SEMITONE_TUNING, 0, { -24, 24 }, Version::V1, suffixI(" " + SEMITONE_UNIT));
addInt(layout, WAVEFORM_CENT_TUNING, 0, { -100, 100 }, Version::V1, suffixI(CENT_UNIT));
addBool(layout, SKIP_ANTIALIASING, false, Version::V1);
addChoice(layout, PLAYBACK_MODE, 0, PLAYBACK_MODE_LABELS, Version::V1);
addFloat(layout, SPEED_FACTOR, 1.f, addSkew({ 0.01f, 5.f, 0.01f }, 1.f), Version::V1, suffixF(SPEED_UNIT, 0.01f));
addFloat(layout, OCTAVE_SPEED_FACTOR, 0.f, { 0.f, 0.6f, 0.15f }, Version::V1, suffixF(SPEED_UNIT, 0.15f));
addBool(layout, DISABLE_WAVETABLE_MODE, false, Version::V1_3);
addBool(layout, PLAY_UNTIL_END, false, Version::V1_3);
addBool(layout, LOOPING_HAS_START, false, Version::V1);
addBool(layout, IS_LOOPING, false, Version::V1);
addBool(layout, LOOPING_HAS_END, false, Version::V1);
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);
addBool(layout, DISABLE_VELOCITY, false, Version::V1_3_2);
addInt(layout, NUM_VOICES, 88, { 1, MAX_VOICES }, Version::V1_2, suffixI(" v"));
addInt(layout, MIDI_START, 0, MIDI_NOTE_RANGE, Version::V1_1, FORMAT_MIDI_NOTE);
addInt(layout, MIDI_END, 127, MIDI_NOTE_RANGE, Version::V1_1, FORMAT_MIDI_NOTE);
addInt(layout, MIDI_ROOT, 69, MIDI_NOTE_RANGE, Version::V1_3, FORMAT_MIDI_NOTE);
addBool(layout, FOLLOW_MIDI_PITCH, true, Version::V1_3);
addInt(layout, FX_PERM, permToParam({ DISTORTION, CHORUS, REVERB, EQ }), { 0, 23 }, Version::V1, FORMAT_PERM_VALUE);
addBool(layout, PRE_FX, false, Version::V1);
addFloat(layout, ATTACK, 1, addSkew(ENVELOPE_TIME_RANGE, 1000.f), Version::V1, suffixF(" " + TIME_UNIT, ENVELOPE_TIME_RANGE.interval));
addFloat(layout, RELEASE, 1, addSkew(ENVELOPE_TIME_RANGE, 1000.f), Version::V1, suffixF(" " + TIME_UNIT, ENVELOPE_TIME_RANGE.interval));
addFloat(layout, ATTACK_SHAPE, 0.f, invertProportions(NormalisableRange{ -10.f, 10.f, 0.1f }), Version::V1);
addFloat(layout, RELEASE_SHAPE, 2.f, { -10.f, 10.f, 0.1f }, Version::V1);
addInt(layout, CROSSFADE_SAMPLES, 1000, { 0, 50000 }, Version::V1_2);
addBool(layout, REVERB_ENABLED, false, Version::V1);
addFloat(layout, REVERB_MIX, 0.5f, { 0.f, 1.f, 0.01f }, Version::V1);
addFloat(layout, REVERB_SIZE, 0.5f, { REVERB_SIZE_RANGE, 1.f }, Version::V1);
addFloat(layout, REVERB_DAMPING, 0.5f, { REVERB_DAMPING_RANGE, 1.f }, Version::V1);
addFloat(layout, REVERB_LOWS, 0.5f, { REVERB_LOWS_RANGE, 0.01f }, Version::V1);
addFloat(layout, REVERB_HIGHS, 0.5f, { REVERB_HIGHS_RANGE, 0.01f }, Version::V1);
addFloat(layout, REVERB_PREDELAY, 0.5f, { 0.f, 500.f, 1.f, 0.5f }, Version::V1, suffixF(" " + TIME_UNIT, 0.5f));
addBool(layout, DISTORTION_ENABLED, false, Version::V1);
addFloat(layout, DISTORTION_MIX, 1.f, { 0.f, 1.f, 0.01f }, Version::V1);
addFloat(layout, DISTORTION_HIGHPASS, 0.f, { DISTORTION_HIGHPASS_RANGE, 0.01f }, Version::V1);
addFloat(layout, DISTORTION_DENSITY, 0.f, addSkew({ DISTORTION_DENSITY_RANGE, 0.01f }, 0.f), Version::V1);
addBool(layout, EQ_ENABLED, false, Version::V1);
addFloat(layout, EQ_LOW_GAIN, 0.f, addSkew(EQ_GAIN_RANGE, 0.f), Version::V1, suffixF(" " + VOLUME_UNIT, EQ_GAIN_RANGE.interval));
addFloat(layout, EQ_MID_GAIN, 0.f, addSkew(EQ_GAIN_RANGE, 0.f), Version::V1, suffixF(" " + VOLUME_UNIT, EQ_GAIN_RANGE.interval));
addFloat(layout, EQ_HIGH_GAIN, 0.f, addSkew(EQ_GAIN_RANGE, 0.f), Version::V1, suffixF(" " + VOLUME_UNIT, EQ_GAIN_RANGE.interval));
addFloat(layout, EQ_LOW_FREQ, EQ_LOW_FREQ_DEFAULT, EQ_LOW_FREQ_RANGE, Version::V1, suffixF(" " + FREQUENCY_UNIT, 1.f));
addFloat(layout, EQ_HIGH_FREQ, EQ_HIGH_FREQ_DEFAULT, EQ_HIGH_FREQ_RANGE, Version::V1, suffixF(" " + FREQUENCY_UNIT, 1.f));
addBool(layout, CHORUS_ENABLED, false, Version::V1);
addFloat(layout, CHORUS_RATE, 1.f, CHORUS_RATE_RANGE, Version::V1, suffixF(" " + FREQUENCY_UNIT, 1.f));
addFloat(layout, CHORUS_DEPTH, 0.25f, CHORUS_DEPTH_RANGE, Version::V1);
addFloat(layout, CHORUS_FEEDBACK, 0.f, { CHORUS_FEEDBACK_RANGE, 0.01f }, Version::V1);
addFloat(layout, CHORUS_CENTER_DELAY, 7.f, { CHORUS_CENTER_DELAY_RANGE, 1.f }, Version::V1, suffixF(" " + TIME_UNIT, 1.f));
addFloat(layout, CHORUS_MIX, 0.5f, { 0.f, 1.f, 0.01f }, Version::V1);
// This is a dummy parameter to notify the host of state changes
addBool(layout, State::UI_DUMMY_PARAM, true, Version::V1, [](bool, int) -> String { return "Dummy Param"; });
return layout;
}
} // namespace PluginParameters

584
Source/PluginProcessor.cpp Normal file
View file

@ -0,0 +1,584 @@
/*
==============================================================================
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<float>& 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<int>(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<juce::OutputStream> stream = std::make_unique<juce::MemoryOutputStream>(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<juce::MemoryBlock>(sampleSize);
mis.read(sampleData->getData(), int(sampleSize));
auto wavStream = new juce::MemoryInputStream(*sampleData, false);
juce::WavAudioFormat wavFormat;
std::unique_ptr<juce::AudioFormatReader> 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<juce::AudioBuffer<float>>& loadedSample, const juce::String&, const std::unique_ptr<juce::AudioFormatReader>& reader) -> void
{
if (!reader)
return;
loadSample(*loadedSample, int(reader->sampleRate), false, sampleHash);
updateFileInfo();
});
}
}
}
}
//==============================================================================
void JustaSampleAudioProcessor::loadSample(juce::AudioBuffer<float>& 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<void(bool)>& callback)
{
const juce::File file{ path };
std::unique_ptr<juce::AudioFormatReader> 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<juce::AudioBuffer<float>>& loadedSample, const juce::String& sampleHash, const std::unique_ptr<juce::AudioFormatReader>& 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<float>& 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<void(const juce::FileChooser&)>& callback, bool wavOnly)
{
// DAW specific exceptions
bool useNative = !hostType.isArdour();
fileChooser = std::make_unique<juce::FileChooser>(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<float> 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<juce::FileOutputStream>(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<juce::OutputStream> 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();
}

196
Source/PluginProcessor.h Normal file
View file

@ -0,0 +1,196 @@
/*
==============================================================================
PluginProcessor.h
Created: 5 Sep 2023
Author: binya
==============================================================================
Welcome to the Just a Sample source code! This is my first plugin, and I am
excited to share it as a free, open-source project. I hope this can serve
as a learning tool for other programmers and a useful plugin for musicians.
Even a "simple" plugin like this one requires some architecture considerations.
Care was taken to make PluginProcessor self-contained with a clear API exposed
to the PluginEditor.
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "CustomLookAndFeel.h"
#include "Sampler/CustomSamplerVoice.h"
#include "Sampler/CustomSynthesizer.h"
#include "Utilities/PitchDetector.h"
#include "Utilities/DeviceRecorder.h"
#include "Utilities/Reaper/ReaperVST3Extensions.h"
#include "Utilities/SampleLoader.h"
#include <libMTSClient.h>
class JustaSampleAudioProcessor final : public juce::AudioProcessor, public juce::Thread::Listener, public DeviceRecorderListener
#if JucePlugin_Enable_ARA
, public juce::AudioProcessorARAExtension
#endif
{
public:
JustaSampleAudioProcessor();
~JustaSampleAudioProcessor() override;
/** Returns whether the sample buffer is too large to be stored in the plugin data */
bool sampleBufferNeedsReference() const { return sampleBufferNeedsReference(sampleBuffer); }
bool sampleBufferNeedsReference(const juce::AudioBuffer<float>& buffer) const;
/** Whether the processor can handle a filePath's extension */
bool canLoadFileExtension(const juce::String& filePath) const;
//==============================================================================
/** Asynchronously loads an audio buffer from a file path, returning (in a callback) whether the sample
was loaded successfully. If an expected hash is provided and continueWithWrongHash = false, then the
sample will only be loaded if the hash matches. If continueWithWrongHash = true, then the sample will
be loaded and parameters will be reset.
*/
void loadSampleFromPath(const juce::String& path, bool resetParameters = true, const juce::String& expectedHash = "", bool continueWithWrongHash = false,
const std::function<void(bool loadedSuccessfully)>& callback = [](bool) -> void {});
/** Open a file chooser */
void openFileChooser(const juce::String& message, int flags, const std::function<void(const juce::FileChooser&)>& callback, bool wavOnly = false);
/** Detects the pitch of the sample between the supplied bounds and adjusts the tuning parameters accordingly */
bool startPitchDetectionRoutine(int startSample, int endSample);
/** Stop all the voices from playing */
void haltVoices();
/** Plays an A5 note */
void playVoice();
//==============================================================================
const juce::AudioBuffer<float>& getSampleBuffer() const { return sampleBuffer; }
float getBufferSampleRate() const { return bufferSampleRate; }
const juce::OwnedArray<CustomSamplerVoice>& getSamplerVoices() const { return samplerVoices; }
/** The APVTS is the central object storing plugin state and audio processing parameters. See PluginParameters.h. */
juce::AudioProcessorValueTreeState& APVTS() { return apvts; }
juce::UndoManager& getUndoManager() { return undoManager; }
/** The plugin state object stores non-parameter data, to be used in a thread-safe way */
PluginParameters::State& getPluginState() { return pluginState; }
DeviceRecorder& getRecorder() { return deviceRecorder; }
juce::AudioDeviceManager& getDeviceManager() { return deviceManager; }
/** We initialize the device manager lazily from the UI thread, so that we only block the plugin when necessary. Call this
from the Editor when you need the device manager configured appropriately.
*/
void initializeDeviceManager();
juce::String getWildcardFilter() const { return formatManager.getWildcardForAllFormats(); }
const SampleLoader& getSampleLoader() const { return sampleLoader; }
/** Use to determine whether the editor should reset UI parameters */
bool hasLoadedFromReaper() const { return loadedFromReaper.load(); }
void setLoadedFromReaper(const bool loaded) { loadedFromReaper.store(loaded); }
//==============================================================================
juce::var p(const juce::Identifier& identifier) const { return apvts.getParameterAsValue(identifier).getValue(); }
juce::Value pv(const juce::Identifier& identifier) const { return apvts.getParameterAsValue(identifier); }
private:
const juce::var& sp(const juce::Identifier& identifier) const { return apvts.state.getProperty(identifier); }
juce::Value spv(const juce::Identifier& identifier) { return apvts.state.getPropertyAsValue(identifier, apvts.undoManager); }
//==============================================================================
#ifndef JucePlugin_PreferredChannelConfigurations
bool isBusesLayoutSupported(const juce::AudioProcessor::BusesLayout& layouts) const override;
#endif
const juce::String getName() const override { return JucePlugin_Name; }
bool acceptsMidi() const override;
bool producesMidi() const override;
bool isMidiEffect() const override;
double getTailLengthSeconds() const override { return 0.0; }
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram(int) override {}
const juce::String getProgramName(int) override { return "Program"; }
void changeProgramName(int, const juce::String&) override {}
bool hasEditor() const override { return true; }
juce::AudioProcessorEditor* createEditor() override;
juce::VST3ClientExtensions* getVST3ClientExtensions() override;
//==============================================================================
void prepareToPlay(double sampleRate, int maximumExpectedSamplesPerBlock) override;
void releaseResources() override;
void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&) override;
/** Add or subtract voices if necessary */
void adjustVoiceCount(int count = -1);
//==============================================================================
/** The plugin's state information includes the full APVTS (with non-parameter values) and audio data if a file
reference is not being used.
*/
void getStateInformation(juce::MemoryBlock& destData) override;
void setStateInformation(const void* data, int sizeInBytes) override;
//==============================================================================
/** Loads an audio buffer into the synth, preparing everything for playback. If
resetParameters is true, the plugin's parameters are reset to default, matching
the new sample. Otherwise, the assumption is that the parameters are in a valid state.
Note that this method uses std::move on the sample.
Also, if necessary, set pluginState.filePath before calling this method, so that the editor syncs correctly.
*/
void loadSample(juce::AudioBuffer<float>& sample, int sampleRate, bool resetParameters = true, const juce::String& precomputedHash = "");
//==============================================================================
void recordingStarted() override {}
void recordingFinished(juce::AudioBuffer<float> recordingBuffer, int recordingSampleRate) override;
/** This runs when the pitch detection thread finishes. */
void exitSignalSent() override;
//==============================================================================
int visibleSamples() const;
//==============================================================================
juce::AudioProcessorValueTreeState apvts;
juce::UndoManager undoManager;
PluginParameters::State pluginState;
CustomSynthesizer synth;
/** Note that this is referenced directly by the Editor. As such, it should only be modified in the Message Thread. */
juce::AudioBuffer<float> sampleBuffer;
float bufferSampleRate{ 0.f };
SamplerParameters samplerSound;
/** We manage MAX_VOICES for the duration of the plugin and control how many the synth has access to */
juce::OwnedArray<CustomSamplerVoice> samplerVoices;
juce::CriticalSection voiceLock;
juce::PluginHostType hostType;
std::unique_ptr<juce::FileChooser> fileChooser;
juce::AudioFormatManager formatManager;
juce::WildcardFileFilter fileFilter;
SampleLoader sampleLoader;
juce::String lastLoadAttempt;
/** We use this to notify the editor that the sample was loaded from Reaper, since this should be treated as a user load */
std::atomic<bool> loadedFromReaper{ false };
juce::AudioDeviceManager deviceManager; // Spent an hour debugging because I put this after the DeviceRecorder, and it crashes without a trace. C++ is fun!
std::unique_ptr<juce::XmlElement> deviceManagerLoadedState;
bool deviceManagerLoaded{ false };
DeviceRecorder deviceRecorder;
PitchDetector pitchDetector;
CustomLookAndFeel lookAndFeel;
ReaperVST3Extensions reaperExtensions;
const juce::String REAPER_FILE_PATH{ "P_EXT:FILE" };
MTSClient* mtsClient{ nullptr };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(JustaSampleAudioProcessor)
};

View file

@ -0,0 +1,635 @@
/*
==============================================================================
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<LowpassStream>(2 * LANCZOS_WINDOW_SIZE));
loopLowpass.emplace_back(std::make_unique<LowpassStream>(2 * LANCZOS_WINDOW_SIZE));
endLowpass.emplace_back(std::make_unique<LowpassStream>(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<float>(releaseSmoothing, float(loopEnd - sampleEnd));
crossfade = juce::jmin<float>(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<float> 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>(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<float>& 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<float>::pi * crossfadePosition / crossfade + juce::MathConstants<float>::pi)));
float crossfadeDecrease = float(std::sqrt(0.5f + 0.5f * std::cos(juce::MathConstants<float>::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<float>::pi * crossfadePosition / crossfade + juce::MathConstants<float>::pi)));
float crossfadeDecrease = float(std::sqrt(0.5f + 0.5f * std::cos(juce::MathConstants<float>::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<std::unique_ptr<LowpassStream>>& 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<float>& 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<Distortion>(), sampleSound.distortionEnabled);
break;
case PluginParameters::REVERB:
effects.emplace_back(PluginParameters::REVERB, std::make_unique<Reverb>(), sampleSound.reverbEnabled);
break;
case PluginParameters::CHORUS:
effects.emplace_back(PluginParameters::CHORUS, std::make_unique<Chorus>(expectedBlockSize), sampleSound.chorusEnabled);
break;
case PluginParameters::EQ:
effects.emplace_back(PluginParameters::EQ, std::make_unique<BandEQ>(), 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<std::unique_ptr<LowpassStream>>& 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<float>::pi * x) * std::sin(juce::MathConstants<float>::pi * x / LANCZOS_WINDOW_SIZE) * INVERSE_SIN_SQUARED / (x * x));
}

View file

@ -0,0 +1,262 @@
/*
==============================================================================
CustomSamplerVoice.h
Created: 5 Sep 2023 3:35:03pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "SamplerParameters.h"
#include "Effects/Effect.h"
#include "Stretcher.h"
#include <libMTSClient.h>
/** This enum includes the different states a voice can be in */
enum VoiceState
{
PLAYING, // The voice is still before or during the loop
PLAYING_END, // The voice is continuing after the loop
STOPPED
};
/** The context information for sample by sample processing is stored in its own struct. This
is primarily to allow for easy multichannel processing but also encapsulates the state nicely.
Note that the smoothing variables are an important part of the state transition logic.
*/
struct VoiceContext
{
VoiceState state{ STOPPED };
double currentPosition{ 0 }; // Fractional positions are necessary
bool isSmoothingAttack{ false }; // The initial attack curve
bool isCrossfadingLoop{ false };
bool isCrossfadingEnd{ false }; // Crossfading between looping and the end part of the sample
bool isReleasing{ false }; // Active when the note is released or when it nears the end of the sample
double crossfadeEndPosition{ 0 }; // The current position of the end crossfade
float speedMovedSinceStart{ 0 }; // Used to time the attack envelope, note this is in terms of time passed, not position
float speedMovedSinceRelease{ 0 }; // Used to time the release envelope
int samplesSinceStopped{ 0 }; // This is needed to time the RMS measurements for reverb tail off (since it has a delay)
};
/** This class is used to store the state of the lowpass filter for a channel / stream
Because of our use case, a circular buffer is used to store past samples, large enough for the size of the lanczos window.
*/
class LowpassStream
{
public:
explicit LowpassStream(int bufferSize) : intermediateBuffer(1, bufferSize) {}
/** Reset the processing state of the stream to a new sample position */
void resetProcessing(int nextSampleToProcess)
{
filter1.reset();
filter2.reset();
filter3.reset();
filter4.reset();
bufferLoc = 0;
startSample = nextSampleToProcess;
nextSample = nextSampleToProcess;
}
/** Process a block of samples, storing the recent result in the intermediate buffer.
nextSample is the index of the next sample that should be processed.
*/
void processSamples(const float* samples, int numSamples)
{
for (int i = 0; i < numSamples; ++i)
{
float processedSample = filter1.processSingleSampleRaw(samples[i]);
processedSample = filter2.processSingleSampleRaw(processedSample);
processedSample = filter3.processSingleSampleRaw(processedSample);
processedSample = filter4.processSingleSampleRaw(processedSample);
intermediateBuffer.setSample(0, bufferLoc, processedSample);
nextSample++;
bufferLoc = (bufferLoc + 1) % intermediateBuffer.getNumSamples();
}
}
/** Get the processed sample at a given index. Asserts the sample is contained. */
float getProcessedSample(int sampleIndex) const
{
jassert(sampleIndex >= startSample && sampleIndex < nextSample && sampleIndex >= nextSample - intermediateBuffer.getNumSamples());
int bufferIndex = (sampleIndex - startSample) % intermediateBuffer.getNumSamples();
return intermediateBuffer.getSample(0, bufferIndex);
}
int getNextSample() const { return nextSample; }
/** Following juce::dsp::FilterDesign::designIIRLowpassHighOrderButterworthMethod(), this is theoretically -48db above 20khz */
void setCoefficients(int sampleRate, float frequency)
{
float order = 8.f;
filter1.setCoefficients(juce::IIRCoefficients::makeLowPass(sampleRate, frequency, 1.f / (2.f * std::cos(1.f * juce::MathConstants<float>::pi / (order * 2.f)))));
filter2.setCoefficients(juce::IIRCoefficients::makeLowPass(sampleRate, frequency, 1.f / (2.f * std::cos(3.f * juce::MathConstants<float>::pi / (order * 2.f)))));
filter3.setCoefficients(juce::IIRCoefficients::makeLowPass(sampleRate, frequency, 1.f / (2.f * std::cos(5.f * juce::MathConstants<float>::pi / (order * 2.f)))));
filter4.setCoefficients(juce::IIRCoefficients::makeLowPass(sampleRate, frequency, 1.f / (2.f * std::cos(7.f * juce::MathConstants<float>::pi / (order * 2.f)))));
}
int getStartSample() const { return startSample; }
private:
juce::SingleThreadedIIRFilter filter1;
juce::SingleThreadedIIRFilter filter2;
juce::SingleThreadedIIRFilter filter3;
juce::SingleThreadedIIRFilter filter4;
juce::AudioBuffer<float> intermediateBuffer;
int startSample{ 0 };
int bufferLoc{ 0 }; // Location in the buffer to write to (circular buffer)
int nextSample{ 0 }; // The next sample to be processed
};
/** This struct serves to separate per instance enablement of effects from the effect classes themselves */
struct Fx
{
Fx(PluginParameters::FxTypes fxType, std::unique_ptr<Effect> fx, juce::AudioParameterBool* enablementSource) :
fxType(fxType), fx(std::move(fx)), enablementSource(enablementSource) {}
PluginParameters::FxTypes fxType;
std::unique_ptr<Effect> fx;
juce::AudioParameterBool* enablementSource;
bool enabled{ false };
bool locallyDisabled{ false }; // used to avoid empty processing
};
//==============================================================================
/** The CustomSamplerVoice is the main DSP logic of this plugin. It can pitch shift directly or integrate with a 3rd party
algorithm. It supports antialiasing, an FX chain, different looping modes, attack and release, and smooth crossfading.
*/
class CustomSamplerVoice final : public juce::SynthesiserVoice
{
public:
CustomSamplerVoice(const SamplerParameters& samplerSound, MTSClient* client, double applicationSampleRate, int expectedBlockSize, bool initSample = true);
/** For general convenience, we'd like to be able to initialize all voices at plugin start */
void initializeSample();
/** Updates the speed and pitch, setting stretchers and filter cutoffs correctly.
Before calling this the first time, set doLowpass = false so that it resets the lowpass filters.
*/
void updateSpeedAndPitch(int currentNote, int pitchWheelPosition);
//==============================================================================
/** Returns whether the voice is actively playing (not stopped or tailing off) */
bool isPlaying() const { return getCurrentlyPlayingSound() && vc.state != STOPPED; }
/** This is the condition for wavetable mode */
static bool isWavetableModeAvailable(float sampleRate, int sampleStart, int sampleEnd)
{
return float(sampleRate) / (sampleEnd - sampleStart + 1) > PluginParameters::WAVETABLE_CUTOFF_HZ;
}
/** Get the effective location of the sampler voice relative to the original sample, not precise in ADVANCED mode */
double getPosition() const { return vc.currentPosition; }
/** Get the current gain of the voice in the attack and release envelopes, for visualization */
float getEnvelopeGain() const;
/** x should be [0, 1] */
static const float exponentialCurve(float a, float x) { return juce::approximatelyEqual(a, 0.f, juce::Tolerance<float>().withAbsolute(0.001f)) ? x : (std::exp(a * x) - 1) / (std::exp(a) - 1); }
void stopNote(float velocity, bool allowTailOff) override;
void immediateHalt();
private:
bool canPlaySound(juce::SynthesiserSound*) override { return true; }
void startNote(int midiNoteNumber, float velocity, juce::SynthesiserSound* sound, int currentPitchWheelPosition) override;
void pitchWheelMoved(int newPitchWheelValue) override;
void controllerMoved(int, int) override {}
void renderNextBlock(juce::AudioBuffer<float>& outputBuffer, int startSample, int numSamples) override;
//==============================================================================
/** Fetch a sample at a given position, in BASIC mode.
Provide a vector of lowpass streams to apply lowpass filtering before interpolation (if doLowpass).
This is necessary to avoid frequencies going above the Nyquist frequency.
*/
float fetchSample(int channel, double position, std::vector<std::unique_ptr<LowpassStream>>& lowpassStreams) const;
/** Fetches the next sample from a stretcher, in ADVANCED mode. Note that on channel 0, the stretcher
advances and stores the other channels' output in the channel buffer at index i. Then it's fetched
from there when nextSample is called with the later channel.
*/
float nextSample(int channel, BungeeStretcher* stretcher, juce::AudioBuffer<float>& channelBuffer, int i) const;
/** Use a Lanczos kernel to calculate fractional sample indices. Applies a lowpass filter beforehand, if doLowpass. */
float lanczosInterpolate(int channel, double position, std::vector<std::unique_ptr<LowpassStream>>& lowpassStreams) const;
inline static float lanczosWindow(double x);
static constexpr int LANCZOS_WINDOW_SIZE{ 5 };
/** Initialize or updates (by reinitializing) the effect chain. This is not real-time safe, but I don't think reordering needs to be. */
void initializeFx();
//==============================================================================
int expectedBlockSize;
const SamplerParameters& sampleSound;
float sampleRateConversion{ 0 }; // Loaded sample rate / application sample rate
float speed{ 0 }; // Used in BASIC mode
int effectiveStart{ 0 };
int effectiveEnd{ 0 };
/** "Wavetable mode" activates when the bounds are very short and can act as a waveform cycle. */
bool wavetableMode{ false };
// Unchanging sampler sound parameters
PluginParameters::PLAYBACK_MODES playbackMode{ PluginParameters::PLAYBACK_MODES::BASIC };
float tuning{ 0.f };
int pitchWheel{ 0 };
float speedFactor{ 0.f }; // Used in ADVANCED mode
float noteVelocity{ 0.f };
bool playUntilEnd{ false };
bool isLooping{ false }, loopingHasStart{ false }, loopingHasEnd{ false };
int sampleStart{ 0 }, sampleEnd{ 0 }, loopStart{ 0 }, loopEnd{ 0 };
/** We call this "smoothing" but it's a pretty normal attack/release envelope. */
float attackSmoothing{ 0.f }, releaseSmoothing{ 0.f };
float attackShape{ 0.f }, releaseShape{ 0.f };
float crossfade{ 0.f };
VoiceContext vc;
bool midiReleased{ false };
juce::AudioBuffer<float> tempOutputBuffer;
juce::AudioBuffer<float> envelopeBuffer; // To enable the PRE_FX option, we store the envelope gain here before applying
static constexpr int TAIL_OFF = 50;
int tailOff{ 0 };
juce::AudioBuffer<float> tailOffBuffer; // To avoid clicks on voice-stealing, we render a tail
BungeeStretcher mainStretcher;
BungeeStretcher loopStretcher;
BungeeStretcher endStretcher;
// Since the stretchers process channels together, buffers are needed to store the output
juce::AudioBuffer<float> mainStretcherBuffer;
juce::AudioBuffer<float> loopStretcherBuffer;
juce::AudioBuffer<float> endStretcherBuffer;
bool doLowpass{ false };
std::vector<std::unique_ptr<LowpassStream>> mainLowpass;
std::vector<std::unique_ptr<LowpassStream>> loopLowpass;
std::vector<std::unique_ptr<LowpassStream>> endLowpass;
//==============================================================================
bool doFxTailOff{ false };
static constexpr int UPDATE_PARAMS_LENGTH{ 4 }; // After how many process calls should we query for FX params
int updateFXParamsTimer{ 0 };
std::vector<Fx> effects;
MTSClient* mtsClient{ nullptr };
};
static constexpr float INVERSE_SIN_SQUARED{ 1.f / (juce::MathConstants<float>::pi * juce::MathConstants<float>::pi) };

View file

@ -0,0 +1,31 @@
/*
==============================================================================
CustomSynthesizer.h
Created: 9 Jun 2024 10:37:38am
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
/** JUCE's voice and sound paradigm is not so helpful for us, so we use a blank sound class and pass in our parameters directly to the voices. */
class BlankSynthesizerSound final : public juce::SynthesiserSound
{
public:
bool appliesToNote(int) override { return true; }
bool appliesToChannel(int) override { return true; }
};
/** We add some custom methods because our Synthesizer does not own its voices */
class CustomSynthesizer final : public juce::Synthesiser
{
public:
juce::SynthesiserVoice* removeVoiceWithoutDeleting(const int index)
{
const juce::ScopedLock sl(lock);
return voices.removeAndReturn(index);
}
};

View file

@ -0,0 +1,98 @@
/*
==============================================================================
BandEQ.h
Created: 2 Jan 2024 5:02:27pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "Effect.h"
/** This is a sample 3-band EQ effect, inspired by the Kiloheart's free 3-Band EQ plugin. */
class BandEQ final : public Effect
{
public:
void initialize(int numChannels, int fxSampleRate) override
{
sampleRate = fxSampleRate;
juce::dsp::ProcessSpec spec{};
spec.numChannels = numChannels;
spec.sampleRate = sampleRate;
filterChain.reset();
filterChain.prepare(spec);
}
void updateParams(float lowFreq, float highFreq, float lowGain, float midGain, float highGain)
{
// This possibly has an issue where the frequencies are outside of range on plugin initialization
auto coeffLow = juce::dsp::IIR::Coefficients<float>::makeLowShelf(sampleRate, lowFreq, Q, juce::Decibels::decibelsToGain(lowGain));
auto coeffMid1 = juce::dsp::IIR::Coefficients<float>::makeHighShelf(sampleRate, lowFreq, Q, juce::Decibels::decibelsToGain(midGain));
auto coeffMid2 = juce::dsp::IIR::Coefficients<float>::makeHighShelf(sampleRate, highFreq, Q, juce::Decibels::decibelsToGain(-midGain));
auto coeffHigh = juce::dsp::IIR::Coefficients<float>::makeHighShelf(sampleRate, highFreq, Q, juce::Decibels::decibelsToGain(highGain));
*filterChain.get<0>().state = *coeffLow;
*filterChain.get<1>().state = *coeffMid1;
*filterChain.get<2>().state = *coeffMid2;
*filterChain.get<3>().state = *coeffHigh;
}
void updateParams(const SamplerParameters& samplerSound, bool) override
{
updateParams(
samplerSound.eqLowFreq->get(), samplerSound.eqHighFreq->get(),
samplerSound.eqLowGain->get(), samplerSound.eqMidGain->get(), samplerSound.eqHighGain->get()
);
}
void process(juce::AudioBuffer<float>& buffer, int numSamples, int startSample = 0) override
{
juce::dsp::AudioBlock block{ buffer.getArrayOfWritePointers(), size_t(buffer.getNumChannels()), size_t(startSample), size_t(numSamples) };
juce::dsp::ProcessContextReplacing context{ block };
filterChain.process(context);
}
juce::Array<double> getMagnitudeForFrequencyArray(juce::Array<double> frequencies)
{
std::array filters{
&filterChain.get<0>(),
&filterChain.get<1>(),
&filterChain.get<2>(),
&filterChain.get<3>()
};
juce::AudioBuffer<double> magnitudes{1, frequencies.size()};
magnitudes.clear();
bool empty{ true };
for (const auto& filter : filters)
{
juce::AudioBuffer<double> temp{ 1, frequencies.size()};
filter->state->getMagnitudeForFrequencyArray(frequencies.getRawDataPointer(), temp.getWritePointer(0), frequencies.size(), 48000);
if (empty)
{
magnitudes.addFrom(0, 0, temp.getReadPointer(0), frequencies.size());
empty = false;
}
else
{
for (int i = 0; i < temp.getNumSamples(); i++)
magnitudes.setSample(0, i, magnitudes.getSample(0, i) * temp.getSample(0, i));
}
}
return juce::Array<double>{magnitudes.getReadPointer(0), frequencies.size()};
}
private:
using Filter = juce::dsp::ProcessorDuplicator<juce::dsp::IIR::Filter<float>, juce::dsp::IIR::Coefficients<float>>;
using FilterChain = juce::dsp::ProcessorChain<Filter, Filter, Filter, Filter>;
static constexpr float Q{ 0.6f }; // Magic number I saw online for the response curve
int sampleRate{ 0 };
FilterChain filterChain;
};

View file

@ -0,0 +1,57 @@
/*
==============================================================================
Chorus.h
Created: 4 Jan 2024 7:22:36pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "Effect.h"
/** This is a simple wrapper around the JUCE Chorus class */
class Chorus final : public Effect
{
public:
explicit Chorus(int expectedBlockSize=MAX_BLOCK_SIZE) : expectedBlockSize(expectedBlockSize) {}
void initialize(int numChannels, int fxSampleRate) override
{
juce::dsp::ProcessSpec processSpec{ double(fxSampleRate), juce::uint32(expectedBlockSize), juce::uint32(numChannels) };
chorus.reset();
chorus.prepare(processSpec);
}
void updateParams(const SamplerParameters& sampleSound, bool realtime) override
{
chorus.setRate(sampleSound.chorusRate->get());
chorus.setDepth(sampleSound.chorusDepth->get());
chorus.setFeedback(sampleSound.chorusFeedback->get());
if (!realtime) // Center delay cannot be modulated
chorus.setCentreDelay(juce::jmin<float>(sampleSound.chorusCenterDelay->get(), 99.9f));
chorus.setMix(sampleSound.chorusMix->get());
}
void process(juce::AudioBuffer<float>& buffer, int numSamples, int startSample = 0) override
{
while (numSamples > 0)
{
juce::dsp::AudioBlock<float> block{ buffer.getArrayOfWritePointers(), size_t(buffer.getNumChannels()), size_t(startSample), size_t(juce::jmin(MAX_BLOCK_SIZE, numSamples)) };
juce::dsp::ProcessContextReplacing<float> context{ block };
chorus.process(context);
startSample += MAX_BLOCK_SIZE;
numSamples -= MAX_BLOCK_SIZE;
}
}
private:
static constexpr int MAX_BLOCK_SIZE{ 1024 };
juce::dsp::Chorus<float> chorus{};
int expectedBlockSize;
};

View file

@ -0,0 +1,73 @@
/*
==============================================================================
Distortion.h
Created: 30 Dec 2023 8:39:04pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "Effect.h"
#include <gin_distortion.h>
/** This is a simple Distortion class that wraps around gin::AirWindowsDistortion */
class Distortion final : public Effect
{
public:
void initialize(int numChannels, int fxSampleRate) override
{
int numEffects = numChannels / 2 + numChannels % 2;
channelDistortions.resize(numEffects);
for (int ch = 0; ch < numEffects; ch++)
{
channelDistortions[ch] = std::make_unique<gin::AirWindowsDistortion>();
channelDistortions[ch]->setSampleRate(fxSampleRate);
}
}
void updateParams(float density, float highpass, float mix)
{
float mappedDensity = density >= 0.f ? juce::jmap<float>(density, 0.2f, 1.f) : juce::jmap<float>(density, -0.5f, 0.f, 0.f, 0.2f);
// We try to keep the gain of the distortion constant:
// This is a sigmoid function found manually from graphing the output of the distortion. When the mapped density < 0.2f,
// a different function needs to be used, since the distortion actually behaves differently according to that threshold.
// Note that the gain parameter only applies if it's less than 1.f, so we need to increase the gain ourselves after processing.
float gainChange = mappedDensity >= 0.2f ? (0.2f * (1.f + expf(-7.f * (mappedDensity - 0.5f)))) : 1.f;
postGain = mappedDensity < 0.2f ? 1.f / (4.f * mappedDensity + 0.2f) : 1.f;
for (const auto& channelDistortion : channelDistortions)
channelDistortion->setParams(mappedDensity, highpass, gainChange, mix);
}
void updateParams(const SamplerParameters& sampleSound, bool) override
{
updateParams(
sampleSound.distortionDensity->get(),
sampleSound.distortionHighpass->get(),
sampleSound.distortionMix->get()
);
}
void process(juce::AudioBuffer<float>& buffer, int numSamples, int startSample=0) override
{
bool lastIsMono = buffer.getNumChannels() % 2 == 1;
for (int ch = 0; ch < buffer.getNumChannels(); ch += 2)
{
// Note that I modified the gin header to make this more straightforward
if (lastIsMono && ch == buffer.getNumChannels() - 1)
channelDistortions[ch / 2]->process(buffer.getWritePointer(ch, startSample), buffer.getWritePointer(ch, startSample), numSamples);
else
channelDistortions[ch / 2]->process(buffer.getWritePointer(ch, startSample), buffer.getWritePointer(ch + 1, startSample), numSamples);
}
buffer.applyGain(startSample, numSamples, postGain);
}
private:
std::vector<std::unique_ptr<gin::AirWindowsDistortion>> channelDistortions;
float postGain{ 0. };
};

View file

@ -0,0 +1,23 @@
/*
==============================================================================
Effect.h
Created: 30 Dec 2023 8:35:28pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../SamplerParameters.h"
class Effect
{
public:
virtual ~Effect() = default;
virtual void initialize(int numChannels, int fxSampleRate) = 0;
virtual void updateParams(const SamplerParameters& sampleSound, bool modulating = false) = 0;
virtual void process(juce::AudioBuffer<float>& buffer, int numSamples, int startSample=0) = 0;
};

View file

@ -0,0 +1,85 @@
/*
==============================================================================
Reverb.h
Created: 30 Dec 2023 8:38:55pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "Effect.h"
#include <gin_simpleverb.h>
/** This is a simple Effect class that wraps around Gin's SimpleVerb implementation */
class Reverb final : public Effect
{
public:
Reverb() = default;
void initialize(int numChannels, int fxSampleRate) override
{
int numEffects = numChannels / 2 + numChannels % 2;
channelGinReverbs.resize(numEffects);
for (int ch = 0; ch < numEffects; ch++)
{
channelGinReverbs[ch] = std::make_unique<gin::SimpleVerb>();
channelGinReverbs[ch]->setSampleRate(float(fxSampleRate));
channelGinReverbs[ch]->setParameters(0.f, 0.f, 1.f, 0.f, 1.f, 0.f, 0.f);
}
}
// The intended ranges of these values are in PluginParameters.h
void updateParams(float size, float damping, float predelay, float lows, float highs, float mix) const
{
for (const auto& channelGinReverb : channelGinReverbs)
{
channelGinReverb->setParameters(
juce::jmap<float>(size, PluginParameters::REVERB_SIZE_RANGE.getStart(), PluginParameters::REVERB_SIZE_RANGE.getEnd(), 0.f, 1.f),
juce::jmap<float>(damping, PluginParameters::REVERB_DAMPING_RANGE.getStart(), PluginParameters::REVERB_DAMPING_RANGE.getEnd(), 0.f, 1.f),
float(sqrtf(predelay / 250.f)), // conversions to counteract the faders in this algorithm
juce::jmap<float>(highs, PluginParameters::REVERB_HIGHS_RANGE.getStart(), PluginParameters::REVERB_HIGHS_RANGE.getEnd(), 0.3f, 1.f),
1.f - juce::jmap<float>(lows, PluginParameters::REVERB_LOWS_RANGE.getStart(), PluginParameters::REVERB_LOWS_RANGE.getEnd(), 0.3f, 1.f),
mix,
(1.f - mix) / 2.f
);
}
}
void updateParams(const SamplerParameters& sampleSound, bool) override
{
updateParams(
sampleSound.reverbSize->get(),
sampleSound.reverbDamping->get(),
sampleSound.reverbPredelay->get(),
sampleSound.reverbLows->get(),
sampleSound.reverbHighs->get(),
sampleSound.reverbMix->get()
);
}
void process(juce::AudioBuffer<float>& buffer, int numSamples, int startSample = 0) override
{
bool lastIsMono = buffer.getNumChannels() % 2 == 1;
for (int ch = 0; ch < buffer.getNumChannels(); ch += 2)
{
// I modified the header of the process method to work easier with this code, you'll need to do the same to get it to compile
// void SimpleVerb::process (const float* in1, const float* in2, float* out1, float* out2, int numSamples)
if (lastIsMono && ch == buffer.getNumChannels() - 1)
channelGinReverbs[ch / 2]->process(
buffer.getReadPointer(ch, startSample), buffer.getReadPointer(ch, startSample),
buffer.getWritePointer(ch, startSample), buffer.getWritePointer(ch, startSample), numSamples);
else
channelGinReverbs[ch / 2]->process(
buffer.getReadPointer(ch, startSample), buffer.getReadPointer(ch + 1, startSample),
buffer.getWritePointer(ch, startSample), buffer.getWritePointer(ch + 1, startSample), numSamples);
}
}
private:
std::vector<std::unique_ptr<gin::SimpleVerb>> channelGinReverbs;
};

View file

@ -0,0 +1,94 @@
/*
==============================================================================
CustomSamplerSound.cpp
Created: 5 Sep 2023 3:35:11pm
Author: binya
==============================================================================
*/
#include "SamplerParameters.h"
SamplerParameters::SamplerParameters(const juce::AudioProcessorValueTreeState& apvts, PluginParameters::State& pluginState, const juce::AudioBuffer<float>& sample, int sampleRate) :
sample(sample), sampleRate(sampleRate),
gain(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::SAMPLE_GAIN))),
speedFactor(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::SPEED_FACTOR))),
octaveSpeedFactor(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::OCTAVE_SPEED_FACTOR))),
attack(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::ATTACK))),
release(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::RELEASE))),
attackShape(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::ATTACK_SHAPE))),
releaseShape(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::RELEASE_SHAPE))),
a4_freq(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::A4_HZ))),
pitchWheelRange(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::PITCH_WHEEL_RANGE))),
wideTuningControl(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::WIDE_TUNING))),
semitoneTuning(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::SEMITONE_TUNING))),
centTuning(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::CENT_TUNING))),
waveformSemitoneTuning(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::WAVEFORM_SEMITONE_TUNING))),
waveformCentTuning(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::WAVEFORM_CENT_TUNING))),
crossfadeSamples(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::CROSSFADE_SAMPLES))),
monoOutput(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::MONO_OUTPUT))),
disableVelocity(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::DISABLE_VELOCITY))),
skipAntialiasing(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::SKIP_ANTIALIASING))),
applyFXPre(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::PRE_FX))),
playUntilEnd(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::PLAY_UNTIL_END))),
disableWavetableMode(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::DISABLE_WAVETABLE_MODE))),
isLooping(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::IS_LOOPING))),
loopingHasStart(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::LOOPING_HAS_START))),
loopingHasEnd(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::LOOPING_HAS_END))),
sampleStart(pluginState.sampleStart), sampleEnd(pluginState.sampleEnd),
loopStart(pluginState.loopStart), loopEnd(pluginState.loopEnd),
midiStart(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::MIDI_START))),
midiEnd(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::MIDI_END))),
midiRoot(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::MIDI_ROOT))),
followMidiPitch(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::FOLLOW_MIDI_PITCH))),
reverbEnabled(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::REVERB_ENABLED))),
distortionEnabled(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::DISTORTION_ENABLED))),
eqEnabled(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::EQ_ENABLED))),
chorusEnabled(dynamic_cast<juce::AudioParameterBool*>(apvts.getParameter(PluginParameters::CHORUS_ENABLED))),
reverbMix(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::REVERB_MIX))),
reverbSize(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::REVERB_SIZE))),
reverbDamping(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::REVERB_DAMPING))),
reverbLows(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::REVERB_LOWS))),
reverbHighs(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::REVERB_HIGHS))),
reverbPredelay(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::REVERB_PREDELAY))),
distortionMix(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::DISTORTION_MIX))),
distortionDensity(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::DISTORTION_DENSITY))),
distortionHighpass(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::DISTORTION_HIGHPASS))),
eqLowGain(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::EQ_LOW_GAIN))),
eqMidGain(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::EQ_MID_GAIN))),
eqHighGain(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::EQ_HIGH_GAIN))),
eqLowFreq(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::EQ_LOW_FREQ))),
eqHighFreq(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::EQ_HIGH_FREQ))),
chorusMix(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::CHORUS_MIX))),
chorusRate(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::CHORUS_RATE))),
chorusDepth(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::CHORUS_DEPTH))),
chorusFeedback(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::CHORUS_FEEDBACK))),
chorusCenterDelay(dynamic_cast<juce::AudioParameterFloat*>(apvts.getParameter(PluginParameters::CHORUS_CENTER_DELAY))),
playbackMode(dynamic_cast<juce::AudioParameterChoice*>(apvts.getParameter(PluginParameters::PLAYBACK_MODE))),
fxOrder(dynamic_cast<juce::AudioParameterInt*>(apvts.getParameter(PluginParameters::FX_PERM)))
{
}
void SamplerParameters::sampleChanged(const int newSampleRate)
{
sampleRate = newSampleRate;
}
PluginParameters::PLAYBACK_MODES SamplerParameters::getPlaybackMode() const
{
return PluginParameters::getPlaybackMode(playbackMode->getIndex());
}
std::array<PluginParameters::FxTypes, 4> SamplerParameters::getFxOrder() const
{
return PluginParameters::paramToPerm(fxOrder->get());
}

View file

@ -0,0 +1,53 @@
/*
==============================================================================
CustomSamplerSound.h
Created: 5 Sep 2023 3:35:11pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../PluginParameters.h"
/** A class defining all parameters for a note played by CustomSamplerVoice.cpp */
class SamplerParameters final
{
public:
SamplerParameters(const juce::AudioProcessorValueTreeState& apvts, PluginParameters::State& pluginState, const juce::AudioBuffer<float>& sample, int sampleRate);
void sampleChanged(int newSampleRate);
/** Fetch the playback mode, as the proper enum type */
PluginParameters::PLAYBACK_MODES getPlaybackMode() const;
/** Fetch the sound's FX chain permutation */
std::array<PluginParameters::FxTypes, 4> getFxOrder() const;
/** The sound to play */
const juce::AudioBuffer<float>& sample;
int sampleRate;
/** 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;
ListenableAtomic<int>& sampleStart, & sampleEnd, & loopStart, & loopEnd;
juce::AudioParameterInt* midiStart, * midiEnd, * midiRoot;
juce::AudioParameterBool* followMidiPitch;
/** FX parameters */
juce::AudioParameterBool* reverbEnabled, * distortionEnabled, * eqEnabled, * chorusEnabled;
juce::AudioParameterFloat* reverbMix, * reverbSize, * reverbDamping, * reverbLows, * reverbHighs, * reverbPredelay;
juce::AudioParameterFloat* distortionMix, * distortionDensity, * distortionHighpass;
juce::AudioParameterFloat* eqLowGain, * eqMidGain, * eqHighGain, * eqLowFreq, * eqHighFreq;
juce::AudioParameterFloat* chorusMix, * chorusRate, * chorusDepth, * chorusFeedback, * chorusCenterDelay;
private:
juce::AudioParameterChoice* playbackMode;
juce::AudioParameterInt* fxOrder;
};

161
Source/Sampler/Stretcher.h Normal file
View file

@ -0,0 +1,161 @@
/*
==============================================================================
Stretcher.h
Created: 27 May 2024 4:09:49pm
Author: binya
==============================================================================
*/
#pragma once
#include <Bungee.h>
// Bungee sets a hard limit on the pitch ratio to simplify memory management. We can increase this limit before building
// and use a resampling hack when necessary (the hack is not great because it requires reallocation of the stretcher).
// This must be set to the value in Timing.cpp (internal to Bungee)
static constexpr int bungeeMaxPitchOctaves = BUNGEE_MAX_OCTAVES;
static constexpr float bungeeMinimumRatio = 1.f / (1 << bungeeMaxPitchOctaves);
class BungeeStretcher
{
public:
explicit BungeeStretcher(const juce::AudioBuffer<float>& sampleBuffer, int sampleRate) : buffer(&sampleBuffer),
bufferSampleRate(sampleRate)
{
}
/** Allocates a new stretcher and the input buffer. */
void preallocateStretcher(int appSampleRate)
{
if (appSampleRate == 0 || appSampleRate == applicationSampleRate)
return;
bungee = std::make_unique<Bungee::Stretcher<Bungee::Basic>>(Bungee::SampleRates{ bufferSampleRate, appSampleRate }, buffer->getNumChannels());
inputData.setSize(1, buffer->getNumChannels() * bungee->maxInputFrameCount(), false, false, true); // Note, maxInputFrameCount has a reported overflow issue
previousInputRate = bufferSampleRate;
applicationSampleRate = appSampleRate;
}
void initialize(long double sampleStart, float initialRatio = 1, float initialSpeed = 1)
{
setPitchAndSpeed(initialRatio, initialSpeed);
// We only reallocate when necessary (if resamplingHack requires it, as it's not good for real-time performance)
int inputRate = int(bufferSampleRate / resamplingHack);
if (inputRate != previousInputRate)
{
bungee = std::make_unique<Bungee::Stretcher<Bungee::Basic>>(Bungee::SampleRates{ inputRate, applicationSampleRate }, buffer->getNumChannels());
inputData.setSize(1, buffer->getNumChannels() * bungee->maxInputFrameCount(), false, false, true); // maxInputFrameCount has a reported overflow issue
previousInputRate = inputRate;
}
output = Bungee::OutputChunk{};
outputIndex = 0;
preroll(double(sampleStart));
}
/** Pre-rolls the stretcher to a new position. Use this before you plan to move the position. */
void preroll(double newPosition)
{
request = Bungee::Request{ newPosition, speedFactor * resamplingHack, pitchRatio, true };
bungee->preroll(request);
while (!output.data || std::isnan(output.request[Bungee::OutputChunk::begin]->position) ||
newPosition > output.request[Bungee::OutputChunk::end]->position || newPosition < output.request[Bungee::OutputChunk::begin]->position)
{
auto input = bungee->specifyGrain(request);
if (buffer->getNumChannels() * (input.end - input.begin) >= inputData.getNumSamples()) // Can happen with extreme ratios
inputData.setSize(1, buffer->getNumChannels() * (input.end - input.begin));
int begin = juce::jlimit<int>(int(std::ceil(newPosition)), buffer->getNumSamples(), input.begin);
int end = juce::jlimit<int>(int(std::ceil(newPosition)), buffer->getNumSamples(), input.end);
inputData.clear();
if (begin < end)
{
for (int ch = 0; ch < buffer->getNumChannels(); ch++)
inputData.copyFrom(0, ch * (input.end - input.begin) + begin - input.begin, *buffer, ch, begin, end - begin);
}
bungee->analyseGrain(inputData.getReadPointer(0), (input.end - input.begin));
bungee->synthesiseGrain(output);
bungee->next(request);
outputIndex = int(std::round((newPosition - output.request[Bungee::OutputChunk::begin]->position) / positionSpeed));
}
}
/** Fetches the next sample. Call this for each channel before advancing. */
float nextSample(int channel, bool advance = true)
{
if (outputIndex >= output.frameCount)
{
request.pitch = pitchRatio;
request.speed = speedFactor * resamplingHack;
auto [begin, end] = bungee->specifyGrain(request);
begin = juce::jlimit<int>(0, buffer->getNumSamples(), begin);
end = juce::jlimit<int>(0, buffer->getNumSamples(), end);
inputData.clear(); // Preferring simplicity over maximum efficiency
for (int ch = 0; ch < buffer->getNumChannels(); ch++)
inputData.copyFrom(0, ch * (end - begin), *buffer, ch, begin, end - begin);
bungee->analyseGrain(inputData.getReadPointer(0), end - begin);
bungee->synthesiseGrain(output);
bungee->next(request);
outputIndex = 0;
}
float result = output.data[channel * output.channelStride + outputIndex];
if (advance)
outputIndex++;
return result;
}
void setPitchAndSpeed(float newPitchRatio, float newSpeedFactor)
{
pitchRatio = newPitchRatio;
speedFactor = newSpeedFactor;
if (newPitchRatio < bungeeMinimumRatio)
{
pitchRatio = bungeeMinimumRatio;
resamplingHack = bungeeMinimumRatio / newPitchRatio;
}
else
{
resamplingHack = 1.f;
}
positionSpeed = speedFactor * bufferSampleRate / applicationSampleRate;
}
/** Returns the speed at which the stretcher advances through the buffer, relative to the buffer's sample rate. */
float getPositionSpeed() const { return positionSpeed; }
private:
const juce::AudioBuffer<float>* buffer{ nullptr };
int bufferSampleRate{ 0 };
int applicationSampleRate{ 0 };
float pitchRatio{ 1. };
float speedFactor{ 1. };
float positionSpeed{ 0. }; // speedFactor * bufferSampleRate / applicationSampleRate
// We use a little hack to ignore Bungee's maxPitchOctaves limit
float resamplingHack{ 1.f };
int previousInputRate{ 0 };
std::unique_ptr<Bungee::Stretcher<Bungee::Basic>> bungee;
Bungee::Request request{};
Bungee::OutputChunk output{};
juce::AudioBuffer<float> inputData;
int outputIndex{ 0 };
};

View file

@ -0,0 +1,131 @@
/*
==============================================================================
BufferUtils.h
Created: 19 May 2024 7:46:31pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
/** Given a source and destination buffer with different number of channels, mix channels appropriately to
get a reasonable output. If mixMono is true, all channels are mixed together to mono.
*/
inline void mixToBuffer(const juce::AudioBuffer<float>& src, juce::AudioBuffer<float>& dest, int startSample, int numSamples, bool mixMono)
{
if (mixMono)
{
// Mix all source channels to mono
for (int ch = 0; ch < src.getNumChannels(); ch++)
{
for (int outputCh = 0; outputCh < dest.getNumChannels(); outputCh++)
{
dest.addFrom(outputCh, startSample, src.getReadPointer(ch), numSamples, 1.f / float(src.getNumChannels()));
}
}
}
else if (dest.getNumChannels() < src.getNumChannels())
{
// Down mix from more source channels to fewer destination channels
float ratio = float(src.getNumChannels()) / dest.getNumChannels();
for (int outputCh = 0; outputCh < dest.getNumChannels(); outputCh++)
{
int startCh = int(outputCh * ratio);
int endCh = int((outputCh + 1) * ratio);
for (int ch = startCh; ch < endCh; ch++)
{
dest.addFrom(outputCh, startSample, src.getReadPointer(ch), numSamples, 1.f / (endCh - startCh));
}
}
}
else if (dest.getNumChannels() >= src.getNumChannels())
{
// Up mix from fewer source channels to more destination channels
float ratio = float(dest.getNumChannels()) / src.getNumChannels();
for (int inputCh = 0; inputCh < src.getNumChannels(); inputCh++)
{
int startCh = int(inputCh * ratio);
int endCh = int((inputCh + 1) * ratio);
for (int outputCh = startCh; outputCh < endCh; outputCh++)
{
dest.addFrom(outputCh, startSample, src.getReadPointer(inputCh), numSamples);
}
}
}
}
/**
Silences the buffer if bad or loud values are detected in the output buffer.
Use this during debugging to avoid blowing out your eardrums on headphones.
If the output value is out of the range [-1, +1] it will be hard clipped.
Credit to https://gist.github.com/hollance/b7219e49c6fc16fa2af05c4a72c186ef for this quick solution.
*/
inline void protectYourEars(float* buffer, int sampleCount)
{
if (buffer == nullptr) { return; }
bool firstWarning = true;
for (int i = 0; i < sampleCount; ++i)
{
float x = buffer[i];
bool silence = false;
if (std::isnan(x))
{
DBG("!!! WARNING: nan detected in audio buffer, silencing !!!");
silence = true;
}
else if (std::isinf(x))
{
DBG("!!! WARNING: inf detected in audio buffer, silencing !!!");
silence = true;
}
else if (x < -2.0f || x > 2.0f) // screaming feedback
{
DBG("!!! WARNING: sample out of range, silencing !!!");
silence = true;
}
else if (x < -1.0f)
{
if (firstWarning)
{
DBG("!!! WARNING: sample out of range, clamping !!!");
firstWarning = false;
}
buffer[i] = -1.0f;
}
else if (x > 1.0f)
{
if (firstWarning)
{
DBG("!!! WARNING: sample out of range, clamping !!!");
firstWarning = false;
}
buffer[i] = 1.0f;
}
if (silence)
{
memset(buffer, 0, sampleCount * sizeof(float));
return;
}
}
}
/** Uses MD-5 hashing to generate an identifier for the AudioBuffer */
inline juce::String getSampleHash(const juce::AudioBuffer<float>& buffer)
{
juce::MemoryBlock memoryBlock;
for (int channel = 0; channel < buffer.getNumChannels(); ++channel)
{
memoryBlock.append(buffer.getReadPointer(channel), buffer.getNumSamples() * sizeof(float));
}
juce::MD5 md5{ memoryBlock };
return md5.toHexString();
}

View file

@ -0,0 +1,243 @@
/*
==============================================================================
CustomComponent.h
Created: 26 Sep 2023 11:49:53pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "../CustomLookAndFeel.h"
// I think I can justify using this one globally. Note that using an entire namespace globally is generally frowned upon.
using APVTS = juce::AudioProcessorValueTreeState;
/** This class is for components which hold a custom help text display.
The idea is for the Editor to react to mouse move and drag events on all child components. In addition, children can send
help text changed updates to the Editor when necessary.
*/
class CustomHelpTextDisplay
{
public:
virtual ~CustomHelpTextDisplay() = default;
virtual void helpTextChanged(const juce::String& newText) = 0;
};
/** JUCE's default help text functionality is not adequate for the help text label I want to display.
This class helps add custom help text functionality to a component.
*/
class CustomHelpTextProvider
{
public:
explicit CustomHelpTextProvider(juce::Component* associatedComponent, CustomHelpTextDisplay* helpTextDisplay = nullptr) :
component(associatedComponent),
textDisplay(helpTextDisplay) {}
virtual ~CustomHelpTextProvider() = default;
virtual juce::String getCustomHelpText()
{
return component->getHelpText();
}
virtual void setCustomHelpText(const juce::String& helpText)
{
bool changed = helpText != component->getHelpText();
component->setHelpText(helpText);
if (changed)
sendHelpTextUpdate();
}
void sendHelpTextUpdate(bool checkMouseOver = true)
{
if ((component->isMouseOverOrDragging() || !checkMouseOver) && textDisplay)
textDisplay->helpTextChanged(getCustomHelpText());
}
private:
juce::Component* component{ nullptr };
CustomHelpTextDisplay* textDisplay{ nullptr };
};
/** This is a way to store the theme in the top level component of our plugin */
class ThemeProvider
{
public:
virtual ~ThemeProvider() = default;
virtual Colors getTheme() const = 0;
virtual void setTheme(Colors newTheme) = 0;
};
/** It's nice to have my own base class when I need to add broader functionality. */
class CustomComponent : public virtual juce::Component, public CustomHelpTextProvider
{
public:
explicit CustomComponent(CustomHelpTextDisplay* helpTextDisplay = nullptr) : CustomHelpTextProvider(this, helpTextDisplay), lnf(dynamic_cast<CustomLookAndFeel&>(getLookAndFeel()))
{
}
/** Easy way to get a disabled version of a color */
juce::Colour disabled(juce::Colour color, bool disabledCondition) const
{
return disabledCondition ? color.withMultipliedAlpha(0.5f) : color;
}
/** Easy way to get a disabled version of a color */
juce::Colour disabled(juce::Colour color) const
{
return disabled(color, !isEnabled());
}
/** In situations where a callback can be called on the wrong thread, component methods cannot be safely used. This method wraps repaint in
an async call with a SafePointer to make sure nothing bad happens.
*/
void safeRepaint()
{
juce::MessageManager::callAsync([p = juce::Component::SafePointer(this)] {
if (p.getComponent())
p->repaint();
});
}
Colors getTheme()
{
if (themeProvider)
return themeProvider->getTheme();
juce::Component* c{ this };
while (c != nullptr)
{
if (auto* tp = dynamic_cast<ThemeProvider*>(c))
{
themeProvider = tp;
break;
}
c = c->getParentComponent();
}
return themeProvider ? themeProvider->getTheme() : defaultTheme;
}
CustomLookAndFeel& lnf;
private:
ThemeProvider* themeProvider{ nullptr };
};
/** Simple mixin class to set a mouse cursor on a component when enabled only. */
class EnabledMouseCursor
{
public:
void setEnabledMouseCursor(const juce::MouseCursor& cursor, const bool use = true)
{
enabledMouseCursor = cursor;
useEnabledCursor = use;
}
void enablementChanged(juce::Component& component) const
{
if (useEnabledCursor)
{
if (component.isEnabled())
component.setMouseCursor(enabledMouseCursor);
else
component.setMouseCursor(juce::MouseCursor::NormalCursor);
}
}
private:
juce::MouseCursor enabledMouseCursor{ juce::MouseCursor::NoCursor };
bool useEnabledCursor{ false };
};
/** This is a rotary slider that includes custom help text functionality. */
class CustomRotary final : public juce::Slider, public CustomHelpTextProvider
{
public:
explicit CustomRotary(SliderStyle style = LinearHorizontal, TextEntryBoxPosition textBoxPosition = NoTextBox) : Slider(style, textBoxPosition), CustomHelpTextProvider(this) {}
juce::String getCustomHelpText() override
{
auto numString = getTextFromValue(getValue());
if (getProperties().contains(ComponentProps::ROTARY_GREATER_UNIT) && getValue() >= 1000.)
numString += " " + getProperties()[ComponentProps::ROTARY_GREATER_UNIT].toString();
else if (getProperties().contains(ComponentProps::ROTARY_UNIT))
numString += " " + getProperties()[ComponentProps::ROTARY_UNIT].toString();
if (getProperties().contains(ComponentProps::ROTARY_PARAMETER_NAME))
numString = getProperties()[ComponentProps::ROTARY_PARAMETER_NAME].toString() + ": " + numString;
return numString;
}
};
/** Helper class to expose the parameter name to the attached rotary */
class CustomRotaryAttachment final : public APVTS::SliderAttachment
{
public:
CustomRotaryAttachment(APVTS& apvts, const juce::String& parameterID, juce::Slider& rotary) : SliderAttachment(apvts, parameterID, rotary)
{
rotary.getProperties().set(ComponentProps::ROTARY_PARAMETER_NAME, parameterID);
// While we use textFromValueFunction in the parameter to expose that to the host, we don't want this
// to show in the rotaries textbox.
float parameterInterval = apvts.getParameter(parameterID)->getNormalisableRange().interval;
rotary.textFromValueFunction = [parameterInterval](double v)
{
int numDecimalPlaces = juce::String{ int(1.f / parameterInterval) }.length() - 1;
return juce::String(v, numDecimalPlaces);
};
}
};
class CustomLabel final : public juce::Label, public EnabledMouseCursor
{
void enablementChanged() override
{
EnabledMouseCursor::enablementChanged(*this);
}
};
/** This includes some utility methods for dealing with selection on a component.
T is intended to be an enum of different parts of the component where the first enum value is NONE.
See FilterResponse for a clear example of how these are used.
*/
template <typename T>
struct CompPart
{
T part;
juce::Rectangle<float> area;
int priority;
CompPart(T part, juce::Rectangle<float> area, int priority) : part(part), area(area), priority(priority) {}
float distanceTo(int x, int y) const
{
float dx = juce::jmax<float>(area.getX() - x, 0, x - area.getRight());
float dy = juce::jmax<float>(area.getY() - y, 0, y - area.getBottom());
return std::sqrt(dx * dx + dy * dy);
}
static T getClosestInRange(juce::Array<CompPart<T>> targets, int x, int y, int snapAmount=0)
{
T closest = static_cast<T>(0);
auto priority = -1;
auto closestDistance = INFINITY;
for (const auto& p : targets)
{
auto distance = p.distanceTo(x, y);
if (((distance < closestDistance && p.priority == priority) || p.priority > priority) && distance <= snapAmount)
{
closest = p.part;
priority = p.priority;
closestDistance = distance;
}
}
return closest;
}
};

View file

@ -0,0 +1,220 @@
/*
==============================================================================
DeviceRecorder.h
Created: 11 May 2024 12:09:24pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include <readerwriterqueue.h>
/** A struct to represent a change in the recording buffer. This is used to update the UI with the latest recording data. */
struct RecordingBufferChange
{
enum RecordingBufferChangeType
{
CLEAR,
ADD
};
explicit RecordingBufferChange(RecordingBufferChangeType type, const juce::AudioBuffer<float>& buffer = {}) :
type(type),
addedBuffer(buffer)
{
}
RecordingBufferChangeType type;
juce::AudioBuffer<float> addedBuffer;
};
/** A listener interface for the DeviceRecorder class. */
class DeviceRecorderListener
{
public:
DeviceRecorderListener() = default;
virtual ~DeviceRecorderListener() = default;
/** A callback once a recording has started. */
virtual void recordingStarted() = 0;
/** A callback once a recording has finished.
This will be called if the user or device manager stops the recording.
The recording buffer will be passed to the listener, along with the sample rate of the recording.
This is called on the message thread.
*/
virtual void recordingFinished(juce::AudioBuffer<float> recording, int recordingSampleRate) = 0;
};
using RecordingQueue = moodycamel::ReaderWriterQueue<RecordingBufferChange, 16384>;
/** This class provides a convenient encapsulation for device recording logic.
Due to the reliance on callbacks from the AudioDeviceManager, a user of the class cannot directly start or stop
the recording process.
*/
class DeviceRecorder final : public juce::AudioIODeviceCallback
{
public:
explicit DeviceRecorder(juce::AudioDeviceManager& deviceManager) : deviceManager(deviceManager)
{
deviceManager.addAudioCallback(this);
}
~DeviceRecorder() override
{
deviceManager.removeAudioCallback(this);
}
void addListener(DeviceRecorderListener* listener)
{
listeners.add(listener);
}
void removeListener(DeviceRecorderListener* listener)
{
listeners.remove(listener);
}
/** Start recording audio from the active device, set shouldRecordToQueue to false if you don't need the queue for UI updates */
void startRecording(bool shouldRecordToQueue = true)
{
this->recordToQueue = shouldRecordToQueue;
shouldRecord = true;
}
void stopRecording()
{
shouldRecord = false;
}
/** Check if the device is currently recording */
bool isRecordingDevice() const
{
return isRecording;
}
/** This indicates whether the device is trying to record or not.
Essentially, this can be controlled synchronously while isRecording is controlled asynchronously.
*/
bool shouldRecordDevice() const
{
return shouldRecord;
}
/** Retrieve a queue of recording updates, which can be used to update the UI */
RecordingQueue& getRecordingBufferQueue()
{
return recordingBufferQueue;
}
private:
void audioDeviceIOCallbackWithContext(const float* const* inputChannelData, int numInputChannels, float* const* /* outputChannelData */, int /* numOutputChannels */, int numSamples, const juce::AudioIODeviceCallbackContext&) override
{
if (shouldRecord)
{
if (!isRecording)
{
isRecording = true;
recordingBufferList.clear();
if (recordToQueue)
recordingBufferQueue.emplace(RecordingBufferChange::CLEAR);
recordingSize = 0;
listeners.call(&DeviceRecorderListener::recordingStarted);
}
if (isRecording && numInputChannels && numSamples)
{
accumulatingRecordingBuffer.setSize(
accumulatingRecordingSize == 0 ? numInputChannels : juce::jmin<int>(numInputChannels, accumulatingRecordingBuffer.getNumChannels()),
juce::jmax<int>(accumulatingRecordingBuffer.getNumSamples(), accumulatingRecordingSize + numSamples), true);
for (int i = 0; i < accumulatingRecordingBuffer.getNumChannels(); i++)
accumulatingRecordingBuffer.copyFrom(i, accumulatingRecordingSize, inputChannelData[i], numSamples);
accumulatingRecordingSize += numSamples;
const int accumulatingMax = recordingSampleRate / PluginParameters::FRAME_RATE;
if (accumulatingRecordingSize > accumulatingMax)
{
flushAccumulatedBuffer();
}
}
}
else if (isRecording)
{
recordingFinished();
}
}
void audioDeviceAboutToStart(juce::AudioIODevice* device) override
{
recordingSampleRate = int(device->getCurrentSampleRate());
}
void audioDeviceStopped() override
{
if (isRecording)
{
recordingFinished();
}
}
void recordingFinished()
{
if (recordingBufferList.empty())
return;
if (accumulatingRecordingSize > 0)
flushAccumulatedBuffer();
int numChannels = recordingBufferList[0]->getNumChannels();
int size = 0;
juce::AudioBuffer<float> recordingBuffer{ numChannels, recordingSize };
for (const auto& i : recordingBufferList)
{
numChannels = juce::jmin<int>(numChannels, i->getNumChannels());
recordingBuffer.setSize(numChannels, recordingBuffer.getNumSamples()); // This will only potentially decrease the channel count
for (int ch = 0; ch < numChannels; ch++)
recordingBuffer.copyFrom(ch, size, *i, ch, 0, i->getNumSamples());
size += i->getNumSamples();
}
isRecording = false;
listeners.call(&DeviceRecorderListener::recordingFinished, std::move(recordingBuffer), recordingSampleRate);
}
/** Flush the accumulating recording buffer to the recording buffer list.
We accumulate samples before pushing to the list so that the UI thread doesn't get
outpaced.
*/
void flushAccumulatedBuffer()
{
int numChannels = accumulatingRecordingBuffer.getNumChannels();
auto recordingBuffer = std::make_unique<juce::AudioBuffer<float>>(numChannels, accumulatingRecordingSize);
for (int i = 0; i < numChannels; i++)
recordingBuffer->copyFrom(i, 0, accumulatingRecordingBuffer, i, 0, accumulatingRecordingSize);
if (recordToQueue)
recordingBufferQueue.emplace(RecordingBufferChange::ADD, *recordingBuffer);
recordingBufferList.emplace_back(std::move(recordingBuffer));
recordingSize += accumulatingRecordingSize;
accumulatingRecordingSize = 0;
}
juce::LightweightListenerList<DeviceRecorderListener> listeners;
juce::AudioDeviceManager& deviceManager;
bool shouldRecord{ false };
bool isRecording{ false };
int recordingSampleRate{ 0 };
int recordingSize{ 0 };
juce::AudioBuffer<float> accumulatingRecordingBuffer; // Necessary since the GUI thread cannot keep up unless the callbacks are accumulated
int accumulatingRecordingSize{ 0 };
std::vector<std::unique_ptr<juce::AudioBuffer<float>>> recordingBufferList;
bool recordToQueue{ true };
#pragma warning(disable: 4324) // structure was padded due to __declspec(align())
RecordingQueue recordingBufferQueue{ 10 };
};

View file

@ -0,0 +1,232 @@
/*
==============================================================================
ListenableAtomic.h
Created: 3 Jun 2024 11:50:52pm
Author: binya
==============================================================================
*/
#pragma once
#include <atomic>
#include <vector>
#include "../PluginParameters.h"
template <typename T>
class ListenableValue;
/** A listener for a ListenableAtomic or a ListenableMutex */
template <typename T>
class ValueListener
{
public:
virtual ~ValueListener() = default;
virtual void valueChanged(ListenableValue<T>& source, T newValue) = 0;
};
template <typename T>
class ListenableValue
{
public:
virtual ~ListenableValue() = default;
virtual T load() const = 0;
virtual ListenableValue& store(T newValue) = 0;
virtual void addListener(ValueListener<T>* listener) = 0;
virtual void removeListener(ValueListener<T>* listener) = 0;
virtual ListenableValue& operator=(T newValue) = 0;
};
/** A thread-safe atomic value that can be listened to. */
template <typename T>
class ListenableAtomic final : public ListenableValue<T>
{
public:
ListenableAtomic() : value() {}
ListenableAtomic(T initial) : value(initial) {}
ListenableAtomic& store(T newValue) override
{
value = newValue;
for (auto listener : listeners)
{
listener->valueChanged(*this, newValue);
}
return *this;
}
T load() const override
{
return value;
}
void addListener(ValueListener<T>* listener) override
{
listeners.push_back(listener);
}
void removeListener(ValueListener<T>* listener) override
{
listeners.erase(std::remove(listeners.begin(), listeners.end(), listener), listeners.end());
}
ListenableAtomic& operator=(T newValue) override
{
return store(newValue);
}
operator T()
{
return load();
}
bool operator==(const ListenableAtomic& other) const
{
return &other == this;
}
bool operator==(T newValue) const
{
return newValue == this->value;
}
private:
std::atomic<T> value;
std::vector<ValueListener<T>*> listeners;
};
/** A thread-safe value that uses locks, for use when atomic operations are not possible. */
template <typename T>
class ListenableMutex final : public ListenableValue<T>
{
public:
ListenableMutex() : value() {}
ListenableMutex(T initial) : value(initial) {}
ListenableMutex& store(T newValue) override
{
{ // Lock scope
juce::ScopedLock lock(mutex);
value = newValue;
}
for (auto listener : listeners)
{
listener->valueChanged(*this, newValue);
}
return *this;
}
T load() const override
{
juce::ScopedLock lock(mutex);
return value;
}
void addListener(ValueListener<T>* listener) override
{
listeners.push_back(listener);
}
void removeListener(ValueListener<T>* listener) override
{
listeners.erase(std::remove(listeners.begin(), listeners.end(), listener), listeners.end());
}
ListenableMutex& operator=(T newValue) override
{
return store(newValue);
}
operator T()
{
return load();
}
bool operator==(const ListenableMutex& other) const
{
return &other == this;
}
bool operator==(T newValue) const
{
return newValue == this->value;
}
private:
juce::CriticalSection mutex;
T value;
std::vector<ValueListener<T>*> listeners;
};
/** A dummy parameter that can be used to notify the host of state updates that it otherwise has no knowledge of.
This allows for undo/redos of otherwise hidden parameters, tested on Reaper.
*/
class UIDummyParam final
{
public:
explicit UIDummyParam(const juce::AudioProcessorValueTreeState& apvts, const juce::String& dummyParamID) :
dummyParam(apvts.getParameter(dummyParamID))
{
}
void sendUIUpdate() const
{
if (pluginHostType.isLogic() || pluginHostType.isGarageBand() || pluginHostType.isMainStage())
return; // Logic implements undo/redo differently
dummyParam->beginChangeGesture();
dummyParam->setValueNotifyingHost(1.f - dummyParam->getValue());
dummyParam->endChangeGesture();
}
private:
juce::RangedAudioParameter* dummyParam{ nullptr };
juce::PluginHostType pluginHostType;
};
/** An attachment connecting juce::Button to a ListenableValue<bool>. */
class ToggleButtonAttachment final : public ValueListener<bool>, public juce::Button::Listener
{
public:
ToggleButtonAttachment(juce::Button& toggleButton, ListenableValue<bool>& listenableValue, UIDummyParam* dummyParam = nullptr) :
button(toggleButton), value(listenableValue), hostDummy(dummyParam)
{
button.setToggleState(value.load(), juce::dontSendNotification);
button.addListener(this);
value.addListener(this);
}
~ToggleButtonAttachment() override
{
button.removeListener(this);
value.removeListener(this);
}
private:
void buttonClicked(juce::Button*) override
{
if (button.getToggleState() != value.load())
{
value = button.getToggleState();
if (hostDummy != nullptr)
hostDummy->sendUIUpdate();
}
}
void valueChanged(ListenableValue<bool>& /* source */, bool newValue) override
{
button.setToggleState(newValue, juce::sendNotificationSync);
}
juce::Button& button;
ListenableValue<bool>& value;
UIDummyParam* hostDummy{ nullptr };
};

View file

@ -0,0 +1,80 @@
/*
==============================================================================
PitchDetector.h
Created: 27 Dec 2023 2:51:29pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include <leaf.h>
#include <leaf-analysis.h>
/* Uses the LEAF library to detect pitch in an audio buffer */
class PitchDetector final : public juce::Thread
{
public:
PitchDetector() : Thread("Pitch_Detector")
{
}
~PitchDetector() override
{
stopThread(1000);
}
void setData(juce::AudioBuffer<float>& buffer, int startSample, int endSample, double audioSampleRate)
{
audioBuffer = &buffer;
sampleStart = startSample;
sampleEnd = endSample;
sampleRate = audioSampleRate;
}
// Inherited via Thread
void run() override
{
if (!audioBuffer)
return;
detectPitch();
signalThreadShouldExit();
}
void detectPitch()
{
int numSamples = sampleEnd - sampleStart + 1;
LEAF leaf;
tPitchDetector pitchDetector;
constexpr size_t memorySize = 5000;
char memory[memorySize];
LEAF_init(&leaf, float(sampleRate), memory, memorySize, []() -> float { return juce::Random().nextFloat(); });
tPitchDetector_init(&pitchDetector, 50, 20000, &leaf);
for (int i = 0; i < numSamples; i++)
{
bool ready = audioBuffer->getNumChannels() == 2 ?
tPitchDetector_tick(&pitchDetector, (audioBuffer->getSample(0, sampleStart + i) + audioBuffer->getSample(1, sampleStart + i)) / 2.f) :
tPitchDetector_tick(&pitchDetector, audioBuffer->getSample(0, sampleStart + i));
if (ready)
break;
}
pitch = double(tPitchDetector_predictFrequency(&pitchDetector));
tPitchDetector_free(&pitchDetector);
}
double getPitch() const
{
return pitch;
}
private:
juce::AudioBuffer<float>* audioBuffer{ nullptr };
int sampleStart{ 0 }, sampleEnd{ 0 };
double sampleRate{ 0. };
double pitch{ 0 };
};

View file

@ -0,0 +1,131 @@
/*
==============================================================================
ReaperVST3Extensions.cpp
Created: 14 May 2025 9:43:44pm
Author: binya
==============================================================================
*/
#include "ReaperVST3Extensions.h"
namespace reaper
{
DEF_CLASS_IID(IReaperHostApplication)
}
juce::String ReaperVST3Extensions::getNamedConfigParam(const juce::String& paramName) const
{
if (GetSetMediaTrackInfo_String == nullptr || GetTrack == nullptr || trackIndex < 0)
return {};
MediaTrack* track = GetTrack(nullptr, trackIndex);
if (track == nullptr)
return {};
// If our track index has not yet been updated, exit early
if (GetTrackGUID(track) != trackGUID)
return {};
constexpr int buffSize = 512;
char buffer[buffSize] = {};
bool success = GetSetMediaTrackInfo_String(track, paramName.getCharPointer(), buffer, false);
if (!success || buffer[0] == 0)
return {};
return juce::String::fromUTF8(buffer);
}
void ReaperVST3Extensions::setNamedConfigParam(const juce::String& paramName, const juce::String& value) const
{
if (GetSetMediaTrackInfo_String == nullptr || GetTrack == nullptr || trackIndex < 0)
return;
MediaTrack* track = GetTrack(nullptr, trackIndex);
if (track == nullptr)
return;
// If our track index has not yet been updated, exit early
if (GetTrackGUID(track) != trackGUID)
return;
GetSetMediaTrackInfo_String(track, paramName.getCharPointer(), value.getCharPointer().getAddress(), true);
}
void ReaperVST3Extensions::setIHostApplication(Steinberg::FUnknown* ptr)
{
if (ptr == nullptr)
return;
void* rawInterface = nullptr;
if (ptr->queryInterface(reaper::IReaperHostApplication::iid, &rawInterface) == Steinberg::kResultOk)
{
if (void* fnPtr = static_cast<reaper::IReaperHostApplication*> (rawInterface)->getReaperApi("GetSetMediaTrackInfo_String"))
{
GetSetMediaTrackInfo_String = reinterpret_cast<bool (*)(MediaTrack*, const char*, char*, bool)>(fnPtr);
}
if (void* fnPtr = static_cast<reaper::IReaperHostApplication*> (rawInterface)->getReaperApi("GetTrack"))
{
GetTrack = reinterpret_cast<MediaTrack * (*)(ReaProject*, int)>(fnPtr);
}
if (void* fnPtr = static_cast<reaper::IReaperHostApplication*> (rawInterface)->getReaperApi("GetTrackGUID"))
{
GetTrackGUID = reinterpret_cast<GUID * (*)(MediaTrack*)>(fnPtr);
}
}
}
int32_t ReaperVST3Extensions::queryIEditController(const Steinberg::TUID string, void** obj)
{
#if JAS_VST3_REAPER_INTEGRATION
if (obj == nullptr)
return -1;
if (Steinberg::FUnknownPrivate::iidEqual(string, Steinberg::Vst::ChannelContext::IInfoListener::iid))
{
*obj = static_cast<Steinberg::Vst::ChannelContext::IInfoListener*>(this);
return 0;
}
*obj = nullptr;
#endif
return -1;
}
Steinberg::tresult ReaperVST3Extensions::queryInterface(const Steinberg::TUID /*_iid*/, void** /*obj*/)
{
return Steinberg::kNoInterface;
}
Steinberg::uint32 ReaperVST3Extensions::addRef()
{
return 1;
}
Steinberg::uint32 ReaperVST3Extensions::release()
{
return 1;
}
Steinberg::tresult ReaperVST3Extensions::setChannelContextInfos(Steinberg::Vst::IAttributeList* list)
{
if (!list || GetTrackGUID == nullptr)
return Steinberg::kResultFalse;
Steinberg::int64 idx = -1;
if (list->getInt(Steinberg::Vst::ChannelContext::kChannelIndexKey, idx) == Steinberg::kResultTrue)
{
trackIndex = static_cast<int>(idx) - 1;
trackGUID = GetTrackGUID(GetTrack(nullptr, trackIndex));
}
return Steinberg::kResultOk;
}

View file

@ -0,0 +1,56 @@
/*
==============================================================================
ReaperVST3Extensions.h
Created: 14 May 2025 9:38:25pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include <reaper_plugin.h>
#include "pluginterfaces/base/funknown.h"
#include "pluginterfaces/vst/ivstchannelcontextinfo.h"
namespace reaper
{
using namespace Steinberg;
using INT_PTR = juce::pointer_sized_int;
using uint32 = Steinberg::uint32;
#include <reaper_vst3_interfaces.h>
}
class ReaperVST3Extensions final : public juce::VST3ClientExtensions, public Steinberg::Vst::ChannelContext::IInfoListener
{
public:
ReaperVST3Extensions() = default;
~ReaperVST3Extensions() override = default;
juce::String getNamedConfigParam(const juce::String& paramName) const;
void setNamedConfigParam(const juce::String& paramName, const juce::String& value) const;
void setIHostApplication(Steinberg::FUnknown* ptr) override;
int32_t queryIEditController(const Steinberg::TUID, void** obj) override;
Steinberg::tresult PLUGIN_API setChannelContextInfos(Steinberg::Vst::IAttributeList* list) override;
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID _iid, void** obj) override;
Steinberg::uint32 PLUGIN_API addRef() override;
Steinberg::uint32 PLUGIN_API release() override;
private:
// https://github.com/justinfrankel/reaper-sdk/blob/main/sdk/reaper_plugin_functions.h
bool (*GetSetMediaTrackInfo_String)(MediaTrack* tr, const char* parmname, char* stringNeedBig, bool setNewValue);
MediaTrack* (*GetTrack)(ReaProject* proj, int trackidx) { nullptr };
GUID* (*GetTrackGUID)(MediaTrack* tr) { nullptr };
GUID* trackGUID{ nullptr };
int trackIndex{ -1 };
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ReaperVST3Extensions)
};

View file

@ -0,0 +1,98 @@
/*
==============================================================================
SampleLoader.h
Created: 14 Jun 2024 5:37:01pm
Author: binya
==============================================================================
*/
#pragma once
#include <JuceHeader.h>
#include "BufferUtils.h"
/** A utility thread for asynchronously loading samples */
class LoaderThread final : public juce::Thread
{
public:
LoaderThread(std::unique_ptr<juce::AudioFormatReader> formatReader, int threadID) : Thread("Loader_Thread_" + juce::String(threadID)),
reader{std::move(formatReader)}
{
}
~LoaderThread() override
{
stopThread(-1);
}
void cancel()
{
canceled = true;
}
juce::AudioBuffer<float>* releaseSample() { return newSample.release(); }
juce::AudioFormatReader* releaseReader() { return reader.release(); }
const juce::String& getLoadedSampleHash() const { return sampleHash; }
private:
void run() override
{
newSample = std::make_unique<juce::AudioBuffer<float>>(int(reader->numChannels), int(reader->lengthInSamples));
reader->read(&*newSample, 0, int(reader->lengthInSamples), 0, true, true);
sampleHash = getSampleHash(*newSample);
if (canceled)
newSample = nullptr;
signalThreadShouldExit();
}
std::unique_ptr<juce::AudioFormatReader> reader;
std::unique_ptr<juce::AudioBuffer<float>> newSample;
juce::String sampleHash;
bool canceled{ false };
};
/** Asynchronously loads a sample */
class SampleLoader final : public juce::Thread::Listener
{
public:
void loadSample(std::unique_ptr<juce::AudioFormatReader> formatReader,
const std::function<void(const std::unique_ptr<juce::AudioBuffer<float>>& loadedSample, const juce::String& sampleHash, const std::unique_ptr<juce::AudioFormatReader>& reader)>& onCompletion)
{
loading = true;
completionCallback = onCompletion;
for (const auto& thread : threads)
{
thread->cancel();
thread->removeListener(this);
}
auto newThread = std::make_unique<LoaderThread>(std::move(formatReader), ++ids);
newThread->addListener(this);
newThread->startThread();
threads.emplace_back(std::move(newThread));
}
bool isLoading() const { return loading; }
private:
void exitSignalSent() override
{
juce::MessageManager::callAsync([this]() -> void {
auto& lastThread = threads[threads.size() - 1];
completionCallback(std::unique_ptr<juce::AudioBuffer<float>>(lastThread->releaseSample()), lastThread->getLoadedSampleHash(), std::unique_ptr<juce::AudioFormatReader>(lastThread->releaseReader()));
loading = false;
});
}
//==============================================================================
int ids{ 0 };
std::vector<std::unique_ptr<LoaderThread>> threads;
std::function<void(std::unique_ptr<juce::AudioBuffer<float>>, const juce::String&, std::unique_ptr<juce::AudioFormatReader>)> completionCallback;
bool loading{ false };
};