spezifisches

Extracting audio samples from Rave Generator 2 VST plugin

reverse engineering music

Rave Generator 2 is a widely used free VST plugin that's basically a sampler/rompler with a bunch of included classic rave and hardcore samples. These instantly take you back to an era of dirty pitched up samples from orchestra stabs and piano chords. The FAQ file already sets the scene:

Q: i like the sound of RaveGenerator but it aliases like hell, what can i do ?
A: enjoy the dirty sound of the 90's rave.

You can get the plugin on the Wavosaur blog for Linux/Windows/Mac.

a screenshot of the Rave Generator window showing a Juno Hoover sample waveform
Who needs an Alpha Juno if you have this?

Motivation

One problem with the Linux version is that it requires Qt4 libraries which are so old that they're not included anymore in most distros. In case of Debian Bullseye you can work around this by copying them from old packages or build them yourself.

But if you're using Bitwig's Flatpak version you're out of luck because you can't easily install Qt4 (if you don't want to build a custom Flatpak package of Bitwig containing Qt4).

a screenshot of the plugin in Bitwig showing a list of presets
The half-working thing

Adding the plugin in Flatpak-Bitwig leaves you with a half-working thing where you can select presets of combinations of samples but you can't access or rearrange individual samples. The main UI is inaccessible because trying to open it just gives the following log messages about missing libraries:

UIRaveGenerator2VST: error while loading shared libraries: libQtCore.so.4: cannot open shared object file: No such file or directory
VST plugin did not create it's window inside the plugin window

The mentioned workarounds seem like a hassle. I waved my fist at a cloud and opened Ghidra.

Finding the samples

a screenshot of Ghidra showing an assembler and C code listing of the pathToBuffer function
Start of Sample::pathToBuffer()

When searching for known sample names I quickly found this function (Sample::pathToBuffer) which takes a resource name string like :/Resources/sounds/stabs2/foo.wav and returns a pointer to WAVE data containing the wanted sample.

The string's syntax reminded me of Qt's QResource so I ran dgchurchill/extract-qt-resources on the plugin file (RaveGenerator2VST-x64.so). But it couldn't detect any files in there.

There are 83 individual samples in there which would have been too many to do manually so I wrote a script using Ghidra-Jupyter-Kotlin for quick prototyping (and so I could do some Kotlin).

The notebook

I uploaded the Jupyter Notebook to Github Gist (mirror). You can just open it and follow the instructions at its top and ignore the rest in here if you don't care about a description of what it does.

So first we have a container for information we want to gather for each sample:

data class RGSample(var fileName: String? = null,
var resourceName: String? = null,
var waveAddr: GenericAddress? = null,
var rootNote: String? = null) {
fun valid(): Boolean {
return fileName != null && resourceName != null && waveAddr != null
}
}

pathToBuffer parser

In a loop we then go through each line (i.e. assembler instruction) of the pathToBuffer function body:

val samples = mutableListOf<RGSample>()
var currentSample = RGSample()

for (codeUnit in currentProgram.listing.getCodeUnits(pathToBuffer.body, true)) {
if (monitor.isCancelled) {
return
}

val mnem = codeUnit.mnemonicString
if (mnem == "LEA") {
// load resource name
val resourceNameAddr = codeUnit.getAddress(1)
val resourceName = getDataAt(resourceNameAddr).getValue() as String
currentSample.resourceName = resourceName
currentSample.fileName = resourceName.removePrefix(":/Resources/")

if (debugPathToBuffer) {
println("${codeUnit.address}: ${resourceNameAddr} ${resourceName}")
}
} else if (mnem.contains("MOV") && codeUnit.toString().contains(" RAX,")) {
// set function return value
val wavePtr = if (mnem.startsWith("CMOV")) {
getDataAt(codeUnit.getAddress(1)).getValue()
} else {
codeUnit.getAddress(0)
} as GenericAddress

currentSample.waveAddr = wavePtr

// [... rootNote handling excluded for clarity ...]

// this sample is done
samples.add(currentSample)

if (debugPathToBuffer) {
println("${codeUnit.address}: wave address ${wavePtr}")
}

// next sample
currentSample = RGSample()
}
}

The LEA instructions here always load a resource name string to compare against the function's input parameter.

The comparison's result is checked and if successful the address of the wanted WAVE data is copied into the RAX register as a return value. These instructions are MOVs, or CMOVNZ in the last occurence.

Luckily the compiler produced pretty much the same code for each of the cases so we can get away with a very simple parser: store the resource name of the checked sample in currentSample.resourceName and the next MOV RAX,... line will be the corresponding data pointer which we store in currentSample.waveAddr.

In the end we have a list of sample names and data pointers in samples which we can use to write the data to external files.

adjustRootNote parser

Okay, we now know where the samples are but I saw that some sample sounds are recorded in a note different from C3 which I assume to be the default root note (because it usually is). It would be nice to have the root note in the file name so I can set it accordingly in my sampler. And so that I actually get a C when I'm pressing C on my keyboard.

a screenshot of Ghidra showing listings of setRootNote and adjustRootNote
start of Sample::adjustRootNote() (left) and C code for Sample::setRootNote() (right)

Also in the Sample class there's an appropiately named adjustRootNote() function which handles the cases for the 22 samples which have different root notes.

The parser code here is pretty similar to the one for pathToBuffer() so here's just an essential differing part:

/*...*/ if (mnem == "JZ") {
if (debugAdjustRootNote) {
println("${codeUnit.address}: $codeUnit")
}

// follow the jump target for the case of the resource name matching
val jmpTarget = codeUnit.getAddress(0)
for (nestedCU in currentProgram.listing.getCodeUnits(jmpTarget, true)) {
if (debugAdjustRootNote) {
println("NEST ${nestedCU.address}: $nestedCU")
}

if (nestedCU.mnemonicString.startsWith("J")) {
// stop at the jump back to avoid cycles
break
} else if (nestedCU.toString().startsWith("MOV ESI,")) {
if (currentResource == "") {
println("ERROR: we missed the root note name")
}

// ex: MOV ESI,0x38
// get argument to call of Sample::setRootNote()
val rootNoteRaw = nestedCU.getScalar(1).getValue()
val rootNote = getRootNoteString(rootNoteRaw)

// write root note
if (debugAdjustRootNote || infoAdjustRootNote) {
println("got root note: ${currentResource} -> ${rootNote}")
}

rootNotes[currentResource] = rootNote
currentResource = ""
}
}
}

The switch statement in this function is structured a bit differently than the previous one. While in pathToBuffer() the instructions for the case of a matching resource string came immediately after the comparison, here the unsuccessful case and the check for the next case follow.

In case of a successful match of the resource string we have a jump to a small set of instructions which call Sample::setRootNote(int note) with the appropriate note value (explained later).

That's where the code shown above comes to play which follows this jump, extracts the 2nd argument to the MOV ESI, ... instruction which contains the argument to the function call to setRootNote(), and stores it in the HashMap rootNotes which gets added to the samples list from before.

setRootNote analysis

So what does this note parameter in setRootNote(int note) mean? I translated the function's decompiled C code to Python to show what's going on:

# Sample::setRootNote() rewritten in Python
factor = 1.0

if note > 60:
for i in range(note - 60):
# pitch down one semitone
factor /= 1.059463094359
elif note < 60:
for i in range(60 - note):
# up by one semitone
factor *= 1.059463094359

So if the root note is above 60 (C3) the "factor" gets scaled down by 1.059463094359 for each semitone that it's above 60. The symmetric case is done for a note lower than 60.

You can easily search the Internet for the term 1.059463094359 and see that it's the 12th root of 2 which is the frequency ratio between two adjacent notes (e.g. C and C# or B and C) in the equal-tempered scale in western music.

We can therefore conclude that the "factor" modifies the playback speed of the sample so that a sample recorded in C# is played at 1/1.059463094359 the original speed when you're pressing C on the keyboard.

Using this background we can write a function to translate these note values to readable strings:

fun getRootNoteString(arg: Long): String {
// Sample::setRootNote(int) pitches the note up or down by the given number of semitones away from 60 (= C3)

// convert that number to a readable note, assuming C3=60, B2=59, C#3=61,...
val octave = (arg / 12).toInt() - 2
val semitone = (arg % 12).toInt()
val notes = arrayOf("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")

return "${notes[semitone]}${octave}"
}

Conclusion

Now we have everything in place to export the samples with meaningful file names for use in another sampler.

In case you missed it above here is the Jupyter Notebook (Github) (mirror) you need to get the WAVE files.

Also I found no full list of samples contained in Rave Generator 2 on the Internet, so here it is in case someone looks for those:

:/Resources/sounds/instrus/Get Up Bass.wav
:/Resources/sounds/instrus/Hardcore Hoover.wav
:/Resources/sounds/instrus/Hi Voltage.wav
:/Resources/sounds/instrus/Juno Bass.wav
:/Resources/sounds/instrus/Juno Hoover.wav
:/Resources/sounds/instrus/m1organ.wav
:/Resources/sounds/instrus/Maniak Techno.wav
:/Resources/sounds/instrus/Mayday Dream.wav
:/Resources/sounds/instrus/Omnibus.wav
:/Resources/sounds/instrus/Pizzicato Dance.wav
:/Resources/sounds/instrus/PsychotropiCZ.wav
:/Resources/sounds/instrus/Pump Bass.wav
:/Resources/sounds/instrus/Rave CutBass.wav
:/Resources/sounds/instrus/Rave Cycle.wav
:/Resources/sounds/instrus/Square Bass.wav
:/Resources/sounds/jx1/JX1 C4+C5.wav
:/Resources/sounds/stabs2/Awesome3.wav
:/Resources/sounds/stabs2/BizarreInc1.wav
:/Resources/sounds/stabs2/Black Riot 2.wav
:/Resources/sounds/stabs2/Cubic22.wav
:/Resources/sounds/stabs2/DeeLiteOrgan.wav
:/Resources/sounds/stabs2/Dist Brazil.wav
:/Resources/sounds/stabs2/DJ Professor.wav
:/Resources/sounds/stabs2/Enjoy.wav
:/Resources/sounds/stabs2/FidelFattiPiano.wav
:/Resources/sounds/stabs2/HappyHardcoreStab.wav
:/Resources/sounds/stabs2/Hit House 2.wav
:/Resources/sounds/stabs2/Landlord 2.wav
:/Resources/sounds/stabs2/Poltergeist.wav
:/Resources/sounds/stabs2/Powerrr.wav
:/Resources/sounds/stabs2/Rave Action.wav
:/Resources/sounds/stabs2/RedTwo.wav
:/Resources/sounds/stabs2/Short Rave.wav
:/Resources/sounds/stabs2/SpeedSoul.wav
:/Resources/sounds/stabs2/Sweat.wav
:/Resources/sounds/stabs2/Take On Higher.wav
:/Resources/sounds/stabs2/Toxic Two.wav
:/Resources/sounds/stabs2/Wave Of Future.wav
:/Resources/sounds/stabs2/Wild Child1.wav
:/Resources/sounds/stabs2/Wild Child2.wav
:/Resources/sounds/stabs2/Wild Child3.wav
:/Resources/sounds/stabs2/Zentral.wav
:/Resources/sounds/stabs3/Belgian Rave.wav
:/Resources/sounds/stabs3/Break Boys.wav
:/Resources/sounds/stabs3/CLS.wav
:/Resources/sounds/stabs3/Cool Stab.wav
:/Resources/sounds/stabs3/CZorgan Alex Party.wav
:/Resources/sounds/stabs3/Distorgan2.wav
:/Resources/sounds/stabs3/Expansion.wav
:/Resources/sounds/stabs3/Gabbers.wav
:/Resources/sounds/stabs3/Hardcore Hoover.wav
:/Resources/sounds/stabs3/Hound Stab.wav
:/Resources/sounds/stabs3/House Nation.wav
:/Resources/sounds/stabs3/Magic Feet.wav
:/Resources/sounds/stabs3/Mayday.wav
:/Resources/sounds/stabs3/Organonox.wav
:/Resources/sounds/stabs3/Party Children.wav
:/Resources/sounds/stabs3/Piano Chord.wav
:/Resources/sounds/stabs3/Rotterdam Hoover.wav
:/Resources/sounds/stabs3/SL2.wav
:/Resources/sounds/stabs3/Synth15 Stab.wav
:/Resources/sounds/stabs3/Techno8.wav
:/Resources/sounds/stabs3/Tremoradelterra.wav
:/Resources/sounds/stabs3/Twilight Bep.wav
:/Resources/sounds/stabs3/Wavez.wav
:/Resources/sounds/stabs3/Week End.wav
:/Resources/sounds/voices/Acieed.wav
:/Resources/sounds/voices/Ayeaaaa First Choice.wav
:/Resources/sounds/voices/Charly.wav
:/Resources/sounds/voices/Dish you.wav
:/Resources/sounds/voices/Energize.wav
:/Resources/sounds/voices/Fast.wav
:/Resources/sounds/voices/Go 2.wav
:/Resources/sounds/voices/Godftaher The Joint.wav
:/Resources/sounds/voices/jieeeeeeeaaaaaaaah.wav
:/Resources/sounds/voices/Kick Out The Jam.wav
:/Resources/sounds/voices/Loon.wav
:/Resources/sounds/voices/Neneh Cherry.wav
:/Resources/sounds/voices/ooooooooh2.wav
:/Resources/sounds/voices/ooooooooh.wav
:/Resources/sounds/voices/Party.wav
:/Resources/sounds/voices/Patti.wav
:/Resources/sounds/voices/Paw.wav