前言
要解析 lrc
格式的歌詞, 首先需要知道什么是 lrc
歌詞, 還需要知道 lrc
歌詞的規(guī)范. 在這里先放出一個(gè)百度百科
的鏈接地址, 僅供大家參考: 百度百科: lrc
關(guān)于本文
本文的歌詞解析部分, 僅僅針對(duì)標(biāo)準(zhǔn)的 lrc
格式歌詞進(jìn)行解析, 像我們常用的 QQ音樂(lè) 的 qrc
等歌詞并未進(jìn)行解析. lrc
的標(biāo)準(zhǔn)格式也不僅有一種(例如時(shí)間標(biāo)簽: [00:01.02]
、[0:1:12]
冰啃、[00:02]
), 本文對(duì)所有標(biāo)準(zhǔn)樣式的 lrc
歌詞都進(jìn)行了解析.
本文的歌詞解析部分, 由于 Demo
中用不到, 所以針對(duì) [ar:藝人名]
、[ti:曲名]
竞川、[al:專輯名]
等這類的 識(shí)別標(biāo)簽
做了過(guò)濾的處理, 并未予以解析.
本文的歌詞展示部分, 采用的是模擬 拉卡OK
的播放樣式(類似 QQ音樂(lè)), 也就是說(shuō)歌詞會(huì)一步一步的高亮顯示, 而不是直接正行的高亮顯示. 為什么說(shuō)是模擬 卡拉OK
的播放樣式呢? 原因在于解析的是標(biāo)準(zhǔn) lrc
歌詞, 它不包含每一個(gè)字的毫秒值, 所以也就無(wú)法做到歌曲唱到哪個(gè)字, 哪個(gè)字就高亮顯示.
關(guān)于Demo
要 Demo
的小伙伴在留言處留下你的郵箱吧, 我有點(diǎn)懶得上傳 Github
了. 抱歉!
效果
俗話說(shuō)得好, 千言萬(wàn)語(yǔ)不如上幾張動(dòng)圖來(lái)的實(shí)在. 先來(lái)看看效果.
普通播放音樂(lè)
切換歌曲
歌詞定位
進(jìn)度條定位
正文
好了, 效果看完了, 現(xiàn)在來(lái)簡(jiǎn)單描述一下歌詞解析的部分. 先來(lái)看看 .h
里面公開(kāi)的方法, 如下:
/** 解析歌詞. 默認(rèn): 解析 .lrc 歌詞, Bundle 為 MainBundle */
+ (NSArray<NSDictionary *> *)ml_parseLyricWithFileName:(NSString *)fileName;
+ (NSArray<NSDictionary *> *)ml_parseLyricWithFileName:(NSString *)fileName
type:(NSString *)type;
+ (NSArray<NSDictionary *> *)ml_parseLyricWithFileName:(NSString *)fileName
type:(NSString *)type
inBundle:(NSBundle *)bundle;
/** 通過(guò)歌詞字符串, 解析歌詞 */
+ (NSArray<NSDictionary *> *)ml_parseLyricWithLyricString:(NSString *)lyricString;
前三個(gè)方法是用來(lái)加載和解析本地歌詞的, 第四個(gè)方法主要是考慮后臺(tái)直接返回歌詞字符串的情況下, 直接可以使用該方法進(jìn)行解析.
返回值是一個(gè)由 NSDictionary
組成的數(shù)組, 其中每一個(gè) NSDictionary
元素包含的 Key
如下所示:
/** 歌詞解析返回 NSDictionary 的 Key */
static NSString *const kMLLyricScrollView_LyricParse_Key_Time = @"MLParseLyricKey_Time";
static NSString *const kMLLyricScrollView_LyricParse_Key_TimeString = @"MLParseLyricKey_TimeString";
static NSString *const kMLLyricScrollView_LyricParse_Key_TimeInterval = @"MLParseLyricKey_TimeInterval";
static NSString *const kMLLyricScrollView_LyricParse_Key_LyricContent = @"MLParseLyricKey_LyricContent";
具體這個(gè) NSDictionary
數(shù)組怎么使用, 下文中會(huì)詳細(xì)進(jìn)行介紹.
拿到 Demo
的同學(xué), 從代碼中不難看出, 其實(shí)主要的代碼邏輯都集中在 + (NSArray<NSDictionary *> *)ml_parseLyricWithLyricString:(NSString *)lyricString;
方法中(上方代碼塊中的第四個(gè)方法), 所以在這里, 我就先從這個(gè)方法入手, 來(lái)說(shuō)說(shuō)解析歌詞的代碼邏輯.
在直接進(jìn)入這個(gè)方法之前, 我先上一段 lrc
, 這更有助于記憶和理解.
Lyric Demo
[ti:剛好遇見(jiàn)你]
[ar:李玉剛]
[al:]
[by:黎起錚]
匹配時(shí)間為: 03 分 20 秒 的歌曲
[0:0.0]
[0:2]剛好遇見(jiàn)你
[0:3.65]作詞:高進(jìn)
[0:4.61]作曲:高進(jìn)
[0:5.65]演唱:李玉剛
[0:6.66]編曲:關(guān)天天
[0:13.35][1:14:75]我們哭了
[0:16.12][1:17:53]我們笑著
[0:19.21][1:20:71]我們抬頭望天空
[0:22.6][1:23:54]星星還亮著幾顆
[0:25.29][1:26:56]我們唱著
[0:28.23][1:29:73]時(shí)間的歌
[0:31.45][1:33:4]才懂得相互擁抱
[0:34.47][1:35:83]到底是為了什么
[00:37.48][01:38.96][02:31.50]因?yàn)槲覄偤糜鲆?jiàn)你
[00:41.3][01:42.36][2:7.36][02:34.74]留下足跡才美麗
[00:44.3][01:45.44][02:10.14][02:37.80]風(fēng)吹花落淚如雨
[00:47.16][01:48.53][02:13.11][02:40.76]因?yàn)椴幌敕蛛x
[00:50.32][01:51.65][02:4.72][02:16.22][02:43.95]因?yàn)閯偤糜鲆?jiàn)你
[00:53.26][01:54.69][02:19.27][02:46.97]留下十年的期許
[00:56.25][01:57.72][02:22.32][02:49.96]如果再相遇
[00:59.68][02:01.5][02:25.79][02:53.28]我想我會(huì)記得你
[02:57]
[03:14]Made By Liguoan
[03:20]
其實(shí)上面這段歌詞是我自己編輯的, 說(shuō)實(shí)在的, 它應(yīng)該算是標(biāo)準(zhǔn) lrc
歌詞里面最不標(biāo)準(zhǔn)的一種了. 主要是為了代碼的測(cè)試, 所以刻意把它寫(xiě)的很不標(biāo)準(zhǔn)(在標(biāo)準(zhǔn) lrc
歌詞的范圍內(nèi)). 首先可以看到, 這段歌詞中包含 Lyric Demo
非洲、匹配時(shí)間為: 03 分 20 秒 的歌曲
這樣的注釋性質(zhì)的文字, 其次, 仔細(xì)觀察時(shí)間標(biāo)簽, 五花八門(mén), 什么樣子的都有, 例如: [0:2]
、[0:6.66]
、[1:17:53]
拐纱、[02:31.50]
等. 最后, 一句歌詞, 不同的觸發(fā)時(shí)間, 寫(xiě)在同一行中, 這樣的歌詞解析出來(lái)最大的問(wèn)題就是無(wú)序(歌詞無(wú)序, 也在 lrc
標(biāo)準(zhǔn)范圍內(nèi)). 所以基本上, 如果上面這段 lrc
歌詞解析成功的話, 那其它在網(wǎng)上找的 lrc
也好, 后臺(tái)給的 lrc
也罷, 只要它在標(biāo)準(zhǔn) lrc
歌詞范圍內(nèi), 那我們的 App
應(yīng)該都可以完美的解析.
好了, lrc
歌詞也了解了, 來(lái)看看解析歌詞的代碼吧.
Step 1
用 \n
字符, 將歌詞字符串分割成為一個(gè)字符串?dāng)?shù)組, 聲明一個(gè) result
變量用來(lái)保存最后的結(jié)果, 聲明一個(gè) arrFilter
變量, 用來(lái)過(guò)濾掉所有的 識(shí)別標(biāo)簽
:
// 解析歌詞, 先通過(guò) "\n" 回車字符, 將 lyric 字符串分割成字符串?dāng)?shù)組
NSMutableArray<NSMutableDictionary *> *result = [NSMutableArray array];
NSArray *arrLycComponents = [lyricString componentsSeparatedByString: @"\n"];
// 設(shè)置標(biāo)簽過(guò)濾數(shù)組. Note: 本段代碼中, 只解析了時(shí)間標(biāo)簽, 其他標(biāo)記標(biāo)簽沒(méi)解析
NSArray<NSString *> *arrFilter = @[@"[ar", @"[ti", @"[al", @"[by", @"[of", @"t_"];
Step 2
For...in
循環(huán), 遍歷歌詞字符串?dāng)?shù)組中的所有內(nèi)容, 并對(duì)每一條遍歷出來(lái)的內(nèi)容進(jìn)行處理 (Step 2
代碼邏輯全在 For..in
循環(huán)中).
Step 2.1
// 解析歌詞部分
for (NSString *lyric in arrLycComponents) {
// 空句直接進(jìn)入下一次循環(huán).
if (!lyric.length) continue;
// 歌詞不以 "[" 開(kāi)頭為非法格式, 直接進(jìn)入下一次循環(huán)
if (![lyric hasPrefix: @"["]) continue;
// 過(guò)濾標(biāo)簽, 如果是標(biāo)簽則直接進(jìn)入下一次循環(huán).
BOOL needFilter = NO;
for (NSString *filter in arrFilter) {
if ([lyric hasPrefix: filter]) {
needFilter = YES;
break;
}
}
if (needFilter) continue;
...
}
判斷是否為空句, 對(duì)歌詞中的空句不予以解析.
標(biāo)準(zhǔn) lrc
歌詞, 都應(yīng)以標(biāo)簽作為開(kāi)頭, 所以過(guò)濾掉所有不合法的 lrc
歌詞語(yǔ)句.
由于本文中, 對(duì) 識(shí)別標(biāo)簽
不予以處理, 所以會(huì)通過(guò) arrFilter
數(shù)組, 將所有的 識(shí)別標(biāo)簽
進(jìn)行過(guò)濾.
Step 2.2
// 解析歌詞部分
for (NSString *lyric in arrLycComponents) {
...
if (![lyric containsString: @"]"]) continue;
NSMutableArray<NSString *> *lrcComponents = [NSMutableArray arrayWithArray: [lyric componentsSeparatedByString: @"]"]];
...
}
通過(guò) ]
字符分割字符串, 是為了解決一句歌詞有很多觸發(fā)時(shí)間點(diǎn)的情況.
分割后的 lrcComponents
有以下幾種情況:
歌詞1: [00:53.00][01:43.88][02:11.23]雖然無(wú)所謂寫(xiě)在臉上
分割后的樣子例如: @[@"[00:53.00", @"[01:43.88", @"[02:11.23", @"雖然無(wú)所謂寫(xiě)在臉上"]
歌詞2: [00:34.57]怎么慢它停也停不了
分割后的樣子例如: @[@"[00:34.57", @"怎么慢它停也停不了"]
歌詞3: [00:48.50][01:29.73]
分割后的樣子例如: @[@"[00:48.50", @"[01:29.73"]
Step 2.3
// 解析歌詞部分
for (NSString *lyric in arrLycComponents) {
...
NSString *lastComponent = [lrcComponents lastObject];
if ([lastComponent hasPrefix: @"["]) { // 最后一部分應(yīng)該是內(nèi)容部分, 不應(yīng)該是 "[" 字符開(kāi)頭.
[lrcComponents addObject: @""];
}
...
}
由于有 Step2.2
中 歌詞3
這種類型的歌詞, 也就是有時(shí)間點(diǎn), 但是沒(méi)有內(nèi)容的歌詞(可能是間奏), 我們需要專門(mén)處理這種類型的歌詞. lrcComponents
這個(gè)數(shù)組的最后一部分, 應(yīng)該為歌詞的內(nèi)容, 所以如果最后一部分不是歌詞內(nèi)容的話, 需要手動(dòng)添加一個(gè)空字符串, 作為歌詞的內(nèi)容.
處理完的 lrcComponents 的樣子應(yīng)該如下(最后一部分, 永遠(yuǎn)是歌詞部分):
@[@"[00:53.00", @"[01:43.88", @"[02:11.23", @"雖然無(wú)所謂寫(xiě)在臉上"]
@[@"[00:34.57", @"怎么慢它停也停不了"]
@[@"[00:48.50", @"[01:29.73", @""]
Step 2.4
// 解析歌詞部分
for (NSString *lyric in arrLycComponents) {
...
for (NSInteger index=0; index<lrcComponents.count-1; index++) {
lrcComponents[index] = [lrcComponents[index] stringByReplacingOccurrencesOfString: @"["
withString: @""];
}
...
}
在 Step 2.4
中, 我們需要將 lrcComponents
字符串?dāng)?shù)組中每個(gè)元素的 [
字符去除.
處理完的 lrcComponents 的樣子應(yīng)該如下:
@[@"00:53.00", @"01:43.88", @"02:11.23", @"雖然無(wú)所謂寫(xiě)在臉上"]
@[@"00:34.57", @"怎么慢它停也停不了"]
@[@"00:48.50", @"01:29.73", @""]
可以看到, 到目前為止, 我們已經(jīng)將歌詞基本解析出來(lái)了, 不必要的字符都也已經(jīng)刪除掉了. 接下來(lái), 就是將字符串?dāng)?shù)組中的歌詞, 拼接成字典, 保存到 result
這個(gè)數(shù)組中.
Step 2.5
// 解析歌詞部分
for (NSString *lyric in arrLycComponents) {
...
for (NSInteger index=0; index<lrcComponents.count-1; index++) {
NSMutableDictionary *lyricDict = [self ml_lyricDictWithContent: [lrcComponents lastObject]
triggerTimeString: lrcComponents[index]];
if (!lyricDict) continue;
...
}
...
}
由于一句歌詞, 可能對(duì)應(yīng)不同的時(shí)間點(diǎn), 所以在 Step 2.5
中, 使用 For
循環(huán), 遍歷每一個(gè)時(shí)間點(diǎn), 然后進(jìn)行字典的拼接. 如果字典拼接失敗(也就是 lyricDict
為空的時(shí)候), 說(shuō)明該歌詞語(yǔ)句存在不合法的情況, 則直接跳過(guò)本次循環(huán), 進(jìn)入下一句歌詞的解析.
可以看到, Step 2.5
中有一個(gè)方法 + (NSMutableDictionary *)ml_lyricDictWithContent:(NSString *)lyricContent triggerTimeString:(NSString *)triggerTimeString
, 該方法用來(lái)拼接字典, 代碼邏輯的實(shí)現(xiàn)如下:
+ (NSMutableDictionary *)ml_lyricDictWithContent:(NSString *)lyricContent triggerTimeString:(NSString *)triggerTimeString {
// 嘗試獲取時(shí)間. 如果時(shí)間不存在或格式不符合 直接返回 nil
// Note: 歌詞格式支持如下幾種(lrc 標(biāo)準(zhǔn)格式均支持)
// 1. [mm:ss.ff]
// 2. [m:s.f]
// 3. [mm:ss:f]
// 4. [mm:ss:ff]
// 5. [mm:ss]
// 通過(guò) ":" 分割, 如果分割后的數(shù)組元素個(gè)數(shù)小于2或者大于3, 說(shuō)明格式有誤, 返回 nil
NSMutableArray *timeComponent = [NSMutableArray arrayWithArray: [triggerTimeString componentsSeparatedByString: @":"]];
if (timeComponent.count<2 || timeComponent.count>3) return nil;
// 如果 timeCompoent 數(shù)量等于2, 說(shuō)明可能為上面的 第1、第2哥倔、第5種情況, 所以需要嘗試用 "." 進(jìn)行毫秒分割
if (timeComponent.count == 2) {
NSString *secondComponent = [timeComponent lastObject];
NSArray *subComponent = [secondComponent componentsSeparatedByString: @"."];
if (subComponent.count>2) return nil;
if (subComponent.count == 2) {
[timeComponent removeObject: secondComponent];
[timeComponent addObjectsFromArray: subComponent];
} else {
[timeComponent addObject: @"00"];
}
}
// 如果 timeCompoent 元素?cái)?shù)量不等于3, 說(shuō)明解析有誤, 直接進(jìn)入下一次循環(huán)
if (timeComponent.count != 3) return nil;
// 分別獲取分秸架、秒、毫秒
NSString *min = [timeComponent firstObject];
NSString *sec = timeComponent[1];
NSString *mm = [timeComponent lastObject];
if (![self ml_isValidTimeString: min] ||
![self ml_isValidTimeString: sec] ||
![self ml_isValidTimeString: mm ]) return nil; // 處理 [0x0C:-34.50] 這種非法情況
if (min.length == 1) min = [NSString stringWithFormat:@"0%@", min];
if (sec.length == 1) sec = [NSString stringWithFormat:@"0%@", sec];
if (mm.length == 1) mm = [NSString stringWithFormat:@"0%@", mm];
// 獲取 歌詞觸發(fā)時(shí)間字符串咆蒿、 歌詞觸發(fā)時(shí)間东抹、 歌詞內(nèi)容
triggerTimeString = [NSString stringWithFormat: @"%@:%@.%@", min, sec, mm]; // 該句歌詞的時(shí)間字符串.
NSTimeInterval time = [min integerValue] * 60 + [sec integerValue] + [mm integerValue]/100.0f; // 該句歌詞的時(shí)間.
return [NSMutableDictionary dictionaryWithDictionary:@{
kMLLyricScrollView_LyricParse_Key_TimeString:triggerTimeString,
kMLLyricScrollView_LyricParse_Key_Time:@(time),
kMLLyricScrollView_LyricParse_Key_LyricContent:lyricContent
}];
}
這部分代碼塊, 看起來(lái)比較多, 不過(guò)注釋都已經(jīng)非常清晰了, 在這里就不做過(guò)多的贅述了, 大家看代碼就好了, 其實(shí)很簡(jiǎn)單. 不過(guò)要說(shuō)一下的是, 代碼塊中包含了一個(gè) + (BOOL)ml_isValidTimeString:(NSString *)timeString
方法, 主要是用來(lái)判斷是否是正確的時(shí)間格式, 通過(guò)這個(gè)方法來(lái)過(guò)濾掉 [0x0C:-34.50]
這種非法的時(shí)間語(yǔ)句.
Step 2.6
// 解析歌詞部分
for (NSString *lyric in arrLycComponents) {
...
for (NSInteger index=0; index<lrcComponents.count-1; index++) {
...
// 在將歌詞添加到數(shù)組之前, 需要考慮下面這種情況 ??
// 有些歌詞第一句沒(méi)內(nèi)容, 所以在這里如果第一句沒(méi)內(nèi)容, 則不添加到數(shù)組中. (result.count 為0, 則代表第一句)
// 如果第一句有內(nèi)容, 為了歌詞的顯示效果, 第一句的時(shí)間應(yīng)該為 [00:00:00].
if (!result.count) {
if (![lrcComponents lastObject].length) continue;
// 修改觸發(fā)時(shí)間
lyricDict[kMLLyricScrollView_LyricParse_Key_TimeString] = @"00:00.00";
lyricDict[kMLLyricScrollView_LyricParse_Key_Time] = @(0);
}
// 保存在數(shù)組中
[result addObject: lyricDict];
}
...
}
Step 3
// 解析歌詞部分
...
// 如果歌詞解析失敗, 直接返回空
if (!result.count) return nil;
// 由于處理了一句歌詞多個(gè)觸發(fā)時(shí)間的歌詞類型, 所以數(shù)組中的內(nèi)容并非有序, 需要做排序操作.
[result sortUsingComparator:^NSComparisonResult(NSMutableDictionary *_Nonnull obj1, NSMutableDictionary *_Nonnull obj2) {
return [obj1[kMLLyricScrollView_LyricParse_Key_Time] integerValue] > [obj2[kMLLyricScrollView_LyricParse_Key_Time] integerValue];
}];
...
Step 4
// 解析歌詞部分
...
// 計(jì)算每一句歌詞的播放時(shí)長(zhǎng)
NSMutableDictionary *lastLyricDict = nil;
for (NSMutableDictionary *lyricDict in result) {
if (lastLyricDict) {
NSTimeInterval timeInterval = [lyricDict[kMLLyricScrollView_LyricParse_Key_Time] integerValue] - [lastLyricDict[kMLLyricScrollView_LyricParse_Key_Time] integerValue];
lastLyricDict[kMLLyricScrollView_LyricParse_Key_TimeInterval] = @(timeInterval);
}
lastLyricDict = lyricDict;
}
// 最后一句歌詞如果沒(méi)有歌詞內(nèi)容, 則刪除掉最后一句
if (![lastLyricDict[kMLLyricScrollView_LyricParse_Key_LyricContent] length]) {
[result removeLastObject];
}
截止到 Step 4
, 歌詞解析部分基本上已經(jīng)完畢了. 接下來(lái)就是將該方法返回的 NSDictionary
數(shù)組轉(zhuǎn)換成數(shù)據(jù)模型的操作. 然后顯示歌詞, Demo
中顯示歌詞使用的是 MLLyricScrollView.h
類, 想要成為這個(gè)歌詞視圖的數(shù)據(jù)源, 需要遵守以下協(xié)議, 協(xié)議內(nèi)容如下:
/** 作為 MLLyricScrollView 數(shù)據(jù)源的數(shù)據(jù)模型, 必須遵守本協(xié)議 */
@protocol MLLyricScrollViewDataSourceProtocol <NSObject>
/** 返回歌詞內(nèi)容 */
- (NSString *)ml_lyricContent;
/** 當(dāng)前這句歌詞的播放時(shí)長(zhǎng) */
- (NSTimeInterval)ml_timeInterval;
/** 當(dāng)前這句歌詞的觸發(fā)時(shí)間 */
- (NSTimeInterval)ml_triggerTime;
@end
任何一個(gè)遵守 MLLyricScrollViewDataSourceProtocol
協(xié)議的數(shù)據(jù)模型, 都可以作為 MLLyricScrollView
的數(shù)據(jù)源. 具體的代碼邏輯的實(shí)現(xiàn)部分, 在本文中就不做贅述了, 感興趣的小伙伴留下自己的郵箱, 我會(huì)將 Demo
發(fā)給你.
結(jié)語(yǔ)
文章寫(xiě)得比較匆忙, 難免會(huì)有表述不清或者偏差的地方, 有什么問(wèn)題請(qǐng)幫我指出, 我會(huì)盡快修改, 謝謝!
Lemon龍說(shuō):
如果您在文章中看到了錯(cuò)誤 或 誤導(dǎo)大家的地方, 請(qǐng)您幫我指出, 我會(huì)盡快更改
如果您有什么疑問(wèn)或者不懂的地方, 請(qǐng)留言給我, 我會(huì)盡快回復(fù)您
如果您覺(jué)得本文對(duì)您有所幫助, 您的喜歡是對(duì)我最大的鼓勵(lì)
如果您有好的文章, 可以投稿給我, 讓更多的 iOS Developer 在簡(jiǎn)書(shū)這個(gè)平臺(tái)能夠更快速的成長(zhǎng)