Files
ReadViewSDK/Sources/RDSpeechReaderView/Sources/RDSpeechReaderController.swift
T
2026-07-27 21:43:13 +08:00

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