I wanted to experience with sound synthesizing in the Commodore 64. It includes a very interesting IC, the MOS 6581/8580, best known as SID (Sound Interface Device). I got to know of a fantastic development, the MSSIAH cartridge. Among other functionalities, it allows you to play music with the SID in monophonic synthesizer mode from a MIDI keyboard. All the parameters of the synthesizer can be modified using the computer keyboard and a joystick. The C64 interface looks as follows:
The problem with the interface is that it is very unpractical for live playing and for experimenting. Fortunately, the MSSIAH cartridge also allows modifying all the synthesizer parameters via MIDI Program Change and Control Change messages.
I came across a YouTube video by Doctor Mix of a custom-made MIDI controller for the C64 Cynthcart cartridge (somehow similar to the MSSIAH cartridge). I liked the idea and decided to go for it, this time targeted to the MSSIAH cartridge and in an Eurorack compatible format. And the result is the following:
BTW: The keyboard in the video is a Toshiba HX-MU901 for MSX computers. I also have a project on how to make conversion to MIDI.THE DEVICE
As I wanted to develop this project just for the fun of playing some music, I did not want to expend much time with the electronics. For this reason I decided to use Arduino-based available modules from Adafruit:
- Feather M0 Proto: the basic microcontroller board, with a programmable LED
- FeatherWing MIDI: a simple MIDI interface for the M0
- FeatherWing Doubler: a protoboard that can fit two Feather modules, the M0 and the MIDI interface
NOTE: I have used these modules, but the circuit and the program are general enough to run on other Cortex M0 or M4 based boards, like Arduino MKR.
The MSSIAH synthesizer includes many parameters, so in order to avoid a very complex panel I decided to include knobs for the most used controls, while relying on a simple GUI for the other parameters. While there are many possible displays available, I wanted a small and simple one, so I decided to try a generic 128x64 OLED display by Fazisi. The display shows the different configurable options, grouped by categories. Selection of options and values is done using a rotary encoder with switch.
Given the different options I selected 11 functions to be attached to knobs. As there are more knobs than available analog inputs in the Feather M0, the potentiometers are routed to a single ADC input (A0) using the MC14067 16-channel analog multiplexer.
The construction is very simple, although time-consuming because of the large number of wires. I started by soldering an IDC-20 connector for powering the project from an Eurorack power supply, and three PST-XH connectors for connecting the rotary encoder (4 pins), the multiplexer (7 pins), and the OLED display (4 pins). The finished FeatherWing Doubler is shown below:
I cut a 3mm acrylic plastic sheet to fit into a 3U/28HP shape (141.9 x 70.8 mm), and then drilled holes for the potentiometers, the rotary encoder, the OLED display and the MIDI connectors. I attached the OLED display and the FeatherWIng Doubler, which I put in place with two L-shaped supports. Then I mounted all the 10k ohm potentiometers on the panel and wired the corresponding cables: 11 signals, plus ground and +3.3V. The result is as follows:
Finally I mounted the multiplexer in a protoboard and routed the inputs from the potentiometers, and the wires to the FeatherWing Doubler. The result is as follows:
TRICK: as you may not have enough colors for all the cables (as it was my case), I put some marks at the end of the cables. You might find the codes in the potentiometers picture
The final result is shown below:
The result is nice and compact. Now, let's make it work. Conceptually, the program is simple: you read the pots and send the corresponding MIDI messages, you read the encoder and switch, modify things in the display and then send the corresponding MIDI messages. While conceptually that simple, the actual program is rather large and has many data structures and methods. I will try to summarize the most important parts of the code, so that anyone interested can easily modify it.
The most important data structure is named Configuration. It serves to store all the GUI based configurable parameters, which are listed below, during runtime, and also for implementing data persistence using the Feather M0 flash memory. Every time a parameter is modified, the data structure is written on the flash memory. In this way, at power up, the parameters can be recovered.
typedef struct
{
bool valid;
// MIDI Configuration
uint8_t midiChan; // MIDI channel [1-16]
uint8_t midiVol; // MIDI volume [0-15]
uint8_t midiPatch; // MIDI patch [1-8]
// VCO
uint8_t vcoWaveA; // Oscillator A waveform [0-9]
bool vcoRingA; // Osicllator A ring modulator [+8]
bool vcoSyncA; // Osicllator A sync [+16]
uint8_t vcoWaveB; // Oscillator B waveform [0-9]
// LFO
uint8_t lfoControl; // LFO control type [0-3]
uint8_t lfoWave; // LFO waveform [0-4]
uint8_t lfoDstFrq; // LFO destination frequency [0-3]
uint8_t lfoDstFlt; // LFO destination pulse+filter [0-7]
// VCA
uint8_t vcaAttack; // VCA attack [0-15]
uint8_t vcaDecay; // VCA decay [0-15]
uint8_t vcaSustain; // VCA sustain [0-15]
uint8_t vcaRelease; // VCA release [0-15]
// Envelope
uint8_t envMod; // Envelope modulation [0-31]
uint8_t envFrq; // Envelope modulation frequency [0-3]
uint8_t envPwm; // Envelope modulation pulse width [0-3]
// Filter
uint8_t fltType; // Filter type [0-15]
uint8_t fltAttack; // Filter envelope attack [0-15]
uint8_t fltDecay; // Filter envelope decay [0-15]
uint8_t fltSustain; // Filter envelope sustain [0-15]
uint8_t fltRelease; // Filter envelope release [0-15]
} Configuration;
The MIDI related part of the software is very simple, mostly due to the use of the fantastic Arduino MIDI library by Forty Seven Effects. For the one hand, we listen to all the MIDI input messages and forward them to the MIDI output port. In addition, as the Feather M0 includes a single LED strip, we use it for having feedback on the received notes. For this task we include two handlers: one for the Note On messages and another for the Note Off messages. The corresponding MIDI initialization is as follows:
MIDI.setHandleNoteOn(handleNoteOn);
MIDI.setHandleNoteOff(handleNoteOff);
MIDI.begin(conf.midiChan);
MIDI.turnThruOn();
The handlers are very simple. The noteCount counter counts the number of active notes. If there is any note playing, the LED strip is colored green, otherwise is light off (by using the changePixel() function).
void handleNoteOn(byte channel, byte pitch, byte velocity)
{
MIDI.sendNoteOn(pitch,velocity,channel);
if (velocity == 0)
{
noteCount --;
if (noteCount == 0)
changePixel ();
return;
}
noteCount ++;
if (noteCount == 1)
changePixel ();
}
void handleNoteOff(byte channel, byte pitch, byte velocity)
{
MIDI.sendNoteOff(pitch,velocity,channel);
noteCount --;
if (noteCount == 0)
changePixel ();
}
The potentiometers reading part is little bit more complex. As we need to use the analog multiplexer, before reading a potentiometer we need to specify which analog input we will be using. The actual reading is done using the ADC at Feather M0 port A0 using 12bits resolution, and the mux control lines are #5, #6, #9 and #10 configured as digital outputs. The initialization is as follows:
#define POT_I A0
#define POT_A 5
#define POT_B 6
#define POT_C 9
#define POT_D 10
[...]
analogReference (AR_DEFAULT);
analogReadResolution (12);
pinMode(POT_A, OUTPUT);
pinMode(POT_B, OUTPUT);
pinMode(POT_C, OUTPUT);
pinMode(POT_D, OUTPUT);
We define two arrays potCur, which stores the current potentiometer readings, and potLast, which stores the previous potentiometer readings. The reading process is as follows: we iterate through all the potentiometers, setting the corresponding mux bits (from variables selectA[], selectB[], selectC[], and selectD[]), and then waiting for a brief period for the signal to stabilize. We stay in the safe side by waiting 1ms. Then we check if the potentiometer values has changed, in which case we send the corresponding MIDI messages. As the potentiometers input tend to be not very stable along time, we use the POT_THR threshold value. If the difference between current reading in potCur and potLast is larger than this threshold, we consider that the potentiometer has changed value. The main code pieces of the process are as follows.
#define POT_THR 25
uint16_t potLast[] = { 5000, 5000, 5000, 5000, 5000, ... };
uint16_t potCur[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
[...]
const bool selectA[] = { true, false, true, false, ... };
const bool selectB[] = { false, true, true, false, ... };
const bool selectC[] = { false, false, false, true, ... };
const bool selectD[] = { false, false, false, false, ... };
[...]
for (int i = 0; i < POT_NUM; i++)
{
digitalWrite (POT_A, selectA[i]);
digitalWrite (POT_B, selectB[i]);
digitalWrite (POT_C, selectC[i]);
digitalWrite (POT_D, selectD[i]);
delay (1);
potCur[i] = analogRead (POT_I);
if (abs (potCur[i]-potLast[i]) > POT_THR)
{
val = potMin[i] + potCur[i] / potScal[i];
if (potSingle[i])
MIDI.sendControlChange(potCC[i], val, conf.midiChan);
else
{
if (val < 128)
MIDI.sendControlChange(potCC[i], val, conf.midiChan);
else
MIDI.sendControlChange(potCC[i]+1, val-127, conf.midiChan);
}
}
potLast[i] = potCur[i];
}
It is interesting to note two interesting points. On the one hand, the val variable stores the correct biased and scaled value to send to MSSIAH, a conversion from the 12-bits ADC range [0...4096] to the corresponding MSSIAH range, which depends on the specific potentiometer. This is encoded in the arrays potMin[] and potScal[]. On the other hand, there are controllers whose ranges are inside [0...127] and other whose ranges are inside [0...255]. Those are identified with the values of the potSingle[] array. The MIDI Control Change numbers are encoded in the potCC[] array. Their definitions are as follows.
const bool potSingle[] = { false, true, false, false, ... };
const uint8_t potCC[] = { 92, 84, 79, 76, 95, 89, 74, ... };
const uint8_t potMin[] = { 0, 0, 0, 0, 0, 57, 14, 0, 0, ... };
const uint16_t potScal[] = { 15, 131, 15, 15, 131, 292, ... };
The potLast variable is initialized with large values to force the updating of all the potentiometer values. This implies that every time we power up the values of the potentiometers are sent to the MSSIAH cartridge. This has its pros and cons, but it is easy to overcome this effect. You are free to modify this behavior.
Now, the rest of the program is devoted to GUI and user interaction. The user interface options are organized in three different levels. The current level is identified with the variable guiLevel.
- Group level. The different controller options are grouped depending on their functionalities. For instance, all the configurations related to the LFO (Low Frequency Oscillator). The groups are shown in the two top lines of the display.
- Option level. When a group is selected, the different options corresponding to this levels are shown in the four bottom lines, below a horizontal line dividing the interface.
- Value level. When an option is selected, the different values (they can be numbers or labels) can be selected.
When any level is selected, its corresponding text is displayed in inverse mode (dark text over bright background). The user can move between the different functionalities of a level using the rotary encoder, pressing the switch to enter such functionality. In order to exit the value level, the user needs to press the switch. In order to exit the option level, the user needs to scroll to the "Finish" option and then press the switch.
Describing the whole GUI code is large and very boring. I will instead describe just one option, but the ideas and names apply to all the functions. You may skip this part if you are not interested in modifying the interface. Let's concentrate on one of the options level, "OSC" the oscillators configuration. The OSC interface is encoded in the following arrays:
#define VCO_NUM 5
#define VCO_WAVA 0
#define VCO_RINA 1
#define VCO_SYNA 2
#define VCO_WAVB 3
#define VCO_END 4
const String vcoText[] = {"Wave A:","Ring A:","Sync A:","Wave B:","Finish"};
const String vcoWaveText[] = { "OFF", "SINE", "SAW", "PULSE", ... };
const String vcoWaveEmpty = " ";
const uint16_t vcoPosX[] = { 8*6, 8*6, 8*6, 8*6 };
const uint16_t vcoOffX[] = { 0, 0, 0, 0, 15*6 };
const uint16_t vcoOffY[] = { 24, 34, 44, 54, 44 };
int16_t vcoSel = VCO_WAVA;
The array vcoText stores the names of the different functionalities of the OSC group, which are drawn at positions vcoOffX[] and vcoOffY[]. The current values of the functionalities are drawn at positions vcoPosX[] and vcoOffY[]. In this case "Ring A" and "Sync A" have boolean values, and "Wave A" and "Wave B" have label values, which are stored in the array vcoWaveText[]. The current selected functionality is stored in the vcoSel variable.
When in the group level the user selects "VCO", the level changes to option level, and the display is updated with the different options and their current values as follows:
inline void gui_optsVco ()
{
display.fillRect (0, 24, 128, 40, SSD1306_BLACK);
display.setTextColor (SSD1306_WHITE, SSD1306_BLACK);
for (int i = 0; i < VCO_NUM; i++)
{
display.setCursor (vcoOffX[i], vcoOffY[i]);
display.print (vcoText[i]);
}
display.setCursor (vcoPosX[0], vcoOffY[0]);
display.print (vcoWaveText[conf.vcoWaveA]);
display.setCursor (vcoPosX[1], vcoOffY[1]);
display.print (conf.vcoRingA ? "ON" : "OFF");
display.setCursor (vcoPosX[2], vcoOffY[2]);
display.print (conf.vcoSyncA ? "ON" : "OFF");
display.setCursor (vcoPosX[3], vcoOffY[3]);
display.print (vcoWaveText[conf.vcoWaveB]);
display.display();
}
When in group level, the code executed when the rotary encoder is moved is as follows (it corresponds to when the user changes the currently selected option). It is pretty straight forward. We write in normal text the current option -with the gui_writeTextNormal() function-, and then we display in inverse text the currently selected value -with the gui_writeTextInverse() function-.
case MENU_VCO:
gui_writeTextNormal (vcoOffX[vcoSel], vcoOffY[vcoSel], vcoText[vcoSel]);
switch (vcoSel)
{
case VCO_WAVA:
gui_writeTextInverse (vcoPosX[vcoSel], vcoOffY[vcoSel], vcoWaveText[conf.vcoWaveA]);
break;
case VCO_RINA:
gui_writeTextInverse (vcoPosX[vcoSel], vcoOffY[vcoSel], conf.vcoRingA ? "ON " : "OFF");
break;
[...]
case VCO_END:
guiLevel -= 2;
break;
}
break;
When in the value level, the code executed when the rotary encoder is moved is as follows (it corresponds to when the user changes the currently value). Two examples are shown. When the selected option is "Wave A", the gui_selectLabel () function displays the currently selected value taking into account the rotation value (diff variable), and sets the corresponding value in the Configuration structure. Then this value is sent using a MIDI Control Change message. In this specific case, the output value which is sent in the MIDI message depends on the combination of the values of "Wave A", "Ring A", and "Sync A" options. The output value is computed with the selectWaveform () function. Finally, the whole Configuration structure is stored into the flash memory.
switch (vcoSel)
{
case VCO_WAVA:
conf.vcoWaveA = gui_selectLabel (vcoPosX[vcoSel], vcoOffY[vcoSel], conf.vcoWaveA, diff, vcoWaveText, vcoWaveEmpty, 9);
MIDI.sendControlChange(70, selectWaveform (conf.vcoWaveA, conf.vcoRingA, conf.vcoSyncA), conf.midiChan);
break;
case VCO_RINA:
conf.vcoRingA = gui_selectBool (vcoPosX[vcoSel], vcoOffY[vcoSel], conf.vcoRingA);
MIDI.sendControlChange(70, selectWaveform (conf.vcoWaveA, conf.vcoRingA, conf.vcoSyncA), conf.midiChan);
break;
[...]
}
[...]
flash.write(conf);
When in the value level, the code executed when the switch is pressed is as follows (it corresponds to when the user selects a given value for an option). It is pretty straight forward. We write in inverse text the current option, and then we display in normal text the currently selected value.
case MENU_VCO:
gui_writeTextInverse (vcoOffX[vcoSel], vcoOffY[vcoSel], vcoText[vcoSel]);
switch (vcoSel)
{
case VCO_WAVA:
gui_writeTextNormal (vcoPosX[vcoSel], vcoOffY[vcoSel], vcoWaveText[conf.vcoWaveA]);
break;
case VCO_RINA:
gui_writeTextNormal (vcoPosX[vcoSel], vcoOffY[vcoSel], conf.vcoRingA ? "ON " : "OFF");
break;
[...]
}
break;
Comments