Simple synth suite in C

I've been wanting to dabble with MIDI for a while now (since before pipedals) and I finally ordered a keyboard like a month ago, it came the same day as I bought my acoustic bass which was pretty nice. Had a fun time with both of those.

Now pipedals was cool and all, but it was pretty boring (just digital guitar pedals, could've had a better execution) but with MIDI you can emulate ANY audio with a synth (even sample recordings). Now similarly to pipedals, I don't like the standard way of implementing them; It's too complex for my tiny monkey brain and I haven't really tried making a synth using real production grade tooling, but I'm sure it's basically the same as a VST plugin.

Now like with pipedals, the entire workflow is now this:

cat /dev/midi* | tiny synth program here :P | aplay ...

That's it. ALSA might be my new favorite thing.

Now if you couldn't tell, I'm copying alot of the parts from the pipedals post, but eh whatever.

The shared code every synth uses is something like this: (C pseudocode)

int main(int argc, char *argv[]) {
  // let the synth have it's own code for initialization. (set up voices, blabla)
  init_synth(argc, argv);
  midi_event event;
  int16_t buf[BUF_SIZE]; // in this case BUF_SIZE is 256, but that's not important.

  // FOREVERRRRRR
  while (1) {
    for (int i = 0; i < BUF_SIZE; i++) {
      while (midi_read(&event)) { // Reads a raw MIDI event (It's THREE (or just two) bytes!)
        synth_event(&event); // let the synth handle events (note_on, note_off, pitch shifting, CC, etc)
      }
      buf[i] = synth_sample(); // store the sample
    }

    write(STDOUT_FILENO, buf, sizeof(buf)); // send it over to ALSA (or a file)
    sample_sleep(BUF_SIZE); // prevent overrunning the audio
  }

  return 0; // why is this here?
}

And for this project, the synth implementation is a little bit less straightforward but it's still pretty simple.

And if you didn't notice, the three functions that the synth has to implement are:

int16_t synth_sample();
void synth_event(midi_event *ev);
void init_synth(int argc, char *argv[]);

Now: here's an example Square Wave synth

#include "synth.lib.h" // Don't ask why i named it this... just has some useful constants and utilities.
#include <stdint.h> // One of my favorite headers

synth_voice voices[128]; // Each note gets it's own voice. For simplicity. Obviously doesn't need this many, but it provides easy indexing

int16_t synth_sample() {
  float mix = 0.0f;

  for (int i = 0; i < 128; i++) { // loop through the voices
    synth_voice *v = &voices[i];

    if (!v->on) // don't process unused voices
      continue;

    v->phase[0] += (v->freq * pitch) / SAMPLE_RATE;

    if (v->phase[0] >= 1.0f)
      v->phase[0] -= 1.0f;

    float sample = v->phase[0] < 0.5f ? 1.0f : -1.0f; // this is the square wave

    mix += sample * v->velocity * 16000.0f; // add to the mix
  }

  if (mix > INT16_MAX) // hard clamp the output
    mix = INT16_MAX;
  if (mix < INT16_MIN)
    mix = INT16_MIN;

  return (int16_t)mix; // return the sample
}

The init code is super simple, don't even know why I'm showing you.

static void init_voices(synth_voice *voices) {
  for (int i = 0; i < 128; i++) { // loop through all 128 voices
    memset(&voices[i], 0, sizeof(synth_voice)); // default for most fields is 0.
    voices[i].freq = 440.0f * powf(2.0f, (i - 69.0f) / 12.0f); // calculate frequency for a given note
  }
}

void init_synth(int argc, char *argv[]) { 
    init_voices(voices); // the global we defined earlier
}

And event handling (boring)

void synth_event(midi_event *ev) {
  uint8_t note = ev->data[0];

  if (ev->type == MIDI_NOTE_ON) { // turn on a note (press)
    voices[note].on = 1;
    voices[note].releasing = 0;
    voices[note].velocity = ev->data[1] / 127.0f;

    voices[note].phase[0] = 0.0f;
  }
  if (ev->type == MIDI_NOTE_OFF) { // turn off a note (release)
    voices[note].on = 0;
  }
}

The midi_event struct is a simplified representation of a raw MIDI message. In the actual MIDI data, the first byte contains both the message type and the channel

TTTTCCCC

where T is the message type and C is the MIDI channel. The parser splits this byte into two separate fields, giving the synth easier access to them:

typedef struct {
  uint8_t type;
  uint8_t chan;
  uint8_t data[2];
} midi_event;

The remaining bytes are stored in data. Most common messages have two data bytes, but some MIDI messages only have one, so the synth has to know how many bytes to read depending on the message type.

And now I'm tired of writing (If only I wrote like this in school). But as you can see, It's actually pretty simple.

Now unlike pipedals there's no helper CLI, just rawdog aplay lol.

I had some more improvements to the code, so if you're interested check it out!