At the end of 2022, Benjie from Benjiao Modular released an Atari Punk Console synthesizer using a couple of 555 timer chips and a handful of other components. Now, there are lots of 555-based synthesizers out there, but in this case he printed it onto a PCB the size of a business card and added his contact information into the silkscreen of the PCB. Thus the SynthCard was born!
This clearly genius idea gained traction and soon others followed. Karltron created a triple VCO/LFO SynthCard, and Juanito Moore of Modular for the Masses created an 808 Kick Drum and MS-20 style filter.
Now, I don’t want to get too side tracked here because ultimately, we are still trying to build a YM3812 EuroRack module. But, all of the hardware we’ve needed for the first eight articles of this series fit nicely onto a single breadboard. And that got me wondering… would it be possible to fit all that onto a SynthCard sized PCB? What if it could only use through-hole components?
Well, this wouldn’t be much of an article if you couldn’t, so… yes! You totally can!
The YM3812 SynthCard
This business card sized module takes a MIDI input, generates sound using a YM3812 OPL2 sound processor and then outputs that sound through a mono jack. The schematic for this is basically the same as the one we created all the way back in article 4, but I’ve included a status LED and also a 5v voltage regulator.
It also has all of the same great features implemented so far:
Support for the General MIDI 1.0 patches and instrument selection
Support for percussion patches on MIDI channel 10
Support for level control for on each incoming note
What can I do with it?
My favorite use case for this module is to send it General MIDI music from video games. While you might think it would sound the same as an Adlib card—because it has the same OPL2 sound chip—the instrumentation can be quite different. For example, take a look at this side by side comparison of Leisure Suit Larry 3:
Notice how the original Adlib instrumentation uses far fewer voices than the General MIDI? I find that very strange because both the Adlib card and this SynthCard use the same OPL2 chip. While the OPL2 chip is clearly capable of playing more sophisticated music, the Adlib instrumentation doesn’t take advantage of it. This leads me to believe that perhaps the original Adlib composition statically assigned patches to each voice and kept them the same throughout the song. Perhaps dynamically reassigning them was too CPU intensive? If anyone knows the answer here, I’d love to hear it!
Bottom line, General MIDI music rendered on an OPL2 chip sounds very different in this game. (I dare say, even better!)
Then go to JLCPCB (or p C B WAAAAaaay!) whichever PCB manufacturer you like best, and drag the zip file onto the “Add Gerber File” box. After a few seconds, you will see an order form. There are only a couple of key changes to make:
The most important change is to select LeadFree HASL. Sure it adds $1 to the price, but since this is supposed to be like a business card that you can pass around and handle… with your hands… you really want to avoid getting lead poisoning. And with my name on it… I just don’t want to feel responsible for that. So please, go unleaded. (And print at your own risk).
OK, enough disclaimers. From here you are able to select your favorite color and then you can also choose “Specify Location” for the Remove Order Number option. (I specified a location in the Gerber File).
From there, just go ahead and place your order! For the low, low price of $3 (plus ~$16 shipping and handling) you get not one… but TWO boards. And if you act now, they will MORE THAN DOUBLE YOUR ORDER and send you FIVE BOARDS! Juuuuust kidding. This deal lasts forever (it’s their minimum order quantity). It’s still a pretty great deal though. So get 5 and pass the extras around to your friends!
How do I build one?
First, you are going to need a few parts. If you have been following along, you probably have most of these parts already—especially the AVR microcontroller, the YM3812, Y3014b, and other chips, as well as the passive components. This board adds just a few new components that you’ll need to source. Specifically, a 78L05 voltage regulator (in a TO-92 package), a 1N4001 rectifier diode, and a mono (black) thonkiconn jack and a stereo (green) thonkiconn jack. For U6 I’m using an OPA2604 operational amplifier instead of a TL072 because this is a single power rail design, but in theory, you might be able to get away with an alternative op-amp. Just play with it a bit.
Bill of Materials
Here is the full list of materials that you will need to populate the board:
CAPACITORS:
• 100 nF (Ceramic)
x9
C1-C6, C11-C13
• 4.7 uF (Electrolytic)
x1
C7
• 10 uF (Electrolytic)
x3
C8-C10
RESISTORS:
• 220R (Axial)
x2
R1, R2
• 100K (Axial)
x1
R3
• 2K (Axial)
x1
R6
• 1K (Axial)
x3
R4, R5, R7
DIODES:
• 1N4148 Signal Diode(Axial)
x2
D1, D2
• 1N4001 Rectifier Diode (Axial)
x1
D4
• LED (3.0mm)
x1
D3
JUMPERS:
• Pin Header 01×02 2.54mm Vertical
x3
J1, J2, J6
• Pin Header 01×04 2.54mm Vertical
x1
J3
JACKS:
• MIDI IN: Green Thonkiconn Stereo 3.5mm Audio Jacks (PJ366ST)
Hopefully the labeling is mostly self explanatory. On the front, I’ve listed all of the component values, and on the back, you’ll find all of the component reference numbers. Here are the usual tips and gotchas for building modules:
Start with the smallest / flattest components first—This is a pretty densely populated board, so soldering things on in the right order will make your life easier. Start with the resistors, diodes, capacitors, and voltage regulator. Then move on to the switches, ICs and the oscillator. Finally solder on the jumpers and Thonkiconn jacks.
Check the alignment of polarized components. The line on the diode should match the line on the board. The strip on the electrolytic capacitors should match the white silk screen on the board. The 78L05 should align to the shape in the silk screen. The longer pin of the LED should go in the round hole. The notch in the ICs (or IC sockets) should match the notch on the board. The square corner of the oscillator should match the corner on the silk screen.
Sockets vs. No Sockets – This is totally up to you. I personally use sockets, but I know there are lots of arguments on why NOT to use sockets (the connections fall out, sometimes they cost as much as the chips, etc.). I would recommend using a socket for the YM3812, Y3014b and the AVR128DA28 as those are either the rarest and/or most expensive chips on the board. To be on the safe side, you might want to power up the board and test the voltages before plugging them in.
Stereo (Green) Thonkiconn jack is used for MIDI In, and the Mono (Black) Thonkiconn jack is used for the Audio Output.
Powering Up!
The 5v regulator allows you to power this device using anything from 6v to 12v DC. No negative voltages are required, so you could use a 9v battery, an external power supply, or even just plug it into the positive end of a EuroRack connector.
Alternatively, you can plug a regulated 5v supply into the UPDI header at the top of the card. This allows you to power the device directly from an USB to FTDI cable or even an external Li-Po battery.
CAUTION: The regulated power connectors on the left and right side of the board have reverse polarity protection, but the 5v connector on the top does not. Be sure to only use 5v and make sure you plug it in the right way around!
Uploading the firmware
The firmware for this module is exactly the same as what we’ve built throughout the article series thus far. Just go find the latest code from Article 8 and then upload it from the Arduino IDE using an FTDI cable. There are lots of details on how to do that throughout this series, but especially in Article 2.
Usage and Troubleshooting
To use a MIDI cable, you will need an adapter. These are pretty easy to find online, but there are several different versions. You will want a version that connects pins 4 and 5 of the MIDI connector to the tip and ring of the 3.5mm jack. I talk more about the different standards in Article 4 if you want some more background. Depending on which version of the adapter you get, you may need to change the direction of the Korg/Art switch on the left side of the SynthCard. If you have a beat step pro or another device that uses a TRS jack for MIDI output, you may be able to connect it directly using a 3.5mm stereo audio cable (no adapter required!).
If you turn the device on and the red activity light just stays on, press the reset button a bunch of times until it turns off. I have yet to track down why that happens. If you guys have any ideas, let me know in the comments!
If things still don’t work, check that all of the polarized parts are in the right way around.
You want some more?
Well, you are in luck! Benjie put together a website, synthcard.com, to host other SynthCard projects! I hope others will join in the fun and create even more mini-synth business card goodness. If you want jump into the conversation, then head on over to the Modular for the Masses discord community.
Errata
The microswitch was labeled as an EG1271 which is an SPDT switch. The correct part should be EG2271 which is a DPDT switch. Many thanks to Peter for debugging this one and identifying the issue!
Today, it’s time to take our module to the next level. Lots of levels in fact! See, right now our module is full volume all the time. And well, music is more than notes and drum sounds—it needs dynamics! Fortissimo! pianissimo and every level in between. Of course, our General MIDI friends thought of this and, their solution has been staring at us the whole time! Here check out the MIDI note-on handler function:
Velocity refers to the speed that you press a key on the keyboard. People often refer to this as “Touch Sensitivity,” but in most keyboards, this is a measure of key press speed, not the amount of pressure applied. There ARE keyboards that measure pressure, and that can be useful in something called “aftertouch” but we aren’t going there… yet…
Every time you press a key, a touch sensitive keyboard communicates both the note number and the velocity over MIDI. The MIDI library passes both of these values to the note on handler function. From there, it’s up to us to do something with it.
Level Scaling
Scaling the level of a channel should be pretty straight forward right? Just take the current level of the note and multiply it by the fraction of velocity / 127. This way high velocity notes will be louder proportionally to velocity. Right? Well, if it was that simple, this would be a boring article. So, not quite. There are a couple of gotchas here we need to talk about.
Operator Scaling
As you might suspect, the level setting controls the loudness of a channel. But level isn’t a channel setting. Level is an operator setting. And our patch definition has four different level settings per channel. So the question becomes, which level setting should we change? To figure that out, we need to review how an operator’s level affects the sound production.
Mixing
Sticking with the two operator capability of a YM3812, there are only two ways to configure the “algorithm.” Mixing and Frequency Modulation. If we mix the two operators together, then BOTH operators contribute equally to the sound output. And, to scale that sound, we need to scale the level of BOTH operators.
Frequency Modulation
In frequency modulation mode, the two operators play very different roles—carrier and modulator. In this mode, only the carrier—operator 2—affects the level of the sound. The level of modulator—operator 1—affects the timbre of the output sound instead. So, we only want to scale the level of operator 2. If we scaled operator 1 as well, then the sound would become brighter the harder we press a key. This could be an interesting way to add after-touch, but again, not what we are trying to do today.
So, let’s put this all together:
First check the algorithm to see if we are mixing or modulating
If mixing, scale both operators
If modulating, only scale the carrier operator (op2)
Calculating Level
Now that we know which numbers to combine, we need to figure out how these properties work. The YM3812 accepts a 6-bit value for the level property. That means it supports values from 0 through 63. On the other hand, the level value of our patch goes from 0 through 127. Take a look at article 6 if you need a refresher on why that is. Similarly, our velocity value also goes from 0 through 127 as well. To put these together, we need to scale level based on the percentage of velocity through 127.
patchLevel * velocity / 127
This formula results in values between 0 and 127, but we need those values to go from 0 to 63. So to fix that, we just need to divide the whole thing by 2 again. Or, better yet, divide by 256. (Yes I know that 127 x 2 is 254, but it actually doesn’t change the integer solutions and 256 is much easier to divide by):
patchLevel * velocity / 256
Let’s plot out some values based on this formula to better understand what’s going on. The table below shows patch levels (columns) and velocities (rows) from 0-127. I’ve also color coded the cells so things are easier to see:
The color coding does a nice job here of showing how the highest values sit in the top right corner. And then those values decrease as you move left or down. This also brings to light our first “gotcha.” The value of Patch Level (for some reason) assumes that 0 is the loudest and 127 is the quietest. We need our values to radiate from the top left corner, instead of the top right. To do that, we can just invert the Patch Level:
(127-patchLevel) * velocity / 256
By subtracting patchLevel from the highest possible value (127), our table plot flips around. Let’s take a look:
Ok, now we are getting somewhere. The top left corner of the table appears to be going from loud to soft in all directions. Now for the second “gotcha.” The YM3812 also expects that zero is the loudest value. So now, we need to invert the entire table:
63 - ((127-patchLevel) * velocity / 256)
Just like before, we take the highest possible value (63) and subtract the entire formula. And now, plotting this out, you can see that the loudest values sit in the top left corner. As you move to a softer patch level (63) or a lower velocity (0) the formula gets closer to 63.
Beautiful. Now that we have our formula, let’s go write some code!
Code Updates!
For the most part, we only need to make a few tweaks from the last article. To keep this straightforward, let’s start at the noteHandler function and work our way inward.
The first change is pretty simple. We need to pass the velocity argument on to the patchNoteOn function of our YM3812 library. Of course for patchNoteOn to accommodate this new argument we have to update that too. So let’s do that next—starting with the function definition in the YM3812.h file:
Remember that there are two versions of the patchNoteOn function, and they both need to be updated. The second version simply calls the first, but now passes velocity along. Now let’s modify the patchNoteOn implementation. This code is in the YM3812.cpp file:
patchNoteOn chooses the next YM3812 channel and then keeps track of the note metadata. We are going to track velocity—just like the midiNote—by saving it into the channel_states array. The one new line of code in this function saves velocity into the channel_states entry for the current channel. This will make the information available later when we need to update the settings of the YM3812. Now, remember that channel_states is an array of YM_Channel data structures. So, to save velocity into it, we need to add a velocity property to the data structure:
After tracking the channel states, the patchNoteOn function calls chPlayNote which in turn calls chSendPatch before playing the note. And, chSendPatch configures the YM3812 for the instrument we want to play, and this is where we get to work our magic. For reference, here is the full function:
Most of this function works exactly as it did before. But now, instead of using the operator’s level value in the patch data, we calculate it using some fancy math. And that math comes in the form of these 5 lines of code:
The first line of code decides whether or not to scale the level of the patch:
Thinking back to the algorithm we talked about earlier, there’s only one case where we don’t scale the level: when the algorithm is zero (FM mode) AND the operator is zero (the modulator operator). In this case, level controls timbre not volume, but in every other case, level affects the volume of the sound. Chips with more operators, require a more complex table, but this one is nice and simple.
The second line of code handles the “don’t scale” case by assigning the current patch’s level value to op_level. This line also right shifts the patch value by one so it falls into the 0 to 63 range. The fourth line handles the “do scale” case, and adjusts the patch’s level proportionally to velocity. While the code looks a little complicated, it really just derives from the formula we created earlier:
63 - ((127-patchLevel) * velocity / 256)
First let’s substitute patchLevel with the reference to the level value in the patch array:
And finally, we use one more trick. Whenever you divide an integer by a power of 2, you can right shift instead to do it MUCH faster. Here, for example, we right shift by 8 bits to divide by 256. Because these are integers, you need to ensure that the numerator is larger than the denominator. This is why we multiply level and velocity before right shifting by 8—hence the parenthesis.
And that’s how you derive that line of code. There’s one more small tweak to make in this function. We need to update the sendData command to use our newly calculated op_level value:
In this first demo, let’s see how this SHOULD sound when only carrier operators are scaled:
Notice how the waveforms scale up and down with the note velocity, but otherwise doesn’t change? This works because in FM mode we don’t scale the modulator operator. Just for the heck of it, let’s see what happens when we scale everything including the modulator operator:
Notice here how the edges and corners of the waveforms become more pronounced as velocity increases. This is how that modulator operator affects the sound. Now, there may be times where it makes sense to have velocity change the quality of a note. Maybe a bass that gets more plucky the harder you play the note? That feels to me like a special case, but perhaps there is an opportunity to add velocity response as an operator property in the patch? Definitely open to feedback there.
Conclusion & Links
I don’t know about you, but I never thought scaling the level of a note would be so difficult. Still, this opens the door to some other cool features down the road like more granular panning—once we have multiple chips of course. As always you can find the code on GitHub. And if you run into any issues, feel free to drop a comment below. For the next article, I’m a bit torn between pitch bend and virtual 4-op voices. If you have a preference, let me know!
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 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
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 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:
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.
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.
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:
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)
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
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
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
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?
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.
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:
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!
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:
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:
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.
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
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.
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:
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
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
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
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:
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:
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:
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!
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
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.
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:
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.