Flutter下 ID3 编码实现-超详细

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

前文引导👇

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

Flutter ID3解码实现-v2.4

前面两篇文章都是讲述的如何去解码MP3中的ID3信息,现在,终于到了实现编码的时候了。一旦实现了编码部分,我在开篇中讲述的下载MP3后信息存储的问题也将由刃而解。而已经实现的解码部分则可以辅助我们验证编码的成功与否,当然你也可以使用社区相关解析库进行验证。

话不多说,我们先从最简单的ID3v1开始。

编码ID3v1

有关ID3v1数据结构的问题,可以阅读我前面写过的相关文章,这里不再做详细讲述。

要实现编码,那么首先我们需要读取MP3数据,并判断当前MP3文件中是否已经存在ID3v1标签了。如果存在,那么就是编辑标签,否则就需要在文件末尾添加128个字节作为接下来的占位符使用。

我对外定义了一个统一的类,通过初始化类ID3Encoder来调用decode:接口实现不同版本ID3的编码过程。

int start = bytes.length - 128;

    final codec = ByteCodec();
    List<int> output = List.from(bytes);

    // exist ID3v1 tags
    if (start >= 0 && codec.decode(output.sublist(start, start + 3)) == 'TAG') {
      start += 3;
    } else {
      start = bytes.length;
      final append = List.filled(128, 0x00);
      output.addAll(append);
      final idBytes = codec.encode('TAG', limitByteLength: 3);
      output.replaceRange(start, start + 3, idBytes);
      start += 3;
    }

那么,接下来就是根据模型MetadataV1Body来填充占位符的过程了。MetadataV1Body结构如下:

class MetadataV1Body extends EncodeMetadata {
  const MetadataV1Body(
      {this.title,
      this.artist,
      this.album,
      this.year,
      this.comment,
      this.track,
      this.genre});
// 歌曲标题
  final String? title;
// 艺术家
  final String? artist;
  // 专辑名字 
  final String? album;
  // 年份,4个字符,'2022‘
  final String? year;
  // 评论 
  final String? comment;
  // 艺术类型 
  final int? genre;
  // 歌曲通道
  final int? track;
}

这里需要注意的是,由于ID3v1只支持ISO_8859_1解码,因此你不能输入中文(或其他ISO_8859_1不支持的字符集),否则在解码的时候会报错。

对于已经存在的字段,比如Title已经存在了,如果你在MetadataV1Body中又给title传入了非空的值,那么将覆盖原文件中的Title。其他字段同理,不再赘述。

还有一点,ID3Encoder只是对字节组做了编码操作,并没有对源文件进行任何修改,因此,如果你需要修改源文件,只需要再将修改后的字节覆盖掉源文件中的字节就可以了。就像下面这样。

final file = File('assets/some_song.mp3');
file.writeAsBytes(updateBytes);

我从网上下载了一首歌,使用ID3Decoder解码后可以看到只有ID3v2.3的一些简单信息。

flutter: [ ID3MetaInfo ]
flutter: - Range: {0, 45}
flutter: == Header ==
flutter: - FileID: ID3
flutter: - Version: V2.3.0
flutter: - Size: 35
flutter: == Frames ==
flutter: - Frame ID: TSSE[Software/Hardware and settings used for encoding]
flutter: - Frame Size: 15
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]
flutter: - Content: {Information: Lavf57.71.100
flutter: == Padding ==
flutter: - Padding Size: 10
flutter: =========> All data received <=======

然后,我随意填一些数据进去,看我操作哒哒哒:

final encoder = ID3Encoder(bytes);
              // ignore: prefer_const_constructors
              bytes = encoder.encode(MetadataV1Body(
                title: 'Ting wo shuo,xiexie ni',
                artist: 'Wu ming',
                album: 'Gan en you ni',
                year: '2021',
                comment: 'I am very happy!',
                track: 1,
                genre: 2
               ));

然后再次读取下编辑后的bytes试试,可以看到ID3MetaInfo更新了,存在了两个ID3标签信息。并且,由于我穿入了TRACK信息,因此这个标签还被解析为了ID3v1.1版本。这就表示,我们的ID3v1编码部分已经大功告成。

flutter: [ ID3MetaInfo ]
flutter: - Range: {10885791, 10885919}
flutter: == Version ==
flutter: - Version: V1.1
flutter: == Title ==
flutter: - Title: Ting wo shuo,xiexie ni
flutter: == Artist ==
flutter: - Artist: Wu ming
flutter: == Album ==
flutter: - Album: Gan en you ni
flutter: == Year ==
flutter: - Year: 2021
flutter: == Comment ==
flutter: - Comment: I am very happy!
flutter: == Reserve ==
flutter: - Reserve: 1
flutter: == Track ==
flutter: - Track: 1
flutter: == Genre ==
flutter: - Genre: Country
flutter: =========> All data received <=======
flutter: [ ID3MetaInfo ]
flutter: - Range: {0, 45}
flutter: == Header ==
flutter: - FileID: ID3
flutter: - Version: V2.3.0
flutter: - Size: 35
flutter: == Frames ==
flutter: - Frame ID: TSSE[Software/Hardware and settings used for encoding]
flutter: - Frame Size: 15
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]
flutter: - Content: {Information: Lavf57.71.100
flutter: == Padding ==
flutter: - Padding Size: 10
flutter: =========> All data received <=======

接下来,难度升级,我们要开始编码ID3v2部分了。

编码ID3v2

老实说,当时写ID3v1编码部分只花了一天时间。但是到写ID3v2.3花了我一周时间,顺便还改了不少BUG😊。

因为ID3v2.2目前支持较少,所以我也不准备支持其编码,有需求再说嘛。因此我们直接从v2.3开始实现。

ID3v2.3

ID3v2.3是比较流行的一个版本。它存在于文件的头部,以3个字节的“ID3”作为起始标识。如果没有找到ID3标识,那么这个文件只会有三种可能,ID3v1编码,ID3v2.4编码且位于文件尾部,没有ID3信息。

针对没有找到ID3标识的情况,我们可以认为其不存在ID3v2.3标签。因此,我们需要根据给定信息创建一个新的ID3v2.3标签出来。

我们先定一个MetadataV2_3Body类,用于封装我们需要修改或添加的数据。title表示歌曲标题,artist表示歌曲的艺术家,album表示歌曲的专辑信息,imageBytes表示歌曲的封面图,encoding表示歌曲的编码方式,userDefines表示用户自定义的数据,以键值对的形式传入。

class MetadataV2_3Body extends MetadataV2Body {
  const MetadataV2_3Body({
    this.title,
    this.artist,
    this.album,
    this.encoding,
    this.imageBytes,
    this.userDefines,
  });

  /// TIT2[Title/songname/content description]
  final String? title;
  /// TPE1[Lead performer(s)/Soloist(s)]
  final String? artist;
  /// TALB[Album/Movie/Show title]
  final String? album;
  /// APIC[Attached picture]
  /// Support PNG, JPEG, TIFF, GIF, BMP
  final List<int>? imageBytes;
  /// TSSE[Software/Hardware and settings used for encoding]
  final String? encoding;
  /// TXXX[User defined text information frame]
  final Map<String, String>? userDefines;
}

当你输入某个属性时,则表示你想添加某个数据帧或修改某个数据帧。属性对应的数据帧的ID已在其注释中标注。当属性传入null时,表示不修改这个属性对应的frame,而不是删除。

创建ID3体

关于ID3v2.3的数据结构我在文章已经做过介绍,因此这里简单过一遍。它的头部结构如下所示,占10个字节。

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

其中,size字段表示除了头部之外的所有数据的大小。也就是总大小-头部(10字节)= size。但是size和数据帧大小有关,而数据帧我们还没有进行编码,无法计算其总大小,因此这里先把size放一放,先去把数据帧的编码工作完成。

数据帧架的头部结构在v2.3中如下所示,由三部分组成,FrameIDFrameSizeFlags。总共占10个字节。其中frameSize是指一个数据帧的内容部分的大小,不包括头部的10个字节。

字段 大小/字节 描述
Frame ID 4 $xx xx xx xx (four characters) 帧的标识符,4个字节
Frame size 4 $xx xx xx xx 帧大小,除去frame header这10个字节后的大小。因此实际一个数据帧大小为 10+size
Flags 2 $xx xx 旗帜信息
字段 大小/字节 描述
Frame Header 10 数据帧头:frame ID,frameSize,flags
Content 不定 数据帧的内容

紧跟在数据帧头部后面的就是内容Content,它的长度保存在frameSize中,结构各不相同,具体可以参考v2.3.0文档

知道了数据帧的数据格式,我们就可以根据给定内容来创建一个新的数据帧出来。我们拿歌曲标题为例子,歌曲标题在ID3v2.3的数据帧架集中对应的ID为TIT2,描述为Title/songname/content description。这个ID属于T开头,但是又不是TXXX,因此归类于Text information frame

<Header for 'Text information frame', ID: "T000" - "TZZZ",
     excluding "TXXX" described in 4.2.2.>
     Text encoding                $xx
     Information                  <text string according to encoding>

可以看到,Text information frametext encodingInformation组成,text encoding占1个字节,表示Inforamtion的编码方式,在ID3v2.3中,encoding只会是0x000x01

  • 0x00: ISO-8859-1,在dart中就是latin1。它无法编码中文。
  • 0x01: unicode,在这里用的是UTF-16编码,注意大小端问题。

由于歌曲标题可能为中文,因此我们需要用到UTF-16进行编码,在dart中,没有直接将字符串用UTF-16编码后转字节序的API。因此,我们需要自己做个转换。

首先要明确,UTF-16编码区分大小端,有BE和LE之分。

  • BE 即 big-endian,大端的意思。大端就是将高位的字节放在低地址表示
  • LE 即 little-endian,小端的意思。小端就是将高位的字节放在高地址表示

0xFE,0xFF开头的就是BE形式的UTF-16,以0xFF,0xFE开头的是LE形式的UTF-16。

并且,UTF-16是2个字节2个字节读取进行编解码的。因此对于UTF-16的编码有下面的转换。

List<int> _encodeWithUTF16(String string, {String bom = 'BE'}) {
    if (bom == 'BE') {
      return _encodeWithUTF16BE(string);
    } else {
      return _encodeWithUTF16LE(string);
    }
  }

  List<int> _encodeWithUTF16BE(String string) {
    List<int> output = [0xFE, 0xFF];
    final utf16Bytes = string.codeUnits;
    for (int i = 0; i < utf16Bytes.length; i++) {
      final utf16Byte = utf16Bytes[i];
      output.add((utf16Byte & 0xFF00) >>> 8);
      output.add((utf16Byte & 0xFF));
    }
    return output;
  }

  List<int> _encodeWithUTF16LE(String string) {
    List<int> output = [0xFF, 0xFE];
    final utf16Bytes = string.codeUnits;
    for (int i = 0; i < utf16Bytes.length; i++) {
      final utf16Byte = utf16Bytes[i];
      output.add((utf16Byte & 0xFF));
      output.add((utf16Byte & 0xFF00) >>> 8);
    }
    return output;
  }

知道如何编码成UTF-16之后,我们就可以进行Content的整体编码工作了。

字段 大小/字节 内容
text encoding 1 0x01
information 编码后的字节长度 UTF-16对歌曲标题进行编码后的字节数组

具体实现如下,output就是整个content表示的字节数组。

class _TextInfomationEncoder extends _ContentEncoder {
  _TextInfomationEncoder(super.frameID);

  @override
  List<int> encode(content) {
    List<int> output = [];

    // set text encoding 'UTF16'
    const defaultTextEncoding = 0x01;
    final codec = ByteCodec(textEncodingByte: defaultTextEncoding);

    // text encoding
    output.add(defaultTextEncoding);

    // information
    final infoBytes = codec.encode(content);
    output.addAll(infoBytes);

    return output;
  }
}

关于数据帧的编码我这里再将一个APIC,也就是代表封面图的数据帧。APIC的数据帧结构略微复杂点,它需要我们传入图片的MIME type,图片类型,描述(这个一般都不传),图片的字节组。这里就涉及到MIME Type的获取。

<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>

图片编码方式获取

我们说桌面上有一张图片,它的类型不是文件名后缀是什么,它就是什么类型,而是要解析文件头来确定的。一般来说,我们只要获取到文件头的前两个字节就可以确认这张图片是什么类型。当然,这种方式只对图片文件有效。具体来说,看下面的映射关系:

  • JPEG: 0xFF 0xD8
  • PNG: 0x89 0x50
  • GIF: 0x47 0x49
  • BMP: 0x42 0x4D
  • TIFF: 0x4D 0x4D或0x49 0x49

上面我只列出了常见的图片编码判断。当我们知道图片的编码方式后,也就知道了其MIME type。如一张png图片,它的MIME type是image/png

一旦获取到MIME type,接下来的事情就好办了。由于涉及图片的编码没有中文,因此,我们可以将text encoding设置为ISO-8859-1,对应的标识为0x00。在编码MIME type的时候,要注意结尾跟上一个0x00,表示结束位置。而Picture type对应的映射表在这里可查,默认传0x00即可,表示其他(Other)。那么对于APIC的编码就完成了,完整代码如下所示:

class _APICEncoder extends _ContentEncoder {
  _APICEncoder(super.frameID);

  @override
  List<int> encode(content) {
    List<int> output = [];

    // set text encoding 'ISO_8859_1'
    const defaultTextEncoding = 0x00;
    final codec = ByteCodec(textEncodingByte: defaultTextEncoding);

    // text encoding
    output.add(defaultTextEncoding);

    // MIME type
    String mimeType = ImageCodec.getImageMimeType(content);
    final mimeBytes =
        codec.encode(mimeType, forceType: ByteCodecType.ISO_8859_1);
    output.addAll(mimeBytes);
    output.add(0x00);

    // Picture type, default set to 'Other'
    final pictypeBytes = 0x00;
    output.add(pictypeBytes);

    // Description
    if (defaultTextEncoding == 0x01 || defaultTextEncoding == 0x02) {
      output.addAll([0x00, 0x00]);
    } else {
      output.addAll([0x00]);
    }

    // Picture data
    output.addAll(content);

    return output;
  }
}

计算总数据帧大小

我们根据传入的歌曲信息,进行数据帧的编码,最终得到一组数据帧字节framesBytes

final contentEncoder = ContentEncoder(body: MetadataV2_3Wrapper(data));
final framesBytes = contentEncoder.encode();

一般,为了方便编辑ID3的数据,而不影响音频数据,导致其平凡的被编排。我们往往会在结尾的位置插入PaddingPadding由一组0x00组成,长度随意,我默认给它100的长度。而Padding+framesBytes就是Headersize的大小。我们在计算完大小后,需要重新修改Header中记录size的值。

final size = framesBytes.length + padding.length;
    List<int> sizeBytes = ByteUtil.toH0Bytes(size);
    header.replaceRange(_calSizeStart, _calSizeStart + 4, sizeBytes);

到这里,新建一个ID3v2.3的所有工作就完成了。总结下,就是先编码ID3的Header部分,记录下size字段的起始位置,方便后续更改。然后编码数据帧部分,计算整个数据帧+padding的大小,将结果记录到Header的size字段中

List<int> _createNewID3Body(MetadataV2_3Body data) {
    int start = 0;
    final codec = ByteCodec();

    List<int> insetBytes = [];
    // Create Header
    final header = List.filled(10, 0x00);
    final id3TagBytes = codec.encode('ID3', limitByteLength: 3);
    header.replaceRange(start, start + 3, id3TagBytes);
    start += 3;

    // version + revision
    header.replaceRange(start, start + 1, [3]);
    start += 2;

    // Flags
    final flagsByte = 0x00;
    header.replaceRange(start, start + 1, [flagsByte]);
    start += 1;

    // Size
    // The ID3v2 tag size is the size of the complete tag after
    // unsychronisation, including padding, excluding the header but not
    // excluding the extended header (total tag size - 10)
    //
    // `size = extended header + frames + padding`
    _calSizeStart = start;
    start += 4;

    // No Extended Header

    // Frames
    final contentEncoder = ContentEncoder(body: MetadataV2_3Wrapper(data));
    final framesBytes = contentEncoder.encode();

    // Padding
    final padding = _defaultPadding;

    // Calculate the size of `size` and store it in 4 bytes
    final size = framesBytes.length + padding.length;
    List<int> sizeBytes = ByteUtil.toH0Bytes(size);
    header.replaceRange(_calSizeStart, _calSizeStart + 4, sizeBytes);

    // package  all bytes
    insetBytes.addAll(header);
    insetBytes.addAll(framesBytes);
    insetBytes.addAll(padding);
    return insetBytes;
  }

修改ID3体

创建相对容易,编辑则比较复杂。因为这部分涉及到字节的重新排序。我们回到最初的逻辑分支中,扫描文件头时,发现了“ID3”标识符。也就是存在ID3v2版本,有可能是2.2,2.3或2.4。

List<int> encode(MetadataV2_3Body data) {
    int start = 0;
    final idBytes = _output.sublist(start, start + 3);
// 存在ID3v2
    if (latin1.decode(idBytes) == 'ID3') {
      start += 3;
      _decodeDeep(start, data);
    } else {
      final insetBytes = _createNewID3Body(data);
      _output.insertAll(0, insetBytes);
    }
    return _output;
  }

在编码前,我们先思考下,不是v2.3的情况下怎么处理?

答案是抹去除Header之外的所有数据。因为v2.2在数据帧上和v2.3有很大的不同,单是frameID在v2.2上是3个字节,到v2.3就有4个字节。而v2.4虽然数据帧的结构和v2.3基本一致,但是在种类上有差别。并且v2.3和v2.4的Extended Header的结构完全不同,因此也不能简单的进行编辑。为了方便处理,一旦识别ID3的版本不是v2.3时,直接将后面的数据抹除。抹除之后就和创建一个新的ID3v2.3在处理数据帧上基本一致。

void _decodeDeep(int start, MetadataV2_3Body data) {
    // version
    final major = _output.sublist(start, start + 1).first;
    start += 2;

    // flags - %abc00000
    final flags = _output.sublist(start, start + 1).first;
    start += 1;
    // We only need to pay attention to whether there is an Extended Header
    final hasExtendedHeader = (flags & 0x40) != 0;

    // size - 4 * %0xxxxxxx
    List<int> sizeBytes = _output.sublist(start, start + 4);
    _calSizeStart = start;
    start += 4;

    // The ID3v2 tag size is the size of the complete tag after
    // unsychronisation, including padding, excluding the header but not
    // excluding the extended header (total tag size - 10).
    final size = ByteUtil.calH0Size(sizeBytes);
    _size = size;

    // If it is not v2.3 version, tag data will be erased
    if (major != 3) {
      // 编码全新的数据帧
      final contentEncoder = ContentEncoder(body: MetadataV2_3Wrapper(data));
      final framesBytes = contentEncoder.encode();
// 这里当新编码的数据帧小于原始数据帧大小时,将多余的部分(filledLength)全部替换为0x00
      if (framesBytes.length <= size) {
        final filledLength = size - framesBytes.length;
        final insertBytes = framesBytes + List.filled(filledLength, 0x00);
        _output.replaceRange(start, start + insertBytes.length, insertBytes);
      } else {
        // 新编码的数据比原始数据大,需要插入更多的占位0x00,长度为moveLength
        final insertBytes = framesBytes + _defaultPadding;
        final moveLength = insertBytes.length - size;
        _output.insertAll(start + size, List.filled(moveLength, 0x00));
        _output.replaceRange(start, start + insertBytes.length, insertBytes);

        // new size
        _size = insertBytes.length;

// 更新数据帧size大小
        List<int> sizeBytes = ByteUtil.toH0Bytes(_size);
        _output.replaceRange(_calSizeStart, _calSizeStart + 4, sizeBytes);
      }
    }
// ...

当ID3的版本就是v2.3时,这里就不能简单的抹除frames了。需要逐一解析,碰到FrameID一致的,则进行编辑,并修改字节的编码书序。

首先,我们要明确,在Header中计算出来的size=frameSize+extendedSize+padding。因此,我们在逐一解码并编辑的时候,需要减掉extendedSize,才是frames的总大小。

没个frame都有一个2字节大小的flags,它的结构为%abc00000 %ijk00000。共有6个值。但是我们在做编辑的时候,只要关注其中两个值ci即可。

  • c: Read Only
  • i: Compression

c代表这个frame是否只是只读的,如果存在c不为0,那么,这个frame不能被编辑。i代表是否压缩,这里用的压缩算法时zlib,在dart中可以直接调用,对象名就叫zlib

编辑数据帧的核心逻辑就是,先匹配FrameID,编码内容,比较重新编码后的数据帧Content和原始Content大小,在整个字节组上进行字节重排。然后更新frame的size大小,更新frame的content值

// 内容编辑器
class ContentEditor {
  const ContentEditor({required this.bytes});
// ...
}

// 编辑某一属性
EditorResult _editFrameWithProperty(int start, String frameID, int frameSize,
      bool compression, MetadataProperty property) {
// 内容编码器,将歌曲信息编码成字节组
    final contentEncoder = ContentEncoder();
    List<int> contentBytes =
        contentEncoder.encodeProperty(frameID: frameID, property: property);
// 编码不成功,有可能是没有对应的内容编码器导致的
    if (contentBytes.isEmpty) {
      return EditorResult.noEdit(frameID: frameID, start: start, frameSize: frameSize);
    }

// 如果需要压缩,调用zlib
    if (compression) {
      // store 'decompressed size' in 4 bytes
      final contentSize = contentBytes.length;
      List<int> decompressedSizeBytes = ByteUtil.toH1Bytes(contentSize);
      contentBytes = decompressedSizeBytes + zlib.encode(contentBytes);
    }
    final int contentLength = contentBytes.length;
// 重新编排字节序,保证后续的解码能正确进行
    // adjust the size of frame content
    if (contentLength <= frameSize) {
      // shrink size
      int shrinkStart = start + contentLength;
      final shrinkSize = frameSize - contentLength;
      bytes.removeRange(shrinkStart, shrinkStart + shrinkSize);
    } else {
      // expansion
      final expansionSize = contentLength - frameSize;
      int expansionStart = start + frameSize;
      bytes.insertAll(expansionStart, List.filled(expansionSize, 0x00));
    }
    bytes.replaceRange(start, start + contentLength, contentBytes);

// 重新计算frame的大小,并更新到frame header中
    // recalculate content size
    frameSize = contentLength;
    List<int> sizeBytes = ByteUtil.toH1Bytes(frameSize);
    
    // 6 = size(4 bytes) + flags(2 bytes)
    int frameSizeStart = start - 6;
    bytes.replaceRange(frameSizeStart, frameSizeStart + 4, sizeBytes);

    start += frameSize;
// 返回编码后的数据帧内容
    return EditorResult(
        frameID: frameID, start: start, modify: true, frameSize: frameSize);
  }

上面的代码详细描述了如何根据传入的属性去编辑某一数据帧的逻辑,但是,有个问题,我们便利原始数据帧,一旦在MetadataV2_3Body中有新的属性,那么必然会出现新增属性无法编码到ID3中的情况,毕竟都没匹配到。所以,我们在后续还要收集未编码的属性,重新在尾部添加进去。

// 存在未编码的属性
    if (wrapperData.hasUnAttachedProperty()) {
final contentEncoder = ContentEncoder(body: data);
      final attachedBytes = contentEncoder.encode();
      if (attachedBytes.isNotEmpty) {
        // 剩余的大小不够编码后的数据,因此需要扩容
        if (remainingSize < attachedBytes.length) {
          final expansionSize = attachedBytes.length - remainingSize;
          _output.insertAll(start + remainingSize, List.filled(expansionSize, 0x00));

          // calculate new `_size` to 4 bytes
// 重新计算总数据帧大小
          _size += expansionSize + _defaultPadding.length;
          List<int> sizeBytes = ByteUtil.toH0Bytes(_size);
          // update size
          _output.replaceRange(_calSizeStart, _calSizeStart + 4, sizeBytes);
        }
// 将新的数据帧插入到ID3的原始frames后面
        // insert new frames
        _output.replaceRange(start, start + attachedBytes.length, attachedBytes);
      }
    }

到这里,我们就完成了编辑数据帧的工作,它的所有代码都在id3_encoder_impl.dart文件中,感兴趣的可以去仓库看完整代码。有不清楚的或错误的地方也希望大家能在评论区中指出。

验证

我们准备一首歌(最好是带有ID3v2信息的),Mac电脑自带ID3信息浏览功能,选中音乐,点击空格即可。看我下图中所示,显示出这首歌歌名为“老公赚钱老婆花”,艺人为“大庆小芳”,专辑为“老公赚钱老婆花”,还有一个花里花俏的封面图。当然你也可以选择右键选择显示简介,能够看到更相信的内容。

截屏2022-11-29 15.38.20.png

我们先用我们的程序id3_decoder解析一下这个mp3文件,可以看到如下信息,嗯,是ID3v2.3,且歌曲标题,专辑,艺人等都能对上。

flutter: [ ID3MetaInfo ]

flutter: - Range: {0, 59663}

flutter: == Header ==

flutter: - FileID: ID3

flutter: - Version: v2.3.0

flutter: - Size: 59653

flutter: == Frames ==

flutter: - Frame ID: TCON[Content type]

flutter: - Frame Size: 4

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: Pop}

flutter: - Frame ID: TSSE[Software[/Hardware]() and settings used for encoding]

flutter: - Frame Size: 13

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: Lavf56.4.101}

flutter: - Frame ID: APIC[Attached picture]

flutter: - Frame Size: 58459

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {MIME: image[/jpg](), PictureType: Other, Description: , Base64: <Has Picture Data>}

flutter: - Frame ID: TALB[Album[/Movie/Show]() title]

flutter: - Frame Size: 17

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: 老公赚钱老婆花}

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[%abc00000 %ijk00000]

flutter: - Content: {Information: 老公赚钱老婆花}

flutter: - Frame ID: COMM[Comments]

flutter: - Frame Size: 539
flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Language: XXX, Short content descrip: , The actual text: 163 key(Don't modify):L64FU3W4YxX3ZFTmbZ+8[/ZMEKmM7caMfLyAwYrpPJYhFT6AEWOrgZxyH/7s5ZDPF4quVX/AFaA4gymaih8RjvaTtK45CfOanxoXqWzvlzPL7VwLQSGc5BunknudHXiQ/xI1i0rvWNr4kNGVQawO2kh8Hr8hM4oMW5]()+lkYeM0Zy9ry[/zpZPxEDkm1PyBM0dqXGionXYMSuzqlEvtUoslNFQRSaOKrjt83c40nL]()+9O42jEKmuzLSJtlS4vxPco8YTj4IkOFE1bwLOl2MeIKuy0D+kSwx3gPwFACwMHRWg6Q84LpA6BDVCoWxm+gypTzNlcSNYJ0XPadMRIn9n8LO2GNXEwJdTYHF1[/cw1smooDToqvvLeQmTQ999SToH]()+ulR801BAKwhgqD5B9xqWgD9pIOMKIn1QzHyuN31s[/iakPGq1RcwcUUSeR2qxzOGUCmvqvtGSfhKKpkFwq2VynrZlwMv]()+6hof3IyWpd+Ie1W14rgfT[/WULNvuYr0OnHu4SkisU]()}

flutter: - Frame ID: TPE1[Lead performer(s)[/Soloist]()(s)]

flutter: - Frame Size: 11

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: 大庆小芳}

flutter: - Frame ID: TRCK[Track number[/Position]() in set]

flutter: - Frame Size: 5

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: 1}

flutter: == Padding ==

flutter: - Padding Size: 508

flutter: =========> All data received <=======

接下来,就是见证奇迹的时刻。

我们要对已知的ID3进行编辑,我们初始化ID3Encoder,并将ID3编辑成v2.3。

final header = await rootBundle.load("assets/wx_header.png");
final headerBytes = header.buffer.asUint8List();

final encoder = ID3Encoder(bytes);
// ignore: prefer_const_constructors
final resultBytes = encoder.encodeSync(MetadataV2_3Body(
  title: '听我说谢谢你!',
  imageBytes: headerBytes,
  artist: '歌手ijinfeng',
  userDefines: {
    "时长": '2:48',
    "userId": "ijinfeng"
  },
  album: 'ijinfeng出产的专辑',
  ));

在上面,我们修改了歌曲的标题,艺人,专辑以及歌曲的封面图,并且,我们还插入了两组自定义数据,分别是时长userId

我们将返回的resultBytes传入到ID3Decoder中再次进行解码,检查下对应的frame是否有变,并且不影响其他数据帧的解析。

flutter: [ ID3MetaInfo ]

flutter: - Range: {0, 186331}

flutter: == Header ==

flutter: - FileID: ID3

flutter: - Version: v2.3.0

flutter: - Size: 186321

flutter: == Frames ==

flutter: - Frame ID: TCON[Content type]

flutter: - Frame Size: 4

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: Pop}

flutter: - Frame ID: TSSE[Software[/Hardware]() and settings used for encoding]

flutter: - Frame Size: 13

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: Lavf56.4.101}

flutter: - Frame ID: APIC[Attached picture]

flutter: - Frame Size: 185103

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {MIME: image[/png](), PictureType: Other, Description: , Base64: <Has Picture Data>}

flutter: - Frame ID: TALB[Album[/Movie/Show]() title]

flutter: - Frame Size: 29

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: ijinfeng出产的专辑}

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[%abc00000 %ijk00000]

flutter: - Content: {Information: 听我说谢谢你!}
flutter: - Frame ID: COMM[Comments]

flutter: - Frame Size: 539

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Language: XXX, Short content descrip: , The actual text: 163 key(Don't modify):L64FU3W4YxX3ZFTmbZ+8[/ZMEKmM7caMfLyAwYrpPJYhFT6AEWOrgZxyH/7s5ZDPF4quVX/AFaA4gymaih8RjvaTtK45CfOanxoXqWzvlzPL7VwLQSGc5BunknudHXiQ/xI1i0rvWNr4kNGVQawO2kh8Hr8hM4oMW5]()+lkYeM0Zy9ry[/zpZPxEDkm1PyBM0dqXGionXYMSuzqlEvtUoslNFQRSaOKrjt83c40nL]()+9O42jEKmuzLSJtlS4vxPco8YTj4IkOFE1bwLOl2MeIKuy0D+kSwx3gPwFACwMHRWg6Q84LpA6BDVCoWxm+gypTzNlcSNYJ0XPadMRIn9n8LO2GNXEwJdTYHF1[/cw1smooDToqvvLeQmTQ999SToH]()+ulR801BAKwhgqD5B9xqWgD9pIOMKIn1QzHyuN31s[/iakPGq1RcwcUUSeR2qxzOGUCmvqvtGSfhKKpkFwq2VynrZlwMv]()+6hof3IyWpd+Ie1W14rgfT[/WULNvuYr0OnHu4SkisU]()}

flutter: - Frame ID: TPE1[Lead performer(s)[/Soloist]()(s)]

flutter: - Frame Size: 23

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: 歌手ijinfeng}

flutter: - Frame ID: TRCK[Track number[/Position]() in set]

flutter: - Frame Size: 5

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Information: 1}

flutter: - Frame ID: TXXX[User defined text information frame]

flutter: - Frame Size: 19

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Description: 时长, Value: 2:48}

flutter: - Frame ID: TXXX[User defined text information frame]

flutter: - Frame Size: 35

flutter: - Frame Flags: a: 0, b: 0, c: 0, i: 0, j: 0, k: 0[%abc00000 %ijk00000]

flutter: - Content: {Description: userId, Value: ijinfeng}

flutter: == Padding ==
flutter: - Padding Size: 434

flutter: =========> All data received <=======

可以清晰的看到,代表歌曲标题的TIT2等都已经修改成了最新的值。说明我们的编辑是成功的。我们做次最后的确认,将编辑后的字节写入到文件,生成一首新的mp3文件,然后使用mac自带的预览功能查看下是否真的修改成功。

var file = File('${downloadDir?.path}/rewrite_song.mp3');
                  final exist = await file.exists();
                  if (!exist) {
                    await file.create(recursive: true);
                  }
                  await file.writeAsBytes(resultBytes);

截屏2022-11-29 17.37.26.png

打开预览,熟悉的音乐响起,老公赚钱老婆花~,🎉🎉🎉修改成功了。

总结

本文详细讲解了有关ID3的编码过程,内容涉及字节操作运算,ID3v1和ID3v2的数据格式,字符编码方式,UTF-16编解码,图片编码方式获取。本文只介绍了ID3v2中的v2.3版本的编码,还有v2.2(不常用),v2.4两个版本没有做讲解。由于篇幅有限,并且另外两个版本的编码编码过程和v2.3大同小异。因此,有关内容可以阅读本文涉及的id3_codec源码,请关注本仓库,我将持续更新,修复BUG。另外,附上最新支持ID3v2.3编码pub包引入方式:id3_codec: ^0.0.6。谢谢大家观看。

参考资料

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

id3v2.2官方文档

id3v2.3.0官方文档

id3v2.4.0-frames官方文档

id3v2.4.0-structure官方文档

id3v2文档

ID3维基百科

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

UTF-16维基百科

图形文件格式

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容