iOS Audio Disruptions

How the app keeps audio and the microphone running when iOS takes focus away, and why Assets/Plugins/iOS/AppBackground.mm exists. The other half of the story — how the microphone session is configured and recovered — lives in Assets/Plugins/iOS/Microphone.mm and its comments.

The disruption classes

iOS delivers overlapping but distinct signals depending on what took the app’s focus:

Disruption UIKit notifications AVAudioSession interruption

Control Center, notification shade, system alert

WillResignActiveDidBecomeActive

None — the session stays active and audible.

Home, app switch, screen lock

WillResignActiveDidEnterBackground, then WillEnterForegroundDidBecomeActive

On suspension the session is deactivated; an interruption may be delivered on return.

Phone call, Siri, another app’s exclusive audio

Depends on overlay

AVAudioSessionInterruptionNotification began/ended.

The overlay class is the interesting one: nothing about it requires audio to stop — music apps play through Control Center — but two separate Unity behaviors used to stop it anyway, producing a paused app while the overlay was open and a double freeze with a quiet, misrouted window on return.

What Unity does on resign-active

Two independent mechanisms react to UIApplicationWillResignActiveNotification:

  1. The trampoline pauses the player. UnityAppController.mm applicationWillResignActive: calls UnitySetPlayerPause(kUnityPauseModePause, …) unless iosUseCustomAppBackgroundBehavior is set. This freezes rendering, scripts and FMOD’s mixer.

  2. FMOD tears down its audio session. The engine’s CoreAudio backend (fmod_output_coreaudio_objc.o inside UnityRuntime.framework) registers its own observers for resign-active, become-active, enter-background, enter-foreground and screen-connect at audio init, unconditionally — no project setting gates them. Disassembly shows resignActive: is a single call to OutputCoreAudio::shutdownAudioSessionInternal() (which stops the output AudioUnit) and becomeActive: a single call to restoreAudioSessionInternal(false).

The restore path is what made returning from Control Center so rough. restoreAudioSessionInternal runs setupAudioSession, which rewrites the shared AVAudioSession: setCategory:withOptions:, setActive:, setPreferredSampleRate:, setPreferredIOBufferDuration: and overrideOutputAudioPort:. Two consequences:

  • setCategory:withOptions: resets the session mode to Default (documented AVFoundation side effect) and applies FMOD’s options — wiping the VoiceChat mode and DefaultToSpeaker option that Microphone.mm maintains. PlayAndRecord without those routes toward the built-in receiver at call gain, which is the "very quiet playback" window.

  • Each session rewrite with a live VoiceProcessingIO unit is a synchronous audio-hardware reconfiguration on the main thread — several hundred milliseconds. FMOD’s rewrite was one freeze; the microphone recovery in EnsureRunning detecting the clobbered mode and rewriting the session back was the second.

Home → return only ever showed one freeze because FMOD’s restore runs on WillEnterForeground, behind the launch snapshot, where it is invisible; Control Center has no foreground transition, so both settles happened on screen.

What we do about it

AppBackground.mm removes both mechanisms for the overlay class and nothing else:

  1. iosUseCustomAppBackgroundBehavior: 1 (set in ProjectSettings.asset and the iOS build profile) stops the trampoline from pausing the player on resign-active. Because something must still pause it on a real background, AppBackground.mm observes UIApplicationDidEnterBackgroundNotification and calls UnitySetPlayerPause there. Resume is left to the trampoline: applicationDidBecomeActive: unpauses any pause the app did not set explicitly, which is the same path stock Unity uses for its own resign-active pause.

  2. At startup a constructor swizzles -[OutputCoreAudioObjC resignActive:] and becomeActive: to no-ops. The disassembled method bodies contain nothing but the shutdown/restore calls, so a no-op removes exactly that behavior. enterBackground:/enterForeground: are left intact, so real backgrounding keeps its teardown and restore, and genuine interruptions (calls, Siri) are still handled by FMOD’s and Microphone.mm’s `AVAudioSessionInterruptionNotification observers.

Net behavior: overlays no longer touch the player or the audio session at all — playback and microphone capture continue while Control Center is open, and closing it has nothing to recover, so no freezes and no quiet window. Backgrounding behaves as it always has.

Why swizzling and not the alternatives

  • Linking FMOD’s globals (FMOD::gOutputObjC is an exported data symbol; observers could be removed with removeObserver:name:) fails hard at link time if Unity ever renames the symbol, and silently un-applies itself whenever FMOD re-runs activate: — it re-registers the observers on every output init, including after a media-services reset.

  • Patching the engine binary in the editor install or post-build achieves the same as the swizzle with strictly worse maintainability.

  • The swizzle resolves everything at runtime and fails open: if a Unity upgrade renames the class or selectors, the handlers log a warning and the app degrades to the old pause-on-overlay behavior instead of breaking the build. After an upgrade, check the device log for [AppBackground] warnings and re-verify the Control Center round-trip.