From 5ca0dfdcd2225ff53ae75c6c833b36fe90f8e164 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 12:47:05 +0200 Subject: [PATCH] fix(apple): stream audio coexists with Music instead of fighting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../PunktfunkKit/Audio/SessionAudio.swift | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index c293822e..7798aaa9 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -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