/* ============================================================================== SampleEditorOverlay.cpp Created: 19 Sep 2023 2:03:29pm Author: binya ============================================================================== */ #include #include "SampleEditor.h" SampleEditorOverlay::SampleEditorOverlay(const APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray& 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(apvts.getParameter(PluginParameters::DISABLE_WAVETABLE_MODE))), isLooping(dynamic_cast(apvts.getParameter(PluginParameters::IS_LOOPING))), loopingHasStart(dynamic_cast(apvts.getParameter(PluginParameters::LOOPING_HAS_START))), loopingHasEnd(dynamic_cast(apvts.getParameter(PluginParameters::LOOPING_HAS_END))), 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) { 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(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(jmin(viewStart, newSample), 0); viewEnd = jmin(jmax(viewEnd, newSample + (2 + int(loopHasEnd)) * Feel::MINIMUM_BOUNDS_DISTANCE), sampleBuffer->getNumSamples() - 1); if (newSample < loopStart) { loopStart = jmax(0, newSample); } else { loopEnd = jmin(jmax(loopEnd, newSample + 3 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasEnd && pin && loopEnd <= viewEnd ? viewEnd.load() : sampleBuffer->getNumSamples() - 1); sampleEnd = jmin(jmax(sampleEnd, newSample + 2 * Feel::MINIMUM_BOUNDS_DISTANCE), jmin(loopHasEnd ? loopEnd - Feel::MINIMUM_BOUNDS_DISTANCE : loopEnd.load(), pin ? viewEnd.load() : sampleBuffer->getNumSamples() - 1)); sampleStart = jmin(jmax(sampleStart, newSample + Feel::MINIMUM_BOUNDS_DISTANCE), sampleEnd - Feel::MINIMUM_BOUNDS_DISTANCE); loopStart = jmin(newSample, sampleStart - Feel::MINIMUM_BOUNDS_DISTANCE); } break; case EditorParts::SAMPLE_START: viewStart = jmax(jmin(viewStart, newSample - int(loopHasStart) * Feel::MINIMUM_BOUNDS_DISTANCE), 0); viewEnd = jmin(jmax(viewEnd, newSample + (1 + int(loopHasEnd)) * Feel::MINIMUM_BOUNDS_DISTANCE), sampleBuffer->getNumSamples() - 1); if (newSample < sampleStart) { loopStart = jmax(jmin(loopStart, newSample - Feel::MINIMUM_BOUNDS_DISTANCE), loopHasStart && pin && loopStart >= viewStart ? viewStart.load() : 0); sampleStart = jmax(newSample, loopStart + int(loopHasStart) * Feel::MINIMUM_BOUNDS_DISTANCE); } else { loopEnd = jmin(jmax(loopEnd, newSample + 2 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasEnd && pin && loopEnd <= viewEnd ? viewEnd.load() : sampleBuffer->getNumSamples() - 1); sampleEnd = jmin(jmax(sampleEnd, newSample + Feel::MINIMUM_BOUNDS_DISTANCE), jmin(loopHasEnd ? loopEnd - Feel::MINIMUM_BOUNDS_DISTANCE : loopEnd.load(), pin ? viewEnd.load() : sampleBuffer->getNumSamples() - 1)); sampleStart = jmin(newSample, sampleEnd - Feel::MINIMUM_BOUNDS_DISTANCE); } break; case EditorParts::SAMPLE_END: viewStart = jmax(jmin(viewStart, newSample - (1 + int(loopHasStart)) * Feel::MINIMUM_BOUNDS_DISTANCE), 0); viewEnd = jmin(jmax(viewEnd, newSample + int(loopHasEnd) * Feel::MINIMUM_BOUNDS_DISTANCE), sampleBuffer->getNumSamples() - 1); if (newSample > sampleEnd) { loopEnd = jmin(jmax(loopEnd, newSample + Feel::MINIMUM_BOUNDS_DISTANCE), loopHasEnd && pin && loopEnd <= viewEnd ? viewEnd.load() : sampleBuffer->getNumSamples() - 1); sampleEnd = jmin(newSample, loopEnd - int(loopHasEnd) * Feel::MINIMUM_BOUNDS_DISTANCE); } else { loopStart = jmax(jmin(loopStart, newSample - 2 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasStart && pin && loopStart >= viewStart ? viewStart.load() : 0); sampleStart = jmax(jmin(sampleStart, newSample - Feel::MINIMUM_BOUNDS_DISTANCE), jmax(loopHasStart ? loopStart + Feel::MINIMUM_BOUNDS_DISTANCE : loopStart.load(), pin ? viewStart.load() : 0)); sampleEnd = jmax(newSample, sampleStart + Feel::MINIMUM_BOUNDS_DISTANCE); } break; case EditorParts::LOOP_END: viewStart = jmax(jmin(viewStart, newSample - (2 + int(loopHasStart)) * Feel::MINIMUM_BOUNDS_DISTANCE), 0); viewEnd = jmin(jmax(viewEnd, newSample), sampleBuffer->getNumSamples() - 1); if (newSample > loopEnd) { loopEnd = jmin(sampleBuffer->getNumSamples() - 1, newSample); } else { loopStart = jmax(jmin(loopStart, newSample - 3 * Feel::MINIMUM_BOUNDS_DISTANCE), loopHasStart && pin && loopStart >= viewStart ? viewStart.load() : 0); sampleStart = jmax(jmin(sampleStart, newSample - 2 * Feel::MINIMUM_BOUNDS_DISTANCE), jmax(loopHasStart ? loopStart + Feel::MINIMUM_BOUNDS_DISTANCE : loopStart.load(), pin ? viewStart.load() : 0)); sampleEnd = jmax(jmin(sampleEnd, newSample - Feel::MINIMUM_BOUNDS_DISTANCE), sampleStart + Feel::MINIMUM_BOUNDS_DISTANCE); loopEnd = jmax(newSample, sampleEnd + Feel::MINIMUM_BOUNDS_DISTANCE); } break; } } //============================================================================== void SampleEditorOverlay::setSample(const juce::AudioBuffer& 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::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(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(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& synthVoices, const std::function& 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& 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(0, sampleBuffer->getNumSamples() - 1, overlay.positionToSample(position - Layout::boundsWidth * getWidth())); } //============================================================================== void SampleEditor::setSample(const juce::AudioBuffer& 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& 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(); }