mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
@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<Void, Never>?
|
||||
|
||||
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..<end)) }
|
||||
}
|
||||
let deliveredEnd = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
|
||||
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
|
||||
if deliveredEnd >= 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<Void, Never>?
|
||||
private var httpPrefetchTasks: [Task<Void, Never>] = []
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user