怀念经典,一步步开发一个ttplayer千千静听,歌词查找

LRCParser 源码完整分析
LRCParser 是用于解析 LRC(歌词)文件的工具类,支持标准 LRC 格式([mm:ss.xx] 时间标签)、增强型 LRC(逐词卡拉 OK,通过 <时间戳> 标记)、ID3 元信息标签([ti:], [ar:] 等),并具备自动字符集检测功能,以正确处理 GBK、UTF-8、EUC-KR 等编码的歌词文件。
1. 包声明与导入
package org.ttplayer.lyrics;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
- 导入
java.nio.file.*用于Files.readAllLines等简化文件操作。 - 导入
java.util.regex.*用于正则匹配时间标签和 ID 标签。
2. 正则表达式常量
private static final Pattern LRC_TAG = Pattern.compile("\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]");
private static final Pattern ID_TAG = Pattern.compile("\\[(ti|ar|al|by|offset):(.*)\\]", Pattern.CASE_INSENSITIVE);
2.1 LRC_TAG —— 标准时间标签
- 模式:
\[(\d+):(\d+(?:\.\d+)?)\] - 含义:
\[和\]:匹配方括号[和]。(\d+):捕获组 1,匹配分钟(整数)。::匹配冒号。(\d+(?:\.\d+)?):捕获组 2,匹配秒数,支持整数(如05)或带小数的浮点数(如05.50),(?:\.\d+)?为非捕获组,表示可选的小数部分。
- 示例匹配:
[03:45.50]→ 组1="03",组2="45.50";[03:45]→ 组1="03",组2="45"。
2.2 ID_TAG —— 元信息标签
- 模式:
\[(ti|ar|al|by|offset):(.*)\],忽略大小写。 - 含义:
(ti|ar|al|by|offset):捕获组 1,匹配标签类型(标题、艺术家、专辑、制作人、偏移量)。::匹配冒号。(.*):捕获组 2,匹配标签值(直到行尾)。
- 示例匹配:
[ar:周杰伦]→ 组1="ar",组2="周杰伦"。
3. 成员变量
private long offsetMs;
- 存储从
[offset:]标签中解析出的全局偏移量(毫秒)。偏移量会累加到所有时间标签上,用于整体调整歌词时间轴。
4. 内部类 LRCData
public static class LRCData {
public List<LRCLine> lines;
public String title;
public String artist;
public String album;
public long offsetMs;
LRCData() {
lines = new ArrayList<>();
}
}
- 作为解析结果的容器:
lines:LRCLine对象列表,按时间排序。title,artist,album:从 ID 标签提取的元信息。offsetMs:解析出的偏移量(毫秒)。
5. 公开解析方法(入口)
5.1 parse(File file) throws IOException
public LRCData parse(File file) throws IOException {
return parse(file, detectCharset(file));
}
- 调用
detectCharset(file)自动检测文件字符集,然后调用parse(File, Charset)。
5.2 parse(File file, Charset charset) throws IOException
public LRCData parse(File file, Charset charset) throws IOException {
List<String> lines = Files.readAllLines(file.toPath(), charset);
return parseLines(lines);
}
- 使用指定字符集读取所有行,委托给
parseLines(List<String>)。
5.3 parse(String content)
public LRCData parse(String content) {
return parseLines(Arrays.asList(content.split("\\r?\\n")));
}
- 直接解析字符串内容(用于从网络或其他来源获取的歌词文本)。
6. 核心解析逻辑 parseLines(List<String> lines)
这是整个类最核心的方法,逐行解析 LRC 文本。
6.1 初始化
LRCData data = new LRCData();
offsetMs = 0;
List<LRCLine> allLines = new ArrayList<>();
- 重置
offsetMs为 0(每次解析独立)。 - 使用临时列表
allLines收集所有解析出的LRCLine。
6.2 主循环:逐行处理
for (String line : lines) {
line = line.trim();
if (line.isEmpty()) continue;
// ...
}
- 去除行首尾空白,空行跳过。
6.2.1 ID 标签处理
Matcher idMatcher = ID_TAG.matcher(line);
if (idMatcher.matches()) {
String tag = idMatcher.group(1).toLowerCase();
String value = idMatcher.group(2).trim();
switch (tag) {
case "ti": data.title = value; break;
case "ar": data.artist = value; break;
case "al": data.album = value; break;
case "offset":
try { offsetMs = Long.parseLong(value); } catch (NumberFormatException ignored) {}
break;
}
continue;
}
- 若行匹配 ID 标签,提取标签类型和值。
[offset:]的值会解析为long,存入成员变量offsetMs(后续时间标签会加上此偏移)。- 处理完后
continue,不进入时间标签解析流程。
6.2.2 提取尾部文本(trailingText)
String trailingText = "";
Matcher lastTag = LRC_TAG.matcher(line);
int lastEnd = -1;
while (lastTag.find()) lastEnd = lastTag.end();
if (lastEnd >= 0) trailingText = line.substring(lastEnd).trim();
- 关键逻辑:查找行中最后一个时间标签的结束位置。
- 若存在时间标签,将最后一个标签之后的文本提取为
trailingText(去除首尾空白)。 - 作用:处理如
[00:10][00:20]歌词内容的行,即多个时间标签共用一个歌词文本。trailingText就是这个共享文本。
6.2.3 时间标签解析与 LRCLine 生成
Matcher matcher = LRC_TAG.matcher(line);
List<LRCLine> currentGroup = new ArrayList<>();
while (matcher.find()) {
int minutes = Integer.parseInt(matcher.group(1));
String secStr = matcher.group(2);
long timeMs;
if (secStr.contains(".")) {
double seconds = Double.parseDouble(secStr);
timeMs = (long) (minutes * 60000 + seconds * 1000);
} else {
timeMs = minutes * 60000 + Long.parseLong(secStr) * 1000;
}
timeMs += offsetMs;
if (timeMs < 0) timeMs = 0;
int start = matcher.end();
Matcher nextMatcher = LRC_TAG.matcher(line);
String text;
if (nextMatcher.find(matcher.end())) {
text = line.substring(start, nextMatcher.start()).trim();
} else {
text = line.substring(start).trim();
}
if (text.isEmpty() && !trailingText.isEmpty()) text = trailingText;
currentGroup.add(new LRCLine(timeMs, text));
}
- 遍历行中所有时间标签。
- 时间转换:
- 分钟 → 毫秒:
minutes * 60000。 - 秒(含小数)→ 毫秒:
Double.parseDouble(secStr) * 1000。 - 加上全局偏移
offsetMs,若结果小于 0 则设为 0。
- 分钟 → 毫秒:
- 提取文本内容:
- 当前标签结束位置(
matcher.end())到下一个时间标签开始位置之间的文本。 - 若没有下一个标签,则是当前标签结束到行尾的文本。
- 若提取的文本为空,且
trailingText不为空,则使用trailingText(即共享文本)。
- 当前标签结束位置(
- 空文本保留:即使
text为空,仍然创建LRCLine对象。注释明确说明:“空文本条目也保留:它标记上一句的结束时间(间奏起点),显示层负责隐藏它,但时间间隔不能被丢掉”。这是一个重要的设计决策,确保歌词显示时能正确处理行之间的空白间隔。
6.2.4 收集结果
if (currentGroup.isEmpty()) continue;
allLines.addAll(currentGroup);
- 若该行解析出的
currentGroup非空,加入allLines列表。
6.3 排序与后处理
Collections.sort(allLines);
for (int i = 0; i < allLines.size(); i++) {
LRCLine cur = allLines.get(i);
long nextTime = (i + 1 < allLines.size()) ? allLines.get(i + 1).getTimeMs()
: cur.getTimeMs() + 4000;
if (cur.getText() != null && cur.getText().indexOf('<') >= 0) {
cur.parseCharsFromEnhanced(cur.getText());
}
if (!cur.hasChars()) {
cur.buildChars(nextTime - cur.getTimeMs());
}
}
data.lines = allLines;
data.offsetMs = offsetMs;
return data;
- 排序:按时间戳升序排序(
LRCLine必须实现Comparable)。 - 计算下一行时间:若存在下一行,用其时间;否则用当前行时间 + 4000ms(兜底时长)。
- 增强型 LRC 检测与解析:
- 若行文本包含
<字符,调用cur.parseCharsFromEnhanced(cur.getText()),解析嵌入时间戳的逐字标签(如<0,200>字)。
- 若行文本包含
- 构建默认字符时间:
- 若
cur尚未包含逐字时间(hasChars()返回 false),则调用cur.buildChars(nextTime - cur.getTimeMs()),将整行文本在行持续时间内均匀分配时间,实现“整行均匀高亮”。
- 若
7. 工具方法 findLRCFile
public static File findLRCFile(File audioFile) {
String path = audioFile.getAbsolutePath();
int dot = path.lastIndexOf('.');
if (dot > 0) {
File lrcFile = new File(path.substring(0, dot) + ".lrc");
if (lrcFile.exists()) return lrcFile;
lrcFile = new File(path.substring(0, dot) + ".txt");
if (lrcFile.exists()) return lrcFile;
}
return null;
}
- 根据音频文件路径,优先查找同名
.lrc文件,若不存在则查找同名.txt文件。 - 只检查文件是否存在,不进行内容校验。
8. 字符集检测 detectCharset
private static Charset detectCharset(File file) throws IOException {
try (InputStream is = new FileInputStream(file)) {
byte[] bom = new byte[3];
int read = is.read(bom);
if (read >= 3 && bom[0] == (byte) 0xEF && bom[1] == (byte) 0xBB && bom[2] == (byte) 0xBF)
return Charset.forName("UTF-8");
if (read >= 2 && bom[0] == (byte) 0xFF && bom[1] == (byte) 0xFE)
return Charset.forName("UTF-16LE");
}
// ...
}
- BOM 检测:
- UTF-8 BOM:
EF BB BF。 - UTF-16LE BOM:
FF FE。
- UTF-8 BOM:
- 检测到 BOM 则立即返回对应字符集。
8.1 无 BOM 时的检测流程
try {
byte[] bytes = Files.readAllBytes(file.toPath());
if (isValidUTF8(bytes)) return Charset.forName("UTF-8");
if (isValidCharset(bytes, "EUC-KR")) return Charset.forName("EUC-KR");
return Charset.forName("GBK");
} catch (Exception e) {
return Charset.forName("GBK");
}
- 读取全部字节到内存。
- 调用
isValidUTF8进行严格的 UTF-8 校验。 - 若不是合法 UTF-8,尝试
EUC-KR(韩文编码)。 - 若仍不成功,兜底为
GBK(中文编码)。
9. 严格 UTF-8 校验 isValidUTF8
private static boolean isValidUTF8(byte[] bytes) {
int i = 0;
int n = bytes.length;
while (i < n) {
int b = bytes[i] & 0xFF;
if (b < 0x80) { i++; continue; }
int need;
if ((b & 0xE0) == 0xC0) need = 1; // 110xxxxx → 1 continuation
else if ((b & 0xF0) == 0xE0) need = 2; // 1110xxxx → 2
else if ((b & 0xF8) == 0xF0) need = 3; // 11110xxx → 3
else return false; // invalid leading byte
if (i + need >= n) return false;
for (int j = 1; j <= need; j++) {
if ((bytes[i + j] & 0xC0) != 0x80) return false; // must be 10xxxxxx
}
// 禁止 overlong / 超出 Unicode 范围
if (need == 1 && b < 0xC2) return false;
if (need == 2 && b == 0xE0 && (bytes[i + 1] & 0xE0) == 0x80) return false;
if (need == 2 && b == 0xED && (bytes[i + 1] & 0xE0) == 0xA0) return false; // surrogates
if (need == 3 && b == 0xF0 && (bytes[i + 1] & 0xF0) == 0x80) return false;
if (need == 3 && b == 0xF4 && (bytes[i + 1] & 0xF0) > 0x80) return false;
if (need == 3 && b > 0xF4) return false;
i += need + 1;
}
return true;
}
- 为什么手写校验?:注释说明:“
new String(bytes, UTF-8)不会因非法序列抛异常,而是静默替换为 U+FFFD,因此必须手动校验,否则韩文 EUC-KR 会被误判为 UTF-8”。这是一个关键设计,确保编码检测的准确性。 - 校验规则:
- 单字节 ASCII(0x00-0x7F):直接通过。
- 多字节序列:
- 2 字节:起始字节
110xxxxx(0xC0-0xDF),后续 1 个10xxxxxx字节。 - 3 字节:起始字节
1110xxxx(0xE0-0xEF),后续 2 个10xxxxxx字节。 - 4 字节:起始字节
11110xxx(0xF0-0xF7),后续 3 个10xxxxxx字节。
- 2 字节:起始字节
- 禁止 overlong(如用 2 字节表示 ASCII 字符)。
- 禁止代理区(UTF-16 surrogates)。
- 限制最大码点不超过 U+10FFFF(通过检查
b > 0xF4)。
10. 字符集有效性校验 isValidCharset
private static boolean isValidCharset(byte[] bytes, String charset) {
try {
java.nio.charset.CharsetDecoder decoder = Charset.forName(charset)
.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT);
decoder.decode(java.nio.ByteBuffer.wrap(bytes));
return true;
} catch (Exception e) {
return false;
}
}
- 使用
REPORT模式解码,遇到非法字符或不可映射字符时抛出异常。 - 若解码成功,返回 true;否则返回 false。
- 用于验证
EUC-KR是否能正确解码文本。
11. 设计总结
| 模块 | 功能 |
|---|---|
| 正则表达式 | 精确匹配标准时间标签和 ID 标签。 |
| ID 标签处理 | 提取标题、艺术家、专辑、偏移量。 |
| 时间标签解析 | 支持 [mm:ss] 和 [mm:ss.xx],叠加全局偏移。 |
共享文本处理(trailingText) |
正确处理 [t1][t2]text 格式。 |
| 空行保留 | 保留空文本行以标记间奏间隔,UI 层负责隐藏。 |
| 增强型 LRC | 检测包含 < 的行,调用 parseCharsFromEnhanced 解析逐词时间。 |
| 字符集检测 | BOM 优先,严格 UTF-8 校验,EUC-KR 回退,GBK 兜底。 |
| 排序与后处理 | 按时间排序,计算每行持续时间,为无逐词信息的行生成均匀字时间。 |
LRCParser 是一个健壮且功能完整的 LRC 解析器,充分考虑了多编码支持、歌词时间轴精度和扩展格式兼容性,是 TTPlayer 歌词功能的核心基础。
License
本项目仅供学习和个人使用。皮肤资源版权归原作者所有。
「获取方式」:后台回复 ttplayer


浙公网安备 33010602011771号