diff --git a/CMakeLists.txt b/CMakeLists.txt index 678b34e..9567bd4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,7 +63,10 @@ target_sources(${PROJECT_NAME} PRIVATE sources/PluginEditor.h sources/PluginProcessor.cpp sources/PluginProcessor.h + sources/SamplerLookAndFeel.cpp sources/SamplerLookAndFeel.h + sources/Knob.cpp + sources/Knob.h ) juce_generate_juce_header(${PROJECT_NAME}) @@ -73,6 +76,7 @@ juce_add_binary_data(BinaryData SOURCES assets/fonts/Inter-Medium.ttf assets/fonts/Inter-SemiBold.ttf assets/images/BG_1376x1032_1.png + assets/images/Knob_152x132_129.png assets/sounds/c5.wav assets/sounds/d5.wav assets/sounds/e5.wav diff --git a/README.md b/README.md index cddbafa..f5187ef 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,164 @@ -## README - -- Directory structure: - - `sources` has the source files - - `sounds` has the samples as WAV files - -- Using CMake instead of Projucer - - Using CPM rather than submodules to bring in JUCE. It's much easier for beginners to use. - - `juce_add_plugin` section: name and properties of the plug-in. - - `IS_SYNTH` must be TRUE - - `NEEDS_MIDI_INPUT` must be TRUE - - `target_sources` has the .h and .cpp files - - It is possible to let CMake find your source files automatically but I like being explicit. It does mean you need to run `cmake` again whenever you add/remove/rename source files (but that's true for Projucer as well). - - `juce_add_binary_data` has the sound files and images - - Mac: `cmake -B build -G Xcode` (if you use Xcode) - - For release builds, do `cmake -B build -G Xcode -D"CMAKE_OSX_ARCHITECTURES=arm64;x86_64"` to enable both Intel and ARM builds. Without this flag, it only builds for your own type of machine (which is faster for debug builds, which is why we don't put the `CMAKE_OSX_ARCHITECTURES` flag inside CMakeLists.txt). - - Windows: `cmake -B build -G "Visual Studio 17 2022"` - - Added the `**/build` folder to the `.gitignore` file so it doesn't get committed. - - A few notable things inside the CMakeLists.txt file: - - `set(CMAKE_MSVC_RUNTIME_LIBRARY ...`: Will link with the static C++ library rather than the dynamic one (on Windows). - - `# generate PDB/dSYM files for the Release build`: This section enables the creation of PDB (on Windows) and dSYM (on Mac) files that allow us to symbolicate release build crash logs. - - `set_property(GLOBAL PROPERTY USE_FOLDERS YES)` and `option(JUCE_ENABLE_MODULE_SOURCE_GROUPS "Enable Module Source Groups" ON)`: These are optional and only change how the project will look in your IDE. - - The `juce_add_binary_data()` section in the CMake file adds the WAV files to the plug-in. - -- Samples: - - We're adding the WAV files as binary data to the plug-in itself. - - If you have many samples or really large ones, they cannot be added to binary data but must be loaded on-demand. This is a lot more complicated. - - Note that Finder/Explorer shows `a5.wav` and before `c5.wav`, since it doesn't understand that these should be musical notes. - - There is only one velocity per note. - - When you make your own samples, make sure there is no silence at the beginning or end and that all samples are roughly equally loud (although we could fix uneven loudness in code). - -- After running `cmake -B build ...` for the first time, if you open the project in your IDE there may be source files in red, notably `JuceHeader.h`, `BinaryData.h`, and `BinaryData1.cpp` and so on. That's because these files are automatically generated during the build process. After the first build, they will appear as normal and you can open them. - -- Some notes about the `PluginProcessor` code: - - This doesn't use the typical code from the JUCE templates like what you get with Projucer (since we're not using Projucer). I've simplified it a little. - - Currently this is stereo only (see `isBusesLayoutSupported`). Mono wouldn't be too hard to add, since `juce::Synthesiser` supports it already. +# JUCE Sampler Audio Plugin + +A simple but functional sampler plugin built with JUCE 8.0.8, featuring chromatic sampling across multiple octaves with decay and reverb controls. + +## Features + +- 8 chromatic samples (C5 through C6) mapped across the full MIDI range +- Adjustable decay envelope +- Built-in reverb effect +- Custom GUI with filmstrip knobs +- VST3, AU, and Standalone formats + +## Directory Structure + +- `sources/` - Source files (.h and .cpp) +- `assets/` - WAV samples, PNG images, and TTF fonts +- `build/` - Generated build files (not in git) + +## Build Instructions + +### Prerequisites + +- CMake 3.22 or higher +- C++20 compatible compiler +- Xcode (macOS) or Visual Studio 2022 (Windows) + +### macOS + +```bash +# Debug build (native architecture only) +cmake -B build -G Xcode + +# Release build (universal binary: Intel + Apple Silicon) +cmake -B build -G Xcode -D"CMAKE_OSX_ARCHITECTURES=arm64;x86_64" + +# Build from command line +cmake --build build --config Release +``` + +After building, plugins will be automatically copied to: +- AU: `~/Library/Audio/Plug-Ins/Components/` +- VST3: `~/Library/Audio/Plug-Ins/VST3/` +- Standalone: `build/Sampler_artefacts/` + +### Windows + +```bash +# Generate Visual Studio project +cmake -B build -G "Visual Studio 17 2022" + +# Build from command line +cmake --build build --config Release +``` + +Open `build/Sampler.sln` in Visual Studio to build and debug. + +### First Build Notes + +- After running CMake for the first time, generated files like `JuceHeader.h` and `BinaryData.h` may appear in red in your IDE +- These files are created during the build process +- After the first successful build, they will be accessible +- If you add/remove/rename source files, you need to re-run CMake + +## Parameters + +### Decay (0-100%) + +Controls the envelope decay time of the sampler voices. + +- **Range:** 0% to 100% +- **Default:** 0% +- **Behavior:** Uses a non-linear (squared) curve to provide finer control at lower values + - 0% = 0.05s decay time (very short) + - 100% = 1.0s decay time (sustained) +- **Attack:** Fixed at 0.0s (instant) +- **Release:** Fixed at 0.05s + +### Reverb (0-100%) + +Controls the reverb room size. + +- **Range:** 0% to 100% +- **Default:** 50% +- **Behavior:** + - Room size scales linearly with percentage + - Damping: Fixed at 0.5 + - Wet level: Fixed at 0.33 + - Dry level: Fixed at 0.4 +- **Note:** Reverb is always active in the signal chain + +## MIDI Note Mapping + +The plugin uses 8 chromatic samples (C5-C6) distributed across the full MIDI range. Each sample covers multiple keys to create a chromatic scale: + +| Sample | Root Note | MIDI Notes Covered | +|--------|-----------|-------------------| +| **C5** | 72 (C5) | 24, 25, 36, 37, 48, 49, 60, 61, 72, 73 (C1-C#1, C2-C#2, C3-C#3, C4-C#4, C5-C#5) | +| **D5** | 74 (D5) | 26, 27, 38, 39, 50, 51, 62, 63, 74, 75, 86, 87, 98, 99 (D1-D#1, D2-D#2, D3-D#3, D4-D#4, D5-D#5, D6-D#6, D7-D#7) | +| **E5** | 76 (E5) | 28, 40, 52, 64, 76, 88, 100 (E1, E2, E3, E4, E5, E6, E7) | +| **F5** | 77 (F5) | 29, 30, 41, 42, 53, 54, 65, 66, 77, 78, 89, 90, 101, 102 (F1-F#1, F2-F#2, F3-F#3, F4-F#4, F5-F#5, F6-F#6, F7-F#7) | +| **G5** | 79 (G5) | 31, 32, 43, 44, 55, 56, 67, 68, 79, 80, 91, 92, 103, 104 (G1-G#1, G2-G#2, G3-G#3, G4-G#4, G5-G#5, G6-G#6, G7-G#7) | +| **A5** | 81 (A5) | 21, 22, 33, 34, 45, 46, 57, 58, 69, 70, 81, 82, 93, 94, 105, 106 (A0-A#0, A1-A#1, A2-A#2, A3-A#3, A4-A#4, A5-A#5, A6-A#6, A7-A#7) | +| **B5** | 83 (B5) | 23, 35, 47, 59, 71, 83, 95, 107 (B0, B1, B2, B3, B4, B5, B6, B7) | +| **C6** | 84 (C6) | 84, 85, 96, 97, 108 (C6-C#6, C7-C#7, C8) | + +**Note:** JUCE's sampler engine automatically pitch-shifts each sample to match the played note, using the root note as the reference pitch. + +## Technical Details + +### Audio Processing + +- **Polyphony:** 8 voices +- **Voice Management:** Standard JUCE voice stealing when polyphony limit is reached +- **Stereo Processing:** Stereo output only (mono not currently supported) +- **Sample Format:** 16-bit WAV files included as binary data + +### CMake Configuration + +This project uses CMake instead of Projucer: + +- **CPM:** Downloads JUCE automatically (no submodules needed) +- **JUCE Version:** 8.0.8 +- **Important flags in CMakeLists.txt:** + - `IS_SYNTH TRUE` - Marks plugin as an instrument + - `NEEDS_MIDI_INPUT TRUE` - Enables MIDI input + - `CMAKE_MSVC_RUNTIME_LIBRARY` - Links with static C++ library on Windows + - PDB/dSYM generation enabled for release builds (allows crash log symbolication) +- **Binary Data:** WAV files, PNG images, and TTF fonts are embedded in the plugin binary via `juce_add_binary_data()` + +### Assets + +**Samples:** +- WAV files are embedded as binary data in the plugin executable +- For projects with many or very large samples, on-demand loading would be required (more complex) +- Current samples have no silence at start/end and are normalized to similar loudness +- Single velocity layer (no velocity sensitivity) +- Note: Finder/Explorer may sort `a5.wav` before `c5.wav` (alphabetical, not musical order) + +**Images and Fonts:** +- Background image and Inter fonts are embedded as binary data +- Plugin is fully self-contained with no external file dependencies at runtime +- All artwork is rendered at 2x resolution: + - Background: 1376×1032 pixels (displayed at 688×516) + - Knob filmstrip: 152×132 pixels per frame, 129 frames total + - 2x assets ensure sharpness on Retina/HiDPI displays +- Inter font family is licensed under SIL Open Font License (royalty-free distribution) + +### Code Structure Notes + +- Simplified `PluginProcessor` implementation (not using standard JUCE/Projucer template) +- Stereo-only output (see `isBusesLayoutSupported()`) +- Mono support could be added as `juce::Synthesiser` already supports it + +## Development Notes + +- The `**/build` folder is in `.gitignore` and won't be committed +- When adding/removing/renaming source files, update `target_sources` in `CMakeLists.txt` and re-run CMake +- Explicit file listing (vs. automatic discovery) provides better control and clarity + +## License + +Check individual asset licenses: +- Inter fonts: SIL Open Font License +- JUCE: GPL/Commercial (see JUCE licensing for distribution) diff --git a/assets/images/Knob_152x132_129.png b/assets/images/Knob_152x132_129.png new file mode 100644 index 0000000..add13af Binary files /dev/null and b/assets/images/Knob_152x132_129.png differ diff --git a/sources/Knob.cpp b/sources/Knob.cpp new file mode 100644 index 0000000..6d73c62 --- /dev/null +++ b/sources/Knob.cpp @@ -0,0 +1,30 @@ +// +// Created by Joshua Hodge on 6/22/26. +// + +#include "Knob.h" + +Knob::Knob(const juce::String& text, + juce::AudioProcessorValueTreeState& apvts, + const juce::ParameterID& parameterID) : + sliderLF(Images::getKnob(), 129), attachment(apvts, parameterID.getParamID(), slider) +{ + setSize(76, 107); + + slider.setSliderStyle(juce::Slider::SliderStyle::RotaryHorizontalVerticalDrag); + slider.setTextBoxStyle(juce::Slider::TextBoxBelow, false, getWidth(), 14); + slider.setLookAndFeel(&sliderLF); + addAndMakeVisible(slider); + + label.setText(text, juce::NotificationType::dontSendNotification); + label.setJustificationType(juce::Justification::horizontallyCentred); + label.setFont(Fonts::getBoldFont()); + label.setColour(juce::Label::textColourId, Colors::textMid); + addAndMakeVisible(label); +} + +void Knob::resized() +{ + label.setBounds(0, 0, getWidth(), 19); + slider.setBounds(0, 19, getWidth(), 88); +} diff --git a/sources/Knob.h b/sources/Knob.h new file mode 100644 index 0000000..2fea1b5 --- /dev/null +++ b/sources/Knob.h @@ -0,0 +1,32 @@ +// +// Created by Joshua Hodge on 6/22/26. +// + +#pragma once + +#include "SamplerLookAndFeel.h" +#include "juce_audio_processors/juce_audio_processors.h" + + +class Knob : public juce::Component +{ +public: + Knob (const juce::String& text, + juce::AudioProcessorValueTreeState& apvts, + const juce::ParameterID& parameterID); + + ~Knob() override = default; + + void resized() override; + + juce::Slider& getSlider() { return slider; } + juce::Label& getLabel() { return label; } + +private: + KnobLookAndFeel sliderLF; + juce::Slider slider; + juce::Label label; + juce::AudioProcessorValueTreeState::SliderAttachment attachment; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(Knob) +}; diff --git a/sources/PluginEditor.cpp b/sources/PluginEditor.cpp index 129258e..390167e 100644 --- a/sources/PluginEditor.cpp +++ b/sources/PluginEditor.cpp @@ -6,10 +6,8 @@ SamplerAudioProcessorEditor::SamplerAudioProcessorEditor(SamplerAudioProcessor& { setOpaque(true); setSize(windowWidth, windowHeight); -} - -SamplerAudioProcessorEditor::~SamplerAudioProcessorEditor() -{ + addAndMakeVisible(decayKnob); + addAndMakeVisible(reverbKnob); } void SamplerAudioProcessorEditor::paint(juce::Graphics& g) @@ -19,4 +17,6 @@ void SamplerAudioProcessorEditor::paint(juce::Graphics& g) void SamplerAudioProcessorEditor::resized() { -} \ No newline at end of file + decayKnob.setTopLeftPosition(451, 91); + reverbKnob.setTopLeftPosition(557, 91); +} diff --git a/sources/PluginEditor.h b/sources/PluginEditor.h index b34c726..492fc01 100644 --- a/sources/PluginEditor.h +++ b/sources/PluginEditor.h @@ -2,13 +2,14 @@ #include "juce_gui_basics/juce_gui_basics.h" #include "SamplerLookAndFeel.h" +#include "Knob.h" #include "PluginProcessor.h" class SamplerAudioProcessorEditor : public juce::AudioProcessorEditor { public: explicit SamplerAudioProcessorEditor(SamplerAudioProcessor&); - ~SamplerAudioProcessorEditor() override; + ~SamplerAudioProcessorEditor() override = default; void paint(juce::Graphics&) override; void resized() override; @@ -18,5 +19,8 @@ class SamplerAudioProcessorEditor : public juce::AudioProcessorEditor static constexpr int windowHeight = 516; SamplerAudioProcessor& audioProcessor; + Knob decayKnob { "Decay", audioProcessor.apvts, "decay" }; + Knob reverbKnob { "Reverb", audioProcessor.apvts, "reverb" }; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SamplerAudioProcessorEditor) }; diff --git a/sources/PluginProcessor.cpp b/sources/PluginProcessor.cpp index bee8fae..8596b81 100644 --- a/sources/PluginProcessor.cpp +++ b/sources/PluginProcessor.cpp @@ -10,7 +10,7 @@ juce::SamplerSound* SamplerAudioProcessor::loadSound(const juce::String& name, { auto inputStream = std::make_unique(data, sizeInBytes, false); - if (auto reader = formatManager.createReaderFor(std::move(inputStream))) + if (const auto rawReader = formatManager.createReaderFor(std::move(inputStream))) { juce::BigInteger midiNotes; @@ -19,7 +19,10 @@ juce::SamplerSound* SamplerAudioProcessor::loadSound(const juce::String& name, midiNotes.setBit(note); } - return new juce::SamplerSound(name, *reader, midiNotes, originalMidiNote, 0.0, 0.1, 10.0); + // Store the raw readers in our vector of smart audio readers so they outlast this function call + audioReaders.emplace_back(rawReader); + + return new juce::SamplerSound(name, *audioReaders.back(), midiNotes, originalMidiNote, 0.0, 0.1, 10.0); } return nullptr; diff --git a/sources/PluginProcessor.h b/sources/PluginProcessor.h index 8aaffc0..5ff363e 100644 --- a/sources/PluginProcessor.h +++ b/sources/PluginProcessor.h @@ -49,6 +49,9 @@ class SamplerAudioProcessor final : public juce::AudioProcessor juce::AudioFormatManager formatManager; + // Fixes memory leak with reader in loadSound() + std::vector> audioReaders; + juce::Reverb reverb; float oldDecay = 0.0f; diff --git a/sources/SamplerLookAndFeel.cpp b/sources/SamplerLookAndFeel.cpp new file mode 100644 index 0000000..d60bd64 --- /dev/null +++ b/sources/SamplerLookAndFeel.cpp @@ -0,0 +1,30 @@ +// +// Created by Joshua Hodge on 6/22/26. +// + +#include "SamplerLookAndFeel.h" + +KnobLookAndFeel::KnobLookAndFeel(const juce::Image& image, int frames) +: filmstrip(image), numFrames(frames) +{ + +} + +void KnobLookAndFeel::drawRotarySlider(juce::Graphics& g, + [[maybe_unused]] int x, + [[maybe_unused]] int y, + [[maybe_unused]] int width, + [[maybe_unused]] int height, + float sliderPosProportional, + [[maybe_unused]] float rotaryStartAngle, + [[maybe_unused]] float rotaryEndAngle, + [[maybe_unused]] juce::Slider& slider) +{ + const int imageWidth = filmstrip.getWidth(); + const int imageHeight = filmstrip.getHeight() / numFrames; + const int frameIndex = static_cast(sliderPosProportional * static_cast(numFrames - 1)); + + g.drawImage(filmstrip, 0, 0, imageWidth / 2, imageHeight / 2, + 0, frameIndex * imageHeight, imageWidth, imageHeight); +} + diff --git a/sources/SamplerLookAndFeel.h b/sources/SamplerLookAndFeel.h index 145e5f9..5694649 100644 --- a/sources/SamplerLookAndFeel.h +++ b/sources/SamplerLookAndFeel.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include namespace Colors @@ -30,7 +31,7 @@ namespace Fonts inline juce::Font getMediumFont(const float height = 14.0f) { - return { juce::FontOptions(typefaceBold).withHeight(height) }; + return { juce::FontOptions(typefaceMedium).withHeight(height) }; } inline juce::Font getBoldFont(const float height = 14.0f) @@ -45,4 +46,26 @@ namespace Images { return juce::ImageCache::getFromMemory(BinaryData::BG_1376x1032_1_png, BinaryData::BG_1376x1032_1_pngSize); } -} \ No newline at end of file + + inline juce::Image getKnob() + { + return juce::ImageCache::getFromMemory(BinaryData::Knob_152x132_129_png, BinaryData::Knob_152x132_129_pngSize); + } +} + +// 1. Now let's set up the drawing behavior for the knob png strip +class KnobLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + KnobLookAndFeel(const juce::Image& image, int numFrames); + + void drawRotarySlider(juce::Graphics&, int x, int y, int width, int height, + float sliderPosProportional, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider&) override; + +private: + juce::Image filmstrip; + int numFrames; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(KnobLookAndFeel) +}; \ No newline at end of file