commit 4cc22e0bf06707182d437efc5becade89a365848 Author: hariel1985 Date: Sun Mar 22 10:59:31 2026 +0100 Initial commit: InstaDrums VST3 drum sampler plugin - 12-pad drum sampler with 4x3 grid (expandable by 4) - Velocity layers with round-robin (Salamander-style filename parsing) - Rhythm Engine-style GUI: pad grid (left), sample editor (right top), FX panel (right bottom), master panel (bottom) - Waveform thumbnails on pads + large waveform in sample editor - ADSR envelope, pitch, pan per pad - Drag & drop sample/folder loading - Kit save/load (.drumkit XML presets) - Load Folder with smart name matching (kick, snare, hihat, etc.) - Choke groups, one-shot/polyphonic mode - Dark modern LookAndFeel with neon accent colors - Built with JUCE framework, CMake, MSVC 2022 Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef158d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +Samples/ +*.user +*.suo +.vs/ +CMakeSettings.json +out/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..218b353 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.22) +project(InstaDrums VERSION 1.0.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../JUCE ${CMAKE_CURRENT_BINARY_DIR}/JUCE) + +juce_add_plugin(InstaDrums + COMPANY_NAME "InstaDrums" + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + NEEDS_MIDI_OUTPUT TRUE + PLUGIN_MANUFACTURER_CODE Inst + PLUGIN_CODE Idrm + FORMATS VST3 Standalone + PRODUCT_NAME "InstaDrums" + COPY_PLUGIN_AFTER_BUILD FALSE +) + +juce_generate_juce_header(InstaDrums) + +target_sources(InstaDrums + PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/DrumPad.cpp + Source/PadComponent.cpp + Source/LookAndFeel.cpp + Source/WaveformDisplay.cpp + Source/SampleEditorPanel.cpp + Source/FxPanel.cpp + Source/MasterPanel.cpp +) + +target_compile_definitions(InstaDrums + PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 +) + +target_link_libraries(InstaDrums + PRIVATE + juce::juce_audio_basics + juce::juce_audio_devices + juce::juce_audio_formats + juce::juce_audio_processors + juce::juce_audio_utils + juce::juce_core + juce::juce_dsp + juce::juce_graphics + juce::juce_gui_basics + juce::juce_gui_extra + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags +) diff --git a/Source/DrumPad.cpp b/Source/DrumPad.cpp new file mode 100644 index 0000000..dc9582f --- /dev/null +++ b/Source/DrumPad.cpp @@ -0,0 +1,385 @@ +#include "DrumPad.h" + +DrumPad::DrumPad() {} +DrumPad::~DrumPad() {} + +void DrumPad::prepareToPlay (double sr, int /*samplesPerBlock*/) +{ + sampleRate = sr; +} + +void DrumPad::releaseResources() +{ + playing = false; + activeSample = nullptr; + envStage = EnvelopeStage::Idle; + envLevel = 0.0f; +} + +// ============================================================ +// Velocity tag parsing from Salamander-style filenames +// Tags: Ghost, PP, P, MP, F, FF +// ============================================================ + +float DrumPad::velocityTagToLow (const juce::String& tag) +{ + if (tag == "Ghost") return 0.0f; + if (tag == "PP") return 0.05f; + if (tag == "P") return 0.15f; + if (tag == "MP") return 0.35f; + if (tag == "F") return 0.55f; + if (tag == "FF") return 0.80f; + return 0.0f; +} + +float DrumPad::velocityTagToHigh (const juce::String& tag) +{ + if (tag == "Ghost") return 0.05f; + if (tag == "PP") return 0.15f; + if (tag == "P") return 0.35f; + if (tag == "MP") return 0.55f; + if (tag == "F") return 0.80f; + if (tag == "FF") return 1.0f; + return 1.0f; +} + +// ============================================================ +// Single sample loading (one layer, full velocity range) +// ============================================================ + +void DrumPad::loadSample (const juce::File& file, juce::AudioFormatManager& formatManager) +{ + std::unique_ptr reader (formatManager.createReaderFor (file)); + if (reader == nullptr) return; + + layers.clear(); + activeSample = nullptr; + + auto* layer = new VelocityLayer(); + layer->velocityLow = 0.0f; + layer->velocityHigh = 1.0f; + + auto* sample = new Sample(); + sample->buffer.setSize ((int) reader->numChannels, (int) reader->lengthInSamples); + reader->read (&sample->buffer, 0, (int) reader->lengthInSamples, 0, true, true); + sample->sampleRate = reader->sampleRate; + sample->file = file; + + layer->samples.add (sample); + layers.add (layer); + + loadedFileName = file.getFileName(); + loadedFile = file; + readPosition = 0.0; + playing = false; +} + +// ============================================================ +// Velocity layer loading from folder +// Expects filenames like: snare_OH_FF_1.flac, snare_OH_Ghost_3.flac +// Groups by velocity tag, each group becomes round-robin variations +// ============================================================ + +void DrumPad::loadLayersFromFolder (const juce::File& folder, juce::AudioFormatManager& formatManager) +{ + if (! folder.isDirectory()) return; + + layers.clear(); + activeSample = nullptr; + + // Collect audio files + juce::Array audioFiles; + for (auto& f : folder.findChildFiles (juce::File::findFiles, false)) + { + auto ext = f.getFileExtension().toLowerCase(); + if (ext == ".wav" || ext == ".aiff" || ext == ".aif" || ext == ".flac" + || ext == ".ogg" || ext == ".mp3") + audioFiles.add (f); + } + + if (audioFiles.isEmpty()) return; + + // Known velocity tags to look for in filenames + static const juce::StringArray velocityTags = { "Ghost", "PP", "P", "MP", "F", "FF" }; + + // Group files by velocity tag + std::map> groups; + + for (auto& file : audioFiles) + { + auto nameNoExt = file.getFileNameWithoutExtension(); + // Split by underscore and look for velocity tags + juce::String foundTag = "FF"; // default if no tag found + + auto parts = juce::StringArray::fromTokens (nameNoExt, "_", ""); + for (auto& part : parts) + { + if (velocityTags.contains (part)) + { + foundTag = part; + break; + } + } + + groups[foundTag].add (file); + } + + // If only one group found with no velocity differentiation, treat as single layer + if (groups.size() == 1 && groups.begin()->first == "FF") + { + // All files are round-robin for a single full-velocity layer + auto* layer = new VelocityLayer(); + layer->velocityLow = 0.0f; + layer->velocityHigh = 1.0f; + + for (auto& file : groups.begin()->second) + { + std::unique_ptr reader (formatManager.createReaderFor (file)); + if (reader != nullptr) + { + auto* sample = new Sample(); + sample->buffer.setSize ((int) reader->numChannels, (int) reader->lengthInSamples); + reader->read (&sample->buffer, 0, (int) reader->lengthInSamples, 0, true, true); + sample->sampleRate = reader->sampleRate; + sample->file = file; + layer->samples.add (sample); + } + } + + if (! layer->samples.isEmpty()) + layers.add (layer); + } + else + { + // Multiple velocity groups — create one layer per group + for (auto& [tag, files] : groups) + { + auto* layer = new VelocityLayer(); + layer->velocityLow = velocityTagToLow (tag); + layer->velocityHigh = velocityTagToHigh (tag); + + files.sort(); + for (auto& file : files) + { + std::unique_ptr reader (formatManager.createReaderFor (file)); + if (reader != nullptr) + { + auto* sample = new Sample(); + sample->buffer.setSize ((int) reader->numChannels, (int) reader->lengthInSamples); + reader->read (&sample->buffer, 0, (int) reader->lengthInSamples, 0, true, true); + sample->sampleRate = reader->sampleRate; + sample->file = file; + layer->samples.add (sample); + } + } + + if (! layer->samples.isEmpty()) + layers.add (layer); + } + } + + // Sort layers by velocity range + std::sort (layers.begin(), layers.end(), + [] (const VelocityLayer* a, const VelocityLayer* b) + { return a->velocityLow < b->velocityLow; }); + + loadedFileName = folder.getFileName() + " (" + juce::String (layers.size()) + " layers)"; + loadedFile = folder; + readPosition = 0.0; + playing = false; +} + +// ============================================================ +// State queries +// ============================================================ + +bool DrumPad::hasSample() const +{ + for (auto* layer : layers) + if (! layer->samples.isEmpty()) + return true; + return false; +} + +const juce::AudioBuffer& DrumPad::getSampleBuffer() const +{ + if (activeSample != nullptr) + return activeSample->buffer; + + // Return first available sample buffer for waveform display + for (auto* layer : layers) + if (! layer->samples.isEmpty()) + return layer->samples[0]->buffer; + + return emptyBuffer; +} + +// ============================================================ +// Velocity layer selection +// ============================================================ + +DrumPad::VelocityLayer* DrumPad::findLayerForVelocity (float velocity) +{ + // Find the layer whose range contains this velocity + for (auto* layer : layers) + if (velocity >= layer->velocityLow && velocity <= layer->velocityHigh) + return layer; + + // Fallback: closest layer + VelocityLayer* closest = nullptr; + float minDist = 2.0f; + for (auto* layer : layers) + { + float mid = (layer->velocityLow + layer->velocityHigh) * 0.5f; + float dist = std::abs (velocity - mid); + if (dist < minDist) + { + minDist = dist; + closest = layer; + } + } + return closest; +} + +// ============================================================ +// Trigger / Stop +// ============================================================ + +void DrumPad::trigger (float velocity) +{ + if (! hasSample()) return; + + auto* layer = findLayerForVelocity (velocity); + if (layer == nullptr) return; + + activeSample = layer->getNextSample(); + if (activeSample == nullptr) return; + + currentVelocity = velocity; + readPosition = 0.0; + envStage = EnvelopeStage::Attack; + envLevel = 0.0f; + playing = true; +} + +void DrumPad::stop() +{ + if (playing) + envStage = EnvelopeStage::Release; +} + +// ============================================================ +// ADSR Envelope +// ============================================================ + +void DrumPad::advanceEnvelope() +{ + float attackSamples = std::max (1.0f, attack * (float) sampleRate); + float decaySamples = std::max (1.0f, decay * (float) sampleRate); + float releaseSamples = std::max (1.0f, release * (float) sampleRate); + + switch (envStage) + { + case EnvelopeStage::Attack: + envLevel += 1.0f / attackSamples; + if (envLevel >= 1.0f) + { + envLevel = 1.0f; + envStage = EnvelopeStage::Decay; + } + break; + + case EnvelopeStage::Decay: + envLevel -= (1.0f - sustain) / decaySamples; + if (envLevel <= sustain) + { + envLevel = sustain; + envStage = EnvelopeStage::Sustain; + } + break; + + case EnvelopeStage::Sustain: + envLevel = sustain; + break; + + case EnvelopeStage::Release: + envLevel -= envLevel / releaseSamples; + if (envLevel < 0.001f) + { + envLevel = 0.0f; + envStage = EnvelopeStage::Idle; + playing = false; + } + break; + + case EnvelopeStage::Idle: + envLevel = 0.0f; + break; + } +} + +// ============================================================ +// Audio rendering +// ============================================================ + +void DrumPad::renderNextBlock (juce::AudioBuffer& outputBuffer, int startSample, int numSamples) +{ + if (! playing || activeSample == nullptr) + return; + + const auto& sampleBuffer = activeSample->buffer; + const int sampleLength = sampleBuffer.getNumSamples(); + const int srcChannels = sampleBuffer.getNumChannels(); + const double sourceSR = activeSample->sampleRate; + + double pitchRatio = std::pow (2.0, (double) pitch / 12.0) * (sourceSR / sampleRate); + + // Constant power pan law + float panPos = (pan + 1.0f) * 0.5f; + float leftGain = std::cos (panPos * juce::MathConstants::halfPi); + float rightGain = std::sin (panPos * juce::MathConstants::halfPi); + + for (int i = 0; i < numSamples; ++i) + { + if (! playing) break; + + int pos0 = (int) readPosition; + if (pos0 >= sampleLength) + { + if (oneShot) + { + playing = false; + envStage = EnvelopeStage::Idle; + envLevel = 0.0f; + activeSample = nullptr; + break; + } + else + { + envStage = EnvelopeStage::Release; + } + } + + if (pos0 < sampleLength) + { + advanceEnvelope(); + float gain = volume * currentVelocity * envLevel; + + int pos1 = std::min (pos0 + 1, sampleLength - 1); + float frac = (float) (readPosition - (double) pos0); + + for (int ch = 0; ch < outputBuffer.getNumChannels(); ++ch) + { + int srcCh = std::min (ch, srcChannels - 1); + float s0 = sampleBuffer.getSample (srcCh, pos0); + float s1 = sampleBuffer.getSample (srcCh, pos1); + float sampleVal = s0 + frac * (s1 - s0); + + float channelGain = (ch == 0) ? leftGain : rightGain; + outputBuffer.addSample (ch, startSample + i, sampleVal * gain * channelGain); + } + } + + readPosition += pitchRatio; + } +} diff --git a/Source/DrumPad.h b/Source/DrumPad.h new file mode 100644 index 0000000..12429a3 --- /dev/null +++ b/Source/DrumPad.h @@ -0,0 +1,106 @@ +#pragma once +#include + +class DrumPad +{ +public: + // A single sample with its audio data and source sample rate + struct Sample + { + juce::AudioBuffer buffer; + double sampleRate = 44100.0; + juce::File file; + }; + + // A velocity layer: velocity range + multiple round-robin samples + struct VelocityLayer + { + float velocityLow = 0.0f; // 0.0 - 1.0 + float velocityHigh = 1.0f; + juce::OwnedArray samples; // round-robin variations + int nextRoundRobin = 0; + + Sample* getNextSample() + { + if (samples.isEmpty()) return nullptr; + auto* s = samples[nextRoundRobin % samples.size()]; + nextRoundRobin = (nextRoundRobin + 1) % samples.size(); + return s; + } + }; + + DrumPad(); + ~DrumPad(); + + void prepareToPlay (double sampleRate, int samplesPerBlock); + void releaseResources(); + + // Single sample loading (backwards compatible) + void loadSample (const juce::File& file, juce::AudioFormatManager& formatManager); + + // Velocity layer loading from a folder + void loadLayersFromFolder (const juce::File& folder, juce::AudioFormatManager& formatManager); + + bool hasSample() const; + + void trigger (float velocity = 1.0f); + void stop(); + + void renderNextBlock (juce::AudioBuffer& outputBuffer, int startSample, int numSamples); + + // Pad properties + juce::String name; + int midiNote = 36; + float volume = 1.0f; + float pan = 0.0f; + float pitch = 0.0f; + bool oneShot = true; + int chokeGroup = -1; + juce::Colour colour { 0xff00ff88 }; + + // ADSR + float attack = 0.001f; + float decay = 0.1f; + float sustain = 1.0f; + float release = 0.05f; + + // State + bool isPlaying() const { return playing; } + juce::String getLoadedFileName() const { return loadedFileName; } + juce::File getLoadedFile() const { return loadedFile; } + int getNumLayers() const { return layers.size(); } + + const juce::AudioBuffer& getSampleBuffer() const; + +private: + // Velocity layers (sorted by velocity range) + juce::OwnedArray layers; + + // Currently playing sample reference + Sample* activeSample = nullptr; + + // Fallback empty buffer for getSampleBuffer when nothing loaded + juce::AudioBuffer emptyBuffer; + + double sampleRate = 44100.0; + double readPosition = 0.0; + bool playing = false; + float currentVelocity = 1.0f; + + // ADSR state + enum class EnvelopeStage { Idle, Attack, Decay, Sustain, Release }; + EnvelopeStage envStage = EnvelopeStage::Idle; + float envLevel = 0.0f; + + juce::String loadedFileName; + juce::File loadedFile; + + void advanceEnvelope(); + VelocityLayer* findLayerForVelocity (float velocity); + + // Parse velocity tag from filename (e.g. "snare_OH_FF_1" -> FF) + static float velocityTagToLow (const juce::String& tag); + static float velocityTagToHigh (const juce::String& tag); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (DrumPad) +}; diff --git a/Source/FxPanel.cpp b/Source/FxPanel.cpp new file mode 100644 index 0000000..219152b --- /dev/null +++ b/Source/FxPanel.cpp @@ -0,0 +1,116 @@ +#include "FxPanel.h" +#include "LookAndFeel.h" + +FxPanel::FxPanel() +{ + setupTitle (compTitle, "COMPRESSOR"); + setupTitle (eqTitle, "EQ"); + setupTitle (distTitle, "DISTORTION"); + setupTitle (reverbTitle, "REVERB"); + + setupKnob (compThreshSlider, compThreshLabel, "Threshold", -60.0, 0.0, -12.0, 0.5); + setupKnob (compRatioSlider, compRatioLabel, "Ratio", 1.0, 20.0, 4.0, 0.1); + setupKnob (eqLoSlider, eqLoLabel, "Lo", -12.0, 12.0, 0.0, 0.1); + setupKnob (eqMidSlider, eqMidLabel, "Mid", -12.0, 12.0, 0.0, 0.1); + setupKnob (eqHiSlider, eqHiLabel, "Hi", -12.0, 12.0, 0.0, 0.1); + setupKnob (distDriveSlider, distDriveLabel, "Drive", 0.0, 1.0, 0.0, 0.01); + setupKnob (distMixSlider, distMixLabel, "Mix", 0.0, 1.0, 0.0, 0.01); + setupKnob (reverbSizeSlider, reverbSizeLabel, "Size", 0.0, 1.0, 0.3, 0.01); + setupKnob (reverbDecaySlider, reverbDecayLabel, "Decay", 0.0, 1.0, 0.5, 0.01); +} + +void FxPanel::setupKnob (juce::Slider& s, juce::Label& l, const juce::String& name, + double min, double max, double val, double step) +{ + s.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + s.setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + s.setRange (min, max, step); + s.setValue (val, juce::dontSendNotification); + addAndMakeVisible (s); + + l.setText (name, juce::dontSendNotification); + l.setFont (juce::FontOptions (9.0f)); + l.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + l.setJustificationType (juce::Justification::centred); + addAndMakeVisible (l); +} + +void FxPanel::setupTitle (juce::Label& l, const juce::String& text) +{ + l.setText (text, juce::dontSendNotification); + l.setFont (juce::FontOptions (10.0f, juce::Font::bold)); + l.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::accent); + l.setJustificationType (juce::Justification::centredLeft); + addAndMakeVisible (l); +} + +void FxPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaDrumsLookAndFeel::bgMedium); + g.fillRoundedRectangle (bounds, 6.0f); + g.setColour (InstaDrumsLookAndFeel::bgLight.withAlpha (0.5f)); + g.drawRoundedRectangle (bounds, 6.0f, 1.0f); + + // "FX" header + g.setColour (InstaDrumsLookAndFeel::textSecondary); + g.setFont (juce::FontOptions (14.0f, juce::Font::bold)); + g.drawText ("FX", bounds.reduced (6, 4).removeFromTop (18), juce::Justification::centredLeft); +} + +void FxPanel::resized() +{ + auto area = getLocalBounds().reduced (6); + area.removeFromTop (20); // FX header + + int halfW = area.getWidth() / 2; + int rowH = area.getHeight() / 2; + + // Top row: Compressor | EQ + auto topRow = area.removeFromTop (rowH); + { + auto compArea = topRow.removeFromLeft (halfW).reduced (2); + compTitle.setBounds (compArea.removeFromTop (14)); + int kw = compArea.getWidth() / 2; + auto c1 = compArea.removeFromLeft (kw); + compThreshLabel.setBounds (c1.removeFromBottom (12)); + compThreshSlider.setBounds (c1); + compRatioLabel.setBounds (compArea.removeFromBottom (12)); + compRatioSlider.setBounds (compArea); + } + { + auto eqArea = topRow.reduced (2); + eqTitle.setBounds (eqArea.removeFromTop (14)); + int kw = eqArea.getWidth() / 3; + auto c1 = eqArea.removeFromLeft (kw); + eqLoLabel.setBounds (c1.removeFromBottom (12)); + eqLoSlider.setBounds (c1); + auto c2 = eqArea.removeFromLeft (kw); + eqMidLabel.setBounds (c2.removeFromBottom (12)); + eqMidSlider.setBounds (c2); + eqHiLabel.setBounds (eqArea.removeFromBottom (12)); + eqHiSlider.setBounds (eqArea); + } + + // Bottom row: Distortion | Reverb + { + auto distArea = area.removeFromLeft (halfW).reduced (2); + distTitle.setBounds (distArea.removeFromTop (14)); + int kw = distArea.getWidth() / 2; + auto c1 = distArea.removeFromLeft (kw); + distDriveLabel.setBounds (c1.removeFromBottom (12)); + distDriveSlider.setBounds (c1); + distMixLabel.setBounds (distArea.removeFromBottom (12)); + distMixSlider.setBounds (distArea); + } + { + auto revArea = area.reduced (2); + reverbTitle.setBounds (revArea.removeFromTop (14)); + int kw = revArea.getWidth() / 2; + auto c1 = revArea.removeFromLeft (kw); + reverbSizeLabel.setBounds (c1.removeFromBottom (12)); + reverbSizeSlider.setBounds (c1); + reverbDecayLabel.setBounds (revArea.removeFromBottom (12)); + reverbDecaySlider.setBounds (revArea); + } +} diff --git a/Source/FxPanel.h b/Source/FxPanel.h new file mode 100644 index 0000000..10c5956 --- /dev/null +++ b/Source/FxPanel.h @@ -0,0 +1,45 @@ +#pragma once +#include + +class FxPanel : public juce::Component +{ +public: + FxPanel(); + + void paint (juce::Graphics& g) override; + void resized() override; + + // FX parameter getters (for processor to read) + float getCompThreshold() const { return (float) compThreshSlider.getValue(); } + float getCompRatio() const { return (float) compRatioSlider.getValue(); } + float getEqLo() const { return (float) eqLoSlider.getValue(); } + float getEqMid() const { return (float) eqMidSlider.getValue(); } + float getEqHi() const { return (float) eqHiSlider.getValue(); } + float getDistDrive() const { return (float) distDriveSlider.getValue(); } + float getDistMix() const { return (float) distMixSlider.getValue(); } + float getReverbSize() const { return (float) reverbSizeSlider.getValue(); } + float getReverbDecay() const { return (float) reverbDecaySlider.getValue(); } + +private: + // Compressor + juce::Slider compThreshSlider, compRatioSlider; + juce::Label compThreshLabel, compRatioLabel, compTitle; + + // EQ + juce::Slider eqLoSlider, eqMidSlider, eqHiSlider; + juce::Label eqLoLabel, eqMidLabel, eqHiLabel, eqTitle; + + // Distortion + juce::Slider distDriveSlider, distMixSlider; + juce::Label distDriveLabel, distMixLabel, distTitle; + + // Reverb + juce::Slider reverbSizeSlider, reverbDecaySlider; + juce::Label reverbSizeLabel, reverbDecayLabel, reverbTitle; + + void setupKnob (juce::Slider& s, juce::Label& l, const juce::String& name, + double min, double max, double val, double step = 0.01); + void setupTitle (juce::Label& l, const juce::String& text); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (FxPanel) +}; diff --git a/Source/LookAndFeel.cpp b/Source/LookAndFeel.cpp new file mode 100644 index 0000000..68d6e06 --- /dev/null +++ b/Source/LookAndFeel.cpp @@ -0,0 +1,44 @@ +#include "LookAndFeel.h" + +InstaDrumsLookAndFeel::InstaDrumsLookAndFeel() +{ + setColour (juce::ResizableWindow::backgroundColourId, bgDark); + setColour (juce::Label::textColourId, textPrimary); + setColour (juce::TextButton::buttonColourId, bgMedium); + setColour (juce::TextButton::textColourOffId, textPrimary); +} + +void InstaDrumsLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) +{ + auto bounds = juce::Rectangle (x, y, width, height).toFloat().reduced (2.0f); + auto radius = std::min (bounds.getWidth(), bounds.getHeight()) / 2.0f; + auto centreX = bounds.getCentreX(); + auto centreY = bounds.getCentreY(); + auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + + // Background arc + juce::Path bgArc; + bgArc.addCentredArc (centreX, centreY, radius - 2, radius - 2, + 0.0f, rotaryStartAngle, rotaryEndAngle, true); + g.setColour (bgLight); + g.strokePath (bgArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + + // Value arc + juce::Path valueArc; + valueArc.addCentredArc (centreX, centreY, radius - 2, radius - 2, + 0.0f, rotaryStartAngle, angle, true); + g.setColour (accent); + g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + + // Pointer + juce::Path pointer; + auto pointerLength = radius * 0.5f; + pointer.addRectangle (-1.5f, -pointerLength, 3.0f, pointerLength); + pointer.applyTransform (juce::AffineTransform::rotation (angle).translated (centreX, centreY)); + g.setColour (textPrimary); + g.fillPath (pointer); +} diff --git a/Source/LookAndFeel.h b/Source/LookAndFeel.h new file mode 100644 index 0000000..d184c5a --- /dev/null +++ b/Source/LookAndFeel.h @@ -0,0 +1,20 @@ +#pragma once +#include + +class InstaDrumsLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + // Colour palette + static inline const juce::Colour bgDark { 0xff1a1a2e }; + static inline const juce::Colour bgMedium { 0xff16213e }; + static inline const juce::Colour bgLight { 0xff0f3460 }; + static inline const juce::Colour textPrimary { 0xffe0e0e0 }; + static inline const juce::Colour textSecondary { 0xff888899 }; + static inline const juce::Colour accent { 0xff00ff88 }; + + InstaDrumsLookAndFeel(); + + void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPosProportional, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) override; +}; diff --git a/Source/MasterPanel.cpp b/Source/MasterPanel.cpp new file mode 100644 index 0000000..f06d16c --- /dev/null +++ b/Source/MasterPanel.cpp @@ -0,0 +1,62 @@ +#include "MasterPanel.h" +#include "LookAndFeel.h" + +MasterPanel::MasterPanel() +{ + masterTitle.setFont (juce::FontOptions (12.0f, juce::Font::bold)); + masterTitle.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + addAndMakeVisible (masterTitle); + + setupKnob (volumeSlider, volumeLabel, "Volume", 0.0, 2.0, 1.0, 0.01); + setupKnob (tuneSlider, tuneLabel, "Tune", -12.0, 12.0, 0.0, 0.1); + setupKnob (panSlider, panLabel, "Pan", -1.0, 1.0, 0.0, 0.01); + + addAndMakeVisible (vuMeter); +} + +void MasterPanel::setupKnob (juce::Slider& s, juce::Label& l, const juce::String& name, + double min, double max, double val, double step) +{ + s.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + s.setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + s.setRange (min, max, step); + s.setValue (val, juce::dontSendNotification); + addAndMakeVisible (s); + + l.setText (name, juce::dontSendNotification); + l.setFont (juce::FontOptions (9.0f)); + l.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + l.setJustificationType (juce::Justification::centred); + addAndMakeVisible (l); +} + +void MasterPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaDrumsLookAndFeel::bgMedium.darker (0.2f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaDrumsLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} + +void MasterPanel::resized() +{ + auto area = getLocalBounds().reduced (4); + + masterTitle.setBounds (area.removeFromLeft (55).reduced (0, 2)); + + // VU meter on the right + vuMeter.setBounds (area.removeFromRight (24).reduced (0, 2)); + area.removeFromRight (4); + + // Knobs + int knobW = area.getWidth() / 3; + juce::Slider* sliders[] = { &volumeSlider, &tuneSlider, &panSlider }; + juce::Label* labels[] = { &volumeLabel, &tuneLabel, &panLabel }; + for (int i = 0; i < 3; ++i) + { + auto col = area.removeFromLeft (knobW); + labels[i]->setBounds (col.removeFromBottom (12)); + sliders[i]->setBounds (col); + } +} diff --git a/Source/MasterPanel.h b/Source/MasterPanel.h new file mode 100644 index 0000000..0ce77c6 --- /dev/null +++ b/Source/MasterPanel.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include "VuMeter.h" + +class MasterPanel : public juce::Component +{ +public: + MasterPanel(); + + void paint (juce::Graphics& g) override; + void resized() override; + + float getMasterVolume() const { return (float) volumeSlider.getValue(); } + float getMasterTune() const { return (float) tuneSlider.getValue(); } + float getMasterPan() const { return (float) panSlider.getValue(); } + + VuMeter& getVuMeter() { return vuMeter; } + +private: + juce::Slider volumeSlider, tuneSlider, panSlider; + juce::Label volumeLabel, tuneLabel, panLabel; + juce::Label masterTitle { {}, "MASTER" }; + VuMeter vuMeter; + + void setupKnob (juce::Slider& s, juce::Label& l, const juce::String& name, + double min, double max, double val, double step = 0.01); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MasterPanel) +}; diff --git a/Source/PadComponent.cpp b/Source/PadComponent.cpp new file mode 100644 index 0000000..cca6742 --- /dev/null +++ b/Source/PadComponent.cpp @@ -0,0 +1,166 @@ +#include "PadComponent.h" +#include "LookAndFeel.h" + +PadComponent::PadComponent (DrumPad& pad, std::function loadCallback, int padIndex) + : drumPad (pad), onLoadSample (std::move (loadCallback)), index (padIndex) +{ +} + +void PadComponent::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat().reduced (2.0f); + float cornerSize = 6.0f; + + // Background + float alpha = isPressed ? 0.85f : (isDragOver ? 0.65f : (selected ? 0.55f : 0.3f)); + g.setColour (drumPad.colour.withAlpha (alpha)); + g.fillRoundedRectangle (bounds, cornerSize); + + // Border — selected = bright accent, normal = pad colour + if (selected) + { + g.setColour (juce::Colour (0xff00aaff)); // blue selection + g.drawRoundedRectangle (bounds, cornerSize, 2.5f); + } + else + { + g.setColour (drumPad.colour.withAlpha (0.6f)); + g.drawRoundedRectangle (bounds, cornerSize, 1.0f); + } + + // Pad number (top-left, small) + g.setColour (InstaDrumsLookAndFeel::textSecondary); + g.setFont (juce::Font (juce::FontOptions (9.0f, juce::Font::bold))); + g.drawText (juce::String (index + 1), bounds.reduced (4, 3), juce::Justification::topLeft); + + // Waveform thumbnail (center area) + if (drumPad.hasSample()) + { + auto waveArea = bounds.reduced (4, 16); + drawWaveformThumbnail (g, waveArea); + } + + // Pad name (bottom) + g.setColour (InstaDrumsLookAndFeel::textPrimary); + g.setFont (juce::Font (juce::FontOptions (10.0f, juce::Font::bold))); + g.drawText (drumPad.name, bounds.reduced (4, 2), juce::Justification::centredBottom); + + // Playing flash + if (drumPad.isPlaying()) + { + g.setColour (juce::Colours::white.withAlpha (0.12f)); + g.fillRoundedRectangle (bounds, cornerSize); + } +} + +void PadComponent::drawWaveformThumbnail (juce::Graphics& g, juce::Rectangle area) +{ + auto& buf = drumPad.getSampleBuffer(); + if (buf.getNumSamples() == 0) return; + + const float* data = buf.getReadPointer (0); + const int numSamples = buf.getNumSamples(); + const float w = area.getWidth(); + const float h = area.getHeight(); + const float midY = area.getCentreY(); + + juce::Path path; + int blockSize = std::max (1, numSamples / (int) w); + + for (int x = 0; x < (int) w; ++x) + { + int si = (int) ((float) x / w * numSamples); + si = juce::jlimit (0, numSamples - 1, si); + + float maxVal = 0.0f; + for (int j = 0; j < blockSize && (si + j) < numSamples; ++j) + maxVal = std::max (maxVal, std::abs (data[si + j])); + + float topY = midY - maxVal * (h * 0.45f); + float botY = midY + maxVal * (h * 0.45f); + + if (x == 0) + path.startNewSubPath (area.getX() + (float) x, topY); + + path.lineTo (area.getX() + (float) x, topY); + } + // Mirror bottom + for (int x = (int) w - 1; x >= 0; --x) + { + int si = (int) ((float) x / w * numSamples); + si = juce::jlimit (0, numSamples - 1, si); + float maxVal = 0.0f; + for (int j = 0; j < blockSize && (si + j) < numSamples; ++j) + maxVal = std::max (maxVal, std::abs (data[si + j])); + float botY = midY + maxVal * (h * 0.45f); + path.lineTo (area.getX() + (float) x, botY); + } + path.closeSubPath(); + + // Greenish waveform tint + g.setColour (juce::Colour (0xff44cc88).withAlpha (0.4f)); + g.fillPath (path); + g.setColour (juce::Colour (0xff44cc88).withAlpha (0.7f)); + g.strokePath (path, juce::PathStrokeType (0.8f)); +} + +void PadComponent::resized() {} + +void PadComponent::mouseDown (const juce::MouseEvent& event) +{ + if (event.mods.isRightButtonDown()) + return; + + // Select this pad + if (onSelected) + onSelected (index); + + isPressed = true; + drumPad.trigger (1.0f); + repaint(); +} + +void PadComponent::mouseUp (const juce::MouseEvent& event) +{ + if (event.mods.isRightButtonDown()) + return; + + isPressed = false; + if (! drumPad.oneShot) + drumPad.stop(); + repaint(); +} + +bool PadComponent::isInterestedInFileDrag (const juce::StringArray& files) +{ + for (auto& f : files) + { + juce::File file (f); + if (file.isDirectory()) return true; + auto ext = file.getFileExtension().toLowerCase(); + if (ext == ".wav" || ext == ".aiff" || ext == ".aif" || ext == ".flac" + || ext == ".ogg" || ext == ".mp3") + return true; + } + return false; +} + +void PadComponent::filesDropped (const juce::StringArray& files, int, int) +{ + isDragOver = false; + if (! files.isEmpty() && onLoadSample) + onLoadSample (index, juce::File (files[0])); + repaint(); +} + +void PadComponent::fileDragEnter (const juce::StringArray&, int, int) +{ + isDragOver = true; + repaint(); +} + +void PadComponent::fileDragExit (const juce::StringArray&) +{ + isDragOver = false; + repaint(); +} diff --git a/Source/PadComponent.h b/Source/PadComponent.h new file mode 100644 index 0000000..c16afd2 --- /dev/null +++ b/Source/PadComponent.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include "DrumPad.h" + +class PadComponent : public juce::Component, + public juce::FileDragAndDropTarget +{ +public: + PadComponent (DrumPad& pad, std::function loadCallback, int padIndex); + + void paint (juce::Graphics& g) override; + void resized() override; + + void mouseDown (const juce::MouseEvent& event) override; + void mouseUp (const juce::MouseEvent& event) override; + + // Drag & Drop + bool isInterestedInFileDrag (const juce::StringArray& files) override; + void filesDropped (const juce::StringArray& files, int x, int y) override; + void fileDragEnter (const juce::StringArray& files, int x, int y) override; + void fileDragExit (const juce::StringArray& files) override; + + void setSelected (bool sel) { selected = sel; repaint(); } + bool isSelected() const { return selected; } + + // Callback when pad is selected (left click) + std::function onSelected; + +private: + DrumPad& drumPad; + std::function onLoadSample; + int index; + bool isPressed = false; + bool isDragOver = false; + bool selected = false; + + void drawWaveformThumbnail (juce::Graphics& g, juce::Rectangle area); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PadComponent) +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..b7de05a --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,217 @@ +#include "PluginEditor.h" + +InstaDrumsEditor::InstaDrumsEditor (InstaDrumsProcessor& p) + : AudioProcessorEditor (&p), processor (p) +{ + setLookAndFeel (&customLookAndFeel); + + // Title + titleLabel.setFont (juce::FontOptions (20.0f, juce::Font::bold)); + titleLabel.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::accent); + titleLabel.setJustificationType (juce::Justification::centredLeft); + addAndMakeVisible (titleLabel); + + versionLabel.setFont (juce::FontOptions (10.0f)); + versionLabel.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + versionLabel.setJustificationType (juce::Justification::centredRight); + addAndMakeVisible (versionLabel); + + padsLabel.setFont (juce::FontOptions (10.0f, juce::Font::bold)); + padsLabel.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + addAndMakeVisible (padsLabel); + + // Buttons + auto styleBtn = [this] (juce::TextButton& btn) { + btn.setColour (juce::TextButton::buttonColourId, InstaDrumsLookAndFeel::bgLight); + btn.setColour (juce::TextButton::textColourOffId, InstaDrumsLookAndFeel::textPrimary); + addAndMakeVisible (btn); + }; + + styleBtn (loadSampleButton); + styleBtn (saveKitButton); + styleBtn (loadKitButton); + styleBtn (loadFolderButton); + + loadSampleButton.onClick = [this] + { + fileChooser = std::make_unique ("Load Sample", juce::File{}, + "*.wav;*.aiff;*.aif;*.flac;*.ogg;*.mp3"); + fileChooser->launchAsync (juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this] (const juce::FileChooser& fc) { + auto file = fc.getResult(); + if (file.existsAsFile()) + { + processor.loadSample (selectedPadIndex, file); + sampleEditor.updateFromPad(); + } + }); + }; + + saveKitButton.onClick = [this] + { + fileChooser = std::make_unique ("Save Kit", juce::File{}, "*.drumkit"); + fileChooser->launchAsync (juce::FileBrowserComponent::saveMode, + [this] (const juce::FileChooser& fc) { + auto file = fc.getResult(); + if (file != juce::File{}) + processor.saveKitPreset (file.hasFileExtension (".drumkit") ? file : file.withFileExtension ("drumkit")); + }); + }; + + loadKitButton.onClick = [this] + { + fileChooser = std::make_unique ("Load Kit", juce::File{}, "*.drumkit"); + fileChooser->launchAsync (juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles, + [this] (const juce::FileChooser& fc) { + auto file = fc.getResult(); + if (file.existsAsFile()) + { + processor.loadKitPreset (file); + rebuildPadGrid(); + selectPad (0); + } + }); + }; + + loadFolderButton.onClick = [this] + { + fileChooser = std::make_unique ("Select Sample Folder", juce::File{}); + fileChooser->launchAsync (juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectDirectories, + [this] (const juce::FileChooser& fc) { + auto folder = fc.getResult(); + if (folder.isDirectory()) + { + processor.loadKitFromFolder (folder); + rebuildPadGrid(); + selectPad (0); + } + }); + }; + + // Panels + addAndMakeVisible (sampleEditor); + addAndMakeVisible (fxPanel); + addAndMakeVisible (masterPanel); + + rebuildPadGrid(); + selectPad (0); + + // Sizing + constrainer.setMinimumSize (800, 500); + constrainer.setMaximumSize (1920, 1080); + setConstrainer (&constrainer); + setSize (960, 600); + setResizable (true, true); + + startTimerHz (30); +} + +InstaDrumsEditor::~InstaDrumsEditor() +{ + setLookAndFeel (nullptr); +} + +void InstaDrumsEditor::rebuildPadGrid() +{ + padComponents.clear(); + + auto loadCallback = [this] (int padIndex, const juce::File& file) { + processor.loadSample (padIndex, file); + if (padIndex == selectedPadIndex) + sampleEditor.updateFromPad(); + }; + + for (int i = 0; i < processor.getNumPads(); ++i) + { + auto* pc = new PadComponent (processor.getPad (i), loadCallback, i); + pc->onSelected = [this] (int idx) { selectPad (idx); }; + addAndMakeVisible (pc); + padComponents.add (pc); + } + + resized(); +} + +void InstaDrumsEditor::selectPad (int index) +{ + selectedPadIndex = index; + + for (int i = 0; i < padComponents.size(); ++i) + padComponents[i]->setSelected (i == index); + + if (index >= 0 && index < processor.getNumPads()) + sampleEditor.setPad (&processor.getPad (index)); +} + +void InstaDrumsEditor::paint (juce::Graphics& g) +{ + g.fillAll (InstaDrumsLookAndFeel::bgDark); + + // Subtle divider lines + auto bounds = getLocalBounds(); + int rightPanelX = (int) (bounds.getWidth() * 0.52f); + int bottomPanelY = bounds.getHeight() - 56; + + g.setColour (InstaDrumsLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawVerticalLine (rightPanelX - 2, 30, (float) bottomPanelY); + g.drawHorizontalLine (bottomPanelY - 1, 0, (float) bounds.getWidth()); +} + +void InstaDrumsEditor::resized() +{ + auto area = getLocalBounds(); + + // Top bar (30px) + auto topBar = area.removeFromTop (30).reduced (6, 4); + titleLabel.setBounds (topBar.removeFromLeft (150)); + versionLabel.setBounds (topBar.removeFromRight (40)); + loadFolderButton.setBounds (topBar.removeFromRight (90).reduced (1)); + loadKitButton.setBounds (topBar.removeFromRight (70).reduced (1)); + saveKitButton.setBounds (topBar.removeFromRight (70).reduced (1)); + loadSampleButton.setBounds (topBar.removeFromRight (95).reduced (1)); + + // Bottom master bar (52px) + masterPanel.setBounds (area.removeFromBottom (52).reduced (4, 2)); + + // Left panel: pad grid (~52% width) + int rightPanelX = (int) (area.getWidth() * 0.52f); + auto leftArea = area.removeFromLeft (rightPanelX).reduced (4); + + // Pads label + auto padsHeader = leftArea.removeFromTop (16); + padsLabel.setBounds (padsHeader); + + // Pad grid + int numPads = padComponents.size(); + if (numPads > 0) + { + int rows = (numPads + padColumns - 1) / padColumns; + int padW = leftArea.getWidth() / padColumns; + int padH = leftArea.getHeight() / rows; + + for (int i = 0; i < numPads; ++i) + { + int row = i / padColumns; + int col = i % padColumns; + padComponents[i]->setBounds (leftArea.getX() + col * padW, + leftArea.getY() + row * padH, + padW, padH); + } + } + + // Right panel: sample editor (top ~55%) + FX (bottom ~45%) + auto rightArea = area.reduced (4); + int editorHeight = (int) (rightArea.getHeight() * 0.55f); + sampleEditor.setBounds (rightArea.removeFromTop (editorHeight).reduced (0, 2)); + fxPanel.setBounds (rightArea.reduced (0, 2)); +} + +void InstaDrumsEditor::timerCallback() +{ + for (auto* pc : padComponents) + pc->repaint(); + + // Update VU meter from processor output levels + // (simplified: just repaint for now) + masterPanel.getVuMeter().repaint(); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..e4970e2 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include "PluginProcessor.h" +#include "PadComponent.h" +#include "SampleEditorPanel.h" +#include "FxPanel.h" +#include "MasterPanel.h" +#include "LookAndFeel.h" + +class InstaDrumsEditor : public juce::AudioProcessorEditor, + private juce::Timer +{ +public: + explicit InstaDrumsEditor (InstaDrumsProcessor&); + ~InstaDrumsEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + +private: + InstaDrumsProcessor& processor; + InstaDrumsLookAndFeel customLookAndFeel; + + // Pad grid (left side) + juce::OwnedArray padComponents; + static constexpr int padColumns = 4; + + // Right side panels + SampleEditorPanel sampleEditor; + FxPanel fxPanel; + + // Bottom + MasterPanel masterPanel; + + // Top bar buttons + juce::TextButton loadSampleButton { "LOAD SAMPLE" }; + juce::TextButton saveKitButton { "SAVE KIT" }; + juce::TextButton loadKitButton { "LOAD KIT" }; + juce::TextButton loadFolderButton { "LOAD FOLDER" }; + + juce::Label titleLabel { {}, "INSTADRUMS" }; + juce::Label versionLabel { {}, "v1.0" }; + juce::Label padsLabel { {}, "DRUM SAMPLER" }; + + // State + int selectedPadIndex = 0; + + void rebuildPadGrid(); + void selectPad (int index); + void timerCallback() override; + + std::unique_ptr fileChooser; + + // Resizable + juce::ComponentBoundsConstrainer constrainer; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaDrumsEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..fea63e4 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,347 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +InstaDrumsProcessor::InstaDrumsProcessor() + : AudioProcessor (BusesProperties() + .withOutput ("Main", juce::AudioChannelSet::stereo(), true)) +{ + formatManager.registerBasicFormats(); + initializeDefaults(); +} + +InstaDrumsProcessor::~InstaDrumsProcessor() {} + +void InstaDrumsProcessor::initializeDefaults() +{ + // GM Drum Map defaults for first 12 pads + struct PadDefault { int note; const char* name; juce::uint32 colour; }; + static const PadDefault defaults[] = { + { 36, "Kick", 0xffff4444 }, // Red + { 38, "Snare", 0xffff8844 }, // Orange + { 42, "CH Hat", 0xffffff44 }, // Yellow + { 46, "OH Hat", 0xff88ff44 }, // Green + { 45, "Low Tom", 0xff44ffaa }, // Teal + { 48, "Mid Tom", 0xff44ddff }, // Cyan + { 50, "Hi Tom", 0xff4488ff }, // Blue + { 49, "Crash", 0xff8844ff }, // Purple + { 51, "Ride", 0xffcc44ff }, // Magenta + { 39, "Clap", 0xffff44cc }, // Pink + { 56, "Cowbell", 0xffff8888 }, // Light red + { 37, "Rimshot", 0xffaaaaff }, // Light blue + }; + + for (int i = 0; i < defaultNumPads && i < (int) std::size (defaults); ++i) + { + pads[i].midiNote = defaults[i].note; + pads[i].name = defaults[i].name; + pads[i].colour = juce::Colour (defaults[i].colour); + } +} + +void InstaDrumsProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + for (int i = 0; i < numActivePads; ++i) + pads[i].prepareToPlay (sampleRate, samplesPerBlock); +} + +void InstaDrumsProcessor::releaseResources() +{ + for (int i = 0; i < numActivePads; ++i) + pads[i].releaseResources(); +} + +void InstaDrumsProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + buffer.clear(); + + // Process MIDI messages + for (const auto metadata : midiMessages) + { + auto msg = metadata.getMessage(); + if (msg.isNoteOn()) + { + auto* pad = findPadForNote (msg.getNoteNumber()); + if (pad != nullptr) + { + // Handle choke groups + if (pad->chokeGroup >= 0) + { + for (int i = 0; i < numActivePads; ++i) + { + if (&pads[i] != pad && pads[i].chokeGroup == pad->chokeGroup) + pads[i].stop(); + } + } + pad->trigger (msg.getFloatVelocity()); + } + } + else if (msg.isNoteOff()) + { + auto* pad = findPadForNote (msg.getNoteNumber()); + if (pad != nullptr && ! pad->oneShot) + pad->stop(); + } + } + + // Render audio from all pads + for (int i = 0; i < numActivePads; ++i) + pads[i].renderNextBlock (buffer, 0, buffer.getNumSamples()); +} + +DrumPad* InstaDrumsProcessor::findPadForNote (int midiNote) +{ + for (int i = 0; i < numActivePads; ++i) + if (pads[i].midiNote == midiNote) + return &pads[i]; + return nullptr; +} + +void InstaDrumsProcessor::loadSample (int padIndex, const juce::File& file) +{ + if (padIndex < 0 || padIndex >= numActivePads) + return; + + if (file.isDirectory()) + pads[padIndex].loadLayersFromFolder (file, formatManager); + else + pads[padIndex].loadSample (file, formatManager); +} + +void InstaDrumsProcessor::addPads (int count) +{ + int newCount = std::min (numActivePads + count, maxPads); + for (int i = numActivePads; i < newCount; ++i) + { + pads[i].name = "Pad " + juce::String (i + 1); + pads[i].midiNote = 36 + i; // Sequential mapping + pads[i].colour = juce::Colour::fromHSV ((float) i / 16.0f, 0.7f, 1.0f, 1.0f); + } + numActivePads = newCount; +} + +void InstaDrumsProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + juce::XmlElement xml ("InstaDrumsState"); + xml.setAttribute ("numPads", numActivePads); + + for (int i = 0; i < numActivePads; ++i) + { + auto* padXml = xml.createNewChildElement ("Pad"); + padXml->setAttribute ("index", i); + padXml->setAttribute ("name", pads[i].name); + padXml->setAttribute ("midiNote", pads[i].midiNote); + padXml->setAttribute ("volume", (double) pads[i].volume); + padXml->setAttribute ("pan", (double) pads[i].pan); + padXml->setAttribute ("pitch", (double) pads[i].pitch); + padXml->setAttribute ("oneShot", pads[i].oneShot); + padXml->setAttribute ("chokeGroup", pads[i].chokeGroup); + padXml->setAttribute ("attack", (double) pads[i].attack); + padXml->setAttribute ("decay", (double) pads[i].decay); + padXml->setAttribute ("sustain", (double) pads[i].sustain); + padXml->setAttribute ("release", (double) pads[i].release); + padXml->setAttribute ("colour", (int) pads[i].colour.getARGB()); + + auto lf = pads[i].getLoadedFile(); + if (lf.existsAsFile() || lf.isDirectory()) + padXml->setAttribute ("samplePath", lf.getFullPathName()); + } + + copyXmlToBinary (xml, destData); +} + +void InstaDrumsProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + auto xml = getXmlFromBinary (data, sizeInBytes); + if (xml != nullptr && xml->hasTagName ("InstaDrumsState")) + { + numActivePads = xml->getIntAttribute ("numPads", defaultNumPads); + + for (auto* padXml : xml->getChildWithTagNameIterator ("Pad")) + { + int index = padXml->getIntAttribute ("index", -1); + if (index < 0 || index >= numActivePads) + continue; + + pads[index].name = padXml->getStringAttribute ("name", "Pad"); + pads[index].midiNote = padXml->getIntAttribute ("midiNote", 36 + index); + pads[index].volume = (float) padXml->getDoubleAttribute ("volume", 1.0); + pads[index].pan = (float) padXml->getDoubleAttribute ("pan", 0.0); + pads[index].pitch = (float) padXml->getDoubleAttribute ("pitch", 0.0); + pads[index].oneShot = padXml->getBoolAttribute ("oneShot", true); + pads[index].chokeGroup = padXml->getIntAttribute ("chokeGroup", -1); + pads[index].attack = (float) padXml->getDoubleAttribute ("attack", 0.001); + pads[index].decay = (float) padXml->getDoubleAttribute ("decay", 0.1); + pads[index].sustain = (float) padXml->getDoubleAttribute ("sustain", 1.0); + pads[index].release = (float) padXml->getDoubleAttribute ("release", 0.05); + pads[index].colour = juce::Colour ((juce::uint32) padXml->getIntAttribute ("colour", 0xff00ff88)); + + juce::String path = padXml->getStringAttribute ("samplePath"); + if (path.isNotEmpty()) + { + juce::File sampleFile (path); + if (sampleFile.isDirectory()) + pads[index].loadLayersFromFolder (sampleFile, formatManager); + else if (sampleFile.existsAsFile()) + pads[index].loadSample (sampleFile, formatManager); + } + } + } +} + +void InstaDrumsProcessor::loadKitFromFolder (const juce::File& folder) +{ + if (! folder.isDirectory()) + return; + + // Collect audio files from the folder + juce::Array audioFiles; + for (auto& f : folder.findChildFiles (juce::File::findFiles, false)) + { + auto ext = f.getFileExtension().toLowerCase(); + if (ext == ".wav" || ext == ".aiff" || ext == ".aif" || ext == ".flac" + || ext == ".ogg" || ext == ".mp3") + audioFiles.add (f); + } + + audioFiles.sort(); + + // Try to match files to pads by name (kick, snare, etc.) + auto matchPad = [&] (const juce::String& fileName) -> int + { + auto lower = fileName.toLowerCase(); + struct NameMatch { const char* keyword; int padIndex; }; + static const NameMatch matches[] = { + { "kick", 0 }, { "bass", 0 }, { "bd", 0 }, + { "snare", 1 }, { "sn", 1 }, { "sd", 1 }, + { "closedhihat", 2 }, { "closedhi", 2 }, { "chh", 2 }, { "ch hat", 2 }, + { "openhihat", 3 }, { "openhi", 3 }, { "ohh", 3 }, { "oh hat", 3 }, + { "lowtom", 4 }, { "low tom", 4 }, { "lt", 4 }, + { "midtom", 5 }, { "mid tom", 5 }, { "mt", 5 }, + { "hitom", 6 }, { "hi tom", 6 }, { "ht", 6 }, + { "crash", 7 }, + { "ride", 8 }, + { "clap", 9 }, + { "cowbell", 10 }, { "bell", 10 }, + { "rim", 11 }, + }; + + for (auto& m : matches) + if (lower.contains (m.keyword)) + return m.padIndex; + return -1; + }; + + // First pass: match by name + juce::Array assigned; + assigned.resize (numActivePads); + for (int i = 0; i < numActivePads; ++i) + assigned.set (i, false); + + for (auto& file : audioFiles) + { + int idx = matchPad (file.getFileNameWithoutExtension()); + if (idx >= 0 && idx < numActivePads && ! assigned[idx]) + { + pads[idx].loadSample (file, formatManager); + assigned.set (idx, true); + } + } + + // Second pass: assign remaining files to unassigned pads + int nextPad = 0; + for (auto& file : audioFiles) + { + int idx = matchPad (file.getFileNameWithoutExtension()); + if (idx >= 0 && idx < numActivePads && assigned[idx]) + continue; // Already assigned + + while (nextPad < numActivePads && assigned[nextPad]) + nextPad++; + + if (nextPad < numActivePads) + { + pads[nextPad].loadSample (file, formatManager); + assigned.set (nextPad, true); + nextPad++; + } + } +} + +void InstaDrumsProcessor::saveKitPreset (const juce::File& file) +{ + juce::XmlElement xml ("InstaDrumsKit"); + xml.setAttribute ("version", "1.0"); + xml.setAttribute ("numPads", numActivePads); + + for (int i = 0; i < numActivePads; ++i) + { + auto* padXml = xml.createNewChildElement ("Pad"); + padXml->setAttribute ("index", i); + padXml->setAttribute ("name", pads[i].name); + padXml->setAttribute ("midiNote", pads[i].midiNote); + padXml->setAttribute ("volume", (double) pads[i].volume); + padXml->setAttribute ("pan", (double) pads[i].pan); + padXml->setAttribute ("pitch", (double) pads[i].pitch); + padXml->setAttribute ("oneShot", pads[i].oneShot); + padXml->setAttribute ("chokeGroup", pads[i].chokeGroup); + padXml->setAttribute ("attack", (double) pads[i].attack); + padXml->setAttribute ("decay", (double) pads[i].decay); + padXml->setAttribute ("sustain", (double) pads[i].sustain); + padXml->setAttribute ("release", (double) pads[i].release); + padXml->setAttribute ("colour", (int) pads[i].colour.getARGB()); + + auto lf = pads[i].getLoadedFile(); + if (lf.existsAsFile() || lf.isDirectory()) + padXml->setAttribute ("samplePath", lf.getFullPathName()); + } + + xml.writeTo (file); +} + +void InstaDrumsProcessor::loadKitPreset (const juce::File& file) +{ + auto xml = juce::XmlDocument::parse (file); + if (xml == nullptr || ! xml->hasTagName ("InstaDrumsKit")) + return; + + numActivePads = xml->getIntAttribute ("numPads", defaultNumPads); + + for (auto* padXml : xml->getChildWithTagNameIterator ("Pad")) + { + int index = padXml->getIntAttribute ("index", -1); + if (index < 0 || index >= numActivePads) + continue; + + pads[index].name = padXml->getStringAttribute ("name", pads[index].name); + pads[index].midiNote = padXml->getIntAttribute ("midiNote", pads[index].midiNote); + pads[index].volume = (float) padXml->getDoubleAttribute ("volume", 1.0); + pads[index].pan = (float) padXml->getDoubleAttribute ("pan", 0.0); + pads[index].pitch = (float) padXml->getDoubleAttribute ("pitch", 0.0); + pads[index].oneShot = padXml->getBoolAttribute ("oneShot", true); + pads[index].chokeGroup = padXml->getIntAttribute ("chokeGroup", -1); + pads[index].attack = (float) padXml->getDoubleAttribute ("attack", 0.001); + pads[index].decay = (float) padXml->getDoubleAttribute ("decay", 0.1); + pads[index].sustain = (float) padXml->getDoubleAttribute ("sustain", 1.0); + pads[index].release = (float) padXml->getDoubleAttribute ("release", 0.05); + pads[index].colour = juce::Colour ((juce::uint32) padXml->getIntAttribute ("colour", (int) pads[index].colour.getARGB())); + + juce::String path = padXml->getStringAttribute ("samplePath"); + if (path.isNotEmpty()) + { + juce::File sampleFile (path); + if (sampleFile.existsAsFile()) + pads[index].loadSample (sampleFile, formatManager); + } + } +} + +juce::AudioProcessorEditor* InstaDrumsProcessor::createEditor() +{ + return new InstaDrumsEditor (*this); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new InstaDrumsProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..8727797 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include "DrumPad.h" + +class InstaDrumsProcessor : public juce::AudioProcessor +{ +public: + static constexpr int defaultNumPads = 12; + static constexpr int maxPads = 64; + + InstaDrumsProcessor(); + ~InstaDrumsProcessor() 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; } + 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; + + // Pad management + int getNumPads() const { return numActivePads; } + DrumPad& getPad (int index) { return pads[index]; } + void addPads (int count = 4); + void loadSample (int padIndex, const juce::File& file); + + juce::AudioFormatManager& getFormatManager() { return formatManager; } + + // Find pad by MIDI note + DrumPad* findPadForNote (int midiNote); + + // Kit management + void loadKitFromFolder (const juce::File& folder); + void saveKitPreset (const juce::File& file); + void loadKitPreset (const juce::File& file); + +private: + std::array pads; + int numActivePads = defaultNumPads; + juce::AudioFormatManager formatManager; + + // Default MIDI mapping (GM drum map) + void initializeDefaults(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaDrumsProcessor) +}; diff --git a/Source/SampleEditorPanel.cpp b/Source/SampleEditorPanel.cpp new file mode 100644 index 0000000..9cb0913 --- /dev/null +++ b/Source/SampleEditorPanel.cpp @@ -0,0 +1,144 @@ +#include "SampleEditorPanel.h" +#include "LookAndFeel.h" + +SampleEditorPanel::SampleEditorPanel() +{ + titleLabel.setFont (juce::FontOptions (14.0f, juce::Font::bold)); + titleLabel.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + addAndMakeVisible (titleLabel); + + padNameLabel.setFont (juce::FontOptions (13.0f, juce::Font::bold)); + padNameLabel.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::accent); + addAndMakeVisible (padNameLabel); + + waveform.setShowADSR (true); + addAndMakeVisible (waveform); + + setupKnob (attackSlider, attackLabel, "Attack", 0.0, 1.0, 0.001, 0.001); + setupKnob (decaySlider, decayLabel, "Decay", 0.0, 2.0, 0.1, 0.01); + setupKnob (sustainSlider, sustainLabel, "Sustain", 0.0, 1.0, 1.0, 0.01); + setupKnob (releaseSlider, releaseLabel, "Release", 0.0, 2.0, 0.05, 0.01); + setupKnob (pitchSlider, pitchLabel, "Pitch", -24.0, 24.0, 0.0, 0.1); + setupKnob (panSlider, panLabel, "Pan", -1.0, 1.0, 0.0, 0.01); + setupKnob (cutoffSlider, cutoffLabel, "Cutoff", 20.0, 20000.0, 20000.0, 1.0); + setupKnob (resoSlider, resoLabel, "Reso", 0.1, 10.0, 0.707, 0.01); + + cutoffSlider.setSkewFactorFromMidPoint (1000.0); +} + +void SampleEditorPanel::setupKnob (juce::Slider& s, juce::Label& l, const juce::String& name, + double min, double max, double val, double step) +{ + s.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + s.setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + s.setRange (min, max, step); + s.setValue (val, juce::dontSendNotification); + s.addListener (this); + addAndMakeVisible (s); + + l.setText (name, juce::dontSendNotification); + l.setFont (juce::FontOptions (9.0f)); + l.setColour (juce::Label::textColourId, InstaDrumsLookAndFeel::textSecondary); + l.setJustificationType (juce::Justification::centred); + addAndMakeVisible (l); +} + +void SampleEditorPanel::setPad (DrumPad* pad) +{ + currentPad = pad; + updateFromPad(); +} + +void SampleEditorPanel::updateFromPad() +{ + if (currentPad == nullptr) return; + + padNameLabel.setText (currentPad->name, juce::dontSendNotification); + + attackSlider.setValue (currentPad->attack, juce::dontSendNotification); + decaySlider.setValue (currentPad->decay, juce::dontSendNotification); + sustainSlider.setValue (currentPad->sustain, juce::dontSendNotification); + releaseSlider.setValue (currentPad->release, juce::dontSendNotification); + pitchSlider.setValue (currentPad->pitch, juce::dontSendNotification); + panSlider.setValue (currentPad->pan, juce::dontSendNotification); + + auto& buf = currentPad->getSampleBuffer(); + waveform.setBuffer (&buf); + waveform.setColour (currentPad->colour); + waveform.setADSR (currentPad->attack, currentPad->decay, currentPad->sustain, currentPad->release); + + repaint(); +} + +void SampleEditorPanel::sliderValueChanged (juce::Slider* slider) +{ + if (currentPad == nullptr) return; + + if (slider == &attackSlider) currentPad->attack = (float) slider->getValue(); + else if (slider == &decaySlider) currentPad->decay = (float) slider->getValue(); + else if (slider == &sustainSlider) currentPad->sustain = (float) slider->getValue(); + else if (slider == &releaseSlider) currentPad->release = (float) slider->getValue(); + else if (slider == &pitchSlider) currentPad->pitch = (float) slider->getValue(); + else if (slider == &panSlider) currentPad->pan = (float) slider->getValue(); + + // Update ADSR overlay + waveform.setADSR (currentPad->attack, currentPad->decay, currentPad->sustain, currentPad->release); +} + +void SampleEditorPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaDrumsLookAndFeel::bgMedium); + g.fillRoundedRectangle (bounds, 6.0f); + g.setColour (InstaDrumsLookAndFeel::bgLight.withAlpha (0.5f)); + g.drawRoundedRectangle (bounds, 6.0f, 1.0f); +} + +void SampleEditorPanel::resized() +{ + auto area = getLocalBounds().reduced (6); + + // Header + auto header = area.removeFromTop (20); + titleLabel.setBounds (header.removeFromLeft (100)); + padNameLabel.setBounds (header); + + area.removeFromTop (2); + + // Waveform (top portion ~40%) + int waveHeight = std::max (60, (int) (area.getHeight() * 0.38f)); + waveform.setBounds (area.removeFromTop (waveHeight)); + + area.removeFromTop (4); + + // ADSR knobs row + int knobH = std::max (40, (int) (area.getHeight() * 0.45f)); + auto adsrRow = area.removeFromTop (knobH); + int knobW = adsrRow.getWidth() / 4; + { + juce::Slider* s[] = { &attackSlider, &decaySlider, &sustainSlider, &releaseSlider }; + juce::Label* l[] = { &attackLabel, &decayLabel, &sustainLabel, &releaseLabel }; + for (int i = 0; i < 4; ++i) + { + auto col = adsrRow.removeFromLeft (knobW); + l[i]->setBounds (col.removeFromBottom (14)); + s[i]->setBounds (col); + } + } + + area.removeFromTop (2); + + // Bottom row: Pitch, Pan, Cutoff, Reso + auto bottomRow = area; + knobW = bottomRow.getWidth() / 4; + { + juce::Slider* s[] = { &pitchSlider, &panSlider, &cutoffSlider, &resoSlider }; + juce::Label* l[] = { &pitchLabel, &panLabel, &cutoffLabel, &resoLabel }; + for (int i = 0; i < 4; ++i) + { + auto col = bottomRow.removeFromLeft (knobW); + l[i]->setBounds (col.removeFromBottom (14)); + s[i]->setBounds (col); + } + } +} diff --git a/Source/SampleEditorPanel.h b/Source/SampleEditorPanel.h new file mode 100644 index 0000000..4e31eb7 --- /dev/null +++ b/Source/SampleEditorPanel.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include "DrumPad.h" +#include "WaveformDisplay.h" + +class SampleEditorPanel : public juce::Component, + public juce::Slider::Listener +{ +public: + SampleEditorPanel(); + + void setPad (DrumPad* pad); + DrumPad* getCurrentPad() const { return currentPad; } + + void paint (juce::Graphics& g) override; + void resized() override; + void sliderValueChanged (juce::Slider* slider) override; + + void updateFromPad(); + +private: + DrumPad* currentPad = nullptr; + + WaveformDisplay waveform; + + // ADSR knobs + juce::Slider attackSlider, decaySlider, sustainSlider, releaseSlider; + juce::Label attackLabel, decayLabel, sustainLabel, releaseLabel; + + // Sample controls + juce::Slider pitchSlider, panSlider, cutoffSlider, resoSlider; + juce::Label pitchLabel, panLabel, cutoffLabel, resoLabel; + + juce::Label titleLabel { {}, "Sample Editor" }; + juce::Label padNameLabel { {}, "" }; + + void setupKnob (juce::Slider& s, juce::Label& l, const juce::String& name, + double min, double max, double val, double step = 0.01); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SampleEditorPanel) +}; diff --git a/Source/VuMeter.h b/Source/VuMeter.h new file mode 100644 index 0000000..4f9e83f --- /dev/null +++ b/Source/VuMeter.h @@ -0,0 +1,47 @@ +#pragma once +#include + +class VuMeter : public juce::Component +{ +public: + void setLevel (float left, float right) + { + levelL = left; + levelR = right; + repaint(); + } + + void paint (juce::Graphics& g) override + { + auto bounds = getLocalBounds().toFloat().reduced (1); + float halfW = bounds.getWidth() / 2.0f - 1; + auto leftBar = bounds.removeFromLeft (halfW); + bounds.removeFromLeft (2); + auto rightBar = bounds; + + drawBar (g, leftBar, levelL); + drawBar (g, rightBar, levelR); + } + +private: + float levelL = 0.0f, levelR = 0.0f; + + void drawBar (juce::Graphics& g, juce::Rectangle bar, float level) + { + g.setColour (juce::Colour (0xff222233)); + g.fillRoundedRectangle (bar, 2.0f); + + float h = bar.getHeight() * juce::jlimit (0.0f, 1.0f, level); + auto filled = bar.removeFromBottom (h); + + // Green -> Yellow -> Red gradient + if (level < 0.6f) + g.setColour (juce::Colour (0xff00cc44)); + else if (level < 0.85f) + g.setColour (juce::Colour (0xffcccc00)); + else + g.setColour (juce::Colour (0xffff3333)); + + g.fillRoundedRectangle (filled, 2.0f); + } +}; diff --git a/Source/WaveformDisplay.cpp b/Source/WaveformDisplay.cpp new file mode 100644 index 0000000..d525ef5 --- /dev/null +++ b/Source/WaveformDisplay.cpp @@ -0,0 +1,106 @@ +#include "WaveformDisplay.h" +#include "LookAndFeel.h" + +WaveformDisplay::WaveformDisplay() {} + +void WaveformDisplay::setBuffer (const juce::AudioBuffer* buffer, double sampleRate) +{ + audioBuffer = buffer; + bufferSampleRate = sampleRate; + repaint(); +} + +void WaveformDisplay::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + + // Background + g.setColour (InstaDrumsLookAndFeel::bgDark.darker (0.3f)); + g.fillRoundedRectangle (bounds, 4.0f); + + if (audioBuffer == nullptr || audioBuffer->getNumSamples() == 0) + return; + + const int numSamples = audioBuffer->getNumSamples(); + const int startSample = (int) (startPos * numSamples); + const int endSample = (int) (endPos * numSamples); + const int visibleSamples = std::max (1, endSample - startSample); + const float width = bounds.getWidth(); + const float height = bounds.getHeight(); + const float midY = bounds.getCentreY(); + + // Draw waveform + juce::Path wavePath; + const float* data = audioBuffer->getReadPointer (0); + + for (int x = 0; x < (int) width; ++x) + { + int sampleIndex = startSample + (int) ((float) x / width * visibleSamples); + sampleIndex = juce::jlimit (0, numSamples - 1, sampleIndex); + + // Find min/max in a small range for better visualization + int blockSize = std::max (1, visibleSamples / (int) width); + float minVal = 1.0f, maxVal = -1.0f; + for (int j = 0; j < blockSize && (sampleIndex + j) < numSamples; ++j) + { + float v = data[sampleIndex + j]; + minVal = std::min (minVal, v); + maxVal = std::max (maxVal, v); + } + + float topY = midY - maxVal * (height * 0.45f); + float botY = midY - minVal * (height * 0.45f); + + if (x == 0) + wavePath.startNewSubPath ((float) x + bounds.getX(), topY); + + wavePath.lineTo ((float) x + bounds.getX(), topY); + + if (x == (int) width - 1) + { + // Close the path by going back along bottom + for (int bx = (int) width - 1; bx >= 0; --bx) + { + int si = startSample + (int) ((float) bx / width * visibleSamples); + si = juce::jlimit (0, numSamples - 1, si); + float mn = 1.0f; + for (int j = 0; j < blockSize && (si + j) < numSamples; ++j) + mn = std::min (mn, data[si + j]); + float by = midY - mn * (height * 0.45f); + wavePath.lineTo ((float) bx + bounds.getX(), by); + } + wavePath.closeSubPath(); + } + } + + // Fill waveform + g.setColour (waveColour.withAlpha (0.5f)); + g.fillPath (wavePath); + g.setColour (waveColour.withAlpha (0.9f)); + g.strokePath (wavePath, juce::PathStrokeType (1.0f)); + + // Draw ADSR overlay + if (showADSR) + { + float totalSeconds = (float) numSamples / (float) bufferSampleRate; + float ax = adsrA / totalSeconds; + float dx = adsrD / totalSeconds; + float sx = 0.4f; // sustain portion + float rx = adsrR / totalSeconds; + float total = ax + dx + sx + rx; + + // Normalize to width + juce::Path adsrPath; + float x0 = bounds.getX(); + float w = bounds.getWidth(); + + adsrPath.startNewSubPath (x0, bounds.getBottom()); + adsrPath.lineTo (x0 + (ax / total) * w, bounds.getY() + 4); // attack peak + adsrPath.lineTo (x0 + ((ax + dx) / total) * w, midY - (adsrS - 0.5f) * height * 0.8f); // decay to sustain + adsrPath.lineTo (x0 + ((ax + dx + sx) / total) * w, midY - (adsrS - 0.5f) * height * 0.8f); // sustain hold + adsrPath.lineTo (x0 + w, bounds.getBottom()); // release to 0 + + g.setColour (InstaDrumsLookAndFeel::accent.withAlpha (0.7f)); + g.strokePath (adsrPath, juce::PathStrokeType (2.0f)); + } +} diff --git a/Source/WaveformDisplay.h b/Source/WaveformDisplay.h new file mode 100644 index 0000000..e5a3e45 --- /dev/null +++ b/Source/WaveformDisplay.h @@ -0,0 +1,26 @@ +#pragma once +#include + +class WaveformDisplay : public juce::Component +{ +public: + WaveformDisplay(); + + void setBuffer (const juce::AudioBuffer* buffer, double sampleRate = 44100.0); + void setColour (juce::Colour c) { waveColour = c; repaint(); } + void setStartEnd (float start, float end) { startPos = start; endPos = end; repaint(); } + void setADSR (float a, float d, float s, float r) { adsrA = a; adsrD = d; adsrS = s; adsrR = r; repaint(); } + void setShowADSR (bool show) { showADSR = show; repaint(); } + + void paint (juce::Graphics& g) override; + +private: + const juce::AudioBuffer* audioBuffer = nullptr; + double bufferSampleRate = 44100.0; + juce::Colour waveColour { 0xffff8844 }; + float startPos = 0.0f, endPos = 1.0f; + float adsrA = 0.001f, adsrD = 0.1f, adsrS = 1.0f, adsrR = 0.05f; + bool showADSR = false; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WaveformDisplay) +};