198п. Mini-project: MIDI — a UART input at 31250 and a synthesizer on the DAC
MIDI is a UART. A slow one (31250 bit/s), with a current loop instead of voltage levels and a couple of tricks in the byte stream, but a UART all the same: start, 8 bits, stop. A keyboard sends "note 69 pressed with velocity 100" over it, and the device at the other end has to play it. Hence this mini-project: a MIDI input on a USART, parsing of the stream, and an eight-voice synthesizer that feeds the DAC through DMA with 32000 samples per second.
Tested without a board. The core (synth_core.c), the parser and the synthesizer,
was run in the experiment sim_midi.c: a stream with running status, a timing clock
byte in the middle of the data and a SysEx, the chord A4 + C#5 + E5, release,
ten notes on eight voices. The experiment was built by gcc on the PC and by arm-none-eabi-gcc
for Cortex-M4; in qemu-system-arm -M netduinoplus2 the output matched the PC
line for line. A Python model (midi_host.py) reproduced
all samples with its own code (the CRC-32 values matched), wrote the chord to a WAV file and found the note frequencies
from the spectrum. The firmware synth_f407.c was built by armclang with the headers of the F4 pack
without warnings; it was not run on a board.
References: the MIDI specification is not quoted here; only well-known
facts are used: 31250 bit/s, the message format, running status, a current loop with
an optocoupler; there are no electrical figures. RM0090: table 42 of DMA1 requests, the field
TSEL1 in DAC_CR; DS8626: PA3 (USART2_RX, AF7) and PA4 (DAC_OUT1).
UART is section 16, the DAC and the table-based generator are section 15, the phase
accumulator is 198л. Check: _verify/midi198p_check.py.
Terms used in the documentation
Term Meaning current loop, opto-isolator a current loop, an optocoupler status byte, data byte status byte (top bit 1), data byte (top bit 0) running status the status is not repeated until it changes Note On, Note Off, velocity key pressed, key released, how hard it was pressed System Real-Time, System Exclusive system real-time messages; SysEx is a vendor's "own" messages wavetable, phase accumulator a table holding one period of the wave, a phase accumulator voice stealing taking a voice away when no free one is left
198п.1 What the device does and what it is made of
| Function | How |
|---|---|
| MIDI input | optocoupler → USART2_RX (PA3, AF7), 31250 bit/s, interrupt per byte |
| parsing | running status, Note On / Off, CC 123; real-time messages and SysEx are skipped |
| synthesis | 8 voices: a 256-point sine table, a 32-bit phase accumulator, envelope 5 / 50 ms |
| sound | TIM6 → 32000 Hz → DAC 1 (PA4), samples through DMA1 Stream5 channel 7, two halves of 64 |
Pins come from the DS8626 tables: PA3 is AF7 USART2_RX, PA4 is the analog
output DAC_OUT1. Clock: an 8 MHz crystal, as on the F4-Discovery (198л): core at 168
MHz, APB1 at 42 MHz, timers on APB1 at 84 MHz.
198п.2 The MIDI port: current loop and optocoupler
A MIDI output is not a voltage level but a current in a loop of two wires in the cable: the transmitter either lets it flow or not. At the input the current flows through the LED of an optocoupler, and the phototransistor pulls the UART input to ground. The grounds of the two devices are not connected: the input is isolated, and there is no ground loop that would make the sound hum. Current flowing is a logic 0, no current is a 1, so the idle line is a one, as a UART line should be.
The rate of 31250 bit/s is 1 MHz / 32. A frame is 10 bits, a byte takes 320 µs. A Note On message is 3 bytes, 960 µs. For USART2 of the F407 the divider comes out exact: 42 MHz / 31250 = 1344, no baud rate error; on the G474 from 170 MHz it is exact too, 5440.
The slow line is noticeable on chords. Ten notes in a row are 30 bytes, 9.60 ms: the last note sounds almost 10 ms after the first. Running status saves the status byte in every message except the first: 21 bytes, 6.72 ms.
198п.3 Parsing the stream
A byte with the top bit set is a status byte, with the top bit clear a data byte. Note On (0x9n) and Note Off (0x8n) have two data bytes: the note and the velocity; n is the channel 0…15. Three rules break a naive parser:
- running status: after one status several data pairs can follow in a row; the status stays in force until another one arrives;
- real-time messages (0xF8…0xFF: timing clock, Active Sensing) can arrive in the middle of a message, between its data bytes, and do not touch the status;
- Note On with velocity 0 is a Note Off.
SysEx (0xF0 … 0xF7) and the other system messages reset running status: data after them without a new status is discarded.
m->data[m->n++] = b;
if (m->n < m->need)
return 0;
m->n = 0; /* статус остаётся: бегущий */
The experiment is 29 bytes: three Note On messages under one status (with 0xF8 between the bytes of the second note), Active Sensing, a Note On on another channel, a SysEx, two data bytes without a status, a Note On with velocity 0, a Note Off and CC 123. The parser produced six events:
| Event | Note | Velocity |
|---|---|---|
| pressed | 69 | 100 |
| pressed | 73 | 100 |
| pressed | 76 | 100 |
| released (Note On, velocity 0) | 69 | 0 |
| released | 73 | 0 |
| all notes off (CC 123) | — | — |
Skipped: two real-time bytes and one SysEx; discarded: three, the message on the other channel and the two data bytes after the SysEx.
198п.4 The synthesizer: table, phase, voices
A voice is a 32-bit phase accumulator: on every sample the note's step is added to the phase; the top 8 bits are the index of a point in the sine table of 256 points, the next 16 are the fraction for linear interpolation (as for the tone in 198л). The step for note n is round(440 · 2^((n − 69)/12) · 2³² / 32000); a table of 128 steps is computed in advance. For A4 it is 59055800. The tuning error due to rounding of the step is at most 0.000437 cent; the notes run from 8.176 Hz to 12543.9 Hz, all below half the sample rate, 16000 Hz.
uint32_t idx = v->phase >> 24, frac = v->phase >> 8 & 0xFFFFu;
int32_t a = SYN_SINE[idx], b = SYN_SINE[idx + 1u];
int32_t smp = a + (int32_t)(((b - a) * (int32_t)frac) >> 16); /* линейная интерполяция */
mix += (((smp * (int32_t)v->env) >> 16) * v->vel) >> 7;
The envelope: attack 5 ms (step 410 in Q16, 160 samples) and release 50 ms (step 41): in the experiment, after CC 123 the voices sound for 1598 samples and fall silent at the 1599th, after 49.97 ms. Without an envelope a note would start and end with a step, which is a click.
There are eight voices. A new note takes the same voice if that note is already sounding; otherwise a free one; otherwise the quietest of the releasing ones; otherwise the oldest. The experiment is ten notes in a row with velocity 127: two voices were stolen, notes 60 and 61 were replaced by 68 and 69.
The mix is shifted so that eight voices at full volume fit into 12 bits: 8 · 32767 · 127/128 / 128 = 2031 ≤ 2047, and this is checked at build time. A single voice pays for it: at velocity 100 it gives only ±200 codes. In the experiment the three-note chord fell into 1448…2646, ten notes into 646…3377, with zero clipping.
198п.5 DAC, DMA and timer
TIM6 counts at 84 MHz: a period of 2625 clocks (ARR = 2624) is exactly 32000 Hz,
and on update it outputs TRGO (MMS = 010). DAC channel 1 takes a
sample on it: TSEL1 = 000 is "Timer 6 TRGO event" per RM0090. The sample is put into
DHR12R1 by DMA1 Stream5 channel 7, the DAC1 request in table 42 of RM0090,
memory → peripheral, 16 bits, circular.
The buffer is two halves of 64 samples, 2 ms each. The DMA interrupt at the half and at the end computes the part the DAC has just played. This is the latency of the synthesizer: up to 2 ms plus the time of the MIDI bytes.
Events from the USART do not go to the synthesizer directly. The USART interrupt (higher priority) puts them into a queue of 32 events, and the DMA interrupt takes the queue before computing a half. Otherwise a note could change in the middle of computing a sample, leaving a voice half old, half new.
On the G474 it does not come out exact: 170 MHz / 32000 = 5312.5. A period of 5312 gives 32003.01 Hz, 5313 gives 31996.99 Hz, an error of ±0.0094 %; the note steps are then computed for the actual frequency, or you look for a PLL divider that gives 32 kHz.
198п.6 Verification: WAV and spectrum
The Python model reproduced the parser and the synthesizer with its own code and got the same samples: the CRC-32 of the chord (40960 samples), its tail, the release and the polyphony test matched the C run. The chord was written to a WAV file (32000 Hz, 16 bits, mono, 40960 frames) and read back without differences.
Spectrum: 32768 samples after the attack, a Hann window, a bin width of 0.9766 Hz; the peak frequency is refined from three neighbouring points:
| Note | From the spectrum | From the phase step | Equal temperament |
|---|---|---|---|
| A4 (69) | 439.992 Hz | 440.0000 Hz | 440.0000 Hz |
| C#5 (73) | 554.350 Hz | 554.3653 Hz | 554.3653 Hz |
| E5 (76) | 659.261 Hz | 659.2551 Hz | 659.2551 Hz |
All three notes were found with an error below 0.02 Hz, fifty times smaller than the bin width. The strongest spurious component away from the notes is −84.2 dB (5288 Hz): errors of a 256-point table with interpolation and of rounding to 12 bits.
armclang 6.24 built the core and the firmware without warnings: 790 and 598 bytes of code, 1074 bytes of tables (sine and note steps), 560 bytes of RAM.
198п.7 Pitfalls
No running status. A parser that expects a status before every note loses all the notes of a chord except the first.
A real-time message in the middle of a note. 0xF8 between the note and the velocity is a normal thing during synchronization. It is skipped without touching the status or the data collected so far.
Note On with velocity 0. This is how a note is released without changing the running status of Note On. Miss it and notes hang.
Data after SysEx. SysEx resets running status; data bytes after it without a new status are not notes.
A lost byte. On a USART overrun (ORE) the message being collected
is wrong, so parsing starts over.
A synthesizer driven from two interrupts. Events go through a queue, and the voices are computed by one interrupt, the DMA one.
Headroom for eight voices. A mix without headroom clips on chords; with headroom a single voice is quiet: ±200 codes out of 2048.
The sample rate does not divide. On the G474, 32000 Hz from 170 MHz does not come out exact: ±0.0094 %.
Ground through MIDI. The input is isolated by the optocoupler: do not connect the grounds of the devices through the MIDI connector, or a ground loop will appear.
198п.8 Summary
- MIDI is a UART at 31250 bit/s: a byte takes 320 µs, a Note On 960 µs; ten notes take 9.60 ms, with running status 6.72 ms; the F407 divider is exactly 1344.
- Parsing: running status, real-time messages in the middle of data, Note On with velocity 0, status reset after SysEx; the 29-byte experiment gave 6 events.
- Synthesis: a 256-point sine table with interpolation, a 32-bit phase, 8 voices, envelope 5 / 50 ms; tuning better than 0.000437 cent.
- The DAC is driven by TIM6 at exactly 32000 Hz through DMA1 Stream5 channel 7, the half-buffer latency is 2 ms; on the G474 the sample rate comes out with an error of ±0.0094 %.
- The Python model: the same samples by CRC-32, WAV, the notes from the spectrum with an error below 0.02 Hz, spurious components no higher than −84.2 dB.
- armclang: 790 + 598 bytes of code, 1074 bytes of tables, 560 bytes of RAM.
Full source code
synth_core.c — the MIDI parser and the synthesizer.
/* synth_core.c — раздел 198п: разбор MIDI и синтезатор (см. synth_core.h). */
#include "synth_core.h"
#include "synth_tab.h"
_Static_assert(SYN_VOICES * 32767u * 127u / 128u / 128u <= 2047u, "8 голосов на полной громкости не выходят за 12 бит");
_Static_assert(sizeof SYN_INC / sizeof SYN_INC[0] == 128u, "шаг фазы на каждую из 128 нот");
/* ── MIDI ──────────────────────────────────────────────────────────────── */
void midi_init(midi_rx *m, int channel)
{
m->status = 0;
m->n = m->need = 0;
m->in_sysex = 0;
m->channel = (int8_t)channel;
m->realtime = m->sysex = m->ignored = 0;
}
int midi_feed(midi_rx *m, uint8_t b, midi_ev *e)
{
if (b >= 0xF8u) { /* реальное время: где угодно, статус не трогает */
m->realtime++;
return 0;
}
if (b >= 0xF0u) { /* системные: SysEx и общие — сбрасывают статус */
if (b == 0xF0u) {
m->in_sysex = 1;
m->sysex++;
} else if (b == 0xF7u) {
m->in_sysex = 0;
}
m->status = 0;
m->n = 0;
return 0;
}
if (b & 0x80u) { /* статус канала */
m->in_sysex = 0;
m->status = b;
m->n = 0;
m->need = (uint8_t)(((b & 0xE0u) == 0xC0u) ? 1u : 2u); /* Cx и Dx — один байт данных */
return 0;
}
if (m->in_sysex)
return 0;
if (!m->status) { /* данные без статуса */
m->ignored++;
return 0;
}
m->data[m->n++] = b;
if (m->n < m->need)
return 0;
m->n = 0; /* статус остаётся: бегущий */
uint8_t type = m->status & 0xF0u, ch = m->status & 0x0Fu;
if (m->channel >= 0 && ch != (uint8_t)m->channel) {
m->ignored++;
return 0;
}
if (type == 0x90u && m->data[1]) {
e->type = EV_ON;
e->note = m->data[0];
e->vel = m->data[1];
return 1;
}
if (type == 0x80u || type == 0x90u) { /* Note Off или Note On со скоростью 0 */
e->type = EV_OFF;
e->note = m->data[0];
e->vel = 0;
return 1;
}
if (type == 0xB0u && m->data[0] == 123u) { /* CC 123: все ноты выключить */
e->type = EV_ALL_OFF;
e->note = e->vel = 0;
return 1;
}
return 0;
}
/* ── синтезатор ────────────────────────────────────────────────────────── */
uint32_t synth_inc(uint8_t note)
{
return SYN_INC[note & 0x7Fu];
}
void synth_init(synth *s)
{
for (unsigned i = 0; i < SYN_VOICES; i++) {
s->v[i].phase = s->v[i].inc = s->v[i].env = s->v[i].age = 0;
s->v[i].note = s->v[i].vel = s->v[i].state = 0;
}
s->clock = s->stolen = s->clipped = 0;
}
static voice *pick_voice(synth *s, uint8_t note)
{
voice *best = 0;
for (unsigned i = 0; i < SYN_VOICES; i++) /* та же нота звучит — перезапустить её */
if (s->v[i].state && s->v[i].note == note)
return &s->v[i];
for (unsigned i = 0; i < SYN_VOICES; i++) /* свободный */
if (!s->v[i].state)
return &s->v[i];
for (unsigned i = 0; i < SYN_VOICES; i++) /* самый тихий из затухающих */
if (s->v[i].state == 3u && (!best || s->v[i].env < best->env))
best = &s->v[i];
if (!best) {
best = &s->v[0]; /* самый старый */
for (unsigned i = 1; i < SYN_VOICES; i++)
if (s->v[i].age < best->age)
best = &s->v[i];
}
s->stolen++;
return best;
}
void synth_event(synth *s, const midi_ev *e)
{
if (e->type == EV_ON) {
voice *v = pick_voice(s, e->note);
if (!v->state) {
v->phase = 0;
v->env = 0;
}
v->note = e->note;
v->vel = e->vel;
v->inc = synth_inc(e->note);
v->state = 1;
v->age = ++s->clock;
return;
}
for (unsigned i = 0; i < SYN_VOICES; i++) {
voice *v = &s->v[i];
if ((v->state == 1u || v->state == 2u) && (e->type == EV_ALL_OFF || v->note == e->note))
v->state = 3;
}
}
void synth_render(synth *s, uint16_t *out, uint32_t n)
{
for (uint32_t k = 0; k < n; k++) {
int32_t mix = 0;
for (unsigned i = 0; i < SYN_VOICES; i++) {
voice *v = &s->v[i];
if (!v->state)
continue;
if (v->state == 1u) { /* атака */
v->env += SYN_ATTACK;
if (v->env >= 65536u) {
v->env = 65536u;
v->state = 2;
}
} else if (v->state == 3u) { /* затухание */
v->env = v->env > SYN_RELEASE ? v->env - SYN_RELEASE : 0u;
if (!v->env) {
v->state = 0;
continue;
}
}
uint32_t idx = v->phase >> 24, frac = v->phase >> 8 & 0xFFFFu;
int32_t a = SYN_SINE[idx], b = SYN_SINE[idx + 1u];
int32_t smp = a + (int32_t)(((b - a) * (int32_t)frac) >> 16); /* линейная интерполяция */
mix += (((smp * (int32_t)v->env) >> 16) * v->vel) >> 7;
v->phase += v->inc;
}
int32_t o = 2048 + (mix >> 7);
if (o < 0 || o > 4095) {
s->clipped++;
o = o < 0 ? 0 : 4095;
}
out[k] = (uint16_t)o;
}
}
Next: section 198р "Mini-project: an OBD-II scanner on STM32 — RPM, speed, temperature and trouble codes".