Using AVFoundation to Play Audio in an SKAction in SpriteKit

SpriteKit offers two ways to play audio:

  1. SKAction.playSoundFileNamed:waitForCompletion: for playing a sound once
  2. SKAudioNode (since iOS 9): for playing background music in a loop, or do other advanced audio stuff like positional audio (3D spatial audio effects).

The first option is quite unflexible. It just plays the sound once. You cannot change the volume or put any effects on them. SKAudioNode on the other hand offers a lot of cool features via SKActions like changing the playback rate and volume and adding live effects like reverb.

This seems to be a no-brainer. I tried it in my game Fusionate and there was a real bad whistling sound, although I didn’t use any effect or positional audio (just volume changes). So, back to the first option and create a lot of audio files by hand, in order to get the desired effect even for simple things like changing the volume?

No way…

I created my own actions using SKAction.runBlock:block, which use an ordinary AVAudioPlayer from AVFoundation to play the sound. The following code snippet shows how an action can be created that is used to play a swoosh sound once at the beginning of playing a level:

let apSwooshIn = initSoundPlayer("sounds/swoosh-in")
let swooshInAction = SKAction.runBlock {
    if !apSwooshIn.playing {
        apSwooshIn.play()
    }
}

The generic (not technically, but generic in that way, that I reuse it for all sounds I play in the game) implementation of initSoundPlayer:fileName:bgMusic
looks like this:

func initSoundPlayer(fileName: String, bgMusic: Bool = false) -> 
        AVAudioPlayer? {
    // This is the place where I store the game settings. They are loaded
    // from disk during initial game loading (and stored each time they are 
    // changed by the user).
    let volume = gameManager.settings.soundVolume

    // The type of `volume` is an enum (.Mute, .Low, .Medium, .High) with a
    // property storing the actual `Float` value.
    guard volume != .Mute else {
        return nil
    }

    guard let fileURL = NSBundle.mainBundle().URLForResource(fileName, withExtension: "wav") else {
        print("Could not load sound '\(fileName)'.")
        return nil
    }
    
    do {
        let aPlayer = try AVAudioPlayer(contentsOfURL: fileURL)
        
        aPlayer.prepareToPlay()
        let soundDelegate = SoundDelegate.sharedInstance
        aPlayer.delegate = soundDelegate
        
        var volume = Float(volume.value)

        // background music should not be as not as loud
        if !bgMusic {
            volume /= 3
            volume *= 2
        }
        aPlayer.volume = volume
        
        // bg music should play indefinetly.
        if bgMusic {
            aPlayer.numberOfLoops = -1
        }
        
        try soundDelegate.setSessionPlayer()
        
        return aPlayer
    }
    catch let e as NSError {
        print("Could not play sound '\(fileName)': \(e.localizedDescription)")
        return nil
    }
}

Yay. This is all you need… for one-time sounds. But what happens, if the sound should be played more than once? Although the action code will always be executed on the main thread, another action can trigger the very same sound concurrently. The !apSwooshIn.playing check will effectively swallow concurrent play the sound instructions. But for sounds that should run concurrently, I create just as many sounds as I “need” and do an if-cascade in the run block. This way, I constrain the maximum amount of concurrently playing sounds 🙂 :

if let apDeDemm = initSoundPlayer("sounds/dedemm"),
        let apDeDemm2 = initSoundPlayer("sounds/dedemm"),
        let apDeDemm3 = initSoundPlayer("sounds/dedemm") {
            userInteractionFailedSound = SKAction.runBlock {
                if !apDeDemm.playing {
                    apDeDemm.play()
                }
                else if !apDeDemm2.playing {
                    apDeDemm2.play()
                }
                else if !apDeDemm3.playing {
                    apDeDemm3.play()
                }
            }
    }
    else {
        userInteractionFailedSound = nil
    }

In the example above a dedemm sound occurs, if the user did something wrong. If he does this too fast (so that more than three sounds would run concurrently) I just do not start a new one until on of the others is finished.

If the whistling of SKAudioNode is resolved in iOS 10 or 11 or whatever I can use the corresponding macro to decide which execution path should be chosen:

if #available(iOS 10, *) {
    // use `SKAudioNode`
}
else {
    // use `AVFoundation`
}

The cool thing about actions in SpriteKit is, that you can reuse them and even use them concurrently on different nodes. I.e., you can do the initialization of your actions during scene loading. This is very handy and efficient.

Exciting 😉 .

Leave a Reply

Your email address will not be published. Required fields are marked *

Captcha * Time limit is exhausted. Please reload CAPTCHA.