Blog
Build This Awesome Sampler Plugin | Part 2: Loading and Playing Samples
Learn to build a JUCE sampler plugin: set up the Synthesiser class, load samples from BinaryData, map MIDI notes with BigInteger, and create reusable loading functions.

News
Build This Awesome Sampler Plugin | Part 2: Loading and Playing Samples
Learn to build a JUCE sampler plugin: set up the Synthesiser class, load samples from BinaryData, map MIDI notes with BigInteger, and create reusable loading functions.

Build This Awesome Sampler Plugin | Part 2: Loading and Playing Samples

Build This Awesome Sampler Plugin | Part 2: Loading and Playing Samples

Welcome to Part 2 of our sampler plugin tutorial series! In this episode, we're moving on to the fun stuff: setting up our audio engine, loading sounds, and actually hearing our sampler play. By the end of this tutorial, you'll have a working sampler that responds to MIDI input and plays back samples at the correct pitch.
This tutorial builds on Part 1 where we set up our project structure and CMake configuration. If you haven't completed that yet, I recommend going back and doing so first.
GitHub Repository
All the code for this tutorial is available on GitHub. I've organized the repository with separate branches for each episode, and within each branch, I've created commits for each major section. This makes it easy to follow along and see exactly what code changes at each step.
Getting Sounds into Your Project with BinaryData
Before we can play any sounds, we need to make sure our project can access them. JUCE provides a convenient way to embed audio files directly into your plugin using BinaryData. This approach compiles your sound files into the binary itself, so you don't need to worry about external file paths at runtime.
First, ensure that BinaryData is listed as a target link library in your CMakeLists.txt:
target_link_libraries(YourPlugin PRIVATE BinaryData)
After making changes to your CMake file, reinvoke CMake to regenerate your build files. Then, include the BinaryData header in your PluginProcessor.h:
#include "BinaryData.h"
This gives you access to all the audio files you've added to your project through CMake's juce_add_binary_data command.
Creating the MIDI Playback Engine
Here's where things get interesting. JUCE provides a class called Synthesiser that handles all the heavy lifting for MIDI-triggered audio playback. Despite its name, this class works perfectly for samplers too. I like to think of it as a "MIDI Playback Engine" because it handles MIDI routing, voice management, and audio rendering whether you're building a synth or a sampler.
Including the Right Libraries
Rather than including the entire JUCE library with JuceHeader.h, I prefer to include only the specific modules I need. This speeds up compilation and makes dependencies clearer. For our sampler, we need two modules:
#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_audio_formats/juce_audio_formats.h>
Creating the Synthesiser Object
In your PluginProcessor.h, add the synthesiser as a private member:
juce::Synthesiser midiPlaybackEngine;
Adding Voices
The number of voices determines how many notes can play simultaneously. For an 8-voice sampler, we add 8 SamplerVoice objects in the constructor:
static constexpr auto numVoices = 8;
// In constructor:
for (int i = 0; i < numVoices; ++i)
{
midiPlaybackEngine.addVoice(new juce::SamplerVoice());
}
Using static constexpr auto makes the voice count a compile-time constant, which is more efficient than a runtime variable. It also eliminates "magic numbers" from your code, making it more readable.
Setting the Sample Rate
The synthesiser needs to know the current sample rate to pitch samples correctly. Set this in prepareToPlay():
void prepareToPlay(double sampleRate, int samplesPerBlock) override
{
midiPlaybackEngine.setCurrentPlaybackSampleRate(sampleRate);
}
Rendering Audio
In processBlock(), call renderNextBlock() to process incoming MIDI and generate audio:
void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages) override
{
midiPlaybackEngine.renderNextBlock(buffer, midiMessages, 0, buffer.getNumSamples());
}
That's it for the playback engine setup! Now we need to give it some sounds to play.
Loading and Playing Your First Sample
Loading audio files in JUCE involves a few steps: creating a format manager, reading the audio data, and creating a SamplerSound object.
The AudioFormatManager
First, create an AudioFormatManager as a class member and register the basic audio formats in your constructor:
// In header: juce::AudioFormatManager formatManager;
// In constructor: formatManager.registerBasicFormats();
This registers handlers for common audio file types like WAV and AIFF.
Loading from BinaryData
To load a sound from BinaryData, we create a MemoryInputStream and pass it to the format manager:
auto inputStream = std::make_unique<juce::MemoryInputStream>(BinaryData::C5_wav, BinaryData::C5_wavSize, false );
auto reader = formatManager.createReaderFor(std::move(inputStream));
Note the use of std::make_unique and std::move. The createReaderFor() method takes ownership of the input stream, so we need to transfer ownership using std::move.
Creating a SamplerSound
With a valid reader, we can create a SamplerSound. The SamplerSound class takes several parameters:
• name - A string identifier for the sound
• reader - Reference to the AudioFormatReader
• midiNotes - A BigInteger specifying which MIDI notes trigger this sound
• originalMidiNote - The MIDI note at which the sample plays at original pitch
• attackTime - Attack time in seconds
• releaseTime - Release time in seconds
• maxSampleLength - Maximum sample length in seconds
if (reader != nullptr)
{
juce::BigInteger midiNotes;
int originalMidiNote = 60;
midiNotes.setBit(originalMidiNote);
double attack = 0.0;
double release = 0.1;
double maxLength = 10.0;
midiPlaybackEngine.addSound(new juce::SamplerSound("C5", *reader, midiNotes, originalMidiNote, attack, release, maxLength));
}
Notice the null check on the reader. This is essential defensive programming - createReaderFor() returns nullptr if the audio file can't be read.
Understanding BigInteger for MIDI Note Mapping
The BigInteger parameter might seem unusual at first. Under the hood, it's a 128-bit value where each bit represents one of the 128 possible MIDI notes (0-127). Setting a bit to 1 means that MIDI note will trigger this sample.
To map a sample across multiple keys, simply set multiple bits:
juce::BigInteger midiNotes; std::vector<int> noteSet = { 60, 61, 62, 63, 64, 65, 66, 67 };
for (auto note : noteSet)
{
midiNotes.setBit(note);
}
When you map a sample across multiple notes, JUCE automatically pitches the sample up or down based on the originalMidiNote parameter. If you set originalMidiNote to 60 and trigger note 62, the sample plays pitched up by two semitones.
Creating a Reusable Sample Loading Function
Loading one sample works, but we need to load many samples. Let's create a reusable function that handles the loading logic:
juce::SamplerSound* loadSound(const juce::String& name,int originalMidiNote, const std::vector<int>& midiNoteSet, const void* data, size_t sizeInBytes)
{
auto inputStream = std::make_unique<juce::MemoryInputStream>(data, sizeInBytes, false);
auto reader = formatManager.createReaderFor(std::move(inputStream));
if (reader != nullptr)
{
juce::BigInteger midiNotes;
for (auto note : midiNoteSet)
{
midiNotes.setBit(note);
}
double attack = 0.0;
double release = 0.1;
double maxLength = 10.0;
return new juce::SamplerSound(name, *reader, midiNotes, originalMidiNote, attack, release, maxLength);
}
return nullptr;
}
Now loading samples is clean and simple:
midiPlaybackEngine.addSound(loadSound("C5", 60, { 60 }, BinaryData::C5_wav, BinaryData::C5_wavSize));
Loading Multiple Samples
With our loadSound() function in place, loading an entire keyboard's worth of samples is straightforward. Each sample gets its own set of MIDI notes and original pitch:
midiPlaybackEngine.addSound(loadSound("C5", 60, {60, 61, 62, 63, 64, 65, 66, 67 }, BinaryData::C5_wav, BinaryData::C5_wavSize )); midiPlaybackEngine.addSound(loadSound("C6", 72, {68, 69, 70, 71, 72, 73, 74, 75 }, BinaryData::C6_wav, BinaryData::C6_wavSize ));
The key is matching each sample's originalMidiNote with the actual pitch of the recorded sample. This ensures accurate pitch shifting when triggering notes above or below the original.
Summary
We covered a lot of ground in this tutorial:
• Set up a JUCE Synthesiser as our MIDI playback engine
• Added SamplerVoice objects for polyphonic playback
• Loaded audio files from BinaryData using MemoryInputStream
• Created SamplerSound objects with proper MIDI note mapping
• Built a reusable loadSound() function for cleaner code
• Mapped samples across multiple keys with automatic pitch shifting
This is one of the foundational episodes in the series. We've gone from a silent gray rectangle to a working sampler that responds to MIDI input and plays back samples at the correct pitch.
In the next tutorial, we'll add a parameter system and build a basic user interface. Until then, happy coding!
Resources
GitHub Repository: github.com/TheAudioProgrammer/JuceSamplerAudioPlugin
JUCE Documentation: juce.com/learn/documentation
Join Our Community: theaudioprogrammer.com/community
Joshua Hodge
The Audio Programmer
More Tutorials
More Meetups
More News
More Articles


Is Music Tech Heading for a Collapse...or a Revolution?
A look at the current state of music technology and why innovation feels stuck – along with the key technical and industry pressures behind it. Drawing on insights from the Audio Developer Conference, this video highlights the patterns holding developers back and the opportunities that could spark the next wave of creativity in music tech.






