PPG Wave - making sounds and samples
In my most recent post I’ve shown how to extract PPG’s waveforms and wavetable data from its EPROM dumps. Since some people seem to be genuinely interested in what I’m doing here, I’ve decided to post more. I hope you like it this time too :)
Loading the wavetables
First things first - to be able to make cool sounds, we need to load the wavetables before.
You may remember that the wavetables are stored in a sparse (slot number, wave number) format - it’s great because it saves space, but unfortunately makes it harder to interpolate between waveforms.
I’m not going to generate a huge array with all possible waveforms - that would waste too much space (~4kB), effectively making it impossible to run the code on embedded devices with very limited RAM resources. Instead, I’m only going to generate the ‘missing’ slots of the wavetable. This way, all the interpolation coefficients are precalculated during loading and later taken into account during playback.
The wavetable is going to be unpacked into an array of size 61 (we won’t need last three waveforms). Each slot will contain indices of waveforms that are going to be interpolated and an interpolation factor.
Speaking in C:
struct wavetable_entry
{
const uint8_t *ptr_l;
const uint8_t *ptr_r;
float factor;
uint8_t is_key;
};
The member variable is_key
determines whether the waveform in this slot is ‘pure’ or a result of interpolation between two other waveforms. The PPG EPROM stores a list of those ‘pure’ waveforms and their positions in the array.
There are also two pointers - ptr_l
and ptr_r
which obviously point to the waveform data. ptr_l
and ptr_r
point to waveform data of the nearest key slots on the left and right respectively.
factor
(0;1) determines balance between left (0) and right (1) waveforms.
Now is the time for some real code - the load_wavetable
function that is going to unpack the data
stored in the PPG EPROM into an array of wavetable_entry
structs.
const uint8_t *load_wavetable( struct wavetable_entry *entries, unsigned int wavetable_size, const uint8_t *data )
{
// Wipe the wavetable
memset( entries, 0, wavetable_size * sizeof( struct wavetable_entry ) );
// The fist byte is ignored
data++;
// Read wavetable entries up to size - 1
unsigned int waveform, pos;
do
{
waveform = *data++;
pos = *data++;
entries[pos].ptr_l = get_waveform_pointer( waveform );
entries[pos].ptr_r = NULL;
entries[pos].factor = 0;
entries[pos].is_key = 1;
}
while ( pos < wavetable_size - 1 );
// Now, generate interpolation coefficients
const struct wavetable_entry *el = NULL, *er = NULL;
for ( unsigned int i = 0; i < wavetable_size; i++ )
{
// If the current entry contains a key-wave
if ( entries[i].is_key )
{
// Write both pointers in case the right key waveform is never found
el = er = &entries[i];
// Look for the next key-wave
for ( unsigned int j = i + 1; j < wavetable_size; j++ )
{
if ( entries[j].is_key )
{
er = &entries[j];
break;
}
}
}
// Total distance between known key waves and distance from the left one
int distance_total = er - el;
int distance_l = &entries[i] - el;
entries[i].ptr_l = el->ptr_l;
entries[i].ptr_r = er->ptr_l;
// Avoid division by 0 for the last slot
entries[i].factor = distance_total ? (float) distance_l / distance_total : 0.0f;
}
// Return pointer to the next wavetable
return data;
}
Unpacking the wavetable data is rather straightforward and consists of looping through the data and writing proper fields in the entries
array. Most of the code is actually responsible for generating interpolation factors.
Each interpolation factor is determined based on the distance from the the nearest left (el
) and right (er
) key waves.
Those pointers are updated each time a key wave is encountered - right wave becomes the left one, and a linear search is performed to find nearest key wave on the right.
The get_waveform_pointer(n)
function is essentially a fancy wrapper that returns a pointer to the n-th waveform. It’s here just to emphasize the fact that the pointer should be passed to get_waveform_sample()
function introduced later on. These two are seemingly useless, but you might want to be able to modify the code easily if the data is stored in a place that requires some special way of accessing it (such as pgm_read_data()
for AVR).
As you may have noticed, load_wavetable()
returns a pointer to the next wavetable found. Wavetables can differ in size, so we can’t just iterate over the EPROM data with fixed increments, hoping that we always end up on begining of a wavetable entry. The most straightforward way of getting N-th table is simply to call the load_wavetable
repeateadly, as follows:
const uint8_t *load_wavetable_n( struct wavetable_entry *entries, unsigned int wavetable_size, const uint8_t *data, unsigned int index )
{
for ( unsigned int i = 0; i < index + 1; i++ )
data = load_wavetable( entries, wavetable_size, data );
return data;
}
Now, let’s actually load a wavetable from the EPROM data. The PPG’s wavetables theoretically contain 64 waves, but the last 3 ones are generated by the synthesizer. We won’t need those, so 61 wavetable entries are enough:
struct wavetable_entry current_wavetable[61];
load_wavetable_n( ¤t_wavetable, 61, ppg_wavetable, 18 );
You might be rightly wondering ‘What is ppg_wavetable
?’. It’s simply first 768 bytes from the PPG EPROM, stored in a C array. You download it here: ppg_data.c, ppg_data.h.
Making noise
It’s time to start making some sounds. Here, we’re going to be using float
variables for all audio-related calculations.
If you want to run this code on something that does not support floating-point arithemtic, please see this version of the code, which is based on 16-bit fixed-point arithmetic.
Let’s start with the wrapper function I mentioned earlier. Aside from simply indexing the array containing the waveform, it also converts sample values from range (0; 255) to values in range (-1; 1):
static inline float get_waveform_sample( const uint8_t *ptr, uint8_t sample )
{
return ( ptr[sample] - 128 ) / 128.f;
}
In another layer of abstraction, we take care of waveform mirroring and map floating-point phase values to sample numbers:
static inline float get_waveform_sample_by_phase( const uint8_t *ptr, float phase )
{
// phase [0; 0.5) ==> samples [0; 63)
// phase [0.5; 1) ==> samples [63;0) (inverted)
if ( phase < 0.5f )
return get_waveform_sample( ptr, phase * 2 * 64 );
else
return -get_waveform_sample( ptr, 63 - ( phase - 0.5f ) * 2 * 64 );
}
The following function returns samples from a waveform described by a wavetable slot. We get two samples - from the nearest key waveforms and perform linear interpolation based on the interpolation factor stored in the slot.
static inline float get_wavetable_sample( const struct wavetable_entry *e, float phase )
{
float sample_l = get_waveform_sample_by_phase( e->ptr_l, phase );
float sample_r = get_waveform_sample_by_phase( e->ptr_r, phase );
float t = e->factor;
// Perform linear interpolation
return ( 1.f - t ) * sample_l + t * sample_r;
}
And that’s it! Now we only need to output the samples, so we can hear it somehow.
I used a very handy program called aplay
and this code:
while ( 1 )
{
// Phasor
static float phase = 0;
float f = 110.f;
float phase_step = f / SAMPLING_FREQ;
if ( phase > 1.f ) phase -= 1.f;
phase += phase_step;
// Time counter
static uint32_t cnt = 0;
static float t = 0;
cnt++;
t = (float)cnt / SAMPLING_FREQ;
// Waveform generation and wavetable sweep
float sample = get_current_wavetable_sample( 30 + 30 * sin( t ), phase );
// Audio output
putchar( 128 + sample * 127 );
}
You can find the entire code here.
Run ./ppg_aplay | aplay
and you should hear a PPG-style waveform sweep. I think it sounds right!
As a side note: When you change waveforms and want to avoid clicking artifacts, make sure you make the switch on a signal zero-crossing (phase values 0 and 0.5).
Exporting WAV files
Let’s face it - playing sounds through aplay
isn’t really that useful… Most of you probably came here for sample files I haven’t provided the last time. Luckily, playing stuff with aplay
is literally one step away from having WAV files.
I quickly wrote a tiny utility - mkwav
. It basically dumps binary data from stdin
into a file, but prepends a proper WAV file header, making it playable binary data.
It’s available in my misc repository.
Then, I modified the playback code, to output a linear sweep over all waveforms from requested wavetable, each one repeated requested number of times (for controlling output file duration).
After all that, I used Bash to glue all the pieces together and save myself from typing the same thing 29 times:
SAMPLERATE=8000
for n in {0..28}; do
./ppg_wt_dump $n 1 | ./mkwav "wav/${n}.wav" $SAMPLERATE
done;
And we’re done! Hear it for yourself.
All samples and code for exporting them is here.
Summary
I hope you enjoyed reading and that you’re happy with the results! Thanks for coming here :)
TL;DR, give me the links:
- PPG wavetables as WAV files
- Code for playing wavetables with
aplay
- Code for dumping wavetable sweeps
- Modified code without floats
- PPG EPROM data as C arrays
mkwav
utility- The previous post