Making a soundboard with React - Part 1
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.
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%;
}