Fading audio with AVPlayer

AVPlayer doesn’t provide a built-in way to fade in or out. I previously described how you achieve a video fade-in (or out) using general CoreAnimation layer animation, as part of making a macOS screen saver. Now let’s tackle the audio.

extension AVPlayer {
    func fadeAudio(from startVolume: Float, to endVolume: Float, duration: Double) {
        let audioMix = AVMutableAudioMix()

        audioMix.inputParameters = (player.currentItem?.tracks ?? [])
                                    .compactMap(\.assetTrack)
                                    .filter({ $0.mediaType == .audio })
                                    .map { track in
            let currentTime = player.currentTime()

            let parameters = AVMutableAudioMixInputParameters(track: track)

            parameters.setVolumeRamp(fromStartVolume: startVolume,
                                     toEndVolume: endVolume,
                                     timeRange: CMTimeRange(start: currentTime,
                                                            duration: CMTime(seconds: duration,
                                                                             preferredTimescale: currentTime.timeScale)))

            return parameters
        }

        player.currentItem?.audioMix = audioMix
    }
}

I’m not certain what curve this implements, but to my ears it doesn’t sound quite as harsh as a naive linear ramp, so perhaps it’s an S-curve or similar.

Edge cases not handled

Where there’s less than duration time left in the track(s).

How you want to handle that might vary depending on context. e.g. you could clamp the duration to the remaining duration (but you have to think about whether your individual tracks all have the same duration, whether they match the duration of the overall playback item, and whether they’re all aligned within the playback sequence), or wrap it around to the beginning (if you’re looping), or carry the fade through to the next item (if you’re playing a sequence of items), etc. Alas this must be left as an exercise to you, the reader.

Starting a fade while another one is still in progress.

It will halt the previous fade, immediately jump to startVolume, and perform the new fade. If you know that a prior fade is in progress you could potentially extrapolate the current volume and start from there instead (though beware of non-linear ramping).

If you move the playhead backwards (e.g. skimming, or looped playback).

The mix will stay in place, and result in wonky volume levels on the subsequent plays through. To work around that, you can add:

player.currentItem?.audioMix = nil
player.volume = endVolume

…wherever you restart playback at the beginning or move playback earlier than the end of the fade.

Future work?

You can work around these limitations by doing a timer-based fade (i.e. player.volume += smallIncrement at regular, short intervals). However, the problem with that approach is that it’s not synchronised to actual playback – e.g. if the audio is paused, stutters, or faces an initial loading delay, your fade won’t wait for it, potentially resulting in no fade at all (e.g. it takes five seconds to buffer the audio before playback starts, at which point your five second “fade” has run to completion, so your audio starts playing abruptly at full volume).

There’s very likely a third option that addresses all these shortcomings, but I explored that a bit and concluded that it’d be a lot more work. If someone wants to explore that all the way, I’d be interested to see the result. But for many purposes the above code is quite sufficient.

Leave a Comment