首次提交

This commit is contained in:
启星
2025-08-08 11:05:33 +08:00
parent 1b3bb91b4a
commit adc1a2a25d
8803 changed files with 708874 additions and 0 deletions

View 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;
}

View 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 */

View 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)
// headerlength
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
}
}
}

View 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()
}
}

View 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")
}
}
}
}

View 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
}
}

View 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
}
}

View 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)
}
}
}

View 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?)
}

View 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"
}
}
}

View 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 { /** 100* **/
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
}
}

View 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
}
}

View 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
}

View 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
}
}

View 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)
}
}
}

View 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
}
}
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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)
}
}

View 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)
}
}
}

View 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)
}
}

View 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
}
}

View 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")
}
}

View 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()
}
}
}

View 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
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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
/** bottomtop **/
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
}
}

View 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)
}
}
}

View 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
}
}

View 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) })
}
}
}

View 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
}
}

View 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: topbottom
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
}
}

View 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
}

View 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)
}

View 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)
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

230
Pods/AgoraLyricsScore/README.md generated Normal file
View 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`*
## 调用时序
![](TimingDiagram.png)
## 对外接口
### 主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'"
```