├── AgoraLyricsScore ├── Tests │ ├── Resource │ │ ├── krc-error-pattern.krc │ │ ├── readme.txt │ │ ├── CJhd625070.lrc │ │ ├── EnhancedLRCformat3.lrc │ │ ├── testissue60022.xml │ │ ├── lrc.lrc │ │ ├── kj5396d18ebe5811ed9efdb2f16c44e48f.lrc │ │ ├── EnhancedLRCformat.lrc │ │ └── 4875936889260991133.krc │ ├── TestParser │ │ ├── Extensions.swift │ │ └── TestLrcParser.swift │ ├── TestLyricsView │ │ ├── TestKaraokeView.swift │ │ └── TestLyricsView.swift │ ├── TestScoringMachine │ │ ├── LogFileReader.swift │ │ └── TestMockScoring.swift │ ├── TestDownload │ │ ├── TestDownloadCancle.swift │ │ ├── TestDownloadMuti.swift │ │ └── TestFileCache.swift │ └── TestLineScoreRecorder │ │ └── TestLineScoreRecorder.swift ├── Resources │ └── Media.xcassets │ │ ├── Contents.json │ │ ├── star1.imageset │ │ ├── star1.png │ │ ├── star1@2x.png │ │ ├── star1@3x.png │ │ └── Contents.json │ │ ├── star2.imageset │ │ ├── star2.png │ │ ├── star2@2x.png │ │ ├── star2@3x.png │ │ └── Contents.json │ │ ├── star3.imageset │ │ ├── star3.png │ │ ├── star3@2x.png │ │ ├── star3@3x.png │ │ └── Contents.json │ │ ├── star4.imageset │ │ ├── star4.png │ │ ├── star4@2x.png │ │ ├── star4@3x.png │ │ └── Contents.json │ │ ├── star5.imageset │ │ ├── star5.png │ │ ├── star5@2x.png │ │ ├── star5@3x.png │ │ └── Contents.json │ │ ├── star6.imageset │ │ ├── star6.png │ │ ├── star6@2x.png │ │ ├── star6@3x.png │ │ └── Contents.json │ │ ├── star7.imageset │ │ ├── star7.png │ │ ├── star7@2x.png │ │ ├── star7@3x.png │ │ └── Contents.json │ │ ├── star8.imageset │ │ ├── star8.png │ │ ├── star8@2x.png │ │ ├── star8@3x.png │ │ └── Contents.json │ │ ├── icon_trangle.imageset │ │ ├── icon_trangle@2X.png │ │ ├── icon_trangle@3X.png │ │ └── Contents.json │ │ ├── bg_scoring_left.imageset │ │ ├── bg_scoring_left@2X.png │ │ ├── bg_scoring_left@3X.png │ │ └── Contents.json │ │ └── icon_vertical_line.imageset │ │ ├── icon_vertical_line@2x.png │ │ ├── icon_vertical_line@3x.png │ │ └── Contents.json └── Class │ ├── Downloader │ ├── LyricsFileDownloader+Info.swift │ ├── Extentions.swift │ ├── DownloaderManager.swift │ └── LyricsFileDownloaderProtocol.swift │ ├── Scoring │ ├── Other │ │ ├── ScoreAlgorithm.swift │ │ ├── ToneCalculator.swift │ │ └── VoicePitchChanger.swift │ ├── View │ │ ├── ScoringView+Events.swift │ │ ├── ConsoleView.swift │ │ └── ScoringCanvasView.swift │ ├── ScoringMachineProtocol+Infos.swift │ ├── ScoringMachineProtocol.swift │ └── ScoringMachineProtocol+Events.swift │ ├── Al │ ├── Algorithm.h │ └── Algorithm.c │ ├── Other │ ├── DataStructs.swift │ ├── Logger.swift │ ├── PitchParser.swift │ ├── Parser.swift │ ├── ProgressChecker.swift │ ├── LineScoreRecorder.swift │ ├── Log.swift │ └── Extensions.swift │ ├── Events.swift │ └── Lyrics │ ├── LyricLabel.swift │ ├── LyricMachine+Events.swift │ └── FirstToneHintView.swift ├── TimingDiagram.png ├── Demo ├── Demo │ ├── Resource │ │ ├── music.mp3 │ │ └── lrc.lrc │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── t1.imageset │ │ │ ├── t1.png │ │ │ └── Contents.json │ │ ├── star1.imageset │ │ │ ├── star1@2x.png │ │ │ ├── star1@3x.png │ │ │ └── Contents.json │ │ ├── star2.imageset │ │ │ ├── star2@2x.png │ │ │ ├── star2@3x.png │ │ │ └── Contents.json │ │ ├── star3.imageset │ │ │ ├── star3@2x.png │ │ │ ├── star3@3x.png │ │ │ └── Contents.json │ │ ├── star4.imageset │ │ │ ├── star4@2x.png │ │ │ ├── star4@3x.png │ │ │ └── Contents.json │ │ ├── star5.imageset │ │ │ ├── star5@2x.png │ │ │ ├── star5@3x.png │ │ │ └── Contents.json │ │ ├── star6.imageset │ │ │ ├── star6@2x.png │ │ │ ├── star6@3x.png │ │ │ └── Contents.json │ │ ├── star7.imageset │ │ │ ├── star7@2x.png │ │ │ ├── star7@3x.png │ │ │ └── Contents.json │ │ ├── star8.imageset │ │ │ ├── star8@2x.png │ │ │ ├── star8@3x.png │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── icon-20@2x.jpg │ │ │ ├── icon-20@3x.jpg │ │ │ ├── icon-29@2x.jpg │ │ │ ├── icon-29@3x.jpg │ │ │ ├── icon-40@2x.jpg │ │ │ ├── icon-40@3x.jpg │ │ │ ├── icon-60@2x.jpg │ │ │ ├── icon-60@3x.jpg │ │ │ ├── icon-1024@2x.png │ │ │ └── Contents.json │ │ ├── backgroundImage.imageset │ │ │ ├── bg.png │ │ │ └── Contents.json │ │ ├── ktv_top_bgIcon.imageset │ │ │ ├── ktv_top_bgIcon@2x.png │ │ │ ├── ktv_top_bgIcon@3x.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Demo-Bridging-Header.h │ ├── VC │ │ ├── OCVC.h │ │ ├── ScoreAniVC.swift │ │ ├── NoLyricsTestVC.swift │ │ ├── IncentiveVC.swift │ │ ├── LyricLabelVC.swift │ │ ├── MainVCEx │ │ │ └── MainTestVCEx+Debug.swift │ │ ├── FirstToneHintViewTestVC.swift │ │ ├── SongListVC.swift │ │ ├── ProfileVC.swift │ │ ├── AVPlayerTestVC.swift │ │ ├── OCVC.m │ │ ├── DownloadVC.swift │ │ ├── SearchVC.swift │ │ ├── EmitterVC.swift │ │ └── GCDTimer.swift │ ├── Config.swift │ ├── Info.plist │ ├── AppDelegate.swift │ ├── Other │ │ ├── Utils │ │ │ ├── AccessProvider.swift │ │ │ ├── SongSourceProvider.swift │ │ │ ├── ProgressProvider.swift │ │ │ └── LyricsCutter.swift │ │ ├── Log │ │ │ └── Log.swift │ │ └── Extensions.swift │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── SceneDelegate.swift │ └── View │ │ └── KTVView.swift ├── Demo.xcodeproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── Demo.xcscheme ├── Demo.xcworkspace │ └── contents.xcworkspacedata └── Podfile ├── AgoraLyricsScore.podspec └── .gitignore /AgoraLyricsScore/Tests/Resource/krc-error-pattern.krc: -------------------------------------------------------------------------------- 1 | [ar: 2 | -------------------------------------------------------------------------------- /TimingDiagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/TimingDiagram.png -------------------------------------------------------------------------------- /Demo/Demo/Resource/music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Resource/music.mp3 -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/t1.imageset/t1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/t1.imageset/t1.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star1.imageset/star1@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star1.imageset/star1@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star1.imageset/star1@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star1.imageset/star1@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star2.imageset/star2@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star2.imageset/star2@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star2.imageset/star2@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star2.imageset/star2@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star3.imageset/star3@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star3.imageset/star3@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star3.imageset/star3@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star3.imageset/star3@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star4.imageset/star4@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star4.imageset/star4@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star4.imageset/star4@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star4.imageset/star4@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star5.imageset/star5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star5.imageset/star5@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star5.imageset/star5@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star5.imageset/star5@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star6.imageset/star6@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star6.imageset/star6@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star6.imageset/star6@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star6.imageset/star6@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star7.imageset/star7@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star7.imageset/star7@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star7.imageset/star7@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star7.imageset/star7@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star8.imageset/star8@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star8.imageset/star8@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star8.imageset/star8@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/star8.imageset/star8@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Demo-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "OCVC.h" 6 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-20@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-20@2x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-20@3x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-20@3x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-29@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-29@2x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-29@3x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-29@3x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-40@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-40@2x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-40@3x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-40@3x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-60@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-60@2x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-60@3x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-60@3x.jpg -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/backgroundImage.imageset/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/backgroundImage.imageset/bg.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-1024@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/AppIcon.appiconset/icon-1024@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/star1@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/star2@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/star3@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/star4@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/star5@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/star6@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/star7@3x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/star8@3x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/ktv_top_bgIcon.imageset/ktv_top_bgIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/ktv_top_bgIcon.imageset/ktv_top_bgIcon@2x.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/ktv_top_bgIcon.imageset/ktv_top_bgIcon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/Demo/Demo/Assets.xcassets/ktv_top_bgIcon.imageset/ktv_top_bgIcon@3x.png -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/icon_trangle@2X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/icon_trangle@2X.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/icon_trangle@3X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/icon_trangle@3X.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/bg_scoring_left@2X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/bg_scoring_left@2X.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/bg_scoring_left@3X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/bg_scoring_left@3X.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/icon_vertical_line@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/icon_vertical_line@2x.png -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/icon_vertical_line@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AgoraIO-Community/LrcView-iOS/HEAD/AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/icon_vertical_line@3x.png -------------------------------------------------------------------------------- /Demo/Demo/VC/OCVC.h: -------------------------------------------------------------------------------- 1 | // 2 | // OCVC.h 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/2/2. 6 | // 7 | 8 | #import 9 | 10 | NS_ASSUME_NONNULL_BEGIN 11 | 12 | @interface OCVC : UIViewController 13 | 14 | @end 15 | 16 | NS_ASSUME_NONNULL_END 17 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/readme.txt: -------------------------------------------------------------------------------- 1 | 资源说明: 2 | 3 | 1.lrc.lrc 正常内容 4 | 2.745012.xml 正常内容 5 | 3.147383.xml 后面句子是pitch = 0 6 | 4.CJ1420023417.xml 异常,正常程序无法处理。有明显的时间错误。 7 | 8 | 5.4875936889260991133.krc 正常内容 9 | 6.4875936889260991133.pitch 正常内容 10 | 7.3017502026609527683.krc 以\n分行 -------------------------------------------------------------------------------- /Demo/Demo.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Downloader/LyricsFileDownloader+Info.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LyricsFileDownloader+Info.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/12/14. 6 | // 7 | 8 | import Foundation 9 | 10 | extension LyricsFileDownloader { 11 | struct TaskInfo { 12 | let requestId: Int 13 | let urlString: String 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/t1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "t1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/backgroundImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "bg.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star1@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star1@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star2@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star2@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star3@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star3@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star4@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star4@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star5@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star5@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star6@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star6@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star7@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star7@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/star8.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "star8@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "star8@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/Other/ScoreAlgorithm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScoreAlgorithm.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/1/11. 6 | // 7 | 8 | import Foundation 9 | 10 | class ScoreAlgorithm: IScoreAlgorithm { 11 | func getLineScore(with toneScores: [ToneScoreModel]) -> Int { 12 | if toneScores.isEmpty { return 0 } 13 | let ret = toneScores.map({ $0.score }).reduce(0.0, +) / Float(toneScores.count) 14 | return Int(ret) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Al/Algorithm.h: -------------------------------------------------------------------------------- 1 | // 2 | // Algorithm.h 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/11/9. 6 | // 7 | 8 | #ifndef Algorithm_h 9 | #define Algorithm_h 10 | 11 | #include 12 | 13 | double pitchToToneC(double pitch); 14 | float calculedScoreC(double voicePitch, double stdPitch, int scoreLevel, int scoreCompensationOffset); 15 | 16 | void resetC(void); 17 | double handlePitchC(double stdPitch, double voicePitch, double stdMaxPitch); 18 | #endif /* Algorithm_h */ 19 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/ktv_top_bgIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "ktv_top_bgIcon@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "ktv_top_bgIcon@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/icon_trangle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "icon_trangle@2X.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "icon_trangle@3X.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/bg_scoring_left.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "bg_scoring_left@2X.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "bg_scoring_left@3X.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/icon_vertical_line.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "icon_vertical_line@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "icon_vertical_line@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star1@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star1@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star2@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star2@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star3.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star3@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star3@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star4.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star4@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star4@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star5.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star5@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star5@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star6.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star6@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star6@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star7.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star7@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star7@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Resources/Media.xcassets/star8.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "star8.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "star8@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "star8@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestParser/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2022/12/22. 6 | // 7 | 8 | import Foundation 9 | 10 | class TG { 11 | 12 | } 13 | 14 | extension Bundle { 15 | static var current: Bundle { 16 | Bundle(for: TG.self) 17 | } 18 | } 19 | 20 | extension Data { 21 | func subdata(in range: CountableClosedRange) -> Data { 22 | return self.subdata(in: range.lowerBound.. 19 | static let mccAppId = <#mccAppId#> 20 | static let mccCertificate = <#mccCertificate#> 21 | 22 | /// ysd important vars 23 | static let pid = <#pid#> 24 | static let pKey = <#pKey#> 25 | static var token: String? = nil 26 | static var userId: String? = nil 27 | } 28 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/CJhd625070.lrc: -------------------------------------------------------------------------------- 1 | [00:11.67]他想知道那是谁 2 | [00:18.77]为何总沉默寡言 3 | [00:25.52]人群中也算抢眼 4 | [00:32.67]抢眼的孤独难免 5 | [00:40.24]快乐当然有一点 6 | [00:47.05]不过寂寞更强烈 7 | [00:53.84]难过时候不流泪 8 | [01:01.06]流泪也不算伤悲 9 | [01:09.26]天真以为是他的独特品味 10 | [01:14.86]殊不知是他 11 | [01:17.96]难以言喻的对决 12 | [01:23.94]子母画面分割上演谍对谍 13 | [01:29.71]而谁是谁 14 | [01:38.40]对于第三人称的角度而言 15 | [01:43.96]也明白其实 16 | [01:46.88]每个人都有缺陷 17 | [01:52.87]不自觉遮掩 多少也算 18 | [01:58.98]自然的行为 19 | [02:20.61] 20 | [02:34.73]快乐当然有一点 21 | [02:42.02]不过寂寞更强烈 22 | [02:49.13]难过时候不流泪 23 | [02:56.11]流泪也不算伤悲 24 | [03:04.14]天真以为是他的独特品味 25 | [03:09.92]殊不知是他 26 | [03:12.67]难以言喻的对决 27 | [03:18.66]子母画面分割上演谍对谍 28 | [03:24.98]而谁是谁 29 | [03:32.39]对于第三人称的角度而言 30 | [03:38.71]也明白其实 31 | [03:41.36]每个人都有缺陷 32 | [03:46.56]才不断的追寻 更好的自己 33 | [03:53.81]直到青春一定程度的浪费 34 | [03:59.88]才觉得可贵 35 | [04:07.85]他想知道那是谁 -------------------------------------------------------------------------------- /Demo/Demo/VC/ScoreAniVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScoreAni.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | import AgoraLyricsScore 11 | 12 | class ScoreAniVC: UIViewController { 13 | 14 | // let localPitchView = LocalPitchView() 15 | // var start = true 16 | // private var timer = GCDTimer() 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | // view.addSubview(localPitchView) 21 | // localPitchView.frame = .init(x: 0, y: 150, width: 150, height: 100) 22 | // timer.scheduledMillisecondsTimer(withName: "EmitterVC", countDown: 1000000, milliseconds: 800, queue: .main) { [weak self](_, time) in 23 | // guard let self = self else { return } 24 | // self.localPitchView.showScoreView(score: Int.random(in: 1...100)) 25 | // } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Demo/Demo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | UIBackgroundModes 25 | 26 | audio 27 | 28 | UIFileSharingEnabled 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Demo/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | target 'Demo' do 5 | # Comment the next line if you don't want to use dynamic frameworks 6 | use_frameworks! 7 | source 'https://github.com/CocoaPods/Specs.git' 8 | 9 | pod "AgoraLyricsScore", :path => "../AgoraLyricsScore.podspec", :testspecs => ['Tests'] 10 | pod 'RTMTokenBuilder', "1.0.2" 11 | # pod 'AgoraRtcEngine_iOS', '4.1.1' 12 | pod 'AgoraRtcEngine_Special_iOS', '4.1.1.24' 13 | pod 'AgoraMccExService', :path => "/Volumes/T5/PodSpec/AgoraMccExService/AgoraMccExService.podspec" 14 | pod "Zip" 15 | pod "ScoreEffectUI", :path => "~/work/DevHistoryProject/ScoreEffectUI/ScoreEffectUI.podspec", :testspecs => ['Tests'] 16 | pod 'AgoraComponetLog', '0.0.3' 17 | pod 'SVProgressHUD' 18 | end 19 | 20 | #post_install do |installer| 21 | # installer.pods_project.build_configurations.each do |config| 22 | # config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64" 23 | # end 24 | #end 25 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/View/ConsoleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConsoleView.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/1/30. 6 | // 7 | 8 | import UIKit 9 | 10 | /// use for debug only 11 | class ConsoleView: UIView { 12 | private let label = UILabel() 13 | 14 | override init(frame: CGRect) { 15 | super.init(frame: frame) 16 | backgroundColor = UIColor.black.withAlphaComponent(0.2) 17 | 18 | label.numberOfLines = 0 19 | label.textColor = .white 20 | label.font = .systemFont(ofSize: 9) 21 | addSubview(label) 22 | label.translatesAutoresizingMaskIntoConstraints = false 23 | label.leftAnchor.constraint(equalTo: leftAnchor).isActive = true 24 | label.rightAnchor.constraint(equalTo: rightAnchor).isActive = true 25 | label.topAnchor.constraint(equalTo: topAnchor).isActive = true 26 | label.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 27 | } 28 | 29 | required init?(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | func set(text: String) { 34 | label.text = text 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/Other/ToneCalculator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToneCalculator.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/1/11. 6 | // 7 | 8 | import Foundation 9 | 10 | var useC = true 11 | 12 | class ToneCalculator { 13 | /// 计算tone分数 14 | static func calculedScore(voicePitch: Double, 15 | stdPitch: Double, 16 | scoreLevel: Int, 17 | scoreCompensationOffset: Int) -> Float { 18 | if useC { 19 | return calculedScoreC(voicePitch, stdPitch, Int32(scoreLevel), Int32(scoreCompensationOffset)) 20 | } 21 | 22 | let stdTone = ToneCalculator.pitchToTone(pitch: stdPitch) 23 | let voiceTone = ToneCalculator.pitchToTone(pitch: voicePitch) 24 | var match = 1 - Float(scoreLevel)/100 * Float(abs(voiceTone - stdTone)) + Float(scoreCompensationOffset)/100 25 | match = max(0, match) 26 | match = min(1, match) 27 | return match * 100 28 | } 29 | 30 | static func pitchToTone(pitch: Double) -> Double { 31 | if useC { 32 | return pitchToToneC(pitch) 33 | } 34 | let eps = 1e-6 35 | return (max(0, log(pitch / 55 + eps) / log(2))) * 12 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Demo/Demo/VC/NoLyricsTestVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NoLyricsTestVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2022/12/23. 6 | // 7 | 8 | import UIKit 9 | import AgoraLyricsScore 10 | 11 | class NoLyricsTestVC: UIViewController { 12 | let karaokeView = KaraokeView() 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | setupUI() 17 | 18 | karaokeView.lyricsView.noLyricTipsFont = .systemFont(ofSize: 20) 19 | karaokeView.lyricsView.noLyricTipsText = "哈哈asdasd" 20 | karaokeView.lyricsView.noLyricTipsColor = .red 21 | karaokeView.setLyricData(data: nil, usingInternalScoring: true) 22 | } 23 | 24 | func setupUI() { 25 | view.backgroundColor = .black 26 | view.addSubview(karaokeView) 27 | karaokeView.translatesAutoresizingMaskIntoConstraints = false 28 | 29 | karaokeView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 30 | karaokeView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true 31 | karaokeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 32 | karaokeView.heightAnchor.constraint(equalToConstant: 350).isActive = true 33 | } 34 | 35 | func commonInit() {} 36 | 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/EnhancedLRCformat3.lrc: -------------------------------------------------------------------------------- 1 | [00:18.912]<00:18.912>他<00:39.322>们<01:01.474>总<01:02.972>是<01:10.112>说<01:10.774>我<01:18.123>有<01:23.974>时<01:25.577>不<01:29.896>会<01:31.080>怎<01:31.463>么<01:31.637>讲<01:32.125>话 2 | [01:34.075]<01:34.075>于<01:35.050>是<01:36.026>那<01:37.280>些<01:38.429>不<01:38.742>该<01:39.021>有<01:39.265>的<01:39.578>错<01:40.728>误<01:48.982>总<01:54.416>会<01:58.143>放<01:58.386>下 3 | [01:59.222]<01:59.222>我<02:01.556>身<02:04.482>上<02:05.109>的<02:05.213>缺<02:05.422>点<02:07.024>被<02:07.303>无<02:08.000>限<02:09.462>的<02:14.095>放<02:15.592>大 4 | [02:16.289]<02:16.289>跟<02:19.772>你<02:21.026>的<02:26.285>矛<02:30.883>盾 <02:32.833>我<02:34.679>的<02:35.585>一<02:35.794>个<02:36.038>惩<02:37.291>罚 5 | 6 | [02:37.570]<02:37.570>他<02:37.709>们<02:38.162>总<02:38.406>是<02:47.183>说<02:56.936>我<02:59.339>有<03:01.568>时<03:25.183>不<03:39.985>会<03:47.543>怎<03:47.822>么<03:53.221>讲<03:54.370>话 7 | [04:01.545]<04:01.545>于<04:04.401>是<04:05.690>那<04:06.839>些<04:06.979>不<04:08.407>该<04:08.825>有<04:09.103>的<04:09.626>错<04:10.636>误<04:10.984>总<04:13.074>会<04:13.283>放<04:17.184>下 8 | [04:33.380]<04:33.380>我<04:33.763>身<04:39.440>上<04:42.505>的<04:42.853>缺<04:43.202>点<04:45.918>被<04:46.545>无<04:47.207>限<04:47.590>的<04:50.725>放<04:51.526>大 9 | [04:53.964]<04:53.964>跟<04:55.949>你<04:56.124>的<04:56.367>矛<04:56.646>盾 <04:57.308>我<04:57.412>的<04:57.795>一<04:57.970>个<04:59.781>惩<05:50.145>罚 -------------------------------------------------------------------------------- /Demo/Demo/VC/IncentiveVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IncentiveVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/1/29. 6 | // 7 | 8 | import UIKit 9 | 10 | import ScoreEffectUI 11 | import AgoraLyricsScore 12 | 13 | class IncentiveVC: UIViewController { 14 | 15 | let incentiveView = IncentiveView() 16 | var start = true 17 | private var timer = GCDTimer() 18 | let list = [0, 80, 60, 83, 89, 90, 83, 84, 85] 19 | var index = 0 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | view.backgroundColor = .black 24 | view.addSubview(incentiveView) 25 | incentiveView.frame = .init(x: 100, y: 100, width: 200, height: 200) 26 | timer.scheduledMillisecondsTimer(withName: "EmitterVC", countDown: 1000000, milliseconds: 1000, queue: .main) { [weak self](_, time) in 27 | guard let self = self else { return } 28 | 29 | if self.index > self.list.count-1 { 30 | // self.index = 0 31 | } 32 | else { 33 | let score = self.list[self.index] 34 | self.incentiveView.show(score: score) 35 | } 36 | self.index += 1 37 | } 38 | 39 | } 40 | 41 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 42 | 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /AgoraLyricsScore.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "AgoraLyricsScore" 3 | spec.version = "2.1.6" 4 | spec.summary = "AgoraLyricsScore" 5 | spec.description = "AgoraLyricsScore" 6 | 7 | spec.homepage = "https://github.com/AgoraIO-Community" 8 | spec.license = "MIT" 9 | spec.author = { "ZYQ" => "zhaoyongqiang@agora.io" } 10 | spec.source = { :git => "https://github.com/AgoraIO-Community/LrcView-iOS.git", :tag => '2.1.6' } 11 | spec.source_files = ["AgoraLyricsScore/Class/**/*.swift", "AgoraLyricsScore/Class/AL/*"] 12 | spec.pod_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64', 'DEFINES_MODULE' => 'YES' } 13 | spec.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'arm64', 'DEFINES_MODULE' => 'YES' } 14 | spec.ios.deployment_target = '10.0' 15 | spec.swift_versions = "5.0" 16 | spec.requires_arc = true 17 | spec.dependency 'AgoraComponetLog', '~> 0.0.3' 18 | spec.dependency 'Zip', '2.1.2' 19 | spec.resource_bundles = { 20 | 'AgoraLyricsScoreBundle' => ['AgoraLyricsScore/Resources/*.xcassets'] 21 | } 22 | 23 | spec.test_spec 'Tests' do |test_spec| 24 | test_spec.source_files = "AgoraLyricsScore/Tests/**/*.{swift}" 25 | test_spec.resource = "AgoraLyricsScore/Tests/Resource/*" 26 | test_spec.frameworks = 'UIKit','Foundation' 27 | test_spec.requires_app_host = true 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /Demo/Demo/VC/LyricLabelVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LyricLabelVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/1/31. 6 | // 7 | 8 | import UIKit 9 | import AgoraLyricsScore 10 | 11 | class LyricLabelVC: UIViewController { 12 | // let label = LyricLabel() 13 | var count = 0 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | view.backgroundColor = .blue 17 | 18 | // label.maxWidth = 350 19 | // label.text = "我们得失的安徽的" 20 | // view.addSubview(label) 21 | // label.translatesAutoresizingMaskIntoConstraints = false 22 | // 23 | // label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true 24 | // label.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true 25 | // 26 | // label.status = .normal 27 | } 28 | 29 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 30 | 31 | // if count < 5 { 32 | // label.status = .selectedOrHighlighted 33 | // label.progressRate = .random(in: 0.1...1) 34 | // } 35 | // else { 36 | // label.text = "" 37 | // label.status = .normal 38 | // label.progressRate = 0 39 | // 40 | // label.text = "我们得失的安徽的" 41 | // label.status = .selectedOrHighlighted 42 | // } 43 | // 44 | // count += 1 45 | } 46 | 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Demo/Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2022/12/21. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 13 | Log.setupLogger() 14 | return true 15 | } 16 | 17 | // MARK: UISceneSession Lifecycle 18 | 19 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 20 | // Called when a new scene session is being created. 21 | // Use this method to select a configuration to create the new scene with. 22 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 23 | } 24 | 25 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 26 | // Called when the user discards a scene session. 27 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 28 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 29 | } 30 | 31 | 32 | } 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/xcode 3 | 4 | ### Xcode ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## Build generated 10 | build/ 11 | DerivedData/ 12 | 13 | ## Various settings 14 | *.pbxuser 15 | !default.pbxuser 16 | *.mode1v3 17 | !default.mode1v3 18 | *.mode2v3 19 | !default.mode2v3 20 | *.perspectivev3 21 | !default.perspectivev3 22 | xcuserdata/ 23 | 24 | ## Other 25 | *.moved-aside 26 | *.xccheckout 27 | *.xcscmblueprint 28 | 29 | ### Xcode Patch ### 30 | *.xcodeproj/* 31 | !*.xcodeproj/project.pbxproj 32 | !*.xcodeproj/xcshareddat 33 | !*/xcuserdata/admin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlista/ 34 | !*.xcworkspace/contents.xcworkspacedata 35 | /*.gcno 36 | 37 | 38 | # End of https://www.gitignore.io/api/xcode 39 | 40 | 41 | 42 | # Created by https://www.gitignore.io/api/cocoapods 43 | 44 | ### CocoaPods ### 45 | ## CocoaPods GitIgnore Template 46 | 47 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 48 | # - Also handy if you have a large number of dependant pods 49 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 50 | Pods/ 51 | Podfile.lock 52 | .DS_Store 53 | Demo/Demo/Config.swift 54 | Demo/Demo/Other/Config.swift 55 | 56 | # End of https://www.gitignore.io/api/cocoapods 57 | 58 | 59 | Demo/Demo/Config.swift 60 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestLyricsView/TestKaraokeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestKaraokeView.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2023/9/26. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | final class TestKaraokeView: XCTestCase, KaraokeDelegate { 12 | var karaokeView: KaraokeView! 13 | let exp = XCTestExpectation(description: "test TestKaraokeView") 14 | 15 | func testExample() throws { /** 重现#CSD-59221、#CSD-60022 **/ 16 | karaokeView = KaraokeView(frame: .init(x: 0, y: 0, width: 350, height: 400),loggers:[ConsoleLogger()]) 17 | let url = URL(fileURLWithPath: Bundle.current.path(forResource: "testissue60022", ofType: "xml")!) 18 | let data = try! Data(contentsOf: url) 19 | guard let model = KaraokeView.parseLyricData(lyricFileData: data) else { 20 | XCTFail() 21 | return 22 | } 23 | karaokeView.delegate = self 24 | karaokeView.setLyricData(data: model, usingInternalScoring: true) 25 | for time: UInt in 0...1001000 { 26 | karaokeView.setProgress(progress: time) 27 | } 28 | sleep(1) 29 | karaokeView.reset() 30 | wait(for: [exp], timeout: 60 * 3) 31 | } 32 | 33 | func onKaraokeView(view: KaraokeView, didFinishLineWith model: LyricLineModel, score: Int, cumulativeScore: Int, lineIndex: Int, lineCount: Int) { 34 | exp.fulfill() 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-20@2x.jpg", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "icon-20@3x.jpg", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "icon-29@2x.jpg", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "icon-29@3x.jpg", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "icon-40@2x.jpg", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "icon-40@3x.jpg", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "icon-60@2x.jpg", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "icon-60@3x.jpg", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "icon-1024@2x.png", 53 | "idiom" : "ios-marketing", 54 | "scale" : "1x", 55 | "size" : "1024x1024" 56 | } 57 | ], 58 | "info" : { 59 | "author" : "xcode", 60 | "version" : 1 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/testissue60022.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 晴天 5 | 周杰伦 6 | 1 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Demo/Demo/VC/MainVCEx/MainTestVCEx+Debug.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTestVCEx+Debug.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2024/5/13. 6 | // 7 | 8 | import Foundation 9 | import AgoraMccExService 10 | 11 | extension MainTestVCEx { /** for debug **/ 12 | static var lastProgressInMs: UInt = 0 13 | static var lastPitchTime: CFAbsoluteTime = 0 14 | 15 | func updateLastProgressInMs_debug(progressInMs: UInt) { 16 | MainTestVCEx.lastProgressInMs = progressInMs 17 | } 18 | 19 | func calculateProgressGap_debug(progressInMs: UInt) -> Int { 20 | let progressGap = Int(progressInMs) - Int(MainTestVCEx.lastProgressInMs) 21 | MainTestVCEx.lastProgressInMs = progressInMs 22 | return progressGap 23 | } 24 | 25 | /// 打印onPitch回调间隔 26 | func logOnPitchInvokeGap_debug() { 27 | let startTime = CFAbsoluteTimeGetCurrent() 28 | let gap = startTime - MainTestVCEx.lastPitchTime 29 | MainTestVCEx.lastPitchTime = startTime 30 | if (gap > 0.1) { 31 | Log.warning(text: "OnPitch invoke gap \(gap)", tag: self.logTag) 32 | } 33 | else { 34 | Log.debug(text: "OnPitch invoke gap \(gap)", tag: self.logTag) 35 | } 36 | } 37 | 38 | // 打印非法的speakerPitch 39 | func logNInvalidSpeakPitch_debug(data: AgoraRawScoreDataEx) { 40 | if (data.speakerPitch < 0) { 41 | Log.errorText(text: "speakerPitch less than 0, \(data.speakerPitch)", tag: self.logTag) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Demo/Demo/Other/Utils/AccessProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessProvider.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2024/5/14. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class AccessProvider { 12 | typealias AccessBlock = (_ userId: String, _ token: String, _ errorMsg: String?) -> Void 13 | static var userId: String? 14 | static var token: String? 15 | 16 | static func fetchAccessData(completed: @escaping AccessBlock) { 17 | let url = Config.accessUrl 18 | let session = URLSession.shared 19 | let task = session.dataTask(with: URL(string: url)!) { (data, response, error) in 20 | 21 | if let e = error { 22 | completed("", "", e.localizedDescription) 23 | return 24 | } 25 | 26 | guard let data = data else { 27 | completed("", "", "data is nil") 28 | return 29 | } 30 | 31 | guard let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers), let dict = json as? [String: Any] else { 32 | completed("", "", "json error") 33 | return 34 | } 35 | 36 | guard let error = dict["error"] as? Int, 37 | error == 0, let data = dict["data"] as? [String: Any], let userId = data["yinsuda_uid"] as? String, let token = data["token"] as? String else { 38 | completed("", "", "data error") 39 | return 40 | } 41 | AccessProvider.userId = userId 42 | AccessProvider.token = token 43 | completed(userId, token, nil) 44 | } 45 | task.resume() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Demo/Demo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Demo/Demo/Resource/lrc.lrc: -------------------------------------------------------------------------------- 1 | [00:23.56]什么是幸福 2 | [00:26.01]什么叫相爱 3 | [00:28.65]什么才是 4 | [00:29.82]永远和未来 5 | [00:33.55]看不到的情 6 | [00:36.03]啊摸不着的爱 7 | [00:38.76]看不见的幸福 8 | [00:41.23]甜蜜和期待 9 | [00:43.82]失去了关心 10 | [00:46.35]失去了关怀 11 | [00:48.82]失去了就 12 | [00:50.03]永远回不来 13 | [00:53.79]怎么去挽回 14 | [00:56.19]啊怎么去等待 15 | [00:58.80]怎么改变也不会 16 | [01:01.24]让一切再重来 17 | [01:04.01]没了心的爱 18 | [01:05.94]就像过了夜的菜 19 | [01:08.95]经不起那 20 | [01:10.26]风吹和雨晒 21 | [01:14.05]两个人的情 22 | [01:16.17]如果出现了意外 23 | [01:19.15]还有什么值得去爱 24 | [01:21.62]舍不得分开 25 | [01:24.24]没了心的爱 26 | [01:26.16]就像鱼儿离了海 27 | [01:29.29]被迫选择 28 | [01:30.45]逃离的无奈 29 | [01:34.33]两个人的心 30 | [01:36.45]如果走不到一块 31 | [01:39.34]结果最后只会 32 | [01:41.22]再让彼此受伤害 33 | [02:04.59]什么是幸福 34 | [02:07.16]什么叫相爱 35 | [02:09.68]什么才是 36 | [02:10.94]永远和未来 37 | [02:14.72]看不到的情 38 | [02:17.04]啊摸不着的爱 39 | [02:19.82]看不见的幸福 40 | [02:22.30]甜蜜和期待 41 | [02:24.83]失去了关心 42 | [02:27.35]失去了关怀 43 | [02:29.88]失去了就 44 | [02:31.14]永远回不来 45 | [02:34.99]怎么去挽回 46 | [02:37.20]啊怎么去等待 47 | [02:40.07]怎么改变也不会 48 | [02:42.14]让一切再重来 49 | [02:44.97]没了心的爱 50 | [02:46.74]就像过了夜的菜 51 | [02:50.11]经不起那 52 | [02:51.32]风吹和雨晒 53 | [02:55.22]两个人的情 54 | [02:57.14]如果出现了意外 55 | [03:00.17]还有什么值得去爱 56 | [03:02.75]舍不得分开 57 | [03:05.38]没了心的爱 58 | [03:07.14]就像鱼儿离了海 59 | [03:10.33]被迫选择 60 | [03:11.54]逃离的无奈 61 | [03:15.28]两个人的心 62 | [03:17.30]如果走不到一块 63 | [03:20.39]结果最后只会 64 | [03:22.26]再让彼此受伤害 65 | [03:25.54]没了心的爱 66 | [03:27.36]就像过了夜的菜 67 | [03:30.54]经不起那 68 | [03:31.75]风吹和雨晒 69 | [03:35.59]两个人的情 70 | [03:37.50]如果出现了意外 71 | [03:40.58]还有什么值得去爱 72 | [03:43.15]舍不得分开 73 | [03:45.78]没了心的爱 74 | [03:47.71]就像鱼儿离了海 75 | [03:50.79]被迫选择 76 | [03:51.95]逃离的无奈 77 | [03:55.84]两个人的心 78 | [03:57.81]如果走不到一块 79 | [04:00.89]结果最后只会 80 | [04:02.71]再让彼此受伤害 81 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/lrc.lrc: -------------------------------------------------------------------------------- 1 | [00:23.56]什么是幸福 2 | [00:26.01]什么叫相爱 3 | [00:28.65]什么才是 4 | [00:29.82]永远和未来 5 | [00:33.55]看不到的情 6 | [00:36.03]啊摸不着的爱 7 | [00:38.76]看不见的幸福 8 | [00:41.23]甜蜜和期待 9 | [00:43.82]失去了关心 10 | [00:46.35]失去了关怀 11 | [00:48.82]失去了就 12 | [00:50.03]永远回不来 13 | [00:53.79]怎么去挽回 14 | [00:56.19]啊怎么去等待 15 | [00:58.80]怎么改变也不会 16 | [01:01.24]让一切再重来 17 | [01:04.01]没了心的爱 18 | [01:05.94]就像过了夜的菜 19 | [01:08.95]经不起那 20 | [01:10.26]风吹和雨晒 21 | [01:14.05]两个人的情 22 | [01:16.17]如果出现了意外 23 | [01:19.15]还有什么值得去爱 24 | [01:21.62]舍不得分开 25 | [01:24.24]没了心的爱 26 | [01:26.16]就像鱼儿离了海 27 | [01:29.29]被迫选择 28 | [01:30.45]逃离的无奈 29 | [01:34.33]两个人的心 30 | [01:36.45]如果走不到一块 31 | [01:39.34]结果最后只会 32 | [01:41.22]再让彼此受伤害 33 | [02:04.59]什么是幸福 34 | [02:07.16]什么叫相爱 35 | [02:09.68]什么才是 36 | [02:10.94]永远和未来 37 | [02:14.72]看不到的情 38 | [02:17.04]啊摸不着的爱 39 | [02:19.82]看不见的幸福 40 | [02:22.30]甜蜜和期待 41 | [02:24.83]失去了关心 42 | [02:27.35]失去了关怀 43 | [02:29.88]失去了就 44 | [02:31.14]永远回不来 45 | [02:34.99]怎么去挽回 46 | [02:37.20]啊怎么去等待 47 | [02:40.07]怎么改变也不会 48 | [02:42.14]让一切再重来 49 | [02:44.97]没了心的爱 50 | [02:46.74]就像过了夜的菜 51 | [02:50.11]经不起那 52 | [02:51.32]风吹和雨晒 53 | [02:55.22]两个人的情 54 | [02:57.14]如果出现了意外 55 | [03:00.17]还有什么值得去爱 56 | [03:02.75]舍不得分开 57 | [03:05.38]没了心的爱 58 | [03:07.14]就像鱼儿离了海 59 | [03:10.33]被迫选择 60 | [03:11.54]逃离的无奈 61 | [03:15.28]两个人的心 62 | [03:17.30]如果走不到一块 63 | [03:20.39]结果最后只会 64 | [03:22.26]再让彼此受伤害 65 | [03:25.54]没了心的爱 66 | [03:27.36]就像过了夜的菜 67 | [03:30.54]经不起那 68 | [03:31.75]风吹和雨晒 69 | [03:35.59]两个人的情 70 | [03:37.50]如果出现了意外 71 | [03:40.58]还有什么值得去爱 72 | [03:43.15]舍不得分开 73 | [03:45.78]没了心的爱 74 | [03:47.71]就像鱼儿离了海 75 | [03:50.79]被迫选择 76 | [03:51.95]逃离的无奈 77 | [03:55.84]两个人的心 78 | [03:57.81]如果走不到一块 79 | [04:00.89]结果最后只会 80 | [04:02.71]再让彼此受伤害 81 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/ScoringMachineProtocol+Infos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScoringMachineProtocol+Infos.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2024/6/4. 6 | // 7 | 8 | import Foundation 9 | 10 | class ScoringMachineInfo { 11 | /// 标准开始时间 (来源自歌词文件) 12 | let beginTime: UInt 13 | /// 标准时长 (来源自歌词文件) 14 | let duration: UInt 15 | /// 需要绘制的开始时间 16 | let drawBeginTime: UInt 17 | /// 需要绘制的时长 18 | var drawDuration: UInt 19 | let word: String 20 | let pitch: Double 21 | 22 | required init(beginTime: UInt, 23 | duration: UInt, 24 | word: String, 25 | pitch: Double, 26 | drawBeginTime: UInt, 27 | drawDuration: UInt) { 28 | self.beginTime = beginTime 29 | self.duration = duration 30 | self.word = word 31 | self.pitch = pitch 32 | self.drawBeginTime = drawBeginTime 33 | self.drawDuration = drawDuration 34 | } 35 | 36 | var endTime: UInt { 37 | beginTime + duration 38 | } 39 | 40 | var drawEndTime: UInt { 41 | drawBeginTime + drawDuration 42 | } 43 | 44 | var tone: LyricToneModel { 45 | return LyricToneModel(beginTime: beginTime, 46 | duration: duration, 47 | word: word, 48 | pitch: pitch, 49 | lang: .zh, 50 | pronounce: "") 51 | } 52 | } 53 | 54 | struct ScoringMachineDrawInfo { 55 | let rect: CGRect 56 | } 57 | 58 | struct ScoringMachineDebugInfo { 59 | /// 原始pitch 60 | let originalPitch: Double 61 | /// 男女音调算法改变后的pitch 62 | let pitch: Double 63 | let hitedInfo: ScoringMachineInfo? 64 | let progress: UInt 65 | } 66 | -------------------------------------------------------------------------------- /Demo/Demo/Other/Log/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2024/5/11. 6 | // 7 | 8 | import Foundation 9 | import AgoraComponetLog 10 | 11 | class Log { 12 | static let shared = Log() 13 | var componetLog: AgoraComponetLog! 14 | 15 | static func setupLogger() { 16 | let fileLogger = AgoraComponetFileLogger(logFilePath: nil, 17 | filePrefixName: "AgoraLyricsDemo", 18 | maxFileSizeOfBytes: 1 * 1024 * 1024, 19 | maxFileCount: 5, 20 | domainName: "ALD") 21 | let consoleLogger = AgoraComponetConsoleLogger(domainName: "ALD") 22 | shared.componetLog = AgoraComponetLog(queueTag: "AgoraLyricsDemo") 23 | shared.componetLog.configLoggers([fileLogger, consoleLogger]) 24 | } 25 | 26 | static func errorText(text: String, 27 | tag: String? = nil) { 28 | shared.componetLog.error(withText: text, tag: tag) 29 | } 30 | 31 | static func error(error: CustomStringConvertible, 32 | tag: String? = nil) { 33 | shared.componetLog.error(withText: error.description, tag: tag) 34 | } 35 | 36 | static func info(text: String, 37 | tag: String? = nil) { 38 | shared.componetLog.info(withText: text, tag: tag) 39 | } 40 | 41 | static func debug(text: String, 42 | tag: String? = nil) { 43 | shared.componetLog.debug(withText: text, tag: tag) 44 | } 45 | 46 | static func warning(text: String, 47 | tag: String? = nil) { 48 | shared.componetLog.warning(withText: text, tag: tag) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestLyricsView/TestLyricsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestLyricsView.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2023/2/17. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | class TestLyricsView: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testLyricsToneSpace() { /** 测试计算进度算法,tone之间有空隙 **/ 22 | let url = URL(fileURLWithPath: Bundle.current.path(forResource: "153378", ofType: "xml")!) 23 | let data = try! Data(contentsOf: url) 24 | guard let model = KaraokeView.parseLyricData(lyricFileData: data) else { 25 | XCTFail() 26 | return 27 | } 28 | 29 | let dataList = model.lines.map({ LyricCell.Model(text: $0.content, 30 | progressRate: 0, 31 | beginTime: $0.beginTime, 32 | duration: $0.duration, 33 | status: .normal, 34 | tones: $0.tones) }) 35 | let line = dataList[3] 36 | 37 | XCTAssertNotNil(LyricMachine.calculateProgressRate(progress: 43285, model: line, scrollByWord: true)) 38 | XCTAssertNil(LyricMachine.calculateProgressRate(progress: 43295, model: line, scrollByWord: true)) 39 | XCTAssertNotNil(LyricMachine.calculateProgressRate(progress: 43795, model: line, scrollByWord: true)) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Demo/Demo/Other/Utils/SongSourceProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongSourceProvider.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2024/6/6. 6 | // 7 | 8 | import Foundation 9 | import AgoraLyricsScore 10 | 11 | struct Song { 12 | let name: String 13 | let id: Int 14 | } 15 | 16 | class SongSourceProvider { 17 | var currentIndex = 0 18 | 19 | let songs: [Song] 20 | let sourceType: SourceType 21 | 22 | init(sourceType: SourceType) { 23 | self.sourceType = sourceType 24 | switch sourceType { 25 | case .useForMcc: 26 | self.songs = [ 27 | Song(name: "十年", id: 6625526605291650), 28 | Song(name: "爱情转移", id: 6246262727282860), 29 | Song(name: "说爱你", id: 6654550221757560), 30 | Song(name: "江南", id: 6246262727300580), 31 | Song(name: "容易受伤的女人", id: 6625526608670440) 32 | ] 33 | case .useForMccEx: 34 | self.songs = [Song(name: "offset test", id: 32056111), 35 | Song(name: "绿光", id: 32133593), 36 | Song(name: "在你的身边", id: 89488966), 37 | Song(name: "奢香夫人", id: 32259070), 38 | Song(name: "十年", id: 40289835), 39 | Song(name: "明月几时有", id: 239038150)] 40 | } 41 | } 42 | 43 | func getNextSong() -> Song { 44 | let index = genNextIndex() 45 | let song = songs[index] 46 | return song 47 | } 48 | 49 | func genNextIndex() -> Int { 50 | let index = currentIndex 51 | if currentIndex == songs.count - 1 { 52 | currentIndex = 0 53 | } 54 | else { 55 | currentIndex += 1 56 | } 57 | return index 58 | } 59 | } 60 | 61 | extension SongSourceProvider { 62 | enum SourceType { 63 | case useForMcc 64 | case useForMccEx 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Demo/Demo/VC/FirstToneHintViewTestVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpTestVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2022/12/22. 6 | // 7 | 8 | import UIKit 9 | import AgoraLyricsScore 10 | 11 | /// 测试等待视图 12 | class FirstToneHintViewTestVC: UIViewController { 13 | 14 | let karaokeView = KaraokeView() 15 | private var timer = GCDTimer() 16 | var progress: UInt = 0 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | setupUI() 21 | let url = URL(fileURLWithPath: Bundle.main.path(forResource: "745012", ofType: "xml")!) 22 | let data = try! Data(contentsOf: url) 23 | let model = KaraokeView.parseLyricData(lyricFileData: data)! 24 | model.preludeEndPosition = 6 * 1000 25 | karaokeView.setLyricData(data: model, usingInternalScoring: true) 26 | } 27 | 28 | func setupUI() { 29 | view.backgroundColor = .white 30 | view.addSubview(karaokeView) 31 | karaokeView.translatesAutoresizingMaskIntoConstraints = false 32 | 33 | karaokeView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 34 | karaokeView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true 35 | karaokeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 36 | karaokeView.heightAnchor.constraint(equalToConstant: 350).isActive = true 37 | } 38 | 39 | func commonInit() {} 40 | 41 | deinit { 42 | timer.destoryTimer(withName: "ExpTestVC") 43 | } 44 | 45 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 46 | timer.scheduledMillisecondsTimer(withName: "ExpTestVC", countDown: 1000000, milliseconds: 10, queue: .main) { [weak self](_, time) in 47 | guard let self = self else { return } 48 | self.progress += 10 49 | self.karaokeView.setProgress(progress: self.progress) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/Other/VoicePitchChanger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoicePitchChanger.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2022/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | class VoicePitchChanger { 11 | /// 累计的偏移值 12 | var offset: Double = 0.0 13 | /// 记录调用次数 14 | var n: Double = 0 15 | 16 | /// 处理Pitch 17 | /// - Parameters: 18 | /// - stdPitch: 标准值 来自歌词文件 19 | /// - voicePitch: 实际值 来自rtc回调 20 | /// - stdMaxPitch: 最大值 来自标准值 21 | /// - Returns: 处理后的值 22 | func handlePitch(stdPitch: Double, 23 | voicePitch: Double, 24 | stdMaxPitch: Double) -> Double { 25 | if useC { 26 | return handlePitchC(stdPitch, voicePitch, stdMaxPitch) 27 | } 28 | 29 | if voicePitch <= 0 { 30 | return 0 31 | } 32 | 33 | n += 1.0 34 | let gap = stdPitch - voicePitch 35 | 36 | offset = offset * (n - 1)/n + gap/n 37 | 38 | if offset < 0 { 39 | offset = max(offset, -1 * stdMaxPitch * 0.4) 40 | } 41 | else { 42 | offset = min(offset, stdMaxPitch * 0.4) 43 | } 44 | 45 | if abs(voicePitch - stdPitch) < 1 { /** 差距过小,直接返回 **/ 46 | return voicePitch 47 | } 48 | 49 | switch n { 50 | case 1: 51 | return min(voicePitch + 0.5 * offset, stdMaxPitch) 52 | case 2: 53 | return min(voicePitch + 0.6 * offset, stdMaxPitch) 54 | case 3: 55 | return min(voicePitch + 0.7 * offset, stdMaxPitch) 56 | case 4: 57 | return min(voicePitch + 0.8 * offset, stdMaxPitch) 58 | case 5: 59 | return min(voicePitch + 0.9 * offset, stdMaxPitch) 60 | default: 61 | return min(voicePitch + offset, stdMaxPitch) 62 | } 63 | } 64 | 65 | func reset() { 66 | if useC { 67 | resetC() 68 | return 69 | } 70 | offset = 0.0 71 | n = 0.0 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Demo/Demo/VC/SongListVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongListVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/3/30. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol SongListVCDelegate: NSObjectProtocol { 11 | func songListVCDidSelectedSong(song: SongListVC.Song) 12 | } 13 | 14 | class SongListVC: UIViewController, UITableViewDelegate, UITableViewDataSource { 15 | var songs = [Song]() 16 | let tableview = UITableView(frame: .zero, style: .grouped) 17 | weak var delegate: SongListVCDelegate? 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | setupUI() 22 | commonInit() 23 | } 24 | 25 | func setupUI() { 26 | view.backgroundColor = .white 27 | view.addSubview(tableview) 28 | tableview.frame = view.bounds 29 | } 30 | 31 | func commonInit() { 32 | tableview.register(UITableViewCell.self, forCellReuseIdentifier: "cell") 33 | tableview.delegate = self 34 | tableview.dataSource = self 35 | } 36 | 37 | // MARK: - UITableViewDelegate, UITableViewDataSource 38 | 39 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 40 | return songs.count 41 | } 42 | 43 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 44 | let cell = tableview.dequeueReusableCell(withIdentifier: "cell", for: indexPath) 45 | let song = songs[indexPath.row] 46 | cell.textLabel?.text = song.name + " [ " + song.singer + " ] " 47 | cell.accessoryType = .disclosureIndicator 48 | return cell 49 | } 50 | 51 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 52 | delegate?.songListVCDidSelectedSong(song: songs[indexPath.row]) 53 | dismiss(animated: true) 54 | } 55 | } 56 | 57 | extension SongListVC { 58 | struct Song { 59 | let name: String 60 | let singer: String 61 | let code: Int 62 | let highStartTime: Int 63 | let highEndTime: Int 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/DataStructs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataStructs.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/12/14. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Queue { 11 | private var elements: [T] = [] 12 | 13 | mutating func enqueue(_ element: T) { 14 | elements.append(element) 15 | } 16 | 17 | mutating func dequeue() -> T? { 18 | return elements.isEmpty ? nil : elements.removeFirst() 19 | } 20 | 21 | var isEmpty: Bool { 22 | return elements.isEmpty 23 | } 24 | 25 | var count: Int { 26 | return elements.count 27 | } 28 | 29 | func peek() -> T? { 30 | return elements.first 31 | } 32 | 33 | mutating func removeAll() { 34 | elements.removeAll() 35 | } 36 | 37 | func getAll() -> [T] { 38 | return elements 39 | } 40 | 41 | mutating func reset(newElements: [T]) { 42 | elements = newElements 43 | } 44 | } 45 | 46 | /// Dictionary for safe, use rwlock 47 | class SafeDictionary { 48 | private var dict: Dictionary = Dictionary() 49 | private var rwlock = pthread_rwlock_t() 50 | private let logTag = "SafeDictionary" 51 | 52 | init() { 53 | Log.debug(text: "init", tag: logTag) 54 | pthread_rwlock_init(&rwlock, nil) 55 | } 56 | 57 | deinit { 58 | Log.debug(text: "deinit", tag: logTag) 59 | pthread_rwlock_destroy(&rwlock) 60 | } 61 | 62 | func set(value: T_VALUE, forkey: T_KEY) { 63 | pthread_rwlock_wrlock(&rwlock) 64 | dict[forkey] = value 65 | pthread_rwlock_unlock(&rwlock) 66 | } 67 | 68 | func getValue(forkey: T_KEY) -> T_VALUE? { 69 | pthread_rwlock_rdlock(&rwlock) 70 | let value = dict[forkey] 71 | pthread_rwlock_unlock(&rwlock) 72 | return value 73 | } 74 | 75 | func removeValue(forkey: T_KEY) { 76 | pthread_rwlock_wrlock(&rwlock) 77 | dict.removeValue(forKey: forkey) 78 | pthread_rwlock_unlock(&rwlock) 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Events.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Events.swift 3 | // NewApi 4 | // 5 | // Created by ZYP on 2022/11/22. 6 | // 7 | 8 | import Foundation 9 | 10 | @objc public protocol KaraokeDelegate: NSObjectProtocol { 11 | /// 拖拽歌词结束后回调 12 | /// - Note: 当 `KaraokeConfig.lyricConfig.draggable == true` 且 用户进行拖动歌词时候 调用 13 | /// - Parameters: 14 | /// - view: KaraokeView 15 | /// - position: 当前时间点 (ms) 16 | @objc optional func onKaraokeView(view: KaraokeView, didDragTo position: UInt) 17 | 18 | /// 歌曲播放完一行(Line)时的歌词回调 19 | /// - Parameters: 20 | /// - model: 行信息 21 | /// - score: 当前行得分 [0, 100] 22 | /// - cumulativeScore: 累计分数 23 | /// - lineIndex: 行索引号 最小值:0 24 | /// - lineCount: 总行数 25 | @objc optional func onKaraokeView(view: KaraokeView, 26 | didFinishLineWith model: LyricLineModel, 27 | score: Int, 28 | cumulativeScore: Int, 29 | lineIndex: Int, 30 | lineCount: Int) 31 | } 32 | 33 | /// 分数计算协议 34 | @objc public protocol IScoreAlgorithm { 35 | // MARK: - 自定义分数 36 | 37 | /// 计算当前行(Line)的分数 38 | /// - Parameters: 39 | /// - models: 字得分信息集合 40 | /// - Returns: 计算后的分数 [0, 100] 41 | @objc func getLineScore(with toneScores: [ToneScoreModel]) -> Int 42 | } 43 | 44 | /// 日志协议 45 | @objc public protocol ILogger { 46 | /// 日志输出 47 | /// - Note: 在子线程执行 48 | /// - Parameters: 49 | /// - content: 内容 50 | /// - tag: 标签 51 | /// - time: 时间 52 | /// - level: 等级 53 | @objc func onLog(content: String, tag: String?, time: String, level: LoggerLevel) 54 | } 55 | 56 | @objc public enum LoggerLevel: UInt8, CustomStringConvertible { 57 | case debug, info, warning, error 58 | 59 | public var description: String { 60 | switch self { 61 | case .debug: 62 | return "D" 63 | case .info: 64 | return "I" 65 | case .warning: 66 | return "W" 67 | case .error: 68 | return "E" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/kj5396d18ebe5811ed9efdb2f16c44e48f.lrc: -------------------------------------------------------------------------------- 1 | [00:47.473]<00:47.473>So<00:47.786> many<00:48.796> times 2 | [00:50.294]<00:50.294>I'm<00:50.607> asking<00:51.617> why 3 | [00:53.011]<00:53.011>Why<00:53.359> not<00:53.742> they<00:54.787> stay<00:55.553> for<00:55.797> a<00:55.867> little<00:56.912> while 4 | [00:58.653]<00:58.653>Why<00:59.036> not<00:59.385> they<01:00.464> stop<01:01.231> and<01:01.509> look<01:02.275> inside 5 | [01:04.330]<01:04.330>But<01:04.714> it's<01:05.131> alright 6 | [01:10.356]<01:10.356>Things<01:11.122> will<01:11.401> change 7 | [01:13.142]<01:13.142>Not<01:13.595> always<01:14.222> great 8 | [01:15.650]<01:15.650>Why<01:15.998> don't<01:16.382> you<01:17.426> slow<01:17.914> down<01:18.158> and<01:18.437> take<01:19.238> a<01:19.516> break 9 | [01:21.258]<01:21.258>Tomorrow<01:23.139> is<01:23.835> a<01:24.114> different<01:25.194> day 10 | [01:27.423]<01:27.423>Cause<01:31.602> it's<01:34.075> alright 11 | [01:39.683]<01:39.683>I<01:42.852> will<01:53.824> be<01:54.137> there<01:56.297> for<01:59.466> you<02:04.830> x3 12 | [02:08.626]<02:08.626>Yes<02:10.786> I<02:11.134> always<02:13.886> will 13 | [02:40.322]<02:40.322>Turn<02:40.774> off<02:41.401> the<02:41.750> light 14 | [02:43.178]<02:43.178>Then<02:43.491> close<02:44.188> your<02:44.606> eyes 15 | [02:45.999]<02:45.999>We<02:46.347> are<02:46.696> here<02:47.775> playing<02:48.472> this<02:48.820> song<02:49.517> for<02:49.900> you 16 | [02:51.607]<02:51.607>Whatever<02:53.453> we<02:54.149> will<02:54.463> make<02:55.229> it<02:55.508> through 17 | [02:55.856]<02:55.856>Cause<02:57.319> it's<02:58.085> alright 18 | [03:03.344]<03:03.344>All<03:04.041> that<03:04.319> pains 19 | [03:06.096]<03:06.096>Come<03:06.862> and<03:07.210> go 20 | [03:08.638]<03:08.638>There<03:08.778> is<03:08.952> one<03:09.300> thing<03:10.380> only<03:11.216> you<03:12.087> should<03:12.470> know 21 | [03:14.211]<03:14.211>Whatever<03:16.022> you<03:16.684> can<03:17.032> let<03:17.834> it<03:18.112> go 22 | [03:19.888]<03:19.888>Yes<03:24.521> it's<03:26.994> alright 23 | [03:51.758]<03:51.758>I<03:57.644> will<03:58.027> be<04:03.321> there<04:56.959> for<05:00.199> you<05:05.493> x3 24 | [05:09.812]<05:09.812>Yes<05:11.484> I<05:11.832> always<05:28.028> will -------------------------------------------------------------------------------- /Demo/Demo/Other/Utils/ProgressProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressProvider.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2024/4/18. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ProgressProviderDelegate: NSObjectProtocol { 11 | /// 读取播放器的进度,用于校准。 12 | func progressProviderGetPlayerPosition(_ provider: ProgressProvider) -> UInt? 13 | /// 通知外部广播发送进度 14 | func progressProvider(_ provider: ProgressProvider, shouldSend postion: UInt) 15 | /// 进度更新 16 | func progressProvider(_ provider: ProgressProvider, didUpdate progressInMs: UInt) 17 | } 18 | 19 | /// 提供20ms级别的歌曲进度 20 | class ProgressProvider: NSObject { 21 | weak var delegate: ProgressProviderDelegate? 22 | private var timer = GCDTimer() 23 | private var isPause = false 24 | private var lastPrpgress: UInt = 0 25 | 26 | func start() { 27 | timer.scheduledMillisecondsTimer(withName: "ProgressProvider", 28 | countDown: 1000000, 29 | milliseconds: 20, 30 | queue: .main) { [weak self](_, time) in 31 | 32 | guard let self = self else { return } 33 | if self.isPause { 34 | return 35 | } 36 | 37 | var current = self.lastPrpgress 38 | if time.truncatingRemainder(dividingBy: 1000) == 0 { 39 | current = delegate!.progressProviderGetPlayerPosition(self) ?? current 40 | delegate?.progressProvider(self, shouldSend: current + 20) 41 | } 42 | current += 20 43 | 44 | self.lastPrpgress = current 45 | delegate?.progressProvider(self, didUpdate: current) 46 | } 47 | } 48 | 49 | func seek(position: UInt) { 50 | timer.destoryTimer(withName: "ProgressProvider") 51 | lastPrpgress = position 52 | start() 53 | } 54 | 55 | func pause() { 56 | isPause = true 57 | } 58 | 59 | func resume() { 60 | isPause = false 61 | } 62 | 63 | func stop() { 64 | isPause = false 65 | lastPrpgress = 0 66 | timer.destoryTimer(withName: "ProgressProvider") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/ScoringMachineProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScoringMachineProtocol.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2024/6/4. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ScoringMachineProtocol { 11 | typealias Info = ScoringMachineInfo 12 | typealias DrawInfo = ScoringMachineDrawInfo 13 | typealias DebugInfo = ScoringMachineDebugInfo 14 | 15 | var delegate: ScoringMachineDelegate? { get set } 16 | /// 游标的起始位置 17 | var defaultPitchCursorX: CGFloat { get set } 18 | /// 音准线的高度 19 | var standardPitchStickViewHeight: CGFloat { get set } 20 | /// 音准线的基准因子 21 | var movingSpeedFactor: CGFloat { get set } 22 | /// 打分容忍度 范围:0-1 23 | var hitScoreThreshold: Float { get set } 24 | var scoreLevel: Int { get set } 25 | var scoreCompensationOffset: Int { get set } 26 | 27 | func setLyricData(data: LyricModel?) 28 | func setProgress(progress: UInt) 29 | func setPitch(speakerPitch: Double, 30 | progressInMs: UInt) 31 | func dragBegain() 32 | func dragDidEnd(position: UInt) 33 | func getCumulativeScore() -> Int 34 | func reset() 35 | 36 | var scoreAlgorithm: IScoreAlgorithm { get set } 37 | 38 | } 39 | 40 | protocol ScoringMachineDelegate: NSObjectProtocol { 41 | /// 获取渲染视图的尺寸 42 | func sizeOfCanvasView(_ scoringMachine: ScoringMachineProtocol) -> CGSize 43 | 44 | /// 更新渲染信息 45 | func scoringMachine(_ scoringMachine: ScoringMachineProtocol, 46 | didUpdateDraw standardInfos: [ScoringMachineDrawInfo], 47 | highlightInfos: [ScoringMachineDrawInfo]) 48 | /// 更新游标位置 49 | func scoringMachine(_ scoringMachine: ScoringMachineProtocol, 50 | didUpdateCursor centerY: CGFloat, 51 | showAnimation: Bool, 52 | debugInfo: ScoringMachineDebugInfo) 53 | 54 | /// 更新句子分数 55 | func scoringMachine(_ scoringMachine: ScoringMachineProtocol, 56 | didFinishLineWith model: LyricLineModel, 57 | score: Int, 58 | cumulativeScore: Int, 59 | lineIndex: Int, 60 | lineCount: Int) 61 | } 62 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Downloader/Extentions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AgoraStringExtention.swift 3 | // AgoraKaraokeScore 4 | // 5 | // Created by zhaoyongqiang on 2021/12/10. 6 | // 7 | 8 | import UIKit 9 | 10 | extension String { 11 | // 获取时间格式 12 | func timeIntervalToMMSSFormat(interval: TimeInterval) -> String { 13 | if interval >= 3600 { 14 | let hour = interval / 3600 15 | let min = interval.truncatingRemainder(dividingBy: 3600) / 60 16 | let sec = interval.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) 17 | return String(format: "%02d:%02d:%02d", Int(hour), Int(min), Int(sec)) 18 | } else { 19 | let min = interval / 60 20 | let sec = interval.truncatingRemainder(dividingBy: 60) 21 | return String(format: "%02d:%02d", Int(min), Int(sec)) 22 | } 23 | } 24 | 25 | /** 26 | * 缓存文件夹路径 27 | */ 28 | static func cacheFolderPath() -> String { 29 | return NSHomeDirectory().appending("/Library").appending("/MusicCaches").appending("/lyricFiles") 30 | } 31 | 32 | /// 下载目录 33 | static func downloadedFloderPath() -> String { 34 | return NSHomeDirectory().appending("/tmp").appending("/LyricDownloadFiles") 35 | } 36 | 37 | /** 38 | * 获取网址中的文件名 39 | */ 40 | var fileName: String { 41 | components(separatedBy: "/").last ?? "" 42 | } 43 | } 44 | 45 | extension FileManager { 46 | static func createDirectoryIfNeeded(atPath path: String) { 47 | let fileManager = FileManager.default 48 | var isDirectory: ObjCBool = false 49 | if fileManager.fileExists(atPath: path, isDirectory: &isDirectory) { 50 | if !isDirectory.boolValue { 51 | Log.debug(text: "给定的路径是一个文件: \(path)", tag: "Downloader Extension") 52 | return 53 | } 54 | Log.debug(text: "已存在:\(path)") 55 | } else { 56 | do { 57 | try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) 58 | Log.debug(text: "已成功创建目录: \(path)", tag: "Downloader Extension") 59 | } catch { 60 | Log.errorText(text: "创建目录失败: \(error.localizedDescription)", tag: "Downloader Extension") 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Demo/Demo/VC/ProfileVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/2/27. 6 | // 7 | 8 | import Foundation 9 | import AgoraLyricsScore 10 | 11 | class ProfileVC: UIViewController { 12 | let karaokeView = KaraokeView() 13 | var progress: UInt = 0 14 | private var timer = GCDTimer() 15 | var model: LyricModel! 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | view.backgroundColor = .white 20 | karaokeView.backgroundImage = UIImage(named: "ktv_top_bgIcon") 21 | karaokeView.scoringView.viewHeight = 100 22 | karaokeView.scoringView.topSpaces = 80 23 | karaokeView.lyricsView.showDebugView = false 24 | view.addSubview(karaokeView) 25 | 26 | karaokeView.translatesAutoresizingMaskIntoConstraints = false 27 | 28 | karaokeView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 29 | karaokeView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true 30 | karaokeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 31 | karaokeView.heightAnchor.constraint(equalToConstant: 350).isActive = true 32 | } 33 | 34 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 35 | let url = URL(fileURLWithPath: Bundle.main.path(forResource: "745012", ofType: "xml")!) 36 | let data = try! Data(contentsOf: url) 37 | model = KaraokeView.parseLyricData(lyricFileData: data)! 38 | karaokeView.setLyricData(data: model, usingInternalScoring: true) 39 | var count = 0 40 | timer.scheduledMillisecondsTimer(withName: "ProfileVC", countDown: 1000000, milliseconds: 20, queue: .main) { [weak self](_, time) in 41 | guard let self = self else { return } 42 | self.progress += 20 43 | 44 | if self.progress > self.model.duration { 45 | self.progress = 0 46 | self.timer.destoryAllTimer() 47 | } 48 | 49 | self.karaokeView.setProgress(progress: self.progress) 50 | 51 | count += 1 52 | if count == 3 { 53 | count = 0 54 | self.karaokeView.setPitch(speakerPitch: Double.random(in: 87...300), progressInMs: 0) 55 | } 56 | 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Demo/Demo/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2022/12/21. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 17 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 18 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 19 | guard let _ = (scene as? UIWindowScene) else { return } 20 | } 21 | 22 | func sceneDidDisconnect(_ scene: UIScene) { 23 | // Called as the scene is being released by the system. 24 | // This occurs shortly after the scene enters the background, or when its session is discarded. 25 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 26 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 27 | } 28 | 29 | func sceneDidBecomeActive(_ scene: UIScene) { 30 | // Called when the scene has moved from an inactive state to an active state. 31 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 32 | } 33 | 34 | func sceneWillResignActive(_ scene: UIScene) { 35 | // Called when the scene will move from an active state to an inactive state. 36 | // This may occur due to temporary interruptions (ex. an incoming phone call). 37 | } 38 | 39 | func sceneWillEnterForeground(_ scene: UIScene) { 40 | // Called as the scene transitions from the background to the foreground. 41 | // Use this method to undo the changes made on entering the background. 42 | } 43 | 44 | func sceneDidEnterBackground(_ scene: UIScene) { 45 | // Called as the scene transitions from the foreground to the background. 46 | // Use this method to save data, release shared resources, and store enough scene-specific state information 47 | // to restore the scene back to its current state. 48 | } 49 | 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/2/3. 6 | // 7 | 8 | import Foundation 9 | import AgoraComponetLog 10 | 11 | // MARK:- ConsoleLogger 12 | 13 | public class ConsoleLogger: NSObject, ILogger { 14 | @objc public func onLog(content: String, 15 | tag: String?, 16 | time: String, 17 | level: LoggerLevel) { 18 | 19 | let text = tag == nil ? "[\(time)][ALS][\(level)]: " + content : "[\(time)][ALS][\(level)][\(tag!)]: " + content 20 | print(text) 21 | } 22 | } 23 | 24 | // MARK:- FileLogger 25 | 26 | public class FileLogger: NSObject, ILogger { 27 | let componetFileLogger: AgoraComponetFileLogger! 28 | let filePrefixName = "agora.AgoraLyricsScore" 29 | let maxFileSizeOfBytes: UInt64 = 1024 * 1024 * 1 30 | let maxFileCount: UInt = 4 31 | let domainName = "ALS" 32 | 33 | @objc public override init() { 34 | self.componetFileLogger = AgoraComponetFileLogger(logFilePath: nil, 35 | filePrefixName: filePrefixName, 36 | maxFileSizeOfBytes: maxFileSizeOfBytes, 37 | maxFileCount: maxFileCount, 38 | domainName: domainName) 39 | super.init() 40 | } 41 | 42 | /// init 43 | /// - Parameter logFilePath: custom log file path. 44 | @objc public init(logFilePath: String) { 45 | componetFileLogger = AgoraComponetFileLogger(logFilePath: logFilePath, 46 | filePrefixName: filePrefixName, 47 | maxFileSizeOfBytes: maxFileSizeOfBytes, 48 | maxFileCount: maxFileCount, 49 | 50 | domainName: domainName) 51 | } 52 | 53 | @objc public func onLog(content: String, 54 | tag: String?, 55 | time: String, 56 | level: LoggerLevel) { 57 | let newLevel = AgoraComponetLoggerLevel(rawValue: UInt(level.rawValue))! 58 | componetFileLogger.onLog(withContent: content, tag: tag, time: time, level: newLevel) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Downloader/DownloaderManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloaderManager.swift 3 | // URLSessionDownloadDemo 4 | // 5 | // Created by FancyLou on 2019/2/22. 6 | // Copyright © 2019 O2OA. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | class DownloaderManager: NSObject { 13 | // 下载缓存池 14 | private var downloadCache = SafeDictionary() 15 | private var failback: DownloadFailClosure? 16 | private let logTag = "DownloaderManager" 17 | 18 | deinit { 19 | Log.info(text: "deinit", tag: logTag) 20 | let downloadFloderURL = NSURL(fileURLWithPath: String.downloadedFloderPath()) 21 | FileManager.createDirectoryIfNeeded(atPath: downloadFloderURL.path!) 22 | } 23 | 24 | override init() { 25 | Log.info(text: "init", tag: logTag) 26 | } 27 | 28 | func download(url: URL, 29 | progress: @escaping DownloadProgressClosure, 30 | completion: @escaping DownloadCompletionClosure, 31 | fail: @escaping DownloadFailClosure) { 32 | self.failback = fail 33 | // 判断缓存池中是否已经存在 34 | var downloader = downloadCache.getValue(forkey: url.absoluteString) 35 | if downloader != nil { 36 | Log.errorText(text: "当前下载已存在不需要重复下载!", tag: logTag) 37 | let e = DownloadError(domainType: .repeatDownloading, 38 | code: DownloadErrorDomainType.repeatDownloading.rawValue, 39 | msg: "当前下载已存在不需要重复下载") 40 | self.failback?(e) 41 | return 42 | } 43 | downloader = Downloader() 44 | downloadCache.set(value: downloader!, forkey: url.absoluteString) 45 | downloader?.download(url: url, progress: progress, completion: { [weak self](filePath) in 46 | guard let self = self else { 47 | return 48 | } 49 | downloadCache.removeValue(forkey: url.absoluteString) 50 | completion(filePath) 51 | }, fail: fail) 52 | } 53 | 54 | func cancelTask(url: URL) { 55 | let downloader = downloadCache.getValue(forkey: url.absoluteString) 56 | if downloader == nil { 57 | Log.debug(text: "任务已经移除,不需要重复移除!", tag: logTag) 58 | return 59 | } 60 | // 从缓存池中删除 61 | downloadCache.removeValue(forkey: url.absoluteString) 62 | //结束任务 63 | downloader?.resetEventCloure() 64 | downloader?.cancel() 65 | } 66 | 67 | 68 | } 69 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Lyrics/LyricLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LyricsLabel.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2022/12/23. 6 | // 7 | 8 | import UIKit 9 | 10 | class LyricLabel: UILabel { 11 | /// [0, 1] 12 | var progressRate: CGFloat = 0 { didSet { setNeedsDisplay() } } 13 | /// 正常歌词颜色 14 | var textNormalColor: UIColor = .gray 15 | /// 选中的歌词颜色 16 | var textSelectedColor: UIColor = .white 17 | /// 高亮的歌词颜色 18 | var textHighlightedColor: UIColor = .orange 19 | /// 正常歌词文字大小 20 | var textNormalFontSize: UIFont = .systemFont(ofSize: 15) 21 | /// 高亮歌词文字大小 22 | var textHighlightFontSize: UIFont = .systemFont(ofSize: 18) 23 | 24 | var status: Status = .normal { didSet { updateState() } } 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | textAlignment = .center 29 | } 30 | 31 | required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | private func updateState() { 36 | if status == .selectedOrHighlighted { 37 | textColor = textSelectedColor 38 | font = textHighlightFontSize 39 | } 40 | else { 41 | textColor = textNormalColor 42 | font = textNormalFontSize 43 | } 44 | } 45 | 46 | override func draw(_ rect: CGRect) { 47 | super.draw(rect) 48 | if progressRate <= 0 { 49 | return 50 | } 51 | let textWidth = sizeThatFits(CGSize(width: CGFloat(MAXFLOAT), 52 | height: font.lineHeight)).width 53 | var leftRightSpace = (bounds.width - textWidth) / 2 54 | if textAlignment == .left { leftRightSpace = 0.0 } 55 | if textAlignment == .right { leftRightSpace = bounds.width - textWidth } 56 | let path = CGMutablePath() 57 | let fillRect = CGRect(x: leftRightSpace, 58 | y: 0, 59 | width: progressRate * textWidth, 60 | height: font.lineHeight) 61 | path.addRect(fillRect) 62 | 63 | if let context = UIGraphicsGetCurrentContext(), !path.isEmpty { 64 | context.setLineWidth(1.0) 65 | context.setLineCap(.butt) 66 | context.addPath(path) 67 | context.clip() 68 | let _textColor = textColor 69 | textColor = textHighlightedColor 70 | super.draw(rect) 71 | textColor = _textColor 72 | } 73 | } 74 | } 75 | 76 | extension LyricLabel { 77 | enum Status { 78 | case normal 79 | case selectedOrHighlighted 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Demo/Demo/VC/AVPlayerTestVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AVPlayerTestVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2022/12/23. 6 | // 7 | 8 | import UIKit 9 | import AgoraRtcKit 10 | import RTMTokenBuilder 11 | import AgoraLyricsScore 12 | import AVFoundation 13 | 14 | class AVPlayerTestVC: UIViewController { 15 | let karaokeView = KaraokeView() 16 | private var audioPlayer: AVAudioPlayer? 17 | let lrcUrl = Bundle.main.path(forResource: "105780", ofType: "xml")! 18 | let songUrl = Bundle.main.path(forResource: "music", ofType: "mp3")! 19 | private var timer = GCDTimer() 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | setupUI() 24 | commonInit() 25 | } 26 | 27 | func setupUI() { 28 | view.backgroundColor = .black 29 | view.addSubview(karaokeView) 30 | karaokeView.translatesAutoresizingMaskIntoConstraints = false 31 | 32 | karaokeView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 33 | karaokeView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true 34 | karaokeView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true 35 | karaokeView.heightAnchor.constraint(equalToConstant: 350).isActive = true 36 | } 37 | 38 | func commonInit() { 39 | let data = try! Data(contentsOf: URL(fileURLWithPath: lrcUrl)) 40 | let model = KaraokeView.parseLyricData(lyricFileData: data)! 41 | karaokeView.setLyricData(data: model, usingInternalScoring: true) 42 | initAudioPlayer(url:URL(fileURLWithPath: songUrl)) 43 | let ret = audioPlayer!.play() 44 | print("play ret \(ret)") 45 | 46 | 47 | 48 | timer.scheduledMillisecondsTimer(withName: "AVPlayerTestVC", 49 | countDown: 1000000, 50 | milliseconds: 50, 51 | queue: .main) { [weak self](_, time) in 52 | guard let self = self else { return } 53 | if let currentTime = self.audioPlayer?.currentTime { 54 | self.karaokeView.setProgress(progress: UInt(currentTime) * 1000) 55 | } 56 | } 57 | } 58 | 59 | private func initAudioPlayer(url: URL) { 60 | do { 61 | audioPlayer = try AVAudioPlayer(contentsOf: url) 62 | } catch let error { 63 | print("\(error)") 64 | print("") 65 | } 66 | audioPlayer?.rate = 1.0 67 | audioPlayer?.prepareToPlay() 68 | } 69 | 70 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 71 | 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/PitchParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PitchParser.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2024/5/9. 6 | // 7 | 8 | import Foundation 9 | 10 | class PitchParser { 11 | fileprivate let logTag = "PitchParser" 12 | 13 | func parse(fileContent data: Data) -> PitchModel? { 14 | /// 把data转成PitchModel 15 | guard !data.isEmpty else { 16 | return nil 17 | } 18 | 19 | do { 20 | let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) 21 | 22 | guard let jsonDict = jsonObject as? [String: Any], 23 | let pitchDatasArray = jsonDict["pitchDatas"] as? [[String: Any]] else { 24 | Log.errorText(text: "Invalid JSON format: missing pitchDatas array", tag: logTag) 25 | return nil 26 | } 27 | 28 | var pitchDatas: [KrcPitchData] = [] 29 | 30 | for pitchDataDict in pitchDatasArray { 31 | // 处理pitch字段 - 支持Int和Double 32 | guard let pitch = (pitchDataDict["pitch"] as? Double) ?? 33 | (pitchDataDict["pitch"] as? Int).map(Double.init) else { 34 | Log.errorText(text: "Invalid pitch data format in array", tag: logTag) 35 | continue 36 | } 37 | 38 | // 处理startTime字段 - 支持Int和UInt 39 | guard let startTime = (pitchDataDict["startTime"] as? UInt) ?? 40 | (pitchDataDict["startTime"] as? Int).map(UInt.init) else { 41 | Log.errorText(text: "Invalid pitch data format in array", tag: logTag) 42 | continue 43 | } 44 | 45 | // 处理duration字段 - 支持Int和UInt 46 | guard let duration = (pitchDataDict["duration"] as? UInt) ?? 47 | (pitchDataDict["duration"] as? Int).map(UInt.init) else { 48 | Log.errorText(text: "Invalid pitch data format in array", tag: logTag) 49 | continue 50 | } 51 | 52 | let krcPitchData = KrcPitchData(pitch: pitch, startTime: startTime, duration: duration) 53 | pitchDatas.append(krcPitchData) 54 | } 55 | 56 | return PitchModel(pitchDatas: pitchDatas) 57 | 58 | } catch let error { 59 | Log.error(error: error.localizedDescription, tag: logTag) 60 | return nil 61 | } 62 | } 63 | } 64 | 65 | 66 | extension PitchParser { 67 | struct PitchModel { 68 | let pitchDatas: [KrcPitchData] 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestScoringMachine/LogFileReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogFileReader.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2023/11/8. 6 | // 7 | 8 | import Foundation 9 | 10 | class LogFileReader { 11 | struct Item { 12 | let progress: Int? 13 | let pitch: Double? 14 | } 15 | 16 | static func readFromFile(fileName: String) -> (result1: [Item], result2: [Item]) { 17 | let name = "TestLogFile/\(fileName).txt" 18 | let fileUrl = URL(fileURLWithPath: Bundle.current.path(forResource: name, ofType:nil)!) 19 | let fileData = try! Data(contentsOf: fileUrl) 20 | let string = String(data: fileData, encoding: .utf8)! 21 | let array = string.split(separator: "\n").map({ String($0) }) 22 | 23 | 24 | var result1 = [Item]() 25 | var result2 = [Item]() 26 | var songIndex: Int = -1 27 | for str in array { 28 | if str.contains("[ALS][I][KaraokeView]: setLyricData") { 29 | if songIndex == -1 { 30 | songIndex = 0 31 | continue 32 | } 33 | if songIndex == 0 { 34 | songIndex = 1 35 | continue 36 | } 37 | } 38 | if songIndex == -1 { continue } 39 | 40 | if str.contains("[ALS][D][ScoringMachine]: progress: ") { 41 | let strVal = String(str.split(separator: " ").last!) 42 | let progress = Int(strVal)! 43 | let item = Item(progress: progress, pitch: nil) 44 | if songIndex == 0 { 45 | result1.append(item) 46 | } 47 | else { 48 | result2.append(item) 49 | } 50 | continue 51 | } 52 | 53 | if str.contains("[ALS][D][ScoringMachine]: pitch:") { 54 | let parts = str.components(separatedBy: "pitch: ") 55 | if let lastPart = parts.last { 56 | let parts2 = lastPart.components(separatedBy: " after: ") 57 | if let firstPart = parts2.first { 58 | if let pitch = Double(firstPart) { 59 | let item = Item(progress: nil, pitch: pitch) 60 | if songIndex == 0 { 61 | result1.append(item) 62 | } 63 | else { 64 | result2.append(item) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | return (result1, result2) 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /Demo/Demo/VC/OCVC.m: -------------------------------------------------------------------------------- 1 | // 2 | // OCVC.m 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/2/2. 6 | // 7 | 8 | #import "OCVC.h" 9 | @import AgoraLyricsScore; 10 | @import ScoreEffectUI; 11 | 12 | @interface OCVC () 13 | 14 | @end 15 | 16 | @implementation OCVC 17 | 18 | - (void)viewDidLoad { 19 | [super viewDidLoad]; 20 | self.view.backgroundColor = [UIColor blackColor]; 21 | FileLogger *fileLogger = [[FileLogger alloc] init]; 22 | KaraokeView *karaokeView = [[KaraokeView alloc] initWithFrame:CGRectZero 23 | loggers:@[[ConsoleLogger new],fileLogger]]; 24 | karaokeView.backgroundImage = [UIImage imageNamed:@"ktv_top_bgIcon"]; 25 | karaokeView.spacing = 5; 26 | karaokeView.scoringEnabled = YES; 27 | karaokeView.frame = CGRectMake(0, 100, self.view.bounds.size.width, 380); 28 | [self.view addSubview:karaokeView]; 29 | karaokeView.delegate = self; 30 | 31 | ScoringView *sView = karaokeView.scoringView; 32 | LyricsView *lView= karaokeView.lyricsView; 33 | sView.viewHeight = 160; 34 | sView.topSpaces = 70; 35 | 36 | GradeView *gview = [[GradeView alloc] initWithFrame:CGRectMake(15, 110, UIScreen.mainScreen.bounds.size.width - 30, 50)]; 37 | [gview setTitleWithTitle:@"123" customCumulativeScoreText:nil]; 38 | 39 | [self.view addSubview:gview]; 40 | [self.view layoutIfNeeded]; 41 | [gview setScoreWithCumulativeScore:1110 totalScore:4000 customCumulativeScoreText:nil]; 42 | 43 | karaokeView.lyricsView.firstToneHintViewStyle.size = 15; 44 | karaokeView.lyricsView.firstToneHintViewStyle.bottomMargin = 25; 45 | karaokeView.lyricsView.firstToneHintViewStyle.backgroundColor = [UIColor redColor]; 46 | 47 | IncentiveView *incentiveView = [IncentiveView new]; 48 | incentiveView.frame = karaokeView.scoringView.bounds; 49 | [self.view addSubview:incentiveView]; 50 | 51 | [incentiveView showWithScore:80]; 52 | 53 | lView.draggable = YES; 54 | lView.noLyricTipsFont = [UIFont systemFontOfSize:23]; 55 | lView.noLyricTipsText = @"没有歌词呢"; 56 | lView.noLyricTipsColor = [UIColor redColor]; 57 | // [karaokeView setLyricDataWithData:nil]; 58 | } 59 | 60 | 61 | - (void)onKaraokeViewWithView:(KaraokeView *)view didDragTo:(NSInteger)position { 62 | 63 | } 64 | 65 | - (void)onKaraokeViewWithView:(KaraokeView *)view 66 | didFinishLineWith:(LyricLineModel *)model 67 | score:(NSInteger)score 68 | cumulativeScore:(NSInteger)cumulativeScore 69 | lineIndex:(NSInteger)lineIndex lineCount:(NSInteger)lineCount {} 70 | 71 | - (void)onLogWithContent:(NSString *)content tag:(NSString *)tag time:(NSString *)time level:(enum LoggerLevel)level { 72 | 73 | } 74 | 75 | @end 76 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Al/Algorithm.c: -------------------------------------------------------------------------------- 1 | // 2 | // Algorithm.c 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/11/9. 6 | // 7 | 8 | #include "Algorithm.h" 9 | #import 10 | //modify by xuguangjian 11 | #define PTS_version "20231021001" 12 | 13 | #define min(a, b) ((a) < (b) ? (a) : (b)) 14 | #define max(a, b) ((a) > (b) ? (a) : (b)) 15 | 16 | 17 | double pitchToToneC(double pitch) { 18 | double eps = 1e-6; 19 | return (fmax(0, log(pitch / 55 + eps) / log(2))) * 12; 20 | } 21 | 22 | float calculedScoreC(double voicePitch, double stdPitch, int scoreLevel, int scoreCompensationOffset) { 23 | if (voicePitch <= 0) { 24 | return 0; 25 | } 26 | if(stdPitch <= 0){ 27 | return 0; 28 | } 29 | 30 | if(scoreLevel<=0){ 31 | scoreLevel = 1; 32 | }else if(scoreLevel > 100){ 33 | scoreLevel = 100; 34 | } 35 | 36 | if(scoreCompensationOffset<0){ 37 | scoreCompensationOffset = 0; 38 | }else if(scoreCompensationOffset > 100){ 39 | scoreCompensationOffset = 100; 40 | } 41 | 42 | double stdTone = pitchToToneC(stdPitch); 43 | double voiceTone = pitchToToneC(voicePitch); 44 | 45 | float match = 1 - (float)scoreLevel / 100 * fabs(voiceTone - stdTone) + (float)scoreCompensationOffset / 100; 46 | float rate = 1 + ((float)scoreLevel/(float)50); 47 | 48 | match = match * 100 * rate; 49 | 50 | match = max(0, match); 51 | match = min(100, match); 52 | return match; 53 | } 54 | 55 | static double n; 56 | static double offset; 57 | 58 | 59 | // octave pitch compensation v0.2 60 | double handlePitchC(double stdPitch, double voicePitch, double stdMaxPitch) { 61 | 62 | int cnt = 0; 63 | double stdTone = pitchToToneC(stdPitch); 64 | double voiceTone = pitchToToneC(voicePitch); 65 | 66 | if (voicePitch <= 0) { 67 | return 0; 68 | } 69 | if(stdPitch <= 0){ 70 | return 0; 71 | } 72 | 73 | if(fabs(voiceTone - stdTone) <= 6){ 74 | return voicePitch; 75 | } 76 | else if(voicePitch < stdPitch){ 77 | for(cnt = 0; cnt <11; cnt++){ 78 | voicePitch = 2*voicePitch; 79 | voiceTone = pitchToToneC(voicePitch); 80 | if(fabs(voiceTone - stdTone) <= 6){ 81 | return voicePitch; 82 | } 83 | } 84 | } 85 | else if(voicePitch > stdPitch){ 86 | for(cnt = 0; cnt <11; cnt++){ 87 | voicePitch = voicePitch/2; 88 | voiceTone = pitchToToneC(voicePitch); 89 | if(fabs(voiceTone - stdTone) <= 6){ 90 | return voicePitch; 91 | } 92 | } 93 | } 94 | return voicePitch; 95 | } 96 | 97 | void resetC(void) { 98 | offset = 0.0; 99 | n = 0.0; 100 | } 101 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2022/12/21. 6 | // 7 | 8 | import Foundation 9 | 10 | class Parser { 11 | private let logTag = "Parser" 12 | func parseLyricData(data: Data, 13 | pitchFileData: Data? = nil, 14 | lyricOffset: Int, 15 | includeCopyrightSentence: Bool = false) -> LyricModel? { 16 | guard data.count > 0 else { 17 | Log.errorText(text: "data.count == 0", tag: logTag) 18 | return nil 19 | } 20 | 21 | let format = detectLyricFormat(from: data) 22 | 23 | switch format { 24 | case .krc: 25 | let parser = KRCParser() 26 | return parser.parse(krcFileData: data, 27 | pitchFileData: pitchFileData, 28 | lyricOffset: lyricOffset, 29 | includeCopyrightSentence: includeCopyrightSentence) 30 | case .xml: 31 | let parser = XmlParser() 32 | return parser.parseLyricData(data: data) 33 | case .lrc: 34 | let parser = LrcParser() 35 | return parser.parseLyricData(data: data) 36 | case .unknown: 37 | Log.errorText(text: "unknow file type", tag: logTag) 38 | return nil 39 | } 40 | } 41 | 42 | enum LyricFormat { 43 | case lrc 44 | case krc 45 | case xml 46 | case unknown 47 | } 48 | 49 | func detectLyricFormat(from data: Data) -> LyricFormat { 50 | // 将Data类型转换为String 51 | guard let string = String(data: data, encoding: .utf8) else { 52 | return .unknown 53 | } 54 | 55 | // 检测LRC格式的特征,通常是时间戳和歌词行 56 | // 正则表达式匹配LRC格式的时间戳 57 | let lrcPattern = "\\[\\d{2}:\\d{2}\\.\\d{2,3}\\]" 58 | if let _ = string.range(of: lrcPattern, options: .regularExpression) { 59 | return .lrc 60 | } 61 | 62 | /// enhance lrc:[00:50.677]<00:50.677>走<00:50.956>走 63 | let enhanceLrcPattern = "\\[\\d{2}:\\d{2}\\.\\d{2,3}\\]\\<\\d{2}:\\d{2}\\.\\d{2,3}\\>" 64 | if let _ = string.range(of: enhanceLrcPattern, options: .regularExpression) { 65 | return .lrc 66 | } 67 | 68 | // 检测KRC格式的特征 69 | let krcPattern = "\\[(\\w+):([^]]*)]" 70 | if let _ = string.range(of: krcPattern, options: .regularExpression) { 71 | return .krc 72 | } 73 | 74 | // 检测XML格式的特征,即XML的开始标签 75 | if string.contains(""), string.contains("") { 81 | return .xml 82 | } 83 | 84 | // 如果没有匹配的特征,返回unknown 85 | return .unknown 86 | } 87 | 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Demo/Demo/View/KTVView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KTVView.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/3/21. 6 | // 7 | 8 | import UIKit 9 | import AgoraLyricsScore 10 | import ScoreEffectUI 11 | 12 | /// 包装 KaraokeView & LineScoreView & GradeView & incentiveView 13 | class KTVView: UIView { 14 | let karaokeView = KaraokeView(frame: .zero, loggers: [ConsoleLogger()]) 15 | let lineScoreView = LineScoreView() 16 | let gradeView = GradeView() 17 | let incentiveView = IncentiveView() 18 | 19 | override init(frame: CGRect) { 20 | super.init(frame: frame) 21 | setupUI() 22 | } 23 | 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | func setupUI() { 29 | karaokeView.backgroundImage = UIImage(named: "ktv_top_bgIcon") 30 | karaokeView.scoringView.viewHeight = 100 31 | karaokeView.scoringView.topSpaces = 80 32 | karaokeView.lyricsView.showDebugView = false 33 | 34 | backgroundColor = .black 35 | addSubview(karaokeView) 36 | addSubview(gradeView) 37 | addSubview(incentiveView) 38 | addSubview(lineScoreView) 39 | 40 | karaokeView.translatesAutoresizingMaskIntoConstraints = false 41 | gradeView.translatesAutoresizingMaskIntoConstraints = false 42 | incentiveView.translatesAutoresizingMaskIntoConstraints = false 43 | lineScoreView.translatesAutoresizingMaskIntoConstraints = false 44 | 45 | karaokeView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true 46 | karaokeView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true 47 | karaokeView.topAnchor.constraint(equalTo: topAnchor).isActive = true 48 | karaokeView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 49 | 50 | gradeView.topAnchor.constraint(equalTo: karaokeView.topAnchor, constant: 15).isActive = true 51 | gradeView.leftAnchor.constraint(equalTo: karaokeView.leftAnchor, constant: 15).isActive = true 52 | gradeView.rightAnchor.constraint(equalTo: karaokeView.rightAnchor, constant: -15).isActive = true 53 | gradeView.heightAnchor.constraint(equalToConstant: 40).isActive = true 54 | 55 | incentiveView.centerYAnchor.constraint(equalTo: karaokeView.scoringView.centerYAnchor).isActive = true 56 | incentiveView.centerXAnchor.constraint(equalTo: karaokeView.centerXAnchor, constant: -10).isActive = true 57 | 58 | lineScoreView.leftAnchor.constraint(equalTo: leftAnchor, constant: karaokeView.scoringView.defaultPitchCursorX).isActive = true 59 | lineScoreView.topAnchor.constraint(equalTo: karaokeView.topAnchor, constant: karaokeView.scoringView.topSpaces).isActive = true 60 | lineScoreView.heightAnchor.constraint(equalToConstant: karaokeView.scoringView.viewHeight).isActive = true 61 | lineScoreView.widthAnchor.constraint(equalToConstant: 50).isActive = true 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestDownload/TestDownloadCancle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestDownloadCancle.swift 3 | // AgoraComponetLog-Unit-Tests 4 | // 5 | // Created by ZYP on 2024/1/17. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | final class TestDownloadCancle: XCTestCase, LyricsFileDownloaderDelegate { 12 | let exp = XCTestExpectation(description: "TestDownloadCancle.cancel") 13 | var actualSuccessCount = 0 14 | var lyricsFileDownloader: LyricsFileDownloader! 15 | let urlStrings = ["https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/1.zip", 16 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/2.zip", 17 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/3.zip", 18 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/4.zip", 19 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/5.zip", 20 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/6.zip", 21 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/7.zip", 22 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/8.lrc", 23 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/9.lrc", 24 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/10.lrc"] 25 | 26 | 27 | override func setUpWithError() throws { 28 | exp.expectedFulfillmentCount = 7 29 | Downloader.requestTimeoutInterval = 9 30 | Log.setLoggers(loggers: [ConsoleLogger()]) 31 | } 32 | 33 | override func tearDownWithError() throws { 34 | lyricsFileDownloader = nil 35 | } 36 | 37 | func testExample() throws { 38 | lyricsFileDownloader = LyricsFileDownloader() 39 | lyricsFileDownloader.delegate = self 40 | lyricsFileDownloader.cleanAll() 41 | 42 | for url in urlStrings { 43 | let id = lyricsFileDownloader.download(urlString: url) 44 | print("[test] download for id:\(id)") 45 | } 46 | 47 | lyricsFileDownloader.cancelDownload(requestId: 7) 48 | lyricsFileDownloader.cancelDownload(requestId: 8) 49 | lyricsFileDownloader.cancelDownload(requestId: 9) 50 | 51 | wait(for: [exp], timeout: 10) 52 | 53 | let fileCache = FileCache() 54 | let count = fileCache.findFiles(inDirectory: .cacheFolderPath()).count 55 | XCTAssertEqual(count, actualSuccessCount) 56 | } 57 | 58 | func onLyricsFileDownloadCompleted(requestId: Int, 59 | fileData: Data?, 60 | error: DownloadError?) { 61 | if error == nil { 62 | actualSuccessCount += 1 63 | } 64 | exp.fulfill() 65 | } 66 | 67 | func onLyricsFileDownloadProgress(requestId: Int, progress: Float) { 68 | 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestDownload/TestDownloadMuti.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestDownloadMuti.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/12/14. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | final class TestDownloadMuti: XCTestCase, LyricsFileDownloaderDelegate { 12 | var lyricsFileDownloader: LyricsFileDownloader! 13 | let exp = XCTestExpectation(description: "TestDownloadMuti") 14 | let expFail = XCTestExpectation(description: "TestDownloadMuti fail") 15 | let urlStrings = ["https://127.0.0.1/lyricsMockDownload/1.zip", 16 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/1.zip", 17 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/2.zip", 18 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/3.zip", 19 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/4.zip", 20 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/5.zip", 21 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/6.zip", 22 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/7.zip", 23 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/8.lrc", 24 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/9.lrc", 25 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/10.lrc"] 26 | override func setUpWithError() throws { 27 | exp.expectedFulfillmentCount = 10 28 | Log.setLoggers(loggers: [ConsoleLogger()]) 29 | lyricsFileDownloader = LyricsFileDownloader() 30 | lyricsFileDownloader.cleanAll() 31 | } 32 | 33 | override func tearDownWithError() throws { 34 | Downloader.requestTimeoutInterval = 60 35 | lyricsFileDownloader.cleanAll() 36 | lyricsFileDownloader = nil 37 | } 38 | 39 | func testMuti() throws { 40 | Downloader.requestTimeoutInterval = 10 41 | lyricsFileDownloader.delegate = self 42 | 43 | for urlString in urlStrings { 44 | let id = lyricsFileDownloader.download(urlString: urlString) 45 | print("[test] download for id:\(id)") 46 | } 47 | 48 | wait(for: [exp, expFail], timeout: 40) 49 | } 50 | 51 | func onLyricsFileDownloadProgress(requestId: Int, progress: Float) { 52 | 53 | } 54 | 55 | func onLyricsFileDownloadCompleted(requestId: Int, 56 | fileData: Data?, 57 | error: DownloadError?) { 58 | let result = error != nil ? "fail" : "success" 59 | print("[test]requestId:\(requestId) \(result)") 60 | exp.fulfill() 61 | 62 | if requestId == 0, error != nil { 63 | expFail.fulfill() 64 | } 65 | if requestId == 0, error == nil { 66 | fatalError() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/ProgressChecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressChecker.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/3/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ProgressCheckerDelegate: NSObjectProtocol { 11 | func progressCheckerDidProgressPause() 12 | } 13 | 14 | /// check progress if pause 15 | class ProgressChecker: NSObject { 16 | private var lastProgress: UInt = 0 17 | private var progress: UInt = 0 18 | private var isStart = false 19 | private let queue = DispatchQueue(label: "queue.progressChecker") 20 | weak var delegate: ProgressCheckerDelegate? 21 | private var isPause = false 22 | private var timer: DispatchSourceTimer? 23 | private let logTag = "ProgressChecker" 24 | 25 | // MARK: - Internal 26 | 27 | /// progress input 28 | func set(progress: UInt) { 29 | queue.async { [weak self] in 30 | self?._set(progress: progress) 31 | } 32 | } 33 | 34 | func reset() { 35 | queue.async { [weak self] in 36 | self?._reset() 37 | } 38 | } 39 | 40 | deinit { 41 | Log.info(text: "deinit", tag: logTag) 42 | } 43 | 44 | // MARK: - Private 45 | 46 | private func _set(progress: UInt) { 47 | self.progress = progress 48 | _start() 49 | } 50 | 51 | private func _start() { 52 | if isStart { return } 53 | isStart = true 54 | setupTimer() 55 | } 56 | 57 | private func _check() { 58 | guard progress > 0 else { 59 | return 60 | } 61 | if lastProgress == progress { 62 | if !isPause { 63 | invokeProgressCheckerDidProgressPause() 64 | } 65 | isPause = true 66 | } 67 | else { 68 | isPause = false 69 | } 70 | lastProgress = progress 71 | } 72 | 73 | private func _reset() { 74 | cancleTimer() 75 | isPause = false 76 | isStart = false 77 | lastProgress = 0 78 | progress = 0 79 | } 80 | 81 | private func setupTimer() { 82 | timer = DispatchSource.makeTimerSource(flags: [], queue: queue) 83 | timer?.schedule(deadline: .now(), repeating: .seconds(1), leeway: .seconds(0)) 84 | timer?.setEventHandler { [weak self] in 85 | self?._check() 86 | } 87 | timer?.resume() 88 | } 89 | 90 | private func cancleTimer() { 91 | guard let t = timer else { 92 | return 93 | } 94 | t.cancel() 95 | timer = nil 96 | } 97 | } 98 | 99 | extension ProgressChecker { 100 | fileprivate func invokeProgressCheckerDidProgressPause() { 101 | if Thread.isMainThread { 102 | delegate?.progressCheckerDidProgressPause() 103 | return 104 | } 105 | 106 | DispatchQueue.main.async { [weak self] in 107 | guard let self = self else { return } 108 | self.delegate?.progressCheckerDidProgressPause() 109 | } 110 | } 111 | } 112 | 113 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/LineScoreRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineScoreRecorder.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2024/6/3. 6 | // 7 | 8 | import Foundation 9 | 10 | @objc public class LineScoreRecorder: NSObject { 11 | var lineScoreDict = [UInt : LineScoreInfo]() 12 | let logTag = "LineScoreReacorder>>>>" 13 | 14 | @objc public func setLyricData(data: LyricModel) { 15 | lineScoreDict = [UInt : LineScoreInfo]() 16 | for (offset, element) in data.lines.enumerated() { 17 | let info = LineScoreInfo(begin: element.beginTime, 18 | duration: element.duration, 19 | score: 0) 20 | lineScoreDict[UInt(offset)] = info 21 | } 22 | 23 | Log.info(text: "lineScoreDict count: \(lineScoreDict.count)", tag: logTag) 24 | } 25 | 26 | /// setLineScore 27 | /// - Parameters: 28 | /// - index: the index of line in original lyric file 29 | /// - score: the score of line 30 | /// - Returns: CumulativeScore 31 | @objc public func setLineScore(index: UInt, score: UInt) -> UInt { 32 | guard let info = lineScoreDict[index] else { 33 | Log.errorText(text: "can not find line score info", tag: logTag) 34 | return calcluateCumulativeScore() 35 | } 36 | info.score = score 37 | Log.info(text: "setLineScore index:(\(index)), score: \(score)", tag: logTag) 38 | Log.info(text: "\(lineScoreDict.sorted(by:{ $0.key < $1.key}).map({ "i:\($0.key) s:\($0.value.score)" }))", tag: logTag) 39 | return calcluateCumulativeScore() 40 | } 41 | 42 | /// seek 43 | /// - Parameter position: current position 44 | /// - Returns: CumulativeScore 45 | @objc public func seek(position: UInt) -> UInt { 46 | Log.info(text: "seek position:\(position)", tag: logTag) 47 | for index in 0..= position { 50 | info.updateScore(score: 0) 51 | Log.info(text: "reset (\(index)) reset, score: \(info.score)", tag: logTag) 52 | } 53 | } 54 | 55 | return calcluateCumulativeScore() 56 | } 57 | 58 | private func calcluateCumulativeScore() -> UInt { 59 | var score: UInt = 0 60 | for (_, linscore) in lineScoreDict { 61 | score += linscore.score 62 | } 63 | Log.info(text: "calcluateCumulativeScore:\(score)") 64 | return score 65 | } 66 | } 67 | 68 | extension LineScoreRecorder { 69 | class LineScoreInfo: CustomStringConvertible { 70 | let begin: UInt 71 | let end: UInt 72 | let duration: UInt 73 | var score: UInt 74 | 75 | init(begin: UInt, duration: UInt, score: UInt) { 76 | self.begin = begin 77 | self.end = begin + duration 78 | self.duration = duration 79 | self.score = score 80 | } 81 | 82 | func updateScore(score: UInt) { 83 | self.score = score 84 | } 85 | 86 | var description: String { 87 | return "score: \(score)" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestParser/TestLrcParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestLrcParser.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2023/7/5. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | final class TestLrcParser: XCTestCase { 12 | 13 | func testLrcParseOfEnhancedFormat() { /** ablout enhancedFormat **/ 14 | let lrcParser = LrcParser() 15 | 16 | let sampleFormat1 = "[00:22.30]我不会发现 我难受" 17 | let sampleFormat2 = "[00:25.745]怎么说出口" 18 | let enhancedFormat1 = "[00:23.997]<00:23.997>又<00:24.694>是<00:25.356>九<00:25.704>月<00:26.017>九<00:26.644>重<00:27.028>阳<00:27.376>夜<00:28.003>难<00:28.351>聚<00:28.665>首" 19 | let enhancedFormat2 = "[00:29.326]<00:29.326>思<00:30.023>乡<00:30.476>的<00:30.719>人<00:31.416>儿<00:32.008>飘<00:32.322>流<00:32.705>在<00:33.053>外<00:33.332>头" 20 | let enhancedFormat3 = "[00:34.690]<00:34.690>又<00:35.352>是<00:36.014>九<00:36.327>月<00:36.675>九<00:37.337>愁<00:37.685>更<00:38.034>愁<00:38.661>情<00:39.009>更<00:39.392>忧" 21 | let enhancedFormat4 = "[00:53.011]<00:53.011> Why<00:53.015> hh" 22 | 23 | XCTAssertFalse(lrcParser.containsWordStartTime(sampleFormat1)) 24 | XCTAssertFalse(lrcParser.containsWordStartTime(sampleFormat2)) 25 | XCTAssertTrue(lrcParser.containsWordStartTime(enhancedFormat1)) 26 | XCTAssertTrue(lrcParser.containsWordStartTime(enhancedFormat2)) 27 | XCTAssertTrue(lrcParser.containsWordStartTime(enhancedFormat3)) 28 | XCTAssertTrue(lrcParser.containsWordStartTime(enhancedFormat4)) 29 | 30 | let tones1 = lrcParser.parseLineStringOfEnhancedFormat(enhancedFormat1) 31 | XCTAssertTrue(tones1.map({ $0.word }).joined() == "又是九月九重阳夜难聚首") 32 | XCTAssertTrue(tones1[0].beginTime == 23 * 1000 + 997) 33 | XCTAssertTrue(tones1[0].duration == (24 * 1000 + 694) - (23 * 1000 + 997)) 34 | XCTAssertTrue(tones1[1].beginTime == 24 * 1000 + 694) 35 | XCTAssertTrue(tones1[1].duration == (25 * 1000 + 356) - (24 * 1000 + 694)) 36 | XCTAssertTrue(tones1[10].beginTime == 28 * 1000 + 665) 37 | XCTAssertTrue(tones1[10].duration == 0) 38 | 39 | let tones2 = lrcParser.parseLineStringOfEnhancedFormat(enhancedFormat2) 40 | XCTAssertTrue(tones2.map({ $0.word }).joined() == "思乡的人儿飘流在外头") 41 | XCTAssertTrue(tones2[0].beginTime == 29 * 1000 + 326) 42 | XCTAssertTrue(tones2[0].duration == (30 * 1000 + 23) - (29 * 1000 + 326)) 43 | XCTAssertTrue(tones2[9].duration == 0) 44 | 45 | let tones4 = lrcParser.parseLineStringOfEnhancedFormat(enhancedFormat4) 46 | XCTAssertEqual(tones4[0].word, " Why") 47 | XCTAssertEqual(tones4[0].beginTime, 53 * 1000 + 11) 48 | XCTAssertEqual(tones4[1].word, " hh") 49 | XCTAssertEqual(tones4[1].beginTime, 53 * 1000 + 15) 50 | } 51 | 52 | func testTimeParae() { 53 | let lrcParser = LrcParser() 54 | XCTAssertTrue(lrcParser.parseTime("00:24.694") == 24694) 55 | XCTAssertTrue(lrcParser.parseTime("01:02.345") == 62345) 56 | XCTAssertTrue(lrcParser.parseTime("01:02.45") == 62045) 57 | XCTAssertTrue(lrcParser.parseTime("00:00.000") == 0) 58 | XCTAssertTrue(lrcParser.parseTime("00:53.011") == 53 * 1000 + 11) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 56 | 57 | 58 | 64 | 66 | 72 | 73 | 74 | 75 | 77 | 78 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestLineScoreRecorder/TestLineScoreRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestLineScoreRecorder.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2024/6/3. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | final class TestLineScoreRecorder: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | Log.setLoggers(loggers: [ConsoleLogger()]) 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | func testLineScoreRecorder() { 21 | let recoder = LineScoreRecorder() 22 | 23 | let krcFileData = try! Data(contentsOf: URL(fileURLWithPath: Bundle.current.path(forResource: "4875936889260991133", ofType: "krc")!)) 24 | let pitchFileData = try! Data(contentsOf: URL(fileURLWithPath: Bundle.current.path(forResource: "4875936889260991133.pitch", ofType: nil)!)) 25 | 26 | let model = KaraokeView.parseLyricData(lyricFileData: krcFileData, pitchFileData: pitchFileData)! 27 | 28 | recoder.setLyricData(data: model) 29 | 30 | var score = recoder.setLineScore(index: 5, score: 1) 31 | XCTAssertEqual(score, 1) 32 | 33 | score = recoder.setLineScore(index: 6, score: 2) 34 | XCTAssertEqual(score, 1+2) 35 | 36 | score = recoder.setLineScore(index: 7, score: 3) 37 | XCTAssertEqual(score, 1+2+3) 38 | 39 | score = recoder.setLineScore(index: 8, score: 4) 40 | XCTAssertEqual(score, 1+2+3+4) 41 | 42 | /// index = 8 43 | score = recoder.seek(position: 30572) 44 | XCTAssertEqual(score, 1+2+3) 45 | 46 | /// index = 6 47 | score = recoder.seek(position: 22638) 48 | XCTAssertEqual(score, 1) 49 | 50 | /// index = 5 51 | score = recoder.seek(position: 19044) 52 | XCTAssertEqual(score, 0) 53 | 54 | /** line begainTime 55 | - 0 : 1067 56 | - 1 : 1720 57 | - 2 : 1870 58 | - 3 : 2173 59 | - 4 : 15106 60 | - 5 : 19044 61 | - 6 : 22638 62 | - 7 : 25955 63 | - 8 : 30572 64 | - 9 : 34724 65 | - 10 : 37621 66 | - 11 : 41673 67 | - 12 : 47453 68 | - 13 : 50725 69 | - 14 : 53597 70 | - 15 : 60854 71 | - 16 : 62717 72 | - 17 : 64580 73 | - 18 : 66642 74 | - 19 : 69058 75 | - 20 : 72791 76 | - 21 : 76369 77 | - 22 : 78082 78 | - 23 : 80050 79 | - 24 : 82165 80 | - 25 : 84532 81 | - 26 : 88308 82 | - 27 : 117010 83 | - 28 : 120282 84 | - 29 : 123250 85 | - 30 : 130478 86 | - 31 : 132445 87 | - 32 : 134410 88 | - 33 : 136321 89 | - 34 : 138689 90 | - 35 : 142461 91 | - 36 : 146035 92 | - 37 : 149712 93 | - 38 : 154196 94 | - 39 : 158071 95 | - 40 : 168121 96 | - 41 : 172203 97 | - 42 : 175070 98 | - 43 : 178917 99 | */ 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /Demo/Demo/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/View/ScoringCanvasView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScoringCanvasView.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/1/3. 6 | // 7 | 8 | import UIKit 9 | 10 | class ScoringCanvasView: UIView { 11 | /// 游标的起始位置 12 | var defaultPitchCursorX: CGFloat = 100 13 | /// 音准线的高度 14 | var standardPitchStickViewHeight: CGFloat = 3 15 | /// 音准线的基准因子 16 | var movingSpeedFactor: CGFloat = 120 17 | /// 音准线默认的背景色 18 | var standardPitchStickViewColor: UIColor = .gray 19 | /// 音准线匹配后的背景色 20 | var standardPitchStickViewHighlightColor: UIColor = .orange 21 | 22 | fileprivate var standardInfos = [DrawInfo]() 23 | fileprivate var highlightInfos = [DrawInfo]() 24 | fileprivate var widthPreMs: CGFloat { movingSpeedFactor / 1000 } 25 | 26 | override func draw(_ rect: CGRect) { 27 | drawStaff() 28 | drawStandardInfos() 29 | drawHighlightInfos() 30 | } 31 | 32 | func draw(standardInfos: [DrawInfo], 33 | highlightInfos: [DrawInfo]) { 34 | self.standardInfos = standardInfos 35 | self.highlightInfos = highlightInfos 36 | setNeedsDisplay() 37 | } 38 | 39 | func reset() { 40 | self.standardInfos = [] 41 | self.highlightInfos = [] 42 | setNeedsDisplay() 43 | } 44 | 45 | override init(frame: CGRect) { 46 | super.init(frame: frame) 47 | backgroundColor = .clear 48 | } 49 | 50 | required init?(coder: NSCoder) { 51 | fatalError("init(coder:) has not been implemented") 52 | } 53 | } 54 | 55 | // MARK: - Draw 56 | extension ScoringCanvasView { 57 | /// 五线谱 58 | fileprivate func drawStaff() { 59 | let height = bounds.height 60 | let width = bounds.width 61 | let lineHeight: CGFloat = 1 62 | let spaceY = (height - lineHeight * 5) / 4 63 | 64 | for i in 0...5 { 65 | let y = CGFloat(i) * (spaceY + lineHeight) 66 | let rect = CGRect(x: 0, y: y, width: width, height: lineHeight) 67 | let linePath = UIBezierPath(rect: rect) 68 | UIColor.white.withAlphaComponent(0.08).setFill() 69 | linePath.fill() 70 | } 71 | } 72 | 73 | fileprivate func drawStandardInfos() { 74 | drawInfosStandrad(infos: standardInfos, fillColor: standardPitchStickViewColor) 75 | } 76 | 77 | fileprivate func drawHighlightInfos() { 78 | drawInfosStandrad(infos: highlightInfos, fillColor: standardPitchStickViewHighlightColor) 79 | } 80 | 81 | private func drawInfosHighlight(infos: [DrawInfo], fillColor: UIColor) { 82 | for info in infos { 83 | let rect = info.rect 84 | let gradient = CAGradientLayer() 85 | gradient.frame = rect 86 | gradient.colors = [UIColor.magenta.cgColor, UIColor.cyan.cgColor] 87 | 88 | layer.addSublayer(gradient) 89 | } 90 | } 91 | 92 | private func drawInfosStandrad(infos: [DrawInfo], fillColor: UIColor) { 93 | for info in infos { 94 | let rect = info.rect 95 | let path = UIBezierPath(roundedRect: rect, cornerRadius: standardPitchStickViewHeight/2) 96 | fillColor.setFill() 97 | path.fill() 98 | } 99 | } 100 | } 101 | 102 | extension ScoringCanvasView { 103 | typealias DrawInfo = ScoringMachineDrawInfo 104 | } 105 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Downloader/LyricsFileDownloaderProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LyricsFileDownloaderProtocol.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/12/13. 6 | // 7 | 8 | import Foundation 9 | 10 | /// the enum type to describe the error 11 | @objc public enum DownloadErrorDomainType: Int { 12 | case general = 0 13 | /// repeat url request, a same url is requesting 14 | case repeatDownloading = 1 15 | /// error from http framework in ios, such as time out 16 | case httpDownloadError = 2 17 | /// http logic error, such as 400/500 18 | case httpDownloadErrorLogic = 3 19 | /// unzip fail 20 | case unzipFail = 4 21 | 22 | /// the string to indicate the type 23 | public var domain: String { 24 | switch self { 25 | case .general: 26 | return "io.agora.LyricsFileDownloader.general" 27 | case .repeatDownloading: 28 | return "io.agora.LyricsFileDownloader.repeatDownloading" 29 | case .httpDownloadError: 30 | return "io.agora.LyricsFileDownloader.httpDownloadError" 31 | case .httpDownloadErrorLogic: 32 | return "io.agora.LyricsFileDownloader.httpDownloadErrorLogic" 33 | case .unzipFail: 34 | return "io.agora.LyricsFileDownloader.unzipFail" 35 | } 36 | } 37 | 38 | /// the name to describe the type 39 | public var name: String { 40 | switch self { 41 | case .general: 42 | return "general" 43 | case .repeatDownloading: 44 | return "repeatDownloading" 45 | case .httpDownloadError: 46 | return "httpDownloadError" 47 | case .httpDownloadErrorLogic: 48 | return "httpDownloadErrorLogic" 49 | case .unzipFail: 50 | return "unzipFail" 51 | } 52 | } 53 | } 54 | 55 | /// the class to describe the error 56 | public class DownloadError: NSError { 57 | /// the message to describe the error 58 | public let msg: String 59 | /// the type of different error 60 | public let domainType: DownloadErrorDomainType 61 | /// original error from http framework in ios, such as time out 62 | public var originalError: NSError? 63 | 64 | init(domainType: DownloadErrorDomainType, code: Int, msg: String) { 65 | self.domainType = domainType 66 | self.msg = msg 67 | super.init(domain: domainType.domain, code: code) 68 | } 69 | 70 | init(domainType: DownloadErrorDomainType, error: NSError) { 71 | self.domainType = domainType 72 | self.msg = error.localizedDescription 73 | self.originalError = error 74 | super.init(domain: domainType.domain, code: error.code) 75 | } 76 | 77 | required init?(coder: NSCoder) { 78 | fatalError("init(coder:) has not been implemented") 79 | } 80 | 81 | public override var description: String { 82 | return "error: \(domainType.name) domain: \(domain) code:\(code) msg:\(msg) originalError:\(originalError?.localizedDescription ?? "nil")" 83 | } 84 | } 85 | 86 | @objc public protocol LyricsFileDownloaderDelegate: NSObjectProtocol { 87 | /// progress event 88 | /// - Parameters: 89 | /// - progress: [0, 1], if equal `1`, means success 90 | func onLyricsFileDownloadProgress(requestId: Int, progress: Float) 91 | 92 | /// Completed event 93 | /// - Parameters: 94 | /// - fileData: lyric data from file, if `nil` means fail 95 | /// - error: if `nil` means success 96 | func onLyricsFileDownloadCompleted(requestId: Int, fileData: Data?, error: DownloadError?) 97 | } 98 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Scoring/ScoringMachineProtocol+Events.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScoringMachineProtocol+Events.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2024/6/4. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ScoringMachineEventInvoker { 11 | static func invokeScoringMachine(scoringMachine: ScoringMachineProtocol, 12 | didUpdateDraw standardInfos: [ScoringMachineDrawInfo], 13 | highlightInfos: [ScoringMachineDrawInfo]) { 14 | if Thread.isMainThread { 15 | scoringMachine.delegate?.scoringMachine(scoringMachine, 16 | didUpdateDraw: standardInfos, 17 | highlightInfos: highlightInfos) 18 | return 19 | } 20 | 21 | DispatchQueue.main.async { 22 | scoringMachine.delegate?.scoringMachine(scoringMachine, 23 | didUpdateDraw: standardInfos, 24 | highlightInfos: highlightInfos) 25 | } 26 | } 27 | 28 | static func invokeScoringMachine(scoringMachine: ScoringMachineProtocol, didUpdateCursor centerY: CGFloat, 29 | showAnimation: Bool, 30 | debugInfo: ScoringMachineDebugInfo) { 31 | if Thread.isMainThread { 32 | scoringMachine.delegate?.scoringMachine(scoringMachine, 33 | didUpdateCursor: centerY, 34 | showAnimation: showAnimation, 35 | debugInfo: debugInfo) 36 | return 37 | } 38 | 39 | DispatchQueue.main.async { 40 | scoringMachine.delegate?.scoringMachine(scoringMachine, 41 | didUpdateCursor: centerY, 42 | showAnimation: showAnimation, 43 | debugInfo: debugInfo) 44 | } 45 | } 46 | 47 | static func invokeScoringMachine(scoringMachine: ScoringMachineProtocol, 48 | didFinishLineWith model: LyricLineModel, 49 | score: Int, 50 | cumulativeScore: Int, 51 | lineIndex: Int, 52 | lineCount: Int) { 53 | if Thread.isMainThread { 54 | scoringMachine.delegate?.scoringMachine(scoringMachine, 55 | didFinishLineWith: model, 56 | score: score, 57 | cumulativeScore: cumulativeScore, 58 | lineIndex: lineIndex, 59 | lineCount: lineCount) 60 | return 61 | } 62 | 63 | DispatchQueue.main.async { 64 | scoringMachine.delegate?.scoringMachine(scoringMachine, 65 | didFinishLineWith: model, 66 | score: score, 67 | cumulativeScore: cumulativeScore, 68 | lineIndex: lineIndex, 69 | lineCount: lineCount) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/Log.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogProvider.swift 3 | // 4 | // 5 | // Created by ZYP on 2021/5/28. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class Log { 12 | static let provider = LogManager.share 13 | 14 | static func errorText(text: String, 15 | tag: String? = nil) { 16 | provider.errorText(text: text, tag: tag) 17 | } 18 | 19 | static func error(error: CustomStringConvertible, 20 | tag: String? = nil) { 21 | provider.errorText(text: error.description, tag: tag) 22 | } 23 | 24 | static func info(text: String, 25 | tag: String? = nil) { 26 | provider.info(text: text, tag: tag) 27 | } 28 | 29 | static func debug(text: String, 30 | tag: String? = nil) { 31 | provider.debug(text: text, tag: tag) 32 | } 33 | 34 | static func warning(text: String, 35 | tag: String? = nil) { 36 | provider.warning(text: text, tag: tag) 37 | } 38 | 39 | static func setLoggers(loggers: [ILogger]) { 40 | provider.loggers = loggers 41 | } 42 | } 43 | 44 | class LogManager { 45 | static let share = LogManager() 46 | var loggers = [ILogger]() 47 | private let queue = DispatchQueue(label: "LogManager") 48 | let dateFormatter: DateFormatter 49 | 50 | init() { 51 | dateFormatter = DateFormatter() 52 | dateFormatter.dateFormat = "dd/MM/YY HH:mm:ss:SSS" 53 | } 54 | 55 | fileprivate func error(error: Error?, 56 | tag: String?, 57 | domainName: String) { 58 | guard let e = error else { 59 | return 60 | } 61 | var text = "" 62 | if e.localizedDescription.count > 1 { 63 | text = e.localizedDescription 64 | } 65 | 66 | let err = e as CustomStringConvertible 67 | if err.description.count > 1 { 68 | text = err.description 69 | } 70 | 71 | errorText(text: text, 72 | tag: tag) 73 | } 74 | 75 | fileprivate func errorText(text: String, 76 | tag: String?) { 77 | log(type: .error, 78 | text: text, 79 | tag: tag) 80 | } 81 | 82 | fileprivate func info(text: String, 83 | tag: String?) { 84 | log(type: .info, 85 | text: text, 86 | tag: tag) 87 | } 88 | 89 | fileprivate func warning(text: String, 90 | tag: String?) { 91 | log(type: .warning, 92 | text: text, 93 | tag: tag) 94 | } 95 | 96 | fileprivate func debug(text: String, 97 | tag: String?) { 98 | log(type: .debug, 99 | text: text, 100 | tag: tag) 101 | } 102 | 103 | fileprivate func log(type: LoggerLevel, 104 | text: String, 105 | tag: String?) { 106 | queue.async { [weak self] in 107 | guard let self = self, !self.loggers.isEmpty else { return } 108 | let time = self.dateFormatter.string(from: .init()) 109 | self.log(content: text, tag: tag, time: time, level: type) 110 | } 111 | } 112 | 113 | func log(content: String, tag: String?, time: String, level: LoggerLevel) { 114 | for logger in loggers { 115 | logger.onLog(content: content, tag: tag, time: time, level: level) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Other/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2022/12/21. 6 | // 7 | 8 | import Foundation 9 | 10 | class AgoraLyricsScore {} 11 | 12 | extension String { 13 | // 字符串截取 14 | func textSubstring(startIndex: Int, length: Int) -> String { 15 | let startIndex = index(self.startIndex, offsetBy: startIndex) 16 | let endIndex = index(startIndex, offsetBy: length) 17 | let subvalues = self[startIndex ..< endIndex] 18 | return String(subvalues) 19 | } 20 | } 21 | 22 | extension LyricLineModel { 23 | var endTime: UInt { 24 | beginTime + duration 25 | } 26 | } 27 | 28 | extension LyricToneModel { 29 | var endTime: UInt { 30 | beginTime + duration 31 | } 32 | } 33 | 34 | extension Bundle { 35 | static var currentBundle: Bundle { 36 | let bundle = Bundle(for: AgoraLyricsScore.self) 37 | let path = bundle.path(forResource: "AgoraLyricsScoreBundle", ofType: "bundle") 38 | if path == nil { 39 | Log.error(error: "bundle not found path", tag: "Bundle") 40 | } 41 | let current = Bundle(path: path!) 42 | if current == nil { 43 | Log.error(error: "bundle not found path: \(path!)", tag: "Bundle") 44 | } 45 | return current! 46 | } 47 | 48 | func image(name: String) -> UIImage? { 49 | return UIImage(named: name, in: self, compatibleWith: nil) 50 | } 51 | } 52 | 53 | extension UIColor{ 54 | class func colorWithHex(hexStr:String) -> UIColor{ 55 | return UIColor.colorWithHex(hexStr : hexStr, alpha:1) 56 | } 57 | 58 | class func colorWithHex(hexStr:String, alpha:Float) -> UIColor{ 59 | 60 | var cStr = hexStr.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).uppercased() as NSString; 61 | 62 | if(cStr.length < 6){ 63 | return UIColor.clear; 64 | } 65 | 66 | if(cStr.hasPrefix("0x")){ 67 | cStr = cStr.substring(from: 2) as NSString 68 | } 69 | 70 | if(cStr.hasPrefix("#")){ 71 | cStr = cStr.substring(from: 1) as NSString 72 | } 73 | 74 | if(cStr.length != 6){ 75 | return UIColor.clear 76 | } 77 | 78 | let rStr = (cStr as NSString).substring(to: 2) 79 | let gStr = ((cStr as NSString).substring(from: 2) as NSString).substring(to: 2) 80 | let bStr = ((cStr as NSString).substring(from: 4) as NSString).substring(to: 2) 81 | 82 | var r: UInt32 = 0x0 83 | var g: UInt32 = 0x0 84 | var b: UInt32 = 0x0 85 | 86 | Scanner.init(string: rStr).scanHexInt32(&r) 87 | Scanner.init(string: gStr).scanHexInt32(&g) 88 | Scanner.init(string: bStr).scanHexInt32(&b) 89 | 90 | return UIColor.init(red: CGFloat(r)/255.0, green: CGFloat(g)/255.0, blue: CGFloat(b)/255.0, alpha: CGFloat(alpha)); 91 | 92 | } 93 | } 94 | 95 | extension Date { 96 | /// 获取当前 秒级 时间戳 - 10位 97 | var timeStamp: Int { 98 | let timeInterval = timeIntervalSince1970 99 | let timeStamp = Int(timeInterval) 100 | return timeStamp 101 | } 102 | 103 | /// 获取当前 毫秒级 时间戳 - 13位 104 | var milliStamp: CLongLong { 105 | let timeInterval = timeIntervalSince1970 106 | let millisecond = CLongLong(round(timeInterval * 1000)) 107 | return millisecond 108 | } 109 | } 110 | 111 | extension Double { 112 | /// 保留2位小数 113 | var keep2: Double { 114 | return Double(Darwin.round(self * 100)/100) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestDownload/TestFileCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestFileCache.swift 3 | // AgoraComponetLog-Unit-Tests 4 | // 5 | // Created by ZYP on 2023/12/13. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | final class TestFileCache: XCTestCase { 12 | var fileCache: FileCache! 13 | 14 | override func setUpWithError() throws { 15 | Log.setLoggers(loggers: [ConsoleLogger()]) 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | fileCache = nil 20 | } 21 | 22 | func testMaxFile() { 23 | fileCache = FileCache() 24 | fileCache.maxFileNum = 10 25 | fileCache.clearAll() 26 | 27 | let fileManager = FileManager.default 28 | let directory = String.cacheFolderPath() 29 | for index in 0...fileCache.maxFileNum { 30 | let filePath = directory + "/" + "\(index).xml" 31 | fileManager.createFile(atPath: filePath, 32 | contents: "123".data(using: .utf8), 33 | attributes: nil) 34 | } 35 | 36 | var files = fileCache.findFiles(inDirectory: .cacheFolderPath()).sorted { l, r in 37 | l.createdTimeStamp < r.createdTimeStamp 38 | } 39 | 40 | for file in files { 41 | print("\(file.createdTimeStamp)-\(file.path.fileName)") 42 | } 43 | XCTAssertEqual(files.count, Int(fileCache.maxFileNum)+1) 44 | 45 | fileCache.removeFilesIfNeeded() 46 | 47 | files = fileCache.findFiles(inDirectory: .cacheFolderPath()).sorted { l, r in 48 | l.createdTimeStamp < r.createdTimeStamp 49 | } 50 | for file in files { 51 | print("\(file.createdTimeStamp)-\(file.path.fileName)") 52 | } 53 | 54 | let fileNames = files.map({ $0.path.fileName }) 55 | XCTAssertEqual(fileNames.count, Int(fileCache.maxFileNum)) 56 | XCTAssertFalse(fileNames.contains("0.xml")) 57 | 58 | fileCache.clearAll() 59 | 60 | files = fileCache.findFiles(inDirectory: .cacheFolderPath()).sorted { l, r in 61 | l.createdTimeStamp < r.createdTimeStamp 62 | } 63 | 64 | XCTAssertEqual(files.count, 0) 65 | } 66 | 67 | func testMaxFileAge() { 68 | fileCache = FileCache() 69 | fileCache.maxFileNum = 5 70 | fileCache.maxFileAge = 1 71 | fileCache.clearAll() 72 | 73 | let fileManager = FileManager.default 74 | let directory = String.cacheFolderPath() 75 | for index in 0...fileCache.maxFileNum { 76 | let filePath = directory + "/" + "\(index).xml" 77 | fileManager.createFile(atPath: filePath, 78 | contents: "123".data(using: .utf8), 79 | attributes: nil) 80 | } 81 | 82 | var files = fileCache.findFiles(inDirectory: .cacheFolderPath()).sorted { l, r in 83 | l.createdTimeStamp < r.createdTimeStamp 84 | } 85 | 86 | for file in files { 87 | print("\(file.createdTimeStamp)-\(file.path.fileName)") 88 | } 89 | XCTAssertEqual(files.count, Int(fileCache.maxFileNum)+1) 90 | 91 | Thread.sleep(forTimeInterval: 2) 92 | fileCache.removeFilesIfNeeded() 93 | 94 | files = fileCache.findFiles(inDirectory: .cacheFolderPath()).sorted { l, r in 95 | l.createdTimeStamp < r.createdTimeStamp 96 | } 97 | 98 | XCTAssertEqual(files.count, 0) 99 | 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/EnhancedLRCformat.lrc: -------------------------------------------------------------------------------- 1 | [00:23.997]<00:23.997>又<00:24.694>是<00:25.356>九<00:25.704>月<00:26.017>九<00:26.644>重<00:27.028>阳<00:27.376>夜<00:28.003>难<00:28.351>聚<00:28.665>首 2 | [00:29.326]<00:29.326>思<00:30.023>乡<00:30.476>的<00:30.719>人<00:31.416>儿<00:32.008>飘<00:32.322>流<00:32.705>在<00:33.053>外<00:33.332>头 3 | [00:34.690]<00:34.690>又<00:35.352>是<00:36.014>九<00:36.327>月<00:36.675>九<00:37.337>愁<00:37.685>更<00:38.034>愁<00:38.661>情<00:39.009>更<00:39.392>忧 4 | [00:40.019]<00:40.019>回<00:40.681>家<00:41.134>的<00:41.343>打<00:42.004>算<00:42.736>始<00:43.014>终<00:43.328>在<00:43.711>心<00:44.025>头 5 | [00:50.677]<00:50.677>走<00:50.956>走<00:51.339>走<00:51.652>走<00:52.035>走<00:52.349>啊<00:52.628>走<00:53.359>走<00:53.672>到<00:54.056>九<00:54.717>月<00:55.205>九 6 | [00:56.006]<00:56.006>他<00:56.250>乡<00:56.703>没<00:57.016>有<00:57.399>烈<00:58.026>酒<00:58.653>没<00:59.036>有<00:59.663>问<01:00.011>候 7 | [01:01.265]<01:01.265>走<01:01.370>走<01:01.683>走<01:02.275>走<01:02.693>走<01:03.111>啊<01:03.320>走<01:04.017>走<01:04.330>到<01:04.679>九<01:05.375>月<01:05.863>九 8 | [01:06.699]<01:06.699>家<01:06.977>中<01:07.361>才<01:07.674>有<01:08.057>自<01:08.684>由<01:09.346>才<01:10.042>有<01:10.356>九<01:10.704>月<01:16.730>九 9 | [01:25.333]<01:25.333>又<01:26.029>是<01:26.691>九<01:27.005>月<01:27.353>九<01:27.980>重<01:28.363>阳<01:28.746>夜<01:29.338>难<01:29.687>聚<01:30.000>首 10 | [01:30.662]<01:30.662>思<01:31.358>乡<01:31.811>的<01:32.055>人<01:32.752>儿<01:33.344>飘<01:33.657>流<01:34.040>在<01:34.389>外<01:34.667>头 11 | [01:36.026]<01:36.026>又<01:36.687>是<01:37.349>九<01:37.663>月<01:38.011>九<01:38.673>愁<01:39.021>更<01:39.404>愁<01:39.996>情<01:40.345>更<01:40.693>忧 12 | [01:41.355]<01:41.355>回<01:42.016>家<01:42.469>的<01:42.678>打<01:43.340>算<01:44.037>始<01:44.350>终<01:44.698>在<01:45.047>心<01:45.395>头 13 | [01:49.365]<01:49.365>走<01:49.679>走<01:50.027>走<01:50.341>走<01:50.689>走<01:51.037>啊<01:51.281>走<01:52.013>走<01:52.361>到<01:52.709>九<01:53.371>月<01:53.859>九 14 | [01:54.660]<01:54.660>他<01:55.008>乡<01:55.356>没<01:55.705>有<01:56.053>烈<01:56.680>酒<01:57.342>没<01:57.690>有<01:58.352>问<01:58.665>候 15 | [02:00.023]<02:00.023>走<02:00.337>走<02:00.685>走<02:01.034>走<02:01.347>走<02:01.695>啊<02:01.939>走<02:02.671>走<02:03.019>到<02:03.367>九<02:03.715>月<02:04.064>九 16 | [02:05.352]<02:05.352>家<02:05.631>中<02:06.014>才<02:06.362>有<02:06.746>自<02:07.338>由<02:08.000>才<02:08.313>有<02:08.696>九<02:09.010>月<02:09.358>九 17 | [02:23.986]<02:23.986>亲<02:24.683>人<02:25.136>和<02:25.345>朋<02:26.007>友<02:26.668>举<02:27.051>起<02:27.330>杯<02:27.992>倒<02:28.340>满<02:28.688>酒 18 | [02:29.350]<02:29.350>饮<02:30.012>尽<02:30.500>这<02:30.743>乡<02:31.336>愁<02:31.997>醉<02:32.346>倒<02:32.485>在<02:32.694>家<02:33.007>门<02:33.321>口 19 | [02:37.361]<02:37.361>走<02:37.605>走<02:38.023>走<02:38.336>走<02:38.685>走<02:39.033>啊<02:39.277>走<02:40.008>走<02:40.357>到<02:40.705>九<02:41.018>月<02:41.367>九 20 | [02:42.655]<02:42.655>他<02:43.038>乡<02:43.352>没<02:43.700>有<02:44.048>烈<02:44.675>酒<02:45.337>没<02:45.685>有<02:46.347>问<02:46.661>候 21 | [02:48.019]<02:48.019>走<02:48.333>走<02:48.681>走<02:49.029>走<02:49.343>走<02:49.691>啊<02:49.935>走<02:50.666>走<02:51.014>到<02:51.363>九<02:51.676>月<02:52.059>九 22 | [02:53.348]<02:53.348>家<02:53.627>中<02:54.010>才<02:54.358>有<02:54.741>自<02:55.333>由<02:55.995>才<02:56.309>有<02:56.692>九<02:57.005>月<02:57.354>九 23 | [02:58.677]<02:58.677>走<02:58.991>走<02:59.339>走<02:59.687>走<03:00.035>走<03:00.384>啊<03:00.628>走<03:01.359>走<03:01.672>到<03:02.021>九<03:02.717>月<03:03.205>九 24 | [03:04.006]<03:04.006>他<03:04.285>乡<03:04.703>没<03:05.016>有<03:05.399>烈<03:06.026>酒<03:06.653>没<03:07.001>有<03:07.663>问<03:08.011>候 25 | [03:09.370]<03:09.370>走<03:09.683>走<03:10.032>走<03:10.345>走<03:10.693>走<03:11.042>啊<03:11.285>走<03:12.017>走<03:12.330>到<03:12.714>九<03:13.027>月<03:13.375>九 26 | [03:14.664]<03:14.664>家<03:14.943>中<03:15.326>才<03:15.709>有<03:16.266>自<03:16.684>由<03:17.346>才<03:17.868>有<03:18.042>九<03:18.356>月<03:18.704>九<03:21.108>噢 -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Lyrics/LyricMachine+Events.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LyricMachine+Events.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2023/3/13. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol LyricMachineDelegate: NSObjectProtocol { 11 | /// 在 `setLyricData` 完成后调用 12 | func lyricMachine(_ lyricMachine: LyricMachine, 13 | didSetLyricData datas: [LyricCell.Model]) 14 | 15 | /// 在 `setLyricData` 完成后调用 16 | /// - Parameters: 17 | /// - remainingTime: 倒计时剩余时间 18 | func lyricMachine(_ lyricMachine: LyricMachine, didUpdate remainingTime: Int) 19 | 20 | /// 换行时调用 21 | func lyricMachine(_ lyricMachine: LyricMachine, 22 | didStartLineAt newIndexPath: IndexPath, 23 | oldIndexPath: IndexPath, 24 | animated: Bool) 25 | /// 行更新时调用 26 | func lyricMachine(_ lyricMachine: LyricMachine, didUpdateLineAt indexPath: IndexPath) 27 | 28 | /// Consloe信息 (仅用于debug) 29 | func lyricMachine(_ lyricMachine: LyricMachine, didUpdateConsloe text: String) 30 | } 31 | 32 | extension LyricMachine { /** invoke **/ 33 | func invokeLyricMachine(didSetLyricData datas: [LyricCell.Model]) { 34 | if Thread.isMainThread { 35 | delegate?.lyricMachine(self, didSetLyricData: datas) 36 | return 37 | } 38 | 39 | DispatchQueue.main.async { [weak self] in 40 | guard let self = self else { return } 41 | self.delegate?.lyricMachine(self, didSetLyricData: datas) 42 | } 43 | } 44 | 45 | func invokeLyricMachine(didUpdate remainingTime: Int) { 46 | if Thread.isMainThread { 47 | delegate?.lyricMachine(self, didUpdate: remainingTime) 48 | return 49 | } 50 | 51 | DispatchQueue.main.async { [weak self] in 52 | guard let self = self else { return } 53 | self.delegate?.lyricMachine(self, didUpdate: remainingTime) 54 | } 55 | } 56 | 57 | func invokeLyricMachine(didStartLineAt newIndexPath: IndexPath, 58 | oldIndexPath: IndexPath, animated: Bool) { 59 | if Thread.isMainThread { 60 | delegate?.lyricMachine(self, 61 | didStartLineAt: newIndexPath, 62 | oldIndexPath: oldIndexPath, 63 | animated: animated) 64 | return 65 | } 66 | 67 | DispatchQueue.main.async { [weak self] in 68 | guard let self = self else { return } 69 | self.delegate?.lyricMachine(self, 70 | didStartLineAt: newIndexPath, 71 | oldIndexPath: oldIndexPath, 72 | animated: animated) 73 | } 74 | } 75 | 76 | func invokeLyricMachine(didUpdateLineAt indexPath: IndexPath) { 77 | if Thread.isMainThread { 78 | delegate?.lyricMachine(self, didUpdateLineAt: indexPath) 79 | return 80 | } 81 | 82 | DispatchQueue.main.async { [weak self] in 83 | guard let self = self else { return } 84 | self.delegate?.lyricMachine(self, didUpdateLineAt: indexPath) 85 | } 86 | } 87 | 88 | func invokeLyricMachine(didUpdateConsloe text: String) { 89 | if Thread.isMainThread { 90 | delegate?.lyricMachine(self, didUpdateConsloe: text) 91 | return 92 | } 93 | 94 | DispatchQueue.main.async { [weak self] in 95 | guard let self = self else { return } 96 | self.delegate?.lyricMachine(self, didUpdateConsloe: text) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Demo/Demo/VC/DownloadVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/12/14. 6 | // 7 | 8 | import UIKit 9 | import AgoraLyricsScore 10 | 11 | class DownloadVC: UIViewController { 12 | let downloadView = DownloadView() 13 | let lyricsFileDownloader = LyricsFileDownloader() 14 | var currentUrlIndex = 0 15 | let urlStrings = ["https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/1.zip", 16 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/2.zip", 17 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/3.zip", 18 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/4.zip", 19 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/5.zip", 20 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/6.zip", 21 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/7.zip", 22 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/8.lrc", 23 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/9.lrc", 24 | "https://fullapp.oss-cn-beijing.aliyuncs.com/lyricsMockDownload/10.lrc"] 25 | 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | 29 | view.addSubview(downloadView) 30 | downloadView.frame = view.bounds 31 | downloadView.delegate = self 32 | 33 | /// init internal log 34 | let _ = KaraokeView.init(frame: .zero, loggers: [ConsoleLogger(), FileLogger()]) 35 | 36 | lyricsFileDownloader.delegate = self 37 | } 38 | } 39 | 40 | extension DownloadVC: DownloadViewDelegate, LyricsFileDownloaderDelegate { 41 | func downloadViewDidTapAction(action: DownloadView.Action, info: DownloadView.Info?) { 42 | if action == .addOne { 43 | let urlString = urlStrings[currentUrlIndex] 44 | let requesetId = lyricsFileDownloader.download(urlString: urlString) 45 | let item = DownloadView.Info(requestId: requesetId, urlString: urlString) 46 | downloadView.addInfos(infos: [item]) 47 | currentUrlIndex = currentUrlIndex == urlStrings.count - 1 ? 0 : currentUrlIndex + 1 48 | return 49 | } 50 | 51 | if action == .addAll { 52 | for urlString in urlStrings { 53 | let requesetId = lyricsFileDownloader.download(urlString: urlString) 54 | let item = DownloadView.Info(requestId: requesetId, urlString: urlString) 55 | downloadView.addInfos(infos: [item]) 56 | } 57 | return 58 | } 59 | 60 | if action == .clear { 61 | lyricsFileDownloader.cleanAll() 62 | return 63 | } 64 | 65 | if action == .cancel, let info = info { 66 | if info.state == .created || info.state == .progress { 67 | lyricsFileDownloader.cancelDownload(requestId: info.requestId) 68 | info.state = .canceled 69 | downloadView.reloadData() 70 | } 71 | } 72 | } 73 | 74 | func onLyricsFileDownloadProgress(requestId: Int, progress: Float) { 75 | print("progress:\(progress)") 76 | if let item = downloadView.getInfo(requestId: requestId) { 77 | item.state = .progress 78 | item.progress = progress 79 | downloadView.reloadData() 80 | } 81 | } 82 | 83 | func onLyricsFileDownloadCompleted(requestId: Int, 84 | fileData: Data?, 85 | error: AgoraLyricsScore.DownloadError?) { 86 | if let item = downloadView.getInfo(requestId: requestId) { 87 | item.state = fileData == nil ? .doneFail : .doneSuccess 88 | downloadView.reloadData() 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Demo/Demo/VC/SearchVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/2/7. 6 | // 7 | 8 | import UIKit 9 | import AgoraRtcKit 10 | 11 | protocol SearchVCDelegate: NSObjectProtocol { 12 | func searchVCDidSelected(songCode: Int) 13 | } 14 | 15 | class SearchVC: UIViewController { 16 | let tableview = UITableView() 17 | let textFeild = UITextField() 18 | var list = [AgoraMusic]() 19 | weak var delegate: SearchVCDelegate? 20 | weak var mcc: AgoraMusicContentCenter! 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | setupUI() 25 | commonInit() 26 | } 27 | 28 | func setup(mcc: AgoraMusicContentCenter) { 29 | self.mcc = mcc 30 | mcc.register(self) 31 | list = [] 32 | } 33 | 34 | func append(musics: [AgoraMusic]) { 35 | list.append(contentsOf: musics) 36 | tableview.reloadData() 37 | } 38 | 39 | func setupUI() { 40 | view.addSubview(textFeild) 41 | view.addSubview(tableview) 42 | tableview.translatesAutoresizingMaskIntoConstraints = false 43 | textFeild.translatesAutoresizingMaskIntoConstraints = false 44 | 45 | textFeild.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 46 | textFeild.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -100).isActive = true 47 | textFeild.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40).isActive = true 48 | 49 | tableview.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true 50 | tableview.topAnchor.constraint(equalTo: view.topAnchor).isActive = true 51 | tableview.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true 52 | tableview.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true 53 | } 54 | 55 | func commonInit() { 56 | tableview.register(UITableViewCell.self, forCellReuseIdentifier: "cell") 57 | tableview.dataSource = self 58 | tableview.delegate = self 59 | tableview.reloadData() 60 | } 61 | } 62 | 63 | extension SearchVC: UITableViewDelegate, UITableViewDataSource { 64 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 65 | return list.count 66 | } 67 | 68 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 69 | let cell = tableview.dequeueReusableCell(withIdentifier: "cell", for: indexPath) 70 | let item = list[indexPath.row] 71 | cell.textLabel?.text = item.name + "[\(item.singer)][\(item.songCode)]" 72 | cell.accessoryType = .disclosureIndicator 73 | return cell 74 | } 75 | 76 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 77 | tableView.deselectRow(at: indexPath, animated: true) 78 | let item = list[indexPath.row] 79 | delegate?.searchVCDidSelected(songCode: item.songCode) 80 | } 81 | } 82 | 83 | extension SearchVC: AgoraMusicContentCenterEventDelegate { 84 | func onMusicChartsResult(_ requestId: String, result: [AgoraMusicChartInfo], errorCode: AgoraMusicContentCenterStatusCode) { 85 | 86 | } 87 | 88 | func onMusicCollectionResult(_ requestId: String, result: AgoraMusicCollection, errorCode: AgoraMusicContentCenterStatusCode) { 89 | 90 | } 91 | 92 | func onLyricResult(_ requestId: String, songCode: Int, lyricUrl: String?, errorCode: AgoraMusicContentCenterStatusCode) { 93 | 94 | } 95 | 96 | func onSongSimpleInfoResult(_ requestId: String, songCode: Int, simpleInfo: String?, errorCode: AgoraMusicContentCenterStatusCode) { 97 | 98 | } 99 | 100 | func onPreLoadEvent(_ requestId: String, songCode: Int, percent: Int, lyricUrl: String?, status: AgoraMusicContentCenterPreloadStatus, errorCode: AgoraMusicContentCenterStatusCode) { 101 | 102 | } 103 | } 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/TestScoringMachine/TestMockScoring.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestMockScoring.swift 3 | // AgoraLyricsScore-Unit-Tests 4 | // 5 | // Created by ZYP on 2023/2/9. 6 | // 7 | 8 | import XCTest 9 | @testable import AgoraLyricsScore 10 | 11 | class TestMockScoring: XCTestCase, ScoringMachineDelegate { 12 | override func setUpWithError() throws { 13 | Log.setLoggers(loggers: [ConsoleLogger()]) 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | vm.reset() 18 | } 19 | 20 | var cumulativeScore = 0 21 | var testCaseNum = 0 22 | var vm = ScoringMachine() 23 | let exp = XCTestExpectation(description: "test score") 24 | let exp2 = XCTestExpectation(description: "test score2") 25 | 26 | func testAll() { /** test score 每个tone命中一次 **/ 27 | let url = URL(fileURLWithPath: Bundle.current.path(forResource: "825003", ofType: "xml")!) 28 | let data = try! Data(contentsOf: url) 29 | guard let model = KaraokeView.parseLyricData(lyricFileData: data) else { 30 | XCTFail() 31 | return 32 | } 33 | vm = ScoringMachine() 34 | vm.scoreLevel = 10 35 | vm.delegate = self 36 | vm.setLyricData(data: model) 37 | for index in 0...5 { 38 | let line = model.lines[index] 39 | for tone in line.tones { 40 | let time = tone.beginTime + tone.duration/2 41 | vm.setProgress(progress: time) 42 | vm.setPitch(speakerPitch: tone.pitch - 1, progressInMs: 0) 43 | } 44 | } 45 | wait(for: [exp], timeout: 3) 46 | } 47 | 48 | func testAll2() { /** test score 每个tone命中多次 **/ 49 | testCaseNum = 1 50 | let url = URL(fileURLWithPath: Bundle.current.path(forResource: "825003", ofType: "xml")!) 51 | let data = try! Data(contentsOf: url) 52 | guard let model = KaraokeView.parseLyricData(lyricFileData: data) else { 53 | XCTFail() 54 | return 55 | } 56 | 57 | vm = ScoringMachine() 58 | vm.delegate = self 59 | vm.scoreLevel = 15 60 | vm.setLyricData(data: model) 61 | 62 | let line = model.lines.first! 63 | var time = line.beginTime 64 | var gap = 0 65 | while time <= line.endTime+20 { 66 | vm.setProgress(progress: time) 67 | if gap == 40 { 68 | gap = 0 69 | vm.setPitch(speakerPitch: 50, progressInMs: 0) 70 | } 71 | gap += 20 72 | time += 20 73 | Thread.sleep(forTimeInterval: 0.02) 74 | } 75 | wait(for: [exp2], timeout: 10) 76 | } 77 | 78 | func sizeOfCanvasView(_ scoringMachine: ScoringMachineProtocol) -> CGSize { 79 | return .init(width: 380, height: 100) 80 | } 81 | 82 | func scoringMachine(_ scoringMachine: ScoringMachineProtocol, didUpdateDraw standardInfos: [ScoringMachine.DrawInfo], highlightInfos: [ScoringMachine.DrawInfo]) { 83 | 84 | } 85 | 86 | func scoringMachine(_ scoringMachine: ScoringMachineProtocol, didUpdateCursor centerY: CGFloat, showAnimation: Bool, debugInfo: ScoringMachine.DebugInfo) { 87 | 88 | } 89 | 90 | func scoringMachine(_ scoringMachine: ScoringMachineProtocol, 91 | didFinishLineWith model: LyricLineModel, 92 | score: Int, 93 | cumulativeScore: Int, 94 | lineIndex: Int, 95 | lineCount: Int) { 96 | if testCaseNum == 0 { 97 | self.cumulativeScore = cumulativeScore 98 | print("didFinishLineWith cumulativeScore: \(cumulativeScore)") 99 | if cumulativeScore == 500 { 100 | exp.fulfill() 101 | } 102 | } 103 | else { 104 | XCTAssertEqual(cumulativeScore, 66) 105 | if cumulativeScore == 66 { 106 | exp2.fulfill() 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Demo/Demo/Other/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2024/6/5. 6 | // 7 | 8 | import AgoraRtcKit 9 | import AgoraMccExService 10 | 11 | extension Double { 12 | var keep3: Double { 13 | return Double(Darwin.round(self * 1000)/1000) 14 | } 15 | } 16 | 17 | extension AgoraMusicContentCenter { 18 | enum LyricFileType: Int, CustomStringConvertible { 19 | case xml = 0 20 | case lrc = 1 21 | 22 | var description: String { 23 | return self == .xml ? "xml" : "lrc" 24 | } 25 | } 26 | } 27 | 28 | extension AgoraMusicContentCenterPreloadStatus: CustomStringConvertible { 29 | public var description: String { 30 | switch self { 31 | case .OK: 32 | return "OK" 33 | case .error: 34 | return "error" 35 | case .preloading: 36 | return "preloading" 37 | case .removeCache: 38 | return "removeCache" 39 | @unknown default: 40 | fatalError() 41 | } 42 | } 43 | } 44 | 45 | extension AgoraMusicContentCenterExState: CustomStringConvertible { 46 | public var description: String { 47 | switch self { 48 | case .initialized: 49 | return "initialized" 50 | case .initializeFailed: 51 | return "initializeFailed" 52 | case .preloadOK: 53 | return "preloadOK" 54 | case .preloadError: 55 | return "preloadError" 56 | case .preloading: 57 | return "preloading" 58 | case .preloadRemoveCache: 59 | return "preloadRemoveCache" 60 | case .startScoreCompleted: 61 | return "startScoreCompleted" 62 | case .startScoreFailed: 63 | return "startScoreFailed" 64 | @unknown default: 65 | fatalError() 66 | } 67 | } 68 | } 69 | 70 | extension AgoraMusicContentCenterExStateReason: CustomStringConvertible { 71 | public var description: String { 72 | switch self { 73 | case .OK: 74 | return "OK" 75 | case .error: 76 | return "error" 77 | case .errorInvalidSignature: 78 | return "errorInvalidSignature" 79 | case .errorHttpInternalError: 80 | return "errorHttpInternalError" 81 | case .ysdErrorLyricError: 82 | return "ysdErrorLyricError" 83 | case .ysdErrorPtsError: 84 | return "ysdErrorPtsError" 85 | case .ysdErrorParamError: 86 | return "ysdErrorParamError" 87 | case .ysdErrorTokenError: 88 | return "ysdErrorTokenError" 89 | case .ysdErrorPitchError: 90 | return "ysdErrorPitchError" 91 | case .ysdErrorNetworkError: 92 | return "ysdErrorNetworkError" 93 | case .ysdErrorRequestError: 94 | return "ysdErrorRequestError" 95 | case .ysdErrorPrivilegeError: 96 | return "ysdErrorPrivilegeError" 97 | case .ysdErrorNoActivateError: 98 | return "ysdErrorNoActivateError" 99 | case .ysdErrorRepeatRequestError: 100 | return "ysdErrorRepeatRequestError" 101 | @unknown default: 102 | fatalError() 103 | } 104 | } 105 | } 106 | 107 | extension AgoraMusicContentCenterStatusCode: CustomStringConvertible { 108 | public var description: String { 109 | switch self { 110 | case .OK: 111 | return "OK" 112 | case .error: 113 | return "error" 114 | case .errorGateway: 115 | return "errorGateway" 116 | case .errorMusicLoading: 117 | return "errorMusicLoading" 118 | case .errorInternalDataParse: 119 | return "errorInternalDataParse" 120 | case .errorPermissionAndResource: 121 | return "errorPermissionAndResource" 122 | case .errorHttpInternalError: 123 | return "errorHttpInternalError" 124 | case .errorMusicDecryption: 125 | return "errorMusicDecryption" 126 | @unknown default: 127 | fatalError() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Demo/Demo/VC/EmitterVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmitterVC.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/1/5. 6 | // 7 | 8 | import UIKit 9 | 10 | class EmitterVC: UIViewController { 11 | 12 | let emitter = Emitter() 13 | var start = true 14 | private var timer = GCDTimer() 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | view.backgroundColor = .red 19 | emitter.setupEmitterPoint(point: .init(x: 300, y: 300)) 20 | view.layer.addSublayer(emitter.layer) 21 | 22 | timer.scheduledMillisecondsTimer(withName: "EmitterVC", countDown: 1000000, milliseconds: 200, queue: .main) { [weak self](_, time) in 23 | guard let self = self else { return } 24 | self.start ? self.emitter.stop() : self.emitter.start() 25 | self.start = !self.start 26 | } 27 | } 28 | 29 | // override func touchesBegan(_ touches: Set, with event: UIEvent?) { 30 | // timer.scheduledMillisecondsTimer(withName: "EmitterVC", countDown: 1000000, milliseconds: 1000, queue: .main) { [weak self](_, time) in 31 | // guard let self = self else { return } 32 | // self.start ? self.emitter.stop() : self.emitter.start() 33 | // self.start = !self.start 34 | // } 35 | // } 36 | 37 | } 38 | 39 | 40 | 41 | class Emitter { 42 | var layer = CAEmitterLayer() 43 | var images: [UIImage]? { 44 | didSet { 45 | updateLayer() 46 | } 47 | } 48 | private var count = 0 49 | private var lastPoint: CGPoint = .zero 50 | private let logTag = "Emitter" 51 | 52 | var defaultImages: [UIImage] { 53 | var list = [UIImage]() 54 | for i in 1...8 { 55 | let image = UIImage(named: "star\(i)")! 56 | list.append(image) 57 | } 58 | return list 59 | } 60 | 61 | init() { 62 | updateLayer() 63 | } 64 | 65 | func updateLayer() { 66 | let superLayer = layer.superlayer 67 | layer.removeFromSuperlayer() 68 | layer = CAEmitterLayer() 69 | superLayer?.addSublayer(layer) 70 | layer.emitterPosition = .zero 71 | layer.preservesDepth = true 72 | layer.renderMode = .oldestLast 73 | layer.masksToBounds = false 74 | layer.emitterMode = .points 75 | layer.emitterShape = .circle 76 | layer.birthRate = 0 77 | layer.emitterPosition = lastPoint 78 | let imgs = (images != nil) ? images! : defaultImages 79 | let count = imgs.count 80 | layer.emitterCells = imgs.enumerated().map({ Emitter.createEmitterCell(name: "cell", image: $0.1, birthRate: count) }) 81 | } 82 | 83 | func setupEmitterPoint(point: CGPoint) { 84 | lastPoint = point 85 | layer.emitterPosition = point 86 | } 87 | 88 | func start() { 89 | count += 1 90 | if count >= 150 { 91 | print("change ==") 92 | count = 0 93 | updateLayer() 94 | } 95 | layer.birthRate = 1 96 | } 97 | 98 | func stop() { 99 | layer.birthRate = 0 100 | } 101 | 102 | static func createEmitterCell(name: String, image: UIImage, birthRate: Int) -> CAEmitterCell { 103 | /// 创建粒子, 并且设置例子相关的属性 104 | let cell = CAEmitterCell() 105 | /// 设置粒子速度 106 | cell.velocity = 1 107 | cell.velocityRange = 1 108 | /// 设置例子的大小 109 | cell.scale = 1 110 | cell.scaleRange = 0.5 111 | /// 设置粒子方向 112 | cell.emissionLongitude = CGFloat.pi * 3 113 | cell.emissionRange = CGFloat.pi / 6 114 | /// 设置粒子的存活时间 115 | cell.lifetime = 3 116 | cell.lifetimeRange = 0.1 117 | /// 设置粒子旋转 118 | cell.spin = CGFloat.pi / 2 119 | cell.spinRange = CGFloat.pi / 4 120 | /// 设置例子每秒弹出的个数 121 | cell.birthRate = Float(birthRate) 122 | cell.alphaRange = 0.75 123 | cell.alphaSpeed = -0.35 124 | /// 初始速度 125 | cell.velocity = 90 126 | cell.name = name 127 | cell.isEnabled = true 128 | cell.contents = image.cgImage 129 | return cell 130 | } 131 | } 132 | 133 | -------------------------------------------------------------------------------- /Demo/Demo/VC/GCDTimer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GCDTimer.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2022/12/22. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class GCDTimer { 12 | typealias ActionBlock = (String, TimeInterval) -> Void 13 | private var timerContainer = [String : DispatchSourceTimer]() 14 | private var currentDuration: TimeInterval = 0 15 | /// 秒级定时器 16 | /// 17 | /// - Parameters: 18 | /// - name: 定时器的名字 19 | /// - timeInterval: 时间间隔 20 | /// - queue: 线程 21 | /// - repeats: 是否重复 22 | /// - action: 执行的操作 23 | func scheduledSecondsTimer(withName name: String?, 24 | timeInterval: Int, 25 | queue: DispatchQueue, 26 | action: @escaping ActionBlock) 27 | { 28 | currentDuration = TimeInterval(timeInterval) 29 | let scheduledName = name ?? Date().timeString() 30 | var timer = timerContainer[scheduledName] 31 | if timer == nil { 32 | timer = DispatchSource.makeTimerSource(flags: [], queue: queue) 33 | timer?.resume() 34 | timerContainer[scheduledName] = timer 35 | } 36 | timer?.schedule(deadline: .now(), repeating: .seconds(1), leeway: .milliseconds(100)) 37 | timer?.setEventHandler(handler: { [weak self] in 38 | guard let self = self else { return } 39 | self.currentDuration -= 1 40 | action(scheduledName, self.currentDuration) 41 | if self.currentDuration <= 0 { 42 | self.destoryTimer(withName: scheduledName) 43 | } 44 | }) 45 | } 46 | 47 | /// 毫秒级定时器 48 | /// - Parameters: 49 | /// - name: 名称 50 | /// - countDown: 倒计时毫秒 51 | /// - timeInterval: 多少毫秒回调一次 52 | /// - queue: 线程 53 | /// - action: 回调 54 | func scheduledMillisecondsTimer(withName name: String?, 55 | countDown: TimeInterval, 56 | milliseconds: TimeInterval, 57 | queue: DispatchQueue, 58 | action: @escaping ActionBlock) 59 | { 60 | currentDuration = countDown 61 | let scheduledName = name ?? Date().timeString() 62 | var timer = timerContainer[scheduledName] 63 | if timer == nil { 64 | timer = DispatchSource.makeTimerSource(flags: [], queue: queue) 65 | timer?.resume() 66 | timerContainer[scheduledName] = timer 67 | } 68 | timer?.schedule(deadline: .now(), repeating: .milliseconds(Int(milliseconds)), leeway: .milliseconds(1)) 69 | timer?.setEventHandler(handler: { [weak self] in 70 | guard let self = self else { return } 71 | self.currentDuration -= milliseconds 72 | action(scheduledName, self.currentDuration) 73 | if self.currentDuration <= 0 { 74 | self.destoryTimer(withName: scheduledName) 75 | } 76 | }) 77 | } 78 | 79 | /// 销毁名字为name的计时器 80 | /// 81 | /// - Parameter name: 计时器的名字 82 | func destoryTimer(withName name: String?) { 83 | guard let name = name else { return } 84 | let timer = timerContainer[name] 85 | if timer == nil { return } 86 | timerContainer.removeValue(forKey: name) 87 | timer?.cancel() 88 | } 89 | 90 | /// 销毁所有计时器 91 | func destoryAllTimer() { 92 | timerContainer.forEach { 93 | destoryTimer(withName: $0.key) 94 | } 95 | } 96 | 97 | /// 检测是否已经存在名字为name的计时器 98 | /// 99 | /// - Parameter name: 计时器的名字 100 | /// - Returns: 返回bool值 101 | func isExistTimer(withName name: String?) -> Bool { 102 | guard let name = name else { return false } 103 | return timerContainer[name] != nil 104 | } 105 | } 106 | 107 | extension Date { 108 | func timeString(ofStyle style: DateFormatter.Style = .medium) -> String { 109 | let dateFormatter = DateFormatter() 110 | dateFormatter.timeStyle = style 111 | dateFormatter.dateStyle = .none 112 | return dateFormatter.string(from: self) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Demo/Demo/Other/Utils/LyricsCutter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LyricsCutter.swift 3 | // Demo 4 | // 5 | // Created by ZYP on 2023/4/25. 6 | // 7 | 8 | import Foundation 9 | import AgoraLyricsScore 10 | 11 | class LyricsCutter { 12 | /// 每一行歌词的信息 13 | public struct Line { 14 | var beginTime: Int 15 | var duration: Int 16 | var endTime: Int { 17 | return beginTime + duration 18 | } 19 | } 20 | 21 | /// 处理时间副歌片段时间(对齐句子) 22 | /// - Parameters: 23 | /// - startTime: 副歌开始时间 24 | /// - endTime: 副歌结束时间 25 | /// - lines: 歌词所有行数据 26 | /// - Returns: (startTime, endTime), nil为无法处理 27 | public static func handleFixTime(startTime: Int, 28 | endTime: Int, 29 | lines: [Line]) -> (Int, Int)? { 30 | guard startTime < endTime else { 31 | return nil 32 | } 33 | 34 | guard startTime != endTime else { 35 | return nil 36 | } 37 | 38 | guard !lines.isEmpty else { 39 | return nil 40 | } 41 | 42 | var start = startTime 43 | var end = endTime 44 | 45 | if start < lines.first!.beginTime, 46 | end < lines.first!.beginTime { 47 | return nil 48 | } 49 | 50 | if start > lines.last!.endTime, 51 | end > lines.last!.endTime { 52 | return nil 53 | } 54 | 55 | /// 跨过第一个 56 | if start < lines.first!.beginTime, end < lines.first!.endTime { 57 | start = lines.first!.beginTime 58 | end = lines.first!.endTime 59 | return (start, end) 60 | } 61 | 62 | /// 跨过最后一个 63 | if start > lines.last!.beginTime, 64 | end > lines.last!.endTime { 65 | start = lines.last!.beginTime 66 | end = lines.last!.endTime 67 | return (start, end) 68 | } 69 | 70 | if start < lines.first!.beginTime { 71 | start = lines.first!.beginTime 72 | } 73 | 74 | if end > lines.last!.endTime { 75 | end = lines.last!.endTime 76 | } 77 | 78 | var startIndex = 0 79 | var startGap = Int.max 80 | var endIndex = 0 81 | var endGap = Int.max 82 | for (offset, line) in lines.enumerated() { 83 | if abs(line.beginTime - start) < startGap { 84 | startGap = abs(line.beginTime - start) 85 | startIndex = offset 86 | } 87 | if abs(line.endTime - end) < endGap { 88 | endGap = abs(line.endTime - end) 89 | endIndex = offset 90 | } 91 | } 92 | 93 | let result = (lines[startIndex].beginTime, lines[endIndex].endTime) 94 | if result.0 < result.1 { /** valid **/ 95 | return result 96 | } 97 | return nil 98 | } 99 | 100 | /// 裁剪副歌片段 101 | /// - Parameters: 102 | /// - model: 歌词模型 103 | /// - start: 副歌开始时间 104 | /// - end: 副歌结束时间 105 | /// - Returns: 副歌模型 106 | public static func cut(model: LyricModel, startTime: Int, endTime: Int) -> LyricModel { 107 | var lines = [LyricLineModel]() 108 | 109 | var flag = false 110 | for line in model.lines { 111 | if line.beginTime == startTime { 112 | flag = true 113 | } 114 | if line.beginTime + line.duration == endTime { 115 | lines.append(line) 116 | break 117 | } 118 | if flag { 119 | lines.append(line) 120 | } 121 | } 122 | 123 | var pitchDatas = [KrcPitchData]() 124 | for pitchData in model.pitchDatas { 125 | if pitchData.startTime >= startTime && pitchData.startTime + pitchData.duration <= endTime { 126 | pitchDatas.append(pitchData) 127 | } 128 | } 129 | model.pitchDatas = pitchDatas 130 | 131 | model.lines = lines 132 | model.preludeEndPosition = 0 133 | model.duration = (lines.last?.beginTime ?? 0) + (lines.last?.duration ?? 0) 134 | return model 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Class/Lyrics/FirstToneHintView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirstToneHintView.swift 3 | // AgoraLyricsScore 4 | // 5 | // Created by ZYP on 2022/12/22. 6 | // 7 | 8 | import UIKit 9 | 10 | public class FirstToneHintViewStyle: NSObject { 11 | /// 背景色 12 | @objc public var backgroundColor: UIColor = .gray { didSet { didUpdate?() } } 13 | /// 大小 14 | @objc public var size: CGFloat = 10 { didSet { didUpdate?() } } 15 | /// 底部间距 16 | @objc public var bottomMargin: CGFloat = 0 { didSet { didUpdate?() } } 17 | 18 | typealias VoidBlock = () -> Void 19 | 20 | var didUpdate: VoidBlock? 21 | } 22 | 23 | class FirstToneHintView: UIView { 24 | var style = FirstToneHintViewStyle() { didSet { updateUI() } } 25 | var mustHidden = false 26 | private let loadViews: [UIView] = [.init(), .init(), .init()] 27 | private var loadViewConstraints = [NSLayoutConstraint]() 28 | /// 剩余开始时间 ms 29 | private var remainingTime = 0 30 | private var lastRemainingTime = 0 31 | fileprivate let logTag = "FirstToneHintView" 32 | 33 | override init(frame: CGRect) { 34 | super.init(frame: frame) 35 | setupUI() 36 | } 37 | 38 | @available(*, unavailable) 39 | required init?(coder _: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | private func setupUI() { 44 | for view in loadViews { 45 | view.backgroundColor = style.backgroundColor 46 | view.layer.cornerRadius = style.size / 2 47 | view.layer.masksToBounds = true 48 | addSubview(view) 49 | view.translatesAutoresizingMaskIntoConstraints = false 50 | let widthConstraint = view.widthAnchor.constraint(equalToConstant: style.size) 51 | let heightConstraint = view.heightAnchor.constraint(equalToConstant: style.size) 52 | widthConstraint.isActive = true 53 | heightConstraint.isActive = true 54 | loadViewConstraints.append(widthConstraint) 55 | loadViewConstraints.append(heightConstraint) 56 | } 57 | 58 | loadViews[1].centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true 59 | loadViews[1].centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true 60 | 61 | loadViews[0].leftAnchor.constraint(equalTo: loadViews[1].rightAnchor, constant: 10).isActive = true 62 | loadViews[0].centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true 63 | 64 | loadViews[2].rightAnchor.constraint(equalTo: loadViews[1].leftAnchor, constant: -10).isActive = true 65 | loadViews[2].centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true 66 | isHidden = true 67 | } 68 | 69 | private func updateUI() { 70 | for view in loadViews { 71 | view.backgroundColor = style.backgroundColor 72 | view.layer.cornerRadius = style.size / 2 73 | } 74 | 75 | for constraint in loadViewConstraints { 76 | constraint.constant = style.size 77 | } 78 | } 79 | 80 | func updateStyle(style: FirstToneHintViewStyle) { 81 | self.style = style 82 | updateUI() 83 | } 84 | 85 | /// 设置剩余开始时间 (前奏时间 - 当前歌曲进度) 86 | /// - Parameter time: 剩余开始唱第一句的时间 87 | func setRemainingTime(time: Int) { 88 | if time < 0 { 89 | reset() 90 | return 91 | } 92 | 93 | /** 过滤,500ms设置一次 **/ 94 | if lastRemainingTime == 0 { 95 | lastRemainingTime = time 96 | } 97 | if lastRemainingTime - time < 500 { 98 | return 99 | } 100 | lastRemainingTime = time 101 | 102 | remainingTime = time 103 | Log.info(text: "remainingTime: \(remainingTime)", tag: logTag) 104 | loadViews[0].isHidden = (remainingTime >= 3 * 1000) ? !loadViews[0].isHidden : true 105 | loadViews[1].isHidden = !(remainingTime >= 2 * 1000) 106 | loadViews[2].isHidden = !(remainingTime >= 1 * 1000) 107 | } 108 | 109 | func reset() { 110 | isHidden = true 111 | self.loadViews[0].isHidden = false 112 | self.loadViews[1].isHidden = false 113 | self.loadViews[2].isHidden = false 114 | lastRemainingTime = 0 115 | remainingTime = 0 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /AgoraLyricsScore/Tests/Resource/4875936889260991133.krc: -------------------------------------------------------------------------------- 1 | [id:$00000000] 2 | [ar:陈奕迅] 3 | [ti:十年] 4 | [by:] 5 | [hash:180f842eeada43cad376a20db1cb5ec6] 6 | [al:] 7 | [sign:] 8 | [qq:] 9 | [total:202684] 10 | [offset:0] 11 | [language:eyJjb250ZW50IjpbXSwidmVyc2lvbiI6MX0=] 12 | [1067,653]<0,502,0>陈<502,50,0>奕<552,0,0>迅 <552,50,0>- <602,51,0>十<653,0,0>年 13 | [1720,150]<0,50,0>作<50,50,0>词:<100,0,0>林<100,50,0>夕 14 | [1870,303]<0,51,0>作<51,0,0>曲:<51,52,0>陈<103,50,0>小<153,150,0>霞 15 | [2173,5238]<0,253,0>编<253,150,0>曲:<403,205,0>陈<608,303,0>辉<911,4327,0>阳 16 | [15106,3841]<0,300,0>如<300,302,0>果<602,352,0>那<954,452,0>两<1406,454,0>个<1860,504,0>字<2364,420,0>没<2784,201,0>有<2985,252,0>颤<3237,604,0>抖 17 | [19044,3594]<0,237,0>我<237,252,0>不<489,757,0>会<1246,202,0>发<1448,453,0>现 <1901,352,0>我<2253,285,0>难<2538,1056,0>受 18 | [22638,3218]<0,152,0>怎<152,200,0>么<352,150,0>说<502,254,0>出<756,2462,0>口 19 | [25955,3361]<0,287,0>也<287,253,0>不<540,199,0>过<739,203,0>是<942,353,0>分<1295,2066,0>手 20 | [30572,4074]<0,252,0>如<252,251,0>果<503,401,0>对<904,454,0>于<1358,502,0>明<1860,555,0>天<2415,251,0>没<2666,202,0>有<2868,200,0>要<3068,1006,0>求 21 | [34724,2897]<0,180,0>牵<180,402,0>牵<582,504,0>手<1086,252,0>就<1338,253,0>像<1591,301,0>旅<1892,1005,0>游 22 | [37621,2921]<0,202,0>成<202,201,0>千<403,202,0>上<605,251,0>万<856,302,0>个<1158,302,0>门<1460,1461,0>口 23 | [41673,2768]<0,151,0>总<151,201,0>有<352,201,0>一<553,251,0>个<804,204,0>人<1008,452,0>要<1460,304,0>先<1764,1004,0>走 24 | [47453,3272]<0,149,0>怀<149,353,0>抱<502,202,0>既<704,303,0>然<1007,453,0>不<1460,251,0>能<1711,503,0>逗<2214,1058,0>留 25 | [50725,2872]<0,202,0>何<202,201,0>不<403,202,0>在<605,252,0>离<857,253,0>开<1110,252,0>的<1362,252,0>时<1614,1258,0>候 26 | [53597,5785]<0,203,0>一<203,202,0>边<405,302,0>享<707,1861,0>受 <2568,656,0>一<3224,703,0>边<3927,552,0>泪<4479,1306,0>流 27 | [60854,1863]<0,151,0>十<151,201,0>年<352,705,0>之<1057,806,0>前 28 | [62717,1863]<0,454,0>我<454,201,0>不<655,252,0>认<907,250,0>识<1157,706,0>你 29 | [64580,2062]<0,554,0>你<554,201,0>不<755,252,0>属<1007,252,0>于<1259,803,0>我 30 | [66642,2416]<0,252,0>我<252,405,0>们<657,201,0>还<858,302,0>是<1160,401,0>一<1561,855,0>样 31 | [69058,3733]<0,203,0>陪<203,202,0>在<405,301,0>一<706,406,0>个<1112,251,0>陌<1363,252,0>生<1615,452,0>人<2067,302,0>左<2369,1364,0>右 32 | [72791,3578]<0,302,0>走<302,202,0>过<504,302,0>渐<806,655,0>渐<1461,254,0>熟<1715,505,0>悉<2220,251,0>的<2471,453,0>街<2924,654,0>头 33 | [76369,1713]<0,202,0>十<202,201,0>年<403,605,0>之<1008,705,0>后 34 | [78082,1968]<0,403,0>我<403,253,0>们<656,302,0>是<958,303,0>朋<1261,707,0>友 35 | [80050,2115]<0,552,0>还<552,251,0>可<803,302,0>以<1105,253,0>问<1358,757,0>候 36 | [82165,2367]<0,403,0>只<403,253,0>是<656,201,0>那<857,302,0>种<1159,403,0>温<1562,805,0>柔 37 | [84532,3776]<0,252,0>再<252,202,0>也<454,251,0>找<705,353,0>不<1058,351,0>到<1409,203,0>拥<1612,454,0>抱<2066,302,0>的<2368,503,0>理<2871,905,0>由 38 | [88308,6101]<0,251,0>情<251,355,0>人<606,203,0>最<809,604,0>后<1413,303,0>难<1716,452,0>免<2168,402,0>沦<2570,1514,0>为<4084,555,0>朋<4639,1462,0>友 39 | [117010,3272]<0,202,0>怀<202,352,0>抱<554,201,0>既<755,302,0>然<1057,455,0>不<1512,252,0>能<1764,604,0>逗<2368,904,0>留 40 | [120282,2968]<0,202,0>何<202,201,0>不<403,251,0>在<654,202,0>离<856,301,0>开<1157,251,0>的<1408,253,0>时<1661,1307,0>候 41 | [123250,6399]<0,202,0>一<202,200,0>边<402,302,0>享<704,1867,0>受 <2571,704,0>一<3275,705,0>边<3980,504,0>泪<4484,1915,0>流 42 | [130478,1967]<0,252,0>十<252,151,0>年<403,755,0>之<1158,809,0>前 43 | [132445,1965]<0,503,0>我<503,202,0>不<705,252,0>认<957,252,0>识<1209,756,0>你 44 | [134410,1911]<0,404,0>你<404,251,0>不<655,200,0>属<855,303,0>于<1158,753,0>我 45 | [136321,2368]<0,402,0>我<402,253,0>们<655,152,0>还<807,302,0>是<1109,453,0>一<1562,806,0>样 46 | [138689,3772]<0,201,0>陪<201,201,0>在<402,251,0>一<653,454,0>个<1107,201,0>陌<1308,252,0>生<1560,604,0>人<2164,200,0>左<2364,1408,0>右 47 | [142461,3574]<0,202,0>走<202,252,0>过<454,202,0>渐<656,858,0>渐<1514,201,0>熟<1715,503,0>悉<2218,251,0>的<2469,502,0>街<2971,603,0>头 48 | [146035,3677]<0,152,0>十<152,252,0>年<404,605,0>之<1009,704,0>后 <1713,502,0>我<2215,254,0>们<2469,202,0>是<2671,252,0>朋<2923,754,0>友 49 | [149712,4484]<0,506,0>还<506,201,0>可<707,251,0>以<958,252,0>问<1210,805,0>候 <2015,454,0>只<2469,250,0>是<2719,202,0>那<2921,253,0>种<3174,453,0>温<3627,857,0>柔 50 | [154196,3875]<0,252,0>再<252,251,0>也<503,252,0>找<755,354,0>不<1109,200,0>到<1309,301,0>拥<1610,453,0>抱<2063,251,0>的<2314,554,0>理<2868,1007,0>由 51 | [158071,7859]<0,201,0>情<201,203,0>人<404,202,0>最<606,706,0>后<1312,251,0>难<1563,557,0>免<2120,303,0>沦<2423,1560,0>为<3983,553,0>朋<4536,3323,0>友 52 | [168121,4082]<0,202,0>直<202,152,0>到<354,503,0>和<857,455,0>你<1312,452,0>做<1764,554,0>了<2318,202,0>多<2520,253,0>年<2773,251,0>朋<3024,1058,0>友 53 | [172203,2867]<0,150,0>才<150,452,0>明<602,603,0>白<1205,201,0>我<1406,253,0>的<1659,301,0>眼<1960,907,0>泪 54 | [175070,3573]<0,202,0>不<202,251,0>是<453,352,0>为<805,251,0>你<1056,252,0>而<1308,2265,0>流 55 | [178917,202684]<0,202,0>也<202,202,0>为<404,254,0>别<658,252,0>人<910,453,0>而<1363,1715,0>流 56 | --------------------------------------------------------------------------------