Building a MIDI player with Server-Side Rendering

Feb 1, 2021 8:38 AM

I keep my (midi) keyboard much closer to my computer these days. I had the idea of recording my practice sessions so I can archive and play from my site. It might be nice to come back one day and listen to how I how I sound as a beginner. I thought it would be easy: record my sessions into a midi file, upload it into cloud storage, and have the browser play it. I knew this was not the case after surveying the midi browser space and trying out a few libraries.

Here’s a solution that I tried: a server transcodes MIDI files into a series of JSON objects representing the stream, while a browser plays the events using a soundfont instrument. I chose to go this route because:

  • cifkao/html-midi-player doesn’t work when imported via node because of the dependencies. I don’t think Svelte likes the custom DOM elements either, despite how nice the library looks.
  • midi-player-js broke on import inside of the browser because of fs imports used to load MIDI from files in the Node version of the library. The browser bundle under browser/midiplayer.js was not working as expected in my client. However, I could use this library perfected fine from Node alone.

My website stack is based on Sapper and Svelte, which has a few patterns that are starting to become familiar to me. First, sapper export generates a bundle per route that pre-loads data. This mean each page will have the transcoded MIDI files embedded at the header of the page. I don’t need to include the midi-player-js or the original MIDI files, just the data. Here’s a stream of events generated by the libary:

  [
    {
      "track": 1,
      "delta": 0,
      "tick": 0,
      "byteIndex": 0,
      "name": "Sequence/Track Name",
      "string": "Yamaha Grand-v2"
    },
    {
      "track": 1,
      "delta": 0,
      "tick": 0,
      "byteIndex": 19,
      "name": "Set Tempo",
      "data": 140
    },
    {
      "track": 1,
      "delta": 512,
      "tick": 512,
      "byteIndex": 26,
      "name": "Note on",
      "channel": 1,
      "noteNumber": 55,
      "noteName": "G3",
      "velocity": 58
    },
    {
      "track": 1,
      "delta": 0,
      "tick": 512,
      "byteIndex": 31,
      "name": "Note on",
      "channel": 1,
      "noteNumber": 67,
      "noteName": "G4",
      "velocity": 60
    },
    ...
    {
      "track": 1,
      "delta": 0,
      "tick": 512,
      "byteIndex": 35,
      "name": "Note on",
      "channel": 1,
      "noteNumber": 48,
      "noteName": "C3",
      "velocity": 54
    },
    {
      "track": 1,
      "delta": 0,
      "tick": 11600,
      "byteIndex": 819,
      "name": "End of Track"
    }
  ],

The events need to be played via a soundfont. I went with soundfont-player by recommendation of midi-player-js and it’s merit of being a lightweight library. With documentation and elbow grease, I got my test track of London Bridge playing on the page.

ac = new window.AudioContext();
instrument = await Soundfont.instrument(ac, "acoustic_grand_piano");
notes = track
  .filter((e) => e.name == "Note on")
  .map((e) => ({ time: e.tick / (140 * 4), note: e.noteNumber }));

Going down this route, I realized how much effort it was to decouple the MIDI decoder from the player itself. If I were going to do this, I would have to build almost all of the primitives for playing, stopping, timing events, and converting ticks to seconds. To get rid of the magic numbers (i.e. 140*4), I took a look at this StackOverflow comment and decided to take a hard tack on my approach.

The formula is 60000 / (BPM * PPQ) (milliseconds).

Where BPM is the tempo of the track (Beats Per Minute).

(i.e. a 120 BPM track would have a MIDI time of (60000 / (120 * 192)) or 2.604 ms for 1 tick.

If you don’t know the BPM then you’ll have to determine that first. MIDI times are entirely dependent on the track tempo.

While I knew the tempo from the event stream (140 BPM), I had to take a guess at the pulses per quarter-note (PPQ). I had a semi-functional player that knew the duration, current time, and could play and pause. In the end, I forked midi-player-js and tweaked it to generate a browser compatible bundle that doesn’t call the fs module.

There are some advantages of using server-side rendering for multimedia like videos where there are bandwidth gains to be made by adapting to device specifications. But for tiny MIDI files, it’s simpler to do all the work in the browser instead of building a custom player.