LRC 格式以及如何使用 TypeScript 解析

LRC 格式

LRC 是一种常见的歌词保存格式, 使用文本的形式保存.

LRC 是 lyric 去掉 y 和 i 后的缩写.

为了避免乱码, 所以的文本都应该使用 utf-8 保存.

在 LRC 文本中, 每一行表示一句歌词, 每一行都遵循一下格式:

[mm:ss.xx]歌词文本

[ ] 括号的内容表示歌词进入的时间, 其中 mm 表示分钟数, ss 表示秒数, xx 表示百分之一秒, 例如 [00:20.43]对慢了 爱人会失去可爱 表示这句歌词在音乐的 0 分 20 秒 430 毫秒处开始. 同时, 歌词文本可以是空字符串.

[01:58.828]拖着梦寐 说着我想说的梦话
[02:02.709]不停摆
[02:05.569]
[02:06.099]寻找明天
[02:07.450]每一辆飞车彻夜向前开

例如上面的第 3 行歌词, 表示第 2 行歌词和第 4 行歌词不是连贯的. 需要注意的是, LRC 并没有规定歌词一定按照时间先后的顺序排列, 也就是说, 后面的歌词可能出现在前面.

LRC 文本还可以添加一些元数据. 元数据使用 key -> value 的形式, key 和 value 使用 : 分隔, 每一行表示一对元数据:

[key:value]

常见的元数据有:

key description example
al 专辑 [al:范特西]
by lrc 文本的作者 [by:mebtte]
ti 音乐的标题 [ti:听妈妈的话]
ar 歌手 [ar:周杰伦]

当然, 你还可以创造自己的元数据:

[author:mebtte]
[copyright:mebtte]

LRC 格式除了可以用来表示歌词以外, 也可用于视频的字幕.

不同于定义的实现

事实上, 很多厂商没有按照定义保存 LRC 文本.

与定义不同的时间标签

在 LRC 中, 时间标签的格式是 [分:秒:百分之一秒], 第三部分是百分之一秒, 范围是 00-99. 但在实现上, 一些厂商第三部分是毫秒, 范围是 000-999, 甚至有些厂商直接去掉了第三部分, 只有分和秒.

时间标签最后一部分是毫秒
时间标签最后一部分是毫秒
时间标签缺少第三部分
时间标签缺少第三部分

多重时间标签

有些厂商为了节省储存空间, 将相同歌词的行合并, 导致一行出现多个时间标签:

一行歌词多个时间标签
一行歌词多个时间标签

行首行末有多余空格

有些 LRC 文本会在行首或者行末添加不定数量的空格:

 [01:58.828]拖着梦寐 说着我想说的梦话
[02:02.709]不停摆
  [02:05.569]
[02:06.099]寻找明天
[02:07.450]每一辆飞车彻夜向前开

所以, 要想实现高兼容性的 LRC 解析器, 就需要考虑到上面几种异常情况.

使用 TypeScript 解析 LRC

对于 TypeScript 解析 LRC, 思路是这样的:

上图中, 关键的步骤是通过正则检查是否符合格式, 涉及到两个正则, 第一个是歌词行:

const LYRIC_LINE = /^((?:\[\d+:\d+(?:\.\d+)?\])+)(.*)$/;

这里兼容了多个时间标签以及兼容时间标签缺少第三部分. 第二个是元数据行:

const METADATA_LINE = /^\[(.+?):(.*?)\]$/;

两个正则利用了正则的分组功能, 可以通过 String.prototype.match 方法直接提取分组部分. 需要注意的是, 歌词行的单个时间标签使用了非捕获分组 (?:), 因为可能含有多个时间标签, 导致捕获分组只能捕获最后一个时间标签, 所以这里需要把所有时间标签作为一个分组, 提取后再单独处理.

下面是完整的解析代码:

/** lrc 行 */
interface LrcLine {
  /** 行号 */
  lineNumber: number;
  /** 行原始数据 */
  raw: string;
}

/** 元数据行 */
interface MetadataLine extends LrcLine {
  key: string;
  value: string;
}

/** 歌词行 */
interface LyricLine extends LrcLine {
  /** 开始时间, 毫秒 */
  startMillisecond: number;
  /** 歌词 */
  content: string;
}

const LYRIC_LINE = /^((?:\[\d+:\d+(?:\.\d+)?\])+)(.*)$/;
const METADATA_LINE = /^\[(.+?):(.*?)\]$/;

function parse(lrc: string) {
  const metadataLines: MetadataLine[] = []; // 元数据行
  const lyricLines: LyricLine[] = []; // 歌词行
  const invalidLines: LrcLine[] = []; // 无法解析的行

  const lines = lrc.split('\n'); // 分隔成独立的行

  for (let i = 0, { length } = lines; i < length; i += 1) {
    const line = lines[i];

    // 歌词行
    const lyricLineMatch = line.match(LYRIC_LINE);
    if (lyricLineMatch) {
      /***
       * 利用了正则的分组
       * 第一个分组是所有时间标签
       * 第二个分组是歌词文本
       */
      const timeTagPart = lyricLineMatch[1];
      const content = lyricLineMatch[2];

      /**
       * 分割多个时间标签
       * 每一个时间标签对应一行歌词
       * 正则表示右方括号和左方括号的位置, 也就是两个时间标签中间位置
       */
      for (const timeTag of timeTagPart.split(/(?<=\])(?=\[)/)) {
        /**
         * 利用了正则的分组
         * 第一个分组是分
         * 第二个分组是秒
         * 第三个分组是百分之一秒, 可能没有
         */
        const timeMatch = timeTag.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/);

        const minute = timeMatch[1];
        const second = timeMatch[2];
        const centisecond = timeMatch[3] || '00'; // 没有的话默认 00

        /** 字符串前面添加 + 可以将字符串转换成数字 */
        lyricLines.push({
          lineNumber: i,
          raw: line,
          startMillisecond:
            +minute * 60 * 1000 + +second * 1000 + +centisecond * 10,
          content,
        });
      }

      continue;
    }

    // 元数据行
    const metadataLineMatch = line.match(METADATA_LINE);
    if (metadataLineMatch) {
      const key = metadataLineMatch[1];
      const value = metadataLineMatch[2];

      metadataLines.push({
        lineNumber: i,
        raw: line,
        key,
        value,
      });

      continue;
    }

    // 无法解析
    invalidLines.push({
      lineNumber: i,
      raw: line,
    });
  }

  return {
    metadataLines,
    lyricLines,
    invalidLines,
  };
}

上面兼容了多个时间标签和缺少百分之一秒的情况, 还需要兼容百分之一秒是毫秒的情况:

// 通过位数判断是百分之一秒还是毫秒
const startMillisecond =
  +minute * 60 * 1000 +
  +second * 1000 +
  +centisecond * (centisecond.length === 2 ? 10 : 1);

以及兼容行前后空格的情况:

/** 通过 trim 方法移除行前后空格 */
const lines = lrc.split('\n').map((l) => l.trim());

还有一点, 前面提到 LRC 没有规定歌词按时间先后排序, 如果需要按时间先后展示歌词, 那么还需要进行一次排序:

lyricLines = lyricLines.map((a, b) => a.startMillisecond - b.startMillisecond);

clrcreact-lrc

基于上面的思路, 封装一个 clrc 的包发布在 npm, 可以通过下面的方法使用:

npm i --save clrc
import { parse } from 'clrc';

const lrc = `
[by:mebtte]
[ar:张叶蕾]
[01:58.828]拖着梦寐&nbsp;说着我想说的梦话
[02:02.709]不停摆
[02:05.569]
[02:06.099]寻找明天
[02:07.450]每一辆飞车彻夜向前开`;

parse<{ by: string; ar: string }>(lrc); // { metadatas, metadata, lyrics, invalidLine }

同时提供了 clrcPlayground .

clrc Playground

在 clrc 的基础上封装了 react-lrc, 为 react 项目提供了一个展示 LRC 的组件, 同样有一个 Playground

react-lrc Playground

参考

使用 Issues 讨论或在 Github 编辑.