Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Simple low-latency audio playback in iOS Swift

I'm a beginner in iOS and I'm trying to design a drum set app with Swift. I designed a view with a single button and wrote the code below, but it has some problems:

  1. Some sounds are lost when I touch the button quickly like a drum roll.
  2. Still in a 'drum roll', the sound is interrupted every time the button is touched, instead of letting the sample play till its ending. It's awful in cymbal roll, for instance. I'd like to hear every sample sounding completely, even if I touch the button again.
  3. There is a latency between the touch and the sound. I know that AVAudioPlayer is not the best choice for low-latency audio, but as a beginner, it's hard to learn OpenAL, AudioUnit without code samples or tutorials in Swift. The problem is similar to this: Which framework should I use to play an audio file (WAV, MP3, AIFF) in iOS with low latency?.

The code:

override func viewDidLoad() {
    super.viewDidLoad()

    // Enable multiple touch for the button
    for v in view.subviews {
        if v.isKindOfClass(UIButton) {
            v.multipleTouchEnabled = true
        }
    }

    // Init audio
    audioURL = NSBundle.mainBundle().URLForResource("snareDrum", withExtension: "wav")!
    do {
        player = try AVAudioPlayer(contentsOfURL: audioURL)
        player?.prepareToPlay()
    } catch {
        print("AVAudioPlayer Error")
    }
}

override func viewDidDisappear(animated: Bool) {
    super.viewDidDisappear(animated)

    player?.stop()
    player = nil
}

@IBAction func playSound(sender: UIButton) {
    player?.currentTime = 0
    player?.play()
}
like image 935
msampaio Avatar asked Jan 08 '16 15:01

msampaio


2 Answers

If you need extremely low latency, I discovered a very simple solution available on the AVAudioSession singleton (which is instantiated automatically when an app launches):

First, get a reference to your app's AVAudioSession singleton using this class method:

(from the AVAudioSession Class Reference) :

Getting the Shared Audio Session

Declaration SWIFT

class func sharedInstance() -> AVAudioSession

Then, attempt to set the preferred IO buffer duration to something very short ( such as .002) using this instance method:

Sets the preferred audio I/O buffer duration, in seconds.

Declaration SWIFT

func setPreferredIOBufferDuration(_ duration: NSTimeInterval) throws

Parameters

duration The audio I/O buffer duration, in seconds, that you want to use.

outError On input, a pointer to an error object. If an error occurs, the pointer is set to an NSError object that describes the error. If you do not want error information, pass in nil. Return Value true if a request was successfully made, or false otherwise.

Discussion

This method requests a change to the I/O buffer duration. To determine whether the change takes effect, use the IOBufferDuration property. For details see Configuring the Audio Session.


Keep in mind the note directly above- whether the IOBufferDuration property is actually set to the value passed into the func setPrefferedIOBufferDuration(_ duration: NSTimeInterval) throws method, depends on the function not returning an error, and other factors that I am not completely clear about. Also- in my testing- I discovered that if you set this value to an extremely low value, the value (or something close to it) does indeed get set, but when playing a file (for instance using the AVAudioPlayerNode) the sound will not be played. No error, just no sound. This is obviously a problem. And I haven't discovered how to test for this issue, except by noticing a lack of sound hitting my ear while testing on an actual device. I'll look into it. But for now, I would recommend setting the preferred duration to no less than .002 or .0015. The value of .0015 seems to work for the iPad Air (Model A1474) I am testing on. While as low as .0012 seems to work well on my iPhone 6S.

And another thing to consider from a CPU overhead standpoint is the format of the audio file. Uncompressed formats will have a very low CPU overhead when played. Apple recommends that for the highest quality and lowest overhead you should use CAF files. For compressed files and the lowest overhead you should use IMA4 compression:

(From the iOS Multimedia Programming Guide) :

Preferred Audio Formats in iOS For uncompressed (highest quality) audio, use 16-bit, little endian, linear PCM audio data packaged in a CAF file. You can convert an audio file to this format in Mac OS X using the afconvert command-line tool, as shown here:

/usr/bin/afconvert -f caff -d LEI16 {INPUT} {OUTPUT}

For less memory usage when you need to play multiple sounds simultaneously, use IMA4 (IMA/ADPCM) compression. This reduces file size but entails minimal CPU impact during decompression. As with linear PCM data, package IMA4 data in a CAF file.

You can convert to IMA4 using afconvert as well:

/usr/bin/afconvert -f AIFC -d ima4 [file]

like image 94
nastynate13 Avatar answered Nov 09 '22 12:11

nastynate13


I spent an afternoon trying to solve this issue by playing around with AVAudioPlayer & AVAudioSession, but I couldn't get anywhere with it. (Setting the IO buffer duration as suggested by the accepted answer here didn't seem to help, unfortunately.) I also tried AudioToolbox, but I found that the resulting performance was pretty much the same - a clearly perceptible delay between the relevant user action and the audio.

After scouring the internet a bit more, I came across this:

www.rockhoppertech.com/blog/swift-avfoundation/

The section on AVAudioEngine turned out to be very useful. The code below is a slight reworking:

import UIKit
import AVFoundation

class ViewController: UIViewController {

var engine = AVAudioEngine()
var playerNode = AVAudioPlayerNode()
var mixerNode: AVAudioMixerNode?
var audioFile: AVAudioFile?

@IBOutlet var button: UIButton!

override func viewDidLoad() {
    super.viewDidLoad()

    engine.attach(playerNode)
    mixerNode = engine.mainMixerNode

    engine.connect(playerNode, to: mixerNode!, format: mixerNode!.outputFormat(forBus: 0))

    do {
        try engine.start()
    }

    catch let error {
        print("Error starting engine: \(error.localizedDescription)")
    }

    let url = Bundle.main.url(forResource: "click_04", withExtension: ".wav")

    do {
        try audioFile = AVAudioFile(forReading: url!)
    }

    catch let error {
        print("Error opening audio file: \(error.localizedDescription)")
    }
}

@IBAction func playSound(_ sender: Any) {

    engine.connect(playerNode, to: engine.mainMixerNode, format: audioFile?.processingFormat)
    playerNode.scheduleFile(audioFile!, at: nil, completionHandler: nil)

    if engine.isRunning{
        playerNode.play()
    } else {
        print ("engine not running")
    }
}

}

This might not be perfect as I'm a Swift newbie and haven't used AVAudioEngine before. It does seem to work, though!

like image 26
CKP78 Avatar answered Nov 09 '22 12:11

CKP78