Create a SoundCloud audio player with Wavesurfer.js

Published: April 15th, 2021 - 14 minute read.
wavesurfer-featured

This tutorial will walk you through the steps required to create a SoundCloud-style audio player with custom audio controls and a live waveform. It's built using plain HTML, CSS, and vanilla Javascript. The only third-party code required is Wavesurfer.js, which will render the waveform and help when handling the playback control functions.

Setup

If you'd like to dive right into the code, it's available on GitHub. You can grab a copy and follow along to understand how it all comes together. You can also view a live version here.

Structure

The structure of the app is fairly straightforward, with just three main files, an index.html, style.css and script.js. There is also an assets folder, which will contain a folder for icons and an audio folder for the sample MP3 used in the project.

The overall is structured as follows:

index.html
style.css
script.js
assets/
├─ icons/
│  ├─ play.svg
│  ├─ pause.svg
│  ├─ volume.svg
│  ├─ mute.svg
├─ audio/
│  ├─ sample.mp3

Wavesurfer

Grab the latest version of Wavesurfer.js from Unpkg. You can simply copy the link to unpkg.

Icons

You can use any icons you'd like, but I'd recommend checking out akaricons.com, which has a great collection of free and open-source SVG icons. We need four icons, one for play, pause, mute and, unmute.

HTML

The HTML is pretty simple, a standard HTML file with a link to the style.css stylesheet and both the wavesurfer.js script and the external script.js. Inside the body, we need to add two tags, a main tag with a class of container and a div inside with the class of audio-player.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SoundCloud Player</title>

    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="container">
      <div class="audio-player"></div>
    </main>
    <script src="https://unpkg.com/wavesurfer.js@4.6.0/dist/wavesurfer.js"></script>
    <script src="script.js"></script>
  </body>
</html>

Play Button

Inside the audio-player div we first create a play button. Inside the button element, we add an image linking to the SVG icon for the play button. It is important to add the id attribute to this element as we'll be using this to select it later on in the javascript.

<button id="playButton" class="play-button">
  <img
    id="playButtonIcon"
    class="play-button-icon"
    src="assets/icons/play.svg"
    alt="Play Button"
  />
</button>

Player Body

Below the play button, we can add the player body. The player body contains an empty div for the waveform, as well as elements for the volume controls and the timecode. The timecode will output the current timestamp and total duration of the audio track.

<div class="player-body">
  <p class="title">Artist - Track Title</p>
  <div id="waveform" class="waveform"></div>

  <div class="controls">
    <div class="volume">
      <img
        id="volumeIcon"
        class="volume-icon"
        src="assets/icons/volume.svg"
        alt="Volume"
      />
      <input
        id="volumeSlider"
        class="volume-slider"
        type="range"
        name="volume-slider"
        min="0"
        max="100"
        value="50"
      />
    </div>

    <div class="timecode">
      <span id="currentTime">00:00:00</span>
      <span>/</span>
      <span id="totalDuration">00:00:00</span>
    </div>
  </div>
</div>

That's all the code required for the HTML. The diagram below demonstrates where each of these elements will be used, with labels indicating each individual HTML code block.

"SoundCloud Player structure"

Putting it all together and the final HTML file should look something like this.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SoundCloud Player</title>

    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="container">
      <h1>SoundCloud Player</h1>

      <div class="audio-player">
        <button id="playButton" class="play-button">
          <img
            id="playButtonIcon"
            class="play-button-icon"
            src="assets/icons/play.svg"
            alt="Play Button"
          />
        </button>

        <div class="player-body">
          <p class="title">Artist - Track Title</p>
          <div id="waveform" class="waveform"></div>

          <div class="controls">
            <div class="volume">
              <img
                id="volumeIcon"
                class="volume-icon"
                src="assets/icons/volume.svg"
                alt="Volume"
              />
              <input
                id="volumeSlider"
                class="volume-slider"
                type="range"
                name="volume-slider"
                min="0"
                max="100"
                value="50"
              />
            </div>

            <div class="timecode">
              <span id="currentTime">00:00:00</span>
              <span>/</span>
              <span id="totalDuration">00:00:00</span>
            </div>
          </div>
        </div>
      </div>
    </main>
    <script src="https://unpkg.com/wavesurfer.js@4.6.0/dist/wavesurfer.js"></script>
    <script src="script.js"></script>
  </body>
</html>

CSS

First, we create the general page styles. I'm using CSS custom properties to define colors and the border, as well as using some basic element resets. None of the general styles below are essential to the player and can be ignored if you're embedding this player into an existing project.

/* General styles */
:root {
  --primary-color: rgb(24, 24, 24);
  --primary-background-color: rgb(221, 221, 221);
  --secondary-color: rgb(75, 75, 75);
  --secondary-background-color: rgb(255, 255, 255);
  --highlight-color: rgb(255, 85, 1);
  --box-shadow-color: rgb(201, 201, 201);
  --disabled-button-color: rgb(175, 175, 175);
  --border-radius: 1rem;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
  width: 100%;
}
html {
  font-size: 62.5%;
}

body {
  font-size: 1.5rem;
  font-family: Arial, Helvetica, sans-serif;
  font-weight: 400;
  color: var(--primary-color);
  background-color: var(--primary-background-color);
}

/* Main Container */
.container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: var(--primary-background-color);
  padding: 1rem;
}

We can then define the audio player styles. Here we set the element to display: flex and give it a maximum width for larger desktop displays. The margin and colour can be customised depending on your personal preference.

/* Audio player */
.audio-player {
  width: 100%;
  max-width: 100rem;
  display: flex;
  margin: 2rem 1rem;
  background-color: var(--secondary-background-color);
  border-radius: var(--border-radius);
  box-shadow: 0.2rem 0.2rem 1rem 0.2rem var(--box-shadow-color);
}

We set the play button to a square with a maximum width and height. The rest of the style removes some of the default button styles and adds a mouse pointer when a user hovers over the element.

/* Play button */
.play-button {
  min-width: 13rem;
  min-height: 13rem;

  /* Reset default button styles */
  border: none;
  background-color: transparent;
  outline: none;
  cursor: pointer;
}

The remaining CSS relates to the layout of the elements, which contains resets for the volume slider that removes some of the browser's default styles.

/* Main player body, which includes title, waveform, volume and timecode */
.player-body {
  width: 100%;
  padding: 1rem;
}

/* Audio track title */
.title {
  width: 100%;
  font-weight: 600;
}

/* Main waveform */
.waveform {
  width: 100%;
  min-height: 8rem;
  padding: 0.5rem 0;
}

/* Controls include volume mute/unmute, volume slider and timecode */
.controls {
  display: flex;
  justify-content: space-between;
}

/* Timecode */
.timecode {
  color: var(--secondary-color);
}

/* Volume */
.volume {
  display: flex;
  align-items: center;
}
.volume-icon {
  cursor: pointer;
}
.volume-slider {
  margin: 0 1rem;
  cursor: pointer;

  width: 100%;
  outline: none;
  -webkit-appearance: none;
  background: transparent;
}

Put it all together, and the final CSS file should look like this.

/* Main styles */
:root {
  --primary-color: rgb(24, 24, 24);
  --primary-background-color: rgb(221, 221, 221);
  --secondary-color: rgb(75, 75, 75);
  --secondary-background-color: rgb(255, 255, 255);
  --highlight-color: rgb(255, 85, 1);
  --box-shadow-color: rgb(201, 201, 201);
  --disabled-button-color: rgb(175, 175, 175);
  --border-radius: 1rem;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
  width: 100%;
}
html {
  font-size: 62.5%;
}

body {
  font-size: 1.5rem;
  font-family: Arial, Helvetica, sans-serif;
  font-weight: 400;
  color: var(--primary-color);
  background-color: var(--primary-background-color);
}

/* Main Container */
.container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  background-color: var(--primary-background-color);
  padding: 1rem;
}

/* Audio player */
.audio-player {
  width: 100%;
  max-width: 100rem;
  display: flex;
  margin: 2rem 1rem;
  background-color: var(--secondary-background-color);
  border-radius: var(--border-radius);
  box-shadow: 0.2rem 0.2rem 1rem 0.2rem var(--box-shadow-color);
}

/* Play button */
.play-button {
  min-width: 13rem;
  min-height: 13rem;

  /* Reset default button styles */
  border: none;
  background-color: transparent;
  outline: none;
  cursor: pointer;
}

/* Main player body, which includes title, waveform, volume and timecode */
.player-body {
  width: 100%;
  padding: 1rem;
}

/* Audio track title */
.title {
  width: 100%;
  font-weight: 600;
}

/* Main waveform */
.waveform {
  width: 100%;
  min-height: 8rem;
  padding: 0.5rem 0;
}

/* Controls include volume mute/unmute, volume slider and timecode */
.controls {
  display: flex;
  justify-content: space-between;
}

/* Timecode */
.timecode {
  color: var(--secondary-color);
}

/* Volume */
.volume {
  display: flex;
  align-items: center;
}
.volume-icon {
  cursor: pointer;
}
.volume-slider {
  margin: 0 1rem;
  cursor: pointer;

  width: 100%;
  outline: none;
  -webkit-appearance: none;
  background: transparent;
}

Javascript

We can now start to add functionality to the player. To begin, we need to define a few variables, which reference different DOM elements that we will use in this section.

const playButton = document.querySelector("#playButton")
const playButtonIcon = document.querySelector("#playButtonIcon")
const waveform = document.querySelector("#waveform")
const volumeIcon = document.querySelector("#volumeIcon")
const volumeSlider = document.querySelector("#volumeSlider")
const currentTime = document.querySelector("#currentTime")
const totalDuration = document.querySelector("#totalDuration")

We can now define the functions for the player. We can start by defining a function, which returns a new Wavesurfer instance. The WaveSurfer.create() method comes from the external wavesurfer.js file. The options passed in such as height and waveColor can be customised using the wavesurfer.js documentation.

const initializeWavesurfer = () => {
  return WaveSurfer.create({
    container: "#waveform",
    responsive: true,
    height: 80,
    waveColor: "#ff5501",
    progressColor: "#d44700",
  })
}

We'll need a function to handle the action when a user clicks the play button. The below togglePlay() function first checks if audio is playing and updates the image to a pause icon. Otherwise, the function will set the icon source to the play button.

const togglePlay = () => {
  wavesurfer.playPause()

  const isPlaying = wavesurfer.isPlaying()

  if (isPlaying) {
    playButtonIcon.src = "assets/icons/pause.svg"
  } else {
    playButtonIcon.src = "assets/icons/play.svg"
  }
}

We can define a function to handle user interaction with the volume slider. It simply sets the volume of the Wavesurfer instance to the current value of the volume input. One important thing to note, although the volume slider ranges from 0 - 100, Wavesurfer accepts values as a decimal between 0 - 1. Because of this, we must divide the volume range by 100 to convert it to the required decimal value. When a user changes volume, the current value is also saved to local storage.

const handleVolumeChange = e => {
  // Set volume as input value divided by 100
  // NB: Wavesurfer only excepts volume value between 0 - 1
  const volume = e.target.value / 100

  wavesurfer.setVolume(volume)

  // Save the value to local storage so it persists between page reloads
  localStorage.setItem("audio-player-volume", volume)
}

This function checks local storage for a value and returns it. If one doesn't exist, it returns a default of 50. We then set the slider value to the value saved in local storage. Due to the same Wavesurfer restriction discussed previously, the volume value must be multiplied by 100 this time.

const setVolumeFromLocalStorage = () => {
  // Retrieves the volume from local storage, or falls back to default value of 50
  const volume = localStorage.getItem("audio-player-volume") * 100 || 50

  volumeSlider.value = volume
}

This function formats a parameter passed in (seconds) and returns the value as HH:MM:SS. For example, 620 seconds would be formatted as 00:10:20.

const formatTimecode = seconds => {
  return new Date(seconds * 1000).toISOString().substr(11, 8)
}

We also need to define a function to toggle the audio to mute/unmute. We first use the wavesurfer.toggleMute() method to change the mute state and then check if the audio is currently muted. If it is muted, we change the icon to mute and disable the volume slider. Otherwise, we do the opposite and change the icon to indicate the volume is unmuted.

const toggleMute = () => {
  wavesurfer.toggleMute()

  const isMuted = wavesurfer.getMute()

  if (isMuted) {
    volumeIcon.src = "assets/icons/mute.svg"
    volumeSlider.disabled = true
  } else {
    volumeSlider.disabled = false
    volumeIcon.src = "assets/icons/volume.svg"
  }
}

Now that we've created the required functions, we can start by creating a new Wavesurfer instance and loading the audio file. We'll be using an audio sample located in the assets/audio directory.

// Create a new instance and load the wavesurfer
const wavesurfer = initializeWavesurfer()
wavesurfer.load("assets/audio/sample.mp3")

We now need to define a few javascript event-listeners to handle setting the volume value of the player. We also define listeners to handle user interaction with the player.

// Javascript Event listeners
window.addEventListener("load", setVolumeFromLocalStorage)

playButton.addEventListener("click", togglePlay)
volumeIcon.addEventListener("click", toggleMute)
volumeSlider.addEventListener("input", handleVolumeChange)

Finally, we can use some of the Wavesurfer events (complete list available in the docs) to handle setting the dynamic timecode value and the audio player volume.

// Wavesurfer event listeners
wavesurfer.on("ready", () => {
  // Set wavesurfer volume
  wavesurfer.setVolume(volumeSlider.value / 100)

  // Set audio track total duration
  const duration = wavesurfer.getDuration()
  totalDuration.innerHTML = formatTimecode(duration)
})

// Sets the timecode current timestamp as audio plays
wavesurfer.on("audioprocess", () => {
  const time = wavesurfer.getCurrentTime()
  currentTime.innerHTML = formatTimecode(time)
})

// Resets the play button icon after audio ends
wavesurfer.on("finish", () => {
  playButtonIcon.src = "assets/icons/play.svg"
})

Putting everything together, you're javascript file should look like this.

const playButton = document.querySelector("#playButton")
const playButtonIcon = document.querySelector("#playButtonIcon")
const waveform = document.querySelector("#waveform")
const volumeIcon = document.querySelector("#volumeIcon")
const volumeSlider = document.querySelector("#volumeSlider")
const currentTime = document.querySelector("#currentTime")
const totalDuration = document.querySelector("#totalDuration")

// --------------------------------------------------------- //

/**
 * Initialize Wavesurfer
 * @returns a new Wavesurfer instance
 */
const initializeWavesurfer = () => {
  return WaveSurfer.create({
    container: "#waveform",
    responsive: true,
    height: 80,
    waveColor: "#ff5501",
    progressColor: "#d44700",
  })
}

// --------------------------------------------------------- //

// Functions

/**
 * Toggle play button
 */
const togglePlay = () => {
  wavesurfer.playPause()

  const isPlaying = wavesurfer.isPlaying()

  if (isPlaying) {
    playButtonIcon.src = "assets/icons/pause.svg"
  } else {
    playButtonIcon.src = "assets/icons/play.svg"
  }
}

/**
 * Handles changing the volume slider input
 * @param {event} e
 */
const handleVolumeChange = e => {
  // Set volume as input value divided by 100
  // NB: Wavesurfer only excepts volume value between 0 - 1
  const volume = e.target.value / 100

  wavesurfer.setVolume(volume)

  // Save the value to local storage so it persists between page reloads
  localStorage.setItem("audio-player-volume", volume)
}

/**
 * Retrieves the volume value from local storage and sets the volume slider
 */
const setVolumeFromLocalStorage = () => {
  // Retrieves the volume from local storage, or falls back to default value of 50
  const volume = localStorage.getItem("audio-player-volume") * 100 || 50

  volumeSlider.value = volume
}

/**
 * Formats time as HH:MM:SS
 * @param {number} seconds
 * @returns time as HH:MM:SS
 */
const formatTimecode = seconds => {
  return new Date(seconds * 1000).toISOString().substr(11, 8)
}

/**
 * Toggles mute/unmute of the Wavesurfer volume
 * Also changes the volume icon and disables the volume slider
 */
const toggleMute = () => {
  wavesurfer.toggleMute()

  const isMuted = wavesurfer.getMute()

  if (isMuted) {
    volumeIcon.src = "assets/icons/mute.svg"
    volumeSlider.disabled = true
  } else {
    volumeSlider.disabled = false
    volumeIcon.src = "assets/icons/volume.svg"
  }
}

// --------------------------------------------------------- //

// Create a new instance and load the wavesurfer
const wavesurfer = initializeWavesurfer()
wavesurfer.load("assets/audio/sample.mp3")

// --------------------------------------------------------- //

// Javascript Event listeners
window.addEventListener("load", setVolumeFromLocalStorage)

playButton.addEventListener("click", togglePlay)
volumeIcon.addEventListener("click", toggleMute)
volumeSlider.addEventListener("input", handleVolumeChange)

// --------------------------------------------------------- //

// Wavesurfer event listeners
wavesurfer.on("ready", () => {
  // Set wavesurfer volume
  wavesurfer.setVolume(volumeSlider.value / 100)

  // Set audio track total duration
  const duration = wavesurfer.getDuration()
  totalDuration.innerHTML = formatTimecode(duration)
})

// Sets the timecode current timestamp as audio plays
wavesurfer.on("audioprocess", () => {
  const time = wavesurfer.getCurrentTime()
  currentTime.innerHTML = formatTimecode(time)
})

// Resets the play button icon after audio ends
wavesurfer.on("finish", () => {
  playButtonIcon.src = "assets/icons/play.svg"
})

With all three files combined, we should now have a fully functional SoundCloud player clone with a waveform, custom audio controls, and a modern design.

"Final SoundCloud player"

Bonus

By default, the volume slider will use the default web browser styles, which varies between web-browser. As a bonus, you can use the following to add a custom volume slider with a consistent style between each web browser.

/* Custom volume slider */
.volume-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 1.5rem;
  border: none;
  border-radius: 50%;
  background: var(--highlight-color);

  margin-top: -0.6rem;
}
.volume-slider::-moz-range-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 1.5rem;
  border: none;
  border-radius: 50%;
  background: var(--highlight-color);
}
.volume-slider::-ms-thumb {
  -webkit-appearance: none;
  height: 1.5rem;
  width: 1.5rem;
  border: none;
  border-radius: 50%;
  background: var(--highlight-color);
}
.volume-slider::-webkit-slider-runnable-track {
  width: 100%;
  height: 0.25rem;
  background-color: var(--secondary-color);
  border-radius: var(--border-radius);
}
.volume-slider::-ms-track {
  background: transparent;
  border-color: transparent;
  color: transparent;

  width: 100%;
  height: 0.25rem;
  background-color: var(--secondary-color);
  border-radius: var(--border-radius);
}

/* Muted/disabled volume slider */
.volume-slider[disabled] {
  cursor: not-allowed;
}
.volume-slider[disabled]::-webkit-slider-thumb {
  background-color: var(--disabled-button-color);
}
.volume-slider[disabled]::-moz-range-thumb {
  background-color: var(--disabled-button-color);
}
.volume-slider[disabled]::-ms-thumb {
  background-color: var(--disabled-button-color);
}
.volume-slider[disabled]::-webkit-slider-runnable-track {
  background-color: var(--disabled-button-color);
}
.volume-slider[disabled]::-ms-track {
  background-color: var(--disabled-button-color);
}

Wrap Up

This tutorial covered creating a custom SoundCloud audio player clone using standard HTML, CSS, and Javascript. You can go further and customise it to your personal preference by reading through the official wavesurfer docs and expanding or building on it.

Previous Post

Add a back button to your Next.js app