Exploring the Web Audio and Web MIDI APIs with virtual pianos

mangelosanto

Matt Angelosanto

Posted on July 19, 2023

Exploring the Web Audio and Web MIDI APIs with virtual pianos

Written by Mads Stoumann✏️

I've had a great deal of fun with the Web Audio API recently. There are already a lot of articles about this API, but there are so many other interesting things you can do with it.

For instance, you can easily use the Web MIDI API in conjunction with the Web Audio API. This opens up opportunities to add a new level of interactivity for users. In this article, we’ll explore how to use the Web Audio API and Web MIDI API using virtual pianos.

Jump ahead:

You can interact with the demos in this CodePen collection. Let’s start with the UI — namely, the virtual pianos.

Creating an accessible UI for our virtual pianos

Most of the examples I could find for "CSS virtual pianos" used float properties and separate containers for the black and white piano keys. This approach isn’t very accessible.

So instead, I wanted a simple wrapper containing all the notes within as <button> elements in the correct order, like so:

<div>
  <button aria-label="A0" data-freq="27.5"></button>
  <button aria-label="A#0" data-freq="29.135"></button>
  <!-- etc -->
</div>
Enter fullscreen mode Exit fullscreen mode

Using <button> elements in the correct order allows you to tab through the notes and trigger their playable state with a click event.

Connecting real devices

I also wanted responsive virtual pianos along with a simple way to render them in various sizes to represent the typical sizes of MIDI keyboards: 88 keys, 61 keys, 49 keys, 32 keys, and 25 keys. These are also the sizes you typically see in music software.

For example, here’s a 32-key keyboard from Native Instruments: Example Midi Keyboard With 32 Keys From Native Instruments

Using your browser's Web MIDI API, you can connect a real USB-connected MIDI keyboard like the one above and detect its events in your browser. These events can then be used to trigger the Web Audio API.

At the end of this article, we’ll use all these techniques together for the practical purpose of building a chord visualizer, a tool to help you learn chords in any key by seeing, hearing, and practicing them on a virtual keyboard.

Rendering the markup with JavaScript

It doesn't make sense to manually type all the <button> elements and their frequencies. Let's use JavaScript for that.

First, let’s define a method to get the frequency for a given note:

const getHz = (N = 0) => 440 * Math.pow(2, N / 12);
Enter fullscreen mode Exit fullscreen mode

We provide an integer N that indicates each note’s position relative to the A440 pitch, which is the A4 key on a piano as well as the tuning standard for Western music. So, A#4 — the next note in the sequence — will have N = 1.

Next, we need an array of the notes in an octave on a piano:

const notes = ['A','A#','B','C','C#','D','D#','E','F','F#','G','G#'];
Enter fullscreen mode Exit fullscreen mode

All the black keys are defined using sharp # note names. I have excluded the flat notes since they're just equivalents of the sharp notes by different names.

Now we need to create some data. The returned data will be an array of objects, with each object representing a note on the piano:

const freqs = (start, end) => {
  let black = 0,
    white = -2;
  return Array(end - start)
    .fill()
    .map((_, i) => {
      const key = (start + i) % 12;
      const note = notes[key < 0 ? 12 + key : key];
      const octave = Math.ceil(4 + (start + i) / 12);
      if (i === 0 && note === "C") black = -3;
      note.includes("#")
        ? ((black += 3), ["C#", "F#"].includes(note) 
        && (black += 3))
        : (white += 3);

      return {
        note,
        freq: getHz(start + i),
        octave: note === "B" || note === "A#" 
        ? octave - 1 : octave,
        offset: note.includes("#") ? black : white,
      };
    });
};
Enter fullscreen mode Exit fullscreen mode

Let's break down what’s happening in the code above:

  • The start and end parameters are integers defining the number of notes to the left (start) and right (end) of A440. On a grand piano, which has 88 keys, this is the same as freqs(-48, 40)
  • We create grid offset variables for the black and white notes, which we will discuss more in the CSS section later
  • We find the note, or key, position. The magic number 12 represents the number of notes in an octave
  • We find the note name in the notes array
  • Using the same magic number 12, we find the octave to which the note belongs. There are seven octaves — plus an additional four notes — on a grand piano
  • A grand piano starts with the A0-note, while other popular keyboards all start with a C, so the if statement handles the indexes for black and white notes no matter what the start note is
  • We return an object containing the note name, freq, octave, and offset for each note

Phew! Let's add a simple render method:

const render = (data) => data.map(item => `
  <button data-note="${item.note}${item.octave}"
  data-freq="${item.freq}" style="--gcs:${item.offset}" 
  type="button>"></button>`).join('\n')
Enter fullscreen mode Exit fullscreen mode

Add a wrapper:

<div id="kb88" class="kb"></div>
Enter fullscreen mode Exit fullscreen mode

Finally, we’ll call our render script:

kb88.innerHTML = render(freqs(-48, 40))
Enter fullscreen mode Exit fullscreen mode

Styling our keyboard with CSS

Now I will finally explain what the offset and the black and white indexes were for!

Each white key on our keyboard will span three grid columns, while each black key will span two grid columns. Additionally, each white key will span five grid rows, while each black key spans three grid rows, or 60 percent of the keyboard height: Virtual Keyboard Styled With Css On a grand piano, there are 52 white keys. Since each key spans three columns, we'll create a custom property --_r to represent the total number of grid columns needed for all our white keys. We’ll set its default value to 52*3, which is 156:

.kb {
  block-size: 10rem;
  display: grid;
  grid-column-gap: 1px;
  grid-template-columns: repeat(var(--_r, 156), 1fr);
  grid-template-rows: repeat(5, 1fr);
}
Enter fullscreen mode Exit fullscreen mode

All the notes need a unique grid-column-start property. This property is set on each <button> — from the offset-property, using either a "black" or "white" index — as a CSS custom property named --_gcs.

As each white key spans three grid columns, there will be a difference of three between each white key. The black keys span two grid columns, but their offset follows a more irregular pattern.

For the notes, we'll add some common styles with a few custom properties:

.kb [data-note] {
  background-color: var(--_bgc, #FFF);
  border: 0;
  border-radius: 0 0 3px 3px;
  grid-column: var(--gcs) / span var(--_csp, 3);
  grid-row: 1 / span var(--_rsp, 5);
}
Enter fullscreen mode Exit fullscreen mode

Then, for the black keys, we'll update some of the properties:

.kb [aria-label*="#"] { 
  --_bgc: #000;
  --_csp: 2;
  --_rsp: 3;
  position: relative;
}
Enter fullscreen mode Exit fullscreen mode

OK, let's see what we've created: 88 Key Variant Of Virtual Piano Cool! Let's create some popular variants.

Here’s what needs to change for a 61-key variant:

kb61.innerHTML = render(freqs(-33, 28));
Enter fullscreen mode Exit fullscreen mode

Here’s the result: 61 Key Variant Of Virtual Piano Likewise, you can update the code like so for the 49-key variant:

kb49.innerHTML = render(freqs(-21, 28));
Enter fullscreen mode Exit fullscreen mode

This would be the outcome: 49 Key Variant Of Virtual Piano Here’s the code for the 32-key variant:

kb32.innerHTML = render(freqs(-9, 23));
Enter fullscreen mode Exit fullscreen mode

The keyboard should look like this: 32 Key Variant Of Virtual Piano Finally, here’s the code for the 25-key variant:

kb61.innerHTML = render(freqs(-33, 28));kb25.innerHTML = render(freqs(-9, 16));
Enter fullscreen mode Exit fullscreen mode

The result should look like the below: 25 Key Variant Of Virtual Piano For these examples, I added an extra wrapper around the keyboards — <div class="synth"> — and added titles.

If we add inline-size: max-content to this, the keyboards will all line up nicely: All Variants Of Virtual Piano You can interact with the pianos in this CodePen demo:

See the Pen CSS Grid Pianos by Mads Stoumann (@stoumann) on CodePen.

For the best experience, I recommend opening the demo in a new tab or browser window so you can interact with it on a larger screen. Note that these pianos are not playable yet — we’ll cover that later in this article.

Reviewing virtual piano accessibility features

All the notes on the pianos are <button> elements and are thus focusable and tabbable. As they are rendered in their natural order within the same wrapper, there's no need to do anything extra to control tab order or anything else.

We added a box-shadow effect to highlight the piano key on hover or when a user is using :focus-visible) to focus on a note. You can change the color of this shadow by updating the --_h hue property. You can also expand this capability to show chords: 61 Key Variant Of Virtual Piano With C Major Chord Highlighted In Light Green Additionally, each note has an aria-label attribute with the note name. With very little CSS and only 385 bytes of minified/gzipped JavaScript, there's plenty of room to be creative with the Web Audio API!

Making the keyboard playable with the Web Audio API

Now it’s time to take a look at the Web Audio API. We’ll use some very simple logic to get us started. First, we’ll create the AudioContext object and a default gainNode:

const audioCtx = new (window.AudioContext || window.webkitAudioContext)
const gainNode = audioCtx.createGain()
const notemap = new Map();
gainNode.connect(audioCtx.destination);
Enter fullscreen mode Exit fullscreen mode

Next, let’s go over a simple method to create an oscillator, which will produce our sound. We’ll add a single param, freq, which is the frequency we want to play:

function createOscillator(freq) {
  const oscNode = audioCtx.createOscillator()
  oscNode.type = 'triangle'
  oscNode.frequency.value = freq
  oscNode.connect(gainNode)
  return oscNode
}
Enter fullscreen mode Exit fullscreen mode

We’ll add two methods to handle noteon and noteoff:

export function noteoff(key) {
  key.classList.remove('keydown')
  const oscNode = notemap.get(key.name)
  if (oscNode) oscNode.stop(0)
  notemap.delete(key.name)
}
Enter fullscreen mode Exit fullscreen mode

And finally, we’ll add event listeners to all the keys:

keys.forEach(key => {
  key.addEventListener('pointerdown', event => {
    noteon(event.target, [{freq: event.target.dataset.freq}])
  })
  key.addEventListener('pointerup', event => { noteoff(event.target) })
  key.addEventListener('pointerleave', event => { noteoff(event.target) })
})
Enter fullscreen mode Exit fullscreen mode

And that’s it. Let’s take a look at the Web MIDI API next.

Attaching a MIDI device with the Web MIDI API

MIDI, or Musical Instrument Digital Interface, is a communication protocol for electronic musical instruments.

Back in the late 1980s, I used it to connect and sync my Yamaha DX7IIFD-synth to my RX7 Digital Rhythm Programmer! This was before anyone used computers for music. But MIDI is still very much alive, and widely used.

If you own a real MIDI keyboard, it’s time to plug it in! We’ll be connecting a 49-key MIDI keyboard in this tutorial: Image Of 49-Key Midi Keyboard Connected For Project Although all browsers except Safari support MIDI, you can test whether your browser supports it using the following command:

if (navigator.requestMIDIAccess) { ... }
Enter fullscreen mode Exit fullscreen mode

If supported, use the following command to request access to the MIDI device:

navigator.requestMIDIAccess().then(onMIDISuccess, onMIDIError)
Enter fullscreen mode Exit fullscreen mode

The then method takes two arguments — onMIDISucess if access is granted, and onMIDIError if access is denied. We have to create both methods, starting with onMIDISuccess:

const onMIDISuccess = (midiAccess) => {
  for (var input of midiAccess.inputs.values()) input.onmidimessage = getMIDIMessage;
}
Enter fullscreen mode Exit fullscreen mode

For the onMIDIError, I simply return an inline method, that returns a message to the console:

() => console.log('Could not access your MIDI devices.')
Enter fullscreen mode Exit fullscreen mode

In the code above, onMIDISuccess receives a midiAccess object that represents a list of inputs, or available MIDI devices.

It then iterates over these inputs and sets the onmidimessage to the function getMIDIMessage. When the MIDI device sends a message — in this case, when a user presses a key on the digital piano — the function will be called.

Now we need to configure the MIDI message events for the getMIDIMessage function. MIDI has a lot of events, but we’ll skip to the most important part: note on and note off:

const getMIDIMessage = message => {
  const [command, note, velocity] = message.data;
  switch (command) {
    case 144: // on
      if (velocity > 0) {
        console.log('note on')
      }
    break;
    case 128: // off
      console.log('note off')
    break;
  }
}
Enter fullscreen mode Exit fullscreen mode

If you press some notes on your keyboard, you should see the MIDI note number in the console.

Now, we’ll replace the console.log entries from the getMIDIMEssage method with custom events:

/* NOTE ON */
const event = new CustomEvent('noteon', { detail: { note, velocity }});
element.dispatchEvent(event)

/* NOTE OFF */
const event = new CustomEvent('noteoff', { detail: { note }});
element.dispatchEvent(event)
Enter fullscreen mode Exit fullscreen mode

Back in our app code, we’ll add two new event listeners. We’ll call the noteon and noteoff-methods from these event listeners:

midi.addEventListener('noteon', (event) => {
  const note = midi.elements[`midi_${event.detail.note}`]
  note.style.setProperty('--v', event.detail.velocity)
  noteon(note, [{freq: note.dataset.freq}])
})
midi.addEventListener('noteoff', (event) => {
  const note = midi.elements[`midi_${event.detail.note}`]
  noteoff(note)
})
Enter fullscreen mode Exit fullscreen mode

If you save and refresh — and have a real MIDI keyboard connected — you should be able to play it now. Make sure you’re also not using Safari. Try the demo below:

See the Pen Web MIDI API Demo by Mads Stoumann (@stoumann) on CodePen.

Creating a chord visualizer tool

For a data scientist, chords are arrays of frequencies. For musicians, they just sound good!

In the final part of this tutorial, let’s create a tool that’ll help us visualize and play all kinds of chords in any key. We’ll be building this: Chord Visualizer Tool With Key Dropdown Showing Selection Of A And Chord Dropdown Showing Selection Of Augmented Major Seventh. Virtual Keyboard Shows Selected Chord Highlighted In Red

First, we need an array of chord types. All chords can be transposed to any key, so we’ll create a “mathematical chord” where the root note or piano key always equals zero.

For a major chord — also called a major triad — we have a root note, a major third, and a perfect fifth. We won’t dive deep into music theory here, but in a JavaScript array, this equals [0, 4, 7], where:

  • 0 is the root note
  • 4 is the major third note, with three piano keys between the root and the major third
  • 7 is the perfect fifth note, with seven piano keys between the root and the perfect fifth

We’ll define other popular chord types like so:

const chords = {
  'Major triad': [0, 4, 7],
  'Minor triad': [0, 3, 7],
  'Augmented triad': [0, 4, 8],
  'Diminished  triad': [0, 3, 6],
  'Dominanth seventh': [0, 4, 7, 10],
  'Major seventh': [0, 4, 7, 11],
  'Minor-major seventh': [0, 3, 7, 11],
  'Minor seventh': [0, 3, 7, 10],
  'Augmented-major seventh': [0, 4, 8, 11],
  'Augmented seventh': [0, 4, 8, 10],
  'Half-diminished seventh': [0, 3, 8, 10],
  'Diminished seventh': [0, 3, 6, 10],
  'Dominant seventh flat five': [0, 4, 6, 10],
  'Major ninth': [0, 4, 7, 11, 14],
  'Dominant ninth': [0, 4, 7, 10, 14],
  'Dominant minor ninth': [0, 4, 7, 10, 13],
  'Minor-major ninth': [0, 3, 7, 11, 14],
  'Minor ninth': [0, 3, 7, 10, 14],
  'Augmented major ninth': [0, 4, 8, 11, 14],
  'Augmented dominant ninth': [0, 4, 8, 10, 14],
  'Half-diminished ninth': [0, 3, 6, 10, 14],
  'Half-diminished minor ninth': [0, 3, 6, 10, 13],
  'Diminished ninth': [0, 3, 6, 10, 14],
  'Diminished minor ninth': [0, 3, 6, 10, 13],
}
Enter fullscreen mode Exit fullscreen mode

We need a little extra markup to help us select the key and chord type:

<form id="app">
  <fieldset>
    <label><strong>Key:</strong><select id="key"></select></label>
    <label><strong>Chord:</strong><select id="chord"></select></label>
  </fieldset>
  /* rendered keyboard here */
</form>
Enter fullscreen mode Exit fullscreen mode

Using the notes array from our first step and the chords from above, we’ll render additional markup that will return the chords as a list of options in a <select> dropdown:

key.innerHTML = notes.map(key => `<option value="${key}">${key}</option>`).join('');
chord.innerHTML = Object.keys(chords).map(key => `<option value="${key}">${key}</option>`).join('');
Enter fullscreen mode Exit fullscreen mode

Next, we’ll set up a getChord method to return an array of note objects:

function getChord(key, chord) {
  const index = notes.indexOf(key);
  return chords[chord].map(note => getNote(note + index))
}
Enter fullscreen mode Exit fullscreen mode

The getChord method uses a small helper method to get a single note within a chord:

function getNote(N) {
  const key = N % 12;
  const note = notes[key < 0 ? 12 + key : key];
  const octave = Math.ceil(4 + (N / 12)); /* 4 is octave of root, 440 */
  return {
    freq: getHz(N),
    midi: N + 69, /* A4 === MIDI note number 69 */
    note,
    octave: note === 'B' || note === 'A#' ? octave - 1 : octave
  }
} 
Enter fullscreen mode Exit fullscreen mode

To trigger a chord change, we’ll add a change event to the form we just added. This event will trigger each time you either change the key or the chord:

app.addEventListener('change', () => {
  const notes = getChord(key.value, chord.value);
  playChord(app, notes)
})
Enter fullscreen mode Exit fullscreen mode

The method playChord is a new addition that iterates the notes of the chord and creates an oscillator for each. Instead of handling noteoff, we simply stop the audio after 1.5 seconds:

function playChord(app, notes) {
  /* Remove 'keydown'-classes for any selected note */
  app.querySelectorAll('.keydown').forEach(key => key.classList.remove('keydown'))
  gainNode.gain.value = 1 / notes.length
  notes.forEach(note => {
    const key = app.elements[`midi_${note.midi}`]
    key.classList.add('keydown')
    const oscNode = createOscillator(note.freq)
    oscNode.start(0)
    oscNode.stop(audioCtx.currentTime + 1.5)
    gainNode.gain.setTargetAtTime(0, 0.75, 0.25)
  })
}
Enter fullscreen mode Exit fullscreen mode

And that’s finally it. Try the demo below:

See the Pen Chord Visualizer by Mads Stoumann (@stoumann) on CodePen.

Conclusion

This concludes my tour of virtual pianos, the Web Audio API, and the Web MIDI API.

The graphic designer in me really loves how you can utilize CSS Grid in so many interesting ways — even building complex, multi-column-spanning, layered piano keys!

The mathematician in me really loves the link between frequencies, chords, and harmonies, along with how you can use simple math to calculate those. Music is math, the same way as colors are math.

And finally, the musician in me just had a great time fumbling around with these APIs, and often got stuck playing a new tune while I was supposed to be writing!

If you want to investigate any of the covered topics further, fork any of the CodePens and have fun!


LogRocket: Debug JavaScript errors more easily by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket JavaScript Signup

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

Try it for free.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on July 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related