Flutter ID3解码实现- v1、v1.1、v2.2、v2.3
之前用Flutter开发了一个桌面版的网易云音乐,传送门:源码,文章。其中有个下载功能,当时是用的mp3+JSON的方式实现下载并保存歌曲信息的。这样有个问题,信息的分离和容易解析出错。其实更好的方式是将歌曲信息写入到MP3文件内部,这里就要用到ID3的知识了。这也是这篇文章的由来。目前,我已经实现并开源了一个id3_codec库来帮助解析读取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个字节。并且只要Header
是TAG
字符串及表示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”为例。看我代码实现,可以看到其实TXX和TXXX**的解码器是一样的。
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部分解码也已经全部完成。
验证
我从网易云音乐上下载了一首歌,它在我的桌面上长这样,还显示了歌曲封面信息。
我们把它丢到程序中去看看能解析出什么来。
final data = await rootBundle.load("assets/song1.mp3");
final decoder = ID3Decoder(data.buffer.asUint8List());
decoder.decodeAsync().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的解析工作将在之后的时间中完成。感兴趣的同学可以关注本文的源码仓库。同时,我也将代码做成了插件,上传到了pub,项目中引入请打开pubspec.yaml文件,并在dependencies
字段下写上:id3_codec: ^0.0.3
。感谢支持。
参考资料
转载自:https://juejin.cn/post/7166063262541283336