不知道大家平时用手机听歌时都用什么 App,我一直挺喜欢 iPhone 自带的 Apple Music,但是有时候我会发现导入的 mp3 歌手会显示未知歌手,
同样的,在 PC 上使用 Windows 自带的 Groove 播放时也会发现有的歌曲展示不一样。大部分从网易云、酷我音乐这些音乐软件中下载下来的 mp3,歌手名、专辑、专辑图都一应俱全:
而一些从网上或者网盘下载的 mp3,则有可能是光秃秃的一片:
如果我们在 Windows 的文件查看布局中选择详情信息时,会发现这两个文件是有区别的:
如果查看文件属性的话,在详细信息标签页,这两个文件也是有区别的:
所以播放器展示的时候,其实是取的文件详细信息中的值,那这个值是怎么来的呢?这就要说到 mp3 的标签了。
一、mp3 标签 ID3
首先 mp3 是一种数据压缩格式,但是它压缩的只是音频!所以它的文件中只有音频数据,为了保存更多信息如歌曲名、歌手名、专辑等(这些信息对于更好的体验,相信还是很有必要的),于是就产生了 mp3 的标签信息 ID3,根据不同版本又分为 ID3V1 和 ID3V2,其中 ID3V2 还细分为 4 个版本,目前主要流行的是 ID3V2 的第三个版本,即 ID3V2.3 版本。那 ID3V1 和 ID3V2 又有什么区别呢?莫慌,让我们一个一个来说。
二、ID3V1
上面说到 mp3 只是保存的音频数据信息,但为了更好的体验,我们在播放器中通常需要展示歌曲名、歌手名、专辑等,于是 1996 年,一个叫 Eric Kemp 的人发明了 mp3 标签格式 ID3,也就是 ID3V1。
ID3V1 是一组附加在音乐文件后面的数据,它的长度是固定的128 字节,这短短的 128 字节,按照固定的格式,包含了我们需要的一些信息,格式定义如下:
- Identifier:开头是固定长度 3 个字节的标识,固定内容 TAG,如果没有这个标识则认为没有 ID3V1 标签。
- Title:标题,30 字节,不足 30 字节时用 \0 补足。
- Artist:歌手,30 字节,不足 30 字节时用 \0 补足。
- Album:专辑,30 字节,不足 30 字节时用 \0 补足。
- Year:年份,4 字节。
- Comment:它有些特殊,分为 28+1+1,有时候会占 28 字节,有时候会占 30 字节,这是因为在 ID3V1.1 时 Comment 切割出来了最后两个字节,用于存放曲目序号,倒数第 2 个字节为 Reversed,如果它为 0,则表示有曲目序号,倒数第 1 个字节为曲目序号,此时 Comment 就占 28 个字节。如果它不为 0,则应该是 Comment 中的内容,就没有曲目序号,此时 Comment 占 30 个字节。
- Genre:音乐风格,1 字节,这个风格会有一个对照表,大家可以百度一下。
了解 ID3V1 的结构后,我们知道它里面的每个部分是顺序存放的,每个部分也是有固定长度的,当这个部分不足它所占字节时,就会用 \0 补足。ID3V1 的优点时它占用空间小,而且在文件尾部,并不会影响音乐的播放。但是缺点也显而易见,那就是扩展很难,ID3V1_1 扩展了一个字节来存放音轨都这么麻烦,而且固定的 128 长度,意味着没办法存太多附加信息。
再以刚才的 mp3 为例,我们查看一下它的二进制信息:
结果发现它的尾部确实没有 TAG 开头的 ID3V1 标签信息,所以在播放器中是没有歌手、专辑之类的信息的。有了上面这些对 ID3V1 的基础认知后,我们就可以通过代码来操作 ID3V1 了,这里我以 Java 为例来为这个 mp3 文件添加 ID3V1 标签:
import java.io.*;
import java.nio.charset.Charset;
/**
* Author: MrQinshou
* Email: cqflqinhao@126.com
* Date: 2023/3/10 11:30
* Description: 类描述
*/
public class Mp3Util {
private static final Charset sCharset = Charset.forName("GBK");
private static final int ID3V1_TAG_LENGTH = 128;
private static final String ID3V1_TAG_START = "TAG";
public static void main(String[] args) throws IOException {
File src = new File("C:\\Users\\admin\\Desktop\\新建文件夹\\孤勇者bkp.mp3");
setID3V1(src, "孤勇者", "陈奕迅", "孤勇者", null, null, null, null);
}
private static byte[] int2Bytes(int i) {
byte[] byteArray = new byte[4];
byteArray[0] = (byte) (i & 0xFF);
byteArray[1] = (byte) ((i & 0xFF00) >> 8);
byteArray[2] = (byte) ((i & 0xFF0000) >> 16);
byteArray[3] = (byte) ((i & 0xFF000000) >> 24);
return byteArray;
}
private static int bytes2Int(byte[] bytes) {
if (bytes == null || bytes.length < 4) {
return 0;
}
return (0xFF & bytes[0]) | (0xFF00 & (bytes[1] << 8)) | (0xFF0000 & (bytes[2] << 16)) | (0xFF000000 & (bytes[3] << 24));
}
public static void setID3V1(File src, String title, String artist, String album, Integer year, String comment, Byte track, Byte genre) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile(src, "rw");
randomAccessFile.seek(randomAccessFile.length() - ID3V1_TAG_LENGTH);
byte[] bytes = new byte[3];
randomAccessFile.read(bytes);
String tag = new String(bytes);
if (ID3V1_TAG_START.equals(tag)) {
// 以前有 ID3V1,则先获取之前的信息,再修改需要修改的
// Title 占 30 字节
bytes = new byte[30];
randomAccessFile.read(bytes);
if (title == null) {
title = new String(bytes, sCharset);
}
// Artist 占 30 字节
randomAccessFile.read(bytes);
if (artist == null) {
artist = new String(bytes, sCharset);
}
// Album 占 30 字节
randomAccessFile.read(bytes);
if (album == null) {
album = new String(bytes, sCharset);
}
// Year 占 4 字节
bytes = new byte[4];
randomAccessFile.read(bytes);
if (year == null) {
year = bytes2Int(bytes);
}
// Comment 占 28 字节,没有曲目序号时占 30 字节
bytes = new byte[30];
randomAccessFile.read(bytes);
// Reserved 占 1 字节,为 0 表示有曲目序号,下一字节为曲目序号
if (bytes[28] == 0) {
if (comment == null) {
comment = new String(bytes, 0, 28, sCharset);
}
if (track == null) {
track = bytes[29];
}
} else {
if (comment == null) {
comment = new String(bytes, sCharset);
}
}
// Genre 占 1 字节,歌曲风格,-1 表示没有风格
bytes = new byte[1];
randomAccessFile.read(bytes);
if (genre == null) {
genre = bytes[0];
}
randomAccessFile.seek(randomAccessFile.length() - ID3V1_TAG_LENGTH);
} else {
// 没有则直接定位到文件末尾追加
randomAccessFile.seek(randomAccessFile.length());
}
// TAG 3 个字符开头,占 3 个字节
bytes = ID3V1_TAG_START.getBytes();
randomAccessFile.write(bytes);
// Title 占 30 字节
bytes = new byte[30];
if (title != null) {
byte[] tmp = title.getBytes(sCharset);
System.arraycopy(tmp, 0, bytes, 0, Math.min(tmp.length, 30));
}
randomAccessFile.write(bytes);
// Artist 占 30 字节
bytes = new byte[30];
if (artist != null) {
byte[] tmp = artist.getBytes(sCharset);
System.arraycopy(tmp, 0, bytes, 0, Math.min(tmp.length, 30));
}
randomAccessFile.write(bytes);
// Album 占 30 字节
bytes = new byte[30];
if (album != null) {
byte[] tmp = album.getBytes(sCharset);
System.arraycopy(tmp, 0, bytes, 0, Math.min(tmp.length, 30));
}
randomAccessFile.write(bytes);
// Year 占 4 字节
bytes = new byte[4];
if (year != null) {
byte[] tmp = int2Bytes(year);
System.arraycopy(tmp, 0, bytes, 0, Math.min(tmp.length, 4));
}
randomAccessFile.write(bytes);
// Comment 占 28 字节,没有曲目序号时占 30 字节
if (track == null) {
// 没有曲目序号
bytes = new byte[30];
if (comment != null) {
byte[] tmp = comment.getBytes(sCharset);
System.arraycopy(tmp, 0, bytes, 0, Math.min(tmp.length, 30));
}
randomAccessFile.write(bytes);
} else {
// 有曲目序号
bytes = new byte[28];
if (comment != null) {
byte[] tmp = comment.getBytes(sCharset);
System.arraycopy(tmp, 0, bytes, 0, Math.min(tmp.length, 28));
}
randomAccessFile.write(bytes);
// Reserved 占 1 字节,为 0 表示有曲目序号,下一字节为曲目序号
bytes = new byte[]{0};
randomAccessFile.write(bytes);
// Track 占 1 字节,曲目序号
bytes = new byte[]{track};
randomAccessFile.write(bytes);
}
// Genre 占 1 字节,歌曲风格,-1 表示没有风格
bytes = new byte[1];
if (genre == null) {
bytes[0] = -1;
} else {
bytes[0] = genre;
}
randomAccessFile.write(bytes);
randomAccessFile.close();
}
public static void removeID3V1(File src) throws IOException {
System.out.println("Remove ID3V1 start.");
RandomAccessFile randomAccessFile = new RandomAccessFile(src, "rw");
randomAccessFile.seek(randomAccessFile.length() - ID3V1_TAG_LENGTH);
byte[] bytes = new byte[3];
randomAccessFile.read(bytes);
String tag = new String(bytes);
if (!ID3V1_TAG_START.equals(tag)) {
randomAccessFile.close();
System.out.println("Remove ID3V1 end.");
return;
}
randomAccessFile.setLength(randomAccessFile.length() - ID3V1_TAG_LENGTH);
System.out.println("Remove ID3V1 end.");
}
}
需要注意一下,在上面的 new String() 和 String.getBytes() 时,都指定了字符集为 GBK 编码而不是 UTF-8,否则是会乱码的。在执行上面的 main() 方法后,再查看它的二进制信息,可以看到 ID3V1 标签已经被添加到尾部了:
用播放器打开:
可以看到已经能展示 mp3 的歌曲名、歌手和专辑了,但是还没有专辑图,这个事已经超出了 ID3V1 的能力范围了,所以我们需要引入 ID3V2。
三、ID3V2
由于 ID3V1 的难以扩展,于是在 ID3V1 制定仅仅一年多后,1998 年 id3.org 的一群贡献者就制定了另一种标签格式来解决这个问题,即 ID3V2。由于 ID3V1 存放在了 mp3 文件尾部,所以 ID3V2 就只能存放在 mp3 文件头部了,因此,操作 ID3V2 时我们通常需要修改整个文件,所以效率会低一点,而且 ID3V2 的结构也会更复杂一点,但相较于 ID3V1,它有极强的扩展性,所以它还是那个被主要推广的一种标签格式,它的格式定义如下:
ID3V2 分为一个标签头和若干个标签帧,每一个标签帧又分为帧头和帧内容。
标签头固定长度 10 个字节:
- Identifier:同 ID3V1 一样,开头是固定长度 3 个字节的标识,固定内容 ID3,如果没有这个标识则认为没有 ID3V2 标签。
- Version:1 字节,主版本号,通常为 3。
- Subversion:1 字节,副版本号,通常为 0。
- Flag:1 字节,意义不大,通常为 0。
- Size:4 字节,ID3V2 标签的长度。
这个 size 有些特殊,首先它是不包括标签头的 10 个字节,然后按照 ID3V2 标准https://id3.org/id3v2.3.0#ID3v2_header 的要求,每个字节只用 7 位,最高位不使用,恒为 0,所以需要将 size 每个字节的最高位 0 位丢弃,而且它是高位在前。举个栗子:如果一个 size 是 43396,转成二进制应该是:
00000000 | 00000000 | 10101011 | 10111110 |
最后一个字节取后 7 位,即 10111110 取后 7 位 0111110,最高位补 0,变成 00111110,然后其余位左移,就变成了:
00000000 | 00000001 | 01010111 | 00111110 |
接着倒数第二个字节取后 7 位,即 01010111 取后 7 位 1010111,最高位补 0,变成 01010111,然后其余位左移,就变成了:
00000000 | 00000010 | 01010111 | 00111110 |
接着倒数第三个字节取后 7 位,即 00000010 取后 7 位 0000010,最高位补 0,变成 00000010,然后其余位左移,就变成了:
00000000 | 00000010 | 01010111 | 00111110 |
接着倒数第四个字节取后 7 位,即 00000000 取后 7 位 00000000,最高位补 0,变成 00000000,就变成了:
00000000 | 00000010 | 01010111 | 00111110 |
这是我们在写入 ID3V2 时需要做的操作,同样的,在读取的时候,需要将这个操作反过来,才能获取到正确的 ID3V2 的 size,在操作标签头的时候需要格外注意这个 size,Java 版的转换方法我先贴出来:
/**
* Author:MrQinshou
* Email:cqflqinhao@126.com
* Date: 2023/3/10 11:40
* Description: 写入 size 时编码
*/
private static int syncIntEncode(int value) {
int result = 0;
int mask = 0x7F;
while ((mask ^ 0x7FFFFFFF) > 0) {
result = value & ~mask;
result <<= 1;
result |= value & mask;
mask = ((mask + 1) << 8) - 1;
value = result;
}
return result;
}
/**
* Author:MrQinshou
* Email:cqflqinhao@126.com
* Date: 2023/3/10 11:41
* Description: 读取 size 时解码
*/
private static int syncIntDecode(int value) {
int a = 0;
int b = 0;
int c = 0;
int d = 0;
int result = 0x0;
a = value & 0xFF;
b = (value >> 8) & 0xFF;
c = (value >> 16) & 0xFF;
d = (value >> 24) & 0xFF;
result = result | a;
result = result | (b << 7);
result = result | (c << 14);
result = result | (d << 21);
return result;
}
然后就是标签帧了,标签帧同样有一个帧头,帧头也是固定长度 10 个字节:
- Id:4 字节,表示不同的帧标识,常用有 APIC(专辑图)、TALB(专辑)、TIT1(内容组描述)、TIT2(标题)、TIT3(副标题)、TPE1(艺术家),更多的 frameId 可以参考 ID3V2 标准 https://id3.org/id3v2.3.0#Declared_ID3v2_frames。
- Size:4 字节,帧内容长度,也是不包括帧头的 10 个字节,也是高位在前,但是没有别的骚操作了。
- Flag:2 字节,意义不大。
帧内容,就是我们需要写入的数据了,需要注意的是不同的信息可能会有特殊的格式,在需要写入不同的标签时,大家参考一下 ID3V2 的规范即可。如专辑图,需要按照这样的格式顺序来写入:
- <Header for 'Attached picture', ID: "APIC">
- Text encoding $xx
- MIME type <text string>
- $00
- Picture type $xx
- Description <text string according to encoding> $00 (00)
- Picture data <binary data>
有了上面这些对 ID3V2 的基础认知后,我们就可以通过代码来操作 ID3V2 了,还是以 Java 为例来为这个 mp3 文件添加 ID3V2 标签:
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* Author: MrQinshou
* Email: cqflqinhao@126.com
* Date: 2023/3/10 11:30
* Description: 类描述
*/
public class Mp3Util {
private static final String TAG = Mp3Util.class.getSimpleName();
private static final Charset sCharset = Charset.forName("GBK");
private static final int ID3V1_TAG_LENGTH = 128;
private static final String ID3V1_TAG_START = "TAG";
private static final String ID3V2_TAG_START = "ID3";
public static void main(String[] args) throws IOException {
File src = new File("C:\\Users\\admin\\Desktop\\新建文件夹\\孤勇者bkp.mp3");
File albumImg = new File("C:\\Users\\admin\\Desktop\\新建文件夹\\孤勇者.jpg");
removeID3V2(src);
setID3V2(src, "孤勇者", "陈奕迅", "孤勇者", albumImg);
}
public static byte[] reverse(byte[] origin) {
for (int i = 0; i < origin.length / 2; i++) {
byte temp = origin[i];
origin[i] = origin[origin.length - i - 1];
origin[origin.length - i - 1] = temp;
}
return origin;
}
private static byte[] int2Bytes(int i) {
byte[] byteArray = new byte[4];
byteArray[0] = (byte) (i & 0xFF);
byteArray[1] = (byte) ((i & 0xFF00) >> 8);
byteArray[2] = (byte) ((i & 0xFF0000) >> 16);
byteArray[3] = (byte) ((i & 0xFF000000) >> 24);
return byteArray;
}
private static int bytes2Int(byte[] bytes) {
if (bytes == null || bytes.length < 4) {
return 0;
}
return (0xFF & bytes[0]) | (0xFF00 & (bytes[1] << 8)) | (0xFF0000 & (bytes[2] << 16)) | (0xFF000000 & (bytes[3] << 24));
}
private static int syncIntEncode(int value) {
int result = 0;
int mask = 0x7F;
while ((mask ^ 0x7FFFFFFF) > 0) {
result = value & ~mask;
result <<= 1;
result |= value & mask;
mask = ((mask + 1) << 8) - 1;
value = result;
}
return result;
}
private static int syncIntDecode(int value) {
int a = 0;
int b = 0;
int c = 0;
int d = 0;
int result = 0x0;
a = value & 0xFF;
b = (value >> 8) & 0xFF;
c = (value >> 16) & 0xFF;
d = (value >> 24) & 0xFF;
result = result | a;
result = result | (b << 7);
result = result | (c << 14);
result = result | (d << 21);
return result;
}
private static class Frame {
private String mId;
private int mSize;
private byte[] mFlag;
private byte[] mContent;
public Frame() {
}
public Frame(String id, int size, byte[] flag, byte[] content) {
mId = id;
mSize = size;
mFlag = flag;
mContent = content;
}
}
public static void setID3V2(File src, String title, String artist, String album, File albumImg) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile(src, "r");
randomAccessFile.seek(0);
byte[] bytes = new byte[3];
randomAccessFile.read(bytes);
String tag = new String(bytes);
Map<String, Frame> frames = new HashMap<>();
if (ID3V2_TAG_START.equals(tag)) {
// 版本号
bytes = new byte[1];
randomAccessFile.read(bytes);
// 副版本号
bytes = new byte[1];
randomAccessFile.read(bytes);
// 标志,意义不大
bytes = new byte[1];
randomAccessFile.read(bytes);
// 标签内容长度,高位在前,不包括标签头的 10 个字节
bytes = new byte[4];
randomAccessFile.read(bytes);
// 标签内容长度,不包括标签头的 10 个字节,按照 ID3V2 标准 https://id3.org/id3v2.3.0#ID3v2_header 的要求,每个字节只用 7 位,
// 最高位不使用,恒为 0,所以需要将 size 每个字节的最高位 0 位丢弃,而且它是高位在前。
int size = bytes2Int(reverse(bytes));
size = syncIntDecode(size);
while (true) {
// Frame Id,4 字节
bytes = new byte[4];
randomAccessFile.read(bytes);
String frameId = new String(bytes);
if (!frameId.matches("([A-Z]|[0-9]){4}")) {
break;
}
Frame frame = new Frame();
frame.mId = frameId;
// Frame size,4 字节
bytes = new byte[4];
randomAccessFile.read(bytes);
int frameSize = bytes2Int(reverse(bytes));
frame.mSize = frameSize;
// Frame flag,2 字节,意义不大
bytes = new byte[2];
randomAccessFile.read(bytes);
frame.mFlag = Arrays.copyOf(bytes, bytes.length);
// Frame content
bytes = new byte[frameSize];
randomAccessFile.read(bytes);
frame.mContent = Arrays.copyOf(bytes, bytes.length);
frames.put(frameId, frame);
}
// 加上标签头的 10 个字节,srcRandomAccessFile seek 到 ID3V2 tag header 之后数据帧开始的位置,用于后面拷贝 mp3 数据帧
randomAccessFile.seek(size + 10);
}
if (title != null) {
// Title
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// Text encoding.
byteArrayOutputStream.write(0);
// Title data.
byteArrayOutputStream.write(title.getBytes(sCharset));
frames.put("TIT2", new Frame("TIT2", byteArrayOutputStream.size(), new byte[2], byteArrayOutputStream.toByteArray()));
byteArrayOutputStream.close();
}
if (artist != null) {
// Artist
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// Text encoding.
byteArrayOutputStream.write(0);
// Artist data.
byteArrayOutputStream.write(artist.getBytes(sCharset));
frames.put("TPE1", new Frame("TPE1", byteArrayOutputStream.size(), new byte[2], byteArrayOutputStream.toByteArray()));
byteArrayOutputStream.close();
}
if (album != null) {
// Album
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// Text encoding.
byteArrayOutputStream.write(0);
// Album data.
byteArrayOutputStream.write(album.getBytes(sCharset));
frames.put("TALB", new Frame("TALB", byteArrayOutputStream.size(), new byte[2], byteArrayOutputStream.toByteArray()));
byteArrayOutputStream.close();
}
/*
Album Image
<Header for 'Attached picture', ID: "APIC">
Text encoding $xx
MIME type <text string>
$00
Picture type $xx
Description <text string according to encoding> $00 (00)
Picture data <binary data>
*/
if (albumImg != null) {
// Album Image
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// Text encoding
byteArrayOutputStream.write(0);
// Mime type.
byteArrayOutputStream.write("image/jpeg".getBytes(StandardCharsets.UTF_8));
// 00
byteArrayOutputStream.write(0);
// Picture type
byteArrayOutputStream.write(0);
// Description
byteArrayOutputStream.write(0);
// Picture data
InputStream inputStream = null;
try {
inputStream = new FileInputStream(albumImg);
byte[] buf = new byte[1024 * 8];
int len = 0;
while ((len = inputStream.read(buf)) != -1) {
byteArrayOutputStream.write(buf, 0, len);
byteArrayOutputStream.flush();
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException ignored) {
}
}
}
frames.put("APIC", new Frame("APIC", byteArrayOutputStream.size(), new byte[2], byteArrayOutputStream.toByteArray()));
byteArrayOutputStream.close();
}
// Calculate ID3V2 size
int id3V2Size = 0;
for (Frame value : frames.values()) {
// 每一个 Frame Header 为 10 字节
id3V2Size += 10;
id3V2Size += value.mSize;
}
// ID3V2 可以先预留一些空白标签帧,这样的好处是今后如果需要增加帧只需要覆盖空白字节即可
// ,否则今后再想增加标签帧就需要又重写整个文件
// byte[] empty = new byte[0];
byte[] empty = new byte[100];
id3V2Size += empty.length;
// ID3V2 tag header
ByteArrayOutputStream id3v2TagHeader = new ByteArrayOutputStream(10);
// TAG 3 个字符开头,占 3 个字节
id3v2TagHeader.write(ID3V2_TAG_START.getBytes());
// 版本号
id3v2TagHeader.write(3);
// 副版本号
id3v2TagHeader.write(0);
// 标志,意义不大
id3v2TagHeader.write(0);
// 标签内容长度,不包括标签头的 10 个字节,按照 ID3V2 标准 https://id3.org/id3v2.3.0#ID3v2_header 的要求,每个字节只用 7 位,
// 最高位不使用,恒为 0,所以需要将 size 每个字节的最高位 0 位丢弃,而且它是高位在前。
int syncIntEncode = syncIntEncode(id3V2Size);
byte[] reverse = reverse(int2Bytes(syncIntEncode));
id3v2TagHeader.write(reverse);
File dst = new File(src.getAbsolutePath() + ".tmp");
FileOutputStream fileOutputStream = new FileOutputStream(dst);
fileOutputStream.write(id3v2TagHeader.toByteArray());
fileOutputStream.flush();
for (Frame value : frames.values()) {
// 每一个 Frame Header 为 10 字节
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(10 + value.mSize);
// Frame Id,4 字节
byteArrayOutputStream.write(value.mId.getBytes());
// Frame size,4 字节
byteArrayOutputStream.write(reverse(int2Bytes(value.mSize)));
// Frame flag,2 字节,意义不大
byteArrayOutputStream.write(value.mFlag);
// Frame content
byteArrayOutputStream.write(value.mContent);
fileOutputStream.write(byteArrayOutputStream.toByteArray());
fileOutputStream.flush();
}
fileOutputStream.write(empty);
fileOutputStream.flush();
bytes = new byte[1024 * 8];
int len = 0;
while ((len = randomAccessFile.read(bytes)) != -1) {
fileOutputStream.write(bytes, 0, len);
fileOutputStream.flush();
}
randomAccessFile.close();
src.delete();
fileOutputStream.close();
dst.renameTo(new File(src.getAbsolutePath()));
}
public static void removeID3V2(File src) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile(src, "r");
randomAccessFile.seek(0);
byte[] bytes = new byte[3];
randomAccessFile.read(bytes);
String tag = new String(bytes);
if (!ID3V2_TAG_START.equals(tag)) {
// 没有则直接 return
randomAccessFile.close();
return;
}
// 版本号
bytes = new byte[1];
randomAccessFile.read(bytes);
// 副版本号
bytes = new byte[1];
randomAccessFile.read(bytes);
// 标志,意义不大
bytes = new byte[1];
randomAccessFile.read(bytes);
// 标签内容长度,高位在前,不包括标签头的 10 个字节
bytes = new byte[4];
randomAccessFile.read(bytes);
// 标签内容长度,不包括标签头的 10 个字节,按照 ID3V2 标准 https://id3.org/id3v2.3.0#ID3v2_header 的要求,每个字节只用 7 位,
// 最高位不使用,恒为 0,所以需要将 size 每个字节的最高位 0 位丢弃,而且它是高位在前。
int size = bytes2Int(reverse(bytes));
size = syncIntDecode(size);
// 加上标签头的 10 个字节,srcRandomAccessFile seek 到 ID3V2 tag header 之后数据帧开始的位置,用于后面拷贝 mp3 数据帧
randomAccessFile.seek(size + 10);
File dst = new File(src.getAbsolutePath() + ".tmp");
FileOutputStream fileOutputStream = new FileOutputStream(dst);
bytes = new byte[1024 * 8];
int len = 0;
while ((len = randomAccessFile.read(bytes)) != -1) {
fileOutputStream.write(bytes, 0, len);
fileOutputStream.flush();
}
randomAccessFile.close();
src.delete();
fileOutputStream.close();
dst.renameTo(new File(src.getAbsolutePath()));
}
}
同样的,在 new String() 和 String.getBytes() 时,都指定了字符集为 GBK 编码而不是 UTF-8,否则是会乱码的。在执行上面的 main() 方法后,再查看它的二进制信息,可以看到 ID3V2 标签已经被添加到头部了:
再用播放器打开就可以看到专辑图也成功加上了:
四、总结
我最开始其实是因为想修改 mp3 的信息只能在电脑上一个个操作,效率太慢而且太累了,所以才想去了解 mp3 的标签到底是怎么回事,电脑在修改标签时到底改了啥。知其然知其所以然,在了解一个东西展示的原理和依据后,要去修改它其实就很简单了,现在通过代码去操作 mp3 的标签信息简直易如反掌。
事实上除了这个规范中的头,我们也可以添加一些自己自定义的头,比如把歌词文件放到 ID3V2 中,然后写一个自定义播放器去解析它,这样就不用将歌词再放到一个单独的 lrc 文件了。