iosswiftavfoundationavaudioengineavaudiofile

AVAudioFile init works on simulator but crashes on device


I want to build an iOS app (using Swift 3 and Xcode 8.2.1) that records from the microphone of an iPhone and saves the record to a .caf file.


First, I've added the following lines to the Info.plist file of my project:

<key>NSMicrophoneUsageDescription</key>
<string>Some description to explain why access is required</string>

Then, I've created a simple UIViewController that sets a AVAudioEngine and install/remove an audio tap when tapping on Record and Stop buttons.

ViewController.swift

import UIKit
import AVFoundation

class ViewController: UIViewController {

    var engine = AVAudioEngine()
    var file: AVAudioFile?
    var player = AVAudioPlayerNode()

    override func viewDidLoad() {
        super.viewDidLoad()

        guard let url = urlFor(filename: "test.caf") else { return }
        do {
            file = try AVAudioFile(forWriting: url, settings: engine.inputNode!.inputFormat(forBus: 0).settings, commonFormat: engine.inputNode!.inputFormat(forBus: 0).commonFormat, interleaved: false)
        } catch {
            print("Error: \(error)")
        }

        engine.attach(player)
        engine.connect(player, to: engine.mainMixerNode, format: engine.mainMixerNode.outputFormat(forBus: 0))

        do {
            try engine.start()
        } catch {
            print("Error: \(error)")
        }
    }

    @IBAction func record(sender: AnyObject) {
        engine.inputNode?.installTap(onBus: 0, bufferSize: 1024, format: engine.mainMixerNode.outputFormat(forBus: 0)) { (buffer, time) -> Void in
            do {
                try self.file?.write(from: buffer)
            } catch {
                print("Error: \(error)")
            }
        }
    }

    @IBAction func stop(sender: AnyObject) {
        engine.inputNode?.removeTap(onBus: 0)
        // Print the url of the saved file
        let urls = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
        print(urls[urls.endIndex - 1])
    }

    func urlFor(filename: String) -> URL? {
        // Get url of saved file
        if let dir = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.allDomainsMask, true).first {
            let path = dir.appending(filename)
            return URL(fileURLWithPath: path)
        }
        return nil
    }

}

The app works fine on the simulator: I can record my voice, grab the url of the recorded file and test that my voice has correctly been recorded. However, the app crashes as soon as I launch it on a device (tested with an iPhone 6s on iOS 10.2.1). The console gives me the following logs:

2017-02-08 17:09:14.330151 AudioEngineSimpleApp[7100:2659416] Audio files cannot be non-interleaved. Ignoring setting AVLinearPCMIsNonInterleaved YES.
2017-02-08 17:09:14.333715 AudioEngineSimpleApp[7100:2659416] [central] 54:   ERROR:    [0x1b7440c40] >avae> AVAudioFile.mm:160: AVAudioFileImpl: error -54
2017-02-08 17:09:14.333796 AudioEngineSimpleApp[7100:2659416] [central] 54:   ERROR:    [0x1b7440c40] >avae> AVAudioFile.mm:122: ReadMagicCookie: error -50
2017-02-08 17:09:14.334369 AudioEngineSimpleApp[7100:2659416] *** Terminating app due to uncaught exception 'com.apple.coreaudio.avfaudio', reason: 'error -50'

This problem seems to be related to the Stack Overflow thread AVAudioPlayer working on Simulator but not on Real Device but I can't figure how to implement its response into my own code in order to solve my problem.


Solution

  • I've refactored my code and have replaced my old urlFor(filename: String) -> URL? method with the following url property:

    let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("test.caf")
    

    As a result, the following code works properly on device:

    import UIKit
    import AVFoundation
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var recordButton: UIButton!
        let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("test.caf")
        //let url = URL(fileURLWithPath: NSTemporaryDirectory().appending("test.caf")) also works
        var engine = AVAudioEngine()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            recordButton.setTitle("Record", for: .normal)
    
            let file: AVAudioFile
            do {
                file = try AVAudioFile(forWriting: url, settings: engine.inputNode!.outputFormat(forBus: 0).settings)
            } catch {
                print("Error: \(error)")
                return
            }
    
            engine.inputNode?.installTap(onBus: 0, bufferSize: 1024, format: engine.inputNode!.outputFormat(forBus: 0)) { (buffer, time) -> Void in
                do {
                    try file.write(from: buffer)
                } catch {
                    print("Error: \(error)")
                }
            }
        }
    
        @IBAction func toggleAction(_ sender: AnyObject) {
            engine.isRunning ? stopRecording() : startRecording()
        }
    
        func startRecording() {
            do {
                // Start engine
                try engine.start()
    
                // Toggle button title
                recordButton.setTitle("Stop", for: .normal)
            } catch {
                print("Error: \(error)")
            }
        }
    
        func stopRecording() {
            // Stop engine
            engine.stop()
    
            // Toggle button title
            recordButton.setTitle("Record", for: .normal)
    
            print(url)
        }
    
    }