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:
void YM3812::chPlayNote( uint8_t ch, uint8_t midiNote ){
regKeyOn( ch, 0 );
if( midiNote > 114 ) return;
if( midiNote < 19 ){
regFrqBlock( ch, 0 );
regFrqFnum( ch, FRQ_SCALE[midiNote] );
} else {
regFrqBlock( ch, (midiNote - 19) / 12 );
regFrqFnum( ch, FRQ_SCALE[((midiNote - 19) % 12) + 19] );
}
channel_note = midiNote; // <--- Save midiNote into channelNote
regKeyOn( ch, 1 );
}
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.
void YM3812::noteOff( uint8_t midiNote ){
if( midiNote == channel_note ){
regKeyOn(0,0);
}
}
Finally, we’ll update the handleNoteOff function in our .ino file to point to the new noteOff function.
void handleNoteOff( byte channel, byte midiNote, byte velocity ){
PROC_YM3812.noteOff( midiNote );
}
And here is a demo of legato mania. Woot!
The Polyphonic “Rotate” Algorithm
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:
void handleNoteOn( byte channel, byte midiNote, byte velocity ){
PROC_YM3812.noteOn( midiNote );
}
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.
uint8_t num_channels = 3;
uint8_t channel_notes[3] = {0,0,0};
uint8_t last_channel = 0;
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:
void YM3812::noteOn( uint8_t midiNote ){
last_channel = (last_channel + 1) % num_channels;
channel_notes[ last_channel ] = midiNote;
chPlayNote( last_channel, midiNote );
}
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.
void YM3812::noteOff( uint8_t midiNote ){
for( uint8_t ch = 0; ch<num_channels; ch++ ){
if( channel_notes[ch] == midiNote ) regKeyOn( ch, 0 );
}
}
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.
uint8_t num_channels = YM3812_NUM_CHANNELS;
YM_Channel channel_states[YM3812_NUM_CHANNELS];
uint8_t last_channel = 0;
Now we need a new function that finds the best channel to play the next note on—chGetNext()
chGetNext() Function
The chGetNext function finds the best channel index to play a new note on and returns it. Have a look:
uint8_t YM3812::chGetNext(){
// Tracking Variables:
uint8_t on_channel = 0xFF;
uint8_t off_channel = 0xFF;
unsigned long oldest_on_time = millis();
unsigned long oldest_off_time = millis();
// Calculate oldest on/off times:
for( uint8_t ch=0; ch < num_channels; ch++ ){
if( channel_states[ch].note_state ){
if( channel_states[ch].state_changed < oldest_on_time ){
oldest_on_time = channel_states[ch].state_changed;
on_channel = ch;
}
} else {
if( channel_states[ch].state_changed < oldest_off_time ){
oldest_off_time = channel_states[ch].state_changed;
off_channel = ch;
}
}
}
// Return the best channel:
if( off_channel < 0xFF ){
return( off_channel );
}
return( on_channel );
}
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.
void YM3812::noteOn( uint8_t midiNote ){
last_channel = chGetNext();
channel_states[ last_channel ].midi_note = midiNote;
channel_states[ last_channel ].note_state = true;
channel_states[ last_channel ].state_changed = millis();
chPlayNote( last_channel, midiNote );
}
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.
void YM3812::noteOff( uint8_t midiNote ){
for( uint8_t ch = 0; ch<num_channels; ch++ ){
if( channel_states[ch].midi_note == midiNote ){
channel_states[ ch ].state_changed = millis();
channel_states[ ch ].note_state = false;
regKeyOn( ch, 0 );
}
}
}
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:
uint8_t num_channels = YM3812_NUM_CHANNELS;
YM_Channel channel_states[YM3812_NUM_CHANNELS];
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:
- Mono Synth: https://github.com/TylerK07/YM3812-Module/tree/master/Articles%205/YM3812_Mono
- Polyphonic Rotate Algorithm: https://github.com/TylerK07/YM3812-Module/tree/master/Articles%205/YM3812_PolyRotate
- Polyphonic “Smart” Algorithm: https://github.com/TylerK07/YM3812-Module/tree/master/Articles%205/YM3812_PolySmart
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!