Skip to content

Commit

Permalink
Fix asynchronoucity with the TTS
Browse files Browse the repository at this point in the history
  • Loading branch information
mickael-menu committed Jul 12, 2024
1 parent 5b41ea9 commit 548b29c
Show file tree
Hide file tree
Showing 8 changed files with 70 additions and 22 deletions.
1 change: 0 additions & 1 deletion Sources/Navigator/EPUB/EPUBNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,6 @@ open class EPUBNavigatorViewController: UIViewController,

/// Intercepts tap gesture when the web views are not loaded.
@objc private func didTapBackground(_ gesture: UITapGestureRecognizer) {
guard case .loading = state else { return }
didTap(at: gesture.location(in: view))
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Navigator/TTS/AVTTSEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public class AVTTSEngine: NSObject, TTSEngine, AVSpeechSynthesizerDelegate, Logg
.map { TTSVoice(voice: $0) }
}

@MainActor
public func speak(
_ utterance: TTSUtterance,
onSpeakRange: @escaping (Range<String.Index>) -> Void
Expand Down
8 changes: 6 additions & 2 deletions Sources/Navigator/TTS/PublicationSpeechSynthesizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import ReadiumShared

public protocol PublicationSpeechSynthesizerDelegate: AnyObject {
/// Called when the synthesizer's state is updated.
@MainActor
func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, stateDidChange state: PublicationSpeechSynthesizer.State)

/// Called when an `error` occurs while speaking `utterance`.
@MainActor
func publicationSpeechSynthesizer(_ synthesizer: PublicationSpeechSynthesizer, utterance: PublicationSpeechSynthesizer.Utterance, didFailWithError error: PublicationSpeechSynthesizer.Error)
}

Expand Down Expand Up @@ -89,7 +91,9 @@ public class PublicationSpeechSynthesizer: Loggable {
AudioSession.shared.user(audioSessionUser, didChangePlaying: state.isPlaying)
}

delegate?.publicationSpeechSynthesizer(self, stateDidChange: state)
Task {
await delegate?.publicationSpeechSynthesizer(self, stateDidChange: state)
}
}
}

Expand Down Expand Up @@ -296,7 +300,7 @@ public class PublicationSpeechSynthesizer: Loggable {
await playNextUtterance(.forward)
case let .failure(error):
state = .paused(utterance)
delegate?.publicationSpeechSynthesizer(self, utterance: utterance, didFailWithError: .engine(error))
await delegate?.publicationSpeechSynthesizer(self, utterance: utterance, didFailWithError: .engine(error))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public struct EPUBFormatSniffer: FormatSniffer {

return await resource.readAsString()
.flatMap { mimetype in
if MediaType.epub.matches(MediaType(mimetype)) {
if MediaType.epub.matches(MediaType(mimetype.trimmingCharacters(in: .whitespacesAndNewlines))) {
var format = format
format.addSpecifications(.epub)
if format.conformsTo(.zip) {
Expand Down
74 changes: 58 additions & 16 deletions Sources/Shared/Toolkit/Media/AudioSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public extension AudioSessionUser {
}

/// Manages an activated `AVAudioSession`.
@MainActor
public final class AudioSession: Loggable {
public struct Configuration: Equatable {
let category: AVAudioSession.Category
Expand All @@ -49,41 +50,67 @@ public final class AudioSession: Loggable {
}

/// Shared `AudioSession` for this app.
public static let shared = AudioSession()
public nonisolated static let shared = AudioSession()

private init() {
observeAppStateChanges()
private nonisolated init() {
Task {
await observeAppStateChanges()
}
}

deinit {
NotificationCenter.default.removeObserver(self)
}

struct User {
let id: ObjectIdentifier
private(set) weak var user: AudioSessionUser?

init(_ user: AudioSessionUser) {
id = ObjectIdentifier(user)
self.user = user
}
}

/// Current user of the `AudioSession`.
private weak var user: AudioSessionUser?
private var user: User?

/// Starts a new audio session with the given `user`.
public func start(with user: AudioSessionUser, isPlaying: Bool) {
guard self.user !== user else {
public nonisolated func start(with user: AudioSessionUser, isPlaying: Bool) {
Task {
await start(with: user, isPlaying: isPlaying)
}
}

private func start(with user: AudioSessionUser, isPlaying: Bool) async {
let id = ObjectIdentifier(user)
guard self.user?.id != id else {
return
}

if let oldUser = self.user {
end(for: oldUser)
end(forUserID: oldUser.id)
}
self.user = user
self.user = User(user)
self.isPlaying = false

startSession(with: user.audioConfiguration)
}

/// Ends the current audio session.
public func end(for user: AudioSessionUser) {
guard self.user === user || self.user == nil else {
public nonisolated func end(for user: AudioSessionUser) {
let id = ObjectIdentifier(user)
Task {
await end(forUserID: id)
}
}

private func end(forUserID id: ObjectIdentifier) {
guard user?.id == id else {
return
}

self.user = nil
user = nil
isPlaying = false

endSession()
Expand All @@ -92,8 +119,15 @@ public final class AudioSession: Loggable {
/// Indicates whether the `user` is playing.
private var isPlaying: Bool = false

public func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) {
guard self.user === user, self.isPlaying != isPlaying else {
public nonisolated func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) {
Task {
await self.user(user, didChangePlaying: isPlaying)
}
}

private func user(_ user: AudioSessionUser, didChangePlaying isPlaying: Bool) async {
let id = ObjectIdentifier(user)
guard self.user?.id == id, self.isPlaying != isPlaying else {
return
}

Expand Down Expand Up @@ -131,6 +165,7 @@ public final class AudioSession: Loggable {
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)")
}
Expand All @@ -154,6 +189,7 @@ public final class AudioSession: Loggable {
do {
AVAudioSession.sharedInstance()
try AVAudioSession.sharedInstance().setActive(false)
log(.info, "Ended audio session")
} catch {
log(.error, "Failed to end the audio session: \(error)")
}
Expand All @@ -168,7 +204,7 @@ public final class AudioSession: Loggable {
/// The observer of audio session interruption notifications.
private var interruptionObserver: Any?

private func handleAudioSessionInterruption(notification: Notification) {
private nonisolated func handleAudioSessionInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let rawInterruptionType = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let interruptionType = AVAudioSession.InterruptionType(rawValue: rawInterruptionType)
Expand All @@ -180,7 +216,13 @@ public final class AudioSession: Loggable {
.map(AVAudioSession.InterruptionOptions.init(rawValue:))
?? []

switch interruptionType {
Task {
await handleAudioSessionInterruption(type: interruptionType, options: options)
}
}

private func handleAudioSessionInterruption(type: AVAudioSession.InterruptionType, options: AVAudioSession.InterruptionOptions) {
switch type {
case .began:
isInterrupted = true

Expand All @@ -190,7 +232,7 @@ public final class AudioSession: Loggable {
// When an interruption ends, determine whether playback should resume automatically,
// and reactivate the audio session if necessary.
do {
if let user = user {
if let user = user?.user {
try AVAudioSession.sharedInstance().setActive(true)

if options.contains(.shouldResume) {
Expand Down
1 change: 1 addition & 0 deletions TestApp/Sources/Library/LibraryError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ enum LibraryError: LocalizedError {
case bookNotFound
case bookDeletionFailed(Error?)
case importFailed(Error)
case publicationIsRestricted(Error)
case openFailed(Error)
case downloadFailed(Error?)
case cancelled
Expand Down
3 changes: 1 addition & 2 deletions TestApp/Sources/Library/LibraryService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,10 @@ final class LibraryService: Loggable {
}

/// Checks if the publication is not still locked by a DRM.
// FIXME: Better error
private func checkIsReadable(publication: Publication) throws {
guard !publication.isRestricted else {
if let error = publication.protectionError {
throw LibraryError.openFailed(error)
throw LibraryError.publicationIsRestricted(error)
} else {
throw LibraryError.cancelled
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class VisualReaderViewController<N: UIViewController & Navigator>: ReaderViewCon

if let state = ttsViewModel?.$state, let controls = ttsControlsViewController {
controls.view.backgroundColor = .clear
controls.view.isHidden = true

addChild(controls)
controls.view.translatesAutoresizingMaskIntoConstraints = false
Expand All @@ -94,6 +95,7 @@ class VisualReaderViewController<N: UIViewController & Navigator>: ReaderViewCon
controls.didMove(toParent: self)

state
.receive(on: DispatchQueue.main)
.sink { state in
controls.view.isHidden = !state.showControls
}
Expand Down

0 comments on commit 548b29c

Please sign in to comment.