更新阅读器功能与示例

This commit is contained in:
shen
2026-07-27 21:43:13 +08:00
parent 68d9363f0a
commit 9392027106
78 changed files with 8780 additions and 2084 deletions
@@ -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
+46
View File
@@ -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
}
}