Building an Audio-loop Player on the Web ๐Ÿ”‰โ™ป๏ธ

Published on
ยท
Time to read
7 min read โ˜•
Post category
tech

In July, I built Tranquil, a very simple web-app that allows you to create your own mix of environmental sounds. I have always loved the sounds of nature such as the rain ๐ŸŒง๏ธ , the blowing wind ๐ŸŽ , the sound of waves hitting the shore ๐Ÿ–๏ธ , etc.

I thought the project would be super simple. I should probably be able to just put some <audio> elements with loop="true" on them, right? Well, as it turned out, it wasn't that straightforward.

The <audio> loop gap

Typically, this is how we would use an <audio> element.

<audio src="https://tranquil.vercel.app/audio/rain2.wav" loop controls />

I added some custom UI for the controls below. Try listening to it!

Raining sound (loop)
0:00 / 0:00

Did you notice the problem?

Most likely, you heard that the audio stopped abruptly before starting again. This gap makes the audio loop very annoying to listen to. It is almost like the experience you get when you watch a YouTube video, and then it buffers for 0.5 seconds before continuing.

This is an inherent limitation of the native <audio> element. A quick search for "seamless audio loop html" should show people struggling with this issue as well. Unfortunately, as far as I know, there is no way to make it work using the native <audio> element.

The Web Audio API

The Web Audio API allows developers to do all sorts of audio processing on the web. Developers can choose audio sources, add effects to audio, create audio visualizations, apply spatial effects (such as panning), and much more. One thing that we care about is that it is able to play audio loops without the annoying gap that we have in <audio> element.

Playing audio file

Compared to the <audio> element, the Web Audio API might feel quite a bit more complicated. To get started, we need to create a new AudioContext.

const audioCtx = new window.AudioContext();

Next, we would need to create an AudioBufferSourceNode for the AudioContext.

const source = audioCtx.createBufferSource();

The 2 lines of code we have so far are not really doing anything yet. Next, we need to load the audio file and assign it to the AudioBufferSourceNode we just created. We can load the audio file using a fetch() call and retrieve the value as an ArrayBuffer.

const arrayBuffer = await fetch(
  'https://tranquil.vercel.app/audio/rain2.wav',
).then((res) => res.arrayBuffer());

The AudioBufferSourceNode instance does not accept an ArrayBuffer though, it needs (you guessed it) an AudioBuffer instead. Fortunately, it is easy to convert the ArrayBuffer to an AudioBuffer.

// Convert ArrayBuffer to an AudioBuffer
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);

Now that we have the AudioBuffer, we can assign it to our AudioBufferSourceNode. While we are at it, we can also set the audio to loop forever.

source.buffer = audioBuffer;
source.loop = true;

At this point, we can connect the source node to AudioContext's destination. Then we can start playing the audio file.

source.connect(audioCtx.destination);
source.start();

The Web Audio API works with nodes. Nodes are the building blocks of the audio processing pipeline.

For example, we can add a GainNode to increase the volume of the audio. If you are familiar with Audio Editor programs, you've probably heard of "Gain" before.

const gainNode = audioCtx.createGain(); // create a GainNode

gainNode.gain.value = 0.5; // set the gain to 50%
source.connect(gainNode); // connect the audio source to the GainNode
gainNode.connect(audioCtx.destination); // connect the GainNode to the AudioContext destination

The MDN docs page explains this in more depth. We don't need them for our use case, but it is useful to know how powerful the Web Audio API is. You can probably build a pretty complex audio processing web-app using them!

Compare it with this improved version, see if you can notice the difference!

Raining sound (loop)
0:00 / 0:00

Playing the audio file with the Web Audio API allows us to work around the issue of the <audio> element, we are now able to have a gapless audio loop!

For easy access, here is the complete code of what we have so far. You can copy them to your browser console to try it out!

const playAudioWithWebAudioAPI = async () => {
  const audioCtx = new window.AudioContext();
  const source = audioCtx.createBufferSource();
  const arrayBuffer = await fetch(
    'https://tranquil.vercel.app/audio/rain2.wav',
  ).then((res) => res.arrayBuffer());
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);

  source.buffer = audioBuffer;
  source.loop = true;
  source.connect(audioCtx.destination);
  source.start();
};

Web Audio API vs. <audio> element

Though powerful, the Web Audio API isn't without its own sets of caveats. Observant readers might have noticed that before we were able to play an audio file with the Web Audio API, we needed to download the whole audio file beforehand! This is a very noticeable issue, especially with bigger audio files. The <audio> element, on the other hand, does not have the same issue. The <audio> element comes with built-in streaming capabilities, allowing it to play chunks of the audio as it downloads them.

This makes sense though. It would be pretty hard to do audio processing before having all of the audio chunks ready in memory, which is what the Web Audio API does. In most cases, for playing audio files, the <audio> element is still preferred unless there is a specific use case that requires the Web Audio API (like we do here!).

The MediaSession API

The MediaSession API is an experimental Web API that allows our web-app to integrate with the operating system in a nicer way. For instance, when Tranquil is running on an Android device, it will show up in the notification tray. Users can play/pause Tranquil from there.

MediaSession API on Android device

For comparison, here is how it looks on a Mac device.

MediaSession API on Mac device

The implementation is pretty simple. We just need to initialize a media session by setting metadata and some action handlers. In React apps, we can do this in a useEffect() hook.

useEffect(() => {
  if ('mediaSession' in navigator) {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: 'Environmental sounds',
      artist: 'Tranquil',
      album: '',
      artwork: [
        {
          src: '/images/rain.jpeg',
          sizes: '951x634', // HeightxWidth
          type: 'image/jpeg',
        },
      ],
    });

    navigator.mediaSession.setActionHandler('play', () => play());
    navigator.mediaSession.setActionHandler('pause', () => pause());
  }
}, [play, pause]);

Unfortunately, the media controls will not be shown unless there is a playing <audio> element in the page. Since Tranquil plays audio file using the Web Audio API, I had to add a blank <audio> element to the page.

// Audio file from: https://github.com/anars/blank-audio
<audio ref={dummyAudioElementRef} src="/audio/15-seconds-of-silence.mp3" />

To make sure the blank audio isn't interfering with the environmental sounds Tranquil is playing, I set its volume to 0. We also want to set the <audio> to loop because the environmental sounds are also looping.

useEffect(() => {
  if (dummyAudioElementRef.current) {
    dummyAudioElementRef.current.volume = 0;
    dummyAudioElementRef.current.loop = true;
  }
}, []);

Finally, we need to play/pause the <audio> element when the user interacts with the media controls from the MediaSession API.

const [playStatus, setPlayStatus] = useState<'PLAYING' | 'STOPPED'>('STOPPED');

useEffect(() => {
  if ('mediaSession' in navigator) {
    if (playStatus === 'PLAYING') {
      navigator.mediaSession.playbackState = 'playing';
      dummyAudioElementRef.current?.play();
    } else {
      navigator.mediaSession.playbackState = 'paused';
      dummyAudioElementRef.current?.pause();
    }
  }
}, [playStatus]);

With that, Tranquil is now fully controllable via the media controls that are integrated nicely with the underlying operating system! โœจ


Closing

The Web Audio API in particular is still a mysterious thing for me. If I hadn't built Tranquil, I wouldn't have even known about them. Though, it is pretty rad to see that the web platform has such powerful APIs! ๐Ÿ”ฅ

All in all, building Tranquil was pretty fun as it provided me with an opportunity to play with Web APIs I otherwise wouldn't be playing with.

Looking for code for Tranquil?

If you are interested in looking at the code for Tranquil, you can find it on GitHub.