Flutter ID3解码实现- v1、v1.1、v2.2、v2.3

之前用Flutter开发了一个桌面版的网易云音乐,传送门:源码文章。其中有个下载功能,当时是用的mp3+JSON的方式实现下载并保存歌曲信息的。这样有个问题,信息的分离和容易解析出错。其实更好的方式是将歌曲信息写入到MP3文件内部,这里就要用到ID3的知识了。这也是这篇文章的由来。目前,我已经实现并开源了一个flutter_id3库来帮助解析读取mp3文件中的ID3信息。接下来我将详细讲解下ID3的原理和代码的实现过程。

概述

ID3是一种metadata容器,多应用于MP3格式的音频文件中。它可以将相关的曲名、演唱者、专辑、音轨数等信息存储在MP3文件中,又称作“ID3Tags”。

ID3一般位于一个mp3文件的开头或末尾的若干字节内,附加了关于该mp3的歌手,标题,专辑名称,年代,风格等信息,该信息就被称为ID3信息。ID3主要分为两个大版本,v1和v2版。这是两个完全不一样的版本,两者间的格式相差巨大。ID3v1两个小版本,分别是ID3v1和ID3v1.1。ID3v2则有三个小版本,ID3v2.2、v2.3、v2.4。接下来,我详细讲解下各个版本的ID3结构。

ID3v1

原始的MP3并没有保存文件媒体信息的方式,一个叫Eric Kemp的人在文件最后的位置加入了一小段资料信息,这就是早期的ID3v1。

ID3v1总共占用128个字节,并且仅能保存歌曲标题(Title),艺术家(Artist),专辑(Album),年份(Year),评论(Comment),艺术类型(Genre)这6个字段。

字段 长度 描述
Header 3 头部信息,固定为‘TAG’
Title 30 标题
Artist 30 艺术家
Album 30 专辑
Year 4 年份
Comment 30 评论
Genre 1 艺术类型

后来由于CD逐渐流行且CD上的音乐大多会采用分割音轨(Track)的方式来区别曲目,让用户能快速选择要听第几首的关系,因此ID3v1后来又衍生出能够保存音轨(Track)消息的版本,即为ID3v1.1。Comment也就被切割成了三部分。其中Comment占28个字节,Reserve占一个字节,Track占1个字节。Reserve字节用于判断是否存在Track字段,当Reserve为非0时,表示存在Track字段存在,也就是ID3v1.1,否则就是ID3v1。因此ID3v1.1也有向下兼容性。

接下来我们就来实现下ID3V1的解析部分。已知IDV1固定在文件末尾,且长度为128个字节。并且只要HeaderTAG字符串及表示ID3V1。


// 解析ID3v1
int start = bytes.length - 128;
    if (readValue(fragments[0], start) != 'TAG') {
      return false;
    }
// 这个是字节解码器,会根据存入的编码方式选择不同的解码算法解码,默认是'ISO_8859_1'
    final codec = ByteCodec();

    metadata.set(value: 'V1', key: "Version");
    start += fragments[0].length;

    // Title
    final titleBytes = bytes.sublist(start, start + 30);
    metadata.set(value: codec.decode(titleBytes), key: 'Title');
    start += 30;

    // Artist
    final artistBytes = bytes.sublist(start, start + 30);
    metadata.set(value: codec.decode(artistBytes), key: 'Artist');
    start += 30;

    // Album
    final albumBytes = bytes.sublist(start, start + 30);
    metadata.set(value: codec.decode(albumBytes), key: 'Album');
    start += 30;

    // Year
    final yearBytes = bytes.sublist(start, start + 4);
    metadata.set(value: codec.decode(yearBytes), key: 'Year');
    start += 4;

    // Comment (30 or 28)
    final hasReserve = bytes.sublist(start + 28, 1).first != 0x00;
    if (hasReserve) {
      // ID3V1.1
      metadata.set(value: 'V1.1', key: "Version");

      final commentBytes = bytes.sublist(start, start + 28);
      metadata.set(value: codec.decode(commentBytes), key: 'Comment');
      start += 28;

      // Reserve
      final reserveBytes = bytes.sublist(start, start + 1).first;
      metadata.set(value: reserveBytes, key: 'Reserve');
      start += 1;

      // Track
      final trackBytes = bytes.sublist(start, start + 1).first;
      metadata.set(value: trackBytes, key: 'Track');
      start += 1;
    } else {
      final commentBytes = bytes.sublist(start, start + 30);
      metadata.set(value: codec.decode(commentBytes), key: 'Comment');
      start += 30;
    }

    // Genre
    final genre = bytes.sublist(start, start + 1).first;
    metadata.set(value: genreList[genre], key: 'Genre');
    start += 1;

ID3V1内容就这么多,它存在于MP3文件的固定位置,占用固定长度,因此解析相对比较简单。想了解更多的可以查看官方对于ID3V1的规范说明,内容存在于文档底部。

ID3v2

ID3v1的固定长度设计固然简单明了,但是却无法扩展,于是在1998年id3.org的一群贡献者商讨出了一种全新的ID3格式来解决这个问题,那就是ID3v2。虽然继承了ID3的名字,但是其结构和ID3v1相比却相差巨大。

ID3v2标签有长度不是固定的,并且有各种不同的格式和大小,常常位于文件的开头,以3字节的ID3作为标识。

ID3v2.2

这是ID3v2的第一个公开版本,它使用3个字符座位数据帧架(Frame)标识符,而非4个,这和v2.3、v2.4就数据帧的解析上有着明显的区别。我们先看看它的整体结构。

字段 大小/字节 描述
Header 10 头部信息
Frames 可变 数据帧架
Padding 可变 填充

Header

ID3v2.2的Header信息比较丰富,因为是可变长度,因此在Header中还记录了整个标签的大小。我们看下其Header结构长什么样。

字段 长度/字节 说明
File ID 3 “ID3” 固定为“ID3”
version 2 $02 00 版本
flags 1 %xx000000 额外信息
size 4 4 * %0xxxxxxx 除Header之外的剩余内容大小

标注此为ID3v2标签的首要前提是前三个字节的值为**“ID3”,否则就不是ID3v2。紧接着是版本信息,占2个字节,第一个字节值为2,表示2.2版本,第二个字节是修订号。接下来的一个字节是Flags**信息,第一个比特位(bit 7)表示是否同步,第二个比特为(bit 6)表示是否压缩。接下来的4个字节记录着除Header之外的所有内容的大小。

这里我详细说明下Size字段的计算方式。官方文档中有提到如下内容:

Size字段说明

The ID3 tag size is encoded with four bytes where the first bit (bit
   7) is set to zero in every byte, making a total of 28 bits. The zeroed
   bits are ignored, so a 257 bytes long tag is represented as $00 00 02
   01.

   The ID3 tag size is the size of the complete tag after
   unsychronisation, including padding, excluding the header (total tag
   size - 10). The reason to use 28 bits (representing up to 256MB) for
   size description is that we don't want to run out of space here.

意思是size占用4个字节大小,且每个字节的最高比特位不被使用,因此实际计算时只用到了28bit。因此最大可以存储256M的标签内容。

因此size的计算方法为(PS:因为每个字节只取7bit,因此在计算前先与上0B01111111,也就是0x7F):int size = (sizeBytes[3] & 0x7F) + ((sizeBytes[2] & 0x7F) << 7) + ((sizeBytes[1] & 0x7F) << 14) + ((sizeBytes[0] & 0x7F) << 21);

完整的代码实现:

List<ID3Fragment> get header => [
        ID3Fragment(name: 'FileID', length: 3),
        ID3Fragment(name: 'Major', length: 1, needDecode: false),
        ID3Fragment(name: 'Revision', length: 1, needDecode: false),
        ID3Fragment(name: 'Flags', length: 1, needDecode: false),
        ID3Fragment(name: 'Size', length: 4, needDecode: false),
      ];

// File ID
metadata.set(value: "ID3", key: header[0].name);
    // Parse Version Tag
    _major = readValue(header[1], start).toString();
    start += header[1].length;
    _revision = readValue(header[2], start).toString();
    start += header[2].length;
    metadata.set(value: version, key: "Version");

    // Parse Flags Tag
    final flags = readValue(header[3], start);
    start += header[3].length;

      bool unsynchronisation = flags & 0x80 != 0;
      bool compression = flags & 0x40 != 0;

      if (unsynchronisation) {
        metadata.set(value: unsynchronisation, key: 'Unsynchronisation');
      } else {
        metadata.set(value: compression, key: 'Compression');
      }
    

    // Parse Size Tag
    List<int> sizeBytes = readValue(header[4], start);
    start += header[4].length;
   
    int size = (sizeBytes[3] & 0x7F) +
        ((sizeBytes[2] & 0x7F) << 7) +
        ((sizeBytes[1] & 0x7F) << 14) +
        ((sizeBytes[0] & 0x7F) << 21);
    _size = size;
    metadata.set(value: "$size", key: header[4].name)

数据帧架(Frames)

数据帧架紧跟在Header之后,size的大小其实就是数据帧架的大小和padding大小之和(Padding我后面再说,简单理解就是个填充)。官方定义了许多常用的帧,并且他们有着自己的结构和特征。

字段 大小/字节 描述
Frame ID 3 帧的标识符,三个字节
Frame size 3 帧大小,除去ID和size这两个字段后的大小。因此实际一个数据帧大小为 6+size
Content 可变 这里内容可变,具体需要查阅文档

篇幅有限,这里我捡两个常见的做个例子。

Text information frames,这是最重要的数据帧集,包含了诸如艺术家,专辑,歌曲标题等重要信息。它是一系列以**“T00”开头,以“TZZ”**结尾的集合。但是这里不包括“TXX”。它的结构如下所示:

Text information identifier  "T00" - "TZZ" , excluding "TXX",
                                   described in 4.2.2.
     Frame size                   $xx xx xx
     Text encoding                $xx
     Information                  <textstring>

前两个字段上文中有提到,我就不过多阐述。我们看第三个字段Text encoding,这个字段占用一个字节,表示信息的编码。在ID3v2中使用到了两种编码,一种是ISO-8859-1,另一种是Unicode。其中Unicode在这里表示带有BOM的UTF-16编码

$00 – ISO-8859-1 (LATIN-1, Identical to ASCII for values smaller than 0x80).
$01 – UCS-2 (UTF-16 encoded Unicode with BOM), in ID3v2.2 and ID3v2.3.

在Flutter中,ISO-8859-1使用lantin1解码器对象接即可。而对于UTF-16,则需要我们自己做点处理。

Text encoding的值为1时,那么就表示使用UTF-16解码,这时,我们要解读下对应编码的前两个字节,也就是BOM信息。当这两个字节为0xFF 0xFE时表示小端编码,高位字节在前。为0xFE 0xFF时,表示大端编码,高位字节在后。

// https://zh.wikipedia.org/wiki/UTF-16
  String _decodeToUTF16(List<int> bytes) {
    final bom = bytes.sublist(0, 2);
    if (bom[0] == 0xFF && bom[1] == 0xFE) {
      return _decodeToUTF16LE(bytes.sublist(2));
    } else if (bom[0] == 0xFE && bom[1] == 0xFF) {
      return _decodeToUTF16BE(bytes.sublist(2));
    }
    return '';
  }

UTF-16是以两个字节表述一个字符的,因此是2个字节2个字节的读取的。

因此在Flutter中解析UTF-16编码的数据有如下算法:

// BE 即 big-endian,大端的意思。大端就是将高位的字节放在低地址表示
  String _decodeToUTF16BE(List<int> bytes) {
    _codecType = ByteCodecType.UTF16BE;
    
    final utf16bes = List.generate((bytes.length / 2).ceil(), (index) => 0);

    for (int i = 0; i < bytes.length; i++) {
      if (i % 2 == 0) {
        utf16bes[i ~/ 2] = (bytes[i] << 8);
      } else {
        utf16bes[i ~/ 2] |= bytes[i];
      }
    }
    return String.fromCharCodes(utf16bes);
  }

  // LE 即 little-endian,小端的意思。小端就是将高位的字节放在高地址表示
  String _decodeToUTF16LE(List<int> bytes) {
    _codecType = ByteCodecType.UTF16LE;

    final utf16les = List.generate((bytes.length / 2).ceil(), (index) => 0);

    for (int i = 0; i < bytes.length; i++) {
      if (i % 2 == 0) {
        utf16les[i ~/ 2] = bytes[i];
      } else {
        utf16les[i ~/ 2] |= (bytes[i] << 8);
      }
    }
    return String.fromCharCodes(utf16les);
  }

到这里其实我们的Text information frames已经可以正确解析出来了。看下完整代码:

List<ID3Fragment> get frameV2_2 => [
    ID3Fragment(name: 'Frame ID', length: 3),
    ID3Fragment(name: 'Frame Size', length: 3, needDecode: false)
    // Content
  ];

int _parseV2_2Frames(int start) {
    int frameSizes = _size;
    while (frameSizes > 0) {
      // Frame ID
      final frameID = readValue(frameV2_2[0], start);
      if (frameID == latin1.decode([0, 0, 0])) {
        break;
      }
      start += frameV2_2[0].length;
      metadata.set(
          value: "$frameID", key: frameV2_2[0].name, desc: frameV2_2Map[frameID]);

      // Frame Size
      final frameSizeBytes = readValue(frameV2_2[1], start);
      start += frameV2_2[1].length;
      int frameSize = frameSizeBytes[2] +
          (frameSizeBytes[1] << 8) +
          (frameSizeBytes[0] << 16);
      metadata.set(value: frameSize, key: frameV2_2[1].name);

      // Content
      final contentBytes = bytes.sublist(start, start + frameSize);
      start += frameSize;
      final decoder = ContentDecoder(frameID: frameID, bytes: contentBytes);
      final content = decoder.decode();
      metadata.set(value: content, key: 'Content');
      // calculate left frame sizes
      frameSizes -=
          (frameSize + frameV2_2[0].length + frameV2_2[1].length);
    } 
    return start;
  }

Content解码器,工厂模式设计,根据不同数据帧架ID匹配不同解码器实现。

class _TextInfomationDecoder extends _ContentDecoder {
  _TextInfomationDecoder(super.frameID);

  @override
  FrameContent decode(List<int> bytes) {
    final encoding = bytes.sublist(0, 1).first;
    final codec = ByteCodec(textEncodingByte: encoding);
    return FrameContent()..set('Information', codec.decode(bytes.sublist(1)));
  }
}

ID3v2.3

v2.3是目前最流行的版本,它将数据帧架标识符扩展到了4个字符,也就是“TXX”变成了“TXXX”,并加入了一些新的数据帧架。同时它还新增了一个Extended Header。我们先来看看她的结构表。

字段 大小/字节 描述
Header 10 头部信息
Extended Header 0或10或14 扩展头
Frames 可变 数据帧架
Padding 可变 填充

ID3v2.3的Header和v2.2的Header大同小异,就Flags有点区别,它仍然占用了1个字节,值为%abc00000

  • a: 不同步(Unsynchronisation):决定这个标签是否使用不同步。由于许多程序根本忽略这个旗标,一般设为0就好了。
  • b: 扩展标头(Extended Header):决定这个标头后面是否还有扩展标头。
  • c: 实验标签(Experimental Indicator):决定这个标签是否为实验或测试用的标签。

Extended Header

这个是新增的结构,它存放Header中没有的高端信息。结构如下:

字段 大小/字节 描述
Extended Header Size 4 扩展Header大小
Extended Flags 2 Flags
Size of Padding 4 填充大小
Total Frame CRC 0或4 CRC-32数值

首先需要Header的Flags中的b不等于0才表示存在扩展Header。Extended Header Size的大小记录的是除了它本身之外的大小,只可能为6或10个字节。Extended Flags虽然占了2个字节,但是它只用到了第一个字节的第7位%x0000000 00000000。用于记录是否计算Frames的CRC-32信息,用以检查Frames的字段消息是否有缺损。

Total Frame CRC字段保存着Frames字段在异步前计算出来的CRC-32数值。以后便能以此CRC-32数值来比对检查目前Frames字段里的数据是否完全正确。若Extended Flags并没有打开Total Frame CRC字段,则此字段无作用。

数据帧架(Frames)

这个其实在前文中也提到了,v2.3和v2.2就数据帧架上最大的区别就是数据帧的标识符从3个字符变成了4个字符。以**”TXX”为例。看我代码实现,可以看到其实TXXTXXX**的解码器是一样的。

class ContentDecoder {
  ContentDecoder({
    required this.frameID,
    required this.bytes,
  }) {
    if (frameID == 'TXXX' || frameID == 'TXX') {
      _decoder = _TXXXDecoder(frameID);
} 
// other frame
}

TXXX解码具体实现。

/*
  <Header for 'User defined text information frame', ID: "TXXX">
     Text encoding     $xx
     Description       <text string according to encoding> $00 (00)
     Value             <text string according to encoding>
*/
class _TXXXDecoder extends _ContentDecoder {
  _TXXXDecoder(super.frameID);

  @override
  FrameContent decode(List<int> bytes) {
    final content = FrameContent();
    int start = 0;

    final encoding = bytes.sublist(0, 1).first;
    final codec = ByteCodec(textEncodingByte: encoding);
    start += 1;

    // Description
    final descBytes = codec.readBytesUtilTerminator(bytes.sublist(start));
    content.set('Description', codec.decode(descBytes.bytes));
    start += descBytes.length;

    // Value
    final value = codec.decode(bytes.sublist(start));
    content.set('Value', value);
    return content;
  }
}

至此,ID3v2.3部分解码也已经全部完成。

验证

我从网易云音乐上下载了一首歌,它在我的桌面上长这样,还显示了歌曲封面信息。

%E6%88%AA%E5%B1%8F2022-11-14_11.47.25.png

我们把它丢到程序中去看看能解析出什么来。

final data = await rootBundle.load("assets/song1.mp3");
            final parser = ID3Reader(data.buffer.asUint8List());
            parser.readAsync().then((metadata) {
              debugPrint(metadata.toString());
            });

解析结果:

flutter: [ ID3MetaInfo ]
flutter: == Header ==
flutter: - FileID: ID3
flutter: - Version: V2.3.0
flutter: - Flags: 0
flutter: - Size: 421143
flutter: == Frame[TSSE] ==
flutter: - Frame ID: TSSE[Software/Hardware and settings used for encoding]
flutter: - Frame Size: 14
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Information: Lavf57.25.100}
flutter: == Frame[TPOS] ==
flutter: - Frame ID: TPOS[Part of a set]
flutter: - Frame Size: 5
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Information: 1}
flutter: == Frame[TRCK] ==
flutter: - Frame ID: TRCK[Track number/Position in set]
flutter: - Frame Size: 7
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Information: 12}
flutter: == Frame[TPE1] ==
flutter: - Frame ID: TPE1[Lead performer(s)/Soloist(s)]
flutter: - Frame Size: 7
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Information: 大壮}
flutter: == Frame[APIC] ==
flutter: - Frame ID: APIC[Attached picture]
flutter: - Frame Size: 420036
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {MIME: image/jpg, PictureType: Other, Description: , Base64: <Has Picture Data>}
flutter: == Frame[COMM] ==
flutter: - Frame ID: COMM[Comments]
flutter: - Frame Size: 583
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Language: XXX, Short content descrip: , The actual text: 163 key(Don't modify):L64FU3W4YxX3ZFTmbZ+8/TRFZM41eYxmgrHCX3OKwWdwXJntbqUJt6Rns3aPZqufwooWfsA9+jpB2dslJqEk9Cb0N38KQMJkR2MxNFhqfJjOwmUCNcimZm5FeqXoqVg1f4K6y9Sb9ffug9+42UCYU0TGYuYhm1PP2PeXaWhdrYXsr1FryO/Ez0mPEfUW8iiWlnUPhemBXEF/o2KJk6n3tNogku39k4sp5SGW9gbFO026xSNPsuFhEKcQ4PcjdsbZ2DM1J44ya/PgOGTkW58V5DZW5pzYKs57los7NNk12qzf4H+Iwgk3cyZiMWIpRWhQSXIMPhQMxBqA3ucIZy6fggRK1syDIMI3zv8Q6Tj5PjvOX01ZrNxvLChL47JZ2Mb0qiOMB0K5acx2UpLsjmjmsMrfKuL537fUEQFZkZaN0Pj2zvkrBgWtxTaH2NUJNDrXKUgec6HAMFiW1M0FtPFO59/v+McRY2RvuRHjvSlfIzH3mlS/6AipRR/Fx490HPJZ0TOxH8FRi4DT8i4SQbXRRP1sKnBkgXFP7jQgBhxbQao=}
flutter: == Frame[TIT2] ==
flutter: - Frame ID: TIT2[Title/songname/content description]
flutter: - Frame Size: 17
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Information: 为你我受冷风吹}
flutter: == Frame[TALB] ==
flutter: - Frame ID: TALB[Album/Movie/Show title]
flutter: - Frame Size: 23
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0
flutter: - Content: {Information: 大壮首张限量定制翻唱}
flutter: =========> All data received <=======

可以看到,歌曲的标题,歌手,专辑,歌曲封面信息(Base64,我给隐藏了,不然太长,后续有需要的话我可以加个开关)等信息。我们的桌面程序也是解析了MP3的ID3之后,才能显示歌曲的封面的。

最后

本文详细讲述了关于ID3v1,ID3v1.1,ID3v2.2,ID3v2.3的结构和解码原理。ID3的解码工作还剩最后的ID3v2.4版本,由于又和v2.3有不少区别,因此关于v2.4的解析工作将在之后的时间中完成。感兴趣的同学可以关注本文的源码仓库,感谢支持。

参考资料

MP3标签格式(ID3,APE)超详细解释

别人开发的id3库

id3v2.2官方文档

id3v2.3.0官方文档

id3v2文档

ID3维基百科

UTF16、UTF16-BE、UTF16-LE三者的区别

UTF-16维基百科

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
相关推荐
  • 暂无相关文章
  • 评论 抢沙发
    头像
    欢迎您留下宝贵的见解!
    提交
    头像

    昵称

    取消
    昵称表情代码图片

      暂无评论内容