// // SpeechModel.swift // Crush // // Created by Leon on 2025/8/1. // import Foundation import AVFoundation // MARK: - Enums enum SpeechPlayState: Int { case `default` // Default state case playing // Playing case complete // Playback completed case failed // Playback failed } enum SpeechLoadState: Int { case `default` // Default state case loading // Loading case complete // Loading completed case failed // Loading failed } enum EGAudioFileType: Int { case file // Local audio file case url // Remote audio file case base64 // Base64 encoded audio data } class SpeechModel: NSObject, AVAudioPlayerDelegate { // MARK: - Properties var playState: SpeechPlayState = .default var loadState: SpeechLoadState = .default var fileType: EGAudioFileType = .file var path: String = "" var canAutoPlay: Bool = false var stateChangedBlock: ((SpeechModel) -> Void)? var timerChangedBlock: ((TimeInterval) -> Void)? var progressChangedBlock: ((TimeInterval, TimeInterval) -> Void)? // 播放进度更新block (当前时间, 总时长) var audioDuration: TimeInterval = 0.0 // 音频总时长(秒) private var player: AVAudioPlayer? private var progressTimer: Timer? // MARK: - Computed Properties /// 时长 (秒x10) var audioTime: Int { guard let player = player else { return 0 } let isOtherPlaying = AVAudioSession.sharedInstance().isOtherAudioPlaying return Int(ceil(player.duration * 10)) + (isOtherPlaying ? 15 : 5) } var isPlaying: Bool { return player?.isPlaying ?? false } var currentTime: TimeInterval { get { player?.currentTime ?? 0 } set { player?.currentTime = newValue } } class func modelWith(path:String?) -> SpeechModel?{ guard let pathValid = path else{ return nil } let model = SpeechModel() model.path = pathValid model.fileType = .url return model } class func modelWithBase64String(_ base64String: String?) -> SpeechModel? { guard let base64Valid = base64String else { return nil } let model = SpeechModel() model.path = base64Valid model.fileType = .base64 return model } // MARK: - Private Methods private func loadFileData() { guard !path.isEmpty else { dlog("❌ SpeechModel's file path is nil") setupDefault() return } loadState = .loading stateChanged() DispatchQueue.global().async { [weak self] in guard let self = self else { return } do { let audioData = try Data(contentsOf: URL(fileURLWithPath: self.path), options: .mappedIfSafe) self.player = try AVAudioPlayer(data: audioData) self.player?.delegate = self self.player?.prepareToPlay() self.audioDuration = self.player?.duration ?? 0.0 self.loadState = .complete self.stateChanged() } catch { self.loadState = .failed self.stateChanged() } } } private func loadUrlData() { guard !path.isEmpty else { dlog("❌ SpeechModel's path is nil") setupDefault() return } loadState = .loading stateChanged() DispatchQueue.global().async { [weak self] in guard let self = self, let url = URL(string: self.path) else { return } do { let audioData = try Data(contentsOf: url, options: .mappedIfSafe) self.player = try AVAudioPlayer(data: audioData) self.player?.delegate = self self.player?.prepareToPlay() self.audioDuration = self.player?.duration ?? 0.0 self.loadState = .complete self.stateChanged() } catch { self.loadState = .failed self.stateChanged() } } } private func loadBase64Data() { guard !path.isEmpty else { dlog("❌ SpeechModel's base64 string is nil") setupDefault() return } loadState = .loading stateChanged() DispatchQueue.global().async { [weak self] in guard let self = self else { return } do { guard let audioData = Data(base64Encoded: self.path) else { DispatchQueue.main.async { self.loadState = .failed self.stateChanged() } return } self.player = try AVAudioPlayer(data: audioData) self.player?.delegate = self self.player?.prepareToPlay() self.audioDuration = self.player?.duration ?? 0.0 self.loadState = .complete self.stateChanged() } catch { self.loadState = .failed self.stateChanged() } } } private func stateChanged() { DispatchQueue.main.async { [weak self] in guard let self = self, let block = self.stateChangedBlock else { return } block(self) } } private func checkPlayState() -> Bool { return player != nil } private func setupDefault() { playState = .default loadState = .default audioDuration = 0.0 stateChanged() } // MARK: - Progress Update Methods private func startProgressUpdate() { stopProgressUpdate() progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in self?.updateProgress() } } private func stopProgressUpdate() { progressTimer?.invalidate() progressTimer = nil } private func updateProgress() { guard let player = player, player.isPlaying else { return } let currentTime = player.currentTime let duration = player.duration DispatchQueue.main.async { [weak self] in guard let self = self, let block = self.progressChangedBlock else { return } block(currentTime, duration) } } // MARK: - Public Methods func refreshPath(path: String){ self.path = path } func loadSpeechFile() { guard !path.isEmpty else { setupDefault() return } guard loadState != .loading else { return } if player != nil { loadState = .complete stateChanged() return } switch fileType { case .file: loadFileData() case .url: loadUrlData() case .base64: loadBase64Data() } } func startPlay() { guard checkPlayState() else { return } guard let player = player, !player.isPlaying else { return } do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .default, options: [ .allowBluetooth, .defaultToSpeaker, .allowAirPlay, .duckOthers, .mixWithOthers ]) try session.setActive(true, options: []) player.currentTime = 0 player.play() playState = .playing startProgressUpdate() stateChanged() } catch { playState = .failed stateChanged() } } func stopPlay() { guard checkPlayState() else { setupDefault() return } if player?.isPlaying == true { player?.stop() playState = .complete stateChanged() stopProgressUpdate() // 可选:停用会话,避免占用音频通道 try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } } func needLoadData() -> Bool { return player == nil && loadState != .loading } // MARK: - AVAudioPlayerDelegate func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { dlog("audioPlayerDidFinishPlaying") playState = .complete stateChanged() stopProgressUpdate() } func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { dlog("audioPlayerDecodeErrorDidOccur") playState = .failed stateChanged() stopProgressUpdate() } // MARK: - Deinit deinit { print("♻️ SpeechModel dealloc") if player?.isPlaying == true { player?.stop() } stopProgressUpdate() } } /* 使用示例: // 创建 SpeechModel 实例 let speechModel = SpeechModel.modelWith(path: "audio_url")! // 设置播放进度更新回调 speechModel.progressChangedBlock = { currentTime, totalDuration in let progress = currentTime / totalDuration print("播放进度: \(Int(currentTime))s / \(Int(totalDuration))s (\(Int(progress * 100))%)") } // 加载音频文件 speechModel.loadSpeechFile() // 开始播放 speechModel.startPlay() // 获取音频总时长 let duration = speechModel.audioDuration print("音频总时长: \(duration) 秒") // 停止播放 speechModel.stopPlay() */