更新阅读器功能与示例
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import RDAIReaderView
|
||||
|
||||
/// Temporary speech content for an AI answer or summary. It deliberately uses
|
||||
/// an in-memory identifier and disables progress persistence by default.
|
||||
@MainActor
|
||||
public final class RDAISpeechContentProvider: RDSpeechContentProvider {
|
||||
private let descriptor: RDSpeechBookDescriptor
|
||||
private let units: [RDSpeechTextUnit]
|
||||
|
||||
public init(title: String, text: String, language: String? = nil) {
|
||||
let identifier = "rdai-speech-\(UUID().uuidString)"
|
||||
descriptor = RDSpeechBookDescriptor(identifier: identifier, title: title)
|
||||
let location = RDSpeechLocation(bookIdentifier: identifier, resourceIdentifier: "ai-result")
|
||||
units = RDSpeechTextPreprocessor.makeUnits(text: text, location: location, language: language)
|
||||
}
|
||||
|
||||
public func speechBookDescriptor() -> RDSpeechBookDescriptor { descriptor }
|
||||
|
||||
public func speechContentBatch(startingAt location: RDSpeechLocation?, limit: Int) async throws -> RDSpeechContentBatch {
|
||||
let start = location.flatMap { requested in units.firstIndex { $0.location.textOffset >= requested.textOffset } } ?? 0
|
||||
let batch = Array(units.dropFirst(start).prefix(max(1, limit)))
|
||||
let nextIndex = start + batch.count
|
||||
return RDSpeechContentBatch(units: batch, nextLocation: nextIndex < units.count ? units[nextIndex].location : nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public extension RDSpeechReaderController {
|
||||
convenience init(aiTitle: String, text: String, language: String? = nil, configuration: RDSpeechReaderConfiguration = .default) {
|
||||
let provider = RDAISpeechContentProvider(title: aiTitle, text: text, language: language)
|
||||
self.init(contentProvider: provider, configuration: configuration, progressStore: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDSpeechReaderView"
|
||||
s.module_name = "RDSpeechReaderView"
|
||||
s.version = "0.1.0"
|
||||
s.summary = "Text-to-speech playback primitives for ReadViewSDK readers"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://example.invalid/RDSpeechReaderView"
|
||||
s.author = { "readoor" => "ios@touchread.com" }
|
||||
s.source = { :path => "." }
|
||||
s.license = "MIT"
|
||||
s.source_files = "Sources/*.swift"
|
||||
s.frameworks = "AVFAudio", "NaturalLanguage", "MediaPlayer"
|
||||
s.requires_arc = true
|
||||
|
||||
s.subspec "AI" do |ai|
|
||||
ai.source_files = "AIBridge/**/*.swift"
|
||||
ai.dependency "RDAIReaderView/Core", "~> 0.1"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
# RDSpeechReaderView
|
||||
|
||||
`RDSpeechReaderView` provides bounded, on-device text-to-speech playback for
|
||||
ReadViewSDK readers. It uses `AVSpeechSynthesizer` and does not upload book
|
||||
content or create exportable audio files.
|
||||
|
||||
## Install
|
||||
|
||||
```ruby
|
||||
pod 'RDSpeechReaderView'
|
||||
pod 'RDPDFReaderView/Speech'
|
||||
pod 'RDEpubReaderView/Speech'
|
||||
```
|
||||
|
||||
The host app must enable the **Audio, AirPlay, and Picture in Picture**
|
||||
background mode to continue reading after it enters the background.
|
||||
|
||||
## Use with PDF
|
||||
|
||||
```swift
|
||||
let provider = reader.makeSpeechContentProvider()
|
||||
let speech = RDSpeechReaderController(contentProvider: provider)
|
||||
try await speech.start()
|
||||
```
|
||||
|
||||
The PDF adapter reads `RDPDFReaderTextRun` values supplied by the host or by
|
||||
PDFKit. Image-only PDFs require an OCR-backed provider before they can speak.
|
||||
|
||||
For automatic page navigation and temporary read-aloud highlighting, create a
|
||||
PDF session instead:
|
||||
|
||||
```swift
|
||||
let session = reader.makeSpeechSession()
|
||||
try await session.start(from: 0)
|
||||
```
|
||||
|
||||
## Use with EPUB
|
||||
|
||||
```swift
|
||||
let provider = reader.makeSpeechContentProvider()
|
||||
let speech = RDSpeechReaderController(contentProvider: provider)
|
||||
try await speech.start(from: nil)
|
||||
```
|
||||
|
||||
Speech locations use EPUB `href` plus a UTF-16 text offset. They remain stable
|
||||
when the user changes fonts or page size, unlike screen page numbers.
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
import AVFAudio
|
||||
|
||||
/// A stable position in a book. The reader-specific adapter owns the mapping
|
||||
/// between this value and its own pagination or document location model.
|
||||
public struct RDSpeechLocation: Codable, Equatable, Hashable, Sendable {
|
||||
public var bookIdentifier: String
|
||||
public var resourceIdentifier: String
|
||||
public var textOffset: Int
|
||||
public var anchor: String?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String,
|
||||
resourceIdentifier: String,
|
||||
textOffset: Int = 0,
|
||||
anchor: String? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.resourceIdentifier = resourceIdentifier
|
||||
self.textOffset = max(0, textOffset)
|
||||
self.anchor = anchor
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDSpeechBookDescriptor: Equatable, Sendable {
|
||||
public let identifier: String
|
||||
public let title: String
|
||||
|
||||
public init(identifier: String, title: String) {
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/// A text unit is normally one sentence. `textRange` is expressed in UTF-16
|
||||
/// offsets within the resource identified by `location`.
|
||||
public struct RDSpeechTextUnit: Equatable, Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let text: String
|
||||
public let location: RDSpeechLocation
|
||||
public let textRange: NSRange
|
||||
public let language: String?
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
text: String,
|
||||
location: RDSpeechLocation,
|
||||
textRange: NSRange,
|
||||
language: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.location = location
|
||||
self.textRange = textRange
|
||||
self.language = language
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded result keeps long books from being loaded into the speech queue
|
||||
/// all at once. `nextLocation` is nil after the final readable unit.
|
||||
public struct RDSpeechContentBatch: Sendable {
|
||||
public let units: [RDSpeechTextUnit]
|
||||
public let nextLocation: RDSpeechLocation?
|
||||
|
||||
public init(units: [RDSpeechTextUnit], nextLocation: RDSpeechLocation?) {
|
||||
self.units = units
|
||||
self.nextLocation = nextLocation
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol RDSpeechContentProvider: AnyObject {
|
||||
func speechBookDescriptor() -> RDSpeechBookDescriptor
|
||||
func speechContentBatch(
|
||||
startingAt location: RDSpeechLocation?,
|
||||
limit: Int
|
||||
) async throws -> RDSpeechContentBatch
|
||||
}
|
||||
|
||||
public enum RDSpeechReaderState: Equatable, Sendable {
|
||||
case idle
|
||||
case preparing
|
||||
case speaking(RDSpeechLocation)
|
||||
case paused(RDSpeechLocation)
|
||||
case finished
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public struct RDSpeechSpokenRange: Equatable, Sendable {
|
||||
public let unit: RDSpeechTextUnit
|
||||
/// UTF-16 range relative to `unit.text`.
|
||||
public let range: NSRange
|
||||
|
||||
public init(unit: RDSpeechTextUnit, range: NSRange) {
|
||||
self.unit = unit
|
||||
self.range = range
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol RDSpeechReaderControllerDelegate: AnyObject {
|
||||
func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState)
|
||||
func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange)
|
||||
}
|
||||
|
||||
public extension RDSpeechReaderControllerDelegate {
|
||||
func speechReaderController(_ controller: RDSpeechReaderController, didChange state: RDSpeechReaderState) {}
|
||||
func speechReaderController(_ controller: RDSpeechReaderController, willSpeak range: RDSpeechSpokenRange) {}
|
||||
}
|
||||
|
||||
public struct RDSpeechReaderConfiguration: Sendable {
|
||||
public var rate: Float
|
||||
public var voiceIdentifier: String?
|
||||
public var defaultLanguage: String
|
||||
public var batchSize: Int
|
||||
public var configuresAudioSession: Bool
|
||||
public var ducksOtherAudio: Bool
|
||||
public var enablesRemoteControls: Bool
|
||||
|
||||
public init(
|
||||
rate: Float = 0.48,
|
||||
voiceIdentifier: String? = nil,
|
||||
defaultLanguage: String = "zh-CN",
|
||||
batchSize: Int = 24,
|
||||
configuresAudioSession: Bool = true,
|
||||
ducksOtherAudio: Bool = false,
|
||||
enablesRemoteControls: Bool = true
|
||||
) {
|
||||
self.rate = min(max(rate, 0.0), 1.0)
|
||||
self.voiceIdentifier = voiceIdentifier
|
||||
self.defaultLanguage = defaultLanguage
|
||||
self.batchSize = max(1, batchSize)
|
||||
self.configuresAudioSession = configuresAudioSession
|
||||
self.ducksOtherAudio = ducksOtherAudio
|
||||
self.enablesRemoteControls = enablesRemoteControls
|
||||
}
|
||||
|
||||
public static let `default` = RDSpeechReaderConfiguration()
|
||||
}
|
||||
|
||||
/// Stores only the book identifier and reading location. Book text is never
|
||||
/// persisted by the speech framework.
|
||||
public protocol RDSpeechProgressPersisting: AnyObject {
|
||||
func restoreSpeechLocation(for bookIdentifier: String) -> RDSpeechLocation?
|
||||
func saveSpeechLocation(_ location: RDSpeechLocation, for bookIdentifier: String)
|
||||
func clearSpeechLocation(for bookIdentifier: String)
|
||||
}
|
||||
|
||||
public final class RDSpeechUserDefaultsProgressStore: RDSpeechProgressPersisting {
|
||||
private let defaults: UserDefaults
|
||||
private let keyPrefix: String
|
||||
|
||||
public init(defaults: UserDefaults = .standard, keyPrefix: String = "com.readoor.rdspeech.progress.") {
|
||||
self.defaults = defaults
|
||||
self.keyPrefix = keyPrefix
|
||||
}
|
||||
|
||||
public func restoreSpeechLocation(for bookIdentifier: String) -> RDSpeechLocation? {
|
||||
guard let data = defaults.data(forKey: key(for: bookIdentifier)) else { return nil }
|
||||
return try? JSONDecoder().decode(RDSpeechLocation.self, from: data)
|
||||
}
|
||||
|
||||
public func saveSpeechLocation(_ location: RDSpeechLocation, for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(location) else { return }
|
||||
defaults.set(data, forKey: key(for: bookIdentifier))
|
||||
}
|
||||
|
||||
public func clearSpeechLocation(for bookIdentifier: String) {
|
||||
defaults.removeObject(forKey: key(for: bookIdentifier))
|
||||
}
|
||||
|
||||
private func key(for bookIdentifier: String) -> String { keyPrefix + bookIdentifier }
|
||||
}
|
||||
|
||||
public struct RDSpeechVoice: Equatable, Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let language: String
|
||||
|
||||
public init(voice: AVSpeechSynthesisVoice) {
|
||||
id = voice.identifier
|
||||
name = voice.name
|
||||
language = voice.language
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDSpeechReaderError: LocalizedError, Equatable {
|
||||
case noReadableContent
|
||||
case invalidContentLocation
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noReadableContent:
|
||||
return "The selected content has no readable text."
|
||||
case .invalidContentLocation:
|
||||
return "The selected speech location is no longer available."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import UIKit
|
||||
|
||||
/// A small host-owned control strip. It deliberately exposes actions as
|
||||
/// closures so reader-specific sessions can keep navigation and highlights in
|
||||
/// sync without coupling this view to a document format.
|
||||
public final class RDSpeechReaderControlView: UIView {
|
||||
public var onTogglePlayback: (() -> Void)?
|
||||
public var onStop: (() -> Void)?
|
||||
public var onChangeRate: (() -> Void)?
|
||||
public var onPreviousSentence: (() -> Void)?
|
||||
public var onNextSentence: (() -> Void)?
|
||||
|
||||
private let previousButton = UIButton(type: .system)
|
||||
private let playPauseButton = UIButton(type: .system)
|
||||
private let nextButton = UIButton(type: .system)
|
||||
private let stopButton = UIButton(type: .system)
|
||||
private let rateButton = UIButton(type: .system)
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.96)
|
||||
layer.cornerRadius = 18
|
||||
layer.cornerCurve = .continuous
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.12
|
||||
layer.shadowRadius = 10
|
||||
layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [previousButton, playPauseButton, nextButton, stopButton, rateButton])
|
||||
stack.axis = .horizontal
|
||||
stack.alignment = .center
|
||||
stack.spacing = 4
|
||||
addSubview(stack)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||||
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor, constant: 6),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6)
|
||||
])
|
||||
|
||||
configure(previousButton, identifier: "rd.speech.previous")
|
||||
configure(playPauseButton, identifier: "rd.speech.playPause")
|
||||
configure(nextButton, identifier: "rd.speech.next")
|
||||
configure(stopButton, identifier: "rd.speech.stop")
|
||||
configure(rateButton, identifier: "rd.speech.rate")
|
||||
previousButton.setImage(UIImage(systemName: "backward.fill"), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "forward.fill"), for: .normal)
|
||||
stopButton.setImage(UIImage(systemName: "stop.fill"), for: .normal)
|
||||
rateButton.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
|
||||
previousButton.addTarget(self, action: #selector(previousSentence), for: .touchUpInside)
|
||||
playPauseButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside)
|
||||
nextButton.addTarget(self, action: #selector(nextSentence), for: .touchUpInside)
|
||||
stopButton.addTarget(self, action: #selector(stop), for: .touchUpInside)
|
||||
rateButton.addTarget(self, action: #selector(changeRate), for: .touchUpInside)
|
||||
update(state: .idle, rate: 0.48)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public func update(state: RDSpeechReaderState, rate: Float) {
|
||||
let isPaused: Bool
|
||||
switch state {
|
||||
case .speaking:
|
||||
isPaused = false
|
||||
playPauseButton.setImage(UIImage(systemName: "pause.fill"), for: .normal)
|
||||
playPauseButton.accessibilityLabel = "暂停朗读"
|
||||
case .paused:
|
||||
isPaused = true
|
||||
playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
|
||||
playPauseButton.accessibilityLabel = "继续朗读"
|
||||
default:
|
||||
isPaused = false
|
||||
playPauseButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
|
||||
playPauseButton.accessibilityLabel = "开始朗读"
|
||||
}
|
||||
stopButton.isEnabled = isPaused || state.isActive
|
||||
previousButton.isEnabled = state.isActive
|
||||
nextButton.isEnabled = state.isActive
|
||||
rateButton.setTitle(String(format: "%.2gx", rate), for: .normal)
|
||||
}
|
||||
|
||||
private func configure(_ button: UIButton, identifier: String) {
|
||||
button.tintColor = .label
|
||||
button.accessibilityIdentifier = identifier
|
||||
button.widthAnchor.constraint(equalToConstant: 42).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 36).isActive = true
|
||||
}
|
||||
|
||||
@objc private func togglePlayback() { onTogglePlayback?() }
|
||||
@objc private func previousSentence() { onPreviousSentence?() }
|
||||
@objc private func nextSentence() { onNextSentence?() }
|
||||
@objc private func stop() { onStop?() }
|
||||
@objc private func changeRate() { onChangeRate?() }
|
||||
}
|
||||
|
||||
private extension RDSpeechReaderState {
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .speaking, .paused:
|
||||
return true
|
||||
case .idle, .finished, .failed:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
public enum RDSpeechTextPreprocessor {
|
||||
/// Splits one resource into sentence-sized units while preserving UTF-16
|
||||
/// offsets, so adapters can map speech progress back to their reader UI.
|
||||
public static func makeUnits(
|
||||
text: String,
|
||||
location: RDSpeechLocation,
|
||||
language: String? = nil
|
||||
) -> [RDSpeechTextUnit] {
|
||||
// Keep the source text unchanged here. Reader adapters use UTF-16
|
||||
// offsets to drive their highlights, so whitespace normalization would
|
||||
// make the returned ranges drift away from the rendered document.
|
||||
let source = text
|
||||
guard !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] }
|
||||
|
||||
let resolvedLanguage = language ?? detectLanguage(in: source)
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
tokenizer.string = source
|
||||
let fullRange = source.startIndex..<source.endIndex
|
||||
var units: [RDSpeechTextUnit] = []
|
||||
|
||||
tokenizer.enumerateTokens(in: fullRange) { range, _ in
|
||||
let sentence = String(source[range])
|
||||
let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return true }
|
||||
|
||||
let leadingUTF16Count = sentence.utf16.count - sentence.drop(while: { $0.isWhitespace || $0.isNewline }).utf16.count
|
||||
let sentenceRange = NSRange(range, in: source)
|
||||
let textRange = NSRange(
|
||||
location: location.textOffset + sentenceRange.location + leadingUTF16Count,
|
||||
length: trimmed.utf16.count
|
||||
)
|
||||
var unitLocation = location
|
||||
unitLocation.textOffset = textRange.location
|
||||
units.append(
|
||||
RDSpeechTextUnit(
|
||||
text: trimmed,
|
||||
location: unitLocation,
|
||||
textRange: textRange,
|
||||
language: resolvedLanguage
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if units.isEmpty {
|
||||
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let range = NSRange(location: location.textOffset, length: trimmed.utf16.count)
|
||||
return [RDSpeechTextUnit(text: trimmed, location: location, textRange: range, language: resolvedLanguage)]
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
public static func normalize(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "\\u{00A0}", with: " ")
|
||||
.replacingOccurrences(of: "\\r\\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\\r", with: "\\n")
|
||||
.replacingOccurrences(of: "[\\t ]+", with: " ", options: .regularExpression)
|
||||
.replacingOccurrences(of: " *\\n *", with: "\\n", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func detectLanguage(in text: String) -> String? {
|
||||
let recognizer = NLLanguageRecognizer()
|
||||
recognizer.processString(text)
|
||||
return recognizer.dominantLanguage?.rawValue
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user