Web audio volume fade for iOS 13+ devices

Viewed 464

I hope somebody here in the Stack Overflow community will be able to provide a workaround for this. It's been a constant thorn in my side for a long time now.

My requirement is pretty simple: fade-in the volume for audio playback on a page.

There are a few ways to do this with javascript:

  1. Update the "volume" property on an HTMLAudioElement. This does not work at all for iOS devices because the property is read-only for the OS.
  2. Use Web Audio API AudioContext.createMediaElementSource() with GainNode. This currently works, however the sound output will often be distorted - cracks and pops will ruin the experience for any streamed audio. This is not caused by using a different sampling rate with the AudioContext.
  3. Use Web Audio API AudioBuffer with the file downloaded via XMLHttpRequest with arraybuffer response type. This seems to result in no volume at all. It works with the simulator, but not on physical devices.

All three approaches work on practically any browser on any device excluding modern iOS devices. iOS versions prior to 13 work fine for approach 2 or 3. I don't mean to go all conspiracy theorist, but could apple be intentionally breaking PWA support to funnel more revenue to their app store?

1 Answers

TL;DR all you need is in this snippet (hopefully)

I have this snippet lying around, you can try it and tell me if that works for you:

// input
const FADE_IN_DURATION = 10
const url = '/path/to/audio.mp3'

// setup
const context = /** @type {AudioContext} */(new (window.AudioContext || window.webkitAudioContext)())
const gainNode = context.createGain()
gainNode.connect(context.destination)
gainNode.gain.value = 0

// fetch
const response = await fetch(url)
const audioData = await response.arrayBuffer()
const buffer = await new Promise((resolve, reject) => {
    context.decodeAudioData(audioData, resolve, reject)
})

// connect
const source = context.createBufferSource()
source.buffer = buffer
source.connect(gainNode)

// play
const startTime = context.currentTime
gainNode.gain.linearRampToValueAtTime(1, startTime + FADE_IN_DURATION)
source.start(startTime)

The key points

  • the GainNode returned by context.createGain() gives you access to the "gain" AudioParam. And AudioParam has a method called linearRampToValueAtTime that does exactly what you are after
  • a gain value above 1 will create some "distorsion" because the "volume peaks" start to go over the maximum possible value. So if you just want a smooth fade-in, stay between 0 and 1.
  • The "wiring" I'm doing is the very basic AudioBufferSourceNode --> GainNode --> AudioContext

Actual working script

The snippet above doesn't really work out of the box because you need

  • a function to handle async/await,
  • and a user input to start playing an audio context). So here it is with all of the proper wrappings:

// input
const FADE_IN_DURATION = 10
const url = 'https://upload.wikimedia.org/wikipedia/commons/d/de/Lorem_ipsum.ogg'

// setup
const context = /** @type {AudioContext} */ (new(window.AudioContext || window.webkitAudioContext)())
const gainNode = context.createGain()
gainNode.connect(context.destination)
gainNode.gain.value = 0

// fetch
;(async function() {
  const response = await fetch(url)
  const audioData = await response.arrayBuffer()
  const buffer = await new Promise((resolve, reject) => {
    context.decodeAudioData(audioData, resolve, reject)
  })

  // user input necessary
  document.getElementById('text').textContent = "Ready. Click anywhere to start audio."
  document.addEventListener('click', () => {
    document.getElementById('text').textContent = "Now playing..."

    // connect
    const source = context.createBufferSource()
    source.buffer = buffer
    source.connect(gainNode)

    // play
    const startTime = context.currentTime
    gainNode.gain.linearRampToValueAtTime(1, startTime + FADE_IN_DURATION)
    source.start(startTime)
  }, {
    once: true
  })
})()
<div id="text">Loading file...</div>

Related