首次提交
100
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Al/Algorithm.c
generated
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// Algorithm.c
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/11/9.
|
||||
//
|
||||
|
||||
#include "Algorithm.h"
|
||||
#import <math.h>
|
||||
//modify by xuguangjian
|
||||
#define PTS_version "20231021001"
|
||||
|
||||
#define min(a, b) ((a) < (b) ? (a) : (b))
|
||||
#define max(a, b) ((a) > (b) ? (a) : (b))
|
||||
|
||||
|
||||
double pitchToToneC(double pitch) {
|
||||
double eps = 1e-6;
|
||||
return (fmax(0, log(pitch / 55 + eps) / log(2))) * 12;
|
||||
}
|
||||
|
||||
float calculedScoreC(double voicePitch, double stdPitch, int scoreLevel, int scoreCompensationOffset) {
|
||||
if (voicePitch <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if(stdPitch <= 0){
|
||||
return 0;
|
||||
}
|
||||
|
||||
if(scoreLevel<=0){
|
||||
scoreLevel = 1;
|
||||
}else if(scoreLevel > 100){
|
||||
scoreLevel = 100;
|
||||
}
|
||||
|
||||
if(scoreCompensationOffset<0){
|
||||
scoreCompensationOffset = 0;
|
||||
}else if(scoreCompensationOffset > 100){
|
||||
scoreCompensationOffset = 100;
|
||||
}
|
||||
|
||||
double stdTone = pitchToToneC(stdPitch);
|
||||
double voiceTone = pitchToToneC(voicePitch);
|
||||
|
||||
float match = 1 - (float)scoreLevel / 100 * fabs(voiceTone - stdTone) + (float)scoreCompensationOffset / 100;
|
||||
float rate = 1 + ((float)scoreLevel/(float)50);
|
||||
|
||||
match = match * 100 * rate;
|
||||
|
||||
match = max(0, match);
|
||||
match = min(100, match);
|
||||
return match;
|
||||
}
|
||||
|
||||
static double n;
|
||||
static double offset;
|
||||
|
||||
|
||||
// octave pitch compensation v0.2
|
||||
double handlePitchC(double stdPitch, double voicePitch, double stdMaxPitch) {
|
||||
|
||||
int cnt = 0;
|
||||
double stdTone = pitchToToneC(stdPitch);
|
||||
double voiceTone = pitchToToneC(voicePitch);
|
||||
|
||||
if (voicePitch <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if(stdPitch <= 0){
|
||||
return 0;
|
||||
}
|
||||
|
||||
if(fabs(voiceTone - stdTone) <= 6){
|
||||
return voicePitch;
|
||||
}
|
||||
else if(voicePitch < stdPitch){
|
||||
for(cnt = 0; cnt <11; cnt++){
|
||||
voicePitch = 2*voicePitch;
|
||||
voiceTone = pitchToToneC(voicePitch);
|
||||
if(fabs(voiceTone - stdTone) <= 6){
|
||||
return voicePitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(voicePitch > stdPitch){
|
||||
for(cnt = 0; cnt <11; cnt++){
|
||||
voicePitch = voicePitch/2;
|
||||
voiceTone = pitchToToneC(voicePitch);
|
||||
if(fabs(voiceTone - stdTone) <= 6){
|
||||
return voicePitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
return voicePitch;
|
||||
}
|
||||
|
||||
void resetC(void) {
|
||||
offset = 0.0;
|
||||
n = 0.0;
|
||||
}
|
||||
18
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Al/Algorithm.h
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Algorithm.h
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/11/9.
|
||||
//
|
||||
|
||||
#ifndef Algorithm_h
|
||||
#define Algorithm_h
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
double pitchToToneC(double pitch);
|
||||
float calculedScoreC(double voicePitch, double stdPitch, int scoreLevel, int scoreCompensationOffset);
|
||||
|
||||
void resetC(void);
|
||||
double handlePitchC(double stdPitch, double voicePitch, double stdMaxPitch);
|
||||
#endif /* Algorithm_h */
|
||||
154
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/Downloader.swift
generated
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// Downloader.swift
|
||||
// URLSessionDownloadDemo
|
||||
//
|
||||
// Created by FancyLou on 2019/2/22.
|
||||
// Copyright © 2019 O2OA. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
typealias DownloadProgressClosure = ((_ progress:Float)->Void)
|
||||
typealias DownloadCompletionClosure = ((_ filePath: String)->Void)
|
||||
typealias DownloadFailClosure = ((_ error: DownloadError)-> Void)
|
||||
|
||||
class Downloader: NSObject {
|
||||
private var fail: DownloadFailClosure?
|
||||
private var completion: DownloadCompletionClosure?
|
||||
private var progress: DownloadProgressClosure?
|
||||
private var downloadUrl: URL?
|
||||
private var localUrl: URL?
|
||||
private var downloadSession: URLSession?
|
||||
private var fileOutputStream: OutputStream?
|
||||
private var downloadLoop: CFRunLoop?
|
||||
private var currentLength: Float = 0.0
|
||||
static var requestTimeoutInterval: TimeInterval = 60
|
||||
private var logTag = "Downloader"
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
if (downloadLoop != nil) {
|
||||
CFRunLoopStop(downloadLoop)
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
Log.info(text: "init", tag: logTag)
|
||||
}
|
||||
|
||||
// 开始下载
|
||||
func download(url: URL, progress: @escaping DownloadProgressClosure, completion: @escaping DownloadCompletionClosure, fail: @escaping DownloadFailClosure) {
|
||||
logTag += "[\(url.lastPathComponent)]"
|
||||
self.progress = progress
|
||||
self.completion = completion
|
||||
self.fail = fail
|
||||
self.downloadUrl = url
|
||||
|
||||
guard self.downloadSession == nil else {
|
||||
Log.errorText(text: "已经开始下载。。。。", tag: logTag)
|
||||
return
|
||||
}
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: self.downloadUrl!)
|
||||
request.httpMethod = "GET"
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.timeoutInterval = Downloader.requestTimeoutInterval
|
||||
// session会话
|
||||
downloadSession = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||
// 创建下载任务
|
||||
let downloadTask = self.downloadSession?.dataTask(with: request)
|
||||
// 开始下载
|
||||
downloadTask?.resume()
|
||||
// 当前运行循环
|
||||
downloadLoop = CFRunLoopGetCurrent()
|
||||
CFRunLoopRun()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 取消下载任务
|
||||
func cancel() {
|
||||
downloadSession?.invalidateAndCancel()
|
||||
downloadSession = nil
|
||||
fileOutputStream = nil
|
||||
// 结束下载的线程
|
||||
if (downloadLoop != nil) {
|
||||
CFRunLoopStop(downloadLoop)
|
||||
}
|
||||
}
|
||||
|
||||
func resetEventCloure() {
|
||||
fail = nil
|
||||
completion = nil
|
||||
progress = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Downloader: URLSessionDataDelegate {
|
||||
|
||||
func urlSession(_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse,
|
||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
Log.debug(text: "remote server start respone...", tag: logTag)
|
||||
if let resp = response as? HTTPURLResponse, resp.statusCode != 200 {
|
||||
Log.errorText(text: resp.description, tag: logTag)
|
||||
let e = DownloadError(domainType: .httpDownloadErrorLogic, code: resp.statusCode, msg: "http error: \(resp.statusCode)")
|
||||
Log.errorText(text: e.description, tag: logTag)
|
||||
fail?(e)
|
||||
fail = nil
|
||||
completionHandler(.cancel)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// file name of remote
|
||||
let filename = dataTask.response?.suggestedFilename ?? "unKnownFileTitle.tmp"
|
||||
let downloadFloderURL = NSURL(fileURLWithPath: String.downloadedFloderPath())
|
||||
FileManager.createDirectoryIfNeeded(atPath: downloadFloderURL.path!)
|
||||
localUrl = downloadFloderURL.appendingPathComponent(filename)
|
||||
Log.debug(text: "local path:\(self.localUrl?.path ?? "")", tag: logTag)
|
||||
fileOutputStream = OutputStream(url: self.localUrl!, append: true)
|
||||
fileOutputStream?.open()
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
/// didReceive
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
Log.debug(text: "didReceive...", tag: logTag)
|
||||
data.withUnsafeBytes { bufferPointer in
|
||||
guard let baseAddress = bufferPointer.baseAddress else { return }
|
||||
self.fileOutputStream?.write(baseAddress, maxLength: bufferPointer.count)
|
||||
}
|
||||
currentLength += Float(data.count)
|
||||
// 这里有个问题 有些自己做的数据返回 header里面没有length 那就无法计算进度
|
||||
let totalLength = Float(dataTask.response?.expectedContentLength ?? -1)
|
||||
var progress = currentLength / totalLength
|
||||
if totalLength<0 {
|
||||
progress = 0.0
|
||||
}
|
||||
Log.info(text: "current: \(currentLength) , total:\(totalLength), progress:\(progress)", tag: logTag)
|
||||
self.progress?(progress)
|
||||
}
|
||||
|
||||
/// Complete
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
fileOutputStream?.close()
|
||||
cancel()
|
||||
if error != nil {
|
||||
Log.errorText(text: "download fail: \(error!.localizedDescription)", tag: logTag)
|
||||
let e = DownloadError(domainType: .httpDownloadError, error: error! as NSError)
|
||||
fail?(e)
|
||||
fail = nil
|
||||
} else {
|
||||
Log.info(text: "download success", tag: logTag)
|
||||
completion?(self.localUrl?.path ?? "")
|
||||
completion = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/DownloaderManager.swift
generated
Normal file
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// DownloaderManager.swift
|
||||
// URLSessionDownloadDemo
|
||||
//
|
||||
// Created by FancyLou on 2019/2/22.
|
||||
// Copyright © 2019 O2OA. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class DownloaderManager: NSObject {
|
||||
// 下载缓存池
|
||||
private var downloadCache = SafeDictionary<String, Downloader>()
|
||||
private var failback: DownloadFailClosure?
|
||||
private let logTag = "DownloaderManager"
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
let downloadFloderURL = NSURL(fileURLWithPath: String.downloadedFloderPath())
|
||||
FileManager.createDirectoryIfNeeded(atPath: downloadFloderURL.path!)
|
||||
}
|
||||
|
||||
override init() {
|
||||
Log.info(text: "init", tag: logTag)
|
||||
}
|
||||
|
||||
func download(url: URL,
|
||||
progress: @escaping DownloadProgressClosure,
|
||||
completion: @escaping DownloadCompletionClosure,
|
||||
fail: @escaping DownloadFailClosure) {
|
||||
self.failback = fail
|
||||
// 判断缓存池中是否已经存在
|
||||
var downloader = downloadCache.getValue(forkey: url.absoluteString)
|
||||
if downloader != nil {
|
||||
Log.errorText(text: "当前下载已存在不需要重复下载!", tag: logTag)
|
||||
let e = DownloadError(domainType: .repeatDownloading,
|
||||
code: DownloadErrorDomainType.repeatDownloading.rawValue,
|
||||
msg: "当前下载已存在不需要重复下载")
|
||||
self.failback?(e)
|
||||
return
|
||||
}
|
||||
downloader = Downloader()
|
||||
downloadCache.set(value: downloader!, forkey: url.absoluteString)
|
||||
downloader?.download(url: url, progress: progress, completion: { [weak self](filePath) in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
downloadCache.removeValue(forkey: url.absoluteString)
|
||||
completion(filePath)
|
||||
}, fail: fail)
|
||||
}
|
||||
|
||||
func cancelTask(url: URL) {
|
||||
let downloader = downloadCache.getValue(forkey: url.absoluteString)
|
||||
if downloader == nil {
|
||||
Log.debug(text: "任务已经移除,不需要重复移除!", tag: logTag)
|
||||
return
|
||||
}
|
||||
// 从缓存池中删除
|
||||
downloadCache.removeValue(forkey: url.absoluteString)
|
||||
//结束任务
|
||||
downloader?.resetEventCloure()
|
||||
downloader?.cancel()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
64
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/Extentions.swift
generated
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// AgoraStringExtention.swift
|
||||
// AgoraKaraokeScore
|
||||
//
|
||||
// Created by zhaoyongqiang on 2021/12/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension String {
|
||||
// 获取时间格式
|
||||
func timeIntervalToMMSSFormat(interval: TimeInterval) -> String {
|
||||
if interval >= 3600 {
|
||||
let hour = interval / 3600
|
||||
let min = interval.truncatingRemainder(dividingBy: 3600) / 60
|
||||
let sec = interval.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60)
|
||||
return String(format: "%02d:%02d:%02d", Int(hour), Int(min), Int(sec))
|
||||
} else {
|
||||
let min = interval / 60
|
||||
let sec = interval.truncatingRemainder(dividingBy: 60)
|
||||
return String(format: "%02d:%02d", Int(min), Int(sec))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存文件夹路径
|
||||
*/
|
||||
static func cacheFolderPath() -> String {
|
||||
return NSHomeDirectory().appending("/Library").appending("/MusicCaches").appending("/lyricFiles")
|
||||
}
|
||||
|
||||
/// 下载目录
|
||||
static func downloadedFloderPath() -> String {
|
||||
return NSHomeDirectory().appending("/tmp").appending("/LyricDownloadFiles")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网址中的文件名
|
||||
*/
|
||||
var fileName: String {
|
||||
components(separatedBy: "/").last ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension FileManager {
|
||||
static func createDirectoryIfNeeded(atPath path: String) {
|
||||
let fileManager = FileManager.default
|
||||
var isDirectory: ObjCBool = false
|
||||
if fileManager.fileExists(atPath: path, isDirectory: &isDirectory) {
|
||||
if !isDirectory.boolValue {
|
||||
Log.debug(text: "给定的路径是一个文件: \(path)", tag: "Downloader Extension")
|
||||
return
|
||||
}
|
||||
Log.debug(text: "已存在:\(path)")
|
||||
} else {
|
||||
do {
|
||||
try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
|
||||
Log.debug(text: "已成功创建目录: \(path)", tag: "Downloader Extension")
|
||||
} catch {
|
||||
Log.errorText(text: "创建目录失败: \(error.localizedDescription)", tag: "Downloader Extension")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/FileCache.swift
generated
Normal file
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// FileCache.swift
|
||||
// Demo
|
||||
//
|
||||
// Created by ZYP on 2023/2/7.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Zip
|
||||
class FileCache {
|
||||
/// 指定一个本地存储的文件数量最大值,当前保存的文件达到该数量时,内部会自动清理旧文件(淘汰最早的记录)。
|
||||
var maxFileNum: UInt8 = 50 { didSet { removeFilesIfNeeded() } }
|
||||
/// 文件的存活时间 单位:s
|
||||
var maxFileAge: UInt = 8 * 60 * 60 { didSet { removeFilesIfNeeded() } }
|
||||
private let logTag = "FileCache"
|
||||
|
||||
init() {
|
||||
Log.info(text: "init", tag: logTag)
|
||||
let cacheFolderPathUrl = NSURL(fileURLWithPath: String.cacheFolderPath())
|
||||
FileManager.createDirectoryIfNeeded(atPath: cacheFolderPathUrl.path!)
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
func removeFilesIfNeeded() {
|
||||
let files = findFiles(inDirectory: String.cacheFolderPath())
|
||||
let manager = FileManager.default
|
||||
var hasFileBeRemove = false
|
||||
var fileForMinCreationTime: ExistedFile?
|
||||
let currentTime = Date().timeIntervalSince1970
|
||||
|
||||
for file in files { /** remove out date one **/
|
||||
let gap = UInt(currentTime - file.createdTimeStamp)
|
||||
let maxAge = UInt(maxFileAge)
|
||||
if gap > maxAge {
|
||||
do {
|
||||
try manager.removeItem(atPath: file.path)
|
||||
hasFileBeRemove = true
|
||||
Log.debug(text: "did remove file: \(file.path)", tag: logTag)
|
||||
} catch let error {
|
||||
Log.error(error: "removeFilesIfNeed error \(error.localizedDescription)", tag: logTag)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if let currentFileForMinCreationTime = fileForMinCreationTime {
|
||||
fileForMinCreationTime = currentFileForMinCreationTime.createdTimeStamp <= file.createdTimeStamp ? currentFileForMinCreationTime : file
|
||||
}
|
||||
else {
|
||||
fileForMinCreationTime = file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// remove earliest one
|
||||
if files.count > maxFileNum,
|
||||
!hasFileBeRemove,
|
||||
let currentFileForMinCreationTime = fileForMinCreationTime {
|
||||
do {
|
||||
try manager.removeItem(atPath: currentFileForMinCreationTime.path)
|
||||
hasFileBeRemove = true
|
||||
Log.debug(text: "did remove file: \(currentFileForMinCreationTime.path)", tag: logTag)
|
||||
} catch let error {
|
||||
Log.error(error: "removeFilesIfNeed 2 error \(error.localizedDescription)", tag: logTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findFiles(inDirectory directoryPath: String) -> [ExistedFile] {
|
||||
let fileManager = FileManager.default
|
||||
guard let directoryURL = URL(string: directoryPath) else {
|
||||
return []
|
||||
}
|
||||
var files: [ExistedFile] = []
|
||||
do {
|
||||
let directoryContents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: [.creationDateKey], options: [.skipsHiddenFiles])
|
||||
for item in directoryContents {
|
||||
if let creationDate = try item.resourceValues(forKeys: [.creationDateKey]).creationDate {
|
||||
let file = ExistedFile(path: item.path, createdTimeStamp: creationDate.timeIntervalSince1970)
|
||||
files.append(file)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log.error(error: "findXMLandLRCFiles: \(error)", tag: logTag)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
Log.debug(text: "clearAll start", tag: logTag)
|
||||
let fileManager = FileManager.default
|
||||
let files = findFiles(inDirectory: String.cacheFolderPath())
|
||||
if files.isEmpty {
|
||||
Log.debug(text: "no need to clear", tag: logTag)
|
||||
return
|
||||
}
|
||||
do {
|
||||
for file in files {
|
||||
try fileManager.removeItem(atPath: file.path)
|
||||
Log.debug(text: "rm \(file.path.fileName)", tag: logTag)
|
||||
}
|
||||
} catch let error {
|
||||
Log.error(error: "clearAll: \(error)", tag: logTag)
|
||||
}
|
||||
Log.debug(text: "clearAll end", tag: logTag)
|
||||
}
|
||||
}
|
||||
|
||||
extension FileCache {
|
||||
/**
|
||||
* 是否存在缓存文件 存在:返回文件路径 不存在:返回nil
|
||||
*/
|
||||
static func cacheFileExists(with url: String) -> String? {
|
||||
let cacheFilePath = "\(String.cacheFolderPath())/\(url.fileName)"
|
||||
if FileManager.default.fileExists(atPath: cacheFilePath) {
|
||||
return cacheFilePath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存文件
|
||||
*/
|
||||
static func clearCache() -> Bool? {
|
||||
let manager = FileManager.default
|
||||
|
||||
if let _ = try? manager.removeItem(atPath: String.cacheFolderPath()) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
struct ExistedFile {
|
||||
let path: String
|
||||
let createdTimeStamp: Double
|
||||
}
|
||||
}
|
||||
15
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/LyricsFileDownloader+Info.swift
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// LyricsFileDownloader+Info.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/12/14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension LyricsFileDownloader {
|
||||
struct TaskInfo {
|
||||
let requestId: Int
|
||||
let urlString: String
|
||||
}
|
||||
}
|
||||
365
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/LyricsFileDownloader.swift
generated
Normal file
@@ -0,0 +1,365 @@
|
||||
//
|
||||
// LyricsFileDownloader.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/12/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Zip
|
||||
|
||||
public class LyricsFileDownloader: NSObject {
|
||||
typealias RequestId = Int
|
||||
|
||||
/// max number of file in local (if reach max, sdk will remove oldest file)
|
||||
@objc public var maxFileNum: UInt8 = 50 { didSet { fileCache.maxFileNum = maxFileNum } }
|
||||
/// age of file (seconds), default is 8 hours
|
||||
@objc public var maxFileAge: UInt = 8 * 60 * 60 { didSet { fileCache.maxFileAge = maxFileAge } }
|
||||
@objc public weak var delegate: LyricsFileDownloaderDelegate?
|
||||
@objc public var delegateQueue = DispatchQueue.main
|
||||
private let fileCache = FileCache()
|
||||
private let downloaderManager = DownloaderManager()
|
||||
private let queue = DispatchQueue(label: "com.agora.LyricsFileDownloader.queue")
|
||||
private var requestIdDict = [RequestId : String]()
|
||||
private var currentRequestId: RequestId = 0
|
||||
private let maxConcurrentRequestCount = 3
|
||||
private var waittingTaskQueue = Queue<TaskInfo>()
|
||||
private let logTag = "LyricsFileDownloader"
|
||||
// MARK: - Public Method
|
||||
|
||||
public override init() {
|
||||
Log.info(text: "init", tag: logTag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
/// start a download
|
||||
/// - Parameters:
|
||||
/// - urlString: url from result of `AgoraMusicContentCenter`
|
||||
/// - Returns: `requestId`, if rseult < 0, means fail, such as -1 means urlString not valid. if rseult >= 0, means success
|
||||
@objc public func download(urlString: String) -> Int {
|
||||
guard isValidURL(urlString: urlString) else {
|
||||
return -1
|
||||
}
|
||||
|
||||
let requestId = genId()
|
||||
Log.info(text: "download: \(requestId)", tag: logTag)
|
||||
|
||||
/// remove file outdate
|
||||
fileCache.removeFilesIfNeeded()
|
||||
|
||||
/** check file Exist **/
|
||||
if let fileData = fetchFromLocal(urlString: urlString) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
invokeOnLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: fileData,
|
||||
error: nil)
|
||||
}
|
||||
return requestId
|
||||
}
|
||||
|
||||
/** start download **/
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
Log.info(text: "requestId:\(requestId) start work", tag: logTag)
|
||||
if requestIdDict.count >= maxConcurrentRequestCount {
|
||||
let logText = "request(\(requestId) was enqueued in waittingTaskQueue, current num of requesting task is \(requestIdDict.count)"
|
||||
Log.info(text: logText, tag: logTag)
|
||||
let taskInfo = TaskInfo(requestId: requestId, urlString: urlString)
|
||||
waittingTaskQueue.enqueue(taskInfo)
|
||||
}
|
||||
else {
|
||||
_addRequest(id: requestId, urlString: urlString)
|
||||
_startDownload(requestId: requestId, urlString: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
return requestId
|
||||
}
|
||||
|
||||
/// cancel a downloading task
|
||||
@objc public func cancelDownload(requestId: Int) {
|
||||
Log.info(text: "cancelDownload: \(requestId)", tag: logTag)
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
_cancelDownload(requestId: requestId)
|
||||
_resumeTaskIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// clean all files in local
|
||||
@objc public func cleanAll() {
|
||||
_cleanAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Method - 0
|
||||
func fetchFromLocal(urlString: String) -> Data? {
|
||||
/** check if Exist **/
|
||||
let fileName = urlString.fileName.components(separatedBy: ".").first ?? ""
|
||||
if let xmlPath = FileCache.cacheFileExists(with: fileName + ".xml") {
|
||||
let url = URL(fileURLWithPath: xmlPath)
|
||||
let data = try? Data(contentsOf: url)
|
||||
return data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func _startDownload(requestId: Int, urlString: String) {
|
||||
Log.debug(text: "_startDownload requestId:\(requestId)", tag: logTag)
|
||||
guard let url = URL(string: urlString) else {
|
||||
_removeRequest(id: requestId)
|
||||
_resumeTaskIfNeeded()
|
||||
return
|
||||
}
|
||||
downloaderManager.download(url: url) { [weak self](progress) in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
invokeOnLyricsFileDownloadProgress(requestId: requestId, progress: progress)
|
||||
} completion: { [weak self](filePath) in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
if filePath.split(separator: ".").last == "lrc" { /** lrc type **/
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
var data: Data?
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
removeRequest(id: requestId)
|
||||
resumeTaskIfNeeded()
|
||||
} catch let error {
|
||||
let logText = "get data from [\(url.path)] failed: \(error.localizedDescription)"
|
||||
Log.errorText(text: logText, tag: logTag)
|
||||
let e = DownloadError(domainType: .general, error: error as NSError)
|
||||
invokeOnLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: nil,
|
||||
error: e)
|
||||
}
|
||||
|
||||
do {
|
||||
FileManager.createDirectoryIfNeeded(atPath: .cacheFolderPath())
|
||||
if FileManager.default.fileExists(atPath: filePath) {
|
||||
Log.debug(text: "file exist: \(filePath)")
|
||||
}
|
||||
try FileManager.default.copyItem(atPath: filePath, toPath: .cacheFolderPath() + "/" + url.lastPathComponent)
|
||||
Log.debug(text: "ready to removeItem: \(filePath)")
|
||||
try FileManager.default.removeItem(atPath: filePath)
|
||||
} catch let error {
|
||||
let logText = "get data from [\(url.path)] failed: \(error.localizedDescription)"
|
||||
Log.errorText(text: logText, tag: logTag)
|
||||
}
|
||||
|
||||
invokeOnLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: data,
|
||||
error: nil)
|
||||
return
|
||||
}
|
||||
|
||||
/** xml type **/
|
||||
unzip(filePath: filePath, requestId: requestId)
|
||||
} fail: { [weak self](error) in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
removeRequest(id: requestId)
|
||||
resumeTaskIfNeeded()
|
||||
invokeOnLyricsFileDownloadCompleted(requestId: requestId, fileData: nil, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func _cancelDownload(requestId: Int) {
|
||||
if let urlString = requestIdDict[requestId] {
|
||||
guard let url = URL(string: urlString) else {
|
||||
Log.errorText(text: "\(urlString) is not valid url", tag: logTag)
|
||||
return
|
||||
}
|
||||
Log.info(text: "_cancelDownload in current request: \(requestId)", tag: logTag)
|
||||
_removeRequest(id: requestId)
|
||||
downloaderManager.cancelTask(url: url)
|
||||
}
|
||||
else {
|
||||
_removeWaittingTaskIfNeeded(requestId: requestId)
|
||||
}
|
||||
}
|
||||
|
||||
func _cleanAll() {
|
||||
fileCache.clearAll()
|
||||
_clearDownloadFloder()
|
||||
}
|
||||
|
||||
// MARK: - Private Method
|
||||
|
||||
private func unzip(filePath: String, requestId: Int) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
_unzip(filePath: filePath, requestId: requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private func _unzip(filePath: String, requestId: Int) {
|
||||
let fileName = filePath.fileName.components(separatedBy: ".").first ?? ""
|
||||
let zipFile = URL(fileURLWithPath: filePath)
|
||||
let destination = URL(fileURLWithPath: .cacheFolderPath())
|
||||
do {
|
||||
try Zip.unzipFile(zipFile, destination: destination, overwrite: true, password: nil)
|
||||
let path = destination.path + "/" + fileName + ".xml"
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let data = try Data(contentsOf: url)
|
||||
removeRequest(id: requestId)
|
||||
resumeTaskIfNeeded()
|
||||
invokeOnLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: data,
|
||||
error: nil)
|
||||
} catch let error {
|
||||
removeRequest(id: requestId)
|
||||
resumeTaskIfNeeded()
|
||||
let e = DownloadError(domainType: .unzipFail,
|
||||
code: DownloadErrorDomainType.unzipFail.rawValue,
|
||||
msg: error.localizedDescription)
|
||||
invokeOnLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: nil,
|
||||
error: e)
|
||||
}
|
||||
}
|
||||
|
||||
private func genId() -> RequestId {
|
||||
let id = currentRequestId
|
||||
currentRequestId = currentRequestId == Int.max ? 0 : currentRequestId + 1
|
||||
return id
|
||||
}
|
||||
|
||||
private func addRequest(id: RequestId, urlString: String) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
_addRequest(id: id, urlString: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeRequest(id: RequestId) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
_removeRequest(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func _addRequest(id: RequestId, urlString: String) {
|
||||
requestIdDict[id] = urlString
|
||||
}
|
||||
|
||||
private func _removeRequest(id: RequestId) {
|
||||
requestIdDict.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
private func resumeTaskIfNeeded() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
_resumeTaskIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func _resumeTaskIfNeeded() {
|
||||
Log.debug(text: "_resumeTaskIfNeeded", tag: logTag)
|
||||
if requestIdDict.count >= maxConcurrentRequestCount {
|
||||
return
|
||||
}
|
||||
|
||||
if let taskInfo = waittingTaskQueue.dequeue() {
|
||||
Log.info(text: "task was resume, requestId: \(taskInfo.requestId)", tag: logTag)
|
||||
_addRequest(id: taskInfo.requestId, urlString: taskInfo.urlString)
|
||||
_startDownload(requestId: taskInfo.requestId, urlString: taskInfo.urlString)
|
||||
}
|
||||
}
|
||||
|
||||
private func _removeWaittingTaskIfNeeded(requestId: Int) {
|
||||
Log.debug(text: "_removeWaittingTaskIfNeeded \(requestId)", tag: logTag)
|
||||
var tasks = waittingTaskQueue.getAll()
|
||||
let contain = tasks.contains(where: { $0.requestId == requestId })
|
||||
if contain {
|
||||
tasks = tasks.filter({ requestId != $0.requestId })
|
||||
waittingTaskQueue.reset(newElements: tasks)
|
||||
Log.debug(text: "task (id:\(requestId)) was remove in waitting tasks ", tag: logTag)
|
||||
}
|
||||
else {
|
||||
Log.debug(text: "no task (id:\(requestId)) was should be remove in waitting tasks", tag: logTag)
|
||||
}
|
||||
}
|
||||
|
||||
private func _clearDownloadFloder() {
|
||||
Log.debug(text: "[DownloadFloder]clearDownloadFloder", tag: logTag)
|
||||
|
||||
guard let directoryURL = URL(string: String.downloadedFloderPath()) else {
|
||||
return
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
let directoryContents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: [.creationDateKey], options: [.skipsHiddenFiles])
|
||||
for url in directoryContents {
|
||||
try fileManager.removeItem(atPath: url.path)
|
||||
Log.debug(text: "[DownloadFloder]rm \(url.path.fileName)", tag: logTag)
|
||||
}
|
||||
} catch let error {
|
||||
Log.error(error: "[DownloadFloder]clearAll: \(error)", tag: logTag)
|
||||
}
|
||||
Log.debug(text: "[DownloadFloder]clearAll end", tag: logTag)
|
||||
}
|
||||
|
||||
private func isValidURL(urlString: String) -> Bool {
|
||||
if urlString.isEmpty {
|
||||
return false
|
||||
}
|
||||
return urlString.hasPrefix("http://") || urlString.hasPrefix("https://")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Invoke
|
||||
|
||||
extension LyricsFileDownloader {
|
||||
fileprivate func invokeOnLyricsFileDownloadCompleted(requestId: Int,
|
||||
fileData: Data?,
|
||||
error: DownloadError?) {
|
||||
/** check local file **/
|
||||
fileCache.removeFilesIfNeeded()
|
||||
|
||||
Log.debug(text: "invokeOnLyricsFileDownloadCompleted requestId:\(requestId) isSuccess:\(error == nil)", tag: logTag)
|
||||
if Thread.isMainThread {
|
||||
delegate?.onLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: fileData,
|
||||
error: error)
|
||||
return
|
||||
}
|
||||
|
||||
delegateQueue.async { [weak self] in
|
||||
self?.delegate?.onLyricsFileDownloadCompleted(requestId: requestId,
|
||||
fileData: fileData,
|
||||
error: error)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func invokeOnLyricsFileDownloadProgress(requestId: Int, progress: Float) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.onLyricsFileDownloadProgress(requestId: requestId, progress: progress)
|
||||
return
|
||||
}
|
||||
delegateQueue.async { [weak self] in
|
||||
self?.delegate?.onLyricsFileDownloadProgress(requestId: requestId, progress: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Downloader/LyricsFileDownloaderProtocol.swift
generated
Normal file
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// LyricsFileDownloaderProtocol.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/12/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// the enum type to describe the error
|
||||
@objc public enum DownloadErrorDomainType: Int {
|
||||
case general = 0
|
||||
/// repeat url request, a same url is requesting
|
||||
case repeatDownloading = 1
|
||||
/// error from http framework in ios, such as time out
|
||||
case httpDownloadError = 2
|
||||
/// http logic error, such as 400/500
|
||||
case httpDownloadErrorLogic = 3
|
||||
/// unzip fail
|
||||
case unzipFail = 4
|
||||
|
||||
/// the string to indicate the type
|
||||
public var domain: String {
|
||||
switch self {
|
||||
case .general:
|
||||
return "io.agora.LyricsFileDownloader.general"
|
||||
case .repeatDownloading:
|
||||
return "io.agora.LyricsFileDownloader.repeatDownloading"
|
||||
case .httpDownloadError:
|
||||
return "io.agora.LyricsFileDownloader.httpDownloadError"
|
||||
case .httpDownloadErrorLogic:
|
||||
return "io.agora.LyricsFileDownloader.httpDownloadErrorLogic"
|
||||
case .unzipFail:
|
||||
return "io.agora.LyricsFileDownloader.unzipFail"
|
||||
}
|
||||
}
|
||||
|
||||
/// the name to describe the type
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .general:
|
||||
return "general"
|
||||
case .repeatDownloading:
|
||||
return "repeatDownloading"
|
||||
case .httpDownloadError:
|
||||
return "httpDownloadError"
|
||||
case .httpDownloadErrorLogic:
|
||||
return "httpDownloadErrorLogic"
|
||||
case .unzipFail:
|
||||
return "unzipFail"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// the class to describe the error
|
||||
public class DownloadError: NSError {
|
||||
/// the message to describe the error
|
||||
public let msg: String
|
||||
/// the type of different error
|
||||
public let domainType: DownloadErrorDomainType
|
||||
/// original error from http framework in ios, such as time out
|
||||
public var originalError: NSError?
|
||||
|
||||
init(domainType: DownloadErrorDomainType, code: Int, msg: String) {
|
||||
self.domainType = domainType
|
||||
self.msg = msg
|
||||
super.init(domain: domainType.domain, code: code)
|
||||
}
|
||||
|
||||
init(domainType: DownloadErrorDomainType, error: NSError) {
|
||||
self.domainType = domainType
|
||||
self.msg = error.localizedDescription
|
||||
self.originalError = error
|
||||
super.init(domain: domainType.domain, code: error.code)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override var description: String {
|
||||
return "error: \(domainType.name) domain: \(domain) code:\(code) msg:\(msg) originalError:\(originalError?.localizedDescription ?? "nil")"
|
||||
}
|
||||
}
|
||||
|
||||
@objc public protocol LyricsFileDownloaderDelegate: NSObjectProtocol {
|
||||
/// progress event
|
||||
/// - Parameters:
|
||||
/// - progress: [0, 1], if equal `1`, means success
|
||||
func onLyricsFileDownloadProgress(requestId: Int, progress: Float)
|
||||
|
||||
/// Completed event
|
||||
/// - Parameters:
|
||||
/// - fileData: lyric data from file, if `nil` means fail
|
||||
/// - error: if `nil` means success
|
||||
func onLyricsFileDownloadCompleted(requestId: Int, fileData: Data?, error: DownloadError?)
|
||||
}
|
||||
71
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Events.swift
generated
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// Events.swift
|
||||
// NewApi
|
||||
//
|
||||
// Created by ZYP on 2022/11/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc public protocol KaraokeDelegate: NSObjectProtocol {
|
||||
/// 拖拽歌词结束后回调
|
||||
/// - Note: 当 `KaraokeConfig.lyricConfig.draggable == true` 且 用户进行拖动歌词时候 调用
|
||||
/// - Parameters:
|
||||
/// - view: KaraokeView
|
||||
/// - position: 当前时间点 (ms)
|
||||
@objc optional func onKaraokeView(view: KaraokeView, didDragTo position: Int)
|
||||
|
||||
/// 歌曲播放完一行(Line)时的歌词回调
|
||||
/// - Parameters:
|
||||
/// - model: 行信息
|
||||
/// - score: 当前行得分 [0, 100]
|
||||
/// - cumulativeScore: 累计分数
|
||||
/// - lineIndex: 行索引号 最小值:0
|
||||
/// - lineCount: 总行数
|
||||
@objc optional func onKaraokeView(view: KaraokeView,
|
||||
didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int)
|
||||
}
|
||||
|
||||
/// 分数计算协议
|
||||
@objc public protocol IScoreAlgorithm {
|
||||
// MARK: - 自定义分数
|
||||
|
||||
/// 计算当前行(Line)的分数
|
||||
/// - Parameters:
|
||||
/// - models: 字得分信息集合
|
||||
/// - Returns: 计算后的分数 [0, 100]
|
||||
@objc func getLineScore(with toneScores: [ToneScoreModel]) -> Int
|
||||
}
|
||||
|
||||
/// 日志协议
|
||||
@objc public protocol ILogger {
|
||||
/// 日志输出
|
||||
/// - Note: 在子线程执行
|
||||
/// - Parameters:
|
||||
/// - content: 内容
|
||||
/// - tag: 标签
|
||||
/// - time: 时间
|
||||
/// - level: 等级
|
||||
@objc func onLog(content: String, tag: String?, time: String, level: LoggerLevel)
|
||||
}
|
||||
|
||||
@objc public enum LoggerLevel: UInt8, CustomStringConvertible {
|
||||
case debug, info, warning, error
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .debug:
|
||||
return "D"
|
||||
case .info:
|
||||
return "I"
|
||||
case .warning:
|
||||
return "W"
|
||||
case .error:
|
||||
return "E"
|
||||
}
|
||||
}
|
||||
}
|
||||
319
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/KaraokeView.swift
generated
Normal file
@@ -0,0 +1,319 @@
|
||||
//
|
||||
// KaraokeView.swift
|
||||
// NewApi
|
||||
//
|
||||
// Created by ZYP on 2022/11/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class KaraokeView: UIView {
|
||||
/// 背景图
|
||||
@objc public var backgroundImage: UIImage? = nil {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
|
||||
/// 是否使用评分功能
|
||||
/// - Note: 当为 `false`, 会隐藏评分视图
|
||||
@objc public var scoringEnabled: Bool = true {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
|
||||
/// 评分组件和歌词组件之间的间距 默认: 0
|
||||
@objc public var spacing: CGFloat = 0 {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
|
||||
@objc public weak var delegate: KaraokeDelegate?
|
||||
@objc public let lyricsView = LyricsView()
|
||||
@objc public let scoringView = ScoringView()
|
||||
fileprivate let backgroundImageView = UIImageView()
|
||||
fileprivate var lyricsViewTopConstraint: NSLayoutConstraint!
|
||||
fileprivate var scoringViewHeightConstraint, scoringViewTopConstraint: NSLayoutConstraint!
|
||||
fileprivate var lyricData: LyricModel?
|
||||
fileprivate let progressChecker = ProgressChecker()
|
||||
fileprivate var pitchIsZeroCount = 0
|
||||
fileprivate var isStart = false
|
||||
fileprivate let logTag = "KaraokeView"
|
||||
/// use for debug
|
||||
fileprivate var lastProgress = 0
|
||||
fileprivate var progressPrintCount = 0
|
||||
fileprivate var progressPrintCountMax = 80
|
||||
|
||||
/// init
|
||||
/// - !!! Only one init method
|
||||
/// - Note: can set custom logger
|
||||
/// - Note: use for Objective-C. `[[KaraokeView alloc] initWithFrame:frame loggers:@[[ConsoleLogger new], [FileLogger new]]]`
|
||||
/// - Note: use for Swift. `KaraokeView(frame: frame)`
|
||||
/// - Parameters:
|
||||
/// - logger: custom logger
|
||||
@objc public convenience init(frame: CGRect, loggers: [ILogger] = [FileLogger(), ConsoleLogger()]) {
|
||||
Log.setLoggers(loggers: loggers)
|
||||
self.init(frame: frame)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// Not Public, Please use `init(frame, loggers)`
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
Log.debug(text: "version \(versionName)", tag: logTag)
|
||||
setupUI()
|
||||
commonInit()
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Method
|
||||
extension KaraokeView {
|
||||
/// 解析歌词文件xml数据
|
||||
/// - Parameter data: xml二进制数据
|
||||
/// - Returns: 歌词信息
|
||||
@objc public static func parseLyricData(data: Data) -> LyricModel? {
|
||||
let parser = Parser()
|
||||
return parser.parseLyricData(data: data)
|
||||
}
|
||||
|
||||
/// 设置歌词数据信息
|
||||
/// - Parameter data: 歌词信息 由 `parseLyricData(data: Data)` 生成. 如果纯音乐, 给 `nil`.
|
||||
@objc public func setLyricData(data: LyricModel?) {
|
||||
Log.info(text: "setLyricData \(data?.name ?? "nil")", tag: logTag)
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setLyricData not isMainThread ", tag: logTag)
|
||||
}
|
||||
|
||||
/** Fix incorrect value of tableView.Height in lyricsView, after update scoringView.height/topSpace **/
|
||||
layoutIfNeeded()
|
||||
|
||||
lyricData = data
|
||||
|
||||
/** 无歌词状态下强制关闭 **/
|
||||
if data == nil {
|
||||
scoringEnabled = false
|
||||
}
|
||||
|
||||
lyricsView.setLyricData(data: data)
|
||||
scoringView.setLyricData(data: data)
|
||||
isStart = true
|
||||
}
|
||||
|
||||
/// 重置, 歌曲停止、切歌需要调用
|
||||
@objc public func reset() {
|
||||
Log.info(text: "reset", tag: logTag)
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke reset not isMainThread ", tag: logTag)
|
||||
}
|
||||
progressChecker.reset()
|
||||
isStart = false
|
||||
pitchIsZeroCount = 0
|
||||
lastProgress = 0
|
||||
progressPrintCount = 0
|
||||
lyricsView.reset()
|
||||
scoringView.reset()
|
||||
}
|
||||
|
||||
/// 设置实时采集(mic)的Pitch
|
||||
/// - Note: 可以从AgoraRTC回调方法 `- (void)rtcEngine:(AgoraRtcEngineKit * _Nonnull)engine reportAudioVolumeIndicationOfSpeakers:(NSArray<AgoraRtcAudioVolumeInfo *> * _Nonnull)speakers totalVolume:(NSInteger)totalVolume` 获取
|
||||
/// - Parameter pitch: 实时音调值
|
||||
@objc public func setPitch(pitch: Double) {
|
||||
Log.info(text: "p:\(pitch)", tag: logTag)
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setPitch not isMainThread ", tag: logTag)
|
||||
}
|
||||
if pitch < 0 { return }
|
||||
guard isStart else { return }
|
||||
if pitch == 0 {
|
||||
pitchIsZeroCount += 1
|
||||
}
|
||||
else {
|
||||
pitchIsZeroCount = 0
|
||||
}
|
||||
if pitch > 0 || pitchIsZeroCount >= 10 { /** 过滤10个0的情况* **/
|
||||
pitchIsZeroCount = 0
|
||||
scoringView.setPitch(pitch: pitch)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置当前歌曲的进度
|
||||
/// - Note: 可以获取播放器的当前进度进行设置
|
||||
/// - Parameter progress: 歌曲进度 (ms)
|
||||
@objc public func setProgress(progress: Int) {
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setProgress not isMainThread ", tag: logTag)
|
||||
}
|
||||
guard isStart else { return }
|
||||
logProgressIfNeed(progress: progress)
|
||||
lyricsView.setProgress(progress: progress)
|
||||
scoringView.progress = progress
|
||||
progressChecker.set(progress: progress)
|
||||
}
|
||||
|
||||
/// 同时设置进度和Pitch (建议观众端使用)
|
||||
/// - Parameters:
|
||||
/// - pitch: 实时音调值
|
||||
/// - progress: 歌曲进度 (ms)
|
||||
@objc public func setPitch(pitch: Double, progress: Int) {
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setPitch(pitch, progress) not isMainThread ", tag: logTag)
|
||||
}
|
||||
setProgress(progress: progress)
|
||||
setPitch(pitch: pitch)
|
||||
}
|
||||
|
||||
/// 设置自定义分数计算对象
|
||||
/// - Note: 如果不调用此方法,则内部使用默认计分规则
|
||||
/// - Parameter algorithm: 遵循`IScoreAlgorithm`协议实现的对象
|
||||
@objc public func setScoreAlgorithm(algorithm: IScoreAlgorithm) {
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setScoreAlgorithm not isMainThread ", tag: logTag)
|
||||
}
|
||||
scoringView.setScoreAlgorithm(algorithm: algorithm)
|
||||
}
|
||||
|
||||
/// 设置打分难易程度(难度系数)
|
||||
/// - Note: 值越小打分难度越小,值越高打分难度越大
|
||||
/// - Parameter level: 系数, 范围:[0, 100], 如不设置默认为15
|
||||
@objc public func setScoreLevel(level: Int) {
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setScoreLevel not isMainThread ", tag: logTag)
|
||||
}
|
||||
if level < 0 || level > 100 {
|
||||
Log.error(error: "setScoreLevel out bounds \(level), [0, 100]", tag: logTag)
|
||||
return
|
||||
}
|
||||
scoringView.scoreLevel = level
|
||||
}
|
||||
|
||||
/// 设置打分分值补偿
|
||||
/// - Note: 在计算分值的时候作为补偿
|
||||
/// - Parameter offset: 分值补偿 [-100, 100], 如不设置默认为0
|
||||
@objc public func setScoreCompensationOffset(offset: Int) {
|
||||
if !Thread.isMainThread {
|
||||
Log.error(error: "invoke setScoreCompensationOffset not isMainThread ", tag: logTag)
|
||||
}
|
||||
if offset < -100 || offset > 100 {
|
||||
Log.error(error: "setScoreCompensationOffset out bounds \(offset), [-100, 100]", tag: logTag)
|
||||
return
|
||||
}
|
||||
scoringView.scoreCompensationOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
extension KaraokeView {
|
||||
fileprivate func setupUI() {
|
||||
scoringView.backgroundColor = .clear
|
||||
lyricsView.backgroundColor = .clear
|
||||
|
||||
backgroundImageView.isHidden = true
|
||||
|
||||
addSubview(backgroundImageView)
|
||||
addSubview(scoringView)
|
||||
addSubview(lyricsView)
|
||||
|
||||
scoringView.translatesAutoresizingMaskIntoConstraints = false
|
||||
lyricsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
scoringView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
scoringView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
scoringViewHeightConstraint = scoringView.heightAnchor.constraint(equalToConstant: scoringView.viewHeight)
|
||||
scoringViewHeightConstraint.isActive = true
|
||||
scoringViewTopConstraint = scoringView.topAnchor.constraint(equalTo: topAnchor, constant: scoringView.topSpaces)
|
||||
scoringViewTopConstraint.isActive = true
|
||||
|
||||
lyricsView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
lyricsView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
lyricsViewTopConstraint = lyricsView.topAnchor.constraint(equalTo: scoringView.bottomAnchor, constant: spacing)
|
||||
lyricsViewTopConstraint.isActive = true
|
||||
lyricsView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
|
||||
backgroundImageView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
backgroundImageView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
backgroundImageView.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
||||
backgroundImageView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
}
|
||||
|
||||
fileprivate func commonInit() {
|
||||
lyricsView.delegate = self
|
||||
scoringView.delegate = self
|
||||
progressChecker.delegate = self
|
||||
}
|
||||
|
||||
fileprivate func updateUI() {
|
||||
backgroundImageView.image = backgroundImage
|
||||
backgroundImageView.isHidden = backgroundImage == nil
|
||||
lyricsViewTopConstraint.constant = scoringEnabled ? spacing : 0 - scoringView.viewHeight
|
||||
scoringViewHeightConstraint.constant = scoringView.viewHeight
|
||||
scoringView.isHidden = !scoringEnabled
|
||||
scoringViewTopConstraint.constant = scoringView.topSpaces
|
||||
}
|
||||
|
||||
fileprivate var versionName: String {
|
||||
guard let version = Bundle.currentBundle.infoDictionary?["CFBundleShortVersionString"] as? String else {
|
||||
return "unknow version"
|
||||
}
|
||||
return version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProgressCheckerDelegate
|
||||
extension KaraokeView: LyricsViewDelegate {
|
||||
func onLyricsViewBegainDrag(view: LyricsView) {
|
||||
scoringView.dragBegain()
|
||||
}
|
||||
|
||||
func onLyricsView(view: LyricsView, didDragTo position: Int) {
|
||||
Log.debug(text: "=== didDragTo \(position)", tag: "drag")
|
||||
scoringView.dragDidEnd(position: position)
|
||||
delegate?.onKaraokeView?(view: self, didDragTo: position)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProgressCheckerDelegate
|
||||
extension KaraokeView: ScoringViewDelegate {
|
||||
func scoringViewShouldUpdateViewLayout(view: ScoringView) {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
func scoringView(_ view: ScoringView,
|
||||
didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int) {
|
||||
Log.info(text: "didFinishLineWith score:\(score) lineIndex:\(lineIndex) lineCount:\(lineCount) cumulativeScore:\(cumulativeScore)", tag: logTag)
|
||||
delegate?.onKaraokeView?(view: self,
|
||||
didFinishLineWith: model,
|
||||
score: score,
|
||||
cumulativeScore: cumulativeScore,
|
||||
lineIndex: lineIndex,
|
||||
lineCount: lineCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension KaraokeView: ProgressCheckerDelegate {
|
||||
func progressCheckerDidProgressPause() {
|
||||
Log.debug(text: "progressCheckerDidProgressPause", tag: logTag)
|
||||
scoringView.forceStopIndicatorAnimationWhenReachingContinuousZeros()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -- Log
|
||||
extension KaraokeView {
|
||||
func logProgressIfNeed(progress: Int) {
|
||||
let gap = progress - lastProgress
|
||||
if progressPrintCount < progressPrintCountMax, gap > 20 {
|
||||
let text = "setProgress:\(progress) last:\(lastProgress) gap:\(progress-lastProgress)"
|
||||
Log.warning(text: text, tag: logTag)
|
||||
progressPrintCount += 1
|
||||
}
|
||||
lastProgress = progress
|
||||
}
|
||||
}
|
||||
118
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Lyrics/FirstToneHintView.swift
generated
Normal file
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// FirstToneHintView.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class FirstToneHintViewStyle: NSObject {
|
||||
/// 背景色
|
||||
@objc public var backgroundColor: UIColor = .gray { didSet { didUpdate?() } }
|
||||
/// 大小
|
||||
@objc public var size: CGFloat = 10 { didSet { didUpdate?() } }
|
||||
/// 底部间距
|
||||
@objc public var bottomMargin: CGFloat = 0 { didSet { didUpdate?() } }
|
||||
|
||||
typealias VoidBlock = () -> Void
|
||||
|
||||
var didUpdate: VoidBlock?
|
||||
}
|
||||
|
||||
class FirstToneHintView: UIView {
|
||||
var style = FirstToneHintViewStyle() { didSet { updateUI() } }
|
||||
var mustHidden = false
|
||||
private let loadViews: [UIView] = [.init(), .init(), .init()]
|
||||
private var loadViewConstraints = [NSLayoutConstraint]()
|
||||
/// 剩余开始时间 ms
|
||||
private var remainingTime = 0
|
||||
private var lastRemainingTime = 0
|
||||
fileprivate let logTag = "FirstToneHintView"
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
for view in loadViews {
|
||||
view.backgroundColor = style.backgroundColor
|
||||
view.layer.cornerRadius = style.size / 2
|
||||
view.layer.masksToBounds = true
|
||||
addSubview(view)
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let widthConstraint = view.widthAnchor.constraint(equalToConstant: style.size)
|
||||
let heightConstraint = view.heightAnchor.constraint(equalToConstant: style.size)
|
||||
widthConstraint.isActive = true
|
||||
heightConstraint.isActive = true
|
||||
loadViewConstraints.append(widthConstraint)
|
||||
loadViewConstraints.append(heightConstraint)
|
||||
}
|
||||
|
||||
loadViews[1].centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
|
||||
loadViews[1].centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||
|
||||
loadViews[0].leftAnchor.constraint(equalTo: loadViews[1].rightAnchor, constant: 10).isActive = true
|
||||
loadViews[0].centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||
|
||||
loadViews[2].rightAnchor.constraint(equalTo: loadViews[1].leftAnchor, constant: -10).isActive = true
|
||||
loadViews[2].centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||
isHidden = true
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
for view in loadViews {
|
||||
view.backgroundColor = style.backgroundColor
|
||||
view.layer.cornerRadius = style.size / 2
|
||||
}
|
||||
|
||||
for constraint in loadViewConstraints {
|
||||
constraint.constant = style.size
|
||||
}
|
||||
}
|
||||
|
||||
func updateStyle(style: FirstToneHintViewStyle) {
|
||||
self.style = style
|
||||
updateUI()
|
||||
}
|
||||
|
||||
/// 设置剩余开始时间 (前奏时间 - 当前歌曲进度)
|
||||
/// - Parameter time: 剩余开始唱第一句的时间
|
||||
func setRemainingTime(time: Int) {
|
||||
if time < 0 {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
/** 过滤,500ms设置一次 **/
|
||||
if lastRemainingTime == 0 {
|
||||
lastRemainingTime = time
|
||||
}
|
||||
if lastRemainingTime - time < 500 {
|
||||
return
|
||||
}
|
||||
lastRemainingTime = time
|
||||
|
||||
remainingTime = time
|
||||
Log.info(text: "remainingTime: \(remainingTime)", tag: logTag)
|
||||
loadViews[0].isHidden = (remainingTime >= 3 * 1000) ? !loadViews[0].isHidden : true
|
||||
loadViews[1].isHidden = !(remainingTime >= 2 * 1000)
|
||||
loadViews[2].isHidden = !(remainingTime >= 1 * 1000)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isHidden = true
|
||||
self.loadViews[0].isHidden = false
|
||||
self.loadViews[1].isHidden = false
|
||||
self.loadViews[2].isHidden = false
|
||||
lastRemainingTime = 0
|
||||
remainingTime = 0
|
||||
}
|
||||
|
||||
}
|
||||
188
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Lyrics/LyricCell.swift
generated
Normal file
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// LyricsCell.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class LyricCell: UITableViewCell {
|
||||
private let label = LyricLabel()
|
||||
/// 正常歌词背景色
|
||||
var textNormalColor: UIColor = .gray {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
/// 选中的歌词颜色
|
||||
var textSelectedColor: UIColor = .white {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
/// 高亮的歌词填充颜色
|
||||
var textHighlightedColor: UIColor = .colorWithHex(hexStr: "#FF8AB4") {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
/// 正常歌词文字大小
|
||||
var textNormalFontSize: UIFont = .systemFont(ofSize: 15) {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
/// 高亮歌词文字大小
|
||||
var textHighlightFontSize: UIFont = .systemFont(ofSize: 18) {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
/// 最大宽度
|
||||
var maxWidth: CGFloat = UIScreen.main.bounds.width - 30 {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
/// 上下间距
|
||||
var lyricLineSpacing: CGFloat = 10 {
|
||||
didSet { updateUI() }
|
||||
}
|
||||
|
||||
private var hasSetupUI = false
|
||||
private var leftConstraint, rightConstraint, bottomConstraint, topConstraint: NSLayoutConstraint!
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
label.text = ""
|
||||
label.status = .normal
|
||||
label.progressRate = 0
|
||||
leftConstraint.constant = 0
|
||||
label.alpha = 1
|
||||
contentView.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
guard !hasSetupUI else {
|
||||
return
|
||||
}
|
||||
|
||||
contentView.backgroundColor = .clear
|
||||
backgroundColor = .clear
|
||||
|
||||
contentView.addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
leftConstraint = label.leftAnchor.constraint(equalTo: contentView.leftAnchor)
|
||||
rightConstraint = label.rightAnchor.constraint(greaterThanOrEqualTo: contentView.rightAnchor)
|
||||
topConstraint = label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10)
|
||||
bottomConstraint = label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10)
|
||||
topConstraint!.isActive = true
|
||||
bottomConstraint!.isActive = true
|
||||
leftConstraint.isActive = true
|
||||
rightConstraint.isActive = true
|
||||
hasSetupUI = true
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
if topConstraint!.constant != lyricLineSpacing {
|
||||
topConstraint!.constant = lyricLineSpacing
|
||||
bottomConstraint!.constant = -1 * lyricLineSpacing
|
||||
}
|
||||
label.textNormalColor = textNormalColor
|
||||
label.textSelectedColor = textSelectedColor
|
||||
label.textHighlightedColor = textHighlightedColor
|
||||
label.textNormalFontSize = textNormalFontSize
|
||||
label.textHighlightFontSize = textHighlightFontSize
|
||||
}
|
||||
|
||||
func update(model: Model) {
|
||||
if model.text.contains("我不会发现") {
|
||||
print("contentView.bounds.width: \(contentView.bounds.width)")
|
||||
}
|
||||
|
||||
label.text = model.text
|
||||
label.status = model.status
|
||||
label.progressRate = CGFloat(model.progressRate)
|
||||
|
||||
rollLabelIfNeed(model: model)
|
||||
}
|
||||
|
||||
private func rollLabelIfNeed(model: Model) {
|
||||
if model.status == .normal { /** 不需要滚动 **/
|
||||
leftConstraint.constant = 0
|
||||
return
|
||||
}
|
||||
|
||||
if model.status == .selectedOrHighlighted,
|
||||
label.bounds.width <= contentView.bounds.width { /** 不需要滚动 **/
|
||||
leftConstraint.constant = 0
|
||||
return
|
||||
}
|
||||
|
||||
let progressRatio = model.progressRate
|
||||
/** 需要滚动label **/
|
||||
/// 当前显示文字占据该句的比率
|
||||
let displayRatio = contentView.bounds.width / label.bounds.width
|
||||
/// 开始滚动的比率位置
|
||||
let startRollingPositionRatio = displayRatio/2
|
||||
/// 结束滚动的比率位置
|
||||
let stopRollingPositionRatio = 1 - startRollingPositionRatio
|
||||
|
||||
if progressRatio > startRollingPositionRatio, progressRatio < stopRollingPositionRatio {
|
||||
/// 计算比率差
|
||||
let deltaRatio = progressRatio - startRollingPositionRatio
|
||||
/// 计算视图的偏移距离
|
||||
let constant = deltaRatio * label.bounds.width
|
||||
/// 更新label的左边距
|
||||
leftConstraint.constant = constant * -1
|
||||
UIView.animate(withDuration: 0.2) { [weak self] in
|
||||
self?.contentView.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricCell {
|
||||
class Model {
|
||||
let text: String
|
||||
/// 进度 0-1
|
||||
var progressRate: Double
|
||||
/// 开始时间 单位为毫秒
|
||||
let beginTime: Int
|
||||
/// 总时长 (ms)
|
||||
let duration: Int
|
||||
/// 状态
|
||||
var status: Status
|
||||
|
||||
var tones: [LyricToneModel]
|
||||
|
||||
init(text: String,
|
||||
progressRate: Double,
|
||||
beginTime: Int,
|
||||
duration: Int,
|
||||
status: Status,
|
||||
tones: [LyricToneModel]) {
|
||||
self.text = text
|
||||
self.progressRate = progressRate
|
||||
self.beginTime = beginTime
|
||||
self.duration = duration
|
||||
self.status = status
|
||||
self.tones = tones
|
||||
}
|
||||
|
||||
func update(progressRate: Double) {
|
||||
self.progressRate = progressRate
|
||||
}
|
||||
|
||||
func update(status: Status) {
|
||||
self.status = status
|
||||
}
|
||||
|
||||
var endTime: Int {
|
||||
beginTime + duration
|
||||
}
|
||||
}
|
||||
|
||||
typealias Status = LyricLabel.Status
|
||||
}
|
||||
79
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Lyrics/LyricLabel.swift
generated
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// LyricsLabel.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/23.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class LyricLabel: UILabel {
|
||||
/// [0, 1]
|
||||
var progressRate: CGFloat = 0 { didSet { setNeedsDisplay() } }
|
||||
/// 正常歌词颜色
|
||||
var textNormalColor: UIColor = .gray
|
||||
/// 选中的歌词颜色
|
||||
var textSelectedColor: UIColor = .white
|
||||
/// 高亮的歌词颜色
|
||||
var textHighlightedColor: UIColor = .orange
|
||||
/// 正常歌词文字大小
|
||||
var textNormalFontSize: UIFont = .systemFont(ofSize: 15)
|
||||
/// 高亮歌词文字大小
|
||||
var textHighlightFontSize: UIFont = .systemFont(ofSize: 18)
|
||||
|
||||
var status: Status = .normal { didSet { updateState() } }
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
textAlignment = .center
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func updateState() {
|
||||
if status == .selectedOrHighlighted {
|
||||
textColor = textSelectedColor
|
||||
font = textHighlightFontSize
|
||||
}
|
||||
else {
|
||||
textColor = textNormalColor
|
||||
font = textNormalFontSize
|
||||
}
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
super.draw(rect)
|
||||
if progressRate <= 0 {
|
||||
return
|
||||
}
|
||||
let textWidth = sizeThatFits(CGSize(width: CGFloat(MAXFLOAT),
|
||||
height: font.lineHeight)).width
|
||||
let leftRightSpace = (bounds.width - textWidth) / 2
|
||||
let path = CGMutablePath()
|
||||
let fillRect = CGRect(x: leftRightSpace,
|
||||
y: 0,
|
||||
width: progressRate * textWidth,
|
||||
height: font.lineHeight)
|
||||
path.addRect(fillRect)
|
||||
|
||||
if let context = UIGraphicsGetCurrentContext(), !path.isEmpty {
|
||||
context.setLineWidth(1.0)
|
||||
context.setLineCap(.butt)
|
||||
context.addPath(path)
|
||||
context.clip()
|
||||
let _textColor = textColor
|
||||
textColor = textHighlightedColor
|
||||
super.draw(rect)
|
||||
textColor = _textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricLabel {
|
||||
enum Status {
|
||||
case normal
|
||||
case selectedOrHighlighted
|
||||
}
|
||||
}
|
||||
99
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Lyrics/LyricMachine+Events.swift
generated
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// LyricMachine+Events.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/3/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol LyricMachineDelegate: NSObjectProtocol {
|
||||
/// 在 `setLyricData` 完成后调用
|
||||
func lyricMachine(_ lyricMachine: LyricMachine,
|
||||
didSetLyricData datas: [LyricCell.Model])
|
||||
|
||||
/// 在 `setLyricData` 完成后调用
|
||||
/// - Parameters:
|
||||
/// - remainingTime: 倒计时剩余时间
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didUpdate remainingTime: Int)
|
||||
|
||||
/// 换行时调用
|
||||
func lyricMachine(_ lyricMachine: LyricMachine,
|
||||
didStartLineAt newIndexPath: IndexPath,
|
||||
oldIndexPath: IndexPath,
|
||||
animated: Bool)
|
||||
/// 行更新时调用
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didUpdateLineAt indexPath: IndexPath)
|
||||
|
||||
/// Consloe信息 (仅用于debug)
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didUpdateConsloe text: String)
|
||||
}
|
||||
|
||||
extension LyricMachine { /** invoke **/
|
||||
func invokeLyricMachine(didSetLyricData datas: [LyricCell.Model]) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.lyricMachine(self, didSetLyricData: datas)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.lyricMachine(self, didSetLyricData: datas)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeLyricMachine(didUpdate remainingTime: Int) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.lyricMachine(self, didUpdate: remainingTime)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.lyricMachine(self, didUpdate: remainingTime)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeLyricMachine(didStartLineAt newIndexPath: IndexPath,
|
||||
oldIndexPath: IndexPath, animated: Bool) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.lyricMachine(self,
|
||||
didStartLineAt: newIndexPath,
|
||||
oldIndexPath: oldIndexPath,
|
||||
animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.lyricMachine(self,
|
||||
didStartLineAt: newIndexPath,
|
||||
oldIndexPath: oldIndexPath,
|
||||
animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeLyricMachine(didUpdateLineAt indexPath: IndexPath) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.lyricMachine(self, didUpdateLineAt: indexPath)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.lyricMachine(self, didUpdateLineAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeLyricMachine(didUpdateConsloe text: String) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.lyricMachine(self, didUpdateConsloe: text)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.lyricMachine(self, didUpdateConsloe: text)
|
||||
}
|
||||
}
|
||||
}
|
||||
167
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Lyrics/LyricMachine.swift
generated
Normal file
@@ -0,0 +1,167 @@
|
||||
//
|
||||
// LyricMachine.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/3/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class LyricMachine {
|
||||
weak var delegate: LyricMachineDelegate?
|
||||
fileprivate var lyricData: LyricModel?
|
||||
fileprivate var dataList = [LyricCell.Model]()
|
||||
fileprivate var progress: Int = 0
|
||||
fileprivate var currentIndex = 0
|
||||
fileprivate var ignoreAnimationAfterDrag = false
|
||||
fileprivate var isStart = false
|
||||
fileprivate let logTag = "LyricMachine"
|
||||
fileprivate let queue = DispatchQueue(label: "queue.LyricMachine")
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
func setLyricData(data: LyricModel?) {
|
||||
queue.async { [weak self] in
|
||||
self?._setLyricData(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
func setProgress(progress: Int) {
|
||||
queue.async { [weak self] in
|
||||
self?._setProgress(progress: progress)
|
||||
}
|
||||
}
|
||||
|
||||
func setDragEnd() {
|
||||
queue.async { [weak self] in
|
||||
self?._setDragEnd()
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
queue.async { [weak self] in
|
||||
self?._reset()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func _setLyricData(data: LyricModel?) {
|
||||
lyricData = data
|
||||
dataList = data?.lines.map({ LyricCell.Model(text: $0.content,
|
||||
progressRate: 0,
|
||||
beginTime: $0.beginTime,
|
||||
duration: $0.duration,
|
||||
status: .normal,
|
||||
tones: $0.tones) }) ?? []
|
||||
if let first = dataList.first { /** 默认高亮第一个 **/
|
||||
first.update(status: .selectedOrHighlighted)
|
||||
}
|
||||
invokeLyricMachine(didSetLyricData: dataList)
|
||||
isStart = true
|
||||
Log.info(text: "_setLyricData", tag: logTag)
|
||||
}
|
||||
|
||||
private func _setProgress(progress: Int) {
|
||||
guard let data = lyricData else { return }
|
||||
let remainingTime = data.preludeEndPosition - progress
|
||||
invokeLyricMachine(didUpdate: remainingTime)
|
||||
|
||||
if currentIndex < dataList.count {
|
||||
if let item = dataList.enumerated().first(where: { progress < $0.element.endTime }) { /** 找出第一个要高亮的 **/
|
||||
let newCurrentIndex = item.offset
|
||||
|
||||
if newCurrentIndex != currentIndex { /** 切换了新的 **/
|
||||
/// 恢复上一个
|
||||
let lastIndex = currentIndex
|
||||
let last = dataList[lastIndex]
|
||||
last.update(status: .normal)
|
||||
last.update(progressRate: 0)
|
||||
let lastIndexPath = IndexPath(row: lastIndex, section: 0)
|
||||
|
||||
/// 更新当前
|
||||
currentIndex = newCurrentIndex
|
||||
let current = dataList[currentIndex]
|
||||
current.update(status: .selectedOrHighlighted)
|
||||
var progressRate: Double = 0
|
||||
if progress > item.element.beginTime, progress <= item.element.endTime { /** 计算比例 **/
|
||||
progressRate = LyricMachine.calculateProgressRate(progress: progress,
|
||||
model: item.element,
|
||||
canScoring: data.hasPitch) ?? current.progressRate
|
||||
}
|
||||
current.update(progressRate: progressRate)
|
||||
let indexPath = IndexPath(row: currentIndex, section: 0)
|
||||
|
||||
let text = "new \(currentIndex) progressRate: \(progressRate) progress:\(progress)"
|
||||
Log.debug(text: text, tag: logTag)
|
||||
invokeLyricMachine(didStartLineAt: indexPath, oldIndexPath: lastIndexPath, animated: !ignoreAnimationAfterDrag)
|
||||
ignoreAnimationAfterDrag = false
|
||||
invokeLyricMachine(didUpdateConsloe: text)
|
||||
return
|
||||
}
|
||||
|
||||
if newCurrentIndex == currentIndex,
|
||||
progress > item.element.beginTime,
|
||||
progress <= item.element.endTime { /** 还在原来的句子 **/
|
||||
|
||||
let current = dataList[currentIndex]
|
||||
let progressRate: Double = LyricMachine.calculateProgressRate(progress: progress,
|
||||
model: item.element,
|
||||
canScoring: data.hasPitch) ?? current.progressRate
|
||||
current.update(progressRate: progressRate)
|
||||
let indexPath = IndexPath(row: currentIndex, section: 0)
|
||||
invokeLyricMachine(didUpdateLineAt: indexPath)
|
||||
|
||||
let text = "append \(currentIndex) progressRate: \(progressRate) progress:\(progress)"
|
||||
Log.debug(text: text, tag: logTag)
|
||||
invokeLyricMachine(didUpdateConsloe: text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func _setDragEnd() {
|
||||
ignoreAnimationAfterDrag = true
|
||||
Log.info(text: "_setDragEnd", tag: logTag)
|
||||
}
|
||||
|
||||
private func _reset() {
|
||||
isStart = false
|
||||
lyricData = nil
|
||||
dataList = []
|
||||
currentIndex = 0
|
||||
progress = 0
|
||||
ignoreAnimationAfterDrag = false
|
||||
Log.info(text: "_reset", tag: logTag)
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricMachine {
|
||||
/// 计算句子的进度
|
||||
/// - Parameters:
|
||||
/// - canScoring: 是否可以打分(数据源是lrc格式不可打分)
|
||||
/// - Returns: `nil` 表示无法计算, 其他: [0, 1]
|
||||
static func calculateProgressRate(progress: Int,
|
||||
model: LyricCell.Model,
|
||||
canScoring: Bool) -> Double? {
|
||||
if canScoring {
|
||||
let toneCount = model.tones.filter({ $0.word.isEmpty == false }).count
|
||||
for (index, tone) in model.tones.enumerated() {
|
||||
if progress >= tone.beginTime, progress <= tone.beginTime + tone.duration {
|
||||
let progressRate = Double((progress - tone.beginTime)) / Double(tone.duration)
|
||||
let total = (Double(index) + progressRate) / Double(toneCount)
|
||||
return total
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
else {
|
||||
let progressRate = Double(progress - model.beginTime) / Double(model.duration)
|
||||
return progressRate
|
||||
}
|
||||
}
|
||||
}
|
||||
296
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Lyrics/LyricsView.swift
generated
Normal file
@@ -0,0 +1,296 @@
|
||||
//
|
||||
// LyricView.swift
|
||||
// NewApi
|
||||
//
|
||||
// Created by ZYP on 2022/11/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol LyricsViewDelegate: NSObjectProtocol {
|
||||
func onLyricsViewBegainDrag(view: LyricsView)
|
||||
func onLyricsView(view: LyricsView, didDragTo position: Int)
|
||||
}
|
||||
|
||||
public class LyricsView: UIView {
|
||||
/// 无歌词提示文案
|
||||
@objc public var noLyricTipsText: String = "无歌词" { didSet { updateUI() } }
|
||||
/// 无歌词提示文字颜色
|
||||
@objc public var noLyricTipsColor: UIColor = .orange { didSet { updateUI() } }
|
||||
/// 无歌词提示文字大小
|
||||
@objc public var noLyricTipsFont: UIFont = .systemFont(ofSize: 17) { didSet { updateUI() } }
|
||||
/// 是否隐藏等待开始圆点
|
||||
@objc public var waitingViewHidden: Bool = false { didSet { updateUI() } }
|
||||
/// 非活跃歌词颜色(未唱、已唱)
|
||||
@objc public var inactiveLineTextColor: UIColor = .white
|
||||
/// 活跃的歌词颜色 (即将唱)
|
||||
@objc public var activeLineUpcomingTextColor: UIColor = .white
|
||||
/// 活跃的歌词颜色 (正在唱)
|
||||
@objc public var activeLinePlayedTextColor: UIColor = .colorWithHex(hexStr: "#FF8AB4")
|
||||
/// 非活跃歌词文字大小(未唱、已唱)
|
||||
@objc public var inactiveLineFontSize = UIFont(name: "PingFangSC-Semibold", size: 15)!
|
||||
/// 活跃歌词文字大小 (即将唱、正在唱)
|
||||
@objc public var activeLineUpcomingFontSize = UIFont(name: "PingFangSC-Semibold", size: 18)!
|
||||
/// 歌词最大宽度
|
||||
@objc public var maxWidth: CGFloat = UIScreen.main.bounds.width - 30
|
||||
/// 歌词上下间距
|
||||
@objc public var lyricLineSpacing: CGFloat = 10
|
||||
/// 等待开始圆点风格
|
||||
@objc public let firstToneHintViewStyle: FirstToneHintViewStyle = .init()
|
||||
/// 是否开启拖拽
|
||||
@objc public var draggable: Bool = false
|
||||
/// 活跃的歌词的位置
|
||||
@objc public var activeLinePosition: UITableView.ScrollPosition = .middle
|
||||
/// use for debug only
|
||||
@objc public var showDebugView = false { didSet { updateUI() } }
|
||||
|
||||
weak var delegate: LyricsViewDelegate?
|
||||
/// 倒计时view
|
||||
fileprivate let firstToneHintView = FirstToneHintView()
|
||||
fileprivate let consoleView = ConsoleView()
|
||||
fileprivate let noLyricTipsLabel = UILabel()
|
||||
fileprivate let tableView = UITableView()
|
||||
fileprivate let logTag = "LyricsView"
|
||||
fileprivate var dataList = [LyricCell.Model]()
|
||||
fileprivate var isNoLyric = false
|
||||
fileprivate let referenceLineView = UIView()
|
||||
fileprivate var isDragging = false { didSet { referenceLineView.isHidden = !isDragging } }
|
||||
fileprivate var tableViewTopConstraint: NSLayoutConstraint!, firstToneHintViewHeightConstraint: NSLayoutConstraint!
|
||||
fileprivate let lyricMachine = LyricMachine()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
updateUI()
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
func setLyricData(data: LyricModel?) {
|
||||
isNoLyric = data == nil
|
||||
updateUI()
|
||||
lyricMachine.setLyricData(data: data)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lyricMachine.reset()
|
||||
tableView.isScrollEnabled = false
|
||||
firstToneHintView.reset()
|
||||
dataList = []
|
||||
tableView.reloadData()
|
||||
Log.info(text: "reset", tag: logTag)
|
||||
}
|
||||
|
||||
func setProgress(progress: Int) {
|
||||
guard !isDragging else { return }
|
||||
lyricMachine.setProgress(progress: progress)
|
||||
}
|
||||
|
||||
private func dragCellHandler(scrollView: UIScrollView) {
|
||||
guard !dataList.isEmpty else {
|
||||
Log.error(error: "dragCellHandler dataList isEmpty", tag: logTag)
|
||||
return
|
||||
}
|
||||
let point = CGPoint(x: 0, y: scrollView.contentOffset.y + scrollView.bounds.height * 0.5)
|
||||
var indexPath = tableView.indexPathForRow(at: point)
|
||||
if indexPath == nil { /** 顶部和底部空隙 **/
|
||||
if scrollView.contentOffset.y < 0 {
|
||||
indexPath = IndexPath(row: 0, section: 0)
|
||||
}
|
||||
else {
|
||||
Log.debug(text: "selecte last at \(point.y)", tag: logTag)
|
||||
indexPath = IndexPath(row: dataList.count - 1, section: 0)
|
||||
}
|
||||
}
|
||||
Log.debug(text:"dragCellHandler \(indexPath!.row) \(point.y) = \(scrollView.contentOffset.y) + \(scrollView.bounds.height * 0.5)", tag: logTag)
|
||||
let model = dataList[indexPath!.row]
|
||||
delegate?.onLyricsView(view: self, didDragTo: model.beginTime)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
extension LyricsView {
|
||||
fileprivate func setupUI() {
|
||||
backgroundColor = .clear
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.showsVerticalScrollIndicator = false
|
||||
referenceLineView.backgroundColor = .red
|
||||
referenceLineView.isHidden = true
|
||||
firstToneHintView.style = firstToneHintViewStyle
|
||||
addSubview(noLyricTipsLabel)
|
||||
addSubview(firstToneHintView)
|
||||
addSubview(tableView)
|
||||
addSubview(referenceLineView)
|
||||
|
||||
noLyricTipsLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
firstToneHintView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
referenceLineView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
noLyricTipsLabel.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
|
||||
noLyricTipsLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||
|
||||
let topSpace: CGFloat = 5
|
||||
firstToneHintView.topAnchor.constraint(equalTo: topAnchor, constant: topSpace).isActive = true
|
||||
firstToneHintView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
firstToneHintView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
firstToneHintViewHeightConstraint = firstToneHintView.heightAnchor.constraint(equalToConstant: firstToneHintViewStyle.size)
|
||||
firstToneHintViewHeightConstraint.isActive = true
|
||||
|
||||
let constant = firstToneHintViewStyle.size + firstToneHintViewStyle.bottomMargin + topSpace
|
||||
tableViewTopConstraint = tableView.topAnchor.constraint(equalTo: topAnchor, constant: constant)
|
||||
tableViewTopConstraint.isActive = true
|
||||
tableView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
tableView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
|
||||
referenceLineView.heightAnchor.constraint(equalToConstant: 1).isActive = true
|
||||
referenceLineView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
referenceLineView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
referenceLineView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor).isActive = true
|
||||
}
|
||||
|
||||
fileprivate func commonInit() {
|
||||
tableView.register(LyricCell.self, forCellReuseIdentifier: "LyricsCell")
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
firstToneHintViewStyle.didUpdate = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.updateUI()
|
||||
}
|
||||
lyricMachine.delegate = self
|
||||
}
|
||||
|
||||
fileprivate func updateUI() {
|
||||
noLyricTipsLabel.textColor = noLyricTipsColor
|
||||
noLyricTipsLabel.text = noLyricTipsText
|
||||
noLyricTipsLabel.font = noLyricTipsFont
|
||||
noLyricTipsLabel.isHidden = !isNoLyric
|
||||
tableView.isHidden = isNoLyric
|
||||
tableView.isScrollEnabled = draggable
|
||||
firstToneHintView.isHidden = isNoLyric ? true : waitingViewHidden
|
||||
firstToneHintView.style = firstToneHintViewStyle
|
||||
let constant = firstToneHintViewStyle.size + firstToneHintViewStyle.bottomMargin
|
||||
tableViewTopConstraint.constant = constant
|
||||
firstToneHintViewHeightConstraint.constant = firstToneHintViewStyle.size
|
||||
if tableView.bounds.width > 0 {
|
||||
let viewFrame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: tableView.bounds.height/2)
|
||||
tableView.tableHeaderView = .init(frame: viewFrame)
|
||||
tableView.tableFooterView = .init(frame: viewFrame)
|
||||
Log.debug(text: "viewFrame:\(viewFrame.height)", tag: logTag)
|
||||
}
|
||||
if showDebugView {
|
||||
if !subviews.contains(consoleView) {
|
||||
addSubview(consoleView)
|
||||
consoleView.translatesAutoresizingMaskIntoConstraints = false
|
||||
consoleView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
consoleView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||
consoleView.widthAnchor.constraint(equalToConstant: 80).isActive = true
|
||||
consoleView.heightAnchor.constraint(equalToConstant: 80).isActive = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
consoleView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource UITableViewDelegate
|
||||
extension LyricsView: UITableViewDataSource, UITableViewDelegate {
|
||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return dataList.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "LyricsCell", for: indexPath) as! LyricCell
|
||||
cell.textNormalColor = inactiveLineTextColor
|
||||
cell.textSelectedColor = activeLineUpcomingTextColor
|
||||
cell.textHighlightedColor = activeLinePlayedTextColor
|
||||
cell.textNormalFontSize = inactiveLineFontSize
|
||||
cell.textHighlightFontSize = activeLineUpcomingFontSize
|
||||
cell.maxWidth = maxWidth
|
||||
cell.lyricLineSpacing = lyricLineSpacing
|
||||
|
||||
let model = dataList[indexPath.row]
|
||||
cell.update(model: model)
|
||||
return cell
|
||||
}
|
||||
|
||||
public func scrollViewWillBeginDragging(_: UIScrollView) {
|
||||
Log.info(text: "scrollViewWillBeginDragging", tag: logTag)
|
||||
isDragging = true
|
||||
delegate?.onLyricsViewBegainDrag(view: self)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
Log.info(text: "scrollViewDidEndDragging decelerate \(decelerate)", tag: logTag)
|
||||
if isDragging {
|
||||
dragCellHandler(scrollView: scrollView)
|
||||
lyricMachine.setDragEnd()
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
Log.info(text: "scrollViewDidEndDecelerating", tag: logTag)
|
||||
if isDragging {
|
||||
dragCellHandler(scrollView: scrollView)
|
||||
lyricMachine.setDragEnd()
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LyricMachineDelegate
|
||||
extension LyricsView: LyricMachineDelegate {
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didSetLyricData datas: [LyricCell.Model]) {
|
||||
Log.debug(text: "set dataList \(datas.count)", tag: logTag)
|
||||
dataList = datas
|
||||
tableView.reloadData()
|
||||
|
||||
if !datas.isEmpty { /** 默认高亮第一个 **/
|
||||
let indexPath = IndexPath(row: 0, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: activeLinePosition, animated: true)
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.reloadRows(at: [indexPath], with: .fade)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didUpdate remainingTime: Int) {
|
||||
firstToneHintView.setRemainingTime(time: remainingTime)
|
||||
}
|
||||
|
||||
func lyricMachine(_ lyricMachine: LyricMachine,
|
||||
didStartLineAt newIndexPath: IndexPath,
|
||||
oldIndexPath: IndexPath,
|
||||
animated: Bool) {
|
||||
guard !dataList.isEmpty else {
|
||||
Log.debug(text: "update tableView will be ignore because dataList is empty", tag: logTag)
|
||||
return
|
||||
}
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.reloadRows(at: [newIndexPath, oldIndexPath], with: .fade)
|
||||
}
|
||||
tableView.scrollToRow(at: newIndexPath, at: activeLinePosition, animated: animated)
|
||||
}
|
||||
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didUpdateLineAt indexPath: IndexPath) {
|
||||
if let cell = tableView.cellForRow(at: indexPath) as? LyricCell{
|
||||
let model = dataList[indexPath.row]
|
||||
cell.update(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
func lyricMachine(_ lyricMachine: LyricMachine, didUpdateConsloe text: String) {
|
||||
consoleView.set(text: text)
|
||||
}
|
||||
}
|
||||
161
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Model.swift
generated
Normal file
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// Model.swift
|
||||
// NewApi
|
||||
//
|
||||
// Created by ZYP on 2022/11/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc public enum MusicType: Int, CustomStringConvertible {
|
||||
/// 快歌
|
||||
case fast = 1
|
||||
/// 慢歌
|
||||
case slow = 2
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .fast:
|
||||
return "fast"
|
||||
default:
|
||||
return "slow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class LyricModel: NSObject {
|
||||
/// 歌曲名称
|
||||
@objc public var name: String
|
||||
/// 歌星名称
|
||||
@objc public var singer: String
|
||||
/// 歌曲类型
|
||||
@objc public var type: MusicType
|
||||
/// 行信息
|
||||
@objc public var lines: [LyricLineModel]
|
||||
/// 前奏结束时间
|
||||
@objc public var preludeEndPosition: Int
|
||||
/// 歌词总时长 (ms)
|
||||
@objc public var duration: Int
|
||||
/// 是否有pitch值
|
||||
@objc public var hasPitch: Bool
|
||||
|
||||
@objc public init(name: String,
|
||||
singer: String,
|
||||
type: MusicType,
|
||||
lines: [LyricLineModel],
|
||||
preludeEndPosition: Int,
|
||||
duration: Int,
|
||||
hasPitch: Bool) {
|
||||
self.name = name
|
||||
self.singer = singer
|
||||
self.type = type
|
||||
self.lines = lines
|
||||
self.preludeEndPosition = preludeEndPosition
|
||||
self.duration = duration
|
||||
self.hasPitch = hasPitch
|
||||
}
|
||||
|
||||
/// 解析歌词文件xml数据
|
||||
/// - Parameter data: xml二进制数据
|
||||
/// - Returns: 歌词信息
|
||||
@objc public init(data: Data) throws {
|
||||
self.name = "name"
|
||||
self.singer = "singer"
|
||||
self.type = .fast
|
||||
self.lines = []
|
||||
self.preludeEndPosition = 0
|
||||
self.duration = 0
|
||||
self.hasPitch = true
|
||||
}
|
||||
|
||||
@objc public override init() {
|
||||
self.name = ""
|
||||
self.singer = ""
|
||||
self.type = .fast
|
||||
self.lines = []
|
||||
self.preludeEndPosition = 0
|
||||
self.duration = 0
|
||||
self.hasPitch = false
|
||||
super.init()
|
||||
}
|
||||
|
||||
@objc public override var description: String {
|
||||
let dict = ["name" : name,
|
||||
"singer" : singer,
|
||||
"type" : type,
|
||||
"preludeEndPosition" : preludeEndPosition,
|
||||
"duration" : duration,
|
||||
"hasPitch" : hasPitch] as [String : Any]
|
||||
return "\(dict)"
|
||||
}
|
||||
}
|
||||
|
||||
public class LyricLineModel: NSObject {
|
||||
/// 开始时间 单位为毫秒
|
||||
@objc public var beginTime: Int
|
||||
/// 总时长 (ms)
|
||||
@objc public var duration: Int
|
||||
/// 行内容
|
||||
@objc public var content: String
|
||||
/// 每行歌词的字信息
|
||||
@objc public var tones: [LyricToneModel]
|
||||
|
||||
@objc public init(beginTime: Int,
|
||||
duration: Int,
|
||||
content: String,
|
||||
tones: [LyricToneModel]) {
|
||||
self.beginTime = beginTime
|
||||
self.duration = duration
|
||||
self.content = content
|
||||
self.tones = tones
|
||||
}
|
||||
}
|
||||
|
||||
public class LyricToneModel: NSObject {
|
||||
@objc public let beginTime: Int
|
||||
@objc public let duration: Int
|
||||
@objc public var word: String
|
||||
@objc public let pitch: Double
|
||||
@objc public var lang: Lang
|
||||
@objc public let pronounce: String
|
||||
|
||||
@objc public init(beginTime: Int,
|
||||
duration: Int,
|
||||
word: String,
|
||||
pitch: Double,
|
||||
lang: Lang,
|
||||
pronounce: String) {
|
||||
self.beginTime = beginTime
|
||||
self.duration = duration
|
||||
self.word = word
|
||||
self.pitch = pitch
|
||||
self.lang = lang
|
||||
self.pronounce = pronounce
|
||||
}
|
||||
}
|
||||
|
||||
/// 字得分
|
||||
public class ToneScoreModel: NSObject {
|
||||
@objc public let tone: LyricToneModel
|
||||
/// 0-100
|
||||
@objc public var score: Float
|
||||
var scores = [Float]()
|
||||
|
||||
@objc public init(tone: LyricToneModel,
|
||||
score: Float) {
|
||||
self.tone = tone
|
||||
self.score = score
|
||||
}
|
||||
|
||||
func addScore(score: Float) {
|
||||
scores.append(score)
|
||||
self.score = scores.reduce(0, +) / Float(scores.count)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public enum Lang: Int {
|
||||
case zh = 1
|
||||
case en = 2
|
||||
case unknown = -1
|
||||
}
|
||||
|
||||
81
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/DataStructs.swift
generated
Normal file
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// DataStructs.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/12/14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Queue<T> {
|
||||
private var elements: [T] = []
|
||||
|
||||
mutating func enqueue(_ element: T) {
|
||||
elements.append(element)
|
||||
}
|
||||
|
||||
mutating func dequeue() -> T? {
|
||||
return elements.isEmpty ? nil : elements.removeFirst()
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
return elements.isEmpty
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
return elements.count
|
||||
}
|
||||
|
||||
func peek() -> T? {
|
||||
return elements.first
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
elements.removeAll()
|
||||
}
|
||||
|
||||
func getAll() -> [T] {
|
||||
return elements
|
||||
}
|
||||
|
||||
mutating func reset(newElements: [T]) {
|
||||
elements = newElements
|
||||
}
|
||||
}
|
||||
|
||||
/// Dictionary for safe, use rwlock
|
||||
class SafeDictionary<T_KEY: Hashable, T_VALUE: Hashable> {
|
||||
private var dict: Dictionary<T_KEY, T_VALUE> = Dictionary()
|
||||
private var rwlock = pthread_rwlock_t()
|
||||
private let logTag = "SafeDictionary"
|
||||
|
||||
init() {
|
||||
Log.debug(text: "init", tag: logTag)
|
||||
pthread_rwlock_init(&rwlock, nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.debug(text: "deinit", tag: logTag)
|
||||
pthread_rwlock_destroy(&rwlock)
|
||||
}
|
||||
|
||||
func set(value: T_VALUE, forkey: T_KEY) {
|
||||
pthread_rwlock_wrlock(&rwlock)
|
||||
dict[forkey] = value
|
||||
pthread_rwlock_unlock(&rwlock)
|
||||
}
|
||||
|
||||
func getValue(forkey: T_KEY) -> T_VALUE? {
|
||||
pthread_rwlock_rdlock(&rwlock)
|
||||
let value = dict[forkey]
|
||||
pthread_rwlock_unlock(&rwlock)
|
||||
return value
|
||||
}
|
||||
|
||||
func removeValue(forkey: T_KEY) {
|
||||
pthread_rwlock_wrlock(&rwlock)
|
||||
dict.removeValue(forKey: forkey)
|
||||
pthread_rwlock_unlock(&rwlock)
|
||||
}
|
||||
}
|
||||
|
||||
116
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/Extensions.swift
generated
Normal file
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// Extensions.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AgoraLyricsScore {}
|
||||
|
||||
extension String {
|
||||
// 字符串截取
|
||||
func textSubstring(startIndex: Int, length: Int) -> String {
|
||||
let startIndex = index(self.startIndex, offsetBy: startIndex)
|
||||
let endIndex = index(startIndex, offsetBy: length)
|
||||
let subvalues = self[startIndex ..< endIndex]
|
||||
return String(subvalues)
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricLineModel {
|
||||
var endTime: Int {
|
||||
beginTime + duration
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricToneModel {
|
||||
var endTime: Int {
|
||||
beginTime + duration
|
||||
}
|
||||
}
|
||||
|
||||
extension Bundle {
|
||||
static var currentBundle: Bundle {
|
||||
let bundle = Bundle(for: AgoraLyricsScore.self)
|
||||
let path = bundle.path(forResource: "AgoraLyricsScoreBundle", ofType: "bundle")
|
||||
if path == nil {
|
||||
Log.error(error: "bundle not found path", tag: "Bundle")
|
||||
}
|
||||
let current = Bundle(path: path!)
|
||||
if current == nil {
|
||||
Log.error(error: "bundle not found path: \(path!)", tag: "Bundle")
|
||||
}
|
||||
return current!
|
||||
}
|
||||
|
||||
func image(name: String) -> UIImage? {
|
||||
return UIImage(named: name, in: self, compatibleWith: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor{
|
||||
class func colorWithHex(hexStr:String) -> UIColor{
|
||||
return UIColor.colorWithHex(hexStr : hexStr, alpha:1)
|
||||
}
|
||||
|
||||
class func colorWithHex(hexStr:String, alpha:Float) -> UIColor{
|
||||
|
||||
var cStr = hexStr.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).uppercased() as NSString;
|
||||
|
||||
if(cStr.length < 6){
|
||||
return UIColor.clear;
|
||||
}
|
||||
|
||||
if(cStr.hasPrefix("0x")){
|
||||
cStr = cStr.substring(from: 2) as NSString
|
||||
}
|
||||
|
||||
if(cStr.hasPrefix("#")){
|
||||
cStr = cStr.substring(from: 1) as NSString
|
||||
}
|
||||
|
||||
if(cStr.length != 6){
|
||||
return UIColor.clear
|
||||
}
|
||||
|
||||
let rStr = (cStr as NSString).substring(to: 2)
|
||||
let gStr = ((cStr as NSString).substring(from: 2) as NSString).substring(to: 2)
|
||||
let bStr = ((cStr as NSString).substring(from: 4) as NSString).substring(to: 2)
|
||||
|
||||
var r: UInt32 = 0x0
|
||||
var g: UInt32 = 0x0
|
||||
var b: UInt32 = 0x0
|
||||
|
||||
Scanner.init(string: rStr).scanHexInt32(&r)
|
||||
Scanner.init(string: gStr).scanHexInt32(&g)
|
||||
Scanner.init(string: bStr).scanHexInt32(&b)
|
||||
|
||||
return UIColor.init(red: CGFloat(r)/255.0, green: CGFloat(g)/255.0, blue: CGFloat(b)/255.0, alpha: CGFloat(alpha));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
/// 获取当前 秒级 时间戳 - 10位
|
||||
var timeStamp: Int {
|
||||
let timeInterval = timeIntervalSince1970
|
||||
let timeStamp = Int(timeInterval)
|
||||
return timeStamp
|
||||
}
|
||||
|
||||
/// 获取当前 毫秒级 时间戳 - 13位
|
||||
var milliStamp: CLongLong {
|
||||
let timeInterval = timeIntervalSince1970
|
||||
let millisecond = CLongLong(round(timeInterval * 1000))
|
||||
return millisecond
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
/// 保留2位小数
|
||||
var keep2: Double {
|
||||
return Double(Darwin.round(self * 100)/100)
|
||||
}
|
||||
}
|
||||
118
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/Log.swift
generated
Normal file
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// LogProvider.swift
|
||||
//
|
||||
//
|
||||
// Created by ZYP on 2021/5/28.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class Log {
|
||||
static let provider = LogManager.share
|
||||
|
||||
static func errorText(text: String,
|
||||
tag: String? = nil) {
|
||||
provider.errorText(text: text, tag: tag)
|
||||
}
|
||||
|
||||
static func error(error: CustomStringConvertible,
|
||||
tag: String? = nil) {
|
||||
provider.errorText(text: error.description, tag: tag)
|
||||
}
|
||||
|
||||
static func info(text: String,
|
||||
tag: String? = nil) {
|
||||
provider.info(text: text, tag: tag)
|
||||
}
|
||||
|
||||
static func debug(text: String,
|
||||
tag: String? = nil) {
|
||||
provider.debug(text: text, tag: tag)
|
||||
}
|
||||
|
||||
static func warning(text: String,
|
||||
tag: String? = nil) {
|
||||
provider.warning(text: text, tag: tag)
|
||||
}
|
||||
|
||||
static func setLoggers(loggers: [ILogger]) {
|
||||
provider.loggers = loggers
|
||||
}
|
||||
}
|
||||
|
||||
class LogManager {
|
||||
static let share = LogManager()
|
||||
var loggers = [ILogger]()
|
||||
private let queue = DispatchQueue(label: "LogManager")
|
||||
let dateFormatter: DateFormatter
|
||||
|
||||
init() {
|
||||
dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd/MM/YY HH:mm:ss:SSS"
|
||||
}
|
||||
|
||||
fileprivate func error(error: Error?,
|
||||
tag: String?,
|
||||
domainName: String) {
|
||||
guard let e = error else {
|
||||
return
|
||||
}
|
||||
var text = "<can not get error info>"
|
||||
if e.localizedDescription.count > 1 {
|
||||
text = e.localizedDescription
|
||||
}
|
||||
|
||||
let err = e as CustomStringConvertible
|
||||
if err.description.count > 1 {
|
||||
text = err.description
|
||||
}
|
||||
|
||||
errorText(text: text,
|
||||
tag: tag)
|
||||
}
|
||||
|
||||
fileprivate func errorText(text: String,
|
||||
tag: String?) {
|
||||
log(type: .error,
|
||||
text: text,
|
||||
tag: tag)
|
||||
}
|
||||
|
||||
fileprivate func info(text: String,
|
||||
tag: String?) {
|
||||
log(type: .info,
|
||||
text: text,
|
||||
tag: tag)
|
||||
}
|
||||
|
||||
fileprivate func warning(text: String,
|
||||
tag: String?) {
|
||||
log(type: .warning,
|
||||
text: text,
|
||||
tag: tag)
|
||||
}
|
||||
|
||||
fileprivate func debug(text: String,
|
||||
tag: String?) {
|
||||
log(type: .debug,
|
||||
text: text,
|
||||
tag: tag)
|
||||
}
|
||||
|
||||
fileprivate func log(type: LoggerLevel,
|
||||
text: String,
|
||||
tag: String?) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, !self.loggers.isEmpty else { return }
|
||||
let time = self.dateFormatter.string(from: .init())
|
||||
self.log(content: text, tag: tag, time: time, level: type)
|
||||
}
|
||||
}
|
||||
|
||||
func log(content: String, tag: String?, time: String, level: LoggerLevel) {
|
||||
for logger in loggers {
|
||||
logger.onLog(content: content, tag: tag, time: time, level: level)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/Logger.swift
generated
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// Logger.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/2/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AgoraComponetLog
|
||||
|
||||
// MARK:- ConsoleLogger
|
||||
|
||||
public class ConsoleLogger: NSObject, ILogger {
|
||||
@objc public func onLog(content: String,
|
||||
tag: String?,
|
||||
time: String,
|
||||
level: LoggerLevel) {
|
||||
|
||||
let text = tag == nil ? "[\(time)][ALS][\(level)]: " + content : "[\(time)][ALS][\(level)][\(tag!)]: " + content
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:- FileLogger
|
||||
|
||||
public class FileLogger: NSObject, ILogger {
|
||||
let componetFileLogger: AgoraComponetFileLogger!
|
||||
let filePrefixName = "agora.AgoraLyricsScore"
|
||||
let maxFileSizeOfBytes: UInt64 = 1024 * 1024 * 1
|
||||
let maxFileCount: UInt = 4
|
||||
let domainName = "ALS"
|
||||
|
||||
@objc public override init() {
|
||||
self.componetFileLogger = AgoraComponetFileLogger(logFilePath: nil,
|
||||
filePrefixName: filePrefixName,
|
||||
maxFileSizeOfBytes: maxFileSizeOfBytes,
|
||||
maxFileCount: maxFileCount,
|
||||
domainName: domainName)
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// init
|
||||
/// - Parameter logFilePath: custom log file path.
|
||||
@objc public init(logFilePath: String) {
|
||||
componetFileLogger = AgoraComponetFileLogger(logFilePath: logFilePath,
|
||||
filePrefixName: filePrefixName,
|
||||
maxFileSizeOfBytes: maxFileSizeOfBytes,
|
||||
maxFileCount: maxFileCount,
|
||||
|
||||
domainName: domainName)
|
||||
}
|
||||
|
||||
@objc public func onLog(content: String,
|
||||
tag: String?,
|
||||
time: String,
|
||||
level: LoggerLevel) {
|
||||
let newLevel = AgoraComponetLoggerLevel(rawValue: UInt(level.rawValue))!
|
||||
componetFileLogger.onLog(withContent: content, tag: tag, time: time, level: newLevel)
|
||||
}
|
||||
}
|
||||
85
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/LrcParser.swift
generated
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// LrcParser.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class LrcParser {
|
||||
private let logTag = "LrcParser"
|
||||
private var lines = [LyricLineModel]()
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
func parseLyricData(data: Data) -> LyricModel? {
|
||||
lines = []
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
Log.errorText(text: "convert to string fail", tag: logTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
return parse(lrcString: string)
|
||||
}
|
||||
|
||||
private func parse(lrcString: String) -> LyricModel? {
|
||||
let lrcConnectArray = lrcString.components(separatedBy: "\r\n")
|
||||
|
||||
let pattern = "\\[[0-9][0-9]:[0-9][0-9].[0-9]{1,}\\]"
|
||||
guard let regular = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
|
||||
return nil
|
||||
}
|
||||
for line in lrcConnectArray {
|
||||
let matchesArray = regular.matches(in: line,
|
||||
options: .reportProgress,
|
||||
range: NSRange(location: 0, length: line.count))
|
||||
guard let lrc = line.components(separatedBy: "]").last else {
|
||||
continue
|
||||
}
|
||||
|
||||
for match in matchesArray {
|
||||
var timeStr = NSString(string: line).substring(with: match.range)
|
||||
// 去掉开头和结尾的[], 得到时间00:00.00
|
||||
timeStr = timeStr.textSubstring(startIndex: 1, length: timeStr.count - 2)
|
||||
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "mm:ss.SS"
|
||||
let date1 = df.date(from: timeStr)
|
||||
let date2 = df.date(from: "00:00.00")
|
||||
var interval1 = date1!.timeIntervalSince1970
|
||||
let interval2 = date2!.timeIntervalSince1970
|
||||
|
||||
interval1 -= interval2
|
||||
if interval1 < 0 {
|
||||
interval1 *= -1
|
||||
}
|
||||
|
||||
let line = LyricLineModel(beginTime: Int(interval1 * 1000),
|
||||
duration: 0,
|
||||
content: lrc,
|
||||
tones: [])
|
||||
if let lastLine = lines.last { /** 把上一句的时间补齐 **/
|
||||
lastLine.duration = line.beginTime - lastLine.beginTime
|
||||
}
|
||||
lines.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
guard lines.count != 0, let preludeEndPosition = lines.first?.beginTime else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let result = LyricModel(name: "unknow",
|
||||
singer: "unknow",
|
||||
type: .fast,
|
||||
lines: lines,
|
||||
preludeEndPosition: preludeEndPosition,
|
||||
duration: 0,
|
||||
hasPitch: false)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
35
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/Parser.swift
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// File.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class Parser {
|
||||
private let logTag = "Parser"
|
||||
func parseLyricData(data: Data) -> LyricModel? {
|
||||
guard data.count > 0 else {
|
||||
Log.errorText(text: "data.count == 0", tag: logTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
Log.errorText(text: "can not verified file type", tag: logTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
if string.first == "<" { /** XML格式 **/
|
||||
let parser = XmlParser()
|
||||
return parser.parseLyricData(data: data)
|
||||
}
|
||||
|
||||
if string.first == "[" { /** LRC格式 **/
|
||||
let parser = LrcParser()
|
||||
return parser.parseLyricData(data: data)
|
||||
}
|
||||
|
||||
fatalError("unknow file type")
|
||||
}
|
||||
}
|
||||
112
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/ProgressChecker.swift
generated
Normal file
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// ProgressChecker.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/3/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ProgressCheckerDelegate: NSObjectProtocol {
|
||||
func progressCheckerDidProgressPause()
|
||||
}
|
||||
|
||||
/// check progress if pause
|
||||
class ProgressChecker: NSObject {
|
||||
private var lastProgress = 0
|
||||
private var progress = 0
|
||||
private var isStart = false
|
||||
private let queue = DispatchQueue(label: "queue.progressChecker")
|
||||
weak var delegate: ProgressCheckerDelegate?
|
||||
private var isPause = false
|
||||
private var timer: DispatchSourceTimer?
|
||||
private let logTag = "ProgressChecker"
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
/// progress input
|
||||
func set(progress: Int) {
|
||||
queue.async { [weak self] in
|
||||
self?._set(progress: progress)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
queue.async { [weak self] in
|
||||
self?._reset()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func _set(progress: Int) {
|
||||
self.progress = progress
|
||||
_start()
|
||||
}
|
||||
|
||||
private func _start() {
|
||||
if isStart { return }
|
||||
isStart = true
|
||||
setupTimer()
|
||||
}
|
||||
|
||||
private func _check() {
|
||||
guard progress > 0 else {
|
||||
return
|
||||
}
|
||||
if lastProgress == progress {
|
||||
if !isPause {
|
||||
invokeProgressCheckerDidProgressPause()
|
||||
}
|
||||
isPause = true
|
||||
}
|
||||
else {
|
||||
isPause = false
|
||||
}
|
||||
lastProgress = progress
|
||||
}
|
||||
|
||||
private func _reset() {
|
||||
cancleTimer()
|
||||
isPause = false
|
||||
isStart = false
|
||||
lastProgress = 0
|
||||
progress = 0
|
||||
}
|
||||
|
||||
private func setupTimer() {
|
||||
timer = DispatchSource.makeTimerSource(flags: [], queue: queue)
|
||||
timer?.schedule(deadline: .now(), repeating: .seconds(1), leeway: .seconds(0))
|
||||
timer?.setEventHandler { [weak self] in
|
||||
self?._check()
|
||||
}
|
||||
timer?.resume()
|
||||
}
|
||||
|
||||
private func cancleTimer() {
|
||||
guard let t = timer else {
|
||||
return
|
||||
}
|
||||
t.cancel()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressChecker {
|
||||
fileprivate func invokeProgressCheckerDidProgressPause() {
|
||||
if Thread.isMainThread {
|
||||
delegate?.progressCheckerDidProgressPause()
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.progressCheckerDidProgressPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
269
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Other/XmlParser.swift
generated
Normal file
@@ -0,0 +1,269 @@
|
||||
//
|
||||
// Parser.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/12/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class XmlParser: NSObject {
|
||||
fileprivate let logTag = "XmlParser"
|
||||
fileprivate var parserTypes: [ParserType] = []
|
||||
fileprivate var song: LyricModel!
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
func parseLyricData(data: Data) -> LyricModel? {
|
||||
song = nil
|
||||
parserTypes = []
|
||||
let parser = XMLParser(data: data)
|
||||
parser.delegate = self
|
||||
let success = parser.parse()
|
||||
if !success {
|
||||
let error = parser.parserError
|
||||
let line = parser.lineNumber
|
||||
let col = parser.columnNumber
|
||||
Log.error(error: "parsing Error(\(error?.localizedDescription ?? "")) at \(line):\(col)", tag: logTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
if song == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return process()
|
||||
}
|
||||
|
||||
private func process() -> LyricModel? {
|
||||
if song.lines.count == 0 {
|
||||
let text = "data error. song.lines: \(song.lines)"
|
||||
Log.error(error: text, tag: logTag)
|
||||
return nil
|
||||
}
|
||||
var hasPitch = false
|
||||
var preludeEndPosition = -1
|
||||
for line in song.lines {
|
||||
var content = ""
|
||||
for item in line.tones.enumerated() {
|
||||
let tone = item.element
|
||||
let index = item.offset
|
||||
if tone.lang == .en, tone.word != "" { /** 处理空白 **/
|
||||
let count = line.tones.count
|
||||
let lead = (index >= 1 && line.tones[index - 1].lang != .en && line.tones[index - 1].word != "") ? " " : ""
|
||||
let trail = index == count - 1 ? "" : " "
|
||||
tone.word = "\(lead)\(tone.word)\(trail)"
|
||||
}
|
||||
if tone.pitch > 0 {
|
||||
hasPitch = true
|
||||
}
|
||||
if preludeEndPosition == -1 {
|
||||
preludeEndPosition = tone.beginTime
|
||||
}
|
||||
content += tone.word
|
||||
}
|
||||
|
||||
let lineBeginTime = line.tones.first?.beginTime ?? -1
|
||||
let lineEndTime = (line.tones.last?.duration ?? -1) + (line.tones.last?.beginTime ?? -1)
|
||||
if lineBeginTime < 0 || lineEndTime < 0 || lineEndTime - lineBeginTime < 0 {
|
||||
let text = "data error. lineBeginTime: \(lineBeginTime) lineEndTime: \(lineEndTime)"
|
||||
Log.error(error: text, tag: logTag)
|
||||
return nil
|
||||
}
|
||||
line.beginTime = lineBeginTime
|
||||
line.duration = lineEndTime - lineBeginTime
|
||||
line.content = content
|
||||
}
|
||||
|
||||
if let lastDuration = song.lines.last?.duration,
|
||||
let lastBeginTime = song.lines.last?.beginTime {
|
||||
song.duration = lastDuration + lastBeginTime
|
||||
}
|
||||
song.hasPitch = hasPitch
|
||||
song.preludeEndPosition = preludeEndPosition
|
||||
return song
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 状态
|
||||
extension XmlParser {
|
||||
fileprivate func current(type: ParserType) -> Bool {
|
||||
return parserTypes.last == type
|
||||
}
|
||||
|
||||
fileprivate func push(_ type: ParserType) {
|
||||
parserTypes.append(type)
|
||||
}
|
||||
|
||||
fileprivate func pop() {
|
||||
parserTypes.removeLast()
|
||||
}
|
||||
|
||||
fileprivate func pop(equal: ParserType) {
|
||||
if current(type: equal) {
|
||||
pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - XMLParserDelegate
|
||||
extension XmlParser: XMLParserDelegate {
|
||||
func parserDidStartDocument(_: XMLParser) {}
|
||||
|
||||
func parserDidEndDocument(_: XMLParser) {}
|
||||
|
||||
func parser(_: XMLParser, parseErrorOccurred parseError: Error) {
|
||||
Log.error(error: parseError.localizedDescription, tag: logTag)
|
||||
}
|
||||
|
||||
func parser(_: XMLParser, validationErrorOccurred validationError: Error) {
|
||||
Log.error(error: validationError.localizedDescription, tag: logTag)
|
||||
}
|
||||
|
||||
func parser(_: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI _: String?,
|
||||
qualifiedName _: String?,
|
||||
attributes attributeDict: [String: String] = [:]) {
|
||||
switch elementName {
|
||||
case "song":
|
||||
song = LyricModel()
|
||||
case "general":
|
||||
push(.general)
|
||||
case "name":
|
||||
push(.name)
|
||||
case "singer":
|
||||
push(.singer)
|
||||
case "type":
|
||||
push(.type)
|
||||
case "sentence":
|
||||
push(.sentence)
|
||||
let line = LyricLineModel(beginTime: -1, duration: -1, content: "", tones: [])
|
||||
song.lines.append(line)
|
||||
case "tone":
|
||||
push(.tone)
|
||||
if let sentence = song.lines.last {
|
||||
let beginValue = Double(attributeDict["begin"] ?? "0") ?? 0
|
||||
let endValue = Double(attributeDict["end"] ?? "0") ?? 0
|
||||
let pitchValue = Float(attributeDict["pitch"] ?? "0") ?? 0
|
||||
let begin = Int(beginValue * 1000)
|
||||
let end = Int(endValue * 1000)
|
||||
let pitch = Double(pitchValue)
|
||||
let pronounce = attributeDict["pronounce"] ?? ""
|
||||
let langValue = Int(attributeDict["lang"] ?? "") ?? -1
|
||||
let lang = Lang(rawValue: langValue)!
|
||||
let tone = LyricToneModel(beginTime: begin,
|
||||
duration: end - begin,
|
||||
word: "",
|
||||
pitch: pitch,
|
||||
lang: lang,
|
||||
pronounce: pronounce)
|
||||
sentence.tones.append(tone)
|
||||
}
|
||||
case "word":
|
||||
push(.word)
|
||||
case "overlap":
|
||||
push(.overlap)
|
||||
let beginValue = Double(attributeDict["begin"] ?? "0") ?? 0
|
||||
let endValue = Double(attributeDict["end"] ?? "0") ?? 0
|
||||
let begin = Int(beginValue * 1000)
|
||||
let end = Int(endValue * 1000)
|
||||
let pitch: Double = 0
|
||||
let pronounce = ""
|
||||
let langValue = Int(attributeDict["lang"] ?? "") ?? -1
|
||||
let lang = Lang(rawValue: langValue)!
|
||||
let tone = LyricToneModel(beginTime: begin,
|
||||
duration: end - begin,
|
||||
word: "",
|
||||
pitch: pitch,
|
||||
lang: lang,
|
||||
pronounce: pronounce)
|
||||
let line = LyricLineModel(beginTime: 0, duration: 0, content: "", tones: [tone])
|
||||
song.lines.append(line)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_: XMLParser, foundCharacters string: String) {
|
||||
if let last = parserTypes.last {
|
||||
switch last {
|
||||
case .name:
|
||||
song.name = string
|
||||
// if song.name != nil {
|
||||
// song.name += string
|
||||
// }
|
||||
// else {
|
||||
// song.name = string
|
||||
// }
|
||||
case .singer:
|
||||
song.singer = string
|
||||
case .type:
|
||||
if let value = Int(string) {
|
||||
song.type = MusicType(rawValue: value) ?? .fast
|
||||
}
|
||||
else {
|
||||
song.type = .fast
|
||||
}
|
||||
case .word, .overlap:
|
||||
if let tone = song.lines.last?.tones.last {
|
||||
tone.word = tone.word + string
|
||||
if tone.lang == .unknown { /** 补偿语言 **/
|
||||
do {
|
||||
let regular = try NSRegularExpression(pattern: "[a-zA-Z]", options: .caseInsensitive)
|
||||
let count = regular.numberOfMatches(in: tone.word, options: .anchored, range: NSRange(location: 0, length: tone.word.count))
|
||||
if count > 0 {
|
||||
tone.lang = .en
|
||||
} else {
|
||||
tone.lang = .zh
|
||||
}
|
||||
} catch {
|
||||
tone.lang = .en
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI _: String?,
|
||||
qualifiedName _: String?) {
|
||||
switch elementName {
|
||||
case "general":
|
||||
pop(equal: .general)
|
||||
case "name":
|
||||
pop(equal: .name)
|
||||
case "singer":
|
||||
pop(equal: .singer)
|
||||
case "type":
|
||||
pop(equal: .type)
|
||||
case "sentence":
|
||||
pop(equal: .sentence)
|
||||
case "tone":
|
||||
pop(equal: .tone)
|
||||
case "word":
|
||||
pop(equal: .word)
|
||||
case "overlap":
|
||||
pop(equal: .overlap)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ParserType {
|
||||
case general
|
||||
case name
|
||||
case singer
|
||||
case type
|
||||
case sentence
|
||||
case tone
|
||||
case word
|
||||
case overlap
|
||||
}
|
||||
16
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/Other/ScoreAlgorithm.swift
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// ScoreAlgorithm.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ScoreAlgorithm: IScoreAlgorithm {
|
||||
func getLineScore(with toneScores: [ToneScoreModel]) -> Int {
|
||||
if toneScores.isEmpty { return 0 }
|
||||
let ret = toneScores.map({ $0.score }).reduce(0.0, +) / Float(toneScores.count)
|
||||
return Int(ret)
|
||||
}
|
||||
}
|
||||
37
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/Other/ToneCalculator.swift
generated
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// ToneCalculator.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
var useC = true
|
||||
|
||||
class ToneCalculator {
|
||||
/// 计算tone分数
|
||||
static func calculedScore(voicePitch: Double,
|
||||
stdPitch: Double,
|
||||
scoreLevel: Int,
|
||||
scoreCompensationOffset: Int) -> Float {
|
||||
if useC {
|
||||
return calculedScoreC(voicePitch, stdPitch, Int32(scoreLevel), Int32(scoreCompensationOffset))
|
||||
}
|
||||
|
||||
let stdTone = ToneCalculator.pitchToTone(pitch: stdPitch)
|
||||
let voiceTone = ToneCalculator.pitchToTone(pitch: voicePitch)
|
||||
var match = 1 - Float(scoreLevel)/100 * Float(abs(voiceTone - stdTone)) + Float(scoreCompensationOffset)/100
|
||||
match = max(0, match)
|
||||
match = min(1, match)
|
||||
return match * 100
|
||||
}
|
||||
|
||||
static func pitchToTone(pitch: Double) -> Double {
|
||||
if useC {
|
||||
return pitchToToneC(pitch)
|
||||
}
|
||||
let eps = 1e-6
|
||||
return (max(0, log(pitch / 55 + eps) / log(2))) * 12
|
||||
}
|
||||
}
|
||||
73
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/Other/VoicePitchChanger.swift
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// VoicePitchChanger.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2022/11/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoicePitchChanger {
|
||||
/// 累计的偏移值
|
||||
var offset: Double = 0.0
|
||||
/// 记录调用次数
|
||||
var n: Double = 0
|
||||
|
||||
/// 处理Pitch
|
||||
/// - Parameters:
|
||||
/// - stdPitch: 标准值 来自歌词文件
|
||||
/// - voicePitch: 实际值 来自rtc回调
|
||||
/// - stdMaxPitch: 最大值 来自标准值
|
||||
/// - Returns: 处理后的值
|
||||
func handlePitch(stdPitch: Double,
|
||||
voicePitch: Double,
|
||||
stdMaxPitch: Double) -> Double {
|
||||
if useC {
|
||||
return handlePitchC(stdPitch, voicePitch, stdMaxPitch)
|
||||
}
|
||||
|
||||
if voicePitch <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
n += 1.0
|
||||
let gap = stdPitch - voicePitch
|
||||
|
||||
offset = offset * (n - 1)/n + gap/n
|
||||
|
||||
if offset < 0 {
|
||||
offset = max(offset, -1 * stdMaxPitch * 0.4)
|
||||
}
|
||||
else {
|
||||
offset = min(offset, stdMaxPitch * 0.4)
|
||||
}
|
||||
|
||||
if abs(voicePitch - stdPitch) < 1 { /** 差距过小,直接返回 **/
|
||||
return voicePitch
|
||||
}
|
||||
|
||||
switch n {
|
||||
case 1:
|
||||
return min(voicePitch + 0.5 * offset, stdMaxPitch)
|
||||
case 2:
|
||||
return min(voicePitch + 0.6 * offset, stdMaxPitch)
|
||||
case 3:
|
||||
return min(voicePitch + 0.7 * offset, stdMaxPitch)
|
||||
case 4:
|
||||
return min(voicePitch + 0.8 * offset, stdMaxPitch)
|
||||
case 5:
|
||||
return min(voicePitch + 0.9 * offset, stdMaxPitch)
|
||||
default:
|
||||
return min(voicePitch + offset, stdMaxPitch)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
if useC {
|
||||
resetC()
|
||||
return
|
||||
}
|
||||
offset = 0.0
|
||||
n = 0.0
|
||||
}
|
||||
}
|
||||
278
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/ScoringMachine/ScoringMachine+DataHandle.swift
generated
Normal file
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// ScoringMachine+DataHandle.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/2/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ScoringMachine {
|
||||
/// 创建Scoring内部数据
|
||||
/// - shouldFixTime: 是否要修复时间异常问题
|
||||
/// - return: (行结束时间, 字模型)
|
||||
static func createData(data: LyricModel, shouldFixTime: Bool = true) -> ([Int], [Info]) {
|
||||
var array = [Info]()
|
||||
var lineEndTimes = [Int]()
|
||||
var preEndTime = 0
|
||||
for line in data.lines {
|
||||
for tone in line.tones {
|
||||
var beginTime = tone.beginTime
|
||||
var duration = tone.duration
|
||||
if shouldFixTime { /** 时间异常修复 **/
|
||||
if beginTime < preEndTime {
|
||||
/// 取出endTime文件原始值
|
||||
let endTime = tone.endTime
|
||||
beginTime = preEndTime
|
||||
duration = endTime - beginTime
|
||||
}
|
||||
}
|
||||
|
||||
let info = Info(beginTime: beginTime,
|
||||
duration: duration,
|
||||
word: tone.word,
|
||||
pitch: tone.pitch,
|
||||
drawBeginTime: tone.beginTime,
|
||||
drawDuration: tone.duration,
|
||||
isLastInLine: tone == line.tones.last)
|
||||
|
||||
preEndTime = tone.endTime
|
||||
|
||||
array.append(info)
|
||||
}
|
||||
lineEndTimes.append(preEndTime)
|
||||
}
|
||||
return (lineEndTimes, array)
|
||||
}
|
||||
|
||||
func makeHighlightInfos(progress: Int,
|
||||
hitedInfo: Info,
|
||||
currentVisiableInfos: [Info],
|
||||
currentHighlightInfos: [Info]) -> [Info] {
|
||||
let pitchDuration = 50
|
||||
if let preHitInfo = currentHighlightInfos.last, preHitInfo.beginTime == hitedInfo.beginTime { /** 判断是否需要追加 **/
|
||||
let newDrawBeginTime = max(progress, preHitInfo.beginTime)
|
||||
let distance = newDrawBeginTime - preHitInfo.drawEndTime
|
||||
if distance < pitchDuration { /** 追加 **/
|
||||
let drawDuration = min(preHitInfo.drawDuration + pitchDuration + distance, preHitInfo.duration)
|
||||
preHitInfo.drawDuration = drawDuration
|
||||
return currentHighlightInfos
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建 **/
|
||||
let stdInfo = hitedInfo
|
||||
let drawBeginTime = max(progress, stdInfo.beginTime)
|
||||
let drawDuration = min(pitchDuration, stdInfo.duration)
|
||||
let info = Info(beginTime: stdInfo.beginTime,
|
||||
duration: stdInfo.duration,
|
||||
word: stdInfo.word,
|
||||
pitch: stdInfo.pitch,
|
||||
drawBeginTime: drawBeginTime,
|
||||
drawDuration: drawDuration,
|
||||
isLastInLine: false)
|
||||
var temp = currentHighlightInfos
|
||||
temp.append(info)
|
||||
return temp
|
||||
}
|
||||
|
||||
/// 生成DrawInfo
|
||||
/// - Returns: (visiableDrawInfos, highlightDrawInfos, currentVisiableInfos, currentHighlightInfos)
|
||||
func makeInfos(progress: Int,
|
||||
dataList: [Info],
|
||||
currentHighlightInfos: [Info],
|
||||
defaultPitchCursorX: CGFloat,
|
||||
widthPreMs: CGFloat,
|
||||
canvasViewSize: CGSize,
|
||||
standardPitchStickViewHeight: CGFloat,
|
||||
minPitch: Double,
|
||||
maxPitch: Double) -> ([DrawInfo], [DrawInfo], [Info], [Info]) {
|
||||
/// 视图最左边到游标这段距离对应的时长
|
||||
let defaultPitchCursorXTime = Int(defaultPitchCursorX / widthPreMs)
|
||||
/// 游标到视图最右边对应的时长
|
||||
let remainTime = Int((canvasViewSize.width - defaultPitchCursorX) / widthPreMs)
|
||||
/// 需要显示音高的开始时间
|
||||
let beginTime = max(progress - defaultPitchCursorXTime, 0)
|
||||
/// 需要显示音高的结束时间
|
||||
let endTime = progress + remainTime
|
||||
|
||||
let currentVisiableInfos = filterInfos(infos: dataList,
|
||||
beginTime: beginTime,
|
||||
endTime: endTime)
|
||||
let highlightInfos = filterInfos(infos: currentHighlightInfos,
|
||||
beginTime: beginTime,
|
||||
endTime: endTime)
|
||||
|
||||
var visiableDrawInfos = [DrawInfo]()
|
||||
for info in currentVisiableInfos {
|
||||
let rect = calculateDrawRect(progress: progress,
|
||||
info: info,
|
||||
standardPitchStickViewHeight: standardPitchStickViewHeight,
|
||||
widthPreMs: widthPreMs,
|
||||
canvasViewSize: canvasViewSize,
|
||||
minPitch: minPitch,
|
||||
maxPitch: maxPitch)
|
||||
let drawInfo = DrawInfo(rect: rect)
|
||||
visiableDrawInfos.append(drawInfo)
|
||||
}
|
||||
|
||||
var highlightDrawInfos = [DrawInfo]()
|
||||
for info in highlightInfos {
|
||||
let rect = calculateDrawRect(progress: progress,
|
||||
info: info,
|
||||
standardPitchStickViewHeight: standardPitchStickViewHeight,
|
||||
widthPreMs: widthPreMs,
|
||||
canvasViewSize: canvasViewSize,
|
||||
minPitch: minPitch,
|
||||
maxPitch: maxPitch)
|
||||
let drawInfo = DrawInfo(rect: rect)
|
||||
highlightDrawInfos.append(drawInfo)
|
||||
}
|
||||
|
||||
return (visiableDrawInfos, highlightDrawInfos, currentVisiableInfos, highlightInfos)
|
||||
}
|
||||
|
||||
/// 生成最大、最小Pitch值
|
||||
/// - Returns: (minPitch, maxPitch)
|
||||
func makeMinMaxPitch(dataList: [Info]) -> (Double, Double) {
|
||||
/** set value **/
|
||||
let pitchs = dataList.filter({ $0.word != " " }).map({ $0.pitch })
|
||||
let maxValue = pitchs.max() ?? 0
|
||||
let minValue = pitchs.min() ?? 0
|
||||
return (minValue, maxValue)
|
||||
}
|
||||
|
||||
/// 获取击中数据
|
||||
func getHitedInfo(progress: Int,
|
||||
currentVisiableInfos: [Info]) -> Info? {
|
||||
let pitchBeginTime = progress
|
||||
return currentVisiableInfos.first { info in
|
||||
return pitchBeginTime >= info.drawBeginTime && pitchBeginTime <= info.endTime
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找当前句子的索引
|
||||
/// - Parameters:
|
||||
/// - Returns: `nil` 表示不合法, ==`lineEndTimes.count` 表示最后一句已经结束
|
||||
func findCurrentIndexOfLine(progress: Int, lineEndTimes: [Int]) -> Int? {
|
||||
if lineEndTimes.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
if progress > lineEndTimes.last! {
|
||||
return lineEndTimes.count
|
||||
}
|
||||
|
||||
if progress <= lineEndTimes.first! {
|
||||
return 0
|
||||
}
|
||||
|
||||
var lastEnd = 0
|
||||
for (offset, value) in lineEndTimes.enumerated() {
|
||||
if progress > lastEnd, progress <= value {
|
||||
return offset
|
||||
}
|
||||
lastEnd = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 筛选指定时间下的infos
|
||||
func filterInfos(infos: [Info],
|
||||
beginTime: Int,
|
||||
endTime: Int) -> [Info] {
|
||||
var result = [Info]()
|
||||
for info in infos {
|
||||
if info.drawBeginTime >= endTime {
|
||||
break
|
||||
}
|
||||
if info.endTime <= beginTime {
|
||||
continue
|
||||
}
|
||||
result.append(info)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 计算累计分数
|
||||
/// - Parameters:
|
||||
/// - indexOfLine: 计算到此index 如:2, 会计算0,1,2的累加值
|
||||
func calculatedCumulativeScore(indexOfLine: Int, lineScores: [Int]) -> Int {
|
||||
var cumulativeScore = 0
|
||||
for (offset, value) in lineScores.enumerated() {
|
||||
if offset <= indexOfLine {
|
||||
cumulativeScore += value
|
||||
}
|
||||
}
|
||||
return cumulativeScore
|
||||
}
|
||||
}
|
||||
|
||||
extension ScoringMachine { /** ui 位置 **/
|
||||
/// 计算音准线的位置
|
||||
func calculateDrawRect(progress: Int,
|
||||
info: Info,
|
||||
standardPitchStickViewHeight: CGFloat,
|
||||
widthPreMs: CGFloat,
|
||||
canvasViewSize: CGSize,
|
||||
minPitch: Double,
|
||||
maxPitch: Double) -> CGRect {
|
||||
let beginTime = info.drawBeginTime
|
||||
let duration = info.drawDuration
|
||||
let pitch = info.pitch
|
||||
|
||||
/// 视图最左边到游标这段距离对应的时长
|
||||
let defaultPitchCursorXTime = Int(defaultPitchCursorX / widthPreMs)
|
||||
let x = CGFloat(beginTime - (progress - defaultPitchCursorXTime)) * widthPreMs
|
||||
let y = calculatedY(pitch: pitch,
|
||||
viewHeight: canvasViewSize.height,
|
||||
minPitch: minPitch,
|
||||
maxPitch: maxPitch,
|
||||
standardPitchStickViewHeight: standardPitchStickViewHeight) ?? 0 - (standardPitchStickViewHeight / 2)
|
||||
let w = widthPreMs * CGFloat(duration)
|
||||
let h = standardPitchStickViewHeight
|
||||
|
||||
let rect = CGRect(x: x, y: y, width: w, height: h)
|
||||
return rect
|
||||
}
|
||||
|
||||
func calculatedY(pitch: Double,
|
||||
viewHeight: CGFloat,
|
||||
minPitch: Double,
|
||||
maxPitch: Double,
|
||||
standardPitchStickViewHeight: CGFloat) -> CGFloat? {
|
||||
if viewHeight <= 0 {
|
||||
Log.errorText(text: "calculatedY viewHeight invalid \(viewHeight)", tag: logTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
/** 计算扩展 **/
|
||||
let pitchPerPoint = (CGFloat(maxPitch) - CGFloat(minPitch)) / viewHeight
|
||||
let extends = pitchPerPoint * standardPitchStickViewHeight
|
||||
|
||||
if pitch < minPitch {
|
||||
return viewHeight - extends/2
|
||||
}
|
||||
|
||||
if pitch > maxPitch {
|
||||
return extends/2
|
||||
}
|
||||
|
||||
/** 计算实际的渲染高度 **/
|
||||
let rate = (pitch - minPitch) / (maxPitch - minPitch)
|
||||
let renderingHeight = viewHeight - extends
|
||||
|
||||
/** 计算距离 (从bottom到top) **/
|
||||
let distance = extends/2 + (renderingHeight * rate)
|
||||
|
||||
/** 计算y **/
|
||||
let y = viewHeight - distance
|
||||
|
||||
if y.isNaN {
|
||||
Log.errorText(text: "calculatedY result invalid pitch:\(pitch) viewHeight:\(viewHeight) minPitch:\(minPitch) maxPitch:\(maxPitch) standardPitchStickViewHeight:\(standardPitchStickViewHeight)", tag: logTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
return y
|
||||
}
|
||||
}
|
||||
96
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/ScoringMachine/ScoringMachine+Events.swift
generated
Normal file
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// ScoringMachine+Events.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ScoringMachineDelegate: NSObjectProtocol {
|
||||
/// 获取渲染视图的尺寸
|
||||
func sizeOfCanvasView(_ scoringMachine: ScoringMachine) -> CGSize
|
||||
|
||||
/// 更新渲染信息
|
||||
func scoringMachine(_ scoringMachine: ScoringMachine,
|
||||
didUpdateDraw standardInfos: [ScoringMachine.DrawInfo],
|
||||
highlightInfos: [ScoringMachine.DrawInfo])
|
||||
/// 更新游标位置
|
||||
func scoringMachine(_ scoringMachine: ScoringMachine,
|
||||
didUpdateCursor centerY: CGFloat,
|
||||
showAnimation: Bool,
|
||||
debugInfo: ScoringMachine.DebugInfo)
|
||||
|
||||
/// 更新句子分数
|
||||
func scoringMachine(_ scoringMachine: ScoringMachine,
|
||||
didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int)
|
||||
}
|
||||
|
||||
extension ScoringMachine { /** invoke **/
|
||||
func invokeScoringMachine(didUpdateDraw standardInfos: [ScoringMachine.DrawInfo],
|
||||
highlightInfos: [ScoringMachine.DrawInfo]) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.scoringMachine(self,
|
||||
didUpdateDraw: standardInfos,
|
||||
highlightInfos: highlightInfos)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.scoringMachine(self,
|
||||
didUpdateDraw: standardInfos,
|
||||
highlightInfos: highlightInfos)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeScoringMachine(didUpdateCursor centerY: CGFloat,
|
||||
showAnimation: Bool,
|
||||
debugInfo: DebugInfo) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.scoringMachine(self,
|
||||
didUpdateCursor: centerY,
|
||||
showAnimation: showAnimation,
|
||||
debugInfo: debugInfo)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.scoringMachine(self,
|
||||
didUpdateCursor: centerY,
|
||||
showAnimation: showAnimation,
|
||||
debugInfo: debugInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeScoringMachine(didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int) {
|
||||
if Thread.isMainThread {
|
||||
delegate?.scoringMachine(self,
|
||||
didFinishLineWith: model,
|
||||
score: score,
|
||||
cumulativeScore: cumulativeScore,
|
||||
lineIndex: lineIndex,
|
||||
lineCount: lineCount)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.scoringMachine(self,
|
||||
didFinishLineWith: model,
|
||||
score: score,
|
||||
cumulativeScore: cumulativeScore,
|
||||
lineIndex: lineIndex,
|
||||
lineCount: lineCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/ScoringMachine/ScoringMachine+Info.swift
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// ScoringVM+Info.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ScoringMachine {
|
||||
class Info {
|
||||
/// 标准开始时间 (来源自歌词文件)
|
||||
let beginTime: Int
|
||||
/// 标准时长 (来源自歌词文件)
|
||||
let duration: Int
|
||||
/// 需要绘制的开始时间
|
||||
let drawBeginTime: Int
|
||||
/// 需要绘制的时长
|
||||
var drawDuration: Int
|
||||
let word: String
|
||||
let pitch: Double
|
||||
/// 是否句子中最后的一个字
|
||||
let isLastInLine: Bool
|
||||
|
||||
required init(beginTime: Int,
|
||||
duration: Int,
|
||||
word: String,
|
||||
pitch: Double,
|
||||
drawBeginTime: Int,
|
||||
drawDuration: Int,
|
||||
isLastInLine: Bool) {
|
||||
self.beginTime = beginTime
|
||||
self.duration = duration
|
||||
self.word = word
|
||||
self.pitch = pitch
|
||||
self.drawBeginTime = drawBeginTime
|
||||
self.drawDuration = drawDuration
|
||||
self.isLastInLine = isLastInLine
|
||||
}
|
||||
|
||||
var endTime: Int {
|
||||
beginTime + duration
|
||||
}
|
||||
|
||||
var drawEndTime: Int {
|
||||
drawBeginTime + drawDuration
|
||||
}
|
||||
|
||||
var tone: LyricToneModel {
|
||||
return LyricToneModel(beginTime: beginTime,
|
||||
duration: duration,
|
||||
word: word,
|
||||
pitch: pitch,
|
||||
lang: .zh,
|
||||
pronounce: "")
|
||||
}
|
||||
}
|
||||
|
||||
struct DrawInfo {
|
||||
let rect: CGRect
|
||||
}
|
||||
|
||||
struct DebugInfo {
|
||||
/// 原始pitch
|
||||
let originalPitch: Double
|
||||
/// 男女音调算法改变后的pitch
|
||||
let pitch: Double
|
||||
let hitedInfo: ScoringMachine.Info?
|
||||
let progress: Int
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
314
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/ScoringMachine/ScoringMachine.swift
generated
Normal file
@@ -0,0 +1,314 @@
|
||||
//
|
||||
// ScoringVM.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ScoringMachine {
|
||||
/// 游标的起始位置
|
||||
var defaultPitchCursorX: CGFloat = 100
|
||||
/// 音准线的高度
|
||||
var standardPitchStickViewHeight: CGFloat = 3
|
||||
/// 音准线的基准因子
|
||||
var movingSpeedFactor: CGFloat = 120
|
||||
/// 打分容忍度 范围:0-1
|
||||
var hitScoreThreshold: Float = 0.7
|
||||
var scoreLevel = 10
|
||||
var scoreCompensationOffset = 0
|
||||
var scoreAlgorithm: IScoreAlgorithm = ScoreAlgorithm()
|
||||
weak var delegate: ScoringMachineDelegate?
|
||||
|
||||
fileprivate var progress: Int = 0
|
||||
fileprivate var widthPreMs: CGFloat { movingSpeedFactor / 1000 }
|
||||
fileprivate var dataList = [Info]()
|
||||
fileprivate var lineEndTimes = [Int]()
|
||||
fileprivate var currentVisiableInfos = [Info]()
|
||||
fileprivate var currentHighlightInfos = [Info]()
|
||||
fileprivate var maxPitch: Double = 0
|
||||
fileprivate var minPitch: Double = 0
|
||||
|
||||
fileprivate var canvasViewSize: CGSize = .zero
|
||||
fileprivate var toneScores = [ToneScoreModel]()
|
||||
fileprivate var lineScores = [Int]()
|
||||
fileprivate var currentIndexOfLine = 0
|
||||
fileprivate var lyricData: LyricModel?
|
||||
fileprivate var cumulativeScore = 0
|
||||
fileprivate var isDragging = false
|
||||
fileprivate var voiceChanger = VoicePitchChanger()
|
||||
fileprivate let queue = DispatchQueue(label: "ScoringMachine")
|
||||
let logTag = "ScoringMachine"
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
func setLyricData(data: LyricModel?) {
|
||||
guard let lyricData = data else { return }
|
||||
guard let size = delegate?.sizeOfCanvasView(self) else { fatalError("sizeOfCanvasView has not been implemented") }
|
||||
queue.async { [weak self] in
|
||||
self?._setLyricData(lyricData: lyricData, size: size)
|
||||
}
|
||||
}
|
||||
|
||||
func setProgress(progress: Int) {
|
||||
Log.debug(text: "progress: \(progress)", tag: "progress")
|
||||
queue.async { [weak self] in
|
||||
self?._setProgress(progress: progress)
|
||||
}
|
||||
}
|
||||
|
||||
func setPitch(pitch: Double) {
|
||||
queue.async { [weak self] in
|
||||
self?._setPitch(pitch: pitch)
|
||||
}
|
||||
}
|
||||
|
||||
func dragBegain() {
|
||||
queue.async { [weak self] in
|
||||
self?._dragBegain()
|
||||
}
|
||||
}
|
||||
|
||||
func dragDidEnd(position: Int) {
|
||||
queue.async { [weak self] in
|
||||
self?._dragDidEnd(position: position)
|
||||
}
|
||||
}
|
||||
|
||||
func getCumulativeScore() -> Int {
|
||||
Log.debug(text: "== getCumulativeScore cumulativeScore:\(cumulativeScore)", tag: "drag")
|
||||
return cumulativeScore
|
||||
}
|
||||
|
||||
func setScoreAlgorithm(algorithm: IScoreAlgorithm) {
|
||||
self.scoreAlgorithm = algorithm
|
||||
}
|
||||
|
||||
func reset() {
|
||||
queue.async { [weak self] in
|
||||
self?._reset()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
Log.info(text: "deinit", tag: logTag)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func _setLyricData(lyricData: LyricModel, size: CGSize) {
|
||||
canvasViewSize = size
|
||||
self.lyricData = lyricData
|
||||
let (lineEnds, infos) = ScoringMachine.createData(data: lyricData)
|
||||
dataList = infos
|
||||
lineEndTimes = lineEnds
|
||||
let (min, max) = makeMinMaxPitch(dataList: dataList)
|
||||
minPitch = min
|
||||
maxPitch = max
|
||||
toneScores = lyricData.lines[0].tones.map({ ToneScoreModel(tone: $0, score: 0) })
|
||||
lineScores = .init(repeating: 0, count: lyricData.lines.count)
|
||||
handleProgress()
|
||||
}
|
||||
|
||||
private func _setProgress(progress: Int) {
|
||||
guard !isDragging else { return }
|
||||
guard let model = lyricData, model.hasPitch else { return }
|
||||
Log.debug(text: "progress: \(progress)", tag: logTag)
|
||||
self.progress = progress
|
||||
handleProgress()
|
||||
}
|
||||
|
||||
private func _setPitch(pitch: Double) {
|
||||
guard !isDragging else { return }
|
||||
guard let model = lyricData, model.hasPitch else { return }
|
||||
|
||||
if pitch <= 0 {
|
||||
let y = canvasViewSize.height
|
||||
let debugInfo = DebugInfo(originalPitch: pitch,
|
||||
pitch: pitch,
|
||||
hitedInfo: nil,
|
||||
progress: progress)
|
||||
invokeScoringMachine(didUpdateCursor: y, showAnimation: false, debugInfo: debugInfo)
|
||||
return
|
||||
}
|
||||
|
||||
/** 1.get hitedInfo **/
|
||||
guard let hitedInfo = getHitedInfo(progress: progress,
|
||||
currentVisiableInfos: currentVisiableInfos) else {
|
||||
let y = calculatedY(pitch: pitch,
|
||||
viewHeight: canvasViewSize.height,
|
||||
minPitch: minPitch,
|
||||
maxPitch: maxPitch,
|
||||
standardPitchStickViewHeight: standardPitchStickViewHeight)
|
||||
|
||||
if y == nil {
|
||||
Log.errorText(text: "y is invalid, at getHitedInfo step", tag: logTag)
|
||||
}
|
||||
let yValue = (y != nil) ? y! : (canvasViewSize.height >= 0 ? canvasViewSize.height : 0)
|
||||
let debugInfo = DebugInfo(originalPitch: pitch,
|
||||
pitch: pitch,
|
||||
hitedInfo: nil,
|
||||
progress: progress)
|
||||
invokeScoringMachine(didUpdateCursor: yValue, showAnimation: false, debugInfo: debugInfo)
|
||||
return
|
||||
}
|
||||
|
||||
/** 2.voice change **/
|
||||
let voicePitch = voiceChanger.handlePitch(stdPitch: hitedInfo.pitch,
|
||||
voicePitch: pitch,
|
||||
stdMaxPitch: maxPitch)
|
||||
Log.debug(text: "pitch: \(pitch) after: \(voicePitch) stdPitch:\(hitedInfo.pitch)", tag: logTag)
|
||||
|
||||
/** 3.calculted score **/
|
||||
let score = ToneCalculator.calculedScore(voicePitch: voicePitch,
|
||||
stdPitch: hitedInfo.pitch,
|
||||
scoreLevel: scoreLevel,
|
||||
scoreCompensationOffset: scoreCompensationOffset)
|
||||
|
||||
/** 4.save tone score **/
|
||||
var hitToneScore = toneScores.first(where: { $0.tone.beginTime == hitedInfo.beginTime })
|
||||
if hitToneScore != nil {
|
||||
hitToneScore!.addScore(score: score)
|
||||
}
|
||||
else { /** reresetToneScores while can not find a specific one **/
|
||||
resetToneScores(position: progress)
|
||||
hitToneScore = toneScores.first(where: { $0.tone.beginTime == hitedInfo.beginTime })
|
||||
if hitToneScore != nil {
|
||||
hitToneScore!.addScore(score: score)
|
||||
}
|
||||
else {
|
||||
Log.error(error: "ignore score \(score) progress: \(progress), beginTime: \(hitedInfo.beginTime), endTime: \(hitedInfo.endTime) \(toneScores.map({ "\($0.tone.beginTime)-" }).reduce("", +))", tag: logTag)
|
||||
}
|
||||
}
|
||||
|
||||
/** 5.update HighlightInfos **/
|
||||
if score >= hitScoreThreshold * 100 {
|
||||
currentHighlightInfos = makeHighlightInfos(progress: progress,
|
||||
hitedInfo: hitedInfo,
|
||||
currentVisiableInfos: currentVisiableInfos,
|
||||
currentHighlightInfos: currentHighlightInfos)
|
||||
}
|
||||
Log.debug(text: "progress:\(progress) score: \(score) pitch: \(pitch) after: \(voicePitch) stdPitch:\(hitedInfo.pitch)", tag: logTag)
|
||||
/** 6.calculated ui info **/
|
||||
let showAnimation = score >= hitScoreThreshold * 100
|
||||
let y = calculatedY(pitch: voicePitch,
|
||||
viewHeight: canvasViewSize.height,
|
||||
minPitch: minPitch,
|
||||
maxPitch: maxPitch,
|
||||
standardPitchStickViewHeight: standardPitchStickViewHeight)
|
||||
if y == nil {
|
||||
Log.errorText(text: "y is invalid, at calculated ui info step", tag: logTag)
|
||||
}
|
||||
let yValue = (y != nil) ? y! : (canvasViewSize.height >= 0 ? canvasViewSize.height : 0)
|
||||
let debugInfo = DebugInfo(originalPitch: pitch,
|
||||
pitch: voicePitch,
|
||||
hitedInfo: hitedInfo,
|
||||
progress: progress)
|
||||
invokeScoringMachine(didUpdateCursor: yValue, showAnimation: showAnimation, debugInfo: debugInfo)
|
||||
}
|
||||
|
||||
private func _dragBegain() {
|
||||
isDragging = true
|
||||
}
|
||||
|
||||
private func _dragDidEnd(position: Int) {
|
||||
guard let index = findCurrentIndexOfLine(progress: position, lineEndTimes: lineEndTimes) else {
|
||||
return
|
||||
}
|
||||
|
||||
let indexOfLine = index-1
|
||||
cumulativeScore = calculatedCumulativeScore(indexOfLine: indexOfLine, lineScores: lineScores)
|
||||
Log.debug(text: "== dragDidEnd cumulativeScore:\(cumulativeScore)", tag: "drag")
|
||||
|
||||
if index >= 0, index < lineEndTimes.count, let data = lyricData {
|
||||
toneScores = data.lines[index].tones.map({ ToneScoreModel(tone: $0, score: 0) })
|
||||
for offset in index..<lineEndTimes.count {
|
||||
lineScores[offset] = 0
|
||||
}
|
||||
}
|
||||
|
||||
progress = position
|
||||
currentHighlightInfos = []
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
private func resetToneScores(position: Int) {
|
||||
guard let index = findCurrentIndexOfLine(progress: position, lineEndTimes: lineEndTimes) else {
|
||||
return
|
||||
}
|
||||
if index >= 0, index < lineEndTimes.count, let data = lyricData {
|
||||
toneScores = data.lines[index].tones.map({ ToneScoreModel(tone: $0, score: 0) })
|
||||
for offset in index..<lineEndTimes.count {
|
||||
lineScores[offset] = 0
|
||||
}
|
||||
}
|
||||
currentHighlightInfos = []
|
||||
Log.info(text: "resetToneScores", tag: logTag)
|
||||
}
|
||||
|
||||
private func _reset() {
|
||||
cumulativeScore = 0
|
||||
lyricData = nil
|
||||
currentVisiableInfos = []
|
||||
currentHighlightInfos = []
|
||||
dataList = []
|
||||
lineEndTimes = []
|
||||
cumulativeScore = 0
|
||||
currentIndexOfLine = 0
|
||||
lineScores = []
|
||||
toneScores = []
|
||||
progress = 0
|
||||
minPitch = 0
|
||||
maxPitch = 0
|
||||
voiceChanger.reset()
|
||||
}
|
||||
|
||||
private func handleProgress() {
|
||||
/// 计算需要绘制的数据
|
||||
let (visiableDrawInfos, highlightDrawInfos, visiableInfos, highlightInfos) = makeInfos(progress: progress,
|
||||
dataList: dataList,
|
||||
currentHighlightInfos: currentHighlightInfos,
|
||||
defaultPitchCursorX: defaultPitchCursorX,
|
||||
widthPreMs: widthPreMs,
|
||||
canvasViewSize: canvasViewSize,
|
||||
standardPitchStickViewHeight: standardPitchStickViewHeight,
|
||||
minPitch: minPitch,
|
||||
maxPitch: maxPitch)
|
||||
currentVisiableInfos = visiableInfos
|
||||
currentHighlightInfos = highlightInfos
|
||||
invokeScoringMachine(didUpdateDraw: visiableDrawInfos, highlightInfos: highlightDrawInfos)
|
||||
guard let index = findCurrentIndexOfLine(progress: progress, lineEndTimes: lineEndTimes) else {
|
||||
return
|
||||
}
|
||||
if currentIndexOfLine != index {
|
||||
if index - currentIndexOfLine == 1 { /** 过滤拖拽导致的进度变化,只有正常进度才回调 **/
|
||||
didLineEnd(indexOfLineEnd: currentIndexOfLine)
|
||||
}
|
||||
Log.debug(text: "currentIndexOfLine: \(index) from old: \(currentIndexOfLine)", tag: "drag")
|
||||
currentIndexOfLine = index
|
||||
}
|
||||
}
|
||||
|
||||
private func didLineEnd(indexOfLineEnd: Int) {
|
||||
guard let data = lyricData, indexOfLineEnd <= data.lines.count else {
|
||||
return
|
||||
}
|
||||
|
||||
let lineScore = scoreAlgorithm.getLineScore(with: toneScores)
|
||||
lineScores[indexOfLineEnd] = lineScore
|
||||
|
||||
cumulativeScore = calculatedCumulativeScore(indexOfLine: indexOfLineEnd,
|
||||
lineScores: lineScores)
|
||||
Log.debug(text: "score didLineEnd indexOfLineEnd: \(indexOfLineEnd) \(lineScore) \(lineScores) cumulativeScore:\(cumulativeScore)", tag: logTag)
|
||||
invokeScoringMachine(didFinishLineWith: data.lines[indexOfLineEnd],
|
||||
score: lineScore,
|
||||
cumulativeScore: cumulativeScore,
|
||||
lineIndex: indexOfLineEnd,
|
||||
lineCount: data.lines.count)
|
||||
let nextIndex = indexOfLineEnd + 1
|
||||
if nextIndex < data.lines.count {
|
||||
toneScores = data.lines[nextIndex].tones.map({ ToneScoreModel(tone: $0, score: 0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/View/ConsoleView.swift
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// ConsoleView.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/30.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// use for debug only
|
||||
class ConsoleView: UIView {
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
|
||||
label.numberOfLines = 0
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 9)
|
||||
addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
label.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
label.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func set(text: String) {
|
||||
label.text = text
|
||||
}
|
||||
}
|
||||
214
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/View/LocalPitchView.swift
generated
Normal file
@@ -0,0 +1,214 @@
|
||||
//
|
||||
// LocalPitchView.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/4.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class LocalPitchView: UIView {
|
||||
private let emitter = Emitter()
|
||||
private let bgView = UIImageView()
|
||||
private let verticalLineView = UIImageView()
|
||||
private let indicatedView = UIImageView()
|
||||
private var indicatedCenterYConstant: CGFloat = 0.0
|
||||
static let scoreAnimateWidth: CGFloat = 30
|
||||
/// 游标的起始位置
|
||||
var defaultPitchCursorX: CGFloat = 100
|
||||
/// 是否隐藏粒子动画效果
|
||||
var particleEffectHidden: Bool = false
|
||||
/** 游标偏移量(X轴) 游标的中心到竖线中心的距离
|
||||
- 等于0:游标中心点和竖线中线点重合
|
||||
- 小于0: 游标向左偏移
|
||||
- 大于0:游标向向偏移 **/
|
||||
var localPitchCursorOffsetX: CGFloat = -3 { didSet { updateUI() } }
|
||||
/// 游标的图片
|
||||
var localPitchCursorImage: UIImage? = nil { didSet { updateUI() } }
|
||||
var emitterImages: [UIImage]? {
|
||||
didSet {
|
||||
emitter.images = emitterImages
|
||||
}
|
||||
}
|
||||
private var indicatedViewCenterYConstraint, indicatedViewCenterXConstraint: NSLayoutConstraint!
|
||||
fileprivate let logTag = "LocalPitchView"
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
bgView.image = Bundle.currentBundle.image(name: "bg_scoring_left")
|
||||
verticalLineView.image = Bundle.currentBundle.image(name: "icon_vertical_line")
|
||||
indicatedView.image = Bundle.currentBundle.image(name: "icon_trangle")
|
||||
|
||||
backgroundColor = .clear
|
||||
addSubview(bgView)
|
||||
addSubview(verticalLineView)
|
||||
addSubview(indicatedView)
|
||||
|
||||
layer.addSublayer(emitter.layer)
|
||||
|
||||
bgView.translatesAutoresizingMaskIntoConstraints = false
|
||||
verticalLineView.translatesAutoresizingMaskIntoConstraints = false
|
||||
indicatedView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
bgView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
bgView.rightAnchor.constraint(equalTo: rightAnchor, constant: -1 * LocalPitchView.scoreAnimateWidth).isActive = true
|
||||
bgView.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
||||
bgView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
|
||||
verticalLineView.rightAnchor.constraint(equalTo: rightAnchor, constant: -1 * (LocalPitchView.scoreAnimateWidth - 0.5)).isActive = true
|
||||
verticalLineView.topAnchor.constraint(equalTo: topAnchor).isActive = true
|
||||
verticalLineView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
|
||||
indicatedViewCenterXConstraint = indicatedView.centerXAnchor.constraint(equalTo: verticalLineView.centerXAnchor, constant: localPitchCursorOffsetX)
|
||||
indicatedViewCenterYConstraint = indicatedView.centerYAnchor.constraint(equalTo: bottomAnchor, constant: 0)
|
||||
indicatedViewCenterXConstraint.isActive = true
|
||||
indicatedViewCenterYConstraint.isActive = true
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
indicatedViewCenterXConstraint.constant = localPitchCursorOffsetX
|
||||
indicatedView.image = localPitchCursorImage ?? Bundle.currentBundle.image(name: "icon_trangle")
|
||||
}
|
||||
|
||||
/// 设置游标位置
|
||||
/// - Parameter y: 从top到bottom方向上的距离
|
||||
func setIndicatedViewY(y: CGFloat) {
|
||||
let constant = (bounds.height - y) * -1
|
||||
let duration: TimeInterval = indicatedCenterYConstant < constant ? 0.15 : 0.05
|
||||
indicatedCenterYConstant = constant
|
||||
indicatedViewCenterYConstraint.constant = constant
|
||||
UIView.animate(withDuration: duration, delay: 0, options: []) { [weak self] in
|
||||
self?.layoutIfNeeded()
|
||||
}
|
||||
emitter.setupEmitterPoint(point: .init(x: defaultPitchCursorX-3, y: y))
|
||||
}
|
||||
|
||||
func startEmitter() {
|
||||
if particleEffectHidden { return }
|
||||
emitter.start()
|
||||
}
|
||||
|
||||
func stopEmitter() {
|
||||
emitter.stop()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
setIndicatedViewY(y: bounds.height)
|
||||
stopEmitter()
|
||||
emitter.reset()
|
||||
}
|
||||
}
|
||||
|
||||
class Emitter {
|
||||
var layer = CAEmitterLayer()
|
||||
var images: [UIImage]? {
|
||||
didSet {
|
||||
updateLayer()
|
||||
}
|
||||
}
|
||||
private var count = 0
|
||||
private var lastPoint: CGPoint = .zero
|
||||
private let logTag = "Emitter"
|
||||
|
||||
var defaultImages: [UIImage] {
|
||||
var list = [UIImage]()
|
||||
for i in 1...8 {
|
||||
if let image = Bundle.currentBundle.image(name: "star\(i)") {
|
||||
list.append(image)
|
||||
}
|
||||
else {
|
||||
Log.error(error: "image == nil", tag: logTag)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
init() {
|
||||
updateLayer()
|
||||
}
|
||||
|
||||
func updateLayer() {
|
||||
let superLayer = layer.superlayer
|
||||
layer.removeFromSuperlayer()
|
||||
|
||||
layer = CAEmitterLayer()
|
||||
superLayer?.addSublayer(layer)
|
||||
layer.emitterPosition = .zero
|
||||
layer.preservesDepth = true
|
||||
layer.renderMode = .oldestLast
|
||||
layer.masksToBounds = false
|
||||
layer.emitterMode = .points
|
||||
layer.emitterShape = .circle
|
||||
layer.birthRate = 0
|
||||
layer.emitterPosition = lastPoint
|
||||
let imgs = (images != nil) ? images! : defaultImages
|
||||
let count = imgs.count
|
||||
layer.emitterCells = imgs.enumerated().map({ Emitter.createEmitterCell(name: "cell", image: $0.1, birthRate: count) })
|
||||
}
|
||||
|
||||
func setCount() {
|
||||
count += 1
|
||||
if count >= 150 {
|
||||
count = 0
|
||||
updateLayer()
|
||||
}
|
||||
}
|
||||
|
||||
func setupEmitterPoint(point: CGPoint) {
|
||||
lastPoint = point
|
||||
layer.emitterPosition = point
|
||||
}
|
||||
|
||||
func start() {
|
||||
setCount()
|
||||
layer.birthRate = 1
|
||||
}
|
||||
|
||||
func stop() {
|
||||
setCount()
|
||||
layer.birthRate = 0
|
||||
}
|
||||
|
||||
func reset(){
|
||||
count = 0
|
||||
updateLayer()
|
||||
}
|
||||
|
||||
static func createEmitterCell(name: String, image: UIImage, birthRate: Int) -> CAEmitterCell {
|
||||
/// 创建粒子, 并且设置例子相关的属性
|
||||
let cell = CAEmitterCell()
|
||||
/// 设置粒子速度
|
||||
cell.velocity = 1
|
||||
cell.velocityRange = 1
|
||||
/// 设置例子的大小
|
||||
cell.scale = 1
|
||||
cell.scaleRange = 0.5
|
||||
/// 设置粒子方向
|
||||
cell.emissionLongitude = CGFloat.pi * 3
|
||||
cell.emissionRange = CGFloat.pi / 6
|
||||
/// 设置粒子的存活时间
|
||||
cell.lifetime = 100
|
||||
cell.lifetimeRange = 0
|
||||
/// 设置粒子旋转
|
||||
cell.spin = CGFloat.pi / 2
|
||||
cell.spinRange = CGFloat.pi / 4
|
||||
/// 设置粒子每秒弹出的个数
|
||||
cell.birthRate = 4
|
||||
cell.alphaRange = 0.75
|
||||
cell.alphaSpeed = -0.35
|
||||
/// 初始速度
|
||||
cell.velocity = 90
|
||||
cell.name = name
|
||||
cell.isEnabled = true
|
||||
cell.contents = image.cgImage
|
||||
return cell
|
||||
}
|
||||
}
|
||||
104
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/View/ScoringCanvasView.swift
generated
Normal file
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// ScoringCanvasView.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/3.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ScoringCanvasView: UIView {
|
||||
/// 游标的起始位置
|
||||
var defaultPitchCursorX: CGFloat = 100
|
||||
/// 音准线的高度
|
||||
var standardPitchStickViewHeight: CGFloat = 3
|
||||
/// 音准线的基准因子
|
||||
var movingSpeedFactor: CGFloat = 120
|
||||
/// 音准线默认的背景色
|
||||
var standardPitchStickViewColor: UIColor = .gray
|
||||
/// 音准线匹配后的背景色
|
||||
var standardPitchStickViewHighlightColor: UIColor = .orange
|
||||
|
||||
fileprivate var standardInfos = [DrawInfo]()
|
||||
fileprivate var highlightInfos = [DrawInfo]()
|
||||
fileprivate var widthPreMs: CGFloat { movingSpeedFactor / 1000 }
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
drawStaff()
|
||||
drawStandardInfos()
|
||||
drawHighlightInfos()
|
||||
}
|
||||
|
||||
func draw(standardInfos: [DrawInfo],
|
||||
highlightInfos: [DrawInfo]) {
|
||||
self.standardInfos = standardInfos
|
||||
self.highlightInfos = highlightInfos
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.standardInfos = []
|
||||
self.highlightInfos = []
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Draw
|
||||
extension ScoringCanvasView {
|
||||
/// 五线谱
|
||||
fileprivate func drawStaff() {
|
||||
let height = bounds.height
|
||||
let width = bounds.width
|
||||
let lineHeight: CGFloat = 1
|
||||
let spaceY = (height - lineHeight * 5) / 4
|
||||
|
||||
for i in 0...5 {
|
||||
let y = CGFloat(i) * (spaceY + lineHeight)
|
||||
let rect = CGRect(x: 0, y: y, width: width, height: lineHeight)
|
||||
let linePath = UIBezierPath(rect: rect)
|
||||
UIColor.white.withAlphaComponent(0.08).setFill()
|
||||
linePath.fill()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func drawStandardInfos() {
|
||||
drawInfosStandrad(infos: standardInfos, fillColor: standardPitchStickViewColor)
|
||||
}
|
||||
|
||||
fileprivate func drawHighlightInfos() {
|
||||
drawInfosStandrad(infos: highlightInfos, fillColor: standardPitchStickViewHighlightColor)
|
||||
}
|
||||
|
||||
private func drawInfosHighlight(infos: [DrawInfo], fillColor: UIColor) {
|
||||
for info in infos {
|
||||
let rect = info.rect
|
||||
let gradient = CAGradientLayer()
|
||||
gradient.frame = rect
|
||||
gradient.colors = [UIColor.magenta.cgColor, UIColor.cyan.cgColor]
|
||||
|
||||
layer.addSublayer(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawInfosStandrad(infos: [DrawInfo], fillColor: UIColor) {
|
||||
for info in infos {
|
||||
let rect = info.rect
|
||||
let path = UIBezierPath(roundedRect: rect, cornerRadius: standardPitchStickViewHeight/2)
|
||||
fillColor.setFill()
|
||||
path.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScoringCanvasView {
|
||||
typealias DrawInfo = ScoringMachine.DrawInfo
|
||||
}
|
||||
21
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/View/ScoringView+Events.swift
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// ScoringView+Events.swift
|
||||
// AgoraLyricsScore
|
||||
//
|
||||
// Created by ZYP on 2023/1/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ScoringViewDelegate: NSObjectProtocol {
|
||||
/// 更新句子分数
|
||||
func scoringView(_ view: ScoringView,
|
||||
didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int)
|
||||
|
||||
/// 更新UI
|
||||
func scoringViewShouldUpdateViewLayout(view: ScoringView)
|
||||
}
|
||||
206
Pods/AgoraLyricsScore/AgoraLyricsScore/Class/Scoring/View/ScoringView.swift
generated
Normal file
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// ScoringView.swift
|
||||
// NewApi
|
||||
//
|
||||
// Created by ZYP on 2022/11/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class ScoringView: UIView {
|
||||
/// 评分视图高度
|
||||
@objc public var viewHeight: CGFloat = 120 { didSet { updateUI() } }
|
||||
/// 渲染视图到顶部的间距
|
||||
@objc public var topSpaces: CGFloat = 0 { didSet { updateUI() } }
|
||||
/// 游标的起始位置
|
||||
@objc public var defaultPitchCursorX: CGFloat = 100 { didSet { updateUI() } }
|
||||
/// 音准线的高度
|
||||
@objc public var standardPitchStickViewHeight: CGFloat = 3 { didSet { updateUI() } }
|
||||
/// 音准线的基准因子
|
||||
@objc public var movingSpeedFactor: CGFloat = 120 { didSet { updateUI() } }
|
||||
/// 音准线默认的背景色
|
||||
@objc public var standardPitchStickViewColor: UIColor = .gray { didSet { updateUI() } }
|
||||
/// 音准线匹配后的背景色
|
||||
@objc public var standardPitchStickViewHighlightColor: UIColor = .colorWithHex(hexStr: "#FF8AB4") { didSet { updateUI() } }
|
||||
/** 游标偏移量(X轴) 游标的中心到竖线中心的距离
|
||||
- 等于0:游标中心点和竖线中心点重合
|
||||
- 小于0: 游标向左偏移
|
||||
- 大于0:游标向向偏移 **/
|
||||
@objc public var localPitchCursorOffsetX: CGFloat = -3 { didSet { updateUI() } }
|
||||
/// 游标的图片
|
||||
@objc public var localPitchCursorImage: UIImage? = nil { didSet { updateUI() } }
|
||||
/// 是否隐藏粒子动画效果
|
||||
@objc public var particleEffectHidden: Bool = false { didSet { updateUI() } }
|
||||
/// 使用图片创建粒子动画
|
||||
@objc public var emitterImages: [UIImage]? { didSet { updateUI() } }
|
||||
/// 打分容忍度 范围:0-1
|
||||
@objc public var hitScoreThreshold: Float = 0.7 { didSet { updateUI() } }
|
||||
/// use for debug only
|
||||
@objc public var showDebugView = false { didSet { updateUI() } }
|
||||
|
||||
var scoreLevel = 15 { didSet { updateUI() } }
|
||||
var scoreCompensationOffset = 0 { didSet { updateUI() } }
|
||||
|
||||
var progress: Int = 0 { didSet { updateProgress() } }
|
||||
fileprivate let localPitchView = LocalPitchView()
|
||||
fileprivate let canvasView = ScoringCanvasView()
|
||||
/// use for debug only
|
||||
fileprivate let consoleView = ConsoleView()
|
||||
private var canvasViewHeightConstraint, localPitchViewWidthConstraint: NSLayoutConstraint!
|
||||
|
||||
fileprivate let scoringMachine = ScoringMachine()
|
||||
weak var delegate: ScoringViewDelegate?
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
updateUI()
|
||||
scoringMachine.delegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 获取当前累计分数
|
||||
@objc public func getCumulativeScore() -> Int {
|
||||
scoringMachine.getCumulativeScore()
|
||||
}
|
||||
|
||||
/// stop Animation and reset position of Cursor
|
||||
@objc public func forceStopIndicatorAnimationWhenReachingContinuousZeros() {
|
||||
localPitchView.reset()
|
||||
}
|
||||
|
||||
func setLyricData(data: LyricModel?) {
|
||||
scoringMachine.setLyricData(data: data)
|
||||
}
|
||||
|
||||
func setPitch(pitch: Double) {
|
||||
scoringMachine.setPitch(pitch: pitch)
|
||||
}
|
||||
|
||||
func setScoreAlgorithm(algorithm: IScoreAlgorithm) {
|
||||
scoringMachine.scoreAlgorithm = algorithm
|
||||
}
|
||||
|
||||
func dragBegain() {
|
||||
scoringMachine.dragBegain()
|
||||
}
|
||||
|
||||
func dragDidEnd(position: Int) {
|
||||
scoringMachine.dragDidEnd(position: position)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
scoringMachine.reset()
|
||||
localPitchView.reset()
|
||||
canvasView.reset()
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
scoringMachine.setProgress(progress: progress)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
addSubview(canvasView)
|
||||
addSubview(localPitchView)
|
||||
|
||||
canvasView.translatesAutoresizingMaskIntoConstraints = false
|
||||
localPitchView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
canvasView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
canvasView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
|
||||
canvasView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
|
||||
canvasViewHeightConstraint = canvasView.heightAnchor.constraint(equalToConstant: viewHeight)
|
||||
canvasViewHeightConstraint.isActive = true
|
||||
|
||||
localPitchView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
localPitchView.topAnchor.constraint(equalTo: canvasView.topAnchor).isActive = true
|
||||
localPitchView.bottomAnchor.constraint(equalTo: canvasView.bottomAnchor).isActive = true
|
||||
let width = defaultPitchCursorX + LocalPitchView.scoreAnimateWidth /** 竖线的宽度是1 **/
|
||||
localPitchViewWidthConstraint = localPitchView.widthAnchor.constraint(equalToConstant: width)
|
||||
localPitchViewWidthConstraint.isActive = true
|
||||
}
|
||||
|
||||
private func updateUI() {
|
||||
canvasView.defaultPitchCursorX = defaultPitchCursorX
|
||||
canvasView.standardPitchStickViewHeight = standardPitchStickViewHeight
|
||||
canvasView.movingSpeedFactor = movingSpeedFactor
|
||||
canvasView.standardPitchStickViewColor = standardPitchStickViewColor
|
||||
canvasView.standardPitchStickViewHighlightColor = standardPitchStickViewHighlightColor
|
||||
|
||||
localPitchView.particleEffectHidden = particleEffectHidden
|
||||
localPitchView.emitterImages = emitterImages
|
||||
localPitchView.defaultPitchCursorX = defaultPitchCursorX
|
||||
localPitchView.localPitchCursorOffsetX = localPitchCursorOffsetX
|
||||
localPitchView.localPitchCursorImage = localPitchCursorImage
|
||||
|
||||
let width = defaultPitchCursorX + LocalPitchView.scoreAnimateWidth /** 竖线的宽度是1 **/
|
||||
localPitchViewWidthConstraint.constant = width
|
||||
canvasViewHeightConstraint.constant = viewHeight
|
||||
|
||||
scoringMachine.defaultPitchCursorX = defaultPitchCursorX
|
||||
scoringMachine.standardPitchStickViewHeight = standardPitchStickViewHeight
|
||||
scoringMachine.movingSpeedFactor = movingSpeedFactor
|
||||
scoringMachine.hitScoreThreshold = hitScoreThreshold
|
||||
scoringMachine.scoreLevel = scoreLevel
|
||||
scoringMachine.scoreCompensationOffset = scoreCompensationOffset
|
||||
|
||||
delegate?.scoringViewShouldUpdateViewLayout(view: self)
|
||||
|
||||
if showDebugView {
|
||||
if !subviews.contains(consoleView) {
|
||||
addSubview(consoleView)
|
||||
consoleView.translatesAutoresizingMaskIntoConstraints = false
|
||||
consoleView.widthAnchor.constraint(equalToConstant: 80).isActive = true
|
||||
consoleView.heightAnchor.constraint(equalToConstant: 80).isActive = true
|
||||
consoleView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
|
||||
consoleView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
consoleView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ScoringMachineDelegate
|
||||
extension ScoringView: ScoringMachineDelegate {
|
||||
func sizeOfCanvasView(_ scoringMachine: ScoringMachine) -> CGSize {
|
||||
return canvasView.bounds.size
|
||||
}
|
||||
|
||||
func scoringMachine(_ scoringMachine: ScoringMachine,
|
||||
didUpdateDraw standardInfos: [ScoringMachine.DrawInfo],
|
||||
highlightInfos: [ScoringMachine.DrawInfo]) {
|
||||
canvasView.draw(standardInfos: standardInfos,
|
||||
highlightInfos: highlightInfos)
|
||||
}
|
||||
|
||||
func scoringMachine(_ scoringMachine: ScoringMachine,
|
||||
didUpdateCursor centerY: CGFloat,
|
||||
showAnimation: Bool,
|
||||
debugInfo: ScoringMachine.DebugInfo) {
|
||||
localPitchView.setIndicatedViewY(y: centerY)
|
||||
showAnimation ? localPitchView.startEmitter() : localPitchView.stopEmitter()
|
||||
if showDebugView {
|
||||
let text = "y: \(Float(centerY)) \npitch: \(debugInfo.pitch.keep2) \nani: \(showAnimation) \nw:\(debugInfo.hitedInfo?.word ?? "") \nstd:\(debugInfo.hitedInfo?.pitch ?? 0) progress: \(debugInfo.progress)"
|
||||
consoleView.set(text: text)
|
||||
}
|
||||
}
|
||||
|
||||
func scoringMachine(_ scoringMachine: ScoringMachine,
|
||||
didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int) {
|
||||
delegate?.scoringView(self,
|
||||
didFinishLineWith: model,
|
||||
score: score,
|
||||
cumulativeScore: cumulativeScore,
|
||||
lineIndex: lineIndex,
|
||||
lineCount: lineCount)
|
||||
}
|
||||
}
|
||||
6
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/Contents.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
22
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "bg_scoring_left@2X.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "bg_scoring_left@3X.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/bg_scoring_left@2X.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/bg_scoring_left@3X.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 61 KiB |
22
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_trangle@2X.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_trangle@3X.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/icon_trangle@2X.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 272 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/icon_trangle@3X.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 324 B |
22
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_vertical_line@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_vertical_line@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/icon_vertical_line@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 234 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/icon_vertical_line@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 364 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star1@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star1@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 311 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 550 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 838 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star2@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star2@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 531 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 828 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star3@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star3@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 310 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 537 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 849 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star4.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star4@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star4@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 311 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 882 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star5.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star5@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star5@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 298 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 526 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 805 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star6.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star6@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star6@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 290 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 505 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 786 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star7.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star7@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star7@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 298 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 794 B |
23
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/Contents.json
generated
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "star8.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "star8@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "star8@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 296 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8@2x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 527 B |
BIN
Pods/AgoraLyricsScore/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8@3x.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 812 B |
230
Pods/AgoraLyricsScore/README.md
generated
Normal file
@@ -0,0 +1,230 @@
|
||||
# KTV歌词解析, 音准评分组件
|
||||
|
||||
## 介绍
|
||||
|
||||
支持XML歌词解析, LRC歌词解析, 解决了多行歌词进度渲染的问题, 评分根据人声实时计算评分
|
||||
|
||||
## 使用方法
|
||||
|
||||
#### 1.初始化
|
||||
|
||||
```swift
|
||||
let karaokeView = KaraokeView(frame: .zero, loggers: [ConsoleLogger(), FileLogger()])
|
||||
karaokeView.frame = ....
|
||||
view.addSubview(karaokeView)
|
||||
karaokeView.delegate = self
|
||||
```
|
||||
#### 2.解析&设置歌词
|
||||
```swift
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
let data = try! Data(contentsOf: url)
|
||||
let model = KaraokeView.parseLyricData(data: data)
|
||||
karaokeView.setLyricData(data: model)
|
||||
```
|
||||
|
||||
|
||||
#### 3.设置进度
|
||||
```swift
|
||||
karaokeView.setProgress(progress: progress)
|
||||
```
|
||||
|
||||
#### 4.设置演唱者音调
|
||||
|
||||
```swift
|
||||
karaokeView.setPitch(pitch: pitch)
|
||||
```
|
||||
|
||||
#### 5.重置
|
||||
|
||||
```swift
|
||||
karaokeView.reset()
|
||||
```
|
||||
|
||||
*除以上之外,还可以参考源码中的`MainTestVC.swift`*
|
||||
|
||||
## 调用时序
|
||||
|
||||

|
||||
|
||||
## 对外接口
|
||||
|
||||
### 主View:**KaraokeView**
|
||||
|
||||
```swift
|
||||
/// 背景图
|
||||
@objc public var backgroundImage: UIImage? = nil
|
||||
|
||||
/// 是否使用评分功能
|
||||
/// - Note: 当`LyricModel.hasPitch = false`,强制不使用
|
||||
/// - Note: 当为 `false`, 会隐藏评分视图
|
||||
@objc public var scoringEnabled: Bool = true
|
||||
|
||||
/// 评分组件和歌词组件之间的间距 默认: 0
|
||||
@objc public var spacing: CGFloat = 0
|
||||
|
||||
@objc public weak var delegate: KaraokeDelegate?
|
||||
@objc public let lyricsView = LyricsView()
|
||||
@objc public let scoringView = ScoringView()
|
||||
|
||||
/// 解析歌词文件xml数据
|
||||
/// - Parameter data: xml二进制数据
|
||||
/// - Returns: 歌词信息
|
||||
@objc public static func parseLyricData(data: Data) -> LyricModel?
|
||||
|
||||
/// 设置歌词数据信息
|
||||
/// - Parameter data: 歌词信息 由 `parseLyricData(data: Data)` 生成. 如果纯音乐, 给 `.empty`.
|
||||
@objc public func setLyricData(data: LyricModel?)
|
||||
|
||||
/// 重置, 歌曲停止、切歌需要调用
|
||||
@objc public func reset()
|
||||
|
||||
/// 设置实时采集(mic)的Pitch
|
||||
/// - Note: 可以从AgoraRTC回调方法 `- (void)rtcEngine:(AgoraRtcEngineKit * _Nonnull)engine reportAudioVolumeIndicationOfSpeakers:(NSArray<AgoraRtcAudioVolumeInfo *> * _Nonnull)speakers totalVolume:(NSInteger)totalVolume` 获取
|
||||
/// - Parameter pitch: 实时音调值
|
||||
@objc public func setPitch(pitch: Double)
|
||||
|
||||
/// 设置当前歌曲的进度
|
||||
/// - Note: 可以获取播放器的当前进度进行设置
|
||||
/// - Parameter progress: 歌曲进度 (ms)
|
||||
@objc public func setProgress(progress: Int)
|
||||
|
||||
/// 同时设置进度和Pitch (建议观众端使用)
|
||||
/// - Parameters:
|
||||
/// - pitch: 实时音调值
|
||||
/// - progress: 歌曲进度 (ms)
|
||||
@objc public func setPitch(pitch: Double, progress: Int)
|
||||
|
||||
/// 设置自定义分数计算对象
|
||||
/// - Note: 如果不调用此方法,则内部使用默认计分规则
|
||||
/// - Parameter algorithm: 遵循`IScoreAlgorithm`协议实现的对象
|
||||
@objc public func setScoreAlgorithm(algorithm: IScoreAlgorithm)
|
||||
|
||||
/// 设置打分难易程度(难度系数)
|
||||
/// - Note: 值越小打分难度越小,值越高打分难度越大
|
||||
/// - Parameter level: 系数, 范围:[0, 100], 如不设置默认为15
|
||||
@objc public func setScoreLevel(level: Int)
|
||||
|
||||
/// 设置打分分值补偿
|
||||
/// - Note: 在计算分值的时候作为补偿
|
||||
/// - Parameter offset: 分值补偿 [-100, 100], 如不设置默认为0
|
||||
@objc public func setScoreCompensationOffset(offset: Int)
|
||||
```
|
||||
|
||||
### 歌词:**LyricsView**
|
||||
|
||||
```swift
|
||||
/// 无歌词提示文案
|
||||
@objc public var noLyricTipsText: String
|
||||
/// 无歌词提示文字颜色
|
||||
@objc public var noLyricTipsColor: UIColor
|
||||
/// 无歌词提示文字大小
|
||||
@objc public var noLyricTipsFont: UIFont
|
||||
/// 是否隐藏等待开始圆点
|
||||
@objc public var waitingViewHidden: Bool
|
||||
/// 正常歌词颜色
|
||||
@objc public var textNormalColor: UIColor
|
||||
/// 选中的歌词颜色
|
||||
@objc public var textSelectedColor: UIColor
|
||||
/// 高亮的歌词颜色 (命中)
|
||||
@objc public var textHighlightedColor: UIColor
|
||||
/// 正常歌词文字大小
|
||||
@objc public var textNormalFontSize
|
||||
/// 高亮歌词文字大小
|
||||
@objc public var textHighlightFontSize
|
||||
/// 歌词最大宽度
|
||||
@objc public var maxWidth: CGFloat
|
||||
/// 歌词上下间距
|
||||
@objc public var lyricLineSpacing: CGFloat
|
||||
/// 等待开始圆点风格
|
||||
@objc public let firstToneHintViewStyle: FirstToneHintViewStyle
|
||||
/// 是否开启拖拽
|
||||
@objc public var draggable: Bool
|
||||
```
|
||||
|
||||
### 评分:**ScoringView**
|
||||
|
||||
```swift
|
||||
/// 评分视图高度
|
||||
@objc public var viewHeight: CGFloat
|
||||
/// 渲染视图到顶部的间距
|
||||
@objc public var topSpaces: CGFloat
|
||||
/// 游标的起始位置
|
||||
@objc public var defaultPitchCursorX: CGFloat
|
||||
/// 音准线的高度
|
||||
@objc public var standardPitchStickViewHeight: CGFloat
|
||||
/// 音准线的基准因子
|
||||
@objc public var movingSpeedFactor: CGFloat
|
||||
/// 音准线默认的背景色
|
||||
@objc public var standardPitchStickViewColor: UIColor
|
||||
/// 音准线匹配后的背景色
|
||||
@objc public var standardPitchStickViewHighlightColor: UIColor
|
||||
/** 游标偏移量(X轴) 游标的中心到竖线中心的距离
|
||||
- 等于0:游标中心点和竖线中线点重合
|
||||
- 小于0: 游标向左偏移
|
||||
- 大于0:游标向向偏移 **/
|
||||
@objc public var localPitchCursorOffsetX: CGFloat
|
||||
/// 游标的图片
|
||||
@objc public var localPitchCursorImage: UIImage?
|
||||
/// 是否隐藏粒子动画效果
|
||||
@objc public var particleEffectHidden: Bool
|
||||
/// 使用图片创建粒子动画
|
||||
@objc public var emitterImages: [UIImage]?
|
||||
/// 打分容忍度 范围:0-1
|
||||
@objc public var hitScoreThreshold: Float = 0.7
|
||||
/// use for debug only
|
||||
@objc public var showDebugView = false
|
||||
```
|
||||
|
||||
## 事件回调
|
||||
|
||||
### **KaraokeDelegate**
|
||||
|
||||
```swift
|
||||
@objc public protocol KaraokeDelegate: NSObjectProtocol {
|
||||
/// 拖拽歌词结束后回调
|
||||
/// - Note: 当 `KaraokeConfig.lyricConfig.draggable == true` 且 用户进行拖动歌词时候 调用
|
||||
/// - Parameters:
|
||||
/// - view: KaraokeView
|
||||
/// - position: 当前时间点 (ms)
|
||||
@objc optional func onKaraokeView(view: KaraokeView, didDragTo position: Int)
|
||||
|
||||
/// 歌曲播放完一行(Line)时的歌词回调
|
||||
/// - Parameters:
|
||||
/// - model: 行信息
|
||||
/// - score: 当前行得分 [0, 100]
|
||||
/// - cumulativeScore: 累计分数
|
||||
/// - lineIndex: 行索引号 最小值:0
|
||||
/// - lineCount: 总行数
|
||||
@objc optional func onKaraokeView(view: KaraokeView,
|
||||
didFinishLineWith model: LyricLineModel,
|
||||
score: Int,
|
||||
cumulativeScore: Int,
|
||||
lineIndex: Int,
|
||||
lineCount: Int)
|
||||
}
|
||||
```
|
||||
|
||||
### **分数计算协议**
|
||||
|
||||
```swift
|
||||
@objc public protocol IScoreAlgorithm {
|
||||
// MARK: - 自定义分数
|
||||
|
||||
/// 计算当前行(Line)的分数
|
||||
/// - Parameters:
|
||||
/// - models: 字得分信息集合
|
||||
/// - Returns: 计算后的分数 [0, 100]
|
||||
@objc func getLineScore(with toneScores: [ToneScoreModel]) -> Int
|
||||
}
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## 集成方式
|
||||
|
||||
### pod引入
|
||||
|
||||
|
||||
```ruby
|
||||
pod 'AgoraLyricsScore', '~> 1.1.6'"
|
||||
```
|
||||