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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user