/* ============================================================================== SampleNavigator.cpp Created: 19 Sep 2023 4:41:12pm Author: binya ============================================================================== */ #include #include "SampleNavigator.h" SampleNavigator::SampleNavigator(APVTS& apvts, PluginParameters::State& pluginState, const juce::OwnedArray& 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(apvts.getParameter(PluginParameters::DISABLE_WAVETABLE_MODE))), isLooping(dynamic_cast(apvts.getParameter(PluginParameters::IS_LOOPING))), loopHasStart(dynamic_cast(apvts.getParameter(PluginParameters::LOOPING_HAS_START))), loopHasEnd(dynamic_cast(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& source, int /*newValue*/) { auto& sourceA = dynamic_cast&>(source); if (!navigatorUpdate && (sourceA == state.loopStart || sourceA == state.loopEnd || sourceA == state.sampleStart || sourceA == state.sampleEnd)) updatePinnedPositions(); safeRepaint(); } void SampleNavigator::valueChanged(ListenableValue& 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&>(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& 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(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 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(sample->getNumSamples() - 1, viewStart + Feel::MINIMUM_VIEW - 1); viewStart = juce::jmax(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(juce::jmin(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(juce::jmax(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(-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(30 - (stopPos - startPos), 0) / 2; juce::Array targets = { CompPart {Drag::SAMPLE_START, juce::Rectangle{startPos - offset, bounds.getY(), 0, bounds.getHeight()}, 2}, CompPart {Drag::SAMPLE_END, juce::Rectangle{stopPos + offset, bounds.getY(), 0, bounds.getHeight()}, 2}, CompPart {Drag::SAMPLE_FULL, juce::Rectangle{startPos, bounds.getY(), stopPos - startPos, bounds.getHeight()}, 1} }; return CompPart::getClosestInRange(targets, x, y, Feel::DRAGGABLE_SNAP); } float SampleNavigator::sampleToPosition(int sampleIndex) const { float boundsWidth = getWidth() * Layout::navigatorBoundsWidth; return juce::jmap(float(sampleIndex), 0.f, float(sample->getNumSamples() - 1), 0.f, float(painter.getWidth())) + boundsWidth; } int SampleNavigator::positionToSample(float position) const { return int(juce::jmap(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::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(0.f, 1.f, float(state.sampleStart - viewStart) / (viewEnd - viewStart)); pinnedSampleEnd = juce::jlimit(0.f, 1.f, float(state.sampleEnd - viewStart) / (viewEnd - viewStart)); pinnedLoopStart = juce::jlimit(0.f, 1.f, float(state.loopStart - viewStart) / (viewEnd - viewStart)); pinnedLoopEnd = juce::jlimit(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(); }