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.