382 lines
15 KiB
Swift
382 lines
15 KiB
Swift
import AVFAudio
|
|
import Foundation
|
|
import MediaPlayer
|
|
|
|
@MainActor
|
|
public final class RDSpeechReaderController: NSObject {
|
|
public weak var delegate: RDSpeechReaderControllerDelegate?
|
|
public private(set) var state: RDSpeechReaderState = .idle {
|
|
didSet {
|
|
updateNowPlayingInfo()
|
|
delegate?.speechReaderController(self, didChange: state)
|
|
}
|
|
}
|
|
public private(set) var configuration: RDSpeechReaderConfiguration
|
|
|
|
private let contentProvider: RDSpeechContentProvider
|
|
private let progressStore: RDSpeechProgressPersisting?
|
|
private let synthesizer = AVSpeechSynthesizer()
|
|
private var currentUnit: RDSpeechTextUnit?
|
|
private var queuedUnits: [RDSpeechTextUnit] = []
|
|
private var completedUnits: [RDSpeechTextUnit] = []
|
|
private var nextLocation: RDSpeechLocation?
|
|
private var isLoadingBatch = false
|
|
private var isStopping = false
|
|
private var shouldResumeAfterInterruption = false
|
|
private var sleepTimerTask: Task<Void, Never>?
|
|
private var notificationTokens: [NSObjectProtocol] = []
|
|
|
|
public init(
|
|
contentProvider: RDSpeechContentProvider,
|
|
configuration: RDSpeechReaderConfiguration = .default,
|
|
progressStore: RDSpeechProgressPersisting? = RDSpeechUserDefaultsProgressStore()
|
|
) {
|
|
self.contentProvider = contentProvider
|
|
self.configuration = configuration
|
|
self.progressStore = progressStore
|
|
super.init()
|
|
synthesizer.delegate = self
|
|
installAudioObservers()
|
|
if configuration.enablesRemoteControls { installRemoteControls() }
|
|
}
|
|
|
|
deinit {
|
|
notificationTokens.forEach(NotificationCenter.default.removeObserver)
|
|
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
|
|
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
|
|
MPRemoteCommandCenter.shared().togglePlayPauseCommand.removeTarget(nil)
|
|
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
|
|
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
|
|
}
|
|
|
|
public func start(from location: RDSpeechLocation? = nil) async throws {
|
|
stop(clearProgress: false)
|
|
isStopping = false
|
|
completedUnits.removeAll()
|
|
state = .preparing
|
|
do {
|
|
if configuration.configuresAudioSession { try configureAudioSession() }
|
|
let initialLocation = location ?? progressStore?.restoreSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
|
|
try await loadBatch(startingAt: initialLocation, requiresContent: true)
|
|
try await speakNextUnit()
|
|
} catch {
|
|
state = .failed(error.localizedDescription)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public func pause() {
|
|
guard synthesizer.isSpeaking else { return }
|
|
synthesizer.pauseSpeaking(at: .word)
|
|
}
|
|
|
|
public func resume() {
|
|
guard synthesizer.isPaused else { return }
|
|
synthesizer.continueSpeaking()
|
|
}
|
|
|
|
public func stop() { stop(clearProgress: false) }
|
|
|
|
public func stop(clearProgress: Bool) {
|
|
isStopping = true
|
|
synthesizer.stopSpeaking(at: .immediate)
|
|
currentUnit = nil
|
|
queuedUnits.removeAll()
|
|
nextLocation = nil
|
|
isLoadingBatch = false
|
|
sleepTimerTask?.cancel()
|
|
sleepTimerTask = nil
|
|
if clearProgress { progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier) }
|
|
state = .idle
|
|
}
|
|
|
|
public func skipToNextSentence() {
|
|
guard state.isActive else { return }
|
|
stopCurrentUtteranceForNavigation()
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do { try await self.speakNextUnit() }
|
|
catch { self.state = .failed(error.localizedDescription) }
|
|
}
|
|
}
|
|
|
|
public func skipToPreviousSentence() {
|
|
guard state.isActive else { return }
|
|
let current = currentUnit
|
|
let previous = completedUnits.popLast() ?? current
|
|
guard let previous else { return }
|
|
if let current, previous.id != current.id { queuedUnits.insert(current, at: 0) }
|
|
stopCurrentUtteranceForNavigation()
|
|
currentUnit = previous
|
|
speak(previous)
|
|
}
|
|
|
|
/// Restarts the current sentence so a changed rate takes effect immediately.
|
|
public func updateRate(_ rate: Float) {
|
|
configuration.rate = min(max(rate, 0.0), 1.0)
|
|
restartCurrentUnitIfNeeded()
|
|
}
|
|
|
|
/// Restarts the current sentence so a changed voice takes effect immediately.
|
|
public func updateVoice(identifier: String?) {
|
|
configuration.voiceIdentifier = identifier
|
|
restartCurrentUnitIfNeeded()
|
|
}
|
|
|
|
public func availableVoices(languagePrefix: String? = nil) -> [RDSpeechVoice] {
|
|
AVSpeechSynthesisVoice.speechVoices()
|
|
.filter { voice in languagePrefix.map { voice.language.hasPrefix($0) } ?? true }
|
|
.map(RDSpeechVoice.init)
|
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
}
|
|
|
|
public func setSleepTimer(after interval: TimeInterval?) {
|
|
sleepTimerTask?.cancel()
|
|
guard let interval, interval > 0 else {
|
|
sleepTimerTask = nil
|
|
return
|
|
}
|
|
sleepTimerTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
|
guard !Task.isCancelled else { return }
|
|
self?.stop()
|
|
}
|
|
}
|
|
|
|
private func loadBatch(startingAt location: RDSpeechLocation?, requiresContent: Bool) async throws {
|
|
guard !isLoadingBatch, !isStopping else { return }
|
|
isLoadingBatch = true
|
|
defer { isLoadingBatch = false }
|
|
var requestedLocation = location
|
|
var mustContainContent = requiresContent
|
|
while !isStopping {
|
|
let batch = try await contentProvider.speechContentBatch(
|
|
startingAt: requestedLocation,
|
|
limit: configuration.batchSize
|
|
)
|
|
guard !isStopping else { return }
|
|
if !batch.units.isEmpty {
|
|
queuedUnits.append(contentsOf: batch.units)
|
|
nextLocation = batch.nextLocation
|
|
return
|
|
}
|
|
guard let next = batch.nextLocation else {
|
|
if mustContainContent { throw RDSpeechReaderError.noReadableContent }
|
|
state = .finished
|
|
progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
|
|
return
|
|
}
|
|
requestedLocation = next
|
|
mustContainContent = false
|
|
}
|
|
}
|
|
|
|
private func speakNextUnit() async throws {
|
|
guard !isStopping else { return }
|
|
if queuedUnits.isEmpty, let nextLocation {
|
|
try await loadBatch(startingAt: nextLocation, requiresContent: false)
|
|
}
|
|
guard !isStopping else { return }
|
|
guard !queuedUnits.isEmpty else {
|
|
if state != .finished {
|
|
state = .finished
|
|
progressStore?.clearSpeechLocation(for: contentProvider.speechBookDescriptor().identifier)
|
|
}
|
|
return
|
|
}
|
|
let unit = queuedUnits.removeFirst()
|
|
currentUnit = unit
|
|
speak(unit)
|
|
}
|
|
|
|
private func speak(_ unit: RDSpeechTextUnit) {
|
|
let utterance = AVSpeechUtterance(string: unit.text)
|
|
utterance.rate = configuration.rate
|
|
utterance.voice = resolvedVoice(for: unit)
|
|
synthesizer.speak(utterance)
|
|
}
|
|
|
|
private func restartCurrentUnitIfNeeded() {
|
|
guard let currentUnit, state.isActive else { return }
|
|
let wasPaused = synthesizer.isPaused
|
|
stopCurrentUtteranceForNavigation()
|
|
self.currentUnit = currentUnit
|
|
speak(currentUnit)
|
|
if wasPaused { synthesizer.pauseSpeaking(at: .immediate) }
|
|
}
|
|
|
|
private func stopCurrentUtteranceForNavigation() {
|
|
isStopping = true
|
|
synthesizer.stopSpeaking(at: .immediate)
|
|
isStopping = false
|
|
}
|
|
|
|
private func resolvedVoice(for unit: RDSpeechTextUnit) -> AVSpeechSynthesisVoice? {
|
|
if let identifier = configuration.voiceIdentifier,
|
|
let voice = AVSpeechSynthesisVoice(identifier: identifier) {
|
|
return voice
|
|
}
|
|
return AVSpeechSynthesisVoice(language: unit.language ?? configuration.defaultLanguage)
|
|
?? AVSpeechSynthesisVoice(language: configuration.defaultLanguage)
|
|
}
|
|
|
|
private func configureAudioSession() throws {
|
|
let session = AVAudioSession.sharedInstance()
|
|
var options: AVAudioSession.CategoryOptions = []
|
|
if configuration.ducksOtherAudio { options.insert(.duckOthers) }
|
|
try session.setCategory(.playback, mode: .spokenAudio, options: options)
|
|
try session.setActive(true, options: [])
|
|
}
|
|
|
|
private func installAudioObservers() {
|
|
let center = NotificationCenter.default
|
|
notificationTokens.append(center.addObserver(
|
|
forName: AVAudioSession.interruptionNotification,
|
|
object: AVAudioSession.sharedInstance(),
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt ?? 0
|
|
guard let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
if type == .began {
|
|
self.shouldResumeAfterInterruption = self.state.isSpeaking
|
|
self.pause()
|
|
} else if self.shouldResumeAfterInterruption {
|
|
self.shouldResumeAfterInterruption = false
|
|
self.resume()
|
|
}
|
|
}
|
|
})
|
|
notificationTokens.append(center.addObserver(
|
|
forName: AVAudioSession.routeChangeNotification,
|
|
object: AVAudioSession.sharedInstance(),
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let rawValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
AVAudioSession.RouteChangeReason(rawValue: rawValue) == .oldDeviceUnavailable else { return }
|
|
Task { @MainActor [weak self] in self?.pause() }
|
|
})
|
|
}
|
|
|
|
private func installRemoteControls() {
|
|
let commands = MPRemoteCommandCenter.shared()
|
|
commands.playCommand.isEnabled = true
|
|
commands.pauseCommand.isEnabled = true
|
|
commands.togglePlayPauseCommand.isEnabled = true
|
|
commands.nextTrackCommand.isEnabled = true
|
|
commands.previousTrackCommand.isEnabled = true
|
|
commands.playCommand.addTarget { [weak self] _ in
|
|
Task { @MainActor [weak self] in self?.resume() }
|
|
return .success
|
|
}
|
|
commands.pauseCommand.addTarget { [weak self] _ in
|
|
Task { @MainActor [weak self] in self?.pause() }
|
|
return .success
|
|
}
|
|
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.synthesizer.isPaused ? self.resume() : self.pause()
|
|
}
|
|
return .success
|
|
}
|
|
commands.nextTrackCommand.addTarget { [weak self] _ in
|
|
Task { @MainActor [weak self] in self?.skipToNextSentence() }
|
|
return .success
|
|
}
|
|
commands.previousTrackCommand.addTarget { [weak self] _ in
|
|
Task { @MainActor [weak self] in self?.skipToPreviousSentence() }
|
|
return .success
|
|
}
|
|
}
|
|
|
|
private func updateNowPlayingInfo() {
|
|
guard configuration.enablesRemoteControls else { return }
|
|
let book = contentProvider.speechBookDescriptor()
|
|
var info: [String: Any] = [
|
|
MPMediaItemPropertyTitle: book.title,
|
|
MPMediaItemPropertyArtist: "ReadViewSDK"
|
|
]
|
|
info[MPNowPlayingInfoPropertyPlaybackRate] = state.isSpeaking ? 1.0 : 0.0
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
}
|
|
}
|
|
|
|
extension RDSpeechReaderController: AVSpeechSynthesizerDelegate {
|
|
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
|
|
Task { @MainActor [weak self] in self?.handleDidStart() }
|
|
}
|
|
|
|
public nonisolated func speechSynthesizer(
|
|
_ synthesizer: AVSpeechSynthesizer,
|
|
willSpeakRangeOfSpeechString characterRange: NSRange,
|
|
utterance: AVSpeechUtterance
|
|
) {
|
|
Task { @MainActor [weak self, characterRange] in self?.handleWillSpeak(characterRange) }
|
|
}
|
|
|
|
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
|
|
Task { @MainActor [weak self] in self?.handleDidPause() }
|
|
}
|
|
|
|
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
|
|
Task { @MainActor [weak self] in self?.handleDidContinue() }
|
|
}
|
|
|
|
public nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
Task { @MainActor [weak self] in self?.handleDidFinish() }
|
|
}
|
|
}
|
|
|
|
private extension RDSpeechReaderController {
|
|
func handleDidStart() {
|
|
guard let currentUnit else { return }
|
|
progressStore?.saveSpeechLocation(currentUnit.location, for: contentProvider.speechBookDescriptor().identifier)
|
|
state = .speaking(currentUnit.location)
|
|
}
|
|
|
|
func handleWillSpeak(_ characterRange: NSRange) {
|
|
guard let currentUnit else { return }
|
|
var location = currentUnit.location
|
|
location.textOffset = currentUnit.textRange.location + characterRange.location
|
|
progressStore?.saveSpeechLocation(location, for: contentProvider.speechBookDescriptor().identifier)
|
|
delegate?.speechReaderController(self, willSpeak: RDSpeechSpokenRange(unit: currentUnit, range: characterRange))
|
|
}
|
|
|
|
func handleDidPause() {
|
|
guard let currentUnit else { return }
|
|
state = .paused(currentUnit.location)
|
|
}
|
|
|
|
func handleDidContinue() {
|
|
guard let currentUnit else { return }
|
|
state = .speaking(currentUnit.location)
|
|
}
|
|
|
|
func handleDidFinish() {
|
|
guard !isStopping, let finished = currentUnit else { return }
|
|
completedUnits.append(finished)
|
|
currentUnit = nil
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do { try await self.speakNextUnit() }
|
|
catch { self.state = .failed(error.localizedDescription) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension RDSpeechReaderState {
|
|
var isActive: Bool {
|
|
switch self {
|
|
case .preparing, .speaking, .paused: return true
|
|
case .idle, .finished, .failed: return false
|
|
}
|
|
}
|
|
|
|
var isSpeaking: Bool {
|
|
if case .speaking = self { return true }
|
|
return false
|
|
}
|
|
}
|