How to Code a Playable Synth Keyboard

Avatar of Bret Cameron
Bret Cameron on

With a little knowledge of music theory, we can use regular HTML, CSS and JavaScript — without any libraries or audio samples — to create a simple digital instrument. Let’s put that into practice and explore one method for creating a digital synth that can be played and hosted on the internet.

Here’s what we’re making:

We’ll use the AudioContext API to create our sounds digitally, without resorting to samples. But first, let’s work on the keyboard’s appearance.

The HTML structure

We’re going to support a standard western keyboard where every letter between A and ; corresponds to a playable natural note (the white keys), while the row above can be used for the sharps and flats (the black keys). This means our keyboard covers just over an octave, starting at C₃ and ending at E₄. (For anyone unfamiliar with musical notation, the subscript numbers indicate the octave.)

One useful thing we can do is store the note value in a custom note attribute so it’s easy to access in our JavaScript. I’ll print the letters of the computer keyboard, to help our users understand what to press.

<ul id="keyboard">
  <li note="C" class="white">A</li>
  <li note="C#" class="black">W</li>
  <li note="D" class="white offset">S</li>
  <li note="D#" class="black">E</li>
  <li note="E" class="white offset">D</li>
  <li note="F" class="white">F</li>
  <li note="F#" class="black">T</li>
  <li note="G" class="white offset">G</li>
  <li note="G#" class="black">Y</li>
  <li note="A" class="white offset">H</li>
  <li note="A#" class="black">U</li>
  <li note="B" class="white offset">J</li>
  <li note="C2" class="white">K</li>
  <li note="C#2" class="black">O</li>
  <li note="D2" class="white offset">L</li>
  <li note="D#2" class="black">P</li>
  <li note="E2" class="white offset">;</li>
</ul>

The CSS styling

We’ll begin our CSS with some boilerplate:

html {
  box-sizing: border-box;
}

*,
*:before,
*:after {
  box-sizing: inherit;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}

body {
  margin: 0;
}

Let’s specify CSS variables for some of the colors we’ll be using. Feel free to change them to whatever you prefer!

:root {
  --keyboard: hsl(300, 100%, 16%);
  --keyboard-shadow: hsla(19, 50%, 66%, 0.2);
  --keyboard-border: hsl(20, 91%, 5%);
  --black-10: hsla(0, 0%, 0%, 0.1);
  --black-20: hsla(0, 0%, 0%, 0.2);
  --black-30: hsla(0, 0%, 0%, 0.3);
  --black-50: hsla(0, 0%, 0%, 0.5);
  --black-60: hsla(0, 0%, 0%, 0.6);
  --white-20: hsla(0, 0%, 100%, 0.2);
  --white-50: hsla(0, 0%, 100%, 0.5);
  --white-80: hsla(0, 0%, 100%, 0.8);
}

In particular, changing the --keyboard and --keyboard-border variables will change the end result dramatically.

For styling the keys and the keyboard — especially in the pressed states — I owe a lot of my inspiration to this CodePen by zastrow. First, we specify the CSS shared by all the keys:

.white,
.black {
  position: relative;
  float: left;
  display: flex;
  justify-content: center;
  align-items: flex-end;
  padding: 0.5rem 0;
  user-select: none;
  cursor: pointer;
}

Using a specific border radius on the first and last key helps make the design look more organic. Without rounding, the top left and top right corners of keys look a little unnatural. Here’s a final design, minus any extra rounding on the first and last keys.

Let’s add some CSS to improve this.

#keyboard li:first-child {
  border-radius: 5px 0 5px 5px;
}

#keyboard li:last-child {
  border-radius: 0 5px 5px 5px;
}

The difference is subtle but effective:

Next, we apply the stylings that differentiate the white and black keys. Notice that the white keys have a z-index of 1 and the black keys have a z-index of 2:

.white {
  height: 12.5rem;
  width: 3.5rem;
  z-index: 1;
  border-left: 1px solid hsl(0, 0%, 73%);
  border-bottom: 1px solid hsl(0, 0%, 73%);
  border-radius: 0 0 5px 5px;
  box-shadow: -1px 0 0 var(--white-80) inset, 0 0 5px hsl(0, 0%, 80%) inset,
    0 0 3px var(--black-20);
  background: linear-gradient(to bottom, hsl(0, 0%, 93%) 0%, white 100%);
  color: var(--black-30);
}

.black {
  height: 8rem;
  width: 2rem;
  margin: 0 0 0 -1rem;
  z-index: 2;
  border: 1px solid black;
  border-radius: 0 0 3px 3px;
  box-shadow: -1px -1px 2px var(--white-20) inset,
    0 -5px 2px 3px var(--black-60) inset, 0 2px 4px var(--black-50);
  background: linear-gradient(45deg, hsl(0, 0%, 13%) 0%, hsl(0, 0%, 33%) 100%);
  color: var(--white-50);
}

When a key is pressed, we’ll use JavaScript to add a class of "pressed" to the relevant li element. For now, we can test this by adding the class directly to our HTML elements.

.white.pressed {
  border-top: 1px solid hsl(0, 0%, 47%);
  border-left: 1px solid hsl(0, 0%, 60%);
  border-bottom: 1px solid hsl(0, 0%, 60%);
  box-shadow: 2px 0 3px var(--black-10) inset,
    -5px 5px 20px var(--black-20) inset, 0 0 3px var(--black-20);
  background: linear-gradient(to bottom, white 0%, hsl(0, 0%, 91%) 100%);
  outline: none;
}

.black.pressed {
  box-shadow: -1px -1px 2px var(--white-20) inset,
    0 -2px 2px 3px var(--black-60) inset, 0 1px 2px var(--black-50);
  background: linear-gradient(
    to right,
    hsl(0, 0%, 27%) 0%,
    hsl(0, 0%, 13%) 100%
  );
  outline: none;
}

Certain white keys need to be moved toward the left so that they sit under the black keys. We give these a class of "offset" in our HTML, so we can keep the CSS simple:

.offset {
  margin: 0 0 0 -1rem;
}

If you’ve followed the CSS up to this point, you should have something like this:

Finally, we’ll style the keyboard itself:

#keyboard {
  height: 15.25rem;
  width: 41rem;
  margin: 0.5rem auto;
  padding: 3rem 0 0 3rem;
  position: relative;
  border: 1px solid var(--keyboard-border);
  border-radius: 1rem;
  background-color: var(--keyboard);
  box-shadow: 0 0 50px var(--black-50) inset, 0 1px var(--keyboard-shadow) inset,
    0 5px 15px var(--black-50);
}

We now have a nice-looking CSS keyboard, but it’s not interactive and it doesn’t make any sounds. To do this, we’ll need JavaScript.

Musical JavaScript

To create the sounds for our synth, we don’t want to rely on audio samples — that’d be cheating! Instead, we can use the web’s AudioContext API, which has tools that can help us turn digital waveforms into sounds.

To create a new audio context, we can use:

const audioContext = new (window.AudioContext || window.webkitAudioContext)();

Before using our audioContext it will be helpful to select all our note elements in the HTML. We can use this helper to easily query the elements:

const getElementByNote = (note) =>
  note && document.querySelector(`[note="${note}"]`);

We can then store the elements in an object, where the key of the object is the key that a user would press on the keyboard to play that note.

const keys = {
  A: { element: getElementByNote("C"), note: "C", octaveOffset: 0 },
  W: { element: getElementByNote("C#"), note: "C#", octaveOffset: 0 },
  S: { element: getElementByNote("D"), note: "D", octaveOffset: 0 },
  E: { element: getElementByNote("D#"), note: "D#", octaveOffset: 0 },
  D: { element: getElementByNote("E"), note: "E", octaveOffset: 0 },
  F: { element: getElementByNote("F"), note: "F", octaveOffset: 0 },
  T: { element: getElementByNote("F#"), note: "F#", octaveOffset: 0 },
  G: { element: getElementByNote("G"), note: "G", octaveOffset: 0 },
  Y: { element: getElementByNote("G#"), note: "G#", octaveOffset: 0 },
  H: { element: getElementByNote("A"), note: "A", octaveOffset: 1 },
  U: { element: getElementByNote("A#"), note: "A#", octaveOffset: 1 },
  J: { element: getElementByNote("B"), note: "B", octaveOffset: 1 },
  K: { element: getElementByNote("C2"), note: "C", octaveOffset: 1 },
  O: { element: getElementByNote("C#2"), note: "C#", octaveOffset: 1 },
  L: { element: getElementByNote("D2"), note: "D", octaveOffset: 1 },
  P: { element: getElementByNote("D#2"), note: "D#", octaveOffset: 1 },
  semicolon: { element: getElementByNote("E2"), note: "E", octaveOffset: 1 }
};

I found it useful to specify the name of the note here, as well as an octaveOffset, which we’ll need when working out the pitch.

We need to supply a pitch in Hz. The equation used to determine pitch is x * 2^(y / 12) where x is the Hz value of a chosen note — usually A₄, which has a pitch of 440Hz — and y is the number of notes above or below that pitch.

That gives us something like this in code:

const getHz = (note = "A", octave = 4) => {
  const A4 = 440;
  let N = 0;
  switch (note) {
    default:
    case "A":
      N = 0;
      break;
    case "A#":
    case "Bb":
      N = 1;
      break;
    case "B":
      N = 2;
      break;
    case "C":
      N = 3;
      break;
    case "C#":
    case "Db":
      N = 4;
      break;
    case "D":
      N = 5;
      break;
    case "D#":
    case "Eb":
      N = 6;
      break;
    case "E":
      N = 7;
      break;
    case "F":
      N = 8;
      break;
    case "F#":
    case "Gb":
      N = 9;
      break;
    case "G":
      N = 10;
      break;
    case "G#":
    case "Ab":
      N = 11;
      break;
  }
  N += 12 * (octave - 4);
  return A4 * Math.pow(2, N / 12);
};

Although we’re only using sharps in the rest of our code, I decided to include flats here as well, so this function could easily be re-used in a different context.

For anyone who’s unsure about musical notation, the notes A# and Bb, for example, describe the exact same pitch. We might choose one over another if we’re playing in a particular key, but for our purposes, the difference doesn’t matter.

Playing notes

We’re ready to start playing some notes!

First, we need some way of telling which notes are playing at any given time. Let’s use a Map to do this, as its unique key constraint can help prevent us from triggering the same note multiple times in a single press. Plus, a user can only click one key at a time, so we can store that as a string.

const pressedNotes = new Map();
let clickedKey = "";

We need two functions, one to play a key — which we’ll trigger on keydown or mousedown — and another to stop playing the key — which we’ll trigger on keyup or mouseup.

Each key will be played on its own oscillator with its own gain node (used to control the volume) and its own waveform type (used to determine the timbre of the sound). I’m opting for a "triangle" waveform, but you can use whatever you prefer of "sine", "triangle", "sawtooth" and "square". The spec offers a little more information on these values.

const playKey = (key) => {
  if (!keys[key]) {
    return;
  }

  const osc = audioContext.createOscillator();
  const noteGainNode = audioContext.createGain();
  noteGainNode.connect(audioContext.destination);
  noteGainNode.gain.value = 0.5;
  osc.connect(noteGainNode);
  osc.type = "triangle";

  const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) + 4);

  if (Number.isFinite(freq)) {
    osc.frequency.value = freq;
  }

  keys[key].element.classList.add("pressed");
  pressedNotes.set(key, osc);
  pressedNotes.get(key).start();
};

Our sound could do with some refinement. At the moment, is has a slightly piercing, microwave-buzzer quality to it! But this is enough to get started. We’ll come back and make some tweaks at the end!

Stopping a key is a simpler task. We need to let each note “ring out” for an amount of time after the user lifts their finger (two seconds is about right), as well as make the necessary visual change.

const stopKey = (key) => {
  if (!keys[key]) {
    return;
  }
  
  keys[key].element.classList.remove("pressed");
  const osc = pressedNotes.get(key);

  if (osc) {
    setTimeout(() => {
      osc.stop();
    }, 2000);

    pressedNotes.delete(key);
  }
};

All that’s left is to add our event listeners:

document.addEventListener("keydown", (e) => {
  const eventKey = e.key.toUpperCase();
  const key = eventKey === ";" ? "semicolon" : eventKey;
  
  if (!key || pressedNotes.get(key)) {
    return;
  }
  playKey(key);
});

document.addEventListener("keyup", (e) => {
  const eventKey = e.key.toUpperCase();
  const key = eventKey === ";" ? "semicolon" : eventKey;
  
  if (!key) {
    return;
  }
  stopKey(key);
});

for (const [key, { element }] of Object.entries(keys)) {
  element.addEventListener("mousedown", () => {
    playKey(key);
    clickedKey = key;
  });
}

document.addEventListener("mouseup", () => {
  stopKey(clickedKey);
});

Note that, while most of our event listeners are added to the HTML document, we can use our keys object to add click listeners to the specific elements we have already queried. We also need to give some special treatment to our highest note, making sure we convert the ";" key into the spelled-out "semicolon" used in our keys object.

We can now play the keys on our synth! There’s just one problem. The sound is still pretty shrill! We might want to knock down the octave of the keyboard by changing the expression that we assign to the freq constant:

const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) + 3);

You might also be able to hear a “click” at the beginning and end of the sound. We can solve this by quickly fading in and more gradually fading out of each sound.

In music production, we use the term attack to describe how quickly a sound goes from nothing to its maximum volume, and “release” to describe how long it takes for a sound to fade to nothing once it’s no longer played. Another useful concept is decay, the the time taken for sound to go from its peak volume to its sustained volume. Thankfully, our noteGainNode has a gain property with a method called exponentialRampToValueAtTime, which we can use to control attack, release and decay. If we replace our previous playKey function with the following one, we’ll get a much nicer plucky sound:

const playKey = (key) => {
  if (!keys[key]) {
    return;
  }

  const osc = audioContext.createOscillator();
  const noteGainNode = audioContext.createGain();
  noteGainNode.connect(audioContext.destination);

  const zeroGain = 0.00001;
  const maxGain = 0.5;
  const sustainedGain = 0.001;

  noteGainNode.gain.value = zeroGain;

  const setAttack = () =>
    noteGainNode.gain.exponentialRampToValueAtTime(
      maxGain,
      audioContext.currentTime + 0.01
    );
  const setDecay = () =>
    noteGainNode.gain.exponentialRampToValueAtTime(
      sustainedGain,
      audioContext.currentTime + 1
    );
  const setRelease = () =>
    noteGainNode.gain.exponentialRampToValueAtTime(
      zeroGain,
      audioContext.currentTime + 2
    );

  setAttack();
  setDecay();
  setRelease();

  osc.connect(noteGainNode);
  osc.type = "triangle";

  const freq = getHz(keys[key].note, (keys[key].octaveOffset || 0) - 1);

  if (Number.isFinite(freq)) {
    osc.frequency.value = freq;
  }

  keys[key].element.classList.add("pressed");
  pressedNotes.set(key, osc);
  pressedNotes.get(key).start();
};

We should have a working, web-ready synth at this point!

The numbers inside our setAttack, setDecay and setRelease functions may seem a bit random, but really they’re just stylistic choices. Try changing them and seeing what happens to the sound. You may end up with something you prefer!

If you’re interested in taking the project further, there are lots of ways you could improve it. Perhaps a volume control, a way to switch between octaves, or a way to choose between waveforms? We could add reverb or a low pass filter. Or perhaps each sound could be made up of multiple oscillators?

For anyone interested in understanding more about how to implement music theory concepts on the web, I recommend looking at the source code of the tonal npm package.