Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 10 additions & 13 deletions Sources/Navigator/Audiobook/AudioNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,23 +148,14 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
)
}

private var audioSessionToken: AudioSessionToken?

isolated deinit {
playTask?.cancel()
durationLoadTask?.cancel()
didPlayToEndTimeTask?.cancel()
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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -377,14 +371,17 @@ 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`.
MainActor.assumeIsolated {
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 {
Expand Down
21 changes: 12 additions & 9 deletions Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,6 @@ 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 {
Expand Down Expand Up @@ -282,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(
Expand Down Expand Up @@ -461,7 +468,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
Expand All @@ -480,15 +486,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) {
Expand Down
133 changes: 86 additions & 47 deletions Sources/Shared/Toolkit/Media/AudioSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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<Void, Never>?

/// 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

Expand All @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion docs/Migration Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,24 @@ 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:

* `audioSessionInterruptionDidBegin()` is called when the interruption begins. Pause the playback if your engine doesn't pause on its own (`AVPlayer` does), and remember whether this interruption paused it.
* `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
Expand Down
Loading