Search code examples
iosswiftcore-audioaudioqueuecaf

A way to crop AudioFile while recording?


I'm writing a first in first out recording app that buffers up to 2.5 mins of audio using AudioQueue. I've got most of it figured out but I'm at a roadblock trying to crop audio data.

I've seen people do it with AVAssetExportSession but it seems like it wouldn't be performant to export a new track every time the AudioQueueInputCallback is called.

I'm not married to using AVAssestExportSession by any means if anyone has a better idea.

Here's where I'm doing my write and was hoping to execute the crop.

 var beforeSeconds = TimeInterval() // find the current estimated duration (not reliable)
    var propertySize = UInt32(MemoryLayout.size(ofValue: beforeSeconds))
    var osStatus = AudioFileGetProperty(audioRecorder.recordFile!, kAudioFilePropertyEstimatedDuration, &propertySize, &beforeSeconds)

    if numPackets > 0 {
      AudioFileWritePackets(audioRecorder.recordFile!, // write to disk
                                       false,
                                       buffer.mAudioDataByteSize,
                                       packetDescriptions,
                                       audioRecorder.recordPacket,
                                       &numPackets,
                                       buffer.mAudioData)
      audioRecorder.recordPacket += Int64(numPackets) // up the packet index

      var afterSeconds = TimeInterval() // find the after write estimated duration (not reliable)
      var propertySize = UInt32(MemoryLayout.size(ofValue: afterSeconds))
      var osStatus = AudioFileGetProperty(audioRecorder.recordFile!, kAudioFilePropertyEstimatedDuration, &propertySize, &afterSeconds)
      assert(osStatus == noErr, "couldn't get record time")

      if afterSeconds >= 150.0 {
        print("hit max buffer!")
        audioRecorder.onBufferMax?(afterSeconds - beforeSeconds)
      }
    }

Here's where the callback is executed

func onBufferMax(_ difference: Double){
    let asset = AVAsset(url: tempFilePath)
    let duration = CMTimeGetSeconds(asset.duration)
    guard duration >= 150.0 else { return }

    guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else {
      print("exporter init failed")
      return }


    exporter.outputURL = getDocumentsDirectory().appendingPathComponent("buffered.caf") // helper function that calls the FileManager
    exporter.outputFileType = AVFileTypeAppleM4A

    let startTime = CMTimeMake(Int64(difference), 1)
    let endTime = CMTimeMake(Int64(WYNDRConstants.maxTimeInterval + difference), 1)

    exporter.timeRange = CMTimeRangeFromTimeToTime(startTime, endTime)
    exporter.exportAsynchronously(completionHandler: {
      switch exporter.status {
      case .failed:
        print("failed to export")
      case .cancelled:
        print("canceled export")
      default:
        print("export successful")
      }
    })
  }

Solution

  • A ring buffer is a useful structure for storing, either in memory or on disk, the most recent n seconds of audio. Here is a simple solution that stores the audio in memory, presented in the traditional UIViewController format.

    N.B 2.5 minutes of 44.1kHz audio stored as floats requires about 26MB of RAM, which is on the heavy side for a mobile device.

    import AVFoundation
    
    class ViewController: UIViewController {
        let engine = AVAudioEngine()
    
        var requiredSamples: AVAudioFrameCount = 0
        var ringBuffer: [AVAudioPCMBuffer] = []
        var ringBufferSizeInSamples: AVAudioFrameCount = 0
    
        func startRecording() {
            let input = engine.inputNode!
    
            let bus = 0
            let inputFormat = input.inputFormat(forBus: bus)
    
            requiredSamples = AVAudioFrameCount(inputFormat.sampleRate * 2.5 * 60)
    
            input.installTap(onBus: bus, bufferSize: 512, format: inputFormat) { (buffer, time) -> Void in
                self.appendAudioBuffer(buffer)
            }
    
            try! engine.start()
        }
    
        func appendAudioBuffer(_ buffer: AVAudioPCMBuffer) {
            ringBuffer.append(buffer)
            ringBufferSizeInSamples += buffer.frameLength
    
            // throw away old buffers if ring buffer gets too large
            if let firstBuffer = ringBuffer.first {
                if ringBufferSizeInSamples - firstBuffer.frameLength >= requiredSamples {
                    ringBuffer.remove(at: 0)
                    ringBufferSizeInSamples -= firstBuffer.frameLength
                }
            }
        }
    
        func stopRecording() {
            engine.stop()
    
            let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("foo.m4a")
            let settings: [String : Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC)]
    
            // write ring buffer to file.
            let file = try! AVAudioFile(forWriting: url, settings: settings)
            for buffer in ringBuffer {
                try! file.write(from: buffer)
            }
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // example usage
            startRecording()
    
            DispatchQueue.main.asyncAfter(deadline: .now() + 4*60) {
                print("stopping")
                self.stopRecording()
            }
        }
    }