What if you could take all of your beloved retro video games and play them on real OPL 2 hardware… but without using a Sound Blaster or Adlib card? In this article, we are going to do just that by adding percussion support to our YM3812 module. With drums in place, we’ll have enough of the General MIDI spec implemented to route video game music directly into the module!
General MIDI Percussion
In addition to 128 instrument names, the General MIDI standard also defines names for 47 percussion sounds as well. These names bring just enough consistency to the way things sound while still leaving lots of room for interpretation by the different sound artists and manufacturers.
In the last article, we talked about how to assign patches to MIDI channels so that each MIDI channel plays a different “instrument” patch. In doing this, we updated the noteOn function of the YM3812 library to be a patchNoteOn function. This way, when a note comes in from MIDI, we just find the patch associated with the MIDI channel and pass it along with the MIDI note number to play and the class does the rest.
Unlike melodic instruments though, General MIDI 1.0 associates ALL percussion sounds with the same channel—MIDI channel 10. It then uses the MIDI note number to determine which sound to play. This way, each key on the keyboard plays in a different percussive sound, starting on MIDI note 35—B0, up through note 82—A4.
Assigning Sounds to Channels
Just like instruments, each key press causes a new patch to be uploaded into the YM3812, but now we use the midi note number to select them. For example, pressing C1 causes the Bass Drum 1 patch to be uploaded, but D1 loads the Acoustic Snare and G#1 loads the Pedal Hi-hat.
In our new ino program file, we can define a few of these key parameters:
DRUM_CHANNEL defines the MIDI channel to associate with the special percussion instrument
FIRST_DRUM_NOTE defines the first MIDI note to associate with a drum sound
drum_patch_data contains an array of all of the percussion patch settings that have been loaded from PROGMEM into RAM. This is similar to the inst_patch_data array that contains patch settings for each of the instruments associated with the other MIDI channels. Of course the drum array contains many more patches (47 vs. 16) and thus takes up a good chunk of memory (3.6k).
drum_patch_index contains an array of integers that associate each drum patch with a patch in the patches array. Initially, we fill this array sequentially with the numbers 0 through 46. But later, keeping this reference will make it easier to associate different keys on the keyboard with different patches.
Loading patches from PROGMEM
Just like with instruments, we also need a way to load patch settings into RAM from the patches array stored in PROGMEM. For instruments, we use the loadPatchFromProgMem, but for drums, we will create a new function called loadDrumPatchFromProgMem:
trackIndex determines the slot in drum_patch_data in which to load the patch
patchIndex determines the drum patch to load from the patches array
There is really only one difference between this function and the loadPatchFromProgMem. Because we stored melodic patches in the first 128 slots of the patches array, we have to add on 128 to get to the first percussion patch. The “128” gets stored as a constant called NUM_MELODIC in the instruments.h file. This file also includes a NUM_DRUMS constant defined as “47” which represents the total number of drum patches.
So, to load the correct patch, we add NUM_MELODIC to the patchIndex. This way, patchIndex will be valid from zero up to (but not including) NUM_DRUMS.
Getting Things Set Up
Now that we have our data structures all in place, we need load all of the percussion patch information from PROGMEM and configure our drum_patch_index pointers. We do this by adding a few lines to our setup function:
The only new code here is the second for loop. This loop goes through each drum and assigns a patch index sequentially. Then it loads each patch from PROGMEM into the drum_patch_data array in RAM by running the loadDrumPatchFromProgMem function.
Playing Percussion Sounds
Now with all of our data loaded, let’s update our handleNoteOn function to accommodate the percussion special case.
In the event that the MIDI channel equals DRUM_CHANNEL then we need to load our patch based on the noteNumber passed into the handleNoteOn function. To calculate the index of the patch in our drum_patch_data array, we need to subtract the FIRST_DRUM_NOTE from the midiNote. We do this because MIDI note 35 is the first key on the keyboard associated with a percussion sound.
Of course, you can play notes on the keyboard outside of the boundaries of the array—lower than B0 or higher than A4. This would cause drumIndex to fall outside of the boundaries of the drum_patch_data array. To fix this, we just mod the index with NUM_DRUMS so that whatever key you press will result in a valid index. This ensures that every key on the keyboard plays a percussion sound even if it falls outside of the valid range.
With a valid index in hand, we can play it by passing the associated entry from drum_patch_data to the patchNoteOn function from our YM3812 library. But wait a tic… doesn’t the patchNoteOn function require a midi note to play?
Using a Default MIDI Note
Yes. The patchNoteOn function does require a MIDI note to play. But which note should we use? If we use the midiNote argument passed to the handleNoteOn function, then the pitch of every percussion patch would depend on the key its associate with. That’s definitely not what we want. We need the ability to assign the best sounding MIDI note for each percussion patch. And that means we need a default MIDI note in our patch. Turns out… we do!
The Note Number setting represents the default note to use for percussion instruments. Now all we just need to update the YM3812 class to use it. And we are going to do that by overloading the patchNoteOn / Off functions inside our YM3812 class:
To overload a function in C++, you just define a new function with the same name. This works so long as the arguments passed into the function are different. In this case, we define patchNoteOn to require ONLY a patch and not a midiNote. Then, inside of the function, we call the original patchNoteOn function and pass the patch as well as the note number contained inside.
With the overloaded function in place, the midiNote argument becomes optional. If you pass it, then it will be used to define the pitch of the note. But if you don’t pass it, then the default note number inside the patch will be used instead.
The Full handleNoteOn function
With all of that theory out of the way, the full function looks like this:
If the MIDI channel isn’t the DRUM_CHANNEL then we call the patchNoteOn function, but pass the patch associated with instrument along with the midiNote to play. The handleNoteOff function works the same way:
Well, those are all of the changes to the code. And of course, you can find everything neatly packaged up on GitHub if you want to try it out for yourself! In the meantime, here are some deep links to the demos in the main video:
Each note on the keyboard plays a different sound:
Going direct from a Beatstep Pro into the module:
A side by side comparison of the Space Quest 3 intro music playing on the YM3812 module vs. emulated Adlib sound from ScummVM:
For reference, the audio played in this demo comes from the ScummVM emulator using the default Adlib sound output setting. Now to be clear, the sound difference between the YM3812 module and the emulated Adlib goes way beyond hardware vs. software implementation. In this case, the sound patches themselves are different. That alone significantly changes things. But I also think that’s part of the fun of this. You can change the orchestration of your favorite songs in your favorite video games, and that’s kind of cool.
Conclusion
With only a few minor code additions, we’ve added another pretty substantial piece of functionality to the module. At this point, I’m debating where to take things next. We still need to add more accurate level control in order to accommodate velocity sensitivity, and similarly, detuning & pitch-bend. Also, we have most things in place to enable 2×2-operator voices. From there, we can start to look at a dual-YM3812 chip setup for stereo and panning. And of course we still need to add input control and a menu system—that’s going to be a big job. If one of these jumps out at you more than another, let me know in the comments. And if you are following along and run into questions, feel free to drop a note there as well!
Here is a link to the GitHub if you want to see the code in full:
A couple years ago, I had this crazy idea of controlling multiple Yamaha FM sound chips with a single microcontroller. At the time, I had a YM3812, YM2151, YM2413 and even a non-Yamaha chip, the SN76489 on a breadboard outputting sound together. It turned out that controlling the chips themselves wasn’t really that hard. They all follow generally the same principles that we have been using to control the YM3812. The tricky part turned out to be finding a way to control the sound properties in an even remotely consistent way. Each of the chips has different properties, and those properties have different ranges. Those differences became pretty impossible to keep track of in my head, which began my quest to find a unified way of controlling them.
If you’ve been following along through the blog over the last few months, I’m building a EuroRack module around the YM3812 OPL2 sound processor. In the last entry, we got our module running with 9 voice polyphony and that made it a pretty playable instrument. Still, to change the characteristics of the sound itself, you have to set all of the sound properties one at a time. In this article, we are going to fix that by bringing together all of the settings into a single data structure called a patch. But this patch isn’t just going to work for the YM3812, it’s going to be cross compatible with several Yamaha sound processors. Moreover, we are going to explore how to assign different MIDI channels to different patches. This will allow us to use multiple patches at the same time and turn our instrument into an orchestra! Let’s get started.
A Unified Patch Structure
Over the years Yamaha produced a variety of sound chips, each with its own unique set of features. Now to keep this exercise reasonably simple, I’m going to focus on a subset of the chips that use four operators or less. Within this list, there are some interesting familial relationships between the chips.
Yamaha FM Sound Processor Feature Comparison
Yamaha Chip Evolution
The YM3526 (OPL) was the first in the OPL family of sound processor. It combined sine waves through frequency modulation to produce a wide variety of sound characteristics and represented a radical departure from the square-wave chips at the time.
The YM3812 (OPL2) added multiple waveforms, but otherwise stayed much the same. In fact, this chip is backwards compatible with the YM3526 and you actually have to take it out of compatibility mode in order to use the broader waveform set.
Yamaha introduced the YM2413 (OPLL) as a budget version of the YM3812 designed for keyboard usage. This chip comes with a set of pre-configured instruments as well as a user-configurable one. You can only use one instrument at a time—which makes sense for a keyboard. The chips have a built in DAC, but I have found the audio to be rather noisy.
The YMF262 (OPL3) advanced the OPL family considerably by upping the number of operators per voice to 4, adding even more waveforms, and—assuming you spring for two DAC chips—up to four output channels. Still, even this chip is backwards compatible all the way to the YM3526.
While the YM2151 (OPM) launched in 1984 around the same time as the YM3526, this family of sound chips departed from the OPL family in a few key ways. For one, this chip started out with four operators per voice, but did not include additional waveforms beyond sine waves. This chip provided a more advanced low frequency oscillator (that support multiple waveforms) and the ability to change tremolo/vibrato levels at the individual channel level. The OPM line also introduced detuning and an alternative style of envelope generation.
The YM2612 (OPN2) builds on the functionality of the YM2151, adopting its 4-op sine-wave-only approach to FM synthesis. This chip removes some of the LFO and noise-generation capabilities of the YM2151, but also adds a DAC as well as SSG envelopes.
Envelope Differences
OPL vs. OPM/N Envelope Types
The OPL family provides two different envelope types that you can select through the “Percussive Envelope” setting. In a percussive envelope, the release cycle begins as soon as the decay cycle ends. This allows you to create percussive instruments like drums, xylophones, wood blocks, etc.
The OPM/N family provides only one envelope type, but adds a new setting called Sustain Decay. With this setting you can create both ADR and ADSR envelope styles as well as anything in between.
SSG Envelopes
SSG envelopes control the channel level in a repeating envelope pattern according to the chart above. The AY-3-8910 introduced this feature in 1978. Later, Yamaha picked up manufacturing of this chip with a couple tweaks and rebadged it as the YM2149 (SSG). These three-voice chips produce sound with square waves and don’t use FM synthesis at all. Still the SSG envelope has a very distinctive sound and it’s interesting to see them reappear as a feature in the YM2612 (OPN2).
Lastly, it’s worth noting that while chips like the YM2149 and the SN76489 don’t provide envelopes or LFOs, you can emulate them by using a microcontroller to adjust the level directly. Maybe a good topic for a future article? 🤔
Comparing Property Ranges
Of course the differences between these chip families differ at more than a feature level. Once you look at the range of values each property supports, their familial relationships become even clearer:
Comparison of Value Ranges in Yamaha FM Sound Processors
Ignoring the YM2149 for a second, the major differences between the chips coincide around the two chip families (the OPL vs. OPM/N). The OPM/N family provides more granular control over the attack and decay envelope settings (0..31 vs. 0..15), level (0..127 vs. 0..63), and envelope scaling (0..3 vs. 0..1). With more operators, the OPM/N family also allows a wider range of values for algorithm (0..7 vs. 0..1). Even the 4-op YMF262 (OPL3) only provides 4 algorithms.
In order to produce a patch that works across these chips, we need a way to represent these ranges consistently. And to do that we’ll review a little bit math. If you recall from the first article, each register byte combines multiple settings by dedicating a few bits to each one.
YM3812 Register Map for Operator Settings
The maximum values derive from the limited number of bits associated with each setting. The more bits, the broader the range, and every maximum is a multiple of two.
All Maximum Values Derived From Bit-depth
With this in mind, scaling settings between different chips could be as simple as left or right shifting the value. For example, Attack on the YM2151 ranges from 0-31 (a 5-bit number), while Attack on the YM3812 ranges from 0-15 (a 4-bit number). So if you had a YM3812 attack value, you could scale it for the YM2151 by shifting the number one bit to the left. This is equivalent to multiplying the number by two. Unfortunately, we always loose fidelity when we scale a number down and back up. For example:
(19 >> 1) << 1 == 18 (not 19)
Finding Consistency
We solve this by only scale numbers down, and never back up. This means we have to start with the largest possible value and scale down from there. By default, the MIDI specification allows values that range from 0-127. You can send larger values, but need to break them up across multiple bytes. Thankfully, this range covers nearly all of the values we’d need anyway, so it’s a good place to start. With this in mind, I updated the combined spec:
Combined list of settings for our Generic Patch Spec
Non-patch settings
Most of the operator-level and channel-level settings make sense to include in the patch because they alter the properties of the sound. However, settings like frequency and output channel are more “runtime” properties. They affect things like the note you are playing and the speaker you are playing it through. Additionally, global settings like Tremolo Depth change all patches being played on the system at the same time. So, if you reassigned those every time you loaded a patch, those patches would conflict and override each other. For this reason, global settings can’t be included in the patch either.
Adding a few extras
Processor Config associates the patch with a sound processor type. Since our module only includes a YM3812 chip, we can ignore this setting for now. For a multi-sound processor module though, this would become super important.
Note Number indicates the note frequency to use when playing percussion sounds. We will dig more into this with the next article, but MIDI channel 10 aligns to a dedicated percussion mode. In this mode, each note on the keyboard aligns to a different patch, providing access to 42 different drum sounds. Of course those percussive sounds still need to be played at a specific frequency. And since we can’t rely on the keyboard key for that frequency, we use the note number setting instead.
Pitch Envelopes (PEG)
Visualization of Pitch Envelopes
This one is a bit of a stretch goal for me. Pitch envelopes—like other envelopes—trigger upon playing a note. But, instead of affecting the sound level, they bend the pitch into the note while it’s held, and then away from the note upon release. You can find this feature on sample-based modules like Yamaha’s MU series, but not in these FM chips. To make this work, we will have to emulate the functionality in the micro-controller. For now, I’ve added it to the spec as a placeholder.
Coding the Final Spec
Index list for all Settings in the Patch Data Structure
Putting everything together, we get a 78 element array of 8-bit integers. Each element of this array corresponds to an index shown above. The first 10 elements store the general patch & channel settings, while the remaining elements store operator settings in groups of 17. Since there are four operators, you can calculate the full patch size by multiplying the number of operator settings by 4 and adding the number of general settings.
I’ve defined these values in a new file called, YMDefs.h:
Also in the YMDefs.h file, the PatchArr data type defines the patch structure. It is simply an array of 8-bit integers:
typedef uint8_t PatchArr[PATCH_SIZE]; // Define the PatchArr type as a uint8_t array
Additionally, I’ve added definitions for every array index. Each setting includes comments that show the supported sound processor types.
// Instrument Level / Virtual
#define PATCH_PROC_CONF 0 // Specifies the processor and desired processor configuration
#define PATCH_NOTE_NUMBER 1 // Indicates the pitch to use when playing as a drum sound
#define PATCH_PEG_INIT_LEVEL 2 // Initial pitch shift before attack begins (Virtual setting - none of the YM chips have this by default)
#define PATCH_PEG_ATTACK 3 // Pitch Envelope Attack (Virtual setting - none of the YM chips have this by default)
#define PATCH_PEG_RELEASE 4 // Pitch Envelope Release (Virtual setting - none of the YM chips have this by default)
#define PATCH_PEG_REL_LEVEL 5 // Final Release Level (Virtual setting - none of the YM chips have this by default)
// Channel Level
#define PATCH_FEEDBACK 6 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 |
#define PATCH_ALGORITHM 7 // Used by: YM3526 | YM3812 | YMF262 | | YM2151 | | YM2612 |
#define PATCH_TREMOLO_SENS 8 // Used by: | | | | YM2151 | | YM2612 | *SN76489
#define PATCH_VIBRATO_SENS 9 // Used by: | | | | YM2151 | | YM2612 | *SN76489
// Operator Level
#define PATCH_WAVEFORM 10 // Used by: | YM3812 | YMF262 | YM2413 | | | |
#define PATCH_LEVEL 11 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | YM2149 | YM2612 | *SN76489
#define PATCH_LEVEL_SCALING 12 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | | | |
#define PATCH_ENV_SCALING 13 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 |
#define PATCH_PERCUSSIVE_ENV 14 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | | | | *SN76489
#define PATCH_ATTACK 15 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 | *SN76489
#define PATCH_DECAY 16 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 | *SN76489
#define PATCH_SUSTAIN_LEVEL 17 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 | *SN76489
#define PATCH_SUSTAIN_DECAY 18 // Used by: | | | | YM2151 | | YM2612
#define PATCH_RELEASE_RATE 19 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 | *SN76489
#define PATCH_TREMOLO 20 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612
#define PATCH_VIBRATO 21 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | | |
#define PATCH_FREQUENCY_MULT 22 // Used by: YM3526 | YM3812 | YMF262 | YM2413 | YM2151 | | YM2612 |
#define PATCH_DETUNE_FINE 23 // Used by: | | | | YM2151 | | YM2612 | *SN76489
#define PATCH_DETUNE_GROSS 24 // Used by: | | | | YM2151 | | YM2612 | *SN76489
#define PATCH_SSGENV_ENABLE 25 // Used by: | | | | | YM2149 | YM2612 | *SN76489
#define PATCH_SSGENV_WAVEFORM 26 // Used by: | | | | | | YM2612 | *SN76489
Now, to access a value inside of a PatchArr object—let’s say you had one called, “patch”—you can use these defined indexes. For example:
patch[PATCH_FEEDBACK] = 27;
To access a specific operator’s setting, you need to add on PATCH_OP_SETTINGS times the operator number that you want—assuming the operator numbers go from 0 to 3:
Now that we have our patch structure, we need to decide how and when to send the patch information to the YM3812. This part of the project threw me for the biggest loop. So, just in case you get my same bad idea, I’ll show you where I went off the rails.
Partitioning YM3812 Channels
Portioned Channel Space Algorithm
First, partition the YM3812’s nine channels into sets of channels that each represent an “instrument.” Then, depending on the assigned instrument, upload the appropriate patch information. Now that everything is configured, listen to the different MIDI input commands and—depending on the MIDI channel number—direct the play note functions to the appropriate set of channels.
In this configuration, each set of channels becomes its own polyphonic instrument. But the more instruments you have, the fewer voices each instrument can support. In fact, if you had 9 instruments, they would all be monophonic. Clearly, to support 16 MIDI channels, we are going to have to do this in another way…
Dynamic Patch Reassignment
Dynamic Patch Reassignment Algorithm
The assumption that led me to partition the channels was that uploading patch information takes too long. Thus, I assumed that it should only occur once during setup—before receiving note information. Let’s set that assumption aside for a moment and see what happens if we reassign voices on the fly.
In this new model, we associate our instrument patches with MIDI channels. When we receive a note, we choose a YM3812 channel using our normal polyphonic algorithm. Then, we can upload the associated patch information to the channel, and finally play the note.
There are several key advantages to this algorithm:
It supports different instruments for every MIDI channel. While it’s true that only nine notes can be played at once, those notes could come from any number of different instruments. By associating a patch with each MIDI channel, we can support a different instrument for each of the 16 MIDI channels.
It supports up to nine-voice polyphony: Even with 16 different instruments, each instrument can play up to 9 notes—just not all at once. We don’t have to decide up front how much polyphony each instrument gets.
We never need to know what’s on the YM3812: Because we keep overwriting the sound properties on the chip, we never really need to know what’s on it. Thus, we don’t need to track all of the register values, and that saves a good bit of RAM.
Testing Reassignment Speed
But what about the disadvantage? Can we really upload a new patch every time we play a note?
Measuring Time to Upload a Patch and Play a Note
Here I played 5 notes and monitored the debug LED that flashes during the SendData command. Each horizontal division represents 1 millisecond. So, each note takes approximately 1ms to send. During my experimentation, I really tried to play all 5 notes at exactly the same time. And yet, as you can see, there is still a gap between the 4th and 5th notes. Clearly, human time just isn’t the same as computer time.
Visualizing the Worst Case Scenario—9ms
With this in mind, let’s look at the worst possible case of playing 9 notes at the same time. It takes 9ms to update every patch on the YM3812 and turn the notes on. At 120BPM, that’s about 7% of a 16th note. Math aside, if there’s a delay, I honestly can’t seem to notice it.
YM3812 Library Updates
Sending all of the patch data at once, requires a fundamental rearchitecting of our YM3812 class.
Replacing Register Functions
First, we replace our Channel and Operator register functions with a single “chSendPatch” function. Yes, this change eliminates a lot of the work we’ve put into the module to date. But, it also provides us with a TON of flexibility. Whether you code the chSendPatch to interface with a YM3812 or a YM2151, you call the function the same way. You pass the same patch object, and use the same 0..127 ranges for all properties.
Let’s have a look at how the chSendPatch function works:
This function takes our generic patch and repackages all of the properties into YM3812 register values. Then, it takes those register bytes and uploads them to the chip. To repackage those properties, this function:
Scales the property from the 0..127 range to the range appropriate for the setting by right shifting the value
Aligns the new value to the correct bits in the register map by left-shifting
ORs together the values that go into the same register byte
Uploads the combined byte to the YM3812 using the sendData function
If you need to brush up on how the formulas above calculate the register addresses, take a look at article #3 of the series.
Calculating the index of each setting in the patch works just like we discussed earlier in the article. We used a named index like, “PATCH_FEEDBACK” to locate the correct setting in the patch array. And for operator settings, we add on PATCH_OP_SETTINGS multiplied by the operator number we want to edit. By cycling through the operators in a for loop, we can easily calculate a patch_offset and just add it onto the name of the setting we want to edit.
This looping structure scales up nicely for the YM2151 that has four operators. You just change the loop to go from 0..4 instead of 0..2. In fact, take a look at the same function written for the YM2151. Pretty similar, eh?
According to our new algorithm we now upload patch information to the YM3812 every time we play a note. So we need to swap our noteOn and noteOff functions with new versions called patchNoteOn and patchNoteOff. Unlike the noteOn function—which takes only a MIDI note number to play—patchNoteOn will take both a MIDI note number and a reference to a PatchArr object.
void YM3812::patchNoteOn( PatchArr &patch, uint8_t midiNote ){
last_channel = chGetNext();
channel_states[ last_channel ].pPatch = &patch; // Store pointer to the patch
channel_states[ last_channel ].midi_note = midiNote; // Store midi note associated with the channel
channel_states[ last_channel ].note_state = true; // Indicate that the note is turned on
channel_states[ last_channel ].state_changed = millis(); // save the time that the note was turned on
chPlayNote( last_channel, midiNote ); // Play the note on the correct YM3812 channel
}
This function works almost identically to the original noteOn function, but now saves a pointer to the patch data in the channel_states array. If you recall from the last article on polyphony, the channel_states array tracks the status of YM3812 channels to determine which to use for every incoming note. By saving a pointer to the patch, we not only know what data to upload to the YM3812, also which instrument is playing on each channel. This becomes important when we go to turn the note off:
void YM3812::patchNoteOff( PatchArr &patch, uint8_t midiNote ){
for( uint8_t ch = 0; ch<num_channels; ch++ ){
if( channel_states[ch].pPatch == &patch ){
if( channel_states[ch].midi_note == midiNote ){
channel_states[ ch ].state_changed = millis(); // Save the time that the state changed
channel_states[ ch ].note_state = false; // Indicate that the note is currently off
regKeyOn( ch, 0 ); // Turn off any channels associated with the midiNote
}
}
}
}
The patchNoteOff function also works mostly like the noteOff function. Both loop through all of the channels in the channel_states array looking for a matching note that’s currently turned on. But now, in patchNoteOff, the note has to both be turned on AND associated with the same patch. Otherwise, if two instruments played the same note, then one instrument would turn them both off. We check this by validating that the patch pointer stored in the channel_states array points to the same patch that was passed to the patchNoteOff function.
Oh, this is probably a good time to mention that I updated the YM_Channel structure to include a PatchArr pointer. Remember, the channel_states array is composed YM_Channels:
struct YM_Channel{
PatchArr *pPatch = NULL; // Pointer to the patch playing on the channel
uint8_t midi_note = 0; // The pitch of the note associated with the channel
bool note_state = false; // Whether the note is on (true) or off (false)
unsigned long state_changed; // The time that the note state changed (millis)
};
I also moved this data structure into the YMDefs.h file. This file now includes all of the functions, definitions and macros that aren’t specific to the YM3812. Structuring things this way allows us to create separate library files for each sound chip without interdependence on each other. I also moved the SET_BITS and GET_BITS macro as well.
Updating chPlayNote
In order to send the patch up to the YM3812, we need to do more than just attach it to the channel_states array, we need to call the chSendPatch function. And we need to call that function at that moment where the note is turned off, and we are updating the frequency properties before turning the note back on. This occurs in the chPlayNote function:
void YM3812::chPlayNote( uint8_t ch, uint8_t midiNote ){ // Play a note on channel ch with pitch midiNote
regKeyOn( ch, 0 ); // Turn off the channel if it is on
if( midiNote > 114 ) return; // Note is out of range, so return
chSendPatch( ch, *channel_states[ch].pPatch ); // Send the patch to the YM3812
if( midiNote < 19 ){ // Note is at the bottom of range
regFrqBlock( ch, 0 ); // So use block zero
regFrqFnum( ch, FRQ_SCALE[midiNote] ); // and pull the value from the s
} else { // If in the normal range
regFrqBlock( ch, (midiNote - 19) / 12 ); // Increment block every 12 notes from zero to 7
regFrqFnum( ch, FRQ_SCALE[((midiNote - 19) % 12) + 19] ); // Increment F-Num from 19 through 30 over and over
}
regKeyOn( ch, 1 ); // Turn the channel back on
}
I only added one line to this function that calls chSendPatch after turning off the note. Keep in mind the chSendPatch function expects the patch to be passed by reference, not as a pointer to the patch. So we need to dereference pPatch using an asterisk. Now this function will turn off the note, upload the patch, set the frequency and then turn it back on.
New Functions
In addition to updating the noteOn and noteOff functions, we can add some new ones as well. The patchAllOff function looks for any channels playing a specific patch and then turns them off:
void YM3812::patchAllOff( PatchArr &patch ){
for( uint8_t ch = 0; ch<num_channels; ch++ ){
if( channel_states[ch].pPatch == &patch ){
channel_states[ ch ].state_changed = millis(); // Save the time that the state changed
channel_states[ ch ].note_state = false; // Indicate that the note is currently off
regKeyOn( ch, 0 ); // Turn off any channels associated with the midiNote
}
}
}
The patchUpdate function takes a patch and then updates the settings of any channels playing that patch. This becomes very useful when we start modifying properties of the patch. Using this function, if you can change a patch property generically (using a 0-127 range) and then re-upload the patch to any relevant channels all at once.
void YM3812::patchUpdate( PatchArr &patch ){ // Update the patch data of any active channels assocaited with the patch
for( byte ch = 0; ch < num_channels; ch++ ){ // Loop through each channel
if( channel_states[ch].pPatch == &patch ) chSendPatch( ch, patch ); // If the channel uses the patch, update the patch data on the chip
}
}
The New YM3812 Library
Since we have edited so much, let’s clean up our revised YM3812 library structure:
If you draw an imaginary line under the new patch functions, you can see which functions require an understanding of how the YM3812 works and the ones that are fully generic. With this new structure, we can not only turn a note on and off, but adjust the sound settings as well. We only need to know the structure of a generic patch, and we are good to go.
It’s also worth noting that, of the functions below the line, most of the details on how the sound chip works are confined to the chSendPatch function. By altering this function, we can quickly rebuild the library for any number of sound processors.
Creating Instruments
We sure have talked a lot about patches. Patch structures… Patch algorithms… Generic patches… but um… how do we make the patches themselves? Well, this turns out to be a pretty well solved problem. The YM3812 has been around for a long time, and as such there are various patch libraries available. Many of these libraries even follow the General MIDI standard. General MIDI provides a naming convention for patches while leaving the nature of the sound up to manufacturers. The naming convention defines 128 different instrument names broken into groups of eight:
Editing Sounds Patches
For my purposes, I found a library containing the old DOOM sound patches, but if you are interested in editing them or creating your own, I highly recommend the OPL3 Bank Editor project:
This open source project lets you create and preview 2-operator, 4-operator and even dual 2-operator sounds. You can build up a library of these patches and then export them into a variety of formats.
Instruments.h
In order to use these sounds properties in our module, we need to import all of the information. I took the easy way by creating an instruments.h file that compiles directly into the code. To create this file, start by exporting a patch library from the OPL3 Bank Editor into a DMX .op2 format file. Then you can use a NodeJS program I wrote to generate the instruments.h file. You can find this converter utility in the InstrumentConvert folder on the GitHub for this article. To use it, just put your GENMIDI.op2 file in the same directory and type:
node op2.js GENMIDI.op2 instruments.h
This will create an instruments.h file that you can copy into your Arduino folder. If you peek into this file, you will find a ton of code. But, it really boils down to a couple of things:
patches[] – Contains all 128 instrument (and 47 drum) patch definitions in our generic patch format
patchNames[] – Contains character arrays for the names of every patch
NUM_MELODIC – Defines the number melodic patches (128)
NUM_DRUMS – Defines the number of drum patches (47)
PROGMEM
If you look closely at the instruments.h file, you will notice the PROGMEM keyword used in the definition of each patch array and every patch label. Here is an excerpt:
const unsigned char ym_patch_0[78] PROGMEM = {0x22,0x00,0x00,0x00,0x00,0x00,0x60,0x00,0x00,0x00,0x40,0x34,0x40,0x40,0x40,0x70,0x08,0x28,0x00,0x18,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x20,0x02,0x00,0x40,0x40,0x78,0x08,0x78,0x00,0x20,0x00,0x00,0x08,0x00,0x00,0x00,0x00,0x40,0x48,0x00,0x40,0x40,0x78,0x00,0x78,0x00,0x18,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x14,0x00,0x40,0x40,0x78,0x08,0x78,0x00,0x20,0x00,0x00,0x08,0x00,0x00,0x00,0x00}; // Acoustic Grand Piano
const unsigned char ym_patch_1[78] PROGMEM = {0x22,0x00,0x00,0x00,0x00,0x00,0x60,0x00,0x00,0x00,0x40,0x24,0x20,0x40,0x40,0x78,0x00,0x78,0x00,0x18,0x00,0x00,0x08,0x00,0x00,0x00,0x00,0x20,0x00,0x00,0x40,0x40,0x78,0x08,0x78,0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x78,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x78,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; // Bright Acoustic Piano
const unsigned char ym_patch_2[78] PROGMEM = {0x23,0x00,0x00,0x00,0x00,0x00,0x40,0x00,0x00,0x00,0x60,0x34,0x00,0x40,0x40,0x70,0x08,0x58,0x00,0x18,0x00,0x00,0x08,0x00,0x00,0x00,0x00,0x00,0x0E,0x00,0x40,0x40,0x78,0x08,0x78,0x00,0x20,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x04,0x00,0x40,0x40,0x78,0x08,0x58,0x00,0x18,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x40,0x0E,0x00,0x40,0x00,0x78,0x10,0x08,0x00,0x20,0x00,0x00,0x08,0x00,0x00,0x00,0x00}; // Electric Grand Piano
const unsigned char ym_patch_3[78] PROGMEM = {0x23,0x00,0x00,0x00,0x00,0x00,0x60,0x00,0x00,0x00,0x40,0x00,0x40,0x40,0x40,0x78,0x50,0x38,0x00,0x18,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x20,0x08,0x00,0x40,0x00,0x68,0x08,0x78,0x00,0x20,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x40,0x00,0x40,0x40,0x40,0x78,0x50,0x38,0x00,0x18,0x00,0x00,0x00,0x50,0x00,0x00,0x00,0x20,0x08,0x00,0x40,0x00,0x68,0x08,0x78,0x00,0x20,0x00,0x00,0x18,0x00,0x00,0x00,0x00}; // Honky-tonk Piano
The AVR microcontrollers (and well, a whole lot of microcontrollers) use a Harvard Architecture that splits memory up between Program Memory and RAM. Code resides in Program Memory, while variables (and things that change) reside in RAM.
Microcontrollers typically have far more Program Memory, so it’s useful to store large data constructs in PROGMEM and only load them into RAM when we need to. Our patches array consumes ~13.5k and the patchNames array consumes another 2k. Keeping this in RAM would consume nearly all of it, so instead I’ve put it in PROGMEM. Just keep this in mind, because we will need to write a function to move things from PROGMEM into RAM.
Using the Library in our .ino file
With our fancy new YM3812 library in hand, it’s time to use it in our main program! Let’s go over what we want this program to do:
Define and manage instruments for every MIDI channel
Manage MIDI events and play each note with the correct patch
Listen for MIDI Program Change events that assign patches to channels
Managing MIDI Instruments
Conceptually, we want assign a different patch to each MIDI channel. Those patches come from the patches array defined in the instruments.h file. So, we need to add a couple of lines of code to track this in our .ino file:
#define MAX_INSTRUMENTS 16 // Total MIDI instruments to support (one per midi channel)
uint8_t inst_patch_index[ MAX_INSTRUMENTS ]; // Contains index of the patch used for each midi instrument / channel
The inst_patch_index array contains the indexes of the active patches in our patches array. The array contains 16 elements, one for each MIDI channel. Additionally, we need a data structure in RAM to store the PROGMEM patch information. I called this, inst_patch_data:
PatchArr inst_patch_data[ MAX_INSTRUMENTS ]; // Contains one patch per instrument
Finally, we need a function that loads PROGMEM patch data from the patches array into this inst_patch_data structure:
void loadPatchFromProgMem( byte instIndex, byte patchIndex ){ // Load patch data from program memory into inst_patch_data array
for( byte i=0; i<PATCH_SIZE; i++ ){ // Loop through instrument data
inst_patch_data[instIndex][i] = pgm_read_byte_near( patches[patchIndex]+i ); // Copy each byte into ram_data
}
}
This function takes an instrument index—a.k.a. the MIDI channel to associate with the patch—and the index of the patch from the patches array.
MIDI Event Handlers
In the articles so far, our program only needed two event handler functions—one for note on and one for note off. We still need these two functions, but now we need to adjust them to send patch information:
void handleNoteOn( byte channel, byte midiNote, byte velocity ){ // Handle MIDI Note On Events
uint8_t ch = channel - 1; // Convert to 0-indexed from MIDI's 1-indexed channel nonsense
PROC_YM3812.patchNoteOn( inst_patch_data[ch], midiNote ); // Pass the patch information for the channel and note to the YM3812
}
void handleNoteOff( byte channel, byte midiNote, byte velocity ){ // Handle MIDI Note Off Events
uint8_t ch = channel - 1; // Convert to 0-indexed from MIDI's 1-indexed channel nonsense
PROC_YM3812.patchNoteOff( inst_patch_data[ch], midiNote ); // Pass the patch information for the channel and note to the YM3812
}
Because we have all of the patch data contained in the inst_patch_data array, all we need to do is pass the patch data associated with the MIDI channel. Note, MIDI channels run from 1..16, not 0..15. So, we have to subtract one in order for it to be zero-indexed.
Next, to allow the user to swap patches via MIDI commands, we need to introduce a new event handler called, handleProgramChange.
void handleProgramChange( byte channel, byte patchIndex ){
uint8_t ch = channel-1; // Convert to 0-indexed from MIDI's 1-indexed channel nonsense
inst_patch_index[ch] = patchIndex; // Store the patch index
loadPatchFromProgMem( ch, inst_patch_index[ch] ); // Load the patch from progmem into regular memory
}
This event runs every time a MIDI device indicates that it wants to select a different patch. To do that, we first update the inst_patch_index array entry associated with the MIDI channel to point to the new patch index. Then, we run the loadPatchFromProgMem function, to copy the patch data from the patches array into RAM. From then on, any note played on this MIDI channel, will use this new patch.
The Setup Function
In all of the previous articles, the setup function loaded all of the sound settings individually into the YM3812. Now, we have patches to do that, and don’t need any of that noise. So if you compare our new setup function to the old one, it looks a whole lot shorter:
void setup(void) {
PROC_YM3812.reset();
// Initialize Patches
for( byte i=0; i<MAX_INSTRUMENTS; i++ ){ // Load patch data from channel_patches into the
inst_patch_index[i] = i; // By default, use a different patch for each midi channel
loadPatchFromProgMem( i, inst_patch_index[i] ); // instruments array (inst_patch_data)
}
//MIDI Setup
MIDI.setHandleNoteOn( handleNoteOn ); // Setup Note-on Handler function
MIDI.setHandleNoteOff( handleNoteOff ); // Setup Note-off Handler function
MIDI.setHandleProgramChange( handleProgramChange ); // Setup Program Change Handler function
MIDI.begin(); // Start listening for incoming MIDI
}
That said, we DO need to load an initial set of default patches for each of the MIDI channels from PROGMEM into RAM. Being lazy, I just used a for loop to load them sequentially, so MIDI channel 1 aligns to patch 0, channel 2 to patch 1, and so on.
Finally, we need to call the setHandleProgramChange function from the MIDI library to connect our handleProgramChange function… and that’s it!
Conclusion?
Well, that was a lot of words. I hope they added at least a little more clarity than confusion. But if not, maybe try looking at the code all together on GitHub.
This was a pretty pivotal moment during the development of this module. I can still remember ruthlessly deleting broad swaths of code hoping for the best. And thankfully, everything kind of clicked into place. By abstracting all of the settings into generic patches, we now have the foundation for multi-sound chip modules. And, by loading patches with every new note, we can even implement percussion mode… which will be our topic of discussion in the next article.
If you’ve followed the series so far, welcome back! In the last article, we added MIDI control to our YM3812 module. This allows us to play music by sending signals from a computer, keyboard or any other midi source. Still, you can only play one note at a time, and there is a pretty annoying bug. If you hold a note down, play another note, and then release the first note, both notes turn off. This makes playing legato scales sound terrible. Here, have a listen:
Demonstration of the legato bug
Fixing the Legato Issue
As it turns out, fixing this legato issue brings us one step closer to a polyphony, so let’s do it!
Animated demonstration of the legato bug
The problem stems from our our handleNote functions. The handleNoteOn function looks for any incoming note-on commands from the MIDI input. It then updates channel 0 of the YM3812 to play that note—even if another note is playing. But then when it receives ANY note off function, it turns channel zero off. To fix this, we need to add a noteOff function that only turns off the channel if the released key matches the last key we turned on. This way releasing a key other than the one you last played won’t turn off the current note.
To start, let’s add a variable—channel_note—that keeps track of the last played note:
uint8_t channel_note = 0;
Then, in the play note function, we can update this variable to the current note being played:
We also need to add a new function called noteOff to our library. This function turns off the the keyOn register for the channel by setting it to false. But first, it checks that the MIDI note to turn off is the same as the last one played.
Now that we’ve fixed the legato issue, let’s find a way to use more than one channel on the YM3812. For our first attempt, we will simply select a new channel with each new note. This will effectively “rotate” through each YM3812 channel. While the YM3812 can play up to nine notes simultaneously, for demonstration purposes, let’s pretend there are only 3. Don’t worry, we will scale back up at the end.
Take a look at a quick demonstration of the algorithm:
Animated demonstration of the Rotate Algorithm
With this algorithm, we rotate through each channel every time we play a new note. For example, to play a C chord, we play the C on channel 1, the E on channel 2, and the G on channel 3. Playing a fourth note just rotates back to the beginning and overwrites the first channel.
Implementing the Rotate Algorithm
We will implement this algorithm into a new noteOn function that only needs the note you want to play. This function will internally manage all of the logic that decides which channel to use. As such, we can update the handleNoteOn function to call this one instead of chPlayNote:
We also need to replace the channel_note integer with an array of integers. This way we can track the notes associated with every channel. I called it channel_notes 🤯. Again, we can assume there are only three channels for demonstration purposes. And, we can keep track of that assumption in a num_channels variable.
Finally, we need a variable to track which channel we played as we rotate through them. We can call it last_channel. With these variables defined, we can write the implementations for our note functions:
The noteOn function increments the last_channel variable. If this index hits the maximum (as defined by num_channels) then the modulus operator just rotates it back to zero. The next line of code saves the MIDI note number into the channel_notes array at the new index. And finally, uses the chPlayNote function to turn the channel on.
The noteOff function compares each of the values stored in the channel_notes array to midiNote. If the current channel matches midiNote, then it turns that channel off using the regKeyOn function. With these changes in place, let’s see how it works:
A demonstration of the “rotate” polyphonic algorithm
The Trouble With Rotation
Well, that definitely created polyphony. But it also introduced some unexpected behavior. As you can see in the video above, if you hold two notes and press a third note repeatedly, then the held notes eventually stop playing. Let’s take a look at the algorithm to understand why this happens.
Animated demonstration of the rotation algorithm’s downfall
Here, we play the three notes of our C chord: C E G. But then, we let go of the E. Now when we add a new note, it still overwrites the lower C even though there is an unused channel. This happens because we are just blindly rotating through channels. Clearly this algorithm has some shortfalls. Let’s see if we can make it better.
Smart Polyphonic Algorithm
Animated demonstration of the smart polyphonic algorithm
Just like before, we play the C, E, G of the C chord. But this time we also keep track of when each key turns on. Then when we let go of the E, we turn it off and track when we turned it off. Finally, when we turn on the high C, we search for the track that has been off the longest and overwrite it.
Why the longest? Well, when you press a key, you trigger the envelope for that note. You get the attack and decay, and then the note stays on until you let go. At that point, the note starts its release cycle. To keep things sounding natural, we want the release cycle to go on as long as possible. To allow for this, we always overwrite the note that has been off the longest.
Animated demonstration of overwriting notes
What happens when all of the notes are still turned on? Well, no big surprise here, you overwrite the note that has been on the longest. Ok! we have the rules. Let’s figure out how to implement this.
Implementing Smart Polyphony
In the rotation algorithm, we only kept track of the the note assigned to each channel. For this algorithm, we need to track a bit more information. To do that, we are going to use a “struct” data structure. Structures are just collections of variables. Our structure—which we will call YM_Channel—contains three variables:
struct YM_Channel{
uint8_t midi_note = 0; // The pitch of the note
bool note_state = false; // Whether the note is on (true) or off (false)
unsigned long state_changed; // The time that the note state changed (millis)
};
Because structures work like other data types, we can adjust the channel_notes array to be an array of YM_Channel objects instead of integers. We’d better rename it to something more generic too. How about channel_states.
This might be the most complex function yet! But we can break it down into three parts.
In the first part, we create a set of variables. These variables track the channel that has been on the longest and off the longest. The variables on_channel and off_channel store the index of the channel, while oldest_on_time and oldest_off_time store the time that their state changed. Note, that we set on_channel and off_channel to 0xFF by default. Obviously there isn’t a 255th channel on the YM3812, so this can’t possibly reference a valid channel. But if we get through the second step without changing the variable then its value will remain at 0xFF. And that will be helpful in step three. (Just go with it…)
In the second step, we loop through each of the channels. If the note_state is on, we check if the state_changed time stamp for that channel is less than oldest_on_time. If it is, then we set on_channel to be this index (ch) and oldest_on_time to be this channel’s state_changed time. In this way, by the time we get to the end of the loop, oldest_on_time will contain the state_changed time of the channel that has been playing the longest. And on_channel will point to that channel’s index. If note_state is false, then we do the same thing, but track the oldest_off_time and off_channel instead.
In step three, we decide whether to return on_channel or off_channel. If we made it through step two and off_channel equals 0xFF, then all of the channels are in use. But if off_channel is less than 0xFF then at least one channel must currently be turned off. So, we return off_channel—which contains the index of the note that’s been off the longest. Otherwise, if no notes are currently off, then we return on_channel—which contains the note that’s been on the longest.
Writing that out felt like writing a Dr. Seuss poem 🤯—hopefully it wasn’t too confusing
NoteOn/Off Function Tweaks
While we definitely buried most of the complexity into the chGetNext function, there are still a few other changes to make.
In the noteOn function, we use chGetNext to update last_channel (instead of rotating through channel numbers). But then, instead of just saving the MIDI note number, we update all three variables associated with the channel. We set midi_note to the currently playing midi note. We set note_state to true (since it is now playing). Finally, we set state_changed to the current time (in milliseconds).
We also need to make similar updates to the noteOff function, saving the state_change time and setting note_state to false.
That should do it! Give it a compile and see if it works. Here is a demo of how things turned out on my end:
Demonstration of a smarter polyphonic algorithm
Going FULL Polyphonic
Now that we have our “smart” algorithm working, let’s update the number of channels. We want to take full advantage of the YM3812’s 9-voice polyphony! All you need to do is update these lines of code in the .h file to point to the YM3812_NUM_CHANNELS constant:
And with that, you should have a note for (almost) every finger!
Wrap Up & Links
Hopefully the code snippets above provided enough clues to get you program working. But if not, you can find the full files on gitHub. I included a separate folder for the updated monosynth algorithm, the rotate algorithm and the “smart” algorithm:
In the next article, we will move on from MIDI and tackle sound patches. Just like polyphony, patches will create another substantial step forward. But to make that leap forward, we are going to take a big step back too.
If you run into any trouble, feel free to leave a note in the comments below!
If you followed along through last three posts, then hopefully you have a Yamaha YM3812 OPL Sound Chip on a breadboard playing a lovely F Major 7th chord over and over and over 😅. Or maybe, the annoying repetition drove you to experiment and find new patterns. It’s a great proof of concept that ensures the hardware and software work, but not exactly an instrument. Today, we are going to fix that by implementing MIDI. This way, you can use any external musical input device or even your computer to play music—not just sound. And that, to me, makes it an instrument.
The Hardware
MIDI Out and MIDI In Schematics
Building hardware that interfaces MIDI with a microcontroller requires only a handful of components and is actually pretty simple. Fundamentally, the schematic just passes a serial TTL logic signal from the Tx pin of one microcontroller to the Rx pin of another. The real magic is that this circuit fully isolates the sender from the receiver. They don’t even need a common ground! Let’s see how.
Input & Output Combined
Schematic of MIDI Out & In connected through a cable
If you connect these two circuits through a MIDI cable, you get the schematic above. An LED inside the 6n138 optocoupler signals a light sensitive transistor. When the LED turns on, the transistor connects pins 5 and 6 of the optocoupler and allows electricity to pass through. When the LED turns off, that connection breaks. The 220 ohm pull-up resistor ensures that pin 6 stays high whenever the LED is off, and low when the LED turns on.
You can think of the other half of the circuit as an LED on an extension chord. 5v flows through two 220 ohm resistors before going through the LED inside of the 6n138. It then flows back through another 220 ohm resistors and into the Tx pin of the microcontroller. When the Tx pin goes high, both sides of the LED are at 5v and no current can pass. But when Tx goes low, the current sinks into the microcontroller and the LED turns on. Turning the LED on closes the connection of pins 5 and 6 on the 6n138 which shorts the Rx line to ground. This way when Tx goes low, Rx goes low—and visa versa.
Also take note that the ground connection that passes into the MIDI cable does not connect to the receiving circuit. This shields the cable from interference, but also keeps both sides fully isolated.
On another note, the placement of the resistors ensures that foul play by either device won’t hurt the microcontroller or the optocoupler. The extra diode ensures that even connecting these pins backwards won’t cause any damage to the 6n138.
MIDI TRS Jacks
If you are familiar with MIDI devices, then you may be wondering why I used a TRS (tip/ring/sleeve) jack instead of the traditional 5-pin DIN connector. And well, I can think of two good reasons: size and cost. Ultimately, I plan to create a set of patchable EuroRack midi devices that allow patching of MIDI signals just like CV or Audio. In this context, 5-pin DIN connectors (which can cost multiple dollars each) and their associated MIDI cables become prohibitively expensive—especially in comparison to TRS jacks and stereo patch cables. Not to mention that a patchwork of MIDI cables would become far too bulky for a EuroRack setup.
Of course, I am far from the first person to decide this, and DIN to TRS converters abound. So I purchased one used by big name brands like Korg and Akai. The circuits above are based on the pinout of these adapters. Later, I added a beat step pro to the mix.
The Beatstep Pro uses TRS jacks straight out of the box, so I happily connected my device with a stereo patch cable… and it didn’t work. This is because Arturio inverts the connection of the tip and ring. Of course you could just as easily say that Korg inverts the tip and the ring connections, because it all depends on your frame of reference. There is no fully standard way to convert between a DIN MIDI connector and a TRS jack. And now I had one Korg converter and two Arturio converters… what to do…
TRS Input Switchable Module
Midi In schematic with Switch
Well, this is easy… Just add a switch and then you can swap from one standard to the other! Besides, like I said earlier, that diode prevents anything bad from happening if you connect things backwards. After building this circuit dozens of times I finally just built a small module that plugs right into a breadboard. This makes things way more compact and simple:
MIDI Breakout Board
You can find all of the the breakout board schematics and PCB files on GitHub in the Article 4 folder. I even included a zip file with a penalized layout that you can upload to your favorite PCB manufacturer and print out for dirt cheap. I think I got 20 copies for $2 USD through JLCPCB—though shipping probably cost 10x that.
Breadboard Schematic
Anyway, how ever you build the circuit out, just hook up the Rx pin of the MIDI input to the Rx pin on the AVR128DA28. Now, we should be good to get to the software. Here is the full schematic for reference:
Schematic: YM3812 controlled by AVR128DA28 and now MIDI
Frequency Nonsense
Before getting into the code that plays a MIDI note, we first need to understand how the YM3812 encodes frequencies. Looking at the register options, two settings control the pitch of a channel: FNum and Block. Note: F-Number splits across two 8-bit register bytes to form a 10-bit number. Block, on the other hand, uses only a 3-bit number.
According to the data sheet, the note frequency (fmus) connects to the F-Number and Block through the formula:
Frequency formula for the YM3812 (from the data sheet)
Note: “fsam” (or sample frequency) equals the master clock frequency (3.6 MHz) divided by 72.
Calculating F-Numbers
Now you may be asking yourself, why do we need more than one variable to select a frequency? And to figure that out, let’s try calculating the F-Num for every MIDI note’s frequency and every for block. To do this, I created a frequency number calculator. Feel free to open that up in another window and play along!
Excerpt from the F-Num Frequency Calculations table
A couple of interesting insights become apparent when we do this.
Depending on the block value used, some of the F-Numbers (shown in grey) end up being too large to represent using a 10-bit number. 10-bit numbers max out at 1023.
Even with the maximum, you can still represent some frequencies with more than one block value / F-Num combination.
Looking at the highest possible values in each column, they seem to follow a pattern: 970, 916, 864, etc. And comparing each instance of those numbers to the note name on the left, those numbers appear to be an octave apart. For example, 970 in block 3 represents F#4 where 970 in block 2 represents F#3. This seems to indicate that blocks are one octave apart.
Now the question remains: knowing we can represent a frequency with multiple block/f-num combinations, which block should we use? To figure that out, let’s use our newly calculated F-Numbers and block values in reverse to calculate the original note frequency.
Rounded Frequencies
Recalculated frequencies
Now things are getting interesting. Some of the newly calculated frequencies are way off—especially for higher block numbers. Because F-numbers have no decimal component, they will always be somewhat inaccurate. Moreover, the lower the F-number, the more the inaccurate the frequency becomes. Take a look at the bottom of the table:
Yikes, that’s a lot of red! Even block 0 does a mediocre job of representing the midi note. Still, A0, which is the lowest note on the piano is MIDI note 21… so I suppose that’s not THAT bad… Everything below that is going to be difficult to hear anyway.
Let’s take this further and come up with a few general rules for choosing blocks and F-numbers. Namely:
We always want to use the lowest possible block value and thus the highest—and most in-tune—F-numbers
The note frequencies follow the same pattern across each block separated by 12 notes (one octave)
Building an Algorithm
With this in mind, I exported the top 31 F-Numbers into an array and lined them up with the midi note scale. Also, I recalculated the F-Numbers for a 3.57954 MHz crystal (instead of 3.6 MHz). You can do this by adjusting the master clock frequency in the calculator tool.
Midi Notes Aligned to an Array of Calculated F-Numbers
Now, with our array lined up based on block number, you can start to see the pattern. The first 18 midi notes use block zero—it’s the only block that can reach those frequencies. Also, for the first 18 notes, the index of F-number in our array aligns to the midi note number. So, midi note 0 aligns with frequency array index 0, and so-forth.
Then, beginning at midi note 19, the block number starts at 0 and, every 12 notes, increments by 1. This way, when we get to midi note 31, we start using block 1. And when we get to midi note 43, we use block 2, etc. Because block is an integer, you can represent this behavior by the formula:
Block = (MidiNote - 19) / 12
To select the appropriate index from our F-Number array, we need to rotate through the top 12 values. We start at index 19, count to 30, and repeat from 19 again. You can represent this using the following formula:
FNumIndex = ((MidiNote - 19) % 12) + 19
Both of these formulas work great until we hit midi note 115. At that point, we’d need a block value of 8—which is more than a 3-bit number can represent. Honestly though… that’s REALLY high so, I think we will be fine.
The Software
OK, enough yapping about theory. Let’s implement this into our YM3812 library.
Play Note Function
First up, we need to add the frequency array into our .h file:
As well as a definition for the play note function:
void chPlayNote( uint8_t ch, uint8_t midiNote );
Here the function takes a YM3812 channel (ch) to play the note on as well as the midi note pitch to play (midiNote).
void YM3812::chPlayNote( uint8_t ch, uint8_t midiNote ){
regKeyOn( ch, 0 ); // Turn off the channel if it is on
if( midiNote > 114 ) return; // Note is out of range, so return
if( midiNote < 19 ){ // Note is at the bottom of range
regFrqBlock( ch, 0 ); // So use block zero
regFrqFnum( ch, FRQ_SCALE[midiNote] ); // and pull the value from the s
} else { // If in the normal range
regFrqBlock( ch, (midiNote - 19) / 12 ); // Increment block every 12 notes
regFrqFnum( ch, FRQ_SCALE[((midiNote - 19) % 12) + 19] ); // Increment F-Num
}
regKeyOn( ch, 1 ); // Turn the channel back on
}
The algorithm of the function first turns off the channel we want to update and then checks to see if the midi note resides in the range of valid YM3812 pitches. If not, the function quits.
For valid notes, under midi note 19, we follow our algorithm and use block zero with the index of the F-Number associated directly with the midi note. For midi notes 19 and over, we use the formulas for block and F-Number index that we determined earlier.
Finally, after setting the frequency, we just turn on the channel to make it start playing the note.
Installing the MIDI Library
Now that we have a way to play notes using a midi note number, we need to update our .ino file to listen for midi events and then trigger the play note function.
First things first, we need to install the MIDI Library by Francois Best. You can find it using the Arduino IDE’s library manager:
Francois Best’s MIDI Library in the Library Manager
If you are having trouble finding the library, select “Communication” in the Topic dropdown. Apparently “midi” appears in the middle of lots of words, so you will likely get a ton of random results.
Now that we have the library installed, include it at the top of the .ino file:
#include <MIDI.h>
Next, we need to create an instance of the MIDI object and tell it to listen to the correct Serial port (Serial2 in our case).
Both of these functions receive the same arguments:
channel: The MIDI channel of the incoming note
midiNote: The pitch number of the note
velocity: The volume of the note (for touch sensitivity)
In the handleNoteOn function, we simply use our play note function and pass the midi note number. For now, let’s keep this a mono-synth and play everything on channel zero of the YM3812.
In the handleNoteOff function, we (rather unsophisticatedly) tell channel zero to stop playing. There are a few issues with this, but we will address those when we make this thing polyphonic.
For these two functions to do anything, we need to register them with the MIDI Library. We do this by adding a few lines to the setup() function:
setHandleNoteOn and setHandleNoteOff connect the functions we wrote above to the MIDI object. This way the MIDI object can run our function whenever it detects the associated event.
The begin function kicks things into gear and tells the MIDI library to start listening.
Listening for Events
Now that we have things set up, we need to regularly prompt the MIDI object to check for new information. We do this by modifying our loop function. If you still have all of the code from article 3, you can delete all of that and update the function to look like this:
void loop() {
while( MIDI.read(0) ){}
}
The read function checks for any events related to the MIDI channel passed to it. If you pass a zero (as we did above) then it reacts to ALL incoming MIDI channels. When the read function detects an event, it initiates the associated handler function to manage that event and then returns true. If it does not detect an event it returns false. This way, by calling the function in a while loop, we ensure that we manage all captured events before moving on.
And with that, compile this puppy and send it up to the micro. With any luck, you should be able to control your YM3812 with an external MIDI keyboard, computer or really any midi device!
Some Things to Try
Once you have things up and running, try playing a note and then another note and then quickly release the first one. The issue becomes especially apparent if you set the properties of the voice to be more organ like. Take a look at the note-off function and see if there is something we can tweak to make things a bit more reliable.
A Few Final Thoughts
Having the ability to receive a midi signal and then play it on our YM3812 represents kind of a milestone. We now have something that functions as a playable instrument—even if that instrument has rather limited capabilities. Still, this was a necessary first step that lays a solid foundation for what’s to come. And in the next article, we will considerably enhance the capabilities of our device through polyphony. I guarantee it will become far more playable.
In the last article, we built a simple circuit to get our YM3812 up and running on a breadboard. To do that, we used code from the GitHub repo and just kind of glossed over how the it works. In this article, let’s delve into the sea of complexity that is c++ programming. Obligatory disclaimer: c++ is an incredibly flexible language with MANY ways of writing code from the simplest to the most enigmatic. I chose one way—not the best way, nor the simplest, nor the most elegant—just the way I thought to do it. If you have recommendations on how to improve the code, please leave a comment!
Code Structure
Code Architecture
For now, the code resides in three files—though we will add a couple more before this project concludes:
YM3812_Breadboard.ino will be our primary “program”—that is, where the setup and main loop functions reside
YM3812.h header file defines the YM3812 library that wraps the chip’s functionality into a class
YM3812.cpp provides implementations for functions defined in the header file
Inside of the YM3812 class are two different types of functions. The Chip Control Functions instruct the microcontroller to send data to (or reset) the YM3812. Their implementation depends entirely on the type of microcontroller used and how its pins are configured. The Register Functions utilize these Chip Control Functions to manipulate register settings in the YM3812. By coding things this way, if you want to use an Arduino or Teensy instead of the AVR128DA28, then you only need to change the sendData and reset functions. Everything else stays the same.
The last piece of our class is the Register Cache. Because we want to manipulate pieces of bytes in the registers at a bit level, we need to know the current state of all of the bits that we don’t want to change. The YM3812 doesn’t allow us to read registers on the chip, so instead, we have to store their current values in the microcontroller’s memory. At some point, we will discuss how to circumvent this, but that’s a few articles away.
Chip Control Functions
Let’s start with the lowest level functions that electrically manipulate the pins on the YM3812. The good news is that there are only two of them: sendData and reset.
sendData( reg, val )
If you read the first article, this should feel pretty familiar. sendData directly implements the timing diagram at the end of that article—but with one small change. “Putting data on the bus” requires sending the byte over SPI and then latching it onto the 74HC595. Let’s take a look:
//Port Bits defined for control bus:
#define YM_WR 0b00000001 // Pin 0, Port D - Write
#define YM_A0 0b00000010 // Pin 1, Port D - Differentiates between address/data
#define YM_IC 0b00000100 // Pin 2, Port D - Reset Pin
#define YM_LATCH 0b00001000 // Pin 3, Port D - Output Latch
#define YM_CS 0b00010000 // Pin 4, Port D - Left YM3812 Chip Select
...
void YM3812::sendData( uint8_t reg, uint8_t val ){
PORTD.OUTCLR = YM_CS; // Enable the chip
PORTD.OUTCLR = YM_A0; // Put chip into register select mode
SPI.transfer(reg); // Put register location onto the data bus through SPI port
PORTD.OUTSET = YM_LATCH; // Latch register location into the 74HC595
delayMicroseconds(1); // wait a tic.
PORTD.OUTCLR = YM_LATCH; // Bring latch low now that the location is latched
PORTD.OUTCLR = YM_WR; // Bring write low to begin the write cycle
delayMicroseconds(10); // Delay so chip completes write cycle
PORTD.OUTSET = YM_WR; // Bring write high
delayMicroseconds(10); // Delay until the chip is ready to continue
PORTD.OUTSET = YM_A0; // Put chip into data write mode
SPI.transfer(val); // Put value onto the data bus through SPI port
PORTD.OUTSET = YM_LATCH; // Latch the value into the 74HC595
delayMicroseconds(1); // wait a tic.
PORTD.OUTCLR = YM_LATCH; // Bring latch low now that the value is latched
PORTD.OUTCLR = YM_WR; // Bring write low to begin the write cycle
delayMicroseconds(10); // Delay so chip completes write cycle
PORTD.OUTSET = YM_WR; // Bring write high
delayMicroseconds(10); // Delay until the chip is ready to continue
PORTD.OUTSET = YM_CS; // Bring Chip Select high to disable the YM3812
}
The pin definitions at the top of the code provide a name for each pin of PORT D on the microcontroller. The sendData function then uses those definitions to directly manipulate the registers and turn on and off those pins.
If you are more accustomed to the digitalWrite() function on an Arduino, then OUTSET and OUTCLR might look a little strange. These map to registers in the AVR128DA28 microcontroller that directly manipulate the output pins. By writing a byte to OUTSET, any bits in that byte that are 1 will turn the corresponding pins on. Bits that are 0 will just be ignored. OUTCLR does the opposite. Any bits that are 1 in the byte will turn off their corresponding pins and, again, the 0s get ignored. While maybe a little less intuitive, directly manipulating these registers makes things MUCH faster.
Take note that in order for SPI.transfer() to work, you first have to run SPI.begin() at some point prior in the code. I got stumped by this bug while reducing the module code for this article. When I removed the code for the TFT screen—which also uses the SPI bus—the microcontroller locked up when it ran the SPI.transfer() function. This is because the TFT screen’s initialization function runs SPI.begin(). Once I added SPI.begin() to the YM3812’s reset function, everything worked perfectly!
void reset()
void YM3812::reset(){
SPI.begin(); // Initialize the SPI bus
//Hard Reset the YM3812
PORTD.OUTCLR = YM_IC; delay(10); // Bring Initialize / Clear line low
PORTD.OUTSET = YM_IC; delay(10); // And then high
//Clear all of our register caches
reg_01 = reg_08 = reg_BD = 0; // Clear out global settings
for( uint8_t ch = 0; ch < YM3812_NUM_CHANNELS; ch++ ){ // Loop through all of the channels
reg_A0[ch] = reg_B0[ch] = reg_C0[ch] = 0; // Set all register info to zero
}
for( uint8_t op = 0; op < YM3812_NUM_OPERATORS; op++ ){ // Loop through all of the operators
reg_20[op] = reg_40[op] = reg_60[op] = 0;
reg_80[op] = reg_E0[op] = 0; // Set all register info to zero
}
regWaveset( 1 ); // Enable all wave forms (not just sine waves)
}
The reset function does four main tasks. First it initializes the SPI bus, then it hard-resets the YM3812 by bringing its reset line low and then high. The delays ensure the chip is ready before continuing. Third, it resets the register cache variables stored in the class to zero (so they match what should be in the chip now). And finally, it sets the “Waveset” flag to allow for all waveform types.
Twiddling Bits
The register functions build upon the chip control functions and follow a very similar pattern:
Mask the new value to ensure it has the correct number of bits for the property you want to change
Look up the current value of the register containing the property
Erase the bits associated with the property that you want to overwrite
Insert the masked property value‘s bits into the register
Save the new combined register value into the register cache and send it to the YM3812
Bit manipulation turns out to be the most complicated part of this process. So let’s start with a macro that, once written, will allow us to tuck all of those details away and not worry about them any more:
SET_BITS( regVal, bitMask, offset, newVal )
This macro takes a register value (regVal) and combines a new property value (newVal) into it. The bitMask determines which of the bits from newVal to include, and offset determines where inside of regVal to insert those bits.
Looking at the left side of the chart above, we can see binary versions of newVal as well as bitMask. In this case, we want to insert a two-bit number into bits 4 and 5 of an existing register value without accidentally overwriting any of the other bits. A two-bit value should only ever have 1s in the lowest two bits, but of course, nothing prevents a developer from accidentally pushing a bigger value. So, to solve this, we use a bitMask that has 1s in the two lowest bits—the ones we want to keep—and then AND it with newVal. This way, we block out any rogue bits (like that 1 in red).
In order to insert those two bits into the current register value, we need to first set any bits we want to overwrite to zero. To create this “hole,” we shift the bitMask to the left by offset and then invert it (with the ~ operator). This puts a 1 in all of the bits we want to keep (as shown on the right side of the chart above). If we logically AND this new mask with regVal then those two bits will be zero.
Now, to insert our trimmed newVal into the two bit hole in regVal, we need to shift the value over by offset so it is in the right spot. Then we just logically OR the repositioned value with the masked regVal and presto! We get a new regVal with newVal inserted into just the right spot, and without altering any other bits.
Representing this as a single formula looks something like this:
In this case, regVal will be overwritten with the new combined value. And, because assigning one value to another returns the assigned value, you could wrap this entire thing in parenthesis and think of it like a function that both updates regVal and returns the updated value.
This is fairly complicated bit-math, so if its confusing, that’s OK. We are going to use this formula a lot though, so let’s tuck away all of that complexity into a macro:
Macros are a little like functions, except they aren’t executed at runtime. Instead, before compiling the code, the pre-compiler looks for any macro calls and replaces them with their associated code. For example, when the pre-compiler finds:
We could write all of our functions without the macro using this expanded format, but personally, I find the first version easier to read. Also, we can combine this with our sendData function to write a single line of code for each of our register update functions. Let’s dive into those next.
Global Register Fns
Let’s put our sendData function and SET_BITS macro together to actually modify a register. There are three types of these register update functions—global, channel and operator. The Global registers are the simplest, so we’ll start there. Let’s write a function that sets the Tremolo Depth for the chip.
Looking at the Register Map, we see that the Deep Tremolo flag is located at bit B7 of register address 0xBD. To update this, we have to fetch the current value of register 0xBD from our register cache and then update bit B7.
What is this register cache? Well, there isn’t any way to read the current value of a register on the YM3812. So in order to update only a few bits of a register value, we need to keep track of the current values for all of the registers in memory on the microcontroller. Global registers only have one instance of their value, so they can be represented with integers. Channels and Operators both have multiple instances of their registers so we use integer arrays to store them. Here are the global register caches:
As a convention, I’ve named each register cache variable after its base address. For global registers that have only one setting per base address, these are simply integers. Channel and Operator caches have multiple settings, so they are stored in arrays. Ok, let’s start piecing together our function.
void regTremoloDepth( uint8_t val ){ sendData( regAddress, value ); }
We know that our function is going to call sendData to set the register value on the YM3812, and that the function requires both a register location and a value to set it to. regAddress comes from the chart above—0xBD—so that’s easy to fill in. value needs to combine the current value of our register cache with the val that was passed into the function. Good thing we have that SET_BITS macro, let’s pop that in:
Filling in the blanks above, we first swap regVal for the variable containing the current value of the register we want to modify—reg_BD in this case. Then, because we are updating only a single bit, we swap bitMask for the constant, 0b00000001. Then, because the DeepTrem flag sits in bit 7, we swap offset for the constant, 7. Finally, we swap newVal for the variable that was passed into the function—val. Here’s what the formula looks like once you make all of those changes:
void regTremoloDepth( uint8_t val ){
sendData(0xBD, SET_BITS( reg_BD, 0b00000001, 7, val ) );
}
And there you have it, our one-liner function that allows the user to update the global tremolo depth value.
Channel Register Fns
Updating the channel registers works much the same way, but with one new wrinkle. Instead of one register per base address, we now have 9—one for each channel. Let’s look at the register caches to see how they are affected:
uint8_t reg_A0[YM3812_NUM_CHANNELS] = {0,0,0,0,0,0,0,0,0}; // frequency (lower 8-bits)
uint8_t reg_B0[YM3812_NUM_CHANNELS] = {0,0,0,0,0,0,0,0,0}; // key on, freq block, frequency (higher 2-bits)
uint8_t reg_C0[YM3812_NUM_CHANNELS] = {0,0,0,0,0,0,0,0,0}; // feedback, algorithm
Instead of simple integers, we use integer arrays to store our cache values. The arrays contain one integer for each of the 9 channels supported by the YM3812. This also means that in our register writing functions, we have to choose the correct register cache value based on the channel we want to update. Here is an example:
void regChFeedback( uint8_t ch, uint8_t val ){
sendData(0xC0+ch, SET_BITS( reg_C0[ch], 0b00000111, 1, val ) );
}
This function modifies the feedback property of a specific channel. It takes two parameters: the channel to update and the value to set the feedback property to. Looking at our call to sendData, we see two important changes. First, we have to compute the address of the register we want to update by adding the channel number (ch) to the base address (0xC0). Second, we need to pull the correct register value from our cache by using ch as the array index.
Operator Register Fns
Updating operator registers works the same way as channels, but with yet another twist. The channel registers are all in sequence, so computing the register address is super simple (just add the channel number to the base address). We aren’t so lucky with the operators.
Operators 1, 2, 3, 4, 5, and 6 all pair with register offsets 0, 1, 2, 3, 4, and 5… so far so good. But then… Operator 7 pairs with register offset 8! what happened to 6 and 7? Then, later, we skip offset locations 0xE and 0xF. Woof.
To get around this, we use an array that maps the operator number to the memory offset:
void regOpAttack( uint8_t op, uint8_t val ){
sendData(0x60+op_map[op], SET_BITS( reg_60[op], 0b00001111, 4, val ) );
}
Now, instead of adding the operator number to the base address (like we did with channels), we add the mapped offset from our array.
All of the register functions follow these patterns, and when you put them together, they create a lovely and readable, almost table-like pattern:
Really, the only things that change are the function name, the base address, the register cache, the bitMask and the offset. And, of course, all of those values come from the Register Map.
For the full implementation of all of these functions, check out the GitHub repot. This version has been heavily simplified to match this article and will serve as a foundation for all of the other functionality we’ll be adding.
Making Music
The time has finally come… Let’s go back to our YM3812_Breadboard.ino file and put our library to good use. Again, we are going to keep this as simple as possible. There are two parts: setting things up in a setup() function, and then playing a sequence of notes in a loop() function.
setup()
The first thing we need to do is initialize the YM3812 library. I’ve defined a global instance of the library with the line:
YM3812 PROC_YM3812
Then, inside of the setup function, we initialize the library with the reset function:
PROC_YM3812.reset();
At this point, we get to set up the properties of the sound that we want to create. To keep this simple, let’s just tell every channel to have the same sound properties:
As we loop through the channels, we have to first compute the index of the operators we want to modify. And of course, it’s quirky. Operator 2‘s index will always be 3 higher than Operator 1‘s. The pattern for Operator 1‘s index however, is less straightforward. To get around this, we are going to add another array to our YM3812 class that maps from channel number to its associated Operator 1 index:
Now that we know which channel to modify as well as the associated operator indexes, the rest of the function configures each of the sound parameters using the register functions.
Loop()
Now finally, we get to play the four notes in our chord. And there are just a few steps to do it:
First, we turn off any notes that might happen to be on:
PROC_YM3812.regKeyOn( 0, false ); //Turn Channel 0 off
PROC_YM3812.regKeyOn( 1, false ); //Turn Channel 1 off
PROC_YM3812.regKeyOn( 2, false ); //Turn Channel 2 off
PROC_YM3812.regKeyOn( 3, false ); //Turn Channel 3 off
Then we set the octave we want the notes to be in:
PROC_YM3812.regFrqFnum( 0, 0x1C9 ); // F
PROC_YM3812.regFrqFnum( 1, 0x240 ); // A
PROC_YM3812.regFrqFnum( 2, 0x2AD ); // C
PROC_YM3812.regFrqFnum( 3, 0x360 ); // E
Wait… 0x1C9, 0x240, 0x2AD and 0x360 look nothing like F A C E… Nothing to see here… we will talk about that in the next article. Anyway… Finally, we turn the notes on:
//Turn on all of the notes one at a time
PROC_YM3812.regKeyOn( 0, true );
delay(100);
PROC_YM3812.regKeyOn( 1, true );
delay(100);
PROC_YM3812.regKeyOn( 2, true );
delay(100);
PROC_YM3812.regKeyOn( 3, true );
delay(1000);
Now, I turned all of the notes on one at a time here by adding delays, but if you remove those, they would turn on all at once.
Final Thoughts
My goodness that was a long article. Apparently “FACE reveal” is a bit more involved than “hello world.” If you made it all of the way through, I hope that a lovely F Major 7th chord is echoing through your speakers at this very moment. If you ran into any trouble along the way, feel free to post questions in the comments. All of the working code is posted on gitHub, so hopefully that will help you in the troubleshooting process as well.
While the journey so far may have been arduous, it’s just getting started. Remember how I glossed over the frequencies for F A C E? Well, if we want to add midi control we are going to need a way to translate midi notes into those values. You know all of those register control functions we made? Eventually we are going to get rid of most of those—there’s a better way. But for now, don’t worry about all of that. Enjoy the fruits of your labor. See if you can create more interesting instruments than the one I used. Maybe program in a nice little jig! You got this.
In the last article we dug into the YM3812 registers and how to manipulate them an electrical level. Today we are going to build up the first part of the schematic—just enough to get sound working through a microcontroller.
Whenever building on a new platform, What’s the first thing you implement? Hello World. It’s a great starting point that ensures everything works—the hardware, the development toolchain, etc. We need an equivalent project for our YM3812 that uses sound instead of text on the screen. We need… a FACE reveal! (you know… play an F major 7th chord… F A C E… yeah… woof… #DadJokes 🙄). OK but seriously… to play a note on this chip, we need working hardware, a library that interfaces with the chip, a properly configured voice, and the ability to turn on/off a note at the right frequency. This is going to be an epic journey… and it’s going to take two articles to complete. In this article, we will start with the hardware and schematic, and then upload the code from the GitHub repo using the Arduino IDE. Then in the next article, we will look into the code in a bit more detail to see exactly how it’s working.
The Components
In the spirit of starting small, I’ve pulled together a simple implementation of the YM3812 hardware. Technically, you could simplify this further—interfacing the full 8-bit data bus directly with the microcontroller, or having the microcontroller produce the 3.58 MHz clock. But we are going to need some of those pins down the line and this all fits on a single breadboard. Let’s take a look at our cast of silicon characters.
AVR128DA28 Microcontroller
AVR128DA28 pinout with some extra functions called out
I like to think of the AVR128DA28 as an Arduino Nano, but in chip form—and far more capable. This chip sports:
128kb of program memory and16kb of ram
10 AD converters, and a DA converter
I2C, SPI, UART, and even 4 hardware serial ports
Operating voltage of 1.8v to 5.5v
Internal auto-tuned 24 MHz oscillator
UPDI programing & Arduino IDE Support
And sooooo much more…
All in an itty bitty 28pin DIP package. There are even more powerful surface mount versions of the chip as well, but this will give us plenty of oomph in our project. I have to give a shout out here to Robbie Langmore from Tangible Waves for introducing me to these microcontrollers. In a world of chip shortages, there are still ~300 in stock at mouser—as of writing this.
74HC595 Shift Register
Pinout of the 74HC595 Shift Register
The 74HC595 shift register is next in our lineup. This chip converts the serial output from the AVR’s SPI bus and converts it into the 8-bit parallel bus that bus required by the YM3812.
YM3812 OPL2 Sound Processor
Pinout of the YM3812 Sound Processor
If you are reading this article and wondering what this chip does, then I highly recommend reading part one of this series. Other than some decoupling capacitors, the YM3812 requires a a 3.58Mhz crystal oscillator.
Y3014B DAC
Pinout of the Y3014B Digital to Analog Converter
Unlike some of the other YM sound processors, the YM3812 does not have an analog output. Instead, it needs to be paired with the Y3014B 10-bit Digital to Analog Converter. This chip takes a serial data stream coming from the YM3812 and converts it into an analog output. The Y3014B requires a voltage of 1/2 VCC on pin 8 (MP) to bias the output signal (pin 2) around. Fear not though, it produces its own reference voltage on pin 8 (RB), but you still need to buffer that voltage using an operational amplifier. Also, buffering the output signal (pin 2) with an operational amplifier is another solid plan.
TL072 / LM358 OpAmp
Did somebody say operational amplifier? OpAmps are the workhorses of Eurorack Modules and Analog Circuitry in general. Unless it’s a passive attenuator, odds are, there’s going to be 1 or maybe 10 of these versatile building blocks. In our case, will use these to buffer the reference voltage and output signal generated by the Y3014B.
Other Parts
After the ICs, there’s only a handful of other parts.
The six .1uF capacitors provide decoupling for the ICs
The two 10 uF capacitors stabilize the reference voltage on the Y3014b
And the 4.7 uF capacitor provides capacitive coupling on the audio output
The 100k resistor ties the output capacitor to ground and the 1k resistor ensures appropriate output impedance
The 1N4148 signal diode converts the single-wire UDPI pin into separate Tx/Rx pins that attach to an FTDI cable
Lastly, a micro-switch provides a reset button for the AVR microcontroller.
Schematic Time!
Basic schematic for a YM3812 circuit with Microcontroller
Microcontroller Connections
Now that we’ve talked through how the pieces work, the schematic should seem pretty straightforward. The Clock (pin 26) and Data (pin 28) of the SPI bus of the AVR128DA28 connects to the Serial Data (pin 14) and Shift Clock (pin 11) inputs on the 74HC595 shift register. PortD-3 (Pin 9) on the microcontroller connects to the Latch Clock (pin 12) on th 74HC595 and latches the output of the register. This sends the 8 Data Bits (pins 15, 1-7) to the YM3812 Data Inputs (pins 10-11, 13-18).
PortD 0, 1, 2 and 4 (pins 6, 7, 8, 10) on the microcontroller connect to the four control lines of the YM3812—Write (pin5), A0 (pin 4), Initialize/Clear (pin 3), and Chip Select (pin 7) respectively. On the YM3812, tie Read (pin 6) high to prevent rogue reads. IRQ (pin 2) can be left floating in the breeze.
Sound Processor & DAC Connections
The Output of the 4.58 MHz Crystal Oscillator flows in to the Master Clock Input (pin 24) on the YM3812. Enable (pin 1) on the oscillator should float. Tying it to ground disables the clock output.
The Serial Data and Clock output pins on the YM3812 connect to the Serial Data and Clock input pins on the Y3014B and transfers the raw sound data to the DAC. Sample & Hold (pin 20) on the YM3812 connects to the Latch (pin 3) on the Y3014B, and updates the analog value of the output.
The Y3014B produces a reference voltage of 1/2 VCC on RB (pin 7) which the TL072 then buffers and sends back to the output bias control pin MP (pin 8) on the Y3014B. C7 and C8 stabilize the reference voltage.
The Analog Output signal of the Y3014B (pin 2) get buffered through another operational amplifier on the TL072 before passing through a coupling capacitor (C8) to remove the DC bias. A high value resistor (R1) ensures our output stays centered at ground, and R2 ensures our output remains at a 1k impedance.
The Other Stuff
Of course all of the ICs require decoupling capacitors on their power pins (C1 – C6). And a reset button connects the ResetLine (pin 18) of the AVR128DA28 to ground. As far as I can tell, the reset button doesn’t require a debouncing filter, you can just directly connect it.
FTDI Connection to UPDI port
Lastly, the AVR128DA28 can be programmed through a one-wire Universal Programming & Debugging Interface. If you don’t have one of those programmers, you can use an FTDI to USB connector. There are many variations of this connector, some include the RTS/CTS lines (which we don’t use) and some don’t. Also, there are a couple of color variations of the wires on the connector. Here is an alternate color scheme that I have seen:
FTDI Cable with Alternate Color Combination
Starter Code
Our circuit isn’t going to do much without a bit of code. We need to program the microcontroller to see if everything works. As it happens that I created a simple library on my GitHub for just that purpose. I’ve trimmed it down to its basic elements and added loads of comments to make things as readable as possible. Feel free to download and play around with it, and in the next video we’ll dive into exactly how the code works.
Note, to get the YM3812_Breadboard.ino file to work, you will need to create a “YM3812_Breadboard” folder in your Arduino folder and then copy YM3812_Breadboard.ino, YM3812.h, and YM3812.cpp into it.
Programming the Micro
Assuming that you chose to use an AVR128DA28 as your microcontroller, you’ll need to install the DxCore in your Boards Manager. DxCore is an open sourced library written by Spence Konde that allows you to use the AVR128 line of microcontrollers in the Arduino IDE. There are detailed installation instructions on the GitHub, but this should at least get you started.
First we need to add a URL to the Additional Boards Manager URLs. To do that, open the menu: Arduino > Preferences:
Arduino Preferences Menu
Enter the url, http://drazzy.com/package_drazzy.com_index.json in the Additional Boards Manager URLs text box as shown above.
Then, locate the boards manager in Tools > Boards:… > Boards Manager
Search for DxCore and locate a package that looks similar to the one below:
After installing the DxCore boards library, you should be able to select the AVR DA-series (no bootloader) from the board menu. Also, select AVR128DA28 as the chip, and 24 MHz Internal as the Clock Speed.
The programmer that worked best for me was SerialUPDI – 230400 baud. It’s quite fast.
Now, just connect your FTDI cable as described in the schematic section of this article. And fingers crossed your program should upload directly into the micro. If it doesn’t… check that the Tx/Rx pins aren’t swapped—I’ve done that a few times.
Final Thoughts
With any luck, you have now experienced the total exhilaration of getting the YM3812 to generate that F Major 7 chord! Realistically, ANY sound at this point is great, because it shows that the hardware is working. If you got everything working, feel free to leave a comment. Similarly, if you see any issues in the schematic, let me know. After all, I’m still learning too!
In the next article, we will look through the code in detail.
Errata
In the schematic, the labels on the SPI Clock and MOSI lines were swapped. Thank you to Peter Hesterman for catching this one!
In conducting a pretty extensive deep dive into Yamaha’s FM synthesis chips, I’ve come to realize that while these chips produce very unique sounds, they are also largely very similar. In fact, if you compare their register settings (and ignore the level of granular control that you get), you can see just how many of the settings are the same:
Comparison of Yamaha FM Synthesis Chips
With all of these similarities in mind, I originally planned to build a single module with a variety of sound processor types all controlled by a single microcontroller. After breadboarding this idea out with a YM3812, YM2151 and even a non-Yamaha SN76494, it worked! And I was even able to translate chip settings from one chip to another seamlessly. But when I started to document how it worked, I realized from a complexity standpoint, I needed to back waaaay up and start from the beginning. To get into the advanced stuff, we need to start with a smaller project that lays a strong foundation. And what better foundation than a Eurorack module powered by Yamaha’s YM3812 OPL2 sound chip. A chip that revolutionized computer audio for the video games of my childhood.
This article is only the first in a series. Together, we will tour the properties of the YM3812, design the hardware for a module and write the supporting microcontroller code. Hopefully, each article will pair with a YouTube video on the subject as well.
Why the YM3812?
Sound cards from the 80’s and 90’s that featured the YM3812 sound processor. Some even included 2!
If you grew up in the 80’s and 90’s and played video games on a PC, then it is highly likely that you have listened to the glorious 8-bit audio output of a YM3812 sound processor. To me, this sound is synonymous with Sierra and Lucas Arts adventure games. I can still remember reading advertisements for the sound card in Sierra’s interAction magazine. The idea of getting music and sound effects other than square waves from a computer was like magic. So after saving up my dog-sitting money I bough a SoundBlaster Pro from Fry’s electronics. The first time using this thing was symphonic—it took adventure gaming to a whole new level. Now, 30-ish years later, I want to recapture that magic.
From a more practical standpoint, the YM3812 supports only two operators per voice making it significantly simpler to understand than other 4-operator or even 6-operator FM synthesizers. But don’t be fooled, this is still a powerful chip, capable of creating a vast variety of nostalgia-generating instrumental and percussion sounds—and that’s what makes this chip such a great starting point.
How does FM synthesis work on the YM3812?
Let’s start with a quick review of FM Synthesis and how sound processors use it to produce sound. The YM3812 is an FM Synthesis sound processor. It plays up to 9 different sounds (voices) simultaneously, with each sound composed of two different operators.
Basic Operator Building Blocks
An operator is the basic building block of FM synthesis. It contains an oscillator that generates a sound wave, and an envelope that adjusts the sound’s level over time. Each voice of the YM3812 includes two of these operators, and they combine together using two possible algorithms:
Operator Algorithms
Mixing is the simplest form of “synthesis.” It adds the output of the two oscillators together like a mixer. In the output, both oscillators’ sounds are audible and distinct.
Frequency Modulation uses the first operator (called the modulator) to modulate the second operator (called the carrier). In this style, only the level of the carrier operator affects the level (volume) of the output signal. The level of the modulator operator affects the brightness of the timbre of the output. The higher the modulator level, the brighter the sound will be.
Frequency Modulation Demo
Words simply can’t do this justice, so let’s try a demonstration. I’ve connected an oscilloscope to the output of the final module (spoiler alert… the module eventually does work and this story will have a happy ending). I configured the module into Frequency Modulation mode and set the modulator operator to have a slowly decaying envelope. This way, you can hear how the amount of modulation changes the timbre of the sound as it slowly decreases. After playing a few notes, I also show how using different waveforms for the modulator and carrier signal also change the overall output.
Demonstration of Frequency Modulation with multiple Waveforms for the Carrier and Modulator Operators
Notice how the brightness quickly drops as the level of the modulating operator decays. In the Oscilloscope, it looks like the waveform collapses back into itself. Pretty cool right? Let’s try something even more mind bending…
Feedback
In the YM3812, the modulator operator (operator 1) also has the ability to modulate itself. What does that mean? This was incredibly difficult to wrap my head around, so I made an animation that starts with a sine wave and slowly increases the amount of feedback. As the amount of feedback increases, the waveform shifts from a sine wave into something more like a sawtooth wave. Then, after adding too much feedback, the wave deteriorates into inharmonic white noise. While less than musical, this white noise is a critical component of making percussive sounds on the YM3812, so it’s actually a good thing!
FM Feedback of a Sine Wave
As an experiment to see if my animation aligns with reality, I plugged the final module into an oscilloscope. Now, we only want operator #1 because that is the operator that has the option for feedback. So, I set the module in mixing mode and turned the second operator’s signal all of the way down. This ensures that only the first operator’s signal appears in the output. I then set the first operator’s waveform to be a sine wave and then slowly increased the amount of feedback. You can see it shift from sine to saw… to crazy…
Demonstrating feedback on the actual module
Types of Settings in the YM3812
The register settings that control sound production on the YM3812 fall into three different categories.
Operator Level settings control how the oscillator and envelope functions. Oscillator settings include things like the waveform, detuning, and vibrato, while the envelope settings include things like attack, decay, sustain, and release. Operator settings have to be defined for every operator on the chip, so there are 18 sets of these settings on the YM3812.
Channel level settings determine how the operators will work together to form a sound, as well as the things that affect that sound overall. Channel settings include the pitch of the note to play, whether the sound is turned on or off, and adding effects like tremolo and vibrato.
Global level settings control the general properties of the chip like turning on “drum mode” or affecting internal chip timers. The “Deep Tremolo” and “Deep Vibrato” settings change the amount of vibrato / tremolo applied to the channels that have vibrato / tremolo enabled. Because these two settings are global, they affect all channels at once. As for the drum mode, you get 6 drum sounds for the cost of 3 channels. It seems like a good thing, but realistically you can achieve far superior drum sounds using normal instrument settings.
What are Registers?
In order to produce sound, the YM3812 uses a set of special-purpose memory locations to store the sound settings called registers. Because space comes at a premium, and there are so many different settings to store, the YM3812 compresses multiple settings into each register byte. So, for example, let’s say we found the value stored at register 0x41 to be 0x4C.
This value represents two different settings—Level Scaling and Total Level for Operator 1 of Channel 2. To understand how these values were combined, you have to look at the value of 0x4C in a binary representation. As shown above, this translates to a binary value of 01001100. The top bits on the left (the two “highest” bits) store the value “01” or in decimal, 1 and the next 6 bits store the value 001100 or in decimal, 12. The first number maps to the Level Scaling setting and the second to Total Level. Combining variables in this way saves memory because not every setting requires 8 bits to represent its value. Conversely, this also means that the maximum integer value a variable represents depends on the number of bits used.
For example, a 1-bit number, can only be on or off, where as a 2-bit number can have 4 states (0-3). The number of allowable states doubles with each successive bit, until you get to a maximum of 256 (0 – 255) for an 8-bit number.
Note, the Frequency Number setting is the only one that uses MORE than 8 bits. This value is broken up across two register settings: an 8-bit Frequency Number LOW setting and a 2-bit Frequency Number HIGH setting. To combine these into a single 10-bit value, you shift the 2 high bits over to the left 8 times and then logically OR it with the low setting.
Finding Register Locations
Earlier, I pulled a random location 0x41 and somehow said, “oh that’s these two settings.” How the heck did I know that? Well, the answer is that I looked it up in the data sheet. But I think with a spreadsheet and a little information design, we can build a simple map to use going forward.
Global Registers
Global Register Map
The chart above shows how settings map to the memory locations shown on the right. These 6 global registers scrunch together 18 different settings. The left side of the chart shows how the settings map to each bit of those 6 bytes. The D0 column represents the lowest bit in the byte and D7 represents the highest bit. So for example, if you wanted to set the Deep Vibrato flag’s setting, you take the current value of BD and do a logical OR with 0b01000000 to set the correct bit, and then write the result into the BD register.
Channel Registers
Channel Register Map
The channel-level registers follow a similar pattern, but with one added twist. Instead of each register type having one location in memory, they have 9—one for each channel. The columns on the left still indicate how bits map to settings, but now the columns on the right show the memory location of the register that corresponds to each channel. So, for example, if I wanted to turn sound generation on for Channel 5, I would have to set bit 5 of memory location 0xB4 to a 1, and that would play the note.
One more note, the base address column in the center provides the memory location of the first channel, and we can use it as an offset to calculate the other memory locations. So if we wanted the memory location of the “B0” setting of channel 5, we could find it with the formula, “0xB0 + 5 – 1”. The minus 1 is in there because the channel names are “1 indexed” instead of “0 indexed” If you labeled them channel 0 – 8, then “channel 5” would become “channel 4” and the formula would just be “0xB0 + 4”
Operator Registers
Operator Register Map
The operator-level register add a bit more complexity to the mix. I’ve laid them out in a similar fashion with the mapping of bits to setting types on the left and memory locations on the right, but now we have 18 different locations to contend with—one for each operator—instead of 9. For every channel, there are two operators—a Modulator and a Carrier. We can also abstract these into “Slot 1” and “Slot 2” because in other Yamaha chips, there are up to 4 operators per channel and numbers add clarity.
I’ve arranged the memory location columns in the table above to keep each operator visually connected with its associated channel. If you look closely, a curious pattern emerges. Channel 1 associates with operators 1 and 4, Channel 2 associates with operators 2 and 5, and so-on. The carrier operator number is always three higher than the modulator’s operator number.
In the chart above, the row below the operator numbers (that starts, “+0, +3, +1,…”) represents the memory offsets for each operator from the base address. So to find a memory location for a specific operator, you could add this value to the base address associated with the setting you want to change. Here again, there is another “gotcha.” The memory locations JUMP between channels 3 and 4, and skip offsets 0x6 and 0x7. A similar jump occurs between channels 6 and 7, where we skip offsets 0xE and 0xF as well. As far as I can tell, the easiest way to manage this is to put the offsets into an array that maps operator to memory location.
Full Register Map
If we put all of these pieces together, the full register map emerges:
If you want to develop code that controls a YM3812, I highly recommend printing this chart, laminating it and pinning it to you wall. You are going to need it!
Oh, one other thing, if you turn on drum mode then channels 7, 8, 9 become dedicated to drum sound production. I’ve indicated this with orange asterisks in the chart above. Again, I’d recommend against using the default percussion sounds, but hey, it’s there if you need it!
Setting Registers on the YM3812
Now that we know the mapping of settings to register locations, let’s talk about how to set those registers… electrically.
YM3812 Pins
Pinout of the YM3812 Sound Processor
The pins of the YM3812 fit into three different types. The power pins connect to 5v and ground. The three pins in yellow provide a serial connection to the Y3014b digital to analog converter chip. The 8 pins in blue connect to the data bus, and the 6 green pins work as control lines. There are also a few unused pins in grey that we can ignore.
For this exercise, our interest lies in the 8 data lines and the A0, Write, and Chip Select control lines. The Init-Clear pin will clear the contents of the chips registers, and the IRQ and read pins are used to read status information from the chip. Unfortunately, you can’t read the contents of a register, so honestly reading information from the chip just isn’t that useful.
Selecting Registers & Sending Data
Timing Diagram for Setting Register / Data on YM3812
Setting the register requires two main steps: selecting the register, and setting its data. To select a register, we:
Begin with the Chip Select and Write control lines high (disabled). The A0 line could be either high or low.
Set the Chip Select line low to enable the chip
Set A0 low to tell the YM3812 that we are selecting a register
Put the address of the register we want to write onto the Data Bus
Set Write to low to tell the YM3812 to use the data on the Data Bus to select the register
Wait 10 microseconds
Set Write to high to complete the write cycle
Wait 10 microseconds. At this point the register is selected
Flip the A0 control line high to indicate that we are now writing data into the register
Put the value onto the Data Bus to write into the selected register
Set Write to low to tell the YM3812 to write the data into the selected register
Wait 10 microseconds
Set Write to high to complete the write cycle.
Wait 10 microseconds. At this point, the data has been written into the YM3812
Set Chip Select back to high to disable the YM3812 again
And that’s it! If you followed the steps above, then you have written data into a register!
Wrap Up
Now that you know where all of the registers are on the chip, and how to manipulate them, you have the fundamental building blocks to control the YM3812. Of course getting from register setting to a working MIDI controlled module will require a bit more discussion. My hope is to take these topics one at a time in future articles. In the next article, let’s go through the module schematic, and after that we will get into some more software algorithms.
Thonk Jacks (well, fake ones anyways) on a front panel
Do you find yourself struggling to tighten the nuts on those 3.5mm Thonk Jacks? Do they break your nails and hurt your fingers, forcing you to resort to pliers that inevitably slip off and scrape your beautiful PCB front panels? Do you have a 3D printer?
Thonk Jack Nut Tightener (in Tinkercad woot!)
Take 14 minutes and print this file out. It fits snugly around the nut and allows you to screw it in with ease. You can tighten using your fingers, or use the hole in the top with that 4mm hex adapter in your screwdriver set. Enjoy, and I hope it saves you as much grief as it did me!
I’ve been meaning to build a noise generator for a while now—especially to make hi-hats, claps, and other percussive sounds. So when my noise2 chip arrived from electricdruid.com, I was eager to dig in. As I looked through the datasheet schematic, I was really surprised by the simplicity of the module design.
Electric Druid’s Noise2 Pinout
The chip has outputs for pink and white noise, so short of powering things, you just need to route the pink and white noise outputs through voltage followers for protection, maybe add a low pass filter on the white noise output, and that’s basically it!
Have you ever wondered about the difference between pink and white noise? All noise is made up of a combination of sine waves of different frequencies. Those waves are added together to create sounds of different timbres. White noise contains sine waves of every frequency all at equal volume. Now, because the frequency of a pitch increases exponentially for every octave, there are more frequencies in higher octaves than in lower ones. For this reason, white noise—to our human ears—sounds high pitched. Pink noise however, equalizes the volume of the frequencies so that each octave has the same total volume.
Adding a Filter
Seeing as there wasn’t much to the noise circuit, I decided to integrate the noise2 chip into the VC Tone Filter experiment that was occupying my breadboard at the time. This experiment uses a TDA1524 tone chip (designed for Treble / Bass / Volume adjustments in cheap stereos), to create something like a VCF/VCA combo.
After adding the noise chip, I experimented to see what kinds of sounds it could generate. I added an envelope generator on loop and, with a little fiddling, got everything from ocean waves to howling winds, to hi-hats, cymbals, claps, footsteps, and base drums. Here is a sample of some of the sounds. It’s about 5 minutes of tweaking settings—feel free to skip around.
Breadboard Noise & Tone Filter Experiment
It was clear to me that this thing needed the full module treatment.
Module Design
After a bit of consideration, I came up with a list of features to include. Most of these are pretty standard for any module:
Output buffering / protection
CV inputs that you can attenuate
Selectable voltages mixed with CV inputs (so you can override)
Adjustable Resonance / Feedback
Selectable pink noise / white noise / no noise
A buffered input signal (which really just allows you to use the module as a combo VCA/VCF instead of the noise)
Eurorack power connector
The Filter
The filter circuit stayed largely the same as the version in my previous experiment. I used the TDA1524 as the core of the filter, and because the chip supports two channels (left and right) I fed the output of the right channel into the input of the left channel. This doubled the effect of the filters.
TDA1524 Base Circuit
While the components are largely the same, I did tweak a couple of values—lowering the capacitance of C4 and C8, and raising the capacitance of C10 and C13. Because the same signal flows through both the left and right channels, the balance control acts as a choke. When balance is turned all the way left, the signal gets amplified before being attenuated on the right side (and visa versa). The signal is only loudest when it is centered. So, by having different capacitors on the left and right side, you get an imbalance between the channels, and this gives you very different sounds as you adjust the balance control.
Inputs & Feedback Loop
Feedback Loop
The feedback loop mixes together the input signal, the white/pink noise, and the filter’s output using an inverting buffer (with unity gain) to create the filter’s input signal. The feedback potentiometer adjusts the attenuation of the output signal to determine the amount of resonance. Note, for the noise switch, you will want to use one of those three-position “on-off-on” switches so you can turn off the noise.
CV Input Mixers
All of the CV inputs (except balance) follow a standard active mixing design:
The CV input attenuates through a potentiometer before being mixed together with a voltage divider. The two inverting amplifiers (unity gain) ensure that the signals mix without cross-talk and the Zener diode (D4) ensures that the CV gets clipped before it can damage the TDA1524 chip.
Full Schematic
Here is the full schematic that also includes the power filtering components. Nothing super crazy there, although as a best practice, I do include low voltage-drop rectifier diodes on the power input to prevent frying the module by plugging it in backwards.
VC Tone & Noise Filter Schematic
PCB Layout
VC Filter PCB Layout & Assembly
After a bunch of fiddling in Kicad, I came up with a rather densely packed single-board PCB layout. The 12u width (60.6mm) feels quite usable and not too cramped. Of course, if creating the most tightly packed Eurorack device is your thing, I bet you could make it smaller by stacking two boards. Personally, I only try to do that when the savings is closer to 40% or so. Here, given the number of physical controls, and the fact you do want SOME space between them, I think you’d probably only save maybe 20% on the board width. Also, hey this is a White/Pink Noise Generator, VCF, and VCA all in one, so 12 units seemed like a pretty good value to me. 🙂
One more note, I added M3 holes at the top of the module for front-panel stand-offs because the pots I’m using are super cheap ones without panel mounts. Without better pots, you need something at the top to fasten on the front panel. Also, keep an eye on those caps sitting between the potentiometers. If they get too tall, they might run into the front-panel. This isn’t too big of a deal because there is plenty of room to bend them down against the board if you have to.
Module Demo Time
The module is extremely versatile and can be used to create lots of effects. To demonstrate as much as possible, I broke the video into two parts. The first part uses the module purely to alter an input signal with the noise turned off. In essence, it acts as a filter—even resonating at one point. In the second video, I turned the noise source on and showed how it can be shaped by the filter and even mixed with the input signal.
Demo of the Filter Capabilities Using Signal Input (no white/pink noise)
Demo of the Filter shaping white and pink noise to create sound effects
As always, if you have any ideas on how to push this module further, please leave a comment! The device is a blast to play with and I will keep trying to improve it. Maybe starting with a proper faceplate.
When starting out in the DIY synth space, one of the first projects people typically tackle after creating their first oscillator is an analog sequencer—like the “baby-8.” It’s a simple circuit that steps through a sequence of adjustable voltages. And if you use those voltages to control an oscillator you’ll get a little tune that repeats over and over.
But what if we turned the clock speed up? Like… WAY up? And, instead of using the output as a control voltage, what if we used those adjustable voltages to directly form a sound wave? Well, that’s what this experiment is about—creating, controlling and visualizing new waveforms.
Grab a bread board and follow along—or just read on—either way, let’s dive down the rabbit hole to figure out how this all works.
The CD4017 Decade Counter
At the heart of the Waveform Generator is a CD4017 Decade Counter:
CD4017 Decade Counter Pinout
Simply put, this chip turns on one of the Q[0-9] outputs at a time, rotating through them with each tick of the clock input pin. In the “on” state, the Q pin will output VCC which can be anything from 3v to 18v.
There are also a couple of control functions to the chip:
Reset will flip back to Q0 when it goes high
Enable turns the chip’s output on and off. I tied this to ground so the chip stays on.
Carry is an output pin that goes high whenever the CD4017 ticks from Q9 back to Q0. If you were chaining multiple counter chips together, you might use this pin as the next chip’s clock to get a second counting digit. I’m not doing that—but this pin will still be important later…
Creating a Sound Wave
When the clock starts ticking, the CD4017 cycles through pins Q0 through Q9 turning each one on in sequence. And the 5 volts I’m using to power the chip (VCC) gets outputted onto the Q pin that’s turned on.
Now, sound waves are just changes in voltage over time. So if each Q pin generated a different voltage and I combined those outputs together, then as the clock ticks through the different Q pins, I would get a sound wave that looks something like this:
Output of the CD4017 with Voltage Dividers
In order to get different voltages for each step in the sequence, I added 10k pots connected from the Q pin to ground. The center pin acts as a voltage divider allowing you to select a value between ground and 5v:
Voltage Divider Circuit (single segment)
I combined the 10 voltage dividers together through diodes to ensure that none of the signal routes back into the CD4017 chip. Here is the schematic:
CD4017 with all 10 Voltage Divider Circuits
It may look a little complicated, but it’s really just a repeating pattern.
As for the other pins on the CD4017, I tied both the Enable pin (13) and Reset pin (15) to ground. I connected the clock pin to pin D6 on an Arduino Nano, and the Carry-out pin to…
Wait… An Arduino Nano Clock?
Converting a control voltage into a frequency is probably one of the most temperamental parts of any analog synthesizer. Synth designers use some highly temperature-dependent analog circuitry tricks to accommodate the exponential relationship of a 1v/octave control voltage and the Oscillator’s Frequency.
For this circuit, I needed a way to drive the CD4017 clock input using a musically accurate square wave that goes 10x as fast as the desired. The Arduino Nano just so happens to have the ability to generate a very fast, very accurate clock signal using the tone() function. You can set it from 32 Hz all of the way up to 65,535 Hz. Even if you divide that by 10—for each of the 10 steps in the waveform—6,535 Hz is higher than the highest note on the piano (4,186 Hz). Moreover, the Arduino Nano has analog inputs that can read a control voltage and do the exponential conversion in code.
Now I know what you are thinking… isn’t using an Arduino cheating? Won’t it contaminate the purity of all of our analog goodness? Well, the clock signal needs to be a digital signal anyway. And, with the exacting frequency control, temperature stability, the ability to use both CV and MIDI input… it seemed like a good trade-off. Lastly, since the timer is hardware-driven, that leaves most of the processing power on the Arduino for… other things… 🙂
Finnessing the Signal
Let’s take another look at the output of our circuit so far:
Notice how the voltage levels appear to be disconnected line segments? When the CD4017 flips from one Q output to the next, the voltage will instantly change. A waveform like this is going to be really grainy and harsh, with lots of overtones—like a square wave. This might be what you want, but if it isn’t, you’ll need a way to round those corners off so the wave looks a bit more like this:
Rounding waves with filters
To round off the signal, I used a simple low-pass filter:
Basic 3-pole low-pass RC filter
First, I buffered the signal coming from the CD4017 to remove any interaction between the RC Filter and the voltage dividers. This ensured that those square edges remained in the output signal when I turned the filter totally off.
After buffering the signal, I used three low-pass Resistor-Capacitor (RC) filters in series. They each apply a small amount of filtering to round off the output signal without attenuating it too much. Still, they do attenuate it a bit, so after filtering the signal, I added an amplification stage.
Amplifying the signal
Amplification Stage
The amplification stage has two parts. A level potentiometer allows you to attenuate the signal coming from the filter between 0% and 100%. That signal is then amplified 3x by the inverting amplifier. This means that the level pot is really amplifying the signal between 0% and 300%.
Note: While this filter & amplification stage will get the job done, it is still pretty primitive and has one significant disadvantage: the filter cutoff doesn’t change as the oscillator’s pitch increases. That’s going to make the sound more hollow as you move up the scale (until you manually adjust the filter accordingly). To fix this, you’d need a much more complicated CV-controlled filter that can adjust the cut-off using a 1v/oct scale. But for now, let’s keep this simple. It works pretty well for an octave or two anyway. Maybe a future article will address this… 🙂
Blocking the DC Offset
DC Blocking Capacitor
The audio signal is almost ready to use, but I had to do one more thing first. Right now, every segment of the waveform sits between 0v and 5v. This means that the center-line of the wave is effectively 2.5v. Audio waves need to center around ground (0v), and the difference between these center lines is a “DC offset.” You really don’t want a DC offset. Thankfully, it’s pretty easy to get rid of this: just add a large capacitor in series before the output. The capacitor only allows changes in voltage to make it through while blocking any direct current.
A Quick Sound Demo
Now that we know how to generate the sound, let’s take a listen:
Tweaking the waveform & filters
Visualizing the Sound
Ok, so we now have a waveform assembled from 10 different segments, filtered with three filters, and then amplified. I’m not sure about you, but visualizing a waveform made from fourteen pots in my head is a pretty steep ask. If only there was a way to visualize this… Wait. Don’t we have a completely underutilized Arduino just waiting for something to do? Why, yes. Yes, we do.
So… an Arduino Nano Oscilloscope?
This seems like a super cheap hack, but honestly… it works surprisingly well. After all, I only need a visual reference and not a precision piece of bench equipment. Here’s a peek at the output (with a few test indicators on top of it):
Oscilloscope readings powered by Arduino Nano
To build this, I basically took the output signal (before it goes into that DC blocking capacitor) and fed it into an analog input on the Arduino. The Arduino captures the waveform and displays it on an OLED screen. There are of course a couple of catches…
Making the signal Arduino-safe
The Arduino’s analog pins can only handle between 0v and 5v. If you go over that, you can easily destroy the analog pins—if not the entire Arduino. And, thanks to that 3x amplifier, the signal can definitely breach that threshold. So I needed a way of clipping the signal:
Signal Clipping Circuit
Let’s break this clipping circuit into a few parts:
The Op-amp (in voltage following mode) just buffers the incoming signal—it doesn’t change it at all. This ensures that the clipping part of the circuit doesn’t interact with the sound output.
The Zener diode (D12) only conducts when the incoming signal goes above 5.1v or below 0v. This ensures that the point between R5 and R7 stays within the 0v to 5.1v range. And, since R7 is connected to that point, it will never exceed those voltage boundaries either, making it safe to go into the Arduino. R7 also limits the current going into the Arduino—which would otherwise fry it.
The left side of R5 connects directly to the incoming signal, and the right side connects to the clipped signal. Whenever the signal is within the clipping bounds, the voltage on both sides will be the same. BUT if the signal voltage exceeds the clipping boundaries, then there will be a potential difference across R5. I used that to power a clipping indicator LED (D11) through a current limiting resistor (R6) when the signal exceeds 5v.
Adding CV Input
CV Clipping Circuit with positive and negative indicators
In order to add a frequency control CV input, I used the same clipping circuit, but added a second diode in reverse to indicate negative voltage clipping. On the first circuit, this wasn’t necessary since the voltage could never go negative. But here, in the wilds of modular, anything is possible.
Hooking up the Arduino
Arduino Nano Connections & MIDI Interface
Now let’s take a look at how to hook up the Arduino Nano:
The clock output that goes into the CD4017 connects to pin D6. Though this could be any digital pin.
The clipped waveform signal that we want to read into our “oscilloscope” connects to pin A3.
The CV input signal connects to pin A2.
The 128×32 pixel OLED screen—which uses an I2C bus—connects to A4 (SDA) and A5 (SCL). These pins are fixed on the Arduino Nano.
Remember when I said the Carry pin on the CD4017 would be important? Well, that connects to D5 and triggers the Arduino to refresh the Oscilloscope reading right at the beginning of a waveform.
Lastly, I added MIDI input through an optocoupler (6n138). A switch selects between programming the Arduino and using MIDI input because they both use the RX pin. I typically use 1/8″ stereo jacks for my MIDI connectors (vs. DIN) because they are smaller and cheaper. I follow the Korg pinout because that’s the adapter I happen to have.
Full Schematic
Full schematic of the Waveform Generator
That’s really all there is to the hardware piece of this project. Here is the full schematic. Let’s move onto the software
On to the Software!
One thing I love about the Arduino platform is that there are tons of libraries out there that do all of the heavy lifting. In this case, Adafruit’s GFX library allows us to write to the screen, and the MIDI library manages incoming MIDI messages.
With the hard stuff out of the way, my code primarily focuses on two key things:
Combining the input CV and current MIDI note into a clock frequency.
Reading the incoming audio signal, and writing it to the display.
Frequency Lookup Tables
The most difficult part of this programming exercise was figuring out how to combine the current MIDI note with the control voltage input.
In my first attempt, I translated MIDI notes into their corresponding frequencies using a lookup table. This is easy and works great—until you try to combine it with the control voltage input. You can’t just convert a 1v/oct CV into a frequency and add it to the frequency of the current midi note. Let’s say we had a MIDI note of 440hz, and our CV input was 1v. A 1v CV would increase the MIDI note by one octave (adding 440 Hz). But, with a MIDI note of 880 Hz, that same 1v CV would have to add 880 Hz to raise the MIDI note by an octave. Unfortunately, the look-up table was a non-starter.
Calculating the Frequency
In order to abandon the lookup table, I needed a formula that calculates the clock frequency based on the input CV and the current MIDI note. Here is how I got there…
We know that the frequency doubles with every octave increase—e.g. the note an octave above A 440 Hz is A 880 Hz. In this way, you could think of the Frequency Control Voltage (which is 1v/oct) as being the number of times we want to double the current pitch (Base Frequency). Mathematically, you could write this as:
(Where “Base Frequency” is the frequency of the lowest note.)
In the MIDI standard, note 0 starts at 8.1758 Hz and goes through note 127 (12,543 Hz). So we can use 8.1758 as our base frequency. Since there are 12 MIDI notes (half steps) in each Octave, you can convert the current MIDI note into a “1 unit per octave” equivalent by dividing the note number by 12.
Now, since both the CV and MIDI note are on the same linear scale, we can add it to the Control Voltage in the formula above, and this gives us our new formula:
And that’s it. If use the input CV (scaled to 0v-5v) as “ControlVoltage”, and the current midi note as “MidiNote” then the result will be the combined equivalent frequency. Lastly, because there are 10 steps in our waveform, we have to multiply our base note by 10. I used 81.758.
Ok, that’s enough maths.
Coding the Oscilloscope
In order to keep the oscilloscope as responsive as possible, I render the screen in three steps.
Collect Readings The first step collects 128 readings from the analog input into an array. Keeping the data collection loop extremely simple ensures that readings are collected as fast as possible—providing most granularity.
Draw Screen Before drawing any pixels, I clear out the display. Then, for each column of pixels, I translate the analog value collected in the array (which ranges from 0 – 1024) to a row on the screen (which ranges from 0 – 32). Lastly, I write a white pixel to the corresponding row and column.
Add any debug info After rendering the wave, I found it super helpful to add a few other interesting indicators. For example, the currently selected MIDI note, the Input CV value, and of course, the current frequency being played. You can pretty easily turn these on or off to suit your needs. Last thing, don’t forget to run display.display(). This function moves everything from the hidden screen buffer we’ve been writing to onto the actual display.
One more timing consideration
Rendering the screen takes a relatively long time. And, while the clock frequency driving the wave is hardware-driven—and thus unaffected by the screen rendering—changes to the clock frequency ARE affected. This means that if you change the CV input, the corresponding frequency change won’t happen until the screen finishes rendering. This creates audible stuttering in the sound as you change the CV—almost like it is being quantized.
In order to fix this, I only render the screen once every 100 milliseconds, and if the CV Input changes more than a small amount, I wait another 100 milliseconds before updating the screen again.
The trade-off is that if you update the CV continuously the screen won’t update, but the frequency changes will be buttery smooth. As soon as the CV stops changing—even for a split second—the screen will refresh.
Enough talk…
Here’s the code
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <MIDI.h>
/**********************
* Screen Definitions *
**********************/
#define SCREEN_WIDTH 128 //OLED display width, in pixels
#define SCREEN_HEIGHT 32 //OLED display height, in pixels
//My screen doesn't have a reset pin... so I just picked a random one
#define OLED_RESET 4 //Reset pin (or -1 if sharing Arduino reset pin)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
/****************************
* Oscilloscope Definitions *
***************************/
#define WAVE_START_PIN 5 //CD4017 carry pin goes high at start of wave
#define TONE_PIN 6 //Output clock pin that drives the tone
#define CV_PIN A2 //Analog CV input pin
#define OSC_IN_PIN A3 //Analog input pin for oscilloscope measurement
#define REFRESH_DELAY 100 //Milliseconds between OLED screen refreshes
//This is where we will track the values for oscilloscope measurement
//We will collect one value for every column of the display
int vals[SCREEN_WIDTH];
unsigned long next_refresh = 0; //Tracks time between screen refreshes
/********************
* MIDI Definitions *
********************/
MIDI_CREATE_DEFAULT_INSTANCE();
byte current_note = 23; //Contains the currently played midi note.
//Defaults to 23 so the CV input of 0v will start
//at C (32.7 hz) even if MIDI input is not used.
float last_CV = 0.0; //Sotres last CV value. This is used to determnine
//change in CV over time (for OLED refresh)
//MIDI note on handler:
void handleNoteOn(byte channel, byte note, byte velocity){
current_note = note;
}
//MIDI note off handler: (nothing to do here really)
void handleNoteOff(byte channel, byte note, byte velocity){
}
/******************
* SETUP FUNCTION *
******************/
void setup() {
pinMode(WAVE_START_PIN, INPUT); //Set Wave Start pin as an input
// SSD1306 Display Setup
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)){ //Addr. 0x3C for 128x32
for(;;); //Fault. Stop here...
}
display.setTextSize(1); //Set Text Size to be really small
display.setTextColor(SSD1306_WHITE); //Make Text white
next_refresh = millis(); //Set next refresh time to be ASAP
//MIDI Setup
MIDI.setHandleNoteOn(handleNoteOn); //Add event handlers for Note On
MIDI.setHandleNoteOff(handleNoteOff); //Add event handlers for Note Off
MIDI.begin(); //Initiate Midi
tone(6, 880); //Set a tone to start with
}
/*****************
* LOOP FUNCTION *
*****************/
void loop() {
//--- SET THE TONE CLOCK FREQUENCY ---//
//Step 1: Read CV pin and convert it to between 0 and 5
//Analogread returns a value [0 to 1024], we need a value [0 to 5]
//You can simply multiply the reading by (5 / 1024) = 0.0048828125
float current_CV = float(analogRead(CV_PIN)) * 0.0048828125;
//Step 2: Convert MIDI note to an equivalent CV and add to current_CV
//Because there are 12 notes per octave, and one volt represents one
//octave, all we need to do is divide the current MIDI note by 12.
current_CV += float(current_note)/12;
//Step 3: Convert Control Voltage into a frequency
//We will assume that the zero'th MIDI note is 8.1758hz which is "C"
//Because there are 10 steps per waveform, we multiply this by 10
float freq = 81.758 * pow(2,current_CV); //BaseFreq x 2 ^ CV
tone( TONE_PIN, freq ); //Tone function sets CD4017 clock pin
//--- UPDATE THE SCREEN ---//
//Step 4: Determine whether it is safe to update the OLED screen
//When fast CV changes occur, it sounds like the tone quantizes.
//This is because the Arduino can't update the tone value while it
//is updating the OLED screen. By checking CV delta, we can see how
//much it changed and then skip the screen refresh to keep things
//buttery smooth.
if( abs(current_CV - last_CV) > .015 )
next_refresh = millis()+REFRESH_DELAY;
last_CV = current_CV; //save the current CV for the next loop
//Step 5: See if it is time to refresh the screen:
if( (millis() > next_refresh) ){
//Step 6: Wait for the start of a new wave by repeatedly
//checking the WAVE_START_PIN pin
while( digitalRead(WAVE_START_PIN) == LOW ){}
next_refresh = millis() + REFRESH_DELAY; //update refresh timer
//Step 7: Collect analog values for each column of the display
//We want the timing to be as fast and consistent as possible,
//hence we just read and store the values
for( byte i=0; i<SCREEN_WIDTH; i++ ){
vals[i] = analogRead(OSC_IN_PIN);
}
//Step 8: Clear the OLED screen and plot each pixel on the screen
//Since the screen is only 32 pixels tall, we have to map the analog
//input value that goes 0-1024 to 0-32. Because both 32 and 1024 are
//powers of 2, we can right-shift the analog input by 5 bits leaving
//only the most significant 5 bits left over. Those remaining 5 bits
//go from 0-32. This method is extremely fast, but if you have a
//different screen height, you will need to adjust this function.
display.clearDisplay();
for( byte i=0; i<SCREEN_WIDTH; i++ ){
display.drawPixel(i, vals[i]>>5, SSD1306_WHITE);
}
//Step 9: OPTIONAL - Display some values on the screen
display.setCursor(0, 25); //Current MIDI note in the bottom left
display.print(current_note);
display.setCursor(0, 0); //Combined CV in the top left
display.print(current_CV);
display.print(" "); //Clock freq after the CV value
display.print(float(freq)/10);
display.print("hz");
//Step 10: Update the display
//Since all changes were made to a hidden display buffer, we have to
//tell the screen update the display based on the buffered changes
display.display();
}
MIDI.read(); //Update MIDI to see if any signals came through
}