Building an Audio-loop Player on the Web ๐โป๏ธ
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)
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)
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.
For comparison, here is how it looks on a 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.
If you are interested in looking at the code for Tranquil, you can find it on GitHub.