From d625b897028ae2dfb40f45f8cef11eeb6bc3d6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 23 Sep 2026 15:19:11 +0200 Subject: [PATCH 1/2] Improve concurrency around `AudioSessionManaging` --- CHANGELOG.md | 3 + .../Navigator/Audiobook/AudioNavigator.swift | 23 ++- .../TTS/PublicationSpeechSynthesizer.swift | 16 +-- .../Shared/Toolkit/Media/AudioSession.swift | 133 +++++++++++------- docs/Migration Guide.md | 12 +- 5 files changed, 117 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e17fcd3c..7c439316f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to this project will be documented in this file. Take a look #### Shared * `AudioSessionUser.play()` is removed. Conformers must implement the new interruption hooks instead, [see the migration guide](docs/Migration%20Guide.md). +* `AudioSessionManaging` changed to activate the audio session off the main thread, [see the migration guide](docs/Migration%20Guide.md). + * `start(with:isPlaying:)` is now `async`, and returns when the audio session is ready to play. + * `end(with:)` takes the `AudioSessionUser` instead of an `AudioSessionToken`, which is removed. ### Fixed diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index e68a8c8150..371b1e0984 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -148,8 +148,6 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo ) } - private var audioSessionToken: AudioSessionToken? - isolated deinit { playTask?.cancel() durationLoadTask?.cancel() @@ -157,14 +155,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo if let rateDidChangeObserver { NotificationCenter.default.removeObserver(rateDidChangeObserver) } - endAudioSession() - } - - private func endAudioSession() { - if let token = audioSessionToken { - audioSession.end(with: token) - audioSessionToken = nil - } + audioSession.end(with: self) } /// Returns whether the resource is currently playing or not. @@ -249,7 +240,10 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo guard !Task.isCancelled else { return } - audioSessionToken = audioSession.start(with: self, isPlaying: false) + await audioSession.start(with: self, isPlaying: false) + guard !Task.isCancelled else { + return + } if player.currentItem == nil { if let location = initialLocation { @@ -280,7 +274,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo public func stop() { playTask = nil pause() - endAudioSession() + audioSession.end(with: self) } /// Toggles the playback. @@ -377,7 +371,9 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo object: player, queue: .main ) { [weak self] notification in - let reason = notification.userInfo?[AVPlayer.rateDidChangeReasonKey] as? AVPlayer.RateDidChangeReason + // `Notification` is not `Sendable`, but it is safe to use here as + // the block is called synchronously on the main queue. + nonisolated(unsafe) let notification = notification // Handled synchronously, to be ordered with the audio session // interruption hooks which read `isPausedByInterruption`. @@ -385,6 +381,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo guard let self else { return } + let reason = notification.userInfo?[AVPlayer.rateDidChangeReasonKey] as? AVPlayer.RateDidChangeReason self.isPausedByInterruption = self.player.rate == 0 && reason == .audioSessionInterrupted switch self.player.timeControlStatus { diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index 34b45df174..f05ea62c75 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -189,11 +189,13 @@ public final class PublicationSpeechSynthesizer: Loggable { /// (Re)starts the synthesizer from the given locator or the beginning of the publication. public func start(from startLocator: Locator? = nil) { - audioSessionUser.start(isPlaying: false) - currentTask?.cancel() publicationIterator = publication.content(from: startLocator)?.iterator() currentTask = Task { + await audioSessionUser.start(isPlaying: false) + guard !Task.isCancelled else { + return + } await playNextUtterance(.forward) } } @@ -461,7 +463,6 @@ public final class PublicationSpeechSynthesizer: Loggable { weak var synthesizer: PublicationSpeechSynthesizer? private let session: any AudioSessionManaging - private var token: AudioSessionToken? init(session: any AudioSessionManaging, config: AudioSession.Configuration) { self.session = session @@ -480,15 +481,12 @@ public final class PublicationSpeechSynthesizer: Loggable { synthesizer?.audioSessionInterruptionDidEnd(shouldResume: shouldResume) } - func start(isPlaying: Bool) { - token = session.start(with: self, isPlaying: isPlaying) + func start(isPlaying: Bool) async { + await session.start(with: self, isPlaying: isPlaying) } func end() { - if let token { - session.end(with: token) - self.token = nil - } + session.end(with: self) } func didChangePlaying(_ isPlaying: Bool) { diff --git a/Sources/Shared/Toolkit/Media/AudioSession.swift b/Sources/Shared/Toolkit/Media/AudioSession.swift index 00fa87b210..256d9a4326 100644 --- a/Sources/Shared/Toolkit/Media/AudioSession.swift +++ b/Sources/Shared/Toolkit/Media/AudioSession.swift @@ -41,29 +41,22 @@ public extension AudioSessionUser { public protocol AudioSessionManaging: Sendable { /// Starts a new audio session with the given `user`. /// - /// The returned opaque token can be used to end the session for the same - /// user. - @discardableResult - func start(with user: any AudioSessionUser, isPlaying: Bool) -> AudioSessionToken + /// Returns when the audio session is ready to play, so the `user` must + /// await it before starting its engine. + func start(with user: any AudioSessionUser, isPlaying: Bool) async - /// Ends the audio session of the user identified by `token`. + /// Ends the audio session of the given `user`. /// /// Does nothing if another user started a session since. - func end(with token: AudioSessionToken) + /// + /// This may be called from the `user`'s `deinit`, so implementations + /// must not retain the `user`. + func end(with user: any AudioSessionUser) /// Indicates whether the `user` is playing. func user(_ user: any AudioSessionUser, didChangePlaying isPlaying: Bool) } -/// Opaque token identifying an audio session user. -public struct AudioSessionToken: Sendable, Equatable { - public let id: ObjectIdentifier - - public init(id: ObjectIdentifier) { - self.id = id - } -} - /// Manages an activated `AVAudioSession`. @MainActor public final class AudioSession: AudioSessionManaging, Sendable, Loggable { @@ -113,27 +106,27 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { private var user: User? /// Starts a new audio session with the given `user`. - @discardableResult - public func start(with user: any AudioSessionUser, isPlaying: Bool) -> AudioSessionToken { + /// + /// Returns when the audio session is ready to play. + public func start(with user: any AudioSessionUser, isPlaying: Bool) async { let id = ObjectIdentifier(user) - let token = AudioSessionToken(id: id) - guard self.user?.id != id else { - return token - } - - if let oldUser = self.user { - end(forUserID: oldUser.id) + if self.user?.id != id { + if let oldUser = self.user { + end(forUserID: oldUser.id) + } + self.user = User(user) + self.isPlaying = isPlaying } - self.user = User(user) - self.isPlaying = isPlaying + // The session of the same user may have been ended in the background, + // or by an interruption. startSession(with: user.audioConfiguration) - return token + await waitForActivation() } - /// Ends the audio session of the user identified by `token`. - public func end(with token: AudioSessionToken) { - end(forUserID: token.id) + /// Ends the audio session of the given `user`. + public func end(with user: any AudioSessionUser) { + end(forUserID: ObjectIdentifier(user)) } private func end(forUserID id: ObjectIdentifier) { @@ -212,15 +205,7 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { return } - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(config.category, mode: config.mode, policy: config.routeSharingPolicy, options: config.options) - try audioSession.setActive(true) - log(.info, "Started audio session with category: \(config.category), mode: \(config.mode), policy: \(config.routeSharingPolicy), options: \(config.options)") - } catch { - log(.error, "Failed to start the audio session: \(error)") - } - + apply(.activate(config)) isSessionStarted = true } @@ -229,16 +214,68 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { return } - do { - try AVAudioSession.sharedInstance().setActive(false) - log(.info, "Ended audio session") - } catch { - log(.error, "Failed to end the audio session: \(error)") - } - + apply(.deactivate) isSessionStarted = false } + /// Last requested change of the `AVAudioSession` activation state. + private var activationTask: Task? + + /// Waits until the last requested activation change is applied, or + /// skipped. + private func waitForActivation() async { + await activationTask?.value + } + + /// Change of the `AVAudioSession` activation state. + private enum ActivationChange: Sendable { + /// Sets the category from the given configuration, then activates + /// the session. + case activate(Configuration) + /// Deactivates the session. + case deactivate + } + + /// Applies the given `change` to the `AVAudioSession`. + /// + /// These are blocking operations which can hang the main thread, so they + /// run in a concurrent context. Each change awaits the previous one, to + /// be applied in the order of the calls. + /// + /// A change still waiting for its turn is skipped when a new one is + /// requested, as only the last requested state matters. Otherwise, a + /// pending deactivation could stop an engine which started playing in + /// the meantime. + private func apply(_ change: ActivationChange) { + activationTask?.cancel() + activationTask = Task { @concurrent [previous = activationTask] in + await previous?.value + guard !Task.isCancelled else { + return + } + + let session = AVAudioSession.sharedInstance() + switch change { + case let .activate(config): + do { + try session.setCategory(config.category, mode: config.mode, policy: config.routeSharingPolicy, options: config.options) + try session.setActive(true) + Self.log(.info, "Started audio session with category: \(config.category), mode: \(config.mode), policy: \(config.routeSharingPolicy), options: \(config.options)") + } catch { + Self.log(.error, "Failed to start the audio session: \(error)") + } + + case .deactivate: + do { + try session.setActive(false) + Self.log(.info, "Ended audio session") + } catch { + Self.log(.error, "Failed to end the audio session: \(error)") + } + } + } + } + /// Whether the audio session is currently interrupted, e.g. by a phone call. public private(set) var isInterrupted: Bool = false @@ -252,8 +289,10 @@ public final class AudioSession: AudioSessionManaging, Sendable, Loggable { ) { switch type { case .began: - // The system deactivated the session. + // The system deactivated the session. A pending activation would + // fail during the interruption, so it is skipped. isSessionStarted = false + activationTask?.cancel() // The app was suspended while the session was active in the // background. Nothing was playing, and no `.ended` will follow. diff --git a/docs/Migration Guide.md b/docs/Migration Guide.md index 429ab5e196..daa6ec5ddb 100644 --- a/docs/Migration Guide.md +++ b/docs/Migration Guide.md @@ -4,7 +4,16 @@ All migration steps necessary in reading apps to upgrade to major versions of th ## Unreleased -### Handling audio interruptions in a custom `AudioSessionUser` +### Audio session changes + +#### Custom `AudioSessionManaging` implementations + +The audio session is now activated off the main thread, as it can block for a noticeable time. + +* `start(with:isPlaying:)` is now `async`. Return only once the audio session is ready to play, as callers start their engine right after. +* `AudioSessionToken` is removed. `end(with:)` takes the `AudioSessionUser` instead. It may be called from the user's `deinit`, so don't retain the user or capture it in a `Task`. + +#### Handling audio interruptions in a custom `AudioSessionUser` `AudioSessionUser.play()` is removed. The `AudioSession` used to call it when an interruption (e.g. a phone call) ended, even if the playback was paused before the interruption. Conformers now implement two hooks and decide themselves whether to resume: @@ -12,6 +21,7 @@ All migration steps necessary in reading apps to upgrade to major versions of th * `audioSessionInterruptionDidEnd(shouldResume:)` is called when it ends. Resume only if `shouldResume` is set and the interruption paused the playback. Forget the paused state in any case, and when the user pauses during the interruption (e.g. with Siri). + ## 4.0.0-alpha.2 ### EPUB bitmap fallbacks are no longer swapped in the reading order From cc4a7faa54eee9de876ad00630697fb4479017a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Wed, 23 Sep 2026 15:34:38 +0200 Subject: [PATCH 2/2] Fix --- .../TTS/PublicationSpeechSynthesizer.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift index f05ea62c75..5060bf503c 100644 --- a/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift +++ b/Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift @@ -192,10 +192,6 @@ public final class PublicationSpeechSynthesizer: Loggable { currentTask?.cancel() publicationIterator = publication.content(from: startLocator)?.iterator() currentTask = Task { - await audioSessionUser.start(isPlaying: false) - guard !Task.isCancelled else { - return - } await playNextUtterance(.forward) } } @@ -284,6 +280,15 @@ public final class PublicationSpeechSynthesizer: Loggable { /// Plays the given `utterance` with the TTS `engine`. private func play(_ utterance: Utterance) async { + // Starts the audio session when (re)starting the playback, e.g. after + // an interruption which deactivated it. Not needed between utterances. + if !state.isPlaying { + await audioSessionUser.start(isPlaying: false) + guard !Task.isCancelled else { + return + } + } + state = .playing(utterance, range: nil) let result = await engine.speak(