AVAudioEngine multiple AVAudioInputNodes do not play in perfect sync AVAudioEngine multiple AVAudioInputNodes do not play in perfect sync objective-c objective-c

AVAudioEngine multiple AVAudioInputNodes do not play in perfect sync


Problem:

Well, the problem is that you retrieve your player.lastRenderTime in every run of the for-loop before playAt:

So, you'll actually get a different now-time for every player!

The way you do it you might as well start all player in the loop with play: or playAtTime:nil !!! You would experience the same result with a loss of sync...

For the same reason your player run out-of-sync in different ways on different devices, depending on the speed of the machine ;-) Your now-times are random magic numbers - so, don't assume they will always work if they just happen to work in your scenario. Even the smallest delay because of a busy run loop or CPU will throw you out-of-sync again...

Solution:

What you really have to do is to get ONE discrete snapshot of now = player.lastRenderTime before the loop and use this very same anchor in order to get a batched synchronized start for all your player.

This way you do not even need to delay your player's start. Admittedly, the system will clip some of the leading frames - (but of course the same amount for every player ;-) - to compensate for the difference between your recently set now (which is actually already in the past and gone) and the actual playTime (which still lies ahead in the very near future) but eventually start all your player exactly in-sync as if you actually had really started them at now in the past. These clipped frames are almost never noticeable and you'll have peace of mind regarding to responsiveness...

If you happen to need these frames - because of audible clicks or artifacts at file/segment/buffer start - well, shift your now to the future by starting your player delayed. But of course you'll get this little lag after hitting the start button - although of course still in perfect sync...

Conclusion:

The point here is to have one single reference now-time for all player and to call your playAtTime:now methods as soon as possible after capturing this now-reference. The bigger the gap the bigger the portion of clipped leading frames will be - unless you provide a reasonable start-delay and add it to your now-time, which of course causes unresponsiveness in form of a delayed start after hitting your start button.

And always be aware of the fact that - whatever delay on whatever device is produced by the audio buffering mechanisms - it DOESN'T effect the synchronicity of any amount of player if done in the proper, above described way! It DOESN'T delay your audio, either! Just the window that actually lets you hear your audio gets opened at a later point in time...


Be advised that:

  • If you go for the un-delayed (super-responsive) start option andfor whatever reason happen to produce a big delay (between thecapturing of now and the actual start of your player), you willclip-off a big leading portion (up to about ~300ms/0.3sec) of youraudio. This means when you start your player it will start right awaybut not resume from the position you recently paused it but rather(up to ~300ms) later in your audio. So the acoustic perception is thatpause-play cuts out a portion of your audio on the go although everything is perfectly in-sync.
  • As the start delay that you provide in the playAtTime:now +myProvidedDelay method call is a fixed constant value (that doesn't getdynamically adjusted to accommodate buffering delay or other varyingparameters at heavy system load) even going for the Delayed Optionwith a provided delay time smaller than about ~300ms can cause aclipping of leading audio-samples if the device-dependent preparationtime exceeds your provided delay time.
  • The maximum amount of clipping does (by design) not get bigger thanthese ~300ms. To get prove just force a controlled (sample-accurate) clippingof leading frames by e.g. adding a negative delay-time to nowand you will perceive a growing clipped audio-portion by augmenting thisnegative value. Every negative value that is bigger then ~300ms getsrectified to ~300ms. So a provided negative delay of 30 seconds willlead to the same behavior as a negative value of 10, 6, 3 or 1seconds, and of course also including negative 0.8, 0.5 seconds downto ~0.3

This examples serves well for demonstration purposes but negative delay values shouldn't be used in production code.


ATTENTION:

The most important thing of all in a multi-player setup is to keep your player.pause in sync. There is still no synchronized exit strategy in AVAudioPlayerNode as of June 2016.

Just a little method look-up or logging out something to the console in-between two player.pause calls could force the latter one to be executed one or even more frame/sample(s) later than the former one. So your player wouldn't actually stop at the same relative position in time. And above all - different devices would yield different behavior...

If you now start them in the above mentioned (sync'ed) manner, these out-of-sync current player positions of your last pause will definitely get force-sync'ed to your new now-position at every playAtTime: - which essentially means that you are propagating the lost sample/frame(s) into the future with every new start of your player. This of course adds up with every new start/pause cycle and widens the gap. Do this fifty or hundred times and you already get a nice delay effect without using an effect-audio-unit ;-)

As we don't have any (by the system provided) control over this factor the only remedy is to put all calls to player.pause straight one after the other in a tight sequence without anything in-between them, like you can see in the examples below. Don't throw them in a for-loop or anything similar - this would be a guaranty for ending up out-of-sync at the next pause/start of your player...

Whether keeping these calls together is a 100% perfect solution or the run-loop under any big CPU load could by chance interfere and force-separate the pause calls from each other and cause frame drops - I don't know - at least in weeks messing around with the AVAudioNode API I could in no way force my multi-player-set to get out-of-sync - however, I still don't feel very comfy or safe with this un-synchronized, random-magic-number pause solution...


Code-example and alternative:

If your engine is already running you got a @property lastRenderTime in AVAudioNode - your player's superclass - This is your ticket to 100% sample-frame accurate sync...

AVAudioFormat *outputFormat = [playerA outputFormatForBus:0];const float kStartDelayTime = 0.0; // seconds - in case you wanna delay the startAVAudioFramePosition now = playerA.lastRenderTime.sampleTime;AVAudioTime *startTime = [AVAudioTime timeWithSampleTime:(now + (kStartDelayTime * outputFormat.sampleRate)) atRate:outputFormat.sampleRate];[playerA playAtTime: startTime];[playerB playAtTime: startTime];[playerC playAtTime: startTime];[playerD playAtTime: startTime];[player...

By the way - you can achieve the same 100% sample-frame accurate result with the AVAudioPlayer/AVAudioRecorder classes...

NSTimeInterval startDelayTime = 0.0; // seconds - in case you wanna delay the startNSTimeInterval now = playerA.deviceCurrentTime;NSTimeIntervall startTime = now + startDelayTime;[playerA playAtTime: startTime];[playerB playAtTime: startTime];[playerC playAtTime: startTime];[playerD playAtTime: startTime];[player...

With no startDelayTime the first 100-200ms of all players will get clipped off because the start command actually takes its time to the run loop although the players have already started (well, been scheduled) 100% in sync at now. But with a startDelayTime = 0.25 you are good to go. And never forget to prepareToPlay your players in advance so that at start time no additional buffering or setup has to be done - just starting them guys ;-)


I used Apple developer support ticket for my own problems with AVAudioEngine in which one problem was (is) exactly the same as yours. I got this code to try:

AudioTimeStamp myAudioQueueStartTime = {0};UInt32 theNumberOfSecondsInTheFuture = 5;Float64 hostTimeFreq = CAHostTimeBase::GetFrequency();UInt64 startHostTime = CAHostTimeBase::GetCurrentTime()+theNumberOfSecondsInTheFuture*hostTimeFreq;myAudioQueueStartTime.mFlags = kAudioTimeStampHostTimeValid;myAudioQueueStartTime.mHostTime = startHostTime;AVAudioTime *time = [AVAudioTime timeWithAudioTimeStamp:&myAudioQueueStartTime sampleRate:_file.processingFormat.sampleRate];

Aside from scheduling play in Skynet era instead of 5 seconds in the future, it still didn't sync two AVAudioPlayerNodes (when I switched GetCurrentTime() for some arbitrary value to actually manage to play the nodes).

So not being able to sync two and more nodes together is a bug (confirmed by Apple support). Generally, if you don't have to use something that was introduced with AVAudioEngine (and you don't know how to translate it into AUGraph), I advise to use AUGraph instead. It's a bit more overhead to implement but you have more control over it.