Building a MIDI instrument in TypeScript
Tags: javascript, webdev, audio, showdev
Published: 2024-04-15 07:00:00

I recently put some work into my web synthesizer and even gave it its own domain at [jssynth.xyz](https://jssynth.xyz). I added some convenience features, but I also worked on a [MIDI](https://en.wikipedia.org/wiki/MIDI) integration.

As with most of the more fringe APIs, the [Web MIDI API](https://developer.mozilla.org/en-US/docs/Web/API/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](https://caniuse.com/?search=midi) 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.

```tsx
if (!navigator.requestMIDIAccess) {
	this.disableMidi();
}
```

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.

```tsx
if (
	(this.inChannel < 0 || this.inChannel === undefined) &&
	(this.outChannel < 0 || this.outChannel === undefined)
) { return; }
```

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

```tsx
navigator
	.requestMIDIAccess()
	.then(this.onMIDISuccess.bind(this))
	.catch((e) => {
		this.disableMidi();
	});
```

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8ek98i6iu6czzu31l1c6.png)

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.

```tsx
inputDevice.onmidimessage = (message: string) => this.onMIDIMessage(message);
```

Those messages look something like this:

```jsx
0x90 0x3c 0x2b 
```

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1f4lk6fvq4vlx1du8qch.png)

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:

```jsx
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,
		};
	}
```

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](https://computermusicresource.com/MIDI.Commands.html) `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:

```jsx
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);
}
```

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:

```jsx
// 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]);
}
```

## Output

![An arrow labeled with the MIDI logo points from a computer with the JSSynth logo on its screen towards a black keyboard.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b6yvg7o0tw32pwawy35a.png)

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:

```tsx
/**
 * 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);
}
```

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](https://github.com/iamschulz/js-synth/blob/main/src/keys.ts) 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.

```tsx
/**
 * 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]);
	});
}
```

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.

```tsx
this.MidiAdapter.onPlayNote(key, 1); // play
this.MidiAdapter.onPlayNote(key, 0); // release
```

## 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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ablwnl93opkk2ylzsz81.jpg)

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](https://sass-lang.com/) workflow. I only used it for nesting anyway and CSS [supports](https://caniuse.com/css-nesting) that natively now.

# Demo

{% embed https://www.youtube.com/watch?v=cG0dzUIOuQ0 %}

You can try it out yourself on [https://jssynth.xyz](https://jssynth.xyz).

# Code

As always, the full code can be found on [GitHub](https://github.com/iamschulz/js-synth).