0%

mp3标签解析和字节序 (上)

mp3文件的标签和字节存取顺序

接下来我们一起来探索如何从一个mp3文件里解析出相应的标签信息, 比如曲目专辑艺术家发行年份等等。

首先一个mp3 文件本身存取了这些信息, 有的在文件的开头, 有的在文件的结尾。

这些信息有个吓人的名称叫id3, 文件开头的叫id3v2, 文件结尾的叫id3v1, 围绕id3也有一些乱七八糟的术语, 咱先不用理会, 两者的区别也非常明显。

v一在mp3文件的最后128字节的位置处, 好处就是所有的信息都是固定的, 从头到尾顺序读取就ok了, 坏处就是承载不了多少信息。

v2好处就是扩展性好, 只要愿意可以把mp3有关的所有信息都塞进里面, 比如曲目的封面和歌词等, 现在这是主流, 坏处就是解析起来比较麻烦而已。

这里我们解析v2版本, v1没什么好说的, 开头的三个固定字符TAG, 紧跟着读取3-32解码字符串, 读取33-62读取解码字符串, 63-92读取解码字符串, 93-96读取解码整数就ok了, 至于第97个字节后面的随意, 我没有看过。

如果你对解析v1没有头绪的话, 看完v2解析原理后手到擒来……

字节序……字节存取顺序

如果我们在内存上保存一个四个字节的32位整数, 可以采取两种策略。

第一种, 按照我们平常的读写习惯从左至右一个个放置, 这就是所谓的大端序, 也叫网络序。

长这样:

bytes([0 0, 0, 100])
b"\x00\x00\x00d"

第二种我们从右至左反着来, 这个叫小端序, 也叫主机序, 长这样。

bytes([100, 0, 0, 0])
b"d\x00\x00\x00"

如果你当前CPU平台的字节序和文件里保存的字节序不一样的话我们需要手动转换, 否则的话解析出来的数据肯定是大错特错了。

具体编写一个简单程序验证一下我们的想法, 首先找个mp3, 用记事本打开看看开头是不是ID3, 如果不是找一个这样的文件, 当然90%应该都是id3开头的。

然后编写如下python脚本

from struct import unpack
# 读取文件第14个字节开始的四个字节数据
fp = open("D:/tmp/m1.mp3", "rb")
fp.seek(14)
buf = fp.read(4)
fp.close()
print(buf)
# 默认解包, 当前CPU平台, x86_amd64都是小端序, 可以用sys.byteorder来检测
print(unpack("i", buf))
# 大端序解包
print(unpack(">i", buf))
# 反转字节序列
buf = buf[::-1]
print(buf)
#反转之后再次 默认解包
print(unpack("i", buf))

输出如下:

b'\x00\x00\x00\x1f'
(520093696,)
(31,)
b'\x1f\x00\x00\x00'
(31,)

从上面的程序运行结果里可以得出如下结论。

  • mp3文件里存取数据和python变量存取数据不一样, 他们刚好是反过来的

  • 所以读取数据的时候不注意很容易出现错误, 一个曲目不可能是几亿个字节, 所以错误显而易见, 如果是其他场景呢

    我们的python变量使用了小端序, 而mp3采用的是大端序, 我们常见的X86_AMD64架构用的是小端序, ARM不确定, 有的平台可以自行配置。

    我们常见的JVM dotnet这些平台基本上采用小端序, 我们每天离不开的TCP数据包采用的是大端序, 只是我们没有意识到他们之间的转换而已。

    我们也不用过于纠结这些玩意儿, 在实战中遇到问题的时候知道有这回事就行了, 在上面的程序里我们用两种办法正确解包了一个int类型的数据。

    第一个办法是用了struct.unpack方法的格式化说明符> , 第二个办法直接反转数据之后读到的四个字节。

    在unpack方法里>采用大端序, <采用小端序, 省略平台默认。

    此外如果你手头没有方便的函数, 可以手动反转。

    结合上面的程序多看几个文章, 这样我们对这些内容心里有数了……

手工解析二进制

如果手头没有struct这样的模块该如何解析二进制数据呢?

首先我们采用稍微笨拙的办法, 这样我们容易理解程序是怎么运行的, 接下来用二进制运算来提高速度, 我这边翻了一番。

为了方便我们针对的是int类型的数据, 也就是32bit的四个字节无符号整数, 其他类型完全可以举一反三。

首先看打包, 也就是把一个int值转换为四个字节的二进制数据。

# --*-- Encoding: UTF-8 --*--
import struct
from time import time

def toBytes(num: int) -> bytes:
# 直接格式化成二进制字符串
binStr = "{:032b}".format(num)
l = []
# 分组为四个值, 每一组站八个位
for i in range(0, len(binStr), 8):
l.append(int(binStr[i: i + 8], 2))

l.reverse()
return bytes(l)

# 加快速度
def toBytes1(num: int) -> bytes:
res = [0, 0, 0, 0]
for i in range(len(res)):
# 获取最后8bit值
res[i] = num & 255
# 截断最后8bit
num >>= 8

return bytes(res)
# 测试函数
def test(fun):
s = time()
for n in range(0, 1000000000, 81):
buf = fun(n)
r = struct.unpack("i", buf)[0]
assert(n == r)

e = time()
print(e - s)

test(toBytes)
test(toBytes1)

我这边运行良好, 字符串版本28秒, 二进制版本14秒。

那么如何编写一个从字节解包int值的函数呢?

原理和我们从1到10嵌套循环里输出1-100的序列没有太大区别。

import struct
from time import time

def toInt(buf: bytes) -> int:
num = 0
i = len(buf)
for i in range(len(buf) - 1, -1, -1):
n = 256 ** i
num += (buf[i] & 255) * n

return num

def test(fun):
s = time()
for i in range(0, 1000000000, 81):
buf = struct.pack("i", i)
r = fun(buf)
assert(i == r)

e = time()
print(e - s)

test(toInt)

这里我们可以继续扩展, 比如mp3的标签头里保存了所有标签帧的总长度, 所谓的标签帧就是一个个信息块, 他们紧跟着十个字节的标签头的后面。

不能理解的是这个重要数据用28bit来保存, 更麻烦的是每个字节保存了7bit的有效值, 最高位需要丢弃。

那么我们该怎么获取每个有效的7bit值, 然后生成一个int值呢?

此外我们如何解包一个long或short值, 遇到负数又该如何。

这些都是有趣的底层知识, 未完待续……