Skirting the iOS/Safari audio auto-play policy for UI sound effects

The physical Beat the Street game had loads of funny sound effects when you tapped a box. So I wanted my virtual clone of it – Zap the Map – to have them too.

This article explains a difficulty I had with this because of the Safari browser’s auto-play policies and a workaround, whilst also detailing a slight mystery.

Playing sound in a browser

Theoretically playing a sound in a browser is pretty simple. You create an audio element with the src attribute set to the URL of your sound, and then when you want to play it, you get the element in your JavaScript and you call the play() method:

<audio src="https://example.com/assets/sounds/bang.mp3"></audio>

<script>
  const sound = document.querySelector('audio');
  sound.play();
</script>

Note that audio elements are not displayed by default so the <audio> is hidden from view.

The problem with playing a sound using JavaScript is that browsers have auto-play policies that demand that a user interaction happen before you can play the sound. So a button click should work fine:

<audio src="https://example.com/assets/sounds/bang.mp3"></audio> 

<button>Play Sound</button>

<script>
  const sound = document.querySelector('audio');
  const button = document.querySelector('button');
  button.addEventListener('click', e => sound.play());
</script>

Note here that sound.play() actually returns a Promise which is resolved when playback starts.

Playing sounds in Vue.js instance methods

My example that I had problems with was a bit more complex than this. I’m running inside a Vue.js for a start and the click handler was a method on the application instance:

<button @click="tap">Zap!</button>

<script>
  var app = new Vue({
    el: '#app',
    methods: {
      tap: function () {
        response = axios.post('/api/v1/tap', {
          // Some data,
        }).then( e => {  
          // Use arrow function to avoid binding this
          if (response.data.points > 0) {
            this.playSound();
          }
        });
      },
      playSound: function() {
        let sound = document.querySelector('audio');
        sound.play();
      }
    }
  });
</script>

Now, this worked fine in Chrome on my desktop, and in Firefox too. And after later testing it seemed to work fine on Chrome in Android as well.

But in Safari, this sound was not playing.

I’ve produced a fairly minimal test case that reproduces this here: https://acoustic-snow.glitch.me/

In constructing this case I found that if Vue.js wasn’t involved everything worked fine everywhere. So there’s something in Vue that causes some kind of interruption to the call stack when you execute the promise that Safari doesn’t understand.

The Fix!

The good news is that I have a hacky fix.

As I explored the developer docs and StackOverflow trying to resolve this issue, I read somewhere, in passing, that once you’ve played the sound once on a user interaction, you can then play it again without further interaction.

So, I came up with the idea of setting the volume to zero and playing the sound in the click handler before the async operation, to “initialise” it, and then, once my async operation is complete, put the volume back up to 1 and play it again.

This worked! Hooray!

<button @click="tap">Zap!</button>

<script>
  var app = new Vue({
    el: '#app',
    methods: {
      tap: function () {
        this.loadSound();
        response = axios.post('/api/v1/tap', {
          // Some data,
        }).then( response => {
          // Use arrow function to avoid binding this
          if (response.data.points > 0) {
            this.playSound();
          }
        });
      },
      loadSound: function() {
        let sound = document.querySelector('audio');
        sound.volume = 0;
        sound.play();
      },
      playSound: function() {
        let sound = document.querySelector('audio');
        sound.volume = 1;
        sound.play();
      }
    }
  });
</script>


A “fun” little mobile Safari bug?

To add to my fun, this application is a walking-around game. So I took it out testing it on my iPhone, and when I did I found that the sound played when it shouldn’t have!

After a bit more digging I found what I can only assume is a proper Mobile Safari bug!

Aside: did you know if you have an iPhone and a Mac you can plug one into the other and use the developer tools in the desktop browser to inspect what is going on on the mobile device? Well if you didn’t, you do now!)

So, this whole sound.volume = 0 thing – this didn’t work in my test case scenario with Vue.js involved. It seemed that sound.volume was read-only. And you can see this in the sample test case when you click the bottom button on mobile Safari.

Console screengrab showing sound.volume assignment not working
Setting sound.volume has no effect on mobile Safari

Outside of my test case, and in desktop Safari, it’s fine, of course!

Console screengrab showing sound.volume assignment working
But it’s fine on desktop Safari!

Fortunately, once I’d tracked this down, I discovered that sound.muted worked fine. So now, I just set both. I won’t post code for that but you can see it in the sample test case.

Summing up!

  • In some odd cases that seem to involve Vue.js (in my case at least) and asynchronous operations, Safari’s auto-playing media policies seem to prevent playing sounds, when playing them should be OK and when other browsers are fine with playing them.
  • There is a workaround that involves playing the sound in muted form before the async operation happens.
  • There is a weird bug in Mobile Safari, I think, that prevents HTTPMediaElement.volume being updated, that almost certainly no one will ever come across.
  • Playing sounds in web browsers SEEMS easy, but it turns out not to be!