Visual_Novel_iOS/crush/Crush/Src/Components/Audio/SpeechModel.swift

338 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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