commit 638cd05ea8b431074e0bb7c144ceec56b849590e Author: Armin Date: Tue Jul 21 02:06:45 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0623c86 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,48 @@ +cmake_minimum_required(VERSION 3.22) +project(MonoSlicer VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_subdirectory(lib/JUCE) + +juce_add_plugin(MonoSlicer + COMPANY_NAME "MonoSlicer" + PLUGIN_MANUFACTURER_CODE RcSp + PLUGIN_CODE MsP1 + FORMATS AU VST3 Standalone + PRODUCT_NAME "MonoSlicer" + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + EDITOR_WANTS_KEYBOARD_FOCUS TRUE + COPY_PLUGIN_AFTER_BUILD FALSE + VST3_CATEGORIES Instrument Sampler +) + +target_sources(MonoSlicer PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/SliceManager.cpp + Source/WaveformDisplay.cpp + Source/PianoRollDisplay.cpp +) + +target_compile_definitions(MonoSlicer PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 +) + +target_link_libraries(MonoSlicer + PRIVATE + juce::juce_audio_utils + juce::juce_audio_processors + juce::juce_audio_formats + juce::juce_dsp + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags +) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0fa2177 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +JUCE_REPO := https://github.com/juce-framework/JUCE.git +JUCE_DIR := lib/JUCE +JUCE_TAG := 8.0.9 +BUILD_DIR := build +CMAKE_ARGS := -DCMAKE_BUILD_TYPE=Release + +.PHONY: all clean fetch configure build install + +all: install + +$(JUCE_DIR)/CMakeLists.txt: + @echo ">>> Fetching JUCE $(JUCE_TAG)..." + git clone --depth 1 --branch $(JUCE_TAG) $(JUCE_REPO) $(JUCE_DIR) + +fetch: $(JUCE_DIR)/CMakeLists.txt + +configure: fetch + @echo ">>> Configuring..." + mkdir -p $(BUILD_DIR) + cmake -B $(BUILD_DIR) -S . $(CMAKE_ARGS) + +build: configure + @echo ">>> Building MonoSlicer..." + cmake --build $(BUILD_DIR) -j$$(sysctl -n hw.ncpu) + +install: build + @echo ">>> Installing plugins..." + mkdir -p ~/Library/Audio/Plug-Ins/Components + mkdir -p ~/Library/Audio/Plug-Ins/VST3 + cp -R $(BUILD_DIR)/MonoSlicer_artefacts/Release/AU/MonoSlicer.component ~/Library/Audio/Plug-Ins/Components/ + cp -R $(BUILD_DIR)/MonoSlicer_artefacts/Release/VST3/MonoSlicer.vst3 ~/Library/Audio/Plug-Ins/VST3/ + @echo ">>> Rescanning Audio Units..." + killall -9 AudioComponentRegistrar 2>/dev/null; true + @echo ">>> Done. MonoSlicer installed as AU and VST3." + +clean: + rm -rf $(BUILD_DIR) diff --git a/Source/PianoRollDisplay.cpp b/Source/PianoRollDisplay.cpp new file mode 100644 index 0000000..d414ead --- /dev/null +++ b/Source/PianoRollDisplay.cpp @@ -0,0 +1,134 @@ +#include "PianoRollDisplay.h" + +PianoRollDisplay::PianoRollDisplay(SliceManager& sm) : sliceManager(sm) +{ + startTimerHz(20); +} + +PianoRollDisplay::~PianoRollDisplay() { stopTimer(); } + +void PianoRollDisplay::timerCallback() { repaint(); } + +void PianoRollDisplay::setActiveSlice(int index) { activeSlice = index; } +void PianoRollDisplay::setSelectedSlice(int index) { selectedSlice = index; } +void PianoRollDisplay::setTotalSamples(int total) { totalSamples = total; } +void PianoRollDisplay::setOnKeyClick(std::function callback) { onKeyClick = std::move(callback); } + +juce::Colour PianoRollDisplay::sliceColour(int sliceIndex) const +{ + juce::Colour colours[] = { + juce::Colour(0xffff6b6b), juce::Colour(0xff4ecdc4), juce::Colour(0xffffe66d), + juce::Colour(0xffa8e6cf), juce::Colour(0xff8b94ff), juce::Colour(0xffff8b94), + juce::Colour(0xffdda0dd), juce::Colour(0xff87ceeb), juce::Colour(0xff98fb98), + juce::Colour(0xffffb347), juce::Colour(0xffc39bd3), juce::Colour(0xff76d7c4) + }; + return colours[sliceIndex % 12]; +} + +int PianoRollDisplay::rowToSliceIndex(int row) const +{ + int noteRow = numRows - 1 - row; + const auto& slices = sliceManager.getSlices(); + for (int i = 0; i < sliceManager.getNumSlices(); ++i) + { + if (slices[static_cast(i)].midiNote % numRows == noteRow) + return i; + } + return -1; +} + +void PianoRollDisplay::paint(juce::Graphics& g) +{ + auto bounds = getLocalBounds(); + float w = static_cast(bounds.getWidth()); + float h = static_cast(bounds.getHeight()); + float rowHeight = h / static_cast(numRows); + + g.fillAll(juce::Colour(0xff1a1a2e)); + + const auto& slices = sliceManager.getSlices(); + int numSlices = sliceManager.getNumSlices(); + + int total = totalSamples; + if (total <= 0 && numSlices > 0) + total = slices[static_cast(numSlices - 1)].endSample; + + // Draw grid lines + for (int row = 0; row < numRows; ++row) + { + float y = static_cast(row) * rowHeight; + g.setColour(juce::Colour(0x20ffffff)); + g.drawHorizontalLine(static_cast(y), 0.0f, w); + } + g.drawHorizontalLine(static_cast(h), 0.0f, w); + + // Draw slice blocks + if (numSlices > 0 && total > 0) + { + for (int i = 0; i < numSlices; ++i) + { + const auto& slice = slices[static_cast(i)]; + int noteRow = slice.midiNote % numRows; + int row = numRows - 1 - noteRow; + + float xFrac = static_cast(slice.startSample) / static_cast(total); + float endFrac = static_cast(slice.endSample) / static_cast(total); + float x = xFrac * w; + float bw = (endFrac - xFrac) * w; + + float y = static_cast(row) * rowHeight + 2.0f; + float bh = rowHeight - 4.0f; + + bw = juce::jmax(bw, 2.0f); + + juce::Colour col = sliceColour(i); + bool isActive = (i == activeSlice); + bool isSelected = (i == selectedSlice); + + g.setColour(isActive ? col.brighter(0.3f) : col.withAlpha(0.8f)); + g.fillRoundedRectangle(x + 1.0f, y, bw - 2.0f, bh, 3.0f); + + if (isSelected && !isActive) + { + g.setColour(col.brighter(0.5f)); + g.drawRoundedRectangle(x + 1.0f, y, bw - 2.0f, bh, 3.0f, 2.0f); + } + else + { + g.setColour(col.brighter(0.5f).withAlpha(0.6f)); + g.drawRoundedRectangle(x + 1.0f, y, bw - 2.0f, bh, 3.0f, 1.0f); + } + + if (bw > 16.0f) + { + g.setColour(juce::Colours::white); + g.setFont(10.0f); + g.drawText(juce::String(i + 1), static_cast(x + 3.0f), static_cast(y), + static_cast(bw - 6.0f), static_cast(bh), + juce::Justification::centred); + } + } + } +} + +void PianoRollDisplay::resized() {} + +void PianoRollDisplay::mouseDown(const juce::MouseEvent& e) +{ + float rowHeight = static_cast(getHeight()) / static_cast(numRows); + int row = static_cast(static_cast(e.getPosition().getY()) / rowHeight); + row = juce::jlimit(0, numRows - 1, row); + + pressedKey = row; + repaint(); + + int sliceIdx = rowToSliceIndex(row); + if (sliceIdx >= 0 && onKeyClick) + onKeyClick(sliceIdx); +} + +void PianoRollDisplay::mouseUp(const juce::MouseEvent&) +{ + pressedKey = -1; + repaint(); +} diff --git a/Source/PianoRollDisplay.h b/Source/PianoRollDisplay.h new file mode 100644 index 0000000..e343a5f --- /dev/null +++ b/Source/PianoRollDisplay.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include "SliceManager.h" +#include + +class PianoRollDisplay : public juce::Component, + public juce::Timer +{ +public: + static constexpr int numRows = 12; + static constexpr int baseMidiNote = 60; + static constexpr float keyWidth = 50.0f; + + PianoRollDisplay(SliceManager& sm); + ~PianoRollDisplay() override; + + void paint(juce::Graphics& g) override; + void resized() override; + void timerCallback() override; + void mouseDown(const juce::MouseEvent& e) override; + void mouseUp(const juce::MouseEvent& e) override; + + void setActiveSlice(int index); + void setSelectedSlice(int index); + void setTotalSamples(int total); + void setOnKeyClick(std::function callback); + +private: + SliceManager& sliceManager; + int activeSlice = -1; + int selectedSlice = -1; + int totalSamples = 0; + int pressedKey = -1; + + std::function onKeyClick; + + juce::Colour sliceColour(int sliceIndex) const; + int rowToSliceIndex(int row) const; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PianoRollDisplay) +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..5f74337 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,462 @@ +#include "PluginEditor.h" + +MonoSlicerEditor::MonoSlicerEditor(MonoSlicerProcessor& p) + : AudioProcessorEditor(p), + processorRef(p), + waveformDisplay(p.getSliceManager()) +{ + setSize(baseWidth, baseHeight); + + // Title bar buttons + auto setupTitleBtn = [this](juce::Button& btn, const juce::String& tip) { + addAndMakeVisible(btn); + btn.addListener(this); + btn.setTooltip(tip); + btn.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2a2a4a)); + }; + + setupTitleBtn(loadSampleButton, "Load audio file"); + setupTitleBtn(prevSampleButton, "Previous sample in folder"); + setupTitleBtn(nextSampleButton, "Next sample in folder"); + setupTitleBtn(undoButton, "Undo"); + setupTitleBtn(redoButton, "Redo"); + setupTitleBtn(zoomInButton, "Zoom In"); + setupTitleBtn(zoomOutButton, "Zoom Out"); + setupTitleBtn(zoomResetButton, "Reset Zoom"); + + addAndMakeVisible(scaleComboBox); + scaleComboBox.addItem("75%", 1); + scaleComboBox.addItem("100%", 2); + scaleComboBox.addItem("125%", 3); + scaleComboBox.addItem("150%", 4); + scaleComboBox.addItem("175%", 5); + scaleComboBox.addItem("200%", 6); + scaleComboBox.addListener(this); + scaleComboBox.setTooltip("UI Scale"); + scaleAttachment = std::make_unique(processorRef.getAPVTS(), "uiScale", scaleComboBox); + + // Bottom panel buttons + addAndMakeVisible(autoSliceButton); + autoSliceButton.addListener(this); + addAndMakeVisible(clearSlicesButton); + clearSlicesButton.addListener(this); + + addAndMakeVisible(waveformDisplay); + + // Slice knobs + attachSlider(bassSlider, bassLabel, "Bass", "bass", bassAttachment); + attachSlider(trebleSlider, trebleLabel, "Treble", "treble", trebleAttachment); + attachSlider(sensitivitySlider, sensitivityLabel, "Sens.", "sensitivity", sensitivityAttachment); + + // Amp ADSR + attachSlider(ampAttackSlider, ampAttackLabel, "A Atk", "ampAttack", ampAttackAttachment); + attachSlider(ampDecaySlider, ampDecayLabel, "A Dcy", "ampDecay", ampDecayAttachment); + attachSlider(ampSustainSlider, ampSustainLabel, "A Sus", "ampSustain", ampSustainAttachment); + attachSlider(ampReleaseSlider, ampReleaseLabel, "A Rel", "ampRelease", ampReleaseAttachment); + + // Filter knobs + attachSlider(filterCutoffSlider, filterCutoffLabel, "Cutoff", "filterCutoff", filterCutoffAttachment); + attachSlider(filterResoSlider, filterResoLabel, "Reso", "filterReso", filterResoAttachment); + attachSlider(filterEnvDepthSlider, filterEnvDepthLabel, "Env Dph", "filterEnvDepth", filterEnvDepthAttachment); + + // Filter type combo + addAndMakeVisible(filterTypeComboBox); + filterTypeComboBox.addItem("LP12", 1); + filterTypeComboBox.addItem("LP24", 2); + filterTypeComboBox.addItem("HP", 3); + filterTypeComboBox.addItem("BP", 4); + filterTypeComboBox.addItem("Notch", 5); + filterTypeAttachment = std::make_unique(processorRef.getAPVTS(), "filterType", filterTypeComboBox); + filterTypeComboBox.setColour(juce::ComboBox::backgroundColourId, juce::Colour(0xff2a2a4a)); + filterTypeComboBox.setColour(juce::ComboBox::textColourId, juce::Colours::white); + filterTypeComboBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff444466)); + filterTypeComboBox.setColour(juce::ComboBox::arrowColourId, juce::Colours::white); + + // Filter ADSR + attachSlider(filterAttackSlider, filterAttackLabel, "F Atk", "filterAttack", filterAttackAttachment); + attachSlider(filterDecaySlider, filterDecayLabel, "F Dcy", "filterDecay", filterDecayAttachment); + attachSlider(filterSustainSlider, filterSustainLabel, "F Sus", "filterSustain", filterSustainAttachment); + attachSlider(filterReleaseSlider, filterReleaseLabel, "F Rel", "filterRelease", filterReleaseAttachment); + + // Time-stretch controls + addAndMakeVisible(bpmSlider); + bpmSlider.setSliderStyle(juce::Slider::IncDecButtons); + bpmSlider.setTextBoxStyle(juce::Slider::TextBoxLeft, false, 50, 20); + bpmSlider.setRange(20.0, 300.0, 1.0); + bpmSlider.setColour(juce::Slider::thumbColourId, juce::Colours::transparentBlack); + bpmSlider.setColour(juce::Slider::rotarySliderFillColourId, juce::Colour(0xffee8833)); + bpmSlider.setColour(juce::Slider::textBoxTextColourId, juce::Colours::white); + bpmSlider.setColour(juce::Slider::textBoxBackgroundColourId, juce::Colour(0xff2a2a4a)); + bpmSlider.setColour(juce::Slider::textBoxOutlineColourId, juce::Colour(0xff444466)); + bpmSlider.setColour(juce::Slider::backgroundColourId, juce::Colour(0xff2a2a4a)); + bpmSlider.setColour(juce::Slider::trackColourId, juce::Colour(0xffee8833)); + stretchBpmAttachment = std::make_unique(processorRef.getAPVTS(), "stretchBpm", bpmSlider); + + addAndMakeVisible(stretchBeatsComboBox); + stretchBeatsComboBox.addItem("1 beat", 1); + stretchBeatsComboBox.addItem("2 beats", 2); + stretchBeatsComboBox.addItem("4 beats", 3); + stretchBeatsComboBox.addItem("8 beats", 4); + stretchBeatsComboBox.addItem("16 beats", 5); + stretchBeatsComboBox.addItem("32 beats", 6); + stretchBeatsComboBox.setSelectedId(3, juce::dontSendNotification); // default 4 beats + stretchBeatsComboBox.setColour(juce::ComboBox::backgroundColourId, juce::Colour(0xff2a2a4a)); + stretchBeatsComboBox.setColour(juce::ComboBox::textColourId, juce::Colours::white); + stretchBeatsComboBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff444466)); + stretchBeatsComboBox.setColour(juce::ComboBox::arrowColourId, juce::Colours::white); + stretchBeatsAttachment = std::make_unique(processorRef.getAPVTS(), "stretchBeats", stretchBeatsComboBox); + + addAndMakeVisible(stretchButton); + stretchButton.addListener(this); + stretchButton.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2a2a4a)); + + // Key shift combo + addAndMakeVisible(keyShiftComboBox); + for (int i = -12; i <= 12; ++i) + keyShiftComboBox.addItem((i >= 0 ? "+" : "") + juce::String(i) + " st", i + 13); + keyShiftComboBox.setSelectedId(13, juce::dontSendNotification); // 0 semitones + keyShiftComboBox.setColour(juce::ComboBox::backgroundColourId, juce::Colour(0xff2a2a4a)); + keyShiftComboBox.setColour(juce::ComboBox::textColourId, juce::Colours::white); + keyShiftComboBox.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff444466)); + keyShiftComboBox.setColour(juce::ComboBox::arrowColourId, juce::Colours::white); + addAndMakeVisible(keyShiftLabel); + keyShiftLabel.setText("Shift", juce::dontSendNotification); + keyShiftLabel.setJustificationType(juce::Justification::centred); + keyShiftLabel.setFont(juce::Font(juce::FontOptions(11.0f))); + keyShiftLabel.attachToComponent(&keyShiftComboBox, false); + keyShiftAttachment = std::make_unique(processorRef.getAPVTS(), "keyShift", keyShiftComboBox); + + auto allKnobs = { &bassSlider, &trebleSlider, &sensitivitySlider, + &AttackSlider, &DecaySlider, &SustainSlider, &ReleaseSlider, + &filterCutoffSlider, &filterResoSlider, &filterEnvDepthSlider, + &filterAttackSlider, &filterDecaySlider, &filterSustainSlider, &filterReleaseSlider }; + + for (auto* slider : allKnobs) + { + slider->setSliderStyle(juce::Slider::RotaryVerticalDrag); + slider->setTextBoxStyle(juce::Slider::TextBoxBelow, false, 40, 14); + slider->setColour(juce::Slider::thumbColourId, juce::Colours::transparentBlack); + slider->setColour(juce::Slider::rotarySliderFillColourId, juce::Colour(0xffee8833)); + slider->setColour(juce::Slider::rotarySliderOutlineColourId, juce::Colour(0xff332211)); + } + + if (processorRef.getSampleBuffer().getNumSamples() > 0) + { + waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); + } + + waveformDisplay.setOnEmptyAreaClick([this] { + buttonClicked(&loadSampleButton); + }); + + waveformDisplay.setOnKeyClick([this](int midiNote) { + keyClickNote = midiNote; + int pitchClass = midiNote % 12; + int sliceIdx = -1; + const auto& slices = processorRef.getSliceManager().getSlices(); + for (int i = 0; i < static_cast(slices.size()); ++i) + { + if (slices[static_cast(i)].midiNote == pitchClass) + { + sliceIdx = i; + break; + } + } + if (sliceIdx >= 0) + processorRef.triggerSlice(sliceIdx, 0.8f); + else + processorRef.triggerSlice(0, 0.8f); + }); + + startTimerHz(30); +} + +MonoSlicerEditor::~MonoSlicerEditor() +{ + autoSliceButton.removeListener(this); + clearSlicesButton.removeListener(this); + scaleComboBox.removeListener(this); + loadSampleButton.removeListener(this); + prevSampleButton.removeListener(this); + nextSampleButton.removeListener(this); + undoButton.removeListener(this); + redoButton.removeListener(this); + zoomInButton.removeListener(this); + zoomOutButton.removeListener(this); + zoomResetButton.removeListener(this); + stretchButton.removeListener(this); +} + +void MonoSlicerEditor::setupSlider(juce::Slider& slider, juce::Label& label, const juce::String& name) +{ + addAndMakeVisible(slider); + addAndMakeVisible(label); + label.setText(name, juce::dontSendNotification); + label.attachToComponent(&slider, false); + label.setJustificationType(juce::Justification::centred); + label.setFont(juce::Font(juce::FontOptions(13.0f))); +} + +void MonoSlicerEditor::attachSlider(juce::Slider& slider, juce::Label& label, const juce::String& name, + const juce::String& paramId, std::unique_ptr& attachment) +{ + setupSlider(slider, label, name); + attachment = std::make_unique(processorRef.getAPVTS(), paramId, slider); +} + +void MonoSlicerEditor::paint(juce::Graphics& g) +{ + g.fillAll(juce::Colour(0xff0f0f1a)); + + auto titleBar = getLocalBounds().removeFromTop(40); + g.setColour(juce::Colour(0xff1a1a30)); + g.fillRect(titleBar); + + g.setColour(juce::Colours::white); + g.setFont(juce::Font(juce::FontOptions(15.0f, juce::Font::bold))); + g.drawText("MONOSLICER", juce::Rectangle(10, titleBar.getY(), 110, titleBar.getHeight()), + juce::Justification::centredLeft); + + g.setColour(juce::Colour(0xff333355)); + g.drawHorizontalLine(40, 0.0f, static_cast(getWidth())); +} + +void MonoSlicerEditor::resized() +{ + float scaleX = static_cast(getWidth()) / static_cast(baseWidth); + float scaleY = static_cast(getHeight()) / static_cast(baseHeight); + setTransform(juce::AffineTransform::scale(juce::jmin(scaleX, scaleY))); + + auto bounds = getLocalBounds(); + auto titleBar = bounds.removeFromTop(40); + + int tx = 120; + + auto placeBtn = [&](juce::Button& btn, int w) { + btn.setBounds(tx, titleBar.getY() + 8, w, 24); + tx += w + 3; + }; + + placeBtn(loadSampleButton, 44); + placeBtn(prevSampleButton, 24); + placeBtn(nextSampleButton, 24); + + tx += 10; + placeBtn(undoButton, 44); + placeBtn(redoButton, 44); + + tx += 10; + placeBtn(zoomOutButton, 28); + placeBtn(zoomResetButton, 32); + placeBtn(zoomInButton, 28); + + scaleComboBox.setBounds(titleBar.getRight() - 100, titleBar.getY() + 8, 90, 24); + + auto bottomPanel = bounds.removeFromBottom(200); + auto bottomRow = bottomPanel.reduced(8); + + // Bottom panel: buttons + stretch | amp ADSR | filter ADSR | filter knobs + auto leftArea = bottomRow.removeFromLeft(200); + { + auto btnArea = leftArea.removeFromTop(28); + autoSliceButton.setBounds(btnArea.getX(), btnArea.getY(), btnArea.getWidth() / 2 - 2, 24); + clearSlicesButton.setBounds(btnArea.getX() + btnArea.getWidth() / 2 + 2, btnArea.getY(), btnArea.getWidth() / 2 - 2, 24); + leftArea.removeFromTop(4); + // Stretch controls + int stretchW = leftArea.getWidth(); + int col1W = stretchW / 2 - 2; + int col2W = stretchW / 2 - 2; + bpmSlider.setBounds(leftArea.getX(), leftArea.getY(), col1W, 20); + stretchBeatsComboBox.setBounds(leftArea.getX() + col1W + 4, leftArea.getY(), col2W, 20); + leftArea.removeFromTop(24); + stretchButton.setBounds(leftArea.getX(), leftArea.getY(), stretchW, 22); + leftArea.removeFromTop(26); + leftArea.removeFromTop(20); + keyShiftComboBox.setBounds(leftArea.getX(), leftArea.getY(), stretchW, 20); + } + + bottomRow.removeFromLeft(8); + + auto ampSection = bottomRow.removeFromLeft(230); + { + int kw = ampSection.getWidth() / 4; + int k = 0; + for (auto* s : { &AttackSlider, &DecaySlider, &SustainSlider, &ReleaseSlider }) + { + s->setBounds(ampSection.getX() + k * kw, ampSection.getY(), kw, ampSection.getHeight()); + ++k; + } + } + + bottomRow.removeFromLeft(8); + + auto fltAdsSection = bottomRow.removeFromLeft(230); + { + int kw = fltAdsSection.getWidth() / 4; + int k = 0; + for (auto* s : { &filterAttackSlider, &filterDecaySlider, &filterSustainSlider, &filterReleaseSlider }) + { + s->setBounds(fltAdsSection.getX() + k * kw, fltAdsSection.getY(), kw, fltAdsSection.getHeight()); + ++k; + } + } + + bottomRow.removeFromLeft(8); + + auto filterArea = bottomRow; + { + int comboW = 56; + int gap = 4; + int knobAreaX = filterArea.getX() + comboW + gap; + int knobAreaW = filterArea.getWidth() - comboW - gap; + int kw = knobAreaW / 3; + int topRowH = 80; + int bottomRowGap = 24; + int bottomRowH = filterArea.getHeight() - topRowH - bottomRowGap; + filterTypeComboBox.setBounds(filterArea.getX(), filterArea.getY() + 2, comboW, 20); + filterCutoffSlider.setBounds(knobAreaX, filterArea.getY(), kw, topRowH); + filterResoSlider.setBounds(knobAreaX + kw, filterArea.getY(), kw, topRowH); + filterEnvDepthSlider.setBounds(knobAreaX + kw * 2, filterArea.getY(), kw, topRowH); + int bottomY = filterArea.getY() + topRowH + bottomRowGap; + bassSlider.setBounds(knobAreaX, bottomY, kw, bottomRowH); + trebleSlider.setBounds(knobAreaX + kw, bottomY, kw, bottomRowH); + sensitivitySlider.setBounds(knobAreaX + kw * 2, bottomY, kw, bottomRowH); + } + + auto mainArea = bounds.reduced(8); + waveformDisplay.setBounds(mainArea); +} + +void MonoSlicerEditor::timerCallback() +{ + waveformDisplay.setPlaybackPosition(processorRef.currentPlaybackSample.load()); + waveformDisplay.setActiveSliceIndex(processorRef.currentActiveSlice.load()); + waveformDisplay.setSelectedSlice(processorRef.selectedSlice.load()); + + // Release key click note when mouse is released + if (keyClickNote >= 0) + { + auto& editor = *this; + if (!editor.isMouseButtonDown()) + { + processorRef.releaseSlice(); + keyClickNote = -1; + } + } +} + +void MonoSlicerEditor::buttonClicked(juce::Button* button) +{ + if (button == &loadSampleButton) + { + fileChooser.launchAsync(juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this](const juce::FileChooser& fc) + { + auto file = fc.getResult(); + if (file.existsAsFile()) + { + processorRef.loadAudioFile(file); + waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); + + } + }); + } + else if (button == &prevSampleButton) + { + processorRef.loadPreviousSample(); + waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); + + } + else if (button == &nextSampleButton) + { + processorRef.loadNextSample(); + waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); + + } + else if (button == &autoSliceButton) + { + processorRef.getSliceManager().autoSlice(); + + } + else if (button == &clearSlicesButton) + { + processorRef.getSliceManager().clearSlices(); + } + else if (button == &undoButton) + { + processorRef.getSliceManager().undo(); + } + else if (button == &redoButton) + { + processorRef.getSliceManager().redo(); + } + else if (button == &stretchButton) + { + int beatsId = stretchBeatsComboBox.getSelectedId(); + int numBeats = 4; + switch (beatsId) + { + case 1: numBeats = 1; break; + case 2: numBeats = 2; break; + case 3: numBeats = 4; break; + case 4: numBeats = 8; break; + case 5: numBeats = 16; break; + case 6: numBeats = 32; break; + default: numBeats = 4; break; + } + float bpm = static_cast(bpmSlider.getValue()); + processorRef.stretchToBeats(numBeats, bpm); + waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); + + } + else if (button == &zoomInButton) + { + waveformDisplay.zoomIn(); + } + else if (button == &zoomOutButton) + { + waveformDisplay.zoomOut(); + } + else if (button == &zoomResetButton) + { + waveformDisplay.zoomReset(); + } +} + +void MonoSlicerEditor::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) +{ + if (comboBoxThatHasChanged == &scaleComboBox) + { + static const float scales[] = { 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f }; + int id = scaleComboBox.getSelectedId(); + if (id >= 1 && id <= 6) + { + float scale = scales[id - 1]; + setSize(static_cast(baseWidth * scale), static_cast(baseHeight * scale)); + } + } +} + +bool MonoSlicerEditor::isInterestedInFileDrag(const juce::StringArray& files) +{ + for (const auto& f : files) + if (f.endsWithIgnoreCase(".wav") || f.endsWithIgnoreCase(".aiff") || + f.endsWithIgnoreCase(".flac") || f.endsWithIgnoreCase(".ogg")) + return true; + return false; +} + +void MonoSlicerEditor::filesDropped(const juce::StringArray& files, int, int) +{ + for (const auto& f : files) + { + juce::File file(f); + if (file.existsAsFile()) + { + processorRef.loadAudioFile(file); + waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded()); + + break; + } + } +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..ca57d07 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,96 @@ +#pragma once +#include +#include "PluginProcessor.h" +#include "WaveformDisplay.h" + +class MonoSlicerEditor : public juce::AudioProcessorEditor, + private juce::FileDragAndDropTarget, + private juce::Button::Listener, + private juce::ComboBox::Listener, + private juce::Timer +{ +public: + MonoSlicerEditor(MonoSlicerProcessor&); + ~MonoSlicerEditor() override; + + void paint(juce::Graphics&) override; + void resized() override; + + bool isInterestedInFileDrag(const juce::StringArray& files) override; + void filesDropped(const juce::StringArray& files, int x, int y) override; + void buttonClicked(juce::Button* button) override; + void comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) override; + void timerCallback() override; + +private: + MonoSlicerProcessor& processorRef; + + WaveformDisplay waveformDisplay; + + // Bottom panel buttons + juce::TextButton autoSliceButton { "Auto Slice" }; + juce::TextButton clearSlicesButton { "Clear Slices" }; + + // Title bar buttons + juce::TextButton loadSampleButton { "Load" }; + juce::TextButton prevSampleButton { "<" }; + juce::TextButton nextSampleButton { ">" }; + juce::TextButton undoButton { "Undo" }; + juce::TextButton redoButton { "Redo" }; + juce::TextButton zoomInButton { "+" }; + juce::TextButton zoomOutButton { "-" }; + juce::TextButton zoomResetButton { "1:1" }; + juce::ComboBox scaleComboBox; + + static constexpr int baseWidth = 1100; + static constexpr int baseHeight = 550; + + // Slice knobs + juce::Slider bassSlider, trebleSlider, sensitivitySlider; + juce::Label bassLabel, trebleLabel, sensitivityLabel; + + // Amp ADSR knobs + juce::Slider ampAttackSlider, ampDecaySlider, ampSustainSlider, ampReleaseSlider; + juce::Label ampAttackLabel, ampDecayLabel, ampSustainLabel, ampReleaseLabel; + + // Filter type selector + juce::ComboBox filterTypeComboBox; + + // Filter knobs + juce::Slider filterCutoffSlider, filterResoSlider, filterEnvDepthSlider; + juce::Label filterCutoffLabel, filterResoLabel, filterEnvDepthLabel; + + using SliderAttachment = juce::AudioProcessorValueTreeState::SliderAttachment; + using ComboBoxAttachment = juce::AudioProcessorValueTreeState::ComboBoxAttachment; + + // Filter ADSR knobs + juce::Slider filterAttackSlider, filterDecaySlider, filterSustainSlider, filterReleaseSlider; + juce::Label filterAttackLabel, filterDecayLabel, filterSustainLabel, filterReleaseLabel; + + // Time-stretch controls + juce::Slider bpmSlider; + juce::ComboBox stretchBeatsComboBox; + juce::TextButton stretchButton { "Stretch" }; + juce::ComboBox keyShiftComboBox; + juce::Label keyShiftLabel; + std::unique_ptr stretchBpmAttachment; + std::unique_ptr stretchBeatsAttachment; + std::unique_ptr keyShiftAttachment; + std::unique_ptr scaleAttachment; + + std::unique_ptr bassAttachment, trebleAttachment, sensitivityAttachment; + std::unique_ptr ampAttackAttachment, ampDecayAttachment, ampSustainAttachment, ampReleaseAttachment; + std::unique_ptr filterCutoffAttachment, filterResoAttachment, filterEnvDepthAttachment; + std::unique_ptr filterAttackAttachment, filterDecayAttachment, filterSustainAttachment, filterReleaseAttachment; + std::unique_ptr filterTypeAttachment; + + juce::FileChooser fileChooser { "Load Audio File", juce::File{}, "*.wav;*.aiff;*.flac;*.ogg" }; + + void setupSlider(juce::Slider& slider, juce::Label& label, const juce::String& name); + void attachSlider(juce::Slider& slider, juce::Label& label, const juce::String& name, + const juce::String& paramId, std::unique_ptr& attachment); + + int keyClickNote = -1; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MonoSlicerEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..69c3a8c --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,633 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +MonoSlicerProcessor::MonoSlicerProcessor() + : AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)), + apvts(*this, nullptr, "Parameters", createParameterLayout()) +{ + formatManager.registerBasicFormats(); +} + +MonoSlicerProcessor::~MonoSlicerProcessor() {} + +juce::AudioProcessorValueTreeState::ParameterLayout MonoSlicerProcessor::createParameterLayout() +{ + juce::AudioProcessorValueTreeState::ParameterLayout layout; + + layout.add(std::make_unique( + "bass", "Bass", juce::NormalisableRange(0.0f, 2.0f, 0.01f), 1.0f)); + + layout.add(std::make_unique( + "treble", "Treble", juce::NormalisableRange(0.0f, 2.0f, 0.01f), 1.0f)); + + layout.add(std::make_unique( + "sensitivity", "Sensitivity", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 0.5f)); + + // Amp ADSR + layout.add(std::make_unique( + "ampAttack", "Amp Attack", juce::NormalisableRange(0.001f, 2.0f, 0.001f, 0.4f), 0.01f)); + + layout.add(std::make_unique( + "ampDecay", "Amp Decay", juce::NormalisableRange(0.001f, 2.0f, 0.001f, 0.4f), 0.1f)); + + layout.add(std::make_unique( + "ampSustain", "Amp Sustain", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 0.7f)); + + layout.add(std::make_unique( + "ampRelease", "Amp Release", juce::NormalisableRange(0.001f, 5.0f, 0.001f, 0.4f), 0.2f)); + + // Filter + juce::StringArray filterTypeNames { "LP12", "LP24", "HP", "BP", "Notch" }; + layout.add(std::make_unique( + "filterType", "Filter Type", filterTypeNames, 0)); + + layout.add(std::make_unique( + "filterCutoff", "Filter Cutoff", juce::NormalisableRange(20.0f, 20000.0f, 1.0f, 0.3f), 20000.0f)); + + layout.add(std::make_unique( + "filterReso", "Filter Reso", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 0.0f)); + + // Filter ADSR + layout.add(std::make_unique( + "filterAttack", "Flt Attack", juce::NormalisableRange(0.001f, 2.0f, 0.001f, 0.4f), 0.01f)); + + layout.add(std::make_unique( + "filterDecay", "Flt Decay", juce::NormalisableRange(0.001f, 2.0f, 0.001f, 0.4f), 0.3f)); + + layout.add(std::make_unique( + "filterSustain", "Flt Sustain", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 0.5f)); + + layout.add(std::make_unique( + "filterRelease", "Flt Release", juce::NormalisableRange(0.001f, 5.0f, 0.001f, 0.4f), 0.5f)); + + layout.add(std::make_unique( + "filterEnvDepth", "Flt Env Depth", juce::NormalisableRange(0.0f, 1.0f, 0.01f), 0.0f)); + + layout.add(std::make_unique( + "selectViaMidi", "Select Via MIDI", false)); + + layout.add(std::make_unique( + "stretchBpm", "Stretch BPM", juce::NormalisableRange(20.0f, 300.0f, 1.0f), 120.0f)); + + juce::StringArray beatChoices { "1", "2", "4", "8", "16", "32" }; + layout.add(std::make_unique( + "stretchBeats", "Stretch Beats", beatChoices, 2)); + + layout.add(std::make_unique( + "keyShift", "Key Shift", -12, 12, 0)); + + juce::StringArray scaleChoices { "50%", "75%", "100%", "125%", "150%", "200%" }; + layout.add(std::make_unique( + "uiScale", "UI Scale", scaleChoices, 2)); + + return layout; +} + +void MonoSlicerProcessor::prepareToPlay(double sampleRate, int samplesPerBlock) +{ + currentSampleRate = sampleRate; + juce::ignoreUnused(samplesPerBlock); +} + +void MonoSlicerProcessor::releaseResources() {} + +void MonoSlicerProcessor::computeBiquadCoeffs(FilterType type, float cutoff, float resonance, + float sampleRate, float& b0, float& b1, float& b2, + float& a1, float& a2) +{ + cutoff = juce::jlimit(20.0f, static_cast(sampleRate * 0.45), cutoff); + float Q = 0.5f + resonance * 19.5f; // Q range: 0.5 to 20 + float w0 = 2.0f * juce::MathConstants::pi * cutoff / sampleRate; + float cosW0 = std::cos(w0); + float sinW0 = std::sin(w0); + float alpha = sinW0 / (2.0f * Q); + + switch (type) + { + case FilterType::LP12: + { + float norm = 1.0f / (1.0f + alpha); + b0 = (1.0f - cosW0) * 0.5f * norm; + b1 = (1.0f - cosW0) * norm; + b2 = (1.0f - cosW0) * 0.5f * norm; + a1 = -2.0f * cosW0 * norm; + a2 = (1.0f - alpha) * norm; + break; + } + case FilterType::LP24: + { + // Two cascaded LP12 + float norm = 1.0f / (1.0f + alpha); + float tb0 = (1.0f - cosW0) * 0.5f * norm; + float tb1 = (1.0f - cosW0) * norm; + float tb2 = (1.0f - cosW0) * 0.5f * norm; + float ta1 = -2.0f * cosW0 * norm; + float ta2 = (1.0f - alpha) * norm; + // Cascade: multiply two identical biquads + b0 = tb0 * tb0; + b1 = 2.0f * tb0 * tb1; + b2 = 2.0f * tb0 * tb2 + tb1 * tb1; + a1 = 2.0f * ta1 + ta1 * ta1 - 2.0f * ta2; + a2 = ta2 * ta2 - ta1 * ta1 * ta2 + ta1 * ta1; + // Simplified: just use LP12 coefficients with boosted resonance effect + // For numerical stability, approximate LP24 as steeper LP12 + b0 = tb0; b1 = tb1; b2 = tb2; a1 = ta1; a2 = ta2; + // Apply gain compensation for steeper rolloff feel + float gainComp = 1.0f + Q * 0.1f; + b0 *= gainComp; b1 *= gainComp; b2 *= gainComp; + break; + } + case FilterType::HP: + { + float norm = 1.0f / (1.0f + alpha); + b0 = (1.0f + cosW0) * 0.5f * norm; + b1 = -(1.0f + cosW0) * norm; + b2 = (1.0f + cosW0) * 0.5f * norm; + a1 = -2.0f * cosW0 * norm; + a2 = (1.0f - alpha) * norm; + break; + } + case FilterType::BP: + { + float norm = 1.0f / (1.0f + alpha); + b0 = alpha * norm; + b1 = 0.0f; + b2 = -alpha * norm; + a1 = -2.0f * cosW0 * norm; + a2 = (1.0f - alpha) * norm; + break; + } + case FilterType::Notch: + { + float norm = 1.0f / (1.0f + alpha); + b0 = norm; + b1 = -2.0f * cosW0 * norm; + b2 = norm; + a1 = -2.0f * cosW0 * norm; + a2 = (1.0f - alpha) * norm; + break; + } + } +} + +void MonoSlicerProcessor::advanceAmpEnvelope(Voice& voice, int samples) +{ + float attack = apvts.getRawParameterValue("ampAttack")->load(); + float decay = apvts.getRawParameterValue("ampDecay")->load(); + float sustain = apvts.getRawParameterValue("ampSustain")->load(); + float release = apvts.getRawParameterValue("ampRelease")->load(); + + int attackSamples = static_cast(attack * currentSampleRate); + int decaySamples = static_cast(decay * currentSampleRate); + int releaseSamples = static_cast(release * currentSampleRate); + if (attackSamples < 1) attackSamples = 1; + if (decaySamples < 1) decaySamples = 1; + if (releaseSamples < 1) releaseSamples = 1; + + for (int i = 0; i < samples; ++i) + { + switch (voice.ampStage) + { + case AmpStage::Attack: + voice.ampEnv += 1.0f / static_cast(attackSamples); + if (voice.ampEnv >= 1.0f) + { + voice.ampEnv = 1.0f; + voice.ampStage = AmpStage::Decay; + voice.ampSamplesToNext = decaySamples; + } + break; + + case AmpStage::Decay: + voice.ampSamplesToNext--; + if (voice.ampSamplesToNext <= 0) + voice.ampStage = AmpStage::Sustain; + else + voice.ampEnv = 1.0f - (1.0f - sustain) * (1.0f - static_cast(voice.ampSamplesToNext) / static_cast(decaySamples)); + break; + + case AmpStage::Sustain: + voice.ampEnv = sustain; + break; + + case AmpStage::Release: + voice.ampEnv -= 1.0f / static_cast(releaseSamples); + if (voice.ampEnv <= 0.0f) + { + voice.ampEnv = 0.0f; + voice.ampStage = AmpStage::Idle; + voice.active = false; + } + break; + + case AmpStage::Idle: + voice.ampEnv = 0.0f; + voice.active = false; + return; + } + } +} + +void MonoSlicerProcessor::advanceFilterEnvelope(Voice& voice, int samples) +{ + float attack = apvts.getRawParameterValue("filterAttack")->load(); + float decay = apvts.getRawParameterValue("filterDecay")->load(); + float sustain = apvts.getRawParameterValue("filterSustain")->load(); + float release = apvts.getRawParameterValue("filterRelease")->load(); + + int attackSamples = static_cast(attack * currentSampleRate); + int decaySamples = static_cast(decay * currentSampleRate); + int releaseSamples = static_cast(release * currentSampleRate); + if (attackSamples < 1) attackSamples = 1; + if (decaySamples < 1) decaySamples = 1; + if (releaseSamples < 1) releaseSamples = 1; + + for (int i = 0; i < samples; ++i) + { + switch (voice.filterStage) + { + case AmpStage::Attack: + voice.filterEnv += 1.0f / static_cast(attackSamples); + if (voice.filterEnv >= 1.0f) + { + voice.filterEnv = 1.0f; + voice.filterStage = AmpStage::Decay; + voice.filterSamplesToNext = decaySamples; + } + break; + + case AmpStage::Decay: + voice.filterSamplesToNext--; + if (voice.filterSamplesToNext <= 0) + voice.filterStage = AmpStage::Sustain; + else + voice.filterEnv = 1.0f - (1.0f - sustain) * (1.0f - static_cast(voice.filterSamplesToNext) / static_cast(decaySamples)); + break; + + case AmpStage::Sustain: + voice.filterEnv = sustain; + break; + + case AmpStage::Release: + voice.filterEnv -= 1.0f / static_cast(releaseSamples); + if (voice.filterEnv <= 0.0f) voice.filterEnv = 0.0f; + break; + + case AmpStage::Idle: + voice.filterEnv = 0.0f; + break; + } + } +} + +void MonoSlicerProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + buffer.clear(); + + if (auto* ph = getPlayHead()) + { + auto posInfo = ph->getPosition(); + if (posInfo.hasValue()) + { + auto bpmOpt = posInfo->getBpm(); + if (bpmOpt.hasValue() && *bpmOpt > 0) + { + float hostBpm = juce::jlimit(20.0f, 300.0f, static_cast(*bpmOpt)); + if (auto* p = apvts.getParameter("stretchBpm")) + p->setValueNotifyingHost(p->convertTo0to1(hostBpm)); + } + } + } + + sliceManager.setSensitivity(apvts.getRawParameterValue("sensitivity")->load()); + sliceManager.setBassGain(apvts.getRawParameterValue("bass")->load()); + sliceManager.setTrebleGain(apvts.getRawParameterValue("treble")->load()); + + if (sampleBuffer.getNumSamples() == 0) + return; + + const int numSamples = buffer.getNumSamples(); + const int numOutputChannels = buffer.getNumChannels(); + + for (const auto metadata : midiMessages) + { + auto msg = metadata.getMessage(); + if (msg.isNoteOn()) + handleNoteOn(msg.getNoteNumber(), msg.getFloatVelocity()); + else if (msg.isNoteOff()) + handleNoteOff(); + } + + for (auto& voice : voices) + { + if (!voice.active) continue; + + int numSlices = sliceManager.getNumSlices(); + int startSample = 0; + int endSample = sampleBuffer.getNumSamples(); + + if (numSlices > 0) + { + if (voice.sliceIndex < 0 || voice.sliceIndex >= numSlices) + { + voice.active = false; + continue; + } + const auto& slice = sliceManager.getSlice(voice.sliceIndex); + startSample = slice.startSample; + endSample = slice.endSample; + } + + int sliceLength = endSample - startSample; + if (sliceLength <= 0) { voice.active = false; continue; } + + advanceAmpEnvelope(voice, numSamples); + advanceFilterEnvelope(voice, numSamples); + + if (!voice.active) continue; + + int filterTypeIdx = static_cast(apvts.getRawParameterValue("filterType")->load()); + FilterType filterType = static_cast(juce::jlimit(0, 4, filterTypeIdx)); + float baseCutoff = apvts.getRawParameterValue("filterCutoff")->load(); + float resonance = apvts.getRawParameterValue("filterReso")->load(); + float envDepth = apvts.getRawParameterValue("filterEnvDepth")->load(); + float minCutoff = 20.0f; + float maxCutoff = juce::jmin(baseCutoff, static_cast(currentSampleRate * 0.45)); + + float effectiveCutoff = maxCutoff - (maxCutoff - minCutoff) * envDepth * voice.filterEnv; + effectiveCutoff = juce::jlimit(20.0f, maxCutoff, effectiveCutoff); + + float b0, b1, b2, a1, a2; + computeBiquadCoeffs(filterType, effectiveCutoff, resonance, static_cast(currentSampleRate), + b0, b1, b2, a1, a2); + + // Key shift: pitch factor = 2^(semitones/12) + int keyShift = static_cast(apvts.getRawParameterValue("keyShift")->load()); + float pitchFactor = std::pow(2.0f, static_cast(keyShift) / 12.0f); + + const int bufSamples = sampleBuffer.getNumSamples(); + + bool filterActive = (envDepth > 0.001f) + || (baseCutoff < static_cast(currentSampleRate * 0.45) - 1.0f) + || (resonance > 0.001f); + + float startPos = voice.position; + float endPos = startPos; + + for (int ch = 0; ch < numOutputChannels; ++ch) + { + float* outData = buffer.getWritePointer(ch); + auto& filt = (ch == 0) ? voice.filterL : voice.filterR; + if (filterActive) + { + filt.b0 = b0; filt.b1 = b1; filt.b2 = b2; + filt.a1 = a1; filt.a2 = a2; + } + + float chPos = startPos; + for (int i = 0; i < numSamples; ++i) + { + if (chPos >= static_cast(sliceLength)) + break; + + int idx = static_cast(chPos); + float frac = chPos - static_cast(idx); + + int sampleIdx = startSample + idx; + if (sampleIdx >= bufSamples) + break; + + int bufCh = juce::jmin(ch, sampleBuffer.getNumChannels() - 1); + float s0 = sampleBuffer.getSample(bufCh, sampleIdx); + float s1 = (sampleIdx + 1 < bufSamples) ? sampleBuffer.getSample(bufCh, sampleIdx + 1) : s0; + float dry = (s0 + frac * (s1 - s0)) * voice.velocity; + + float output; + if (filterActive) + output = filt.process(dry) * voice.ampEnv; + else + output = dry * voice.ampEnv; + + outData[i] += output; + chPos += pitchFactor; + } + endPos = chPos; + } + voice.position = endPos; + + if (voice.position >= static_cast(sliceLength)) + { + voice.ampStage = AmpStage::Release; + voice.filterStage = AmpStage::Release; + voice.active = false; + } + } + + currentActiveSlice.store(-1); + for (auto& voice : voices) + { + if (voice.active) + { + int numSlices = sliceManager.getNumSlices(); + int baseStart = 0; + if (numSlices > 0 && voice.sliceIndex >= 0 && voice.sliceIndex < numSlices) + baseStart = sliceManager.getSlice(voice.sliceIndex).startSample; + currentPlaybackSample.store(baseStart + static_cast(voice.position)); + currentActiveSlice.store(voice.sliceIndex); + break; + } + } + if (currentActiveSlice.load() < 0) + currentPlaybackSample.store(-1); +} + +void MonoSlicerProcessor::handleNoteOn(int noteNumber, float velocity) +{ + int sliceIdx = noteNumber - 60; + sliceIdx = juce::jlimit(0, juce::jmax(0, sliceManager.getNumSlices() - 1), sliceIdx); + + if (apvts.getRawParameterValue("selectViaMidi")->load() > 0.5f) + selectedSlice.store(sliceIdx); + + for (auto& voice : voices) + { + if (!voice.active) + { + voice.active = true; + voice.sliceIndex = sliceIdx; + voice.position = 0; + voice.velocity = velocity; + + voice.ampStage = AmpStage::Attack; + voice.ampEnv = 0.0f; + + voice.filterStage = AmpStage::Attack; + voice.filterEnv = 0.0f; + + voice.filterL.reset(); + voice.filterR.reset(); + return; + } + } +} + +void MonoSlicerProcessor::handleNoteOff() +{ + for (auto& voice : voices) + { + if (voice.active && voice.ampStage != AmpStage::Release && voice.ampStage != AmpStage::Idle) + { + voice.ampStage = AmpStage::Release; + voice.filterStage = AmpStage::Release; + } + } +} + +void MonoSlicerProcessor::loadAudioFile(const juce::File& file) +{ + std::unique_ptr reader(formatManager.createReaderFor(file)); + if (reader) + { + int numSamples = static_cast(reader->lengthInSamples); + sampleBuffer.setSize(static_cast(reader->numChannels), numSamples); + reader->read(&sampleBuffer, 0, numSamples, 0, true, true); + loadedSampleRate = reader->sampleRate; + currentFile = file; + refreshFolderList(); + sliceManager.setSampleBuffer(&sampleBuffer, loadedSampleRate); + selectedSlice.store(-1); + } +} + +void MonoSlicerProcessor::refreshFolderList() +{ + folderFiles.clear(); + currentFileIndex = -1; + + if (!currentFile.existsAsFile()) return; + + auto dir = currentFile.getParentDirectory(); + juce::DirectoryIterator iter(dir, false, "*.wav;*.aiff;*.flac;*.ogg"); + while (iter.next()) + { + auto f = iter.getFile(); + if (f.existsAsFile()) + { + folderFiles.add(f); + if (f == currentFile) + currentFileIndex = folderFiles.size() - 1; + } + } +} + +void MonoSlicerProcessor::loadNextSample() +{ + if (folderFiles.isEmpty()) return; + int nextIdx = (currentFileIndex + 1) % folderFiles.size(); + loadAudioFile(folderFiles[nextIdx]); +} + +void MonoSlicerProcessor::loadPreviousSample() +{ + if (folderFiles.isEmpty()) return; + int prevIdx = (currentFileIndex - 1 + folderFiles.size()) % folderFiles.size(); + loadAudioFile(folderFiles[prevIdx]); +} + +void MonoSlicerProcessor::triggerSlice(int sliceIndex, float velocity) +{ + int noteNumber = sliceIndex + 60; + handleNoteOn(noteNumber, velocity); +} + +void MonoSlicerProcessor::releaseSlice() +{ + handleNoteOff(); +} + +juce::AudioProcessorEditor* MonoSlicerProcessor::createEditor() +{ + return new MonoSlicerEditor(*this); +} + +void MonoSlicerProcessor::getStateInformation(juce::MemoryBlock& destData) +{ + auto state = apvts.copyState(); + juce::MemoryOutputStream stream(destData, false); + state.writeToStream(stream); +} + +void MonoSlicerProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + auto stream = juce::MemoryInputStream(data, static_cast(sizeInBytes), false); + auto state = juce::ValueTree::readFromStream(stream); + if (state.isValid()) + apvts.replaceState(state); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new MonoSlicerProcessor(); +} + +void MonoSlicerProcessor::stretchToBeats(int numBeats, float bpm) +{ + if (sampleBuffer.getNumSamples() == 0 || bpm <= 0.0f || numBeats <= 0) return; + + double sr = static_cast(loadedSampleRate); + int currentLength = sampleBuffer.getNumSamples(); + double currentDuration = static_cast(currentLength) / sr; + double targetDuration = static_cast(numBeats) * 60.0 / static_cast(bpm); + double ratio = targetDuration / currentDuration; + + if (std::abs(ratio - 1.0) < 0.001) return; + + int newLength = static_cast(static_cast(currentLength) * ratio); + if (newLength < 1) return; + + const int numChannels = sampleBuffer.getNumChannels(); + + // Save slices before setSampleBuffer clears them + auto savedSlices = sliceManager.getSlices(); + + juce::AudioBuffer newBuffer(numChannels, newLength); + for (int ch = 0; ch < numChannels; ++ch) + { + const float* inData = sampleBuffer.getReadPointer(ch); + float* outData = newBuffer.getWritePointer(ch); + + for (int i = 0; i < newLength; ++i) + { + double srcPos = static_cast(i) / ratio; + int idx = static_cast(srcPos); + float frac = static_cast(srcPos - static_cast(idx)); + if (idx >= currentLength - 1) + { + outData[i] = inData[currentLength - 1]; + } + else + { + outData[i] = inData[idx] * (1.0f - frac) + inData[idx + 1] * frac; + } + } + } + + sampleBuffer.setSize(numChannels, newLength, false, false, false); + for (int ch = 0; ch < numChannels; ++ch) + sampleBuffer.copyFrom(ch, 0, newBuffer, ch, 0, newLength); + + sliceManager.setSampleBuffer(&sampleBuffer, loadedSampleRate); + + for (const auto& s : savedSlices) + { + int newStart = static_cast(static_cast(s.startSample) * ratio); + int newEnd = static_cast(static_cast(s.endSample) * ratio); + if (newEnd > newStart + 100) + sliceManager.addSliceManual(newStart); + } + + selectedSlice.store(-1); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..10433da --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,125 @@ +#pragma once +#include +#include +#include +#include "SliceManager.h" + +class MonoSlicerProcessor : public juce::AudioProcessor +{ +public: + MonoSlicerProcessor(); + ~MonoSlicerProcessor() override; + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + + const juce::String getName() const override { return JucePlugin_Name; } + + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return true; } + bool isMidiEffect() const override { return false; } + 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 {}; } + void changeProgramName(int, const juce::String&) override {} + + void getStateInformation(juce::MemoryBlock& destData) override; + void setStateInformation(const void* data, int sizeInBytes) override; + + void loadAudioFile(const juce::File& file); + void loadNextSample(); + void loadPreviousSample(); + void triggerSlice(int sliceIndex, float velocity = 0.8f); + void releaseSlice(); + void stretchToBeats(int numBeats, float bpm); + void psolaStretch(int targetLength); + float getHostBpm() const; + + juce::AudioProcessorValueTreeState& getAPVTS() { return apvts; } + SliceManager& getSliceManager() { return sliceManager; } + + const juce::AudioBuffer& getSampleBuffer() const { return sampleBuffer; } + double getSampleRateLoaded() const { return loadedSampleRate; } + const juce::File& getCurrentFile() const { return currentFile; } + int getCurrentFileIndex() const { return currentFileIndex; } + int getNumFilesInFolder() const { return static_cast(folderFiles.size()); } + + std::atomic currentPlaybackSample { -1 }; + std::atomic currentActiveSlice { -1 }; + std::atomic selectedSlice { -1 }; + +private: + juce::AudioProcessorValueTreeState apvts; + juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + + SliceManager sliceManager; + + juce::AudioFormatManager formatManager; + juce::AudioBuffer sampleBuffer; + double loadedSampleRate = 44100.0; + double currentSampleRate = 44100.0; + + juce::File currentFile; + juce::Array folderFiles; + int currentFileIndex = -1; + void refreshFolderList(); + + enum class AmpStage { Idle, Attack, Decay, Sustain, Release }; + enum class FilterType { LP12, LP24, HP, BP, Notch }; + + struct BiquadState + { + float x1 = 0.0f, x2 = 0.0f, y1 = 0.0f, y2 = 0.0f; + float b0 = 0.0f, b1 = 0.0f, b2 = 0.0f; + float a1 = 0.0f, a2 = 0.0f; + + void reset() { x1 = x2 = y1 = y2 = 0.0f; } + + float process(float input) + { + float output = b0 * input + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; + x2 = x1; x1 = input; + y2 = y1; y1 = output; + return output; + } + }; + + struct Voice + { + bool active = false; + int sliceIndex = -1; + float position = 0.0f; + float velocity = 0.0f; + bool midiNoteSent = false; + + AmpStage ampStage = AmpStage::Idle; + float ampEnv = 0.0f; + int ampSamplesToNext = 0; + + AmpStage filterStage = AmpStage::Idle; + float filterEnv = 0.0f; + int filterSamplesToNext = 0; + + BiquadState filterL, filterR; + }; + + static constexpr int maxVoices = 16; + std::array voices; + + void handleNoteOn(int noteNumber, float velocity); + void handleNoteOff(); + + void advanceAmpEnvelope(Voice& voice, int samples); + void advanceFilterEnvelope(Voice& voice, int samples); + void computeBiquadCoeffs(FilterType type, float cutoff, float resonance, float sampleRate, + float& b0, float& b1, float& b2, float& a1, float& a2); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MonoSlicerProcessor) +}; diff --git a/Source/SliceManager.cpp b/Source/SliceManager.cpp new file mode 100644 index 0000000..bf092ae --- /dev/null +++ b/Source/SliceManager.cpp @@ -0,0 +1,359 @@ +#include "SliceManager.h" + +SliceManager::SliceManager() = default; + +void SliceManager::setSampleBuffer(juce::AudioBuffer* buffer, double sampleRate) +{ + sampleBuffer = buffer; + currentSampleRate = sampleRate; + slices.clear(); +} + +void SliceManager::setSensitivity(float s) { sensitivity = juce::jlimit(0.0f, 1.0f, s); } +void SliceManager::setBassGain(float g) { bassGain = juce::jlimit(0.0f, 2.0f, g); } +void SliceManager::setTrebleGain(float g) { trebleGain = juce::jlimit(0.0f, 2.0f, g); } + +void SliceManager::autoSlice() +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) + return; + + pushHistory(); + + const int numSamples = sampleBuffer->getNumSamples(); + const int numChannels = sampleBuffer->getNumChannels(); + + // Mix down to mono + std::vector mono(static_cast(numSamples), 0.0f); + for (int ch = 0; ch < numChannels; ++ch) + { + const float* data = sampleBuffer->getReadPointer(ch); + for (int i = 0; i < numSamples; ++i) + mono[static_cast(i)] += data[i] / static_cast(numChannels); + } + + // Apply bass/treble shaping + float bassCutoff = 200.0f; + float trebleCutoff = 3000.0f; + + auto shaped = mono; + if (bassGain != 1.0f) + { + auto bassComponent = lowpassFilter(mono, bassCutoff); + for (int i = 0; i < numSamples; ++i) + shaped[static_cast(i)] += (bassComponent[static_cast(i)] - mono[static_cast(i)]) * (bassGain - 1.0f); + } + if (trebleGain != 1.0f) + { + auto trebleComponent = highpassFilter(mono, trebleCutoff); + for (int i = 0; i < numSamples; ++i) + shaped[static_cast(i)] += (trebleComponent[static_cast(i)] - mono[static_cast(i)]) * (trebleGain - 1.0f); + } + + // Compute envelope + auto envelope = computeEnvelope(shaped.data(), numSamples); + + // Detect onsets + float threshold = 0.05f + (1.0f - sensitivity) * 0.45f; + auto onsets = detectOnsets(envelope, threshold); + + slices.clear(); + if (onsets.empty()) + { + // Fallback: single slice + slices.push_back({ 0, numSamples, 0 }); + } + else + { + // Add beginning if not close to first onset + if (onsets[0] > static_cast(numSamples / 100)) + onsets.insert(onsets.begin(), 0); + + for (size_t i = 0; i < onsets.size(); ++i) + { + int start = static_cast(onsets[i]); + int end = (i + 1 < onsets.size()) ? static_cast(onsets[i + 1]) : numSamples; + if (end > start + 100) // minimum slice length + slices.push_back({ start, end, 0 }); + } + } + + assignMidiNotes(); +} + +void SliceManager::clearSlices() +{ + pushHistory(); + slices.clear(); +} + +void SliceManager::addSliceManual(int samplePos) +{ + if (!sampleBuffer) return; + const int numSamples = sampleBuffer->getNumSamples(); + samplePos = juce::jlimit(1, numSamples - 1, samplePos); + + if (slices.empty()) + { + pushHistory(); + slices.push_back({ 0, numSamples, 0 }); + assignMidiNotes(); + return; + } + + int idx = findSliceIndexForSample(samplePos); + if (idx < 0) + { + pushHistory(); + slices.push_back({ samplePos, numSamples, 0 }); + sortSlices(); + assignMidiNotes(); + return; + } + + auto& parent = slices[static_cast(idx)]; + if (samplePos <= parent.startSample || samplePos >= parent.endSample) + return; + + pushHistory(); + + int oldEnd = parent.endSample; + parent.endSample = samplePos; + + slices.insert(slices.begin() + idx + 1, { samplePos, oldEnd, 0 }); + + assignMidiNotes(); +} + +void SliceManager::removeSliceAt(int samplePos, int tolerance) +{ + for (auto it = slices.begin(); it != slices.end(); ++it) + { + int dist = std::abs(it->startSample - samplePos); + if (dist <= tolerance) + { + pushHistory(); + slices.erase(it); + assignMidiNotes(); + return; + } + } +} + +void SliceManager::moveSlice(int fromSample, int toSample) +{ + for (size_t i = 0; i < slices.size(); ++i) + { + if (std::abs(slices[i].startSample - fromSample) < 10) + { + pushHistory(); + slices[i].startSample = juce::jlimit(0, sampleBuffer->getNumSamples() - 1, toSample); + if (i > 0) + slices[i - 1].endSample = slices[i].startSample; + slices[i].endSample = juce::jmax(slices[i].endSample, slices[i].startSample + 1); + sortSlices(); + assignMidiNotes(); + return; + } + } +} + +void SliceManager::trimStart(int sample) +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) return; + sample = juce::jlimit(0, sampleBuffer->getNumSamples() - 1, sample); + if (sample <= 0) return; + + pushHistory(); + + const int numChannels = sampleBuffer->getNumChannels(); + const int oldNumSamples = sampleBuffer->getNumSamples(); + const int newNumSamples = oldNumSamples - sample; + + juce::AudioBuffer newBuffer(numChannels, newNumSamples); + for (int ch = 0; ch < numChannels; ++ch) + newBuffer.copyFrom(ch, 0, *sampleBuffer, ch, sample, newNumSamples); + + sampleBuffer->setSize(numChannels, newNumSamples, false, false, false); + for (int ch = 0; ch < numChannels; ++ch) + sampleBuffer->copyFrom(ch, 0, newBuffer, ch, 0, newNumSamples); + + for (auto& s : slices) + { + s.startSample -= sample; + s.endSample -= sample; + } + + auto it = std::remove_if(slices.begin(), slices.end(), + [](const Slice& s) { return s.endSample <= 0 || s.startSample < 0; }); + slices.erase(it, slices.end()); + + for (auto& s : slices) + { + s.startSample = juce::jmax(0, s.startSample); + s.endSample = juce::jmin(newNumSamples, s.endSample); + } + + assignMidiNotes(); +} + +void SliceManager::trimEnd(int sample) +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) return; + const int numSamples = sampleBuffer->getNumSamples(); + sample = juce::jlimit(0, numSamples, sample); + if (sample >= numSamples) return; + + pushHistory(); + + const int numChannels = sampleBuffer->getNumChannels(); + + juce::AudioBuffer newBuffer(numChannels, sample); + for (int ch = 0; ch < numChannels; ++ch) + newBuffer.copyFrom(ch, 0, *sampleBuffer, ch, 0, sample); + + sampleBuffer->setSize(numChannels, sample, false, false, false); + for (int ch = 0; ch < numChannels; ++ch) + sampleBuffer->copyFrom(ch, 0, newBuffer, ch, 0, sample); + + auto it = std::remove_if(slices.begin(), slices.end(), + [sample](const Slice& s) { return s.startSample >= sample; }); + slices.erase(it, slices.end()); + + for (auto& s : slices) + s.endSample = juce::jmin(sample, s.endSample); + + assignMidiNotes(); +} + +int SliceManager::findSliceIndexForSample(int samplePos) const +{ + for (int i = 0; i < static_cast(slices.size()); ++i) + { + if (samplePos >= slices[static_cast(i)].startSample && samplePos < slices[static_cast(i)].endSample) + return i; + } + return -1; +} + +void SliceManager::sortSlices() +{ + std::sort(slices.begin(), slices.end(), + [](const Slice& a, const Slice& b) { return a.startSample < b.startSample; }); +} + +void SliceManager::assignMidiNotes() +{ + for (int i = 0; i < static_cast(slices.size()); ++i) + slices[static_cast(i)].midiNote = i % 12; +} + +std::vector SliceManager::computeEnvelope(const float* data, int numSamples) +{ + const int windowSize = juce::jmax(1, static_cast(currentSampleRate * 0.01)); // 10ms window + const int numWindows = (numSamples + windowSize - 1) / windowSize; + std::vector envelope(static_cast(numWindows), 0.0f); + + for (int w = 0; w < numWindows; ++w) + { + int start = w * windowSize; + int end = juce::jmin(start + windowSize, numSamples); + float sum = 0.0f; + for (int i = start; i < end; ++i) + sum += data[i] * data[i]; + envelope[static_cast(w)] = std::sqrt(sum / static_cast(end - start)); + } + return envelope; +} + +std::vector SliceManager::lowpassFilter(const std::vector& input, float cutoffHz) +{ + std::vector output(input.size()); + float rc = 1.0f / (2.0f * juce::MathConstants::pi * cutoffHz); + float dt = 1.0f / static_cast(currentSampleRate); + float alpha = dt / (rc + dt); + + output[0] = input[0]; + for (size_t i = 1; i < input.size(); ++i) + output[i] = output[i - 1] + alpha * (input[i] - output[i - 1]); + return output; +} + +std::vector SliceManager::highpassFilter(const std::vector& input, float cutoffHz) +{ + auto lowpassed = lowpassFilter(input, cutoffHz); + std::vector output(input.size()); + for (size_t i = 0; i < input.size(); ++i) + output[i] = input[i] - lowpassed[i]; + return output; +} + +std::vector SliceManager::detectOnsets(const std::vector& envelope, float threshold) +{ + std::vector onsets; + if (envelope.size() < 2) return onsets; + + // Compute first derivative of envelope + std::vector diff(envelope.size()); + diff[0] = 0.0f; + for (size_t i = 1; i < envelope.size(); ++i) + diff[i] = envelope[i] - envelope[i - 1]; + + // Peak picking on derivative + const int windowSize = juce::jmax(1, static_cast(currentSampleRate * 0.01)); + + for (size_t i = 1; i < diff.size() - 1; ++i) + { + if (diff[i] > threshold && diff[i] > diff[i - 1] && diff[i] >= diff[i + 1]) + { + // Check minimum distance from last onset + float samplePos = static_cast(i) * static_cast(windowSize); + bool tooClose = false; + for (float o : onsets) + { + if (std::abs(samplePos - o) < currentSampleRate * 0.02) // 20ms minimum gap + { + tooClose = true; + break; + } + } + if (!tooClose) + onsets.push_back(samplePos); + } + } + + return onsets; +} + +void SliceManager::pushHistory() +{ + undoStack.push_back(slices); + if (undoStack.size() > maxHistory) + undoStack.erase(undoStack.begin()); + redoStack.clear(); +} + +void SliceManager::truncateHistory() +{ + undoStack.clear(); + redoStack.clear(); +} + +void SliceManager::undo() +{ + if (undoStack.empty()) return; + redoStack.push_back(slices); + slices = undoStack.back(); + undoStack.pop_back(); +} + +void SliceManager::redo() +{ + if (redoStack.empty()) return; + undoStack.push_back(slices); + slices = redoStack.back(); + redoStack.pop_back(); +} + +bool SliceManager::canUndo() const { return !undoStack.empty(); } +bool SliceManager::canRedo() const { return !redoStack.empty(); } diff --git a/Source/SliceManager.h b/Source/SliceManager.h new file mode 100644 index 0000000..8acd1c2 --- /dev/null +++ b/Source/SliceManager.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include + +class SliceManager +{ +public: + struct Slice + { + int startSample; + int endSample; + int midiNote; // halftone offset from C3 (0 = C3, 1 = C#3, etc.) + }; + + SliceManager(); + + void setSampleBuffer(juce::AudioBuffer* buffer, double sampleRate); + void setSensitivity(float s); + void setBassGain(float g); + void setTrebleGain(float g); + + void autoSlice(); + void clearSlices(); + void addSliceManual(int samplePos); + void removeSliceAt(int samplePos, int tolerance = 5); + void moveSlice(int fromSample, int toSample); + + void trimStart(int sample); + void trimEnd(int sample); + + void undo(); + void redo(); + bool canUndo() const; + bool canRedo() const; + + const std::vector& getSlices() const { return slices; } + int getNumSlices() const { return static_cast(slices.size()); } + const Slice& getSlice(int index) const { return slices[static_cast(index)]; } + + int findSliceIndexForSample(int samplePos) const; + + juce::AudioBuffer* getMutableBuffer() { return sampleBuffer; } + +private: + void sortSlices(); + void assignMidiNotes(); + void pushHistory(); + void truncateHistory(); + + std::vector slices; + std::vector> undoStack; + std::vector> redoStack; + static constexpr size_t maxHistory = 50; + juce::AudioBuffer* sampleBuffer = nullptr; + double currentSampleRate = 44100.0; + + float sensitivity = 0.5f; + float bassGain = 1.0f; + float trebleGain = 1.0f; + + std::vector computeEnvelope(const float* channelData, int numSamples); + std::vector lowpassFilter(const std::vector& input, float cutoffHz); + std::vector highpassFilter(const std::vector& input, float cutoffHz); + std::vector detectOnsets(const std::vector& envelope, float threshold); +}; diff --git a/Source/WaveformDisplay.cpp b/Source/WaveformDisplay.cpp new file mode 100644 index 0000000..f82244b --- /dev/null +++ b/Source/WaveformDisplay.cpp @@ -0,0 +1,692 @@ +#include "WaveformDisplay.h" + +WaveformDisplay::WaveformDisplay(SliceManager& sm) : sliceManager(sm) +{ + addAndMakeVisible(hScrollBar); + hScrollBar.addListener(this); + hScrollBar.setRangeLimits(0.0, 1.0); + hScrollBar.setCurrentRange(0.0, 1.0); + + auto setupScrollBtn = [this](juce::TextButton& btn) { + addAndMakeVisible(btn); + btn.setColour(juce::TextButton::buttonColourId, juce::Colour(0xff2a2a4a)); + }; + setupScrollBtn(scrollLeftBtn); + setupScrollBtn(scrollRightBtn); + + startTimerHz(30); +} + +WaveformDisplay::~WaveformDisplay() +{ + stopTimer(); + hScrollBar.removeListener(this); +} + +void WaveformDisplay::timerCallback() { repaint(); } + +void WaveformDisplay::setSampleBuffer(const juce::AudioBuffer* buffer, double sr) +{ + sampleBuffer = buffer; + sampleRate = sr; + zoomFactor = 1.0f; + scrollOffset = 0.0f; + overviewDirty = true; + rebuildOverview(); + syncScrollBar(); + repaint(); +} + +void WaveformDisplay::setPlaybackPosition(int sample) { playbackSample = sample; } +void WaveformDisplay::setActiveSliceIndex(int index) { activeSliceIndex = index; } +void WaveformDisplay::setSelectedSlice(int index) { selectedSliceIndex = index; } +void WaveformDisplay::setOnEmptyAreaClick(std::function callback) { onEmptyAreaClick = std::move(callback); } +void WaveformDisplay::setOnKeyClick(std::function callback) { onKeyClick = std::move(callback); } + +void WaveformDisplay::zoomIn() +{ + zoomFactor *= 1.5f; + zoomFactor = juce::jlimit(1.0f, 64.0f, zoomFactor); + syncScrollBar(); + repaint(); +} + +void WaveformDisplay::zoomOut() +{ + zoomFactor /= 1.5f; + zoomFactor = juce::jlimit(1.0f, 64.0f, zoomFactor); + syncScrollBar(); + repaint(); +} + +void WaveformDisplay::zoomReset() +{ + zoomFactor = 1.0f; + scrollOffset = 0.0f; + syncScrollBar(); + repaint(); +} + +void WaveformDisplay::syncScrollBar() +{ + if (updatingScroll) return; + updatingScroll = true; + float visibleRatio = 1.0f / zoomFactor; + hScrollBar.setCurrentRange(scrollOffset, visibleRatio); + updatingScroll = false; +} + +void WaveformDisplay::scrollBarMoved(juce::ScrollBar*, double) +{ + if (updatingScroll) return; + updatingScroll = true; + scrollOffset = static_cast(hScrollBar.getCurrentRangeStart()); + scrollOffset = juce::jlimit(0.0f, 1.0f, scrollOffset); + updatingScroll = false; + repaint(); +} + +void WaveformDisplay::rebuildOverview() +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) + return; + + const int numSamples = sampleBuffer->getNumSamples(); + const int numChannels = sampleBuffer->getNumChannels(); + const int blockSize = juce::jmax(1, numSamples / overviewResolution); + + overviewBuffer.setSize(2, overviewResolution, false, false, false); + for (int i = 0; i < overviewResolution; ++i) + { + int start = i * blockSize; + int end = juce::jmin(start + blockSize, numSamples); + float minVal = 0.0f, maxVal = 0.0f; + + for (int s = start; s < end; ++s) + { + float avg = 0.0f; + for (int ch = 0; ch < numChannels; ++ch) + avg += sampleBuffer->getSample(ch, s); + avg /= static_cast(juce::jmax(1, numChannels)); + minVal = juce::jmin(minVal, avg); + maxVal = juce::jmax(maxVal, avg); + } + + overviewBuffer.setSample(0, i, minVal); + overviewBuffer.setSample(1, i, maxVal); + } + + overviewDirty = false; +} + +juce::Rectangle WaveformDisplay::getWaveformArea() const +{ + auto b = getLocalBounds().toFloat(); + return b.withTrimmedLeft(keyWidth).withTrimmedBottom(scrollbarHeight); +} + +int WaveformDisplay::getVisibleStart() const +{ + int total = getNumSamples(); + int visible = static_cast(static_cast(total) / zoomFactor); + return static_cast(scrollOffset * static_cast(juce::jmax(0, total - visible))); +} + +int WaveformDisplay::getVisibleEnd() const +{ + int total = getNumSamples(); + int visible = static_cast(static_cast(total) / zoomFactor); + return juce::jmin(total, getVisibleStart() + visible); +} + +int WaveformDisplay::xToSample(int x) const +{ + auto area = getWaveformArea(); + float ratio = static_cast(x - static_cast(area.getX())) / area.getWidth(); + ratio = juce::jlimit(0.0f, 1.0f, ratio); + int start = getVisibleStart(); + int end = getVisibleEnd(); + return start + static_cast(ratio * static_cast(end - start)); +} + +int WaveformDisplay::sampleToX(int sample) const +{ + auto area = getWaveformArea(); + int start = getVisibleStart(); + int end = getVisibleEnd(); + int range = end - start; + if (range <= 0) return static_cast(area.getX()); + float ratio = static_cast(sample - start) / static_cast(range); + return static_cast(area.getX() + ratio * area.getWidth()); +} + +int WaveformDisplay::keyRowToMidiNote(int row) const +{ + const int numRows = 12; + const int baseMidiNote = 48; + return baseMidiNote + (numRows - 1 - row); +} + +int WaveformDisplay::midiNoteToSliceIndex(int note) const +{ + const auto& slices = sliceManager.getSlices(); + int numSlices = sliceManager.getNumSlices(); + for (int i = 0; i < numSlices; ++i) + { + if (slices[static_cast(i)].midiNote == note) + return i; + } + return -1; +} + +int WaveformDisplay::findNearestSliceAtPixel(int pixelX, int tolerancePx) const +{ + const auto& slices = sliceManager.getSlices(); + int bestIdx = -1; + int bestDist = tolerancePx + 1; + for (int i = 0; i < static_cast(slices.size()); ++i) + { + int markerX = sampleToX(slices[static_cast(i)].startSample); + int dist = std::abs(pixelX - markerX); + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + return bestIdx; +} + +void WaveformDisplay::paint(juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.fillAll(juce::Colour(0xff1a1a2e)); + + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) + { + auto textArea = bounds.withTrimmedBottom(scrollbarHeight); + g.setColour(juce::Colours::grey); + g.setFont(14.0f); + g.drawText("Drop or load an audio file", textArea, juce::Justification::centred); + hScrollBar.setBounds(bounds.removeFromBottom(scrollbarHeight).toNearestInt()); + return; + } + + auto waveBounds = bounds.withTrimmedBottom(scrollbarHeight); + + // Left key area (matches piano roll) + drawKeys(g, waveBounds.withWidth(keyWidth)); + + // Ruler across full width + drawRuler(g, waveBounds.removeFromTop(20.0f)); + + // Waveform and markers in the area to the right of keys + auto waveArea = waveBounds.withTrimmedLeft(keyWidth); + drawWaveform(g, waveArea); + drawSliceMarkers(g, waveArea); + + // Scrollbar at bottom + hScrollBar.setBounds(bounds.getBottomLeft().getX(), + bounds.getBottom() - scrollbarHeight, + bounds.getWidth(), scrollbarHeight); +} + +void WaveformDisplay::drawKeys(juce::Graphics& g, juce::Rectangle bounds) +{ + g.setColour(juce::Colour(0xff222233)); + g.fillRect(bounds); + + const int numRows = 12; + float rowHeight = bounds.getHeight() / static_cast(numRows); + + for (int row = 0; row < numRows; ++row) + { + float y = bounds.getY() + static_cast(row) * rowHeight; + int noteInOctave = (numRows - 1 - row) % 12; + bool isBlack = (noteInOctave == 1 || noteInOctave == 3 || noteInOctave == 6 || + noteInOctave == 8 || noteInOctave == 10); + + int midiNote = keyRowToMidiNote(row); + + juce::Colour baseCol = isBlack ? juce::Colour(0xff333344) : juce::Colour(0xff555566); + + // Highlight row if it has a slice and that slice is active + int sliceIdx = midiNoteToSliceIndex(midiNote); + if (sliceIdx >= 0 && sliceIdx == activeSliceIndex) + { + juce::Colour markerColours[] = { + juce::Colour(0xffff6b6b), juce::Colour(0xff4ecdc4), juce::Colour(0xffffe66d), + juce::Colour(0xffa8e6cf), juce::Colour(0xffff8b94), juce::Colour(0xffdda0dd) + }; + baseCol = markerColours[sliceIdx % 6].withAlpha(0.25f); + } + + g.setColour(baseCol); + g.fillRect(bounds.getX(), y, bounds.getWidth(), rowHeight); + + g.setColour(juce::Colour(0xffaaaaaa)); + g.drawRect(bounds.getX(), y, bounds.getWidth(), rowHeight, 0.5f); + + const char* noteNames[] = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; + int noteIdx = midiNote % 12; + int octave = midiNote / 12 - 1; + juce::String name = juce::String(noteNames[noteIdx]) + juce::String(octave); + + g.setColour(juce::Colours::white.withAlpha(0.6f)); + g.setFont(juce::Font(juce::FontOptions(8.0f))); + g.drawText(name, static_cast(bounds.getX() + 2.0f), static_cast(y + 1.0f), + static_cast(bounds.getWidth() - 4.0f), static_cast(rowHeight - 2.0f), + juce::Justification::centredRight); + } +} + +void WaveformDisplay::drawWaveform(juce::Graphics& g, juce::Rectangle bounds) +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) return; + + int start = getVisibleStart(); + int end = getVisibleEnd(); + int total = getNumSamples(); + float w = bounds.getWidth(); + float h = bounds.getHeight(); + float midY = bounds.getY() + h * 0.5f; + + // Highlight active slice region + if (activeSliceIndex >= 0 && activeSliceIndex < sliceManager.getNumSlices()) + { + const auto& slice = sliceManager.getSlice(activeSliceIndex); + int sStart = juce::jmax(slice.startSample, start); + int sEnd = juce::jmin(slice.endSample, end); + if (sEnd > sStart) + { + float x1 = bounds.getX() + static_cast(sStart - start) / static_cast(end - start) * w; + float x2 = bounds.getX() + static_cast(sEnd - start) / static_cast(end - start) * w; + + juce::Colour markerColours[] = { + juce::Colour(0xffff6b6b), juce::Colour(0xff4ecdc4), juce::Colour(0xffffe66d), + juce::Colour(0xffa8e6cf), juce::Colour(0xffff8b94), juce::Colour(0xffdda0dd) + }; + g.setColour(markerColours[activeSliceIndex % 6].withAlpha(0.12f)); + g.fillRect(x1, bounds.getY(), x2 - x1, h); + } + } + + // Highlight selected slice region (subtler than active) + if (selectedSliceIndex >= 0 && selectedSliceIndex < sliceManager.getNumSlices() + && selectedSliceIndex != activeSliceIndex) + { + const auto& slice = sliceManager.getSlice(selectedSliceIndex); + int sStart = juce::jmax(slice.startSample, start); + int sEnd = juce::jmin(slice.endSample, end); + if (sEnd > sStart) + { + float x1 = bounds.getX() + static_cast(sStart - start) / static_cast(end - start) * w; + float x2 = bounds.getX() + static_cast(sEnd - start) / static_cast(end - start) * w; + + juce::Colour markerColours[] = { + juce::Colour(0xffff6b6b), juce::Colour(0xff4ecdc4), juce::Colour(0xffffe66d), + juce::Colour(0xffa8e6cf), juce::Colour(0xffff8b94), juce::Colour(0xffdda0dd) + }; + g.setColour(markerColours[selectedSliceIndex % 6].withAlpha(0.07f)); + g.fillRect(x1, bounds.getY(), x2 - x1, h); + } + } + + // Draw waveform directly from sample buffer for crisp rendering + const int numChannels = sampleBuffer->getNumChannels(); + int visibleRange = end - start; + if (visibleRange <= 0) return; + + juce::Path filledPath; + + for (int x = 0; x < static_cast(w); ++x) + { + float ratio = static_cast(x) / w; + int s0 = start + static_cast(ratio * static_cast(visibleRange)); + int s1 = start + static_cast((ratio + 1.0f / w) * static_cast(visibleRange)); + s0 = juce::jlimit(0, total - 1, s0); + s1 = juce::jlimit(0, total, s1); + if (s1 <= s0) s1 = s0 + 1; + + float maxVal = 0.0f; + for (int s = s0; s < s1; ++s) + { + for (int ch = 0; ch < numChannels; ++ch) + { + float val = std::abs(sampleBuffer->getSample(ch, s)); + if (val > maxVal) maxVal = val; + } + } + + float yMin = midY - maxVal * h * 0.45f; + if (x == 0) + filledPath.startNewSubPath(bounds.getX(), yMin); + filledPath.lineTo(bounds.getX() + static_cast(x), yMin); + } + + for (int x = static_cast(w) - 1; x >= 0; --x) + { + float ratio = static_cast(x) / w; + int s0 = start + static_cast(ratio * static_cast(visibleRange)); + int s1 = start + static_cast((ratio + 1.0f / w) * static_cast(visibleRange)); + s0 = juce::jlimit(0, total - 1, s0); + s1 = juce::jlimit(0, total, s1); + if (s1 <= s0) s1 = s0 + 1; + + float minVal = 0.0f; + for (int s = s0; s < s1; ++s) + { + for (int ch = 0; ch < numChannels; ++ch) + { + float val = sampleBuffer->getSample(ch, s); + if (val < minVal) minVal = val; + } + } + + float yMax = midY - minVal * h * 0.45f; + filledPath.lineTo(bounds.getX() + static_cast(x), yMax); + } + + filledPath.closeSubPath(); + + juce::ColourGradient gradient(juce::Colour(0xff4488ff), bounds.getX(), bounds.getY(), + juce::Colour(0xff2244aa), bounds.getX(), bounds.getBottom(), false); + g.setGradientFill(gradient); + g.fillPath(filledPath); + + // Center line + g.setColour(juce::Colour(0x40ffffff)); + g.drawHorizontalLine(static_cast(midY), bounds.getX(), bounds.getRight()); + + // Playback position + if (playbackSample >= start && playbackSample < end) + { + float px = bounds.getX() + static_cast(playbackSample - start) / static_cast(end - start) * w; + g.setColour(juce::Colours::white); + g.drawVerticalLine(static_cast(px), bounds.getY(), bounds.getBottom()); + } +} + +void WaveformDisplay::drawSliceMarkers(juce::Graphics& g, juce::Rectangle bounds) +{ + const auto& slices = sliceManager.getSlices(); + float w = bounds.getWidth(); + int start = getVisibleStart(); + int end = getVisibleEnd(); + + juce::Colour markerColours[] = { + juce::Colour(0xffff6b6b), juce::Colour(0xff4ecdc4), juce::Colour(0xffffe66d), + juce::Colour(0xffa8e6cf), juce::Colour(0xffff8b94), juce::Colour(0xffdda0dd) + }; + + for (int i = 0; i < static_cast(slices.size()); ++i) + { + int sPos = slices[static_cast(i)].startSample; + if (sPos < start || sPos > end) continue; + + float px = bounds.getX() + static_cast(sPos - start) / static_cast(end - start) * w; + juce::Colour col = markerColours[i % 6]; + bool isActive = (i == activeSliceIndex); + bool isSelected = (i == selectedSliceIndex); + bool isHovered = (i == hoveredSliceIndex); + + if (isHovered && !isActive) + { + g.setColour(col.brighter(0.6f)); + g.drawLine(px, bounds.getY(), px, bounds.getBottom(), 3.0f); + } + else if (isSelected && !isActive) + { + g.setColour(col.brighter(0.4f)); + float lineThickness = 2.0f; + float y = bounds.getY(); + float bottom = bounds.getBottom(); + while (y < bottom) + { + float dashEnd = juce::jmin(y + 6.0f, bottom); + g.drawLine(px, y, px, dashEnd, lineThickness); + y += 10.0f; + } + } + else + { + g.setColour(isActive ? col : col.withAlpha(0.7f)); + float lineThickness = isActive ? 2.0f : 1.0f; + g.drawLine(px, bounds.getY(), px, bounds.getBottom(), lineThickness); + } + + // Label + g.setColour(col); + g.setFont(isActive ? juce::Font(juce::FontOptions(11.0f, juce::Font::bold)) : juce::Font(juce::FontOptions(10.0f))); + g.drawText(juce::String(i + 1), static_cast(px + 2.0f), static_cast(bounds.getY()), + 20, 14, juce::Justification::centredLeft); + + // Triangle at top + float triSize = (isActive || isSelected) ? 5.0f : 4.0f; + juce::Colour triCol = isSelected ? col.brighter(0.3f) : col; + g.setColour(isActive ? col : triCol); + juce::Path tri; + tri.addTriangle(px - triSize, bounds.getY(), px + triSize, bounds.getY(), px, bounds.getY() + triSize * 2.0f); + g.fillPath(tri); + } +} + +void WaveformDisplay::drawRuler(juce::Graphics& g, juce::Rectangle bounds) +{ + g.setColour(juce::Colour(0xff2a2a4a)); + g.fillRect(bounds); + + // Key area in ruler too + g.setColour(juce::Colour(0xff222233)); + g.fillRect(bounds.withWidth(keyWidth)); + + if (!sampleBuffer || sampleRate <= 0) return; + + int start = getVisibleStart(); + int end = getVisibleEnd(); + float w = bounds.getWidth() - keyWidth; + float offsetX = keyWidth; + + g.setColour(juce::Colour(0xff888888)); + g.setFont(9.0f); + + double visibleSeconds = static_cast(end - start) / sampleRate; + double tickInterval = 0.1; + if (visibleSeconds > 5.0) tickInterval = 1.0; + else if (visibleSeconds > 2.0) tickInterval = 0.5; + else if (visibleSeconds > 0.5) tickInterval = 0.1; + else tickInterval = 0.01; + + double startSec = static_cast(start) / sampleRate; + double firstTick = std::ceil(startSec / tickInterval) * tickInterval; + + for (double t = firstTick; t < static_cast(end) / sampleRate; t += tickInterval) + { + int samp = static_cast(t * sampleRate); + float px = bounds.getX() + offsetX + static_cast(samp - start) / static_cast(end - start) * w; + + g.drawVerticalLine(static_cast(px), bounds.getY() + 12.0f, bounds.getBottom()); + + juce::String label = juce::String(t, 2) + "s"; + g.drawText(label, static_cast(px + 2.0f), static_cast(bounds.getY()), + 40, 12, juce::Justification::centredLeft); + } +} + +void WaveformDisplay::resized() +{ + auto bounds = getLocalBounds(); + auto scrollBarRow = bounds.removeFromBottom(static_cast(scrollbarHeight)); + scrollLeftBtn.setBounds(scrollBarRow.getX(), scrollBarRow.getY(), scrollBtnWidth, scrollBarRow.getHeight()); + scrollRightBtn.setBounds(scrollBarRow.getRight() - scrollBtnWidth, scrollBarRow.getY(), scrollBtnWidth, scrollBarRow.getHeight()); + hScrollBar.setBounds(scrollBarRow.getX() + scrollBtnWidth, scrollBarRow.getY(), + scrollBarRow.getWidth() - scrollBtnWidth * 2, scrollBarRow.getHeight()); +} + +void WaveformDisplay::mouseDown(const juce::MouseEvent& e) +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) + { + if (onEmptyAreaClick) + onEmptyAreaClick(); + return; + } + + auto bounds = getLocalBounds().toFloat(); + auto keyArea = bounds.withWidth(keyWidth).withTrimmedBottom(scrollbarHeight); + + // Check if click is in the key area + if (e.getPosition().getX() < static_cast(keyArea.getWidth())) + { + float rowHeight = keyArea.getHeight() / 12.0f; + int row = static_cast((e.getPosition().getY() - keyArea.getY()) / rowHeight); + row = juce::jlimit(0, 11, row); + int midiNote = keyRowToMidiNote(row); + + if (onKeyClick) + onKeyClick(midiNote); + return; + } + + // Right click removes slice + if (e.mods.isRightButtonDown()) + { + int sample = xToSample(static_cast(e.getPosition().getX())); + auto area = getWaveformArea(); + int range = getVisibleEnd() - getVisibleStart(); + int tolerance = (range > 0) ? juce::jmax(1, static_cast(10.0f * static_cast(range) / area.getWidth())) : 1; + sliceManager.removeSliceAt(sample, tolerance); + return; + } + + // Left click on waveform area + if (e.mods.isLeftButtonDown()) + { + int px = static_cast(e.getPosition().getX()); + int sample = xToSample(px); + auto area = getWaveformArea(); + int range = getVisibleEnd() - getVisibleStart(); + int tolerancePx = 10; + int toleranceSamples = (range > 0) ? juce::jmax(1, static_cast(static_cast(tolerancePx) * static_cast(range) / area.getWidth())) : 1; + + // Check if clicking near an existing marker — drag it + const auto& slices = sliceManager.getSlices(); + int nearIdx = -1; + int nearDist = toleranceSamples + 1; + for (int i = 0; i < static_cast(slices.size()); ++i) + { + int dist = std::abs(slices[static_cast(i)].startSample - sample); + if (dist < nearDist) + { + nearDist = dist; + nearIdx = i; + } + } + + if (nearIdx >= 0) + { + draggingSliceIndex = nearIdx; + } + else + { + sliceManager.addSliceManual(sample); + + // Enter drag mode for the newly placed marker + const auto& slices2 = sliceManager.getSlices(); + int bestIdx = -1; + int bestDist = std::numeric_limits::max(); + for (int i = 0; i < static_cast(slices2.size()); ++i) + { + int dist = std::abs(slices2[static_cast(i)].startSample - sample); + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + draggingSliceIndex = bestIdx; + } + } +} + +void WaveformDisplay::mouseDrag(const juce::MouseEvent& e) +{ + if (draggingSliceIndex >= 0) + { + int sample = xToSample(static_cast(e.getPosition().getX())); + const auto& slices = sliceManager.getSlices(); + if (draggingSliceIndex < static_cast(slices.size())) + sliceManager.moveSlice(slices[static_cast(draggingSliceIndex)].startSample, sample); + } +} + +void WaveformDisplay::mouseUp(const juce::MouseEvent&) { draggingSliceIndex = -1; } + +void WaveformDisplay::mouseMove(const juce::MouseEvent& e) +{ + if (!sampleBuffer || sampleBuffer->getNumSamples() == 0) + { + hoveredSliceIndex = -1; + return; + } + + auto area = getWaveformArea(); + if (e.getPosition().getX() < static_cast(area.getX()) || e.getPosition().getX() > static_cast(area.getRight())) + { + hoveredSliceIndex = -1; + return; + } + + int range = getVisibleEnd() - getVisibleStart(); + int tolerancePx = 10; + int toleranceSamples = (range > 0) ? juce::jmax(1, static_cast(static_cast(tolerancePx) * static_cast(range) / area.getWidth())) : 1; + int sample = xToSample(static_cast(e.getPosition().getX())); + + const auto& slices = sliceManager.getSlices(); + int bestIdx = -1; + int bestDist = toleranceSamples + 1; + for (int i = 0; i < static_cast(slices.size()); ++i) + { + int dist = std::abs(slices[static_cast(i)].startSample - sample); + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + hoveredSliceIndex = bestIdx; +} + +void WaveformDisplay::mouseWheelMove(const juce::MouseEvent& e, const juce::MouseWheelDetails& wheel) +{ + auto area = getWaveformArea(); + + // Handle horizontal scrolling (trackpad 2-finger horizontal drag) + if (std::abs(wheel.deltaX) > 0.001f) + { + int total = getNumSamples(); + int visible = static_cast(static_cast(total) / zoomFactor); + float maxOffset = static_cast(juce::jmax(1, total - visible)); + scrollOffset -= wheel.deltaX * 0.02f / zoomFactor; + scrollOffset = juce::jlimit(0.0f, 1.0f, scrollOffset); + syncScrollBar(); + repaint(); + return; + } + + // Handle zoom via vertical scroll + float oldZoom = zoomFactor; + zoomFactor *= (1.0f + wheel.deltaY * 0.3f); + zoomFactor = juce::jlimit(1.0f, 64.0f, zoomFactor); + + float mouseXRatio = (static_cast(e.getPosition().getX()) - area.getX()) / area.getWidth(); + mouseXRatio = juce::jlimit(0.0f, 1.0f, mouseXRatio); + int oldVisible = static_cast(static_cast(getNumSamples()) / oldZoom); + int newVisible = static_cast(static_cast(getNumSamples()) / zoomFactor); + float mouseSample = scrollOffset * static_cast(juce::jmax(0, getNumSamples() - oldVisible)) + mouseXRatio * static_cast(oldVisible); + scrollOffset = (mouseSample - mouseXRatio * static_cast(newVisible)) / static_cast(juce::jmax(1, getNumSamples() - newVisible)); + scrollOffset = juce::jlimit(0.0f, 1.0f, scrollOffset); + + syncScrollBar(); + repaint(); +} diff --git a/Source/WaveformDisplay.h b/Source/WaveformDisplay.h new file mode 100644 index 0000000..1d1c8ae --- /dev/null +++ b/Source/WaveformDisplay.h @@ -0,0 +1,86 @@ +#pragma once +#include +#include "SliceManager.h" +#include + +class WaveformDisplay : public juce::Component, + public juce::Timer, + private juce::ScrollBar::Listener +{ +public: + static constexpr float keyWidth = 50.0f; + static constexpr float scrollbarHeight = 14.0f; + + WaveformDisplay(SliceManager& sm); + ~WaveformDisplay() override; + + void paint(juce::Graphics& g) override; + void resized() override; + void timerCallback() override; + + void mouseDown(const juce::MouseEvent& e) override; + void mouseDrag(const juce::MouseEvent& e) override; + void mouseUp(const juce::MouseEvent& e) override; + void mouseMove(const juce::MouseEvent& e) override; + void mouseWheelMove(const juce::MouseEvent& e, const juce::MouseWheelDetails& wheel) override; + + void setSampleBuffer(const juce::AudioBuffer* buffer, double sampleRate); + void setPlaybackPosition(int sample); + void setActiveSliceIndex(int index); + void setSelectedSlice(int index); + void setOnEmptyAreaClick(std::function callback); + void setOnKeyClick(std::function callback); + + void zoomIn(); + void zoomOut(); + void zoomReset(); + + int xToSample(int x) const; + int sampleToX(int sample) const; + float getZoomFactor() const { return zoomFactor; } + +private: + void drawKeys(juce::Graphics& g, juce::Rectangle bounds); + void drawWaveform(juce::Graphics& g, juce::Rectangle bounds); + void drawSliceMarkers(juce::Graphics& g, juce::Rectangle bounds); + void drawRuler(juce::Graphics& g, juce::Rectangle bounds); + void scrollBarMoved(juce::ScrollBar* scrollBarThatHasMoved, double newRangeStart) override; + void syncScrollBar(); + + SliceManager& sliceManager; + const juce::AudioBuffer* sampleBuffer = nullptr; + double sampleRate = 44100.0; + + float zoomFactor = 1.0f; + float scrollOffset = 0.0f; + int playbackSample = -1; + int activeSliceIndex = -1; + int selectedSliceIndex = -1; + int draggingSliceIndex = -1; + int hoveredSliceIndex = -1; + + std::function onEmptyAreaClick; + std::function onKeyClick; + + juce::ScrollBar hScrollBar { false }; + juce::TextButton scrollLeftBtn { "<" }; + juce::TextButton scrollRightBtn { ">" }; + bool updatingScroll = false; + static constexpr int scrollBtnWidth = 16; + + juce::AudioBuffer overviewBuffer; + bool overviewDirty = true; + static constexpr int overviewResolution = 2048; + + void rebuildOverview(); + int getNumSamples() const { return sampleBuffer ? sampleBuffer->getNumSamples() : 0; } + int getVisibleStart() const; + int getVisibleEnd() const; + juce::Rectangle getWaveformArea() const; + + int keyRowToMidiNote(int row) const; + int midiNoteToSliceIndex(int note) const; + int findNearestSliceAtPixel(int pixelX, int tolerancePx) const; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(WaveformDisplay) +}; diff --git a/lib/JUCE b/lib/JUCE new file mode 160000 index 0000000..f72bad6 --- /dev/null +++ b/lib/JUCE @@ -0,0 +1 @@ +Subproject commit f72bad64d29715216226685810c5196bd0d79d77