Search code examples
swiftxcodeaudioavfoundationpitch

Xcode 8 Swift 3 Pitch-altering sounds


I'm trying to make a simple game with a hit sound that has a different pitch whenever you hit something. I thought it'd be simple, but it ended up with a whole lot of stuff (most of which I completely copied from someone else):

func hitSound(value: Float) {  
 
    let audioPlayerNode = AVAudioPlayerNode()  
 
     audioPlayerNode.stop()  
     engine.stop() // This is an AVAudioEngine defined previously  
     engine.reset()  
 
     engine.attach(audioPlayerNode)  
 
     let changeAudioUnitTime = AVAudioUnitTimePitch()  
     changeAudioUnitTime.pitch = value  
 
     engine.attach(changeAudioUnitTime)  
     engine.connect(audioPlayerNode, to: changeAudioUnitTime, format: nil)  
     engine.connect(changeAudioUnitTime, to: engine.outputNode, format: nil)  
     audioPlayerNode.scheduleFile(file, at: nil, completionHandler: nil) // File is an AVAudioFile defined previously  
     try? engine.start()  
 
     audioPlayerNode.play()  
 }  

Since this code seems to stop playing any sounds currently being played in order to play the new sound, is there a way I can alter this behaviour so it doesn't stop playing anything? I tried removing the engine.stop and engine.reset bits, but this just crashes the app. Also, this code is incredibly slow when called frequently. Is there something I could do to speed it up? This hit sound is needed very frequently.


Solution

  • You're resetting the engine every time you play a sound! And you're creating extra player nodes - it's actually much simpler than that if you only want one instance of the pitch shifted sound playing at once:

    // instance variables
    let engine = AVAudioEngine()
    let audioPlayerNode = AVAudioPlayerNode()
    let changeAudioUnitTime = AVAudioUnitTimePitch()
    

    call setupAudioEngine() once:

    func setupAudioEngine() {
        engine.attach(self.audioPlayerNode)
    
        engine.attach(changeAudioUnitTime)
        engine.connect(audioPlayerNode, to: changeAudioUnitTime, format: nil)
        engine.connect(changeAudioUnitTime, to: engine.outputNode, format: nil)
        try? engine.start()
        audioPlayerNode.play()
    }
    

    and call hitSound() as many times as you like:

    func hitSound(value: Float) {
        changeAudioUnitTime.pitch = value
    
        audioPlayerNode.scheduleFile(file, at: nil, completionHandler: nil) // File is an AVAudioFile defined previously
    }
    

    p.s. pitch can be shifted two octaves up or down, for a range of 4 octaves, and lies in the numerical range of [-2400, 2400], having the unit "cents".

    p.p.s AVAudioUnitTimePitch is very cool technology. We definitely didn't have anything like it when I was a kid.

    UPDATE

    If you want multi channel, you can easily set up multiple player and pitch nodes, however you must choose the number of channels before you start the engine. Here's how you'd do two (it's easy to extend to n instances, and you'll probably want to choose your own method of choosing which channel to interrupt when all are playing):

    // instance variables
    let engine = AVAudioEngine()
    var nextPlayerIndex = 0
    let audioPlayers = [AVAudioPlayerNode(), AVAudioPlayerNode()]
    let pitchUnits = [AVAudioUnitTimePitch(), AVAudioUnitTimePitch()]
    
    func setupAudioEngine() {
        var i = 0
        for playerNode in audioPlayers {
            let pitchUnit = pitchUnits[i]
    
            engine.attach(playerNode)
            engine.attach(pitchUnit)
            engine.connect(playerNode, to: pitchUnit, format: nil)
            engine.connect(pitchUnit, to:engine.mainMixerNode, format: nil)
    
            i += 1
        }
    
        try? engine.start()
    
        for playerNode in audioPlayers {
            playerNode.play()
        }
    }
    
    func hitSound(value: Float) {
        let playerNode = audioPlayers[nextPlayerIndex]
        let pitchUnit = pitchUnits[nextPlayerIndex]
    
        pitchUnit.pitch = value
    
        // interrupt playing sound if you have to
        if playerNode.isPlaying {
            playerNode.stop()
            playerNode.play()
        }
    
        playerNode.scheduleFile(file, at: nil, completionHandler: nil) // File is an AVAudioFile defined previously
    
        nextPlayerIndex = (nextPlayerIndex + 1) % audioPlayers.count
    }