Close

Audio Synthesis Engine Implementation - Part 2

A project log for Vintage Toy Synthesiser

A wooden toy piano converted into a standalone digital synthesiser.

liam-laceyLiam Lacey 09/02/2018 at 20:350 Comments

(Original post date: 20/03/16)

Just over a month ago I posted about the implementation of the audio synthesis engine for the vintage toy synthesiser, however since then I've got the synths front panel developed and fully working which has allowed me to rapidly complete the main features of the synth. Here I'm going to follow on from that blogpost and talk about the final few features I've implemented since then, however it's worth mentioning that there are still a few refinements I need to make before I can settle on a final implementation of the brain and sound engine software for the synth, which I'll probably cover in a future log.

Voice Mode and Voice Allocation

The Voice Mode parameter on the synth sets whether the device is in polyphonic mode or monophonic mode. Here I'm going to cover how I've implemented both poly and mono mode in the vintage toy synth, which is implemented within the vintageBrain application on the synth.

Polyphonic Mode

Poly mode is implemented using an array that stores an ordered-list of 'free' voices - voices that are not currently playing a note. The number at the beginning of the list always represents the next available free voice. I've also implemented 'last note' voice stealing, so that if attempting to play a note when there are no free voices left it will 'steal' the voice that is playing the last played note.

This is how poly mode works when a note-on message is received:

  1. The next available free voice is pulled out of the first index of the 'free voice' array
  2. If the voice number from point 1 is a valid voice number (1 or above):
    1. The 'free voice' array is rearranged so that all numbers are shuffled forward by 1 (removing the next available free voice), and a '0' (representing 'no free voice') is added to the end of the array. This puts the following free voice for the next note-on message at the beginning of the array.
    2. The note number of the note message is stored in an array of 'voice notes', which signifies what note each voice is currently playing.
    3. The voice number is stored as the last used voice (for the note stealing implementation).
    4. The voice number is used to set the MIDI channel of the MIDI note-on message that is sent to the voice engine, triggering that particular voice to play a note.
  3. If the voice number from point 1 invalid voice number (0), meaning there are no free voices left:
    1. The last used voice number is set as the voice to use
    2. A MIDI note-off message is sent to the stolen voice so that when sending the new note-on it enters the attack phase of the note
    3. The note number of the note-on message is stored in an array of 'voice notes', which signifies what note each voice is currently playing.
    4. The voice number is used to set the MIDI channel of the MIDI note-on message that is sent to the voice engine, triggering that particular voice to play a note.

When a note-off message is received:

  1. A search for the note number in the 'voice notes' array is done
  2. If the note number is found in the 'voice notes' array, the index of the number is used to signify the voice number that is currently playing the note
  3. The voice number is put back into the 'free voice' array, replacing the first instance of '0' found at the end of the array
  4. The index of the 'voice notes' array that represents this voice is set to -1 to signify that this voice is no longer playing a note
  5. The voice number is used to set the MIDI channel of the MIDI note-off message that is sent to the voice engine, triggering that particular voice to stop playing a note.

Monophonic Mode

Surprisingly, the mono mode implementation is just as complex as poly mode even though it only ever uses the first voice. This is because we need to store a 'stack' of notes that represent all the keys that are currently being held down, so that if a key is released whilst there are still keys being held down the played note is changed to the previously played key rather than just turning the note off. This is the expected behaviour of a monophonic voice mode within a synthesiser.

This is how mono mode works when a note-on message is received:

  1. The note number is added to the 'mono stack' array, at an index that represents the number of keys currently being held down (if this is the first pressed key it will be the 1st index, if there is already one key being held down it will be 2nd index, and so on).
  2. A 'stack pointer' variable is incremented by 1 to signify that a note has been added to the 'mono stack' array
  3. The note number is sent to voice 0 of the sound engine in the form of a MIDI note-on message

When a note-off is received:

  1. A search for the note number in the 'mono stack' array is done
  2. If the note number is found in the 'mono stack' array, the note is removed by shuffling forward all elements of the array above it by 1
  3. The 'stack pointer' variable is decremented by 1 to signify that a note has been removed from the 'mono stack' array
  4. If there is still at least 1 note in the 'mono stack' array, signified by the value of the 'stack pointer' variable, a MIDI note-on message is sent to voice 0 of the sound engine using the note number at the top of the mono stack, changing the playing note.
  5. If there is no notes left in the 'mono stack' array, a MIDI note-off message is sent to voice 0 of the sound engine, stopping the playing note.

Below is the current code that handles both poly and mono voice/note allocation, however for an up-to-date version of the code see the vintageBrain.c file in the projects Github repo.

//====================================================================================
//====================================================================================
//====================================================================================
//Gets the next free voice (the oldest played voice) from the voice_alloc_data.free_voices buffer,
//or steals the oldest voice if not voices are currently free.

uint8_t GetNextFreeVoice (VoiceAllocData *voice_alloc_data)
{
    uint8_t free_voice = 0;
    
    //get the next free voice number from first index of the free_voices array
    free_voice = voice_alloc_data->free_voices[0];
    
    //if got a free voice
    if (free_voice != 0)
    {
        //shift all voices forwards, removing the first value, and adding 0 on the end...
        
        for (uint8_t voice = 0; voice < NUM_OF_VOICES - 1; voice++)
        {
            voice_alloc_data->free_voices[voice] = voice_alloc_data->free_voices[voice + 1];
        }
        
        voice_alloc_data->free_voices[NUM_OF_VOICES - 1] = 0;
        
    } //if (free_voice != 0)
    
    else
    {
        //use the oldest voice
        free_voice = voice_alloc_data->last_voice;
        
        //TODO: Send a note-off message to the stolen voice so that when
        //sending the new note-on it enters the attack phase....
        
    } //else ((free_voice != 0))
    
    return free_voice;
}

//====================================================================================
//====================================================================================
//====================================================================================
//Adds a new free voice to the voice_alloc_data.free_voices buffer

uint8_t FreeVoiceOfNote (uint8_t note_num, VoiceAllocData *voice_alloc_data)
{
    //first, find which voice note_num is currently being played on
    
    uint8_t free_voice = 0;
    
    for (uint8_t voice = 0; voice < NUM_OF_VOICES; voice++)
    {
        if (note_num == voice_alloc_data->note_data[voice].note_num)
        {
            free_voice = voice + 1;
            break;
        }
        
    } //for (uint8_t voice = 0; voice < NUM_OF_VOICES; voice++)
    
    
    //if we have a voice to free up
    if (free_voice > 0)
    {
        //find space in voice buffer
        
        for (uint8_t voice = 0; voice < NUM_OF_VOICES; voice++)
        {
            //if we find zero put the voice in that place
            if (voice_alloc_data->free_voices[voice] == 0)
            {
                voice_alloc_data->free_voices[voice] = free_voice;
                break;
            }
            
        } //for (uint8_t voice = 0; voice < NUM_OF_VOICES; voice++)
        
    } //if (free_voice > 0)
    
    return free_voice;
}

//====================================================================================
//====================================================================================
//====================================================================================
//Returns a list of voices that are currently playing note note_num (using the voice_list array)
//as well as returning the number of voices.
//Even though at the moment it will probably only ever be 1 voice here, I'm implementing
//it to be able to return multiple voices incase in the future I allow the same note
//to play multiple voices.

uint8_t GetVoicesOfNote (uint8_t note_num, VoiceAllocData *voice_alloc_data, uint8_t voice_list[])
{
    uint8_t num_of_voices = 0;
    
    for (uint8_t voice = 0; voice < NUM_OF_VOICES; voice++)
    {
        if (note_num == voice_alloc_data->note_data[voice].note_num)
        {
            voice_list[num_of_voices] = voice + 1;
            num_of_voices++;
        }
        
    } //for (uint8_t voice = 0; voice < NUM_OF_VOICES; voice++)
    
    return num_of_voices;
}

//====================================================================================
//====================================================================================
//====================================================================================
///Removes a note from the mono stack by shuffling a set of notes down

void RemoveNoteFromMonoStack (uint8_t start_index, uint8_t end_index, VoiceAllocData *voice_alloc_data)
{
    //shuffle the notes in the stack down to remove the note
    for (uint8_t index = start_index; index < end_index; index++)
    {
        voice_alloc_data->note_data[index].note_num = voice_alloc_data->note_data[index + 1].note_num;
    }
    
    //set top of stack to empty
    voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].note_num = VOICE_NO_NOTE;
    voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].note_vel = VOICE_NO_NOTE;
    
    //set internal keyboard note stuff
    voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].keyboard_key_num = VOICE_NO_NOTE;
    
    //decrement pointer if above 0
    if (voice_alloc_data->mono_note_stack_pointer)
    {
        voice_alloc_data->mono_note_stack_pointer--;
    }
}

//====================================================================================
//====================================================================================
//====================================================================================
//Adds a note to mono mode stack

void AddNoteToMonoStack (uint8_t note_num, uint8_t note_vel, VoiceAllocData *voice_alloc_data, bool from_internal_keyboard, uint8_t keyboard_key_num)
{
    //add note to the top of the stack
    voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].note_num = note_num;
    voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].note_vel = note_vel;
    
    //set internal keyboard note stuff
    voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].from_internal_keyboard = from_internal_keyboard;
    if (from_internal_keyboard)
        voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer].keyboard_key_num = keyboard_key_num;
    
    //increase stack pointer
    voice_alloc_data->mono_note_stack_pointer++;
    
    //if the stack is full
    if (voice_alloc_data->mono_note_stack_pointer >= VOICE_MONO_BUFFER_SIZE)
    {
        //remove the oldest note from the stack
        RemoveNoteFromMonoStack (0, VOICE_MONO_BUFFER_SIZE, voice_alloc_data);
    }
}

//====================================================================================
//====================================================================================
//====================================================================================
//Pulls a note from the mono stack

void PullNoteFromMonoStack (uint8_t note_num, VoiceAllocData *voice_alloc_data)
{
    uint8_t note_index;
    
    //find the note in the stack buffer
    for (uint8_t i = 0; i < voice_alloc_data->mono_note_stack_pointer; i++)
    {
        //if it matches
        if (voice_alloc_data->note_data[i].note_num == note_num)
        {
            //store index
            note_index = i;
            
            //break from loop
            break;
        }
        
    } //if (uint8_t i = 0; i < voice_alloc_data->mono_note_stack_pointer; i++)
    
    //remove the note from the stack
    RemoveNoteFromMonoStack (note_index, voice_alloc_data->mono_note_stack_pointer, voice_alloc_data);
}

//====================================================================================
//====================================================================================
//====================================================================================
//Processes a note message recieived from any source, sending it to the needed places

void ProcessNoteMessage (uint8_t message_buffer[],
                         PatchParameterData patch_param_data[],
                         VoiceAllocData *voice_alloc_data,
                         bool send_to_midi_out,
                         int sock,
                         struct sockaddr_un sound_engine_sock_addr,
                         bool from_internal_keyboard,
                         uint8_t keyboard_key_num)
{
    
    //====================================================================================
    //Voice allocation for sound engine
    
    //FIXME: it is kind of confusing how in mono mode the seperate functions handle the setting
    //of voice_alloc_data, however in poly mode all of that is done within this function. It
    //may be a good idea to rewrite the voice allocation stuff to make this neater.
    
    //=========================================
    //if a note-on message
    if ((message_buffer[0] & MIDI_STATUS_BITS) == MIDI_NOTEON)
    {
        //====================
        //if in poly mode
        if (patch_param_data[PARAM_VOICE_MODE].user_val > 0)
        {
            //get next voice we can use
            uint8_t free_voice = GetNextFreeVoice (voice_alloc_data);
            
            #ifdef DEBUG
            printf ("[VB] Next free voice: %d\r\n", free_voice);
            #endif
            
            //if we have a voice to use
            if (free_voice > 0)
            {
                //put free_voice into the correct range
                free_voice -= 1;
                
                //store the note info for this voice
                voice_alloc_data->note_data[free_voice].note_num = message_buffer[1];
                voice_alloc_data->note_data[free_voice].note_vel = message_buffer[2];
                
                //set the last played voice (for note stealing)
                voice_alloc_data->last_voice = free_voice + 1;
                
                //set internal keyboard note stuff
                voice_alloc_data->note_data[free_voice].from_internal_keyboard = from_internal_keyboard;
                if (from_internal_keyboard)
                    voice_alloc_data->note_data[free_voice].keyboard_key_num = keyboard_key_num;
                
                //Send to the sound engine...
                
                uint8_t note_buffer[3] = {MIDI_NOTEON + free_voice, message_buffer[1], message_buffer[2]};
                SendToSoundEngine (note_buffer, 3, sock, sound_engine_sock_addr);
                
            } //if (free_voice > 0)
            
        } //if (patch_param_data[PARAM_VOICE_MODE].user_val > 0)
        
        //====================
        //if in mono mode
        else
        {
            AddNoteToMonoStack (message_buffer[1], message_buffer[2], voice_alloc_data, from_internal_keyboard, keyboard_key_num);
            
            //Send to the sound engine for voice 0...
            uint8_t note_buffer[3] = {MIDI_NOTEON, message_buffer[1], message_buffer[2]};
            SendToSoundEngine (note_buffer, 3, sock, sound_engine_sock_addr);
            
        } //else (mono mode)
        
    } //((message_buffer[0] & MIDI_STATUS_BITS) == MIDI_NOTEON)
    
    //=========================================
    //if a note-off message
    else
    {
        //====================
        //if in poly mode
        if (patch_param_data[PARAM_VOICE_MODE].user_val > 0)
        {
            //free used voice of this note
            uint8_t freed_voice = FreeVoiceOfNote (message_buffer[1], voice_alloc_data);
            
            #ifdef DEBUG
            printf ("[VB] freed voice: %d\r\n", freed_voice);
            #endif
            
            //if we sucessfully freed a voice
            if (freed_voice > 0)
            {
                //put freed_voice into the correct range
                freed_voice -= 1;
                
                //reset the note info for this voice
                voice_alloc_data->note_data[freed_voice].note_num = VOICE_NO_NOTE;
                voice_alloc_data->note_data[freed_voice].note_vel = VOICE_NO_NOTE;
                voice_alloc_data->note_data[freed_voice].keyboard_key_num = VOICE_NO_NOTE;
                
                //Send to the sound engine...
                
                uint8_t note_buffer[3] = {MIDI_NOTEOFF + freed_voice, message_buffer[1], message_buffer[2]};
                SendToSoundEngine (note_buffer, 3, sock, sound_engine_sock_addr);
                
            } //if (freed_voice > 0)
            
        } //if (patch_param_data[PARAM_VOICE_MODE].user_val > 0)
        
        //====================
        //if in mono mode
        else
        {
            PullNoteFromMonoStack (message_buffer[1], voice_alloc_data);
            
            //if there is still atleast one note on the stack
            if (voice_alloc_data->mono_note_stack_pointer != 0)
            {
                //Send a note-on message to the sound engine with the previous note on the stack...
                
                uint8_t note_num = voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer - 1].note_num;
                uint8_t note_vel = voice_alloc_data->note_data[voice_alloc_data->mono_note_stack_pointer - 1].note_vel;
                
                uint8_t note_buffer[3] = {MIDI_NOTEON, note_num, note_vel};
                SendToSoundEngine (note_buffer, 3, sock, sound_engine_sock_addr);
                
            } //if (prev_stack_note != VOICE_NO_NOTE)
            
            //if this was the last note in the stack
            else
            {
                //Send to the sound engine as a note off...
                
                uint8_t note_buffer[3] = {MIDI_NOTEOFF, message_buffer[1], message_buffer[2]};
                SendToSoundEngine (note_buffer, 3, sock, sound_engine_sock_addr);
            }
            
        } //else (mono mode)
        
    } //else (note-off message)
    
    //====================================================================================
    //Sending to MIDI-out
    
    //Send to MIDI out if needed
    if (send_to_midi_out)
    {
        WriteToMidiOutFd (message_buffer, 3);
    }
}

Keyboard Parameters

There are three keyboard parameters on the vintage toy synth that generate and control what notes the keyboard plays - scale, octave, and transpose; another set of parameters that are implemented within the vintageBrain application on the synth. The keyboard on the synth sends key/note messages to the brain application using MIDI note messages, using note numbers 0-17 to signify the key number that has been pressed/released, therefore it is up to these parameters to convert these key numbers into meaningful and audible note numbers.

Scale

Scale controls what particular musical scale is played by the keyboard, and at the moment I have included a selection of 8 scale - chromatic, major, major pentatonic, minor, minor pentatonic, melodic minor, harmonic minor, and blues. This has been implemented fairly simply by putting each scale into its own array in the form of semitones starting from 0, and using the key number coming from the keyboard to select a note/semitone value from the array:

//apply scale value  
//Note numbers come from the keyboard in the range of 0 - KEYBOARD_NUM_OF_KEYS-1,  
//and are used to select an index of keyboardScales[patchParameterData[PARAM_KEYS_SCALE].user_val]  
note_num = keyboardScales[patch_param_data[PARAM_KEYS_SCALE].user_val][keyboard_key_num];  

Octave

Octave controls the musical octave that the keyboard scale is offset by, where an octave value of 0 sets the bottom key on the keyboard to play middle E (MIDI note 64), with greater octave value adding 12 semitones each time, or lower octave values reducing the notes by 12 semitones each time:

//apply octave value
//if octave value is 64 (0) bottom key is note 64 (middle E, as E is the first key)
note_num = (note_num + 64) + ((patch_param_data[PARAM_KEYS_OCTAVE].user_val - 64) * 12);

Transpose

Transpose controls a singular semitone offset applied to the note number, and allows the bottom key on the keyboard to be any musical note rather than just E:

//apply tranpose
//a value of 64 (0) means no transpose
note_num += patch_param_data[PARAM_KEYS_TRANSPOSE].user_val - 64;

Global Volume

The global volume parameter is a boring yet essential control that needs to be included, and I have implemented this to control the main volume of the Linux OS soundcard driver I am using on the BBB. As I am using the ALSA soundcard driver for audio output, I need to use the amixer command-line application to do this, using the sset command:

//set the Linux system volume...
        
//create start of amixer command to set 'Speaker' control value
//See http://linux.die.net/man/1/amixer for more options
uint8_t volume_cmd[64] = {"amixer -q sset Speaker "};
        
//turn the param value into a percentage string
uint8_t volume_string[16];
sprintf(volume_string, "%d%%", param_val);
        
//append the value string onto the command
strcat (volume_cmd, volume_string);
        
//Send the command to the system
system (volume_cmd);

Vintage Amount

The idea of the Vintage Amount parameter is to allow the synth to model old or even broken analogue synthesiser voices, however as this is an uncommon setting found on commercial synthesisers there is no set functionality for this parameter. The most obvious behaviour for this parameter, and the way it currently works, is that it randomly modifies the pitch of each voice when a new note is played, with a greater amount value creating larger pitch offsets:

//============================
//Set 'vintage amount' pitch offset
int16_t vintage_pitch_offset = 0;
        
//if there is a vintage value
if (patchParameterData[PARAM_GLOBAL_VINTAGE_AMOUNT].voice_val != 0)
{
    //get a random pitch value using the vintage amount as the max possible value
    vintage_pitch_offset = rand() % (int)patchParameterData[PARAM_GLOBAL_VINTAGE_AMOUNT].voice_val;
    //offset the random pitch value so that the offset could be negative
    vintage_pitch_offset -= patchParameterData[PARAM_GLOBAL_VINTAGE_AMOUNT].voice_val / 2;
            
    //FIXME: the above algorithm will make lower notes sound less out of tune than higher notes - fix this.
            
} //if (patchParameterData[PARAM_GLOBAL_VINTAGE_AMOUNT].voice_val != 0)

However the more and more I play with the current implementation, the more I realise that adding random pitch offsets to each voice isn't very musically useful, especially when using a large amount value.

Therefore I'm probably going to experiment with other behaviours for this parameter that are potentially more musical useful before I settle on a final implementation for this, such as:

Discussions