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()
|
|||
|
|
*/
|