The 303 and MIDI


At the time when the TB-303 was made, there was no MIDI yet.
Some time later, MIDI popped into existance, and thru the years various MIDI kits were designed for the 303, as well as a number of 303 clones and emulations (hardware and software), most of which have MIDI.


 

How does the MIDI implementation work in a 303/clone?


Typically using MIDI Notes, where playing Legato (overlapping notes) results in Slide, and the Note Velocity is split into two zones to derive the Accent.

There are various additional features in some implementations, and other inconsistencies.


 

What's the problem?

In many implementations - TB-303 patterns don't fully translate. They translate most of the time, but there are cases where they don't translate 100% accurately.
The problem is with the Accent in a specific case.

A minimum example of the problem is:
- A slide between two notes with the same pitch, but different Accent attributes.

This is not something rare. It happens normally in 303 patterns, it's perfectly valid to slide between two or more notes with the same pitch.

When this occurs, the results can be different depending on the exact implementation, but here are two examples:
- Accent is missing and/or doesn't match, the remaining attributes of the pattern translate
- The pattern becomes fragmented, and/or some notes get cut-off short, and/or some slides are gone.

In the following sections, I will go into the details to show exactly where things break.


 

Sequencer->Synth


To fully understand the problem, we need to know a few things about the normal behaviour of the 303.

Timing:
The sequencer runs on a 24ppqn clock, and has 2 step-modes (4/4 and 3/4). But the really important things happen at just two specific moments.

Step: the duration of a step is either 4/4 or 3/4 of a beat.
The two moments that matter are the beginning of the step, and the middle of the step (I'll call this the "Mid-Step" moment).

Pattern:
The pattern is sliced into steps, where for each step, there can be a valid Note, or a Rest.


The sequencer controls the Synth using 4 control signals:

Slide:
The slide is set in the beginning of a step, and doesn't change Mid-Step. It's either ON or OFF.

Pitch:
The pitch is set in the beginning of a step, and doesn't change Mid-Step, nor if the step is a Rest.
The normal range goes from 1 to 5 Volts, which covers notes C1 (~32.7Hz) to C5 (523.2Hz).

Accent:
The accent is set in the beginning of a step, and doesn't change Mid-Step, nor if the step is a Rest.
It's either ON or OFF. The actual control voltage in the circuit is inversed (ON=LOW, OFF=HIGH).

Gate:
The gate can be raised only in the beginning of a step, and lowered only on a Mid-Step.
It's either ON or OFF.
Retriggering of the envelopes (of the VCF and VCA) happens when the Gate is raised after it has been low for at least a short amount of time.


Here are pseudocode prototype functions for the note_on() and note_off() functions for the Sequencer->Synth control:

// Sequencer -> Synth control

void note_on(uint8_t note, bool slide, bool accent) { SLIDE = slide; PITCH = note; // <some latch code here> GATE = 1; ACCENT = !accent; // inverted }

void note_off(void) { GATE = 0; }


Playing the patterns:
As explained above, while playing patterns the Sequencer only has to act two times per step.

The Mid-Step moment has a few more key roles, at that point, the sequencer must load the NEXT step, which may require it to load the NEXT pattern, which may require it to load the NEXT chain, etc..

In the 303 sequencer - a new note begins with the Slide OFF.
The Slide signal is ON on a step, if the previously played step had Slide, and the current step is a valid Note.


Here is pseudocode of how the Sequencer controls the Synth while playing patterns:

// On the Beginning of a step:

{ <(re)load the current step from the pattern> }

s1 = s0; s0 = <get the slide flag from this step>;

if (<this step is a Rest>) { s0 = 0; } else { uint8_t note = <get the pitch from the note>; bool accent = <get the accent from the note>; bool slide = s1; note_on(note, slide, accent); }

// On the Mid-Step:
    
    { <load the NEXT step from the pattern> }
    
    if (<next step is a Rest>) { s0 = 0; }
    if (s0 == 0) { note_off(); }

When the sequencer stops playing: set s0 to 0.


 

The test


I've made a test pattern designed to expose the problem:


Fig. 1: The Reference pattern.

At the top, there's an illustration of the Gate, Accent, and Slide control signals.

On some steps, there are yellow asterisk marks on the Slide signal. The Slide signal on those steps is probably high (i haven't had the chance to test that), but it can be low as well, since it practically doesn't matter - the pitch remains the same in those situations.

In the middle, there's the actual Notes (the Accent and Slide attributes are marked with capital A and S).

There are 5 cases marked above the pattern. They will be referenced later on. Cases 3, 4, and 5 are the problematic ones.

On the bottom, there is an illustration of the Notes as they would be sequenced on a typical Piano-Roll-style sequencer.

Non-accented notes are shown with black, while accented - with green.

The horizontal grid shows the step. The Gate signal has additional grid divisions to show the Mid-Step moments.


Here is an audio recording of the Test pattern, played by a x0xb0x:

Note: the first two steps from Fig. 1 are excluded from the pattern.


 

Sequencer->MIDI


The first half of the puzzle is to add MIDI-Output implementation.

Generating MIDI from the patterns:

At any given moment, the Sequencer should only need to overlap 2 MIDI notes.

// Sent MIDI Notes (memory)

uint8_t note_x1 = 0xFF; uint8_t note_x2 = 0xFF;

// On the Beginning of a step:

{ <(re)load the current step from the pattern> }

s1 = s0; s0 = <get the slide flag from this step>;

if (<this step is a Rest>) { s0 = 0; } else { uint8_t note = <get the pitch from the note>; bool accent = <get the accent from the note>; bool slide = s1; note_on(note, slide, accent);

// MIDI-OUT: uint8_t note_x0 = note | (accent << 7); if (note_x0 != note_x1) { note_x2 = note_x1; note_x1 = note_x0; uint8_t velo = (accent ? 127 : 63); send_midi_noteON(note, velo); } }

// On the Mid-Step:
    
    { <load the NEXT step from the pattern> }
    
    if (<next step is a Rest>) { s0 = 0; }
    if (s0 == 0) { note_off(); }

// MIDI-OUT: if (note_x2 != 0xFF) { uint8_t note = note_x2 & 0x7F; bool accent = (note_x2 >> 7); // top bit uint8_t velo = (accent ? 127 : 63); send_midi_noteOFF(note, velo); note_x2 = 0xFF; } if ((s0 == 0) && (note_x1 != 0xFF)) { uint8_t note = note_x1 & 0x7F; bool accent = (note_x2 >> 7); // top bit uint8_t velo = (accent ? 127 : 63); send_midi_noteOFF(note, velo); note_x1 = 0xFF; }

When the sequencer stops playing: set s0 to 0, and check if note_x1 and note_x2 are equal to 0xFF. If they aren't - it means they are hanging and must be ended manually (send_midi_noteOFF()).


 

MIDI->Synth


The second half of the puzzle is the MIDI-Input implementation.

The Synth here simply waits for MIDI Notes from the outside. There are no Steps here, nor a time grid.

 

With rv0's assistance, we had our hands on one of the first MIDI-IN kits for the 303 - the "MIDI303" kit (which seems to have vanished from the internet now).
We tested it, then I tried to emulate its behaviour on a x0xb0x. Then, rv0 tested my implementation against the MIDI303, and it worked identically.
It turns out that the MIDI-IN implementation in the stock x0xb0x firmware (even in Sokkos2, where it hasn't changed) is virtually identical to the code I wrote.
This isn't surprising at all, given the simplicity of the algorithm behind it.

I'll call this the "Classic-Naive" implementation.


 

Classic-Naive implementation


This is the most simple implementation I can imagine.

It's present in at least two devices:


Here is the MIDI-IN pseudocode:

// memory
uint8_t prev_note = 0xFF;

on_midi_noteON(uint8_t note, uint8_t velocity) { bool slide = (prev_note != 0xFF); bool accent = (velocity >= 100);

note_on(note, slide, accent);

prev_note = note; }

on_midi_noteOFF(uint8_t note, uint8_t velocity) { if (prev_note == note) { note_off(); prev_note = 0xFF; } }

 

Now let's see what happens when we try to drive this algorithm with the Test pattern (MIDI generated by the Sequencer):


Fig. 2: Classic-Naive results


Result: Fail on cases 3, 4, and 5.

Case 3: The first note comes in, note_on() is called and prev_note is set to "C2". Then on the next step, the accented note comes, Slide and Accent are raised, prev_note is set to "C2" again. But at the middle of the step - the first note ends, and that's where things break. The code sees a MIDI NoteOFF for "C2" which equals prev_note. Thus note_off() is called and the Gate is lowered. In the middle of the next step, the accented note's end comes, and nothing happens, since prev_note is already 0xFF.

Case 4: Similar to Case 3, except for the third step. On the third step, prev_note is already 0xFF, when a non-accented D#2 note comes - it results in a new note - the Gate is raised, the envelopes are retriggered, and the Slide is OFF for that step. Then at the Mid-Step - two MIDI NoteOFF messages come, one for the C2 note, which does nothing, and another one for the D#2 note, which equals prev_note and results in a note_off() call.

Case 5: Similar to Case 4, except the D#2 note is a C2 note, and what happens on the 2nd and 3rd steps in Case 4 is repeated here twice.


Here is an audio recording of the result:

Observation: with the Classic-Naive implementation, the problematic cases cause some notes to be cut-off, or fragmented into smaller chunks, and some Slides to be missed.


 

Work-arounds:
To translate the Test pattern without altering the MIDI-IN algorithm of the Classic-Naive implementation, we can instead alter the MIDI Notes themselves (and thus the MIDI-OUT implementation).
I can think of at least two ways:


 

Classic-Naive work-around 1


Keep in mind that when the same note is overlapped, the first MIDI NoteOFF with that same pitch (equal to prev_note) is enough to result in note_off() and thus lowering of the Gate.
In that situation, we can extend the endings of those notes, so that they occur either at the point where the Gate should really be lowered, or at a point after another note (with different pitch) has occured.
 


Fig. 3: Classic-Naive work-around 1: Extending the notes


This will pass the Test pattern, but it's not a very good solution.
The notes from Fig. 3 can be sequenced like that in many Piano-Roll-style (or other) sequencers, even if it might be inconvenient in some.
But it's not practical for the Sequencer MIDI-OUT implementation.
Below the Pattern in Fig. 3, there are labels indicating the number of MIDI NoteOFF messages that would occur at those moments in time.
Specifically in Case 5 - that number is 5. That's 5 NoteOFF messages that would have to be generated and sent out at that single moment.
This can easily lead to disaster. Think of a case where the two notes [e(S), e(AS)] are repeated together over and over.


 

Classic-Naive work-around 2


Another way to do it is to avoid sending MIDI NoteOFF messages in the problematic situation.

This is very easy to implement in the MIDI-OUT code.
However, the number of NoteONs will not be equal to the number of NoteOFFs, which is a bad practice IMO (even if it wouldn't cause disasters).
Notes with missing NoteOFFs can't be sequenced probably in most sequencers.
Missing NoteOFFs can cause stuck notes in some situations.

This work-around will pass the Test pattern easily, but a better solution is very welcome.


 

Modified-Naive implementation


A small modification to the Classic-Naive MIDI-IN algorithm can be made, to handle the problematic case:

// memory
uint8_t prev_note = 0xFF;
uint8_t mnmii_fix = 0;

on_midi_noteON(uint8_t note, uint8_t velocity) { bool slide = (prev_note != 0xFF); bool accent = (velocity >= 100);

note_on(note, slide, accent);

if (prev_note == note) { ++mnmii_fix; } prev_note = note; }

on_midi_noteOFF(uint8_t note, uint8_t velocity) { if (prev_note == note) { if (mnmii_fix) { --mnmii_fix; return; } note_off(); prev_note = 0xFF; } }

When the same note is overlapped - a counter increments. When that note goes OFF - the counter first decrements an equal number of times before it lets the code reach note_off().

Result: This implementation passes.


 

Classic-MVA implementation


In contrast to the Naive approach, a smarter way to do things is using a proper Voice Allocating algorithm.

Voice Allocation is typically used in polyphonic synths. When too many notes have to be played at the same time, and the synthesizer runs out of free voices - it is the Voice Allocator's job to decide which of the notes has highest priority to steal a voice.

They are also used in monophonic synthesizers, and polyphonic synthesizers in monophonic mode.

In the context of a monophonic synth - things are simple, the Voice Allocator is thus Monophonic (I'll call it "MVA" for short), and it comes down to what kind of note-priority rule would be used.
For the 303 specifically, some additional things must be added to the MVA and around it, in order to properly deal with the Slide and Accent.

There are probably many ways to implement a MVA algorithm for the 303, and I haven't had the chance to probe/test/study an existing implementation of one.


Existing variants of MVA-based implementations are present in at least the following MIDI-IN kits:

Variants of MVA-based implementations are also present among the software 303 emulations.


What's the idea behind the MVA?

The algorithm uses an array, in which it stores (remembers and keeps track of) up to N currently held Notes.
When keys (as on a keyboard) are pressed and released - the algorithm uses its note-priority logic to sort the held Notes, and to decide which of them has the highest priority to "take" the voice of the monosynth.

The MVA in our case must deal with the "gotchas" of the 303.


The following pseudocode is for a basic MVA, suitable for the 303. It's not meant to mimic the exact behaviour of any of the existing implementations.

// Monophonic Voice Allocator (with Accent, suitable for the 303)
// "Newest" note-priority rule

#define MIDI_MVA_SZ 8 typedef struct { uint8_t buf[MIDI_MVA_SZ]; uint8_t n; } mva_data;

void mva_note_on(mva_data *p, uint8_t note, uint8_t accent) { if (accent) { accent = 0x80; } uint8_t s = 0; uint8_t i = 0;

// check if this note is already in the buffer while (i < p->n) { if (note == (p->buf[i] & 0x7F)) { // it is. update it? s = 1; p->buf[i] = note | accent; break; } ++i; } if (s == 0) { // the note wasn't in the buffer // shift all notes back uint8_t m = p->n + 1; m = (m > MIDI_MVA_SZ ? MIDI_MVA_SZ : m); s = m; i = m; while (i > 0) { --s; p->buf[i] = p->buf[s]; i = s; } // put the new note first p->buf[0] = note | accent; // update the voice counter p->n = m; } }

void mva_note_off(mva_data *p, uint8_t note) { uint8_t s = 0; uint8_t i = 0;

// find if the note is actually in the buffer uint8_t m = p->n; while (i < m) { if (note == (p->buf[i] & 0x7F)) { // found it! if (i < (p->n - 1)) // don't shift if this was the last note.. { // remove it now.. just shift everything after it s = i; while (i < m) { ++s; p->buf[i] = p->buf[s]; i = s; } } // update the voice counter if (m > 0) { p->n = m - 1; } break; } ++i; } }

void mva_reset(mva_data *p) { p->n = 0; }

Here's how it fits in the MIDI-IN code:

// Classic-MVA MIDI-IN implementation

mva_data mva1;

on_midi_noteON(uint8_t note, uint8_t velocity) { mva_note_on(&mva1, note, (velocity >= 100));

bool slide = (mva1.n > 1); bool accent = (mva1.buf[0] >> 7); // top bit note = mva1.buf[0] & 0x7F;

note_on(note, slide, accent); }

on_midi_noteOFF(uint8_t note, uint8_t velocity) { if (mva1.n == 0) { return; } uint8_t tmp = mva1.buf[0]; mva_note_off(&mva, note);

if (mva1.n > 0) { if (mva1.buf[0] != tmp) { bool accent = (mva1.buf[0] >> 7); // top bit bool slide = 1; note = mva1.buf[0] & 0x7F; note_on(note, slide, accent); } } else { note_off(); } }

Playing the Synth in legato style with an MVA-based implementation via a MIDI Keyboard is much better compared to the Naive implementation.
But let's see how this responds to the Test pattern:


Result: Fail on Cases 3, 4, and 5.
This implementation fails pretty much the same way as the Classic-Naive algo.

This was the third time I write such an algorithm (monophonic voice allocation), and I always get something wrong at the end.


 

Classic-MVA work-around 1


This is a work-around which I've implemented already in my x0xb0x firmware (n0nx0x v2.10).

The idea was to use both the NoteON and NoteOFF velocity in the MVA, in order to distinguish accented from non-accented notes as they are released.
Two instances of the same note (with different accent value) can then exist within the buffer.
It requires a small modification to the MVA algorithm.

I knew it would cause potential problems if the NoteOFF velocity isn't consistent (for any reason) with the NoteON velocity (leading to unintentional sustained notes).
Once it went into the hands of the users - a number of them reported having issues with this in their setups (I also had in my own setup to begin with), thus I don't recommend it.
My conclusion here is: Avoid having to rely on NoteOFF velocity.


Result: This work-around passes the test.
However, playing the Synth via a MIDI Keyboard will lead to unintentional sustained (stuck) notes, if the Keyboard sends NoteONs and NoteOFFs with different velocity values for each note (and most keyboards do exactly that, which is perfectly normal).


 

Modified-MVA implementation


After some careful inspection of the problem, and the "fix" for the same problem as part of the Modified-Naive algo, I found a simple solution.
A small modification to the MVA algorithm can be made, in order to handle the problematic case.

I can simply let the MVA store the same Note multiple times. When searching for the Note to be deleted from the buffer - I will use a dirty trick - search backwards (oldest Notes first).

// Monophonic Voice Allocator (with Accent, suitable for the 303)
// "Newest" note-priority rule
// Modified version, allows multiple Notes with the same pitch

#define MIDI_MVA_SZ 8 typedef struct { uint8_t buf[MIDI_MVA_SZ]; uint8_t n; } mva_data;

void mva_note_on(mva_data *p, uint8_t note, uint8_t accent) { if (accent) { accent = 0x80; } uint8_t s = 0; uint8_t i = 0;

// shift all notes back uint8_t m = p->n + 1; m = (m > MIDI_MVA_SZ ? MIDI_MVA_SZ : m); s = m; i = m; while (i > 0) { --s; p->buf[i] = p->buf[s]; i = s; } // put the new note first p->buf[0] = note | accent; // update the voice counter p->n = m; }

void mva_note_off(mva_data *p, uint8_t note) { uint8_t s = 0;

// find if the note is actually in the buffer uint8_t m = p->n; uint8_t i = m; while (i) // count backwards (oldest notes first) { --i; if (note == (p->buf[i] & 0x7F)) { // found it! if (i < (p->n - 1)) // don't shift if this was the last note.. { // remove it now.. just shift everything after it s = i; while (i < m) { ++s; p->buf[i] = p->buf[s]; i = s; } } // update the voice counter if (m > 0) { p->n = m - 1; } break; } } }

void mva_reset(mva_data *p) { p->n = 0; }

And this fits into the MIDI-IN code the same way as the basic MVA implementation.


Result: This implementation passes the test.


 

Other implementations


We (at #x0xb0x) discussed various other approaches, most of which required a slightly different Sequencer->Synth implementation.
To mention some of the ideas:

Each of these approaches would work and properly pass the test, and fully translate 303 patterns over MIDI. But each of those ideas had its own kind of downsides.


 

Conclusion


A number of approaches were discussed here. I still don't think there is one of them so good that I can just pick it and cover all ground.
I think that it would have been great if individual MIDI Notes can be somehow identified (and reliably), but this isn't the case.

Each implementation has some kind of downside, some more, some less.


Results:

Implementation Result MIDI-IN MIDI-OUT Notes
Classic-Naive Fails (3, 4, 5) - -  
Classic-Naive work-around 1 Passes - bad^2 Extending the notes can potentially turn into a catastrophy.
Classic-Naive work-around 2 Passes - bad Missing NoteOFF events is not a good practice IMO.
Modified-Naive Passes - -  
Classic-MVA Fails (3, 4, 5) okay -  
Classic-MVA work-around 1 Passes bad - Relying on consistent NoteON & NoteOFF velocity is not too safe in practice.
Modified-MVA Passes okay -  

The MIDI-IN column shows how suitable the implementation is for sequencing with other sequencers/DAWs, and playing via MIDI Keyboards.
The MIDI-OUT column shows how suitable the generated MIDI data is for use with other devices.
Classic-Naive has neutral scores there. Some implmenetations have slightly better scores, some have bad scores.

To sum it up:

A Sequencer->MIDI implementation pseudocode was proposed.
A bunch of MIDI->Synth implementations were covered.

I recommend using the Modified-MVA approach, or, if its complexity is a problem - the Modified-Naive.

If you'll be rolling your own implementation, take this advice:

When implementing MIDI support on a 303/clone/emulation - it's very easy to overlook the tricky things with the Accent and Slide. I've done it myself probably 3 times, and it typically leads to incomplete translation.


 

Final details:


The pseudocode I've given is simplified on purpose, it lacks note offseting, handling of out-of-range notes, and other similar details.
I suggest using MIDI Note 24 (C1) as the default offset to the lowest note on the 303 (1V @ ~32.7Hz).


Going beyond the normal behaviour:

The normal way the 303 sequencer controls the synth when playing patterns was covered here.
However, in some implementations it might be desirable to go beyond those limitations.
For example the limitation about starting new notes always with Slide OFF. An implementation might want to remove this in order to get portamento.
In fact, the control signals (Slide, Accent, Pitch) can be changed at any time. All kinds of weird effects can be achieved this way.
These "extras" can be added to the MIDI-IN implementation in the form of MIDI CC or in some other way, but I will not go further into details.


If you find the information here useful, or if you have questions, or if you've found bugs/flaws/errors - don't hesitate to contact me.