5

I am using AVAudioEngine to both play and record audio. For my use case, I need to play a sound exactly when I start recording audio. Currently, my recording seems to start before the sound is played. How would I make the sound and the recording start at the same time? Ideally, I would like the recording to start and the sound to play at the same time instead of syncing them in post-processing.

Here is my code currently:

class Recorder {
  enum RecordingState {
    case recording, paused, stopped
  }
  
  private var engine: AVAudioEngine!
  private var mixerNode: AVAudioMixerNode!
  private var state: RecordingState = .stopped
    
    

  private var audioPlayer = AVAudioPlayerNode()
  
  init() {
    setupSession()
    setupEngine()
    
  }
    
    
  fileprivate func setupSession() {
      let session = AVAudioSession.sharedInstance()
    try? session.setCategory(.playAndRecord, options: [.mixWithOthers, .defaultToSpeaker])
      try? session.setActive(true, options: .notifyOthersOnDeactivation)
   }
    
    fileprivate func setupEngine() {
      engine = AVAudioEngine()
      mixerNode = AVAudioMixerNode()

      // Set volume to 0 to avoid audio feedback while recording.
      mixerNode.volume = 0

      engine.attach(mixerNode)

    engine.attach(audioPlayer)
        
      makeConnections()

      // Prepare the engine in advance, in order for the system to allocate the necessary resources.
      engine.prepare()
    }

    
    fileprivate func makeConnections() {
       
      let inputNode = engine.inputNode
      let inputFormat = inputNode.outputFormat(forBus: 0)
        print("Input Sample Rate: \(inputFormat.sampleRate)")
      engine.connect(inputNode, to: mixerNode, format: inputFormat)
      
      let mainMixerNode = engine.mainMixerNode
      let mixerFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: inputFormat.sampleRate, channels: 1, interleaved: false)
    
      engine.connect(mixerNode, to: mainMixerNode, format: mixerFormat)

      let path = Bundle.main.path(forResource: "effect1.wav", ofType:nil)!
      let url = URL(fileURLWithPath: path)
      let file = try! AVAudioFile(forReading: url)
      audioPlayer.scheduleFile(file, at: nil)
      engine.connect(audioPlayer, to: mainMixerNode, format: nil)
        
        }
    
    
    //MARK: Start Recording Function
    func startRecording() throws {
        print("Start Recording!")
      let tapNode: AVAudioNode = mixerNode
      let format = tapNode.outputFormat(forBus: 0)

      let documentURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        
      // AVAudioFile uses the Core Audio Format (CAF) to write to disk.
      // So we're using the caf file extension.
        let file = try AVAudioFile(forWriting: documentURL.appendingPathComponent("recording.caf"), settings: format.settings)
       
      tapNode.installTap(onBus: 0, bufferSize: 4096, format: format, block: {
        (buffer, time) in

        try? file.write(from: buffer)
        print(buffer.description)
        print(buffer.stride)
       
        //Do Stuff
        print("Doing Stuff")
      })
    
      
      try engine.start()
        audioPlayer.play()
      state = .recording
    }
    
    
    //MARK: Other recording functions
    func resumeRecording() throws {
      try engine.start()
      state = .recording
    }

    func pauseRecording() {
      engine.pause()
      state = .paused
    }

    func stopRecording() {
      // Remove existing taps on nodes
      mixerNode.removeTap(onBus: 0)
      
      engine.stop()
      state = .stopped
    }
    

    
    
}
coder
  • 283
  • 1
  • 16
  • Unfortunately (and perhaps surprisingly) I don't think there is an accurate way to determine latency without measuring it – sbooth Apr 06 '21 at 14:25
  • @sbooth How would I measure latency? – coder Apr 06 '21 at 14:35
  • The ideal way is with a loopback cable from a device's output to its input. You send a test signal to the output, recording the time at which it is sent, and then detect the same signal in the input. The difference in times is the round-trip latency. There are ways to estimate with `AVAudioEngine` but in my experiments none are sample-accurate. – sbooth Apr 06 '21 at 14:39
  • Hmmm...I could do this, but what happens if the user lowers the volume? This would make it hard to detect the output signal. If you have a way to estimate round-trip latency with `AVAudioEngine` please share them as an answer. An estimate is better than nothing! – coder Apr 07 '21 at 02:44
  • There is some discussion at https://stackoverflow.com/questions/65600996/avaudioengine-reconcile-sync-input-output-timestamps-on-macos-ios/ – sbooth Apr 08 '21 at 17:31

1 Answers1

0

Have you tried to start the player before install the tap?

// Stop the player to be sure the engine.start calls the prepare function
audioPlayer.stop()
try engine.start()
audioPlayer.play()
state = .recording

tapNode.installTap(onBus: 0, bufferSize: 4096, format: format, block: {
        (buffer, time) in
        try? file.write(from: buffer)
      })

If, in this case, your recording is a bit after, maybe try to compensate by using the player.outputPresentationLatency. According to the doc, this is a maximum value. Meaning time could be slightly inferior. I hope it desserves a try.

print(player.outputPresentationLatency)
// 0.009999999776482582

let nanoseconds = Int(player.outputPresentationLatency * pow(10,9))
let dispatchTimeInterval = DispatchTimeInterval.nanoseconds(nanoseconds)
            
player.play()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + dispatchTimeInterval) {
    self.installTap()
    self.state = .recording
}
Moose
  • 2,095
  • 18
  • 21
  • When I try to implement your solution I get an error `'Value of type 'AVAudioPlayerNode' has no member 'prepareToPlay'`. Remember that `audioPlayer` has a type of `AVAudioPlayerNode`. – coder Apr 06 '21 at 00:11
  • Also the lag must be as little as possible, since I may be using the number of samples to calculate the time at which certain sounds or patterns occur in my audio. – coder Apr 06 '21 at 00:16
  • Oops. Sorry. AudioEngine.prepare does the same thing. I correct my answer. I'll try few test before answer, because it is strange that the prepare function is not enough. Maybe use callbacks – Moose Apr 06 '21 at 07:32
  • I have edited my answer ( after a coffee ) . I would be very interested by the result, because I am working on an app that will probably need this feature and accuracy. Unfortunately, I don't have time to push the experiments right now. Please keep me updated. ;) – Moose Apr 06 '21 at 09:07
  • I have been quite busy for the past few days so I haven't been able to do much testing. However, my initial findings suggest that starting the player before the tap does very little, if anything. I am pretty sure I tried this before. I had to remove the `audioPlayer.stop()` line to get the sound to play for some reason. If you know why it is doing this please let me know. As for the latency, I haven't looked into that much as of yet, but initial testing suggests that the `outputPresentationLatency` isn't the sole contributor to the problem. I will be back with more soon :). – coder Apr 07 '21 at 02:41