338 lines
9.6 KiB
Swift
338 lines
9.6 KiB
Swift
//
|
||
// 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()
|
||
*/
|