Java 修改 mp3 的标签(ID3V1 和 ID3V2)

不知道大家平时用手机听歌时都用什么 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,转成二进制应该是:

00000000000000001010101110111110

最后一个字节取后 7 位,即 10111110 取后 7 位 0111110,最高位补 0,变成 00111110,然后其余位左移,就变成了:

00000000000000010101011100111110

接着倒数第二个字节取后 7 位,即 01010111 取后 7 位 1010111,最高位补 0,变成 01010111,然后其余位左移,就变成了:

00000000000000100101011100111110

接着倒数第三个字节取后 7 位,即 00000010 取后 7 位 0000010,最高位补 0,变成 00000010,然后其余位左移,就变成了:

00000000000000100101011100111110

接着倒数第四个字节取后 7 位,即 00000000 取后 7 位 00000000,最高位补 0,变成 00000000,就变成了:

00000000000000100101011100111110

这是我们在写入 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 的规范即可。如专辑图,需要按照这样的格式顺序来写入:

  1. <Header for 'Attached picture', ID: "APIC">
  2. Text encoding   $xx
  3. MIME type       <text string>
  4. $00
  5. Picture type    $xx
  6. Description     <text string according to encoding> $00 (00)
  7. 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 文件了。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
好的,以下是三个使用Java调用百度图像审核接口的示例代码: 1. 使用Java的HttpURLConnection实现: ```java import java.io.*; import java.net.*; public class ImageAuditDemo1 { public static void main(String[] args) throws Exception { String url = "https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined"; String apiKey = "Your API Key"; String secretKey = "Your Secret Key"; String filePath = "image.jpg"; URL requestUrl = new URL(url + "?access_token=" + getAccessToken(apiKey, secretKey)); HttpURLConnection conn = (HttpURLConnection) requestUrl.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); conn.setDoOutput(true); OutputStream outputStream = conn.getOutputStream(); FileInputStream fileInputStream = new FileInputStream(filePath); byte[] buffer = new byte[4096]; int bytesRead = -1; while ((bytesRead = fileInputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } fileInputStream.close(); outputStream.close(); BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line = null; while ((line = reader.readLine()) != null) { System.out.println(line); } } private static String getAccessToken(String apiKey, String secretKey) { try { String authUrl = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + apiKey + "&client_secret=" + secretKey; URL url = new URL(authUrl); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line = null; StringBuilder sb = new StringBuilder(); while ((line = reader.readLine()) != null) { sb.append(line); } reader.close(); String jsonStr = sb.toString(); int startIndex = jsonStr.indexOf("access_token") + "access_token".length() + 3; int endIndex = jsonStr.indexOf(",", startIndex) - 1; return jsonStr.substring(startIndex, endIndex); } catch (Exception e) { e.printStackTrace(); return null; } } } ``` 2. 使用Java的HttpClient实现: ```java import java.io.*; import org.apache.http.*; import org.apache.http.client.*; import org.apache.http.client.methods.*; import org.apache.http.entity.mime.*; import org.apache.http.entity.mime.content.*; import org.apache.http.impl.client.*; import org.apache.http.message.*; public class ImageAuditDemo2 { public static void main(String[] args) throws Exception { String url = "https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined"; String apiKey = "Your API Key"; String secretKey = "Your Secret Key"; String filePath = "image.jpg"; String accessToken = getAccessToken(apiKey, secretKey); HttpClient httpClient = HttpClientBuilder.create().build(); HttpPost httpPost = new HttpPost(url + "?access_token=" + accessToken); FileBody fileBody = new FileBody(new File(filePath)); MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addPart("image", fileBody); HttpEntity entity = builder.build(); httpPost.setEntity(entity); HttpResponse response = httpClient.execute(httpPost); HttpEntity responseEntity = response.getEntity(); BufferedReader reader = new BufferedReader(new InputStreamReader(responseEntity.getContent())); StringBuilder sb = new StringBuilder(); String line = null; while ((line = reader.readLine()) != null) { sb.append(line); } System.out.println(sb.toString()); } private static String getAccessToken(String apiKey, String secretKey) { try { String authUrl = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + apiKey + "&client_secret=" + secretKey; HttpClient httpClient = HttpClientBuilder.create().build(); HttpGet httpGet = new HttpGet(authUrl); HttpResponse response = httpClient.execute(httpGet); HttpEntity entity = response.getEntity(); BufferedReader reader = new BufferedReader(new InputStreamReader(entity.getContent())); StringBuilder sb = new StringBuilder(); String line = null; while ((line = reader.readLine()) != null) { sb.append(line); } reader.close(); String jsonStr = sb.toString(); int startIndex = jsonStr.indexOf("access_token") + "access_token".length() + 3; int endIndex = jsonStr.indexOf(",", startIndex) - 1; return jsonStr.substring(startIndex, endIndex); } catch (Exception e) { e.printStackTrace(); return null; } } } ``` 3. 使用Java的OkHttp实现: ```java import java.io.*; import okhttp3.*; public class ImageAuditDemo3 { public static void main(String[] args) throws Exception { String url = "https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined"; String apiKey = "Your API Key"; String secretKey = "Your Secret Key"; String filePath = "image.jpg"; String accessToken = getAccessToken(apiKey, secretKey); OkHttpClient httpClient = new OkHttpClient(); RequestBody requestBody = new MultipartBody.Builder().setType(MultipartBody.FORM) .addFormDataPart("image", "image.jpg", RequestBody.create(MediaType.parse("image/jpeg"), new File(filePath))) .build(); Request request = new Request.Builder().url(url + "?access_token=" + accessToken).post(requestBody).build(); Response response = httpClient.newCall(request).execute(); String responseBody = response.body().string(); System.out.println(responseBody); } private static String getAccessToken(String apiKey, String secretKey) { try { String authUrl = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + apiKey + "&client_secret=" + secretKey; OkHttpClient httpClient = new OkHttpClient(); Request request = new Request.Builder().url(authUrl).get().build(); Response response = httpClient.newCall(request).execute(); String responseBody = response.body().string(); int startIndex = responseBody.indexOf("access_token") + "access_token".length() + 3; int endIndex = responseBody.indexOf(",", startIndex) - 1; return responseBody.substring(startIndex, endIndex); } catch (Exception e) { e.printStackTrace(); return null; } } } ``` 需要注意的是,以上示例代码中的 `Your API Key` 和 `Your Secret Key` 应该替换成你在百度AI平台上申请的API Key和Secret Key。同时,示例代码中的图片路径也需要替换成你自己的图片路径。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值