Python audioop 库中的一个 Bug

最近遇到的一个需求是将 8bit 的 WAV 音频文件采用 ADPCM 方法压缩到 4bit。WAV 文件一般采用 PCM(Pulse-code Modulation,脉冲编码调制)编码,它将采样得到的声音信号强度数据量化后按顺序原样保存,是一种无损的音频编码格式,但也占用了最多的存储空间。为进行音频压缩,可选择 FLAC、APE 等无损压缩及 MP3、AAC、ADPCM 等有损压缩算法。

ADPCM(Adaptive Differential PCM)是针对 8bit 及更高声音波形数据的一种有损压缩算法,对 16bit 的数据可以实现 4 倍的压缩比,而且算法实现简单。它计算的是当前采样和前一采样的差值,并将差值以某个步长量化为 4bit 数据,根据量化结果,自动调整下一采样差值的量化步长。算法的具体实施可参考此页面,不再重复。网上找到的算法基本都是对 16bit 数据进行处理的,连 Python 也不例外。Python 的 audioop 库中有 lin2adpcmadpcm2lin 两个函数可以实现 ADPCM 压缩及解压算法。对 8bit 音频,则先是补 0 成为 16bit,再压缩为 4bit,只达到了 2 倍压缩比。测试代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import audioop
import wave
INPUT_FILE = 'input.wav'
DECODE_FILE = 'decode.wav'
finput = wave.open(INPUT_FILE)
params = finput.getparams()
sampwidth = params[1]
nframes = params[3]
data_input = finput.readframes(nframes)
data_encode, _ = audioop.lin2adpcm(data_input, sampwidth, None)
data_decode, _ = audioop.adpcm2lin(data_encode, sampwidth, None)
fdecode = wave.open(DECODE_FILE, 'wb')
fdecode.setparams(params)
fdecode.writeframes(data_decode)
fdecode.close()

然而这样解码出来的音频噪声极大,基本听不到原来的声音。联想到之前处理 8bit 音频时遇到的问题,猜想大概是数据格式出了问题。当时16bit 降为 8bit 时只做了简单移位,得到的音频完全是错误的。据维基百科所述,16bit 及更高的 WAV 音频编码数据是有符号整数,单单 8bit 数据是无符号的。为什么这么设计,一个可能的原因是使用 8bit 编码的场合硬件资源往往非常有限,无符号数可以省去补码运算直接进入 DAC。

查看 Python 源码,发现了问题所在

1
2
3
#define GETINTX(T, cp, i) (*(T *)((unsigned char *)(cp) + (i)))
#define GETINT8(cp, i) GETINTX(signed char, (cp), (i))

GETINI8 在取 8bit 数据时居然存储成了 signed char 格式,这就是错误的根源了。于是用 Python 重新实现了压缩和解压算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import audioop
import wave
import numpy as np
INPUT_FILE = 'input.wav'
DECODE_FILE = 'decode.wav'
finput = wave.open(INPUT_FILE)
params = finput.getparams()
sampwidth = params[1]
nframes = params[3]
data_input = finput.readframes(nframes)
num_input = np.fromstring(data_input, dtype=np.uint8)
num_input_16bit = (num_input - 128) * 256
data_encode, _ = audioop.lin2adpcm(num_input_16bit.tostring(), 2, None)
data_decode, _ = audioop.adpcm2lin(data_encode, 2, None)
data_decode_8bit = bytearray()
for i in range(len(data_decode)):
if i%2 != 0:
data_decode_8bit.append(data_decode[i])
num_decode_8bit = np.fromstring(bytes(data_decode_8bit), dtype=np.int8)
num_decode_u8bit = np.uint8(num_decode_8bit + 128)
fdecode = wave.open(DECODE_FILE, 'wb')
fdecode.setparams(params)
fdecode.writeframes(num_decode_u8bit.tostring())
fdecode.close()

为什么这个 Bug 没有被发现呢?大概是处理 8bit 音频的需求太小众了吧,而且对于 8bit 音频压缩有能实现更高压缩比的算法,转成 16bit 再进行 ADPCM 压缩实在是有些浪费。