Part 3. Rompler gun
In order for MembrainSC to stand-out, alongside with the other features, we want it be able to play one-shot percussion PCM samples. This might seem unimportant and obvious, but combining synthethis / modeling with samples is extremely valuable. Again, I want to empaphize, that it moves complexity of layering different elements of percussion hits away from your DAW routing.
So, playing wave samples. JUCE has built-in support for that, and the API is surprisingly agile and straightforward. All you need to do, is to define a new class, make it inherit Synthesiser, define a file reader for your wav, define the key range (yes, JUCE even does the sample pitch-mapping for you!).
void RomplerGun::setup() {
// add voices to our sampler
for (int i = 0; i < MAX_VOICES; i++) {
addVoice(new SamplerVoice());
}
// set up our AudioFormatManager class as detailed in the API docs
// we can now use WAV and AIFF files!
audioFormatManager.registerBasicFormats();
// now that we have our manager, lets read a simple file so we can pass it to our SamplerSound object.
File* file = new File(File::getSpecialLocation(File::currentApplicationFile).getFullPathName() + "/Contents/Resources/kick_trimmed.wav");
AudioFormatReader* reader = audioFormatManager.createReaderFor(*file);
// lock our sound to middle C
BigInteger allNotes;
allNotes.setRange(60, 1, true);
// finally, add our sound
addSound(new SamplerSound("default", *reader, allNotes, 60, 0, 10, 10.0));
int numFiles = scanROM(File::getSpecialLocation(File::userDocumentsDirectory).getFullPathName() + "/MembrainSC");
std::cout << "Scanned files: " << numFiles << "\n";
sampleLoaded = true;
sampleIndex = 0;
}
Now, let’s just go through a couple of non-obvious nitty-gritty implementation details of that.
Sample indexing
For simplicity, we will declare, that all our drumkit-piece specific samples are placed in a separate directory within user files area, for example: /User/Documents/MembrainSC/Samples/Kick JUCE has build-in convenient directory indexing utilities.
int RomplerGun::scanROM(String directory) {
DirectoryIterator iter (File (directory), false, "*.wav");
int i = 0;
while (iter.next())
{
File theFileItFound (iter.getFile());
availableSamples.push_back(theFileItFound);
i++;
}
return i;
}
Sample loading
In the example above, we were loading sample during initiialization. If we want to change load more wave sounds, dynamically after, we, obviously, should not do it in the main processing thread. We definitely do not want to change the synth sample sound to something that is not loaded yet, either. The answer for that seems a little bit scary for the regular software, and outright nightmarish for time-crtitical DSP environment: threading. Especially for someone, who does not know a damn thing about C++ threading, like me.
There is a lonely beam of light in this darkness, though. Recently, I saw Pete Goodliffe’s panel at JUCE ADC, where he told that you don’t really want to use all those mutex-based patterns in real-world DSP, because of lock acquisition and release times, anyway. Instead, he proposed more simple approaches, based on atomicity of some core C++ data types, such as boolean and pointer. So, if you’re doing stuff on the one thread, set completed = false. Once done, update the pointer and set completed = true. On the other thread, just check the completed flag, and if it is set, you can safely read from the pointer.
So, here we are, implementing this pattern for sample loading. We have a class that implements JUCE thread, and performs sample loading:
Lots of poor C++ code
struct SampleLoadThread : public Thread
{
SampleLoadThread (RomplerGun* romplerGun, File f) : Thread ("sampleLoad"), file(f) {
rg = romplerGun;
}
void run() override
{
rg->loadSample(file);
std::cout << "Sample loaded!\n";
}
RomplerGun* rg;
File file;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SampleLoadThread)
~SampleLoadThread () {
this->stopThread(-1);
}
};
void RomplerGun::loadSample(File file) {
sampleLoaded = false;
AudioFormatReader* reader = audioFormatManager.createReaderFor(file);
BigInteger allNotes;
allNotes.setRange(60, 1, true);
tempSound = (new SamplerSound("default", *reader, allNotes, 60, 0, 10, 10.0));
sampleLoaded = true;
}
void RomplerGun::checkIfNewSampleLoaded() {
if(sampleLoaded && tempSound != nullptr) {
clearSounds();
addSound(tempSound);
tempSound = nullptr;
std::cout << "Sample changed!\n";
}
}
In RomplerGun we have simple methods, that allow us to start sample switch, and swap SamplerSounds in our synth, when a new one finished loading. Those a lightweight and can be called in the processing block.
void MembrainScAudioProcessor::processBlock (AudioBuffer<float>& buffer, MidiBuffer& midiMessages)
{
// ...
kickRomplerGun.setSample(sampleNum->get());
kickRomplerGun.checkIfNewSampleLoaded();
// ...
}