fix(apple): stream audio coexists with Music instead of fighting it
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / web (pull_request) Successful in 2m28s
ci / bun-nix (pull_request) Successful in 32s
android / android (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 2m24s
ci / rust (pull_request) Successful in 8m25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m29s

Field report, with a competitor cited doing it right: connecting to a host
PAUSED the user's Apple Music, and resuming Music mid-stream silenced the
stream's audio for good. Both are one defect — the audio session was
EXCLUSIVE. Activating it evicted whoever was playing (the pause at
connect), and Music's resume evicted us right back; iOS stops the engines
on that interruption and restarts nothing, so the stream stayed silent
until reconnect.

`.mixWithOthers` on every category path (playAndRecord, playback, tvOS) is
the heart of it: a mixable session interrupts nobody at activation and is
not interrupted by another app's mixable playback — game audio rides over
the playlist, the way a console does it. The trade is that a mixable
session is nobody's Now Playing app, so the lock screen keeps showing the
music. For a game stream that is the correct reading.

The other half is `installInterruptionObserver`: interruptions still exist
for a mixable session — a phone call, Siri, an app claiming a NON-mixable
session of its own — and ending one restarts nothing by itself. On
`.ended` the observer re-runs the full activation (an interruption can drop
the category, and the earpiece steer is per-route) and revives the engines
through the same conservative `reviveStoppedEngines` the route-change and
media-reset observers already use — unconditionally, not only on the
`.shouldResume` hint: a live stream is the one case where the user's intent
to keep hearing it is not in doubt, and the revive already declines when
playback never went down.

The teardown's `.notifyOthersOnDeactivation` stays as a courtesy for the
edge where an old exclusive install interrupted something; a mixable
session has nobody to notify.

macOS build (session paths are #if'd out there), tvOS typecheck, 284 tests.
This commit is contained in:
2026-08-10 12:47:05 +02:00
parent a14b000c9b
commit 5ca0dfdcd2
@@ -90,6 +90,10 @@ public final class SessionAudio {
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
/// Token for the interruption observer a phone call or a non-mixable app stops the engines,
/// and ending the interruption restarts nothing by itself (see
/// `installInterruptionObserver`). Guarded by `stateLock`.
private var interruptionObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
@@ -219,9 +223,17 @@ public final class SessionAudio {
// make a headset's MIC usable, but it buys that by dragging the whole route onto
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
// the built-in mic is the better trade for a game-streaming client.
// `.mixWithOthers`, both branches: without it this session is EXCLUSIVE merely
// activating it paused the user's Music at connect, and Music's RESUME took the
// session right back, which read as "stream audio stops when I resume Music"
// (field report; the interruption observer below is the other half of that fix).
// A game stream mixing over someone's playlist is the behavior a console has,
// and what this client's peers do. The trade is real but right: a mixable
// session is nobody's Now Playing app, so the lock screen shows the music, not
// the stream which is exactly how it should read.
try session.setCategory(
.playAndRecord, mode: .default,
options: [.allowBluetoothA2DP])
options: [.allowBluetoothA2DP, .mixWithOthers])
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
// quantum is most of the mic path's burst latency). Best-effort the hardware
// has the final word (a Bluetooth route will ignore both), and whatever quantum
@@ -229,10 +241,10 @@ public final class SessionAudio {
try? session.setPreferredIOBufferDuration(0.005)
try? session.setPreferredSampleRate(48_000)
} else {
try session.setCategory(.playback, mode: .default)
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
}
#else // tvOS no app-accessible mic
try session.setCategory(.playback, mode: .default)
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
#endif
try session.setActive(true)
#if os(iOS)
@@ -396,6 +408,8 @@ public final class SessionAudio {
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
let interruption = interruptionObserver
interruptionObserver = nil
#endif
stateLock.unlock()
// Every watcher goes before the engines do: a device change landing during teardown must
@@ -406,10 +420,13 @@ public final class SessionAudio {
#if !os(macOS)
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
if let interruption { NotificationCenter.default.removeObserver(interruption) }
#endif
tearDownEngines()
#if !os(macOS)
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// Release the session. (A mixable session interrupts nobody, so the resume cue below is
// now a courtesy for the edge where an OLD non-mixable install interrupted something
// harmless either way, and deactivating promptly is still what orders a reconnect.) Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
// (off the main thread). Enqueued HERE engines already stopped, and BEFORE the drain wait
// below so across a reconnect it lands ahead of the next session's activate on the shared
@@ -493,6 +510,7 @@ public final class SessionAudio {
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
installInterruptionObserver(micEnabled: micEnabled)
#endif
}
@@ -654,6 +672,38 @@ public final class SessionAudio {
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
/// Interruptions still happen to a mixable session a phone call, Siri, an app that claims
/// a NON-mixable session of its own. iOS stops the engines, and when the interruption ends it
/// restarts NOTHING by itself; before this observer the stream just stayed silent (under the
/// old exclusive category, Music itself was such an interrupter, which is how "resume Music,
/// lose the stream" was ever possible). Reactivate and revive on `.ended` unconditionally,
/// not only when iOS hints `.shouldResume`: a live stream is the one case where the user's
/// intent to keep hearing it is not in doubt, and `reviveStoppedEngines` already declines
/// when playback never went down.
private func installInterruptionObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(), queue: nil
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
// The full activation, not a bare `setActive`: an interruption can drop the
// category configuration too, and on iOS the earpiece steer is per-route.
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.reviveStoppedEngines("an audio interruption ended")
}
}
}
stateLock.lock()
let stale = interruptionObserver
interruptionObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting