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

image

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<>();
    }
}
  • 作为解析结果的容器:
    • linesLRCLine 对象列表,按时间排序。
    • 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
  • 检测到 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 字节。
    • 禁止 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

image

posted @ 2026-08-28 12:12  tomj2ee  阅读(2)  评论(0)    收藏  举报