作者:郑童宇
GitHub:https://github.com/CrazyZty
1.前言
做了三年左右的Android开发,一直没写过博客,最近正好打算换工作,算是闲一些,就将以前开发所遇到的一些有趣的问题及解决方法写出来,以供他人借鉴。
MP3作为音频领域应用最广的压缩方式之一想必大家都有所了解,本篇博文探究的不是MP3著名的压缩方式,而是如何为MP3文件写入ID3Tags。写下本博文的原因是类似的博文太少,以前做这方面的时候,google出来的大多是使用某一软件或库来进行处理,少许涉及代码的基本都是读取标签信息,或写入ID3v1标签,更糟糕的是网上大多数对ID3v2标签的读取是存在问题,以至于当我试图借鉴读取标签的博文来反向为MP3文件写入ID3v2标签时一直出现标签信息乱码的情况,直到google出一篇文章才得以解决。
本篇博文以Java作为示例语言,以Android作为示例平台。
2.ID3标签
2.1.ID3v1标签
ID3v1是一个很方便写入和解析的标签,维基百科上对ID3v1的解释是"v1版的ID3在MP3文件的末尾128字节,以TAG三个字符开头,后面跟上歌曲信息"
通过http://bbs.pfan.cn/post-372622.html,我们可以获取128个字节的具体信息:
名称 | 字节 | 说明 |
---|---|---|
Tag | 3 | ID3V1标识符“TAG”的Ascii码 |
Title | 30 | 歌曲名 |
Artist | 30 | 歌手名 |
Album | 30 | 专辑名 |
Year | 4 | 日期信息 |
Comment | 28 | 注释信息,有时为30字节 |
Reserved | 1 | =0说明有音轨,下一字节就是音轨;≠0表示注释是30个字节 |
Track | 1 | 音轨(字节型数值),歌曲在专辑里的序号 |
Genre | 1 | 歌曲风格(字节型数值) |
看到这个数据结构,想必大家已经很清楚如何写入ID3v1标签了,下面我就直接贴出代码,并对代码中部分模块进行解释。
public static void StorageMusicFileWithID3V1Tag(File sourceFile, String musicFilePath,
String songName, String artistName,
String albumName) {
try {
sourceFile.renameTo(new File(musicFilePath));
RandomAccessFile musicRandomAccessFile = new RandomAccessFile(musicFilePath, "rw");
musicRandomAccessFile.seek(musicRandomAccessFile.length() - 128); // 跳到ID3V1开始的位置
byte[] tag = new byte[3];
musicRandomAccessFile.read(tag);
if (new String(tag).equals("TAG")) {
return;
}
byte[] tagByteArray = new byte[128];
musicRandomAccessFile.seek(musicRandomAccessFile.length());
byte[] songNameByteArray = songName.getBytes("GBK");
byte[] artistNameByteArray = artistName.getBytes("GBK");
byte[] albumNameByteArray = albumName.getBytes("GBK");
int songNameByteArrayLength = songNameByteArray.length;
int artistNameByteArrayLength = artistNameByteArray.length;
int albumNameByteArrayLength = albumNameByteArray.length;
songNameByteArrayLength = songNameByteArrayLength > 30 ? 30 : songNameByteArrayLength;
artistNameByteArrayLength =
artistNameByteArrayLength > 30 ? 30 : artistNameByteArrayLength;
albumNameByteArrayLength =
albumNameByteArrayLength > 30 ? 30 : albumNameByteArrayLength;
System.arraycopy("TAG".getBytes(), 0, tagByteArray, 0, 3);
System.arraycopy(songNameByteArray, 0, tagByteArray, 3, songNameByteArrayLength);
System.arraycopy(artistNameByteArray, 0, tagByteArray, 33, artistNameByteArrayLength);
System.arraycopy(albumNameByteArray, 0, tagByteArray, 63, albumNameByteArrayLength);
tagByteArray[127] = (byte) 0xFF; // 将流派显示为指定音乐的流派
musicRandomAccessFile.write(tagByteArray);
} catch (Exception e) {
LogFunction.error("写入音乐标签异常", e);
}
}
代码很简单,另外对于中文电脑而言,解析ID3v1中的标签是按照GBK的格式来的,所以在这里要注意一下转换字节的格式,当然如果要标签写入的是除了非亚洲语言的话那就按照对应的字符编码来修改代码即可,除此以外没有复杂的地方。
不过从上面代码我们可以很明显看出ID3v1标签的缺点,一是可以保存的信息比较少,二是如果歌曲的信息过长,比如歌名过长那就无法完整存储的歌名,如何解决这一问题呢,那就是使用ID3v2标签。
2.2.ID3v2标签
再次借鉴于http://bbs.pfan.cn/post-372622.html,我们可以得到ID3v2标签的基本信息。
ID3V2 与 ID3V1 的作用差不多,也是记录 MP3 的有关信息,但 ID3V2 的结构比 ID3V1 要复杂得多,而且可以伸缩和扩展。ID3V2 到现在一共有 4 个版本,但流行的播放软件一般只支持第 3 版,既ID3V2.3。由于ID3V1记录在 MP3 文件的末尾,ID3V2 就只好记录在 MP3 文件的首部了。
每个 ID3V2.3 的标签都一个标签头和若干个标签帧或一个扩展标签头组成。歌曲的信息如标题、作者等都存放在不同的标签帧中,扩展标签头和标签帧并不是必要的,但每个标签至少要有一个标签帧。对于 VB 爱好者来说,你可以把 ID3V2 看作是一个对象,而把标签帧看作是 ID3V2 的一个属性,那么,标签帧的标识符就可以看作是属性名了。
一首MP3如果有ID3V2.3的话,那么ID3V2.3的标签头占用文件最前面的10个字节,其数据结构如下:
1.ID3V2.3标签头结构:
名称 | 字节 | 说明 |
---|---|---|
Header | 3 | ID3V2.3标识符"ID3"的Ascii码,否则认为没有ID3V2.3 |
Ver | 1 | 版本号,=03 |
Revision | 1 | 副版本号,=00 |
flag | 1 | 标志字节,一般没意义,=00 |
Size | 4 | 标签内容长度,高位在前,不包括标签头的10个字节 |
2.ID3V2标签帧
标签内容由若干个标签帧组成。每个标签帧都由一个10个字节的帧头和至少 1个字节的不固定长度的帧内容组成,它们顺序存放在文件中。
每个帧都由帧头和帧内容组成,数据结构如下:
名称 | 字节 | 说明 |
---|---|---|
FrameID | 4 | 帧标识符的Ascii码 |
Size | 4 | 帧内容及编码方式的合计长度,高位在前 |
Flags | 2 | 标志,只使用了6位,详见表6,一般均=0 |
encode | 4 | 帧内容所用的编码方式。许多帧没有此项(注意,这一部分作者写错了,文档上事实并没有此属性,因为其他的文章关于标签帧的描述都没有提及此属性,故而我专门去了http://id3.org/id3v2.3.0进行考证,最后在文章3.3.ID3v2 frame overview中可以看到ID3V2标签帧并没有此内容) |
帧内容 | 至少1个字节 |
基本的标签格式内容展示这些就足够了,更多的扩展内容我就不放到这里了,现在上代码。
public static void StorageMusicFileWithID3V2Tag(File sourceFile, String musicFilePath,
String songName, String artistName,
String albumName) {
try {
RandomAccessFile musicRandomAccessFile =
new RandomAccessFile(sourceFile.getAbsolutePath(), "rw");
musicRandomAccessFile.seek(0);
byte[] tag = new byte[3];
musicRandomAccessFile.read(tag);
if (new String(tag).equals("ID3")) {
sourceFile.renameTo(new File(musicFilePath));
return;
}
} catch (Exception e) {
LogFunction.error("存储音乐文件异常", e);
}
try {
byte[] encodeByte = {3}; // 03 表示的UTF8编码
byte[] tagByteArray;
byte[] tagHeadByteArray;
byte[] tagFrameHeadByteArray;
byte[] songNameByteArray = songName.getBytes("UTF-8");
byte[] artistNameByteArray = artistName.getBytes("UTF-8");
byte[] albumNameByteArray = albumName.getBytes("UTF-8");
final int tagHeadLength = 10;
final int tagFrameHeadLength = 10;
final int tagFrameEncodeLength = 1;
final int tagFillByteLength = 20; // 这个填充字节是我看到其他MP3文件ID3标签都会在尾端添加的数据,为了保险起见我也加上了
int byteArrayOffset = 0;
int songNameByteArrayLength = songNameByteArray.length;
int artistNameByteArrayLength = artistNameByteArray.length;
int albumNameByteArrayLength = albumNameByteArray.length;
int songNameFrameTotalLength = songNameByteArrayLength + tagFrameEncodeLength;
int artistNameFrameTotalLength = artistNameByteArrayLength + tagFrameEncodeLength;
int albumNameFrameTotalLength = albumNameByteArrayLength + tagFrameEncodeLength;
int totalTagLength = tagHeadLength + tagFrameHeadLength + songNameByteArrayLength +
tagFrameHeadLength + artistNameByteArrayLength +
tagFrameHeadLength + albumNameByteArrayLength +
tagFillByteLength;
int tagContentLength = totalTagLength - tagHeadLength;
tagByteArray = new byte[totalTagLength];
tagHeadByteArray = new byte[tagHeadLength];
System.arraycopy("ID3".getBytes(), 0, tagHeadByteArray, 0, 3);
tagHeadByteArray[3] = 3;
tagHeadByteArray[4] = 0;
tagHeadByteArray[5] = 0;
tagHeadByteArray[6] = (byte) ((tagContentLength >> 7 >> 7 >> 7) % 128);
tagHeadByteArray[7] = (byte) ((tagContentLength >> 7 >> 7) % 128);
tagHeadByteArray[8] = (byte) ((tagContentLength >> 7) % 128);
tagHeadByteArray[9] = (byte) (tagContentLength % 128);
System.arraycopy(tagHeadByteArray, 0, tagByteArray, byteArrayOffset,
tagHeadLength);
byteArrayOffset += tagHeadLength;
tagFrameHeadByteArray = new byte[tagFrameHeadLength];
System.arraycopy("TIT2".getBytes(), 0, tagFrameHeadByteArray, 0, 4);
tagFrameHeadByteArray[4] = (byte) ((songNameFrameTotalLength >> 8 >> 8 >> 8) % 256);
tagFrameHeadByteArray[5] = (byte) ((songNameFrameTotalLength >> 8 >> 8) % 256);
tagFrameHeadByteArray[6] = (byte) ((songNameFrameTotalLength >> 8) % 256);
tagFrameHeadByteArray[7] = (byte) (songNameFrameTotalLength % 256);
tagFrameHeadByteArray[8] = 0;
tagFrameHeadByteArray[9] = 0;
System.arraycopy(tagFrameHeadByteArray, 0, tagByteArray, byteArrayOffset, tagFrameHeadLength);
byteArrayOffset += tagFrameHeadLength;
System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagFrameEncodeLength);
byteArrayOffset += tagFrameEncodeLength;
System.arraycopy(songNameByteArray, 0, tagByteArray, byteArrayOffset,
songNameByteArrayLength);
byteArrayOffset += songNameByteArrayLength;
tagFrameHeadByteArray = new byte[tagFrameHeadLength];
System.arraycopy("TPE1".getBytes(), 0, tagFrameHeadByteArray, 0, 4);
tagFrameHeadByteArray[4] = (byte) ((artistNameFrameTotalLength >> 8 >> 8 >> 8) % 256);
tagFrameHeadByteArray[5] = (byte) ((artistNameFrameTotalLength >> 8 >> 8) % 256);
tagFrameHeadByteArray[6] = (byte) ((artistNameFrameTotalLength >> 8) % 256);
tagFrameHeadByteArray[7] = (byte) (artistNameFrameTotalLength % 256);
tagFrameHeadByteArray[8] = 0;
tagFrameHeadByteArray[9] = 0;
System.arraycopy(tagFrameHeadByteArray, 0, tagByteArray, byteArrayOffset, tagFrameHeadLength);
byteArrayOffset += tagFrameHeadLength;
System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagFrameEncodeLength);
byteArrayOffset += tagFrameEncodeLength;
System.arraycopy(artistNameByteArray, 0, tagByteArray, byteArrayOffset,
artistNameByteArrayLength);
byteArrayOffset += artistNameByteArrayLength;
tagFrameHeadByteArray = new byte[tagFrameHeadLength];
System.arraycopy("TALB".getBytes(), 0, tagFrameHeadByteArray, 0, 4);
tagFrameHeadByteArray[4] = (byte) ((albumNameFrameTotalLength >> 8 >> 8 >> 8) % 256);
tagFrameHeadByteArray[5] = (byte) ((albumNameFrameTotalLength >> 8 >> 8) % 256);
tagFrameHeadByteArray[6] = (byte) ((albumNameFrameTotalLength >> 8) % 256);
tagFrameHeadByteArray[7] = (byte) (albumNameFrameTotalLength % 256);
tagFrameHeadByteArray[8] = 0;
tagFrameHeadByteArray[9] = 0;
System.arraycopy(tagFrameHeadByteArray, 0, tagByteArray, byteArrayOffset, tagFrameHeadLength);
byteArrayOffset += tagFrameHeadLength;
System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagFrameEncodeLength);
byteArrayOffset += tagFrameEncodeLength;
System.arraycopy(albumNameByteArray, 0, tagByteArray, byteArrayOffset,
albumNameByteArrayLength);
byte[] dataByteBuffer = new byte[1024];
FileInputStream fileInputStream = new FileInputStream(sourceFile);
FileOutputStream fileOutputStream =
FileFunction.GetFileOutputStreamFromFile(musicFilePath);
fileOutputStream.write(tagByteArray);
while (fileInputStream.read(dataByteBuffer) > 0) {
fileOutputStream.write(dataByteBuffer);
}
fileOutputStream.close();
fileInputStream.close();
FileFunction.DeleteFile(sourceFile.getAbsolutePath());
} catch (Exception e) {
LogFunction.error("写入音乐标签异常", e);
}
}
关于"TIT2","TPE1","TALB"分别代表什么意思,还有帧头的计算,我就不过多描述了,这些通过阅读资料都很容易理解。
然而有一个地方需要注意,同时这也是我认为的ID3v2标签中的一个大坑,可以跳到代码的74行看到这么一段代码: System.arraycopy(encodeByte, 0, tagByteArray, byteArrayOffset, tagEncodeLength);,同时这段代码也在代码的90行和106行出现,这段代码十分重要,原因可以参考http://blog.csdn.net/chenmeimei_8899/article/details/3901948。核心内容如下:
"关于MP3 tag的资料很多,大家可以自己去找,大概讲的都是大同小异,但是我觉得他们忘了很重要的一点,也是我忽略的,后来解析文件出错了,才恍然大悟。那就是tag信息的编码。
大家都知道每个frame的头是由10个字节组成的,具体内容我就不仔细写了,在着10个字节后有一个字节,大家可以仔细观察一下,只有四种情况 00 ,01,02,03,这个代表什么呢?这个就是表示tag的编码方式的。00代表的就是ISO-8859-1,01代表的就是UTF-16编码,02代表 UTF16BE,03表示的UTF8编码,在tag信息是不采用这种表明,但是这种编码在tag里不是错的。"
还记得我上面关于ID3v2标签信息标红的那一段吗,对就是encode那个属性,咦,你这会肯定好奇了,我明明说了文档没有,但现在在代码中却加了上去,为什么呢?
事实是http://id3.org/id3v2.3.0的3.3.ID3v2 frame overview中有这么一段:
"A tag must contain at least one frame. A frame must be at least 1 byte big, excluding the header.
If nothing else is said a string is represented as ISO-8859-1 characters in the range $20 - $FF. Such strings are represented as <text string>, or <full text string> if newlines are allowed, in the frame descriptions. All Unicode strings use 16-bit unicode 2.0 (ISO/IEC 10646-1:1993, UCS-2). Unicode strings must begin with the Unicode BOM ($FF FE or $FE FF) to identify the byte order.
All numeric strings and URLs are always encoded as ISO-8859-1. Terminated strings are terminated with $00 if encoded with ISO-8859-1 and $00 00 if encoded as unicode. If nothing else is said newline character is forbidden. In ISO-8859-1 a new line is represented, when allowed, with $0A only. Frames that allow different types of text encoding have a text encoding description byte directly after the frame size. If ISO-8859-1 is used this byte should be $00, if Unicode is used it should be $01. Strings dependent on encoding is represented as <text string according to encoding>, or <full text string according to encoding> if newlines are allowed. Any empty Unicode strings which are NULL-terminated may have the Unicode BOM followed by a Unicode NULL ($FF FE 00 00 or $FE FF 00 00).“
我的理解是这样的,一个标签至少有一帧,一帧在除去帧头后至少有一字节,就是前面描述的数据帧中的帧内容,而这个帧内容就实现了encode的功能,不过我翻了数次3.3.ID3v2 frame overview我依旧没找到"01代表的就是UTF-16编码,02代表 UTF16BE,03表示的UTF8编码"这样的细分,不知道是不是后面默认的规则,还是别的原因,如果有朋友知道请务必告诉我。另外还有个很奇怪的地方,我用HexEditor2读取的网上下载的MP3文件中,它们的ID3v2头中除了标签头和标签帧以外后面都是会有很长一段空白数据,我没有输出这段空白数据也没什么影响,不会影响标签解析,但为什么会有这么一段数据,我是没有找到原因,如果有知道的朋友请务必告诉我。
好了,回到代码中,我在给标签赋的数据是UTF8编码,所以将帧头后的第一字节赋值为03,然后我们就可以顺利的随意写入指定的信息了。
当然肯定会有朋友问我,如果我们不写入这个字节会如何,那我就微笑着回答你吧“:会乱码,会有让你反复观察代码,反复参考网上的文档,抱着我明明没错但为什么解决不了的绝望心情却也解决不了的乱码“,诶,好像若有若无的透露出一些不该透露的信息呢,呵呵。
ps:如果要使用我的代码,请注意一下目前的功能只会为没有对应tag信息的MP3文件写入标签信息,不会覆盖已有的tag,这是当时的需求决定的,后期我会考虑在GitHub上完善,当然现在有这样需求的同学就请自己写啦。另外我的代码只写入了歌名,歌手名,专辑名这三个标签,如果有比如说需要写入图片之类的朋友完全可以参照我的写入代码进行扩展,毕竟原理是相同的。
3.总结
编写ID3标签写入这一模块真是段很有趣的经历,最开始的ID3v1标签实现起来十分简单,但考虑到ID3v1标签局限性太强了,就直接转ID3v2标签,本来信心满满,但一上手才发现两个的难度差别极大,前面倒好,数据的写入与帧头的计算还算简单,但写出文件后,文件信息永远是乱码,反复在网上翻找文档,一直找不到解决方法。
因为写入标签只是我临时起意,不属于公司硬性需求,所以数小时没解决后,我都打算放弃,但心里实在是不服,我干脆在网上下了新的音乐文件,用HexEditor2将下载的文件和我写入标签的音乐文件在字节层次上进行对比,同时手动通过HexEditor2来修改音乐文件信息来观察标签信息改变情况,最后发现正常的音乐文件和我自己写出的文件之间差了一个字节,其实算我运气好,因为在到这个文件前以及比对了别的文件,但当时前面几组正常音乐文件的编码不是UTF8编码,所以tag后面的信息是完全不同的,而这个文件是UTF8编码的tag,我进而猜测了问题所在,但网上的文档对于这个编码字节基本没有描述,最后花了很长时间才找到描述编码那篇文章,接着修正代码,得到正确的程序。虽然花了比较长的时间,不过修正代码后,输出正常的音乐文件时内心的满足感让我感觉值了。身为一名工程师自然应该披荆斩棘,直面难题。
本文所有代码已经托管到https://github.com/CrazyZty/WriteTag,大家可以自由下载。
嘛,这篇博文就到这里结束了,后期我有时间的话会做音频数据合成,蓝牙SPP数据通信,手机敲击感应等一系列博客,我尽量选我做过的但网上开源比较少的模块做分享,当然如果与公司以前项目有关的话,我会隐藏核心的算法与数据,以我做的基础版本来讲解(这里顺便一提,我所要分享的都是我自己做的,我是不会拿别人的东西做分享的),其他的比如蓝牙BLE通信这些网上有比较成熟方案的我会斟酌情况,如果其中有存在一些比较棘手的模块,我会考虑进行分享。