Finally, we are at the end of this series, where I am going to give an overview of the software behind all the menu system and of course, all the handling of user inputs and sending of MIDI messages. We’ve spent so much time configuring the various peripherals and writing driver code to abstract away the nitty-gritty implementation stuff, this is where it all comes together!

For the project files, do check out the Github repo.

To put it simply, there are 3 files which are relevant for this section, which are main.c , User_Params.h and Menu.h .

User_Params.h has all of the parameters/options that can be configured by the user via the menu system. Menu.h contains all the menu text strings, and pointers to all the corresponding user parameter variables.

I will mainly be breaking down main.c here, since that’s where all the stuff happens.

Global Variables – just so that we’re all on the same page

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
volatile uint8_t brightness[4] = {1,1,1,1}; //initialize array holding brightness values for the 4 indicator LED's
volatile uint8_t BAMIndex = 0; //which step are we at in the BAM driver
volatile uint8_t blocked = 0; //time critical BAM code running, do not disable interrupts


volatile uint8_t currentEncoder = 0; //which encoder are we polling right now?
volatile uint8_t lastEncoder[5] = {0,0,0,0,0};//initialize array containing past encoder readoff
volatile int encoderValues[5] = {0,0,0,0,0};//initialize array containing encoder values
volatile int8_t encoderLUT[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
int lastEncoderValues[5] = {0, 0, 0, 0, 0};

uint8_t lastButtonState = 0; //menu encoder's button


uint32_t lastKeyMatrix = 0; //last state of the key matrix
uint32_t currentKeyMatrix = 0; //current state of the key matrix

int8_t MidiCCValues[128]; //values for all 128 MIDI CC channels

int adcSmooth[3]; //adc data after its passed thru the IIR filter
int lastFaderValues[3] = {0, 0, 0};


char LCDQueueTop[17];
char LCDQueueBottom[17]; //unused
uint8_t LCDTopQueued; //does the LCD have to be updated this round?
uint8_t LCDBottomQueued;

//menu button debounce.
#define MAXIMUM  15
int8_t dIntegrator = 0;
int8_t dOutput = 0;

Initialization

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//read in all user parameters from the EEPROM
I2C2->CR1 |= (1<<8); //send start condition

while ((I2C2->SR1 & 1) == 0); //clear SB
I2C2->DR = 0xA0; //address the EEPROM in write mode
while ((I2C2->SR1 & (1<<1)) == 0); //wait for ADDR flag
while ((I2C2->SR2 & (1<<2)) == 0); //read I2C SR2
while ((I2C2->SR1 & (1<<7)) == 0); //make sure TxE is 1
I2C2->DR = 0x0; //address
while ((I2C2->SR1 & (1<<7)) == 0); //make sure TxE is 1
I2C2->DR = 0x0; //address
while ((I2C2->SR1 & (1<<7)) == 0); //make sure TxE is 1
while ((I2C2->SR1 & (1<<2)) == 0); //make sure BTF is 1
I2C2->CR1 |= 1<<10;
I2C2->CR1 |= (1<<8); //send start condition
while ((I2C2->SR1 & 1) == 0); //clear SB
I2C2->DR = 0xA1; //address the EEPROM in read mode
while ((I2C2->SR1 & (1<<1)) == 0); //wait for ADDR flag
while ((I2C2->SR2) & 0); //read I2C SR2
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiNoteOffset = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiNoteVelo = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiChannel = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCFaderLUT[0] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCFaderLUT[1] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCFaderLUT[2] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCEncoderLUT[0] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCEncoderLUT[1] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCEncoderLUT[2] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
MidiCCEncoderLUT[3] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderSpeed[0] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderSpeed[1] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderSpeed[2] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderSpeed[3] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderNote[0] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderNote[1] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderNote[2] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
EncoderNote[3] = (I2C2->DR) & 0xff;
while ((I2C2->SR1 & 1<<6) == 0); //wait for RXNE
I2C2->CR1 &= ~(1<<10); //NACK
I2C2->CR1 |= 1<<9; //STOP
filterBeta = I2C2->DR;

Customizability was one of the things that I really focused on in the development of this project, not so much because I anticipated that I would actually be changing loads of parameters when using this MIDI controller, but because products like the Ableton Pushes, Maschines, Komplete Kontrols, MIDI Fighters seemed so nerdy, so flexible, so beautiful thanks to their very well written and tightly integrated software that I get really excited every time I see someone use them on YouTube. When I was just starting to explore the world of orchestral programming, it seemed like there were just a million things you could change on these devices to tailor them to your specific workflow. At the very least, I wanted a rudimentary version of that.

Plus, I have never actually programmed a menu interface that allows a user to change settings before, and this seemed like the perfect chance to give it a go.

So before anything else, we’ve got to read back all the parameters, which are stored in EEPROM every time you change them. This retains changes you’ve made across power cycles, because it would just be crazy if you’d have to reprogram everything every single time you plug the controller in.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
IWDG->KR = 0xAAAA; //reset the watchdog timer                  
blocked = 0;                                                   
I2C2->CR1 |= 1; //enable i2c 2 peripheral for LCD and EEPROM   
I2C1->CR1 |= 1; //enable i2c 1 peripheral for LED Matrix       
                                                               
LCDInit(LCD_Address);                                          
LEDMatrixInit(LEDMatrix_Address);                              
                                                               
LCDClear(LCD_Address);                                         
                                                               
LCDSetCursor(1, 1, LCD_Address);                               
                                                               
LCDPrepareInt();                              
                                                                                                                              
TIM2->CR1 |= 1; //enable BAM Driver                            
TIM3->CR1 |= 1; //enable encoder scan driver                   

Then, we can go ahead and enable all the relevant hardware peripherals. (The configuring of these peripherals has been taken care of by the IDE’s code generation tool. I’ve omitted them here)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//indicate where are all the note C's                                        
for(int8_t i = 0; i < 4; i++){                                               
  LEDMatrix[i] &= 0xf; //clear all the red channels                        
}                                                                            
                                                                             
for(int8_t i = 0; i < 16; i++){ //we have 16 keys                            
                                                                             
  int8_t row = 3-(i>>2);                                                   
  int8_t col = (i%4);                                                      
  if((MidiNoteOffset+i)%12 == 0){ //this key is a C                        
  LEDMatrix[row] |= (1<<(4+col));                                      
  }                                                                        
                                                                             
}                                                                            
                                                                          
for(int i = 0; i < 4; i++){ //function to drive the LED's                    
  LEDMatrixBuffer[i*4] = 0b1111; //clear all pins first to prevent ghosting
  LEDMatrixBuffer[i*4+1] = 0x00;                                           
  LEDMatrixBuffer[i*4+2] = ~(1<<i);                                        
  LEDMatrixBuffer[i*4+3] = LEDMatrix[i];                                   
}                                                                            
                                                                             
LEDMatrixStart(LEDMatrix_Address);                                           

The way I chose to utilize the LED Matrix is to use the red LEDs to indicate the start of each octave, indexed at the note C. That way, one can easily know the positions of the notes in the 4×4 grid. MidiNoteOffset is the MIDI note number of the bottom-leftmost key. In MIDI, notes are numbered sequentially up the scale, so 60 is C4 or middle C, 61 is C#4 and so on. The highlighted bit basically steps through every single switch, and sets the red LED at that position to be on if that particular note is a C.

The rest of the code is basically the straight from Part 3 on the LED Matrix. Then at the end, we call LEDMatrixStart to start the DMA and therefore enable the LED matrix.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
for(int i = 0; i < 4; i++){ //function to fill in the MidiNoteLut      
                                                                       
  for(int j = 0; j < 4; j++){                                        
                                                                       
  MidiNoteLUT[5*(3-i)+j+1] = MidiNoteOffset + (4*i+j); //math... 
                                                 
  }                                                                  
  MidiNoteLUT[5*i] = EncoderNote[i];                                 
                                                                       
}                                                                  

For the actual keys themselves, I decided to use the array MidiNoteLUT[]to store their MIDI note values so they can easily be recalled when it comes time to send over the MIDI messages. This bit basically fills in this LUT. For now, the encoder buttons also trigger notes, so they too share the LUT. The code is kinda messy since I didn’t really give much thought when I wired up the key matrix, but oh well, nothing a little math can’t fix…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
for(int i = 1; i < 4; i++){                
                                           
  int currentADC = ADC1ReadVal(i);       
  currentADC += ADC1ReadVal(i);          
  currentADC += ADC1ReadVal(i);          
  currentADC = currentADC/3;             
                                           
  lastFaderValues[i-1] = currentADC >> 5;
                       
}                                          

Then, we gotta fill in lastFaderValues[] with, well, the last fader values. Notice that the for loop starts with i = 1, this is because I actually broke the first fader, and so only had 3 left. I also did some averaging since things did get quite noisy. In the end I had to implement a simple IIR filter to prevent the ADC values from fluctuating too much. More on that later.

1
2
3
4
5
for(int i = 0; i < 5; i++){                 
                                            
  lastEncoderValues[i] = encoderValues[i];
                                            
}                                           

And finally, filling in lastEncoderValues[i].

The main loop

Here’s where things really heat up.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/* USER CODE BEGIN WHILE */                                
while (1)                                                  
{                                                          
  IWDG->KR = 0xAAAA; //reset the watchdog timer          
  flushMidi();                                           
  /* USER CODE END WHILE */                                
                                                           
  /* USER CODE BEGIN 3 */                                  
                                                           
  brightness[0] = MidiCCValues[MidiCCEncoderLUT[3]] << 1;
  brightness[1] = MidiCCValues[MidiCCEncoderLUT[2]] << 1;
  brightness[2] = MidiCCValues[MidiCCEncoderLUT[1]] << 1;
  brightness[3] = MidiCCValues[MidiCCEncoderLUT[0]] << 1;          

The way I chose to do the MIDI messages is basically to have the onboard USB act as a serial port, and use the ever popular Hairless MIDI to Serial Bridge to send incoming serial messages to an internal MIDI loopback (such as loopMIDI ). I initially naively sent the MIDI messages as soon as they were received. However, that proved to be an unwise idea, since the USB CDC driver doesn’t really like being bombarded with calls to send messages over USB, and multiple keypresses/releases (chords) didn’t work well at all.

So I basically implemented a FIFO buffer again, and every time the loop executes, it would flush the buffer out. Check out Part 2 for a primer on FIFO buffers, and Midi.c for the specific implementation.

Then, the code updates the four BAM dimmed LEDs to represent the current values of the CC channels assigned to each encoder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
uint8_t currentButtonState = ((GPIOB->IDR)&1);                              
                                                                         
/*                                                                          
 *                                                                          
 * debounce.c                                                               
written by Kenneth A. Kuhn                                               
version 1.00                                                             
 */                                                                         
                                                                            
/* Step 1: Update the integrator based on the input signal.  Note that the  
integrator follows the input, decreasing or increasing towards the limits as
determined by the input state (0 or 1). */                                  
                                                                            
if (currentButtonState == 0){ //button is currently depressed               
 if(dIntegrator < MAXIMUM){                                              
 dIntegrator++;                                                      
 }                                                                       
}                                                                           
else if(dIntegrator > 0){ //button is not depressed                         
 dIntegrator--;                                                          
}                                                                           
                                                                            
/* Step 2: Update the output state based on the integrator.  Note that the  
output will only change states if the integrator has reached a limit, either
                                                                            
0 or MAXIMUM. */                                                            
                                                                            
if(dIntegrator == 0){                                                       
 dOutput = 0;                                                            
}                                                                           
                                                                            
else if(dIntegrator >= MAXIMUM){                                            
 dOutput = 1;                                                            
 dIntegrator = MAXIMUM; /* defensive code if integrator got corrupted */ 
}                                                                           

Then, we read the current state of the menu navigation encoder’s button, and do some debouncing. I did not write this debounce bit, but instead got it from here .

The menu system

Initially, I wanted to use a tree based approach to implement the menu, where there will be nicely defined data structures for nodes which would represent each menu page. That would make adding new functionality to the menu system really elegant. But alas, I’m quite a noob when it comes to programming, and after trying really hard I just gave up and fell back to a pretty basic approach that ultimately got the job done.

Before moving on, have Menu.h open, otherwise none of this is going to make sense.

I chose to use a state machine approach to implement the menu, where each level in the menu hierarchy is a state. Depending on the current state, the code then knows what to do when it receives user input.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/*begin button handler*/
if (dOutput == 1 && lastButtonState == 0){ //button has been pressed

    switch (status){

    case Status:
        //we are now entering menu mode
        status = Menu;
        menuItemSelected = 0; //start from the first item
        snprintf(LCDQueueTop, 17, "\x7E%s", menuItems[menuItemSelected]);
        snprintf(LCDQueueBottom, 17, " %s", menuItems[menuItemSelected+1]); //normally you'd have to check if there is a next item available, but since this is only the menu init, we don't have to. (We will have to in the encoder-rotated handler)
        LCDTopQueued = 1; //signal that we need to update the LCD
        LCDBottomQueued = 1;
        break;

    case Menu:
        //we are already in our menu, time to enter whatever submenu is selected (or exit)
        if(menuItemSelected != MENU_SIZE){ //we have not selected "back", go into the selected SubMenu;
            status = SubMenu;
            subMenuSelected = menuItemSelected;
            parameterSelected = 0; //start afresh
            snprintf(LCDQueueTop, 17, "\x7E%s", (*(subMenus[subMenuSelected]+parameterSelected)));
            snprintf(LCDQueueBottom, 17, " %s", (*(subMenus[subMenuSelected]+parameterSelected+1)));
            LCDTopQueued = 1; //signal that we need to update the LCD
            LCDBottomQueued = 1;

        }

        else{ //exit menu back into status
            status = Status;
            snprintf(LCDQueueTop, 17, "                "); //clear LCD
            snprintf(LCDQueueBottom, 17, "                ");
            LCDTopQueued = 1; //signal that we need to update the LCD
            LCDBottomQueued = 1;
        }
        break;

    case SubMenu:

        if(parameterSelected != subMenuSizes[subMenuSelected]){//we have not selected "back", go into selected parameter page
            status = ParaSet;
            snprintf(LCDQueueTop, 17, "%s", (*(subMenus[subMenuSelected]+parameterSelected))); //print the current parameter on the top line
            if((subMenuSelected == 2) | (subMenuSelected == 3 && parameterSelected == 0)){ //if we are changing notenames
                snprintf(LCDQueueBottom, 17, "%s%d", noteNames[(*(*(parameters[subMenuSelected]+parameterSelected))) % 12], (int8_t)(((*(*(parameters[subMenuSelected]+parameterSelected))) / 12) - 1)); //print the NOTENAME of the parameter under question
            }
            else{
                snprintf(LCDQueueBottom, 17, "%d", (*(*(parameters[subMenuSelected]+parameterSelected)))); //print the current value of the parameter under question
            }
            LCDTopQueued = 1; //signal that we need to update the LCD
            LCDBottomQueued = 1;
        }

        else{ //exit sub menu back into main menu
            status = Menu;
            snprintf(LCDQueueTop, 17, "\x7E%s", menuItems[menuItemSelected]);
            snprintf(LCDQueueBottom, 17, " %s", menuItems[menuItemSelected+1]); //normally you'd have to check if there is a next item available, but since this is only the menu init, we don't have to. (We will have to in the encoder-rotated handler)
            LCDTopQueued = 1; //signal that we need to update the LCD
            LCDBottomQueued = 1;
        }
        break;

    case ParaSet: //save parameter and return to the SubMenu

        status = SubMenu;

        EEPROMWriteParameter(*(parameterEAddrs[subMenuSelected]+parameterSelected), *(*(parameters[subMenuSelected]+parameterSelected)));

        //if we were changing notenames
        if((subMenuSelected == 2) | (subMenuSelected == 3 && parameterSelected == 0)){ //update the note definitions

            for(int i = 0; i < 4; i++){ //function to fill in the MidiNoteLut

                for(int j = 0; j < 4; j++){

                    MidiNoteLUT[5*(3-i)+j+1] = MidiNoteOffset + (4*i+j); //math...


                }
                MidiNoteLUT[5*i] = EncoderNote[i];

            }
        }
        snprintf(LCDQueueTop, 17, "\x7E%s", (*(subMenus[subMenuSelected]+parameterSelected)));
        snprintf(LCDQueueBottom, 17, " %s", (*(subMenus[subMenuSelected]+parameterSelected+1)));
        LCDTopQueued = 1; //signal that we need to update the LCD
        LCDBottomQueued = 1;


        break;

    default:
        break;
    }
}
lastButtonState = dOutput;
/* end button handler */

So, on detecting that the menu navigation encoder’s button has been pressed, the code enters the state machine, with the current state stored in status. Then, based on the current state, the code advances to the next state and updates the LCD accordingly to let the user know where they are in the menu system. On power up, status is set to Status, where the LCD displays the last user input (on the key matrix or one of the CC controllers).

If ParaSet is the current state, that means that a parameter is currently being configured. So, apart from updating the LCD, if the user presses the button i.e. they are happy with whatever change they’ve made, that change is also saved to the relevant location in the EEPROM. Then, if the user changed what notes the key matrix triggers, we need to go ahead and update MidiNoteLUT too.

Parameter pages have the parameter name displayed on the top row of the LCD, and the current value of that parameter on the bottom row. However, I wanted note names to be displayed as note names, not some lame, unintuitive MIDI note number. Hence the whole kaabodle in the highlighted part.

In each level of the menu (except parameter pages), the last item always is a “back” option. Therefore, if the currently selected item is the last item, we reverse one step in the state machine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/* begin control encoder rotated handler */
if(((encoderValues[4] - lastEncoderValues[4]) >= 2) | ((lastEncoderValues[4] - encoderValues[4]) >= 2)){ //control encoder has been rotated

    int8_t increment = encoderValues[4]>lastEncoderValues[4] ? 1 : -1; //this control encoder is 2 counts per indent

    switch (status){

    case Menu:

        clampedIncrement(&menuItemSelected, increment, 0, MENU_SIZE);
        if(increment > 0 && menuItemSelected != 0){ //we advance in the menu, pointer should be in second row
            snprintf(LCDQueueTop, 17, " %s", menuItems[menuItemSelected-1]);
            snprintf(LCDQueueBottom, 17, "\x7E%s", menuItems[menuItemSelected]);
        }
        else if(menuItemSelected != MENU_SIZE){
            snprintf(LCDQueueTop, 17, "\x7E%s", menuItems[menuItemSelected]);
            snprintf(LCDQueueBottom, 17, " %s", menuItems[menuItemSelected+1]);
        }
        LCDTopQueued = 1; //signal that we need to update the LCD
        LCDBottomQueued = 1;
        break;

    case SubMenu:

        clampedIncrement(&parameterSelected, increment, 0, subMenuSizes[subMenuSelected]);
        if(increment > 0 && parameterSelected != 0){ //we advance in the menu, pointer should be in second row
            snprintf(LCDQueueTop, 17, " %s", (*(subMenus[subMenuSelected]+parameterSelected-1)));
            snprintf(LCDQueueBottom, 17, "\x7E%s", (*(subMenus[subMenuSelected]+parameterSelected)));
        }
        else if(parameterSelected != subMenuSizes[subMenuSelected]){
            snprintf(LCDQueueTop, 17, "\x7E%s", (*(subMenus[subMenuSelected]+parameterSelected)));
            snprintf(LCDQueueBottom, 17, " %s", (*(subMenus[subMenuSelected]+parameterSelected+1)));
        }
        LCDTopQueued = 1; //signal that we need to update the LCD
        LCDBottomQueued = 1;
        break;

    case ParaSet:

        clampedIncrement((*(parameters[subMenuSelected]+parameterSelected)), increment, (*(parameterLBs[subMenuSelected]+parameterSelected)), (*(parameterUBs[subMenuSelected]+parameterSelected)));

        //if we are changing notenames
        if((subMenuSelected == 2) | (subMenuSelected == 3 && parameterSelected == 0)){
            snprintf(LCDQueueBottom, 17, "%s%d", noteNames[(*(*(parameters[subMenuSelected]+parameterSelected))) % 12], (int8_t)(((*(*(parameters[subMenuSelected]+parameterSelected))) / 12) - 1)); //print the NOTENAME of the parameter under question
        }
        else{
            //snprintf(LCDQueueBottom, 17, "%d", *(parameterLBs[subMenuSelected]+parameterSelected));
            snprintf(LCDQueueBottom, 17, "%d", (*(*(parameters[subMenuSelected]+parameterSelected))));//print the current value of the parameter under question
        }

        //update red LED's to indicate positions of C
        if(subMenuSelected == 3 && parameterSelected == 0){

            for(int8_t i = 0; i < 4; i++){
                LEDMatrix[i] &= 0xf; //clear all the red channels
            }

            for(int8_t i = 0; i < 16; i++){ //we have 16 keys

                int8_t row = 3-(i>>2);
                int8_t col = (i%4);
                if((MidiNoteOffset+i)%12 == 0){ //this key is a C
                    LEDMatrix[row] |= (1<<(4+col));
                }

            }
            for(int i = 0; i < 4; i++){ //function to drive the LED's

                LEDMatrixBuffer[i*4] = 0b1111; //clear all pins first to prevent ghosting
                LEDMatrixBuffer[i*4+1] = 0x00;
                LEDMatrixBuffer[i*4+2] = ~(1<<i);
                LEDMatrixBuffer[i*4+3] = LEDMatrix[i];

            }
        }

        //nothing special

        LCDBottomQueued = 1;
        break;

    default:
        break;
    }

    lastEncoderValues[4] = encoderValues[4];
}
/* end control encoder rotated handler */

Now, lets code for the case where the user rotates the menu navigation encoder. Note that the encoder I used generates 2 state changes per indent, so we only react if the value of the encoder increments by 2 or more.

In this case, if we are currently adjusting the note assignments for the key matrix, the LEDs should react to every change we make so that we can know what we’re actually doing. So, if the current page is the MIDI note offset page, update the LED matrix every single time the encoder is rotated.

clampedIncrement() is a small helper function I wrote to ensure that parameters don’t get set out of bounds. It goes something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void clampedIncrement(int8_t* n, int8_t inc, int8_t lo, int8_t hi){

int a = *n;
int temp = a + inc;

if(temp < lo){
*n = lo;
}
else if(temp > hi){
*n = hi;
}
else{
*n = (int8_t)temp;
}

}

Since MIDI values only go up to 127, we can rather safely use 8 bit fixed width integers (-127 to 127). However, if you’re not short of RAM, don’t, because all it takes is one overlooked value and there goes hours of your life chasing red herrings.

I initially wanted to use branchless programming for this bit, but after a bit of digging online, it seems like with modern compiler optimization, the above code is as efficient as it gets for such an implementation.

SubMenu defines a state where the menu shows a list of parameters that can be changed for the user to select. Therefore, some sort of indication regarding which parameter is currently selected is needed, i.e. a cursor of sorts. The highlighted bit above implements this functionality by inserting a small arrow at the beginning of the row.

I considered doing a custom character thing for the indicator arrow, but if we look at the datasheet of the HD44780, there is the character map below (for ROM Code A00, if you try this and the character comes out different, your HD44780 is ROM Code A02 and has a different character map)

Which, conveniently has nice little arrows with code 0b01111110. That is 0x7E, so all we need to do is to insert this byte at the start of the selected row’s string before printing. The way to do this in C is by using the backslash, i.e. \x7E.

MIDI functionality

CC Controls

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*begin CC encoder handler*/
for(int i = 0; i < 4; i++){ //send encoder CC Values

    if(encoderValues[i] != lastEncoderValues[i]){ //encoder was rotated

        int8_t increment = encoderValues[i] > lastEncoderValues[i] ? 1 : -1;
        clampedIncrement(&MidiCCValues[MidiCCEncoderLUT[i]], increment*EncoderSpeed[i], 0, 127);
        MidiCC(MidiChannel, MidiCCEncoderLUT[i], MidiCCValues[MidiCCEncoderLUT[i]]);

        if(status == Status){
            snprintf(LCDQueueTop, 17, "CC %d", MidiCCEncoderLUT[i]);
            LCDTopQueued = 1;
            snprintf(LCDQueueBottom, 17, "%-16d", MidiCCValues[MidiCCEncoderLUT[i]]);
            LCDBottomQueued = 1;
        }

        lastEncoderValues[i] = encoderValues[i];

    }

}
/*end CC encoder handler*/

/*begin ADC handler*/
for(int j = 1; j < 4; j++){

    int i = j-1;
    int currentADC = ADC1ReadVal(j);
    //IIR filter https://kiritchatterjee.wordpress.com/2014/11/10/a-simple-digital-low-pass-filter-in-c/
    //but we are working in 12 bit fixed point anyways
    adcSmooth[i] = (adcSmooth[i] << filterBeta) - adcSmooth[i];
    adcSmooth[i] += currentADC;
    adcSmooth[i] = adcSmooth[i] >> filterBeta;

    currentADC = adcSmooth[i] >> 5; //convert the filter output to 7 bit, and store it currentADC
    //Note: lastFaderValues[i] is 7 bit
    //Note: this is NOT division for signed ints, due to the sign bit in front


    if(lastFaderValues[i] != currentADC){ // this particular ADC Channel has been updated

        MidiCCValues[MidiCCFaderLUT[i]] = currentADC & 0x7f; //mask off only last 7 bits

        MidiCC(MidiChannel, MidiCCFaderLUT[i], MidiCCValues[MidiCCFaderLUT[i]]);

        if(status == Status){
            snprintf(LCDQueueTop, 17, "CC %d", MidiCCFaderLUT[i]);
            LCDTopQueued = 1;
            snprintf(LCDQueueBottom, 17, "%-16d", MidiCCValues[MidiCCFaderLUT[i]]);
            LCDBottomQueued = 1;
        }


        lastFaderValues[i] = currentADC;

    }

}
/*end ADC handler*/

This next bit handles all the inputs that send MIDI CC messages, which are the 4 encoders and 4 (3) faders.

Since we have two ways of altering the value of a given MIDI CC channel, there is the 128 element array MidiCCValues[] that keeps track of everything. This also allows the MIDI CC value of each channel to be retained if the user changes the CC channel assignment for any of the encoders, and thus ensures that the intensity of the LED accurately reflects the value of the currently assigned channel. MidiCC()adds the relevant MIDI CC message to the USB FIFO (see Midi.c ).

The ADC of the STM32F103 is 12 bits (check ADC.c for a really basic way to read from the ADC), so we need to discard the final 5 bits.

Also, as I kinda expected, the subpar construction techniques I used made the ADC readings really noisy. I experimented with rolling average filters, simple averaging and IIR filters to try to stabilize the ADC readings, and in the end IIR gave a vastly superior result, especially with the filter beta cranked.

However, it can’t be set too high, since at high filter beta values the output of the filter takes a really long time to approach the set value, and for our case no amount of fader manipulation would allow it to reach the max MIDI CC value of 127 in the end, so you need to experiment with the beta value to find the perfect balance. As it stands the value is configurable via the menu tho, so finding the best value shouldn’t be too inconvenient.

In any case, if the user is not currently in the menu (status is Status), we display the most recently modified CC channel and its value on the LCD to give the user some feedback.

Note Triggering

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//scan key matrix
for(int i = 0; i < 4; i++){

    GPIOA->BRR = (0b1111 << 4);  //clear all of PA 4,5,6,7
    GPIOA->BSRR = (1 << (4+i));  //energize the ith row
    currentKeyMatrix |= ((((GPIOB->IDR) >> 3) & 0b11111) << (5*i)); //hmmmmmmmmm

}

//a key was pressed
if(currentKeyMatrix != lastKeyMatrix){

    //handle keys here
    for(int i = 0; i < 4; i++){
        LEDMatrix[3-i] &= 0xf0; //clear the greens
        LEDMatrix[3-i] |= (currentKeyMatrix >> ((5*i)+1)) & 0b1111;
        //LEDMatrix[3-i] = (1<<i); //FRAK ZERO INDEXING alkfjngkjkfla (originally the idiot me had 4-i)
        //hmmm, but on a more serious note tho, why is this array out of bounds not detected... that's definitely something to keep in mind
    }

    for(int i = 0; i < 4; i++){ //function to drive the LED's

        LEDMatrixBuffer[i*4] = 0b1111; //clear all pins first to prevent ghosting
        LEDMatrixBuffer[i*4+1] = 0x00;
        LEDMatrixBuffer[i*4+2] = ~(1<<i);
        LEDMatrixBuffer[i*4+3] = LEDMatrix[i];

    }

    for(int i = 0; i < 20; i++){ //iterate through all 20 bits and send out Midi Note messages as necessary

        if((currentKeyMatrix & (1<<i)) && ((lastKeyMatrix & (1<<i)) == 0)){ //this key was pressed

            MidiNoteOn(MidiChannel, MidiNoteLUT[i], MidiNoteVelo);

        }


        else if((lastKeyMatrix & (1<<i)) && ((currentKeyMatrix & (1<<i)) == 0)){

            MidiNoteOff(MidiChannel, MidiNoteLUT[i], 0);

        }

    }

    lastKeyMatrix = currentKeyMatrix;
}

currentKeyMatrix = 0; //start afresh

Notes are triggered via the 4×4 key matrix, and also through the 4 CC encoder pushbuttons. The code to implement that functionality is quite simple and self explanatory.

Of course, in programming mistakes do happen, and you can see one such instance in the snippet above. Before this bit, I actually thought that compilers will give warnings for array out of bounds errors, but it turns out that if you don’t directly try to access elements of an array that don’t exist (as is the case above with the array element controlled by the for loop), then the compiler doesn’t notice. Maybe static code analysis would have alerted me? That’d be a topic for another day…

With the key matrix being scanned once every time the loop executes, I found that contact bounce isn’t an issue at all, hence the complete lack of debouncing. Even if it were, the capacitive behavior of the input pins would probably help smooth it out anyways. I found that out of the box, the Gateron yellow switches and also the buttons on the Bourns encoders didn’t really have too much contact bounce, so everything was fine.

Updating the LCD

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if(!isLCDPrinting){ //update LCD here
        //assumption: at most only one parameter will be changed with each loop, therefore, only allow for 1 queued write

        if(LCDTopQueued){
            LCDPrintStringTop(LCDQueueTop);
            LCDTopQueued = 0;
        }
        else if(LCDBottomQueued){
            LCDPrintStringBottom(LCDQueueBottom);
            LCDBottomQueued = 0;
        }

    }

}

Finally, we are at the end of the loop. Since we’ve done all the MIDI-ing and LCD-print-queueing, we can now call the functions to print whatever is queued on the LCD itself if the I2C bus is currently not doing anything. Check out Part 1B for more details on how this works under the hood.


Phew! There we go, that’s all the firmware I have written for this thing. It took literally months of coding on and off (thanks STEP II) to arrive at this point, but I must say, I have learned a ton pushing through all the bugs and challenges that presented themselves along the way, and I hope you have learned somthing too!

Before you go, there will be one more post in this series, because there is so much stuff that I want to add to this project, yet didn’t have the time for. Stick around for the brief discussion on that.

Thank you for coming along with me on this journey!