Building a MIDI instrument in TypeScript

Daniel Schulz - Apr 15 - - Dev Community

I recently put some work into my web synthesizer and even gave it its own domain at jssynth.xyz. I added some convenience features, but I also worked on a MIDI integration.

As with most of the more fringe APIs, the Web MIDI API is built as a mirror image of it’s non-web counterpart. It doesn’t support fully specced out events like onClick and its messages might be raw byte streams of hex numbers. There aren’t even unambiguous input events. Oh boy!

Anyways it is a way to connect a MIDI keyboard to a website and vice versa.


Before I go through the code, let’s state that the whole MIDI feature is wrapped up in a class. In the following snippets this will be the class context.

Permissions

The Web MIDI API requires permissions to work. Users are prompted with a dialog to grant or deny those permissions. I’m not a big fan of those prompts. They often come unsolicited and tend to get in the way. If a user doesn’t need MIDI functionality, there’s no need to annoy them. So I wait for them to select a MIDI in or out channel. Now I can ensure the user has interacted with the site in a way that they probably know how to respond to the MIDI permission prompt.

First, let’s deal with browsers that don’t support the MIDI API. It’s a bit ironic that those browsers are made by the company that markets heavily towards creatives.

Unsupported features shouldn’t be available in the UI.

if (!navigator.requestMIDIAccess) {
    this.disableMidi();
}
Enter fullscreen mode Exit fullscreen mode

I consider undefined or negative values for MIDI channels as off. This is also the default value. Users have to actively select a channel to use this.

if (
    (this.inChannel < 0 || this.inChannel === undefined) &&
    (this.outChannel < 0 || this.outChannel === undefined)
) { return; }
Enter fullscreen mode Exit fullscreen mode

Only then I will request MIDI Access, which will prompt the user for permissions.

navigator
    .requestMIDIAccess()
    .then(this.onMIDISuccess.bind(this))
    .catch((e) => {
        this.disableMidi();
    });
Enter fullscreen mode Exit fullscreen mode

Now I can start listening for events.

Input

An arrow labeled with the MIDI logo points from a black keyboard towards a computer with the JSSynth logo on its screen.

An arrow labeled with the MIDI logo points from a black keyboard towards a computer with the JSSynth logo on its screen.

MIDI doesn’t only work on multiple channels, it also supports those channels on multiple devices. I chose to ignore those devices. Instead, I handle all events from all devices as if it was one. I get enough granularity with MIDI channels (at least for now). I do understand that the proper solution would have been the other way around, as this is an existing convention called Omni Mode. But now I built it this way and it works for me 🤷

The MIDI API exposes an onmidimessage callback on its input devices, which provides a MIDI message.

inputDevice.onmidimessage = (message: string) => this.onMIDIMessage(message);
Enter fullscreen mode Exit fullscreen mode

Those messages look something like this:

0x90 0x3c 0x2b 
Enter fullscreen mode Exit fullscreen mode

This command corresponds to to a keypress of the middle C on channel 0 with a key velocity of 33%.

A Diagram. The MIDI message 0x90 0x3c 0x2b gets converted to the decimal numbers 144 60 43, which then gets converted into the signal “play on channel 0 a middle C on 33% velocity.

A Diagram. The MIDI message 0x90 0x3c 0x2b gets converted to the decimal numbers 144 60 43, which then gets converted into the signal “play on channel 0 a middle C on 33% velocity.

This is how we can decode the message into human readable values:

parseMidiMessage(message: string): MidiMessage {
        const arr = message.split(" ");
        return {
            command: parseInt(arr[0]) >> 4,
            channel: parseInt(arr[0]) & 0xf,
            note: parseInt(arr[1]),
            velocity: parseInt(arr[2]) / 127,
        };
    }
Enter fullscreen mode Exit fullscreen mode

With that, I can pass the information along to the synthesizer and start playing the note.

The interesting part here is the first part of the message, indicating the command and channel. MIDI can provide all sorts of commands and has been adapted even well beyond musical information (like stage lights and such). But there are some well defined commands, like play note on channel 1. This will always be MIDI command 144. As the command is being encoded in the hexadecimal number 90 and we only get 16 MIDI channels (from 0 to f), I can split it up into two hexadecimal digits: 9 being they play command and 0 being the channel.

Playing a note is only half the work though. As with my on-screen and keyboard controls, I need to register key releases as well, or else I’d play infinite notes. Key releases can come in two ways: Either as a release command (0x80 through 0x8f) or as a play command (0x90 through 0x9f) with a velocity of 0.

Now all of the input handling looks like this:

const data = this.parseMidiMessage(str);

if (data.channel === this.inChannel && data.command === 9 && data.velocity > 0) {
    this.playCallback(data.note, data.velocity);
}

if (
    (data.channel === this.inChannel && data.command === 8) ||
    (data.channel === this.inChannel && data.command === 9 && data.velocity === 0)
) {
    this.releaseCallback(data.note);
}

if (data.channel === this.inChannel && data.command === 8) {
  this.releaseCallback(data.note);
}
Enter fullscreen mode Exit fullscreen mode

I don’t really support velocity in the synthesizer yet, but it felt better to built this in now. The callbacks look similar to the other play triggers in the synth code:

// in the Synth class
onMidiPlay(midiCode: number, velocity: number): void {
    const note = Object.keys(this.keys).find((x) => this.keys[x].midiIn === midiCode);

    if (!note) { return; }
    if (!this.keys[note]?.key || this.nodes[note]) { return; }

    this.playNote(note);
}

onMidiRelease(midiCode: number): void {
    const note = Object.keys(this.keys).find((x) => this.keys[x].midiIn === midiCode);

    if (!note) { return; }
    if (!this.keys[note]?.key || !this.nodes[note]) { return; }

    this.endNote(this.nodes[note]);
}
Enter fullscreen mode Exit fullscreen mode

Output

An arrow labeled with the MIDI logo points from a computer with the JSSynth logo on its screen towards a black keyboard.

An arrow labeled with the MIDI logo points from a computer with the JSSynth logo on its screen towards a black keyboard.

MIDI out woks basically the same, but the other way around. I let the Synth class hook into the MIDI adapter and call a function:

/**
 * Callback for MIDI-out from in-browser inputs.
 *
 * @param key - The key, e.g. 'c2'.
 * @param velocity - The velocity of the key.
 */
public onPlayNote(key: string, velocity: number): void {
    this.sendMidiMessage("play", key, velocity);
}
Enter fullscreen mode Exit fullscreen mode

Then I need to put together a MIDI message, like the one I got above. I get the MIDI code for the note from a big ol’ object matching the two. Calculating the notes programmatically is possible. but I had the object anyways, because it also holds information for keyboard shortcuts (keyboard meaning the computer input device).

The MIDI command is compiled from the out channel and the playNote command code (0x90 through 0x9f). The velocity gets passed through, but will always be 1 for now. Convert the three values into hexadecimals and push them out across all devices.

/**
 * Sends out a MIDI message.
 *
 * @param command - The MIDI command.
 * @param note - The MIDI note.
 * @param velocity - The velocity.
 * @returns
 */
sendMidiMessage(command: string, note: string, velocity = 0): void {
    if (this.outChannel < 0 || command !== "play" || !this.midi) {
        return;
    }

    const midiCode = Keys[note].midiIn;
    const midiCommand = "0x" + ((9 << 4) | this.outChannel).toString(16);
    const midiVelocity = "0x" + (velocity * 127).toString(16);

    this.midi!.outputs.forEach((outputDevice) => {
        outputDevice.send([midiCommand, midiCode, midiVelocity]);
    });
}
Enter fullscreen mode Exit fullscreen mode

Since MIDI key releases can be either the release command or the play command with a velocity of 0, I can save a few lines of code and use the same function to send key releases. The synth just needs to pass a 0 velocity along.

this.MidiAdapter.onPlayNote(key, 1); // play
this.MidiAdapter.onPlayNote(key, 0); // release
Enter fullscreen mode Exit fullscreen mode

UI Changes

The obvious additions are select fields for the MIDI channels, but that’s not all.

Before, the Synthesizer supported one whole octave. That was enough to play around a bit, but not really useful for anything. I support MIDI input now, but it wouldn’t be fair to provide all those extra notes only to dedicated hardware. So I added two more octaves, showing up as the screen width increases.

The JSSSynth UI in three different viewport sizes. The left-hand one shows one octave and vertically stack controls. The middle one shows one active with horizontally stacked controls. It’s keys are labeled with keyboard shortcuts. The right-hand one is the same, but wider and showing two octaves.

The JSSSynth UI in three different viewport sizes. The left-hand one shows one octave and vertically stack controls. The middle one shows one active with horizontally stacked controls. It’s keys are labeled with keyboard shortcuts. The right-hand one is the same, but wider and showing two octaves.

In the process, I got rid of my Sass workflow. I only used it for nesting anyway and CSS supports that natively now.

Demo

You can try it out yourself on https://jssynth.xyz.

Code

As always, the full code can be found on GitHub.

. . . . . . . . . . . . . . . . . . . . . . . .