@preconcurrency import AVFoundation @preconcurrency import MediaPlayer import SwiftUI import UniformTypeIdentifiers private final class IncrementalAudioResourceLoader: NSObject, AVAssetResourceLoaderDelegate, @unchecked Sendable { private let queue = DispatchQueue(label: "io.legado.tts.stream-loader") private let contentType: String? private let expectedLength: Int64 private var data = Data() private var pending: [AVAssetResourceLoadingRequest] = [] private var finished = false private var failure: Error? private var consumeTask: Task? init(stream: HttpTTSAudioStream) { contentType = stream.contentType expectedLength = stream.expectedContentLength ?? 0 super.init() consumeTask = Task { [weak self] in do { for try await chunk in stream.chunks { guard let self else { return } self.queue.async { [weak self] in self?.data.append(chunk) self?.processPendingRequests() } } self?.queue.async { [weak self] in self?.finished = true self?.processPendingRequests() } } catch { self?.queue.async { [weak self] in self?.failure = error self?.finished = true self?.processPendingRequests() } } } } deinit { consumeTask?.cancel() } func cancel() { consumeTask?.cancel() queue.async { [weak self] in guard let self, !finished else { return } failure = CancellationError() finished = true processPendingRequests() } } func install(on asset: AVURLAsset) { asset.resourceLoader.setDelegate(self, queue: queue) } func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest ) -> Bool { pending.append(loadingRequest) processPendingRequests() return true } func resourceLoader( _ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest ) { pending.removeAll { $0 === loadingRequest } } private func processPendingRequests() { pending.removeAll { request in if let info = request.contentInformationRequest { info.contentType = contentType.flatMap { UTType(mimeType: $0)?.identifier } info.contentLength = expectedLength info.isByteRangeAccessSupported = false } if let failure { request.finishLoading(with: failure) return true } guard let dataRequest = request.dataRequest else { if finished { request.finishLoading(); return true } return false } let offset = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset)) if offset < data.count { let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength let end = min(data.count, requestedEnd) if end > offset { dataRequest.respond(with: data.subdata(in: offset..= requestedEnd || finished { request.finishLoading() return true } return false } } } @MainActor final class ReaderSpeechController: NSObject, ObservableObject, @preconcurrency AVSpeechSynthesizerDelegate, @preconcurrency AVAudioPlayerDelegate { @Published private(set) var isSpeaking = false @Published private(set) var isPaused = false @Published private(set) var errorMessage: String? @Published private(set) var completionCount = 0 @Published private(set) var currentSegmentIndex = 0 private let synthesizer = AVSpeechSynthesizer() private let httpService = HttpTTSService() private var audioPlayer: AVAudioPlayer? private var streamPlayer: AVPlayer? private var streamLoader: IncrementalAudioResourceLoader? private var streamCompletionToken: NSObjectProtocol? private var httpTask: Task? private var httpPrefetchTasks: [Task] = [] private var httpSegments: [String] = [] private var httpSegmentIndex = 0 private var httpEngine: HttpTTS? private var systemSegments: [String] = [] private var systemSegmentIndex = 0 private var segmentIndexOffset = 0 private var followsSystemRate = true private var speechRate = 5 private var readByPage = false private var streamsHTTPAudio = true private var voiceIdentifier = "" private var continuesAcrossChapters = false private var notificationTokens: [NSObjectProtocol] = [] nonisolated(unsafe) private var remoteCommandTargets: [(MPRemoteCommand, Any)] = [] private var resumesAfterInterruption = false private var isChangingSystemSegment = false private var ignoresAudioFocus = false private var pausesForPhoneCalls = false private var mediaButtonPerNext = false private var bookName = "" private var chapterTitle = "" private var author = "" var onNextChapter: (() -> Void)? var onPreviousChapter: (() -> Void)? var onRemoteStop: (() -> Void)? override init() { super.init() synthesizer.delegate = self observeAudioSession() installRemoteCommands() } deinit { notificationTokens.forEach(NotificationCenter.default.removeObserver) remoteCommandTargets.forEach { command, target in command.removeTarget(target) } } func toggle(text: String, continuesAcrossChapters: Bool = false) { if isSpeaking || isPaused { stop() return } speak(text, continuesAcrossChapters: continuesAcrossChapters) } func toggleHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) { if isSpeaking || isPaused { stop() return } speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters) } func stop() { synthesizer.stopSpeaking(at: .word) audioPlayer?.stop() audioPlayer = nil streamPlayer?.pause() streamLoader?.cancel() streamPlayer = nil streamLoader = nil if let streamCompletionToken { NotificationCenter.default.removeObserver(streamCompletionToken) self.streamCompletionToken = nil } httpTask?.cancel() httpTask = nil httpPrefetchTasks.forEach { $0.cancel() } httpPrefetchTasks.removeAll() httpSegments = [] httpSegmentIndex = 0 httpEngine = nil systemSegments = [] systemSegmentIndex = 0 segmentIndexOffset = 0 continuesAcrossChapters = false isSpeaking = false isPaused = false updateNowPlaying() try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } func configure( followsSystemRate: Bool, speechRate: Int, readByPage: Bool, streamsHTTPAudio: Bool = true, voiceIdentifier: String = "", ignoresAudioFocus: Bool = false, pausesForPhoneCalls: Bool = false, mediaButtonPerNext: Bool = false, bookName: String = "", chapterTitle: String = "", author: String = "" ) { self.followsSystemRate = followsSystemRate self.speechRate = min(max(speechRate, 0), 45) self.readByPage = readByPage self.streamsHTTPAudio = streamsHTTPAudio self.voiceIdentifier = voiceIdentifier self.ignoresAudioFocus = ignoresAudioFocus self.pausesForPhoneCalls = pausesForPhoneCalls self.mediaButtonPerNext = mediaButtonPerNext self.bookName = bookName self.chapterTitle = chapterTitle self.author = author } func speak(_ text: String, continuesAcrossChapters: Bool = false, segmentIndexOffset: Int = 0) { stop() errorMessage = nil activateAudioSession() self.continuesAcrossChapters = continuesAcrossChapters if synthesizer.isSpeaking || synthesizer.isPaused { synthesizer.stopSpeaking(at: .immediate) } guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } systemSegments = segments(for: text) guard !systemSegments.isEmpty else { return } systemSegmentIndex = 0 self.segmentIndexOffset = max(segmentIndexOffset, 0) playCurrentSystemSegment() } func speakHTTP( engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false, segmentIndexOffset: Int = 0 ) { stop() errorMessage = nil activateAudioSession() self.continuesAcrossChapters = continuesAcrossChapters httpEngine = engine httpSegments = segments(for: text) guard !httpSegments.isEmpty else { return } httpSegmentIndex = 0 self.segmentIndexOffset = max(segmentIndexOffset, 0) isSpeaking = true playCurrentHTTPSegment() prefetchHTTP(engine: engine, texts: Array(httpSegments.dropFirst())) } func prefetchHTTP(engine: HttpTTS, text: String, limit: Int = 10) { let values = Array(segments(for: text).prefix(max(limit, 0))) prefetchHTTP(engine: engine, texts: values) } func pauseOrResume(text: String, continuesAcrossChapters: Bool = false) { if isPaused { resumePlayback() } else if isSpeaking { pausePlayback() } else { speak(text, continuesAcrossChapters: continuesAcrossChapters) } } func pauseOrResumeHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) { if audioPlayer != nil { pauseOrResume(text: text, continuesAcrossChapters: continuesAcrossChapters) } else if isSpeaking { httpTask?.cancel() isSpeaking = false isPaused = true } else if isPaused, httpEngine != nil { isPaused = false isSpeaking = true playCurrentHTTPSegment() } else { speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters) } } private func segments(for text: String) -> [String] { let values = readByPage ? [text] : text.components(separatedBy: .newlines) return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } } private func activateAudioSession() { let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .spokenAudio, options: ignoresAudioFocus ? [.mixWithOthers] : []) try? session.setActive(true) } private func pausePlayback() { if let audioPlayer, audioPlayer.isPlaying { audioPlayer.pause() } else if let streamPlayer { streamPlayer.pause() } else if synthesizer.isSpeaking { synthesizer.pauseSpeaking(at: .word) } else if httpTask != nil { httpTask?.cancel() httpTask = nil } else { return } isPaused = true isSpeaking = false updateNowPlaying() } private func resumePlayback() { activateAudioSession() if let audioPlayer { isSpeaking = audioPlayer.play() } else if let streamPlayer { streamPlayer.play() isSpeaking = true } else if synthesizer.isPaused { synthesizer.continueSpeaking() isSpeaking = true } else if httpEngine != nil, httpSegments.indices.contains(httpSegmentIndex) { isSpeaking = true playCurrentHTTPSegment() } else { return } isPaused = false updateNowPlaying() } private func observeAudioSession() { let center = NotificationCenter.default notificationTokens.append(center.addObserver( forName: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] note in let type = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt let options = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 Task { @MainActor in self?.handleAudioInterruption(type: type, options: options) } }) notificationTokens.append(center.addObserver( forName: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance(), queue: .main ) { [weak self] note in let reason = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt Task { @MainActor in self?.handleRouteChange(reason: reason) } }) } private func handleAudioInterruption(type raw: UInt?, options rawOptions: UInt) { guard let raw, let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } switch type { case .began: if ignoresAudioFocus && !pausesForPhoneCalls { return } resumesAfterInterruption = isSpeaking if isSpeaking { pausePlayback() } case .ended: if ignoresAudioFocus && !pausesForPhoneCalls { return } let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions) if resumesAfterInterruption, options.contains(.shouldResume) { resumePlayback() } resumesAfterInterruption = false @unknown default: break } } private func handleRouteChange(reason raw: UInt?) { guard let raw, AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable else { return } if isSpeaking { pausePlayback() } } private func installRemoteCommands() { let center = MPRemoteCommandCenter.shared() center.playCommand.isEnabled = true center.pauseCommand.isEnabled = true center.togglePlayPauseCommand.isEnabled = true center.stopCommand.isEnabled = true center.nextTrackCommand.isEnabled = true center.previousTrackCommand.isEnabled = true addRemoteTarget(center.playCommand) { $0.resumePlayback() } addRemoteTarget(center.pauseCommand) { $0.pausePlayback() } addRemoteTarget(center.togglePlayPauseCommand) { controller in controller.isPaused ? controller.resumePlayback() : controller.pausePlayback() } addRemoteTarget(center.stopCommand) { controller in controller.stop() controller.onRemoteStop?() } addRemoteTarget(center.nextTrackCommand) { $0.skipForward(usesChapterPreference: true) } addRemoteTarget(center.previousTrackCommand) { $0.skipBackward(usesChapterPreference: true) } } private func addRemoteTarget(_ command: MPRemoteCommand, action: @escaping @MainActor (ReaderSpeechController) -> Void) { let target = command.addTarget { [weak self] _ in guard self != nil else { return .noSuchContent } Task { @MainActor [weak self] in guard let self else { return } action(self) } return .success } remoteCommandTargets.append((command, target)) } func nextSegment() { guard isSpeaking || isPaused else { return } skipForward(usesChapterPreference: false) } func previousSegment() { guard isSpeaking || isPaused else { return } skipBackward(usesChapterPreference: false) } private func playCurrentSystemSegment() { guard systemSegments.indices.contains(systemSegmentIndex) else { finishNaturally() return } let utterance = AVSpeechUtterance(string: systemSegments[systemSegmentIndex]) utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier) ?? AVSpeechSynthesisVoice(language: "zh-CN") let multiplier = followsSystemRate ? 1 : Float(speechRate + 5) / 10 utterance.rate = min( max(AVSpeechUtteranceDefaultSpeechRate * multiplier, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate ) utterance.pitchMultiplier = 1 synthesizer.speak(utterance) currentSegmentIndex = segmentIndexOffset + systemSegmentIndex isSpeaking = true isPaused = false updateNowPlaying() } private func skipForward(usesChapterPreference: Bool) { if usesChapterPreference && mediaButtonPerNext { onNextChapter?() } else if httpEngine != nil, httpSegmentIndex + 1 < httpSegments.count { stopCurrentHTTPPlayback() httpSegmentIndex += 1 playCurrentHTTPSegment() } else if !systemSegments.isEmpty, systemSegmentIndex + 1 < systemSegments.count { changeSystemSegment(to: systemSegmentIndex + 1) } else { onNextChapter?() } } private func skipBackward(usesChapterPreference: Bool) { if usesChapterPreference && mediaButtonPerNext { onPreviousChapter?() } else if httpEngine != nil, httpSegmentIndex > 0 { stopCurrentHTTPPlayback() httpSegmentIndex -= 1 playCurrentHTTPSegment() } else if !systemSegments.isEmpty, systemSegmentIndex > 0 { changeSystemSegment(to: systemSegmentIndex - 1) } else { onPreviousChapter?() } } private func stopCurrentHTTPPlayback() { audioPlayer?.stop() audioPlayer = nil streamPlayer?.pause() streamLoader?.cancel() streamPlayer = nil streamLoader = nil if let streamCompletionToken { NotificationCenter.default.removeObserver(streamCompletionToken) self.streamCompletionToken = nil } httpTask?.cancel() httpTask = nil } private func changeSystemSegment(to index: Int) { isChangingSystemSegment = true synthesizer.stopSpeaking(at: .immediate) systemSegmentIndex = index playCurrentSystemSegment() DispatchQueue.main.async { [weak self] in self?.isChangingSystemSegment = false } } private func updateNowPlaying() { guard isSpeaking || isPaused else { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil return } MPNowPlayingInfoCenter.default().nowPlayingInfo = [ MPMediaItemPropertyTitle: chapterTitle, MPMediaItemPropertyAlbumTitle: bookName, MPMediaItemPropertyArtist: author, MPNowPlayingInfoPropertyPlaybackRate: isSpeaking ? 1 : 0 ] } private func playCurrentHTTPSegment() { guard let engine = httpEngine, httpSegments.indices.contains(httpSegmentIndex) else { stop() return } let text = httpSegments[httpSegmentIndex] currentSegmentIndex = segmentIndexOffset + httpSegmentIndex let rate = speechRate + 5 httpTask?.cancel() httpTask = Task { [weak self] in guard let self else { return } do { if streamsHTTPAudio { let stream = try await httpService.audioStream(engine: engine, text: text, speechRate: rate) try Task.checkCancellation() if stream.isIncremental, stream.expectedContentLength ?? 0 > 0 { playIncrementalHTTPStream(stream) } else { var data = Data() for try await chunk in stream.chunks { data.append(chunk) } try Task.checkCancellation() try playBufferedHTTPAudio(data) } } else { let audio = try await httpService.audio(engine: engine, text: text, speechRate: rate) try Task.checkCancellation() try playBufferedHTTPAudio(audio.data) } isPaused = false updateNowPlaying() if !isSpeaking { throw CocoaError(.fileReadCorruptFile) } } catch is CancellationError { return } catch { errorMessage = error.localizedDescription stop() } } } private func playBufferedHTTPAudio(_ data: Data) throws { let player = try AVAudioPlayer(data: data) player.delegate = self player.prepareToPlay() audioPlayer = player isSpeaking = player.play() } private func playIncrementalHTTPStream(_ stream: HttpTTSAudioStream) { let url = URL(string: "legado-tts-stream://audio/\(UUID().uuidString)")! let asset = AVURLAsset(url: url) let loader = IncrementalAudioResourceLoader(stream: stream) loader.install(on: asset) let item = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: item) streamLoader = loader streamPlayer = player streamCompletionToken = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { [weak self] _ in Task { @MainActor in self?.incrementalPlayerDidFinish() } } player.play() isSpeaking = true } private func incrementalPlayerDidFinish() { stopCurrentHTTPPlayback() httpSegmentIndex += 1 if httpSegmentIndex < httpSegments.count { playCurrentHTTPSegment() } else { finishNaturally() } } private func prefetchHTTP(engine: HttpTTS, texts: [String]) { guard !texts.isEmpty else { return } let rate = speechRate + 5 let task = Task { [weak self] in guard let self else { return } for text in texts { guard !Task.isCancelled else { return } _ = try? await self.httpService.audio(engine: engine, text: text, speechRate: rate) } } httpPrefetchTasks.append(task) } func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { audioPlayer = nil guard flag else { errorMessage = "HTTP TTS 音频播放失败" stop() return } httpSegmentIndex += 1 if httpSegmentIndex < httpSegments.count { playCurrentHTTPSegment() } else { finishNaturally() } } private func finishNaturally() { let shouldContinue = continuesAcrossChapters stop() if shouldContinue { completionCount += 1 } } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { guard !isChangingSystemSegment else { return } systemSegmentIndex += 1 if systemSegmentIndex < systemSegments.count { playCurrentSystemSegment() } else { finishNaturally() } } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { guard !isChangingSystemSegment else { return } guard !synthesizer.isSpeaking else { return } isSpeaking = false isPaused = false updateNowPlaying() } }