Skip to content

5.23.0

Choose a tag to compare

@superuser404notfound superuser404notfound released this 25 Jul 09:18

Playback ended, the receiver kept looping (#215)

On an E-AC-3 / Atmos bitstream passthrough route, the HDMI sink runs its own decode ring. Release the AVPlayer while the shared AVAudioSession is still active and that ring is never told to stop: the receiver keeps looping the last MAT frame after you leave the video, and it keeps doing it with the app off-screen. Reported and device-verified by Brandon Moore on an Apple TV 4K (3rd gen, tvOS 27) feeding an HDMI AVR from a Dolby Vision Profile 7 MKV with E-AC-3 JOC audio over SMB.

The engine never deactivated the session because, on the native path, it never activates it either. That is deliberate: AVKit activates per playback, which is what lets tvOS negotiate the HDMI route at the right moment (issue #24). Deactivating a session the host app owns is a different question, and it is the host's to answer.

AetherEngine.deactivatesAudioSessionOnStop

New, default false, so nothing changes for anyone who does not ask:

engine.deactivatesAudioSessionOnStop = true

Set it if your app owns the audio session. Leave it alone if your app plays its own audio (UI sounds, TTS, an AVAudioEngine, a background music player), because otherwise engine teardown would pull the session out from under it.

Only a genuine final teardown honours the flag. stop() is one. A native to native reload, a native to audio or software handoff, and a live retune are not: audio is expected to keep flowing across those seams, and the live-reload watchdog in particular calls a bare internal stop and expects the host to retune immediately. Teardown intent is now explicit rather than inferred from the display-criteria flag, and stop(resetDisplayCriteria:finalTeardown:) lets a host that deliberately keeps criteria across a stop / load pair still declare a real teardown. finalTeardown defaults to resetDisplayCriteria, so existing callers are unchanged.

An AVAudioSessionErrorCodeIsBusy result is logged, not retried. Per AVAudioSession.h the session deactivates regardless since iOS 8 and the code exists only to flag that I/O was still running, and iOS / tvOS 26 stopped returning it at all. A retry would also be actively harmful: one landing after a subsequent load() would deactivate the new session.

Every backend, not just the native one

The merged fix released the session inside NativeAVPlayerHost, which covers the path the loop was reported on and no other. The software and audio renderer paths bypass AVKit and activate the session themselves, so they are the paths where the engine is releasing what the engine took, and they were the ones left holding an active .playback session after a final teardown. The release now runs as the last step of the engine's own teardown, once the item is unloaded, the AVPlayer is released and the software / audio outputs are stopped. Same synchronous call in the same teardown, so the ordering the fix was verified against is unchanged.

The gate is a pure function with its truth table under test. Getting it wrong is not cosmetic: deactivating mid-reload would kill audio for a session the host expects to keep playing.

Covered by: 1060 tests green across 171 suites, AudioSessionTeardownPolicyTests new. Strict-concurrency clean, macOS plus tvOS and iOS Simulator builds green.

Thanks: @thatcube for the diagnosis, the fix, and the passthrough device verification.