carton.pm

Making a soundboard with React - Part 1

January 31, 2021

I was working on a presentation for a reading group on Designing Data-Intensive Applications by Martin Kleppmann, which I recommend if you have an interest in distributed systems and data stores in general.

We were working through the transaction chapter, and I wanted to make things a little fun, so I decided to turn the reading group session into a game of "Who wants to receive a large transaction". The idea was that I'd be the host and we'd figure out a way to get everybody to be a contestant and answer questions on the chapter.

Who Wants To Receive A Large Transaction

I looked for a soundboard app online that I could use on my work macbook, but I couldn't find anything free that was half-decent.

So, uh... Let's build a soundboard!

Let's play some sounds

Before I can play some sounds on a webpage, I need a webpage:

npx create-react-app --template typescript soundbox

K, now we can start writing a component that'll play sounds when we click on it. Fortunately for us, there's an <audio> tag (see MDN) that we can use in a React component.

It looks like this if you pass in the controls attribute:

<audio src="./sounds/correct.mp3" controls />

For our soundboard, the goal is to have big buttons that are hard to miss, because presumably you're doing something at the same time. This means we're going to have to implement the controls ourselves.

Here are some quick requirements for the UX of our buttons:

  • if you click on the button it starts the sound
  • if you click again on the button it stops the sound
  • if you click again then the sounds plays from the start

We'll need a ref on the audio element so we can call its DOM API, then we'll have to keep track of whether the media is playing or not so we know whether to play/pause.

Our first two hooks here: useRef and useState.

So our implementation so far looks like this:

interface Props {
  src: string
}

export const SoundControlV1: React.FunctionComponent<Props> = ({ src }) => {
  const audioRef = useRef<HTMLAudioElement>(null)
  const [isPlaying, setIsPlaying] = useState(false)

  // Start playing
  const start = () => {
    audioRef.current.play()
    setIsPlaying(true)
  }

  // Stop playing
  const stop = () => {
    const element = audioRef.current

    // Pause (there is no stop())
    element.pause()

    // We need to reset the audio when we stop
    element.currentTime = 0
    setIsPlaying(false)
  }

  const handleClick = async () => {
    const element = audioRef.current

    // Toggle between play/stop
    if (!isPlaying) {
      start()
    } else {
      stop()
    }
  }

  return (
    <button className="soundcontrol" onClick={handleClick}>
      Click me
      <audio src={src} ref={audioRef} />
    </button>
  )
}

It would have been nice to have an element.stop(), but unfortunately the API only supports pause(). We can implement stop() by combining pause() and setting currentTime to 0, this way our button will not simply resume when we click on it again.

There is a subtle bug with this implementation: when the media stops playing, isPlaying is still true! We need to update the state when the component is done playing if we want it to be correct.

We're going to need a new hook to handle side effects: when the media is done playing, the <audio> element fires an event called ended, and the stop() method to be called. We're also going to specify that the event listener should be removed when React unmounts the component.

  useEffect(() => {
    const element = audioRef.current
    element.addEventListener("ended", stop)

    return () => {
      element.removeEventListener("ended", stop)
    }
  }, [])

After slapping a little bit of CSS on the button and fixing our side effect bug, we have the following React component:

It satisfies the 3 requirements we had so far:

  • if you click on the button it starts the sound ✔
  • if you click again on the button it stops the sound ✔
  • if you click again then the sounds plays from the start ✔

Let's get fancy

Eventually the soundboard will have more buttons, it would be nice to know which one is currently playing (if any), and also have an idea of how long until the sound is done playing, that would also make the ugly isPlaying label obsolete.

We could make the background behave like a progress bar.

My first idea was to use box-shadow for this, but it would make the background be rounded since my borders are rounded. We could also use the event timeupdate to automatically resize something, but that means we'd have to handle those in javascript and trigger rerenders somehow.

It would be much better if we could have something done in CSS, so that we only start a transition when the element starts playing, and then we time so that the transition takes the same time as the media.

Let's add a div that will render our background:

    <button className="soundcontrol" onClick={handleClick}>
      <div className="soundcontrol-content">
        Click me
        <audio src={src} ref={audioRef} />
      </div>

      <div
        className={`soundcontrol-progress ${isPlaying ? "playing" : ""}`}
        style={{
          transitionDuration: `${
            !isPlaying ? 0 : audioRef.current?.duration ?? 1
          }s`,
        }}
      />
    </button>

The transitionDuration is set as a property of the element itself, because we can't put it in the css, because we don't know it until the file has been loaded! Note that the duration when the media is not playing is 0, so that the progress doesn't leisurely go up and down, we want it to disappear as soon as the media stops playing.

We also had to put the content of the button in a separate div, so that we can overlay it on top of the progress bar using its z-index:

.soundcontrol {
  /* Required to support the absolute positioning of the progress bar below */
  position: relative;
  /* We want to clip the content of the progress bar */
  overflow: hidden;
}
.soundcontrol-content {
  z-index: 200;
}
.soundcontrol-progress {
  position: absolute;
  left: 0;
  width: 0;
  height: 100%;
  /* This is a white background with 25% opacity */
  background: rgba(255, 255, 255, 0.25);
  z-index: 100;
  transition-property: width;
}
.soundcontrol-progress.playing {
  width: 100%;
}

Alternatives could have been to use multiple backgrounds and then transition the size, or use a css transform, which may be less resource intensive than transitions.

So far

We have a component that can play a sound when clicked, it handles resetting, as well as displaying the current progress. There's room for improvement around the CSS, but it does the job, and we can polish the CSS once we have a soundboard with multiple buttons.

We didn't use useCallback to memoize the callbacks (start(), stop(), etc.), but they're trivial, and it would make the code harder to follow.

In part 2 we'll cover how to make multiple buttons cooperate together, and how to add keyboard shortcuts to control the playback.

Here's the code for the V2 of the button with the progress bar:

import React, { useEffect, useRef, useState } from "react"

import "./SoundControl.css"

interface Props {
  src: string
}

export const SoundControlV2: React.FunctionComponent<Props> = ({ src }) => {
  const audioRef = useRef<HTMLAudioElement>(null)
  const [isPlaying, setIsPlaying] = useState(false)

  const start = () => {
    audioRef.current.play()
    setIsPlaying(true)
  }
  const stop = () => {
    const element = audioRef.current
    element.pause()
    element.currentTime = 0
    setIsPlaying(false)
  }

  const handleClick = async () => {
    const element = audioRef.current

    if (!isPlaying) {
      start()
    } else {
      stop()
    }
  }

  useEffect(() => {
    const element = audioRef.current
    element.addEventListener("ended", stop)

    return () => {
      element.removeEventListener("ended", stop)
    }
  }, [])

  return (
    <button className="soundcontrol" onClick={handleClick}>
      <div className="soundcontrol-content">
        Click me
        <audio src={src} ref={audioRef} />
      </div>

      <div
        className={`soundcontrol-progress ${isPlaying ? "playing" : ""}`}
        style={{
          transitionDuration: `${
            !isPlaying ? 0 : audioRef.current?.duration ?? 1
          }s`,
        }}
      />
    </button>
  )
}

And the css:

.soundcontrol {
  color: white;
  background: blueviolet;
  padding: 1rem;
  border: 0;
  border-radius: 2rem;
  min-height: 6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20rem;
  margin: auto;
}

.soundcontrol:focus {
  outline-width: 1px;
  outline-style: dashed;
  outline-color: fuchsia;
}

.soundcontrol:hover {
  cursor: pointer;
  background: fuchsia;
}

.soundcontrol {
  position: relative;
  overflow: hidden;
}
.soundcontrol-content {
  z-index: 200;
}
.soundcontrol-progress {
  position: absolute;
  left: 0;
  width: 0;
  height: 100%;
  background: rgba(255, 255, 255, 0.25);
  z-index: 100;
  transition-property: width;
}
.soundcontrol-progress.playing {
  width: 100%;
}